@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 +1 -1
- package/src/supervisor/boot.ts +39 -21
- package/src/supervisor/daemon.ts +18 -15
- package/src/transport/index.ts +29 -12
package/package.json
CHANGED
package/src/supervisor/boot.ts
CHANGED
|
@@ -121,36 +121,54 @@ export function nextNagAction(adapter: NagPredicate, viewport: string, enc: KeyE
|
|
|
121
121
|
return { kind: 'none' }
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
136
|
-
/** The last
|
|
137
|
-
|
|
138
|
-
/** Wall-clock ms when the
|
|
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
|
|
143
|
-
return {
|
|
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
|
|
148
|
-
*
|
|
149
|
-
* Pure w.r.t. time (clock injected) —
|
|
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
|
|
152
|
-
if (
|
|
153
|
-
gate.
|
|
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
|
}
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
729
|
-
// В60 —
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
// for NAG_STUCK_MS
|
|
733
|
-
|
|
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()
|
package/src/transport/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
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(
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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/)) {
|