@agfpd/iapeer 0.4.79 → 0.4.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.79",
3
+ "version": "0.4.81",
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",
@@ -121,36 +121,54 @@ export function nextNagAction(adapter: NagPredicate, viewport: string, enc: KeyE
121
121
  return { kind: 'none' }
122
122
  }
123
123
 
124
- // ── В60 — stuck-gate for the nag-watcher (timing signal, closer to the root than text) ────────────
125
- // A REAL blocked modal freezes the pty: claude stops writing entirely (the dialog is static and the
126
- // session cannot proceed). A LIVE peer that merely RENDERS the modal text — reviewing this code,
127
- // quoting a bug report, editing the adapter (the В40 live incident class) — keeps WRITING (streamed
128
- // output, spinner frames). So "the pane has written NOTHING for a while" discriminates a genuinely
129
- // stuck modal from a working peer displaying the same glyphs, where any text/position match
130
- // structurally cannot. The gate composes WITH the В40 position match (both must pass); the watcher
131
- // stays load-bearing (В59 suppression-at-source is expected but unproven) — this gate just limits its
132
- // fire to a genuinely wedged session.
124
+ /**
125
+ * Content signature of a NagAction for the stability gate (below) — identifies "the SAME wedged prompt"
126
+ * across ticks so a repainting modal is recognized as one persistent prompt, not fresh each frame.
127
+ * - 'approve' → taxonomy + the verbatim `detail` (the modal block for unknown-modal, the command/target
128
+ * trace for a known breaker) — stable and unique per prompt.
129
+ * - 'dismiss' → the dismiss keys (constant while a given nag is up).
130
+ * - 'none' → null (nothing actionable → the gate re-arms).
131
+ */
132
+ export function nagSignature(action: NagAction): string | null {
133
+ if (action.kind === 'approve') return `approve:${action.taxonomy}:${action.detail}`
134
+ if (action.kind === 'dismiss') return `dismiss:${action.keys.join(',')}`
135
+ return null
136
+ }
137
+
138
+ // ── В60/В61 — stability gate for the nag-watcher (content signature, NOT raw pty writes) ────────────
139
+ // The watcher must fire ONLY on a genuinely WEDGED prompt, never on a live peer merely RENDERING modal-
140
+ // like text (reviewing this code, quoting a bug report — the В40 incident class). The ORIGINAL В60 signal
141
+ // was "the pty wrote NOTHING for a while" (writeSeq frozen): a claude modal freezes the pty, a working
142
+ // peer keeps streaming. But codex DEFEATS that — it REPAINTS its native approval modal continuously (live
143
+ // repro eph-cdxdet 08.07: pane-log grew ~788 B every 2 s while the RENDERED modal was byte-identical), so
144
+ // writeSeq never froze and the watcher NEVER fired (0 events, peer hung on the modal). The correct signal
145
+ // is one level up: the DETECTED action's CONTENT is stable. A wedged modal holds the SAME signature across
146
+ // ticks even while its bytes churn; a working/streaming peer changes it (or shows no actionable modal) and
147
+ // re-arms. This composes WITH the adapter's structural match (detectNumberedModal's composer-replaced guard
148
+ // already rejects a quoted modal) — the gate just requires that match to PERSIST, so a transient render
149
+ // never fires. Covers BOTH runtimes: claude's static modal and codex's repainting one stabilize identically.
133
150
 
134
151
  /** Mutable gate state the caller owns (one per watched session). */
135
- export interface StuckGate {
136
- /** The last write-sequence observed (any pty output bumps it). -1 = not yet baselined. */
137
- lastSeq: number
138
- /** Wall-clock ms when the sequence last CHANGED (progress observed). */
152
+ export interface StabilityGate {
153
+ /** The last content signature observed (nagSignature). null = not yet baselined / nothing actionable. */
154
+ lastSig: string | null
155
+ /** Wall-clock ms when the signature last CHANGED. */
139
156
  stableSinceMs: number
140
157
  }
141
158
 
142
- export function newStuckGate(nowMs: number): StuckGate {
143
- return { lastSeq: -1, stableSinceMs: nowMs }
159
+ export function newStabilityGate(nowMs: number): StabilityGate {
160
+ return { lastSig: null, stableSinceMs: nowMs }
144
161
  }
145
162
 
146
163
  /**
147
- * One gate step: feed the current write-sequence + clock; returns true iff the pane has been
148
- * COMPLETELY static (no pty writes) for at least `thresholdMs`. Any progress re-arms the gate.
149
- * Pure w.r.t. time (clock injected) — unit-testable.
164
+ * One gate step: feed the current content signature + clock; returns true iff the SAME NON-NULL signature
165
+ * has persisted for at least `thresholdMs`. A null signature (nothing actionable) or a CHANGED signature
166
+ * re-arms the gate (returns false) and records the new stableSince. Pure w.r.t. time (clock injected) —
167
+ * unit-testable, and INDEPENDENT of raw pty write cadence (a repainting-but-content-stable modal fires).
150
168
  */
151
- export function paneIsStuck(gate: StuckGate, seq: number, nowMs: number, thresholdMs: number): boolean {
152
- if (seq !== gate.lastSeq) {
153
- gate.lastSeq = seq
169
+ export function signatureStable(gate: StabilityGate, sig: string | null, nowMs: number, thresholdMs: number): boolean {
170
+ if (sig === null || sig !== gate.lastSig) {
171
+ gate.lastSig = sig
154
172
  gate.stableSinceMs = nowMs
155
173
  return false
156
174
  }
@@ -27,7 +27,7 @@ import {
27
27
  } from './protocol.ts'
28
28
  import { formatEventLine } from '../storage/rotatelog.ts'
29
29
  import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry, writePidFile } from './paths.ts'
30
- import { newStuckGate, nextBootAction, nextNagAction, paneIsStuck, type BootPredicates, type NagPredicate } from './boot.ts'
30
+ import { newStabilityGate, nagSignature, nextBootAction, nextNagAction, signatureStable, type BootPredicates, type NagPredicate } from './boot.ts'
31
31
  import { modelToPlainText } from './render.ts'
32
32
  import { codexAdapter } from '../launch/adapters/codex.ts'
33
33
  import { claudeAdapter } from '../launch/adapters/claude.ts'
@@ -725,25 +725,18 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
725
725
  // (writeSeq advance) so a later, DIFFERENT modal is handled fresh.
726
726
  let unknownModalAttempts = 0
727
727
  let unknownModalParked = false
728
- let lastNagSeq = -1
729
- // В60 — stuck-gate: a REAL blocked modal freezes the pty entirely, while a live peer merely
730
- // RENDERING the modal text (code review / quoted report — the В40 false-fire class) keeps
731
- // writing. Fire the dismiss ONLY when the pane has been completely static (writeSeq unchanged)
732
- // for NAG_STUCK_MS — the timing signal composes with the adapter's position match.
733
- const stuckGate = newStuckGate(Date.now())
728
+ let lastNagSig: string | null = null
729
+ // В60/В61 — fire ONLY on a genuinely WEDGED prompt, gated on the detected modal/nag's CONTENT
730
+ // STABILITY, not raw pty writes: codex REPAINTS its native approval modal every ~2 s, so a writeSeq
731
+ // stuck-gate never froze and the watcher never fired (live repro eph-cdxdet 08.07: 0 events, peer hung).
732
+ // The SAME signature persisting for NAG_STUCK_MS is the "wedged" signal; a working/streaming peer changes
733
+ // it (or shows no modal) and re-arms. Composes with the adapter's structural match. See signatureStable.
734
+ const stabilityGate = newStabilityGate(Date.now())
734
735
  const nagWatch = setInterval(() => {
735
736
  if (down) {
736
737
  clearInterval(nagWatch)
737
738
  return
738
739
  }
739
- // Pane made PROGRESS since last tick → the modal (if any) cleared / the peer moved on: reset the
740
- // unknown-modal re-enqueue counters so a subsequent, different modal starts a fresh budget.
741
- if (writeSeq !== lastNagSeq) {
742
- lastNagSeq = writeSeq
743
- unknownModalAttempts = 0
744
- unknownModalParked = false
745
- }
746
- if (!paneIsStuck(stuckGate, writeSeq, Date.now(), NAG_STUCK_MS)) return // progressing → not a wedged modal
747
740
  if (Date.now() - nagFiredMs < NAG_COOLDOWN_MS) return // let a just-answered modal repaint away first
748
741
  let action
749
742
  try {
@@ -752,6 +745,16 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
752
745
  } catch {
753
746
  return // transient model read — retry next tick
754
747
  }
748
+ // Debounce on the detected action's CONTENT signature (not writeSeq — codex repaints its modal, so
749
+ // writeSeq churns while the modal is stable). A NEW/absent signature (a different modal, or the modal
750
+ // cleared) resets the unknown-modal re-enqueue budget so a later modal starts a fresh budget.
751
+ const sig = nagSignature(action)
752
+ if (sig !== lastNagSig) {
753
+ lastNagSig = sig
754
+ unknownModalAttempts = 0
755
+ unknownModalParked = false
756
+ }
757
+ if (!signatureStable(stabilityGate, sig, Date.now(), NAG_STUCK_MS)) return // not stable long enough → not wedged
755
758
  if (action.kind === 'dismiss') {
756
759
  child.terminal.write(action.bytes)
757
760
  nagFiredMs = Date.now()
@@ -307,7 +307,7 @@ async function deliverViaHost(
307
307
  // a short grace covers it. The receiver's own assistant/tool turn never reproduces the
308
308
  // `<iap from-personality=…>` wrapper, so it cannot forge a confirm. No such record within the grace →
309
309
  // false-FAIL (the sender retries) — NOT a false-OK (silent loss the contract forbids).
310
- const confirm = seam.confirmLanded ?? ((payload: string) => transcriptCarriesEnvelope(baseline, payload))
310
+ const confirm = seam.confirmLanded ?? ((payload: string) => transcriptCarriesEnvelope(baseline, payload, { env }))
311
311
  const sleep = seam.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
312
312
  const graceMs = confirmGraceMs()
313
313
  const graceDeadline = monotonicMs() + graceMs
@@ -579,20 +579,37 @@ function jsonStringValuesContain(value: unknown, needle: string): boolean {
579
579
  * the full envelope is UNFORGEABLE by a concurrent turn — no false-OK. Because only bytes PAST the
580
580
  * baseline offset are read, a pre-existing copy of an identical earlier message cannot pass either.
581
581
  *
582
- * Iterates the baseline file LIST verbatim — it does NOT re-scan the runtime's session dir each poll
583
- * (unlike the minutes-long compact-done gate). A delivery's confirm window is seconds, so a brand-new
584
- * session file appearing mid-delivery (a resume mid-flight) is negligible, and missing it fails in the
585
- * SAFE direction (false-FAIL → the sender retries, never a silent loss). For codex this also avoids a
586
- * full recursive `~/.codex/sessions` re-scan on every ~100ms poll (the active session file is already
587
- * in the baseline), keeping the hot delivery path cheap.
582
+ * Scans each baseline file from its captured offset AND any session file that APPEARS AFTER the baseline
583
+ * from offset 0 — the post-baseline pickup is load-bearing. A FRESH session (self-fresh/eager-fresh)
584
+ * writes its NEW jsonl LAZILY on the first delivered turn, so the confirming record for the FIRST delivery
585
+ * into that session lands in a file the baseline never captured (the fresh jsonl is born ~a beat AFTER the
586
+ * pre-deliver baseline snapshot). Iterating baseline-only made EVERY such first-delivery a false-FAIL: the
587
+ * dead OLD session file gets scanned, the live NEW one is ignored, and the confirm reported "not delivered"
588
+ * for a message the session had actually accepted + processed (verified live 2026-07-10: telegram-arthur →
589
+ * mrmechanic ok=false, yet the envelope stood in the fresh jsonl at +0.3s, within the grace). A
590
+ * post-baseline file is BRAND-NEW for this cwd, so reading it from 0 cannot match a pre-existing copy
591
+ * (nothing pre-existed), and the full `<iap from-personality=…>` envelope is unforgeable by the receiver's
592
+ * own assistant/tool turn — the SAME false-OK safety `compactTranscriptHasDone` already relies on. In
593
+ * production this loop runs ONLY for claude (codex confirms by the socket-ack BEFORE reaching here), so the
594
+ * per-poll re-list is a single cheap readdirSync of one project dir — never a recursive `~/.codex/sessions`
595
+ * walk. `env` scopes the re-list under a test HOME (default process.env).
588
596
  */
589
- export function transcriptCarriesEnvelope(baseline: CompactDoneBaseline, envelope: string): boolean {
597
+ export function transcriptCarriesEnvelope(
598
+ baseline: CompactDoneBaseline,
599
+ envelope: string,
600
+ opts: { env?: NodeJS.ProcessEnv } = {},
601
+ ): boolean {
590
602
  const needle = envelope.replace(/\r/g, '')
591
603
  if (!needle) return false
592
- for (const { path, size: offset } of baseline.files) {
593
- // Read ONLY the tail past the baseline offset — a full readFileSync here re-read the ENTIRE
594
- // transcript (hundreds of MB for a long-running peer) on every ~100ms confirm-poll, stalling the
595
- // single-threaded router event loop for the whole fleet.
604
+ // Baseline files at their pre-deliver offset; a file NOT in the baseline (born after it) is read from 0.
605
+ const offsets = new Map(baseline.files.map(f => [f.path, f.size]))
606
+ for (const path of compactCandidateFiles(baseline.runtime, baseline.cwd, opts.env)) {
607
+ if (!offsets.has(path)) offsets.set(path, 0)
608
+ }
609
+ for (const [path, offset] of offsets) {
610
+ // Read ONLY the tail past the offset — a full readFileSync here re-read the ENTIRE transcript
611
+ // (hundreds of MB for a long-running peer) on every ~100ms confirm-poll, stalling the single-threaded
612
+ // router event loop for the whole fleet.
596
613
  const tail = readFileTailFrom(path, offset)
597
614
  if (!tail) continue
598
615
  for (const line of tail.split(/\r?\n/)) {