diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv new file mode 100644 index 000000000000..0ee258a6541c --- /dev/null +++ b/.github/swift-file-length-budget.tsv @@ -0,0 +1,262 @@ +# cmux-owned Swift file length budget. +# Format: max_linesrelative path +# Reduce counts as files shrink. CI fails if tracked files exceed this budget. +35874 CLI/cmux.swift +18229 Sources/AppDelegate.swift +16443 Sources/ContentView.swift +15163 Sources/TerminalController.swift +13232 Sources/Workspace.swift +12511 Sources/GhosttyTerminalView.swift +12348 cmuxTests/AppDelegateShortcutRoutingTests.swift +11669 Sources/Panels/BrowserPanel.swift +9579 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +8032 Sources/Panels/BrowserPanelView.swift +8016 CLI/cmux_open.swift +7743 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +7662 cmuxTests/WorkspaceRemoteConnectionTests.swift +7490 cmuxTests/WorkspaceUnitTests.swift +6359 cmuxTests/SessionPersistenceTests.swift +6255 cmuxTests/GhosttyConfigTests.swift +6223 Sources/TabManager.swift +5915 cmuxTests/TerminalAndGhosttyTests.swift +5809 Sources/TextBoxInput.swift +5573 cmuxTests/BrowserConfigTests.swift +4759 Sources/cmuxApp.swift +4482 Sources/Panels/FilePreviewPanel.swift +4367 cmuxTests/BrowserPanelTests.swift +4121 Sources/BrowserWindowPortal.swift +4007 cmuxTests/TabManagerUnitTests.swift +3964 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +3953 cmuxTests/WindowAndDragTests.swift +3934 Sources/Feed/FeedPanelView.swift +3673 cmuxTests/TabManagerSessionSnapshotTests.swift +3668 cmuxTests/CLIGenericHookPersistenceTests.swift +3323 Sources/CmuxConfig.swift +3124 Sources/Update/UpdateTitlebarAccessory.swift +2876 cmuxTests/CMUXOpenCommandTests.swift +2875 Sources/SessionIndexView.swift +2611 Sources/KeyboardShortcutSettings.swift +2563 Sources/Panels/CmuxWebView.swift +2546 cmuxTests/WorkspaceManualUnreadTests.swift +2524 cmuxTests/CommandPaletteSearchEngineTests.swift +2404 Sources/Mobile/MobileHostService.swift +2328 cmuxTests/CJKIMEInputTests.swift +2242 Sources/TerminalNotificationStore.swift +2229 Sources/TerminalWindowPortal.swift +2225 Sources/RestorableAgentSession.swift +2133 cmuxTests/ShortcutAndCommandPaletteTests.swift +2126 cmuxTests/CmuxConfigTests.swift +2079 Sources/SessionPersistence.swift +2011 Sources/KeyboardShortcutSettingsFileStore.swift +1900 cmuxTests/NotificationAndMenuBarTests.swift +1866 Sources/Panels/BrowserWebAuthnSupport.swift +1847 cmuxTests/TerminalControllerSocketSecurityTests.swift +1810 Sources/SessionIndexStore.swift +1760 Sources/WindowDragHandleView.swift +1732 cmuxTests/WorkspacePullRequestSidebarTests.swift +1687 cmuxTests/MarkdownPanelTests.swift +1680 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +1656 Sources/FileExplorerView.swift +1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +1604 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +1560 cmuxTests/TextBoxMentionCompletionTests.swift +1523 cmuxTests/RestorableAgentSessionIndexTests.swift +1500 cmuxUITests/MultiWindowNotificationsUITests.swift +1499 cmuxTests/OmnibarAndToolsTests.swift +1433 Sources/RemoteTmuxControlConnection.swift +1428 cmuxTests/AgentSessionAutoResumeSwiftTests.swift +1420 cmuxTests/AppDelegateIssue2907RoutingTests.swift +1384 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift +1363 Sources/CMUXInstalledExtensionSidebarHostView.swift +1360 Sources/Feed/FeedButtonStyleDebugWindowController.swift +1317 Sources/FileExplorerStore.swift +1295 cmuxTests/MobileHostAuthorizationTests.swift +1291 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift +1290 cmuxTests/TextBoxSubmitActionTests.swift +1285 cmuxUITests/SidebarHelpMenuUITests.swift +1258 Sources/Feed/FeedCoordinator.swift +1240 cmuxTests/SidebarOrderingTests.swift +1209 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift +1205 Sources/RemoteTmuxController.swift +1204 cmuxTests/FileExplorerStoreTests.swift +1197 cmuxTests/CodexAppServerSessionTests.swift +1197 cmuxTests/VMDefaultCloudCommandTests.swift +1166 Sources/VaultAgentProcessScanner.swift +1147 cmuxTests/PiVaultAgentPersistenceTests.swift +1121 cmuxTests/AgentHibernationTests.swift +1110 Sources/AppDelegate+CmuxSSHURL.swift +1093 cmuxUITests/BonsplitTabDragUITests.swift +1087 Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift +1050 cmuxTests/WorkspaceGroupTests.swift +1033 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift +1021 cmuxUITests/TerminalCmdClickUITests.swift +1009 cmuxTests/CmuxTopSnapshotScopeTests.swift +1006 cmuxTests/CmuxSSHURLRequestTests.swift +1002 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTests.swift +1000 cmuxTests/DockSocketLifecycleTests.swift +982 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift +974 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift +951 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift +951 Sources/App/TerminalDirectoryOpenSupport.swift +948 Sources/App/ShortcutRoutingSupport.swift +947 Sources/TerminalNotificationPolicy.swift +945 Sources/SessionIndexRegisteredAgents.swift +944 Sources/CommandPalette/CommandPaletteSettingsToggle.swift +942 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceMacSelectionTests.swift +937 Sources/TextBoxMentionIndexStore.swift +932 Sources/Cloud/VMClient.swift +929 Sources/Panels/TerminalPanel.swift +918 Sources/Panels/BrowserPopupWindowController.swift +905 Sources/CmuxSSHURLRequest.swift +899 Sources/Panels/MarkdownWebRenderer.swift +885 cmuxTests/SidebarWorkspaceDropPlannerTests.swift +882 Sources/DockSplitStore.swift +871 cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift +868 Sources/Panels/BrowserScreenshotSnapshotter.swift +864 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift +847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift +845 cmuxTests/SSHStartupSignalLifecycleTests.swift +841 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift +834 Sources/MainWindowFocusController.swift +830 Sources/TaskManagerTypes.swift +828 Sources/PricingPlansScreen.swift +825 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift +822 Sources/WorkspaceContentView.swift +810 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift +803 Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift +802 Sources/TerminalController+ControlPaneContext.swift +799 Sources/ClosedItemHistory.swift +779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift +773 Sources/App/MenuBarExtraController.swift +769 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift +768 cmuxUITests/BrowserFixtureInteractionUITests.swift +762 Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift +757 Sources/TerminalController+ControlWorkspaceContext.swift +756 Sources/Panels/AgentSessionWebRendererCoordinator.swift +754 cmuxTests/GhosttyTerminalStartupEnvironmentTests.swift +753 Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +752 cmuxUITests/CloseWorkspaceCmdDUITests.swift +749 cmuxTests/UpdatePillReleaseVisibilityTests.swift +738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift +717 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift +716 Sources/TaskManagerSnapshot.swift +714 Sources/AppleScriptSupport.swift +710 Sources/TerminalSSHSessionDetector.swift +709 Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver.swift +706 CLI/CMUXCLI+Config.swift +700 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +699 cmuxTests/TerminalNotificationClearAllTests.swift +698 cmuxTests/RestorableAgentHookProviderResumeTests.swift +696 cmuxTests/KeyboardShortcutContextTests.swift +691 Sources/NotificationSoundSettings.swift +691 cmuxTests/TaskManagerResourcesTests.swift +690 cmuxTests/SessionIndexViewTests.swift +683 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftViewInterpreter.swift +683 Sources/Panels/CodexAppServerSession.swift +681 Sources/Panels/AgentSessionProcessStore.swift +680 Sources/FileExplorerSearchController.swift +677 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Bootstrap.swift +676 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift +672 cmuxTests/SessionPersistenceResumeBindingTests.swift +668 cmuxTests/FeedCoordinatorTests.swift +668 cmuxTests/SettingsWindowPresenterTests.swift +667 Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Tunnel/RemoteDaemonProxyTunnel.swift +665 cmuxTests/CLICodexHookTimeoutRegressionTests.swift +664 Sources/CmuxTopSnapshot.swift +663 Sources/PortScanner.swift +661 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift +660 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift +655 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift +650 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift +650 Sources/Panels/MarkdownRemoteImageLoader.swift +648 cmuxTests/TerminalNotificationQueueTests.swift +646 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift +642 cmuxTests/RemoteTmuxControlParserTests.swift +641 cmuxTests/CommandPaletteNucleoFFITests.swift +637 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +635 cmuxUITests/RightSidebarChromeHeightUITests.swift +630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift +623 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift +620 Sources/Panels/BrowserNavigationDelegate.swift +620 cmuxTests/FinderFileDropRegressionTests.swift +618 Sources/SettingsNavigation.swift +617 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift +608 cmuxUITests/FeedSidebarUITests.swift +607 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +607 Sources/SessionIndexModels.swift +607 Sources/SleepyFaceView.swift +604 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift +602 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift +601 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift +598 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +598 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift +596 cmuxTests/CmuxEventBusTests.swift +594 cmuxTests/PortalTabDragRoutingTests.swift +592 Sources/TextBoxSubmitActions.swift +591 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelLifecycleTests.swift +591 cmuxTests/CmuxConfigContextMenuTests.swift +588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift +586 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift +586 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift +586 Sources/JSONCParser.swift +580 Packages/macOS/CmuxExtensionKit/Tests/CmuxExtensionKitTests/CmuxExtensionKitTests.swift +580 Sources/Update/TitlebarCloudVMButton.swift +580 cmuxTests/CLIHookNoResponseTests.swift +578 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift +577 cmuxTests/AppearanceSettingsTests.swift +576 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift +572 Sources/Feed/FeedTextEditorDebugWindowController.swift +567 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift +567 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift +562 cmuxTests/AgentExecutableResolverTests.swift +561 cmuxTests/GhosttyConfigPathResolverTests.swift +560 CLI/CMUXCLI+AgentHookDefinitions.swift +560 cmuxTests/CLISSHPTYResizeInputTests.swift +558 Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Config.swift +553 Sources/RightSidebarPanelView.swift +550 Sources/CloudVMActionLauncher.swift +549 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarMetadataV1.swift +549 Sources/Panels/BrowserAutomation.swift +547 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift +546 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift +544 cmuxUITests/DisplayResolutionRegressionUITests.swift +540 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift +539 CLI/CMUXCLI+Themes.swift +539 CLI/CodexTeamsApprovalBridge.swift +538 Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridTests.swift +538 Sources/TerminalController+ControlSurfaceContext2.swift +535 Sources/App/WorkspaceRuntimeSettings.swift +533 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift +530 Sources/BrowserPaneDropTargetView.swift +528 cmuxUITests/AutomationSocketUITests.swift +527 CLI/CLISocketPathResolver.swift +527 cmuxTests/BrowserHTTPBasicAuthPromptTests.swift +526 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift +526 Sources/TerminalPaneDropTargetView.swift +525 Packages/macOS/CmuxSettings/Sources/CmuxSettings/SocketControl/SocketControlSettings.swift +524 CLI/CMUXCLI+AutoNaming.swift +520 CLI/CMUXCLI+AmpExtension.swift +520 cmuxTests/MainWindowVisibilityControllerTests.swift +519 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-two-column-cockpit-sidebar.swift +518 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +518 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-git-review-queue-command-deck.swift +516 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +516 Sources/TerminalImageTransfer.swift +514 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift +514 cmuxUITests/UpdatePillUITests.swift +511 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarReportsV1.swift +509 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift +508 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift +507 Sources/TerminalControllerTopSupport.swift +506 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift +506 Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/PTYBridge/RemotePTYBridgeSession.swift +506 Sources/App/MainWindowVisibilityController.swift +504 Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreTests.swift +504 cmuxTests/TerminalNotificationSocketActionTests.swift +503 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +503 Sources/Settings/ConfigSource.swift +503 Sources/TerminalNotificationQueue.swift +502 Sources/CmuxEventPublishing.swift +500 Sources/KeyboardShortcutRecorder.swift diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 48b23d5dec1b..426cc042ecaa 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -142,6 +142,7 @@ extension CMUXCLI { "session-start": .sessionStart, "prompt-submit": .promptSubmit, "stop": .stop, + "stop-failure": .stop, "notification": .notification, "notify": .notification, "agent-response": .stop, diff --git a/CLI/FeedEventClassifier.swift b/CLI/FeedEventClassifier.swift index 12b68a19b18e..906a29de5b88 100644 --- a/CLI/FeedEventClassifier.swift +++ b/CLI/FeedEventClassifier.swift @@ -179,6 +179,7 @@ struct FeedEventClassifier { "SessionStart": .sessionStart, "SessionEnd": .sessionEnd, "Stop": .response, + "StopFailure": .response, "SubagentStart": .subagentStart, "SubagentStop": .subagentResponse, "Notification": .statusNotification, diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 7a7292f252e4..45ac539dd710 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -59,8 +59,10 @@ struct ClaudeHookParsedInput { enum AgentHookRuntimeStatus: String, Codable { case running + case waiting case idle case needsInput + case completed case error } @@ -857,6 +859,68 @@ final class ClaudeHookSessionStore { } } + @discardableResult + func recordCodexMonitorIdleIfCurrent( + sessionId: String, + workspaceId: String, + surfaceId: String, + cwd: String?, + turnId: String? + ) throws -> Bool { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return false } + return try withLockedState { state in + let now = Date().timeIntervalSince1970 + var record = makeSessionRecord( + state: state, + sessionId: normalized, + workspaceId: workspaceId, + surfaceId: surfaceId, + now: now + ) + let turnStack = activePromptTurnStack(from: record) + let activeDepth = max(max(0, record.activePromptDepth ?? 0), turnStack.count) + if let normalizedTurnId = normalizeOptional(turnId) { + if let activeTurnId = turnStack.last, activeTurnId != normalizedTurnId { + return false + } + if turnStack.isEmpty, activeDepth > 0 { + return false + } + if turnStack.last == normalizedTurnId { + var updatedStack = turnStack + updatedStack.removeLast() + setActivePromptTurnStack(updatedStack, totalDepth: max(0, activeDepth - 1), on: &record) + markPromptTurnTerminal(normalizedTurnId, on: &record) + } else if !terminalPromptTurnSet(from: record).contains(normalizedTurnId) { + markPromptTurnTerminal(normalizedTurnId, on: &record) + } + } else if activeDepth > 0 { + return false + } + update( + &record, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + transcriptPath: nil, + pid: nil, + launchCommand: nil, + isRestorable: nil, + agentLifecycle: .idle, + lastSubtitle: nil, + lastBody: nil, + lastNotificationStatus: .idle, + updateLastNotificationStatus: true, + runtimeStatus: .idle, + updateRuntimeStatus: true, + now: now + ) + state.sessions[normalized] = record + return true + } + } + func codexSessionStartIsStale( sessionId: String, incomingPID: Int?, @@ -1228,7 +1292,7 @@ final class ClaudeHookSessionStore { func hasRunningSession( workspaceId: String, - surfaceId: String?, + surfaceId: String? = nil, excludingSessionId: String?, onlyNewerThanExcludedSession: Bool = false, requireLiveProcess: Bool = false @@ -1245,9 +1309,11 @@ final class ClaudeHookSessionStore { for sessionId in Array(state.sessions.keys) { guard var record = state.sessions[sessionId] else { continue } + let isActiveStatus = record.runtimeStatus == .running + || (normalizedSurface != nil && record.runtimeStatus == .needsInput) guard normalizeOptional(record.workspaceId) == normalizedWorkspace, record.sessionId != excluded, - record.runtimeStatus == .running else { + isActiveStatus else { continue } if let normalizedSurface, normalizeOptional(record.surfaceId) != normalizedSurface { @@ -24113,7 +24179,7 @@ struct CMUXCLI { } printClaudeHookAck() - case "stop", "idle": + case "stop", "idle", "stop-failure": telemetry.breadcrumb("claude-hook.stop") do { // Turn ended. Don't consume session or clear PID — Claude is still alive. @@ -24164,10 +24230,16 @@ struct CMUXCLI { let hasPendingBackgroundWork = hasActiveClaudeBackgroundWork(parsedInput) // Update session with transcript summary and send completion notification. - let completion = summarizeClaudeHookStop( + // StopFailure is a terminal turn boundary, but it is not a completed response. + let isStopFailure = subcommand == "stop-failure" + let completion = isStopFailure ? nil : summarizeClaudeHookStop( parsedInput: parsedInput, sessionRecord: mappedSession ) + let lifecycleAfterTurn: AgentHibernationLifecycleState = { + if hasPendingBackgroundWork { return .running } + return isStopFailure ? .idle : .completed + }() if let sessionId = parsedInput.sessionId { try? sessionStore.upsert( sessionId: sessionId, @@ -24179,7 +24251,7 @@ struct CMUXCLI { // Pending background work keeps the pane out of the // hibernatable .idle state so the planner cannot SIGTERM // a live task (mirrors the antigravity fullyIdle flip). - agentLifecycle: hasPendingBackgroundWork ? .running : .idle, + agentLifecycle: lifecycleAfterTurn, lastSubtitle: completion?.subtitle, lastBody: completion?.body, hadPendingBackgroundWorkAtStop: hasPendingBackgroundWork, @@ -24203,7 +24275,7 @@ struct CMUXCLI { setAgentLifecycle( client: client, key: Self.claudeCodeStatusKey, - lifecycle: hasPendingBackgroundWork ? .running : .idle, + lifecycle: lifecycleAfterTurn, workspaceId: workspaceId, surfaceId: surfaceId ) @@ -24220,7 +24292,7 @@ struct CMUXCLI { icon: "bolt.fill", color: "#4C8DFF" ) - } else { + } else if isStopFailure { try? setClaudeStatus( client: client, workspaceId: workspaceId, @@ -24229,6 +24301,15 @@ struct CMUXCLI { icon: "pause.circle.fill", color: "#8E8E93" ) + } else { + try? setClaudeStatus( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + value: String(localized: "agent.generic.status.completed", defaultValue: "Completed"), + icon: "checkmark.circle.fill", + color: "#34C759" + ) } if let completion { let title = String( @@ -24524,7 +24605,7 @@ struct CMUXCLI { surfaceId: surfaceId, cwd: parsedInput.cwd, transcriptPath: parsedInput.transcriptPath, - agentLifecycle: .needsInput, + agentLifecycle: .waiting, lastSubtitle: summary.subtitle, lastBody: summary.body ) @@ -24534,7 +24615,7 @@ struct CMUXCLI { setAgentLifecycle( client: client, key: Self.claudeCodeStatusKey, - lifecycle: .needsInput, + lifecycle: .waiting, workspaceId: workspaceId, surfaceId: surfaceId ) @@ -24542,9 +24623,9 @@ struct CMUXCLI { client: client, workspaceId: workspaceId, surfaceId: surfaceId, - value: "Needs input", + value: String(localized: "agent.generic.notification.subtitle.waiting", defaultValue: "Waiting"), icon: "bell.fill", - color: "#4C8DFF", pid: claudePid + color: "#FF9500", pid: claudePid ) } _ = try sendV1Command("notify_target_async \(workspaceId) \(surfaceId) \(payload)", client: client) @@ -24793,14 +24874,14 @@ struct CMUXCLI { surfaceId: existingSurfaceId, cwd: parsedInput.cwd, transcriptPath: parsedInput.transcriptPath, - agentLifecycle: .needsInput, + agentLifecycle: .waiting, lastSubtitle: waitingSubtitle, lastBody: needsInputBody ) setAgentLifecycle( client: client, key: Self.claudeCodeStatusKey, - lifecycle: .needsInput, + lifecycle: .waiting, workspaceId: workspaceId, surfaceId: existingSurfaceId ) @@ -24824,9 +24905,9 @@ struct CMUXCLI { client: client, workspaceId: workspaceId, surfaceId: existingSurfaceId, - value: String(localized: "feed.status.needsInput", defaultValue: "Needs input"), + value: String(localized: "agent.generic.notification.subtitle.waiting", defaultValue: "Waiting"), icon: "bell.fill", - color: "#4C8DFF", + color: "#FF9500", pid: claudePid ) let title = String( @@ -24891,9 +24972,9 @@ struct CMUXCLI { case "help", "--help", "-h": telemetry.breadcrumb("claude-hook.help") print( - """ - cmux claude-hook [--workspace ] [--surface ] - """ + String(localized: "cli.claude-hook.help", defaultValue: """ + cmux claude-hook [--workspace ] [--surface ] + """) ) default: @@ -25795,6 +25876,14 @@ struct CMUXCLI { var sawAssistantMessage = false var sawTerminalTurn = false var sawRelevantTurn = turnId == nil + func beginRelevantTurn() { + sawRelevantTurn = true + candidate = nil + candidateCanPublishBeforeTerminal = false + sawAssistantMessage = false + sawTerminalTurn = false + } + for line in lines { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, @@ -25823,9 +25912,7 @@ struct CMUXCLI { continue } } - sawRelevantTurn = true - candidate = nil - candidateCanPublishBeforeTerminal = false + beginRelevantTurn() case "error": let payloadTurnId = firstString(in: payload, keys: ["turn_id", "turnId"]) if let turnId, let payloadTurnId { @@ -26031,6 +26118,9 @@ struct CMUXCLI { } else { sawRelevantTurn = true } + if sawRelevantTurn { + candidate = nil + } case "request_user_input": if let userInput = codexUserInputEventCandidate( @@ -26708,6 +26798,7 @@ struct CMUXCLI { let turnId = optionValue(commandArgs, name: "--turn") var transcriptPath = optionValue(commandArgs, name: "--transcript") let leasePath = optionValue(commandArgs, name: "--lease") + let cwd = optionValue(commandArgs, name: "--cwd") guard !workspaceId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !sessionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -26740,11 +26831,17 @@ struct CMUXCLI { turnId: turnId, excluding: publishedUserInputCallIds ) { + if isCodexMonitorLeaseRetired(path: leasePath) { + return + } publishedUserInputCallIds.insert(userInput.callId) publishCodexMonitorUserInput( userInput, + sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, + cwd: cwd, + env: env, client: client ) } @@ -26755,14 +26852,32 @@ struct CMUXCLI { requireTerminalCompletion: true ) { case .failure(let failure): + if isCodexMonitorLeaseRetired(path: leasePath) { + return + } publishCodexMonitorFailure( failure, + sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, + cwd: cwd, + env: env, client: client ) return case .healthy: + if isCodexMonitorLeaseRetired(path: leasePath) { + return + } + publishCodexMonitorIdle( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + turnId: turnId, + cwd: cwd, + env: env, + client: client + ) return case .pending: break @@ -26784,10 +26899,152 @@ struct CMUXCLI { } } + private func agentHookRuntimeStatus(for notificationStatus: AgentHookNotificationStatus?) -> AgentHookRuntimeStatus? { + switch notificationStatus { + case .idle?: + return .idle + case .needsInput?: + return .needsInput + case .error?: + return .error + case nil: + return nil + } + } + + private func codexHookSessionStore(env: [String: String]) -> ClaudeHookSessionStore { + ClaudeHookSessionStore( + processEnv: env.merging( + ["CMUX_CLAUDE_HOOK_STATE_PATH": agentHookStatePath(sessionStoreSuffix: "codex", env: env)], + uniquingKeysWith: { _, new in new } + ) + ) + } + + private func recordCodexMonitorRuntimeStatus( + sessionId: String, + workspaceId: String, + surfaceId: String?, + cwd: String?, + env: [String: String], + notificationStatus: AgentHookNotificationStatus, + subtitle: String? = nil, + body: String? = nil + ) { + let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedWorkspaceId = workspaceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedSessionId.isEmpty, !normalizedWorkspaceId.isEmpty else { + return + } + let normalizedSurfaceId = surfaceId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + try? codexHookSessionStore(env: env).upsert( + sessionId: normalizedSessionId, + workspaceId: normalizedWorkspaceId, + surfaceId: normalizedSurfaceId, + cwd: cwd, + lastSubtitle: subtitle, + lastBody: body, + lastNotificationStatus: notificationStatus, + updateLastNotificationStatus: true, + runtimeStatus: agentHookRuntimeStatus(for: notificationStatus), + updateRuntimeStatus: true + ) + } + + private func hasOtherRunningCodexMonitorSession( + sessionId: String, + workspaceId: String, + surfaceId: String?, + env: [String: String], + includeWorkspaceRunningSessions: Bool = true + ) -> Bool { + let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedWorkspaceId = workspaceId.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedSurfaceId = surfaceId?.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedWorkspaceId.isEmpty else { + return false + } + if includeWorkspaceRunningSessions, + (try? codexHookSessionStore(env: env).hasRunningSession( + workspaceId: normalizedWorkspaceId, + excludingSessionId: normalizedSessionId, + requireLiveProcess: true + )) == true { + return true + } + guard normalizedSurfaceId != nil else { + return false + } + return (try? codexHookSessionStore(env: env).hasRunningSession( + workspaceId: normalizedWorkspaceId, + surfaceId: normalizedSurfaceId, + excludingSessionId: normalizedSessionId, + requireLiveProcess: true + )) == true + } + + private func publishCodexMonitorIdle( + sessionId: String, + workspaceId: String, + surfaceId: String?, + turnId: String?, + cwd: String?, + env: [String: String], + client: SocketClient + ) { + let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedWorkspaceId = workspaceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedSessionId.isEmpty, !normalizedWorkspaceId.isEmpty else { + return + } + let normalizedSurfaceId = surfaceId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard ((try? codexHookSessionStore(env: env).recordCodexMonitorIdleIfCurrent( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: normalizedSurfaceId, + cwd: cwd, + turnId: turnId + )) == true) else { + return + } + guard !hasOtherRunningCodexMonitorSession( + sessionId: normalizedSessionId, + workspaceId: normalizedWorkspaceId, + surfaceId: surfaceId, + env: env, + includeWorkspaceRunningSessions: false + ) else { + return + } + setAgentLifecycle( + client: client, + key: "codex", + lifecycle: .idle, + workspaceId: workspaceId, + surfaceId: surfaceId + ) + guard !hasOtherRunningCodexMonitorSession( + sessionId: normalizedSessionId, + workspaceId: normalizedWorkspaceId, + surfaceId: surfaceId, + env: env + ) else { + return + } + let idleStatus = String(localized: "agent.generic.notification.status.idle", defaultValue: "Idle") + _ = try? sendV1Command( + "set_status codex \(idleStatus) --icon=pause.circle.fill --color=#8E8E93 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + client: client + ) + } + private func publishCodexMonitorUserInput( _ userInput: CodexHookUserInputCandidate, + sessionId: String, workspaceId: String, surfaceId: String?, + cwd: String?, + env: [String: String], client: SocketClient ) { let subtitle = String(localized: "agent.codex.input.subtitle.waiting", defaultValue: "Waiting") @@ -26795,6 +27052,16 @@ struct CMUXCLI { localized: "agent.codex.input.body.needsInput", defaultValue: "Codex is asking a question" ) + recordCodexMonitorRuntimeStatus( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + env: env, + notificationStatus: .needsInput, + subtitle: subtitle, + body: body + ) if let surfaceId, !surfaceId.isEmpty { let payload = "Codex|\(sanitizeNotificationField(subtitle))|\(sanitizeNotificationField(body))" _ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) @@ -26808,11 +27075,24 @@ struct CMUXCLI { private func publishCodexMonitorFailure( _ failure: CodexHookFailureCandidate, + sessionId: String, workspaceId: String, surfaceId: String?, + cwd: String?, + env: [String: String], client: SocketClient ) { let summary = summarizeCodexHookFailureCandidate(failure) + recordCodexMonitorRuntimeStatus( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + env: env, + notificationStatus: .error, + subtitle: summary.subtitle, + body: summary.body + ) if let surfaceId, !surfaceId.isEmpty { let payload = "Codex|\(sanitizeNotificationField(summary.subtitle))|\(sanitizeNotificationField(summary.body))" _ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) @@ -30311,7 +30591,7 @@ export default CMUXSessionRestore; func runtimeStatus(for notificationStatus: AgentHookNotificationStatus?) -> AgentHookRuntimeStatus? { switch notificationStatus { case .idle?: - return .idle + return .completed case .needsInput?: return .needsInput case .error?: @@ -30323,9 +30603,11 @@ export default CMUXSessionRestore; func agentLifecycle(for notificationStatus: AgentHookNotificationStatus?) -> AgentHibernationLifecycleState? { switch notificationStatus { case .idle?: - return .idle - case .needsInput?, .error?: - return .needsInput + return .completed + case .needsInput?: + return .waiting + case .error?: + return .failed case nil: return nil } @@ -30339,16 +30621,53 @@ export default CMUXSessionRestore; requireLiveProcess: true )) == true } - func hasOtherRunningSession(workspaceId: String) -> Bool { - (try? store.hasRunningSession( + func hasOtherRunningSession(workspaceId: String, surfaceId: String?) -> Bool { + if (try? store.hasRunningSession( workspaceId: workspaceId, - surfaceId: nil, + excludingSessionId: sessionId, + requireLiveProcess: true + )) == true { + return true + } + guard let surfaceId else { + return false + } + return (try? store.hasRunningSession( + workspaceId: workspaceId, + surfaceId: surfaceId, excludingSessionId: sessionId, requireLiveProcess: true )) == true } func setIdleStatusUnlessAnotherSessionIsRunning(workspaceId: String, surfaceId: String) { - if hasOtherRunningSession(workspaceId: workspaceId) { + setTerminalStatusUnlessAnotherSessionIsRunning( + workspaceId: workspaceId, + surfaceId: surfaceId, + lifecycle: .idle, + statusValue: String(localized: "agent.generic.notification.status.idle", defaultValue: "Idle"), + icon: "pause.circle.fill", + color: "#8E8E93" + ) + } + func setCompletedStatusUnlessAnotherSessionIsRunning(workspaceId: String, surfaceId: String) { + setTerminalStatusUnlessAnotherSessionIsRunning( + workspaceId: workspaceId, + surfaceId: surfaceId, + lifecycle: .completed, + statusValue: String(localized: "agent.generic.status.completed", defaultValue: "Completed"), + icon: "checkmark.circle.fill", + color: "#34C759" + ) + } + func setTerminalStatusUnlessAnotherSessionIsRunning( + workspaceId: String, + surfaceId: String, + lifecycle: AgentHibernationLifecycleState, + statusValue: String, + icon: String, + color: String + ) { + if hasOtherRunningSession(workspaceId: workspaceId, surfaceId: surfaceId) { #if DEBUG agentHookDebugLog( "agentHook.status.keepRunning agent=\(def.name) session=\(agentHookDebugShort(sessionId)) workspace=\(agentHookDebugShort(workspaceId)) surface=\(agentHookDebugShort(surfaceId))", @@ -30358,9 +30677,15 @@ export default CMUXSessionRestore; #endif return } - let idleStatus = String(localized: "agent.generic.notification.status.idle", defaultValue: "Idle") + setAgentLifecycle( + client: client, + key: def.statusKey, + lifecycle: lifecycle, + workspaceId: workspaceId, + surfaceId: surfaceId + ) _ = try? sendV1Command( - "set_status \(def.statusKey) \(idleStatus) --icon=pause.circle.fill --color=#8E8E93 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + "set_status \(def.statusKey) \(statusValue) --icon=\(icon) --color=\(color) --tab=\(workspaceId)\(socketPanelOption(surfaceId))", client: client ) } @@ -30739,22 +31064,29 @@ export default CMUXSessionRestore; "set_status \(def.statusKey) \(runningStatus) --icon=bolt.fill --color=#4C8DFF --tab=\(workspaceId)\(socketPanelOption(surfaceId))", client: client ) - case .idle?: + case .idle?, .completed?: if !hasNewerRunningSession(workspaceId: workspaceId, surfaceId: surfaceId) { setAgentLifecycle( client: client, key: def.statusKey, - lifecycle: .idle, + lifecycle: latest.runtimeStatus == .completed ? .completed : .idle, workspaceId: workspaceId, surfaceId: surfaceId ) } - setIdleStatusUnlessAnotherSessionIsRunning(workspaceId: workspaceId, surfaceId: surfaceId) - case .needsInput?: + if latest.runtimeStatus == .completed { + setCompletedStatusUnlessAnotherSessionIsRunning( + workspaceId: workspaceId, + surfaceId: surfaceId + ) + } else { + setIdleStatusUnlessAnotherSessionIsRunning(workspaceId: workspaceId, surfaceId: surfaceId) + } + case .waiting?, .needsInput?: setAgentLifecycle( client: client, key: def.statusKey, - lifecycle: .needsInput, + lifecycle: .waiting, workspaceId: workspaceId, surfaceId: surfaceId ) @@ -30763,14 +31095,14 @@ export default CMUXSessionRestore; def.displayName ) _ = try? sendV1Command( - "set_status \(def.statusKey) \(statusValue) --icon=bell.fill --color=#4C8DFF --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + "set_status \(def.statusKey) \(statusValue) --icon=bell.fill --color=#FF9500 --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", client: client ) case .error?: setAgentLifecycle( client: client, key: def.statusKey, - lifecycle: .needsInput, + lifecycle: .failed, workspaceId: workspaceId, surfaceId: surfaceId ) @@ -31106,9 +31438,16 @@ export default CMUXSessionRestore; if antigravityHasActiveBackgroundWork && stopNotificationStatus == .idle { return .running } - return stopNotificationStatus == .idle ? .idle : .needsInput + switch stopNotificationStatus { + case .idle: + return .completed + case .error: + return .failed + case .needsInput: + return .waiting + } }() - let staleIdleStopHasNewerRunningSession = lifecycleAfterStop == .idle && + let staleIdleStopHasNewerRunningSession = lifecycleAfterStop == .completed && hasNewerRunningSession(workspaceId: workspaceId, surfaceId: surfaceId) let terminalActivePromptTurnIdsForStop: Set if !staleIdleStopHasNewerRunningSession, @@ -31277,7 +31616,7 @@ export default CMUXSessionRestore; setAgentLifecycle( client: client, key: def.statusKey, - lifecycle: .needsInput, + lifecycle: .failed, workspaceId: workspaceId, surfaceId: surfaceId ) @@ -31289,7 +31628,7 @@ export default CMUXSessionRestore; setAgentLifecycle( client: client, key: def.statusKey, - lifecycle: .needsInput, + lifecycle: .failed, workspaceId: workspaceId, surfaceId: surfaceId ) @@ -31318,11 +31657,36 @@ export default CMUXSessionRestore; setAgentLifecycle( client: client, key: def.statusKey, - lifecycle: .idle, + lifecycle: lifecycleAfterStop, workspaceId: workspaceId, surfaceId: surfaceId ) - setIdleStatusUnlessAnotherSessionIsRunning(workspaceId: workspaceId, surfaceId: surfaceId) + if lifecycleAfterStop == .completed { + setCompletedStatusUnlessAnotherSessionIsRunning( + workspaceId: workspaceId, + surfaceId: surfaceId + ) + } else if lifecycleAfterStop == .failed { + let statusValue = String.localizedStringWithFormat( + String(localized: "agent.generic.notification.status.error", defaultValue: "%@ error"), + def.displayName + ) + _ = try? sendV1Command( + "set_status \(def.statusKey) \(statusValue) --icon=exclamationmark.triangle.fill --color=#FF453A --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + client: client + ) + } else if lifecycleAfterStop == .waiting { + let statusValue = String.localizedStringWithFormat( + String(localized: "agent.generic.notification.status.needsInput", defaultValue: "%@ needs input"), + def.displayName + ) + _ = try? sendV1Command( + "set_status \(def.statusKey) \(statusValue) --icon=bell.fill --color=#FF9500 --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + client: client + ) + } else { + setIdleStatusUnlessAnotherSessionIsRunning(workspaceId: workspaceId, surfaceId: surfaceId) + } } } @@ -31677,7 +32041,7 @@ export default CMUXSessionRestore; setAgentLifecycle( client: client, key: def.statusKey, - lifecycle: .needsInput, + lifecycle: .waiting, workspaceId: workspaceId, surfaceId: surfaceId ) @@ -31693,7 +32057,7 @@ export default CMUXSessionRestore; setAgentLifecycle( client: client, key: def.statusKey, - lifecycle: .needsInput, + lifecycle: .failed, workspaceId: workspaceId, surfaceId: surfaceId ) @@ -31710,12 +32074,12 @@ export default CMUXSessionRestore; setAgentLifecycle( client: client, key: def.statusKey, - lifecycle: .idle, + lifecycle: .completed, workspaceId: workspaceId, surfaceId: surfaceId ) } - setIdleStatusUnlessAnotherSessionIsRunning(workspaceId: workspaceId, surfaceId: surfaceId) + setCompletedStatusUnlessAnotherSessionIsRunning(workspaceId: workspaceId, surfaceId: surfaceId) case nil: break } @@ -32338,7 +32702,7 @@ export default CMUXSessionRestore; case "prompt-submit": return "UserPromptSubmit" case "pre-tool-use", "cron-create-guard": return "PreToolUse" case "post-tool-use", "push-notification": return "PostToolUse" - case "stop", "idle": return "Stop" + case "stop", "idle", "stop-failure": return "Stop" case "session-end": return "SessionEnd" case "notification", "notify": return "Notification" default: return "" diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7bce34947f82..6f78fd969d77 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -32598,6 +32598,125 @@ } } }, + "cli.claude-hook.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux claude-hook [--workspace ] [--surface ]" + } + } + } + }, "cli.claude-hook.notification.title": { "extractionState": "manual", "localizations": { diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 1cd5d09487bd..616d31982f8b 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -884,8 +884,8 @@ install_cmux_node_options # Claude Code merges --settings additively with the user's own settings.json. # - preferredNotifChannel: disables Claude Code's OSC/terminal notification # channel inside cmux so hooks are the only notification source. -# - SessionStart/Stop/Notification: existing lifecycle hooks -# - SessionEnd: cleanup when Claude exits (covers Ctrl+C where Stop doesn't fire) +# - SessionStart/Stop/StopFailure/Notification: existing lifecycle hooks +# - SessionEnd: cleanup when Claude exits # - UserPromptSubmit: clears "Needs input" and sets "Running" on new prompt # - PreToolUse: rejects unsupported durable cron requests synchronously, then # (async) clears "Needs input" / sets "Running" for ordinary tools, and flags @@ -912,7 +912,7 @@ install_cmux_node_options # timeout because it may run a summarization subprocess; it gates itself # on the workspaceAutoNaming setting via a socket probe, so it is a # no-op when the feature is disabled. -HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude auto-name","timeout":120,"async":true}]}],"SubagentStop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"CronCreate","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude cron-create-guard","timeout":5}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PostToolUse":[{"matcher":"PushNotification","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude push-notification","timeout":10,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}' +HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude auto-name","timeout":120,"async":true}]}],"StopFailure":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop-failure","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SubagentStop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"CronCreate","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude cron-create-guard","timeout":5}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PostToolUse":[{"matcher":"PushNotification","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude push-notification","timeout":10,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}' # Fold any user-provided --settings into HOOKS_JSON before launching. # diff --git a/Sources/AgentHibernation/AgentHibernationLifecycleState.swift b/Sources/AgentHibernation/AgentHibernationLifecycleState.swift index cbb14c776343..6ad2a5878305 100644 --- a/Sources/AgentHibernation/AgentHibernationLifecycleState.swift +++ b/Sources/AgentHibernation/AgentHibernationLifecycleState.swift @@ -3,8 +3,11 @@ import Foundation enum AgentHibernationLifecycleState: String, Codable, Sendable, Equatable, CaseIterable { case unknown case running + case waiting case idle case needsInput + case completed + case failed init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() @@ -12,8 +15,29 @@ enum AgentHibernationLifecycleState: String, Codable, Sendable, Equatable, CaseI self = Self.parse(rawValue) ?? .unknown } + /// Whether a hibernated agent panel can stay suspended in this state. var allowsHibernation: Bool { - self == .idle + switch self { + case .idle, .completed, .failed: + return true + case .unknown, .running, .waiting, .needsInput: + return false + } + } + + /// Whether the agent is actively executing work (tools, model, compaction). + var isActivelyRunning: Bool { + self == .running + } + + /// Whether the agent is blocked on user input, approval, or an error. + var needsUserAttention: Bool { + switch self { + case .waiting, .needsInput, .failed: + return true + case .unknown, .running, .idle, .completed: + return false + } } func encode(to encoder: Encoder) throws { @@ -35,10 +59,16 @@ enum AgentHibernationLifecycleState: String, Codable, Sendable, Equatable, CaseI return .unknown case "running": return .running + case "waiting": + return .waiting case "idle": return .idle case "needsinput", "needs-input": return .needsInput + case "completed", "complete", "done": + return .completed + case "failed", "error": + return .failed default: return nil } diff --git a/Sources/Workspace+AgentLifecycle.swift b/Sources/Workspace+AgentLifecycle.swift index b2fed9a5556d..806571aa0c0e 100644 --- a/Sources/Workspace+AgentLifecycle.swift +++ b/Sources/Workspace+AgentLifecycle.swift @@ -252,7 +252,10 @@ extension Workspace { return fallback ?? .unknown } if states.contains(.running) { return .running } + if states.contains(.failed) { return .failed } + if states.contains(.waiting) { return .waiting } if states.contains(.needsInput) { return .needsInput } + if states.contains(.completed) { return .completed } if states.contains(.unknown) { return .unknown } if states.contains(.idle) { return .idle } return fallback ?? .unknown diff --git a/Sources/Workspace+Todos.swift b/Sources/Workspace+Todos.swift index 0cb619c11d5e..24fc1e0b8414 100644 --- a/Sources/Workspace+Todos.swift +++ b/Sources/Workspace+Todos.swift @@ -17,8 +17,8 @@ extension Workspace { var anyAgentRunning = false for (panelId, states) in agentLifecycleStatesByPanelId where panels[panelId] != nil { for state in states.values { - if state == .needsInput { anyAgentNeedsInput = true } - if state == .running { anyAgentRunning = true } + if state.needsUserAttention { anyAgentNeedsInput = true } + if state.isActivelyRunning { anyAgentRunning = true } } } let pullRequests = sidebarPullRequestsInDisplayOrder() diff --git a/cmuxTests/AgentHibernationTests.swift b/cmuxTests/AgentHibernationTests.swift index 2c1a03986ae3..8e156533058c 100644 --- a/cmuxTests/AgentHibernationTests.swift +++ b/cmuxTests/AgentHibernationTests.swift @@ -17,6 +17,11 @@ struct AgentHibernationTests { expectEqual(AgentHibernationLifecycleState.parseCLIValue("needsInput"), .needsInput) expectEqual(AgentHibernationLifecycleState.parseCLIValue("needs-input"), .needsInput) expectEqual(AgentHibernationLifecycleState.parseCLIValue("needs_input"), .needsInput) + expectEqual(AgentHibernationLifecycleState.parseCLIValue("waiting"), .waiting) + expectEqual(AgentHibernationLifecycleState.parseCLIValue("completed"), .completed) + expectEqual(AgentHibernationLifecycleState.parseCLIValue("done"), .completed) + expectEqual(AgentHibernationLifecycleState.parseCLIValue("failed"), .failed) + expectEqual(AgentHibernationLifecycleState.parseCLIValue("error"), .failed) expectNil(AgentHibernationLifecycleState.parseCLIValue("paused")) let decoded = try JSONDecoder().decode( @@ -26,6 +31,23 @@ struct AgentHibernationTests { expectEqual(decoded, .unknown) } + @Test + func testLifecycleStateHibernationAndAttentionSemantics() { + expectTrue(AgentHibernationLifecycleState.idle.allowsHibernation) + expectTrue(AgentHibernationLifecycleState.completed.allowsHibernation) + expectTrue(AgentHibernationLifecycleState.failed.allowsHibernation) + expectFalse(AgentHibernationLifecycleState.running.allowsHibernation) + expectFalse(AgentHibernationLifecycleState.waiting.allowsHibernation) + + expectTrue(AgentHibernationLifecycleState.running.isActivelyRunning) + expectFalse(AgentHibernationLifecycleState.completed.isActivelyRunning) + + expectTrue(AgentHibernationLifecycleState.waiting.needsUserAttention) + expectTrue(AgentHibernationLifecycleState.needsInput.needsUserAttention) + expectTrue(AgentHibernationLifecycleState.failed.needsUserAttention) + expectFalse(AgentHibernationLifecycleState.completed.needsUserAttention) + } + @MainActor @Test func testSocketLifecycleRejectsUnsupportedStatusKey() { diff --git a/cmuxTests/CLICodexHookTimeoutRegressionTests.swift b/cmuxTests/CLICodexHookTimeoutRegressionTests.swift index 1f0fb823ac9c..bae5e7a2c921 100644 --- a/cmuxTests/CLICodexHookTimeoutRegressionTests.swift +++ b/cmuxTests/CLICodexHookTimeoutRegressionTests.swift @@ -293,7 +293,7 @@ struct CLICodexHookTimeoutRegressionTests { let staleStopCommands = Array(commands.snapshot().dropFirst(staleStopStart)) #expect( !staleStopCommands.contains { - $0.hasPrefix("notify_target") || ($0.hasPrefix("set_status codex ") && $0.contains(" Idle ")) + $0.hasPrefix("notify_target") || ($0.hasPrefix("set_status codex ") && $0.contains(" Completed ")) }, "An installed async Stop from an older turn must not notify or mark a newer running turn idle, saw \(staleStopCommands)" ) diff --git a/cmuxTests/CLIGenericHookPersistenceTests.swift b/cmuxTests/CLIGenericHookPersistenceTests.swift index 7aa3f71a830c..0bcf5ee60b75 100644 --- a/cmuxTests/CLIGenericHookPersistenceTests.swift +++ b/cmuxTests/CLIGenericHookPersistenceTests.swift @@ -369,7 +369,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Antigravity Stop with active background work should keep the session running, saw \(backgroundStopCommands)" ) XCTAssertFalse( - backgroundStopCommands.contains { $0.contains("set_status antigravity Idle") }, + backgroundStopCommands.contains { $0.contains("set_status antigravity Completed") }, "Antigravity Stop with active background work must not mark idle, saw \(backgroundStopCommands)" ) @@ -388,7 +388,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Idle-classified Antigravity notifications must not double-notify while background work is active, saw \(backgroundDuplicateCommands)" ) XCTAssertFalse( - backgroundDuplicateCommands.contains { $0.contains("set_status antigravity Idle") }, + backgroundDuplicateCommands.contains { $0.contains("set_status antigravity Completed") }, "Idle-classified Antigravity notifications must not override the running status while background work is active, saw \(backgroundDuplicateCommands)" ) @@ -424,7 +424,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Antigravity idle notifications without fullyIdle must publish instead of staying suppressed, saw \(missingFullyIdleNotificationCommands)" ) XCTAssertFalse( - missingFullyIdleNotificationCommands.contains { $0.contains("set_status antigravity Idle") }, + missingFullyIdleNotificationCommands.contains { $0.contains("set_status antigravity Completed") }, "Antigravity idle notifications must not reset the shared status while another background session is running, saw \(missingFullyIdleNotificationCommands)" ) @@ -447,7 +447,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected Antigravity stop to publish a turn-completion notification, saw \(stopCommands)" ) XCTAssertTrue( - stopCommands.contains { $0.contains("set_status antigravity Idle") }, + stopCommands.contains { $0.contains("set_status antigravity Completed") }, "Expected Antigravity stop to leave the session idle, saw \(stopCommands)" ) @@ -628,7 +628,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected Hermes completion notification to use extra.assistant_response, saw \(stopCommands)" ) XCTAssertTrue( - stopCommands.contains { $0.contains("set_status hermes-agent Idle") }, + stopCommands.contains { $0.contains("set_status hermes-agent Completed") }, "Expected Hermes completion to leave status idle, saw \(stopCommands)" ) @@ -1413,7 +1413,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Grok Stop should not publish a generic completion notification; Notification carries the real message. Saw \(stopCommands)" ) XCTAssertTrue( - stopCommands.contains { $0.contains("set_status grok Idle") }, + stopCommands.contains { $0.contains("set_status grok Completed") }, "Expected Grok Stop to keep task-manager status idle, saw \(stopCommands)" ) @@ -1434,7 +1434,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected Grok Notification to forward the payload message, saw \(notificationCommands)" ) XCTAssertTrue( - notificationCommands.contains { $0.contains("set_status grok Idle") }, + notificationCommands.contains { $0.contains("set_status grok Completed") }, "Expected completion notification to leave Grok idle, saw \(notificationCommands)" ) @@ -1478,7 +1478,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Grok generic completion notifications should still fire before there is an assistant response, saw \(preAssistantGenericCommands)" ) XCTAssertTrue( - preAssistantGenericCommands.contains { $0.contains("set_status grok Idle") }, + preAssistantGenericCommands.contains { $0.contains("set_status grok Completed") }, "Expected generic completion without an assistant response to leave Grok idle, saw \(preAssistantGenericCommands)" ) json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) @@ -1521,7 +1521,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected Grok Stop fallback to publish the cwd-scoped assistant response when Grok only emits internal Notification events, saw \(enrichedStopCommands)" ) XCTAssertTrue( - enrichedStopCommands.contains { $0.contains("set_status grok Idle") }, + enrichedStopCommands.contains { $0.contains("set_status grok Completed") }, "Expected enriched Grok Stop to leave Grok idle, saw \(enrichedStopCommands)" ) @@ -1642,7 +1642,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Grok completion Notification must not double-notify after Stop fallback already published the completion, saw \(genericCompletionCommands)" ) XCTAssertTrue( - genericCompletionCommands.contains { $0.contains("set_status grok Idle") }, + genericCompletionCommands.contains { $0.contains("set_status grok Completed") }, "Expected enriched completion notification to leave Grok idle, saw \(genericCompletionCommands)" ) @@ -1759,7 +1759,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Grok completion notifications must not read another cwd's latest session, saw \(scopedMissCommands)" ) XCTAssertTrue( - scopedMissCommands.contains { $0.contains("set_status grok Idle") }, + scopedMissCommands.contains { $0.contains("set_status grok Completed") }, "Expected scoped completion without transcript to leave Grok idle, saw \(scopedMissCommands)" ) @@ -2054,12 +2054,12 @@ extension CLINotifyProcessIntegrationRegressionTests { ) if thread.index == 1 { XCTAssertFalse( - stopCommands.contains { $0.contains("set_status grok Idle") }, + stopCommands.contains { $0.contains("set_status grok Completed") }, "First Grok thread must not reset shared status while thread 2 is still running, saw \(stopCommands)" ) } else { XCTAssertTrue( - stopCommands.contains { $0.contains("set_status grok Idle") }, + stopCommands.contains { $0.contains("set_status grok Completed") }, "Expected final Grok Stop to leave Grok idle, saw \(stopCommands)" ) } @@ -2165,7 +2165,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected Grok Stop without cwd to notify with a generic completion body, saw \(stopCommands)" ) XCTAssertTrue( - stopCommands.contains { $0.contains("set_status grok Idle") }, + stopCommands.contains { $0.contains("set_status grok Completed") }, "Expected Grok Stop without cwd to leave Grok idle, saw \(stopCommands)" ) @@ -2277,7 +2277,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected Grok completion notification for prompt \(index), saw \(notificationCommands)" ) XCTAssertTrue( - notificationCommands.contains { $0.contains("set_status grok Idle") }, + notificationCommands.contains { $0.contains("set_status grok Completed") }, "Expected Grok completion for prompt \(index) to leave Grok idle, saw \(notificationCommands)" ) } @@ -2426,7 +2426,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected Grok completion notification for chat message \(index), saw \(notificationCommands)" ) XCTAssertTrue( - notificationCommands.contains { $0.contains("set_status grok Idle") }, + notificationCommands.contains { $0.contains("set_status grok Completed") }, "Expected Grok completion for chat message \(index) to leave Grok idle, saw \(notificationCommands)" ) @@ -2595,7 +2595,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected completing Grok session to notify its own surface, saw \(completionCommands)" ) XCTAssertFalse( - completionCommands.contains { $0.contains("set_status grok Idle") }, + completionCommands.contains { $0.contains("set_status grok Completed") }, "Completing Grok session must not reset the shared Grok status while a sibling session is running, saw \(completionCommands)" ) } @@ -2691,7 +2691,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertEqual(completion.stdout, "{}\n") XCTAssertTrue( - state.commands.contains { $0.contains("set_status grok Idle") }, + state.commands.contains { $0.contains("set_status grok Completed") }, "Dead PID running records must not keep the shared Grok status running, saw \(state.commands)" ) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 2905962f55aa..d288d64ac2db 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -7,6 +7,88 @@ import Darwin #endif final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { + func testClaudeWrapperInstallsStopFailureHookAsTurnEndSignal() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-claude-wrapper-stopfailure-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let socketPath = root.appendingPathComponent("cmux.sock", isDirectory: false).path + let listenerFD = try bindUnixSocket(at: socketPath) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let fakeCmuxURL = root.appendingPathComponent("cmux", isDirectory: false) + try """ + #!/bin/sh + if [ "$1" = "--socket" ]; then + shift + shift + fi + if [ "$1" = "ping" ]; then + exit 0 + fi + printf '{}\\n' + exit 0 + """.write(to: fakeCmuxURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fakeCmuxURL.path) + + let fakeClaudeURL = root.appendingPathComponent("real-claude", isDirectory: false) + try """ + #!/bin/sh + while [ "$#" -gt 0 ]; do + if [ "$1" = "--settings" ]; then + shift + printf '%s\\n' "$1" + exit 0 + fi + shift + done + echo "missing --settings" >&2 + exit 66 + """.write(to: fakeClaudeURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fakeClaudeURL.path) + + let sourceRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let wrapperURL = sourceRoot.appendingPathComponent("Resources/bin/cmux-claude-wrapper", isDirectory: false) + + var environment = ProcessInfo.processInfo.environment + environment["HOME"] = root.path + environment["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin" + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_SURFACE_ID"] = "22222222-2222-2222-2222-222222222222" + environment["CMUX_BUNDLED_CLI_PATH"] = fakeCmuxURL.path + environment["CMUX_CUSTOM_CLAUDE_PATH"] = fakeClaudeURL.path + environment["CMUX_CLAUDE_HOOKS_DISABLED"] = "0" + + let result = runProcess( + executablePath: wrapperURL.path, + arguments: [], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let settingsData = try XCTUnwrap(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8)) + let settings = try XCTUnwrap(JSONSerialization.jsonObject(with: settingsData) as? [String: Any]) + let hooks = try XCTUnwrap(settings["hooks"] as? [String: Any]) + let stopFailureGroups = try XCTUnwrap(hooks["StopFailure"] as? [[String: Any]]) + let stopFailureCommands = stopFailureGroups + .compactMap { $0["hooks"] as? [[String: Any]] } + .flatMap { $0 } + .compactMap { $0["command"] as? String } + + XCTAssertTrue( + stopFailureCommands.contains { $0.contains("hooks claude stop-failure") }, + "StopFailure must invoke the stop-failure subcommand so interrupt cleanup skips completion notification, saw \(stopFailureCommands)" + ) + } + func testClaudeClearSessionStartMarksWorkspaceRunning() throws { let context = try makeClaudeHookContext(name: "claude-clear-running") defer { context.cleanup() } @@ -169,7 +251,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue( context.state.commands.contains { - $0.hasPrefix("set_agent_lifecycle claude_code needsInput --tab=\(context.workspaceId)") + $0.hasPrefix("set_agent_lifecycle claude_code waiting --tab=\(context.workspaceId)") && $0.contains("--panel=\(context.surfaceId)") }, "ExitPlanMode PreToolUse must drive Needs input, saw \(context.state.commands)" @@ -199,7 +281,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) let record = try readClaudeHookSession("exitplan-session", context: context) - XCTAssertEqual(record["agentLifecycle"] as? String, "needsInput") + XCTAssertEqual(record["agentLifecycle"] as? String, "waiting") XCTAssertEqual( (record["lastBody"] as? String)?.contains("echo hi"), true, "Expected the saved needs-input body to summarize the plan, saw \(record["lastBody"] ?? "nil")" @@ -228,7 +310,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue( context.state.commands.contains { - $0.hasPrefix("set_agent_lifecycle claude_code needsInput --tab=\(context.workspaceId)") + $0.hasPrefix("set_agent_lifecycle claude_code waiting --tab=\(context.workspaceId)") && $0.contains("--panel=\(context.surfaceId)") }, "AskUserQuestion PreToolUse must drive Needs input, saw \(context.state.commands)" @@ -258,7 +340,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) let record = try readClaudeHookSession("askquestion-session", context: context) - XCTAssertEqual(record["agentLifecycle"] as? String, "needsInput") + XCTAssertEqual(record["agentLifecycle"] as? String, "waiting") XCTAssertEqual( (record["lastBody"] as? String)?.contains("Which color"), true, "Expected the saved needs-input body to carry the question text, saw \(record["lastBody"] ?? "nil")" @@ -284,7 +366,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue( context.state.commands.contains { - $0.hasPrefix("set_agent_lifecycle claude_code needsInput --tab=\(context.workspaceId)") + $0.hasPrefix("set_agent_lifecycle claude_code waiting --tab=\(context.workspaceId)") }, "AskUserQuestion PreToolUse must drive Needs input in every mode, saw \(context.state.commands)" ) @@ -531,7 +613,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) XCTAssertFalse( context.state.commands.contains { - $0.hasPrefix("set_status claude_code Idle ") && $0.contains("--tab=\(context.workspaceId)") + $0.hasPrefix("set_status claude_code Completed ") && $0.contains("--tab=\(context.workspaceId)") }, "Expected stale Stop from old session not to clobber the clear session, saw \(context.state.commands)" ) @@ -1776,7 +1858,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { "Parent Codex Stop should still notify, saw \(parentStopCommands)" ) XCTAssertTrue( - parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Completed ") }, "Parent Codex Stop should mark Codex idle, saw \(parentStopCommands)" ) } @@ -1826,7 +1908,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { let notificationCommands = Array(context.state.commands.dropFirst(notificationStart)) XCTAssertTrue( notificationCommands.contains { - $0.hasPrefix("set_agent_lifecycle codex needsInput --tab=\(context.workspaceId)") + $0.hasPrefix("set_agent_lifecycle codex waiting --tab=\(context.workspaceId)") && $0.contains("--panel=\(context.surfaceId)") }, "Notification requiring user input must correct the visible lifecycle, saw \(notificationCommands)" @@ -1835,7 +1917,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { state = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) sessions = try XCTUnwrap(state["sessions"] as? [String: Any]) record = try XCTUnwrap(sessions[sessionId] as? [String: Any]) - XCTAssertEqual(record["agentLifecycle"] as? String, "needsInput") + XCTAssertEqual(record["agentLifecycle"] as? String, "waiting") } func testGenericAgentStaleIdleStopDoesNotOverwriteNewerRunningLifecycle() throws { @@ -1878,11 +1960,11 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { let staleStopCommands = Array(context.state.commands.dropFirst(staleStopStart)) XCTAssertFalse( - staleStopCommands.contains { $0.hasPrefix("set_agent_lifecycle codex idle ") }, + staleStopCommands.contains { $0.hasPrefix("set_agent_lifecycle codex completed ") }, "A stale Stop from an older session must not mark the surface idle, saw \(staleStopCommands)" ) XCTAssertFalse( - staleStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + staleStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Completed ") }, "A stale Stop from an older session must not replace the newer Running status, saw \(staleStopCommands)" ) XCTAssertFalse( @@ -1938,11 +2020,11 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { let staleNotificationCommands = Array(context.state.commands.dropFirst(staleNotificationStart)) XCTAssertFalse( - staleNotificationCommands.contains { $0.hasPrefix("set_agent_lifecycle codex idle ") }, + staleNotificationCommands.contains { $0.hasPrefix("set_agent_lifecycle codex completed ") }, "A stale idle notification must not mark the newer session idle, saw \(staleNotificationCommands)" ) XCTAssertFalse( - staleNotificationCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + staleNotificationCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Completed ") }, "A stale idle notification must not replace the newer Running status, saw \(staleNotificationCommands)" ) XCTAssertFalse( @@ -2014,7 +2096,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { "The parent Stop must still notify after a legacy child Stop without a turn_id, saw \(parentStopCommands)" ) XCTAssertTrue( - parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Completed ") }, "The parent Stop must still mark Codex idle after a legacy child Stop without a turn_id, saw \(parentStopCommands)" ) } @@ -2089,7 +2171,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { "The generic parent Stop must still notify after its nested child, saw \(parentStopCommands)" ) XCTAssertTrue( - parentStopCommands.contains { $0.hasPrefix("set_status gemini ") && $0.contains(" Idle ") }, + parentStopCommands.contains { $0.hasPrefix("set_status gemini ") && $0.contains(" Completed ") }, "The generic parent Stop must still mark Gemini idle, saw \(parentStopCommands)" ) } @@ -2194,7 +2276,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { "The parent Stop must still notify after mixed anonymous depth, saw \(parentStopCommands)" ) XCTAssertTrue( - parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Completed ") }, "The parent Stop must still mark Codex idle after mixed anonymous depth, saw \(parentStopCommands)" ) } @@ -2255,7 +2337,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { "The parent Stop must still notify after a child Stop supplies a new turn_id, saw \(parentStopCommands)" ) XCTAssertTrue( - parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Completed ") }, "The parent Stop must still mark Codex idle after a child Stop supplies a new turn_id, saw \(parentStopCommands)" ) } @@ -2308,7 +2390,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { "A stale prompt depth from an interrupted prior turn must not suppress the current top-level completion notification, saw \(stopCommands)" ) XCTAssertTrue( - stopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + stopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Completed ") }, "A stale prompt depth from an interrupted prior turn must not leave Codex marked running, saw \(stopCommands)" ) } @@ -2374,7 +2456,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { "A late terminal prior turn must not suppress the current top-level completion notification, saw \(currentStopCommands)" ) XCTAssertTrue( - currentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + currentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Completed ") }, "A late terminal prior turn must not leave Codex marked running after the current Stop, saw \(currentStopCommands)" ) } @@ -2811,7 +2893,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { "The depth-only parent Stop must notify after its child turn stops, saw \(parentStopCommands)" ) XCTAssertTrue( - parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Completed ") }, "The depth-only parent Stop must mark Codex idle, saw \(parentStopCommands)" ) } @@ -3142,7 +3224,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { "A Stop after a missed prompt-submit must clear terminal stale turns and notify, saw \(currentStopCommands)" ) XCTAssertTrue( - currentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + currentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Completed ") }, "A Stop after a missed prompt-submit must mark Codex idle, saw \(currentStopCommands)" ) } @@ -3205,7 +3287,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { "A missed prompt-submit Stop must clear a fully terminal stored stack and notify, saw \(currentStopCommands)" ) XCTAssertTrue( - currentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + currentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Completed ") }, "A missed prompt-submit Stop must clear a fully terminal stored stack and mark Codex idle, saw \(currentStopCommands)" ) } diff --git a/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift index 45c494461d96..61cdce5fc7d7 100644 --- a/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift +++ b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift @@ -96,11 +96,11 @@ struct ClaudeBackgroundWorkNotifyTests { #expect(notifyLine(snapshot, containing: "c=turn-complete;p=0") != nil, "Truly-idle stop must tag pending=0; saw \(snapshot)") #expect(cached == false) - // Truly-idle turn end keeps the "Idle" pill and the hibernatable lifecycle. - #expect(statusLine(snapshot, value: "Idle") != nil, - "Truly-idle stop must show the Idle pill; saw \(snapshot)") - #expect(lifecycleLine(snapshot, value: "idle") != nil, - "Truly-idle stop must publish an idle lifecycle; saw \(snapshot)") + // Truly-idle turn end keeps the "Completed" pill and the hibernatable lifecycle. + #expect(statusLine(snapshot, value: "Completed") != nil, + "Truly-idle stop must show the Completed pill; saw \(snapshot)") + #expect(lifecycleLine(snapshot, value: "completed") != nil, + "Truly-idle stop must publish a completed lifecycle; saw \(snapshot)") } @Test func stopWithPendingCronTagsPending() throws { diff --git a/cmuxTests/ClaudeHookLiveDeliveryTargetTests.swift b/cmuxTests/ClaudeHookLiveDeliveryTargetTests.swift index e935930e8d16..9b07f57dd58d 100644 --- a/cmuxTests/ClaudeHookLiveDeliveryTargetTests.swift +++ b/cmuxTests/ClaudeHookLiveDeliveryTargetTests.swift @@ -74,7 +74,7 @@ struct ClaudeHookLiveDeliveryTargetTests { ) #expect( commands.contains { - $0.hasPrefix("set_status claude_code Idle ") + $0.hasPrefix("set_status claude_code Completed ") && $0.contains("--tab=\(Self.liveWorkspaceId)") && $0.contains("--panel=\(Self.liveSurfaceId)") }, diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index 281bd887cfb7..25ea8f999555 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -82,6 +82,12 @@ struct FeedEventClassificationTests { #expect(classify("claude", "PermissionRequest", tool: "AskUserQuestion").name == "AskUserQuestion") } + @Test func claudeStopFailureMapsToStopTelemetry() { + let stopFailure = classify("claude", "StopFailure") + #expect(stopFailure.name == "Stop") + #expect(stopFailure.actionable == false) + } + @Test func claudeLifecycleFeedEventsStayTelemetryAndPreserveNames() { for event in ["PostToolUse", "PreCompact", "PostCompact", "SubagentStart", "SubagentStop"] { let classification = classify("claude", event, tool: "Bash") diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 2be307813679..7faa77610214 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -4775,7 +4775,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertEqual(result.stdout, "{}\n") XCTAssertTrue( state.commands.contains { command in - command.contains("set_status codex Idle") && + command.contains("set_status codex Completed") && command.contains("--icon=pause.circle.fill") && command.contains("--tab=\(workspaceId)") }, @@ -5001,7 +5001,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertEqual(result.stdout, "{}\n") XCTAssertTrue( state.commands.contains { command in - command.contains("set_status codex Idle") && + command.contains("set_status codex Completed") && command.contains("--tab=\(workspaceId)") }, "Expected scoped assistant reply to suppress no-final-response error, saw \(state.commands)" @@ -5071,7 +5071,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertEqual(result.stdout, "{}\n") XCTAssertTrue( state.commands.contains { command in - command.contains("set_status codex Idle") && + command.contains("set_status codex Completed") && command.contains("--tab=\(workspaceId)") }, "Expected stale unscoped error to leave Codex idle, saw \(state.commands)" @@ -5164,6 +5164,240 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + func testCodexHookMonitorSetsIdleStatusFromHealthyTerminalCompletionWithoutStopHook() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("codex") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-monitor-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let otherSurfaceId = "33333333-3333-3333-3333-333333333333" + let sessionId = "codex-session-monitor-healthy" + let turnId = "turn-monitor-healthy" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let transcriptURL = root.appendingPathComponent("rollout-\(sessionId).jsonl") + try """ + {"timestamp":"2026-04-25T07:55:29.462Z","type":"session_meta","payload":{"id":"\(sessionId)","cwd":"\(root.path)"}} + {"timestamp":"2026-04-25T07:55:29.500Z","type":"event_msg","payload":{"type":"task_started","turn_id":"\(turnId)","started_at":1777107522}} + {"timestamp":"2026-04-25T07:55:29.600Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done"}]}} + {"timestamp":"2026-04-25T07:55:29.804Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"\(turnId)","last_agent_message":"Done"}} + """.write(to: transcriptURL, atomically: true, encoding: .utf8) + + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + try """ + { + "version": 1, + "sessions": { + "other-running-session": { + "sessionId": "other-running-session", + "workspaceId": "\(workspaceId)", + "surfaceId": "\(otherSurfaceId)", + "runtimeStatus": "running", + "startedAt": 1777107000, + "updatedAt": 1777107000 + }, + "stale-same-surface-session": { + "sessionId": "stale-same-surface-session", + "workspaceId": "\(workspaceId)", + "surfaceId": "\(surfaceId)", + "pid": -1, + "runtimeStatus": "running", + "startedAt": 1777107000, + "updatedAt": 1777107000 + }, + "\(sessionId)": { + "sessionId": "\(sessionId)", + "workspaceId": "\(workspaceId)", + "surfaceId": "\(surfaceId)", + "activePromptDepth": 1, + "activePromptTurnId": "\(turnId)", + "activePromptTurnIds": ["\(turnId)"], + "agentLifecycle": "running", + "runtimeStatus": "running", + "startedAt": 1777107000, + "updatedAt": 1777107000 + } + } + } + """.write(to: stateURL, atomically: true, encoding: .utf8) + + startMockServerAccepting(listenerFD: listenerFD, state: state, connectionLimit: 4) { line in + if let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String { + return self.v2Response(id: id, ok: true, result: ["surfaces": [["id": surfaceId, "ref": surfaceId]]]) + } + return "OK" + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "hooks", "codex", "monitor", + "--workspace", + workspaceId, + "--surface", + surfaceId, + "--session", + sessionId, + "--turn", + turnId, + "--transcript", + transcriptURL.path, + ], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "") + XCTAssertTrue( + waitForSocketCommand(state: state, timeout: 5) { command in + command.hasPrefix("set_agent_lifecycle codex idle ") + && command.contains("--tab=\(workspaceId)") + && command.contains("--panel=\(surfaceId)") + }, + "Expected monitor to clear panel-scoped Codex lifecycle when a turn completes without Stop, saw \(state.snapshot())" + ) + XCTAssertTrue( + waitForSocketCommand(state: state, timeout: 5) { command in + command.contains("set_status codex Idle") && + command.contains("--icon=pause.circle.fill") && + command.contains("--color=#8E8E93") && + command.contains("--tab=\(workspaceId)") + }, + "Expected monitor to clear panel-scoped Running when a Codex turn completes without Stop, even if stale running sessions remain; saw \(state.snapshot())" + ) + let updatedState = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let sessions = try XCTUnwrap(updatedState["sessions"] as? [String: Any]) + let record = try XCTUnwrap(sessions[sessionId] as? [String: Any]) + XCTAssertEqual(record["agentLifecycle"] as? String, "idle") + } + + func testCodexHookMonitorDoesNotPublishSharedIdleStatusOverOtherLiveWorkspaceSession() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("codex") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-monitor-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let otherSurfaceId = "33333333-3333-3333-3333-333333333333" + let sessionId = "codex-session-monitor-shared-status" + let turnId = "turn-monitor-shared-status" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let transcriptURL = root.appendingPathComponent("rollout-\(sessionId).jsonl") + try """ + {"timestamp":"2026-04-25T07:55:29.462Z","type":"session_meta","payload":{"id":"\(sessionId)","cwd":"\(root.path)"}} + {"timestamp":"2026-04-25T07:55:29.500Z","type":"event_msg","payload":{"type":"task_started","turn_id":"\(turnId)","started_at":1777107522}} + {"timestamp":"2026-04-25T07:55:29.600Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done"}]}} + {"timestamp":"2026-04-25T07:55:29.804Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"\(turnId)","last_agent_message":"Done"}} + """.write(to: transcriptURL, atomically: true, encoding: .utf8) + + let livePID = ProcessInfo.processInfo.processIdentifier + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + try """ + { + "version": 1, + "sessions": { + "other-live-running-session": { + "sessionId": "other-live-running-session", + "workspaceId": "\(workspaceId)", + "surfaceId": "\(otherSurfaceId)", + "pid": \(livePID), + "runtimeStatus": "running", + "startedAt": 1777107000, + "updatedAt": 1777107000 + }, + "\(sessionId)": { + "sessionId": "\(sessionId)", + "workspaceId": "\(workspaceId)", + "surfaceId": "\(surfaceId)", + "activePromptDepth": 1, + "activePromptTurnId": "\(turnId)", + "activePromptTurnIds": ["\(turnId)"], + "agentLifecycle": "running", + "runtimeStatus": "running", + "startedAt": 1777107000, + "updatedAt": 1777107000 + } + } + } + """.write(to: stateURL, atomically: true, encoding: .utf8) + + startMockServerAccepting(listenerFD: listenerFD, state: state, connectionLimit: 4) { line in + if let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String { + return self.v2Response(id: id, ok: true, result: ["surfaces": [["id": surfaceId, "ref": surfaceId]]]) + } + return "OK" + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "hooks", "codex", "monitor", + "--workspace", + workspaceId, + "--surface", + surfaceId, + "--session", + sessionId, + "--turn", + turnId, + "--transcript", + transcriptURL.path, + ], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "") + XCTAssertTrue( + waitForSocketCommand(state: state, timeout: 5) { command in + command.hasPrefix("set_agent_lifecycle codex idle ") + && command.contains("--tab=\(workspaceId)") + && command.contains("--panel=\(surfaceId)") + }, + "Expected monitor to clear the completed panel lifecycle, saw \(state.snapshot())" + ) + XCTAssertFalse( + state.snapshot().contains { $0.contains("set_status codex Idle") }, + "A completed panel must not overwrite the workspace-shared Codex status while another panel is still running, saw \(state.snapshot())" + ) + } + func testCodexHookMonitorReportsExplicitErrorBeforeTerminalCompletion() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -5645,6 +5879,122 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + func testCodexHookMonitorKeepsUnscopedCurrentTurnPendingAfterOldTerminalTurn() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("codex") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-monitor-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "codex-session-monitor-unscoped-current" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let transcriptURL = root.appendingPathComponent("rollout-\(sessionId).jsonl") + try """ + {"timestamp":"2026-04-25T07:55:29.462Z","type":"session_meta","payload":{"id":"\(sessionId)","cwd":"\(root.path)"}} + {"timestamp":"2026-04-25T07:55:29.500Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-old","started_at":1777107522}} + {"timestamp":"2026-04-25T07:55:29.804Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-old","last_agent_message":"Old turn completed."}} + {"timestamp":"2026-04-25T07:55:30.000Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-current","started_at":1777107530}} + """.write(to: transcriptURL, atomically: true, encoding: .utf8) + + let serverHandled = startMockServerSignal(listenerFD: listenerFD, state: state) { line in + if let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String { + return self.v2Response(id: id, ok: true, result: ["surfaces": [["id": surfaceId, "ref": surfaceId]]]) + } + return "OK" + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: cliPath) + process.arguments = [ + "hooks", "codex", "monitor", + "--workspace", + workspaceId, + "--surface", + surfaceId, + "--session", + sessionId, + "--transcript", + transcriptURL.path, + ] + process.environment = environment + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + try process.run() + defer { + if process.isRunning { + process.terminate() + } + } + + let exitSignal = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + exitSignal.signal() + } + + XCTAssertTrue( + waitForProcess(process, toHoldOpenFile: transcriptURL.path, timeout: 2), + "Monitor did not keep watching the transcript after the current unscoped turn started" + ) + XCTAssertTrue(process.isRunning, "Monitor exited on an old terminal turn before the current unscoped turn completed") + + let appendHandle = try FileHandle(forWritingTo: transcriptURL) + try appendHandle.seekToEnd() + appendHandle.write(Data("\n".utf8)) + appendHandle.write(Data(""" + {"timestamp":"2026-04-25T07:55:30.100Z","type":"event_msg","payload":{"type":"error","turn_id":"turn-current","message":"Stream disconnected before completion.","codex_error_info":"response_stream_disconnected"}} + """.utf8)) + try appendHandle.close() + + let serverTimedOut = serverHandled.wait(timeout: .now() + 5) == .timedOut + let timedOut = exitSignal.wait(timeout: .now() + 5) == .timedOut + if timedOut { + process.terminate() + _ = exitSignal.wait(timeout: .now() + 1) + } + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + XCTAssertFalse(serverTimedOut, "Timed out waiting for mock socket command. stderr: \(stderr)") + XCTAssertFalse(timedOut, stderr) + XCTAssertEqual(process.terminationStatus, 0, stderr) + XCTAssertEqual(stdout, "") + XCTAssertTrue( + state.commands.contains { command in + command.contains("notify_target \(workspaceId) \(surfaceId) Codex|Network error|Stream disconnected before completion.") + }, + "Expected monitor to ignore old terminal state and report the current unscoped stream error, saw \(state.commands)" + ) + XCTAssertTrue( + state.commands.contains { command in + command.contains("set_status codex Codex network error") && + command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--color=#FF453A") && + command.contains("--priority=100") && + command.contains("--tab=\(workspaceId)") + }, + "Expected monitor to publish current unscoped Codex network error status, saw \(state.commands)" + ) + } + func testPTYBridgeFlushesReadyBeforeImmediateExit() throws { let rpcClient = ImmediateExitPTYBridgeRPC() let stopped = DispatchSemaphore(value: 0) diff --git a/tests/test_bash_integration_no_done_notifications.py b/tests/test_bash_integration_no_done_notifications.py index f9155520a533..debe8cdbf5be 100644 --- a/tests/test_bash_integration_no_done_notifications.py +++ b/tests/test_bash_integration_no_done_notifications.py @@ -42,30 +42,71 @@ def __enter__(self) -> "InteractiveBash": return self def __exit__(self, exc_type, exc, tb) -> None: + pid = self.pid if self.fd is not None: try: os.write(self.fd, b"exit\n") self._read_until(b"exit", timeout=1) except OSError: pass + if pid is not None: + self._wait_or_terminate(pid) + if self.fd is not None: try: os.close(self.fd) except OSError: pass - if self.pid is not None: + + def _wait_or_terminate(self, pid: int) -> None: + deadline = time.time() + 2 + while time.time() < deadline: + try: + waited, _ = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + return + if waited == pid: + return + time.sleep(0.05) + + try: + process_group = os.getpgid(pid) + except ProcessLookupError: + process_group = None + if process_group is not None and process_group != os.getpgrp(): try: - os.killpg(os.getpgid(self.pid), signal.SIGKILL) + os.killpg(process_group, signal.SIGTERM) except ProcessLookupError: pass - except OSError: - try: - os.kill(self.pid, signal.SIGKILL) - except ProcessLookupError: - pass + else: + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + + deadline = time.time() + 1 + while time.time() < deadline: try: - os.waitpid(self.pid, 0) + waited, _ = os.waitpid(pid, os.WNOHANG) except ChildProcessError: + return + if waited == pid: + return + time.sleep(0.05) + + if process_group is not None and process_group != os.getpgrp(): + try: + os.killpg(process_group, signal.SIGKILL) + except ProcessLookupError: + pass + else: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: pass + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass def run(self, command: str, timeout: float = 5) -> None: if self.fd is None: diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index b2b66d40bafb..4bf1fc3dac7d 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -519,11 +519,12 @@ def test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures: failures, ) hooks = settings.get("hooks", {}) - expected_hooks = {"SessionStart", "Stop", "SubagentStop", "SessionEnd", "Notification", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest"} + expected_hooks = {"SessionStart", "Stop", "StopFailure", "SubagentStop", "SessionEnd", "Notification", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest"} expect(set(hooks.keys()) == expected_hooks, f"unexpected hook keys: {hooks.keys()}, expected {expected_hooks}", failures) for hook_name, expected_subcommand in { "SessionStart": "session-start", "Stop": "stop", + "StopFailure": "stop-failure", "SessionEnd": "session-end", "Notification": "notification", "UserPromptSubmit": "prompt-submit", @@ -631,7 +632,7 @@ def test_live_socket_merges_user_settings_into_hooks(failures: list[str]) -> Non failures, ) expected_hooks = { - "SessionStart", "Stop", "SubagentStop", "SessionEnd", + "SessionStart", "Stop", "StopFailure", "SubagentStop", "SessionEnd", "Notification", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", } expect(