@agfpd/iapeer 0.4.70 → 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.70",
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",
@@ -65,6 +65,27 @@ export function defaultIntelligenceForRuntime(runtime: string): Intelligence {
65
65
  return 'artificial'
66
66
  }
67
67
 
68
+ /**
69
+ * The intelligences a runtime may LEGITIMATELY carry (the single Ф0 source; provision
70
+ * validates against it, the launch gate enforces it). Identity (natural/absent/artificial)
71
+ * is ORTHOGONAL to the channel: a human-channel runtime can carry a HUMAN (natural) OR a
72
+ * FACELESS SERVICE bot (absent) — e.g. a Telegram approval-card bot — but never an LLM agent
73
+ * (artificial) on that channel. A service-only runtime carries services (absent). An agentic
74
+ * runtime carries an LLM (artificial). The DEFAULT (defaultIntelligenceForRuntime) stays the
75
+ * first of each set, so an existing peer with no explicit nature is unchanged — `absent` on a
76
+ * channel runtime is strictly OPT-IN (an explicit `--intelligence absent` / manifest decl).
77
+ */
78
+ export function allowedIntelligencesForRuntime(runtime: string): readonly Intelligence[] {
79
+ if (NATURAL_RUNTIMES.has(runtime)) return ['natural', 'absent']
80
+ if (ABSENT_RUNTIMES.has(runtime)) return ['absent']
81
+ return ['artificial']
82
+ }
83
+
84
+ /** Is `intelligence` a legitimate nature for `runtime`? (provision fail-loud + launch gate.) */
85
+ export function isIntelligenceAllowedForRuntime(runtime: string, intelligence: Intelligence): boolean {
86
+ return allowedIntelligencesForRuntime(runtime).includes(intelligence)
87
+ }
88
+
68
89
  // Infra runtimes are ALWAYS-ON (held live by launchd KeepAlive), as opposed to the
69
90
  // warm-on-demand agentic runtimes (claude/codex, woken by the daemon). Liveness is
70
91
  // a property of the RUNTIME, not the personality (zone Идентичность). Infra runtimes
@@ -11,8 +11,10 @@ import {
11
11
  IAPEER_DIR,
12
12
  PEER_PROFILE_FILE,
13
13
  NAME_RE,
14
+ allowedIntelligencesForRuntime,
14
15
  defaultIntelligenceForRuntime,
15
16
  isInfraRuntime,
17
+ isIntelligenceAllowedForRuntime,
16
18
  isRuntime,
17
19
  isValidName,
18
20
  normalizeIntelligenceValue,
@@ -131,6 +133,12 @@ export interface EnsurePeerProfileOptions {
131
133
  * always-on plist so launchd's minimal PATH resolves it. Forwarded to
132
134
  * installAlwaysOnPlist; ignored for warm-on-demand runtimes. */
133
135
  runtimeBin?: string
136
+ /** Explicit peer nature (natural/absent/artificial). OPT-IN override of the runtime
137
+ * default (defaultIntelligenceForRuntime): a channel runtime defaults `natural` but may be
138
+ * provisioned `absent` for a FACELESS SERVICE bot (e.g. a Telegram approval-card bot).
139
+ * Validated against allowedIntelligencesForRuntime — an out-of-set value fails LOUD. Absent
140
+ * (undefined) ⇒ the runtime default (unchanged for every existing peer). */
141
+ intelligence?: Intelligence
134
142
  /** В36 — peer description, persisted into the LOCAL profile (the source of truth the
135
143
  * registry is reindexed FROM). Writing it only to the registry made it a stale copy:
136
144
  * the first reindexFromLocals (addRuntime on a dual-runtime create, connect telegram,
@@ -503,6 +511,21 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
503
511
  const peers = options.peers ?? []
504
512
  ensureLocalIapScaffold(cwd)
505
513
  const discoveredRuntimes = discoverPeerRuntimes(cwd, options.runtime)
514
+ // Nature = the EXPLICIT intelligence when supplied (validated against the runtime's allowed
515
+ // set — an out-of-set value, e.g. artificial on telegram, fails LOUD), else the runtime
516
+ // default. This is where a FACELESS SERVICE bot (absent) on a channel runtime becomes
517
+ // first-class (docs/Идентичность); an omitted intelligence keeps every existing peer's
518
+ // default nature unchanged.
519
+ const resolvedIntelligence = (rt: Runtime): Intelligence => {
520
+ const explicit = options.intelligence
521
+ if (explicit === undefined) return defaultIntelligenceForRuntime(rt)
522
+ if (!isIntelligenceAllowedForRuntime(rt, explicit)) {
523
+ throw new IapError(
524
+ `intelligence "${explicit}" is not valid for runtime "${rt}" — allowed: ${allowedIntelligencesForRuntime(rt).join(', ')}`,
525
+ )
526
+ }
527
+ return explicit
528
+ }
506
529
  const existing = readPeerProfile(cwd)
507
530
  if (!existing) {
508
531
  let personality: string
@@ -550,7 +573,7 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
550
573
  // В36 — the description lands in the LOCAL profile at birth (source of truth);
551
574
  // the registry row is a projection of this file, not the other way around.
552
575
  description: options.description?.trim() ?? '',
553
- intelligence: defaultIntelligenceForRuntime(options.runtime),
576
+ intelligence: resolvedIntelligence(options.runtime),
554
577
  }
555
578
  ensureLocalRuntimeScopes(cwd, profile.runtimes)
556
579
  // INFRA runtime → provision the always-on launchd plist that holds it live.
@@ -607,7 +630,10 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
607
630
  runtimeBin: options.runtimeBin,
608
631
  env: options.env,
609
632
  })
610
- intelligence = defaultIntelligenceForRuntime(options.runtime)
633
+ // Re-provision honors an EXPLICIT nature (a manifest-declared absent service bot survives
634
+ // update-runtime re-provision), else the runtime default (the legacy vocab-flip this fix
635
+ // preserved). resolvedIntelligence validates the explicit against the runtime's allowed set.
636
+ intelligence = resolvedIntelligence(options.runtime)
611
637
  }
612
638
  // В36 — an explicit description on RE-provision updates the local profile too (H1 in
613
639
  // writePeerProfileAtomic still guards: absent/empty never wipes an existing value).
@@ -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
+ }
@@ -23,6 +23,7 @@
23
23
  // that is install-time (blueprint §0.6 fast-wake), not session bring-up.
24
24
 
25
25
  import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
26
+ import { allowedIntelligencesForRuntime } from '../../core/constants.ts'
26
27
 
27
28
  // ─────────────────────────────────────────────────────────────────────────────
28
29
  // telegramAdapter
@@ -38,13 +39,14 @@ export const telegramAdapter: RuntimeAdapter = {
38
39
  deliveryMarkers: { promptGlyphs: [] },
39
40
 
40
41
  /**
41
- * telegram is a HUMAN channel — launch REFUSES a non-natural peer (fail-loud).
42
- * Porting the persistent-peer FATAL guard (it stood in two places): a peer with
43
- * intelligence artificial/absent must never be brought up on telegram (it would
44
- * route a human's bot to an agent/script). The launch primitive enforces this
45
- * against LaunchSpec.intelligence; source of intelligence → docs/Идентичность.
42
+ * telegram carries a HUMAN (natural) OR a FACELESS SERVICE bot (absent) — but never an
43
+ * LLM agent (artificial), which would route a human's channel to an agent/script. Launch
44
+ * REFUSES anything outside this set (fail-loud), the persistent-peer FATAL guard relaxed to
45
+ * first-class faceless service bots (e.g. a Telegram approval-card bot). Sourced from the
46
+ * single Ф0 truth (allowedIntelligencesForRuntime); enforced by the launch primitive against
47
+ * LaunchSpec.intelligence. Source of intelligence → docs/Идентичность.
46
48
  */
47
- requiresIntelligence: 'natural',
49
+ allowedIntelligences: allowedIntelligencesForRuntime('telegram'),
48
50
 
49
51
  /**
50
52
  * argv = telegramBin + 'run' + extraArgs (telegram-start.sh:119,
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // voicetalk is a presence-runtime for a HUMAN (the owner's voice channel), modelled on
11
11
  // telegram per the foundation presence-runtime contract: kind:'router',
12
- // usesDoctrine:false, requiresIntelligence:'natural', argv = `<bin> run <extra>`. The
12
+ // usesDoctrine:false, allowedIntelligences:['natural','absent'], argv = `<bin> run <extra>`. The
13
13
  // STT/TTS engines and providers live in a shared voice service (voice-connect), NOT in
14
14
  // this adapter and NOT in the peer passport — interfaces.voicetalk carries only the
15
15
  // binding ({wake_word, device}), opaque to the foundation.
@@ -19,6 +19,7 @@
19
19
  // bring-up.
20
20
 
21
21
  import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
22
+ import { allowedIntelligencesForRuntime } from '../../core/constants.ts'
22
23
 
23
24
  // ─────────────────────────────────────────────────────────────────────────────
24
25
  // voicetalkAdapter
@@ -34,13 +35,12 @@ export const voicetalkAdapter: RuntimeAdapter = {
34
35
  deliveryMarkers: { promptGlyphs: [] },
35
36
 
36
37
  /**
37
- * voicetalk is a HUMAN channel — launch REFUSES a non-natural peer (fail-loud).
38
- * Same nature gate as telegram: a peer with intelligence artificial/absent must never
39
- * be brought up on voicetalk (it would route the owner's voice channel to an
40
- * agent/script). The launch primitive enforces this against LaunchSpec.intelligence;
41
- * source of intelligence → docs/Идентичность.
38
+ * voicetalk carries a HUMAN (natural) OR a FACELESS SERVICE bot (absent) — never an LLM
39
+ * agent (artificial), which would route the owner's voice channel to an agent/script. Same
40
+ * nature gate as telegram, sourced from the single Ф0 truth (allowedIntelligencesForRuntime);
41
+ * enforced by the launch primitive against LaunchSpec.intelligence. Source → docs/Идентичность.
42
42
  */
43
- requiresIntelligence: 'natural',
43
+ allowedIntelligences: allowedIntelligencesForRuntime('voicetalk'),
44
44
 
45
45
  /**
46
46
  * argv = voicetalkBin + 'run' + extraArgs.
@@ -131,14 +131,16 @@ export const launch: LaunchFn = async (
131
131
  const { identity, socketPath: sock, cwd } = spec
132
132
  const env = cfg.env ?? process.env
133
133
 
134
- // (0) Intelligence gate (fail-loud BEFORE any tmux work): an adapter that declares
135
- // requiresIntelligence (telegram → 'natural') refuses a peer whose nature does
136
- // not match — and refuses too when the nature is unknown (cannot confirm). Ports
137
- // the persistent-peer FATAL human-channel guard (docs/Рантайм-адаптеры).
138
- if (adapter.requiresIntelligence && spec.intelligence !== adapter.requiresIntelligence) {
134
+ // (0) Intelligence gate (fail-loud BEFORE any bring-up work): an adapter that declares
135
+ // allowedIntelligences (telegram/voicetalk → ['natural','absent']) refuses a peer whose
136
+ // nature is not IN the set — and refuses too when the nature is unknown (cannot confirm).
137
+ // A channel runtime carries a human (natural) OR a faceless service bot (absent), never an
138
+ // LLM agent (artificial). Ports the persistent-peer FATAL human-channel guard, relaxed to
139
+ // first-class faceless service bots (docs/Рантайм-адаптеры).
140
+ if (adapter.allowedIntelligences && (spec.intelligence == null || !adapter.allowedIntelligences.includes(spec.intelligence))) {
139
141
  return fail(
140
142
  identity,
141
- `runtime "${spec.runtime}" requires intelligence=${adapter.requiresIntelligence}, ` +
143
+ `runtime "${spec.runtime}" requires intelligence ∈ {${adapter.allowedIntelligences.join(', ')}}, ` +
142
144
  `peer "${spec.personality}" is ${spec.intelligence ?? 'unknown'} — refusing to launch`,
143
145
  )
144
146
  }
@@ -160,9 +160,9 @@ export interface LaunchSpec {
160
160
  /** Free-form extra CLI args (PEER_START_ARGS). */
161
161
  extraArgs?: string[]
162
162
  /** Peer intelligence (artificial/natural/absent). Used to enforce an adapter's
163
- * intelligence gate at launch (telegram requires natural). Optional: a doctrine-
164
- * less throwaway may omit it, but an adapter that declares requiresIntelligence
165
- * then refuses (cannot confirm the required nature). */
163
+ * intelligence gate at launch (telegram allows natural|absent). Optional: a doctrine-
164
+ * less throwaway may omit it, but an adapter that declares allowedIntelligences
165
+ * then refuses (cannot confirm the nature is in the set). */
166
166
  intelligence?: Intelligence
167
167
  /** Human-approval mode (docs/17): `yolo` (default/omitted) launches with the
168
168
  * runtime bypass flag (current fleet behavior); `gated` launches WITHOUT bypass
@@ -273,13 +273,14 @@ export interface RuntimeAdapter {
273
273
  deliveryConfirm?: 'transcript' | 'socket-ack'
274
274
 
275
275
  /**
276
- * If set, launch REFUSES unless the peer's intelligence equals this value
277
- * (fail-loud). telegram → 'natural' — it is a human channel; launching an
278
- * artificial/absent peer on it is a category error (the persistent-peer path held
279
- * a FATAL guard in two places). Most adapters omit it (no nature gate). Source of intelligence →
276
+ * If set, launch REFUSES unless the peer's intelligence is IN this set (fail-loud —
277
+ * also when the nature is unknown). A channel runtime carries a HUMAN (natural) OR a
278
+ * FACELESS SERVICE bot (absent) but never an LLM agent (artificial): telegram/voicetalk →
279
+ * ['natural','absent'] (sourced from allowedIntelligencesForRuntime — the single Ф0 truth).
280
+ * Most adapters omit it (no nature gate — claude/codex/notifier). Source of intelligence →
280
281
  * docs/Идентичность; enforced by the launch primitive against LaunchSpec.intelligence.
281
282
  */
282
- requiresIntelligence?: Intelligence
283
+ allowedIntelligences?: readonly Intelligence[]
283
284
 
284
285
  /**
285
286
  * Build the runtime argv: binary + flags, wiring systemPromptFile per-runtime
@@ -328,8 +329,33 @@ export interface RuntimeAdapter {
328
329
  * content mentioning the phrase never fires; else null. Optional (codex/router omit it). tui only.
329
330
  * Human-approval lands via the daemon broker (docs/17); the vault idea is "Human-approval
330
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).
331
337
  */
332
- 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
333
359
 
334
360
  /** Is the input surface ready for the first message? (tui: ready marker present
335
361
  * AND startup dialogs gone). Router runtimes return true (no input surface).
@@ -89,6 +89,11 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
89
89
  peers,
90
90
  personality: opts.personality,
91
91
  runtimeBin,
92
+ // The EXPLICIT nature now reaches the LOCAL profile too (A1) — previously it landed only
93
+ // in the registry upsert below, so a `--intelligence absent` telegram bot got a natural
94
+ // local profile (split-brain) and its plist crash-looped the natural gate. Validated
95
+ // against the runtime's allowed set inside ensurePeerProfile.
96
+ intelligence: opts.intelligence,
92
97
  // В36 — description goes into the LOCAL profile (source of truth); the registry
93
98
  // upsert below then persists a value reindexFromLocals can no longer wipe.
94
99
  description: opts.description,
@@ -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.