SLOPSHOPPER

delegate

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…

newpanerowsguardcommandprompt
v0.4.3MITupdated 2026-10-16HarzerHeribert/claude-modpack/delegate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · delegate
│ ┃ delegate ✕ › fix the failing auth test and add an audit log call │ ┃ No hand-offs in this session yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /delegate │ ⎿ delegate: No hand-offs logged yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · delegate
No hand-offs in this session yet.
README

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

Release License: MIT Claude Code 2.1.296 730 graded runs, all correct 8 mods Lab notes

Claude Code with fewer wasted steps, and a clear view of what it's doing.

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">

Why it saves

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.

What you get

Always know where your plan stands

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

Several changes in one step, and you can still undo them

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

A cheaper model builds what you approved

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

Feature work goes to the cheaper model by itself (opt-in)

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.

Straight answers about your code

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.

Fewer, leaner steps when Claude looks around

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.

You stay in control

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

Install

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.

Honest numbers

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 jobWithoutWith 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
  • Where it doesn't help: quick questions in a brand-new session. The pack's tools come along with every step, and a three-step lookup has little to save: 5% more one day, 4% less the next, about even.
  • Writing tests: the pack alone saves nothing there (−2%, 12 runs each way): every step waits on the last test run. route hands it to the worker instead (−36% to −46%, below).
  • Nothing got worse: all 224 runs behind this table produced a correct result, with the pack and without.
  • The 95% ranges: long session −39% to −27%; fresh −7% to −1%; bigger jobs fresh −12% to −3%; quick questions −1% to +6%. The long session is two runs per job each day, and the days differ: −26% on one, −40% on the other, mostly from the feature (plain needed 12 requests one day, 16 the next).
  • The same on other code: on a TypeScript library (chrono) the pack cost 7.6% less (−12% to −3%) and took fewer steps on all eight jobs. On 30 real bug reports from SWE-bench Verified it fixed 30 against 29, with 23% fewer steps; the cost, −12%, is not certain at one attempt per bug (−33% to +12%).
  • A plan handed to a cheaper model: −22% on a feature, −26% on tests, against Opus doing it alone with the pack (16 runs, all correct).
  • With route turned on: a further −14% on click's eight jobs (route 0.1). With 0.6.0's verified hand-offs, against the pack in the same batch on TypeScript: a feature −51% and 42% faster, writing tests −36%; Claude itself takes 2 steps instead of 6 to 11 (24 runs, all correct).
  • Corrected 16 Oct: worker costs were undercounted. A worker's transcript records its output as each response began, and our bench counted it from there, so every number with a hand-off in it came out too good. Corrected from Claude Code's own totals: a planned feature −22% (was −27%), tests −26% (was −33%), route on click −14% (was −17%), the TypeScript feature −51% (was −57%). Every saving is still a saving, a smaller one. The table above has no hand-offs and did not change. Each claim, old and new: CORRECTION-1016.md.
  • With seek (new in 0.6.0, measured against the pack rather than plain Claude Code, so not in the table above): −12% fresh on click, −16% on TypeScript, −10% to −16% in long sessions (seek, below).
  • Two days, two versions: the 13 October runs used the pack as of 0.3.0 and Claude Code 2.1.296, the 15 October runs 0.4.0 and 2.1.299. What changed in the pack between them (on/off switches, setup, messages shown when a worker is refused) never reached the model in these runs: its first request was 26.7k tokens plain and 30.6k with the pack on both days. The second day's plain runs were done twice, because the first try had the pack installed by mistake: lab notes 8 tells how we caught it.

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

Questions

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.

The mods in depth

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
  • Its own segments: the model and its effort, the context against the auto-compact point (a highlight sweeps along it while Claude works), and how much of the plan's 5-hour and 7-day windows is used, with when each resets: quiet under half, plain past it, amber from 80%.
  • Other mods' segments: a mod calls $.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.
  • Narrow terminals drop the least important first: the model, then the windows' resets, the windows, the gauge, and another mod's countdown before its own words.
  • Published pages: a pill on the left lists the pages this session published with the Artifact tool.
  • Phone and IDE: where no line is drawn, the same line is the session's status text, in a session the phone or IDE hosts. A terminal session watched over Remote Control (/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.
OptionDefault
enabledtruefalse turns the mod off; /mods does it for all of the pack.
placementlinetail: appended dim to Claude Code's hint. band: above the prompt. off.
limitsalwayshalf: a plan window only once it is half used. off: never.
animatetrueThe 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:

OptionCost
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:

  1. Sends keep-alives while they cost less than compacting would, counting lost detail as compactPenaltyUsd. It stops after maxIdleHours.
  2. Then compacts while the cache is still warm.
  3. Leaves small sessions alone: below 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.

OptionDefault
enabledtruefalse turns the mod off; /mods does it for all of the pack.
strategysmartcompact: compact at the first expiry. keepalive: never compact. off: status line only.
cacheTtlauto1h or 5m to override detection.
minSavingsUsd0.25Below this re-cache cost, the cache is left to expire.
compactPenaltyUsd0.5Higher keeps the cache warm longer before compacting.
minCompactTokens100000Never compacts a smaller context.
maxIdleHours6Stops keep-alives after this long idle.
compactInstructionsemptyPassed 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.

  1. Where it reaches. For each focus entry the mod asks the language server for references and callers (with lsp-plus, the docs that mention it come along) and tells the worker.
  2. The worker. A subagent, read-only for 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.
  3. The evidence. The diff against a git snapshot taken at the start, however the worker edited; the check command, run by the mod itself; references outside the changed files; what the work cost against the same steps on the main model.
  4. The review. The main model judges the diff and the check, not the report.
  5. The log. Each hand-off goes to ~/.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

Source 4 files
hooks/register.tsx 946 lines
1import { 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}
946
hooks/plan.ts 466 lines
1// 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}
466
hooks/view.tsx 225 lines
1// 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}
225
types/index.d.ts 54 lines
1/** 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