Repository navigation
fix(agent-status): a Claude background shell counts as live work from the tool call that starts it - #24116
fix(agent-status): a Claude background shell counts as live work from the tool call that starts it#24116brennanb2025 wants to merge 11 commits into
Conversation
… the tool call that starts it A per-pane record of Claude's non-agent background tasks, keyed by Claude's task id with the launching tool_use_id and the task's kind, replaces the running-shell boolean. The launching PostToolUse (tool_response.backgroundTaskId) and the Stop inventory start it; the next inventory, a TaskStop result, and the queue-operation enqueue line Claude writes when a task ends (matched by task id and launch tool_use_id) end it. A session change keeps it: a shell survives /clear. The transcript watch gains one reason (a launch-recorded task) and one fact, and arms at the start of a cleared session's file Claude has not created yet. A watch row carrying a fact is marked transcriptFact, on the relay envelope too, and the desktop's cancel latch re-folds it under a cancel the relay never learned. The desktop no longer mirrors a relayed pane's shell fact; it reads the row. The record commits on a cancel-latch hold: a shell launched by a hook in flight at the Ctrl+C runs.
…relayed end line after the latch window
|
Comments Outside DiffThese findings could not be posted inline.
|
…e launching turn's tail A startup or resume SessionStart retires the record again, as before this branch: a new process can never end its predecessor's tasks, so keeping them showed Monitoring over a fresh idle Claude until its first Stop. /clear keeps the record, and while a shell survives it the pane keeps the turn record that launched it (verdict and turn stamp). The SessionStart row no longer reads as a new turn, so a /tasks kill before any prompt no longer fires a second completion notification.
…re its launch hook A background command can end before Orca handles the PostToolUse that launched it. Its queue-operation end line then sits behind the watch: a new cursor arms at the file's end, and an existing one already read the line during the pre-event catch-up, when no recorded task matched. In a Ctrl+C'd turn (no Stop) the pane stayed on Monitoring over an ended shell until the next turn's Stop. Accepting the launch now reads back a bounded tail (256 KiB) once for that launch's exact task id and tool-use id pair.
…captured order Claude's notification turn opens (47.504 s) before the task's end line (47.551 s), so the pane reads working through the end line and done at that turn's Stop. The story replayed the end line first and asserted a done the real order never produces.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the full diff of this stacked PR (step 2 of 3 on top of #24075): a Claude background shell now counts as live work from the tool call that launches it and ends on Claude's own record of its end.
- Per-pane task record replaces the boolean set.
claudeRunningNonAgentTaskPaneKeysbecomesclaudeNonAgentWorkByPaneKey, keyed by Claude's task id, each with kind and launchingtool_use_id(claude-non-agent-work.ts). - Launch and inventory producers. A main-agent
PostToolUsetool_response.backgroundTaskIdrecords the task (kindunknown); aStopbackground_tasksinventory replaces the record, keeping each still-running task's launch. - Three end producers. The next inventory that omits the task, a
TaskStop/KillShell/KillBashresult naming it, and thequeue-operationenqueueline Claude writes on task end (retired only when one terminal notification names both the task id and the launch tool-use id). - Transcript watch. Gains the
recorded-taskreason and the end-line fact, with a two-substring line pre-filter so the per-turnprompt_snapshotrow never reachesJSON.parse; the cursor can arm at the start of a session file Claude has not created yet. - Session change is not an end.
SessionStartno longer deletes the record, so a shell surviving/clearkeeps the pane Monitoring until its end line in the new transcript. - SSH/cancel-latch. A new optional
transcriptFactenvelope field marks a watch row that carries a fact, and the desktop latch re-folds it under an inferred cancel (R1-6); the desktop keeps no record for relayed panes and reads the row'sclaudeRunningNonAgentTask. - Latch accept-on-hold.
applyNormalizedStatusnow runsonAcceptedwhen the latch holds the row, committing the background evidence of an in-flight launch;onAcceptedis only ever the local background-evidence writer.
I read the complete diff and ran the new and changed tests (claude-non-agent-work, the background-shell capture and relayed-shell suites, the interrupt-inference guards, transcript watch, ended-process reconciliation, session replacement, parity, task status, and the renderer completion coordinator) — all pass. The author-documented residual risks (record surviving a startup SessionStart, the pane staying watched for the shell's lifetime, launch recording not gated on tool name) all fail toward Monitoring and are bounded by the next inventory; nothing here warrants a change before merge.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…e the test launch The look-back ran at two exits with an untyped cursor literal, which tsc rejected (no `unpublished`). Sync now falls through to one call on the cursor it left, which also keeps the watch under the line cap.
…d its notification turn In the raw capture the end line (stamped 07:51:30.885Z) was on disk before the notification turn's prompt hook ran (30.951Z, file at 6233 bytes); the fixture's transcript `t` is the rig's poll time. Orca can handle either first, so the /clear story and the completion test now replay both orders, each with one completion after /clear. Also words the /clear carry as the last turn that ended, which need not be the launching turn, and notes an uninferred Esc is not covered yet.
…rocess is replaced or ends The store doc and two comments said a shell leaves only on Claude's own record of its end, but a startup or resume SessionStart, process or PTY exit, and teardown clear the record too. Comment, doc and test title only.
There was a problem hiding this comment.
ℹ️ No critical issues — one description-accuracy nit below; no code concerns in the new commits.
Reviewed changes — the delta after the prior pullfrog review (e651b3e339), 6 commits on the stacked base branch (brennanb2025/claude-transcript-watch):
SessionStartnarrowed to the process boundary.startup/resumeclear the background-task record again; only/clearkeeps it and pins the pane to the last ended turn's verdict and turn stamp (claude-events.ts,listener-state.ts).- Bounded look-back for a task that ended before its launch hook.
retireClaudeTaskEndedBeforeLaunchHookreads back 256 KiB from the cursor and retires only on an exact task-id/tool-use-id pair match. - Cursor-sync refactor.
syncClaudeTranscriptCursornow always runs that look-back from the single cursor it leaves and marks the retired fact unpublished so the next tick publishes the row. - Both-order
/cleartests. The server capture and the renderer completion coordinator each replay the shell's end line and the notification turn in either order. - Docs + shared pre-filter.
agent-status-store.mdrestates the reset and the look-back;mayBeClaudeTaskEndLineis now shared by the watch reason and the look-back.
I read the full delta (git diff e651b3e339...HEAD, 8 files, +399/-84) and the changed files, and ran claude-non-agent-work, claude-transcript-watch, and server-claude-background-shell-captures (40 passed); pnpm tc:node and oxlint on the changed files are clean. The look-back fires only after onAccepted commits the record and after the pre-event catch-up has moved the cursor to the file end, on both the local and relay hosts, so its window is measured from the end; its failure mode either way is Monitoring until the next inventory, matching the author's stated bound.
ℹ️ Nitpicks
- The PR body's "Not an end: a session change" still states
SessionStart(startup,resume,clear) no longer deletes the shell fact and that "the record now survives a session change"; commit7506282d84narrowed that to/clearonly. Its "Still not fixed" list also still names "a shell that ends before its launchingPostToolUsereaches Orca", which commit5a8ac4daa8now covers with the bounded look-back.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
Review status: ready for your review (not merged)The problem. In a Claude terminal pane, when Claude starts a background shell (a dev server, a test run, any What you see now. The pane shows Monitoring from the tool call that starts the shell until the shell really ends, then Done. A Where this sits. This PR is step 2 of 3 and is stacked on #24075 (one transcript watch per pane). Step 3 is the Deny/Esc PR, which merges after this one. Fixed during review
Deferred (not fixed here)
Not caused by this PR (seen during QA)
Verified
Not verified
Live QA (remote Mac, real Claude Code 2.1.285, isolated home, hidden dev Orca)Base = #24075 head
Not exercised live: SSH, WSL, Windows and Linux panes; a workflow Ctrl+C'd while between agent steps; a command that ends before its launch hook is handled. Wording correction ( |
…lay, not SSH An SSH pane never gets a desktop-inferred Ctrl+C (its input is not delivery-confirmed), so the relayed cancel case these stories cover is WSL-only.
|
Closing this older stacked implementation and rebuilding background-command tracking on current main. The request remains to show a shell from its launch until its own end, including after cancellation or a task-panel kill. |













ELI5
The bug. In a Claude terminal pane, Claude starts a background shell (a dev server, a test run, any
Bashcall withrun_in_background), and in that same turn you press Ctrl+C. Orca shows the pane done, interrupted while the shell keeps running, and corrects itself only when some later turn ends.Why it happens. Orca learns that a shell is running only from the list of background tasks Claude attaches to its end-of-turn
Stophook. Ctrl+C sends no hook and noStop(in all six captured Ctrl+C runs, no hook arrived for 20 s after the key), so when Orca records the cancel it has never heard of the shell. ThePostToolUsehook that launches the shell already names it (tool_response.backgroundTaskId), but nothing in Orca read that field.Two related bugs. Killing a shell from Claude's
/taskspanel sends no hook and starts no turn, so the pane keeps reading Monitoring until your next prompt. And/clearmade Orca forget a shell that Claude keeps running.What you see after this PR:
/taskskill: the pane clears within about a second. At the idle prompt it goes from Monitoring to done; mid-turn, the shell drops off the working row./clearwith a shell still running: the pane keeps Monitoring until the shell really ends, and the shell's end does not raise a second "finished" notification for a turn that was already announced.Scope.
worktree ps, mobile and the dashboard all read the same status row, so they all change together.Keep-awake (Auto mode) and STA-8711. A Monitoring pane reports
state: workingwithworkingMode: monitoring, which keeps the computer awake in Auto mode (up to 2 h after its last update). This PR adds one more situation of that shape (a shell left running after a Ctrl+C), exactly as a cleanStopwith a running shell already does on main, and it releases the hold sooner after a/taskskill. Tools that readstate(STA-8711's watchdog,worktree psscripts) seeworkingthere too;workingModetells a finished agent with a live shell from a busy one. #22322 changes whether a background watcher keeps the computer awake.The plan. This PR is step 2 of 3:
A later change then turns on Claude's own interrupt notice for every turn (STA-2413), which retires the temporary pieces listed under Why.
What Changed
The record replaces a yes/no flag. The per-pane set "this pane has a running shell" is replaced by a per-pane record of Claude's non-agent background tasks (
claude-non-agent-work.ts). Each task is keyed by Claude's own task id and keeps the id of the tool call that launched it and its kind (command, monitor, workflow, or unknown). A separate flag covers running work that Claude reported without an id. The record lives in the hook listener on the host that runs Claude: the desktop for a local pane, the relay for SSH and WSL. It replaces the old set rather than sitting beside it, and it is never saved to disk.How a task enters the record:
PostToolUsewithtool_response.backgroundTaskId(new). Its kind is unknown until Claude's end-of-turn list names it, and unknown counts as running. A subagent's launch is not recorded, because its shell is the subagent's own work.background_taskslist on a main-agentStop, which replaces the whole record and keeps the launching tool call of each task still running.How a task leaves the record:
/tasks, even while Claude sits idle and sends no hook), Claude writes onequeue-operationenqueueline to its transcript carrying the task's notification, 0.12 to 0.17 s after the key. A prompt you type while Claude is busy writes the same kind of line with only your text, so the line proves nothing on its own. It retires a task only when it is exactly one notification with an ended status naming both the task id and the launching tool call's id that Orca recorded. Claude creates both ids fresh for every launch and never shows the tool call's id on screen.TaskStopresult (also sent under the namesKillShellandKillBash) naming the task. Claude writes no end line for these.SessionStartwith sourcestartuporresumeclears the record, as it cleared the old flag, because a new process can never end its predecessor's tasks, so keeping them would show Monitoring over a fresh idle Claude./clearkeeps the record. Captured: the shell survives/clear(still running 28 s later), and its end line lands in the new session's transcript. The new session's transcript file appears about 0.09 s after itsSessionStarthook, so the watch now starts at the beginning of a file that does not exist yet (in a directory that does) and reads it once Claude creates it. While a shell survives, the pane also keeps the record of the last turn that ended, including that turn's completion time and verdict. The shell's end therefore closes out that turn instead of being announced as a new one, which would fire a second "finished" notification.A command that ends before Orca handles its launch. A command that fails at once can end, and Claude can write its end line, before Orca has handled the hook that launched it. The watch then starts reading after that line, or has already read past it while the task was unknown. When Orca accepts the launching hook, it now reads back once over the last 256 KiB of the transcript for that launch's exact id pair. A launch's end line can precede its hook only by the lines Claude writes around that one tool call, and Claude's largest lines, its per-turn snapshots, run about 55 KB. The read-back only reads: it never moves the watch's position, and it adds no timer.
The watch. #24075's watch gains one reason to keep reading (the record holds a task whose launch Orca saw) and one fact (the end line above). It skips any line that lacks both
"type":"queue-operation"and<task-notification>before parsing, because Claude's roughly 55 KB per-turn snapshot line quotes the notification tag.WSL (the local relay). A WSL pane's relay never learns about a Ctrl+C the desktop inferred, so every row it sends afterwards still says its main agent is working. SSH panes never reach this case: the desktop infers a Ctrl+C only from input it has confirmed was delivered, and input to a remote SSH pane is never confirmed that way. For 15 s after an inferred Ctrl+C, the desktop keeps the cancel against rows that merely restate older hooks. When the relay's watch retires a shell after that window (a
/taskskill 20 s later), its row would have shown Working over an idle Claude. A row the watch builds from a transcript fact is now markedtranscriptFact: a new optional field, carried on the relay envelope too. The desktop applies such a row under the cancel it holds, as it already does for a subagent's row and a replayed row, so the pane reads done, interrupted.Readers.
claudeRunningNonAgentTaskflag from the record, through refactor(agent-status): one Claude transcript watch per pane, read before every event #24075's single stamp function.claudeRunningNonAgentTask, and only for rows from relays too old to send the main agent's own state.claude-background-task-kind.tsand gainsstopped, a status Claude's task notifications use. The end-of-turn list and the end-line parser share it.A launch still in flight at the Ctrl+C. When the desktop keeps the cancel against a late event, it now still records that event's shell: it keeps the cancel on screen, but the shell really runs. What the pane shows in that case is listed under "Still not fixed".
Docs.
docs/reference/agent-status-store.md(tracked) states the record, how a task enters and leaves it, and the/clearand SessionStart rules.Changes to existing tests.
trueafter a livefalseto be ignored; a relay replays the row it last sent, so the replay is its current record.Why
Root cause over patch. The bug is that a shell becomes known only at
Stop. This PR makes it known at its launch and ends it on Claude's own record of that task's end (or when the Claude process is replaced or ends, as before), with the end-of-turn list kept as the check that corrects anything the end edges miss. The cancel model from #22476 is untouched: a cancel is still only a verdict on the main agent, combined with the work the turn left running. This PR changes what goes into that combination, not how it combines.Alternatives considered:
Stoplist only: that is the bug./taskskill, and the second is missing on older builds.UserPromptSubmithook, matched by the id pair: fewer parts, but it misses the idle/taskskill, which fires no hook.SessionStart: a new process's tasks can never end, so a crashed or killed Claude would leave the next one on Monitoring. Only/clearis captured keeping its shells.#22322 (keep-awake). #22322 replaces the same flag with its own working/monitoring map. Since this record already holds each task's kind, whichever of #22322 and this PR lands second should derive that liveness from this record instead of storing a second map.
Wire compatibility. Desktop and relay never run different versions. An SSH relay is version-locked to its desktop, because the connection handshake refuses a version mismatch. A WSL relay runs the bundle its Orca shipped with. The one new envelope field (
transcriptFact) is optional, and a decoder that predates it drops it. The published row shape is unchanged. What the relay publishes changes only in timing:claudeRunningNonAgentTask: truefrom the launch (was: only atStop), andfalsefrom the end line orTaskStop(was: the nextStop).Differences from the reference implementations, labelled:
PostToolUsehook'sbackgroundTaskId, not a task-start event from a streaming session. Intended: the hook path has no start event, and every captured launch id reappears in Claude's list or end line.queue-operationend line matched by both recorded ids, not a task-notification or task-update event from a streaming session. Intended: the line is written at task end with the same two ids, and matching both makes an accidental match impossible.Stoplist replaces the record (the primary references never re-derive it). Intended: the hook path's end signals are incomplete (tasks Orca never saw launch, unreadable files), so something must retire what they miss./clearkeeps the record (the primary references settle open tasks when a session is replaced). Intended:/clearkeeps the same process, and captured shells survive it and report their end in the new transcript. A new process'sstartup/resumeclears the record, as in the references.SessionEnddoes not end a task. Temporary:/exitkills shells by default beforeSessionEnd, and process exit clears the pane. A follow-up adds it only if Orca shows a stale row that needs it, with an owner check against a nestedclaude -p.Linked Issue
Part of STA-3344 (#12382). Its reporter's rows are background workflows. Whether a workflow launch carries
backgroundTaskIdis not captured, and no live QA of a workflow started and Ctrl+C'd in the same turn has run yet, so this PR does not claim to fix it.Related: STA-2794 (#10997), STA-8711 (#23414), #22322. #12724 is superseded by #22476 alone (flagged, not closed).
Visual Proof
Live QA (remote Mac, real Claude Code 2.1.285, isolated home, hidden dev Orca)
Base = #24075 head
fe3a427c8d6; this PR =e651b3e339b(all four scenarios) and the reviewed heada3d97b99ddf(scenarios 1–3 re-run; the only later commit3391636c997changes comments). Notifications were counted from Orca's notification record, which also keeps notifications suppressed for being too close together.sleep 40runs/taskskill while Monitoring/clearwith a running shell/clearwhile the shell ran ~27 s more/clear, cleared 0.19 s after the shell ended; exactly one completion (Claude's own turn after the shell ended)/clear, then an idle/taskskilltaskId/taskType: local_workflow, notbackgroundTaskId, so this PR does not record it/taskskill/clear, shell running/clearNot exercised live: SSH, WSL, Windows and Linux panes; a workflow Ctrl+C'd while between agent steps; a command that ends before its launch hook is handled.
Testing
New capture fixtures (
src/shared/__fixtures__/claude-background-shell-{ctrl-c,tasks-kill-idle,tasks-kill-busy,clear,typed-while-busy}-hooks.jsonl, sidecars beside them), recorded from Claude Code 2.1.284/2.1.285 over a real terminal, in #22722's format: hook payloads merged in time order with the driver's keys and Claude'squeue-operationtranscript lines. Scrubbed: every session, prompt and row id maps to a placeholder distinct per fixture; task ids and tool call ids are kept verbatim; onlyqueue-operationlines are kept.New tests, and what each reads on the base (#24075):
/taskskill settles the pane in under 2 s, carrying theStop's completion time. Base: Monitoring until the next prompt./taskskill mid-turn drops the shell from the working row. Base: the flag stays true./clearkeeps the shell and the last finished turn's completion time, and settles on the end line in the new transcript, in both orders the capture allows: the end line before the notification turn's prompt (the captured write order) and after it. Base: done right after/clearwhile the shell runs.startupandresumeclear the record, and the row reads done. Base: the same./taskskill after a clean end of turn, a/taskskill after/clear, and the shell ending by itself after/clearin both orders (exactly one notification, for Claude's own notification turn). Base: the watch never starts.TaskStopunder all three names, aStoplist replacing the record and keeping the launch, the 64-task cap, the line skip before parsing, and the parser's refusals (typed text, a running status,remove, two notifications, a task only aStoplist named).Ablations (one production change reverted at a time; named test files; restored after). The first ten were run before the review fixes; the last three were run on the reviewed code:
/tasks,/clearand relayed story.TaskStopproducer: 1 fails./clearstory fails./tasksstory and the no-second-notification test fail.startup/resumekeep the record: the new-process test fails (the row reads working)./clearresets the last turn's record: 4 fail (two notifications instead of one in the/tasks-after-/clearand end-line-first tests, and both/clearstories lose the completion time).Runs:
tc-gate pnpm tc:node,tc:webandtc:clipass.pnpm run check:code-quality:changed: 0 new findings.oxlinton every touched file: clean. Vitest, 103 named files (the listener, hook server, relay, watch, inference, completion logic and runtime suites, includingorca-runtime.test.ts): 2519 passed, 1 skipped.Platforms: macOS vitest only. No Linux, Windows, SSH or WSL run; live QA on macOS is under Visual Proof.
Review
Still not fixed. Each fails toward Monitoring or toward today's behaviour, never toward hiding a running shell, unless stated otherwise:
/clear, then the shell ends: a "finished" notification fires (before this PR,/clearforgot the shell and none fired). Orca does not yet read Esc as a cancel, so that turn never ended, and/clearhas no finished turn to keep. Temporary: the planned follow-up that reads Claude's own interrupt notice for every turn (STA-2413) ends that turn on Esc, which puts this case on the covered/clearpath./tasks: only the nextStoplist can retire it.claude -pin the pane moves the watch to its own transcript (refactor(agent-status): one Claude transcript watch per pane, read before every event #24075's known limit)./resume, if Claude reports it asresume, clears the record, so its shell reads done while running, the same as before this PR.Unchanged hides, equal to main: an Orca or relay restart forgets a running shell until the next
Stop.Cost: a pane stays watched for as long as a launched shell runs, possibly hours: one
statper second while idle, and each new byte read once while busy. Each background launch adds one read of at most 1 MiB (the 256 KiB read-back plus any bytes not yet read). Not measured over an hour.Not verified: a live run on Linux, Windows, WSL or SSH; the workflow route for STA-3344; the
KillShell/KillBashhook names (taken from the binary's alias list); a real command that ends before its launch hook is handled (no capture has that order).Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)