muse-crew 0.14.9 → 0.14.11

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/lib/see-act.js CHANGED
@@ -1,9 +1,46 @@
1
1
  #!/usr/bin/env node
2
2
  // see-act.js — single-step browser driver for experiential QA.
3
3
  //
4
- // The agent closes the OODA loop: run one step, READ the screenshot or aria
5
- // output, decide the next step. Each invocation launches a fresh browser,
6
- // performs exactly one action, prints one JSON line to stdout, and exits.
4
+ // ONE-SHOT (the original contract, frozen): the agent closes the OODA loop:
5
+ // run one step, READ the screenshot or aria output, decide the next step.
6
+ // Each invocation launches a fresh browser, performs exactly one action,
7
+ // prints one JSON line to stdout, and exits.
8
+ //
9
+ // SESSION PROTOCOL (blocker 41, 2026-09-22): the one-shot driver's fresh
10
+ // browser per invocation cannot drive multi-step in-page flows (open a deck,
11
+ // then click Study — the second step needs the first step's page state).
12
+ // The session protocol keeps ONE Chromium + page alive across invocations:
13
+ //
14
+ // node see-act.js session-start --name <name> --url <url> [--session-dir <dir>]
15
+ // [--log <ooda-log.jsonl>] [--viewport desktop|mobile] [--timeout-ms <n>]
16
+ // [--idle-ttl-ms <n>]
17
+ // Launches Chromium once (same loopback-proxy stack as one-shot),
18
+ // navigates to --url, and spawns a detached daemon holding the browser.
19
+ // Prints one JSON line {ok:true, session, session_dir, control_port}.
20
+ // The daemon exits after --idle-ttl-ms (default 10 min) with no act.
21
+ //
22
+ // node see-act.js session-act --name <name> --attempt <id> [--session-dir <dir>]
23
+ // [--timeout-ms <n>] <aria|shot|click|scroll|type> [args]
24
+ // Performs ONE action inside the living page state, appends one JSON
25
+ // line to the session's ooda-log (the --log path from session-start,
26
+ // default <session-dir>/<name>/ooda-log.jsonl) in the exact
27
+ // append-ooda-step.js schema {step, attempt, action, args, exit,
28
+ // screenshot, observation}, prints the action's JSON result, and exits
29
+ // with the action's exit code (0/2/3, same meanings as one-shot).
30
+ //
31
+ // node see-act.js session-end --name <name> [--session-dir <dir>]
32
+ // Closes the browser, prints the session summary {ok:true, session,
33
+ // log, steps}. The session dir (and its ooda-log) is kept as evidence.
34
+ //
35
+ // The one-shot invocation shape is untouched by the session protocol: same
36
+ // flags, same JSON, same exit codes, same archive behavior. The session
37
+ // daemon duplicates the action implementations deliberately — the one-shot
38
+ // code path is frozen and never refactored under a live contract.
39
+ //
40
+ // Session state lives in <session-dir>/<name>/session.json (control port,
41
+ // daemon pid, status). <session-dir> defaults to SEE_ACT_SESSION_DIR or
42
+ // ./.see-act-sessions. Session names are [A-Za-z0-9_-]+. The control server
43
+ // binds 127.0.0.1 only.
7
44
  //
8
45
  // Self-contained: spawns its own loopback forward proxy on an ephemeral port
9
46
  // (Chromium's Local Network Access checks block direct loopback navigation),
@@ -34,9 +71,10 @@
34
71
 
35
72
  import { createServer, request as httpRequest } from "node:http";
36
73
  import { request as httpsRequest } from "node:https";
37
- import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from "node:fs";
74
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, renameSync, appendFileSync } from "node:fs";
38
75
  import { join, dirname, resolve } from "node:path";
39
76
  import { createRequire } from "node:module";
77
+ import { spawn } from "node:child_process";
40
78
 
41
79
  const cjsRequire = createRequire(import.meta.url);
42
80
 
@@ -65,6 +103,11 @@ function parseArgs(argv) {
65
103
  else if (a === "--y") out.y = argv[++i];
66
104
  else if (a === "--text") out.text = argv[++i];
67
105
  else if (a === "--full") out.full = true;
106
+ else if (a === "--name") out.name = argv[++i];
107
+ else if (a === "--session-dir") out.sessionDir = argv[++i];
108
+ else if (a === "--attempt") out.attempt = argv[++i];
109
+ else if (a === "--log") out.log = argv[++i];
110
+ else if (a === "--idle-ttl-ms") out.idleTtlMs = parseInt(argv[++i], 10);
68
111
  else if (!a.startsWith("--") && out.action === null) out.action = a;
69
112
  else rest.push(a);
70
113
  }
@@ -190,13 +233,580 @@ function startProxy() {
190
233
  });
191
234
  }
192
235
 
236
+ // === Session protocol (blocker 41, 2026-09-22) ===
237
+ // A session is one Chromium + page held alive by a detached daemon process.
238
+ // The daemon owns a loopback control server (127.0.0.1 only); session-act
239
+ // POSTs one action at a time and the daemon performs it on the living page.
240
+ // The one-shot code path above is frozen — the daemon duplicates the action
241
+ // implementations deliberately rather than refactoring a live contract.
242
+
243
+ const SESSION_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
244
+ const DEFAULT_IDLE_TTL_MS = 600000; // 10 min with no act -> daemon exits
245
+ const SESSION_START_POLLS = 120; // 120 x 250ms = 30s for the daemon to bind
246
+ const SESSION_ACTIONS = { aria: 1, shot: 1, click: 1, scroll: 1, type: 1 };
247
+
248
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
249
+
250
+ function resolveSessionDir(explicit) {
251
+ const d = ((explicit || process.env.SEE_ACT_SESSION_DIR || "").trim());
252
+ return resolve(d || join(process.cwd(), ".see-act-sessions"));
253
+ }
254
+
255
+ function readSessionJson(p) {
256
+ try {
257
+ return JSON.parse(readFileSync(p, "utf8"));
258
+ } catch (e) {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ // Atomic state write: the session-start poller and the daemon never see a
264
+ // torn session.json.
265
+ function writeSessionJson(p, obj) {
266
+ const tmp = p + ".tmp";
267
+ writeFileSync(tmp, JSON.stringify(obj) + "\n");
268
+ renameSync(tmp, p);
269
+ }
270
+
271
+ function httpJson(options, body, timeoutMs) {
272
+ return new Promise((resolve, reject) => {
273
+ const req = httpRequest(options, (res) => {
274
+ let b = "";
275
+ res.on("data", (c) => { b += c; });
276
+ res.on("end", () => {
277
+ try {
278
+ resolve({ status: res.statusCode, body: JSON.parse(b) });
279
+ } catch (e) {
280
+ reject(new Error("unparseable daemon response"));
281
+ }
282
+ });
283
+ });
284
+ req.on("error", reject);
285
+ req.setTimeout(timeoutMs, () => { req.destroy(new Error("daemon request timed out")); });
286
+ if (body !== undefined) {
287
+ const s = JSON.stringify(body);
288
+ req.setHeader("content-type", "application/json");
289
+ req.setHeader("content-length", Buffer.byteLength(s));
290
+ req.write(s);
291
+ }
292
+ req.end();
293
+ });
294
+ }
295
+
296
+ function daemonPing(port) {
297
+ return httpJson({ hostname: "127.0.0.1", port: port, path: "/ping", method: "GET" }, undefined, 2000)
298
+ .then((r) => r.status === 200 && r.body === "pong");
299
+ }
300
+
301
+ // Session-start preflights the environment in THIS process (same env the
302
+ // daemon inherits) so a missing playwright-core/Chromium reports exit 3
303
+ // NOT POSSIBLE immediately instead of dying inside the detached daemon.
304
+ function sessionStartPreflight() {
305
+ try { cjsRequire("playwright-core"); }
306
+ catch (e) {
307
+ const envDir = (process.env.PLAYWRIGHT_CORE_DIR || "").trim();
308
+ let okp = false;
309
+ if (envDir) {
310
+ try { cjsRequire(join(envDir, "playwright-core")); okp = true; } catch (e2) { /* fall through */ }
311
+ if (!okp) { try { cjsRequire(envDir); okp = true; } catch (e3) { /* fall through */ } }
312
+ }
313
+ if (!okp) {
314
+ fail(3, {
315
+ ok: false,
316
+ not_possible: "NOT POSSIBLE: playwright-core is not installed or not resolvable. " +
317
+ "Install it (npm install playwright-core) or set PLAYWRIGHT_CORE_DIR.",
318
+ });
319
+ }
320
+ }
321
+ const envPath = (process.env.CHROME_PATH || "").trim();
322
+ if (!((envPath && existsSync(envPath)) || existsSync("/opt/meta-chromium/chrome"))) {
323
+ fail(3, {
324
+ ok: false,
325
+ not_possible: "NOT POSSIBLE: no Chromium binary found. " +
326
+ "Set CHROME_PATH to a Chromium/Chrome executable.",
327
+ });
328
+ }
329
+ }
330
+
331
+ async function runSessionStart(args) {
332
+ if (!args.name || !SESSION_NAME_RE.test(args.name)) {
333
+ fail(2, { ok: false, error: "session-start requires --name <letters, digits, _-> (got: " + (args.name || "none") + ")" });
334
+ }
335
+ if (!args.url) {
336
+ fail(2, { ok: false, error: "session-start requires --url <url>" });
337
+ }
338
+ if (!VIEWPORTS[args.viewport]) {
339
+ fail(2, { ok: false, error: "unknown viewport: " + args.viewport + " (desktop|mobile)" });
340
+ }
341
+ sessionStartPreflight();
342
+ const sessionDir = resolveSessionDir(args.sessionDir);
343
+ const sessPath = join(sessionDir, args.name);
344
+ const statePath = join(sessPath, "session.json");
345
+ const existing = readSessionJson(statePath);
346
+ if (existing && existing.status === "running" && existing.controlPort) {
347
+ const alive = await daemonPing(existing.controlPort).catch(() => false);
348
+ if (alive) {
349
+ fail(2, { ok: false, error: "session already running: " + args.name });
350
+ }
351
+ // Stale state (daemon died without session-end): fall through and replace.
352
+ }
353
+ mkdirSync(sessPath, { recursive: true });
354
+ const logPath = args.log ? resolve(args.log) : join(sessPath, "ooda-log.jsonl");
355
+ try {
356
+ mkdirSync(dirname(logPath), { recursive: true });
357
+ } catch (e) {
358
+ fail(2, { ok: false, error: "cannot create ooda-log dir: " + dirname(logPath) });
359
+ }
360
+ const idleTtlMs = (Number.isFinite(args.idleTtlMs) && args.idleTtlMs > 0) ? args.idleTtlMs : DEFAULT_IDLE_TTL_MS;
361
+ const state = {
362
+ name: args.name, url: args.url, viewport: args.viewport, timeoutMs: args.timeoutMs,
363
+ log: logPath, idleTtlMs: idleTtlMs, status: "starting", pid: null, controlPort: null,
364
+ };
365
+ writeSessionJson(statePath, state);
366
+ const child = spawn(process.execPath, [process.argv[1], "--daemon", statePath], {
367
+ detached: true, stdio: "ignore",
368
+ });
369
+ child.unref();
370
+ let running = null;
371
+ for (let i = 0; i < SESSION_START_POLLS; i++) {
372
+ await sleep(250);
373
+ const st = readSessionJson(statePath);
374
+ if (st && st.status === "running" && st.controlPort) { running = st; break; }
375
+ if (st && st.status === "failed") {
376
+ fail(3, { ok: false, not_possible: st.error || "NOT POSSIBLE: session daemon failed to start" });
377
+ }
378
+ }
379
+ if (!running) {
380
+ const st = readSessionJson(statePath);
381
+ if (st && st.pid) { try { process.kill(st.pid, "SIGKILL"); } catch (e) { /* already gone */ } }
382
+ fail(2, { ok: false, error: "session daemon did not become ready in time: " + args.name });
383
+ }
384
+ const pong = await daemonPing(running.controlPort).catch(() => false);
385
+ if (!pong) {
386
+ fail(2, { ok: false, error: "session daemon not answering: " + args.name });
387
+ }
388
+ process.stdout.write(JSON.stringify({
389
+ ok: true, session: args.name, session_dir: sessPath,
390
+ url: args.url, viewport: args.viewport, control_port: running.controlPort,
391
+ }) + "\n");
392
+ process.exit(0);
393
+ }
394
+
395
+ async function runSessionAct(args) {
396
+ if (!args.name || !SESSION_NAME_RE.test(args.name)) {
397
+ fail(2, { ok: false, error: "session-act requires --name <session>" });
398
+ }
399
+ if (!args.attempt) {
400
+ fail(2, { ok: false, error: "session-act requires --attempt <id> (the ooda-log attempt identity)" });
401
+ }
402
+ if (!SESSION_ACTIONS[args.action]) {
403
+ fail(2, { ok: false, error: "unknown action: " + args.action + " (aria|shot|click|scroll|type)" });
404
+ }
405
+ const needsShot = args.action !== "aria";
406
+ const archiveDir = resolveArchiveDir();
407
+ if (needsShot && !args.out && !archiveDir) {
408
+ fail(2, { ok: false, error: "action " + args.action + " requires --out <png> (or set SEE_ACT_ARCHIVE_DIR to archive automatically)" });
409
+ }
410
+ if (args.action === "click" && !args.selector) {
411
+ fail(2, { ok: false, error: "click requires --selector <css>" });
412
+ }
413
+ if (args.action === "type" && (!args.selector || args.text === undefined)) {
414
+ fail(2, { ok: false, error: "type requires --selector <css> --text <text>" });
415
+ }
416
+ const statePath = join(resolveSessionDir(args.sessionDir), args.name, "session.json");
417
+ const st = readSessionJson(statePath);
418
+ if (!st) {
419
+ fail(2, { ok: false, error: "no such session: " + args.name });
420
+ }
421
+ if (st.status !== "running" || !st.controlPort) {
422
+ fail(2, { ok: false, error: "session not running: " + args.name + " (status: " + (st.status || "unknown") + ")" });
423
+ }
424
+ const actTimeoutMs = Number.isFinite(args.timeoutMs) ? args.timeoutMs : st.timeoutMs;
425
+ let resp;
426
+ try {
427
+ resp = await httpJson(
428
+ { hostname: "127.0.0.1", port: st.controlPort, path: "/act", method: "POST" },
429
+ {
430
+ action: args.action,
431
+ args: {
432
+ out: args.out || null, selector: args.selector || null, y: args.y || null,
433
+ text: args.text, full: !!args.full, viewport: st.viewport, timeoutMs: actTimeoutMs,
434
+ },
435
+ attempt: args.attempt,
436
+ archiveDir: archiveDir,
437
+ },
438
+ actTimeoutMs + 30000
439
+ );
440
+ } catch (e) {
441
+ fail(2, { ok: false, error: "session unreachable: " + args.name + " (" + ((e && e.message) || String(e)) + ")" });
442
+ }
443
+ process.stdout.write(JSON.stringify(resp.body.result) + "\n");
444
+ process.exit(resp.body.exit || 0);
445
+ }
446
+
447
+ async function runSessionEnd(args) {
448
+ if (!args.name || !SESSION_NAME_RE.test(args.name)) {
449
+ fail(2, { ok: false, error: "session-end requires --name <session>" });
450
+ }
451
+ const statePath = join(resolveSessionDir(args.sessionDir), args.name, "session.json");
452
+ const st = readSessionJson(statePath);
453
+ if (!st) {
454
+ fail(2, { ok: false, error: "no such session: " + args.name });
455
+ }
456
+ let summary = null;
457
+ if (st.status === "running" && st.controlPort) {
458
+ try {
459
+ const resp = await httpJson(
460
+ { hostname: "127.0.0.1", port: st.controlPort, path: "/end", method: "POST" },
461
+ undefined, 30000
462
+ );
463
+ summary = resp.body;
464
+ } catch (e) { /* daemon already gone — mark ended below */ }
465
+ }
466
+ // The session dir (and its ooda-log) is kept as evidence; only the state
467
+ // is marked ended so the name can be reused.
468
+ try {
469
+ writeSessionJson(statePath, Object.assign({}, st, { status: "ended", controlPort: null }));
470
+ } catch (e) { /* best effort */ }
471
+ if (summary && summary.ok) {
472
+ process.stdout.write(JSON.stringify(summary) + "\n");
473
+ } else {
474
+ process.stdout.write(JSON.stringify({
475
+ ok: true, session: args.name, ended: "daemon-unreachable",
476
+ note: "marked ended; the session dir and its ooda-log are preserved",
477
+ }) + "\n");
478
+ }
479
+ process.exit(0);
480
+ }
481
+
482
+ // Read the tail of a session ooda-log: the next step number, or the corruption
483
+ // that must fail the act loudly BEFORE it performs (lost evidence fails loud).
484
+ function readSessionLogTail(logPath) {
485
+ let raw;
486
+ try {
487
+ raw = readFileSync(logPath, "utf8");
488
+ } catch (e) {
489
+ return { corrupt: false, lastStep: 0 };
490
+ }
491
+ const lines = raw.split("\n").filter((l) => l.trim().length > 0);
492
+ if (lines.length === 0) return { corrupt: false, lastStep: 0 };
493
+ let last;
494
+ try {
495
+ last = JSON.parse(lines[lines.length - 1]);
496
+ } catch (e) {
497
+ return { corrupt: true, reason: "unparseable last line" };
498
+ }
499
+ if (!last || typeof last.step !== "number" || !Number.isFinite(last.step)) {
500
+ return { corrupt: true, reason: "last line carries no numeric step" };
501
+ }
502
+ return { corrupt: false, lastStep: last.step };
503
+ }
504
+
505
+ // The machine observation on a session ooda-log line: what the driver itself
506
+ // saw, never agent prose. Same schema as append-ooda-step.js lines.
507
+ function sessionMachineObservation(action, result) {
508
+ const parts = [];
509
+ if (action === "aria" && Array.isArray(result.aria)) parts.push("elements:" + result.aria.length);
510
+ if (result.console_error_count) parts.push("console_errors:" + result.console_error_count);
511
+ return parts.join(" ");
512
+ }
513
+
514
+ // Daemon-side action implementations. Deliberately duplicated from the
515
+ // one-shot path (which is frozen): the daemon acts on the LIVING page — no
516
+ // navigation, no fresh proxy — and drains the console-error buffer per act.
517
+ async function daemonPerformAction(page, action, a, ctx) {
518
+ const result = { ok: true, action: action, viewport: a.viewport };
519
+ async function capture(opts) {
520
+ const archivePath = ctx.archiveDir ? nextArchivePath(ctx.archiveDir, action, a.viewport) : null;
521
+ const dest = a.out || archivePath;
522
+ await page.screenshot(Object.assign({ path: dest }, opts));
523
+ if (a.out) result.out = a.out;
524
+ result.screenshot = dest;
525
+ if (archivePath && resolve(dest) !== archivePath) {
526
+ copyFileSync(dest, archivePath);
527
+ result.archived = archivePath;
528
+ } else if (archivePath) {
529
+ result.archived = archivePath;
530
+ }
531
+ }
532
+ if (action === "aria") {
533
+ const els = await page.evaluate(() => {
534
+ const out = [];
535
+ document.querySelectorAll("button, a, [role=button], [role=tab], input, select, textarea, h1, h2").forEach((el) => {
536
+ const t = (el.innerText || el.value || el.getAttribute("aria-label") || "").trim().slice(0, 80).replace(/\s+/g, " ");
537
+ if (t) out.push(el.tagName.toLowerCase() + ": " + t);
538
+ });
539
+ document.querySelectorAll("footer").forEach((el) => {
540
+ const t = (el.innerText || "").trim().slice(0, 300).replace(/\s+/g, " ");
541
+ if (t) out.push("footer: " + t);
542
+ });
543
+ return out.slice(0, 60);
544
+ });
545
+ result.aria = els;
546
+ } else if (action === "shot") {
547
+ await capture({ fullPage: !!a.full });
548
+ result.full_page = !!a.full;
549
+ } else if (action === "click") {
550
+ await page.click(a.selector, { timeout: a.timeoutMs });
551
+ await page.waitForTimeout(SETTLE_MS);
552
+ await capture({});
553
+ result.selector = a.selector;
554
+ } else if (action === "scroll") {
555
+ const y = a.y === undefined || a.y === null || a.y === "bottom" ? "bottom" : String(parseInt(a.y, 10));
556
+ await page.evaluate((yy) => {
557
+ const wantBottom = yy === "bottom";
558
+ const px = wantBottom ? 0 : parseInt(yy, 10);
559
+ let el = document.scrollingElement || document.body;
560
+ if (el.scrollHeight <= el.clientHeight + 1) {
561
+ let best = null, bestH = 0;
562
+ document.querySelectorAll("*").forEach((e) => {
563
+ if (e.scrollHeight > e.clientHeight + 50 && e.scrollHeight > bestH) { best = e; bestH = e.scrollHeight; }
564
+ });
565
+ if (best) el = best;
566
+ }
567
+ el.scrollTo(0, wantBottom ? el.scrollHeight : px);
568
+ }, y);
569
+ await page.waitForTimeout(1000);
570
+ await capture({});
571
+ result.y = y;
572
+ } else if (action === "type") {
573
+ await page.fill(a.selector, a.text, { timeout: a.timeoutMs });
574
+ await page.waitForTimeout(800);
575
+ await capture({});
576
+ result.selector = a.selector;
577
+ }
578
+ const errs = ctx.drainConsoleErrors();
579
+ result.console_errors = errs.slice(0, 10);
580
+ result.console_error_count = errs.length;
581
+ return result;
582
+ }
583
+
584
+ function readDaemonJsonBody(req) {
585
+ return new Promise((resolve, reject) => {
586
+ let b = "";
587
+ req.on("data", (c) => { b += c; });
588
+ req.on("end", () => {
589
+ try { resolve(JSON.parse(b)); }
590
+ catch (e) { reject(new Error("bad json body")); }
591
+ });
592
+ req.on("error", reject);
593
+ });
594
+ }
595
+
596
+ // The daemon: holds one Chromium + page, serves the control protocol on
597
+ // 127.0.0.1, appends every act to the session ooda-log. Never returns;
598
+ // exits 0 on session-end / idle timeout, 1 on startup failure (the state
599
+ // file carries status:"failed" so session-start can report it).
600
+ async function runDaemon(statePath) {
601
+ const st = readSessionJson(statePath);
602
+ if (!st || !st.url) {
603
+ process.stderr.write("see-act daemon: unreadable session state\n");
604
+ process.exit(1);
605
+ }
606
+ const daemonFail = (notPossible) => {
607
+ try {
608
+ writeSessionJson(statePath, Object.assign({}, st, { status: "failed", error: notPossible }));
609
+ } catch (e) { /* best effort */ }
610
+ process.exit(1);
611
+ };
612
+ try {
613
+ writeSessionJson(statePath, Object.assign({}, st, { pid: process.pid, status: "starting" }));
614
+ } catch (e) {
615
+ process.exit(1);
616
+ }
617
+ // Daemon-side environment resolution (mirrors loadPlaywright/findChromium,
618
+ // but reports through the state file instead of exiting with a JSON line
619
+ // on an ignored stdout).
620
+ let playwright = null;
621
+ try { playwright = cjsRequire("playwright-core"); } catch (e) { /* fall through */ }
622
+ if (!playwright) {
623
+ const envDir = (process.env.PLAYWRIGHT_CORE_DIR || "").trim();
624
+ if (envDir) {
625
+ try { playwright = cjsRequire(join(envDir, "playwright-core")); } catch (e) { /* fall through */ }
626
+ if (!playwright) { try { playwright = cjsRequire(envDir); } catch (e2) { /* fall through */ } }
627
+ }
628
+ }
629
+ if (!playwright) {
630
+ daemonFail("NOT POSSIBLE: playwright-core is not installed or not resolvable. " +
631
+ "Install it (npm install playwright-core) or set PLAYWRIGHT_CORE_DIR.");
632
+ }
633
+ const envPath = (process.env.CHROME_PATH || "").trim();
634
+ const exePath = (envPath && existsSync(envPath)) ? envPath
635
+ : existsSync("/opt/meta-chromium/chrome") ? "/opt/meta-chromium/chrome" : null;
636
+ if (!exePath) {
637
+ daemonFail("NOT POSSIBLE: no Chromium binary found. " +
638
+ "Set CHROME_PATH to a Chromium/Chrome executable.");
639
+ }
640
+
641
+ let proxy = null;
642
+ try {
643
+ proxy = await startProxy();
644
+ } catch (e) {
645
+ daemonFail("NOT POSSIBLE: could not start the loopback proxy: " + ((e && e.message) || String(e)));
646
+ }
647
+ let browser = null;
648
+ try {
649
+ browser = await playwright.chromium.launch({
650
+ executablePath: exePath,
651
+ args: ["--no-sandbox"],
652
+ proxy: { server: "http://127.0.0.1:" + proxy.port },
653
+ });
654
+ } catch (e) {
655
+ proxy.server.close();
656
+ daemonFail("NOT POSSIBLE: Chromium launch failed: " + ((e && e.message) || String(e)).slice(0, 300));
657
+ }
658
+ const page = await browser.newPage({ viewport: VIEWPORTS[st.viewport] || VIEWPORTS.desktop });
659
+ const consoleBuffer = [];
660
+ page.on("console", (msg) => {
661
+ if (msg.type() === "error") consoleBuffer.push(msg.text().slice(0, 300));
662
+ });
663
+ page.on("pageerror", (err) => {
664
+ consoleBuffer.push(String((err && err.message) || err).slice(0, 300));
665
+ });
666
+ const drainConsoleErrors = () => consoleBuffer.splice(0, consoleBuffer.length);
667
+ try {
668
+ await page.goto(st.url, { waitUntil: "domcontentloaded", timeout: st.timeoutMs });
669
+ await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {});
670
+ await page.waitForTimeout(SETTLE_MS);
671
+ } catch (e) {
672
+ await browser.close().catch(() => {});
673
+ proxy.server.close();
674
+ daemonFail("NOT POSSIBLE: session-start navigation failed: " + ((e && e.message) || String(e)).slice(0, 300));
675
+ }
676
+
677
+ let idleTimer = null;
678
+ let ended = false;
679
+ const server = createServer();
680
+ async function cleanup(reason) {
681
+ if (ended) return;
682
+ ended = true;
683
+ if (idleTimer) clearTimeout(idleTimer);
684
+ try {
685
+ const cur = readSessionJson(statePath) || st;
686
+ writeSessionJson(statePath, Object.assign({}, cur, { status: "ended", controlPort: null, ended_reason: reason }));
687
+ } catch (e) { /* best effort */ }
688
+ server.close();
689
+ await browser.close().catch(() => {});
690
+ proxy.server.close();
691
+ }
692
+ function armIdle() {
693
+ if (idleTimer) clearTimeout(idleTimer);
694
+ idleTimer = setTimeout(() => {
695
+ cleanup("idle-timeout").then(() => process.exit(0));
696
+ }, st.idleTtlMs);
697
+ if (idleTimer.unref) idleTimer.unref();
698
+ }
699
+ async function handleAct(body) {
700
+ if (!body || !SESSION_ACTIONS[body.action]) {
701
+ return { exit: 2, result: { ok: false, error: "unknown action: " + ((body && body.action) || "none") } };
702
+ }
703
+ const a = body.args || {};
704
+ // The log tail is validated BEFORE the act: corrupt evidence fails loud,
705
+ // never silently.
706
+ const tail = readSessionLogTail(st.log);
707
+ if (tail.corrupt) {
708
+ return { exit: 2, result: { ok: false, error: "session ooda-log corrupt (" + tail.reason + "): " + st.log } };
709
+ }
710
+ const step = tail.lastStep + 1;
711
+ let result, exit;
712
+ try {
713
+ result = await daemonPerformAction(page, body.action, a, {
714
+ archiveDir: body.archiveDir || null,
715
+ drainConsoleErrors: drainConsoleErrors,
716
+ });
717
+ exit = 0;
718
+ } catch (e) {
719
+ result = { ok: false, error: "action failed: " + ((e && e.message) || String(e)).slice(0, 500) };
720
+ exit = 2;
721
+ }
722
+ const line = {
723
+ step: step,
724
+ attempt: body.attempt || null,
725
+ action: body.action,
726
+ args: { selector: a.selector || undefined, y: a.y || undefined, text: a.text, full: !!a.full },
727
+ exit: exit,
728
+ screenshot: result.screenshot || null,
729
+ observation: exit === 0 ? sessionMachineObservation(body.action, result) : (result.error || ""),
730
+ };
731
+ try {
732
+ appendFileSync(st.log, JSON.stringify(line) + "\n");
733
+ } catch (e) {
734
+ return { exit: 2, result: { ok: false, error: "session ooda-log not writable: " + st.log } };
735
+ }
736
+ return { exit: exit, result: result };
737
+ }
738
+ server.on("request", async (req, res) => {
739
+ armIdle();
740
+ if (req.method === "GET" && req.url === "/ping") {
741
+ res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify("pong")); return;
742
+ }
743
+ if (req.method === "POST" && req.url === "/act") {
744
+ let body;
745
+ try { body = await readDaemonJsonBody(req); }
746
+ catch (e) { res.writeHead(400); res.end("bad json body"); return; }
747
+ const handled = await handleAct(body);
748
+ res.writeHead(200, { "content-type": "application/json" });
749
+ res.end(JSON.stringify(handled));
750
+ return;
751
+ }
752
+ if (req.method === "POST" && req.url === "/end") {
753
+ const tail = readSessionLogTail(st.log);
754
+ const summary = {
755
+ ok: true, session: st.name, log: st.log,
756
+ steps: tail.corrupt ? null : tail.lastStep, ended: "client-request",
757
+ };
758
+ res.writeHead(200, { "content-type": "application/json" });
759
+ res.end(JSON.stringify(summary), () => {
760
+ cleanup("client-request").then(() => process.exit(0));
761
+ });
762
+ return;
763
+ }
764
+ res.writeHead(404); res.end("unknown control path");
765
+ });
766
+ const onSignal = () => { cleanup("signal").then(() => process.exit(0)); };
767
+ process.on("SIGTERM", onSignal);
768
+ process.on("SIGINT", onSignal);
769
+ await new Promise((resolve, reject) => {
770
+ server.on("error", reject);
771
+ server.listen(0, "127.0.0.1", resolve);
772
+ });
773
+ const controlPort = server.address().port;
774
+ writeSessionJson(statePath, Object.assign({}, st, { status: "running", pid: process.pid, controlPort: controlPort }));
775
+ armIdle();
776
+ // The daemon lives until session-end, the idle timeout, or a signal.
777
+ }
778
+
193
779
  async function main() {
780
+ const argv = process.argv.slice(2);
781
+ // Internal daemon entry (spawned by session-start, detached).
782
+ if (argv[0] === "--daemon") {
783
+ await runDaemon(argv[1]);
784
+ return;
785
+ }
194
786
  // --help: before required-arg parsing (shebang⇔CLI contract).
195
- if (process.argv.slice(2).includes("--help")) {
787
+ if (argv.includes("--help")) {
196
788
  console.log("usage: node see-act.js --url <url> [--viewport desktop|mobile] [--timeout-ms <n>] <action> [args]\n" +
197
- "actions: aria | shot --out <png> [--full] | click --out <png> --selector <css> | scroll --out <png> --y <px|bottom> | type --out <png> --selector <css> --text <text>");
789
+ "actions: aria | shot --out <png> [--full] | click --out <png> --selector <css> | scroll --out <png> --y <px|bottom> | type --out <png> --selector <css> --text <text>\n" +
790
+ "session protocol (one browser held alive across invocations):\n" +
791
+ " session-start --name <name> --url <url> [--session-dir <dir>] [--log <ooda-log.jsonl>]\n" +
792
+ " [--viewport desktop|mobile] [--timeout-ms <n>] [--idle-ttl-ms <n>]\n" +
793
+ " session-act --name <name> --attempt <id> [--session-dir <dir>] [--timeout-ms <n>] <action> [args]\n" +
794
+ " session-end --name <name> [--session-dir <dir>]");
198
795
  process.exit(0);
199
796
  }
797
+ // Session subcommands dispatch before the one-shot path (which is frozen).
798
+ if (argv[0] === "session-start") {
799
+ await runSessionStart(parseArgs(argv.slice(1)));
800
+ return;
801
+ }
802
+ if (argv[0] === "session-act") {
803
+ await runSessionAct(parseArgs(argv.slice(1)));
804
+ return;
805
+ }
806
+ if (argv[0] === "session-end") {
807
+ await runSessionEnd(parseArgs(argv.slice(1)));
808
+ return;
809
+ }
200
810
  const args = parseArgs(process.argv.slice(2));
201
811
  if (!args.url) {
202
812
  fail(2, { ok: false, error: "usage: node see-act.js --url <url> [--viewport desktop|mobile] <aria|shot|click|scroll|type> [args]" });