@agfpd/iapeer 0.4.68 → 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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Approval-broker durable log — the daemon's DURABLE trace of every human-approval
|
|
2
|
+
// request it brokers (docs/17). Sibling to lifecycle.log / delivery.log / exits.log
|
|
3
|
+
// under cfg.eventLogDir; tailed by the fleet-API SSE stream (fleetEventFiles), so the
|
|
4
|
+
// `approval-request` / `approval-resolved` events reach every operator client (CLI,
|
|
5
|
+
// tray, telegram) with at-least-once semantics — and a postmortem can reconstruct who
|
|
6
|
+
// asked, what, and how it resolved after the in-memory queue is gone.
|
|
7
|
+
//
|
|
8
|
+
// What is logged: approval METADATA + a one-line `summary` of the action — NOT the full
|
|
9
|
+
// multi-line content (a Write's whole body / a long diff would bloat the log). The full
|
|
10
|
+
// human-readable content lives in the in-memory queue and is served by GET
|
|
11
|
+
// /fleet/v1/approvals/<id>; the log carries enough to correlate + render a badge/line.
|
|
12
|
+
//
|
|
13
|
+
// Same rotation class as its siblings: written by OUR code → self-rotates via the shared
|
|
14
|
+
// storage/rotatelog primitive. The dir is passed IN by the composition point, never
|
|
15
|
+
// re-resolved from env — a falsy dir → no-op (library/test daemons stay hermetic).
|
|
16
|
+
|
|
17
|
+
import { join } from 'path'
|
|
18
|
+
import { DEFAULT_LOG_KEEP, DEFAULT_LOG_MAX_BYTES, appendRotatedEvent } from '../storage/rotatelog.ts'
|
|
19
|
+
|
|
20
|
+
/** The approval-broker log inside `logDir` (sibling to lifecycle.log). */
|
|
21
|
+
export function approvalsLogPath(logDir: string): string {
|
|
22
|
+
return join(logDir, 'approvals.log')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function envPosInt(raw: string | undefined, dflt: number): number {
|
|
26
|
+
const n = parseInt(raw ?? '', 10)
|
|
27
|
+
return Number.isFinite(n) && n > 0 ? n : dflt
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AppendApprovalOptions {
|
|
31
|
+
/** Reads the rotation knobs IAPEER_APPROVALS_LOG_MAX_BYTES / _KEEP. */
|
|
32
|
+
env?: NodeJS.ProcessEnv
|
|
33
|
+
/** Stamp the line with this epoch-ms. Default Date.now(). */
|
|
34
|
+
nowMs?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Append one approval event line into `logDir`/approvals.log. A falsy `logDir` is a
|
|
39
|
+
* no-op (the default for library/test daemons). Fully best-effort — never throws;
|
|
40
|
+
* logging must never fail an approval round-trip.
|
|
41
|
+
*/
|
|
42
|
+
export function appendApprovalEvent(
|
|
43
|
+
logDir: string | undefined,
|
|
44
|
+
fields: Record<string, string | number | undefined>,
|
|
45
|
+
opts: AppendApprovalOptions = {},
|
|
46
|
+
): void {
|
|
47
|
+
if (!logDir) return
|
|
48
|
+
const env = opts.env ?? process.env
|
|
49
|
+
appendRotatedEvent(approvalsLogPath(logDir), fields, {
|
|
50
|
+
nowMs: opts.nowMs,
|
|
51
|
+
maxBytes: envPosInt(env.IAPEER_APPROVALS_LOG_MAX_BYTES, DEFAULT_LOG_MAX_BYTES),
|
|
52
|
+
keep: envPosInt(env.IAPEER_APPROVALS_LOG_KEEP, DEFAULT_LOG_KEEP),
|
|
53
|
+
})
|
|
54
|
+
}
|
package/src/daemon/fleet.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
isEphemeralPeer,
|
|
43
43
|
isLaunchdManaged,
|
|
44
44
|
loadLifecycleConfig,
|
|
45
|
+
peerApprovalMode,
|
|
45
46
|
wakeOrSpawn,
|
|
46
47
|
type LifecycleConfig,
|
|
47
48
|
} from '../lifecycle/index.ts'
|
|
@@ -59,6 +60,8 @@ import {
|
|
|
59
60
|
} from '../storage/rotatelog.ts'
|
|
60
61
|
import { heartbeatAgeSecs, probeFullDiskAccess, readMemoryProvider, readVoiceProvider } from '../status/index.ts'
|
|
61
62
|
import { deliveryLogPath } from './deliverylog.ts'
|
|
63
|
+
import { approvalsLogPath } from './approvalslog.ts'
|
|
64
|
+
import { ApprovalBroker } from './approvals.ts'
|
|
62
65
|
import { callTool, type CallToolDeps } from './index.ts'
|
|
63
66
|
|
|
64
67
|
/** The API generation baked into every snapshot (additive evolution within v1 —
|
|
@@ -93,6 +96,9 @@ export interface FleetPeer {
|
|
|
93
96
|
wake_policy: 'warm' | 'ephemeral'
|
|
94
97
|
/** Ephemeral peers only: pending serial-queue tasks across declared runtimes. */
|
|
95
98
|
queue_depth?: number
|
|
99
|
+
/** Human-approval mode (docs/17-approval): `yolo` (default — bypass + auto-confirm
|
|
100
|
+
* circuit-breakers) or `gated` (blocking runtime approvals routed to a human). */
|
|
101
|
+
approval_mode: 'yolo' | 'gated'
|
|
96
102
|
}
|
|
97
103
|
|
|
98
104
|
export interface FleetHost {
|
|
@@ -163,6 +169,9 @@ export function buildFleetSnapshot(
|
|
|
163
169
|
if (ephemeral) {
|
|
164
170
|
for (const s of r.runtimes) queueDepth += ephemeralQueueDepth(cfg, buildProcessAddress(s.runtime, r.personality))
|
|
165
171
|
}
|
|
172
|
+
// Approval mode from the LOCAL profile (same source as ephemeral); a read hiccup →
|
|
173
|
+
// the safe `yolo` default (peerApprovalMode swallows it).
|
|
174
|
+
const approvalMode = peerApprovalMode(r.cwd)
|
|
166
175
|
return {
|
|
167
176
|
personality: r.personality,
|
|
168
177
|
description: r.description,
|
|
@@ -176,6 +185,7 @@ export function buildFleetSnapshot(
|
|
|
176
185
|
launchd_managed: isLaunchdManaged(r.personality, env),
|
|
177
186
|
wake_policy: ephemeral ? ('ephemeral' as const) : ('warm' as const),
|
|
178
187
|
...(ephemeral ? { queue_depth: queueDepth } : {}),
|
|
188
|
+
approval_mode: approvalMode,
|
|
179
189
|
}
|
|
180
190
|
})
|
|
181
191
|
const mem = readMemoryProvider(env)
|
|
@@ -217,6 +227,9 @@ export function fleetEventFiles(cfg: LifecycleConfig): Array<{ path: string; src
|
|
|
217
227
|
{ path: lifecycleLogPath(cfg.eventLogDir), src: 'lifecycle' },
|
|
218
228
|
{ path: deliveryLogPath(cfg.eventLogDir), src: 'delivery' },
|
|
219
229
|
{ path: exitLogPath(cfg.eventLogDir), src: 'exits' },
|
|
230
|
+
// Human-approval broker events (docs/17): approval-request / approval-resolved.
|
|
231
|
+
// A pre-approval daemon simply never writes this file (readLogTail tolerates absence).
|
|
232
|
+
{ path: approvalsLogPath(cfg.eventLogDir), src: 'approvals' },
|
|
220
233
|
]
|
|
221
234
|
}
|
|
222
235
|
|
|
@@ -432,6 +445,8 @@ export interface FleetHandlerOptions {
|
|
|
432
445
|
ops?: FleetOps
|
|
433
446
|
/** Injectable tailer poll interval (tests). */
|
|
434
447
|
pollMs?: number
|
|
448
|
+
/** Injectable approval broker (tests — e.g. a short timeout). Default: a fresh one. */
|
|
449
|
+
broker?: ApprovalBroker
|
|
435
450
|
}
|
|
436
451
|
|
|
437
452
|
export type FleetHandler = (
|
|
@@ -451,6 +466,9 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
|
|
|
451
466
|
const startedAtMs = Date.now()
|
|
452
467
|
const files = fleetEventFiles(cfg)
|
|
453
468
|
const tailer = new FleetEventTailer(files, opts.pollMs)
|
|
469
|
+
// The approval broker — one per daemon; the ASK side (POST /approvals long-poll) and
|
|
470
|
+
// the ANSWER side (GET/POST /approvals/*) are all interfaces to this one queue.
|
|
471
|
+
const broker = opts.broker ?? new ApprovalBroker({ logDir: cfg.eventLogDir, env })
|
|
454
472
|
let opsPromise: Promise<FleetOps> | undefined
|
|
455
473
|
const getOps = (): Promise<FleetOps> => {
|
|
456
474
|
if (opts.ops) return Promise.resolve(opts.ops)
|
|
@@ -533,6 +551,72 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
|
|
|
533
551
|
if (result.isError) return sendJson(res, 502, { error: text })
|
|
534
552
|
return sendJson(res, 200, { from: caller.address, result: safeParse(text) })
|
|
535
553
|
}
|
|
554
|
+
// ── approvals — the human-approval broker surface (docs/17) ────────────
|
|
555
|
+
if (path === '/fleet/v1/approvals') {
|
|
556
|
+
if (method === 'GET') {
|
|
557
|
+
return sendJson(res, 200, { api: FLEET_API_VERSION, version: IAPEER_VERSION, ts: new Date().toISOString(), approvals: broker.list() })
|
|
558
|
+
}
|
|
559
|
+
if (method === 'POST') {
|
|
560
|
+
// The gated peer's hook BLOCKS here (long-poll): enqueue + await a decision.
|
|
561
|
+
let body: Record<string, unknown>
|
|
562
|
+
try {
|
|
563
|
+
body = await readBody(req)
|
|
564
|
+
} catch (e) {
|
|
565
|
+
return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
|
|
566
|
+
}
|
|
567
|
+
if (typeof body.personality !== 'string' || !body.personality || typeof body.tool !== 'string' || typeof body.content !== 'string') {
|
|
568
|
+
return sendJson(res, 400, { error: 'approval request needs {personality, runtime, tool, content} (+ optional kind/summary/title/approvers)' })
|
|
569
|
+
}
|
|
570
|
+
const { id, decision } = broker.request({
|
|
571
|
+
personality: body.personality,
|
|
572
|
+
runtime: typeof body.runtime === 'string' ? body.runtime : 'claude',
|
|
573
|
+
kind: typeof body.kind === 'string' ? body.kind : 'tool',
|
|
574
|
+
tool: body.tool,
|
|
575
|
+
content: body.content,
|
|
576
|
+
summary: typeof body.summary === 'string' ? body.summary : undefined,
|
|
577
|
+
title: typeof body.title === 'string' ? body.title : undefined,
|
|
578
|
+
approvers: Array.isArray(body.approvers) ? body.approvers.filter((a): a is string => typeof a === 'string') : undefined,
|
|
579
|
+
})
|
|
580
|
+
// No idle-socket kill during the (≤ broker-timeout) hold; cancel default-deny
|
|
581
|
+
// if the asking hook's transport dies before an answer (drop it from the queue).
|
|
582
|
+
res.setTimeout?.(0)
|
|
583
|
+
let settled = false
|
|
584
|
+
const onClose = (): void => {
|
|
585
|
+
if (!settled) broker.cancel(id)
|
|
586
|
+
}
|
|
587
|
+
req.on('close', onClose)
|
|
588
|
+
const d = await decision
|
|
589
|
+
settled = true
|
|
590
|
+
return sendJson(res, 200, { id, ...d })
|
|
591
|
+
}
|
|
592
|
+
return sendJson(res, 405, { error: 'method not allowed — GET|POST /fleet/v1/approvals' })
|
|
593
|
+
}
|
|
594
|
+
const apMatch = /^\/fleet\/v1\/approvals\/([^/]+)(?:\/(approve|deny))?$/.exec(path)
|
|
595
|
+
if (apMatch) {
|
|
596
|
+
const id = decodeURIComponent(apMatch[1]!)
|
|
597
|
+
const action = apMatch[2]
|
|
598
|
+
if (!action) {
|
|
599
|
+
if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/approvals/<id>' })
|
|
600
|
+
const item = broker.get(id)
|
|
601
|
+
if (!item) return sendJson(res, 404, { error: `no pending approval "${id}"` })
|
|
602
|
+
return sendJson(res, 200, { api: FLEET_API_VERSION, version: IAPEER_VERSION, ts: new Date().toISOString(), approval: item })
|
|
603
|
+
}
|
|
604
|
+
if (method !== 'POST') return sendJson(res, 405, { error: 'method not allowed — POST /fleet/v1/approvals/<id>/(approve|deny)' })
|
|
605
|
+
let body: Record<string, unknown> = {}
|
|
606
|
+
try {
|
|
607
|
+
body = await readBody(req)
|
|
608
|
+
} catch {
|
|
609
|
+
/* empty/invalid body is fine — approve/deny carry only optional fields */
|
|
610
|
+
}
|
|
611
|
+
const by = typeof body.approver === 'string' ? body.approver : undefined
|
|
612
|
+
const via = typeof body.via === 'string' ? body.via : 'cli'
|
|
613
|
+
const ok =
|
|
614
|
+
action === 'approve'
|
|
615
|
+
? broker.resolve(id, { decision: 'allow', reason: typeof body.reason === 'string' ? body.reason : undefined }, { by, via })
|
|
616
|
+
: broker.resolve(id, { decision: 'deny', reason: typeof body.reason === 'string' ? body.reason : 'denied by human' }, { by, via })
|
|
617
|
+
if (!ok) return sendJson(res, 404, { error: `no pending approval "${id}" (already resolved, expired, or unknown)` })
|
|
618
|
+
return sendJson(res, 200, { id, action, ok: true })
|
|
619
|
+
}
|
|
536
620
|
return sendJson(res, 404, {
|
|
537
621
|
error: `unknown fleet endpoint ${method} ${path}`,
|
|
538
622
|
endpoints: [
|
|
@@ -541,6 +625,10 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
|
|
|
541
625
|
'GET /fleet/v1/events?replay=N',
|
|
542
626
|
'POST /fleet/v1/peers/<peer>/(wake|stop|start|new|refresh|interrupt|compact)',
|
|
543
627
|
'POST /fleet/v1/send',
|
|
628
|
+
'GET /fleet/v1/approvals',
|
|
629
|
+
'POST /fleet/v1/approvals (hook long-poll)',
|
|
630
|
+
'GET /fleet/v1/approvals/<id>',
|
|
631
|
+
'POST /fleet/v1/approvals/<id>/(approve|deny)',
|
|
544
632
|
],
|
|
545
633
|
})
|
|
546
634
|
} catch (e) {
|
package/src/identity/index.ts
CHANGED
|
@@ -56,6 +56,14 @@ import {
|
|
|
56
56
|
* Enum (not bool) to leave room for future policies. */
|
|
57
57
|
export type WakePolicy = 'ephemeral'
|
|
58
58
|
|
|
59
|
+
/** Per-peer human-approval mode (foundation lifecycle-owned; docs/17-approval).
|
|
60
|
+
* `yolo` (default, absent) = current fleet behavior — runtime bypass flag + the
|
|
61
|
+
* supervisor auto-confirms circuit-breakers. `gated` = launch WITHOUT bypass, the
|
|
62
|
+
* runtime's blocking approval requests are routed to a human through the daemon
|
|
63
|
+
* approval broker. Only `gated` is persisted (yolo = field absent, byte-identical to
|
|
64
|
+
* a never-toggled profile — mirrors wake_policy). */
|
|
65
|
+
export type ApprovalMode = 'yolo' | 'gated'
|
|
66
|
+
|
|
59
67
|
export interface PeerProfile {
|
|
60
68
|
personality: string
|
|
61
69
|
runtime: Runtime
|
|
@@ -69,6 +77,9 @@ export interface PeerProfile {
|
|
|
69
77
|
interfaces?: PeerInterfaces
|
|
70
78
|
/** Per-peer wake policy (lifecycle-owned). Absent = normal warm-on-demand. */
|
|
71
79
|
wake_policy?: WakePolicy
|
|
80
|
+
/** Per-peer human-approval mode (lifecycle-owned). Absent = `yolo` (default). Only
|
|
81
|
+
* `gated` is persisted; see ApprovalMode. */
|
|
82
|
+
approval_mode?: ApprovalMode
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
// Write shape: intelligence/description optional so a caller can write a profile
|
|
@@ -241,9 +252,19 @@ export function readPeerProfile(cwd: string = process.cwd()): PeerProfile | null
|
|
|
241
252
|
// Wake policy — only the known enum value is honored; anything else → omitted
|
|
242
253
|
// (treated as normal warm-on-demand, never throws on an unknown future value).
|
|
243
254
|
...(obj.wake_policy === 'ephemeral' ? { wake_policy: 'ephemeral' as const } : {}),
|
|
255
|
+
// Approval mode — only `gated` is persisted/honored; absent / `yolo` / unknown →
|
|
256
|
+
// omitted (treated as the default `yolo`, never throws on an unknown future value).
|
|
257
|
+
...(obj.approval_mode === 'gated' ? { approval_mode: 'gated' as const } : {}),
|
|
244
258
|
}
|
|
245
259
|
}
|
|
246
260
|
|
|
261
|
+
/** The effective approval mode of a profile: `gated` iff persisted, else the default
|
|
262
|
+
* `yolo`. The ONE place the "absent ⇒ yolo" default lives — every reader (launch,
|
|
263
|
+
* supervisor, fleet snapshot, CLI) resolves the mode through this, never re-derives it. */
|
|
264
|
+
export function approvalModeOf(profile: PeerProfile | null | undefined): ApprovalMode {
|
|
265
|
+
return profile?.approval_mode === 'gated' ? 'gated' : 'yolo'
|
|
266
|
+
}
|
|
267
|
+
|
|
247
268
|
/**
|
|
248
269
|
* Atomically write peer-profile.json, preserving fields the foundation does not
|
|
249
270
|
* own (e.g. persistent-peer's initial_prompt/aliases section) via a raw read-
|
|
@@ -375,6 +396,13 @@ function writePeerProfileMerged(path: string, profile: PeerProfileWrite): void {
|
|
|
375
396
|
if (typeof profile.initial_prompt === 'string' && profile.initial_prompt) {
|
|
376
397
|
merged.initial_prompt = profile.initial_prompt
|
|
377
398
|
}
|
|
399
|
+
// Approval mode (lifecycle-owned toggle). `gated` persists the field; `yolo` REMOVES
|
|
400
|
+
// it (default = absence, so a gated→yolo flip restores a byte-identical pristine
|
|
401
|
+
// profile — the toggle idempotency invariant). An ABSENT approval_mode in the write
|
|
402
|
+
// (any non-toggle writer: verify --fix, rename, re-provision) preserves the existing
|
|
403
|
+
// value verbatim via `...existing` — a caller that does not own the toggle never flips it.
|
|
404
|
+
if (profile.approval_mode === 'gated') merged.approval_mode = 'gated'
|
|
405
|
+
else if (profile.approval_mode === 'yolo') delete merged.approval_mode
|
|
378
406
|
writeFileAtomic(path, `${JSON.stringify(merged, null, 2)}\n`)
|
|
379
407
|
}
|
|
380
408
|
|
|
@@ -115,6 +115,9 @@ export function validateProfileStandard(raw: unknown, cwd: string): ProfileIssue
|
|
|
115
115
|
if (obj.wake_policy !== undefined && obj.wake_policy !== 'ephemeral') {
|
|
116
116
|
issues.push({ severity: 'warn', field: 'wake_policy', message: `unknown value "${String(obj.wake_policy)}" — only 'ephemeral' is honored` })
|
|
117
117
|
}
|
|
118
|
+
if (obj.approval_mode !== undefined && obj.approval_mode !== 'gated' && obj.approval_mode !== 'yolo') {
|
|
119
|
+
issues.push({ severity: 'warn', field: 'approval_mode', message: `unknown value "${String(obj.approval_mode)}" — only 'gated'|'yolo' honored (absent = yolo default)` })
|
|
120
|
+
}
|
|
118
121
|
|
|
119
122
|
// Owner sections — well-typed only; foundation never restructures them.
|
|
120
123
|
if (obj.interfaces !== undefined && (typeof obj.interfaces !== 'object' || obj.interfaces === null || Array.isArray(obj.interfaces))) {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import { homedir } from 'os'
|
|
24
24
|
import { join } from 'path'
|
|
25
25
|
import { readdirSync, realpathSync, statSync } from 'fs'
|
|
26
|
-
import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
|
|
26
|
+
import type { ApprovalMode, ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
|
|
27
27
|
import { lastTimestampedEntryMs } from './transcriptTail.ts'
|
|
28
28
|
|
|
29
29
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -129,6 +129,46 @@ function dangerousRmDetail(pane: string): string {
|
|
|
129
129
|
return [cmd && `cmd=${JSON.stringify(cmd)}`, target && `target=${JSON.stringify(target)}`].filter(Boolean).join(' ') || '(unparsed)'
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// Standard command-approval prompt (claude 2.1.201 — the NO-RULE-MATCH passthrough / file-access "ask")
|
|
133
|
+
// needles. This is the prompt a peer sees when it is NOT in bypass — i.e. GATED mode, OR a yolo peer
|
|
134
|
+
// whose bypass the runtime disabled mid-session (the `tengu_disable_bypass_permissions_mode` feature
|
|
135
|
+
// gate / `disableBypassPermissionsMode` setting). Under GENUINE bypass this prompt never surfaces (only
|
|
136
|
+
// the dangerous-rm breaker does — verified from the 2.1.201 permission-gate: bypass auto-allows every
|
|
137
|
+
// `ask` except "Dangerous rm/rmdir operation"). The live render (captured via a nested pty, default
|
|
138
|
+
// mode) is a THREE-option select — "❯ 1. Yes / 2. Yes, and <don't ask again | always allow …> / 3. No"
|
|
139
|
+
// under "Do you want to proceed?" — which is what distinguishes it from the dangerous-rm breaker's
|
|
140
|
+
// TWO-option "1. Yes / 2. No". Split-literal (В40): a peer reviewing this adapter must not self-trigger.
|
|
141
|
+
const ORG_POLICY_APPROVAL = 'organization requires ' + 'approval' // MCP org-restriction — NEVER auto-pressed
|
|
142
|
+
const BASH_CMD_HEADER = 'Bash' + ' command'
|
|
143
|
+
/** FULL-signature + position match for the LIVE standard command-approval prompt. Returns true ONLY for
|
|
144
|
+
* the 3-option layout with the bottom-most `❯` cursor on "1. Yes" — so a mere QUOTE of the prompt (ready
|
|
145
|
+
* composer `❯` below) never matches. Two hard exclusions satisfy the owner's NARROW-matcher rule:
|
|
146
|
+
* · org-policy ("Your organization requires approval for this tool", an MCP restriction) → excluded,
|
|
147
|
+
* the owner's org rule is HUMAN-only and must never be auto-pressed;
|
|
148
|
+
* · the dangerous-rm breaker → handled by its own taxonomy (checked first in blockingConfirm), and
|
|
149
|
+
* structurally excluded here anyway (it is a 2-option "2. No", never "2. Yes," + "3. No"). */
|
|
150
|
+
function isCommandApprovalPrompt(pane: string): boolean {
|
|
151
|
+
if (!pane.includes(RM_PROCEED)) return false // shares "Do you want to proceed?" with the rm breaker
|
|
152
|
+
if (pane.includes(ORG_POLICY_APPROVAL)) return false // org-policy → never auto-Yes
|
|
153
|
+
if (isDangerousRmPrompt(pane)) return false // its own taxonomy owns this pane
|
|
154
|
+
// 3-option standard layout: option 2 is a "Yes," variant + option 3 is "No" (the rm breaker is
|
|
155
|
+
// 2-option "1. Yes / 2. No", so it fails BOTH tests — no overlap).
|
|
156
|
+
if (!/(^|\n)\s*2\.\s*Yes/.test(pane)) return false
|
|
157
|
+
if (!/(^|\n)\s*3\.\s*No\b/.test(pane)) return false
|
|
158
|
+
// live-modal position gate: the bottom-most `❯` row must sit on "1. Yes".
|
|
159
|
+
let lastCursorRow = ''
|
|
160
|
+
for (const line of pane.split(/\r?\n/)) if (line.includes('❯')) lastCursorRow = line
|
|
161
|
+
return /❯\s*1\.\s*Yes\b/.test(lastCursorRow)
|
|
162
|
+
}
|
|
163
|
+
/** Best-effort trace of the command the approval prompt guarded — the first non-blank line under the
|
|
164
|
+
* "Bash command" block header. Mirrors dangerousRmDetail's post-hoc audit purpose. */
|
|
165
|
+
function commandApprovalDetail(pane: string): string {
|
|
166
|
+
const lines = pane.split(/\r?\n/)
|
|
167
|
+
const hi = lines.findIndex(l => l.trim() === BASH_CMD_HEADER)
|
|
168
|
+
if (hi >= 0) for (let j = hi + 1; j < lines.length; j++) { const t = lines[j].trim(); if (t) return `cmd=${JSON.stringify(t)}` }
|
|
169
|
+
return '(unparsed)'
|
|
170
|
+
}
|
|
171
|
+
|
|
132
172
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
133
173
|
// Transcript activity proxy + resume uuid (lifecycle port, claude slug fix)
|
|
134
174
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -228,9 +268,16 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
228
268
|
* NO currency — no marketplace/install/update on this path.
|
|
229
269
|
*/
|
|
230
270
|
buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
|
|
271
|
+
// Human-approval mode (docs/17). yolo (default) = '--dangerously-skip-permissions'
|
|
272
|
+
// (headless bypass, current fleet behavior). gated = NO bypass + an EXPLICIT
|
|
273
|
+
// '--permission-mode default' so the base is deterministic (never inherits an
|
|
274
|
+
// acceptEdits/bypass defaultMode from settings): the runtime's permission layer is
|
|
275
|
+
// active as a fail-SAFE backstop, and the separately-installed PreToolUse hook is
|
|
276
|
+
// the primary interceptor (it fires regardless of mode — verified live 2.1.201).
|
|
277
|
+
const gated = spec.approvalMode === 'gated'
|
|
231
278
|
return [
|
|
232
279
|
cfg.claudeBin,
|
|
233
|
-
'--dangerously-skip-permissions',
|
|
280
|
+
...(gated ? ['--permission-mode', 'default'] : ['--dangerously-skip-permissions']),
|
|
234
281
|
'--disallowedTools',
|
|
235
282
|
'AskUserQuestion',
|
|
236
283
|
'--add-dir',
|
|
@@ -329,18 +376,30 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
329
376
|
},
|
|
330
377
|
|
|
331
378
|
/**
|
|
332
|
-
*
|
|
333
|
-
* (benign upsell → DECLINE),
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
379
|
+
* Blocking-confirm circuit-breaker (RuntimeAdapter.blockingConfirm). Unlike the fullscreen nag
|
|
380
|
+
* (benign upsell → DECLINE), these guards hang a headless peer forever with no human to answer.
|
|
381
|
+
* Owner decision (Артур, 04.07): auto-press YES — peers run on bypass, so this only restores the
|
|
382
|
+
* pre-2.1.x status quo, and a hang is real recurring harm. Two signatures are matched, most-specific
|
|
383
|
+
* first, each with its own taxonomy so the supervisor's audit log and (later) the human-approval
|
|
384
|
+
* broker can tell them apart:
|
|
385
|
+
* 1. 'dangerous-rm' — the rm/rmdir breaker that sits ABOVE `--dangerously-skip-permissions` (the
|
|
386
|
+
* ONLY prompt that surfaces under genuine bypass). Default cursor on "1. Yes" (verified live).
|
|
387
|
+
* 2. 'command-approval' — the STANDARD 3-option approval prompt, surfaced when the peer is NOT in
|
|
388
|
+
* bypass: gated mode, or a yolo peer whose bypass the runtime disabled mid-session (feature gate
|
|
389
|
+
* / setting). Auto-Yes keeps a yolo peer alive; a gated peer is routed to the human broker by the
|
|
390
|
+
* supervisor (Unit 4, mode-aware). Org-policy ("Your organization requires approval for this
|
|
391
|
+
* tool") is EXCLUDED by the matcher — the owner's org rule is never auto-pressed.
|
|
392
|
+
* Both AFFIRM with ['1','Enter'] (the position-robust affirmative — cursor already on "1. Yes"); the
|
|
393
|
+
* DECLINE keys differ by layout: dangerous-rm is a 2-option "1.Yes/2.No" → ['2','Enter']; command-
|
|
394
|
+
* approval is a 3-option "1.Yes/2.Yes,…/3.No" → ['3','Enter']. The supervisor presses `keys` (yolo
|
|
395
|
+
* auto-Yes / gated-approved) or `denyKeys` (gated-denied / broker fail-safe). Each carries a one-line
|
|
396
|
+
* command/target trace for the audit + broker content. NB deliberately NOT a "smart" safe-decline —
|
|
397
|
+
* the gated human-approval path lands via the daemon broker (docs/17, Unit 4).
|
|
340
398
|
*/
|
|
341
|
-
blockingConfirm(pane: string): { keys: string[]; taxonomy: string; detail: string } | null {
|
|
342
|
-
if (
|
|
343
|
-
return { keys: ['1', 'Enter'], taxonomy: '
|
|
399
|
+
blockingConfirm(pane: string): { keys: string[]; denyKeys: string[]; taxonomy: string; detail: string } | null {
|
|
400
|
+
if (isDangerousRmPrompt(pane)) return { keys: ['1', 'Enter'], denyKeys: ['2', 'Enter'], taxonomy: 'dangerous-rm', detail: dangerousRmDetail(pane) }
|
|
401
|
+
if (isCommandApprovalPrompt(pane)) return { keys: ['1', 'Enter'], denyKeys: ['3', 'Enter'], taxonomy: 'command-approval', detail: commandApprovalDetail(pane) }
|
|
402
|
+
return null
|
|
344
403
|
},
|
|
345
404
|
|
|
346
405
|
/**
|
|
@@ -350,12 +409,20 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
350
409
|
* - '❯' (U+276F) — the TUI input-prompt glyph, present only at the ready
|
|
351
410
|
* input row, never in the splash art (claude-start.sh:357-360).
|
|
352
411
|
* - 'bypass permissions on' — the banner --dangerously-skip-permissions
|
|
353
|
-
* emits once booted past the splash.
|
|
412
|
+
* emits once booted past the splash (YOLO mode only).
|
|
354
413
|
* - none of the boot dialogs present (a dialog row can also carry '❯').
|
|
414
|
+
*
|
|
415
|
+
* GATED (docs/17): the peer launches WITHOUT --dangerously-skip-permissions, so the
|
|
416
|
+
* 'bypass permissions on' banner is ABSENT (verified live 2.1.201 — the no-bypass
|
|
417
|
+
* ready pane is '❯ Try "edit <filepath> to…"', no banner). The ready signal is then
|
|
418
|
+
* the composer '❯' with every boot dialog cleared. This branch is evaluated ONLY at
|
|
419
|
+
* BOOT (waitHostReady / the boot-driver), before any tool runs — so a mid-session
|
|
420
|
+
* '❯'-bearing modal (dangerous-rm / nag) cannot false-positive it.
|
|
355
421
|
*/
|
|
356
|
-
isInputReady(pane: string): boolean {
|
|
422
|
+
isInputReady(pane: string, mode?: ApprovalMode): boolean {
|
|
357
423
|
if (anyBootDialog(pane)) return false
|
|
358
|
-
|
|
424
|
+
if (!pane.includes('❯')) return false
|
|
425
|
+
return mode === 'gated' ? true : pane.includes('bypass permissions on')
|
|
359
426
|
},
|
|
360
427
|
|
|
361
428
|
/**
|
|
@@ -43,7 +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 type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
|
|
46
|
+
import type { ApprovalMode, ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
|
|
47
47
|
|
|
48
48
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
49
|
// Boot dialog markers (watcher.codexUpdate/DirTrust/HooksReview, 253-261)
|
|
@@ -199,6 +199,13 @@ export const codexAdapter: RuntimeAdapter = {
|
|
|
199
199
|
* NO currency — no marketplace/install/update on this path.
|
|
200
200
|
*/
|
|
201
201
|
buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
|
|
202
|
+
// Human-approval mode (docs/17). yolo (default) = the YOLO bypass flag (current
|
|
203
|
+
// fleet behavior). gated = NO bypass + `-c approval_policy=on-request` (the model
|
|
204
|
+
// asks before acting → the PreToolUse/PermissionRequest hook intercepts) with
|
|
205
|
+
// `-c sandbox_mode=danger-full-access` — gated gates APPROVALS, it does NOT add a
|
|
206
|
+
// sandbox (workspace-write would sever the loopback-MCP send_to_peer + vault writes;
|
|
207
|
+
// sandboxing is a separate axis, not v1). Session-scoped `-c`, never a host-config edit.
|
|
208
|
+
const gated = spec.approvalMode === 'gated'
|
|
202
209
|
return [
|
|
203
210
|
cfg.codexBin,
|
|
204
211
|
...(spec.resume ? ['resume', '--last'] : []),
|
|
@@ -208,7 +215,9 @@ export const codexAdapter: RuntimeAdapter = {
|
|
|
208
215
|
...(spec.systemPromptFile
|
|
209
216
|
? ['-c', `model_instructions_file=${spec.systemPromptFile}`]
|
|
210
217
|
: []),
|
|
211
|
-
|
|
218
|
+
...(gated
|
|
219
|
+
? ['-c', 'approval_policy=on-request', '-c', 'sandbox_mode=danger-full-access']
|
|
220
|
+
: ['--dangerously-bypass-approvals-and-sandbox']),
|
|
212
221
|
...(spec.extraArgs ?? []),
|
|
213
222
|
]
|
|
214
223
|
},
|
|
@@ -261,7 +270,9 @@ export const codexAdapter: RuntimeAdapter = {
|
|
|
261
270
|
* composer, and the ready-gate still verifies a real model turn (activity
|
|
262
271
|
* mtime advance) before declaring READY.
|
|
263
272
|
*/
|
|
264
|
-
|
|
273
|
+
// mode is accepted for interface parity but ignored: codex's ready marker (the '› '
|
|
274
|
+
// composer arrow) is the same whether or not it launched with the YOLO bypass flag.
|
|
275
|
+
isInputReady(pane: string, _mode?: ApprovalMode): boolean {
|
|
265
276
|
if (pane.includes('Press enter to continue')) return false
|
|
266
277
|
return pane.split(/\r?\n/).some(line => line.trimStart().startsWith('› '))
|
|
267
278
|
},
|
package/src/launch/index.ts
CHANGED
|
@@ -202,7 +202,7 @@ export const launch: LaunchFn = async (
|
|
|
202
202
|
// the SAME shape as the tmux boot/deliver/ready-gate below, with host equivalents.
|
|
203
203
|
const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
|
|
204
204
|
const isReady = await waitHostReady(
|
|
205
|
-
{ identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs, paneLogStartByte, env },
|
|
205
|
+
{ identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs, paneLogStartByte, approvalMode: spec.approvalMode, env },
|
|
206
206
|
sleep,
|
|
207
207
|
)
|
|
208
208
|
if (!isReady) return fail(identity, 'never-became-ready (hosted: supervisor input surface not ready)')
|
package/src/launch/ptyHost.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { parseSessionName } from '../core/socket.ts'
|
|
|
19
19
|
import type { TmuxRuntime } from '../core/constants.ts'
|
|
20
20
|
import { paneLogViewport } from './readyGateModel.ts'
|
|
21
21
|
import type { LaunchInvocation } from './invocation.ts'
|
|
22
|
-
import type { RuntimeAdapter } from './types.ts'
|
|
22
|
+
import type { ApprovalMode, RuntimeAdapter } from './types.ts'
|
|
23
23
|
|
|
24
24
|
/** Match the tmux launch geometry (index.ts new-session -x 220 -y 50) so the served child + the
|
|
25
25
|
* pane-log model render at the same size launch reads readiness at. */
|
|
@@ -154,7 +154,7 @@ export async function startPtyHost(b: {
|
|
|
154
154
|
* 2 s cadence + bootDeadline.
|
|
155
155
|
*/
|
|
156
156
|
export async function waitHostReady(
|
|
157
|
-
b: { identity: string; logDir: string; adapter: RuntimeAdapter; bootDeadlineSecs: number; paneLogStartByte?: number; env?: NodeJS.ProcessEnv },
|
|
157
|
+
b: { identity: string; logDir: string; adapter: RuntimeAdapter; bootDeadlineSecs: number; paneLogStartByte?: number; approvalMode?: ApprovalMode; env?: NodeJS.ProcessEnv },
|
|
158
158
|
sleep: (ms: number) => Promise<void>,
|
|
159
159
|
): Promise<boolean> {
|
|
160
160
|
const log = `${b.logDir}/${b.identity}.log`
|
|
@@ -168,7 +168,7 @@ export async function waitHostReady(
|
|
|
168
168
|
await sleep(2000)
|
|
169
169
|
if (!hostSessionAlive(b.identity, b.env)) return false
|
|
170
170
|
const view = await paneLogViewport(log, HOST_COLS, HOST_ROWS, b.paneLogStartByte ?? 0)
|
|
171
|
-
if (view && b.adapter.isInputReady(view)) return true
|
|
171
|
+
if (view && b.adapter.isInputReady(view, b.approvalMode)) return true
|
|
172
172
|
}
|
|
173
173
|
return false
|
|
174
174
|
}
|
package/src/launch/types.ts
CHANGED
|
@@ -14,8 +14,13 @@
|
|
|
14
14
|
// be implemented independently against a known contract.
|
|
15
15
|
|
|
16
16
|
import type { Intelligence, Runtime } from '../core/constants.ts'
|
|
17
|
+
import type { ApprovalMode } from '../identity/index.ts'
|
|
17
18
|
import type { PublicPeerSummary } from '../registry/index.ts'
|
|
18
19
|
|
|
20
|
+
// Re-export so the adapters (which already import their contract types from here) get
|
|
21
|
+
// ApprovalMode from ONE place rather than reaching into identity directly.
|
|
22
|
+
export type { ApprovalMode }
|
|
23
|
+
|
|
19
24
|
export type { PublicPeerSummary } from '../registry/index.ts'
|
|
20
25
|
|
|
21
26
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -159,6 +164,12 @@ export interface LaunchSpec {
|
|
|
159
164
|
* less throwaway may omit it, but an adapter that declares requiresIntelligence
|
|
160
165
|
* then refuses (cannot confirm the required nature). */
|
|
161
166
|
intelligence?: Intelligence
|
|
167
|
+
/** Human-approval mode (docs/17): `yolo` (default/omitted) launches with the
|
|
168
|
+
* runtime bypass flag (current fleet behavior); `gated` launches WITHOUT bypass
|
|
169
|
+
* and swaps in the gated permission/approval config, so the runtime's blocking
|
|
170
|
+
* requests route to a human via the daemon broker (a PreToolUse hook, installed
|
|
171
|
+
* separately at toggle time, is the interceptor). buildArgv reads it. */
|
|
172
|
+
approvalMode?: ApprovalMode
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -307,17 +318,25 @@ export interface RuntimeAdapter {
|
|
|
307
318
|
* settings, or flag disables it). A headless peer has no human to press 1/2, so it HANGS forever
|
|
308
319
|
* (live-observed on a verification session). Owner decision (Артур, 04.07): auto-press YES — peers
|
|
309
320
|
* already run on bypass, so this only restores the pre-2.1.x status quo; a hang is real recurring
|
|
310
|
-
* harm. Return `{keys, taxonomy, detail}` (the
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
321
|
+
* harm. Return `{keys, denyKeys, taxonomy, detail}` (the AFFIRMATIVE keys + the taxonomy-specific
|
|
322
|
+
* DECLINE keys + a class tag + a one-line trace of WHAT was approved) so the supervisor can press
|
|
323
|
+
* YES on a yolo peer AND leave a post-hoc log, OR route the confirm to the human broker on a gated
|
|
324
|
+
* peer and press the decision (Unit 4 — allow→keys, deny→denyKeys). `denyKeys` differs by taxonomy
|
|
325
|
+
* because the modal layouts differ (dangerous-rm is a 2-option "1.Yes/2.No" → `['2','Enter']`;
|
|
326
|
+
* command-approval is a 3-option "1.Yes/2.Yes,…/3.No" → `['3','Enter']`) — the adapter owns that
|
|
327
|
+
* layout knowledge, not the supervisor. Match the FULL signature + cursor position so ordinary
|
|
328
|
+
* content mentioning the phrase never fires; else null. Optional (codex/router omit it). tui only.
|
|
329
|
+
* Human-approval lands via the daemon broker (docs/17); the vault idea is "Human-approval
|
|
330
|
+
* подтверждения действий пира через Telegram-кнопки".
|
|
315
331
|
*/
|
|
316
|
-
blockingConfirm?(pane: string): { keys: string[]; taxonomy: string; detail: string } | null
|
|
332
|
+
blockingConfirm?(pane: string): { keys: string[]; denyKeys: string[]; taxonomy: string; detail: string } | null
|
|
317
333
|
|
|
318
334
|
/** Is the input surface ready for the first message? (tui: ready marker present
|
|
319
|
-
* AND startup dialogs gone). Router runtimes return true (no input surface).
|
|
320
|
-
|
|
335
|
+
* AND startup dialogs gone). Router runtimes return true (no input surface).
|
|
336
|
+
* `mode` (default yolo) lets a runtime whose READY marker differs by approval mode
|
|
337
|
+
* pick the right one — claude's yolo ready banner ('bypass permissions on') is
|
|
338
|
+
* absent under gated (launched WITHOUT --dangerously-skip-permissions). */
|
|
339
|
+
isInputReady(pane: string, mode?: ApprovalMode): boolean
|
|
321
340
|
|
|
322
341
|
/** Newest activity-proxy FILE mtime — used by the READY-GATE (it waits for ANY transcript write past
|
|
323
342
|
* baseline = "the model produced its first turn"). NOT for idle accounting: a live session re-saves
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/
|
|
|
28
28
|
import { err, ok, type Result } from '../core/errors.ts'
|
|
29
29
|
import { capPaneLogs } from '../launch/cmdlog.ts'
|
|
30
30
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
31
|
-
import { readPeerProfile, resolveIdentity } from '../identity/index.ts'
|
|
31
|
+
import { approvalModeOf, readPeerProfile, resolveIdentity, type ApprovalMode } from '../identity/index.ts'
|
|
32
32
|
import {
|
|
33
33
|
ephemeralQueueDepth,
|
|
34
34
|
listQueuedIdentities,
|
|
@@ -600,6 +600,18 @@ export function isEphemeralPeer(cwd: string): boolean {
|
|
|
600
600
|
}
|
|
601
601
|
}
|
|
602
602
|
|
|
603
|
+
/** The effective human-approval mode of the peer at `cwd` — `gated` iff its profile
|
|
604
|
+
* persists it, else the default `yolo`. A profile read hiccup → `yolo` (safe default:
|
|
605
|
+
* the current-fleet behavior; never gates a peer we cannot read). The single cwd-level
|
|
606
|
+
* reader the launch/supervisor/fleet paths call (default lives in approvalModeOf). */
|
|
607
|
+
export function peerApprovalMode(cwd: string): ApprovalMode {
|
|
608
|
+
try {
|
|
609
|
+
return approvalModeOf(readPeerProfile(cwd))
|
|
610
|
+
} catch {
|
|
611
|
+
return 'yolo'
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
603
615
|
/**
|
|
604
616
|
* Decide resume vs fresh on a wake (TARGET redesign). Branch order:
|
|
605
617
|
* 1. argsResume === false (folder-launch `iapeer <runtime>`) → FRESH.
|
|
@@ -1112,6 +1124,9 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1112
1124
|
// Carry the peer's nature so the launch primitive can enforce an adapter's
|
|
1113
1125
|
// intelligence gate (telegram requires natural). From the registry record.
|
|
1114
1126
|
intelligence: peer.intelligence,
|
|
1127
|
+
// Human-approval mode from the LOCAL profile (docs/17): drives the runtime
|
|
1128
|
+
// bypass flag + gated permission/approval config in buildArgv. Default yolo.
|
|
1129
|
+
approvalMode: peerApprovalMode(cwd),
|
|
1115
1130
|
}
|
|
1116
1131
|
const launchCfg: LaunchConfig = {
|
|
1117
1132
|
claudeBin: cfg.claudeBin,
|