From 206a520fe84cf9737c639b18b614bf02570ca68f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 15:05:09 -0700 Subject: [PATCH 01/19] Add interrupt status regression tests --- ...ifyProcessIntegrationRegressionTests.swift | 82 +++++++++++++++++++ .../WorkspaceRemoteConnectionTests.swift | 74 +++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 76396508bd9a..fb8de58c9bfb 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -7,6 +7,88 @@ import Darwin #endif final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { + func testClaudeWrapperInstallsStopFailureHookAsTurnEndSignal() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-claude-wrapper-stopfailure-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let socketPath = root.appendingPathComponent("cmux.sock", isDirectory: false).path + let listenerFD = try bindUnixSocket(at: socketPath) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let fakeCmuxURL = root.appendingPathComponent("cmux", isDirectory: false) + try """ + #!/bin/sh + if [ "$1" = "--socket" ]; then + shift + shift + fi + if [ "$1" = "ping" ]; then + exit 0 + fi + printf '{}\\n' + exit 0 + """.write(to: fakeCmuxURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fakeCmuxURL.path) + + let fakeClaudeURL = root.appendingPathComponent("real-claude", isDirectory: false) + try """ + #!/bin/sh + while [ "$#" -gt 0 ]; do + if [ "$1" = "--settings" ]; then + shift + printf '%s\\n' "$1" + exit 0 + fi + shift + done + echo "missing --settings" >&2 + exit 66 + """.write(to: fakeClaudeURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fakeClaudeURL.path) + + let sourceRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let wrapperURL = sourceRoot.appendingPathComponent("Resources/bin/claude", isDirectory: false) + + var environment = ProcessInfo.processInfo.environment + environment["HOME"] = root.path + environment["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin" + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_SURFACE_ID"] = "22222222-2222-2222-2222-222222222222" + environment["CMUX_BUNDLED_CLI_PATH"] = fakeCmuxURL.path + environment["CMUX_CUSTOM_CLAUDE_PATH"] = fakeClaudeURL.path + environment["CMUX_CLAUDE_HOOKS_DISABLED"] = "0" + + let result = runProcess( + executablePath: wrapperURL.path, + arguments: [], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let settingsData = try XCTUnwrap(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8)) + let settings = try XCTUnwrap(JSONSerialization.jsonObject(with: settingsData) as? [String: Any]) + let hooks = try XCTUnwrap(settings["hooks"] as? [String: Any]) + let stopFailureGroups = try XCTUnwrap(hooks["StopFailure"] as? [[String: Any]]) + let stopFailureCommands = stopFailureGroups + .compactMap { $0["hooks"] as? [[String: Any]] } + .flatMap { $0 } + .compactMap { $0["command"] as? String } + + XCTAssertTrue( + stopFailureCommands.contains { $0.contains("hooks claude stop") }, + "StopFailure must route through the same turn-end status path as Stop, saw \(stopFailureCommands)" + ) + } + func testClaudeClearSessionStartMarksWorkspaceRunning() throws { let context = try makeClaudeHookContext(name: "claude-clear-running") defer { context.cleanup() } diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 2bec1d8464d2..b5400f431881 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -3023,6 +3023,80 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + func testCodexHookMonitorSetsIdleStatusFromHealthyTerminalCompletionWithoutStopHook() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("codex") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-monitor-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "codex-session-monitor-healthy" + let turnId = "turn-monitor-healthy" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let transcriptURL = root.appendingPathComponent("rollout-\(sessionId).jsonl") + try """ + {"timestamp":"2026-04-25T07:55:29.462Z","type":"session_meta","payload":{"id":"\(sessionId)","cwd":"\(root.path)"}} + {"timestamp":"2026-04-25T07:55:29.500Z","type":"event_msg","payload":{"type":"task_started","turn_id":"\(turnId)","started_at":1777107522}} + {"timestamp":"2026-04-25T07:55:29.600Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done"}]}} + {"timestamp":"2026-04-25T07:55:29.804Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"\(turnId)","last_agent_message":"Done"}} + """.write(to: transcriptURL, atomically: true, encoding: .utf8) + + startMockServerAccepting(listenerFD: listenerFD, state: state, connectionLimit: 4) { line in + if let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String { + return self.v2Response(id: id, ok: true, result: ["surfaces": [["id": surfaceId, "ref": surfaceId]]]) + } + return "OK" + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "hooks", "codex", "monitor", + "--workspace", + workspaceId, + "--surface", + surfaceId, + "--session", + sessionId, + "--turn", + turnId, + "--transcript", + transcriptURL.path, + ], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "") + XCTAssertTrue( + state.commands.contains { command in + command.contains("set_status codex Idle") && + command.contains("--icon=pause.circle.fill") && + command.contains("--color=#8E8E93") && + command.contains("--tab=\(workspaceId)") + }, + "Expected monitor to clear Running when a Codex turn completes without Stop, saw \(state.commands)" + ) + } + func testCodexHookMonitorReportsExplicitErrorBeforeTerminalCompletion() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") From d074d14d9c4a8bff6c0274be8a8f26eeae5f242b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 15:09:55 -0700 Subject: [PATCH 02/19] Clear agent running status on interrupt --- CLI/CMUXCLI+AgentHookDefinitions.swift | 1 + CLI/cmux.swift | 147 ++++++++++++++++++++++++- Resources/bin/claude | 6 +- 3 files changed, 145 insertions(+), 9 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 2f91aa805d91..1a76c5ee0c00 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -108,6 +108,7 @@ extension CMUXCLI { "session-start": .sessionStart, "prompt-submit": .promptSubmit, "stop": .stop, + "stop-failure": .stop, "notification": .notification, "notify": .notification, "agent-response": .stop, diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c8703ef8b301..9cc761764019 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -17146,7 +17146,7 @@ struct CMUXCLI { } print("OK") - case "stop", "idle": + case "stop", "idle", "stop-failure": telemetry.breadcrumb("claude-hook.stop") do { // Turn ended. Don't consume session or clear PID — Claude is still alive. @@ -17176,8 +17176,8 @@ struct CMUXCLI { return } - // Update session with transcript summary and send completion notification. - let completion = summarizeClaudeHookStop( + // StopFailure is a terminal turn boundary, but it is not a completed response. + let completion = subcommand == "stop-failure" ? nil : summarizeClaudeHookStop( parsedInput: parsedInput, sessionRecord: mappedSession ) @@ -17504,7 +17504,7 @@ struct CMUXCLI { telemetry.breadcrumb("claude-hook.help") print( """ - cmux claude-hook [--workspace ] [--surface ] + cmux claude-hook [--workspace ] [--surface ] """ ) @@ -19173,6 +19173,7 @@ struct CMUXCLI { let turnId = optionValue(commandArgs, name: "--turn") var transcriptPath = optionValue(commandArgs, name: "--transcript") let leasePath = optionValue(commandArgs, name: "--lease") + let cwd = optionValue(commandArgs, name: "--cwd") guard !workspaceId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !sessionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -19208,8 +19209,11 @@ struct CMUXCLI { publishedUserInputCallIds.insert(userInput.callId) publishCodexMonitorUserInput( userInput, + sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, + cwd: cwd, + env: env, client: client ) } @@ -19222,12 +19226,23 @@ struct CMUXCLI { case .failure(let failure): publishCodexMonitorFailure( failure, + sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, + cwd: cwd, + env: env, client: client ) return case .healthy: + publishCodexMonitorIdle( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + env: env, + client: client + ) return case .pending: break @@ -19249,10 +19264,107 @@ struct CMUXCLI { } } + private func agentHookRuntimeStatus(for notificationStatus: AgentHookNotificationStatus?) -> AgentHookRuntimeStatus? { + switch notificationStatus { + case .idle?: + return .idle + case .needsInput?: + return .needsInput + case .error?: + return .error + case nil: + return nil + } + } + + private func codexHookSessionStore(env: [String: String]) -> ClaudeHookSessionStore { + ClaudeHookSessionStore( + processEnv: env.merging( + ["CMUX_CLAUDE_HOOK_STATE_PATH": agentHookStatePath(sessionStoreSuffix: "codex", env: env)], + uniquingKeysWith: { _, new in new } + ) + ) + } + + private func recordCodexMonitorRuntimeStatus( + sessionId: String, + workspaceId: String, + surfaceId: String?, + cwd: String?, + env: [String: String], + notificationStatus: AgentHookNotificationStatus, + subtitle: String? = nil, + body: String? = nil + ) { + let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedWorkspaceId = workspaceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedSessionId.isEmpty, !normalizedWorkspaceId.isEmpty else { + return + } + let normalizedSurfaceId = surfaceId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + try? codexHookSessionStore(env: env).upsert( + sessionId: normalizedSessionId, + workspaceId: normalizedWorkspaceId, + surfaceId: normalizedSurfaceId, + cwd: cwd, + lastSubtitle: subtitle, + lastBody: body, + lastNotificationStatus: notificationStatus, + updateLastNotificationStatus: true, + runtimeStatus: agentHookRuntimeStatus(for: notificationStatus), + updateRuntimeStatus: true + ) + } + + private func hasOtherRunningCodexMonitorSession( + sessionId: String, + workspaceId: String, + env: [String: String] + ) -> Bool { + let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedWorkspaceId = workspaceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedWorkspaceId.isEmpty else { + return false + } + return (try? codexHookSessionStore(env: env).hasRunningSession( + workspaceId: normalizedWorkspaceId, + excludingSessionId: normalizedSessionId + )) == true + } + + private func publishCodexMonitorIdle( + sessionId: String, + workspaceId: String, + surfaceId: String?, + cwd: String?, + env: [String: String], + client: SocketClient + ) { + recordCodexMonitorRuntimeStatus( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + env: env, + notificationStatus: .idle + ) + guard !hasOtherRunningCodexMonitorSession(sessionId: sessionId, workspaceId: workspaceId, env: env) else { + return + } + let idleStatus = String(localized: "agent.generic.notification.status.idle", defaultValue: "Idle") + _ = try? sendV1Command( + "set_status codex \(idleStatus) --icon=pause.circle.fill --color=#8E8E93 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + client: client + ) + } + private func publishCodexMonitorUserInput( _ userInput: CodexHookUserInputCandidate, + sessionId: String, workspaceId: String, surfaceId: String?, + cwd: String?, + env: [String: String], client: SocketClient ) { let subtitle = String(localized: "agent.codex.input.subtitle.waiting", defaultValue: "Waiting") @@ -19260,6 +19372,16 @@ struct CMUXCLI { localized: "agent.codex.input.body.needsInput", defaultValue: "Codex is asking a question" ) + recordCodexMonitorRuntimeStatus( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + env: env, + notificationStatus: .needsInput, + subtitle: subtitle, + body: body + ) if let surfaceId, !surfaceId.isEmpty { let payload = "Codex|\(sanitizeNotificationField(subtitle))|\(sanitizeNotificationField(body))" _ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) @@ -19273,11 +19395,24 @@ struct CMUXCLI { private func publishCodexMonitorFailure( _ failure: CodexHookFailureCandidate, + sessionId: String, workspaceId: String, surfaceId: String?, + cwd: String?, + env: [String: String], client: SocketClient ) { let summary = summarizeCodexHookFailureCandidate(failure) + recordCodexMonitorRuntimeStatus( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + env: env, + notificationStatus: .error, + subtitle: summary.subtitle, + body: summary.body + ) if let surfaceId, !surfaceId.isEmpty { let payload = "Codex|\(sanitizeNotificationField(summary.subtitle))|\(sanitizeNotificationField(summary.body))" _ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) @@ -23495,7 +23630,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { case "prompt-submit": return "UserPromptSubmit" case "pre-tool-use", "cron-create-guard": return "PreToolUse" case "post-tool-use": return "PostToolUse" - case "stop", "idle": return "Stop" + case "stop", "idle", "stop-failure": return "Stop" case "session-end": return "SessionEnd" case "notification", "notify": return "Notification" default: return "" @@ -25060,7 +25195,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { return ("SessionStart", false) case "SessionEnd": return ("SessionEnd", false) - case "Stop", "SubagentStop": + case "Stop", "StopFailure", "SubagentStop": return ("Stop", false) case "Notification": return ("Notification", false) diff --git a/Resources/bin/claude b/Resources/bin/claude index 44431169f0ec..4f27fe1865aa 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -456,8 +456,8 @@ fi # Claude Code merges --settings additively with the user's own settings.json. # - preferredNotifChannel: disables Claude Code's OSC/terminal notification # channel inside cmux so hooks are the only notification source. -# - SessionStart/Stop/Notification: existing lifecycle hooks -# - SessionEnd: cleanup when Claude exits (covers Ctrl+C where Stop doesn't fire) +# - SessionStart/Stop/StopFailure/Notification: existing lifecycle hooks +# - SessionEnd: cleanup when Claude exits # - UserPromptSubmit: clears "Needs input" and sets "Running" on new prompt # - PreToolUse: rejects unsupported durable cron requests synchronously, then # clears "Needs input" and captures AskUserQuestion text for notifications @@ -466,7 +466,7 @@ fi # This is Claude Code's native blocking decision hook. It covers # permissions, ExitPlanMode, and AskUserQuestion without using # PreToolUse denial as a side channel. -HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"CronCreate","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude cron-create-guard","timeout":5}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}' +HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"StopFailure":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop-failure","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"CronCreate","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude cron-create-guard","timeout":5}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}' if [[ "$SKIP_SESSION_ID" == true ]]; then exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@" From bfefa8812d342028f19ff659671de1ddf58ea778 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 15:15:18 -0700 Subject: [PATCH 03/19] Tighten StopFailure hook assertion --- cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index fb8de58c9bfb..33b1b6eb4fe7 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -84,8 +84,8 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { .compactMap { $0["command"] as? String } XCTAssertTrue( - stopFailureCommands.contains { $0.contains("hooks claude stop") }, - "StopFailure must route through the same turn-end status path as Stop, saw \(stopFailureCommands)" + stopFailureCommands.contains { $0.contains("hooks claude stop-failure") }, + "StopFailure must invoke the stop-failure subcommand so interrupt cleanup skips completion notification, saw \(stopFailureCommands)" ) } From d336309637af38a8155cc132eee59c118df82c31 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 15:19:52 -0700 Subject: [PATCH 04/19] Guard Codex monitor idle races --- CLI/cmux.swift | 27 +++++++++++++++++-- .../WorkspaceRemoteConnectionTests.swift | 20 +++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 9cc761764019..f238cff087b5 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -660,14 +660,20 @@ private final class ClaudeHookSessionStore { } } - func hasRunningSession(workspaceId: String, excludingSessionId: String?) throws -> Bool { + func hasRunningSession( + workspaceId: String, + surfaceId: String? = nil, + excludingSessionId: String? + ) throws -> Bool { guard let normalizedWorkspace = normalizeOptional(workspaceId) else { return false } + let normalizedSurface = normalizeOptional(surfaceId) let excluded = normalizeOptional(excludingSessionId) return try withLockedState { state in state.sessions.values.contains { record in normalizeOptional(record.workspaceId) == normalizedWorkspace + && (normalizedSurface == nil || normalizeOptional(record.surfaceId) == normalizedSurface) && record.sessionId != excluded && record.runtimeStatus == .running } @@ -19206,6 +19212,9 @@ struct CMUXCLI { turnId: turnId, excluding: publishedUserInputCallIds ) { + if isCodexMonitorLeaseRetired(path: leasePath) { + return + } publishedUserInputCallIds.insert(userInput.callId) publishCodexMonitorUserInput( userInput, @@ -19224,6 +19233,9 @@ struct CMUXCLI { requireTerminalCompletion: true ) { case .failure(let failure): + if isCodexMonitorLeaseRetired(path: leasePath) { + return + } publishCodexMonitorFailure( failure, sessionId: sessionId, @@ -19235,6 +19247,9 @@ struct CMUXCLI { ) return case .healthy: + if isCodexMonitorLeaseRetired(path: leasePath) { + return + } publishCodexMonitorIdle( sessionId: sessionId, workspaceId: workspaceId, @@ -19319,15 +19334,18 @@ struct CMUXCLI { private func hasOtherRunningCodexMonitorSession( sessionId: String, workspaceId: String, + surfaceId: String?, env: [String: String] ) -> Bool { let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedWorkspaceId = workspaceId.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedSurfaceId = surfaceId?.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedWorkspaceId.isEmpty else { return false } return (try? codexHookSessionStore(env: env).hasRunningSession( workspaceId: normalizedWorkspaceId, + surfaceId: normalizedSurfaceId, excludingSessionId: normalizedSessionId )) == true } @@ -19348,7 +19366,12 @@ struct CMUXCLI { env: env, notificationStatus: .idle ) - guard !hasOtherRunningCodexMonitorSession(sessionId: sessionId, workspaceId: workspaceId, env: env) else { + guard !hasOtherRunningCodexMonitorSession( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + env: env + ) else { return } let idleStatus = String(localized: "agent.generic.notification.status.idle", defaultValue: "Idle") diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index b5400f431881..57136623d7e2 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -3032,6 +3032,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { .appendingPathComponent("cmux-codex-monitor-\(UUID().uuidString)", isDirectory: true) let workspaceId = "11111111-1111-1111-1111-111111111111" let surfaceId = "22222222-2222-2222-2222-222222222222" + let otherSurfaceId = "33333333-3333-3333-3333-333333333333" let sessionId = "codex-session-monitor-healthy" let turnId = "turn-monitor-healthy" @@ -3050,6 +3051,23 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { {"timestamp":"2026-04-25T07:55:29.804Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"\(turnId)","last_agent_message":"Done"}} """.write(to: transcriptURL, atomically: true, encoding: .utf8) + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + try """ + { + "version": 1, + "sessions": { + "other-running-session": { + "sessionId": "other-running-session", + "workspaceId": "\(workspaceId)", + "surfaceId": "\(otherSurfaceId)", + "runtimeStatus": "running", + "startedAt": 1777107000, + "updatedAt": 1777107000 + } + } + } + """.write(to: stateURL, atomically: true, encoding: .utf8) + startMockServerAccepting(listenerFD: listenerFD, state: state, connectionLimit: 4) { line in if let data = line.data(using: .utf8), let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], @@ -3093,7 +3111,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { command.contains("--color=#8E8E93") && command.contains("--tab=\(workspaceId)") }, - "Expected monitor to clear Running when a Codex turn completes without Stop, saw \(state.commands)" + "Expected monitor to clear panel-scoped Running when a Codex turn completes without Stop, even if another panel is still running; saw \(state.commands)" ) } From 51023364e69424bd8f21261aad2d75af3ee4c513 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 15:21:13 -0700 Subject: [PATCH 05/19] Localize Claude hook help --- CLI/cmux.swift | 4 ++-- Resources/Localizable.xcstrings | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index f238cff087b5..d67d95ee17a1 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -17509,9 +17509,9 @@ struct CMUXCLI { case "help", "--help", "-h": telemetry.breadcrumb("claude-hook.help") print( - """ + String(localized: "cli.claude-hook.help", defaultValue: """ cmux claude-hook [--workspace ] [--surface ] - """ + """) ) default: diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 528fb35c76c6..7215b6080993 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -3,6 +3,7 @@ "version": "1.0", "strings": { "cli.hooks.error.configDirectoryIsFile": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "cmux could not create the hooks directory: a file exists at %@; remove or rename the conflicting file and re-run `cmux hooks setup`" } }, "ja": { "stringUnit": { "state": "translated", "value": "cmux は hooks ディレクトリを作成できませんでした: %@ にファイルがあります。競合するファイルを削除または名前変更してから `cmux hooks setup` を再実行してください" } } } }, + "cli.claude-hook.help": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "ja": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } } } }, "cli.error.dismissNotificationSelector": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "dismiss-notification requires exactly one of --id or --all-read" } }, "ja": { "stringUnit": { "state": "translated", "value": "dismiss-notification には --id または --all-read のどちらか一方だけを指定してください" } } } }, "cli.error.markNotificationReadSelector": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "mark-notification-read requires exactly one selector: --id, --workspace, or --all" } }, "ja": { "stringUnit": { "state": "translated", "value": "mark-notification-read には --id、--workspace、--all のいずれか一つだけを指定してください" } } } }, "cli.error.markNotificationReadSurfaceRequiresWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "--surface requires --workspace" } }, "ja": { "stringUnit": { "state": "translated", "value": "--surface には --workspace が必要です" } } } }, From 78ce6a3b38d975757351fbef908a38bb666e9a5c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 15:23:26 -0700 Subject: [PATCH 06/19] Add all locale entries for Claude hook help --- Resources/Localizable.xcstrings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7215b6080993..a6af6cf90b55 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -3,7 +3,7 @@ "version": "1.0", "strings": { "cli.hooks.error.configDirectoryIsFile": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "cmux could not create the hooks directory: a file exists at %@; remove or rename the conflicting file and re-run `cmux hooks setup`" } }, "ja": { "stringUnit": { "state": "translated", "value": "cmux は hooks ディレクトリを作成できませんでした: %@ にファイルがあります。競合するファイルを削除または名前変更してから `cmux hooks setup` を再実行してください" } } } }, - "cli.claude-hook.help": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "ja": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } } } }, + "cli.claude-hook.help": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "bs": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "da": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "de": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "en": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "es": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "fr": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "it": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "ja": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "ko": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "nb": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "pl": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "ru": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "th": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "tr": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "uk": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "cmux claude-hook [--workspace ] [--surface ]" } } } }, "cli.error.dismissNotificationSelector": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "dismiss-notification requires exactly one of --id or --all-read" } }, "ja": { "stringUnit": { "state": "translated", "value": "dismiss-notification には --id または --all-read のどちらか一方だけを指定してください" } } } }, "cli.error.markNotificationReadSelector": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "mark-notification-read requires exactly one selector: --id, --workspace, or --all" } }, "ja": { "stringUnit": { "state": "translated", "value": "mark-notification-read には --id、--workspace、--all のいずれか一つだけを指定してください" } } } }, "cli.error.markNotificationReadSurfaceRequiresWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "--surface requires --workspace" } }, "ja": { "stringUnit": { "state": "translated", "value": "--surface には --workspace が必要です" } } } }, From a762689603dac894df870efe37bbb64a3b504f97 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 15:32:58 -0700 Subject: [PATCH 07/19] Tighten Codex idle status guard --- CLI/cmux.swift | 2 +- cmuxTests/WorkspaceRemoteConnectionTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index d67d95ee17a1..2c84bf50272d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -675,7 +675,7 @@ private final class ClaudeHookSessionStore { normalizeOptional(record.workspaceId) == normalizedWorkspace && (normalizedSurface == nil || normalizeOptional(record.surfaceId) == normalizedSurface) && record.sessionId != excluded - && record.runtimeStatus == .running + && (record.runtimeStatus == .running || record.runtimeStatus == .needsInput) } } } diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 57136623d7e2..01f804ba15f1 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -3105,13 +3105,13 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertEqual(result.status, 0, result.stderr) XCTAssertEqual(result.stdout, "") XCTAssertTrue( - state.commands.contains { command in + waitForSocketCommand(state: state, timeout: 5) { command in command.contains("set_status codex Idle") && command.contains("--icon=pause.circle.fill") && command.contains("--color=#8E8E93") && command.contains("--tab=\(workspaceId)") }, - "Expected monitor to clear panel-scoped Running when a Codex turn completes without Stop, even if another panel is still running; saw \(state.commands)" + "Expected monitor to clear panel-scoped Running when a Codex turn completes without Stop, even if another panel is still running; saw \(state.snapshot())" ) } From 972e0a4cf852f6cbe4951716e76b3e9bdd5919c4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 15:45:50 -0700 Subject: [PATCH 08/19] Scope agent idle suppression by surface --- CLI/cmux.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2c84bf50272d..3a7202b8d98e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -675,7 +675,7 @@ private final class ClaudeHookSessionStore { normalizeOptional(record.workspaceId) == normalizedWorkspace && (normalizedSurface == nil || normalizeOptional(record.surfaceId) == normalizedSurface) && record.sessionId != excluded - && (record.runtimeStatus == .running || record.runtimeStatus == .needsInput) + && (record.runtimeStatus == .running || (normalizedSurface != nil && record.runtimeStatus == .needsInput)) } } } @@ -22560,11 +22560,15 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { return nil } } - func hasOtherRunningSession(workspaceId: String) -> Bool { - (try? store.hasRunningSession(workspaceId: workspaceId, excludingSessionId: sessionId)) == true + func hasOtherRunningSession(workspaceId: String, surfaceId: String?) -> Bool { + (try? store.hasRunningSession( + workspaceId: workspaceId, + surfaceId: surfaceId, + excludingSessionId: sessionId + )) == true } func setIdleStatusUnlessAnotherSessionIsRunning(workspaceId: String, surfaceId: String) { - if hasOtherRunningSession(workspaceId: workspaceId) { + if hasOtherRunningSession(workspaceId: workspaceId, surfaceId: surfaceId) { #if DEBUG agentHookDebugLog( "agentHook.status.keepRunning agent=\(def.name) session=\(agentHookDebugShort(sessionId)) workspace=\(agentHookDebugShort(workspaceId)) surface=\(agentHookDebugShort(surfaceId))", From 0501e7fc8da7fee329ab0f32f29831d482466062 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 26 May 2026 12:02:54 -0700 Subject: [PATCH 09/19] Tighten Codex monitor stale-session guard --- CLI/cmux.swift | 3 ++- cmuxTests/WorkspaceRemoteConnectionTests.swift | 11 ++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 64db6df04b8c..0a4570369100 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -22214,7 +22214,8 @@ struct CMUXCLI { return (try? codexHookSessionStore(env: env).hasRunningSession( workspaceId: normalizedWorkspaceId, surfaceId: normalizedSurfaceId, - excludingSessionId: normalizedSessionId + excludingSessionId: normalizedSessionId, + requireLiveProcess: true )) == true } diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index c63535d8bcda..3cb5153fd9d4 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -4018,6 +4018,15 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { "runtimeStatus": "running", "startedAt": 1777107000, "updatedAt": 1777107000 + }, + "stale-same-surface-session": { + "sessionId": "stale-same-surface-session", + "workspaceId": "\(workspaceId)", + "surfaceId": "\(surfaceId)", + "pid": -1, + "runtimeStatus": "running", + "startedAt": 1777107000, + "updatedAt": 1777107000 } } } @@ -4066,7 +4075,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { command.contains("--color=#8E8E93") && command.contains("--tab=\(workspaceId)") }, - "Expected monitor to clear panel-scoped Running when a Codex turn completes without Stop, even if another panel is still running; saw \(state.snapshot())" + "Expected monitor to clear panel-scoped Running when a Codex turn completes without Stop, even if another panel is still running and a stale same-surface session remains; saw \(state.snapshot())" ) } From 7619802a57dd0f6b73b192209b14ce1f903502aa Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 26 May 2026 14:13:08 -0700 Subject: [PATCH 10/19] Update Claude wrapper hook regression for StopFailure --- tests/test_claude_wrapper_hooks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 0d10d12bf947..176d899d6639 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -503,11 +503,12 @@ def test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures: failures, ) hooks = settings.get("hooks", {}) - expected_hooks = {"SessionStart", "Stop", "SubagentStop", "SessionEnd", "Notification", "UserPromptSubmit", "PreToolUse", "PermissionRequest"} + expected_hooks = {"SessionStart", "Stop", "StopFailure", "SubagentStop", "SessionEnd", "Notification", "UserPromptSubmit", "PreToolUse", "PermissionRequest"} expect(set(hooks.keys()) == expected_hooks, f"unexpected hook keys: {hooks.keys()}, expected {expected_hooks}", failures) for hook_name, expected_subcommand in { "SessionStart": "session-start", "Stop": "stop", + "StopFailure": "stop-failure", "SessionEnd": "session-end", "Notification": "notification", "UserPromptSubmit": "prompt-submit", From 225b479fea94e48f5a9f34720b6517a02b256b93 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 6 Jun 2026 02:27:16 -0700 Subject: [PATCH 11/19] Add stale Codex monitor regression test --- .../WorkspaceRemoteConnectionTests.swift | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index ed65e810066d..856de2744725 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -5321,6 +5321,122 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + func testCodexHookMonitorKeepsUnscopedCurrentTurnPendingAfterOldTerminalTurn() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("codex") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-monitor-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "codex-session-monitor-unscoped-current" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let transcriptURL = root.appendingPathComponent("rollout-\(sessionId).jsonl") + try """ + {"timestamp":"2026-04-25T07:55:29.462Z","type":"session_meta","payload":{"id":"\(sessionId)","cwd":"\(root.path)"}} + {"timestamp":"2026-04-25T07:55:29.500Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-old","started_at":1777107522}} + {"timestamp":"2026-04-25T07:55:29.804Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-old","last_agent_message":"Old turn completed."}} + {"timestamp":"2026-04-25T07:55:30.000Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-current","started_at":1777107530}} + """.write(to: transcriptURL, atomically: true, encoding: .utf8) + + let serverHandled = startMockServerSignal(listenerFD: listenerFD, state: state) { line in + if let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String { + return self.v2Response(id: id, ok: true, result: ["surfaces": [["id": surfaceId, "ref": surfaceId]]]) + } + return "OK" + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: cliPath) + process.arguments = [ + "hooks", "codex", "monitor", + "--workspace", + workspaceId, + "--surface", + surfaceId, + "--session", + sessionId, + "--transcript", + transcriptURL.path, + ] + process.environment = environment + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + try process.run() + defer { + if process.isRunning { + process.terminate() + } + } + + let exitSignal = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + exitSignal.signal() + } + + XCTAssertTrue( + waitForProcess(process, toHoldOpenFile: transcriptURL.path, timeout: 2), + "Monitor did not keep watching the transcript after the current unscoped turn started" + ) + XCTAssertTrue(process.isRunning, "Monitor exited on an old terminal turn before the current unscoped turn completed") + + let appendHandle = try FileHandle(forWritingTo: transcriptURL) + try appendHandle.seekToEnd() + appendHandle.write(Data("\n".utf8)) + appendHandle.write(Data(""" + {"timestamp":"2026-04-25T07:55:30.100Z","type":"event_msg","payload":{"type":"error","turn_id":"turn-current","message":"Stream disconnected before completion.","codex_error_info":"response_stream_disconnected"}} + """.utf8)) + try appendHandle.close() + + let serverTimedOut = serverHandled.wait(timeout: .now() + 5) == .timedOut + let timedOut = exitSignal.wait(timeout: .now() + 5) == .timedOut + if timedOut { + process.terminate() + _ = exitSignal.wait(timeout: .now() + 1) + } + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + XCTAssertFalse(serverTimedOut, "Timed out waiting for mock socket command. stderr: \(stderr)") + XCTAssertFalse(timedOut, stderr) + XCTAssertEqual(process.terminationStatus, 0, stderr) + XCTAssertEqual(stdout, "") + XCTAssertTrue( + state.commands.contains { command in + command.contains("notify_target \(workspaceId) \(surfaceId) Codex|Network error|Stream disconnected before completion.") + }, + "Expected monitor to ignore old terminal state and report the current unscoped stream error, saw \(state.commands)" + ) + XCTAssertTrue( + state.commands.contains { command in + command.contains("set_status codex Codex network error") && + command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--color=#FF453A") && + command.contains("--priority=100") && + command.contains("--tab=\(workspaceId)") + }, + "Expected monitor to publish current unscoped Codex network error status, saw \(state.commands)" + ) + } + func testPTYBridgeFlushesReadyBeforeImmediateExit() throws { let rpcClient = ImmediateExitPTYBridgeRPC() let stopped = DispatchSemaphore(value: 0) From 9aca673a65231581106fcd930c4bf8d37bb6ff32 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 6 Jun 2026 02:28:57 -0700 Subject: [PATCH 12/19] Reset Codex monitor state on new task --- CLI/cmux.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 46dbd0e6f1e6..9ba97760c8fb 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -22916,6 +22916,14 @@ struct CMUXCLI { var sawAssistantMessage = false var sawTerminalTurn = false var sawRelevantTurn = turnId == nil + func beginRelevantTurn() { + sawRelevantTurn = true + candidate = nil + candidateCanPublishBeforeTerminal = false + sawAssistantMessage = false + sawTerminalTurn = false + } + for line in lines { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, @@ -22944,9 +22952,7 @@ struct CMUXCLI { continue } } - sawRelevantTurn = true - candidate = nil - candidateCanPublishBeforeTerminal = false + beginRelevantTurn() case "error": let payloadTurnId = firstString(in: payload, keys: ["turn_id", "turnId"]) if let turnId, let payloadTurnId { @@ -23134,6 +23140,9 @@ struct CMUXCLI { } else { sawRelevantTurn = true } + if sawRelevantTurn { + candidate = nil + } case "request_user_input": if let userInput = codexUserInputEventCandidate( From b3c23275ccd1252f971541cd344b8ecc2d367885 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 12:17:35 -0700 Subject: [PATCH 13/19] docs: avoid conflict-marker-like separators --- CLAUDE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a920858e22b7..61c6196d98ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,17 +38,17 @@ App path: **Claude Code** outputs: ```markdown -======================================================= +------------------------------------------------------- [cmux DEV my-tag.app](file:///Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux%20DEV%20my-tag.app) -======================================================= +------------------------------------------------------- ``` **Codex** outputs: ```markdown -======================================================= +------------------------------------------------------- [my-tag: file:///Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux%20DEV%20my-tag.app](file:///Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux%20DEV%20my-tag.app) -======================================================= +------------------------------------------------------- ``` Never use `/tmp/cmux-/...` app links in chat output. From 682b3f297218e1d1a7de5091e0ccb7be0e5a3450 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 12:29:43 -0700 Subject: [PATCH 14/19] ci: refresh Swift length budget for PR changes --- .github/swift-file-length-budget.tsv | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index ea13c6d70701..827bb7ea1903 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -33857 CLI/cmux.swift +34021 CLI/cmux.swift 17914 Sources/AppDelegate.swift 16740 Sources/ContentView.swift 14612 Sources/TerminalController.swift @@ -9,10 +9,10 @@ 12088 Sources/GhosttyTerminalView.swift 12046 cmuxTests/AppDelegateShortcutRoutingTests.swift 11992 Sources/Workspace.swift -9331 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +9413 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7911 Sources/Panels/BrowserPanelView.swift 7350 cmuxTests/WorkspaceUnitTests.swift -6944 cmuxTests/WorkspaceRemoteConnectionTests.swift +7161 cmuxTests/WorkspaceRemoteConnectionTests.swift 6317 cmuxTests/SessionPersistenceTests.swift 6299 cmuxTests/GhosttyConfigTests.swift 6153 CLI/cmux_open.swift @@ -123,7 +123,7 @@ 715 Sources/AppleScriptSupport.swift 710 Sources/TerminalSSHSessionDetector.swift 706 CLI/CMUXCLI+Config.swift -707 CLI/CMUXCLI+AgentHookDefinitions.swift +708 CLI/CMUXCLI+AgentHookDefinitions.swift 699 Sources/RightSidebarPanelView.swift 699 cmuxTests/TerminalNotificationClearAllTests.swift 698 cmuxTests/RestorableAgentHookProviderResumeTests.swift From 7b0b2281b8aac1f91cbcc7a4c03389f9eb1859fc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 12:32:34 -0700 Subject: [PATCH 15/19] ci: regenerate Swift file length budget --- .github/swift-file-length-budget.tsv | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 827bb7ea1903..c153f293547d 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -20,8 +20,8 @@ 6074 Sources/TextBoxInput.swift 5925 cmuxTests/TerminalAndGhosttyTests.swift 5522 cmuxTests/BrowserConfigTests.swift -4921 Sources/cmuxApp.swift 5113 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +4921 Sources/cmuxApp.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift 4227 Sources/BrowserWindowPortal.swift @@ -32,7 +32,7 @@ 3664 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3397 Sources/CmuxConfig.swift 3331 cmuxTests/TabManagerSessionSnapshotTests.swift -3204 Sources/Update/UpdateTitlebarAccessory.swift +3200 Sources/Update/UpdateTitlebarAccessory.swift 2878 Sources/SessionIndexView.swift 2871 cmuxTests/CMUXOpenCommandTests.swift 2573 Sources/KeyboardShortcutSettings.swift @@ -59,11 +59,11 @@ 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift 1574 cmuxTests/MarkdownPanelTests.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift -1498 cmuxTests/OmnibarAndToolsTests.swift +1497 cmuxTests/OmnibarAndToolsTests.swift 1496 cmuxUITests/MultiWindowNotificationsUITests.swift 1446 Sources/FileExplorerStore.swift -1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1382 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1373 cmuxTests/AppDelegateIssue2907RoutingTests.swift 1366 Sources/Feed/FeedButtonStyleDebugWindowController.swift 1362 Sources/CMUXInstalledExtensionSidebarHostView.swift @@ -92,9 +92,9 @@ 937 Sources/TextBoxMentionIndexStore.swift 934 Sources/App/ShortcutRoutingSupport.swift 926 Sources/DockPanelView.swift +920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 919 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift 918 cmuxTests/WorkspaceGroupTests.swift -920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 905 Sources/CmuxSSHURLRequest.swift 901 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 893 Sources/WorkspaceContentView.swift @@ -117,13 +117,14 @@ 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 746 Sources/App/MenuBarExtraController.swift 738 Packages/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift -738 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift +736 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift +726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 716 Sources/TaskManagerSnapshot.swift 715 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift 715 Sources/AppleScriptSupport.swift 710 Sources/TerminalSSHSessionDetector.swift -706 CLI/CMUXCLI+Config.swift 708 CLI/CMUXCLI+AgentHookDefinitions.swift +706 CLI/CMUXCLI+Config.swift 699 Sources/RightSidebarPanelView.swift 699 cmuxTests/TerminalNotificationClearAllTests.swift 698 cmuxTests/RestorableAgentHookProviderResumeTests.swift @@ -187,7 +188,6 @@ 528 cmuxTests/CLINotifyProcessTestSupport.swift 528 cmuxUITests/AutomationSocketUITests.swift 527 CLI/CLISocketPathResolver.swift -726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 523 Packages/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift 520 CLI/CMUXCLI+AmpExtension.swift 520 cmuxTests/MainWindowVisibilityControllerTests.swift From 30265846a882bc5e178f97f90abc5d4e6ec6493d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 12:40:17 -0700 Subject: [PATCH 16/19] ci: regenerate Swift length budget after main merge --- .github/swift-file-length-budget.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index c153f293547d..111da7872cf7 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 34021 CLI/cmux.swift 17914 Sources/AppDelegate.swift -16740 Sources/ContentView.swift +16709 Sources/ContentView.swift 14612 Sources/TerminalController.swift 13595 Sources/Panels/BrowserPanel.swift 12088 Sources/GhosttyTerminalView.swift @@ -13,8 +13,8 @@ 7911 Sources/Panels/BrowserPanelView.swift 7350 cmuxTests/WorkspaceUnitTests.swift 7161 cmuxTests/WorkspaceRemoteConnectionTests.swift +6363 cmuxTests/GhosttyConfigTests.swift 6317 cmuxTests/SessionPersistenceTests.swift -6299 cmuxTests/GhosttyConfigTests.swift 6153 CLI/cmux_open.swift 6074 Sources/TabManager.swift 6074 Sources/TextBoxInput.swift From 5563857ef734e2e56889ece1e566fc0d081cf0d8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 12:53:56 -0700 Subject: [PATCH 17/19] fix: keep Codex monitor idle updates current --- CLI/cmux.swift | 85 +++++++++++++++++-- ...ifyProcessIntegrationRegressionTests.swift | 2 +- ..._bash_integration_no_done_notifications.py | 54 +++++++++++- 3 files changed, 131 insertions(+), 10 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8fe8e21da031..535f227954df 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1005,6 +1005,68 @@ private final class ClaudeHookSessionStore { } } + @discardableResult + func recordCodexMonitorIdleIfCurrent( + sessionId: String, + workspaceId: String, + surfaceId: String, + cwd: String?, + turnId: String? + ) throws -> Bool { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return false } + return try withLockedState { state in + let now = Date().timeIntervalSince1970 + var record = makeSessionRecord( + state: state, + sessionId: normalized, + workspaceId: workspaceId, + surfaceId: surfaceId, + now: now + ) + let turnStack = activePromptTurnStack(from: record) + let activeDepth = max(max(0, record.activePromptDepth ?? 0), turnStack.count) + if let normalizedTurnId = normalizeOptional(turnId) { + if let activeTurnId = turnStack.last, activeTurnId != normalizedTurnId { + return false + } + if turnStack.isEmpty, activeDepth > 0 { + return false + } + if turnStack.last == normalizedTurnId { + var updatedStack = turnStack + updatedStack.removeLast() + setActivePromptTurnStack(updatedStack, totalDepth: max(0, activeDepth - 1), on: &record) + markPromptTurnTerminal(normalizedTurnId, on: &record) + } else if !terminalPromptTurnSet(from: record).contains(normalizedTurnId) { + markPromptTurnTerminal(normalizedTurnId, on: &record) + } + } else if activeDepth > 0 { + return false + } + update( + &record, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + transcriptPath: nil, + pid: nil, + launchCommand: nil, + isRestorable: nil, + agentLifecycle: nil, + lastSubtitle: nil, + lastBody: nil, + lastNotificationStatus: .idle, + updateLastNotificationStatus: true, + runtimeStatus: .idle, + updateRuntimeStatus: true, + now: now + ) + state.sessions[normalized] = record + return true + } + } + func codexSessionStartIsStale( sessionId: String, incomingPID: Int?, @@ -25212,6 +25274,7 @@ struct CMUXCLI { sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, + turnId: turnId, cwd: cwd, env: env, client: client @@ -25313,21 +25376,29 @@ struct CMUXCLI { sessionId: String, workspaceId: String, surfaceId: String?, + turnId: String?, cwd: String?, env: [String: String], client: SocketClient ) { - recordCodexMonitorRuntimeStatus( + let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedWorkspaceId = workspaceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedSessionId.isEmpty, !normalizedWorkspaceId.isEmpty else { + return + } + let normalizedSurfaceId = surfaceId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard ((try? codexHookSessionStore(env: env).recordCodexMonitorIdleIfCurrent( sessionId: sessionId, workspaceId: workspaceId, - surfaceId: surfaceId, + surfaceId: normalizedSurfaceId, cwd: cwd, - env: env, - notificationStatus: .idle - ) + turnId: turnId + )) == true) else { + return + } guard !hasOtherRunningCodexMonitorSession( - sessionId: sessionId, - workspaceId: workspaceId, + sessionId: normalizedSessionId, + workspaceId: normalizedWorkspaceId, surfaceId: surfaceId, env: env ) else { diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index a605a327dd2e..e108e349280a 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -54,7 +54,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { let sourceRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() - let wrapperURL = sourceRoot.appendingPathComponent("Resources/bin/claude", isDirectory: false) + let wrapperURL = sourceRoot.appendingPathComponent("Resources/bin/cmux-claude-wrapper", isDirectory: false) var environment = ProcessInfo.processInfo.environment environment["HOME"] = root.path diff --git a/tests/test_bash_integration_no_done_notifications.py b/tests/test_bash_integration_no_done_notifications.py index ff69d18d010c..debe8cdbf5be 100644 --- a/tests/test_bash_integration_no_done_notifications.py +++ b/tests/test_bash_integration_no_done_notifications.py @@ -42,21 +42,71 @@ def __enter__(self) -> "InteractiveBash": return self def __exit__(self, exc_type, exc, tb) -> None: + pid = self.pid if self.fd is not None: try: os.write(self.fd, b"exit\n") self._read_until(b"exit", timeout=1) except OSError: pass + if pid is not None: + self._wait_or_terminate(pid) + if self.fd is not None: try: os.close(self.fd) except OSError: pass - if self.pid is not None: + + def _wait_or_terminate(self, pid: int) -> None: + deadline = time.time() + 2 + while time.time() < deadline: + try: + waited, _ = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + return + if waited == pid: + return + time.sleep(0.05) + + try: + process_group = os.getpgid(pid) + except ProcessLookupError: + process_group = None + if process_group is not None and process_group != os.getpgrp(): + try: + os.killpg(process_group, signal.SIGTERM) + except ProcessLookupError: + pass + else: + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + + deadline = time.time() + 1 + while time.time() < deadline: + try: + waited, _ = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + return + if waited == pid: + return + time.sleep(0.05) + + if process_group is not None and process_group != os.getpgrp(): + try: + os.killpg(process_group, signal.SIGKILL) + except ProcessLookupError: + pass + else: try: - os.kill(self.pid, signal.SIGKILL) + os.kill(pid, signal.SIGKILL) except ProcessLookupError: pass + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass def run(self, command: str, timeout: float = 5) -> None: if self.fd is None: From fc9fbab7365b0505f6dabad7b45933d03f763331 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 13:06:22 -0700 Subject: [PATCH 18/19] ci: regenerate Swift file length budget --- .github/swift-file-length-budget.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 111da7872cf7..ffaa9fc8273e 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34021 CLI/cmux.swift +34092 CLI/cmux.swift 17914 Sources/AppDelegate.swift 16709 Sources/ContentView.swift 14612 Sources/TerminalController.swift From 60cb8d148390d39b947666b51ab8ab9bee8d3af6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 13:34:52 -0700 Subject: [PATCH 19/19] fix: preserve shared agent status while clearing monitor lifecycle --- .github/swift-file-length-budget.tsv | 4 +- CLI/cmux.swift | 44 +++++- .../WorkspaceRemoteConnectionTests.swift | 135 +++++++++++++++++- 3 files changed, 177 insertions(+), 6 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index ffaa9fc8273e..1a220e2db98d 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34092 CLI/cmux.swift +34130 CLI/cmux.swift 17914 Sources/AppDelegate.swift 16709 Sources/ContentView.swift 14612 Sources/TerminalController.swift @@ -12,7 +12,7 @@ 9413 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7911 Sources/Panels/BrowserPanelView.swift 7350 cmuxTests/WorkspaceUnitTests.swift -7161 cmuxTests/WorkspaceRemoteConnectionTests.swift +7294 cmuxTests/WorkspaceRemoteConnectionTests.swift 6363 cmuxTests/GhosttyConfigTests.swift 6317 cmuxTests/SessionPersistenceTests.swift 6153 CLI/cmux_open.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 535f227954df..a9eb47a69da4 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1053,7 +1053,7 @@ private final class ClaudeHookSessionStore { pid: nil, launchCommand: nil, isRestorable: nil, - agentLifecycle: nil, + agentLifecycle: .idle, lastSubtitle: nil, lastBody: nil, lastNotificationStatus: .idle, @@ -25356,7 +25356,8 @@ struct CMUXCLI { sessionId: String, workspaceId: String, surfaceId: String?, - env: [String: String] + env: [String: String], + includeWorkspaceRunningSessions: Bool = true ) -> Bool { let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedWorkspaceId = workspaceId.trimmingCharacters(in: .whitespacesAndNewlines) @@ -25364,6 +25365,17 @@ struct CMUXCLI { guard !normalizedWorkspaceId.isEmpty else { return false } + if includeWorkspaceRunningSessions, + (try? codexHookSessionStore(env: env).hasRunningSession( + workspaceId: normalizedWorkspaceId, + excludingSessionId: normalizedSessionId, + requireLiveProcess: true + )) == true { + return true + } + guard normalizedSurfaceId != nil else { + return false + } return (try? codexHookSessionStore(env: env).hasRunningSession( workspaceId: normalizedWorkspaceId, surfaceId: normalizedSurfaceId, @@ -25396,6 +25408,22 @@ struct CMUXCLI { )) == true) else { return } + guard !hasOtherRunningCodexMonitorSession( + sessionId: normalizedSessionId, + workspaceId: normalizedWorkspaceId, + surfaceId: surfaceId, + env: env, + includeWorkspaceRunningSessions: false + ) else { + return + } + setAgentLifecycle( + client: client, + key: "codex", + lifecycle: .idle, + workspaceId: workspaceId, + surfaceId: surfaceId + ) guard !hasOtherRunningCodexMonitorSession( sessionId: normalizedSessionId, workspaceId: normalizedWorkspaceId, @@ -29316,7 +29344,17 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { )) == true } func hasOtherRunningSession(workspaceId: String, surfaceId: String?) -> Bool { - (try? store.hasRunningSession( + if (try? store.hasRunningSession( + workspaceId: workspaceId, + excludingSessionId: sessionId, + requireLiveProcess: true + )) == true { + return true + } + guard let surfaceId else { + return false + } + return (try? store.hasRunningSession( workspaceId: workspaceId, surfaceId: surfaceId, excludingSessionId: sessionId, diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 92596b13694b..1372243c136c 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -4849,6 +4849,18 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { "runtimeStatus": "running", "startedAt": 1777107000, "updatedAt": 1777107000 + }, + "\(sessionId)": { + "sessionId": "\(sessionId)", + "workspaceId": "\(workspaceId)", + "surfaceId": "\(surfaceId)", + "activePromptDepth": 1, + "activePromptTurnId": "\(turnId)", + "activePromptTurnIds": ["\(turnId)"], + "agentLifecycle": "running", + "runtimeStatus": "running", + "startedAt": 1777107000, + "updatedAt": 1777107000 } } } @@ -4890,6 +4902,14 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertFalse(result.timedOut, result.stderr) XCTAssertEqual(result.status, 0, result.stderr) XCTAssertEqual(result.stdout, "") + XCTAssertTrue( + waitForSocketCommand(state: state, timeout: 5) { command in + command.hasPrefix("set_agent_lifecycle codex idle ") + && command.contains("--tab=\(workspaceId)") + && command.contains("--panel=\(surfaceId)") + }, + "Expected monitor to clear panel-scoped Codex lifecycle when a turn completes without Stop, saw \(state.snapshot())" + ) XCTAssertTrue( waitForSocketCommand(state: state, timeout: 5) { command in command.contains("set_status codex Idle") && @@ -4897,7 +4917,120 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { command.contains("--color=#8E8E93") && command.contains("--tab=\(workspaceId)") }, - "Expected monitor to clear panel-scoped Running when a Codex turn completes without Stop, even if another panel is still running and a stale same-surface session remains; saw \(state.snapshot())" + "Expected monitor to clear panel-scoped Running when a Codex turn completes without Stop, even if stale running sessions remain; saw \(state.snapshot())" + ) + let updatedState = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let sessions = try XCTUnwrap(updatedState["sessions"] as? [String: Any]) + let record = try XCTUnwrap(sessions[sessionId] as? [String: Any]) + XCTAssertEqual(record["agentLifecycle"] as? String, "idle") + } + + func testCodexHookMonitorDoesNotPublishSharedIdleStatusOverOtherLiveWorkspaceSession() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("codex") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-monitor-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let otherSurfaceId = "33333333-3333-3333-3333-333333333333" + let sessionId = "codex-session-monitor-shared-status" + let turnId = "turn-monitor-shared-status" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let transcriptURL = root.appendingPathComponent("rollout-\(sessionId).jsonl") + try """ + {"timestamp":"2026-04-25T07:55:29.462Z","type":"session_meta","payload":{"id":"\(sessionId)","cwd":"\(root.path)"}} + {"timestamp":"2026-04-25T07:55:29.500Z","type":"event_msg","payload":{"type":"task_started","turn_id":"\(turnId)","started_at":1777107522}} + {"timestamp":"2026-04-25T07:55:29.600Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done"}]}} + {"timestamp":"2026-04-25T07:55:29.804Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"\(turnId)","last_agent_message":"Done"}} + """.write(to: transcriptURL, atomically: true, encoding: .utf8) + + let livePID = ProcessInfo.processInfo.processIdentifier + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + try """ + { + "version": 1, + "sessions": { + "other-live-running-session": { + "sessionId": "other-live-running-session", + "workspaceId": "\(workspaceId)", + "surfaceId": "\(otherSurfaceId)", + "pid": \(livePID), + "runtimeStatus": "running", + "startedAt": 1777107000, + "updatedAt": 1777107000 + }, + "\(sessionId)": { + "sessionId": "\(sessionId)", + "workspaceId": "\(workspaceId)", + "surfaceId": "\(surfaceId)", + "activePromptDepth": 1, + "activePromptTurnId": "\(turnId)", + "activePromptTurnIds": ["\(turnId)"], + "agentLifecycle": "running", + "runtimeStatus": "running", + "startedAt": 1777107000, + "updatedAt": 1777107000 + } + } + } + """.write(to: stateURL, atomically: true, encoding: .utf8) + + startMockServerAccepting(listenerFD: listenerFD, state: state, connectionLimit: 4) { line in + if let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String { + return self.v2Response(id: id, ok: true, result: ["surfaces": [["id": surfaceId, "ref": surfaceId]]]) + } + return "OK" + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "hooks", "codex", "monitor", + "--workspace", + workspaceId, + "--surface", + surfaceId, + "--session", + sessionId, + "--turn", + turnId, + "--transcript", + transcriptURL.path, + ], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "") + XCTAssertTrue( + waitForSocketCommand(state: state, timeout: 5) { command in + command.hasPrefix("set_agent_lifecycle codex idle ") + && command.contains("--tab=\(workspaceId)") + && command.contains("--panel=\(surfaceId)") + }, + "Expected monitor to clear the completed panel lifecycle, saw \(state.snapshot())" + ) + XCTAssertFalse( + state.snapshot().contains { $0.contains("set_status codex Idle") }, + "A completed panel must not overwrite the workspace-shared Codex status while another panel is still running, saw \(state.snapshot())" ) }