@agfpd/iapeer 0.2.91 → 0.2.92

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.91",
3
+ "version": "0.2.92",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  purgeIdentityState,
42
42
  removeSessionState,
43
43
  setEphemeralArmed,
44
+ setFreshNext,
44
45
  setIdleReaped,
45
46
  setNewEager,
46
47
  setStopped,
@@ -292,6 +293,41 @@ export function startPeer(personality: string, runtime: string | undefined, opts
292
293
  return out
293
294
  }
294
295
 
296
+ export interface RefreshOutcome {
297
+ personality: string
298
+ runtime: Runtime
299
+ action: 'refresh-armed' | 'skipped-non-agentic'
300
+ }
301
+
302
+ /** Runtimes that consume the layered system prompt (doctrine/fragments) — the only ones a soft-reload
303
+ * affects. Routers (notifier/telegram) and absent runtimes carry no doctrine. */
304
+ const AGENTIC_RUNTIMES: ReadonlySet<string> = new Set(['claude', 'codex'])
305
+
306
+ /**
307
+ * refresh <peer> [runtime] (or --all): arm a LAZY soft-reload. Each agentic (claude/codex) runtime of the
308
+ * peer comes up FRESH on its NEXT natural wake — re-reading doctrine/fragments from disk — WITHOUT killing
309
+ * the live session and WITHOUT eager-relaunch / burst-wake. Non-agentic runtimes (notifier/telegram/absent)
310
+ * are skipped (no doctrine). H4-safe: writes a `.fresh-next` marker only — never wakes / reaps / kills, so
311
+ * it is inert for launchd-managed peers (their lifecycle is KeepAlive's, resolveWakeMode is not their path).
312
+ * The lazy counterpart to `iapeer new` (hard kill) and self-fresh/.new-eager (eager relaunch + burst wake).
313
+ */
314
+ export function refreshPeer(personality: string, runtime: string | undefined, opts: CliEnvOptions = {}): RefreshOutcome[] {
315
+ const env = opts.env ?? process.env
316
+ const cfg = loadLifecycleConfig(env)
317
+ const peer = findPeer(readPeersIndex({ env }), personality)
318
+ if (!peer) throw new Error(`peer "${personality}" is not registered`)
319
+ const out: RefreshOutcome[] = []
320
+ for (const rt of targetRuntimes(peer, runtime)) {
321
+ if (!AGENTIC_RUNTIMES.has(rt)) {
322
+ out.push({ personality, runtime: rt, action: 'skipped-non-agentic' })
323
+ continue
324
+ }
325
+ setFreshNext(cfg, buildProcessAddress(rt, personality))
326
+ out.push({ personality, runtime: rt, action: 'refresh-armed' })
327
+ }
328
+ return out
329
+ }
330
+
295
331
  export interface AddRuntimeOutcome {
296
332
  personality: string
297
333
  action: 'added' | 'already' | 'skipped-infra-peer' | 'failed'
@@ -923,6 +959,8 @@ const USAGE = `usage: iapeer <verb> [args]
923
959
  until \`start\`; always-on (notifier/telegram): launchctl bootout. --all = every registered peer
924
960
  start <peer> [runtime] | --all warm peer: clear the stop-flag — wakeable again; does NOT launch a session (the daemon brings
925
961
  it up on the peer's first message); always-on: launchctl bootstrap. --all = every registered peer
962
+ refresh <peer> [runtime] | --all LAZY soft-reload: agentic peer comes up FRESH (re-reads doctrine) on its NEXT natural wake — no
963
+ kill, no burst-wake; non-agentic runtimes skipped. --all = whole fleet. (eager: \`new\`/self-fresh)
926
964
  remove <peer> [--force] delete a peer's registry record (locked writer); refuses a LIVE peer unless --force
927
965
  send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>] manual IAP send (fallback)
928
966
  <runtime> launch the cwd's peer FRESH; on a TTY drop into it (like attach), else detached
@@ -1383,6 +1421,20 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1383
1421
  for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
1384
1422
  return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
1385
1423
  }
1424
+ case 'refresh': {
1425
+ // LAZY soft-reload (fleet doctrine refresh): arm `.fresh-next` so each agentic peer comes up FRESH
1426
+ // on its NEXT natural wake (re-reads doctrine/fragments) — no kill, no eager relaunch, no burst-wake.
1427
+ // --all marks the whole registered fleet. H4-safe (marker only); non-agentic runtimes are skipped.
1428
+ const peers = flags.all === true
1429
+ ? readPeersIndex({ env }).peers.map(p => p.personality)
1430
+ : positionals[0]
1431
+ ? [positionals[0]]
1432
+ : null
1433
+ if (!peers) return usage(errOut)
1434
+ const outcomes = peers.flatMap(p => refreshPeer(p, flags.all === true ? undefined : positionals[1], { env }))
1435
+ for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}\n`)
1436
+ return 0
1437
+ }
1386
1438
  case 'remove': {
1387
1439
  // Reap a registry record through the locked writer (the operator path over
1388
1440
  // registry.removePeer). Idempotent on an absent peer (exit 0). Refuses a LIVE
@@ -292,6 +292,32 @@ export function clearNewEager(cfg: LifecycleConfig, identity: string): void {
292
292
  }
293
293
  }
294
294
 
295
+ function freshNextPath(cfg: LifecycleConfig, identity: string): string {
296
+ return join(cfg.stateDir, `${identity}.fresh-next`)
297
+ }
298
+
299
+ /** True iff the identity carries a LAZY soft-reload mark (→ FRESH on its next natural wake,
300
+ * re-reading doctrine/fragments from disk). Unlike .new-eager this is NOT eager — no relaunch,
301
+ * no burst-wake; it only changes the resume decision when the peer next wakes on its own. */
302
+ export function hasFreshNext(cfg: LifecycleConfig, identity: string): boolean {
303
+ return existsSync(freshNextPath(cfg, identity))
304
+ }
305
+
306
+ /** Set the lazy soft-reload mark (the `iapeer refresh` verb does this — no kill, no relaunch). */
307
+ export function setFreshNext(cfg: LifecycleConfig, identity: string): void {
308
+ mkdirSync(cfg.stateDir, { recursive: true, mode: 0o700 })
309
+ writeFileSync(freshNextPath(cfg, identity), `${new Date().toISOString()}\n`, { mode: 0o600 })
310
+ }
311
+
312
+ /** Consume the lazy soft-reload mark (resolveWakeMode does this on the fresh decision). */
313
+ export function clearFreshNext(cfg: LifecycleConfig, identity: string): void {
314
+ try {
315
+ rmSync(freshNextPath(cfg, identity), { force: true })
316
+ } catch {
317
+ /* already gone */
318
+ }
319
+ }
320
+
295
321
  function ephemeralArmedPath(cfg: LifecycleConfig, identity: string): string {
296
322
  return join(cfg.stateDir, `${identity}.ephemeral-armed`)
297
323
  }
@@ -564,6 +590,18 @@ export function resolveWakeMode(
564
590
  if (!r.ok) return { resume: false, failReason: r.reason ?? 'resume requested but nothing to resume', cause: 'attach-nothing-to-resume' }
565
591
  return { resume: true, resumeRef: r.ref, cause: 'attach' }
566
592
  }
593
+ // 3-soft-reload (LAZY fleet refresh, `iapeer refresh`): a `.fresh-next` mark means "come up FRESH on the
594
+ // next natural wake" — re-read doctrine/fragments from disk WITHOUT killing the live session or burst-
595
+ // waking the fleet. Checked at the TOP of branch 3, BEFORE the type/death-cause logic, so it overrides
596
+ // resume for ALL peer types — including telegram-fronted (human-conversational) ones, which otherwise
597
+ // RESUME and chew stale doctrine session-after-session until an explicit /new. Consume-on-fire: clear
598
+ // .fresh-next AND .idle-reaped (mirror the ephemeral hygiene below — else a stale .idle-reaped would make
599
+ // a later CRASH read as a clean park in 3b → RESUME a killed context, exactly the 3a hazard).
600
+ if (hasFreshNext(cfg, identity)) {
601
+ clearFreshNext(cfg, identity)
602
+ clearIdleReaped(cfg, identity)
603
+ return { resume: false, cause: 'soft-reload' }
604
+ }
567
605
  // 3. default (a message woke a dead/asleep peer): decide by the death cause.
568
606
  // 3-ephemeral (M1): a stateless worker ALWAYS wakes fresh on delivery — never resume,
569
607
  // regardless of death cause or topic. Its clean-window-per-task is the whole point.