@agfpd/iapeer 0.4.79 → 0.4.80

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.80",
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()