@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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 (274) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -2
  4. package/SECURITY.md +5 -0
  5. package/package.json +112 -4
  6. package/packages/board/CHANGELOG.md +340 -0
  7. package/packages/board/CONTRIBUTING.md +7 -0
  8. package/packages/board/LICENSE +21 -0
  9. package/packages/board/README.md +88 -0
  10. package/packages/board/SECURITY.md +19 -0
  11. package/packages/board/docs/how-it-works.md +31 -0
  12. package/packages/board/docs/reference.md +278 -0
  13. package/packages/board/package.json +81 -0
  14. package/packages/board/runner/job-runner-legacy.mjs +68 -0
  15. package/packages/board/runner/job-runner.mjs +834 -0
  16. package/packages/board/runner/pty-runner-legacy.mjs +50 -0
  17. package/packages/board/runner/pty-runner.mjs +2175 -0
  18. package/packages/board/runner/state-coordinator.mjs +673 -0
  19. package/packages/board/runner/state-runner.mjs +203 -0
  20. package/packages/board/runner/title-runner.mjs +80 -0
  21. package/packages/board/src/commands/agent-board.ts +394 -0
  22. package/packages/board/src/commands/attach-decision.mjs +66 -0
  23. package/packages/board/src/commands/attach-flow.ts +251 -0
  24. package/packages/board/src/commands/bg.ts +103 -0
  25. package/packages/board/src/commands/board-model.ts +21 -0
  26. package/packages/board/src/core/atomic.mjs +184 -0
  27. package/packages/board/src/core/attach-delete.mjs +24 -0
  28. package/packages/board/src/core/attach-frame-proof.mjs +870 -0
  29. package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
  30. package/packages/board/src/core/attach-frame-stream.mjs +266 -0
  31. package/packages/board/src/core/attach-view-proof.mjs +709 -0
  32. package/packages/board/src/core/auto-state.mjs +377 -0
  33. package/packages/board/src/core/background-runs.mjs +273 -0
  34. package/packages/board/src/core/code-refs-store.mjs +315 -0
  35. package/packages/board/src/core/code-refs.mjs +913 -0
  36. package/packages/board/src/core/config.mjs +77 -0
  37. package/packages/board/src/core/control-protocol.mjs +482 -0
  38. package/packages/board/src/core/coordinator-client.mjs +633 -0
  39. package/packages/board/src/core/coordinator-journal.mjs +282 -0
  40. package/packages/board/src/core/coordinator-protocol.mjs +14 -0
  41. package/packages/board/src/core/cwd-stats.mjs +114 -0
  42. package/packages/board/src/core/dashboard-render.mjs +10 -0
  43. package/packages/board/src/core/derive.mjs +117 -0
  44. package/packages/board/src/core/diagnostics.mjs +133 -0
  45. package/packages/board/src/core/double-press.mjs +26 -0
  46. package/packages/board/src/core/editor-state-reporter.mjs +112 -0
  47. package/packages/board/src/core/events.mjs +286 -0
  48. package/packages/board/src/core/evidence.mjs +245 -0
  49. package/packages/board/src/core/follow-up-queue.mjs +211 -0
  50. package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
  51. package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
  52. package/packages/board/src/core/frame-witness-update.mjs +35 -0
  53. package/packages/board/src/core/heuristics.mjs +313 -0
  54. package/packages/board/src/core/hold-input.mjs +27 -0
  55. package/packages/board/src/core/host-coordination.mjs +300 -0
  56. package/packages/board/src/core/host-crash.mjs +79 -0
  57. package/packages/board/src/core/host-probe.mjs +207 -0
  58. package/packages/board/src/core/host-protocol.mjs +73 -0
  59. package/packages/board/src/core/ids.mjs +35 -0
  60. package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
  61. package/packages/board/src/core/invocation.mjs +43 -0
  62. package/packages/board/src/core/launch-options.mjs +418 -0
  63. package/packages/board/src/core/launch.mjs +157 -0
  64. package/packages/board/src/core/load-config.mjs +4 -0
  65. package/packages/board/src/core/locks.mjs +530 -0
  66. package/packages/board/src/core/paths.mjs +183 -0
  67. package/packages/board/src/core/pid.mjs +75 -0
  68. package/packages/board/src/core/prewarm-schedule.mjs +41 -0
  69. package/packages/board/src/core/process-identity-probe.mjs +228 -0
  70. package/packages/board/src/core/prompt-transport.mjs +13 -0
  71. package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
  72. package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
  73. package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
  74. package/packages/board/src/core/pty-attach-render.mjs +229 -0
  75. package/packages/board/src/core/pty-links.mjs +81 -0
  76. package/packages/board/src/core/pty-scroll.mjs +522 -0
  77. package/packages/board/src/core/pty-support.mjs +327 -0
  78. package/packages/board/src/core/repo.mjs +103 -0
  79. package/packages/board/src/core/rows.mjs +414 -0
  80. package/packages/board/src/core/screen-log-gc.mjs +198 -0
  81. package/packages/board/src/core/screen-log.mjs +160 -0
  82. package/packages/board/src/core/session-view.mjs +174 -0
  83. package/packages/board/src/core/slash-commands.mjs +52 -0
  84. package/packages/board/src/core/state-commands.mjs +757 -0
  85. package/packages/board/src/core/status-consistency.mjs +98 -0
  86. package/packages/board/src/core/steering-prompts.mjs +34 -0
  87. package/packages/board/src/core/steering.mjs +133 -0
  88. package/packages/board/src/core/stop-latch.mjs +41 -0
  89. package/packages/board/src/core/store.mjs +523 -0
  90. package/packages/board/src/core/suspend.mjs +36 -0
  91. package/packages/board/src/core/terminal-attach-client.mjs +981 -0
  92. package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
  93. package/packages/board/src/core/terminal-model.mjs +222 -0
  94. package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
  95. package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
  96. package/packages/board/src/core/terminal-snapshot.mjs +440 -0
  97. package/packages/board/src/core/test-knobs.mjs +28 -0
  98. package/packages/board/src/core/title.mjs +43 -0
  99. package/packages/board/src/core/types.mjs +414 -0
  100. package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
  101. package/packages/board/src/core/worktree.mjs +65 -0
  102. package/packages/board/src/index.ts +303 -0
  103. package/packages/board/src/runtime/service.mjs +3222 -0
  104. package/packages/board/src/ui/chrome.ts +20 -0
  105. package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
  106. package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
  107. package/packages/board/src/ui/dashboard.ts +2237 -0
  108. package/packages/board/src/ui/double-left.ts +30 -0
  109. package/packages/board/src/ui/external-editor.ts +25 -0
  110. package/packages/board/src/ui/focus.ts +32 -0
  111. package/packages/board/src/ui/pty-attach.ts +3184 -0
  112. package/packages/board/src/ui/waiting.ts +30 -0
  113. package/packages/commands/LICENSE +21 -0
  114. package/packages/commands/README.md +19 -0
  115. package/packages/commands/package.json +22 -0
  116. package/packages/commands/src/index.ts +43 -0
  117. package/packages/commands/src/security-review.ts +109 -0
  118. package/packages/frame/CHANGELOG.md +113 -0
  119. package/packages/frame/CONTRIBUTING.md +7 -0
  120. package/packages/frame/LICENSE +22 -0
  121. package/packages/frame/README.md +105 -0
  122. package/packages/frame/SECURITY.md +16 -0
  123. package/packages/frame/docs/how-it-works.md +53 -0
  124. package/packages/frame/docs/reference.md +78 -0
  125. package/packages/frame/package.json +72 -0
  126. package/packages/frame/src/data/git.ts +90 -0
  127. package/packages/frame/src/data/github.ts +140 -0
  128. package/packages/frame/src/data/gitlab.ts +63 -0
  129. package/packages/frame/src/data/mode.ts +49 -0
  130. package/packages/frame/src/data/past-prompts.ts +57 -0
  131. package/packages/frame/src/data/runner.ts +32 -0
  132. package/packages/frame/src/data/snapshot.ts +223 -0
  133. package/packages/frame/src/data/start-notes.ts +104 -0
  134. package/packages/frame/src/data/telemetry.ts +337 -0
  135. package/packages/frame/src/data/todos.ts +162 -0
  136. package/packages/frame/src/data/totals.ts +94 -0
  137. package/packages/frame/src/data/visits.ts +45 -0
  138. package/packages/frame/src/index.ts +696 -0
  139. package/packages/frame/src/kit/LICENSE +21 -0
  140. package/packages/frame/src/kit/app/attribution.ts +30 -0
  141. package/packages/frame/src/kit/app/builder.ts +186 -0
  142. package/packages/frame/src/kit/app/claim.ts +27 -0
  143. package/packages/frame/src/kit/app/feature.ts +91 -0
  144. package/packages/frame/src/kit/app/host.ts +214 -0
  145. package/packages/frame/src/kit/app/renderers.ts +62 -0
  146. package/packages/frame/src/kit/app/scope.ts +125 -0
  147. package/packages/frame/src/kit/board/model.ts +337 -0
  148. package/packages/frame/src/kit/contract.ts +79 -0
  149. package/packages/frame/src/kit/contracts/accounts.ts +33 -0
  150. package/packages/frame/src/kit/contracts/ask.ts +120 -0
  151. package/packages/frame/src/kit/contracts/board.ts +6 -0
  152. package/packages/frame/src/kit/contracts/by.ts +22 -0
  153. package/packages/frame/src/kit/contracts/calls.ts +46 -0
  154. package/packages/frame/src/kit/contracts/events.ts +89 -0
  155. package/packages/frame/src/kit/contracts/mode.ts +26 -0
  156. package/packages/frame/src/kit/contracts/names.ts +12 -0
  157. package/packages/frame/src/kit/contracts/peers.ts +77 -0
  158. package/packages/frame/src/kit/contracts/prompts.ts +8 -0
  159. package/packages/frame/src/kit/contracts/screen.ts +75 -0
  160. package/packages/frame/src/kit/contracts/skills.ts +81 -0
  161. package/packages/frame/src/kit/contracts/sources.ts +81 -0
  162. package/packages/frame/src/kit/contracts/views.ts +42 -0
  163. package/packages/frame/src/kit/control.ts +59 -0
  164. package/packages/frame/src/kit/dark-theme.ts +41 -0
  165. package/packages/frame/src/kit/decode.ts +210 -0
  166. package/packages/frame/src/kit/events/log.ts +313 -0
  167. package/packages/frame/src/kit/format.ts +25 -0
  168. package/packages/frame/src/kit/host.ts +4 -0
  169. package/packages/frame/src/kit/index.ts +25 -0
  170. package/packages/frame/src/kit/legacy-tasks.ts +39 -0
  171. package/packages/frame/src/kit/overlay-images.ts +36 -0
  172. package/packages/frame/src/kit/paths.ts +16 -0
  173. package/packages/frame/src/kit/screen/client.ts +74 -0
  174. package/packages/frame/src/kit/screen/feature.ts +64 -0
  175. package/packages/frame/src/kit/screen/model.ts +182 -0
  176. package/packages/frame/src/kit/screen/view.ts +838 -0
  177. package/packages/frame/src/kit/settings/files.ts +90 -0
  178. package/packages/frame/src/kit/settings/setting.ts +57 -0
  179. package/packages/frame/src/kit/settings/store.ts +192 -0
  180. package/packages/frame/src/kit/sources/file.ts +129 -0
  181. package/packages/frame/src/kit/sources/follow.ts +155 -0
  182. package/packages/frame/src/kit/testing.ts +260 -0
  183. package/packages/frame/src/kit/totals.ts +72 -0
  184. package/packages/frame/src/kit/ui/entry.ts +75 -0
  185. package/packages/frame/src/kit/ui/frame.ts +212 -0
  186. package/packages/frame/src/kit/ui/glyphs.ts +132 -0
  187. package/packages/frame/src/kit/ui/scroll.ts +14 -0
  188. package/packages/frame/src/render/diff.ts +157 -0
  189. package/packages/frame/src/render/fit.ts +86 -0
  190. package/packages/frame/src/render/format.ts +108 -0
  191. package/packages/frame/src/render/frame.ts +106 -0
  192. package/packages/frame/src/render/glyphs.ts +189 -0
  193. package/packages/frame/src/render/keys.ts +43 -0
  194. package/packages/frame/src/render/lead.ts +37 -0
  195. package/packages/frame/src/render/paint.ts +135 -0
  196. package/packages/frame/src/render/safe.ts +19 -0
  197. package/packages/frame/src/render/segments.ts +735 -0
  198. package/packages/frame/src/render/streaming.ts +223 -0
  199. package/packages/frame/src/render/timing.ts +106 -0
  200. package/packages/frame/src/render/tools.ts +167 -0
  201. package/packages/frame/src/settings.ts +105 -0
  202. package/packages/frame/src/ui/editor.ts +272 -0
  203. package/packages/frame/src/ui/effort.ts +36 -0
  204. package/packages/frame/src/ui/footer.ts +165 -0
  205. package/packages/frame/src/ui/header.ts +362 -0
  206. package/packages/frame/src/ui/history-search.ts +23 -0
  207. package/packages/frame/src/ui/interrupt.ts +21 -0
  208. package/packages/frame/src/ui/outputs.ts +170 -0
  209. package/packages/frame/src/ui/pi-entries.ts +298 -0
  210. package/packages/frame/src/ui/previews.ts +72 -0
  211. package/packages/frame/src/ui/queue.ts +75 -0
  212. package/packages/frame/src/ui/screen.ts +59 -0
  213. package/packages/frame/src/ui/timings.ts +48 -0
  214. package/packages/frame/src/ui/todos.ts +205 -0
  215. package/packages/frame/src/ui/tool-frame.ts +595 -0
  216. package/packages/frame/src/ui/working.ts +25 -0
  217. package/packages/frame/src/view/state.ts +97 -0
  218. package/packages/frame/src/view/transcript.ts +175 -0
  219. package/packages/frame/src/view/view.ts +162 -0
  220. package/packages/memory/CONTRIBUTING.md +7 -0
  221. package/packages/memory/LICENSE +21 -0
  222. package/packages/memory/README.md +44 -0
  223. package/packages/memory/SECURITY.md +20 -0
  224. package/packages/memory/package.json +55 -0
  225. package/packages/memory/skills/review-memory/SKILL.md +48 -0
  226. package/packages/memory/src/index.ts +163 -0
  227. package/packages/memory/src/prompt.ts +25 -0
  228. package/packages/memory/src/review.ts +150 -0
  229. package/packages/memory/src/secrets.ts +22 -0
  230. package/packages/memory/src/store.ts +173 -0
  231. package/packages/modes/CHANGELOG.md +104 -0
  232. package/packages/modes/CONTRIBUTING.md +7 -0
  233. package/packages/modes/LICENSE +21 -0
  234. package/packages/modes/README.md +174 -0
  235. package/packages/modes/SECURITY.md +17 -0
  236. package/packages/modes/package.json +55 -0
  237. package/packages/modes/skills/review-permissions/SKILL.md +54 -0
  238. package/packages/modes/src/bash.ts +218 -0
  239. package/packages/modes/src/deny-off.ts +62 -0
  240. package/packages/modes/src/git-safety.ts +72 -0
  241. package/packages/modes/src/index.ts +338 -0
  242. package/packages/modes/src/interview.ts +206 -0
  243. package/packages/modes/src/judge-gate.ts +113 -0
  244. package/packages/modes/src/modes.ts +121 -0
  245. package/packages/modes/src/review/apply.mjs +156 -0
  246. package/packages/modes/src/review/collect.mjs +208 -0
  247. package/packages/modes/src/review/tools.ts +71 -0
  248. package/packages/modes/src/sandbox.ts +37 -0
  249. package/packages/router/CHANGELOG.md +8 -0
  250. package/packages/router/LICENSE +21 -0
  251. package/packages/router/README.md +90 -0
  252. package/packages/router/package.json +51 -0
  253. package/packages/router/src/config.ts +65 -0
  254. package/packages/router/src/index.ts +172 -0
  255. package/packages/router/src/judge.ts +51 -0
  256. package/packages/router/src/route.ts +67 -0
  257. package/packages/web/CHANGELOG.md +36 -0
  258. package/packages/web/LICENSE +21 -0
  259. package/packages/web/README.md +87 -0
  260. package/packages/web/SECURITY.md +13 -0
  261. package/packages/web/package.json +59 -0
  262. package/packages/web/src/config.ts +109 -0
  263. package/packages/web/src/dnsfilter.ts +97 -0
  264. package/packages/web/src/extract.ts +65 -0
  265. package/packages/web/src/index.ts +217 -0
  266. package/packages/web/src/markdown.ts +122 -0
  267. package/packages/web/src/net.ts +109 -0
  268. package/packages/web/src/reader.ts +73 -0
  269. package/packages/web/src/sanitize.ts +50 -0
  270. package/packages/web/src/search.ts +76 -0
  271. package/packages/web/src/secrets.ts +22 -0
  272. package/packages/web/src/validate.ts +186 -0
  273. package/src/index.ts +156 -0
  274. package/src/settings.ts +79 -0
@@ -0,0 +1,981 @@
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, scrollbackLines? }`
86
+ * after continuity is verified at `snapshot_end`. `scrollbackLines` is the
87
+ * runner's scrollback line count for the frame just delivered; the key is
88
+ * omitted (never `undefined`) when the runner did not send one. UI:
89
+ * `term.reset()` + `write(frame)` (the frame is self-contained on dirty
90
+ * terminals), or loading baseline for `empty`. Empty baselines carry no frame.
91
+ * - `output` — live/replay chunk data (protocol-managed only), including gate
92
+ * strays emitted during `reconciling`.
93
+ * - `viewProofRecord` — sanitized S/F/A view-proof record from live/catch-up/replay,
94
+ * in receive order. It is an independent fact, never readiness.
95
+ * - `viewProof` — sanitized current coverage from `view_proof_state` (replay end or
96
+ * reporter reset). Optional `snapshotReady.viewProof` is the snapshot_end coverage
97
+ * with begin/state fallback; both are independent of transport validation.
98
+ * - `resubscribing` — the client sent a recovery subscribe itself. The plan
99
+ * contract originally made resubscribe UI-actionable; it is internal now
100
+ * (send is injected and socket-scoped) so the UI wiring stays minimal —
101
+ * the event is informational.
102
+ * - `protocolError` — `{ code, message }` for runner-side protocol failures
103
+ * (observability/diagnostics; recovery is automatic until the fallback).
104
+ * - `cmdAck` — (phase 5) `{ commandId, type, stage, ... }` for every enveloped
105
+ * control ack (`applied`/`observed`/`superseded`) and enveloped errors
106
+ * (`stage: "error"`, `code`). Correlation bookkeeping is internal; the UI
107
+ * acts only on what it already shows.
108
+ * - `reconciled` — `{ generation, hostRevision, terminalCursor,
109
+ * stateMaterializedRevision, unresolved }` after a reconnect reconcile.
110
+ * - `epochReset` — `{ previous, current }` when the generation change forced a
111
+ * cursor discard (structural fix for the phase-4 epoch ambiguity).
112
+ *
113
+ * ## Control commands (phase 5, D4)
114
+ *
115
+ * `sendControl(type, payload)` envelopes `resize`/`interrupt`/`terminate`/
116
+ * `detach` with `{commandId, clientId, seq, viewId, instanceId}` (identity
117
+ * derived from hello/status — the UI passes no identity options). Returns the
118
+ * commandId, or `null` when the host has no instance fence (legacy-mode main):
119
+ * the caller then falls back to the legacy plain message. Keystroke `input`
120
+ * is never enveloped (fire-and-forget by contract — throws). A resize that
121
+ * lands in the runner's starting window answers `host_starting`; the client
122
+ * retries with a fresh commandId (bounded chain, latest-wins) — parity with
123
+ * the legacy runner-side cachedResize.
124
+ *
125
+ * The frame/DTO version axes live in the runner module. This client advertises
126
+ * its `frameVersion` on every `subscribe_terminal` (the runner's mismatch gate
127
+ * is only reachable when the field is present) and treats
128
+ * `frame_version_mismatch` as legacy fallback.
129
+ *
130
+ * `AGENT_BOARD_TERMINAL_SNAPSHOT=0` (read once at factory time, overridable
131
+ * via the `forceLegacy` option) forces the legacy path without probing —
132
+ * escape hatch and deterministic legacy-test switch.
133
+ */
134
+
135
+ import {
136
+ MAX_VIEW_PROOF_RECORDS,
137
+ TERMINAL_FRAME_VERSION,
138
+ VIEW_PROOF_PROTOCOL_VERSION,
139
+ sanitizeViewProofRecord,
140
+ } from "./terminal-attach-protocol.mjs";
141
+ import { classifyCommandAck, encodeCommand, CONTROL_ERROR_CODES, TERMINAL_ERROR_CODES } from "./control-protocol.mjs";
142
+
143
+ /**
144
+ * @typedef {"probing" | "collecting" | "live" | "resyncing" | "reconciling" | "legacy" | "closed"} AttachClientState
145
+ */
146
+
147
+ /**
148
+ * @typedef {(
149
+ * event: "mode" | "snapshotBegin" | "snapshotReady" | "output" | "viewProof" | "viewProofRecord" |
150
+ * "resubscribing" | "protocolError" | "cmdAck" | "reconciled" | "epochReset",
151
+ * payload?: any,
152
+ * ) => void} AttachClientEmit
153
+ */
154
+
155
+ const MAX_SNAPSHOT_FAILURES = 3; // up to 3 recovery attempts, then legacy fallback
156
+ const MAX_RESIZE_START_RETRIES = 5; // max TOTAL sends per size change (initial + retries; legacy cachedResize parity is "eventually applied", not infinite)
157
+ const RESIZE_RETRY_DELAY_MS = 300;
158
+
159
+ /**
160
+ * @typedef {{
161
+ * send: (msg: Record<string, unknown>) => void,
162
+ * emit: AttachClientEmit,
163
+ * probeTimeoutMs?: number,
164
+ * scheduleTimeout?: (delayMs: number, fn: () => void) => () => void,
165
+ * forceLegacy?: boolean,
166
+ * }} AttachClientOptions
167
+ */
168
+
169
+ const VIEW_PROOF_STATUSES = new Set(["covered", "unavailable", "unknown"]);
170
+
171
+ function unknownViewProof(reason) {
172
+ return { status: "unknown", reason, proof: null, throughSeq: null, cursorVisible: null };
173
+ }
174
+
175
+ /** Bounded coverage sanitizer: accepts only tracker-shaped fields and a valid F proof. */
176
+ export function sanitizeViewProofCoverage(value) {
177
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
178
+ if (!VIEW_PROOF_STATUSES.has(value.status)) return null;
179
+ if (value.reason !== null && value.reason !== undefined && typeof value.reason !== "string") return null;
180
+ if (value.throughSeq !== null && value.throughSeq !== undefined && (!Number.isInteger(value.throughSeq) || value.throughSeq < 0)) return null;
181
+ if (value.cursorVisible !== null && value.cursorVisible !== undefined && typeof value.cursorVisible !== "boolean") return null;
182
+ const status = value.status;
183
+ let proof = null;
184
+ if (value.proof !== null && value.proof !== undefined) {
185
+ proof = sanitizeViewProofRecord(value.proof);
186
+ if (proof === null || proof.marker !== "F") return null;
187
+ }
188
+ if (status === "covered" && (proof === null || !Number.isInteger(value.throughSeq))) return null;
189
+ if (status === "covered" && proof.seq > value.throughSeq) return null;
190
+ let previousComplete = null;
191
+ if (value.previousComplete !== null && value.previousComplete !== undefined) {
192
+ previousComplete = sanitizeViewProofRecord(value.previousComplete);
193
+ if (previousComplete === null || previousComplete.marker !== "F") return null;
194
+ }
195
+ return {
196
+ status,
197
+ reason: typeof value.reason === "string" ? value.reason : null,
198
+ proof,
199
+ throughSeq: Number.isInteger(value.throughSeq) ? value.throughSeq : null,
200
+ cursorVisible: typeof value.cursorVisible === "boolean" ? value.cursorVisible : null,
201
+ ...(previousComplete === null ? {} : { previousComplete }),
202
+ };
203
+ }
204
+
205
+ /** @type {NonNullable<AttachClientOptions["scheduleTimeout"]>} */
206
+ const defaultScheduleTimeout = (delayMs, fn) => {
207
+ const t = setTimeout(fn, delayMs);
208
+ // Never hold the runner/UI process open for a probe deadline.
209
+ t.unref?.();
210
+ return () => clearTimeout(t);
211
+ };
212
+
213
+ /**
214
+ * @param {{
215
+ * send: (msg: Record<string, unknown>) => void,
216
+ * emit: AttachClientEmit,
217
+ * probeTimeoutMs?: number,
218
+ * scheduleTimeout?: (delayMs: number, fn: () => void) => () => void,
219
+ * forceLegacy?: boolean,
220
+ * clientId?: string,
221
+ * reconcileTimeoutMs?: number,
222
+ * }} opts
223
+ */
224
+ export function createTerminalAttachClient({
225
+ send: rawSend,
226
+ emit,
227
+ probeTimeoutMs = 1500,
228
+ scheduleTimeout = defaultScheduleTimeout,
229
+ forceLegacy,
230
+ clientId = `ui-${Date.now()}-${Math.floor(Math.random() * 1e6)}`,
231
+ reconcileTimeoutMs = 1500,
232
+ }) {
233
+ // Env is read once at factory time (plan contract); the explicit option
234
+ // wins so tests and embedders can pin behavior regardless of environment.
235
+ const forced =
236
+ forceLegacy !== undefined ? forceLegacy : process.env.AGENT_BOARD_TERMINAL_SNAPSHOT === "0";
237
+
238
+ /** @type {AttachClientState} */
239
+ let state = forced ? "legacy" : "probing";
240
+ let modeDecided = false; // "mode" fires once per direction, never both
241
+ let lastSeq = 0;
242
+ let probeTimer = null;
243
+ let snapshotFailures = 0;
244
+
245
+ // --- Control lifecycle (issue #91 phase 5, D4) ---
246
+ // Identity is DERIVED from hello/status (review ruling 1): the UI passes no
247
+ // viewId/instanceId options. `instanceId === null` means a legacy-mode host
248
+ // (instance fence does not exist): envelopes are NEVER sent to it — control
249
+ // commands fall back to the legacy plain messages; the attach snapshot
250
+ // protocol itself still works there (the legacy main serves subscribe).
251
+ let instanceId = null;
252
+ let viewId = null;
253
+ let generation = null; // last seen generation token (epoch detection)
254
+ let seq = 0; // per-connection envelope ordering (resets on reconnect)
255
+ let commandCounter = 0;
256
+ /** @type {Map<string, { type: string }>} */
257
+ const pendingCommands = new Map(); // commandId → correlation record
258
+ let reconcileTimer = null;
259
+ /** Pending reconnect gate: reconcile must resolve (or time out) before the
260
+ * subscribe decision — spec D4 binds hello → reconcile → snapshot/subscribe. */
261
+ let reconnectCursor = null;
262
+ /** Generation baseline AT reconnect time — the fresh socket's hello must
263
+ * not clobber it before the epoch comparison (that comparison is the whole
264
+ * point of the gate). */
265
+ let reconnectFromGeneration = null;
266
+ /** Highest seq seen in legacy-broadcast strays during a reconnect gate. The
267
+ * subscribe cursor must start past these or the runner's replay re-sends
268
+ * them (wire-level duplicate — see the reconciling output branch). */
269
+ let strayHighWater = 0;
270
+ /** Generation observed at disconnect time — the epoch baseline. The live
271
+ * `generation` field is refreshed by every hello/status/snapshot_begin,
272
+ * INCLUDING the replacement runner's hello that precedes reconnect(), so
273
+ * comparing against it can never detect a generation change. */
274
+ let generationAtDisconnect = null;
275
+ /** Guards against a double reconcile within one reconnect gate (reconnect()
276
+ * sends with the remembered identity; the fresh hello would otherwise send
277
+ * a second one). */
278
+ let reconcileInFlight = false;
279
+ /** Active starting-window resize retry chain (latest-wins: a new user
280
+ * resize replaces it). { commandId, cols, rows, attempt } | null */
281
+ let resizeStartRetry = null;
282
+
283
+ // Partial-snapshot stash (collecting): the runner sends frame, then its
284
+ // catch-up flush outputs, then end — one microtask batch, tiny by contract
285
+ // (ring pressure during capture yields resnapshot_required, not an
286
+ // unbounded flush). Buffered here so the UI applies frame-then-flush in
287
+ // exact order after snapshotReady. View-proof facts are bounded and cleared
288
+ // with every new capture/resync.
289
+ const emptyPartial = () => ({
290
+ frame: null,
291
+ empty: false,
292
+ resnapshot: false,
293
+ beginSeq: 0,
294
+ flush: [],
295
+ viewProofBegin: null,
296
+ viewProofState: null,
297
+ viewProofRecords: [],
298
+ viewProofRecordsDropped: false,
299
+ });
300
+ /** @type {ReturnType<typeof emptyPartial>} */
301
+ let partial = emptyPartial();
302
+
303
+ const send = (msg) => {
304
+ rawSend(msg);
305
+ };
306
+
307
+ const subscribeMessage = (extra = {}) => ({
308
+ type: "subscribe_terminal",
309
+ frameVersion: TERMINAL_FRAME_VERSION,
310
+ viewProof: VIEW_PROOF_PROTOCOL_VERSION,
311
+ ...extra,
312
+ });
313
+
314
+ const fireProbeTimer = () => {
315
+ cancelProbeTimer();
316
+ probeTimer = scheduleTimeout(probeTimeoutMs, () => {
317
+ probeTimer = null;
318
+ if (modeDecided) return; // begin already decided protocol (timer would be cancelled anyway)
319
+ fallbackToLegacy("probe_timeout");
320
+ });
321
+ };
322
+
323
+ const cancelProbeTimer = () => {
324
+ if (probeTimer) {
325
+ probeTimer();
326
+ probeTimer = null;
327
+ }
328
+ };
329
+
330
+ const cancelReconcileTimer = () => {
331
+ if (reconcileTimer) {
332
+ reconcileTimer();
333
+ reconcileTimer = null;
334
+ }
335
+ };
336
+
337
+ const decideProtocol = () => {
338
+ if (!modeDecided) {
339
+ modeDecided = true;
340
+ emit("mode", "protocol");
341
+ }
342
+ };
343
+
344
+ const fallbackToLegacy = (reason) => {
345
+ cancelProbeTimer();
346
+ state = "legacy";
347
+ // The legacy decision is ALWAYS reported, including after a protocol
348
+ // session: the UI switched to the snapshot path at "protocol" and must
349
+ // observe the downgrade to switch back (jiggle, raw output). A silent
350
+ // protocol→legacy transition would leave the UI frozen on a stale frame.
351
+ modeDecided = true;
352
+ emit("mode", "legacy");
353
+ emit("protocolError", { code: "legacy_fallback", message: reason });
354
+ };
355
+
356
+ /**
357
+ * Recovery subscribe: a fresh full snapshot. Deliberately WITHOUT
358
+ * `sinceSeq` — the runner answers a seq-less request with a fresh snapshot,
359
+ * always (its replay branch exists only for cursor reconnects), so this can
360
+ * never receive a partial tail (spec: never stitch).
361
+ */
362
+ const resync = (reason) => {
363
+ // Before the mode is decided the probe deadline still governs the legacy
364
+ // fallback: a runner that fails the snapshot and then goes silent must
365
+ // not strand the client outside both paths. Once protocol is decided the
366
+ // timer is meaningless and gets cancelled.
367
+ if (modeDecided) cancelProbeTimer();
368
+ partial = emptyPartial();
369
+ state = "resyncing";
370
+ send(subscribeMessage());
371
+ emit("resubscribing", { reason });
372
+ };
373
+
374
+ const resyncOrFail = (reason) => {
375
+ snapshotFailures += 1;
376
+ if (snapshotFailures > MAX_SNAPSHOT_FAILURES) {
377
+ fallbackToLegacy(reason);
378
+ return;
379
+ }
380
+ resync(reason);
381
+ };
382
+
383
+ // --- Control lifecycle helpers (phase 5, D4) ---
384
+
385
+ /** Capture identity + generation from hello/status (UI-owned messages the
386
+ * client only peeks at; they remain false/non-consumed). */
387
+ const observeIdentity = (msg) => {
388
+ const status = msg.status ?? {};
389
+ // Normalize: an owned host carries a non-empty instanceId; a legacy-mode
390
+ // host has none (undefined) — both mean "no envelope possible".
391
+ instanceId = typeof status.instanceId === "string" && status.instanceId ? status.instanceId : null;
392
+ viewId = typeof status.viewId === "string" && status.viewId ? status.viewId : viewId;
393
+ if (typeof msg.generation === "string" && msg.generation) generation = msg.generation;
394
+ // A reconnect waiting for identity on the fresh socket can now reconcile.
395
+ if (state === "reconciling" && instanceId && reconnectCursor !== null && !reconcileInFlight) sendReconcile();
396
+ };
397
+
398
+ const sendReconcile = () => {
399
+ reconcileInFlight = true;
400
+ const commandId = `${clientId}-rec-${++commandCounter}`;
401
+ seq += 1;
402
+ send(encodeCommand("reconcile", {}, { commandId, clientId, seq, viewId: viewId ?? "unknown", instanceId }));
403
+ };
404
+
405
+ const issueReconnectSubscribe = (sinceSeq) => {
406
+ // The cursor must clear every stray the legacy broadcast already
407
+ // DELIVERED to this socket during the gate. Strays still in flight when
408
+ // the subscribe is written cannot be folded in: the ring replay re-sends
409
+ // them and seq-checked consumption dedups — the guarantee is UI-level
410
+ // exactly-once; a wire-level repeat at the broadcast→replay handoff is
411
+ // expected (#140).
412
+ const effective = Math.max(sinceSeq, strayHighWater);
413
+ strayHighWater = 0;
414
+ const cursor = reconnectCursor;
415
+ reconnectCursor = null;
416
+ reconcileInFlight = false;
417
+ reconnectFromGeneration = null;
418
+ cancelReconcileTimer();
419
+ lastSeq = effective;
420
+ partial = emptyPartial();
421
+ state = "resyncing";
422
+ send(effective > 0 ? subscribeMessage({ sinceSeq: effective }) : subscribeMessage());
423
+ return cursor;
424
+ };
425
+
426
+ /** Epoch rule (spec D4 + phase-4 residual fix): a generation CHANGE versus
427
+ * the baseline captured at reconnect() means the runner (and its child)
428
+ * was replaced — the remembered cursor describes a dead stream, so ring
429
+ * replay across generations is structurally impossible: discard the cursor
430
+ * and take a fresh snapshot. Same generation → seamless replay from the
431
+ * applied cursor. No generation (unreachable: reconcile needs an
432
+ * instanceId) or an unanswered reconcile (phase-4 runner) → phase-4
433
+ * semantics: the runner's resnapshot/empty begin flags protect correctness. */
434
+ const resolveReconnect = (result) => {
435
+ const nextGeneration = typeof result?.generation === "string" && result.generation ? result.generation : null;
436
+ generation = nextGeneration ?? generation;
437
+ emit("reconciled", {
438
+ generation,
439
+ hostRevision: result?.hostRevision,
440
+ terminalCursor: result?.terminalCursor,
441
+ stateMaterializedRevision: result?.stateMaterializedRevision,
442
+ unresolved: result?.unresolved,
443
+ });
444
+ if (nextGeneration && reconnectFromGeneration && nextGeneration !== reconnectFromGeneration) {
445
+ lastSeq = 0;
446
+ emit("epochReset", { previous: reconnectFromGeneration, current: nextGeneration });
447
+ // Epoch reset demands a seq-less subscribe (fresh snapshot). Gate strays
448
+ // from the NEW runner must not turn it into a tail replay onto the stale
449
+ // buffer — the max() in issueReconnectSubscribe is for the SAME-generation
450
+ // continuity path only. The fresh frame subsumes any emitted strays.
451
+ strayHighWater = 0;
452
+ issueReconnectSubscribe(0);
453
+ return;
454
+ }
455
+ issueReconnectSubscribe(reconnectCursor ?? 0);
456
+ };
457
+
458
+ const cancelResizeStartRetry = () => {
459
+ resizeStartRetry = null;
460
+ };
461
+
462
+ /** Re-arm the starting-window retry chain (bounded, latest-wins). Object
463
+ * identity is the liveness marker: a newer resize replaces the chain, a
464
+ * terminal ack cancels it, and a stale timer is a no-op. */
465
+ const scheduleResizeStartRetry = (chain) => {
466
+ if (chain.attempt + 1 >= MAX_RESIZE_START_RETRIES) {
467
+ resizeStartRetry = null;
468
+ return;
469
+ }
470
+ const next = { ...chain, attempt: chain.attempt + 1 };
471
+ resizeStartRetry = next;
472
+ scheduleTimeout(RESIZE_RETRY_DELAY_MS, () => {
473
+ if (resizeStartRetry !== next) return; // superseded or cancelled
474
+ sendControl("resize", { cols: next.cols, rows: next.rows }, { isRetry: true });
475
+ });
476
+ };
477
+
478
+ /**
479
+ * Send a control command with the phase-5 envelope (spec D4). Returns the
480
+ * commandId, or null when no instance fence is known (legacy-mode host):
481
+ * the caller falls back to the legacy plain message. Keystroke `input` is
482
+ * deliberately NOT sendable here — plain keyboard input is fire-and-forget
483
+ * by contract (never enveloped, never acked, never replayed).
484
+ *
485
+ * @param {"resize" | "interrupt" | "terminate" | "detach"} type
486
+ * @param {Record<string, unknown>} payload
487
+ * @param {{ isRetry?: boolean }} [opts] internal: retry sends keep the chain
488
+ */
489
+ function sendControl(type, payload = {}, opts = {}) {
490
+ if (type === "input") throw new TypeError("sendControl: keystroke input is never enveloped (fire-and-forget contract)");
491
+ if (state === "closed" || state === "legacy" || !instanceId || !viewId) return null;
492
+ const commandId = `${clientId}-${++commandCounter}`;
493
+ seq += 1;
494
+ send(encodeCommand(type, payload, { commandId, clientId, seq, viewId, instanceId }));
495
+ pendingCommands.set(commandId, { type });
496
+ if (type === "resize" && !opts.isRetry) {
497
+ // latest-wins: a new user resize replaces any retry chain
498
+ resizeStartRetry = { cols: payload.cols, rows: payload.rows, attempt: 0 };
499
+ }
500
+ return { commandId };
501
+ }
502
+
503
+ /** Enter collecting from a begin message (both probing and resyncing).
504
+ * Deliberately does NOT reset the failure budget: a runner that keeps
505
+ * sending begins interleaved with garbage must still trip the cap — only
506
+ * a VERIFIED snapshot (or verified replay) pays the budget back. */
507
+ const beginCollecting = (msg) => {
508
+ decideProtocol();
509
+ cancelProbeTimer();
510
+ // Size-sync hook (CR R1 blocking): every begin carries the runner's
511
+ // captured geometry — empty baselines included, so a child that has not
512
+ // produced output yet still gets corrected BEFORE its first bytes land.
513
+ if (typeof msg.cols === "number" && typeof msg.rows === "number") {
514
+ emit("snapshotBegin", {
515
+ cols: msg.cols,
516
+ rows: msg.rows,
517
+ ...(msg.empty === true ? { empty: true } : {}),
518
+ ...(typeof msg.generation === "string" ? { generation: msg.generation } : {}),
519
+ });
520
+ }
521
+ let beginCoverage = null;
522
+ if (msg.viewProof !== undefined) {
523
+ beginCoverage = sanitizeViewProofCoverage(msg.viewProof);
524
+ if (beginCoverage === null) {
525
+ emit("protocolError", { code: "view_proof_invalid", message: "snapshot_begin.viewProof" });
526
+ beginCoverage = unknownViewProof("invalid-view-proof");
527
+ }
528
+ }
529
+ partial = {
530
+ ...emptyPartial(),
531
+ empty: msg.empty === true,
532
+ resnapshot: msg.resnapshot === true,
533
+ beginSeq: typeof msg.snapshotSeq === "number" ? msg.snapshotSeq : 0,
534
+ viewProofBegin: beginCoverage,
535
+ };
536
+ state = "collecting";
537
+ if (partial.empty) {
538
+ // Empty baselines carry no frame and no flush; the end message is
539
+ // already in the runner's send batch.
540
+ return;
541
+ }
542
+ };
543
+
544
+ const finishCollecting = (msg) => {
545
+ const nextSeq = msg.nextSeq;
546
+ if (!partial.frame && !partial.empty) {
547
+ // begin (non-empty) + end with no frame in between: never paint from
548
+ // an unverified window. Bounded recovery: fresh snapshot.
549
+ resyncOrFail("snapshot_frame_missing");
550
+ return;
551
+ }
552
+ const expected = partial.flush.length
553
+ ? partial.flush[partial.flush.length - 1].seq + 1
554
+ : (partial.empty ? 0 : partial.beginSeq) + 1;
555
+ if (nextSeq !== expected) {
556
+ // Continuity broken inside the snapshot window: never paint a
557
+ // partially-verified frame. Recovery: fresh snapshot.
558
+ resyncOrFail("snapshot_end_mismatch");
559
+ return;
560
+ }
561
+ snapshotFailures = 0; // a verified snapshot pays back the recovery budget
562
+ let endCoverage = null;
563
+ if (msg.viewProof !== undefined) {
564
+ endCoverage = sanitizeViewProofCoverage(msg.viewProof);
565
+ if (endCoverage === null) {
566
+ emit("protocolError", { code: "view_proof_invalid", message: "snapshot_end.viewProof" });
567
+ endCoverage = unknownViewProof("invalid-view-proof");
568
+ }
569
+ }
570
+ let viewProof = endCoverage ?? partial.viewProofState ?? partial.viewProofBegin;
571
+ let viewProofRecords = partial.viewProofRecords;
572
+ if (viewProof !== null && viewProof.status === "covered" && viewProof.throughSeq > nextSeq - 1) {
573
+ viewProof = unknownViewProof("view-proof-future");
574
+ }
575
+ if (partial.viewProofRecordsDropped) {
576
+ viewProof = unknownViewProof("records-dropped");
577
+ viewProofRecords = [];
578
+ } else if (viewProofRecords.length > 0) {
579
+ // A record after the baseline needs its catch-up output segment. Missing it
580
+ // means the client cannot prove the receipt order; never keep "covered".
581
+ const flushSeqs = new Set(partial.flush.map((chunk) => chunk.seq));
582
+ const missing = viewProofRecords.some((record) => record.seq > partial.beginSeq && !flushSeqs.has(record.seq));
583
+ if (missing && (viewProof === null || viewProof.status === "covered")) viewProof = unknownViewProof("proof-output-gap");
584
+ }
585
+ emit("snapshotReady", {
586
+ frame: partial.frame ?? undefined,
587
+ empty: partial.empty || undefined,
588
+ resnapshot: partial.resnapshot || undefined,
589
+ nextSeq,
590
+ // Optional field: omit the key for a runner that predates it, so the
591
+ // event shape stays exactly what old consumers assert on.
592
+ ...(typeof msg.scrollbackLines === "number" ? { scrollbackLines: msg.scrollbackLines } : {}),
593
+ ...(viewProof === null ? {} : { viewProof }),
594
+ ...(viewProofRecords.length > 0 ? { viewProofRecords } : {}),
595
+ });
596
+ for (const chunk of partial.flush) emit("output", chunk.data);
597
+ lastSeq = nextSeq - 1;
598
+ state = "live";
599
+ };
600
+
601
+ /**
602
+ * Handle one runner→client message. Returns true when the message is
603
+ * protocol-managed (consumed); false means the caller (UI) should process
604
+ * it through its existing path.
605
+ *
606
+ * @param {any} msg
607
+ * @returns {boolean}
608
+ */
609
+ function handleMessage(msg) {
610
+ if (!msg || typeof msg !== "object" || state === "closed" || state === "legacy") return false;
611
+ switch (msg.type) {
612
+ case "hello":
613
+ case "status": {
614
+ // Identity/generation peek (UI-owned message, not consumed).
615
+ observeIdentity(msg);
616
+ return false;
617
+ }
618
+ case "cmd_ack": {
619
+ // Control-lifecycle correlation (spec D4). The ack is consumed even
620
+ // for unknown commandIds (a late ack after internal retry cleanup is
621
+ // not a UI message).
622
+ const pending = pendingCommands.get(msg.commandId);
623
+ if (pending) {
624
+ const verdict = classifyCommandAck(pending.type, msg);
625
+ if (verdict.ok) {
626
+ if (verdict.stage === "applied" || verdict.stage === "observed" || verdict.stage === "superseded") {
627
+ pendingCommands.delete(msg.commandId);
628
+ if (pending.type === "resize") cancelResizeStartRetry();
629
+ }
630
+ } else {
631
+ emit("protocolError", { code: "ack_invalid", message: verdict.reason, commandId: msg.commandId });
632
+ }
633
+ }
634
+ emit("cmdAck", { commandId: msg.commandId, type: pending?.type, stage: msg.stage, cols: msg.cols, rows: msg.rows, ...(msg.geometryRevision === undefined ? {} : { resized: msg.resized, geometryRevision: msg.geometryRevision, intentId: msg.intentId }), byCommandId: msg.byCommandId, value: msg.value, exitConfirmed: msg.exitConfirmed, runnerFinalizing: msg.runnerFinalizing });
635
+ return true;
636
+ }
637
+ case "reconcile_result": {
638
+ if (state === "reconciling") resolveReconnect(msg);
639
+ return true;
640
+ }
641
+ case "error": {
642
+ if (msg.code === "frame_version_mismatch") {
643
+ // Wire contract mismatch: treat this runner as incompatible.
644
+ fallbackToLegacy("frame_version_mismatch");
645
+ return true;
646
+ }
647
+ if (msg.code === "view_proof_version_mismatch") {
648
+ // Conservative: never fake modern proof for an incompatible runner.
649
+ fallbackToLegacy("view_proof_version_mismatch");
650
+ return true;
651
+ }
652
+ if (msg.code === "snapshot_failed" || msg.code === "invalid_since_seq") {
653
+ if (state === "probing" || state === "resyncing" || state === "collecting") {
654
+ resyncOrFail(msg.code);
655
+ return true;
656
+ }
657
+ return false;
658
+ }
659
+ if (msg.code === "instance_mismatch" && state === "reconciling" && typeof msg.currentInstanceId === "string" && msg.currentInstanceId) {
660
+ // Recovery signal (review ruling 1): the host was replaced with a
661
+ // new instance fence — adopt and re-reconcile (same deadline).
662
+ instanceId = msg.currentInstanceId;
663
+ sendReconcile();
664
+ return true;
665
+ }
666
+ if (msg.code === "host_starting" && msg.commandId) {
667
+ const pending = pendingCommands.get(msg.commandId);
668
+ pendingCommands.delete(msg.commandId);
669
+ emit("cmdAck", { commandId: msg.commandId, type: pending?.type, stage: "error", code: msg.code });
670
+ if (pending?.type === "resize" && resizeStartRetry) {
671
+ scheduleResizeStartRetry(resizeStartRetry);
672
+ }
673
+ return true;
674
+ }
675
+ if (msg.code && CONTROL_ERROR_CODES[msg.code] && msg.commandId) {
676
+ // Task 4 (review P2): a taxonomy error correlated by commandId must
677
+ // consume the pending entry and surface as a cmdAck error — a
678
+ // swallowed error leaves the command pending forever. Terminal codes
679
+ // also cancel the resize starting-window retry chain (retrying into
680
+ // a moved fence or a caller-bug rejection is futile; host_starting
681
+ // above keeps its bounded-retry semantics).
682
+ const pending = pendingCommands.get(msg.commandId);
683
+ pendingCommands.delete(msg.commandId);
684
+ if (pending?.type === "resize" && TERMINAL_ERROR_CODES.includes(msg.code)) {
685
+ cancelResizeStartRetry();
686
+ }
687
+ emit("cmdAck", { commandId: msg.commandId, type: pending?.type, stage: "error", code: msg.code, currentInstanceId: msg.currentInstanceId });
688
+ return true;
689
+ }
690
+ return false; // UI-owned status errors
691
+ }
692
+ case "view_proof": {
693
+ const record = sanitizeViewProofRecord(msg.record);
694
+ if (record === null) {
695
+ emit("protocolError", { code: "view_proof_invalid", message: "view_proof.record" });
696
+ return true;
697
+ }
698
+ if (state === "collecting") {
699
+ if (partial.viewProofRecords.length >= MAX_VIEW_PROOF_RECORDS) partial.viewProofRecordsDropped = true;
700
+ else partial.viewProofRecords.push(record);
701
+ return true;
702
+ }
703
+ if (state === "live" || state === "resyncing") {
704
+ if (record.seq > lastSeq) {
705
+ emit("protocolError", { code: "view_proof_future", message: `seq ${record.seq} > ${lastSeq}` });
706
+ return true;
707
+ }
708
+ emit("viewProofRecord", record);
709
+ return true;
710
+ }
711
+ return true; // probing/reconciling: pre-subscribe stray
712
+ }
713
+ case "view_proof_state": {
714
+ let coverage = sanitizeViewProofCoverage(msg.viewProof);
715
+ if (coverage === null) {
716
+ emit("protocolError", { code: "view_proof_invalid", message: "view_proof_state.viewProof" });
717
+ coverage = unknownViewProof("invalid-view-proof");
718
+ }
719
+ if ((state === "live" || state === "resyncing") && coverage.status === "covered" && coverage.throughSeq > lastSeq) {
720
+ emit("protocolError", { code: "view_proof_future", message: `throughSeq ${coverage.throughSeq} > ${lastSeq}` });
721
+ coverage = unknownViewProof("view-proof-future");
722
+ }
723
+ if (state === "collecting") {
724
+ partial.viewProofState = coverage;
725
+ return true;
726
+ }
727
+ if (state === "resyncing") {
728
+ // Zero-output replay completion: the state message is the only
729
+ // proof-bearing completion signal. Normal replay completes on output.
730
+ decideProtocol();
731
+ snapshotFailures = 0;
732
+ state = "live";
733
+ emit("viewProof", coverage);
734
+ return true;
735
+ }
736
+ if (state === "live") {
737
+ emit("viewProof", coverage);
738
+ return true;
739
+ }
740
+ return true; // probing/reconciling: pre-subscribe stray
741
+ }
742
+ case "snapshot_begin": {
743
+ if (typeof msg.generation === "string" && msg.generation) generation = msg.generation;
744
+ if (state === "probing" || state === "resyncing") {
745
+ beginCollecting(msg);
746
+ return true;
747
+ }
748
+ if (state === "reconciling") return true; // pre-subscribe stray: consume
749
+ // Unsolicited begin in collecting (duplicate) or live: protocol
750
+ // violation — strictness surfaces runner bugs; a fresh subscribe
751
+ // converges either way.
752
+ resyncOrFail("unexpected_snapshot_begin");
753
+ return true;
754
+ }
755
+ case "snapshot_frame": {
756
+ if (state === "reconciling") return true; // pre-subscribe stray: consume
757
+ if (state !== "collecting" || partial.empty || typeof msg.data !== "string") {
758
+ resyncOrFail("unexpected_snapshot_frame");
759
+ return true;
760
+ }
761
+ partial.frame = msg.data;
762
+ return true;
763
+ }
764
+ case "snapshot_end": {
765
+ if (state === "reconciling") return true; // pre-subscribe stray: consume
766
+ if (state !== "collecting") {
767
+ resyncOrFail("unexpected_snapshot_end");
768
+ return true;
769
+ }
770
+ finishCollecting(msg);
771
+ return true;
772
+ }
773
+ case "resnapshot_required": {
774
+ if (state === "live" || state === "collecting" || state === "resyncing") {
775
+ resyncOrFail("resnapshot_required");
776
+ return true;
777
+ }
778
+ if (state === "reconciling") return true; // pre-subscribe stray: consume
779
+ return false;
780
+ }
781
+ case "output": {
782
+ if (state === "live") {
783
+ if (typeof msg.seq !== "number") {
784
+ // A protocol-owned socket must never receive seq-less
785
+ // output; consume and ignore so unsequenced bytes can
786
+ // never corrupt the canonical-state buffer.
787
+ return true;
788
+ }
789
+ if (msg.seq === lastSeq + 1) {
790
+ lastSeq = msg.seq;
791
+ emit("output", msg.data);
792
+ return true;
793
+ }
794
+ if (msg.seq <= lastSeq) return true; // duplicate/stale
795
+ resyncOrFail("seq_gap");
796
+ return true;
797
+ }
798
+ if (state === "collecting") {
799
+ if (typeof msg.seq !== "number") return true; // consume+ignore, see live branch
800
+ if (!partial.frame) {
801
+ // Output before the frame: the runner never emits this
802
+ // (its catch-up flush is frame → outputs → end). Violation.
803
+ resyncOrFail("output_before_snapshot_frame");
804
+ return true;
805
+ }
806
+ const expected = partial.flush.length
807
+ ? partial.flush[partial.flush.length - 1].seq + 1
808
+ : partial.beginSeq + 1;
809
+ if (msg.seq !== expected) {
810
+ resyncOrFail("snapshot_flush_gap");
811
+ return true;
812
+ }
813
+ partial.flush.push({ seq: msg.seq, data: msg.data });
814
+ return true;
815
+ }
816
+ if (state === "resyncing") {
817
+ // Complete-replay continuation is the only accepted output:
818
+ // it proves the runner answered with the full retained range.
819
+ if (typeof msg.seq === "number" && msg.seq === lastSeq + 1) {
820
+ decideProtocol();
821
+ lastSeq = msg.seq;
822
+ emit("output", msg.data);
823
+ state = "live";
824
+ snapshotFailures = 0; // verified complete replay pays the budget back
825
+ return true;
826
+ }
827
+ return true; // strays (pre-resync in-flight chunks): ignored
828
+ }
829
+ if (state === "reconciling") {
830
+ // The fresh socket is not subscribed yet: everything here is
831
+ // broadcast stray. Consume so the UI legacy path can never
832
+ // double-feed — and EMIT it: these bytes must reach the UI exactly
833
+ // once (OBSERVED strays cover (cursor, strayHighWater]; the replay
834
+ // starts past the high-water; in-flight strays are re-sent by the
835
+ // replay and deduped). Consuming WITHOUT emitting would silently
836
+ // drop them from the rendered output — a data-loss regression the
837
+ // task-5 review caught. lastSeq deliberately does NOT advance here:
838
+ // the subscribe cursor (max(cursor, strayHighWater)) carries the
839
+ // applied position once the replay/stream resumes.
840
+ if (typeof msg.seq === "number" && msg.seq > strayHighWater) strayHighWater = msg.seq;
841
+ emit("output", msg.data);
842
+ return true;
843
+ }
844
+ // probing: outputs belong to the legacy broadcast window (old
845
+ // runner, or new runner before subscribe processing). Not ours.
846
+ return false;
847
+ }
848
+ // (A duplicate dead `case "error":` block that survived the phase-5
849
+ // insertion was removed here — task 3 review P2: the live error case
850
+ // above returns on every path, so a second label was unreachable.)
851
+ default:
852
+ return false;
853
+ }
854
+ }
855
+
856
+ /** Arm the probe. Sends only `subscribe_terminal` — the UI keeps ownership
857
+ * of `hello` (it carries UI-specific fields) and sends it itself. */
858
+ function start() {
859
+ if (state === "legacy") {
860
+ if (!modeDecided) {
861
+ modeDecided = true;
862
+ emit("mode", "legacy");
863
+ }
864
+ return;
865
+ }
866
+ if (state !== "probing") return;
867
+ send(subscribeMessage());
868
+ fireProbeTimer();
869
+ }
870
+
871
+ /**
872
+ * New socket after a drop. Protocol mode: spec D4 binds the reconnect order
873
+ * `hello → reconcile → snapshot/subscribe` — the reconcile answer's
874
+ * generation drives the epoch rule (changed ⇒ discard cursor ⇒ fresh
875
+ * snapshot; same ⇒ replay from the applied cursor). Still probing: re-issue
876
+ * the probe on the new socket. Legacy: nothing — the UI legacy path
877
+ * reconnects on its own. A legacy-mode HOST (instanceId null) cannot be
878
+ * reconciled by contract (envelopes are never sent there) → phase-4
879
+ * semantics directly (the runner's resnapshot/empty flags protect).
880
+ *
881
+ * @param {number} cursorSeq last applied protocol seq (getLastSeq())
882
+ */
883
+ function reconnect(cursorSeq) {
884
+ if (state === "closed" || state === "legacy") return;
885
+ if (state === "probing") {
886
+ send(subscribeMessage());
887
+ fireProbeTimer();
888
+ return;
889
+ }
890
+ seq = 0; // per-connection ordering resets with the socket (spec D4)
891
+ snapshotFailures = 0; // fresh connection, fresh recovery budget
892
+ partial = emptyPartial();
893
+ if (!instanceId) {
894
+ issueReconnectSubscribe(cursorSeq);
895
+ return;
896
+ }
897
+ reconnectCursor = cursorSeq;
898
+ reconnectFromGeneration = generationAtDisconnect ?? generation;
899
+ state = "reconciling";
900
+ cancelReconcileTimer();
901
+ reconcileTimer = scheduleTimeout(reconcileTimeoutMs, () => {
902
+ reconcileTimer = null;
903
+ if (state !== "reconciling") return;
904
+ // Unanswered reconcile (phase-4 runner or dead window): fall back to
905
+ // the phase-4 reconnect (sinceSeq; the runner's begin flags protect).
906
+ issueReconnectSubscribe(reconnectCursor ?? 0);
907
+ });
908
+ if (instanceId && viewId) sendReconcile();
909
+ // else: wait for the fresh hello (observeIdentity triggers sendReconcile).
910
+ }
911
+
912
+ /** Tear down: cancel timers, go inert. */
913
+ function close() {
914
+ cancelProbeTimer();
915
+ cancelReconcileTimer();
916
+ // CR R1 advisory: undeliverable correlation entries must not outlive the
917
+ // client — a new connection issues fresh commandIds, so a stale entry can
918
+ // never be consumed by a late ack.
919
+ pendingCommands.clear();
920
+ state = "closed";
921
+ }
922
+
923
+ /** Socket dropped (CR R1 advisory): cancel the probe deadline so a dead
924
+ * window can never downgrade a protocol-capable runner. The timer is
925
+ * absolute — a reconnect landing after the 1500ms deadline would otherwise
926
+ * inherit the stale "no snapshot" verdict for the whole session. The next
927
+ * `start()`/`reconnect()` on the new socket re-arms everything; decided
928
+ * modes (legacy) and closed stay inert. */
929
+ function onDisconnect() {
930
+ if (state === "closed" || state === "legacy") return;
931
+ // Epoch baseline: snapshot the generation AS OF THE DISCONNECT. The live
932
+ // `generation` field will be refreshed by the replacement runner's hello
933
+ // before reconnect() runs, so only this capture can detect a change.
934
+ if (generation) generationAtDisconnect = generation;
935
+ cancelProbeTimer();
936
+ cancelReconcileTimer();
937
+ // CR R1 advisory: pending correlation entries cannot be acked through a
938
+ // dead socket; a reconnect uses fresh commandIds. Clearing here means a
939
+ // half-open late ack is treated as uncorrelated — fine, the socket died.
940
+ pendingCommands.clear();
941
+ }
942
+
943
+ return {
944
+ start,
945
+ reconnect,
946
+ onDisconnect,
947
+ handleMessage,
948
+ close,
949
+ /** Bounded fresh seq-less resync request (no timer/retry loop here). */
950
+ requestResync(reason = "requested") {
951
+ if (state === "closed" || state === "legacy") return false;
952
+ resync(reason);
953
+ return true;
954
+ },
955
+ /** Conservative public legacy decision (used for observer-unavailable). */
956
+ forceLegacy(reason = "forced") {
957
+ if (state === "closed") return false;
958
+ if (state === "legacy") return true;
959
+ fallbackToLegacy(reason);
960
+ return true;
961
+ },
962
+ /** @returns {"probing" | "protocol" | "legacy" | "closed"} UI-facing mode */
963
+ getMode() {
964
+ if (state === "collecting" || state === "live" || state === "resyncing" || state === "reconciling") return "protocol";
965
+ return state;
966
+ },
967
+ /** Last applied protocol seq (reconnect cursor). */
968
+ getLastSeq() {
969
+ return lastSeq;
970
+ },
971
+ sendControl,
972
+ /** Identity snapshot (test/diagnostics aid). */
973
+ getIdentity() {
974
+ return { instanceId, viewId, generation };
975
+ },
976
+ };
977
+ }
978
+
979
+ /**
980
+ * @typedef {ReturnType<typeof createTerminalAttachClient>} TerminalAttachClient
981
+ */