@zhuxixi/pi-agent-board 0.7.0 → 0.9.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 (70) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +16 -12
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/RELEASE.md +148 -0
  6. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  7. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  8. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  9. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  10. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  11. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  12. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  13. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  14. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  15. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  16. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  17. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  18. package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +25 -0
  19. package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +393 -0
  20. package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +51 -0
  21. package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +58 -0
  22. package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +698 -0
  23. package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +605 -0
  24. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  25. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  26. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  27. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  28. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  29. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  30. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  31. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  32. package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +43 -0
  33. package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +95 -0
  34. package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +63 -0
  35. package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +57 -0
  36. package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +98 -0
  37. package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +190 -0
  38. package/package.json +3 -2
  39. package/runner/job-runner.mjs +8 -7
  40. package/runner/pty-runner.mjs +633 -31
  41. package/runner/state-coordinator.mjs +43 -17
  42. package/runner/state-runner.mjs +6 -5
  43. package/scripts/release.mjs +446 -0
  44. package/scripts/run-perf-gate.mjs +40 -0
  45. package/src/commands/agent-board.ts +8 -8
  46. package/src/commands/attach-flow.ts +5 -5
  47. package/src/core/control-protocol.mjs +482 -0
  48. package/src/core/editor-state-reporter.mjs +11 -1
  49. package/src/core/foreground-preview-cache.mjs +117 -0
  50. package/src/core/host-protocol.mjs +62 -0
  51. package/src/core/launch-options.mjs +37 -0
  52. package/src/core/locks.mjs +68 -14
  53. package/src/core/paths.mjs +35 -3
  54. package/src/core/pid.mjs +32 -1
  55. package/src/core/pty-attach-jiggle-controller.mjs +27 -3
  56. package/src/core/pty-attach-reconnect.mjs +13 -6
  57. package/src/core/pty-attach-render.mjs +20 -0
  58. package/src/core/state-commands.mjs +88 -6
  59. package/src/core/status-consistency.mjs +98 -0
  60. package/src/core/store.mjs +59 -13
  61. package/src/core/terminal-attach-client.mjs +809 -0
  62. package/src/core/terminal-attach-protocol.mjs +252 -0
  63. package/src/core/terminal-model.mjs +222 -0
  64. package/src/core/terminal-query-sequences.mjs +132 -0
  65. package/src/core/terminal-snapshot.mjs +440 -0
  66. package/src/index.ts +12 -4
  67. package/src/runtime/service.mjs +284 -16
  68. package/src/ui/dashboard.ts +61 -41
  69. package/src/ui/pty-attach.ts +318 -72
  70. 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
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Terminal query sequence extraction and color-scheme report mapping for the
3
+ * PTY attach client (issue #128).
4
+ *
5
+ * The attach client rebuilds the child screen in a headless terminal model, so
6
+ * the child's terminal capability queries (OSC 11 background color, color
7
+ * scheme change notifications) are consumed locally and never reach the real
8
+ * terminal. extractOscQuerySequences pulls exactly those query/notify forms
9
+ * out of the child output stream so the attach client can forward them to the
10
+ * real terminal; the replies travel back through the existing input
11
+ * passthrough (handleInput fallback). Setting forms (e.g. `\x1b]11;rgb:...`)
12
+ * are deliberately NOT extracted: forwarding those would repaint the local
13
+ * terminal's own colors.
14
+ *
15
+ * Kitty keyboard protocol / DA1 negotiation (`\x1b[>7u`, `\x1b[?u`, `\x1b[c`)
16
+ * is intentionally out of scope: the child negotiates once at spawn, before
17
+ * any attach client exists, and pushing kitty flags would retune the shared
18
+ * real-terminal keyboard stack under the local Pi's feet.
19
+ *
20
+ * Pure functions only — no I/O, no pi-tui import (the report format below is
21
+ * pinned by unit tests that round-trip it through pi-tui's parser).
22
+ */
23
+
24
+ // OSC 11 background-color QUERY. The `?` payload is what makes it a query;
25
+ // the SET form `\x1b]11;rgb:...` never matches this prefix (`;r` vs `;?`).
26
+ const OSC11_QUERY_PREFIX = "\x1b]11;?";
27
+ const OSC11_QUERY_BEL = OSC11_QUERY_PREFIX + "\x07";
28
+ const OSC11_QUERY_ST = OSC11_QUERY_PREFIX + "\x1b\\";
29
+
30
+ // Color-scheme change notification switch (host Pi toggles it at startup and
31
+ // when its own setting changes; the real terminal then reports scheme changes
32
+ // as `\x1b[?997;{1|2}n` reports). Fixed-form CSI, no variadic fields.
33
+ const COLOR_SCHEME_NOTIFY_ON = "\x1b[?2031h";
34
+ const COLOR_SCHEME_NOTIFY_OFF = "\x1b[?2031l";
35
+
36
+ // Carry slack aligned with the extractor family in pty-attach.ts (payload cap
37
+ // + 4096). Our longest target is 8 bytes, so 4096 is ample even for a
38
+ // malformed no-terminator tail, and slice(-N) keeps the carry bounded.
39
+ export const OSC_QUERY_CARRY_MAX_BYTES = 4096;
40
+
41
+ const TARGETS = [OSC11_QUERY_PREFIX, COLOR_SCHEME_NOTIFY_ON, COLOR_SCHEME_NOTIFY_OFF];
42
+ const MAX_TARGET_LENGTH = Math.max(...TARGETS.map((t) => t.length));
43
+
44
+ /**
45
+ * Extract forwardable terminal query / notify sequences from a child output
46
+ * chunk. Returns the sequences (in order) and the carry: the unmatched tail
47
+ * to prepend to the next chunk. The carry is either a target prefix suffix
48
+ * (sequence split across chunks) or an unterminated OSC 11 query waiting for
49
+ * its BEL/ST terminator.
50
+ */
51
+ export function extractOscQuerySequences(input) {
52
+ const sequences = [];
53
+ let scanFrom = 0;
54
+ let carryStart = -1;
55
+ while (scanFrom < input.length) {
56
+ const osc = input.indexOf(OSC11_QUERY_PREFIX, scanFrom);
57
+ const notifyOn = input.indexOf(COLOR_SCHEME_NOTIFY_ON, scanFrom);
58
+ const notifyOff = input.indexOf(COLOR_SCHEME_NOTIFY_OFF, scanFrom);
59
+ const start = firstIndex(firstIndex(osc, notifyOn), notifyOff);
60
+ if (start < 0) break;
61
+ if (start === osc) {
62
+ const after = start + OSC11_QUERY_PREFIX.length;
63
+ const bel = input.indexOf("\x07", after);
64
+ const st = input.indexOf("\x1b\\", after);
65
+ const end = firstTerminator(bel, st);
66
+ if (!end) {
67
+ carryStart = start;
68
+ break;
69
+ }
70
+ const [endIndex, terminatorLength] = end;
71
+ const seq = input.slice(start, endIndex + terminatorLength);
72
+ // Strict form: an OSC 11 query has no payload between `?` and the
73
+ // terminator. Anything else (e.g. `\x1b]11;?<junk>\x07`) is not a
74
+ // query we forward.
75
+ if (seq === OSC11_QUERY_BEL || seq === OSC11_QUERY_ST) sequences.push(seq);
76
+ scanFrom = endIndex + terminatorLength;
77
+ } else {
78
+ // Fixed-form CSI notify switch: an indexOf hit is always complete.
79
+ const seq = start === notifyOn ? COLOR_SCHEME_NOTIFY_ON : COLOR_SCHEME_NOTIFY_OFF;
80
+ sequences.push(seq);
81
+ scanFrom = start + seq.length;
82
+ }
83
+ }
84
+ const carry = carryStart >= 0
85
+ ? input.slice(carryStart).slice(-OSC_QUERY_CARRY_MAX_BYTES)
86
+ : queryPrefixSuffix(input);
87
+ return { sequences, carry };
88
+ }
89
+
90
+ /**
91
+ * Longest suffix of `input` that is a strict prefix of one of our target
92
+ * sequences (empty when none matches). Used as the carry when every target
93
+ * occurrence in this chunk was complete.
94
+ */
95
+ function queryPrefixSuffix(input) {
96
+ const max = Math.min(input.length, MAX_TARGET_LENGTH - 1);
97
+ for (let len = max; len > 0; len--) {
98
+ const suffix = input.slice(-len);
99
+ if (TARGETS.some((t) => t.startsWith(suffix))) return suffix;
100
+ }
101
+ return "";
102
+ }
103
+
104
+ function firstIndex(a, b) {
105
+ if (a < 0) return b;
106
+ if (b < 0) return a;
107
+ return Math.min(a, b);
108
+ }
109
+
110
+ function firstTerminator(bel, st) {
111
+ if (bel < 0 && st < 0) return null;
112
+ if (bel >= 0 && (st < 0 || bel < st)) return [bel, 1];
113
+ return [st, 2];
114
+ }
115
+
116
+ // Color-scheme report expected by pi-tui's parseTerminalColorSchemeReport
117
+ // (node_modules/@earendil-works/pi-tui/dist/terminal-colors.js:19):
118
+ // /^\x1b\[\?997;(1|2)n$/ with "2" => light, "1" => dark (line 57). Note the
119
+ // report code point is 997 — the QUERY the host sends is `\x1b[?996n`.
120
+ const COLOR_SCHEME_REPORTS = {
121
+ light: "\x1b[?997;2n",
122
+ dark: "\x1b[?997;1n",
123
+ };
124
+
125
+ /**
126
+ * Map a color scheme ("light" | "dark") to the report sequence the host Pi's
127
+ * pi-tui parser consumes. Returns "" for anything else; callers skip sending
128
+ * on empty.
129
+ */
130
+ export function toColorSchemeReport(scheme) {
131
+ return Object.hasOwn(COLOR_SCHEME_REPORTS, scheme) ? COLOR_SCHEME_REPORTS[scheme] : "";
132
+ }