@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,460 @@
1
+ import { validateFrameMetadata } from "./attach-frame-proof.mjs";
2
+ import { ringChunksAfter } from "./terminal-model.mjs";
3
+ import {
4
+ captureTerminalSnapshot,
5
+ synthesizeFullRedrawFrame,
6
+ TERMINAL_SNAPSHOT_VERSION,
7
+ } from "./terminal-snapshot.mjs";
8
+
9
+ /**
10
+ * Capture-and-subscribe protocol for one attach socket (issue #91, D2 phase 3).
11
+ *
12
+ * Pure state machine: no sockets, no fs — `send` is injected and the runner
13
+ * feeds `onOutput` for every chunk it broadcasts. Correctness rests on two
14
+ * invariants:
15
+ *
16
+ * 1. Snapshot capture is async (parser idle wait), but output keeps flowing.
17
+ * Chunks fed during capture are retained in the model ring; the catch-up
18
+ * flush after the frame replays exactly the chunks past `dto.snapshotSeq`.
19
+ * The ring therefore IS the gap buffer — nothing is buffered twice.
20
+ * 2. Delivery order per socket is gap-free and duplicate-free: live output is
21
+ * only forwarded when `seq === lastSeq + 1`; any discontinuity downgrades
22
+ * the socket to `resnapshot_required` instead of stitching (spec: never
23
+ * recover from a partial tail).
24
+ *
25
+ * Ownership contract (runner seam): once a socket speaks `subscribe_terminal`,
26
+ * its output stream is owned by this state machine — the runner must exclude it
27
+ * from the legacy fire-and-forget output broadcast (`subscribed()` is the
28
+ * sticky marker for that exclusion). Delivering both streams would duplicate
29
+ * every chunk.
30
+ */
31
+
32
+ /**
33
+ * Wire frame version axis: the synthesized full-redraw frame format carried in
34
+ * `snapshot_frame` payloads (Route B). Clients advertising a different
35
+ * `frameVersion` on `subscribe_terminal` are rejected — the wire contract must
36
+ * match exactly; there is no negotiated degradation inside this protocol.
37
+ *
38
+ * This axis is independent from {@link TERMINAL_SNAPSHOT_VERSION} (the DTO
39
+ * capture format, an internal artifact that never crosses the wire in Route B).
40
+ */
41
+ export const TERMINAL_FRAME_VERSION = 1;
42
+ /** Optional per-socket view-proof capability (issue #222 FW-06, step 23b2b). */
43
+ export const VIEW_PROOF_PROTOCOL_VERSION = 1;
44
+ /** Bounded per-socket capture queue for S/F/A marker records. */
45
+ export const MAX_VIEW_PROOF_RECORDS = 256;
46
+
47
+ export { TERMINAL_SNAPSHOT_VERSION };
48
+
49
+ function unknownCoverage(reason) {
50
+ return { status: "unknown", reason, proof: null, throughSeq: null, cursorVisible: null };
51
+ }
52
+
53
+ function emptyBaselineCoverage() {
54
+ return unknownCoverage("empty-baseline");
55
+ }
56
+
57
+ /** Strip everything except the bounded view-proof record fields (never token/user text). */
58
+ export function sanitizeViewProofRecord(record) {
59
+ if (record === null || typeof record !== "object" || Array.isArray(record)) return null;
60
+ if (record.marker !== "S" && record.marker !== "F" && record.marker !== "A") return null;
61
+ if (!Number.isInteger(record.seq) || record.seq < 1) return null;
62
+ if (!Number.isInteger(record.frameId) || record.frameId < 1) return null;
63
+ if (record.sessionEpoch !== undefined && (!Number.isInteger(record.sessionEpoch) || record.sessionEpoch < 0)) return null;
64
+ const base = {
65
+ marker: record.marker,
66
+ seq: record.seq,
67
+ frameId: record.frameId,
68
+ ...(record.sessionEpoch === undefined ? {} : { sessionEpoch: record.sessionEpoch }),
69
+ };
70
+ if (record.marker === "S") {
71
+ return {
72
+ ...base,
73
+ ...(Number.isInteger(record.intentId) ? { intentId: record.intentId } : {}),
74
+ ...(Number.isInteger(record.sampledCols) ? { sampledCols: record.sampledCols } : {}),
75
+ };
76
+ }
77
+ if (record.marker === "F") {
78
+ const metadata = validateFrameMetadata(record.metadata);
79
+ if (!metadata.ok) return null;
80
+ if (record.sessionEpoch !== undefined && record.sessionEpoch !== metadata.value.sessionEpoch) return null;
81
+ return { ...base, metadata: metadata.value };
82
+ }
83
+ return { ...base, ...(typeof record.reason === "string" ? { reason: record.reason } : {}) };
84
+ }
85
+
86
+ /**
87
+ * @typedef {ReturnType<typeof createTerminalSubscription>} TerminalSubscription
88
+ */
89
+
90
+ /**
91
+ * Create the per-socket terminal subscription state.
92
+ *
93
+ * @param {{
94
+ * model: import("./terminal-model.mjs").TerminalModel,
95
+ * send: (msg: Record<string, unknown>) => void,
96
+ * viewProof?: { coverage: (seq: number) => any, replay: (fromSeq: number, throughSeq: number) => any },
97
+ * }} opts
98
+ */
99
+ export function createTerminalSubscription({ model, send: rawSend, generation, viewProof = null }) {
100
+ /** @type {"idle" | "capturing" | "live"} */
101
+ let state = "idle";
102
+ let lastSeq = 0;
103
+ // Optional FW-06 capability: negotiated per socket, never downgraded by a later
104
+ // subscribe_terminal without the field. Queued only while a capture is in flight.
105
+ let viewProofEnabled = false;
106
+ /** @type {any[]} */
107
+ let viewProofQueue = [];
108
+ let viewProofQueueDropped = false;
109
+ let captureViewProofReset = null;
110
+ // Sticky protocol-ownership marker: set the moment a socket speaks
111
+ // subscribe_terminal, never reset (not even by gap downgrade or snapshot
112
+ // failure). The runner excludes subscribed sockets from the legacy output
113
+ // broadcast; delivery then belongs to this state machine alone.
114
+ let subscribed = false;
115
+
116
+ // Choke-point guard (CR R1 blocking): every protocol write funnels through
117
+ // this wrapper, so a synchronous socket.write throw can never escape into
118
+ // the runner's uncaughtException crash path (a runner crash kills the
119
+ // hosted child). Pinned semantics (tested): a send-throw is treated like
120
+ // socket death — the subscription downgrades to "idle" silently, further
121
+ // sends are swallowed no-ops, live fan-out stops, and the runner's socket
122
+ // error/close handler owns the actual cleanup. "live" transitions are
123
+ // gated on sendBroken so a throw mid-replay or mid-snapshot cannot
124
+ // resurrect a dead socket into live state.
125
+ let sendBroken = false;
126
+ const send = (msg) => {
127
+ if (sendBroken) return;
128
+ try {
129
+ rawSend(msg);
130
+ } catch {
131
+ sendBroken = true;
132
+ state = "idle";
133
+ }
134
+ };
135
+
136
+ const fail = (code, extra = {}) => {
137
+ send({ type: "error", code, ...extra });
138
+ };
139
+
140
+ function coverageAt(seq, overrideReason = null) {
141
+ if (overrideReason !== null) return unknownCoverage(overrideReason);
142
+ if (seq === 0) return emptyBaselineCoverage();
143
+ const coverage = viewProof?.coverage?.(seq);
144
+ return coverage && typeof coverage === "object" ? coverage : unknownCoverage("no-frame-witness");
145
+ }
146
+
147
+ function clearCaptureViewProof(reason = null) {
148
+ viewProofQueue = [];
149
+ viewProofQueueDropped = false;
150
+ captureViewProofReset = reason;
151
+ }
152
+
153
+ function queueViewProofRecord(record) {
154
+ if (viewProofQueue.length >= MAX_VIEW_PROOF_RECORDS) {
155
+ viewProofQueueDropped = true;
156
+ return;
157
+ }
158
+ viewProofQueue.push(record);
159
+ }
160
+
161
+ /**
162
+ * Emit queued S/F/A records in seq order around the ring catch-up. Output at
163
+ * an equal seq is either already in the snapshot frame (seq <= snapshotSeq)
164
+ * or emitted first by the caller (seq > snapshotSeq).
165
+ */
166
+ function flushCaptureViewProofRecords(chunks, snapshotSeq) {
167
+ if (!viewProofEnabled) {
168
+ for (const chunk of chunks) {
169
+ send({ type: "output", seq: chunk.seq, data: chunk.data });
170
+ lastSeq = chunk.seq;
171
+ }
172
+ return;
173
+ }
174
+ if (viewProofQueueDropped) {
175
+ viewProofQueue = [];
176
+ for (const chunk of chunks) {
177
+ send({ type: "output", seq: chunk.seq, data: chunk.data });
178
+ lastSeq = chunk.seq;
179
+ }
180
+ return;
181
+ }
182
+ const records = viewProofQueue.slice();
183
+ viewProofQueue = [];
184
+ const beforeChunks = [];
185
+ const bySeq = new Map();
186
+ const afterChunks = [];
187
+ for (const record of records) {
188
+ if (record.seq <= snapshotSeq) beforeChunks.push(record);
189
+ else {
190
+ const list = bySeq.get(record.seq) ?? [];
191
+ list.push(record);
192
+ bySeq.set(record.seq, list);
193
+ }
194
+ }
195
+ for (const record of beforeChunks) send({ type: "view_proof", record });
196
+ const sentSeqs = new Set();
197
+ for (const chunk of chunks) {
198
+ send({ type: "output", seq: chunk.seq, data: chunk.data });
199
+ lastSeq = chunk.seq;
200
+ sentSeqs.add(chunk.seq);
201
+ for (const record of bySeq.get(chunk.seq) ?? []) send({ type: "view_proof", record });
202
+ }
203
+ for (const [seq, list] of bySeq) {
204
+ if (sentSeqs.has(seq)) continue;
205
+ for (const record of list) afterChunks.push(record);
206
+ }
207
+ // A record can only be missing its catch-up chunk if the ring lost data;
208
+ // do not pretend it was stitched. The end-state coverage marks UNKNOWN.
209
+ if (afterChunks.length > 0) viewProofQueueDropped = true;
210
+ }
211
+
212
+ /**
213
+ * Fresh-snapshot path (first subscribe or unrecoverable cursor). Frame
214
+ * metadata comes from the captured DTO, so a resize racing the async
215
+ * capture cannot produce a half-old header.
216
+ * @param {{ resnapshot?: boolean }} [marks]
217
+ */
218
+ async function startSnapshot({ resnapshot = false } = {}) {
219
+ state = "capturing";
220
+ clearCaptureViewProof(null);
221
+ try {
222
+ const empty = model.lastSeq === 0;
223
+ const begin = /** @type {Record<string, unknown>} */ ({
224
+ type: "snapshot_begin",
225
+ snapshotSeq: 0,
226
+ cols: model.cols,
227
+ rows: model.rows,
228
+ frameVersion: TERMINAL_FRAME_VERSION,
229
+ // Runner boot identity (issue #91 phase 5): lets a reconnecting
230
+ // client detect a runner replacement from the snapshot answer alone.
231
+ ...(generation ? { generation } : {}),
232
+ });
233
+ if (empty) {
234
+ // Runner (re)started and the new child has produced no output yet:
235
+ // the "host starting" baseline. No frame; live output starts at seq 1.
236
+ // The resnapshot marker applies here too: a client presenting a stale
237
+ // cursor must discard its local buffer either way (whole-branch review:
238
+ // marker contract must be consistent across resnapshot paths).
239
+ begin.empty = true;
240
+ if (resnapshot) begin.resnapshot = true;
241
+ if (viewProofEnabled) begin.viewProof = emptyBaselineCoverage();
242
+ send(begin);
243
+ lastSeq = 0;
244
+ send({
245
+ type: "snapshot_end",
246
+ nextSeq: lastSeq + 1,
247
+ scrollbackLines: 0,
248
+ ...(viewProofEnabled ? { viewProof: emptyBaselineCoverage() } : {}),
249
+ });
250
+ if (!sendBroken) state = "live";
251
+ return;
252
+ }
253
+ const dto = await captureTerminalSnapshot(model);
254
+ begin.snapshotSeq = dto.snapshotSeq;
255
+ begin.cols = dto.cols;
256
+ begin.rows = dto.rows;
257
+ if (resnapshot) begin.resnapshot = true;
258
+ if (viewProofEnabled) begin.viewProof = coverageAt(dto.snapshotSeq, captureViewProofReset);
259
+ send(begin);
260
+ send({
261
+ type: "snapshot_frame",
262
+ frameVersion: TERMINAL_FRAME_VERSION,
263
+ data: await synthesizeFullRedrawFrame(dto),
264
+ });
265
+ // Catch-up flush: chunks fed while the capture was in flight (they are
266
+ // already part of the parser state past dto.snapshotSeq, so replaying
267
+ // the ring past that cursor is exact — no gap, no duplicate).
268
+ const after = ringChunksAfter(model, dto.snapshotSeq);
269
+ if (after.evicted) {
270
+ // Ring pressure during capture: refuse to stitch, ask for a redo.
271
+ state = "idle";
272
+ clearCaptureViewProof(null);
273
+ send({ type: "resnapshot_required", lastSeq, missing: dto.snapshotSeq + 1 });
274
+ return;
275
+ }
276
+ flushCaptureViewProofRecords(after.chunks, dto.snapshotSeq);
277
+ if (after.chunks.length === 0) lastSeq = dto.snapshotSeq;
278
+ // scrollbackLines is the runner-authoritative history count for the very
279
+ // frame just sent (same capture): the client needs it to tell real history
280
+ // from rows its local hydration scrolled out of the viewport (issue #205).
281
+ const endCoverageReason = captureViewProofReset ?? (viewProofQueueDropped ? "view-proof-records-dropped" : null);
282
+ send({
283
+ type: "snapshot_end",
284
+ nextSeq: lastSeq + 1,
285
+ scrollbackLines: dto.scrollback.length,
286
+ ...(viewProofEnabled ? { viewProof: coverageAt(lastSeq, endCoverageReason) } : {}),
287
+ });
288
+ clearCaptureViewProof(null);
289
+ if (!sendBroken) state = "live";
290
+ } catch (err) {
291
+ state = "idle";
292
+ clearCaptureViewProof(null);
293
+ fail("snapshot_failed", { message: err instanceof Error ? err.message : String(err) });
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Reconnect path with a still-retained cursor. Fully synchronous: the ring
299
+ * read and the replay writes cannot interleave with feedOutput, so the
300
+ * subscription lands in live state with lastSeq === model.lastSeq.
301
+ * @param {number} sinceSeq
302
+ */
303
+ function startReplay(sinceSeq) {
304
+ const after = ringChunksAfter(model, sinceSeq);
305
+ if (after.evicted) {
306
+ state = "idle";
307
+ send({ type: "resnapshot_required", lastSeq, missing: sinceSeq + 1 });
308
+ return;
309
+ }
310
+ const hasRange = model.lastSeq > sinceSeq;
311
+ const replayResult = viewProofEnabled && hasRange ? viewProof?.replay?.(sinceSeq + 1, model.lastSeq) : null;
312
+ let proofBySeq = null;
313
+ let proofProblem = null;
314
+ if (viewProofEnabled && hasRange) {
315
+ if (replayResult === null || replayResult === undefined) proofProblem = "no-frame-witness";
316
+ else if (replayResult.status !== "ok") proofProblem = `proof-replay-${replayResult.reason ?? replayResult.status}`;
317
+ else if (replayResult.entries.some((entry) => entry.droppedProofs > 0 || entry.droppedEvents > 0)) proofProblem = "proof-replay-dropped";
318
+ else {
319
+ const chunkSeqs = new Set(after.chunks.map((chunk) => chunk.seq));
320
+ const map = new Map();
321
+ for (const entry of replayResult.entries) {
322
+ if (entry.proofs.length === 0) continue;
323
+ if (!chunkSeqs.has(entry.seq)) {
324
+ proofProblem = "proof-replay-gap";
325
+ break;
326
+ }
327
+ map.set(entry.seq, entry.proofs);
328
+ }
329
+ if (proofProblem === null) proofBySeq = map;
330
+ }
331
+ }
332
+ for (const chunk of after.chunks) {
333
+ send({ type: "output", seq: chunk.seq, data: chunk.data });
334
+ if (proofBySeq !== null) {
335
+ for (const proof of proofBySeq.get(chunk.seq) ?? []) {
336
+ const record = sanitizeViewProofRecord(proof);
337
+ if (record !== null) send({ type: "view_proof", record });
338
+ }
339
+ }
340
+ }
341
+ lastSeq = model.lastSeq;
342
+ if (viewProofEnabled) send({ type: "view_proof_state", viewProof: coverageAt(lastSeq, proofProblem) });
343
+ if (!sendBroken) state = "live";
344
+ }
345
+
346
+ /**
347
+ * Handle one client→runner terminal message. Returns true when consumed.
348
+ *
349
+ * `subscribe_terminal` is the only message of this protocol. Defensive
350
+ * frameVersion gate: a client that carries a `frameVersion` we do not emit
351
+ * is rejected up front instead of receiving a frame it cannot parse.
352
+ *
353
+ * @param {any} msg
354
+ * @returns {boolean}
355
+ */
356
+ function handleMessage(msg) {
357
+ if (!msg || msg.type !== "subscribe_terminal") return false;
358
+ // Every subscribe_terminal speaker is protocol-managed from here on,
359
+ // including rejected ones (frameVersion mismatch, invalid cursor): the
360
+ // legacy broadcast stream must never interleave with this protocol.
361
+ subscribed = true;
362
+ if (msg.frameVersion !== undefined && msg.frameVersion !== TERMINAL_FRAME_VERSION) {
363
+ fail("frame_version_mismatch", { supported: TERMINAL_FRAME_VERSION, received: msg.frameVersion });
364
+ return true;
365
+ }
366
+ if (msg.viewProof !== undefined) {
367
+ if (msg.viewProof !== VIEW_PROOF_PROTOCOL_VERSION) {
368
+ fail("view_proof_version_mismatch", { supported: VIEW_PROOF_PROTOCOL_VERSION, received: msg.viewProof });
369
+ return true;
370
+ }
371
+ viewProofEnabled = true;
372
+ }
373
+ if (
374
+ msg.sinceSeq !== undefined &&
375
+ (!Number.isInteger(msg.sinceSeq) || msg.sinceSeq < 0)
376
+ ) {
377
+ fail("invalid_since_seq", { sinceSeq: msg.sinceSeq });
378
+ return true;
379
+ }
380
+ if (state === "capturing") return true; // snapshot already in flight; ignore duplicates
381
+ if (msg.sinceSeq === undefined) {
382
+ // .catch belt-and-braces (CR R1): the snapshot flow already funnels
383
+ // failures into fail() — which is now throw-safe via the choke point —
384
+ // but a stray rejection must never surface as an unhandledRejection
385
+ // crash (the runner treats that as fatal).
386
+ void startSnapshot().catch(() => {});
387
+ } else if (msg.sinceSeq > model.lastSeq || msg.sinceSeq < model.evictedThrough) {
388
+ // Client ahead of the runner (foreign cursor) or needs an evicted
389
+ // range: both mean "no shared baseline" — fresh snapshot, marked so
390
+ // the client discards its local buffer first.
391
+ void startSnapshot({ resnapshot: true }).catch(() => {});
392
+ } else {
393
+ // Ring invariant: (evictedThrough, lastSeq] is always contiguous, so
394
+ // sinceSeq === evictedThrough is a COMPLETE replay, not a partial tail.
395
+ startReplay(msg.sinceSeq);
396
+ }
397
+ return true;
398
+ }
399
+
400
+ /**
401
+ * Live fan-out for one chunk. Called by the runner for every chunk it
402
+ * broadcasts, in feed order. No-op while a snapshot is in flight (the
403
+ * catch-up flush owns delivery for that window).
404
+ *
405
+ * @param {number} seq
406
+ * @param {string} data
407
+ */
408
+ function onOutput(seq, data) {
409
+ if (state !== "live") return;
410
+ if (seq === lastSeq + 1) {
411
+ lastSeq = seq;
412
+ send({ type: "output", seq, data });
413
+ return;
414
+ }
415
+ // Gap: never skip silently, never fabricate. One marker, then the client
416
+ // must re-subscribe (fresh snapshot or replay from its real cursor).
417
+ state = "idle";
418
+ clearCaptureViewProof(null);
419
+ send({ type: "resnapshot_required", lastSeq, missing: seq });
420
+ }
421
+
422
+ /** Live/capture delivery of one accepted S/F/A record (opt-in sockets only). */
423
+ function onViewProofRecord(record) {
424
+ if (!viewProofEnabled) return;
425
+ const clean = sanitizeViewProofRecord(record);
426
+ if (clean === null) return;
427
+ if (state === "live") send({ type: "view_proof", record: clean });
428
+ else if (state === "capturing") queueViewProofRecord(clean);
429
+ }
430
+
431
+ /**
432
+ * Reporter replacement/close: drop queued old-token records and force current
433
+ * view-proof state UNKNOWN. Never carries old proofs into a new reporter.
434
+ */
435
+ function onViewProofReset(reason) {
436
+ viewProofQueue = [];
437
+ viewProofQueueDropped = false;
438
+ if (!viewProofEnabled) return;
439
+ if (state === "capturing") {
440
+ captureViewProofReset = reason;
441
+ return;
442
+ }
443
+ if (state === "live") send({ type: "view_proof_state", viewProof: unknownCoverage(reason) });
444
+ }
445
+
446
+ return {
447
+ handleMessage,
448
+ onOutput,
449
+ onViewProofRecord,
450
+ onViewProofReset,
451
+ /** Sticky: true once the socket sent subscribe_terminal. The runner uses
452
+ * this to keep the socket out of the legacy output broadcast. */
453
+ subscribed: () => subscribed,
454
+ /** Socket went away; the runner drops its reference. */
455
+ detach() {
456
+ state = "idle";
457
+ clearCaptureViewProof(null);
458
+ },
459
+ };
460
+ }
@@ -0,0 +1,222 @@
1
+ import xtermHeadless from "@xterm/headless";
2
+
3
+ // @xterm/headless is CommonJS; named import from ESM fails, so destructure.
4
+ const { Terminal } = /** @type {any} */ (xtermHeadless);
5
+
6
+ /**
7
+ * Canonical terminal model for one hosted view (issue #91, D2/D5).
8
+ *
9
+ * The PTY runner owns one model per host: every child output chunk is assigned a
10
+ * strictly increasing `outputSeq`, fed to a real terminal state machine
11
+ * (@xterm/headless by default), and retained in a bounded in-memory ring so a
12
+ * late subscriber can replay chunks after a snapshot cursor. This module is the
13
+ * pure-ish core: no fs, no sockets — the runner wires it into `child.onData`.
14
+ *
15
+ * The canonical state lives for the runner's lifetime only; it is never
16
+ * persisted across runner restarts (runner death kills the child; a new child
17
+ * establishes a fresh baseline).
18
+ */
19
+
20
+ /** Default ring retention: chunk count cap. */
21
+ export const DEFAULT_RING_CHUNK_CAP = 2048;
22
+ /** Default ring retention: total buffered bytes cap (4 MiB). */
23
+ export const DEFAULT_RING_BYTE_CAP = 4 * 1024 * 1024;
24
+
25
+ /**
26
+ * Default parser factory. `write(data, cb)` callbacks are supported by
27
+ * @xterm/headless v6 and fire after the chunk is fully parsed, which makes
28
+ * `whenIdle` deterministic without polling.
29
+ *
30
+ * @param {{ cols: number, rows: number, scrollback: number }} opts
31
+ */
32
+ export function defaultParserFactory({ cols, rows, scrollback }) {
33
+ return new Terminal({ cols, rows, scrollback, allowProposedApi: true });
34
+ }
35
+
36
+ /**
37
+ * @typedef {{
38
+ * seq: number,
39
+ * data: string,
40
+ * }} RingChunk
41
+ */
42
+
43
+ /**
44
+ * @typedef {object} TerminalModel
45
+ * @property {(opts: { cols: number, rows: number, scrollback: number }) => unknown} parserFactory
46
+ * @property {unknown} parser injected terminal state machine
47
+ * @property {number} cols
48
+ * @property {number} rows
49
+ * @property {number} scrollback
50
+ * @property {number} ringChunkCap
51
+ * @property {number} ringByteCap
52
+ * @property {number} lastSeq seq of the last fed chunk; 0 before the first chunk
53
+ * @property {number} pendingWrites in-flight parser writes (write called, callback not yet fired)
54
+ * @property {Array<() => void>} idleWaiters
55
+ * @property {RingChunk[]} ring oldest-first retained chunks
56
+ * @property {number} ringBytes total buffered bytes across ring chunks
57
+ * @property {number} evictedThrough highest seq evicted from the ring; 0 when nothing was evicted
58
+ */
59
+
60
+ /**
61
+ * Create a canonical terminal model. The parser factory is injectable so tests
62
+ * can drive seq/ring logic with a deterministic fake and render tests can use
63
+ * the real @xterm/headless parser.
64
+ *
65
+ * @param {{
66
+ * cols?: number,
67
+ * rows?: number,
68
+ * scrollback?: number,
69
+ * ringChunkCap?: number,
70
+ * ringByteCap?: number,
71
+ * parserFactory?: typeof defaultParserFactory,
72
+ * }} [opts]
73
+ * @returns {TerminalModel}
74
+ */
75
+ export function createTerminalModel({
76
+ cols = 80,
77
+ rows = 24,
78
+ scrollback = 2000,
79
+ ringChunkCap = DEFAULT_RING_CHUNK_CAP,
80
+ ringByteCap = DEFAULT_RING_BYTE_CAP,
81
+ parserFactory = defaultParserFactory,
82
+ } = {}) {
83
+ return {
84
+ parserFactory,
85
+ parser: parserFactory({ cols, rows, scrollback }),
86
+ cols,
87
+ rows,
88
+ scrollback,
89
+ ringChunkCap,
90
+ ringByteCap,
91
+ lastSeq: 0,
92
+ pendingWrites: 0,
93
+ idleWaiters: [],
94
+ ring: [],
95
+ ringBytes: 0,
96
+ evictedThrough: 0,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Feed one child output chunk: assign the next seq, append to the ring (with
102
+ * eviction), and hand the exact bytes to the parser in feed order. Escape
103
+ * sequences split across chunk boundaries stay correct because every chunk is
104
+ * forwarded to the parser verbatim, in order.
105
+ *
106
+ * Fire-and-forget by design; callers that need the parse result await
107
+ * `whenIdle(model)` first (e.g. before capturing a snapshot).
108
+ *
109
+ * @param {TerminalModel} model
110
+ * @param {string} data
111
+ * @returns {number} the seq assigned to this chunk
112
+ */
113
+ export function feedOutput(model, data) {
114
+ const seq = model.lastSeq + 1;
115
+ model.lastSeq = seq;
116
+ appendRing(model, { seq, data });
117
+ // Increment before write so a synchronously-firing callback still observes
118
+ // consistent accounting.
119
+ model.pendingWrites += 1;
120
+ model.parser.write(data, () => {
121
+ model.pendingWrites -= 1;
122
+ if (model.pendingWrites === 0 && model.idleWaiters.length > 0) {
123
+ const waiters = model.idleWaiters;
124
+ model.idleWaiters = [];
125
+ for (const resolve of waiters) resolve();
126
+ }
127
+ });
128
+ return seq;
129
+ }
130
+
131
+ /**
132
+ * Evict-then-append retention: trim oldest chunks until both caps hold. The
133
+ * newest chunk is never evicted by its own append (a single oversized chunk
134
+ * alone in the ring is allowed; subscribers needing anything before it get a
135
+ * fresh snapshot instead of a stitched tail).
136
+ *
137
+ * @param {TerminalModel} model
138
+ * @param {RingChunk} chunk
139
+ */
140
+ function appendRing(model, chunk) {
141
+ model.ring.push(chunk);
142
+ model.ringBytes += Buffer.byteLength(chunk.data, "utf8");
143
+ while (
144
+ model.ring.length > 1 &&
145
+ (model.ring.length > model.ringChunkCap || model.ringBytes > model.ringByteCap)
146
+ ) {
147
+ const evicted = model.ring.shift();
148
+ model.ringBytes -= Buffer.byteLength(evicted.data, "utf8");
149
+ model.evictedThrough = evicted.seq;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Resolve once every chunk fed so far has been fully parsed. Deterministic:
155
+ * backed by parser write callbacks (no timing-based polling). Safe to call
156
+ * repeatedly and concurrently; waiters are released in registration order.
157
+ *
158
+ * @param {TerminalModel} model
159
+ * @returns {Promise<void>}
160
+ */
161
+ export function whenIdle(model) {
162
+ if (model.pendingWrites === 0) return Promise.resolve();
163
+ return new Promise((resolve) => {
164
+ model.idleWaiters.push(resolve);
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Chunks strictly after `seq`, or an eviction marker when part of the requested
170
+ * range `(seq, ...]` is no longer retained. Callers must treat `evicted: true`
171
+ * as "fresh snapshot required" — never stitch from a partial tail.
172
+ *
173
+ * @param {TerminalModel} model
174
+ * @param {number} seq cursor to read from (e.g. a snapshot's snapshotSeq)
175
+ * @returns {{ evicted: false, chunks: RingChunk[] } | { evicted: true, evictedThrough: number }}
176
+ */
177
+ export function ringChunksAfter(model, seq) {
178
+ if (seq < model.evictedThrough) {
179
+ return { evicted: true, evictedThrough: model.evictedThrough };
180
+ }
181
+ const chunks = [];
182
+ for (const chunk of model.ring) {
183
+ if (chunk.seq > seq) chunks.push(chunk);
184
+ }
185
+ return { evicted: false, chunks };
186
+ }
187
+
188
+ /**
189
+ * Resize the model and its parser (reflow is the parser's job). Cols/rows on
190
+ * the model are the authoritative post-resize dimensions for snapshot metadata.
191
+ *
192
+ * @param {TerminalModel} model
193
+ * @param {number} cols
194
+ * @param {number} rows
195
+ */
196
+ export function resizeTerminalModel(model, cols, rows) {
197
+ model.cols = cols;
198
+ model.rows = rows;
199
+ const parser = /** @type {{ resize: (c: number, r: number) => void }} */ (model.parser);
200
+ parser.resize(cols, rows);
201
+ }
202
+
203
+ /**
204
+ * Resize the real PTY and the canonical terminal model as ONE paired step
205
+ * (CR R1 advisory): the model reflows only when `child.resize` succeeded, so
206
+ * snapshot geometry always mirrors the actual child grid. A throw (child
207
+ * racing exit) leaves the PTY at its old geometry and the model matching it,
208
+ * instead of a canonical model that misrepresents the real child output.
209
+ *
210
+ * @param {{ resize: (cols: number, rows: number) => void }} child duck-typed PTY handle
211
+ * @param {TerminalModel} model
212
+ * @param {number} cols
213
+ * @param {number} rows
214
+ */
215
+ export function resizeChildAndModel(child, model, cols, rows) {
216
+ try {
217
+ child.resize(cols, rows);
218
+ resizeTerminalModel(model, cols, rows);
219
+ } catch {
220
+ /* child racing exit; PTY kept its old geometry — model stays consistent */
221
+ }
222
+ }