This repo is used to patch Claude Code. I'd like to find a way of achiving the same thing using the new Mod API. Please take a look
This repo is used to patch Claude Code. I'd like to find a way of achiving t…
I'll start by understanding what this repo patches, then load the mod-authoring skill to see what the Mod API offers.
Bashgit log --oneline | head -30 && ls -la && cat README* 2>/dev/null | head -150✓
$ {
"command": "git log --oneline | head -30 && ls -la && cat README* 2>/dev/null | head -150",
"description": "Survey repo history, layout, and README"
}
9ea65d5 Draw the status line from frames cc-candybar's daemon pushes
9d88e65 Say that a refused connect does not prove the owner is dead
1524c4e Let the verdict's written order be the order that runs
ec6262f Read the flag cluster the way the CLI reads it
98cbe87 Say what the terminal predicate cannot answer, instead of implying it can
bb8b265 Judge a print run by whether it started, not by whether stdout saw a byte
662581b Give the connection a deadline it owns, and the probe one at all
01488e9 Bind the socket first, and put the deadline back when the answer goes out
1523bb9 Import fs where the module imports everything else
e571e33 Detach the handlers on the ending that skips the verdict
fa6c908 Make the switch server own its socket, or say it does not
e196bf7 Ask what the invocation is, not whether there is a terminal
ddf3f6a Place the reference copy: make its imports resolve where it now lives
1eefa83 Keep the craft-gate patch here, where it was very nearly deleted instead
ad35f90 Give the host the launcher that was supposed to read its verdicts
39588e7 Say what the lookbehind proves, not what it flatters
1378912 Prove the splice lands in the gate's own body, not just after a brace
48778d9 Address the high review, and fix a comment a mutation sweep caught
80b5fdf Give the model the goal tool the shipped build already carries
1698cb2 Fix six defects a review found, and cover the module that had no tests
27c6fb0 Host Claude Code under node: the module graph, the Bun surface, the seams
total 120
drwxr-xr-x@ 8 bmf staff 256 Oct 19 12:48 .
drwxr-xr-x@ 3 bmf staff 96 Oct 19 12:48 ..
-rw-r--r--@ 1 bmf staff 73 Oct 19 12:48 .git
-rw-r--r--@ 1 bmf staff 329 Oct 19 12:48 package.json
-rw-r--r--@ 1 bmf staff 3698 Oct 19 12:48 README.md
drwxr-xr-x@ 10 bmf staff 320 Oct 19 12:48 reference
-rw-r--r--@ 1 bmf staff 45212 Oct 19 12:48 SEAMS.md
drwxr-xr-x@ 38 bmf staff 1216 Oct 19 12:48 src
# cc-extra
Run the shipped Claude Code binary under node, with a `Bun` global assembled from node primitives,
and splice its sources at named seams on the way in. The installed binary is never modified.
```sh
node src/launch.js "$(readlink -f "$(which claude)")" [args...]
npm test
```
`launch.js` is the entry point: it runs a list of plans and keeps the first that boots, with stock
`claude` as the floor, so opting into a hosted session can never leave you with a broken one. To
watch a boot instead of using one, run the host directly — its reports land on stderr rather than on
the launcher's private descriptor:
```sh
node --experimental-vm-modules src/bun-host.mjs "$(readlink -f "$(which claude)")" --version
```
`--experimental-vm-modules` is mandatory there; without it the runtime dies on
`vm.SourceTextModule is not a constructor` and it looks like the whole approach is broken. The
launcher passes it for you.
## Why node and not Bun
Bun offers no JavaScript injection for a standalone binary. `BUN_INSPECT_PRELOAD` and
`BUN_INSPECT_CONNECT_TO` are inert, and `BUN_INSPECT` opens a listener that cannot bind breakpoints
inside the startup window. Node's V8 inspector is the only route in. These are measured dead ends —
`SEAMS.md` records them so they are not paid for twice.
## Layout
| | |
|---|---|
| `src/bun-graph.js` | reads the module table out of the installed binary |
| `src/embedded-fs.js` | presents the embedded modules as a read-only filesystem |
| `src/bun-surface.js` | the `Bun` global, assembled from node primitives |
| `src/cell-segmenter.js` `src/yaml.js` `src/toml.js` `src/sockets.js` `src/image.js` | the members that global is built from |
| `src/recording.js` | how the surface answers "that member isn't here" |
| `src/bun-runtime.mjs` | links and evaluates the graph under `vm.SourceTextModule` |
| `src/seams.js` `src/seam-plan.js` `src/seam-registry.js` | what to splice, where it resolves to, and who reports back |
| `src/boot-channel.js` `src/boot-guard.js` | the line protocol the host reports on, and crash naming during boot |
| `src/launch.js` | runs the plans, forms the verdict the host refuses to form, falls back to stock |
| `src/switch-channel.js` | the unix socket a live session is asked to do something over |
| `src/bun-host.mjs` | the wiring: one hosted boot, reporting on the channel and deciding nothing |
## What it splices today
One seam, `propose-goal`. The shipped build already carries `ProposeGoal`, the tool that lets the
model set a session goal by enqueueing `/goal <condition>` — already written, already in the base
tool array, and kept out of the offered set by a server-side feature gate that is off. The seam
forces that one gate true, so a hosted session offers the model the goal tool and shows the
`Claude-proposed goals` row in `/config`; a stock session does neither.
A patch is added to `src/seams.js` as a value — an anchor and the statement to insert — and nothing
else changes shape.
## The one doctrine
Every member of the `Bun` surface is a real implementation or it is **absent**. Never a stub that
returns `undefined` or a plausible constant: a present-but-wrong member keeps the process alive
while corrupting what it touched, and is invisible. An absent one is reported on the boot channel
by name.
`SEAMS.md` is the mechanism, the measurements behind it, and the history of what was tried and
failed. `reference/` is the one patch that was built on all this before the `propose-goal` seam —
a live craft switch — kept for reading. Two of its files import a `laws-excise.js` that exists in no
repo any more, so they are read and not run; its seam and that seam's tests do run.Skill✓
{
"skill": "plugin-authoring"
}Launching skill: plugin-authoringBase directory for this skill: /private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring
WHERE TO WRITE IT. Write each mod in its own child folder, /Users/bmf/.claude/dev-mods/35af6be8-e4c9-4793-bbbc-20df60bf3d79/<mod-name>/, three files written directly:
.claude-plugin/plugin.json:{ "name": "<mod-name>", "version": "0.1.0", "description": "<one line>" }hooks/hooks.json:{ "modules": ["./register.tsx"] }, one path, relative to that filehooks/register.tsx(or.ts): the hooks module,export const register: Register = (on, options) => { ... }, the type imported from'claude-code'
A mod that keeps values in $.state has a fourth file, types/index.d.ts: its type contract, declaring each value in interface PluginState under the mod's name, named in plugin.json as "types": "./types/index.d.ts". The module imports its value types from '../types', and claude plugin validate holds every $.state key the module names to that contract.
WHERE THE TYPES ARE. The engine writes them; there is no command to run. Before the mod has loaded: /private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts, this build's API and built-in tools, written as this skill loaded (this process's own folder: after a restart the next load of this skill writes and names a new one). Once the engine has loaded the mod (from this mods folder, or a --plugin-dir folder it hot-reloads), <mod folder>/.claude-plugin/types/ holds the same for the editor: claude-code/index.d.ts, the API; claude-code-tools/index.d.ts, so e.tool === "Bash" narrows e; claude-code-mcp/index.d.ts, the MCP tools connected when the mod last reloaded; each dependencies plugin's contract; and a tsconfig.json the mod's own extends, so tsc -p <mod folder> type-checks it. The API file carries every event's input and result, every noun and method on $ with its doc comment and an example, and every element's props, in about 14,000 lines: grep it for the name at hand ('tool.call', Pane: {, export type ToolCallResult) and read the declaration it lands on.
WHAT HAPPENS WHEN THE TURN ENDS. Loading this skill through the Skill tool or its slash command starts the engine's watch on the folder that holds the mod folders. The first file written under it makes the engine ask the person, once, right then, while the turn goes on: "Enable hot reloading for this session?", with How does this work? first, then Enable for this session and Not now. That question is the switch, and the person alone answers it: no permission mode, rule or hook does. On Enable for this session the folder joins the session's plugin folders and the mod loads when the turn ends, whole, and each later edit reloads it when the turn that made the edit ends. The answer reaches you as a notice at the start of your next turn, one of: enabled, with what the load came to; declined (the mods are written and load the next time this session starts; the person can ask for the question again); still open (a new prompt from the person takes the question down, and the engine asks again when that turn ends); or off, with the reason (nobody could be asked, as under claude -p; the question got no pick three times; an organization's policy; an untrusted workspace). A process that restarted loads an enabled folder again by itself; otherwise its watch starts the next time this skill loads, and a manifest already in the folder raises the question when that turn ends.
A reload is a fresh load of the module: register runs again and session.start fires again. Values in $.state (the session's) and $.store (across sessions) are the host's and stay; the module's own variables start over.
THIS SESSION is drawn in a terminal. A pane the person opened seats at any width; one opened unasked seats from 144 columns, or 110 as $.ui.open's doc says, and waits below that. Where hot reloading is off as nobody could be asked, claude --plugin-dir <mod folder> starts a session with the mod loaded.
A mod in one paragraph
on(event, matcher?, hook) adds a hook, and every hook is ($, e, next): $ is the engine interface, each call spelled noun then method; e is the event's input, a plain frozen value; next(e) runs the plugins beneath and then the engine's own behaviour, resolving to the event's result. A hook that returns without next answers for itself; next({ ...e, x }) rewrites what the rest sees. The module runs in an environment of its own, with no DOM and no Node: $ reaches everything outside it. JSX compiles against the global h, and the elements come from the drawing surface's own table, const { Box, Text, Button } = $.ui.resolve(e), where e.surface is terminal, desktop, vscode or mobile.
From the ask to the shape
Each example, in /private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/examples/, is one complete hooks module; with the two JSON files above, and its contract where it has one, it is a mod that loads, validates and type-checks on this build.
| The person asks for | What it is | Shown in |
|---|---|---|
| a pane, panel, sidebar, live view | $.ui.open({ id, title }), drawn by a ui.render hook on { component: 'Pane', requestId: id }; it resolves { isPlaced }, whether a surface draws it now |
pane.tsx, its contract pane-state.d.ts |
| a band or row above the prompt | a ui.render hook on { component: 'AbovePrompt' } returning a tree, or next(e) with nothing to show |
band.tsx, its contract band-state.d.ts |
| a status line entry | $.ui.status(text) from any hook; undefined clears it |
tool-call.ts |
| a toast | $.ui.toast(text) from any hook |
band.tsx |
| block, rewrite or react to a tool call | on('tool.call', { tool }, hook): return { deny }, call next({ ...e, ... }), or await next(e) and act on the result |
tool-call.ts |
| change or react to a prompt | on('prompt.submit', hook): next({ ...e, text }) |
band.tsx |
| change the system prompt | on('prompt.compose', hook): answer next(e)'s { sections } with one added last (scope: 'session'), replaced or dropped |
its doc in the types |
| play a sound | $.audio.play({ asset }) from any hook, the asset a file of the mod's |
its doc in the types |
| a slash command | $.command.register({ name, description }) in session.start, answered by a command.run hook returning { text } |
pane.tsx |
| values a drawing reads | atom(ref, initial), read($, atom) while drawing, update($, atom, fn) from a handler or another event; the write redraws the readers; each value declared in the contract |
pane-state.d.ts |
| work on a timer, a tool the model calls, a subagent type, model calls, files, processes | $.clock, $.tool, $.agent, $.model, $.fs, $.process |
/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/reference.md |
Checking it and reading what the engine refused
claude plugin validate <mod folder> reads the manifest and the module's source the way the engine will, and reports what the module hooks and calls and all the engine would refuse. Type-check the mod: with its types laid, tsc -p <mod folder>; else (the first write, claude -p, an SDK host), with the tsconfig.json from the types file's header, kept outside the mod folder, its include naming that file and the mod's hooks. Write at least one *.test.ts for the behaviour asked for and run claude plugin test <mod folder>. A finished mod is at its full path, loaded or not by the hot-reloading answer: enabled, it is loaded when the turn ends, with nothing to run; any other answer, it is not loaded.
While the session hot-reloads a plugin folder the transcript carries one dim line naming the plugin, the event and the reason when a hook fails or a module does not load, and says when a hook's tree did not validate and the engine drew its own instead: <plugin>: ui.render (<Component>) refused: <reason>; the engine drew its own, <plugin> being the one plugin whose hook could have drawn the tree, hooks otherwise. In any other session those lines go to the debug log alone, as <plugin>: <line>. The debug log (claude --debug) carries a line for every occurrence and every result the engine refused; in every session such a tree has a line there beginning ui.render (<Component>): a hook returned a tree that does not validate, followed by the reason.
/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/reference.md is the long form: the full event list, streaming events, ui.render in depth, $.state contracts, --plugin-dir and CLAUDE_CODE_PLUGIN_DIRS while developing, userConfig options, what a test holds, and Sharing a mod, the GitHub road, done when the person asks, or says yes where this skill offers it: .claude-plugin/marketplace.json in its repository and, in the README, /plugin install <mod> --marketplace <owner>/<repo>.
Bashcat src/seams.js && ls src reference && git show --stat HEAD | head -40 && sed -n 1,80p SEAMS.md✓
$ {
"command": "cat src/seams.js && ls src reference && git show --stat HEAD | head -40 && sed -n 1,80p SEAMS.md",
"description": "Read seam definitions, layout, and latest commit"
}
// seams.js — the fragments of the app's own source the injector anchors on, and what it splices in.
//
// This file is the ONLY place in the tree that knows anything about Claude Code's internals, and a
// seam deliberately knows as little as possible: one fragment of source, and one statement to insert
// before it. Everything else — which module carries it, at what offset — is resolved from the
// installed binary at launch by seam-plan.js. There is no chunk name here and no version literal,
// because both change every release and a map that changes weekly is a map that is already lying.
//
// WHY THESE ANCHORS SURVIVE MINIFICATION. Bun renames every module-scope binding — the gate function
// below was renamed between two releases a week apart — but it cannot rename a PROPERTY, and it
// cannot rewrite a STRING LITERAL the app sends to a server. Property names and literals come
// through verbatim. Anchoring on one of those rather than on an offset, a variable, or a line number
// is what makes this hold across releases without a table someone maintains by hand. No minified
// name is spelled here: the one place any of them appears is the dated measurement below.
'use strict';
// The global a spliced statement calls. The host installs it before a single module is evaluated,
// which is why inserted code can call it unguarded — an absent registrar is unrepresentable rather
// than survivable. [LAW:no-defensive-null-guards]
const REGISTRAR = '__CC_EXTRA_SEAM__';
// THE PROPOSE-GOAL SEAM — give the model the goal tool the shipped build already carries.
//
// `/goal` has two shipped forms. The one a person types is a slash command; the one the MODEL can
// reach is a real tool, `ProposeGoal`, which enqueues `/goal <condition>` on the session's own
// message queue — the shipped way a tool runs a slash command. That tool is already built, already
// in `getAllBaseTools()`'s array, and already excluded from the offered set by its own `isEnabled()`.
//
// MEASURED on the shipped 2.1.280 (a dated observation, not an input — nothing below reads it, and
// every name in it is minified and already different from 2.1.278's). `isEnabled()` is five
// conjuncts, and on an ordinary interactive local session exactly one of them is false:
//
// isEnabled(){ if(Te()||Zn())return!1; if(At())return!1; if(!Pjt())return!1;
// let e=pze(); if(e==="disabled")return!1; return E(e),!0 }
//
// Te() — the session is NOT interactive. false here: it is.
// Zn() — the workspace is remote. false here: it is local.
// At() — `s9()==="bg"`, a background job. false here: it is a terminal session.
// pze() — the `modelProposedGoals` setting, `"auto"` when absent, and only `"disabled"` refuses.
// Pjt() — `x("tengu_propose_goal",!1)`, a SERVER-SIDE feature gate, default false. This one.
//
// WHY THE GATE AND NOT THE TOOL. That gate is the app's single authority on whether the goal feature
// exists at all: the tool's `isEnabled` reads it, and so does the `/config` panel, which shows the
// `modelProposedGoals` row only when the gate is on. Splicing the gate turns the feature on once, at
// its source, and both consumers agree by construction. Splicing `isEnabled` instead would leave a
// session whose goal tool works and whose settings panel denies the tool exists — two maps of one
// fact, drifting the moment either is read. [LAW:one-source-of-truth] [LAW:single-enforcer]
//
// WHY NOT CONFIGURATION. Feature values resolve environment override -> config override -> remote
// payload -> disk cache -> default, and on a shipped build the first two are compiled to
// `return null` / `return`. When the remote payload is non-empty but lacks the key the resolver
// returns the default and SKIPS the disk cache entirely, so seeding the cache does nothing — and
// writing it would make this a second writer to a file the gate client owns and rewrites on its own
// schedule. The gate is a runtime value; the only honest place to change it is at runtime.
//
// WHY THE ANCHOR IS SHAPED THE WAY IT IS. `"tengu_propose_goal"` is a key the app sends to a feature
// service, so it survives minification untouched and occurs exactly ONCE across all 1,991 js modules.
// The rest of the anchor is not there to find the site — the literal already did that — it is there
// to PROVE the site is a statement whose value is the gate's answer, because the only insertion that
// could go wrong here is one absorbed into a larger expression that inverts it:
//
// the `function <name>(){` pins the brace to a FUNCTION BODY, so the spliced `return true;`
// lookbehind is provably the first statement that function runs, and the leading `[;}\n]` pins that
// declaration to a STATEMENT POSITION — a function declaration, never an expression
// whose value an enclosing operator consumes. Neither half is ornamental. A lookbehind
// of just `\{` admits `if(W()){return P(...)}else{...}`: it resolves to one site,
// splices cleanly, and leaves a gate that still answers the server on the other branch
// — resolved to the wrong place and still running, which is the one failure this whole
// anchor exists to prevent. Without `[;}\n]`, `return!function R(){return P(...)}()`
// admits the same way, and there an operator inverts what the splice returns. The
// `function ` keyword carries its own weight: `\w+ ` in its place admits the valid class
// members `get G(){`, `async G(){` and `static G(){`.
// IT DOES NOT PROVE NESTING DEPTH — no regex counts braces. A declaration inside another
// function body still matches whenever `;`, `}` or a newline precedes it, and
// `seams.test.js` pins that as the known limit rather than implying it away. Proving top
// level would take a parser, and it would refuse the one nesting that actually occurs: a
// Bun `__esm`/`__commonJS` chunk wrapper, where the nested declaration IS the gate and
// splicing into it is correct. The residual is a hand-written
// `function outer(){…function R(){gate}return!R()}`, which minification does not produce
// from this source. The lookbehind is variable-length, which V8 supports and most
// engines do not; node is the only host here, so that is a fact rather than a bet.
// `\s+` a `return` whose operand is a call has to be separated from it, so requiring the
// space is what tells `return P(...)` apart from a call to a function NAMED
// `returnP(...)`. `\s*` would match the second and splice a return into a function
// that never asked the gate anything. (A negated call, `return!P(...)`, is refused by
// the identifier class that follows, not by this.)
// the quote the closing `"` pins the exact key, so a future `"tengu_propose_goal_v2"` refuses
// instead of resolving onto a gate that means something else.
//
// Every one of those is a REFUSAL when the shape moves, and a refusal costs the user the hosted
// session and never a working one: the host reports `no-module-carries-the-seam` and the caller falls
// back to stock claude. A seam that resolved to the wrong place is the failure worth engineering
// against, because that one still runs. [LAW:no-silent-failure]
//
// A patch enters this list as a VALUE — an anchor and the statement to insert — and every other part
// of the mechanism is already unconditional: the plan is resolved, the transform is applied and the
// registrar is installed on every boot, whether this list holds one seam or ten. Adding a patch is
// adding data, never a new path. [LAW:dataflow-not-control-flow]
// THE STATUSLINE-PUSH SEAM — draw the status line from frames cc-candybar's daemon pushes
// (statusline-push.js says why and how).
//
// MEASURED on the shipped 2.1.299 (dated, not an input). The status line controller is one class;
// it draws through a PRIVATE emitter, and runs the command from an arrow inside its own method:
//
// #R(e,n,r,s){ … await _Yo({signal:e, executeCommand:()=>{ let M=this.#t, z=kYo({…});
// if(!e.aborted)this.#b(wYo(z)); return this.#e.execute(z,e,r) },
// getCommandLength:()=>…, pendingResultLogRef:this.#g, onResult:(M)=>this.#S(M), … }) }
// #S(e){ if(this.#n.text===e)return; this.#n={text:e}; …; this.#o.emit() }
//
// `z` is the statusline JSON, `this.#e.execute(z,e,r)` runs the command, and `#S` is what its output
// is drawn through. A private name can only be written inside the class body, which is exactly where
// this splice lands: `this` is the controller and `this.#S` is reachable. The anchor CAPTURES the
// pieces the insert needs — the deps field, the payload, the run's other two arguments, the emitter —
// so no minified name is written here; each is read off the release being spliced.
//
// THE SHAPE OF THE INSERT. A splice only adds text before its anchor, and the anchor is the command
// call itself, so the original call stays after the insert as the right operand of `||`:
//
// return <registrar>.statusLine(this, z, (t)=>this.#S(t), ()=>this.#e.execute(z,e,r)) || this.#e.execute(z,e,r)
//
// `statusLine` is async, so it always returns a promise and the right operand never runs; the run
// goes through the thunk the registrar is handed, once.
//
// WHY THE ANCHOR IS SHAPED THE WAY IT IS. `.execute(` alone occurs all over the graph; what makes
// this one the status line's is what follows it — the `getCommandLength:` key of the same object
// literal, then that literal's `onResult:` arrow into a private method — keys the minifier cannot
// rename. `(?<=return )` pins the anchor to the operand of the arrow's `return`, so the `||` lands in
// a return expression and nowhere an operator could absorb it. The `[^]{0,400}?` between
// `getCommandLength:` and `onResult:` spans the two keys between them in this literal (about 120
// characters in 2.1.299); bounded, so it cannot reach into another object literal's `onResult:`.
// Resolved to exactly one site in every installed release, 2.1.267 through 2.1.299.
const STATUSLINE_PUSH = {
name: 'statusline-push',
anchor:
/(?<=return )this\.(?<deps>#[\w$]+)\.execute\((?<payload>[\w$]+),(?<rest>[\w$]+,[\w$]+)\)(?=\},getCommandLength:[^]{0,400}?onResult:\([\w$]+\)=>this\.(?<emit>#[\w$]+)\()/,
insert: `globalThis.${REGISTRAR}.statusLine(this,$<payload>,(t)=>this.$<emit>(t),()=>this.$<deps>.execute($<payload>,$<rest>))||`,
};
const SEAMS = [
{
name: 'propose-goal',
anchor: /(?<=[;}\n]function [A-Za-z_$][\w$]*\(\)\{)return\s+[A-Za-z_$][\w$]*\("tengu_propose_goal"/,
insert: 'return true;',
},
STATUSLINE_PUSH,
];
module.exports = { REGISTRAR, SEAMS };
reference:
live-switch.js
live-switch.test.js
README.md
SEAMS-craft-gate.md
seams.js
seams.test.js
switch-request.js
switch-request.test.js
src:
boot-channel.js
boot-channel.test.js
boot-guard.js
boot-guard.test.js
bun-graph.js
bun-graph.test.js
bun-host.mjs
bun-runtime.mjs
bun-runtime.test.mjs
bun-surface.js
bun-surface.test.js
cell-segmenter.js
cell-segmenter.test.js
embedded-fs.js
embedded-fs.test.js
image.js
image.test.js
launch.js
launch.test.js
recording.js
seam-plan.js
seam-plan.test.js
seam-registry.js
seam-registry.test.js
seams.js
seams.test.js
sockets.js
sockets.test.js
statusline-push.js
statusline-push.test.js
switch-channel.js
switch-channel.test.js
toml.js
toml.test.js
yaml.js
yaml.test.js
commit 9ea65d556703aef41b4317fb85b42c68901b8bde
Author: Brandon Fryslie <[redacted email address]>
Date: Thu Oct 15 23:52:01 2026 -0600
Draw the status line from frames cc-candybar's daemon pushes
A statusline-push seam wraps the status line controller's command call: the registrar subscribes to the daemon for the payload's session and feeds each frame to the controller's private emitter, answering with the newest frame while frames arrive and with the command's output otherwise. Seam inserts may now name their anchor's captures, so no minified name is written into seams.js. Measured live on 2.1.299: one hosted session's bar changed 16 times a second.
src/bun-host.mjs | 14 +++-
src/seam-plan.js | 35 ++++++++--
src/seam-plan.test.js | 35 ++++++++++
src/seams.js | 41 ++++++++++++
src/seams.test.js | 83 ++++++++++++++++++++++++
src/statusline-push.js | 153 ++++++++++++++++++++++++++++++++++++++++++++
src/statusline-push.test.js | 140 ++++++++++++++++++++++++++++++++++++++++
7 files changed, 492 insertions(+), 9 deletions(-)
# Hosting Claude Code under node — mechanism, measurements, and history
The shipped `claude` is a Bun-compiled standalone binary. This repo reads its embedded module graph,
serves that graph under node with a `Bun` global built from node primitives, and splices the sources
at named seams on the way in. Nothing on disk is ever modified.
Entry point, and the only command needed to run it:
```
node --experimental-vm-modules src/bun-host.mjs ~/.local/share/claude/versions/<version> [args...]
tr "\036" "\n" < /tmp/host.err # the boot channel, when stderr was redirected there
```
`--experimental-vm-modules` is MANDATORY. Without it the runtime dies on `vm.SourceTextModule is not
a constructor`, which reads like the whole approach is broken when nothing is wrong but the flag.
> Bundle symbol names are minified and **drift every Claude Code release**. Resolve seams by the
> invariant anchors below (strings, body fingerprints, stable env/CLI surfaces), never by a
> minified name. Nothing here is re-derived by hand after an update: `seam-plan.js` resolves every
> anchor against the whole module graph at launch and refuses on a miss, so drift shows up as a boot
> refusal rather than as silent misbehaviour.
## HISTORY — the channel that was: Bun's inspector, opened by an env var
**Removed 2026-09-05.** Nothing here opens an inspector, sets `BUN_INSPECT`, or evaluates anything
in the session's global scope, and `inspect-eval.js` was deleted with the relaunch apparatus it
served. The measurements in this section and the next are kept because they are facts about the
shipped binary, not about the apparatus — the next person to point an inspector at Claude Code would
otherwise pay for them a second time.
Claude Code ships as a Bun-compiled standalone Mach-O (`~/.local/share/claude/versions/<v>`, a
symlink target of `claude`). It honors `BUN_INSPECT`: set it to a ws URL and the process opens a
WebKit/JSC inspector on that socket. **No code patch — just an env var** — which is why the
channel was stable across weekly minified releases and met the ticket's "lightweight, shippable in
the plugin" bar. For the compiled binary the ws URL you set IS the endpoint; there
is no `http://host:port/json/list` discovery (that exists only for `bun --inspect-brk bundle.js`).
## HISTORY — primitives measured over that channel (shipped 2.1.226)
These four were measured, and each is a property of the binary rather than of the client that asked.
They were exercised through `inspect-eval.js`, deleted 2026-09-05; reproducing them today means
writing a ws client again. The command was
`BUN_INSPECT="ws://127.0.0.1:9933/dbg?wait=1" claude --help &` followed by
`node inspect-eval.js ws://127.0.0.1:9933/dbg --probe`.
1. **Inspector opens.** `BUN_INSPECT="ws://127.0.0.1:<port>/dbg?wait=1"` opens a LISTEN socket on
`<port>` and blocks the process at entry until a debugger sends `Runtime.runIfWaitingForDebugger`.
(`BUN_INSPECT_WAIT`/`BUN_INSPECT_BRK` alone did not block in probes; the `ws://…?wait=1` form does.)
2. **In-process global eval.** After `Runtime.enable`, `Runtime.evaluate` returns live values from
the running process — verified reading `process.pid`, `globalThis`, `globalThis.fetch` (a
function), and `argv = ["bun","/$bunfs/root/src/entrypoints/cli.js", …]` (the bundle runs from
Bun's virtual FS). Zero-dep client: Node's global `WebSocket` (Node ≥ 22).
3. **Live interactive TUI, not just `--help`.** All of the above works against a real interactive
session (launched in a tmux PTY under `BUN_INSPECT`), and the session keeps running after the
eval — the channel does not disturb the TUI.
4. **stdin-injection drives built-in commands.** `process.stdin.push(Buffer.from("/context\r"))`
evaluated in-process made the `/context` panel render in the live session. The TUI reads stdin
in pull mode (paused ReadStream, one `readable` listener, zero `data` listeners), so `.push()`
is the right primitive and a trailing `\r` submits. This runs commands through the session's
OWN dispatch path, which was the reason the reload was once expected to ride Claude's own
mechanisms (`/compact`, `/clear`) rather than reach into minified internals.
`inspect-eval.js` packaged primitives 1–4 as `connect`, `evaluate`, `injectStdin` and `probe`. By the
end its only remaining job was ending the session so the launcher could relaunch it, and it was
deleted with the launcher on 2026-09-05.
## The container format: Bun's module table, read from the installed binary
Packaged as `bun-graph.js`. Reproduce: `node bun-graph.js "$(readlink -f "$(which claude)")"`.
**The NUL-delimited record scan is dead — it cannot read what ships today.** 2.1.226 embedded its
JavaScript as ONE CommonJS module, recoverable by scanning for `\0<path>\0<contents>` records.
2.1.258 embeds an ESM module graph instead: 1,639 code-split chunks
(`/$bunfs/root/chunk-<hash>.js`) around one entry — 1,640 js modules in all — plus native,
compressed and text assets: 1,818 modules, 41.9MB of
contents. A module's NAME and its CONTENTS are no longer adjacent in the file (names sit near offset
69.8M, contents near 156M–188M), so the delimiter scan has nothing to key on; run it against 2.1.258
and it returns `no-contents-record-for-path`. The entry point moved as well: not
`/$bunfs/root/src/entrypoints/cli.js` any more, but `/$bunfs/root/cli`.Bashcat src/statusline-push.js && cat reference/README.md && sed -n 1,80p reference/live-switch.js && grep -n "craft\|SEAM\|anchor" reference/seams.js | head -40✓
$ {
"command": "cat src/statusline-push.js && cat reference/README.md && sed -n 1,80p reference/live-switch.js && grep -n \"craft\\|SEAM\\|anchor\" reference/seams.js | head -40",
"description": "Read statusline-push and the reference craft switch"
}
// statusline-push.js — draw the status line from the frames cc-candybar's daemon pushes, instead of
// only the output of the command Claude Code reruns.
//
// WHY. Claude Code reruns the status line command once a second at best (`refreshInterval` is
// clamped to a whole second, and every tick passes a 300 ms debounce that restarts itself), so an
// animated bar moves at one frame a second. cc-candybar's daemon can push a session's frames as it
// draws them — up to thirty a second while the bar moves, and the moment a click changes it — over a
// `subscribe` request on its socket (cc-candybar candybar-animation-wbxz.jmm.usl). This module holds
// one such subscription per status line controller and hands each frame to the controller's own
// emitter, the one the command's output goes through, so the bar is drawn by the code that always
// drew it.
//
// THE COMMAND KEEPS RUNNING. The daemon draws a session's frames from the latest statusline JSON it
// was sent, and the command is what sends it: Claude Code builds that JSON and runs the command on
// its own schedule, as it always has, so the data stays exactly as fresh as a stock session's. What
// changes is what the command's own answer is used for. While frames arrive, the answer handed back
// to the controller is the newest pushed frame, not the command's output — that output is the bar
// as it stood when the command ran, an instant older than the frame already on screen, and showing
// it would step the animation backwards once a tick. While no frames arrive (no daemon listening,
// one too old to know `subscribe`, a session it has no data for yet), the command's output is the
// answer, exactly as in a stock session: both come from the same daemon's one renderer, so this is
// not a fallback to a different source. [LAW:one-source-of-truth]
//
// [LAW:effects-at-boundaries] the socket is passed in (`connect`), so the suite drives this with no
// daemon. The one thing this module knows of cc-candybar's internals is its wire contract, below.
'use strict';
// cc-candybar's wire contract (its src/daemon/protocol.ts): every frame a 4-byte big-endian length
// and a UTF-8 JSON body; a request names the protocol version it speaks; the daemon listens on the
// socket `CC_CANDYBAR_SOCKET` names, else the one derived from the user's uid. A daemon on another
// version answers VERSION_MISMATCH, which this module reports and stops on — never guesses past.
const PROTOCOL_VERSION = 3;
const HEADER_BYTES = 4;
const MAX_FRAME_BYTES = 16 * 1024 * 1024;
const daemonSocketPath = (env, uid) => env.CC_CANDYBAR_SOCKET || `/tmp/cc-candybar-${uid}/socket`;
const encodeFrame = (value) => {
const body = Buffer.from(JSON.stringify(value), 'utf8');
const header = Buffer.alloc(HEADER_BYTES);
header.writeUInt32BE(body.length, 0);
return Buffer.concat([header, body]);
};
// Calls `onFrame` with each complete frame's parsed body; `onError` once, on a frame that cannot be
// one (too large, not JSON), after which it reads nothing more.
function frameReader(onFrame, onError) {
let buf = Buffer.alloc(0);
let broken = false;
return (chunk) => {
if (broken) return;
buf = Buffer.concat([buf, chunk]);
while (buf.length >= HEADER_BYTES) {
const len = buf.readUInt32BE(0);
if (len > MAX_FRAME_BYTES) { broken = true; onError(new Error(`frame too large: ${len}`)); return; }
if (buf.length < HEADER_BYTES + len) return;
const body = buf.subarray(HEADER_BYTES, HEADER_BYTES + len).toString('utf8');
buf = buf.subarray(HEADER_BYTES + len);
let frame;
try { frame = JSON.parse(body); } catch (e) { broken = true; onError(e); return; }
onFrame(frame);
}
};
}
// What Claude Code does to a status line command's stdout before it draws it (MEASURED in 2.1.299's
// command runner: trim, trim each line, drop the empty ones; nothing left is no status line). A
// pushed frame goes through the same, so it draws exactly as the command's output would.
const asStatusText = (output) =>
output.trim().split('\n').flatMap((line) => line.trim() || []).join('\n') || undefined;
// [LAW:types-are-the-program] One controller's subscription, as state: none open; one open and not
// yet carrying frames; one carrying frames, with the newest; or refused for good by the daemon (a
// version it does not speak, a request it does not know), which is not retried for that session.
// { kind: 'closed' } | { kind: 'open', socket } | { kind: 'live', socket, text } |
// { kind: 'refused', reason }
function createStatusLinePush({ connect, socketPath, report }) {
// WEAK, so a controller the app drops takes its subscription's bookkeeping with it. The socket
// itself is closed by the stream ending or by the session moving; a controller that stops running
// its command and is collected leaves its socket to close when the daemon or the process does.
const feeds = new WeakMap();
const open = (feed, sessionId) => {
const socket = connect(socketPath);
feed.sessionId = sessionId;
feed.state = { kind: 'open', socket };
const mine = () => feed.state.socket === socket;
const end = (why) => {
if (!mine()) return;
feed.state = { kind: 'closed' };
socket.destroy();
report(`statusline-push sid=${JSON.stringify(sessionId)} ended: ${why}`);
};
socket.on('connect', () => {
socket.write(encodeFrame({ v: PROTOCOL_VERSION, kind: 'subscribe', sessionId }));
report(`statusline-push sid=${JSON.stringify(sessionId)} subscribed`);
});
socket.on('data', frameReader(
(resp) => {
if (!mine()) return;
if (resp && resp.ok === true && typeof resp.output === 'string') {
const text = asStatusText(resp.output);
feed.state = { kind: 'live', socket, text };
feed.emit(text);
return;
}
const code = resp && resp.code;
// A daemon that does not know the request, or speaks another version, will refuse it the
// same way next tick: stop asking for this session, and say why once.
if (code === 'BAD_REQUEST' || code === 'VERSION_MISMATCH') {
feed.state = { kind: 'refused', reason: `${code}: ${resp.error}` };
socket.destroy();
report(`statusline-push sid=${JSON.stringify(sessionId)} refused: ${code}: ${resp.error}`);
return;
}
// A frame the daemon could not draw: the command's own answer shows how it failed, as in a
// stock session, until a frame draws again.
feed.state = { kind: 'open', socket };
report(`statusline-push sid=${JSON.stringify(sessionId)} frame failed: ${code}: ${resp && resp.error}`);
},
(e) => end(`unreadable frame: ${e.message}`),
));
socket.on('error', (e) => end(e.message));
socket.on('close', () => end('closed by the daemon'));
};
// What the spliced statement calls each time the controller runs its command: the controller, the
// statusline JSON it built, its own emitter, and the run itself. Always a promise, so the splice's
// `||` never reaches the original call it leaves in place.
async function statusLine(controller, payload, emit, run) {
const feed = feeds.get(controller) || { sessionId: null, state: { kind: 'closed' }, emit };
feeds.set(controller, feed);
feed.emit = emit;
// [LAW:dataflow-not-control-flow] the session the subscription is for is read off every payload,
// so a new session id (after /clear) moves it, and a closed one reopens on the next run.
const sessionId = payload.session_id;
const moved = feed.sessionId !== sessionId;
if (moved && feed.state.socket) {
const old = feed.state.socket;
feed.state = { kind: 'closed' };
old.destroy();
}
if (moved || feed.state.kind === 'closed') open(feed, sessionId);
const ran = await run();
return feed.state.kind === 'live' ? feed.state.text : ran;
}
return { statusLine };
}
module.exports = { PROTOCOL_VERSION, asStatusText, createStatusLinePush, daemonSocketPath, encodeFrame, frameReader };
# reference/ — the craft-gate patch, kept for reading
The toolkit is `../src`. This directory is the one real patch that was built on it, kept whole
because it is the only worked example of what a seam is *for* — and because it was very nearly lost
when a copy of it in another repo was deleted rather than moved.
What it did: a PreToolUse guard denied loading a second, incompatible craft skill into a session.
This turned that flat DENY into a four-choice switch — reject, tombstone, rewind+summarize,
rewind+discard — enacted against the **live** conversation, with no restart.
| File | What to read it for |
| --- | --- |
| `seams.js` | A real seam: one property-name anchor (`restoreMessageSync`, a class field so it runs at construction), one statement spliced before it. `../src/seams.js` carries the seam that ships today; this is the shape a second one takes. |
| `seams.test.js` | How an anchor is tested without the binary — the lookbehind that excludes `this.restoreMessageSync` and the props-object key is the whole trick. |
| `live-switch.js` | Crossing from an on-disk representation (a tree of records with `parentUuid`) to the live in-memory one (a flat array). The two are not the same data structure, which is why the crossing is a module. |
| `switch-request.js` | Composition only: the policy is the guard's, which conversation is the registry's proof, the plan is `live-switch`'s. Nothing re-decides anything. |
| `SEAMS-craft-gate.md` | The history of this patch — the frontier it closed, the seam anchors it carried, the status ledger. The generic mechanism it was built on is `../SEAMS.md`. |
## What runs here, and what does not
`live-switch.js` and `switch-request.js` import `laws-excise.js` — the transcript surgery and the
`decide()` gate — and that file is not in this repo. It is not anywhere else either: the injector
directory it came from in `promptctl/laws` is deleted. So these two cannot be run, cannot be tested,
and are not on `npm test`'s `src/*.test.js` glob. They are read.
`seams.js` and `seams.test.js` have no such dependency and do run — `node --test
reference/seams.test.js`, 16 cases, green. Their imports point at `../src/`, where this toolkit's
own modules live.
Two traps for anyone reading these as a template:
- `seams.js` announces on `__LAWS_SEAM__`; the host installs `__CC_EXTRA_SEAM__` (`../src/seams.js`).
Lift the anchor, not the constant, or the splice resolves cleanly and then throws at the first
controller the live session builds.
- A review on 2026-09-23 found three real defects in the two files that do not run: `rewind_summarize`
stubs `conflicts[0]` while the rewind is anchored on `summarizeTo`, and nothing checks they are the
same message; the `rewind_discard` "nothing before it" guard tests a raw index against a store that
carries holes, so a leading hole truncates the conversation and reports success; and a throw from
`consumeOffer()` escapes after the live store has already been cut, leaving the offer enactable a
second time. They are recorded, not fixed — nothing executes this code, and a fix to a file no
process loads is a change that only looks real.
// live-switch.js — turn a switch decision into the operations that enact it on a LIVE conversation.
//
// WHY THIS IS NOT laws-excise's job. That module owns the policy and the DISK surgery, and it stays
// the only place that decides anything: `decide()` is called here unchanged and its verdict is taken
// whole. What this file adds is the crossing from the on-disk representation to the in-memory one,
// which is a real boundary because the two are not the same data structure:
//
// on disk — a TREE. Records carry parentUuid, branches can be severed, and bookkeeping records
// (last-prompt, mode, file-history-snapshot) have no uuid at all.
// live — a FLAT ARRAY. No parentUuid anywhere; the app cuts it with lastIndexOf. It also holds
// transient `progress` records that are never persisted.
//
// MEASURED on a hosted 2.1.258 session: 27 disk records against 18 live messages, and every disk
// record carrying a uuid was present live. So UUID is the only thing that crosses, and `decide()`'s
// `conflictIndices` — which are LINE NUMBERS — must never be used on this side. They name positions
// in a file, and using them against the live array would silently address the wrong messages.
//
// THE OFF-BY-ONE THAT MATTERS. The two rewinds are not symmetric with their disk counterparts:
// rewindTo(lines, anchor) keeps THROUGH the anchor (disk; the anchor survives)
// rewindConversationTo(msg) keeps UP TO msg (live; msg itself is dropped)
// So a live rewind that must keep the craft-load message is given the message AFTER it, and a live
// rewind that must drop the craft-load message is given the craft-load message itself. Both spellings
// produce the same conversation as the disk path, and getting this backwards is a one-message error
// that would look almost right.
//
// [LAW:effects-at-boundaries] planLiveSwitch is pure — snapshot in, plan out — so every case above is
// testable against synthetic snapshots with no binary, no session and no terminal. applyLiveSwitch
// is the one unit that touches the running app.
// [LAW:parse-dont-validate] a plan is proof that every uuid it PLACES is present live; the applier
// takes message OBJECTS, never uuids, so it cannot be handed one that does not resolve.
'use strict';
const { stubText, SWITCH_ACTIONS } = require('../scripts/laws-excise.js');
// One spelling of "a controller's live messages", shared with seam-registry, whose suite asserts
// callers cannot spell it differently. [LAW:one-source-of-truth]
const { snapshotOf } = require('../src/seam-registry.js');
// Every way a decision can fail to become a live plan, named — these reach the caller that asked for
// the switch, so they are contract. [LAW:no-silent-failure]
const UNPLANNABLE = {
unknownChoice: 'not-one-of-the-four-switch-choices',
notLive: 'a-message-the-decision-named-is-not-in-the-live-conversation',
noTemplate: 'the-live-conversation-has-no-user-message-to-shape-a-summary-from',
summaryMissing: 'rewind_summarize-was-asked-for-without-a-summary',
notStubbable: 'a-craft-load-message-does-not-carry-replaceable-text',
nothingBefore: 'the-craft-load-is-the-first-live-message-so-a-discard-has-nowhere-to-land',
};
// The app's own value for "the user picked a message and rewound to it", which is what a craft switch
// is. It is used rather than a string of our own because it is the one the app's source already
// carries: an unrecognised source would take the caller through a validator this file cannot see, and
// the two behavioural branches downstream of it (`auto_restore_cancel`) are ones we specifically do
// NOT want. The telemetry it produces is therefore the app's, and honest about what happened.
const REWIND_SOURCE = 'message_selector';
// DERIVED, never re-listed. bin/laws-switch already takes its accepted choices from this same
// table, so a fifth action added there would be accepted by the CLI and refused here as
// `unknownChoice` — the CLI and the enactment silently disagreeing about what a switch is.
// [LAW:one-source-of-truth]
const CHOICES = Object.keys(SWITCH_ACTIONS);
const unplannable = (reason, detail) => ({ ok: false, reason, detail });
// The shape of a switch that did nothing. Exported because `reject` reaches it without a controller
// — it deliberately never proves ownership — so it cannot go through applyLiveSwitch to get here.
// One spelling, so a new field cannot arrive for three of the four choices and not the fourth.
// [LAW:one-source-of-truth]
const noopResult = () => ({ ok: true, rewound: false, tombstoned: 0, changed: false });
// The live messages a craft load produced, by uuid. `decide()` names the OLDEST conflicting load in
// `rewind.summarizeTo`; that uuid is the one that crosses, and the tombstone targets every live
// message whose uuid appears in the decision's own set.
// It enforces its own precondition rather than trusting a caller's: an absent uuid would otherwise
// match the first record that also has none, and this function is what PROVES a message's identity —
// the same ambiguity seam-registry's ownerOf refuses, which only screens the anchor and not the rest
// of the conflicting set. [LAW:parse-dont-validate]
const liveIndexOf = (snapshot, uuid) => (uuid ? snapshot.findIndex((m) => m && m.uuid === uuid) : -1);
// A tombstoned copy. A NEW object, never a mutation: the store is read through getSnapshot and the UI
1:// seams.js — the fragments of the app's own source the injector anchors on, and what it splices in.
18:// the field anchored below occur exactly once across all 1,818 embedded modules, 1,788 characters
25:// is the anchor rather than the method next to it: a method body only runs if the user rewinds, and
28:// Deliberately NOT `globalThis.__LAWS_SEAM__?.controller(this)`. The host installs the registrar
31:// cannot switch crafts. It fails at construction instead, which the boot self-check names and the
33:const REGISTRAR = '__LAWS_SEAM__';
35:const SEAMS = [
40: anchor: /(?<![.\w$])restoreMessageSync\s*=\s*(?=\()/,
45:module.exports = { REGISTRAR, SEAMS };Thinking
I've identified the repo's three key features: propose-goal for forcing the ProposeGoal tool gate, statusline-push for 30fps daemon frame rendering, and the reference-only craft switch for rewinding/tombstoning conversations. Next, I'll check what the Mod API exposes for each of these.
BashT=/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring; wc -l $T/types/claude-code.d.ts; ls $T/examples; grep -n "^ *\(readonly \)\?[a-z][A-Za-z]*: {$\|^ [a-z]*: \(Readonly<\)\?{" $T/types/claude-code.d.ts | head -80✓
$ {
"command": "T=/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring; wc -l $T/types/claude-code.d.ts; ls $T/examples; grep -n \"^ *\\(readonly \\)\\?[a-z][A-Za-z]*: {$\\|^ [a-z]*: \\(Readonly<\\)\\?{\" $T/types/claude-code.d.ts | head -80",
"description": "Survey Mod API nouns in the types file"
}
21987 /private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts
band-state.d.ts
band.tsx
pane-state.d.ts
pane.tsx
tool-call.ts
2345: plugin: {
2359: ui: {
2617: model: {
2687: audio: {
2723: mcp: {
2767: session: {
2962: turn: {
2984: prompt: {
3062: tool: {
3115: command: {
3156: config: {
3187: telemetry: {
3225: agent: {
3282: fs: {
3410: store: {
3443: state: {
3485: clock: {
3528: http: {
3557: process: {
3622: settings: {
3647: env: {
3873: terminal: {
3898: desktop: {
3916: mobile: {
3933: vscode: {
4787: tool: {
4792: command: {
4796: config: {
4800: prompt: {
4810: skill: {
4813: attribution: {
4816: agent: {
4820: session: {
4831: telemetry: {
4835: turn: {
4840: ui: {
6636: readonly to: {
8445: outputStyle: {
9625: props: {
9699: press: {
9725: props: {
9759: press: {
9774: props: {
9805: press: {
9859: press: {
9879: client: {
9910: raster: {
9929: image: {
10789: message: {
15342: readonly subtle: {
16943: organization: {
16947: plugin: {
16964: sends: {
16985: list: {
17154: plugins: {
17308: usage: {
17410: type: {
17438: docs: {
17460: instances: {
17572: read: {
17638: instances: {
17653: quickstart: {
17692: instances: {
17725: files: {
17729: skipped: {
17737: files: {
17741: skipped: {
17760: threads: {
17781: comments: {
17817: watch: {
17840: unwatch: {
17925: files: {
17933: skipped: {
17972: results: {
18039: assets: {
18047: usage: {
18082: assets: {
18095: files: {
18183: pin: {
18190: shared: {BashT=/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts; sed -n 2340,2620p $T✕
$ {
"command": "T=/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts; sed -n 2340,2620p $T",
"description": "Read $.plugin and $.ui API surface"
}
This session is isolated in the worktree /Users/bmf/code/cc-extra/.claude/worktrees/flickering-knitting-star, but this command runs sed with a value computed at runtime (the variable T) where an option may stand (a value that is not double-quoted, or whose first character is matched or computed rather than spelled out, may begin with -; put -- before it) in a plain command, so what it runs cannot be shown not to be git. Refusing to run it — a worktree-isolated session's git operations must target its own worktree. Run the plain command from /Users/bmf/code/cc-extra/.claude/worktrees/flickering-knitting-star.BashT=/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts; sed -n 2767,3160p $T✕
$ {
"command": "T=/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts; sed -n 2767,3160p $T",
"description": "Read $.session, turn, prompt, tool, command APIs"
}
This session is isolated in the worktree /Users/bmf/code/cc-extra/.claude/worktrees/flickering-knitting-star, but this command runs sed with a value computed at runtime (the variable T) where an option may stand (a value that is not double-quoted, or whose first character is matched or computed rather than spelled out, may begin with -; put -- before it) in a plain command, so what it runs cannot be shown not to be git. Refusing to run it — a worktree-isolated session's git operations must target its own worktree. Run the plain command from /Users/bmf/code/cc-extra/.claude/worktrees/flickering-knitting-star.BashT=/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts; sed -n 3485,3660p $T; grep -n -i "goal\|socket\|unix\|feature gate\|rewind\|statusline\|status line" $T | head -60✕
$ {
"command": "T=/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts; sed -n 3485,3660p $T; grep -n -i \"goal\\|socket\\|unix\\|feature gate\\|rewind\\|statusline\\|status line\" $T | head -60",
"description": "Read clock/http/process APIs and grep for goal, socket, rewind, status line"
}
This session is isolated in the worktree /Users/bmf/code/cc-extra/.claude/worktrees/flickering-knitting-star, but this command runs sed with a value computed at runtime (the variable T) where an option may stand (a value that is not double-quoted, or whose first character is matched or computed rather than spelled out, may begin with -; put -- before it) in a plain command, so what it runs cannot be shown not to be git. Refusing to run it — a worktree-isolated session's git operations must target its own worktree. Run the plain command from /Users/bmf/code/cc-extra/.claude/worktrees/flickering-knitting-star.Read/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts✓
Read/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts✓
Read/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts✓
Thinking
I've mapped the repo's three patches (goal-tool gate, 30fps status-line push, reference craft switch) and am now checking the Mod API types to see which are reachable by mods, then will look at the status-line render component and tool/feature hooks.
BashF="/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts"; grep -n -i "StatusLine\|'tool.describe'\|\"tool.describe\"\|goal\|feature\|gate\|rewind\|truncate\|session.rewind\|AbovePrompt\|PromptFooter\|component: '" -- "$F" | head -80✓
$ {
"command": "F=\"/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts\"; grep -n -i \"StatusLine\\|'tool.describe'\\|\\\"tool.describe\\\"\\|goal\\|feature\\|gate\\|rewind\\|truncate\\|session.rewind\\|AbovePrompt\\|PromptFooter\\|component: '\" -- \"$F\" | head -80",
"description": "Grep Mod API for status line, tool.describe, goal, rewind"
}
467: * When to delegate to this agent: the line the model reads in its listing
749: * pre-ship mandate and the commit gate's deny: the same event at each.
765: * footer, the mandate's or the commit gate's sentence naming the exemption.
864: * Reasoning effort applied to the current turn. Same shape as StatusLineCommandInput.effort. Present for hooks that fire within a tool-use context (PreToolUse, PostToolUse, Stop, SubagentStop, etc.) on a model that supports the effort parameter; absent for session-lifecycle hooks and models without effort support.
1696: * `'truncate-end'` cuts it at the edge with an ellipsis; Text's other
1699: wrap?: 'wrap' | 'truncate-end';
2468: * remotely); an unpaired surrogate half in it is drawn as U+FFFD
2679: * const kind = await $.model.classify(e.text, ["bug", "feature"])
2754: * hooks: its gate is absent, yet its server connects. Ship it as managed.
2940: * with no first-party credential (a 3P provider, a gateway, no login).
3184: * Analytics: first-party rows and feature marks for Anthropic, and records
3207: * Marks one use of a feature as the CLI's own feature events do, the
3214: * @param entry the feature, how its use went, why, and its properties
3217: * await $.telemetry.mark({ feature: "learn_page", kind: "sad",
3255: * @param spec `name`, `description` (when to delegate), `prompt` (its
3570: * bytes (`isStdoutTruncated`, `isStderrTruncated` say when cut)
4118: * a `Pane` body or the `AbovePrompt` band, at its edges too; `$.ui.scroll`.
4289: * loaded. Cached until `$.ui.invalidate("tool.describe")`: a changed
4295: * on("tool.describe", { tool: "Bash" }, ($, e) => ({ ...e, description }))
4297: * on("tool.describe", { tool: "Monitor" }, pin) // {...e, isDeferred: false}
4299: 'tool.describe': ToolDescribeInput;
4365: * Fires when one use of a feature is marked (`$.telemetry.mark`), as the
4366: * CLI's own feature events mark one; always for `anthropic`, no `to`.
4685: 'tool.describe': ToolDescribeResult;
5548: * Focused through the same ring as `Button` (`abovePrompt:focus`); while it
5649: export type InvalidatableEventName = RenderEventName | 'prompt.section' | 'prompt.context' | 'prompt.attachment' | 'tool.describe' | 'command.describe' | 'config.describe';
6280: * @remarks On Bedrock, Vertex, Foundry or behind a gateway, text marked here
6488: * @remarks On Bedrock, Vertex, Foundry or behind a gateway the text is found
7310: * credential (a third-party provider, a cloud gateway, no login or key).
7497: * An unpaired surrogate half (a `.slice()` through an emoji) is drawn as
7763: * `tier` and `uses` are the host's reading of the module, what a gate holds
8123: * const { stdout, isStdoutTruncated } = await $.process.run(["git", "log"])
8132: * bytes (4 MiB), the rest read and dropped (`isStdoutTruncated`).
8141: * bytes (4 MiB), the rest read and dropped (`isStderrTruncated`).
8148: isStdoutTruncated: boolean;
8153: isStderrTruncated: boolean;
9168: * hook and provenance gate.
9605: export type RenderComponent = 'AskUserQuestion' | 'UserMessage' | 'AssistantMessage' | 'ToolUse' | 'ToolResult' | 'ToolGroup' | 'ToolProgress' | 'CommandOutput' | 'Spinner' | 'TurnDuration' | 'InfoNotice' | 'SessionMode' | 'PromptHint' | 'AbovePrompt' | 'Pane';
10500: AbovePrompt: {
10526: * the five hold the `[-]`. A wider tree wraps or truncates as its Text
10640: * Cells across. A tree wider than this wraps or truncates, as its Text
10712: export type Selected<P extends string> = P extends '*' ? EventName : P extends `!${infer Negated}` ? Exclude<EventName, Selected<Negated>> : P extends `${infer Prefix}.*` ? Extract<EventName, `${Prefix}.${string}`> : Extract<EventName, P>;
10735: * Focused through the same ring as `Button` (`abovePrompt:focus`); keys reach
11597: * Which window: `five_hour`, `seven_day`, or a Claude gateway's
11636: * isOwnViewer, data, isTruncated }` as the page's viewers wrote it.
12036: * `seven_day`, a gateway's `spend_limit`); empty when none has a reading.
12822: * The input of `telemetry.mark`: one use of a feature, as the CLI's own
12823: * feature events mark one.
12829: * Which feature was used, by its snake_case name.
12831: feature: string;
12847: * How one use of a feature went, as the CLI's own feature events count it.
13031: wrap?: 'wrap' | 'end' | 'middle' | 'truncate' | 'truncate-start' | 'truncate-middle' | 'truncate-end';
13577: * listed); an unpaired surrogate half in it is drawn as U+FFFD.
14236: * The render component the `Client` was drawn in (`AbovePrompt`, `Pane`,
14326: export type UiFocusComponent = 'Pane' | 'AbovePrompt';
14338: * Which site: a `Pane` body or the `AbovePrompt` band.
14427: * The render component the element was drawn in (`AbovePrompt`,
14504: * The render component the `Client` was drawn in (`AbovePrompt`, `Pane`,
14773: export type UiScrollComponent = 'Pane' | 'AbovePrompt';
14785: * Which site: a `Pane` body or the `AbovePrompt` band.
14935: * The render component the element was drawn in (`AbovePrompt`,
15643: * `.not` negates; `.resolves` and `.rejects` check what a promise settles
16282: * $.ui.mount({ plugin: 'notes', surface, component: 'Pane', props: PANE })
16299: * `AbovePrompt`, `ToolUse`, ...): what a `ui.render` matcher narrows on.
16327: * The checks negated.
16481: /** Reasoning effort for this agent. Set this ONLY when the user, or instructions such as CLAUDE.md or a skill, explicitly ask that this agent or delegated work run at a specific effort level, never on your own judgment; otherwise omit it and the agent runs at its usual effort. Ignored for subagent_type: "fork": a fork runs at your own effort. */
16489: /** Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo. "remote" launches the agent in a remote cloud environment (always runs in background; availability is gated). */
16636: /** The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?" */
16741: /** report_validate: aggregate from the final .render-check.json — counts only, no component names or paths. */
16921: ProposeGoal: {
16924: /** Whether to ask the user for approval before the goal is set. Defaults to true — an approval dialog is shown. Set false ONLY when the user's own words in this conversation stated this outcome as what they want; the goal is then set directly, with a visible notice in the transcript, and the user can clear it with /goal clear. */
16997: /** The notification body. Keep it under 200 characters; mobile OSes truncate. */
17112: /** A 5-10 word label for your own transcript row (not transmitted — the recipient previews the first line of `message`). Truncated to 200 characters rather than rejected. */
17293: /** The subagent's whole report, never truncated, with a backslash inserted into text that imitates harness markup, such as a system tag (`<system-reminder>`), a `[harness:` line or a turn marker (`Human:` at the start of a line) */
17565: truncated?: boolean
18218: truncated?: boolean
18422: truncated?: boolean
19075: truncated?: boolean
19279: truncated?: boolean
19932: truncated?: booleanRead/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts✓
BashF="/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts"; grep -n "export interface ToolDescribeInput\|export interface ToolDescribeResult\|export type ToolDescribeResult\|export type ToolDescribeInput" -- "$F"; sed -n '16915,16930p' -- "$F"; sed -n '10495,10560p' -- "$F"✕
$ {
"command": "F=\"/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts\"; grep -n \"export interface ToolDescribeInput\\|export interface ToolDescribeResult\\|export type ToolDescribeResult\\|export type ToolDescribeInput\" -- \"$F\"; sed -n '16915,16930p' -- \"$F\"; sed -n '10495,10560p' -- \"$F\"",
"description": "Find ToolDescribe types, ProposeGoal input, AbovePrompt props"
}
Exit code 1
13361: export type ToolDescribeInput = {
13398: export type ToolDescribeResult = {
sed: --: No such file or directory
/** memory file paths where this procedure was observed */
evidence?: string[]
/** The complete SKILL.md exactly as it should be saved: frontmatter plus the full body. When the user saves, the body below the frontmatter becomes the skill's entire instructions and the name and description come from the fields above; other frontmatter keys are not kept. For an improvement this replaces the existing skill's SKILL.md entirely, so read that skill's current SKILL.md first and include everything worth keeping, not only the changes. */
skillMd: string
}>
}
ProposeGoal: {
/** The completion condition to propose, written so a separate evaluator can verify it from the conversation (e.g. "all tests in test/auth pass (bun test exits 0)"). At most 500 characters — the user must be able to read the whole condition in the approval dialog. */
condition: string
/** Whether to ask the user for approval before the goal is set. Defaults to true — an approval dialog is shown. Set false ONLY when the user's own words in this conversation stated this outcome as what they want; the goal is then set directly, with a visible notice in the transcript, and the user can clear it with /goal clear. */
ask_user?: boolean
}
PublishPlugin: {
/** The plugin's folder on this machine: the one that holds .claude-plugin/plugin.json, or a mod's hooks/ where there is no manifest yet. */
path: string
/** Where to publish. The one destination is the library of the organization the session is signed in to. */
sed: --: No such file or directory
* (ctrl+x ctrl+a, `[-]`) or focuses it (a click, ctrl+x tab): an Input
* types, a Button arms the hotkeys, a tree taller than it scrolls.
*
* Raised on the terminal and desktop surfaces only.
*/
AbovePrompt: {
/**
* True while a survey holds the band; a hook yields to it. Read-only.
*/
hasSurvey: boolean;
/**
* True while a model turn is running. Read-only.
*/
isWorking: boolean;
/**
* Rows the band may take: in fullscreen, what the bottom slot has left
* above the prompt; otherwise the terminal's height. Read-only.
*
* That slot is capped at half the terminal's rows, the prompt's included.
* A tree of at most `maxRows` rows shows whole; a taller one scrolls in a
* window of `scroll.bodyRows`.
*
* @remarks A bare digit arms only the hotkeys of the Buttons wholly inside
* that window, never one scrolled out of view.
*/
maxRows: number;
/**
* Cells the band's tree is laid out in, none under a mark of the engine's:
* its column's width less the engine's five at the right end. Read-only.
*
* The column is the terminal, or the transcript's beside a docked `Pane`;
* the five hold the `[-]`. A wider tree wraps or truncates as its Text
* props say; size a table to it rather than to `viewport.columns`.
*/
bodyColumns: number;
/**
* The band's window over a tree taller than `maxRows`: engine-owned,
* moved by the wheel, and by the person's keys while the band is focused.
*
* `bodyRows` is `maxRows` less the `n more` row. Read-only.
*/
scroll: SiteScroll;
/**
* Which transcript is on screen above the band: the main conversation's
* (no `agentId`) or one agent's, opened from the tasks list.
*
* The same band under either; a switch re-runs the hook with the new
* view. Read-only: a rewrite carries it on as received; one that changes
* or drops it is refused, the hook that passed it failing.
*/
view: SiteView;
};
/**
* The framed region a plugin opened with `$.ui.open({ id })`: one instance
* per id (`requestId`), its body the hook's tree, one shown, the rest tabs.
*
* Placed by the surface (docked in fullscreen, else above the prompt) and
* keyed by the person (ctrl+x tab, Esc), who closes it from the engine's mark
* or ctrl+x x: `ui.close` with origin `person`, which a hook may refuse.
*
* Raised on every attached surface.
*/
Pane: {
/**
* Its tab's label while more than one pane is open (with one, the engine
* draws no title): the `title` it was opened with, or its id.Thinking
I've narrowed the target to the 30fps animated status bar. Now checking PromptHint, Raster/blit, and ANSI color handling to see which mod surface can support it.
BashF="/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts"; grep -n -i "PromptHint: {\|RasterProps = \|export interface RasterProps\|ansi\|escape sequence\|SGR" "$F" | head -40✓
$ {
"command": "F=\"/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts\"; grep -n -i \"PromptHint: {\\|RasterProps = \\|export interface RasterProps\\|ansi\\|escape sequence\\|SGR\" \"$F\" | head -40",
"description": "Locate PromptHint, Raster props, and ANSI handling"
}
1327: * UserPromptExpansion).
1388: UserPromptExpansion: 'additionalContext' | 'suppressOriginalPrompt';
4260: * reminder, a mode transition, a mentioned file), as a request carries it.
5256: type HookInput = PreToolUseHookInput | PostToolUseHookInput | PostToolUseFailureHookInput | PostToolBatchHookInput | PermissionDeniedHookInput | NotificationHookInput | UserPromptSubmitHookInput | UserPromptExpansionHookInput | SessionStartHookInput | SessionEndHookInput | StopHookInput | StopFailureHookInput | SubagentStartHookInput | SubagentStopHookInput | PreCompactHookInput | PostCompactHookInput | PreModelSwitchHookInput | PostModelSwitchHookInput | PermissionRequestHookInput | SetupHookInput | TeammateIdleHookInput | TaskCreatedHookInput | TaskCompletedHookInput | ElicitationHookInput | ElicitationResultHookInput | ConfigChangeHookInput | InstructionsLoadedHookInput | WorktreeCreateHookInput | WorktreeRemoveHookInput | CwdChangedHookInput | FileChangedHookInput | DirectoryAddedHookInput | MessageDisplayHookInput;
7523: * While the pane is the one shown the surface holds every transient toast:
8263: * A reminder, a mode transition, a listing, a mentioned file, a hook's
8281: * The engine's own prose or framing: a reminder, a mode transition, a
9490: export type RasterProps = {
10462: PromptHint: {
15130: type UserPromptExpansionHookInput = BaseHookInput & {
15131: hook_event_name: 'UserPromptExpansion';
15132: expansion_type: 'slash_command' | 'mcp_prompt';
17353: /** Model in use at the backgrounding transition (a pre-background swap is reflected here) */
21540: /** Extracted page images, in page order. Present only transiently in-process: the page image bytes are delivered solely as image blocks in the model-facing tool_result content and are not retained on the tool_use_result, so this key is absent on the emitted/persisted result */Read/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts✓
Read/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts✓
[Request interrupted by user]
Actually we're not looking to REPLACE what we have with the mod. We're looking to undebounce the existing statusline renderer. The mod API doesn't support what we need right now, but it does execute in-process so I'm wondering if we can simply change that code
Thinking
Checking the docs to see whether a mod shares the app's JavaScript realm, which determines if its code could reach the status line controller and strip the debounce itself.
BashR="/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/reference.md"; wc -l "$R"; grep -n -i "realm\|isolate\|sandbox\|globalThis\|environment of its own\|no node\|eval\|Function(\|import(\|require\|in-process\|worker\|quickjs\|tier" "$R" | head -60✓
$ {
"command": "R=\"/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/reference.md\"; wc -l \"$R\"; grep -n -i \"realm\\|isolate\\|sandbox\\|globalThis\\|environment of its own\\|no node\\|eval\\|Function(\\|import(\\|require\\|in-process\\|worker\\|quickjs\\|tier\" \"$R\" | head -60",
"description": "Search mod reference for runtime isolation details"
}
240 /private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/reference.md
14:file), exporting `register(on, options)`; the module, and every file it imports from the plugin, is named `.ts`, `.tsx`, `.jsx`, `.js`, `.mjs`, `.cjs`, `.mts` or `.cts` (a file named otherwise is not loaded) and is an ES module whatever its suffix. A file of the plugin is imported with an `import` declaration. A module holding `import()` does not load. `on(event, matcher?, hook)` adds a
23:The module runs in an environment of its own, with no DOM and no Node:
30:async generator (`async function* ($, e, next) {}`, the one form that loads there); `next(e)` is the stream beneath, `yield* next(e)` forwards it and evaluates to the
187:reloads. If the engine replaces the plugin's copy mid-session without the plugin having changed (the hooks worker was lost), the hook runs again in the fresh copy, its timers and state gone: a `$.prompt.submit`, `$.command.run` or `$.agent.spawn` the hook already made, with the same input, resolves to the first call's result and is not made twice. That covers a call made while the hook is still running, awaited or not, a timer of its own that fires in that time included; a call made after the hook returned (a later timer, a continuation), and one another hook makes in answer to a call of the start hook's, is made again. A subagent the engine interrupted because the copy that awaited it was lost is started again. `$.prompt.submit` queues a prompt that starts a turn of its own once the session is idle (never folded into a running turn; the call resolves as that turn starts, not when it ends), soRead/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/reference.md✓
BashR="/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/reference.md"; sed -n '180,192p' "$R"; F="/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts"; grep -n -i "hooks worker\|worker thread\|the worker\|tier\b\|'tier'\|tier:" "$F" | head -20✓
$ {
"command": "R=\"/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/reference.md\"; sed -n '180,192p' \"$R\"; F=\"/private/tmp/claude-501/bundled-skills/2.1.299/6e6a43f723aff9f2e117eb585b261576/plugin-authoring/types/claude-code.d.ts\"; grep -n -i \"hooks worker\\|worker thread\\|the worker\\|tier\\b\\|'tier'\\|tier:\" \"$F\" | head -20",
"description": "Read hooks-worker passage and grep types for worker/tier"
}
when that dispatch is abandoned (the user interrupted, another hook settled
first, the budget ran out); anything started for the dispatch should stop
on it. Work meant to outlive a dispatch belongs elsewhere: start it from a
`session.start` hook, which fires once when the session is ready and is
awaited before the first prompt (so a `$.tool.register` awaited there is
listed by turn one), and keep it going with `$.clock.every` and
`$.clock.after`, whose timers run until cancelled or until the module
reloads. If the engine replaces the plugin's copy mid-session without the plugin having changed (the hooks worker was lost), the hook runs again in the fresh copy, its timers and state gone: a `$.prompt.submit`, `$.command.run` or `$.agent.spawn` the hook already made, with the same input, resolves to the first call's result and is not made twice. That covers a call made while the hook is still running, awaited or not, a timer of its own that fires in that time included; a call made after the hook returned (a later timer, a continuation), and one another hook makes in answer to a call of the start hook's, is made again. A subagent the engine interrupted because the copy that awaited it was lost is started again. `$.prompt.submit` queues a prompt that starts a turn of its own once the session is idle (never folded into a running turn; the call resolves as that turn starts, not when it ends), so
background work can wake a quiet session. `$.model.complete({ model, prompt })` runs one text completion with no history on the session's own client and always resolves a result (`ModelCompleteResult`), never a bare string and never a rejection over what the provider did: `isAnswered` with `text` and `usage` (a `ModelUsage`, the four token counts as the API spells them: the one shape `session.compact`'s result, `turn.complete`'s `usage` and the context breakdown's `apiUsage` report a call's cost in too), or `isAnswered: false` with a `reason`, `api-error` (with the HTTP `status` and the `error` kind, never the error's text), `empty-reply`, or `aborted` (its `timeoutMs` elapsed, or the dispatch that made the call was abandoned), `usage` on each arm; only a request the engine refuses to send (a blocked model, a bad `maxTokens`) rejects. `prompt` and `system` each take a string or a list of `{ text, cache? }` blocks (`ModelTextBlock`), sent in order as written: `cache: true` on a block keeps the request up to and including it in the prompt cache, so a mod that sends the same long opening on every call puts that text first and marks its last block, then reads `usage.cache_creation_input_tokens` on the call that wrote it and `usage.cache_read_input_tokens` on the calls that read it within five minutes (both zero: the text is under the model's minimum, or prompt caching is switched off, where the mark is dropped and the text still sent); on Bedrock, Vertex, Foundry or behind a gateway the cached text is found again only while `prompt` opens the same way, so there the fixed text leads `prompt` and is not marked in `system`; marks are not counted, and one more than the API takes comes back as `api-error` with status 400; a hook on `model.complete` is handed the request's `prompt` and `system` as strings, as it always was (a list's texts joined in order), with the lists beside them as `promptBlocks` and `systemBlocks` (`ModelCompleteInput`): the string is what the model is sent, and the marks hold as far as the leading blocks still open it, so a hook that appends keeps them and one that rewrites the opening sends unmarked text. `$.model.fork({ prompt })` asks one tool-less question over the session's own transcript as the main thread last sent it, same model and system prompt, so the API serves that prefix from its prompt cache (`usage.cache_read_input_tokens` says how much it served); its result is the same arms plus `nothing-to-fork` (a new session before its first turn, and again right after a /clear), its `aborted` the turn whose hook forked interrupted while the fork ran. `$.ui.status`, `$.ui.toast` and
`$.ui.log` show state without starting a turn, `$.ui.copy({ text, surface })` puts text on the clipboard of the surface the caller names (a press hook passes `e.surface`; left out, the session's first): the terminal's through the machine's clipboard tool and OSC 52 (not sent in a VTE terminal once the tool has started), resolving `{ isCopied: true }`; `{ isCopied: false, reason }` on a remote surface, `no-clipboard`, or with nothing drawing, `no-surface`; the event `ui.copy` carries the target `surface`, so a hook may rewrite, refuse or take it), `$.ui.notify(text, { title })` raises a native notification by the person's own channel (their `preferredNotifChannel` setting, after their classic `Notification` hooks ran reading `plugin_notification`; headed by the plugin's name when no title is given), resolving `{ isSent: true, channel }`, or `{ isSent: false, reason }`: `disabled` when they turned notifications off, `no-channel` when their terminal has none, `no-surface` where the session holds no terminal (a `-p` run, the SDK, the desktop: the hooks alone run) or none is bound yet or the process is exiting (a call at the module's load, or in a `session.end` hook after a SIGTERM: nothing runs), `refused` when a hook on `ui.notify` answered `{ value }` without sending (a hook's `{ deny }` rejects the call instead); `terminal_bell` as the channel means a bell alone, no text shown; `$.store` keeps values
across sessions, `$.session.version()` answers the engine's `version`, its `base` release and its `builtAt` build time (the values the engine's own analytics rows carry, in every mode and build), and `$.process.run` runs a host command by argv. `$.fs` reads (text, or `{ as: 'bytes' }` for `{ base64 }`), writes, lists and stats paths; `$.fs.stat(path, { resolve: true })` also answers `realPath`, every symbolic link and `..` resolved (what `realpath` gives: a hard link, a `/.vol/` file-id spelling or a case alias keeps its own spelling), so a guard's robust form is an allow-list on `realPath` under a root it resolved the same way, and a deny-list on spellings is best effort.
`$.ui.selection()` answers what the person last selected with the mouse in
35:// expect, tier, and `mock`, whose clock, store and env answer those nouns
230: * Who provides this agent: the plugin and its tier; `{ plugin: "engine",
231: * tier: "core" }` for a built-in. Pinned: a rewrite is refused.
294: * Who provides this agent: the plugin and its tier; `{ plugin: "engine",
295: * tier: "core" }` for a built-in. Pinned with the spawn's identity.
1743: * Who provides this command: the plugin and its tier; `{ plugin: "engine",
1744: * tier: "core" }` for a built-in. Pinned: a rewrite is refused.
2084: * Who owns the row: `{ plugin: "engine", tier: "core" }` for the panel's
2085: * own, the plugin and its tier for a `userConfig` field. Pinned.
2753: * @remarks Under `allowManagedHooksOnly` a `user`-tier plugin loads no
4507: * plugins admitted before it and the binary's; judge by `tier` and `uses`.
4512: * on("plugin.register", { tier: "user" }, () => ({ refuse: "managed only" }))
5144: * Continues this dispatch at a tier, as Next's `to` (a managed hook's),
5147: readonly to: (e: Args<N>, tier: TargetTier) => Promise<GlobNextResult<N>>;
5212: * `throw`: it threw, returned what the site refuses, or its hooks worker
5235: * What was thrown or why the worker ended, or for a timeout what the last
5597: * Every tier an instruction file can belong to, for checking a hook's
5604: * tier, its text as loaded, and the file that `@`-imported it if one did.
5612: * Its tier.
5626: * What tier an instruction file belongs to: the organization's managedThinking
The docs confirm mods run in a dedicated "hooks worker" with no DOM or Node access. I'll now verify in the shipped binary that this is truly a separate worker with its own heap, not the app's realm.
BashB="$(readlink -f "$(which claude)")"; echo "$B"; LC_ALL=C grep -a -o -E '.{0,160}new Worker\(.{0,200}' "$B" | grep -i -E 'hook|plugin|mod' | head -5; echo ---; LC_ALL=C grep -a -o -E '.{0,120}hooks worker.{0,120}' "$B" | head -5✓
$ {
"command": "B=\"$(readlink -f \"$(which claude)\")\"; echo \"$B\"; LC_ALL=C grep -a -o -E '.{0,160}new Worker\\(.{0,200}' \"$B\" | grep -i -E 'hook|plugin|mod' | head -5; echo ---; LC_ALL=C grep -a -o -E '.{0,120}hooks worker.{0,120}' \"$B\" | head -5",
"description": "Look for how the binary starts the mod hooks worker"
}
/Users/bmf/.local/share/claude/versions/2.1.299
ugrep: error: error at position 90
\x80-\xbf][\x80-\xbf]*){0,200}
exceeds complexity limits___/
---
ugrep: error: error at position 90
\x80-\xbf][\x80-\xbf]*){0,120}
exceeds complexity limits___/BashB="$(readlink -f "$(which claude)")"; python3 -I - "$B" <<'EOF' import sys,re d=open(sys.argv[1],'rb').read() for pat in [rb'hooks worker', rb'new Worker\(', rb'SharedArrayBuffer', rb'ShadowRealm']: hits=[m.start() for m in re.finditer(pat,d)] print(pat, len(hits)) for h in hits[:6]: s=d[max(0,h-200):h+250].decode('utf8','replace').replace('\n',' ') if pat!=rb'new Worker\(' or re.search(r'(?i)hook|plugin|mod',s): print(' ',s[:450]); print() EOF✓
$ {
"command": "B=\"$(readlink -f \"$(which claude)\")\"; python3 -I - \"$B\" <<'EOF'\nimport sys,re\nd=open(sys.argv[1],'rb').read()\nfor pat in [rb'hooks worker', rb'new Worker\\(', rb'SharedArrayBuffer', rb'ShadowRealm']:\n hits=[m.start() for m in re.finditer(pat,d)]\n print(pat, len(hits))\n for h in hits[:6]:\n s=d[max(0,h-200):h+250].decode('utf8','replace').replace('\\n',' ')\n if pat!=rb'new Worker\\(' or re.search(r'(?i)hook|plugin|mod',s): print(' ',s[:450]); print()\nEOF",
"description": "Scan the binary for the mod worker spawn site"
}
b'hooks worker' 67
source vouches for the copy it loaded from), where a plugin adds nouns and withholds none7 �=H the set was refolded in part since it last loaded whole
�J�� \bimport\s*\( ' �wuF what held the hooks worker is not known K �d� Cannot destructure property 'loadHooksModules' from null or undefined value |�x o p t i o n s c h a n g e d �J� index.d.ts �K(] claude-code-mcp ��B� claude-code-tools ��] ./types/index.d.
_code_execution_tool_result �wG code_execution_tool_result �Y� container_upload ��� mcp_tool_listing&