SLOPSHOPPER

pinpoint

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…

statuspromptprocessnetworktimer
★ 6v0.5.1MITupdated 2026-10-20bengous/claude-code-plugins/pinpoint
A shopper browsing a rack in a slop shop
README

pinpoint

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.

Requirements

  • Claude Code with mods, as vellum needs them: the npm 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.
  • An app you run on this machine, served over http(s): a dev server (Vite, Next, Rails, Django, Storybook…) or any other.
  • A recent Chromium or Firefox.

Install

/plugin marketplace add bengous/claude-code-plugins
/plugin install pinpoint@bengous-plugins

Start

  1. In the Claude Code session, run /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.
  2. "Which app?" names the session the batches will reach, by its folder, so a start in the wrong session shows before the first batch. It lists the apps answering HTML on the usual dev ports of this machine (3000 to 3005, 4000, 4173, 4200, 4321, 5000, 5173 to 5179, 6006, 8000, 8080, 8081, 8888, 9000), each by its page's title, and takes any other address. A port that took the connection but did not answer within a second, as a dev server compiling its first page, is listed too, marked so. Click one: the address now serves that app, with the overlay.
  3. Press Alt+P.

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

Point

  • Alt+P turns pointing on and off. While it is on, the app sees no click: a pick is not a click on the app.
  • A click picks an element; inside an SVG, the whole 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.
  • Ctrl+click or Ctrl+drag (⌘ on a Mac) adds to the selection being written. While Ctrl is held, the composer and the panel fade and let the click through.
  • The composer offers the quick labels (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.
  • The composer opens beside what was picked. Drag its top row to move it out of the way: it stays pinned there, and every next composer of the tab opens at that place, across reloads, kept inside the window. Unpin in the composer forgets the place and opens it beside each pick again.
  • Send (n) in the panel sends the whole batch, and so does Ctrl+Enter while pointing, which first puts the selection being written in it; with pointing off, Ctrl+Enter is the app's. Send on a card sends that selection alone, Remove drops it. A send waits up to ten seconds for the images still on their way; a zone whose image is not in by then says so in the batch instead. Hovering a card shows where its targets were.
  • The panel's status line says where the last batch is: sent, delivered to the session, not delivered (its selections come back to the batch), not sent (the server does not answer; nothing leaves the batch), or unknown to the server (it keeps what became of the last hundred batches only). Each tab hears only of the batches it sent. The dot before the panel's name says whether the server answered the tab's last question.
  • Under the panel's name, 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.
  • The batch, and the batches still waiting, survive a reload of the page in the same tab: a reloaded page says the last one waiting, then what became of it. An image still on its way at the reload went with the page: its zone says so in the batch. So does pointing survive: a page reloaded while it is on comes back with it on.

Configure

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

What a batch says

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

How it works

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.
  • The module holds one state: 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.
  • The server's own routes sit behind the token in the path. Until an app is chosen, / 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.
  • The app is the server's to hold. /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.
  • A request whose 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.
  • The server builds the overlay once at its start with the session's directory written in (PINPOINT_ROOT), so every page it serves names the same session with no request of its own.
  • The overlay takes a zone's image with snapdom, which the server bundles into 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.
  • A batch is numbered and kept on disk, under ~/.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.
  • A server, as it starts, removes the files of every other session in which nothing changed (a batch, an image, a delivery, its own server's start or heartbeat, which dates the directory) for Claude Code's retention period, so a session whose server runs is never swept: the 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.
  • What the module reports of the last hundred batches stays on disk too, across a server started again. A page asks 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.
  • What the session was handed belongs to its tenure, from the start or the restore after a reload to the stop, carried across every server a revival replaces: the last batch handed over, which a batch told again does not pass, and one queue that relays the batches of all its servers one after the other, never in the loop that reads a server.
  • The server exits after a minute without the module's heartbeat: a claude killed hard sends no signal.

What it hooks

HookMatcherWhat it does
session.startStarts the server the store kept for the session again, after a reload.
skill.promptskill=pinpoint:startStarts 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.promptskill=pinpoint:stopCloses 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.

What it calls on $

CallWhat for
$.process.spawnThe server, read line by line for as long as it runs.
$.process.runcli.ts clean, at /pinpoint:stop with no server left to close: the session's files.
$.prompt.submitEach batch, once the session is idle, in order.
$.http.fetchThe server's heartbeat, each batch's delivery, a switch of the app, the browser opened, the close.
$.store.get, $.store.set, $.store.deleteThe session's record: root, port, token, the app served, last batch relayed.
$.session.id, $.session.rootWhich session the server serves, and the directory its panel and "Which app?" name.
$.session.surfacesWhether somebody sees the session on this machine: a -p run, or a phone alone, opens no browser.
$.settings.readcleanupPeriodDays, how long the other sessions' files stay.
$.clock.after, $.clock.every, $.clock.nowThe start's timeout, the heartbeat, the crash window.
$.ui.statusThe line under the prompt, for a server lost alone.
$.ui.logErrors 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.

Limits

  • Your own processes can learn the token: a revived server carries it on its command line, and the plugin's store keeps it. So can any script running in a page the server serves, whose HTML carries it. The token and the 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.
  • A page whose Content Security Policy allows scripts by nonce, 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.
  • An absolute URL to the app's own origin inside a page or a script is not rewritten: a link to it leaves the session's address, and the overlay with it, and a fetch to it goes to the app directly, under the app's own CORS rules. Only redirects are brought back.
  • A zone's image is the DOM drawn again, not the screen: an iframe of another origin, a video frame or a WebGL canvas comes out blank or approximate, and so may a CSS effect snapdom does not reproduce (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).
  • A session's files go once nothing changed in them for the retention period, whether its transcript still cites them or not, as Claude Code's own pasted images go. A session resumed after that, as a Claude Desktop one can be at any age, finds its zones' images gone, and its batches still waiting with them; its next batch numbers on. The sweep reads the merged settings only. A settings file Claude Code cannot read, or user settings left out by --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.
  • The files live under ~/.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.
  • A sweep that fails writes why on the server's stderr, which the module reads only when a start fails: in a running session nobody sees it, as with the server's other errors.
  • "Which app?" finds only the apps on its list of ports, at 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.
  • On Claude Code Desktop, a prompt a plugin submits while the session is idle shows only once you type in the session (anthropics/claude-code#96336); the terminal is unaffected.

Development

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.

License

MIT

Author

Augustin BENGOLEA <bengous@protonmail.com>

Source 5 files
hooks/register.ts 503 lines
1import 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};
503
shared/target.ts 23 lines
1/**
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}
23
shared/wire.ts 109 lines
1/**
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" };
109
hooks/parse.ts 133 lines
1import 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 */
133
hooks/server.ts 250 lines
1import 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