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