@bivy/bivy 0.18.4 → 0.18.5-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.
package/bin/bivy.mjs CHANGED
@@ -194,6 +194,9 @@ function commandPath(extraPath = "") {
194
194
  path.join(os.homedir(), ".local", "bin"),
195
195
  extraPath,
196
196
  process.env.PATH || "",
197
+ // npm global executables normally live beside the Node binary (including
198
+ // nvm/fnm installs). Services need this even without shell initialization.
199
+ path.dirname(process.execPath),
197
200
  ].filter(Boolean);
198
201
  return [...new Set(parts.flatMap((part) => String(part).split(path.delimiter)).filter(Boolean))].join(path.delimiter);
199
202
  }
@@ -18,7 +18,7 @@ import { randomUUID } from "node:crypto";
18
18
  import { EventEmitter } from "node:events";
19
19
  import { withExactCapabilitySurface } from "../../runtime/types.js";
20
20
  import { mapToolCall, mapToolResult } from "../../runtime/tool-call-map.js";
21
- import { spawnSync } from "node:child_process";
21
+ import { resolveExecutable } from "../../executable.js";
22
22
  import fs from "node:fs";
23
23
  import { depCacheEnv } from "../../harness/dep-cache.js";
24
24
  import os from "node:os";
@@ -33,23 +33,9 @@ import { bivySessionEnv } from "../../runtime/session-env.js";
33
33
  /** Binary names a live Claude Code process could be running under (see
34
34
  * native-process-scan.ts's best-effort cwd match). */
35
35
  const CLAUDE_BIN_NAMES = ["claude"];
36
- const CLAUDE_CLI_PATH_CACHE = new Map();
37
36
  /** Resolve the operator-installed Claude Code CLI; never use the SDK's bundled fallback. */
38
37
  export function claudeCliPath() {
39
- const command = process.env.BIVY_CLAUDE_COMMAND?.trim() || "claude";
40
- const key = `${command}\0${process.env.PATH ?? ""}`;
41
- if (CLAUDE_CLI_PATH_CACHE.has(key))
42
- return CLAUDE_CLI_PATH_CACHE.get(key);
43
- const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
44
- shell: process.platform !== "win32",
45
- encoding: "utf8",
46
- });
47
- const resolved = result.status === 0 ? result.stdout.split(/\r?\n/)[0]?.trim() || undefined : undefined;
48
- CLAUDE_CLI_PATH_CACHE.set(key, resolved);
49
- return resolved;
50
- }
51
- export function invalidateClaudeCliProbe() {
52
- CLAUDE_CLI_PATH_CACHE.clear();
38
+ return resolveExecutable(process.env.BIVY_CLAUDE_COMMAND?.trim() || "claude") ?? undefined;
53
39
  }
54
40
  export function claudeCliAvailable() {
55
41
  return Boolean(claudeCliPath());
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
- import { spawn, spawnSync } from "node:child_process";
3
+ import { spawn } from "node:child_process";
4
+ import { resolveExecutable } from "../../executable.js";
4
5
  import path from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { defineAgentIntegration } from "../definition.js";
@@ -10,25 +11,11 @@ import { deleteCodexSession, discoverNativeCodexSessions, loadCodexTranscript, w
10
11
  import { ProtocolRuntime } from "../../runtime/protocol.js";
11
12
  import { codexSlashCommands } from "../../runtime/slash-commands.js";
12
13
  export const CODEX_TESTED_VERSION = "0.156.1";
13
- const CODEX_AVAILABLE_CACHE = new Map();
14
14
  function codexCommand() {
15
15
  return process.env.BIVY_CODEX_BIN?.trim() || "codex";
16
16
  }
17
17
  export function codexCommandAvailable() {
18
- const command = codexCommand();
19
- const cached = CODEX_AVAILABLE_CACHE.get(command);
20
- if (cached !== undefined)
21
- return cached;
22
- const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
23
- shell: process.platform !== "win32",
24
- stdio: "ignore",
25
- });
26
- const available = result.status === 0;
27
- CODEX_AVAILABLE_CACHE.set(command, available);
28
- return available;
29
- }
30
- export function invalidateCodexCommandProbe() {
31
- CODEX_AVAILABLE_CACHE.clear();
18
+ return resolveExecutable(codexCommand()) !== null;
32
19
  }
33
20
  async function suggestCodexSessionName(firstPrompt, context) {
34
21
  const prompt = firstPrompt.trim();
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
- import { spawnSync } from "node:child_process";
3
+ import { resolveExecutable } from "../../executable.js";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { defineAgentIntegration } from "../definition.js";
@@ -36,22 +36,8 @@ export async function listNativePiSessions() {
36
36
  firstMessage: session.firstMessage,
37
37
  }));
38
38
  }
39
- const PI_COMMAND_CACHE = new Map();
40
39
  export function piCommandAvailable() {
41
- const command = piCommand();
42
- const cached = PI_COMMAND_CACHE.get(command);
43
- if (cached !== undefined)
44
- return cached;
45
- const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
46
- shell: process.platform !== "win32",
47
- stdio: "ignore",
48
- });
49
- const available = result.status === 0;
50
- PI_COMMAND_CACHE.set(command, available);
51
- return available;
52
- }
53
- export function invalidatePiCommandProbe() {
54
- PI_COMMAND_CACHE.clear();
40
+ return resolveExecutable(piCommand()) !== null;
55
41
  }
56
42
  export function piBridgeInstalled() {
57
43
  try {
@@ -0,0 +1,35 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ /** Resolve without a shell or a lifetime cache: installs, removals and PATH
6
+ * changes must be visible to a running daemon as well as its terminals. */
7
+ export function resolveExecutable(command, env = process.env, cwd = process.cwd()) {
8
+ if (!command.trim())
9
+ return null;
10
+ const win = process.platform === "win32";
11
+ const isExecutable = (file) => {
12
+ try {
13
+ if (!fs.statSync(file).isFile())
14
+ return false;
15
+ fs.accessSync(file, win ? fs.constants.F_OK : fs.constants.X_OK);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ };
22
+ const exts = win ? (env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
23
+ const withExts = (file) => exts.map((ext) => (ext && !file.toLowerCase().endsWith(ext.toLowerCase()) ? file + ext : file));
24
+ const explicit = command.includes("/") || (win && command.includes("\\"));
25
+ const bases = explicit
26
+ ? [path.resolve(cwd, command)]
27
+ : (env.PATH || "").split(path.delimiter).filter(Boolean).map((dir) => path.resolve(cwd, dir, command));
28
+ for (const base of bases) {
29
+ for (const candidate of withExts(base)) {
30
+ if (isExecutable(candidate))
31
+ return candidate;
32
+ }
33
+ }
34
+ return null;
35
+ }
@@ -0,0 +1,22 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ /** Validate keys against the vault before caching them or sharing them with peers.
4
+ * A stale local key must not hide a fresh peer wrap; an unusable wrap must never
5
+ * be persisted as though key recovery succeeded. No credential data is changed.
6
+ */
7
+ export function recoverModelAuthKey(options) {
8
+ if (options.localKey) {
9
+ try {
10
+ return { key: options.localKey, value: options.decrypt(options.localKey) };
11
+ }
12
+ catch { /* Try the peer's replacement before requesting another wrap. */ }
13
+ }
14
+ if (options.unwrap) {
15
+ try {
16
+ const key = options.unwrap();
17
+ return { key, value: options.decrypt(key) };
18
+ }
19
+ catch { /* The caller must reject this exact wrap and request a new one. */ }
20
+ }
21
+ return undefined;
22
+ }
@@ -66,8 +66,9 @@ export class RuntimeHost {
66
66
  const info = this.list(canonical).find((candidate) => candidate.id === canonical);
67
67
  if (!info)
68
68
  throw new Error(`Unknown agent: ${wantId}`);
69
- if (info.status !== "available")
70
- throw new Error(`${info.displayName} is not available on this node yet.`);
69
+ if (info.status !== "available") {
70
+ throw new Error(`${info.displayName} is not available on this node. ${info.notes || "Install this agent on the node before starting a session."}`);
71
+ }
71
72
  return canonical;
72
73
  }
73
74
  get(requested, fallback, sandbox) {
@@ -4,12 +4,13 @@
4
4
  // This is the seam where additional runtimes (Claude Agent SDK, generic RPC,
5
5
  // …) are registered without touching the daemon.
6
6
  import { spawnSync } from "node:child_process";
7
+ import { resolveExecutable as resolveCommandPath } from "../executable.js";
7
8
  import path from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
- import { ClaudeCodeRuntime, claudeRuntimeFromEnv, claudeSdkInstalled, invalidateClaudeCliProbe } from "../agents/claude-code/runtime.js";
10
+ import { ClaudeCodeRuntime, claudeRuntimeFromEnv, claudeSdkInstalled } from "../agents/claude-code/runtime.js";
10
11
  import { claudeCodeIntegration } from "../agents/claude-code/integration.js";
11
- import { codexAppServerRuntime, codexIntegration, invalidateCodexCommandProbe, } from "../agents/codex/integration.js";
12
- import { invalidatePiCommandProbe, piAgentDir, piCommandAvailable, piIntegration, LazyPiRuntime } from "../agents/pi/integration.js";
12
+ import { codexAppServerRuntime, codexIntegration, } from "../agents/codex/integration.js";
13
+ import { piAgentDir, piCommandAvailable, piIntegration, LazyPiRuntime } from "../agents/pi/integration.js";
13
14
  import { deleteCodexSession, loadCodexTranscript } from "./codex-sessions.js";
14
15
  import { deleteOpenCodeSession, exportOpenCodeSession, importOpenCodeSession, loadOpenCodeTranscript, writeOpenCodeHistory } from "./opencode-sessions.js";
15
16
  import { discoverNativeGrokSessions, listGrokSessions, loadGrokTranscript } from "./grok-sessions.js";
@@ -70,27 +71,9 @@ function cliSlashCommands(spec) {
70
71
  }
71
72
  export * from "./types.js";
72
73
  export { NodeCredentialResolver, createCredentialStore } from "./credentials.js";
73
- // Memoized CLI probes. `commandAvailable`/`resolveCommandPath`/`probeHelpText`
74
- // each shell out with a BLOCKING spawnSync, and the runtime catalog that calls
75
- // them (cliAgentInfo → prefersAcp/acpSupportedByBinary) is rebuilt often — on
76
- // every advertise and every runtimes.list send. Re-probing per build stalled the
77
- // event loop for seconds at a time (a synchronous spawn storm). A CLI's presence
78
- // is effectively constant for the daemon's run, so cache per command for the
79
- // process lifetime and clear on install (invalidateCliProbeCache).
80
- const COMMAND_AVAILABLE_CACHE = new Map();
74
+ // Filesystem lookup stays fresh without spawning a shell on every catalog build.
81
75
  function commandAvailable(command) {
82
- if (!command.trim())
83
- return false;
84
- const cached = COMMAND_AVAILABLE_CACHE.get(command);
85
- if (cached !== undefined)
86
- return cached;
87
- const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
88
- shell: process.platform !== "win32",
89
- stdio: "ignore",
90
- });
91
- const available = result.status === 0;
92
- COMMAND_AVAILABLE_CACHE.set(command, available);
93
- return available;
76
+ return resolveCommandPath(command) !== null;
94
77
  }
95
78
  function genericCliInfo() {
96
79
  const options = processRuntimeFromEnv();
@@ -421,29 +404,6 @@ function cliThinkingConfig(id, spec) {
421
404
  // installed binary doesn't actually mention. It never UPGRADES — adding a
422
405
  // capability needs the exact arg template, which help text can't safely supply — so
423
406
  // probing can only make the catalog MORE honest, never invent a no-op control.
424
- /**
425
- * Absolute path of a command on the current PATH, or null when it isn't there.
426
- * Used to key the help-probe cache: caching by the bare NAME would keep serving a
427
- * stale answer after the binary behind that name changed (a CLI upgraded or
428
- * installed while the daemon is running, or a different PATH entry winning).
429
- * Memoized per command (see COMMAND_AVAILABLE_CACHE) — it spawnSyncs, and is hit
430
- * on every catalog build.
431
- */
432
- const COMMAND_PATH_CACHE = new Map();
433
- function resolveCommandPath(command) {
434
- if (!command.trim())
435
- return null;
436
- const cached = COMMAND_PATH_CACHE.get(command);
437
- if (cached !== undefined)
438
- return cached;
439
- const res = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
440
- shell: process.platform !== "win32",
441
- encoding: "utf8",
442
- });
443
- const resolved = res.status !== 0 ? null : ((res.stdout ?? "").split(/\r?\n/)[0]?.trim() || null);
444
- COMMAND_PATH_CACHE.set(command, resolved);
445
- return resolved;
446
- }
447
407
  const HELP_PROBE_CACHE = new Map();
448
408
  function probeHelpText(command) {
449
409
  const key = resolveCommandPath(command) ?? command;
@@ -461,21 +421,10 @@ function probeHelpText(command) {
461
421
  HELP_PROBE_CACHE.set(key, text);
462
422
  return text;
463
423
  }
464
- /**
465
- * Drop every memoized CLI probe (availability, resolved path, --help text). These
466
- * probes shell out with a blocking spawnSync and are cached for the process
467
- * lifetime to keep the frequently-rebuilt runtime catalog off the event loop, so a
468
- * CLI installed/updated mid-run wouldn't otherwise be noticed until a restart.
469
- * Call this right after Bivy installs a runtime so the next catalog build re-probes
470
- * and reflects the new binary immediately.
471
- */
424
+ /** Refresh expensive help probes after Bivy installs or updates an agent.
425
+ * Executable availability itself is never cached. */
472
426
  export function invalidateCliProbeCache() {
473
- COMMAND_AVAILABLE_CACHE.clear();
474
- COMMAND_PATH_CACHE.clear();
475
427
  HELP_PROBE_CACHE.clear();
476
- invalidatePiCommandProbe();
477
- invalidateClaudeCliProbe();
478
- invalidateCodexCommandProbe();
479
428
  }
480
429
  // A resume template mixes launch flags (`-p`, `--force`) with the resume-specific
481
430
  // token(s) (`--resume`, `threads continue`, `-s`, `--restore`, …). Only the latter
package/dist/server.js CHANGED
@@ -149,6 +149,7 @@ import { redactSecrets } from "./redact.js";
149
149
  import { getSttConfig, isSttProvider, removeSttKey, setSttKey, setSttProvider, transcribeAudio, MAX_AUDIO_BYTES, } from "./stt.js";
150
150
  import { synthesizeOpenAiSpeech } from "./tts.js";
151
151
  import { seal, open } from "./e2e.js";
152
+ import { recoverModelAuthKey } from "./model-auth-key-recovery.js";
152
153
  import { RunDelegationService, parseDelegationSource } from "./run-tools.js";
153
154
  import { ControlPlaneTaskPoller, resolveControlPlaneTaskConfig, } from "./control-plane-tasks.js";
154
155
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -239,7 +240,7 @@ process.chdir(appDir);
239
240
  // dirs here makes commands work from agent shells without requiring a user shell
240
241
  // profile.
241
242
  const userLocalPrefix = process.env.BIVY_NPM_GLOBAL_PREFIX || path.join(process.env.HOME ?? os.homedir(), ".local");
242
- process.env.PATH = [path.join(repoRoot, "bin"), path.join(userLocalPrefix, "bin"), process.env.PATH || ""].filter(Boolean).join(path.delimiter);
243
+ process.env.PATH = [path.join(repoRoot, "bin"), path.join(userLocalPrefix, "bin"), process.env.PATH || "", path.dirname(process.execPath)].filter(Boolean).join(path.delimiter);
243
244
  const piDir = path.join(appDir, "pi");
244
245
  // The node's shared, agent-neutral credential vault (auth.enc/auth.key). NOT
245
246
  // inside any one agent's dir — every runtime (Pi, Codex, Claude, …) reads the
@@ -3365,20 +3366,31 @@ async function syncModelAuthFromControlPlane() {
3365
3366
  }
3366
3367
  const targetVault = hostedCustody ? hostedData.hostedVault : data.vault;
3367
3368
  let vaultKeyB64 = hostedCustody ? readHostedModelAuthVaultKey() : readLocalModelAuthVaultKey();
3368
- if (!hostedCustody && !vaultKeyB64 && data.wrappedKey?.wrappedKey) {
3369
- vaultKeyB64 = pairingStore.unwrapFromNodePublicKey(data.wrappedKey.wrappedByPublicKey, data.wrappedKey.wrappedKey);
3370
- writeLocalModelAuthVaultKey(vaultKeyB64);
3371
- }
3372
3369
  // Hosted runners may decrypt ONLY the separately encrypted snapshot of
3373
3370
  // records that the user explicitly granted to unattended execution.
3374
3371
  if (hostedCustody && !vaultKeyB64 && hostedData.hostedKey && hostedData.hostedVault && Buffer.from(hostedData.hostedKey, "base64").length === 32) {
3375
3372
  vaultKeyB64 = hostedData.hostedKey;
3376
3373
  writeHostedModelAuthVaultKey(vaultKeyB64);
3377
3374
  }
3378
- if (targetVault?.ciphertext && vaultKeyB64) {
3375
+ if (targetVault?.ciphertext && (vaultKeyB64 || (!hostedCustody && data.wrappedKey))) {
3379
3376
  let decrypted;
3380
3377
  try {
3381
- decrypted = decryptModelAuthEnvelope(targetVault.ciphertext, vaultKeyB64);
3378
+ if (hostedCustody) {
3379
+ decrypted = decryptModelAuthEnvelope(targetVault.ciphertext, vaultKeyB64);
3380
+ }
3381
+ else {
3382
+ const wrapped = data.wrappedKey;
3383
+ const recovered = recoverModelAuthKey({
3384
+ localKey: vaultKeyB64,
3385
+ unwrap: wrapped ? () => pairingStore.unwrapFromNodePublicKey(wrapped.wrappedByPublicKey, wrapped.wrappedKey) : undefined,
3386
+ decrypt: (key) => decryptModelAuthEnvelope(targetVault.ciphertext, key),
3387
+ });
3388
+ if (!recovered)
3389
+ throw new Error("No available key can decrypt the account vault");
3390
+ vaultKeyB64 = recovered.key;
3391
+ decrypted = recovered.value;
3392
+ writeLocalModelAuthVaultKey(vaultKeyB64);
3393
+ }
3382
3394
  }
3383
3395
  catch (error) {
3384
3396
  // Most commonly this node cached the previous generation while another
@@ -3394,7 +3406,10 @@ async function syncModelAuthFromControlPlane() {
3394
3406
  else {
3395
3407
  forgetLocalModelAuthVaultKey();
3396
3408
  lastPushedModelAuthCiphertext = "";
3397
- await modelAuthFetch("/node/model-auth-key/request", { method: "POST", body: JSON.stringify({ publicKey: pairingStore.nodePublicKeyB64() }) });
3409
+ await modelAuthFetch("/node/model-auth-key/request", {
3410
+ method: "POST",
3411
+ body: JSON.stringify({ publicKey: pairingStore.nodePublicKeyB64(), rejectedWrappedKey: data.wrappedKey?.wrappedKey }),
3412
+ });
3398
3413
  ensureModelAuthColdStart();
3399
3414
  }
3400
3415
  console.warn("[auth-sync] cached vault key is stale; requested the rotated key:", error.message);
package/dist/terminal.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
3
  import { randomUUID } from "node:crypto";
4
4
  import fs from "node:fs";
5
+ import { resolveExecutable } from "./executable.js";
5
6
  import { createRequire } from "node:module";
6
7
  import path from "node:path";
7
8
  import { depCacheEnv } from "./harness/dep-cache.js";
@@ -70,41 +71,7 @@ function defaultShell() {
70
71
  * Resolving up front lets open() throw a clear, catchable error — and hands the
71
72
  * PTY an unambiguous absolute path.
72
73
  */
73
- export function resolveExecutable(command, env, cwd) {
74
- const win = process.platform === "win32";
75
- const isExecutable = (file) => {
76
- try {
77
- if (!fs.statSync(file).isFile())
78
- return false;
79
- if (win)
80
- return true;
81
- fs.accessSync(file, fs.constants.X_OK);
82
- return true;
83
- }
84
- catch {
85
- return false;
86
- }
87
- };
88
- // On Windows a bare name may need a PATHEXT suffix (claude → claude.cmd).
89
- const exts = win ? (env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
90
- const withExts = (file) => exts.map((ext) => (ext && !file.toLowerCase().endsWith(ext.toLowerCase()) ? file + ext : file));
91
- // A command with an explicit path is resolved directly (against the workspace
92
- // for a relative one), never via PATH — same as exec.
93
- if (command.includes("/") || (win && command.includes("\\"))) {
94
- for (const candidate of withExts(path.resolve(cwd, command))) {
95
- if (isExecutable(candidate))
96
- return candidate;
97
- }
98
- return null;
99
- }
100
- for (const dir of (env.PATH || "").split(path.delimiter).filter(Boolean)) {
101
- for (const candidate of withExts(path.join(dir, command))) {
102
- if (isExecutable(candidate))
103
- return candidate;
104
- }
105
- }
106
- return null;
107
- }
74
+ export { resolveExecutable } from "./executable.js";
108
75
  /**
109
76
  * Restore the execute bit on node-pty's bundled `spawn-helper` binary.
110
77
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.18.4",
3
+ "version": "0.18.5-staging.2",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",