Skip to content

fix(agent-status): a Claude background shell counts as live work from the tool call that starts it - #24116

Closed
brennanb2025 wants to merge 11 commits into
brennanb2025/claude-transcript-watchfrom
brennanb2025/claude-background-shell-record
Closed

brennanb2025 wants to merge 11 commits into
brennanb2025/claude-transcript-watchfrom
brennanb2025/claude-background-shell-record

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 24 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1334 $\color{#cf222e}{\Huge{\mathbf{−}}}$​41 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1293
Prod 22 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​558 $\color{#cf222e}{\Huge{\mathbf{−}}}$​150 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​408

ELI5

The bug. In a Claude terminal pane, Claude starts a background shell (a dev server, a test run, any Bash call with run_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 Stop hook. Ctrl+C sends no hook and no Stop (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. The PostToolUse hook 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 /tasks panel sends no hook and starts no turn, so the pane keeps reading Monitoring until your next prompt. And /clear made Orca forget a shell that Claude keeps running.

What you see after this PR:

  • Ctrl+C in the turn that started a background shell: the pane reads Monitoring, stopped by you for as long as the shell runs, then done within about a second of the shell ending. This needs the launching hook to have reached Orca before the key; see "Still not fixed" for the other order.
  • A /tasks kill: 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.
  • /clear with 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.
  • A background command that fails or finishes before Orca has handled its launching hook no longer leaves a Ctrl+C'd pane on Monitoring.
  • The same on SSH and WSL panes, where the relay on the remote host does the work.

Scope.

  • Surface: Claude sessions running in Orca terminal panes (the hook-driven status path), local, SSH and WSL. The sidebar, worktree ps, mobile and the dashboard all read the same status row, so they all change together.
  • Conditions: only panes where Claude's main agent has started a background task Orca saw launch, or where Claude's end-of-turn list reports one.
  • Not affected: panes with no background task (their rows are identical to before), Claude's own subagents and the shells they start, Claude chats in Orca's chat view (a separate status path), and every other agent (Codex, Gemini and the rest).

Keep-awake (Auto mode) and STA-8711. A Monitoring pane reports state: working with workingMode: 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 clean Stop with a running shell already does on main, and it releases the hold sooner after a /tasks kill. Tools that read state (STA-8711's watchdog, worktree ps scripts) see working there too; workingMode tells 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:

  1. refactor(agent-status): one Claude transcript watch per pane, read before every event #24075 (this PR's base): one watch per pane on Claude's own session log (its "transcript"), read by the host that runs Claude.
  2. This PR: a background shell counts as live work from the tool call that starts it, and stops counting on Claude's own record of its end, or when the pane's Claude process is replaced or ends.
  3. The Deny/Esc PR: answering a Claude permission prompt with No, or pressing Esc, ends Orca's "needs you" wait and is recorded as a cancel. It merges after this PR, because its cancel would otherwise show done over a shell launched earlier in the same turn.

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:

  • The main agent's launching PostToolUse with tool_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.
  • The background_tasks list on a main-agent Stop, which replaces the whole record and keeps the launching tool call of each task still running.

How a task leaves the record:

  • Claude's end line (new). However a task ends (finished, killed with its turn's Ctrl+C, killed from /tasks, even while Claude sits idle and sends no hook), Claude writes one queue-operation enqueue line 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.
  • A TaskStop result (also sent under the names KillShell and KillBash) naming the task. Claude writes no end line for these.
  • The next main-agent end-of-turn list that omits it.
  • Claude's process ending, the terminal closing, or the relay stopping, which clear the whole pane (unchanged).
  • A new Claude process starting in the pane: a SessionStart with source startup or resume clears 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.

/clear keeps 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 its SessionStart hook, 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 /tasks kill 20 s later), its row would have shown Working over an idle Claude. A row the watch builds from a transcript fact is now marked transcriptFact: 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.

  • Every row the listener or the watch builds takes its claudeRunningNonAgentTask flag from the record, through refactor(agent-status): one Claude transcript watch per pane, read before every event #24075's single stamp function.
  • The desktop's copy of a relayed pane's shell fact is deleted. Its only relayed-pane reader, the Ctrl+C guard, now reads the row's own claudeRunningNonAgentTask, and only for rows from relays too old to send the main agent's own state.
  • The table of ended task statuses moves beside the task-kind table in claude-background-task-kind.ts and gains stopped, 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 /clear and SessionStart rules.

Changes to existing tests.

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:

  • Keep the Stop list only: that is the bug.
  • Infer shell death from the process table: nothing maps a process to Claude's task id, it cannot see SSH hosts, and on Windows a process walk runs against the security-software posture.
  • Two "structural" transcript lines (the notification Claude absorbs into its next turn, and the turn that reports on it): they miss the idle /tasks kill, and the second is missing on older builds.
  • Retire on the notification turn's UserPromptSubmit hook, matched by the id pair: fewer parts, but it misses the idle /tasks kill, which fires no hook.
  • A timer: it re-derives nothing, and it would hide live work.
  • Keep the record on every SessionStart: a new process's tasks can never end, so a crashed or killed Claude would leave the next one on Monitoring. Only /clear is captured keeping its shells.
  • Watch from an earlier point in the file for every launch: a fixed read-back for the one launch being handled covers the early-end case without rereading older history.

#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: true from the launch (was: only at Stop), and false from the end line or TaskStop (was: the next Stop).

Differences from the reference implementations, labelled:

  • The start is the launching PostToolUse hook's backgroundTaskId, 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.
  • The end is Claude's queue-operation end 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.
  • The Stop list 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.
  • Nothing is settled at an interrupt (the references settle open tasks when their own interrupt stops the session). Intended: Orca does not own the Ctrl+C, and Claude keeps the shell.
  • /clear keeps the record (the primary references settle open tasks when a session is replaced). Intended: /clear keeps the same process, and captured shells survive it and report their end in the new transcript. A new process's startup/resume clears the record, as in the references.
  • SessionEnd does not end a task. Temporary: /exit kills shells by default before SessionEnd, 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 nested claude -p.
  • The record lives in the hook listener, not in the shared child-work model. Temporary: that model admits structured sessions only and the relay has no canonical store; the stored tool call id makes the later move to the host-owned store mechanical.
  • Claude's interrupt notice is not turned on for turns with a watched shell. Intended here: the later STA-2413 change turns it on for every turn.

Linked Issue

Part of STA-3344 (#12382). Its reporter's rows are background workflows. Whether a workflow launch carries backgroundTaskId is 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 head a3d97b99ddf (scenarios 1–3 re-run; the only later commit 3391636c997 changes comments). Notifications were counted from Orca's notification record, which also keeps notifications suppressed for being too close together.

Scenario Base This PR
1. Ctrl+C while a background sleep 40 runs Interrupted for ~31 s while the shell ran, plus a "Claude stopped" notification Monitoring ~1.7 s after the Ctrl+C until the shell ended, then Done; no notification at the Ctrl+C
2. /tasks kill while Monitoring Monitoring for ~40 s with no shell left, until the next prompt Done 0.94 s after the kill; no notification at the kill
3. /clear with a running shell Done at /clear while the shell ran ~27 s more Monitoring kept across /clear, cleared 0.19 s after the shell ended; exactly one completion (Claude's own turn after the shell ended)
3b. /clear, then an idle /tasks kill (shell already forgotten) Done 0.54 s after the kill; no second "finished"
4. Background workflow + Ctrl+C Working until the workflow ended (existing subagent tracking) Same as base: never Monitoring. A workflow launch carries taskId / taskType: local_workflow, not backgroundTaskId, so this PR does not record it
Base This PR
1. +3 s after Ctrl+C, shell running base: Interrupted after Ctrl+C this PR: Monitoring after Ctrl+C
1. Shell ended base: shell ended this PR: Done after the shell ended
2. +2 s after the /tasks kill base: still Monitoring after the kill this PR: Done after the kill
3. +5 s after /clear, shell running base: Done after /clear this PR: Monitoring after /clear
3. Shell ended this PR: Done after the shell ended
3b. +2 s after the kill that follows /clear this PR: Done, no second notification
4. +3 s after Ctrl+C with a workflow running base: Working via the workflow's agent this PR: same as base

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.

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's queue-operation transcript lines. Scrubbed: every session, prompt and row id maps to a placeholder distinct per fixture; task ids and tool call ids are kept verbatim; only queue-operation lines are kept.

New tests, and what each reads on the base (#24075):

  • Ctrl+C in the launching turn keeps the pane Monitoring while the shell runs, and settles on Claude's end line (ctrl-c fixture). Base: done, interrupted.
  • A matched end line retires the task; the captured typed-while-busy line, and the captured end line with the tool call id or the task id swapped (hand-built), do not. Base: nothing watches for a shell.
  • An idle /tasks kill settles the pane in under 2 s, carrying the Stop's completion time. Base: Monitoring until the next prompt.
  • A /tasks kill mid-turn drops the shell from the working row. Base: the flag stays true.
  • /clear keeps 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 /clear while the shell runs.
  • startup and resume clear the record, and the row reads done. Base: the same.
  • No second "finished" notification: the real listener and watch feed the real renderer completion logic for a /tasks kill after a clean end of turn, a /tasks kill after /clear, and the shell ending by itself after /clear in both orders (exactly one notification, for Claude's own notification turn). Base: the watch never starts.
  • A command that ends before Orca handles its launch hook is found by the read-back, both when the watch starts at the file's end and when it had already read past the line. After a Ctrl+C in that turn, the pane reads done, interrupted (a hand-placed end line on the ctrl-c fixture). Base: done, interrupted, because the shell was never known.
  • A relayed pane (WSL's local relay): the relay's launch row turns the desktop's Ctrl+C into Monitoring. Base: done.
  • A relayed pane (WSL's local relay), the end line read 20 s after the Ctrl+C: the desktop reads done, interrupted, not Working. Base: done throughout, so it fails at the Monitoring step.
  • A launch whose hook lands after the inferred Ctrl+C (hand-placed order) is recorded: the pane keeps reading done, interrupted, and the next cancelled turn reads Monitoring. Base: never recorded.
  • Unit tests for the record: launch, a subagent's launch ignored, TaskStop under all three names, a Stop list 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 a Stop list 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:

  • Drop the launch producer: 18 tests fail, including every captured Ctrl+C, /tasks, /clear and relayed story.
  • Drop the end-line fact: 8 fail.
  • Match the end line by task id only: 3 fail (the swapped tool call id retires the shell).
  • Drop the TaskStop producer: 1 fails.
  • No watch on a not-yet-created file: the /clear story fails.
  • Do not apply a fact row under a held cancel: the relayed 20 s story reads Working, main agent working.
  • Do not record a late launch when the cancel is held: the in-flight launch story fails.
  • Skip lines on the notification tag alone: the snapshot line reaches the parser.
  • The relayed Ctrl+C guard reads the listener record instead of the row: 2 guards fail.
  • The watch row drops the completion time: the /tasks story and the no-second-notification test fail.
  • startup/resume keep the record: the new-process test fails (the row reads working).
  • /clear resets the last turn's record: 4 fail (two notifications instead of one in the /tasks-after-/clear and end-line-first tests, and both /clear stories lose the completion time).
  • No read-back at launch: 3 fail (both read-back unit tests, and the early-end Ctrl+C story reads Monitoring).

Runs: tc-gate pnpm tc:node, tc:web and tc:cli pass. pnpm run check:code-quality:changed: 0 new findings. oxlint on every touched file: clean. Vitest, 103 named files (the listener, hook server, relay, watch, inference, completion logic and runtime suites, including orca-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.

  • I manually tested these changes (live QA on a remote Mac, above)
  • Automated tests added/updated, or explained why not below

Review

Still not fixed. Each fails toward Monitoring or toward today's behaviour, never toward hiding a running shell, unless stated otherwise:

  • A launch whose hook reaches Orca more than half a second after the Ctrl+C: the shell is recorded, but the pane reads done, interrupted while it runs, the same as before this PR. The next cancelled turn reads Monitoring. None of the six captured Ctrl+C runs has a hook that late. Temporary: the fix needs a change to how the desktop holds the cancel against late hooks, left for a follow-up.
  • Deny a permission prompt with feedback, then Claude launches a background shell in the same turn: the idle pane reads "waiting" until your next prompt, and Monitoring then outlives the shell by one turn. Same as before this PR. Temporary: the Deny/Esc PR (step 3) ends the permission wait on Claude's own end of turn.
  • Esc in the turn that launched a shell, then /clear, then the shell ends: a "finished" notification fires (before this PR, /clear forgot the shell and none fired). Orca does not yet read Esc as a cancel, so that turn never ended, and /clear has 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 /clear path.
  • When Claude kills its background agents and a shell's end line arrives in the same read, the row loses the note that it stands for the agent's stop. This matters only if that agent's permission prompt is held on screen in the same second. Temporary: a small follow-up lets the agent's stop take precedence.
  • A shell whose launch Orca never saw (after an Orca or relay restart, or started before Orca was listening) and that is then killed from /tasks: only the next Stop list can retire it.
  • A shell started by a subagent, a command backgrounded with Ctrl+B or by timeout, and a background workflow launch: none captured.
  • A transcript the watch cannot read: a WSL path handed to Windows directly, more than 1 MiB written between two reads, or an unreadable file.
  • A nested claude -p in 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).
  • An in-process /resume, if Claude reports it as resume, 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 stat per 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/KillBash hook 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

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

… 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.
@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium risk] Changes how background shell tasks are tracked and reported.

No new blocking issue was introduced since the previous review.

Summary

The PR records Claude background tasks when their launching tool calls arrive, retires them from matched transcript notifications, and carries that status through /clear and relayed panes. Since the previous review, the only change clarifies a test comment about WSL and SSH.

Reviews (5) · Last reviewed commit: "test(agent-status): name the relayed Ctr..."

@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Comments Outside Diff

These findings could not be posted inline.

  • P1 Late launch stays hidden src/main/agent-hooks/server/server-status-update.ts:163 ▶

    If a background shell’s PostToolUse hook arrives after Orca infers Ctrl+C, this branch records the shell but keeps the pane’s previous done, interrupted row. The transcript watch can see the new record, but the cancel latch holds its record-only update, so the pane hides a running shell until another hook arrives. The in-flight-launch test also expects the row to stay done immediately after the shell is recorded.

…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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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. claudeRunningNonAgentTaskPaneKeys becomes claudeNonAgentWorkByPaneKey, keyed by Claude's task id, each with kind and launching tool_use_id (claude-non-agent-work.ts).
  • Launch and inventory producers. A main-agent PostToolUse tool_response.backgroundTaskId records the task (kind unknown); a Stop background_tasks inventory replaces the record, keeping each still-running task's launch.
  • Three end producers. The next inventory that omits the task, a TaskStop/KillShell/KillBash result naming it, and the queue-operation enqueue line 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-task reason and the end-line fact, with a two-substring line pre-filter so the per-turn prompt_snapshot row never reaches JSON.parse; the cursor can arm at the start of a session file Claude has not created yet.
  • Session change is not an end. SessionStart no longer deletes the record, so a shell surviving /clear keeps the pane Monitoring until its end line in the new transcript.
  • SSH/cancel-latch. A new optional transcriptFact envelope 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's claudeRunningNonAgentTask.
  • Latch accept-on-hold. applyNormalizedStatus now runs onAccepted when the latch holds the row, committing the background evidence of an in-flight launch; onAccepted is 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.

Pullfrog  | View workflow run | Using 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.
Comment thread src/shared/agent-hook-listener/providers/claude-non-agent-work.ts
…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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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):

  • SessionStart narrowed to the process boundary. startup/resume clear the background-task record again; only /clear keeps 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. retireClaudeTaskEndedBeforeLaunchHook reads back 256 KiB from the cursor and retires only on an exact task-id/tool-use-id pair match.
  • Cursor-sync refactor. syncClaudeTranscriptCursor now 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 /clear tests. 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.md restates the reset and the look-back; mayBeClaudeTaskEndLine is 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"; commit 7506282d84 narrowed that to /clear only. Its "Still not fixed" list also still names "a shell that ends before its launching PostToolUse reaches Orca", which commit 5a8ac4daa8 now covers with the bounded look-back.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025

brennanb2025 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

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 Bash call with run_in_background) and you press Ctrl+C in that same turn, Orca showed the pane Interrupted and sent a "Claude stopped" notification while the shell kept running. Orca only learned about shells from the task list Claude attaches to the end of a turn, and a Ctrl+C'd turn has no end-of-turn hook. Killing a shell from Claude's /tasks panel left the pane on Monitoring until your next prompt, and /clear made Orca forget a shell that was still running.

What you see now. The pane shows Monitoring from the tool call that starts the shell until the shell really ends, then Done. A /tasks kill clears the pane within about a second, and /clear keeps Monitoring until the shell ends. You get exactly one "finished" notification per turn, including after /clear. The same applies to SSH and WSL panes, where the remote relay does the work.

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

  • A fresh Claude process (startup / resume session start) no longer inherits the previous process's background tasks. Before this fix, a crashed Claude's shells would have kept the next Claude on Monitoring. Only /clear keeps them, because only /clear is captured keeping its shells.
  • /clear with a running shell, then a /tasks kill before any prompt, no longer fires a second "finished" notification. The pane keeps the last finished turn's stamp across /clear, so the shell's end closes out that turn instead of starting a new one.
  • A background command that ends before Orca has handled the hook that launched it no longer leaves a Ctrl+C'd pane stuck on Monitoring. When Orca accepts the launch, it reads back once over the end of the transcript for that launch's exact ids.
  • The /clear tests now cover both real orders of the shell's end line and Claude's follow-up turn. The capture shows the end line first, which the original test had reversed.
  • The PR body's wire-compatibility paragraph is corrected. An SSH relay is version-locked to its desktop, so mixed versions don't occur. The body also no longer claims Monitoring for a launch hook that arrives after the Ctrl+C.
  • Stale comments and doc lines that stated the old rules are corrected.

Deferred (not fixed here)

  • A launch hook that reaches Orca more than 0.5 s after the Ctrl+C: the pane reads done/interrupted while that shell runs, the same as before this PR. None of the six captured Ctrl+C runs has a hook that late, and fixing it needs a change to how the desktop holds a cancel against late hooks. Follow-up.
  • Deny a permission prompt with feedback, then Claude launches a background shell in the same turn: the pane reads "waiting" until your next prompt, and Monitoring then outlives the shell by one turn. The same happens before this PR. Step 3 (Deny/Esc) ends the permission wait on Claude's own end of turn.
  • Esc in the turn that launched a shell, then /clear: an extra "finished" notification can fire. Orca doesn't read Esc as a cancel until step 3, which puts this case on the fixed /clear path.
  • When Claude kills its background agents and a shell's end line arrives in the same read: the row loses the note that it stands for the agent's stop. This matters only if that agent's permission prompt is on screen in the same second.
  • Background workflows (STA-3344): not covered. Live QA shows that Claude 2.1.285's workflow launch carries taskId / taskType: local_workflow, not backgroundTaskId, so this PR never records one. The pane still stays busy through a Ctrl+C because the existing subagent tracking sees the workflow's running agent. A Ctrl+C that lands while a workflow is between agent steps would read done. The body therefore says Part of STA-3344, not Fixes.

Not caused by this PR (seen during QA)

  • After /clear, the sidebar card and the tab title show the raw internal text <local-command-caveat>The command below was run directly in Claude Code…. It appears on the base too.

Verified

  • Live QA on a remote Mac with the real Claude Code 2.1.285 in an isolated home and a hidden dev build of Orca, base (refactor(agent-status): one Claude transcript watch per pane, read before every event #24075 head) against this PR. Screenshots below.
  • Tests: 103 named test files, 2519 passed and 1 skipped. Each new behaviour test was checked by reverting its production change and watching it fail.
  • Typecheck: node, web and cli projects pass. Lint and the changed-lines quality gate show 0 new findings.
  • Architecture: checked against how comparable tools track background-task liveness. Every difference is labelled in the body as intended or temporary.
  • Readiness checklist: passes on the final diff.
  • Merge state: clean with current main.

Not verified

  • Any live SSH, WSL, Windows or Linux run (tests only).
  • A real command that ends before its launch hook is handled (no capture has that order).
  • The KillShell / KillBash hook names (taken from the binary's alias list; the captured stop is TaskStop).
  • The watch's cost over an hour.

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 head a3d97b99ddf (scenarios 1–3 re-run; the only later commit 3391636c997 changes comments). Notifications were counted from Orca's notification record, which also keeps notifications suppressed for being too close together.

Scenario Base This PR
1. Ctrl+C while a background sleep 40 runs Interrupted for ~31 s while the shell ran, plus a "Claude stopped" notification Monitoring ~1.7 s after the Ctrl+C until the shell ended, then Done; no notification at the Ctrl+C
2. /tasks kill while Monitoring Monitoring for ~40 s with no shell left, until the next prompt Done 0.94 s after the kill; no notification at the kill
3. /clear with a running shell Done at /clear while the shell ran ~27 s more Monitoring kept across /clear, cleared 0.19 s after the shell ended; exactly one completion (Claude's own turn after the shell ended)
3b. /clear, then an idle /tasks kill (shell already forgotten) Done 0.54 s after the kill; no second "finished"
4. Background workflow + Ctrl+C Working until the workflow ended (existing subagent tracking) Same as base: never Monitoring. A workflow launch carries taskId / taskType: local_workflow, not backgroundTaskId, so this PR does not record it
Base This PR
1. +3 s after Ctrl+C, shell running base: Interrupted after Ctrl+C this PR: Monitoring after Ctrl+C
1. Shell ended base: shell ended this PR: Done after the shell ended
2. +2 s after the /tasks kill base: still Monitoring after the kill this PR: Done after the kill
3. +5 s after /clear, shell running base: Done after /clear this PR: Monitoring after /clear
3. Shell ended this PR: Done after the shell ended
3b. +2 s after the kill that follows /clear this PR: Done, no second notification
4. +3 s after Ctrl+C with a workflow running base: Working via the workflow's agent this PR: same as base

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 (18047aa61a3, text only): the relayed-cancel case (the 15 s window, marked rows) is WSL-only, not SSH: the desktop infers a Ctrl+C only from delivery-confirmed input, which a remote SSH pane never has. The body and a test comment now say WSL, and Esc followed by /clear belongs to the planned follow-up that reads Claude's interrupt notice for every turn (STA-2413), not to the Deny/Esc PR as stated above.

…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.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant