@zhuxixi/pi-agent-board 0.6.2 → 0.8.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 (67) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +6 -3
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
  6. package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
  7. package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
  8. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  9. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  10. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  11. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  12. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  13. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  14. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  15. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  16. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  17. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  18. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  19. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  20. package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
  21. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
  22. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  23. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  24. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  25. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  26. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  27. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  28. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  29. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  30. package/package.json +3 -2
  31. package/runner/job-runner-legacy.mjs +68 -0
  32. package/runner/job-runner.mjs +371 -67
  33. package/runner/pty-runner-legacy.mjs +50 -0
  34. package/runner/pty-runner.mjs +685 -58
  35. package/runner/state-coordinator.mjs +429 -0
  36. package/runner/state-runner.mjs +90 -15
  37. package/scripts/run-perf-gate.mjs +40 -0
  38. package/src/commands/agent-board.ts +8 -8
  39. package/src/commands/attach-flow.ts +5 -5
  40. package/src/commands/bg.ts +2 -1
  41. package/src/core/control-protocol.mjs +482 -0
  42. package/src/core/coordinator-client.mjs +313 -0
  43. package/src/core/coordinator-journal.mjs +282 -0
  44. package/src/core/coordinator-protocol.mjs +12 -0
  45. package/src/core/editor-state-reporter.mjs +11 -1
  46. package/src/core/foreground-preview-cache.mjs +117 -0
  47. package/src/core/host-protocol.mjs +24 -0
  48. package/src/core/launch.mjs +15 -0
  49. package/src/core/locks.mjs +68 -14
  50. package/src/core/paths.mjs +48 -0
  51. package/src/core/pid.mjs +32 -1
  52. package/src/core/pty-attach-jiggle-controller.mjs +83 -6
  53. package/src/core/pty-attach-reconnect.mjs +13 -6
  54. package/src/core/pty-attach-render.mjs +50 -0
  55. package/src/core/state-commands.mjs +699 -0
  56. package/src/core/status-consistency.mjs +98 -0
  57. package/src/core/store.mjs +59 -13
  58. package/src/core/terminal-attach-client.mjs +803 -0
  59. package/src/core/terminal-attach-protocol.mjs +252 -0
  60. package/src/core/terminal-model.mjs +222 -0
  61. package/src/core/terminal-snapshot.mjs +440 -0
  62. package/src/core/types.mjs +2 -0
  63. package/src/index.ts +12 -4
  64. package/src/runtime/service.mjs +694 -121
  65. package/src/ui/dashboard.ts +67 -92
  66. package/src/ui/pty-attach.ts +298 -72
  67. package/src/core/pty-input.mjs +0 -47
@@ -0,0 +1,252 @@
1
+ import { ringChunksAfter } from "./terminal-model.mjs";
2
+ import {
3
+ captureTerminalSnapshot,
4
+ synthesizeFullRedrawFrame,
5
+ TERMINAL_SNAPSHOT_VERSION,
6
+ } from "./terminal-snapshot.mjs";
7
+
8
+ /**
9
+ * Capture-and-subscribe protocol for one attach socket (issue #91, D2 phase 3).
10
+ *
11
+ * Pure state machine: no sockets, no fs — `send` is injected and the runner
12
+ * feeds `onOutput` for every chunk it broadcasts. Correctness rests on two
13
+ * invariants:
14
+ *
15
+ * 1. Snapshot capture is async (parser idle wait), but output keeps flowing.
16
+ * Chunks fed during capture are retained in the model ring; the catch-up
17
+ * flush after the frame replays exactly the chunks past `dto.snapshotSeq`.
18
+ * The ring therefore IS the gap buffer — nothing is buffered twice.
19
+ * 2. Delivery order per socket is gap-free and duplicate-free: live output is
20
+ * only forwarded when `seq === lastSeq + 1`; any discontinuity downgrades
21
+ * the socket to `resnapshot_required` instead of stitching (spec: never
22
+ * recover from a partial tail).
23
+ *
24
+ * Ownership contract (runner seam): once a socket speaks `subscribe_terminal`,
25
+ * its output stream is owned by this state machine — the runner must exclude it
26
+ * from the legacy fire-and-forget output broadcast (`subscribed()` is the
27
+ * sticky marker for that exclusion). Delivering both streams would duplicate
28
+ * every chunk.
29
+ */
30
+
31
+ /**
32
+ * Wire frame version axis: the synthesized full-redraw frame format carried in
33
+ * `snapshot_frame` payloads (Route B). Clients advertising a different
34
+ * `frameVersion` on `subscribe_terminal` are rejected — the wire contract must
35
+ * match exactly; there is no negotiated degradation inside this protocol.
36
+ *
37
+ * This axis is independent from {@link TERMINAL_SNAPSHOT_VERSION} (the DTO
38
+ * capture format, an internal artifact that never crosses the wire in Route B).
39
+ */
40
+ export const TERMINAL_FRAME_VERSION = 1;
41
+
42
+ export { TERMINAL_SNAPSHOT_VERSION };
43
+
44
+ /**
45
+ * @typedef {ReturnType<typeof createTerminalSubscription>} TerminalSubscription
46
+ */
47
+
48
+ /**
49
+ * Create the per-socket terminal subscription state.
50
+ *
51
+ * @param {{
52
+ * model: import("./terminal-model.mjs").TerminalModel,
53
+ * send: (msg: Record<string, unknown>) => void,
54
+ * }} opts
55
+ */
56
+ export function createTerminalSubscription({ model, send: rawSend, generation }) {
57
+ /** @type {"idle" | "capturing" | "live"} */
58
+ let state = "idle";
59
+ let lastSeq = 0;
60
+ // Sticky protocol-ownership marker: set the moment a socket speaks
61
+ // subscribe_terminal, never reset (not even by gap downgrade or snapshot
62
+ // failure). The runner excludes subscribed sockets from the legacy output
63
+ // broadcast; delivery then belongs to this state machine alone.
64
+ let subscribed = false;
65
+
66
+ // Choke-point guard (CR R1 blocking): every protocol write funnels through
67
+ // this wrapper, so a synchronous socket.write throw can never escape into
68
+ // the runner's uncaughtException crash path (a runner crash kills the
69
+ // hosted child). Pinned semantics (tested): a send-throw is treated like
70
+ // socket death — the subscription downgrades to "idle" silently, further
71
+ // sends are swallowed no-ops, live fan-out stops, and the runner's socket
72
+ // error/close handler owns the actual cleanup. "live" transitions are
73
+ // gated on sendBroken so a throw mid-replay or mid-snapshot cannot
74
+ // resurrect a dead socket into live state.
75
+ let sendBroken = false;
76
+ const send = (msg) => {
77
+ if (sendBroken) return;
78
+ try {
79
+ rawSend(msg);
80
+ } catch {
81
+ sendBroken = true;
82
+ state = "idle";
83
+ }
84
+ };
85
+
86
+ const fail = (code, extra = {}) => {
87
+ send({ type: "error", code, ...extra });
88
+ };
89
+
90
+ /**
91
+ * Fresh-snapshot path (first subscribe or unrecoverable cursor). Frame
92
+ * metadata comes from the captured DTO, so a resize racing the async
93
+ * capture cannot produce a half-old header.
94
+ * @param {{ resnapshot?: boolean }} [marks]
95
+ */
96
+ async function startSnapshot({ resnapshot = false } = {}) {
97
+ state = "capturing";
98
+ try {
99
+ const empty = model.lastSeq === 0;
100
+ const begin = /** @type {Record<string, unknown>} */ ({
101
+ type: "snapshot_begin",
102
+ snapshotSeq: 0,
103
+ cols: model.cols,
104
+ rows: model.rows,
105
+ frameVersion: TERMINAL_FRAME_VERSION,
106
+ // Runner boot identity (issue #91 phase 5): lets a reconnecting
107
+ // client detect a runner replacement from the snapshot answer alone.
108
+ ...(generation ? { generation } : {}),
109
+ });
110
+ if (empty) {
111
+ // Runner (re)started and the new child has produced no output yet:
112
+ // the "host starting" baseline. No frame; live output starts at seq 1.
113
+ // The resnapshot marker applies here too: a client presenting a stale
114
+ // cursor must discard its local buffer either way (whole-branch review:
115
+ // marker contract must be consistent across resnapshot paths).
116
+ begin.empty = true;
117
+ if (resnapshot) begin.resnapshot = true;
118
+ send(begin);
119
+ lastSeq = 0;
120
+ send({ type: "snapshot_end", nextSeq: lastSeq + 1 });
121
+ if (!sendBroken) state = "live";
122
+ return;
123
+ }
124
+ const dto = await captureTerminalSnapshot(model);
125
+ begin.snapshotSeq = dto.snapshotSeq;
126
+ begin.cols = dto.cols;
127
+ begin.rows = dto.rows;
128
+ if (resnapshot) begin.resnapshot = true;
129
+ send(begin);
130
+ send({
131
+ type: "snapshot_frame",
132
+ frameVersion: TERMINAL_FRAME_VERSION,
133
+ data: await synthesizeFullRedrawFrame(dto),
134
+ });
135
+ // Catch-up flush: chunks fed while the capture was in flight (they are
136
+ // already part of the parser state past dto.snapshotSeq, so replaying
137
+ // the ring past that cursor is exact — no gap, no duplicate).
138
+ const after = ringChunksAfter(model, dto.snapshotSeq);
139
+ if (after.evicted) {
140
+ // Ring pressure during capture: refuse to stitch, ask for a redo.
141
+ state = "idle";
142
+ send({ type: "resnapshot_required", lastSeq, missing: dto.snapshotSeq + 1 });
143
+ return;
144
+ }
145
+ for (const chunk of after.chunks) {
146
+ send({ type: "output", seq: chunk.seq, data: chunk.data });
147
+ lastSeq = chunk.seq;
148
+ }
149
+ if (after.chunks.length === 0) lastSeq = dto.snapshotSeq;
150
+ send({ type: "snapshot_end", nextSeq: lastSeq + 1 });
151
+ if (!sendBroken) state = "live";
152
+ } catch (err) {
153
+ state = "idle";
154
+ fail("snapshot_failed", { message: err instanceof Error ? err.message : String(err) });
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Reconnect path with a still-retained cursor. Fully synchronous: the ring
160
+ * read and the replay writes cannot interleave with feedOutput, so the
161
+ * subscription lands in live state with lastSeq === model.lastSeq.
162
+ * @param {number} sinceSeq
163
+ */
164
+ function startReplay(sinceSeq) {
165
+ const after = ringChunksAfter(model, sinceSeq);
166
+ for (const chunk of after.chunks) {
167
+ send({ type: "output", seq: chunk.seq, data: chunk.data });
168
+ }
169
+ lastSeq = model.lastSeq;
170
+ if (!sendBroken) state = "live";
171
+ }
172
+
173
+ /**
174
+ * Handle one client→runner terminal message. Returns true when consumed.
175
+ *
176
+ * `subscribe_terminal` is the only message of this protocol. Defensive
177
+ * frameVersion gate: a client that carries a `frameVersion` we do not emit
178
+ * is rejected up front instead of receiving a frame it cannot parse.
179
+ *
180
+ * @param {any} msg
181
+ * @returns {boolean}
182
+ */
183
+ function handleMessage(msg) {
184
+ if (!msg || msg.type !== "subscribe_terminal") return false;
185
+ // Every subscribe_terminal speaker is protocol-managed from here on,
186
+ // including rejected ones (frameVersion mismatch, invalid cursor): the
187
+ // legacy broadcast stream must never interleave with this protocol.
188
+ subscribed = true;
189
+ if (msg.frameVersion !== undefined && msg.frameVersion !== TERMINAL_FRAME_VERSION) {
190
+ fail("frame_version_mismatch", { supported: TERMINAL_FRAME_VERSION, received: msg.frameVersion });
191
+ return true;
192
+ }
193
+ if (
194
+ msg.sinceSeq !== undefined &&
195
+ (!Number.isInteger(msg.sinceSeq) || msg.sinceSeq < 0)
196
+ ) {
197
+ fail("invalid_since_seq", { sinceSeq: msg.sinceSeq });
198
+ return true;
199
+ }
200
+ if (state === "capturing") return true; // snapshot already in flight; ignore duplicates
201
+ if (msg.sinceSeq === undefined) {
202
+ // .catch belt-and-braces (CR R1): the snapshot flow already funnels
203
+ // failures into fail() — which is now throw-safe via the choke point —
204
+ // but a stray rejection must never surface as an unhandledRejection
205
+ // crash (the runner treats that as fatal).
206
+ void startSnapshot().catch(() => {});
207
+ } else if (msg.sinceSeq > model.lastSeq || msg.sinceSeq < model.evictedThrough) {
208
+ // Client ahead of the runner (foreign cursor) or needs an evicted
209
+ // range: both mean "no shared baseline" — fresh snapshot, marked so
210
+ // the client discards its local buffer first.
211
+ void startSnapshot({ resnapshot: true }).catch(() => {});
212
+ } else {
213
+ // Ring invariant: (evictedThrough, lastSeq] is always contiguous, so
214
+ // sinceSeq === evictedThrough is a COMPLETE replay, not a partial tail.
215
+ startReplay(msg.sinceSeq);
216
+ }
217
+ return true;
218
+ }
219
+
220
+ /**
221
+ * Live fan-out for one chunk. Called by the runner for every chunk it
222
+ * broadcasts, in feed order. No-op while a snapshot is in flight (the
223
+ * catch-up flush owns delivery for that window).
224
+ *
225
+ * @param {number} seq
226
+ * @param {string} data
227
+ */
228
+ function onOutput(seq, data) {
229
+ if (state !== "live") return;
230
+ if (seq === lastSeq + 1) {
231
+ lastSeq = seq;
232
+ send({ type: "output", seq, data });
233
+ return;
234
+ }
235
+ // Gap: never skip silently, never fabricate. One marker, then the client
236
+ // must re-subscribe (fresh snapshot or replay from its real cursor).
237
+ state = "idle";
238
+ send({ type: "resnapshot_required", lastSeq, missing: seq });
239
+ }
240
+
241
+ return {
242
+ handleMessage,
243
+ onOutput,
244
+ /** Sticky: true once the socket sent subscribe_terminal. The runner uses
245
+ * this to keep the socket out of the legacy output broadcast. */
246
+ subscribed: () => subscribed,
247
+ /** Socket went away; the runner drops its reference. */
248
+ detach() {
249
+ state = "idle";
250
+ },
251
+ };
252
+ }
@@ -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
+ }