@agfpd/iapeer 0.4.127 → 0.4.129

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.
@@ -336,7 +336,9 @@ nothing to spare). A sweep only opens files whose mtime moved since the last pas
336
336
 
337
337
  Attribution does not derive the path from `cwd`. It reads the cwd each file states **about
338
338
  itself** (claude: the `cwd` field on every line; codex: `session_meta.payload.cwd`) and matches
339
- it against the registry, case-insensitively. A file whose cwd belongs to no peer is ignored —
339
+ it against the registry, case-insensitively — the peer whose cwd is that path or its NEAREST
340
+ ancestor (a peer that `cd`-ed into a subdirectory stays itself, 18.10.2026; ancestors shallower
341
+ than `/<a>/<b>/<c>` match only exactly). A file whose cwd belongs to no peer is ignored —
340
342
  a human's own session never notifies.
341
343
 
342
344
  The reason is that one rule must serve both runtimes: the claude slug is a naming convention we
@@ -456,7 +458,11 @@ five-hour wall at 04:55; claude painted «resets 8:30am», the daemon idle-reape
456
458
  3. **Nudge.** After `resetsAt + 3 min`, if the session made no progress since the event (claude: a
457
459
  real, non-`<synthetic>` assistant line after it; codex: a `task_complete` with a message whose
458
460
  `completed_at` is after it), the core delivers ONE continuation message through the normal
459
- route — wake-on-miss resumes a reaped session (an idle reap is resume-eligible). Progress
461
+ route — wake-on-miss RESUMES a reaped session: the nudge carries an explicit continue-work
462
+ wake intent that satisfies the executor topic gate by itself (wake cause
463
+ `idle-reaped-continue`); it never outranks an owner `/new`, a soft-reload or a crash. Without
464
+ it the first live run (0.4.127) woke the peer FRESH (`idle-reaped-new-topic`) and the peer
465
+ answered «nothing to resume» — the very loss this section exists to prevent. Progress
460
466
  already made → the park is dropped without a message (no duplicate turn). A failed delivery is
461
467
  retried (5 min backoff, 3 attempts); an operator `stop` is never fought.
462
468
  4. **Sender** = the core's system identity `iapeer-system` (runtime `core`, intelligence
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.127",
3
+ "version": "0.4.129",
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",
@@ -74,7 +74,10 @@ export async function processLimitResumes(
74
74
  }
75
75
  let reason: string
76
76
  try {
77
- const r = await send(systemCaller(), { personality: p.personality, runtime: p.runtime, message: limitResumeMessage(p), topic: 'limit-resume' }, routeDeps)
77
+ // Topic = the stopped session's own thread (captured at park time), so the topic set still
78
+ // follows the work; the RESUME itself rides the explicit continue-work intent, not the topic.
79
+ const topic = p.topic || 'limit-resume'
80
+ const r = await send(systemCaller(), { personality: p.personality, runtime: p.runtime, message: limitResumeMessage(p), topic }, { ...routeDeps, wakeContinueWork: true })
78
81
  if (r.ok) {
79
82
  clearLimitPark(cfg, p.identity)
80
83
  log({ identity: p.identity, outcome: 'nudged', woke: String(r.value.woke), resets: new Date(p.resetsAtMs).toISOString() })
@@ -140,7 +140,7 @@ const DAEMON_MAIN_PATH = fileURLToPath(import.meta.url)
140
140
  export function makeWakeFn(cfg: LifecycleConfig, env: NodeJS.ProcessEnv): WakeFn {
141
141
  return (req: WakeRequest): Promise<WakeOutcome> =>
142
142
  wakeOrSpawn(
143
- { personality: req.personality, runtime: req.runtime, topic: req.topic, task: req.task },
143
+ { personality: req.personality, runtime: req.runtime, topic: req.topic, task: req.task, ...(req.continueWork ? { continueWork: true } : {}) },
144
144
  { cfg, env },
145
145
  )
146
146
  }
@@ -493,6 +493,7 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
493
493
  ...(d.file ? { file: d.file } : {}),
494
494
  ...(d.sessionId ? { sessionId: d.sessionId } : {}),
495
495
  errorType: d.errorType,
496
+ ...(readTopic(cfg, identity) ? { topic: readTopic(cfg, identity) } : {}),
496
497
  })
497
498
  if (fresh) appendLifecycleEvent(cfg.eventLogDir, { ev: 'limit-park', identity, resets: new Date(d.resetsAtMs).toISOString(), error_type: d.errorType }, { env })
498
499
  },
@@ -557,6 +557,28 @@ export function muteWatchTick(deps: MuteWatchDeps, sinceMs: number): { raised: n
557
557
  return { raised, sinceMs: startedMs }
558
558
  }
559
559
 
560
+ /**
561
+ * personality ← a session's self-stated cwd: the peer whose registered cwd is that path OR its
562
+ * NEAREST ancestor. A session records the cwd it is IN at each line, and a peer that `cd`s into a
563
+ * subdirectory of its project (observed 18.10.2026: `…/oc-test-nemo/.iapeer`) would otherwise fall
564
+ * out of attribution — no notice, no limit-park. Longest prefix wins, so a peer nested inside
565
+ * another peer's tree is attributed to itself. Keys are normalized (trailing slash off, lowercase).
566
+ */
567
+ export function peerByCwdPrefix(byCwd: Map<string, string>, cwd: string): string | undefined {
568
+ const exact = cwd.replace(/\/+$/, '').toLowerCase()
569
+ let p = exact
570
+ while (p) {
571
+ const hit = byCwd.get(p)
572
+ // An ANCESTOR hit counts only below /<a>/<b>/<c> depth: a peer whose cwd is $HOME (or shallower)
573
+ // must not swallow every session on the host — such a peer attributes by exact match only.
574
+ if (hit && (p === exact || p.split('/').length > 4)) return hit
575
+ const i = p.lastIndexOf('/')
576
+ if (i <= 0) return undefined
577
+ p = p.slice(0, i)
578
+ }
579
+ return undefined
580
+ }
581
+
560
582
  export interface StartMuteWatchOptions {
561
583
  env?: NodeJS.ProcessEnv
562
584
  intervalMs?: number
@@ -604,7 +626,7 @@ export function startMuteWatch(cfg: LifecycleConfig, board: NoticeBoard, opts: S
604
626
  now,
605
627
  onError: opts.onError,
606
628
  onLimit: opts.onLimit,
607
- peerByCwd: (cwd: string) => byCwd.get(cwd.replace(/\/+$/, '').toLowerCase()),
629
+ peerByCwd: (cwd: string) => peerByCwdPrefix(byCwd, cwd),
608
630
  opencodeJournals: () =>
609
631
  rows
610
632
  .filter(r => (r.runtimes ?? []).includes('opencode'))
@@ -653,6 +653,10 @@ export function resolveWakeMode(
653
653
  * next resume/fresh decision (a crash-looping binary used to eat .idle-reaped, and after the fix the
654
654
  * peer came back FRESH, its context lost). Absent → consume immediately (legacy/test callers). */
655
655
  defer?: (consume: () => void) => void,
656
+ /** The waking message is an explicit "continue THIS work" (the limit-resume nudge, docs/19 §9):
657
+ * in the idle-reaped executor branch it satisfies the topic gate by itself. Never outranks an
658
+ * owner's /new, a soft-reload, a folder-launch or a crash (those decide before 3b). */
659
+ continueWork?: boolean,
656
660
  ): WakeMode {
657
661
  const done = defer ?? ((f: () => void) => f())
658
662
  // 1. folder-launch → always fresh. Consume a standing .new-eager too — the mark's
@@ -725,6 +729,12 @@ export function resolveWakeMode(
725
729
  // gap is not a request to resume specific work; defaulting it to resume revived stale
726
730
  // context (e.g. a 3-day-old session). The sender opts INTO continuity by passing the topic.
727
731
  const topic = incomingTopic?.trim() ?? ''
732
+ if (continueWork) {
733
+ const r = resolveResume(cwd)
734
+ return r.ok
735
+ ? { resume: true, resumeRef: r.ref, cause: 'idle-reaped-continue' }
736
+ : { resume: false, cause: 'idle-reaped-no-resume' }
737
+ }
728
738
  if (!topic) return { resume: false, cause: 'idle-reaped-no-topic' }
729
739
  // RESUME if the incoming topic matches ANY thread this session lineage worked on — not
730
740
  // just the last. Different threads accumulate in one session; a later ping on an earlier
@@ -967,6 +977,8 @@ export interface WakeArgs {
967
977
  /** First message delivered to the woken session (the routed envelope). */
968
978
  task: string
969
979
  resume?: boolean
980
+ /** Explicit "continue THIS work" intent — see resolveWakeMode `continueWork`. */
981
+ continueWork?: boolean
970
982
  }
971
983
 
972
984
  export interface WakeResult {
@@ -1124,6 +1136,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1124
1136
  c => adapter.resolveResume(c, { personality: args.personality, env }),
1125
1137
  args.topic,
1126
1138
  f => consumeOnUp.push(f),
1139
+ args.continueWork,
1127
1140
  )
1128
1141
  const applyConsumed = (): void => {
1129
1142
  for (const f of consumeOnUp.splice(0)) {
@@ -42,6 +42,8 @@ export interface LimitPark {
42
42
  file?: string
43
43
  sessionId?: string
44
44
  errorType?: string
45
+ /** The session's most-recent topic when parked — the nudge continues that thread. */
46
+ topic?: string
45
47
  attempts?: number
46
48
  lastAttemptMs?: number
47
49
  }
@@ -1272,6 +1272,8 @@ export interface WakeRequest {
1272
1272
  topic?: string
1273
1273
  /** First message delivered to the woken session (the routed envelope). */
1274
1274
  task: string
1275
+ /** Explicit "continue THIS work" intent (core-originated limit-resume only). */
1276
+ continueWork?: boolean
1275
1277
  }
1276
1278
  export interface WakeOutcome {
1277
1279
  status: 'READY' | 'FAILED'
@@ -1347,6 +1349,9 @@ export interface ComposerQueueRouteDeps {
1347
1349
  }
1348
1350
 
1349
1351
  export interface RouteDeps {
1352
+ /** A wake triggered by THIS send carries the "continue THIS work" intent (resume an idle-reaped
1353
+ * session regardless of topic). Set only by the core's limit-resume pass — never from a request. */
1354
+ wakeContinueWork?: boolean
1350
1355
  /** Б7 — injected env sandboxes ALL of routeSend's env-resolved reads (the registry, the supervisor
1351
1356
  * run-dir liveness/delivery). Default process.env (prod: env === process.env). Closes the daemon:286
1352
1357
  * isolation invariant — a library/test daemon with an injected root no longer reads the real registry
@@ -1970,7 +1975,7 @@ async function routeSendInner(
1970
1975
  `(restart window outlasted the grace?) — message NOT delivered; retry`,
1971
1976
  )
1972
1977
  }
1973
- const woke = await deps.wake({ personality, runtime, topic, task: envelope })
1978
+ const woke = await deps.wake({ personality, runtime, topic, task: envelope, ...(deps.wakeContinueWork ? { continueWork: true } : {}) })
1974
1979
  if (woke.status === 'FAILED') {
1975
1980
  // C1 — a durably STOPPED peer is a deliberate halt, not a transient miss:
1976
1981
  // surface the explicit "stopped" reason (no "offline and wake failed" wrapping).