@zhuxixi/pi-agent-board 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +6 -3
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
  6. package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
  7. package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
  8. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  9. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  10. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  11. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  12. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  13. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  14. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  15. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  16. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  17. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  18. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  19. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  20. package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
  21. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
  22. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  23. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  24. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  25. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  26. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  27. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  28. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  29. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  30. package/package.json +3 -2
  31. package/runner/job-runner-legacy.mjs +68 -0
  32. package/runner/job-runner.mjs +371 -67
  33. package/runner/pty-runner-legacy.mjs +50 -0
  34. package/runner/pty-runner.mjs +685 -58
  35. package/runner/state-coordinator.mjs +429 -0
  36. package/runner/state-runner.mjs +90 -15
  37. package/scripts/run-perf-gate.mjs +40 -0
  38. package/src/commands/agent-board.ts +8 -8
  39. package/src/commands/attach-flow.ts +5 -5
  40. package/src/commands/bg.ts +2 -1
  41. package/src/core/control-protocol.mjs +482 -0
  42. package/src/core/coordinator-client.mjs +313 -0
  43. package/src/core/coordinator-journal.mjs +282 -0
  44. package/src/core/coordinator-protocol.mjs +12 -0
  45. package/src/core/editor-state-reporter.mjs +11 -1
  46. package/src/core/foreground-preview-cache.mjs +117 -0
  47. package/src/core/host-protocol.mjs +24 -0
  48. package/src/core/launch.mjs +15 -0
  49. package/src/core/locks.mjs +68 -14
  50. package/src/core/paths.mjs +48 -0
  51. package/src/core/pid.mjs +32 -1
  52. package/src/core/pty-attach-jiggle-controller.mjs +83 -6
  53. package/src/core/pty-attach-reconnect.mjs +13 -6
  54. package/src/core/pty-attach-render.mjs +50 -0
  55. package/src/core/state-commands.mjs +699 -0
  56. package/src/core/status-consistency.mjs +98 -0
  57. package/src/core/store.mjs +59 -13
  58. package/src/core/terminal-attach-client.mjs +803 -0
  59. package/src/core/terminal-attach-protocol.mjs +252 -0
  60. package/src/core/terminal-model.mjs +222 -0
  61. package/src/core/terminal-snapshot.mjs +440 -0
  62. package/src/core/types.mjs +2 -0
  63. package/src/index.ts +12 -4
  64. package/src/runtime/service.mjs +694 -121
  65. package/src/ui/dashboard.ts +67 -92
  66. package/src/ui/pty-attach.ts +298 -72
  67. package/src/core/pty-input.mjs +0 -47
@@ -9,8 +9,9 @@
9
9
  */
10
10
  import { spawn } from "node:child_process";
11
11
  import { createRequire } from "node:module";
12
+ import { randomUUID } from "node:crypto";
12
13
  import { createServer } from "node:net";
13
- import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs";
14
+ import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeFileSync } from "node:fs";
14
15
  import { tmpdir } from "node:os";
15
16
  import { join } from "node:path";
16
17
  import { appendLine, readJson } from "../src/core/atomic.mjs";
@@ -21,8 +22,23 @@ import { lastVisibleLogLine } from "../src/core/heuristics.mjs";
21
22
  import { acquireOwnedViewLock } from "../src/core/locks.mjs";
22
23
  import * as P from "../src/core/paths.mjs";
23
24
  import { appendBoundedScreenLog, reconcileScreenLog } from "../src/core/screen-log.mjs";
25
+ import { createTerminalModel, feedOutput, resizeChildAndModel } from "../src/core/terminal-model.mjs";
26
+ import { createTerminalSubscription } from "../src/core/terminal-attach-protocol.mjs";
24
27
  import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
25
- import { readHost, readState, updateOwnedHost, writeHost, writeState } from "../src/core/store.mjs";
28
+ import {
29
+ checkSeq,
30
+ CONTROL_COMMAND_TYPES,
31
+ createResizeTracker,
32
+ journalAppendRecord,
33
+ journalGc,
34
+ journalUnresolved,
35
+ JOURNAL_KEEP_DEFAULT,
36
+ validateCommandEnvelope,
37
+ } from "../src/core/control-protocol.mjs";
38
+ import { readHost, readState, updateOwnedHost, writeHost } from "../src/core/store.mjs";
39
+ import { sendStateCommand } from "../src/core/coordinator-client.mjs";
40
+ import { classifyClientHello } from "../src/core/host-protocol.mjs";
41
+ import { markRowFailedDirect } from "./pty-runner-legacy.mjs";
26
42
  import { ensureNodePtySpawnHelperExecutable } from "../src/core/pty-support.mjs";
27
43
 
28
44
  const requireForPty = createRequire(import.meta.url);
@@ -40,6 +56,14 @@ const HEARTBEAT_MS = 1000;
40
56
  const HOST_ACK_DEDUP_MAX = 1000;
41
57
  /** Max time an owned runner waits to take the per-view host-start lease (issue #70). */
42
58
  const HOST_RUNNER_LOCK_WAIT_MS = 5_000;
59
+ /** Boot identity for the control lifecycle (issue #91 phase 5, spec D4 generation
60
+ * token): one runner process = one generation. Clients detect a runner
61
+ * replacement by the generation changing, which structurally disambiguates a
62
+ * reconnect ring-replay from a fresh child (phase 4 epoch ambiguity). */
63
+ const GENERATION = randomUUID();
64
+ /** GC trigger: rewrite the journal when records exceed 2× the keep bound.
65
+ * Append-only between rewrites, so the common path stays O(1) per command. */
66
+ const JOURNAL_GC_TRIGGER = JOURNAL_KEEP_DEFAULT * 2;
43
67
  /** How much of the screen.log tail to scan when attributing an abnormal child
44
68
  * exit (issue #90). Tail-only: the log can be 100MB+. */
45
69
  const EXIT_LOG_TAIL_BYTES = 8_192;
@@ -117,6 +141,8 @@ function legacyMain(config) {
117
141
 
118
142
  /** @type {Set<import("node:net").Socket>} */
119
143
  const clients = new Set();
144
+ /** Resident editor-state reporters: connected but never "attached" (#103). */
145
+ const editorReporters = new Set();
120
146
  let childPid = null;
121
147
  let child = null;
122
148
  let exitCode = null;
@@ -153,7 +179,57 @@ function legacyMain(config) {
153
179
  rows: config.rows || 36,
154
180
  attachedClients: 0,
155
181
  attachedEver: false,
182
+ revision: 0,
156
183
  };
184
+ // Canonical terminal model + per-socket snapshot subscriptions (issue #91
185
+ // phase 3). Fed from child.onData alongside the legacy screen.log/broadcast
186
+ // path; legacy message semantics unchanged (the `seq` field is additive).
187
+ const terminalModel = createTerminalModel({ cols: host.cols, rows: host.rows, scrollback: 2000 });
188
+ /** @type {Map<import("node:net").Socket, ReturnType<typeof createTerminalSubscription>>} */
189
+ const terminalSubscriptions = new Map();
190
+ // Control lifecycle runtime (issue #91 phase 5). instanceId is null in this
191
+ // legacy-mode main: no instance fence exists, so EVERY envelope claims a
192
+ // foreign instance and is rejected with `instance_mismatch` (documented
193
+ // contract — legacy-mode hosts serve only envelope-less clients).
194
+ const control = createControlRuntime({
195
+ viewId: config.viewId,
196
+ root: config.root,
197
+ instanceId: null,
198
+ send,
199
+ diag: (code, message, details) => {
200
+ try {
201
+ appendDiagnostic(config.root, config.viewId, { source: "runner", code, message, ...(details ? { details } : {}) });
202
+ } catch {
203
+ /* diagnostics must never kill the host */
204
+ }
205
+ },
206
+ actions: {
207
+ childReady: () => Boolean(child),
208
+ writeInput: (data) => {
209
+ child.write(data);
210
+ },
211
+ hostCols: () => host.cols,
212
+ hostRows: () => host.rows,
213
+ applyResize: (cols, rows) => {
214
+ resizeChildAndModel(child, terminalModel, cols, rows);
215
+ update({ cols, rows });
216
+ },
217
+ currentDims: () => ({ cols: terminalModel.cols, rows: terminalModel.rows }),
218
+ applyInterrupt: () => {
219
+ if (child) child.write("\x1b");
220
+ },
221
+ applyTerminate: () => {
222
+ killChild(child, childPid, "SIGTERM");
223
+ setTimeout(() => killChild(child, childPid, "SIGKILL"), 4000).unref?.();
224
+ },
225
+ applyDetach: (s) => {
226
+ s.end();
227
+ },
228
+ hostRevision: () => host.revision ?? 0,
229
+ cursor: () => ({ lastSeq: terminalModel.lastSeq, cols: terminalModel.cols, rows: terminalModel.rows }),
230
+ stateStamp: () => readState(config.root, config.viewId)?.materializedRevision ?? null,
231
+ },
232
+ });
157
233
  /** Persist host.json. A transient failure (e.g. Windows rename EPERM racing a
158
234
  * reader) must degrade, not kill the host: record a diagnostic and let the
159
235
  * next heartbeat tick retry. Socket protocol is the attach main channel, so
@@ -177,9 +253,9 @@ function legacyMain(config) {
177
253
  for (const c of clients) c.write(line);
178
254
  };
179
255
  const update = (patch = {}) => {
180
- host = { ...host, ...patch, lastSeenAt: Date.now(), attachedClients: clients.size };
256
+ host = { ...host, ...patch, revision: (host.revision ?? 0) + 1, lastSeenAt: Date.now(), attachedClients: clients.size };
181
257
  persist();
182
- broadcast({ type: "status", status: host });
258
+ broadcast({ type: "status", status: host, generation: GENERATION });
183
259
  };
184
260
  persist();
185
261
 
@@ -222,6 +298,9 @@ function legacyMain(config) {
222
298
  AGENT_BOARD_VIEW_ID: config.viewId,
223
299
  AGENT_BOARD_CHILD: "1",
224
300
  AGENT_BOARD_HOSTED: "pty",
301
+ // The endpoint this host actually bound: the child's editor-state reporter
302
+ // dials it instead of guessing the stable per-view address (issue #103).
303
+ AGENT_BOARD_CONTROL_SOCKET: socketPath,
225
304
  // Legacy names are exported too so older child extension builds still behave.
226
305
  AGENT_VIEW_ROOT: config.root,
227
306
  AGENT_VIEW_VIEW_ID: config.viewId,
@@ -240,20 +319,39 @@ function legacyMain(config) {
240
319
  } catch (err) {
241
320
  const message = err instanceof Error ? err.message : String(err);
242
321
  update({ state: "failed", endedAt: Date.now(), exitCode: 1, error: message });
243
- markRowFailed(config.root, config.viewId, `PTY host failed: ${message}`);
244
- process.exit(1);
322
+ // The command settles within the client's own timeout (never throws), so
323
+ // the exit stays bounded while the fenced write gets its chance. Return
324
+ // instead of falling through: child is null here and the rest of this
325
+ // function assumes a spawned child.
326
+ void markRowFailed(config.root, config.viewId, `PTY host failed: ${message}`).finally(() => process.exit(1));
327
+ return;
245
328
  }
246
329
  childPid = child.pid ?? null;
247
330
  update({ childPid });
248
331
 
249
332
  child.onData((data) => {
250
333
  screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
251
- broadcast({ type: "output", data });
334
+ const outputSeq = feedOutput(terminalModel, data);
335
+ // Per-socket output delivery (issue #91 phase 3): sockets that speak the
336
+ // subscribe_terminal protocol get exactly-once, gap-checked chunks from
337
+ // their subscription state machine; every other client keeps the legacy
338
+ // fire-and-forget broadcast (additive seq). Both streams to one socket
339
+ // would duplicate every chunk.
340
+ const outputLine = JSON.stringify({ type: "output", seq: outputSeq, data }) + "\n";
341
+ for (const [socket, sub] of terminalSubscriptions) {
342
+ if (sub.subscribed()) sub.onOutput(outputSeq, data);
343
+ // Per-socket guard: a synchronous throw from a dead socket must never
344
+ // escape into the uncaughtException crash path (whole-branch review).
345
+ else { try { socket.write(outputLine); } catch { /* 'error' handler cleans up */ } }
346
+ }
252
347
  });
253
348
  child.onExit((code) => {
254
349
  childExited = true;
255
350
  resolveChildExit?.();
256
351
  exitCode = code ?? 0;
352
+ // Terminate lifecycle (spec D4): a confirmed child exit is the observed
353
+ // evidence for any enveloped terminate still pending on a live socket.
354
+ control.flushTerminateObservations(exitCode, false);
257
355
  // After a crash the handler already persisted "failed" and broadcast
258
356
  // exit; this callback must not overwrite that state.
259
357
  if (!crashed) {
@@ -275,8 +373,11 @@ function legacyMain(config) {
275
373
  let server;
276
374
  server = createServer((socket) => {
277
375
  clients.add(socket);
278
- update({ attachedEver: true });
279
- socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty }) + "\n");
376
+ terminalSubscriptions.set(
377
+ socket,
378
+ createTerminalSubscription({ model: terminalModel, send: (msg) => send(socket, msg), generation: GENERATION }),
379
+ );
380
+ socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty, generation: GENERATION }) + "\n");
280
381
  let buffer = "";
281
382
  socket.on("data", (chunk) => {
282
383
  buffer += chunk.toString("utf8");
@@ -286,10 +387,16 @@ function legacyMain(config) {
286
387
  });
287
388
  socket.on("close", () => {
288
389
  clients.delete(socket);
390
+ editorReporters.delete(socket);
391
+ terminalSubscriptions.delete(socket);
392
+ control.closeSocket(socket);
289
393
  update();
290
394
  });
291
395
  socket.on("error", () => {
292
396
  clients.delete(socket);
397
+ editorReporters.delete(socket);
398
+ terminalSubscriptions.delete(socket);
399
+ control.closeSocket(socket);
293
400
  update();
294
401
  });
295
402
  });
@@ -303,23 +410,50 @@ function legacyMain(config) {
303
410
  if (!line.trim()) return;
304
411
  let msg;
305
412
  try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
413
+ // Enveloped control commands take the phase-5 lifecycle path; everything
414
+ // else (no commandId, or a non-control type carrying one) falls through to
415
+ // the legacy switch byte-identically.
416
+ const envelope = validateCommandEnvelope(msg);
417
+ if (envelope.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)) {
418
+ control.handle(msg, socket, envelope);
419
+ return;
420
+ }
306
421
  switch (msg.type) {
307
- case "hello":
308
- send(socket, { type: "hello", status: host, editorEmpty });
422
+ case "hello": {
423
+ // Bookkeeping-only clients must never pin the host against warm-host
424
+ // reclaim (issue #103 §C): probes are read-only, the editor reporter
425
+ // is resident. A reporter socket leaves `clients` (the attachedClients
426
+ // source) but stays writable so editor_state keeps flowing.
427
+ const kind = classifyClientHello(msg);
428
+ if (kind === "client") update({ attachedEver: true });
429
+ if (kind === "editor-reporter") {
430
+ clients.delete(socket);
431
+ terminalSubscriptions.delete(socket);
432
+ editorReporters.add(socket);
433
+ update();
434
+ }
435
+ send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
309
436
  break;
437
+ }
310
438
  case "input":
311
439
  if (typeof msg.data === "string") child.write(msg.data);
312
440
  break;
313
441
  case "resize": {
314
442
  const cols = clampInt(msg.cols, 20, 300, host.cols);
315
443
  const rows = clampInt(msg.rows, 5, 120, host.rows);
316
- child.resize(cols, rows);
444
+ // Paired step (CR R1 advisory): model reflows only when the real PTY
445
+ // resize succeeded. host.cols/rows keep recording the intended size
446
+ // (new-client clamp baseline), deliberately outside the guard.
447
+ resizeChildAndModel(child, terminalModel, cols, rows);
317
448
  update({ cols, rows });
318
449
  break;
319
450
  }
320
451
  case "interrupt":
321
452
  child.write("\x1b");
322
453
  break;
454
+ case "subscribe_terminal":
455
+ terminalSubscriptions.get(socket)?.handleMessage(msg);
456
+ break;
323
457
  case "terminate": {
324
458
  killChild(child, childPid, "SIGTERM");
325
459
  setTimeout(() => killChild(child, childPid, "SIGKILL"), 4000).unref?.();
@@ -329,7 +463,7 @@ function legacyMain(config) {
329
463
  socket.end();
330
464
  break;
331
465
  case "get_status":
332
- send(socket, { type: "status", status: host });
466
+ send(socket, { type: "status", status: host, generation: GENERATION });
333
467
  break;
334
468
  case "editor_state": {
335
469
  editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
@@ -364,11 +498,19 @@ function legacyMain(config) {
364
498
  if (requestedExitCode !== null) shutdownExitCode = requestedExitCode;
365
499
  if (shutdownStarted) return;
366
500
  shutdownStarted = true;
501
+ // Terminate lifecycle (spec D4): pending enveloped terminates get the
502
+ // runner-finalizing evidence before sockets end (exit-confirm flush in
503
+ // child.onExit covers the natural path; this covers deliberate shutdown).
504
+ control.flushTerminateObservations(null, true);
367
505
  try { server?.close(); } catch {}
368
506
  try { if (existsSync(socketPath)) unlinkSync(socketPath); } catch {}
369
507
  for (const client of clients) {
370
508
  try { client.end(); } catch {}
371
509
  }
510
+ for (const reporter of editorReporters) {
511
+ try { reporter.end(); } catch { /* best effort */ }
512
+ }
513
+ editorReporters.clear();
372
514
  killChild(child, childPid, "SIGTERM");
373
515
  if (!(await waitForChildExit(4000)) && !childExited) {
374
516
  killChild(child, childPid, "SIGKILL");
@@ -409,6 +551,8 @@ async function ownedMain(config) {
409
551
 
410
552
  /** @type {Set<import("node:net").Socket>} */
411
553
  const clients = new Set();
554
+ /** Resident editor-state reporters: connected but never "attached" (#103). */
555
+ const editorReporters = new Set();
412
556
  let childPid = null;
413
557
  let child = null;
414
558
  let exitCode = null;
@@ -461,7 +605,10 @@ async function ownedMain(config) {
461
605
  const ownedUpdate = (mutate) => {
462
606
  const result = updateOwnedHost(config.root, config.viewId, config.instanceId, (cur) => {
463
607
  const next = mutate(cur);
464
- return { ...next, lastSeenAt: Date.now(), attachedClients: clients.size };
608
+ // Record revision (phase 5 reconcile baseline): every committed fenced
609
+ // write bumps it, heartbeats included — a client comparing revisions
610
+ // sees any host-record movement, which is exactly the contract.
611
+ return { ...next, revision: (cur.revision ?? 0) + 1, lastSeenAt: Date.now(), attachedClients: clients.size };
465
612
  });
466
613
  if (result.updated && result.host) host = result.host;
467
614
  return result;
@@ -508,6 +655,11 @@ async function ownedMain(config) {
508
655
  async function finishHost(reason, requestedExitCode = null) {
509
656
  if (shutdownStarted) return;
510
657
  shutdownStarted = true;
658
+ // Terminate lifecycle (spec D4): pending enveloped terminates get the
659
+ // runner-finalizing evidence before teardown ends sockets. The child-exit
660
+ // flush (exitConfirmed) normally wins the race; whichever fires first
661
+ // clears the pending set, so exactly one observed ack goes out.
662
+ control.flushTerminateObservations(null, true);
511
663
  if (heartbeatTimer) {
512
664
  clearInterval(heartbeatTimer);
513
665
  heartbeatTimer = null;
@@ -516,13 +668,17 @@ async function ownedMain(config) {
516
668
  // superseded owner (or a duplicate that never claimed) never writes.
517
669
  if (claimedRecord && reason !== "owner_lost" && isOwnerNow()) {
518
670
  ownedUpdate((cur) => ({ ...cur, state: "stopping", stopReason: reason }));
519
- broadcast({ type: "status", status: host });
671
+ broadcast({ type: "status", status: host, generation: GENERATION });
520
672
  }
521
673
  try { broadcast({ type: "exit", exitCode: requestedExitCode ?? exitCode ?? 0 }); } catch { /* best effort */ }
522
674
  for (const c of clients) {
523
675
  try { c.destroy(); } catch { /* best effort */ }
524
676
  }
525
677
  clients.clear();
678
+ for (const reporter of editorReporters) {
679
+ try { reporter.destroy(); } catch { /* best effort */ }
680
+ }
681
+ editorReporters.clear();
526
682
  // Bounded server close (1s): never let a stuck client block cleanup.
527
683
  await new Promise((resolve) => {
528
684
  if (!server) return resolve();
@@ -646,6 +802,61 @@ async function ownedMain(config) {
646
802
  }
647
803
  host = current;
648
804
 
805
+ // Canonical terminal model + per-socket snapshot subscriptions (issue #91
806
+ // phase 3). Created from the owned host record before the endpoint binds,
807
+ // so every connect (including probes) can carry a subscription. Legacy
808
+ // message semantics unchanged (the `seq` field on output is additive).
809
+ const terminalModel = createTerminalModel({ cols: host.cols, rows: host.rows, scrollback: 2000 });
810
+ /** @type {Map<import("node:net").Socket, ReturnType<typeof createTerminalSubscription>>} */
811
+ const terminalSubscriptions = new Map();
812
+ // Control lifecycle runtime (issue #91 phase 5). Created after the terminal
813
+ // model (actions close over it) and BEFORE the endpoint binds, so the durable
814
+ // journal is loaded from disk before any client can reconcile. The action
815
+ // closures read `child`/`host` at call time (both are let-bound above).
816
+ const control = createControlRuntime({
817
+ viewId: config.viewId,
818
+ root: config.root,
819
+ instanceId: config.instanceId,
820
+ send,
821
+ diag,
822
+ actions: {
823
+ childReady: () => Boolean(child),
824
+ writeInput: (data) => {
825
+ child.write(data);
826
+ },
827
+ hostCols: () => host.cols,
828
+ hostRows: () => host.rows,
829
+ applyResize: (cols, rows) => {
830
+ // The runtime pre-checks childReady (host_starting reply otherwise),
831
+ // so this only runs against a live child — the starting window's
832
+ // cachedResize path belongs exclusively to the legacy switch below.
833
+ // Paired step (CR R1 advisory): model reflows only when the real
834
+ // PTY resize succeeded; cachedResize cleared either way — a failed
835
+ // resize is not retried against a exiting child.
836
+ resizeChildAndModel(child, terminalModel, cols, rows);
837
+ notifyChildResize(childPid);
838
+ cachedResize = null;
839
+ ownedUpdate((cur) => ({ ...cur, cols, rows }));
840
+ },
841
+ currentDims: () => ({ cols: terminalModel.cols, rows: terminalModel.rows }),
842
+ applyInterrupt: () => {
843
+ if (child) child.write("\x1b");
844
+ },
845
+ applyTerminate: () => {
846
+ // Single exit path: finishHost carries the SIGTERM→4s→SIGKILL ladder,
847
+ // so a SIGTERM-immune child still terminates within a bounded window.
848
+ // The applied ack was already sent by the runtime BEFORE this hook.
849
+ finish("terminated", 0);
850
+ },
851
+ applyDetach: (s) => {
852
+ s.end();
853
+ },
854
+ hostRevision: () => host.revision ?? 0,
855
+ cursor: () => ({ lastSeq: terminalModel.lastSeq, cols: terminalModel.cols, rows: terminalModel.rows }),
856
+ stateStamp: () => readState(config.root, config.viewId)?.materializedRevision ?? null,
857
+ },
858
+ });
859
+
649
860
  // 3. Bind the per-instance endpoint. NO unlink: the path is unique to this
650
861
  // instance; an occupied path means someone else owns it.
651
862
  const listenOutcome = await new Promise((resolveListen) => {
@@ -656,8 +867,12 @@ async function ownedMain(config) {
656
867
  const probeSockets = new WeakSet();
657
868
  server = createServer((socket) => {
658
869
  clients.add(socket);
659
- socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty }) + "\n");
660
- broadcast({ type: "status", status: host });
870
+ terminalSubscriptions.set(
871
+ socket,
872
+ createTerminalSubscription({ model: terminalModel, send: (msg) => send(socket, msg), generation: GENERATION }),
873
+ );
874
+ socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty, generation: GENERATION }) + "\n");
875
+ broadcast({ type: "status", status: host, generation: GENERATION });
661
876
  let buffer = "";
662
877
  socket.on("data", (chunk) => {
663
878
  buffer += chunk.toString("utf8");
@@ -667,6 +882,9 @@ async function ownedMain(config) {
667
882
  });
668
883
  socket.on("close", () => {
669
884
  clients.delete(socket);
885
+ editorReporters.delete(socket);
886
+ terminalSubscriptions.delete(socket);
887
+ control.closeSocket(socket);
670
888
  if (probeSockets.has(socket)) return;
671
889
  // Merge into the live record (a stale closure spread here erases a
672
890
  // concurrent revoke — final review finding 2).
@@ -674,6 +892,9 @@ async function ownedMain(config) {
674
892
  });
675
893
  socket.on("error", () => {
676
894
  clients.delete(socket);
895
+ editorReporters.delete(socket);
896
+ terminalSubscriptions.delete(socket);
897
+ control.closeSocket(socket);
677
898
  if (probeSockets.has(socket)) return;
678
899
  ownedUpdate((cur) => ({ ...cur }));
679
900
  });
@@ -715,7 +936,7 @@ async function ownedMain(config) {
715
936
  return;
716
937
  }
717
938
  claimedRecord = true;
718
- broadcast({ type: "status", status: host });
939
+ broadcast({ type: "status", status: host, generation: GENERATION });
719
940
 
720
941
  // Test seam (issue #70 Task 8): a config-level delay between publishing the
721
942
  // runner identity and spawning the child creates a REAL starting window
@@ -737,6 +958,9 @@ async function ownedMain(config) {
737
958
  AGENT_BOARD_VIEW_ID: config.viewId,
738
959
  AGENT_BOARD_CHILD: "1",
739
960
  AGENT_BOARD_HOSTED: "pty",
961
+ // The endpoint this host actually bound: the child's editor-state reporter
962
+ // dials it instead of guessing the stable per-view address (issue #103).
963
+ AGENT_BOARD_CONTROL_SOCKET: socketPath,
740
964
  // Legacy names are exported too so older child extension builds still behave.
741
965
  AGENT_VIEW_ROOT: config.root,
742
966
  AGENT_VIEW_VIEW_ID: config.viewId,
@@ -754,19 +978,32 @@ async function ownedMain(config) {
754
978
  } catch (err) {
755
979
  const message = err instanceof Error ? err.message : String(err);
756
980
  diag("child_spawn_failed", message);
757
- markRowFailed(config.root, config.viewId, `PTY host failed: ${message}`);
981
+ await markRowFailed(config.root, config.viewId, `PTY host failed: ${message}`);
758
982
  await finishHost("child_spawn_failed", 1);
759
983
  return;
760
984
  }
761
985
  childPid = child.pid ?? null;
762
986
  child.onData((data) => {
763
987
  screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
764
- broadcast({ type: "output", data });
988
+ const outputSeq = feedOutput(terminalModel, data);
989
+ // Per-socket output delivery — same contract as legacyMain above.
990
+ const outputLine = JSON.stringify({ type: "output", seq: outputSeq, data }) + "\n";
991
+ for (const [socket, sub] of terminalSubscriptions) {
992
+ if (sub.subscribed()) sub.onOutput(outputSeq, data);
993
+ // Same per-socket guard the legacy broadcast had: a synchronous throw
994
+ // from a dead socket must never escape into the uncaughtException
995
+ // crash path (whole-branch review finding).
996
+ else { try { socket.write(outputLine); } catch { /* socket 'error' handler cleans up */ } }
997
+ }
765
998
  });
766
999
  child.onExit((code) => {
767
1000
  childExited = true;
768
1001
  resolveChildExit?.();
769
1002
  exitCode = code ?? 0;
1003
+ // Terminate lifecycle (spec D4): a confirmed child exit is the observed
1004
+ // evidence for any enveloped terminate still pending — flush before the
1005
+ // finish teardown ends the client sockets.
1006
+ control.flushTerminateObservations(exitCode, false);
770
1007
  editorEmpty = null;
771
1008
  broadcast({ type: "editor_state", empty: null });
772
1009
  finish("child_exit", exitCode);
@@ -792,7 +1029,7 @@ async function ownedMain(config) {
792
1029
  await finishHost(revoked ? "host_start_revoked" : "owner_lost", 0);
793
1030
  return;
794
1031
  }
795
- broadcast({ type: "status", status: host });
1032
+ broadcast({ type: "status", status: host, generation: GENERATION });
796
1033
 
797
1034
  // A starting-window resize cannot be applied at spawn-return nor at first
798
1035
  // output: the child node process is still bootstrapping and a SIGWINCH
@@ -803,7 +1040,8 @@ async function ownedMain(config) {
803
1040
  if (cachedResize) {
804
1041
  const applyHeld = setTimeout(() => {
805
1042
  if (!cachedResize || !child || shutdownStarted) return;
806
- try { child.resize(cachedResize.cols, cachedResize.rows); } catch { /* best effort */ }
1043
+ // Paired step (CR R1 advisory): model follows the real PTY, never leads.
1044
+ resizeChildAndModel(child, terminalModel, cachedResize.cols, cachedResize.rows);
807
1045
  notifyChildResize(childPid);
808
1046
  cachedResize = null;
809
1047
  }, 500);
@@ -840,19 +1078,34 @@ async function ownedMain(config) {
840
1078
  if (!line.trim()) return;
841
1079
  let msg;
842
1080
  try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
1081
+ // Enveloped control commands take the phase-5 lifecycle path; everything
1082
+ // else (no commandId, or a non-control type carrying one — e.g.
1083
+ // subscribe_terminal with correlation metadata) falls through to the
1084
+ // legacy switch byte-identically.
1085
+ const envelope = validateCommandEnvelope(msg);
1086
+ if (envelope.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)) {
1087
+ control.handle(msg, socket, envelope);
1088
+ return;
1089
+ }
843
1090
  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") {
1091
+ case "hello": {
1092
+ // Probe and reporter sockets are bookkeeping-only: neither may flip
1093
+ // attachedEver nor keep attachedClients non-zero, or warm-host reclaim
1094
+ // never fires and hosts leak (issue #103 §C).
1095
+ const kind = classifyClientHello(msg);
1096
+ if (kind === "probe") {
850
1097
  socket.markProbe?.();
1098
+ } else if (kind === "editor-reporter") {
1099
+ clients.delete(socket);
1100
+ terminalSubscriptions.delete(socket);
1101
+ editorReporters.add(socket);
1102
+ ownedUpdate((cur) => ({ ...cur }));
851
1103
  } else {
852
1104
  ownedUpdate((cur) => ({ ...cur, attachedEver: true }));
853
1105
  }
854
- send(socket, { type: "hello", status: host, editorEmpty });
1106
+ send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
855
1107
  break;
1108
+ }
856
1109
  case "input": {
857
1110
  if (typeof msg.data !== "string") break;
858
1111
  if (typeof msg.requestId !== "string" || !msg.requestId) {
@@ -880,7 +1133,10 @@ async function ownedMain(config) {
880
1133
  const cols = clampInt(msg.cols, 20, 300, host.cols);
881
1134
  const rows = clampInt(msg.rows, 5, 120, host.rows);
882
1135
  if (child) {
883
- child.resize(cols, rows);
1136
+ // Paired step (CR R1 advisory): model reflows only when the real
1137
+ // PTY resize succeeded; cachedResize cleared either way — a failed
1138
+ // resize is not retried against a exiting child.
1139
+ resizeChildAndModel(child, terminalModel, cols, rows);
884
1140
  notifyChildResize(childPid);
885
1141
  cachedResize = null;
886
1142
  } else {
@@ -892,6 +1148,9 @@ async function ownedMain(config) {
892
1148
  case "interrupt":
893
1149
  if (child) child.write("\x1b");
894
1150
  break;
1151
+ case "subscribe_terminal":
1152
+ terminalSubscriptions.get(socket)?.handleMessage(msg);
1153
+ break;
895
1154
  case "terminate":
896
1155
  // Single exit path: finishHost carries the SIGTERM→4s→SIGKILL ladder,
897
1156
  // so a SIGTERM-immune child still terminates within a bounded window.
@@ -901,7 +1160,7 @@ async function ownedMain(config) {
901
1160
  socket.end();
902
1161
  break;
903
1162
  case "get_status":
904
- send(socket, { type: "status", status: host });
1163
+ send(socket, { type: "status", status: host, generation: GENERATION });
905
1164
  break;
906
1165
  case "editor_state": {
907
1166
  editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
@@ -998,6 +1257,342 @@ function clampInt(value, min, max, fallback) {
998
1257
  return Math.max(min, Math.min(max, Math.floor(n)));
999
1258
  }
1000
1259
 
1260
+ // ---------------------------------------------------------------------------
1261
+ // Control-command lifecycle runtime (issue #91 phase 5, spec D4). Pure
1262
+ // decision logic lives in src/core/control-protocol.mjs; this is the runner's
1263
+ // serial side-effect shell: per-socket seq tracking, the durable command
1264
+ // journal, resize latest-wins routing, and staged ack emission. Messages
1265
+ // WITHOUT an envelope (no commandId) never reach this code — the legacy
1266
+ // switch keeps byte-identical behavior.
1267
+ // ---------------------------------------------------------------------------
1268
+
1269
+ /** Load journal records, dropping torn tail lines from a crash mid-append. */
1270
+ function loadJournalRecords(path) {
1271
+ try {
1272
+ const records = [];
1273
+ for (const line of readFileSync(path, "utf8").split("\n")) {
1274
+ if (!line.trim()) continue;
1275
+ try {
1276
+ records.push(JSON.parse(line));
1277
+ } catch {
1278
+ /* torn tail: the accepted/applied truth for that line is unknown —
1279
+ dropping it can only widen the accepted_unknown set, never hide one */
1280
+ }
1281
+ }
1282
+ return records;
1283
+ } catch {
1284
+ return [];
1285
+ }
1286
+ }
1287
+
1288
+ /**
1289
+ * One control runtime per main. `actions` bundles the main-specific side
1290
+ * effects (child shape differs between the legacy and owned lifecycles); the
1291
+ * decision order here is shared and binding:
1292
+ * envelope validity → instance fence → seq monotonicity → per-type dispatch.
1293
+ * @param {{
1294
+ * viewId: string, root: string, instanceId: string|null,
1295
+ * send: (socket: import("node:net").Socket, msg: object) => void,
1296
+ * diag: (code: string, message: string, details?: object) => void,
1297
+ * actions: {
1298
+ * childReady(): boolean,
1299
+ * writeInput(data: string): void,
1300
+ * hostCols(): number, hostRows(): number,
1301
+ * applyResize(cols: number, rows: number): void, // runtime pre-checks childReady — never called without a child
1302
+ * currentDims(): { cols: number, rows: number },
1303
+ * applyInterrupt(): void,
1304
+ * applyTerminate(): void,
1305
+ * applyDetach(socket: import("node:net").Socket): void,
1306
+ * hostRevision(): number,
1307
+ * cursor(): { lastSeq: number, cols: number, rows: number },
1308
+ * stateStamp(): number | null,
1309
+ * },
1310
+ * }} opts
1311
+ */
1312
+ function createControlRuntime({ viewId, root, instanceId, send, diag, actions }) {
1313
+ /** Per-connection seq watermark (ordering aid only — never dedup). */
1314
+ const connectionSeq = new Map();
1315
+ const journalPath = P.controlJournalPath(root, viewId);
1316
+ const journalRecords = loadJournalRecords(journalPath);
1317
+ /** O(1) journal dedup — rebuilt after GC so it mirrors the retained records. */
1318
+ const journalIds = new Set(journalRecords.map((r) => r.commandId));
1319
+ const resizes = createResizeTracker();
1320
+ /** commandId → socket awaiting the resize ack (superseded/applied routing). */
1321
+ const pendingResizeSockets = new Map();
1322
+ /** commandId → socket that requested terminate (observed routing). */
1323
+ const pendingTerminates = new Map();
1324
+
1325
+ const rawAppend = (record) => {
1326
+ appendFileSync(journalPath, JSON.stringify(record) + "\n");
1327
+ };
1328
+ const maybeGcJournal = () => {
1329
+ if (journalRecords.length <= JOURNAL_GC_TRIGGER) return;
1330
+ const kept = journalGc(journalRecords, JOURNAL_KEEP_DEFAULT);
1331
+ journalRecords.length = 0;
1332
+ journalRecords.push(...kept);
1333
+ journalIds.clear();
1334
+ for (const record of kept) journalIds.add(record.commandId);
1335
+ try {
1336
+ writeFileSync(journalPath, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
1337
+ } catch (err) {
1338
+ diag("control_journal_rewrite_failed", err instanceof Error ? err.message : String(err));
1339
+ }
1340
+ };
1341
+ /** @returns {boolean} false when the append failed — the command is NOT accepted. */
1342
+ const journalAccept = (commandId, command, now) => {
1343
+ // Ruling (b): a journaled commandId is NEVER re-appended — the dedup
1344
+ // check is the caller's, and this guard is the last line of defense.
1345
+ if (journalIds.has(commandId)) return true;
1346
+ const record = { kind: "accepted", commandId, command, acceptedAt: now };
1347
+ journalAppendRecord(journalRecords, record); // shape validation; throws on a runner bug
1348
+ try {
1349
+ rawAppend(record);
1350
+ } catch (err) {
1351
+ // Nothing was pushed into journalRecords for this record (pure
1352
+ // validate + manual push on success), so there is nothing to unwind —
1353
+ // the command is simply not accepted.
1354
+ diag("control_journal_write_failed", err instanceof Error ? err.message : String(err), { commandId });
1355
+ return false;
1356
+ }
1357
+ journalRecords.push(record);
1358
+ journalIds.add(commandId);
1359
+ maybeGcJournal();
1360
+ return true;
1361
+ };
1362
+ const journalApply = (commandId, now) => {
1363
+ const record = { kind: "applied", commandId, appliedAt: now };
1364
+ try {
1365
+ rawAppend(record);
1366
+ } catch (err) {
1367
+ diag("control_journal_write_failed", err instanceof Error ? err.message : String(err), { commandId });
1368
+ return;
1369
+ }
1370
+ journalRecords.push(record);
1371
+ maybeGcJournal();
1372
+ };
1373
+
1374
+ /** Socket-death semantics for every ack: a dead client must never turn a
1375
+ * control reply into a runner crash (same invariant as the broadcast guard). */
1376
+ const reply = (socket, msg) => {
1377
+ try {
1378
+ send(socket, msg);
1379
+ } catch {
1380
+ /* socket 'error'/'close' handler owns cleanup */
1381
+ }
1382
+ };
1383
+
1384
+ const flushTerminateObservations = (exitCode, runnerFinalizing) => {
1385
+ for (const [commandId, termSocket] of pendingTerminates) {
1386
+ reply(termSocket, {
1387
+ type: "cmd_ack",
1388
+ commandId,
1389
+ stage: "observed",
1390
+ ...(exitCode != null ? { exitConfirmed: true, exitCode } : {}),
1391
+ ...(runnerFinalizing ? { runnerFinalizing: true } : {}),
1392
+ });
1393
+ }
1394
+ pendingTerminates.clear();
1395
+ };
1396
+
1397
+ /**
1398
+ * Dispatch an enveloped control command. Caller guarantees
1399
+ * `env.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)`.
1400
+ *
1401
+ * Crash containment: a throwing action (e.g. child.write on a pty that died
1402
+ * mid-command) must degrade to an error reply + diagnostic, never escape
1403
+ * into the runner's uncaughtException path — the legacy switch predates this
1404
+ * invariant, but new protocol code must not inherit that crash class.
1405
+ */
1406
+ const handle = (msg, socket, env) => {
1407
+ // (d) errors are only meaningful for enveloped messages — this function is
1408
+ // only entered for enveloped control types, so every error below is legal.
1409
+ if (env.errors.length > 0) {
1410
+ reply(socket, { type: "error", code: "envelope_invalid", commandId: msg.commandId, errors: env.errors });
1411
+ return;
1412
+ }
1413
+ try {
1414
+ handleChecked(msg, socket, env);
1415
+ } catch (err) {
1416
+ diag("control_command_failed", err instanceof Error ? err.message : String(err), { commandId: msg.commandId, type: msg.type });
1417
+ reply(socket, { type: "error", code: "command_failed", commandId: msg.commandId });
1418
+ }
1419
+ };
1420
+
1421
+ const handleChecked = (msg, socket, env) => {
1422
+ // (a) instance fencing, reject-with-current: a client naming a DIFFERENT
1423
+ // instance is talking across a runner replacement (stale socket race);
1424
+ // silently applying its commands would resurrect phantom controls — the
1425
+ // exact class #70 fencing exists to prevent. The error carries the current
1426
+ // instanceId so a well-behaved client re-hellos and recovers. Legacy-mode
1427
+ // hosts (instanceId null) fence EVERY envelope: an envelope claims an
1428
+ // instance a legacy-mode runner never had.
1429
+ if (msg.instanceId !== instanceId) {
1430
+ diag("instance_mismatch", "control command carries a foreign instanceId", {
1431
+ commandId: msg.commandId,
1432
+ got: msg.instanceId,
1433
+ current: instanceId,
1434
+ });
1435
+ reply(socket, { type: "error", code: "instance_mismatch", commandId: msg.commandId, currentInstanceId: instanceId });
1436
+ return;
1437
+ }
1438
+ // (c) per-connection monotonic seq: an ordering aid. Repeats/regressions
1439
+ // are dropped with a diagnostic — NOT a dedup signal (dedup keys on
1440
+ // commandId only); a legitimate re-send after reconnect uses a new socket.
1441
+ const lastSeq = connectionSeq.get(socket) ?? 0;
1442
+ const seqVerdict = checkSeq(lastSeq, msg.seq);
1443
+ if (!seqVerdict.ok) {
1444
+ diag("seq_out_of_order", "control command dropped for non-monotonic seq", {
1445
+ commandId: msg.commandId,
1446
+ seq: msg.seq,
1447
+ lastSeq,
1448
+ });
1449
+ return;
1450
+ }
1451
+ connectionSeq.set(socket, msg.seq);
1452
+
1453
+ switch (msg.type) {
1454
+ case "input": {
1455
+ if (typeof msg.data !== "string") {
1456
+ reply(socket, { type: "error", code: "envelope_invalid", commandId: msg.commandId, errors: ["input_data_missing"] });
1457
+ return;
1458
+ }
1459
+ if (msg.durable !== true) {
1460
+ // Keystroke with a correlation id: fire-and-forget preserved — no
1461
+ // ack, no journal (journal writes must never sit on the keystroke
1462
+ // path). The envelope is correlation/debugging metadata only.
1463
+ if (actions.childReady()) actions.writeInput(msg.data);
1464
+ return;
1465
+ }
1466
+ // Durable follow-up: accepted (journaled) → applied (written). A
1467
+ // re-send of a journaled commandId returns the cached final stage —
1468
+ // never re-appended (ruling b), never re-written (commandId dedup).
1469
+ if (journalIds.has(msg.commandId)) {
1470
+ const wasApplied = journalRecords.some((r) => r.kind === "applied" && r.commandId === msg.commandId);
1471
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: wasApplied ? "applied" : "accepted", durable: true });
1472
+ return;
1473
+ }
1474
+ if (!actions.childReady()) {
1475
+ // Starting window: nothing accepted (matches the requestId path's
1476
+ // host_starting contract; the service retries with its own policy).
1477
+ reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
1478
+ return;
1479
+ }
1480
+ if (!journalAccept(msg.commandId, msg.data, Date.now())) {
1481
+ // accepted means JOURNALED — without the journal entry the stage
1482
+ // would be a lie, so the command is refused, not silently applied.
1483
+ reply(socket, { type: "error", code: "journal_unavailable", commandId: msg.commandId });
1484
+ return;
1485
+ }
1486
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "accepted", durable: true });
1487
+ try {
1488
+ actions.writeInput(msg.data);
1489
+ } catch (err) {
1490
+ // §10 honesty: accepted but the write failed — the command stays
1491
+ // accepted_unknown; reconcile reports it; never auto-replayed.
1492
+ diag("input_apply_failed", err instanceof Error ? err.message : String(err), { commandId: msg.commandId });
1493
+ return;
1494
+ }
1495
+ journalApply(msg.commandId, Date.now());
1496
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied" });
1497
+ return;
1498
+ }
1499
+ case "resize": {
1500
+ // Uniform starting-window contract with input: without a child there
1501
+ // is nothing to resize and no honest applied value — the client
1502
+ // retries (new commandId). This also keeps latest-wins tracking off
1503
+ // the books for commands that never applied.
1504
+ if (!actions.childReady()) {
1505
+ reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
1506
+ return;
1507
+ }
1508
+ const cols = clampInt(msg.cols, 20, 300, actions.hostCols());
1509
+ const rows = clampInt(msg.rows, 5, 120, actions.hostRows());
1510
+ const tracked = resizes.track({ commandId: msg.commandId, clientId: msg.clientId, cols, rows });
1511
+ for (const sup of tracked.superseded) {
1512
+ const oldSocket = pendingResizeSockets.get(sup.commandId) ?? socket;
1513
+ pendingResizeSockets.delete(sup.commandId);
1514
+ reply(oldSocket, { type: "cmd_ack", commandId: sup.commandId, stage: "superseded", byCommandId: sup.byCommandId });
1515
+ }
1516
+ if (tracked.duplicate) {
1517
+ // Same commandId re-request: applyResize is synchronous, so a
1518
+ // duplicate always finds the command already applied — return the
1519
+ // cached result. (CR R1 advisory: the pendingResizeSockets entry is
1520
+ // deliberately NOT set here — the original resolved synchronously,
1521
+ // so a re-pointed entry would be a stale leak.)
1522
+ const cached = resizes.resultFor(msg.commandId);
1523
+ if (cached) reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", cols: cached.cols, rows: cached.rows });
1524
+ return;
1525
+ }
1526
+ pendingResizeSockets.set(msg.commandId, socket);
1527
+ actions.applyResize(cols, rows);
1528
+ // The paired resize step keeps the model mirroring the REAL PTY
1529
+ // (reflow only on successful child.resize), so the model's dims are
1530
+ // the honest applied value — even when the PTY kept its old geometry.
1531
+ const dims = actions.currentDims();
1532
+ resizes.applied(msg.commandId, dims.cols, dims.rows);
1533
+ pendingResizeSockets.delete(msg.commandId);
1534
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", cols: dims.cols, rows: dims.rows });
1535
+ return;
1536
+ }
1537
+ case "interrupt": {
1538
+ // Starting-window honesty (CR R1 blocking): applyInterrupt no-ops
1539
+ // without a child, so an unconditional applied ack would be a lie —
1540
+ // same contract as input/resize. The client consumes host_starting
1541
+ // as a cmdAck error (no retry chain: interrupt is transient and
1542
+ // user-timed; a re-send carries a fresh commandId).
1543
+ if (!actions.childReady()) {
1544
+ reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
1545
+ return;
1546
+ }
1547
+ actions.applyInterrupt();
1548
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied" });
1549
+ return;
1550
+ }
1551
+ case "terminate": {
1552
+ // Idempotent: a repeat while the termination is in flight returns the
1553
+ // current stage instead of claiming a fresh start.
1554
+ if (pendingTerminates.has(msg.commandId)) {
1555
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "termination_started" });
1556
+ return;
1557
+ }
1558
+ pendingTerminates.set(msg.commandId, socket);
1559
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "termination_started" });
1560
+ actions.applyTerminate();
1561
+ return;
1562
+ }
1563
+ case "detach": {
1564
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "detach_accepted" });
1565
+ actions.applyDetach(socket);
1566
+ return;
1567
+ }
1568
+ case "reconcile": {
1569
+ reply(socket, {
1570
+ type: "reconcile_result",
1571
+ commandId: msg.commandId,
1572
+ generation: GENERATION,
1573
+ hostRevision: actions.hostRevision(),
1574
+ terminalCursor: actions.cursor(),
1575
+ stateMaterializedRevision: actions.stateStamp(),
1576
+ unresolved: journalUnresolved(journalRecords).map((u) => ({ ...u, status: "accepted_unknown" })),
1577
+ });
1578
+ return;
1579
+ }
1580
+ }
1581
+ };
1582
+
1583
+ /** Socket teardown: drop the seq watermark and any ack-routing entries. */
1584
+ const closeSocket = (socket) => {
1585
+ connectionSeq.delete(socket);
1586
+ for (const [commandId, sock] of pendingResizeSockets) if (sock === socket) pendingResizeSockets.delete(commandId);
1587
+ for (const [commandId, sock] of pendingTerminates) if (sock === socket) pendingTerminates.delete(commandId);
1588
+ };
1589
+
1590
+ // (completeHeldResize was removed as dead code: the starting-window resize
1591
+ // path rejects with host_starting instead of holding, and applyResize is
1592
+ // synchronous — there is no held-resize completion path. Task 2 review P2-C.)
1593
+ return { handle, closeSocket, flushTerminateObservations, journalIds };
1594
+ }
1595
+
1001
1596
  /** POSIX process start token — /proc/<pid>/stat field 22 (starttime), stable
1002
1597
  * across exec(2). Recorded in published identities so service-side recovery
1003
1598
  * (issue #70 Task 11) can tell an owned-live pid from a reused one; null on
@@ -1018,34 +1613,66 @@ function captureStartToken(pid) {
1018
1613
  }
1019
1614
 
1020
1615
 
1021
- function markRowFailed(root, viewId, message) {
1022
- const now = Date.now();
1023
- const state = readState(root, viewId) ?? {
1024
- version: 1,
1616
+ /**
1617
+ * Finalize the view row as failed when the PTY host itself fails (child spawn
1618
+ * failure). Routed through the View State Coordinator as a fenced
1619
+ * `host_run_failed` command (issue #91, PR #1 residual risk #2): a manually
1620
+ * completed row rejects with manual_fence and stays completed, and a row
1621
+ * re-pointed to a newer run rejects with stale_run.
1622
+ *
1623
+ * The runId is sourced from the row's current state.json (null = the row had
1624
+ * no run) so the coordinator's stale_run guard stays effective; the
1625
+ * coordinator cannot enforce this from its side because it has no visibility
1626
+ * into what the host knows.
1627
+ *
1628
+ * Never throws: every client outcome is handled so the crash path cannot hang.
1629
+ * - applied → done (journaled + materialized by the coordinator).
1630
+ * - coordinator_disabled → legacy direct write (documented escape hatch).
1631
+ * - decided rejections (manual_fence / stale_run / no_change) → info
1632
+ * diagnostic; the coordinator's verdict governs.
1633
+ * - everything else (timeout / connection reset / unavailable) → warn
1634
+ * diagnostic: the outcome is unknown or the row could not be marked — the
1635
+ * runner still shuts down.
1636
+ * @param {string} root
1637
+ * @param {string} viewId
1638
+ * @param {string} message
1639
+ */
1640
+ async function markRowFailed(root, viewId, message) {
1641
+ const knownRunId = readState(root, viewId)?.currentRunId ?? null;
1642
+ const result = await sendStateCommand(root, {
1643
+ type: "state_command",
1025
1644
  viewId,
1026
- currentRunId: null,
1027
- semanticState: "queued",
1028
- processState: "exited",
1029
- summary: "Queued",
1030
- lastActivityAt: now,
1031
- updatedAt: now,
1032
- needsInput: false,
1033
- hasError: false,
1034
- latestAssistantPreview: "",
1035
- latestTool: null,
1036
- question: null,
1037
- pendingQuestions: [],
1038
- error: null,
1039
- };
1040
- state.semanticState = "failed";
1041
- state.processState = "exited";
1042
- state.summary = message;
1043
- state.hasError = true;
1044
- state.needsInput = false;
1045
- state.error = message;
1046
- state.updatedAt = now;
1047
- state.lastActivityAt = now;
1048
- writeState(root, state);
1645
+ runId: knownRunId,
1646
+ source: "pty-runner",
1647
+ kind: "host_run_failed",
1648
+ payload: { error: message },
1649
+ });
1650
+ if (result.status === "applied") return;
1651
+ if (result.reason === "coordinator_disabled") {
1652
+ markRowFailedDirect(root, viewId, message);
1653
+ return;
1654
+ }
1655
+ if (result.reason === "manual_fence" || result.reason === "stale_run" || result.reason === "no_change") {
1656
+ try {
1657
+ appendDiagnostic(root, viewId, {
1658
+ source: "runner",
1659
+ level: "info",
1660
+ code: "host_run_failed_skipped",
1661
+ message: `Host failure not applied to the row (${result.reason})`,
1662
+ details: { reason: result.reason },
1663
+ });
1664
+ } catch { /* best effort */ }
1665
+ return;
1666
+ }
1667
+ try {
1668
+ appendDiagnostic(root, viewId, {
1669
+ source: "runner",
1670
+ level: "warn",
1671
+ code: "host_run_failed_ambiguous",
1672
+ message: `Host failure outcome unknown (${result.reason}); the row may not reflect the failed host`,
1673
+ details: { reason: result.reason },
1674
+ });
1675
+ } catch { /* best effort */ }
1049
1676
  }
1050
1677
 
1051
1678
  function failEarly(message) {