stuntdouble 0.1.2 → 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.
- package/README.md +7 -4
- package/dist/{chunk-JAB46QV7.js → chunk-7IC2G3Z3.js} +298 -9
- package/dist/chunk-7IC2G3Z3.js.map +1 -0
- package/dist/chunk-JXTUCMO2.js +77 -0
- package/dist/chunk-JXTUCMO2.js.map +1 -0
- package/dist/cli.js +18 -63
- package/dist/cli.js.map +1 -1
- package/dist/doctor-7PW2QD3M.js +12 -0
- package/dist/{mcp-CYPMT37O.js → mcp-MOXTYXZU.js} +122 -21
- package/dist/mcp-MOXTYXZU.js.map +1 -0
- package/dist/ui-server-ZUJ5WJ7Q.js +9 -0
- package/dist/ui-server-ZUJ5WJ7Q.js.map +1 -0
- package/dist/ui.html +112 -49
- package/package.json +1 -1
- package/dist/chunk-JAB46QV7.js.map +0 -1
- package/dist/chunk-VWTYG7G4.js +0 -238
- package/dist/chunk-VWTYG7G4.js.map +0 -1
- package/dist/mcp-CYPMT37O.js.map +0 -1
- package/dist/ui-server-AYIR7DTN.js +0 -10
- /package/dist/{ui-server-AYIR7DTN.js.map → doctor-7PW2QD3M.js.map} +0 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
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.
|
|
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 local
|
|
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.
|
|
6
6
|
|
|
7
7
|
Most people use it through the Stunt Double plugin for Claude Code, which starts it for you: `/stuntdouble setup`, then `/stuntdouble run <checklist>`.
|
|
8
8
|
|
|
@@ -11,7 +11,7 @@ Most people use it through the Stunt Double plugin for Claude Code, which starts
|
|
|
11
11
|
```sh
|
|
12
12
|
npx stuntdouble mcp # the MCP server over stdio; your agent starts this
|
|
13
13
|
npx stuntdouble view [dir] # the viewer over a project's .stuntdouble/, no agent needed
|
|
14
|
-
npx stuntdouble doctor # Node, Chrome, the viewer port, and how local runs are billed
|
|
14
|
+
npx stuntdouble doctor # Node, Chrome, the viewer port, the project, and how local runs are billed
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
To add it to another MCP client, run `npx -y stuntdouble mcp` with `STUNTDOUBLE_PROJECT_DIR` set to the project folder (the current directory otherwise).
|
|
@@ -26,7 +26,8 @@ Needs Node 22.18 or later and Google Chrome (or Playwright's Chromium, from `npx
|
|
|
26
26
|
| `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
27
|
| `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
28
|
| `finish_run` | Settles the checks left, scores the run and closes the browser. |
|
|
29
|
-
| `open_ui` | The viewer's link.
|
|
29
|
+
| `open_ui` | The viewer's link, and a line on each of the five latest runs: checklist, status, outcome, checks passed and when it started. |
|
|
30
|
+
| `doctor` | Read only. The same lines as `npx stuntdouble doctor`, one per check (`ok`, `FAIL`, `note` or `--`), so an agent can run it without a shell. While the viewer runs, it reports the viewer's port rather than probing 4747. |
|
|
30
31
|
|
|
31
32
|
## Files
|
|
32
33
|
|
|
@@ -46,9 +47,11 @@ Actors and checklists use the same fields, and the same limits, as the Stunt Dou
|
|
|
46
47
|
|
|
47
48
|
Local results are self-judged: the actor records its own verdicts. Cloud runs add a second judge.
|
|
48
49
|
|
|
50
|
+
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
|
+
|
|
49
52
|
## Safety
|
|
50
53
|
|
|
51
|
-
- Every run gets a fresh Chrome profile, deleted afterwards. An actor with `persist_login` keeps its own profile in `~/.stuntdouble/profiles/<actor
|
|
54
|
+
- Every run gets a fresh Chrome profile, deleted afterwards. An actor with `persist_login` keeps its own profile in `~/.stuntdouble/profiles/<project>/<actor>` (the project as a short hash of its path, so two projects with the same actor name never share cookies), never yours.
|
|
52
55
|
- 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.
|
|
53
56
|
- 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.
|
|
54
57
|
- 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.
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
// src/ui-server.ts
|
|
2
|
+
import { randomBytes, timingSafeEqual } from "crypto";
|
|
3
|
+
import { watch } from "fs";
|
|
4
|
+
import { readdir as readdir2, readFile as readFile2, realpath } from "fs/promises";
|
|
5
|
+
import { createServer } from "http";
|
|
6
|
+
import { basename, extname, join as join2, resolve, sep as sep2 } from "path";
|
|
7
|
+
|
|
1
8
|
// src/files.ts
|
|
2
9
|
import { randomUUID } from "crypto";
|
|
10
|
+
import { readFileSync } from "fs";
|
|
3
11
|
import { appendFile, lstat, mkdir, readdir, readFile, rename, writeFile } from "fs/promises";
|
|
4
12
|
import { dirname, join, sep } from "path";
|
|
5
13
|
import { z } from "zod";
|
|
@@ -326,7 +334,7 @@ var ActorSchema = z.strictObject({
|
|
|
326
334
|
content: z.string().min(1).max(ACTOR_KNOWLEDGE_CONTENT_MAX_LENGTH)
|
|
327
335
|
})
|
|
328
336
|
).default([]),
|
|
329
|
-
/** Keep this actor's cookies between runs, in ~/.stuntdouble/profiles/<actor>. */
|
|
337
|
+
/** Keep this actor's cookies between runs, in ~/.stuntdouble/profiles/<project>/<actor>. */
|
|
330
338
|
persist_login: z.boolean().default(false),
|
|
331
339
|
cloud
|
|
332
340
|
});
|
|
@@ -397,7 +405,8 @@ var RunSchema = z.object({
|
|
|
397
405
|
status: z.enum(RUN_STATUSES),
|
|
398
406
|
pid: z.number().optional(),
|
|
399
407
|
url: z.string(),
|
|
400
|
-
|
|
408
|
+
// A run pulled back from the cloud may not have started.
|
|
409
|
+
started_at: z.string().nullable(),
|
|
401
410
|
finished_at: z.string().nullable(),
|
|
402
411
|
checklist: z.string(),
|
|
403
412
|
actor: z.string(),
|
|
@@ -421,8 +430,14 @@ async function readJson(path, schema, label = path) {
|
|
|
421
430
|
let raw;
|
|
422
431
|
try {
|
|
423
432
|
raw = await readFile(path, "utf8");
|
|
424
|
-
} catch {
|
|
425
|
-
|
|
433
|
+
} catch (err) {
|
|
434
|
+
const code = err.code;
|
|
435
|
+
throw new Error(
|
|
436
|
+
`${label}: ${code === "ENOENT" ? "file not found" : `could not read (${code})`}`,
|
|
437
|
+
{
|
|
438
|
+
cause: err
|
|
439
|
+
}
|
|
440
|
+
);
|
|
426
441
|
}
|
|
427
442
|
let data;
|
|
428
443
|
try {
|
|
@@ -465,11 +480,41 @@ function isAlive(pid) {
|
|
|
465
480
|
try {
|
|
466
481
|
process.kill(pid, 0);
|
|
467
482
|
return true;
|
|
483
|
+
} catch {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
var runningMarker = (root, id) => join(root, ".live", "running", id);
|
|
488
|
+
function isLive(root, run) {
|
|
489
|
+
let marker;
|
|
490
|
+
try {
|
|
491
|
+
marker = readFileSync(runningMarker(root, run.id), "utf8").trim();
|
|
492
|
+
} catch {
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
return marker === String(run.pid) && isAlive(run.pid);
|
|
496
|
+
}
|
|
497
|
+
var effectiveStatus = (root, run) => run.status === "running" && !isLive(root, run) ? "interrupted" : run.status;
|
|
498
|
+
function tallyRun(run) {
|
|
499
|
+
const status = new Map(run.results.map((r) => [r.check, r.status]));
|
|
500
|
+
const scored = run.checklist_snapshot.checks.filter(
|
|
501
|
+
(c) => c.type !== "informational" && status.get(c.key) !== "skipped"
|
|
502
|
+
);
|
|
503
|
+
return {
|
|
504
|
+
passed: scored.filter((c) => status.get(c.key) === "passed").length,
|
|
505
|
+
total: scored.length
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
async function readRun(root, id) {
|
|
509
|
+
if (!/^[\w.-]+$/.test(id) || id.startsWith(".")) return null;
|
|
510
|
+
const path = join(root, "runs", id, "run.json");
|
|
511
|
+
if (!await lstat(join(root, "runs", id)).catch(() => null)) return null;
|
|
512
|
+
try {
|
|
513
|
+
return { id, run: await readJson(path, RunSchema) };
|
|
468
514
|
} catch (err) {
|
|
469
|
-
return err.
|
|
515
|
+
return { id, error: err.message };
|
|
470
516
|
}
|
|
471
517
|
}
|
|
472
|
-
var effectiveStatus = (run) => run.status === "running" && !isAlive(run.pid) ? "interrupted" : run.status;
|
|
473
518
|
async function readRuns(root) {
|
|
474
519
|
const ids = await readdir(join(root, "runs")).catch(() => []);
|
|
475
520
|
const entries = await Promise.all(
|
|
@@ -484,6 +529,246 @@ async function readRuns(root) {
|
|
|
484
529
|
return entries.sort((a, b) => a.id < b.id ? 1 : -1);
|
|
485
530
|
}
|
|
486
531
|
|
|
532
|
+
// src/ui-server.ts
|
|
533
|
+
var FIRST_PORT = 4747;
|
|
534
|
+
var PORT_TRIES = 20;
|
|
535
|
+
var TYPES = {
|
|
536
|
+
".png": "image/png",
|
|
537
|
+
".json": "application/json",
|
|
538
|
+
".jsonl": "text/plain; charset=utf-8"
|
|
539
|
+
};
|
|
540
|
+
var PAGE_HEADERS = {
|
|
541
|
+
"Content-Security-Policy": "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self'; frame-ancestors 'none'; form-action 'none'"
|
|
542
|
+
};
|
|
543
|
+
function send(res, status, body, type = "text/plain; charset=utf-8", headers = {}) {
|
|
544
|
+
res.writeHead(status, {
|
|
545
|
+
"Content-Type": type,
|
|
546
|
+
"Cache-Control": "no-store",
|
|
547
|
+
"X-Content-Type-Options": "nosniff",
|
|
548
|
+
"Referrer-Policy": "no-referrer",
|
|
549
|
+
...headers
|
|
550
|
+
});
|
|
551
|
+
res.end(body);
|
|
552
|
+
}
|
|
553
|
+
var json = (res, data) => send(res, 200, JSON.stringify(data), "application/json");
|
|
554
|
+
function sameSecret(given, token) {
|
|
555
|
+
const a = Buffer.from(given);
|
|
556
|
+
const b = Buffer.from(token);
|
|
557
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
558
|
+
}
|
|
559
|
+
function cookie(req, name) {
|
|
560
|
+
for (const part of (req.headers.cookie ?? "").split(";")) {
|
|
561
|
+
const [key, ...value] = part.trim().split("=");
|
|
562
|
+
if (key === name) return value.join("=");
|
|
563
|
+
}
|
|
564
|
+
return void 0;
|
|
565
|
+
}
|
|
566
|
+
async function confined(root, rel) {
|
|
567
|
+
try {
|
|
568
|
+
const realRoot = await realpath(root);
|
|
569
|
+
const real = await realpath(resolve(root, rel));
|
|
570
|
+
return real.startsWith(realRoot + sep2) ? real : null;
|
|
571
|
+
} catch {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
async function definitions(root, folder) {
|
|
576
|
+
const schema = folder === "checklists" ? ChecklistSchema : ActorSchema;
|
|
577
|
+
const names = await readdir2(join2(root, folder)).catch(() => []);
|
|
578
|
+
return Promise.all(
|
|
579
|
+
names.filter((n) => n.endsWith(".json")).sort().map(async (name) => {
|
|
580
|
+
const slug2 = basename(name, ".json");
|
|
581
|
+
try {
|
|
582
|
+
return { slug: slug2, data: await readJson(join2(root, folder, name), schema, name) };
|
|
583
|
+
} catch (err) {
|
|
584
|
+
return { slug: slug2, error: err.message };
|
|
585
|
+
}
|
|
586
|
+
})
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
async function actorNames(root) {
|
|
590
|
+
const actors = await definitions(root, "actors");
|
|
591
|
+
return new Map(actors.flatMap((a) => "data" in a && a.data ? [[a.slug, a.data.name]] : []));
|
|
592
|
+
}
|
|
593
|
+
function runSummary(root, run, names) {
|
|
594
|
+
const { checklist_snapshot, results: _results, ...rest } = run;
|
|
595
|
+
return {
|
|
596
|
+
...rest,
|
|
597
|
+
status: effectiveStatus(root, run),
|
|
598
|
+
actor_name: names.get(run.actor) ?? null,
|
|
599
|
+
checklist_name: typeof checklist_snapshot.name === "string" ? checklist_snapshot.name : null,
|
|
600
|
+
tally: tallyRun(run)
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
async function readBody(req) {
|
|
604
|
+
let body = "";
|
|
605
|
+
for await (const chunk of req) {
|
|
606
|
+
body += chunk;
|
|
607
|
+
if (body.length > 1e4) throw new Error("Body too large");
|
|
608
|
+
}
|
|
609
|
+
return body;
|
|
610
|
+
}
|
|
611
|
+
async function startUi(options) {
|
|
612
|
+
const root = resolve(options.root);
|
|
613
|
+
const token = randomBytes(24).toString("base64url");
|
|
614
|
+
const clients = /* @__PURE__ */ new Set();
|
|
615
|
+
let port = 0;
|
|
616
|
+
async function handle(req, res) {
|
|
617
|
+
const host = req.headers.host ?? "";
|
|
618
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
|
|
619
|
+
return send(res, 403, "Forbidden");
|
|
620
|
+
}
|
|
621
|
+
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
622
|
+
if (url.pathname === "/__stuntdouble") return json(res, { stuntdouble: true });
|
|
623
|
+
if (url.pathname === "/favicon.ico") return send(res, 204, "");
|
|
624
|
+
const cookieName = `stuntdouble_${port}`;
|
|
625
|
+
const key = url.searchParams.get("t");
|
|
626
|
+
if (key !== null) {
|
|
627
|
+
if (!sameSecret(key, token)) return send(res, 403, "This link is out of date.");
|
|
628
|
+
url.searchParams.delete("t");
|
|
629
|
+
return send(res, 302, "", void 0, {
|
|
630
|
+
Location: `${url.pathname}${url.search}`,
|
|
631
|
+
"Set-Cookie": `${cookieName}=${token}; HttpOnly; SameSite=Strict; Path=/`
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
const signedIn = cookie(req, cookieName);
|
|
635
|
+
if (!signedIn || !sameSecret(signedIn, token)) {
|
|
636
|
+
return send(
|
|
637
|
+
res,
|
|
638
|
+
403,
|
|
639
|
+
"Open the viewer from the link Stunt Double printed: it carries the key."
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
const path = url.pathname;
|
|
643
|
+
if (req.method === "POST" && path === "/api/stop") {
|
|
644
|
+
const origin = req.headers.origin;
|
|
645
|
+
if (origin && origin !== `http://${host}`) return send(res, 403, "Forbidden");
|
|
646
|
+
const { run } = JSON.parse(await readBody(req));
|
|
647
|
+
const entry = typeof run === "string" ? await readRun(root, run) : null;
|
|
648
|
+
if (!entry || !("run" in entry) || effectiveStatus(root, entry.run) !== "running") {
|
|
649
|
+
return send(res, 409, "That run is not going.");
|
|
650
|
+
}
|
|
651
|
+
await writeFileAtomic(join2(root, ".live", "stop"), entry.id);
|
|
652
|
+
return send(res, 204, "");
|
|
653
|
+
}
|
|
654
|
+
if (req.method !== "GET") return send(res, 405, "Method not allowed");
|
|
655
|
+
if (path === "/" || path === "/definitions" || /^\/runs\/[^/]+$/.test(path)) {
|
|
656
|
+
const page = await readFile2(new URL("./ui.html", import.meta.url));
|
|
657
|
+
return send(res, 200, page, "text/html; charset=utf-8", PAGE_HEADERS);
|
|
658
|
+
}
|
|
659
|
+
if (path === "/api/runs") {
|
|
660
|
+
const [runs, names] = await Promise.all([readRuns(root), actorNames(root)]);
|
|
661
|
+
return json(
|
|
662
|
+
res,
|
|
663
|
+
runs.map((e) => "run" in e ? runSummary(root, e.run, names) : e)
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
const runMatch = path.match(/^\/api\/runs\/([^/]+)$/);
|
|
667
|
+
if (runMatch) {
|
|
668
|
+
const id = decodeURIComponent(runMatch[1]);
|
|
669
|
+
const entry = await readRun(root, id);
|
|
670
|
+
if (!entry) return send(res, 404, "No such run");
|
|
671
|
+
const activity = await readFile2(join2(root, "runs", entry.id, "activity.jsonl"), "utf8").then(
|
|
672
|
+
(text) => text.split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
673
|
+
).catch(() => []);
|
|
674
|
+
const stop = await readFile2(join2(root, ".live", "stop"), "utf8").catch(() => "");
|
|
675
|
+
const names = "run" in entry ? await actorNames(root) : /* @__PURE__ */ new Map();
|
|
676
|
+
return json(res, {
|
|
677
|
+
...entry,
|
|
678
|
+
..."run" in entry ? {
|
|
679
|
+
run: {
|
|
680
|
+
...entry.run,
|
|
681
|
+
status: effectiveStatus(root, entry.run),
|
|
682
|
+
actor_name: names.get(entry.run.actor) ?? null
|
|
683
|
+
}
|
|
684
|
+
} : {},
|
|
685
|
+
activity,
|
|
686
|
+
stop_requested: stop.trim() === entry.id
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
if (path === "/api/definitions") {
|
|
690
|
+
return json(res, {
|
|
691
|
+
checklists: await definitions(root, "checklists"),
|
|
692
|
+
actors: await definitions(root, "actors")
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
if (path === "/api/events") {
|
|
696
|
+
res.writeHead(200, {
|
|
697
|
+
"Content-Type": "text/event-stream",
|
|
698
|
+
"Cache-Control": "no-store",
|
|
699
|
+
"X-Content-Type-Options": "nosniff"
|
|
700
|
+
});
|
|
701
|
+
res.write(": connected\n\n");
|
|
702
|
+
clients.add(res);
|
|
703
|
+
req.on("close", () => clients.delete(res));
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (path.startsWith("/files/")) {
|
|
707
|
+
const rel = decodeURIComponent(path.slice("/files/".length));
|
|
708
|
+
const file = await confined(root, rel);
|
|
709
|
+
if (!file) return send(res, 403, "Outside .stuntdouble/");
|
|
710
|
+
const type = TYPES[extname(file)];
|
|
711
|
+
if (!type) return send(res, 404, "Not found");
|
|
712
|
+
const cache = rel.includes("/shots/") ? "private, max-age=86400" : "no-store";
|
|
713
|
+
return send(res, 200, await readFile2(file), type, { "Cache-Control": cache });
|
|
714
|
+
}
|
|
715
|
+
return send(res, 404, "Not found");
|
|
716
|
+
}
|
|
717
|
+
const server = createServer((req, res) => {
|
|
718
|
+
handle(req, res).catch(() => {
|
|
719
|
+
if (res.headersSent) res.end();
|
|
720
|
+
else send(res, 500, "Something went wrong reading .stuntdouble/");
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
const first = options.port ?? FIRST_PORT;
|
|
724
|
+
for (let candidate = first; ; candidate++) {
|
|
725
|
+
try {
|
|
726
|
+
await new Promise((ok, fail) => {
|
|
727
|
+
server.once("error", fail);
|
|
728
|
+
server.listen(candidate, "127.0.0.1", () => {
|
|
729
|
+
server.off("error", fail);
|
|
730
|
+
ok();
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
break;
|
|
734
|
+
} catch (err) {
|
|
735
|
+
const busy = err.code === "EADDRINUSE";
|
|
736
|
+
if (!busy || candidate >= first + PORT_TRIES) throw err;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
port = server.address().port;
|
|
740
|
+
let watcher = null;
|
|
741
|
+
let pending = /* @__PURE__ */ new Set();
|
|
742
|
+
let timer;
|
|
743
|
+
try {
|
|
744
|
+
watcher = watch(root, { recursive: true }, (_event, name) => {
|
|
745
|
+
if (!name || name.endsWith(".tmp")) return;
|
|
746
|
+
pending.add(String(name).split(sep2).join("/"));
|
|
747
|
+
timer ??= setTimeout(() => {
|
|
748
|
+
const data = `data: ${JSON.stringify({ paths: [...pending] })}
|
|
749
|
+
|
|
750
|
+
`;
|
|
751
|
+
for (const client of clients) client.write(data);
|
|
752
|
+
pending = /* @__PURE__ */ new Set();
|
|
753
|
+
timer = void 0;
|
|
754
|
+
}, 150);
|
|
755
|
+
});
|
|
756
|
+
} catch {
|
|
757
|
+
}
|
|
758
|
+
return {
|
|
759
|
+
port,
|
|
760
|
+
token,
|
|
761
|
+
link: (path = "/") => `http://127.0.0.1:${port}${path}?t=${token}`,
|
|
762
|
+
close: async () => {
|
|
763
|
+
watcher?.close();
|
|
764
|
+
clearTimeout(timer);
|
|
765
|
+
for (const client of clients) client.end();
|
|
766
|
+
server.closeAllConnections();
|
|
767
|
+
await new Promise((ok) => server.close(() => ok()));
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
487
772
|
export {
|
|
488
773
|
resolveSkippedChecks,
|
|
489
774
|
resolveHaltedChecks,
|
|
@@ -499,8 +784,12 @@ export {
|
|
|
499
784
|
writeJsonAtomic,
|
|
500
785
|
writeFileAtomic,
|
|
501
786
|
appendJsonl,
|
|
502
|
-
|
|
787
|
+
runningMarker,
|
|
788
|
+
isLive,
|
|
503
789
|
effectiveStatus,
|
|
504
|
-
|
|
790
|
+
tallyRun,
|
|
791
|
+
readRuns,
|
|
792
|
+
FIRST_PORT,
|
|
793
|
+
startUi
|
|
505
794
|
};
|
|
506
|
-
//# sourceMappingURL=chunk-
|
|
795
|
+
//# sourceMappingURL=chunk-7IC2G3Z3.js.map
|