Point at your running app and talk to Claude: the session's address serves the app with an overlay that picks an element by a click or a zone by a drag, takes…

Point at your running app and talk to Claude. The session's address serves your app with an overlay that lets you pick an element by a click or a zone by a drag, give it a quick label or a note, and keep it in a batch. You send the batch when you are done, and it reaches the Claude Code session as one prompt, each element described precisely enough to find it in the code: selector, role, accessible name, the heading it sits under, opening tag, text, box and a few computed styles, and each zone with an image of it as the page drew it. Claude answers in the terminal; the overlay shows whether each batch went in. No file of your app is touched.
latest channel loads them by default. Where they are off (--bare, --safe-mode, disableAllHooks, an organization's policy) the two skills load but nothing starts.bun on the PATH: the server is a Bun script.http(s): a dev server (Vite, Next, Rails, Django, Storybook…) or any other./plugin marketplace add bengous/claude-code-plugins
/plugin install pinpoint@bengous-plugins
/pinpoint:start. It starts the session's server, opens the browser at its address, http://localhost:<port>/, and prints the address too, for a machine with no browser to open./pinpoint:start <url> serves that app at once: a URL, localhost:3000/admin, or a port alone. Run it again with another URL to switch apps; the address stays the same, so the open tabs show the new app once reloaded. The app chosen comes back with a server the module revives. The server refuses its own address, which would serve itself.
The browser opens when /pinpoint:start starts the session's server, at the app's path when a URL was given, while somebody sees the session on this machine: a terminal or Claude Code Desktop. Claude Code for VS Code attaches no surface yet, so it gets the address only. A start while the server runs, a switch, and a server that comes back (a revival, a reload, /clear, a start after it was lost) open nothing: their address is the one the tabs already hold. A -p run, which draws on no surface, and a session only a phone shows open nothing either; a phone attached beside the terminal, as Remote Control stays, does not stop it. An opener that does not run, as xdg-open missing from the PATH, leaves the address to open by hand, and the session says so.
svg. A drag draws a zone, which takes the outermost elements it covers (half of the element's box, or of what it draws, inside it) and an image of the zone, taken when the drag ends, the overlay left out.Misaligned, Too big, Too small, Wrong text, Remove) and a note. A label puts the selection in the batch at once, with the note typed so far; a zone's image follows, and its card says image on its way until it is in. Enter puts it there with the note; Shift+Enter breaks the line; Esc drops it, and a second Esc turns pointing off.to <folder> names the Claude Code session the batches go to, the one that started the server, by the last segment of its directory; hovering it shows the whole directory. A name wider than the panel gives way in the middle: its last ten characters stay in view.window.pinpointContext = () => ({ ... }), set by the app in dev, rides with each selection as JSON: the route, the saved state a screen is built from, what Claude needs to see the same thing. It is cut past 10 000 characters.pinpoint batch 3: 2 selections the user made in their running app. Under each label and note, the lines are what the page reported; boxes are in page pixels, from the top-left corner of the document; a zone's image is a PNG of the zone as the page drew it.
1. [Too small] Must read from afar.
page: http://localhost:41237/settings · "Settings" · viewport 1440 × 900 · scroll 0, 120
element: body > main > form.settings > button.save
role button · name "Save" · under "Account"
<button class="save" type="submit">
box x 1180, y 812, 96 × 32
styles: font 500 13px/16px system-ui; color rgb(29, 36, 48); padding 6px 12px; display inline-flex
app context: {"route":"settings"}
2. [Misaligned]
zone: x 950, y 800, 420 × 90, 2 elements in it
image: ~/.claude/pinpoint/sessions/<session>/images/0d5e2c1a-7b3f-4e8a-9c6d-1f2e3a4b5c6d.png
- body > main > form.settings > p.hint
...
Boxes are in page pixels, from the top-left corner of the document, each measured at its pick: a scroll between two picks, or before the selection goes in the batch, moves none of them. The page line's scroll is where the user stood when the selection went in the batch, and its URL is the session's address. The page and the app context repeat only when they change. A zone's image is a file Claude reads, at the screen's pixel ratio, cut to the zone; a zone whose image the page could not take or hand over says image: not taken (<why>). Claude Code shows the prompt as "Prompt from the pinpoint plugin" and hands it to Claude as "The pinpoint plugin sent a message:", a message to address (measured in a live session on Claude Code 2.1.301, Linux, 2026-10-16).
browser tab at http://localhost:<port>/
│ the app's own requests, ▲ the app's pages with the overlay's tag, every
│ overlay.js, POST api/image, │ other answer as the app gave it; its WebSockets
│ POST api/batch, GET api/outcomes │ relayed both ways
▼ │
pinpoint server (Bun, 127.0.0.1, one per session) ◀──every other request──▶ your app
│ stdout: one JSON line per batch ▲ POST api/delivery: delivered or dropped
│ and per app chosen │ POST api/target: the app to serve
▼ │
hooks module ──$.prompt.submit──▶ the session
/pinpoint:start spawns server/cli.ts serve as a child of the hooks module, on a free port with a fresh token, and keeps both in the plugin's store, with the app it serves. Once the module has taken that server, it asks it to open the browser at its address (POST api/open, the module's alone), and the server runs open on macOS, rundll32 url.dll,FileProtocolHandler on Windows, xdg-open elsewhere, detached and best effort. A server that ends on its own, or whose output stops being readable, comes back on the same port and token, serving the same app, so the open tabs reach it again; three ends within a minute make it lost and the status line says so. A reload of the module starts the stored server again. /clear carries it over to the new session id.idle, starting, live, stopping or lost. A way in while the server starts or stops starts no second one. /pinpoint:stop enters stopping before anything else, so the end it causes revives nothing; it closes the server, which removes its files, or runs cli.ts clean when no server is left to close (lost, still starting, or not answering), then forgets the record. After each wait (a prompt entering, a store write, a server starting) the module goes on only while the state is still the one it started from, so a stop meanwhile leaves no record behind and starts no server./ is the "Which app?" page, which probes the usual dev ports at localhost in parallel, for a second at most, keeps those answering HTML with their page's first <title>, read up to its end only, and those still silent, and reuses a probe for five seconds. Once an app is chosen, every other request goes to that app's origin: an HTML page comes back with <script src="http://<host>/t/<token>/overlay.js" defer> at the end of its body, a redirect to the app's own origin (the address it knows itself by, as a configured base URL) moves to the same path on the session's address, any other answer comes back as the app gave it, and its WebSockets, Vite's hot reload among them, are relayed both ways./pinpoint:start <url> passes it as --target to a server it starts, or posts it to api/target of the one running; the "Which app?" page posts its form to /t/<token>/pick, which takes a post from that page's own origin only, and only while no app is served: once one is, the app's own scripts share the origin, and only the module switches it. Either way the server checks the address against its own once bound, serves the app from then on, and says so on stdout, which the module records for the next revival. Each request it forwards carries a header only this server knows; one that comes back to it, through an address that names the server under another name (localhost., an IPv4-mapped address, a hosts file alias), is answered 508 instead of forwarded again.Host is not a name of this machine (localhost, 127.0.0.1, [::1], any port) is refused, which keeps out a page whose name was rebound to 127.0.0.1, and so is one whose Origin is not a page of this machine. The server's own routes take a page's request only from the server's own origin, its port under any of those names: a page of another app on this machine is refused even with the token in hand, and no CORS header is ever sent. The module's own routes refuse any page.PINPOINT_ROOT), so every page it serves names the same session with no request of its own.overlay.js: when the drag ends, it draws the page's DOM again into a PNG cut to the zone, the overlay hidden, so it asks no permission and reads no screen. The selection goes in the batch at once, its zone's image on its way; the page posts the PNG to api/image once drawn, if the card is still there, so a zone dropped before leaves nothing behind, and the server keeps it as ~/.claude/pinpoint/sessions/<session>/images/<id>.png until the session's files go, at /pinpoint:stop or once Claude Code's retention period has passed over them; the selection carries only the id, since the batch the tab keeps across a reload lives in its sessionStorage. A send waits up to ten seconds for the images still on their way, then gives the rest up, each zone saying so.~/.claude/pinpoint/sessions/<session>/, until the module reports it went in, so a server started again tells the session what it still holds. Batches are numbered, kept and written to stdout one at a time, so their lines come in the order of their numbers. A number always passes the last batch the module handed over, which it passes the server as --relayed, and every batch the server told again at its start: a session whose files are gone, before the server started or since, numbers on instead of from 1, where the module would take each new batch for one it already handed over. A seq that is there and does not read refuses the batch instead.cleanupPeriodDays of the settings merged over every source, which the module reads with $.settings.read() and passes as --keep-days, 30 days when no source sets it. A value Claude Code refuses, anything but a whole number of at least 1, sweeps nothing, as Claude Code pauses its own sweep then. The sweep runs after ready, so a start does not wait for it.GET api/outcomes after the batches its tab sent and not yet settled, every second while one waits, then forgets each one settled; with none waiting, a visible page asks after nothing every ten seconds, which keeps its dot true. A batch the server answers unknown for is said once and asked after no more. No page holds a connection open, so a browser's few connections per host stay free for the app and the batches of every tab.claude killed hard sends no signal.| Hook | Matcher | What it does | |
|---|---|---|---|
session.start | Starts the server the store kept for the session again, after a reload. | ||
skill.prompt | skill=pinpoint:start | Starts the server, or keeps the one running, serving the app the URL typed after it names (the ARGUMENTS: line Claude Code ends the skill's text with), or switches the running one to it; appends the address to open. | |
skill.prompt | skill=pinpoint:stop | Closes the server, or removes its files when none is left, and forgets the session's record. | |
command.run | `command=clear\ | resume` | Moves the record to the new session id, the server kept. |
$| Call | What for |
|---|---|
$.process.spawn | The server, read line by line for as long as it runs. |
$.process.run | cli.ts clean, at /pinpoint:stop with no server left to close: the session's files. |
$.prompt.submit | Each batch, once the session is idle, in order. |
$.http.fetch | The server's heartbeat, each batch's delivery, a switch of the app, the browser opened, the close. |
$.store.get, $.store.set, $.store.delete | The session's record: root, port, token, the app served, last batch relayed. |
$.session.id, $.session.root | Which session the server serves, and the directory its panel and "Which app?" name. |
$.session.surfaces | Whether somebody sees the session on this machine: a -p run, or a phone alone, opens no browser. |
$.settings.read | cleanupPeriodDays, how long the other sessions' files stay. |
$.clock.after, $.clock.every, $.clock.now | The start's timeout, the heartbeat, the crash window. |
$.ui.status | The line under the prompt, for a server lost alone. |
$.ui.log | Errors only: a server that did not start or could not be read, a batch not relayed, a line the module does not read, a stored record that does not read, files not removed. |
claude plugin validate pinpoint prints both lists from the module's source.
Host and Origin checks keep other sites from reading what the server serves or posting a batch, not your own processes. A batch is framed as the plugin's message, never as your own words.script-src 'nonce-…' 'strict-dynamic', does not run the overlay: its tag carries no nonce. The page's own scripts run, and no panel appears. A policy of script-src 'self' lets it run, since the overlay comes from the page's own origin. Measured through the session's address on a page sending each policy as a header, Chromium headless (agent-browser 0.39.1), Linux, 2026-10-18.fetch to it goes to the app directly, under the app's own CORS rules. Only redirects are brought back.backdrop-filter). An image of the 1280 × 800 screen at pixel ratios 1 and 2, on a page scrolled or not, matched the screen by eye (Chromium headless, agent-browser 0.39.1, Linux, 2026-10-18).--setting-sources with no other source setting cleanupPeriodDays, pauses Claude Code's sweep but not this one, which then sweeps at 30 days whatever the user settings say; desktopSessionCleanupPeriodDays plays no part.~/.claude/pinpoint whatever CLAUDE_CONFIG_DIR says, so every profile shares them: the profile with the shortest cleanupPeriodDays that starts a server sweeps the sessions of all of them.localhost; any other takes its address in the field or after /pinpoint:start. A probe asks each app for its /, which a dev server may compile on the way.bun install --cwd pinpoint # snapdom, which the server bundles into the overlay
bun test pinpoint/ # server, overlay and shared specs (*.spec.ts)
claude plugin test pinpoint # the hooks module in the engine's kit (hooks/*.test.ts)
claude plugin validate pinpoint # what the module hooks and calls
command claude --plugin-dir pinpoint # a live session from source
Claude Code installs package.json's dependencies at the plugin's cache; under --plugin-dir nothing installs, hence the first line.
The server is server/cli.ts and serve.ts, its routes; proxy.ts sends the app's requests on and relays its WebSockets, apps.ts is "Which app?", build.ts bundles the overlay, files.ts keeps the session's batches and images on disk. The overlay is plain TypeScript in a shadow root, no framework: overlay/main.ts is the controller, describe.ts and pick.ts what an element is (adapted from vellum's src/formats/html/), view.ts what it draws, capture.ts a zone's image, tray.ts the batch as a pure model. shared/wire.ts types everything that crosses a process boundary; shared/target.ts reads the address of an app.
MIT
Augustin BENGOLEA <bengous@protonmail.com>
hooks/register.ts 503 lines1import type { EngineInterface, Register, RenderSurface, Timer } from "claude-code";
2
3import { addressOn, targetOf } from "../shared/target.ts";
4import type { Delivery, ServerLine } from "../shared/wire.ts";
5import {
6 argumentsOf,
7 parseServerLine,
8 type SessionRecord,
9 type Stored,
10 storedRecord,
11} from "./parse.ts";
12import {
13 baseOf,
14 type Child,
15 clean,
16 close,
17 type Host,
18 openBrowser,
19 start,
20 switchTarget,
21 tellDelivery,
22} from "./server.ts";
23
24/**
25 * The pinpoint mod: `/pinpoint:start [url]` spawns one server per session, which serves the app
26 * with the overlay at its address, or asks which app until one is chosen; each batch the server
27 * prints is handed to the session as a prompt, in order, and the server told it went in.
28 * `/pinpoint:start <other url>` switches the app, `/pinpoint:stop` ends it. A server that dies
29 * comes back on its port and token, serving the same app, so the pages reconnect by themselves.
30 */
31
32const START_SKILL = "pinpoint:start";
33
34const STOP_SKILL = "pinpoint:stop";
35
36/** The server leaves after 60 s without one (`server/cli.ts`): a `claude` killed hard sends no signal. */
37export const HEARTBEAT_MS = 15_000;
38
39/** This many unexpected ends within the window stop the revivals. */
40export const CRASHES = 3;
41
42const CRASH_WINDOW_MS = 60_000;
43
44type Batch = Extract<ServerLine, { type: "batch" }>;
45
46/**
47 * A session's pinpoint, from `/pinpoint:start` or the restore after a reload to `/pinpoint:stop`,
48 * carried across every server a revival replaces: what was handed over, and the one queue every
49 * relay of its servers goes through, in order.
50 */
51type Tenure = {
52 /** The Claude Code session; a `/clear` or a `/resume` moves it to the new id, in place. */
53 sessionId: string;
54 /** The store's copy, rewritten as a server starts and after each relay. */
55 record: SessionRecord;
56 relays: Promise<void>;
57};
58
59type Starting = { readonly kind: "starting"; readonly tenure: Tenure };
60
61type Live = {
62 readonly kind: "live";
63 readonly tenure: Tenure;
64 readonly child: Child;
65 readonly heartbeat: Timer;
66 /** When the tenure's servers ended on their own, for the crash window. */
67 readonly ends: readonly number[];
68};
69
70/** A server that could not be revived: its tenure kept, so `/pinpoint:start` tries again on its port. */
71type Lost = { readonly kind: "lost"; readonly tenure: Tenure; readonly reason: string };
72
73/** `/pinpoint:stop` under way: its tenure dropped, and only that stop leaves it. */
74type Stopping = { readonly kind: "stopping" };
75
76type State = { readonly kind: "idle" } | Starting | Live | Stopping | Lost;
77
78/** The tenure `state` carries: none while idle, nor once a stop dropped it. */
79function tenureOf(state: State): Tenure | null {
80 return state.kind === "idle" || state.kind === "stopping" ? null : state.tenure;
81}
82
83/**
84 * The surfaces drawn on this machine, whose person sees a browser opened here: a phone (`mobile`)
85 * is elsewhere, a `-p` run draws on none, and Claude Code for VS Code attaches none yet.
86 */
87const HERE: ReadonlySet<RenderSurface> = new Set(["terminal", "desktop"]);
88
89function recordKey(sessionId: string): string {
90 return `session:${sessionId}`;
91}
92
93/**
94 * How the session hears of its address: the browser opened there, an address for the person to
95 * open, or the one a server already running keeps, where its tabs are.
96 */
97type Reach = "opened" | "open" | "kept";
98
99function startedText(record: SessionRecord, reach: Reach): string {
100 const target = record.target === null ? null : new URL(record.target);
101 const address = addressOn(record.port, target);
102 const opened = reach === "opened";
103
104 const where = {
105 opened: `The browser opens ${address}`,
106 open: `Open ${address} in the browser`,
107 kept: `Its address stays ${address}`,
108 }[reach];
109
110 return [
111 reach === "kept"
112 ? `pinpoint is already live for ${record.root}.`
113 : `pinpoint is live for ${record.root}.`,
114 target === null
115 ? `${where}: "Which app?" lists the apps running on this machine and takes any other address. /pinpoint:start <url> serves one directly.`
116 : `${where}: it serves ${target.origin} with the overlay. /pinpoint:start <url> switches to another app at the same address.`,
117 ...(opened ? ["If no tab comes up, open that address yourself."] : []),
118 "Press Alt+P on the page to point.",
119 ].join("\n");
120}
121
122/** The record `stored` holds; one that does not read is logged, and the session starts afresh. */
123function recordIn(host: Host, stored: Stored): SessionRecord | null {
124 if (stored.kind === "unreadable") {
125 host.log(
126 `pinpoint: the session's stored record does not read, so no server comes back for it (${stored.value}); run /pinpoint:start to start one`,
127 );
128 }
129
130 return stored.kind === "kept" ? stored.record : null;
131}
132
133function switchedText(record: SessionRecord, target: URL): string {
134 return `pinpoint now serves ${target.origin} at ${addressOn(record.port, target)}; reload the tabs open there.`;
135}
136
137function selfText(record: SessionRecord, asked: string, opened: boolean): string {
138 const address = addressOn(record.port, null);
139 const pick = opened ? `The browser opens ${address}` : `Open ${address}`;
140
141 return `pinpoint did not take ${asked}: it is this pinpoint server's own address, which would serve itself. ${pick} to pick the app, or run /pinpoint:start with the app's address.`;
142}
143
144function notAnAddressText(given: string): string {
145 return `pinpoint: "${given}" is not an http(s) address, so nothing changed. Run /pinpoint:start <url>, as /pinpoint:start http://localhost:5173/, or /pinpoint:start alone to pick the app in the browser.`;
146}
147
148const NOT_STARTED =
149 "pinpoint did not start its server; the transcript's log line says why. Run /pinpoint:start again once it is fixed.";
150
151/** What `/pinpoint:start` says of the state it finds, or finds again once a stop or another way in got there first. */
152function wayInText(state: State): string {
153 switch (state.kind) {
154 case "live":
155 return startedText(state.tenure.record, "kept");
156 case "starting":
157 return "pinpoint is starting its server; run /pinpoint:start again in a moment to see where it listens.";
158 case "lost":
159 return NOT_STARTED;
160 case "stopping":
161 case "idle":
162 return "pinpoint was stopped meanwhile; run /pinpoint:start again to start it.";
163 }
164}
165
166/**
167 * Binds this dispatch's `$`, the one place it is spelled: the loader follows it only into a
168 * function declared at the top of this file. A timer or a read loop keeps the host of the dispatch
169 * that started it.
170 */
171function hostOf($: EngineInterface): Host {
172 return {
173 pluginRoot: $.plugin.root,
174 spawn: (request) => $.process.spawn(request),
175 run: (argv) => $.process.run(argv),
176 fetch: (url, init) => $.http.fetch(url, init),
177 submit: (text) => $.prompt.submit({ text }),
178 after: (ms, fn) => $.clock.after(ms, fn),
179 repeat: (ms, fn) => $.clock.every(ms, fn),
180 now: () => $.clock.now(),
181 storeSet: (key, value) => $.store.set(key, value),
182 settings: () => $.settings.read(),
183 status: (text) => $.ui.status(text),
184 log: (text) => $.ui.log(text),
185 };
186}
187
188export const register: Register = (on) => {
189 let state: State = { kind: "idle" };
190
191 /**
192 * Every write of `state`. Leaving a live state stops its heartbeat and ends its server, but for
193 * `stopping`: the stop closes the server first, and the close removes its files.
194 */
195 function become(host: Host, next: State): void {
196 const was = state;
197 state = next;
198
199 if (was.kind === "live" && next !== was) {
200 was.heartbeat.cancel();
201
202 if (next.kind !== "stopping") was.child.end();
203 }
204
205 host.status(next.kind === "lost" ? next.reason : undefined);
206 }
207
208 /** Whether `state` is still `from`, read through a call: the compiler keeps a narrowing of `state` across an await. */
209 function holds(from: State): boolean {
210 return state === from;
211 }
212
213 /** Tells the tenure's server what became of a batch; with none up, the next one tells the batch again. */
214 async function tell(host: Host, tenure: Tenure, delivery: Delivery): Promise<void> {
215 if (state.kind === "live" && state.tenure === tenure) {
216 await tellDelivery(host, state.child.address, delivery);
217 }
218 }
219
220 async function relay(host: Host, tenure: Tenure, batch: Batch): Promise<void> {
221 if (tenureOf(state) !== tenure) return;
222
223 // A server started anew tells again what it holds: a batch already handed over is only acknowledged.
224 if (batch.seq <= tenure.record.relayed) {
225 await tell(host, tenure, { seq: batch.seq, outcome: "delivered" });
226
227 return;
228 }
229
230 const entered = await host
231 .submit(batch.text)
232 .catch((cause: unknown) => ({ drop: String(cause) }));
233
234 if (tenureOf(state) !== tenure) return;
235
236 if (entered.drop !== undefined) {
237 host.log(`pinpoint: batch ${batch.seq} did not enter the session: ${entered.drop}`);
238 await tell(host, tenure, { seq: batch.seq, outcome: "dropped", reason: entered.drop });
239
240 return;
241 }
242
243 tenure.record = { ...tenure.record, relayed: batch.seq };
244 await host.storeSet(recordKey(tenure.sessionId), tenure.record);
245 await tell(host, tenure, { seq: batch.seq, outcome: "delivered" });
246 }
247
248 /**
249 * Starts the server of `from`'s tenure, on its port and token when it has them, and makes it the
250 * state. A server that does not start leaves `from` as it is, for the caller to say what follows;
251 * a `from` left meanwhile takes nothing, and the server started for it ends.
252 */
253 async function enter(host: Host, from: Starting, ends: readonly number[]): Promise<void> {
254 const { tenure } = from;
255 const child = await start(host, tenure.record);
256
257 if (child === null) return;
258
259 if (!holds(from)) {
260 child.end();
261
262 return;
263 }
264
265 tenure.record = {
266 ...tenure.record,
267 port: child.address.port,
268 token: child.address.token,
269 target: child.target,
270 };
271 await host.storeSet(recordKey(tenure.sessionId), tenure.record);
272
273 if (!holds(from)) {
274 child.end();
275
276 return;
277 }
278
279 // A beat that fails says nothing the read loop does not: a server that died ends its stream.
280 const heartbeat = host.repeat(HEARTBEAT_MS, () => {
281 void host.fetch(`${baseOf(child.address)}api/heartbeat`, { method: "POST" }).catch(() => {});
282 });
283
284 const live: Live = { kind: "live", tenure, child, heartbeat, ends };
285 become(host, live);
286
287 follow(host, live).catch((cause: unknown) => {
288 // A reload or the end of a kit test tears the environment down under the loop: its `$`
289 // then refuses every call, the log included, and nobody is left to tell.
290 try {
291 host.log(`pinpoint: the server's read loop failed: ${String(cause)}`);
292 } catch {
293 // Gone with the environment: there is no transcript left to write to.
294 }
295 });
296 }
297
298 /** What a server that ended on its own leaves: a new one on its port, or `lost` past the crash window. */
299 async function revive(host: Host, from: Live): Promise<void> {
300 const now = await host.now();
301
302 if (!holds(from)) return;
303 const ends = [...from.ends.filter((at) => now - at < CRASH_WINDOW_MS), now];
304 const { tenure } = from;
305
306 if (ends.length >= CRASHES) {
307 const reason = `pinpoint: the server ended ${ends.length} times within a minute; run /pinpoint:start to try again`;
308 become(host, { kind: "lost", tenure, reason });
309
310 return;
311 }
312
313 const starting: Starting = { kind: "starting", tenure };
314 become(host, starting);
315 await enter(host, starting, ends);
316
317 if (holds(starting)) {
318 const reason = "pinpoint: the server ended and did not start again; run /pinpoint:start";
319 become(host, { kind: "lost", tenure, reason });
320 }
321 }
322
323 /**
324 * Reads the child until it ends, queueing each batch on the tenure's relays and recording each
325 * app it says it serves, which a revival serves again.
326 */
327 async function read(host: Host, live: Live): Promise<void> {
328 const { tenure } = live;
329
330 for (;;) {
331 const next = await live.child.lines.next();
332
333 if (next.done === true) return;
334 const line = parseServerLine(next.value);
335
336 if (line?.type === "batch") {
337 tenure.relays = tenure.relays
338 .then(() => relay(host, tenure, line))
339 .catch((cause: unknown) => {
340 host.log(`pinpoint: batch ${line.seq} was not relayed: ${String(cause)}`);
341 });
342 } else if (line?.type === "target") {
343 if (!holds(live)) continue;
344 tenure.record = { ...tenure.record, target: line.target };
345 await host.storeSet(recordKey(tenure.sessionId), tenure.record);
346 } else {
347 host.log(
348 `pinpoint: the server printed a line the module does not read: ${next.value.slice(0, 200)}`,
349 );
350 }
351 }
352 }
353
354 /** Reads the child to its end, a read that fails being one: an end nobody asked for revives the server. */
355 async function follow(host: Host, live: Live): Promise<void> {
356 await read(host, live).catch((cause: unknown) => {
357 if (holds(live)) host.log(`pinpoint: the server's read loop failed: ${String(cause)}`);
358 });
359
360 if (holds(live)) await revive(host, live);
361 }
362
363 // A reload of the module ends its children: the session's record starts the server again.
364 on("session.start", async ($, e, next) => {
365 const result = await next(e);
366
367 if (state.kind !== "idle") return result;
368 const from = state;
369 const host = hostOf($);
370 const sessionId = await $.session.id();
371 const record = recordIn(host, storedRecord(await $.store.get(recordKey(sessionId))));
372
373 if (record === null || !holds(from)) return result;
374 const tenure: Tenure = { sessionId, record, relays: Promise.resolve() };
375 const starting: Starting = { kind: "starting", tenure };
376 become(host, starting);
377 await enter(host, starting, []);
378
379 if (holds(starting)) {
380 const reason = "pinpoint: the server did not start again after a reload; run /pinpoint:start";
381 become(host, { kind: "lost", tenure, reason });
382 }
383
384 return result;
385 });
386
387 on("skill.prompt", { skill: START_SKILL }, async ($, e, next) => {
388 const result = await next(e);
389 const host = hostOf($);
390 const from = state;
391 const said = (line: string) => ({ text: `${result.text}\n\n${line}` });
392 const given = argumentsOf(e.text);
393 const asked = given === null ? null : targetOf(given);
394
395 if (given !== null && asked === null) return said(notAnAddressText(given));
396
397 if (from.kind === "live" && asked !== null) {
398 const refused = await switchTarget(host, from.child.address, asked.href);
399
400 if (refused !== null) return said(`pinpoint kept the app it serves: ${refused}`);
401
402 return said(switchedText(from.tenure.record, asked));
403 }
404
405 if (from.kind !== "idle" && from.kind !== "lost") return said(wayInText(from));
406 let tenure: Tenure;
407
408 if (from.kind === "lost") {
409 tenure = from.tenure;
410 } else {
411 const sessionId = await $.session.id();
412 const stored = recordIn(host, storedRecord(await $.store.get(recordKey(sessionId))));
413
414 const record = stored ?? {
415 serverSession: sessionId,
416 root: await $.session.root(),
417 port: 0,
418 token: "",
419 target: null,
420 relayed: 0,
421 };
422
423 if (!holds(from)) return said(wayInText(state));
424 tenure = { sessionId, record, relays: Promise.resolve() };
425 }
426
427 if (asked !== null) tenure.record = { ...tenure.record, target: asked.href };
428 const starting: Starting = { kind: "starting", tenure };
429 become(host, starting);
430 await enter(host, starting, []);
431
432 if (!holds(starting)) {
433 if (state.kind !== "live") return said(wayInText(state));
434 const live: Live = state;
435
436 // From `lost` the server comes back on its kept address, the one its tabs hold already.
437 const opened =
438 from.kind === "idle" &&
439 (await $.session.surfaces()).some((surface) => HERE.has(surface)) &&
440 holds(live) &&
441 (await openBrowser(host, live.child.address));
442
443 // A stop, or an end and a revival, came meanwhile: no tab opened is said of what is left.
444 if (!holds(live)) {
445 return said(
446 state.kind === "live" ? startedText(state.tenure.record, "open") : wayInText(state),
447 );
448 }
449
450 // The server refuses a `--target` that is its own address, which only its bind could tell.
451 return asked !== null && live.child.target === null
452 ? said(selfText(live.tenure.record, asked.href, opened))
453 : said(startedText(live.tenure.record, opened ? "opened" : "open"));
454 }
455
456 become(host, from);
457
458 return said(NOT_STARTED);
459 });
460
461 on("skill.prompt", { skill: STOP_SKILL }, async ($, e, next) => {
462 const result = await next(e);
463 const host = hostOf($);
464 const was = state;
465 const said = (line: string) => ({ text: `${result.text}\n\n${line}` });
466
467 if (was.kind === "idle") return said("No pinpoint server was running.");
468
469 if (was.kind === "stopping") return said("pinpoint is already stopping.");
470 become(host, { kind: "stopping" });
471
472 // The server removes its files, pending batches included: stopping is the user's word.
473 const closed = was.kind === "live" && (await close(host, was.child.address));
474
475 if (was.kind === "live") was.child.end();
476
477 if (!closed) await clean(host, was.tenure.record.serverSession);
478 await $.store.delete(recordKey(was.tenure.sessionId));
479 become(host, { kind: "idle" });
480
481 return said("pinpoint stopped: its server ended, and the app's overlay disconnects.");
482 });
483
484 // A `/clear` mints a new session id, and `/resume` may land in another: the tenure carries over.
485 on("command.run", { command: ["clear", "resume"] }, async ($, e, next) => {
486 const result = await next(e);
487 const sessionId = await $.session.id();
488 const tenure = tenureOf(state);
489
490 if (tenure === null || sessionId === tenure.sessionId) return result;
491 const left = tenure.sessionId;
492 tenure.sessionId = sessionId;
493 await $.store.delete(recordKey(left));
494
495 // A first server not up yet has no record to move: its start writes one under the new id.
496 if (tenureOf(state) === tenure && tenure.record.port !== 0) {
497 await $.store.set(recordKey(sessionId), tenure.record);
498 }
499
500 return result;
501 });
502};
503shared/target.ts 23 lines1/**
2 * The app a person names, as `/pinpoint:start <url>`, the "Which app?" field or `cli.ts serve
3 * --target` read it: an `http:` or `https:` URL. A bare port is an app on this machine, and an
4 * address with no scheme is taken as `http:`, as a dev server prints it. A URL a paste broke over
5 * lines is joined again.
6 */
7export function targetOf(text: string): URL | null {
8 const typed = text.trim().replaceAll(/\s*\n\s*/gu, "");
9 const spelled = /^\d+$/u.test(typed) ? `localhost:${typed}` : typed;
10 const url = URL.parse(/^[a-z][a-z\d+.-]*:\/\//iu.test(spelled) ? spelled : `http://${spelled}`);
11
12 return url !== null &&
13 (url.protocol === "http:" || url.protocol === "https:") &&
14 url.hostname !== ""
15 ? url
16 : null;
17}
18
19/** The session's address on `port`: the app's path once one is served, "Which app?" with none. */
20export function addressOn(port: number, target: URL | null): string {
21 return `http://localhost:${port}${target === null ? "/" : `${target.pathname}${target.search}`}`;
22}
23shared/wire.ts 109 lines1/**
2 * Every value that crosses a process boundary of pinpoint: the page to the server (HTTP), the
3 * server to the page (its answers), the server to the hooks module (its stdout), the module to
4 * the server (HTTP). The "Which app?" form posts one field, `url`, read by `targetOf`
5 * (`target.ts`).
6 */
7
8/**
9 * A box in CSS pixels. Every box a selection carries is in page pixels, from the top-left corner
10 * of the document: measured at its pick, the viewport's box plus the scroll then.
11 */
12export type Box = {
13 readonly x: number;
14 readonly y: number;
15 readonly width: number;
16 readonly height: number;
17};
18
19/** One computed style the selection carries, `[property, value]`. */
20export type Style = readonly [property: string, value: string];
21
22/** What an element is, read off the page at the pick: what a reader who does not see the page needs. */
23export type ElementInfo = {
24 readonly selector: string;
25 readonly role: string;
26 /** Its accessible name, `""` for none. */
27 readonly name: string;
28 /** The name of the last heading before it, `""` for none. */
29 readonly heading: string;
30 readonly openingTag: string;
31 readonly text: string;
32 readonly box: Box;
33 readonly styles: readonly Style[];
34};
35
36/**
37 * A zone's image as the page drew it at the pick: the id the server gave the PNG it keeps
38 * (`ImageAccepted`), or why the page could not take or hand it over.
39 */
40export type ZoneImage = { readonly id: string } | { readonly failed: string };
41
42export type Target =
43 | { readonly kind: "element"; readonly element: ElementInfo }
44 | {
45 readonly kind: "zone";
46 readonly box: Box;
47 readonly elements: readonly ElementInfo[];
48 readonly image: ZoneImage;
49 };
50
51export type PageInfo = {
52 readonly url: string;
53 readonly title: string;
54 readonly viewport: { readonly width: number; readonly height: number };
55 readonly scroll: { readonly x: number; readonly y: number };
56};
57
58export type Selection = {
59 readonly label: string | null;
60 readonly note: string;
61 readonly targets: readonly Target[];
62 readonly page: PageInfo;
63 /** What the app's `window.pinpointContext()` answered, as JSON text; `null` when the app gives none. */
64 readonly context: string | null;
65};
66
67/** `POST /t/<token>/api/batch`. */
68export type BatchRequest = { readonly selections: readonly Selection[] };
69
70/** Its answer, 202: the number the server gave the batch. */
71export type BatchAccepted = { readonly seq: number };
72
73/** The answer to `POST /t/<token>/api/image`, a PNG as the body, 201: the id it is kept under. */
74export type ImageAccepted = { readonly id: string };
75
76/**
77 * What the server writes on stdout for the hooks module, one JSON per line, `ready` first.
78 * `target` is the app the server serves, an `http(s)` URL's `href`, `null` while none is chosen:
79 * `ready` says the one it took from `--target`, which it refuses when that is its own address; a
80 * `target` line says the app a page picked or `api/target` set since.
81 */
82export type ServerLine =
83 | {
84 readonly type: "ready";
85 readonly port: number;
86 readonly token: string;
87 readonly target: string | null;
88 }
89 | { readonly type: "batch"; readonly seq: number; readonly text: string }
90 | { readonly type: "target"; readonly target: string };
91
92/** `POST /t/<token>/api/target`, the module's alone: the app to serve from now on. */
93export type TargetRequest = { readonly url: string };
94
95/** `POST /t/<token>/api/delivery`: what became of a batch the module handed to the session. */
96export type Delivery =
97 | { readonly seq: number; readonly outcome: "delivered" }
98 | { readonly seq: number; readonly outcome: "dropped"; readonly reason: string };
99
100/**
101 * What became of a batch, as `GET /t/<token>/api/outcomes?seq=<n>&seq=<n>` answers it, one per
102 * seq asked, in order: `pending` until the module reports it, then what it reported; `unknown`
103 * for a seq the server holds nothing of, never numbered or settled too long ago.
104 */
105export type Outcome =
106 | { readonly seq: number; readonly outcome: "pending" }
107 | Delivery
108 | { readonly seq: number; readonly outcome: "unknown" };
109hooks/parse.ts 133 lines1import type { ServerLine } from "../shared/wire.ts";
2
3/**
4 * The module's boundary: a line of the server's stdout, a record of `$.store` and a key of the
5 * settings arrive as text or `unknown`, and are parsed here, once.
6 */
7
8/** What the store keeps per Claude Code session, under `session:<id>`, so a reload starts the server again where it was. */
9export type SessionRecord = {
10 /** The id the server was started under, kept across a `/clear`: its files live under it. */
11 readonly serverSession: string;
12 readonly root: string;
13 readonly port: number;
14 readonly token: string;
15 /** The app the server serves, as its `ready` or `target` line last said: a revival serves it again. */
16 readonly target: string | null;
17 /** The last batch handed to the session: a server that tells it again is not relayed twice. */
18 readonly relayed: number;
19};
20
21/* oxlint-disable anti-slop/no-runtime-typeof, anti-slop/no-unknown-parameters, anti-slop/no-unsafe-dictionary-type -- the block below IS the boundary parser of the server's stdout and of the plugin's store. */
22function isRecord(value: unknown): value is Record<string, unknown> {
23 return typeof value === "object" && value !== null;
24}
25
26/** An app's address as the server writes it, an `http(s)` URL's `href`. */
27function isAddress(value: unknown): value is string {
28 const url = typeof value === "string" ? URL.parse(value) : null;
29
30 return url !== null && (url.protocol === "http:" || url.protocol === "https:");
31}
32
33export function parseServerLine(text: string): ServerLine | null {
34 let value: unknown;
35
36 try {
37 value = JSON.parse(text);
38 } catch {
39 return null;
40 }
41
42 if (!isRecord(value)) return null;
43
44 if (value.type === "ready") {
45 const { port, token, target } = value;
46
47 return typeof port === "number" &&
48 typeof token === "string" &&
49 (target === null || isAddress(target))
50 ? { type: "ready", port, token, target }
51 : null;
52 }
53
54 if (value.type === "batch") {
55 const { seq, text: said } = value;
56
57 return typeof seq === "number" && typeof said === "string"
58 ? { type: "batch", seq, text: said }
59 : null;
60 }
61
62 if (value.type === "target") {
63 const { target } = value;
64
65 return isAddress(target) ? { type: "target", target } : null;
66 }
67
68 return null;
69}
70
71const ARGUMENTS = "\n\nARGUMENTS: ";
72
73/**
74 * What the person typed after the skill's name, `null` for nothing: with no `$ARGUMENTS` in its
75 * file, Claude Code ends the skill's text with `ARGUMENTS: <input>`, before `skill.prompt` runs.
76 * The input may run over several lines.
77 */
78export function argumentsOf(text: string): string | null {
79 const at = text.lastIndexOf(ARGUMENTS);
80 const given = at === -1 ? "" : text.slice(at + ARGUMENTS.length).trim();
81
82 return given === "" ? null : given;
83}
84
85export function parseSessionRecord(value: unknown): SessionRecord | null {
86 if (!isRecord(value)) return null;
87 const { serverSession, root, port, token, relayed } = value;
88 // A record the module stored before it kept an app has no `target`: read as none, so a reload
89 // still revives its server on the port and token its pages load from, and the batch it last
90 // handed over is not handed over twice.
91 const target = "target" in value ? value.target : null;
92
93 return typeof serverSession === "string" &&
94 typeof root === "string" &&
95 typeof port === "number" &&
96 typeof token === "string" &&
97 (target === null || isAddress(target)) &&
98 typeof relayed === "number" &&
99 Number.isSafeInteger(relayed) &&
100 relayed >= 0
101 ? { serverSession, root, port, token, target, relayed }
102 : null;
103}
104
105/** Claude Code's `cleanupPeriodDays` when no settings set it. */
106const CLEANUP_PERIOD_DEFAULT = 30;
107
108/**
109 * How many days Claude Code keeps a session's data, as the merged settings' `cleanupPeriodDays`
110 * says: `null` for a value it refuses, under which it pauses its own sweep.
111 */
112export function retentionDays(value: unknown): number | null {
113 if (value === undefined) return CLEANUP_PERIOD_DEFAULT;
114
115 return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ? value : null;
116}
117
118/** What `$.store.get` gave for a session: nothing, a record, or a value that is not one. */
119export type Stored =
120 | { readonly kind: "none" }
121 | { readonly kind: "kept"; readonly record: SessionRecord }
122 | { readonly kind: "unreadable"; readonly value: string };
123
124export function storedRecord(value: unknown): Stored {
125 if (value === undefined) return { kind: "none" };
126 const record = parseSessionRecord(value);
127
128 return record === null
129 ? { kind: "unreadable", value: JSON.stringify(value).slice(0, 200) }
130 : { kind: "kept", record };
131}
132/* oxlint-enable anti-slop/no-runtime-typeof, anti-slop/no-unknown-parameters, anti-slop/no-unsafe-dictionary-type */
133hooks/server.ts 250 lines1import type {
2 HookStream,
3 HttpInit,
4 HttpResponse,
5 ProcessRunResult,
6 ProcessSpawnChunk,
7 ProcessSpawnRequest,
8 ProcessSpawnResult,
9 PromptSubmitResult,
10 Settings,
11 Timer,
12} from "claude-code";
13
14import type { Delivery, TargetRequest } from "../shared/wire.ts";
15import { parseServerLine, retentionDays, type SessionRecord } from "./parse.ts";
16
17/**
18 * The engine as a hook bound it from its `$`, each member spelled `$.noun.verb(...)` in
19 * `register.ts`: the loader follows `$` only into a function declared in the file that registers
20 * the hook, so every other file takes this record instead.
21 */
22export type Host = {
23 readonly pluginRoot: string;
24 readonly spawn: (
25 request: ProcessSpawnRequest,
26 ) => HookStream<ProcessSpawnChunk, ProcessSpawnResult>;
27 /** `$.process.run`. */
28 readonly run: (argv: readonly string[]) => Promise<ProcessRunResult>;
29 readonly fetch: (url: string, init?: HttpInit) => Promise<HttpResponse>;
30 readonly submit: (text: string) => Promise<PromptSubmitResult>;
31 readonly after: (ms: number, fn: () => void) => Timer;
32 /** `$.clock.every`. */
33 readonly repeat: (ms: number, fn: () => void) => Timer;
34 /** `$.clock.now()`: milliseconds since the epoch, as the engine's clock reads them. */
35 readonly now: () => Promise<number>;
36 readonly storeSet: (key: string, value: SessionRecord) => Promise<void>;
37 /** `$.settings.read()`: the settings merged over every source, as the engine runs under them. */
38 readonly settings: () => Promise<Settings>;
39 /** The line under the prompt, for a failure alone; `undefined` clears it. */
40 readonly status: (text: string | undefined) => void;
41 readonly log: (text: string) => void;
42};
43
44/** How long a start waits for `ready`: a hook that waits longer holds the way in. */
45export const START_TIMEOUT_MS = 5_000;
46
47/** What a failed start says of the child's stderr, its end being where the error is. */
48const STDERR_KEPT = 2_000;
49
50export type Address = { readonly port: number; readonly token: string };
51
52/** The child's stdout after `ready`, a line at a time, then how the child ended. */
53type Lines = AsyncGenerator<string, ProcessSpawnResult>;
54
55/**
56 * A server this module spawned: the app its `ready` said it serves, the rest of its output, and
57 * the way to end it. `end` returns the engine's stream, which kills the child even while a read is
58 * pending.
59 */
60export type Child = {
61 readonly address: Address;
62 readonly target: string | null;
63 readonly lines: Lines;
64 readonly end: () => void;
65};
66
67/** Cuts the pieces of stdout on `\n` and keeps the tail for the next piece: a piece ends wherever the child's write did. */
68async function* linesOf(
69 stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>,
70 heard: { stderr: string },
71): Lines {
72 let tail = "";
73
74 for await (const { stream: pipe, text } of stream) {
75 if (pipe === "stderr") {
76 heard.stderr = `${heard.stderr}${text}`.slice(-STDERR_KEPT);
77 continue;
78 }
79
80 const cut = `${tail}${text}`.split("\n");
81 tail = cut.pop() ?? "";
82 yield* cut;
83 }
84
85 return await stream.result;
86}
87
88export function baseOf(address: Address): string {
89 return `http://127.0.0.1:${address.port}/t/${address.token}/`;
90}
91
92/**
93 * Spawns the server of `record` at its root and reads it up to `ready`, for `START_TIMEOUT_MS` at
94 * most: on the port and the token the app's pages already load from once it has them, serving its
95 * app, numbering past its last batch relayed, and sweeping the other sessions Claude Code's
96 * retention period has passed. A child that gives no `ready` is ended, and `null` answered with the
97 * reason logged.
98 */
99export async function start(host: Host, record: SessionRecord): Promise<Child | null> {
100 const keepDays = await host.settings().then(
101 (settings) => retentionDays(settings.cleanupPeriodDays),
102 (cause: unknown) => {
103 host.log(
104 `pinpoint: the settings did not read, so no other session is swept: ${String(cause)}`,
105 );
106
107 return null;
108 },
109 );
110
111 const argv = [
112 "bun",
113 `${host.pluginRoot}/server/cli.ts`,
114 "serve",
115 "--session",
116 record.serverSession,
117 "--root",
118 record.root,
119 "--relayed",
120 String(record.relayed),
121 ...(keepDays === null ? [] : ["--keep-days", String(keepDays)]),
122 ...(record.port === 0 ? [] : ["--port", String(record.port), "--token", record.token]),
123 ...(record.target === null ? [] : ["--target", record.target]),
124 ];
125
126 const heard = { stderr: "" };
127 const stream = host.spawn({ argv, cwd: record.root });
128 const lines = linesOf(stream, heard);
129
130 const end = (): void => {
131 void stream.return({ code: null, signal: "SIGTERM" });
132 };
133
134 const { promise: timeout, resolve: late } = Promise.withResolvers<"late">();
135 const timer = host.after(START_TIMEOUT_MS, () => late("late"));
136
137 try {
138 const first = await Promise.race([lines.next(), timeout]);
139
140 if (first === "late") {
141 end();
142 host.log(`pinpoint: the server did not start: no ready line within ${START_TIMEOUT_MS} ms`);
143
144 return null;
145 }
146
147 if (first.done === true) {
148 host.log(
149 `pinpoint: the server did not start: ${heard.stderr.trim() || `it ended (${JSON.stringify(first.value)})`}`,
150 );
151
152 return null;
153 }
154
155 const ready = parseServerLine(first.value);
156
157 if (ready?.type === "ready") {
158 return {
159 address: { port: ready.port, token: ready.token },
160 target: ready.target,
161 lines,
162 end,
163 };
164 }
165
166 end();
167 host.log(
168 `pinpoint: the server did not start: its first line is not ready: ${first.value.slice(0, 200)}`,
169 );
170 } catch (cause) {
171 host.log(`pinpoint: the server did not start: ${String(cause)}`);
172 } finally {
173 timer.cancel();
174 }
175
176 return null;
177}
178
179/** Posts to one of the module's routes of the server; `false` when it did not answer that it took it. */
180async function post(host: Host, address: Address, route: string): Promise<boolean> {
181 const response = await host
182 .fetch(`${baseOf(address)}${route}`, { method: "POST" })
183 .catch(() => ({ ok: false }));
184
185 return response.ok;
186}
187
188/** Asks the server to open the browser at the session's address; `false` when its opener did not run. */
189export function openBrowser(host: Host, address: Address): Promise<boolean> {
190 return post(host, address, "api/open");
191}
192
193/** Asks the server to remove its files and exit; `false` when it did not answer that it would. */
194export function close(host: Host, address: Address): Promise<boolean> {
195 return post(host, address, "api/close");
196}
197
198/**
199 * Asks the server to serve `url` from now on: `null` once it does, else why not, in its words
200 * or the fetch's. The server's `target` line follows, which the read loop records.
201 */
202export async function switchTarget(
203 host: Host,
204 address: Address,
205 url: string,
206): Promise<string | null> {
207 const request: TargetRequest = { url };
208
209 const response = await host
210 .fetch(`${baseOf(address)}api/target`, {
211 method: "POST",
212 headers: { "content-type": "application/json" },
213 body: JSON.stringify(request),
214 })
215 .catch((cause: unknown) => ({ ok: false, status: 0, text: String(cause) }));
216
217 return response.ok ? null : response.text.trim() || `the server answered ${response.status}`;
218}
219
220/** Tells the server what became of a batch, so the page says it; a server that does not hear is logged. */
221export async function tellDelivery(
222 host: Host,
223 address: Address,
224 delivery: Delivery,
225): Promise<void> {
226 const response = await host
227 .fetch(`${baseOf(address)}api/delivery`, {
228 method: "POST",
229 headers: { "content-type": "application/json" },
230 body: JSON.stringify(delivery),
231 })
232 .catch((cause: unknown) => ({ ok: false, status: 0, text: String(cause) }));
233
234 if (!response.ok)
235 host.log(
236 `pinpoint: the server did not take the delivery of batch ${delivery.seq}: ${response.status} ${response.text}`,
237 );
238}
239
240/** Removes the files of a session whose server is gone, which no server is left to remove: `cli.ts clean`. */
241export async function clean(host: Host, serverSession: string): Promise<void> {
242 const ran = await host
243 .run(["bun", `${host.pluginRoot}/server/cli.ts`, "clean", "--session", serverSession])
244 .catch((cause: unknown) => ({ exitCode: null, stderr: String(cause) }));
245
246 if (ran.exitCode !== 0) {
247 host.log(`pinpoint: the session's files were not removed: ${ran.stderr.trim()}`);
248 }
249}
250