@agfpd/iapeer 0.4.36 → 0.4.38

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.36",
3
+ "version": "0.4.38",
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,
@@ -547,6 +552,25 @@ if (import.meta.main) {
547
552
  }
548
553
  process.on('SIGTERM', shutdown)
549
554
  process.on('SIGINT', shutdown)
555
+ // В5 — a NON-signal crash (an unhandledRejection, or any uncaught error) would otherwise kill the
556
+ // daemon WITHOUT running handle.close() → failAll, so in-memory composer-queued messages (the sender
557
+ // already got queued:true) would vanish SILENTLY — the forbidden loss class. Run failAll on these
558
+ // paths too so senders are notified, then exit(1) so launchd KeepAlive respawns a clean daemon. A
559
+ // bounded timeout guarantees the exit even if close() hangs. (Hard SIGKILL/OOM cannot be caught — a
560
+ // durable on-disk composer queue would be needed for that, a deliberate follow-up.)
561
+ let crashing = false
562
+ const crashExit = (kind: string) => (errObj: unknown) => {
563
+ if (crashing) return
564
+ crashing = true
565
+ const detail = errObj instanceof Error ? (errObj.stack ?? errObj.message) : String(errObj)
566
+ process.stderr.write(`[iapeer-daemon] ${kind} — failing pending queued deliveries then exiting: ${detail}\n`)
567
+ const done = (): never => process.exit(1)
568
+ const t = setTimeout(done, 3000)
569
+ if (typeof t.unref === 'function') t.unref()
570
+ void handle.close().then(done, done)
571
+ }
572
+ process.on('uncaughtException', crashExit('uncaughtException'))
573
+ process.on('unhandledRejection', crashExit('unhandledRejection'))
550
574
  await new Promise(() => {}) // launchd KeepAlive holds this process; block forever
551
575
  }
552
576
  }
@@ -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
  }
@@ -727,6 +755,12 @@ export async function withWakeLock<T>(
727
755
  stale: 60_000,
728
756
  update: 5_000,
729
757
  retries: { retries: cfg.wakeLockRetries, factor: 1.3, minTimeout: 100, maxTimeout: 1_000 },
758
+ // В5 — a compromised wake-lock (mtime-refresh timer fired late, e.g. after system sleep >60s) MUST
759
+ // NOT rethrow from proper-lockfile's timer: unhandled it crashes the whole router and silently drops
760
+ // the in-memory composer queue. Log and continue — a controlled degrade beats killing the daemon.
761
+ onCompromised: (err: Error) => {
762
+ process.stderr.write(`[iapeer] WARN wake-lock compromised for ${identity} (continuing, not crashing): ${err.message}\n`)
763
+ },
730
764
  })
731
765
  try {
732
766
  return await fn()
@@ -1322,7 +1356,11 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1322
1356
  const ephemeral = isEphemeralPeer(s.cwd)
1323
1357
  let lt = 0
1324
1358
  try { lt = lastTurnMt(s.runtime, s.cwd) ?? 0 } catch { /* unreadable → wokeAt floor */ }
1325
- 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))
1326
1364
  const ageSecs = Math.floor((nowMs - mt) / 1000)
1327
1365
  // wake_policy:ephemeral M2 — die-after-reply: an ARMED ephemeral session (the
1328
1366
  // daemon routed its outbound reply) is reaped after a QUIET window, checked
@@ -297,6 +297,13 @@ export async function withPeersLock<T>(
297
297
  stale: 10_000,
298
298
  update: 1_000,
299
299
  retries: { retries: 13, factor: 1.4, minTimeout: 50, maxTimeout: 500 },
300
+ // В5 — a compromised lock (e.g. the mtime-refresh timer fired late after system sleep, so the lock
301
+ // read as stale) MUST NOT rethrow from proper-lockfile's timer: unhandled, it would crash the whole
302
+ // router process and silently drop the in-memory composer queue. Log loudly and let the current
303
+ // (near-done) critical section finish — a controlled degrade beats taking the daemon down.
304
+ onCompromised: (err: Error) => {
305
+ process.stderr.write(`[iapeer] WARN peers-lock compromised (continuing, not crashing): ${err.message}\n`)
306
+ },
300
307
  })
301
308
  try {
302
309
  return await fn(paths)
@@ -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
 
@@ -1025,7 +1065,17 @@ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions):
1025
1065
  function kick(identity: string): void {
1026
1066
  if (draining.has(identity)) return
1027
1067
  draining.add(identity)
1028
- void drain(identity).finally(() => draining.delete(identity))
1068
+ void drain(identity)
1069
+ .catch(() => {
1070
+ /* drain is internally best-effort; a stray throw must not surface as an unhandled rejection */
1071
+ })
1072
+ .finally(() => {
1073
+ draining.delete(identity)
1074
+ // В9 — close the enqueue-during-teardown race: a job pushed AFTER drain's empty-return but
1075
+ // BEFORE this finally cleared `draining` saw kick() no-op (draining still held), leaving it
1076
+ // undrained until some unrelated later enqueue. Re-kick if work remains so it drains promptly.
1077
+ if (queues.get(identity)?.length) kick(identity)
1078
+ })
1029
1079
  }
1030
1080
 
1031
1081
  async function drain(identity: string): Promise<void> {
@@ -1050,12 +1100,29 @@ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions):
1050
1100
  continue
1051
1101
  }
1052
1102
 
1053
- const delivered = await deliver(job.target, job.envelope, job.peer.cwd)
1103
+ // В8 — take the head OUT of the queue BEFORE committing to deliver. A concurrent failAll snapshots
1104
+ // only what remains queued, so an IN-FLIGHT job is never both delivered AND failed (the dup / false-
1105
+ // failure the exactly-once contract forbids). The head stayed queued during the composer poll-wait
1106
+ // above (still undelivered → failAll correctly fails it); it leaves the queue only at this commit.
1054
1107
  q.shift()
1108
+ let delivered: Result<void>
1109
+ try {
1110
+ delivered = await deliver(job.target, job.envelope, job.peer.cwd)
1111
+ } catch (e) {
1112
+ // deliverWarm normally returns a Result, but a defensive catch keeps drain from throwing (which,
1113
+ // with the В9 re-kick, could otherwise spin) — an unexpected throw is a delivery failure.
1114
+ await notifyFailed(job, `queued delivery to ${job.target.personality} (${job.target.runtime}) failed: ${e instanceof Error ? e.message : String(e)}`)
1115
+ continue
1116
+ }
1055
1117
  if (!delivered.ok) {
1056
1118
  await notifyFailed(job, `queued delivery to ${job.target.personality} (${job.target.runtime}) failed: ${delivered.error.message}`)
1057
1119
  } else {
1058
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
+ }
1059
1126
  try {
1060
1127
  opts.onDelivered?.(job.caller)
1061
1128
  } catch {
@@ -1257,6 +1324,7 @@ export async function routeSend(
1257
1324
  const delivered = await deliverWarm(target.value, envelope, peer.cwd)
1258
1325
  if (!delivered.ok) return delivered
1259
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
1260
1328
  return ok({
1261
1329
  ok: true,
1262
1330
  delivered_to: { personality: target.value.personality, runtime: target.value.runtime },