Skip to content

Claude subagents: give a typed /skill's fork a chip and a tab - #1043

Merged
zeronsh[bot] merged 3 commits into
zeronsh:mainfrom
alexandre-vl:subagents/typed-fork-chip
Oct 26, 2026
Merged

zeronsh[bot] merged 3 commits into
zeronsh:mainfrom
alexandre-vl:subagents/typed-fork-chip

Conversation

@alexandre-vl

@alexandre-vl alexandre-vl commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1040: the chip and the tab. The reply is #787's.

A /skill typed 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's task_started carries no tool id, and its transcript arrives under forked-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 on this branch. 35 s in, the session card lists the fork as running and its tab holds the skill's prompt. After the fork, the row is under 1 finished and the tab holds every step.

/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

  • Claude adapter, the chip. A task_started with no tool id whose description starts with / makes a spawn chip Agent: /<name>, with Claude subagents: count and open only this chat's spawns #1041's forked_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 words starts with "description":"/fanout", and the arguments end its prompt (ARGUMENTS: two words). The chip's id is forked-command-<name>-<task>, because the CLI tags every run of a command forked-command-<name>. Its task and chip are registered like an Agent spawn's, and the skill's body (the start's prompt) 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.
  • Claude adapter, the replay. The fork is one Option<TypedFork> (normalize.rs:350, the struct at :302), since a turn runs one at most. While it's pending, tagged traffic under any forked-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 into agent_tool_spawns. Once the turn is over, a forked-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 whole prompt, ARGUMENTS: line included (checked live, below).
  • Claude adapter, the settle. The fork's task_notification comes 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's Done: 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 :1081 and :1202). After that, nothing reaches the chip, so an echo can't reopen it as a steer (sessions.rs:4156). A result that 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 a Done, so they go out before the held Done and before the Steered split (mod.rs:1380-1383).
  • Driver. When a Stop gets its answer but no result within 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 normalized Done does (mod.rs:1523). Otherwise the chip would keep running in the parked session, and the next message would go out next, waiting on a tool that had ended.
  • Steering during a typed fork (behaviour change). The chip is an open tool (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 open Agent call. 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 out now (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.
  • CI. scripts/ci/test-core.sh names engine test binaries one by one. It now runs claude_subagents too.
  • Wire. task_started gains description and prompt (crates/harness/src/claude/wire.rs:108-112). prompt is a Value, 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 a String (see Known limits).

Testing

  • macOS 27.0, rustc 1.99.0, 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), e2e 29 (2 ignored), codex_subagents 2, subagent_idle_reap 2, restart_resume 11 (1 ignored), side_chats 6, claude_subagents 2 (this branch's).
  • Each regression test fails with its part of the fix reverted. Most of these checks ran on a WIP commit with the same logic, before a formatting fix in two of the new tests and the rename of a test helper (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 yields left: [].
    • a_typed_forks_replay_feeds_its_chip_under_any_forked_command_id. Without the re-key, the replay stays under forked-command-fanout. Re-keying only the predicted forked-command-fanout fails on forked-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 words in 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's Done: left: [Done { status: Completed, … }, Subagent { … }]. With the turn's status over the notified one, the chip gets Completed where its notification said failed. 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 id forked-command-<name> alone, the second run's chip is the first's, forked-command-fanout.
    • nothing_reaches_a_settled_typed_forks_chip. With a lasting forked-command-fanout → chip mapping, a late echo comes out as a UserMessage on 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 unreadable result, leaves the fork open: no settle events before the Done.
    • 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 out now. Without the settle's open_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 out next and is queued, the fork's result, then the message started. The chip's Done, then its ToolResult, come before the one Steered, and the first Done is the message's own. Settled in the driver's is_done branch instead, after the boundary continue (on this commit): "the chip settled after the turn split", with the chip's Done and ToolResult after Steered and 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 inside normalize fail.
    • The replay test below fails without the chip, the re-key (left: ["prompt", "done"]), the echo drop (["prompt", "prompt", …]), the hold at the notification (["prompt", "done", "thinking", …]) or the settle's place before Done.
    • The first engine test below fails without the chip (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 runs claude_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.
    • The second engine test below (on this commit) fails without the parked gate's Steered arm (crates/engine/src/sessions.rs:4422-4425), and with the fork's task_started sent before the message's started. Both times: timed out waiting for the fork's running row.
  • captured_live_typed_forked_command_replays_into_its_chip replays live-2.1.310-typed-forked-command.jsonl, a typed /fanout captured with Zeron's launch flags (--replay-user-messages included) and CLAUDE_CODE_FORWARD_SUBAGENT_TEXT=1. The chip is the feed's one spawn, and it comes before the init that the CLI sends only after the fork is done. Its tab gets the prompt once, a thought, the two Agent calls, their two results, a thought, alpha bravo, then its Done, 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, init cut 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_version and uuid among them, which nothing reads), the fork's 8 replayed frames and its agents' opening prompts, the reply, the isReplay echo and the result. 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 then thinking, 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 /probe goes to the live process as a steer, in the order 2.1.310 gives a typed prompt: command_lifecycle "started" (so Steered, which un-parks the chat), then the fork's task_started. The fork's row runs with the pill at 1, then it's done, and the tab holds the prompt and the reply.
  • Live, Claude Code 2.1.310 with Haiku, on Claude subagents: forward a foreground subagent's text and thinking #1042's build and this branch's, each a headless engine with its own data dir. These were the builds from before the restack, which changed only tests and comments below this branch. This branch's open_tools.clear() acts only when a stop gets no result within 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:
Prompt #1042 This branch
/fanout (2 foreground agents) no chip, no tab, pill 0 and no row in all 138 samples. The doc ends at the prompt Agent: /fanout row 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) same, idle at 71.8 s row at 2.0 s. The tab fills, and the row is done, at 74.1 s
Stop 30 s into /e-slow-steps idle at 31.4 s, the prompt alone idle at 31.5 s with the pill at 0. The row is done, its tab aborted after the step it started
A queued message sent now, 30 s into /e-slow-steps the new turn idles at 35.5 s the pill at 0 and the row done at 31.6 s. The new turn replies ok, idle at 34.6 s

The steps ran in the fork's foreground. In the capture, each local_bash task ("is_backgrounded":false) starts only after the previous one finished, and each Bash call returns about 20 s after it's made. /e-slow-steps was a temporary skill (allowed-tools: Bash(python3:*), see Known limits), removed after the runs.

  • Two more live runs, this branch only, the same way. Times are from the send.
    • A message steered (not sent now) 30 s into /e-slow-steps. It goes out "priority":"next" at 30.3 s and is queued 2 ms later. The fork's steps 2 and 3 still run. Its notification comes at 72.66 s and its result (num_turns 0) at 72.69 s. The message is started 9 ms after that result, 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 replies ok, idle at 77.7 s. Pill = running rows in all 108 samples.
    • /fanout two words. The fork's task_started says "description":"/fanout", its prompt ends ARGUMENTS: two words, and the first forked-command-fanout user 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.
  • With Claude: show replies to local slash commands #787 cherry-picked on top (one conflict, see Known limits), live /fanout: the reply shows in the chat, text("alpha\nbravo") after the Agent: /fanout chip. 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 claude on that commit: 461 and 38 passed, 3 ignored.
  • Rebased onto v0.2.124 (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's mock-server tests, zeron-mcp and the UI's settings:: tests, each executable checked to be built from that merge. The one failure, preview's backgrounded_scrubbed_terminal_and_out_of_tree_servers_stay_attributed, fails the same way on v0.2.124 alone, on this Mac.
    • Live on that merged build: a typed /f-slow-steps got 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 message started before the fork's task_started.
  • Not run: CLIs other than 2.1.310, Windows and Linux, the full app by hand.

Known limits

Screenshots

Offscreen renders of the production Shell (Window::render_to_image), attached to the engine; docs/media/typed-fork-chip/README.md says how they were made. The media are in their own commit, 96ade4aa, if you'd rather drop them before merging.

35 s in After the fork
The session card lists /f-slow-steps as running; its tab holds the skill's prompt and a working trailer Under 1 finished, /f-slow-steps is done; its tab holds two thoughts, three steps and the reply
Full windows
35 s in After the fork
35 s in, full window After the fork, full window

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

alexandre-vl and others added 3 commits October 26, 2026 21:59
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>
@zeronsh
zeronsh Bot force-pushed the subagents/typed-fork-chip branch from a4aa0ac to 0959278 Compare October 26, 2026 22:02

@zeronsh zeronsh 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.

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.sh conflict with #1045 was resolved by keeping both side_chats and claude_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.

@zeronsh
zeronsh Bot merged commit 95b2946 into zeronsh:main Oct 26, 2026
20 checks passed
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.

2 participants