@agfpd/iapeer 0.4.37 → 0.4.39

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.4.37",
3
+ "version": "0.4.39",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -192,6 +192,8 @@ export interface CallToolDeps {
192
192
  * see transport.RouteDeps.noteLiveTopic. Injected so the daemon never imports
193
193
  * lifecycle; the production main wires makeNoteLiveTopic. */
194
194
  noteLiveTopic?: (identity: string, topic: string) => void
195
+ /** В7 — confirmed-delivery stamp → the target's idle-proxy floor (see transport.RouteDeps.noteDelivered). */
196
+ noteDelivered?: (identity: string) => void
195
197
  /** Busy-human-composer queue — see transport.createComposerDeliveryQueue. */
196
198
  composerQueue?: ComposerQueueRouteDeps
197
199
  /** H4 launchd-managed detector — see transport.RouteDeps.isLaunchdManaged (the
@@ -219,6 +221,7 @@ export async function callTool(
219
221
  wake: deps.wake,
220
222
  ephemeral: deps.ephemeral,
221
223
  noteLiveTopic: deps.noteLiveTopic,
224
+ noteDelivered: deps.noteDelivered,
222
225
  composerQueue: deps.composerQueue,
223
226
  isLaunchdManaged: deps.isLaunchdManaged,
224
227
  })
@@ -390,6 +393,9 @@ export interface StartDaemonOptions extends StorageOptions {
390
393
  * main wires the lifecycle marker write (makeNoteLiveTopic).
391
394
  */
392
395
  noteLiveTopic?: (identity: string, topic: string) => void
396
+ /** В7 — confirmed-delivery stamp → the target's idle-proxy floor (setLastDelivered). OFF by default;
397
+ * the production main wires it. */
398
+ noteDelivered?: (identity: string) => void
393
399
  /**
394
400
  * Busy-human-composer queue — async acceptance for live local TUI delivery when
395
401
  * an attached operator has non-dim text in the target composer. OFF by default
@@ -433,6 +439,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
433
439
  onDelivered: opts.onDelivered,
434
440
  ephemeral: opts.ephemeral,
435
441
  noteLiveTopic: opts.noteLiveTopic,
442
+ noteDelivered: opts.noteDelivered,
436
443
  composerQueue: opts.composerQueue,
437
444
  isLaunchdManaged: opts.isLaunchdManaged,
438
445
  })
@@ -42,6 +42,7 @@ import {
42
42
  readTopic,
43
43
  resolveWakeRuntime,
44
44
  setEphemeralArmed,
45
+ setLastDelivered,
45
46
  superviseTick,
46
47
  wakeOrSpawn,
47
48
  type LifecycleConfig,
@@ -222,6 +223,7 @@ export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.Pro
222
223
  // flip + the shadow observer. tmux targets ignore it.
223
224
  logDir: cfg.logDir,
224
225
  noteLiveTopic,
226
+ noteDelivered: identity => setLastDelivered(cfg, identity),
225
227
  onDelivered,
226
228
  notifyFailed: async (job, reason) => {
227
229
  const index = readPeersIndex({ env })
@@ -330,6 +332,9 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
330
332
  // the wake resolver's executor discriminator then compares against the topic
331
333
  // the session last WORKED ON, not the one it woke with.
332
334
  noteLiveTopic,
335
+ // В7 — a confirmed live delivery floors the target's idle proxy (setLastDelivered), so a
336
+ // just-delivered message is never reaped away before its turn record lands.
337
+ noteDelivered: identity => setLastDelivered(cfg, identity),
333
338
  // Busy-human-composer queue: fast queued ack, async drain, fail all pending
334
339
  // on daemon close/update so a queued envelope is never silently lost.
335
340
  composerQueue,
@@ -292,6 +292,34 @@ export function clearIdleReaped(cfg: LifecycleConfig, identity: string): void {
292
292
  }
293
293
  }
294
294
 
295
+ function lastDeliveredPath(cfg: LifecycleConfig, identity: string): string {
296
+ return join(cfg.stateDir, `${identity}.last-delivered`)
297
+ }
298
+
299
+ /** Stamp the moment a live delivery to this session was CONFIRMED (В7). superviseTick folds this into
300
+ * the idle proxy so a message delivered just as the session crosses idleSecs cannot be reaped out from
301
+ * under the just-delivered message before the runtime writes its turn record — the receiver's own
302
+ * process may not have appended the user-turn yet (codex socket-ack lands ok BEFORE ingest), so the
303
+ * transcript proxy alone would miss it and the reap would silently drop the message behind ok:true. */
304
+ export function setLastDelivered(cfg: LifecycleConfig, identity: string, nowMs: number = Date.now()): void {
305
+ mkdirSync(cfg.stateDir, { recursive: true, mode: 0o700 })
306
+ try {
307
+ writeFileSync(lastDeliveredPath(cfg, identity), String(nowMs), { mode: 0o600 })
308
+ } catch {
309
+ /* best-effort — the transcript proxy still governs; this only ADDS a floor, never removes one */
310
+ }
311
+ }
312
+
313
+ /** Epoch-ms of the last confirmed live delivery to this session, or 0 when none/unreadable. */
314
+ export function readLastDelivered(cfg: LifecycleConfig, identity: string): number {
315
+ try {
316
+ const n = parseInt(readFileSync(lastDeliveredPath(cfg, identity), 'utf8').trim(), 10)
317
+ return Number.isFinite(n) && n > 0 ? n : 0
318
+ } catch {
319
+ return 0
320
+ }
321
+ }
322
+
295
323
  function newEagerPath(cfg: LifecycleConfig, identity: string): string {
296
324
  return join(cfg.stateDir, `${identity}.new-eager`)
297
325
  }
@@ -1328,7 +1356,11 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1328
1356
  const ephemeral = isEphemeralPeer(s.cwd)
1329
1357
  let lt = 0
1330
1358
  try { lt = lastTurnMt(s.runtime, s.cwd) ?? 0 } catch { /* unreadable → wokeAt floor */ }
1331
- const mt = Math.max(lt, s.wokeAt)
1359
+ // В7 — fold in the last CONFIRMED live delivery. A message delivered just as the session crosses
1360
+ // idleSecs (codex acks BEFORE it writes the turn record) would otherwise be reaped out from under the
1361
+ // just-delivered message before lt catches up — a silent loss behind ok:true. lastDelivered only ever
1362
+ // RAISES the floor (a delivery is activity), so it can never keep a genuinely idle session alive.
1363
+ const mt = Math.max(lt, s.wokeAt, readLastDelivered(cfg, s.identity))
1332
1364
  const ageSecs = Math.floor((nowMs - mt) / 1000)
1333
1365
  // wake_policy:ephemeral M2 — die-after-reply: an ARMED ephemeral session (the
1334
1366
  // daemon routed its outbound reply) is reaped after a QUIET window, checked
@@ -22,6 +22,7 @@ import {
22
22
  frame,
23
23
  makeFramer,
24
24
  parseSize,
25
+ splitDanglingEscape,
25
26
  stripQueries,
26
27
  } from './protocol.ts'
27
28
  import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry } from './paths.ts'
@@ -86,6 +87,10 @@ const BOOT_DRIVE_MS = 120_000
86
87
  // survives the hold (our key did not take), the next tick re-fires, so retries stay bounded, not lost.
87
88
  const NAG_POLL_MS = 2000
88
89
  const NAG_COOLDOWN_MS = 4000
90
+ // В25 — cap the attach-snapshot stabilization wait: under saturating output the model never goes quiet,
91
+ // so without a budget firstAttach would never complete (frozen attach). On timeout we snapshot the
92
+ // current model; live-forward delivers the rest once the gate lifts.
93
+ const MODEL_STABLE_BUDGET_MS = 500
89
94
 
90
95
  // Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
91
96
  interface PtyChild {
@@ -187,7 +192,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
187
192
  // (same inode) so a normal cap never triggers a spurious reopen. Tied to output (no idle wakeups) —
188
193
  // exactly when the log matters.
189
194
  const healPaneLog = (): void => {
190
- if (!opts.paneLogPath || paneLogFd < 0) return
195
+ if (!opts.paneLogPath) return
191
196
  const now = Date.now()
192
197
  if (now - paneLogCheckedMs < PANELOG_HEAL_MS) return
193
198
  paneLogCheckedMs = now
@@ -197,7 +202,12 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
197
202
  } catch {
198
203
  ino = -1 // path gone
199
204
  }
200
- if (ino !== paneLogIno) openPaneLog()
205
+ // В19 — reopen when the fd is DEAD (initial open failed / a prior reopen failed) as well as when the
206
+ // path maps to a DIFFERENT inode (unlinked/replaced). The old `paneLogFd < 0` early-return above made
207
+ // a single failed open PERMANENT — the log stayed blind for the whole session (composer-busy detect,
208
+ // telegram typing, boot ready-gate all read this file). fd<0 with ino=-1 (path also gone) still needs
209
+ // a reopen attempt: openPaneLog recreates the file.
210
+ if (paneLogFd < 0 || ino !== paneLogIno) openPaneLog()
201
211
  }
202
212
  openPaneLog()
203
213
 
@@ -210,7 +220,11 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
210
220
  // NO E3 capability) → smeared/overlapping scroll-up history. After the catch-up the supervisor does
211
221
  // NOTHING on resize — the child redraws its viewport (SIGWINCH) and the terminal reflows its own
212
222
  // scrollback, like a directly-launched claude.
213
- const painted = new WeakSet<object>()
223
+ // В23 — a SET (not a WeakSet) so `.size` gates the .attached marker on INTERACTIVE clients only. A
224
+ // client is painted iff it sent a FRAME_RESIZE (runClient always does on connect; a deliver-client
225
+ // NEVER resizes). Using clients.size counted deliver-clients too → a delivery briefly marked "human
226
+ // attached", falsely holding a concurrent delivery in the composer queue. Kept in sync with removals.
227
+ const painted = new Set<object>()
214
228
  // INITIALIZING GATE (scrollback fix): a client is held OUT of live output-forwarding from connect until
215
229
  // its one-time attach snapshot has been sent. Without this, live frames (claude's relative-cursor
216
230
  // redraws) race ahead of the snapshot and corrupt the fresh terminal. The model still ingests those
@@ -223,6 +237,10 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
223
237
  // write's parse-callback; `writeSeq` bumps on every write so the drain can detect new arrivals mid-await.
224
238
  let writeChain: Promise<void> = Promise.resolve()
225
239
  let writeSeq = 0
240
+ // В24 — a trailing INCOMPLETE escape sequence carried from one child-output chunk to the next, so a
241
+ // capability query split across a pty read boundary is detected/stripped as a whole (client-facing only;
242
+ // the model + pane-log always get the raw bytes immediately).
243
+ let queryTail = ''
226
244
  // ATTACHED-CLIENT MARKER (spawn-flip Ф0b-3 slice 3c): keep `<runDir>/<session>.attached` in sync with
227
245
  // clients.size — present iff ≥1 operator is attached. The warm-deliver path (a DIFFERENT process)
228
246
  // reads it to gate the hosted busy-composer hold: hold ONLY when a human is attached (their unfinished
@@ -231,7 +249,9 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
231
249
  const attachedMarker = attachedPath(runDir, session)
232
250
  const syncAttached = (): void => {
233
251
  try {
234
- if (clients.size > 0) writeFileSync(attachedMarker, '')
252
+ // В23 — gate on PAINTED (interactive) clients, not raw client count: a deliver-client connection
253
+ // must never read as "a human is attached".
254
+ if (painted.size > 0) writeFileSync(attachedMarker, '')
235
255
  else if (existsSync(attachedMarker)) unlinkSync(attachedMarker)
236
256
  } catch {
237
257
  /* */
@@ -253,7 +273,11 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
253
273
  /** Backpressure-safe send to one client; drop it (and report false) if its socket is dead. */
254
274
  const sendClient = (s: { write(b: Buffer): number }, buf: Buffer): boolean => {
255
275
  const ok = writerFor(s).send(buf)
256
- if (!ok) clients.delete(s)
276
+ if (!ok) {
277
+ clients.delete(s)
278
+ painted.delete(s as object) // В23 — keep the painted set (marker gate) in sync on every drop
279
+ syncAttached()
280
+ }
257
281
  return ok
258
282
  }
259
283
 
@@ -269,19 +293,26 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
269
293
  const buf = Buffer.isBuffer(d) ? d : typeof d === 'string' ? Buffer.from(d, 'utf8') : Buffer.from(d)
270
294
  // PANE-LOG: the RAW child bytes, exactly as pipe-pane writes them (before any query-stripping
271
295
  // for clients) — written as a Buffer, never a string. The `s` below is for query DETECTION only.
272
- if (paneLogFd >= 0) {
273
- healPaneLog() // reopen if the path was unlinked/replaced out from under us (Defect 2)
274
- try {
275
- writeSync(paneLogFd, buf)
276
- } catch {
277
- /* best-effort */
296
+ if (opts.paneLogPath) {
297
+ healPaneLog() // reopen if never-opened / unlinked / replaced out from under us (Defect 2 + В19)
298
+ if (paneLogFd >= 0) {
299
+ try {
300
+ writeSync(paneLogFd, buf)
301
+ } catch {
302
+ /* best-effort */
303
+ }
278
304
  }
279
305
  }
280
- const s = buf.toString('latin1')
281
306
  writeSeq++ // a new write arrived (lets the attach drain detect bytes that land mid-await)
282
- writeChain = new Promise<void>(resolve => xterm.write(buf, () => resolve())) // authoritative model (+ parse barrier)
283
- for (const r of capabilityResponses(s)) child.terminal.write(r) // answer capability queries
284
- const fwd = Buffer.from(stripQueries(s), 'latin1') // forward to clients sans queries (daemon is authoritative)
307
+ writeChain = new Promise<void>(resolve => xterm.write(buf, () => resolve())) // authoritative model (+ parse barrier) — RAW bytes
308
+ // В24 — run capability detect/strip over the chunk PLUS any carried tail, holding back a trailing
309
+ // INCOMPLETE escape for the next chunk so a query split across a pty read boundary is matched as a
310
+ // whole. The model + pane-log above get the RAW bytes immediately; only the client-facing detect/
311
+ // strip is tail-buffered (the held bytes reach the client next chunk, once complete).
312
+ const [complete, dangling] = splitDanglingEscape(queryTail + buf.toString('latin1'))
313
+ queryTail = dangling
314
+ for (const r of capabilityResponses(complete)) child.terminal.write(r) // answer capability queries
315
+ const fwd = Buffer.from(stripQueries(complete), 'latin1') // forward to clients sans queries (daemon is authoritative)
285
316
  if (fwd.length) {
286
317
  // Forward live output to every ATTACHED client — but NOT to one still INITIALIZING (its
287
318
  // snapshot hasn't been sent; these bytes are already in the model and will be in that snapshot,
@@ -337,6 +368,12 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
337
368
  // bytes still land in the model and bump writeSeq — loop until the sequence holds steady so the snapshot
338
369
  // reflects every byte received so far (no stale screen, no dropped tail).
339
370
  const awaitModelParsedStable = async (): Promise<void> => {
371
+ // В25 — BOUND the stabilization. Under a saturating output stream (a big file dump, a verbose build,
372
+ // a tailing log) every await window sees a new write → seq never holds steady → firstAttach never
373
+ // completes, so the attaching operator sees a FROZEN blank screen while their keystrokes go to the
374
+ // child blind. Cap the wait: on timeout, snapshot the CURRENT model — bytes that land after are
375
+ // delivered by normal live-forward once the gate lifts (the model already ingests them).
376
+ const deadline = Date.now() + MODEL_STABLE_BUDGET_MS
340
377
  for (;;) {
341
378
  const seq = writeSeq
342
379
  try {
@@ -345,6 +382,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
345
382
  /* a write parse-callback should not reject */
346
383
  }
347
384
  if (seq === writeSeq) return
385
+ if (Date.now() > deadline) return // saturating output — snapshot now, live-forward catches the rest
348
386
  }
349
387
  }
350
388
  // Build + send the one-time attach SNAPSHOT (full resolved model: scrollback + screen + cursor + SGR colour
@@ -428,6 +466,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
428
466
  const sz = parseSize(payload)
429
467
  if (!painted.has(s as object)) {
430
468
  painted.add(s as object)
469
+ syncAttached() // В23 — this client is now INTERACTIVE (it resized) → the .attached marker reflects it
431
470
  void firstAttach(s, sz.cols, sz.rows) // split-resize: model → snapshot → gate-lift → child (clean frame0)
432
471
  } else {
433
472
  applyGeometry(sz.cols, sz.rows) // LATER resize: one real resize, no re-snapshot
@@ -458,16 +497,18 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
458
497
  framers.get(s)?.(d)
459
498
  },
460
499
  drain(s: object) {
461
- if (!writers.get(s)?.flush()) { clients.delete(s); syncAttached() } // writable again → flush queued tail
500
+ if (!writers.get(s)?.flush()) { clients.delete(s); painted.delete(s); syncAttached() } // writable again → flush queued tail
462
501
  },
463
502
  close(s: object) {
464
503
  clients.delete(s)
504
+ painted.delete(s)
465
505
  writers.delete(s)
466
506
  syncAttached()
467
507
  restoreServeGeometryIfIdle() // last client gone → un-stick the pty from its size
468
508
  },
469
509
  error(s: object) {
470
510
  clients.delete(s)
511
+ painted.delete(s)
471
512
  writers.delete(s)
472
513
  syncAttached()
473
514
  restoreServeGeometryIfIdle()
@@ -542,8 +583,16 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
542
583
  } catch {
543
584
  return // transient model read — retry next tick
544
585
  }
586
+ // В21 — check the deadline FIRST, independent of the action. Previously the clearInterval only
587
+ // fired in the `else` branch, so a boot-dialog predicate that kept returning 'dialog' (a resume-
588
+ // picker whose layout changed after a claude update, a screen matching the dialog pattern) drove
589
+ // keystrokes into the pty EVERY tick for the whole session — the BOOT_DRIVE_MS backstop was dead.
590
+ if (Date.now() > driveDeadline) {
591
+ clearInterval(driver)
592
+ return
593
+ }
545
594
  if (action.kind === 'dialog') child.terminal.write(action.bytes)
546
- else if (action.kind === 'ready' || Date.now() > driveDeadline) clearInterval(driver)
595
+ else if (action.kind === 'ready') clearInterval(driver)
547
596
  }, BOOT_POLL_MS)
548
597
  ;(driver as { unref?: () => void }).unref?.()
549
598
  }
@@ -70,6 +70,51 @@ export function stripQueries(s: string): string {
70
70
  return s.replace(QUERY_RE, '')
71
71
  }
72
72
 
73
+ // ── chunk-boundary query buffering (В24) ────────────────────────────────────────
74
+ // A capability query split across a pty read boundary (`\x1b[>` in one chunk, `q` in the next)
75
+ // matches NEITHER chunk → the daemon neither answers nor strips it, and the fragment LEAKS to an
76
+ // attached client whose real terminal answers it as keystrokes into the child. Before running the
77
+ // capability detect/strip on a chunk, carry a trailing INCOMPLETE escape into the next one.
78
+
79
+ const MAX_DANGLING = 16 // a real short query fits well within this — a longer trailing ESC is passed through
80
+
81
+ function isCompleteEscape(seq: string): boolean {
82
+ if (seq.length < 2) return false // a bare ESC — could begin any sequence
83
+ switch (seq[1]) {
84
+ case '[': {
85
+ // CSI — parameter/intermediate bytes then a FINAL byte 0x40–0x7e
86
+ for (let i = 2; i < seq.length; i++) {
87
+ const c = seq.charCodeAt(i)
88
+ if (c >= 0x40 && c <= 0x7e) return true
89
+ }
90
+ return false
91
+ }
92
+ case ']':
93
+ case 'P':
94
+ case 'X':
95
+ case '^':
96
+ case '_':
97
+ // OSC / DCS / SOS / PM / APC — terminated by BEL or ST (\x1b\)
98
+ return seq.includes('\x07') || seq.includes('\x1b\\')
99
+ case 'O':
100
+ return seq.length >= 3 // SS3 (ESC O <byte>)
101
+ default:
102
+ return true // any other 2-byte escape (ESC + one final char) is complete once the 2nd byte arrives
103
+ }
104
+ }
105
+
106
+ /** Split `s` into [complete, dangling]: `dangling` is a trailing incomplete escape sequence to prepend
107
+ * to the NEXT chunk before capability detect/strip. Bounded — a lone trailing ESC with no follow-up
108
+ * never stalls the forward forever (it is passed through once it exceeds MAX_DANGLING). Pure/testable. */
109
+ export function splitDanglingEscape(s: string): [string, string] {
110
+ const esc = s.lastIndexOf('\x1b')
111
+ if (esc < 0) return [s, '']
112
+ const tail = s.slice(esc)
113
+ if (tail.length > MAX_DANGLING) return [s, '']
114
+ if (isCompleteEscape(tail)) return [s, '']
115
+ return [s.slice(0, esc), tail]
116
+ }
117
+
73
118
  // ── key-name → pty bytes (boot-driver, serving slice a) ─────────────────────────
74
119
  // Translate a tmux send-keys-style key list into the raw bytes a pty expects, so the supervisor
75
120
  // boot-driver can answer startup dialogs (codex trust/update/hooks, claude trust/resume-picker) off
@@ -217,13 +217,37 @@ export interface WarmDeliverSeam {
217
217
  * via launchd KeepAlive).
218
218
  * See `deliverViaHost`.
219
219
  */
220
+ // В6 — per-target delivery serialization. deliverToHost is paste → 300ms settle → CR over the pty; two
221
+ // CONCURRENT deliveries to the SAME hosted session interleave their frames (paste2 lands inside paste1's
222
+ // settle window → CR1 submits the SPLICED "envelope1envelope2" as one turn, CR2 submits an empty
223
+ // composer; for a router target the splice breaks the mate's per-envelope framing). Chain deliveries per
224
+ // address so each paste+settle+CR (and its confirm) completes as a UNIT before the next to that address
225
+ // starts. Different addresses stay parallel. Covers the DOMINANT path — every agent send_to_peer routes
226
+ // through the SINGLE daemon process; a rare CLI in-process `iapeer send` is a separate process this chain
227
+ // does not span (fully hermetic serialization would live in the supervisor as a per-session transaction).
228
+ const deliverTails = new Map<string, Promise<unknown>>()
229
+
220
230
  export async function deliverWarm(
221
231
  target: DeliveryTarget,
222
232
  envelope: string,
223
233
  cwd?: string,
224
234
  seam: WarmDeliverSeam = {},
225
235
  ): Promise<Result<void>> {
226
- return deliverViaHost(target, envelope, cwd, seam)
236
+ const address = target.address
237
+ const prev = deliverTails.get(address) ?? Promise.resolve()
238
+ // run AFTER prev settles (success or failure) — a prior delivery's error must not stall the chain
239
+ const run = prev.then(
240
+ () => deliverViaHost(target, envelope, cwd, seam),
241
+ () => deliverViaHost(target, envelope, cwd, seam),
242
+ )
243
+ deliverTails.set(
244
+ address,
245
+ run.then(
246
+ () => {},
247
+ () => {},
248
+ ),
249
+ )
250
+ return run
227
251
  }
228
252
 
229
253
  async function deliverViaHost(
@@ -890,6 +914,10 @@ export interface RouteDeps {
890
914
  * imports lifecycle (layering — main.ts is where the two meet); call sites
891
915
  * swallow hook errors (a hook must never fail a delivered message). */
892
916
  noteLiveTopic?: (identity: string, topic: string) => void
917
+ /** В7 — stamp a CONFIRMED live delivery (target identity) so superviseTick's idle proxy floors on it
918
+ * and cannot reap the session before the just-delivered message's turn record lands. Injected (same
919
+ * layering as noteLiveTopic — lifecycle.setLastDelivered wired in main.ts). */
920
+ noteDelivered?: (identity: string) => void
893
921
  /** H4 launchd-managed detector (injected so transport never imports lifecycle —
894
922
  * main.ts wires the real `isLaunchdManaged`). A MISS on a launchd-managed target is
895
923
  * almost always a TRANSIENT restart window (router restart on connect / `iapeer
@@ -913,6 +941,16 @@ function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined)
913
941
  }
914
942
  }
915
943
 
944
+ /** Best-effort lastDelivered stamp (В7) — records a CONFIRMED live delivery so the idle-reap proxy
945
+ * cannot reap the session out from under a just-delivered message before its turn record lands. */
946
+ function noteDelivered(deps: RouteDeps, identity: string): void {
947
+ try {
948
+ deps.noteDelivered?.(identity)
949
+ } catch {
950
+ /* a post-delivery hook must never fail the delivery */
951
+ }
952
+ }
953
+
916
954
  export interface ComposerDeliveryQueueOptions {
917
955
  env?: NodeJS.ProcessEnv
918
956
  pollMs?: number
@@ -938,6 +976,8 @@ export interface ComposerDeliveryQueueOptions {
938
976
  deliver?: (target: DeliveryTarget, envelope: string, cwd?: string) => Result<void> | Promise<Result<void>>
939
977
  notifyFailed: (job: ComposerQueuedDelivery, reason: string) => void | Promise<void>
940
978
  noteLiveTopic?: (identity: string, topic: string) => void
979
+ /** В7 — stamp a confirmed queued delivery (same seam as RouteDeps.noteDelivered). */
980
+ noteDelivered?: (identity: string) => void
941
981
  onDelivered?: (caller: ResolvedCaller) => void
942
982
  }
943
983
 
@@ -1078,6 +1118,11 @@ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions):
1078
1118
  await notifyFailed(job, `queued delivery to ${job.target.personality} (${job.target.runtime}) failed: ${delivered.error.message}`)
1079
1119
  } else {
1080
1120
  noteTopic({ noteLiveTopic: opts.noteLiveTopic }, job.target.address, job.topic)
1121
+ try {
1122
+ opts.noteDelivered?.(job.target.address) // В7 — floor the idle proxy for the queued delivery too
1123
+ } catch {
1124
+ /* best-effort */
1125
+ }
1081
1126
  try {
1082
1127
  opts.onDelivered?.(job.caller)
1083
1128
  } catch {
@@ -1279,6 +1324,7 @@ export async function routeSend(
1279
1324
  const delivered = await deliverWarm(target.value, envelope, peer.cwd)
1280
1325
  if (!delivered.ok) return delivered
1281
1326
  noteTopic(deps, target.value.address, topic)
1327
+ noteDelivered(deps, target.value.address) // В7 — floor the idle proxy so this delivery isn't reaped away
1282
1328
  return ok({
1283
1329
  ok: true,
1284
1330
  delivered_to: { personality: target.value.personality, runtime: target.value.runtime },