@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.
- package/CHANGELOG.md +43 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -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/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -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/package.json +3 -2
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +371 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +685 -58
- package/runner/state-coordinator.mjs +429 -0
- package/runner/state-runner.mjs +90 -15
- 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/commands/bg.ts +2 -1
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -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 +24 -0
- package/src/core/launch.mjs +15 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +48 -0
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +83 -6
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +50 -0
- package/src/core/state-commands.mjs +699 -0
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/core/types.mjs +2 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +694 -121
- package/src/ui/dashboard.ts +67 -92
- package/src/ui/pty-attach.ts +298 -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
|
+
}
|