@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.
- package/CHANGELOG.md +46 -0
- package/README.md +16 -12
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/RELEASE.md +148 -0
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
- package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +25 -0
- package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +393 -0
- package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +51 -0
- package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +58 -0
- package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +698 -0
- package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +605 -0
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +43 -0
- package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +95 -0
- package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +63 -0
- package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +57 -0
- package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +98 -0
- package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +190 -0
- package/package.json +3 -2
- package/runner/job-runner.mjs +8 -7
- package/runner/pty-runner.mjs +633 -31
- package/runner/state-coordinator.mjs +43 -17
- package/runner/state-runner.mjs +6 -5
- package/scripts/release.mjs +446 -0
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +62 -0
- package/src/core/launch-options.mjs +37 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +35 -3
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +27 -3
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +20 -0
- package/src/core/state-commands.mjs +88 -6
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +809 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-query-sequences.mjs +132 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +284 -16
- package/src/ui/dashboard.ts +61 -41
- package/src/ui/pty-attach.ts +318 -72
- 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
|
+
}
|