@agfpd/iapeer 0.4.67 → 0.4.69
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/15-fleet-api.md +3 -1
- package/docs/17-approval.md +128 -0
- package/docs/README.md +1 -0
- package/docs/ru/17-approval.md +128 -0
- package/docs/ru/README.md +1 -0
- package/package.json +1 -1
- package/src/approval/brokerClient.ts +92 -0
- package/src/approval/cli.ts +118 -0
- package/src/approval/hook.ts +193 -0
- package/src/approval/install.ts +194 -0
- package/src/cli/index.ts +47 -0
- package/src/daemon/approvals.ts +202 -0
- package/src/daemon/approvalslog.ts +54 -0
- package/src/daemon/fleet.ts +88 -0
- package/src/identity/index.ts +28 -0
- package/src/identity/profileStandard.ts +3 -0
- package/src/launch/adapters/claude.ts +83 -16
- package/src/launch/adapters/codex.ts +14 -3
- package/src/launch/index.ts +1 -1
- package/src/launch/ptyHost.ts +3 -3
- package/src/launch/types.ts +27 -8
- package/src/lifecycle/index.ts +16 -1
- package/src/supervisor/approvalRoute.ts +76 -0
- package/src/supervisor/boot.ts +13 -3
- package/src/supervisor/daemon.ts +62 -6
- package/src/tray/install.ts +4 -3
- package/src/tray/render.ts +6 -2
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Supervisor circuit-breaker → human-approval routing (Unit 4, docs/17). The GATED half of the
|
|
2
|
+
// nag-watcher's 'approve' branch: instead of auto-pressing YES (the yolo status-quo), POST the breaker
|
|
3
|
+
// to the daemon approval broker, block on a human's allow/deny, and inject the decided keys into the
|
|
4
|
+
// pty — the AFFIRMATIVE keys on allow, the taxonomy-specific DECLINE keys on deny.
|
|
5
|
+
//
|
|
6
|
+
// FAIL-SAFE by construction: ANY broker failure (daemon unreachable, abort/timeout, non-200) resolves
|
|
7
|
+
// to DENY (inject the decline keys), NEVER auto-Yes — this is the engineering basis of gated=fail-safe
|
|
8
|
+
// (a gated peer under a supervision fault falls back to the safe answer, not the permissive one). The
|
|
9
|
+
// breaker still sits ABOVE the permission layer, so this is the ONLY interceptor for the prompts the
|
|
10
|
+
// runtime hook cannot see (dangerous-rm; an rm from a subprocess/script).
|
|
11
|
+
//
|
|
12
|
+
// IO is injected (fetch, the pty-write, the log, the clock), so the routing + fail-safe + key selection
|
|
13
|
+
// are unit-testable without a live daemon or a real pty.
|
|
14
|
+
|
|
15
|
+
import { requestApproval, resolveFleetBase, type ApprovalDecision } from '../approval/brokerClient.ts'
|
|
16
|
+
|
|
17
|
+
export interface BreakerApprovalInput {
|
|
18
|
+
/** Class tag — 'dangerous-rm' | 'command-approval'. Sent as the broker `tool` + `summary`. */
|
|
19
|
+
taxonomy: string
|
|
20
|
+
/** Best-effort one-line command/target trace parsed off the pane. Sent as the broker `content`. */
|
|
21
|
+
detail: string
|
|
22
|
+
/** Keys to inject on ALLOW — the position-robust affirmative ('1'+Enter). */
|
|
23
|
+
approveBytes: Buffer
|
|
24
|
+
/** Keys to inject on DENY — the taxonomy-specific decline (2.No | 3.No). */
|
|
25
|
+
denyBytes: Buffer
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BreakerRouteDeps {
|
|
29
|
+
personality: string
|
|
30
|
+
runtime: string
|
|
31
|
+
env: NodeJS.ProcessEnv
|
|
32
|
+
/** Inject keys into the pty (child.terminal.write). */
|
|
33
|
+
write: (bytes: Buffer) => void
|
|
34
|
+
/** Injectable fetch (tests). */
|
|
35
|
+
fetch?: typeof fetch
|
|
36
|
+
/** Injectable client timeout (tests). */
|
|
37
|
+
timeoutMs?: number
|
|
38
|
+
/** Post-hoc audit line (console.warn in prod; a spy in tests). */
|
|
39
|
+
log?: (line: string) => void
|
|
40
|
+
/** Injectable clock (tests). Default Date.now. */
|
|
41
|
+
now?: () => number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* POST the circuit-breaker to the human-approval broker, await the decision, and inject the decided
|
|
46
|
+
* keys into the pty. Returns the decision for the caller's cooldown/in-flight bookkeeping. NEVER
|
|
47
|
+
* throws — a broker failure fails safe to deny (decline keys injected). Every path leaves one audit
|
|
48
|
+
* line: BREAKER-ALLOW / BREAKER-DENY with the taxonomy, the parsed detail, and (on deny) the reason.
|
|
49
|
+
*/
|
|
50
|
+
export async function routeCircuitBreaker(input: BreakerApprovalInput, deps: BreakerRouteDeps): Promise<ApprovalDecision> {
|
|
51
|
+
const now = deps.now ?? Date.now
|
|
52
|
+
let decision: ApprovalDecision
|
|
53
|
+
try {
|
|
54
|
+
decision = await requestApproval(
|
|
55
|
+
resolveFleetBase(deps.env),
|
|
56
|
+
{
|
|
57
|
+
personality: deps.personality,
|
|
58
|
+
runtime: deps.runtime,
|
|
59
|
+
kind: 'circuit-breaker',
|
|
60
|
+
tool: input.taxonomy,
|
|
61
|
+
content: input.detail,
|
|
62
|
+
summary: input.taxonomy,
|
|
63
|
+
},
|
|
64
|
+
{ env: deps.env, fetch: deps.fetch, timeoutMs: deps.timeoutMs },
|
|
65
|
+
)
|
|
66
|
+
} catch (e) {
|
|
67
|
+
// Broker unreachable / abort / non-200 → DENY (never auto-Yes). gated = fail-safe.
|
|
68
|
+
decision = { decision: 'deny', reason: `iapeer approval unavailable (${e instanceof Error ? e.message : String(e)}) — denied fail-safe` }
|
|
69
|
+
}
|
|
70
|
+
deps.write(decision.decision === 'allow' ? input.approveBytes : input.denyBytes)
|
|
71
|
+
deps.log?.(
|
|
72
|
+
`[supervisor] BREAKER-${decision.decision.toUpperCase()} ${input.taxonomy} ts=${new Date(now()).toISOString()} ${input.detail}` +
|
|
73
|
+
(decision.reason ? ` reason=${JSON.stringify(decision.reason)}` : ''),
|
|
74
|
+
)
|
|
75
|
+
return decision
|
|
76
|
+
}
|
package/src/supervisor/boot.ts
CHANGED
|
@@ -38,12 +38,14 @@ export function nextBootAction(adapter: BootPredicates, viewport: string, enc: K
|
|
|
38
38
|
* claudeAdapter satisfies both; codex/router omit them → no action. */
|
|
39
39
|
export interface NagPredicate {
|
|
40
40
|
nagDismissKeys?(pane: string): string[] | null
|
|
41
|
-
blockingConfirm?(pane: string): { keys: string[]; taxonomy: string; detail: string } | null
|
|
41
|
+
blockingConfirm?(pane: string): { keys: string[]; denyKeys: string[]; taxonomy: string; detail: string } | null
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export type NagAction =
|
|
45
45
|
| { kind: 'dismiss'; keys: string[]; bytes: Buffer } // benign upsell — press keys silently
|
|
46
|
-
|
|
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 }
|
|
47
49
|
| { kind: 'none' }
|
|
48
50
|
|
|
49
51
|
/**
|
|
@@ -59,7 +61,15 @@ export type NagAction =
|
|
|
59
61
|
export function nextNagAction(adapter: NagPredicate, viewport: string, enc: KeyEncoding = {}): NagAction {
|
|
60
62
|
const confirm = adapter.blockingConfirm?.(viewport)
|
|
61
63
|
if (confirm && confirm.keys.length)
|
|
62
|
-
return {
|
|
64
|
+
return {
|
|
65
|
+
kind: 'approve',
|
|
66
|
+
keys: confirm.keys,
|
|
67
|
+
bytes: keysToBytes(confirm.keys, enc),
|
|
68
|
+
denyKeys: confirm.denyKeys,
|
|
69
|
+
denyBytes: keysToBytes(confirm.denyKeys, enc),
|
|
70
|
+
taxonomy: confirm.taxonomy,
|
|
71
|
+
detail: confirm.detail,
|
|
72
|
+
}
|
|
63
73
|
const keys = adapter.nagDismissKeys?.(viewport)
|
|
64
74
|
if (keys && keys.length) return { kind: 'dismiss', keys, bytes: keysToBytes(keys, enc) }
|
|
65
75
|
return { kind: 'none' }
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -31,6 +31,8 @@ import { newStuckGate, nextBootAction, nextNagAction, paneIsStuck, type BootPred
|
|
|
31
31
|
import { modelToPlainText } from './render.ts'
|
|
32
32
|
import { codexAdapter } from '../launch/adapters/codex.ts'
|
|
33
33
|
import { claudeAdapter } from '../launch/adapters/claude.ts'
|
|
34
|
+
import { approvalModeOf, readPeerProfile, type ApprovalMode } from '../identity/index.ts'
|
|
35
|
+
import { routeCircuitBreaker } from './approvalRoute.ts'
|
|
34
36
|
|
|
35
37
|
/** Resolve a runtime launcher from the host AT CALL TIME — env override (IAPEER_<RT>_BIN, the same
|
|
36
38
|
* knob the rest of the foundation uses) → PATH lookup — never a baked machine-specific path. Returns
|
|
@@ -688,6 +690,30 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
688
690
|
// the now-visible composer). Only runs for a runtime whose adapter declares nagDismissKeys.
|
|
689
691
|
const nagAdapter = BOOT_ADAPTERS[runtime] as NagPredicate | undefined
|
|
690
692
|
if (nagAdapter?.nagDismissKeys || nagAdapter?.blockingConfirm) {
|
|
693
|
+
// ── Unit 4 — approval-mode routing for the circuit-breaker ──────────────────────
|
|
694
|
+
// Resolve the peer's approval mode ONCE at watcher setup: the profile is stable for the session (a
|
|
695
|
+
// mid-session flip needs a fresh launch), and re-reading it every tick would hammer the fs. yolo
|
|
696
|
+
// (default) → the breaker AUTO-presses YES as before (restores the pre-2.1.x bypass status quo,
|
|
697
|
+
// owner decision). gated → the breaker ROUTES the confirm to the human-approval broker (blocking
|
|
698
|
+
// POST, inject the human's Yes/No). approvalModeOf is the ONE "absent⇒yolo" default — never
|
|
699
|
+
// re-derived here; an unreadable/malformed profile → yolo (approvalModeOf(null)), the safe backstop
|
|
700
|
+
// (a gated peer only launched WITHOUT bypass because the SAME profile read gated moments earlier).
|
|
701
|
+
let approvalMode: ApprovalMode = 'yolo'
|
|
702
|
+
try {
|
|
703
|
+
approvalMode = approvalModeOf(readPeerProfile(opts.cwd ?? runDir))
|
|
704
|
+
} catch {
|
|
705
|
+
/* unreadable / malformed profile → yolo default */
|
|
706
|
+
}
|
|
707
|
+
// Broker identity: prefer the composed child-env ABI (PEER_PERSONALITY/RUNTIME, invocation.ts), else
|
|
708
|
+
// derive from the session name (`<runtime>-<personality>`) by STRIPPING the runtime prefix — the
|
|
709
|
+
// personality itself may carry a '-', so never split on the first dash.
|
|
710
|
+
const brokerEnv = opts.env ?? process.env
|
|
711
|
+
const brokerPersonality =
|
|
712
|
+
brokerEnv.PEER_PERSONALITY?.trim() || (session.startsWith(`${runtime}-`) ? session.slice(runtime.length + 1) : session)
|
|
713
|
+
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,
|
|
715
|
+
// the pane stays stuck), so gate to EXACTLY ONE broker request per wedged modal.
|
|
716
|
+
let brokerInFlight = false
|
|
691
717
|
let nagFiredMs = 0
|
|
692
718
|
// В60 — stuck-gate: a REAL blocked modal freezes the pty entirely, while a live peer merely
|
|
693
719
|
// RENDERING the modal text (code review / quoted report — the В40 false-fire class) keeps
|
|
@@ -712,12 +738,42 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
712
738
|
child.terminal.write(action.bytes)
|
|
713
739
|
nagFiredMs = Date.now()
|
|
714
740
|
} else if (action.kind === 'approve') {
|
|
715
|
-
// CIRCUIT-BREAKER confirm (e.g. dangerous-rm) —
|
|
716
|
-
// hangs
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
|
751
|
+
brokerInFlight = true
|
|
752
|
+
void routeCircuitBreaker(
|
|
753
|
+
{ taxonomy: action.taxonomy, detail: action.detail, approveBytes: action.bytes, denyBytes: action.denyBytes },
|
|
754
|
+
{
|
|
755
|
+
personality: brokerPersonality,
|
|
756
|
+
runtime: brokerRuntime,
|
|
757
|
+
env: brokerEnv,
|
|
758
|
+
write: b => child.terminal.write(b),
|
|
759
|
+
log: line => {
|
|
760
|
+
try {
|
|
761
|
+
console.warn(line)
|
|
762
|
+
} catch {
|
|
763
|
+
/* logging must never break the conduit */
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
).finally(() => {
|
|
768
|
+
brokerInFlight = false
|
|
769
|
+
nagFiredMs = Date.now() // just answered → let the (now-clearing) modal repaint away before re-check
|
|
770
|
+
})
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
// YOLO (default) — auto-press YES so a headless peer never hangs (owner decision: restores the
|
|
774
|
+
// pre-2.1.x bypass status quo). Leave a post-hoc AUDIT line (WHICH peer, WHAT taxonomy + parsed
|
|
775
|
+
// command/target, WHEN) so a rare bad rm is at least visible after the fact. Best-effort log,
|
|
776
|
+
// never gates the keypress.
|
|
721
777
|
child.terminal.write(action.bytes)
|
|
722
778
|
nagFiredMs = Date.now()
|
|
723
779
|
try {
|
package/src/tray/install.ts
CHANGED
|
@@ -100,9 +100,10 @@ export function buildPluginShim(binPath: string): string {
|
|
|
100
100
|
'# <xbar.author>iapeer</xbar.author>',
|
|
101
101
|
'# <xbar.desc>iapeer multi-agent fleet — live peer dashboard over the daemon fleet API.</xbar.desc>',
|
|
102
102
|
'# <swiftbar.type>streamable</swiftbar.type>',
|
|
103
|
-
'
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
// NB: deliberately NOT hiding SwiftBar's own service items (Run-in-Terminal /
|
|
104
|
+
// Disable / Last-Updated / Preferences / Refresh). They are the right-click "do
|
|
105
|
+
// stuff" menu the owner expects; hiding them made the icon feel dead. Minimalism is
|
|
106
|
+
// for OUR content (status + peer rows), not SwiftBar's utility menu.
|
|
106
107
|
// The dashboard ONLY ever talks to the local daemon (unix socket / loopback), so it
|
|
107
108
|
// must never route through an HTTP proxy. Bun's fetch caches proxy config at process
|
|
108
109
|
// start and ignores NO_PROXY for loopback, so clearing the vars must happen in the
|
package/src/tray/render.ts
CHANGED
|
@@ -163,8 +163,11 @@ export function renderSwiftBar(snapshot: TraySnapshot, opts: RenderOptions): str
|
|
|
163
163
|
lines.push(...renderManage(peers, bin))
|
|
164
164
|
|
|
165
165
|
// ── footer ──
|
|
166
|
+
// No explicit "Refresh" item: the plugin is streamable + SSE-driven, so the menu is
|
|
167
|
+
// always current on open — a manual refresh gives the user nothing and (being a
|
|
168
|
+
// SwiftBar refresh) just closes the dropdown. The emergency "restart the plugin" lever
|
|
169
|
+
// lives in SwiftBar's own service menu (right-click → Refresh).
|
|
166
170
|
lines.push('---')
|
|
167
|
-
lines.push(line(0, 'Refresh', { refresh: true, sfimage: 'arrow.clockwise' }))
|
|
168
171
|
lines.push(line(0, `iapeer · fleet dashboard`, { color: COLOR.meta, size: 11 }))
|
|
169
172
|
return lines.join('\n') + '\n'
|
|
170
173
|
}
|
|
@@ -222,6 +225,8 @@ function renderManage(peers: TrayPeer[], bin: string): string[] {
|
|
|
222
225
|
for (const p of peers) {
|
|
223
226
|
out.push(line(1, p.personality))
|
|
224
227
|
for (const c of MANAGE_COMMANDS) {
|
|
228
|
+
// No refresh=true: the SSE stream reflects the command's lifecycle event on its
|
|
229
|
+
// own (streamable is always fresh); a refresh would only restart the stream.
|
|
225
230
|
out.push(
|
|
226
231
|
line(2, c.label, {
|
|
227
232
|
bash: bin,
|
|
@@ -230,7 +235,6 @@ function renderManage(peers: TrayPeer[], bin: string): string[] {
|
|
|
230
235
|
param3: c.cmd,
|
|
231
236
|
param4: p.personality,
|
|
232
237
|
terminal: false,
|
|
233
|
-
refresh: true,
|
|
234
238
|
}),
|
|
235
239
|
)
|
|
236
240
|
}
|