SLOPSHOPPER

vellum

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…

newbandguardstatusprompttool
★ 5v0.17.3MITupdated 2026-10-07bengous/claude-code-plugins/vellum
A shopper browsing a rack in a slop shop
README

vellum

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.

Requirements

  • Claude Code with mods. The npm 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.
  • A Claude Code whose $.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.
  • A browser: Chromium or Firefox, recent. The page uses the CSS Custom Highlight API.
  • On Claude Code Desktop, a prompt the plugin submits while the session is idle (a batch you sent after Claude's turn ended, the approval) does not show until you type in the session (anthropics/claude-code#96336, open); the terminal is unaffected. A Send that answers a round Claude waits on reaches it as the result of grill_ask, and is not affected.
  • Managed settings without an 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.

Skill

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

  1. Propose the next step. A one-sentence diff gets no plan. Otherwise Claude explores, then proposes with 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.
  2. Settle the open choices in a grill, in the review page, which a grill you pick opens. Only a question whose answer changes the architecture, an interface or the scope is asked; the rest becomes a recorded assumption.
  3. Write the plan to 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).

Review in the browser

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

  1. The page lists the working directory's renderable files from the start: Markdown, HTML in a sandboxed iframe, images. The rail keeps three groups apart: the plan, the artifacts of its directory, and the files it cites elsewhere in the project; an artifact can sit beside the plan. A comment sent before the first version is written to .review/v0.feedback-<n>.md and reaches Claude as a prompt at its next idle: it revises the file and goes on.
  2. Claude writes 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.
  3. From the second version on, the bar shows beside the version how many lines were added and removed, and Changes since vN-1 marks them on the rendered plan: a green bar on an added or changed block, each added line of a code block highlighted, the removed lines folded in a red block above what replaced them, after the checkbox of a task and as a row of its own in a table. The marks are off at every load, and comments work with them on.
  4. Comments: the Comment switch, off at every load, starts them, and the key 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.
  5. Edit opens the plan's Markdown source on the block you were reading. Done, or Ctrl+Enter, renders your text, marked "edited, not sent", moves the comments already made to their new lines, and brings you back where you were; a comment on a text of the version your edit removed says so and moves nowhere. Discard edit is the reverse: the version's text again, the comments back on its lines. A comment on a line only your edit holds has no line of the version to go back to: it goes with the edit at Discard edit, and with the first Done that removes one of its lines. The comments stay readable while you edit; only their actions wait. The edit leaves with your next decision, as the next version: .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.
  6. Send is the page's one way to Claude, its badge counting what goes: your comments, the choices you made in mockups, the questions of an open grill round you answered, and your edit. One click writes one batch, .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.
  7. Approve renames the directory to the slug of the plan's title (-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.
  8. The unsent comments, the unsent edit, the choices made in mockups and what you typed and did not add or send (the general box, a comment being written, a grill's answers, the editor's text) are saved in .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.

A grill

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.

Agent

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.

What it does to the session

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.

What it hooks

HookMatcherWhat it does
session.startRegisters 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.promptskill=vellum:startEnters the mode: reaches or starts the server, then appends the working directory and the page's link to the skill's text.
skill.promptskill=vellum:stopLeaves 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.rendercomponent=AbovePromptDraws the band while the mode holds a session; passes while it is idle or a survey holds the band.
tool.checkThe 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.calltool=mcp__vellum__submitGates the plan and names the version, without running a tool.
tool.calltool=mcp__vellum__stateAnswers 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.calltool= each extension's tool and each tool an extension refuses, as register.ts lists themServes 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.submitWhile 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.startNotes whether the turn was started by one of Vellum's own relays, from the origin prompt.submit saw.
turn.completeGates 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.

What it calls on $

CallWhat for
$.tool.registerThe submit and state tools, and each extension's, at the session's start.
$.session.idWhich session the mode belongs to; a /clear mints a new one.

| $.session.cwd | Where the session runs now, to resolve a relative

Source 45 files
src/runtime/hooks/register.ts 522 lines
1import 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};
522
src/review/hooks.ts 54 lines
1import 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}
54
src/runtime/hooks/band.ts 25 lines
1import { 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}
25
src/runtime/hooks/extension.ts 277 lines
1import 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>>;
277
src/runtime/hooks/host.ts 93 lines
1import 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};
93
src/runtime/hooks/lock.ts 152 lines
1import 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}
152
src/runtime/hooks/mode.ts 464 lines
1import 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}
464
src/runtime/hooks/parse.ts 388 lines
1import 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 */
388
src/runtime/hooks/place.ts 113 lines
1import 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}
113
src/runtime/hooks/relay.ts 231 lines
1import 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}
231
src/runtime/hooks/slices.ts 13 lines
1import { 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];
13
src/runtime/hooks/turn.ts 71 lines
1/**
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