@agfpd/iapeer 0.2.87 → 0.2.89
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 +10 -13
- package/src/supervisor/daemon.ts +71 -26
package/package.json
CHANGED
package/src/shadow/render.ts
CHANGED
|
@@ -30,22 +30,19 @@ export function modelToPlainText(term: Terminal, cols: number, rows: number): st
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* ATTACH
|
|
34
|
-
*
|
|
35
|
-
* the
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* blanks. SGR colour is a deliberate follow-up — plain text first kills the mush without colour as a
|
|
42
|
-
* second source of truth.
|
|
33
|
+
* ATTACH HISTORY rows — the model's SCROLLBACK ONLY (rows `0..baseY`, the committed history ABOVE the
|
|
34
|
+
* live viewport), as physical rows of plain text. The LIVE viewport (`baseY..`) is DELIBERATELY EXCLUDED:
|
|
35
|
+
* claude paints the current screen itself (its live render, forwarded right after attach), so including
|
|
36
|
+
* the viewport here would DOUBLE-PAINT it — the snapshot's copy collides with claude's live repaint →
|
|
37
|
+
* smeared overlap (the recurring bug). So on attach we ONLY prepend the history; claude shows the current
|
|
38
|
+
* screen. The @xterm model has already flattened claude's relative-cursor redraws into committed lines, so
|
|
39
|
+
* these rows render as native scrollback with no duplication. Trailing blank rows are dropped. SGR colour
|
|
40
|
+
* is a deliberate follow-up — plain text first.
|
|
43
41
|
*/
|
|
44
|
-
export function modelAttachSnapshotRows(term: Terminal, cols: number
|
|
42
|
+
export function modelAttachSnapshotRows(term: Terminal, cols: number): string[] {
|
|
45
43
|
const b = term.buffer.active
|
|
46
44
|
const out: string[] = []
|
|
47
|
-
|
|
48
|
-
for (let y = 0; y < end; y++) {
|
|
45
|
+
for (let y = 0; y < b.baseY; y++) {
|
|
49
46
|
const l = b.getLine(y)
|
|
50
47
|
out.push(l ? l.translateToString(true, 0, cols) : '')
|
|
51
48
|
}
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -176,9 +176,11 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
176
176
|
// client leaves this set and live forwarding resumes — no gap, no duplication.
|
|
177
177
|
const initializing = new WeakSet<object>()
|
|
178
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
|
|
180
|
-
//
|
|
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.
|
|
181
182
|
let writeChain: Promise<void> = Promise.resolve()
|
|
183
|
+
let writeSeq = 0
|
|
182
184
|
// ATTACHED-CLIENT MARKER (spawn-flip Ф0b-3 slice 3c): keep `<runDir>/<session>.attached` in sync with
|
|
183
185
|
// clients.size — present iff ≥1 operator is attached. The warm-deliver path (a DIFFERENT process)
|
|
184
186
|
// reads it to gate the hosted busy-composer hold: hold ONLY when a human is attached (their unfinished
|
|
@@ -232,6 +234,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
232
234
|
/* best-effort */
|
|
233
235
|
}
|
|
234
236
|
const s = buf.toString('latin1')
|
|
237
|
+
writeSeq++ // a new write arrived (lets the attach drain detect bytes that land mid-await)
|
|
235
238
|
writeChain = new Promise<void>(resolve => xterm.write(buf, () => resolve())) // authoritative model (+ parse barrier)
|
|
236
239
|
for (const r of capabilityResponses(s)) child.terminal.write(r) // answer capability queries
|
|
237
240
|
const fwd = Buffer.from(stripQueries(s), 'latin1') // forward to clients sans queries (daemon is authoritative)
|
|
@@ -285,25 +288,46 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
285
288
|
* (BackpressureWriter) — a large snapshot MUST NOT drop past the socket buffer (port-dep #2). Called
|
|
286
289
|
* EXACTLY ONCE per client (its first FRAME_RESIZE). NO clears (\x1b[2J/3J): a fresh window needs none,
|
|
287
290
|
* and Apple Terminal scrolls 2J into scrollback + ignores 3J. */
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
301
304
|
}
|
|
302
|
-
|
|
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 historyRows = modelAttachSnapshotRows(xterm, cols) // SCROLLBACK only — claude paints the viewport live
|
|
315
|
+
const cleaned = stripSpinnerScrollback(historyRows.join('\r\n'), 0) // strip spinner remnants from ALL of history
|
|
316
|
+
// Prepend the committed HISTORY above the screen claude is about to repaint, after a modes-restore prefix.
|
|
317
|
+
// NO viewport rows — that is claude's job (forwarded live), which avoids the snapshot↔live double-paint
|
|
318
|
+
// overlap. Always send at least the modes prefix (restores bracketed-paste / app-cursor for a fresh session).
|
|
319
|
+
const body = '\x1b[0m' + terminalModesPrefix(xterm) + (cleaned ? cleaned + '\r\n' : '')
|
|
320
|
+
sendClient(s, frame(FRAME_DATA, Buffer.from(body, 'utf8'))) // UTF-8: rows carry real glyphs (❯, box chars)
|
|
321
|
+
initializing.delete(s as object) // history sent → resume live forwarding (claude's viewport repaint)
|
|
303
322
|
}
|
|
304
323
|
|
|
305
|
-
// Resize the model + child pty to (c, r). Shared by client
|
|
324
|
+
// Resize the model + child pty to (c, r). Shared by LATER client resizes and the last-detach restore.
|
|
325
|
+
// NO-OP when already at (c,r): a gratuitous resize SIGWINCHes the child → claude reflows its WHOLE
|
|
326
|
+
// transcript, and when that reflow is taller than the viewport the overflow scrolls into the model's
|
|
327
|
+
// scrollback as a DUPLICATE copy (the scrollback-pollution root). One real resize = one SIGWINCH; the
|
|
328
|
+
// old (r-1)→r "nudge" doubled every SIGWINCH and is removed for the same reason.
|
|
306
329
|
const applyGeometry = (c: number, r: number): void => {
|
|
330
|
+
if (c === cols && r === rows) return // unchanged → no SIGWINCH → no reflow → no scrollback pollution
|
|
307
331
|
cols = c
|
|
308
332
|
rows = r
|
|
309
333
|
try {
|
|
@@ -312,14 +336,37 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
312
336
|
} catch {
|
|
313
337
|
/* transient */
|
|
314
338
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
339
|
+
}
|
|
340
|
+
// FIRST attach for a client. Order matters for a clean frame0 (codex review):
|
|
341
|
+
// 1. DRAIN to stability — pending child bytes were generated under the OLD pty geometry; parsing them
|
|
342
|
+
// AFTER a model resize would rewrap them wrong, and the drain also captures bytes that land while the
|
|
343
|
+
// client is gated, so they make the snapshot rather than being lost.
|
|
344
|
+
// 2. MODEL resize to the client geometry (so the snapshot wraps at the client's width).
|
|
345
|
+
// 3. SNAPSHOT — build + send + lift gate, SYNCHRONOUS (no await), so no live byte interleaves.
|
|
346
|
+
// 4. CHILD resize → SIGWINCH → claude's live reflow, forwarded AFTER the snapshot (post-gate).
|
|
347
|
+
// Resizing the child FIRST would SIGWINCH claude into a reflow whose bytes pollute frame0.
|
|
348
|
+
const firstAttach = async (s: { write(b: Buffer): number }, c: number, r: number): Promise<void> => {
|
|
349
|
+
await awaitModelParsedStable()
|
|
350
|
+
const changed = c !== cols || r !== rows
|
|
351
|
+
if (changed) {
|
|
352
|
+
try {
|
|
353
|
+
xterm.resize(c, r) // MODEL only — not the child yet
|
|
354
|
+
} catch {
|
|
355
|
+
/* transient */
|
|
356
|
+
}
|
|
357
|
+
cols = c
|
|
358
|
+
rows = r
|
|
359
|
+
}
|
|
360
|
+
sendSnapshot(s) // prepend the scrollback HISTORY (no viewport) + lift gate — synchronous, no interleave
|
|
361
|
+
// Force claude to repaint its CURRENT viewport BELOW the prepended history. A SIGWINCH triggers a full
|
|
362
|
+
// redraw; the (r-1)→r nudge guarantees it fires even on a SAME-size reattach (else an idle claude would
|
|
363
|
+
// leave the viewport blank until its next frame). Safe now: the snapshot carries NO viewport, so claude's
|
|
364
|
+
// repaint has nothing to collide with — the smeared-overlap class is gone at its source.
|
|
318
365
|
try {
|
|
319
366
|
child.terminal.resize(c, Math.max(1, r - 1))
|
|
320
367
|
child.terminal.resize(c, r)
|
|
321
368
|
} catch {
|
|
322
|
-
/* */
|
|
369
|
+
/* transient */
|
|
323
370
|
}
|
|
324
371
|
}
|
|
325
372
|
// Restore the serve geometry when the last client detaches, so the unattached session never sticks at
|
|
@@ -334,14 +381,12 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
334
381
|
child.terminal.write(payload) // keystrokes → child
|
|
335
382
|
} else if (type === FRAME_RESIZE) {
|
|
336
383
|
const sz = parseSize(payload)
|
|
337
|
-
applyGeometry(sz.cols, sz.rows)
|
|
338
384
|
if (!painted.has(s as object)) {
|
|
339
385
|
painted.add(s as object)
|
|
340
|
-
void
|
|
386
|
+
void firstAttach(s, sz.cols, sz.rows) // split-resize: model → snapshot → gate-lift → child (clean frame0)
|
|
387
|
+
} else {
|
|
388
|
+
applyGeometry(sz.cols, sz.rows) // LATER resize: one real resize, no re-snapshot
|
|
341
389
|
}
|
|
342
|
-
// LATER resizes: nothing from the supervisor. applyGeometry already SIGWINCH'd the child → it
|
|
343
|
-
// redraws its own viewport (forwarded live) and the terminal reflows its own scrollback, exactly
|
|
344
|
-
// like a directly-launched claude. Re-dumping here would stack history in a no-\x1b[3J terminal.
|
|
345
390
|
}
|
|
346
391
|
}
|
|
347
392
|
|