Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 4 additions & 16 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28954,10 +28954,8 @@ struct CMUXCLI {
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 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
// work: it must not mark the turn pending. Only authoritative
// background-work signals keep the turn out of Idle.
let hasUnsettledWork = stopFailure == nil && hasPendingBackgroundWork

// Update session with transcript summary and send completion notification.
Expand Down Expand Up @@ -29026,8 +29024,8 @@ 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`) keeps the pill on Running separately
// because it does not represent pending background work.
// (`stop_hook_active`) does not change this decision because
// it is not pending background work.
try? setClaudeStatus(
client: client,
workspaceId: workspaceId,
Expand All @@ -29037,16 +29035,6 @@ struct CMUXCLI {
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(
client: client,
Expand Down
10 changes: 6 additions & 4 deletions cmuxTests/ClaudeBackgroundWorkNotifyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct ClaudeBackgroundWorkNotifyTests {
(snapshot.compactMap(AgentHookTestNotificationPipeline.candidatePresentation) + snapshot).first { $0.hasPrefix("notify_target_async ") && $0.contains(needle) }
}

@Test func stopHookContinuationDoesNotPoisonTheLaterIdleSignal() throws {
@Test func reentrantStopWithoutBackgroundWorkSettlesIdle() throws {
let result = try runStopHook(name: "stop-continuation", sessionId: "continued-session", stdin: """
{"session_id":"continued-session","hook_event_name":"Stop","stop_hook_active":true,"last_assistant_message":"Intermediate response","background_tasks":[],"session_crons":[]}
""")
Expand All @@ -22,9 +22,11 @@ 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)
// Hook recursion is not evidence of live work. The final re-entrant
// Stop must settle the pane so it cannot remain stuck on Running until
// another user prompt arrives.
#expect(statusLine(result.snapshot, value: "Idle") != nil)
#expect(statusLine(result.snapshot, value: "Running") == nil)
#expect(statusLine(result.snapshot, value: "Waiting") == nil)
}

Expand Down
8 changes: 5 additions & 3 deletions tests/test_claude_hook_stop_last_assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,13 +264,15 @@ def main() -> int:
print(f"FAIL: incorrect semantic completion: {notifications[0]!r}")
return 1
if not any(
command.startswith("set_status claude_code Running ")
command.startswith("set_status claude_code Idle ")
and f"--tab={workspace_id}" in command
and f"--panel={surface_id}" in command
and "--work=running" in command
for command in server.commands
):
print(f"FAIL: re-entrant Stop with stop_hook_active=true did not keep Running: {server.commands!r}")
print(f"FAIL: re-entrant Stop with stop_hook_active=true did not settle Idle: {server.commands!r}")
return 1
if any(command.startswith("set_status claude_code Running ") for command in server.commands):
print(f"FAIL: re-entrant Stop with stop_hook_active=true set Running: {server.commands!r}")
return 1

print("PASS: Claude cron guard denies durable jobs and Stop notification uses final assistant text")
Expand Down
Loading