@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.
- package/docs/17-approval.md +17 -1
- package/package.json +1 -1
- package/src/launch/adapters/claude.ts +56 -1
- package/src/launch/adapters/codex.ts +24 -0
- package/src/launch/adapters/modalDetect.ts +102 -0
- package/src/launch/types.ts +26 -1
- package/src/supervisor/approvalRoute.ts +50 -6
- package/src/supervisor/boot.ts +59 -13
- package/src/supervisor/daemon.ts +64 -14
package/docs/17-approval.md
CHANGED
|
@@ -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
|
|
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
|
@@ -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
|
+
}
|
package/src/launch/types.ts
CHANGED
|
@@ -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
|
|
18
|
+
/** Class tag — 'dangerous-rm' | 'command-approval' | 'org-policy' | 'unknown-modal'. Sent as the
|
|
19
|
+
* broker `tool`. */
|
|
19
20
|
taxonomy: string
|
|
20
|
-
/**
|
|
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
|
|
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
|
|
62
|
-
summary
|
|
105
|
+
content,
|
|
106
|
+
summary,
|
|
63
107
|
},
|
|
64
108
|
{ env: deps.env, fetch: deps.fetch, timeoutMs: deps.timeoutMs },
|
|
65
109
|
)
|
package/src/supervisor/boot.ts
CHANGED
|
@@ -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 +
|
|
38
|
-
*
|
|
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
|
-
//
|
|
47
|
-
// (
|
|
48
|
-
|
|
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
|
|
54
|
-
* mid-session blocking prompt no headless peer can answer:
|
|
55
|
-
* 1. a
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
742
|
-
//
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
{
|
|
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
|
|
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.
|