@agfpd/iapeer 0.2.88 → 0.2.90

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.88",
3
+ "version": "0.2.90",
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",
@@ -12,7 +12,7 @@
12
12
  // - readiness reuses paneLogViewport, whose @xterm import is dynamic and only loads flag-on.
13
13
  import { existsSync, readFileSync } from 'node:fs'
14
14
  import { deliverToHost, type DeliverResult } from '../supervisor/deliver.ts'
15
- import { attachedPath, defaultRunDir, pidPath } from '../supervisor/paths.ts'
15
+ import { attachedPath, defaultRunDir, pidPath, readGeometry } from '../supervisor/paths.ts'
16
16
  import { sessionAlive as hostDaemonAlive } from '../supervisor/client.ts'
17
17
  import { killSession as killSupervisorSession, listSessions as listSupervisorSessions } from '../supervisor/index.ts'
18
18
  import { parseSessionName } from '../core/socket.ts'
@@ -72,6 +72,15 @@ export function hasAttachedSupervisorClient(identity: string): boolean {
72
72
  return existsSync(attachedPath(hostRunDir(), identity))
73
73
  }
74
74
 
75
+ /** Current child pty geometry of a hosted session, from the supervisor's `<session>.geometry.json`
76
+ * sidecar, or HOST defaults when absent/unreadable. Warm-deliver occupancy / ready-gate readers use THIS
77
+ * rather than assuming the fixed HOST geometry, because a DETACHED session keeps its last client geometry
78
+ * (the child is no longer reverted to serve on detach — that revert caused the reattach-duplicate bug).
79
+ * Reading the actual geometry keeps the pane-log composer-occupancy model aligned with the real pty size. */
80
+ export function hostGeometry(identity: string): { cols: number; rows: number } {
81
+ return readGeometry(hostRunDir(), identity) ?? { cols: HOST_COLS, rows: HOST_ROWS }
82
+ }
83
+
75
84
  /**
76
85
  * A session-REPLACEMENT token for a hosted session (spawn-flip Ф0b-3 slice 3c): the supervisor
77
86
  * daemon's pid. A `/new` or restart spawns a NEW daemon (new pid), so a message queued against the old
@@ -29,64 +29,11 @@ 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
- }
32
+ // NOTE: the attach catch-up SNAPSHOT is built in the supervisor daemon via SerializeAddon.serialize()
33
+ // (scrollback + screen + cursor + SGR colour + private modes, all at once), NOT from this leaf. Earlier
34
+ // plain-text attempts here (modelAttachSnapshotRows / terminalModesPrefix) were removed: translateToString
35
+ // is text-only (→ monochrome) and a hand-rolled rows dump can't reproduce the grid+cursor the way the
36
+ // emulate-then-rerender multiplexers (tmux/zellij) do. See the daemon's sendSnapshot.
90
37
 
91
38
  const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
92
39
 
@@ -11,6 +11,7 @@
11
11
  // dump, both of which corrupt scrollback for a relative-cursor, no-scroll-region TUI like claude.
12
12
  // @xterm/headless bundles under `bun build --compile` (pure JS; the Bun-native pty has no native addon).
13
13
  import { Terminal } from '@xterm/headless'
14
+ import { SerializeAddon } from '@xterm/addon-serialize'
14
15
  import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
15
16
  import { dirname } from 'node:path'
16
17
  import {
@@ -23,9 +24,9 @@ import {
23
24
  parseSize,
24
25
  stripQueries,
25
26
  } from './protocol.ts'
26
- import { attachedPath, pidPath, servePath, sockPath } from './paths.ts'
27
+ import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry } from './paths.ts'
27
28
  import { nextBootAction, type BootPredicates } from './boot.ts'
28
- import { modelAttachSnapshotRows, modelToPlainText, terminalModesPrefix } from '../shadow/render.ts'
29
+ import { modelToPlainText } from '../shadow/render.ts'
29
30
  import { codexAdapter } from '../launch/adapters/codex.ts'
30
31
  import { claudeAdapter } from '../launch/adapters/claude.ts'
31
32
 
@@ -132,6 +133,12 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
132
133
  const pid = pidPath(runDir, session)
133
134
 
134
135
  const xterm = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 5000 })
136
+ // SerializeAddon turns the resolved model (scrollback + screen + cursor + SGR colour + private modes)
137
+ // into a byte string that recreates it in a fresh terminal — the attach catch-up. This is how tmux /
138
+ // screen / zellij restore state on attach (emulate the VT, re-render on attach). Color comes free here
139
+ // (translateToString was text-only → the monochrome bug); serialize() carries SGR (xterm PR #2369).
140
+ const serializeAddon = new SerializeAddon()
141
+ xterm.loadAddon(serializeAddon)
135
142
 
136
143
  const bin = opts.bin ?? knownLauncher(runtime) ?? runtime
137
144
  // SERVING SEAM (slice b): opts.argv (launch-composed, carries the system-prompt swap + PEER_START_ARGS)
@@ -303,21 +310,20 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
303
310
  if (seq === writeSeq) return
304
311
  }
305
312
  }
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
+ // Build + send the one-time attach SNAPSHOT (full resolved model: scrollback + screen + cursor + SGR colour
314
+ // + private modes) via SerializeAddon, and LIFT the initializing gate — SYNCHRONOUS (no await), so the live
315
+ // data() handler cannot interleave between serializing and resuming live-forward (single-threaded) → no gap,
316
+ // no double-forward. Called only from firstAttach, which owns the order (drain → model-resize-to-client →
317
+ // THIS → child-resize-if-changed). serialize() recreates the EXACT grid incl. cursor, so claude's live bytes
318
+ // continue seamlessly from the matching state — no separate viewport repaint, no double-paint overlap. The
319
+ // model is clean because the child is NEVER shrunk on detach (the resize-churn that polluted scrollback is
320
+ // gone), so serialize faithfully reports clean coloured history. Trailing \x1b[m guards the wrap bg-bleed
321
+ // artifact (xterm.js #3102). This is the tmux/zellij "re-render the emulated model on attach" architecture.
313
322
  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
323
+ const snap = serializeAddon.serialize({ scrollback: xterm.buffer.active.baseY }) // all scrollback + screen, with colour
324
+ const body = snap.length > 0 ? snap + '\x1b[m' : ''
325
+ if (body) sendClient(s, frame(FRAME_DATA, Buffer.from(body, 'utf8')))
326
+ initializing.delete(s as object) // snapshot sent → resume live forwarding
321
327
  }
322
328
 
323
329
  // Resize the model + child pty to (c, r). Shared by LATER client resizes and the last-detach restore.
@@ -335,44 +341,47 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
335
341
  } catch {
336
342
  /* transient */
337
343
  }
344
+ writeGeometry(runDir, session, c, r) // record current child geometry → sidecar for warm-deliver / ready-gate readers
338
345
  }
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.
346
+ // FIRST attach for a client. Order (codex review) for a clean, coloured, correctly-sized snapshot:
347
+ // 1. DRAIN to stability — pending child bytes were generated under the OLD geometry; parse them BEFORE a
348
+ // model resize (else they rewrap wrong), and capture bytes that land while the client is gated.
349
+ // 2. MODEL resize to the client geometry IF it differs — serialize() round-trips only at matching dims
350
+ // (xterm.js #3093), so the model must be the client's size before we serialize.
351
+ // 3. SNAPSHOT — serialize + send + lift gate, SYNCHRONOUS (no await), so no live byte interleaves.
352
+ // 4. CHILD resize IF it differs — an honest one-SIGWINCH window-resize (like native). NOT a nudge, NOT a
353
+ // forced repaint: serialize already recreated screen+cursor, so claude's live bytes continue from the
354
+ // matching grid. A same-size reattach touches the child ZERO times → pure snapshot, no re-render.
347
355
  const firstAttach = async (s: { write(b: Buffer): number }, c: number, r: number): Promise<void> => {
348
356
  await awaitModelParsedStable()
349
357
  const changed = c !== cols || r !== rows
350
358
  if (changed) {
351
359
  try {
352
- xterm.resize(c, r) // MODEL only — not the child yet
360
+ xterm.resize(c, r) // MODEL only (for the serialize round-trip) — child resized below, after the snapshot
353
361
  } catch {
354
362
  /* transient */
355
363
  }
356
364
  cols = c
357
365
  rows = r
358
366
  }
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.
367
+ sendSnapshot(s) // serialize the model → send → lift gate (synchronous, no interleave)
363
368
  if (changed) {
364
369
  try {
365
- child.terminal.resize(c, r) // SIGWINCH the child → live reflow (post-snapshot, forwarded live)
370
+ child.terminal.resize(c, r) // honest one-SIGWINCH resize (post-snapshot, forwarded live), like native
366
371
  } catch {
367
372
  /* transient */
368
373
  }
374
+ writeGeometry(runDir, session, c, r) // record the new current child geometry for the warm-deliver readers
369
375
  }
370
376
  }
371
- // Restore the serve geometry when the last client detaches, so the unattached session never sticks at
372
- // a departed client's size (FINDING-2 — would mismatch the fixed-geometry warm-deliver/ready-gate
373
- // viewport model). No-op when already at serve geometry or a client is still attached.
377
+ // DETACH = NO RESIZE (emulator invariant). A detached hosted TUI keeps its LAST client geometry — it is
378
+ // NEVER shrunk back to serve. The old FINDING-2 revert shrank the child into a tiny viewport, making claude
379
+ // reflow its live region and scroll DUPLICATE copies into scrollback — the root of the reattach-dup bug.
380
+ // tmux/screen likewise never collapse a detached session's size. Warm-deliver / ready-gate readers follow
381
+ // the actual geometry via the `<session>.geometry.json` sidecar instead of assuming serve. Kept as a no-op
382
+ // (callers in the close/drain/error handlers stay unchanged).
374
383
  const restoreServeGeometryIfIdle = (): void => {
375
- if (clients.size === 0 && (cols !== serveCols || rows !== serveRows)) applyGeometry(serveCols, serveRows)
384
+ /* intentional no-op: detach must NOT resize the child — see comment above */
376
385
  }
377
386
 
378
387
  const onClientFrame = (s: { write(b: Buffer): number }, type: number, payload: Buffer): void => {
@@ -429,6 +438,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
429
438
  },
430
439
  })
431
440
  writeFileSync(pid, String(process.pid))
441
+ writeGeometry(runDir, session, serveCols, serveRows) // initial geometry sidecar (= serve); updated on every real child resize
432
442
 
433
443
  let down = false
434
444
  function shutdown(): void {
@@ -450,7 +460,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
450
460
  } catch {
451
461
  /* */
452
462
  }
453
- for (const f of [sock, pid, servePath(runDir, session), attachedMarker]) {
463
+ for (const f of [sock, pid, servePath(runDir, session), attachedMarker, geometryPath(runDir, session)]) {
454
464
  try {
455
465
  unlinkSync(f)
456
466
  } catch {
@@ -1,6 +1,8 @@
1
1
  // Supervisor session paths — under a configurable run dir (NOT hardcoded). Prod resolves it to
2
2
  // ~/.iapeer/state/supervisor via the storage layer; tests pass a temp dir, so the daemon's
3
3
  // socket/pid files never touch a real location.
4
+ import { renameSync, unlinkSync, writeFileSync } from 'node:fs'
5
+ import { readFileSync } from 'node:fs'
4
6
  import { join } from 'node:path'
5
7
  import { pluginStateDir } from '../storage/index.ts'
6
8
 
@@ -21,3 +23,46 @@ export const servePath = (runDir: string, session: string): string => join(runDi
21
23
  * reads it to gate the hosted busy-composer hold — never paste over a HUMAN's unfinished input, but
22
24
  * never hold for the AI's own composer output when no operator is attached. */
23
25
  export const attachedPath = (runDir: string, session: string): string => join(runDir, `${session}.attached`)
26
+
27
+ /** Geometry sidecar (emulator-model fix): the CURRENT child pty geometry of a hosted session. The
28
+ * supervisor (the only process that resizes the child) writes it after every successful resize; the
29
+ * warm-deliver / ready-gate readers in OTHER processes read it to build their pane-log viewport model at
30
+ * the session's actual size instead of assuming the fixed HOST geometry. Critical because a detached
31
+ * session is NO LONGER reverted to serve geometry (that revert caused claude to reflow into a tiny
32
+ * viewport → duplicate scrollback) — so the child can sit at the last client's size, and readers must
33
+ * follow it. Absent / unreadable → readers fall back to HOST geometry. */
34
+ export const geometryPath = (runDir: string, session: string): string => join(runDir, `${session}.geometry.json`)
35
+
36
+ /** Atomically (temp + rename) record the current child geometry — best-effort, never throws (a hint file). */
37
+ export function writeGeometry(runDir: string, session: string, cols: number, rows: number): void {
38
+ const p = geometryPath(runDir, session)
39
+ try {
40
+ const tmp = `${p}.${process.pid}.tmp`
41
+ writeFileSync(tmp, JSON.stringify({ cols, rows }), { mode: 0o600 })
42
+ renameSync(tmp, p)
43
+ } catch {
44
+ /* best-effort: a geometry hint never blocks serving */
45
+ }
46
+ }
47
+
48
+ /** Read the current child geometry of a hosted session; null when absent / unreadable / invalid. */
49
+ export function readGeometry(runDir: string, session: string): { cols: number; rows: number } | null {
50
+ try {
51
+ const o = JSON.parse(readFileSync(geometryPath(runDir, session), 'utf8')) as { cols?: unknown; rows?: unknown }
52
+ if (typeof o.cols === 'number' && typeof o.rows === 'number' && o.cols > 0 && o.rows > 0) {
53
+ return { cols: o.cols, rows: o.rows }
54
+ }
55
+ } catch {
56
+ /* absent / malformed → caller falls back to HOST */
57
+ }
58
+ return null
59
+ }
60
+
61
+ /** Remove the geometry sidecar (shutdown cleanup); best-effort. */
62
+ export function clearGeometry(runDir: string, session: string): void {
63
+ try {
64
+ unlinkSync(geometryPath(runDir, session))
65
+ } catch {
66
+ /* */
67
+ }
68
+ }
@@ -51,12 +51,11 @@ import type { ControlCommand, DeliveryMarkers } from '../launch/types.ts'
51
51
  import {
52
52
  deliverHosted,
53
53
  hasAttachedSupervisorClient,
54
+ hostGeometry,
54
55
  hostRunDir,
55
56
  hostSessionAlive,
56
57
  hostSessionToken,
57
58
  listHostedPeers,
58
- HOST_COLS,
59
- HOST_ROWS,
60
59
  } from '../launch/ptyHost.ts'
61
60
  import { sendControlToHost, type DeliverResult } from '../supervisor/deliver.ts'
62
61
  import { keysToBytes } from '../supervisor/protocol.ts' // pure send-keys-vocab→pty-byte translation (boot-driver slice a), @xterm-free
@@ -1158,7 +1157,7 @@ async function shouldQueueForComposer(target: DeliveryTarget, logDir: string | u
1158
1157
  if (!hostSessionAlive(target.address)) return shouldQueueForHumanComposer(target)
1159
1158
  if (!isSupportedLocalRuntime(target.runtime) || !hasAttachedSupervisorClient(target.address)) return false
1160
1159
  if (!logDir) return false // no pane-log dir → cannot read occupancy → don't hold (deliver, never stall)
1161
- return paneLogComposerOccupied(`${logDir}/${target.address}.log`, HOST_COLS, HOST_ROWS, target.runtime)
1160
+ return paneLogComposerOccupied(`${logDir}/${target.address}.log`, hostGeometry(target.address).cols, hostGeometry(target.address).rows, target.runtime)
1162
1161
  }
1163
1162
 
1164
1163
  /** Drain re-check (no attached-client prereq — once queued, abandoned input waits until cleared/ceiling).
@@ -1166,7 +1165,7 @@ async function shouldQueueForComposer(target: DeliveryTarget, logDir: string | u
1166
1165
  async function composerStillBusy(target: DeliveryTarget, logDir: string | undefined): Promise<boolean> {
1167
1166
  if (!hostSessionAlive(target.address)) return targetComposerBlocksDelivery(target)
1168
1167
  if (!logDir) return false
1169
- return paneLogComposerOccupied(`${logDir}/${target.address}.log`, HOST_COLS, HOST_ROWS, target.runtime)
1168
+ return paneLogComposerOccupied(`${logDir}/${target.address}.log`, hostGeometry(target.address).cols, hostGeometry(target.address).rows, target.runtime)
1170
1169
  }
1171
1170
 
1172
1171
  /** Session-replacement token. HOSTED: supervisor daemon pid; tmux: session_id:pane_id. */