@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.86",
3
+ "version": "0.2.88",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -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
  /**
@@ -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
- process.stdout.write(`\x1b[2m[supervisor] attached to "${session}". detach: Ctrl-] (session keeps running)\x1b[0m\r\n`)
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
@@ -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 is NOT a model serialize — it REPLAYS the child's recent raw output ring
10
- // (claude's own bytes → native scrollback, no duplication; see repaint). @xterm/headless bundles under
11
- // `bun build --compile` (pure JS; the Bun-native pty has no native addon).
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
- // RAW REPLAY RING (Bug B catch-up): the last ~RAW_RING_MAX bytes of the child's RAW output, exactly as
172
- // forwarded to live clients. The attach catch-up REPLAYS this verbatim so the client renders claude's
173
- // OWN bytes — native scrollback, NO duplication. (The previous catch-up dumped the @xterm model via
174
- // SerializeAddon, which captures claude's no-scroll-region status/tool-list redraws as DUPLICATE
175
- // scrollback frames; a directly-launched claude has no such duplication — the dup was the model's, not
176
- // claude's.) No clears in the replay: Apple Terminal scrolls \x1b[2J into scrollback and ignores
177
- // \x1b[3J, so claude's own print+\n stream is the only clean primitive.
178
- const RAW_RING_MAX = 1024 * 1024
179
- const rawRing: Buffer[] = []
180
- let rawRingBytes = 0
181
- const ringAppend = (b: Buffer): void => {
182
- rawRing.push(b)
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
- xterm.write(buf) // authoritative model
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
- ringAppend(fwd) // keep recent raw output for the attach catch-up (raw replay = native, no dup)
244
- for (const c of [...clients]) sendClient(c as { write(b: Buffer): number }, frame(FRAME_DATA, fwd))
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 REPLAYING the child's recent
274
- * RAW output (its own bytes from the ring) — so the client renders exactly what a directly-launched
275
- * claude would, with native scrollback and NO duplication. Backpressure-safe — a ~1 MB replay MUST NOT
276
- * drop past the socket buffer (port-dep #2). Called EXACTLY ONCE per client (its first FRAME_RESIZE):
277
- * re-replaying on a later resize would stack a second history copy in a terminal that ignores \x1b[3J
278
- * (Apple Terminal has no E3) → smeared scroll-up history (Bug B). After the catch-up the supervisor
279
- * stops intervening: a resize just re-sizes the child (applyGeometry → SIGWINCH), and the child redraws
280
- * its own viewport while the terminal reflows its own scrollback — exactly like a direct claude launch.
281
- * NO clears (\x1b[2J/3J): Apple Terminal scrolls 2J into scrollback and ignores 3J — claude's own
282
- * print+\n stream is the only clean primitive. A leading \x1b[0m resets any inherited SGR before the
283
- * replay; the child's SIGWINCH redraw (from applyGeometry) then settles the current screen at the
284
- * client's width. Empty ring (fresh session, no output yet) → nothing to replay; the SIGWINCH redraw
285
- * paints the first screen. */
286
- const repaint = (s: { write(b: Buffer): number }): void => {
287
- if (rawRingBytes === 0) return
288
- sendClient(s, frame(FRAME_DATA, Buffer.concat([Buffer.from('\x1b[0m', 'latin1'), ...rawRing])))
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 FRAME_RESIZE and the last-detach restore.
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
- // Best-effort SIGWINCH nudge for a SIGWINCH-reactive child (forces a repaint at the new size); NOT
302
- // load-bearing for an attached client (the complete snapshot repaint is), but for the unattached
303
- // restore it is what makes the child redraw into the pane-log at the serve geometry.
304
- try {
305
- child.terminal.resize(c, Math.max(1, r - 1))
306
- child.terminal.resize(c, r)
307
- } catch {
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
- repaint(s) // FIRST resize for this client = the one-time catch-up (full snapshot at its real size)
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.