From 6667b65882f765ab2aca74b97bf654aa42f1e0e6 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 01:32:42 -0700 Subject: [PATCH 1/7] Add subagent and waiting agent states to the sidebar The compact status glyph had one "running" state, so a pane running a fan-out of subagents, a pane parked on a background command, and a pane typing a reply all looked identical. Two of those are worth telling apart: subagent work is the loudest thing an agent does, and a pane waiting on a deterministic wakeup is not asking for anything. Claude's hooks now report what a running pane is running on through a new `set_status --work=running|subagents|waiting` option: - PreToolUse with `tool_name` of `Task` reports subagents. A Task call blocks the parent inside the tool until its subagents finish, so the state holds for exactly that span and the next parent hook clears it. No counter to drift. - Stop with a live background task or scheduled wakeup reports waiting instead of running. A re-entrant Stop stays running: that is the agent itself still going. The work state rides alongside the agent lifecycle rather than inside it. A waiting pane keeps reporting a running lifecycle on purpose, so hibernation can never SIGTERM live background work; the work state is presentational only, and the resolver reads it before the lifecycle branch. Waiting wins only when every agent in the workspace reports it, so one agent still working keeps the row running. Glyphs: subagents is a pulsing gray connected-points symbol, waiting is a still gray hourglass. Waiting does not pulse, because the agent is parked and a pulsing hourglass would claim otherwise. Both are configurable through `sidebar.compactStatusIcons`, and both reach the non-compact rows through the icon the hook sends. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit b4bee23803fb41edc8cc9a563cdaecc1833ea571) --- CLI/CMUXCLI+AgentHookStopStatus.swift | 9 + CLI/CMUXCLI+ClaudeHookStopFailure.swift | 6 +- CLI/cmux.swift | 35 +++- ...CommandCoordinator+SidebarMetadataV1.swift | 17 +- .../ControlSidebarAgentWorkState.swift | 13 ++ .../Sidebar/ControlSidebarContext.swift | 3 +- ...mmandContextTestStubs+SidebarBrowser.swift | 3 +- ...trolCommandCoordinatorSidebarV1Tests.swift | 44 +++++ .../FakeSidebarV1ControlCommandContext.swift | 24 +++ .../CmuxConfigSchema.generated.swift | 2 + .../Status/SidebarAgentWorkState.swift | 37 ++++ .../Status/SidebarStatusEntry.swift | 8 +- Resources/Localizable.xcstrings | 118 ++++++++++++ .../SidebarCompactStatusGlyph+Resolve.swift | 17 +- .../Sidebar/SidebarCompactStatusGlyph.swift | 42 ++-- ...inalController+ControlSidebarContext.swift | 10 +- Sources/TerminalController.swift | 24 ++- cmux.xcodeproj/project.pbxproj | 8 + ...entNotificationMutationBoundaryTests.swift | 9 +- .../ClaudeBackgroundWorkNotifyTests.swift | 14 +- cmuxTests/ClaudeHookWorkStateTests.swift | 82 ++++++++ cmuxTests/SidebarAgentWorkStateTests.swift | 179 ++++++++++++++++++ docs/configuration.md | 4 +- .../harnesses/sidebar-compact-status.swift | 2 + web/data/cmux.schema.json | 2 + 25 files changed, 669 insertions(+), 43 deletions(-) create mode 100644 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarAgentWorkState.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/SidebarAgentWorkState.swift create mode 100644 cmuxTests/ClaudeHookWorkStateTests.swift create mode 100644 cmuxTests/SidebarAgentWorkStateTests.swift diff --git a/CLI/CMUXCLI+AgentHookStopStatus.swift b/CLI/CMUXCLI+AgentHookStopStatus.swift index 5082a3af032e..e2e3d532a203 100644 --- a/CLI/CMUXCLI+AgentHookStopStatus.swift +++ b/CLI/CMUXCLI+AgentHookStopStatus.swift @@ -1,5 +1,14 @@ import Foundation +/// What a running agent is working on, as the sidebar's `set_status --work` +/// option spells it. The CLI does not link the sidebar package, so these raw +/// values are the wire contract with the app's `SidebarAgentWorkState`. +enum AgentSidebarWorkState: String { + case running + case subagents + case waiting +} + extension CMUXCLI { /// Restores the shared needs-input status after a completion Stop that /// followed an attention request in the same turn. diff --git a/CLI/CMUXCLI+ClaudeHookStopFailure.swift b/CLI/CMUXCLI+ClaudeHookStopFailure.swift index 83cb529bab7f..edd278015f9f 100644 --- a/CLI/CMUXCLI+ClaudeHookStopFailure.swift +++ b/CLI/CMUXCLI+ClaudeHookStopFailure.swift @@ -10,7 +10,8 @@ extension CMUXCLI { icon: String, color: String, pid: Int? = nil, - priority: Int? = nil + priority: Int? = nil, + workState: AgentSidebarWorkState? = nil ) throws { // The socket tokenizer opens a quoted token at a bare ' or ", so a // localized value such as "Erreur d'API" would swallow every option @@ -22,6 +23,9 @@ extension CMUXCLI { if let priority { cmd += " --priority=\(priority)" } + if let workState { + cmd += " --work=\(workState.rawValue)" + } cmd += " --tab=\(workspaceId)\(socketPanelOption(surfaceId))" if let pid, ProcessInfo.processInfo.environment[agentHookRelayOriginEnvironmentKey] != "1" { diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4656b3af7061..a582fa8c29ae 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4369,6 +4369,9 @@ struct CMUXCLI { } static let claudeCodeStatusKey = "claude_code" + /// The SF Symbol a running-with-subagents row shows: several connected + /// points, distinct from the git-branch symbol the sidebar already uses. + static let subagentsStatusIcon = "point.3.filled.connected.trianglepath.dotted" private static func agentNotificationMeta( category: AgentHookNotifyCategory, @@ -28608,13 +28611,21 @@ struct CMUXCLI { // still live, so the pane is not idle — show it as still // running rather than the misleading "Idle". Reuse the shared // generic-agent status strings so the pill stays localized. + // + // A background task or cron is a deterministic wakeup the pane + // is parked on, which reads as Waiting. A re-entrant Stop + // (`stop_hook_active`) is not: the agent itself is still going. + let isWaitingOnBackgroundWork = hasPendingBackgroundWork try? setClaudeStatus( client: client, workspaceId: workspaceId, surfaceId: surfaceId, - value: String(localized: "agent.generic.status.running", defaultValue: "Running"), - icon: "bolt.fill", - color: "#4C8DFF" + value: isWaitingOnBackgroundWork + ? String(localized: "agent.generic.status.waiting", defaultValue: "Waiting") + : String(localized: "agent.generic.status.running", defaultValue: "Running"), + icon: isWaitingOnBackgroundWork ? "hourglass" : "bolt.fill", + color: isWaitingOnBackgroundWork ? "#8E8E93" : "#4C8DFF", + workState: isWaitingOnBackgroundWork ? .waiting : .running ) } else { try? setClaudeStatus( @@ -28809,7 +28820,8 @@ struct CMUXCLI { surfaceId: surfaceId, value: "Running", icon: "bolt.fill", - color: "#4C8DFF" + color: "#4C8DFF", + workState: .running ) printClaudeHookAck() @@ -29504,10 +29516,20 @@ struct CMUXCLI { telemetry: telemetry ) + // A `Task` call blocks the parent inside the tool until its + // subagents finish, so no other parent hook can fire meanwhile: + // the subagent state holds for exactly that span, and the next + // parent PreToolUse or Stop clears it. No counter to drift. + let runsSubagents = (parsedInput.object?["tool_name"] as? String) == "Task" let statusValue: String if UserDefaults.standard.bool(forKey: "claudeCodeVerboseStatus"), let toolStatus = describeToolUse(parsedInput.object) { statusValue = toolStatus + } else if runsSubagents { + statusValue = String( + localized: "agent.generic.status.runningSubagents", + defaultValue: "Running subagents" + ) } else { statusValue = "Running" } @@ -29516,9 +29538,10 @@ struct CMUXCLI { workspaceId: workspaceId, surfaceId: surfaceId, value: statusValue, - icon: "bolt.fill", + icon: runsSubagents ? Self.subagentsStatusIcon : "bolt.fill", color: "#4C8DFF", - pid: claudePid + pid: claudePid, + workState: runsSubagents ? .subagents : .running ) printClaudeHookAck() diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarMetadataV1.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarMetadataV1.swift index d728b8446c4f..77e414157a7b 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarMetadataV1.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarMetadataV1.swift @@ -38,6 +38,16 @@ extension ControlCommandCoordinator { return "ERROR: Invalid metadata format '\(formatRaw)' — use: plain, markdown" } + let workState: ControlSidebarAgentWorkState? + if let rawWorkState = sidebarNormalizedOptionValue(parsed.options["work"]) { + guard let parsedWorkState = ControlSidebarAgentWorkState(rawValue: rawWorkState.lowercased()) else { + return "ERROR: Invalid work state '\(rawWorkState)' — use: running, subagents, waiting" + } + workState = parsedWorkState + } else { + workState = nil + } + let priority: Int if let rawPriority = sidebarNormalizedOptionValue(parsed.options["priority"]) { guard let parsedPriority = Int(rawPriority) else { @@ -66,7 +76,7 @@ extension ControlCommandCoordinator { } let panelResolution = sidebarParseOptionalPanelIdOption( options: parsed.options, - usage: "set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] [--panel=ID]" + usage: "set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--work=running|subagents|waiting] [--tab=X] [--panel=ID]" ) if let error = panelResolution.error { return error @@ -90,7 +100,8 @@ extension ControlCommandCoordinator { priority: priority, format: format, panelID: panelResolution.panelId, - pid: pidValue + pid: pidValue, + workState: workState ) return "OK" } @@ -131,7 +142,7 @@ extension ControlCommandCoordinator { nonisolated func sidebarSetStatus(_ args: String, context: (any ControlCommandContext)?) -> String { sidebarUpsertMetadata( args, - missingError: "ERROR: Missing status key or value — usage: set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]", + missingError: "ERROR: Missing status key or value — usage: set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--work=running|subagents|waiting] [--tab=X]", context: context ) } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarAgentWorkState.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarAgentWorkState.swift new file mode 100644 index 000000000000..dcb119afb4b9 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarAgentWorkState.swift @@ -0,0 +1,13 @@ +internal import Foundation + +/// What a running agent is running on (the typed twin of the app's +/// `SidebarAgentWorkState`; raw values match so the conformance can rebuild +/// the app enum losslessly). +public enum ControlSidebarAgentWorkState: String, Sendable, Equatable, CaseIterable { + /// The agent itself is working. + case running + /// The agent is working through background subagents. + case subagents + /// The agent is parked on a deterministic external event. + case waiting +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarContext.swift index f9455c91e953..6834e56bb17a 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarContext.swift @@ -59,7 +59,8 @@ public protocol ControlSidebarContext: AnyObject { priority: Int, format: ControlSidebarMetadataFormat, panelID: UUID?, - pid: Int32? + pid: Int32?, + workState: ControlSidebarAgentWorkState? ) /// Enqueues the `clear_status`/`clear_meta` removal mutation. diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+SidebarBrowser.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+SidebarBrowser.swift index 4c683b124318..69a4cab9b5c3 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+SidebarBrowser.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+SidebarBrowser.swift @@ -58,7 +58,8 @@ extension ControlSidebarContext { priority: Int, format: ControlSidebarMetadataFormat, panelID: UUID?, - pid: Int32? + pid: Int32?, + workState: ControlSidebarAgentWorkState? ) {} nonisolated func controlSidebarScheduleStatusClear( diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSidebarV1Tests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSidebarV1Tests.swift index 7ad1659d4f07..8a671e8283f4 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSidebarV1Tests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSidebarV1Tests.swift @@ -42,6 +42,50 @@ struct ControlCommandCoordinatorSidebarV1Tests { #expect(context.statusClearCall?.panelID == panelID) } + @Test func statusUpsertForwardsAgentWorkState() { + let context = FakeSidebarV1ControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let workspaceID = UUID() + + let response = coordinator.handleSidebarV1( + command: "set_status", + args: "claude_code Running subagents --icon=bolt.fill --work=SUBAGENTS --tab=\(workspaceID.uuidString)" + ) + + #expect(response == "OK") + #expect(context.statusUpsertCall?.key == "claude_code") + #expect(context.statusUpsertCall?.value == "Running subagents") + #expect(context.statusUpsertCall?.workState == .subagents) + } + + /// Every existing reporter omits `--work`, and those rows must keep + /// resolving the way they did before the option existed. + @Test func statusUpsertWithoutWorkOptionForwardsNoWorkState() { + let context = FakeSidebarV1ControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + + let response = coordinator.handleSidebarV1( + command: "set_status", + args: "claude_code Running --icon=bolt.fill --tab=\(UUID().uuidString)" + ) + + #expect(response == "OK") + #expect(context.statusUpsertCall?.workState == nil) + } + + @Test func statusUpsertRejectsUnknownWorkStateBeforeMutation() { + let context = FakeSidebarV1ControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + + let response = coordinator.handleSidebarV1( + command: "set_status", + args: "claude_code Thinking --work=thinking --tab=\(UUID().uuidString)" + ) + + #expect(response.hasPrefix("ERROR: Invalid work state 'thinking'")) + #expect(context.statusUpsertCall == nil) + } + @Test func workspaceLoadingFailureReasonReturnsErrorLine() { let context = FakeSidebarV1ControlCommandContext() context.workspaceLoadingResult = ControlSidebarWorkspaceLoadingState( diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSidebarV1ControlCommandContext.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSidebarV1ControlCommandContext.swift index a815ed6222e0..6530c34a2493 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSidebarV1ControlCommandContext.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSidebarV1ControlCommandContext.swift @@ -21,6 +21,14 @@ final class FakeSidebarV1ControlCommandContext: ControlCommandContext { scope: ControlSidebarPanelScope, stateRawValue: String )? + nonisolated(unsafe) var statusUpsertCall: ( + target: ControlSidebarTabTarget, + key: String, + value: String, + icon: String?, + panelID: UUID?, + workState: ControlSidebarAgentWorkState? + )? nonisolated func controlSurfaceParseShellActivityState( _ rawState: String @@ -32,6 +40,22 @@ final class FakeSidebarV1ControlCommandContext: ControlCommandContext { } } + nonisolated func controlSidebarScheduleStatusUpsert( + target: ControlSidebarTabTarget, + key: String, + value: String, + icon: String?, + color: String?, + url: URL?, + priority: Int, + format: ControlSidebarMetadataFormat, + panelID: UUID?, + pid: Int32?, + workState: ControlSidebarAgentWorkState? + ) { + statusUpsertCall = (target, key, value, icon, panelID, workState) + } + nonisolated func controlSidebarScheduleStatusClear( target: ControlSidebarTabTarget, key: String, diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift index 51b259bb0190..6878d7c11a40 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift @@ -1458,7 +1458,9 @@ enum CmuxEmbeddedConfigSchema { "properties": { "error": { "type": "string", "minLength": 1 }, "needsInput": { "type": "string", "minLength": 1 }, + "subagents": { "type": "string", "minLength": 1 }, "running": { "type": "string", "minLength": 1 }, + "waiting": { "type": "string", "minLength": 1 }, "starting": { "type": "string", "minLength": 1 }, "unseen": { "type": "string", "minLength": 1 }, "pullRequestOpen": { "type": "string", "minLength": 1 }, diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/SidebarAgentWorkState.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/SidebarAgentWorkState.swift new file mode 100644 index 000000000000..3f924656e54a --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/SidebarAgentWorkState.swift @@ -0,0 +1,37 @@ +public import Foundation + +/// What a *running* agent is running on, as reported by its hook. +/// +/// This refines the running half of the sidebar's agent vocabulary; it never +/// replaces it. Needs input, idle and error stay where they already live (the +/// lifecycle state and the entry's own icon), and hibernation keeps reading +/// `AgentHibernationLifecycleState`, so a work state can never make a pane +/// with live work look hibernatable. +/// +/// An entry without a work state reads exactly as it did before: a plain +/// running row. An unrecognized value from a newer CLI parses to `nil` and +/// degrades the same way. +public enum SidebarAgentWorkState: String, Sendable, Equatable, CaseIterable { + /// The agent itself is working: a model turn or a tool call is in flight. + case running + /// The agent is working through background subagents it spawned. + case subagents + /// The turn is over but the agent is parked on a deterministic external + /// event it will be woken by: a background command, a scheduled wakeup, a + /// CI run. Not idle, and not hibernatable. + case waiting + + /// Parses a reported value, tolerating case and `-`/`_` spelling. + public static func parse(_ rawValue: String) -> SidebarAgentWorkState? { + let normalized = rawValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "_", with: "-") + switch normalized { + case "running": return .running + case "subagents", "subagent": return .subagents + case "waiting": return .waiting + default: return nil + } + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/SidebarStatusEntry.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/SidebarStatusEntry.swift index a90488ad979a..852643b7c793 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/SidebarStatusEntry.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/SidebarStatusEntry.swift @@ -19,6 +19,10 @@ public struct SidebarStatusEntry: Equatable, Sendable { public let format: SidebarMetadataFormat /// When the entry was reported. public let timestamp: Date + /// What the agent is running on, when the reporter said so. `nil` on every + /// entry that is not an agent row, and on agent rows from a reporter that + /// does not report it. + public let workState: SidebarAgentWorkState? /// Creates a status row (defaults mirror the legacy initializer). public init( @@ -29,7 +33,8 @@ public struct SidebarStatusEntry: Equatable, Sendable { url: URL? = nil, priority: Int = 0, format: SidebarMetadataFormat = .plain, - timestamp: Date = Date() + timestamp: Date = Date(), + workState: SidebarAgentWorkState? = nil ) { self.key = key self.value = value @@ -39,5 +44,6 @@ public struct SidebarStatusEntry: Equatable, Sendable { self.priority = priority self.format = format self.timestamp = timestamp + self.workState = workState } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d621e4f654a6..94d38429dd8d 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -583994,6 +583994,124 @@ } } } + }, + "agent.generic.status.runningSubagents": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running subagents" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تشغيل الوكلاء الفرعيين" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Subagenten werden ausgeführt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ejecutando subagentes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sous-agents en cours d’exécution" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サブエージェントを実行中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "하위 에이전트 실행 중" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在运行子代理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "執行子代理程式中" + } + } + } + }, + "agent.generic.status.waiting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "في الانتظار" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "待機中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대기 중" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待中" + } + } + } } }, "version": "1.0" diff --git a/Sources/Sidebar/SidebarCompactStatusGlyph+Resolve.swift b/Sources/Sidebar/SidebarCompactStatusGlyph+Resolve.swift index 6adc61ce5106..a9b1fe78d91f 100644 --- a/Sources/Sidebar/SidebarCompactStatusGlyph+Resolve.swift +++ b/Sources/Sidebar/SidebarCompactStatusGlyph+Resolve.swift @@ -39,12 +39,25 @@ extension SidebarCompactStatusGlyph { static func resolve(_ input: Input) -> SidebarCompactStatusGlyph { let kind: Kind + let workStates = input.agentEntries.compactMap(\.workState) + // Waiting reports a running lifecycle on purpose (a pane with live + // background work must not look hibernatable), so it has to be read + // off the entries before the lifecycle branch below, and only when + // every agent in the workspace reports it: one agent still working + // keeps the row running. + let everyAgentIsWaiting = !workStates.isEmpty + && workStates.count == input.agentEntries.count + && workStates.allSatisfy { $0 == .waiting } if input.agentEntries.contains(where: Self.reportsError) { kind = .error - } else if input.hasActiveAgent || input.lifecycleStates.contains(.running) || input.lifecycleStates.contains(.backgroundWorkPending) { - kind = .running } else if input.lifecycleStates.contains(.needsInput) { kind = .needsInput + } else if workStates.contains(.subagents) { + kind = .subagents + } else if everyAgentIsWaiting { + kind = .waiting + } else if input.hasActiveAgent || input.lifecycleStates.contains(.running) || input.lifecycleStates.contains(.backgroundWorkPending) { + kind = .running } else if input.lifecycleStates.contains(.unknown) { kind = .pending // A stale pull request is data repeated refresh failures could not diff --git a/Sources/Sidebar/SidebarCompactStatusGlyph.swift b/Sources/Sidebar/SidebarCompactStatusGlyph.swift index a3b3f4469ca6..5521739df306 100644 --- a/Sources/Sidebar/SidebarCompactStatusGlyph.swift +++ b/Sources/Sidebar/SidebarCompactStatusGlyph.swift @@ -24,18 +24,22 @@ import Foundation /// 1. Error (an agent reported a failure): red warning triangle. Only for /// something that broke. /// 2. Needs input: amber dot. -/// 3. Running: pulsing gray dot. It replaces the row's loading spinner. -/// 4. Starting (agent present, state not reported yet): hollow ring. -/// 5. Unseen (unread notifications): blue dot. Applied by the row, which owns +/// 3. Running through subagents: pulsing gray connected-points symbol. The +/// agent is working, but through background agents it spawned. +/// 4. Running: pulsing gray dot. It replaces the row's loading spinner. +/// 5. Waiting on a deterministic wakeup (a background command, a scheduled +/// wakeup, a CI run): gray hourglass. Not your turn, and not finished. +/// 6. Starting (agent present, state not reported yet): hollow ring. +/// 7. Unseen (unread notifications): blue dot. Applied by the row, which owns /// the unread count; see ``applyingUnread(_:latestNotificationText:)``. It /// outranks "starting", which asks for nothing. -/// 6. Pull request: merged purple, open gray, closed gray with a minus badge. +/// 8. Pull request: merged purple, open gray, closed gray with a minus badge. /// cmux does not fetch CI or mergeability for a pull request, so there is /// no passing/failing/conflict glyph: adding one would advertise a color /// no user could see. See #12807. -/// 7. Agent idle (done and seen): gray checkmark. -/// 8. Branch, no pull request: gray branch. -/// 9. Otherwise, a plain terminal: nothing, so the title starts at the +/// 9. Agent idle (done and seen): gray checkmark. +/// 10. Branch, no pull request: gray branch. +/// 11. Otherwise, a plain terminal: nothing, so the title starts at the /// row's edge (a `terminal` entry in `sidebar.compactStatusIcons` adds one). /// Only the three agent states Claude marks with dots (needs input, unseen, /// running) are dots; everything settled gets a symbol that says what it is. @@ -43,7 +47,9 @@ struct SidebarCompactStatusGlyph: Equatable, Hashable { enum Kind: Equatable, Hashable { case error case needsInput + case subagents case running + case waiting case pending case unseen case pullRequest(PullRequestState) @@ -69,7 +75,9 @@ struct SidebarCompactStatusGlyph: Equatable, Hashable { enum IconSlot: String, CaseIterable { case error case needsInput + case subagents case running + case waiting case starting case unseen case pullRequestOpen @@ -84,7 +92,9 @@ struct SidebarCompactStatusGlyph: Equatable, Hashable { switch kind { case .error: return .error case .needsInput: return .needsInput + case .subagents: return .subagents case .running: return .running + case .waiting: return .waiting case .pending: return .starting case .unseen: return .unseen case .pullRequest(.open): return .pullRequestOpen @@ -135,6 +145,8 @@ struct SidebarCompactStatusGlyph: Equatable, Hashable { case .pullRequest(.merged): return SidebarCompactStatusDrawnGlyph.merge.rawValue case .pullRequest: return SidebarCompactStatusDrawnGlyph.pullRequest.rawValue case .pending: return "circle.dashed" + case .subagents: return "point.3.filled.connected.trianglepath.dotted" + case .waiting: return "hourglass" case .needsInput, .running, .unseen: return "circle.fill" case .idle: return "checkmark.circle" case .branch: return "arrow.triangle.branch" @@ -178,8 +190,9 @@ struct SidebarCompactStatusGlyph: Equatable, Hashable { kind != .terminal || customSymbolName != nil } - /// Whether the glyph pulses (the running indicator). - var pulses: Bool { kind == .running } + /// Whether the glyph pulses (the running indicators). Waiting does not: + /// the agent is parked, and a pulsing hourglass would claim otherwise. + var pulses: Bool { kind == .running || kind == .subagents } /// Unread notifications turn a settled row blue; agent activity and /// errors stay louder. The latest notification leads the tooltip, since @@ -196,7 +209,7 @@ struct SidebarCompactStatusGlyph: Equatable, Hashable { // states that ask for attention. case .pullRequest, .idle, .branch, .terminal, .pending: return SidebarCompactStatusGlyph(kind: .unseen, tooltip: unreadTooltip, iconOverrides: iconOverrides) - case .error, .needsInput, .running, .unseen: + case .error, .needsInput, .subagents, .running, .waiting, .unseen: return SidebarCompactStatusGlyph(kind: kind, tooltip: unreadTooltip, iconOverrides: iconOverrides) } } @@ -214,8 +227,10 @@ struct SidebarCompactStatusGlyph: Equatable, Hashable { switch kind { case .error: return 0 case .needsInput: return 1 - case .running: return 2 - case .unseen: return 3 + case .subagents: return 2 + case .running: return 3 + case .waiting: return 4 + case .unseen: return 5 case .pending, .pullRequest, .idle, .branch, .terminal: return nil } } @@ -280,7 +295,8 @@ struct SidebarCompactStatusGlyph: Equatable, Hashable { case .needsInput: return Self.needsInputColor case .unseen: return .systemBlue case .pullRequest(.merged): return .systemPurple - case .running, .pending, .idle, .branch, .terminal, .pullRequest(.open), .pullRequest(.closed): + case .subagents, .running, .waiting, .pending, .idle, .branch, .terminal, + .pullRequest(.open), .pullRequest(.closed): return secondary } } diff --git a/Sources/TerminalController+ControlSidebarContext.swift b/Sources/TerminalController+ControlSidebarContext.swift index 96f2c728fecc..ba629a067aec 100644 --- a/Sources/TerminalController+ControlSidebarContext.swift +++ b/Sources/TerminalController+ControlSidebarContext.swift @@ -26,9 +26,11 @@ extension TerminalController: ControlSidebarContext { priority: Int, format: ControlSidebarMetadataFormat, panelID: UUID?, - pid: Int32? + pid: Int32?, + workState: ControlSidebarAgentWorkState? ) { let appFormat = SidebarMetadataFormat(rawValue: format.rawValue) ?? .plain + let appWorkState = workState.flatMap { SidebarAgentWorkState(rawValue: $0.rawValue) } controlSidebarSchedulePanelOwnedMutation(target: target, panelID: panelID) { _, owner in guard Self.shouldReplaceStatusEntry( current: owner.statusEntry(key: key, panelId: panelID), @@ -38,7 +40,8 @@ extension TerminalController: ControlSidebarContext { color: color, url: url, priority: priority, - format: appFormat + format: appFormat, + workState: appWorkState ) else { // Still update PID tracking even if the status display hasn't changed. if let pid { @@ -54,7 +57,8 @@ extension TerminalController: ControlSidebarContext { url: url, priority: priority, format: appFormat, - timestamp: Date() + timestamp: Date(), + workState: appWorkState ), key: key, panelId: panelID) if let pid { owner.recordAgentPID(key: key, pid: pid, panelId: panelID) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 783c4b39442a..fe99019823d5 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -902,7 +902,8 @@ class TerminalController { color: String?, url: URL?, priority: Int, - format: SidebarMetadataFormat + format: SidebarMetadataFormat, + workState: SidebarAgentWorkState? ) -> Bool { guard let current else { return true } return current.key != key || @@ -911,7 +912,8 @@ class TerminalController { current.color != color || current.url != url || current.priority != priority || - current.format != format + current.format != format || + current.workState != workState } nonisolated static func shouldReplaceMetadataBlock( @@ -14691,6 +14693,16 @@ class TerminalController { return "ERROR: Invalid metadata format '\(formatRaw)' — use: plain, markdown" } + let workState: SidebarAgentWorkState? + if let rawWorkState = normalizedOptionValue(parsed.options["work"]) { + guard let parsedWorkState = SidebarAgentWorkState.parse(rawWorkState) else { + return "ERROR: Invalid work state '\(rawWorkState)' — use: running, subagents, waiting" + } + workState = parsedWorkState + } else { + workState = nil + } + let priority: Int if let rawPriority = normalizedOptionValue(parsed.options["priority"]) { guard let parsedPriority = Int(rawPriority) else { @@ -14719,7 +14731,7 @@ class TerminalController { } let panelResolution = parseOptionalPanelIdOption( options: parsed.options, - usage: "set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] [--panel=ID]" + usage: "set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--work=running|subagents|waiting] [--tab=X] [--panel=ID]" ) if let error = panelResolution.error { return error @@ -14745,7 +14757,8 @@ class TerminalController { color: color, url: parsedURL, priority: priority, - format: format + format: format, + workState: workState ) else { // Still update PID tracking even if the status display hasn't changed. if let pidValue { @@ -14761,7 +14774,8 @@ class TerminalController { url: parsedURL, priority: priority, format: format, - timestamp: Date() + timestamp: Date(), + workState: workState ) if let pidValue { tab.recordAgentPID(key: key, pid: pidValue, panelId: panelResolution.panelId) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 7a78a77cf35c..ae12b5d37b33 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -580,6 +580,7 @@ 89930004AABBCCDDEEFF0001 /* ClaudeHookSessionStoreFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89930004AABBCCDDEEFF0002 /* ClaudeHookSessionStoreFile.swift */; }; A5D41220A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41221A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift */; }; CC7FDF58CBB0EDDE5DADE167 /* ClaudeHookSurfaceSocketState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1136E25CD5300988F9F2162 /* ClaudeHookSurfaceSocketState.swift */; }; + F6332A8451C63D5C64997403 /* ClaudeHookWorkStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C851974591E32B011E9704A7 /* ClaudeHookWorkStateTests.swift */; }; A5D41222A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */; }; 97CEF52570A6C3AFB83E1A7C /* ClaudeStopFailureStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEAB02E73CAA231F5E266CF7 /* ClaudeStopFailureStatusTests.swift */; }; A9F200000000000000000015 /* ClaudeStreamJSONAccumulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000015 /* ClaudeStreamJSONAccumulator.swift */; }; @@ -3004,6 +3005,7 @@ A6AC71020000000000000001 /* SidebarAgentActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC71030000000000000001 /* SidebarAgentActivityIndicator.swift */; }; A6AC71040000000000000001 /* SidebarAgentActivitySummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC71050000000000000001 /* SidebarAgentActivitySummary.swift */; }; 08F11E56C54306DB62A5BFC4 /* SidebarAgentProfileLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE995EC3107F5E9B19839CCF /* SidebarAgentProfileLabel.swift */; }; + A4C6D5D1089BA194936B5100 /* SidebarAgentWorkStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3917E88FC3A4BFAD8ED176 /* SidebarAgentWorkStateTests.swift */; }; 211A75777F433ED8BA5AE208 /* SidebarAppearanceSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */; }; B804C0030000000000000003 /* SidebarAppKitRowCellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804D0030000000000000003 /* SidebarAppKitRowCellTests.swift */; }; D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */; }; @@ -4875,6 +4877,7 @@ 89930004AABBCCDDEEFF0002 /* ClaudeHookSessionStoreFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHookSessionStoreFile.swift; sourceTree = ""; }; A5D41221A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHookSurfaceResolutionSwiftTests.swift; sourceTree = ""; }; D1136E25CD5300988F9F2162 /* ClaudeHookSurfaceSocketState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ClaudeHookSurfaceSocketState.swift"; sourceTree = ""; }; + C851974591E32B011E9704A7 /* ClaudeHookWorkStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ClaudeHookWorkStateTests.swift"; sourceTree = ""; }; A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeNotificationStatusLifecycleTests.swift; sourceTree = ""; }; DEAB02E73CAA231F5E266CF7 /* ClaudeStopFailureStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ClaudeStopFailureStatusTests.swift"; sourceTree = ""; }; A9F100000000000000000015 /* ClaudeStreamJSONAccumulator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ClaudeStreamJSONAccumulator.swift; sourceTree = ""; }; @@ -7145,6 +7148,7 @@ A6AC71030000000000000001 /* SidebarAgentActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarAgentActivityIndicator.swift; sourceTree = ""; }; A6AC71050000000000000001 /* SidebarAgentActivitySummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarAgentActivitySummary.swift; sourceTree = ""; }; DE995EC3107F5E9B19839CCF /* SidebarAgentProfileLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarAgentProfileLabel.swift; sourceTree = ""; }; + 2D3917E88FC3A4BFAD8ED176 /* SidebarAgentWorkStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarAgentWorkStateTests.swift"; sourceTree = ""; }; 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarAppearanceSupport.swift; sourceTree = ""; }; B804D0030000000000000003 /* SidebarAppKitRowCellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarAppKitRowCellTests.swift; sourceTree = ""; }; D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift; sourceTree = ""; }; @@ -12655,6 +12659,8 @@ 08202CA6A24B08873B5CC6FD /* GhosttyDialogThemeTests.swift */, 4AD84F07ADB08D5E8EBAA10D /* DeviceDiscoverabilityGatingTests.swift */, 34A1987FFA112D2457B20A9C /* LocalTerminalSizingHostLifecycleTests.swift */, + C851974591E32B011E9704A7 /* ClaudeHookWorkStateTests.swift */, + 2D3917E88FC3A4BFAD8ED176 /* SidebarAgentWorkStateTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -16431,6 +16437,7 @@ 5F0C70BF9BF7328093200E72 /* ClaudeHookSessionLivenessTests.swift in Sources */, A5D41220A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift in Sources */, CC7FDF58CBB0EDDE5DADE167 /* ClaudeHookSurfaceSocketState.swift in Sources */, + F6332A8451C63D5C64997403 /* ClaudeHookWorkStateTests.swift in Sources */, A5D41222A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift in Sources */, 97CEF52570A6C3AFB83E1A7C /* ClaudeStopFailureStatusTests.swift in Sources */, 9758A0000000000000000004 /* CLIAmpLifecycleIntegrationTests.swift in Sources */, @@ -17170,6 +17177,7 @@ F6001000A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift in Sources */, 29B9EA7A64AF6741463D582B /* SidebarAccessibilityTreeTests.swift in Sources */, 18E25585AB1083F87E29B06F /* SidebarAccessibilityTreeWalk.swift in Sources */, + A4C6D5D1089BA194936B5100 /* SidebarAgentWorkStateTests.swift in Sources */, B804C0030000000000000003 /* SidebarAppKitRowCellTests.swift in Sources */, 9BE45B94B05D471FA386203D /* SidebarCloudWorkspaceBadgeTests.swift in Sources */, 5EB96EEB7197F4186489176F /* SidebarCompactAgentStatusTests.swift in Sources */, diff --git a/cmuxTests/AgentNotificationMutationBoundaryTests.swift b/cmuxTests/AgentNotificationMutationBoundaryTests.swift index 2f96232df550..b505e6a49677 100644 --- a/cmuxTests/AgentNotificationMutationBoundaryTests.swift +++ b/cmuxTests/AgentNotificationMutationBoundaryTests.swift @@ -215,7 +215,8 @@ extension AgentNotificationRegressionTests { priority: 0, format: .plain, panelID: fixture.panelId, - pid: nil + pid: nil, + workState: nil ) TerminalController.shared.controlSidebarScheduleAgentPIDRecord( target: .workspace(dockOwnerId), @@ -267,7 +268,8 @@ extension AgentNotificationRegressionTests { priority: 0, format: .plain, panelID: fixture.panelId, - pid: nil + pid: nil, + workState: nil ) TerminalController.shared.controlSidebarScheduleAgentLifecycle( target: .workspace(dockOwnerId), @@ -591,7 +593,8 @@ extension AgentNotificationRegressionTests { priority: 0, format: .plain, panelID: fixture.panelId, - pid: 43_210 + pid: 43_210, + workState: nil ) TerminalController.shared.controlSidebarScheduleAgentLifecycle( target: .workspace(fixture.source.id), diff --git a/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift index fa56ca67ec86..b07b61512541 100644 --- a/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift +++ b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift @@ -22,6 +22,10 @@ struct ClaudeBackgroundWorkNotifyTests { // must not mark the completion as pending or poison the later idle signal. #expect(notifyLine(result.snapshot, containing: "c=turn-complete;p=0") != nil) #expect(journalEvent(result.snapshot, kind: "agent.turn.completed", pendingWork: false) != nil) + // A re-entrant Stop is the agent itself still going, not a pane parked + // on a deterministic wakeup, so it stays Running. + #expect(statusLine(result.snapshot, value: "Running") != nil) + #expect(statusLine(result.snapshot, value: "Waiting") == nil) } private func statusLine(_ snapshot: [String], value: String) -> String? { @@ -102,10 +106,14 @@ struct ClaudeBackgroundWorkNotifyTests { "Stop with a running background task must tag the done-ping pending; saw \(snapshot)" ) #expect(cached == true) - // Sidebar pill must not say "Idle" while background work is live. - #expect(statusLine(snapshot, value: "Running") != nil, - "Pending stop must show a Running pill, not Idle; saw \(snapshot)") + // Sidebar pill must not say "Idle" while background work is live. A + // live background task is a deterministic wakeup, so the pane reads as + // Waiting rather than Running, and reports that to the compact glyph. + #expect(statusLine(snapshot, value: "Waiting") != nil, + "Pending stop must show a Waiting pill, not Idle; saw \(snapshot)") #expect(statusLine(snapshot, value: "Idle") == nil) + #expect(lastLine(snapshot, prefix: "set_status claude_code Waiting ")?.contains("--work=waiting") == true, + "The Waiting pill must carry the work state the sidebar glyph reads; saw \(snapshot)") // And the journaled turn boundary must carry pending_work=true so the // reduced lifecycle stays running (non-hibernatable) while the // background task is live. diff --git a/cmuxTests/ClaudeHookWorkStateTests.swift b/cmuxTests/ClaudeHookWorkStateTests.swift new file mode 100644 index 000000000000..69841fcc3238 --- /dev/null +++ b/cmuxTests/ClaudeHookWorkStateTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing + +/// The Claude hooks that tell the sidebar what a *running* pane is running on: +/// `--work=subagents` while a `Task` call is in flight, and plain +/// `--work=running` otherwise. The waiting half (a Stop with live background +/// work) is covered in `ClaudeBackgroundWorkNotifyTests`. +@Suite(.serialized) +struct ClaudeHookWorkStateTests { + private typealias Harness = ClaudeHookLiveDeliveryHarness + + private static let workspaceId = "11111111-1111-1111-1111-111111111111" + private static let surfaceId = "22222222-2222-2222-2222-222222222222" + + /// Runs one PreToolUse hook for `toolName` and returns the commands the + /// mock app saw. + private func runPreToolUse(name: String, toolName: String, pid: String) throws -> [String] { + let context = try Harness.makeContext(name: name) + defer { context.cleanup() } + let sessionId = "\(name)-session" + + try Harness.writeSessionStore( + to: context.storeURL, + sessionId: sessionId, + workspaceId: Self.workspaceId, + surfaceId: Self.surfaceId, + cwd: context.root.path + ) + let serverHandled = Harness.startDeliveryTargetServer( + context: context, + surfacesByWorkspace: [Self.workspaceId: [Self.surfaceId]], + pidTarget: nil, + surfaceTargets: [Self.surfaceId: Self.workspaceId] + ) + + var environment = Harness.hookEnvironment(context: context) + environment["CMUX_WORKSPACE_ID"] = Self.workspaceId + environment["CMUX_SURFACE_ID"] = Self.surfaceId + environment["CMUX_CLAUDE_PID"] = pid + + let result = Harness.runHookProcess( + context: context, + arguments: ["hooks", "claude", "pre-tool-use"], + environment: environment, + standardInput: #"{"session_id":"\#(sessionId)","hook_event_name":"PreToolUse","tool_name":"\#(toolName)","cwd":"\#(context.root.path)"}"# + ) + + #expect(serverHandled.wait(timeout: .now() + 5) == .success) + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + return context.state.snapshot() + } + + private func statusLine(_ commands: [String]) -> String? { + commands.last { $0.hasPrefix("set_status claude_code ") } + } + + /// A `Task` call blocks the parent inside the tool until its subagents + /// finish, so the subagent state holds for exactly that span. + @Test func taskToolReportsRunningSubagents() throws { + let commands = try runPreToolUse(name: "work-state-task", toolName: "Task", pid: "43401") + let status = statusLine(commands) + #expect(status?.hasPrefix("set_status claude_code Running subagents ") == true, + "A Task call must say the agent is running subagents; saw \(commands)") + #expect(status?.contains("--work=subagents") == true, + "The subagents pill must carry the work state the glyph reads; saw \(commands)") + #expect(status?.contains("--icon=point.3.filled.connected.trianglepath.dotted") == true, + "The subagents pill must use the connected-points symbol; saw \(commands)") + } + + /// Every other tool is the agent working directly, which must keep the + /// pill and glyph it had before the work state existed. + @Test func ordinaryToolReportsPlainRunning() throws { + let commands = try runPreToolUse(name: "work-state-bash", toolName: "Bash", pid: "43402") + let status = statusLine(commands) + #expect(status?.hasPrefix("set_status claude_code Running ") == true, + "An ordinary tool must keep the plain Running pill; saw \(commands)") + #expect(status?.contains("--work=running") == true) + #expect(status?.contains("--icon=bolt.fill") == true) + #expect(status?.contains("subagents") == false) + } +} diff --git a/cmuxTests/SidebarAgentWorkStateTests.swift b/cmuxTests/SidebarAgentWorkStateTests.swift new file mode 100644 index 000000000000..67f4df72db0f --- /dev/null +++ b/cmuxTests/SidebarAgentWorkStateTests.swift @@ -0,0 +1,179 @@ +import AppKit +import CmuxSidebar +import Testing +@testable import cmux_DEV + +/// The two richer running states an agent hook can report through +/// `set_status --work=`: running through subagents, and waiting on a +/// deterministic wakeup. +@Suite(.serialized) +@MainActor +struct SidebarAgentWorkStateTests { + private typealias Glyph = SidebarCompactStatusGlyph + + private static func entry( + _ key: String, + _ value: String, + workState: SidebarAgentWorkState? + ) -> SidebarStatusEntry { + SidebarStatusEntry(key: key, value: value, icon: "bolt.fill", color: "#4C8DFF", workState: workState) + } + + // MARK: Parsing + + @Test + func parseAcceptsTheReportedSpellingsAndRejectsEverythingElse() { + #expect(SidebarAgentWorkState.parse("running") == .running) + #expect(SidebarAgentWorkState.parse(" Subagents ") == .subagents) + #expect(SidebarAgentWorkState.parse("WAITING") == .waiting) + // A value from a newer CLI degrades to a plain running row. + #expect(SidebarAgentWorkState.parse("compacting") == nil) + #expect(SidebarAgentWorkState.parse("") == nil) + } + + // MARK: Resolution + + @Test + func subagentsOutranksRunning() { + let glyph = Glyph.resolve(Glyph.Input( + agentEntries: [Self.entry("claude_code", "Running subagents", workState: .subagents)], + lifecycleStates: [.running], + hasActiveAgent: true + )) + #expect(glyph.kind == .subagents) + } + + /// Waiting reports a running lifecycle on purpose: a pane with live + /// background work must not look hibernatable. The glyph still has to say + /// waiting, which is the regression this whole state exists for. + @Test + func waitingWinsOverItsOwnRunningLifecycle() { + let glyph = Glyph.resolve(Glyph.Input( + agentEntries: [Self.entry("claude_code", "Waiting", workState: .waiting)], + lifecycleStates: [.running], + hasActiveAgent: true + )) + #expect(glyph.kind == .waiting) + } + + @Test + func oneAgentStillWorkingKeepsTheRowRunning() { + let glyph = Glyph.resolve(Glyph.Input( + agentEntries: [ + Self.entry("claude_code", "Waiting", workState: .waiting), + Self.entry("codex", "Running", workState: .running), + ], + lifecycleStates: [.running], + hasActiveAgent: true + )) + #expect(glyph.kind == .running) + } + + /// An agent that reports no work state says nothing about whether it is + /// parked, so its workspace cannot be called waiting on the strength of + /// another agent's report. + @Test + func anAgentWithoutAWorkStateKeepsTheRowRunning() { + let glyph = Glyph.resolve(Glyph.Input( + agentEntries: [ + Self.entry("claude_code", "Waiting", workState: .waiting), + Self.entry("codex", "Running", workState: nil), + ], + lifecycleStates: [.running], + hasActiveAgent: true + )) + #expect(glyph.kind == .running) + } + + @Test + func needsInputAndErrorStillOutrankBothNewStates() { + #expect(Glyph.resolve(Glyph.Input( + agentEntries: [Self.entry("claude_code", "Running subagents", workState: .subagents)], + lifecycleStates: [.needsInput, .running], + hasActiveAgent: true + )).kind == .needsInput) + #expect(Glyph.resolve(Glyph.Input( + agentEntries: [ + SidebarStatusEntry( + key: "claude_code", + value: "Failed", + icon: "exclamationmark.triangle.fill", + workState: .waiting + ) + ], + lifecycleStates: [.running], + hasActiveAgent: true + )).kind == .error) + } + + @Test + func anEntryWithoutAWorkStateResolvesExactlyAsBefore() { + let glyph = Glyph.resolve(Glyph.Input( + agentEntries: [Self.entry("claude_code", "Running", workState: nil)], + lifecycleStates: [.running], + hasActiveAgent: true + )) + #expect(glyph.kind == .running) + } + + // MARK: Presentation + + @Test + func theNewStatesDrawCalmSymbolsRatherThanMoreDots() { + let subagents = Glyph(kind: .subagents, tooltip: "") + let waiting = Glyph(kind: .waiting, tooltip: "") + #expect(subagents.defaultSymbolName == "point.3.filled.connected.trianglepath.dotted") + #expect(waiting.defaultSymbolName == "hourglass") + // Symbols draw full size; only the three dots shrink. + #expect(subagents.sizeScale == 1) + #expect(waiting.sizeScale == 1) + #expect(Glyph(kind: .running, tooltip: "").sizeScale == 0.6) + } + + @Test + func subagentsPulsesBecauseItIsRunningAndWaitingDoesNot() { + #expect(Glyph(kind: .subagents, tooltip: "").pulses) + #expect(!Glyph(kind: .waiting, tooltip: "").pulses) + } + + @Test + func bothNewStatesStaySecondaryGrayAndTakeTheSelectionColorWhenActive() { + for kind in [Glyph.Kind.subagents, .waiting] { + let glyph = Glyph(kind: kind, tooltip: "") + #expect(glyph.color(isActive: false, selected: .white, secondary: .secondaryLabelColor) == .secondaryLabelColor) + #expect(glyph.color(isActive: true, selected: .white, secondary: .secondaryLabelColor) == .white) + } + } + + @Test + func bothNewStatesAreConfigurableIconSlots() { + #expect(Glyph(kind: .subagents, tooltip: "").iconSlot == .subagents) + #expect(Glyph(kind: .waiting, tooltip: "").iconSlot == .waiting) + let overrides = Glyph.validIconOverrides(["subagents": "circle.grid.2x2", "waiting": "clock"]) + #expect(overrides == ["subagents": "circle.grid.2x2", "waiting": "clock"]) + #expect(Glyph(kind: .waiting, tooltip: "", iconOverrides: overrides).symbolName == "clock") + } + + // MARK: Group headers + + @Test + func groupHeadersRankSubagentsWithRunningAndWaitingBelowIt() { + func member(_ title: String, _ kind: Glyph.Kind) -> Glyph.GroupMember { + Glyph.GroupMember(title: title, glyph: Glyph(kind: kind, tooltip: "detail")) + } + #expect(Glyph.rollUp([member("a", .running), member("b", .subagents)])?.kind == .subagents) + #expect(Glyph.rollUp([member("a", .waiting), member("b", .running)])?.kind == .running) + #expect(Glyph.rollUp([member("a", .waiting), member("b", .unseen)])?.kind == .waiting) + #expect(Glyph.rollUp([member("a", .waiting), member("b", .needsInput)])?.kind == .needsInput) + } + + @Test + func unreadNotificationsDoNotOverrideEitherNewState() { + for kind in [Glyph.Kind.subagents, .waiting] { + let glyph = Glyph(kind: kind, tooltip: "Claude Code: Waiting") + .applyingUnread(3, latestNotificationText: "Finished") + #expect(glyph.kind == kind) + #expect(glyph.tooltip == "Finished\nClaude Code: Waiting") + } + } +} diff --git a/docs/configuration.md b/docs/configuration.md index df9d1c6f9dcb..c4a09c8724fd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -223,7 +223,9 @@ The glyph shows the loudest state that applies: | --- | --- | | An agent reported an error | red warning triangle | | An agent needs input | amber dot | +| An agent is running through subagents | pulsing gray connected-points glyph | | An agent is running | pulsing gray dot, in place of the loading spinner | +| An agent is waiting on a background command, a scheduled wakeup or a CI run | gray hourglass | | An agent is starting (no state reported yet) | dashed ring | | Unread notifications | blue dot, in place of the unread count badge | | Open pull request | gray pull request glyph | @@ -235,7 +237,7 @@ The glyph shows the loudest state that applies: cmux does not fetch a pull request's checks or mergeability, so an open pull request is gray whatever CI says. A pull request whose state repeated refresh failures could not confirm does not set the glyph at all. -Change any of them with `sidebar.compactStatusIcons`, a map from state to an [SF Symbol](https://developer.apple.com/sf-symbols/) name. The states are `error`, `needsInput`, `running`, `starting`, `unseen`, `pullRequestOpen`, `pullRequestMerged`, `pullRequestClosed`, `idle`, `branch` and `terminal`. Colors stay the same; a configured symbol replaces the badge too, draws at full size, and a name that does not render falls back to the built-in symbol. The built-in pull request and merge glyphs are drawn by cmux, since the SF Symbols ones are too narrow at sidebar size; name them `cmux.pullrequest` and `cmux.merge` to use them for another state. +Change any of them with `sidebar.compactStatusIcons`, a map from state to an [SF Symbol](https://developer.apple.com/sf-symbols/) name. The states are `error`, `needsInput`, `subagents`, `running`, `waiting`, `starting`, `unseen`, `pullRequestOpen`, `pullRequestMerged`, `pullRequestClosed`, `idle`, `branch` and `terminal`. Colors stay the same; a configured symbol replaces the badge too, draws at full size, and a name that does not render falls back to the built-in symbol. The built-in pull request and merge glyphs are drawn by cmux, since the SF Symbols ones are too narrow at sidebar size; name them `cmux.pullrequest` and `cmux.merge` to use them for another state. ```json { diff --git a/scripts/ui-lab/harnesses/sidebar-compact-status.swift b/scripts/ui-lab/harnesses/sidebar-compact-status.swift index 35d160f1f693..dd4364baec11 100644 --- a/scripts/ui-lab/harnesses/sidebar-compact-status.swift +++ b/scripts/ui-lab/harnesses/sidebar-compact-status.swift @@ -19,7 +19,9 @@ let lineCenter: CGFloat = 12.5 * 0.6 + 8 let rows: [(String, Glyph.Kind?, Bool)] = [ ("needs input", .needsInput, false), + ("running subagents", .subagents, false), ("running", .running, false), + ("waiting", .waiting, false), ("unseen", .unseen, false), ("error", .error, false), ("starting agent", .pending, false), diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 1a6fd38caee4..e2859392d888 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1448,7 +1448,9 @@ "properties": { "error": { "type": "string", "minLength": 1 }, "needsInput": { "type": "string", "minLength": 1 }, + "subagents": { "type": "string", "minLength": 1 }, "running": { "type": "string", "minLength": 1 }, + "waiting": { "type": "string", "minLength": 1 }, "starting": { "type": "string", "minLength": 1 }, "unseen": { "type": "string", "minLength": 1 }, "pullRequestOpen": { "type": "string", "minLength": 1 }, From 9232609913c1bc8e33deb5f9afc774bcadfbeb5f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 01:36:50 -0700 Subject: [PATCH 2/7] Add dogfood tours for the new sidebar agent work states Two tours over the same five workspaces (subagents, waiting, running, needs input, idle): one with the compact glyph on, one with it off so the metadata rows show the icons the hooks send. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 5904e8a144236889b4d1506fb15029f26d3b577d) --- .../sidebar-agent-work-state-compact.json | 50 +++++++++++++++++++ .../sidebar-agent-work-state-rows.json | 50 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 dogfood/scenarios/sidebar-agent-work-state-compact.json create mode 100644 dogfood/scenarios/sidebar-agent-work-state-rows.json diff --git a/dogfood/scenarios/sidebar-agent-work-state-compact.json b/dogfood/scenarios/sidebar-agent-work-state-compact.json new file mode 100644 index 000000000000..64fc9fcdba96 --- /dev/null +++ b/dogfood/scenarios/sidebar-agent-work-state-compact.json @@ -0,0 +1,50 @@ +{ + "launch": { + "args": [ + "-sidebarCompactAgentStatus", + "", + "-sidebarShellIntegration", + "", + "-newWorkspacePlacement", + "end" + ], + "env": { + "CMUX_UI_TEST_SOCKET_COMMANDS": "new_workspace api server\nselect_workspace {last}\nwait 500\nlet api\nset_agent_pid claude_code {pid} --tab={api}\nset_agent_lifecycle claude_code running --tab={api}\nset_status claude_code \"Running subagents\" --icon=point.3.filled.connected.trianglepath.dotted --color=#4C8DFF --work=subagents --tab={api}\nreport_git_branch feat/fan-out --status=dirty --tab={api}\nwait 400\nnew_workspace release gate\nselect_workspace {last}\nwait 500\nlet gate\nset_agent_pid claude_code {pid} --tab={gate}\nset_agent_lifecycle claude_code running --tab={gate}\nset_status claude_code Waiting --icon=hourglass --color=#8E8E93 --work=waiting --tab={gate}\nreport_git_branch feat/release-gate --status=clean --tab={gate}\nwait 400\nnew_workspace worker\nselect_workspace {last}\nwait 500\nlet worker\nset_agent_pid codex {pid} --tab={worker}\nset_agent_lifecycle codex running --tab={worker}\nset_status codex Running --icon=bolt.fill --color=#4C8DFF --work=running --tab={worker}\nreport_git_branch feat/worker-queue --status=clean --tab={worker}\nwait 400\nnew_workspace review\nselect_workspace {last}\nwait 500\nlet review\nset_agent_pid claude_code {pid} --tab={review}\nset_agent_lifecycle claude_code needsInput --tab={review}\nset_status claude_code \"Needs input\" --icon=bell.fill --color=#4C8DFF --tab={review}\nwait 400\nnew_workspace docs\nselect_workspace {last}\nwait 500\nlet docs\nset_agent_lifecycle claude_code idle --tab={docs}\nreport_git_branch docs/configuration --tab={docs}\nreport_pr 15164 https://github.com/manaflow-ai/cmux/pull/15164 --label=PR --state=merged --tab={docs}\nwait 400\nselect_workspace {api}\nwait 600" + } + }, + "steps": [ + { + "wait": 2.5 + }, + { + "shot": "work-state-compact-sidebar" + }, + { + "tree": "work-state-compact-tree" + }, + { + "hoverAt": { + "x": 0.06, + "y": 0.18 + } + }, + { + "wait": 0.8 + }, + { + "shot": "work-state-compact-hover-subagents" + }, + { + "hoverAt": { + "x": 0.06, + "y": 0.22 + } + }, + { + "wait": 0.8 + }, + { + "shot": "work-state-compact-hover-waiting" + } + ] +} diff --git a/dogfood/scenarios/sidebar-agent-work-state-rows.json b/dogfood/scenarios/sidebar-agent-work-state-rows.json new file mode 100644 index 000000000000..9b59e610ffed --- /dev/null +++ b/dogfood/scenarios/sidebar-agent-work-state-rows.json @@ -0,0 +1,50 @@ +{ + "launch": { + "args": [ + "-sidebarCompactAgentStatus", + "", + "-sidebarShellIntegration", + "", + "-newWorkspacePlacement", + "end" + ], + "env": { + "CMUX_UI_TEST_SOCKET_COMMANDS": "new_workspace api server\nselect_workspace {last}\nwait 500\nlet api\nset_agent_pid claude_code {pid} --tab={api}\nset_agent_lifecycle claude_code running --tab={api}\nset_status claude_code \"Running subagents\" --icon=point.3.filled.connected.trianglepath.dotted --color=#4C8DFF --work=subagents --tab={api}\nreport_git_branch feat/fan-out --status=dirty --tab={api}\nwait 400\nnew_workspace release gate\nselect_workspace {last}\nwait 500\nlet gate\nset_agent_pid claude_code {pid} --tab={gate}\nset_agent_lifecycle claude_code running --tab={gate}\nset_status claude_code Waiting --icon=hourglass --color=#8E8E93 --work=waiting --tab={gate}\nreport_git_branch feat/release-gate --status=clean --tab={gate}\nwait 400\nnew_workspace worker\nselect_workspace {last}\nwait 500\nlet worker\nset_agent_pid codex {pid} --tab={worker}\nset_agent_lifecycle codex running --tab={worker}\nset_status codex Running --icon=bolt.fill --color=#4C8DFF --work=running --tab={worker}\nreport_git_branch feat/worker-queue --status=clean --tab={worker}\nwait 400\nnew_workspace review\nselect_workspace {last}\nwait 500\nlet review\nset_agent_pid claude_code {pid} --tab={review}\nset_agent_lifecycle claude_code needsInput --tab={review}\nset_status claude_code \"Needs input\" --icon=bell.fill --color=#4C8DFF --tab={review}\nwait 400\nnew_workspace docs\nselect_workspace {last}\nwait 500\nlet docs\nset_agent_lifecycle claude_code idle --tab={docs}\nreport_git_branch docs/configuration --tab={docs}\nreport_pr 15164 https://github.com/manaflow-ai/cmux/pull/15164 --label=PR --state=merged --tab={docs}\nwait 400\nselect_workspace {api}\nwait 600" + } + }, + "steps": [ + { + "wait": 2.5 + }, + { + "shot": "work-state-rows-sidebar" + }, + { + "tree": "work-state-rows-tree" + }, + { + "hoverAt": { + "x": 0.06, + "y": 0.18 + } + }, + { + "wait": 0.8 + }, + { + "shot": "work-state-rows-hover-subagents" + }, + { + "hoverAt": { + "x": 0.06, + "y": 0.22 + } + }, + { + "wait": 0.8 + }, + { + "shot": "work-state-rows-hover-waiting" + } + ] +} From 42c0d546c5a5a28e625a208f1b0e21873fe58908 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 02:17:27 -0700 Subject: [PATCH 3/7] Fix the work-state compile breaks and the shared-key waiting regression CI on 5904e8a caught two compile breaks the branch shipped with: the two `shouldReplaceStatusEntry` call sites in SidebarOrderingTests never gained the new `workState` argument, and a new control-socket test called `hasPrefix` on an optional response. `everyIconSlotHasADistinctState` also still pinned 11 icon slots against the 13 the branch now has. The cmuxTests target could not build, so none of the branch's own tests ran. The review that ran alongside it found three behavioral defects: Subagents never appeared on a current Claude Code. The PreToolUse row matched only `tool_name == "Task"`, and 2.x sends `Agent` for the same spawn. Both names now count, the way `AgentChatSessionRegistry.isTaskSpawn` already handles it for the mobile child-run tracker. An hourglass could cover a pane that was still working. Status entries are keyed per workspace while lifecycle states are keyed per panel, so two Claude panes in one workspace share one `claude_code` entry and the second to report wins. Waiting now also requires that every running lifecycle is covered by a waiting report, so a sibling pane mid-tool-call keeps the row running. Two panes both waiting under one key read as running, which is the conservative direction. The work state is now listed by `list_status` and `sidebar_state` as `work=`, so the state behind the glyph is observable instead of screenshot-only. Also: the doc comment promised that an unknown work state degrades to a plain running row, while the socket rejects the whole `set_status` the way it already rejects an unknown `--format`; the comment now describes what the code does. `SidebarAgentWorkState.parse` dropped a `_`/`-` pass that no input could reach and a singular `subagent` alias the socket rejects, so the two parses accept the same set. The glyph header and docs/configuration.md listed Running above Waiting while the resolver checks Waiting first. Tests: the renamed spawn tool, the two-pane shared-key case both ways, two agents both parked, the listing line, and a pin on the raw values the sidebar and control-socket copies of the wire contract share. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 1bad27daa4fced8056599f9af11aed74bc542b52) --- CLI/cmux.swift | 15 ++++- .../ControlCommandCoordinator+SidebarV1.swift | 1 + .../ControlSidebarStatusEntrySnapshot.swift | 8 ++- ...trolCommandCoordinatorSidebarV1Tests.swift | 33 ++++++++- .../Status/SidebarAgentWorkState.swift | 27 ++++---- .../SidebarCompactStatusGlyph+Resolve.swift | 10 +++ .../Sidebar/SidebarCompactStatusGlyph.swift | 10 ++- ...inalController+ControlSidebarContext.swift | 3 +- ...nalController+ControlSidebarContext2.swift | 5 +- Sources/TerminalController.swift | 1 + cmuxTests/ClaudeHookWorkStateTests.swift | 12 ++++ cmuxTests/SidebarAgentWorkStateTests.swift | 67 ++++++++++++++++++- .../SidebarCompactAgentStatusTests.swift | 6 +- cmuxTests/SidebarOrderingTests.swift | 6 +- docs/configuration.md | 4 +- 15 files changed, 179 insertions(+), 29 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a582fa8c29ae..cac869fb92eb 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4373,6 +4373,15 @@ struct CMUXCLI { /// points, distinct from the git-branch symbol the sidebar already uses. static let subagentsStatusIcon = "point.3.filled.connected.trianglepath.dotted" + /// Whether a PreToolUse tool name is Claude Code's subagent spawn. + /// Claude Code renamed the spawn tool "Task" -> "Agent" (2.x); both names + /// remain on the wire depending on CLI version, so both must count (see + /// `AgentChatSessionRegistry.isTaskSpawn`, which says the same thing for + /// the mobile child-run tracker). + static func spawnsSubagents(toolName: String?) -> Bool { + toolName == "Task" || toolName == "Agent" + } + private static func agentNotificationMeta( category: AgentHookNotifyCategory, isError: Bool, @@ -29516,11 +29525,13 @@ struct CMUXCLI { telemetry: telemetry ) - // A `Task` call blocks the parent inside the tool until its + // A spawn call blocks the parent inside the tool until its // subagents finish, so no other parent hook can fire meanwhile: // the subagent state holds for exactly that span, and the next // parent PreToolUse or Stop clears it. No counter to drift. - let runsSubagents = (parsedInput.object?["tool_name"] as? String) == "Task" + let runsSubagents = Self.spawnsSubagents( + toolName: parsedInput.object?["tool_name"] as? String + ) let statusValue: String if UserDefaults.standard.bool(forKey: "claudeCodeVerboseStatus"), let toolStatus = describeToolUse(parsedInput.object) { diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarV1.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarV1.swift index 2b9b259886eb..58b5b1b2c86f 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarV1.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarV1.swift @@ -403,6 +403,7 @@ extension ControlCommandCoordinator { if let url = entry.urlAbsoluteString { line += " url=\(url)" } if entry.priority != 0 { line += " priority=\(entry.priority)" } if entry.format != .plain { line += " format=\(entry.format.rawValue)" } + if let workState = entry.workState { line += " work=\(workState.rawValue)" } return line } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarStatusEntrySnapshot.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarStatusEntrySnapshot.swift index 286992092409..8be75b624265 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarStatusEntrySnapshot.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarStatusEntrySnapshot.swift @@ -17,6 +17,9 @@ public struct ControlSidebarStatusEntrySnapshot: Sendable, Equatable { public let priority: Int /// The render format (non-`plain` is appended to the listing line). public let format: ControlSidebarMetadataFormat + /// What a running agent is running on, when it reported one. Listed so + /// the state the compact glyph reads is observable over the socket. + public let workState: ControlSidebarAgentWorkState? /// Creates a snapshot. /// @@ -28,6 +31,7 @@ public struct ControlSidebarStatusEntrySnapshot: Sendable, Equatable { /// - urlAbsoluteString: The optional absolute URL string. /// - priority: The display priority. /// - format: The render format. + /// - workState: The reported agent work state, if any. public init( key: String, value: String, @@ -35,7 +39,8 @@ public struct ControlSidebarStatusEntrySnapshot: Sendable, Equatable { color: String?, urlAbsoluteString: String?, priority: Int, - format: ControlSidebarMetadataFormat + format: ControlSidebarMetadataFormat, + workState: ControlSidebarAgentWorkState? = nil ) { self.key = key self.value = value @@ -44,5 +49,6 @@ public struct ControlSidebarStatusEntrySnapshot: Sendable, Equatable { self.urlAbsoluteString = urlAbsoluteString self.priority = priority self.format = format + self.workState = workState } } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSidebarV1Tests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSidebarV1Tests.swift index 8a671e8283f4..2e37aa8a8f9b 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSidebarV1Tests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSidebarV1Tests.swift @@ -82,10 +82,41 @@ struct ControlCommandCoordinatorSidebarV1Tests { args: "claude_code Thinking --work=thinking --tab=\(UUID().uuidString)" ) - #expect(response.hasPrefix("ERROR: Invalid work state 'thinking'")) + #expect(response?.hasPrefix("ERROR: Invalid work state 'thinking'") == true, + "An unknown work state must be named in the error; saw \(response ?? "nil")") #expect(context.statusUpsertCall == nil) } + /// The work state drives a glyph with no text of its own, so the + /// `list_status` / `sidebar_state` line has to carry it: it is the only + /// way a test or a user can see which state a row is actually in. + @Test func statusListingLineCarriesTheWorkState() { + let coordinator = ControlCommandCoordinator(context: FakeSidebarV1ControlCommandContext()) + let waiting = ControlSidebarStatusEntrySnapshot( + key: "claude_code", + value: "Waiting", + icon: "hourglass", + color: "#8E8E93", + urlAbsoluteString: nil, + priority: 0, + format: .plain, + workState: .waiting + ) + #expect(coordinator.sidebarMetadataLine(waiting) + == "claude_code=Waiting icon=hourglass color=#8E8E93 work=waiting") + + let plain = ControlSidebarStatusEntrySnapshot( + key: "deploy", + value: "staging green", + icon: nil, + color: nil, + urlAbsoluteString: nil, + priority: 0, + format: .plain + ) + #expect(coordinator.sidebarMetadataLine(plain) == "deploy=staging green") + } + @Test func workspaceLoadingFailureReasonReturnsErrorLine() { let context = FakeSidebarV1ControlCommandContext() context.workspaceLoadingResult = ControlSidebarWorkspaceLoadingState( diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/SidebarAgentWorkState.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/SidebarAgentWorkState.swift index 3f924656e54a..b735b4fd366e 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/SidebarAgentWorkState.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/SidebarAgentWorkState.swift @@ -9,8 +9,11 @@ public import Foundation /// with live work look hibernatable. /// /// An entry without a work state reads exactly as it did before: a plain -/// running row. An unrecognized value from a newer CLI parses to `nil` and -/// degrades the same way. +/// running row. An unrecognized value is not accepted anywhere: the control +/// socket rejects the whole `set_status` with an error, the same way it +/// already rejects an unknown `--format` or a non-integer `--priority`. A +/// future fourth state therefore needs the app to ship before the CLI that +/// sends it. public enum SidebarAgentWorkState: String, Sendable, Equatable, CaseIterable { /// The agent itself is working: a model turn or a tool call is in flight. case running @@ -21,17 +24,15 @@ public enum SidebarAgentWorkState: String, Sendable, Equatable, CaseIterable { /// CI run. Not idle, and not hibernatable. case waiting - /// Parses a reported value, tolerating case and `-`/`_` spelling. + /// Parses a reported value, tolerating surrounding space and case and + /// nothing else. Deliberately the same acceptance set as the control + /// socket's own `--work` parse, so a value the socket rejects can never + /// look parseable here. public static func parse(_ rawValue: String) -> SidebarAgentWorkState? { - let normalized = rawValue - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .replacingOccurrences(of: "_", with: "-") - switch normalized { - case "running": return .running - case "subagents", "subagent": return .subagents - case "waiting": return .waiting - default: return nil - } + SidebarAgentWorkState( + rawValue: rawValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + ) } } diff --git a/Sources/Sidebar/SidebarCompactStatusGlyph+Resolve.swift b/Sources/Sidebar/SidebarCompactStatusGlyph+Resolve.swift index a9b1fe78d91f..7cf8762136b0 100644 --- a/Sources/Sidebar/SidebarCompactStatusGlyph+Resolve.swift +++ b/Sources/Sidebar/SidebarCompactStatusGlyph+Resolve.swift @@ -45,9 +45,19 @@ extension SidebarCompactStatusGlyph { // off the entries before the lifecycle branch below, and only when // every agent in the workspace reports it: one agent still working // keeps the row running. + // + // Status entries are keyed per workspace, while lifecycle states are + // keyed per panel, so two Claude panes in one workspace share a single + // `claude_code` entry and the second one to report wins. Counting the + // running lifecycles closes that gap: an hourglass only goes up when + // every running agent is covered by a waiting report. A pane that is + // still working can never hide behind another pane's hourglass; the + // cost is that two panes both waiting under one key show as running. + let runningLifecycleCount = input.lifecycleStates.filter { $0 == .running }.count let everyAgentIsWaiting = !workStates.isEmpty && workStates.count == input.agentEntries.count && workStates.allSatisfy { $0 == .waiting } + && runningLifecycleCount <= workStates.count if input.agentEntries.contains(where: Self.reportsError) { kind = .error } else if input.lifecycleStates.contains(.needsInput) { diff --git a/Sources/Sidebar/SidebarCompactStatusGlyph.swift b/Sources/Sidebar/SidebarCompactStatusGlyph.swift index 5521739df306..d26d6203320b 100644 --- a/Sources/Sidebar/SidebarCompactStatusGlyph.swift +++ b/Sources/Sidebar/SidebarCompactStatusGlyph.swift @@ -26,9 +26,13 @@ import Foundation /// 2. Needs input: amber dot. /// 3. Running through subagents: pulsing gray connected-points symbol. The /// agent is working, but through background agents it spawned. -/// 4. Running: pulsing gray dot. It replaces the row's loading spinner. -/// 5. Waiting on a deterministic wakeup (a background command, a scheduled -/// wakeup, a CI run): gray hourglass. Not your turn, and not finished. +/// 4. Waiting on a deterministic wakeup (a background command, a scheduled +/// wakeup, a CI run): gray hourglass. Not your turn, and not finished. A +/// waiting pane still reports a *running* lifecycle (it must not look +/// hibernatable), so waiting is read off the reported work state and wins +/// over the running branch below, but only when every running agent in the +/// workspace is covered by a waiting report. +/// 5. Running: pulsing gray dot. It replaces the row's loading spinner. /// 6. Starting (agent present, state not reported yet): hollow ring. /// 7. Unseen (unread notifications): blue dot. Applied by the row, which owns /// the unread count; see ``applyingUnread(_:latestNotificationText:)``. It diff --git a/Sources/TerminalController+ControlSidebarContext.swift b/Sources/TerminalController+ControlSidebarContext.swift index ba629a067aec..4d8bb10c9bdb 100644 --- a/Sources/TerminalController+ControlSidebarContext.swift +++ b/Sources/TerminalController+ControlSidebarContext.swift @@ -262,7 +262,8 @@ extension TerminalController: ControlSidebarContext { color: entry.color, urlAbsoluteString: entry.url?.absoluteString, priority: entry.priority, - format: ControlSidebarMetadataFormat(rawValue: entry.format.rawValue) ?? .plain + format: ControlSidebarMetadataFormat(rawValue: entry.format.rawValue) ?? .plain, + workState: entry.workState.flatMap { ControlSidebarAgentWorkState(rawValue: $0.rawValue) } ) } diff --git a/Sources/TerminalController+ControlSidebarContext2.swift b/Sources/TerminalController+ControlSidebarContext2.swift index 5adda480cc03..b81f383de2b0 100644 --- a/Sources/TerminalController+ControlSidebarContext2.swift +++ b/Sources/TerminalController+ControlSidebarContext2.swift @@ -439,7 +439,10 @@ extension TerminalController { color: $0.color, urlAbsoluteString: $0.url?.absoluteString, priority: $0.priority, - format: ControlSidebarMetadataFormat(rawValue: $0.format.rawValue) ?? .plain + format: ControlSidebarMetadataFormat(rawValue: $0.format.rawValue) ?? .plain, + workState: $0.workState.flatMap { state in + ControlSidebarAgentWorkState(rawValue: state.rawValue) + } ) }, metadataBlocks: tab.sidebarMetadataBlocksInDisplayOrder().map { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index fe99019823d5..66dcefd4f522 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -14836,6 +14836,7 @@ class TerminalController { if let url = entry.url { line += " url=\(url.absoluteString)" } if entry.priority != 0 { line += " priority=\(entry.priority)" } if entry.format != .plain { line += " format=\(entry.format.rawValue)" } + if let workState = entry.workState { line += " work=\(workState.rawValue)" } return line } diff --git a/cmuxTests/ClaudeHookWorkStateTests.swift b/cmuxTests/ClaudeHookWorkStateTests.swift index 69841fcc3238..47d6fc01d1f6 100644 --- a/cmuxTests/ClaudeHookWorkStateTests.swift +++ b/cmuxTests/ClaudeHookWorkStateTests.swift @@ -68,6 +68,18 @@ struct ClaudeHookWorkStateTests { "The subagents pill must use the connected-points symbol; saw \(commands)") } + /// Claude Code renamed the spawn tool "Task" -> "Agent" in 2.x and both + /// names are still on the wire, so a row that only matched "Task" would + /// show a plain running dot for everyone on a current CLI. + @Test func renamedAgentToolAlsoReportsRunningSubagents() throws { + let commands = try runPreToolUse(name: "work-state-agent", toolName: "Agent", pid: "43403") + let status = statusLine(commands) + #expect(status?.contains("--work=subagents") == true, + "The 2.x spawn tool name must report subagents too; saw \(commands)") + #expect(status?.contains("--icon=point.3.filled.connected.trianglepath.dotted") == true, + "The subagents pill must use the connected-points symbol; saw \(commands)") + } + /// Every other tool is the agent working directly, which must keep the /// pill and glyph it had before the work state existed. @Test func ordinaryToolReportsPlainRunning() throws { diff --git a/cmuxTests/SidebarAgentWorkStateTests.swift b/cmuxTests/SidebarAgentWorkStateTests.swift index 67f4df72db0f..abdb517ddebe 100644 --- a/cmuxTests/SidebarAgentWorkStateTests.swift +++ b/cmuxTests/SidebarAgentWorkStateTests.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxControlSocket import CmuxSidebar import Testing @testable import cmux_DEV @@ -26,9 +27,30 @@ struct SidebarAgentWorkStateTests { #expect(SidebarAgentWorkState.parse("running") == .running) #expect(SidebarAgentWorkState.parse(" Subagents ") == .subagents) #expect(SidebarAgentWorkState.parse("WAITING") == .waiting) - // A value from a newer CLI degrades to a plain running row. #expect(SidebarAgentWorkState.parse("compacting") == nil) #expect(SidebarAgentWorkState.parse("") == nil) + // The singular reads like it should work, and it must not: the + // control socket rejects the whole `set_status` for it, so a parse + // that accepted it here would describe a state the app never sees. + #expect(SidebarAgentWorkState.parse("subagent") == nil) + } + + /// The wire contract is three raw strings repeated in modules that + /// deliberately do not depend on each other. Nothing else fails if one + /// copy is renamed, so pin the two that link here; the CLI's third copy + /// is pinned on the wire by `ClaudeHookWorkStateTests` and + /// `ClaudeBackgroundWorkNotifyTests`, which read the emitted `--work=`. + @Test + func theThreeCopiesOfTheWireContractAgree() { + #expect(SidebarAgentWorkState.allCases.map(\.rawValue) == ["running", "subagents", "waiting"]) + #expect( + ControlSidebarAgentWorkState.allCases.map(\.rawValue) + == SidebarAgentWorkState.allCases.map(\.rawValue) + ) + for state in ControlSidebarAgentWorkState.allCases { + #expect(SidebarAgentWorkState(rawValue: state.rawValue) != nil, + "The control socket's '\(state.rawValue)' has no sidebar twin") + } } // MARK: Resolution @@ -85,6 +107,49 @@ struct SidebarAgentWorkStateTests { #expect(glyph.kind == .running) } + /// Status entries are keyed per workspace and lifecycle states per panel, + /// so two Claude panes in one workspace share a single `claude_code` + /// entry: the pane that reports last wins. A pane parked on background + /// work must not put an hourglass over a sibling pane that is still + /// working, so a running lifecycle that no waiting report accounts for + /// keeps the row running. + @Test + func aSecondPaneStillWorkingUnderTheSharedKeyKeepsTheRowRunning() { + let glyph = Glyph.resolve(Glyph.Input( + agentEntries: [Self.entry("claude_code", "Waiting", workState: .waiting)], + lifecycleStates: [.running, .running], + hasActiveAgent: true + )) + #expect(glyph.kind == .running) + } + + /// The same workspace once the sibling pane goes idle: one running + /// lifecycle, one waiting report, so the hourglass is honest again. + @Test + func theHourglassReturnsOnceTheSiblingPaneIsDone() { + let glyph = Glyph.resolve(Glyph.Input( + agentEntries: [Self.entry("claude_code", "Waiting", workState: .waiting)], + lifecycleStates: [.running, .idle], + hasActiveAgent: true + )) + #expect(glyph.kind == .waiting) + } + + /// Two agents under two keys, both parked, is the ordinary all-waiting + /// case and still reads as waiting. + @Test + func twoAgentsBothParkedStayWaiting() { + let glyph = Glyph.resolve(Glyph.Input( + agentEntries: [ + Self.entry("claude_code", "Waiting", workState: .waiting), + Self.entry("codex", "Waiting", workState: .waiting), + ], + lifecycleStates: [.running, .running], + hasActiveAgent: true + )) + #expect(glyph.kind == .waiting) + } + @Test func needsInputAndErrorStillOutrankBothNewStates() { #expect(Glyph.resolve(Glyph.Input( diff --git a/cmuxTests/SidebarCompactAgentStatusTests.swift b/cmuxTests/SidebarCompactAgentStatusTests.swift index 8ecf40b04ce3..3be654472e13 100644 --- a/cmuxTests/SidebarCompactAgentStatusTests.swift +++ b/cmuxTests/SidebarCompactAgentStatusTests.swift @@ -276,11 +276,11 @@ struct SidebarCompactAgentStatusTests { @Test func everyIconSlotHasADistinctState() { - #expect(Glyph.IconSlot.allCases.count == 11) - #expect(Set(Glyph.IconSlot.allCases.map(\.rawValue)).count == 11) + #expect(Glyph.IconSlot.allCases.count == 13) + #expect(Set(Glyph.IconSlot.allCases.map(\.rawValue)).count == 13) // Every slot is a state the app can actually reach. #expect(Set(Glyph.IconSlot.allCases) == Set([ - .error, .needsInput, .running, .starting, .unseen, + .error, .needsInput, .running, .subagents, .waiting, .starting, .unseen, .pullRequestOpen, .pullRequestMerged, .pullRequestClosed, .idle, .branch, .terminal, ])) diff --git a/cmuxTests/SidebarOrderingTests.swift b/cmuxTests/SidebarOrderingTests.swift index 6a935b0e71f1..e69188e578d8 100644 --- a/cmuxTests/SidebarOrderingTests.swift +++ b/cmuxTests/SidebarOrderingTests.swift @@ -1100,7 +1100,8 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase { color: "#ffffff", url: nil, priority: 0, - format: .plain + format: .plain, + workState: nil ) ) } @@ -1122,7 +1123,8 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase { color: "#ffffff", url: nil, priority: 0, - format: .plain + format: .plain, + workState: nil ) ) } diff --git a/docs/configuration.md b/docs/configuration.md index c4a09c8724fd..908c5a7229db 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -224,8 +224,8 @@ The glyph shows the loudest state that applies: | An agent reported an error | red warning triangle | | An agent needs input | amber dot | | An agent is running through subagents | pulsing gray connected-points glyph | -| An agent is running | pulsing gray dot, in place of the loading spinner | | An agent is waiting on a background command, a scheduled wakeup or a CI run | gray hourglass | +| An agent is running | pulsing gray dot, in place of the loading spinner | | An agent is starting (no state reported yet) | dashed ring | | Unread notifications | blue dot, in place of the unread count badge | | Open pull request | gray pull request glyph | @@ -235,6 +235,8 @@ The glyph shows the loudest state that applies: | Branch, no pull request | gray branch glyph | | Plain terminal | none; the title starts at the row's edge | +The hourglass only goes up when every running agent in the workspace reported that it is waiting, so a second agent still working keeps the row running. + cmux does not fetch a pull request's checks or mergeability, so an open pull request is gray whatever CI says. A pull request whose state repeated refresh failures could not confirm does not set the glyph at all. Change any of them with `sidebar.compactStatusIcons`, a map from state to an [SF Symbol](https://developer.apple.com/sf-symbols/) name. The states are `error`, `needsInput`, `subagents`, `running`, `waiting`, `starting`, `unseen`, `pullRequestOpen`, `pullRequestMerged`, `pullRequestClosed`, `idle`, `branch` and `terminal`. Colors stay the same; a configured symbol replaces the badge too, draws at full size, and a name that does not render falls back to the built-in symbol. The built-in pull request and merge glyphs are drawn by cmux, since the SF Symbols ones are too narrow at sidebar size; name them `cmux.pullrequest` and `cmux.merge` to use them for another state. From f209a046214a0ae6740989fca778e23d9ce7c989 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 02:28:47 -0700 Subject: [PATCH 4/7] Append the work option last so it does not split an older command prefix CI caught this on the app-host lane: CLINotifyProcessIntegrationRegressionTests.testClaudePromptSubmitFrom NewSessionCanReplaceStoppedSession asserts the prompt-submit command as a prefix through `--tab=`, and `--work=running` was being inserted between `--color=` and `--tab=`, so the prefix no longer matched. Three assertions in tests/test_claude_hook_clear_running_status.py use the same contiguous fragment and would have failed on their own lane for the same reason. None of those four assertions is about work states; they check that prompt-submit sets Claude running on the right tab. Options are order-independent on the wire, since the coordinator reads a parsed option dictionary, so the new optional one goes at the end of the command instead and the older assertions stay intact. Updating them to expect `--work=running` would have coupled four unrelated checks to this feature and broken them again the next time the work state for prompt-submit changed. Pinned by a new test in ClaudeHookWorkStateTests: the running command must still start with the historical prefix and must end with the work option. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLI/CMUXCLI+ClaudeHookStopFailure.swift | 10 +++++++--- cmuxTests/ClaudeHookWorkStateTests.swift | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CLI/CMUXCLI+ClaudeHookStopFailure.swift b/CLI/CMUXCLI+ClaudeHookStopFailure.swift index edd278015f9f..7e3cd018f862 100644 --- a/CLI/CMUXCLI+ClaudeHookStopFailure.swift +++ b/CLI/CMUXCLI+ClaudeHookStopFailure.swift @@ -23,14 +23,18 @@ extension CMUXCLI { if let priority { cmd += " --priority=\(priority)" } - if let workState { - cmd += " --work=\(workState.rawValue)" - } cmd += " --tab=\(workspaceId)\(socketPanelOption(surfaceId))" if let pid, ProcessInfo.processInfo.environment[agentHookRelayOriginEnvironmentKey] != "1" { cmd += " --pid=\(pid)" } + // Options are order-independent on the wire, and this one is new and + // optional, so it goes last: several existing tests pin the command + // up to `--tab=`, and inserting a flag before that would break them + // for no reason connected to what they check. + if let workState { + cmd += " --work=\(workState.rawValue)" + } _ = try client.send(command: cmd) } diff --git a/cmuxTests/ClaudeHookWorkStateTests.swift b/cmuxTests/ClaudeHookWorkStateTests.swift index 47d6fc01d1f6..456148a94d74 100644 --- a/cmuxTests/ClaudeHookWorkStateTests.swift +++ b/cmuxTests/ClaudeHookWorkStateTests.swift @@ -91,4 +91,23 @@ struct ClaudeHookWorkStateTests { #expect(status?.contains("--icon=bolt.fill") == true) #expect(status?.contains("subagents") == false) } + + /// The work option is appended after every option the command already + /// had. Suites that have nothing to do with work states pin the running + /// command as a prefix up to `--tab=` + /// (`CLINotifyProcessIntegrationRegressionTests`, + /// `test_claude_hook_clear_running_status.py`), and a flag inserted + /// ahead of that breaks them while saying nothing about what they check. + @Test func workStateGoesLastAndLeavesTheOlderPrefixIntact() throws { + let commands = try runPreToolUse(name: "work-state-order", toolName: "Bash", pid: "43404") + let status = statusLine(commands) + #expect( + status?.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=") == true, + "The work option must not split the prefix other suites pin; saw \(commands)" + ) + #expect( + status?.hasSuffix("--work=running") == true, + "The work option must come last; saw \(commands)" + ) + } } From 8953f203209813e006c01061306c2d6f06331c07 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 02:47:40 -0700 Subject: [PATCH 5/7] fix(sidebar): list the work option in the socket help output The `help` text for `set_status` was the one place that still omitted `--work`, while the usage and error strings in both coordinator copies already list it. Pin the work-state ordering test through the workspace id, so it stands in byte for byte for the prefix the older suites assert, and say in the comment why order independence holds: every option here is `--key=value`, which a future bare flag would not be. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLI/CMUXCLI+ClaudeHookStopFailure.swift | 11 +++++++---- Sources/TerminalController.swift | 2 +- cmuxTests/ClaudeHookWorkStateTests.swift | 4 +++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/CLI/CMUXCLI+ClaudeHookStopFailure.swift b/CLI/CMUXCLI+ClaudeHookStopFailure.swift index 7e3cd018f862..4020e53510bf 100644 --- a/CLI/CMUXCLI+ClaudeHookStopFailure.swift +++ b/CLI/CMUXCLI+ClaudeHookStopFailure.swift @@ -28,10 +28,13 @@ extension CMUXCLI { ProcessInfo.processInfo.environment[agentHookRelayOriginEnvironmentKey] != "1" { cmd += " --pid=\(pid)" } - // Options are order-independent on the wire, and this one is new and - // optional, so it goes last: several existing tests pin the command - // up to `--tab=`, and inserting a flag before that would break them - // for no reason connected to what they check. + // Every option here is `--key=value`, so none can swallow another and + // the reader takes them from a dictionary: order on the wire does not + // matter. This one is new and optional, so it goes last, because + // several existing tests pin the command up to `--tab=` and a flag + // inserted before that breaks them for no reason connected to what + // they check. A future bare flag would not be order-independent: the + // parser reads the next token as its value. if let workState { cmd += " --work=\(workState.rawValue)" } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 66dcefd4f522..c9af913aac2b 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -11931,7 +11931,7 @@ class TerminalController { clear_notifications [--tab=X] [--panel=ID] - Clear notifications (all, per-tab, or per-panel) set_app_focus - Override app focus state simulate_app_active - Trigger app active handler - set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set a status entry + set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--work=running|subagents|waiting] [--tab=X] - Set a status entry set_agent_lifecycle [--tab=X] [--panel=ID] - Report coding-agent lifecycle for hibernation agent_hibernation - Enable or disable routine Agent Hibernation report_meta [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set sidebar metadata entry diff --git a/cmuxTests/ClaudeHookWorkStateTests.swift b/cmuxTests/ClaudeHookWorkStateTests.swift index 456148a94d74..92caf714be05 100644 --- a/cmuxTests/ClaudeHookWorkStateTests.swift +++ b/cmuxTests/ClaudeHookWorkStateTests.swift @@ -102,7 +102,9 @@ struct ClaudeHookWorkStateTests { let commands = try runPreToolUse(name: "work-state-order", toolName: "Bash", pid: "43404") let status = statusLine(commands) #expect( - status?.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=") == true, + status?.hasPrefix( + "set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(Self.workspaceId)" + ) == true, "The work option must not split the prefix other suites pin; saw \(commands)" ) #expect( From eab54ec984c0375b8e51ae3a7e3d4fcf2fb74d34 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 09:21:00 -0700 Subject: [PATCH 6/7] Keep the sidebar pill on Running for a re-entrant Claude Stop Co-Authored-By: Claude Opus 5 --- CLI/cmux.swift | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index e939abe069d4..9ec2984423a3 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -28575,9 +28575,11 @@ struct CMUXCLI { // to the app so it can suppress the done-ping until work truly drains. let hasPendingBackgroundWork = hasActiveClaudeBackgroundWork(parsedInput) // Claude sets stop_hook_active on a re-entry after a Stop hook - // blocked once. That flag describes hook recursion, not work - // still running; only authoritative background-work signals - // should keep the sidebar in Running. + // blocked once. That flag describes hook recursion, not pending + // work: it must not mark the turn pending, but it does keep the + // sidebar pill in Running. Only authoritative background-work + // signals mark the turn pending and show Waiting. + let isReentrantStop = parsedInput.rawObject?["stop_hook_active"] as? Bool == true let hasUnsettledWork = stopFailure == nil && hasPendingBackgroundWork // Update session with transcript summary and send completion notification. @@ -28637,7 +28639,7 @@ struct CMUXCLI { ) if let stopFailure { try? setClaudeStopFailureStatus(stopFailure, client: client, workspaceId: workspaceId, surfaceId: surfaceId) - } else if hasUnsettledWork { + } else if hasPendingBackgroundWork { // The turn ended but a background task or scheduled wakeup is // still live, so the pane is not idle — show it as still // running rather than the misleading "Idle". Reuse the shared @@ -28645,18 +28647,26 @@ struct CMUXCLI { // // A background task or cron is a deterministic wakeup the pane // is parked on, which reads as Waiting. A re-entrant Stop - // (`stop_hook_active`) is not: the agent itself is still going. - let isWaitingOnBackgroundWork = hasPendingBackgroundWork + // (`stop_hook_active`) keeps the pill on Running separately + // because it does not represent pending background work. try? setClaudeStatus( client: client, workspaceId: workspaceId, surfaceId: surfaceId, - value: isWaitingOnBackgroundWork - ? String(localized: "agent.generic.status.waiting", defaultValue: "Waiting") - : String(localized: "agent.generic.status.running", defaultValue: "Running"), - icon: isWaitingOnBackgroundWork ? "hourglass" : "bolt.fill", - color: isWaitingOnBackgroundWork ? "#8E8E93" : "#4C8DFF", - workState: isWaitingOnBackgroundWork ? .waiting : .running + value: String(localized: "agent.generic.status.waiting", defaultValue: "Waiting"), + icon: "hourglass", + color: "#8E8E93", + workState: .waiting + ) + } else if isReentrantStop { + try? setClaudeStatus( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + value: String(localized: "agent.generic.status.running", defaultValue: "Running"), + icon: "bolt.fill", + color: "#4C8DFF", + workState: .running ) } else { try? setClaudeStatus( From db8e2f1d005f3016d398284f2bbc37c0101fd783 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 10:48:49 -0700 Subject: [PATCH 7/7] docs: list the subagents and waiting roll-up states on group headers `groupRank` ranks error, needs input, subagents, running, waiting and unseen, but the group header paragraph still named only the four states that existed before this branch. Anyone reading it would expect a workspace running through subagents, or waiting on a background command, to leave its group header blank while collapsed. Co-Authored-By: Claude Opus 5 --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 908c5a7229db..032182e183c4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -257,7 +257,7 @@ Change any of them with `sidebar.compactStatusIcons`, a map from state to an [SF - Default: `false`. - Only agent-owned status keys lose their rows (`claude_code`, `codex`, and the other built-in agent integrations). Status set with `cmux set-status` under any other key keeps its row. - The notification preview moves to the top of the tooltip too. Rows you added yourself (a workspace description, `cmux set-status` under other keys, logs, progress, ports) keep their lines. -- Workspace group headers show a glyph, after the group name, for the workspaces without a row of their own: the anchor workspace while the group is expanded, and every member once it is collapsed. Only states that ask for attention appear there (error, needs input, running, unread), the loudest first; hover it to see which workspace each comes from. It replaces the header's unread count. +- Workspace group headers show a glyph, after the group name, for the workspaces without a row of their own: the anchor workspace while the group is expanded, and every member once it is collapsed. Only states that ask for attention appear there (error, needs input, subagents, running, waiting, unread), the loudest first; hover it to see which workspace each comes from. It replaces the header's unread count. - The pulse is a Core Animation opacity loop capped at 30 Hz. It stops while the window is hidden or occluded, and Reduce Motion keeps the dot still. - A pull request glyph shows whether the pull request is open, merged or closed, and nothing about its checks. cmux does not fetch CI status or mergeability for a pull request, so there is no passing, failing or conflict glyph: adding one would advertise a color no user could see. An open pull request shows gray, merged shows purple, and closed shows gray with a minus badge. See [#12807](https://github.com/manaflow-ai/cmux/issues/12807). - Pull request and branch details follow `sidebar.showPullRequests` and the git branch toggle: turn either off and the glyph ignores it. Toggle compact status from **Settings > Sidebar > Compact Agent Status**.