@agfpd/iapeer 0.4.71 → 0.4.72

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.
@@ -63,10 +63,26 @@ Both runtimes carry a Claude-compatible **hook** system that intercepts an appro
63
63
 
64
64
  - **claude `PermissionRequest`** — stdin carries `{tool_name, tool_input, …}`; the hook prints `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow|deny","message":"…"}}}`. A `deny` carries `message` to the model. Fires only on prompt-worthy calls.
65
65
  - **codex `PreToolUse`** — same stdin shape; prints `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow|deny","permissionDecisionReason":"…"}}`. `exit 2` (+ stderr) also denies. Verified live (0.142.5): codex fires `PreToolUse` (not `PermissionRequest`) for tool calls with `tool_name:"Bash"` for shell; a `deny` hard-blocks the tool and the reason reaches the model even under `sandbox_mode=danger-full-access` — so under the gated config (danger-full-access + on-request → `permission_mode=bypassPermissions`) the hook is the SOLE gate.
66
- - **supervisor circuit-breaker** — claude's `dangerous-rm`/`rmdir` guard (and the standard command-approval prompt a no-bypass session shows) is a TUI select the hook never sees. The pty supervisor reads it off its authoritative model and, under gated, POSTs it to the broker (below); under yolo it auto-presses YES with an audit line.
66
+ - **supervisor circuit-breaker** — claude's `dangerous-rm`/`rmdir` guard (and the standard command-approval prompt a no-bypass session shows) is a TUI select the hook never sees. The pty supervisor reads it off its authoritative model and routes by class (see *Supervisor pty-scrape classification* below): the known auto-Yes classes are pressed under yolo (with an audit line) / brokered under gated, while `org-policy` and any unrecognized `unknown-modal` always go to the human.
67
67
 
68
68
  The hook binary is the `iapeer approval-hook` subcommand: it reads the runtime's hook JSON on stdin, resolves `PEER_PERSONALITY`/`PEER_RUNTIME` from its session env and the daemon URL from `router.json`, POSTs a blocking request to the broker, and prints the runtime-appropriate decision JSON.
69
69
 
70
+ ### Supervisor pty-scrape classification (yolo-robustness)
71
+
72
+ The supervisor nag-watcher classifies every mid-session blocking TUI modal it observes off its authoritative model. It fires only when the pane has been **completely static** for a threshold (the timing stuck-gate: a real blocked modal freezes the pty; a peer merely streaming or rendering the modal text keeps writing) **and** the modal is a live numbered-select that **replaced the composer** (the bottom-most cursor-glyph row is a numbered option, not the composer — the same idle→composer / modal→option invariant the known matchers rest on). Owner rule: **yolo presses what it KNOWS (maximum known rights); anything new or above the peer, the human must SEE and confirm** — never a blind auto-Yes (a modal a runtime raised on purpose must not be swallowed), never a hang.
73
+
74
+ | Class | Detection | yolo | gated |
75
+ |---|---|---|---|
76
+ | `dangerous-rm` | known needle (2-option `1.Yes/2.No`) | auto-Yes + audit | broker (Allow=1 / Deny=2) |
77
+ | `command-approval` | known needle (3-option `1.Yes/2.Yes,…/3.No`) | auto-Yes + audit | broker (Allow=1 / Deny=3) |
78
+ | `org-policy` | known needle ("organization requires approval") | **broker — never auto-Yes** | broker (Allow=1 / Deny=3) |
79
+ | `unknown-modal` | **generic** — numbered select (≥2 options) matching NO known signature | **broker — never auto-Yes** | **broker** |
80
+
81
+ - **`org-policy`** is a barrier ABOVE the peer (an MCP org-restriction). It is recognized but **always routed to the human** on both modes (the owner's org rule is human-only), with the precise 3-option keys. Previously it was excluded from the matcher and a yolo peer HUNG on it — now closed.
82
+ - **`unknown-modal`** is the generic residue: a numbered-select modal (a new one Anthropic/OpenAI shipped) matching none of the known signatures. It is **always routed to the human** on both modes. Because the option layout is unknown, the injection is **binary** (v1): **Allow presses option 1** (the proceed/primary position in every known select; the position-robust affirmative) and **Deny presses `Escape`** (the universal modal cancel — it commits no numbered choice, so it can never accidentally affirm). The broker `content` carries **explicit button semantics** — the verbatim modal block plus a header stating exactly what Allow presses (`option 1: "<verbatim label>"`) and that Deny cancels via Esc — so the human decides informed, not on an abstract Allow/Deny. A **>2-way** choice on an unknown modal is a v2 extension (most approvals are binary). If a modal ignores the injected keys (Esc not honored), the daemon **bounds re-enqueue** (a few rounds) then **logs once and safe-parks** — the peer stays blocked (safe), never an infinite ask loop nor a blind Yes. Both claude (`❯`) and codex (`›`) are covered; codex's known boot dialogs are excluded (owned by the boot-driver).
83
+
84
+ The supervisor scrape is the ONLY interceptor for prompts the runtime hook cannot see (dangerous-rm above the permission layer; a non-hookable modal such as codex MCP-elicitation). A blocking prompt that is NOT a numbered select (a free-form text-input prompt) is out of v1 scope — the numbered-select family is the approval/confirm class in practice.
85
+
70
86
  ## Nomenclature v1 and content per action (criterion «what is being approved»)
71
87
 
72
88
  In every channel a human sees the **concrete content** of the action, not just the tool name.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.71",
3
+ "version": "0.4.72",
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",
@@ -25,6 +25,7 @@ import { join } from 'path'
25
25
  import { readdirSync, realpathSync, statSync } from 'fs'
26
26
  import type { ApprovalMode, ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
27
27
  import { lastTimestampedEntryMs } from './transcriptTail.ts'
28
+ import { detectNumberedModal } from './modalDetect.ts'
28
29
 
29
30
  // ─────────────────────────────────────────────────────────────────────────────
30
31
  // Boot dialog + ready markers (lifecycle claudeBootDialog / claudeInputReady)
@@ -169,6 +170,44 @@ function commandApprovalDetail(pane: string): string {
169
170
  return '(unparsed)'
170
171
  }
171
172
 
173
+ // ORG-POLICY approval prompt (docs/17 — yolo-robustness). "Your organization requires approval for this
174
+ // tool" is an MCP org-restriction: a barrier ABOVE the peer that the owner's rule says is HUMAN-only —
175
+ // NEVER auto-pressed on ANY peer (yolo included). It was previously EXCLUDED from isCommandApprovalPrompt
176
+ // (→ fell through to 'none' → a yolo peer HUNG on it). Now it is its OWN recognized-but-always-human tier
177
+ // (blockingConfirm alwaysHuman:true) so it routes to the broker under BOTH modes with the SAME 3-option
178
+ // command-approval layout keys (Allow=1.Yes, Deny=3.No). If a future org-policy render is NOT this
179
+ // 3-option layout, isOrgPolicyPrompt returns false and the GENERIC unknownBlockingModal catches it
180
+ // instead (→ human with Esc-deny) — either way it reaches a human, never auto-Yes.
181
+ /** FULL-signature + position match for the org-policy approval prompt: the org needle + the 3-option
182
+ * "proceed?" select with the bottom-most `❯` cursor on "1. Yes" (a live modal, not a quote). */
183
+ function isOrgPolicyPrompt(pane: string): boolean {
184
+ if (!pane.includes(RM_PROCEED)) return false
185
+ if (!pane.includes(ORG_POLICY_APPROVAL)) return false
186
+ if (isDangerousRmPrompt(pane)) return false // 2-option breaker owns its pane
187
+ if (!/(^|\n)\s*2\.\s*Yes/.test(pane)) return false
188
+ if (!/(^|\n)\s*3\.\s*No\b/.test(pane)) return false
189
+ let lastCursorRow = ''
190
+ for (const line of pane.split(/\r?\n/)) if (line.includes('❯')) lastCursorRow = line
191
+ return /❯\s*1\.\s*Yes\b/.test(lastCursorRow)
192
+ }
193
+
194
+ // claude's select-modal cursor glyph (U+276F) — the same glyph isInputReady / the nag matcher key on.
195
+ const CLAUDE_SELECT_GLYPH = '❯'
196
+ /** Does the pane carry a KNOWN claude blocking signature (dangerous-rm / command-approval / org-policy /
197
+ * fullscreen-nag)? unknownBlockingModal returns null for these so it fires ONLY for the unrecognized
198
+ * residue — the daemon also checks them first, but this keeps the detector self-contained + testable. */
199
+ function isKnownClaudeBlockingSignature(pane: string): boolean {
200
+ // anyBootDialog: the numbered boot selects (resume-picker / bypass-accept / theme / MCP-approval) are
201
+ // owned by the boot-driver (nextBootAction) — never treat them as an "unknown" mid-session modal.
202
+ return (
203
+ anyBootDialog(pane) ||
204
+ isDangerousRmPrompt(pane) ||
205
+ isCommandApprovalPrompt(pane) ||
206
+ isOrgPolicyPrompt(pane) ||
207
+ isFullscreenNagText(pane)
208
+ )
209
+ }
210
+
172
211
  // ─────────────────────────────────────────────────────────────────────────────
173
212
  // Transcript activity proxy + resume uuid (lifecycle port, claude slug fix)
174
213
  // ─────────────────────────────────────────────────────────────────────────────
@@ -396,12 +435,28 @@ export const claudeAdapter: RuntimeAdapter = {
396
435
  * command/target trace for the audit + broker content. NB deliberately NOT a "smart" safe-decline —
397
436
  * the gated human-approval path lands via the daemon broker (docs/17, Unit 4).
398
437
  */
399
- blockingConfirm(pane: string): { keys: string[]; denyKeys: string[]; taxonomy: string; detail: string } | null {
438
+ blockingConfirm(pane: string): { keys: string[]; denyKeys: string[]; taxonomy: string; detail: string; alwaysHuman?: boolean } | null {
400
439
  if (isDangerousRmPrompt(pane)) return { keys: ['1', 'Enter'], denyKeys: ['2', 'Enter'], taxonomy: 'dangerous-rm', detail: dangerousRmDetail(pane) }
401
440
  if (isCommandApprovalPrompt(pane)) return { keys: ['1', 'Enter'], denyKeys: ['3', 'Enter'], taxonomy: 'command-approval', detail: commandApprovalDetail(pane) }
441
+ // Org-policy: recognized, but NEVER auto-Yes — always to the human (both modes). Same 3-option keys.
442
+ if (isOrgPolicyPrompt(pane)) return { keys: ['1', 'Enter'], denyKeys: ['3', 'Enter'], taxonomy: 'org-policy', detail: commandApprovalDetail(pane), alwaysHuman: true }
402
443
  return null
403
444
  },
404
445
 
446
+ /**
447
+ * GENERIC unknown blocking-modal detector (docs/17 — yolo-robustness). Fires for a numbered-select
448
+ * modal that halts the pty but matches NONE of the known signatures above (a new modal we did not
449
+ * foresee — the class that hung a live yolo peer). Returns null for the known signatures (checked by
450
+ * isKnownClaudeBlockingSignature) so the daemon routes those through their own tiers. The verbatim
451
+ * block + option-1 label let the daemon give the human EXPLICIT button semantics (Allow presses
452
+ * option 1 = "<option1>", Deny cancels via Esc). Detection is structural (bottom-most `❯` is a
453
+ * numbered option + ≥2 options) — see modalDetect.ts; the daemon's timing stuck-gate composes on top.
454
+ */
455
+ unknownBlockingModal(pane: string): { content: string; option1: string } | null {
456
+ if (isKnownClaudeBlockingSignature(pane)) return null
457
+ return detectNumberedModal(pane, CLAUDE_SELECT_GLYPH)
458
+ },
459
+
405
460
  /**
406
461
  * Ready for the first message iff the input surface is rendered AND no startup
407
462
  * dialog is still up (lifecycle.claudeInputReady, index.ts:272-279;
@@ -43,6 +43,7 @@ import { homedir } from 'os'
43
43
  import { join } from 'path'
44
44
  import { readdirSync, realpathSync, statSync } from 'fs'
45
45
  import { codexSessionCwd, lastTimestampedEntryMs } from './transcriptTail.ts'
46
+ import { detectNumberedModal } from './modalDetect.ts'
46
47
  import type { ApprovalMode, ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
47
48
 
48
49
  // ─────────────────────────────────────────────────────────────────────────────
@@ -75,6 +76,15 @@ function codexHooksReviewActive(pane: string): boolean {
75
76
  return pane.includes('Hooks need review')
76
77
  }
77
78
 
79
+ // codex's select-modal glyph (U+203A) — the same arrow isInputReady keys on (a numbered option row is
80
+ // `› 1. …`; the composer is `› <text>`, no leading digit-dot).
81
+ const CODEX_SELECT_GLYPH = '›'
82
+ /** Does the pane carry a KNOWN codex boot dialog (update / dir-trust / hooks-review)? unknownBlockingModal
83
+ * returns null for these so they stay owned by the boot-driver and are never re-routed to a human. */
84
+ function isKnownCodexBlockingSignature(pane: string): boolean {
85
+ return codexUpdatePromptActive(pane) || codexDirTrustActive(pane) || codexHooksReviewActive(pane)
86
+ }
87
+
78
88
  // ─────────────────────────────────────────────────────────────────────────────
79
89
  // Session activity proxy + resume preflight (watcher / spawner port)
80
90
  // ─────────────────────────────────────────────────────────────────────────────
@@ -248,6 +258,20 @@ export const codexAdapter: RuntimeAdapter = {
248
258
  return null
249
259
  },
250
260
 
261
+ /**
262
+ * GENERIC unknown blocking-modal detector (docs/17 — yolo-robustness). codex carries no known
263
+ * mid-session circuit-breaker (its tool approvals ride the PreToolUse hook under gated, and yolo
264
+ * bypasses them), so it declares no blockingConfirm/nagDismissKeys — but a NON-hookable modal
265
+ * (MCP-elicitation, a future codex prompt) can still surface on screen and hang the pty. This catches
266
+ * that residue structurally (bottom-most `›` is a numbered option + ≥2 options; see modalDetect.ts),
267
+ * excluding the known boot dialogs (owned by the boot-driver). The daemon routes it to the human with
268
+ * explicit button semantics + the timing stuck-gate on top.
269
+ */
270
+ unknownBlockingModal(pane: string): { content: string; option1: string } | null {
271
+ if (isKnownCodexBlockingSignature(pane)) return null
272
+ return detectNumberedModal(pane, CODEX_SELECT_GLYPH)
273
+ },
274
+
251
275
  /**
252
276
  * Ready for the first message iff the codex TUI composer is rendered and no
253
277
  * startup screen is still up:
@@ -0,0 +1,102 @@
1
+ // GENERIC numbered-SELECT modal detector (docs/17 — yolo-robustness / unknown-modal). Shared by the
2
+ // claude (`❯`) and codex (`›`) adapters' unknownBlockingModal: a modal that halts the pty on a keypress
3
+ // but matches NONE of the adapter's KNOWN signatures — a new modal Anthropic/OpenAI shipped that we did
4
+ // not foresee (the class that hung a live yolo peer). It is detected STRUCTURALLY, not by phrase, on the
5
+ // SAME invariant the known matchers rest on (an idle-ready TUI shows the composer at the bottom; a live
6
+ // modal REPLACES it with a numbered select), minus the phrase gate:
7
+ //
8
+ // - the BOTTOM-MOST cursor-glyph row is a numbered option (`glyph … N. …`), not the composer
9
+ // (`glyph <placeholder/text>` carries no leading digit-dot), and
10
+ // - the contiguous non-blank block ending there carries ≥2 distinct numbered options (a real select,
11
+ // not a lone printed `N.` line).
12
+ //
13
+ // A peer merely RENDERING/quoting a modal in its output fails this: the READY composer glyph renders
14
+ // BELOW the quote, so the bottom-most glyph row is the composer, not an option (the В40 class the known
15
+ // matchers already survive). The daemon's timing STUCK-gate composes on top (fires only on a frozen
16
+ // pane), so a working/streaming peer is excluded there.
17
+
18
+ /** Verbatim modal block (shown to the human) + the option-1 label (what an ALLOW presses). */
19
+ export interface NumberedModal {
20
+ content: string
21
+ option1: string
22
+ }
23
+
24
+ const CAP_LINES = 24
25
+ const CAP_BYTES = 8192
26
+ // Unicode box-drawing block (U+2500–U+257F) — modal borders/separators; stripped for PARSING only
27
+ // (the verbatim `content` keeps them).
28
+ const BOX_DRAWING = /[─-╿]/g
29
+
30
+ function escapeForRegex(s: string): string {
31
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
32
+ }
33
+
34
+ /** Parse a numbered option row (`❯ 1. Yes` / `│ 2. No │`) → {num, label}, else null. Box glyphs and a
35
+ * leading select glyph are stripped before matching so a boxed/selected row parses the same. */
36
+ function parseOption(raw: string, glyph: string): { num: number; label: string } | null {
37
+ const s = raw.replace(BOX_DRAWING, ' ').split(glyph).join(' ').trim()
38
+ const m = /^(\d+)[.)]\s+(\S.*)$/.exec(s)
39
+ if (!m) return null
40
+ return { num: parseInt(m[1], 10), label: m[2].replace(/\s+$/, '').trim() }
41
+ }
42
+
43
+ /** Cap the verbatim block: keep the LAST CAP_LINES rows (the options sit at the bottom, most load-
44
+ * bearing), each right-trimmed, then a hard byte ceiling. A leading marker notes any drop. */
45
+ function capBlock(block: string[]): string {
46
+ let rows = block.map(l => l.replace(/\s+$/, ''))
47
+ let dropped = false
48
+ if (rows.length > CAP_LINES) {
49
+ rows = rows.slice(rows.length - CAP_LINES)
50
+ dropped = true
51
+ }
52
+ let out = rows.join('\n')
53
+ if (Buffer.byteLength(out, 'utf8') > CAP_BYTES) {
54
+ out = out.slice(-CAP_BYTES)
55
+ dropped = true
56
+ }
57
+ return dropped ? `…\n${out}` : out
58
+ }
59
+
60
+ /**
61
+ * Detect a generic numbered-select modal that has replaced the composer. `glyph` is the runtime's
62
+ * cursor/select glyph (`❯` claude, `›` codex). Returns the verbatim block + option-1 label, or null.
63
+ * The CALLER must first exclude its OWN known signatures (so this fires only for the unrecognized
64
+ * residue) — this function is signature-agnostic by design.
65
+ */
66
+ export function detectNumberedModal(pane: string, glyph: string): NumberedModal | null {
67
+ const lines = pane.split(/\r?\n/)
68
+ // bottom-most row carrying the select glyph.
69
+ let selected = -1
70
+ for (let i = 0; i < lines.length; i++) if (lines[i].includes(glyph)) selected = i
71
+ if (selected < 0) return null
72
+ // it must be a SELECTED numbered option (`glyph … N. …`) — the composer (`glyph <text>`) carries no
73
+ // leading digit-dot, so an idle/ready pane and a mere quote-with-composer-below both fail here.
74
+ const selectedRe = new RegExp(`${escapeForRegex(glyph)}\\s*\\d+[.)]\\s`)
75
+ if (!selectedRe.test(lines[selected])) return null
76
+ // The select glyph sits only on the SELECTED option; siblings render WITHOUT it (above and below). Grow
77
+ // the contiguous OPTION RUN in BOTH directions from the selected row while each line parses as a
78
+ // numbered option (an interleaved blank / non-option line ends the run). This captures every option,
79
+ // not just the ones above the cursor.
80
+ let optTop = selected
81
+ let optBottom = selected
82
+ while (optTop - 1 >= 0 && parseOption(lines[optTop - 1], glyph)) optTop--
83
+ while (optBottom + 1 < lines.length && parseOption(lines[optBottom + 1], glyph)) optBottom++
84
+ // need ≥2 DISTINCT numbered options (a real select, not a lone printed `N.` line).
85
+ const nums = new Set<number>()
86
+ let option1 = ''
87
+ for (let i = optTop; i <= optBottom; i++) {
88
+ const opt = parseOption(lines[i], glyph)
89
+ if (!opt) continue
90
+ nums.add(opt.num)
91
+ if (opt.num === 1 && !option1) option1 = opt.label
92
+ }
93
+ if (nums.size < 2) return null
94
+ // Verbatim block shown to the human: the option run + a little context (the question above / a footer
95
+ // below), then leading/trailing blank lines trimmed. Bounded by the caps.
96
+ const start = Math.max(0, optTop - 3)
97
+ const end = Math.min(lines.length - 1, optBottom + 2)
98
+ const block = lines.slice(start, end + 1)
99
+ while (block.length && block[0].trim() === '') block.shift()
100
+ while (block.length && block[block.length - 1].trim() === '') block.pop()
101
+ return { content: capBlock(block), option1: option1 || '(option 1)' }
102
+ }
@@ -329,8 +329,33 @@ export interface RuntimeAdapter {
329
329
  * content mentioning the phrase never fires; else null. Optional (codex/router omit it). tui only.
330
330
  * Human-approval lands via the daemon broker (docs/17); the vault idea is "Human-approval
331
331
  * подтверждения действий пира через Telegram-кнопки".
332
+ *
333
+ * `alwaysHuman` (default false) marks a KNOWN class that must NEVER be auto-pressed on ANY peer —
334
+ * it always routes to the human broker regardless of approval mode (the owner's org-policy rule:
335
+ * "a barrier ABOVE us is never auto-answered"). yolo auto-Yes applies only to alwaysHuman=false
336
+ * classes (dangerous-rm / command-approval — the "yolo presses what it KNOWS" set).
332
337
  */
333
- blockingConfirm?(pane: string): { keys: string[]; denyKeys: string[]; taxonomy: string; detail: string } | null
338
+ blockingConfirm?(pane: string): { keys: string[]; denyKeys: string[]; taxonomy: string; detail: string; alwaysHuman?: boolean } | null
339
+
340
+ /**
341
+ * GENERIC blocking-modal detector (docs/17 — yolo-robustness / unknown-modal). The residual sibling
342
+ * of blockingConfirm: a numbered-SELECT modal that halts the pty on a keypress but matches NONE of
343
+ * the adapter's KNOWN signatures (a new modal Anthropic/OpenAI shipped that we did not foresee — the
344
+ * class that hung a live yolo peer). Detected STRUCTURALLY, not by phrase: the bottom-most cursor-glyph
345
+ * row (`❯`/`›`) is a numbered option (`N.`) AND the contiguous block ending there carries ≥2 numbered
346
+ * options — i.e. a live select REPLACED the composer (the same idle→composer / modal→option invariant
347
+ * the known matchers rest on, minus the phrase gate). The adapter returns null for its OWN known
348
+ * signatures (checked first by the caller), so this fires ONLY for the unrecognized residue.
349
+ *
350
+ * Returns `{ content, option1 }`: `content` = the VERBATIM modal block (question + all option rows,
351
+ * CR-stripped, capped) shown to the human; `option1` = the verbatim label of option 1 (what an ALLOW
352
+ * will press — the supervisor injects ['1','Enter'] on allow, ['Escape'] to cancel on deny). The human
353
+ * sees the exact options and what Allow selects, so the decision is INFORMED (owner's "new I must SEE
354
+ * and confirm" — предметно). v1 is BINARY (option-1-or-cancel); a >2-way choice on an unknown modal is
355
+ * a v2 extension (most approvals are binary). The timing STUCK-gate (daemon) composes on top — this
356
+ * fires only on a genuinely frozen pane. Optional (router omits it). tui only.
357
+ */
358
+ unknownBlockingModal?(pane: string): { content: string; option1: string } | null
334
359
 
335
360
  /** Is the input surface ready for the first message? (tui: ready marker present
336
361
  * AND startup dialogs gone). Router runtimes return true (no input surface).
@@ -15,16 +15,59 @@
15
15
  import { requestApproval, resolveFleetBase, type ApprovalDecision } from '../approval/brokerClient.ts'
16
16
 
17
17
  export interface BreakerApprovalInput {
18
- /** Class tag — 'dangerous-rm' | 'command-approval'. Sent as the broker `tool` + `summary`. */
18
+ /** Class tag — 'dangerous-rm' | 'command-approval' | 'org-policy' | 'unknown-modal'. Sent as the
19
+ * broker `tool`. */
19
20
  taxonomy: string
20
- /** Best-effort one-line command/target trace parsed off the pane. Sent as the broker `content`. */
21
+ /** Broker taxonomy `kind` (docs/17): 'circuit-breaker' for the known breakers + org-policy, or
22
+ * 'unknown-modal' for the generic residue. Default 'circuit-breaker'. */
23
+ brokerKind?: string
24
+ /** Known breakers: a one-line command/target trace. unknown-modal: the VERBATIM modal block. */
21
25
  detail: string
26
+ /** unknown-modal only: the verbatim label of option 1 — what an ALLOW presses. Powers the explicit
27
+ * button semantics the human sees (owner's "new I must SEE and confirm — предметно"). */
28
+ option1?: string
22
29
  /** Keys to inject on ALLOW — the position-robust affirmative ('1'+Enter). */
23
30
  approveBytes: Buffer
24
- /** Keys to inject on DENY — the taxonomy-specific decline (2.No | 3.No). */
31
+ /** Keys to inject on DENY — known: the taxonomy decline (2.No | 3.No); unknown-modal: Escape (cancel). */
25
32
  denyBytes: Buffer
26
33
  }
27
34
 
35
+ /**
36
+ * Compose the human-facing broker `content` + `summary`. KNOWN breakers (dangerous-rm / command-approval)
37
+ * keep the Ф1 format verbatim (content = the parsed trace) — no regression. The new always-human classes
38
+ * get an EXPLICIT button-semantics header so a human answering Allow/Deny knows precisely what each does
39
+ * (boris's mandatory refinement): for an unknown modal, Allow presses option 1 (whose verbatim text is
40
+ * shown) and Deny cancels via Esc; for org-policy, the known 1.Yes / 3.No mapping is stated. Pure —
41
+ * unit-testable.
42
+ */
43
+ export function composeApprovalContent(input: BreakerApprovalInput): { content: string; summary: string } {
44
+ const kind = input.brokerKind ?? 'circuit-breaker'
45
+ if (kind === 'unknown-modal') {
46
+ const opt1 = input.option1?.trim() || '(option 1)'
47
+ const content = [
48
+ '⚠ Unrecognized blocking modal — iapeer could not classify it, so it is asking you.',
49
+ `Allow → presses option 1: ${JSON.stringify(opt1)}`,
50
+ "Deny → cancels the modal (Esc); the peer's action is declined and it continues.",
51
+ '',
52
+ '─── modal (verbatim) ───',
53
+ input.detail,
54
+ ].join('\n')
55
+ return { content, summary: `unrecognized blocking modal — Allow presses option 1: ${opt1}`.slice(0, 240) }
56
+ }
57
+ if (input.taxonomy === 'org-policy') {
58
+ const content = [
59
+ '⚠ Organization policy requires approval for this action (a barrier above the peer).',
60
+ 'Allow → presses "1. Yes" (proceed).',
61
+ 'Deny → presses "3. No" (refuse); the reason goes to the model.',
62
+ '',
63
+ input.detail,
64
+ ].join('\n')
65
+ return { content, summary: `org-policy approval required — ${input.detail}`.slice(0, 240) }
66
+ }
67
+ // Known circuit-breaker (dangerous-rm / command-approval) — Ф1 format, unchanged.
68
+ return { content: input.detail, summary: input.taxonomy }
69
+ }
70
+
28
71
  export interface BreakerRouteDeps {
29
72
  personality: string
30
73
  runtime: string
@@ -49,6 +92,7 @@ export interface BreakerRouteDeps {
49
92
  */
50
93
  export async function routeCircuitBreaker(input: BreakerApprovalInput, deps: BreakerRouteDeps): Promise<ApprovalDecision> {
51
94
  const now = deps.now ?? Date.now
95
+ const { content, summary } = composeApprovalContent(input)
52
96
  let decision: ApprovalDecision
53
97
  try {
54
98
  decision = await requestApproval(
@@ -56,10 +100,10 @@ export async function routeCircuitBreaker(input: BreakerApprovalInput, deps: Bre
56
100
  {
57
101
  personality: deps.personality,
58
102
  runtime: deps.runtime,
59
- kind: 'circuit-breaker',
103
+ kind: input.brokerKind ?? 'circuit-breaker',
60
104
  tool: input.taxonomy,
61
- content: input.detail,
62
- summary: input.taxonomy,
105
+ content,
106
+ summary,
63
107
  },
64
108
  { env: deps.env, fetch: deps.fetch, timeoutMs: deps.timeoutMs },
65
109
  )
@@ -34,29 +34,58 @@ export function nextBootAction(adapter: BootPredicates, viewport: string, enc: K
34
34
  }
35
35
 
36
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. */
37
+ * (benign upsell → silent decline) + blockingConfirm (known circuit-breaker → affirmative/decision) +
38
+ * unknownBlockingModal (docs/17 yolo-robustness — a numbered-select modal matching NO known signature →
39
+ * always to the human). claudeAdapter satisfies all three; codex declares unknownBlockingModal only;
40
+ * router omits all → no action. */
39
41
  export interface NagPredicate {
40
42
  nagDismissKeys?(pane: string): string[] | null
41
- blockingConfirm?(pane: string): { keys: string[]; denyKeys: string[]; taxonomy: string; detail: string } | null
43
+ blockingConfirm?(pane: string): { keys: string[]; denyKeys: string[]; taxonomy: string; detail: string; alwaysHuman?: boolean } | null
44
+ unknownBlockingModal?(pane: string): { content: string; option1: string } | null
42
45
  }
43
46
 
47
+ // Fixed keys for a GENERIC unknown modal whose option layout we do NOT know (docs/17 fork 3): ALLOW
48
+ // presses option 1 (the proceed/primary position in every known claude/codex select; the human is told
49
+ // exactly what option 1 is), DENY presses Escape (the universal modal cancel — commits no numbered
50
+ // choice, so it can never accidentally affirm; if a modal ignores Esc the daemon bounds re-enqueue and
51
+ // safe-parks). v1 is BINARY (option-1-or-cancel); a >2-way choice on an unknown modal is a v2 extension.
52
+ export const UNKNOWN_MODAL_ALLOW_KEYS = ['1', 'Enter']
53
+ export const UNKNOWN_MODAL_DENY_KEYS = ['Escape']
54
+
44
55
  export type NagAction =
45
56
  | { kind: 'dismiss'; keys: string[]; bytes: Buffer } // benign upsell — press keys silently
46
- // circuit-breaker — press `bytes` to AFFIRM (yolo auto-Yes / gated-approved) or `denyBytes` to DECLINE
47
- // (gated-denied / broker fail-safe); LOG the taxonomy + detail either way.
48
- | { kind: 'approve'; keys: string[]; bytes: Buffer; denyKeys: string[]; denyBytes: Buffer; taxonomy: string; detail: string }
57
+ // A blocking confirm/modal needing a Yes/No decision. Press `bytes` to AFFIRM or `denyBytes` to DECLINE.
58
+ // - alwaysHuman=false (dangerous-rm / command-approval): yolo auto-Yes, gated → broker.
59
+ // - alwaysHuman=true (org-policy / unknown-modal): ALWAYS → broker (both modes; never auto-Yes).
60
+ // `brokerKind` is the broker taxonomy tag (docs/17); `option1` (unknown-modal only) is the verbatim
61
+ // label of option 1, so the human is shown EXACTLY what an Allow will press. `detail` is the audit
62
+ // trace (known) or the verbatim modal block (unknown-modal).
63
+ | {
64
+ kind: 'approve'
65
+ keys: string[]
66
+ bytes: Buffer
67
+ denyKeys: string[]
68
+ denyBytes: Buffer
69
+ taxonomy: string
70
+ detail: string
71
+ alwaysHuman: boolean
72
+ brokerKind: string
73
+ option1?: string
74
+ }
49
75
  | { kind: 'none' }
50
76
 
51
77
  /**
52
78
  * MID-SESSION nag/confirm step (livability) — the persistent sibling of nextBootAction. Unlike the
53
- * boot-driver (which STOPS at ready), the daemon loops this for the WHOLE session for TWO classes of
54
- * mid-session blocking prompt no headless peer can answer:
55
- * 1. a CIRCUIT-BREAKER confirm (blockingConfirm — e.g. the dangerous-rm guard above the permission
56
- * layer) → press the affirmative AND surface a `detail`/`taxonomy` the caller logs (owner audit);
57
- * 2. a benign UPSELL modal (nagDismissKeys — e.g. "Try the new fullscreen-renderer?") → decline silently.
58
- * The circuit-breaker is checked FIRST (safety-relevant). The caller writes `action.bytes` (cooldown- +
59
- * stuck-gate-guarded so a cleared prompt is never double-answered). An adapter with neither → 'none'.
79
+ * boot-driver (which STOPS at ready), the daemon loops this for the WHOLE session over THREE classes of
80
+ * mid-session blocking prompt no headless peer can answer, checked most-specific FIRST:
81
+ * 1. a KNOWN circuit-breaker (blockingConfirm — dangerous-rm / command-approval / org-policy) → press
82
+ * the affirmative/decision AND surface a `detail`/`taxonomy` the caller logs; `alwaysHuman` decides
83
+ * whether a yolo peer auto-Yeses (false) or always routes to the human (true, org-policy);
84
+ * 2. a benign UPSELL modal (nagDismissKeys — e.g. "Try the new fullscreen-renderer?") → decline silently;
85
+ * 3. a GENERIC unknown modal (unknownBlockingModal — a numbered select matching no known signature) →
86
+ * ALWAYS to the human with fixed option-1-or-cancel keys + the verbatim block (docs/17 yolo-robustness).
87
+ * The caller writes `action.bytes` (cooldown- + stuck-gate-guarded so a cleared prompt is never double-
88
+ * answered). An adapter with none of the three → 'none'.
60
89
  */
61
90
  export function nextNagAction(adapter: NagPredicate, viewport: string, enc: KeyEncoding = {}): NagAction {
62
91
  const confirm = adapter.blockingConfirm?.(viewport)
@@ -69,9 +98,26 @@ export function nextNagAction(adapter: NagPredicate, viewport: string, enc: KeyE
69
98
  denyBytes: keysToBytes(confirm.denyKeys, enc),
70
99
  taxonomy: confirm.taxonomy,
71
100
  detail: confirm.detail,
101
+ alwaysHuman: confirm.alwaysHuman ?? false,
102
+ brokerKind: 'circuit-breaker',
72
103
  }
73
104
  const keys = adapter.nagDismissKeys?.(viewport)
74
105
  if (keys && keys.length) return { kind: 'dismiss', keys, bytes: keysToBytes(keys, enc) }
106
+ // GENERIC residue — checked LAST so a known signature always wins its precise keys.
107
+ const unknown = adapter.unknownBlockingModal?.(viewport)
108
+ if (unknown)
109
+ return {
110
+ kind: 'approve',
111
+ keys: UNKNOWN_MODAL_ALLOW_KEYS,
112
+ bytes: keysToBytes(UNKNOWN_MODAL_ALLOW_KEYS, enc),
113
+ denyKeys: UNKNOWN_MODAL_DENY_KEYS,
114
+ denyBytes: keysToBytes(UNKNOWN_MODAL_DENY_KEYS, enc),
115
+ taxonomy: 'unknown-modal',
116
+ detail: unknown.content,
117
+ alwaysHuman: true,
118
+ brokerKind: 'unknown-modal',
119
+ option1: unknown.option1,
120
+ }
75
121
  return { kind: 'none' }
76
122
  }
77
123
 
@@ -94,6 +94,11 @@ const NAG_COOLDOWN_MS = 4000
94
94
  * fire. A genuinely blocked modal freezes the session forever, so a ~10 s delay costs nothing;
95
95
  * a live peer rendering the modal text keeps writing (stream/spinner) and never crosses this. */
96
96
  const NAG_STUCK_MS = 10_000
97
+ // docs/17 fork 3 — bound how many times the daemon re-asks a human about a GENERIC unknown modal whose
98
+ // injected keys did NOT clear it (a modal ignoring both option-1 and Esc). After this many answered-but-
99
+ // still-static rounds it logs once and safe-PARKS (the peer stays blocked = safe) instead of looping
100
+ // forever. The counter resets the moment the pane makes progress (writeSeq advances → the modal cleared).
101
+ const MAX_UNKNOWN_MODAL_ATTEMPTS = 3
97
102
  // В25 — cap the attach-snapshot stabilization wait: under saturating output the model never goes quiet,
98
103
  // so without a budget firstAttach would never complete (frozen attach). On timeout we snapshot the
99
104
  // current model; live-forward delivers the rest once the gate lifts.
@@ -689,7 +694,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
689
694
  // NAG_COOLDOWN_MS so a just-dismissed modal is never double-answered (a stray keystroke would land in
690
695
  // the now-visible composer). Only runs for a runtime whose adapter declares nagDismissKeys.
691
696
  const nagAdapter = BOOT_ADAPTERS[runtime] as NagPredicate | undefined
692
- if (nagAdapter?.nagDismissKeys || nagAdapter?.blockingConfirm) {
697
+ if (nagAdapter?.nagDismissKeys || nagAdapter?.blockingConfirm || nagAdapter?.unknownBlockingModal) {
693
698
  // ── Unit 4 — approval-mode routing for the circuit-breaker ──────────────────────
694
699
  // Resolve the peer's approval mode ONCE at watcher setup: the profile is stable for the session (a
695
700
  // mid-session flip needs a fresh launch), and re-reading it every tick would hammer the fs. yolo
@@ -711,10 +716,16 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
711
716
  const brokerPersonality =
712
717
  brokerEnv.PEER_PERSONALITY?.trim() || (session.startsWith(`${runtime}-`) ? session.slice(runtime.length + 1) : session)
713
718
  const brokerRuntime = brokerEnv.PEER_RUNTIME?.trim() || runtime
714
- // In-flight guard: the setInterval keeps firing while a gated POST is outstanding (the modal stays up,
719
+ // In-flight guard: the setInterval keeps firing while a broker POST is outstanding (the modal stays up,
715
720
  // the pane stays stuck), so gate to EXACTLY ONE broker request per wedged modal.
716
721
  let brokerInFlight = false
717
722
  let nagFiredMs = 0
723
+ // docs/17 fork 3 — bounded re-enqueue state for a GENERIC unknown modal. `attempts` counts answered-
724
+ // but-still-static rounds; `parked` latches after MAX to stop asking. Both reset on pane progress
725
+ // (writeSeq advance) so a later, DIFFERENT modal is handled fresh.
726
+ let unknownModalAttempts = 0
727
+ let unknownModalParked = false
728
+ let lastNagSeq = -1
718
729
  // В60 — stuck-gate: a REAL blocked modal freezes the pty entirely, while a live peer merely
719
730
  // RENDERING the modal text (code review / quoted report — the В40 false-fire class) keeps
720
731
  // writing. Fire the dismiss ONLY when the pane has been completely static (writeSeq unchanged)
@@ -725,6 +736,13 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
725
736
  clearInterval(nagWatch)
726
737
  return
727
738
  }
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
+ }
728
746
  if (!paneIsStuck(stuckGate, writeSeq, Date.now(), NAG_STUCK_MS)) return // progressing → not a wedged modal
729
747
  if (Date.now() - nagFiredMs < NAG_COOLDOWN_MS) return // let a just-answered modal repaint away first
730
748
  let action
@@ -738,19 +756,51 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
738
756
  child.terminal.write(action.bytes)
739
757
  nagFiredMs = Date.now()
740
758
  } else if (action.kind === 'approve') {
741
- // CIRCUIT-BREAKER confirm (e.g. dangerous-rm) — a headless peer has no human to press it, so it
742
- // hangs forever. What we press depends on the peer's approval MODE (resolved once above):
743
- if (approvalMode === 'gated') {
744
- // GATED — route the confirm to the human-approval broker (docs/17, Unit 4). A blocking POST
745
- // awaits a human's allow/deny (≤ broker 300s); routeCircuitBreaker injects the affirmative on
746
- // allow, the taxonomy-specific decline on deny (dangerous-rm=2.No / command-approval=3.No), and
747
- // fails safe to DENY on any broker fault. The breaker sits ABOVE the permission layer, so this
748
- // is the ONLY interceptor for prompts the runtime hook cannot see (dangerous-rm; rm from a
749
- // subprocess). Guarded by brokerInFlight so a re-firing tick never opens a second request.
750
- if (brokerInFlight) return
759
+ // A blocking confirm/modal a headless peer can't answer → it hangs forever. WHERE it goes depends
760
+ // on the class + the peer's approval MODE (resolved once above):
761
+ // - alwaysHuman (org-policy / unknown-modal, docs/17 yolo-robustness) → ALWAYS to the human
762
+ // broker in EITHER mode. Owner's rule: yolo presses what it KNOWS (max known rights); a modal
763
+ // ABOVE us (org-policy) or one we could NOT classify (unknown) I must SEE and confirm — never
764
+ // a blind auto-Yes (an unknown modal Anthropic put up on purpose must not be swallowed).
765
+ // - known auto-Yes class (dangerous-rm / command-approval): gated → broker, yolo → auto-Yes.
766
+ const routeToHuman = action.alwaysHuman || approvalMode === 'gated'
767
+ if (routeToHuman) {
768
+ if (brokerInFlight) return // one broker request per wedged modal (the tick keeps firing)
769
+ // BOUNDED re-enqueue for a GENERIC unknown modal whose injected keys may not dismiss it (fork 3):
770
+ // after MAX_UNKNOWN_MODAL_ATTEMPTS answered rounds that leave the pane STILL static, stop asking —
771
+ // log once and safe-PARK (the peer stays blocked = safe; never an infinite ask loop nor a blind
772
+ // Yes). Counters reset on pane progress (loop top). Known classes (org-policy) have precise keys
773
+ // that DO dismiss, so this bound applies only to the heuristic unknown-modal path.
774
+ if (action.brokerKind === 'unknown-modal') {
775
+ if (unknownModalParked) return
776
+ if (unknownModalAttempts >= MAX_UNKNOWN_MODAL_ATTEMPTS) {
777
+ unknownModalParked = true
778
+ try {
779
+ console.warn(
780
+ `[supervisor] UNKNOWN-MODAL PARKED identity=${session} ts=${new Date().toISOString()} attempts=${unknownModalAttempts} — injected keys did not clear it; manual attention needed`,
781
+ )
782
+ } catch {
783
+ /* logging must never break the conduit */
784
+ }
785
+ return
786
+ }
787
+ unknownModalAttempts++
788
+ }
789
+ // Route to the human-approval broker (docs/17). A blocking POST awaits a human's allow/deny (≤
790
+ // broker 300s); routeCircuitBreaker injects the affirmative on allow, the class-specific decline
791
+ // on deny (dangerous-rm=2.No / command-approval|org-policy=3.No / unknown-modal=Esc), and fails
792
+ // safe to DENY on any broker fault. For unknown-modal the broker content carries EXPLICIT button
793
+ // semantics (Allow presses option 1 "<label>"; Deny cancels via Esc) so the human decides informed.
751
794
  brokerInFlight = true
752
795
  void routeCircuitBreaker(
753
- { taxonomy: action.taxonomy, detail: action.detail, approveBytes: action.bytes, denyBytes: action.denyBytes },
796
+ {
797
+ taxonomy: action.taxonomy,
798
+ brokerKind: action.brokerKind,
799
+ detail: action.detail,
800
+ option1: action.option1,
801
+ approveBytes: action.bytes,
802
+ denyBytes: action.denyBytes,
803
+ },
754
804
  {
755
805
  personality: brokerPersonality,
756
806
  runtime: brokerRuntime,
@@ -770,7 +820,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
770
820
  })
771
821
  return
772
822
  }
773
- // YOLO (default) — auto-press YES so a headless peer never hangs (owner decision: restores the
823
+ // YOLO + known class — auto-press YES so a headless peer never hangs (owner decision: restores the
774
824
  // pre-2.1.x bypass status quo). Leave a post-hoc AUDIT line (WHICH peer, WHAT taxonomy + parsed
775
825
  // command/target, WHEN) so a rare bad rm is at least visible after the fact. Best-effort log,
776
826
  // never gates the keypress.