stuntdouble 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +31 -6
  2. package/dist/chunk-BFI6P5DO.js +278 -0
  3. package/dist/chunk-BFI6P5DO.js.map +1 -0
  4. package/dist/chunk-DFXASIGV.js +431 -0
  5. package/dist/chunk-DFXASIGV.js.map +1 -0
  6. package/dist/{chunk-7IC2G3Z3.js → chunk-F3R3QL7V.js} +19 -252
  7. package/dist/chunk-F3R3QL7V.js.map +1 -0
  8. package/dist/{mcp-MOXTYXZU.js → chunk-FMZTT2MC.js} +72 -1160
  9. package/dist/chunk-FMZTT2MC.js.map +1 -0
  10. package/dist/chunk-MSV5OLSY.js +117 -0
  11. package/dist/chunk-MSV5OLSY.js.map +1 -0
  12. package/dist/chunk-P3CI4FTE.js +469 -0
  13. package/dist/chunk-P3CI4FTE.js.map +1 -0
  14. package/dist/{chunk-JXTUCMO2.js → chunk-QDA5FTUH.js} +5 -3
  15. package/dist/{chunk-JXTUCMO2.js.map → chunk-QDA5FTUH.js.map} +1 -1
  16. package/dist/chunk-XHSDRB3X.js +996 -0
  17. package/dist/chunk-XHSDRB3X.js.map +1 -0
  18. package/dist/cli.js +179 -22
  19. package/dist/cli.js.map +1 -1
  20. package/dist/cloud-DLLNKYND.js +34 -0
  21. package/dist/credentials-QHESDY4K.js +27 -0
  22. package/dist/doctor-ZAYOOCPT.js +13 -0
  23. package/dist/doctor-ZAYOOCPT.js.map +1 -0
  24. package/dist/files-UBPF7PKW.js +45 -0
  25. package/dist/files-UBPF7PKW.js.map +1 -0
  26. package/dist/mcp-HFIAQ6HP.js +133 -0
  27. package/dist/mcp-HFIAQ6HP.js.map +1 -0
  28. package/dist/open-browser-CTOGH5HQ.js +17 -0
  29. package/dist/open-browser-CTOGH5HQ.js.map +1 -0
  30. package/dist/run-MPIAR3LQ.js +20 -0
  31. package/dist/run-MPIAR3LQ.js.map +1 -0
  32. package/dist/runner-E45EJ76B.js +17 -0
  33. package/dist/runner-E45EJ76B.js.map +1 -0
  34. package/dist/studio-FBC66BER.js +360 -0
  35. package/dist/studio-FBC66BER.js.map +1 -0
  36. package/dist/studio.js +1621 -0
  37. package/dist/ui-server-C7CFJWRD.js +12 -0
  38. package/dist/ui-server-C7CFJWRD.js.map +1 -0
  39. package/dist/ui.html +100 -56
  40. package/package.json +2 -2
  41. package/dist/chunk-7IC2G3Z3.js.map +0 -1
  42. package/dist/doctor-7PW2QD3M.js +0 -12
  43. package/dist/mcp-MOXTYXZU.js.map +0 -1
  44. package/dist/ui-server-ZUJ5WJ7Q.js +0 -9
  45. /package/dist/{doctor-7PW2QD3M.js.map → cloud-DLLNKYND.js.map} +0 -0
  46. /package/dist/{ui-server-ZUJ5WJ7Q.js.map → credentials-QHESDY4K.js.map} +0 -0
package/README.md CHANGED
@@ -1,12 +1,36 @@
1
1
  # stuntdouble
2
2
 
3
- Stunt Double Local: run [Stunt Double](https://stuntdouble.io) checklists in a Chrome on your own machine, from your own coding agent session, with every run kept as files in your project and a local viewer to watch and review them.
3
+ [![npm](https://img.shields.io/npm/v/stuntdouble.svg)](https://www.npmjs.com/package/stuntdouble) [![License](https://img.shields.io/npm/l/stuntdouble.svg)](./LICENSE)
4
4
 
5
- It is an MCP server and nothing more. Your agent session does all of the reasoning: it reads the actor's brief, drives the browser through the tools here and judges each check. This package drives the browser, checks that a passed verdict cites something on the page, scores the run with the same code as the Stunt Double cloud, and writes the files. It never calls a model, never starts an agent, and never reads an agent's credentials (a test fails the build if it tries), so a local run uses whatever account your agent is signed in with, and nothing else.
5
+ Run [Stunt Double](https://stuntdouble.io) checklists in a Chrome on your own machine, with every run kept as files in your project. Two ways in:
6
6
 
7
- Most people use it through the Stunt Double plugin for Claude Code, which starts it for you: `/stuntdouble setup`, then `/stuntdouble run <checklist>`.
7
+ - **Stunt Double Studio**, a local app: `npx stuntdouble studio` opens it in your browser. Create and edit actors and checklists, run one with an AI actor on your own model connection, watch it live, stop it and review the verdicts. Signed in to Stunt Double, the same checklist also runs in the cloud.
8
+ - **Stunt Double Local** inside your coding agent: an MCP server, which the Stunt Double plugin for Claude Code starts for you (`/stuntdouble setup`, then `/stuntdouble run <checklist>`).
8
9
 
9
- ## Commands
10
+ Both drive the same browser tools, check that a passed verdict cites something on the page, score the run with the same code as the Stunt Double cloud and write the same files.
11
+
12
+ **The policy line.** Inside your agent, your agent session is the only model: the MCP server never calls a model, never starts an agent and never reads an agent's credentials. Studio runs use only a connection you configure, an Anthropic API key or an OpenAI-compatible endpoint (OpenAI, OpenRouter, or a local server such as Ollama or LM Studio). Nothing in this package uses a Claude subscription: no claude.ai login, no Claude Code credentials, no `claude` process. `test/no-model.test.ts` fails the build if any of that changes.
13
+
14
+ ## Studio
15
+
16
+ ```sh
17
+ npx stuntdouble # in a terminal: the same as `studio` in this folder
18
+ npx stuntdouble studio [dir] # open Studio for a project (--port, --no-open)
19
+ npx stuntdouble run <checklist> # run one from the terminal on your connection (--resume, --max-steps)
20
+ npx stuntdouble run <checklist> --cloud # push it and run it in your Stunt Double workspace
21
+ npx stuntdouble login # sign in for cloud runs (--workspace <slug>)
22
+ npx stuntdouble logout
23
+ ```
24
+
25
+ A project with no `.stuntdouble/` yet starts with one question, the address to test, and gets a first-time visitor and a first checklist to edit. The editors check every field with the same schemas a run reads the files with and show what to fix beside the field.
26
+
27
+ **Connection.** Set it in Settings: Anthropic (default model `claude-opus-5-5`, or `ANTHROPIC_API_KEY` from the environment) or an OpenAI-compatible endpoint with its model and key (`OPENAI_API_KEY` works too; Ollama and LM Studio on this machine need none). The key is kept in `~/.stuntdouble/credentials.json`, readable by you only, never in the project, never in a log and never sent to the page, which hears only whether a key is set. Test connection makes one short call.
28
+
29
+ **A Studio run** is the MCP run with a model on your connection in the agent's place: the same brief, browser tools, `evaluate_check` and `finish_run`. It stops at 80 model calls or 3,000,000 tokens (the model is told to finish first), shows calls, tokens and, where the provider reports it or the price is known, the cost so far, and Stop ends it at the next step without another model call. Screenshots reach the model only when it asks for one. On Anthropic the prompt is cached and old page reads are cleared server side; on other endpoints old page reads are trimmed. The viewer's key never goes to the model.
30
+
31
+ **Cloud runs.** Sign in from Settings or `stuntdouble login`: OAuth with PKCE against the hosted MCP, back to Studio's own `127.0.0.1` address, with the token kept in `~/.stuntdouble/credentials.json`. A cloud run pushes the project, the actor and the checklist to your workspace (reusing the ids already in the files, and writing new ones back), runs it there, checks every 30 seconds and writes the result beside your local runs. The cloud cannot reach `localhost`: give the checklist a public or preview URL and keep the local one in `local_base_url`. Cloud runs are for workspaces on a paid plan and are billed to the workspace; a refusal from Stunt Double comes back as it was written, with the billing link.
32
+
33
+ ## In your agent
10
34
 
11
35
  ```sh
12
36
  npx stuntdouble mcp # the MCP server over stdio; your agent starts this
@@ -22,7 +46,7 @@ Needs Node 22.18 or later and Google Chrome (or Playwright's Chromium, from `npx
22
46
 
23
47
  | Tool | What it does |
24
48
  | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
- | `navigate`, `read_page`, `find`, `get_page_text`, `click`, `type`, `form_input`, `key`, `scroll`, `screenshot` | The direct tools from [`@stunt-double/browser-toolset`](../browser-toolset). Screenshots go back to the model only from `screenshot`, unless `model_screenshots` is `every_action`. |
49
+ | `navigate`, `read_page`, `find`, `get_page_text`, `click`, `type`, `form_input`, `key`, `scroll`, `screenshot` | The direct tools from [`@stuntkit/browser-toolset`](../browser-toolset). Screenshots go back to the model only from `screenshot`, unless `model_screenshots` is `every_action`. |
26
50
  | `start_run` | Validates the files (naming every field to fix), opens a fresh Chrome at the checklist's URL and returns the viewer link, the actor's brief and the checks. `resume` continues an interrupted run, or one still open in this session. |
27
51
  | `evaluate_check` | Records one verdict. A pass must cite text or a selector that is on the page now. Scrolls the evidence into view and saves `shots/<check>.png`. |
28
52
  | `finish_run` | Settles the checks left, scores the run and closes the browser. |
@@ -45,7 +69,7 @@ Everything lives in `.stuntdouble/` in your project, and everything but `.live/`
45
69
 
46
70
  Actors and checklists use the same fields, and the same limits, as the Stunt Double MCP server's `create_actor` and `create_checklist`, so the same files run in the cloud. The exception is `browser_config` (a preset, viewport, touch or locale), which the cloud inputs do not take yet, so a cloud run uses the default desktop browser. A local run swaps the checklist URL's origin for `local_base_url`, so one checklist covers `localhost` here and the public address there.
47
71
 
48
- Local results are self-judged: the actor records its own verdicts. Cloud runs add a second judge.
72
+ Local results are self-judged: the actor records its own verdicts. Cloud runs add a second judge. A Studio run's `run.json` says which model it ran on: `runner` gains `model` (`{"provider": "anthropic", "id": "claude-opus-5-5"}`) and, once it finishes, `usage` (`steps`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cost_usd`). Both are optional, so older files read as before.
49
73
 
50
74
  A checklist with no `weighted` checks has no score: it passes only when every pass/fail check passes, and its `pass_threshold`, which applies to weighted checks only, is not used. `start_run` and `finish_run` say so, and the viewer shows the tally with the same sentence.
51
75
 
@@ -55,6 +79,7 @@ A checklist with no `weighted` checks has no score: it passes only when every pa
55
79
  - The browser reaches local and private addresses only for the app under test: the exact origins (scheme, host and port) of the checklist's URL and `local_base_url`. The rule holds however the browser gets there, by a link, a script, a popup, a frame or a redirect; a redirect's request is still sent, but the page is cleared before any tool reads it.
56
80
  - With `safety` at `third_party` (the default), on any other host the tools refuse to pay, subscribe, create an account or type a password or card number. Set it to `off` when your app signs in on another domain.
57
81
  - The viewer listens on `127.0.0.1` only (port 4747, or the next free one), answers only to its own Host header, needs the key from the printed link (kept as an HttpOnly, SameSite=Strict cookie), sends no CORS headers and serves files from `.stuntdouble/` only. Its one control is Stop.
82
+ - Studio is the same server with more on it, and keeps every one of those rules. Each write also needs Studio's own `Origin` and a JSON body. Values from files and replies are shown as text, and only `http(s)` addresses become links. The OAuth callback is the one route without the key: it answers only a sign-in this process started.
58
83
 
59
84
  ## Licence
60
85
 
@@ -0,0 +1,278 @@
1
+ import {
2
+ ActorSchema,
3
+ ChecklistSchema,
4
+ effectiveStatus,
5
+ readJson,
6
+ readRun,
7
+ readRuns,
8
+ tallyRun,
9
+ writeFileAtomic
10
+ } from "./chunk-F3R3QL7V.js";
11
+
12
+ // src/ui-server.ts
13
+ import { randomBytes, timingSafeEqual } from "crypto";
14
+ import { watch } from "fs";
15
+ import { readdir, readFile, realpath } from "fs/promises";
16
+ import { createServer } from "http";
17
+ import { basename, extname, join, resolve, sep } from "path";
18
+ var FIRST_PORT = 4747;
19
+ var PORT_TRIES = 20;
20
+ var TYPES = {
21
+ ".png": "image/png",
22
+ ".json": "application/json",
23
+ ".jsonl": "text/plain; charset=utf-8"
24
+ };
25
+ var PAGE_HEADERS = {
26
+ "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self'; frame-ancestors 'none'; form-action 'none'"
27
+ };
28
+ function send(res, status, body, type = "text/plain; charset=utf-8", headers = {}) {
29
+ res.writeHead(status, {
30
+ "Content-Type": type,
31
+ "Cache-Control": "no-store",
32
+ "X-Content-Type-Options": "nosniff",
33
+ "Referrer-Policy": "no-referrer",
34
+ ...headers
35
+ });
36
+ res.end(body);
37
+ }
38
+ var json = (res, data) => send(res, 200, JSON.stringify(data), "application/json");
39
+ function sameSecret(given, token) {
40
+ const a = Buffer.from(given);
41
+ const b = Buffer.from(token);
42
+ return a.length === b.length && timingSafeEqual(a, b);
43
+ }
44
+ function cookie(req, name) {
45
+ for (const part of (req.headers.cookie ?? "").split(";")) {
46
+ const [key, ...value] = part.trim().split("=");
47
+ if (key === name) return value.join("=");
48
+ }
49
+ return void 0;
50
+ }
51
+ async function confined(root, rel) {
52
+ try {
53
+ const realRoot = await realpath(root);
54
+ const real = await realpath(resolve(root, rel));
55
+ return real.startsWith(realRoot + sep) ? real : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+ async function definitions(root, folder) {
61
+ const schema = folder === "checklists" ? ChecklistSchema : ActorSchema;
62
+ const names = await readdir(join(root, folder)).catch(() => []);
63
+ return Promise.all(
64
+ names.filter((n) => n.endsWith(".json")).sort().map(async (name) => {
65
+ const slug = basename(name, ".json");
66
+ try {
67
+ return { slug, data: await readJson(join(root, folder, name), schema, name) };
68
+ } catch (err) {
69
+ return { slug, error: err.message };
70
+ }
71
+ })
72
+ );
73
+ }
74
+ async function actorNames(root) {
75
+ const actors = await definitions(root, "actors");
76
+ return new Map(actors.flatMap((a) => "data" in a && a.data ? [[a.slug, a.data.name]] : []));
77
+ }
78
+ function runSummary(root, run, names) {
79
+ const { checklist_snapshot, results: _results, ...rest } = run;
80
+ return {
81
+ ...rest,
82
+ status: effectiveStatus(root, run),
83
+ actor_name: names.get(run.actor) ?? null,
84
+ checklist_name: typeof checklist_snapshot.name === "string" ? checklist_snapshot.name : null,
85
+ tally: tallyRun(run)
86
+ };
87
+ }
88
+ async function readBody(req) {
89
+ let body = "";
90
+ for await (const chunk of req) {
91
+ body += chunk;
92
+ if (body.length > 1e4) throw new Error("Body too large");
93
+ }
94
+ return body;
95
+ }
96
+ async function startUi(options) {
97
+ const extension = options.extension;
98
+ const root = resolve(options.root);
99
+ const token = randomBytes(24).toString("base64url");
100
+ const clients = /* @__PURE__ */ new Set();
101
+ let port = 0;
102
+ let ensureWatch = () => {
103
+ };
104
+ async function handle(req, res) {
105
+ const host = req.headers.host ?? "";
106
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
107
+ return send(res, 403, "Forbidden");
108
+ }
109
+ const url = new URL(req.url ?? "/", `http://${host}`);
110
+ if (url.pathname === "/__stuntdouble") return json(res, { stuntdouble: true });
111
+ if (url.pathname === "/favicon.ico") return send(res, 204, "");
112
+ if (extension && url.pathname === "/oauth/callback" && req.method === "GET") {
113
+ return extension.oauthCallback(req, res, url);
114
+ }
115
+ const cookieName = `stuntdouble_${port}`;
116
+ const key = url.searchParams.get("t");
117
+ if (key !== null) {
118
+ if (!sameSecret(key, token)) return send(res, 403, "This link is out of date.");
119
+ url.searchParams.delete("t");
120
+ return send(res, 302, "", void 0, {
121
+ Location: `${url.pathname}${url.search}`,
122
+ "Set-Cookie": `${cookieName}=${token}; HttpOnly; SameSite=Strict; Path=/`
123
+ });
124
+ }
125
+ const signedIn = cookie(req, cookieName);
126
+ if (!signedIn || !sameSecret(signedIn, token)) {
127
+ return send(
128
+ res,
129
+ 403,
130
+ "Open the viewer from the link Stunt Double printed: it carries the key."
131
+ );
132
+ }
133
+ const path = url.pathname;
134
+ if (req.method === "POST" && path === "/api/stop") {
135
+ const origin = req.headers.origin;
136
+ if (origin && origin !== `http://${host}`) return send(res, 403, "Forbidden");
137
+ const { run } = JSON.parse(await readBody(req));
138
+ const entry = typeof run === "string" ? await readRun(root, run) : null;
139
+ if (!entry || !("run" in entry) || effectiveStatus(root, entry.run) !== "running") {
140
+ return send(res, 409, "That run is not going.");
141
+ }
142
+ await writeFileAtomic(join(root, ".live", "stop"), entry.id);
143
+ return send(res, 204, "");
144
+ }
145
+ if (extension && await extension.handle(req, res, url)) return;
146
+ if (req.method !== "GET") return send(res, 405, "Method not allowed");
147
+ if (path === "/" || path === "/definitions" || /^\/runs\/[^/]+$/.test(path) || extension?.pages.test(path)) {
148
+ let page = await readFile(new URL("./ui.html", import.meta.url), "utf8");
149
+ if (extension) {
150
+ page = page.replace("</body>", `<script src="${extension.script}"></script>
151
+ </body>`);
152
+ }
153
+ return send(res, 200, page, "text/html; charset=utf-8", PAGE_HEADERS);
154
+ }
155
+ if (path === "/api/runs") {
156
+ const [runs, names] = await Promise.all([readRuns(root), actorNames(root)]);
157
+ return json(
158
+ res,
159
+ runs.map((e) => "run" in e ? runSummary(root, e.run, names) : e)
160
+ );
161
+ }
162
+ const runMatch = path.match(/^\/api\/runs\/([^/]+)$/);
163
+ if (runMatch) {
164
+ const id = decodeURIComponent(runMatch[1]);
165
+ const entry = await readRun(root, id);
166
+ if (!entry) return send(res, 404, "No such run");
167
+ const activity = await readFile(join(root, "runs", entry.id, "activity.jsonl"), "utf8").then(
168
+ (text) => text.split("\n").filter(Boolean).map((line) => JSON.parse(line))
169
+ ).catch(() => []);
170
+ const stop = await readFile(join(root, ".live", "stop"), "utf8").catch(() => "");
171
+ const names = "run" in entry ? await actorNames(root) : /* @__PURE__ */ new Map();
172
+ return json(res, {
173
+ ...entry,
174
+ ..."run" in entry ? {
175
+ run: {
176
+ ...entry.run,
177
+ status: effectiveStatus(root, entry.run),
178
+ actor_name: names.get(entry.run.actor) ?? null
179
+ }
180
+ } : {},
181
+ activity,
182
+ stop_requested: stop.trim() === entry.id
183
+ });
184
+ }
185
+ if (path === "/api/definitions") {
186
+ return json(res, {
187
+ checklists: await definitions(root, "checklists"),
188
+ actors: await definitions(root, "actors")
189
+ });
190
+ }
191
+ if (path === "/api/events") {
192
+ res.writeHead(200, {
193
+ "Content-Type": "text/event-stream",
194
+ "Cache-Control": "no-store",
195
+ "X-Content-Type-Options": "nosniff"
196
+ });
197
+ res.write(": connected\n\n");
198
+ ensureWatch();
199
+ clients.add(res);
200
+ req.on("close", () => clients.delete(res));
201
+ return;
202
+ }
203
+ if (path.startsWith("/files/")) {
204
+ const rel = decodeURIComponent(path.slice("/files/".length));
205
+ const file = await confined(root, rel);
206
+ if (!file) return send(res, 403, "Outside .stuntdouble/");
207
+ const type = TYPES[extname(file)];
208
+ if (!type) return send(res, 404, "Not found");
209
+ const cache = rel.includes("/shots/") ? "private, max-age=86400" : "no-store";
210
+ return send(res, 200, await readFile(file), type, { "Cache-Control": cache });
211
+ }
212
+ return send(res, 404, "Not found");
213
+ }
214
+ const server = createServer((req, res) => {
215
+ handle(req, res).catch(() => {
216
+ if (res.headersSent) res.end();
217
+ else send(res, 500, "Something went wrong reading .stuntdouble/");
218
+ });
219
+ });
220
+ const first = options.port ?? FIRST_PORT;
221
+ for (let candidate = first; ; candidate++) {
222
+ try {
223
+ await new Promise((ok, fail) => {
224
+ server.once("error", fail);
225
+ server.listen(candidate, "127.0.0.1", () => {
226
+ server.off("error", fail);
227
+ ok();
228
+ });
229
+ });
230
+ break;
231
+ } catch (err) {
232
+ const busy = err.code === "EADDRINUSE";
233
+ if (!busy || candidate >= first + PORT_TRIES) throw err;
234
+ }
235
+ }
236
+ port = server.address().port;
237
+ let watcher = null;
238
+ let pending = /* @__PURE__ */ new Set();
239
+ let timer;
240
+ ensureWatch = () => {
241
+ if (watcher) return;
242
+ try {
243
+ watcher = watch(root, { recursive: true }, (_event, name) => {
244
+ if (!name || name.endsWith(".tmp")) return;
245
+ pending.add(String(name).split(sep).join("/"));
246
+ timer ??= setTimeout(() => {
247
+ const data = `data: ${JSON.stringify({ paths: [...pending] })}
248
+
249
+ `;
250
+ for (const client of clients) client.write(data);
251
+ pending = /* @__PURE__ */ new Set();
252
+ timer = void 0;
253
+ }, 150);
254
+ });
255
+ } catch {
256
+ }
257
+ };
258
+ ensureWatch();
259
+ return {
260
+ port,
261
+ token,
262
+ link: (path = "/") => `http://127.0.0.1:${port}${path}?t=${token}`,
263
+ close: async () => {
264
+ watcher?.close();
265
+ clearTimeout(timer);
266
+ for (const client of clients) client.end();
267
+ server.closeAllConnections();
268
+ await new Promise((ok) => server.close(() => ok()));
269
+ }
270
+ };
271
+ }
272
+
273
+ export {
274
+ FIRST_PORT,
275
+ send,
276
+ startUi
277
+ };
278
+ //# sourceMappingURL=chunk-BFI6P5DO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/ui-server.ts"],"sourcesContent":["// The local viewer: one HTML page over the files in `.stuntdouble/`, with live\n// updates while a run is going and a Stop button.\n//\n// It listens on loopback only, and still trusts nothing that can reach\n// loopback: a page in the person's browser can, and so can a DNS rebinding\n// attack. So every request must name this server in its Host header, and\n// everything but the identity probe needs the per-start key, which the printed\n// link carries once and an HttpOnly, SameSite=Strict cookie carries after\n// that. No CORS headers are ever sent, and files are served from inside\n// `.stuntdouble/` only.\n\nimport { randomBytes, timingSafeEqual } from 'node:crypto';\nimport { watch, type FSWatcher } from 'node:fs';\nimport { readdir, readFile, realpath } from 'node:fs/promises';\nimport { createServer, type IncomingMessage, type ServerResponse } from 'node:http';\nimport type { AddressInfo } from 'node:net';\nimport { basename, extname, join, resolve, sep } from 'node:path';\n\nimport {\n ActorSchema,\n ChecklistSchema,\n effectiveStatus,\n readJson,\n readRun,\n readRuns,\n tallyRun,\n writeFileAtomic,\n type Run,\n} from './files.ts';\n\nexport const FIRST_PORT = 4747;\nconst PORT_TRIES = 20;\n\n/**\n * What the Studio adds to the viewer (studio.ts). The viewer alone has none of\n * it: inside Claude Code it reads files and offers Stop, nothing more.\n */\nexport type UiExtension = {\n /** Paths beyond the viewer's own that serve the page. */\n pages: RegExp;\n /** A script the page loads after its own, served by `handle`. */\n script: string;\n /** A signed-in request the viewer does not answer. Resolves false for a 404. */\n handle(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean>;\n /**\n * `/oauth/callback`, which a cross-site redirect reaches without the\n * SameSite=Strict cookie: it proves itself with the OAuth state instead.\n */\n oauthCallback(req: IncomingMessage, res: ServerResponse, url: URL): Promise<void>;\n};\n\nexport type UiServer = {\n port: number;\n token: string;\n /** An address that signs the browser in, opening at `path`. */\n link(path?: string): string;\n close(): Promise<void>;\n};\n\nconst TYPES: Record<string, string> = {\n '.png': 'image/png',\n '.json': 'application/json',\n '.jsonl': 'text/plain; charset=utf-8',\n};\n\nconst PAGE_HEADERS = {\n 'Content-Security-Policy':\n \"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self'; frame-ancestors 'none'; form-action 'none'\",\n};\n\nexport function send(\n res: ServerResponse,\n status: number,\n body: string | Uint8Array,\n type = 'text/plain; charset=utf-8',\n headers: Record<string, string> = {}\n): void {\n res.writeHead(status, {\n 'Content-Type': type,\n 'Cache-Control': 'no-store',\n 'X-Content-Type-Options': 'nosniff',\n 'Referrer-Policy': 'no-referrer',\n ...headers,\n });\n res.end(body);\n}\n\nconst json = (res: ServerResponse, data: unknown) =>\n send(res, 200, JSON.stringify(data), 'application/json');\n\nfunction sameSecret(given: string, token: string): boolean {\n const a = Buffer.from(given);\n const b = Buffer.from(token);\n return a.length === b.length && timingSafeEqual(a, b);\n}\n\nfunction cookie(req: IncomingMessage, name: string): string | undefined {\n for (const part of (req.headers.cookie ?? '').split(';')) {\n const [key, ...value] = part.trim().split('=');\n if (key === name) return value.join('=');\n }\n return undefined;\n}\n\n/** The real path of `rel` under `root`, or null when it would leave it (`..`, a symlink). */\nasync function confined(root: string, rel: string): Promise<string | null> {\n try {\n const realRoot = await realpath(root);\n const real = await realpath(resolve(root, rel));\n return real.startsWith(realRoot + sep) ? real : null;\n } catch {\n return null;\n }\n}\n\nasync function definitions(root: string, folder: 'checklists' | 'actors') {\n const schema = folder === 'checklists' ? ChecklistSchema : ActorSchema;\n const names = await readdir(join(root, folder)).catch(() => [] as string[]);\n return Promise.all(\n names\n .filter((n) => n.endsWith('.json'))\n .sort()\n .map(async (name) => {\n const slug = basename(name, '.json');\n try {\n return { slug, data: await readJson(join(root, folder, name), schema, name) };\n } catch (err) {\n return { slug, error: (err as Error).message };\n }\n })\n );\n}\n\n/** Actor slugs to the names people gave them, for labels. */\nasync function actorNames(root: string): Promise<Map<string, string>> {\n const actors = await definitions(root, 'actors');\n return new Map(actors.flatMap((a) => ('data' in a && a.data ? [[a.slug, a.data.name]] : [])));\n}\n\n/** What the runs list shows of a run: not its snapshot or its results. */\nfunction runSummary(root: string, run: Run, names: Map<string, string>) {\n const { checklist_snapshot, results: _results, ...rest } = run;\n return {\n ...rest,\n status: effectiveStatus(root, run),\n actor_name: names.get(run.actor) ?? null,\n checklist_name: typeof checklist_snapshot.name === 'string' ? checklist_snapshot.name : null,\n tally: tallyRun(run),\n };\n}\n\nasync function readBody(req: IncomingMessage): Promise<string> {\n let body = '';\n for await (const chunk of req) {\n body += chunk;\n if (body.length > 10_000) throw new Error('Body too large');\n }\n return body;\n}\n\nexport async function startUi(options: {\n root: string;\n port?: number;\n extension?: UiExtension;\n}): Promise<UiServer> {\n const extension = options.extension;\n const root = resolve(options.root);\n const token = randomBytes(24).toString('base64url');\n const clients = new Set<ServerResponse>();\n let port = 0;\n let ensureWatch = () => {};\n\n async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {\n const host = req.headers.host ?? '';\n if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {\n return send(res, 403, 'Forbidden');\n }\n const url = new URL(req.url ?? '/', `http://${host}`);\n if (url.pathname === '/__stuntdouble') return json(res, { stuntdouble: true });\n // Browsers ask for it unprompted; there is none, and that is not an error.\n if (url.pathname === '/favicon.ico') return send(res, 204, '');\n if (extension && url.pathname === '/oauth/callback' && req.method === 'GET') {\n return extension.oauthCallback(req, res, url);\n }\n\n // Cookies are per host, not per port, so each server names its own.\n const cookieName = `stuntdouble_${port}`;\n const key = url.searchParams.get('t');\n if (key !== null) {\n if (!sameSecret(key, token)) return send(res, 403, 'This link is out of date.');\n url.searchParams.delete('t');\n return send(res, 302, '', undefined, {\n Location: `${url.pathname}${url.search}`,\n 'Set-Cookie': `${cookieName}=${token}; HttpOnly; SameSite=Strict; Path=/`,\n });\n }\n const signedIn = cookie(req, cookieName);\n if (!signedIn || !sameSecret(signedIn, token)) {\n return send(\n res,\n 403,\n 'Open the viewer from the link Stunt Double printed: it carries the key.'\n );\n }\n\n const path = url.pathname;\n if (req.method === 'POST' && path === '/api/stop') {\n const origin = req.headers.origin;\n if (origin && origin !== `http://${host}`) return send(res, 403, 'Forbidden');\n const { run } = JSON.parse(await readBody(req)) as { run?: unknown };\n const entry = typeof run === 'string' ? await readRun(root, run) : null;\n if (!entry || !('run' in entry) || effectiveStatus(root, entry.run) !== 'running') {\n return send(res, 409, 'That run is not going.');\n }\n await writeFileAtomic(join(root, '.live', 'stop'), entry.id);\n return send(res, 204, '');\n }\n if (extension && (await extension.handle(req, res, url))) return;\n if (req.method !== 'GET') return send(res, 405, 'Method not allowed');\n\n if (\n path === '/' ||\n path === '/definitions' ||\n /^\\/runs\\/[^/]+$/.test(path) ||\n extension?.pages.test(path)\n ) {\n let page = await readFile(new URL('./ui.html', import.meta.url), 'utf8');\n if (extension) {\n page = page.replace('</body>', `<script src=\"${extension.script}\"></script>\\n </body>`);\n }\n return send(res, 200, page, 'text/html; charset=utf-8', PAGE_HEADERS);\n }\n if (path === '/api/runs') {\n const [runs, names] = await Promise.all([readRuns(root), actorNames(root)]);\n return json(\n res,\n runs.map((e) => ('run' in e ? runSummary(root, e.run, names) : e))\n );\n }\n const runMatch = path.match(/^\\/api\\/runs\\/([^/]+)$/);\n if (runMatch) {\n const id = decodeURIComponent(runMatch[1]!);\n const entry = await readRun(root, id);\n if (!entry) return send(res, 404, 'No such run');\n const activity = await readFile(join(root, 'runs', entry.id, 'activity.jsonl'), 'utf8')\n .then((text) =>\n text\n .split('\\n')\n .filter(Boolean)\n .map((line) => JSON.parse(line))\n )\n .catch(() => []);\n const stop = await readFile(join(root, '.live', 'stop'), 'utf8').catch(() => '');\n const names = 'run' in entry ? await actorNames(root) : new Map<string, string>();\n return json(res, {\n ...entry,\n ...('run' in entry\n ? {\n run: {\n ...entry.run,\n status: effectiveStatus(root, entry.run),\n actor_name: names.get(entry.run.actor) ?? null,\n },\n }\n : {}),\n activity,\n stop_requested: stop.trim() === entry.id,\n });\n }\n if (path === '/api/definitions') {\n return json(res, {\n checklists: await definitions(root, 'checklists'),\n actors: await definitions(root, 'actors'),\n });\n }\n if (path === '/api/events') {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-store',\n 'X-Content-Type-Options': 'nosniff',\n });\n res.write(': connected\\n\\n');\n ensureWatch();\n clients.add(res);\n req.on('close', () => clients.delete(res));\n return;\n }\n if (path.startsWith('/files/')) {\n const rel = decodeURIComponent(path.slice('/files/'.length));\n const file = await confined(root, rel);\n if (!file) return send(res, 403, 'Outside .stuntdouble/');\n const type = TYPES[extname(file)];\n if (!type) return send(res, 404, 'Not found');\n // A verdict's screenshot never changes once written; the live frame does.\n const cache = rel.includes('/shots/') ? 'private, max-age=86400' : 'no-store';\n return send(res, 200, await readFile(file), type, { 'Cache-Control': cache });\n }\n return send(res, 404, 'Not found');\n }\n\n const server = createServer((req, res) => {\n handle(req, res).catch(() => {\n if (res.headersSent) res.end();\n else send(res, 500, 'Something went wrong reading .stuntdouble/');\n });\n });\n\n const first = options.port ?? FIRST_PORT;\n for (let candidate = first; ; candidate++) {\n try {\n await new Promise<void>((ok, fail) => {\n server.once('error', fail);\n server.listen(candidate, '127.0.0.1', () => {\n server.off('error', fail);\n ok();\n });\n });\n break;\n } catch (err) {\n const busy = (err as NodeJS.ErrnoException).code === 'EADDRINUSE';\n if (!busy || candidate >= first + PORT_TRIES) throw err;\n }\n }\n port = (server.address() as AddressInfo).port;\n\n // Every change under .stuntdouble/ is announced, and the page refetches what\n // it shows. This works the same whether the run is in this process or not.\n let watcher: FSWatcher | null = null;\n let pending = new Set<string>();\n let timer: ReturnType<typeof setTimeout> | undefined;\n // Tried again on each new page: the Studio creates .stuntdouble/ after starting.\n ensureWatch = () => {\n if (watcher) return;\n try {\n watcher = watch(root, { recursive: true }, (_event, name) => {\n if (!name || name.endsWith('.tmp')) return;\n pending.add(String(name).split(sep).join('/'));\n timer ??= setTimeout(() => {\n const data = `data: ${JSON.stringify({ paths: [...pending] })}\\n\\n`;\n for (const client of clients) client.write(data);\n pending = new Set();\n timer = undefined;\n }, 150);\n });\n } catch {\n // No .stuntdouble/ yet: the viewer shows the empty state without live updates.\n }\n };\n ensureWatch();\n\n return {\n port,\n token,\n link: (path = '/') => `http://127.0.0.1:${port}${path}?t=${token}`,\n close: async () => {\n watcher?.close();\n clearTimeout(timer);\n for (const client of clients) client.end();\n server.closeAllConnections();\n await new Promise<void>((ok) => server.close(() => ok()));\n },\n };\n}\n"],"mappings":"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","names":[]}