@agfpd/iapeer 0.2.86 → 0.2.88
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/package.json +1 -1
- package/src/shadow/render.ts +59 -0
- package/src/supervisor/client.ts +3 -1
- package/src/supervisor/daemon.ts +111 -52
package/package.json
CHANGED
package/src/shadow/render.ts
CHANGED
|
@@ -29,6 +29,65 @@ export function modelToPlainText(term: Terminal, cols: number, rows: number): st
|
|
|
29
29
|
return lines.join('\n')
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* ATTACH SNAPSHOT (scrollback fix): the FULL model buffer — scrollback (`0..baseY`) + live viewport
|
|
34
|
+
* (`baseY..baseY+rows`) — as PHYSICAL rows of plain text. This is the resolved-buffer reconstruction:
|
|
35
|
+
* the @xterm model has already flattened claude's thousands of relative-cursor in-place redraws
|
|
36
|
+
* (no DECSTBM, no alt-screen — see the daemon census) into final COMMITTED lines, so emitting these
|
|
37
|
+
* rows as `line\r\n` into a fresh terminal reproduces native scrollback with NO duplication. Replaying
|
|
38
|
+
* the child's RAW frame bytes can't do this (relative cursor-up can't reach scrolled-off lines on a
|
|
39
|
+
* linear dump → overlap), and SerializeAddon re-emits escapes that the no-scroll-region redraws turn
|
|
40
|
+
* into duplicate scrollback. Trailing blank rows are dropped so a fresh/short session emits no wall of
|
|
41
|
+
* blanks. SGR colour is a deliberate follow-up — plain text first kills the mush without colour as a
|
|
42
|
+
* second source of truth.
|
|
43
|
+
*/
|
|
44
|
+
export function modelAttachSnapshotRows(term: Terminal, cols: number, rows: number): string[] {
|
|
45
|
+
const b = term.buffer.active
|
|
46
|
+
const out: string[] = []
|
|
47
|
+
const end = b.baseY + rows
|
|
48
|
+
for (let y = 0; y < end; y++) {
|
|
49
|
+
const l = b.getLine(y)
|
|
50
|
+
out.push(l ? l.translateToString(true, 0, cols) : '')
|
|
51
|
+
}
|
|
52
|
+
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop()
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface XtermModeData {
|
|
57
|
+
applicationCursorKeysMode?: boolean
|
|
58
|
+
applicationKeypadMode?: boolean
|
|
59
|
+
bracketedPasteMode?: boolean
|
|
60
|
+
sendFocusMode?: boolean
|
|
61
|
+
mouseTrackingMode?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Terminal MODES restore prefix for the attach snapshot. A plain-text snapshot loses the private
|
|
66
|
+
* modes the child set (bracketed paste `?2004h`, application cursor keys `?1h`, focus `?1004h`,
|
|
67
|
+
* keypad, mouse) — the RAW-byte replay restored them only INCIDENTALLY because those set-sequences
|
|
68
|
+
* sat inline in the bytes. Without them, after attach paste / arrow keys can behave unlike a native
|
|
69
|
+
* run. We re-assert them explicitly from the model's tracked mode state (proposed API; guarded).
|
|
70
|
+
* Ends by showing the cursor.
|
|
71
|
+
*/
|
|
72
|
+
export function terminalModesPrefix(term: Terminal): string {
|
|
73
|
+
let out = ''
|
|
74
|
+
try {
|
|
75
|
+
const m = (term as unknown as { modes?: XtermModeData }).modes
|
|
76
|
+
if (m) {
|
|
77
|
+
if (m.applicationCursorKeysMode) out += '\x1b[?1h'
|
|
78
|
+
if (m.applicationKeypadMode) out += '\x1b='
|
|
79
|
+
if (m.bracketedPasteMode) out += '\x1b[?2004h'
|
|
80
|
+
if (m.sendFocusMode) out += '\x1b[?1004h'
|
|
81
|
+
const mouse: Record<string, string> = { x10: '?9h', vt200: '?1000h', drag: '?1002h', any: '?1003h' }
|
|
82
|
+
const seq = m.mouseTrackingMode ? mouse[m.mouseTrackingMode] : undefined
|
|
83
|
+
if (seq) out += '\x1b[' + seq
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
/* modes is proposed API — absent → no restore, snapshot still renders */
|
|
87
|
+
}
|
|
88
|
+
return out + '\x1b[?25h'
|
|
89
|
+
}
|
|
90
|
+
|
|
32
91
|
const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
|
|
33
92
|
|
|
34
93
|
/**
|
package/src/supervisor/client.ts
CHANGED
|
@@ -122,7 +122,9 @@ export async function runSupervisorClient(runDir: string, session: string): Prom
|
|
|
122
122
|
})
|
|
123
123
|
}
|
|
124
124
|
process.on('exit', () => restore()) // last-resort sync net
|
|
125
|
-
|
|
125
|
+
// NO pre-repaint attach banner: any stdout (even stderr — same tty device) printed before the daemon's
|
|
126
|
+
// snapshot lands pollutes the fresh terminal's scrollback, defeating native-history reconstruction. The
|
|
127
|
+
// detach key (Ctrl-]) is documented in CLI help / the attach verb, not emitted as a scrollback line.
|
|
126
128
|
// STAY ATTACHED. The client lives on its handles (raw stdin + the socket); EVERY terminal path
|
|
127
129
|
// (detach Ctrl-], session-end close(), SIGHUP/SIGTERM, socket error) calls process.exit ITSELF.
|
|
128
130
|
// This promise INTENTIONALLY never resolves: returning here would resolve runSupervisorClient the
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
// @xterm never loads in the central router's delivery path nor in the launch/lifecycle process.
|
|
7
7
|
//
|
|
8
8
|
// Loads @xterm/headless as the authoritative model (boot-driver reads it; capability queries answered
|
|
9
|
-
// off it). The attach catch-up
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// off it). The attach catch-up sends a SNAPSHOT of that resolved model (scrollback + viewport as
|
|
10
|
+
// committed text rows; see repaint) — NOT a replay of the child's raw frames and NOT a SerializeAddon
|
|
11
|
+
// dump, both of which corrupt scrollback for a relative-cursor, no-scroll-region TUI like claude.
|
|
12
|
+
// @xterm/headless bundles under `bun build --compile` (pure JS; the Bun-native pty has no native addon).
|
|
12
13
|
import { Terminal } from '@xterm/headless'
|
|
13
14
|
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
|
|
14
15
|
import { dirname } from 'node:path'
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
} from './protocol.ts'
|
|
25
26
|
import { attachedPath, pidPath, servePath, sockPath } from './paths.ts'
|
|
26
27
|
import { nextBootAction, type BootPredicates } from './boot.ts'
|
|
27
|
-
import { modelToPlainText } from '../shadow/render.ts'
|
|
28
|
+
import { modelAttachSnapshotRows, modelToPlainText, terminalModesPrefix } from '../shadow/render.ts'
|
|
28
29
|
import { codexAdapter } from '../launch/adapters/codex.ts'
|
|
29
30
|
import { claudeAdapter } from '../launch/adapters/claude.ts'
|
|
30
31
|
|
|
@@ -168,21 +169,18 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
168
169
|
// NOTHING on resize — the child redraws its viewport (SIGWINCH) and the terminal reflows its own
|
|
169
170
|
// scrollback, like a directly-launched claude.
|
|
170
171
|
const painted = new WeakSet<object>()
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
rawRingBytes += b.length
|
|
184
|
-
while (rawRingBytes > RAW_RING_MAX && rawRing.length > 1) rawRingBytes -= rawRing.shift()!.length
|
|
185
|
-
}
|
|
172
|
+
// INITIALIZING GATE (scrollback fix): a client is held OUT of live output-forwarding from connect until
|
|
173
|
+
// its one-time attach snapshot has been sent. Without this, live frames (claude's relative-cursor
|
|
174
|
+
// redraws) race ahead of the snapshot and corrupt the fresh terminal. The model still ingests those
|
|
175
|
+
// bytes, so the snapshot (built FROM the model) already contains them; once the snapshot is sent the
|
|
176
|
+
// client leaves this set and live forwarding resumes — no gap, no duplication.
|
|
177
|
+
const initializing = new WeakSet<object>()
|
|
178
|
+
// MODEL-WRITE PARSE BARRIER: @xterm `write` is async (parsed on an internal queue). The attach snapshot
|
|
179
|
+
// must be built only AFTER in-flight writes have parsed AND none arrived during the wait, else it reflects
|
|
180
|
+
// a stale screen / drops bytes that landed while the client was gated. `writeChain` tracks the latest
|
|
181
|
+
// write's parse-callback; `writeSeq` bumps on every write so the drain can detect new arrivals mid-await.
|
|
182
|
+
let writeChain: Promise<void> = Promise.resolve()
|
|
183
|
+
let writeSeq = 0
|
|
186
184
|
// ATTACHED-CLIENT MARKER (spawn-flip Ф0b-3 slice 3c): keep `<runDir>/<session>.attached` in sync with
|
|
187
185
|
// clients.size — present iff ≥1 operator is attached. The warm-deliver path (a DIFFERENT process)
|
|
188
186
|
// reads it to gate the hosted busy-composer hold: hold ONLY when a human is attached (their unfinished
|
|
@@ -236,12 +234,18 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
236
234
|
/* best-effort */
|
|
237
235
|
}
|
|
238
236
|
const s = buf.toString('latin1')
|
|
239
|
-
|
|
237
|
+
writeSeq++ // a new write arrived (lets the attach drain detect bytes that land mid-await)
|
|
238
|
+
writeChain = new Promise<void>(resolve => xterm.write(buf, () => resolve())) // authoritative model (+ parse barrier)
|
|
240
239
|
for (const r of capabilityResponses(s)) child.terminal.write(r) // answer capability queries
|
|
241
240
|
const fwd = Buffer.from(stripQueries(s), 'latin1') // forward to clients sans queries (daemon is authoritative)
|
|
242
241
|
if (fwd.length) {
|
|
243
|
-
|
|
244
|
-
|
|
242
|
+
// Forward live output to every ATTACHED client — but NOT to one still INITIALIZING (its
|
|
243
|
+
// snapshot hasn't been sent; these bytes are already in the model and will be in that snapshot,
|
|
244
|
+
// so forwarding now would duplicate / race ahead of the snapshot).
|
|
245
|
+
for (const c of [...clients]) {
|
|
246
|
+
if (initializing.has(c as object)) continue
|
|
247
|
+
sendClient(c as { write(b: Buffer): number }, frame(FRAME_DATA, fwd))
|
|
248
|
+
}
|
|
245
249
|
}
|
|
246
250
|
},
|
|
247
251
|
exit(_t?: unknown, exitCode?: number | null, signalCode?: number | null) {
|
|
@@ -270,26 +274,59 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
270
274
|
} as unknown as Parameters<typeof Bun.spawn>[1]) as unknown as PtyChild
|
|
271
275
|
xterm.onData(resp => child.terminal.write(resp)) // @xterm auto-answers DA1/DSR → back to child
|
|
272
276
|
|
|
273
|
-
/** Catch up a (re)attaching client to the current screen + history ONCE by
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
277
|
+
/** Catch up a (re)attaching client to the current screen + history ONCE by sending a SNAPSHOT of the
|
|
278
|
+
* resolved @xterm MODEL — scrollback + viewport as committed plain-text rows — NOT a replay of the
|
|
279
|
+
* child's raw frames. The model has already flattened claude's relative-cursor in-place redraws into
|
|
280
|
+
* final lines (no DECSTBM / alt-screen — see the census), so the rows render as native scrollback with
|
|
281
|
+
* NO duplication. Raw-frame replay can't (relative cursor-up can't reach scrolled-off lines on a linear
|
|
282
|
+
* dump → overlap) and SerializeAddon re-emits escapes that the no-scroll-region redraws turn into
|
|
283
|
+
* DUPLICATE scrollback — the two prior dead-ends. Awaits the model parse barrier first (write is async),
|
|
284
|
+
* then builds + sends the snapshot SYNCHRONOUSLY and lifts the INITIALIZING gate; with no await between
|
|
285
|
+
* build and lift, a live data() event cannot interleave (single-threaded) → no gap, no double-forward.
|
|
286
|
+
* Scrollback rows are spinner-stripped; the viewport stays verbatim. The child's SIGWINCH redraw (from
|
|
287
|
+
* applyGeometry) then settles the live viewport at the client's width. Backpressure-safe
|
|
288
|
+
* (BackpressureWriter) — a large snapshot MUST NOT drop past the socket buffer (port-dep #2). Called
|
|
289
|
+
* EXACTLY ONCE per client (its first FRAME_RESIZE). NO clears (\x1b[2J/3J): a fresh window needs none,
|
|
290
|
+
* and Apple Terminal scrolls 2J into scrollback + ignores 3J. */
|
|
291
|
+
// Await until the model has parsed ALL writes AND none arrived during the wait (stable). Awaiting a single
|
|
292
|
+
// write promise is not enough: while the client is gated (initializing, live-forward skipped) new child
|
|
293
|
+
// bytes still land in the model and bump writeSeq — loop until the sequence holds steady so the snapshot
|
|
294
|
+
// reflects every byte received so far (no stale screen, no dropped tail).
|
|
295
|
+
const awaitModelParsedStable = async (): Promise<void> => {
|
|
296
|
+
for (;;) {
|
|
297
|
+
const seq = writeSeq
|
|
298
|
+
try {
|
|
299
|
+
await writeChain
|
|
300
|
+
} catch {
|
|
301
|
+
/* a write parse-callback should not reject */
|
|
302
|
+
}
|
|
303
|
+
if (seq === writeSeq) return
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// Build + send the one-time attach SNAPSHOT of the resolved model and LIFT the initializing gate —
|
|
307
|
+
// SYNCHRONOUS (no await), so the live data() handler cannot interleave between building the snapshot and
|
|
308
|
+
// resuming live-forward (single-threaded) → no gap, no double-forward. Called only from firstAttach, which
|
|
309
|
+
// owns the full order (drain → model-resize → THIS → child-resize). It is a SNAPSHOT of the resolved @xterm
|
|
310
|
+
// model (committed scrollback + viewport rows), NOT a raw-frame replay / SerializeAddon — both corrupt
|
|
311
|
+
// scrollback for claude's relative-cursor, no-scroll-region TUI. Scrollback rows are spinner-stripped, the
|
|
312
|
+
// viewport is verbatim. NO clears (\x1b[2J/3J): a fresh window needs none; Apple Terminal mishandles them.
|
|
313
|
+
const sendSnapshot = (s: { write(b: Buffer): number }): void => {
|
|
314
|
+
const rowsArr = modelAttachSnapshotRows(xterm, cols, rows)
|
|
315
|
+
if (rowsArr.length > 0) {
|
|
316
|
+
const cleaned = stripSpinnerScrollback(rowsArr.join('\r\n'), rows) // dechrome scrollback, viewport verbatim
|
|
317
|
+
const body = '\x1b[0m' + terminalModesPrefix(xterm) + cleaned + '\r\n'
|
|
318
|
+
sendClient(s, frame(FRAME_DATA, Buffer.from(body, 'utf8'))) // UTF-8: rows carry real glyphs (❯, box chars)
|
|
319
|
+
}
|
|
320
|
+
initializing.delete(s as object) // snapshot sent → resume live forwarding for this client
|
|
289
321
|
}
|
|
290
322
|
|
|
291
|
-
// Resize the model + child pty to (c, r). Shared by client
|
|
323
|
+
// Resize the model + child pty to (c, r). Shared by LATER client resizes and the last-detach restore.
|
|
324
|
+
// NO-OP when already at (c,r): a gratuitous resize SIGWINCHes the child → claude reflows its WHOLE
|
|
325
|
+
// transcript, and when that reflow is taller than the viewport the overflow scrolls into the model's
|
|
326
|
+
// scrollback as a DUPLICATE copy (the scrollback-pollution root). One real resize = one SIGWINCH; the
|
|
327
|
+
// old (r-1)→r "nudge" doubled every SIGWINCH and is removed for the same reason.
|
|
292
328
|
const applyGeometry = (c: number, r: number): void => {
|
|
329
|
+
if (c === cols && r === rows) return // unchanged → no SIGWINCH → no reflow → no scrollback pollution
|
|
293
330
|
cols = c
|
|
294
331
|
rows = r
|
|
295
332
|
try {
|
|
@@ -298,14 +335,37 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
298
335
|
} catch {
|
|
299
336
|
/* transient */
|
|
300
337
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
338
|
+
}
|
|
339
|
+
// FIRST attach for a client. Order matters for a clean frame0 (codex review):
|
|
340
|
+
// 1. DRAIN to stability — pending child bytes were generated under the OLD pty geometry; parsing them
|
|
341
|
+
// AFTER a model resize would rewrap them wrong, and the drain also captures bytes that land while the
|
|
342
|
+
// client is gated, so they make the snapshot rather than being lost.
|
|
343
|
+
// 2. MODEL resize to the client geometry (so the snapshot wraps at the client's width).
|
|
344
|
+
// 3. SNAPSHOT — build + send + lift gate, SYNCHRONOUS (no await), so no live byte interleaves.
|
|
345
|
+
// 4. CHILD resize → SIGWINCH → claude's live reflow, forwarded AFTER the snapshot (post-gate).
|
|
346
|
+
// Resizing the child FIRST would SIGWINCH claude into a reflow whose bytes pollute frame0.
|
|
347
|
+
const firstAttach = async (s: { write(b: Buffer): number }, c: number, r: number): Promise<void> => {
|
|
348
|
+
await awaitModelParsedStable()
|
|
349
|
+
const changed = c !== cols || r !== rows
|
|
350
|
+
if (changed) {
|
|
351
|
+
try {
|
|
352
|
+
xterm.resize(c, r) // MODEL only — not the child yet
|
|
353
|
+
} catch {
|
|
354
|
+
/* transient */
|
|
355
|
+
}
|
|
356
|
+
cols = c
|
|
357
|
+
rows = r
|
|
358
|
+
}
|
|
359
|
+
sendSnapshot(s) // synchronous: build + send + lift gate (no await before the child resize below)
|
|
360
|
+
// CHILD resize only when the size actually CHANGED. A reattach at the SAME geometry must NOT SIGWINCH the
|
|
361
|
+
// child — a gratuitous SIGWINCH makes claude re-render on-screen content, which (if taller than the
|
|
362
|
+
// viewport) scrolls a DUPLICATE copy into the model. Same-size reattach = pure snapshot, zero pollution.
|
|
363
|
+
if (changed) {
|
|
364
|
+
try {
|
|
365
|
+
child.terminal.resize(c, r) // SIGWINCH the child → live reflow (post-snapshot, forwarded live)
|
|
366
|
+
} catch {
|
|
367
|
+
/* transient */
|
|
368
|
+
}
|
|
309
369
|
}
|
|
310
370
|
}
|
|
311
371
|
// Restore the serve geometry when the last client detaches, so the unattached session never sticks at
|
|
@@ -320,14 +380,12 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
320
380
|
child.terminal.write(payload) // keystrokes → child
|
|
321
381
|
} else if (type === FRAME_RESIZE) {
|
|
322
382
|
const sz = parseSize(payload)
|
|
323
|
-
applyGeometry(sz.cols, sz.rows)
|
|
324
383
|
if (!painted.has(s as object)) {
|
|
325
384
|
painted.add(s as object)
|
|
326
|
-
|
|
385
|
+
void firstAttach(s, sz.cols, sz.rows) // split-resize: model → snapshot → gate-lift → child (clean frame0)
|
|
386
|
+
} else {
|
|
387
|
+
applyGeometry(sz.cols, sz.rows) // LATER resize: one real resize, no re-snapshot
|
|
327
388
|
}
|
|
328
|
-
// LATER resizes: nothing from the supervisor. applyGeometry already SIGWINCH'd the child → it
|
|
329
|
-
// redraws its own viewport (forwarded live) and the terminal reflows its own scrollback, exactly
|
|
330
|
-
// like a directly-launched claude. Re-dumping here would stack history in a no-\x1b[3J terminal.
|
|
331
389
|
}
|
|
332
390
|
}
|
|
333
391
|
|
|
@@ -342,6 +400,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
342
400
|
open(s: { write(b: Buffer): number }) {
|
|
343
401
|
framers.set(s as object, makeFramer((t, p) => onClientFrame(s, t, p)))
|
|
344
402
|
clients.add(s)
|
|
403
|
+
initializing.add(s as object) // hold live-forward until this client's snapshot is sent (race guard)
|
|
345
404
|
syncAttached() // ≥1 client now attached → marker present
|
|
346
405
|
// NO repaint here. A viewer's runClient sends its real terminal size as the FIRST FRAME_RESIZE on
|
|
347
406
|
// connect (client.ts open → "repaint at our size"); we paint THEN, ONCE, at the correct geometry.
|