@zhuxixi/pi-agent-board 0.7.0 → 0.9.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 (70) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +16 -12
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/RELEASE.md +148 -0
  6. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  7. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  8. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  9. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  10. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  11. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  12. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  13. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  14. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  15. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  16. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  17. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  18. package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +25 -0
  19. package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +393 -0
  20. package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +51 -0
  21. package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +58 -0
  22. package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +698 -0
  23. package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +605 -0
  24. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  25. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  26. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  27. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  28. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  29. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  30. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  31. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  32. package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +43 -0
  33. package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +95 -0
  34. package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +63 -0
  35. package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +57 -0
  36. package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +98 -0
  37. package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +190 -0
  38. package/package.json +3 -2
  39. package/runner/job-runner.mjs +8 -7
  40. package/runner/pty-runner.mjs +633 -31
  41. package/runner/state-coordinator.mjs +43 -17
  42. package/runner/state-runner.mjs +6 -5
  43. package/scripts/release.mjs +446 -0
  44. package/scripts/run-perf-gate.mjs +40 -0
  45. package/src/commands/agent-board.ts +8 -8
  46. package/src/commands/attach-flow.ts +5 -5
  47. package/src/core/control-protocol.mjs +482 -0
  48. package/src/core/editor-state-reporter.mjs +11 -1
  49. package/src/core/foreground-preview-cache.mjs +117 -0
  50. package/src/core/host-protocol.mjs +62 -0
  51. package/src/core/launch-options.mjs +37 -0
  52. package/src/core/locks.mjs +68 -14
  53. package/src/core/paths.mjs +35 -3
  54. package/src/core/pid.mjs +32 -1
  55. package/src/core/pty-attach-jiggle-controller.mjs +27 -3
  56. package/src/core/pty-attach-reconnect.mjs +13 -6
  57. package/src/core/pty-attach-render.mjs +20 -0
  58. package/src/core/state-commands.mjs +88 -6
  59. package/src/core/status-consistency.mjs +98 -0
  60. package/src/core/store.mjs +59 -13
  61. package/src/core/terminal-attach-client.mjs +809 -0
  62. package/src/core/terminal-attach-protocol.mjs +252 -0
  63. package/src/core/terminal-model.mjs +222 -0
  64. package/src/core/terminal-query-sequences.mjs +132 -0
  65. package/src/core/terminal-snapshot.mjs +440 -0
  66. package/src/index.ts +12 -4
  67. package/src/runtime/service.mjs +284 -16
  68. package/src/ui/dashboard.ts +61 -41
  69. package/src/ui/pty-attach.ts +318 -72
  70. 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,9 +22,22 @@ 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";
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";
25
38
  import { readHost, readState, updateOwnedHost, writeHost } from "../src/core/store.mjs";
26
39
  import { sendStateCommand } from "../src/core/coordinator-client.mjs";
40
+ import { classifyClientHello, helloBookkeeping } from "../src/core/host-protocol.mjs";
27
41
  import { markRowFailedDirect } from "./pty-runner-legacy.mjs";
28
42
  import { ensureNodePtySpawnHelperExecutable } from "../src/core/pty-support.mjs";
29
43
 
@@ -42,6 +56,14 @@ const HEARTBEAT_MS = 1000;
42
56
  const HOST_ACK_DEDUP_MAX = 1000;
43
57
  /** Max time an owned runner waits to take the per-view host-start lease (issue #70). */
44
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;
45
67
  /** How much of the screen.log tail to scan when attributing an abnormal child
46
68
  * exit (issue #90). Tail-only: the log can be 100MB+. */
47
69
  const EXIT_LOG_TAIL_BYTES = 8_192;
@@ -119,6 +141,8 @@ function legacyMain(config) {
119
141
 
120
142
  /** @type {Set<import("node:net").Socket>} */
121
143
  const clients = new Set();
144
+ /** Resident editor-state reporters: connected but never "attached" (#103). */
145
+ const editorReporters = new Set();
122
146
  let childPid = null;
123
147
  let child = null;
124
148
  let exitCode = null;
@@ -155,7 +179,57 @@ function legacyMain(config) {
155
179
  rows: config.rows || 36,
156
180
  attachedClients: 0,
157
181
  attachedEver: false,
182
+ revision: 0,
158
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
+ });
159
233
  /** Persist host.json. A transient failure (e.g. Windows rename EPERM racing a
160
234
  * reader) must degrade, not kill the host: record a diagnostic and let the
161
235
  * next heartbeat tick retry. Socket protocol is the attach main channel, so
@@ -179,9 +253,9 @@ function legacyMain(config) {
179
253
  for (const c of clients) c.write(line);
180
254
  };
181
255
  const update = (patch = {}) => {
182
- host = { ...host, ...patch, lastSeenAt: Date.now(), attachedClients: clients.size };
256
+ host = { ...host, ...patch, revision: (host.revision ?? 0) + 1, lastSeenAt: Date.now(), attachedClients: clients.size };
183
257
  persist();
184
- broadcast({ type: "status", status: host });
258
+ broadcast({ type: "status", status: host, generation: GENERATION });
185
259
  };
186
260
  persist();
187
261
 
@@ -224,6 +298,9 @@ function legacyMain(config) {
224
298
  AGENT_BOARD_VIEW_ID: config.viewId,
225
299
  AGENT_BOARD_CHILD: "1",
226
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,
227
304
  // Legacy names are exported too so older child extension builds still behave.
228
305
  AGENT_VIEW_ROOT: config.root,
229
306
  AGENT_VIEW_VIEW_ID: config.viewId,
@@ -254,12 +331,27 @@ function legacyMain(config) {
254
331
 
255
332
  child.onData((data) => {
256
333
  screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
257
- 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
+ }
258
347
  });
259
348
  child.onExit((code) => {
260
349
  childExited = true;
261
350
  resolveChildExit?.();
262
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);
263
355
  // After a crash the handler already persisted "failed" and broadcast
264
356
  // exit; this callback must not overwrite that state.
265
357
  if (!crashed) {
@@ -281,8 +373,11 @@ function legacyMain(config) {
281
373
  let server;
282
374
  server = createServer((socket) => {
283
375
  clients.add(socket);
284
- update({ attachedEver: true });
285
- 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");
286
381
  let buffer = "";
287
382
  socket.on("data", (chunk) => {
288
383
  buffer += chunk.toString("utf8");
@@ -292,10 +387,16 @@ function legacyMain(config) {
292
387
  });
293
388
  socket.on("close", () => {
294
389
  clients.delete(socket);
390
+ editorReporters.delete(socket);
391
+ terminalSubscriptions.delete(socket);
392
+ control.closeSocket(socket);
295
393
  update();
296
394
  });
297
395
  socket.on("error", () => {
298
396
  clients.delete(socket);
397
+ editorReporters.delete(socket);
398
+ terminalSubscriptions.delete(socket);
399
+ control.closeSocket(socket);
299
400
  update();
300
401
  });
301
402
  });
@@ -309,23 +410,54 @@ function legacyMain(config) {
309
410
  if (!line.trim()) return;
310
411
  let msg;
311
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
+ }
312
421
  switch (msg.type) {
313
- case "hello":
314
- 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 / #130): probes are read-only and transient,
425
+ // the editor reporter is resident. Both leave `clients` — the sole
426
+ // source of `attachedClients` — while their socket stays writable so
427
+ // probe replies and editor_state keep flowing. The policy lives in
428
+ // `helloBookkeeping`, so this path and the owned one cannot drift.
429
+ const book = helloBookkeeping(classifyClientHello(msg));
430
+ if (!book.keepInClients) {
431
+ clients.delete(socket);
432
+ terminalSubscriptions.delete(socket);
433
+ }
434
+ if (book.registerReporter) editorReporters.add(socket);
435
+ // A real client writes via flipAttachedEver alone (hence the `else
436
+ // if`); `persist` is the write flag for rows that write as-is (reporter).
437
+ if (book.flipAttachedEver) update({ attachedEver: true });
438
+ else if (book.persist) update();
439
+ send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
315
440
  break;
441
+ }
316
442
  case "input":
317
443
  if (typeof msg.data === "string") child.write(msg.data);
318
444
  break;
319
445
  case "resize": {
320
446
  const cols = clampInt(msg.cols, 20, 300, host.cols);
321
447
  const rows = clampInt(msg.rows, 5, 120, host.rows);
322
- child.resize(cols, rows);
448
+ // Paired step (CR R1 advisory): model reflows only when the real PTY
449
+ // resize succeeded. host.cols/rows keep recording the intended size
450
+ // (new-client clamp baseline), deliberately outside the guard.
451
+ resizeChildAndModel(child, terminalModel, cols, rows);
323
452
  update({ cols, rows });
324
453
  break;
325
454
  }
326
455
  case "interrupt":
327
456
  child.write("\x1b");
328
457
  break;
458
+ case "subscribe_terminal":
459
+ terminalSubscriptions.get(socket)?.handleMessage(msg);
460
+ break;
329
461
  case "terminate": {
330
462
  killChild(child, childPid, "SIGTERM");
331
463
  setTimeout(() => killChild(child, childPid, "SIGKILL"), 4000).unref?.();
@@ -335,7 +467,7 @@ function legacyMain(config) {
335
467
  socket.end();
336
468
  break;
337
469
  case "get_status":
338
- send(socket, { type: "status", status: host });
470
+ send(socket, { type: "status", status: host, generation: GENERATION });
339
471
  break;
340
472
  case "editor_state": {
341
473
  editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
@@ -370,11 +502,19 @@ function legacyMain(config) {
370
502
  if (requestedExitCode !== null) shutdownExitCode = requestedExitCode;
371
503
  if (shutdownStarted) return;
372
504
  shutdownStarted = true;
505
+ // Terminate lifecycle (spec D4): pending enveloped terminates get the
506
+ // runner-finalizing evidence before sockets end (exit-confirm flush in
507
+ // child.onExit covers the natural path; this covers deliberate shutdown).
508
+ control.flushTerminateObservations(null, true);
373
509
  try { server?.close(); } catch {}
374
510
  try { if (existsSync(socketPath)) unlinkSync(socketPath); } catch {}
375
511
  for (const client of clients) {
376
512
  try { client.end(); } catch {}
377
513
  }
514
+ for (const reporter of editorReporters) {
515
+ try { reporter.end(); } catch { /* best effort */ }
516
+ }
517
+ editorReporters.clear();
378
518
  killChild(child, childPid, "SIGTERM");
379
519
  if (!(await waitForChildExit(4000)) && !childExited) {
380
520
  killChild(child, childPid, "SIGKILL");
@@ -415,6 +555,8 @@ async function ownedMain(config) {
415
555
 
416
556
  /** @type {Set<import("node:net").Socket>} */
417
557
  const clients = new Set();
558
+ /** Resident editor-state reporters: connected but never "attached" (#103). */
559
+ const editorReporters = new Set();
418
560
  let childPid = null;
419
561
  let child = null;
420
562
  let exitCode = null;
@@ -467,7 +609,10 @@ async function ownedMain(config) {
467
609
  const ownedUpdate = (mutate) => {
468
610
  const result = updateOwnedHost(config.root, config.viewId, config.instanceId, (cur) => {
469
611
  const next = mutate(cur);
470
- return { ...next, lastSeenAt: Date.now(), attachedClients: clients.size };
612
+ // Record revision (phase 5 reconcile baseline): every committed fenced
613
+ // write bumps it, heartbeats included — a client comparing revisions
614
+ // sees any host-record movement, which is exactly the contract.
615
+ return { ...next, revision: (cur.revision ?? 0) + 1, lastSeenAt: Date.now(), attachedClients: clients.size };
471
616
  });
472
617
  if (result.updated && result.host) host = result.host;
473
618
  return result;
@@ -514,6 +659,11 @@ async function ownedMain(config) {
514
659
  async function finishHost(reason, requestedExitCode = null) {
515
660
  if (shutdownStarted) return;
516
661
  shutdownStarted = true;
662
+ // Terminate lifecycle (spec D4): pending enveloped terminates get the
663
+ // runner-finalizing evidence before teardown ends sockets. The child-exit
664
+ // flush (exitConfirmed) normally wins the race; whichever fires first
665
+ // clears the pending set, so exactly one observed ack goes out.
666
+ control.flushTerminateObservations(null, true);
517
667
  if (heartbeatTimer) {
518
668
  clearInterval(heartbeatTimer);
519
669
  heartbeatTimer = null;
@@ -522,13 +672,17 @@ async function ownedMain(config) {
522
672
  // superseded owner (or a duplicate that never claimed) never writes.
523
673
  if (claimedRecord && reason !== "owner_lost" && isOwnerNow()) {
524
674
  ownedUpdate((cur) => ({ ...cur, state: "stopping", stopReason: reason }));
525
- broadcast({ type: "status", status: host });
675
+ broadcast({ type: "status", status: host, generation: GENERATION });
526
676
  }
527
677
  try { broadcast({ type: "exit", exitCode: requestedExitCode ?? exitCode ?? 0 }); } catch { /* best effort */ }
528
678
  for (const c of clients) {
529
679
  try { c.destroy(); } catch { /* best effort */ }
530
680
  }
531
681
  clients.clear();
682
+ for (const reporter of editorReporters) {
683
+ try { reporter.destroy(); } catch { /* best effort */ }
684
+ }
685
+ editorReporters.clear();
532
686
  // Bounded server close (1s): never let a stuck client block cleanup.
533
687
  await new Promise((resolve) => {
534
688
  if (!server) return resolve();
@@ -652,18 +806,79 @@ async function ownedMain(config) {
652
806
  }
653
807
  host = current;
654
808
 
809
+ // Canonical terminal model + per-socket snapshot subscriptions (issue #91
810
+ // phase 3). Created from the owned host record before the endpoint binds,
811
+ // so every connect (including probes) can carry a subscription. Legacy
812
+ // message semantics unchanged (the `seq` field on output is additive).
813
+ const terminalModel = createTerminalModel({ cols: host.cols, rows: host.rows, scrollback: 2000 });
814
+ /** @type {Map<import("node:net").Socket, ReturnType<typeof createTerminalSubscription>>} */
815
+ const terminalSubscriptions = new Map();
816
+ // Control lifecycle runtime (issue #91 phase 5). Created after the terminal
817
+ // model (actions close over it) and BEFORE the endpoint binds, so the durable
818
+ // journal is loaded from disk before any client can reconcile. The action
819
+ // closures read `child`/`host` at call time (both are let-bound above).
820
+ const control = createControlRuntime({
821
+ viewId: config.viewId,
822
+ root: config.root,
823
+ instanceId: config.instanceId,
824
+ send,
825
+ diag,
826
+ actions: {
827
+ childReady: () => Boolean(child),
828
+ writeInput: (data) => {
829
+ child.write(data);
830
+ },
831
+ hostCols: () => host.cols,
832
+ hostRows: () => host.rows,
833
+ applyResize: (cols, rows) => {
834
+ // The runtime pre-checks childReady (host_starting reply otherwise),
835
+ // so this only runs against a live child — the starting window's
836
+ // cachedResize path belongs exclusively to the legacy switch below.
837
+ // Paired step (CR R1 advisory): model reflows only when the real
838
+ // PTY resize succeeded; cachedResize cleared either way — a failed
839
+ // resize is not retried against a exiting child.
840
+ resizeChildAndModel(child, terminalModel, cols, rows);
841
+ notifyChildResize(childPid);
842
+ cachedResize = null;
843
+ ownedUpdate((cur) => ({ ...cur, cols, rows }));
844
+ },
845
+ currentDims: () => ({ cols: terminalModel.cols, rows: terminalModel.rows }),
846
+ applyInterrupt: () => {
847
+ if (child) child.write("\x1b");
848
+ },
849
+ applyTerminate: () => {
850
+ // Single exit path: finishHost carries the SIGTERM→4s→SIGKILL ladder,
851
+ // so a SIGTERM-immune child still terminates within a bounded window.
852
+ // The applied ack was already sent by the runtime BEFORE this hook.
853
+ finish("terminated", 0);
854
+ },
855
+ applyDetach: (s) => {
856
+ s.end();
857
+ },
858
+ hostRevision: () => host.revision ?? 0,
859
+ cursor: () => ({ lastSeq: terminalModel.lastSeq, cols: terminalModel.cols, rows: terminalModel.rows }),
860
+ stateStamp: () => readState(config.root, config.viewId)?.materializedRevision ?? null,
861
+ },
862
+ });
863
+
655
864
  // 3. Bind the per-instance endpoint. NO unlink: the path is unique to this
656
865
  // instance; an occupied path means someone else owns it.
657
866
  const listenOutcome = await new Promise((resolveListen) => {
658
867
  const onError = (err) => resolveListen({ ok: false, error: err });
659
868
  // Probe connections (clientId:"probe" hello) must not write host.json: the
660
869
  // attach resolver's 150ms probe loop would amplify fenced writes and flip
661
- // attachedEver with no client ever attached (CR round-1 finding 3).
870
+ // attachedEver with no client ever attached (CR round-1 finding 3). The
871
+ // "not counted" half is handled at hello time instead (#130): the socket
872
+ // leaves `clients` there, so this WeakSet only suppresses the write.
662
873
  const probeSockets = new WeakSet();
663
874
  server = createServer((socket) => {
664
875
  clients.add(socket);
665
- socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty }) + "\n");
666
- broadcast({ type: "status", status: host });
876
+ terminalSubscriptions.set(
877
+ socket,
878
+ createTerminalSubscription({ model: terminalModel, send: (msg) => send(socket, msg), generation: GENERATION }),
879
+ );
880
+ socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty, generation: GENERATION }) + "\n");
881
+ broadcast({ type: "status", status: host, generation: GENERATION });
667
882
  let buffer = "";
668
883
  socket.on("data", (chunk) => {
669
884
  buffer += chunk.toString("utf8");
@@ -673,6 +888,9 @@ async function ownedMain(config) {
673
888
  });
674
889
  socket.on("close", () => {
675
890
  clients.delete(socket);
891
+ editorReporters.delete(socket);
892
+ terminalSubscriptions.delete(socket);
893
+ control.closeSocket(socket);
676
894
  if (probeSockets.has(socket)) return;
677
895
  // Merge into the live record (a stale closure spread here erases a
678
896
  // concurrent revoke — final review finding 2).
@@ -680,6 +898,9 @@ async function ownedMain(config) {
680
898
  });
681
899
  socket.on("error", () => {
682
900
  clients.delete(socket);
901
+ editorReporters.delete(socket);
902
+ terminalSubscriptions.delete(socket);
903
+ control.closeSocket(socket);
683
904
  if (probeSockets.has(socket)) return;
684
905
  ownedUpdate((cur) => ({ ...cur }));
685
906
  });
@@ -721,7 +942,7 @@ async function ownedMain(config) {
721
942
  return;
722
943
  }
723
944
  claimedRecord = true;
724
- broadcast({ type: "status", status: host });
945
+ broadcast({ type: "status", status: host, generation: GENERATION });
725
946
 
726
947
  // Test seam (issue #70 Task 8): a config-level delay between publishing the
727
948
  // runner identity and spawning the child creates a REAL starting window
@@ -743,6 +964,9 @@ async function ownedMain(config) {
743
964
  AGENT_BOARD_VIEW_ID: config.viewId,
744
965
  AGENT_BOARD_CHILD: "1",
745
966
  AGENT_BOARD_HOSTED: "pty",
967
+ // The endpoint this host actually bound: the child's editor-state reporter
968
+ // dials it instead of guessing the stable per-view address (issue #103).
969
+ AGENT_BOARD_CONTROL_SOCKET: socketPath,
746
970
  // Legacy names are exported too so older child extension builds still behave.
747
971
  AGENT_VIEW_ROOT: config.root,
748
972
  AGENT_VIEW_VIEW_ID: config.viewId,
@@ -767,12 +991,25 @@ async function ownedMain(config) {
767
991
  childPid = child.pid ?? null;
768
992
  child.onData((data) => {
769
993
  screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
770
- broadcast({ type: "output", data });
994
+ const outputSeq = feedOutput(terminalModel, data);
995
+ // Per-socket output delivery — same contract as legacyMain above.
996
+ const outputLine = JSON.stringify({ type: "output", seq: outputSeq, data }) + "\n";
997
+ for (const [socket, sub] of terminalSubscriptions) {
998
+ if (sub.subscribed()) sub.onOutput(outputSeq, data);
999
+ // Same per-socket guard the legacy broadcast had: a synchronous throw
1000
+ // from a dead socket must never escape into the uncaughtException
1001
+ // crash path (whole-branch review finding).
1002
+ else { try { socket.write(outputLine); } catch { /* socket 'error' handler cleans up */ } }
1003
+ }
771
1004
  });
772
1005
  child.onExit((code) => {
773
1006
  childExited = true;
774
1007
  resolveChildExit?.();
775
1008
  exitCode = code ?? 0;
1009
+ // Terminate lifecycle (spec D4): a confirmed child exit is the observed
1010
+ // evidence for any enveloped terminate still pending — flush before the
1011
+ // finish teardown ends the client sockets.
1012
+ control.flushTerminateObservations(exitCode, false);
776
1013
  editorEmpty = null;
777
1014
  broadcast({ type: "editor_state", empty: null });
778
1015
  finish("child_exit", exitCode);
@@ -798,7 +1035,7 @@ async function ownedMain(config) {
798
1035
  await finishHost(revoked ? "host_start_revoked" : "owner_lost", 0);
799
1036
  return;
800
1037
  }
801
- broadcast({ type: "status", status: host });
1038
+ broadcast({ type: "status", status: host, generation: GENERATION });
802
1039
 
803
1040
  // A starting-window resize cannot be applied at spawn-return nor at first
804
1041
  // output: the child node process is still bootstrapping and a SIGWINCH
@@ -809,7 +1046,8 @@ async function ownedMain(config) {
809
1046
  if (cachedResize) {
810
1047
  const applyHeld = setTimeout(() => {
811
1048
  if (!cachedResize || !child || shutdownStarted) return;
812
- try { child.resize(cachedResize.cols, cachedResize.rows); } catch { /* best effort */ }
1049
+ // Paired step (CR R1 advisory): model follows the real PTY, never leads.
1050
+ resizeChildAndModel(child, terminalModel, cachedResize.cols, cachedResize.rows);
813
1051
  notifyChildResize(childPid);
814
1052
  cachedResize = null;
815
1053
  }, 500);
@@ -846,19 +1084,41 @@ async function ownedMain(config) {
846
1084
  if (!line.trim()) return;
847
1085
  let msg;
848
1086
  try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
1087
+ // Enveloped control commands take the phase-5 lifecycle path; everything
1088
+ // else (no commandId, or a non-control type carrying one — e.g.
1089
+ // subscribe_terminal with correlation metadata) falls through to the
1090
+ // legacy switch byte-identically.
1091
+ const envelope = validateCommandEnvelope(msg);
1092
+ if (envelope.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)) {
1093
+ control.handle(msg, socket, envelope);
1094
+ return;
1095
+ }
849
1096
  switch (msg.type) {
850
- case "hello":
851
- // Probe handshakes (host-probe.mjs) are read-only: mark the socket so
852
- // close/error skip the merge write, and never flip attachedEver
853
- // (CR round-1 finding 3). Real clients record attachedEver here —
854
- // deferred from connect time, which cannot distinguish them yet.
855
- if (msg.clientId === "probe") {
856
- socket.markProbe?.();
857
- } else {
858
- ownedUpdate((cur) => ({ ...cur, attachedEver: true }));
1097
+ case "hello": {
1098
+ // Same bookkeeping contract as the legacy path (issue #103 §C /
1099
+ // #130): probes and the resident editor reporter are
1100
+ // bookkeeping-only — neither may flip attachedEver nor keep
1101
+ // attachedClients non-zero, or warm-host reclaim never fires and
1102
+ // hosts leak. The policy lives in `helloBookkeeping`.
1103
+ const book = helloBookkeeping(classifyClientHello(msg));
1104
+ if (!book.keepInClients) {
1105
+ clients.delete(socket);
1106
+ terminalSubscriptions.delete(socket);
859
1107
  }
860
- send(socket, { type: "hello", status: host, editorEmpty });
1108
+ if (book.registerReporter) editorReporters.add(socket);
1109
+ // The probe's close-time host.json refresh stays suppressed: the
1110
+ // resolver probes every HOST_PROBE_RETRY_MS, so flushing there
1111
+ // would amplify fenced writes (see the probeSockets comment at the
1112
+ // listen block). Being out of `clients` is what keeps the count
1113
+ // right — this only avoids a pointless write.
1114
+ if (book.suppressCloseWrite) socket.markProbe?.();
1115
+ // A real client writes via flipAttachedEver alone (hence the `else
1116
+ // if`); `persist` is the write flag for rows that write as-is (reporter).
1117
+ if (book.flipAttachedEver) ownedUpdate((cur) => ({ ...cur, attachedEver: true }));
1118
+ else if (book.persist) ownedUpdate((cur) => ({ ...cur }));
1119
+ send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
861
1120
  break;
1121
+ }
862
1122
  case "input": {
863
1123
  if (typeof msg.data !== "string") break;
864
1124
  if (typeof msg.requestId !== "string" || !msg.requestId) {
@@ -886,7 +1146,10 @@ async function ownedMain(config) {
886
1146
  const cols = clampInt(msg.cols, 20, 300, host.cols);
887
1147
  const rows = clampInt(msg.rows, 5, 120, host.rows);
888
1148
  if (child) {
889
- child.resize(cols, rows);
1149
+ // Paired step (CR R1 advisory): model reflows only when the real
1150
+ // PTY resize succeeded; cachedResize cleared either way — a failed
1151
+ // resize is not retried against a exiting child.
1152
+ resizeChildAndModel(child, terminalModel, cols, rows);
890
1153
  notifyChildResize(childPid);
891
1154
  cachedResize = null;
892
1155
  } else {
@@ -898,6 +1161,9 @@ async function ownedMain(config) {
898
1161
  case "interrupt":
899
1162
  if (child) child.write("\x1b");
900
1163
  break;
1164
+ case "subscribe_terminal":
1165
+ terminalSubscriptions.get(socket)?.handleMessage(msg);
1166
+ break;
901
1167
  case "terminate":
902
1168
  // Single exit path: finishHost carries the SIGTERM→4s→SIGKILL ladder,
903
1169
  // so a SIGTERM-immune child still terminates within a bounded window.
@@ -907,7 +1173,7 @@ async function ownedMain(config) {
907
1173
  socket.end();
908
1174
  break;
909
1175
  case "get_status":
910
- send(socket, { type: "status", status: host });
1176
+ send(socket, { type: "status", status: host, generation: GENERATION });
911
1177
  break;
912
1178
  case "editor_state": {
913
1179
  editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
@@ -1004,6 +1270,342 @@ function clampInt(value, min, max, fallback) {
1004
1270
  return Math.max(min, Math.min(max, Math.floor(n)));
1005
1271
  }
1006
1272
 
1273
+ // ---------------------------------------------------------------------------
1274
+ // Control-command lifecycle runtime (issue #91 phase 5, spec D4). Pure
1275
+ // decision logic lives in src/core/control-protocol.mjs; this is the runner's
1276
+ // serial side-effect shell: per-socket seq tracking, the durable command
1277
+ // journal, resize latest-wins routing, and staged ack emission. Messages
1278
+ // WITHOUT an envelope (no commandId) never reach this code — the legacy
1279
+ // switch keeps byte-identical behavior.
1280
+ // ---------------------------------------------------------------------------
1281
+
1282
+ /** Load journal records, dropping torn tail lines from a crash mid-append. */
1283
+ function loadJournalRecords(path) {
1284
+ try {
1285
+ const records = [];
1286
+ for (const line of readFileSync(path, "utf8").split("\n")) {
1287
+ if (!line.trim()) continue;
1288
+ try {
1289
+ records.push(JSON.parse(line));
1290
+ } catch {
1291
+ /* torn tail: the accepted/applied truth for that line is unknown —
1292
+ dropping it can only widen the accepted_unknown set, never hide one */
1293
+ }
1294
+ }
1295
+ return records;
1296
+ } catch {
1297
+ return [];
1298
+ }
1299
+ }
1300
+
1301
+ /**
1302
+ * One control runtime per main. `actions` bundles the main-specific side
1303
+ * effects (child shape differs between the legacy and owned lifecycles); the
1304
+ * decision order here is shared and binding:
1305
+ * envelope validity → instance fence → seq monotonicity → per-type dispatch.
1306
+ * @param {{
1307
+ * viewId: string, root: string, instanceId: string|null,
1308
+ * send: (socket: import("node:net").Socket, msg: object) => void,
1309
+ * diag: (code: string, message: string, details?: object) => void,
1310
+ * actions: {
1311
+ * childReady(): boolean,
1312
+ * writeInput(data: string): void,
1313
+ * hostCols(): number, hostRows(): number,
1314
+ * applyResize(cols: number, rows: number): void, // runtime pre-checks childReady — never called without a child
1315
+ * currentDims(): { cols: number, rows: number },
1316
+ * applyInterrupt(): void,
1317
+ * applyTerminate(): void,
1318
+ * applyDetach(socket: import("node:net").Socket): void,
1319
+ * hostRevision(): number,
1320
+ * cursor(): { lastSeq: number, cols: number, rows: number },
1321
+ * stateStamp(): number | null,
1322
+ * },
1323
+ * }} opts
1324
+ */
1325
+ function createControlRuntime({ viewId, root, instanceId, send, diag, actions }) {
1326
+ /** Per-connection seq watermark (ordering aid only — never dedup). */
1327
+ const connectionSeq = new Map();
1328
+ const journalPath = P.controlJournalPath(root, viewId);
1329
+ const journalRecords = loadJournalRecords(journalPath);
1330
+ /** O(1) journal dedup — rebuilt after GC so it mirrors the retained records. */
1331
+ const journalIds = new Set(journalRecords.map((r) => r.commandId));
1332
+ const resizes = createResizeTracker();
1333
+ /** commandId → socket awaiting the resize ack (superseded/applied routing). */
1334
+ const pendingResizeSockets = new Map();
1335
+ /** commandId → socket that requested terminate (observed routing). */
1336
+ const pendingTerminates = new Map();
1337
+
1338
+ const rawAppend = (record) => {
1339
+ appendFileSync(journalPath, JSON.stringify(record) + "\n");
1340
+ };
1341
+ const maybeGcJournal = () => {
1342
+ if (journalRecords.length <= JOURNAL_GC_TRIGGER) return;
1343
+ const kept = journalGc(journalRecords, JOURNAL_KEEP_DEFAULT);
1344
+ journalRecords.length = 0;
1345
+ journalRecords.push(...kept);
1346
+ journalIds.clear();
1347
+ for (const record of kept) journalIds.add(record.commandId);
1348
+ try {
1349
+ writeFileSync(journalPath, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
1350
+ } catch (err) {
1351
+ diag("control_journal_rewrite_failed", err instanceof Error ? err.message : String(err));
1352
+ }
1353
+ };
1354
+ /** @returns {boolean} false when the append failed — the command is NOT accepted. */
1355
+ const journalAccept = (commandId, command, now) => {
1356
+ // Ruling (b): a journaled commandId is NEVER re-appended — the dedup
1357
+ // check is the caller's, and this guard is the last line of defense.
1358
+ if (journalIds.has(commandId)) return true;
1359
+ const record = { kind: "accepted", commandId, command, acceptedAt: now };
1360
+ journalAppendRecord(journalRecords, record); // shape validation; throws on a runner bug
1361
+ try {
1362
+ rawAppend(record);
1363
+ } catch (err) {
1364
+ // Nothing was pushed into journalRecords for this record (pure
1365
+ // validate + manual push on success), so there is nothing to unwind —
1366
+ // the command is simply not accepted.
1367
+ diag("control_journal_write_failed", err instanceof Error ? err.message : String(err), { commandId });
1368
+ return false;
1369
+ }
1370
+ journalRecords.push(record);
1371
+ journalIds.add(commandId);
1372
+ maybeGcJournal();
1373
+ return true;
1374
+ };
1375
+ const journalApply = (commandId, now) => {
1376
+ const record = { kind: "applied", commandId, appliedAt: now };
1377
+ try {
1378
+ rawAppend(record);
1379
+ } catch (err) {
1380
+ diag("control_journal_write_failed", err instanceof Error ? err.message : String(err), { commandId });
1381
+ return;
1382
+ }
1383
+ journalRecords.push(record);
1384
+ maybeGcJournal();
1385
+ };
1386
+
1387
+ /** Socket-death semantics for every ack: a dead client must never turn a
1388
+ * control reply into a runner crash (same invariant as the broadcast guard). */
1389
+ const reply = (socket, msg) => {
1390
+ try {
1391
+ send(socket, msg);
1392
+ } catch {
1393
+ /* socket 'error'/'close' handler owns cleanup */
1394
+ }
1395
+ };
1396
+
1397
+ const flushTerminateObservations = (exitCode, runnerFinalizing) => {
1398
+ for (const [commandId, termSocket] of pendingTerminates) {
1399
+ reply(termSocket, {
1400
+ type: "cmd_ack",
1401
+ commandId,
1402
+ stage: "observed",
1403
+ ...(exitCode != null ? { exitConfirmed: true, exitCode } : {}),
1404
+ ...(runnerFinalizing ? { runnerFinalizing: true } : {}),
1405
+ });
1406
+ }
1407
+ pendingTerminates.clear();
1408
+ };
1409
+
1410
+ /**
1411
+ * Dispatch an enveloped control command. Caller guarantees
1412
+ * `env.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)`.
1413
+ *
1414
+ * Crash containment: a throwing action (e.g. child.write on a pty that died
1415
+ * mid-command) must degrade to an error reply + diagnostic, never escape
1416
+ * into the runner's uncaughtException path — the legacy switch predates this
1417
+ * invariant, but new protocol code must not inherit that crash class.
1418
+ */
1419
+ const handle = (msg, socket, env) => {
1420
+ // (d) errors are only meaningful for enveloped messages — this function is
1421
+ // only entered for enveloped control types, so every error below is legal.
1422
+ if (env.errors.length > 0) {
1423
+ reply(socket, { type: "error", code: "envelope_invalid", commandId: msg.commandId, errors: env.errors });
1424
+ return;
1425
+ }
1426
+ try {
1427
+ handleChecked(msg, socket, env);
1428
+ } catch (err) {
1429
+ diag("control_command_failed", err instanceof Error ? err.message : String(err), { commandId: msg.commandId, type: msg.type });
1430
+ reply(socket, { type: "error", code: "command_failed", commandId: msg.commandId });
1431
+ }
1432
+ };
1433
+
1434
+ const handleChecked = (msg, socket, env) => {
1435
+ // (a) instance fencing, reject-with-current: a client naming a DIFFERENT
1436
+ // instance is talking across a runner replacement (stale socket race);
1437
+ // silently applying its commands would resurrect phantom controls — the
1438
+ // exact class #70 fencing exists to prevent. The error carries the current
1439
+ // instanceId so a well-behaved client re-hellos and recovers. Legacy-mode
1440
+ // hosts (instanceId null) fence EVERY envelope: an envelope claims an
1441
+ // instance a legacy-mode runner never had.
1442
+ if (msg.instanceId !== instanceId) {
1443
+ diag("instance_mismatch", "control command carries a foreign instanceId", {
1444
+ commandId: msg.commandId,
1445
+ got: msg.instanceId,
1446
+ current: instanceId,
1447
+ });
1448
+ reply(socket, { type: "error", code: "instance_mismatch", commandId: msg.commandId, currentInstanceId: instanceId });
1449
+ return;
1450
+ }
1451
+ // (c) per-connection monotonic seq: an ordering aid. Repeats/regressions
1452
+ // are dropped with a diagnostic — NOT a dedup signal (dedup keys on
1453
+ // commandId only); a legitimate re-send after reconnect uses a new socket.
1454
+ const lastSeq = connectionSeq.get(socket) ?? 0;
1455
+ const seqVerdict = checkSeq(lastSeq, msg.seq);
1456
+ if (!seqVerdict.ok) {
1457
+ diag("seq_out_of_order", "control command dropped for non-monotonic seq", {
1458
+ commandId: msg.commandId,
1459
+ seq: msg.seq,
1460
+ lastSeq,
1461
+ });
1462
+ return;
1463
+ }
1464
+ connectionSeq.set(socket, msg.seq);
1465
+
1466
+ switch (msg.type) {
1467
+ case "input": {
1468
+ if (typeof msg.data !== "string") {
1469
+ reply(socket, { type: "error", code: "envelope_invalid", commandId: msg.commandId, errors: ["input_data_missing"] });
1470
+ return;
1471
+ }
1472
+ if (msg.durable !== true) {
1473
+ // Keystroke with a correlation id: fire-and-forget preserved — no
1474
+ // ack, no journal (journal writes must never sit on the keystroke
1475
+ // path). The envelope is correlation/debugging metadata only.
1476
+ if (actions.childReady()) actions.writeInput(msg.data);
1477
+ return;
1478
+ }
1479
+ // Durable follow-up: accepted (journaled) → applied (written). A
1480
+ // re-send of a journaled commandId returns the cached final stage —
1481
+ // never re-appended (ruling b), never re-written (commandId dedup).
1482
+ if (journalIds.has(msg.commandId)) {
1483
+ const wasApplied = journalRecords.some((r) => r.kind === "applied" && r.commandId === msg.commandId);
1484
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: wasApplied ? "applied" : "accepted", durable: true });
1485
+ return;
1486
+ }
1487
+ if (!actions.childReady()) {
1488
+ // Starting window: nothing accepted (matches the requestId path's
1489
+ // host_starting contract; the service retries with its own policy).
1490
+ reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
1491
+ return;
1492
+ }
1493
+ if (!journalAccept(msg.commandId, msg.data, Date.now())) {
1494
+ // accepted means JOURNALED — without the journal entry the stage
1495
+ // would be a lie, so the command is refused, not silently applied.
1496
+ reply(socket, { type: "error", code: "journal_unavailable", commandId: msg.commandId });
1497
+ return;
1498
+ }
1499
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "accepted", durable: true });
1500
+ try {
1501
+ actions.writeInput(msg.data);
1502
+ } catch (err) {
1503
+ // §10 honesty: accepted but the write failed — the command stays
1504
+ // accepted_unknown; reconcile reports it; never auto-replayed.
1505
+ diag("input_apply_failed", err instanceof Error ? err.message : String(err), { commandId: msg.commandId });
1506
+ return;
1507
+ }
1508
+ journalApply(msg.commandId, Date.now());
1509
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied" });
1510
+ return;
1511
+ }
1512
+ case "resize": {
1513
+ // Uniform starting-window contract with input: without a child there
1514
+ // is nothing to resize and no honest applied value — the client
1515
+ // retries (new commandId). This also keeps latest-wins tracking off
1516
+ // the books for commands that never applied.
1517
+ if (!actions.childReady()) {
1518
+ reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
1519
+ return;
1520
+ }
1521
+ const cols = clampInt(msg.cols, 20, 300, actions.hostCols());
1522
+ const rows = clampInt(msg.rows, 5, 120, actions.hostRows());
1523
+ const tracked = resizes.track({ commandId: msg.commandId, clientId: msg.clientId, cols, rows });
1524
+ for (const sup of tracked.superseded) {
1525
+ const oldSocket = pendingResizeSockets.get(sup.commandId) ?? socket;
1526
+ pendingResizeSockets.delete(sup.commandId);
1527
+ reply(oldSocket, { type: "cmd_ack", commandId: sup.commandId, stage: "superseded", byCommandId: sup.byCommandId });
1528
+ }
1529
+ if (tracked.duplicate) {
1530
+ // Same commandId re-request: applyResize is synchronous, so a
1531
+ // duplicate always finds the command already applied — return the
1532
+ // cached result. (CR R1 advisory: the pendingResizeSockets entry is
1533
+ // deliberately NOT set here — the original resolved synchronously,
1534
+ // so a re-pointed entry would be a stale leak.)
1535
+ const cached = resizes.resultFor(msg.commandId);
1536
+ if (cached) reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", cols: cached.cols, rows: cached.rows });
1537
+ return;
1538
+ }
1539
+ pendingResizeSockets.set(msg.commandId, socket);
1540
+ actions.applyResize(cols, rows);
1541
+ // The paired resize step keeps the model mirroring the REAL PTY
1542
+ // (reflow only on successful child.resize), so the model's dims are
1543
+ // the honest applied value — even when the PTY kept its old geometry.
1544
+ const dims = actions.currentDims();
1545
+ resizes.applied(msg.commandId, dims.cols, dims.rows);
1546
+ pendingResizeSockets.delete(msg.commandId);
1547
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", cols: dims.cols, rows: dims.rows });
1548
+ return;
1549
+ }
1550
+ case "interrupt": {
1551
+ // Starting-window honesty (CR R1 blocking): applyInterrupt no-ops
1552
+ // without a child, so an unconditional applied ack would be a lie —
1553
+ // same contract as input/resize. The client consumes host_starting
1554
+ // as a cmdAck error (no retry chain: interrupt is transient and
1555
+ // user-timed; a re-send carries a fresh commandId).
1556
+ if (!actions.childReady()) {
1557
+ reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
1558
+ return;
1559
+ }
1560
+ actions.applyInterrupt();
1561
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied" });
1562
+ return;
1563
+ }
1564
+ case "terminate": {
1565
+ // Idempotent: a repeat while the termination is in flight returns the
1566
+ // current stage instead of claiming a fresh start.
1567
+ if (pendingTerminates.has(msg.commandId)) {
1568
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "termination_started" });
1569
+ return;
1570
+ }
1571
+ pendingTerminates.set(msg.commandId, socket);
1572
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "termination_started" });
1573
+ actions.applyTerminate();
1574
+ return;
1575
+ }
1576
+ case "detach": {
1577
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "detach_accepted" });
1578
+ actions.applyDetach(socket);
1579
+ return;
1580
+ }
1581
+ case "reconcile": {
1582
+ reply(socket, {
1583
+ type: "reconcile_result",
1584
+ commandId: msg.commandId,
1585
+ generation: GENERATION,
1586
+ hostRevision: actions.hostRevision(),
1587
+ terminalCursor: actions.cursor(),
1588
+ stateMaterializedRevision: actions.stateStamp(),
1589
+ unresolved: journalUnresolved(journalRecords).map((u) => ({ ...u, status: "accepted_unknown" })),
1590
+ });
1591
+ return;
1592
+ }
1593
+ }
1594
+ };
1595
+
1596
+ /** Socket teardown: drop the seq watermark and any ack-routing entries. */
1597
+ const closeSocket = (socket) => {
1598
+ connectionSeq.delete(socket);
1599
+ for (const [commandId, sock] of pendingResizeSockets) if (sock === socket) pendingResizeSockets.delete(commandId);
1600
+ for (const [commandId, sock] of pendingTerminates) if (sock === socket) pendingTerminates.delete(commandId);
1601
+ };
1602
+
1603
+ // (completeHeldResize was removed as dead code: the starting-window resize
1604
+ // path rejects with host_starting instead of holding, and applyResize is
1605
+ // synchronous — there is no held-resize completion path. Task 2 review P2-C.)
1606
+ return { handle, closeSocket, flushTerminateObservations, journalIds };
1607
+ }
1608
+
1007
1609
  /** POSIX process start token — /proc/<pid>/stat field 22 (starttime), stable
1008
1610
  * across exec(2). Recorded in published identities so service-side recovery
1009
1611
  * (issue #70 Task 11) can tell an owned-live pid from a reused one; null on