From 8d6e751fb1b984738317e2628ac9856377f4bd58 Mon Sep 17 00:00:00 2001 From: KangYu <160075935+MadKangYu@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:42:08 +0900 Subject: [PATCH 1/6] Protect operators from disruptive Kakao read loops Background-safe reads must fail cleanly instead of activating KakaoTalk, opening rooms, or leaking available chat titles. The read JSON now also carries media/link/attachment metadata so operations can queue image-bearing messages without storing raw image content. Constraint: AX-based Kakao reads can only inspect exposed UI safely without stealing focus Rejected: Frequent idle polling wrapper | still risks user disruption and lives outside the CLI contract Rejected: Raw image capture in read JSON | would expand private data exposure beyond message metadata Confidence: medium Scope-risk: moderate Directive: Do not make background-safe paths call activate, launch, login, search, resize, or close windows without revalidating operator impact Tested: swift build Tested: python3 -m unittest discover -s tests Tested: kmsg read __codex_no_such_chat__ --background-safe exits with BACKGROUND_SAFE_BLOCKED and hides window titles --- README.md | 14 +- Sources/kmsg/Commands/ReadCommand.swift | 32 +++- .../kmsg/KakaoTalk/ChatWindowResolver.swift | 44 ++++- .../KakaoTalk/MessageContextResolver.swift | 21 ++- Sources/kmsg/KakaoTalk/TranscriptReader.swift | 156 +++++++++++++++++- tests/test_read_background_safe_contract.py | 48 ++++++ 6 files changed, 298 insertions(+), 17 deletions(-) create mode 100644 tests/test_read_background_safe_contract.py diff --git a/README.md b/README.md index 7dd74af..3a95a16 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ kmsg chats --json kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --keep-window kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --json +kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --json --background-safe kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --deep-recovery kmsg watch "본인, 친구, 또는 단톡방 이름" kmsg watch "본인, 친구, 또는 단톡방 이름" --json @@ -87,7 +88,7 @@ kmsg status [--verbose] - `--verbose`: 상세 상태 출력 -`status`, `chats`, `read`, `send`, `send-image`, `watch`, `cache warmup`은 카카오톡 로그인이 풀려 있으면 저장된 자격 증명으로 자동 로그인을 시도합니다. 저장된 정보가 없거나 불완전하면 터미널에서 아이디/비밀번호를 입력받아 `~/.config/kmsg/credentials.json`에 저장하고, 비밀번호 암호키는 `~/.config/kmsg/credentials/`에 별도로 보관합니다. +`status`, `chats`, `read`, `send`, `send-image`, `watch`, `cache warmup`은 카카오톡 로그인이 풀려 있으면 저장된 자격 증명으로 자동 로그인을 시도합니다. 저장된 정보가 없거나 불완전하면 터미널에서 아이디/비밀번호를 입력받아 `~/.config/kmsg/credentials.json`에 저장하고, 비밀번호 암호키는 `~/.config/kmsg/credentials/`에 별도로 보관합니다. 단, `read --background-safe`는 카카오톡 실행/활성화/자동 로그인을 하지 않습니다. ### auth login @@ -117,13 +118,14 @@ kmsg chats [--verbose] [--limit ] [--trace-ax] [--json] [--keep-window] ### read ```bash -kmsg read [--limit ] [--debug] [--trace-ax] [--keep-window] [--deep-recovery] [--json] +kmsg read [--limit ] [--debug] [--trace-ax] [--keep-window] [--background-safe] [--deep-recovery] [--json] ``` - `-l, --limit `: 최대 메시지 개수 (기본값: 20) - `--debug`: raw element 디버그 정보 출력 - `--trace-ax`: AX 탐색/재시도 로그 출력 - `-k, --keep-window`: 자동으로 연 채팅창과 리스트창 유지 +- `--background-safe`: 카카오톡 실행/활성화/자동 로그인/검색/채팅방 열기/창 크기 변경/자동 닫기를 하지 않고, 이미 노출된 매칭 채팅창만 읽음 - `--deep-recovery`: 빠른 탐색 실패 시 deep recovery 수행 - `--json`: JSON 형식으로 출력 @@ -218,6 +220,7 @@ kmsg help cache ```bash kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --json +kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --json --background-safe ``` ### 출력 형식 @@ -231,7 +234,12 @@ kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --json { "author": "홍길동", "time_raw": "00:27", - "body": "밤이 깊었네" + "body": "밤이 깊었네", + "has_image": false, + "image_count": 0, + "link_count": 0, + "has_attachment": false, + "attachment_count": 0 } ] } diff --git a/Sources/kmsg/Commands/ReadCommand.swift b/Sources/kmsg/Commands/ReadCommand.swift index 995a1d4..de78d89 100644 --- a/Sources/kmsg/Commands/ReadCommand.swift +++ b/Sources/kmsg/Commands/ReadCommand.swift @@ -26,6 +26,9 @@ struct ReadCommand: ParsableCommand { kmsg read kmsg read --chat-id + Use --background-safe to read only already exposed chat windows without launching, + activating, logging in, searching, opening rows, resizing, or closing windows. + When author is "(me)", the message was sent by you. """ ) @@ -48,6 +51,12 @@ struct ReadCommand: ParsableCommand { @Flag(name: [.short, .long], help: "Keep auto-opened chat window after read") var keepWindow: Bool = false + @Flag( + name: .long, + help: "Do not activate, search, open, resize, or close KakaoTalk windows; only read already exposed matching chat windows" + ) + var backgroundSafe: Bool = false + @Flag( name: .long, help: ArgumentHelp( @@ -83,14 +92,21 @@ struct ReadCommand: ParsableCommand { } let runner = AXActionRunner(traceEnabled: traceAX) - let kakao = try AuthBootstrap.requireAuthenticated(traceAX: traceAX) + let kakao = backgroundSafe + ? try KakaoTalkApp(autoLaunch: false) + : try AuthBootstrap.requireAuthenticated(traceAX: traceAX) let chatWindowResolver = ChatWindowResolver( kakao: kakao, runner: runner, deepRecoveryEnabled: deepRecovery, - layoutMode: layout + layoutMode: layout, + interactionMode: backgroundSafe ? .backgroundSafe : .allowUIAutomation + ) + let transcriptReader = KakaoTalkTranscriptReader( + kakao: kakao, + runner: runner, + interactionMode: backgroundSafe ? .backgroundSafe : .allowUIAutomation ) - let transcriptReader = KakaoTalkTranscriptReader(kakao: kakao, runner: runner) let resolution: ChatWindowResolution let requestedChat: String @@ -106,9 +122,13 @@ struct ReadCommand: ParsableCommand { } catch { print("No chat window found for '\(requestedChat)'") print("Reason: \(error)") - print("\nAvailable windows:") - for (index, window) in kakao.windows.enumerated() { - print(" [\(index)] \(window.title ?? "(untitled)")") + if backgroundSafe { + print("Available windows: \(kakao.windows.count) title(s) hidden in background-safe mode") + } else { + print("\nAvailable windows:") + for (index, window) in kakao.windows.enumerated() { + print(" [\(index)] \(window.title ?? "(untitled)")") + } } throw ExitCode.failure } diff --git a/Sources/kmsg/KakaoTalk/ChatWindowResolver.swift b/Sources/kmsg/KakaoTalk/ChatWindowResolver.swift index 15169d7..627e9d6 100644 --- a/Sources/kmsg/KakaoTalk/ChatWindowResolver.swift +++ b/Sources/kmsg/KakaoTalk/ChatWindowResolver.swift @@ -13,6 +13,11 @@ enum ChatWindowResolutionMethod { case openedViaSearch } +enum ChatWindowInteractionMode { + case allowUIAutomation + case backgroundSafe +} + struct ChatWindowResolution { let window: UIElement let method: ChatWindowResolutionMethod @@ -27,6 +32,7 @@ struct ChatWindowResolution { } private enum ChatWindowFailureCode: String { + case backgroundSafeBlocked = "BACKGROUND_SAFE_BLOCKED" case focusFail = "FOCUS_FAIL" case inputNotReflected = "INPUT_NOT_REFLECTED" case windowNotReady = "WINDOW_NOT_READY" @@ -66,22 +72,29 @@ struct ChatWindowResolver { private let useCache: Bool private let deepRecoveryEnabled: Bool private let layoutMode: ChatWindowLayoutMode + private let interactionMode: ChatWindowInteractionMode init( kakao: KakaoTalkApp, runner: AXActionRunner, useCache: Bool = true, deepRecoveryEnabled: Bool = false, - layoutMode: ChatWindowLayoutMode = .preserve + layoutMode: ChatWindowLayoutMode = .preserve, + interactionMode: ChatWindowInteractionMode = .allowUIAutomation ) { self.kakao = kakao self.runner = runner self.useCache = useCache self.deepRecoveryEnabled = deepRecoveryEnabled self.layoutMode = layoutMode + self.interactionMode = interactionMode } func resolve(query: String) throws -> ChatWindowResolution { + if interactionMode == .backgroundSafe { + return try resolveExistingWindowOnly(query: query) + } + let usableWindow = try requireUsableWindow() if let existingWindow = findMatchingChatWindow(in: kakao.windows, query: query) { @@ -101,6 +114,10 @@ struct ChatWindowResolver { throw KakaoTalkError.elementNotFound("Unknown chat_id '\(chatID)'. Run 'kmsg chats' first to refresh the local registry.") } + if interactionMode == .backgroundSafe { + return try resolveExistingWindowOnly(query: record.displayName) + } + let usableWindow = try requireUsableWindow() let query = record.displayName @@ -158,6 +175,26 @@ struct ChatWindowResolver { return waitForWindowClosed(window, label: "close via cmd+w") } + private func resolveExistingWindowOnly(query: String) throws -> ChatWindowResolution { + if let existingWindow = findMatchingChatWindow(in: kakao.windows, query: query) { + runner.log("background-safe: matched already exposed chat window") + return ChatWindowResolution(window: existingWindow, method: .existingWindow) + } + + if let focusedWindow = kakao.focusedWindow, + let title = focusedWindow.title, + scoreQueryMatch(query: query, candidateText: title) > 0 + { + runner.log("background-safe: matched already focused chat window") + return ChatWindowResolution(window: focusedWindow, method: .existingWindow) + } + + throw KakaoTalkError.elementNotFound( + "[\(ChatWindowFailureCode.backgroundSafeBlocked.rawValue)] No already exposed chat window matched '\(query)'. " + + "Background-safe mode does not activate KakaoTalk, open chat rows, search, resize, or close windows." + ) + } + private func requireUsableWindow() throws -> UIElement { if let immediateWindow = kakao.focusedWindow ?? kakao.mainWindow ?? kakao.windows.first { runner.log("Usable window found via immediate probe") @@ -335,6 +372,11 @@ struct ChatWindowResolver { } private func standardizeReadableWindow(_ window: UIElement, label: String) { + guard interactionMode != .backgroundSafe else { + runner.log("\(label): background-safe mode; preserving window focus, size, and position") + return + } + kakao.activate() _ = tryRaiseWindow(window) diff --git a/Sources/kmsg/KakaoTalk/MessageContextResolver.swift b/Sources/kmsg/KakaoTalk/MessageContextResolver.swift index 323a8d1..9472a31 100644 --- a/Sources/kmsg/KakaoTalk/MessageContextResolver.swift +++ b/Sources/kmsg/KakaoTalk/MessageContextResolver.swift @@ -10,11 +10,18 @@ struct MessageContextResolver { private let kakao: KakaoTalkApp private let runner: AXActionRunner private let useCache: Bool - - init(kakao: KakaoTalkApp, runner: AXActionRunner, useCache: Bool = true) { + private let interactionMode: ChatWindowInteractionMode + + init( + kakao: KakaoTalkApp, + runner: AXActionRunner, + useCache: Bool = true, + interactionMode: ChatWindowInteractionMode = .allowUIAutomation + ) { self.kakao = kakao self.runner = runner self.useCache = useCache + self.interactionMode = interactionMode } func resolve(in chatWindow: UIElement) -> MessageTranscriptContext? { @@ -95,9 +102,13 @@ struct MessageContextResolver { return input } - kakao.activate() - _ = runner.focusWithVerification(chatWindow, label: "chat window", attempts: 1) - Thread.sleep(forTimeInterval: 0.05) + if interactionMode == .backgroundSafe { + runner.log("read: background-safe mode; skipping chat window activation fallback") + } else { + kakao.activate() + _ = runner.focusWithVerification(chatWindow, label: "chat window", attempts: 1) + Thread.sleep(forTimeInterval: 0.05) + } } let appCandidates = collectMessageInputCandidates(from: kakao.applicationElement, limit: 90) diff --git a/Sources/kmsg/KakaoTalk/TranscriptReader.swift b/Sources/kmsg/KakaoTalk/TranscriptReader.swift index a1e7078..11d56c3 100644 --- a/Sources/kmsg/KakaoTalk/TranscriptReader.swift +++ b/Sources/kmsg/KakaoTalk/TranscriptReader.swift @@ -5,17 +5,55 @@ struct TranscriptMessage: Encodable, Equatable, Sendable { let author: String? let timeRaw: String? let body: String + let imageCount: Int + let linkCount: Int + let attachmentCount: Int let isSystem: Bool let logicalTimestamp: Date? /// Calendar date of the message ("YYYY-MM-DD"), read from the time /// label's AXHelp tooltip. nil when the tooltip was unavailable. let date: String? + var hasImage: Bool { + imageCount > 0 + } + + var hasAttachment: Bool { + attachmentCount > 0 + } + enum CodingKeys: String, CodingKey { case author case timeRaw = "time_raw" case body case date + case hasImage = "has_image" + case imageCount = "image_count" + case linkCount = "link_count" + case hasAttachment = "has_attachment" + case attachmentCount = "attachment_count" + } + + init( + author: String?, + timeRaw: String?, + body: String, + imageCount: Int = 0, + linkCount: Int = 0, + attachmentCount: Int = 0, + isSystem: Bool, + logicalTimestamp: Date?, + date: String? = nil + ) { + self.author = author + self.timeRaw = timeRaw + self.body = body + self.imageCount = max(0, imageCount) + self.linkCount = max(0, linkCount) + self.attachmentCount = max(0, attachmentCount) + self.isSystem = isSystem + self.logicalTimestamp = logicalTimestamp + self.date = date } func encode(to encoder: Encoder) throws { @@ -24,6 +62,11 @@ struct TranscriptMessage: Encodable, Equatable, Sendable { try container.encodeIfPresent(timeRaw, forKey: .timeRaw) try container.encode(body, forKey: .body) try container.encodeIfPresent(date, forKey: .date) + try container.encode(hasImage, forKey: .hasImage) + try container.encode(imageCount, forKey: .imageCount) + try container.encode(linkCount, forKey: .linkCount) + try container.encode(hasAttachment, forKey: .hasAttachment) + try container.encode(attachmentCount, forKey: .attachmentCount) } } @@ -57,10 +100,16 @@ enum TranscriptReadError: LocalizedError { struct KakaoTalkTranscriptReader { private let kakao: KakaoTalkApp private let runner: AXActionRunner + private let interactionMode: ChatWindowInteractionMode - init(kakao: KakaoTalkApp, runner: AXActionRunner) { + init( + kakao: KakaoTalkApp, + runner: AXActionRunner, + interactionMode: ChatWindowInteractionMode = .allowUIAutomation + ) { self.kakao = kakao self.runner = runner + self.interactionMode = interactionMode } func readSnapshot( @@ -70,7 +119,11 @@ struct KakaoTalkTranscriptReader { includeSystemMessages: Bool = false ) throws -> TranscriptSnapshot { let referenceDate = Date() - let messageContextResolver = MessageContextResolver(kakao: kakao, runner: runner) + let messageContextResolver = MessageContextResolver( + kakao: kakao, + runner: runner, + interactionMode: interactionMode + ) guard let messageContext = messageContextResolver.resolve(in: window) else { throw TranscriptReadError.transcriptContextUnavailable } @@ -260,6 +313,9 @@ struct KakaoTalkTranscriptReader { author: nil, timeRaw: analysis.timeRaw, body: bodyCandidate.body, + imageCount: analysis.imageCount, + linkCount: analysis.linkCount, + attachmentCount: analysis.attachmentCount, isSystem: true, logicalTimestamp: currentDateAnchor, date: resolvedDate @@ -292,6 +348,9 @@ struct KakaoTalkTranscriptReader { author: author, timeRaw: resolvedTime, body: bodyCandidate.body, + imageCount: analysis.imageCount, + linkCount: analysis.linkCount, + attachmentCount: analysis.attachmentCount, isSystem: false, logicalTimestamp: logicalTimestamp( for: resolvedTime, @@ -338,12 +397,15 @@ struct KakaoTalkTranscriptReader { var buttonTitlesBuffer: [String] = [] var imageFrames: [CGRect] = [] var rowHelpDate: String? + var linkElementCount = 0 + var urlTokenCount = 0 for container in containers { var textAreas: [UIElement] = [] var staticTexts: [UIElement] = [] var images: [UIElement] = [] var buttons: [UIElement] = [] + var links: [UIElement] = [] for child in container.children { switch child.role { @@ -355,6 +417,8 @@ struct KakaoTalkTranscriptReader { images.append(child) case kAXButtonRole: buttons.append(child) + case kAXLinkRole: + links.append(child) default: break } @@ -365,6 +429,7 @@ struct KakaoTalkTranscriptReader { staticTexts.isEmpty ? kAXStaticTextRole : nil, images.isEmpty ? kAXImageRole : nil, buttons.isEmpty ? kAXButtonRole : nil, + links.isEmpty ? kAXLinkRole : nil, ].compactMap { $0 } if !missingRoles.isEmpty { @@ -375,6 +440,7 @@ struct KakaoTalkTranscriptReader { kAXStaticTextRole: 8, kAXImageRole: 3, kAXButtonRole: 6, + kAXLinkRole: 6, ], maxNodes: 140 ) @@ -382,6 +448,7 @@ struct KakaoTalkTranscriptReader { if staticTexts.isEmpty { staticTexts = found[kAXStaticTextRole] ?? [] } if images.isEmpty { images = found[kAXImageRole] ?? [] } if buttons.isEmpty { buttons = found[kAXButtonRole] ?? [] } + if links.isEmpty { links = found[kAXLinkRole] ?? [] } } for staticText in staticTexts { @@ -391,12 +458,14 @@ struct KakaoTalkTranscriptReader { let normalized = normalizeBodyText(staticText.stringValue) guard !normalized.isEmpty else { continue } metadataTokensBuffer.append(contentsOf: metadataTokens(from: normalized)) + urlTokenCount += countURLTokens(in: normalized) } for button in buttons { let title = normalizeBodyText(button.title) guard !title.isEmpty else { continue } buttonTitlesBuffer.append(title) + urlTokenCount += countURLTokens(in: title) } for image in images { @@ -405,9 +474,12 @@ struct KakaoTalkTranscriptReader { } } + linkElementCount += links.count + for textArea in textAreas { let normalized = normalizeBodyText(textArea.stringValue) guard !normalized.isEmpty else { continue } + urlTokenCount += countURLTokens(in: normalized) var resolved = normalized if shouldPromoteLinkTitle(for: normalized), @@ -426,6 +498,7 @@ struct KakaoTalkTranscriptReader { if textAreas.isEmpty, let linkOnlyText = bestLinkTitle(from: container) { bodyCandidates.append(MessageBodyCandidate(body: linkOnlyText, frame: container.frame)) + linkElementCount = max(linkElementCount, 1) } } @@ -437,6 +510,16 @@ struct KakaoTalkTranscriptReader { let uniqueButtonTitles = deduplicatePreservingOrder(buttonTitlesBuffer) let metadata = parseRowMetadata(tokens: metadataTokensBuffer) let cachedRowFrame = frameCache.frame(of: row) + let messageImageFrames = likelyMessageImageFrames( + imageFrames, + bodyFrame: bestBody?.frame, + rowFrame: cachedRowFrame, + transcriptRoot: transcriptRoot + ) + let imageCount = messageImageFrames.count + let attachmentMetadataCount = uniqueMetadataTokens.filter(isLikelyAttachmentMetadataToken).count + let attachmentActionCount = uniqueButtonTitles.filter(isLikelyAttachmentButtonTitle).count + let attachmentCount = imageCount + attachmentMetadataCount + attachmentActionCount let side = inferMessageSide( bodyFrame: bestBody?.frame, imageFrames: imageFrames, @@ -455,6 +538,9 @@ struct KakaoTalkTranscriptReader { timeRaw: metadata.timeRaw, side: side, rowFrame: cachedRowFrame, + imageCount: imageCount, + linkCount: max(linkElementCount, urlTokenCount), + attachmentCount: attachmentCount, isSystemLikeRow: systemLikeRow, axHelpDate: rowHelpDate ) @@ -484,6 +570,7 @@ struct KakaoTalkTranscriptReader { author: metadata.author, timeRaw: metadata.timeRaw, body: resolved, + linkCount: countURLTokens(in: resolved), isSystem: false, logicalTimestamp: logicalTimestamp( for: metadata.timeRaw, @@ -506,6 +593,7 @@ struct KakaoTalkTranscriptReader { author: nil, timeRaw: nil, body: title, + linkCount: max(1, countURLTokens(in: title)), isSystem: false, logicalTimestamp: nil, date: nil @@ -654,6 +742,51 @@ struct KakaoTalkTranscriptReader { return false } + private func likelyMessageImageFrames( + _ imageFrames: [CGRect], + bodyFrame: CGRect?, + rowFrame: CGRect?, + transcriptRoot: UIElement + ) -> [CGRect] { + imageFrames.filter { frame in + isLikelyMessageImageFrame( + frame, + bodyFrame: bodyFrame, + rowFrame: rowFrame, + transcriptFrame: transcriptRoot.frame + ) + } + } + + private func isLikelyMessageImageFrame( + _ frame: CGRect, + bodyFrame: CGRect?, + rowFrame: CGRect?, + transcriptFrame: CGRect? + ) -> Bool { + guard frame.width >= 48, frame.height >= 48, frame.width * frame.height >= 2_304 else { + return false + } + + if let bodyFrame, + frame.maxX + 10 < bodyFrame.minX, + frame.width <= 72, + frame.height <= 72 + { + return false + } + + if let rowFrame, !rowFrame.intersects(frame) { + return false + } + + if let transcriptFrame, !transcriptFrame.intersects(frame) { + return false + } + + return true + } + private func isLikelySystemRow( metadataTokens: [String], buttonTitles: [String], @@ -998,6 +1131,22 @@ struct KakaoTalkTranscriptReader { return trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") } + private func countURLTokens(in text: String) -> Int { + var count = 0 + var searchRange = text.startIndex.. Int { var score = min(text.count * 10, 500) if text.contains("\n") { @@ -1067,6 +1216,9 @@ private struct RowAnalysis { let timeRaw: String? let side: MessageSide let rowFrame: CGRect? + let imageCount: Int + let linkCount: Int + let attachmentCount: Int let isSystemLikeRow: Bool let axHelpDate: String? diff --git a/tests/test_read_background_safe_contract.py b/tests/test_read_background_safe_contract.py new file mode 100644 index 0000000..e5b8dd2 --- /dev/null +++ b/tests/test_read_background_safe_contract.py @@ -0,0 +1,48 @@ +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +READ_COMMAND = REPO_ROOT / "Sources" / "kmsg" / "Commands" / "ReadCommand.swift" +CHAT_WINDOW_RESOLVER = REPO_ROOT / "Sources" / "kmsg" / "KakaoTalk" / "ChatWindowResolver.swift" +MESSAGE_CONTEXT_RESOLVER = REPO_ROOT / "Sources" / "kmsg" / "KakaoTalk" / "MessageContextResolver.swift" +TRANSCRIPT_READER = REPO_ROOT / "Sources" / "kmsg" / "KakaoTalk" / "TranscriptReader.swift" + + +class ReadBackgroundSafeContractTests(unittest.TestCase): + def test_read_command_exposes_background_safe_flag(self) -> None: + source = READ_COMMAND.read_text(encoding="utf-8") + + self.assertIn("var backgroundSafe: Bool = false", source) + self.assertIn("KakaoTalkApp(autoLaunch: false)", source) + self.assertIn("interactionMode: backgroundSafe ? .backgroundSafe", source) + self.assertIn("title(s) hidden in background-safe mode", source) + + def test_background_safe_resolver_blocks_focus_stealing_paths(self) -> None: + source = CHAT_WINDOW_RESOLVER.read_text(encoding="utf-8") + + self.assertIn("case backgroundSafe", source) + self.assertIn("resolveExistingWindowOnly", source) + self.assertIn("BACKGROUND_SAFE_BLOCKED", source) + self.assertIn("background-safe mode; preserving window focus, size, and position", source) + + def test_background_safe_context_resolution_skips_activation_fallback(self) -> None: + source = MESSAGE_CONTEXT_RESOLVER.read_text(encoding="utf-8") + + self.assertIn("interactionMode: ChatWindowInteractionMode = .allowUIAutomation", source) + self.assertIn("background-safe mode; skipping chat window activation fallback", source) + + def test_transcript_message_json_exposes_media_metadata(self) -> None: + source = TRANSCRIPT_READER.read_text(encoding="utf-8") + + self.assertIn('case hasImage = "has_image"', source) + self.assertIn('case imageCount = "image_count"', source) + self.assertIn('case linkCount = "link_count"', source) + self.assertIn('case hasAttachment = "has_attachment"', source) + self.assertIn('case attachmentCount = "attachment_count"', source) + self.assertIn("likelyMessageImageFrames", source) + self.assertIn("countURLTokens", source) + + +if __name__ == "__main__": + unittest.main() From 93bc7800a6d0918302763f25d60805fd25520a0f Mon Sep 17 00:00:00 2001 From: KangYu <160075935+MadKangYu@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:52:53 +0900 Subject: [PATCH 2/6] Make Kakao reads fit marketing split-screen work Marketing operators need to keep KakaoTalk beside browsers and ad tools without the CLI resizing into a large operator-only window. The read command now supports split-left and split-right layouts, MCP accepts the same values, and the entrypoint follows the installed executable name so local operations can install the patched build as kangyu instead of replacing kmsg. Constraint: Local automation should not overwrite the existing kmsg command when the operator asks for a separate kangyu command Rejected: Symlink kangyu to a kmsg-named binary only | help and examples would still instruct users to run kmsg Confidence: high Scope-risk: narrow Directive: Keep background-safe behavior non-mutating; split layouts are for explicit layout requests only Tested: swift build Tested: python3 -m unittest discover -s tests Tested: /Users/yu/.local/bin/kangyu --help shows kangyu and read --layout split-left/split-right Tested: /Users/yu/.local/bin/kangyu read __codex_no_such_chat__ --background-safe --limit 1 --json exits with BACKGROUND_SAFE_BLOCKED --- README.md | 4 ++- Sources/kmsg/Commands/MCPServerCommand.swift | 8 ++--- Sources/kmsg/Commands/ReadCommand.swift | 2 +- .../kmsg/KakaoTalk/ChatWindowResolver.swift | 31 ++++++++++++++++- Sources/kmsg/kmsg.swift | 34 ++++++++++++------- tests/test_read_background_safe_contract.py | 20 +++++++++++ 6 files changed, 79 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 3a95a16..c6650cf 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --keep-window kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --json kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --json --background-safe kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --deep-recovery +kmsg read "본인, 친구, 또는 단톡방 이름" --limit 20 --layout split-right kmsg watch "본인, 친구, 또는 단톡방 이름" kmsg watch "본인, 친구, 또는 단톡방 이름" --json kmsg watch "본인, 친구, 또는 단톡방 이름" --json --poll-interval 0.5 @@ -118,7 +119,7 @@ kmsg chats [--verbose] [--limit ] [--trace-ax] [--json] [--keep-window] ### read ```bash -kmsg read [--limit ] [--debug] [--trace-ax] [--keep-window] [--background-safe] [--deep-recovery] [--json] +kmsg read [--limit ] [--debug] [--trace-ax] [--keep-window] [--background-safe] [--deep-recovery] [--layout ] [--json] ``` - `-l, --limit `: 최대 메시지 개수 (기본값: 20) @@ -127,6 +128,7 @@ kmsg read [--limit ] [--debug] [--trace-ax] [--keep-window] [--bac - `-k, --keep-window`: 자동으로 연 채팅창과 리스트창 유지 - `--background-safe`: 카카오톡 실행/활성화/자동 로그인/검색/채팅방 열기/창 크기 변경/자동 닫기를 하지 않고, 이미 노출된 매칭 채팅창만 읽음 - `--deep-recovery`: 빠른 탐색 실패 시 deep recovery 수행 +- `--layout `: `preserve`, `left`, `right`, `split-left`, `split-right`. 마케팅 작업자가 브라우저/광고툴과 화면을 나눠 쓸 때는 `split-left` 또는 `split-right`를 사용 - `--json`: JSON 형식으로 출력 ### watch diff --git a/Sources/kmsg/Commands/MCPServerCommand.swift b/Sources/kmsg/Commands/MCPServerCommand.swift index 69ef643..a752b16 100644 --- a/Sources/kmsg/Commands/MCPServerCommand.swift +++ b/Sources/kmsg/Commands/MCPServerCommand.swift @@ -312,7 +312,7 @@ private final class KmsgMCPServer { ], "layout": [ "type": "string", - "enum": ["preserve", "left", "right"], + "enum": ["preserve", "left", "right", "split-left", "split-right"], "default": readLayoutDefault, "description": "Window layout before reading", ], @@ -462,8 +462,8 @@ private final class KmsgMCPServer { guard let layout = Self.validReadLayout(arguments["layout"] as? String ?? readLayoutDefault) else { return errorPayload( code: "INVALID_ARGUMENT", - message: "layout must be preserve, left, or right", - hint: "Use layout=preserve, layout=left, or layout=right.", + message: "layout must be preserve, left, right, split-left, or split-right", + hint: "Use layout=preserve, layout=left, layout=right, layout=split-left, or layout=split-right.", rawStdout: "", rawStderr: "", latencyMs: 0 @@ -801,7 +801,7 @@ private final class KmsgMCPServer { guard let raw else { return nil } let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() switch normalized { - case "preserve", "left", "right": + case "preserve", "left", "right", "split-left", "split-right": return normalized default: return nil diff --git a/Sources/kmsg/Commands/ReadCommand.swift b/Sources/kmsg/Commands/ReadCommand.swift index de78d89..5acd597 100644 --- a/Sources/kmsg/Commands/ReadCommand.swift +++ b/Sources/kmsg/Commands/ReadCommand.swift @@ -66,7 +66,7 @@ struct ReadCommand: ParsableCommand { ) var deepRecovery: Bool = false - @Option(name: .long, help: "Window layout before reading: preserve, left, or right") + @Option(name: .long, help: "Window layout before reading: preserve, left, right, split-left, or split-right") var layout: ChatWindowLayoutMode = .preserve @Flag(name: .long, help: "Output in JSON format") diff --git a/Sources/kmsg/KakaoTalk/ChatWindowResolver.swift b/Sources/kmsg/KakaoTalk/ChatWindowResolver.swift index 627e9d6..a31fadf 100644 --- a/Sources/kmsg/KakaoTalk/ChatWindowResolver.swift +++ b/Sources/kmsg/KakaoTalk/ChatWindowResolver.swift @@ -5,6 +5,16 @@ enum ChatWindowLayoutMode: String { case preserve case left case right + case splitLeft = "split-left" + case splitRight = "split-right" + + var isRightAligned: Bool { + self == .right || self == .splitRight + } + + var isSplit: Bool { + self == .splitLeft || self == .splitRight + } } enum ChatWindowResolutionMethod { @@ -65,6 +75,7 @@ private struct SearchCandidate { struct ChatWindowResolver { private static let minimumReadableWindowSize = CGSize(width: 760, height: 900) + private static let minimumSplitWindowSize = CGSize(width: 520, height: 680) private static let maximumAutomaticWindowSize = CGSize(width: 1200, height: 1000) private let kakao: KakaoTalkApp @@ -456,6 +467,10 @@ struct ChatWindowResolver { return nil } + if layoutMode.isSplit { + return splitLayoutFrame(in: usableFrame) + } + let layoutSize = CGSize( width: min( max(preferredSize.width, Self.minimumReadableWindowSize.width), @@ -466,11 +481,25 @@ struct ChatWindowResolver { min(Self.maximumAutomaticWindowSize.height, usableFrame.height) ) ) - let x = layoutMode == .right ? usableFrame.maxX - layoutSize.width : usableFrame.minX + let x = layoutMode.isRightAligned ? usableFrame.maxX - layoutSize.width : usableFrame.minX let y = min(max(currentFrame.minY, usableFrame.minY), usableFrame.maxY - layoutSize.height) return CGRect(origin: CGPoint(x: x, y: y), size: layoutSize) } + private func splitLayoutFrame(in usableFrame: CGRect) -> CGRect { + let halfWidth = floor(usableFrame.width / 2) + let width = min( + max(halfWidth, min(Self.minimumSplitWindowSize.width, usableFrame.width)), + usableFrame.width + ) + let height = min( + max(Self.minimumSplitWindowSize.height, usableFrame.height), + usableFrame.height + ) + let x = layoutMode.isRightAligned ? usableFrame.maxX - width : usableFrame.minX + return CGRect(origin: CGPoint(x: x, y: usableFrame.minY), size: CGSize(width: width, height: height)) + } + private func screenFrame(containing frame: CGRect) -> CGRect? { var displayCount: UInt32 = 0 guard CGGetActiveDisplayList(0, nil, &displayCount) == .success, displayCount > 0 else { diff --git a/Sources/kmsg/kmsg.swift b/Sources/kmsg/kmsg.swift index 7cd5064..f0827b9 100644 --- a/Sources/kmsg/kmsg.swift +++ b/Sources/kmsg/kmsg.swift @@ -1,32 +1,40 @@ import ArgumentParser import Foundation +private func invokedCommandName() -> String { + let executable = CommandLine.arguments.first ?? "kmsg" + let name = URL(fileURLWithPath: executable).lastPathComponent + return name.isEmpty ? "kmsg" : name +} + @main struct Kmsg: ParsableCommand { + private static let commandName = invokedCommandName() + static let configuration = CommandConfiguration( - commandName: "kmsg", + commandName: commandName, abstract: "A CLI tool for KakaoTalk on macOS", discussion: """ - kmsg uses macOS Accessibility APIs to interact with KakaoTalk. + \(commandName) uses macOS Accessibility APIs to interact with KakaoTalk. - Before using kmsg, make sure: + Before using \(commandName), make sure: 1. KakaoTalk is installed and running 2. Accessibility permission is granted (System Settings > Privacy & Security > Accessibility) - Run 'kmsg status' to check if everything is set up correctly. + Run '\(commandName) status' to check if everything is set up correctly. Examples: - kmsg status - kmsg auth login - kmsg chats --json - kmsg send "채팅방" "메시지" - kmsg send-image "채팅방" "/path/to/image.png" - kmsg watch "채팅방" - kmsg watch "채팅방" --json - kmsg mcp-server + \(commandName) status + \(commandName) auth login + \(commandName) chats --json + \(commandName) send "채팅방" "메시지" + \(commandName) send-image "채팅방" "/path/to/image.png" + \(commandName) watch "채팅방" + \(commandName) watch "채팅방" --json + \(commandName) mcp-server Tip: - kmsg -v + \(commandName) -v """, version: BuildVersion.current, subcommands: [ diff --git a/tests/test_read_background_safe_contract.py b/tests/test_read_background_safe_contract.py index e5b8dd2..5bff1e8 100644 --- a/tests/test_read_background_safe_contract.py +++ b/tests/test_read_background_safe_contract.py @@ -4,12 +4,21 @@ REPO_ROOT = Path(__file__).resolve().parents[1] READ_COMMAND = REPO_ROOT / "Sources" / "kmsg" / "Commands" / "ReadCommand.swift" +KMSG_ENTRYPOINT = REPO_ROOT / "Sources" / "kmsg" / "kmsg.swift" +MCP_SERVER_COMMAND = REPO_ROOT / "Sources" / "kmsg" / "Commands" / "MCPServerCommand.swift" CHAT_WINDOW_RESOLVER = REPO_ROOT / "Sources" / "kmsg" / "KakaoTalk" / "ChatWindowResolver.swift" MESSAGE_CONTEXT_RESOLVER = REPO_ROOT / "Sources" / "kmsg" / "KakaoTalk" / "MessageContextResolver.swift" TRANSCRIPT_READER = REPO_ROOT / "Sources" / "kmsg" / "KakaoTalk" / "TranscriptReader.swift" class ReadBackgroundSafeContractTests(unittest.TestCase): + def test_entrypoint_uses_invoked_command_name_for_aliases(self) -> None: + source = KMSG_ENTRYPOINT.read_text(encoding="utf-8") + + self.assertIn("invokedCommandName", source) + self.assertIn("CommandLine.arguments.first", source) + self.assertIn("commandName: commandName", source) + def test_read_command_exposes_background_safe_flag(self) -> None: source = READ_COMMAND.read_text(encoding="utf-8") @@ -26,6 +35,17 @@ def test_background_safe_resolver_blocks_focus_stealing_paths(self) -> None: self.assertIn("BACKGROUND_SAFE_BLOCKED", source) self.assertIn("background-safe mode; preserving window focus, size, and position", source) + def test_split_layout_modes_support_marketing_workspaces(self) -> None: + resolver_source = CHAT_WINDOW_RESOLVER.read_text(encoding="utf-8") + read_source = READ_COMMAND.read_text(encoding="utf-8") + mcp_source = MCP_SERVER_COMMAND.read_text(encoding="utf-8") + + self.assertIn('case splitLeft = "split-left"', resolver_source) + self.assertIn('case splitRight = "split-right"', resolver_source) + self.assertIn("splitLayoutFrame", resolver_source) + self.assertIn("split-left, or split-right", read_source) + self.assertIn('"split-left", "split-right"', mcp_source) + def test_background_safe_context_resolution_skips_activation_fallback(self) -> None: source = MESSAGE_CONTEXT_RESOLVER.read_text(encoding="utf-8") From 4ee6f3b0a1df3353fd21728293ac521884e434f2 Mon Sep 17 00:00:00 2001 From: KangYu <160075935+MadKangYu@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:03:12 +0900 Subject: [PATCH 3/6] Clarify KangYu deployment identity for MCP operators Madstamp runs the patched upstream kmsg build as a separate kangyu executable and a kmsg.kangyu MCP server ID until the upstream PR lands. Documenting the source-of-truth repo URLs prevents operators from replacing the canonical kmsg binary or guessing which fork owns the deployment patch.\n\nConstraint: Upstream official repository remains channprj/kmsg\nConstraint: Local Madstamp Hermes profile should not overwrite /Users/yu/.local/bin/kmsg\nRejected: Rename the upstream project to KangYu | would confuse upstream PR review and public package identity\nConfidence: high\nScope-risk: narrow\nDirective: Keep MCP server ID kmsg.kangyu for Madstamp profile while command points to /Users/yu/.local/bin/kangyu\nTested: python3 -m unittest discover -s tests\nTested: hermes --profile madstamp mcp list shows kmsg.kangyu enabled\nNot-tested: Upstream maintainer acceptance of the deployment naming note --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c6650cf..8f5ba39 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,8 @@ kmsg watch "본인, 친구, 또는 단톡방 이름" --json - MCP: `kmsg_read`, `kmsg_send`, `kmsg_send_image` - 실시간 감시: `kmsg watch "" --json` +공식 upstream 저장소는 `https://github.com/channprj/kmsg` 입니다. KangYu/Madstamp 운영에서는 upstream PR 전까지 패치 빌드를 `/Users/yu/.local/bin/kangyu`로 설치하고, MCP 서버 ID는 `kmsg.kangyu`를 사용합니다. GitHub 운영 fork는 `https://github.com/MadKangYu/kmsg-upstream-fork`이며, 개인 fork 미러는 `https://github.com/MadKangYu/kmsg` 입니다. + 즉, OpenClaw 연동은 보통 아래 두 프로세스로 구성합니다. ```bash @@ -301,17 +303,24 @@ kmsg mcp-server kmsg watch "채팅방 이름" --json ``` +KangYu/Madstamp 운영 프로필에서는 다음처럼 실행 파일을 분리합니다. + +```bash +/Users/yu/.local/bin/kangyu mcp-server +``` + OpenClaw MCP 설정 예시: ```json { "mcpServers": { - "kmsg": { - "command": "kmsg", + "kmsg.kangyu": { + "command": "/Users/yu/.local/bin/kangyu", "args": ["mcp-server"], "env": { "KMSG_DEFAULT_DEEP_RECOVERY": "false", - "KMSG_TRACE_DEFAULT": "false" + "KMSG_TRACE_DEFAULT": "false", + "KMSG_DEFAULT_READ_LAYOUT": "split-right" } } } From 31f7d298710a5a25860dadef6aae3c5e80de76d7 Mon Sep 17 00:00:00 2001 From: KangYu <160075935+MadKangYu@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:09:12 +0900 Subject: [PATCH 4/6] Expose background-safe reads through MCP The CLI supported --background-safe, but MCP clients could not request it, so Hermes would still have to choose between normal read and split layout behavior. Add a background_safe tool argument, pass it to read, and avoid automatic deep-recovery retry in that mode so a blocked background-safe read remains non-disruptive.\n\nConstraint: Background-safe MCP reads must not fall through into window-opening recovery behavior\nRejected: Rely on split-right default layout only | still permits operator-visible UI movement and misses the user's background-read requirement\nConfidence: high\nScope-risk: narrow\nDirective: Keep background_safe available on kmsg_read whenever --background-safe exists on the CLI\nTested: swift build\nTested: python3 -m unittest discover -s tests\nNot-tested: Live KakaoTalk MCP read of a real exposed chat window --- Sources/kmsg/Commands/MCPServerCommand.swift | 10 +++++++++- tests/test_read_background_safe_contract.py | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Sources/kmsg/Commands/MCPServerCommand.swift b/Sources/kmsg/Commands/MCPServerCommand.swift index a752b16..b847ceb 100644 --- a/Sources/kmsg/Commands/MCPServerCommand.swift +++ b/Sources/kmsg/Commands/MCPServerCommand.swift @@ -300,6 +300,11 @@ private final class KmsgMCPServer { "default": deepRecoveryDefault, "description": "Enable deep recovery mode for window resolution", ], + "background_safe": [ + "type": "boolean", + "default": false, + "description": "Only read already exposed matching chat windows; do not launch, activate, search, resize, or close KakaoTalk windows", + ], "keep_window": [ "type": "boolean", "default": false, @@ -457,6 +462,7 @@ private final class KmsgMCPServer { let boundedLimit = max(1, min(limit, 100)) let deepRecovery = boolValue(arguments["deep_recovery"], defaultValue: deepRecoveryDefault) + let backgroundSafe = boolValue(arguments["background_safe"], defaultValue: false) let keepWindow = boolValue(arguments["keep_window"], defaultValue: false) let traceAX = boolValue(arguments["trace_ax"], defaultValue: traceDefault) guard let layout = Self.validReadLayout(arguments["layout"] as? String ?? readLayoutDefault) else { @@ -478,6 +484,7 @@ private final class KmsgMCPServer { } command.append(contentsOf: ["--json", "--limit", String(boundedLimit), "--layout", layout]) if deepRecovery { command.append("--deep-recovery") } + if backgroundSafe { command.append("--background-safe") } if keepWindow { command.append("--keep-window") } if traceAX { command.append("--trace-ax") } @@ -498,7 +505,7 @@ private final class KmsgMCPServer { if first.returncode != 0 { let combined = "\(first.stdout)\n\(first.stderr)" let code = extractErrorCode(combined) - if code == "CHAT_NOT_FOUND" && !deepRecovery { + if code == "CHAT_NOT_FOUND" && !deepRecovery && !backgroundSafe { var retryCommand = command retryCommand.append("--deep-recovery") let retry = runner.run(retryCommand, timeoutSec: 15.0) @@ -547,6 +554,7 @@ private final class KmsgMCPServer { "meta": [ "latency_ms": first.latencyMs, "layout": layout, + "background_safe": backgroundSafe, ], ] if traceAX, !first.stderr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { diff --git a/tests/test_read_background_safe_contract.py b/tests/test_read_background_safe_contract.py index 5bff1e8..cc323c9 100644 --- a/tests/test_read_background_safe_contract.py +++ b/tests/test_read_background_safe_contract.py @@ -35,6 +35,14 @@ def test_background_safe_resolver_blocks_focus_stealing_paths(self) -> None: self.assertIn("BACKGROUND_SAFE_BLOCKED", source) self.assertIn("background-safe mode; preserving window focus, size, and position", source) + def test_mcp_read_exposes_background_safe_without_ui_retry(self) -> None: + source = MCP_SERVER_COMMAND.read_text(encoding="utf-8") + + self.assertIn('"background_safe"', source) + self.assertIn('command.append("--background-safe")', source) + self.assertIn("!backgroundSafe", source) + self.assertIn('"background_safe": backgroundSafe', source) + def test_split_layout_modes_support_marketing_workspaces(self) -> None: resolver_source = CHAT_WINDOW_RESOLVER.read_text(encoding="utf-8") read_source = READ_COMMAND.read_text(encoding="utf-8") From c43fa112ed48a073483a13bc952b7fe3158766ab Mon Sep 17 00:00:00 2001 From: KangYu <160075935+MadKangYu@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:11:43 +0900 Subject: [PATCH 5/6] Keep MCP startup handshakes nonblocking MCP initialize was still running a Kakao status probe during startup, so an AX or app-state stall could block Hermes before tools/list was available. Startup now verifies the executable version and skips status by default, while retaining an opt-in KMSG_MCP_STARTUP_STATUS_CHECK gate for manual diagnostics.\n\nConstraint: MCP clients need initialize/tools-list to return even when KakaoTalk UI readiness is unstable\nRejected: Increase the status timeout | it preserves the blocking failure mode\nConfidence: high\nScope-risk: narrow\nDirective: Do not make MCP initialize depend on foreground KakaoTalk UI state by default\nTested: swift build\nTested: python3 -m unittest discover -s tests\nNot-tested: KMSG_MCP_STARTUP_STATUS_CHECK=true on a healthy live KakaoTalk session --- Sources/kmsg/Commands/MCPServerCommand.swift | 13 +++++++++++++ tests/test_read_background_safe_contract.py | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/Sources/kmsg/Commands/MCPServerCommand.swift b/Sources/kmsg/Commands/MCPServerCommand.swift index b847ceb..04b9023 100644 --- a/Sources/kmsg/Commands/MCPServerCommand.swift +++ b/Sources/kmsg/Commands/MCPServerCommand.swift @@ -146,6 +146,19 @@ private final class KmsgSubprocessRunner { ) } + let env = ProcessInfo.processInfo.environment + let shouldRunStatusCheck = (env["KMSG_MCP_STARTUP_STATUS_CHECK"] ?? "false").lowercased() == "true" + if !shouldRunStatusCheck { + return ( + true, + [ + "kmsg_bin": executablePath, + "version": version.stdout.trimmingCharacters(in: .whitespacesAndNewlines), + "status_check": "skipped", + ] + ) + } + let status = run(["status"], timeoutSec: 15.0) if status.returncode != 0 { return ( diff --git a/tests/test_read_background_safe_contract.py b/tests/test_read_background_safe_contract.py index cc323c9..36ed352 100644 --- a/tests/test_read_background_safe_contract.py +++ b/tests/test_read_background_safe_contract.py @@ -43,6 +43,13 @@ def test_mcp_read_exposes_background_safe_without_ui_retry(self) -> None: self.assertIn("!backgroundSafe", source) self.assertIn('"background_safe": backgroundSafe', source) + def test_mcp_startup_skips_status_check_by_default(self) -> None: + source = MCP_SERVER_COMMAND.read_text(encoding="utf-8") + + self.assertIn("KMSG_MCP_STARTUP_STATUS_CHECK", source) + self.assertIn('"status_check": "skipped"', source) + self.assertIn('run(["status"], timeoutSec: 15.0)', source) + def test_split_layout_modes_support_marketing_workspaces(self) -> None: resolver_source = CHAT_WINDOW_RESOLVER.read_text(encoding="utf-8") read_source = READ_COMMAND.read_text(encoding="utf-8") From 9ef776606471c1543e1f992fd06d85bb59023efa Mon Sep 17 00:00:00 2001 From: Park Hee Chan Date: Wed, 17 Jun 2026 23:42:00 +0900 Subject: [PATCH 6/6] fix(chore): remove kangyu local environment examples --- README.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8f5ba39..683c419 100644 --- a/README.md +++ b/README.md @@ -294,8 +294,6 @@ kmsg watch "본인, 친구, 또는 단톡방 이름" --json - MCP: `kmsg_read`, `kmsg_send`, `kmsg_send_image` - 실시간 감시: `kmsg watch "" --json` -공식 upstream 저장소는 `https://github.com/channprj/kmsg` 입니다. KangYu/Madstamp 운영에서는 upstream PR 전까지 패치 빌드를 `/Users/yu/.local/bin/kangyu`로 설치하고, MCP 서버 ID는 `kmsg.kangyu`를 사용합니다. GitHub 운영 fork는 `https://github.com/MadKangYu/kmsg-upstream-fork`이며, 개인 fork 미러는 `https://github.com/MadKangYu/kmsg` 입니다. - 즉, OpenClaw 연동은 보통 아래 두 프로세스로 구성합니다. ```bash @@ -303,10 +301,8 @@ kmsg mcp-server kmsg watch "채팅방 이름" --json ``` -KangYu/Madstamp 운영 프로필에서는 다음처럼 실행 파일을 분리합니다. - ```bash -/Users/yu/.local/bin/kangyu mcp-server +$HOME/.local/bin/kmsg mcp-server ``` OpenClaw MCP 설정 예시: @@ -314,8 +310,8 @@ OpenClaw MCP 설정 예시: ```json { "mcpServers": { - "kmsg.kangyu": { - "command": "/Users/yu/.local/bin/kangyu", + "kmsg": { + "command": "$HOME/.local/bin/kmsg", "args": ["mcp-server"], "env": { "KMSG_DEFAULT_DEEP_RECOVERY": "false",