@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.
- package/docs/17-approval.md +17 -1
- package/package.json +1 -1
- package/src/core/constants.ts +21 -0
- package/src/identity/index.ts +28 -2
- 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/adapters/telegram.ts +8 -6
- package/src/launch/adapters/voicetalk.ts +7 -7
- package/src/launch/index.ts +8 -6
- package/src/launch/types.ts +35 -9
- package/src/provision/index.ts +5 -0
- 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
package/src/core/constants.ts
CHANGED
|
@@ -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
|
package/src/identity/index.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
43
|
+
allowedIntelligences: allowedIntelligencesForRuntime('voicetalk'),
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* argv = voicetalkBin + 'run' + extraArgs.
|
package/src/launch/index.ts
CHANGED
|
@@ -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
|
|
135
|
-
//
|
|
136
|
-
// not
|
|
137
|
-
//
|
|
138
|
-
|
|
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
|
|
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
|
}
|
package/src/launch/types.ts
CHANGED
|
@@ -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
|
|
164
|
-
* less throwaway may omit it, but an adapter that declares
|
|
165
|
-
* then refuses (cannot confirm the
|
|
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
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
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
|
-
|
|
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).
|
package/src/provision/index.ts
CHANGED
|
@@ -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
|
|
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.
|