@bivy/bivy 0.7.0 → 0.8.0

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.
@@ -21,6 +21,17 @@
21
21
  // whether to suppress the turn's error toast before kicking off the async swap +
22
22
  // retry (`applyReroute`). Reroute happens only at the turn boundary, so there is
23
23
  // no partial-work hazard.
24
+ //
25
+ // It also plans the OTHER in-place recovery a live session can do: waiting out a
26
+ // provider usage/rate limit and re-sending the same prompt when the window
27
+ // resets (`planResume`). Unlike a reroute (which the controller applies itself),
28
+ // a resume can be hours away and must survive a daemon restart, so scheduling +
29
+ // persistence live in the caller (src/server.ts) — the controller only decides
30
+ // whether a resume is warranted and by when.
31
+ /** Below this, a "retry" is ordinary backoff (seconds) — not worth deferring an
32
+ * interactive turn for; let it surface. A real usage/rate window reset is
33
+ * minutes-to-days out and always clears this bar. */
34
+ const MIN_RESUME_DELAY_MS = 60_000;
24
35
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
25
36
  export class SessionRerouteController {
26
37
  deps;
@@ -50,6 +61,61 @@ export class SessionRerouteController {
50
61
  attempt: this.attempt,
51
62
  rerouteCount: this.rerouteCount,
52
63
  });
64
+ if (decision.action !== "reroute")
65
+ return null;
66
+ return this.rerouteFrom(decision, currentModel);
67
+ }
68
+ /**
69
+ * Decide whether this turn error should be recovered by WAITING for a provider
70
+ * usage/rate limit to reset and re-sending the same prompt. Returns a plan the
71
+ * caller should persist + schedule, or null (surface the error as usual).
72
+ *
73
+ * `resetsAtHint` is the authoritative reset time when the caller has one (the
74
+ * provider's structured usage snapshot) — essential for a multi-day "weekly"
75
+ * window, whose error text only states a time-of-day. `now` is injectable for
76
+ * deterministic tests. Pure w.r.t. the controller's counters.
77
+ */
78
+ planResume(rawError, currentModel, opts = {}) {
79
+ if (this.applying)
80
+ return null;
81
+ const now = opts.now ?? Date.now();
82
+ const decision = this.deps.policy.decide({
83
+ routing: { model: currentModel },
84
+ error: rawError,
85
+ attempt: this.attempt,
86
+ rerouteCount: this.rerouteCount,
87
+ resetsAtHint: opts.resetsAtHint,
88
+ });
89
+ if (decision.action !== "retry")
90
+ return null;
91
+ // Only defer for a concrete recovery window — a provider reset, or a delay
92
+ // long enough that it's clearly a limit rather than routine backoff.
93
+ if (decision.resetsAt === undefined && decision.delayMs < MIN_RESUME_DELAY_MS)
94
+ return null;
95
+ // Resolve the due time (provider reset when known, else backoff) and floor it
96
+ // to at least MIN_RESUME_DELAY_MS in the FUTURE. A reset time can be in the
97
+ // past or ~now — a stale/elapsed reset, clock skew, or (most often) a window
98
+ // that already lapsed while the daemon was down — and using it verbatim yields
99
+ // a 0ms delay. The caller arms a timer at that delay, so a 0ms resume re-sends
100
+ // instantly, re-hits the still-standing limit, and re-schedules 0ms again: a
101
+ // tight loop that pins a CPU core and never settles. Flooring turns a
102
+ // not-yet-cleared limit into a slow retry the attempt budget can still park.
103
+ const rawDueMs = decision.resetsAt ? Date.parse(decision.resetsAt) : now + decision.delayMs;
104
+ const dueMs = Math.max(Number.isFinite(rawDueMs) ? rawDueMs : now, now + MIN_RESUME_DELAY_MS);
105
+ return {
106
+ condition: decision.condition,
107
+ summary: decision.summary,
108
+ delayMs: dueMs - now,
109
+ resumeAt: new Date(dueMs).toISOString(),
110
+ };
111
+ }
112
+ /** Advance the attempt budget once the caller has committed to a resume, so a
113
+ * limit that re-fires after the reset counts toward `maxAttempts` and can
114
+ * eventually exhaust (→ park) instead of looping forever. */
115
+ noteResumeApplied() {
116
+ this.attempt += 1;
117
+ }
118
+ rerouteFrom(decision, currentModel) {
53
119
  if (decision.action !== "reroute")
54
120
  return null;
55
121
  const model = decision.routing.model;
@@ -100,6 +100,25 @@ export async function resolveGitHubToken(env = process.env) {
100
100
  return undefined;
101
101
  }
102
102
  }
103
+ /**
104
+ * Whether the GitHub CLI (`gh`) is installed on this machine — used only to
105
+ * shade the "no GitHub token" message: when `gh` is present but `gh auth token`
106
+ * gave us nothing, the user is one `gh auth login` away, so the picker can say
107
+ * so. It never means `gh` is REQUIRED — `bivy github:connect` is the primary
108
+ * path and needs no CLI (see resolveGitHubToken). Mirrors the `command -v`
109
+ * probe in secrets.ts.
110
+ */
111
+ export async function ghCliInstalled() {
112
+ const which = process.platform === "win32" ? "where" : "command";
113
+ const args = process.platform === "win32" ? ["gh"] : ["-v", "gh"];
114
+ try {
115
+ await exec(which, args, process.platform === "win32" ? {} : { shell: true });
116
+ return true;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ }
103
122
  /**
104
123
  * Refresh the remote-tracking refs so a session branches off the CURRENT state
105
124
  * of `origin`, not whatever the local checkout last saw. Best-effort: an offline
@@ -116,14 +116,27 @@ function claudeCodeInfo() {
116
116
  },
117
117
  };
118
118
  }
119
+ // Memoized CLI probes. `commandAvailable`/`resolveCommandPath`/`probeHelpText`
120
+ // each shell out with a BLOCKING spawnSync, and the runtime catalog that calls
121
+ // them (cliAgentInfo → prefersAcp/acpSupportedByBinary) is rebuilt often — on
122
+ // every advertise and every runtimes.list send. Re-probing per build stalled the
123
+ // event loop for seconds at a time (a synchronous spawn storm). A CLI's presence
124
+ // is effectively constant for the daemon's run, so cache per command for the
125
+ // process lifetime and clear on install (invalidateCliProbeCache).
126
+ const COMMAND_AVAILABLE_CACHE = new Map();
119
127
  function commandAvailable(command) {
120
128
  if (!command.trim())
121
129
  return false;
130
+ const cached = COMMAND_AVAILABLE_CACHE.get(command);
131
+ if (cached !== undefined)
132
+ return cached;
122
133
  const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
123
134
  shell: process.platform !== "win32",
124
135
  stdio: "ignore",
125
136
  });
126
- return result.status === 0;
137
+ const available = result.status === 0;
138
+ COMMAND_AVAILABLE_CACHE.set(command, available);
139
+ return available;
127
140
  }
128
141
  function genericCliInfo() {
129
142
  const options = processRuntimeFromEnv();
@@ -179,7 +192,11 @@ const CLI_AGENT_SPECS = {
179
192
  // reply to stdout (the TUI needs a real TTY and would hang over a pipe).
180
193
  args: ["run"],
181
194
  promptMode: "argv",
182
- supportTier: "beta",
195
+ // Supported tier: OpenCode runs on the governed ACP path by default (per-tool
196
+ // Approve/Deny + session/load resume + a real model picker), the same bar Pi,
197
+ // Claude Code, and Codex clear. See `acp` below for the version fallback.
198
+ supportTier: "supported",
199
+ testedVersion: "1.18.13",
183
200
  blurb: "The most widely used open-source coding harness (OpenCode CLI).",
184
201
  // `opencode run -s <id> "<prompt>"` continues a prior session by its own id
185
202
  // (`-s, --session session id to continue`, per `opencode run --help`).
@@ -195,11 +212,14 @@ const CLI_AGENT_SPECS = {
195
212
  { id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro", provider: "google" },
196
213
  ],
197
214
  },
198
- // OpenCode ships a native ACP server (`opencode acp`, per opencode.ai/docs/acp),
199
- // so it can be driven through the governed ProtocolRuntime instead of the pipe
200
- // per-tool approvals + streaming + resume. Opt in with BIVY_OPENCODE_ACP=1 (or
201
- // global BIVY_PREFER_ACP=1); off by default until validated for your version.
202
- acp: { args: ["acp"] },
215
+ // `opencode acp` ("start ACP (Agent Client Protocol) server") drives OpenCode
216
+ // through the governed ProtocolRuntime instead of the one-shot pipe: per-tool
217
+ // Approve/Deny, streaming, `session/load` resume, and `session/set_model`.
218
+ // Validated against opencode 1.18.13, so it is ON by default (`preferred`)
219
+ // gated on the binary actually listing the `acp` subcommand, so an older
220
+ // OpenCode falls back to the pipe path rather than opening a dead session.
221
+ // Force the pipe path back with BIVY_OPENCODE_ACP=0.
222
+ acp: { args: ["acp"], helpToken: "acp", preferred: true },
203
223
  install: { kind: "npm", pkg: "opencode-ai" },
204
224
  },
205
225
  aider: {
@@ -857,10 +877,34 @@ function cliThinkingConfig(id) {
857
877
  // installed binary doesn't actually mention. It never UPGRADES — adding a
858
878
  // capability needs the exact arg template, which help text can't safely supply — so
859
879
  // probing can only make the catalog MORE honest, never invent a no-op control.
880
+ /**
881
+ * Absolute path of a command on the current PATH, or null when it isn't there.
882
+ * Used to key the help-probe cache: caching by the bare NAME would keep serving a
883
+ * stale answer after the binary behind that name changed (a CLI upgraded or
884
+ * installed while the daemon is running, or a different PATH entry winning).
885
+ * Memoized per command (see COMMAND_AVAILABLE_CACHE) — it spawnSyncs, and is hit
886
+ * on every catalog build.
887
+ */
888
+ const COMMAND_PATH_CACHE = new Map();
889
+ function resolveCommandPath(command) {
890
+ if (!command.trim())
891
+ return null;
892
+ const cached = COMMAND_PATH_CACHE.get(command);
893
+ if (cached !== undefined)
894
+ return cached;
895
+ const res = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
896
+ shell: process.platform !== "win32",
897
+ encoding: "utf8",
898
+ });
899
+ const resolved = res.status !== 0 ? null : ((res.stdout ?? "").split(/\r?\n/)[0]?.trim() || null);
900
+ COMMAND_PATH_CACHE.set(command, resolved);
901
+ return resolved;
902
+ }
860
903
  const HELP_PROBE_CACHE = new Map();
861
904
  function probeHelpText(command) {
862
- if (HELP_PROBE_CACHE.has(command))
863
- return HELP_PROBE_CACHE.get(command) ?? null;
905
+ const key = resolveCommandPath(command) ?? command;
906
+ if (HELP_PROBE_CACHE.has(key))
907
+ return HELP_PROBE_CACHE.get(key) ?? null;
864
908
  let text = null;
865
909
  try {
866
910
  const res = spawnSync(command, ["--help"], { encoding: "utf8", timeout: 4000 });
@@ -870,9 +914,22 @@ function probeHelpText(command) {
870
914
  catch {
871
915
  text = null;
872
916
  }
873
- HELP_PROBE_CACHE.set(command, text);
917
+ HELP_PROBE_CACHE.set(key, text);
874
918
  return text;
875
919
  }
920
+ /**
921
+ * Drop every memoized CLI probe (availability, resolved path, --help text). These
922
+ * probes shell out with a blocking spawnSync and are cached for the process
923
+ * lifetime to keep the frequently-rebuilt runtime catalog off the event loop, so a
924
+ * CLI installed/updated mid-run wouldn't otherwise be noticed until a restart.
925
+ * Call this right after Bivy installs a runtime so the next catalog build re-probes
926
+ * and reflects the new binary immediately.
927
+ */
928
+ export function invalidateCliProbeCache() {
929
+ COMMAND_AVAILABLE_CACHE.clear();
930
+ COMMAND_PATH_CACHE.clear();
931
+ HELP_PROBE_CACHE.clear();
932
+ }
876
933
  // A resume template mixes launch flags (`-p`, `--force`) with the resume-specific
877
934
  // token(s) (`--resume`, `threads continue`, `-s`, `--restore`, …). Only the latter
878
935
  // evidence resume support, so we match on those — otherwise a shared launch flag
@@ -963,9 +1020,14 @@ function cliAgentInfo(id) {
963
1020
  // src/harness/mcp-inject.ts + governMcpCall in src/server.ts.
964
1021
  capabilities: { toolInterception: acpActive, mcpToolApprovals: acpActive || Boolean(process.env.BIVY_MCP_PROXY), modelSelection, resume, packages: false, fork: false, usageReporting, sessionDiscovery: id === "codex" },
965
1022
  supportTier: spec.supportTier ?? (id === "codex" ? "supported" : "experimental"),
1023
+ testedVersion: spec.testedVersion,
966
1024
  authOwner: spec.authOwner ?? "agent",
967
1025
  notes: installed
968
- ? `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.`
1026
+ ? acpActive
1027
+ // Promoted to ACP: the description must match the governed path actually in
1028
+ // use, not the pipe path this agent would otherwise take.
1029
+ ? `Available on PATH, driven through its Agent Client Protocol server (\`${spec.command} ${spec.acp?.args.join(" ")}\`): each tool call is gated by Bivy's Approve/Deny before it runs, and sessions resume natively. Force the plain stdout pipe with BIVY_${id.toUpperCase()}_ACP=0.`
1030
+ : `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.`
969
1031
  : `${spec.command} was not found on PATH. Install it on this node, then select this agent again.`,
970
1032
  install: installed || !installCommand ? undefined : {
971
1033
  label: `Install ${spec.displayName}`,
@@ -980,6 +1042,13 @@ function cliAgentInfo(id) {
980
1042
  // Approve/Deny card via guardianInterceptor, AND it resumes a prior thread by its
981
1043
  // rollout id (thread/resume). Governed + resumable in one runtime supersedes the
982
1044
  // exec path, which stays runnable via `BIVY_RUNTIME=codex` for a no-approval flow.
1045
+ /**
1046
+ * The Codex CLI release this adapter was last certified against. Unlike Pi and the
1047
+ * Claude Agent SDK, Codex is an external binary rather than a pinned npm dependency,
1048
+ * so there is no lockfile entry to derive this from — it is bumped deliberately when
1049
+ * the app-server shim is re-validated against a new Codex release.
1050
+ */
1051
+ const CODEX_TESTED_VERSION = "0.145.0";
983
1052
  function codexApprovalsInfo() {
984
1053
  const installed = commandAvailable("codex");
985
1054
  return {
@@ -1009,7 +1078,12 @@ function codexApprovalsInfo() {
1009
1078
  nativeSessionDiscovery: true,
1010
1079
  nativeSessionAdoption: true,
1011
1080
  },
1012
- supportTier: "beta",
1081
+ // Supported tier: the app-server shim already clears the same bar as Pi and
1082
+ // Claude Code — per-tool Approve/Deny, model selection, thread resume, usage
1083
+ // reporting, and native session discovery/adoption — all over a bidirectional
1084
+ // protocol rather than a one-shot pipe.
1085
+ supportTier: "supported",
1086
+ testedVersion: CODEX_TESTED_VERSION,
1013
1087
  authOwner: "agent",
1014
1088
  notes: installed
1015
1089
  ? "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."
@@ -1202,15 +1276,50 @@ function acpRuntimeFromEnv(credsDir) {
1202
1276
  return acpRuntimeOptions({ id: "acp", displayName: process.env.BIVY_ACP_NAME?.trim() || "ACP Agent", command, agentArgs, credsDir });
1203
1277
  }
1204
1278
  /**
1205
- * Whether a CLI agent should be driven through ACP rather than the one-shot pipe:
1206
- * it declares an `acp` mode AND ACP is preferred for it (per-agent `BIVY_<ID>_ACP=1`
1207
- * or global `BIVY_PREFER_ACP=1`). This is the data-driven "promote an agent to the
1208
- * high-capability path" switch no per-agent code, just a spec field + a flag.
1279
+ * Does the INSTALLED binary actually evidence the agent's ACP mode? A default-on
1280
+ * promotion must never be taken on faith: ACP is a hard switch (the pipe path is
1281
+ * unreachable once a session opens), so a CLI too old to have the subcommand would
1282
+ * otherwise hang and die instead of degrading. We reuse the same cached `--help`
1283
+ * probe the opt-in capability refinement uses, and fail CLOSED — a missing binary
1284
+ * or unreadable help keeps the agent on the honest pipe path.
1285
+ */
1286
+ function acpSupportedByBinary(id) {
1287
+ const spec = CLI_AGENT_SPECS[id];
1288
+ if (!spec.acp)
1289
+ return false;
1290
+ if (!commandAvailable(spec.command))
1291
+ return false;
1292
+ const help = probeHelpText(spec.command);
1293
+ if (!help)
1294
+ return false;
1295
+ const token = (spec.acp.helpToken ?? spec.acp.args[0] ?? "acp").toLowerCase();
1296
+ return help.includes(token);
1297
+ }
1298
+ /**
1299
+ * Whether a CLI agent should be driven through ACP rather than the one-shot pipe.
1300
+ * Three ways in, in precedence order:
1301
+ * - `BIVY_<ID>_ACP=0` — operator forces the pipe path back (escape hatch).
1302
+ * - `BIVY_<ID>_ACP=1` / `BIVY_PREFER_ACP=1` — operator forces ACP, no probe (they
1303
+ * know their binary; an explicit request shouldn't be second-guessed).
1304
+ * - `spec.acp.preferred` — validated agents are promoted by DEFAULT, but only
1305
+ * when the installed binary evidences the ACP mode (see acpSupportedByBinary).
1306
+ * Still no per-agent code: a spec field plus a flag.
1307
+ *
1308
+ * Both the catalog (cliAgentInfo) and the launch path (makeCliRuntime) call this,
1309
+ * so what the picker advertises and what actually starts cannot disagree.
1209
1310
  */
1210
1311
  function prefersAcp(id) {
1211
- if (!CLI_AGENT_SPECS[id].acp)
1312
+ const spec = CLI_AGENT_SPECS[id];
1313
+ if (!spec.acp)
1314
+ return false;
1315
+ const override = process.env[`BIVY_${id.toUpperCase()}_ACP`];
1316
+ if (override === "0")
1317
+ return false;
1318
+ if (override === "1" || process.env.BIVY_PREFER_ACP === "1")
1319
+ return true;
1320
+ if (!spec.acp.preferred)
1212
1321
  return false;
1213
- return process.env.BIVY_PREFER_ACP === "1" || process.env[`BIVY_${id.toUpperCase()}_ACP`] === "1";
1322
+ return acpSupportedByBinary(id);
1214
1323
  }
1215
1324
  /**
1216
1325
  * Resolve the communication mode for a CLI agent. This is deliberately pure so
@@ -410,6 +410,21 @@ class ProtocolSession {
410
410
  this.runtimeSessionRef = msg.runtimeSessionRef;
411
411
  return;
412
412
  }
413
+ // Late-arriving model registry. A shim that knows its models up front puts them
414
+ // in `hello`; one whose list is only knowable per session — an ACP agent's
415
+ // models depend on which providers the user has authenticated, and arrive with
416
+ // session/new — publishes them here instead. Same contract as the hello path: a
417
+ // picker backed by a real `model.set` the shim answers, never a claimed one.
418
+ if (type === "runtime.models") {
419
+ const models = parseModels(msg.models);
420
+ if (models.length) {
421
+ this.models = models;
422
+ this.capabilitiesRef.modelSelection = true;
423
+ if (typeof msg.currentModel === "string")
424
+ this.currentModelId = msg.currentModel;
425
+ }
426
+ return;
427
+ }
413
428
  if (type === "message.delta") {
414
429
  const text = String(msg.text ?? "");
415
430
  if (!this.assistantText)
package/dist/secrets.js CHANGED
@@ -183,8 +183,12 @@ export class SecretVault {
183
183
  if (fs.existsSync(this.keyFile))
184
184
  checks.push({ name: "local key file permissions", ok: modeIsPrivate(this.keyFile), detail: this.keyFile });
185
185
  checks.push({ name: "1Password CLI", ok: await commandExists("op"), detail: "required for op:// references" });
186
- checks.push({ name: "GitHub CLI", ok: await commandExists("gh"), detail: "fallback for GitHub tokens when no Bivy secret is configured" });
187
- return { ok: checks.every((c) => c.ok || c.name === "1Password CLI" || c.name === "GitHub CLI"), checks };
186
+ checks.push({ name: "GitHub CLI (optional)", ok: await commandExists("gh"), detail: "optional token shortcut; Bivy connects GitHub itself via `bivy github:connect`" });
187
+ // 1Password and the GitHub CLI are optional shortcuts a missing one must
188
+ // not fail the vault's health (Bivy connects GitHub itself; op is only for
189
+ // op:// refs). Match on a stable prefix so renaming the label can't silently
190
+ // turn either back into a hard failure.
191
+ return { ok: checks.every((c) => c.ok || c.name.startsWith("1Password CLI") || c.name.startsWith("GitHub CLI")), checks };
188
192
  }
189
193
  key() {
190
194
  try {