@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,870 @@
1
+ /**
2
+ * Pure frame-witness logic for the attach readiness fast path (issue #222, step 23a).
3
+ *
4
+ * Owns exactly three concerns and nothing else: the candidate grammar that decides when
5
+ * an in-stream `S`/`F`/`A` marker is justified, the FROZEN marker literals from the
6
+ * converged contract (`plans/issue-222-attach-ready-signal-implementation.md` §3), and
7
+ * metadata validation. No I/O, no timers, no Terminal: the reporter shell
8
+ * (`attach-frame-reporter.mjs`) owns every side effect.
9
+ *
10
+ * ## Ordered operation grammar (contract §2.1, §3)
11
+ *
12
+ * A write is NOT classified by substring search. Each original write is scanned into an
13
+ * ordered token stream (sync OPEN/CLOSE, cursor position, cursor visibility, erase,
14
+ * other control, plain text, or malformed) with full terminators required; cross-write
15
+ * state is kept by the evaluator. Consequences that the state machine enforces:
16
+ *
17
+ * - Cursor positioning evidence is absolute `H`/`f`/`G`/`d` or relative `A`–`D`.
18
+ * (`G`/`d` are single-axis absolute positioning; the real renderer's parking write is
19
+ * `CSI n A` + `CSI m G`, so rejecting `G` would reject the host's own park sequence.)
20
+ * Cursor visibility evidence is exactly `?25h` / `?25l`. Erase finals (`J`,`K`,`X`) are
21
+ * NEVER cursor evidence. Any other cursor-relevant control is `unknown` and aborts.
22
+ * - `S` is inserted immediately BEFORE this frame's first OPEN token — even when a
23
+ * previous frame's CLOSE precedes it in the SAME write. The reporter splits the write
24
+ * at that exact offset; the marker never precedes a previous frame's CLOSE.
25
+ * - A regular parking write must be OPEN-free and carry a real cursor position or
26
+ * visibility operation. The next frame's OPEN is not parking: it aborts the candidate.
27
+ *
28
+ * ## Candidate lifecycle
29
+ *
30
+ * - A candidate starts only at a complete `columns` + `rows` pair; the first pair is
31
+ * locked. A later complete pair invalidates the open candidate (`resampled`) before the
32
+ * new one opens, so stale geometry can never be proven by a newer frame.
33
+ * - `S` requires a FRESH sentinel fact observed after the pair (any operation before it
34
+ * invalidates the candidate) plus valid focus, unchanged mode and a size match.
35
+ * - At completion the LATEST sentinel fact is rechecked: same focus identity (reference,
36
+ * never a name/title/text), present focus, same mode, compatible overlay. A change
37
+ * aborts instead of publishing.
38
+ * - `frameId` is strictly increasing within one reporter token, carried across
39
+ * rendererEpoch (mode-switch) changes.
40
+ */
41
+ export const FRAME_WITNESS_PROTOCOL = 1;
42
+ export const FRAME_REPORTER_CLIENT_ID = "frame-reporter";
43
+ export const MAX_FRAME_MARKER_BYTES = 4096;
44
+ export const MAX_DECISION_LOG = 256;
45
+ export const MAX_ABORT_REASON_LENGTH = 64;
46
+
47
+ const DCS_INTRO = "\u001bP> ";
48
+ const DCS_TERM = "\u001b\\";
49
+ const MARKER_FAMILY = "agent-board-frame/1";
50
+
51
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]{22,}$/; // >= 128 bits as base64url without padding
52
+ const REASON_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
53
+
54
+ const ESC = "\u001b";
55
+ const MODES = new Set(["fullscreen", "regular"]);
56
+
57
+ export const MAX_PENDING_TAIL = 4096;
58
+
59
+ /**
60
+ * Classify a complete CSI body/final that is not sync/cursor/erase. Only the converged
61
+ * §5 no-grid controls are allowed inside a candidate; everything else is `unknown-control`.
62
+ * Plain SGR (`m` without a private prefix) is allowed because it changes rendition only —
63
+ * never the grid, layout or cursor — and the real host emits it inside frame writes.
64
+ */
65
+ function classifyCsi(body, final) {
66
+ if (body === "?2004" && (final === "h" || final === "l")) return { kind: "allowed-control", family: "bracketed-paste" };
67
+ if (body === "?2031" && (final === "h" || final === "l")) return { kind: "allowed-control", family: "color-scheme" };
68
+ if (body === "16" && final === "t") return { kind: "allowed-control", family: "cell-size-query" };
69
+ if (body === "" && final === "c") return { kind: "allowed-control", family: "device-attributes" };
70
+ if (body === ">7" && final === "u") return { kind: "allowed-control", family: "keyboard-protocol-push" };
71
+ if (body === "?" && final === "u") return { kind: "allowed-control", family: "keyboard-protocol-query" };
72
+ if ((body === ">4;0" || body === ">4;2") && final === "m") return { kind: "allowed-control", family: "modify-other-keys" };
73
+ if (body === "<" && final === "u") return { kind: "allowed-control", family: "keyboard-protocol-pop" };
74
+ // Rendition attributes only (no private prefix): colours, bold, reverse, reset.
75
+ if (/^[0-9;]*$/.test(body) && final === "m") return { kind: "allowed-control", family: "sgr" };
76
+ return { kind: "unknown-control", family: `csi:${body}${final}` };
77
+ }
78
+
79
+ /**
80
+ * Classify a complete OSC body (the part between `ESC ]` and the terminator).
81
+ *
82
+ * Scope clarification (parent-approved, fix2): OSC 8 hyperlinks inside the CURRENT frame
83
+ * are a text ANNOTATION — not a grid/cursor operation. §5 lists OSC 8 as invalidating for
84
+ * the POST-F trailing window (proof.seq < snapshotSeq, FW-06); that rule is unchanged. The
85
+ * annotation kind can never establish or complete a proof, never counts as parking or
86
+ * visibility, and an OSC 8 after this candidate's CLOSE invalidates the frame.
87
+ */
88
+ function classifyOsc(body) {
89
+ if (/^8;;/.test(body)) return { kind: "annotation", family: "osc-hyperlink" };
90
+ if (/^8;/.test(body)) return { kind: "unknown-control", family: "osc8-malformed" };
91
+ if (/^0;/.test(body)) return { kind: "allowed-control", family: "osc-title" };
92
+ if (/^9;4;/.test(body)) return { kind: "allowed-control", family: "osc-progress" };
93
+ if (/^7501;/.test(body)) return { kind: "allowed-control", family: "osc-program-status" };
94
+ // Palette / foreground / background queries: 4;<n>;? and 10;? / 11;?.
95
+ if (/^4(;[0-9]+)*;\?$/.test(body) || /^(10|11);\?$/.test(body)) return { kind: "allowed-control", family: "osc-palette-query" };
96
+ return { kind: "unknown-control", family: `osc:${body.slice(0, 16)}` };
97
+ }
98
+
99
+ /**
100
+ * Bounded, grammar-aware scanner with CROSS-WRITE state.
101
+ *
102
+ * `pending` carries an unterminated escape tail (max `MAX_PENDING_TAIL` code units) from
103
+ * one write into the next, so a synchronized CLOSE or an allowed control split by the
104
+ * writer completes normally instead of failing as malformed. A sequence is `malformed`
105
+ * only when it COMPLETES and is still invalid. Tokens that started in a previous write
106
+ * report `offset: null` and `startedEarlier: true` (they cannot be an insertion target).
107
+ *
108
+ * OFFSETS ARE JS UTF-16 CODE-UNIT INDICES used with `text.slice` — NOT byte counts and NOT
109
+ * outputSeq. FW-03 must keep its own byte/outputSeq accounting separately.
110
+ */
111
+ export function scanWrite(text, pending = null, { baseOffset = 0 } = {}) {
112
+ const tokens = [];
113
+ if (typeof text !== "string") return { tokens, pending: pending === null ? null : pending, overflow: false };
114
+ const prefix = pending === null ? "" : pending.raw;
115
+ const combined = prefix + text;
116
+ const startedEarlierLength = prefix.length;
117
+ const push = (token) => {
118
+ // Offsets index the CURRENT write's text; a token whose start lies in the carried
119
+ // prefix has no usable insertion offset.
120
+ if (token.offset < startedEarlierLength) tokens.push({ ...token, offset: null, startedEarlier: true, streamOffset: baseOffset - startedEarlierLength + token.offset });
121
+ else tokens.push({ ...token, offset: token.offset - startedEarlierLength, startedEarlier: false, streamOffset: baseOffset + token.offset - startedEarlierLength });
122
+ };
123
+ const paramsEnd = (from) => {
124
+ let index = from;
125
+ while (index < combined.length && /[0-9;?<=>]/.test(combined[index])) index += 1;
126
+ while (index < combined.length && /[ -/]/.test(combined[index])) index += 1;
127
+ if (index >= combined.length) return -1;
128
+ return /[@-~]/.test(combined[index]) ? index : -1;
129
+ };
130
+ let index = 0;
131
+ let textStart = 0;
132
+ const flushText = (end) => {
133
+ if (end > textStart) push({ kind: "text", offset: textStart, raw: combined.slice(textStart, end) });
134
+ };
135
+ let tail = null;
136
+ while (index < combined.length) {
137
+ if (combined[index] !== ESC) {
138
+ index += 1;
139
+ continue;
140
+ }
141
+ flushText(index);
142
+ const at = index;
143
+ const next = combined[index + 1];
144
+ if (next === undefined) {
145
+ tail = { kind: "esc", raw: combined.slice(at), startOffset: baseOffset - startedEarlierLength + at };
146
+ index = combined.length;
147
+ break;
148
+ }
149
+ if (next === "[") {
150
+ const end = paramsEnd(index + 2);
151
+ if (end < 0) {
152
+ // Unterminated CSI: carry the tail into the next write instead of failing.
153
+ if (combined.length - at > MAX_PENDING_TAIL) return { tokens, pending: null, overflow: true };
154
+ tail = { kind: "csi", raw: combined.slice(at), startOffset: baseOffset - startedEarlierLength + at };
155
+ index = combined.length;
156
+ break;
157
+ }
158
+ const body = combined.slice(index + 2, end);
159
+ const final = combined[end];
160
+ const raw = combined.slice(at, end + 1);
161
+ if (body === "?2026" && final === "h") push({ kind: "sync-open", offset: at, raw });
162
+ else if (body === "?2026" && final === "l") push({ kind: "sync-close", offset: at, raw });
163
+ else if (body === "?25" && (final === "h" || final === "l")) push({ kind: "cursor-visibility", offset: at, raw, visible: final === "h" });
164
+ else if (["H", "f", "A", "B", "C", "D", "G", "d"].includes(final)) push({ kind: "cursor-position", offset: at, raw });
165
+ else if (["J", "K", "X"].includes(final)) push({ kind: "erase", offset: at, raw });
166
+ else push({ ...classifyCsi(body, final), offset: at, raw });
167
+ index = end + 1;
168
+ textStart = index;
169
+ continue;
170
+ }
171
+ if (next === "]" || next === "P") {
172
+ let cursor = index + 2;
173
+ let end = -1;
174
+ while (cursor < combined.length) {
175
+ if (combined[cursor] === "\u0007") {
176
+ end = cursor + 1;
177
+ break;
178
+ }
179
+ if (combined[cursor] === ESC && combined[cursor + 1] === "\\") {
180
+ end = cursor + 2;
181
+ break;
182
+ }
183
+ cursor += 1;
184
+ }
185
+ if (end < 0) {
186
+ if (combined.length - at > MAX_PENDING_TAIL) return { tokens, pending: null, overflow: true };
187
+ tail = { kind: next === "]" ? "osc" : "dcs", raw: combined.slice(at), startOffset: baseOffset - startedEarlierLength + at };
188
+ index = combined.length;
189
+ break;
190
+ }
191
+ const body = combined.slice(index + 2, end === cursor + 1 ? end - 1 : end - 2);
192
+ const raw = combined.slice(at, end);
193
+ if (next === "P") push({ kind: "unknown-control", offset: at, raw, family: "dcs" });
194
+ else push({ ...classifyOsc(body), offset: at, raw });
195
+ index = end;
196
+ textStart = index;
197
+ continue;
198
+ }
199
+ // Two-byte escape (ESC M, ESC 7, ESC ( …): cursor/scroll/grid relevant, never allowed.
200
+ push({ kind: "unknown-control", offset: at, raw: combined.slice(at, at + 2), family: `esc:${next}` });
201
+ index += 2;
202
+ textStart = index;
203
+ }
204
+ flushText(combined.length);
205
+ return { tokens, pending: tail, overflow: false };
206
+ }
207
+
208
+ /** Convenience wrapper for a single complete write (tests/diagnostics). */
209
+ export function scanTerminalOps(text) {
210
+ return scanWrite(text, null).tokens;
211
+ }
212
+
213
+ export function isValidFrameWitnessToken(token) {
214
+ return typeof token === "string" && TOKEN_PATTERN.test(token);
215
+ }
216
+
217
+ function requireToken(token) {
218
+ if (!isValidFrameWitnessToken(token)) throw new Error("frame marker requires a valid base64url token (>=128 bits)");
219
+ return token;
220
+ }
221
+
222
+ /** frameId and sampledCols must be >= 1; intentId/geometryRevision may be 0. */
223
+ function requirePositiveId(name, value) {
224
+ if (!Number.isInteger(value) || value < 1) throw new Error(`frame marker requires ${name} >= 1`);
225
+ return value;
226
+ }
227
+
228
+ function requireNonNegativeInt(name, value) {
229
+ if (!Number.isInteger(value) || value < 0) throw new Error(`frame marker requires an integer ${name} >= 0`);
230
+ return value;
231
+ }
232
+
233
+ function markerBytes(text) {
234
+ return Buffer.byteLength(text, "utf8");
235
+ }
236
+
237
+ function assertMarkerSize(text) {
238
+ if (markerBytes(text) > MAX_FRAME_MARKER_BYTES) throw new Error(`frame marker exceeds ${MAX_FRAME_MARKER_BYTES} bytes`);
239
+ return text;
240
+ }
241
+
242
+ export function encodeFrameMarkerS({ token, frameId, intentId = 0, sampledCols }) {
243
+ const text = `${DCS_INTRO}${MARKER_FAMILY} ; ${requireToken(token)} ; S ; ${requirePositiveId("frameId", frameId)} ; ${requireNonNegativeInt("intentId", intentId)} ; ${requirePositiveId("sampledCols", sampledCols)}${DCS_TERM}`;
244
+ return assertMarkerSize(text);
245
+ }
246
+
247
+ export function encodeFrameMetadataBase64(metadata) {
248
+ const validation = validateFrameMetadata(metadata);
249
+ if (!validation.ok) throw new Error(`invalid frame metadata: ${validation.reason}`);
250
+ return Buffer.from(JSON.stringify(validation.value), "utf8").toString("base64url");
251
+ }
252
+
253
+ export function encodeFrameMarkerF({ token, frameId, metadata }) {
254
+ const text = `${DCS_INTRO}${MARKER_FAMILY} ; ${requireToken(token)} ; F ; ${requirePositiveId("frameId", frameId)} ; ${encodeFrameMetadataBase64(metadata)}${DCS_TERM}`;
255
+ return assertMarkerSize(text);
256
+ }
257
+
258
+ export function encodeFrameMarkerA({ token, frameId, reason }) {
259
+ if (typeof reason !== "string" || !REASON_PATTERN.test(reason) || reason.length > MAX_ABORT_REASON_LENGTH) {
260
+ throw new Error("frame abort reason must be bounded lowercase-kebab");
261
+ }
262
+ const text = `${DCS_INTRO}${MARKER_FAMILY} ; ${requireToken(token)} ; A ; ${requirePositiveId("frameId", frameId)} ; ${reason}${DCS_TERM}`;
263
+ return assertMarkerSize(text);
264
+ }
265
+
266
+ /**
267
+ * Parse a complete marker (tests and the future runner parser). Fails closed: oversize
268
+ * complete markers, non-positive ids/cols and overlong reasons are all rejected.
269
+ */
270
+ export function decodeFrameMarker(text) {
271
+ if (typeof text !== "string" || !text.startsWith(DCS_INTRO) || !text.endsWith(DCS_TERM)) return null;
272
+ if (markerBytes(text) > MAX_FRAME_MARKER_BYTES) return null;
273
+ const body = text.slice(DCS_INTRO.length, text.length - DCS_TERM.length);
274
+ const parts = body.split(" ; ");
275
+ if (parts.length < 4 || parts[0] !== MARKER_FAMILY) return null;
276
+ const token = parts[1];
277
+ if (!isValidFrameWitnessToken(token)) return null;
278
+ const kind = parts[2];
279
+ if (kind === "S") {
280
+ if (parts.length !== 6) return null;
281
+ const frameId = Number(parts[3]);
282
+ const intentId = Number(parts[4]);
283
+ const sampledCols = Number(parts[5]);
284
+ if (!Number.isInteger(frameId) || frameId < 1) return null;
285
+ if (!Number.isInteger(intentId) || intentId < 0) return null;
286
+ if (!Number.isInteger(sampledCols) || sampledCols < 1) return null;
287
+ return { token, kind, frameId, intentId, sampledCols };
288
+ }
289
+ if (kind === "F") {
290
+ if (parts.length !== 5) return null;
291
+ const frameId = Number(parts[3]);
292
+ if (!Number.isInteger(frameId) || frameId < 1) return null;
293
+ let metadata;
294
+ try {
295
+ metadata = JSON.parse(Buffer.from(parts[4], "base64url").toString("utf8"));
296
+ } catch {
297
+ return null;
298
+ }
299
+ const validation = validateFrameMetadata(metadata);
300
+ if (!validation.ok) return null;
301
+ return { token, kind, frameId, metadata: validation.value };
302
+ }
303
+ if (kind === "A") {
304
+ if (parts.length !== 5) return null;
305
+ const frameId = Number(parts[3]);
306
+ if (!Number.isInteger(frameId) || frameId < 1) return null;
307
+ if (!REASON_PATTERN.test(parts[4]) || parts[4].length > MAX_ABORT_REASON_LENGTH) return null;
308
+ return { token, kind, frameId, reason: parts[4] };
309
+ }
310
+ return null;
311
+ }
312
+
313
+ /**
314
+ * Metadata must contain exactly the converged contract fields with validated values.
315
+ * Unknown/extra fields fail closed rather than being silently published.
316
+ */
317
+ export function validateFrameMetadata(metadata) {
318
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) return { ok: false, reason: "not-an-object" };
319
+ const allowed = ["protocol", "cols", "rows", "mode", "rendererEpoch", "sessionEpoch", "focusPresent", "overlayPresent", "intentId", "geometryRevision"];
320
+ for (const key of Object.keys(metadata)) if (!allowed.includes(key)) return { ok: false, reason: `unexpected-field:${key}` };
321
+ if (metadata.protocol !== FRAME_WITNESS_PROTOCOL) return { ok: false, reason: "protocol" };
322
+ if (!Number.isInteger(metadata.cols) || metadata.cols < 1) return { ok: false, reason: "cols" };
323
+ if (!Number.isInteger(metadata.rows) || metadata.rows < 1) return { ok: false, reason: "rows" };
324
+ if (!MODES.has(metadata.mode)) return { ok: false, reason: "mode" };
325
+ if (!Number.isInteger(metadata.rendererEpoch) || metadata.rendererEpoch < 0) return { ok: false, reason: "rendererEpoch" };
326
+ if (!Number.isInteger(metadata.sessionEpoch) || metadata.sessionEpoch < 0) return { ok: false, reason: "sessionEpoch" };
327
+ if (typeof metadata.focusPresent !== "boolean") return { ok: false, reason: "focusPresent" };
328
+ if (typeof metadata.overlayPresent !== "boolean") return { ok: false, reason: "overlayPresent" };
329
+ if (!Number.isInteger(metadata.intentId) || metadata.intentId < 0) return { ok: false, reason: "intentId" };
330
+ if (!Number.isInteger(metadata.geometryRevision) || metadata.geometryRevision < 0) return { ok: false, reason: "geometryRevision" };
331
+ return {
332
+ ok: true,
333
+ value: {
334
+ protocol: FRAME_WITNESS_PROTOCOL,
335
+ cols: metadata.cols,
336
+ rows: metadata.rows,
337
+ mode: metadata.mode,
338
+ rendererEpoch: metadata.rendererEpoch,
339
+ sessionEpoch: metadata.sessionEpoch,
340
+ focusPresent: metadata.focusPresent,
341
+ overlayPresent: metadata.overlayPresent,
342
+ intentId: metadata.intentId,
343
+ geometryRevision: metadata.geometryRevision,
344
+ },
345
+ };
346
+ }
347
+
348
+ const NO_INTENT = Object.freeze({ intentId: 0, geometryRevision: 0, cols: null, rows: null });
349
+
350
+ /**
351
+ * Diagnostic candidate evaluator (the production grammar; the accepted smoke's
352
+ * test-support evaluator stays the independent oracle).
353
+ *
354
+ * `op()` returns `{decisions, insertions}`. `insertions` carries the exact write offset
355
+ * and frameId where `S` must be inserted, so the reporter can split the write at the
356
+ * boundary immediately before THIS frame's OPEN token.
357
+ */
358
+ export class FrameCandidateEvaluator {
359
+ #mode;
360
+ #rendererEpoch;
361
+ #sessionEpoch;
362
+ #frameIdCounter;
363
+ #pendingCols = null;
364
+ #pendingRows = null;
365
+ #candidate = null;
366
+ #decisions = [];
367
+ // Every decision ever logged. The log's own length stops growing at its cap, so it can't say which are new.
368
+ #decisionCount = 0;
369
+ #markerFailures = 0;
370
+ #intent = NO_INTENT;
371
+ #intentRefused = false;
372
+ #latestSentinel = null;
373
+ #stopped = false;
374
+ #inOp = false;
375
+ #pending = null;
376
+ #streamOffset = 0;
377
+
378
+ constructor({ mode, rendererEpoch = 1, sessionEpoch = 0, frameIdCounter = { value: 0 } }) {
379
+ if (!MODES.has(mode)) throw new Error(`unknown renderer mode: ${mode}`);
380
+ this.#mode = mode;
381
+ this.#rendererEpoch = rendererEpoch;
382
+ this.#sessionEpoch = sessionEpoch;
383
+ this.#frameIdCounter = frameIdCounter;
384
+ }
385
+
386
+ get mode() {
387
+ return this.#mode;
388
+ }
389
+
390
+ get frameIdCounter() {
391
+ return this.#frameIdCounter;
392
+ }
393
+
394
+ get lastFrameId() {
395
+ return this.#frameIdCounter.value;
396
+ }
397
+
398
+ get openFrameId() {
399
+ return this.#candidate === null ? null : this.#candidate.frameId;
400
+ }
401
+
402
+ get intentRefused() {
403
+ return this.#intentRefused;
404
+ }
405
+
406
+ get decisions() {
407
+ return this.#decisions.slice();
408
+ }
409
+
410
+ #decisionsSince(before) {
411
+ const added = this.#decisionCount - before;
412
+ return added === 0 ? [] : this.#decisions.slice(-added);
413
+ }
414
+
415
+ get markerFailures() {
416
+ return this.#markerFailures;
417
+ }
418
+
419
+ /**
420
+ * Runner intent plumbing (adapter boundary only; real runner intent is FW-04/23b).
421
+ * An INVALID object enters a refusing state — it is never coerced to the ordinary
422
+ * `intentId 0`, because 0 is the "ordinary frame" sentinel and coercion would fail
423
+ * open. `null`/`undefined` explicitly clears back to the ordinary intent.
424
+ */
425
+ setIntent(intent) {
426
+ if (intent === null || intent === undefined) {
427
+ this.#intent = NO_INTENT;
428
+ this.#intentRefused = false;
429
+ return { ok: true, cleared: true };
430
+ }
431
+ const problems = [];
432
+ if (typeof intent !== "object" || Array.isArray(intent)) problems.push("not-an-object");
433
+ else {
434
+ const allowed = ["intentId", "geometryRevision", "cols", "rows"];
435
+ for (const key of Object.keys(intent)) if (!allowed.includes(key)) problems.push(`unexpected-field:${key}`);
436
+ if (!Number.isInteger(intent.intentId) || intent.intentId < 0) problems.push("intentId");
437
+ if (!Number.isInteger(intent.geometryRevision) || intent.geometryRevision < 0) problems.push("geometryRevision");
438
+ const hasCols = intent.cols !== undefined && intent.cols !== null;
439
+ const hasRows = intent.rows !== undefined && intent.rows !== null;
440
+ if (hasCols !== hasRows) problems.push("one-sided-dims");
441
+ else if (hasCols && (!Number.isInteger(intent.cols) || intent.cols < 1 || !Number.isInteger(intent.rows) || intent.rows < 1)) problems.push("dims");
442
+ }
443
+ if (problems.length > 0) {
444
+ this.#intent = null;
445
+ this.#intentRefused = true;
446
+ this.#abortCandidate("intent-invalid");
447
+ return { ok: false, reason: problems.join(",") };
448
+ }
449
+ const next = {
450
+ intentId: intent.intentId,
451
+ geometryRevision: intent.geometryRevision,
452
+ cols: intent.cols ?? null,
453
+ rows: intent.rows ?? null,
454
+ };
455
+ const current = this.#intent;
456
+ if (this.#candidate !== null && (current === null || next.geometryRevision !== current.geometryRevision || next.intentId !== current.intentId)) {
457
+ // A newer intent supersedes the open candidate: abort before any S/F instead of
458
+ // retroactively stamping the new identity onto an existing frame.
459
+ this.#abortCandidate("intent-superseded");
460
+ }
461
+ this.#intent = next;
462
+ this.#intentRefused = false;
463
+ return { ok: true, cleared: false };
464
+ }
465
+
466
+ setEpoch({ rendererEpoch = this.#rendererEpoch, sessionEpoch = this.#sessionEpoch } = {}) {
467
+ this.#rendererEpoch = rendererEpoch;
468
+ this.#sessionEpoch = sessionEpoch;
469
+ }
470
+
471
+ /** Sampling read of a shared-Terminal size getter. */
472
+ sizeRead(key, value) {
473
+ if (this.#stopped || typeof value !== "number") return [];
474
+ const before = this.#decisionCount;
475
+ if (key === "columns") this.#pendingCols = value;
476
+ else if (key === "rows") this.#pendingRows = value;
477
+ else return [];
478
+ if (this.#pendingCols === null || this.#pendingRows === null) return [];
479
+ // A complete NEW pair invalidates the open candidate BEFORE the new one opens:
480
+ // stale sampled geometry can never be completed by a newer frame.
481
+ if (this.#candidate !== null) this.#abortCandidate("resampled", before);
482
+ this.#frameIdCounter.value += 1;
483
+ this.#candidate = {
484
+ frameId: this.#frameIdCounter.value,
485
+ sampledCols: this.#pendingCols,
486
+ sampledRows: this.#pendingRows,
487
+ sentinel: null,
488
+ sEmitted: false,
489
+ syncOpenSeen: false,
490
+ syncCloseSeen: false,
491
+ closeWriteIndex: null,
492
+ writeCount: 0,
493
+ cursorParkingAfterClose: false,
494
+ fVisibilityAfterClose: false,
495
+ annotationDepth: 0,
496
+ // Intent is snapshotted AT CREATION: a later intent can never be stamped onto
497
+ // this frame, and S/F of one frame always carry the same intent identity.
498
+ intentId: this.#intent?.intentId ?? 0,
499
+ geometryRevision: this.#intent?.geometryRevision ?? 0,
500
+ intentCols: this.#intent?.cols ?? null,
501
+ intentRows: this.#intent?.rows ?? null,
502
+ };
503
+ this.#pendingCols = null;
504
+ this.#pendingRows = null;
505
+ this.#push({ kind: "candidate", frameId: this.#candidate.frameId, reason: "size-pair-sampled" });
506
+ return this.#decisionsSince(before);
507
+ }
508
+
509
+ /** The post-pair sentinel fact. The LATEST fact wins (focus is rechecked later). */
510
+ sentinel(fact) {
511
+ if (this.#stopped) return [];
512
+ const before = this.#decisionCount;
513
+ if (this.#candidate === null) {
514
+ this.#push({ kind: "none", frameId: null, reason: "sentinel-without-candidate" });
515
+ return this.#decisionsSince(before);
516
+ }
517
+ this.#latestSentinel = fact;
518
+ if (this.#candidate.sentinel === null) this.#candidate.sentinel = fact;
519
+ return this.#decisionsSince(before);
520
+ }
521
+
522
+ /**
523
+ * A shared-Terminal operation. Returns the decisions produced by this operation plus
524
+ * the exact insertion offsets where `S` must be written (immediately before this
525
+ * frame's OPEN token).
526
+ */
527
+ op(op) {
528
+ if (this.#stopped) return { decisions: [], insertions: [] };
529
+ const before = this.#decisionCount;
530
+ const insertions = [];
531
+ this.#inOp = true;
532
+ try {
533
+ this.#handleOp(op, insertions);
534
+ } finally {
535
+ this.#inOp = false;
536
+ }
537
+ return { decisions: this.#decisionsSince(before), insertions };
538
+ }
539
+
540
+ /** Marker accounting hook: never counted as a terminal operation. */
541
+ markerWrite(kind) {
542
+ if (this.#inOp || this.#stopped) {
543
+ this.#markerFailures += 1;
544
+ return false;
545
+ }
546
+ return typeof kind === "string";
547
+ }
548
+
549
+ /**
550
+ * Completion check, called by the reporter AFTER an operation returned (or threw).
551
+ * Rechecks the LATEST focus identity reference, presence, mode and overlay compatibility
552
+ * before any F could be written; returns the decisions produced by this check.
553
+ */
554
+ finishWrite(fact) {
555
+ const candidate = this.#candidate;
556
+ if (candidate === null) return [];
557
+ const before = this.#decisionCount;
558
+ if (!this.#completed(candidate)) return [];
559
+ if (fact === null || typeof fact !== "object" || fact.focusPresent !== true) {
560
+ this.#abortCandidate("focus-missing-at-completion", before);
561
+ return this.#decisionsSince(before);
562
+ }
563
+ const sFact = candidate.sentinel;
564
+ if (sFact === null || !Object.is(sFact.focusIdentity, fact.focusIdentity)) {
565
+ this.#abortCandidate("focus-changed-at-completion", before);
566
+ return this.#decisionsSince(before);
567
+ }
568
+ if (fact.mode !== this.#mode) {
569
+ this.#abortCandidate("mode-changed-at-completion", before);
570
+ return this.#decisionsSince(before);
571
+ }
572
+ if (Boolean(fact.overlayPresent) !== Boolean(sFact.overlayPresent)) {
573
+ this.#abortCandidate("overlay-changed-at-completion", before);
574
+ return this.#decisionsSince(before);
575
+ }
576
+ if (candidate.annotationDepth !== 0) {
577
+ this.#abortCandidate("unclosed-annotation", before);
578
+ return this.#decisionsSince(before);
579
+ }
580
+ this.#push({ kind: "F", frameId: candidate.frameId, reason: this.#mode === "fullscreen" ? "complete-fullscreen-sync-write-with-cursor" : "regular-open-close-park-and-visibility" });
581
+ this.#candidate = null;
582
+ return this.#decisionsSince(before);
583
+ }
584
+
585
+ fence() {
586
+ if (this.#stopped) return [];
587
+ const before = this.#decisionCount;
588
+ const candidate = this.#candidate;
589
+ if (candidate === null) {
590
+ this.#pending = null;
591
+ return [];
592
+ }
593
+ if (this.#pending !== null) {
594
+ this.#abortCandidate("pending-escape-abandoned", before);
595
+ this.#pending = null;
596
+ return this.#decisionsSince(before);
597
+ }
598
+ if (!candidate.sEmitted) this.#push({ kind: "none", frameId: candidate.frameId, reason: "expired-without-sync-output" });
599
+ else if (!this.#completed(candidate)) this.#push({ kind: "A", frameId: candidate.frameId, reason: "incomplete-at-fence" });
600
+ this.#candidate = null;
601
+ return this.#decisionsSince(before);
602
+ }
603
+
604
+ abort(reason) {
605
+ const before = this.#decisionCount;
606
+ this.#abortCandidate(reason, before);
607
+ return this.#decisionsSince(before);
608
+ }
609
+
610
+ stop() {
611
+ const produced = this.abort(this.#pending === null ? "stop" : "pending-escape-abandoned");
612
+ this.#pending = null;
613
+ this.#stopped = true;
614
+ return produced;
615
+ }
616
+
617
+ /** Metadata for a proof decision produced by this evaluator. */
618
+ metadataFor(decision) {
619
+ return {
620
+ protocol: FRAME_WITNESS_PROTOCOL,
621
+ cols: decision.cols,
622
+ rows: decision.rows,
623
+ mode: this.#mode,
624
+ rendererEpoch: decision.rendererEpoch ?? this.#rendererEpoch,
625
+ sessionEpoch: this.#sessionEpoch,
626
+ focusPresent: decision.focusPresent === true,
627
+ overlayPresent: decision.overlayPresent === true,
628
+ intentId: decision.intentId ?? 0,
629
+ geometryRevision: decision.geometryRevision ?? 0,
630
+ };
631
+ }
632
+
633
+ #abortCandidate(reason, before = this.#decisionCount) {
634
+ const candidate = this.#candidate;
635
+ if (candidate === null) return;
636
+ if (candidate.sEmitted) this.#push({ kind: "A", frameId: candidate.frameId, reason });
637
+ else this.#push({ kind: "none", frameId: candidate.frameId, reason: `${reason}-before-s` });
638
+ this.#candidate = null;
639
+ void before;
640
+ }
641
+
642
+ #handleOp(op, insertions) {
643
+ const candidate = this.#candidate;
644
+ const text = op.text ?? "";
645
+ const scanned = scanWrite(text, this.#pending, { baseOffset: this.#streamOffset });
646
+ this.#streamOffset += typeof text === "string" ? text.length : 0;
647
+ this.#pending = scanned.pending;
648
+ const tokens = scanned.tokens;
649
+ if (candidate === null) {
650
+ this.#push({ kind: "none", frameId: null, reason: `op-${op.kind}-without-candidate` });
651
+ return;
652
+ }
653
+ // Candidate-origin rule: until a fresh sentinel fact exists, any operation means
654
+ // the pair did not come from this frame's renderer entry.
655
+ if (candidate.sentinel === null) {
656
+ this.#push({ kind: "none", frameId: candidate.frameId, reason: `op-${op.kind}-before-fresh-sentinel` });
657
+ this.#candidate = null;
658
+ return;
659
+ }
660
+ if (scanned.overflow) {
661
+ this.#abortCandidate("pending-escape-overflow");
662
+ return;
663
+ }
664
+ if (tokens.some((token) => token.kind === "malformed")) {
665
+ this.#abortCandidate("malformed-control");
666
+ return;
667
+ }
668
+ // Inside a candidate only the §5 no-grid controls are tolerated; every other
669
+ // control (grid/scroll/unknown private modes, unclassified DCS) fails closed.
670
+ if (tokens.some((token) => token.kind === "unknown-control")) {
671
+ this.#abortCandidate("unknown-control");
672
+ return;
673
+ }
674
+ // A synchronized OPEN split across writes escaped its first bytes before S could
675
+ // be inserted, so it can never be proven: no retroactive S.
676
+ if (tokens.some((token) => token.kind === "sync-open" && token.startedEarlier)) {
677
+ this.#abortCandidate("split-sync-open-unsupported");
678
+ return;
679
+ }
680
+ if (this.#intentRefused) {
681
+ this.#abortCandidate("intent-invalid");
682
+ return;
683
+ }
684
+ if (tokens.some((token) => token.kind === "annotation" && token.startedEarlier && candidate.syncCloseSeen)) {
685
+ this.#abortCandidate("annotation-after-close");
686
+ return;
687
+ }
688
+ if (op.kind !== "write") {
689
+ this.#handleNonWrite(candidate, op);
690
+ return;
691
+ }
692
+ candidate.writeCount += 1;
693
+ const writeIndex = candidate.writeCount;
694
+ if (this.#applyAnnotations(candidate, tokens, writeIndex)) return;
695
+ this.#handleWrite(candidate, tokens, writeIndex, insertions);
696
+ }
697
+
698
+ /**
699
+ * OSC 8 annotations are metadata only: they can never establish or complete a proof,
700
+ * never count as parking/visibility, and one that appears AFTER this candidate's CLOSE
701
+ * invalidates the frame per §5. Returns true when the candidate was aborted.
702
+ */
703
+ #applyAnnotations(candidate, tokens, writeIndex) {
704
+ const openToken = tokens.find((token) => token.kind === "sync-open");
705
+ const closeToken = tokens.find((token) => token.kind === "sync-close");
706
+ for (const token of tokens) {
707
+ if (token.kind !== "annotation") continue;
708
+ const raw = token.raw ?? "";
709
+ const body = raw.slice(2, raw.endsWith("\u0007") ? raw.length - 1 : raw.length - 2);
710
+ const uri = body.slice(3); // after "8;;"
711
+ const closeOffsetHere = closeToken === undefined ? null : closeToken.offset;
712
+ const afterCloseInThisWrite = closeOffsetHere !== null && token.offset !== null && token.offset > closeOffsetHere;
713
+ const afterCloseFromEarlier = candidate.syncCloseSeen && candidate.closeWriteIndex !== writeIndex;
714
+ if (afterCloseFromEarlier || afterCloseInThisWrite) {
715
+ this.#abortCandidate("annotation-after-close");
716
+ return true;
717
+ }
718
+ // Annotations that appear BEFORE this frame's OPEN belong to the previous frame.
719
+ if (!candidate.syncOpenSeen && (openToken === undefined || token.offset === null || token.offset < openToken.offset)) continue;
720
+ if (uri === "") candidate.annotationDepth = Math.max(0, candidate.annotationDepth - 1);
721
+ else candidate.annotationDepth += 1;
722
+ }
723
+ return false;
724
+ }
725
+
726
+ #handleNonWrite(candidate, op) {
727
+ // Cursor visibility alone can never establish a first/full proof, but after this
728
+ // candidate's CLOSE it is the required final visibility step.
729
+ if (!candidate.sEmitted) {
730
+ this.#push({ kind: "none", frameId: candidate.frameId, reason: `${op.kind}-cursor-only-no-proof` });
731
+ return;
732
+ }
733
+ if (!candidate.syncCloseSeen) {
734
+ this.#abortCandidate(`${op.kind}-before-sync-close`);
735
+ return;
736
+ }
737
+ if (candidate.cursorParkingAfterClose) candidate.fVisibilityAfterClose = true;
738
+ }
739
+
740
+ /**
741
+ * Classify one write's ORDERED token stream against the candidate. Nothing here is a
742
+ * substring test: the candidate's own OPEN is the first `sync-open` token of the write
743
+ * that opens it, its CLOSE is the first `sync-close` AFTER that OPEN, and the S marker
744
+ * insertion offset is exactly that OPEN's byte offset (so a previous frame's CLOSE,
745
+ * even in the same write, never precedes S).
746
+ */
747
+ #handleWrite(candidate, tokens, writeIndex, insertions) {
748
+ const openIndex = tokens.findIndex((token) => token.kind === "sync-open");
749
+ if (!candidate.syncOpenSeen) {
750
+ if (openIndex < 0) {
751
+ this.#push({ kind: "none", frameId: candidate.frameId, reason: "no-sync-block" });
752
+ return;
753
+ }
754
+ const ownOpen = tokens[openIndex];
755
+ let closeIndex = -1;
756
+ let cursorIndex = -1;
757
+ for (let index = openIndex + 1; index < tokens.length; index += 1) {
758
+ const kind = tokens[index].kind;
759
+ if (closeIndex < 0 && kind === "sync-close") closeIndex = index;
760
+ if (cursorIndex < 0 && (kind === "cursor-position" || kind === "cursor-visibility")) cursorIndex = index;
761
+ }
762
+ if (this.#mode === "fullscreen") {
763
+ if (closeIndex < 0) {
764
+ this.#push({ kind: "none", frameId: candidate.frameId, reason: "fullscreen-sync-block-not-closed" });
765
+ return;
766
+ }
767
+ if (cursorIndex < 0) {
768
+ this.#push({ kind: "none", frameId: candidate.frameId, reason: "fullscreen-frame-without-cursor" });
769
+ return;
770
+ }
771
+ }
772
+ const eligibility = this.#sEligibility(candidate);
773
+ if (eligibility !== "ok") {
774
+ this.#push({ kind: "none", frameId: candidate.frameId, reason: eligibility });
775
+ return;
776
+ }
777
+ candidate.syncOpenSeen = true;
778
+ candidate.sEmitted = true;
779
+ candidate.openWriteIndex = writeIndex;
780
+ insertions.push({ offset: ownOpen.offset, frameId: candidate.frameId });
781
+ this.#push({ kind: "S", frameId: candidate.frameId, reason: this.#mode === "fullscreen" ? "before-complete-fullscreen-sync-write" : "before-sync-open" });
782
+ if (closeIndex > openIndex) {
783
+ candidate.syncCloseSeen = true;
784
+ candidate.closeWriteIndex = writeIndex;
785
+ candidate.closeTokenOffset = tokens[closeIndex].offset;
786
+ }
787
+ if (cursorIndex > openIndex) candidate.cursorEvidenceSeen = true;
788
+ return;
789
+ }
790
+ if (openIndex >= 0) {
791
+ // A later OPEN in the same candidate's lifetime is either a nested open or the
792
+ // next frame's OPEN used as a fake "parking" write. Neither may complete a frame.
793
+ this.#abortCandidate(candidate.syncCloseSeen ? "park-open-crossed" : "nested-sync-open");
794
+ return;
795
+ }
796
+ let closeIndex = -1;
797
+ for (let index = 0; index < tokens.length; index += 1) {
798
+ if (tokens[index].kind === "sync-close") {
799
+ closeIndex = index;
800
+ break;
801
+ }
802
+ }
803
+ let closeThisWrite = false;
804
+ if (!candidate.syncCloseSeen && closeIndex >= 0) {
805
+ candidate.syncCloseSeen = true;
806
+ candidate.closeWriteIndex = writeIndex;
807
+ // A CLOSE completed from a carried tail occupies the start of this write, so any
808
+ // cursor token here is after it; use -1 to mean "before every offset in the write".
809
+ candidate.closeTokenOffset = tokens[closeIndex].offset === null ? -1 : tokens[closeIndex].offset;
810
+ closeThisWrite = true;
811
+ }
812
+ const cursorTokens = tokens.filter((token) => token.kind === "cursor-position" || token.kind === "cursor-visibility");
813
+ if (cursorTokens.length > 0) candidate.cursorEvidenceSeen = true;
814
+ if (candidate.syncCloseSeen) {
815
+ for (const token of cursorTokens) {
816
+ // Parking must be STRICTLY AFTER the CLOSE: a cursor operation before the
817
+ // CLOSE in the same write is not parking.
818
+ const afterClose = candidate.closeWriteIndex !== writeIndex || (token.offset !== null && token.offset > (candidate.closeTokenOffset ?? -1));
819
+ if (!afterClose) continue;
820
+ if (token.kind === "cursor-position") candidate.cursorParkingAfterClose = true;
821
+ if (token.kind === "cursor-visibility") {
822
+ candidate.cursorParkingAfterClose = true;
823
+ candidate.fVisibilityAfterClose = true;
824
+ }
825
+ }
826
+ }
827
+ void closeThisWrite;
828
+ }
829
+
830
+ #sEligibility(candidate) {
831
+ if (!candidate.sentinel.focusPresent) return "focus-missing-at-sentinel";
832
+ if (candidate.sentinel.mode !== this.#mode) return "mode-changed";
833
+ if (candidate.intentCols !== null && candidate.intentRows !== null) {
834
+ if (candidate.sampledCols !== candidate.intentCols || candidate.sampledRows !== candidate.intentRows) return "sampled-size-mismatch";
835
+ }
836
+ return "ok";
837
+ }
838
+
839
+ #completed(candidate) {
840
+ if (this.#mode === "fullscreen") {
841
+ return Boolean(candidate.sEmitted && candidate.syncCloseSeen && candidate.cursorEvidenceSeen);
842
+ }
843
+ return Boolean(candidate.sEmitted && candidate.syncOpenSeen && candidate.syncCloseSeen && candidate.cursorParkingAfterClose && candidate.fVisibilityAfterClose);
844
+ }
845
+
846
+ #push(decision) {
847
+ const candidate = this.#candidate;
848
+ const isProof = decision.kind === "S" || decision.kind === "F";
849
+ this.#decisionCount += 1;
850
+ this.#decisions.push({
851
+ kind: decision.kind,
852
+ frameId: decision.frameId === null || decision.frameId === undefined ? null : decision.frameId,
853
+ reason: decision.reason,
854
+ mode: this.#mode,
855
+ rendererEpoch: this.#rendererEpoch,
856
+ cols: isProof && candidate !== null ? candidate.sampledCols : null,
857
+ rows: isProof && candidate !== null ? candidate.sampledRows : null,
858
+ focusPresent: isProof && candidate !== null && candidate.sentinel !== null ? Boolean(candidate.sentinel.focusPresent) : null,
859
+ overlayPresent: isProof && candidate !== null && candidate.sentinel !== null ? Boolean(candidate.sentinel.overlayPresent) : null,
860
+ intentId: isProof && candidate !== null ? candidate.intentId : null,
861
+ geometryRevision: isProof && candidate !== null ? candidate.geometryRevision : null,
862
+ });
863
+ if (this.#decisions.length > MAX_DECISION_LOG) this.#decisions.splice(0, this.#decisions.length - MAX_DECISION_LOG);
864
+ }
865
+ }
866
+
867
+ /** Decisions a consumer may publish (S/F/A); `candidate`/`none` are diagnostics. */
868
+ export function publishedFrameDecisions(decisions) {
869
+ return decisions.filter((decision) => decision.kind === "S" || decision.kind === "F" || decision.kind === "A");
870
+ }