Plan at the frontiers and review the plan in the browser: decisions and interfaces before mechanics, vertical slices each closed by a check. /vellum:start…

Write a plan the way its reviewer reads it, then review it in the browser. The skill orders the plan by what the reviewer is most likely to change and buries the mechanics; the hooks module holds a planning mode of its own: /vellum:start enters it, Claude writes the plan and its mockups in a working directory, the reviewer comments them in a page or approves, and the answer reaches Claude as a prompt.
latest channel loads them by default; an older build, stable included until it catches up, loads them only with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. Where they are off (--bare, --safe-mode, disableAllHooks, an organization's policy) the hooks module does not load: the skill still writes a plan under plans/<date>/<slug>/, but there is no mode, no page and no mcp__vellum__submit tool; use the native plan mode for that session.$.fs.stat answers realPath with { resolve: true }: the npm latest channel has it, stable may not yet. The lock places every path through it, so on an older engine the lock fails closed and every Write and Edit is denied while vellum plans, plan.md included, with "the lock failed (throw); retry the call". claude --debug then logs "lands nowhere" and the cause; update Claude Code, or /vellum:stop and use the native plan mode.bun on the PATH: the review server is a Bun script. Claude Code installs the plugin's dependencies (preact, remark, rehype-highlight, mermaid, diff) at its cache from package.json and bun.lock.grill_ask, and is not affected.allowedMcpServers key. Where that key is set at all, empty included, Anthropic's sec-default refuses a user-tier $.tool.register by name: mcp__vellum__submit does not exist on that machine and the mode cannot be entered. The skill still writes a plan./vellum:start triggers itself when a design choice is open, a change crosses several modules or interfaces, or a refactor reshapes a contract. Three moves:
mcp__vellum__propose what to do next: a grill, a mockup, a throwaway prototype or the plan, the one it recommends marked. You pick one in the review page, or your own, and the call returns your pick. Claude proposes again each time a step ends.plan.md, ordered by probability of revision: decisions, interfaces, files, slices with their check, out of scope, then mechanics. Then call mcp__vellum__submit.References, loaded one at a time: program-design.md (signatures, call-stack and file trees, command interfaces, contracts), slices.md (vertical order, sizing, implementation notes), visual.md (when a mockup or a diagram earns its place).
/vellum:start enters the mode. The hooks module creates plans/<date>/wip-<sid8>/, tells Claude to put the plan and its artifacts there, starts one review server per session on 127.0.0.1, a child of the session that tells the module on its output each thing the reviewer sends (leaving the mode ends it, and it exits on its own once the session's heartbeat stopped and no tab shows the page; if it dies, stops answering, or a reload of the plugin ends it, the module starts it again on the same port and token, so the page reconnects by itself, and after three ends within a minute it stops trying and says so) and opens the page. While the mode is live, Edit, Write and NotebookEdit under the project and outside the working directory are refused with a reason Claude reads; inside it they pass without a prompt; a path outside the project is no change to the codebase and follows the session's own permission flow, so the session's scratchpad passes there without a prompt; a lock that fails refuses the call rather than letting it past; every other tool follows the session's own permission flow, and a shell command (Bash, PowerShell or Monitor) that a settings allow rule would approve asks instead, a prompt in the manual mode; a command the permission mode approves on its own, such as mkdir or mv in acceptEdits, still runs. A shell command that moves into the working directory (cd, pushd, Set-Location), or one run in the background that names it, is refused too: a shell standing in the folder holds it on Windows, and the approval could not rename it. While the mode holds, every Bash and PowerShell command starts at the project's root, as Claude Code does when CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR is true: vellum sets it as the mode begins and unsets it as the mode ends, and leaves it alone when you had already set it to a true value (1, true, yes, on); a false one is replaced, then unset. The native plan mode is untouched and stays available for a plan that needs no review page.
.review/v0.feedback-<n>.md and reaches Claude as a prompt at its next idle: it revises the file and goes on.plan.md at the directory's root and ends its turn: the module submits the text, saved as .review/vN.md, and the page shows it. The same text keeps its version, so a turn that only asks a question opens none. mcp__vellum__submit submits before the turn ends; once you sent a batch on the version, that explicit call is a new version even with the same text.C flips it while the focus is in the document, a mockup's frame included. On, Tab stops on every block of a Markdown document and Enter picks it, Ctrl+Enter adds it, as a click and a Ctrl+click do. On, a drag in a Markdown document quotes a passage and a click picks a block, a link, a code block, a table cell or a diagram; Ctrl adds either one to the same comment. In an HTML mockup a click picks an element, a drag picks the element that holds the dragged text and quotes that text, and Ctrl adds either one. Off, the page selects, copies and follows links as any page does, and a mockup runs its own scripts. Where Claude marked a decision between options in a mockup, a click on an option's Choose adds that choice to your draft, boxed Chosen in the mockup until it is sent, one option per decision: choosing another replaces it, and Choose again on it withdraws it; a double click chooses once. Its card in the comments panel names the mockup, the decision and the option, by the heading the option holds or else its key, with Delete and Send now. A choice whose option Claude has since removed from the mockup says so on its card, keeps Delete alone, and no Send takes it. With Comment on, the same click picks the button like any element. The same popover takes a quick label (Clarify, Verify, Too much, Missing check) or Delete this, each a comment by itself sent at the click, or a text of the reviewer's own; the feedback prints a label as a sentence Claude acts on. The box under the comments takes a general comment. Code blocks are coloured and Mermaid blocks are drawn..review/vN+1.md and plan.md hold your text, and vN.md stays what Claude submitted. An edit made on a version Claude has since replaced is refused, and the page says so rather than overwrite the revision..review/vN.feedback-<k>.md (the grill's reply first, then each comment: path, then lines and quote, or for a mockup's element its selector, its role and name, the heading it sits under, its opening tag and its text; then the comment; then each choice: the mockup, the decision, the option and the button clicked), and submits a prompt: Claude reads the file, revises, and vN+1 is submitted when its turn ends. A Send locks nothing: you go on commenting on the same version, and the next Send is the next batch of it. With a question of the round left unanswered, Send asks first (2 questions have no answer) and a second click takes the recommendations; with an edit the file says first that the reviewer edited plan.md and that those edits stay, and the edit lands as vN+1; an edit alone can be sent. A Send takes what was on screen at the click: a comment added while it is out stays for the next one, and so does text typed in the general box or a comment's field. Send now on a comment's or a choice's card sends it alone and leaves the round open; on a comment on the plan it waits while an edit is pending, since Done moved its lines to the edit, and Send takes them together.-2 on collision, plan without a title), rewrites the links in every text file of it, and submits a prompt naming the final directory. The mode closes and the lock lifts. On Windows a folder a program holds cannot be renamed: the rename is tried again for three seconds, then the page names what may hold it (a terminal, File Explorer, an editor, a background command open in it), to close before Retry approval. Approve with notes… takes a note for Claude: it is kept in .review/vN.notes.md, never in the plan, and the prompt says to read that file first, so Claude reads it before it acts. An approval that carries an edit writes that file too, to tell Claude to read plan.md again. With unsent comments or choices, either button first warns that approving discards them; with the review held (a grill open, a plan review running), that approving ends it and loses what it was doing. While plan.md holds a text the version under review lacks, Approve is refused, and the notice offers Record, then approve: plan.md becomes the next version, then that version is approved. The pill says where the review stands, and Refused now beside it lists what is refused at the moment and why..review/draft.json, at every change or as soon as the typing pauses, and at once when the tab is hidden or closed, so a reload right after a keystroke keeps it; only a large draft, as the edit of a long plan can make it, past 64 KiB or past what a save still under way leaves of them, may lose its last keystrokes to a tab closed before the typing paused. A reload restores them, in any browser, since the file is the server's: the general box and the grill's answers show again, and the next comment on the same document or the next Edit on the same version starts with the text. The server sends what the file holds, so the page writes it once more before a Send; what a Send took leaves the file, and an approval removes it. Nothing typed is thrown in silence, but for a comment on a line your own edit added, which the Done that removes one of its lines takes with it: Send and Approve warn first, Cancel in the editor asks first, Discard edit asks first and counts the comments that go with the edit, End grill sends the answers typed, and a card's Edit reopens its text in place.When Claude proposes the next step, the Next step window asks you over the page: the reason, then the moves, each a grill on a subject, a mockup of a screen, a throwaway prototype for a question, or the plan, the one Claude recommends marked Recommended, none checked until you pick one. Something else… takes any kind of step with your own subject, or your own words. None of these declines every move, with a note if you type one, and the button then reads Decline. Choose tells Claude your pick, as the result of the propose call that waits for it (Accepted: <move>. for the one it recommended, Chose: <move>. for another, Own: <text>., Declined. or Declined: <note>.), and a grill picked opens at once. Esc puts the window off onto a dot on the Next step button. A proposal that lands while you type or edit, or while the window is up, waits on that dot instead. A proposal is kept in .review/step.json, so a server started again still shows it. When Claude stops waiting on it (its turn interrupted, a server started again), it no longer opens by itself: it waits on the dot, marked Paused, and your pick reaches Claude as a message; Claude's next proposal replaces it. A proposal that offers the plan once plan.md exists is refused. Claude never opens a grill. The Next step button with no proposal opens the same window, blank, on a step of your own. An open grill is a panel right of the documents: the rail still opens the plan or an artifact beside it, Beside the plan still splits them, and the comments panel folds when the grill appears, its handle kept. A band in the grill's colour runs across the page above them, with the grill's subject, its round and how many questions still wait for your choice, the chips still outlined (round 2 · 2 questions waiting), and End grill; the Next step button hides meanwhile, and Claude proposes nothing until the grill ends. Each grill is a file of the working directory, grill-<n>.md, listed with the other artifacts and carried to the final directory by the approval. It keeps the rounds: Claude's questions, your answers beside them, and what Claude says in the turns of the grill; opened from the rail it reads as a document, and the panel is where you answer. A command of the session (/vellum:start, /clear) is kept as an event line; what you type in the terminal is not the grill's.
Claude asks a round with mcp__vellum__grill_ask. The panel keeps what Claude says between rounds, and under it every question of the grill as a chip, by round: green once answered, grey once taken by default, outlined while it waits for you. The chips, and the arrows under the question (‹ Q1, Q3 ›), show one question at a time: its number, its topic, the question, and two choices, Recommended and Your answer, a field, neither checked until you pick one: a recommendation checked in advance ends up accepted unread. All recommended picks the recommendation for every question you left untouched. Choosing the recommendation sends As recommended.: Claude wrote it, so its text never goes back to it; while the field holds your text, Recommended is greyed, so a click never throws what you typed. A question already answered, opened from its chip, reads its answer and takes nothing. Your answers and your note leave with the bar's Send, beside your comments, and close every open question; one you left untouched is taken as recommended, marked as taken by default, once Send asked you. Your reply is written in the round of its questions, and Claude receives it at once, as the result of the grill_ask call that waits for it, under Reviewer:: your note first, then the answers you typed, never a default, then the batch to read when it holds comments too. Interrupted, the call returns nothing, and the batch arrives as a prompt once Claude's turn ends. While Claude works, the panel names the round it prepares. A round whose asking turn was interrupted reads Paused and stays open: your answers reach Claude as a message. Once Claude's turn ends with no question open, the panel shows its last words above End grill and return to plan, the main action, and Add a note; a turn that was interrupted says so, with the note to send Claude back to work. While a grill is open the band above Claude Code's prompt says grill · open: what you type in the terminal is answered there and is not the grill's. A question stays open until you send, whatever happens in the session meanwhile. End grill closes the file with a footer and tells Claude, in one sentence that names the file, that the grill ended; the page returns to the plan, under a notice that counts the grill's decisions (Grill ended: 8 decisions. Claude proposes the next step.) and opens the transcript, until you dismiss it or open another grill. /vellum:stop and the approval close the grill too, with no notice, the approval on the server, whether or not the session is still there. While a grill is open the review is held: no version of Claude's is recorded, and mcp__vellum__submit is refused with the reason. Claude may still write plan.md, and the pill says so (Held · grill 1 is open · plan.md waits); once the grill ends Claude is told once to integrate what it settled, and the end of that turn records the version. Send still goes, but for your edit of the plan, which stays in the draft with your comments on the plan's lines, under a notice, until the first Send after the hold lifts; ending the grill lifts the hold. While the mode is live AskUserQuestion is refused: the page is your one channel. One grill is open at a time.
/vellum:stop leaves the mode without a plan; the directory is kept. /clear, and a /resume that lands in another session, suspend it: timers stopped, the session's record kept, so resuming that session later finds its directory. While the mode is live, a band above Claude Code's prompt reads like a status line, vellum │ plan v2 · in review │ grill · open │ Held · grill 1 is open │ Review page ↗: where the plan stands (draft, v<n> · in review, v<n> · approved), a grill that is open or a plan review that runs, the page's pill, and the review page's link, clickable where the terminal draws links. The line under the prompt is kept for a failure: a server that is lost, or a working directory that is gone; the band then shrinks to vellum and the link. Claude Code lets you collapse the band (ctrl+x ctrl+a), and a survey takes it over while it runs.
plan-reviewer reads a plan and its artifacts, read-only, and reports Approved or Issues found with a verdict: overengineered, underengineered or right. The skill calls it for a large change or a plan no human will read; call it yourself with the plan path otherwise. The page's Review button runs it on the version under review, and its text lands in reviews/. While it runs the review is held, as by a grill: the pill reads Held · plan review <n> of v<N> is running, Claude's versions wait, and when Claude wrote plan.md meanwhile it is told once the run ends, and the end of that turn records the version; Review is greyed while a grill holds the review. The ✕ beside the running button gives the run up and stops the agent, as the approval and /vellum:stop do; a run whose agent is gone without a verdict (a claude --resume, a reload) ends as failed ten seconds after the session sees it.
The plugin installs a hooks module that refuses writes and spawns a process. These two tables say what it hooks and what it calls, nothing more.
| Hook | Matcher | What it does | |
|---|---|---|---|
session.start | Registers submit, state and each extension's tools, and picks the mode back up on a server started again on the stored port and token. | ||
skill.prompt | skill=vellum:start | Enters the mode: reaches or starts the server, then appends the working directory and the page's link to the skill's text. | |
skill.prompt | skill=vellum:stop | Leaves the mode and says which directory is kept. | |
command.run | `command=clear\ | resume` | Suspends the mode after the command ran, when the session id changed: timers stopped, the record kept. |
ui.render | component=AbovePrompt | Draws the band while the mode holds a session; passes while it is idle or a survey holds the band. | |
tool.check | The lock, and the refusal of a shell command that enters the working directory or names it from the background. Its .catch denies whatever the failure, so a hook that throws or overruns cannot open it. | ||
tool.call | tool=mcp__vellum__submit | Gates the plan and names the version, without running a tool. | |
tool.call | tool=mcp__vellum__state | Answers where the session stands, read from the server: the plan's stage and plan.md, what holds the review, and what is refused now with each reason. | |
tool.call | tool= each extension's tool and each tool an extension refuses, as register.ts lists them | Serves each extension's tool: one that waits for your answer returns it as its result. Refuses, while the mode is live, the tools an extension refuses (the terminal's AskUserQuestion: the page is where you answer). Its .catch answers a call that failed, never with a permission prompt: one that waited says your answer comes as a prompt. | |
prompt.submit | While the mode is live, hands a command of the session to the open grill's transcript as an event, Vellum's own relays left out, then passes every prompt on unchanged. | ||
turn.start | Notes whether the turn was started by one of Vellum's own relays, from the origin prompt.submit saw. | ||
turn.complete | Gates plan.md after a main-loop turn answered while the mode is live; an unchanged text is kept, and a held review refuses it. Hands the main loop's final text to the open grill's transcript, which keeps it when a relay of Vellum started the turn. After a turn interrupted while a tool waited for your answer, tells the server that wait is paused. |
$| Call | What for |
|---|---|
$.tool.register | The submit and state tools, and each extension's, at the session's start. |
$.session.id | Which session the mode belongs to; a /clear mints a new one. |
| $.session.cwd | Where the session runs now, to resolve a relative
src/runtime/hooks/register.ts 522 lines1import type { EngineInterface, Register } from "claude-code";
2
3import { gateAtTurnEnd, SUBMIT, submitPlan, submitResult } from "../../review/hooks.ts";
4import { type Band, liveBand, lostBand } from "./band.ts";
5import type { EngineContext, EngineExtension, ToolContext } from "./extension.ts";
6import type { Host } from "./host.ts";
7import { checkVerdict, lockFailed, lockVerdict, SHELLS, shellVerdict } from "./lock.ts";
8import {
9 close,
10 connect,
11 discard,
12 type Live,
13 restore,
14 type Revive,
15 revived,
16 sessionOf,
17 type Settle,
18 type Staged,
19 type State,
20 suspend,
21 tenureOf,
22 type Wiring,
23} from "./mode.ts";
24import {
25 type DrawnWire,
26 editedPath,
27 rowText,
28 type GateWire,
29 sessionId,
30 shellCall,
31 type StateWire,
32} from "./parse.ts";
33import { landed } from "./place.ts";
34import type { Claim } from "./relay.ts";
35import { engineExtensions } from "./slices.ts";
36import { completed, NO_TURN, ownOf, prompted, replied, started, type Turns } from "./turn.ts";
37
38const START_SKILL = "vellum:start";
39
40const STOP_SKILL = "vellum:stop";
41
42// Kept small on purpose: a tool's schema rides in every request.
43const STATE = {
44 name: "state",
45 description:
46 "Where the vellum planning session stands, read on demand: the plan's stage and whether plan.md holds a text no version has, each part of the review (grill, proposal, plan review) and what holds it, then each event refused now with its reason. No input. Refused outside a vellum planning session.",
47 inputSchema: { type: "object" },
48};
49
50/** The session as `mcp__vellum__state` prints it: the stage and `plan.md`, one line per region, then what is refused now. */
51function stateResult(state: StateWire): string {
52 const { stage } = state;
53 const where = "version" in stage ? `${stage.kind} v${stage.version}` : stage.kind;
54
55 const refused = state.refused.map(
56 ({ event, effect, reason }) => ` ${event} (${effect}): ${reason}`,
57 );
58
59 return [
60 `workspace: ${where} · plan.md: ${state.planText}`,
61 ...state.lines,
62 refused.length === 0 ? "refused now: none" : "refused now:",
63 ...refused,
64 ].join("\n");
65}
66
67const NOT_PLANNING = "no vellum planning in progress; run /vellum:start";
68
69/** What Claude reads when a call that waited for the reviewer failed: the answer comes all the same. */
70const ANSWER_BY_PROMPT =
71 "The reviewer's answer will arrive as a prompt, once they send it. End your turn.";
72
73const EXTENSION_TOOLS = engineExtensions.flatMap((extension) =>
74 (extension.tools ?? []).map((tool) => ({ extension, tool })),
75);
76
77const UNREACHABLE: GateWire = {
78 error: "the vellum review server is not answering; run /vellum:start again",
79};
80
81/**
82 * Binds this dispatch's `$`. Every call is spelled here, the one place the loader follows it;
83 * a timer a transition starts keeps the host it was given, as a closure over `$` would.
84 */
85function hostOf($: EngineInterface): Host {
86 return {
87 sessionId: () => $.session.id(),
88 cwd: () => $.session.cwd(),
89 root: () => $.session.root(),
90 stat: (path) => $.fs.stat(path, { resolve: true }),
91 pluginRoot: $.plugin.root,
92 storeGet: (key) => $.store.get(key),
93 storeSet: (key, value) => $.store.set(key, value),
94 storeDelete: (key) => $.store.delete(key),
95 fetch: (url, init) => $.http.fetch(url, init),
96 spawn: (request) => $.process.spawn(request),
97 every: (ms, fn) => $.clock.every(ms, fn),
98 after: (ms, fn) => $.clock.after(ms, fn),
99 now: () => $.clock.now(),
100 submitPrompt: (text) => $.prompt.submit({ text }),
101 spawnAgent: (args) => $.agent.spawn(args),
102 listAgents: () => $.agent.list(),
103 callTool: (call) => $.tool.call(call),
104 status: (text) => $.ui.status(text),
105 invalidate: () => $.ui.invalidate("ui.render"),
106 log: (text) => $.ui.log(text),
107 projectCwdFlag: () => $.env.get("CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR"),
108 setProjectCwdFlag: (value) => $.env.set("CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR", value),
109 };
110}
111
112const SEPARATOR = " │ ";
113
114function contextOf(host: Host, live: Live, extension: EngineExtension): EngineContext {
115 return { host, live, api: live.server.extension(extension.id) };
116}
117
118/** Whether the state holds a session whose variable vellum set: `live` or `lost`, as the lock reads it. */
119function pinned(state: State): boolean {
120 return sessionOf(state)?.pinnedCwd === true;
121}
122
123export const register: Register = (on) => {
124 let state: State = { kind: "idle" };
125
126 // Reset wherever the mode leaves `live`, and ignored outside it: see `turn.ts`.
127 let turns: Turns = NO_TURN;
128
129 function forgetTurns(): void {
130 turns = NO_TURN;
131 }
132
133 /**
134 * Hands one event to every extension, in registry order. An extension that throws is logged
135 * and the next one runs: no extension may stop the core's own hook, or another extension.
136 */
137 async function handed(
138 host: Host,
139 live: Live,
140 event: string,
141 hand: (extension: EngineExtension, context: EngineContext) => Promise<void> | undefined,
142 ): Promise<void> {
143 for (const extension of engineExtensions) {
144 try {
145 await hand(extension, contextOf(host, live, extension));
146 } catch (cause) {
147 host.log(`${extension.id} failed on ${event}: ${String(cause)}`);
148 }
149 }
150 }
151
152 /** The extensions end what they started in the session, while the mode is live and its server answers. */
153 async function closing(host: Host, live: Live): Promise<void> {
154 await handed(host, live, "closing", (extension, context) => extension.closing?.(context));
155 }
156
157 // What each mode's server last said the band draws, keyed by the mode: a new way in starts with none.
158 const stages = new WeakMap<Live, DrawnWire>();
159
160 function bandOf(): Band | null {
161 if (state.kind === "idle") return null;
162
163 if (state.kind === "lost") return lostBand(state.session.server);
164
165 return liveBand(state.live.session.server, stages.get(state.live) ?? null);
166 }
167
168 // What `ui.render` draws; `redraw` alone writes it, so a line that changed nothing redraws nothing.
169 let band: Band | null = null;
170
171 function redraw(host: Host): void {
172 const next = bandOf();
173
174 if (JSON.stringify(next) === JSON.stringify(band)) return;
175 band = next;
176 host.invalidate();
177 }
178
179 /**
180 * Every write of `state`: the band follows it, from one place. Leaving a live mode ends its
181 * server, and a follower the next state no longer holds is stopped: the module owns its child,
182 * and what a left mode's server would still say reaches nobody. A follower, not a tenure, is
183 * compared: a revival carries the same follower in a new tenure, its ends counted anew.
184 * While the state holds a session vellum pinned, every shell command starts at the project's
185 * root; the variable goes as the last such state leaves, and one the person set stays theirs.
186 */
187 function become(host: Host, next: State): void {
188 const was = state;
189 state = next;
190
191 if (pinned(next) !== pinned(was)) {
192 const value = pinned(next) ? "1" : undefined;
193
194 host.setProjectCwdFlag(value).catch((cause: unknown) => {
195 host.log(
196 `the project's working directory was not ${value === undefined ? "released" : "pinned"}: ${String(cause)}`,
197 );
198 });
199 }
200
201 if (was.kind === "live" && (next.kind !== "live" || next.live !== was.live)) {
202 was.live.child.end();
203 }
204
205 const follower = tenureOf(was)?.follower;
206
207 if (follower !== undefined && follower !== tenureOf(next)?.follower) follower.stop();
208 redraw(host);
209 }
210
211 const settle: Settle = async (host, id) => {
212 if (state.kind === "live" && state.live.session.id === id) await closing(host, state.live);
213
214 // Checked after `closing`: a way into another session meanwhile keeps its mode.
215 if (sessionOf(state)?.id !== id) return;
216 forgetTurns();
217 become(host, await close(host, state));
218 };
219
220 const revive: Revive = async (host, from, how) => {
221 if (state !== from) return;
222 const next = await revived(host, from, () => state === from, wiring, how);
223
224 if (next === null) return;
225
226 // The mode was left while the revival wrote its record: the revived server is not taken.
227 if (state !== from) {
228 discard(next);
229
230 return;
231 }
232
233 forgetTurns();
234 become(host, next);
235 };
236
237 const staged: Staged = async (host, live, drawn) => {
238 stages.set(live, drawn);
239 await handed(host, live, "staged", (extension, context) => extension.staged?.(context));
240 redraw(host);
241 };
242
243 const wiring: Wiring = { settle, staged, revive, current: (from) => state === from };
244
245 // The waits of the extensions' tool calls, by the call's id, and the calls that failed while
246 // waiting: their `.catch` answers Claude that the answer comes as a prompt.
247 const waits = new Map<string, Claim>();
248 const failedWaiting = new Set<string>();
249
250 on("session.start", async ($, e, next) => {
251 await $.tool.register(SUBMIT);
252 await $.tool.register(STATE);
253
254 for (const { tool } of EXTENSION_TOOLS) {
255 await $.tool.register({
256 name: tool.name,
257 description: tool.description,
258 inputSchema: tool.inputSchema,
259 });
260 }
261
262 const host = hostOf($);
263 become(host, await restore(host, state, wiring));
264
265 return next(e);
266 });
267
268 on("skill.prompt", { skill: START_SKILL }, async ($, e, next) => {
269 const host = hostOf($);
270 become(host, await connect(host, state, wiring));
271 const result = await next(e);
272
273 if (state.kind !== "live") return result;
274 // The page opens on the way in, so the reviewer can comment on the artifacts before v1.
275 void state.live.server.open();
276
277 // The reviewer reads the link here: `$.ui.log` does not show in the transcript, so the
278 // skill is the deterministic channel the module owns.
279 const lines = `Working directory: ${state.live.session.workdir}\nReview page: ${state.live.server.url}`;
280
281 return { text: `${result.text}\n\n${lines}` };
282 });
283
284 // The way out is a skill, not `$.command.register`: a registered command takes the global
285 // namespace, and `disable-model-invocation` keeps this one the reviewer's to run.
286 on("skill.prompt", { skill: STOP_SKILL }, async ($, e, next) => {
287 const session = sessionOf(state);
288
289 const line =
290 session === null
291 ? "no vellum planning in progress"
292 : `vellum planning closed; ${session.workdir} is kept`;
293
294 const host = hostOf($);
295
296 if (state.kind === "live") await closing(host, state.live);
297 forgetTurns();
298 become(host, await close(host, state));
299 const result = await next(e);
300
301 return { text: `${result.text}\n\n${line}` };
302 });
303
304 // The deterministic way the mode ends when the session forgets it: a `/clear` mints a new
305 // session id, so the old heartbeat would run on until the next way in noticed.
306 on("command.run", { command: ["clear", "resume"] }, async ($, e, next) => {
307 const result = await next(e);
308
309 const session = sessionOf(state);
310
311 if (session === null) return result;
312 const host = hostOf($);
313 const left = e.command === "clear" || sessionId(await host.sessionId()) !== session.id;
314
315 if (!left) return result;
316 forgetTurns();
317 become(host, suspend(host, state));
318
319 return result;
320 });
321
322 // The mode's one place in the terminal: the status line keeps a failure alone. A survey holds
323 // the band over any plugin, and the person may collapse it.
324 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
325 if (band === null || e.props.hasSurvey) return next(e);
326 const { Box, Text, Link } = $.ui.resolve(e);
327 const separator = () => Text({ dimColor: true, children: SEPARATOR });
328
329 return Box({
330 flexDirection: "row",
331 children: [
332 Text({ children: "vellum" }),
333 ...band.segments.flatMap((segment) => [
334 separator(),
335 Text({ dimColor: true, children: segment }),
336 ]),
337 separator(),
338 Link({ href: band.href, label: "Review page ↗" }),
339 ],
340 });
341 });
342
343 on("tool.check", async ($, e, next) => {
344 const session = sessionOf(state);
345
346 if (session === null) return next(e);
347 const call = SHELLS.has(e.tool) ? shellCall(e.tool, e.input) : null;
348 const moved = call === null ? null : shellVerdict(call, session.workdir);
349
350 if (moved?.kind === "deny") return { decision: "deny", reason: moved.reason };
351 // ponytail: the lock reads the file tools only, so a shell command the session's own flow
352 // approves still writes anywhere; a command classifier is the upgrade if that ever bites.
353 const path = editedPath(e.tool, e.input);
354
355 if (path === null) return checkVerdict(e.tool, await next(e));
356 const verdict = lockVerdict(path, session.workdir, await landed(hostOf($), session, path));
357
358 if (verdict.kind === "deny") return { decision: "deny", reason: verdict.reason };
359
360 if (verdict.kind === "allow") return { decision: "allow" };
361
362 return checkVerdict(e.tool, await next(e));
363 }).catch((_, e, next) => (state.kind === "idle" ? next(e) : lockFailed(next.error.kind)));
364
365 on("tool.call", { tool: "mcp__vellum__submit" }, async ($) => {
366 if (state.kind === "idle") return { deny: NOT_PLANNING };
367
368 if (state.kind === "lost") return { deny: UNREACHABLE.error };
369
370 return submitResult(await submitPlan(hostOf($), state.live, "record").catch(() => UNREACHABLE));
371 });
372
373 // Read on demand, never joined to a relay (D10): the reasons Claude meets arrive with each refusal.
374 on("tool.call", { tool: "mcp__vellum__state" }, async () => {
375 if (state.kind === "idle") return { deny: NOT_PLANNING };
376
377 if (state.kind === "lost") return { deny: UNREACHABLE.error };
378 const read = await state.live.server.state().catch(() => UNREACHABLE);
379
380 return "error" in read ? { deny: read.error } : { result: stateResult(read) };
381 });
382
383 // Matched, never open: an unmatched `tool.call` hook wraps every tool call of every agent in
384 // the session, and a worktree-isolated agent's shell loses its working directory inside it.
385 // The literal is the registry's tools and refusals, held equal to it by `register.spec.ts`.
386 // A call that waits ends its wait however it ends; one the engine gave up on (a throw, an
387 // overrun, Escape) answers from `.catch`, and what it held reaches Claude through the channel.
388 on(
389 "tool.call",
390 { tool: ["mcp__vellum__grill_ask", "mcp__vellum__propose", "AskUserQuestion"] },
391 async ($, e, next) => {
392 const name = e.tool;
393 const owned = EXTENSION_TOOLS.find(({ tool }) => `mcp__vellum__${tool.name}` === name);
394
395 if (owned !== undefined) {
396 if (state.kind === "idle") return { deny: NOT_PLANNING };
397
398 if (state.kind === "lost") return { deny: UNREACHABLE.error };
399 const { live } = state;
400 const { tool, extension } = owned;
401 const id = e.tool_use_id;
402
403 const context: ToolContext = {
404 ...contextOf(hostOf($), live, extension),
405 waiting: () => {
406 if (tool.awaits !== undefined && !waits.has(id)) {
407 waits.set(id, live.tenure.follower.claim(tool.awaits));
408 }
409 },
410 };
411
412 try {
413 const answer = await tool.call(context, e);
414
415 if ("deny" in answer) return { deny: answer.deny };
416
417 // An Escape that landed as the answer came: the result reaches nobody, the entry goes.
418 if ("returns" in answer && !next.signal.aborted) {
419 waits.get(id)?.returned(answer.returns);
420 turns = replied(turns);
421 }
422
423 return { result: answer.result };
424 } catch (cause) {
425 // Escape calls no `.catch` (hook-runtime.md): only a failure it will hear is remembered.
426 if (waits.has(id) && !next.signal.aborted) failedWaiting.add(id);
427 throw cause;
428 } finally {
429 waits.get(id)?.close();
430 waits.delete(id);
431 }
432 }
433
434 if (state.kind !== "live") return next(e);
435
436 const refusal = engineExtensions
437 .map((extension) => extension.refuses?.[name])
438 .find((reason) => reason !== undefined);
439
440 return refusal === undefined ? next(e) : { deny: refusal };
441 },
442 ).catch((_, e, next) => {
443 const id = e.tool_use_id;
444 // Still open after an overrun: the hook's code may run on, and what it returns late is not heard.
445 const overrun = waits.get(id);
446 overrun?.close();
447 waits.delete(id);
448
449 if (overrun !== undefined || failedWaiting.delete(id)) return { result: ANSWER_BY_PROMPT };
450 const name = e.tool;
451
452 if (!EXTENSION_TOOLS.some(({ tool }) => `mcp__vellum__${tool.name}` === name)) return next(e);
453
454 return {
455 deny: `vellum failed on ${name} (${next.error.message ?? next.error.kind}); retry the call`,
456 };
457 });
458
459 // The engine skips this hook for vellum's own relays (`skipped: re-entry`), so an extension
460 // never hears of one, and whose turn a relay starts is read off its row below.
461 on("prompt.submit", async ($, e, next) => {
462 if (state.kind === "live") {
463 await handed(hostOf($), state.live, "prompted", (extension, context) =>
464 extension.prompted?.(context, { text: e.text, origin: e.origin }),
465 );
466 }
467
468 return next(e);
469 });
470
471 // The main loop's prompt row is kept just before the turn it starts, a relay's as a typed
472 // prompt's (`turn.ts` says what a row with no turn of its own costs).
473 on("session.append", { door: "prompt" }, (_, e, next) => {
474 if (e.agentId !== undefined) return next(e);
475 const own = e.origin.kind === "plugin" && "name" in e.origin && e.origin.name === "vellum";
476
477 if (state.kind === "live") turns = prompted(turns, rowText(e.message.content), own);
478 else forgetTurns();
479
480 return next(e);
481 });
482
483 on("turn.start", (_, e, next) => {
484 turns = state.kind === "live" ? started(turns, e.text, e.turnId) : NO_TURN;
485
486 return next(e);
487 });
488
489 // The turn's end is the deterministic submit: the reviewer sees each new plan.md the moment
490 // Claude hands back, and never has to ask for one. An unchanged text is kept, even after a
491 // feedback, so a turn that answered a question opens no version; the explicit tool does. A
492 // refusal (a hold, the plan approved) is the server's to journal: Claude hears of a hold once it
493 // ends, through the notice, never here.
494 on("turn.complete", async ($, e, next) => {
495 const result = await next(e);
496
497 if (state.kind !== "live") return result;
498 const host = hostOf($);
499 const { agentId } = e;
500
501 // A subagent's end is its answer to whoever spawned it; the turns and the gate are the main loop's.
502 if (agentId !== undefined) {
503 await handed(host, state.live, "agentAnswered", (extension, context) =>
504 extension.agentAnswered?.(context, { agentId, text: e.answer, reason: e.reason }),
505 );
506
507 return result;
508 }
509
510 const own = ownOf(turns, e.turnId);
511 turns = completed(turns, e.turnId);
512
513 if (e.reason === "answer") await gateAtTurnEnd(host, state.live);
514
515 await handed(host, state.live, "answered", (extension, context) =>
516 extension.answered?.(context, { text: e.answer, reason: e.reason, own }),
517 );
518
519 return result;
520 });
521};
522src/review/hooks.ts 54 lines1import type { Unchanged } from "../runtime/hooks/client.ts";
2import type { Host } from "../runtime/hooks/host.ts";
3import type { Live } from "../runtime/hooks/mode.ts";
4import type { GateWire } from "../runtime/hooks/parse.ts";
5
6/**
7 * The review's part of the hooks module: its tool, `submit`, and the gate of `plan.md` at the
8 * turn's end. `runtime/hooks/register.ts` registers the tool and serves both from its hooks, since
9 * `$` is spelled there alone and the matchers are its literals. Loaded by the hooks module, it
10 * loads nothing: it reaches `runtime/hooks/` as types.
11 */
12
13export const SUBMIT = {
14 name: "submit",
15 description:
16 "Submit plan.md from the vellum working directory for review in the browser, before the turn ends. The turn's end submits it anyway, but only when its text changed; once the reviewer sent a batch on the version under review, this tool also records an unchanged plan.md as the next version. Answers with the version under review. Refused, with the reason, outside a vellum planning session (entered by /vellum:start), when plan.md is missing, when the plan is approved, and while the review is held (a grill, a plan review): plan.md then waits, a prompt tells you once the hold ends, and the end of that turn records it.",
17 inputSchema: { type: "object" },
18};
19
20/**
21 * Submits `plan.md` and says where it stands. A recorded version is announced in the
22 * transcript, and the band draws it from the next `stage` line; a kept one changes nothing; a
23 * refusal (no `plan.md` yet, the plan approved) is the caller's to read; a server that does not
24 * answer is a rejection.
25 */
26export async function submitPlan(host: Host, live: Live, unchanged: Unchanged): Promise<GateWire> {
27 const gate = await live.server.gate(unchanged);
28
29 if ("error" in gate || gate.kept) return gate;
30 host.log(`plan v${gate.version} is under review in the browser`);
31
32 return gate;
33}
34
35/**
36 * What the model reads from `submit`. A kept version reads as a recorded one: the model ends
37 * its turn on both, and the skill `start` already says the review arrives as a prompt.
38 */
39export function submitResult(gate: GateWire): { result: string } | { deny: string } {
40 return "error" in gate
41 ? { deny: gate.error }
42 : { result: `Plan v${gate.version} under review. End your turn.` };
43}
44
45/**
46 * The gate at the main loop's answer: an unchanged text is kept, so a turn that answered a
47 * question opens no version, and a server that does not answer is one log line.
48 */
49export async function gateAtTurnEnd(host: Host, live: Live): Promise<void> {
50 await submitPlan(host, live, "keep").catch((cause: unknown) => {
51 host.log(`plan.md was not submitted at the turn's end: ${String(cause)}`);
52 });
53}
54src/runtime/hooks/band.ts 25 lines1import { pageUrl } from "./client.ts";
2import type { ServerInfo } from "./mode.ts";
3import type { DrawnWire } from "./parse.ts";
4
5/** What the band above the prompt draws after vellum's name, in order: the segments, then the page's link. */
6export type Band = { readonly segments: readonly string[]; readonly href: string };
7
8/** `LinkProps.href` takes `https:` or `http://localhost` alone, and refuses the whole tree over `http://127.0.0.1`. */
9function pageHref(info: ServerInfo): string {
10 return pageUrl(info, "localhost");
11}
12
13/** The segments the server sent, the plan's first, then its pill: nothing before its first `stage` line. */
14export function liveBand(info: ServerInfo, drawn: DrawnWire | null): Band {
15 return {
16 segments: drawn === null ? [] : [...drawn.segments, drawn.pill.text],
17 href: pageHref(info),
18 };
19}
20
21/** A server that is gone says nothing of the plan: the status line says what went wrong. */
22export function lostBand(info: ServerInfo): Band {
23 return { segments: [], href: pageHref(info) };
24}
25src/runtime/hooks/extension.ts 277 lines1import type { HttpResponse, PromptOrigin, ToolSpec, TurnCompleteReason } from "claude-code";
2
3import type {
4 AnswerOf,
5 BodyOf,
6 GetOf,
7 GetRoute,
8 Listen,
9 Json,
10 Parser,
11 Plugs,
12 PostOf,
13 PostRoute,
14 Undeclared,
15} from "../../workshop/plugs.ts";
16import type { Host } from "./host.ts";
17import type { Live } from "./mode.ts";
18import type { ChannelEntryWire } from "./parse.ts";
19
20/**
21 * The engine half of an extension. Claude Code takes one hooks module per plugin and one
22 * unmatched hook per event, so an extension never calls `on(...)`: `register.ts` keeps every
23 * event and calls these handlers, each handed a `Host`, never `$`.
24 */
25
26export type ToolAnswer =
27 | { readonly result: string }
28 /** The result is the channel's entry `returns`, which the follower then never relays. */
29 | { readonly result: string; readonly returns: number }
30 | { readonly deny: string };
31
32/** The extension's own routes on the review server, `/api/x/<id>/<path>`, token header set. */
33export type ExtensionApi = {
34 get: (path: string) => Promise<HttpResponse>;
35 /** `json` is the body, serialized: the extension types it in its own `protocol.ts`. */
36 post: (path: string, json: string) => Promise<HttpResponse>;
37};
38
39export type EngineContext = {
40 readonly host: Host;
41 readonly live: Live;
42 readonly api: ExtensionApi;
43};
44
45/**
46 * A call of an extension's tool. `waiting` says the call now waits for the reviewer: from then
47 * on the follower holds each entry the tool `awaits` until the call answers, and a call that
48 * fails answers Claude that the reviewer's answer comes as a prompt, never a permission prompt.
49 */
50export type ToolContext = EngineContext & { readonly waiting: () => void };
51
52export type ExtensionTool = {
53 /** Registered as `mcp__vellum__<name>`, one name per tool across the extensions (`register.spec.ts`). */
54 readonly name: string;
55 readonly description: string;
56 readonly inputSchema: NonNullable<ToolSpec["inputSchema"]>;
57 /** The entries a call that waits may return as its result: `grill_ask`, the batch that closes its round; `propose`, the answer to it. */
58 readonly awaits?: (entry: ChannelEntryWire) => boolean;
59 // oxlint-disable-next-line anti-slop/no-unknown-parameters -- `input` is the tool call as the engine hands it, the model's own arguments; the extension's `parse.ts` is the boundary that reads it.
60 readonly call: (context: ToolContext, input: unknown) => Promise<ToolAnswer>;
61};
62
63export type Prompted = { readonly text: string; readonly origin: PromptOrigin };
64
65/**
66 * `own`: a vellum relay started the turn, or a waiting tool returned the reviewer's entry in it,
67 * so its text answers the reviewer, not the terminal.
68 */
69export type Answered = {
70 readonly text: string;
71 readonly reason: string;
72 readonly own: boolean;
73};
74
75/** A subagent's turn, as its `turn.complete` carries it: `agentId` is the one `Host.spawnAgent` answered. */
76export type AgentAnswered = {
77 readonly agentId: string;
78 readonly text: string;
79 readonly reason: TurnCompleteReason;
80};
81
82/**
83 * The hooks module's own representation of a part, which no part writes: `hooks/slices.ts` makes
84 * it from the part's `HooksHalf` through `engineExtension`.
85 */
86export type EngineExtension = {
87 readonly id: string;
88 readonly tools?: readonly ExtensionTool[];
89 /** Tools denied while live, by the engine's tool name, with the reason the model reads. */
90 readonly refuses?: Readonly<Record<string, string>>;
91 /** Every prompt that enters while live, but the ones vellum itself submits. */
92 readonly prompted?: (context: EngineContext, prompt: Prompted) => Promise<void>;
93 /** The main loop's turn only, after the core's own gate. */
94 readonly answered?: (context: EngineContext, turn: Answered) => Promise<void>;
95 /** A subagent's turn, any subagent's, while live: each half tells its own agents from the rest; `review` reads its run off the server. */
96 readonly agentAnswered?: (context: EngineContext, turn: AgentAnswered) => Promise<void>;
97 /**
98 * Each time the server says the review changed, a `stage` line, while live: what the extension
99 * reads again to act on it. A throw is logged and the next extension runs.
100 */
101 readonly staged?: (context: EngineContext) => Promise<void>;
102 /**
103 * The mode closes, by `/vellum:stop` or by the approval (`settle`), while the server still
104 * answers: what the extension must end in the session, it ends here. What the approval must
105 * close on the server is closed there, by the server half's `approved`, module alive or not.
106 */
107 readonly closing?: (context: EngineContext) => Promise<void>;
108};
109
110type Handlers<C> = {
111 readonly prompted: (context: C, prompt: Prompted) => Promise<void>;
112 readonly answered: (context: C, turn: Answered) => Promise<void>;
113 readonly agentAnswered: (context: C, turn: AgentAnswered) => Promise<void>;
114 readonly staged: (context: C) => Promise<void>;
115 readonly closing: (context: C) => Promise<void>;
116};
117
118/** The engine events a half may listen to, as the workshop names them, each handed the half's own context `C`. */
119export type Listeners<C> = { readonly [Event in Listen]: Handlers<C>[Event] };
120
121export type { Listen };
122
123/**
124 * What a slice's route answered the hooks half: the answer its plugs declare, read by the route's
125 * own parser in `answers`; or the status, the text and, when the route refused, why.
126 */
127export type Posted<A> =
128 | { readonly ok: true; readonly answer: A }
129 | {
130 readonly ok: false;
131 readonly status: number;
132 readonly text: string;
133 readonly reason: string | null;
134 };
135
136/** One parser per route the hooks half posts or reads, reading the answer its plugs declare; `null` for a route that answers nothing (204). */
137export type Answers<P extends Plugs> = {
138 readonly [Route in P["hooks"]["posts"] | P["hooks"]["gets"]]: AnswerOf<
139 P["server"],
140 Route
141 > extends null
142 ? null
143 : Parser<AnswerOf<P["server"], Route>>;
144};
145
146/** A slice's client of its own server half: a route its plugs let it post, with the body they declare, answering the answer they declare. */
147export type SlicePost<P extends Plugs> = <Route extends P["hooks"]["posts"]>(
148 route: Route,
149 body: BodyOf<P["server"], Route>,
150) => Promise<Posted<AnswerOf<P["server"], Route>>>;
151
152/** The same client reading a route its plugs let it read, answering the answer they declare. */
153export type SliceGet<P extends Plugs> = <Route extends P["hooks"]["gets"]>(
154 route: Route,
155) => Promise<Posted<AnswerOf<P["server"], Route>>>;
156
157export type HooksContext<P extends Plugs> = {
158 readonly host: Host;
159 readonly live: Live;
160 readonly post: SlicePost<P>;
161 readonly get: SliceGet<P>;
162 /**
163 * What a call of the half's in this mode waited on and heard nothing back for, taken:
164 * `undefined` once the wait ended. A listener reads it at the turn's end, as a turn cut short
165 * leaves it.
166 */
167 readonly unanswered: () => string | undefined;
168};
169
170/**
171 * A call's wait for the reviewer: `route` posted with `body`, held by the server under the
172 * engine's 30 s cut, again and again until `settle` answers what the call returns. `mark` names
173 * what the call waits on, kept until the wait ends, so a turn cut short reads it back
174 * (`unanswered`). A post that fails is asked again at once up to `attempts` times, so a `$` call
175 * stays in flight and the hook's budget never runs: after a crash the second one usually reaches
176 * the revived server. One that fails every time throws, and the core's `.catch` answers Claude
177 * that the reviewer's answer comes as a prompt.
178 */
179export type Hold<P extends Plugs, Route extends P["hooks"]["posts"]> = {
180 readonly mark: string;
181 readonly route: Route;
182 readonly body: BodyOf<P["server"], Route>;
183 readonly attempts: 1 | 2;
184 /** What the call returns once the wait ended, `null` while it is still open. */
185 readonly settle: (answer: AnswerOf<P["server"], Route>) => ToolAnswer | null;
186};
187
188/** What a slice's tool call is handed: its half's context, and the wait for the reviewer. */
189export type ToolCallContext<P extends Plugs> = HooksContext<P> & {
190 readonly waitFor: <Route extends P["hooks"]["posts"]>(
191 hold: Hold<P, Route>,
192 ) => Promise<ToolAnswer>;
193};
194
195/** A tool of a slice, registered under its key in `tools` as `mcp__vellum__<key>`. */
196export type HooksTool<C> = {
197 readonly description: string;
198 readonly inputSchema: NonNullable<ToolSpec["inputSchema"]>;
199 /**
200 * The entries of the channel a call that waits may return as its result, which the follower
201 * holds for it: `"own"` for the text entries of its own slice.
202 */
203 readonly awaits?: "own" | ((entry: ChannelEntryWire) => boolean);
204 readonly call: (
205 context: C,
206 // oxlint-disable-next-line anti-slop/no-unknown-parameters -- `input` is the tool call as the engine hands it, the model's own arguments; the slice's `parse.ts` is the boundary that reads it.
207 input: unknown,
208 ) => Promise<ToolAnswer>;
209};
210
211/**
212 * What a slice's `hooks.ts` fills: one tool per name its plugs declare, one listener per engine
213 * event they declare, a parser per route they let it post or read, and nothing else. A route it
214 * posts or reads that the server does not declare is a property no half can fill.
215 */
216export type HooksHalf<P extends Plugs> = {
217 readonly id: P["id"];
218 readonly tools: ContractTools<P>;
219 readonly answers: Answers<P>;
220} & Pick<Listeners<HooksContext<P>>, P["hooks"]["listens"]> & {
221 readonly [Unheard in Exclude<Listen, P["hooks"]["listens"]>]?: Undeclared<
222 `${Unheard} is not declared in contract.ts: add it to hooks.listens`,
223 Listeners<HooksContext<P>>[Unheard]
224 >;
225 } & {
226 readonly [
227 Stray in Exclude<P["hooks"]["posts"], PostOf<P["server"]>>
228 ]: Undeclared<`${Stray} is in hooks.posts of contract.ts, not in its routes: declare the route`>;
229 } & {
230 readonly [
231 Stray in Exclude<P["hooks"]["gets"], GetOf<P["server"]>>
232 ]: Undeclared<`${Stray} is in hooks.gets of contract.ts, not in its routes: declare the route`>;
233 } & Denying<P>;
234
235/** The tools of `hooks.tools` in the slice's `contract.ts`, each registered as `mcp__vellum__<name>`: no other. */
236export type ContractTools<P extends Plugs> = {
237 readonly [Name in P["hooks"]["tools"]]: HooksTool<ToolCallContext<P>>;
238};
239
240/** The engine's tools the half denies while the mode is live, each with the reason the model reads. */
241type Denying<P extends Plugs> = [P["hooks"]["denies"]] extends [never]
242 ? {
243 readonly refuses?: Undeclared<
244 "refuses is not declared in contract.ts: add the tools to hooks.denies",
245 Readonly<Record<string, string>>
246 >;
247 }
248 : { readonly refuses: { readonly [Tool in P["hooks"]["denies"]]: string } };
249
250/** A hooks half with its plugs forgotten, as `engineExtension` takes it: every `HooksHalf` is one. */
251export type ErasedContext = {
252 readonly host: Host;
253 readonly live: Live;
254 readonly post: (route: PostRoute, body: Json) => Promise<Posted<never>>;
255 readonly get: (route: GetRoute) => Promise<Posted<never>>;
256 readonly unanswered: () => string | undefined;
257};
258
259export type ErasedHold = {
260 readonly mark: string;
261 readonly route: PostRoute;
262 readonly body: Json;
263 readonly attempts: 1 | 2;
264 readonly settle: (answer: never) => ToolAnswer | null;
265};
266
267export type ErasedToolContext = ErasedContext & {
268 readonly waitFor: (hold: ErasedHold) => Promise<ToolAnswer>;
269};
270
271export type ErasedHooks = {
272 readonly id: string;
273 readonly tools: { readonly [name: string]: HooksTool<ErasedToolContext> };
274 readonly answers: { readonly [route: string]: Parser<Json> | null };
275 readonly refuses?: { readonly [tool: string]: string };
276} & Partial<Listeners<ErasedContext>>;
277src/runtime/hooks/host.ts 93 lines1import type {
2 AgentInfo,
3 AgentSpawnArgs,
4 AgentSpawnResult,
5 FsStat,
6 HookStream,
7 HttpInit,
8 HttpResponse,
9 ProcessSpawnChunk,
10 ProcessSpawnRequest,
11 ProcessSpawnResult,
12 PromptSubmitResult,
13 TimerCall,
14 ToolCallArgs,
15 ToolCallResult,
16} from "claude-code";
17
18/**
19 * The engine as a hook bound it from its `$`, each member spelled `$.noun.event(...)` there.
20 *
21 * The loader follows `$` only into a function declared in the file that registers the hook,
22 * so every other file of the module takes this instead: "$ is followed only into a function
23 * declared in this same file, never across an import; $ is always spelled $.noun.event(...)
24 * at the call site".
25 */
26export type Host = {
27 sessionId: () => Promise<string>;
28
29 cwd: () => Promise<string>;
30
31 /** `$.session.root()`: where the session started; a shell `cd` does not move it. */
32 root: () => Promise<string>;
33
34 /** `$.fs.stat(path, { resolve: true })`: `realPath` is where the path lands, and a missing path rejects. */
35 stat: (path: string) => Promise<FsStat>;
36
37 readonly pluginRoot: string;
38
39 // oxlint-disable-next-line anti-slop/no-unknown-returns -- the plugin store keeps whatever a plugin put in it; `unknown` is the engine's own result type (`ResultOf['store.get']`), and `parse.ts` is what reads it.
40 storeGet: (key: string) => Promise<unknown>;
41
42 // oxlint-disable-next-line anti-slop/no-unknown-parameters -- the store takes any JSON value, as `$.store.set` does; the module's own records are typed where they are built.
43 storeSet: (key: string, value: unknown) => Promise<void>;
44
45 storeDelete: (key: string) => Promise<void>;
46
47 fetch: (url: string, init?: HttpInit) => Promise<HttpResponse>;
48
49 /**
50 * `$.process.spawn`: the child lives as long as its stream is read, and dies with the module.
51 * Spawned from `session.start`, `skill.prompt` or a timer, never from a `tool.call`, whose
52 * Escape ends the child with the call.
53 */
54 spawn: (request: ProcessSpawnRequest) => HookStream<ProcessSpawnChunk, ProcessSpawnResult>;
55
56 every: TimerCall;
57
58 after: TimerCall;
59
60 /** `$.clock.now()`: milliseconds since the epoch, as the engine's clock reads them. */
61 now: () => Promise<number>;
62
63 submitPrompt: (text: string) => Promise<PromptSubmitResult>;
64
65 /**
66 * `$.agent.spawn`: resolves once the subagent started, in the background. A subagent this
67 * module spawns steps past every hook of vellum's but `turn.complete`, which carries its final
68 * text under the `agentId` this answers.
69 */
70 spawnAgent: (args: AgentSpawnArgs) => Promise<AgentSpawnResult>;
71
72 listAgents: () => Promise<AgentInfo[]>;
73
74 /**
75 * `$.tool.call(call)`: a tool of the session run by the module. The module's own hooks step past
76 * it, as its spawned agents do.
77 */
78 callTool: (call: ToolCallArgs) => Promise<ToolCallResult>;
79
80 status: (text: string | undefined) => void;
81
82 /** `$.ui.invalidate("ui.render")`: the engine asks the band again. */
83 invalidate: () => void;
84
85 log: (text: string) => void;
86
87 /** `$.env.get("CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR")` */
88 projectCwdFlag: () => Promise<string | undefined>;
89
90 /** `$.env.set("CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR", value)`; `undefined` unsets it. */
91 setProjectCwdFlag: (value: string | undefined) => Promise<void>;
92};
93src/runtime/hooks/lock.ts 152 lines1import type { HookFailure, ResultOf } from "claude-code";
2
3import type { ShellCall, Workdir } from "./parse.ts";
4
5/**
6 * `allow` runs the call whatever the session's mode, `check` hands it to the session's own
7 * permission flow, `deny` refuses it with the reason the model reads.
8 */
9export type Verdict =
10 | { readonly kind: "allow" }
11 | { readonly kind: "check" }
12 | { readonly kind: "deny"; readonly reason: string };
13
14/** Where `\` separates names, as `/` does, or is a character of one. */
15export type Platform = "posix" | "windows";
16
17/**
18 * Where the call's file and the project land, as `placed` answers them: `file` is `null` when
19 * nothing can tell.
20 */
21export type Landed = {
22 readonly file: string | null;
23 readonly project: string;
24 readonly platform: Platform;
25};
26
27/** `realPath` answers the platform's own separator, and `placed` joins a missing tail with `/`. */
28const SEPARATORS = { posix: /\/+/gu, windows: /[\\/]+/gu } as const;
29
30/** One spelling to compare: `/` alone between segments and none at the end, so `/` is `""`. */
31function spelled(path: string, platform: Platform): string {
32 const joined = path.replaceAll(SEPARATORS[platform], "/");
33
34 return joined.endsWith("/") ? joined.slice(0, -1) : joined;
35}
36
37function holds(root: string, file: string): boolean {
38 return file.startsWith(`${root}/`);
39}
40
41/**
42 * While vellum plans, the files a call may write are the working directory's, and it writes
43 * them outright, since the directory is vellum's own and the page shows every file in it.
44 * A file outside the project is no change to the codebase, so the session's own flow decides
45 * it: the scratchpad passes there without a prompt, and a write to a home or system file still
46 * asks. Every other tool goes to that flow too, so reads are
47 * untouched.
48 *
49 * The verdict compares where the paths land, so a symbolic link, a `..` or a platform's other
50 * spelling of a file is already that file. The working directory is the project's own: where
51 * the project lands, then `workdir` as written, so a link on the way to it, or the directory
52 * itself a link, leads out of it and allows nothing. `realPath` keeps a case alias as written:
53 * the allow compares as written and the deny folds the case, so on a volume that folds it
54 * (NTFS, APFS) another case never takes a project file to the session's flow. Where the case
55 * counts, the cost is a deny on `/work/PROJ` beside a project at `/work/proj`. A file that
56 * lands nowhere known is denied, since the tool may still open it.
57 */
58export function lockVerdict(path: string, workdir: Workdir, landed: Landed): Verdict {
59 if (landed.file === null) {
60 return {
61 kind: "deny",
62 reason: `vellum is planning and cannot tell where ${path} lands; name the file by its full path`,
63 };
64 }
65
66 const file = spelled(landed.file, landed.platform);
67 const project = spelled(landed.project, landed.platform);
68
69 if (holds(spelled(`${project}/${workdir}`, landed.platform), file)) return { kind: "allow" };
70
71 return holds(project.toLowerCase(), file.toLowerCase())
72 ? {
73 kind: "deny",
74 reason: `vellum is planning: files outside ${workdir} change after the plan is approved`,
75 }
76 : { kind: "check" };
77}
78
79/**
80 * The lock's answer when its own hook failed. A `tool.check` hook that throws or overruns is
81 * skipped and what is beneath runs in its place, which opens the lock; this closes it, whether
82 * the failure landed before or after `next(e)`.
83 */
84export function lockFailed(kind: HookFailure["kind"]): ResultOf["tool.check"] {
85 return { decision: "deny", reason: `the lock failed (${kind}); retry the call` };
86}
87
88/**
89 * The tools that run a shell command. The engine offers `PowerShell` beside `Bash`, by default
90 * on Windows, and its docs tell a hook that inspects shell commands to match `Bash|PowerShell`;
91 * `Monitor` runs its command through the shell, under Bash's rules.
92 */
93export const SHELLS: ReadonlySet<string> = new Set(["Bash", "PowerShell", "Monitor"]);
94
95/**
96 * A move into a directory and its target, quoted or not, a character escaped by `\` kept: `cd`,
97 * `pushd`, `chdir`, and PowerShell's `Set-Location`, `Push-Location` and `sl`, where a command
98 * starts, each after the flags it takes.
99 */
100const MOVE =
101 /(?:^|[;&|(\n])\s*(?:cd|pushd|chdir|set-location|push-location|sl)\s+(?:-\S+\s+)*("[^"]*"|'[^']*'|(?:\\.|[^\s;&|)])+)/gu;
102
103/**
104 * A command that puts a process in the background itself: a lone `&` (not `&&`, `2>&1`, `&>` or
105 * `|&`), or `nohup`, `setsid`, `Start-Process` or `Start-Job` where a command starts.
106 */
107const DETACHED = /(?<![&>|])&(?![&>])|(?:^|[;&|(\n])\s*(?:nohup|setsid|start-process|start-job)\b/u;
108
109/**
110 * A shell call that enters the working directory, or names it from the background, is denied
111 * while vellum plans: on Windows a process standing in a folder holds it, and the approval
112 * cannot rename it. A command that ends gives the folder back, since the session returns to the
113 * project's root after each one; one in the background holds it for as long as it runs. Only
114 * the folder's name as written is read: a path the shell computes (`cd "$D"`) passes, and the
115 * return to the root covers it.
116 */
117export function shellVerdict(call: ShellCall, workdir: Workdir): Verdict {
118 const command = call.command.toLowerCase();
119 const segment = workdir.split("/").findLast((part) => part !== "") ?? workdir;
120 const folder = segment.toLowerCase();
121
122 if (call.background || DETACHED.test(command)) {
123 return command.includes(folder)
124 ? {
125 kind: "deny",
126 reason: `vellum is planning: a background command that uses ${workdir} keeps the folder busy and blocks the approval on Windows; run it in the foreground`,
127 }
128 : { kind: "check" };
129 }
130
131 return [...command.matchAll(MOVE)].some(([, target]) => target?.includes(folder) === true)
132 ? {
133 kind: "deny",
134 reason: `vellum is planning: stay at the project root and name files from there; a cd into ${workdir} keeps the folder busy and blocks the approval on Windows`,
135 }
136 : { kind: "check" };
137}
138
139/**
140 * A settings allow rule (`Bash(mkdir:*)`, `PowerShell(Set-Content:*)`) would let a
141 * file-modifying shell command past the lock, as the native plan mode never does: the lock
142 * answers `ask` instead, which the engine puts to the mode's decider, a prompt in the manual
143 * mode. An allow with no rule is the mode's own and stands: the built-in read-only set (`git
144 * log`, `ls`), and in `acceptEdits` the filesystem commands that mode approves (`mkdir`, `mv`,
145 * `Set-Content`), which still write into the project while vellum plans.
146 */
147export function checkVerdict(tool: string, engine: ResultOf["tool.check"]): ResultOf["tool.check"] {
148 return SHELLS.has(tool) && engine.decision === "allow" && engine.rule !== undefined
149 ? { ...engine, decision: "ask" }
150 : engine;
151}
152src/runtime/hooks/mode.ts 464 lines1import type { ProcessSpawnResult, Timer } from "claude-code";
2
3import { type Child, type Launched, read, type ReviewServer, start } from "./client.ts";
4import type { Host } from "./host.ts";
5import {
6 parseSession,
7 projectDir,
8 type ProjectDir,
9 sessionId,
10 type SessionId,
11 type DrawnWire,
12 type Token,
13 type Workdir,
14 workdirOf,
15} from "./parse.ts";
16import { follow, type Follower } from "./relay.ts";
17
18export const HEARTBEAT_MS = 30_000;
19
20export const LOST_RETRY_MS = 30_000;
21
22/** Heartbeats in a row a server may leave unanswered before it is ended, and revived. */
23const HEARTBEATS_MISSED = 2;
24
25/** Unexpected ends of a mode's servers within `CRASH_WINDOW_MS` that stop the revivals. */
26export const CRASHES_BEFORE_LOST = 3;
27
28export const CRASH_WINDOW_MS = 60_000;
29
30const STATUS_LOST = "server lost, retrying";
31
32const STATUS_GONE = "working directory gone, run /vellum:stop";
33
34export type ServerInfo = { readonly port: number; readonly token: Token; readonly pid: number };
35
36/**
37 * What `$.store` keeps under `session:<id>`, so a reloaded module finds its server again.
38 * `project` is the root the server was started in: the working directory hangs off it, while
39 * the session's own directory moves with every `cd` the model runs. `final` is where an approval
40 * renamed the working directory, once the server said so: a server revived before the approval
41 * reached Claude is started there. `pinnedCwd` says vellum set
42 * `CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR` as the mode began and unsets it as the mode returns
43 * to idle; the record keeps it, so a reload or a `claude --resume` still knows the value is vellum's.
44 */
45export type Session = {
46 readonly id: SessionId;
47 readonly server: ServerInfo;
48 readonly project: ProjectDir;
49 readonly workdir: Workdir;
50 readonly final: Workdir | null;
51 readonly pinnedCwd: boolean;
52};
53
54/**
55 * What a mode keeps across the servers a revival replaces, and drops when it leaves: the follower
56 * of its channel, and when its servers ended unexpectedly, within `CRASH_WINDOW_MS`.
57 */
58export type Tenure = { readonly follower: Follower; readonly crashes: readonly number[] };
59
60/** A review server this module spawned, the timer that keeps it alive, and the mode's tenure. */
61export type Live = {
62 readonly session: Session;
63 readonly server: ReviewServer;
64 readonly child: Child;
65 readonly heartbeat: Timer;
66 readonly tenure: Tenure;
67};
68
69/**
70 * The vellum mode, entered by `/vellum:start` and left by Approve or `/vellum:stop`. While
71 * `live` a server answers, the lock holds, and what the server writes on its stdout is read.
72 * What is under review lives on the server's disk; every transition here is an engine event or
73 * a line of that server.
74 */
75export type State =
76 | { readonly kind: "idle" }
77 | { readonly kind: "live"; readonly live: Live }
78 /** The server is gone and did not come back: the lock holds, a slow timer retries. */
79 | {
80 readonly kind: "lost";
81 readonly session: Session;
82 readonly retry: Timer;
83 readonly tenure: Tenure;
84 };
85
86/**
87 * How the relayed approval closes the mode of session `id`: `register.ts` owns the one `state`,
88 * and closes it only while it holds that session, live or lost, so an approval that lands after a
89 * way into another session leaves that mode alone.
90 */
91export type Settle = (host: Host, id: SessionId) => Promise<void>;
92
93/**
94 * What the band draws, each time the server says the review changed, then the extensions' part.
95 * `register.ts` owns the band and the registry.
96 */
97export type Staged = (host: Host, live: Live, drawn: DrawnWire) => Promise<void>;
98
99/**
100 * A mode whose server ended while it was the current one, `how` it ended; `null` for the slow
101 * retry of `lost`. `register.ts` swaps in the revived mode, or `lost`.
102 */
103export type Revive = (host: Host, from: State, how: ProcessSpawnResult | null) => Promise<void>;
104
105/** What `register.ts` hands every way into the mode: it owns the one `state` and the registry. */
106export type Wiring = {
107 readonly settle: Settle;
108 readonly staged: Staged;
109 readonly revive: Revive;
110 /** Whether `from` is still the current state: a server that ends after its mode left is no crash. */
111 readonly current: (from: State) => boolean;
112};
113
114/** The session the lock reads: the mode holds one while `live` and while `lost` alike. */
115export function sessionOf(state: State): Session | null {
116 if (state.kind === "idle") return null;
117
118 return state.kind === "live" ? state.live.session : state.session;
119}
120
121/** What the mode keeps across its servers; `null` while idle. */
122export function tenureOf(state: State): Tenure | null {
123 if (state.kind === "idle") return null;
124
125 return state.kind === "live" ? state.live.tenure : state.tenure;
126}
127
128export function sessionKey(id: SessionId): string {
129 return `session:${id}`;
130}
131
132function storedSession(host: Host, id: SessionId): Promise<Session | null> {
133 return host.storeGet(sessionKey(id)).then(parseSession);
134}
135
136/** The values Claude Code reads as true in a variable. */
137const TRUE_VALUES: ReadonlySet<string> = new Set(["1", "true", "yes", "on"]);
138
139/**
140 * Whether vellum pins the working directory for the mode it opens. A record that says so keeps
141 * it: a reload or a resume finds vellum's own value set. Otherwise a value already true while
142 * `state` holds no session vellum pinned is the person's, and vellum never touches it.
143 */
144async function pinsCwd(host: Host, state: State, stored: Session | null): Promise<boolean> {
145 if (stored?.pinnedCwd === true) return true;
146
147 // A value nobody can read is nobody's: vellum sets it, and a failed write is only logged.
148 const value = await host.projectCwdFlag().catch((cause: unknown) => {
149 host.log(`the project's working directory could not be read: ${String(cause)}`);
150
151 return null;
152 });
153
154 const set = value !== null && value !== undefined && TRUE_VALUES.has(value.trim().toLowerCase());
155
156 return !set || sessionOf(state)?.pinnedCwd === true;
157}
158
159/** The tenure `state` holds for session `id`, else a new one: one follower per session. */
160function tenureFor(host: Host, state: State, id: SessionId, wiring: Wiring): Tenure {
161 const held = tenureOf(state);
162
163 if (held !== null && sessionOf(state)?.id === id) return held;
164
165 return { follower: follow(host, id, () => wiring.settle(host, id)), crashes: [] };
166}
167
168/** A server for a session, spawned, before any timer starts: the caller may still drop it. */
169type Found =
170 | {
171 readonly kind: "up";
172 readonly session: Session;
173 readonly server: ReviewServer;
174 readonly child: Child;
175 readonly channel: string;
176 }
177 | Exclude<Launched, { kind: "up" }>;
178
179/**
180 * A new server on the port, the token and the directory the store kept, the final one once an
181 * approval renamed it. A server this module did not spawn cannot be read, so the kept one is never
182 * taken back: a reload has ended it, and a port another process still holds gives the new server
183 * another port, under a new token.
184 */
185async function relaunched(host: Host, stored: Session): Promise<Found> {
186 const { id, project, workdir, server, final } = stored;
187 const launched = await start(host, id, project, workdir, server, final);
188
189 if (launched.kind !== "up") return launched;
190
191 return { ...launched, session: { ...stored, server: launched.server.info } };
192}
193
194function stopTimers(state: State): void {
195 if (state.kind === "idle") return;
196
197 if (state.kind === "lost") {
198 state.retry.cancel();
199
200 return;
201 }
202
203 state.live.heartbeat.cancel();
204}
205
206/** A state a transition never took: its timers stop and its server ends. */
207export function discard(state: State): void {
208 stopTimers(state);
209
210 if (state.kind === "live") state.live.child.end();
211}
212
213/**
214 * Never `idle`: the lock opens outside the mode, so a server that fails would hand Claude the
215 * repository without an approval. The session is kept, and the retry goes through `revive`.
216 */
217function lose(
218 host: Host,
219 session: Session,
220 why: "gone" | "failed",
221 wiring: Wiring,
222 tenure: Tenure,
223): State {
224 host.status(why === "gone" ? STATUS_GONE : STATUS_LOST);
225
226 // oxlint-disable-next-line unicorn/no-array-callback-reference -- `host.every` is `$.clock.every`, a timer, not `Array.prototype.every`.
227 const retry = host.every(LOST_RETRY_MS, () => {
228 wiring.revive(host, lost, null).catch((cause: unknown) => {
229 host.log(`the review server was not revived: ${String(cause)}`);
230 });
231 });
232
233 const lost: State = { kind: "lost", session, retry, tenure };
234
235 return lost;
236}
237
238async function settled(
239 host: Host,
240 stored: Session,
241 got: Found,
242 wiring: Wiring,
243 tenure: Tenure,
244): Promise<State> {
245 if (got.kind !== "up") return lose(host, stored, got.kind, wiring, tenure);
246 await host.storeSet(sessionKey(got.session.id), got.session);
247
248 return enter(host, got, wiring, tenure);
249}
250
251/**
252 * Enters the mode on a spawned server: reads what it writes for as long as it runs, hands the
253 * channel to the tenure's follower, and keeps it alive. A server that ends while its mode is the
254 * current one is revived, and so is one that leaves `HEARTBEATS_MISSED` heartbeats unanswered,
255 * ended first. A line this module does not read is logged, never taken for nothing.
256 */
257function enter(
258 host: Host,
259 got: Extract<Found, { kind: "up" }>,
260 wiring: Wiring,
261 tenure: Tenure,
262): State {
263 const { session, server, child, channel } = got;
264 const current = (): boolean => wiring.current(entered);
265 let missed = 0;
266 let final = session.final;
267
268 // oxlint-disable-next-line unicorn/no-array-callback-reference -- `host.every` is `$.clock.every`, a timer, not `Array.prototype.every`.
269 const heartbeat = host.every(HEARTBEAT_MS, () => {
270 tenure.follower.retry();
271 void server.heartbeat().then((answered) => {
272 missed = answered ? 0 : missed + 1;
273
274 if (missed < HEARTBEATS_MISSED) return;
275 host.log(`the review server left ${missed} heartbeats unanswered: ended, to be revived`);
276 child.end();
277 });
278 });
279
280 const live: Live = { session, server, child, heartbeat, tenure };
281 const entered: State = { kind: "live", live };
282
283 const revive = (how: ProcessSpawnResult): void => {
284 wiring.revive(host, entered, how).catch((cause: unknown) => {
285 host.log(`the review server was not revived: ${String(cause)}`);
286 });
287 };
288
289 // One stage at a time, in the order written: an extension's read for an older one never lands last.
290 let staging = Promise.resolve();
291
292 void read(child.lines, {
293 line: (line) => {
294 if (line.type === "channel") tenure.follower.hand(line.line);
295
296 if (line.type !== "stage") return;
297
298 // Where the review lives once approved, before the approval reaches Claude: a server
299 // revived meanwhile is started there, and relays it.
300 if (line.stage.kind === "approved" && final === null && current()) {
301 final = line.dir;
302 void host
303 .storeSet(sessionKey(session.id), { ...session, final })
304 .catch((cause: unknown) => {
305 host.log(`the approved directory was not kept: ${String(cause)}`);
306 });
307 }
308
309 staging = staging
310 .then(() => wiring.staged(host, live, line.drawn))
311 .catch((cause: unknown) => {
312 host.log(`the review's stage was not drawn: ${String(cause)}`);
313 });
314 },
315 unread: (text) => {
316 host.log(`the review server wrote a line this module does not read: ${text.slice(0, 200)}`);
317 },
318 ended: (how) => {
319 if (!current()) return;
320 host.log(`the review server ended: ${JSON.stringify(how)}`);
321 revive(how);
322 },
323 }).catch((cause: unknown) => {
324 if (!current()) return;
325 host.log(`the review server could not be read: ${String(cause)}`);
326 revive({ code: null, signal: null });
327 });
328
329 tenure.follower.serve(server, channel);
330 // The band shows the mode; the status line is kept for what went wrong, and a revival ends it.
331 host.status(undefined);
332
333 return entered;
334}
335
336/**
337 * Stops the mode's timers and forgets nothing: the session's record stays, so a `/resume` of
338 * that session later finds its directory and restarts a server on it. `register.ts` ends the
339 * server as the mode leaves.
340 */
341export function suspend(host: Host, state: State): State {
342 if (state.kind === "idle") return state;
343 stopTimers(state);
344 host.status(undefined);
345
346 return { kind: "idle" };
347}
348
349/**
350 * Leaves the mode. What was relayed stays in the store: a `/vellum:start` after a stop reuses
351 * the session's directory, and what Claude already read must not be named again.
352 */
353export async function close(host: Host, state: State): Promise<State> {
354 const session = sessionOf(state);
355
356 if (session === null) return state;
357 await host.storeDelete(sessionKey(session.id));
358
359 return suspend(host, state);
360}
361
362/**
363 * `session.start`: a reload ended every server this module spawned, so the mode the store kept
364 * comes back on a relaunched one. The mode of another session is left first; this session's own
365 * live mode is kept.
366 */
367export async function restore(host: Host, state: State, wiring: Wiring): Promise<State> {
368 const id = sessionId(await host.sessionId());
369
370 if (state.kind === "live" && state.live.session.id === id) return state;
371 const kept = await storedSession(host, id);
372
373 if (kept === null) return state;
374 const stored = { ...kept, pinnedCwd: await pinsCwd(host, state, kept) };
375 stopTimers(state);
376 const tenure = tenureFor(host, state, id, wiring);
377
378 return settled(host, stored, await relaunched(host, stored), wiring, tenure);
379}
380
381/** When the mode's servers ended unexpectedly, this one at `now` included, within the window. */
382function crashesUntil(tenure: Tenure, now: number): readonly number[] {
383 return [...tenure.crashes, now].filter((at) => now - at < CRASH_WINDOW_MS);
384}
385
386/**
387 * `from`'s server ended, `how` it did; `null` for the slow retry of `lost`. A server that ended
388 * `CRASHES_BEFORE_LOST` times within `CRASH_WINDOW_MS` is not revived: the mode goes `lost`, and
389 * its slow retry paces what comes next. `null` when `from` was left during the launch: a `/clear`,
390 * a `/vellum:stop` or a new way in wins, and the server started for nothing is ended.
391 */
392export async function revived(
393 host: Host,
394 from: State,
395 still: () => boolean,
396 wiring: Wiring,
397 how: ProcessSpawnResult | null,
398): Promise<State | null> {
399 const held = sessionOf(from);
400 const kept = tenureOf(from);
401
402 if (held === null || kept === null) return null;
403 // The store knows the final directory the moment the server said it; `from` may not. The pin
404 // is the mode's own: a relaunch that failed never stored what `restore` decided.
405 const session = { ...((await storedSession(host, held.id)) ?? held), pinnedCwd: held.pinnedCwd };
406 const crashes = how === null ? kept.crashes : crashesUntil(kept, await host.now());
407 const tenure = { ...kept, crashes };
408
409 if (how !== null && crashes.length >= CRASHES_BEFORE_LOST) {
410 host.log(
411 `the review server ended ${crashes.length} times within ${CRASH_WINDOW_MS / 1000} s, the last with ${JSON.stringify(how)}: not revived`,
412 );
413
414 if (!still()) return null;
415 stopTimers(from);
416
417 return lose(host, session, "failed", wiring, tenure);
418 }
419
420 const got = await relaunched(host, session);
421
422 if (!still()) {
423 if (got.kind === "up") got.child.end();
424
425 return null;
426 }
427
428 stopTimers(from);
429
430 return settled(host, session, got, wiring, tenure);
431}
432
433/**
434 * Reaches a server, in order: the one this session already has when it answers, a relaunched one
435 * on the directory, the port and the token `$.store` kept, or a new one on a fresh directory. A
436 * `/clear` changes the session id, so the mode of another id is left and a new one takes over.
437 */
438export async function connect(host: Host, state: State, wiring: Wiring): Promise<State> {
439 const id = sessionId(await host.sessionId());
440 const current = state.kind === "live" ? state.live : null;
441
442 if (current?.session.id === id && (await current.server.alive())) return state;
443 const kept = await storedSession(host, id);
444 const pinnedCwd = await pinsCwd(host, state, kept);
445 stopTimers(state);
446 const tenure = tenureFor(host, state, id, wiring);
447
448 if (kept !== null) {
449 const stored = { ...kept, pinnedCwd };
450
451 return settled(host, stored, await relaunched(host, stored), wiring, tenure);
452 }
453
454 // The root, not the session's directory: the pin sends every command back there.
455 const project = projectDir(await host.root());
456 const workdir = workdirOf(id, new Date().toISOString().slice(0, 10));
457 const launched = await start(host, id, project, workdir, null, null);
458
459 if (launched.kind !== "up") return { kind: "idle" };
460 const session = { id, server: launched.server.info, project, workdir, final: null, pinnedCwd };
461
462 return settled(host, session, { ...launched, session }, wiring, tenure);
463}
464src/runtime/hooks/parse.ts 388 lines1import type { HttpResponse } from "claude-code";
2
3import type { GateAnswer } from "../../review/contract.ts";
4import type { ChannelLine, PlanWorkspace, ServerLine, WorkflowAnswer } from "../protocol.ts";
5import type { ServerInfo, Session } from "./mode.ts";
6import type { Relayed } from "./relay.ts";
7
8declare const brand: unique symbol;
9
10type Brand<T, Name extends string> = T & { readonly [brand]: Name };
11
12export type SessionId = Brand<string, "SessionId">;
13
14export type Token = Brand<string, "Token">;
15
16/** Absolute: the directory the review server was started in. */
17export type ProjectDir = Brand<string, "ProjectDir">;
18
19/** Relative to the project, trailing slash kept. */
20export type Workdir = Brand<string, "Workdir">;
21
22/**
23 * A server type as it crosses HTTP: every brand the domain mints falls back to the value it
24 * wraps, since the module reads the server's JSON and grants none of the server's brands.
25 * A field the server adds or renames fails the parser below, which is what holds the two ends
26 * together.
27 *
28 * `object` is tested first on purpose: a brand is a primitive intersected with an object, so
29 * that arm catches it while a bare `kind: "none"` falls through and keeps its literal.
30 */
31export type Json<T> = T extends object
32 ? T extends readonly (infer U)[]
33 ? readonly Json<U>[]
34 : T extends string
35 ? string
36 : T extends number
37 ? number
38 : { readonly [K in keyof T]: Json<T[K]> }
39 : T;
40
41export type WorkspaceWire = Json<PlanWorkspace>;
42
43export type ChannelLineWire = Json<ChannelLine>;
44
45export type ChannelEntryWire = ChannelLineWire["entry"];
46
47/** Distributes over the workspace's variants: each keeps its `kind`, and its `version` where it has one. */
48type StageOf<W> = W extends { readonly kind: infer K; readonly version: infer V }
49 ? { readonly kind: K; readonly version: V }
50 : W extends { readonly kind: infer K }
51 ? { readonly kind: K }
52 : never;
53
54/** Where the plan stands, as much of the workspace as the module reads. */
55export type StageWire = StageOf<WorkspaceWire>;
56
57type StageLine = Extract<ServerLine, { readonly type: "stage" }>;
58
59/** What the band draws of a `stage` line, ready-made by the server: its pill and its segments. */
60export type DrawnWire = Json<Pick<StageLine, "pill" | "segments">>;
61
62/**
63 * A line of the server's stdout as the module reads it: `ready` as the server it names and its
64 * channel's identity, a `stage` as where the review lives and what the band draws of it.
65 */
66export type ServerLineWire =
67 | { readonly type: "ready"; readonly info: ServerInfo; readonly channel: string }
68 | { readonly type: "channel"; readonly line: ChannelLineWire }
69 | {
70 readonly type: "stage";
71 readonly stage: StageWire;
72 readonly dir: Workdir;
73 readonly drawn: DrawnWire;
74 };
75
76/** What `POST /api/gate` answers: the version the browser shows, or why it shows none. */
77export type GateWire = Json<GateAnswer>;
78
79type WorkflowWire = Json<WorkflowAnswer>;
80
81/**
82 * What `mcp__vellum__state` prints, read off `GET /api/workflow`: where the plan stands, `plan.md`
83 * against its last version, each region's line in its extension's words, and what is refused now.
84 */
85export type StateWire = {
86 readonly stage: StageWire;
87 readonly planText: WorkflowWire["planText"];
88 readonly lines: readonly WorkflowWire["regions"][number]["line"][];
89 readonly refused: readonly Pick<WorkflowWire["refused"][number], "event" | "effect" | "reason">[];
90};
91
92/* oxlint-disable anti-slop/no-runtime-typeof, anti-slop/no-unknown-parameters, anti-slop/no-unsafe-dictionary-type, anti-slop/no-unknown-returns, anti-slop/no-known-value-widening, anti-slop/require-safety-comment-for-type-assertion -- this file IS the boundary parser the rules ask for: a tool call's input, `$.store` values and the server's JSON arrive as `unknown`, there is no earlier place to parse them, and the brands above are minted here and nowhere else. */
93function isRecord(value: unknown): value is Record<string, unknown> {
94 return typeof value === "object" && value !== null;
95}
96
97export function sessionId(raw: string): SessionId {
98 return raw as SessionId;
99}
100
101export function projectDir(raw: string): ProjectDir {
102 return raw as ProjectDir;
103}
104
105export function workdirOf(id: SessionId, date: string): Workdir {
106 return `plans/${date}/wip-${id.slice(0, 8)}/` as Workdir;
107}
108
109/** What a shell tool runs: `Monitor` always in the background, Bash and PowerShell by `run_in_background`. */
110export type ShellCall = { readonly command: string; readonly background: boolean };
111
112/** The shell call of `input`, or `null`; the caller reads it for a tool of `SHELLS` alone. */
113export function shellCall(tool: string, input: unknown): ShellCall | null {
114 if (!isRecord(input) || typeof input.command !== "string") return null;
115
116 return {
117 command: input.command,
118 background: tool === "Monitor" || input.run_in_background === true,
119 };
120}
121
122/** The file a call would write, for the three tools that write one; `null` for every other. */
123export function editedPath(tool: string, input: unknown): string | null {
124 if (!isRecord(input)) return null;
125
126 if (tool === "NotebookEdit") {
127 return typeof input.notebook_path === "string" ? input.notebook_path : null;
128 }
129
130 if (tool !== "Edit" && tool !== "Write") return null;
131
132 return typeof input.file_path === "string" ? input.file_path : null;
133}
134
135/** A prompt row's first text block, the text its turn starts on; `""` when it holds none. */
136export function rowText(content: readonly { readonly type: string }[]): string {
137 const text = content.find(
138 (block): block is { type: "text"; text: unknown } => block.type === "text",
139 );
140
141 return typeof text?.text === "string" ? text.text : "";
142}
143
144export function parseServerInfo(value: unknown): ServerInfo | null {
145 return isRecord(value) &&
146 typeof value.port === "number" &&
147 typeof value.token === "string" &&
148 typeof value.pid === "number"
149 ? { port: value.port, token: value.token as Token, pid: value.pid }
150 : null;
151}
152
153export function parseSession(value: unknown): Session | null {
154 const server = isRecord(value) ? parseServerInfo(value.server) : null;
155
156 return server !== null &&
157 isRecord(value) &&
158 typeof value.id === "string" &&
159 typeof value.project === "string" &&
160 typeof value.workdir === "string" &&
161 (value.final === null || typeof value.final === "string") &&
162 typeof value.pinnedCwd === "boolean"
163 ? {
164 id: value.id as SessionId,
165 server,
166 project: value.project as ProjectDir,
167 workdir: value.workdir as Workdir,
168 final: value.final as Workdir | null,
169 pinnedCwd: value.pinnedCwd,
170 }
171 : null;
172}
173
174export function parseJson(text: string): unknown {
175 try {
176 return JSON.parse(text);
177 } catch {
178 return null;
179 }
180}
181
182function isCount(value: unknown): value is number {
183 return typeof value === "number" && Number.isInteger(value) && value >= 0;
184}
185
186/** What the store kept for this channel; another channel's reads as nothing relayed. */
187export function parseRelayed(value: unknown, channel: string): Relayed {
188 return isRecord(value) && value.channel === channel && isCount(value.seq)
189 ? { channel, seq: value.seq }
190 : { channel, seq: 0 };
191}
192
193function parseEntry(value: unknown): ChannelEntryWire | null {
194 if (!isRecord(value)) return null;
195
196 if (value.kind === "sent") {
197 return typeof value.file === "string" ? { kind: "sent", file: value.file } : null;
198 }
199
200 if (value.kind === "text") {
201 return typeof value.from === "string" && typeof value.text === "string"
202 ? { kind: "text", from: value.from, text: value.text }
203 : null;
204 }
205
206 return value.kind === "approved" &&
207 typeof value.version === "number" &&
208 typeof value.dir === "string" &&
209 (value.notes === null || typeof value.notes === "string")
210 ? { kind: "approved", version: value.version, dir: value.dir, notes: value.notes }
211 : null;
212}
213
214function parseChannelLine(value: unknown): ChannelLineWire | null {
215 const entry = isRecord(value) ? parseEntry(value.entry) : null;
216
217 return entry !== null && isRecord(value) && isCount(value.seq) && value.seq > 0
218 ? { seq: value.seq, entry }
219 : null;
220}
221
222function parseStage(value: unknown): StageWire | null {
223 if (!isRecord(value)) return null;
224
225 if (value.kind === "drafting") return { kind: "drafting" };
226
227 if (typeof value.version !== "number") return null;
228
229 if (value.kind === "inReview") return { kind: "inReview", version: value.version };
230
231 if (value.kind === "approved") return { kind: "approved", version: value.version };
232
233 return null;
234}
235
236function parseDrawn(value: Record<string, unknown>): DrawnWire | null {
237 const { pill, segments } = value;
238 const tone = isRecord(pill) ? pill.tone : null;
239
240 if (!isRecord(pill) || typeof pill.text !== "string" || !Array.isArray(segments)) return null;
241
242 if (tone !== "neutral" && tone !== "ok" && tone !== "err") return null;
243
244 const texts = segments.filter(
245 (segment: unknown): segment is string => typeof segment === "string",
246 );
247
248 return texts.length === segments.length
249 ? { pill: { text: pill.text, tone }, segments: texts }
250 : null;
251}
252
253/**
254 * One line of the server's stdout; `null` for a line this module does not read, which the caller
255 * logs: read as nothing, an entry would never reach Claude.
256 */
257export function parseServerLine(text: string): ServerLineWire | null {
258 const value = parseJson(text);
259
260 if (!isRecord(value)) return null;
261
262 if (value.type === "ready") {
263 const info = parseServerInfo(value);
264
265 const channel =
266 typeof value.channel === "string" && value.channel !== "" ? value.channel : null;
267
268 if (info === null || channel === null) return null;
269
270 // Held to the server's own line: a field it adds or renames fails the typecheck here.
271 const ready = { ...info, channel } satisfies Omit<
272 Json<Extract<ServerLine, { type: "ready" }>>,
273 "type"
274 >;
275
276 return { type: "ready", info, channel: ready.channel };
277 }
278
279 if (value.type === "channel") {
280 const line = parseChannelLine(value.line);
281
282 return line === null ? null : { type: "channel", line };
283 }
284
285 const workspace = value.type === "stage" && isRecord(value.workspace) ? value.workspace : null;
286 const stage = workspace === null ? null : parseStage(workspace);
287 const drawn = parseDrawn(value);
288
289 return stage === null || drawn === null || typeof workspace?.dir !== "string"
290 ? null
291 : { type: "stage", stage, dir: workspace.dir as Workdir, drawn };
292}
293
294/**
295 * What `GET /api/channel` answers. Throws on an answer it does not read, the shape of another
296 * version of the server included: read as no entry, it would drop the reviewer's without a word.
297 */
298export function parseChannel(text: string): ChannelLineWire[] {
299 const value = parseJson(text);
300
301 const lines = Array.isArray(value)
302 ? value.map((line: unknown) => parseChannelLine(line))
303 : [null];
304
305 if (lines.includes(null)) {
306 throw new Error(
307 `GET /api/channel answered a shape this module does not read: ${text.slice(0, 200)}`,
308 );
309 }
310
311 return lines.filter((line) => line !== null);
312}
313
314function parseRefused(value: unknown): StateWire["refused"][number] | null {
315 if (!isRecord(value) || typeof value.event !== "string" || typeof value.reason !== "string") {
316 return null;
317 }
318
319 const { effect } = value;
320
321 return effect === "refuse" || effect === "confirm"
322 ? { event: value.event, effect, reason: value.reason }
323 : null;
324}
325
326/**
327 * What `GET /api/workflow` answers, as much as the state tool prints; an error for a workflow of
328 * another shape, never a guess, and for a server that refused.
329 */
330export function parseState(response: HttpResponse): StateWire | { readonly error: string } {
331 if (!response.ok) return { error: `the vellum review server answered ${response.status}` };
332 const value = parseJson(response.text);
333 const workflow = isRecord(value) ? value : null;
334 const stage = parseStage(workflow?.workspace);
335 const planText = workflow?.planText;
336 const regions: unknown[] = Array.isArray(workflow?.regions) ? workflow.regions : [null];
337 const listed: unknown[] = Array.isArray(workflow?.refused) ? workflow.refused : [null];
338
339 const lines = regions.map((region) =>
340 isRecord(region) && typeof region.line === "string" ? region.line : null,
341 );
342
343 const refused = listed.map((one) => parseRefused(one));
344
345 if (
346 stage === null ||
347 (planText !== "none" && planText !== "pending" && planText !== "absent") ||
348 lines.includes(null) ||
349 refused.includes(null)
350 ) {
351 return { error: "the vellum review server answered a workflow this module does not read" };
352 }
353
354 return {
355 stage,
356 planText,
357 lines: lines.filter((line) => line !== null),
358 refused: refused.filter((one) => one !== null),
359 };
360}
361
362/**
363 * Why a slice's route refused, as `serverExtension` words it: a 404's plain text, another status's
364 * `{ error }`; `null` when the answer holds neither.
365 */
366export function parseRefusal(response: HttpResponse): string | null {
367 if (response.status === 404) return response.text;
368 const value = parseJson(response.text);
369
370 return isRecord(value) && typeof value.error === "string" ? value.error : null;
371}
372
373export function parseGate(response: HttpResponse): GateWire {
374 const value = parseJson(response.text);
375
376 if (isRecord(value) && typeof value.version === "number" && typeof value.kept === "boolean") {
377 return { version: value.version, kept: value.kept };
378 }
379
380 return {
381 error:
382 isRecord(value) && typeof value.error === "string"
383 ? value.error
384 : `the vellum review server answered ${response.status}`,
385 };
386}
387/* oxlint-enable anti-slop/no-runtime-typeof, anti-slop/no-unknown-parameters, anti-slop/no-unsafe-dictionary-type, anti-slop/no-unknown-returns, anti-slop/no-known-value-widening, anti-slop/require-safety-comment-for-type-assertion */
388src/runtime/hooks/place.ts 113 lines1import type { Host } from "./host.ts";
2import type { Landed, Platform } from "./lock.ts";
3import type { ProjectDir } from "./parse.ts";
4
5/**
6 * A missing name Windows reads off the folder it seems to be in: `D:x` lands in drive D's own
7 * directory, and `C:stream` names a drive or a stream. The engine's guard recipe (`$.fs.stat`
8 * in `claude-code.d.ts`) refuses both.
9 */
10const DRIVE_NAME = /^[A-Za-z]:/u;
11
12/**
13 * A share or a device on Windows, `\\host\share` or `\\?\…`, in either separator. Asking where it
14 * lands contacts the host, and above a share root the walk climbs onto the local drive (`//host/`
15 * resolves as `D:\host`), so the engine's guard recipe refuses it by spelling, before any file
16 * system call. A call's path reaches the hooks already resolved, a POSIX `//` folded.
17 */
18const NETWORK = /^[\\/]{2}/u;
19
20/** Rooted on POSIX, on a Windows drive, or on the session's drive without naming it. */
21const ROOTED = /^(?:[\\/]|[A-Za-z]:[\\/])/u;
22
23/** Windows ends a name on `\` as well as `/`; on POSIX `\` is a character of a name. */
24const SEPARATORS = {
25 posix: { last: /\/(?=[^/]*$)/u, trailing: /\/+$/u },
26 windows: { last: /[\\/](?=[^\\/]*$)/u, trailing: /[\\/]+$/u },
27} as const;
28
29/** The engine ends its rejection of a missing path on the errno, and sets no `code`. */
30const NOT_THERE = / failed: ENOENT$/u;
31
32/**
33 * What `stat` says of a path: where it lands, or that it is not there. `refused` is any other
34 * rejection (another errno, a hook above), which says nothing of where the path lands.
35 */
36type Asked =
37 | { readonly kind: "found"; readonly realPath: string | undefined }
38 | { readonly kind: "missing" }
39 | { readonly kind: "refused" };
40
41async function asked(host: Host, path: string): Promise<Asked> {
42 try {
43 return { kind: "found", realPath: (await host.stat(path)).realPath };
44 } catch (error) {
45 return error instanceof Error && NOT_THERE.test(error.message)
46 ? { kind: "missing" }
47 : { kind: "refused" };
48 }
49}
50
51/**
52 * Where a path lands, as the file system answers it: every symbolic link followed, whatever
53 * the platform's spelling. A file not written yet lands under the first of its folders that
54 * exists. `null` when nothing can tell: a link that leads nowhere, a stat refused for another
55 * reason than a missing path, a network or device path, a name Windows reads as a drive. The
56 * lock denies on `null`, since the tool may still open it.
57 */
58export async function placed(host: Host, path: string, platform: Platform): Promise<string | null> {
59 if (NETWORK.test(path)) return null;
60 const { last, trailing } = SEPARATORS[platform];
61 const missing: string[] = [];
62 let rest = path;
63
64 for (;;) {
65 // oxlint-disable-next-line no-await-in-loop -- a folder is asked only once what it holds is known to be missing.
66 const found = await asked(host, rest);
67
68 if (found.kind === "refused") return null;
69
70 if (found.kind === "found") {
71 return found.realPath === undefined
72 ? null
73 : [found.realPath.replace(trailing, ""), ...missing].join("/");
74 }
75
76 const named = rest.replace(trailing, "");
77 const cut = named.search(last);
78 const name = named.slice(cut + 1);
79
80 if (name === "" || name === "." || name === ".." || DRIVE_NAME.test(name)) return null;
81 missing.unshift(name);
82 rest = cut < 0 ? "." : named.slice(0, cut + 1);
83 }
84}
85
86/**
87 * Where a call's file and the project land. The project's `realPath` also says the platform:
88 * POSIX answers it from `/`, Windows from a drive or a share. A relative path hangs off the
89 * session's directory, read for that path alone. A project that cannot be placed throws, and
90 * the lock fails closed on it.
91 */
92export async function landed(
93 host: Host,
94 session: { readonly project: ProjectDir },
95 path: string,
96): Promise<Landed> {
97 const project = (await host.stat(session.project)).realPath;
98
99 if (project === undefined) {
100 throw new Error(
101 `the project ${session.project} lands nowhere: \`$.fs.stat\` answered no \`realPath\`, as an engine older than the one \`types/claude-code.d.ts\` was written by does`,
102 );
103 }
104
105 const platform = project.startsWith("/") ? "posix" : "windows";
106
107 const whole = ROOTED.test(path)
108 ? path
109 : `${(await host.cwd()).replace(SEPARATORS[platform].trailing, "")}/${path}`;
110
111 return { file: await placed(host, whole, platform), project, platform };
112}
113src/runtime/hooks/relay.ts 231 lines1import type { ReviewServer } from "./client.ts";
2import type { Host } from "./host.ts";
3import {
4 type ChannelEntryWire,
5 type ChannelLineWire,
6 parseRelayed,
7 type SessionId,
8} from "./parse.ts";
9
10/**
11 * What `$.store` keeps under `relayed:<id>`: the number of the last channel entry Claude was
12 * handed, for one channel. A new working directory at the same path is another channel, with
13 * another identity, and its numbers restart: a number kept for another channel is worth nothing.
14 */
15export type Relayed = { readonly channel: string; readonly seq: number };
16
17/**
18 * A tool call that waits for the reviewer's answer. `returned` names the entry its result carried
19 * to Claude, which is never relayed; `close` ends the wait, after which an entry it held and did
20 * not return is relayed, and a `returned` said late is not heard.
21 */
22export type Claim = {
23 readonly returned: (seq: number) => void;
24 readonly close: () => void;
25};
26
27/**
28 * Relays a session's channel to Claude, once each entry and in order, for as long as its mode
29 * holds it: one per session in a module's environment, across the servers a revival replaces.
30 * `serve` reads from a server from now on and catches up; `hand` takes a line as the server
31 * writes it; `retry` reads the channel again after a dropped prompt, or closes an approval whose
32 * closing failed; `stop` relays nothing more once the mode was left. `claim` holds, while a tool
33 * call waits, each entry `awaits` picks: the call may return it as its result, and then it is
34 * never relayed.
35 */
36export type Follower = {
37 readonly serve: (server: ReviewServer, channel: string) => void;
38 readonly hand: (line: ChannelLineWire) => void;
39 readonly retry: () => void;
40 readonly stop: () => void;
41 readonly claim: (awaits: (entry: ChannelEntryWire) => boolean) => Claim;
42};
43
44/**
45 * `behind`: a prompt was dropped, or a read failed, and the channel is read again. `closing`:
46 * the approval was told and the mode not closed yet; it is closed again, never told again.
47 */
48type Phase = "following" | "behind" | "closing" | "stopped";
49
50/** What a follower reads before its first server. */
51function nothingToRead(): Promise<ChannelLineWire[]> {
52 return Promise.resolve([]);
53}
54
55export function relayedKey(id: SessionId): string {
56 return `relayed:${id}`;
57}
58
59/**
60 * Each entry as Claude reads it: the fact and its object, nothing else. The skill `start` already
61 * says what to do with a file the reviewer sent and with an approval.
62 */
63function promptOf(entry: ChannelEntryWire): string {
64 if (entry.kind === "text") return entry.text;
65
66 if (entry.kind === "sent") return `Reviewer sent: read ${entry.file}.`;
67 const notes = entry.notes === null ? "" : ` Read ${entry.notes} first.`;
68
69 return `Plan v${entry.version} approved, at ${entry.dir}.${notes}`;
70}
71
72/**
73 * One queue runs every relay, apart from the loop that reads the server: a prompt submitted while
74 * a turn runs resolves once its own turn starts, and the loop must never wait with it. A number
75 * already relayed is skipped; a number past the next one reads the entries it missed first. The
76 * number is written to `$.store` after each prompt, so a reloaded module or a relaunched server
77 * repeats nothing; the approval drops the record, and `approved` closes the mode.
78 */
79export function follow(host: Host, id: SessionId, approved: () => Promise<void>): Follower {
80 const key = relayedKey(id);
81 let relayed: Relayed = { channel: "", seq: 0 };
82 let read: (after: number) => Promise<ChannelLineWire[]> = nothingToRead;
83 let phase: Phase = "following";
84 let queue = Promise.resolve();
85 // The waits open now, and the entries a tool returned: an entry a wait may return is held until
86 // the wait says, since the entry's line and the tool's answer reach the module by two paths.
87 const claims = new Set<(entry: ChannelEntryWire) => boolean>();
88 const returned = new Set<number>();
89 let changed = Promise.withResolvers<void>();
90
91 const change = (): void => {
92 changed.resolve();
93 changed = Promise.withResolvers<void>();
94 };
95
96 const held = (entry: ChannelEntryWire): boolean => [...claims].some((awaits) => awaits(entry));
97
98 /**
99 * Whether the entry went to Claude as a tool's result, is due as a prompt, or goes nowhere since
100 * the mode was left: while a tool may still return it, it waits.
101 */
102 const fateOf = async ({
103 seq,
104 entry,
105 }: ChannelLineWire): Promise<"returned" | "due" | "stopped"> => {
106 const waiting = (): boolean => !returned.has(seq) && held(entry) && phase !== "stopped";
107
108 while (waiting()) await changed.promise;
109
110 if (phase === "stopped") return "stopped";
111
112 return returned.delete(seq) ? "returned" : "due";
113 };
114
115 const run = (work: () => Promise<void>): void => {
116 queue = queue.then(work).catch((cause: unknown) => {
117 host.log(`the channel relay failed: ${String(cause)}`);
118
119 if (phase === "following") phase = "behind";
120 });
121 };
122
123 const close = async (): Promise<void> => {
124 await approved();
125 phase = "stopped";
126 };
127
128 /** `false` stops a catch-up: a dropped prompt stays due, and the approval ends the relays. */
129 const relay = async (line: ChannelLineWire): Promise<boolean> => {
130 const { seq, entry } = line;
131
132 if (phase === "stopped" || phase === "closing") return false;
133
134 if (seq <= relayed.seq) return true;
135 const fate = await fateOf(line);
136
137 if (fate === "stopped") return false;
138 const result = fate === "returned" ? null : await host.submitPrompt(promptOf(entry));
139
140 if (result?.drop !== undefined) {
141 host.log(`the review prompt was dropped: ${result.drop}`);
142
143 if (phase === "following") phase = "behind";
144
145 return false;
146 }
147
148 relayed = { ...relayed, seq };
149
150 if (entry.kind === "approved") {
151 await host.storeDelete(key).catch((cause: unknown) => {
152 host.log(`the relayed record was not dropped: ${String(cause)}`);
153 });
154 phase = "closing";
155 await close();
156
157 return false;
158 }
159
160 // A store that refuses the record is logged, not obeyed: the prompt went out, once.
161 await host.storeSet(key, relayed).catch((cause: unknown) => {
162 host.log(`the relayed record was not kept: ${String(cause)}`);
163 });
164
165 return true;
166 };
167
168 const catchUp = async (): Promise<void> => {
169 let next = relayed.seq + 1;
170
171 for (const line of await read(relayed.seq)) {
172 if (line.seq > next) host.log(`the channel holds no entry ${next} to ${line.seq - 1}`);
173
174 if (!(await relay(line))) return;
175 next = line.seq + 1;
176 }
177
178 if (phase === "behind") phase = "following";
179 };
180
181 return {
182 serve: (server, channel) => {
183 run(async () => {
184 read = (after) => server.channel(after);
185
186 // Another channel's numbers start over: what a tool returned there says nothing here.
187 if (channel !== relayed.channel) {
188 relayed = parseRelayed(await host.storeGet(key), channel);
189 returned.clear();
190 }
191
192 await catchUp();
193 });
194 },
195 hand: (line) => {
196 run(async () => {
197 if (phase === "behind" || line.seq > relayed.seq + 1) await catchUp();
198 else await relay(line);
199 });
200 },
201 retry: () => {
202 if (phase === "behind") run(catchUp);
203 else if (phase === "closing") run(close);
204 },
205 stop: () => {
206 phase = "stopped";
207 change();
208 },
209 claim: (awaits) => {
210 // Its own function, so two calls of one tool are two waits.
211 const hold = (entry: ChannelEntryWire): boolean => awaits(entry);
212 let open = true;
213 claims.add(hold);
214
215 return {
216 returned: (seq) => {
217 // An entry relayed already, or returned once already, is not held for again.
218 if (!open || seq <= relayed.seq) return;
219 returned.add(seq);
220 change();
221 },
222 close: () => {
223 open = false;
224 claims.delete(hold);
225 change();
226 },
227 };
228 },
229 };
230}
231src/runtime/hooks/slices.ts 13 lines1import { hooks as reviewHooks } from "../../steps/agent-review/hooks.ts";
2import { hooks as grillHooks } from "../../steps/grill/hooks.ts";
3import { hooks as stepHooks } from "../../steps/proposal/hooks.ts";
4import type { EngineExtension } from "./extension.ts";
5import { engineExtension } from "./slice.ts";
6
7/** Every engine half, in dispatch order; `runtime/hooks/register.ts` alone imports this. */
8export const engineExtensions: readonly EngineExtension[] = [
9 engineExtension(grillHooks),
10 engineExtension(stepHooks),
11 engineExtension(reviewHooks),
12];
13src/runtime/hooks/turn.ts 71 lines1/**
2 * Whose turn runs: the one thing the module knows that the server does not. `turn.start`
3 * carries no origin, so `session.append` notes each prompt row the engine keeps before a turn,
4 * its text and whether vellum relayed it, and the turn that starts on that text takes it. Two
5 * facts that coexist, since a row may be kept while a turn still runs.
6 * Neither is a variant of the mode's `State`: they say who started a turn, nothing about what
7 * is allowed.
8 *
9 * Every miss falls on one side, a turn read as not vellum's, whose text is written nowhere: two
10 * rows before one turn are vellum's only if both are, a turn whose text does not hold the last
11 * row's is nobody's, and a reload between the row and its turn loses the note.
12 */
13type Noted =
14 | { readonly kind: "none" }
15 | { readonly kind: "prompt"; readonly text: string; readonly own: boolean };
16
17type Running =
18 | { readonly kind: "none" }
19 | { readonly kind: "turn"; readonly turnId: string; readonly own: boolean };
20
21export type Turns = { readonly noted: Noted; readonly running: Running };
22
23export const NO_TURN: Turns = { noted: { kind: "none" }, running: { kind: "none" } };
24
25/** `session.append` of a prompt row: the next turn's note; the turn that runs is left as it is. */
26export function prompted(turns: Turns, text: string, own: boolean): Turns {
27 const { noted } = turns;
28 const all = noted.kind === "prompt" ? noted.own && own : own;
29
30 return { ...turns, noted: { kind: "prompt", text, own: all } };
31}
32
33/**
34 * `turn.start`, which takes the note. The turn's text is the row's as measured, framed by the
35 * engine for a plugin's prompt; `includes` keeps a turn that adds to it. An empty note matches
36 * nothing, so a continuation, whose text is empty, never takes a note left behind.
37 */
38export function started(turns: Turns, text: string, turnId: string): Turns {
39 const { noted } = turns;
40
41 const own =
42 noted.kind === "prompt" && noted.own && noted.text !== "" && text.includes(noted.text);
43
44 return { noted: { kind: "none" }, running: { kind: "turn", turnId, own } };
45}
46
47export function ownOf(turns: Turns, turnId: string): boolean {
48 const { running } = turns;
49
50 return running.kind === "turn" && running.turnId === turnId && running.own;
51}
52
53/**
54 * A tool call of the running turn returned an entry of the reviewer's, as a waiting tool's
55 * result: the reviewer spoke into the turn, so what Claude says next answers them.
56 */
57export function replied(turns: Turns): Turns {
58 const { running } = turns;
59
60 return running.kind === "turn" ? { ...turns, running: { ...running, own: true } } : turns;
61}
62
63/** `turn.complete` of the running turn; the end of any other one changes nothing. */
64export function completed(turns: Turns, turnId: string): Turns {
65 const { running } = turns;
66
67 return running.kind === "turn" && running.turnId === turnId
68 ? { ...turns, running: { kind: "none" } }
69 : turns;
70}
71