@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,709 @@
1
+ /**
2
+ * Pure attach view-proof coverage and safe-trailing classification (issue #222, step 23b2a).
3
+ *
4
+ * This module is deliberately inert: no sockets, files, timers, terminals, fs or full
5
+ * scrollback. It owns the FW-06 decisions that a future runner/subscription (23b2b) and
6
+ * attach consumer (23b2c) must share, so both can be unit-tested without a PTY:
7
+ *
8
+ * 1. `createSafeTrailingClassifier()` — a streaming, bounded, grammar-aware classifier for
9
+ * the EXHAUSTIVE §5 post-F allowlist. Ordinary output is split arbitrarily; incomplete
10
+ * CSI/OSC tails are carried across `push()` calls, and unknown, malformed or oversize
11
+ * input becomes sticky `unsafe`. A complete safe sequence remains an ordinary control
12
+ * fact (title/progress/query/keyboard mode/DECTCEM): it never becomes a proof and never
13
+ * erases what the control meant.
14
+ * 2. `createViewProofTracker()` — records ordered output segments (`feedOutput`), producer
15
+ * candidate boundaries (`openCandidate`/`abortCandidate`) and completed proofs
16
+ * (`completeProof`), then answers `coverageFor(snapshotSeq)` and bounded `replay()`.
17
+ *
18
+ * Coverage uses `proof.seq`, the preceding ORDINARY output segment sequence — never a
19
+ * child frameId, wall clock or socket arrival order. Exact coverage requires
20
+ * `proof.seq === snapshotSeq`. An older proof is accepted only while every segment after it
21
+ * (through the snapshot) is completely classified safe. Unsafe or pending output,
22
+ * an open/aborted producer candidate, gaps, malformed identity and evicted history stay
23
+ * unavailable/UNKNOWN; they are never stitched into a proof.
24
+ *
25
+ * The journal stores only bounded classification/proof metadata and dropped counts. It
26
+ * never stores output text, raw frames or a copied scrollback. Replay drop counts are
27
+ * deliberately split: aggregate counts are LIFETIME, per-entry counts are RANGE-LOCAL, and
28
+ * range completeness comes from the entries plus explicit gap/evicted status — never from a
29
+ * lifetime count alone.
30
+ *
31
+ * `snapshot().currentProof` is the last COMPLETED proof only. It is not coverage: output may
32
+ * have arrived after it, a candidate may be open, or the journal may be truncated. Callers
33
+ * that need a display decision must use `coverageFor(snapshotSeq)`.
34
+ */
35
+ import { validateFrameMetadata } from "./attach-frame-proof.mjs";
36
+
37
+ export const VIEW_PROOF_PROTOCOL = 1;
38
+ /** Maximum carried/incomplete escape tail and complete classifiable sequence size, in UTF-8 BYTES. */
39
+ export const MAX_TRAILING_PENDING = 4096;
40
+ export const MAX_VIEW_PROOF_JOURNAL = 256;
41
+ export const MAX_VIEW_PROOF_DIAGNOSTICS = 64;
42
+ export const MAX_PROOFS_PER_SEQ = 16;
43
+ export const MAX_EVENTS_PER_SEQ = 16;
44
+
45
+ const ESC = "\u001b";
46
+ const BEL = "\u0007";
47
+ const CSI_FINAL = /[@-~]/;
48
+ const CSI_PARAM_OR_INTERMEDIATE = /[\x20-\x3f]/;
49
+ const DCS_INTRO = "\u001bP";
50
+ const SOS_INTRO = "\u001bX";
51
+ const PM_INTRO = "\u001b^";
52
+ const APC_INTRO = "\u001b_";
53
+
54
+ function utf8Bytes(text) {
55
+ return Buffer.byteLength(text, "utf8");
56
+ }
57
+
58
+ function defaultMetadata() {
59
+ return {
60
+ bracketedPaste: null,
61
+ colorSchemeNotifications: null,
62
+ keyboardProtocolPushes: 0,
63
+ keyboardProtocolPops: 0,
64
+ keyboardProtocolQueries: 0,
65
+ modifyOtherKeys: null,
66
+ cellSizeQueries: 0,
67
+ deviceAttributesQueries: 0,
68
+ paletteQueries: 0,
69
+ titles: 0,
70
+ progress: null,
71
+ programStatuses: 0,
72
+ };
73
+ }
74
+
75
+ function cloneMetadata(metadata) {
76
+ return { ...metadata, progress: metadata.progress === null ? null : { ...metadata.progress } };
77
+ }
78
+
79
+ function cloneEvent(event) {
80
+ return event.progress === undefined ? { ...event } : { ...event, progress: { ...event.progress } };
81
+ }
82
+
83
+ function boundEvents(events) {
84
+ const stored = events.slice(0, MAX_EVENTS_PER_SEQ).map(cloneEvent);
85
+ return { events: stored, droppedEvents: Math.max(0, events.length - stored.length) };
86
+ }
87
+
88
+ /**
89
+ * Classify one complete CSI body/final. Returns an event for an exact §5 allowlist item,
90
+ * otherwise `null` (caller marks the sequence unsafe). Plain SGR is intentionally absent:
91
+ * inside a producer frame rendition is annotation, but after F the allowlist is exhaustive.
92
+ */
93
+ function classifyCsi(body, final) {
94
+ if (body === "?2004" && (final === "h" || final === "l")) return { family: "bracketed-paste", enabled: final === "h" };
95
+ if (body === "?2031" && (final === "h" || final === "l")) return { family: "color-scheme", enabled: final === "h" };
96
+ if (body === "16" && final === "t") return { family: "cell-size-query" };
97
+ if (body === "" && final === "c") return { family: "device-attributes" };
98
+ if (body === ">7" && final === "u") return { family: "keyboard-protocol-push" };
99
+ if (body === "?" && final === "u") return { family: "keyboard-protocol-query" };
100
+ if ((body === ">4;0" || body === ">4;2") && final === "m") return { family: "modify-other-keys", mode: body === ">4;0" ? 0 : 2 };
101
+ if (body === "<" && final === "u") return { family: "keyboard-protocol-pop" };
102
+ if (body === "?25" && (final === "h" || final === "l")) return { family: "dectcem", visible: final === "h" };
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * Classify one complete OSC body (bytes between `ESC ]` and BEL/ST). OSC 8, OSC 52 and
108
+ * palette SETTERS are deliberately absent; only the literal §5 queries are accepted.
109
+ * Content-bearing controls retain bounded meaning (title/status byte counts, progress
110
+ * numbers, palette query text) so the caller can still act on them.
111
+ */
112
+ function classifyOsc(body) {
113
+ if (body.startsWith("0;")) return { family: "osc-title", bytes: Buffer.byteLength(body.slice(2), "utf8") };
114
+ if (/^9;4;[0-9]+(?:;[0-9]+)?$/.test(body)) {
115
+ const parts = body.split(";");
116
+ const state = Number(parts[2]);
117
+ const percent = parts.length === 4 ? Number(parts[3]) : null;
118
+ return { family: "osc-progress", progress: { state, percent } };
119
+ }
120
+ if (body.startsWith("7501;")) return { family: "osc-program-status", bytes: Buffer.byteLength(body.slice(5), "utf8") };
121
+ if (/^4(;[0-9]+)*;\?$/.test(body) || /^(10|11);\?$/.test(body)) {
122
+ return { family: "osc-palette-query", query: body.slice(0, 64) };
123
+ }
124
+ return null;
125
+ }
126
+
127
+ function applyEvent(metadata, event) {
128
+ switch (event.family) {
129
+ case "bracketed-paste":
130
+ metadata.bracketedPaste = event.enabled;
131
+ return;
132
+ case "color-scheme":
133
+ metadata.colorSchemeNotifications = event.enabled;
134
+ return;
135
+ case "keyboard-protocol-push":
136
+ metadata.keyboardProtocolPushes += 1;
137
+ return;
138
+ case "keyboard-protocol-pop":
139
+ metadata.keyboardProtocolPops += 1;
140
+ return;
141
+ case "keyboard-protocol-query":
142
+ metadata.keyboardProtocolQueries += 1;
143
+ return;
144
+ case "modify-other-keys":
145
+ metadata.modifyOtherKeys = event.mode;
146
+ return;
147
+ case "cell-size-query":
148
+ metadata.cellSizeQueries += 1;
149
+ return;
150
+ case "device-attributes":
151
+ metadata.deviceAttributesQueries += 1;
152
+ return;
153
+ case "osc-title":
154
+ metadata.titles += 1;
155
+ return;
156
+ case "osc-progress":
157
+ metadata.progress = { ...event.progress };
158
+ return;
159
+ case "osc-program-status":
160
+ metadata.programStatuses += 1;
161
+ return;
162
+ case "osc-palette-query":
163
+ metadata.paletteQueries += 1;
164
+ return;
165
+ default:
166
+ return;
167
+ }
168
+ }
169
+
170
+ /** Scan a CSI from its ESC byte. Returns `{end, body, final}`, `{pending}` or `{unsafe}`. */
171
+ function scanCsi(text, start, maxPending) {
172
+ let index = start + 2;
173
+ while (index < text.length) {
174
+ const character = text[index];
175
+ if (CSI_FINAL.test(character)) {
176
+ const body = text.slice(start + 2, index);
177
+ if (utf8Bytes(body) > maxPending) return { unsafe: "sequence-oversize" };
178
+ return { end: index + 1, body, final: character };
179
+ }
180
+ if (!CSI_PARAM_OR_INTERMEDIATE.test(character)) return { unsafe: "csi-malformed" };
181
+ index += 1;
182
+ }
183
+ const tail = text.slice(start);
184
+ if (utf8Bytes(tail) > maxPending) return { unsafe: "pending-overflow" };
185
+ return { pending: tail };
186
+ }
187
+
188
+ /** Scan an OSC from its ESC byte, accepting only BEL or ST terminators. */
189
+ function scanOsc(text, start, maxPending) {
190
+ let index = start + 2;
191
+ while (index < text.length) {
192
+ const character = text[index];
193
+ if (character === BEL) {
194
+ const body = text.slice(start + 2, index);
195
+ if (utf8Bytes(body) > maxPending) return { unsafe: "sequence-oversize" };
196
+ return { end: index + 1, body };
197
+ }
198
+ if (character === ESC) {
199
+ const next = text[index + 1];
200
+ if (next === undefined) {
201
+ const tail = text.slice(start);
202
+ if (utf8Bytes(tail) > maxPending) return { unsafe: "pending-overflow" };
203
+ return { pending: tail };
204
+ }
205
+ if (next !== "\\") return { unsafe: "osc-malformed" };
206
+ const body = text.slice(start + 2, index);
207
+ if (utf8Bytes(body) > maxPending) return { unsafe: "sequence-oversize" };
208
+ return { end: index + 2, body };
209
+ }
210
+ if (character < " " || character === "\u007f") return { unsafe: "osc-malformed" };
211
+ index += 1;
212
+ }
213
+ const tail = text.slice(start);
214
+ if (utf8Bytes(tail) > maxPending) return { unsafe: "pending-overflow" };
215
+ return { pending: tail };
216
+ }
217
+
218
+ /**
219
+ * Streaming exporter for the post-F no-grid control window.
220
+ *
221
+ * `push(chunk)` returns `{status, reason, events, droppedEvents, pendingBytes,
222
+ * cursorVisible, metadata}` where status is exactly `safe`, `pending` or `unsafe`, and
223
+ * `pendingBytes` is a UTF-8 byte count. `pending` is NOT safe: an incomplete escape tail has
224
+ * not been classified yet. Once `unsafe`, the classifier stays unsafe until `reset()` (the
225
+ * caller resets only on a new complete producer proof).
226
+ */
227
+ export function createSafeTrailingClassifier({ maxPending = MAX_TRAILING_PENDING } = {}) {
228
+ if (!Number.isInteger(maxPending) || maxPending < 16) throw new Error("safe trailing classifier requires maxPending >= 16");
229
+ let pending = "";
230
+ let unsafeReason = null;
231
+ let cursorVisible = null;
232
+ let metadata = defaultMetadata();
233
+
234
+ const result = (status, events) => {
235
+ const bounded = boundEvents(events);
236
+ return {
237
+ status,
238
+ reason: unsafeReason,
239
+ events: bounded.events,
240
+ droppedEvents: bounded.droppedEvents,
241
+ pendingBytes: utf8Bytes(pending),
242
+ cursorVisible,
243
+ metadata: cloneMetadata(metadata),
244
+ };
245
+ };
246
+ const markUnsafe = (reason) => {
247
+ unsafeReason = reason;
248
+ pending = "";
249
+ };
250
+
251
+ function push(chunk) {
252
+ if (unsafeReason !== null) return result("unsafe", []);
253
+ if (typeof chunk !== "string") {
254
+ markUnsafe("invalid-input");
255
+ return result("unsafe", []);
256
+ }
257
+ const text = pending + chunk;
258
+ pending = "";
259
+ const events = [];
260
+ let index = 0;
261
+ while (index < text.length) {
262
+ if (text[index] !== ESC) {
263
+ markUnsafe("bare-byte");
264
+ break;
265
+ }
266
+ const next = text[index + 1];
267
+ if (next === undefined) {
268
+ pending = text.slice(index);
269
+ break;
270
+ }
271
+ if (next === "[") {
272
+ const scanned = scanCsi(text, index, maxPending);
273
+ if (scanned.unsafe !== undefined) {
274
+ markUnsafe(scanned.unsafe);
275
+ break;
276
+ }
277
+ if (scanned.pending !== undefined) {
278
+ pending = scanned.pending;
279
+ break;
280
+ }
281
+ const event = classifyCsi(scanned.body, scanned.final);
282
+ if (event === null) {
283
+ markUnsafe("csi-unknown");
284
+ break;
285
+ }
286
+ events.push(event);
287
+ applyEvent(metadata, event);
288
+ if (event.family === "dectcem") cursorVisible = event.visible;
289
+ index = scanned.end;
290
+ continue;
291
+ }
292
+ if (next === "]") {
293
+ const scanned = scanOsc(text, index, maxPending);
294
+ if (scanned.unsafe !== undefined) {
295
+ markUnsafe(scanned.unsafe);
296
+ break;
297
+ }
298
+ if (scanned.pending !== undefined) {
299
+ pending = scanned.pending;
300
+ break;
301
+ }
302
+ const event = classifyOsc(scanned.body);
303
+ if (event === null) {
304
+ markUnsafe("osc-unknown");
305
+ break;
306
+ }
307
+ events.push(event);
308
+ applyEvent(metadata, event);
309
+ index = scanned.end;
310
+ continue;
311
+ }
312
+ if (text.startsWith(DCS_INTRO, index)) markUnsafe("dcs");
313
+ else if (text.startsWith(SOS_INTRO, index) || text.startsWith(PM_INTRO, index) || text.startsWith(APC_INTRO, index)) markUnsafe("string-control");
314
+ else markUnsafe("escape-unknown");
315
+ break;
316
+ }
317
+ if (unsafeReason !== null) return result("unsafe", events);
318
+ if (utf8Bytes(pending) > maxPending) {
319
+ markUnsafe("pending-overflow");
320
+ return result("unsafe", events);
321
+ }
322
+ return result(pending.length > 0 ? "pending" : "safe", events);
323
+ }
324
+
325
+ /**
326
+ * Start a new trailing window after a complete proof. The sticky unsafe/pending state is
327
+ * cleared and cursor visibility comes from the new proof (null means UNKNOWN); terminal
328
+ * metadata such as an already-set title or keyboard mode is retained because a later
329
+ * frame does not erase what those controls meant.
330
+ */
331
+ /** @param {{ cursorVisible?: boolean }} [options] */
332
+ function reset({ cursorVisible: initialCursor = null } = {}) {
333
+ pending = "";
334
+ unsafeReason = null;
335
+ cursorVisible = typeof initialCursor === "boolean" ? initialCursor : null;
336
+ }
337
+
338
+ function state() {
339
+ return {
340
+ status: unsafeReason !== null ? "unsafe" : pending.length > 0 ? "pending" : "safe",
341
+ reason: unsafeReason,
342
+ pendingBytes: utf8Bytes(pending),
343
+ cursorVisible,
344
+ metadata: cloneMetadata(metadata),
345
+ };
346
+ }
347
+
348
+ return { push, reset, state };
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Proof tracker
353
+ // ---------------------------------------------------------------------------
354
+
355
+ const PROOF_FIELDS = new Set(["marker", "kind", "frameId", "seq", "sessionEpoch", "metadata", "cursorVisible"]);
356
+
357
+ function validateCandidate(record) {
358
+ if (record === null || typeof record !== "object" || Array.isArray(record)) return { ok: false, reason: "candidate-not-object" };
359
+ if (!Number.isInteger(record.seq) || record.seq < 1) return { ok: false, reason: "candidate-seq" };
360
+ if (!Number.isInteger(record.frameId) || record.frameId < 1) return { ok: false, reason: "candidate-frame-id" };
361
+ if (record.sessionEpoch !== undefined && (!Number.isInteger(record.sessionEpoch) || record.sessionEpoch < 0)) return { ok: false, reason: "candidate-session-epoch" };
362
+ return { ok: true, seq: record.seq, frameId: record.frameId, sessionEpoch: record.sessionEpoch ?? 0 };
363
+ }
364
+
365
+ function validateProof(record) {
366
+ if (record === null || typeof record !== "object" || Array.isArray(record)) return { ok: false, reason: "proof-not-object" };
367
+ if (record.reason !== undefined) return { ok: false, reason: "proof-reason" };
368
+ for (const key of Object.keys(record)) {
369
+ if (!PROOF_FIELDS.has(key)) return { ok: false, reason: `proof-unexpected-field:${key}` };
370
+ }
371
+ if (record.marker !== undefined && record.marker !== "F") return { ok: false, reason: "proof-marker" };
372
+ if (record.kind !== undefined && record.kind !== "F") return { ok: false, reason: "proof-kind" };
373
+ if (!Number.isInteger(record.seq) || record.seq < 1) return { ok: false, reason: "proof-seq" };
374
+ if (!Number.isInteger(record.frameId) || record.frameId < 1) return { ok: false, reason: "proof-frame-id" };
375
+ if (record.sessionEpoch !== undefined && (!Number.isInteger(record.sessionEpoch) || record.sessionEpoch < 0)) return { ok: false, reason: "proof-session-epoch" };
376
+ if (record.cursorVisible !== undefined && typeof record.cursorVisible !== "boolean") return { ok: false, reason: "proof-cursor-visible" };
377
+ if (record.metadata === undefined || record.metadata === null) return { ok: false, reason: "proof-metadata-missing" };
378
+ const validation = validateFrameMetadata(record.metadata);
379
+ if (!validation.ok) return { ok: false, reason: `proof-metadata:${validation.reason}` };
380
+ const sessionEpoch = record.sessionEpoch === undefined ? validation.value.sessionEpoch : record.sessionEpoch;
381
+ if (sessionEpoch !== validation.value.sessionEpoch) return { ok: false, reason: "proof-session-epoch-conflict" };
382
+ return {
383
+ ok: true,
384
+ marker: "F",
385
+ seq: record.seq,
386
+ frameId: record.frameId,
387
+ sessionEpoch,
388
+ cursorVisible: record.cursorVisible ?? null,
389
+ metadata: validation.value,
390
+ };
391
+ }
392
+
393
+ function validateAbort(record) {
394
+ const candidate = validateCandidate(record);
395
+ if (!candidate.ok) return candidate;
396
+ if (record.reason !== undefined && typeof record.reason !== "string") return { ok: false, reason: "abort-reason" };
397
+ return { ...candidate, reason: record.reason ?? null };
398
+ }
399
+
400
+ function cloneProof(proof) {
401
+ if (proof === null) return null;
402
+ return { ...proof, metadata: proof.metadata === null ? null : { ...proof.metadata } };
403
+ }
404
+
405
+ function cloneEntry(entry) {
406
+ return {
407
+ seq: entry.seq,
408
+ status: entry.status,
409
+ reason: entry.reason,
410
+ events: entry.events.map(cloneEvent),
411
+ droppedEvents: entry.droppedEvents,
412
+ droppedProofs: entry.droppedProofs,
413
+ pendingBytes: entry.pendingBytes,
414
+ cursorVisible: entry.cursorVisible,
415
+ proofs: entry.proofs.map(cloneProof),
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Create the ordered view-proof tracker. `feedOutput` must receive contiguous output
421
+ * segment sequence numbers (a first arbitrary number is accepted as the tracker's
422
+ * baseline); one missing number is a protocol error and keeps coverage UNKNOWN.
423
+ */
424
+ export function createViewProofTracker({ maxJournal = MAX_VIEW_PROOF_JOURNAL, maxPending = MAX_TRAILING_PENDING, maxDiagnostics = MAX_VIEW_PROOF_DIAGNOSTICS } = {}) {
425
+ if (!Number.isInteger(maxJournal) || maxJournal < 1) throw new Error("view proof tracker requires maxJournal >= 1");
426
+ if (!Number.isInteger(maxDiagnostics) || maxDiagnostics < 1) throw new Error("view proof tracker requires maxDiagnostics >= 1");
427
+ const classifier = createSafeTrailingClassifier({ maxPending });
428
+ const journal = new Map();
429
+ const diagnostics = [];
430
+ let firstSeq = null;
431
+ let lastOutputSeq = 0;
432
+ let lastProofSeq = 0;
433
+ let lastFrameId = 0;
434
+ let candidate = null;
435
+ let candidateState = "idle"; // idle | open | aborted
436
+ let currentProof = null;
437
+ let previousComplete = null;
438
+ let gap = false;
439
+ let pendingSinceSeq = null;
440
+ let droppedSegments = 0;
441
+ let droppedProofs = 0;
442
+ let droppedEvents = 0;
443
+
444
+ const diagnostic = (reason, extra = {}) => {
445
+ diagnostics.push({ reason, ...extra });
446
+ if (diagnostics.length > maxDiagnostics) diagnostics.splice(0, diagnostics.length - maxDiagnostics);
447
+ };
448
+
449
+ const protocolError = (reason, extra = {}) => {
450
+ diagnostic(reason, extra);
451
+ return { status: "protocol-error", reason, ...extra };
452
+ };
453
+
454
+ const earliestRetainedSeq = () => (journal.size === 0 ? null : journal.keys().next().value);
455
+
456
+ function insertEntry(entry) {
457
+ journal.set(entry.seq, entry);
458
+ if (journal.size > maxJournal) {
459
+ const oldest = journal.keys().next().value;
460
+ const evicted = journal.get(oldest);
461
+ journal.delete(oldest);
462
+ droppedSegments += 1;
463
+ // Per-entry drops were already counted at truncation time; eviction adds only the
464
+ // proofs still retained in the evicted entry. Adding entry.droppedProofs here would
465
+ // double count the lifetime aggregate.
466
+ droppedProofs += evicted.proofs.length;
467
+ }
468
+ }
469
+
470
+ function findNewestProofAtOrBefore(snapshotSeq) {
471
+ let found = null;
472
+ for (const entry of journal.values()) {
473
+ for (const proof of entry.proofs) if (proof.seq <= snapshotSeq) found = proof;
474
+ }
475
+ if (found === null && currentProof !== null && currentProof.seq <= snapshotSeq) return { proof: currentProof, evicted: true };
476
+ return { proof: found, evicted: false };
477
+ }
478
+
479
+ function setPendingEntriesStatus(fromSeq, throughSeq, status, reason) {
480
+ for (const entry of journal.values()) {
481
+ if (entry.seq >= fromSeq && entry.seq <= throughSeq && entry.status === "pending") {
482
+ entry.status = status;
483
+ entry.reason = reason;
484
+ }
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Feed one ordinary output segment. `seq` is the ordered output segment number, not a
490
+ * child frame id. The text is classified only; no copy of it is retained.
491
+ */
492
+ function feedOutput(seq, data) {
493
+ if (!Number.isInteger(seq) || seq < 1) return protocolError("invalid-output-seq", { seq });
494
+ if (typeof data !== "string") return protocolError("invalid-output-data", { seq });
495
+ if (firstSeq === null) {
496
+ firstSeq = seq;
497
+ lastOutputSeq = seq;
498
+ } else if (seq <= lastOutputSeq) {
499
+ return protocolError("output-seq-regressed", { seq, lastOutputSeq });
500
+ } else if (seq !== lastOutputSeq + 1) {
501
+ gap = true;
502
+ const expected = lastOutputSeq + 1;
503
+ lastOutputSeq = seq;
504
+ insertEntry({ seq, status: "unknown", reason: "output-gap", events: [], droppedEvents: 0, droppedProofs: 0, pendingBytes: 0, cursorVisible: null, proofs: [] });
505
+ return protocolError("output-gap", { seq, expected });
506
+ } else {
507
+ lastOutputSeq = seq;
508
+ }
509
+ const wasPending = classifier.state().status === "pending";
510
+ const pendingFrom = wasPending ? pendingSinceSeq : null;
511
+ const classified = classifier.push(data);
512
+ if (pendingFrom !== null) {
513
+ if (classified.status === "safe") setPendingEntriesStatus(pendingFrom, seq - 1, "safe", null);
514
+ else if (classified.status === "unsafe") setPendingEntriesStatus(pendingFrom, seq - 1, "unsafe", classified.reason);
515
+ }
516
+ if (classified.status === "pending") {
517
+ if (pendingSinceSeq === null) pendingSinceSeq = seq;
518
+ } else {
519
+ pendingSinceSeq = null;
520
+ }
521
+ insertEntry({
522
+ seq,
523
+ status: classified.status,
524
+ reason: classified.reason,
525
+ events: classified.events,
526
+ droppedEvents: classified.droppedEvents,
527
+ droppedProofs: 0,
528
+ pendingBytes: classified.pendingBytes,
529
+ cursorVisible: classified.cursorVisible,
530
+ proofs: [],
531
+ });
532
+ droppedEvents += classified.droppedEvents;
533
+ return {
534
+ status: classified.status,
535
+ reason: classified.reason,
536
+ seq,
537
+ events: classified.events,
538
+ droppedEvents: classified.droppedEvents,
539
+ cursorVisible: classified.cursorVisible,
540
+ metadata: classified.metadata,
541
+ };
542
+ }
543
+
544
+ /** Record the S boundary of a producer candidate (preceding output segment seq). */
545
+ function openCandidate(record) {
546
+ const validation = validateCandidate(record);
547
+ if (!validation.ok) return protocolError(validation.reason);
548
+ const { seq, frameId, sessionEpoch } = validation;
549
+ if (firstSeq === null || seq > lastOutputSeq) return protocolError("candidate-seq-future", { seq, lastOutputSeq });
550
+ if (candidateState === "open") return protocolError("candidate-already-open", { openFrameId: candidate.frameId, frameId });
551
+ if (seq < lastProofSeq) return protocolError("candidate-seq-regressed", { seq, lastProofSeq });
552
+ if (frameId <= lastFrameId) return protocolError("frame-id-regressed", { frameId, lastFrameId });
553
+ lastFrameId = frameId;
554
+ previousComplete = cloneProof(currentProof);
555
+ candidate = { seq, frameId, sessionEpoch };
556
+ candidateState = "open";
557
+ return { status: "ok", candidate: { ...candidate } };
558
+ }
559
+
560
+ /** Abort an open candidate. The aborted frame can never become a proof. */
561
+ function abortCandidate(record) {
562
+ const validation = validateAbort(record);
563
+ if (!validation.ok) return protocolError(validation.reason);
564
+ const { seq, frameId, reason } = validation;
565
+ if (candidateState !== "open" || candidate === null) return protocolError("abort-without-candidate", { frameId });
566
+ if (candidate.frameId !== frameId) return protocolError("abort-frame-mismatch", { frameId, openFrameId: candidate.frameId });
567
+ if (seq < candidate.seq || seq > lastOutputSeq) return protocolError("abort-seq", { seq, candidateSeq: candidate.seq, lastOutputSeq });
568
+ candidate = null;
569
+ candidateState = "aborted";
570
+ diagnostic("candidate-aborted", { seq, frameId, reason });
571
+ return { status: "ok", candidateState };
572
+ }
573
+
574
+ /**
575
+ * Complete the open candidate. The accepted proof's `seq` is the preceding ordinary
576
+ * output segment. A new complete proof resets the trailing classifier and supersedes
577
+ * any same-seq proof already recorded for current coverage.
578
+ */
579
+ function completeProof(record) {
580
+ const validation = validateProof(record);
581
+ if (!validation.ok) return protocolError(validation.reason);
582
+ const { seq, frameId, sessionEpoch, cursorVisible, metadata } = validation;
583
+ if (candidateState !== "open" || candidate === null) return protocolError("proof-without-candidate", { seq, frameId });
584
+ if (candidate.frameId !== frameId) return protocolError("proof-frame-mismatch", { frameId, openFrameId: candidate.frameId });
585
+ if (candidate.sessionEpoch !== sessionEpoch) return protocolError("proof-session-epoch-mismatch", { sessionEpoch, candidateSessionEpoch: candidate.sessionEpoch });
586
+ if (seq < candidate.seq) return protocolError("proof-seq-before-candidate", { seq, candidateSeq: candidate.seq });
587
+ if (seq > lastOutputSeq) return protocolError("proof-seq-future", { seq, lastOutputSeq });
588
+ if (seq < lastProofSeq) return protocolError("proof-seq-regressed", { seq, lastProofSeq });
589
+ const entry = journal.get(seq);
590
+ if (entry === undefined) return protocolError("proof-seq-not-observed", { seq });
591
+ const proof = { marker: "F", seq, frameId, sessionEpoch, metadata, cursorVisible };
592
+ entry.proofs.push(proof);
593
+ if (entry.proofs.length > MAX_PROOFS_PER_SEQ) {
594
+ entry.proofs.shift();
595
+ entry.droppedProofs += 1;
596
+ droppedProofs += 1;
597
+ }
598
+ lastProofSeq = seq;
599
+ currentProof = proof;
600
+ previousComplete = null;
601
+ candidate = null;
602
+ candidateState = "idle";
603
+ pendingSinceSeq = null;
604
+ classifier.reset({ cursorVisible });
605
+ return { status: "ok", proof: cloneProof(proof) };
606
+ }
607
+
608
+ /**
609
+ * Answer whether `snapshotSeq` is backed by a complete view proof plus only explicitly
610
+ * classified no-grid trailing controls. Returns `covered`, `unavailable` or `unknown`.
611
+ */
612
+ function coverageFor(snapshotSeq) {
613
+ if (!Number.isInteger(snapshotSeq) || snapshotSeq < 1) return { status: "unknown", reason: "invalid-snapshot-seq", proof: null, throughSeq: null, cursorVisible: null };
614
+ if (firstSeq === null) return { status: "unknown", reason: "no-output", proof: null, throughSeq: null, cursorVisible: null };
615
+ if (gap) return { status: "unknown", reason: "output-gap", proof: null, throughSeq: null, cursorVisible: null };
616
+ if (snapshotSeq > lastOutputSeq) return { status: "unknown", reason: "snapshot-seq-future", proof: null, throughSeq: null, cursorVisible: null };
617
+ if (snapshotSeq < firstSeq) return { status: "unknown", reason: "snapshot-before-window", proof: null, throughSeq: null, cursorVisible: null };
618
+ if (candidateState === "open") {
619
+ return { status: "unavailable", reason: "candidate-open", proof: null, throughSeq: null, cursorVisible: null, previousComplete: cloneProof(previousComplete) };
620
+ }
621
+ if (candidateState === "aborted") {
622
+ return { status: "unavailable", reason: "candidate-aborted", proof: null, throughSeq: null, cursorVisible: null, previousComplete: cloneProof(previousComplete) };
623
+ }
624
+ const found = findNewestProofAtOrBefore(snapshotSeq);
625
+ if (found.proof === null) return { status: "unknown", reason: "no-proof", proof: null, throughSeq: null, cursorVisible: null };
626
+ const proof = found.proof;
627
+ if (proof.seq === snapshotSeq) {
628
+ return { status: "covered", reason: "exact-proof", proof: cloneProof(proof), throughSeq: snapshotSeq, cursorVisible: proof.cursorVisible };
629
+ }
630
+ const retained = earliestRetainedSeq();
631
+ let throughSeq = proof.seq;
632
+ let cursorVisible = proof.cursorVisible;
633
+ for (let seq = proof.seq + 1; seq <= snapshotSeq; seq += 1) {
634
+ const entry = journal.get(seq);
635
+ if (entry === undefined) {
636
+ const reason = retained !== null && seq < retained ? "journal-evicted" : found.evicted ? "proof-evicted" : "journal-gap";
637
+ return { status: "unknown", reason, proof: null, throughSeq, cursorVisible };
638
+ }
639
+ if (entry.status === "pending") {
640
+ return { status: "unavailable", reason: "trailing-pending", proof: null, throughSeq, cursorVisible, detail: "incomplete-escape", pendingBytes: entry.pendingBytes };
641
+ }
642
+ if (entry.status !== "safe") {
643
+ return { status: "unavailable", reason: "trailing-unsafe", proof: null, throughSeq, cursorVisible, detail: entry.reason };
644
+ }
645
+ if (entry.cursorVisible !== null) cursorVisible = entry.cursorVisible;
646
+ throughSeq = seq;
647
+ }
648
+ return { status: "covered", reason: "safe-trailing", proof: cloneProof(proof), throughSeq: snapshotSeq, cursorVisible };
649
+ }
650
+
651
+ /**
652
+ * Bounded replay/catch-up query. Returns `ok` entries in seq order, or an explicit
653
+ * `evicted`/`gap`/`unknown` result — never a stitched or guessed history. Per-entry and
654
+ * aggregate `droppedProofs`/`droppedEvents` are surfaced on every result. Aggregate counts
655
+ * are LIFETIME totals; per-entry counts are RANGE-LOCAL. A fully retained range can still
656
+ * have a nonzero lifetime count, so range-completeness decisions must use the entries plus
657
+ * explicit gap/evicted status, not lifetime nonzero alone.
658
+ */
659
+ function replay(fromSeq, throughSeq = lastOutputSeq) {
660
+ const drops = () => ({ droppedProofs, droppedEvents });
661
+ if (!Number.isInteger(fromSeq) || fromSeq < 1 || !Number.isInteger(throughSeq) || throughSeq < fromSeq) {
662
+ return { status: "unknown", reason: "invalid-range", ...drops(), entries: [] };
663
+ }
664
+ if (firstSeq === null) return { status: "unknown", reason: "no-output", ...drops(), entries: [] };
665
+ if (fromSeq < firstSeq) return { status: "unknown", reason: "before-window", ...drops(), entries: [] };
666
+ if (throughSeq > lastOutputSeq) return { status: "unknown", reason: "future-range", ...drops(), entries: [] };
667
+ const earliest = earliestRetainedSeq();
668
+ if (earliest === null || fromSeq < earliest) {
669
+ return { status: "evicted", earliestSeq: earliest, droppedSegments, ...drops(), entries: [] };
670
+ }
671
+ const entries = [];
672
+ for (let seq = fromSeq; seq <= throughSeq; seq += 1) {
673
+ const entry = journal.get(seq);
674
+ if (entry === undefined) return { status: "gap", missingSeq: seq, ...drops(), entries: [] };
675
+ if (entry.status === "unknown" && entry.reason === "output-gap") return { status: "gap", missingSeq: seq, ...drops(), entries: [] };
676
+ entries.push(cloneEntry(entry));
677
+ }
678
+ return { status: "ok", ...drops(), entries };
679
+ }
680
+
681
+ function snapshot() {
682
+ const earliest = earliestRetainedSeq();
683
+ return {
684
+ protocol: VIEW_PROOF_PROTOCOL,
685
+ firstSeq,
686
+ lastOutputSeq,
687
+ lastProofSeq,
688
+ lastFrameId,
689
+ gap,
690
+ candidateState,
691
+ candidate: candidate === null ? null : { ...candidate },
692
+ // Last COMPLETED proof only. This is not coverage: use coverageFor(snapshotSeq)
693
+ // to decide whether the current snapshot is proven.
694
+ currentProof: candidateState === "idle" ? cloneProof(currentProof) : null,
695
+ previousComplete: cloneProof(previousComplete),
696
+ journal: {
697
+ size: journal.size,
698
+ earliestSeq: earliest,
699
+ droppedSegments,
700
+ droppedProofs,
701
+ droppedEvents,
702
+ },
703
+ classifier: classifier.state(),
704
+ diagnostics: diagnostics.slice(),
705
+ };
706
+ }
707
+
708
+ return { feedOutput, openCandidate, abortCandidate, completeProof, coverageFor, replay, snapshot };
709
+ }