diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 5708e925e77..c533bf5c9d5 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. -33285 CLI/cmux.swift +33346 CLI/cmux.swift 19985 Sources/Workspace.swift 19265 Sources/ContentView.swift 18118 Sources/AppDelegate.swift @@ -10,7 +10,7 @@ 13606 Sources/Panels/BrowserPanel.swift 12044 cmuxTests/AppDelegateShortcutRoutingTests.swift 10020 Sources/TabManager.swift -9345 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +9389 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7737 Sources/Panels/BrowserPanelView.swift 7291 cmuxTests/WorkspaceUnitTests.swift 6948 cmuxTests/WorkspaceRemoteConnectionTests.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0b99b150262..a110693ac3f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -22158,6 +22158,15 @@ struct CMUXCLI { ) let shouldPromoteActiveSession = !isForkSessionLaunch && (isClearSessionStart || canReplaceStoppedSession) if let sessionId = parsedInput.sessionId, !isForkSessionLaunch { + let mappedSession = try? sessionStore.lookup(sessionId: sessionId) + let canPublishResumeBinding = shouldPublishClaudeResumeBinding( + sessionId: sessionId, + parsedInput: parsedInput, + mappedSession: mappedSession, + env: ProcessInfo.processInfo.environment, + fallbackPID: claudePid + ) + let shouldPromoteThisSession = shouldPromoteActiveSession && canPublishResumeBinding // Non-clear SessionStart can arrive late from startup/resume/compact // after /clear, so only /clear or replacement of a stopped owner // establishes a new active boundary. @@ -22170,11 +22179,11 @@ struct CMUXCLI { pid: claudePid, launchCommand: launchCommand, isRestorable: false, - agentLifecycle: shouldPromoteActiveSession ? .running : .unknown, - markActive: shouldPromoteActiveSession, + agentLifecycle: shouldPromoteThisSession ? .running : .unknown, + markActive: shouldPromoteThisSession, turnId: parsedInput.turnId ) - if shouldPromoteActiveSession { + if shouldPromoteThisSession { publishAgentSurfaceResumeBinding( client: client, workspaceId: workspaceId, @@ -22283,29 +22292,38 @@ struct CMUXCLI { sessionRecord: mappedSession ) if let sessionId = parsedInput.sessionId { + let canPublishResumeBinding = shouldPublishClaudeResumeBinding( + sessionId: sessionId, + parsedInput: parsedInput, + mappedSession: mappedSession, + env: ProcessInfo.processInfo.environment, + fallbackPID: claudePid + ) try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: parsedInput.cwd, transcriptPath: parsedInput.transcriptPath, - isRestorable: true, + isRestorable: canPublishResumeBinding, agentLifecycle: .idle, lastSubtitle: completion?.subtitle, lastBody: completion?.body, markActive: true, allowsNewSessionReplacement: true ) - publishAgentSurfaceResumeBinding( - client: client, - workspaceId: workspaceId, - surfaceId: surfaceId, - kind: "claude", - displayName: String(localized: "cli.claude-hook.notification.title", defaultValue: "Claude Code"), - sessionId: sessionId, - cwd: parsedInput.cwd ?? mappedSession?.cwd, - launchCommand: mappedSession?.launchCommand - ) + if canPublishResumeBinding { + publishAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + kind: "claude", + displayName: String(localized: "cli.claude-hook.notification.title", defaultValue: "Claude Code"), + sessionId: sessionId, + cwd: parsedInput.cwd ?? mappedSession?.cwd, + launchCommand: mappedSession?.launchCommand + ) + } } setAgentLifecycle( @@ -22402,6 +22420,13 @@ struct CMUXCLI { cwd: parsedInput.cwd ) : nil + let canPublishResumeBinding = shouldPublishClaudeResumeBinding( + sessionId: sessionId, + parsedInput: parsedInput, + mappedSession: mappedSession, + env: ProcessInfo.processInfo.environment, + fallbackPID: claudePid + ) try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, @@ -22410,21 +22435,23 @@ struct CMUXCLI { transcriptPath: parsedInput.transcriptPath, pid: mappedSession == nil ? claudePid : nil, launchCommand: firstSightingLaunchCommand, - isRestorable: true, + isRestorable: canPublishResumeBinding, agentLifecycle: .running, markActive: true, turnId: parsedInput.turnId ) - publishAgentSurfaceResumeBinding( - client: client, - workspaceId: workspaceId, - surfaceId: surfaceId, - kind: "claude", - displayName: String(localized: "cli.claude-hook.notification.title", defaultValue: "Claude Code"), - sessionId: sessionId, - cwd: parsedInput.cwd ?? mappedSession?.cwd, - launchCommand: mappedSession?.launchCommand ?? firstSightingLaunchCommand - ) + if canPublishResumeBinding { + publishAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + kind: "claude", + displayName: String(localized: "cli.claude-hook.notification.title", defaultValue: "Claude Code"), + sessionId: sessionId, + cwd: parsedInput.cwd ?? mappedSession?.cwd, + launchCommand: mappedSession?.launchCommand ?? firstSightingLaunchCommand + ) + } } _ = try sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) setAgentLifecycle( @@ -22944,6 +22971,40 @@ struct CMUXCLI { } } + private func shouldPublishClaudeResumeBinding( + sessionId: String, + parsedInput: ClaudeHookParsedInput, + mappedSession: ClaudeHookSessionRecord?, + env: [String: String], + fallbackPID: Int? + ) -> Bool { + guard let normalizedSessionId = normalizedHookValue(sessionId) else { return false } + if normalizedHookValue(parsedInput.transcriptPath ?? mappedSession?.transcriptPath) != nil { + return true + } + if let resumedSessionId = claudeResumeLaunchSessionId(env: env, fallbackPID: fallbackPID), + resumedSessionId == normalizedSessionId { + return false + } + return true + } + + private func claudeResumeLaunchSessionId(env: [String: String], fallbackPID: Int?) -> String? { + guard let arguments = claudeRawLaunchArguments(env: env, fallbackPID: fallbackPID) else { + return nil + } + for (index, argument) in arguments.enumerated() { + if argument == "--resume" || argument == "-r" { + guard index + 1 < arguments.count else { return nil } + return normalizedHookValue(arguments[index + 1]) + } + for prefix in ["--resume=", "-r="] where argument.hasPrefix(prefix) { + return normalizedHookValue(String(argument.dropFirst(prefix.count))) + } + } + return nil + } + private func isClaudeClearSessionStart(_ parsedInput: ClaudeHookParsedInput) -> Bool { guard let source = parsedInput.object?["source"] as? String else { return false diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 1df2d0acfc2..b3d7044858f 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -402,8 +402,9 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { func testClaudePromptSubmitFromNewSessionCanReplaceStoppedSession() throws { let context = try makeClaudeHookContext(name: "claude-new-session-after-stop") defer { context.cleanup() } + startAgentHookMockServerAccepting(context: context, connectionLimit: 32) - let oldStart = runClaudeHook( + let oldStart = runClaudeHookWithoutServer( context: context, arguments: ["hooks", "claude", "session-start"], standardInput: #"{"session_id":"old-session","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# @@ -411,7 +412,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertFalse(oldStart.timedOut, oldStart.stderr) XCTAssertEqual(oldStart.status, 0, oldStart.stderr) - let oldPrompt = runClaudeHook( + let oldPrompt = runClaudeHookWithoutServer( context: context, arguments: ["hooks", "claude", "prompt-submit"], standardInput: #"{"session_id":"old-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"PromptSubmit"}"# @@ -419,7 +420,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) XCTAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) - let oldStop = runClaudeHook( + let oldStop = runClaudeHookWithoutServer( context: context, arguments: ["hooks", "claude", "stop"], standardInput: #"{"session_id":"old-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"old turn finished"}"# @@ -427,7 +428,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertFalse(oldStop.timedOut, oldStop.stderr) XCTAssertEqual(oldStop.status, 0, oldStop.stderr) - let newStart = runClaudeHook( + let newStart = runClaudeHookWithoutServer( context: context, arguments: ["hooks", "claude", "session-start"], standardInput: #"{"session_id":"new-session","source":"startup","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# @@ -436,7 +437,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(newStart.status, 0, newStart.stderr) let newPromptStart = context.state.commands.count - let newPrompt = runClaudeHook( + let newPrompt = runClaudeHookWithoutServer( context: context, arguments: ["hooks", "claude", "prompt-submit"], standardInput: #"{"session_id":"new-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"PromptSubmit"}"# @@ -453,6 +454,47 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + func testClaudeFailedResumePromptDoesNotPublishSurfaceResumeBinding() throws { + let context = try makeClaudeHookContext(name: "claude-failed-resume-binding") + defer { context.cleanup() } + startAgentHookMockServerAccepting(context: context, connectionLimit: 8) + + let missingSessionId = "932d910e-b979-4583-bf08-66285e5514ce" + let resumeEnvironment = agentLaunchEnvironment( + context: context, + kind: "claude", + executable: "/usr/local/bin/claude", + arguments: ["/usr/local/bin/claude", "--resume", missingSessionId, "--dangerously-skip-permissions"] + ) + let start = runClaudeHookWithoutServer( + context: context, + arguments: ["hooks", "claude", "session-start"], + standardInput: #"{"session_id":"\#(missingSessionId)","source":"startup","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"#, + extraEnvironment: resumeEnvironment + ) + XCTAssertFalse(start.timedOut, start.stderr) + XCTAssertEqual(start.status, 0, start.stderr) + + let prompt = runClaudeHookWithoutServer( + context: context, + arguments: ["hooks", "claude", "prompt-submit"], + standardInput: #"{"session_id":"\#(missingSessionId)","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"resume"}"#, + extraEnvironment: resumeEnvironment + ) + XCTAssertFalse(prompt.timedOut, prompt.stderr) + XCTAssertEqual(prompt.status, 0, prompt.stderr) + + XCTAssertFalse( + context.state.commands.contains { command in + guard let payload = jsonObject(command) else { return false } + return payload["method"] as? String == "surface.resume.set" + }, + "A failed first-sighting Claude resume must not replace the pane's durable resume binding, saw \(context.state.commands)" + ) + let session = try readClaudeHookSession(missingSessionId, context: context) + XCTAssertEqual(session["isRestorable"] as? Bool, false) + } + // MARK: - Forked conversation restore (https://github.com/manaflow-ai/cmux/issues/5908) // // `claude --resume --fork-session` fires SessionStart with the PARENT @@ -8666,6 +8708,8 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { return self.surfaceListResponse(id: id, surfaceId: context.surfaceId) case "feed.push": return self.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return self.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) case "surface.resume.clear": return self.v2Response(id: id, ok: true, result: ["cleared": true]) default: