fix(agent-status): reflect Claude's idle-prompt Ctrl+C killing background subagents - #22722
brennanb2025 wants to merge 8 commits into
Conversation
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Idle-prompt Ctrl+C inference for Claude — a new branch in
server-status-inference.tsinferInterruptadmits the keypress when the main agent is settled and the row still has working agent children, retiring exactly those children (one-shots leave, teammate-shaped ids park idle) instead of refusing the cancel. - Local vs relayed retirement — local panes retire the listener roster (
markClaudeBackgroundAgentsStopped); relayed panes retire the row's own snapshots and re-fold, since the provider records live on the relay. - No verdict fabricated — the settled main agent's
mainAgentverdict and clock pass through untouched;interruptedis only restated if already present, and shells/crons are never retired. - Captured-evidence tests + fixtures — four scrubbed real-PTY captures (
claude-idle-ctrl-c-*) replayed through the server's HTTP ingress cover retire-and-monitor, settles-to-done, the unsent-draft double-press, and the two no-ops; relayed coverage plus roster helper unit tests. - Docs —
docs/reference/agent-status-store.mdupdated to describe the mirrored behavior.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…ound subagents Measured on Claude Code 2.1.280 (fixtures claude-idle-ctrl-c-*): ONE Ctrl+C at the idle prompt kills every background agent immediately, paints 'All background agents stopped', and fires no hook; background shells and crons survive, and only the next turn's Stop inventory reflects the kill. Orca's inferred-cancel gate refused the keypress outright on a settled mainAgent, so the dead subagent kept the row 'working' until the session ended. The inference now mirrors exactly what the CLI does: on a Claude row whose mainAgent is settled, a Ctrl+C retires the row's agent children (listener roster locally, row snapshots for a relayed pane; teammate-shaped ids park idle) and touches nothing else - no verdict, no shell/cron retirement. The row keeps monitoring a surviving shell and settles to done when nothing is left. A child the CLI did not kill is re-added by its own next hook, and every later inventory re-derives the truth.
c138184 to
f7f7e36
Compare
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Captured unsent-draft evidence — added the
claude-idle-ctrl-c-draft-hooksjsonl plus meta sidecar, a real 2.1.280 PTY capture showing that with unsent text at the prompt, one Ctrl+C still kills the background agent (time.sleep(240)dies,sleep 600survives) and a second leaves the shell untouched, with no hook on either press. - Draft regression test — new
an unsent draft before idle-prompt Ctrl+C (captured)case replays hooks 0–10 through the server's HTTP ingress, asserts the first inference retires the agent intomonitoringwith no fabricatedinterrupted, and the second keypress is a no-op. - Fixture loader fields —
CapturedCancelgained optionaldraft_present/ps_before/ps_after; a smallparseCapturedBackgroundTasknarrowing helper replaced an inlineReflect.getfilter in the existing settle-to-done test.
This commit is test-only — the prod inference path is unchanged from the previously approved revision, and the new case passes (46 tests green across the two suites run).
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. 📝 WalkthroughWalkthroughClaude transcript polling now detects newly appended Priority: ⬇️ Low Merge Risk: 🔵 Low · up to Idle Ctrl+C agent kills in Claude are now detected from the session transcript, and the row updates to show surviving shell work or done. In one narrow case, a pane with no recorded main-agent state, the killed agent can stay visible on the row and reappear after a restart. The change is mergeable, but this small follow-up should ideally be fixed first. Security Architecture ReviewSecurity architecture risk: 🟡 Moderate · up to Background-task status can now change based on a local activity record. The update is scoped to a session, but the record is not visibly tied to that session’s identity, so someone able to alter it could make a working child appear stopped. No broader execution authority is established. Retained concerns
Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 48.48% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 33 functions across 15 files. (1 skipped: 1 unsupported.) Full details: Description checkExplanation The description is detailed and covers the change, rationale, behavior, testing, visual proof, compatibility, and checklist. However, the required Linked Issue reference is missing; it states there is no standalone issue and does not provide a valid issue number.
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…retires agents The synthesized row rebuilt the payload from a field whitelist, so the keypress also erased the main agent's last reply and tool from the row. Carry the existing payload forward and replace only child-derived fields.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Payload carry-forward on idle Ctrl+C retirement —
applyClaudeIdleBackgroundAgentStopno longer rebuilds the synthesized row from a field whitelist; it spreads the prior payload and recomputes only the six child-derived fields (state,workingMode,subagents,interrupted,turnCompletedAt,mainAgent), so the settled turn'slastAssistantMessage,toolName, andtoolInputsurvive the keypress. - Reply-preservation assertions — the local and relayed idle-Ctrl+C captures now pin
lastAssistantMessage: 'STARTED'(plustoolName: 'Agent'locally) on the row both before and after the keypress, which would fail against the old whitelist.
The fix is correctly scoped: the six excluded fields are exactly those the retirement recomputes, and interrupted/turnCompletedAt/mainAgent are still re-added under their original conditions, so the settled verdict and clock pass through unchanged. Ran the three affected suites (58 passed) and pnpm tc (clean).
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…dle-ctrl-c-subagents
…t's agents_killed record Claude kills every background agent on an idle-prompt Ctrl+C (and on "stop all agents") without firing a hook; its only trace is an id-less system/agents_killed line in the session transcript. Replace the keypress inference with that fact: the shared hook listener, on whichever host runs the session (desktop for local panes, the SSH/WSL relay for remote ones), arms a bounded transcript watch at the file's end while the pane has working agent children, and on a new agents_killed line retires the children that started by then with SubagentStop semantics. The result is published as a child-attributed SubagentStop row through the existing store, so the desktop's cancel latch re-folds it on SSH panes. Both existing transcript-poll schedulers carry the watch; no new wire message. The keypress path (server inference branch, idle-agent-stop provider and the snapshot retirement) is deleted.
…led line must not count
There was a problem hiding this comment.
Important
The rework from keypress inference to the transcript watch is sound, and the captured evidence is strong. One regression to fix before merge.
Reviewed changes
- Replaced keypress inference with a session-transcript watch — the earlier
ctrl-c-while-idle branch and its roster mutation are gone; the id-lesssystem/agents_killedline Claude appends to its own transcript is now the sole trigger. - Added the watcher provider (
claude-agents-killed-transcript.ts) —syncClaudeAgentsKilledWatcharms at the file's end only while the pane has a working agent child and noconnectionId, re-arms on path change, andpollClaudeAgentsKilledretires children that started by the line's timestamp and publishes oneSubagentStoprow with no fabricated verdict. - Factored the transcript-poll policy into shared helpers (
transcriptPollAnchor,hookTranscriptPollUpdate) used by both the desktop server and the relay scheduler, and taught bothscheduleTranscriptPollpaths to acceptclaude. - Added
stopWorkingClaudeSubagentsStartedBywith SubagentStop semantics (one-shot leaves, teammate-shaped parks idle, later starters survive) andcreateJsonlCursorAtEnd. - Replaced the fixtures with four captured Claude 2.1.280 transcripts (bg-agent, unsent-draft, shell-only, already-finished) plus relay and unit coverage;
docs/reference/agent-status-store.mdupdated.
This is a large behavioral rewrite of previously approved code, so the findings below are against the new mechanism.
ℹ️ A working child restored from a snapshot never arms the watch
The watch only starts when a hook reaches scheduleTranscriptPoll. After an Orca or relay restart, seedClaudeSubagentRosterFromSnapshots restores a working child into the roster, but hydration does not arm a watch, and the only other arm sites are the HTTP ingress handlers and the poll's own reschedule. So a pane that comes back with a running background agent, then gets an idle Ctrl+C before any further hook, writes agents_killed with nobody reading it — the stale child persists until the next Stop's inventory, which is the pre-PR behavior.
Technical details
# Restored working child does not arm the watch
## Affected sites
- `src/main/agent-hooks/server/server-hydration.ts:126-133` — seeds the roster/lead turn from persisted status, no scheduling.
- `src/shared/agent-hook-listener/providers/claude-roster-state.ts:210` — `seedClaudeSubagentRosterFromSnapshots` populates working rows.
- Only `scheduleTranscriptPoll` callers: `server-lifecycle.ts:135` (HTTP ingress), `agent-hook-server.ts:287` (relay HTTP ingress), and the reschedule inside `runTranscriptPoll`.
## Required outcome
- Either accept this explicitly (it degrades to the documented older-host behavior), or arm the watch once for hydrated panes that have a working Claude child and a readable local transcript path.
## Open questions for the human
- Is restart coverage intentionally out of scope? The PR body describes the older-host degradation but not the post-restart case.DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| return pollClaudeAgentsKilled(state, anchor) | ||
| } | ||
| const polled = normalizeHookPayload(state, source, body, env) | ||
| return polled && transcriptPollUpdate(source, anchor, polled) |
There was a problem hiding this comment.
polled && transcriptPollUpdate(...) returns undefined, not the documented null, when normalizeHookPayload fails — so the update === null early return in both runTranscriptPolls (desktop and relay) is dead, and Codex/Muse transcript polls now reschedule on every tick instead of stopping. That drops the if (!normalized) return guard the pre-refactor code had.
Technical details
# `hookTranscriptPollUpdate` never signals stop-on-failed-normalization
## Affected sites
- `src/shared/agent-hook-listener/transcript-poll-policy.ts:54-55` — `return polled && transcriptPollUpdate(...)`; when `polled` is `undefined` this yields `undefined`.
- `src/main/agent-hooks/server/server-status-retries.ts:78` — `if (update === null) return` is unreachable.
- `src/relay/agent-hook-result-retry-scheduler.ts:115` — same unreachable branch.
## Required outcome
- A failed re-normalization must stop the poll (or the `null` contract and its two branches must be removed). The `undefined` result must keep meaning "transcript added nothing, keep polling".
## Suggested approach
- `return polled ? transcriptPollUpdate(source, anchor, polled) : null`There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: c5c49785-837a-4a71-ac5e-93ed3c91441a
📒 Files selected for processing (13)
docs/reference/agent-status-store.mdsrc/main/agent-hooks/server-claude-idle-ctrl-c-captures.test.tssrc/main/agent-hooks/server-relayed-claude-cancel.test.tssrc/main/agent-hooks/server/server-state.tssrc/main/agent-hooks/server/server-status-retries.tssrc/relay/agent-hook-result-retry-scheduler.tssrc/shared/agent-hook-listener/listener-state.tssrc/shared/agent-hook-listener/providers/claude-agents-killed-transcript.test.tssrc/shared/agent-hook-listener/providers/claude-agents-killed-transcript.tssrc/shared/agent-hook-listener/transcript-poll-policy.tssrc/shared/claude-subagent-roster.test.tssrc/shared/claude-subagent-roster.tssrc/shared/codex-rollout-jsonl-cursor.ts
Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review.
| const retired = stopWorkingClaudeSubagentsStartedBy(roster, killedAt) | ||
| if (retired.length === 0) { | ||
| return undefined | ||
| } | ||
| clearClaudePendingWaitForAgent(state, paneKey, (agentId) => retired.includes(agentId)) | ||
| if (roster.size === 0) { | ||
| state.claudeSubagentRosterByPaneKey.delete(paneKey) | ||
| } | ||
| // Why: null without a main agent record — the kill ends children, it never wakes a parent. | ||
| const payload = buildClaudeCachedLeadStatusPayload(state, 'SubagentStop', paneKey, {}) | ||
| if (!payload) { | ||
| return undefined | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- changed file diff ---'
git diff --unified=30 f9356d491ab535022e0c6381d0941bd9d7bf4eff 8ca1e1a1f20fba3cf2f92d81d047867718f49181 -- src/shared/agent-hook-listener/providers/claude-agents-killed-transcript.ts
printf '%s\n' '--- changed file source ---'
cat -n src/shared/agent-hook-listener/providers/claude-agents-killed-transcript.ts
printf '%s\n' '--- payload builder definitions/usages ---'
rg -n -C 12 'buildClaudeCachedLeadStatusPayload|endedChildWork' src
printf '%s\n' '--- cited tests ---'
rg -n -C 20 'pollClaudeAgentsKilled|agents_killed|endedChildWork' src/shared/agent-hook-listener --glob '*test*' --glob '*spec*'
printf '%s\n' '--- hydration and persistence symbols ---'
rg -n -C 10 'claudeLeadStateByPaneKey|claudeSubagentRosterByPaneKey|mainAgent|persist.*Claude|Claude.*persist|hydrate' src/sharedRepository: stablyai/orca
Length of output: 45653
🤖 get_repo_knowledge executed:
get_repo_knowledge stablyai/orca /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/learnings /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/conventions
Length of output: 18343
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- lifecycle builder locations ---'
rg -n 'export function buildClaudeCachedLeadStatusPayload|function buildClaudeCachedLeadStatusPayload|endedChildWork|seedClaudeStateFromSnapshot|seed.*Claude.*Snapshot' src/shared/agent-hook-listener/providers src/shared --glob '*.ts'
printf '%s\n' '--- lifecycle builder ---'
sed -n '1,240p' src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts
printf '%s\n' '--- transcript listener tests ---'
sed -n '1,240p' src/shared/agent-hook-listener-claude-agents-killed-transcript.test.ts
printf '%s\n' '--- Claude roster hydration/persistence references ---'
rg -n -C 8 'claudeLeadStateByPaneKey|claudeSubagentRosterByPaneKey|mainAgent' src/shared/agent-hook-listener src/main --glob '*.ts' | head -n 1200Repository: stablyai/orca
Length of output: 10790
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- matching test files ---'
git ls-files | rg 'claude.*(killed|transcript)|agent-hook-listener.*claude|claude.*agent-hook'
printf '%s\n' '--- killed-transcript test references ---'
rg -n -C 18 'pollClaudeAgentsKilled|agents_killed|expects.*undefined|undefined.*payload' src --glob '*.test.ts'
printf '%s\n' '--- Claude snapshot seed definition and callers ---'
sed -n '180,280p' src/shared/agent-hook-listener/providers/claude-roster-state.ts
rg -n -C 18 'seedClaudeSubagentRosterFromSnapshots|seedClaude.*Lead|mainAgent.*done|state\.claudeLeadStateByPaneKey' src --glob '*.ts' | head -n 1600
printf '%s\n' '--- payload publication/persistence call sites ---'
rg -n -C 15 'buildClaudeStatusPayload|normalizeClaudeSubagentLifecycleEvent|pollClaudeAgentsKilled|agentStatusChanged|persist.*status|status.*persist' src/shared src/main --glob '*.ts' | head -n 1800Repository: stablyai/orca
Length of output: 45667
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- exact transcript listener test ---'
cat -n src/shared/agent-hook-listener/providers/claude-agents-killed-transcript.test.ts
printf '%s\n' '--- roster snapshot seeding ---'
cat -n src/shared/agent-hook-listener/providers/claude-roster-state.ts | sed -n '180,280p'
printf '%s\n' '--- relevant seed/set locations ---'
rg -l 'seedClaudeSubagentRosterFromSnapshots|claudeLeadStateByPaneKey\.set' src/main src/shared --glob '*.ts' | while read -r f; do
echo "--- $f"
rg -n -C 12 'seedClaudeSubagentRosterFromSnapshots|claudeLeadStateByPaneKey\.set' "$f"
done
printf '%s\n' '--- poll caller ---'
rg -n -C 25 'pollClaudeAgentsKilled' src/main src/shared --glob '*.ts'Repository: stablyai/orca
Length of output: 41984
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- transcript poll update path ---'
rg -n -C 25 'hookTranscriptPollUpdate|shouldPollHookTranscript|transcriptPoll' src/main/agent-hooks src/shared/agent-hook-listener --glob '*.ts'
printf '%s\n' '--- status mutation and persistence path ---'
rg -n -C 20 'ingestHook|ingest.*Status|lastStatusByPaneKey|scheduleStatusPersist|notifyStatusChangeListeners' src/main/agent-hooks/server --glob '*.ts' | head -n 1400Repository: stablyai/orca
Length of output: 43181
Publish the roster transition when no lead state exists.
When the kill retires the last child, buildClaudeCachedLeadStatusPayload returns null without lead evidence. The poll then returns undefined, disarms the transcript watch because no working child remains, and leaves the last published row unchanged. A persisted working row can therefore retain the killed child and seed it again after restart.
Pass endedChildWork: true so the builder publishes the roster as unconfirmed working without marking the lead complete.
🐛 Suggested fix
- // Why: null without a main agent record — the kill ends children, it never wakes a parent.
- const payload = buildClaudeCachedLeadStatusPayload(state, 'SubagentStop', paneKey, {})
+ // Why: the kill ends children and never wakes a parent; without a main agent record the
+ // roster change still has to reach the row, as unconfirmed working (never a completion).
+ const payload = buildClaudeCachedLeadStatusPayload(state, 'SubagentStop', paneKey, {}, {
+ endedChildWork: true
+ })📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const retired = stopWorkingClaudeSubagentsStartedBy(roster, killedAt) | |
| if (retired.length === 0) { | |
| return undefined | |
| } | |
| clearClaudePendingWaitForAgent(state, paneKey, (agentId) => retired.includes(agentId)) | |
| if (roster.size === 0) { | |
| state.claudeSubagentRosterByPaneKey.delete(paneKey) | |
| } | |
| // Why: null without a main agent record — the kill ends children, it never wakes a parent. | |
| const payload = buildClaudeCachedLeadStatusPayload(state, 'SubagentStop', paneKey, {}) | |
| if (!payload) { | |
| return undefined | |
| } | |
| const retired = stopWorkingClaudeSubagentsStartedBy(roster, killedAt) | |
| if (retired.length === 0) { | |
| return undefined | |
| } | |
| clearClaudePendingWaitForAgent(state, paneKey, (agentId) => retired.includes(agentId)) | |
| if (roster.size === 0) { | |
| state.claudeSubagentRosterByPaneKey.delete(paneKey) | |
| } | |
| // Why: the kill ends children and never wakes a parent; without a main agent record the | |
| // roster change still has to reach the row, as unconfirmed working (never a completion). | |
| const payload = buildClaudeCachedLeadStatusPayload(state, 'SubagentStop', paneKey, {}, { | |
| endedChildWork: true | |
| }) | |
| if (!payload) { | |
| return undefined | |
| } |
Review status: ready for reviewHead: What changed during this review
Review loops
Live QA (screenshots are in the PR body)
Seen during QA, not caused by this PR
|

ELI5
In a Claude Code terminal you can have background helpers running: a background shell (some long command) and a background agent (a helper AI working on a side task). Pressing Ctrl+C once while Claude sits idle at its prompt (or using "stop all agents") makes Claude kill every background agent at once. Background shells keep running, and Claude tells nobody: no hook fires.
Orca's sidebar didn't know this, so it kept showing the killed agent as "working" until the next turn ended. Claude does write one line about it in its own session log. Orca now watches that log, only while an agent is running, and when the line appears it removes the killed agents from the row. A surviving shell keeps the row on "Monitoring background tasks", and the row turns "done" only when nothing is left.
What Changed
Before: a Claude pane with a background shell and a background agent; the main turn has finished, so the row reads working because of the agent. One Ctrl+C at the idle prompt kills the agent right away (measured on Claude Code 2.1.280), but Orca kept showing it as working until the next Stop hook listed what was really left.
After: within about a second of the kill, the killed agents leave the row. A teammate-style agent is shown as idle instead of removed, which is what its own stop event would do. Shells and scheduled checks stay, and the main agent's result, clock and last reply are unchanged: the kill ends no turn, so no cancellation and no "interrupted" flag are invented. This works the same for local panes and for SSH and WSL panes.
Mechanism: when Claude really kills every running background agent, it appends one line with no agent ids to its session transcript:
{"type":"system","subtype":"agents_killed","timestamp":...}. Orca's shared hook listener already reads transcripts for other agents. It runs on whichever machine owns the session: the desktop for a local pane, the SSH relay or the WSL guest relay for a remote one. That listener now watches a Claude pane's transcript, but only while the pane has a working agent child and a transcript path it can open locally:--resumekeeps old lines in the same file, and "Move to background and exit" forks into a new file that copies the old line.agents_killedline retires the children that started by the line's timestamp, with SubagentStop semantics. A child that started after the kill survives.SubagentStoprow. Because the row is tied to a child, the desktop keeps a Ctrl+C cancel it inferred earlier for an SSH pane: the relay never learned of that cancel.The keypress inference this PR first used is deleted completely. A mid-turn Ctrl+C is still inferred as a cancel, unchanged from #22476.
The trigger was established on real Claude Code 2.1.280 captures (committed as
src/shared/__fixtures__/claude-idle-ctrl-c-*-hooks.jsonl, plus raw runs recorded outside the repo):/tasks,/or/modelswallowed;Older hosts: an SSH or WSL relay from before this change has no watcher. On those hosts a killed agent stays on the row until the next Stop, which is the same as before this PR.
After an Orca restart: Orca restores a settled main agent only when its row says no background shell was running, because it cannot prove a shell survived the restart. So if Orca restarts while a local Claude pane has both a background shell and a background agent, a kill that happens before the main agent's next hook is not reflected until Claude sends its next hook, the same as before this PR.
Windows limit: the Windows desktop listener watches only drive-letter paths. If a WSL pane's hooks reach the Windows listener directly instead of the guest relay (mirrored networking or WSL1, while the guest relay isn't running), its transcript path is a guest Linux path. Orca does not watch it, because a synchronous read across the WSL file share can stall the main process. The guest relay watches such sessions natively when it runs. Otherwise the behavior is as on an older host.
Why
/taskspanel swallows kills nothing, and we observed this live. Guessing from the keypress would have hidden an agent that was still running. The desktop also never learns of a keypress's effect on an SSH or WSL host, so the remote relay kept restating the dead agent.Linked Issue
No standalone issue. This was found during live cancel QA of #22476 (Claude, idle Ctrl+C left a killed subagent showing as working).
Fixes: the stale "working (subagent)" row after Claude kills its background agents at the idle prompt.
Visual Proof
Live run on an isolated Orca dev instance with real Claude Code 2.1.280 in a terminal pane: a background shell (
sleep 600) and a background agent, main turn finished.Before: the row is working, with the background agent listed as a child.
After ONE Ctrl+C at the idle prompt: Claude prints "All background agents stopped", the agent child leaves the row within about a second, and the surviving shell keeps the row on "Monitoring background tasks".
Ctrl+C while Claude's
/tasksdetail panel is open: Claude swallows the key and kills nothing, and Orca correctly keeps the agent as working (this is the case the removed keypress guess got wrong).Agent only, no shell: after the kill the row settles to done.
Over SSH (throwaway Linux container as the SSH host): the transcript exists only on the remote host, and appending the
agents_killedline there retires the child on the desktop row, so the watch ran on the remote host. Before and after:Testing
Captured hook payloads replayed through the server's real HTTP ingress, on the capture's own clock, with the captured
agents_killedline appended to a temp transcript (src/main/agent-hooks/server-claude-idle-ctrl-c-captures.test.ts). Cases:Real
RelayAgentHookServertests (server-relayed-claude-cancel.test.ts):Unit tests for the path rule, arming and disarming (a relayed row is never watched), the waiting-child case, and the roster function.
Each safeguard was removed on its own, and the removal made a test fail for the right reason:
pnpm tc,oxlinton the touched directories, andpnpm run check:code-quality:changedpass. The fullsrc/main/agent-hooks,src/shared/agent-hook-listenerandsrc/relaysuites pass; two unrelated timing flakes (src/relay/subprocess.test.ts,managed-hook-script-refresh.test.ts) passed when re-run.Live Electron QA with real Claude Code 2.1.280 on macOS, all passing: idle Ctrl+C with a shell and an agent (monitoring), agent only (done), unsent draft, shell only (unchanged),
/taskspanel open (nothing retired), Ctrl+C inside one agent's view (only that agent leaves, through Claude's own hooks), the next turn (the killed agent does not come back), mid-turn Ctrl+C (agent survives and stays listed), and "stop all agents". Retirement showed 0.7–1.4 s after the kill, with no notification for the killed agent.SSH QA over a real SSH connection to a throwaway Linux container: Claude cannot run there without copying credentials in, so its captured hook payloads were replayed through the real installed hook command in a real remote Orca terminal, and the captured
agents_killedline was appended to the remote transcript. Retirement, reconnect replay (the child does not come back), a stray line with no live child, and a line already in the file before the child started all behaved as described. Windows and WSL were not run live.I manually tested these changes locally
Automated tests added/updated, or explained why not below
AI Disclosure
Author: @BrennanKB5
Review
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.Notes
SubagentStoprow that older desktops already understand; an older relay publishes nothing new (see Older hosts).docs/reference/agent-status-store.md(already tracked) is updated to describe the watch.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)