@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.85",
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
@@ -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 + @xterm/addon-serialize (the latter is the repaint source: a reattaching
9
- // client gets the full current screen even if the child is silent). Both bundle under
10
- // `bun build --compile` (pure JS; the Bun-native pty has no native addon).
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) for (const c of [...clients]) sendClient(c as { write(b: Buffer): number }, frame(FRAME_DATA, fwd))
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: clear+home, then the full
258
- * grid from the headless snapshot. Backpressure-safe — a ~90 KB heavy snapshot MUST NOT drop past the
259
- * socket buffer (port-dep #2). Called EXACTLY ONCE per client (its first FRAME_RESIZE). It is NEVER
260
- * re-issued on a later resize: re-dumping stacks a second history copy in a terminal that ignores
261
- * \x1b[3J (Apple Terminal has no E3) → smeared scroll-up history (Bug B). After the first catch-up the
262
- * supervisor stops intervening: a resize just re-sizes the child (applyGeometry → SIGWINCH), and the
263
- * child redraws its own viewport while the terminal reflows its own scrollback — exactly like a
264
- * directly-launched claude. */
265
- const repaint = (s: { write(b: Buffer): number }): void => {
266
- const snap = '\x1b[2J\x1b[3J\x1b[H' + stripSpinnerScrollback(ser.serialize(), xterm.rows)
267
- sendClient(s, frame(FRAME_DATA, Buffer.from(snap, 'utf8')))
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 (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)
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.