@agfpd/iapeer 0.4.126 → 0.4.127

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,38 @@ 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 (an idle reap is resume-eligible). Progress
460
+ already made → the park is dropped without a message (no duplicate turn). A failed delivery is
461
+ retried (5 min backoff, 3 attempts); an operator `stop` is never fought.
462
+ 4. **Sender** = the core's system identity `iapeer-system` (runtime `core`, intelligence
463
+ `absent`): synthesized, never registered, the name reserved by the registry — so it exists on
464
+ every install and is never the target (the target may be the peer named `iapeer` itself). The
465
+ same identity now signs the OC07 interrupted/undelivered notices and the composer-queue
466
+ failure notices.
467
+
468
+ Every decision is in `lifecycle.log`: `ev=limit-park`, `outcome=limit-hold`, `ev=limit-resume
469
+ outcome=nudged|progressed|retry|gave-up|dropped`.
470
+
471
+ **Not covered:** opencode (its `session.error` states no reset) and a claude per-model bucket
472
+ (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.127",
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,98 @@
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
+ const r = await send(systemCaller(), { personality: p.personality, runtime: p.runtime, message: limitResumeMessage(p), topic: 'limit-resume' }, routeDeps)
78
+ if (r.ok) {
79
+ clearLimitPark(cfg, p.identity)
80
+ log({ identity: p.identity, outcome: 'nudged', woke: String(r.value.woke), resets: new Date(p.resetsAtMs).toISOString() })
81
+ continue
82
+ }
83
+ reason = r.error.message
84
+ } catch (e) {
85
+ reason = e instanceof Error ? e.message : String(e)
86
+ }
87
+ const attempts = (p.attempts ?? 0) + 1
88
+ // A deliberate operator halt (stopped) is not a transient — never fight it.
89
+ const permanent = /\bstopped\b|not in the iapeer peers index/.test(reason)
90
+ if (permanent || attempts >= LIMIT_MAX_NUDGE_ATTEMPTS) {
91
+ clearLimitPark(cfg, p.identity)
92
+ log({ identity: p.identity, outcome: permanent ? 'dropped' : 'gave-up', attempts, err: reason.slice(0, 300) })
93
+ } else {
94
+ updateLimitPark(cfg, { ...p, attempts, lastAttemptMs: now })
95
+ log({ identity: p.identity, outcome: 'retry', attempts, err: reason.slice(0, 300) })
96
+ }
97
+ }
98
+ }
@@ -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'
@@ -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,23 @@ 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
+ })
497
+ if (fresh) appendLifecycleEvent(cfg.eventLogDir, { ev: 'limit-park', identity, resets: new Date(d.resetsAtMs).toISOString(), error_type: d.errorType }, { env })
498
+ },
511
499
  })
512
500
  // Goal-watch (docs/19) — the SAME board and the same class as the mute-watch: a peer left
513
501
  // alive and green while the thing it was told to do quietly stopped. Separate detector
@@ -627,6 +615,16 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
627
615
  noteInflight,
628
616
  isLaunchdManaged: (personality: string) => isLaunchdManaged(personality, env),
629
617
  })
618
+ // Limit-resume (docs/19 §9): a peer stopped by a model limit continues after the reset.
619
+ await processLimitResumes(cfg, env, {
620
+ env,
621
+ wake: makeWakeFn(cfg, env),
622
+ ephemeral,
623
+ noteLiveTopic,
624
+ noteDelivered: identity => setLastDelivered(cfg, identity),
625
+ noteInflight,
626
+ isLaunchdManaged: (personality: string) => isLaunchdManaged(personality, env),
627
+ })
630
628
  },
631
629
  // Surface a supervise-tick throw that the daemon timer would otherwise SWALLOW (the class that
632
630
  // 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'
@@ -1520,6 +1521,18 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1520
1521
  }
1521
1522
  }
1522
1523
  if (ageSecs > cfg.idleSecs) {
1524
+ // LIMIT HOLD (docs/19 §9) — a session stopped by an exhausted model limit that STATED its reset is
1525
+ // not idle, it is waiting: keep it (warm context; the runtime's own continuation gets to fire) until
1526
+ // the reset + grace. Bounded in limitpark.ts; past the bound the reap proceeds and the limit-resume
1527
+ // nudge wakes it (an idle reap is resume-eligible) — either way the turn is not lost.
1528
+ const held = limitHoldActive(cfg, s.identity, nowMs)
1529
+ if (held) {
1530
+ // Traced ONCE per park (the tick runs every minute; a 3 h hold would flood lifecycle.log).
1531
+ const key = `${s.identity}@${held.eventAtMs}`
1532
+ if (verbose || !limitHoldTraced.has(key)) trace({ identity: s.identity, action: 'alive', age: `${ageSecs}s`, outcome: 'limit-hold', resets: new Date(held.resetsAtMs).toISOString() })
1533
+ limitHoldTraced.add(key)
1534
+ return { identity: s.identity, action: 'alive', reason: `limit hold until ${new Date(held.resetsAtMs).toISOString()}` }
1535
+ }
1523
1536
  // THE ONLY place .idle-reaped is written: this is the one death the daemon
1524
1537
  // INITIATES. Its presence on the next wake = the session was parked cleanly =
1525
1538
  // RESUME-eligible (resolveWakeMode branch 3b). A crash/self-close (the dead
@@ -1536,6 +1549,9 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1536
1549
  return { identity: s.identity, action: 'alive' }
1537
1550
  }
1538
1551
 
1552
+ /** Parks whose hold was already traced (process-local; a daemon restart re-traces once — harmless). */
1553
+ const limitHoldTraced = new Set<string>()
1554
+
1539
1555
  export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): SuperviseOutcome[] {
1540
1556
  const env = deps.env ?? process.env
1541
1557
  const nowMs = deps.nowMs ?? Date.now()
@@ -0,0 +1,163 @@
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
+ attempts?: number
46
+ lastAttemptMs?: number
47
+ }
48
+
49
+ const DIR = 'limit-parks'
50
+
51
+ function parkPath(cfg: LifecycleConfig, identity: string): string {
52
+ return join(cfg.stateDir, DIR, `${identity}.json`)
53
+ }
54
+
55
+ export function readLimitPark(cfg: LifecycleConfig, identity: string): LimitPark | null {
56
+ try {
57
+ const p = JSON.parse(readFileSync(parkPath(cfg, identity), 'utf8')) as LimitPark
58
+ return typeof p.resetsAtMs === 'number' && typeof p.eventAtMs === 'number' ? p : null
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ /** Record (or supersede) a park. Returns false when an equal-or-newer event is already recorded —
65
+ * the overlapping mute-watch window re-reads the same line every sweep. */
66
+ export function recordLimitPark(cfg: LifecycleConfig, park: LimitPark): boolean {
67
+ const cur = readLimitPark(cfg, park.identity)
68
+ if (cur && cur.eventAtMs >= park.eventAtMs) return false
69
+ mkdirSync(join(cfg.stateDir, DIR), { recursive: true, mode: 0o700 })
70
+ writeFileAtomic(parkPath(cfg, park.identity), `${JSON.stringify({ ...park, attempts: 0 })}\n`)
71
+ return true
72
+ }
73
+
74
+ export function updateLimitPark(cfg: LifecycleConfig, park: LimitPark): void {
75
+ mkdirSync(join(cfg.stateDir, DIR), { recursive: true, mode: 0o700 })
76
+ writeFileAtomic(parkPath(cfg, park.identity), `${JSON.stringify(park)}\n`)
77
+ }
78
+
79
+ export function clearLimitPark(cfg: LifecycleConfig, identity: string): void {
80
+ try {
81
+ rmSync(parkPath(cfg, identity), { force: true })
82
+ } catch {
83
+ /* already gone */
84
+ }
85
+ }
86
+
87
+ export function listLimitParks(cfg: LifecycleConfig): LimitPark[] {
88
+ const dir = join(cfg.stateDir, DIR)
89
+ if (!existsSync(dir)) return []
90
+ const out: LimitPark[] = []
91
+ for (const name of readdirSync(dir)) {
92
+ if (!name.endsWith('.json')) continue
93
+ const p = readLimitPark(cfg, name.slice(0, -'.json'.length))
94
+ if (p) out.push(p)
95
+ }
96
+ return out
97
+ }
98
+
99
+ /** True while superviseTick must NOT idle-reap this identity: a live park whose reset (plus grace)
100
+ * has not arrived yet and whose hold is within the bound. */
101
+ export function limitHoldActive(cfg: LifecycleConfig, identity: string, nowMs: number): LimitPark | null {
102
+ const p = readLimitPark(cfg, identity)
103
+ if (!p) return null
104
+ if (nowMs >= p.resetsAtMs + LIMIT_RESUME_GRACE_MS) return null
105
+ if (p.resetsAtMs - p.eventAtMs > LIMIT_MAX_HOLD_MS) return null
106
+ return p
107
+ }
108
+
109
+ // ─────────────────────────────────────────────────────────────────────────────
110
+ // Progress probe — did the session do real work AFTER the limit event?
111
+ // ─────────────────────────────────────────────────────────────────────────────
112
+
113
+ /** Claude: a REAL assistant reply (model ≠ "<synthetic>", which is the refusal itself) stamped after
114
+ * the event. A new limit refusal is not progress — it supersedes the park instead. */
115
+ export function claudeProgressedSince(text: string, eventAtMs: number): boolean {
116
+ for (const line of text.split('\n')) {
117
+ const s = line.trim()
118
+ if (!s.startsWith('{') || !s.includes('"assistant"')) continue
119
+ let d: { type?: unknown; timestamp?: unknown; isApiErrorMessage?: unknown; message?: { model?: unknown } }
120
+ try {
121
+ d = JSON.parse(s)
122
+ } catch {
123
+ continue
124
+ }
125
+ if (d.type !== 'assistant' || d.isApiErrorMessage === true) continue
126
+ const model = d.message?.model
127
+ if (typeof model !== 'string' || !model || model === '<synthetic>') continue
128
+ const at = typeof d.timestamp === 'string' ? Date.parse(d.timestamp) : NaN
129
+ if (Number.isFinite(at) && at > eventAtMs) return true
130
+ }
131
+ return false
132
+ }
133
+
134
+ /** Codex: a turn that COMPLETED with a message after the event. Keyed on completed_at (replay-safe:
135
+ * a resumed rollout re-stamps line timestamps but keeps completed_at — see mutewatch.ts). */
136
+ export function codexProgressedSince(text: string, eventAtMs: number): boolean {
137
+ for (const line of text.split('\n')) {
138
+ const s = line.trim()
139
+ if (!s.startsWith('{') || !s.includes('task_complete')) continue
140
+ let d: { payload?: { type?: unknown; last_agent_message?: unknown; completed_at?: unknown } }
141
+ try {
142
+ d = JSON.parse(s)
143
+ } catch {
144
+ continue
145
+ }
146
+ const p = d.payload
147
+ if (!p || p.type !== 'task_complete') continue
148
+ if (typeof p.last_agent_message !== 'string' || !p.last_agent_message) continue
149
+ if (typeof p.completed_at === 'number' && p.completed_at * 1000 > eventAtMs) return true
150
+ }
151
+ return false
152
+ }
153
+
154
+ /** Did the parked session progress since its limit event? Unknown (no file / unreadable) → false:
155
+ * the nudge is cheap and a missed continuation is exactly the defect this exists for. */
156
+ export function parkProgressed(park: LimitPark): boolean {
157
+ if (!park.file) return false
158
+ const t = readSessionSlices(park.file, 512 * 1024)
159
+ if (!t) return false
160
+ if (park.runtime === 'claude') return claudeProgressedSince(t.tail, park.eventAtMs)
161
+ if (park.runtime === 'codex') return codexProgressedSince(t.tail, park.eventAtMs)
162
+ return false
163
+ }
@@ -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 {