@agfpd/iapeer 0.4.60 → 0.4.62
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/launch/adapters/claude.ts +44 -0
- package/src/launch/types.ts +16 -0
- package/src/supervisor/boot.ts +19 -9
- package/src/supervisor/daemon.ts +39 -3
package/package.json
CHANGED
|
@@ -100,6 +100,35 @@ function isFullscreenNagOptionRow(row: string): boolean {
|
|
|
100
100
|
return row.includes('❯') && (row.includes(NAG_OPTION_YES) || /❯\s*1\.\s*Yes/.test(row))
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
// Dangerous-rm circuit-breaker needles — split-literal (same В40 discipline: a peer reviewing THIS
|
|
104
|
+
// adapter / the doctrine must not self-trigger). Verified verbatim against claude 2.1.201 (Mach-O
|
|
105
|
+
// strings + a live pty capture): the breaker fires for `rm` AND `rmdir` on the cwd/an ancestor, above
|
|
106
|
+
// the permission layer, with a `❯ 1. Yes / 2. No` select whose default cursor is on YES.
|
|
107
|
+
const RM_ANCESTOR = 'working directory or its ' + 'ancestor'
|
|
108
|
+
const RM_PROCEED = 'Do you want to ' + 'proceed?'
|
|
109
|
+
/** FULL-signature + position match for the LIVE dangerous-rm/rmdir confirm (not a mere quote of it):
|
|
110
|
+
* the breaker phrase + the proceed prompt + the bottom-most `❯` cursor sitting on the "1. Yes" option. */
|
|
111
|
+
function isDangerousRmPrompt(pane: string): boolean {
|
|
112
|
+
if (!pane.includes(RM_ANCESTOR) || !pane.includes(RM_PROCEED)) return false
|
|
113
|
+
let lastCursorRow = ''
|
|
114
|
+
for (const line of pane.split(/\r?\n/)) if (line.includes('❯')) lastCursorRow = line
|
|
115
|
+
return /❯\s*1\.\s*Yes\b/.test(lastCursorRow) // live select on Yes; a quote has the ready composer `❯` below
|
|
116
|
+
}
|
|
117
|
+
/** Best-effort one-line trace of WHAT the breaker guarded — the rm/rmdir command line if the pane still
|
|
118
|
+
* shows the "Bash command" block, plus the target path printed after "…ancestor:". Owner's post-hoc
|
|
119
|
+
* audit hook (which peer / which command / when — `when` + `who` are added by the daemon). */
|
|
120
|
+
function dangerousRmDetail(pane: string): string {
|
|
121
|
+
const lines = pane.split(/\r?\n/).map(l => l.trim())
|
|
122
|
+
let target = ''
|
|
123
|
+
const ai = lines.findIndex(l => l.includes(RM_ANCESTOR))
|
|
124
|
+
if (ai >= 0) for (let j = ai + 1; j < lines.length; j++) if (lines[j]) { target = lines[j]; break }
|
|
125
|
+
// ANCHORED to a line that STARTS with rm/rmdir (the "Bash command" block's command line, e.g.
|
|
126
|
+
// "rm -rf /path"); never a mid-line match, or the composer's echoed user prompt ("…run: rm -rf")
|
|
127
|
+
// would be captured instead of the actual command.
|
|
128
|
+
const cmd = lines.find(l => /^rm(?:dir)?\s+\S/.test(l)) ?? ''
|
|
129
|
+
return [cmd && `cmd=${JSON.stringify(cmd)}`, target && `target=${JSON.stringify(target)}`].filter(Boolean).join(' ') || '(unparsed)'
|
|
130
|
+
}
|
|
131
|
+
|
|
103
132
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
104
133
|
// Transcript activity proxy + resume uuid (lifecycle port, claude slug fix)
|
|
105
134
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -299,6 +328,21 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
299
328
|
return isFullscreenNagOptionRow(lastCursorRow) ? ['2', 'Enter'] : null
|
|
300
329
|
},
|
|
301
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Dangerous-rm/rmdir circuit-breaker (RuntimeAdapter.blockingConfirm). Unlike the fullscreen nag
|
|
333
|
+
* (benign upsell → DECLINE), this guard sits ABOVE `--dangerously-skip-permissions` and hangs a
|
|
334
|
+
* headless peer forever with no human to answer. Owner decision (Артур, 04.07): auto-press YES —
|
|
335
|
+
* peers run on bypass, so this only restores the pre-2.1.x status quo, and a hang is real recurring
|
|
336
|
+
* harm. The default cursor is already on "1. Yes" (verified live), so ['1','Enter'] is the explicit,
|
|
337
|
+
* position-robust affirmative. Returns the class tag + a one-line command/target trace so the
|
|
338
|
+
* supervisor logs a post-hoc audit line. NB deliberately NOT a "smart" safe-decline/escalation — that
|
|
339
|
+
* lands later on the vault idea "Human-approval подтверждения действий пира через Telegram-кнопки".
|
|
340
|
+
*/
|
|
341
|
+
blockingConfirm(pane: string): { keys: string[]; taxonomy: string; detail: string } | null {
|
|
342
|
+
if (!isDangerousRmPrompt(pane)) return null
|
|
343
|
+
return { keys: ['1', 'Enter'], taxonomy: 'dangerous-rm', detail: dangerousRmDetail(pane) }
|
|
344
|
+
},
|
|
345
|
+
|
|
302
346
|
/**
|
|
303
347
|
* Ready for the first message iff the input surface is rendered AND no startup
|
|
304
348
|
* dialog is still up (lifecycle.claudeInputReady, index.ts:272-279;
|
package/src/launch/types.ts
CHANGED
|
@@ -299,6 +299,22 @@ export interface RuntimeAdapter {
|
|
|
299
299
|
*/
|
|
300
300
|
nagDismissKeys?(pane: string): string[] | null
|
|
301
301
|
|
|
302
|
+
/**
|
|
303
|
+
* MID-SESSION blocking CIRCUIT-BREAKER confirm (livability + safety). A sibling of nagDismissKeys
|
|
304
|
+
* for a DIFFERENT class: an interactive confirm that even `--dangerously-skip-permissions` does NOT
|
|
305
|
+
* bypass because it sits ABOVE the permission layer — the "Dangerous rm operation on working
|
|
306
|
+
* directory or its ancestor / Do you want to proceed? 1.Yes 2.No" guard claude 2.1.x added (no env,
|
|
307
|
+
* settings, or flag disables it). A headless peer has no human to press 1/2, so it HANGS forever
|
|
308
|
+
* (live-observed on a verification session). Owner decision (Артур, 04.07): auto-press YES — peers
|
|
309
|
+
* already run on bypass, so this only restores the pre-2.1.x status quo; a hang is real recurring
|
|
310
|
+
* harm. Return `{keys, taxonomy, detail}` (the affirmative keys + a class tag + a one-line trace of
|
|
311
|
+
* WHAT was approved) so the supervisor can press it AND leave a post-hoc log; else null. Match the
|
|
312
|
+
* FULL signature + cursor position so ordinary content mentioning the phrase never fires. Optional
|
|
313
|
+
* (codex/router omit it). tui only. FUTURE: replace blind-YES with human approval via Telegram —
|
|
314
|
+
* see the vault idea "Human-approval подтверждения действий пира через Telegram-кнопки".
|
|
315
|
+
*/
|
|
316
|
+
blockingConfirm?(pane: string): { keys: string[]; taxonomy: string; detail: string } | null
|
|
317
|
+
|
|
302
318
|
/** Is the input surface ready for the first message? (tui: ready marker present
|
|
303
319
|
* AND startup dialogs gone). Router runtimes return true (no input surface). */
|
|
304
320
|
isInputReady(pane: string): boolean
|
package/src/supervisor/boot.ts
CHANGED
|
@@ -33,23 +33,33 @@ export function nextBootAction(adapter: BootPredicates, viewport: string, enc: K
|
|
|
33
33
|
return { kind: 'wait' }
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/** The slice of a RuntimeAdapter the MID-SESSION nag-watcher consumes — the optional
|
|
37
|
-
*
|
|
36
|
+
/** The slice of a RuntimeAdapter the MID-SESSION nag-watcher consumes — the optional nagDismissKeys
|
|
37
|
+
* (benign upsell → silent decline) + blockingConfirm (circuit-breaker → affirmative + audit log).
|
|
38
|
+
* claudeAdapter satisfies both; codex/router omit them → no action. */
|
|
38
39
|
export interface NagPredicate {
|
|
39
40
|
nagDismissKeys?(pane: string): string[] | null
|
|
41
|
+
blockingConfirm?(pane: string): { keys: string[]; taxonomy: string; detail: string } | null
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
export type NagAction =
|
|
44
|
+
export type NagAction =
|
|
45
|
+
| { kind: 'dismiss'; keys: string[]; bytes: Buffer } // benign upsell — press keys silently
|
|
46
|
+
| { kind: 'approve'; keys: string[]; bytes: Buffer; taxonomy: string; detail: string } // circuit-breaker — press keys + LOG
|
|
47
|
+
| { kind: 'none' }
|
|
43
48
|
|
|
44
49
|
/**
|
|
45
|
-
* MID-SESSION nag/
|
|
46
|
-
* boot-driver
|
|
47
|
-
*
|
|
48
|
-
* a
|
|
49
|
-
*
|
|
50
|
-
*
|
|
50
|
+
* MID-SESSION nag/confirm step (livability) — the persistent sibling of nextBootAction. Unlike the
|
|
51
|
+
* boot-driver (which STOPS at ready), the daemon loops this for the WHOLE session for TWO classes of
|
|
52
|
+
* mid-session blocking prompt no headless peer can answer:
|
|
53
|
+
* 1. a CIRCUIT-BREAKER confirm (blockingConfirm — e.g. the dangerous-rm guard above the permission
|
|
54
|
+
* layer) → press the affirmative AND surface a `detail`/`taxonomy` the caller logs (owner audit);
|
|
55
|
+
* 2. a benign UPSELL modal (nagDismissKeys — e.g. "Try the new fullscreen-renderer?") → decline silently.
|
|
56
|
+
* The circuit-breaker is checked FIRST (safety-relevant). The caller writes `action.bytes` (cooldown- +
|
|
57
|
+
* stuck-gate-guarded so a cleared prompt is never double-answered). An adapter with neither → 'none'.
|
|
51
58
|
*/
|
|
52
59
|
export function nextNagAction(adapter: NagPredicate, viewport: string, enc: KeyEncoding = {}): NagAction {
|
|
60
|
+
const confirm = adapter.blockingConfirm?.(viewport)
|
|
61
|
+
if (confirm && confirm.keys.length)
|
|
62
|
+
return { kind: 'approve', keys: confirm.keys, bytes: keysToBytes(confirm.keys, enc), taxonomy: confirm.taxonomy, detail: confirm.detail }
|
|
53
63
|
const keys = adapter.nagDismissKeys?.(viewport)
|
|
54
64
|
if (keys && keys.length) return { kind: 'dismiss', keys, bytes: keysToBytes(keys, enc) }
|
|
55
65
|
return { kind: 'none' }
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -450,17 +450,37 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
450
450
|
// and a busy child's tick cadence has quiet gaps — the stability loop returns in such a gap
|
|
451
451
|
// BEFORE the redraw arrives (proven by the repro byte log: the \x1b[NA redraw came as frame #2,
|
|
452
452
|
// after the snapshot), which re-creates the dup exactly.
|
|
453
|
-
const awaitSeqBump = async (timeoutMs: number): Promise<
|
|
453
|
+
const awaitSeqBump = async (timeoutMs: number): Promise<boolean> => {
|
|
454
454
|
const seq = writeSeq
|
|
455
455
|
const deadline = Date.now() + timeoutMs
|
|
456
456
|
while (writeSeq === seq && Date.now() < deadline) await Bun.sleep(15)
|
|
457
|
+
return writeSeq !== seq // true = the child actually emitted its SIGWINCH redraw (vs a silent child)
|
|
457
458
|
}
|
|
458
459
|
const firstAttach = async (s: { write(b: Buffer): number }, c: number, r: number): Promise<void> => {
|
|
459
460
|
await awaitModelParsedStable()
|
|
461
|
+
// ORPHAN-SCROLLBACK on a shrinking attach (Артур welcome-attach corruption, 04.07 — live repro):
|
|
462
|
+
// the model is hosted at HOST 220x50; a real terminal attaches NARROWER (e.g. 120x30). applyGeometry
|
|
463
|
+
// REFLOWS the model (220→120): every wide line rewraps TALLER, and with fewer rows the TOP of the
|
|
464
|
+
// pre-resize viewport SPILLS into scrollback. The child's SIGWINCH redraw repaints only the VIEWPORT
|
|
465
|
+
// (a relative-cursor TUI can't reach scrollback), so the spilled rows linger as stale, reflow-wrapped
|
|
466
|
+
// orphans ABOVE the clean repaint — the "two frames overlaid + left-clipped" screenshot. This is a
|
|
467
|
+
// DIFFERENT artifact from the 0.4.59 viewport-dup (that stacked inside the viewport; this is scrollback).
|
|
468
|
+
const preBaseY = xterm.buffer.active.baseY // committed scrollback history depth BEFORE the resize
|
|
460
469
|
if (c !== cols || r !== rows) {
|
|
461
470
|
applyGeometry(c, r) // model + child together — one honest SIGWINCH, BEFORE the snapshot
|
|
462
|
-
await awaitSeqBump(SIGWINCH_REDRAW_WAIT_MS) // the redraw wave ARRIVES (or the child has none)
|
|
471
|
+
const redrew = await awaitSeqBump(SIGWINCH_REDRAW_WAIT_MS) // the redraw wave ARRIVES (or the child has none)
|
|
463
472
|
await awaitModelParsedStable() // …then drain it to stability (bounded, В25 cap)
|
|
473
|
+
// Erase the orphan scrollback ONLY when it is PROVABLY 100% orphan: preBaseY===0 means there was NO
|
|
474
|
+
// committed history — the ENTIRE pre-resize screen was the child's dynamic tree, which the redraw has
|
|
475
|
+
// now fully repainted into the viewport, so every post-resize scrollback row is a redundant spilled
|
|
476
|
+
// copy. Erase it server-side (ESC[3J on the MODEL → serialize then carries only the clean viewport;
|
|
477
|
+
// no clear reaches the client's native scrollback). GUARDED on an actual redraw (a silent child keeps
|
|
478
|
+
// its only copy) and on new scrollback existing. preBaseY>0 (real scroll-up history) is left intact:
|
|
479
|
+
// the history/dynamic-tree seam lives inside the child's renderer and can't be derived here, so we
|
|
480
|
+
// never risk dropping real history — that narrower case is a separate follow-up.
|
|
481
|
+
if (redrew && preBaseY === 0 && xterm.buffer.active.baseY > 0) {
|
|
482
|
+
await new Promise<void>(res => xterm.write('\x1b[3J', () => res())) // eraseScrollback + parse barrier
|
|
483
|
+
}
|
|
464
484
|
}
|
|
465
485
|
sendSnapshot(s) // serialize the post-redraw model → send → lift gate (synchronous, no interleave)
|
|
466
486
|
}
|
|
@@ -623,7 +643,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
623
643
|
// NAG_COOLDOWN_MS so a just-dismissed modal is never double-answered (a stray keystroke would land in
|
|
624
644
|
// the now-visible composer). Only runs for a runtime whose adapter declares nagDismissKeys.
|
|
625
645
|
const nagAdapter = BOOT_ADAPTERS[runtime] as NagPredicate | undefined
|
|
626
|
-
if (nagAdapter?.nagDismissKeys) {
|
|
646
|
+
if (nagAdapter?.nagDismissKeys || nagAdapter?.blockingConfirm) {
|
|
627
647
|
let nagFiredMs = 0
|
|
628
648
|
// В60 — stuck-gate: a REAL blocked modal freezes the pty entirely, while a live peer merely
|
|
629
649
|
// RENDERING the modal text (code review / quoted report — the В40 false-fire class) keeps
|
|
@@ -647,6 +667,22 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
647
667
|
if (action.kind === 'dismiss') {
|
|
648
668
|
child.terminal.write(action.bytes)
|
|
649
669
|
nagFiredMs = Date.now()
|
|
670
|
+
} else if (action.kind === 'approve') {
|
|
671
|
+
// CIRCUIT-BREAKER confirm (e.g. dangerous-rm) — press the affirmative so a headless peer never
|
|
672
|
+
// hangs on it (owner decision: auto-YES restores the pre-2.1.x bypass status quo). Leave a
|
|
673
|
+
// post-hoc AUDIT line in the supervisor's own log (console → logPath): WHICH peer (session),
|
|
674
|
+
// WHAT (taxonomy + the parsed command/target), WHEN (ts). A rare bad rm is then at least visible
|
|
675
|
+
// after the fact — the interim before human-approval (Telegram) lands. Best-effort log, never
|
|
676
|
+
// gates the keypress.
|
|
677
|
+
child.terminal.write(action.bytes)
|
|
678
|
+
nagFiredMs = Date.now()
|
|
679
|
+
try {
|
|
680
|
+
console.warn(
|
|
681
|
+
`[supervisor] AUTO-CONFIRM ${action.taxonomy} identity=${session} ts=${new Date().toISOString()} ${action.detail}`,
|
|
682
|
+
)
|
|
683
|
+
} catch {
|
|
684
|
+
/* logging must never break the conduit */
|
|
685
|
+
}
|
|
650
686
|
}
|
|
651
687
|
}, NAG_POLL_MS)
|
|
652
688
|
;(nagWatch as { unref?: () => void }).unref?.()
|