@agfpd/iapeer 0.2.70 → 0.2.72

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.70",
3
+ "version": "0.2.72",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -256,10 +256,14 @@ export const launch: LaunchFn = async (
256
256
  // read off the pane-log model (the supervisor's boot-driver answers startup dialogs internally);
257
257
  // delivery goes over the supervisor socket. UNCONDITIONAL SPAWN-FALLBACK: any host-bringup failure
258
258
  // falls through to the tmux path below — a flipped peer is NEVER worse than tmux ON SPAWN. Flag-off →
259
- // this whole block is skipped and the tmux path is byte-identical (golden). Routers are never hosted
260
- // (infra, launchd-owned). @xterm stays out of THIS process (ptyHost: @xterm-free start + deliver +
261
- // sessionAlive; readiness's @xterm import is dynamic and only loads here, flag-on).
262
- if (adapter.kind === 'tui' && ptyHostEnabled(cfg.logDir, identity)) {
259
+ // this whole block is skipped and the tmux path is byte-identical (golden). Routers (telegram/
260
+ // notifier) are ALSO hostable flag-on (cutover infra-track): launchd STILL owns their lifecycle via
261
+ // run-infra (H4) — the supervisor only provides the pty + delivery socket + pane-log; the router
262
+ // branch below skips every TUI phase (no boot dialogs — BOOT_ADAPTERS has no router entry so the
263
+ // supervisor's boot-driver is skipped; no transcript ready-gate — newestActivityMtime=null). @xterm
264
+ // stays out of THIS process (ptyHost: @xterm-free start + deliver + sessionAlive; readiness's @xterm
265
+ // import is dynamic and only loads here, flag-on — and the router branch never reaches it).
266
+ if ((adapter.kind === 'tui' || adapter.kind === 'router') && ptyHostEnabled(cfg.logDir, identity)) {
263
267
  const invocation = buildLaunchInvocation(spec, adapter, cfg)
264
268
  mkdirSync(cfg.logDir, { recursive: true, mode: 0o700 }) // the supervisor writes the pane-log here
265
269
  const paneLogPath = `${cfg.logDir}/${identity}.log`
@@ -272,6 +276,17 @@ export const launch: LaunchFn = async (
272
276
  invocation,
273
277
  })
274
278
  if (host.ok) {
279
+ // ROUTER (telegram/notifier): no startup dialogs and no transcript. The session is "up" the
280
+ // instant the supervisor child is serving — symmetric to the tmux router path, where launch
281
+ // returns READY the moment `tmux new-session` succeeds and the always-on caller (runAlwaysOn)
282
+ // does its own BOOT_RECHECK to catch an immediate child-death. Always-on routers launch with an
283
+ // EMPTY firstMessage; deliveries arrive LATER over the socket (deliverHosted → child stdin → the
284
+ // router's <iap>-marker stdin reader). So confirm the hosted session is live and return ready —
285
+ // no waitHostReady (would load @xterm for nothing), no first-message deliver, no ready-gate.
286
+ if (adapter.kind === 'router') {
287
+ if (hostSessionAlive(identity)) return ready(identity)
288
+ return fail(identity, 'hosted router session not alive immediately after bringup')
289
+ }
275
290
  // BOOT: the supervisor's boot-driver dismisses startup dialogs internally; wait for the input
276
291
  // surface via the pane-log model, then deliver over the socket + ready-gate on a model turn —
277
292
  // the SAME shape as the tmux boot/deliver/ready-gate below, with host equivalents.
@@ -23,6 +23,7 @@ import { buildProcessAddress, buildSocketPath } from '../core/socket.ts'
23
23
  import { peerLogsDir, pluginLogsDir } from '../storage/index.ts'
24
24
  import { readPeerProfile } from '../identity/index.ts'
25
25
  import { getAdapter, launch } from './index.ts'
26
+ import { hostSessionAlive, killPtyHost } from './ptyHost.ts'
26
27
  import { dismissCanary, signalCanaryClean } from './canary.ts'
27
28
  import type { LaunchConfig, LaunchSpec } from './types.ts'
28
29
 
@@ -115,6 +116,14 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
115
116
  const sock = buildSocketPath(runtime, personality, sockDir)
116
117
  const adapter = getAdapter(runtime)
117
118
 
119
+ // Host-aware liveness (cutover infra-track): a flag-on router is supervisor-HOSTED (no tmux session);
120
+ // a flag-off one is tmux. Probe BOTH backings by RUNTIME STATE (not the `.pty-host` marker), so this
121
+ // is robust to a spawn-FALLBACK — if startPtyHost failed and launch fell back to tmux, the marker is
122
+ // present yet the live session is tmux. The router is "up" iff EITHER backing is alive; on child
123
+ // death BOTH go false → block-watch exits → run-infra exits → launchd KeepAlive respawns (the SOLE
124
+ // respawn owner; the supervisor never self-respawns — H4 held).
125
+ const isAlive = (): boolean => hostSessionAlive(identity) || sessionAlive(sock, identity)
126
+
118
127
  const cfg: LaunchConfig = {
119
128
  claudeBin: env.CLAUDE_BIN ?? 'claude',
120
129
  codexBin: env.CODEX_BIN ?? 'codex',
@@ -143,7 +152,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
143
152
  const spec = buildAlwaysOnSpec(personality, runtime, cwd, sockDir)
144
153
 
145
154
  // Idempotent: bring up only when not already live.
146
- if (!sessionAlive(sock, identity)) {
155
+ if (!isAlive()) {
147
156
  const result = await launch(spec, adapter, '', cfg)
148
157
  if (result.status !== 'READY') {
149
158
  process.stderr.write(`launchdRun: launch FAILED for ${identity}: ${result.reason ?? 'unknown'}\n`)
@@ -155,7 +164,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
155
164
  // (a diagnostic, throttled by the plist) instead of a clean exit that reads as a
156
165
  // healthy run in the launchd logs.
157
166
  await sleep(BOOT_RECHECK_MS)
158
- if (!sessionAlive(sock, identity)) {
167
+ if (!isAlive()) {
159
168
  process.stderr.write(
160
169
  `launchdRun: ${identity} session died immediately after launch — check ${cfg.notifierBin ?? `${runtime}-runtime`}\n`,
161
170
  )
@@ -175,7 +184,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
175
184
  }
176
185
  process.on('SIGTERM', onSignal)
177
186
  process.on('SIGINT', onSignal)
178
- while (!stop && sessionAlive(sock, identity)) {
187
+ while (!stop && isAlive()) {
179
188
  await new Promise<void>(resolve => {
180
189
  const timer = setTimeout(resolve, WATCH_INTERVAL_MS)
181
190
  interrupt = () => {
@@ -190,7 +199,14 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
190
199
  // holding stale in-memory state (см. teardownAlwaysOnSession). A natural session
191
200
  // death (stop=false) skips this: there is nothing to tear down, exit 0 →
192
201
  // KeepAlive respawns a fresh bring-up.
193
- if (stop) teardownAlwaysOnSession(sock, identity)
202
+ // Signal-initiated stop (bootout / shutdown / kickstart -k) tears the session down WITH this watcher.
203
+ // A hosted router → kill the supervisor daemon (killPtyHost: SIGTERM → it SIGKILLs the child + cleans
204
+ // up); a tmux router → the tmux teardown. Pick by the live backing. A NATURAL death (stop=false) skips
205
+ // this: nothing to tear down, exit 0 → KeepAlive respawns a fresh bring-up.
206
+ if (stop) {
207
+ if (hostSessionAlive(identity)) killPtyHost(identity)
208
+ else teardownAlwaysOnSession(sock, identity)
209
+ }
194
210
  return 0
195
211
  }
196
212
 
@@ -174,4 +174,40 @@ d('supervisor daemon (Bun-native pty, tick runtime)', () => {
174
174
  }
175
175
  }
176
176
  })
177
+
178
+ // FINDING-2 — restore the SERVE geometry on last detach. The daemon resizes the child pty to a
179
+ // client's size on attach and (before this) never restored it, so an unattached session stuck at a
180
+ // departed client's size → the fixed-geometry warm-deliver/ready-gate viewport model mismatched the
181
+ // pty. Drive it with a child that prints its live pty winsize ("ROWS COLS") to the pane-log: attach
182
+ // small → pty shrinks; detach → pty restores to the serve geometry.
183
+ test('FINDING-2: last detach restores the serve geometry (no sticking at the client size)', async () => {
184
+ const gSession = 'ptsgeo'
185
+ const paneLog = join(runDir, 'geo-pane.log')
186
+ const lastSize = (): string => {
187
+ const t = existsSync(paneLog) ? readFileSync(paneLog, 'utf8') : ''
188
+ const m = [...t.matchAll(/(\d+) (\d+)/g)]
189
+ return m.length ? `${m[m.length - 1][1]}x${m[m.length - 1][2]}` : ''
190
+ }
191
+ try {
192
+ const r = startSupervisorDaemon({
193
+ session: gSession,
194
+ runtime: 'tick',
195
+ runDir,
196
+ // serve geometry 200x50 (distinct from defaults); child reports its live pty winsize as "rows cols"
197
+ serve: { argv: ['sh', '-c', 'while :; do stty size 2>/dev/null; sleep 0.3; done'], env: { PATH: process.env.PATH ?? '' }, cwd: runDir, paneLogPath: paneLog, cols: 200, rows: 50 },
198
+ })
199
+ expect(r.state).toBe('started')
200
+ expect(await waitFor(() => lastSize() === '50 200'.replace(' ', 'x'), 6000)).toBe(true) // at serve geometry
201
+ const c = await attach(runDir, gSession, 90, 30) // attach at a DIFFERENT size → pty resizes to it
202
+ expect(await waitFor(() => lastSize() === '30x90', 6000)).toBe(true) // pty shrank to the client size
203
+ c.close() // last client gone
204
+ expect(await waitFor(() => lastSize() === '50x200', 6000)).toBe(true) // RESTORED to serve geometry
205
+ } finally {
206
+ try {
207
+ killSession(runDir, gSession)
208
+ } catch {
209
+ /* */
210
+ }
211
+ }
212
+ })
177
213
  })
@@ -90,6 +90,13 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
90
90
  const { session, runtime, runDir } = opts
91
91
  let cols = opts.cols ?? 80
92
92
  let rows = opts.rows ?? 30
93
+ // The SERVE geometry — the size the session is hosted at (HOST_COLS×HOST_ROWS for a pty-host peer),
94
+ // immutable. `cols`/`rows` above track the CURRENT (client-driven) size. A client attaches at its own
95
+ // terminal size and the pty resizes to match; on LAST detach we restore the serve geometry (below) so
96
+ // the unattached session never sticks at a departed client's size — otherwise the warm-deliver /
97
+ // ready-gate viewport model (built at the fixed serve geometry) mismatches the actually-resized pty.
98
+ const serveCols = cols
99
+ const serveRows = rows
93
100
  const sock = sockPath(runDir, session)
94
101
  const pid = pidPath(runDir, session)
95
102
 
@@ -217,28 +224,40 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
217
224
  sendClient(s, frame(FRAME_DATA, Buffer.from(snap, 'utf8')))
218
225
  }
219
226
 
227
+ // Resize the model + child pty to (c, r). Shared by client FRAME_RESIZE and the last-detach restore.
228
+ const applyGeometry = (c: number, r: number): void => {
229
+ cols = c
230
+ rows = r
231
+ try {
232
+ xterm.resize(c, r)
233
+ child.terminal.resize(c, r)
234
+ } catch {
235
+ /* transient */
236
+ }
237
+ // Best-effort SIGWINCH nudge for a SIGWINCH-reactive child (forces a repaint at the new size); NOT
238
+ // load-bearing for an attached client (the complete snapshot repaint is), but for the unattached
239
+ // restore it is what makes the child redraw into the pane-log at the serve geometry.
240
+ try {
241
+ child.terminal.resize(c, Math.max(1, r - 1))
242
+ child.terminal.resize(c, r)
243
+ } catch {
244
+ /* */
245
+ }
246
+ }
247
+ // Restore the serve geometry when the last client detaches, so the unattached session never sticks at
248
+ // a departed client's size (FINDING-2 — would mismatch the fixed-geometry warm-deliver/ready-gate
249
+ // viewport model). No-op when already at serve geometry or a client is still attached.
250
+ const restoreServeGeometryIfIdle = (): void => {
251
+ if (clients.size === 0 && (cols !== serveCols || rows !== serveRows)) applyGeometry(serveCols, serveRows)
252
+ }
253
+
220
254
  const onClientFrame = (s: { write(b: Buffer): number }, type: number, payload: Buffer): void => {
221
255
  if (type === FRAME_DATA) {
222
256
  child.terminal.write(payload) // keystrokes → child
223
257
  } else if (type === FRAME_RESIZE) {
224
258
  const sz = parseSize(payload)
225
- cols = sz.cols
226
- rows = sz.rows
227
- try {
228
- xterm.resize(cols, rows)
229
- child.terminal.resize(cols, rows)
230
- } catch {
231
- /* transient */
232
- }
259
+ applyGeometry(sz.cols, sz.rows)
233
260
  repaint(s) // instant paint from the snapshot (delivered COMPLETE via backpressure)
234
- // Best-effort SIGWINCH nudge for a SIGWINCH-reactive child; NOT load-bearing (the complete
235
- // snapshot above is the real freshness fix).
236
- try {
237
- child.terminal.resize(cols, Math.max(1, rows - 1))
238
- child.terminal.resize(cols, rows)
239
- } catch {
240
- /* */
241
- }
242
261
  }
243
262
  }
244
263
 
@@ -266,11 +285,13 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
266
285
  clients.delete(s)
267
286
  writers.delete(s)
268
287
  syncAttached()
288
+ restoreServeGeometryIfIdle() // last client gone → un-stick the pty from its size
269
289
  },
270
290
  error(s: object) {
271
291
  clients.delete(s)
272
292
  writers.delete(s)
273
293
  syncAttached()
294
+ restoreServeGeometryIfIdle()
274
295
  },
275
296
  },
276
297
  })
@@ -100,3 +100,38 @@ describe('deliverWarm — hosted target delivers over the socket + confirms by t
100
100
  expect(mtimeProbes).toBe(0) // no cwd → the transcript proxy is never consulted
101
101
  })
102
102
  })
103
+
104
+ // Cutover infra-track — a hosted ROUTER (telegram/notifier) has NO transcript proxy
105
+ // (adapter.newestActivityMtime=null), so the mtime-advance confirm a TUI uses can never be satisfied
106
+ // and would false-FAIL every router delivery. The host path confirms a router by the socket-ack —
107
+ // PARITY with deliverViaTmux's router C-j path (delivery-level confirm; router liveness is structural
108
+ // via launchd, not a model turn). These cases pin that behavior.
109
+ const routerTarget: DeliveryTarget = {
110
+ runtime: 'notifier', // notifierAdapter.kind === 'router'
111
+ personality: 'timer',
112
+ address: 'notifier-timer',
113
+ socketPath: '/tmp/nonexistent-iap-test.sock',
114
+ }
115
+
116
+ describe('deliverWarm — hosted ROUTER confirms by socket-ack (no transcript proxy)', () => {
117
+ test('router deliver ok → ok EVEN THOUGH mtime never advances (the exact scenario a TUI fails on)', async () => {
118
+ process.env.IAP_HOST_LIVENESS_GRACE_MS = '0' // a mtime-gated path would fail at once here
119
+ const seam: WarmDeliverSeam = {
120
+ hostAlive: () => true,
121
+ deliverHosted: async () => ({ ok: true }),
122
+ newestActivityMtime: () => 100, // constant — a router writes no transcript; a TUI here → loud fail
123
+ }
124
+ const r = await deliverWarm(routerTarget, '<iap>x</iap>', '/peer/cwd', seam)
125
+ expect(r.ok).toBe(true) // socket-ack IS the confirm for a router; the non-advancing transcript is irrelevant
126
+ })
127
+
128
+ test('router socket deliver fails (dead/stalled) → loud fail, no false ok', async () => {
129
+ const seam: WarmDeliverSeam = {
130
+ hostAlive: () => true,
131
+ deliverHosted: async () => ({ ok: false, error: 'socket dead during paste' }),
132
+ }
133
+ const r = await deliverWarm(routerTarget, '<iap>x</iap>', '/peer/cwd', seam)
134
+ expect(r.ok).toBe(false)
135
+ if (!r.ok) expect(r.error.message).toContain('deliver failed')
136
+ })
137
+ })
@@ -564,6 +564,13 @@ async function deliverViaHost(
564
564
  `message NOT delivered`,
565
565
  )
566
566
  }
567
+ // Router (telegram/notifier): no transcript proxy (adapter.newestActivityMtime=null) → the
568
+ // mtime-advance confirm below can NEVER be satisfied and would false-FAIL every router delivery.
569
+ // Confirm by the socket-ack just obtained — PARITY with deliverViaTmux's router path (C-j success =
570
+ // delivery-level confirm; a router's liveness is structural via launchd KeepAlive, not a model
571
+ // turn). See deliverViaTmux router branch (paste-buffer -r + C-j, no transcript confirm) + its
572
+ // header note "router … liveness is structural".
573
+ if (adapter.kind === 'router') return ok(undefined)
567
574
  // socket-ack ≠ landed. With no activity proxy (direct callers/tests without a cwd) we can only
568
575
  // trust the flushed submit — confirmed-only, mirroring deliverViaTmux's legacy-caller path.
569
576
  if (!cwd) return ok(undefined)