@agfpd/iapeer 0.2.86 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.86",
3
+ "version": "0.2.87",
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,16 @@ 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, 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()
186
182
  // ATTACHED-CLIENT MARKER (spawn-flip Ф0b-3 slice 3c): keep `<runDir>/<session>.attached` in sync with
187
183
  // clients.size — present iff ≥1 operator is attached. The warm-deliver path (a DIFFERENT process)
188
184
  // reads it to gate the hosted busy-composer hold: hold ONLY when a human is attached (their unfinished
@@ -236,12 +232,17 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
236
232
  /* best-effort */
237
233
  }
238
234
  const s = buf.toString('latin1')
239
- xterm.write(buf) // authoritative model
235
+ writeChain = new Promise<void>(resolve => xterm.write(buf, () => resolve())) // authoritative model (+ parse barrier)
240
236
  for (const r of capabilityResponses(s)) child.terminal.write(r) // answer capability queries
241
237
  const fwd = Buffer.from(stripQueries(s), 'latin1') // forward to clients sans queries (daemon is authoritative)
242
238
  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))
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
+ }
245
246
  }
246
247
  },
247
248
  exit(_t?: unknown, exitCode?: number | null, signalCode?: number | null) {
@@ -270,22 +271,35 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
270
271
  } as unknown as Parameters<typeof Bun.spawn>[1]) as unknown as PtyChild
271
272
  xterm.onData(resp => child.terminal.write(resp)) // @xterm auto-answers DA1/DSR → back to child
272
273
 
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])))
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
289
303
  }
290
304
 
291
305
  // Resize the model + child pty to (c, r). Shared by client FRAME_RESIZE and the last-detach restore.
@@ -323,7 +337,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
323
337
  applyGeometry(sz.cols, sz.rows)
324
338
  if (!painted.has(s as object)) {
325
339
  painted.add(s as object)
326
- repaint(s) // FIRST resize for this client = the one-time catch-up (full snapshot at its real size)
340
+ void repaint(s) // FIRST resize for this client = the one-time catch-up (model snapshot at its real size)
327
341
  }
328
342
  // LATER resizes: nothing from the supervisor. applyGeometry already SIGWINCH'd the child → it
329
343
  // redraws its own viewport (forwarded live) and the terminal reflows its own scrollback, exactly
@@ -342,6 +356,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
342
356
  open(s: { write(b: Buffer): number }) {
343
357
  framers.set(s as object, makeFramer((t, p) => onClientFrame(s, t, p)))
344
358
  clients.add(s)
359
+ initializing.add(s as object) // hold live-forward until this client's snapshot is sent (race guard)
345
360
  syncAttached() // ≥1 client now attached → marker present
346
361
  // NO repaint here. A viewer's runClient sends its real terminal size as the FIRST FRAME_RESIZE on
347
362
  // connect (client.ts open → "repaint at our size"); we paint THEN, ONCE, at the correct geometry.