@agfpd/iapeer 0.4.37 → 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.37",
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,
@@ -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
@@ -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 },