stuntdouble 0.1.1 → 0.1.3

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.
@@ -1,24 +1,25 @@
1
- import {
2
- startUi
3
- } from "./chunk-VWTYG7G4.js";
4
1
  import {
5
2
  ActorSchema,
6
3
  ChecklistSchema,
7
4
  ConfigSchema,
8
5
  appendJsonl,
9
6
  describeRunScore,
10
- isAlive,
7
+ effectiveStatus,
8
+ isLive,
11
9
  newChecklistScoring,
12
10
  projectDir,
13
11
  readJson,
14
12
  readRuns,
15
13
  resolveHaltedChecks,
16
14
  resolveSkippedChecks,
15
+ runningMarker,
17
16
  scoreChecklistRun,
18
17
  sdDir,
18
+ startUi,
19
+ tallyRun,
19
20
  writeFileAtomic,
20
21
  writeJsonAtomic
21
- } from "./chunk-JAB46QV7.js";
22
+ } from "./chunk-7IC2G3Z3.js";
22
23
 
23
24
  // src/mcp.ts
24
25
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -1420,17 +1421,19 @@ ${where}` : where,
1420
1421
  };
1421
1422
 
1422
1423
  // src/mcp.ts
1424
+ import { dirname } from "path";
1423
1425
  import { z } from "zod";
1424
1426
 
1425
1427
  // src/run.ts
1426
- import { mkdir, mkdtemp, readFile, rm, stat } from "fs/promises";
1428
+ import { createHash } from "crypto";
1429
+ import { mkdir, mkdtemp, readdir, readFile, rm, stat } from "fs/promises";
1427
1430
  import { homedir, tmpdir } from "os";
1428
1431
  import { join } from "path";
1429
1432
 
1430
1433
  // package.json
1431
1434
  var package_default = {
1432
1435
  name: "stuntdouble",
1433
- version: "0.1.1",
1436
+ version: "0.1.3",
1434
1437
  description: "Stunt Double Local: run Stunt Double checklists in a local Chrome from your own coding agent session, with the runs kept as files and a local viewer",
1435
1438
  keywords: [
1436
1439
  "stunt-double",
@@ -1503,7 +1506,7 @@ function checkLine(check) {
1503
1506
  function buildBrief(input) {
1504
1507
  const { actor, checklist, url, checks, resumed } = input;
1505
1508
  const persona = [
1506
- `You are ${actor.name}${actor.description ? `: ${actor.description}` : ""}.`,
1509
+ `You are ${actor.name}${actor.description ? `: ${actor.description.replace(/[.\s]+$/, "")}` : ""}.`,
1507
1510
  actor.system_prompt ?? ""
1508
1511
  ].filter(Boolean).join("\n");
1509
1512
  const knowledge = actor.knowledge.length ? [
@@ -1522,6 +1525,7 @@ ${checks.map(checkLine).join("\n")}`,
1522
1525
  "How to work:",
1523
1526
  "- Stay in persona: act and judge as this person would, not as a tester.",
1524
1527
  "- Read the page with read_page and find. Ask for a screenshot only when how something looks is the question.",
1528
+ '- get_page_text and read_page "all" return up to 20,000 characters: keep them for long text such as a policy.',
1525
1529
  "- Judge each check with evaluate_check as soon as you have observed it, while the evidence is on screen.",
1526
1530
  "- A passed verdict needs a locator: text visible on the page, or a CSS selector, that shows the evidence. A failure may describe something missing.",
1527
1531
  "- Cite only what you saw in this run. Never pass a check on what a page probably does.",
@@ -1698,6 +1702,12 @@ var PlaywrightDriver = class _PlaywrightDriver {
1698
1702
  channel: "chrome"
1699
1703
  });
1700
1704
  } catch (chromeError) {
1705
+ if (/ProcessSingleton|SingletonLock|already in use/i.test(chromeError.message)) {
1706
+ throw new Error(
1707
+ "Another run is already using this actor's kept login (persist_login). Finish that run first, or try again when it ends.",
1708
+ { cause: chromeError }
1709
+ );
1710
+ }
1701
1711
  try {
1702
1712
  context = await chromium.launchPersistentContext(options.profileDir, settings);
1703
1713
  } catch {
@@ -1746,6 +1756,7 @@ var PlaywrightDriver = class _PlaywrightDriver {
1746
1756
  var MAX_LOCATOR_TEXT = 120;
1747
1757
  var MAX_LOCATOR_SELECTOR = 400;
1748
1758
  var SETTLE_MS2 = 350;
1759
+ var FIND_TIMEOUT_MS = 5e3;
1749
1760
  function cleanLocator(locator) {
1750
1761
  const clean = (value) => value?.replace(/\s+/g, " ").trim() || null;
1751
1762
  const selector = clean(locator?.selector);
@@ -1754,9 +1765,13 @@ function cleanLocator(locator) {
1754
1765
  text: clean(locator?.text)?.slice(0, MAX_LOCATOR_TEXT) ?? null
1755
1766
  };
1756
1767
  }
1757
- async function focusEvidence(page, query) {
1768
+ async function focusEvidence(page, query, timeoutMs = FIND_TIMEOUT_MS) {
1758
1769
  if (!query.selector && !query.text) return null;
1759
- const via = await page.evaluate(findAndScroll, query).catch(() => null);
1770
+ let timer;
1771
+ const via = await Promise.race([
1772
+ page.evaluate(findAndScroll, query),
1773
+ new Promise((ok) => timer = setTimeout(() => ok(null), timeoutMs))
1774
+ ]).catch(() => null).finally(() => clearTimeout(timer));
1760
1775
  if (via) await page.waitForTimeout(SETTLE_MS2).catch(() => {
1761
1776
  });
1762
1777
  return via;
@@ -1821,6 +1836,7 @@ var PACKAGE = `${package_default.name}@${package_default.version}`;
1821
1836
  var STOPPED = "This run was stopped from the viewer. Do not use the browser again: call finish_run now with a summary of what you found.";
1822
1837
  var NOT_JUDGED = "Not judged before the run finished.";
1823
1838
  var PRIVATE_REFUSED = "Local and private addresses other than the app under test are not reachable.";
1839
+ var SETUP = "Run /stuntdouble setup to create one.";
1824
1840
  var LOG_TEXT_CHARS = 500;
1825
1841
  var PRESETS = {
1826
1842
  desktop: { width: 1280, height: 720, touch: false },
@@ -1837,11 +1853,21 @@ function stamp(date = /* @__PURE__ */ new Date()) {
1837
1853
  return `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}-${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`;
1838
1854
  }
1839
1855
  var now = () => (/* @__PURE__ */ new Date()).toISOString();
1856
+ function ago(iso) {
1857
+ if (!iso) return "not started";
1858
+ const minutes = Math.round((Date.now() - Date.parse(iso)) / 6e4);
1859
+ if (minutes < 1) return "just now";
1860
+ if (minutes < 60) return `${minutes} min ago`;
1861
+ const hours = Math.round(minutes / 60);
1862
+ return hours < 48 ? `${hours} h ago` : `${Math.round(hours / 24)} days ago`;
1863
+ }
1840
1864
  var LocalSession = class {
1841
1865
  root;
1842
1866
  launch;
1843
1867
  active = null;
1844
1868
  ui = null;
1869
+ /** The run this session last finished, so a second finish_run is answered, not refused. */
1870
+ finished = null;
1845
1871
  constructor(options) {
1846
1872
  this.root = sdDir(options.project);
1847
1873
  this.launch = options.launch ?? PlaywrightDriver.launch;
@@ -1856,6 +1882,28 @@ var LocalSession = class {
1856
1882
  });
1857
1883
  return this.ui.link(path);
1858
1884
  }
1885
+ /** The viewer's port while it is running in this process. */
1886
+ get viewerPort() {
1887
+ return this.ui?.port ?? null;
1888
+ }
1889
+ /** open_ui: the link, and a line on each of the latest runs. */
1890
+ async overview() {
1891
+ const link = await this.openUi();
1892
+ const runs = (await readRuns(this.root)).slice(0, 5);
1893
+ if (runs.length === 0) return `${link}
1894
+
1895
+ No runs yet.`;
1896
+ const lines = runs.map((e) => {
1897
+ if (!("run" in e)) return `- ${e.id}: unreadable (${e.error.split("\n")[0]})`;
1898
+ const { run } = e;
1899
+ const status = effectiveStatus(this.root, run);
1900
+ const t = tallyRun(run);
1901
+ const outcome = run.score ? `${status}, ${run.score.outcome}` : status;
1902
+ return `- ${run.checklist}: ${outcome}, ${t.passed}/${t.total} checks passed, started ${ago(run.started_at)} (${run.id})`;
1903
+ });
1904
+ return [link, `Latest runs, newest first:
1905
+ ${lines.join("\n")}`].join("\n\n");
1906
+ }
1859
1907
  async startRun(input) {
1860
1908
  if (this.active) return this.resumeActive(input);
1861
1909
  if (!/^[a-z0-9][a-z0-9-]*$/.test(input.checklist)) {
@@ -1865,12 +1913,20 @@ var LocalSession = class {
1865
1913
  join(this.root, "stuntdouble.json"),
1866
1914
  ConfigSchema,
1867
1915
  ".stuntdouble/stuntdouble.json"
1868
- );
1916
+ ).catch((err) => {
1917
+ throw /file not found$/.test(err.message) ? new Error(`${err.message}. ${SETUP}`) : err;
1918
+ });
1869
1919
  let checklist = await readJson(
1870
1920
  join(this.root, "checklists", `${input.checklist}.json`),
1871
1921
  ChecklistSchema,
1872
1922
  `.stuntdouble/checklists/${input.checklist}.json`
1873
- );
1923
+ ).catch(async (err) => {
1924
+ if (!/file not found$/.test(err.message)) throw err;
1925
+ const slugs = (await readdir(join(this.root, "checklists")).catch(() => [])).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -".json".length));
1926
+ throw new Error(
1927
+ `${err.message}. ${slugs.length ? `The checklists here are: ${slugs.join(", ")}.` : SETUP}`
1928
+ );
1929
+ });
1874
1930
  const actor = await readJson(
1875
1931
  join(this.root, "actors", `${checklist.actor}.json`),
1876
1932
  ActorSchema,
@@ -1932,7 +1988,8 @@ var LocalSession = class {
1932
1988
  checkNavigation: refuseNavigation
1933
1989
  };
1934
1990
  const tempProfile = actor.persist_login ? null : await mkdtemp(join(tmpdir(), "stuntdouble-profile-"));
1935
- const profileDir = tempProfile ?? join(homedir(), ".stuntdouble", "profiles", checklist.actor);
1991
+ const project = createHash("sha256").update(this.root).digest("hex").slice(0, 12);
1992
+ const profileDir = tempProfile ?? join(homedir(), ".stuntdouble", "profiles", project, checklist.actor);
1936
1993
  await mkdir(profileDir, { recursive: true });
1937
1994
  let driver;
1938
1995
  try {
@@ -1977,13 +2034,16 @@ var LocalSession = class {
1977
2034
  everyScreenshot: config.model_screenshots === "every_action",
1978
2035
  stopped: false
1979
2036
  };
2037
+ this.finished = null;
1980
2038
  await this.save();
2039
+ await this.markRunning();
1981
2040
  await this.log("start_run", input, input.resume ? "Resumed" : "Started");
1982
2041
  const opened = await this.browserTool("navigate", { url: run.url });
1983
2042
  const pending = this.pending();
1984
2043
  const viewer = await this.openUi(`/runs/${run.id}`);
1985
2044
  return [
1986
2045
  `Run ${run.id} ${input.resume ? "resumed" : "started"}. Watch it or stop it at ${viewer}`,
2046
+ unweightedNote(checklist),
1987
2047
  buildBrief({
1988
2048
  actor,
1989
2049
  checklist,
@@ -1991,10 +2051,12 @@ var LocalSession = class {
1991
2051
  checks: pending,
1992
2052
  resumed: Boolean(input.resume)
1993
2053
  }),
2054
+ // Not the navigate result: it carries the page title, and this reply is
2055
+ // read by the main session, which never reads page content.
1994
2056
  opened.isError ? `The start page did not load: ${opened.text}
1995
- Try navigate again, or judge the checks you cannot reach as "blocked".` : opened.text,
2057
+ Try navigate again, or judge the checks you cannot reach as "blocked".` : `The browser is open at ${run.url}: read the page with read_page before acting.`,
1996
2058
  this.nextStep()
1997
- ].join("\n\n");
2059
+ ].filter(Boolean).join("\n\n");
1998
2060
  }
1999
2061
  /** One of the browser toolset's direct tools, on the active run's browser. */
2000
2062
  async browserTool(name, input) {
@@ -2002,7 +2064,7 @@ Try navigate again, or judge the checks you cannot reach as "blocked".` : opened
2002
2064
  if (await this.stopRequested(active)) return { text: STOPPED, isError: true };
2003
2065
  const page = await active.driver.context.activePage();
2004
2066
  const host = page ? URL.parse(await page.url())?.hostname ?? "" : "";
2005
- const tools = active.guarded && host && !active.trustedHosts.some((t) => hostMatchesDomain(host, t)) ? active.guarded : active.trusted;
2067
+ const tools = active.guarded && !isTrustedHost(host, active.trustedHosts) ? active.guarded : active.trusted;
2006
2068
  const result = await tools.run(name, input);
2007
2069
  const refusal = active.driver.takeRefusal();
2008
2070
  if (refusal) {
@@ -2017,6 +2079,7 @@ Try navigate again, or judge the checks you cannot reach as "blocked".` : opened
2017
2079
  return { text: result.text, ...image2 ? { image: image2 } : {}, isError: Boolean(result.error) };
2018
2080
  }
2019
2081
  async evaluateCheck(input) {
2082
+ if (!this.active && this.finished) return this.alreadyFinished();
2020
2083
  const active = this.requireActive();
2021
2084
  if (await this.stopRequested(active)) throw new Error(STOPPED);
2022
2085
  const { run, checklist } = active;
@@ -2095,6 +2158,7 @@ Try navigate again, or judge the checks you cannot reach as "blocked".` : opened
2095
2158
  return [recorded, ...notes, this.nextStep()].join("\n");
2096
2159
  }
2097
2160
  async finishRun(input) {
2161
+ if (!this.active && this.finished) return this.alreadyFinished();
2098
2162
  const active = this.requireActive();
2099
2163
  const stopped = await this.stopRequested(active);
2100
2164
  const { run, checklist } = active;
@@ -2131,8 +2195,11 @@ Try navigate again, or judge the checks you cannot reach as "blocked".` : opened
2131
2195
  passedCount: run.results.filter((r) => r.status === "passed").length,
2132
2196
  totalCount: checklist.checks.length
2133
2197
  });
2198
+ const t = tallyRun(run);
2199
+ const tally = score.weighted ? ` ${t.passed}/${t.total} checks passed.` : "";
2200
+ this.finished = { id: run.id, report: viewer };
2134
2201
  return [
2135
- `Run ${run.id} ${run.status}: ${score.outcome}. ${counts}. Self-judged: the actor recorded its own verdicts.`,
2202
+ `Run ${run.id} ${run.status}: ${score.outcome}. ${counts}.${tally} ${score.weighted ? "" : `${unweightedNote(checklist, true)} `}Self-judged: the actor recorded its own verdicts.`,
2136
2203
  failed.length ? `Failed:
2137
2204
  ${failed.map(line).join("\n")}` : "",
2138
2205
  blocked.length ? `Not judged:
@@ -2156,6 +2223,7 @@ ${blocked.map(line).join("\n")}` : "",
2156
2223
  run.status = "running";
2157
2224
  await this.clearStop(run.id);
2158
2225
  await this.save();
2226
+ await this.markRunning();
2159
2227
  await this.log("start_run", input, "Resumed");
2160
2228
  const viewer = await this.openUi(`/runs/${run.id}`);
2161
2229
  return [
@@ -2182,6 +2250,10 @@ ${blocked.map(line).join("\n")}` : "",
2182
2250
  await this.ui?.close();
2183
2251
  this.ui = null;
2184
2252
  }
2253
+ alreadyFinished() {
2254
+ const { id, report } = this.finished;
2255
+ return `Run ${id} has already finished: its report is at ${report}. Start another with start_run.`;
2256
+ }
2185
2257
  requireActive() {
2186
2258
  if (!this.active) throw new Error("No run is going: call start_run first.");
2187
2259
  return this.active;
@@ -2224,7 +2296,7 @@ ${blocked.map(line).join("\n")}` : "",
2224
2296
  async markInterrupted() {
2225
2297
  for (const entry of await readRuns(this.root)) {
2226
2298
  if (!("run" in entry) || entry.run.status !== "running") continue;
2227
- if (entry.run.pid !== process.pid && isAlive(entry.run.pid)) continue;
2299
+ if (entry.run.pid !== process.pid && isLive(this.root, entry.run)) continue;
2228
2300
  await writeJsonAtomic(join(this.root, "runs", entry.id, "run.json"), {
2229
2301
  ...entry.run,
2230
2302
  status: "interrupted"
@@ -2239,6 +2311,11 @@ ${blocked.map(line).join("\n")}` : "",
2239
2311
  });
2240
2312
  if (active.tempProfile) await rm(active.tempProfile, { recursive: true, force: true });
2241
2313
  await this.clearStop(active.run.id);
2314
+ await rm(runningMarker(this.root, active.run.id), { force: true });
2315
+ }
2316
+ async markRunning() {
2317
+ const { run } = this.requireActive();
2318
+ await writeFileAtomic(runningMarker(this.root, run.id), String(run.pid));
2242
2319
  }
2243
2320
  async clearStop(id) {
2244
2321
  const stopFile = join(this.root, ".live", "stop");
@@ -2264,6 +2341,15 @@ ${blocked.map(line).join("\n")}` : "",
2264
2341
  });
2265
2342
  }
2266
2343
  };
2344
+ function isTrustedHost(host, trustedHosts) {
2345
+ return host !== "" && trustedHosts.some((t) => hostMatchesDomain(host, t));
2346
+ }
2347
+ function unweightedNote(checklist, atFinish = false) {
2348
+ if (checklist.checks.some((c) => c.type === "weighted")) return null;
2349
+ const threshold = checklist.pass_threshold == null ? "" : ` Its pass_threshold (${checklist.pass_threshold}) applies to weighted checks only.`;
2350
+ if (!atFinish && !threshold) return null;
2351
+ return `No score: this checklist has no weighted checks, so it passes only when every pass/fail check passes.${threshold}`;
2352
+ }
2267
2353
  function scoreLocalRun(checklist, results) {
2268
2354
  const scoring = newChecklistScoring({
2269
2355
  scoreType: checklist.score_type,
@@ -2368,9 +2454,24 @@ function createServer(session) {
2368
2454
  server.registerTool(
2369
2455
  "open_ui",
2370
2456
  {
2371
- description: "The local viewer for this project: every run with its verdicts, screenshots and activity, and the checklists and actors. Returns a link to open in a browser."
2457
+ description: "The local viewer for this project: every run with its verdicts, screenshots and activity, and the checklists and actors. Returns a link to open in a browser and a line on each of the latest runs."
2372
2458
  },
2373
- () => attempt(() => session.openUi())
2459
+ () => attempt(() => session.overview())
2460
+ );
2461
+ server.registerTool(
2462
+ "doctor",
2463
+ {
2464
+ description: "Check what local runs need: Node, Chrome, the viewer port, this project's stuntdouble.json and how runs are billed. Read only. Returns one line per check, each starting ok, FAIL, note or --.",
2465
+ annotations: { readOnlyHint: true }
2466
+ },
2467
+ () => attempt(async () => {
2468
+ const { doctor } = await import("./doctor-7PW2QD3M.js");
2469
+ const { lines } = await doctor({
2470
+ project: dirname(session.root),
2471
+ viewerPort: session.viewerPort
2472
+ });
2473
+ return lines.join("\n");
2474
+ })
2374
2475
  );
2375
2476
  return server;
2376
2477
  }
@@ -2388,4 +2489,4 @@ export {
2388
2489
  createServer,
2389
2490
  serveMcp
2390
2491
  };
2391
- //# sourceMappingURL=mcp-O2LGDSU7.js.map
2492
+ //# sourceMappingURL=mcp-MOXTYXZU.js.map