@agfpd/iapeer 0.4.93 → 0.4.95

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.
@@ -146,3 +146,65 @@ The approval surface is served on both daemon listeners under `/fleet/v1`, same
146
146
  ## Compatibility
147
147
 
148
148
  Additive within `/fleet/v1` (docs/15): the `approval-*` endpoints and `ev` kinds grow the surface without a version bump. A pre-approval daemon simply omits `approval_mode` from the snapshot and serves no `/approvals` endpoints; a client feature-detects by the presence of the field / a `200` from `GET /fleet/v1/approvals`.
149
+
150
+ ## What an EMPTY broker does and does not mean
151
+
152
+ Diagnosed live on 15.07.2026 after an owner-visible false alarm; recorded here because the
153
+ misreading is natural and cost three people an incident.
154
+
155
+ **An empty `/fleet/v1/approvals` is the NORMAL state of a yolo fleet, not a symptom.** In
156
+ `yolo` the supervisor auto-presses Yes for the known classes (`dangerous-rm`,
157
+ `command-approval`) and the broker is deliberately uninvolved — there is no human decision to
158
+ route, so `approvals.log` correctly stays silent. Only `gated` peers ever reach the broker.
159
+
160
+ **A circuit breaker fires even under `--dangerously-skip-permissions`** — that is what makes it
161
+ a breaker. Seeing a modal on a bypassed peer is not evidence that the bypass failed. The live
162
+ sample: `Dangerous rm operation on working directory or its ancestor: /tmp/codex-probe` on a
163
+ peer launched with the bypass flag; the supervisor auto-confirmed 11 s later (nag-poll
164
+ cadence) and the tool completed 180 ms after that. Nothing hung, nothing leaked.
165
+
166
+ A useful narrowing when diagnosing one of these: under `bypassPermissions` the permission
167
+ engine auto-allows **every** `ask` decision except reasons beginning `Dangerous rm operation` /
168
+ `Dangerous rmdir operation` (established from the 2.1.201 engine). So a live modal on a
169
+ bypassed peer is almost certainly that one class — the "the bypass broke" hypothesis can be
170
+ dropped before investigating it.
171
+
172
+ **The yolo auto-confirm IS audited, but only off-surface — and that is DECIDED, not pending.**
173
+ The supervisor writes `[supervisor] AUTO-CONFIRM <taxonomy> identity=<id> ts=<…> <detail>` to
174
+ its own stderr log (`~/.iapeer/state/supervisor/<identity>.log`). That line is **not** in
175
+ `approvals.log`, **not** in the Fleet API, **not** in the SSE stream, and therefore invisible to
176
+ tray / telegram / web. Measured frequency: 9 auto-confirms across 10 days over the whole real
177
+ fleet (8 `dangerous-rm`, 1 `command-approval`).
178
+
179
+ > **Decision (owner, 15.07.2026): auto-confirms are NOT surfaced as notices.** Proposed after a
180
+ > destructive auto-approval proved invisible to every operator surface; considered, rejected.
181
+ > Rationale: `yolo` **means** silent auto-confirmation — the owner chose it explicitly to stop
182
+ > being asked, so a card per auto-confirm would be precisely the noise he opted out of. Even the
183
+ > narrow variant (destructive class only, post-hoc, one-way) was declined. The audit stays in the
184
+ > supervisor log.
185
+ >
186
+ > **The honest boundary of that decision, accepted knowingly:** observability of auto-confirms
187
+ > lives ONLY in `~/.iapeer/state/supervisor/<identity>.log`. You must know that file exists and
188
+ > that there is a reason to grep it. This is not a defect — it is the price of `yolo`, chosen
189
+ > deliberately. Re-proposing "let's just show them" re-opens a settled question: the counter-
190
+ > argument is above, and the frequency number is the reason it does not accumulate.
191
+
192
+ **The runtime's OWN notifications are outside this contour.** Claude Code pushes a native
193
+ "Claude needs your permission: Bash …" notification to the owner's phone when a modal appears —
194
+ including modals this contour auto-resolves seconds later. It is not ours (no `Notification`
195
+ hook exists in any settings here), we do not control it, and it is indistinguishable from a
196
+ real approval request. An owner-visible push therefore does NOT imply the broker saw anything.
197
+ Whether `preferredNotifChannel` (a real Claude Code setting) can silence this class is
198
+ **unverified**.
199
+
200
+ ## Known unverified area: modals from a child runtime process
201
+
202
+ A peer's turn can spawn its own `claude` / `codex` child process (e.g. `claude -p …` inside a
203
+ Bash call). Such a child does **not** carry the peer's launch flags and its modal does **not**
204
+ render in the peer's pane — so the supervisor's nag-watch, the breaker path and the broker are
205
+ all structurally blind to it.
206
+
207
+ This is **not known to be a problem and not known to be safe: it has never been tested.** It is
208
+ recorded here so nobody later concludes the contour covers it. The scenario is real — it
209
+ occurred on this host on 15.07 during live acceptance (`claude --model fable -p ping` fired
210
+ from a peer's turn). Worth probing when there is a reason; not measured to date.
@@ -117,10 +117,10 @@ resets, the plan) — data, never our interpretation of the cause.
117
117
  | `summary` | string | one line, e.g. `boris · claude — rate_limit (Fable 5)` |
118
118
  | `content` | string | verbatim (claude) / rendered from typed fields (codex) |
119
119
  | `sessionId` | string? | correlates with the on-disk transcript |
120
- | `createdMs` | number | first detection |
121
- | `lastMs` | number | most recent detection folded in |
120
+ | `createdMs` | number | when the notice was raised (board clock — the TTL anchor) |
121
+ | `lastMs` | number | when the latest occurrence was folded in (board clock). A sweep that merely re-reads an already-counted event does not move it. |
122
122
  | `expiresMs` | number | TTL boundary |
123
- | `count` | number | detections folded into this notice (≥1) — render as `×N` |
123
+ | `count` | number | distinct OCCURRENCES folded in (≥1) — how many times the peer actually hit the wall. Render as `×N`. The sweep window overlaps on purpose, so the same runtime event is re-read across passes; the board discriminates on the event's own timestamp, and a re-read never bumps this. |
124
124
 
125
125
  ### Snapshot
126
126
 
@@ -148,6 +148,40 @@ ignore unknown `ev` kinds as always.
148
148
  4. **Unknown `kind` / `errorType` ⇒ still render.** Both are growth seams.
149
149
  5. **Do not key on `id` across daemon restarts** — the board is in-memory; a restart
150
150
  re-detects from the on-disk evidence and issues new ids.
151
+ 6. **On startup, seed the board SILENTLY.** Whatever is already live when your face comes up
152
+ is history: mark it seen and deliver none of it. Deliver only notices raised *while you
153
+ were running* — i.e. whose `createdMs` is later than your own start.
154
+
155
+ #### Why obligation 6 exists, and what it costs
156
+
157
+ A notice is one-way: nobody is waiting on it, nothing unblocks. So a face restart is an event
158
+ in the **face's** life, not news about the fleet — and re-announcing the live board because
159
+ *you* redeployed tells the owner nothing he did not already know. It is your deploy, buzzing
160
+ his phone.
161
+
162
+ This is not hypothetical: on 15.07.2026 a telegram-runtime deploy re-delivered all five live
163
+ cards; the web-runtime, from the same board and the same version of this document, seeded
164
+ silently. Neither face was wrong — **the contract was silent, so each guessed, and they
165
+ guessed differently.** That is what this obligation ends.
166
+
167
+ **The cost, named:** a notice raised while your face was DOWN is never delivered by you.
168
+ Accepted, because it is bounded and self-healing:
169
+
170
+ - if the peer recovered during your downtime, the notice was moot — there was nothing to act on;
171
+ - if the peer is **still** broken, the board re-raises a FRESH notice when the TTL expires
172
+ (§5). Its `createdMs` is then later than your start, so you deliver it. The worst case is a
173
+ delay of one TTL, not a permanent loss.
174
+
175
+ **Approvals are the OPPOSITE — do not copy this rule across (docs/17).** A pending approval
176
+ has a human on the other end and a ≤300 s default-deny deadline: a face that starts up and
177
+ stays quiet about it lets the request time out into a denial. So approvals *are* delivered on
178
+ startup. The discriminator is not which surface you are, it is **whether anything is blocked
179
+ waiting on a human**. Notices block nothing; approvals block a peer's tool call.
180
+
181
+ **Implementation:** record your start time at boot and compare `createdMs`. No persisted
182
+ seen-set is needed, and it stays correct across a daemon restart — a restarted daemon
183
+ re-detects from the on-disk evidence and stamps newer `createdMs`, so genuinely current
184
+ conditions are delivered rather than suppressed.
151
185
 
152
186
  ## 5. Dedup and TTL — one mechanism
153
187
 
@@ -156,6 +190,12 @@ A mute peer re-emits its error on **every** attempted turn. So a notice carries
156
190
  a repeat detection only bumps `count` and `lastMs`: **no new id, no new log line, no new SSE
157
191
  event**. The owner sees one card saying `×7`, not seven cards.
158
192
 
193
+ `count` means **occurrences, not sweeps**. The sweep window deliberately overlaps (so an event
194
+ landing between two passes is never missed), which means the same transcript line is re-read on
195
+ the next pass. The board therefore discriminates on the runtime event's OWN timestamp: a re-read
196
+ of an already-counted event folds silently and bumps nothing. Without that, two real refusals
197
+ would render as `×3` — a number the owner cannot check and that we would have made up.
198
+
159
199
  When the TTL passes (`IAPEER_NOTICE_TTL_MS`, default 1 h) the notice expires silently. If the
160
200
  peer is still broken, the next detection raises a **fresh** notice — a deliberate periodic
161
201
  reminder rather than one card that scrolls away forever. Expiry is not an event: either the
@@ -193,3 +233,45 @@ ignored — a human's own session never notifies.
193
233
  raw fact even if the heuristic picks wrong.
194
234
 
195
235
  Forensic evidence for all of the above: `docs/internals/forensics/model-limit-2026-07-15/`.
236
+
237
+ ## 8. Known hole: codex generic API errors — measured, not assumed
238
+
239
+ **A codex peer left mute by anything other than a rate limit is not detected.** Its
240
+ rate-limit path — the actual incident class — IS covered by `rate_limit_reached_type`. Mute
241
+ from overload or an expired auth is not.
242
+
243
+ This is a property of codex, not an omission here. Measured on 15.07.2026:
244
+
245
+ - **A real 429 leaves no trace in the rollout.** An isolated codex (`CODEX_HOME` on a temp
246
+ tree, a custom `model_provider` pointed at a local endpoint — the live fleet untouched) was
247
+ fault-injected with a genuine HTTP 429. Codex's OWN retry and error path ran and the turn
248
+ died: `ERROR: exceeded retry limit, last status: 429 Too Many Requests`. Its rollout
249
+ contains: `session_meta`, `task_started`, three `response_item/message`, `world_state`,
250
+ `turn_context`, `user_message`, `task_complete`. **Zero error items.**
251
+ - **Worse: the rollout affirmatively says the turn SUCCEEDED.** It records
252
+ `task_complete { last_agent_message: null }` for the turn that just died on a 429. The
253
+ rollout is not merely silent about the failure — for this purpose it is wrong.
254
+ - **Corroborated across real history**: 43 real rollouts, ~6147 `token_count` events, zero
255
+ error events of any kind; `TurnAbortedReason`'s variants (`interrupted`, `review_ended`,
256
+ `budget_limited`, `other`) contain no API-error member; codex's own `logs_2.sqlite` holds
257
+ exactly one ERROR row, unrelated (a model-refresh timeout).
258
+
259
+ So codex's only structural error surface is the `rate_limits` snapshot this detector already
260
+ reads. The claude/codex asymmetry in §2 is that fact, not a shortcut.
261
+
262
+ **Rejected, deliberately:** `task_complete` with `last_agent_message: null` is the only tell
263
+ that the turn failed — and it is an **absence, not a signal**. A detector on it would fire on
264
+ every turn that legitimately produced no agent message. Inferring "mute" from it would be the
265
+ exact plausible-inference defect this whole note exists to prevent; it is not built.
266
+
267
+ **Not answered:** whether `stream_error` — a real serde variant of codex's `EventMsg`, next to
268
+ `deprecation_notice` and `patch_apply_begin` — is persisted to the rollout. Codex filters
269
+ which `event_msg` kinds land there (`patch_apply_end` does, `patch_apply_begin` does not), and
270
+ it has never appeared in any rollout here. A mid-stream-break probe was run; codex retries a
271
+ broken stream with a long backoff and did not terminate inside the time box. **This is left
272
+ unanswered rather than inferred from the 429 result.**
273
+
274
+ **Caveat on the measurement:** the probe drove a custom `model_provider`. The real ChatGPT
275
+ provider path could in principle record something this one did not.
276
+
277
+ Artifacts (rollout, injector, config): `docs/internals/forensics/model-limit-2026-07-15/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.93",
3
+ "version": "0.4.95",
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",
@@ -367,6 +367,9 @@ export function muteWatchTick(deps: MuteWatchDeps, sinceMs: number): { raised: n
367
367
  resetsAtMs: d.resetsAtMs,
368
368
  content: d.content,
369
369
  sessionId: d.sessionId,
370
+ // The runtime event's OWN timestamp — lets the board tell a new occurrence from the
371
+ // same line re-read by the deliberately-overlapping sweep window.
372
+ eventAtMs: d.atMs,
370
373
  summary: `${d.personality} · ${d.runtime} — ${d.errorType}${d.model ? ` (${d.model})` : ''}`,
371
374
  })
372
375
  if (!deduped) raised += 1
@@ -51,18 +51,29 @@ export interface NoticeInput {
51
51
  content: string
52
52
  /** The session that hit it (correlation with the transcript on disk). */
53
53
  sessionId?: string
54
+ /** When the underlying runtime event happened (the transcript line's own timestamp) —
55
+ * NOT when we swept. This is what makes `count` mean OCCURRENCES rather than sweeps:
56
+ * the sweep window deliberately overlaps, so the same line is re-read across passes.
57
+ * Absent → every raise counts (the caller has no event clock to dedup against). */
58
+ eventAtMs?: number
54
59
  }
55
60
 
56
61
  export interface Notice extends NoticeInput {
57
62
  id: string
58
63
  summary: string
59
64
  dedupKey: string
65
+ /** When the notice was raised (board clock — the TTL anchor, hence not the event's own
66
+ * timestamp: expiry must be measured from when the owner could first see it). */
60
67
  createdMs: number
61
- /** Last time this same condition was re-detected (dedup bump). */
68
+ /** When the latest OCCURRENCE was folded in (board clock). A sweep that merely re-reads an
69
+ * already-counted event does NOT move it — only a genuinely new occurrence does. */
62
70
  lastMs: number
63
71
  expiresMs: number
64
- /** How many times the condition was detected while this notice stayed live (≥1). */
72
+ /** Distinct occurrences folded into this notice (≥1) — how many times the peer actually
73
+ * hit the wall, which is what "×N" claims to the owner. */
65
74
  count: number
75
+ /** The newest event timestamp already counted — the overlap discriminator. */
76
+ lastEventMs?: number
66
77
  }
67
78
 
68
79
  export interface NoticeBoardOptions {
@@ -122,8 +133,16 @@ export class NoticeBoard {
122
133
  const dedupKey = noticeDedupKey(input)
123
134
  const existing = this.live.get(dedupKey)
124
135
  if (existing) {
125
- existing.count += 1
126
- existing.lastMs = nowMs
136
+ // Same condition. Is this a NEW occurrence, or the same runtime event re-read by an
137
+ // overlapping sweep? Counting the latter would inflate "×N" into a claim the owner
138
+ // cannot check — 2 real refusals rendering as ×3. Only a strictly newer event counts.
139
+ const at = input.eventAtMs
140
+ const isNewOccurrence = at === undefined || existing.lastEventMs === undefined || at > existing.lastEventMs
141
+ if (isNewOccurrence) {
142
+ existing.count += 1
143
+ existing.lastMs = nowMs
144
+ if (at !== undefined) existing.lastEventMs = at
145
+ }
127
146
  return { notice: existing, deduped: true }
128
147
  }
129
148
  const id = `n${++this.counter}`
@@ -137,6 +156,7 @@ export class NoticeBoard {
137
156
  lastMs: nowMs,
138
157
  expiresMs: nowMs + this.ttlMs,
139
158
  count: 1,
159
+ lastEventMs: input.eventAtMs,
140
160
  }
141
161
  this.live.set(dedupKey, notice)
142
162
  appendNoticeEvent(
@@ -574,13 +574,63 @@ export function compactTranscriptHasDone(
574
574
 
575
575
  /** Deep-search every string value of a parsed JSON record for `needle` (CR-stripped on the haystack
576
576
  * side; the needle is pre-stripped by the caller). */
577
- function jsonStringValuesContain(value: unknown, needle: string): boolean {
578
- if (typeof value === 'string') return value.replace(/\r/g, '').includes(needle)
579
- if (Array.isArray(value)) return value.some(v => jsonStringValuesContain(v, needle))
580
- if (value && typeof value === 'object') return Object.values(value).some(v => jsonStringValuesContain(v, needle))
577
+ /** ALL needles must be present in ONE string value — not scattered across the record. Splitting
578
+ * the confirm into head+tail (see confirmNeedles) only stays as forge-resistant as the whole
579
+ * envelope if both halves are found in the SAME string the receiver actually stored. */
580
+ function jsonStringValuesContainAll(value: unknown, needles: readonly string[]): boolean {
581
+ if (typeof value === 'string') {
582
+ const s = value.replace(/\r/g, '')
583
+ return needles.every(n => s.includes(n))
584
+ }
585
+ if (Array.isArray(value)) return value.some(v => jsonStringValuesContainAll(v, needles))
586
+ if (value && typeof value === 'object') return Object.values(value).some(v => jsonStringValuesContainAll(v, needles))
581
587
  return false
582
588
  }
583
589
 
590
+ /**
591
+ * The substrings whose presence in ONE new record proves the receiver took THIS envelope.
592
+ *
593
+ * Normally that is the whole envelope: long, unique, and a receiver's own turn does not
594
+ * reproduce it verbatim, so it cannot forge a confirm.
595
+ *
596
+ * WITH ATTACHMENTS the whole envelope is the wrong needle, because the receiver REWRITES it.
597
+ * Measured 15.07.2026 (claude receiver, 4 PNGs). Sent:
598
+ *
599
+ * <iap …>⏎Reply via send_to_peer.⏎<attachments>a.png⏎…⏎d.png</attachments>⏎<msg>…</msg>⏎</iap>
600
+ *
601
+ * Stored by the receiver:
602
+ *
603
+ * [Image #108] [Image #109]<iap …>⏎Reply via send_to_peer.⏎d.png</attachments>⏎<msg>…</msg>⏎</iap>
604
+ *
605
+ * The runtime hoists the images into separate image blocks, prepends `[Image #N]` placeholders,
606
+ * and eats the OPENING `<attachments>` tag plus the paths it consumed. The envelope no longer
607
+ * contains itself → no match → a 4 s false-FAIL on a message that DID land. The sender then
608
+ * retries and duplicates it in the receiver's context (three times, live, before this fix).
609
+ *
610
+ * The mutation is structurally CONFINED to the attachments region: the `<iap …>` header and the
611
+ * whole `<msg>…</msg></iap>` tail were byte-intact in the stored record. So we match around the
612
+ * damage — head + tail, both required in the same string value.
613
+ *
614
+ * NOT a short unique token in the header, which is the tempting fix: a short, predictable needle
615
+ * is one the receiver can reproduce in its OWN turn (agents quote IAP headers when reporting),
616
+ * and that forges a confirm. A false-OK is silent loss — the one outcome this contract forbids —
617
+ * while a false-FAIL merely costs a retry. Head+tail keeps the original forge-resistance bar:
618
+ * to fake it the receiver must echo both the header and the entire body verbatim.
619
+ */
620
+ export function confirmNeedles(envelope: string): string[] {
621
+ const n = envelope.replace(/\r/g, '')
622
+ const OPEN = '<attachments>'
623
+ const CLOSE = '</attachments>'
624
+ const open = n.indexOf(OPEN)
625
+ if (open < 0) return [n] // no attachments → nothing rewrites the envelope → strongest needle
626
+ const close = n.indexOf(CLOSE, open + OPEN.length)
627
+ if (close < 0) return [n] // malformed → fall back to the whole envelope rather than guess
628
+ const head = n.slice(0, open).trimEnd()
629
+ const tail = n.slice(close + CLOSE.length).trimStart()
630
+ const parts = [head, tail].filter(p => p.length > 0)
631
+ return parts.length ? parts : [n]
632
+ }
633
+
584
634
  /**
585
635
  * Did a NEW transcript/session-jsonl record CARRYING `envelope` appear since `baseline` (the
586
636
  * per-runtime absolute file paths + SIZES captured immediately before delivery)? This is the
@@ -618,8 +668,8 @@ export function transcriptCarriesEnvelope(
618
668
  envelope: string,
619
669
  opts: { env?: NodeJS.ProcessEnv } = {},
620
670
  ): boolean {
621
- const needle = envelope.replace(/\r/g, '')
622
- if (!needle) return false
671
+ const needles = confirmNeedles(envelope)
672
+ if (!needles.length || needles.every(n => !n)) return false
623
673
  // Baseline files at their pre-deliver offset; a file NOT in the baseline (born after it) is read from 0.
624
674
  const offsets = new Map(baseline.files.map(f => [f.path, f.size]))
625
675
  for (const path of compactCandidateFiles(baseline.runtime, baseline.cwd, opts.env)) {
@@ -639,7 +689,7 @@ export function transcriptCarriesEnvelope(
639
689
  } catch {
640
690
  continue
641
691
  }
642
- if (jsonStringValuesContain(obj, needle)) return true
692
+ if (jsonStringValuesContainAll(obj, needles)) return true
643
693
  }
644
694
  }
645
695
  return false