@agfpd/iapeer 0.2.85 → 0.2.87
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 +57 -21
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
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
// detached process, dynamic-imported from the launch path (supervisor/index.ts → daemon.ts), so
|
|
6
6
|
// @xterm never loads in the central router's delivery path nor in the launch/lifecycle process.
|
|
7
7
|
//
|
|
8
|
-
// Loads @xterm/headless
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// Loads @xterm/headless as the authoritative model (boot-driver reads it; capability queries answered
|
|
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).
|
|
11
13
|
import { Terminal } from '@xterm/headless'
|
|
12
|
-
import { SerializeAddon } from '@xterm/addon-serialize'
|
|
13
14
|
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
|
|
14
15
|
import { dirname } from 'node:path'
|
|
15
16
|
import {
|
|
@@ -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
|
|
|
@@ -131,8 +132,6 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
131
132
|
const pid = pidPath(runDir, session)
|
|
132
133
|
|
|
133
134
|
const xterm = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 5000 })
|
|
134
|
-
const ser = new SerializeAddon()
|
|
135
|
-
xterm.loadAddon(ser)
|
|
136
135
|
|
|
137
136
|
const bin = opts.bin ?? knownLauncher(runtime) ?? runtime
|
|
138
137
|
// SERVING SEAM (slice b): opts.argv (launch-composed, carries the system-prompt swap + PEER_START_ARGS)
|
|
@@ -170,6 +169,16 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
170
169
|
// NOTHING on resize — the child redraws its viewport (SIGWINCH) and the terminal reflows its own
|
|
171
170
|
// scrollback, like a directly-launched claude.
|
|
172
171
|
const painted = new WeakSet<object>()
|
|
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, else it reflects a stale screen. We chain the
|
|
180
|
+
// latest write's parse-callback; repaint awaits it before reading the buffer.
|
|
181
|
+
let writeChain: Promise<void> = Promise.resolve()
|
|
173
182
|
// ATTACHED-CLIENT MARKER (spawn-flip Ф0b-3 slice 3c): keep `<runDir>/<session>.attached` in sync with
|
|
174
183
|
// clients.size — present iff ≥1 operator is attached. The warm-deliver path (a DIFFERENT process)
|
|
175
184
|
// reads it to gate the hosted busy-composer hold: hold ONLY when a human is attached (their unfinished
|
|
@@ -223,10 +232,18 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
223
232
|
/* best-effort */
|
|
224
233
|
}
|
|
225
234
|
const s = buf.toString('latin1')
|
|
226
|
-
xterm.write(buf) // authoritative model
|
|
235
|
+
writeChain = new Promise<void>(resolve => xterm.write(buf, () => resolve())) // authoritative model (+ parse barrier)
|
|
227
236
|
for (const r of capabilityResponses(s)) child.terminal.write(r) // answer capability queries
|
|
228
237
|
const fwd = Buffer.from(stripQueries(s), 'latin1') // forward to clients sans queries (daemon is authoritative)
|
|
229
|
-
if (fwd.length)
|
|
238
|
+
if (fwd.length) {
|
|
239
|
+
// Forward live output to every ATTACHED client — but NOT to one still INITIALIZING (its
|
|
240
|
+
// snapshot hasn't been sent; these bytes are already in the model and will be in that snapshot,
|
|
241
|
+
// so forwarding now would duplicate / race ahead of the snapshot).
|
|
242
|
+
for (const c of [...clients]) {
|
|
243
|
+
if (initializing.has(c as object)) continue
|
|
244
|
+
sendClient(c as { write(b: Buffer): number }, frame(FRAME_DATA, fwd))
|
|
245
|
+
}
|
|
246
|
+
}
|
|
230
247
|
},
|
|
231
248
|
exit(_t?: unknown, exitCode?: number | null, signalCode?: number | null) {
|
|
232
249
|
// resilience-b exit-cause (spawn-flip Ф0b): record WHY the hosted child died into exits.log,
|
|
@@ -254,17 +271,35 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
254
271
|
} as unknown as Parameters<typeof Bun.spawn>[1]) as unknown as PtyChild
|
|
255
272
|
xterm.onData(resp => child.terminal.write(resp)) // @xterm auto-answers DA1/DSR → back to child
|
|
256
273
|
|
|
257
|
-
/** Catch up a (re)attaching client to the current screen + history ONCE
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
274
|
+
/** Catch up a (re)attaching client to the current screen + history ONCE by sending a SNAPSHOT of the
|
|
275
|
+
* resolved @xterm MODEL — scrollback + viewport as committed plain-text rows — NOT a replay of the
|
|
276
|
+
* child's raw frames. The model has already flattened claude's relative-cursor in-place redraws into
|
|
277
|
+
* final lines (no DECSTBM / alt-screen — see the census), so the rows render as native scrollback with
|
|
278
|
+
* NO duplication. Raw-frame replay can't (relative cursor-up can't reach scrolled-off lines on a linear
|
|
279
|
+
* dump → overlap) and SerializeAddon re-emits escapes that the no-scroll-region redraws turn into
|
|
280
|
+
* DUPLICATE scrollback — the two prior dead-ends. Awaits the model parse barrier first (write is async),
|
|
281
|
+
* then builds + sends the snapshot SYNCHRONOUSLY and lifts the INITIALIZING gate; with no await between
|
|
282
|
+
* build and lift, a live data() event cannot interleave (single-threaded) → no gap, no double-forward.
|
|
283
|
+
* Scrollback rows are spinner-stripped; the viewport stays verbatim. The child's SIGWINCH redraw (from
|
|
284
|
+
* applyGeometry) then settles the live viewport at the client's width. Backpressure-safe
|
|
285
|
+
* (BackpressureWriter) — a large snapshot MUST NOT drop past the socket buffer (port-dep #2). Called
|
|
286
|
+
* EXACTLY ONCE per client (its first FRAME_RESIZE). NO clears (\x1b[2J/3J): a fresh window needs none,
|
|
287
|
+
* and Apple Terminal scrolls 2J into scrollback + ignores 3J. */
|
|
288
|
+
const repaint = async (s: { write(b: Buffer): number }): Promise<void> => {
|
|
289
|
+
try {
|
|
290
|
+
await writeChain // ensure in-flight model writes have parsed before snapshotting
|
|
291
|
+
} catch {
|
|
292
|
+
/* a write parse-callback should not reject; snapshot from whatever has parsed */
|
|
293
|
+
}
|
|
294
|
+
// Synchronous from here — NO await — so the live data() handler cannot interleave between building the
|
|
295
|
+
// snapshot and lifting the gate (single-threaded), guaranteeing no gap and no double-forward.
|
|
296
|
+
const rowsArr = modelAttachSnapshotRows(xterm, cols, rows)
|
|
297
|
+
if (rowsArr.length > 0) {
|
|
298
|
+
const cleaned = stripSpinnerScrollback(rowsArr.join('\r\n'), rows) // dechrome scrollback, viewport verbatim
|
|
299
|
+
const body = '\x1b[0m' + terminalModesPrefix(xterm) + cleaned + '\r\n'
|
|
300
|
+
sendClient(s, frame(FRAME_DATA, Buffer.from(body, 'utf8'))) // UTF-8: rows carry real glyphs (❯, box chars)
|
|
301
|
+
}
|
|
302
|
+
initializing.delete(s as object) // snapshot sent → resume live forwarding for this client
|
|
268
303
|
}
|
|
269
304
|
|
|
270
305
|
// Resize the model + child pty to (c, r). Shared by client FRAME_RESIZE and the last-detach restore.
|
|
@@ -302,7 +337,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
302
337
|
applyGeometry(sz.cols, sz.rows)
|
|
303
338
|
if (!painted.has(s as object)) {
|
|
304
339
|
painted.add(s as object)
|
|
305
|
-
repaint(s) // FIRST resize for this client = the one-time catch-up (
|
|
340
|
+
void repaint(s) // FIRST resize for this client = the one-time catch-up (model snapshot at its real size)
|
|
306
341
|
}
|
|
307
342
|
// LATER resizes: nothing from the supervisor. applyGeometry already SIGWINCH'd the child → it
|
|
308
343
|
// redraws its own viewport (forwarded live) and the terminal reflows its own scrollback, exactly
|
|
@@ -321,6 +356,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
321
356
|
open(s: { write(b: Buffer): number }) {
|
|
322
357
|
framers.set(s as object, makeFramer((t, p) => onClientFrame(s, t, p)))
|
|
323
358
|
clients.add(s)
|
|
359
|
+
initializing.add(s as object) // hold live-forward until this client's snapshot is sent (race guard)
|
|
324
360
|
syncAttached() // ≥1 client now attached → marker present
|
|
325
361
|
// NO repaint here. A viewer's runClient sends its real terminal size as the FIRST FRAME_RESIZE on
|
|
326
362
|
// connect (client.ts open → "repaint at our size"); we paint THEN, ONCE, at the correct geometry.
|