@agfpd/iapeer 0.4.126 → 0.4.128

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.
@@ -191,7 +191,11 @@ thread, no self-fresh, and auto-continuation genuinely resumed.
191
191
  A notice states who, which runtime, which error type, which model, and when the wall lifts.
192
192
  Two of those are **absent by design rather than guessed**:
193
193
 
194
- - **`resetsAtMs` on claude.** Claude does not say when a per-model bucket lifts. Its
194
+ - **`resetsAtMs` on claude — only when stated.** An ACCOUNT-level wall (five-hour / weekly) IS
195
+ stated structurally on the refusal line (measured 18.10.2026, claude 2.1.302:
196
+ `quotaLimits.resetsAt` and `apiErrorParams.rate_limit_info.resetsAt`, epoch seconds,
197
+ `rateLimitType: "five_hour"`) — the notice carries it and §9 acts on it. A **per-model
198
+ bucket** states none: claude does not say when a per-model bucket lifts. Its
195
199
  `errorDetails` is a raw 429 reading *"Please try again later"* — no stamp. The 5h/7d
196
200
  `resets_at` in the statusline blob belongs to a **different limit** and must never be
197
201
  substituted: measured live on 15.07.2026, the 5h bucket sat at **11 %** and the 7d at
@@ -431,3 +435,42 @@ unanswered rather than inferred from the 429 result.**
431
435
  provider path could in principle record something this one did not.
432
436
 
433
437
  Artifacts (rollout, injector, config): `docs/internals/forensics/model-limit-2026-07-15/`.
438
+
439
+ ## 9. Limit-resume — a peer stopped by a limit continues by itself after the reset
440
+
441
+ **Invariant.** A peer whose turn was stopped by an exhausted model limit that STATED its reset
442
+ continues after that reset without a human. Incident 18.10.2026: the fleet hit the claude
443
+ five-hour wall at 04:55; claude painted «resets 8:30am», the daemon idle-reaped the sessions at
444
+ ~05:55, nothing woke them after the reset and every peer stood still until a manual ping at 10:12.
445
+
446
+ **Mechanism** (`lifecycle/limitpark.ts`, `daemon/limitresume.ts`):
447
+
448
+ 1. **Park.** A `peer-mute` detection that carries `resetsAtMs` writes one record per endpoint
449
+ (`state/lifecycle/limit-parks/<runtime>-<personality>.json`: reset, event time, session file).
450
+ A newer limit event supersedes it; the overlapping sweep re-reading the same line does not.
451
+ No stated reset → no park (the notice still tells the owner; nothing is extrapolated).
452
+ 2. **Hold.** While `now < resetsAt + 3 min` the idle-reap skips the session (`outcome=limit-hold`,
453
+ traced once per park): the context stays warm and the runtime's own continuation can fire.
454
+ Bounded: a reset more than 6 h after the event (a weekly wall) is not held — the session is
455
+ reaped normally and step 3 resumes it.
456
+ 3. **Nudge.** After `resetsAt + 3 min`, if the session made no progress since the event (claude: a
457
+ real, non-`<synthetic>` assistant line after it; codex: a `task_complete` with a message whose
458
+ `completed_at` is after it), the core delivers ONE continuation message through the normal
459
+ route — wake-on-miss RESUMES a reaped session: the nudge carries an explicit continue-work
460
+ wake intent that satisfies the executor topic gate by itself (wake cause
461
+ `idle-reaped-continue`); it never outranks an owner `/new`, a soft-reload or a crash. Without
462
+ it the first live run (0.4.127) woke the peer FRESH (`idle-reaped-new-topic`) and the peer
463
+ answered «nothing to resume» — the very loss this section exists to prevent. Progress
464
+ already made → the park is dropped without a message (no duplicate turn). A failed delivery is
465
+ retried (5 min backoff, 3 attempts); an operator `stop` is never fought.
466
+ 4. **Sender** = the core's system identity `iapeer-system` (runtime `core`, intelligence
467
+ `absent`): synthesized, never registered, the name reserved by the registry — so it exists on
468
+ every install and is never the target (the target may be the peer named `iapeer` itself). The
469
+ same identity now signs the OC07 interrupted/undelivered notices and the composer-queue
470
+ failure notices.
471
+
472
+ Every decision is in `lifecycle.log`: `ev=limit-park`, `outcome=limit-hold`, `ev=limit-resume
473
+ outcome=nudged|progressed|retry|gave-up|dropped`.
474
+
475
+ **Not covered:** opencode (its `session.error` states no reset) and a claude per-model bucket
476
+ (no reset stated) — both still raise the notice; the owner resumes by hand.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.126",
3
+ "version": "0.4.128",
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",
package/src/cli/index.ts CHANGED
@@ -1448,6 +1448,21 @@ export async function renamePeerCli(
1448
1448
  sideEffects.push(`claude .mcp.json: failed (${e instanceof Error ? e.message : String(e)})`)
1449
1449
  }
1450
1450
  }
1451
+ if (peer.runtimes.includes('opencode')) {
1452
+ // The opencode private dir is keyed by personality (docs/20): move it with the peer, or the endpoint
1453
+ // loses its session (resume), journal history and provider/memory/capability fragments.
1454
+ try {
1455
+ const { opencodePrivateDir } = await import('../storage/index.ts')
1456
+ const from = opencodePrivateDir(oldPersonality, { env })
1457
+ const to = opencodePrivateDir(newPersonality, { env })
1458
+ if (existsSync(from) && !existsSync(to)) {
1459
+ renameSync(from, to)
1460
+ sideEffects.push(`opencode private dir moved: ${to}`)
1461
+ } else if (existsSync(from)) sideEffects.push(`opencode private dir NOT moved: ${to} already exists`)
1462
+ } catch (e) {
1463
+ sideEffects.push(`opencode private dir: failed (${e instanceof Error ? e.message : String(e)})`)
1464
+ }
1465
+ }
1451
1466
  const purgedState = peer.runtimes.flatMap(rt => purgeIdentityState(cfg, buildProcessAddress(rt, oldPersonality)))
1452
1467
  return { oldPersonality, newPersonality, action: 'renamed', oldCwd, newCwd, transcriptMoved, purgedState, sideEffects }
1453
1468
  }
@@ -237,3 +237,14 @@ export function isValidName(value: unknown): boolean {
237
237
  // module so the transliteration engine is swappable behind one interface; it is
238
238
  // re-exported here so existing `from '../core/constants.ts'` importers are unchanged.
239
239
  export { normalizeNameCandidate } from './normalize.ts'
240
+
241
+ /**
242
+ * The CORE's own sender identity — the author of daemon-originated messages (OC07 interrupted /
243
+ * undelivered notices, the composer-queue failure outlet, the limit-resume nudge). It exists on EVERY
244
+ * install by construction: it is synthesized, never registered, so a host with no peer of any given
245
+ * name still gets its system notices (a registry-dependent sender was skipped silently there).
246
+ * The name is RESERVED: the registry refuses to register a peer under it, so it can never be spoofed
247
+ * by — or collide with — a real peer. Intelligence `absent`: a recipient does not answer it.
248
+ */
249
+ export const SYSTEM_PERSONALITY = 'iapeer-system'
250
+ export const SYSTEM_RUNTIME = 'core'
@@ -0,0 +1,101 @@
1
+ // Limit-resume — the NUDGE half of the limit-park contract (lifecycle/limitpark.ts, docs/19 §9).
2
+ //
3
+ // Invariant: a peer stopped by an exhausted model limit mid-task continues by itself after the
4
+ // stated reset — no human. Run after every supervise tick: for each park whose reset + grace has
5
+ // passed, either the session already progressed (the runtime's own continuation fired, or a later
6
+ // message got a real reply) → the park is dropped; or it did not → the core delivers ONE
7
+ // continuation message through the normal route. Wake-on-miss resumes a reaped session (an idle
8
+ // reap is resume-eligible), so the same message serves a held-alive and a reaped session.
9
+ //
10
+ // Sender = the core's system identity (identity/systemCaller): registry-independent, and never the
11
+ // target — the target may well be the peer named `iapeer` itself (it was, on 18.10.2026).
12
+
13
+ import type { LifecycleConfig } from '../lifecycle/index.ts'
14
+ import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
15
+ import {
16
+ LIMIT_MAX_NUDGE_ATTEMPTS,
17
+ LIMIT_RESUME_GRACE_MS,
18
+ clearLimitPark,
19
+ listLimitParks,
20
+ parkProgressed,
21
+ updateLimitPark,
22
+ type LimitPark,
23
+ } from '../lifecycle/limitpark.ts'
24
+ import { systemCaller } from '../identity/index.ts'
25
+ import { routeSend, type RouteDeps } from '../transport/index.ts'
26
+
27
+ /** Between two failed nudge attempts. */
28
+ const RETRY_BACKOFF_MS = 5 * 60_000
29
+
30
+ export function limitResumeMessage(p: LimitPark): string {
31
+ return (
32
+ `[iapeer] Your model limit (${p.errorType ?? 'rate limit'}) has reset — it lifted at ${new Date(p.resetsAtMs).toISOString()}. ` +
33
+ `Your previous turn was stopped by that limit at ${new Date(p.eventAtMs).toISOString()} and nothing has continued it since. ` +
34
+ `Resume the interrupted work from where it stopped (re-check its state first). ` +
35
+ `If that work is already finished, no reply is needed — this notice comes from the iapeer core, not a peer.`
36
+ )
37
+ }
38
+
39
+ export interface LimitResumeSeam {
40
+ send?: typeof routeSend
41
+ progressed?: (p: LimitPark) => boolean
42
+ now?: () => number
43
+ }
44
+
45
+ export async function processLimitResumes(
46
+ cfg: LifecycleConfig,
47
+ env: NodeJS.ProcessEnv,
48
+ routeDeps: RouteDeps,
49
+ seam: LimitResumeSeam = {},
50
+ ): Promise<void> {
51
+ const now = (seam.now ?? Date.now)()
52
+ const send = seam.send ?? routeSend
53
+ const progressed = seam.progressed ?? parkProgressed
54
+ const log = (fields: Record<string, string | number | undefined>): void => {
55
+ try {
56
+ appendLifecycleEvent(cfg.eventLogDir, { ev: 'limit-resume', ...fields }, { env })
57
+ } catch {
58
+ /* best-effort */
59
+ }
60
+ }
61
+ for (const p of listLimitParks(cfg)) {
62
+ if (now < p.resetsAtMs + LIMIT_RESUME_GRACE_MS) continue
63
+ if (p.lastAttemptMs && now - p.lastAttemptMs < RETRY_BACKOFF_MS) continue
64
+ let moved = false
65
+ try {
66
+ moved = progressed(p)
67
+ } catch {
68
+ moved = false
69
+ }
70
+ if (moved) {
71
+ clearLimitPark(cfg, p.identity)
72
+ log({ identity: p.identity, outcome: 'progressed', resets: new Date(p.resetsAtMs).toISOString() })
73
+ continue
74
+ }
75
+ let reason: string
76
+ try {
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 })
81
+ if (r.ok) {
82
+ clearLimitPark(cfg, p.identity)
83
+ log({ identity: p.identity, outcome: 'nudged', woke: String(r.value.woke), resets: new Date(p.resetsAtMs).toISOString() })
84
+ continue
85
+ }
86
+ reason = r.error.message
87
+ } catch (e) {
88
+ reason = e instanceof Error ? e.message : String(e)
89
+ }
90
+ const attempts = (p.attempts ?? 0) + 1
91
+ // A deliberate operator halt (stopped) is not a transient — never fight it.
92
+ const permanent = /\bstopped\b|not in the iapeer peers index/.test(reason)
93
+ if (permanent || attempts >= LIMIT_MAX_NUDGE_ATTEMPTS) {
94
+ clearLimitPark(cfg, p.identity)
95
+ log({ identity: p.identity, outcome: permanent ? 'dropped' : 'gave-up', attempts, err: reason.slice(0, 300) })
96
+ } else {
97
+ updateLimitPark(cfg, { ...p, attempts, lastAttemptMs: now })
98
+ log({ identity: p.identity, outcome: 'retry', attempts, err: reason.slice(0, 300) })
99
+ }
100
+ }
101
+ }
@@ -51,9 +51,9 @@ import {
51
51
  import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
52
52
  import { buildProcessAddress } from '../core/socket.ts'
53
53
  import { err, ok } from '../core/errors.ts'
54
- import { resolveCallerIdentity } from '../identity/index.ts'
54
+ import { resolveCallerIdentity, systemCaller } from '../identity/index.ts'
55
55
  import type { ResolvedCaller } from '../identity/index.ts'
56
- import { findPeer, readPeersIndex } from '../registry/index.ts'
56
+ import { readPeersIndex } from '../registry/index.ts'
57
57
  import {
58
58
  createComposerDeliveryQueue,
59
59
  deliveryCompleted,
@@ -74,6 +74,8 @@ import { appendDeliveryEvent } from './deliverylog.ts'
74
74
  import { buildFleetHandler } from './fleet.ts'
75
75
  import { NoticeBoard } from './notices.ts'
76
76
  import { startMuteWatch } from './mutewatch.ts'
77
+ import { processLimitResumes } from './limitresume.ts'
78
+ import { recordLimitPark } from '../lifecycle/limitpark.ts'
77
79
  import { DEFAULT_PROFILE_SYNC_INTERVAL_MS, startProfileSync } from './profilesync.ts'
78
80
  import { startGoalWatch } from './goalwatch.ts'
79
81
  import { ActivityBoard, startTurnWatch } from './turnwatch.ts'
@@ -138,7 +140,7 @@ const DAEMON_MAIN_PATH = fileURLToPath(import.meta.url)
138
140
  export function makeWakeFn(cfg: LifecycleConfig, env: NodeJS.ProcessEnv): WakeFn {
139
141
  return (req: WakeRequest): Promise<WakeOutcome> =>
140
142
  wakeOrSpawn(
141
- { 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 } : {}) },
142
144
  { cfg, env },
143
145
  )
144
146
  }
@@ -307,14 +309,12 @@ export async function processInflight(
307
309
  ): Promise<void> {
308
310
  const processed = seam.processed ?? ((e: InflightEntry) => deliveryProcessed(e, env))
309
311
  const send = seam.send ?? routeSend
310
- /** A notice FROM iapeer to the entry's original sender (skipped when the sender is iapeer itself). */
312
+ /** A notice FROM the core's system identity to the entry's original sender. Registry-independent
313
+ * (systemCaller is synthesized), so it is deliverable on every install — the earlier sender was the
314
+ * peer named `iapeer`, and a host without it skipped the notice silently (linus 18.10). */
311
315
  const notifySender = async (e: InflightEntry, message: string): Promise<string> => {
312
316
  try {
313
- const index = readPeersIndex({ env })
314
- const self = findPeer(index, 'iapeer')
315
- if (!self || `${e.caller.runtime}-${e.caller.personality}` === `${self.runtime}-iapeer`) return 'skipped'
316
- const sender = resolveCallerIdentity({ personality: 'iapeer', runtime: self.runtime }, index)
317
- const n = await send(sender, { personality: e.caller.personality, runtime: e.caller.runtime, message, topic: e.topic ?? 'delivery-failed' }, routeDeps)
317
+ const n = await send(systemCaller(), { personality: e.caller.personality, runtime: e.caller.runtime, message, topic: e.topic ?? 'delivery-failed' }, routeDeps)
318
318
  return String(n.ok)
319
319
  } catch {
320
320
  return 'false'
@@ -384,38 +384,9 @@ export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.Pro
384
384
  noteInflight: makeNoteInflight(cfg),
385
385
  onDelivered,
386
386
  notifyFailed: async (job, reason) => {
387
- const index = readPeersIndex({ env })
388
- const self = findPeer(index, 'iapeer')
389
- if (!self) {
390
- appendDeliveryEvent(cfg.eventLogDir, {
391
- ev: 'composer-queue-failed-notify',
392
- caller: 'iapeer',
393
- to: job.caller.personality,
394
- rt: job.caller.runtime,
395
- ok: 'false',
396
- err: 'foundation peer "iapeer" is not registered; cannot notify queued sender',
397
- })
398
- return
399
- }
400
- const sender = resolveCallerIdentity({ personality: 'iapeer', runtime: self.runtime }, index)
401
- // SELF-NOTIFY GUARD: the failure outlet sends FROM the foundation peer (iapeer). When the QUEUED
402
- // sender IS the foundation peer on the same runtime (e.g. iapeer itself queued a cross-runtime send to
403
- // its codex twin and that queue later failed), this notify would be iapeer→iapeer — a genuine
404
- // self-send the address-based router correctly refuses with 'cannot send to self', logging ok=false
405
- // and tripping monitoring alarms. The notification is moot anyway (the sender IS the one being
406
- // notified about its own send). Skip it and log a benign skip instead of attempting a self-send.
407
- if (`${job.caller.runtime}-${job.caller.personality}` === sender.address) {
408
- appendDeliveryEvent(cfg.eventLogDir, {
409
- ev: 'composer-queue-failed-notify',
410
- caller: sender.address,
411
- to: job.caller.personality,
412
- rt: job.caller.runtime,
413
- ok: 'true',
414
- err: 'skipped self-notify (queued sender is the foundation peer itself)',
415
- topic: job.topic ?? 'delivery-failed',
416
- })
417
- return
418
- }
387
+ // The core's system identity — exists on every install and is never a peer, so there is no
388
+ // missing-sender case and no self-send to guard (it used to be the peer named `iapeer`).
389
+ const sender = systemCaller()
419
390
  const result = await routeSend(
420
391
  sender,
421
392
  {
@@ -508,6 +479,24 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
508
479
  appendLifecycleEvent(cfg.eventLogDir, { ev: 'mutewatch-error', error: detail.replace(/\s+/g, ' ').slice(0, 600) }, { env })
509
480
  } catch { /* best-effort */ }
510
481
  },
482
+ // Limit-park (docs/19 §9): a limit event that STATES its reset parks the endpoint — the supervise
483
+ // tick holds it from idle-reap and the limit-resume pass nudges it after the reset.
484
+ onLimit: d => {
485
+ const identity = `${d.runtime}-${d.personality}`
486
+ if (d.resetsAtMs === undefined) return
487
+ const fresh = recordLimitPark(cfg, {
488
+ identity,
489
+ personality: d.personality,
490
+ runtime: d.runtime,
491
+ resetsAtMs: d.resetsAtMs,
492
+ eventAtMs: d.atMs,
493
+ ...(d.file ? { file: d.file } : {}),
494
+ ...(d.sessionId ? { sessionId: d.sessionId } : {}),
495
+ errorType: d.errorType,
496
+ ...(readTopic(cfg, identity) ? { topic: readTopic(cfg, identity) } : {}),
497
+ })
498
+ if (fresh) appendLifecycleEvent(cfg.eventLogDir, { ev: 'limit-park', identity, resets: new Date(d.resetsAtMs).toISOString(), error_type: d.errorType }, { env })
499
+ },
511
500
  })
512
501
  // Goal-watch (docs/19) — the SAME board and the same class as the mute-watch: a peer left
513
502
  // alive and green while the thing it was told to do quietly stopped. Separate detector
@@ -627,6 +616,16 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
627
616
  noteInflight,
628
617
  isLaunchdManaged: (personality: string) => isLaunchdManaged(personality, env),
629
618
  })
619
+ // Limit-resume (docs/19 §9): a peer stopped by a model limit continues after the reset.
620
+ await processLimitResumes(cfg, env, {
621
+ env,
622
+ wake: makeWakeFn(cfg, env),
623
+ ephemeral,
624
+ noteLiveTopic,
625
+ noteDelivered: identity => setLastDelivered(cfg, identity),
626
+ noteInflight,
627
+ isLaunchdManaged: (personality: string) => isLaunchdManaged(personality, env),
628
+ })
630
629
  },
631
630
  // Surface a supervise-tick throw that the daemon timer would otherwise SWALLOW (the class that
632
631
  // hid a stuck reaper for hours): record ev=supervise-error in lifecycle.log, flattened to one
@@ -102,9 +102,11 @@ export interface MuteDetection {
102
102
  sessionId?: string
103
103
  atMs: number
104
104
  cwd: string
105
+ /** The session file the event was read from (the limit-park progress probe re-reads it). */
106
+ file?: string
105
107
  }
106
108
 
107
- type RawDetection = Omit<MuteDetection, 'personality' | 'runtime'>
109
+ type RawDetection = Omit<MuteDetection, 'personality' | 'runtime' | 'file'>
108
110
 
109
111
  // ─────────────────────────────────────────────────────────────────────────────
110
112
  // Pure parsers — string in, detection out (fs-free: tests need no temp tree)
@@ -165,12 +167,24 @@ export function parseClaudeTranscript(text: string, sinceMs: number): RawDetecti
165
167
  sessionId: typeof d.sessionId === 'string' ? d.sessionId : undefined,
166
168
  atMs,
167
169
  cwd,
168
- // Claude states no reset for a per-model bucket → resetsAtMs deliberately absent.
170
+ // The reset ONLY when claude states it structurally (account-level walls, observed 18.10.2026 on
171
+ // claude 2.1.302: `quotaLimits.resetsAt` / `apiErrorParams.rate_limit_info.resetsAt`, epoch s,
172
+ // rateLimitType five_hour). A per-model bucket states none («try again later») → omitted, never
173
+ // extrapolated from the other buckets.
174
+ ...(claudeResetsAtMs(d) !== undefined ? { resetsAtMs: claudeResetsAtMs(d) } : {}),
169
175
  }
170
176
  }
171
177
  return found
172
178
  }
173
179
 
180
+ /** Claude's structurally stated reset instant (ms), or undefined. Epoch SECONDS on the wire. */
181
+ export function claudeResetsAtMs(d: Record<string, unknown>): number | undefined {
182
+ const q = d.quotaLimits as { resetsAt?: unknown } | undefined
183
+ const r = (d.apiErrorParams as { rate_limit_info?: { resetsAt?: unknown } } | undefined)?.rate_limit_info
184
+ const v = typeof q?.resetsAt === 'number' ? q.resetsAt : typeof r?.resetsAt === 'number' ? r.resetsAt : undefined
185
+ return v !== undefined && v > 0 ? v * 1000 : undefined
186
+ }
187
+
174
188
  interface CodexWindow {
175
189
  used_percent?: unknown
176
190
  window_minutes?: unknown
@@ -463,21 +477,21 @@ export function resolveMuteWatchPaths(env: NodeJS.ProcessEnv): MuteWatchPaths {
463
477
  export function scanMuteEvents(deps: ScanDeps, sinceMs: number): MuteDetection[] {
464
478
  const floor = sinceMs - SCAN_OVERLAP_MS
465
479
  const out: MuteDetection[] = []
466
- const push = (raw: RawDetection | null, runtime: 'claude' | 'codex'): void => {
480
+ const push = (raw: RawDetection | null, runtime: 'claude' | 'codex', file: string): void => {
467
481
  if (!raw) return
468
482
  const personality = deps.peerByCwd(raw.cwd)
469
483
  if (!personality) return // not a peer's session (a human's own shell) — never notify
470
- out.push({ ...raw, personality, runtime })
484
+ out.push({ ...raw, personality, runtime, file })
471
485
  }
472
486
  // claude: ~/.claude/projects/<slug>/<sessionId>.jsonl — one level of slug dirs.
473
487
  for (const file of changedJsonl(deps.claudeProjectsDir, floor, 1)) {
474
488
  const t = readSessionSlices(file, TAIL_BYTES)
475
- if (t) push(parseClaudeTranscript(t.tail, floor), 'claude')
489
+ if (t) push(parseClaudeTranscript(t.tail, floor), 'claude', file)
476
490
  }
477
491
  // codex: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
478
492
  for (const file of changedJsonl(deps.codexSessionsDir, floor, CODEX_WALK_DEPTH)) {
479
493
  const t = readSessionSlices(file, TAIL_BYTES)
480
- if (t) push(parseCodexRollout(t.tail, floor, t.head), 'codex')
494
+ if (t) push(parseCodexRollout(t.tail, floor, t.head), 'codex', file)
481
495
  }
482
496
  // opencode: the core-owned journal per endpoint (personality is known — no cwd mapping needed).
483
497
  for (const j of deps.opencodeJournals?.() ?? []) {
@@ -500,6 +514,8 @@ export interface MuteWatchDeps extends ScanDeps {
500
514
  now?: () => number
501
515
  /** Reported, never thrown — a detector must never take the daemon down. */
502
516
  onError?: (err: unknown) => void
517
+ /** Every detection that STATES a reset (deduped or not) — the limit-park writer (docs/19 §9). */
518
+ onLimit?: (d: MuteDetection) => void
503
519
  }
504
520
 
505
521
  /**
@@ -527,6 +543,13 @@ export function muteWatchTick(deps: MuteWatchDeps, sinceMs: number): { raised: n
527
543
  summary: `${d.personality} · ${d.runtime} — ${d.errorType}${d.model ? ` (${d.model})` : ''}`,
528
544
  })
529
545
  if (!deduped) raised += 1
546
+ if (d.resetsAtMs !== undefined && deps.onLimit) {
547
+ try {
548
+ deps.onLimit(d)
549
+ } catch (e) {
550
+ deps.onError?.(e)
551
+ }
552
+ }
530
553
  }
531
554
  } catch (e) {
532
555
  deps.onError?.(e)
@@ -543,6 +566,7 @@ export interface StartMuteWatchOptions {
543
566
  listPeers?: () => Array<{ personality: string; cwd: string; runtimes?: string[] }>
544
567
  now?: () => number
545
568
  onError?: (err: unknown) => void
569
+ onLimit?: (d: MuteDetection) => void
546
570
  }
547
571
 
548
572
  /**
@@ -579,6 +603,7 @@ export function startMuteWatch(cfg: LifecycleConfig, board: NoticeBoard, opts: S
579
603
  board,
580
604
  now,
581
605
  onError: opts.onError,
606
+ onLimit: opts.onLimit,
582
607
  peerByCwd: (cwd: string) => byCwd.get(cwd.replace(/\/+$/, '').toLowerCase()),
583
608
  opencodeJournals: () =>
584
609
  rows
@@ -11,6 +11,8 @@ import {
11
11
  IAPEER_DIR,
12
12
  PEER_PROFILE_FILE,
13
13
  NAME_RE,
14
+ SYSTEM_PERSONALITY,
15
+ SYSTEM_RUNTIME,
14
16
  allowedIntelligencesForRuntime,
15
17
  defaultIntelligenceForRuntime,
16
18
  isInfraRuntime,
@@ -781,6 +783,32 @@ export interface ResolvedCaller {
781
783
  record: PeerRecord
782
784
  }
783
785
 
786
+ /**
787
+ * The core's own sender (core/constants SYSTEM_PERSONALITY). Synthesized — never read from the
788
+ * registry — so daemon-originated notices are deliverable on every install, independent of which
789
+ * peers happen to be registered. Never a request identity: resolveCallerIdentity refuses it (the
790
+ * name cannot be registered), so no client can send AS the core.
791
+ */
792
+ export function systemCaller(): ResolvedCaller {
793
+ const record: PeerRecord = {
794
+ personality: SYSTEM_PERSONALITY,
795
+ runtime: SYSTEM_RUNTIME,
796
+ runtimes: [SYSTEM_RUNTIME],
797
+ description: 'iapeer core — system notices (no reply needed)',
798
+ intelligence: 'absent',
799
+ cwd: '',
800
+ }
801
+ return {
802
+ personality: SYSTEM_PERSONALITY,
803
+ runtime: SYSTEM_RUNTIME,
804
+ address: buildProcessAddress(SYSTEM_RUNTIME, SYSTEM_PERSONALITY),
805
+ description: record.description,
806
+ intelligence: 'absent',
807
+ cwd: '',
808
+ record,
809
+ }
810
+ }
811
+
784
812
  /**
785
813
  * Resolve a caller identity carried IN THE REQUEST (X-IAPeer-Identity), against
786
814
  * the registry only. Deliberately does NOT read process.cwd(), process.env, or
@@ -12,6 +12,7 @@
12
12
  // before any wake or reap. wakeOrSpawn refuses a launchd peer; superviseTick and
13
13
  // sweepZombies skip it. Only daemon-owned (no-plist) peers are managed here.
14
14
 
15
+ import { limitHoldActive } from './limitpark.ts'
15
16
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
16
17
  import { homedir } from 'os'
17
18
  import { join } from 'path'
@@ -652,6 +653,10 @@ export function resolveWakeMode(
652
653
  * next resume/fresh decision (a crash-looping binary used to eat .idle-reaped, and after the fix the
653
654
  * peer came back FRESH, its context lost). Absent → consume immediately (legacy/test callers). */
654
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,
655
660
  ): WakeMode {
656
661
  const done = defer ?? ((f: () => void) => f())
657
662
  // 1. folder-launch → always fresh. Consume a standing .new-eager too — the mark's
@@ -724,6 +729,12 @@ export function resolveWakeMode(
724
729
  // gap is not a request to resume specific work; defaulting it to resume revived stale
725
730
  // context (e.g. a 3-day-old session). The sender opts INTO continuity by passing the topic.
726
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
+ }
727
738
  if (!topic) return { resume: false, cause: 'idle-reaped-no-topic' }
728
739
  // RESUME if the incoming topic matches ANY thread this session lineage worked on — not
729
740
  // just the last. Different threads accumulate in one session; a later ping on an earlier
@@ -966,6 +977,8 @@ export interface WakeArgs {
966
977
  /** First message delivered to the woken session (the routed envelope). */
967
978
  task: string
968
979
  resume?: boolean
980
+ /** Explicit "continue THIS work" intent — see resolveWakeMode `continueWork`. */
981
+ continueWork?: boolean
969
982
  }
970
983
 
971
984
  export interface WakeResult {
@@ -1123,6 +1136,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1123
1136
  c => adapter.resolveResume(c, { personality: args.personality, env }),
1124
1137
  args.topic,
1125
1138
  f => consumeOnUp.push(f),
1139
+ args.continueWork,
1126
1140
  )
1127
1141
  const applyConsumed = (): void => {
1128
1142
  for (const f of consumeOnUp.splice(0)) {
@@ -1520,6 +1534,18 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1520
1534
  }
1521
1535
  }
1522
1536
  if (ageSecs > cfg.idleSecs) {
1537
+ // LIMIT HOLD (docs/19 §9) — a session stopped by an exhausted model limit that STATED its reset is
1538
+ // not idle, it is waiting: keep it (warm context; the runtime's own continuation gets to fire) until
1539
+ // the reset + grace. Bounded in limitpark.ts; past the bound the reap proceeds and the limit-resume
1540
+ // nudge wakes it (an idle reap is resume-eligible) — either way the turn is not lost.
1541
+ const held = limitHoldActive(cfg, s.identity, nowMs)
1542
+ if (held) {
1543
+ // Traced ONCE per park (the tick runs every minute; a 3 h hold would flood lifecycle.log).
1544
+ const key = `${s.identity}@${held.eventAtMs}`
1545
+ if (verbose || !limitHoldTraced.has(key)) trace({ identity: s.identity, action: 'alive', age: `${ageSecs}s`, outcome: 'limit-hold', resets: new Date(held.resetsAtMs).toISOString() })
1546
+ limitHoldTraced.add(key)
1547
+ return { identity: s.identity, action: 'alive', reason: `limit hold until ${new Date(held.resetsAtMs).toISOString()}` }
1548
+ }
1523
1549
  // THE ONLY place .idle-reaped is written: this is the one death the daemon
1524
1550
  // INITIATES. Its presence on the next wake = the session was parked cleanly =
1525
1551
  // RESUME-eligible (resolveWakeMode branch 3b). A crash/self-close (the dead
@@ -1536,6 +1562,9 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1536
1562
  return { identity: s.identity, action: 'alive' }
1537
1563
  }
1538
1564
 
1565
+ /** Parks whose hold was already traced (process-local; a daemon restart re-traces once — harmless). */
1566
+ const limitHoldTraced = new Set<string>()
1567
+
1539
1568
  export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): SuperviseOutcome[] {
1540
1569
  const env = deps.env ?? process.env
1541
1570
  const nowMs = deps.nowMs ?? Date.now()
@@ -0,0 +1,165 @@
1
+ // Limit-park — a peer whose turn was stopped by an exhausted model limit must CONTINUE on its own
2
+ // once the limit lifts (docs/19 §9). Incident 18.10.2026: the fleet hit the claude five-hour limit
3
+ // at 04:55; claude painted «resets 8:30am» in the pane, the daemon idle-reaped the sessions an hour
4
+ // later, nothing woke them after the reset, and every peer stood still until a human pinged it.
5
+ //
6
+ // TWO HALVES, one record per endpoint (identity = <runtime>-<personality>):
7
+ // • HOLD — while a park is live (now < resetsAt + grace) superviseTick does NOT idle-reap the
8
+ // session. The context stays warm and a runtime that resumes by itself (claude's own «Continuing
9
+ // automatically») gets the chance to. Bounded by MAX_HOLD_MS: a weekly wall is not worth a
10
+ // process held for days — beyond it the session is reaped normally and the nudge resumes it.
11
+ // • NUDGE — after resetsAt + grace, if the session made NO progress since the limit event, the
12
+ // daemon delivers a continuation message (daemon/limitresume.ts). Wake-on-miss resumes a reaped
13
+ // session (an idle reap is resume-eligible), so the nudge works whether the hold applied or not.
14
+ //
15
+ // The record is written by the mute-watch on a structural limit event that STATES a reset time —
16
+ // never extrapolated: no resetsAt, no park (the notice still tells the owner). A newer limit event
17
+ // for the same endpoint replaces the record (each event earns exactly one nudge).
18
+
19
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+ import type { LifecycleConfig } from './index.ts'
22
+ import { writeFileAtomic } from '../storage/index.ts'
23
+ import { readSessionSlices } from '../daemon/sessionfiles.ts'
24
+
25
+ /** Wait this long past the stated reset before nudging: the runtime's own continuation (and a clock
26
+ * skew between the provider and this host) gets to win first. */
27
+ export const LIMIT_RESUME_GRACE_MS = 3 * 60_000
28
+ /** The hold never keeps a session alive longer than this past the limit event. */
29
+ export const LIMIT_MAX_HOLD_MS = 6 * 60 * 60_000
30
+ /** A failed nudge is retried at most this many times (then the park is dropped, logged). */
31
+ export const LIMIT_MAX_NUDGE_ATTEMPTS = 3
32
+
33
+ export interface LimitPark {
34
+ identity: string
35
+ personality: string
36
+ runtime: string
37
+ /** The runtime's own stated reset instant. */
38
+ resetsAtMs: number
39
+ /** The limit event's own time (transcript timestamp / codex completed_at). */
40
+ eventAtMs: number
41
+ /** The session file the event was read from — the progress probe re-reads it. */
42
+ file?: string
43
+ sessionId?: string
44
+ errorType?: string
45
+ /** The session's most-recent topic when parked — the nudge continues that thread. */
46
+ topic?: string
47
+ attempts?: number
48
+ lastAttemptMs?: number
49
+ }
50
+
51
+ const DIR = 'limit-parks'
52
+
53
+ function parkPath(cfg: LifecycleConfig, identity: string): string {
54
+ return join(cfg.stateDir, DIR, `${identity}.json`)
55
+ }
56
+
57
+ export function readLimitPark(cfg: LifecycleConfig, identity: string): LimitPark | null {
58
+ try {
59
+ const p = JSON.parse(readFileSync(parkPath(cfg, identity), 'utf8')) as LimitPark
60
+ return typeof p.resetsAtMs === 'number' && typeof p.eventAtMs === 'number' ? p : null
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ /** Record (or supersede) a park. Returns false when an equal-or-newer event is already recorded —
67
+ * the overlapping mute-watch window re-reads the same line every sweep. */
68
+ export function recordLimitPark(cfg: LifecycleConfig, park: LimitPark): boolean {
69
+ const cur = readLimitPark(cfg, park.identity)
70
+ if (cur && cur.eventAtMs >= park.eventAtMs) return false
71
+ mkdirSync(join(cfg.stateDir, DIR), { recursive: true, mode: 0o700 })
72
+ writeFileAtomic(parkPath(cfg, park.identity), `${JSON.stringify({ ...park, attempts: 0 })}\n`)
73
+ return true
74
+ }
75
+
76
+ export function updateLimitPark(cfg: LifecycleConfig, park: LimitPark): void {
77
+ mkdirSync(join(cfg.stateDir, DIR), { recursive: true, mode: 0o700 })
78
+ writeFileAtomic(parkPath(cfg, park.identity), `${JSON.stringify(park)}\n`)
79
+ }
80
+
81
+ export function clearLimitPark(cfg: LifecycleConfig, identity: string): void {
82
+ try {
83
+ rmSync(parkPath(cfg, identity), { force: true })
84
+ } catch {
85
+ /* already gone */
86
+ }
87
+ }
88
+
89
+ export function listLimitParks(cfg: LifecycleConfig): LimitPark[] {
90
+ const dir = join(cfg.stateDir, DIR)
91
+ if (!existsSync(dir)) return []
92
+ const out: LimitPark[] = []
93
+ for (const name of readdirSync(dir)) {
94
+ if (!name.endsWith('.json')) continue
95
+ const p = readLimitPark(cfg, name.slice(0, -'.json'.length))
96
+ if (p) out.push(p)
97
+ }
98
+ return out
99
+ }
100
+
101
+ /** True while superviseTick must NOT idle-reap this identity: a live park whose reset (plus grace)
102
+ * has not arrived yet and whose hold is within the bound. */
103
+ export function limitHoldActive(cfg: LifecycleConfig, identity: string, nowMs: number): LimitPark | null {
104
+ const p = readLimitPark(cfg, identity)
105
+ if (!p) return null
106
+ if (nowMs >= p.resetsAtMs + LIMIT_RESUME_GRACE_MS) return null
107
+ if (p.resetsAtMs - p.eventAtMs > LIMIT_MAX_HOLD_MS) return null
108
+ return p
109
+ }
110
+
111
+ // ─────────────────────────────────────────────────────────────────────────────
112
+ // Progress probe — did the session do real work AFTER the limit event?
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+
115
+ /** Claude: a REAL assistant reply (model ≠ "<synthetic>", which is the refusal itself) stamped after
116
+ * the event. A new limit refusal is not progress — it supersedes the park instead. */
117
+ export function claudeProgressedSince(text: string, eventAtMs: number): boolean {
118
+ for (const line of text.split('\n')) {
119
+ const s = line.trim()
120
+ if (!s.startsWith('{') || !s.includes('"assistant"')) continue
121
+ let d: { type?: unknown; timestamp?: unknown; isApiErrorMessage?: unknown; message?: { model?: unknown } }
122
+ try {
123
+ d = JSON.parse(s)
124
+ } catch {
125
+ continue
126
+ }
127
+ if (d.type !== 'assistant' || d.isApiErrorMessage === true) continue
128
+ const model = d.message?.model
129
+ if (typeof model !== 'string' || !model || model === '<synthetic>') continue
130
+ const at = typeof d.timestamp === 'string' ? Date.parse(d.timestamp) : NaN
131
+ if (Number.isFinite(at) && at > eventAtMs) return true
132
+ }
133
+ return false
134
+ }
135
+
136
+ /** Codex: a turn that COMPLETED with a message after the event. Keyed on completed_at (replay-safe:
137
+ * a resumed rollout re-stamps line timestamps but keeps completed_at — see mutewatch.ts). */
138
+ export function codexProgressedSince(text: string, eventAtMs: number): boolean {
139
+ for (const line of text.split('\n')) {
140
+ const s = line.trim()
141
+ if (!s.startsWith('{') || !s.includes('task_complete')) continue
142
+ let d: { payload?: { type?: unknown; last_agent_message?: unknown; completed_at?: unknown } }
143
+ try {
144
+ d = JSON.parse(s)
145
+ } catch {
146
+ continue
147
+ }
148
+ const p = d.payload
149
+ if (!p || p.type !== 'task_complete') continue
150
+ if (typeof p.last_agent_message !== 'string' || !p.last_agent_message) continue
151
+ if (typeof p.completed_at === 'number' && p.completed_at * 1000 > eventAtMs) return true
152
+ }
153
+ return false
154
+ }
155
+
156
+ /** Did the parked session progress since its limit event? Unknown (no file / unreadable) → false:
157
+ * the nudge is cheap and a missed continuation is exactly the defect this exists for. */
158
+ export function parkProgressed(park: LimitPark): boolean {
159
+ if (!park.file) return false
160
+ const t = readSessionSlices(park.file, 512 * 1024)
161
+ if (!t) return false
162
+ if (park.runtime === 'claude') return claudeProgressedSince(t.tail, park.eventAtMs)
163
+ if (park.runtime === 'codex') return codexProgressedSince(t.tail, park.eventAtMs)
164
+ return false
165
+ }
@@ -18,6 +18,7 @@ import {
18
18
  MAX_DESCRIPTION_LEN,
19
19
  PEERS_PROFILES_FILE,
20
20
  PEERS_SCHEMA_VERSION,
21
+ SYSTEM_PERSONALITY,
21
22
  defaultIntelligenceForRuntime,
22
23
  isIntelligence,
23
24
  isRuntime,
@@ -120,6 +121,9 @@ function ensurePersonality(personality: string): void {
120
121
  `invalid personality "${personality}" — must match /^[a-z][a-z0-9-]{0,31}$/`,
121
122
  )
122
123
  }
124
+ if (personality === SYSTEM_PERSONALITY) {
125
+ throw new IapError(`personality "${personality}" is reserved for the iapeer core's own sender identity`)
126
+ }
123
127
  }
124
128
 
125
129
  function ensureRuntime(runtime: string): Runtime {
@@ -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).