Lets Claude make several edits and their check, or a language-server lookup or rename, in one step through the real tools; guidance on chaining, which a…

<img alt="claude-modpack: eight Claude Code mods, each measured on real tasks before it shipped, built to work together. statusline, smart-compact, delegate, lsp-plus, cells, eta, seek, route." src="assets/banner-light.svg" width="880">
<h3>Install in Claude Code</h3>
/plugin install modpack --marketplace HarzerHeribert/claude-modpack
Answer y, then run /modpack:setup to set everything up step by step.<br> Prefer doing it by hand? SETUP.md has every step.
Eight free add-ons that work on their own. One command installs them, and there is nothing new to learn.<br> Every number on this page was measured, including where it doesn't help.
Install · What you get · Honest numbers · The site: ideas and evidence · Lab notes · Questions
<img alt="Animated: the cost per job with and without the pack, as bars that grow: in a long session $0.44 against $0.29 (−33%; measured twice, −26% one day and −40% the next), bigger jobs in a fresh session $0.55 against $0.51 (−8%), quick questions in a fresh session $0.23 against $0.24 (+2%, about even: +5% one day, −4% the next); then four numbers: −33% cost per job in a long session, −21% steps for the same work, −22% cost of a feature a cheaper model builds from your plan, 224 of 224 results correct." src="assets/numbers.gif" width="880">
Every time Claude takes a step (reads a file, makes an edit, runs the tests), it reads your whole conversation again. That re-reading is most of what a session costs, and it grows the longer you work. So the cheapest step is the one Claude never has to take.
The pack takes out steps that need no thinking. Several edits and their test run as one step. A search for a name in your code comes back with the exact answer. And when you approve a plan, a cheaper model does the building while Claude checks the result. The work comes out the same; Claude just gets there in fewer round trips.
<img alt="A real Claude Code session: Claude starts the stress tests in the background; above the prompt a row shows them with a bar, a percentage and a finish time, climbing from 8% to 100%; under the prompt the status line shows the model, the context, the plan's 5-hour and weekly use and a green ready dot." src="assets/demo-band.gif" width="880">
One line under the prompt: the model, how full the conversation is, and how much of your plan's 5-hour and weekly limits you have used, with when each one resets. Long jobs get a progress bar and a finish time above the prompt. And when you step away, your session is kept ready, or tidied up before it goes cold, whichever costs less; the line tells you which.
These are the statusline, eta and smart-compact mods.
<img alt="A real Claude Code session: asked to add two docstrings and run the whole test suite, Claude runs one cell; an orange card shows the read, the two edits, the diff and the test run live, and the tests pass: 1990 passed." src="assets/demo-cells.gif" width="880">
Claude makes several edits and runs the tests as one step, and you watch each part happen in a live card. Every edit still goes through Claude Code's own edit tool: you see each change, and /rewind undoes it.
That's the cells mod.
<img alt="A real Claude Code session: in plan mode Claude plans a new click.Duration parameter type; the plan is approved; Claude hands it to a Sonnet worker, whose orange card shows its steps live with a row above the prompt (hand-off 1, sonnet medium, requests and cost); then Claude checks the diff and reports: the new type works and the full suite passes." src="assets/demo-delegate.gif" width="880">
When you approve a plan, Claude hands the building to Sonnet, a cheaper model, and checks its work before reporting back. In our tests that made a feature 22% cheaper and new tests 26% cheaper, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 27% and 33%). You can watch the helper work in its card, and /delegate shows every step it took.
That's the delegate mod.
A small, fast classifier reads each prompt you send. Only when it is feature or test work is Claude told to hand it straight to delegate's worker: the worker reads the code and builds, delegate checks the result itself, and Claude reports after two steps of its own. On a TypeScript feature that made the job 51% cheaper and 42% faster than the pack alone, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 57%); everything else stays with Claude. The classifier is an outside service (TypeSafe's Jev or ScaleDown), so this is off until you turn it on and give it a key: from then on, the start of each prompt goes to it, in the US, to be classified.
That's the route mod.
When Claude searches your code for a name, it gets the exact list of places that use it, from the same engine your editor uses, instead of reading through search results. Renames and "who calls this?" questions take fewer steps. New in 0.5.0: Go, Kotlin and Ruby code, shell scripts and Terraform. Run /lsp-plus:setup once in your project to turn it on.
That's the lsp-plus mod.
Before Claude changes anything, it looks around your code, and everything it reads stays in the conversation, read again with every later step. Beside the first prompt you send in a session, and again as the session grows, the pack passes on your preference to look around in few, lean steps: the searches it can already name in one go, the lines that matter rather than whole files, nothing read twice. Against the pack without it, that made jobs 12% cheaper in a fresh session, 16% on a TypeScript library and 10 to 16% in long sessions, at the same quality. It runs on your machine and sends nothing anywhere.
That's the seek mod.
<img alt="A real Claude Code session: /mods lists the pack's mods (recorded with the six of 0.4.0, before route and seek) and opens a pane of cards; the status line is turned off and disappears from under the prompt, then turned on again." src="assets/demo-mods.gif" width="880">
/mods lists the eight and turns any of them on or off, right away, with a click or by typing, such as /mods statusline off. When something can't fully work on your computer, the pack tells you once, with the command that fixes it. When another plugin's permission or notification hook hangs (Warp's does outside the Warp terminal), the pack stops waiting for it after 10 seconds, so Claude Code can't freeze, and tells you which one it was. It keeps itself up to date (or only lets you know, if you prefer).
That's the modpack plugin, which installs the other eight.
In Claude Code, type:
/plugin install modpack --marketplace HarzerHeribert/claude-modpack
Answer y to add the marketplace, then pick where to install it. That installs all eight. Then, in the project you work in:
/modpack:setup
It checks your machine, asks once what you want on (route stays off unless you say yes), sets each mod up in order, including the language servers for the languages your project uses, and checks that each one works. Done. /mods shows what's on.
By hand instead: SETUP.md has every step.
Each mod installs on its own the same way, for example /plugin install smart-compact --marketplace HarzerHeribert/claude-modpack. lsp-plus, delegate and cells build on Claude Code's LSP tool, which needs a language server for each language: Language servers lists them.
We measured the pack on Claude Opus 5.5 doing seven real jobs in an open-source project (click), from a one-line lookup to building a feature, on two days, 13 and 15 October. Every result was checked by hidden tests. The same jobs ran with the pack and without it, and we compared what they cost at API prices.
| Cost per job | Without | With the pack | |
|---|---|---|---|
| In a long session (Claude has already read the whole project) | $0.44 | $0.29 | −33% |
| Bigger jobs in a long session (a feature, tests, a rename) | $0.91 | $0.57 | −37% |
| In a fresh session | $0.37 | $0.35 | −4% |
| Bigger jobs in a fresh session | $0.55 | $0.51 | −8%, and 15% faster |
| Quick questions in a fresh session | $0.23 | $0.24 | +2%, about even |
<img alt="Cost per task, plain Claude Code against the modpack, in a fresh session and in a session already holding 216k tokens." src="assets/cost-light.svg" width="880">
<img alt="Model requests per task, plain Claude Code against the modpack: 3.2 against 5.6 in a session already holding 216k tokens." src="assets/requests-light.svg" width="880">
<img alt="Cost per task, Opus alone against a plan handed to a worker: feature work 22% less, tests 26% less." src="assets/delegate-light.svg" width="880">
How we measured, every run's record, and the scripts to run it all again are in benchmarks/, starting with SUITE.md. What we tried that did not make it, a 10,000-star code graph among it, is in the lab notes (as Markdown). Not measured: what smart-compact saves you while you're away (worked out from prices, not tested) and Windows.
Will Claude's work get worse? No. In every run we graded, 730 in all, the result was correct with the pack and without, and every one was graded again for this release. The pack changes how many steps Claude takes to get there, and in our tests the results came out the same.
Is it safe? It's open source (MIT), so every line is here to read. Code that Claude runs inside a cell runs in a sandbox with no network access. Edits go through Claude Code's own edit tool, so your usual permission prompts and /rewind still apply. The pack sends nothing anywhere except a once-a-day check on GitHub for a new release, which you can turn off, and, only if you turn route on, the start of each prompt to the classifier you chose (TypeSafe or ScaleDown).
Does the pack add anything to my prompts? Your prompt itself is never changed. Beside it, Claude can get a short note in your name: seek's setting to look around in few, lean steps (with the first prompt of a session, and again after a compaction), and, only if you turn route on, the order to hand feature or test work to the worker; delegate gives the same order when you approve a plan of 20 lines or more. A subagent Claude starts gets cells' and seek's settings at the end of its task, since your prompts never reach it. /mods seek off and /mods route off stop theirs; subagents: false in cells or seek stops theirs for subagents.
I'm on a Pro or Max plan. Does this help me? You don't pay per job, but every step counts against your 5-hour and weekly limits. We measured cost at API prices, not plan usage, so we can't promise a number. Fewer, lighter steps should leave more of your limits, and the status line shows exactly where you stand.
Does it work in the desktop app? Yes, cards, progress and the status line included. One limit: in auto mode the desktop app doesn't let an add-on start a helper model, so plans aren't handed off there (turn route off there too); in manual mode, or in the terminal, they are. Windows hasn't been tested yet.
How do I turn it off? /mods switches each part off or on, right away. /plugin removes them.
Were any of these numbers wrong? Yes, the hand-off ones. On 16 October we found that our bench had counted a worker's output only as each of its responses began, so every saving that involved a worker came out too large: a planned feature was −27% and is −22%. They are corrected here, the old and new number side by side in CORRECTION-1016.md. The pack-against-plain numbers had no workers and stand.
What does it cost? Nothing. It's free and open source.
One line under the prompt, drawn in Claude Code's own hint row so the mode indicator and the status share it:
⏵⏵ auto mode on Opus 5.5 xhigh 412k ━━━━━━────── 5h 34% ↺2h14 7d 12% ↺3d ● ready · cold in 44m
$.statusline.set({ id, priority, parts, short?, side? }) and lists statusline among its dependencies. A part is text, a countdown ({ until, prefix?, warnMs? }, counted by the line) or a spinner ({ spin: true }), so a mod sends a segment when it changes, not every second. $.statusline.remove({ id }) takes it off./rc) from the desktop app, the web or the phone gets no mod drawing and no status text as of Claude Code 2.1.296.| Option | Default | |
|---|---|---|
enabled | true | false turns the mod off; /mods does it for all of the pack. |
placement | line | tail: appended dim to Claude Code's hint. band: above the prompt. off. |
limits | always | half: a plan window only once it is half used. off: never. |
animate | true | The highlight along the gauge. |
An idle session's prompt cache expires after its TTL: 1 hour on a subscription, 5 minutes in overage or on an API key. The next prompt then writes the whole context to the cache again, at 1.25x (5 min) or 2x (1 h) the input price. For a 400k-token Opus 5.5 session on the 1-hour cache:
| Option | Cost |
|---|---|
| Let it expire, re-cache on return | $3.20 |
| One keep-alive (re-read the cached prefix; a read resets the TTL) | $0.09 |
| Compact before it expires | ~$0.87, plus detail lost to the summary |
A few minutes before the cache expires (2.5 min on the 1-hour TTL, 45 s on the 5-minute one), the smart strategy:
compactPenaltyUsd. It stops after maxIdleHours.minSavingsUsd of re-cache cost, or below minCompactTokens of context, it never compacts.It never compacts while the prompt holds a draft or agents are running; it sends a keep-alive to hold the cache instead. If the machine sleeps past the deadline, it marks the cache expired and does nothing. A failed action is retried twice, 20 s apart. The TTL is detected from the plan and corrected from the cache hits and misses it observes.
On statusline's line it says whether the session is ready to prompt or has gone cold: ● ready, · cold in m:ss (amber in the last five minutes), · compacts in … when an idle compaction is near, ○ cold, or what it is doing (compacting, keeping warm, paused). /cache shows the live figures and plan; /cache pause, /cache resume and /cache keepalive control it.
| Option | Default | |
|---|---|---|
enabled | true | false turns the mod off; /mods does it for all of the pack. |
strategy | smart | compact: compact at the first expiry. keepalive: never compact. off: status line only. |
cacheTtl | auto | 1h or 5m to override detection. |
minSavingsUsd | 0.25 | Below this re-cache cost, the cache is left to expire. |
compactPenaltyUsd | 0.5 | Higher keeps the cache warm longer before compacting. |
minCompactTokens | 100000 | Never compacts a smaller context. |
maxIdleHours | 6 | Stops keep-alives after this long idle. |
compactInstructions | empty | Passed to idle compactions, like /compact <text>. |
Prices are list prices, so on a subscription they are API-equivalent estimates, not a bill.
Every request of the main model re-reads its whole context: at 600k tokens on Opus 5.5 that is about $0.12 a step before any work is done. A worker reads only its own small context. Claude calls hand_off with a task that stands alone, its kind (read: changes no file; mechanical: the task spells out every change; build: something is left to work out), the symbols or file:line places it touches (focus), and for edits a check command. The kind picks the worker, so Claude has no model to weigh: Haiku at low effort for read and mechanical work, Sonnet at medium for build work.
read, with Edit and Write for mechanical and build. The call waits for it inside the tool call (one request of the main model) and returns the report with the evidence; with background it returns at once and the report arrives as a message. A follow-up within 50 minutes goes to the same worker, its reading still in its context.~/.claude/mods/delegate/log.jsonl; /delegate sums it up and opens a side pane. While a worker runs, its card is drawn like a cell (the task, a row per step, the state and cost), and with eta loaded it is a row above the prompt too.Measured on 120 Opus runs, all correct: a feature or tests cost 23–35% less handed off; lookups, traces, explanations and small edits cost more. Opus does not hand off unless told to, so ask for it as an instruction: "hand the tests to a worker". An order the mod gives works as well as yours (8 of 8 runs, 22–26% less), so when you approve a plan of 20 lines or more, delegate gives that order: plan in plan mode, approve, and a Sonnet worker builds it. Corrected 16 Oct: worker costs were undercounted; these were 27–37% and 27–33%.
A verified hand-off (0.3.0). The worker ends its report with a VERIFY block: the command that runs only its new tests, and what it left open. d
hooks/register.tsx 671 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
3
4import { parseLocations } from '../runner/lsp.mjs'
5import { applyFixes } from '../runner/repair.mjs'
6import type { CellsLive } from '../types'
7import { MAX_IN_FLIGHT, absolute, exitCode, labelOf, lines, report, tidy } from './bridge'
8import type { CallRecord, Done, Need, Reply } from './bridge'
9import { NUDGE, NUDGE_CELLS, TOOL_DESCRIPTION, TOOL_NAME } from './guide'
10import { PROBE_SCRIPT, bwrapArgv, choose, offLine, offNote, parseProbe, runnerArgv, weakness } from './isolation'
11import type { Bwrap, Isolation } from './isolation'
12import { Budget, costOf, kindOf, missed, noteFor } from './nudge'
13import type { Step } from './nudge'
14import { SUBAGENT_CELLS, SUBAGENT_CHAINING, reaches, withSetting } from './subagents'
15import { cellCard, parseReport, resultBody, seconds, stateOf } from './view'
16import type { Card } from './view'
17
18type Engine = EngineInterface
19type Mode = 'cells' | 'nudge' | 'off'
20
21const LIVE = atom({ plugin: 'cells', key: 'live' } as const, {} as Record<string, CellsLive>)
22
23let mode: Mode = 'cells'
24let visuals = true
25let nudging = true
26// Whether a subagent's task carries the chaining guidance (its own system prompt never has it).
27let subagents = true
28// The cells running now, and those the model is still writing, for the redraw that animates them.
29const running = new Set<string>()
30const writing = new Set<string>()
31let frames: Timer | undefined
32const FRAME_MS = 125
33let made = 0
34let nodePath = 'node'
35let platform = ''
36// How the runner is shut in on this machine; a sandbox of undefined means cells cannot run here.
37let isolation: Isolation | undefined
38let dir = ''
39// The runner's real folder: node --permission resolves symlinks, so a mod loaded
40// through a link must name the folder the link points to.
41let runnerDir = ''
42// The handles of the cells run since the person's last prompt, as JSON.
43let handles = '{}'
44// The last program that did not parse, as sent and, where one was offered, repaired: what fix and repair apply to.
45let pending: { code: string; repaired?: string } | undefined
46// Kinds of source file whose language server has had time to index.
47const warm = new Set<string>()
48// The main loop's last few model requests, and the notes' allowance.
49let steps: Step[] = []
50let stepCount = 0
51let budget = new Budget()
52
53function readOptions(o: PluginOptions) {
54 mode = o.mode === 'nudge' || o.mode === 'off' ? o.mode : 'cells'
55 visuals = o.visuals !== false
56 nudging = o.nudges !== false
57 subagents = o.subagents !== false
58}
59
60/** Redraws at 8 fps while a cell is written or runs; at rest nothing moves. */
61function animate($: Engine) {
62 const moving = running.size + writing.size > 0
63 if (visuals && moving && frames === undefined) {
64 frames = $.clock.every(FRAME_MS, () => $.ui.invalidate('ui.render'))
65 } else if (!moving && frames !== undefined) {
66 frames.cancel()
67 frames = undefined
68 $.ui.invalidate('ui.render')
69 }
70}
71
72function outputText(output: unknown): string | undefined {
73 if (typeof output === 'string') return output
74 if (Array.isArray(output)) {
75 const texts = output.map(b => (b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : '')).filter(Boolean)
76 return texts.length > 0 ? texts.join('\n') : undefined
77 }
78 return undefined
79}
80
81/** True where the run_cell tool exists: the mode asks for it and something closes the network to the runner. */
82function cellsOn(): boolean {
83 return mode === 'cells' && isolation?.sandbox !== undefined
84}
85
86/** Why the mode asks for the tool but this machine cannot have it, and what would let it; undefined where it can. */
87function offReason(): { why: string; fix: string } | undefined {
88 if (mode !== 'cells' || cellsOn()) return undefined
89 return isolation?.sandbox === undefined && isolation ? { why: isolation.why, fix: isolation.fix } : { why: 'its runner could not be set up', fix: 'run /cells:setup' }
90}
91
92async function setup($: Engine) {
93 // The node that will run the cells says where it is, which platform, which version, and its temp folder.
94 const probed = await $.process.run(['node', '-p', PROBE_SCRIPT]).catch(() => undefined)
95 const probe = probed?.exitCode === 0 ? parseProbe(probed.stdout.trim()) : undefined
96 if (!probe) {
97 isolation = { sandbox: undefined, why: 'no node on the PATH Claude Code runs with', fix: 'install Node 25 or later' }
98 return
99 }
100 // Everything is worked out first and kept at the end: a setup that fails halfway (seen after a reload) leaves
101 // nothing half set, and the next cell runs it again.
102 const folder = `${probe.tmpdir.replace(/[\\/]$/, '')}/claude-cells/${await $.session.id()}`
103 await $.process.run([probe.nodePath, '-e', 'require("fs").mkdirSync(process.argv[1], { recursive: true })', folder])
104 const linked = `${$.plugin.root}/runner`
105 const stat = await $.fs.stat(linked, { resolve: true }).catch(() => undefined)
106 const runner = stat?.realPath ?? linked
107 nodePath = probe.nodePath
108 platform = probe.platform
109 dir = folder
110 const chosen = choose(probe, probe.platform === 'linux' ? await tryBwrap($, runner) : { works: false })
111 runnerDir = runner
112 isolation = chosen
113}
114
115/** Whether bubblewrap starts node in the sandbox the runner would get: installed is not enough where user namespaces are off. */
116async function tryBwrap($: Engine, runner: string): Promise<Bwrap> {
117 const r = await $.process
118 .run(bwrapArgv(nodePath, runner, dir, [nodePath, '-e', '0']), { timeoutMs: 8000 })
119 .catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
120 if (r.exitCode === 0) return { works: true }
121 const said = r.stderr.split('\n').find(l => l.trim() !== '')?.replace(/^bwrap:\s*/, '').trim() ?? ''
122 return { works: false, why: said === '' || /ENOENT|not found|No such file/i.test(said) ? undefined : said.slice(0, 160) }
123}
124
125// The few file chores of the host's side, with POSIX tools where they are and node where not (Windows).
126const CLEAN = 'const fs = require("fs"); const [d, id] = process.argv.slice(1); for (const f of fs.readdirSync(d)) if (f.startsWith(id + ".")) fs.rmSync(d + "/" + f)'
127const WAIT = 'const fs = require("fs"); let i = 0; const t = setInterval(() => { if (fs.existsSync(process.argv[1]) || ++i >= 10) clearInterval(t) }, 200)'
128
129function cleanArgv(id: string): string[] {
130 return platform === 'win32' ? [nodePath, '-e', CLEAN, dir, id] : ['/bin/sh', '-c', 'rm -f "$1"/"$2".*', 'sh', dir, id]
131}
132
133function waitArgv(marker: string): string[] {
134 return platform === 'win32'
135 ? [nodePath, '-e', WAIT, marker]
136 : ['/bin/sh', '-c', 'i=0; while [ ! -e "$1" ] && [ $i -lt 10 ]; do sleep 0.2; i=$((i+1)); done', 'sh', marker]
137}
138
139function pauseArgv(seconds: number): string[] {
140 return platform === 'win32' ? [nodePath, '-e', `setTimeout(() => {}, ${seconds * 1000})`] : ['sleep', String(seconds)]
141}
142
143type BashRecord = { stdout?: string; stderr?: string; persistedOutputPath?: string; interrupted?: boolean }
144type ReadRecord = { type?: string; file?: { content?: string; startLine?: number; numLines?: number; totalLines?: number } }
145
146/** Keeps what the plugins along a call's way added for the model (lsp-plus's notes), once each, for the cell's report. */
147function keep(sink: string[], r: { context?: readonly string[] }) {
148 for (const note of r.context ?? []) if (!sink.includes(note)) sink.push(note)
149}
150
151/** How long the first question about a kind of file may wait for a server that is still indexing. */
152const COLD_MS = 10_000
153
154/** Whole-word mentions of `name` in the project's tracked files of one kind: what a warm server could list at most. */
155async function mentionsOf($: Engine, cwd: string, name: string, kind: string): Promise<number> {
156 if (!/^[A-Za-z_$][\w$]*$/.test(name) || !/^\.\w+$/.test(kind)) return 2
157 const r = await $.process.run(['git', 'grep', '-c', '-I', '-w', '-F', '-e', name, '--', `*${kind}`], { cwd, timeoutMs: 8000 }).catch(() => undefined)
158 if (r === undefined || r.exitCode !== 0) return 2
159 return r.stdout.split('\n').reduce((sum, row) => sum + (Number(/:(\d+)$/.exec(row)?.[1]) || 0), 0)
160}
161
162async function serveLsp($: Engine, i: Record<string, unknown>, sink: string[]): Promise<Reply> {
163 const cwd = await $.session.cwd()
164 const operation = String(i.operation ?? '') as 'findReferences'
165 const filePath = absolute(cwd, i.filePath)
166 const kind = /\.[^./]+$/.exec(filePath)?.[0] ?? ''
167 const ask = async (): Promise<Reply> => {
168 const r = await $.tool.call({ tool: 'LSP', operation, filePath, line: Number(i.line), character: Number(i.character) })
169 keep(sink, r)
170 if (r.deny !== undefined) return { ok: false, denied: r.deny }
171 if (r.isError) return { ok: false, text: r.text ?? 'the language server failed' }
172 const text = r.text ?? ''
173 if (/^(Error performing|No LSP server)/.test(text)) return { ok: false, text }
174 return { ok: true, text, places: parseLocations(text).map(p => ({ ...p, file: tidy(cwd, p.file) })), context: [...(r.context ?? [])] }
175 }
176 let best = await ask()
177 if (!best.ok || warm.has(kind)) return best
178 // A server still indexing answers with the file it has open, or nothing: ask again
179 // while it lists fewer places than the text could hold, until it is warm.
180 const started = await $.clock.now()
181 const enough =
182 operation === 'findReferences' ? await mentionsOf($, cwd, String(i.name ?? ''), kind) : operation === 'goToDefinition' ? 1 : 0
183 while ((best.places?.length ?? 0) < enough && (await $.clock.now()) - started < COLD_MS) {
184 await $.process.run(pauseArgv(1.5))
185 const again = await ask()
186 if (!again.ok) break
187 if ((again.places?.length ?? 0) >= (best.places?.length ?? 0)) best = again
188 }
189 warm.add(kind)
190 return best
191}
192
193/** Runs one call a cell asked for, as a real Claude Code tool call. */
194async function serve($: Engine, need: Need, cwd: string, sink: string[]): Promise<Reply> {
195 const i = need.input
196 try {
197 if (need.tool === 'Bash') {
198 const r = await $.tool.call({
199 tool: 'Bash',
200 command: String(i.command ?? ''),
201 ...(typeof i.timeout === 'number' ? { timeout: i.timeout } : {}),
202 ...(i.background === true ? { run_in_background: true } : {}),
203 })
204 keep(sink, r)
205 if (r.deny !== undefined) return { ok: false, denied: r.deny }
206 // A background shell is Claude Code's own task: it notifies the model when it ends.
207 const task = (r.result as { backgroundTaskId?: unknown } | undefined)?.backgroundTaskId
208 if (i.background === true && typeof task === 'string' && !r.isError) {
209 const file = /written to:?\s+(\S+)/.exec(r.text ?? '')?.[1]?.replace(/\.$/, '')
210 return { ok: true, out: r.text ?? '', err: '', code: 0, task, file }
211 }
212 if (r.isError) {
213 const text = r.text ?? ''
214 return { ok: false, out: text.replace(/^Exit code \d+\n?/, ''), err: '', code: exitCode(text), text }
215 }
216 const rec = (r.result ?? {}) as BashRecord
217 // Claude Code shortens long output for the model; the cell gets all of it.
218 const full = rec.persistedOutputPath ? await $.fs.read(rec.persistedOutputPath).catch(() => undefined) : undefined
219 const out = typeof full === 'string' ? full : (rec.stdout ?? '')
220 return { ok: !rec.interrupted, out, err: rec.stderr ?? '', code: rec.interrupted ? 130 : 0 }
221 }
222
223 if (need.tool === 'Read') {
224 const path = absolute(cwd, i.file_path)
225 const r = await $.tool.call({
226 tool: 'Read',
227 file_path: path,
228 ...(typeof i.offset === 'number' ? { offset: i.offset } : {}),
229 ...(typeof i.limit === 'number' ? { limit: i.limit } : {}),
230 })
231 keep(sink, r)
232 if (r.deny !== undefined) return { ok: false, denied: r.deny }
233 if (r.isError) return { ok: false, text: r.text ?? 'read failed' }
234 const rec = (r.result ?? {}) as ReadRecord
235 if (rec.type === 'file_unchanged') {
236 // Read allowed it and says it is as last read: the cell still needs the text.
237 const all = (await $.fs.read(path)).split('\n')
238 const from = typeof i.offset === 'number' ? i.offset : 1
239 const page = all.slice(from - 1, typeof i.limit === 'number' ? from - 1 + i.limit : undefined)
240 return { ok: true, content: page.join('\n'), start: from, num: page.length, total: all.length }
241 }
242 const f = rec.file ?? {}
243 return { ok: true, content: f.content ?? r.text ?? '', start: f.startLine, num: f.numLines, total: f.totalLines }
244 }
245
246 if (need.tool === 'Edit') {
247 const r = await $.tool.call({
248 tool: 'Edit',
249 file_path: absolute(cwd, i.file_path),
250 old_string: String(i.old_string ?? ''),
251 new_string: String(i.new_string ?? ''),
252 replace_all: i.replace_all === true,
253 })
254 keep(sink, r)
255 if (r.deny !== undefined) return { ok: false, denied: r.deny }
256 if (r.isError) return { ok: false, text: r.text ?? 'edit failed' }
257 return { ok: true }
258 }
259
260 if (need.tool === 'Write') {
261 const r = await $.tool.call({ tool: 'Write', file_path: absolute(cwd, i.file_path), content: String(i.content ?? '') })
262 keep(sink, r)
263 if (r.deny !== undefined) return { ok: false, denied: r.deny }
264 if (r.isError) return { ok: false, text: r.text ?? 'write failed' }
265 return { ok: true }
266 }
267
268 if (need.tool === 'LSP') return await serveLsp($, i, sink)
269
270 return { ok: false, text: `no such cell function: ${need.tool}` }
271 } catch (err) {
272 return { ok: false, text: err instanceof Error ? err.message : String(err) }
273 }
274}
275
276/** Serves one need, records it for the report and the row, and replies. */
277async function answer(
278 $: Engine,
279 need: Need,
280 cwd: string,
281 id: string,
282 calls: CallRecord[],
283 useId: string | undefined,
284 notes: string[],
285 isStopped: () => boolean = () => false,
286) {
287 const startedAt = await $.clock.now()
288 const index = calls.length
289 const label = labelOf(need)
290 calls.push({ tool: need.tool, label, ok: false, refused: false, ms: 0 })
291 if (useId) {
292 await update($, LIVE, l => {
293 const cell = l[useId]
294 if (!cell) return l
295 return { ...l, [useId]: { ...cell, calls: [...cell.calls, { tool: need.tool, label, state: 'running' as const, startedAt }] } }
296 })
297 }
298
299 // A cell the person stopped makes no further call; the one already running ended as it would.
300 const reply: Reply = isStopped() ? { ok: false, denied: 'the person stopped this cell' } : await serve($, need, cwd, notes)
301 await $.fs.write(`${dir}/${id}.${need.n}.json`, JSON.stringify(reply))
302
303 const ms = (await $.clock.now()) - startedAt
304 const refused = reply.denied !== undefined
305 const note = refused ? 'refused' : need.tool === 'Bash' && !reply.ok ? `exit ${reply.code ?? 1}` : undefined
306 calls[index] = { tool: need.tool, label, ok: reply.ok, refused, ms, note }
307 if (useId) {
308 await update($, LIVE, l => {
309 const cell = l[useId]
310 if (!cell) return l
311 const rows = cell.calls.map((c, k) =>
312 k === index ? { ...c, state: refused ? ('refused' as const) : reply.ok ? ('ok' as const) : ('failed' as const), ms, note } : c,
313 )
314 return { ...l, [useId]: { ...cell, calls: rows } }
315 })
316 }
317}
318
319/** Runs one cell in the sandboxed runner and returns what the model reads. */
320async function runCell($: Engine, code: string, useId: string | undefined, job?: Job): Promise<string> {
321 // A setup that stopped halfway set no sandbox: run it again.
322 if (!dir || isolation === undefined) await setup($)
323 const cwd = await $.session.cwd()
324 const startedAt = await $.clock.now()
325 const id = `${++made}-${startedAt}`
326 const runner = runnerDir || `${$.plugin.root}/runner`
327 const calls: CallRecord[] = []
328 const notes: string[] = []
329 if (useId) await update($, LIVE, l => ({ ...l, [useId]: { startedAt, calls: [], code, background: job?.background, stopped: job?.stopped } }))
330 running.add(id)
331 animate($)
332
333 const argv = isolation ? runnerArgv(isolation, nodePath, runner, dir) : undefined
334 if (!argv || !isolation) {
335 running.delete(id)
336 animate($)
337 const off = isolation && isolation.sandbox === undefined ? isolation : undefined
338 return `cell · not run: ${off ? `${off.why}. To turn it on, ${off.fix}, or the person runs /cells:setup.` : 'the runner has no sandbox here.'}`
339 }
340 const stream = $.process.spawn({ argv, input: JSON.stringify({ code, handles, dir, id }) })
341
342 let buffer = ''
343 let stderr = ''
344 let done: Done | undefined
345 const inFlight = new Set<Promise<void>>()
346
347 for await (const chunk of stream) {
348 if (chunk.stream === 'stderr') {
349 stderr += chunk.text
350 continue
351 }
352 const split = lines(buffer + chunk.text)
353 buffer = split.rest
354 for (const message of split.messages) {
355 const m = message as { need?: Need; done?: Done }
356 if (m.done) done = m.done
357 if (m.need) {
358 while (inFlight.size >= MAX_IN_FLIGHT) await Promise.race(inFlight)
359 const p: Promise<void> = answer($, m.need, cwd, id, calls, useId, notes, () => job?.stopped === true).finally(() => inFlight.delete(p))
360 inFlight.add(p)
361 }
362 }
363 }
364 await Promise.all(inFlight)
365 await $.process.run(cleanArgv(id)).catch(() => undefined)
366
367 if (done) handles = done.handles
368 // A program that did not parse, or threw before its first call, ran nothing: a fix can redo it safely.
369 pending = done?.unparsed || (done?.error !== undefined && calls.length === 0) ? { code, repaired: done?.repaired } : undefined
370 running.delete(id)
371 animate($)
372 const ms = (await $.clock.now()) - startedAt
373 if (useId) {
374 const endedAt = startedAt + ms
375 await update($, LIVE, l => {
376 const cell = l[useId]
377 return cell ? { ...l, [useId]: { ...cell, endedAt, error: done?.error ?? (done ? undefined : 'the runner ended') } } : l
378 })
379 }
380 const text = report(calls, done, ms, stderr, notes)
381 const weak = weakness(isolation)
382 return weak ? text.replace(/^[^\n]*/, head => `${head} · ${weak}`) : text
383}
384
385/**
386 * A cell as the tool call started it. It runs detached from the call (its own dispatch), so
387 * the call can end while the cell runs on: asked for with `background`, or when the person
388 * sends a prompt while it runs (ctrl+enter), which abandons the call.
389 */
390type Job = { n: number; marker: string; finished: boolean; report?: string; background: boolean; stopped: boolean }
391let jobsMade = 0
392// When the person last sent a prompt: one at the moment a running cell's call is abandoned
393// means "send now" and the cell runs on; none means they interrupted it.
394let lastPersonPrompt = 0
395const PERSON = new Set(['composer', 'bridge'])
396
397function startCell($: Engine, code: string, useId: string | undefined, background: boolean): Job {
398 jobsMade += 1
399 const job: Job = { n: jobsMade, marker: `${dir}/job-${jobsMade}.done`, finished: false, background, stopped: false }
400 $.clock.after(0, () => {
401 void runCell($, code, useId, job)
402 .catch((err: unknown) => `cell · the cells mod failed: ${err instanceof Error ? err.message : String(err)}`)
403 .then(async text => {
404 job.report = text
405 job.finished = true
406 await $.fs.write(job.marker, '1').catch(() => undefined)
407 if (job.background && !job.stopped) await deliver($, job)
408 })
409 })
410 return job
411}
412
413/** A cell that ran on in the background reports as a message the model reads when the session is free. */
414async function deliver($: Engine, job: Job) {
415 await $.prompt.submit({ text: `Cell #${job.n}, which ran on in the background, has ended. Its report:\n\n${job.report ?? ''}` }).catch(() => undefined)
416}
417
418/**
419 * Waits for the cell through short `$` calls (a hook's own time stops only while one runs),
420 * each a waiter of at most two seconds on the cell's done-file. True once the cell ended,
421 * false when the call was abandoned first.
422 */
423async function waitFor($: Engine, job: Job, signal: AbortSignal): Promise<boolean> {
424 while (!job.finished && !signal.aborted) {
425 await $.process
426 .run(waitArgv(job.marker), { timeoutMs: 5000 })
427 .catch(() => undefined)
428 }
429 return job.finished
430}
431
432/** Whether the person sent a prompt around the moment the call was abandoned (it may come just after). */
433async function personSentPrompt($: Engine, at: number): Promise<boolean> {
434 for (let waited = 0; waited <= 1500; waited += 100) {
435 if (lastPersonPrompt >= at - 3000) return true
436 await $.clock.sleep(100).catch(() => undefined)
437 }
438 return false
439}
440
441async function markLive($: Engine, useId: string | undefined, flags: { background?: boolean; stopped?: boolean }) {
442 if (!useId) return
443 await update($, LIVE, l => {
444 const cell = l[useId]
445 return cell ? { ...l, [useId]: { ...cell, ...flags } } : l
446 })
447}
448
449/** Runs a cell for its tool call: waits for its report, or lets it run on in the background. */
450async function runFor($: Engine, signal: AbortSignal, code: string, useId: string | undefined, background: boolean): Promise<string> {
451 // A setup that stopped halfway set no sandbox: run it again.
452 if (!dir || isolation === undefined) await setup($)
453 const job = startCell($, code, useId, background)
454 if (background) {
455 await markLive($, useId, { background: true })
456 return `Cell #${job.n} runs in the background: its report arrives as a message when it ends. Carry on meanwhile, or end your turn to wait.`
457 }
458 if (await waitFor($, job, signal)) return job.report ?? ''
459 if (await personSentPrompt($, await $.clock.now())) {
460 job.background = true
461 await markLive($, useId, { background: true })
462 if (job.finished) await deliver($, job)
463 return `Cell #${job.n} moved to the background: its report arrives as a message when it ends.`
464 }
465 job.stopped = true
466 await markLive($, useId, { stopped: true })
467 return `Cell #${job.n} was stopped: the call running then ended as it would, and no call after it was made.`
468}
469
470export const register: Register = (on, options) => {
471 if (options.enabled === false) {
472 // Off: a tool registered earlier may outlive the reload, so only its call is answered.
473 on('tool.call', { tool: 'mcp__cells__run_cell' }, () => ({ result: 'cells is turned off (/mods turns it on).' }))
474 return
475 }
476 readOptions(options)
477 dir = ''
478 runnerDir = ''
479 isolation = undefined
480 handles = '{}'
481 steps = []
482 stepCount = 0
483 budget = new Budget()
484 pending = undefined
485
486 on('session.start', async ($, e, next) => {
487 if (mode === 'off') return next(e)
488 await setup($).catch(() => undefined)
489 // Where nothing closes the network to the runner, there is no tool: the person sees why and the fix, and
490 // Claude, told in the system prompt, offers it once.
491 const off = offReason()
492 if (off) $.ui.log(offLine(off.why, off.fix))
493 if (cellsOn()) {
494 await $.tool.register({
495 name: TOOL_NAME,
496 description: TOOL_DESCRIPTION,
497 isDeferred: false,
498 inputSchema: {
499 type: 'object',
500 properties: {
501 code: { type: 'string', description: 'The JavaScript to run (TypeScript types are stripped).' },
502 fix: {
503 type: 'array',
504 description: 'Instead of code, after a cell that did not parse: exact replacements in that program, each old text occurring once; the fixed program runs.',
505 items: { type: 'object', properties: { old: { type: 'string' }, new: { type: 'string' } }, required: ['old', 'new'] },
506 },
507 repair: { type: 'boolean', description: 'Instead of code: run the repaired program the last report offered.' },
508 background: { type: 'boolean', description: 'Run the cell in the background: the call returns at once and the report arrives as a message when the cell ends.' },
509 },
510 },
511 })
512 }
513 return next(e)
514 })
515
516 on('prompt.compose', async ($, e, next) => {
517 const composed = await next(e)
518 if (mode === 'off') return composed
519 const text = cellsOn() ? NUDGE_CELLS : NUDGE
520 const off = isolation ? offReason() : undefined
521 const offSection = off ? [{ id: 'cells-off', text: offNote(off.why, off.fix), scope: 'session' as const }] : []
522 return { sections: [...composed.sections, { id: 'cells-chaining', text, scope: 'session' as const }, ...offSection] }
523 }).catch(($, e, next) => next(e))
524
525 // A subagent's task: the guidance, in the person's name, since the system prompt above never reaches it; run_cell
526 // named only where the session has it.
527 on('agent.spawn', async ($, e, next) => {
528 if (!subagents || mode === 'off' || !reaches(e)) return next(e)
529 const line = cellsOn() ? `${SUBAGENT_CHAINING} ${SUBAGENT_CELLS}` : SUBAGENT_CHAINING
530 return next({ ...e, prompt: withSetting(e.prompt, line) })
531 }).catch(($, e, next) => next(e))
532
533 // Handles live for one prompt, as sterna's do.
534 on('turn.start', async ($, e, next) => {
535 handles = '{}'
536 return next(e)
537 })
538
539 // Each model request of the main loop: what it called and what it re-read, for the notes.
540 on('turn.step', async function* ($, e, next) {
541 const started = await $.clock.now()
542 const stream = next(e)
543 try {
544 for await (const chunk of stream) {
545 // A cell the model has begun to write animates until the response ends.
546 if (visuals && mode === 'cells' && chunk.kind === 'tool' && chunk.name === 'mcp__cells__run_cell') {
547 writing.add(chunk.id)
548 animate($)
549 }
550 yield chunk
551 }
552 } finally {
553 if (writing.size > 0) {
554 writing.clear()
555 animate($)
556 }
557 }
558 const r = await stream.result
559 if (!nudging || mode === 'off' || e.agentId !== undefined) return r
560 try {
561 const u = r.usage
562 steps = [
563 ...steps.slice(-3),
564 {
565 turnId: e.turnId,
566 index: e.index,
567 calls: r.toolUses.map(t => ({
568 tool: t.name,
569 kind: kindOf(t.name, t.input),
570 command: t.name === 'Bash' ? String((t.input as { command?: unknown } | undefined)?.command ?? '') : undefined,
571 })),
572 ok: [],
573 readTokens: u?.cache_read_input_tokens ?? 0,
574 uncachedTokens: u?.input_tokens ?? 0,
575 model: u?.model ?? e.model,
576 ms: (await $.clock.now()) - started,
577 },
578 ]
579 stepCount++
580 } catch {
581 // A step the notes could not read is one they skip.
582 }
583 return r
584 })
585
586 // The person's prompts: one arriving as a running cell's call is abandoned moves the cell to the background.
587 on('prompt.submit', async ($, e, next) => {
588 if (PERSON.has(e.origin.kind)) lastPersonPrompt = await $.clock.now()
589 return next(e)
590 }).catch(($, e, next) => next(e))
591
592 on('tool.call', { tool: 'mcp__cells__run_cell' }, async ($, e, next) => {
593 const input = e as unknown as { code?: unknown; fix?: unknown; repair?: unknown; background?: unknown; tool_use_id?: unknown }
594 const useId = typeof input.tool_use_id === 'string' ? input.tool_use_id : undefined
595 const background = input.background === true
596 const runCell = (code: string, id: string | undefined) => runFor($, next.signal, code, id, background)
597 if (input.repair === true) {
598 if (pending?.repaired === undefined) return { result: 'run_cell: no repaired program is on offer; the last cell parsed, or had no repair. Nothing ran.' }
599 return { result: await runCell(pending.repaired, useId) }
600 }
601 if (Array.isArray(input.fix)) {
602 if (pending === undefined) return { result: 'run_cell: no program to fix; the last cell parsed. Send code. Nothing ran.' }
603 const fixed = applyFixes(pending.code, input.fix as { old: string; new: string }[])
604 if (fixed.error !== undefined) return { result: `run_cell: ${fixed.error}. Nothing ran; the program is kept for another fix.` }
605 return { result: await runCell(fixed.code, useId) }
606 }
607 if (typeof input.code !== 'string' || input.code.trim() === '') {
608 return { result: 'run_cell needs `code`: the JavaScript to run (or `fix` or `repair` after a cell that did not parse).' }
609 }
610 return { result: await runCell(input.code, useId) }
611 }).catch(() => ({ result: 'cell · the cells mod failed before the cell could finish; nothing after its last reported call ran.' }))
612
613 // The model's own calls: a note on one that was an avoidable request of its own.
614 on('tool.call', async ($, e, next) => {
615 const r = await next(e)
616 if (!nudging || mode === 'off' || e.agentId !== undefined || running.size > 0) return r
617 if (typeof e.tool_use_id !== 'string' || !e.tool_use_id.startsWith('toolu_')) return r
618 try {
619 const current = steps.at(-1)
620 if (current === undefined) return r
621 const ok = r.deny === undefined && r.isError !== true
622 current.ok.push(ok)
623 if (!ok || r.deny !== undefined) return r
624 const mine = { tool: e.tool, kind: kindOf(e.tool, e), command: e.tool === 'Bash' ? e.command : undefined }
625 const pattern = missed(steps.at(-2), current, mine, cellsOn())
626 const size = pattern === undefined ? undefined : budget.take(pattern, stepCount)
627 if (pattern === undefined || size === undefined) return r
628 return { ...r, context: [...(r.context ?? []), noteFor(pattern, size, costOf(current), cellsOn())] }
629 } catch {
630 return r
631 }
632 })
633
634 on('ui.render', { component: 'ToolUse', props: { tool: 'mcp__cells__run_cell' } }, async ($, e, next) => {
635 if (!visuals) return next(e)
636 const now = await $.clock.now()
637 const live = (await read($, LIVE))[e.requestId]
638 const input = e.props.input as { code?: unknown } | undefined
639 // A fix or a repair sends no code: the program the cell runs is in its live state.
640 const code = live?.code ?? (typeof input?.code === 'string' ? input.code : '')
641 const width = e.viewport?.columns ?? 100
642 const frame = Math.floor(now / FRAME_MS)
643 const report = outputText(e.props.output)
644 let card: Card
645 if (live) {
646 // A plugin's tool row may never be flagged as running: the cell's own end says whether it still is.
647 const state = stateOf(live, e.props.isRunning)
648 const going = state === 'running' || state === 'background'
649 const parsed = report ? parseReport(report) : undefined
650 const summary = going
651 ? `${live.calls.length} call${live.calls.length === 1 ? '' : 's'} · ${seconds(now - live.startedAt)}`
652 : (parsed?.summary ?? '')
653 card = { state, code, calls: live.calls, now, frame, width, summary }
654 } else if (report) {
655 const parsed = parseReport(report)
656 card = { state: parsed.state, code, calls: parsed.calls, now, frame, width, summary: parsed.summary }
657 } else {
658 // No result and not running: the model is still writing the program, or the call waits its turn.
659 const waiting = !e.props.isRunning && !e.props.isErrored && !e.props.isInterrupted
660 card = { state: e.props.isRunning ? 'running' : waiting ? 'writing' : 'ran', code, calls: [], now, frame, width, summary: '' }
661 }
662 return cellCard($.ui.resolve(e), card)
663 })
664
665 on('ui.render', { component: 'ToolResult', props: { tool: 'mcp__cells__run_cell' } }, async ($, e, next) => {
666 const report = outputText(e.props.output)
667 if (!visuals || e.props.isErrored || report === undefined) return next(e)
668 return resultBody($.ui.resolve(e), report) ?? next(e)
669 })
670}
671runner/lsp.mjs 161 lines1// What a cell needs to work with the language server: where to ask about a
2// name, the places an answer lists, and a rename planned as exact edits the
3// Edit tool can apply. Pure; shared by the runner, the mod and the tests.
4
5const KEYWORDS = 'function|class|interface|type|enum|struct|trait|def|fn|func|const|let|var|val|record|module|namespace'
6
7const IDENT = /^[A-Za-z_$][\w$]*$/
8
9export const isName = name => typeof name === 'string' && IDENT.test(name)
10
11const escape = name => name.replace(/\$/g, '\\$')
12
13/** The 1-based column of `name` as a whole word on one line, or 0. */
14function columnOn(text, name) {
15 const m = new RegExp(`(^|[^\\w$])${escape(name)}(?![\\w$])`).exec(text)
16 return m ? m.index + m[1].length + 1 : 0
17}
18
19/**
20 * Where to ask the language server about `name` in a file's text: on `line`
21 * when given, else where it is defined, else where it first appears. 1-based.
22 */
23export function anchorIn(text, name, line = 0) {
24 const lines = String(text).split('\n')
25 if (line > 0) {
26 const col = columnOn(lines[line - 1] ?? '', name)
27 return col ? { line, col } : undefined
28 }
29 const def = new RegExp(`\\b(?:${KEYWORDS})\\s+${escape(name)}(?![\\w$])`)
30 const at = lines.findIndex(l => def.test(l))
31 const first = at >= 0 ? at : lines.findIndex(l => columnOn(l, name) > 0)
32 if (first < 0) return undefined
33 return { line: first + 1, col: columnOn(lines[first], name) }
34}
35
36/**
37 * The places an LSP answer lists, whichever way it lists them: a `path:`
38 * heading over `Line l:c` rows (references), or `path:l:c` inline
39 * (`Defined in src/a.ts:24:17`). Columns are 0 where the answer gives none.
40 */
41export function parseLocations(text) {
42 const found = []
43 let file
44 for (const row of String(text).split('\n')) {
45 const heading = /^\s*((?:\/|\.{1,2}\/)?[\w@.\-/ ]+\.\w+):\s*$/.exec(row)
46 if (heading) {
47 file = heading[1].trim()
48 continue
49 }
50 const at = /^\s*(?:[-•*]\s*)?Line\s+(\d+)(?::(\d+))?/i.exec(row)
51 if (at && file !== undefined) {
52 found.push({ file, line: Number(at[1]), col: Number(at[2] ?? 0) })
53 continue
54 }
55 for (const m of row.matchAll(/((?:\/|\.{1,2}\/)?[\w@.\-/]+\.[A-Za-z]\w*):(\d+)(?::(\d+))?/g)) {
56 found.push({ file: m[1], line: Number(m[2]), col: Number(m[3] ?? 0) })
57 }
58 }
59 const seen = new Set()
60 return found.filter(p => {
61 const key = `${p.file}:${p.line}:${p.col}`
62 if (seen.has(key)) return false
63 seen.add(key)
64 return true
65 })
66}
67
68/** How often `part` occurs in `text`, matches not overlapping (as the Edit tool counts). */
69function occurrences(text, part) {
70 let n = 0
71 for (let at = text.indexOf(part); at >= 0; at = text.indexOf(part, at + part.length)) n++
72 return n
73}
74
75/** Context lines an edit may grow by to make its text unique in the file. */
76const MAX_CONTEXT = 12
77
78/**
79 * A rename in one file's text, as exact edits: every place (1-based line and
80 * column where `name` starts) becomes `newName`. Places where the text does
81 * not hold `name` as a whole word are skipped and said so. Each edit is a
82 * run of whole lines, grown by context until it occurs once in the file as
83 * it stands when that edit is applied; edits come bottom to top, the order
84 * to apply them in.
85 */
86export function planRename(text, places, name, newName) {
87 const lines = String(text).split('\n')
88 const skipped = []
89 const cols = new Map()
90 for (const { line, col } of places) {
91 const l = lines[line - 1]
92 const ok =
93 l !== undefined &&
94 col > 0 &&
95 l.slice(col - 1, col - 1 + name.length) === name &&
96 !/[\w$]/.test(l[col - 2] ?? '') &&
97 !/[\w$]/.test(l[col - 1 + name.length] ?? '')
98 if (!ok) {
99 skipped.push({ line, col, text: (l ?? '').trim().slice(0, 160) })
100 continue
101 }
102 if (!cols.has(line)) cols.set(line, new Set())
103 cols.get(line).add(col)
104 }
105
106 const renamed = new Map()
107 let changed = 0
108 for (const [line, set] of cols) {
109 let l = lines[line - 1]
110 for (const col of [...set].sort((a, b) => b - a)) {
111 l = l.slice(0, col - 1) + newName + l.slice(col - 1 + name.length)
112 changed++
113 }
114 renamed.set(line, l)
115 }
116
117 const blocks = []
118 for (const n of [...renamed.keys()].sort((a, b) => a - b)) {
119 const last = blocks.at(-1)
120 if (last && n === last.to + 1) last.to = n
121 else blocks.push({ from: n, to: n })
122 }
123
124 const current = lines.slice()
125 const edits = []
126 const ambiguous = []
127 for (const { from, to } of blocks.reverse()) {
128 let a = from
129 let b = to
130 const old = () => current.slice(a - 1, b).join('\n')
131 let grown = 0
132 while (occurrences(current.join('\n'), old()) !== 1 && grown < MAX_CONTEXT) {
133 if (grown % 2 === 0 && a > 1) a--
134 else if (b < current.length) b++
135 else if (a > 1) a--
136 else break
137 grown++
138 }
139 if (occurrences(current.join('\n'), old()) !== 1) {
140 for (let n = from; n <= to; n++) ambiguous.push({ line: n, text: lines[n - 1].trim().slice(0, 160) })
141 continue
142 }
143 const before = old()
144 for (let n = from; n <= to; n++) current[n - 1] = renamed.get(n)
145 edits.push({ old: before, new: current.slice(a - 1, b).join('\n') })
146 }
147 return { edits, changed, skipped, ambiguous }
148}
149
150/** `path:line:text` rows of a grep, as places; at most `max`, with the count of all. */
151export function parseMentions(out, max = 40) {
152 const rows = String(out)
153 .split('\n')
154 .map(r => /^(.+?):(\d+):(.*)$/.exec(r))
155 .filter(Boolean)
156 return {
157 count: rows.length,
158 places: rows.slice(0, max).map(m => ({ file: m[1], line: Number(m[2]), text: m[3].trim().slice(0, 160) })),
159 }
160}
161runner/repair.mjs 149 lines1// Cells that do not parse: what can be said about them deterministically, and
2// a repair for the one slip that has a single reading. Nothing here runs a
3// program; the runner checks that a repair parses, and the model decides
4// whether to run it. Pure; shared by the runner, the mod and the tests.
5
6/**
7 * Where code holds an escape that is only valid inside a template literal
8 * (`` \` `` or `\${`) outside of any string, template, comment or regular
9 * expression: the sign of code escaped for a template it is not in. -1 if
10 * there is none.
11 */
12export function strayEscapeAt(code) {
13 const stack = [] // 'tpl' inside a template's text, 'expr' inside its ${…}, 'brace' inside other braces
14 let prev = '' // the last significant character, to tell a regular expression from a division
15 let i = 0
16 while (i < code.length) {
17 const c = code[i]
18 const n = code[i + 1]
19 if (stack.at(-1) === 'tpl') {
20 if (c === '\\') i += 2
21 else if (c === '`') {
22 stack.pop()
23 prev = '`'
24 i++
25 } else if (c === '$' && n === '{') {
26 stack.push('expr')
27 i += 2
28 } else i++
29 continue
30 }
31 if (c === '\\' && (n === '`' || (n === '$' && code[i + 2] === '{'))) return i
32 if (c === '/' && n === '/') {
33 const end = code.indexOf('\n', i)
34 i = end < 0 ? code.length : end
35 continue
36 }
37 if (c === '/' && n === '*') {
38 const end = code.indexOf('*/', i + 2)
39 i = end < 0 ? code.length : end + 2
40 continue
41 }
42 if (c === '"' || c === "'") {
43 i++
44 while (i < code.length && code[i] !== c && code[i] !== '\n') i += code[i] === '\\' ? 2 : 1
45 i++
46 prev = 'a'
47 continue
48 }
49 if (c === '/' && !/[\w$)\]}`'"]/.test(prev)) {
50 // A regular expression: up to the closing slash outside a class, then its flags.
51 let inClass = false
52 i++
53 while (i < code.length && code[i] !== '\n') {
54 if (code[i] === '\\') i += 2
55 else if (code[i] === '[') (inClass = true), i++
56 else if (code[i] === ']') (inClass = false), i++
57 else if (code[i] === '/' && !inClass) break
58 else i++
59 }
60 i++
61 while (/[a-z]/.test(code[i] ?? '')) i++
62 prev = 'a'
63 continue
64 }
65 if (c === '`') stack.push('tpl')
66 else if (c === '{') stack.push('brace')
67 else if (c === '}') stack.pop()
68 if (!/\s/.test(c)) prev = c
69 i++
70 }
71 return -1
72}
73
74/** Escapes that mean the same written once as written twice: kept as escapes, so no line moves. */
75const KEPT = new Set(['n', 'r', 't', 'b', 'f', 'v', '0', 'u', 'x'])
76
77/**
78 * A template literal written as if inside another one, read the way that
79 * outer template would have read it: from the stray `` \` `` at `start` to its
80 * closing `` \` ``, one level of escaping comes off (`` \` `` → `` ` ``,
81 * `\${` → `${`, `\\` → `\`); `\n` and its kind stay escapes, so no line moves.
82 * Undefined when it never closes.
83 */
84function unescapeRegion(code, start) {
85 let out = '`'
86 let escaped = false // the last character of the read text was a lone backslash
87 let i = start + 2
88 while (i < code.length) {
89 let ch
90 if (code[i] === '\\' && i + 1 < code.length) {
91 const next = code[i + 1]
92 i += 2
93 if (KEPT.has(next)) {
94 out += `\\${next}`
95 escaped = false
96 continue
97 }
98 ch = next
99 } else {
100 ch = code[i]
101 i++
102 }
103 if (ch === '`' && !escaped) return { text: `${out}\``, end: i }
104 out += ch
105 escaped = ch === '\\' && !escaped
106 }
107 return undefined
108}
109
110/**
111 * Every template the code escaped as if it sat inside another one, with one
112 * level of escaping taken off; the 1-based lines each began on. Undefined
113 * when there is none, or one that never closes.
114 */
115export function unescapeTemplates(code) {
116 let text = code
117 const lines = []
118 for (let round = 0; round < 20; round++) {
119 const at = strayEscapeAt(text)
120 if (at < 0) break
121 if (text[at + 1] !== '`') return undefined
122 const region = unescapeRegion(text, at)
123 if (region === undefined) return undefined
124 lines.push(text.slice(0, at).split('\n').length)
125 text = text.slice(0, at) + region.text + text.slice(region.end)
126 }
127 return lines.length > 0 ? { code: text, lines } : undefined
128}
129
130/** Exact replacements in a program, each of whose old text occurs once, as the Edit tool takes them. */
131export function applyFixes(code, fixes) {
132 let text = code
133 for (const [k, f] of fixes.entries()) {
134 const old = String(f?.old ?? '')
135 const count = old === '' ? 0 : text.split(old).length - 1
136 if (count !== 1) return { error: `fix ${k + 1}: its old text occurs ${count} times in the program; it must occur once` }
137 text = text.replace(old, () => String(f.new ?? ''))
138 }
139 return { code: text }
140}
141
142/** Where a program failed to parse, as the model reads it: the message, the line and a caret. */
143export function describeSyntax(message, code, line, caret) {
144 const shown = line !== undefined ? code.split('\n')[line - 1] : undefined
145 const at = line !== undefined ? ` (line ${line})` : ''
146 const frame = shown === undefined ? '' : `\n ${shown.length > 200 ? `${shown.slice(0, 199)}…` : shown}${caret ? `\n ${caret}` : ''}`
147 return `SyntaxError: ${message}${at}; nothing ran.${frame}`
148}
149hooks/bridge.ts 152 lines1// Runs one cell: starts the sandboxed runner, serves each tool call it asks
2// for through Claude Code's own tools, and writes the result the model reads.
3
4import { cut } from '../runner/fold.mjs'
5import type { Place } from '../runner/lsp.mjs'
6
7export type Need = { n: number; tool: string; input: Record<string, unknown> }
8
9/** What the runner reads back for one call. */
10export type Reply = {
11 ok: boolean
12 denied?: string
13 text?: string
14 out?: string
15 err?: string
16 code?: number
17 content?: string
18 /** Read: where the page starts, how many lines it holds, how many the file has. */
19 start?: number
20 num?: number
21 total?: number
22 /** Bash in the background: the task's id and the file its output goes to. */
23 task?: string
24 file?: string
25 /** LSP: the places the answer lists, and what a plugin above added. */
26 places?: Place[]
27 context?: string[]
28}
29
30export type Done = {
31 output: string
32 log: string
33 previews: string
34 handles: string
35 dropped: string[]
36 error?: string
37 /** The program did not parse, so nothing ran. */
38 unparsed?: boolean
39 /** That program with one level of escaping taken off templates written as if inside another one, where that parses; the lines they began on, and what changed. */
40 repaired?: string
41 repairedLines?: number[]
42 repairPreview?: string
43}
44
45export type CallRecord = { tool: string; label: string; ok: boolean; refused: boolean; ms: number; note?: string }
46
47export const MARK = '\u0001cell '
48export const MAX_IN_FLIGHT = 4
49
50const VERB: Record<string, string> = { Bash: 'sh', Read: 'read', Edit: 'edit', Write: 'write', LSP: 'lsp' }
51
52export function labelOf(need: Need): string {
53 const i = need.input
54 if (need.tool === 'Bash') return String(i.command ?? '').replace(/\s+/g, ' ').trim()
55 if (need.tool === 'LSP') return `${String(i.operation ?? '')} ${String(i.filePath ?? '')}:${String(i.line ?? '')}:${String(i.character ?? '')}`
56 return String(i.file_path ?? '')
57}
58
59export function absolute(cwd: string, file: unknown): string {
60 const p = String(file ?? '')
61 return p.startsWith('/') ? p : `${cwd.replace(/\/$/, '')}/${p.replace(/^\.\//, '')}`
62}
63
64/** A path as a cell should see it: relative to `cwd` when inside it, else absolute; `.` and `..` resolved. */
65export function tidy(cwd: string, file: string): string {
66 const parts: string[] = []
67 for (const part of absolute(cwd, file).split('/')) {
68 if (part === '' || part === '.') continue
69 if (part === '..') parts.pop()
70 else parts.push(part)
71 }
72 const full = `/${parts.join('/')}`
73 const root = cwd.replace(/\/$/, '')
74 return full.startsWith(`${root}/`) ? full.slice(root.length + 1) : full
75}
76
77/** The exit code Claude Code writes at the head of a failed Bash result. */
78export function exitCode(text: string | undefined): number {
79 const m = /^Exit code (\d+)/m.exec(text ?? '')
80 return m ? Number(m[1]) : 1
81}
82
83/** Splits the runner's stdout into its messages, keeping a partial line. */
84export function lines(buffer: string): { messages: unknown[]; rest: string } {
85 const messages: unknown[] = []
86 let rest = buffer
87 let at = rest.indexOf('\n')
88 while (at >= 0) {
89 const line = rest.slice(0, at)
90 rest = rest.slice(at + 1)
91 if (line.startsWith(MARK)) {
92 try {
93 messages.push(JSON.parse(line.slice(MARK.length)))
94 } catch {
95 // A line the runner did not write whole; nothing to act on.
96 }
97 }
98 at = rest.indexOf('\n')
99 }
100 return { messages, rest }
101}
102
103function seconds(ms: number): string {
104 return ms < 10_000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.round(ms / 1000)} s`
105}
106
107/** The one tool result the model reads for a cell. */
108/** Notes from other plugins a report carries at most, each cut to NOTE_ROOM. */
109const MAX_NOTES = 6
110const NOTE_ROOM = 800
111
112export function report(calls: readonly CallRecord[], done: Done | undefined, ms: number, stderr: string, notes: readonly string[] = []): string {
113 const failed = calls.filter(c => !c.ok).length
114 const head = `cell · ${calls.length} call${calls.length === 1 ? '' : 's'}${failed ? ` · ${failed} failed` : ''} · ${seconds(ms)}`
115 const rows = calls.map(c => {
116 const mark = c.refused ? '⊘' : c.ok ? '✓' : '✕'
117 const verb = (VERB[c.tool] ?? c.tool).padEnd(5)
118 const label = c.label.length > 90 ? `${c.label.slice(0, 89)}…` : c.label
119 const note = c.note ? ` ${c.note}` : ''
120 return ` ${mark} ${verb} ${label}${note} ${seconds(c.ms)}`
121 })
122 const parts = [head, ...rows]
123
124 if (!done) {
125 parts.push('## Error', `the runner ended without a result${stderr ? `:\n${cut(stderr, 1500)}` : ''}`)
126 return parts.join('\n')
127 }
128 if (done.output) parts.push('## Output', done.output)
129 if (done.log) parts.push('## Log', done.log)
130 if (done.previews) parts.push('## Handles', done.previews)
131 if (done.dropped.length > 0) parts.push(`(not kept, too large: ${done.dropped.map(k => `h.${k}`).join(', ')})`)
132 if (notes.length > 0) {
133 parts.push('## Notes', ...notes.slice(0, MAX_NOTES).map(n => cut(n, NOTE_ROOM)))
134 if (notes.length > MAX_NOTES) parts.push(`(${notes.length - MAX_NOTES} more notes not shown)`)
135 }
136 if (done.error) parts.push('## Error', done.error)
137 if (done.unparsed && done.repaired !== undefined) {
138 const lines = done.repairedLines ?? []
139 parts.push(
140 '## Repair',
141 'Line' + (lines.length === 1 ? ' ' : 's ') + lines.join(', ') + ': a template literal escaped as if it sat inside another one (\\` or \\${ outside any template). With one level of escaping taken off, the program parses:',
142 done.repairPreview ?? '',
143 'Run it so with run_cell({ repair: true }), or fix it with run_cell({ fix: [{ old, new }] }).',
144 )
145 } else if (done.unparsed || (done.error !== undefined && calls.length === 0)) {
146 parts.push('## Fix', 'Send run_cell({ fix: [{ old, new }] }): exact replacements in this program, which then runs. No need to send it again.')
147 }
148 if (!done.output && !done.log && !done.error && calls.length > 0) parts.push('(the cell returned nothing)')
149 return parts.join('\n')
150}
151
152hooks/guide.ts 56 lines1// What the model reads: the chaining guidance (a system-prompt section, in
2// both modes) and the run_cell tool's description (cells mode only).
3
4export const NUDGE = `# Fewer round-trips
5
6Every model request re-reads the whole conversation, so a step that needs no judgment should not cost a request of its own.
7
8- When you already know several things you need to see, get them in one Bash call (join commands with \`;\` and label each part with an \`echo\`), or as parallel tool calls in one response.
9- When you already know several edits, send them in one response with the check after them: calls in one response run in order, so the check sees the edits. When a check fails, print the failing part in the same command (for example \`pytest -x -q 2>&1 | tail -40\`).
10- When a search names the files you will open next, open them in the same command (for example \`rg -l pattern | head -3 | xargs sed -n '1,120p'\`).
11- When you wait for something, wait in one command (\`until …; do sleep 5; done\`) instead of checking again and again.
12
13Take a separate step only when what you do next depends on reading the result.`
14
15export const NUDGE_CELLS = `${NUDGE}
16
17run_cell chains real tool calls where one response can't: edits you would otherwise script in Python or sed (it keeps the Edit tool's diff and /rewind), the language server's references, definitions and renames without loading the LSP tool, and steps that branch on results without your judgment.`
18
19export const TOOL_NAME = 'run_cell'
20
21export const TOOL_DESCRIPTION = `Run a short JavaScript program (a cell) that chains real tool calls in one step. Each call inside is a normal Read, Edit, Write, Bash or LSP call with the usual permissions; edits show as diffs and can be rewound.
22
23Use it where one step can do what would otherwise take several requests:
24 - Edits and their check: read, edit, edit, then the tests or type check, returning only what failed. Prefer it to a Python or sed script that rewrites files: the same single step, through the Edit tool.
25 - The language server, without loading the LSP tool: refs() and defn() list each place with its line; rename() changes every reference of a symbol and lists the text mentions it left (docs, strings, comments) for you to judge.
26 - Inspections you already know you need, or that follow from each other without your judgment (a search, then its hits).
27Don't use it for one call, or when you need to read a result before deciding the next step.
28
29API (all async; plain JavaScript, TypeScript types are stripped):
30 sh(command, { timeout?, check?, background? }) → { ok, out, err, code, task? }
31 Bash; with check: true a non-zero exit throws; background: true starts it as a background task (task is its id;
32 Claude Code tells you when it ends), for a server, a watcher or a long job the cell need not wait for
33 read(path, { offset?, limit? }) → string Read
34 edit(path, oldText, newText, { all? }) Edit; throws if it fails; needs a read of the file first (in the cell is fine)
35 write(path, text) Write
36 refs(path, name, { line? }) → [{ file, line, col, text }] every reference of name, as defined or used in path (on line, if given); the declaration included
37 defn(path, name, { line? }) → [{ file, line, col, text }] where name, as used in path, is defined
38 rename(path, name, newName, { line? }) → { changed, files, skipped, failed, left, leftCount }
39 edits every reference the language server lists, through Edit; left lists where the old name still appears as text:
40 docs, strings, comments, and code the server did not resolve (another package, dynamic use). Check each.
41 fold(text) → string folds passing tests, build chatter and repeated lines; keeps every failure line
42 log(...values) notes shown in the result
43 h an object whose values survive into later cells of this prompt; the result previews them
44Use Promise.all([...]) for independent calls. \`return\` what you want to see: long text is folded and cut, so keep a full value in h when you may need more of it.
45
46run_cell({ code, background: true }) returns at once and the cell's report arrives as a message when it ends: for a cell that waits on something long.
47
48A cell that does not parse, or throws before its first call, has run nothing, and the report shows the line. Don't send it again: run_cell({ fix: [{ old, new }] }) applies exact replacements to that program (each old text a short snippet that occurs once) and runs it. Where the slip is a template literal escaped as if it sat inside another one (\\\` or \\\${ outside any template), the report shows the repair and run_cell({ repair: true }) runs it.
49
50Example:
51 await read('src/app.py')
52 await edit('src/app.py', 'retries = 3', 'retries = 5')
53 await edit('src/app.py', 'timeout = 10', 'timeout = 30')
54 const t = await sh('python -m pytest -x -q tests/test_app.py 2>&1')
55 return t.ok ? 'tests pass' : fold(t.out)`
56hooks/isolation.ts 109 lines1// Where a cell's runner may run, and how it is shut in.
2//
3// What a cell does to the machine it does through Claude Code's tools, which the person's
4// permissions and auto mode judge one call at a time. The runner itself must have no other
5// way out, or one run_cell call could do what no tool call shows. Node's permission model
6// closes the disk, child processes and workers, and from Node 25 on the network; an OS
7// sandbox closes the network and signals on any Node: Seatbelt on macOS, bubblewrap on Linux.
8// Where nothing closes the network, cells does not run, and says what would let it.
9
10/** What the `node` on Claude Code's PATH reports about itself. */
11export type Probe = { nodePath: string; platform: string; nodeVersion: string; tmpdir: string }
12
13export type Sandbox = 'seatbelt' | 'bwrap' | 'node'
14
15export type Isolation =
16 | { sandbox: Sandbox; signalsOpen: boolean }
17 | { sandbox: undefined; why: string; fix: string }
18
19/** From Node 25 the permission model also refuses network access unless `--allow-net` opens it. */
20export const NODE_NET_MAJOR = 25
21
22/** The major version of "26.1.0" or "v22.22.3"; 0 when it is not a version. */
23export function major(version: string): number {
24 return Number(/^v?(\d+)\./.exec(version)?.[1] ?? 0)
25}
26
27/** The program `node -p` runs to report its path, platform and version as JSON. */
28export const PROBE_SCRIPT = 'JSON.stringify([process.execPath, process.platform, process.versions.node, require("os").tmpdir()])'
29
30export function parseProbe(stdout: string): Probe | undefined {
31 try {
32 const [nodePath, platform, nodeVersion, tmpdir] = JSON.parse(stdout) as unknown[]
33 if (typeof nodePath !== 'string' || typeof platform !== 'string' || typeof nodeVersion !== 'string' || typeof tmpdir !== 'string') return undefined
34 return { nodePath, platform, nodeVersion, tmpdir }
35 } catch {
36 return undefined
37 }
38}
39
40/** bubblewrap's verdict: whether it started a program in the sandbox, and if not, its first word on why. */
41export type Bwrap = { works: boolean; why?: string }
42
43export function choose(probe: Probe, bwrap: Bwrap): Isolation {
44 if (probe.platform === 'darwin') return { sandbox: 'seatbelt', signalsOpen: false }
45 if (probe.platform === 'linux' && bwrap.works) return { sandbox: 'bwrap', signalsOpen: false }
46 if (major(probe.nodeVersion) >= NODE_NET_MAJOR) return { sandbox: 'node', signalsOpen: true }
47 const node = `Node ${probe.nodeVersion} lets a program reach the network`
48 if (probe.platform === 'linux') {
49 const sandbox = bwrap.why ? `bubblewrap can't start a sandbox here (${bwrap.why})` : 'bubblewrap (bwrap) is not installed'
50 return { sandbox: undefined, why: `${sandbox}, and ${node}`, fix: 'install Node 25 or later, or make bubblewrap work' }
51 }
52 return { sandbox: undefined, why: `there is no OS sandbox for it here, and ${node}`, fix: 'install Node 25 or later' }
53}
54
55/** The runner under Node's permission model: it reads its own folder and the cell's, nothing else. */
56export function nodeArgv(nodePath: string, runner: string, dir: string): string[] {
57 return [nodePath, '--permission', `--allow-fs-read=${runner}`, `--allow-fs-read=${dir}`, '--no-warnings', `${runner}/runner.mjs`]
58}
59
60export function seatbeltArgv(nodePath: string, runner: string, dir: string): string[] {
61 return ['/usr/bin/sandbox-exec', '-f', `${runner}/profile.sb`, ...nodeArgv(nodePath, runner, dir)]
62}
63
64/**
65 * bubblewrap: every namespace its own (network, processes, IPC), so neither the network nor
66 * another process is in reach; read-only binds of the system's libraries, Node, the runner
67 * and the cell's folder, and nothing else of the host's files. Assumes a merged /usr, as
68 * Debian, Ubuntu, Fedora and Arch have it.
69 */
70export function bwrapArgv(nodePath: string, runner: string, dir: string, program: string[] = nodeArgv(nodePath, runner, dir)): string[] {
71 // Node's install prefix: /usr/local/bin/node → /usr/local, ~/.nvm/versions/node/v25.1.0/bin/node → its version folder.
72 const prefix = nodePath.replace(/\/bin\/[^/]+$/, '')
73 const nodeBind = prefix === '/usr' || prefix.startsWith('/usr/') ? [] : ['--ro-bind', prefix, prefix]
74 return [
75 'bwrap', '--unshare-all', '--die-with-parent', '--new-session', '--cap-drop', 'ALL', '--clearenv',
76 '--ro-bind', '/usr', '/usr', '--symlink', 'usr/lib', '/lib', '--symlink', 'usr/lib64', '/lib64', '--symlink', 'usr/bin', '/bin',
77 '--ro-bind-try', '/etc/ld.so.cache', '/etc/ld.so.cache',
78 ...nodeBind,
79 '--ro-bind', runner, runner,
80 '--ro-bind', dir, dir,
81 '--dev', '/dev',
82 ...program,
83 ]
84}
85
86export function runnerArgv(isolation: Isolation, nodePath: string, runner: string, dir: string): string[] | undefined {
87 if (isolation.sandbox === 'seatbelt') return seatbeltArgv(nodePath, runner, dir)
88 if (isolation.sandbox === 'bwrap') return bwrapArgv(nodePath, runner, dir)
89 if (isolation.sandbox === 'node') return nodeArgv(nodePath, runner, dir)
90 return undefined
91}
92
93/** What a cell's report adds to its first line where the runner is shut in less than elsewhere. */
94export function weakness(isolation: Isolation): string | undefined {
95 return isolation.sandbox === 'node' && isolation.signalsOpen ? "Node's sandbox only: signals open" : undefined
96}
97
98/** The system-prompt section where cells cannot run: Claude offers the fix once, the person decides. */
99export function offNote(why: string, fix: string): string {
100 return `# cells is off on this machine
101
102The cells mod could not start its run_cell tool: ${why}. Where chaining several edits and their check in one step would have helped the person, tell them once and offer the fix (${fix}); /cells:setup walks them through it. Don't bring it up again after that.`
103}
104
105/** The line the person sees where cells cannot run. */
106export function offLine(why: string, fix: string): string {
107 return `cells is off: ${why}. To turn it on, ${fix}, or run /cells:setup.`
108}
109hooks/nudge.ts 144 lines1// Notes after the fact: when a model request did no more than a step that
2// needed no judgment (a check right after edits, one more single edit, the
3// same command again), its tool result says what that request re-read and
4// how to save it next time; a script that rewrote files around the Edit tool
5// is pointed to run_cell. Notes only, never a block, and few of them.
6
7export type Kind = 'edit' | 'script-edit' | 'check' | 'edit-check' | 'bash' | 'other'
8
9/** A command that tests, type-checks, builds or lints. */
10const CHECK =
11 /(\btsc\b|pytest|py\.test|\bnpm (run )?(test|build|lint|check|typecheck)\b|\b(pnpm|yarn|bun) (run )?(test|build|lint|typecheck)\b|\bcargo (test|check|build|clippy)\b|\bgo (test|build|vet)\b|claude plugin (test|validate)|\bruff\b|\bmypy\b|\bpyright\b|\beslint\b|\bjest\b|\bvitest\b|(^|[;&|]\s*)make\b|\bswift (build|test)\b|xcodebuild|\bgradle\b|\bmvn\b|dotnet (build|test)|node --test|-m (pytest|unittest)\b|board validate|latexmk|\bshellcheck\b|py_compile|node --check)/
12
13/** A command that writes files: a script, sed or perl in place, a redirect into a file. */
14const WRITES = /(\.write\(|open\([^)]*['"][wa]['"]|write_text\(|writeFileSync|\bsed -i|\bperl -[a-z0-9]*i|\bcat >|\btee \S)/
15
16/**
17 * A script that rewrites text inside existing files: Python or Node replacing
18 * and writing back, or perl in place. Exact and multi-line replacements are
19 * what the Edit tool is for; a one-line `sed -i` or a new file is not meant.
20 */
21const REWRITES = /(\.replace\(|re\.sub\(|\.sub\(|splice|\.slice\()[\s\S]*(open\([^)]*['"]w['"]|write_text\(|writeFileSync)|\bperl -[a-z0-9]*pi/
22
23export function kindOf(tool: string, input: unknown): Kind {
24 if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit') return 'edit'
25 if (tool !== 'Bash') return 'other'
26 const command = String((input as { command?: unknown } | undefined)?.command ?? '')
27 const writes = WRITES.test(command)
28 const checks = CHECK.test(command)
29 if (writes && checks) return 'edit-check'
30 if (writes) return 'script-edit'
31 if (checks) return 'check'
32 return 'bash'
33}
34
35export function rewritesText(command: string): boolean {
36 return REWRITES.test(command)
37}
38
39/** One model request of the main loop, as far as the notes need it. */
40export type Step = {
41 turnId: string
42 index: number
43 calls: { tool: string; kind: Kind; command?: string }[]
44 /** Whether each call has come back without an error, in the order they did. */
45 ok: boolean[]
46 /** Tokens the request read: from the cache, and uncached. */
47 readTokens: number
48 uncachedTokens: number
49 model: string
50 ms: number
51}
52
53export type Pattern = 'check-alone' | 'edit-alone' | 'repeat' | 'script-edit'
54
55const normal = (command: string | undefined) => (command ?? '').replace(/\s+/g, ' ').trim()
56
57/**
58 * Which avoidable step `current` was, given the request before it in the same
59 * turn, as seen from `mine`, one of its calls; undefined when it was none.
60 */
61export function missed(previous: Step | undefined, current: Step, mine: Step['calls'][number], cells: boolean): Pattern | undefined {
62 if (cells && mine.kind === 'script-edit' && rewritesText(mine.command ?? '')) return 'script-edit'
63 const before =
64 previous !== undefined && previous.turnId === current.turnId && previous.index === current.index - 1 && previous.calls.length > 0
65 ? previous
66 : undefined
67 if (before === undefined || current.calls.length !== 1) return undefined
68 const fine = before.ok.length === before.calls.length && before.ok.every(Boolean)
69 if (mine.kind === 'check' && fine && before.calls.every(c => c.kind === 'edit' || c.kind === 'script-edit')) return 'check-alone'
70 if (mine.kind === 'edit' && fine && before.calls.length === 1 && before.calls[0]?.kind === 'edit') return 'edit-alone'
71 const only = before.calls.length === 1 ? before.calls[0] : undefined
72 if (only !== undefined && (only.kind === 'bash' || only.kind === 'check') && (mine.kind === 'bash' || mine.kind === 'check')) {
73 const a = normal(only.command)
74 const b = normal(mine.command)
75 if (a !== '' && (a === b || (/\bsleep \d/.test(a) && /\bsleep \d/.test(b)))) return 'repeat'
76 }
77 return undefined
78}
79
80/** How often each note may come: the first in full, later ones short, spaced out and capped. */
81const LIMITS: Record<Pattern, { gap: number; max: number }> = {
82 'check-alone': { gap: 12, max: 4 },
83 'edit-alone': { gap: 12, max: 4 },
84 repeat: { gap: 8, max: 4 },
85 'script-edit': { gap: 25, max: 3 },
86}
87
88export class Budget {
89 private fired = new Map<Pattern, { count: number; step: number }>()
90
91 /** 'full' the first time, 'short' when a later note is due, else undefined. Counts what it allows. */
92 take(pattern: Pattern, step: number): 'full' | 'short' | undefined {
93 const seen = this.fired.get(pattern)
94 const limit = LIMITS[pattern]
95 if (seen === undefined) {
96 this.fired.set(pattern, { count: 1, step })
97 return 'full'
98 }
99 if (seen.count >= limit.max || step - seen.step < limit.gap) return undefined
100 this.fired.set(pattern, { count: seen.count + 1, step })
101 return 'short'
102 }
103}
104
105/** $/M tokens, list prices, as Claude Code totals them. */
106const PRICES: Record<string, { read: number; input: number }> = {
107 haiku: { read: 0.01, input: 0.1 },
108 sonnet: { read: 0.2, input: 2 },
109 opus: { read: 0.2, input: 4 },
110 fable: { read: 0.25, input: 10 },
111}
112
113/** What re-reading the context cost the request: "412k tokens, ≈$0.08, 6 s". */
114export function costOf(step: Step): string {
115 const key = Object.keys(PRICES).find(k => step.model.toLowerCase().includes(k)) ?? 'opus'
116 const p = PRICES[key] as { read: number; input: number }
117 const usd = (step.readTokens * p.read + step.uncachedTokens * p.input) / 1e6
118 const tokens = step.readTokens + step.uncachedTokens
119 const k = tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(tokens)
120 const money = usd < 0.01 ? `≈$${usd.toFixed(3)}` : `≈$${usd.toFixed(2)}`
121 return `${k} tokens, ${money}, ${Math.max(1, Math.round(step.ms / 1000))} s`
122}
123
124export function noteFor(pattern: Pattern, size: 'full' | 'short', cost: string, cells: boolean): string {
125 if (pattern === 'check-alone') {
126 return size === 'full'
127 ? `cells: this check ran in a model request of its own right after the edits, and that request re-read the whole context (${cost}). Next time send the check in the same response as the edits: calls in one response run in order, so it sees them.${cells ? ' Or make the edits and the check one run_cell that returns only what failed.' : ''}`
128 : `cells: another check in a request of its own (${cost}); it can go in the response with the edits.`
129 }
130 if (pattern === 'edit-alone') {
131 return size === 'full'
132 ? `cells: a second request in a row that made one edit; it re-read the whole context (${cost}). Edits you already know can go together in one response${cells ? ', or into one run_cell with their check' : ''}.`
133 : `cells: another one-edit request (${cost}); edits you already know can go together.`
134 }
135 if (pattern === 'repeat') {
136 return size === 'full'
137 ? `cells: the same command again in a new request (${cost}). To wait for something, wait in one command (\`until <test>; do sleep 5; done\`) or watch it with Monitor.`
138 : `cells: the same command again (${cost}); wait in one command instead.`
139 }
140 return size === 'full'
141 ? 'cells: this script rewrote files around the Edit tool, so the person sees no diff and checkpoints do not track it (/rewind cannot restore it). run_cell does the same in one step through Edit: `await read(p); await edit(p, old, new); return (await sh(check)).out`, and rename(path, name, newName) renames a symbol by its references.'
142 : "cells: another script edit around the Edit tool; run_cell's edit() keeps the diff and /rewind."
143}
144hooks/subagents.ts 42 lines1// A subagent Claude starts has a system prompt of its own, which the chaining guidance (a section of the main one)
2// never reaches; the task it is given is the one thing that rides with it. Pure: no I/O.
3//
4// Measured in the person's transcripts: 0 of 18 subagents had the guidance, and general-purpose subagents ran about a
5// third of the person's Opus requests, most of the longer ones without run_cell. An order beside the task, in the
6// person's name, is followed; advice is not.
7
8/**
9 * The block the pack's mods put their settings under, at the end of a subagent's task; each mod adds its own line,
10 * so either can be turned off alone. seek writes the same head.
11 */
12export const SETTINGS_HEAD = "The person's settings for this work:"
13
14/** The task with one more setting: a line under the block a mod before this one began, or a new block at the end. */
15export function withSetting(prompt: string, line: string): string {
16 if (prompt.includes(line)) return prompt
17 const at = prompt.lastIndexOf(`\n\n${SETTINGS_HEAD}\n`)
18 if (at >= 0 && !prompt.slice(at + 2).includes('\n\n')) return `${prompt.trimEnd()}\n- ${line}`
19 return `${prompt.trimEnd()}\n\n${SETTINGS_HEAD}\n- ${line}`
20}
21
22type Spawn = { subagentType: string; fork: boolean; isTeammate?: true; resume?: unknown }
23
24/**
25 * Whether a spawn starts a subagent on a task of its own: not a fork (it has the parent's context, the guidance
26 * included), not a resume (its task is fixed), not a teammate, not delegate's worker (a tuned prompt, no run_cell).
27 */
28export function reaches(e: Spawn): boolean {
29 return !e.fork && e.resume === undefined && e.isTeammate !== true && !e.subagentType.startsWith('delegate:')
30}
31
32/** The chaining guidance, short, for a subagent's task. */
33export const SUBAGENT_CHAINING =
34 'Fewer round-trips: every response you make re-reads the whole conversation, so a step that needs no judgment should not cost a response of its own. ' +
35 'Get what you already know you need in one Bash call (join the commands with `;`, label each part with an `echo`) or as parallel tool calls in one response; ' +
36 'send edits you already know in one response with their check after them; when a search names the files you will open next, open them in the same command. ' +
37 'Take a separate step only when what you do next depends on reading the result.'
38
39/** Added only where the session has the tool; a subagent whose type lists its own tools may not. */
40export const SUBAGENT_CELLS =
41 "Where you have the run_cell tool, it chains real tool calls one response can't: edits and their check, the language server's references and renames, steps that follow from each other without your judgment."
42hooks/view.tsx 279 lines1// How a cell draws in the transcript, after sterna's cells: a rounded card
2// with the program dimmed, one row per call with its mark and time, and a
3// closing line that carries the state. Only what is changing state moves:
4// the scanner beside a running cell's state, and a turning disc on the call
5// in flight.
6
7import type { BoxProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
8
9import type { CellsCall, CellsLive } from '../types'
10
11export type Elements = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
12
13export type CellState = 'writing' | 'running' | 'background' | 'ran' | 'failed' | 'threw' | 'stopped'
14
15export type Card = {
16 state: CellState
17 code: string
18 calls: readonly CellsCall[]
19 now: number
20 frame: number
21 width: number
22 summary: string
23}
24
25/** How wide the scanner is, and its two-cell head sweeping out and back. */
26const SCAN = 10
27const TURN = ['◐', '◓', '◑', '◒'] as const
28const VERB: Record<string, string> = { Bash: 'sh', Read: 'read', Edit: 'edit', Write: 'write', LSP: 'lsp' }
29const CODE_LINES = 6
30
31export function scanner(frame: number): { before: string; head: string; after: string } {
32 const span = SCAN - 2
33 const t = frame % (2 * span)
34 const at = t <= span ? t : 2 * span - t
35 return { before: '·'.repeat(at), head: '━━', after: '·'.repeat(span - at) }
36}
37
38/** A cell's state from its own record: running until it has ended, whatever its row says. */
39export function stateOf(live: CellsLive, rowRunning: boolean): CellState {
40 if (live.stopped) return 'stopped'
41 if (rowRunning || live.endedAt === undefined) return live.background ? 'background' : 'running'
42 if (live.error) return 'threw'
43 return live.calls.some(c => c.state !== 'ok' && c.state !== 'running') ? 'failed' : 'ran'
44}
45
46export function seconds(ms: number): string {
47 return ms < 10_000 ? `${(Math.max(0, ms) / 1000).toFixed(1)} s` : `${Math.round(ms / 1000)} s`
48}
49
50function fit(text: string, room: number): string {
51 const flat = text.replace(/\s+/g, ' ').trim()
52 return flat.length > room ? `${flat.slice(0, Math.max(1, room - 1))}…` : flat
53}
54
55const TONE: Record<CellState, string> = { writing: 'claude', running: 'claude', background: 'claude', ran: 'success', failed: 'warning', threw: 'error', stopped: 'warning' }
56
57export function cellCard({ Box, Text }: Elements, c: Card): RenderElement {
58 const inner = Math.max(24, c.width - 6)
59 const sweep = scanner(c.frame)
60 const program = c.code.replace(/\s+$/, '')
61 const lines = program === '' ? [] : program.split('\n')
62 const moving = c.state === 'running' || c.state === 'writing' || c.state === 'background'
63 const shown = lines.slice(0, CODE_LINES)
64
65 const footer = (
66 <Box flexDirection="row" gap={1}>
67 <Text bold color={TONE[c.state]}>
68 {c.state}
69 </Text>
70 {moving ? (
71 <Text>
72 <Text color="inactive">{sweep.before}</Text>
73 <Text color="claude">{sweep.head}</Text>
74 <Text color="inactive">{sweep.after}</Text>
75 </Text>
76 ) : null}
77 {c.summary ? <Text color="subtle">{`· ${c.summary}`}</Text> : null}
78 </Box>
79 )
80
81 const code = lines.length === 0 ? null : (
82 <Box flexDirection="column" paddingLeft={2}>
83 {shown.map(line => (
84 <Text color="subtle" wrap="truncate-end">
85 {line.length > inner ? `${line.slice(0, inner - 1)}…` : line || ' '}
86 </Text>
87 ))}
88 {lines.length > CODE_LINES ? <Text color="inactive">{`… ${lines.length - CODE_LINES} more lines`}</Text> : null}
89 </Box>
90 )
91
92 const rows = c.calls.map(call => {
93 const mark =
94 call.state === 'running'
95 ? TURN[c.frame % TURN.length]
96 : call.state === 'ok'
97 ? '✓'
98 : call.state === 'refused'
99 ? '⊘'
100 : '✕'
101 const markTone = call.state === 'running' ? 'claude' : call.state === 'ok' ? 'success' : call.state === 'refused' ? 'warning' : 'error'
102 const ms = call.state === 'running' ? c.now - call.startedAt : (call.ms ?? 0)
103 const note = call.note ? ` ${call.note}` : ''
104 return (
105 <Box flexDirection="row" justifyContent="space-between">
106 <Box flexDirection="row" gap={1} flexShrink={1}>
107 <Text color={markTone}>{mark}</Text>
108 <Text color="subtle">{(VERB[call.tool] ?? call.tool).padEnd(5)}</Text>
109 <Text wrap="truncate-end">{fit(call.label, inner - 16 - note.length)}</Text>
110 {note ? <Text color="warning">{note.trim()}</Text> : null}
111 </Box>
112 <Text color="subtle">{seconds(ms)}</Text>
113 </Box>
114 )
115 })
116
117 return (
118 <Box flexDirection="column" borderStyle="round" borderColor={moving ? 'claude' : 'subtle'} paddingX={1}>
119 {code}
120 {rows.length > 0 ? <Box flexDirection="column">{rows}</Box> : null}
121 {footer}
122 </Box>
123 )
124}
125
126/** A finished cell rebuilt from the report the model read: its header and call rows. */
127export function parseReport(text: string): { summary: string; calls: CellsCall[]; state: CellState } {
128 const all = text.split('\n')
129 const summary = (all[0] ?? '').replace(/^cell · /, '')
130 const calls: CellsCall[] = []
131 for (const line of all.slice(1)) {
132 const m = /^ {2}([✓✕⊘]) (\S+)\s+(.*?)\s{2}(?:(refused|exit \d+)\s{2})?([\d.]+) s$/.exec(line)
133 if (!m) break
134 const verb = m[2] ?? ''
135 const tool = Object.entries(VERB).find(([, v]) => v === verb)?.[0] ?? verb
136 const state = m[1] === '✓' ? 'ok' : m[1] === '⊘' ? 'refused' : 'failed'
137 calls.push({ tool, label: m[3] ?? '', state, startedAt: 0, ms: Number(m[5]) * 1000, note: m[4] })
138 }
139 const threw = /\n## Error\n/.test(text)
140 const failed = calls.some(c => c.state !== 'ok')
141 return { summary, calls, state: threw ? 'threw' : failed ? 'failed' : 'ran' }
142}
143
144export type LineTone = 'error' | 'warning' | 'success' | undefined
145
146const ERROR_LINE = /\berror\b|\bfail(?:ed|s|ure|ures)?\b|✕|✘|\bTraceback\b|\bpanicked\b|\bexception\b|\bexit [1-9]|^E\s/i
147const NOT_ERROR = /\b0 (?:errors?|fail(?:ed|s|ures?)?)\b|\bno errors?\b/i
148const WARN_LINE = /\bwarn(?:ing)?s?\b|⚠/i
149const PASS_LINE = /✔|✓|\bpass(?:ed|es)?\b|\bok\b|\bsucceeded\b/i
150
151/** What a line of a result says, by its words: an error (and the indented lines under one), a warning, a pass. */
152export function toneOf(line: string, previous: LineTone): LineTone {
153 if (previous === 'error' && /^\s+\S/.test(line) && !PASS_LINE.test(line)) return 'error'
154 if (ERROR_LINE.test(line) && !NOT_ERROR.test(line)) return 'error'
155 if (WARN_LINE.test(line)) return 'warning'
156 if (PASS_LINE.test(line) || NOT_ERROR.test(line)) return 'success'
157 return undefined
158}
159
160const LOCATION = /^(\s*)((?:[\w@.~-]+\/)*[\w@.~-]+\.\w+(?:\(\d+,\d+\)|:\d+(?::\d+)?):?)(.*)$/
161
162/** A line that starts with where it is about (`a.ts(3,5):`, `a.py:12:`): that place, and the rest. */
163export function splitLocation(line: string): { indent: string; at: string; rest: string } | undefined {
164 const m = LOCATION.exec(line)
165 return m ? { indent: m[1] ?? '', at: m[2] ?? '', rest: m[3] ?? '' } : undefined
166}
167
168const SECTION_TONE: Record<string, string> = { error: 'error', repair: 'warning', fix: 'warning', notes: 'suggestion', handles: 'inactive' }
169/** Lines a section may take before the long ones share the rest: what went wrong first. */
170const SECTION_CAP: Record<string, number> = { error: 8, repair: 8, fix: 2, notes: 4 }
171const PRIORITY = ['error', 'repair', 'fix', 'notes', 'output', 'log', 'handles']
172
173/** The part of the report after the call rows: each section under its heading, its lines coloured by what they say, about `room` lines in all. */
174export function resultBody({ Box, Text }: Elements, text: string, room = 14): RenderElement | undefined {
175 const all = text.split('\n')
176 const start = all.findIndex(l => l.startsWith('## '))
177 if (start < 0) return undefined
178 const sections: { name: string; lines: string[] }[] = []
179 for (const line of all.slice(start)) {
180 if (line.startsWith('## ')) sections.push({ name: line.slice(3).toLowerCase(), lines: [] })
181 else sections.at(-1)?.lines.push(line)
182 }
183
184 // What went wrong is shown first and whole (up to its cap); the rest share what is left, two lines each at least.
185 const take = new Map<(typeof sections)[number], number>()
186 let left = room
187 const order = [...sections].sort((a, b) => (PRIORITY.indexOf(a.name) + 1 || 99) - (PRIORITY.indexOf(b.name) + 1 || 99))
188 for (const s of order) {
189 const want = Math.min(s.lines.length, SECTION_CAP[s.name] ?? s.lines.length)
190 const n = Math.min(s.lines.length, Math.max(Math.min(want, left), 2))
191 take.set(s, n)
192 left = Math.max(0, left - n)
193 }
194
195 const row = (line: string, tone: LineTone) => {
196 if (line.startsWith('### ')) {
197 return (
198 <Text bold color="claude">
199 {line.slice(4)}
200 </Text>
201 )
202 }
203 const place = splitLocation(line)
204 if (place === undefined) {
205 return (
206 <Text wrap="truncate-end" color={tone}>
207 {line || ' '}
208 </Text>
209 )
210 }
211 return (
212 <Text wrap="truncate-end">
213 {place.indent}
214 <Text color="suggestion">{place.at}</Text>
215 <Text color={tone}>{place.rest}</Text>
216 </Text>
217 )
218 }
219
220 return (
221 <Box flexDirection="column" paddingLeft={2}>
222 {sections.map(s => {
223 // A value returned as named parts (\`### tsc\`, \`### tests\`) shows every part, each with its share of the lines.
224 const parts = partsOf(s.lines)
225 const takes = share(parts, take.get(s) ?? 0)
226 return (
227 <Box flexDirection="column">
228 <Text bold color={SECTION_TONE[s.name] ?? 'subtle'}>
229 {s.name}
230 </Text>
231 <Box flexDirection="column" paddingLeft={1}>
232 {parts.map((p, k) => {
233 const n = takes[k] ?? 0
234 let tone: LineTone
235 return (
236 <Box flexDirection="column">
237 {p.head !== undefined ? (
238 <Text bold color="claude">
239 {p.head}
240 </Text>
241 ) : null}
242 {p.lines.slice(0, n).map(line => {
243 tone = s.name === 'handles' ? undefined : toneOf(line, tone)
244 return row(line, s.name === 'error' ? 'error' : tone)
245 })}
246 {p.lines.length > n ? <Text color="inactive">{`… ${p.lines.length - n} more lines (ctrl+o)`}</Text> : null}
247 </Box>
248 )
249 })}
250 </Box>
251 </Box>
252 )
253 })}
254 </Box>
255 )
256}
257
258/** A section's lines split at its \`### name\` headings. */
259function partsOf(lines: readonly string[]): { head?: string; lines: string[] }[] {
260 const parts: { head?: string; lines: string[] }[] = [{ lines: [] }]
261 for (const line of lines) {
262 if (line.startsWith('### ')) parts.push({ head: line.slice(4), lines: [] })
263 else parts.at(-1)?.lines.push(line)
264 }
265 return parts.filter(p => p.head !== undefined || p.lines.length > 0)
266}
267
268/** `n` lines shared among the parts: two each at least, the rest in order. */
269function share(parts: readonly { lines: readonly string[] }[], n: number): number[] {
270 const takes = parts.map(p => Math.min(p.lines.length, 2))
271 let left = Math.max(0, n - takes.reduce((a, b) => a + b, 0))
272 for (let k = 0; k < parts.length && left > 0; k++) {
273 const more = Math.min((parts[k]?.lines.length ?? 0) - (takes[k] ?? 0), left)
274 takes[k] = (takes[k] ?? 0) + more
275 left -= more
276 }
277 return takes
278}
279runner/fold.mjs 64 lines1// Folds long command output before a model reads it: passing tests, build
2// chatter and repeated lines collapse into one count each; a line that looks
3// like a failure is never dropped. Shared by the cells runner and the mod.
4
5const PASSING = [
6 /^\s*test\s.+\s\.\.\.\s*ok\s*$/, // cargo test, unittest -v
7 /^\s*ok\s+\d+\b/, // TAP
8 /\bPASSED\b/, // pytest -v
9 /^\s*✓\s/, // jest, vitest
10 /^\s*[\w./\\-]+\.py\s+[.sSxX]+\s*(\[\s*\d+%\])?\s*$/, // pytest progress dots
11]
12
13const CHATTER =
14 /^\s*(Compiling|Checking|Downloaded|Downloading|Fresh|Blocking|Updating|Locking|Adding|Installing|Collecting|Requirement already satisfied|Building|Built|Resolved|Prepared|Audited)\b/
15
16const FAILURE =
17 /(error|fail|panic|traceback|exception|warning:|assert|not ok|✗|✕|^E\s|^\s*E\s{2,}|exit code [1-9])/i
18
19export const FOLD_ABOVE = 8_000
20
21export function fold(text, above = FOLD_ABOVE) {
22 if (typeof text !== 'string' || text.length < above) return text
23
24 const kept = []
25 let passing = 0
26 let chatter = 0
27 let repeated = 0
28 let previous
29
30 for (const line of text.split('\n')) {
31 if (FAILURE.test(line)) {
32 kept.push(line)
33 previous = line
34 } else if (PASSING.some(p => p.test(line))) {
35 passing += 1
36 } else if (CHATTER.test(line)) {
37 chatter += 1
38 } else if (line === previous && line.trim() !== '') {
39 repeated += 1
40 } else {
41 kept.push(line)
42 previous = line
43 }
44 }
45
46 const said = [
47 passing && `${passing} passing-test lines`,
48 chatter && `${chatter} build lines`,
49 repeated && `${repeated} repeated lines`,
50 ].filter(Boolean)
51
52 if (said.length === 0) return text
53 return `[folded ${said.join(', ')}; every failure line kept]\n${kept.join('\n')}`
54}
55
56/** A long text's head and tail, with one line saying what was cut. */
57export function cut(text, room, hint = '') {
58 if (text.length <= room) return text
59 const head = Math.floor(room * 0.65)
60 const tail = room - head
61 const gone = text.length - head - tail
62 return `${text.slice(0, head)}\n… ${gone} chars cut${hint} …\n${text.slice(-tail)}`
63}
64types/index.d.ts 35 lines1export type CellsCallState = 'running' | 'ok' | 'failed' | 'refused'
2
3/** One tool call a cell made, as its row draws. */
4export type CellsCall = {
5 tool: string
6 label: string
7 state: CellsCallState
8 startedAt: number
9 ms?: number
10 note?: string
11}
12
13/** A cell while it runs and after: what the transcript row draws. */
14export type CellsLive = {
15 startedAt: number
16 /** The program it runs: as sent, or as a fix or repair made it. */
17 code?: string
18 endedAt?: number
19 calls: CellsCall[]
20 error?: string
21 /** Runs on after its tool call ended: asked for, or the person sent a prompt while it ran. */
22 background?: boolean
23 /** The person interrupted it: no call after that one ran. */
24 stopped?: boolean
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 cells: {
30 /** By the tool_use_id of the run_cell call. */
31 live: Record<string, CellsLive>
32 }
33 }
34}
35