@zhuxixi/pi-agent-board 0.5.2 → 0.6.1

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 (61) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +43 -5
  3. package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +551 -0
  4. package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +209 -0
  5. package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +796 -0
  6. package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +114 -0
  7. package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +73 -0
  8. package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +143 -0
  9. package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +226 -0
  10. package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +87 -0
  11. package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +53 -0
  12. package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +731 -0
  13. package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +30 -0
  14. package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +68 -0
  15. package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +125 -0
  16. package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +56 -0
  17. package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +96 -0
  18. package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +1 -1
  19. package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +92 -0
  20. package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +50 -0
  21. package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +106 -0
  22. package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +64 -0
  23. package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +48 -0
  24. package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +64 -0
  25. package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +71 -0
  26. package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +49 -0
  27. package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +76 -0
  28. package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +510 -0
  29. package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +58 -0
  30. package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +52 -0
  31. package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +87 -0
  32. package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +56 -0
  33. package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +79 -0
  34. package/package.json +83 -81
  35. package/runner/job-runner.mjs +2 -2
  36. package/runner/pty-runner.mjs +626 -3
  37. package/runner/state-runner.mjs +3 -0
  38. package/runner/title-runner.mjs +1 -1
  39. package/scripts/release_helper.mjs +277 -0
  40. package/src/commands/agent-board.ts +47 -35
  41. package/src/commands/attach-decision.mjs +66 -0
  42. package/src/commands/attach-flow.ts +45 -39
  43. package/src/commands/bg.ts +9 -0
  44. package/src/core/code-refs.mjs +85 -33
  45. package/src/core/evidence.mjs +2 -2
  46. package/src/core/heuristics.mjs +75 -2
  47. package/src/core/host-coordination.mjs +182 -0
  48. package/src/core/host-crash.mjs +43 -3
  49. package/src/core/host-probe.mjs +196 -0
  50. package/src/core/launch-options.mjs +17 -0
  51. package/src/core/launch.mjs +35 -35
  52. package/src/core/locks.mjs +196 -1
  53. package/src/core/paths.mjs +24 -0
  54. package/src/core/store.mjs +164 -5
  55. package/src/core/types.mjs +17 -1
  56. package/src/core/warm-host-sweeper.mjs +150 -0
  57. package/src/index.ts +40 -3
  58. package/src/runtime/service.mjs +1071 -108
  59. package/src/ui/dashboard-decisions.mjs +55 -0
  60. package/src/ui/dashboard.ts +67 -13
  61. package/src/ui/pty-attach.ts +23 -5
@@ -10,16 +10,19 @@
10
10
  import { spawn } from "node:child_process";
11
11
  import { createRequire } from "node:module";
12
12
  import { createServer } from "node:net";
13
- import { existsSync, unlinkSync } from "node:fs";
13
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs";
14
14
  import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { appendLine, readJson } from "../src/core/atomic.mjs";
17
17
  import { appendDiagnostic } from "../src/core/diagnostics.mjs";
18
18
  import { finalizeHostCrash } from "../src/core/host-crash.mjs";
19
+ import { ownsEndpoint, shouldYieldRunner } from "../src/core/host-coordination.mjs";
20
+ import { lastVisibleLogLine } from "../src/core/heuristics.mjs";
21
+ import { acquireOwnedViewLock } from "../src/core/locks.mjs";
19
22
  import * as P from "../src/core/paths.mjs";
20
23
  import { appendBoundedScreenLog, reconcileScreenLog } from "../src/core/screen-log.mjs";
21
24
  import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
22
- import { readState, writeHost, writeState } from "../src/core/store.mjs";
25
+ import { readHost, readState, updateOwnedHost, writeHost, writeState } from "../src/core/store.mjs";
23
26
  import { ensureNodePtySpawnHelperExecutable } from "../src/core/pty-support.mjs";
24
27
 
25
28
  const requireForPty = createRequire(import.meta.url);
@@ -33,6 +36,52 @@ try {
33
36
  }
34
37
 
35
38
  const HEARTBEAT_MS = 1000;
39
+ /** Service-input ack dedup bound (issue #70 Task 8): requestId → true, FIFO evict. */
40
+ const HOST_ACK_DEDUP_MAX = 1000;
41
+ /** Max time an owned runner waits to take the per-view host-start lease (issue #70). */
42
+ const HOST_RUNNER_LOCK_WAIT_MS = 5_000;
43
+ /** How much of the screen.log tail to scan when attributing an abnormal child
44
+ * exit (issue #90). Tail-only: the log can be 100MB+. */
45
+ const EXIT_LOG_TAIL_BYTES = 8_192;
46
+
47
+ /** Tail-read the last `maxBytes` of a file without loading it whole.
48
+ * @param {string} path
49
+ * @param {number} maxBytes
50
+ * @returns {string}
51
+ */
52
+ function readScreenLogTail(path, maxBytes) {
53
+ let fd;
54
+ try {
55
+ fd = openSync(path, "r");
56
+ const size = fstatSync(fd).size;
57
+ const start = Math.max(0, size - maxBytes);
58
+ const length = size - start;
59
+ const buffer = Buffer.allocUnsafe(length);
60
+ readSync(fd, buffer, 0, length, start);
61
+ return buffer.toString("utf8");
62
+ } finally {
63
+ try { if (fd != null) closeSync(fd); } catch { /* best effort */ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Best-effort error attribution for an abnormal child exit (issue #90): the
69
+ * child's failure reason (e.g. "Model X not found") exists only in the raw
70
+ * screen log, so surface its last visible line in host.json.error. Never
71
+ * throws — this runs on the exit path.
72
+ * @param {number} exitCode
73
+ * @param {string} screenLogPath
74
+ * @returns {{} | { error: string }}
75
+ */
76
+ function attributedExitError(exitCode, screenLogPath) {
77
+ if (exitCode === 0) return {};
78
+ try {
79
+ const line = lastVisibleLogLine(readScreenLogTail(screenLogPath, EXIT_LOG_TAIL_BYTES));
80
+ return line ? { error: line } : {};
81
+ } catch {
82
+ return {};
83
+ }
84
+ }
36
85
 
37
86
  function main() {
38
87
  const configPath = process.argv[2];
@@ -40,7 +89,18 @@ function main() {
40
89
  /** @type {import("../src/core/types.mjs").HostConfig|null} */
41
90
  const config = readJson(configPath, null);
42
91
  if (!config) failEarly(`pty-runner: cannot read config ${configPath}`);
92
+ // New ownership protocol (issue #70): instance-scoped config selects the fenced
93
+ // path. Legacy configs (no instanceId) keep the historical behavior unchanged.
94
+ if (config.instanceId) {
95
+ // A mid-flight throw in ownedMain must still terminate the process — the
96
+ // runner is detached with ignored stdio; an unhandled rejection would hang it.
97
+ ownedMain(config).catch(() => process.exit(1));
98
+ return;
99
+ }
100
+ legacyMain(config);
101
+ }
43
102
 
103
+ function legacyMain(config) {
44
104
  const socketPath = P.controlSocketPath(config.root, config.viewId);
45
105
  const screenLog = P.screenLogPath(config.root, config.viewId);
46
106
  // Optional per-install cap override from launch prefs (screenLogMaxSize).
@@ -197,7 +257,9 @@ function main() {
197
257
  // After a crash the handler already persisted "failed" and broadcast
198
258
  // exit; this callback must not overwrite that state.
199
259
  if (!crashed) {
200
- update({ state: "exited", endedAt: Date.now(), exitCode, childPid: null });
260
+ // Attribute only a NATURAL abnormal exit (issue #90): after a deliberate
261
+ // stop (shutdownStarted) the child was killed by us — no error line.
262
+ update({ state: "exited", endedAt: Date.now(), exitCode, childPid: null, ...(shutdownStarted ? {} : attributedExitError(exitCode, screenLog)) });
201
263
  editorEmpty = null;
202
264
  broadcast({ type: "editor_state", empty: null });
203
265
  broadcast({ type: "exit", exitCode });
@@ -323,6 +385,533 @@ function main() {
323
385
  process.on("SIGINT", () => { void shutdown(); });
324
386
  }
325
387
 
388
+ /**
389
+ * New-protocol host runner (issue #70).
390
+ *
391
+ * Runs only for instance-scoped configs. The lifecycle is fenced by an
392
+ * `instanceId` owner token and the per-view `host-start` lease:
393
+ * lease → ownership decision → bind UNIQUE endpoint → publish runner identity
394
+ * → spawn child → publish ready → release lease → heartbeat.
395
+ * All exits funnel through finishHost(), which writes only while the instance
396
+ * still owns host.json and unlinks only the exact socket inode it bound.
397
+ * @param {import("../src/core/types.mjs").HostConfig & { instanceId: string }} config
398
+ */
399
+ async function ownedMain(config) {
400
+ if (!config.socketPath) failEarly("pty-runner: owned config missing socketPath");
401
+ const socketPath = config.socketPath;
402
+ const screenLog = P.screenLogPath(config.root, config.viewId);
403
+ const screenLogMaxBytes =
404
+ Number.isFinite(config.screenLogMaxBytes) && config.screenLogMaxBytes > 0
405
+ ? Math.floor(config.screenLogMaxBytes)
406
+ : undefined;
407
+ const screenLogLimits = { maxBytes: screenLogMaxBytes };
408
+ let screenLogBytes = reconcileScreenLog(screenLog, screenLogLimits);
409
+
410
+ /** @type {Set<import("node:net").Socket>} */
411
+ const clients = new Set();
412
+ let childPid = null;
413
+ let child = null;
414
+ let exitCode = null;
415
+ let childExited = false;
416
+ let resolveChildExit;
417
+ const childExitPromise = new Promise((resolve) => {
418
+ resolveChildExit = resolve;
419
+ });
420
+ /** {dev,ino} recorded at bind time; cleanup unlinks only this exact inode. */
421
+ let boundSocketIdentity = null;
422
+ /** Set once THIS process published its runner identity into the record —
423
+ * only then may finishHost write host state. A same-instance duplicate that
424
+ * dies at listen (EADDRINUSE) shares the fencing token but never took the
425
+ * record, so it must leave the winner's state untouched (issue #70). */
426
+ let claimedRecord = false;
427
+ let shutdownStarted = false;
428
+ /** Authoritative child editor emptiness (issue #68); null = unknown. */
429
+ let editorEmpty = null;
430
+ /** In-memory snapshot of the owned host record, refreshed on every owned write. */
431
+ let host = readHost(config.root, config.viewId);
432
+ /** @type {{ token: string, touch(): boolean, isOwner(): boolean, release(): boolean } | null} */
433
+ let startLease = null;
434
+ let startTouchTimer = null;
435
+ let heartbeatTimer = null;
436
+ let server = null;
437
+ /** Service-input ack dedup: requestId → true. FIFO-capped at
438
+ * HOST_ACK_DEDUP_MAX. Interactive UI keystrokes (no requestId) bypass it. */
439
+ const ackedRequestIds = new Map();
440
+ /** Last {cols, rows} held while the child does not exist yet; applied when
441
+ * ready is published (issue #70 §6.3 starting protocol). */
442
+ let cachedResize = null;
443
+
444
+ const diag = (code, message, details) => {
445
+ try {
446
+ appendDiagnostic(config.root, config.viewId, {
447
+ source: "runner",
448
+ code,
449
+ message,
450
+ ...(details ? { details } : {}),
451
+ });
452
+ } catch { /* best effort */ }
453
+ };
454
+ const broadcast = (msg) => {
455
+ const line = JSON.stringify(msg) + "\n";
456
+ for (const c of clients) {
457
+ try { c.write(line); } catch { /* best effort */ }
458
+ }
459
+ };
460
+ /** Owner-fenced host write; refreshes the in-memory snapshot. */
461
+ const ownedUpdate = (mutate) => {
462
+ const result = updateOwnedHost(config.root, config.viewId, config.instanceId, (cur) => {
463
+ const next = mutate(cur);
464
+ return { ...next, lastSeenAt: Date.now(), attachedClients: clients.size };
465
+ });
466
+ if (result.updated && result.host) host = result.host;
467
+ return result;
468
+ };
469
+ const isOwnerNow = () => {
470
+ const h = readHost(config.root, config.viewId);
471
+ return Boolean(h && h.instanceId === config.instanceId);
472
+ };
473
+ const releaseStartLease = () => {
474
+ if (startTouchTimer) {
475
+ clearInterval(startTouchTimer);
476
+ startTouchTimer = null;
477
+ }
478
+ const lease = startLease;
479
+ startLease = null;
480
+ try { lease?.release(); } catch { /* best effort */ }
481
+ };
482
+
483
+ function waitForChildExit(timeoutMs) {
484
+ if (childExited) return Promise.resolve(true);
485
+ return new Promise((resolve) => {
486
+ let settled = false;
487
+ let timer;
488
+ const finish = (exited) => {
489
+ if (settled) return;
490
+ settled = true;
491
+ clearTimeout(timer);
492
+ resolve(exited);
493
+ };
494
+ timer = setTimeout(() => finish(false), timeoutMs);
495
+ childExitPromise.then(() => finish(true));
496
+ });
497
+ }
498
+
499
+ /** Reasons that finalize as `failed` rather than `exited`. */
500
+ const FAILED_REASONS = new Set(["server_error", "child_error", "crash", "child_spawn_failed", "endpoint_busy"]);
501
+
502
+ /**
503
+ * Idempotent unified finish: fenced terminal write, bounded server close,
504
+ * child exit with escalation, inode-owned socket cleanup, config cleanup.
505
+ * @param {string} reason
506
+ * @param {number|null} [requestedExitCode]
507
+ */
508
+ async function finishHost(reason, requestedExitCode = null) {
509
+ if (shutdownStarted) return;
510
+ shutdownStarted = true;
511
+ if (heartbeatTimer) {
512
+ clearInterval(heartbeatTimer);
513
+ heartbeatTimer = null;
514
+ }
515
+ // Only the runner that actually claimed the record writes terminal state; a
516
+ // superseded owner (or a duplicate that never claimed) never writes.
517
+ if (claimedRecord && reason !== "owner_lost" && isOwnerNow()) {
518
+ ownedUpdate((cur) => ({ ...cur, state: "stopping", stopReason: reason }));
519
+ broadcast({ type: "status", status: host });
520
+ }
521
+ try { broadcast({ type: "exit", exitCode: requestedExitCode ?? exitCode ?? 0 }); } catch { /* best effort */ }
522
+ for (const c of clients) {
523
+ try { c.destroy(); } catch { /* best effort */ }
524
+ }
525
+ clients.clear();
526
+ // Bounded server close (1s): never let a stuck client block cleanup.
527
+ await new Promise((resolve) => {
528
+ if (!server) return resolve();
529
+ let settled = false;
530
+ const done = () => {
531
+ if (!settled) {
532
+ settled = true;
533
+ resolve();
534
+ }
535
+ };
536
+ const timer = setTimeout(done, 1000);
537
+ server.close(() => {
538
+ clearTimeout(timer);
539
+ done();
540
+ });
541
+ });
542
+ if (child && !childExited) {
543
+ killChild(child, childPid, "SIGTERM");
544
+ if (!(await waitForChildExit(4000)) && !childExited) {
545
+ killChild(child, childPid, "SIGKILL");
546
+ await waitForChildExit(1000);
547
+ }
548
+ }
549
+ if (claimedRecord && reason !== "owner_lost" && isOwnerNow()) {
550
+ const failed = FAILED_REASONS.has(reason);
551
+ ownedUpdate((cur) => ({
552
+ ...cur,
553
+ state: failed ? "failed" : "exited",
554
+ endedAt: Date.now(),
555
+ exitCode: requestedExitCode ?? exitCode ?? 0,
556
+ childPid: null,
557
+ readyAt: null,
558
+ stopRequestedAt: null,
559
+ stopReason: reason,
560
+ // Attribute abnormal NATURAL child exits only (issue #90): reason
561
+ // "child_exit" with a non-zero code. Stops ("signal") and crashes
562
+ // have their own attribution (stopReason / crash finalize), and a
563
+ // SIGTERM'd healthy child must not capture a junk error line.
564
+ ...(reason === "child_exit" && cur.error == null
565
+ ? attributedExitError(requestedExitCode ?? exitCode ?? 0, screenLog)
566
+ : {}),
567
+ }));
568
+ }
569
+ // Endpoint cleanup: only the exact inode this instance bound.
570
+ if (process.platform !== "win32" && boundSocketIdentity) {
571
+ try {
572
+ const st = statSync(socketPath);
573
+ if (ownsEndpoint(boundSocketIdentity, { dev: st.dev, ino: st.ino })) {
574
+ try { unlinkSync(socketPath); } catch { /* best effort */ }
575
+ }
576
+ } catch { /* path gone — nothing to clean */ }
577
+ }
578
+ // Best-effort config cleanup (instance-scoped file only).
579
+ try {
580
+ if (config.configPath && config.instanceId && config.configPath.includes(config.instanceId)) {
581
+ unlinkSync(config.configPath);
582
+ }
583
+ } catch { /* best effort */ }
584
+ releaseStartLease();
585
+ process.exit(requestedExitCode ?? exitCode ?? 0);
586
+ }
587
+
588
+ // Bounded finish: a mid-finish throw must still exit the process (the runner
589
+ // is detached; an unhandled rejection inside finishHost would otherwise hang).
590
+ const finish = (reason, requestedExitCode = null) => {
591
+ finishHost(reason, requestedExitCode).catch(() => process.exit(1));
592
+ };
593
+
594
+ process.on("SIGTERM", () => { finish("signal"); });
595
+ process.on("SIGINT", () => { finish("signal"); });
596
+ process.on("uncaughtException", (err) => {
597
+ process.removeAllListeners("uncaughtException");
598
+ const message = err instanceof Error ? err.message : String(err);
599
+ diag("runner_crash", message, { stack: err instanceof Error ? err.stack : undefined });
600
+ // Crash finalize is owner-fenced via the expected-instance path in
601
+ // finalizeHostCrash: a superseded owner must not clobber the replacement's
602
+ // record (it records host_crash_owner_changed instead of writing).
603
+ host = finalizeHostCrash(config.root, config.viewId, host, err, { expectedInstanceId: config.instanceId });
604
+ finish("crash", 1);
605
+ });
606
+
607
+ // 1. Take the per-view host-start lease — the launch transaction boundary.
608
+ try {
609
+ startLease = acquireOwnedViewLock(config.root, config.viewId, "host-start", {
610
+ waitMs: HOST_RUNNER_LOCK_WAIT_MS,
611
+ identity: { pid: process.pid, startToken: captureStartToken(process.pid) },
612
+ });
613
+ } catch (err) {
614
+ diag("host_start_lock_timeout", err instanceof Error ? err.message : String(err));
615
+ process.exit(1);
616
+ }
617
+ startTouchTimer = setInterval(() => {
618
+ try { startLease?.touch(); } catch { /* best effort */ }
619
+ }, HEARTBEAT_MS);
620
+ startTouchTimer.unref?.();
621
+
622
+ // 2. Ownership decision inside the lease: never write host.json on the yield paths.
623
+ const current = readHost(config.root, config.viewId);
624
+ if (shouldYieldRunner({ host: current, instanceId: config.instanceId })) {
625
+ diag("host_start_yielded", "host record belongs to another active instance", { recordInstance: current?.instanceId ?? null });
626
+ releaseStartLease();
627
+ process.exit(0);
628
+ }
629
+ if (!current || current.instanceId !== config.instanceId) {
630
+ diag("host_start_stale_record", "no matching host claim for this instance", { recordInstance: current?.instanceId ?? null });
631
+ releaseStartLease();
632
+ process.exit(0);
633
+ }
634
+ // Own-terminal record: this instance already finished once (e.g. the prior
635
+ // runner of the SAME instance exited after claiming). Continuing would
636
+ // re-publish exited→starting and re-deliver initialPrompt — exit instead.
637
+ if (current.state === "exited" || current.state === "failed") {
638
+ diag("host_start_own_terminal", "host record for this instance is already terminal");
639
+ releaseStartLease();
640
+ process.exit(0);
641
+ }
642
+ if (current.state === "stopping" || current.stopRequestedAt != null) {
643
+ diag("host_start_revoked", "host claim was revoked before this runner started");
644
+ releaseStartLease();
645
+ process.exit(0);
646
+ }
647
+ host = current;
648
+
649
+ // 3. Bind the per-instance endpoint. NO unlink: the path is unique to this
650
+ // instance; an occupied path means someone else owns it.
651
+ const listenOutcome = await new Promise((resolveListen) => {
652
+ const onError = (err) => resolveListen({ ok: false, error: err });
653
+ // Probe connections (clientId:"probe" hello) must not write host.json: the
654
+ // attach resolver's 150ms probe loop would amplify fenced writes and flip
655
+ // attachedEver with no client ever attached (CR round-1 finding 3).
656
+ const probeSockets = new WeakSet();
657
+ server = createServer((socket) => {
658
+ clients.add(socket);
659
+ socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty }) + "\n");
660
+ broadcast({ type: "status", status: host });
661
+ let buffer = "";
662
+ socket.on("data", (chunk) => {
663
+ buffer += chunk.toString("utf8");
664
+ const lines = buffer.split("\n");
665
+ buffer = lines.pop() ?? "";
666
+ for (const line of lines) handleClientLine(line, socket);
667
+ });
668
+ socket.on("close", () => {
669
+ clients.delete(socket);
670
+ if (probeSockets.has(socket)) return;
671
+ // Merge into the live record (a stale closure spread here erases a
672
+ // concurrent revoke — final review finding 2).
673
+ ownedUpdate((cur) => ({ ...cur }));
674
+ });
675
+ socket.on("error", () => {
676
+ clients.delete(socket);
677
+ if (probeSockets.has(socket)) return;
678
+ ownedUpdate((cur) => ({ ...cur }));
679
+ });
680
+ socket.markProbe = () => probeSockets.add(socket);
681
+ });
682
+ server.once("error", onError);
683
+ server.listen(socketPath, () => {
684
+ server?.removeListener("error", onError);
685
+ resolveListen({ ok: true });
686
+ });
687
+ });
688
+ if (!listenOutcome.ok) {
689
+ diag("host_endpoint_busy", listenOutcome.error instanceof Error ? listenOutcome.error.message : String(listenOutcome.error));
690
+ await finishHost("endpoint_busy", 1);
691
+ return;
692
+ }
693
+ server.on("error", (err) => {
694
+ diag("server_error", err instanceof Error ? err.message : String(err));
695
+ finish("server_error", 1);
696
+ });
697
+ // POSIX: record the exact inode we bound — cleanup matches dev+ino.
698
+ if (process.platform !== "win32") {
699
+ try {
700
+ const st = statSync(socketPath);
701
+ boundSocketIdentity = { dev: st.dev, ino: st.ino };
702
+ } catch { /* without the identity, cleanup degrades to no-op */ }
703
+ }
704
+
705
+ // 4. Publish runner identity while still holding the lease.
706
+ const claimedSelf = ownedUpdate((cur) => ({
707
+ ...cur,
708
+ runnerPid: process.pid,
709
+ runnerIdentity: { pid: process.pid, startToken: captureStartToken(process.pid) },
710
+ runnerSpawnedAt: cur.runnerSpawnedAt ?? Date.now(),
711
+ state: cur.state === "alive" ? "alive" : "starting",
712
+ }));
713
+ if (!claimedSelf.updated) {
714
+ await finishHost("owner_lost", 0);
715
+ return;
716
+ }
717
+ claimedRecord = true;
718
+ broadcast({ type: "status", status: host });
719
+
720
+ // Test seam (issue #70 Task 8): a config-level delay between publishing the
721
+ // runner identity and spawning the child creates a REAL starting window
722
+ // (endpoint accepting, child null) so the starting protocol is testable.
723
+ // Only host configs written by tests set this env; the service never does.
724
+ const spawnDelayMs = Number(config.env?.AGENT_BOARD_TEST_SPAWN_DELAY_MS || 0);
725
+ if (spawnDelayMs > 0) await new Promise((r) => { setTimeout(r, spawnDelayMs).unref?.(); });
726
+
727
+ // 5. Spawn the child — only after the endpoint is bound and owned.
728
+ const args = [...config.piArgsPrefix, "--session", config.sessionFile];
729
+ if (config.model) args.push("--model", config.model);
730
+ if (config.thinkingLevel) args.push("--thinking", config.thinkingLevel);
731
+ if (config.tools) args.push("--tools", config.tools);
732
+ if (config.initialPrompt) args.push(encodePromptForCliArg(config.initialPrompt));
733
+ const env = {
734
+ ...process.env,
735
+ ...(config.env || {}),
736
+ AGENT_BOARD_ROOT: config.root,
737
+ AGENT_BOARD_VIEW_ID: config.viewId,
738
+ AGENT_BOARD_CHILD: "1",
739
+ AGENT_BOARD_HOSTED: "pty",
740
+ // Legacy names are exported too so older child extension builds still behave.
741
+ AGENT_VIEW_ROOT: config.root,
742
+ AGENT_VIEW_VIEW_ID: config.viewId,
743
+ AGENT_VIEW_CHILD: "1",
744
+ AGENT_VIEW_HOSTED: "pty",
745
+ };
746
+ try {
747
+ child = spawnInteractive(config.piCommand, args, {
748
+ cwd: config.cwd,
749
+ env,
750
+ cols: host.cols,
751
+ rows: host.rows,
752
+ allowPipeFallback: config.env?.AGENT_BOARD_ALLOW_PIPE_FALLBACK === "1" || config.env?.AGENT_VIEW_ALLOW_PIPE_FALLBACK === "1",
753
+ });
754
+ } catch (err) {
755
+ const message = err instanceof Error ? err.message : String(err);
756
+ diag("child_spawn_failed", message);
757
+ markRowFailed(config.root, config.viewId, `PTY host failed: ${message}`);
758
+ await finishHost("child_spawn_failed", 1);
759
+ return;
760
+ }
761
+ childPid = child.pid ?? null;
762
+ child.onData((data) => {
763
+ screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
764
+ broadcast({ type: "output", data });
765
+ });
766
+ child.onExit((code) => {
767
+ childExited = true;
768
+ resolveChildExit?.();
769
+ exitCode = code ?? 0;
770
+ editorEmpty = null;
771
+ broadcast({ type: "editor_state", empty: null });
772
+ finish("child_exit", exitCode);
773
+ });
774
+ child.onError((err) => {
775
+ diag("child_error", err instanceof Error ? err.message : String(err));
776
+ finish("child_error", 1);
777
+ });
778
+
779
+ // 6. Re-check ownership after the child exists, then publish ready.
780
+ const ready = ownedUpdate((cur) => ({
781
+ ...cur,
782
+ childPid,
783
+ childIdentity: { pid: childPid, startToken: captureStartToken(childPid) },
784
+ childSpawnedAt: Date.now(),
785
+ state: "alive",
786
+ readyAt: Date.now(),
787
+ }));
788
+ if (!ready.updated) {
789
+ // Superseded between spawn and ready: never publish; take the child down.
790
+ killChild(child, childPid, "SIGTERM");
791
+ const revoked = readHost(config.root, config.viewId)?.stopRequestedAt != null;
792
+ await finishHost(revoked ? "host_start_revoked" : "owner_lost", 0);
793
+ return;
794
+ }
795
+ broadcast({ type: "status", status: host });
796
+
797
+ // A starting-window resize cannot be applied at spawn-return nor at first
798
+ // output: the child node process is still bootstrapping and a SIGWINCH
799
+ // landing in that window is silently lost (pending-signal race, observed
800
+ // empirically). Defer the apply until the child has had time to finish
801
+ // bootstrapping; one-shot, best-effort, superseded by any newer direct
802
+ // resize (which clears the cache).
803
+ if (cachedResize) {
804
+ const applyHeld = setTimeout(() => {
805
+ if (!cachedResize || !child || shutdownStarted) return;
806
+ try { child.resize(cachedResize.cols, cachedResize.rows); } catch { /* best effort */ }
807
+ notifyChildResize(childPid);
808
+ cachedResize = null;
809
+ }, 500);
810
+ applyHeld.unref?.();
811
+ }
812
+
813
+ // 7. Launch transaction complete — hand the start lease back.
814
+ releaseStartLease();
815
+
816
+ // 8. Heartbeat: watch the owner record; loss or revoke routes to finishHost.
817
+ heartbeatTimer = setInterval(() => {
818
+ const h = readHost(config.root, config.viewId);
819
+ if (!h || h.instanceId !== config.instanceId) {
820
+ finish("owner_lost", 0);
821
+ return;
822
+ }
823
+ if (h.stopRequestedAt != null) {
824
+ finish("revoked", 0);
825
+ return;
826
+ }
827
+ const hb = ownedUpdate((cur) => ({ ...cur }));
828
+ // Plain host-meta lease contention returns {updated:false, ownerChanged:false}
829
+ // — transient, retry next tick (store.mjs contract). Only a confirmed
830
+ // ownership change tears the session down (CR round-1 finding 1).
831
+ if (hb.ownerChanged) finish("owner_lost", 0);
832
+ }, HEARTBEAT_MS);
833
+ heartbeatTimer.unref?.();
834
+
835
+ /** Client commands with the starting protocol (issue #70 §6.3): while the
836
+ * child does not exist, service inputs answer host_starting, resizes are
837
+ * held, and interactive keystrokes are dropped safely; once ready,
838
+ * requestId-tagged inputs ack with an in-process dedup table. */
839
+ function handleClientLine(line, socket) {
840
+ if (!line.trim()) return;
841
+ let msg;
842
+ try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
843
+ switch (msg.type) {
844
+ case "hello":
845
+ // Probe handshakes (host-probe.mjs) are read-only: mark the socket so
846
+ // close/error skip the merge write, and never flip attachedEver
847
+ // (CR round-1 finding 3). Real clients record attachedEver here —
848
+ // deferred from connect time, which cannot distinguish them yet.
849
+ if (msg.clientId === "probe") {
850
+ socket.markProbe?.();
851
+ } else {
852
+ ownedUpdate((cur) => ({ ...cur, attachedEver: true }));
853
+ }
854
+ send(socket, { type: "hello", status: host, editorEmpty });
855
+ break;
856
+ case "input": {
857
+ if (typeof msg.data !== "string") break;
858
+ if (typeof msg.requestId !== "string" || !msg.requestId) {
859
+ // Interactive UI keystrokes: fire-and-forget, no ack protocol.
860
+ if (child) child.write(msg.data);
861
+ break;
862
+ }
863
+ if (!child) {
864
+ send(socket, { type: "error", code: "host_starting", requestId: msg.requestId });
865
+ break;
866
+ }
867
+ if (ackedRequestIds.has(msg.requestId)) {
868
+ send(socket, { type: "input_ack", requestId: msg.requestId });
869
+ break;
870
+ }
871
+ child.write(msg.data);
872
+ ackedRequestIds.set(msg.requestId, true);
873
+ if (ackedRequestIds.size > HOST_ACK_DEDUP_MAX) {
874
+ ackedRequestIds.delete(ackedRequestIds.keys().next().value);
875
+ }
876
+ send(socket, { type: "input_ack", requestId: msg.requestId });
877
+ break;
878
+ }
879
+ case "resize": {
880
+ const cols = clampInt(msg.cols, 20, 300, host.cols);
881
+ const rows = clampInt(msg.rows, 5, 120, host.rows);
882
+ if (child) {
883
+ child.resize(cols, rows);
884
+ notifyChildResize(childPid);
885
+ cachedResize = null;
886
+ } else {
887
+ cachedResize = { cols, rows };
888
+ }
889
+ ownedUpdate((cur) => ({ ...cur, cols, rows }));
890
+ break;
891
+ }
892
+ case "interrupt":
893
+ if (child) child.write("\x1b");
894
+ break;
895
+ case "terminate":
896
+ // Single exit path: finishHost carries the SIGTERM→4s→SIGKILL ladder,
897
+ // so a SIGTERM-immune child still terminates within a bounded window.
898
+ finish("terminated", 0);
899
+ break;
900
+ case "detach":
901
+ socket.end();
902
+ break;
903
+ case "get_status":
904
+ send(socket, { type: "status", status: host });
905
+ break;
906
+ case "editor_state": {
907
+ editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
908
+ broadcast({ type: "editor_state", empty: editorEmpty });
909
+ break;
910
+ }
911
+ }
912
+ }
913
+ }
914
+
326
915
  function spawnInteractive(command, args, opts) {
327
916
  ensureNodePtySpawnHelperExecutable(requireForPty);
328
917
  if (pty?.spawn) {
@@ -388,12 +977,46 @@ function killChild(child, pid, signal = "SIGTERM") {
388
977
  try { child.kill(signal); } catch {}
389
978
  }
390
979
 
980
+ /**
981
+ * node-pty's TIOCSWINSZ updates the kernel tty size, but the automatic
982
+ * SIGWINCH to the pty's foreground group is NOT reliably delivered when the
983
+ * runner itself is a detached session (empirically verified on Linux: the
984
+ * child's kernel size is correct, its signal handler never fires, and a
985
+ * direct kill delivers instantly). Real TUI children poll or handle SIGWINCH
986
+ * — an explicit best-effort signal is harmless for them and makes resize
987
+ * deterministic for every child. POSIX only.
988
+ * @param {number|null} pid
989
+ */
990
+ function notifyChildResize(pid) {
991
+ if (process.platform === "win32" || !pid) return;
992
+ try { process.kill(pid, "SIGWINCH"); } catch { /* best effort */ }
993
+ }
994
+
391
995
  function clampInt(value, min, max, fallback) {
392
996
  const n = Number(value);
393
997
  if (!Number.isFinite(n)) return fallback;
394
998
  return Math.max(min, Math.min(max, Math.floor(n)));
395
999
  }
396
1000
 
1001
+ /** POSIX process start token — /proc/<pid>/stat field 22 (starttime), stable
1002
+ * across exec(2). Recorded in published identities so service-side recovery
1003
+ * (issue #70 Task 11) can tell an owned-live pid from a reused one; null on
1004
+ * failure or non-Linux platforms. */
1005
+ function captureStartToken(pid) {
1006
+ if (process.platform !== "linux" || !pid) return null;
1007
+ try {
1008
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
1009
+ // comm (field 2) may contain spaces and parens — fields resume AFTER the
1010
+ // last ')'. fields[0] is state (field 3) → starttime (field 22) is [19].
1011
+ const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trimStart();
1012
+ const fields = afterComm.split(/\s+/);
1013
+ const startTime = fields[19];
1014
+ return startTime ?? null;
1015
+ } catch {
1016
+ return null;
1017
+ }
1018
+ }
1019
+
397
1020
 
398
1021
  function markRowFailed(root, viewId, message) {
399
1022
  const now = Date.now();
@@ -79,6 +79,9 @@ function sanitizedEnv() {
79
79
  delete env.AGENT_VIEW_VIEW_ID;
80
80
  delete env.AGENT_BOARD_HOSTED;
81
81
  delete env.AGENT_VIEW_HOSTED;
82
+ // Board-spawned classifier workers must not arm the warm-host sweeper
83
+ // (they share the board root with real hosts).
84
+ env.AGENT_BOARD_NO_SWEEP = "1";
82
85
  return env;
83
86
  }
84
87