A hand_off tool: Claude gives a self-contained task to a cheaper worker model (Haiku) that stays warm between tasks, with where the change reaches from the…

<img alt="claude-modpack: eight Claude Code mods, each measured on real tasks before it shipped, built to work together. statusline, smart-compact, delegate, lsp-plus, cells, eta, seek, route." src="assets/banner-light.svg" width="880">
<h3>Install in Claude Code</h3>
/plugin install modpack --marketplace HarzerHeribert/claude-modpack
Answer y, then run /modpack:setup to set everything up step by step.<br> Prefer doing it by hand? SETUP.md has every step.
Eight free add-ons that work on their own. One command installs them, and there is nothing new to learn.<br> Every number on this page was measured, including where it doesn't help.
Install · What you get · Honest numbers · The site: ideas and evidence · Lab notes · Questions
<img alt="Animated: the cost per job with and without the pack, as bars that grow: in a long session $0.44 against $0.29 (−33%; measured twice, −26% one day and −40% the next), bigger jobs in a fresh session $0.55 against $0.51 (−8%), quick questions in a fresh session $0.23 against $0.24 (+2%, about even: +5% one day, −4% the next); then four numbers: −33% cost per job in a long session, −21% steps for the same work, −22% cost of a feature a cheaper model builds from your plan, 224 of 224 results correct." src="assets/numbers.gif" width="880">
Every time Claude takes a step (reads a file, makes an edit, runs the tests), it reads your whole conversation again. That re-reading is most of what a session costs, and it grows the longer you work. So the cheapest step is the one Claude never has to take.
The pack takes out steps that need no thinking. Several edits and their test run as one step. A search for a name in your code comes back with the exact answer. And when you approve a plan, a cheaper model does the building while Claude checks the result. The work comes out the same; Claude just gets there in fewer round trips.
<img alt="A real Claude Code session: Claude starts the stress tests in the background; above the prompt a row shows them with a bar, a percentage and a finish time, climbing from 8% to 100%; under the prompt the status line shows the model, the context, the plan's 5-hour and weekly use and a green ready dot." src="assets/demo-band.gif" width="880">
One line under the prompt: the model, how full the conversation is, and how much of your plan's 5-hour and weekly limits you have used, with when each one resets. Long jobs get a progress bar and a finish time above the prompt. And when you step away, your session is kept ready, or tidied up before it goes cold, whichever costs less; the line tells you which.
These are the statusline, eta and smart-compact mods.
<img alt="A real Claude Code session: asked to add two docstrings and run the whole test suite, Claude runs one cell; an orange card shows the read, the two edits, the diff and the test run live, and the tests pass: 1990 passed." src="assets/demo-cells.gif" width="880">
Claude makes several edits and runs the tests as one step, and you watch each part happen in a live card. Every edit still goes through Claude Code's own edit tool: you see each change, and /rewind undoes it.
That's the cells mod.
<img alt="A real Claude Code session: in plan mode Claude plans a new click.Duration parameter type; the plan is approved; Claude hands it to a Sonnet worker, whose orange card shows its steps live with a row above the prompt (hand-off 1, sonnet medium, requests and cost); then Claude checks the diff and reports: the new type works and the full suite passes." src="assets/demo-delegate.gif" width="880">
When you approve a plan, Claude hands the building to Sonnet, a cheaper model, and checks its work before reporting back. In our tests that made a feature 22% cheaper and new tests 26% cheaper, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 27% and 33%). You can watch the helper work in its card, and /delegate shows every step it took.
That's the delegate mod.
A small, fast classifier reads each prompt you send. Only when it is feature or test work is Claude told to hand it straight to delegate's worker: the worker reads the code and builds, delegate checks the result itself, and Claude reports after two steps of its own. On a TypeScript feature that made the job 51% cheaper and 42% faster than the pack alone, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 57%); everything else stays with Claude. The classifier is an outside service (TypeSafe's Jev or ScaleDown), so this is off until you turn it on and give it a key: from then on, the start of each prompt goes to it, in the US, to be classified.
That's the route mod.
When Claude searches your code for a name, it gets the exact list of places that use it, from the same engine your editor uses, instead of reading through search results. Renames and "who calls this?" questions take fewer steps. New in 0.5.0: Go, Kotlin and Ruby code, shell scripts and Terraform. Run /lsp-plus:setup once in your project to turn it on.
That's the lsp-plus mod.
Before Claude changes anything, it looks around your code, and everything it reads stays in the conversation, read again with every later step. Beside the first prompt you send in a session, and again as the session grows, the pack passes on your preference to look around in few, lean steps: the searches it can already name in one go, the lines that matter rather than whole files, nothing read twice. Against the pack without it, that made jobs 12% cheaper in a fresh session, 16% on a TypeScript library and 10 to 16% in long sessions, at the same quality. It runs on your machine and sends nothing anywhere.
That's the seek mod.
<img alt="A real Claude Code session: /mods lists the pack's mods (recorded with the six of 0.4.0, before route and seek) and opens a pane of cards; the status line is turned off and disappears from under the prompt, then turned on again." src="assets/demo-mods.gif" width="880">
/mods lists the eight and turns any of them on or off, right away, with a click or by typing, such as /mods statusline off. When something can't fully work on your computer, the pack tells you once, with the command that fixes it. When another plugin's permission or notification hook hangs (Warp's does outside the Warp terminal), the pack stops waiting for it after 10 seconds, so Claude Code can't freeze, and tells you which one it was. It keeps itself up to date (or only lets you know, if you prefer).
That's the modpack plugin, which installs the other eight.
In Claude Code, type:
/plugin install modpack --marketplace HarzerHeribert/claude-modpack
Answer y to add the marketplace, then pick where to install it. That installs all eight. Then, in the project you work in:
/modpack:setup
It checks your machine, asks once what you want on (route stays off unless you say yes), sets each mod up in order, including the language servers for the languages your project uses, and checks that each one works. Done. /mods shows what's on.
By hand instead: SETUP.md has every step.
Each mod installs on its own the same way, for example /plugin install smart-compact --marketplace HarzerHeribert/claude-modpack. lsp-plus, delegate and cells build on Claude Code's LSP tool, which needs a language server for each language: Language servers lists them.
We measured the pack on Claude Opus 5.5 doing seven real jobs in an open-source project (click), from a one-line lookup to building a feature, on two days, 13 and 15 October. Every result was checked by hidden tests. The same jobs ran with the pack and without it, and we compared what they cost at API prices.
| Cost per job | Without | With the pack | |
|---|---|---|---|
| In a long session (Claude has already read the whole project) | $0.44 | $0.29 | −33% |
| Bigger jobs in a long session (a feature, tests, a rename) | $0.91 | $0.57 | −37% |
| In a fresh session | $0.37 | $0.35 | −4% |
| Bigger jobs in a fresh session | $0.55 | $0.51 | −8%, and 15% faster |
| Quick questions in a fresh session | $0.23 | $0.24 | +2%, about even |
<img alt="Cost per task, plain Claude Code against the modpack, in a fresh session and in a session already holding 216k tokens." src="assets/cost-light.svg" width="880">
<img alt="Model requests per task, plain Claude Code against the modpack: 3.2 against 5.6 in a session already holding 216k tokens." src="assets/requests-light.svg" width="880">
<img alt="Cost per task, Opus alone against a plan handed to a worker: feature work 22% less, tests 26% less." src="assets/delegate-light.svg" width="880">
How we measured, every run's record, and the scripts to run it all again are in benchmarks/, starting with SUITE.md. What we tried that did not make it, a 10,000-star code graph among it, is in the lab notes (as Markdown). Not measured: what smart-compact saves you while you're away (worked out from prices, not tested) and Windows.
Will Claude's work get worse? No. In every run we graded, 730 in all, the result was correct with the pack and without, and every one was graded again for this release. The pack changes how many steps Claude takes to get there, and in our tests the results came out the same.
Is it safe? It's open source (MIT), so every line is here to read. Code that Claude runs inside a cell runs in a sandbox with no network access. Edits go through Claude Code's own edit tool, so your usual permission prompts and /rewind still apply. The pack sends nothing anywhere except a once-a-day check on GitHub for a new release, which you can turn off, and, only if you turn route on, the start of each prompt to the classifier you chose (TypeSafe or ScaleDown).
Does the pack add anything to my prompts? Your prompt itself is never changed. Beside it, Claude can get a short note in your name: seek's setting to look around in few, lean steps (with the first prompt of a session, and again after a compaction), and, only if you turn route on, the order to hand feature or test work to the worker; delegate gives the same order when you approve a plan of 20 lines or more. A subagent Claude starts gets cells' and seek's settings at the end of its task, since your prompts never reach it. /mods seek off and /mods route off stop theirs; subagents: false in cells or seek stops theirs for subagents.
I'm on a Pro or Max plan. Does this help me? You don't pay per job, but every step counts against your 5-hour and weekly limits. We measured cost at API prices, not plan usage, so we can't promise a number. Fewer, lighter steps should leave more of your limits, and the status line shows exactly where you stand.
Does it work in the desktop app? Yes, cards, progress and the status line included. One limit: in auto mode the desktop app doesn't let an add-on start a helper model, so plans aren't handed off there (turn route off there too); in manual mode, or in the terminal, they are. Windows hasn't been tested yet.
How do I turn it off? /mods switches each part off or on, right away. /plugin removes them.
Were any of these numbers wrong? Yes, the hand-off ones. On 16 October we found that our bench had counted a worker's output only as each of its responses began, so every saving that involved a worker came out too large: a planned feature was −27% and is −22%. They are corrected here, the old and new number side by side in CORRECTION-1016.md. The pack-against-plain numbers had no workers and stand.
What does it cost? Nothing. It's free and open source.
One line under the prompt, drawn in Claude Code's own hint row so the mode indicator and the status share it:
⏵⏵ auto mode on Opus 5.5 xhigh 412k ━━━━━━────── 5h 34% ↺2h14 7d 12% ↺3d ● ready · cold in 44m
$.statusline.set({ id, priority, parts, short?, side? }) and lists statusline among its dependencies. A part is text, a countdown ({ until, prefix?, warnMs? }, counted by the line) or a spinner ({ spin: true }), so a mod sends a segment when it changes, not every second. $.statusline.remove({ id }) takes it off./rc) from the desktop app, the web or the phone gets no mod drawing and no status text as of Claude Code 2.1.296.| Option | Default | |
|---|---|---|
enabled | true | false turns the mod off; /mods does it for all of the pack. |
placement | line | tail: appended dim to Claude Code's hint. band: above the prompt. off. |
limits | always | half: a plan window only once it is half used. off: never. |
animate | true | The highlight along the gauge. |
An idle session's prompt cache expires after its TTL: 1 hour on a subscription, 5 minutes in overage or on an API key. The next prompt then writes the whole context to the cache again, at 1.25x (5 min) or 2x (1 h) the input price. For a 400k-token Opus 5.5 session on the 1-hour cache:
| Option | Cost |
|---|---|
| Let it expire, re-cache on return | $3.20 |
| One keep-alive (re-read the cached prefix; a read resets the TTL) | $0.09 |
| Compact before it expires | ~$0.87, plus detail lost to the summary |
A few minutes before the cache expires (2.5 min on the 1-hour TTL, 45 s on the 5-minute one), the smart strategy:
compactPenaltyUsd. It stops after maxIdleHours.minSavingsUsd of re-cache cost, or below minCompactTokens of context, it never compacts.It never compacts while the prompt holds a draft or agents are running; it sends a keep-alive to hold the cache instead. If the machine sleeps past the deadline, it marks the cache expired and does nothing. A failed action is retried twice, 20 s apart. The TTL is detected from the plan and corrected from the cache hits and misses it observes.
On statusline's line it says whether the session is ready to prompt or has gone cold: ● ready, · cold in m:ss (amber in the last five minutes), · compacts in … when an idle compaction is near, ○ cold, or what it is doing (compacting, keeping warm, paused). /cache shows the live figures and plan; /cache pause, /cache resume and /cache keepalive control it.
| Option | Default | |
|---|---|---|
enabled | true | false turns the mod off; /mods does it for all of the pack. |
strategy | smart | compact: compact at the first expiry. keepalive: never compact. off: status line only. |
cacheTtl | auto | 1h or 5m to override detection. |
minSavingsUsd | 0.25 | Below this re-cache cost, the cache is left to expire. |
compactPenaltyUsd | 0.5 | Higher keeps the cache warm longer before compacting. |
minCompactTokens | 100000 | Never compacts a smaller context. |
maxIdleHours | 6 | Stops keep-alives after this long idle. |
compactInstructions | empty | Passed to idle compactions, like /compact <text>. |
Prices are list prices, so on a subscription they are API-equivalent estimates, not a bill.
Every request of the main model re-reads its whole context: at 600k tokens on Opus 5.5 that is about $0.12 a step before any work is done. A worker reads only its own small context. Claude calls hand_off with a task that stands alone, its kind (read: changes no file; mechanical: the task spells out every change; build: something is left to work out), the symbols or file:line places it touches (focus), and for edits a check command. The kind picks the worker, so Claude has no model to weigh: Haiku at low effort for read and mechanical work, Sonnet at medium for build work.
read, with Edit and Write for mechanical and build. The call waits for it inside the tool call (one request of the main model) and returns the report with the evidence; with background it returns at once and the report arrives as a message. A follow-up within 50 minutes goes to the same worker, its reading still in its context.~/.claude/mods/delegate/log.jsonl; /delegate sums it up and opens a side pane. While a worker runs, its card is drawn like a cell (the task, a row per step, the state and cost), and with eta loaded it is a row above the prompt too.Measured on 120 Opus runs, all correct: a feature or tests cost 23–35% less handed off; lookups, traces, explanations and small edits cost more. Opus does not hand off unless told to, so ask for it as an instruction: "hand the tests to a worker". An order the mod gives works as well as yours (8 of 8 runs, 22–26% less), so when you approve a plan of 20 lines or more, delegate gives that order: plan in plan mode, approve, and a Sonnet worker builds it. Corrected 16 Oct: worker costs were undercounted; these were 27–37% and 27–33%.
A verified hand-off (0.3.0). The worker ends its report with a VERIFY block: the command that runs only its new tests, and what it left open. d
hooks/register.tsx 946 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import {
5 DEFAULT_REVIEW,
6 WORKER_RULES,
7 REVIEW_PROMPT,
8 anchorOn,
9 capLines,
10 costUsd,
11 TEXT_SEARCH_CAP,
12 definitionPattern,
13 gitDefinitionPattern,
14 describeReach,
15 parseWorker,
16 diffStat,
17 filesOf,
18 kTokens,
19 mainAloneUsd,
20 money,
21 parseFocus,
22 parseLocations,
23 parseVerdict,
24 places,
25 reviewRoute,
26 summarize,
27 tokensOf,
28 workerPrompt,
29 parseVerify,
30 parsePlaces,
31 checkPlace,
32 readVerdict,
33 testCounts,
34 verdict,
35} from './plan'
36import type { Anchor, HandOff, Kind, Mode, Place, Reach, Usage } from './plan'
37import type { DelegateJob } from '../types'
38import { FRAME_MS, actionLabel, jobCard, pane, usd, verb } from './view'
39import type { Elements } from './view'
40
41type Engine = EngineInterface
42
43const TOOL = 'hand_off'
44const AGENT: { [K in Kind]: string } = { read: 'reader', mechanical: 'mechanic', build: 'builder' }
45const MODE: { [K in Kind]: Mode } = { read: 'explore', mechanical: 'edit', build: 'edit' }
46// The engine's tool-name type lists only tools it knows; implement_spec is registered at run time by `framing`.
47const HAND_OFF_TOOLS = ['mcp__delegate__hand_off', 'mcp__delegate__implement_spec'] as unknown as 'mcp__delegate__hand_off'[]
48const WORKER_TOOLS: { [K in Kind]: string[] } = {
49 read: ['Read', 'Grep', 'Glob', 'LSP', 'Bash'],
50 mechanical: ['Read', 'Grep', 'Glob', 'LSP', 'Bash', 'Edit', 'Write'],
51 build: ['Read', 'Grep', 'Glob', 'LSP', 'Bash', 'Edit', 'Write'],
52}
53const MAX_FOCUS = 6
54const DIFF_LINES = 300
55
56const RETURNS = {
57 tool: 'It returns when the worker is done (usually within a minute or two) with its report and evidence the tool gathered itself',
58 message: "It returns at once; the worker's report arrives as a notification when it finishes, with evidence the tool gathered itself",
59}
60const describe = (wait: Wait) => `Outsources one self-contained task to a cheaper worker model. ${RETURNS[wait]}: for edit work the diff and the result of a check command it runs, where the change reaches (references, callers and doc mentions from the language server), and what the work cost.
61
62Each of your own steps re-reads your whole context; a worker's steps read only its own small one, but the hand-off itself takes you two to five steps (writing the task, reading the evidence, finishing). Measured on real tasks at the same quality: a feature from a precise spec and tests to a coverage target cost 23-35% less handed off, in a fresh session and in one holding 233k tokens; lookups, traces, explanations and small edits cost more handed off, up to 3.6 times as much in a session that already knows the code.
63
64Outsource work that is fully specified and would take you many steps in this session, where only the result matters to you: implementing a feature from a precise spec, writing tests to a target, one change applied across many files.
65The "kind" you give decides the worker: read changes no file (find, list, trace, explain); mechanical changes files and the task spells out every change (a rename, one edit applied everywhere, moving code); build changes files and something is left to work out (a feature from a spec, tests to a target).
66Keep in house: lookups, tracing, explanations and small edits, design decisions, subtle debugging, and anything that depends on what was decided in this conversation unless the task states it. The worker knows only the task text.
67
68Write the task so it stands alone: the goal, the files or symbols, constraints, and what done means. Name the symbols or file:line places it touches in focus so the tool can find where they are used. For edit work, give a check command (tests, a type check) that proves it. Don't edit the files an edit hand-off works on until its report is in.
69
70Judge what comes back by the diff and the check output, not by the worker's report. A follow-up task reaches the same worker while it is warm, its earlier reading still in its context. With background, the call returns at once and the report arrives as a notification: use it when you have other work to do meanwhile.`
71
72const CAPABILITY = 'implement_spec'
73const describeCapability = () => `Implements a precisely specified change and proves it. Give it the spec (what to build, where, the constraints, what done means) and a check command (tests, a type check); it makes the change in the working tree and returns the diff, the check's output, where the change reaches (references, callers and doc mentions from the language server), and what it cost. Use it for work that is fully specified and takes many steps: a feature from a precise spec, tests to a coverage target, one change applied in many places. Keep lookups, tracing, explanations, design decisions and debugging yourself. Judge the result by the diff and the check output. Don't edit the files it works on until it returns.`
74
75type Wait = 'tool' | 'message'
76/** The longest a hand-off holds the tool call; past it the report comes as a message. */
77const HOLD_MS = 30 * 60_000
78
79const INPUT_SCHEMA = {
80 type: 'object',
81 properties: {
82 task: { type: 'string', description: 'The whole task, standing alone: goal, files or symbols, constraints, what done means.' },
83 kind: {
84 type: 'string',
85 enum: ['read', 'mechanical', 'build'],
86 description:
87 'read: changes no file (find, list, trace, explain). mechanical: changes files, and the task spells out every change (a rename, one edit applied everywhere, moving code). build: changes files, and something is left to work out (a feature from a spec, tests to a target).',
88 },
89 focus: {
90 type: 'array',
91 items: { type: 'string' },
92 description: 'Symbols or places the task touches: "name", "path#name" or "path:line". The tool asks the language server where they are used.',
93 },
94 check: { type: 'string', description: "Edit mode: a shell command that proves the work, best the project's whole test suite (and its type check). Run by this tool after the worker finishes, with the worker's own new tests run on their own beside it." },
95 fresh: { type: 'boolean', description: 'Start a new worker instead of continuing the warm one.' },
96 background: {
97 type: 'boolean',
98 description: 'Return at once and let the report arrive as a notification, so you can work on something else meanwhile. Without it the call waits for the report, which costs you fewer requests.',
99 },
100 },
101 required: ['task', 'kind'],
102}
103
104const CAPABILITY_SCHEMA = {
105 type: 'object',
106 properties: {
107 spec: { type: 'string', description: 'The whole spec, standing alone: goal, files or symbols, constraints, what done means.' },
108 check: { type: 'string', description: 'A shell command that proves the work.' },
109 focus: INPUT_SCHEMA.properties.focus,
110 background: INPUT_SCHEMA.properties.background,
111 },
112 required: ['spec', 'check'],
113}
114
115const workers: { [K in Kind]: { model: string; effort?: string } } = { read: { model: 'haiku' }, mechanical: { model: 'haiku' }, build: { model: 'sonnet' } }
116let framing: 'hand_off' | 'capability' = 'hand_off'
117let review = 'inline'
118let reuseMs = 50 * 60_000
119let capTokens = 60_000
120let wait: Wait = 'tool'
121let afterPlan: 'hand_off' | 'off' = 'off'
122// Set once auto mode (the desktop app) has refused a worker start; reset in session.start.
123let autoModeBlocked = false
124
125export function isAutoModeBlock(deny: string): boolean {
126 return /auto mode classifier gave no verdict/i.test(deny)
127}
128
129/** For tests only. */
130export function setAutoModeBlocked(v: boolean): void {
131 autoModeBlocked = v
132}
133
134/** The tool result for a refused worker start; the first auto-mode refusal also tells the person. */
135export async function refusedStart($: Engine, deny: string): Promise<{ text: string }> {
136 if (!isAutoModeBlock(deny)) return { text: `The worker did not start: ${deny}` }
137 if (!autoModeBlocked) {
138 autoModeBlocked = true
139 await $.ui.log('delegate: auto mode in the desktop app cannot judge a worker the mod starts, so hand-offs are off here. They run in manual permission mode (no prompt) or in the terminal.')
140 }
141 return {
142 text: 'Not handed off: auto mode in this app cannot judge a worker that the delegate mod starts (the terminal can). Do this task yourself. Tell the person once, in one sentence: hand-offs run in manual permission mode, where they start without a prompt, or from the terminal.',
143 }
144}
145
146/** The order attached to an approved plan: implement it through a hand-off. */
147export function planOrder(framing: 'hand_off' | 'capability'): string {
148 const how = framing === 'capability' ? 'implement_spec' : 'the hand_off tool, kind build (kind mechanical when the plan spells out every change)'
149 return `The person approved this plan, and their setting for approved plans is: implement it through ${how}. The approved plan is the worker's spec: write it into the task so it stands alone, with the project's whole test suite as the check. The tool checks the worker's work itself; when its result says Verified and the diff is what the plan asked for, report to the person without running anything again.`
150}
151
152type Input = { task: string; kind: Kind; mode: Mode; focus: string[]; check?: string; fresh: boolean; background: boolean; worker: string; effort?: string }
153const label = (model: string, effort?: string) => (effort ? `${model} ${effort}` : model)
154type Done = { answer: string; reason: string }
155
156/** One hand-off from the tool call to the notice of its worker's end. */
157type Job = {
158 n: number
159 toolUseId: string
160 agentId: string
161 input: Input
162 started: number
163 reused: boolean
164 reach: Reach[]
165 reachText: string
166 /** Its requests so far, what they cost in tokens, and the size of the last one (the worker's context). */
167 requests: number
168 usage: Usage
169 lastContext: number
170 workerModel?: string
171 /** Each file the worker edited, as it was before its first edit (null: it did not exist). */
172 originals: Map<string, string | null>
173 /** The work tree when the hand-off began (a commit of it), and the untracked files then; absent outside git. */
174 gitBase?: string
175 untracked?: string[]
176 /** The report reached the main model; the worker's turn may end a moment later. */
177 reported: boolean
178 /** The tool call is holding the turn for this worker. */
179 holding: boolean
180 /** The report went back as the tool's result: the worker's own hand-back message is dropped. */
181 returned: boolean
182 /** The worker's report, from its hand-back message when that came first. */
183 handback?: string
184 done?: Done
185 ended?: number
186 /** eta is showing this hand-off: the status file it reads, and what has been written to it. */
187 eta?: { file: string; text: string }
188}
189
190type Warm = { agentId: string; at: number; requestTokens: number }
191
192/** The worker kept warm between hand-offs, one per kind. */
193const warm = new Map<string, Warm>()
194const warmKey = (input: Input) => input.kind
195/** Hand-offs whose worker is running or whose notice has not arrived yet, by the worker's agent id. */
196const jobs = new Map<string, Job>()
197/** Hand-offs reported, written to the log when the main turn that received them ends. */
198let records: HandOff[] = []
199/** Files reported edit hand-offs changed: the main model's later edits to them count as rework. */
200let touched = new Map<string, HandOff>()
201/** Workers whose end this mod reported itself (no notice came); a host notice for one is dropped. */
202const handedOver = new Set<string>()
203let count = 0
204let dir = ''
205/** True while this mod continues a warm worker: resuming an agent needs its type offered. */
206let resuming = false
207
208// What the card and the pane draw: each hand-off by its tool_use_id, and the one the pane has open.
209const VIEW = atom({ plugin: 'delegate', key: 'jobs' } as const, [] as DelegateJob[])
210const WATCHING = atom({ plugin: 'delegate', key: 'watching' } as const, null as string | null)
211const PANE = 'delegate'
212let frames: Timer | undefined
213
214const LOST = 'lost track: the mod reloaded or Claude Code restarted while it ran'
215
216/** The running entries whose worker this mod no longer knows (it reloaded, or Claude Code restarted). */
217export function lostEntries(view: readonly DelegateJob[], liveAgentIds: ReadonlySet<string>): DelegateJob[] {
218 return view.filter(j => j.state === 'running' && !liveAgentIds.has(j.agentId))
219}
220
221/** Ends the cards of hand-offs whose worker report can no longer be matched, and their eta rows; never throws. */
222export async function reconcile($: Engine): Promise<void> {
223 try {
224 const lost = lostEntries(await read($, VIEW), new Set(jobs.keys()))
225 if (lost.length === 0) return
226 const now = await $.clock.now()
227 const ids = new Set(lost.map(j => j.toolUseId))
228 await update($, VIEW, list => list.map(j => (ids.has(j.toolUseId) && j.state === 'running' && !jobs.has(j.agentId) ? { ...j, state: 'failed' as const, endedAt: now, note: LOST } : j)))
229 for (const j of lost) {
230 try {
231 const file = `${dir}/hand-off-${j.n}.status`
232 const text = await $.fs.read(file).catch(() => undefined)
233 if (text !== undefined) await $.fs.write(file, `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}failed · lost track\n`)
234 } catch {
235 // eta is optional.
236 }
237 }
238 await animate($)
239 } catch {
240 // A stale card is better than a failing hook.
241 }
242}
243
244async function setView($: Engine, toolUseId: string, change: (j: DelegateJob) => Partial<DelegateJob>) {
245 await update($, VIEW, list => list.map(j => (j.toolUseId === toolUseId ? { ...j, ...change(j) } : j)))
246}
247
248/** Redraws four times a second while a worker runs; at rest nothing moves. */
249async function animate($: Engine) {
250 const running = (await read($, VIEW)).some(j => j.state === 'running')
251 if (running && frames === undefined) frames = $.clock.every(FRAME_MS, () => $.ui.invalidate('ui.render'))
252 else if (!running && frames !== undefined) {
253 frames.cancel()
254 frames = undefined
255 $.ui.invalidate('ui.render')
256 }
257}
258
259async function openPane($: Engine, toolUseId?: string) {
260 if (toolUseId !== undefined) await update($, WATCHING, () => toolUseId)
261 await $.ui.open({ id: PANE, title: 'Hand-offs' })
262}
263
264/** The worker's report out of its hand-back frame, unindented. */
265function reportText(text: string): string {
266 const body = /The report follows:\n([\s\S]*?)(?:<\/agent-message>|$)/.exec(text)?.[1] ?? text
267 return body
268 .split('\n')
269 .map(l => l.replace(/^ /, ''))
270 .join('\n')
271 .trim()
272}
273
274const join = (cwd: string, path: string) => (path.startsWith('/') ? path : `${cwd}/${path}`)
275const rel = (cwd: string, path: string) => (path.startsWith(cwd + '/') ? path.slice(cwd.length + 1) : path)
276
277async function git($: Engine, cwd: string, args: string[]): Promise<string | undefined> {
278 const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 8000 }).catch(() => undefined)
279 return r !== undefined && (r.exitCode === 0 || r.exitCode === 1) ? r.stdout : undefined
280}
281
282/** Calls the LSP tool the way the model would; a plugin above it (lsp-plus) adds what it adds. */
283async function lsp($: Engine, operation: string, a: Anchor): Promise<{ text: string; context: string[] } | undefined> {
284 const r = await $.tool
285 .call({ tool: 'LSP', operation: operation as 'findReferences', filePath: a.path, line: a.line, character: a.character })
286 .catch(() => undefined)
287 if (r === undefined || r.deny !== undefined || r.isError) return undefined
288 const text = r.text ?? ''
289 if (/^(Error performing|No LSP server)/.test(text)) return undefined
290 return { text, context: [...(r.context ?? [])] }
291}
292
293/** Resolves one focus entry to a place in the code. */
294async function anchor($: Engine, cwd: string, raw: string): Promise<{ anchor?: Anchor; name: string }> {
295 const f = parseFocus(raw)
296 if (f.path !== undefined) {
297 const text = await $.fs.read(join(cwd, f.path)).catch(() => undefined)
298 if (text === undefined) return { name: f.name ?? raw }
299 const lines = text.split('\n')
300 let line = f.line
301 if (line === undefined && f.name !== undefined) {
302 const def = new RegExp(definitionPattern(f.name))
303 const at = lines.findIndex(l => def.test(l))
304 const any = lines.findIndex(l => new RegExp(`\\b${f.name}\\b`).test(l))
305 line = at >= 0 ? at + 1 : any >= 0 ? any + 1 : undefined
306 }
307 if (line === undefined) return { name: f.name ?? raw }
308 const on = anchorOn(lines[line - 1] ?? '', f.name)
309 return on ? { name: on.name, anchor: { name: on.name, path: f.path, line, character: on.character } } : { name: f.name ?? raw }
310 }
311 const name = f.name as string
312 const out = await git($, cwd, ['grep', '--untracked', '-n', '-I', '-w', '-E', '-e', gitDefinitionPattern(name), '--', '.', ':(exclude)*.md'])
313 const hit = out ? /^(.+?):(\d+):(.*)$/.exec(out.split('\n')[0] ?? '') : null
314 if (!hit) return { name }
315 const on = anchorOn(hit[3] as string, name)
316 return { name, anchor: on ? { name, path: hit[1] as string, line: Number(hit[2]), character: on.character } : undefined }
317}
318
319/** Where the task reaches: for each focus entry, references and callers from the language server, else a text search. */
320async function reachOf($: Engine, cwd: string, focus: string[]): Promise<Reach[]> {
321 const reach: Reach[] = []
322 for (const raw of focus.slice(0, MAX_FOCUS)) {
323 const { anchor: a, name } = await anchor($, cwd, raw)
324 if (a !== undefined) {
325 // A language server still indexing answers with the definition alone: give it a few seconds.
326 let refs = await lsp($, 'findReferences', a)
327 for (let i = 0; i < 3 && refs !== undefined && parseLocations(refs.text, cwd).length <= 1; i++) {
328 await $.process.run(['sleep', '3']).catch(() => undefined)
329 refs = await lsp($, 'findReferences', a)
330 }
331 if (refs !== undefined && parseLocations(refs.text, cwd).length > 1) {
332 const calls = await lsp($, 'incomingCalls', a)
333 reach.push({
334 anchor: a,
335 name,
336 refs: parseLocations(refs.text, cwd).filter(p => !(p.path === a.path && p.line === a.line)),
337 callers: calls ? parseLocations(calls.text, cwd) : [],
338 docs: refs.context,
339 via: 'lsp',
340 })
341 continue
342 }
343 }
344 const out = await git($, cwd, ['grep', '--untracked', '-n', '-I', '-w', '-F', '-e', name, '--', '.'])
345 const refs: Place[] = (out ?? '').split('\n').flatMap(row => {
346 const m = /^(.+?):(\d+):/.exec(row)
347 return m ? [{ path: m[1] as string, line: Number(m[2]) }] : []
348 })
349 if (refs.length > TEXT_SEARCH_CAP) reach.push({ anchor: a, name, refs: [], callers: [], docs: [], via: 'none' })
350 else reach.push({ anchor: a, name, refs, callers: [], docs: [], via: refs.length > 0 ? 'text' : 'none' })
351 }
352 return reach
353}
354
355/** Continues the warm worker of this mode and model, or starts one. */
356async function startWorker($: Engine, input: Input, prompt: string, description: string): Promise<{ agentId: string; reused: boolean; why?: string } | { deny: string }> {
357 const { kind, fresh } = input
358 const w = warm.get(warmKey(input))
359 let why: string | undefined
360 const busy = w !== undefined && jobs.get(w.agentId)?.reported === false
361 if (!fresh && w !== undefined && !busy && (await $.clock.now()) - w.at < reuseMs && w.requestTokens < capTokens) {
362 resuming = true
363 const sent = await $.session
364 .send({ to: { agentId: w.agentId }, text: prompt })
365 .catch(() => ({ isDelivered: false as const, reason: 'failed' }))
366 .finally(() => {
367 resuming = false
368 })
369 if (sent.isDelivered) return { agentId: w.agentId, reused: true }
370 warm.delete(warmKey(input))
371 why = sent.reason
372 }
373 // The session's folder, named: a worker whose shell started elsewhere once answered from another checkout on the disk.
374 const r = await $.agent.spawn({ prompt, subagentType: `delegate:${AGENT[kind]}`, description, model: input.worker, cwd: await $.session.cwd() })
375 if (r.deny !== undefined || r.agentId === undefined) return { deny: r.deny ?? 'the worker did not start' }
376 return { agentId: r.agentId, reused: false, why }
377}
378
379/** The worker's changes against the work tree it started from: what git sees, plus new untracked files. */
380async function gitDiff($: Engine, cwd: string, job: Job & { gitBase: string }): Promise<{ diff: string; files: string[] }> {
381 // From the repository's root: run from a subfolder, ls-files saw only the untracked files below it, and a worker's
382 // new folder elsewhere in the repository was missing from the evidence.
383 const top = ((await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 5000 }).then(r => r.stdout, () => '')) || cwd).trim()
384 const run = (args: string[]) => $.process.run(['git', ...args], { cwd: top, timeoutMs: 15_000 }).then(r => r.stdout).catch(() => '')
385 const tracked = await run(['diff', '--no-color', '-M', '-U2', job.gitBase])
386 const names = (await run(['diff', '--name-only', '-M', job.gitBase])).split('\n').filter(Boolean)
387 const before = new Set(job.untracked ?? [])
388 const added = (await run(['ls-files', '--others', '--exclude-standard'])).split('\n').filter(f => f && !before.has(f))
389 const fresh: string[] = []
390 for (const f of added) fresh.push(await run(['diff', '--no-index', '--no-color', '-U2', '--', '/dev/null', f]))
391 return { diff: [tracked, ...fresh].join('').trimEnd(), files: [...names, ...added].map(f => rel(cwd, join(top, f))) }
392}
393
394/** The worker's changes as one unified diff, paths relative to the session's folder. */
395async function diffOf($: Engine, cwd: string, job: Job): Promise<string> {
396 const parts: string[] = []
397 let i = 0
398 for (const [path, before] of job.originals) {
399 const after = await $.fs.read(path).catch(() => null)
400 if (after === before) continue
401 const old = `${dir}/before-${job.n}-${i++}`
402 if (before !== null) await $.fs.write(old, before)
403 const a = before === null ? '/dev/null' : old
404 const b = after === null ? '/dev/null' : path
405 const r = await $.process.run(['git', 'diff', '--no-index', '--no-color', '--no-prefix', '-U2', '--', a, b], { cwd, timeoutMs: 8000 }).catch(() => undefined)
406 // --no-prefix prints each absolute path without its leading slash.
407 const name = rel(cwd, path)
408 if (r !== undefined) parts.push([old, path, old.slice(1), path.slice(1)].reduce((t, p) => t.split(p).join(name), r.stdout))
409 }
410 return parts.join('').trimEnd()
411}
412
413async function runCheck($: Engine, command: string): Promise<{ exit: number; tail: string }> {
414 const r = await $.tool.call({ tool: 'Bash', command, timeout: 600_000 }).catch(err => ({ deny: String(err) }))
415 if (r.deny !== undefined) return { exit: -1, tail: `not run: ${r.deny}` }
416 const text = r.text ?? ''
417 const exit = r.isError ? Number(/Exit code (\d+)/.exec(text)?.[1] ?? 1) : 0
418 return { exit, tail: text.split('\n').slice(-25).join('\n').trim() }
419}
420
421const logFile = async ($: Engine) => `${(await $.env.get('HOME')) ?? ''}/.claude/mods/delegate/log.jsonl`
422
423async function flush($: Engine, done: HandOff[]) {
424 if (done.length === 0) return
425 const file = await logFile($)
426 await $.process.run(['mkdir', '-p', file.slice(0, file.lastIndexOf('/'))])
427 const old = await $.fs.read(file).catch(() => '')
428 await $.fs.write(file, old + done.map(r => JSON.stringify(r)).join('\n') + '\n')
429}
430
431async function readLog($: Engine): Promise<HandOff[]> {
432 const text = await $.fs.read(await logFile($)).catch(() => '')
433 return text.split('\n').flatMap(line => {
434 try {
435 return line ? [JSON.parse(line) as HandOff] : []
436 } catch {
437 return []
438 }
439 })
440}
441
442const ETA_TOOL = 'mcp__eta__track_progress'
443
444/** One line of the status file eta shows beside the hand-off: its requests, cost and latest action. */
445export function etaStatusLine(job: { requests: number; usd: number }, action?: { tool: string; label: string }): string {
446 const tail = action === undefined ? '' : ` · ${verb(action.tool)} ${action.label}`.replace(/\s+/g, ' ').trimEnd()
447 return `${job.requests} req · ${usd(job.usd)}${tail}`.slice(0, 120)
448}
449
450/** Appends a line to the hand-off's status file; never throws. */
451export async function writeEta($: Engine, job: { eta?: { file: string; text: string } }, line: string): Promise<void> {
452 const eta = job.eta
453 if (eta === undefined) return
454 try {
455 eta.text += `${line.replace(/\s*[\r\n]+\s*/g, ' ')}\n`
456 await $.fs.write(eta.file, eta.text)
457 } catch {
458 // eta is optional; the hand-off goes on without it.
459 }
460}
461
462/** Asks eta, when it is present, to show hand-off n beside the prompt. Returns what to remember on the job; never throws. */
463export async function trackInEta($: Engine, dir: string, n: number, worker: string): Promise<{ file: string; text: string } | undefined> {
464 try {
465 if (!(await $.tool.list()).some(t => t.name === ETA_TOOL)) return undefined
466 const file = `${dir}/hand-off-${n}.status`
467 const text = `starting · ${worker}\n`
468 await $.fs.write(file, text)
469 await $.tool.call({
470 tool: ETA_TOOL, task: `hand-off-${n}`, label: `hand-off ${n} · ${worker}`, file, finished_pattern: '^(done|failed) ·', status_line: true,
471 } as never).catch(() => undefined)
472 return { file, text }
473 } catch {
474 return undefined
475 }
476}
477
478/** hand_off: where the task reaches, then the worker, started or continued. */
479async function start($: Engine, toolUseId: string, input: Input): Promise<{ text: string; agentId?: string }> {
480 const cwd = await $.session.cwd()
481 const started = await $.clock.now()
482 const reach = await reachOf($, cwd, input.focus)
483 const reachText = describeReach(reach)
484 const prompt = workerPrompt({ task: input.task, mode: input.mode, reach: reachText, check: input.check, root: cwd })
485 const description = input.task.trim().split('\n')[0]?.slice(0, 60) ?? 'hand-off'
486 // The work tree as it is now, so the worker's changes can be told apart, however it makes them (Edit, sed, git mv).
487 const stash = input.mode === 'edit' ? await git($, cwd, ['stash', 'create']) : undefined
488 const gitBase = stash === undefined ? undefined : stash.trim() || (await git($, cwd, ['rev-parse', 'HEAD']))?.trim() || undefined
489 // Root-relative (--full-name, the whole repository), as gitDiff lists them.
490 const untracked = gitBase === undefined ? undefined : ((await git($, cwd, ['ls-files', '--others', '--exclude-standard', '--full-name', ':/'])) ?? '').split('\n').filter(Boolean)
491 const worker = await startWorker($, input, prompt, description)
492 if ('deny' in worker) return refusedStart($, worker.deny)
493 handedOver.delete(worker.agentId)
494 jobs.set(worker.agentId, {
495 n: ++count,
496 toolUseId,
497 agentId: worker.agentId,
498 input,
499 started,
500 reused: worker.reused,
501 reach,
502 reachText,
503 requests: 0,
504 usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
505 lastContext: 0,
506 originals: new Map(),
507 reported: false,
508 holding: !input.background,
509 returned: false,
510 gitBase,
511 untracked,
512 })
513 const refs = reach.reduce((k, r) => k + r.refs.length, 0)
514 await update($, VIEW, list => [
515 ...list.slice(-29),
516 {
517 n: count,
518 toolUseId,
519 agentId: worker.agentId,
520 mode: input.mode,
521 task: input.task.trim(),
522 model: input.worker,
523 effort: input.effort,
524 reused: worker.reused,
525 reach: refs,
526 startedAt: started,
527 background: input.background === true,
528 state: 'running',
529 requests: 0,
530 usd: 0,
531 actions: [],
532 } satisfies DelegateJob,
533 ])
534 const made = jobs.get(worker.agentId)
535 if (made !== undefined) made.eta = await trackInEta($, dir, made.n, label(input.worker, input.effort))
536 await animate($)
537 const reachLine = reach.length > 0 ? ` It was told where the task reaches: ${refs} reference${refs === 1 ? '' : 's'}.` : ''
538 const cold = worker.why === undefined ? '' : ` (the warm one could not be reached: ${worker.why})`
539 const later = `Its report arrives as a notification when it finishes, with ${input.mode === 'edit' ? `the diff${input.check ? ', the check' : ''} and ` : ''}what it cost. Carry on with other work meanwhile, or end your turn to wait.`
540 return { text: `The ${worker.reused ? 'warm' : 'new'} ${label(input.worker, input.effort)} worker (${worker.agentId}) is on it${cold}.${reachLine}${input.background ? ` ${later}` : ''}`, agentId: worker.agentId }
541}
542
543/** The report reached the main model (as the tool's result or a message): log it, keep the worker warm, update the card. */
544async function deliver($: Engine, job: Job, reportBody: string): Promise<string> {
545 job.reported = true
546 job.ended ??= await $.clock.now()
547 if (job.done === undefined || job.done.reason === 'answer') warm.set(warmKey(job.input), { agentId: job.agentId, at: job.ended, requestTokens: job.lastContext })
548 const made = await report($, job, reportBody).catch(err => ({ text: `The delegate mod could not gather the evidence: ${String(err)}`, record: undefined }))
549 const rec = made.record
550 await setView($, job.toolUseId, () => ({
551 state: 'reported',
552 endedAt: job.ended,
553 report: reportBody,
554 ...(rec === undefined
555 ? {}
556 : { usd: rec.workerUsd, aloneUsd: rec.mainAloneUsd, files: rec.files, added: rec.added, removed: rec.removed, check: rec.check, verdict: rec.verdict }),
557 }))
558 await writeEta($, job, `done · ${job.requests} req · ${usd(rec?.workerUsd ?? costUsd(job.usage, job.workerModel ?? ''))}`)
559 await animate($)
560 return made.text
561}
562
563export function handOverText(n: number, agentId: string, report: string, evidence: string): string {
564 return `Hand-off ${n} (${agentId}) has ended. Its report:\n\n${report || '(none)'}\n\n${evidence}`
565}
566
567export function failedText(n: number, agentId: string, why: string): string {
568 return `Hand-off ${n} (${agentId}) failed: ${why}. Do the task yourself or hand it off again.`
569}
570
571/** A host notice for a worker whose end the mod already reported, and not the mod's own prompt: drop it. */
572export function dropsNotice(handed: ReadonlySet<string>, originKind: string | undefined, text: string): boolean {
573 if (originKind === undefined || originKind === 'composer' || originKind === 'bridge' || originKind === 'sdk' || originKind === 'plugin') return false
574 return [...handed].some(id => text.includes(id))
575}
576
577/** The second copy of a report the model already has. Inside a running turn (turnId set) it rides along as one line: no yellow row, no extra request; on its own it would start a turn, so it is dropped. */
578export function secondCopy(turnId: string | undefined): { drop: string } | { text: string } {
579 if (turnId === undefined) return { drop: 'delegate: no second copy needed, the worker\'s report already reached Claude.' }
580 return { text: 'delegate: the worker\'s report already reached Claude as the hand_off result.' }
581}
582
583/** Nobody waits on the worker (a background hand-off, or a held one past its limit) and its end brought no notice: report it from here. */
584async function handOver($: Engine, job: Job): Promise<void> {
585 if (job.reported || job.holding || job.done === undefined) return
586 handedOver.add(job.agentId)
587 const done = job.done
588 if (done.reason === 'answer') {
589 const evidence = await deliver($, job, done.answer)
590 jobs.delete(job.agentId)
591 await $.prompt.submit({ text: handOverText(job.n, job.agentId, done.answer, evidence) }).catch(() => undefined)
592 return
593 }
594 job.reported = true
595 await setView($, job.toolUseId, () => ({ state: 'failed', endedAt: job.ended }))
596 await writeEta($, job, `failed · ${job.requests} req · ${usd(costUsd(job.usage, job.workerModel ?? ''))}`)
597 await animate($)
598 jobs.delete(job.agentId)
599 await $.prompt.submit({ text: failedText(job.n, job.agentId, done.reason) }).catch(() => undefined)
600}
601
602/**
603 * Holds the tool call until the worker is done. Each wait is a one-second
604 * \`sleep\` run through the engine, during which a hook's own clock stands
605 * still, so the wait never counts against its budget. Undefined when the
606 * person interrupted it or it ran past HOLD_MS: the report then comes as a message.
607 */
608async function hold($: Engine, job: Job, signal: AbortSignal): Promise<string | undefined> {
609 const until = (await $.clock.now()) + HOLD_MS
610 while (job.done === undefined && job.handback === undefined) {
611 if (signal.aborted || (await $.clock.now()) > until) {
612 job.holding = false
613 return undefined
614 }
615 await $.process.run(['sleep', '1']).catch(() => undefined)
616 }
617 job.holding = false
618 job.returned = true
619 const body = job.handback ?? job.done?.answer ?? ''
620 const evidence = await deliver($, job, body)
621 if (job.done !== undefined && job.handback !== undefined) jobs.delete(job.agentId)
622 return `The worker's report:\n${body || '(none)'}\n\n${evidence}`
623}
624
625/** The worker's notice arrived: the evidence to read beside its report. */
626async function report($: Engine, job: Job, reportBody: string): Promise<{ text: string; record: HandOff }> {
627 const cwd = await $.session.cwd()
628 // The hand-back reaches the main model before the worker's turn has formally ended.
629 const done = job.done ?? { answer: '', reason: 'answer' }
630 const usage = job.usage
631 const requests = Math.max(1, job.requests)
632 const workerModel = job.workerModel ?? job.input.worker
633 const workerUsd = costUsd(usage, workerModel)
634 const mainModel = await $.session.model().catch(() => 'opus')
635 const contextTokens = (await $.session.usage().catch(() => undefined))?.context.tokens ?? 0
636 const alone = mainAloneUsd({ requests, contextTokens, worker: usage, model: mainModel })
637 const ended = job.ended ?? (await $.clock.now())
638 const seconds = Math.round((ended - job.started) / 1000)
639
640 const parts = [
641 `Hand-off ${job.n} (${job.input.mode}) ended: ${label(workerModel.replace(/^claude-/, ''), job.input.effort)} · ${requests} request${requests === 1 ? '' : 's'} · ${seconds}s · ${money(workerUsd)}; the same steps on your model: about ${money(alone)} at ${kTokens(contextTokens)} context.${job.reused ? ' A warm worker continued.' : ''}`,
642 ]
643 if (done.reason !== 'answer') parts.push(`The worker did not finish cleanly (${done.reason}).`)
644
645 const record: HandOff = {
646 at: job.started,
647 session: await $.session.id(),
648 mode: job.input.mode,
649 kind: job.input.kind,
650 model: workerModel,
651 effort: job.input.effort,
652 reused: job.reused,
653 focus: job.input.focus.length,
654 reachRefs: job.reach.reduce((k, r) => k + r.refs.length, 0),
655 requests,
656 durationMs: ended - job.started,
657 workerUsd,
658 mainAloneUsd: alone,
659 contextTokens,
660 files: [...new Set(job.reach.flatMap(r => filesOf(r.refs)))],
661 added: 0,
662 removed: 0,
663 reworkEdits: 0,
664 outcome: done.reason === 'answer' ? 'answer' : done.reason === 'refusal' ? 'refused' : done.reason === 'aborted' ? 'aborted' : 'error',
665 }
666
667 if (job.input.mode === 'edit') {
668 const fromGit = job.gitBase === undefined ? undefined : await gitDiff($, cwd, { ...job, gitBase: job.gitBase })
669 const diff = fromGit?.diff ?? (await diffOf($, cwd, job))
670 const files = fromGit?.files ?? [...job.originals.keys()].map(p => rel(cwd, p))
671 const stat = diffStat(diff)
672 Object.assign(record, { files, added: stat.added, removed: stat.removed })
673 const check = job.input.check ? await runCheck($, job.input.check) : undefined
674 if (check !== undefined && job.input.check !== undefined) record.check = { command: job.input.check, exit: check.exit }
675 const block = parseVerify(reportBody)
676 const tests = block?.tests ? await runCheck($, block.tests) : undefined
677 const checkCounts = check ? testCounts(check.tail) : undefined
678 const testsCounts = tests ? testCounts(tests.tail) : undefined
679 const v = verdict({
680 changed: files,
681 check: check ? { exit: check.exit, counts: checkCounts } : undefined,
682 tests: tests ? { exit: tests.exit, counts: testsCounts } : undefined,
683 // A worker that stopped on an error is not proven by tests that happen to pass.
684 open: [block?.open, done.reason === 'answer' ? undefined : `it did not finish cleanly (${done.reason})`].filter(Boolean).join('; ') || undefined,
685 block: block !== undefined,
686 })
687 record.verified = v.verified
688 parts.splice(1, 0, v.line)
689 const outside = job.reach.flatMap(r => [...r.refs, ...r.callers]).filter(p => !files.includes(p.path))
690 const fullPath = `${dir}/hand-off-${job.n}.diff`
691 if (diff) await $.fs.write(fullPath, diff + '\n')
692 const checkText = check ? `Check \`${job.input.check}\` (run by the tool): exit ${check.exit}\n${check.tail}` : 'No check command was given.'
693 const outsideText = outside.length > 0 ? `References outside the changed files (does the change keep them right?): ${places(outside)}` : ''
694 const testsText = tests && block?.tests ? `New tests \`${block.tests}\` (run by the tool): exit ${tests.exit}\n${tests.tail}` : ''
695 const evidence = [diff ? `Diff:\n${diff}` : 'The worker changed no file.', checkText, testsText, outsideText].filter(Boolean).join('\n\n')
696 const route = diff ? reviewRoute(review, tokensOf(evidence), contextTokens) : 'inline'
697 record.route = route
698 for (const f of files) touched.set(join(cwd, f), record)
699
700 parts.push(diff ? `Changed ${files.length} file${files.length === 1 ? '' : 's'} (+${stat.added} −${stat.removed}): ${files.join(', ')}` : 'The worker changed no file.')
701 const fork = route === 'fork' ? await $.model.fork({ prompt: REVIEW_PROMPT(job.input.task, evidence) }) : undefined
702 if (fork !== undefined && fork.isAnswered) {
703 record.reviewUsd = costUsd(fork.usage as Usage, mainModel)
704 record.reviewUsage = fork.usage as Usage
705 record.verdict = parseVerdict(fork.text)
706 parts.push(`Review of the full diff by a fork of you (${money(record.reviewUsd)}):\n${fork.text.trim()}\nFull diff: ${fullPath}`)
707 parts.push(checkText)
708 if (testsText) parts.push(testsText)
709 } else {
710 record.route = 'inline'
711 const shown = capLines(diff, DIFF_LINES)
712 if (diff) parts.push(`${shown.text}${shown.cut > 0 ? `\n… ${shown.cut} more lines in ${fullPath}` : ''}`)
713 parts.push(checkText)
714 if (testsText) parts.push(testsText)
715 if (outsideText) parts.push(outsideText)
716 }
717 if (!v.verified) parts.push('Judge the work by the diff and the check, not by the report.')
718 } else {
719 const named = parsePlaces(reportBody)
720 const fileCache = new Map<string, string[] | undefined>()
721 for (const path of new Set((named ?? []).map(p => p.path))) {
722 fileCache.set(path, await $.fs.read(join(cwd, path)).then(t => String(t).split('\n'), () => undefined))
723 }
724 const lspFiles = job.reach.filter(r => r.via === 'lsp').flatMap(r => filesOf(r.refs))
725 const v = readVerdict({
726 places: (named ?? []).map(p => ({ ...p, result: checkPlace(fileCache.get(p.path), p.line, p.symbol) })),
727 block: named !== undefined,
728 serverFiles: lspFiles.length > 0 ? [...new Set(lspFiles)] : undefined,
729 })
730 record.verified = v.verified
731 parts.splice(1, 0, v.line)
732 if (job.reachText) parts.push(`It was told where the task reaches:\n${job.reachText}`)
733 }
734
735 records.push(record)
736 return { text: parts.join('\n\n'), record }
737}
738
739export const register: Register = (on, options) => {
740 if (options.enabled === false) {
741 // Off: a tool registered earlier may outlive the reload, so only its call is answered.
742 for (const tool of HAND_OFF_TOOLS) {
743 on('tool.call', { tool }, () => ({ result: 'delegate is turned off (/mods turns it on).' }))
744 }
745 return
746 }
747 for (const [k, key, dflt] of [['read', 'readWorker', 'haiku'], ['mechanical', 'mechanicalWorker', 'haiku'], ['build', 'buildWorker', 'sonnet']] as const) {
748 const w = parseWorker(typeof options[key] === 'string' && (options[key] as string).trim() ? (options[key] as string) : dflt)
749 workers[k] = w.model ? w : parseWorker(dflt)
750 }
751 review = options.review === 'auto' || options.review === 'fork' || options.review === 'inline' ? options.review : DEFAULT_REVIEW
752 reuseMs = (typeof options.reuseMinutes === 'number' ? options.reuseMinutes : 50) * 60_000
753 capTokens = typeof options.workerContextCap === 'number' ? options.workerContextCap : 60_000
754 wait = options.wait === 'message' ? 'message' : 'tool'
755 framing = options.framing === 'capability' ? 'capability' : 'hand_off'
756 afterPlan = options.afterPlan === 'hand_off' ? 'hand_off' : 'off'
757
758 on('session.start', async ($, e, next) => {
759 autoModeBlocked = false
760 const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp/').replace(/\/$/, '')
761 dir = `${tmp}/claude-delegate/${await $.session.id()}`
762 await $.process.run(['mkdir', '-p', dir])
763 const have = new Set((await $.tool.list()).map(t => t.name))
764 for (const kind of ['read', 'mechanical', 'build'] as const) {
765 await $.agent.register({
766 name: AGENT[kind],
767 description: `The ${kind === 'read' ? 'read-only' : kind === 'mechanical' ? 'mechanical-edit' : 'building'} worker of the hand_off tool.`,
768 prompt: WORKER_RULES,
769 tools: WORKER_TOOLS[kind].filter(t => have.has(t)),
770 model: workers[kind].model,
771 ...(workers[kind].effort ? { effort: workers[kind].effort } : {}),
772 })
773 }
774 if (framing === 'capability') await $.tool.register({ name: CAPABILITY, description: describeCapability(), inputSchema: CAPABILITY_SCHEMA, isDeferred: false })
775 else await $.tool.register({ name: TOOL, description: describe(wait), inputSchema: INPUT_SCHEMA, isDeferred: false })
776 await $.command.register({ name: 'delegate', description: 'What hand-offs to the worker model cost and saved, and how often their work came back' })
777 await reconcile($)
778 return next(e)
779 })
780
781 on('turn.start', async ($, e, next) => {
782 await reconcile($)
783 return next(e)
784 })
785
786 // The workers are this tool's alone: the model reaches them through hand_off, never the Agent tool.
787 // Their type is offered only while this mod continues one, which resuming requires.
788 for (const kind of ['read', 'mechanical', 'build'] as const) on('agent.offer', { agent: `delegate:${AGENT[kind]}` }, () => ({ isOffered: resuming }))
789
790 on('command.run', { command: 'delegate' }, async $ => {
791 if ((await read($, VIEW)).length > 0) await openPane($).catch(() => undefined)
792 return { text: summarize([...(await readLog($)), ...records]) }
793 })
794
795 for (const tool of HAND_OFF_TOOLS) on('tool.call', { tool }, async ($, e, next) => {
796 const i = e as unknown as { task?: unknown; spec?: unknown; kind?: unknown; focus?: unknown; check?: unknown; fresh?: unknown; background?: unknown}
797 const capability = (e.tool as string) === 'mcp__delegate__implement_spec'
798 const task = capability ? i.spec : i.task
799 if (typeof task !== 'string' || !task.trim()) return { result: capability ? 'implement_spec needs a spec.' : 'hand_off needs a task.' }
800 const kind: Kind = capability ? 'build' : i.kind === 'read' || i.kind === 'mechanical' || i.kind === 'build' ? i.kind : 'build'
801 const mode = MODE[kind]
802 const focus = Array.isArray(i.focus) ? i.focus.filter((f): f is string => typeof f === 'string') : []
803 const check = typeof i.check === 'string' && i.check.trim() ? i.check : undefined
804 const background = typeof i.background === 'boolean' ? i.background : wait === 'message'
805 const { model: worker, effort } = workers[kind]
806 const started = await start($, e.tool_use_id, { task, kind, mode, effort, focus, check, fresh: !capability && i.fresh === true, background, worker })
807 const job = started.agentId === undefined ? undefined : jobs.get(started.agentId)
808 if (job === undefined || !job.holding) return { result: started.text }
809 const held = await hold($, job, next.signal)
810 return { result: held ?? `${started.text} Stopped waiting: its report arrives as a notification when it finishes.` }
811 }).catch(() => ({ result: 'The hand-off failed inside the delegate mod. Do the task yourself.' }))
812
813 /** Measured: a 50-line feature plan handed off was correct and cheap; renames (a few lines) cost more handed off. */
814 const PLAN_MIN_LINES = 20
815
816 // An approved plan: the order to implement it through a hand-off joins the tool's result. Only a plan
817 // the size of real work: a small one (a rename) costs more handed off than done in place.
818 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
819 const r = await next(e)
820 const plan = (e as { plan?: unknown }).plan
821 const lines = typeof plan === 'string' ? plan.split('\n').filter(l => l.trim() !== '').length : 0
822 if (afterPlan !== 'hand_off' || autoModeBlocked || lines < PLAN_MIN_LINES || r.deny !== undefined || r.isError) return r
823 return { ...r, context: [...(r.context ?? []), planOrder(framing)] }
824 }).catch(($, e, next) => next(e))
825
826 // The workers' own edits (each file kept as it was before), and the main model's edits to their files afterwards.
827 on('tool.call', async ($, e, next) => {
828 const agentId = (e as { agentId?: string }).agentId
829 const path = (e as { file_path?: unknown }).file_path
830 const edits = (e.tool === 'Edit' || e.tool === 'Write') && typeof path === 'string'
831 if (agentId !== undefined) {
832 const job = jobs.get(agentId)
833 if (job === undefined) return next(e)
834 if (edits) {
835 const abs = join(await $.session.cwd(), path)
836 if (!job.originals.has(abs)) job.originals.set(abs, await $.fs.read(abs).catch(() => null))
837 }
838 const startedAt = await $.clock.now()
839 const label = actionLabel(e.tool, e as unknown as { [k: string]: unknown })
840 let index = 0
841 await setView($, job.toolUseId, j => {
842 index = j.actions.length
843 return { actions: [...j.actions, { tool: e.tool, label, state: 'running', startedAt }] }
844 })
845 const r = await next(e)
846 const ms = (await $.clock.now()) - startedAt
847 const state = r.deny !== undefined || r.isError ? 'failed' : 'ok'
848 await setView($, job.toolUseId, j => ({ actions: j.actions.map((a, i) => (i === index ? { ...a, state, ms } : a)) }))
849 await writeEta($, job, etaStatusLine({ requests: job.requests, usd: costUsd(job.usage, job.workerModel ?? '') }, { tool: e.tool, label }))
850 return r
851 }
852 const r = await next(e)
853 if (edits && r.deny === undefined && !r.isError) {
854 const record = touched.get(join(await $.session.cwd(), path))
855 if (record !== undefined) record.reworkEdits++
856 }
857 return r
858 }).catch(($, e, next) => next(e))
859
860 on('turn.step', async function* ($, e, next) {
861 const r = yield* next(e)
862 const job = e.agentId === undefined ? undefined : jobs.get(e.agentId)
863 if (job !== undefined && r.usage) {
864 const u = r.usage
865 job.requests++
866 job.workerModel = u.model
867 job.usage = {
868 input_tokens: job.usage.input_tokens + u.input_tokens,
869 output_tokens: job.usage.output_tokens + u.output_tokens,
870 cache_read_input_tokens: job.usage.cache_read_input_tokens + u.cache_read_input_tokens,
871 cache_creation_input_tokens: job.usage.cache_creation_input_tokens + u.cache_creation_input_tokens,
872 }
873 job.lastContext = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
874 const spent = costUsd(job.usage, u.model)
875 await setView($, job.toolUseId, () => ({ requests: job.requests, usd: spent, model: u.model.replace(/^claude-/, '').replace(/-\d.*$/, '') }))
876 await writeEta($, job, etaStatusLine({ requests: job.requests, usd: spent }))
877 }
878 return r
879 })
880
881 on('turn.complete', async ($, e, next) => {
882 const r = await next(e)
883 if (e.agentId !== undefined) {
884 const job = jobs.get(e.agentId)
885 if (job !== undefined) {
886 job.done = { answer: e.answer, reason: e.reason }
887 job.ended ??= await $.clock.now()
888 if (e.reason !== 'answer' && warm.get(warmKey(job.input))?.agentId === e.agentId) warm.delete(warmKey(job.input))
889 if (job.reported && (!job.returned || job.handback !== undefined)) jobs.delete(e.agentId)
890 else await handOver($, job)
891 }
892 return r
893 }
894 // The main turn that read the reports ended: they go to the log, and edits stop counting as rework.
895 const done = records
896 records = []
897 touched = new Map()
898 await flush($, done).catch(() => undefined)
899 return r
900 })
901
902 // The card in the transcript: live while the worker runs, then its outcome; its button opens the pane.
903 for (const tool of HAND_OFF_TOOLS) on('ui.render', { component: 'ToolUse', props: { tool } }, async ($, e, next) => {
904 const job = (await read($, VIEW)).find(j => j.toolUseId === e.requestId)
905 if (job === undefined) return next(e)
906 const now = await $.clock.now()
907 const open = () => void openPane($, job.toolUseId).catch(() => undefined)
908 return jobCard($.ui.resolve(e) as unknown as Elements, job, now, e.viewport?.columns ?? 100, open)
909 })
910
911 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
912 const now = await $.clock.now()
913 const pick = (id: string) => void update($, WATCHING, () => id).catch(() => undefined)
914 return pane($.ui.resolve(e) as unknown as Elements, await read($, VIEW), await read($, WATCHING), now, e.props.bodyColumns, pick)
915 })
916
917 // The card says it all: the tool's own result ("the worker is on it") draws nothing under it.
918 for (const tool of HAND_OFF_TOOLS) on('ui.render', { component: 'ToolResult', props: { tool } }, async ($, e, next) => {
919 if ((await read($, VIEW)).every(j => j.toolUseId !== e.requestId) || e.props.isErrored) return next(e)
920 const { Box } = $.ui.resolve(e)
921 return <Box />
922 })
923
924 // The worker's report reaching the main model (its hand-back message, or a task notice): the evidence joins it.
925 on('prompt.submit', async ($, e, next) => {
926 const kind = e.origin?.kind
927 if (kind === undefined || kind === 'composer' || kind === 'bridge' || kind === 'sdk' || kind === 'plugin') return next(e)
928 if (dropsNotice(handedOver, kind, e.text)) {
929 const copy = secondCopy(e.turnId)
930 return 'drop' in copy ? { drop: copy.drop } : next({ ...e, text: copy.text })
931 }
932 const job = [...jobs.values()].find(j => (!j.reported || (j.returned && j.handback === undefined)) && e.text.includes(j.agentId))
933 if (job === undefined) return next(e)
934 // Held in the tool call: the report goes back as its result, so the message itself does not enter.
935 if (job.holding || job.returned) {
936 job.handback = reportText(e.text)
937 if (job.returned && job.done !== undefined) jobs.delete(job.agentId)
938 const copy = secondCopy(e.turnId)
939 return 'drop' in copy ? { drop: copy.drop } : next({ ...e, text: copy.text })
940 }
941 if (job.done !== undefined) jobs.delete(job.agentId)
942 const evidence = await deliver($, job, reportText(e.text))
943 return next({ ...e, context: [...(e.context ?? []), evidence] })
944 }).catch(($, e, next) => next(e))
945}
946hooks/plan.ts 466 lines1// Pure parts of the hand-off: where a task reaches, what the worker reads,
2// what the work cost against doing it on the main model, and what goes back.
3
4export type Mode = 'explore' | 'edit'
5export type Kind = 'read' | 'mechanical' | 'build'
6export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
7
8/** "sonnet:low" -> { model: 'sonnet', effort: 'low' }; "haiku" -> { model: 'haiku' }; an unknown effort word is dropped. */
9export function parseWorker(setting: string): { model: string; effort?: string } {
10 const [m, e] = setting.trim().split(':')
11 const model = (m ?? '').trim()
12 const effort = (e ?? '').trim().toLowerCase()
13 return EFFORTS.includes(effort) ? { model, effort } : { model }
14}
15
16export type Place = { path: string; line: number }
17
18/** One thing the task touches, resolved to a place the language server can be asked about. */
19export type Anchor = { name: string; path: string; line: number; character: number }
20
21/** What the task reaches: per anchor, its references, callers and doc mentions. */
22export type Reach = {
23 anchor?: Anchor
24 name: string
25 refs: Place[]
26 callers: Place[]
27 docs: string[]
28 /** How the references were found: the language server, or a text search where none answered. */
29 via: 'lsp' | 'text' | 'none'
30}
31
32export type Focus = { path?: string; line?: number; name?: string }
33
34/** `path:line`, `path#name`, or a bare `name`. */
35export function parseFocus(raw: string): Focus {
36 const s = raw.trim()
37 const at = /^(.+?):(\d+)$/.exec(s)
38 if (at) return { path: at[1], line: Number(at[2]) }
39 const hash = /^(.+?)#([A-Za-z_$][\w$]*)$/.exec(s)
40 if (hash) return { path: hash[1], name: hash[2] }
41 if (/^[A-Za-z_$][\w$.]*$/.test(s)) return { name: s.split('.').at(-1) }
42 return { path: s }
43}
44
45const KEYWORDS = 'function|class|interface|type|enum|struct|trait|def|fn|func|const|let|var|val|record|module|namespace'
46
47/** An extended regex for `git grep -E` that finds where `name` is defined. */
48export const definitionPattern = (name: string) => `\\b(${KEYWORDS})\\s+${name}\\b`
49
50/** The same for `git grep -w -E`, whose POSIX expressions have no `\\b` or `\\s`. */
51export const gitDefinitionPattern = (name: string) => `(${KEYWORDS})[[:space:]]+${name}`
52
53/** Where on a source line to ask about a symbol: its definition's name, else `name`, else the first identifier. */
54export function anchorOn(text: string, name?: string): { name: string; character: number } | undefined {
55 const def = new RegExp(`\\b(?:${KEYWORDS})\\s+([A-Za-z_$][\\w$]*)`).exec(text)
56 if (name !== undefined) {
57 const at = new RegExp(`(^|[^\\w$])(${name.replace(/\$/g, '\\$')})($|[^\\w$])`).exec(text)
58 if (at) return { name, character: at.index + (at[1] as string).length + 1 }
59 }
60 if (def) return { name: def[1] as string, character: def.index + def[0].length - (def[1] as string).length + 1 }
61 // An import line is about what it imports, never the words `from` or `import`.
62 const imported = /\bimport\s+(?:\{\s*)?([A-Za-z_$][\w$]*)/.exec(text)
63 if (imported) return { name: imported[1] as string, character: imported.index + imported[0].length - (imported[1] as string).length + 1 }
64 for (const m of text.matchAll(/[A-Za-z_$][\w$]*/g)) {
65 if (!NOT_NAMES.has(m[0])) return { name: m[0], character: (m.index ?? 0) + 1 }
66 }
67 return undefined
68}
69
70const NOT_NAMES = new Set(
71 'export public private protected static async await pub from import as return if else for while const let var def class function fn func new await yield in of not and or self this'.split(' '),
72)
73
74/** A text search past this many hits names something too common to trace. */
75export const TEXT_SEARCH_CAP = 200
76
77const relative = (cwd: string, path: string) => (path.startsWith(cwd + '/') ? path.slice(cwd.length + 1) : path)
78
79/**
80 * Places in an LSP result, whichever way it lists them: `path:line[:col]`, or a
81 * `path:` heading over `Line n:c` rows.
82 */
83export function parseLocations(text: string, cwd: string): Place[] {
84 const found: Place[] = []
85 let file: string | undefined
86 for (const row of text.split('\n')) {
87 const heading = /^\s*((?:\/|\.{1,2}\/)?[\w@.\-/ ]+\.[\w]+):\s*$/.exec(row)
88 if (heading) {
89 file = relative(cwd, (heading[1] as string).trim())
90 continue
91 }
92 const line = /^\s*(?:[-•*]\s*)?Line\s+(\d+)(?::\d+)?/i.exec(row)
93 if (line && file !== undefined) {
94 found.push({ path: file, line: Number(line[1]) })
95 continue
96 }
97 for (const m of row.matchAll(/((?:\/|\.{1,2}\/)?[\w@.\-/]+\.[A-Za-z]\w*):(\d+)(?::\d+)?/g)) {
98 found.push({ path: relative(cwd, m[1] as string), line: Number(m[2]) })
99 }
100 }
101 const seen = new Set<string>()
102 return found.filter(p => {
103 const key = `${p.path}:${p.line}`
104 if (seen.has(key)) return false
105 seen.add(key)
106 return true
107 })
108}
109
110/** "a.ts:3,9 · b.ts:12 · … and 4 more files" */
111export function places(list: readonly Place[], maxFiles = 8): string {
112 const byFile = new Map<string, number[]>()
113 for (const p of list) byFile.set(p.path, [...(byFile.get(p.path) ?? []), p.line])
114 const files = [...byFile.entries()]
115 const shown = files.slice(0, maxFiles).map(([f, lines]) => `${f}:${lines.slice(0, 6).join(',')}${lines.length > 6 ? ',…' : ''}`)
116 const more = files.length - shown.length
117 return shown.join(' · ') + (more > 0 ? ` · and ${more} more file${more === 1 ? '' : 's'}` : '')
118}
119
120export function filesOf(list: readonly Place[]): string[] {
121 return [...new Set(list.map(p => p.path))]
122}
123
124/** The reach as the worker and the reviewer read it. */
125export function describeReach(reach: readonly Reach[]): string {
126 if (reach.length === 0) return ''
127 const rows = reach.map(r => {
128 const where = r.anchor ? ` (${r.anchor.path}:${r.anchor.line})` : ''
129 if (r.via === 'none') return `- \`${r.name}\`${where}: not found`
130 const head = `- \`${r.name}\`${where}: ${r.refs.length} reference${r.refs.length === 1 ? '' : 's'} in ${filesOf(r.refs).length} file${filesOf(r.refs).length === 1 ? '' : 's'}${r.via === 'text' ? ' (text search, not the language server)' : ''}`
131 const lines = [head]
132 if (r.refs.length > 0) lines.push(` ${places(r.refs)}`)
133 if (r.callers.length > 0) lines.push(` called from ${places(r.callers)}`)
134 for (const d of r.docs) lines.push(` ${d.split('\n').join('\n ')}`)
135 return lines.join('\n')
136 })
137 return rows.join('\n')
138}
139
140export const WORKER_RULES = `You work for another Claude, which handed you one task and will check what you did. Work fast and stay inside the task.
141
142- The task text is all you know of the conversation it came from. If it leaves a decision open that matters, do not guess: finish what you can and name what is open.
143- Before you change a function's signature or a symbol's name, ask the LSP tool for its references (findReferences) and change every one, or say which you left.
144- Do not claim you ran or verified anything you did not run. The other Claude runs the check itself and reads the diff.
145- End with a short report: what you found or changed, each with file:line, and anything you are unsure of. No preamble, no summary of the task.`
146
147const VERIFY_INSTRUCTION = `Your report must end with this block, exactly, no code fence:
148VERIFY
149tests: <one shell command, run from the project's root, that runs only the tests you added or changed; or none>
150open: <what you left undone or are unsure of, in one line; or none>`
151
152const PLACES_INSTRUCTION = `Your report must end with this block, exactly, no code fence, one line per place the answer names:
153PLACES
154<path relative to the project root>:<line> <the symbol as it appears on that line>`
155
156export function workerPrompt(args: { task: string; mode: Mode; reach: string; check?: string; root?: string }): string {
157 const mode =
158 args.mode === 'edit'
159 ? `Mode: edit. Change the files the task needs and nothing else.${args.check ? ` Afterwards this check will run: \`${args.check}\`; you may run it too.` : ''}\n\n${VERIFY_INSTRUCTION}`
160 : `Mode: explore. Read only: change no file. Find what the task asks and report it with file:line.\n\n${PLACES_INSTRUCTION}`
161 const reach = args.reach ? `\n\nWhere this reaches (from the language server; places that use what the task touches):\n${args.reach}` : ''
162 // Also for a warm worker resumed with a new task: its shell may have been left in another folder.
163 const root = args.root ? `\n\nThe project is ${args.root}: work there, never in another copy you may find on the disk.` : ''
164 return `${args.task.trim()}\n\n${mode}${root}${reach}`
165}
166
167type Counts = { passed: number; failed: number }
168
169/** The worker's VERIFY block: the last one in the report, `none` or empty read as absent. */
170export function parseVerify(report: string): { tests?: string; open?: string } | undefined {
171 const lines = report.split('\n')
172 let at = -1
173 lines.forEach((l, i) => {
174 if (l.trim() === 'VERIFY') at = i
175 })
176 if (at < 0) return undefined
177 const out: { tests?: string; open?: string } = {}
178 for (const l of lines.slice(at + 1)) {
179 const m = /^\s*(tests|open):\s*(.*)$/i.exec(l)
180 if (!m) continue
181 const v = (m[2] as string).trim()
182 if (v === '' || v.toLowerCase() === 'none') continue
183 out[(m[1] as string).toLowerCase() as 'tests' | 'open'] = v
184 }
185 return out
186}
187
188/** The pass and fail counts of the last test summary in a runner's output, or undefined where none is recognised. */
189export function testCounts(output: string): Counts | undefined {
190 const lines = output.split('\n')
191 for (let i = lines.length - 1; i >= 0; i--) {
192 const l = lines[i] as string
193 if (/no tests ran/i.test(l)) return { passed: 0, failed: 0 }
194 // pytest
195 if (/\b\d+ (passed|failed|errors?|skipped|deselected|xfailed|xpassed)\b/.test(l) && /\bin [\d.]+s/.test(l)) {
196 return { passed: num(l, /(\d+) passed/), failed: num(l, /(\d+) failed/) + num(l, /(\d+) errors?\b/) }
197 }
198 // jest
199 if (/^\s*Tests:\s/.test(l)) return { passed: num(l, /(\d+) passed/), failed: num(l, /(\d+) failed/) }
200 // vitest
201 if (/^\s*Tests\s+\d/.test(l)) return { passed: num(l, /(\d+) passed/), failed: num(l, /(\d+) failed/) }
202 // node:test
203 if (/^\s*# (pass|fail) \d+/.test(l)) {
204 const all = lines.join('\n')
205 const last = (k: string) => [...all.matchAll(new RegExp(`^\\s*# ${k} (\\d+)`, 'gm'))].at(-1)
206 const p = last('pass')
207 const f = last('fail')
208 if (p || f) return { passed: Number(p?.[1] ?? 0), failed: Number(f?.[1] ?? 0) }
209 }
210 // bun
211 if (/^\s*\d+ (pass|fail)\s*$/.test(l)) {
212 const all = lines.join('\n')
213 const last = (k: string) => [...all.matchAll(new RegExp(`^\\s*(\\d+) ${k}\\s*$`, 'gm'))].at(-1)
214 return { passed: Number(last('pass')?.[1] ?? 0), failed: Number(last('fail')?.[1] ?? 0) }
215 }
216 }
217 return undefined
218}
219
220const num = (line: string, re: RegExp) => Number(re.exec(line)?.[1] ?? 0)
221
222/** Whether a path is a test file by where it lives or how it is named. */
223export function isTestFile(path: string): boolean {
224 if (/(^|\/)(tests?|__tests__|spec)\//.test(path)) return true
225 const name = path.split('/').at(-1) ?? ''
226 return /^test_.*\.py$/.test(name) || /_test\.(py|go)$/.test(name) || /\.(test|spec)\.(js|jsx|ts|tsx|mjs)$/.test(name)
227}
228
229type Ran = { exit: number; counts?: Counts }
230
231/** The one line that tells the main model whether the worker's work is proven, and what to look at if not. */
232export function verdict(args: { changed: string[]; check?: Ran; tests?: Ran; open?: string; block: boolean }): { verified: boolean; line: string } {
233 const bad: string[] = []
234 const good: string[] = []
235 const n = (c?: Counts) => (c ? ` (${c.passed} passed)` : '')
236 if (args.changed.length === 0) bad.push('the worker changed no file')
237 const c = args.check
238 if (!c) bad.push('the check was not given')
239 else if (c.exit !== 0) bad.push(`the check failed with exit ${c.exit}`)
240 else if (c.counts && c.counts.failed > 0) bad.push(`the check had ${c.counts.failed} failing`)
241 else good.push(`the check passed${n(c.counts)}`)
242 const t = args.tests
243 if (t) {
244 if (t.exit !== 0) bad.push(`the new tests failed with exit ${t.exit}`)
245 else if (t.counts && t.counts.failed > 0) bad.push(`the new tests had ${t.counts.failed} failing`)
246 else if (t.counts && t.counts.passed === 0) bad.push('the new tests did not run (0 selected)')
247 else good.push(`the new tests passed on their own${n(t.counts)}`)
248 } else if (args.changed.some(isTestFile)) bad.push('test files changed but no command ran them on their own')
249 if (!args.block) bad.push("the worker's report had no VERIFY block")
250 if (args.open) bad.push(`the worker left open: ${args.open}`)
251 if (bad.length === 0) {
252 return { verified: true, line: `Verified: ${good.join('; ')}; nothing left open. If the diff is what you asked for, report to the person now: nothing is left to run.` }
253 }
254 return { verified: false, line: `Not verified: ${bad.join('; ')}. Look at these before you report.` }
255}
256
257type PlaceResult = 'found' | { near: number } | 'missing'
258
259/** The places in the worker's PLACES block: the last one in the report; undefined where there is none. */
260export function parsePlaces(report: string): { path: string; line: number; symbol: string }[] | undefined {
261 const lines = report.split('\n')
262 let at = -1
263 lines.forEach((l, i) => {
264 if (l.trim() === 'PLACES') at = i
265 })
266 if (at < 0) return undefined
267 const out: { path: string; line: number; symbol: string }[] = []
268 for (const l of lines.slice(at + 1)) {
269 const m = /^\s*(\S+?):(\d+)\s+(\S+)/.exec(l)
270 if (!m) break
271 out.push({ path: m[1] as string, line: Number(m[2]), symbol: (m[3] as string).replace(/`/g, '') })
272 }
273 return out
274}
275
276/** Whether the symbol is on the given 1-based line of the file, or near it, or nowhere close. */
277export function checkPlace(fileLines: string[] | undefined, line: number, symbol: string): PlaceResult {
278 if (!fileLines || symbol === '') return 'missing'
279 const esc = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
280 const re = /^\w+$/.test(symbol) ? new RegExp(`\\b${esc}\\b`) : new RegExp(esc)
281 const has = (n: number) => n >= 1 && n <= fileLines.length && re.test(fileLines[n - 1] as string)
282 if (has(line)) return 'found'
283 for (let d = 1; d <= 3; d++) {
284 if (has(line - d)) return { near: line - d }
285 if (has(line + d)) return { near: line + d }
286 }
287 return 'missing'
288}
289
290/** The one line that tells the main model whether a lookup answer is proven in the code, and what to open if not. */
291export function readVerdict(args: {
292 places: { path: string; line: number; symbol: string; result: PlaceResult }[]
293 block: boolean
294 serverFiles?: string[]
295}): { verified: boolean; line: string } {
296 const bad: string[] = []
297 const missing = args.places.filter(p => p.result === 'missing')
298 const near = args.places.filter(p => typeof p.result === 'object')
299 const named = new Set(args.places.map(p => p.path))
300 const unnamed = (args.serverFiles ?? []).filter(f => !named.has(f))
301 if (!args.block) bad.push('no PLACES block')
302 else if (args.places.length === 0) bad.push('the answer named no places')
303 if (missing.length > 0) {
304 const shown = missing.slice(0, 5).map(p => `${p.path}:${p.line} \`${p.symbol}\``).join(', ')
305 bad.push(`${missing.length} place${missing.length === 1 ? '' : 's'} not found at the line given: ${shown}${missing.length > 5 ? ', …' : ''}`)
306 }
307 // Files the server finds references in that the answer leaves out are named, not held against it: a question about
308 // src/ rightly leaves the tests out, and only the main model knows what the question covers.
309 const also = unnamed.length > 0 ? `; the language server also finds references in files the answer does not name (check whether the question covers them): ${unnamed.slice(0, 5).join(', ')}${unnamed.length > 5 ? ', …' : ''}` : ''
310 if (bad.length === 0) {
311 const off = near.length > 0 ? ` (${near.length} ${near.length === 1 ? 'was' : 'were'} a line or two off)` : ''
312 const server = args.serverFiles && args.serverFiles.length > 0 && unnamed.length === 0 ? "; the language server's references fall in the files the answer names" : also
313 return {
314 verified: true,
315 line: `Verified: all ${args.places.length} places checked in the code (the symbol is on each line named)${off}${server}. If this answers the question, answer the person from it now: nothing is left to open.`,
316 }
317 }
318 return { verified: false, line: `Not verified: ${bad.join('; ')}${also}. Open only these before you answer.` }
319}
320
321/** $/M tokens. Cache writes at the 5-minute rate (1.25x input). */
322export type Price = { input: number; output: number; read: number; write: number }
323
324export const PRICES: Record<string, Price> = {
325 haiku: { input: 0.1, output: 0.5, read: 0.01, write: 0.125 },
326 sonnet: { input: 2, output: 10, read: 0.2, write: 2.5 },
327 opus: { input: 4, output: 20, read: 0.2, write: 5 },
328 fable: { input: 10, output: 50, read: 0.25, write: 12.5 },
329}
330
331export function priceOf(model: string): Price {
332 const m = model.toLowerCase()
333 for (const k of Object.keys(PRICES)) if (m.includes(k)) return PRICES[k] as Price
334 return PRICES.opus as Price
335}
336
337export type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
338
339export function costUsd(u: Usage, model: string): number {
340 const p = priceOf(model)
341 return (u.input_tokens * p.input + u.output_tokens * p.output + u.cache_read_input_tokens * p.read + u.cache_creation_input_tokens * p.write) / 1e6
342}
343
344/**
345 * What the same work would have cost on the main model: each of the worker's
346 * requests re-reads the main context from cache, what the worker read fresh is
347 * written to the main cache once, and its output is the main model's output.
348 */
349export function mainAloneUsd(args: { requests: number; contextTokens: number; worker: Usage; model: string }): number {
350 const p = priceOf(args.model)
351 const fresh = args.worker.input_tokens + args.worker.cache_creation_input_tokens
352 return (args.requests * args.contextTokens * p.read + fresh * p.write + args.worker.output_tokens * p.output) / 1e6
353}
354
355export type Route = 'inline' | 'fork'
356
357/**
358 * Who reviews the evidence. Inline: the main model reads it in its next step,
359 * so it is written to the main cache once and re-read on every later step. Fork:
360 * one extra request re-reads the whole main context and only a verdict comes
361 * back. The fork pays once the evidence costs more to carry than the context
362 * costs to read: about a 60th of the context, never under 2,000 tokens.
363 */
364export function reviewRoute(setting: string, evidenceTokens: number, contextTokens: number): Route {
365 if (setting === 'inline' || setting === 'fork') return setting
366 return evidenceTokens > Math.max(2000, contextTokens / 60) ? 'fork' : 'inline'
367}
368
369/**
370 * The default. Forks measured in the benchmark cost $0.03-0.38 each at a
371 * 40-60k context, far more than reading the evidence inline: they did not
372 * read the session's cache as assumed. Inline stays the default until they do.
373 */
374export const DEFAULT_REVIEW = 'inline'
375
376export const tokensOf = (text: string) => Math.ceil(text.length / 4)
377
378export function capLines(text: string, max: number): { text: string; cut: number } {
379 const lines = text.split('\n')
380 if (lines.length <= max) return { text, cut: 0 }
381 return { text: lines.slice(0, max).join('\n'), cut: lines.length - max }
382}
383
384/** "+40 −12" from a unified diff. */
385export function diffStat(diff: string): { added: number; removed: number } {
386 let added = 0
387 let removed = 0
388 for (const row of diff.split('\n')) {
389 if (row.startsWith('+') && !row.startsWith('+++')) added++
390 else if (row.startsWith('-') && !row.startsWith('---')) removed++
391 }
392 return { added, removed }
393}
394
395export const REVIEW_PROMPT = (task: string, evidence: string) => `A cheaper worker model just did a task you handed it. Review its work from the evidence below (the diff and the check output, not its own claims), using what you know of this conversation.
396
397The task you gave it:
398${task.trim()}
399
400${evidence}
401
402Answer in this shape and nothing else:
403VERDICT: accept | fix | redo
404Then at most five lines, each a concrete problem with file:line, or "none". "fix" means small corrections you will make yourself; "redo" means the approach is wrong.`
405
406export function parseVerdict(text: string): 'accept' | 'fix' | 'redo' | undefined {
407 const m = /VERDICT:\s*(accept|fix|redo)/i.exec(text)
408 return m ? (m[1]?.toLowerCase() as 'accept' | 'fix' | 'redo') : undefined
409}
410
411export const money = (usd: number) => (usd < 0.01 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`)
412export const kTokens = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
413
414/** One hand-off as the log keeps it, for measuring what it saves and how often its work comes back. */
415export type HandOff = {
416 at: number
417 session: string
418 mode: Mode
419 kind?: Kind
420 model: string
421 effort?: string
422 reused: boolean
423 focus: number
424 reachRefs: number
425 requests: number
426 durationMs: number
427 workerUsd: number
428 mainAloneUsd: number
429 contextTokens: number
430 files: string[]
431 added: number
432 removed: number
433 check?: { command: string; exit: number }
434 route?: Route
435 reviewUsd?: number
436 /** The fork's tokens: whether it read the session's cache or paid for the whole context again. */
437 reviewUsage?: Usage
438 verdict?: string
439 /** Whether the tool's own checks passed and nothing was left open. */
440 verified?: boolean
441 /** Edits the main model made afterwards, before the person's next prompt, to files the worker changed. */
442 reworkEdits: number
443 outcome: 'answer' | 'aborted' | 'error' | 'timeout' | 'refused'
444}
445
446export function summarize(records: readonly HandOff[]): string {
447 if (records.length === 0) return 'No hand-offs logged yet.'
448 const sum = (f: (r: HandOff) => number) => records.reduce((n, r) => n + f(r), 0)
449 const worker = sum(r => r.workerUsd + (r.reviewUsd ?? 0))
450 const alone = sum(r => r.mainAloneUsd)
451 const edits = records.filter(r => r.mode === 'edit')
452 const reworked = edits.filter(r => r.reworkEdits > 0 || r.verdict === 'redo').length
453 const checked = edits.filter(r => r.check !== undefined)
454 const passed = checked.filter(r => r.check?.exit === 0).length
455 const reused = records.filter(r => r.reused).length
456 return [
457 `${records.length} hand-offs (${records.length - edits.length} explore, ${edits.length} edit), ${reused} on a warm worker.`,
458 `Worker and reviews: ${money(worker)}. The same steps on the main model: about ${money(alone)}. Saved: about ${money(alone - worker)}.`,
459 edits.length > 0 ? `Edit work that came back: ${reworked} of ${edits.length} (main model edited the same files afterwards, or the review said redo).` : '',
460 checked.length > 0 ? `Checks passed first time: ${passed} of ${checked.length}.` : '',
461 'The estimate re-reads the main context once per worker request; it cannot know how many steps the main model would really have taken.',
462 ]
463 .filter(Boolean)
464 .join('\n')
465}
466hooks/view.tsx 225 lines1// How a hand-off draws: in the transcript, a card that stays live while its
2// worker runs (a turning disc, a scanner, the clock, requests, cost and its
3// last actions) and then reads as an outcome; and a pane, opened from the
4// card, that lists everything each worker did and, once in, its report.
5
6import type { BoxProps, ButtonProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
7
8import type { DelegateAction, DelegateJob } from '../types'
9
10export type Elements = {
11 Box: ElementConstructor<BoxProps>
12 Text: ElementConstructor<TextProps>
13 Button: ElementConstructor<ButtonProps>
14}
15
16export const FRAME_MS = 250
17const TURN = ['◐', '◓', '◑', '◒'] as const
18const SCAN = 8
19const VERB: Record<string, string> = { Bash: 'run', Read: 'read', Edit: 'edit', Write: 'write', LSP: 'lsp', Grep: 'grep', Glob: 'glob', SubagentHandback: 'report' }
20
21export function scanner(frame: number): { before: string; head: string; after: string } {
22 const span = SCAN - 2
23 const t = frame % (2 * span)
24 const at = t <= span ? t : 2 * span - t
25 return { before: '·'.repeat(at), head: '━━', after: '·'.repeat(span - at) }
26}
27
28export function clock(ms: number): string {
29 const s = Math.max(0, Math.round(ms / 1000))
30 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
31}
32
33export const usd = (n: number) => (n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`)
34
35export function fit(text: string, room: number): string {
36 const flat = text.replace(/\s+/g, ' ').trim()
37 return flat.length > room ? `${flat.slice(0, Math.max(1, room - 1))}…` : flat
38}
39
40/** What a worker's tool call was about, in a few words. */
41export function actionLabel(tool: string, input: Record<string, unknown>): string {
42 const s = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
43 const short = (p: string) => p.split('/').slice(-2).join('/')
44 if (tool === 'Bash') return s('command').split('\n')[0] ?? ''
45 if (tool === 'LSP') return `${s('operation')} ${short(s('filePath'))}:${String(input.line ?? '')}`
46 if (tool === 'Grep' || tool === 'Glob') return s('pattern')
47 return short(s('file_path') || s('path') || s('filePath'))
48}
49
50export const verb = (tool: string) => VERB[tool] ?? tool.replace(/^mcp__\w+__/, '').toLowerCase()
51
52/** The model, and the effort when one is set: "sonnet low". */
53export const workerName = (j: { model: string; effort?: string }) => (j.effort ? `${j.model} ${j.effort}` : j.model)
54
55/** The card's head: what state, which worker, how far. */
56export function headline(j: DelegateJob, now: number): string {
57 const time = clock((j.endedAt ?? now) - j.startedAt)
58 const req = `${j.requests} req`
59 const cost = usd(j.usd)
60 if (j.state === 'running') return `hand-off ${j.n} · ${j.mode} · ${workerName(j)}${j.reused ? ' (warm)' : ''} · ${time} · ${req} · ${cost}`
61 const saved = j.aloneUsd !== undefined ? ` · on your model ≈ ${usd(j.aloneUsd)}` : ''
62 return `hand-off ${j.n} · ${j.mode} · ${workerName(j)} · ${time} · ${req} · ${cost}${saved}`
63}
64
65/** The outcome line under a finished card: what changed and whether the check held. */
66export function outcome(j: DelegateJob): string | undefined {
67 if (j.state === 'running') return undefined
68 const parts: string[] = []
69 if (j.mode === 'edit') {
70 const n = j.files?.length ?? 0
71 parts.push(n === 0 ? 'no file changed' : `+${j.added ?? 0} −${j.removed ?? 0} in ${n} file${n === 1 ? '' : 's'}`)
72 if (j.check) parts.push(j.check.exit === 0 ? 'check passed' : `check failed (exit ${j.check.exit})`)
73 if (j.verdict) parts.push(`review: ${j.verdict}`)
74 } else {
75 parts.push(`${j.actions.length} step${j.actions.length === 1 ? '' : 's'} read`)
76 }
77 if (j.reach > 0) parts.push(`reach: ${j.reach} reference${j.reach === 1 ? '' : 's'}`)
78 return parts.join(' · ')
79}
80
81function actionRow({ Box, Text }: Elements, a: DelegateAction, now: number, frame: number, room: number, key: string): RenderElement {
82 const mark = a.state === 'running' ? TURN[frame % TURN.length] : a.state === 'ok' ? '✓' : '✕'
83 const tone = a.state === 'running' ? 'claude' : a.state === 'ok' ? 'success' : 'error'
84 const time = a.state === 'running' ? clock(now - a.startedAt) : a.ms !== undefined ? clock(a.ms) : ''
85 return (
86 <Box key={key} flexDirection="row" gap={1}>
87 <Text color={tone}>{mark}</Text>
88 <Text>{verb(a.tool).padEnd(5)}</Text>
89 <Text dimColor wrap="truncate-end">
90 {fit(a.label, Math.max(8, room - 16))}
91 </Text>
92 <Text dimColor>{time}</Text>
93 </Box>
94 )
95}
96
97const CODE_LINES = 6
98const SHOWN_ACTIONS = 6
99const TONE = { running: 'claude', background: 'claude', done: 'success', failed: 'warning' } as const
100
101/** Cells' time format: "0.0 s" under ten seconds, whole seconds after. */
102export function seconds(ms: number): string {
103 return ms < 10_000 ? `${(Math.max(0, ms) / 1000).toFixed(1)} s` : `${Math.round(ms / 1000)} s`
104}
105
106/** A hand-off drawn like a cell: the task dimmed, one row per worker action, a closing line that carries the state. */
107export function jobCard(el: Elements, j: DelegateJob, now: number, width: number, onOpen: () => void): RenderElement {
108 const { Box, Text, Button } = el
109 const frame = Math.floor(now / FRAME_MS)
110 const inner = Math.max(24, width - 6)
111 const running = j.state === 'running'
112 const state = running ? (j.background ? 'background' : 'running') : j.state === 'reported' ? 'done' : 'failed'
113 const sweep = scanner(frame)
114 const program = j.task.replace(/\s+$/, '')
115 const lines = program === '' ? [] : program.split('\n')
116 const shown = lines.slice(0, CODE_LINES)
117 const hidden = Math.max(0, j.actions.length - SHOWN_ACTIONS)
118 const last = j.actions.slice(hidden)
119 const line = outcome(j)
120 const summary = [
121 `hand-off ${j.n}`,
122 workerName(j),
123 clock((j.endedAt ?? now) - j.startedAt),
124 `${j.requests} req`,
125 usd(j.usd),
126 ].join(' · ')
127 const saved = j.aloneUsd !== undefined ? ` · ≈ ${usd(j.aloneUsd)} on your model` : ''
128
129 const rows = last.map((a, i) => {
130 const mark = a.state === 'running' ? TURN[frame % TURN.length] : a.state === 'ok' ? '✓' : '✕'
131 const markTone = a.state === 'running' ? 'claude' : a.state === 'ok' ? 'success' : 'error'
132 const ms = a.state === 'running' ? now - a.startedAt : (a.ms ?? 0)
133 return (
134 <Box key={`a${hidden + i}`} flexDirection="row" justifyContent="space-between">
135 <Box flexDirection="row" gap={1} flexShrink={1}>
136 <Text color={markTone}>{mark}</Text>
137 <Text color="subtle">{verb(a.tool).padEnd(5)}</Text>
138 <Text wrap="truncate-end">{fit(a.label, inner - 16)}</Text>
139 </Box>
140 <Text color="subtle">{seconds(ms)}</Text>
141 </Box>
142 )
143 })
144
145 return (
146 <Box flexDirection="column" borderStyle="round" borderColor={running ? 'claude' : 'subtle'} paddingX={1}>
147 {lines.length === 0 ? null : (
148 <Box flexDirection="column" paddingLeft={2}>
149 {shown.map((t, i) => (
150 <Text key={`t${i}`} color="subtle" wrap="truncate-end">
151 {t.length > inner ? `${t.slice(0, inner - 1)}…` : t || ' '}
152 </Text>
153 ))}
154 {lines.length > CODE_LINES ? <Text color="inactive">{`… ${lines.length - CODE_LINES} more lines`}</Text> : null}
155 </Box>
156 )}
157 {rows.length > 0 ? (
158 <Box flexDirection="column">
159 {hidden > 0 ? <Text color="inactive">{`… ${hidden} earlier steps`}</Text> : null}
160 {rows}
161 </Box>
162 ) : null}
163 <Box flexDirection="row" gap={1}>
164 <Text bold color={TONE[state]}>
165 {state}
166 </Text>
167 {running ? (
168 <Text>
169 <Text color="inactive">{sweep.before}</Text>
170 <Text color="claude">{sweep.head}</Text>
171 <Text color="inactive">{sweep.after}</Text>
172 </Text>
173 ) : null}
174 <Text color="subtle">{`· ${summary}${saved}${line !== undefined ? ` · ${line}` : ''}`}</Text>
175 {j.note ? <Text color="warning">{`· ${j.note}`}</Text> : null}
176 <Button key={`open-${j.toolUseId}`} plain dimColor label={running ? 'watch ▸' : 'view ▸'} onPress={onOpen} />
177 </Box>
178 </Box>
179 )
180}
181
182/** The pane: the watched hand-off with all its worker did and its report, the others below; the engine scrolls it. */
183export function pane(el: Elements, jobs: readonly DelegateJob[], watching: string | null, now: number, width: number, onPick: (id: string) => void): RenderElement {
184 const { Box, Text, Button } = el
185 const frame = Math.floor(now / FRAME_MS)
186 const room = Math.max(24, width)
187 if (jobs.length === 0) return <Text dimColor>No hand-offs in this session yet.</Text>
188 const open = jobs.find(j => j.toolUseId === watching) ?? (jobs.at(-1) as DelegateJob)
189 const others = jobs.filter(j => j !== open).reverse()
190 const line = outcome(open)
191 return (
192 <Box flexDirection="column">
193 <Text bold color={open.state === 'running' ? 'claude' : open.state === 'reported' ? 'success' : 'error'}>
194 {headline(open, now)}
195 </Text>
196 <Text dimColor>{open.task}</Text>
197 {line !== undefined && <Text>{line}</Text>}
198 <Text dimColor>worker {open.agentId}</Text>
199 <Text> </Text>
200 {open.actions.length === 0 && <Text dimColor>starting…</Text>}
201 {open.actions.map((a, i) => actionRow(el, a, now, frame, room, `p${i}`))}
202 {open.report !== undefined && (
203 <Box flexDirection="column" marginTop={1}>
204 <Text bold>Report</Text>
205 <Text>{open.report}</Text>
206 </Box>
207 )}
208 {others.length > 0 && (
209 <Box flexDirection="column" marginTop={1}>
210 <Text bold>Earlier hand-offs</Text>
211 {others.map(j => (
212 <Button
213 key={`pick-${j.toolUseId}`}
214 plain
215 dimColor
216 label={fit(`${j.state === 'running' ? TURN[frame % TURN.length] : j.state === 'reported' ? '✓' : '✕'} ${headline(j, now)}`, room)}
217 onPress={() => onPick(j.toolUseId)}
218 />
219 ))}
220 </Box>
221 )}
222 </Box>
223 )
224}
225types/index.d.ts 54 lines1/** One tool call a worker made, as the card and the pane list it. */
2export type DelegateAction = {
3 tool: string
4 label: string
5 state: 'running' | 'ok' | 'failed'
6 startedAt: number
7 ms?: number
8}
9
10/** One hand-off as it draws: live while its worker runs, then its outcome. */
11export type DelegateJob = {
12 n: number
13 toolUseId: string
14 agentId: string
15 mode: 'explore' | 'edit'
16 task: string
17 model: string
18 /** The effort the worker runs at, when one is set. */
19 effort?: string
20 reused: boolean
21 /** References the language server (or a text search) found for the focus. */
22 reach: number
23 startedAt: number
24 endedAt?: number
25 /** The hand-off returned at once and the worker runs on. */
26 background?: boolean
27 state: 'running' | 'reported' | 'failed'
28 requests: number
29 usd: number
30 /** The same steps on the main model, estimated when the report arrives. */
31 aloneUsd?: number
32 actions: DelegateAction[]
33 files?: string[]
34 added?: number
35 removed?: number
36 check?: { command: string; exit: number }
37 verdict?: string
38 /** The worker's report, as it reached the main model. */
39 report?: string
40 /** Why the card ended as it did, when the outcome alone does not say. */
41 note?: string
42}
43
44declare module 'claude-code' {
45 interface PluginState {
46 delegate: {
47 /** This session's hand-offs, oldest first. */
48 jobs: DelegateJob[]
49 /** The hand-off the pane shows open, by its tool_use_id. */
50 watching: string | null
51 }
52 }
53}
54