Repository navigation
Claude subagents: give a typed /skill's fork a chip and a tab - #1043
Conversation
96ade4a to
a4aa0ac
Compare
A `/skill` typed as the prompt, when the skill runs in a fork (`context: fork`), showed nothing while it ran: no chip, no tab, and the badge and card didn't count it. No tool call starts it. Its `task_started` carries no tool id, and its messages carry `forked-command-<name>` and arrive "together and in order after the forked skill finishes" (https://code.claude.com/docs/en/headless.md#follow-subagent-messages). The spawn gate kept them in the journal. That task start now makes the fork a spawn chip, `Agent: /<name>`, and the skill's prompt opens its tab. The chip's id adds the task to the CLI's, which repeats on every run. Until the turn ends, any `forked-command-*` traffic feeds the chip, and the prompt's echo is dropped once. The fork's notification comes before that replay, so it only records the status. The chip settles just before the turn's Done, or when the driver ends a stopped turn itself. After that the CLI's id stays in the journal again, and a late frame can't reopen the chip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A turn runs one typed fork, but nothing enforced it: a second task start before the first settled replaced it, and the first's chip ran on with nothing left to settle it, counted in the badge for good. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: zeronsh[bot] <346184242+zeronsh[bot]@users.noreply.github.com>
a4aa0ac to
0959278
Compare
There was a problem hiding this comment.
Security audit: clean. No new dependencies, workflows, build scripts, network hosts or unsafe code; the fixtures are local CLI captures, and the PNGs are plain (IHDR/IDAT/IEND only, no trailing data).
Review: the typed fork reuses #1041's forked_skill_call chip and the existing subagent tab, card row and badge, so no UI code is added and it renders like any spawn. Routing, echo-drop, deferred settle and the driver-stop settle look right.
Pushed:
- Rebased onto main without the merged #1041/#1042 commits. The
test-core.shconflict with #1045 was resolved by keeping bothside_chatsandclaude_subagents. - A second typed-fork start before the first settled used to overwrite it, leaving the first chip running and counted forever. It now settles the first (status from its notification, else interrupted). Added a test.
Local, Linux (rustc 1.97.1): zeron-harness lib 455, claude 39, codex 46 passed. zeron-engine passed claude_subagents 2, e2e 29, side_chats 7, message_queue 30, restart_resume 11, codex_subagents 2 and subagent_idle_reap 2. Engine lib: 624 passed, and 4 failed because of this host's environment, in modules this PR doesn't touch. fmt and clippy are clean on the changed lines.
Part of #1040: the chip and the tab. The reply is #787's.
A
/skilltyped as the prompt showed nothing while its skill ran in a fork (context: fork): no chip and no tab, and the ● N pill and the session card didn't count it, for as long as the skill took. Claude Code has no tool call for it. The fork'stask_startedcarries no tool id, and its transcript arrives underforked-command-<name>only once it's over, which #1041's gate leaves in the run journal. Now that task start makes the fork a spawn chip, with the skill's prompt in its tab, and the transcript lands in that tab when the CLI sends it./f-slow-steps, a skill whose fork runs three 20 s steps, 35 s in and after the fork.The reply still doesn't show in the chat. It comes in an untagged assistant frame with no deltas, which #787 covers (checked below with both applied).
What changes
task_startedwith no tool id whose description starts with/makes a spawn chipAgent: /<name>, with Claude subagents: count and open only this chat's spawns #1041'sforked_skill_call(crates/harness/src/claude/normalize.rs:659). The name is the description's first word. Typed with arguments, 2.1.310 still describes the bare command:/fanout two wordsstarts with"description":"/fanout", and the arguments end itsprompt(ARGUMENTS: two words). The chip's id isforked-command-<name>-<task>, because the CLI tags every run of a commandforked-command-<name>. Its task and chip are registered like anAgentspawn's, and the skill's body (the start'sprompt) opens its tab. That opens the sink right away: the card, the pill and the tab show the fork while the CLI holds its transcript back.Option<TypedFork>(normalize.rs:350, the struct at:302), since a turn runs one at most. While it's pending, tagged traffic under anyforked-command-*id goes to its chip (route,normalize.rs:531), because the name after the prefix "can differ from the one you typed" (Follow subagent messages). Nothing goes intoagent_tool_spawns. Once the turn is over, aforked-command-*id is the CLI's own again and stays in the journal. The replay's first frame echoes the prompt the tab opened with, and that echo is dropped once (normalize.rs:1007). With arguments it still matches: it repeats the wholeprompt,ARGUMENTS:line included (checked live, below).task_notificationcomes before its replay. Settled there, the chip's tab would drop the replay as stragglers (crates/engine/src/sessions.rs:4158-4163), so only the status is kept (normalize.rs:611). The chip settles once, just before the turn'sDone: with that status, or the turn's if no notification came, plus a tool result, since nothing else answers the chip (settle_typed_fork,normalize.rs:545, called at:1081and:1202). After that, nothing reaches the chip, so an echo can't reopen it as a steer (sessions.rs:4156). Aresultthat only marks a steer boundary, because a message waits on the fork, settles the chip at once too. The settle's two events aren't aDone, so they go out before the heldDoneand before theSteeredsplit (mod.rs:1380-1383).resultwithin 3 s, the driver ends the turn itself. It now settles a pending typed fork first, with the status its notification brought, else as interrupted (crates/harness/src/claude/mod.rs:1513,normalize.rs:551). It also clears the open tools, as a normalizedDonedoes (mod.rs:1523). Otherwise the chip would keep running in the parked session, and the next message would go outnext, waiting on a tool that had ended.mod.rs:1348), so a message sent while the fork runs goes out"priority":"next"(:1456). It waits out the fork instead of cutting it short (wire.rs:484-489), as with an openAgentcall. On Claude subagents: forward a foreground subagent's text and thinking #1042 nothing top-level is open during the fork, so the same message goes outnow(from the code, not run). "Send now" still interrupts the turn before it sends (crates/engine/src/doc_host.rs:4092-4127). Checked live below.scripts/ci/test-core.shnames engine test binaries one by one. It now runsclaude_subagentstoo.task_startedgainsdescriptionandprompt(crates/harness/src/claude/wire.rs:108-112).promptis aValue, so a shape we don't expect doesn't cost the frame. Claude: stop doubling a foreground subagent's opening prompt #952 adds the same field as aString(see Known limits).Testing
cargo test --locked, all on this commit:-p zeron-doc --lib: 151 passed.-p zeron-harness --lib --test claude --test codex: 454, 39 and 45 passed, 3 and 4 ignored. This branch adds 7 and 3 of them.-p zeron-engine --lib --test e2e --test codex_subagents --test subagent_idle_reap --test restart_resume --test side_chats --test claude_subagents: lib 631 passed (5 ignored),e2e29 (2 ignored),codex_subagents2,subagent_idle_reap2,restart_resume11 (1 ignored),side_chats6,claude_subagents2 (this branch's).feed_one, see Known limits). The checks marked "on this commit" came with the review fixes:a_typed_fork_is_a_spawn_chip_from_its_task_start. Without the chip at the task start, the start yieldsleft: [].a_typed_forks_replay_feeds_its_chip_under_any_forked_command_id. Without the re-key, the replay stays underforked-command-fanout. Re-keying only the predictedforked-command-fanoutfails onforked-command-plugin-fanout. Settling at the notification fails on the notification, which must yield nothing.a_typed_forks_replayed_prompt_is_dropped_once. Never dropped: "the replay echoes the prompt the tab opened with". Dropped every time, the fork's own repeat of those words is lost:left: []. It also plays/fanout two wordsin its live shape. With the opening seeded without its arguments (on this commit), the tab opens with"Fan out two agents."where the test expects"Fan out two agents.\n\n\nARGUMENTS: two words".a_typed_fork_settles_once_just_before_its_turns_done. Settled after the turn'sDone:left: [Done { status: Completed, … }, Subagent { … }]. With the turn's status over the notified one, the chip getsCompletedwhere its notification saidfailed. With its task and chip left registered, a late notification settles it again.each_typed_run_of_a_command_is_its_own_chip. With the idforked-command-<name>alone, the second run's chip is the first's,forked-command-fanout.nothing_reaches_a_settled_typed_forks_chip. With a lastingforked-command-fanout→ chip mapping, a late echo comes out as aUserMessageon the settled chip, which the engine treats as a steer.an_interrupted_turn_settles_its_typed_fork_too. Settling only on completed turns, or not on an unreadableresult, leaves the fork open: no settle events before theDone.a_stop_the_cli_never_reports_settles_its_typed_fork(driver, fake CLI). Without the driver's settle, the stop yields only[Done { status: Interrupted, … }]. The test then sends a message, which must go outnow. Without the settle'sopen_tools.clear()(on this commit):left: "priority:next".a_typed_fork_settles_before_a_message_waiting_on_it_splits_the_turn(driver, fake CLI) plays the live order of the steer run below: the fork, a message that goes outnextand isqueued, the fork'sresult, then the messagestarted. The chip'sDone, then itsToolResult, come before the oneSteered, and the firstDoneis the message's own. Settled in the driver'sis_donebranch instead, after the boundarycontinue(on this commit): "the chip settled after the turn split", with the chip'sDoneandToolResultafterSteeredand the message's reply. The other typed-fork tests in the driver and the engine pass with that change. In the normalizer, the three that check the settle insidenormalizefail.left: ["prompt", "done"]), the echo drop (["prompt", "prompt", …]), the hold at the notification (["prompt", "done", "thinking", …]) or the settle's place beforeDone.timed out waiting for the fork's running row), the echo drop (left: [User, User, Assistant]) or the hold at the notification (left: [User]: the tab drops the replay as stragglers). CI now runsclaude_subagents(scripts/ci/test-core.sh:27). Without the chip (on this commit), both of its tests time out waiting for the fork's running row.Steeredarm (crates/engine/src/sessions.rs:4422-4425), and with the fork'stask_startedsent before the message'sstarted. Both times:timed out waiting for the fork's running row.captured_live_typed_forked_command_replays_into_its_chipreplayslive-2.1.310-typed-forked-command.jsonl, a typed/fanoutcaptured with Zeron's launch flags (--replay-user-messagesincluded) andCLAUDE_CODE_FORWARD_SUBAGENT_TEXT=1. The chip is the feed's one spawn, and it comes before theinitthat the CLI sends only after the fork is done. Its tab gets the prompt once, a thought, the twoAgentcalls, their two results, a thought,alpha bravo, then itsDone, two events before the turn's. The capture keeps 20 of its 99 frames, in their order: the fork's and its agents' starts and notifications,initcut to the 9 fields Claude subagents: count and open only this chat's spawns #1041's and Claude subagents: forward a foreground subagent's text and thinking #1042's fixtures keep (permissionMode,claude_code_versionanduuidamong them, which nothing reads), the fork's 8 replayed frames and its agents' opening prompts, the reply, theisReplayecho and theresult. Two thoughts that named the account's output style and connectors were reworded, and their signatures dropped.a_typed_fork_is_a_running_row_until_its_replayed_transcript_lands(crates/engine/tests/claude_subagents.rs) plays the same capture through the fake CLI, which holds it after the starts. Meanwhile the chat has one row, running, the pill reads 1 and the tab holds the prompt. After the rest, the pill reads 0, the row is done, the tab holds the prompt once and thenthinking, agent, agent, thinking, reply, all complete, and the fork's two agents have no doc.a_typed_fork_sent_to_a_parked_session_keeps_its_chip(same file). A turn ends and the session parks. Then/probegoes to the live process as a steer, in the order 2.1.310 gives a typed prompt:command_lifecycle"started" (soSteered, which un-parks the chat), then the fork'stask_started. The fork's row runs with the pill at 1, then it's done, and the tab holds the prompt and the reply.open_tools.clear()acts only when a stop gets noresultwithin 3 s, and no run here hit that: the Stop row idles 1.5 s after its stop. The pill, the card's rows and the tab were sampled every second until 30 s after the session went idle:/fanout(2 foreground agents)Agent: /fanoutrow at 2.0 s, pill 1. Its tab holds the skill's prompt until 107.5 s, then the replay. Idle with the pill at 0, the row done. Pill = running rows in all 137 samples/e-slow-steps(three 20 s foreground steps)/e-slow-stepsabortedafter the step it started/e-slow-stepsok, idle at 34.6 sThe steps ran in the fork's foreground. In the capture, each
local_bashtask ("is_backgrounded":false) starts only after the previous one finished, and eachBashcall returns about 20 s after it's made./e-slow-stepswas a temporary skill (allowed-tools: Bash(python3:*), see Known limits), removed after the runs./e-slow-steps. It goes out"priority":"next"at 30.3 s and isqueued2 ms later. The fork's steps 2 and 3 still run. Its notification comes at 72.66 s and itsresult(num_turns0) at 72.69 s. The message isstarted9 ms after thatresult, so the chip settles before the split. The row is done at 73.7 s with the pill at 0, and the message's own turn repliesok, idle at 77.7 s. Pill = running rows in all 108 samples./fanout two words. The fork'stask_startedsays"description":"/fanout", itspromptendsARGUMENTS: two words, and the firstforked-command-fanoutuser frame is that same prompt. So the echo is dropped: the tab holds the prompt once, then the replay. The row is done and the chat idle at 103.9 s. Pill = running rows in all 134 samples./fanout: the reply shows in the chat,text("alpha\nbravo")after theAgent: /fanoutchip. The tab still fills (the prompt, then the replay) and the row settles; pill = running rows in all 108 samples.-p zeron-harness --lib --test claudeon that commit: 461 and 38 passed, 3 ignored.c04e3749) after these runs, with no conflict. Merged there with the other five PRs of this series (Claude subagents: count and open only this chat's spawns #1041, Claude subagents: forward a foreground subagent's text and thinking #1042, Claude subagents: fill a typed fork's tab while the CLI holds it #1044, Fork: settle the running subagent chips a fork copies #1045, Providers: a switch to turn native subagents off #1046), it passes every suite above again, plus the rest of CI's selection, sync'smock-servertests,zeron-mcpand the UI'ssettings::tests, each executable checked to be built from that merge. The one failure, preview'sbackgrounded_scrubbed_terminal_and_out_of_tree_servers_stay_attributed, fails the same way on v0.2.124 alone, on this Mac./f-slow-stepsgot its chip, row and tab, the pill matching the rows in all 185 samples. With Providers: a switch to turn native subagents off #1046's switch off it matched in 183 of 184: in the other, taken as the fork opened, the pill read 1 and the row showed 0.5 s later. Sent as a parked session's second turn, it kept its chip and tab, the pill matching in all 187 samples: the CLI reported the messagestartedbefore the fork'stask_started.Known limits
crates/ui/src/transcript.rs:7176-7208), and the card's row showsWorking(crates/ui/src/shell/session_lanes.rs:76-83). Both show in the first screenshot below, 35 s in. The whole fork reached the tab at 76 s. Reading the transcript from disk as it grows is Claude subagents: fill a typed fork's tab while the CLI holds it #1044.python3, its first step came back"is_error":truewith"This command requires approval", and that run's capture has nocontrol_requestat all. It's the CLI's doing. Straight throughclaude -p, with the permission channel the Agent SDK uses forcanUseTool, a forked skill invoked through theSkilltool asks (can_use_toolwith the fork'sagent_id), as a foregroundAgentdoes. Typed as/skill, the same skill's call is refused without asking the host or running aPermissionRequesthook (repro added to [BUG] Cowork: a context: fork skill's connector tool calls are refused with no permission prompt unless the tool is set to Always allow anthropics/claude-code#99657). So a skill whose tools need approval has to allow them inallowed-tools, which a typed fork honours.Agent's tab still shows its prompt twice. Claude: stop doubling a foreground subagent's opening prompt #952 covers that; only the typed fork's echo is dropped here.prompttoSystemFrame, as anOption<String>, at the same spot: right aftersubagent_type, withis_backgrounded(wire.rs:105-112). The two conflict there, and keeping both sides declaresprompttwice (E0124). Keep this branch'sValue, so a shape we don't expect doesn't cost the frame, and port Claude: stop doubling a foreground subagent's opening prompt #952'sf.prompt.as_deref()tof.prompt.as_ref().and_then(Value::as_str), as atnormalize.rs:677-681. The two seed openings differently, on purpose. Claude: stop doubling a foreground subagent's opening prompt #952 stops seeding anAgent's opening from its call: a foreground child echoes its prompt as its first tagged frame, right away, and only a background child is seeded, fromtask_started. A typed fork is foreground too ("is_backgrounded":false), but its echo comes only with the replay, after the fork ends. So its tab opens fromtask_started.prompt, and the echo is dropped.return vec![tag(parent, Done…)], to put a workflow's summary before theDone, right under this branch's hold (normalize.rs:611-627). That's a text conflict: keep the hold first, since it returns only for the typed fork's chip. Claude: show Workflow runs as subagents with live progress #784, which Claude: open each Workflow agent's live transcript #785 is stacked on, addsworkflow_progressto the line ofscripts/ci/test-core.shwhere this branch addsclaude_subagents, and Fork: settle the running subagent chips a fork copies #1045 and Providers: a switch to turn native subagents off #1046 addside_chatsto it: whichever lands second keeps every name./skillsent to a parked session keeps its chip because the CLI reports the message "started" before the fork'stask_started. ThatSteeredun-parks the chat first. In the other order, the chip'sToolCallwould reach the parked gate (crates/engine/src/sessions.rs:4395-4449) while the chat is still parked. Within a second of the park it is dropped, so there is no row and no tab: the engine test fails that way with the two frames swapped. Later, it would un-park the chat on its own, and from the code theSteeredthat follows would split the chip from itsToolResult. The order was seen live for a typed first prompt (started47 ms beforetask_started), and for a typed second turn sent to a parked session, on this series' merged build (Testing).resultbranch. It's the same hunk Claude: show replies to local slash commands #787 has withmain, whose unreadable-result branch came after Claude: show replies to local slash commands #787's base; this branch's settle only adds to our side of it. Its reset,self.streamed_text = false;, goes first. Claude: show replies to local slash commands #787's tests also add afeed(norm, &[&str])helper to the module where these tests hadfeed(norm, &str), which broke the lib test's build (E0428). That's why this branch's helper isfeed_one.Screenshots
Offscreen renders of the production
Shell(Window::render_to_image), attached to the engine;docs/media/typed-fork-chip/README.mdsays how they were made. The media are in their own commit,96ade4aa, if you'd rather drop them before merging.Full windows
Need help on this PR? Tag
@codesmith-botwith what you need.