SLOPSHOPPER

cells

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…

newrowsguardprompttoolprocess
v0.4.3MITupdated 2026-10-16HarzerHeribert/claude-modpack/cells
A shopper browsing a rack in a slop shop
README

<img alt="claude-modpack: eight Claude Code mods, each measured on real tasks before it shipped, built to work together. statusline, smart-compact, delegate, lsp-plus, cells, eta, seek, route." src="assets/banner-light.svg" width="880">

<h3>Install in Claude Code</h3>

/plugin install modpack --marketplace HarzerHeribert/claude-modpack

Answer y, then run /modpack:setup to set everything up step by step.<br> Prefer doing it by hand? SETUP.md has every step.

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

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

Eight free add-ons that work on their own. One command installs them, and there is nothing new to learn.<br> Every number on this page was measured, including where it doesn't help.

Install · What you get · Honest numbers · The site: ideas and evidence · Lab notes · Questions

<img alt="Animated: the cost per job with and without the pack, as bars that grow: in a long session $0.44 against $0.29 (−33%; measured twice, −26% one day and −40% the next), bigger jobs in a fresh session $0.55 against $0.51 (−8%), quick questions in a fresh session $0.23 against $0.24 (+2%, about even: +5% one day, −4% the next); then four numbers: −33% cost per job in a long session, −21% steps for the same work, −22% cost of a feature a cheaper model builds from your plan, 224 of 224 results correct." src="assets/numbers.gif" width="880">

Why it saves

Every time Claude takes a step (reads a file, makes an edit, runs the tests), it reads your whole conversation again. That re-reading is most of what a session costs, and it grows the longer you work. So the cheapest step is the one Claude never has to take.

The pack takes out steps that need no thinking. Several edits and their test run as one step. A search for a name in your code comes back with the exact answer. And when you approve a plan, a cheaper model does the building while Claude checks the result. The work comes out the same; Claude just gets there in fewer round trips.

What you get

Always know where your plan stands

<img alt="A real Claude Code session: Claude starts the stress tests in the background; above the prompt a row shows them with a bar, a percentage and a finish time, climbing from 8% to 100%; under the prompt the status line shows the model, the context, the plan's 5-hour and weekly use and a green ready dot." src="assets/demo-band.gif" width="880">

One line under the prompt: the model, how full the conversation is, and how much of your plan's 5-hour and weekly limits you have used, with when each one resets. Long jobs get a progress bar and a finish time above the prompt. And when you step away, your session is kept ready, or tidied up before it goes cold, whichever costs less; the line tells you which.

These are the statusline, eta and smart-compact mods.

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

<img alt="A real Claude Code session: asked to add two docstrings and run the whole test suite, Claude runs one cell; an orange card shows the read, the two edits, the diff and the test run live, and the tests pass: 1990 passed." src="assets/demo-cells.gif" width="880">

Claude makes several edits and runs the tests as one step, and you watch each part happen in a live card. Every edit still goes through Claude Code's own edit tool: you see each change, and /rewind undoes it.

That's the cells mod.

A cheaper model builds what you approved

<img alt="A real Claude Code session: in plan mode Claude plans a new click.Duration parameter type; the plan is approved; Claude hands it to a Sonnet worker, whose orange card shows its steps live with a row above the prompt (hand-off 1, sonnet medium, requests and cost); then Claude checks the diff and reports: the new type works and the full suite passes." src="assets/demo-delegate.gif" width="880">

When you approve a plan, Claude hands the building to Sonnet, a cheaper model, and checks its work before reporting back. In our tests that made a feature 22% cheaper and new tests 26% cheaper, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 27% and 33%). You can watch the helper work in its card, and /delegate shows every step it took.

That's the delegate mod.

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

A small, fast classifier reads each prompt you send. Only when it is feature or test work is Claude told to hand it straight to delegate's worker: the worker reads the code and builds, delegate checks the result itself, and Claude reports after two steps of its own. On a TypeScript feature that made the job 51% cheaper and 42% faster than the pack alone, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 57%); everything else stays with Claude. The classifier is an outside service (TypeSafe's Jev or ScaleDown), so this is off until you turn it on and give it a key: from then on, the start of each prompt goes to it, in the US, to be classified.

That's the route mod.

Straight answers about your code

When Claude searches your code for a name, it gets the exact list of places that use it, from the same engine your editor uses, instead of reading through search results. Renames and "who calls this?" questions take fewer steps. New in 0.5.0: Go, Kotlin and Ruby code, shell scripts and Terraform. Run /lsp-plus:setup once in your project to turn it on.

That's the lsp-plus mod.

Fewer, leaner steps when Claude looks around

Before Claude changes anything, it looks around your code, and everything it reads stays in the conversation, read again with every later step. Beside the first prompt you send in a session, and again as the session grows, the pack passes on your preference to look around in few, lean steps: the searches it can already name in one go, the lines that matter rather than whole files, nothing read twice. Against the pack without it, that made jobs 12% cheaper in a fresh session, 16% on a TypeScript library and 10 to 16% in long sessions, at the same quality. It runs on your machine and sends nothing anywhere.

That's the seek mod.

You stay in control

<img alt="A real Claude Code session: /mods lists the pack's mods (recorded with the six of 0.4.0, before route and seek) and opens a pane of cards; the status line is turned off and disappears from under the prompt, then turned on again." src="assets/demo-mods.gif" width="880">

/mods lists the eight and turns any of them on or off, right away, with a click or by typing, such as /mods statusline off. When something can't fully work on your computer, the pack tells you once, with the command that fixes it. When another plugin's permission or notification hook hangs (Warp's does outside the Warp terminal), the pack stops waiting for it after 10 seconds, so Claude Code can't freeze, and tells you which one it was. It keeps itself up to date (or only lets you know, if you prefer).

That's the modpack plugin, which installs the other eight.

Install

In Claude Code, type:

/plugin install modpack --marketplace HarzerHeribert/claude-modpack

Answer y to add the marketplace, then pick where to install it. That installs all eight. Then, in the project you work in:

/modpack:setup

It checks your machine, asks once what you want on (route stays off unless you say yes), sets each mod up in order, including the language servers for the languages your project uses, and checks that each one works. Done. /mods shows what's on.

By hand instead: SETUP.md has every step.

Each mod installs on its own the same way, for example /plugin install smart-compact --marketplace HarzerHeribert/claude-modpack. lsp-plus, delegate and cells build on Claude Code's LSP tool, which needs a language server for each language: Language servers lists them.

Honest numbers

We measured the pack on Claude Opus 5.5 doing seven real jobs in an open-source project (click), from a one-line lookup to building a feature, on two days, 13 and 15 October. Every result was checked by hidden tests. The same jobs ran with the pack and without it, and we compared what they cost at API prices.

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

<img alt="Cost per task, plain Claude Code against the modpack, in a fresh session and in a session already holding 216k tokens." src="assets/cost-light.svg" width="880">

<img alt="Model requests per task, plain Claude Code against the modpack: 3.2 against 5.6 in a session already holding 216k tokens." src="assets/requests-light.svg" width="880">

<img alt="Cost per task, Opus alone against a plan handed to a worker: feature work 22% less, tests 26% less." src="assets/delegate-light.svg" width="880">

How we measured, every run's record, and the scripts to run it all again are in benchmarks/, starting with SUITE.md. What we tried that did not make it, a 10,000-star code graph among it, is in the lab notes (as Markdown). Not measured: what smart-compact saves you while you're away (worked out from prices, not tested) and Windows.

Questions

Will Claude's work get worse? No. In every run we graded, 730 in all, the result was correct with the pack and without, and every one was graded again for this release. The pack changes how many steps Claude takes to get there, and in our tests the results came out the same.

Is it safe? It's open source (MIT), so every line is here to read. Code that Claude runs inside a cell runs in a sandbox with no network access. Edits go through Claude Code's own edit tool, so your usual permission prompts and /rewind still apply. The pack sends nothing anywhere except a once-a-day check on GitHub for a new release, which you can turn off, and, only if you turn route on, the start of each prompt to the classifier you chose (TypeSafe or ScaleDown).

Does the pack add anything to my prompts? Your prompt itself is never changed. Beside it, Claude can get a short note in your name: seek's setting to look around in few, lean steps (with the first prompt of a session, and again after a compaction), and, only if you turn route on, the order to hand feature or test work to the worker; delegate gives the same order when you approve a plan of 20 lines or more. A subagent Claude starts gets cells' and seek's settings at the end of its task, since your prompts never reach it. /mods seek off and /mods route off stop theirs; subagents: false in cells or seek stops theirs for subagents.

I'm on a Pro or Max plan. Does this help me? You don't pay per job, but every step counts against your 5-hour and weekly limits. We measured cost at API prices, not plan usage, so we can't promise a number. Fewer, lighter steps should leave more of your limits, and the status line shows exactly where you stand.

Does it work in the desktop app? Yes, cards, progress and the status line included. One limit: in auto mode the desktop app doesn't let an add-on start a helper model, so plans aren't handed off there (turn route off there too); in manual mode, or in the terminal, they are. Windows hasn't been tested yet.

How do I turn it off? /mods switches each part off or on, right away. /plugin removes them.

Were any of these numbers wrong? Yes, the hand-off ones. On 16 October we found that our bench had counted a worker's output only as each of its responses began, so every saving that involved a worker came out too large: a planned feature was −27% and is −22%. They are corrected here, the old and new number side by side in CORRECTION-1016.md. The pack-against-plain numbers had no workers and stand.

What does it cost? Nothing. It's free and open source.

The mods in depth

One line under the prompt, drawn in Claude Code's own hint row so the mode indicator and the status share it:

⏵⏵ auto mode on       Opus 5.5 xhigh   412k ━━━━━━──────   5h 34% ↺2h14   7d 12% ↺3d   ● ready · cold in 44m
  • Its own segments: the model and its effort, the context against the auto-compact point (a highlight sweeps along it while Claude works), and how much of the plan's 5-hour and 7-day windows is used, with when each resets: quiet under half, plain past it, amber from 80%.
  • Other mods' segments: a mod calls $.statusline.set({ id, priority, parts, short?, side? }) and lists statusline among its dependencies. A part is text, a countdown ({ until, prefix?, warnMs? }, counted by the line) or a spinner ({ spin: true }), so a mod sends a segment when it changes, not every second. $.statusline.remove({ id }) takes it off.
  • Narrow terminals drop the least important first: the model, then the windows' resets, the windows, the gauge, and another mod's countdown before its own words.
  • Published pages: a pill on the left lists the pages this session published with the Artifact tool.
  • Phone and IDE: where no line is drawn, the same line is the session's status text, in a session the phone or IDE hosts. A terminal session watched over Remote Control (/rc) from the desktop app, the web or the phone gets no mod drawing and no status text as of Claude Code 2.1.296.
OptionDefault
enabledtruefalse turns the mod off; /mods does it for all of the pack.
placementlinetail: appended dim to Claude Code's hint. band: above the prompt. off.
limitsalwayshalf: a plan window only once it is half used. off: never.
animatetrueThe highlight along the gauge.

An idle session's prompt cache expires after its TTL: 1 hour on a subscription, 5 minutes in overage or on an API key. The next prompt then writes the whole context to the cache again, at 1.25x (5 min) or 2x (1 h) the input price. For a 400k-token Opus 5.5 session on the 1-hour cache:

OptionCost
Let it expire, re-cache on return$3.20
One keep-alive (re-read the cached prefix; a read resets the TTL)$0.09
Compact before it expires~$0.87, plus detail lost to the summary

A few minutes before the cache expires (2.5 min on the 1-hour TTL, 45 s on the 5-minute one), the smart strategy:

  1. Sends keep-alives while they cost less than compacting would, counting lost detail as compactPenaltyUsd. It stops after maxIdleHours.
  2. Then compacts while the cache is still warm.
  3. Leaves small sessions alone: below minSavingsUsd of re-cache cost, or below minCompactTokens of context, it never compacts.

It never compacts while the prompt holds a draft or agents are running; it sends a keep-alive to hold the cache instead. If the machine sleeps past the deadline, it marks the cache expired and does nothing. A failed action is retried twice, 20 s apart. The TTL is detected from the plan and corrected from the cache hits and misses it observes.

On statusline's line it says whether the session is ready to prompt or has gone cold: ● ready, · cold in m:ss (amber in the last five minutes), · compacts in … when an idle compaction is near, ○ cold, or what it is doing (compacting, keeping warm, paused). /cache shows the live figures and plan; /cache pause, /cache resume and /cache keepalive control it.

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

Prices are list prices, so on a subscription they are API-equivalent estimates, not a bill.

Every request of the main model re-reads its whole context: at 600k tokens on Opus 5.5 that is about $0.12 a step before any work is done. A worker reads only its own small context. Claude calls hand_off with a task that stands alone, its kind (read: changes no file; mechanical: the task spells out every change; build: something is left to work out), the symbols or file:line places it touches (focus), and for edits a check command. The kind picks the worker, so Claude has no model to weigh: Haiku at low effort for read and mechanical work, Sonnet at medium for build work.

  1. Where it reaches. For each focus entry the mod asks the language server for references and callers (with lsp-plus, the docs that mention it come along) and tells the worker.
  2. The worker. A subagent, read-only for read, with Edit and Write for mechanical and build. The call waits for it inside the tool call (one request of the main model) and returns the report with the evidence; with background it returns at once and the report arrives as a message. A follow-up within 50 minutes goes to the same worker, its reading still in its context.
  3. The evidence. The diff against a git snapshot taken at the start, however the worker edited; the check command, run by the mod itself; references outside the changed files; what the work cost against the same steps on the main model.
  4. The review. The main model judges the diff and the check, not the report.
  5. The log. Each hand-off goes to ~/.claude/mods/delegate/log.jsonl; /delegate sums it up and opens a side pane. While a worker runs, its card is drawn like a cell (the task, a row per step, the state and cost), and with eta loaded it is a row above the prompt too.

Measured on 120 Opus runs, all correct: a feature or tests cost 23–35% less handed off; lookups, traces, explanations and small edits cost more. Opus does not hand off unless told to, so ask for it as an instruction: "hand the tests to a worker". An order the mod gives works as well as yours (8 of 8 runs, 22–26% less), so when you approve a plan of 20 lines or more, delegate gives that order: plan in plan mode, approve, and a Sonnet worker builds it. Corrected 16 Oct: worker costs were undercounted; these were 27–37% and 27–33%.

A verified hand-off (0.3.0). The worker ends its report with a VERIFY block: the command that runs only its new tests, and what it left open. d

Source 11 files
hooks/register.tsx 671 lines
1import { 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}
671
runner/lsp.mjs 161 lines
1// 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}
161
runner/repair.mjs 149 lines
1// 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}
149
hooks/bridge.ts 152 lines
1// 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
152
hooks/guide.ts 56 lines
1// 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)`
56
hooks/isolation.ts 109 lines
1// 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}
109
hooks/nudge.ts 144 lines
1// 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}
144
hooks/subagents.ts 42 lines
1// 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."
42
hooks/view.tsx 279 lines
1// 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}
279
runner/fold.mjs 64 lines
1// 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}
64
types/index.d.ts 35 lines
1export 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