Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bd04cdd
test: cover agent activity after turn completion
teamleaderleo Sep 30, 2026
5727bf4
fix: keep agent status running while hooks show activity
teamleaderleo Sep 30, 2026
522f79e
fix: ignore ambiguous late tool activity
teamleaderleo Sep 30, 2026
eccc5d7
Add subagent and waiting agent states to the sidebar
teamleaderleo Sep 28, 2026
2c3c303
Add dogfood tours for the new sidebar agent work states
teamleaderleo Sep 28, 2026
da79da2
Fix the work-state compile breaks and the shared-key waiting regression
teamleaderleo Sep 28, 2026
8a8e3f7
Append the work option last so it does not split an older command prefix
teamleaderleo Sep 30, 2026
0b1226d
fix(sidebar): list the work option in the socket help output
teamleaderleo Sep 30, 2026
6195742
Keep the sidebar pill on Running for a re-entrant Claude Stop
teamleaderleo Sep 30, 2026
6c0adc1
test: replay deterministic wait tools in sidebar hooks
teamleaderleo Sep 30, 2026
1e0bc98
fix: show deterministic Claude waits in sidebar
teamleaderleo Sep 30, 2026
bdfb760
fix: reopen identityless fresh tool activity
teamleaderleo Sep 30, 2026
b8c99a6
fix: gate identityless activity by hook phase
teamleaderleo Sep 30, 2026
e183ae4
test: model fresh identityless tool resolution
teamleaderleo Sep 30, 2026
ad671ee
Merge main (fc3e6e33d98e) into fix/agent-status-idle-while-working
teamleaderleo Sep 30, 2026
22dcc88
Merge remote-tracking branch 'upstream/main' into fix/agent-status-id…
teamleaderleo Sep 30, 2026
84cdd1b
Merge remote-tracking branch 'upstream/main' into fix/agent-status-id…
teamleaderleo Sep 30, 2026
afb5913
Merge remote-tracking branch 'upstream/main' into fix/agent-status-id…
teamleaderleo Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CLI/CMUXCLI+AgentHookStopStatus.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,36 @@
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 {
/// A blocking monitor or known wait command parks the turn on a wakeup.
static func waitsOnDeterministicEvent(toolName: String?, toolInput: Any?) -> Bool {
let tool = toolName?.lowercased() ?? ""
let input = toolInput as? [String: Any] ?? [:]
if tool == "monitor" { return true }
if tool == "taskoutput" {
// Nonblocking probes return immediately, so only a blocking read
// should hold the hourglass. Claude defaults `block` to true.
return input["block"] as? Bool != false
}
guard ["bash", "shell", "exec_command"].contains(tool),
let command = input["command"] as? String ?? input["cmd"] as? String else { return false }
// Match executable positions, not prose or arguments mentioning a
// wait. A shell loop is waiting only when it contains a real sleep.
let executable = #"(?:^|[;\n]|&&|\|\||\bdo\s+)\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:[^\s;]+/)?"#
let wait = #"(?:glaeda-gh\s+wait\b|gh\s+run\s+watch\b|sleep\s+(?:\d|\$)|watch\s+)"#
if command.range(of: executable + wait, options: .regularExpression) != nil { return true }
let checks = #"gh\s+pr\s+checks\b[^;\n]*(?:--watch\b|(?:^|\s)-w(?:\s|$))"#
return command.range(of: executable + checks, options: .regularExpression) != nil
}

/// Restores the shared needs-input status after a completion Stop that
/// followed an attention request in the same turn.
func setAgentNeedsInputStatus(
Expand Down
13 changes: 12 additions & 1 deletion CLI/CMUXCLI+ClaudeHookStopFailure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +28,16 @@ extension CMUXCLI {
ProcessInfo.processInfo.environment[agentHookRelayOriginEnvironmentKey] != "1" {
cmd += " --pid=\(pid)"
}
// 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)"
}
_ = try client.send(command: cmd)
}

Expand Down
70 changes: 60 additions & 10 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4343,6 +4343,18 @@ 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"

/// 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,
Expand Down Expand Up @@ -28595,9 +28607,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.
Expand Down Expand Up @@ -28657,18 +28671,34 @@ 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
// 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`) keeps the pill on Running separately
// because it does not represent pending background work.
try? setClaudeStatus(
client: client,
workspaceId: workspaceId,
surfaceId: surfaceId,
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"
color: "#4C8DFF",
workState: .running
)
} else {
try? setClaudeStatus(
Expand Down Expand Up @@ -28863,7 +28893,8 @@ struct CMUXCLI {
surfaceId: surfaceId,
value: "Running",
icon: "bolt.fill",
color: "#4C8DFF"
color: "#4C8DFF",
workState: .running
)
printClaudeHookAck()

Expand Down Expand Up @@ -29558,10 +29589,28 @@ struct CMUXCLI {
telemetry: telemetry
)

// 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 = Self.spawnsSubagents(
toolName: parsedInput.object?["tool_name"] as? String
)
let waits = Self.waitsOnDeterministicEvent(
toolName: parsedInput.object?["tool_name"] as? String,
toolInput: parsedInput.object?["tool_input"]
)
let statusValue: String
if UserDefaults.standard.bool(forKey: "claudeCodeVerboseStatus"),
let toolStatus = describeToolUse(parsedInput.object) {
if waits {
statusValue = String(localized: "agent.generic.status.waiting", defaultValue: "Waiting")
} else 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"
}
Expand All @@ -29570,9 +29619,10 @@ struct CMUXCLI {
workspaceId: workspaceId,
surfaceId: surfaceId,
value: statusValue,
icon: "bolt.fill",
icon: runsSubagents ? Self.subagentsStatusIcon : (waits ? "hourglass" : "bolt.fill"),
color: "#4C8DFF",
pid: claudePid
pid: claudePid,
workState: runsSubagents ? .subagents : (waits ? .waiting : .running)
)
printClaudeHookAck()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,31 @@ public struct AgentNotificationReconciler: Sendable {
if isResolution {
// A work observation is not a resolution. Only this explicit semantic
// event can retire an immutable request/child ID behind a newer watermark.
// A late reply still retires its own request, but it never reopens a
// turn that settled after it.
let fresh = draft.occurredAtMs >= session.occurredAtMs
// A late reply still retires its own request. Only a fresh running
// tool event may reopen a settled turn.
let fresh = draft.occurredAtMs > session.occurredAtMs
|| (draft.occurredAtMs == session.occurredAtMs && event.sequence > session.sequence)
let isIdentitylessToolStart = incomingTurn == nil
&& draft.nativeEvent?.caseInsensitiveCompare("PreToolUse") == .orderedSame
let incomingTurn = context?.turnIdentity
let isNewTurn = incomingTurn != nil
&& session.nativeTurn != nil
&& incomingTurn != session.nativeTurn
if draft.kind == .attentionResolved, draft.declaredPhase == .running, fresh,
!session.ended,
draft.pendingWork
|| (isNewTurn && incomingTurn.map { !session.seenTurns.contains($0) } == true)
|| (isIdentitylessToolStart && fresh) {
// A tool completion is progress, including a continuation
// that starts without UserPromptSubmit. Reopen only fresh
// activity with a new turn identity; an older or same-turn
// result cannot resurrect a settled pane.
if let incomingTurn, isNewTurn {
session.turn = incomingTurn
session.nativeTurn = incomingTurn
}
session.resumeWork()
}
if let request = context?.requestIdentity {
if draft.kind == .attentionResolved {
let wasResolved = session.resolvedRequests.contains(request)
Expand All @@ -101,8 +123,7 @@ public struct AgentNotificationReconciler: Sendable {
session.phase = .needsInput
} else if draft.declaredPhase == .idle {
// Only an idle declaration (a dismissed idle dialog) settles the
// turn. Feed declares every tool result `.running`, which must not
// reopen a turn that already completed.
// turn. Fresh tool activity explicitly reopens it below.
session.phase = .idle
session.rootStopped = true
} else if session.rootStopped && session.children.isEmpty {
Expand Down Expand Up @@ -162,9 +183,8 @@ public struct AgentNotificationReconciler: Sendable {
session.completionIdentity = nil
}
if draft.kind == .idleObserved {
let matchesTurn = context?.turnIdentity != nil && context?.turnIdentity == session.nativeTurn
guard !draft.pendingWork, session.attentionIdentities.isEmpty, session.children.isEmpty,
session.phase == .idle || session.phase == .unknown || (session.phase == .running && matchesTurn),
session.phase == .idle || session.phase == .unknown,
!session.ended else { return .init(.delayed, projectsLifecycle: false) }
session.phase = .idle
session.rootStopped = true
Expand Down Expand Up @@ -242,6 +262,22 @@ public struct AgentNotificationReconciler: Sendable {
session.ended = true
case .stateChanged:
if draft.declaredPhase == .running {
let isNewTurn = incomingTurn != nil
&& incomingTurn != session.nativeTurn
&& incomingTurn.map { !session.seenTurns.contains($0) } == true
let isFreshIdentitylessActivity = incomingTurn == nil
&& draft.nativeEvent?.caseInsensitiveCompare("PreToolUse") == .orderedSame
&& (draft.occurredAtMs > session.occurredAtMs
|| (draft.occurredAtMs == session.occurredAtMs && event.sequence > session.sequence))
// A tool event without a turn identity is ambiguous after a
// completion. Reopen only when its event timestamp/sequence is
// newer than the completion watermark; this admits promptless
// continuations while a late older event remains stale.
guard session.phase != .idle || isNewTurn || isFreshIdentitylessActivity else { break }
if isNewTurn, let incomingTurn {
session.turn = incomingTurn
session.nativeTurn = incomingTurn
}
session.rootStopped = false
session.pendingCompletion = nil
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ struct AgentLifecycleReducerTests {
#expect(state.combinedPhase(surfaceId: surface, agentKey: "claude_code") == .backgroundWorkPending)
}

@Test func toolActivityReopensASettledTurnAndLateStopIsIgnored() {
let state = fold([
event(1, .turnCompleted, occurredAtMs: 10),
event(2, .stateChanged, declaredPhase: .running, occurredAtMs: 20),
event(3, .turnCompleted, occurredAtMs: 10),
])
#expect(state.combinedPhase(surfaceId: surface, agentKey: "claude_code") == .running)
}

@Test func newTurnRemainsRunningWhileBackgroundWorkIsPending() {
let state = fold([
event(1, .turnStarted),
Expand Down
Loading
Loading