@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.87",
3
+ "version": "0.2.89",
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",
@@ -30,22 +30,19 @@ export function modelToPlainText(term: Terminal, cols: number, rows: number): st
30
30
  }
31
31
 
32
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.
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, rows: number): string[] {
42
+ export function modelAttachSnapshotRows(term: Terminal, cols: number): string[] {
45
43
  const b = term.buffer.active
46
44
  const out: string[] = []
47
- const end = b.baseY + rows
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
  }
@@ -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, else it reflects a stale screen. We chain the
180
- // latest write's parse-callback; repaint awaits it before reading the buffer.
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
- 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)
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
- initializing.delete(s as object) // snapshot sent → resume live forwarding for this client
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 FRAME_RESIZE and the last-detach restore.
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
- // Best-effort SIGWINCH nudge for a SIGWINCH-reactive child (forces a repaint at the new size); NOT
316
- // load-bearing for an attached client (the complete snapshot repaint is), but for the unattached
317
- // restore it is what makes the child redraw into the pane-log at the serve geometry.
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 repaint(s) // FIRST resize for this client = the one-time catch-up (model snapshot at its real size)
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