@bivy/bivy 0.0.0 → 0.1.0-staging.2

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.
Files changed (146) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +265 -5
  3. package/bin/acp-shim.mjs +298 -0
  4. package/bin/agent-manifest.json +277 -0
  5. package/bin/bivy.mjs +4100 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/prune-sessions.mjs +52 -0
  9. package/bin/sessions-list.mjs +27 -0
  10. package/bin/shim-path.mjs +126 -0
  11. package/bin/uninstall-paths.mjs +48 -0
  12. package/dist/approval.js +87 -0
  13. package/dist/attach.js +248 -0
  14. package/dist/auth.js +258 -0
  15. package/dist/bivy-login.js +180 -0
  16. package/dist/browser-open.js +50 -0
  17. package/dist/control-plane-tasks.js +236 -0
  18. package/dist/data-dir.js +25 -0
  19. package/dist/device-registry.js +201 -0
  20. package/dist/e2e.js +70 -0
  21. package/dist/ephemeral-exec.js +109 -0
  22. package/dist/exec.js +209 -0
  23. package/dist/git-auth.js +155 -0
  24. package/dist/github-app-auth.js +107 -0
  25. package/dist/github-app-connect.js +235 -0
  26. package/dist/github-app-manifest.js +82 -0
  27. package/dist/github-app-sync-cli.js +93 -0
  28. package/dist/github-app-vault.js +106 -0
  29. package/dist/github-apps.js +121 -0
  30. package/dist/github-connect-repo.js +74 -0
  31. package/dist/github-device-auth.js +109 -0
  32. package/dist/github-tasks.js +650 -0
  33. package/dist/guard.js +109 -0
  34. package/dist/harness/cache-evict.js +88 -0
  35. package/dist/harness/checkpoint.js +0 -0
  36. package/dist/harness/cow-clone.js +84 -0
  37. package/dist/harness/dep-cache.js +78 -0
  38. package/dist/harness/disk-admission.js +46 -0
  39. package/dist/harness/egress.js +30 -0
  40. package/dist/harness/manager.js +97 -0
  41. package/dist/harness/mcp-config-formats.js +164 -0
  42. package/dist/harness/mcp-config.js +111 -0
  43. package/dist/harness/mcp-inject.js +134 -0
  44. package/dist/harness/mcp-proxy-cli.js +88 -0
  45. package/dist/harness/mcp-proxy.js +150 -0
  46. package/dist/harness/net-proxy.js +120 -0
  47. package/dist/harness/sandbox.js +96 -0
  48. package/dist/history-sync.js +26 -0
  49. package/dist/hosted-endpoints.d.mts +14 -0
  50. package/dist/hosted-endpoints.mjs +35 -0
  51. package/dist/identity.js +153 -0
  52. package/dist/integrations/index.js +4 -0
  53. package/dist/integrations/manager.js +279 -0
  54. package/dist/integrations/oauth.js +78 -0
  55. package/dist/integrations/registry.js +239 -0
  56. package/dist/integrations/store.js +54 -0
  57. package/dist/integrations/types.js +1 -0
  58. package/dist/linear-tasks.js +49 -0
  59. package/dist/metadata.js +226 -0
  60. package/dist/multiplexer.js +79 -0
  61. package/dist/native-pi.js +38 -0
  62. package/dist/node-stats.js +237 -0
  63. package/dist/pairing-crypto.js +105 -0
  64. package/dist/policy/conditions.js +103 -0
  65. package/dist/policy/policy-engine.js +20 -0
  66. package/dist/policy/risk.js +18 -0
  67. package/dist/policy/ruleset.js +113 -0
  68. package/dist/policy/run-policy.js +108 -0
  69. package/dist/policy/session-reroute.js +96 -0
  70. package/dist/pty-runner.py +95 -0
  71. package/dist/question.js +146 -0
  72. package/dist/redact.js +97 -0
  73. package/dist/relay-attach.js +345 -0
  74. package/dist/relay-chunk.js +73 -0
  75. package/dist/relay-cli-crypto.js +70 -0
  76. package/dist/relay-client.js +344 -0
  77. package/dist/relay-setup.js +262 -0
  78. package/dist/repo-workspace.js +208 -0
  79. package/dist/runtime/adoption.js +45 -0
  80. package/dist/runtime/agent-service-bin.js +149 -0
  81. package/dist/runtime/agent-service.js +439 -0
  82. package/dist/runtime/ansi.js +27 -0
  83. package/dist/runtime/anthropic-preflight.js +80 -0
  84. package/dist/runtime/claude-code.js +1364 -0
  85. package/dist/runtime/cli-parsers.js +647 -0
  86. package/dist/runtime/codex-auth.js +168 -0
  87. package/dist/runtime/codex-preflight.js +60 -0
  88. package/dist/runtime/codex-sessions.js +229 -0
  89. package/dist/runtime/control-plane-location.js +74 -0
  90. package/dist/runtime/credential-ingest.js +122 -0
  91. package/dist/runtime/credential-provisioning.js +79 -0
  92. package/dist/runtime/credential-store.js +435 -0
  93. package/dist/runtime/credentials.js +153 -0
  94. package/dist/runtime/host.js +153 -0
  95. package/dist/runtime/index.js +1548 -0
  96. package/dist/runtime/local-model-store.js +194 -0
  97. package/dist/runtime/location-registry.js +28 -0
  98. package/dist/runtime/model-catalog.js +97 -0
  99. package/dist/runtime/model-namer.js +85 -0
  100. package/dist/runtime/native-process-scan.js +102 -0
  101. package/dist/runtime/native-session-discovery.js +103 -0
  102. package/dist/runtime/normalize.js +75 -0
  103. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  104. package/dist/runtime/oauth/model-oauth.js +324 -0
  105. package/dist/runtime/opencode-preflight.js +55 -0
  106. package/dist/runtime/pi-auth.js +82 -0
  107. package/dist/runtime/pi-oauth.js +52 -0
  108. package/dist/runtime/pi-session-discovery.js +42 -0
  109. package/dist/runtime/pi.js +518 -0
  110. package/dist/runtime/process.js +499 -0
  111. package/dist/runtime/protocol.js +630 -0
  112. package/dist/runtime/remote.js +541 -0
  113. package/dist/runtime/rpc-protocol.js +56 -0
  114. package/dist/runtime/ruleset-store.js +117 -0
  115. package/dist/runtime/session-location.js +50 -0
  116. package/dist/runtime/types.js +17 -0
  117. package/dist/secrets-cli.js +134 -0
  118. package/dist/secrets.js +264 -0
  119. package/dist/server.js +9411 -0
  120. package/dist/session/bivy-session.js +1 -0
  121. package/dist/session/checkpoint-pack.js +133 -0
  122. package/dist/session/event-log.js +340 -0
  123. package/dist/session/fork-dirty.js +73 -0
  124. package/dist/session/fork-prereqs.js +61 -0
  125. package/dist/session/fork.js +57 -0
  126. package/dist/session/native-import.js +56 -0
  127. package/dist/session/reconnect.js +168 -0
  128. package/dist/session/replication-service.js +236 -0
  129. package/dist/session/replication.js +106 -0
  130. package/dist/session/replicator.js +140 -0
  131. package/dist/session/session-new-dedupe.js +42 -0
  132. package/dist/session/sibling-client.js +201 -0
  133. package/dist/session/transcript-merge.js +131 -0
  134. package/dist/session/transcript-normal.js +130 -0
  135. package/dist/session/workspace-context.js +1 -0
  136. package/dist/session-event-coalescer.js +50 -0
  137. package/dist/session-identity.js +34 -0
  138. package/dist/session-ref.js +65 -0
  139. package/dist/stt-cli.js +131 -0
  140. package/dist/stt.js +168 -0
  141. package/dist/terminal.js +409 -0
  142. package/dist/wire-format.js +67 -0
  143. package/dist/worktree-provision.js +118 -0
  144. package/dist/worktree.js +117 -0
  145. package/package.json +40 -6
  146. package/public/qr.js +464 -0
@@ -0,0 +1,1548 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Runtime registry. Selects the agent runtime by BIVY_RUNTIME (default "pi").
4
+ // This is the seam where additional runtimes (Claude Agent SDK, generic RPC,
5
+ // …) are registered without touching the daemon.
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { ClaudeCodeRuntime, claudeRuntimeFromEnv, claudeSdkInstalled } from "./claude-code.js";
10
+ import { deleteCodexSession, discoverNativeCodexSessions, loadCodexTranscript } from "./codex-sessions.js";
11
+ import { createCredentialStore } from "./credentials.js";
12
+ // Args that continue an existing Codex session each prompt. Codex assigns its own
13
+ // session id (no launch-time pin), so a resumed run threads it via `exec resume`.
14
+ // The exact flags vary by Codex version, so `BIVY_CODEX_RESUME_TEMPLATE` (a JSON
15
+ // array with `{id}` / `{tier}` placeholders) overrides this without a code change.
16
+ function codexResumeArgs(sessionId, tier) {
17
+ const raw = process.env.BIVY_CODEX_RESUME_TEMPLATE?.trim();
18
+ if (raw) {
19
+ try {
20
+ const tpl = JSON.parse(raw);
21
+ if (Array.isArray(tpl))
22
+ return tpl.map((a) => String(a).replace(/\{id\}/g, sessionId).replace(/\{tier\}/g, tier));
23
+ }
24
+ catch {
25
+ // malformed override — fall through to the default
26
+ }
27
+ }
28
+ // `--json` and `--sandbox` are options of `codex exec`, not of the `resume`
29
+ // subcommand, so they must precede `resume` — otherwise clap rejects them with
30
+ // "unexpected argument '--sandbox'". Verified against codex-cli 0.142.5 and
31
+ // 0.144.1. Bivy's SandboxTier values are exactly Codex's --sandbox modes
32
+ // (read-only | workspace-write | danger-full-access), so `tier` needs no mapping.
33
+ return ["exec", "--json", "--sandbox", tier, "resume", sessionId];
34
+ }
35
+ import { PiRuntime } from "./pi.js";
36
+ import { ProcessRuntime, processRuntimeFromEnv } from "./process.js";
37
+ import { codexCredentialPreflight } from "./codex-preflight.js";
38
+ import { opencodeCredentialPreflight } from "./opencode-preflight.js";
39
+ import { ensureCodexAuth } from "./codex-auth.js";
40
+ import { parserFactoryFor } from "./cli-parsers.js";
41
+ import { sandboxTier, sandboxArgsFor, codexSandboxPolicy } from "../harness/sandbox.js";
42
+ import { ProtocolRuntime, protocolRuntimeFromEnv, protocolCommandsFromEnv } from "./protocol.js";
43
+ export * from "./types.js";
44
+ export { NodeCredentialResolver, createCredentialStore } from "./credentials.js";
45
+ const PI_CAPABILITIES = {
46
+ toolInterception: true,
47
+ modelSelection: true,
48
+ packages: true,
49
+ resume: true,
50
+ fork: false,
51
+ interactiveTui: true,
52
+ usageReporting: true,
53
+ sessionDiscovery: true,
54
+ // Must match PiRuntime.capabilities (src/runtime/pi.ts) — the composer reads
55
+ // steer support from the session-less runtimes.list catalog, so if this drifts
56
+ // from the live runtime the client never learns steering is available and
57
+ // force-queues every mid-turn message instead of offering an immediate send.
58
+ streamingBehaviors: ["steer", "followUp"],
59
+ };
60
+ const CLAUDE_CAPABILITIES = {
61
+ toolInterception: true,
62
+ modelSelection: true,
63
+ packages: false,
64
+ resume: true,
65
+ fork: true,
66
+ usageReporting: true,
67
+ // Sessions started outside Bivy (a bare `claude` in a terminal) are
68
+ // discoverable and adoptable with a true native resume — see issue #156 and
69
+ // ClaudeCodeRuntime.discoverNativeSessions.
70
+ nativeSessionDiscovery: true,
71
+ nativeSessionAdoption: true,
72
+ // Must match ClaudeCodeRuntime.capabilities (src/runtime/claude-code.ts): a
73
+ // mid-turn prompt re-enters the SDK streaming-input queue and behaves as an
74
+ // immediate steer. Advertised here too so the composer offers "send now"
75
+ // straight from runtimes.list — before any session-capabilities merge, which
76
+ // won't fire on a reconnect to an already-running session (the mobile case).
77
+ streamingBehaviors: ["steer"],
78
+ };
79
+ function claudeCodeInfo() {
80
+ const installed = claudeSdkInstalled();
81
+ return {
82
+ id: "claude-code-sdk",
83
+ displayName: "Claude Code SDK",
84
+ description: "Anthropic's Claude Agent SDK driven as a Bivy runtime: streaming turns, model picker, and tool approvals via the SDK permission callback.",
85
+ status: installed ? "available" : "planned",
86
+ packageName: "@anthropic-ai/claude-agent-sdk",
87
+ language: "TypeScript",
88
+ // The interactive TUI is a chat<->CLI hand-off; only advertise it when the
89
+ // standalone `claude` CLI is actually installed on this node.
90
+ capabilities: { ...CLAUDE_CAPABILITIES, interactiveTui: commandAvailable("claude") },
91
+ supportTier: "supported",
92
+ authOwner: "mixed",
93
+ notes: installed
94
+ ? "Multi-turn sessions over a streaming-input query(); approvals map to canUseTool. Set BIVY_CLAUDE_MODEL to pick a default model."
95
+ : "Install @anthropic-ai/claude-agent-sdk to enable this runtime (npm install @anthropic-ai/claude-agent-sdk).",
96
+ install: installed ? undefined : {
97
+ label: "Install Claude Code SDK",
98
+ description: "Installs the optional Anthropic SDK package into this Bivy node.",
99
+ command: "npm install @anthropic-ai/claude-agent-sdk",
100
+ },
101
+ };
102
+ }
103
+ function commandAvailable(command) {
104
+ if (!command.trim())
105
+ return false;
106
+ const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
107
+ shell: process.platform !== "win32",
108
+ stdio: "ignore",
109
+ });
110
+ return result.status === 0;
111
+ }
112
+ function genericCliInfo() {
113
+ const options = processRuntimeFromEnv();
114
+ const configured = Boolean(options);
115
+ // Same generic primitive as the built-in CLI agents: honest only when
116
+ // BIVY_AGENT_RESUME_TEMPLATE actually wired resumeArgs (see processRuntimeFromEnv).
117
+ const resume = Boolean(options?.resumeArgs);
118
+ return {
119
+ id: "generic-cli",
120
+ displayName: process.env.BIVY_AGENT_NAME?.trim() || "Generic CLI Agent",
121
+ description: "Run any local agent CLI underneath Bivy by spawning a configured process and streaming stdout/stderr.",
122
+ status: configured ? "available" : "planned",
123
+ packageName: process.env.BIVY_AGENT_COMMAND?.trim() || "Set BIVY_AGENT_COMMAND",
124
+ language: "Process",
125
+ capabilities: { toolInterception: false, modelSelection: false, resume, packages: false, fork: false },
126
+ supportTier: "experimental",
127
+ authOwner: "agent",
128
+ notes: configured
129
+ ? `Configured through BIVY_AGENT_COMMAND / BIVY_AGENT_ARGS / BIVY_AGENT_PROMPT_MODE. Provides universal streaming but not structured approvals unless the agent speaks Bivy protocol.${resume ? " Resumable via BIVY_AGENT_RESUME_TEMPLATE." : " Set BIVY_AGENT_RESUME_TEMPLATE (a JSON arg array with {id}) if the configured agent has its own \"continue session <id>\" flag."}`
130
+ : "Set BIVY_AGENT_COMMAND to enable this universal CLI runtime.",
131
+ };
132
+ }
133
+ const CLI_AGENT_SPECS = {
134
+ codex: {
135
+ displayName: "Codex",
136
+ command: "codex",
137
+ // Bare `codex "<prompt>"` launches the interactive TUI, which needs a real
138
+ // TTY and dies with "stdin is not a terminal" when driven over a pipe. The
139
+ // `exec` subcommand is Codex's non-interactive mode: it takes the prompt as
140
+ // an argument, runs headless, and streams the result to stdout.
141
+ args: ["exec"],
142
+ // `codex exec --json` streams a thread/turn/item JSONL event model.
143
+ jsonArgs: ["exec", "--json"],
144
+ parserId: "codex-json",
145
+ // `codex exec --json --sandbox <tier> <prompt>` — native OS sandbox.
146
+ composeArgs: ({ structured, tier }) => ["exec", ...(structured ? ["--json"] : []), "--sandbox", tier],
147
+ // Reasoning effort via a config override, after the `exec` subcommand.
148
+ // `codex exec -c model_reasoning_effort=<level> …`.
149
+ thinking: { levels: ["minimal", "low", "medium", "high"], default: "medium", template: ["-c", "model_reasoning_effort={level}"], insertAt: 1 },
150
+ packageName: "@openai/codex",
151
+ promptMode: "argv",
152
+ // Hidden from the picker: the governed `codex-approvals` app-server shim
153
+ // supersedes this plain exec path (still runnable via BIVY_RUNTIME=codex).
154
+ hidden: true,
155
+ install: { kind: "npm", pkg: "@openai/codex" },
156
+ },
157
+ opencode: {
158
+ displayName: "OpenCode",
159
+ command: "opencode",
160
+ packageName: "opencode-ai",
161
+ // `opencode run "<prompt>"` runs one non-interactive turn and streams the
162
+ // reply to stdout (the TUI needs a real TTY and would hang over a pipe).
163
+ args: ["run"],
164
+ promptMode: "argv",
165
+ supportTier: "beta",
166
+ blurb: "The most widely used open-source coding harness (OpenCode CLI).",
167
+ // `opencode run -s <id> "<prompt>"` continues a prior session by its own id
168
+ // (`-s, --session session id to continue`, per `opencode run --help`).
169
+ resume: { template: ["run", "-s", "{id}"] },
170
+ // `opencode run --model <provider/model> "<prompt>"` — the flag follows the
171
+ // `run` subcommand (insertAt: 1). Models are `provider/model` ids.
172
+ model: {
173
+ flag: "--model",
174
+ insertAt: 1,
175
+ models: [
176
+ { id: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic" },
177
+ { id: "openai/gpt-5", name: "GPT-5", provider: "openai" },
178
+ { id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro", provider: "google" },
179
+ ],
180
+ },
181
+ // OpenCode ships a native ACP server (`opencode acp`, per opencode.ai/docs/acp),
182
+ // so it can be driven through the governed ProtocolRuntime instead of the pipe —
183
+ // per-tool approvals + streaming + resume. Opt in with BIVY_OPENCODE_ACP=1 (or
184
+ // global BIVY_PREFER_ACP=1); off by default until validated for your version.
185
+ acp: { args: ["acp"] },
186
+ install: { kind: "npm", pkg: "opencode-ai" },
187
+ },
188
+ aider: {
189
+ displayName: "Aider",
190
+ command: "aider",
191
+ packageName: "aider-chat",
192
+ // `aider --message "<prompt>" --yes-always` runs a single non-interactive,
193
+ // git-aware turn and exits instead of dropping into the REPL.
194
+ args: ["--yes-always", "--message"],
195
+ promptMode: "argv",
196
+ supportTier: "beta",
197
+ authOwner: "mixed",
198
+ blurb: "Popular git-native pair-programming agent (Aider).",
199
+ // `aider --model <id> …` — a leading option (insertAt: 0). Aider resolves its
200
+ // own short aliases (sonnet/opus/gpt-4o/…) to concrete provider models.
201
+ model: {
202
+ flag: "--model",
203
+ models: [
204
+ { id: "sonnet", name: "Claude Sonnet (alias)", provider: "anthropic" },
205
+ { id: "opus", name: "Claude Opus (alias)", provider: "anthropic" },
206
+ { id: "gpt-5", name: "GPT-5", provider: "openai" },
207
+ { id: "o3", name: "OpenAI o3", provider: "openai" },
208
+ { id: "gemini", name: "Gemini (alias)", provider: "google" },
209
+ { id: "deepseek", name: "DeepSeek (alias)", provider: "deepseek" },
210
+ ],
211
+ },
212
+ // No `resume`: stock aider-chat has no "continue session <id>" flag. Its own
213
+ // continuity is `--restore-chat-history` reading `.aider.chat.history.md`,
214
+ // scoped to the cwd rather than to a Bivy session id — orthogonal to the
215
+ // generic id-based primitive, and unsafe to bolt on generically (a second,
216
+ // unrelated session opened in the same workspace would inherit that file's
217
+ // history). See docs/agents-not-fully-supported.md.
218
+ install: { kind: "pip", pkg: "aider-chat" },
219
+ },
220
+ hermes: {
221
+ displayName: "Hermes",
222
+ command: "hermes",
223
+ // The npm package is `hermes-agent` (ships the `hermes` bin); the bare
224
+ // `hermes` package is an unrelated abandoned segmentio lib.
225
+ packageName: "hermes-agent",
226
+ promptMode: "argv",
227
+ // Hidden from the picker: dumb-pipe adapter with no validated JSON parser or
228
+ // documented session/resume flag (still runnable via BIVY_RUNTIME=hermes).
229
+ hidden: true,
230
+ // No `resume`: no documented session/resume flag.
231
+ install: { kind: "npm", pkg: "hermes-agent" },
232
+ },
233
+ goose: {
234
+ displayName: "Goose",
235
+ command: "goose",
236
+ packageName: "block/goose",
237
+ args: ["run", "-t"],
238
+ // `goose run --output-format stream-json` streams message/complete envelopes.
239
+ jsonArgs: ["run", "--output-format", "stream-json", "-t"],
240
+ parserId: "goose-stream-json",
241
+ // Goose has no CLI sandbox flag; governed by the FS/MCP/network channels.
242
+ composeArgs: ({ structured }) => (structured ? ["run", "--output-format", "stream-json", "-t"] : ["run", "-t"]),
243
+ // `goose run --resume --session-id <id> -t "<prompt>"` continues a prior
244
+ // session by id (`--session-id` "Requires --resume", per `goose run --help`).
245
+ resume: { template: ["run", "--output-format", "stream-json", "--resume", "--session-id", "{id}", "-t"] },
246
+ // Goose exposes a native ACP server (`goose acp`, per the Goose "ACP clients"
247
+ // guide), so it can be driven through the governed ProtocolRuntime instead of the
248
+ // stream-json pipe — per-tool approvals + streaming + resume. Opt in with
249
+ // BIVY_GOOSE_ACP=1 (or global BIVY_PREFER_ACP=1); off by default until validated.
250
+ acp: { args: ["acp"] },
251
+ promptMode: "argv",
252
+ supportTier: "beta",
253
+ blurb: "Block's open-source agent with a structured stream-json protocol (Goose).",
254
+ // Homebrew isn't present on stock Linux nodes (brew → ENOENT); the official
255
+ // download script installs the goose binary on both Linux and macOS.
256
+ // `brew install block/tap/goose` ENOENTs on any node without Homebrew; the
257
+ // official download script installs the binary into `{bin}` (on PATH) on both
258
+ // Linux and macOS. `{bin}` expands to `<prefix>/bin` at install time.
259
+ install: {
260
+ kind: "curl",
261
+ display: "curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | bash",
262
+ shell: 'mkdir -p "{bin}" && curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | CONFIGURE=false GOOSE_BIN_DIR="{bin}" bash',
263
+ },
264
+ },
265
+ gemini: {
266
+ displayName: "Gemini CLI",
267
+ command: "gemini",
268
+ packageName: "@google/gemini-cli",
269
+ supportTier: "beta",
270
+ blurb: "Google's terminal coding agent (Gemini CLI).",
271
+ // `gemini -m <id> … -p "<prompt>"` — a leading option before the trailing `-p`
272
+ // (insertAt: 0). The prompt flag stays last, so prepending is safe.
273
+ model: {
274
+ flag: "-m",
275
+ models: [
276
+ { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", provider: "google" },
277
+ { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", provider: "google" },
278
+ ],
279
+ },
280
+ args: ["-p"],
281
+ // `gemini -o json` returns one final {session_id,response,stats,error} object.
282
+ jsonArgs: ["-o", "json", "-p"],
283
+ parserId: "gemini-json",
284
+ // Gemini contains via --approval-mode; -p must stay last so the prompt
285
+ // (appended by ProcessRuntime) lands as its value.
286
+ composeArgs: ({ structured, tier }) => [...(structured ? ["-o", "json"] : []), ...sandboxArgsFor("gemini", tier), "-p"],
287
+ // `gemini -o json --approval-mode <mode> -r <id> -p "<prompt>"` continues a
288
+ // previous session (`-r, --resume Resume a previous session. Use "latest" for
289
+ // most recent or index number (e.g. --resume 5)`, per `gemini --help`; a
290
+ // session UUID also works). `{sandbox}` re-derives --approval-mode from the
291
+ // tier so a resumed turn stays as contained as a fresh one.
292
+ resume: { template: ["-o", "json", "{sandbox}", "-r", "{id}", "-p"] },
293
+ // Gemini CLI speaks ACP (`--experimental-acp`), so it can be driven through the
294
+ // governed ProtocolRuntime instead of the one-shot pipe — per-tool approvals +
295
+ // streaming + resume. Opt in with BIVY_GEMINI_ACP=1 (or global BIVY_PREFER_ACP=1);
296
+ // off by default until validated for your Gemini version.
297
+ acp: { args: ["--experimental-acp"] },
298
+ promptMode: "argv",
299
+ install: { kind: "npm", pkg: "@google/gemini-cli" },
300
+ },
301
+ qwen: {
302
+ displayName: "Qwen Code",
303
+ command: "qwen",
304
+ packageName: "@qwen-code/qwen-code",
305
+ supportTier: "beta",
306
+ blurb: "Alibaba's Qwen Code CLI (a Gemini-CLI fork tuned for Qwen-Coder models).",
307
+ // Gemini-CLI fork: same `-m <id> … -p` model flag (insertAt: 0).
308
+ model: {
309
+ flag: "-m",
310
+ models: [
311
+ { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus", provider: "qwen" },
312
+ { id: "qwen3-coder-flash", name: "Qwen3 Coder Flash", provider: "qwen" },
313
+ ],
314
+ },
315
+ // Qwen Code is a Gemini-CLI fork: `-p` runs headless and `--output-format json`
316
+ // emits the same {response,stats,error} envelope Gemini does, so it reuses the
317
+ // gemini-json parser. (Per the Qwen Code headless docs.)
318
+ args: ["-p"],
319
+ jsonArgs: ["--output-format", "json", "-p"],
320
+ parserId: "gemini-json",
321
+ // Shares Gemini's `--approval-mode` containment (see sandboxArgsFor("qwen")).
322
+ composeArgs: ({ structured, tier }) => [...(structured ? ["--output-format", "json"] : []), ...sandboxArgsFor("qwen", tier), "-p"],
323
+ // Gemini-CLI fork: same `--resume <id>` headless resume form (Qwen Code docs,
324
+ // "Headless Mode"). `{sandbox}` re-derives --approval-mode from the tier.
325
+ resume: { template: ["--output-format", "json", "{sandbox}", "--resume", "{id}", "-p"] },
326
+ // Qwen Code inherits Gemini CLI's ACP server (packages/cli/src/acp-integration),
327
+ // so it can be driven through the governed ProtocolRuntime instead of the pipe —
328
+ // per-tool approvals + streaming + resume. Newer builds graduated the flag to
329
+ // `--acp`, but `--experimental-acp` remains a backward-compatible alias across
330
+ // versions (deprecation warning goes to stderr, which the shim logs separately,
331
+ // so it can't corrupt the JSON-RPC stream). Opt in with BIVY_QWEN_ACP=1 (or
332
+ // global BIVY_PREFER_ACP=1); off by default until validated for your version.
333
+ // Zed's ACP registry lists qwen-code with `args: ["--acp"]`.
334
+ acp: { args: ["--experimental-acp"] },
335
+ promptMode: "argv",
336
+ install: { kind: "npm", pkg: "@qwen-code/qwen-code" },
337
+ },
338
+ cline: {
339
+ displayName: "Cline",
340
+ command: "cline",
341
+ packageName: "cline",
342
+ supportTier: "beta",
343
+ blurb: "Cline's standalone terminal agent (the CLI sibling of the Cline IDE extension).",
344
+ // `cline -y "<prompt>"` runs one autonomous, non-interactive task (‑y/‑‑yolo
345
+ // skips per-tool prompts so a piped run doesn't wedge on approval). Bivy's
346
+ // sandbox tier still bounds real effects. Flags are best-effort against the
347
+ // Cline CLI docs — override with BIVY_CLINE_ARGS if a version differs.
348
+ args: ["-y"],
349
+ // `cline --id <id> "<prompt>" -y` resumes an existing session by id
350
+ // (`--id <session-id>` "Resume an existing session by ID", per the Cline CLI
351
+ // reference). No native sandbox/approval-mode flag, so no `{sandbox}` here.
352
+ resume: { template: ["--id", "{id}", "-y"] },
353
+ // The Cline CLI (>2.0.0) speaks ACP via `cline --acp` (per docs.cline.bot ACP
354
+ // editor integrations), so it can be driven through the governed ProtocolRuntime
355
+ // instead of the `-y` pipe — per-tool approvals + streaming + resume. Opt in with
356
+ // BIVY_CLINE_ACP=1 (or global BIVY_PREFER_ACP=1); off by default until validated.
357
+ acp: { args: ["--acp"] },
358
+ promptMode: "argv",
359
+ install: { kind: "npm", pkg: "cline" },
360
+ },
361
+ crush: {
362
+ displayName: "Crush",
363
+ command: "crush",
364
+ packageName: "@charmland/crush",
365
+ supportTier: "beta",
366
+ blurb: "Charm's glamourous open-source coding agent (Crush).",
367
+ // `crush run "<prompt>"` runs a single non-interactive prompt and exits;
368
+ // `-q/--quiet` suppresses the spinner UI so stdout is just the reply.
369
+ // Override with BIVY_CRUSH_ARGS if a version differs.
370
+ args: ["run", "-q"],
371
+ // No `resume`: `crush run` has no session/continue flag upstream yet
372
+ // (charmbracelet/crush#1982, #1015 track adding one) — see
373
+ // docs/agents-not-fully-supported.md.
374
+ promptMode: "argv",
375
+ install: { kind: "npm", pkg: "@charmland/crush" },
376
+ },
377
+ // ---- Second wave (the next-most-used coding-agent CLIs) --------------------
378
+ // Each is pure data on the shared ProcessRuntime path — the same "add an agent
379
+ // = add a spec, not code" mechanism as the block above. Launch/resume/model
380
+ // flags are validated against each CLI's current docs; every one is overridable
381
+ // per node with BIVY_<ID>_ARGS / _RESUME_TEMPLATE / _MODELS.
382
+ cursor: {
383
+ displayName: "Cursor",
384
+ command: "cursor-agent",
385
+ packageName: "cursor (curl https://cursor.com/install)",
386
+ supportTier: "beta",
387
+ blurb: "Cursor's standalone terminal coding agent (cursor-agent) — the editor's engine on the CLI.",
388
+ // `cursor-agent --force -p "<prompt>"` runs one non-interactive print turn and
389
+ // exits (`-p/--print`); `--force` auto-approves tool/command execution so a
390
+ // piped run never blocks on approvals. Prompt is the trailing positional arg.
391
+ args: ["--force", "-p"],
392
+ // Cursor's `--output-format stream-json` emits a streaming JSON event log; the
393
+ // tolerant generic parser reads it. Opt-in (unverified schema) — see below.
394
+ jsonArgs: ["--output-format", "stream-json", "--force", "-p"],
395
+ parserId: "generic-stream-json",
396
+ parserUnverified: true,
397
+ // `cursor-agent --resume=<chatId> …` continues a prior chat by its own id.
398
+ resume: { template: ["--force", "--resume={id}", "-p"] },
399
+ // `cursor-agent -m <id> …` — a leading option (insertAt: 0).
400
+ model: {
401
+ flag: "-m",
402
+ models: [
403
+ { id: "sonnet-4.5", name: "Claude Sonnet 4.5", provider: "anthropic" },
404
+ { id: "opus-4.1", name: "Claude Opus 4.1", provider: "anthropic" },
405
+ { id: "gpt-5", name: "GPT-5", provider: "openai" },
406
+ ],
407
+ },
408
+ // Cursor's agent speaks ACP (`cursor-agent acp`, per cursor.com/docs/cli/acp),
409
+ // so it can be driven through the governed ProtocolRuntime instead of the
410
+ // `--force -p` pipe — per-tool approvals + streaming + resume. Opt in with
411
+ // BIVY_CURSOR_ACP=1 (or global BIVY_PREFER_ACP=1); off by default until validated.
412
+ acp: { args: ["acp"] },
413
+ promptMode: "argv",
414
+ // Not on npm — Cursor ships a curl installer that drops `cursor-agent` on PATH.
415
+ install: { kind: "curl", display: "curl https://cursor.com/install -fsS | bash", shell: "curl https://cursor.com/install -fsS | bash" },
416
+ },
417
+ copilot: {
418
+ displayName: "GitHub Copilot",
419
+ command: "copilot",
420
+ packageName: "@github/copilot",
421
+ supportTier: "beta",
422
+ blurb: "GitHub's official terminal coding agent (Copilot CLI).",
423
+ // `copilot --allow-all-tools -p "<prompt>"` runs one programmatic turn and
424
+ // exits; --allow-all-tools skips per-tool approval so a piped run doesn't
425
+ // wedge. `-p/--prompt` takes the prompt as its value (kept last so the
426
+ // trailing prompt lands there).
427
+ args: ["--allow-all-tools", "-p"],
428
+ // `copilot --model <id> …`
429
+ model: {
430
+ flag: "--model",
431
+ models: [
432
+ { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", provider: "anthropic" },
433
+ { id: "gpt-5", name: "GPT-5", provider: "openai" },
434
+ ],
435
+ },
436
+ // No `resume`: Copilot's resume flag isn't pinned to a stable by-id form yet;
437
+ // wire one with BIVY_COPILOT_RESUME_TEMPLATE if your version documents it.
438
+ // Copilot CLI ships an ACP server (`copilot --acp`; public preview Jan 2026, per
439
+ // docs.github.com Copilot CLI reference), so it can be driven through the governed
440
+ // ProtocolRuntime instead of the `--allow-all-tools -p` pipe — per-tool approvals
441
+ // + streaming + resume (ACP `session/load` covers the resume the pipe lacks). Opt
442
+ // in with BIVY_COPILOT_ACP=1 (or global BIVY_PREFER_ACP=1); off by default until
443
+ // validated for your version.
444
+ acp: { args: ["--acp"] },
445
+ promptMode: "argv",
446
+ install: { kind: "npm", pkg: "@github/copilot" },
447
+ },
448
+ grok: {
449
+ displayName: "Grok",
450
+ command: "grok",
451
+ packageName: "@vibe-kit/grok-cli",
452
+ supportTier: "beta",
453
+ blurb: "Open-source terminal agent for xAI's Grok models (Grok CLI).",
454
+ // `grok -p "<prompt>"` runs one prompt and exits (headless); `-m <id>` picks
455
+ // the model. (The widely-installed @vibe-kit/grok-cli has no by-id resume or
456
+ // JSON flag; the superagent `grok-dev` fork does — override via env if you run
457
+ // that one.)
458
+ args: ["-p"],
459
+ model: {
460
+ flag: "-m",
461
+ models: [
462
+ { id: "grok-code-fast-1", name: "Grok Code Fast 1", provider: "xai" },
463
+ { id: "grok-4-latest", name: "Grok 4", provider: "xai" },
464
+ { id: "grok-3-fast", name: "Grok 3 Fast", provider: "xai" },
465
+ ],
466
+ },
467
+ promptMode: "argv",
468
+ install: { kind: "npm", pkg: "@vibe-kit/grok-cli" },
469
+ },
470
+ amp: {
471
+ displayName: "Amp",
472
+ command: "amp",
473
+ packageName: "@sourcegraph/amp",
474
+ supportTier: "beta",
475
+ blurb: "Sourcegraph's autonomous coding agent with persistent threads (Amp).",
476
+ // `amp -x "<prompt>"` (`--execute`) runs one thread turn and streams to stdout;
477
+ // Amp doesn't gate tools per-run (governed by its own allowlist config), so no
478
+ // approval flag is needed. Prompt trails `-x`.
479
+ args: ["-x"],
480
+ // Amp's `--stream-json` emits one JSON object per line; the tolerant generic
481
+ // parser reads it. Opt-in (unverified schema) — see parserUnverified below.
482
+ jsonArgs: ["--stream-json", "-x"],
483
+ parserId: "generic-stream-json",
484
+ parserUnverified: true,
485
+ // `amp threads continue <id> -x "<prompt>"` continues a prior thread by id.
486
+ resume: { template: ["threads", "continue", "{id}", "-x"] },
487
+ // No model flag: Amp manages model selection itself (agent "mode"), so we don't
488
+ // advertise a picker it can't drive.
489
+ promptMode: "argv",
490
+ install: { kind: "npm", pkg: "@sourcegraph/amp" },
491
+ },
492
+ auggie: {
493
+ displayName: "Auggie",
494
+ command: "auggie",
495
+ packageName: "@augmentcode/auggie",
496
+ supportTier: "beta",
497
+ blurb: "Augment Code's terminal agent backed by its codebase context engine (Auggie).",
498
+ // `auggie --quiet --print "<prompt>"` runs one non-interactive turn and prints
499
+ // the final reply (`--print`); `--quiet` drops the UI chatter. Prompt trails.
500
+ args: ["--quiet", "--print"],
501
+ // No pinned by-id resume or model flag upstream (Augment manages the model);
502
+ // override via BIVY_AUGGIE_RESUME_TEMPLATE / _MODELS if your version adds them.
503
+ promptMode: "argv",
504
+ install: { kind: "npm", pkg: "@augmentcode/auggie" },
505
+ },
506
+ droid: {
507
+ displayName: "Droid",
508
+ command: "droid",
509
+ packageName: "droid (curl https://app.factory.ai/cli)",
510
+ supportTier: "beta",
511
+ blurb: "Factory AI's autonomous terminal coding agent (Droid).",
512
+ // `droid exec --auto high "<prompt>"` runs one headless task at high autonomy
513
+ // (auto-approves) and streams to stdout. Prompt trails the `exec` subcommand.
514
+ args: ["exec", "--auto", "high"],
515
+ // `droid exec --output-format json` prints a final JSON object; the tolerant
516
+ // generic parser reads it. Opt-in (unverified schema) — see below.
517
+ jsonArgs: ["exec", "--output-format", "json", "--auto", "high"],
518
+ parserId: "generic-json",
519
+ parserUnverified: true,
520
+ // `droid exec --model <id> …` — after the `exec` subcommand (insertAt: 1).
521
+ model: {
522
+ flag: "--model",
523
+ insertAt: 1,
524
+ models: [
525
+ { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", provider: "anthropic" },
526
+ { id: "claude-opus-4.1", name: "Claude Opus 4.1", provider: "anthropic" },
527
+ { id: "gpt-5-codex", name: "GPT-5 Codex", provider: "openai" },
528
+ ],
529
+ },
530
+ promptMode: "argv",
531
+ // Not on npm — Factory ships a curl installer that drops `droid` on PATH.
532
+ install: { kind: "curl", display: "curl -fsSL https://app.factory.ai/cli | sh", shell: "curl -fsSL https://app.factory.ai/cli | sh" },
533
+ },
534
+ continue: {
535
+ displayName: "Continue",
536
+ command: "cn",
537
+ packageName: "@continuedev/cli",
538
+ supportTier: "beta",
539
+ blurb: "Continue's headless terminal agent (cn) driving configurable assistants.",
540
+ // `cn --auto -p "<prompt>"` runs one headless turn (`-p` = no TUI) and prints
541
+ // the final response; `--auto` allows all tools without prompting. Prompt
542
+ // trails `-p`.
543
+ args: ["--auto", "-p"],
544
+ // `cn -p … --format json` prints a final JSON object; the tolerant generic
545
+ // parser reads it. Opt-in (unverified schema) — see below.
546
+ jsonArgs: ["--auto", "--format", "json", "-p"],
547
+ parserId: "generic-json",
548
+ parserUnverified: true,
549
+ // `cn --model <slug> …` — Continue Hub owner/model slugs (insertAt: 0).
550
+ model: {
551
+ flag: "--model",
552
+ models: [
553
+ { id: "anthropic/claude-4-sonnet", name: "Claude Sonnet 4", provider: "anthropic" },
554
+ { id: "openai/gpt-5", name: "GPT-5", provider: "openai" },
555
+ ],
556
+ },
557
+ // No `resume`: `cn --resume` continues only the last session for the current
558
+ // terminal — there's no resume-by-id form to plug into the generic primitive.
559
+ promptMode: "argv",
560
+ install: { kind: "npm", pkg: "@continuedev/cli" },
561
+ },
562
+ kilocode: {
563
+ displayName: "Kilo Code",
564
+ command: "kilo",
565
+ packageName: "@kilocode/cli",
566
+ supportTier: "beta",
567
+ blurb: "Kilo Code's terminal CLI (an OpenCode fork) for pipeline-friendly agentic coding.",
568
+ // `kilo run --auto "<prompt>"` runs one non-interactive turn (`run`) with
569
+ // auto-approved permissions (`--auto`) and streams to stdout. Prompt trails.
570
+ args: ["run", "--auto"],
571
+ // `kilo run --format json` emits raw JSON events; the tolerant generic parser
572
+ // reads them. Opt-in (unverified schema) — see below.
573
+ jsonArgs: ["run", "--format", "json", "--auto"],
574
+ parserId: "generic-stream-json",
575
+ parserUnverified: true,
576
+ // `kilo run -s <id> --auto "<prompt>"` continues a session by id.
577
+ resume: { template: ["run", "-s", "{id}", "--auto"] },
578
+ // Kilo Code exposes a native ACP server (`kilo acp`, per the Kilo CLI docs —
579
+ // mirroring OpenCode's design, its upstream). Driven through the governed
580
+ // ProtocolRuntime instead of the `run --auto` pipe, it gains per-tool approvals +
581
+ // streaming + resume. Opt in with BIVY_KILOCODE_ACP=1 (or global BIVY_PREFER_ACP=1);
582
+ // off by default until validated for your version.
583
+ acp: { args: ["acp"] },
584
+ // `kilo run -m <provider/model> …` — after the `run` subcommand (insertAt: 1).
585
+ model: {
586
+ flag: "-m",
587
+ insertAt: 1,
588
+ models: [
589
+ { id: "anthropic/claude-sonnet-4-20250514", name: "Claude Sonnet 4", provider: "anthropic" },
590
+ { id: "openai/gpt-5", name: "GPT-5", provider: "openai" },
591
+ ],
592
+ },
593
+ promptMode: "argv",
594
+ install: { kind: "npm", pkg: "@kilocode/cli" },
595
+ },
596
+ rovodev: {
597
+ displayName: "Rovo Dev",
598
+ command: "acli",
599
+ packageName: "atlassian acli (rovodev)",
600
+ supportTier: "beta",
601
+ blurb: "Atlassian's Rovo Dev terminal coding agent, run through the acli CLI.",
602
+ // `acli rovodev run --yolo "<prompt>"` runs one instruction headlessly; --yolo
603
+ // skips tool-approval prompts. Prompt trails the `rovodev run` subcommand.
604
+ args: ["rovodev", "run", "--yolo"],
605
+ // `acli rovodev run --yolo --restore <id> "<prompt>"` restores a prior session.
606
+ resume: { template: ["rovodev", "run", "--yolo", "--restore", "{id}"] },
607
+ // No `--model` CLI flag (Atlassian-managed; models switch via the in-session
608
+ // /models command), so we don't advertise a picker it can't drive.
609
+ promptMode: "argv",
610
+ // Ships as part of the Atlassian CLI, not npm — installed out of band.
611
+ },
612
+ codebuff: {
613
+ displayName: "Codebuff",
614
+ command: "codebuff",
615
+ packageName: "codebuff",
616
+ // Hidden from the picker (see PICKER_RUNTIME_IDS). The `codebuff` binary has no
617
+ // verified non-TTY headless / print-and-exit mode upstream — its trailing-arg
618
+ // `codebuff "<prompt>"` seeds the interactive TUI, and true automation is meant
619
+ // to go through @codebuff/sdk. We keep the spec so it's runnable via
620
+ // BIVY_RUNTIME=codebuff and promotable to the picker (data-only) the moment a
621
+ // headless flag ships; until then it stays out of the picker to keep it honest.
622
+ supportTier: "experimental",
623
+ blurb: "Open-source multi-agent terminal coding assistant (Codebuff). Headless automation is via @codebuff/sdk today.",
624
+ hidden: true,
625
+ args: [],
626
+ // `codebuff --continue <id> "<prompt>"` continues a prior conversation by id.
627
+ resume: { template: ["--continue", "{id}"] },
628
+ promptMode: "argv",
629
+ install: { kind: "npm", pkg: "codebuff" },
630
+ },
631
+ };
632
+ export function isCliAgentId(id) {
633
+ return Object.prototype.hasOwnProperty.call(CLI_AGENT_SPECS, id);
634
+ }
635
+ /** Ordered CLI agent ids (spec insertion order) — the manifest's canonical order. */
636
+ export const CLI_AGENT_IDS = Object.keys(CLI_AGENT_SPECS);
637
+ /**
638
+ * The install command for a CLI agent, derived from its structured `install`
639
+ * descriptor — the SINGLE source of truth shared by the catalog "Install" button,
640
+ * the server auto-install endpoint, and the terminal CLI manifest. Returns the
641
+ * executable form (`command`/`args`) plus the human `display` string, or undefined
642
+ * when the agent installs out of band (no `install`).
643
+ *
644
+ * `prefix` is the node's npm/bin prefix (BIVY_NPM_GLOBAL_PREFIX, default ~/.local).
645
+ * `{bin}` in a curl `shell` expands to `<prefix>/bin`.
646
+ */
647
+ export function cliInstallSpec(id, prefix) {
648
+ const install = CLI_AGENT_SPECS[id].install;
649
+ if (!install)
650
+ return undefined;
651
+ if (install.kind === "npm") {
652
+ return {
653
+ command: "npm",
654
+ args: ["install", "--global", "--prefix", prefix, install.pkg],
655
+ display: `npm install --global --prefix ${prefix} ${install.pkg}`,
656
+ };
657
+ }
658
+ if (install.kind === "pip") {
659
+ // Some node images ship a python3 without pip; bootstrap it via ensurepip
660
+ // (best-effort) before installing, but show users the plain pip line.
661
+ return {
662
+ command: "sh",
663
+ args: ["-c", `python3 -m ensurepip --user >/dev/null 2>&1 || true; python3 -m pip install --user ${install.pkg}`],
664
+ display: `python3 -m pip install --user ${install.pkg}`,
665
+ };
666
+ }
667
+ // curl / script: `{bin}` → the node's <prefix>/bin so binaries land on PATH.
668
+ const shell = install.shell.replace(/\{bin\}/g, `${prefix}/bin`);
669
+ return { command: "sh", args: ["-c", shell], display: install.display };
670
+ }
671
+ /**
672
+ * Serializable agent manifest — the identity/install/visibility subset of
673
+ * CLI_AGENT_SPECS with no functions, so it can be written to
674
+ * `bin/agent-manifest.json` and consumed by the plain-JS terminal CLI
675
+ * (`bin/bivy.mjs`) that can't import this TypeScript module. `scripts/
676
+ * generate-agent-manifest.mjs` regenerates the JSON; a unit test asserts the file
677
+ * is in sync so the two never drift.
678
+ */
679
+ export function cliAgentManifest() {
680
+ return CLI_AGENT_IDS.map((id) => {
681
+ const spec = CLI_AGENT_SPECS[id];
682
+ // The tokens that mean "one-shot / headless" for `bivy run <agent> …` — the
683
+ // spec's own launch args plus its resume subcommand, deduped. This lets the
684
+ // terminal detect a human running a one-shot without a hand-maintained list.
685
+ const headless = new Set();
686
+ for (const a of spec.args ?? [])
687
+ if (a.startsWith("-") || /^[a-z]/.test(a))
688
+ headless.add(a);
689
+ if (spec.resume)
690
+ for (const a of spec.resume.template)
691
+ if (a.startsWith("-") || /^[a-z]/.test(a))
692
+ headless.add(a);
693
+ return {
694
+ id,
695
+ label: spec.displayName,
696
+ command: spec.command,
697
+ hidden: Boolean(spec.hidden),
698
+ headlessFlags: [...headless].filter((a) => !a.includes("{")),
699
+ install: spec.install ?? null,
700
+ };
701
+ });
702
+ }
703
+ /**
704
+ * Per-agent launch-arg override, e.g. `BIVY_CLINE_ARGS='["task","--json"]'`. Lets
705
+ * an operator correct a CLI's flags for a version we haven't pinned without a code
706
+ * change (the beta CLI agents ship best-effort defaults). Malformed = ignored.
707
+ */
708
+ function cliArgsOverride(id) {
709
+ const raw = process.env[`BIVY_${id.toUpperCase()}_ARGS`]?.trim();
710
+ if (!raw)
711
+ return undefined;
712
+ try {
713
+ const parsed = JSON.parse(raw);
714
+ if (Array.isArray(parsed))
715
+ return parsed.map(String);
716
+ }
717
+ catch {
718
+ // fall through — ignore a malformed override
719
+ }
720
+ return undefined;
721
+ }
722
+ /**
723
+ * Resolve a CLI agent's resume template: an operator override
724
+ * (`BIVY_<ID>_RESUME_TEMPLATE`, a JSON arg array with `{id}`/`{tier}`) wins, else
725
+ * the spec's built-in template. Returns undefined when the agent has no known
726
+ * resume form — which keeps the catalog honest (resume reported off).
727
+ */
728
+ function cliResumeTemplate(id) {
729
+ const raw = process.env[`BIVY_${id.toUpperCase()}_RESUME_TEMPLATE`]?.trim();
730
+ if (raw) {
731
+ try {
732
+ const parsed = JSON.parse(raw);
733
+ if (Array.isArray(parsed))
734
+ return parsed.map(String);
735
+ }
736
+ catch {
737
+ // fall through to the spec default
738
+ }
739
+ }
740
+ return CLI_AGENT_SPECS[id].resume?.template;
741
+ }
742
+ /**
743
+ * Resolve a CLI agent's selectable model list: `BIVY_<ID>_MODELS` (a JSON array of
744
+ * `{id,name?,provider?}`) overrides the spec's curated defaults. Each entry is
745
+ * normalized to a full ModelInfo (the id is the CLI's own model name). Returns an
746
+ * empty list when the agent has no model config and no override.
747
+ */
748
+ function cliModelList(id) {
749
+ const spec = CLI_AGENT_SPECS[id];
750
+ let entries = spec.model?.models;
751
+ const raw = process.env[`BIVY_${id.toUpperCase()}_MODELS`]?.trim();
752
+ if (raw) {
753
+ try {
754
+ const parsed = JSON.parse(raw);
755
+ if (Array.isArray(parsed)) {
756
+ const out = [];
757
+ for (const item of parsed) {
758
+ const e = (item && typeof item === "object" ? item : { id: item });
759
+ const modelId = typeof e.id === "string" ? e.id.trim() : "";
760
+ if (!modelId)
761
+ continue;
762
+ out.push({
763
+ id: modelId,
764
+ name: typeof e.name === "string" ? e.name : undefined,
765
+ provider: typeof e.provider === "string" ? e.provider : undefined,
766
+ });
767
+ }
768
+ entries = out;
769
+ }
770
+ }
771
+ catch {
772
+ // fall through to the spec defaults
773
+ }
774
+ }
775
+ return (entries ?? []).map((e) => ({ provider: e.provider ?? id, id: e.id, name: e.name ?? e.id }));
776
+ }
777
+ /**
778
+ * Build the ProcessRuntime model config for a CLI agent, or undefined when the
779
+ * agent has no model flag or an empty list (so the runtime honestly reports
780
+ * modelSelection off). The chosen model id is passed as the value of `spec.model.flag`.
781
+ */
782
+ function cliModelConfig(id) {
783
+ const spec = CLI_AGENT_SPECS[id];
784
+ if (!spec.model)
785
+ return undefined;
786
+ const models = cliModelList(id);
787
+ if (!models.length)
788
+ return undefined;
789
+ return {
790
+ models,
791
+ modelArgs: (modelId) => [spec.model.flag, modelId],
792
+ insertAt: spec.model.insertAt,
793
+ };
794
+ }
795
+ // Structured parsers that extract token usage from the agent's output (so the
796
+ // runtime can honestly advertise usageReporting — see cli-parsers.extractTokenUsage).
797
+ const USAGE_PARSERS = new Set(["codex-json", "gemini-json", "goose-stream-json"]);
798
+ /** Whether a CLI agent runs a usage-emitting structured parser this launch. */
799
+ function cliUsageReporting(id) {
800
+ const parserId = process.env.BIVY_AGENT_PARSER || CLI_AGENT_SPECS[id].parserId;
801
+ return Boolean(parserId) && USAGE_PARSERS.has(parserId) && process.env.BIVY_AGENT_STRUCTURED !== "0";
802
+ }
803
+ /**
804
+ * Build the ProcessRuntime thinking config for a CLI agent, or undefined when it
805
+ * has no reasoning-effort flag. `BIVY_<ID>_THINKING` (JSON
806
+ * `{levels,template,insertAt?,default?}`) overrides/enables it for any agent.
807
+ */
808
+ function cliThinkingConfig(id) {
809
+ let cfg = CLI_AGENT_SPECS[id].thinking;
810
+ const raw = process.env[`BIVY_${id.toUpperCase()}_THINKING`]?.trim();
811
+ if (raw) {
812
+ try {
813
+ const parsed = JSON.parse(raw);
814
+ if (parsed && Array.isArray(parsed.levels) && Array.isArray(parsed.template)) {
815
+ cfg = { levels: parsed.levels.map(String), template: parsed.template.map(String), insertAt: typeof parsed.insertAt === "number" ? parsed.insertAt : undefined, default: parsed.default ? String(parsed.default) : undefined };
816
+ }
817
+ }
818
+ catch {
819
+ // fall through to the spec default
820
+ }
821
+ }
822
+ if (!cfg || !cfg.levels.length)
823
+ return undefined;
824
+ const template = cfg.template;
825
+ return {
826
+ levels: cfg.levels,
827
+ default: cfg.default,
828
+ thinkingArgs: (level) => template.map((a) => a.replace(/\{level\}/g, level)),
829
+ insertAt: cfg.insertAt,
830
+ };
831
+ }
832
+ // --- #4: opt-in capability probing (self-healing honesty) -------------------
833
+ // Our advertised resume/model capabilities are pinned against each CLI's docs at a
834
+ // point in time, so a version that renamed or dropped a flag would keep rendering a
835
+ // control that silently no-ops. `BIVY_AGENT_PROBE=1` turns on a preflight that runs
836
+ // `<cli> --help` once (cached) and DOWNGRADES any capability whose flag the
837
+ // installed binary doesn't actually mention. It never UPGRADES — adding a
838
+ // capability needs the exact arg template, which help text can't safely supply — so
839
+ // probing can only make the catalog MORE honest, never invent a no-op control.
840
+ const HELP_PROBE_CACHE = new Map();
841
+ function probeHelpText(command) {
842
+ if (HELP_PROBE_CACHE.has(command))
843
+ return HELP_PROBE_CACHE.get(command) ?? null;
844
+ let text = null;
845
+ try {
846
+ const res = spawnSync(command, ["--help"], { encoding: "utf8", timeout: 4000 });
847
+ const out = `${res.stdout ?? ""}\n${res.stderr ?? ""}`.trim();
848
+ text = out.length > 20 ? out.toLowerCase() : null; // too-short output = not real help
849
+ }
850
+ catch {
851
+ text = null;
852
+ }
853
+ HELP_PROBE_CACHE.set(command, text);
854
+ return text;
855
+ }
856
+ // A resume template mixes launch flags (`-p`, `--force`) with the resume-specific
857
+ // token(s) (`--resume`, `threads continue`, `-s`, `--restore`, …). Only the latter
858
+ // evidence resume support, so we match on those — otherwise a shared launch flag
859
+ // appearing in help would mask a genuinely-missing resume flag.
860
+ const RESUME_HINT = /resume|continue|restore|session|thread|^-s$|^-r$|^-c$|^--id$/i;
861
+ /** The resume-indicative flag/subcommand tokens of a resume template. */
862
+ function resumeTokensFor(id) {
863
+ const tmpl = cliResumeTemplate(id) ?? [];
864
+ return tmpl
865
+ .map((t) => t.replace(/=\{[a-z]+\}/g, "").replace(/\{[a-z]+\}/g, "").trim())
866
+ .filter((t) => t && !t.startsWith("{") && RESUME_HINT.test(t));
867
+ }
868
+ /**
869
+ * Pure refinement: given an installed CLI's `--help` text, drop any capability the
870
+ * binary doesn't evidence. Exported for direct unit testing. `resumeTokens` are the
871
+ * resume form's flag/subcommand words (e.g. `["--resume"]`, `["threads","continue"]`);
872
+ * if NONE appear in help, resume is downgraded. Likewise the model flag.
873
+ */
874
+ export function refineCapabilitiesFromHelp(help, current, spec) {
875
+ const h = help.toLowerCase();
876
+ let { resume, modelSelection } = current;
877
+ if (resume && spec.resumeTokens.length && !spec.resumeTokens.some((t) => h.includes(t.toLowerCase()))) {
878
+ resume = false;
879
+ }
880
+ if (modelSelection && spec.modelFlag && !h.includes(spec.modelFlag.toLowerCase())) {
881
+ modelSelection = false;
882
+ }
883
+ return { resume, modelSelection };
884
+ }
885
+ function cliAgentInfo(id) {
886
+ const spec = CLI_AGENT_SPECS[id];
887
+ const installed = commandAvailable(spec.command);
888
+ const npmPrefix = process.env.BIVY_NPM_GLOBAL_PREFIX || "~/.local";
889
+ const installCommand = cliInstallSpec(id, npmPrefix);
890
+ // Honesty invariant (see docs/agents-not-fully-supported.md): capabilities must
891
+ // reflect what the ProcessRuntime path actually delivers, or the PWA renders a
892
+ // picker that silently no-ops. These CLI adapters stream stdout (structured via
893
+ // a CliParser when the agent has a validated JSON mode, else raw) and are
894
+ // governed at the effect level (sandbox tier / FS-MCP-network channels), so
895
+ // toolInterception + modelSelection stay false. resume is on only when the
896
+ // agent has a known resume form (spec.resume or a BIVY_<ID>_RESUME_TEMPLATE
897
+ // override) — Codex is the built-in example; the rest are fresh-process-per-
898
+ // prompt until a resume template is wired.
899
+ let resume = id === "codex" || Boolean(cliResumeTemplate(id));
900
+ let modelSelection = Boolean(cliModelConfig(id));
901
+ const usageReporting = cliUsageReporting(id);
902
+ // When the agent is promoted to ACP (spec.acp + BIVY_<ID>_ACP / BIVY_PREFER_ACP),
903
+ // it runs through the governed ProtocolRuntime — so it honestly gains per-tool
904
+ // approvals and resume. Reflect that in the catalog the picker reads.
905
+ const acpActive = prefersAcp(id);
906
+ if (acpActive)
907
+ resume = true;
908
+ // Opt-in self-healing: if the installed binary's --help doesn't evidence a
909
+ // resume/model flag we advertise, downgrade it (never upgrade). Codex keeps its
910
+ // native, separately-verified resume path, so it's exempt.
911
+ if (process.env.BIVY_AGENT_PROBE === "1" && installed && id !== "codex") {
912
+ const help = probeHelpText(spec.command);
913
+ if (help) {
914
+ const refined = refineCapabilitiesFromHelp(help, { resume, modelSelection }, { resumeTokens: resumeTokensFor(id), modelFlag: spec.model?.flag });
915
+ resume = refined.resume;
916
+ modelSelection = refined.modelSelection;
917
+ }
918
+ }
919
+ return {
920
+ id,
921
+ displayName: spec.displayName,
922
+ description: spec.blurb ?? `Run the local ${spec.displayName} CLI underneath Bivy in the session workspace.`,
923
+ status: installed ? "available" : "external",
924
+ packageName: spec.packageName,
925
+ language: "Process",
926
+ // MCP tool calls are gated by real approvals when the proxy shim is enabled
927
+ // (BIVY_MCP_PROXY) — an honest, narrower capability than full toolInterception
928
+ // (it governs MCP tools, not the agent's built-in shell/edits). See
929
+ // src/harness/mcp-inject.ts + governMcpCall in src/server.ts.
930
+ capabilities: { toolInterception: acpActive, mcpToolApprovals: acpActive || Boolean(process.env.BIVY_MCP_PROXY), modelSelection, resume, packages: false, fork: false, usageReporting, sessionDiscovery: id === "codex" },
931
+ supportTier: spec.supportTier ?? (id === "codex" ? "supported" : "experimental"),
932
+ authOwner: spec.authOwner ?? "agent",
933
+ notes: installed
934
+ ? `Available on PATH. This process adapter ${spec.parserId && !spec.parserUnverified ? "parses its native JSON stream into a structured transcript" : spec.parserId ? "streams stdout/stderr (a structured JSON parser is available; opt in with BIVY_AGENT_STRUCTURED=1 once validated for your version)" : "streams stdout/stderr"}; Bivy governs its filesystem/exec/MCP effects at the sandbox tier rather than intercepting each tool call. Override its launch flags with BIVY_${id.toUpperCase()}_ARGS if your CLI version differs.`
935
+ : `${spec.command} was not found on PATH. Install it on this node, then select this agent again.`,
936
+ install: installed || !installCommand ? undefined : {
937
+ label: `Install ${spec.displayName}`,
938
+ description: `Install ${spec.displayName} on this node now (${installCommand.display}).`,
939
+ command: installCommand.display,
940
+ },
941
+ };
942
+ }
943
+ // "Codex" — the app-server shim runtime (id `codex-approvals`). This is the single
944
+ // Codex we surface: same binary as the plain exec runtime, but driven through the
945
+ // app-server shim so each shell command / file change gets a pre-execution
946
+ // Approve/Deny card via guardianInterceptor, AND it resumes a prior thread by its
947
+ // rollout id (thread/resume). Governed + resumable in one runtime supersedes the
948
+ // exec path, which stays runnable via `BIVY_RUNTIME=codex` for a no-approval flow.
949
+ function codexApprovalsInfo() {
950
+ const installed = commandAvailable("codex");
951
+ return {
952
+ id: "codex-approvals",
953
+ displayName: "Codex",
954
+ description: "Codex driven through its app-server: every shell command or file change it proposes is gated through Bivy's Approve/Deny before it runs (not just the exec jail), and sessions resume with full history.",
955
+ status: installed ? "available" : "external",
956
+ packageName: "codex",
957
+ language: "Process",
958
+ capabilities: {
959
+ toolInterception: true,
960
+ modelSelection: true,
961
+ resume: true,
962
+ packages: false,
963
+ fork: false,
964
+ sessionDiscovery: true,
965
+ // The governed/resumable Codex variant is the one that owns native
966
+ // discovery+adoption (issue #156) — not the plain exec runtime below —
967
+ // so an adopted session gets per-tool approvals from the moment it's
968
+ // imported rather than the ungoverned exec jail.
969
+ nativeSessionDiscovery: true,
970
+ nativeSessionAdoption: true,
971
+ },
972
+ supportTier: "beta",
973
+ authOwner: "agent",
974
+ notes: installed
975
+ ? "Drives Codex's experimental app-server so tool calls surface as in-chat approval cards, and resumes a prior thread by its rollout id (thread/resume). Governance AND resume in one runtime."
976
+ : "codex was not found on PATH. Install it on this node, then select this agent again.",
977
+ };
978
+ }
979
+ async function suggestCodexSessionName(firstPrompt, context) {
980
+ const prompt = firstPrompt.trim();
981
+ if (!prompt)
982
+ return undefined;
983
+ const instruction = [
984
+ "Name this coding-agent session from the user request below.",
985
+ "Return only a concise title of 2-6 words, with no quotes, punctuation, prefix, or explanation.",
986
+ "",
987
+ prompt.slice(0, 4000),
988
+ ].join("\n");
989
+ return new Promise((resolve) => {
990
+ const args = ["exec", "--ephemeral", "--json", "--sandbox", "read-only", "--skip-git-repo-check"];
991
+ if (context.model)
992
+ args.push("--model", context.model);
993
+ args.push(instruction);
994
+ const child = spawn(process.env.BIVY_CODEX_BIN || "codex", args, { cwd: context.cwd, stdio: ["ignore", "pipe", "ignore"] });
995
+ let stdout = "";
996
+ const timer = setTimeout(() => { child.kill("SIGTERM"); resolve(undefined); }, 60_000);
997
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
998
+ child.on("error", () => { clearTimeout(timer); resolve(undefined); });
999
+ child.on("close", (code) => {
1000
+ clearTimeout(timer);
1001
+ if (code !== 0) {
1002
+ resolve(undefined);
1003
+ return;
1004
+ }
1005
+ let text = "";
1006
+ for (const line of stdout.split(/\r?\n/)) {
1007
+ try {
1008
+ const event = JSON.parse(line);
1009
+ if (event.type === "item.completed" && event.item?.type === "agent_message" && event.item.text)
1010
+ text = event.item.text;
1011
+ }
1012
+ catch { /* ignore non-JSON output */ }
1013
+ }
1014
+ const clean = text.replace(/[\r\n'"`]/g, " ").replace(/\p{Control}/gu, "").replace(/\s+/g, " ").trim().replace(/[.?!,:;–—-]+$/g, "").slice(0, 60).trim();
1015
+ resolve(clean || undefined);
1016
+ });
1017
+ });
1018
+ }
1019
+ // Build the Tier-2 Codex runtime: a ProtocolRuntime driving the app-server shim,
1020
+ // with the concrete agent id so takeover/discovery/UI treat it as its own
1021
+ // selectable Codex variant. Capabilities are seeded (toolInterception up front)
1022
+ // because the daemon decides whether to attach guardianInterceptor from
1023
+ // runtime.capabilities before the shim's hello handshake lands.
1024
+ /**
1025
+ * Catalog-capable runtimes for the unified model catalog — Pi included as one
1026
+ * contributor among equals, not a privileged base. Each runtime's `listCatalog()`
1027
+ * contributes its providers + models, deduped and stamped with the shared vault's
1028
+ * auth status by `aggregateModelCatalog`. Construction is cheap (no session
1029
+ * spawned); listCatalog() is static. Codex is always listed; Claude Code when its
1030
+ * SDK is installed.
1031
+ */
1032
+ export function catalogRuntimes(credsDir, piDir, sessionsDir) {
1033
+ const runtimes = [
1034
+ new PiRuntime({ credsDir, piDir, sessionsDir }),
1035
+ codexAppServerRuntime(credsDir),
1036
+ ];
1037
+ if (claudeSdkInstalled())
1038
+ runtimes.push(new ClaudeCodeRuntime(claudeRuntimeFromEnv()));
1039
+ return runtimes;
1040
+ }
1041
+ // `tier` threads the session's chosen sandbox into the app-server shim as env it
1042
+ // reads at launch (BIVY_CODEX_SANDBOX / BIVY_CODEX_APPROVAL_POLICY), so the
1043
+ // governed Codex runtime is contained at the selected tier — including "full
1044
+ // access" actually disabling the sandbox — instead of the shim's hardcoded
1045
+ // workspace-write default. Absent (the session-less catalog build) leaves the
1046
+ // shim on its own defaults.
1047
+ function codexAppServerRuntime(credsDir, tier) {
1048
+ const shim = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "bin", "codex-app-server-shim.mjs");
1049
+ const policy = tier ? codexSandboxPolicy(tier) : undefined;
1050
+ return new ProtocolRuntime({
1051
+ id: "codex-approvals",
1052
+ displayName: "Codex",
1053
+ command: process.execPath,
1054
+ args: [shim],
1055
+ ...(policy ? { env: { BIVY_CODEX_SANDBOX: policy.sandbox, BIVY_CODEX_APPROVAL_POLICY: policy.approvalPolicy } } : {}),
1056
+ credentials: createCredentialStore(credsDir),
1057
+ // Session-less catalog contribution: Codex runs OpenAI models under a ChatGPT
1058
+ // subscription (provider id "openai-codex"). The authoritative per-session
1059
+ // list comes from the app-server; this is the picker preview.
1060
+ catalog: [
1061
+ {
1062
+ id: "openai-codex",
1063
+ name: "OpenAI Codex (ChatGPT)",
1064
+ oauth: true,
1065
+ models: [
1066
+ { provider: "openai-codex", id: "gpt-5-codex", name: "GPT-5 Codex", reasoning: true },
1067
+ { provider: "openai-codex", id: "gpt-5", name: "GPT-5", reasoning: true },
1068
+ ],
1069
+ },
1070
+ ],
1071
+ capabilities: { toolInterception: true, modelSelection: true, resume: true, nativeSessionDiscovery: true, nativeSessionAdoption: true },
1072
+ // Resume: the shim reconnects a prior thread via thread/resume by its rollout
1073
+ // id, and history preloads from the same on-disk rollout the exec path reads —
1074
+ // so takeover/reopen continues a governed session. (Validated on codex-cli
1075
+ // 0.144.1; the app-server threadId == the rollout/session id.)
1076
+ resumable: true,
1077
+ loadHistory: (sessionId) => loadCodexTranscript(sessionId),
1078
+ deleteHistory: (sessionId) => void deleteCodexSession(sessionId),
1079
+ suggestName: suggestCodexSessionName,
1080
+ // Native discovery (issue #156): enumerate Codex rollouts on this node that
1081
+ // Bivy didn't start, so a pre-existing `codex` session can be adopted here
1082
+ // (the governed variant), never the plain exec runtime below.
1083
+ discoverNativeSessions: () => discoverNativeCodexSessions(),
1084
+ });
1085
+ }
1086
+ // --- #2: the GENERAL ACP adapter (Agent Client Protocol) --------------------
1087
+ // Generalizes the app-server shim pattern to the open ACP standard: any ACP agent
1088
+ // (e.g. `gemini --experimental-acp`) is driven through bin/acp-shim.mjs → the same
1089
+ // ProtocolRuntime that backs Codex approvals, so it gets per-tool Approve/Deny,
1090
+ // streaming, and resume with ZERO per-agent code. Configured as data via
1091
+ // BIVY_ACP_COMMAND / BIVY_ACP_ARGS (mirrors generic-cli / bivy-agent-protocol);
1092
+ // hidden from the picker until validated against a given agent, then promotable
1093
+ // with one catalog edit. Returns null when BIVY_ACP_COMMAND isn't set.
1094
+ /** Absolute path to the ACP bridge shim. */
1095
+ function acpShimPath() {
1096
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "bin", "acp-shim.mjs");
1097
+ }
1098
+ /**
1099
+ * Build ProtocolRuntime options that drive an ACP agent (`command` + `agentArgs`)
1100
+ * through bin/acp-shim.mjs. Shared by the generic `acp` runtime and the per-agent
1101
+ * ACP promotion path so both wrap agents identically.
1102
+ */
1103
+ function acpRuntimeOptions(opts) {
1104
+ return {
1105
+ id: opts.id,
1106
+ displayName: opts.displayName,
1107
+ command: process.execPath,
1108
+ args: [acpShimPath(), "--agent", opts.command, "--", ...opts.agentArgs],
1109
+ // Seed governed+resumable up front so the daemon attaches guardianInterceptor to
1110
+ // the FIRST session (before the shim's hello lands); the hello confirms them.
1111
+ capabilities: { toolInterception: true, resume: true },
1112
+ resumable: true,
1113
+ ...(opts.credsDir ? { credentials: createCredentialStore(opts.credsDir) } : {}),
1114
+ };
1115
+ }
1116
+ function acpRuntimeFromEnv(credsDir) {
1117
+ const command = process.env.BIVY_ACP_COMMAND?.trim();
1118
+ if (!command)
1119
+ return null;
1120
+ let agentArgs = [];
1121
+ const rawArgs = process.env.BIVY_ACP_ARGS?.trim();
1122
+ if (rawArgs) {
1123
+ try {
1124
+ const p = JSON.parse(rawArgs);
1125
+ if (Array.isArray(p))
1126
+ agentArgs = p.map(String);
1127
+ }
1128
+ catch { /* ignore malformed */ }
1129
+ }
1130
+ return acpRuntimeOptions({ id: "acp", displayName: process.env.BIVY_ACP_NAME?.trim() || "ACP Agent", command, agentArgs, credsDir });
1131
+ }
1132
+ /**
1133
+ * Whether a CLI agent should be driven through ACP rather than the one-shot pipe:
1134
+ * it declares an `acp` mode AND ACP is preferred for it (per-agent `BIVY_<ID>_ACP=1`
1135
+ * or global `BIVY_PREFER_ACP=1`). This is the data-driven "promote an agent to the
1136
+ * high-capability path" switch — no per-agent code, just a spec field + a flag.
1137
+ */
1138
+ function prefersAcp(id) {
1139
+ if (!CLI_AGENT_SPECS[id].acp)
1140
+ return false;
1141
+ return process.env.BIVY_PREFER_ACP === "1" || process.env[`BIVY_${id.toUpperCase()}_ACP`] === "1";
1142
+ }
1143
+ function acpInfo() {
1144
+ const configured = Boolean(process.env.BIVY_ACP_COMMAND?.trim());
1145
+ return {
1146
+ id: "acp",
1147
+ displayName: process.env.BIVY_ACP_NAME?.trim() || "ACP Agent",
1148
+ description: "Any Agent Client Protocol (ACP) agent, driven through Bivy's shim for per-tool approvals, streaming, and resume.",
1149
+ status: configured ? "available" : "planned",
1150
+ packageName: process.env.BIVY_ACP_COMMAND?.trim() || "Set BIVY_ACP_COMMAND",
1151
+ language: "Process",
1152
+ capabilities: { toolInterception: true, modelSelection: false, resume: true, packages: false, fork: false },
1153
+ supportTier: "experimental",
1154
+ authOwner: "agent",
1155
+ notes: configured
1156
+ ? "Drives an ACP agent via bin/acp-shim.mjs → ProtocolRuntime: per-tool Approve/Deny, streaming transcript, and session/load resume — no per-agent code. Validate against your agent, then promote it into the picker as data."
1157
+ : "Set BIVY_ACP_COMMAND (and optional BIVY_ACP_ARGS, a JSON array) to the ACP agent's launch command, e.g. BIVY_ACP_COMMAND=gemini BIVY_ACP_ARGS='[\"--experimental-acp\"]'.",
1158
+ };
1159
+ }
1160
+ function splitEnvArgs(value, fallback) {
1161
+ if (!value?.trim())
1162
+ return fallback;
1163
+ try {
1164
+ const parsed = JSON.parse(value);
1165
+ if (Array.isArray(parsed))
1166
+ return parsed.map(String);
1167
+ }
1168
+ catch {
1169
+ // fall through to a small shell-like splitter
1170
+ }
1171
+ const out = [];
1172
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
1173
+ let match;
1174
+ while ((match = re.exec(value)))
1175
+ out.push(match[1] ?? match[2] ?? match[3] ?? "");
1176
+ return out;
1177
+ }
1178
+ function openClawProcessOptions() {
1179
+ const command = process.env.BIVY_OPENCLAW_COMMAND?.trim() || "openclaw";
1180
+ const args = splitEnvArgs(process.env.BIVY_OPENCLAW_ARGS, ["agent", "--message"]);
1181
+ const agent = process.env.BIVY_OPENCLAW_AGENT?.trim();
1182
+ const agentFlagIndex = args.indexOf("--message");
1183
+ const argsWithAgent = !agent ? args : agentFlagIndex >= 0
1184
+ ? [...args.slice(0, agentFlagIndex), "--agent", agent, ...args.slice(agentFlagIndex)]
1185
+ : [...args, "--agent", agent];
1186
+ return {
1187
+ id: "openclaw",
1188
+ displayName: agent ? `OpenClaw (${agent})` : "OpenClaw",
1189
+ command,
1190
+ args: argsWithAgent,
1191
+ promptMode: "argv",
1192
+ };
1193
+ }
1194
+ function openClawInfo() {
1195
+ const options = openClawProcessOptions();
1196
+ const installed = commandAvailable(options.command);
1197
+ const npmPrefix = process.env.BIVY_NPM_GLOBAL_PREFIX || "~/.local";
1198
+ const installCommand = `npm install --global --prefix ${npmPrefix} openclaw`;
1199
+ return {
1200
+ id: "openclaw",
1201
+ displayName: options.displayName,
1202
+ description: "Run the local OpenClaw CLI underneath Bivy in the session workspace.",
1203
+ status: installed ? "available" : "external",
1204
+ packageName: "openclaw",
1205
+ language: "Process",
1206
+ capabilities: { toolInterception: false, modelSelection: false, resume: false, packages: false, fork: false },
1207
+ supportTier: "experimental",
1208
+ authOwner: "agent",
1209
+ notes: installed
1210
+ ? "Available on PATH. This phase-1 CLI adapter streams stdout/stderr only; Gateway RPC and structured tool approvals require a future OpenClaw protocol bridge. Configure with BIVY_OPENCLAW_COMMAND, BIVY_OPENCLAW_ARGS, and BIVY_OPENCLAW_AGENT."
1211
+ : `${options.command} was not found on PATH. Install OpenClaw on this node, use the PWA install button, or set BIVY_OPENCLAW_COMMAND to its CLI path.`,
1212
+ install: installed ? undefined : {
1213
+ label: "Install OpenClaw",
1214
+ description: `Install the OpenClaw CLI on this node now (${installCommand}).`,
1215
+ command: installCommand,
1216
+ },
1217
+ };
1218
+ }
1219
+ function protocolInfo() {
1220
+ const configured = Boolean(protocolRuntimeFromEnv());
1221
+ // Surface any BIVY_PROTOCOL_COMMANDS-seeded slash commands in the catalog so
1222
+ // the composer can offer them in autocomplete before the first session's hello
1223
+ // (discovery reads this RuntimeInfo, not the live runtime's refined caps).
1224
+ const commands = protocolCommandsFromEnv();
1225
+ return {
1226
+ id: "bivy-agent-protocol",
1227
+ displayName: process.env.BIVY_PROTOCOL_NAME?.trim() || "Bivy Protocol",
1228
+ description: "JSON-lines process protocol for any agent to expose structured events, tool calls, approvals, models, and sessions without a bespoke Bivy adapter.",
1229
+ status: configured ? "available" : "planned",
1230
+ packageName: process.env.BIVY_PROTOCOL_COMMAND?.trim() || "stdio/jsonl",
1231
+ language: "Any",
1232
+ capabilities: { toolInterception: true, modelSelection: true, resume: true, packages: false, fork: false, ...(commands ? { commands } : {}) },
1233
+ supportTier: "experimental",
1234
+ authOwner: "mixed",
1235
+ notes: configured
1236
+ ? "Configured through BIVY_PROTOCOL_COMMAND / BIVY_PROTOCOL_ARGS. Advertise agent-native slash commands with BIVY_PROTOCOL_COMMANDS (JSON [{name,description}]); other capability flags are finalized by the agent handshake."
1237
+ : "Set BIVY_PROTOCOL_COMMAND to enable a JSONL Bivy Agent Protocol runtime.",
1238
+ };
1239
+ }
1240
+ export const RUNTIME_CATALOG = [
1241
+ {
1242
+ id: "pi",
1243
+ displayName: "Pi",
1244
+ description: "Native Bivy/Pi coding agent runtime with packages, approvals, and model picker.",
1245
+ status: "available",
1246
+ packageName: "@earendil-works/pi-coding-agent",
1247
+ language: "TypeScript",
1248
+ capabilities: PI_CAPABILITIES,
1249
+ supportTier: "supported",
1250
+ authOwner: "bivy",
1251
+ },
1252
+ genericCliInfo(),
1253
+ // `codex` sits before the governed shim it feeds; the rest of the CLI agents are
1254
+ // derived straight from CLI_AGENT_SPECS (adding a spec = one data edit, no list
1255
+ // to keep in sync here).
1256
+ cliAgentInfo("codex"),
1257
+ codexApprovalsInfo(),
1258
+ ...CLI_AGENT_IDS.filter((id) => id !== "codex").map(cliAgentInfo),
1259
+ openClawInfo(),
1260
+ claudeCodeInfo(),
1261
+ protocolInfo(),
1262
+ acpInfo(),
1263
+ {
1264
+ id: "openhands",
1265
+ displayName: "OpenHands",
1266
+ description: "Open-source autonomous software engineering agent, usually run as an app/server with sandboxed execution.",
1267
+ status: "planned",
1268
+ packageName: "openhands-ai/openhands",
1269
+ language: "Python",
1270
+ capabilities: { toolInterception: false, modelSelection: true, resume: true, packages: false, fork: false },
1271
+ supportTier: "planned",
1272
+ authOwner: "agent",
1273
+ notes: "Likely needs a server/protocol adapter rather than the generic CLI path so Bivy can map tasks, logs, files, and approvals cleanly.",
1274
+ },
1275
+ {
1276
+ id: "swe-agent",
1277
+ displayName: "SWE-agent",
1278
+ description: "Batch/task-oriented open-source software engineering agent for issue-to-patch workflows.",
1279
+ status: "planned",
1280
+ packageName: "swe-agent",
1281
+ language: "Python",
1282
+ capabilities: { toolInterception: false, modelSelection: true, resume: false, packages: false, fork: false },
1283
+ supportTier: "planned",
1284
+ authOwner: "agent",
1285
+ notes: "Strong fit for GitHub issue queue runs; likely exposed as a task runner/protocol adapter rather than conversational chat.",
1286
+ },
1287
+ {
1288
+ id: "openai-agents-sdk",
1289
+ displayName: "OpenAI Agents SDK",
1290
+ description: "OpenAI's agent framework with tools, handoffs, guardrails, and tracing.",
1291
+ status: "planned",
1292
+ packageName: "@openai/agents",
1293
+ language: "TypeScript/Python",
1294
+ capabilities: { toolInterception: true, modelSelection: true, resume: false, packages: false, fork: false },
1295
+ supportTier: "planned",
1296
+ authOwner: "mixed",
1297
+ notes: "Good candidate for non-coding workflows; needs a coding-tool bundle to match Pi/Claude Code behavior.",
1298
+ },
1299
+ {
1300
+ id: "langgraph",
1301
+ displayName: "LangGraph",
1302
+ description: "Stateful agent graph runtime from LangChain for durable multi-step workflows.",
1303
+ status: "planned",
1304
+ packageName: "@langchain/langgraph",
1305
+ language: "TypeScript/Python",
1306
+ capabilities: { toolInterception: true, modelSelection: true, resume: true, packages: false, fork: true },
1307
+ supportTier: "planned",
1308
+ authOwner: "mixed",
1309
+ notes: "Strong for custom orchestrations; coding-agent semantics would be defined by our graph and tools.",
1310
+ },
1311
+ {
1312
+ id: "google-adk",
1313
+ displayName: "Google ADK",
1314
+ description: "Google Agent Development Kit for Gemini-oriented agents.",
1315
+ status: "planned",
1316
+ packageName: "google-adk",
1317
+ language: "Python",
1318
+ capabilities: { toolInterception: true, modelSelection: true, resume: false, packages: false, fork: false },
1319
+ supportTier: "planned",
1320
+ authOwner: "mixed",
1321
+ notes: "Likely via a sidecar process/RPC adapter because the primary SDK is Python."
1322
+ },
1323
+ {
1324
+ id: "autogen",
1325
+ displayName: "AutoGen",
1326
+ description: "Microsoft's multi-agent conversation framework.",
1327
+ status: "planned",
1328
+ packageName: "autogen-agentchat",
1329
+ language: "Python/.NET",
1330
+ capabilities: { toolInterception: true, modelSelection: true, resume: false, packages: false, fork: false },
1331
+ supportTier: "planned",
1332
+ authOwner: "mixed",
1333
+ notes: "Best suited for multi-agent workflows; use a sidecar adapter for Bivy.",
1334
+ },
1335
+ {
1336
+ id: "crew-ai",
1337
+ displayName: "CrewAI",
1338
+ description: "Python framework for role-based multi-agent task crews.",
1339
+ status: "planned",
1340
+ packageName: "crewai",
1341
+ language: "Python",
1342
+ capabilities: { toolInterception: true, modelSelection: true, resume: false, packages: false, fork: false },
1343
+ supportTier: "planned",
1344
+ authOwner: "mixed",
1345
+ notes: "Use for workflow/crew tasks rather than low-latency coding sessions; sidecar adapter recommended.",
1346
+ },
1347
+ ];
1348
+ // Agents Bivy fully integrates today and therefore shows in the agent picker —
1349
+ // the most-used coding agents, all driven through Bivy's general paths (the
1350
+ // native Pi/Claude runtimes, the Codex app-server shim, and the data-driven CLI
1351
+ // ProcessRuntime + CliParser path) rather than bespoke per-agent code:
1352
+ //
1353
+ // pi, claude-code-sdk — native runtimes (approvals, models, resume)
1354
+ // codex-approvals — Codex via the app-server shim (approvals + resume)
1355
+ // opencode, gemini, qwen, — CLI agents on the shared ProcessRuntime path:
1356
+ // goose, aider, cline, crush, structured streaming (JSON parser where the CLI
1357
+ // cursor, copilot, grok, amp, has one), effect-level governance (sandbox tier /
1358
+ // auggie, droid, continue, FS-MCP-network channels), honest capabilities
1359
+ // kilocode, rovodev (resume/model advertised only where the CLI
1360
+ // actually drives it).
1361
+ //
1362
+ // "Codex" here is the app-server *shim* runtime (`codex-approvals`): governed
1363
+ // (per-tool Approve/Deny) AND resumable (thread/resume by rollout id), which
1364
+ // strictly supersedes the plain `codex` exec runtime; that exec path stays
1365
+ // runnable via `BIVY_RUNTIME=codex` for the fast, no-approval flow.
1366
+ //
1367
+ // Everything else in RUNTIME_CATALOG is an extension hook that only works once
1368
+ // configured via env (generic-cli, bivy-agent-protocol), a niche/phase-1 adapter
1369
+ // (hermes, openclaw), or an aspirational "planned" placeholder with no adapter.
1370
+ // They are hidden from the picker to keep it honest, but remain fully runnable via
1371
+ // `BIVY_RUNTIME=<id>`. To promote a CLI agent into the picker, give it honest
1372
+ // capabilities (no silently-no-op pickers) and drop its `hidden: true` flag in
1373
+ // CLI_AGENT_SPECS — the picker set below derives from that one field.
1374
+ // See docs/agents-not-fully-supported.md for the rationale and the promotion path.
1375
+ //
1376
+ // The picker = the native/shim runtimes that aren't CLI-agent specs, PLUS every
1377
+ // non-hidden CLI agent. Visibility lives on the spec (`hidden`), so promoting or
1378
+ // demoting an agent is a single data edit with no id list to drift.
1379
+ const NON_CLI_PICKER_IDS = ["pi", "claude-code-sdk", "codex-approvals"];
1380
+ const PICKER_RUNTIME_IDS = new Set([
1381
+ ...NON_CLI_PICKER_IDS,
1382
+ ...CLI_AGENT_IDS.filter((id) => !CLI_AGENT_SPECS[id].hidden),
1383
+ ]);
1384
+ export function listRuntimes(currentId) {
1385
+ return RUNTIME_CATALOG
1386
+ // Keep the current runtime visible even if hidden, so a session pinned to a
1387
+ // hidden agent (e.g. someone running BIVY_RUNTIME=goose) still renders its
1388
+ // selection instead of showing an empty picker.
1389
+ .filter((runtime) => PICKER_RUNTIME_IDS.has(runtime.id) || runtime.id === currentId)
1390
+ .map((runtime) => {
1391
+ if (runtime.id === "generic-cli")
1392
+ return genericCliInfo();
1393
+ if (isCliAgentId(runtime.id))
1394
+ return cliAgentInfo(runtime.id);
1395
+ if (runtime.id === "codex-approvals")
1396
+ return codexApprovalsInfo();
1397
+ if (runtime.id === "openclaw")
1398
+ return openClawInfo();
1399
+ if (runtime.id === "claude-code-sdk")
1400
+ return claudeCodeInfo();
1401
+ if (runtime.id === "bivy-agent-protocol")
1402
+ return protocolInfo();
1403
+ if (runtime.id === "acp")
1404
+ return acpInfo();
1405
+ return runtime;
1406
+ }).map((runtime) => ({ ...runtime, current: runtime.id === currentId }));
1407
+ }
1408
+ export function makeRuntime(options) {
1409
+ const id = (options.runtime ?? process.env.BIVY_RUNTIME ?? "pi").toLowerCase();
1410
+ switch (id) {
1411
+ case "pi":
1412
+ return new PiRuntime(options);
1413
+ case "generic-cli": {
1414
+ const processOptions = processRuntimeFromEnv();
1415
+ if (!processOptions)
1416
+ throw new Error("generic-cli requires BIVY_AGENT_COMMAND to be set.");
1417
+ // Share the node's provider logins (the shared vault) so the CLI agent finds
1418
+ // whatever model key it needs without a separate per-agent sign-in.
1419
+ // BIVY_AGENT_PARSER opts this agent into Phase 4 structured mode (fidelity).
1420
+ return new ProcessRuntime({ ...processOptions, credentials: createCredentialStore(options.credsDir), parserFactory: parserFactoryFor(process.env.BIVY_AGENT_PARSER) });
1421
+ }
1422
+ case "codex-approvals": {
1423
+ // Tier 2, per-session: the user picked governed Codex from the agent picker.
1424
+ // Drives Codex's app-server through the bivy-agent-protocol shim so each
1425
+ // shell/patch the model proposes becomes a pre-execution Approve/Deny card
1426
+ // via guardianInterceptor — not just the effect-level exec jail.
1427
+ if (!commandAvailable("codex"))
1428
+ throw new Error("Codex command not found on PATH: codex");
1429
+ return codexAppServerRuntime(options.credsDir, sandboxTier(options.sandbox));
1430
+ }
1431
+ case "openclaw": {
1432
+ const openClawOptions = openClawProcessOptions();
1433
+ if (!commandAvailable(openClawOptions.command))
1434
+ throw new Error(`OpenClaw command not found on PATH: ${openClawOptions.command}`);
1435
+ // OpenClaw owns its own auth profiles by default; Bivy only supervises the
1436
+ // local CLI process in this phase-1 adapter.
1437
+ return new ProcessRuntime(openClawOptions);
1438
+ }
1439
+ case "bivy-agent-protocol": {
1440
+ const protocolOptions = protocolRuntimeFromEnv();
1441
+ if (!protocolOptions)
1442
+ throw new Error("bivy-agent-protocol requires BIVY_PROTOCOL_COMMAND to be set.");
1443
+ return new ProtocolRuntime({ ...protocolOptions, credentials: createCredentialStore(options.credsDir) });
1444
+ }
1445
+ case "acp": {
1446
+ const acpOptions = acpRuntimeFromEnv(options.credsDir);
1447
+ if (!acpOptions)
1448
+ throw new Error("acp requires BIVY_ACP_COMMAND to be set (the ACP agent's launch command, e.g. gemini).");
1449
+ return new ProtocolRuntime(acpOptions);
1450
+ }
1451
+ case "claude":
1452
+ case "claude-code":
1453
+ case "claude-code-sdk":
1454
+ // Share the node's provider logins (the shared vault) so the user doesn't
1455
+ // re-auth Anthropic for this agent.
1456
+ return new ClaudeCodeRuntime({ ...claudeRuntimeFromEnv(), credentials: createCredentialStore(options.credsDir), sandbox: options.sandbox });
1457
+ default:
1458
+ // Every CLI agent in CLI_AGENT_SPECS is dispatched here as data — no per-id
1459
+ // case to maintain. Anything that isn't a known CLI agent throws below.
1460
+ if (isCliAgentId(id))
1461
+ return makeCliRuntime(id, options);
1462
+ throw new Error(`Unknown or unavailable BIVY_RUNTIME "${id}". Available runtimes: pi, openclaw/codex/opencode/aider/hermes/goose/gemini/qwen/cline/crush/cursor/copilot/grok/amp/auggie/droid/continue/kilocode/rovodev/codebuff (when their CLI is installed), generic-cli (when BIVY_AGENT_COMMAND is set), claude-code-sdk (when @anthropic-ai/claude-agent-sdk is installed).`);
1463
+ }
1464
+ }
1465
+ /**
1466
+ * Build a ProcessRuntime for any CLI agent from its CLI_AGENT_SPECS entry — the
1467
+ * single data-driven launch path (structured JSON mode where a parser exists,
1468
+ * effect-level governance, generic resume). Extracted from the makeRuntime switch
1469
+ * so adding an agent stays a pure-data change.
1470
+ */
1471
+ function makeCliRuntime(id, options) {
1472
+ const spec = CLI_AGENT_SPECS[id];
1473
+ if (!commandAvailable(spec.command))
1474
+ throw new Error(`${spec.displayName} command not found on PATH: ${spec.command}`);
1475
+ // ACP promotion: when the agent declares an `acp` mode and it's preferred
1476
+ // (BIVY_<ID>_ACP=1 / BIVY_PREFER_ACP=1), drive it through the governed
1477
+ // ProtocolRuntime (per-tool approvals + streaming + resume) instead of the
1478
+ // one-shot pipe below — the high-capability path, selected as data.
1479
+ if (spec.acp && prefersAcp(id)) {
1480
+ return new ProtocolRuntime(acpRuntimeOptions({ id, displayName: spec.displayName, command: spec.command, agentArgs: spec.acp.args, credsDir: options.credsDir }));
1481
+ }
1482
+ // Phase 4 — structured mode ON by default when the agent has a VALIDATED JSON
1483
+ // parser: launch with its native JSON flags and parse stdout into normalized
1484
+ // events. BIVY_AGENT_STRUCTURED=0 forces the dumb-pipe fallback everywhere;
1485
+ // BIVY_AGENT_STRUCTURED=1 opts INTO structured mode for agents whose parser
1486
+ // is still unverified (spec.parserUnverified — safe default is dumb pipe so a
1487
+ // wrong flag can't regress a working agent). BIVY_AGENT_PARSER overrides the
1488
+ // parser id (e.g. to "bivy-protocol").
1489
+ const structuredPref = process.env.BIVY_AGENT_STRUCTURED;
1490
+ const parserReady = Boolean(spec.parserId) && (!spec.parserUnverified || structuredPref === "1");
1491
+ const structured = parserReady && structuredPref !== "0";
1492
+ const parserId = process.env.BIVY_AGENT_PARSER || (structured ? spec.parserId : undefined);
1493
+ const tier = sandboxTier(options.sandbox);
1494
+ // BIVY_<ID>_ARGS overrides the launch flags for a CLI version we haven't
1495
+ // pinned; else composeArgs (native sandbox) wins; else structured jsonArgs;
1496
+ // else the plain args.
1497
+ const runArgs = cliArgsOverride(id)
1498
+ ?? (spec.composeArgs
1499
+ ? spec.composeArgs({ structured, tier })
1500
+ : structured && spec.jsonArgs
1501
+ ? spec.jsonArgs
1502
+ : spec.args);
1503
+ // Codex reads OPENAI_API_KEY or its own `$CODEX_HOME/auth.json`. When the
1504
+ // user connected a ChatGPT/Codex subscription in Bivy (but hasn't run
1505
+ // `codex login`), `prepare` mints that auth file from the shared vault so
1506
+ // the run just works; the preflight still catches the genuinely
1507
+ // uncredentialed case with an actionable message instead of an opaque 401.
1508
+ const preflight = id === "codex"
1509
+ ? (env) => codexCredentialPreflight(env)
1510
+ : id === "opencode"
1511
+ ? (env, ctx) => opencodeCredentialPreflight(env, ctx)
1512
+ : undefined;
1513
+ const prepare = id === "codex"
1514
+ ? async () => {
1515
+ const home = await ensureCodexAuth(options.credsDir);
1516
+ return home ? { CODEX_HOME: home } : {};
1517
+ }
1518
+ : undefined;
1519
+ // Resume, the generic way. Codex keeps its verified path (rollout history +
1520
+ // tier-aware `codex exec resume <id> --json`). Every other CLI agent becomes
1521
+ // resumable purely as data: a spec.resume template (or a BIVY_<ID>_RESUME_
1522
+ // TEMPLATE override) whose {id}/{tier}/{sandbox} placeholders are filled per
1523
+ // prompt — no per-agent code. Absent = fresh process per prompt (resume
1524
+ // stays off; see the per-agent comments in CLI_AGENT_SPECS for why some
1525
+ // genuinely have no native "continue session <id>" form).
1526
+ const resumeTemplate = id === "codex" ? undefined : cliResumeTemplate(id);
1527
+ const resumeOpts = id === "codex"
1528
+ ? {
1529
+ resumable: true,
1530
+ loadHistory: (sessionId) => loadCodexTranscript(sessionId),
1531
+ deleteHistory: (sessionId) => void deleteCodexSession(sessionId),
1532
+ resumeArgs: (sessionId) => codexResumeArgs(sessionId, tier),
1533
+ }
1534
+ : resumeTemplate
1535
+ ? {
1536
+ resumable: true,
1537
+ loadHistory: spec.resume?.loadHistory,
1538
+ // `{sandbox}` expands to that agent's native containment flags for
1539
+ // the tier (e.g. Gemini/Qwen's `--approval-mode <mode>`) — a whole
1540
+ // token, not a string substitution, since it can be multiple argv
1541
+ // words; `{id}`/`{tier}` stay plain per-token string replacement.
1542
+ resumeArgs: (sessionId) => resumeTemplate.flatMap((a) => a === "{sandbox}"
1543
+ ? sandboxArgsFor(id, tier)
1544
+ : [a.replace(/\{id\}/g, sessionId).replace(/\{tier\}/g, tier)]),
1545
+ }
1546
+ : {};
1547
+ return new ProcessRuntime({ id, displayName: spec.displayName, command: spec.command, args: runArgs, promptMode: spec.promptMode, credentials: createCredentialStore(options.credsDir), parserFactory: parserFactoryFor(parserId), preflight, prepare, model: cliModelConfig(id), thinking: cliThinkingConfig(id), usageReporting: cliUsageReporting(id), ...resumeOpts });
1548
+ }