@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
@@ -0,0 +1,803 @@
1
+ /**
2
+ * Client-side terminal attach protocol state machine (issue #91 Phase 4, D2).
3
+ *
4
+ * Pure logic: no sockets, no fs, no timers. `send` (client→runner messages)
5
+ * and `emit` (UI-facing events) are injected; the probe timeout goes through
6
+ * an injected `scheduleTimeout(delayMs, fn) → cancelFn` so tests drive time
7
+ * deterministically. The runner-side counterpart is
8
+ * `src/core/terminal-attach-protocol.mjs` — message shapes and boundary
9
+ * semantics there are authoritative.
10
+ *
11
+ * ## States
12
+ *
13
+ * - `probing` — `subscribe_terminal` sent, awaiting `snapshot_begin`.
14
+ * - `snapshot_begin` → `collecting` (mode decision: protocol)
15
+ * - probe timeout → `legacy` (old runner silently ignores the probe)
16
+ * - `error frame_version_mismatch` → `legacy`
17
+ * - `error snapshot_failed` / `error invalid_since_seq` → resend fresh
18
+ * subscribe (same probe window); up to 3 recovery attempts, then
19
+ * `legacy` fallback
20
+ * - `collecting` — `snapshot_begin` seen; waiting for `snapshot_end`.
21
+ * - `snapshot_frame` → frame stashed
22
+ * - `output` WITH seq between frame and end = runner catch-up flush
23
+ * (tested runner contract): stashed, applied after the frame, never
24
+ * counted before `snapshotReady`
25
+ * - `output` WITH seq before any frame = protocol violation → resync
26
+ * - `snapshot_end` → verify frame present (or empty baseline) and
27
+ * `nextSeq === stashed tail + 1` (or `begin.snapshotSeq + 1` with no
28
+ * flush), then emit `snapshotReady` followed by the stashed flush in
29
+ * order → `live`
30
+ * - `resnapshot_required` / `snapshot_failed` (interrupted snapshot) →
31
+ * discard the partial snapshot → resync
32
+ * - `live` — seq-checked consumption against `lastSeq`.
33
+ * - `seq === lastSeq + 1` → emit `output`, advance
34
+ * - `seq <= lastSeq` → duplicate/stale, silently ignored
35
+ * - `seq > lastSeq + 1` → gap → resync (fresh snapshot, never stitched)
36
+ * - `resnapshot_required` → resync; unsolicited `snapshot_begin` → resync
37
+ * - `resyncing` — a fresh `subscribe_terminal` (no `sinceSeq`: the runner
38
+ * answers a seq-less request with a fresh snapshot, always; the replay
39
+ * continuation below is defensive for cursor reconnects only) is in flight.
40
+ * - `snapshot_begin` → `collecting`
41
+ * - `output` with `seq === lastSeq + 1` (complete replay) → `live`,
42
+ * seamless (no `snapshotReady`, UI keeps its buffer)
43
+ * - stray outputs / duplicate markers → ignored
44
+ * - `snapshot_failed` / `resnapshot_required` → resend; up to 3 recovery
45
+ * attempts, then `legacy` fallback
46
+ * - `reconciling` — (phase 5, D4) reconnect gate: `reconcile` sent on the new
47
+ * socket, awaiting `reconcile_result` (or the reconcile deadline). Spec D4
48
+ * binds the reconnect order hello → reconcile → snapshot/subscribe. All
49
+ * runner messages in this state are consumed; outputs are ALSO EMITTED —
50
+ * they are legacy-broadcast strays that must reach the UI exactly once
51
+ * (strays cover (cursor, strayHighWater]; the replay starts past the
52
+ * high-water; see the reconciling output branch). Resolution:
53
+ * - `reconcile_result` → epoch rule: generation CHANGED since last seen ⇒
54
+ * discard the cursor (`lastSeq = 0`, `epochReset` event) ⇒ seq-less
55
+ * subscribe (fresh snapshot — ring replay across runner generations is
56
+ * structurally impossible); same generation ⇒ subscribe with `sinceSeq`
57
+ * (seamless replay)
58
+ * - deadline without an answer (phase-4 runner) ⇒ phase-4 semantics:
59
+ * subscribe with `sinceSeq` (the runner's resnapshot/empty begin flags
60
+ * protect correctness)
61
+ * - `error instance_mismatch` ⇒ adopt `currentInstanceId`, re-reconcile
62
+ * - `legacy` — mode decided against this runner: inert, every message
63
+ * returns false so the UI legacy path (screen.log replay + live output +
64
+ * jiggle) owns everything.
65
+ * - `closed` — inert; timers cancelled.
66
+ *
67
+ * `reconnect(lastSeq)` (new socket after a drop, protocol already achieved):
68
+ * subscribe with `sinceSeq: lastSeq`. Ring retained → replay → seamless
69
+ * `live`; evicted / ahead-of-runner / restarted-runner → fresh snapshot
70
+ * (`begin.resnapshot` and/or `begin.empty` set → UI resets its buffer).
71
+ * While still `probing`, `reconnect()` re-issues the probe instead.
72
+ *
73
+ * ## Events (emit)
74
+ *
75
+ * - `mode` — `"protocol"` (first `snapshot_begin`) or `"legacy"` (fallback
76
+ * decided — including a downgrade after a protocol session, which the UI
77
+ * must observe to switch paths); each direction fires exactly once.
78
+ * - `snapshotBegin` — `{ cols, rows }` from every `snapshot_begin` (fresh,
79
+ * reconnect, and empty baselines alike). The UI compares against its own
80
+ * terminal size and resizes the child when they differ: legacy attach
81
+ * resized at every connect (jiggle start), the protocol probe carries no
82
+ * size, so without this sync a full-screen TUI child would keep its
83
+ * host-creation geometry (CR R1 blocking). Matched sizes send nothing.
84
+ * - `snapshotReady` — `{ frame?, empty?, resnapshot?, nextSeq }` after
85
+ * continuity is verified at `snapshot_end`. UI: `term.reset()` +
86
+ * `write(frame)` (the frame is self-contained on dirty terminals), or
87
+ * loading baseline for `empty`. Empty baselines carry no frame.
88
+ * - `output` — live/replay chunk data (protocol-managed only), including gate
89
+ * strays emitted during `reconciling`.
90
+ * - `resubscribing` — the client sent a recovery subscribe itself. The plan
91
+ * contract originally made resubscribe UI-actionable; it is internal now
92
+ * (send is injected and socket-scoped) so the UI wiring stays minimal —
93
+ * the event is informational.
94
+ * - `protocolError` — `{ code, message }` for runner-side protocol failures
95
+ * (observability/diagnostics; recovery is automatic until the fallback).
96
+ * - `cmdAck` — (phase 5) `{ commandId, type, stage, ... }` for every enveloped
97
+ * control ack (`applied`/`observed`/`superseded`) and enveloped errors
98
+ * (`stage: "error"`, `code`). Correlation bookkeeping is internal; the UI
99
+ * acts only on what it already shows.
100
+ * - `reconciled` — `{ generation, hostRevision, terminalCursor,
101
+ * stateMaterializedRevision, unresolved }` after a reconnect reconcile.
102
+ * - `epochReset` — `{ previous, current }` when the generation change forced a
103
+ * cursor discard (structural fix for the phase-4 epoch ambiguity).
104
+ *
105
+ * ## Control commands (phase 5, D4)
106
+ *
107
+ * `sendControl(type, payload)` envelopes `resize`/`interrupt`/`terminate`/
108
+ * `detach` with `{commandId, clientId, seq, viewId, instanceId}` (identity
109
+ * derived from hello/status — the UI passes no identity options). Returns the
110
+ * commandId, or `null` when the host has no instance fence (legacy-mode main):
111
+ * the caller then falls back to the legacy plain message. Keystroke `input`
112
+ * is never enveloped (fire-and-forget by contract — throws). A resize that
113
+ * lands in the runner's starting window answers `host_starting`; the client
114
+ * retries with a fresh commandId (bounded chain, latest-wins) — parity with
115
+ * the legacy runner-side cachedResize.
116
+ *
117
+ * The frame/DTO version axes live in the runner module. This client advertises
118
+ * its `frameVersion` on every `subscribe_terminal` (the runner's mismatch gate
119
+ * is only reachable when the field is present) and treats
120
+ * `frame_version_mismatch` as legacy fallback.
121
+ *
122
+ * `AGENT_BOARD_TERMINAL_SNAPSHOT=0` (read once at factory time, overridable
123
+ * via the `forceLegacy` option) forces the legacy path without probing —
124
+ * escape hatch and deterministic legacy-test switch.
125
+ */
126
+
127
+ import { TERMINAL_FRAME_VERSION } from "./terminal-attach-protocol.mjs";
128
+ import { classifyCommandAck, encodeCommand, CONTROL_ERROR_CODES, TERMINAL_ERROR_CODES } from "./control-protocol.mjs";
129
+
130
+ /**
131
+ * @typedef {"probing" | "collecting" | "live" | "resyncing" | "reconciling" | "legacy" | "closed"} AttachClientState
132
+ */
133
+
134
+ /**
135
+ * @typedef {(
136
+ * event: "mode" | "snapshotBegin" | "snapshotReady" | "output" | "resubscribing" | "protocolError" |
137
+ * "cmdAck" | "reconciled" | "epochReset",
138
+ * payload?: any,
139
+ * ) => void} AttachClientEmit
140
+ */
141
+
142
+ const MAX_SNAPSHOT_FAILURES = 3; // up to 3 recovery attempts, then legacy fallback
143
+ const MAX_RESIZE_START_RETRIES = 5; // max TOTAL sends per size change (initial + retries; legacy cachedResize parity is "eventually applied", not infinite)
144
+ const RESIZE_RETRY_DELAY_MS = 300;
145
+
146
+ /**
147
+ * @typedef {{
148
+ * send: (msg: Record<string, unknown>) => void,
149
+ * emit: AttachClientEmit,
150
+ * probeTimeoutMs?: number,
151
+ * scheduleTimeout?: (delayMs: number, fn: () => void) => () => void,
152
+ * forceLegacy?: boolean,
153
+ * }} AttachClientOptions
154
+ */
155
+
156
+ /** @type {NonNullable<AttachClientOptions["scheduleTimeout"]>} */
157
+ const defaultScheduleTimeout = (delayMs, fn) => {
158
+ const t = setTimeout(fn, delayMs);
159
+ // Never hold the runner/UI process open for a probe deadline.
160
+ t.unref?.();
161
+ return () => clearTimeout(t);
162
+ };
163
+
164
+ /**
165
+ * @param {{
166
+ * send: (msg: Record<string, unknown>) => void,
167
+ * emit: AttachClientEmit,
168
+ * probeTimeoutMs?: number,
169
+ * scheduleTimeout?: (delayMs: number, fn: () => void) => () => void,
170
+ * forceLegacy?: boolean,
171
+ * clientId?: string,
172
+ * reconcileTimeoutMs?: number,
173
+ * }} opts
174
+ */
175
+ export function createTerminalAttachClient({
176
+ send: rawSend,
177
+ emit,
178
+ probeTimeoutMs = 1500,
179
+ scheduleTimeout = defaultScheduleTimeout,
180
+ forceLegacy,
181
+ clientId = `ui-${Date.now()}-${Math.floor(Math.random() * 1e6)}`,
182
+ reconcileTimeoutMs = 1500,
183
+ }) {
184
+ // Env is read once at factory time (plan contract); the explicit option
185
+ // wins so tests and embedders can pin behavior regardless of environment.
186
+ const forced =
187
+ forceLegacy !== undefined ? forceLegacy : process.env.AGENT_BOARD_TERMINAL_SNAPSHOT === "0";
188
+
189
+ /** @type {AttachClientState} */
190
+ let state = forced ? "legacy" : "probing";
191
+ let modeDecided = false; // "mode" fires once per direction, never both
192
+ let lastSeq = 0;
193
+ let probeTimer = null;
194
+ let snapshotFailures = 0;
195
+
196
+ // --- Control lifecycle (issue #91 phase 5, D4) ---
197
+ // Identity is DERIVED from hello/status (review ruling 1): the UI passes no
198
+ // viewId/instanceId options. `instanceId === null` means a legacy-mode host
199
+ // (instance fence does not exist): envelopes are NEVER sent to it — control
200
+ // commands fall back to the legacy plain messages; the attach snapshot
201
+ // protocol itself still works there (the legacy main serves subscribe).
202
+ let instanceId = null;
203
+ let viewId = null;
204
+ let generation = null; // last seen generation token (epoch detection)
205
+ let seq = 0; // per-connection envelope ordering (resets on reconnect)
206
+ let commandCounter = 0;
207
+ /** @type {Map<string, { type: string }>} */
208
+ const pendingCommands = new Map(); // commandId → correlation record
209
+ let reconcileTimer = null;
210
+ /** Pending reconnect gate: reconcile must resolve (or time out) before the
211
+ * subscribe decision — spec D4 binds hello → reconcile → snapshot/subscribe. */
212
+ let reconnectCursor = null;
213
+ /** Generation baseline AT reconnect time — the fresh socket's hello must
214
+ * not clobber it before the epoch comparison (that comparison is the whole
215
+ * point of the gate). */
216
+ let reconnectFromGeneration = null;
217
+ /** Highest seq seen in legacy-broadcast strays during a reconnect gate. The
218
+ * subscribe cursor must start past these or the runner's replay re-sends
219
+ * them (wire-level duplicate — see the reconciling output branch). */
220
+ let strayHighWater = 0;
221
+ /** Generation observed at disconnect time — the epoch baseline. The live
222
+ * `generation` field is refreshed by every hello/status/snapshot_begin,
223
+ * INCLUDING the replacement runner's hello that precedes reconnect(), so
224
+ * comparing against it can never detect a generation change. */
225
+ let generationAtDisconnect = null;
226
+ /** Guards against a double reconcile within one reconnect gate (reconnect()
227
+ * sends with the remembered identity; the fresh hello would otherwise send
228
+ * a second one). */
229
+ let reconcileInFlight = false;
230
+ /** Active starting-window resize retry chain (latest-wins: a new user
231
+ * resize replaces it). { commandId, cols, rows, attempt } | null */
232
+ let resizeStartRetry = null;
233
+
234
+ // Partial-snapshot stash (collecting): the runner sends frame, then its
235
+ // catch-up flush outputs, then end — one microtask batch, tiny by contract
236
+ // (ring pressure during capture yields resnapshot_required, not an
237
+ // unbounded flush). Buffered here so the UI applies frame-then-flush in
238
+ // exact order after snapshotReady.
239
+ /** @type {{ frame: string | null, empty: boolean, resnapshot: boolean, beginSeq: number, flush: { seq: number, data: string }[] }} */
240
+ let partial = { frame: null, empty: false, resnapshot: false, beginSeq: 0, flush: [] };
241
+
242
+ const send = (msg) => {
243
+ rawSend(msg);
244
+ };
245
+
246
+ const fireProbeTimer = () => {
247
+ cancelProbeTimer();
248
+ probeTimer = scheduleTimeout(probeTimeoutMs, () => {
249
+ probeTimer = null;
250
+ if (modeDecided) return; // begin already decided protocol (timer would be cancelled anyway)
251
+ fallbackToLegacy("probe_timeout");
252
+ });
253
+ };
254
+
255
+ const cancelProbeTimer = () => {
256
+ if (probeTimer) {
257
+ probeTimer();
258
+ probeTimer = null;
259
+ }
260
+ };
261
+
262
+ const cancelReconcileTimer = () => {
263
+ if (reconcileTimer) {
264
+ reconcileTimer();
265
+ reconcileTimer = null;
266
+ }
267
+ };
268
+
269
+ const decideProtocol = () => {
270
+ if (!modeDecided) {
271
+ modeDecided = true;
272
+ emit("mode", "protocol");
273
+ }
274
+ };
275
+
276
+ const fallbackToLegacy = (reason) => {
277
+ cancelProbeTimer();
278
+ state = "legacy";
279
+ // The legacy decision is ALWAYS reported, including after a protocol
280
+ // session: the UI switched to the snapshot path at "protocol" and must
281
+ // observe the downgrade to switch back (jiggle, raw output). A silent
282
+ // protocol→legacy transition would leave the UI frozen on a stale frame.
283
+ modeDecided = true;
284
+ emit("mode", "legacy");
285
+ emit("protocolError", { code: "legacy_fallback", message: reason });
286
+ };
287
+
288
+ /**
289
+ * Recovery subscribe: a fresh full snapshot. Deliberately WITHOUT
290
+ * `sinceSeq` — the runner answers a seq-less request with a fresh snapshot,
291
+ * always (its replay branch exists only for cursor reconnects), so this can
292
+ * never receive a partial tail (spec: never stitch).
293
+ */
294
+ const resync = (reason) => {
295
+ // Before the mode is decided the probe deadline still governs the legacy
296
+ // fallback: a runner that fails the snapshot and then goes silent must
297
+ // not strand the client outside both paths. Once protocol is decided the
298
+ // timer is meaningless and gets cancelled.
299
+ if (modeDecided) cancelProbeTimer();
300
+ partial = { frame: null, empty: false, resnapshot: false, beginSeq: 0, flush: [] };
301
+ state = "resyncing";
302
+ send({ type: "subscribe_terminal", frameVersion: TERMINAL_FRAME_VERSION });
303
+ emit("resubscribing", { reason });
304
+ };
305
+
306
+ const resyncOrFail = (reason) => {
307
+ snapshotFailures += 1;
308
+ if (snapshotFailures > MAX_SNAPSHOT_FAILURES) {
309
+ fallbackToLegacy(reason);
310
+ return;
311
+ }
312
+ resync(reason);
313
+ };
314
+
315
+ // --- Control lifecycle helpers (phase 5, D4) ---
316
+
317
+ /** Capture identity + generation from hello/status (UI-owned messages the
318
+ * client only peeks at; they remain false/non-consumed). */
319
+ const observeIdentity = (msg) => {
320
+ const status = msg.status ?? {};
321
+ // Normalize: an owned host carries a non-empty instanceId; a legacy-mode
322
+ // host has none (undefined) — both mean "no envelope possible".
323
+ instanceId = typeof status.instanceId === "string" && status.instanceId ? status.instanceId : null;
324
+ viewId = typeof status.viewId === "string" && status.viewId ? status.viewId : viewId;
325
+ if (typeof msg.generation === "string" && msg.generation) generation = msg.generation;
326
+ // A reconnect waiting for identity on the fresh socket can now reconcile.
327
+ if (state === "reconciling" && instanceId && reconnectCursor !== null && !reconcileInFlight) sendReconcile();
328
+ };
329
+
330
+ const sendReconcile = () => {
331
+ reconcileInFlight = true;
332
+ const commandId = `${clientId}-rec-${++commandCounter}`;
333
+ seq += 1;
334
+ send(encodeCommand("reconcile", {}, { commandId, clientId, seq, viewId: viewId ?? "unknown", instanceId }));
335
+ };
336
+
337
+ const issueReconnectSubscribe = (sinceSeq) => {
338
+ // The cursor must clear every stray the legacy broadcast already
339
+ // delivered to this socket during the gate (wire-level no-dup).
340
+ const effective = Math.max(sinceSeq, strayHighWater);
341
+ strayHighWater = 0;
342
+ const cursor = reconnectCursor;
343
+ reconnectCursor = null;
344
+ reconcileInFlight = false;
345
+ reconnectFromGeneration = null;
346
+ cancelReconcileTimer();
347
+ lastSeq = effective;
348
+ partial = { frame: null, empty: false, resnapshot: false, beginSeq: 0, flush: [] };
349
+ state = "resyncing";
350
+ send(
351
+ effective > 0
352
+ ? { type: "subscribe_terminal", frameVersion: TERMINAL_FRAME_VERSION, sinceSeq: effective }
353
+ : { type: "subscribe_terminal", frameVersion: TERMINAL_FRAME_VERSION },
354
+ );
355
+ return cursor;
356
+ };
357
+
358
+ /** Epoch rule (spec D4 + phase-4 residual fix): a generation CHANGE versus
359
+ * the baseline captured at reconnect() means the runner (and its child)
360
+ * was replaced — the remembered cursor describes a dead stream, so ring
361
+ * replay across generations is structurally impossible: discard the cursor
362
+ * and take a fresh snapshot. Same generation → seamless replay from the
363
+ * applied cursor. No generation (unreachable: reconcile needs an
364
+ * instanceId) or an unanswered reconcile (phase-4 runner) → phase-4
365
+ * semantics: the runner's resnapshot/empty begin flags protect correctness. */
366
+ const resolveReconnect = (result) => {
367
+ const nextGeneration = typeof result?.generation === "string" && result.generation ? result.generation : null;
368
+ generation = nextGeneration ?? generation;
369
+ emit("reconciled", {
370
+ generation,
371
+ hostRevision: result?.hostRevision,
372
+ terminalCursor: result?.terminalCursor,
373
+ stateMaterializedRevision: result?.stateMaterializedRevision,
374
+ unresolved: result?.unresolved,
375
+ });
376
+ if (nextGeneration && reconnectFromGeneration && nextGeneration !== reconnectFromGeneration) {
377
+ lastSeq = 0;
378
+ emit("epochReset", { previous: reconnectFromGeneration, current: nextGeneration });
379
+ // Epoch reset demands a seq-less subscribe (fresh snapshot). Gate strays
380
+ // from the NEW runner must not turn it into a tail replay onto the stale
381
+ // buffer — the max() in issueReconnectSubscribe is for the SAME-generation
382
+ // continuity path only. The fresh frame subsumes any emitted strays.
383
+ strayHighWater = 0;
384
+ issueReconnectSubscribe(0);
385
+ return;
386
+ }
387
+ issueReconnectSubscribe(reconnectCursor ?? 0);
388
+ };
389
+
390
+ const cancelResizeStartRetry = () => {
391
+ resizeStartRetry = null;
392
+ };
393
+
394
+ /** Re-arm the starting-window retry chain (bounded, latest-wins). Object
395
+ * identity is the liveness marker: a newer resize replaces the chain, a
396
+ * terminal ack cancels it, and a stale timer is a no-op. */
397
+ const scheduleResizeStartRetry = (chain) => {
398
+ if (chain.attempt + 1 >= MAX_RESIZE_START_RETRIES) {
399
+ resizeStartRetry = null;
400
+ return;
401
+ }
402
+ const next = { ...chain, attempt: chain.attempt + 1 };
403
+ resizeStartRetry = next;
404
+ scheduleTimeout(RESIZE_RETRY_DELAY_MS, () => {
405
+ if (resizeStartRetry !== next) return; // superseded or cancelled
406
+ sendControl("resize", { cols: next.cols, rows: next.rows }, { isRetry: true });
407
+ });
408
+ };
409
+
410
+ /**
411
+ * Send a control command with the phase-5 envelope (spec D4). Returns the
412
+ * commandId, or null when no instance fence is known (legacy-mode host):
413
+ * the caller falls back to the legacy plain message. Keystroke `input` is
414
+ * deliberately NOT sendable here — plain keyboard input is fire-and-forget
415
+ * by contract (never enveloped, never acked, never replayed).
416
+ *
417
+ * @param {"resize" | "interrupt" | "terminate" | "detach"} type
418
+ * @param {Record<string, unknown>} payload
419
+ * @param {{ isRetry?: boolean }} [opts] internal: retry sends keep the chain
420
+ */
421
+ function sendControl(type, payload = {}, opts = {}) {
422
+ if (type === "input") throw new TypeError("sendControl: keystroke input is never enveloped (fire-and-forget contract)");
423
+ if (state === "closed" || state === "legacy" || !instanceId || !viewId) return null;
424
+ const commandId = `${clientId}-${++commandCounter}`;
425
+ seq += 1;
426
+ send(encodeCommand(type, payload, { commandId, clientId, seq, viewId, instanceId }));
427
+ pendingCommands.set(commandId, { type });
428
+ if (type === "resize" && !opts.isRetry) {
429
+ // latest-wins: a new user resize replaces any retry chain
430
+ resizeStartRetry = { cols: payload.cols, rows: payload.rows, attempt: 0 };
431
+ }
432
+ return { commandId };
433
+ }
434
+
435
+ /** Enter collecting from a begin message (both probing and resyncing).
436
+ * Deliberately does NOT reset the failure budget: a runner that keeps
437
+ * sending begins interleaved with garbage must still trip the cap — only
438
+ * a VERIFIED snapshot (or verified replay) pays the budget back. */
439
+ const beginCollecting = (msg) => {
440
+ decideProtocol();
441
+ cancelProbeTimer();
442
+ // Size-sync hook (CR R1 blocking): every begin carries the runner's
443
+ // captured geometry — empty baselines included, so a child that has not
444
+ // produced output yet still gets corrected BEFORE its first bytes land.
445
+ if (typeof msg.cols === "number" && typeof msg.rows === "number") {
446
+ emit("snapshotBegin", { cols: msg.cols, rows: msg.rows });
447
+ }
448
+ partial = {
449
+ frame: null,
450
+ empty: msg.empty === true,
451
+ resnapshot: msg.resnapshot === true,
452
+ beginSeq: typeof msg.snapshotSeq === "number" ? msg.snapshotSeq : 0,
453
+ flush: [],
454
+ };
455
+ state = "collecting";
456
+ if (partial.empty) {
457
+ // Empty baselines carry no frame and no flush; the end message is
458
+ // already in the runner's send batch.
459
+ return;
460
+ }
461
+ };
462
+
463
+ const finishCollecting = (msg) => {
464
+ const nextSeq = msg.nextSeq;
465
+ if (!partial.frame && !partial.empty) {
466
+ // begin (non-empty) + end with no frame in between: never paint from
467
+ // an unverified window. Bounded recovery: fresh snapshot.
468
+ resyncOrFail("snapshot_frame_missing");
469
+ return;
470
+ }
471
+ const expected = partial.flush.length
472
+ ? partial.flush[partial.flush.length - 1].seq + 1
473
+ : (partial.empty ? 0 : partial.beginSeq) + 1;
474
+ if (nextSeq !== expected) {
475
+ // Continuity broken inside the snapshot window: never paint a
476
+ // partially-verified frame. Recovery: fresh snapshot.
477
+ resyncOrFail("snapshot_end_mismatch");
478
+ return;
479
+ }
480
+ snapshotFailures = 0; // a verified snapshot pays back the recovery budget
481
+ emit("snapshotReady", {
482
+ frame: partial.frame ?? undefined,
483
+ empty: partial.empty || undefined,
484
+ resnapshot: partial.resnapshot || undefined,
485
+ nextSeq,
486
+ });
487
+ for (const chunk of partial.flush) emit("output", chunk.data);
488
+ lastSeq = nextSeq - 1;
489
+ state = "live";
490
+ };
491
+
492
+ /**
493
+ * Handle one runner→client message. Returns true when the message is
494
+ * protocol-managed (consumed); false means the caller (UI) should process
495
+ * it through its existing path.
496
+ *
497
+ * @param {any} msg
498
+ * @returns {boolean}
499
+ */
500
+ function handleMessage(msg) {
501
+ if (!msg || typeof msg !== "object" || state === "closed" || state === "legacy") return false;
502
+ switch (msg.type) {
503
+ case "hello":
504
+ case "status": {
505
+ // Identity/generation peek (UI-owned message, not consumed).
506
+ observeIdentity(msg);
507
+ return false;
508
+ }
509
+ case "cmd_ack": {
510
+ // Control-lifecycle correlation (spec D4). The ack is consumed even
511
+ // for unknown commandIds (a late ack after internal retry cleanup is
512
+ // not a UI message).
513
+ const pending = pendingCommands.get(msg.commandId);
514
+ if (pending) {
515
+ const verdict = classifyCommandAck(pending.type, msg);
516
+ if (verdict.ok) {
517
+ if (verdict.stage === "applied" || verdict.stage === "observed" || verdict.stage === "superseded") {
518
+ pendingCommands.delete(msg.commandId);
519
+ if (pending.type === "resize") cancelResizeStartRetry();
520
+ }
521
+ } else {
522
+ emit("protocolError", { code: "ack_invalid", message: verdict.reason, commandId: msg.commandId });
523
+ }
524
+ }
525
+ emit("cmdAck", { commandId: msg.commandId, type: pending?.type, stage: msg.stage, cols: msg.cols, rows: msg.rows, byCommandId: msg.byCommandId, value: msg.value, exitConfirmed: msg.exitConfirmed, runnerFinalizing: msg.runnerFinalizing });
526
+ return true;
527
+ }
528
+ case "reconcile_result": {
529
+ if (state === "reconciling") resolveReconnect(msg);
530
+ return true;
531
+ }
532
+ case "error": {
533
+ if (msg.code === "frame_version_mismatch") {
534
+ // Wire contract mismatch: treat this runner as incompatible.
535
+ fallbackToLegacy("frame_version_mismatch");
536
+ return true;
537
+ }
538
+ if (msg.code === "snapshot_failed" || msg.code === "invalid_since_seq") {
539
+ if (state === "probing" || state === "resyncing" || state === "collecting") {
540
+ resyncOrFail(msg.code);
541
+ return true;
542
+ }
543
+ return false;
544
+ }
545
+ if (msg.code === "instance_mismatch" && state === "reconciling" && typeof msg.currentInstanceId === "string" && msg.currentInstanceId) {
546
+ // Recovery signal (review ruling 1): the host was replaced with a
547
+ // new instance fence — adopt and re-reconcile (same deadline).
548
+ instanceId = msg.currentInstanceId;
549
+ sendReconcile();
550
+ return true;
551
+ }
552
+ if (msg.code === "host_starting" && msg.commandId) {
553
+ const pending = pendingCommands.get(msg.commandId);
554
+ pendingCommands.delete(msg.commandId);
555
+ emit("cmdAck", { commandId: msg.commandId, type: pending?.type, stage: "error", code: msg.code });
556
+ if (pending?.type === "resize" && resizeStartRetry) {
557
+ scheduleResizeStartRetry(resizeStartRetry);
558
+ }
559
+ return true;
560
+ }
561
+ if (msg.code && CONTROL_ERROR_CODES[msg.code] && msg.commandId) {
562
+ // Task 4 (review P2): a taxonomy error correlated by commandId must
563
+ // consume the pending entry and surface as a cmdAck error — a
564
+ // swallowed error leaves the command pending forever. Terminal codes
565
+ // also cancel the resize starting-window retry chain (retrying into
566
+ // a moved fence or a caller-bug rejection is futile; host_starting
567
+ // above keeps its bounded-retry semantics).
568
+ const pending = pendingCommands.get(msg.commandId);
569
+ pendingCommands.delete(msg.commandId);
570
+ if (pending?.type === "resize" && TERMINAL_ERROR_CODES.includes(msg.code)) {
571
+ cancelResizeStartRetry();
572
+ }
573
+ emit("cmdAck", { commandId: msg.commandId, type: pending?.type, stage: "error", code: msg.code, currentInstanceId: msg.currentInstanceId });
574
+ return true;
575
+ }
576
+ return false; // UI-owned status errors
577
+ }
578
+ case "snapshot_begin": {
579
+ if (typeof msg.generation === "string" && msg.generation) generation = msg.generation;
580
+ if (state === "probing" || state === "resyncing") {
581
+ beginCollecting(msg);
582
+ return true;
583
+ }
584
+ if (state === "reconciling") return true; // pre-subscribe stray: consume
585
+ // Unsolicited begin in collecting (duplicate) or live: protocol
586
+ // violation — strictness surfaces runner bugs; a fresh subscribe
587
+ // converges either way.
588
+ resyncOrFail("unexpected_snapshot_begin");
589
+ return true;
590
+ }
591
+ case "snapshot_frame": {
592
+ if (state === "reconciling") return true; // pre-subscribe stray: consume
593
+ if (state !== "collecting" || partial.empty || typeof msg.data !== "string") {
594
+ resyncOrFail("unexpected_snapshot_frame");
595
+ return true;
596
+ }
597
+ partial.frame = msg.data;
598
+ return true;
599
+ }
600
+ case "snapshot_end": {
601
+ if (state === "reconciling") return true; // pre-subscribe stray: consume
602
+ if (state !== "collecting") {
603
+ resyncOrFail("unexpected_snapshot_end");
604
+ return true;
605
+ }
606
+ finishCollecting(msg);
607
+ return true;
608
+ }
609
+ case "resnapshot_required": {
610
+ if (state === "live" || state === "collecting" || state === "resyncing") {
611
+ resyncOrFail("resnapshot_required");
612
+ return true;
613
+ }
614
+ if (state === "reconciling") return true; // pre-subscribe stray: consume
615
+ return false;
616
+ }
617
+ case "output": {
618
+ if (state === "live") {
619
+ if (typeof msg.seq !== "number") {
620
+ // A protocol-owned socket must never receive seq-less
621
+ // output; consume and ignore so unsequenced bytes can
622
+ // never corrupt the canonical-state buffer.
623
+ return true;
624
+ }
625
+ if (msg.seq === lastSeq + 1) {
626
+ lastSeq = msg.seq;
627
+ emit("output", msg.data);
628
+ return true;
629
+ }
630
+ if (msg.seq <= lastSeq) return true; // duplicate/stale
631
+ resyncOrFail("seq_gap");
632
+ return true;
633
+ }
634
+ if (state === "collecting") {
635
+ if (typeof msg.seq !== "number") return true; // consume+ignore, see live branch
636
+ if (!partial.frame) {
637
+ // Output before the frame: the runner never emits this
638
+ // (its catch-up flush is frame → outputs → end). Violation.
639
+ resyncOrFail("output_before_snapshot_frame");
640
+ return true;
641
+ }
642
+ const expected = partial.flush.length
643
+ ? partial.flush[partial.flush.length - 1].seq + 1
644
+ : partial.beginSeq + 1;
645
+ if (msg.seq !== expected) {
646
+ resyncOrFail("snapshot_flush_gap");
647
+ return true;
648
+ }
649
+ partial.flush.push({ seq: msg.seq, data: msg.data });
650
+ return true;
651
+ }
652
+ if (state === "resyncing") {
653
+ // Complete-replay continuation is the only accepted output:
654
+ // it proves the runner answered with the full retained range.
655
+ if (typeof msg.seq === "number" && msg.seq === lastSeq + 1) {
656
+ decideProtocol();
657
+ lastSeq = msg.seq;
658
+ emit("output", msg.data);
659
+ state = "live";
660
+ snapshotFailures = 0; // verified complete replay pays the budget back
661
+ return true;
662
+ }
663
+ return true; // strays (pre-resync in-flight chunks): ignored
664
+ }
665
+ if (state === "reconciling") {
666
+ // The fresh socket is not subscribed yet: everything here is
667
+ // broadcast stray. Consume so the UI legacy path can never
668
+ // double-feed — and EMIT it: these bytes must reach the UI exactly
669
+ // once (strays cover (cursor, strayHighWater]; the replay starts
670
+ // past strayHighWater). Consuming WITHOUT emitting would silently
671
+ // drop them from the rendered output — a data-loss regression the
672
+ // task-5 review caught. lastSeq deliberately does NOT advance here:
673
+ // the subscribe cursor (max(cursor, strayHighWater)) carries the
674
+ // applied position once the replay/stream resumes.
675
+ if (typeof msg.seq === "number" && msg.seq > strayHighWater) strayHighWater = msg.seq;
676
+ emit("output", msg.data);
677
+ return true;
678
+ }
679
+ // probing: outputs belong to the legacy broadcast window (old
680
+ // runner, or new runner before subscribe processing). Not ours.
681
+ return false;
682
+ }
683
+ // (A duplicate dead `case "error":` block that survived the phase-5
684
+ // insertion was removed here — task 3 review P2: the live error case
685
+ // above returns on every path, so a second label was unreachable.)
686
+ default:
687
+ return false;
688
+ }
689
+ }
690
+
691
+ /** Arm the probe. Sends only `subscribe_terminal` — the UI keeps ownership
692
+ * of `hello` (it carries UI-specific fields) and sends it itself. */
693
+ function start() {
694
+ if (state === "legacy") {
695
+ if (!modeDecided) {
696
+ modeDecided = true;
697
+ emit("mode", "legacy");
698
+ }
699
+ return;
700
+ }
701
+ if (state !== "probing") return;
702
+ send({ type: "subscribe_terminal", frameVersion: TERMINAL_FRAME_VERSION });
703
+ fireProbeTimer();
704
+ }
705
+
706
+ /**
707
+ * New socket after a drop. Protocol mode: spec D4 binds the reconnect order
708
+ * `hello → reconcile → snapshot/subscribe` — the reconcile answer's
709
+ * generation drives the epoch rule (changed ⇒ discard cursor ⇒ fresh
710
+ * snapshot; same ⇒ replay from the applied cursor). Still probing: re-issue
711
+ * the probe on the new socket. Legacy: nothing — the UI legacy path
712
+ * reconnects on its own. A legacy-mode HOST (instanceId null) cannot be
713
+ * reconciled by contract (envelopes are never sent there) → phase-4
714
+ * semantics directly (the runner's resnapshot/empty flags protect).
715
+ *
716
+ * @param {number} cursorSeq last applied protocol seq (getLastSeq())
717
+ */
718
+ function reconnect(cursorSeq) {
719
+ if (state === "closed" || state === "legacy") return;
720
+ if (state === "probing") {
721
+ send({ type: "subscribe_terminal", frameVersion: TERMINAL_FRAME_VERSION });
722
+ fireProbeTimer();
723
+ return;
724
+ }
725
+ seq = 0; // per-connection ordering resets with the socket (spec D4)
726
+ snapshotFailures = 0; // fresh connection, fresh recovery budget
727
+ partial = { frame: null, empty: false, resnapshot: false, beginSeq: 0, flush: [] };
728
+ if (!instanceId) {
729
+ issueReconnectSubscribe(cursorSeq);
730
+ return;
731
+ }
732
+ reconnectCursor = cursorSeq;
733
+ reconnectFromGeneration = generationAtDisconnect ?? generation;
734
+ state = "reconciling";
735
+ cancelReconcileTimer();
736
+ reconcileTimer = scheduleTimeout(reconcileTimeoutMs, () => {
737
+ reconcileTimer = null;
738
+ if (state !== "reconciling") return;
739
+ // Unanswered reconcile (phase-4 runner or dead window): fall back to
740
+ // the phase-4 reconnect (sinceSeq; the runner's begin flags protect).
741
+ issueReconnectSubscribe(reconnectCursor ?? 0);
742
+ });
743
+ if (instanceId && viewId) sendReconcile();
744
+ // else: wait for the fresh hello (observeIdentity triggers sendReconcile).
745
+ }
746
+
747
+ /** Tear down: cancel timers, go inert. */
748
+ function close() {
749
+ cancelProbeTimer();
750
+ cancelReconcileTimer();
751
+ // CR R1 advisory: undeliverable correlation entries must not outlive the
752
+ // client — a new connection issues fresh commandIds, so a stale entry can
753
+ // never be consumed by a late ack.
754
+ pendingCommands.clear();
755
+ state = "closed";
756
+ }
757
+
758
+ /** Socket dropped (CR R1 advisory): cancel the probe deadline so a dead
759
+ * window can never downgrade a protocol-capable runner. The timer is
760
+ * absolute — a reconnect landing after the 1500ms deadline would otherwise
761
+ * inherit the stale "no snapshot" verdict for the whole session. The next
762
+ * `start()`/`reconnect()` on the new socket re-arms everything; decided
763
+ * modes (legacy) and closed stay inert. */
764
+ function onDisconnect() {
765
+ if (state === "closed" || state === "legacy") return;
766
+ // Epoch baseline: snapshot the generation AS OF THE DISCONNECT. The live
767
+ // `generation` field will be refreshed by the replacement runner's hello
768
+ // before reconnect() runs, so only this capture can detect a change.
769
+ if (generation) generationAtDisconnect = generation;
770
+ cancelProbeTimer();
771
+ cancelReconcileTimer();
772
+ // CR R1 advisory: pending correlation entries cannot be acked through a
773
+ // dead socket; a reconnect uses fresh commandIds. Clearing here means a
774
+ // half-open late ack is treated as uncorrelated — fine, the socket died.
775
+ pendingCommands.clear();
776
+ }
777
+
778
+ return {
779
+ start,
780
+ reconnect,
781
+ onDisconnect,
782
+ handleMessage,
783
+ close,
784
+ /** @returns {"probing" | "protocol" | "legacy" | "closed"} UI-facing mode */
785
+ getMode() {
786
+ if (state === "collecting" || state === "live" || state === "resyncing" || state === "reconciling") return "protocol";
787
+ return state;
788
+ },
789
+ /** Last applied protocol seq (reconnect cursor). */
790
+ getLastSeq() {
791
+ return lastSeq;
792
+ },
793
+ sendControl,
794
+ /** Identity snapshot (test/diagnostics aid). */
795
+ getIdentity() {
796
+ return { instanceId, viewId, generation };
797
+ },
798
+ };
799
+ }
800
+
801
+ /**
802
+ * @typedef {ReturnType<typeof createTerminalAttachClient>} TerminalAttachClient
803
+ */