diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index ea13c6d707..d58e53d86d 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,9 +1,9 @@ # 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 +34120 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,15 +13,15 @@ 7911 Sources/Panels/BrowserPanelView.swift 7350 cmuxTests/WorkspaceUnitTests.swift 6944 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 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 707 CLI/CMUXCLI+AgentHookDefinitions.swift +706 CLI/CMUXCLI+Config.swift 699 Sources/RightSidebarPanelView.swift 699 cmuxTests/TerminalNotificationClearAllTests.swift 698 cmuxTests/RestorableAgentHookProviderResumeTests.swift @@ -137,6 +138,7 @@ 681 Sources/Panels/AgentSessionProcessStore.swift 680 Sources/FileExplorerSearchController.swift 677 Packages/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Bootstrap.swift +675 cmuxTests/ClaudeNoFlickerHookTransientTests.swift 668 cmuxTests/FeedCoordinatorTests.swift 654 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift 650 Sources/Panels/MarkdownRemoteImageLoader.swift @@ -187,7 +189,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 diff --git a/CLAUDE.md b/CLAUDE.md index a920858e22..61c6196d98 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. diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1a259dcddd..d1e6c40b9f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -3481,6 +3481,7 @@ struct CMUXCLI { if command == "setup-hooks" || command == "uninstall-hooks" { try runSetupHooks(uninstall: command == "uninstall-hooks"); return } // Backwards compatibility for old hook setup docs/scripts. if (command == "codex-hook" || command == "feed-hook"), processEnv["CMUX_SURFACE_ID"]?.isEmpty != false, processEnv["CMUX_WORKSPACE_ID"]?.isEmpty != false, !commandArgs.contains(where: { $0 == "--workspace" || $0 == "--surface" || $0.hasPrefix("--workspace=") || $0.hasPrefix("--surface=") }) { print("{}"); return } // Backwards compatibility for old installed hooks outside cmux terminals. + let shouldGracefullyNoOpMissingClaudeHookSocket = command == "hooks" && commandArgs.first?.lowercased() == "claude" && processEnv["CMUX_SURFACE_ID"]?.isEmpty != false && processEnv["CMUX_WORKSPACE_ID"]?.isEmpty != false && !commandArgs.contains(where: { $0 == "--workspace" || $0 == "--surface" || $0.hasPrefix("--workspace=") || $0.hasPrefix("--surface=") }) && (claudeAgentPID(from: processEnv) != nil || ["CMUX_CLI_TTY_NAME", "CMUX_TTY_NAME"].contains { processEnv[$0]?.isEmpty == false }) if command == "hooks" { if try runHooksNoSocketCommand(commandArgs: commandArgs) { return @@ -3488,7 +3489,8 @@ struct CMUXCLI { if Self.hooksCommandNeedsCmuxTarget(commandArgs), processEnv["CMUX_SURFACE_ID"]?.isEmpty != false, processEnv["CMUX_WORKSPACE_ID"]?.isEmpty != false, - !commandArgs.contains(where: { $0 == "--workspace" || $0 == "--surface" || $0.hasPrefix("--workspace=") || $0.hasPrefix("--surface=") }) { + !commandArgs.contains(where: { $0 == "--workspace" || $0 == "--surface" || $0.hasPrefix("--workspace=") || $0.hasPrefix("--surface=") }), + !shouldGracefullyNoOpMissingClaudeHookSocket { print("{}") return } @@ -3511,7 +3513,6 @@ struct CMUXCLI { ) return } - // Feed helpers: clear the persistent workstream history. if command == "feed" { let sub = commandArgs.first?.lowercased() ?? "help" @@ -3583,6 +3584,7 @@ struct CMUXCLI { try client.connect() cliTelemetry.breadcrumb("socket.connect.success", data: ["path": resolvedSocketPath]) } catch { + if shouldGracefullyNoOpMissingClaudeHookSocket { print("{}"); return } cliTelemetry.breadcrumb("socket.connect.failure", data: ["path": resolvedSocketPath]) cliTelemetry.captureError(stage: "socket_connect", error: error) throw error @@ -22401,11 +22403,18 @@ struct CMUXCLI { telemetry: CLISocketSentryTelemetry, socketPassword: String? = nil ) throws { + let env = ProcessInfo.processInfo.environment let subcommand = commandArgs.first?.lowercased() ?? "help" let hookArgs = Array(commandArgs.dropFirst()) let hookWsFlag = optionValue(hookArgs, name: "--workspace") - let workspaceArg = hookWsFlag ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] - let surfaceArg = optionValue(hookArgs, name: "--surface") ?? (hookWsFlag == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) + let hookSurfaceFlag = optionValue(hookArgs, name: "--surface") + let workspaceArg = hookWsFlag ?? env["CMUX_WORKSPACE_ID"] + let workspaceArgIsAmbient = hookWsFlag == nil + let surfaceArg = hookSurfaceFlag ?? (hookWsFlag == nil ? env["CMUX_SURFACE_ID"] : nil) + let surfaceArgIsAmbient = hookSurfaceFlag == nil && hookWsFlag == nil + let hookClaudePid = claudeAgentPID(from: env) + let needsRecoveredHookTarget = hookWsFlag == nil && hookSurfaceFlag == nil && nonEmptyClaudeHookIdentifier(env["CMUX_WORKSPACE_ID"]) == nil && nonEmptyClaudeHookIdentifier(env["CMUX_SURFACE_ID"]) == nil + && (hookClaudePid != nil || ["CMUX_CLI_TTY_NAME", "CMUX_TTY_NAME"].contains { env[$0]?.isEmpty == false }) let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" let parsedInput = parseClaudeHookInput(rawInput: rawInput) let sessionStore = ClaudeHookSessionStore() @@ -22415,7 +22424,7 @@ struct CMUXCLI { "subcommand": subcommand, "has_session_id": parsedInput.sessionId != nil, "has_workspace_flag": hookWsFlag != nil, - "has_surface_flag": optionValue(hookArgs, name: "--surface") != nil + "has_surface_flag": hookSurfaceFlag != nil ] ) @@ -22436,43 +22445,67 @@ struct CMUXCLI { sendClaudeFeedTelemetry() } } + func missingRecoveredHookTarget(agentPID: Int?, allowProcessSnapshotBinding: Bool? = nil, terminalBindingCache: inout ClaudeHookTerminalBindingCache) -> Bool { + guard needsRecoveredHookTarget else { return false } + guard let binding = resolveClaudeHookTerminalBinding(agentPID: agentPID, allowProcessSnapshotBinding: allowProcessSnapshotBinding, terminalBindingCache: &terminalBindingCache, client: client), + let workspaceId = resolveClaudeHookWorkspaceId(binding.workspaceId, client: client), + resolveClaudeHookBindingSurfaceId(binding, workspaceId: workspaceId, client: client) != nil else { return true } + return false + } + + // In ambient no-flicker hooks, a stored record can skip recovery only + // while both its workspace and surface still exist; otherwise fallback + // routing can publish status onto the focused pane. + // https://github.com/manaflow-ai/cmux/issues/6048 + func mappedHookTargetIsReachable(_ mappedSession: ClaudeHookSessionRecord?) -> Bool { + guard let mappedSession else { return false } + guard needsRecoveredHookTarget else { return true } + guard let workspaceId = resolveClaudeHookWorkspaceId(mappedSession.workspaceId, client: client), + claudeHookWorkspaceIsAccessible(workspaceId, client: client, allowUnknown: true), + resolveAccessibleClaudeHookSurfaceId(mappedSession.surfaceId, workspaceId: workspaceId, client: client) != nil else { return false } + return true + } switch subcommand { case "session-start", "active": telemetry.breadcrumb("claude-hook.session-start") + let claudePid = hookClaudePid + var terminalBindingCache: ClaudeHookTerminalBindingCache = (didResolve: false, agentPID: nil, allowProcessSnapshotBinding: hookClaudePid != nil, socketPassword: socketPassword, binding: nil) + guard !missingRecoveredHookTarget(agentPID: claudePid, terminalBindingCache: &terminalBindingCache) else { didSendFeedTelemetry = true; print("{}"); return } let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( preferred: nil, fallback: workspaceArg, + fallbackIsAmbient: workspaceArgIsAmbient, + agentPID: claudePid, + terminalBindingCache: &terminalBindingCache, client: client ) let resolvedSurface = try resolvePreferredSurfaceForClaudeHookDetailed( preferred: nil, fallback: surfaceArg, + fallbackIsAmbient: surfaceArgIsAmbient, workspaceId: workspaceId, + agentPID: claudePid, + terminalBindingCache: &terminalBindingCache, client: client ) let surfaceId = resolvedSurface.surfaceId sendClaudeFeedTelemetry(workspaceId: workspaceId) - let claudePid = claudeAgentPID(from: ProcessInfo.processInfo.environment) let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: claudePid, - env: ProcessInfo.processInfo.environment + env: env ) let launchCommand = agentLaunchCommandFromEnvironment( - ProcessInfo.processInfo.environment, + env, fallbackPID: claudePid, fallbackKind: "claude", cwd: parsedInput.cwd ) - // `claude --resume --fork-session` fires SessionStart with the - // PARENT session id — the forked session id is only minted at the first - // UserPromptSubmit. Upserting here would steal the parent record's - // surface/pid/launch command from the pane that still owns that - // conversation, so fork launches leave the store untouched; the forked - // session is recorded once its own id appears on prompt-submit. + // Fork SessionStart reports the parent session id; the forked id is + // minted on first UserPromptSubmit, so leave the parent record intact. // https://github.com/manaflow-ai/cmux/issues/5908 let isForkSessionLaunch = isClaudeForkSessionLaunch( - env: ProcessInfo.processInfo.environment, + env: env, fallbackPID: claudePid ) let isClearSessionStart = isClaudeClearSessionStart(parsedInput) @@ -22494,7 +22527,7 @@ struct CMUXCLI { surfaceId: surfaceId, cwd: parsedInput.cwd, transcriptPath: parsedInput.transcriptPath, - pid: claudePid, + pid: resolvedSurface.isAuthoritative ? claudePid : nil, launchCommand: launchCommand, isRestorable: false, agentLifecycle: shouldPromoteActiveSession ? .running : .unknown, @@ -22514,18 +22547,9 @@ struct CMUXCLI { ) } } - // Register PID for stale-session detection and OSC suppression. - // Startup/resume SessionStart remains non-visible; /clear is a - // new active boundary and must keep the sidebar Running before - // any late pre-clear Stop can write Idle. - // Fork launches register their PID only with an authoritative - // surface: the hook reports the PARENT session id (which is often - // the workspace-active session), and the pre-prompt fork SessionEnd - // cleanup clears only authoritative targets, so a fallback-pane - // registration would leave a stale agent PID on a pane the fork - // never owned. + // Register PID for stale detection; forks require authoritative non-snapshot surfaces. let shouldRegisterPID = isForkSessionLaunch - ? resolvedSurface.isAuthoritative + ? resolvedSurface.isAuthoritative && !resolvedSurface.isProcessSnapshotBound : shouldPromoteActiveSession || shouldApplyClaudeHookVisibleMutation( sessionStore: sessionStore, @@ -22556,7 +22580,7 @@ struct CMUXCLI { value: "Running", icon: "bolt.fill", color: "#4C8DFF", - pid: claudePid + pid: resolvedSurface.isAuthoritative ? claudePid : nil ) } print("OK") @@ -22567,22 +22591,30 @@ struct CMUXCLI { // Turn ended. Don't consume session or clear PID — Claude is still alive. // Notification hook handles user-facing notifications; SessionEnd handles cleanup. let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } + let claudePid = hookClaudePid ?? mappedSession?.pid + var terminalBindingCache: ClaudeHookTerminalBindingCache = (didResolve: false, agentPID: nil, allowProcessSnapshotBinding: hookClaudePid != nil && mappedSession?.pid != hookClaudePid, socketPassword: socketPassword, binding: nil) + guard mappedHookTargetIsReachable(mappedSession) || !missingRecoveredHookTarget(agentPID: claudePid, terminalBindingCache: &terminalBindingCache) else { didSendFeedTelemetry = true; print("{}"); return } let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( preferred: mappedSession?.workspaceId, fallback: workspaceArg, + fallbackIsAmbient: workspaceArgIsAmbient, + agentPID: claudePid, + terminalBindingCache: &terminalBindingCache, client: client ) let resolvedSurface = try resolvePreferredSurfaceForClaudeHookDetailed( preferred: mappedSession?.surfaceId, fallback: surfaceArg, + fallbackIsAmbient: surfaceArgIsAmbient, workspaceId: workspaceId, + agentPID: claudePid, + terminalBindingCache: &terminalBindingCache, client: client ) let surfaceId = resolvedSurface.surfaceId - let claudePid = mappedSession?.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: claudePid, - env: ProcessInfo.processInfo.environment + env: env ) sendClaudeFeedTelemetry(workspaceId: workspaceId) @@ -22671,22 +22703,30 @@ struct CMUXCLI { case "prompt-submit": telemetry.breadcrumb("claude-hook.prompt-submit") let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } + let claudePid = hookClaudePid ?? mappedSession?.pid + var terminalBindingCache: ClaudeHookTerminalBindingCache = (didResolve: false, agentPID: nil, allowProcessSnapshotBinding: hookClaudePid != nil && mappedSession?.pid != hookClaudePid, socketPassword: socketPassword, binding: nil) + guard mappedHookTargetIsReachable(mappedSession) || !missingRecoveredHookTarget(agentPID: claudePid, terminalBindingCache: &terminalBindingCache) else { didSendFeedTelemetry = true; print("{}"); return } let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( preferred: mappedSession?.workspaceId, fallback: workspaceArg, + fallbackIsAmbient: workspaceArgIsAmbient, + agentPID: claudePid, + terminalBindingCache: &terminalBindingCache, client: client ) let resolvedSurface = try resolvePreferredSurfaceForClaudeHookDetailed( preferred: mappedSession?.surfaceId, fallback: surfaceArg, + fallbackIsAmbient: surfaceArgIsAmbient, workspaceId: workspaceId, + agentPID: claudePid, + terminalBindingCache: &terminalBindingCache, client: client ) let surfaceId = resolvedSurface.surfaceId - let claudePid = mappedSession?.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: claudePid, - env: ProcessInfo.processInfo.environment + env: env ) sendClaudeFeedTelemetry(workspaceId: workspaceId) let shouldApplyPromptSubmit = @@ -22723,7 +22763,7 @@ struct CMUXCLI { // https://github.com/manaflow-ai/cmux/issues/5908 let firstSightingLaunchCommand = mappedSession == nil ? agentLaunchCommandFromEnvironment( - ProcessInfo.processInfo.environment, + env, fallbackPID: claudePid, fallbackKind: "claude", cwd: parsedInput.cwd @@ -22735,7 +22775,7 @@ struct CMUXCLI { surfaceId: surfaceId, cwd: parsedInput.cwd, transcriptPath: parsedInput.transcriptPath, - pid: mappedSession == nil ? claudePid : nil, + pid: resolvedSurface.isAuthoritative ? (hookClaudePid ?? (mappedSession == nil ? claudePid : nil)) : nil, launchCommand: firstSightingLaunchCommand, isRestorable: true, agentLifecycle: .running, @@ -22753,6 +22793,12 @@ struct CMUXCLI { launchCommand: mappedSession?.launchCommand ?? firstSightingLaunchCommand ) } + if let hookClaudePid, resolvedSurface.isAuthoritative { + _ = try? sendV1Command( + "set_agent_pid \(Self.claudeCodeStatusKey) \(hookClaudePid) --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + client: client + ) + } _ = try sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) setAgentLifecycle( client: client, @@ -22767,7 +22813,8 @@ struct CMUXCLI { surfaceId: surfaceId, value: "Running", icon: "bolt.fill", - color: "#4C8DFF" + color: "#4C8DFF", + pid: resolvedSurface.isAuthoritative ? hookClaudePid : nil ) print("OK") @@ -22776,21 +22823,29 @@ struct CMUXCLI { var summary = summarizeClaudeHookNotification(parsedInput: parsedInput) let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } + let claudePid = hookClaudePid ?? mappedSession?.pid + var terminalBindingCache: ClaudeHookTerminalBindingCache = (didResolve: false, agentPID: nil, allowProcessSnapshotBinding: hookClaudePid != nil && mappedSession?.pid != hookClaudePid, socketPassword: socketPassword, binding: nil) + guard mappedHookTargetIsReachable(mappedSession) || !missingRecoveredHookTarget(agentPID: claudePid, terminalBindingCache: &terminalBindingCache) else { didSendFeedTelemetry = true; print("{}"); return } let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( preferred: mappedSession?.workspaceId, fallback: workspaceArg, + fallbackIsAmbient: workspaceArgIsAmbient, + agentPID: claudePid, + terminalBindingCache: &terminalBindingCache, client: client ) - let claudePid = mappedSession?.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: claudePid, - env: ProcessInfo.processInfo.environment + env: env ) sendClaudeFeedTelemetry(workspaceId: workspaceId) let resolvedSurface = try resolvePreferredSurfaceForClaudeHookDetailed( preferred: mappedSession?.surfaceId, fallback: surfaceArg, + fallbackIsAmbient: surfaceArgIsAmbient, workspaceId: workspaceId, + agentPID: claudePid, + terminalBindingCache: &terminalBindingCache, client: client ) let surfaceId = resolvedSurface.surfaceId @@ -22855,18 +22910,15 @@ struct CMUXCLI { case "session-end": telemetry.breadcrumb("claude-hook.session-end") - // A fork launch that exits before its first prompt fires SessionEnd - // with the PARENT session id (the forked id is only minted at the - // first UserPromptSubmit). Consuming it would delete the parent - // pane's restore record and clear its resume binding even though - // that pane still owns the conversation. Post-prompt fork exits - // report the forked id and consume normally. + // Pre-prompt fork SessionEnd reports the parent id; consuming it would + // delete the parent pane's restore record. Post-prompt exits report + // the forked id and consume normally. // https://github.com/manaflow-ai/cmux/issues/5908 if let reportedSessionId = parsedInput.sessionId?.trimmingCharacters(in: .whitespacesAndNewlines), !reportedSessionId.isEmpty, let forkParentSessionId = claudeForkSessionParentId( - env: ProcessInfo.processInfo.environment, - fallbackPID: claudeAgentPID(from: ProcessInfo.processInfo.environment) + env: env, + fallbackPID: hookClaudePid ), reportedSessionId == forkParentSessionId { telemetry.breadcrumb("claude-hook.session-end.fork-parent-skipped") @@ -22874,28 +22926,39 @@ struct CMUXCLI { // that the fork SessionStart registered for this pane, but leave // the parent record, its resume binding, and the workspace's // notifications alone. - let forkClaudePid = claudeAgentPID(from: ProcessInfo.processInfo.environment) + let forkClaudePid = hookClaudePid let suppressForkVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: forkClaudePid, - env: ProcessInfo.processInfo.environment + env: env ) - if !suppressForkVisibleMutations, - let forkWorkspaceId = try? resolvePreferredWorkspaceIdForClaudeHook( - preferred: nil, - fallback: workspaceArg, - client: client - ), - let forkSurface = try? resolvePreferredSurfaceForClaudeHookDetailed( - preferred: nil, - fallback: surfaceArg, - workspaceId: forkWorkspaceId, - client: client - ), - forkSurface.isAuthoritative { - _ = try? sendV1Command( - "clear_agent_pid \(Self.claudeCodeStatusKey) --tab=\(forkWorkspaceId)\(socketPanelOption(forkSurface.surfaceId)) --clear-status", + if !suppressForkVisibleMutations { + var forkTerminalBindingCache: ClaudeHookTerminalBindingCache = (didResolve: false, agentPID: nil, allowProcessSnapshotBinding: true, socketPassword: socketPassword, binding: nil) + guard !missingRecoveredHookTarget(agentPID: forkClaudePid, allowProcessSnapshotBinding: false, terminalBindingCache: &forkTerminalBindingCache) else { didSendFeedTelemetry = true; print("{}"); return } + if let forkWorkspaceId = try? resolvePreferredWorkspaceIdForClaudeHook( + preferred: nil, + fallback: workspaceArg, + fallbackIsAmbient: workspaceArgIsAmbient, + agentPID: forkClaudePid, + allowProcessSnapshotBinding: false, + terminalBindingCache: &forkTerminalBindingCache, client: client - ) + ), + let forkSurface = try? resolvePreferredSurfaceForClaudeHookDetailed( + preferred: nil, + fallback: surfaceArg, + fallbackIsAmbient: surfaceArgIsAmbient, + workspaceId: forkWorkspaceId, + agentPID: forkClaudePid, + allowProcessSnapshotBinding: false, + terminalBindingCache: &forkTerminalBindingCache, + client: client + ), + forkSurface.isAuthoritative { + _ = try? sendV1Command( + "clear_agent_pid \(Self.claudeCodeStatusKey) --tab=\(forkWorkspaceId)\(socketPanelOption(forkSurface.surfaceId)) --clear-status", + client: client + ) + } } print("OK") return @@ -22905,9 +22968,16 @@ struct CMUXCLI { // If Stop already consumed the session, consumedSession is nil and we skip // to avoid wiping the completion notification that Stop just delivered. let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } + let fallbackClaudePid = hookClaudePid ?? mappedSession?.pid + var fallbackTerminalBindingCache: ClaudeHookTerminalBindingCache = (didResolve: false, agentPID: nil, allowProcessSnapshotBinding: hookClaudePid != nil && mappedSession?.pid != hookClaudePid, socketPassword: socketPassword, binding: nil) + guard mappedSession != nil || !missingRecoveredHookTarget(agentPID: fallbackClaudePid, allowProcessSnapshotBinding: false, terminalBindingCache: &fallbackTerminalBindingCache) else { didSendFeedTelemetry = true; print("{}"); return } let fallbackWorkspaceId = try? resolvePreferredWorkspaceIdForClaudeHook( preferred: mappedSession?.workspaceId, fallback: workspaceArg, + fallbackIsAmbient: workspaceArgIsAmbient, + agentPID: fallbackClaudePid, + allowProcessSnapshotBinding: false, + terminalBindingCache: &fallbackTerminalBindingCache, client: client ) let fallbackSurfaceId: String? = { @@ -22915,7 +22985,11 @@ struct CMUXCLI { return try? resolvePreferredSurfaceIdForClaudeHook( preferred: mappedSession?.surfaceId, fallback: surfaceArg, + fallbackIsAmbient: surfaceArgIsAmbient, workspaceId: fallbackWorkspaceId, + agentPID: fallbackClaudePid, + allowProcessSnapshotBinding: false, + terminalBindingCache: &fallbackTerminalBindingCache, client: client ) }() @@ -22945,10 +23019,10 @@ struct CMUXCLI { surfaceId: consumedSession.surfaceId, telemetry: telemetry ) - let claudePid = consumedSession.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) + let claudePid = hookClaudePid ?? consumedSession.pid let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: claudePid, - env: ProcessInfo.processInfo.environment + env: env ) if shouldClearVisibleState, !suppressVisibleMutations { _ = try? sendV1Command( @@ -22979,23 +23053,31 @@ struct CMUXCLI { // Clears "Needs input" status and notification when Claude resumes work // (e.g. after permission grant). Runs async so it doesn't block tool execution. let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } + let claudePid = hookClaudePid ?? mappedSession?.pid + var terminalBindingCache: ClaudeHookTerminalBindingCache = (didResolve: false, agentPID: nil, allowProcessSnapshotBinding: hookClaudePid != nil && mappedSession?.pid != hookClaudePid, socketPassword: socketPassword, binding: nil) + guard mappedHookTargetIsReachable(mappedSession) || !missingRecoveredHookTarget(agentPID: claudePid, terminalBindingCache: &terminalBindingCache) else { didSendFeedTelemetry = true; print("{}"); return } let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( preferred: mappedSession?.workspaceId, fallback: workspaceArg, + fallbackIsAmbient: workspaceArgIsAmbient, + agentPID: claudePid, + terminalBindingCache: &terminalBindingCache, client: client ) let resolvedSurface = try resolvePreferredSurfaceForClaudeHookDetailed( preferred: mappedSession?.surfaceId, fallback: surfaceArg, + fallbackIsAmbient: surfaceArgIsAmbient, workspaceId: workspaceId, + agentPID: claudePid, + terminalBindingCache: &terminalBindingCache, client: client ) let surfaceId = resolvedSurface.surfaceId sendClaudeFeedTelemetry(workspaceId: workspaceId) - let claudePid = mappedSession?.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: claudePid, - env: ProcessInfo.processInfo.environment + env: env ) guard shouldApplyClaudeHookVisibleMutation( sessionStore: sessionStore, @@ -23080,7 +23162,7 @@ struct CMUXCLI { value: statusValue, icon: "bolt.fill", color: "#4C8DFF", - pid: claudePid + pid: resolvedSurface.isAuthoritative ? claudePid : nil ) print("OK") @@ -23290,34 +23372,86 @@ struct CMUXCLI { private func resolvePreferredWorkspaceIdForClaudeHook( preferred: String?, fallback: String?, + fallbackIsAmbient: Bool = false, + agentPID: Int? = nil, + allowProcessSnapshotBinding: Bool? = nil, + terminalBindingCache: inout ClaudeHookTerminalBindingCache, client: SocketClient ) throws -> String { - if let preferred = nonEmptyClaudeHookIdentifier(preferred) { - return try resolveWorkspaceIdForClaudeHook(preferred, client: client) + if let preferred = nonEmptyClaudeHookIdentifier(preferred), let resolvedPreferred = try? resolveWorkspaceId(preferred, client: client), claudeHookWorkspaceIsAccessible(resolvedPreferred, client: client, allowUnknown: true) { + if let binding = resolveClaudeHookTerminalBinding( + agentPID: agentPID, + allowProcessSnapshotBinding: allowProcessSnapshotBinding, + terminalBindingCache: &terminalBindingCache, + client: client + ), + binding.isProcessSnapshotBound, + let boundWorkspaceId = resolveClaudeHookWorkspaceId(binding.workspaceId, client: client), + boundWorkspaceId != resolvedPreferred, + resolveClaudeHookBindingSurfaceId(binding, workspaceId: boundWorkspaceId, client: client) != nil { + return boundWorkspaceId + } + return resolvedPreferred } if let fallback = nonEmptyClaudeHookIdentifier(fallback) { + guard fallbackIsAmbient else { + return try resolveWorkspaceId(fallback, client: client) + } + let resolvedFallback = try? resolveWorkspaceId(fallback, client: client) + if let binding = resolveClaudeHookTerminalBinding( + agentPID: agentPID, + allowProcessSnapshotBinding: allowProcessSnapshotBinding, + terminalBindingCache: &terminalBindingCache, + client: client + ), + let boundWorkspaceId = resolveClaudeHookWorkspaceId(binding.workspaceId, client: client), + resolveClaudeHookBindingSurfaceId(binding, workspaceId: boundWorkspaceId, client: client) != nil { + return boundWorkspaceId + } + if let resolvedFallback, + claudeHookWorkspaceIsAccessible(resolvedFallback, client: client) { + return resolvedFallback + } return try resolveWorkspaceIdForClaudeHook(fallback, client: client) } + if let binding = resolveClaudeHookTerminalBinding( + agentPID: agentPID, + allowProcessSnapshotBinding: allowProcessSnapshotBinding, + terminalBindingCache: &terminalBindingCache, + client: client + ), + let workspaceId = resolveClaudeHookWorkspaceId(binding.workspaceId, client: client), + resolveClaudeHookBindingSurfaceId(binding, workspaceId: workspaceId, client: client) != nil { + return workspaceId + } return try resolveWorkspaceIdForClaudeHook(nil, client: client) } private func resolvePreferredSurfaceIdForClaudeHook( preferred: String?, fallback: String?, + fallbackIsAmbient: Bool = false, workspaceId: String, + agentPID: Int? = nil, + allowProcessSnapshotBinding: Bool? = nil, + terminalBindingCache: inout ClaudeHookTerminalBindingCache, client: SocketClient ) throws -> String { try resolvePreferredSurfaceForClaudeHookDetailed( preferred: preferred, fallback: fallback, + fallbackIsAmbient: fallbackIsAmbient, workspaceId: workspaceId, + agentPID: agentPID, + allowProcessSnapshotBinding: allowProcessSnapshotBinding, + terminalBindingCache: &terminalBindingCache, client: client ).surfaceId } /// Like `resolvePreferredSurfaceIdForClaudeHook`, but also reports whether the /// surface came from the hook's own identity (the session record's surface, - /// the --surface/CMUX_SURFACE_ID value, or the calling process's tty binding) + /// the --surface/CMUX_SURFACE_ID value, or the caller/PID terminal binding) /// or from the focused/first-surface fallback. Only an identity-derived /// surface may participate in cross-surface staleness decisions — a borrowed /// fallback surface must not let a stale hook masquerade as another pane's. @@ -23325,18 +23459,130 @@ struct CMUXCLI { private func resolvePreferredSurfaceForClaudeHookDetailed( preferred: String?, fallback: String?, + fallbackIsAmbient: Bool = false, workspaceId: String, + agentPID: Int? = nil, + allowProcessSnapshotBinding: Bool? = nil, + terminalBindingCache: inout ClaudeHookTerminalBindingCache, client: SocketClient ) throws -> ClaudeHookResolvedSurface { - if let preferred = nonEmptyClaudeHookIdentifier(preferred) { - return try resolveSurfaceAllowingFallbackDetailed(preferred, workspaceId: workspaceId, client: client) + func terminalBindingSurface() -> ClaudeHookResolvedSurface? { + guard let binding = resolveClaudeHookTerminalBinding( + agentPID: agentPID, + allowProcessSnapshotBinding: allowProcessSnapshotBinding, + terminalBindingCache: &terminalBindingCache, + client: client + ), + let boundSurface = resolveClaudeHookBindingSurfaceId(binding, workspaceId: workspaceId, client: client) else { + return nil + } + return ClaudeHookResolvedSurface( + surfaceId: boundSurface, + isAuthoritative: true, + isProcessSnapshotBound: binding.isProcessSnapshotBound + ) + } + if let preferred = nonEmptyClaudeHookIdentifier(preferred), + let surfaceId = resolveAccessibleClaudeHookSurfaceId(preferred, workspaceId: workspaceId, client: client) { + if let boundSurface = terminalBindingSurface(), boundSurface.isProcessSnapshotBound, boundSurface.surfaceId != surfaceId { return boundSurface } + return ClaudeHookResolvedSurface(surfaceId: surfaceId, isAuthoritative: true) } if let fallback = nonEmptyClaudeHookIdentifier(fallback) { - return try resolveSurfaceAllowingFallbackDetailed(fallback, workspaceId: workspaceId, client: client) + let resolved = try resolveSurfaceAllowingFallbackDetailed(fallback, workspaceId: workspaceId, client: client) + if fallbackIsAmbient, + let boundSurface = terminalBindingSurface(), + !resolved.isAuthoritative || boundSurface.surfaceId != resolved.surfaceId { + return boundSurface + } + return resolved + } + if let boundSurface = terminalBindingSurface() { + return boundSurface } return try resolveSurfaceAllowingFallbackDetailed(nil, workspaceId: workspaceId, client: client) } + private func resolveClaudeHookWorkspaceId(_ raw: String?, client: SocketClient) -> String? { + guard let raw = nonEmptyClaudeHookIdentifier(raw), let candidate = try? resolveWorkspaceId(raw, client: client) else { return nil } + return candidate + } + + private func resolveAccessibleClaudeHookSurfaceId( + _ raw: String?, + workspaceId: String, + client: SocketClient + ) -> String? { + guard let raw = nonEmptyClaudeHookIdentifier(raw), + let candidate = try? resolveSurfaceId(raw, workspaceId: workspaceId, client: client), + claudeHookSurfaceIsListed(candidate, workspaceId: workspaceId, client: client, allowUnknown: true) else { + return nil + } + return candidate + } + + private func claudeHookWorkspaceIsAccessible(_ workspaceId: String, client: SocketClient, allowUnknown: Bool = false) -> Bool { + do { + _ = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) + return true + } catch { + let message = String(describing: error).lowercased() + return allowUnknown && !message.contains("workspace_not_found") && !message.contains("workspace not found") + } + } + + private typealias CallerTerminalBinding = ( + workspaceId: String, + surfaceId: String, + isProcessSnapshotBound: Bool + ) + private typealias ClaudeHookTerminalBindingCache = (didResolve: Bool, agentPID: Int?, allowProcessSnapshotBinding: Bool, socketPassword: String?, binding: CallerTerminalBinding?) + + private func resolveClaudeHookTerminalBinding( + agentPID: Int?, + allowProcessSnapshotBinding: Bool? = nil, + terminalBindingCache: inout ClaudeHookTerminalBindingCache, + client: SocketClient + ) -> CallerTerminalBinding? { + if terminalBindingCache.didResolve, terminalBindingCache.agentPID == agentPID, + terminalBindingCache.allowProcessSnapshotBinding == (allowProcessSnapshotBinding ?? terminalBindingCache.allowProcessSnapshotBinding) { + return terminalBindingCache.binding + } + terminalBindingCache.didResolve = true + terminalBindingCache.agentPID = agentPID + terminalBindingCache.allowProcessSnapshotBinding = allowProcessSnapshotBinding ?? terminalBindingCache.allowProcessSnapshotBinding + terminalBindingCache.binding = resolveClaudeHookTerminalBinding( + agentPID: agentPID, + allowProcessSnapshotBinding: terminalBindingCache.allowProcessSnapshotBinding, + socketPassword: terminalBindingCache.socketPassword, + client: client + ) + return terminalBindingCache.binding + } + + private func resolveClaudeHookTerminalBinding( + agentPID: Int?, + allowProcessSnapshotBinding: Bool = true, + socketPassword: String?, client: SocketClient + ) -> CallerTerminalBinding? { + if allowProcessSnapshotBinding, + let binding = resolveAgentProcessTerminalBinding(pid: agentPID, client: client, socketPassword: socketPassword) { + return binding + } + return resolveCallerTerminalBindingByTTY(client: client, cmuxOnly: true) + } + + private func resolveClaudeHookBindingSurfaceId( + _ binding: CallerTerminalBinding?, + workspaceId: String, + client: SocketClient + ) -> String? { + guard let binding, + resolveClaudeHookWorkspaceId(binding.workspaceId, client: client) == workspaceId else { + return nil + } + return resolveAccessibleClaudeHookSurfaceId(binding.surfaceId, workspaceId: workspaceId, client: client) + } + private func nonEmptyClaudeHookIdentifier(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { @@ -23479,13 +23725,23 @@ struct CMUXCLI { } return try resolveWorkspaceId(nil, client: client) } - private struct ClaudeHookResolvedSurface { let surfaceId: String /// Resolved from the hook's own identity (the supplied surface value or - /// the calling process's tty binding) rather than the focused/first- + /// the caller/PID terminal binding) rather than the focused/first- /// surface fallback. let isAuthoritative: Bool + let isProcessSnapshotBound: Bool + + init( + surfaceId: String, + isAuthoritative: Bool, + isProcessSnapshotBound: Bool = false + ) { + self.surfaceId = surfaceId + self.isAuthoritative = isAuthoritative + self.isProcessSnapshotBound = isProcessSnapshotBound + } } private func resolveSurfaceIdAllowingFallback( @@ -23520,20 +23776,19 @@ struct CMUXCLI { private func claudeHookSurfaceIsListed( _ candidate: String, workspaceId: String, - client: SocketClient + client: SocketClient, + allowUnknown: Bool = false ) -> Bool { - guard let listed = try? client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) else { - return false + do { + let listed = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) + let items = listed["surfaces"] as? [[String: Any]] ?? [] + return items.contains(where: { + ($0["id"] as? String) == candidate || ($0["ref"] as? String) == candidate + }) + } catch { + let message = String(describing: error).lowercased() + return allowUnknown && !message.contains("workspace_not_found") && !message.contains("workspace not found") && !message.contains("surface_not_found") && !message.contains("surface not found") } - let items = listed["surfaces"] as? [[String: Any]] ?? [] - return items.contains(where: { - ($0["id"] as? String) == candidate || ($0["ref"] as? String) == candidate - }) - } - - private struct CallerTerminalBinding { - let workspaceId: String - let surfaceId: String } private func resolveCallerWorkspaceIdByTTY(client: SocketClient) -> String? { @@ -23548,20 +23803,17 @@ struct CMUXCLI { return binding.surfaceId } - private func resolveCallerTerminalBindingByTTY(client: SocketClient) -> CallerTerminalBinding? { - guard let ttyName = resolveCallerTTYName() else { - return nil - } + private func resolveCallerTerminalBindingByTTY(client: SocketClient, cmuxOnly: Bool = false) -> CallerTerminalBinding? { + guard let ttyName = resolveCallerTTYName(cmuxOnly: cmuxOnly) else { return nil } return resolveTerminalBinding(ttyName: ttyName, client: client) } - private func resolveAgentProcessTerminalBinding(pid: Int?, client: SocketClient) -> CallerTerminalBinding? { + private func resolveAgentProcessTerminalBinding(pid: Int?, client: SocketClient, socketPassword: String? = nil) -> CallerTerminalBinding? { guard let pid else { return nil } - guard let payload = try? client.sendV2( - method: "system.top", - params: ["all_windows": true, "include_processes": true], - responseTimeout: 2.0 - ) else { + let snapshotClient = SocketClient(path: client.socketPath); defer { snapshotClient.close() } + guard (try? snapshotClient.connectWithoutRetry(responseTimeout: 2.0)) != nil, + (try? Self.authenticateSocketClientIfNeeded(snapshotClient, explicitPassword: socketPassword, socketPath: client.socketPath, responseTimeout: 2.0)) != nil, + let payload = try? snapshotClient.sendV2(method: "system.top", params: ["all_windows": true, "include_processes": true], responseTimeout: 2.0) else { return nil } let windows = payload["windows"] as? [[String: Any]] ?? [] @@ -23583,11 +23835,19 @@ struct CMUXCLI { let topLevelPIDs = (surface["top_level_pids"] as? [Any] ?? []) .compactMap { Self.intValue($0) } if topLevelPIDs.contains(pid) { - return CallerTerminalBinding(workspaceId: workspaceId, surfaceId: surfaceId) + return ( + workspaceId: workspaceId, + surfaceId: surfaceId, + isProcessSnapshotBound: true + ) } let processes = surface["processes"] as? [[String: Any]] ?? [] if topProcessTreeContainsPID(processes, pid: pid) { - return CallerTerminalBinding(workspaceId: workspaceId, surfaceId: surfaceId) + return ( + workspaceId: workspaceId, + surfaceId: surfaceId, + isProcessSnapshotBound: true + ) } } } @@ -23624,18 +23884,20 @@ struct CMUXCLI { let surfaceId = normalizedHandleValue(terminal["surface_id"] as? String) else { continue } - return CallerTerminalBinding(workspaceId: workspaceId, surfaceId: surfaceId) + return (workspaceId: workspaceId, surfaceId: surfaceId, isProcessSnapshotBound: false) } return nil } - private func resolveCallerTTYName() -> String? { + private func resolveCallerTTYName(cmuxOnly: Bool = false) -> String? { let env = ProcessInfo.processInfo.environment - for key in ["CMUX_CLI_TTY_NAME", "CMUX_TTY_NAME", "TTY", "SSH_TTY"] { + let keys = cmuxOnly ? ["CMUX_CLI_TTY_NAME", "CMUX_TTY_NAME"] : ["CMUX_CLI_TTY_NAME", "CMUX_TTY_NAME", "TTY", "SSH_TTY"] + for key in keys { if let ttyName = normalizedTTYName(env[key]) { return ttyName } } + if cmuxOnly { return nil } for fileDescriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] { if let rawTTYName = ttyname(fileDescriptor), let ttyName = normalizedTTYName(String(cString: rawTTYName)) { @@ -28930,7 +29192,11 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { // Workspace/surface resolution: prefer --workspace/--surface flags, // then env, then the caller process. Grok strips CMUX_* from hook // subprocesses, so PID attribution is the only reliable live binding. - let inferredPID = agentPIDFromHookEnvironment(agentName: def.name, env: env) ?? inferredAgentPID() + var inferredPID: Int? + func agentPID() -> Int? { + inferredPID = inferredPID ?? agentPIDFromHookEnvironment(agentName: def.name, env: env) ?? inferredAgentPID() + return inferredPID + } let hookWsFlag = optionValue(hookArgs, name: "--workspace") let directWorkspaceArg = hookWsFlag ?? normalizedHookValue(env["CMUX_WORKSPACE_ID"]) let explicitSurfaceFlag = optionValue(hookArgs, name: "--surface") @@ -28970,15 +29236,9 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { func processBinding() -> CallerTerminalBinding? { if !didResolveProcessBinding { didResolveProcessBinding = true - // Always resolve the agent process's own terminal binding (TTY first, then PID), even - // when env supplies both ids. Historically this was suppressed whenever both env ids - // were present, which made a leaked/stale CMUX_SURFACE_ID impossible to correct — the - // codex jumble class, where a session routes to the wrong surface and the no-pid-gate - // resume binding persists it across reload. resolveAgentHookTarget now uses this - // binding to OVERRIDE a disagreeing ambient-env surface; the binding stays nil (env - // trusted) under remote/SSH where no local TTY maps to a surface. + let agentPID = agentPID() processBindingCache = resolveCallerTerminalBindingByTTY(client: client) - ?? resolveAgentProcessTerminalBinding(pid: inferredPID, client: client) + ?? resolveAgentProcessTerminalBinding(pid: agentPID, client: client, socketPassword: socketPassword) } return processBindingCache } @@ -29280,7 +29540,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } let workspaceId = target.workspaceId let surfaceId = target.surfaceId - let pid = inferredPID + let pid = agentPID() let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations(currentAgentPID: pid, env: env) let launchCommand = agentLaunchCommandFromEnvironment( env, @@ -29409,7 +29669,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } let workspaceId = target.workspaceId let surfaceId = target.surfaceId - let pid = mapped?.pid ?? inferredPID + let pid = mapped?.pid ?? agentPID() let launchCommand = agentLaunchCommandFromEnvironment( env, fallbackPID: pid, @@ -29744,7 +30004,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { let workspaceId = target.workspaceId let surfaceId = target.surfaceId sendAgentFeedTelemetry(workspaceId: workspaceId) - let pid = mapped?.pid ?? inferredPID + let pid = mapped?.pid ?? agentPID() let codexFailure: CodexHookFailureSummary? let codexSubagentSignals: CodexTranscriptSubagentSignals if def.name == "codex" { @@ -30034,7 +30294,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { let workspaceId = target.workspaceId let surfaceId = target.surfaceId sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId) - let pid = mapped?.pid ?? inferredPID + let pid = mapped?.pid ?? agentPID() let launchCommand = agentLaunchCommandFromEnvironment( env, fallbackPID: pid, @@ -30195,7 +30455,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } if !sessionId.isEmpty { - let pid = mapped?.pid ?? inferredPID + let pid = mapped?.pid ?? agentPID() let launchCommand = agentLaunchCommandFromEnvironment( env, fallbackPID: pid, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index ac5daf2eb7..58d335cdb3 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -126,6 +126,10 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */; }; C13519000000000000000007 /* ClaudeConfigDirectoryPathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13519000000000000000008 /* ClaudeConfigDirectoryPathTests.swift */; }; + C6048B01B2C3D4E5F6071801 /* ClaudeHookRoutingTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6048B02B2C3D4E5F6071802 /* ClaudeHookRoutingTestSupport.swift */; }; + C6048C01B2C3D4E5F6071801 /* ClaudeNoFlickerHookBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6048C02B2C3D4E5F6071802 /* ClaudeNoFlickerHookBindingTests.swift */; }; + C6048A01B2C3D4E5F6071801 /* ClaudeNoFlickerHookRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6048A02B2C3D4E5F6071802 /* ClaudeNoFlickerHookRoutingTests.swift */; }; + C6048D01B2C3D4E5F6071801 /* ClaudeNoFlickerHookTransientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6048D02B2C3D4E5F6071802 /* ClaudeNoFlickerHookTransientTests.swift */; }; A9F200000000000000000015 /* ClaudeStreamJSONAccumulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000015 /* ClaudeStreamJSONAccumulator.swift */; }; A5D4120DA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */; }; C0D3F1F00000000000000103 /* CLICodexHookTimeoutRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F00000000000000104 /* CLICodexHookTimeoutRegressionTests.swift */; }; @@ -1059,6 +1063,10 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEMarkedSelectionTests.swift; sourceTree = ""; }; C13519000000000000000008 /* ClaudeConfigDirectoryPathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeConfigDirectoryPathTests.swift; sourceTree = ""; }; + C6048B02B2C3D4E5F6071802 /* ClaudeHookRoutingTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHookRoutingTestSupport.swift; sourceTree = ""; }; + C6048C02B2C3D4E5F6071802 /* ClaudeNoFlickerHookBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeNoFlickerHookBindingTests.swift; sourceTree = ""; }; + C6048A02B2C3D4E5F6071802 /* ClaudeNoFlickerHookRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeNoFlickerHookRoutingTests.swift; sourceTree = ""; }; + C6048D02B2C3D4E5F6071802 /* ClaudeNoFlickerHookTransientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeNoFlickerHookTransientTests.swift; sourceTree = ""; }; A9F100000000000000000015 /* ClaudeStreamJSONAccumulator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ClaudeStreamJSONAccumulator.swift; sourceTree = ""; }; A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIAuthAliasTests.swift; sourceTree = ""; }; C0D3F1F00000000000000104 /* CLICodexHookTimeoutRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICodexHookTimeoutRegressionTests.swift; sourceTree = ""; }; @@ -2678,6 +2686,10 @@ A5E380700000000000000002 /* TerminalNotificationSocketActionTests.swift */, A5A5A506A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift */, A5C41104A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift */, + C6048B02B2C3D4E5F6071802 /* ClaudeHookRoutingTestSupport.swift */, + C6048C02B2C3D4E5F6071802 /* ClaudeNoFlickerHookBindingTests.swift */, + C6048A02B2C3D4E5F6071802 /* ClaudeNoFlickerHookRoutingTests.swift */, + C6048D02B2C3D4E5F6071802 /* ClaudeNoFlickerHookTransientTests.swift */, A5D41204A1B2C3D4E5F60718 /* CLINotifyProcessIntegrationRegressionTests.swift */, C0F16B000000000000000002 /* CLIRemoteShellStartupPerformanceTests.swift */, A5D41206A1B2C3D4E5F60718 /* CLINotifyProcessTestSupport.swift */, @@ -3782,6 +3794,10 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */, C13519000000000000000007 /* ClaudeConfigDirectoryPathTests.swift in Sources */, + C6048B01B2C3D4E5F6071801 /* ClaudeHookRoutingTestSupport.swift in Sources */, + C6048C01B2C3D4E5F6071801 /* ClaudeNoFlickerHookBindingTests.swift in Sources */, + C6048A01B2C3D4E5F6071801 /* ClaudeNoFlickerHookRoutingTests.swift in Sources */, + C6048D01B2C3D4E5F6071801 /* ClaudeNoFlickerHookTransientTests.swift in Sources */, A5D4120DA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift in Sources */, C0D3F1F00000000000000103 /* CLICodexHookTimeoutRegressionTests.swift in Sources */, C46790000000000000000001 /* CLIForwardingLaunchArgumentTests.swift in Sources */, diff --git a/cmuxTests/ClaudeHookRoutingTestSupport.swift b/cmuxTests/ClaudeHookRoutingTestSupport.swift new file mode 100644 index 0000000000..a3d8d05043 --- /dev/null +++ b/cmuxTests/ClaudeHookRoutingTestSupport.swift @@ -0,0 +1,347 @@ +import Darwin +import Foundation + +struct ClaudeHookRoutingTestSupport { + final class BundleMarker: NSObject {} + + struct ProcessRunResult: Sendable { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool + } + + final class MockSocketServerState: @unchecked Sendable { + private let lock = NSLock() + private var commands: [String] = [] + + func append(_ command: String) { + lock.lock() + commands.append(command) + lock.unlock() + } + + func snapshot() -> [String] { + lock.lock() + let value = commands + lock.unlock() + return value + } + } + + struct HookContext: @unchecked Sendable { + let cliPath: String + let socketPath: String + let listenerFD: Int32 + let state: MockSocketServerState + let root: URL + let workspaceId: String + let surfaceId: String + + func cleanup() { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + } + + func makeHookContext(name: String) throws -> HookContext { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-\(name)-\(UUID().uuidString)", isDirectory: true) + let socketPath = makeSocketPath(String(name.prefix(6))) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return HookContext( + cliPath: try BundledCLITestSupport.bundledCLIPath(for: BundleMarker.self), + socketPath: socketPath, + listenerFD: try bindUnixSocket(at: socketPath), + state: MockSocketServerState(), + root: root, + workspaceId: "11111111-1111-1111-1111-111111111111", + surfaceId: "22222222-2222-2222-2222-222222222222" + ) + } + + func startMockServer( + listenerFD: Int32, + state: MockSocketServerState, + handler: @escaping @Sendable (String) -> String + ) -> DispatchSemaphore { + let finished = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + while true { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) + } + } + guard clientFD >= 0 else { + if errno == EINTR { continue } + finished.signal() + return + } + DispatchQueue.global(qos: .userInitiated).async { + defer { + Darwin.close(clientFD) + finished.signal() + } + + var pending = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(clientFD, &buffer, buffer.count) + if count < 0 { + if errno == EINTR { continue } + return + } + if count == 0 { return } + pending.append(buffer, count: count) + + while let newlineRange = pending.firstRange(of: Data([0x0A])) { + let lineData = pending.subdata(in: 0.. ProcessRunResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.environment = environment + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let stdinPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + process.standardInput = stdinPipe + + do { + try process.run() + // Tolerate a closed stdin: no-op hook paths can exit before reading + // stdin, so a non-throwing write could hit EPIPE and crash the test + // runner. The throwing variant + try? swallows that benign race. + try? stdinPipe.fileHandleForWriting.write(contentsOf: Data(standardInput.utf8)) + try? stdinPipe.fileHandleForWriting.close() + } catch { + return ProcessRunResult(status: -1, stdout: "", stderr: String(describing: error), timedOut: false) + } + + let exitSignal = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + exitSignal.signal() + } + let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut + if timedOut { + process.terminate() + _ = exitSignal.wait(timeout: .now() + 1) + if process.isRunning { + Darwin.kill(process.processIdentifier, SIGKILL) + _ = exitSignal.wait(timeout: .now() + 1) + } + } + return ProcessRunResult( + status: process.terminationStatus, + stdout: String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "", + stderr: String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "", + timedOut: timedOut + ) + } + + func baseHookEnvironment(context: HookContext) -> [String: String] { + [ + "HOME": context.root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": context.socketPath, + "CMUX_WORKSPACE_ID": context.workspaceId, + "CMUX_SURFACE_ID": context.surfaceId, + "CMUX_CLAUDE_HOOK_STATE_PATH": context.root.appendingPathComponent("claude-hook-sessions.json").path, + "CMUX_CLI_SENTRY_DISABLED": "1", + "CMUX_CLAUDE_HOOK_SENTRY_DISABLED": "1", + ] + } + + func agentLaunchEnvironment( + context: HookContext, + kind: String, + executable: String, + arguments: [String]? = nil + ) -> [String: String] { + [ + "CMUX_AGENT_LAUNCH_KIND": kind, + "CMUX_AGENT_LAUNCH_EXECUTABLE": executable, + "CMUX_AGENT_LAUNCH_CWD": context.root.path, + "CMUX_AGENT_LAUNCH_ARGV_B64": base64NULSeparated(arguments ?? [executable]), + ] + } + + func claudeForkLaunchEnvironment(context: HookContext, parentSessionId: String) -> [String: String] { + agentLaunchEnvironment( + context: context, + kind: "claude", + executable: "/usr/local/bin/claude", + arguments: ["/usr/local/bin/claude", "--resume", parentSessionId, "--fork-session"] + ) + } + + func seedClaudeForkHookStore( + context: HookContext, + parentSessionId: String, + parentSurfaceId: String, + activeSessionId: String + ) throws { + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 1, + "sessions": [ + parentSessionId: [ + "sessionId": parentSessionId, + "workspaceId": context.workspaceId, + "surfaceId": parentSurfaceId, + "cwd": context.root.path, + "agentLifecycle": "running", + "startedAt": now, + "updatedAt": now, + ], + ], + "activeSessionsByWorkspace": [ + context.workspaceId: [ + "sessionId": activeSessionId, + "updatedAt": now, + ], + ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted]) + .write( + to: context.root.appendingPathComponent("claude-hook-sessions.json"), + options: .atomic + ) + } + + static func v2Response( + id: String, + ok: Bool, + result: [String: Any]? = nil, + error: [String: Any]? = nil + ) -> String { + var payload: [String: Any] = ["id": id, "ok": ok] + if let result { payload["result"] = result } + if let error { payload["error"] = error } + let data = try? JSONSerialization.data(withJSONObject: payload, options: []) + return String(data: data ?? Data("{}".utf8), encoding: .utf8) ?? "{}" + } + + static func malformedRequestResponse(id: String? = nil, raw: String) -> String { + v2Response( + id: id ?? "unknown", + ok: false, + error: ["code": "malformed_request", "message": "invalid or non-JSON payload", "raw": raw] + ) + } + + static func surfaceListResponse(id: String, surfaceId: String) -> String { + v2Response( + id: id, + ok: true, + result: ["surfaces": [["id": surfaceId, "ref": "surface:1", "focused": true]]] + ) + } + + static func jsonObject(_ line: String) -> [String: Any]? { + guard let data = line.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + private func makeSocketPath(_ name: String) -> String { + let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) + return URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("cli-\(name.prefix(6))-\(shortID).sock") + .path + } + + private func bindUnixSocket(at path: String) throws -> Int32 { + unlink(path) + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw posixError("socket") + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path) + let utf8 = Array(path.utf8) + guard utf8.count < maxPathLength else { + Darwin.close(fd) + throw NSError(domain: "cmux.tests", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "socket path is too long: \(path)", + ]) + } + _ = withUnsafeMutablePointer(to: &addr.sun_path) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: maxPathLength) { buffer in + for index in 0...size)) + } + } + guard bindResult == 0 else { + Darwin.close(fd) + throw posixError("bind") + } + guard Darwin.listen(fd, 1) == 0 else { + Darwin.close(fd) + throw posixError("listen") + } + return fd + } + + private func base64NULSeparated(_ values: [String]) -> String { + var data = Data() + for value in values { + data.append(contentsOf: value.utf8) + data.append(0) + } + return data.base64EncodedString() + } + + private func posixError(_ operation: String) -> NSError { + NSError(domain: "cmux.tests", code: Int(errno), userInfo: [ + NSLocalizedDescriptionKey: "\(operation) failed: \(String(cString: strerror(errno)))", + ]) + } +} diff --git a/cmuxTests/ClaudeNoFlickerHookBindingTests.swift b/cmuxTests/ClaudeNoFlickerHookBindingTests.swift new file mode 100644 index 0000000000..f63d8ba3dd --- /dev/null +++ b/cmuxTests/ClaudeNoFlickerHookBindingTests.swift @@ -0,0 +1,499 @@ +import Foundation +import Testing + +@Suite(.serialized) +struct ClaudeNoFlickerHookBindingTests { + private let support = ClaudeHookRoutingTestSupport() + + @Test + func claudePromptSubmitIgnoresPIDBindingWhenBoundSurfaceIsGone() throws { + let context = try support.makeHookContext(name: "claude-stale-pid-bound-surface") + defer { context.cleanup() } + + let staleWorkspaceId = "55555555-5555-5555-5555-555555555555" + let staleSurfaceId = "66666666-6666-6666-6666-666666666666" + let staleFallbackSurfaceId = "77777777-7777-7777-7777-777777777777" + let claudePID = "6048" + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + let params = payload["params"] as? [String: Any] ?? [:] + if params["workspace_id"] as? String == staleWorkspaceId { + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: true, + result: ["surfaces": [["id": staleFallbackSurfaceId, "ref": "surface:1", "focused": true]]] + ) + } + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "system.top": + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: true, + result: [ + "windows": [ + [ + "workspaces": [ + [ + "id": staleWorkspaceId, + "panes": [ + [ + "surfaces": [ + [ + "id": staleSurfaceId, + "top_level_pids": [Int(claudePID)!], + ], + ], + ], + ], + ], + ], + ], + ], + ] + ) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_CLAUDE_PID"] = claudePID + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"gone-pid-surface-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let commands = context.state.snapshot() + #expect( + commands.contains { + $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Expected a PID binding with a gone surface to fall back to the ambient workspace, saw \(commands)" + ) + #expect( + !commands.contains { + ($0.hasPrefix("set_status claude_code Running ") || $0.hasPrefix("set_agent_pid claude_code ")) + && $0.contains("--tab=\(staleWorkspaceId)") + }, + "A stale PID-bound surface must not borrow another surface in its workspace, saw \(commands)" + ) + #expect( + !commands.contains { + ($0.hasPrefix("set_status claude_code Running ") || $0.hasPrefix("set_agent_pid claude_code ")) + && $0.contains("--panel=\(staleFallbackSurfaceId)") + }, + "A stale PID-bound surface must not fall back to another panel, saw \(commands)" + ) + } + + @Test + func claudePromptSubmitPrefersPIDSnapshotOverInheritedTTY() throws { + let context = try support.makeHookContext(name: "claude-stale-inherited-tty") + defer { context.cleanup() } + + let staleWorkspaceId = "55555555-5555-5555-5555-555555555555" + let staleSurfaceId = "66666666-6666-6666-6666-666666666666" + let staleTTY = "ttys6048" + let claudePID = "6048" + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "debug.terminals": + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: true, + result: [ + "terminals": [ + [ + "tty": "/dev/\(staleTTY)", + "workspace_id": staleWorkspaceId, + "surface_id": staleSurfaceId, + ], + ], + ] + ) + case "surface.list": + let params = payload["params"] as? [String: Any] ?? [:] + if params["workspace_id"] as? String == staleWorkspaceId { + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: staleSurfaceId) + } + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "system.top": + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: true, + result: [ + "windows": [ + [ + "workspaces": [ + [ + "id": context.workspaceId, + "panes": [ + [ + "surfaces": [ + [ + "id": context.surfaceId, + "top_level_pids": [Int(claudePID)!], + ], + ], + ], + ], + ], + ], + ], + ], + ] + ) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_CLAUDE_PID"] = claudePID + environment["CMUX_TTY_NAME"] = staleTTY + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"stale-inherited-tty-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let commands = context.state.snapshot() + #expect( + commands.contains { + $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Expected the live Claude PID snapshot to override stale inherited TTY metadata, saw \(commands)" + ) + #expect( + !commands.contains { + ($0.hasPrefix("set_status claude_code Running ") || $0.hasPrefix("set_agent_pid claude_code ")) + && $0.contains("--tab=\(staleWorkspaceId)") + }, + "Stale inherited TTY metadata must not receive Claude visible state, saw \(commands)" + ) + #expect( + !commands.contains { + ($0.hasPrefix("set_status claude_code Running ") || $0.hasPrefix("set_agent_pid claude_code ")) + && $0.contains("--panel=\(staleSurfaceId)") + }, + "Stale inherited TTY metadata must not receive Claude visible state, saw \(commands)" + ) + } + + @Test + func claudePromptSubmitFallsBackWhenStoredWorkspaceIsGone() throws { + let context = try support.makeHookContext(name: "claude-gone-stored-workspace") + defer { context.cleanup() } + + let sessionId = "stored-stale-workspace-session" + let staleWorkspaceId = "55555555-5555-5555-5555-555555555555" + let staleSurfaceId = "66666666-6666-6666-6666-666666666666" + try seedStoredClaudeSession( + context: context, + sessionId: sessionId, + workspaceId: staleWorkspaceId, + surfaceId: staleSurfaceId + ) + + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + let params = payload["params"] as? [String: Any] ?? [:] + if params["workspace_id"] as? String == staleWorkspaceId { + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: false, + error: ["code": "workspace_not_found", "message": "workspace not found"] + ) + } + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: support.baseHookEnvironment(context: context), + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let commands = context.state.snapshot() + #expect( + commands.contains { + $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Expected stale stored workspace to fall back to the live ambient workspace, saw \(commands)" + ) + #expect( + !commands.contains { + ($0.hasPrefix("set_status claude_code Running ") || $0.hasPrefix("set_agent_pid claude_code ")) + && $0.contains("--tab=\(staleWorkspaceId)") + }, + "Stale stored workspace must not receive Claude visible state, saw \(commands)" + ) + } + + @Test + func claudePromptSubmitUsesPIDWhenStoredSurfaceIsGone() throws { + let context = try support.makeHookContext(name: "claude-gone-stored-surface") + defer { context.cleanup() } + + let sessionId = "stored-stale-surface-session" + let staleSurfaceId = "66666666-6666-6666-6666-666666666666" + let borrowedSurfaceId = "77777777-7777-7777-7777-777777777777" + let claudePID = "6048" + try seedStoredClaudeSession( + context: context, + sessionId: sessionId, + workspaceId: context.workspaceId, + surfaceId: staleSurfaceId + ) + + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: true, + result: [ + "surfaces": [ + ["id": borrowedSurfaceId, "ref": "surface:1", "focused": true], + ["id": context.surfaceId, "ref": "surface:2", "focused": false], + ], + ] + ) + case "system.top": + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: true, + result: [ + "windows": [ + [ + "workspaces": [ + [ + "id": context.workspaceId, + "panes": [ + [ + "surfaces": [ + [ + "id": context.surfaceId, + "top_level_pids": [Int(claudePID)!], + ], + [ + "id": borrowedSurfaceId, + "top_level_pids": [], + ], + ], + ], + ], + ], + ], + ], + ], + ] + ) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_CLAUDE_PID"] = claudePID + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let commands = context.state.snapshot() + #expect( + commands.contains { + $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Expected stale stored surface to route to the live Claude PID surface, saw \(commands)" + ) + #expect( + !commands.contains { + ($0.hasPrefix("set_status claude_code Running ") || $0.hasPrefix("set_agent_pid claude_code ")) + && $0.contains("--panel=\(borrowedSurfaceId)") + }, + "Stale stored surface must not borrow the focused/default surface, saw \(commands)" + ) + } + + @Test + func claudePromptSubmitIgnoresRawShellTTYWithoutCmuxTarget() throws { + let context = try support.makeHookContext(name: "claude-raw-tty-no-target") + defer { context.cleanup() } + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_WORKSPACE_ID"] = "" + environment["CMUX_SURFACE_ID"] = "" + environment["TTY"] = "/dev/ttys6048" + environment["SSH_TTY"] = "/dev/ttys6049" + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"raw-tty-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "{}\n") + #expect(context.state.snapshot().isEmpty) + } + + @Test + func claudePromptSubmitKeepsValidAmbientTargetOverRawShellTTY() throws { + let context = try support.makeHookContext(name: "claude-raw-tty-valid-env") + defer { context.cleanup() } + let staleWorkspaceId = "55555555-5555-5555-5555-555555555555" + let staleSurfaceId = "66666666-6666-6666-6666-666666666666" + let staleTTY = "ttys6048" + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { return "OK" } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "debug.terminals": + return ClaudeHookRoutingTestSupport.v2Response( + id: id, ok: true, + result: ["terminals": [["tty": "/dev/\(staleTTY)", "workspace_id": staleWorkspaceId, "surface_id": staleSurfaceId]]] + ) + case "surface.list": + let workspaceId = (payload["params"] as? [String: Any])?["workspace_id"] as? String + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: workspaceId == staleWorkspaceId ? staleSurfaceId : context.surfaceId) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + var environment = support.baseHookEnvironment(context: context) + environment["TTY"] = "/dev/\(staleTTY)" + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"raw-tty-valid-env","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut && result.status == 0, Comment(rawValue: result.stderr)) + let commands = context.state.snapshot() + let expectedStatusPrefix = "set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)" + let expectedPanel = "--panel=\(context.surfaceId)" + let staleTargetReceivedState = commands.contains { + ($0.hasPrefix("set_status claude_code Running ") || $0.hasPrefix("set_agent_pid claude_code ")) && + ($0.contains("--tab=\(staleWorkspaceId)") || $0.contains("--panel=\(staleSurfaceId)")) + } + #expect(commands.contains { $0.hasPrefix(expectedStatusPrefix) && $0.contains(expectedPanel) }, "Expected valid ambient target, saw \(commands)") + #expect(!staleTargetReceivedState, "Raw shell TTY target must not receive Claude visible state, saw \(commands)") + } + private func seedStoredClaudeSession( + context: ClaudeHookRoutingTestSupport.HookContext, + sessionId: String, + workspaceId: String, + surfaceId: String + ) throws { + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 1, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": workspaceId, + "surfaceId": surfaceId, + "cwd": context.root.path, + "agentLifecycle": "idle", + "startedAt": now, + "updatedAt": now, + ], + ], + "activeSessionsByWorkspace": [ + workspaceId: [ + "sessionId": sessionId, + "updatedAt": now, + ], + ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted]) + .write( + to: context.root.appendingPathComponent("claude-hook-sessions.json"), + options: .atomic + ) + } +} diff --git a/cmuxTests/ClaudeNoFlickerHookRoutingTests.swift b/cmuxTests/ClaudeNoFlickerHookRoutingTests.swift new file mode 100644 index 0000000000..a5e2e73fef --- /dev/null +++ b/cmuxTests/ClaudeNoFlickerHookRoutingTests.swift @@ -0,0 +1,494 @@ +import Foundation +import Testing + +@Suite(.serialized) +struct ClaudeNoFlickerHookRoutingTests { + private let support = ClaudeHookRoutingTestSupport() + + @Test + func claudePromptSubmitUsesAgentPIDWhenNoFlickerHookInheritsStaleSurface() throws { + let context = try support.makeHookContext(name: "claude-no-flicker-stale-surface") + defer { context.cleanup() } + + let staleSurfaceId = "33333333-3333-3333-3333-333333333333" + let staleWorkspaceId = "44444444-4444-4444-4444-444444444444" + let claudePID = "6048" + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: true, + result: [ + "surfaces": [ + [ + "id": context.surfaceId, + "ref": "surface:1", + "focused": false, + ], + [ + "id": staleSurfaceId, + "ref": "surface:2", + "focused": true, + ], + ], + ] + ) + case "system.top": + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: true, + result: [ + "windows": [ + [ + "workspaces": [ + [ + "id": context.workspaceId, + "panes": [ + [ + "surfaces": [ + [ + "id": context.surfaceId, + "top_level_pids": [Int(claudePID)!], + ], + [ + "id": staleSurfaceId, + "top_level_pids": [], + ], + ], + ], + ], + ], + ], + ], + ], + ] + ) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_WORKSPACE_ID"] = staleWorkspaceId + environment["CMUX_SURFACE_ID"] = staleSurfaceId + environment["CMUX_CLAUDE_PID"] = claudePID + environment["CLAUDE_CODE_NO_FLICKER"] = "1" + environment.merge( + support.agentLaunchEnvironment(context: context, kind: "claude", executable: "/usr/local/bin/claude") + ) { _, new in new } + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"no-flicker-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "OK\n") + let commands = context.state.snapshot() + #expect( + commands.contains { + $0.hasPrefix("set_agent_pid claude_code \(claudePID) --tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Expected prompt-submit to refresh Claude's PID gate for the PID-bound pane, saw \(commands)" + ) + #expect( + commands.contains { + $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Expected prompt-submit to mark the PID-bound pane Running, saw \(commands)" + ) + #expect( + !commands.contains { + ($0.hasPrefix("set_status claude_code Running ") || $0.hasPrefix("set_agent_pid claude_code ")) + && $0.contains("--panel=\(staleSurfaceId)") + }, + "Stale ambient CMUX_SURFACE_ID must not receive Claude's visible status or PID gate, saw \(commands)" + ) + #expect( + !commands.contains { + ($0.hasPrefix("set_status claude_code Running ") || $0.hasPrefix("set_agent_pid claude_code ")) + && $0.contains("--tab=\(staleWorkspaceId)") + }, + "Stale ambient CMUX_WORKSPACE_ID must not receive Claude's visible status or PID gate, saw \(commands)" + ) + let systemTopCalls = commands.filter { + ClaudeHookRoutingTestSupport.jsonObject($0)?["method"] as? String == "system.top" + } + #expect(systemTopCalls.count == 1, "Expected one cached process snapshot, saw \(commands)") + } + + @Test + func claudePromptSubmitValidatesStaleAmbientWorkspaceWithoutBinding() throws { + let context = try support.makeHookContext(name: "claude-stale-workspace-no-binding") + defer { context.cleanup() } + + let staleWorkspaceId = "44444444-4444-4444-4444-444444444444" + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + let params = payload["params"] as? [String: Any] ?? [:] + if params["workspace_id"] as? String == staleWorkspaceId { + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: false, + error: ["code": "workspace_not_found", "message": "workspace not found"] + ) + } + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "workspace.current": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["workspace_id": context.workspaceId]) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_WORKSPACE_ID"] = staleWorkspaceId + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"stale-workspace-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let commands = context.state.snapshot() + #expect( + commands.contains { + $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Expected stale ambient workspace to fall back to the app-selected workspace, saw \(commands)" + ) + #expect( + !commands.contains { + ($0.hasPrefix("set_status claude_code Running ") || $0.hasPrefix("set_agent_pid claude_code ")) + && $0.contains("--tab=\(staleWorkspaceId)") + }, + "Stale ambient CMUX_WORKSPACE_ID must not receive Claude visible state without a terminal/PID binding, saw \(commands)" + ) + } + + @Test + func claudePromptSubmitIgnoresStalePIDBoundWorkspace() throws { + let context = try support.makeHookContext(name: "claude-stale-pid-bound-workspace") + defer { context.cleanup() } + + let staleWorkspaceId = "55555555-5555-5555-5555-555555555555" + let staleSurfaceId = "66666666-6666-6666-6666-666666666666" + let claudePID = "6048" + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + let params = payload["params"] as? [String: Any] ?? [:] + if params["workspace_id"] as? String == staleWorkspaceId { + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: false, + error: ["code": "workspace_not_found", "message": "workspace not found"] + ) + } + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "system.top": + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: true, + result: [ + "windows": [ + [ + "workspaces": [ + [ + "id": staleWorkspaceId, + "panes": [ + [ + "surfaces": [ + [ + "id": staleSurfaceId, + "top_level_pids": [Int(claudePID)!], + ], + ], + ], + ], + ], + ], + ], + ], + ] + ) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_CLAUDE_PID"] = claudePID + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"stale-pid-workspace-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let commands = context.state.snapshot() + #expect( + commands.contains { + $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Expected stale PID-bound workspace to fall back to the ambient workspace, saw \(commands)" + ) + #expect( + !commands.contains { + ($0.hasPrefix("set_status claude_code Running ") || $0.hasPrefix("set_agent_pid claude_code ")) + && $0.contains("--tab=\(staleWorkspaceId)") + }, + "Stale PID-bound workspace must not receive Claude visible state, saw \(commands)" + ) + } + + @Test + func claudePromptSubmitPrefersLiveHookPIDOverStoredPID() throws { + let context = try support.makeHookContext(name: "claude-live-pid-over-stored") + defer { context.cleanup() } + + let sessionId = "stored-stale-pid-session" + let staleWorkspaceId = "55555555-5555-5555-5555-555555555555" + let staleSurfaceId = "66666666-6666-6666-6666-666666666666" + let stalePID = 1111 + let livePID = 6048 + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 1, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": staleWorkspaceId, + "surfaceId": staleSurfaceId, + "cwd": context.root.path, + "pid": stalePID, + "agentLifecycle": "idle", + "startedAt": now, + "updatedAt": now, + ], + ], + "activeSessionsByWorkspace": [staleWorkspaceId: ["sessionId": sessionId, "updatedAt": now]], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted]) + .write(to: context.root.appendingPathComponent("claude-hook-sessions.json"), options: .atomic) + + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + let workspaceId = (payload["params"] as? [String: Any])?["workspace_id"] as? String + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: true, + result: ["surfaces": [["id": workspaceId == staleWorkspaceId ? staleSurfaceId : context.surfaceId, "ref": "surface:1"]]] + ) + case "system.top": + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: true, + result: [ + "windows": [[ + "workspaces": [[ + "id": context.workspaceId, + "panes": [["surfaces": [ + ["id": staleSurfaceId, "top_level_pids": [stalePID]], + ["id": context.surfaceId, "top_level_pids": [livePID]], + ]]], + ]], + ]], + ] + ) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_CLAUDE_PID"] = "\(livePID)" + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let commands = context.state.snapshot() + #expect( + commands.contains { + $0.hasPrefix("set_agent_pid claude_code \(livePID) --tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Expected prompt-submit to refresh Claude's PID gate for the live PID-bound pane, saw \(commands)" + ) + #expect( + commands.contains { + $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Expected prompt-submit to mark the live PID-bound pane Running, saw \(commands)" + ) + #expect( + !commands.contains { + ($0.hasPrefix("set_status claude_code Running ") || $0.hasPrefix("set_agent_pid claude_code ")) + && $0.contains("--panel=\(staleSurfaceId)") + }, + "Stored stale PID surface must not receive Claude's visible status or PID gate, saw \(commands)" + ) + let systemTopCalls = commands.filter { + ClaudeHookRoutingTestSupport.jsonObject($0)?["method"] as? String == "system.top" + } + #expect(systemTopCalls.count == 1, "Expected one cached process snapshot, saw \(commands)") + let persistedData = try Data(contentsOf: context.root.appendingPathComponent("claude-hook-sessions.json")) + let persisted = try #require(JSONSerialization.jsonObject(with: persistedData) as? [String: Any]) + let sessions = try #require(persisted["sessions"] as? [String: Any]) + let session = try #require(sessions[sessionId] as? [String: Any]) + #expect(session["pid"] as? Int == livePID, "Expected live hook PID to replace the stale stored PID, saw \(session)") + } + + @Test + func claudeForkSessionStartDoesNotRegisterProcessSnapshotOnlyPID() throws { + let context = try support.makeHookContext(name: "claude-fork-process-only") + defer { context.cleanup() } + + let parentSessionId = "parent-session" + let parentSurfaceId = "99999999-9999-9999-9999-999999999999" + let claudePID = "6048" + try support.seedClaudeForkHookStore( + context: context, + parentSessionId: parentSessionId, + parentSurfaceId: parentSurfaceId, + activeSessionId: parentSessionId + ) + + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "system.top": + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: true, + result: [ + "windows": [ + [ + "workspaces": [ + [ + "id": context.workspaceId, + "panes": [ + [ + "surfaces": [ + [ + "id": context.surfaceId, + "top_level_pids": [Int(claudePID)!], + ], + ], + ], + ], + ], + ], + ], + ], + ] + ) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_WORKSPACE_ID"] = "" + environment["CMUX_SURFACE_ID"] = "" + environment["CMUX_CLAUDE_PID"] = claudePID + environment.merge(support.claudeForkLaunchEnvironment(context: context, parentSessionId: parentSessionId)) { _, new in new } + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "session-start"], + environment: environment, + standardInput: #"{"session_id":"\#(parentSessionId)","source":"resume","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let commands = context.state.snapshot() + #expect( + !commands.contains { $0.hasPrefix("set_agent_pid claude_code ") }, + "A pre-prompt fork SessionStart must not register a PID found only through process snapshot binding, saw \(commands)" + ) + } + +} diff --git a/cmuxTests/ClaudeNoFlickerHookTransientTests.swift b/cmuxTests/ClaudeNoFlickerHookTransientTests.swift new file mode 100644 index 0000000000..2ddb58f515 --- /dev/null +++ b/cmuxTests/ClaudeNoFlickerHookTransientTests.swift @@ -0,0 +1,675 @@ +import Foundation +import Testing + +@Suite(.serialized) +struct ClaudeNoFlickerHookTransientTests { + private let support = ClaudeHookRoutingTestSupport() + + @Test + func claudeHookNoOpsWhenRecoverableTargetHasNoSocket() throws { + let context = try support.makeHookContext(name: "claude-missing-socket") + defer { context.cleanup() } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_SOCKET_PATH"] = context.socketPath + ".missing" + environment["CMUX_WORKSPACE_ID"] = "" + environment["CMUX_SURFACE_ID"] = "" + environment["CMUX_CLAUDE_PID"] = "6048" + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "session-start"], + environment: environment, + standardInput: "", + timeout: 5 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "{}\n") + } + + @Test + func claudePromptSubmitNoOpsWhenPIDOnlyRecoverySurfaceIsGone() throws { + let context = try support.makeHookContext(name: "claude-pid-recovery-miss") + defer { context.cleanup() } + + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "system.top": + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: true, + result: [ + "windows": [[ + "workspaces": [[ + "id": context.workspaceId, + "panes": [["surfaces": [["id": context.surfaceId, "top_level_pids": [6048]]]]], + ]], + ]], + ] + ) + case "surface.list": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["surfaces": []]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_WORKSPACE_ID"] = "" + environment["CMUX_SURFACE_ID"] = "" + environment["CMUX_CLAUDE_PID"] = "6048" + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"pid-recovery-miss","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "{}\n") + let commands = context.state.snapshot() + #expect(commands.contains { ClaudeHookRoutingTestSupport.jsonObject($0)?["method"] as? String == "system.top" }) + #expect(!commands.contains { $0.hasPrefix("set_status claude_code ") || $0.hasPrefix("set_agent_pid claude_code ") || $0.contains("\"method\":\"feed.push\"") }, "PID recovery miss must not fall back to the focused workspace, saw \(commands)") + } + + // Regression: a mapped Claude session whose stored workspace was closed must + // not bypass the recovered-target no-op and borrow the focused pane. The + // `mappedSession != nil` guard short-circuit let an ambient no-flicker + // prompt-submit (PID recovery missing) publish Running status and the PID + // gate onto whichever pane happened to be focused — the cross-pane routing + // class this PR fixes. https://github.com/manaflow-ai/cmux/issues/6048 + @Test + func claudePromptSubmitNoOpsWhenMappedSessionWorkspaceIsClosed() throws { + let context = try support.makeHookContext(name: "claude-mapped-workspace-closed") + defer { context.cleanup() } + + let sessionId = "mapped-closed-workspace-session" + let staleWorkspaceId = "99999999-9999-9999-9999-999999999999" + let staleSurfaceId = "88888888-8888-8888-8888-888888888888" + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 1, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": staleWorkspaceId, + "surfaceId": staleSurfaceId, + "cwd": context.root.path, + "agentLifecycle": "running", + "startedAt": now, + "updatedAt": now, + ], + ], + "activeSessionsByWorkspace": [ + staleWorkspaceId: [ + "sessionId": sessionId, + "updatedAt": now, + ], + ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted]) + .write( + to: context.root.appendingPathComponent("claude-hook-sessions.json"), + options: .atomic + ) + + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + let params = payload["params"] as? [String: Any] ?? [:] + if params["workspace_id"] as? String == staleWorkspaceId { + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: false, + error: ["code": "workspace_not_found", "message": "workspace not found"] + ) + } + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "system.top": + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: true, + result: [ + "windows": [[ + "workspaces": [[ + "id": context.workspaceId, + "panes": [["surfaces": [["id": context.surfaceId, "top_level_pids": []]]]], + ]], + ]], + ] + ) + case "workspace.current": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["workspace_id": context.workspaceId]) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_WORKSPACE_ID"] = "" + environment["CMUX_SURFACE_ID"] = "" + environment["CMUX_CLAUDE_PID"] = "6048" + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "{}\n", "Closed-workspace mapped session must no-op, saw stdout=\(result.stdout)") + let commands = context.state.snapshot() + #expect( + !commands.contains { + $0.hasPrefix("set_status claude_code ") + || $0.hasPrefix("set_agent_pid claude_code ") + }, + "A mapped session whose workspace was closed must not publish Claude status or the PID gate onto the focused pane, saw \(commands)" + ) + #expect( + !commands.contains { + $0.contains("--tab=\(context.workspaceId)") + && ($0.hasPrefix("set_status claude_code ") || $0.hasPrefix("set_agent_pid claude_code ")) + }, + "The focused workspace must not receive a stale mapped session's visible mutations, saw \(commands)" + ) + } + + // Regression: a mapped Claude session whose stored *surface* was closed + // (workspace still open) must also no-op rather than borrow the focused + // surface. With the stored PID matching the hook PID, process-snapshot + // recovery is skipped, so without a surface reachability check the resolver + // would publish status onto whichever surface is focused in the workspace. + // https://github.com/manaflow-ai/cmux/issues/6048 + @Test + func claudePromptSubmitNoOpsWhenMappedSessionSurfaceIsClosed() throws { + let context = try support.makeHookContext(name: "claude-mapped-surface-closed") + defer { context.cleanup() } + + let sessionId = "mapped-closed-surface-session" + let staleSurfaceId = "88888888-8888-8888-8888-888888888888" + let livePID = 6048 + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 1, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": context.workspaceId, + "surfaceId": staleSurfaceId, + "cwd": context.root.path, + "pid": livePID, + "agentLifecycle": "running", + "startedAt": now, + "updatedAt": now, + ], + ], + "activeSessionsByWorkspace": [ + context.workspaceId: [ + "sessionId": sessionId, + "updatedAt": now, + ], + ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted]) + .write( + to: context.root.appendingPathComponent("claude-hook-sessions.json"), + options: .atomic + ) + + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + // Workspace is open, but the stored surface is no longer listed. + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "system.top": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["windows": []]) + case "workspace.current": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["workspace_id": context.workspaceId]) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_WORKSPACE_ID"] = "" + environment["CMUX_SURFACE_ID"] = "" + environment["CMUX_CLAUDE_PID"] = "\(livePID)" + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "{}\n", "Closed-surface mapped session must no-op, saw stdout=\(result.stdout)") + let commands = context.state.snapshot() + #expect( + !commands.contains { + $0.hasPrefix("set_status claude_code ") + || $0.hasPrefix("set_agent_pid claude_code ") + }, + "A mapped session whose stored surface was closed must not publish status onto the focused surface, saw \(commands)" + ) + } + + @Test + func claudeSessionEndUsesStoredTargetWhenPIDRecoveryMisses() throws { + let context = try support.makeHookContext(name: "claude-session-end-stored-target") + defer { context.cleanup() } + + let sessionId = "stored-session-end-target" + try support.seedClaudeForkHookStore( + context: context, + parentSessionId: sessionId, + parentSurfaceId: context.surfaceId, + activeSessionId: sessionId + ) + + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "surface.resume.clear": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_WORKSPACE_ID"] = "" + environment["CMUX_SURFACE_ID"] = "" + environment["CMUX_CLAUDE_PID"] = "6048" + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "session-end"], + environment: environment, + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"SessionEnd"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "OK\n") + let commands = context.state.snapshot() + #expect(commands.contains { $0.hasPrefix("clear_agent_pid claude_code --tab=\(context.workspaceId)") && $0.contains("--panel=\(context.surfaceId)") }, "SessionEnd must clean the stored target, saw \(commands)") + } + + @Test + func claudePromptSubmitSkipsProcessSnapshotWhenStoredPIDMatchesHookPID() throws { + let context = try support.makeHookContext(name: "claude-stored-pid-hot-path") + defer { context.cleanup() } + + let sessionId = "stored-pid-hot-path" + let livePID = 6048 + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 1, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": context.workspaceId, + "surfaceId": context.surfaceId, + "cwd": context.root.path, + "pid": livePID, + "agentLifecycle": "idle", + "startedAt": now, + "updatedAt": now, + ], + ], + "activeSessionsByWorkspace": [context.workspaceId: ["sessionId": sessionId, "updatedAt": now]], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted]) + .write(to: context.root.appendingPathComponent("claude-hook-sessions.json"), options: .atomic) + + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "system.top": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["windows": []]) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_CLAUDE_PID"] = "\(livePID)" + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let commands = context.state.snapshot() + #expect(!commands.contains { ClaudeHookRoutingTestSupport.jsonObject($0)?["method"] as? String == "system.top" }, "Current stored PID target must not trigger process snapshot recovery, saw \(commands)") + #expect(commands.contains { $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") && $0.contains("--panel=\(context.surfaceId)") }, "Expected stored target to receive Running status, saw \(commands)") + } + + @Test + func claudePromptSubmitUsesStoredTargetWhenEnvMissingAndPIDMatches() throws { + let context = try support.makeHookContext(name: "claude-stored-pid-no-env") + defer { context.cleanup() } + + let sessionId = "stored-pid-no-env" + let livePID = 6048 + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 1, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": context.workspaceId, + "surfaceId": context.surfaceId, + "cwd": context.root.path, + "pid": livePID, + "agentLifecycle": "idle", + "startedAt": now, + "updatedAt": now, + ], + ], + "activeSessionsByWorkspace": [context.workspaceId: ["sessionId": sessionId, "updatedAt": now]], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted]) + .write(to: context.root.appendingPathComponent("claude-hook-sessions.json"), options: .atomic) + + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_WORKSPACE_ID"] = "" + environment["CMUX_SURFACE_ID"] = "" + environment["CMUX_CLAUDE_PID"] = "\(livePID)" + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "OK\n") + let commands = context.state.snapshot() + #expect(!commands.contains { ClaudeHookRoutingTestSupport.jsonObject($0)?["method"] as? String == "system.top" }, "Current stored PID target must not trigger process snapshot recovery, saw \(commands)") + #expect(commands.contains { $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") && $0.contains("--panel=\(context.surfaceId)") }, "Expected stored target to be used without terminal recovery, saw \(commands)") + } + + @Test + func claudeSessionStartDoesNotPersistPIDOnFallbackSurface() throws { + let context = try support.makeHookContext(name: "claude-fallback-no-pid-gate") + defer { context.cleanup() } + + let livePID = 6048 + let sessionId = "fallback-no-pid-gate-session" + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "system.top": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["windows": []]) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_SURFACE_ID"] = "" + environment["CMUX_CLAUDE_PID"] = "\(livePID)" + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "session-start"], + environment: environment, + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let commands = context.state.snapshot() + #expect(!commands.contains { $0.hasPrefix("set_agent_pid claude_code ") || $0.contains("--pid=\(livePID)") }, "Fallback surface must not receive durable Claude PID metadata, saw \(commands)") + let persistedData = try Data(contentsOf: context.root.appendingPathComponent("claude-hook-sessions.json")) + let persisted = try #require(JSONSerialization.jsonObject(with: persistedData) as? [String: Any]) + let sessions = try #require(persisted["sessions"] as? [String: Any]) + let session = try #require(sessions[sessionId] as? [String: Any]) + #expect(session["pid"] == nil, "Fallback surface must not persist Claude PID metadata, saw \(session)") + } + + @Test + func claudePromptSubmitDoesNotRepublishStoredPIDWithoutLiveHookPID() throws { + let context = try support.makeHookContext(name: "claude-stored-pid-no-live-pid") + defer { context.cleanup() } + + let sessionId = "stored-pid-no-live-pid" + let storedPID = 6048 + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 1, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": context.workspaceId, + "surfaceId": context.surfaceId, + "cwd": context.root.path, + "pid": storedPID, + "agentLifecycle": "idle", + "startedAt": now, + "updatedAt": now, + ], + ], + "activeSessionsByWorkspace": [context.workspaceId: ["sessionId": sessionId, "updatedAt": now]], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted]) + .write(to: context.root.appendingPathComponent("claude-hook-sessions.json"), options: .atomic) + + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { return "OK" } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + let environment = support.baseHookEnvironment(context: context) + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut && result.status == 0, Comment(rawValue: result.stderr)) + let commands = context.state.snapshot() + #expect( + !commands.contains { ClaudeHookRoutingTestSupport.jsonObject($0)?["method"] as? String == "system.top" }, + "Stored target without live hook PID must not trigger process snapshot recovery, saw \(commands)" + ) + let expectedStatusPrefix = "set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)" + let expectedPanel = "--panel=\(context.surfaceId)" + let storedPIDWasRepublished = commands.contains { + $0.hasPrefix("set_agent_pid claude_code ") || $0.contains("--pid=\(storedPID)") + } + #expect(commands.contains { $0.hasPrefix(expectedStatusPrefix) && $0.contains(expectedPanel) }, "Expected stored target to receive Running status, saw \(commands)") + #expect(!storedPIDWasRepublished, "Stored PID must not be re-published without a live hook PID, saw \(commands)") + } + + @Test + func claudePromptSubmitKeepsStoredTargetOnTransientWorkspaceProbeFailure() throws { + let context = try support.makeHookContext(name: "claude-transient-stored-workspace") + defer { context.cleanup() } + + let sessionId = "stored-transient-workspace-session" + let staleWorkspaceId = "55555555-5555-5555-5555-555555555555" + let staleSurfaceId = "66666666-6666-6666-6666-666666666666" + try support.seedClaudeForkHookStore( + context: context, + parentSessionId: sessionId, + parentSurfaceId: context.surfaceId, + activeSessionId: sessionId + ) + + let server = support.startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = ClaudeHookRoutingTestSupport.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return ClaudeHookRoutingTestSupport.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + let params = payload["params"] as? [String: Any] ?? [:] + if params["workspace_id"] as? String == context.workspaceId { + return ClaudeHookRoutingTestSupport.v2Response( + id: id, + ok: false, + error: ["code": "socket_timeout", "message": "temporary surface list timeout"] + ) + } + return ClaudeHookRoutingTestSupport.surfaceListResponse(id: id, surfaceId: staleSurfaceId) + case "feed.push": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return ClaudeHookRoutingTestSupport.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = support.baseHookEnvironment(context: context) + environment["CMUX_WORKSPACE_ID"] = staleWorkspaceId + environment["CMUX_SURFACE_ID"] = staleSurfaceId + + let result = support.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"run"}"#, + timeout: 5 + ) + + #expect(server.wait(timeout: .now() + 5) == .success, "mock server did not finish") + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let commands = context.state.snapshot() + #expect( + commands.contains { + $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Expected transient probe failure to keep the stored Claude target, saw \(commands)" + ) + #expect( + !commands.contains { + ($0.hasPrefix("set_status claude_code Running ") || $0.hasPrefix("set_agent_pid claude_code ")) + && ($0.contains("--tab=\(staleWorkspaceId)") || $0.contains("--panel=\(staleSurfaceId)")) + }, + "Transient probe failure must not fall through to stale ambient state, saw \(commands)" + ) + } +}