@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.
- package/LICENSE +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- package/public/qr.js +464 -0
package/bin/bivy.mjs
ADDED
|
@@ -0,0 +1,4100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
3
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
4
|
+
/**
|
|
5
|
+
* bivy — one-command CLI for a Bivy node.
|
|
6
|
+
*
|
|
7
|
+
* Collapses the old multi-step node setup (clone, npm install, model login,
|
|
8
|
+
* relay-setup with long flags, npm run dev, hand-written launchd/systemd files)
|
|
9
|
+
* into a single guided flow:
|
|
10
|
+
*
|
|
11
|
+
* bivy setup first-run wizard: deps, remote sync + sign-in, background service
|
|
12
|
+
* bivy start run the daemon in the foreground
|
|
13
|
+
* bivy stop stop the background service
|
|
14
|
+
* bivy restart restart the background service (waits for active sessions to finish; --force to skip)
|
|
15
|
+
* bivy status show config + whether the node is reachable
|
|
16
|
+
* bivy login sign into a model provider (native Pi /login)
|
|
17
|
+
* bivy update update Bivy + install deps + restart service (waits for active sessions to finish; --force to skip)
|
|
18
|
+
* bivy update:log show output of the last (or in-progress) update
|
|
19
|
+
* bivy open open the browser UI
|
|
20
|
+
* bivy relay:setup enable secure remote web/PWA access (one-click sign-in)
|
|
21
|
+
* bivy service install|uninstall|status manage the background service
|
|
22
|
+
*
|
|
23
|
+
* No external dependencies: Node built-ins only. The daemon itself runs via the
|
|
24
|
+
* bundled tsx, so there is no build step.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import readline from "node:readline";
|
|
31
|
+
import { StringDecoder } from "node:string_decoder";
|
|
32
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
33
|
+
import { randomBytes, createCipheriv, createDecipheriv, randomUUID } from "node:crypto";
|
|
34
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
35
|
+
import vm from "node:vm";
|
|
36
|
+
import { selectStaleSessions, sessionActivityMs } from "./prune-sessions.mjs";
|
|
37
|
+
import { resolveSessionsLimit, truncateSavedSessions } from "./sessions-list.mjs";
|
|
38
|
+
import { renderManagedBlock, upsertManagedBlock, removeManagedBlock, rcFileForShell } from "./shim-path.mjs";
|
|
39
|
+
import { removeExcept } from "./uninstall-paths.mjs";
|
|
40
|
+
|
|
41
|
+
const selfScript = fileURLToPath(import.meta.url);
|
|
42
|
+
const __dirname = path.dirname(selfScript);
|
|
43
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
44
|
+
// Mutable node state (config, relay E2E keys, sessions, logs) must live in a
|
|
45
|
+
// stable, user-owned directory that survives reinstalls. Pick it by install type:
|
|
46
|
+
// - BIVY_DATA_DIR set -> honor it (explicit override)
|
|
47
|
+
// - git dev checkout, or an existing
|
|
48
|
+
// <repoRoot>/.bivy (an install.sh tree lives
|
|
49
|
+
// in a user-owned dir and preserves .bivy
|
|
50
|
+
// across updates) -> keep state co-located
|
|
51
|
+
// - otherwise (npm i -g / npx: the package dir
|
|
52
|
+
// may be root-owned and is replaced on
|
|
53
|
+
// update, wiping state) -> ~/.bivy
|
|
54
|
+
function resolveAppDir() {
|
|
55
|
+
if (process.env.BIVY_DATA_DIR) return path.resolve(process.env.BIVY_DATA_DIR);
|
|
56
|
+
const local = path.join(repoRoot, ".bivy");
|
|
57
|
+
if (fs.existsSync(local) || fs.existsSync(path.join(repoRoot, ".git"))) return local;
|
|
58
|
+
return path.join(os.homedir(), ".bivy");
|
|
59
|
+
}
|
|
60
|
+
const appDir = resolveAppDir();
|
|
61
|
+
// Propagate the resolved data dir to every child process (daemon, native-pi,
|
|
62
|
+
// exec, …) via the environment so none of them independently fall back to
|
|
63
|
+
// <repoRoot>/.bivy. The daemon reads BIVY_DATA_DIR (see src/server.ts).
|
|
64
|
+
process.env.BIVY_DATA_DIR = appDir;
|
|
65
|
+
|
|
66
|
+
// How was this CLI installed? Governs update strategy and whether a persistent
|
|
67
|
+
// background service can point at repoRoot:
|
|
68
|
+
// - "git" dev checkout (has .git)
|
|
69
|
+
// - "npx" ephemeral `npx bivy` run (repoRoot under an npm _npx cache)
|
|
70
|
+
// - "npm-global" `npm i -g @bivy/bivy` (repoRoot's parent dir is node_modules)
|
|
71
|
+
// - "packaged" install.sh tarball tree (user-owned, self-preserving)
|
|
72
|
+
function detectInstallKind() {
|
|
73
|
+
if (fs.existsSync(path.join(repoRoot, ".git"))) return "git";
|
|
74
|
+
const inNodeModules = path.basename(path.dirname(repoRoot)) === "node_modules";
|
|
75
|
+
if (inNodeModules && /[\\/]_npx[\\/]/.test(repoRoot)) return "npx";
|
|
76
|
+
if (inNodeModules) return "npm-global";
|
|
77
|
+
return "packaged";
|
|
78
|
+
}
|
|
79
|
+
const cliConfigPath = path.join(appDir, "cli.json");
|
|
80
|
+
const relayConfigPath = path.join(appDir, "relay.json");
|
|
81
|
+
// Short-lived handoff: relay:setup writes the account session it just obtained
|
|
82
|
+
// here (0600) so `bivy setup` can open the remote app signed into the *account*
|
|
83
|
+
// (all nodes), not a node-scoped link grant that would show only this node.
|
|
84
|
+
// Read once and deleted immediately — never a credential left at rest.
|
|
85
|
+
const setupSessionPath = path.join(appDir, ".setup-session.json");
|
|
86
|
+
const updateLogPath = path.join(appDir, "update.log");
|
|
87
|
+
const packaged = fs.existsSync(path.join(repoRoot, "dist", "server.js"));
|
|
88
|
+
const serverEntry = path.join(repoRoot, packaged ? "dist/server.js" : "src/server.ts");
|
|
89
|
+
const nativePiEntry = path.join(repoRoot, packaged ? "dist/native-pi.js" : "src/native-pi.ts");
|
|
90
|
+
const bivyLoginEntry = path.join(repoRoot, packaged ? "dist/bivy-login.js" : "src/bivy-login.ts");
|
|
91
|
+
const relaySetupEntry = path.join(repoRoot, packaged ? "dist/relay-setup.js" : "src/relay-setup.ts");
|
|
92
|
+
// Dependency-free hosted-endpoint helper. Shipped to dist/ in the release
|
|
93
|
+
// artifact (src/ is not packaged), so resolve it the same packaged-aware way as
|
|
94
|
+
// the runtime entries and import it dynamically at call time.
|
|
95
|
+
const hostedEndpointsEntry = path.join(repoRoot, packaged ? "dist/hosted-endpoints.mjs" : "src/hosted-endpoints.mjs");
|
|
96
|
+
const githubConnectEntry = path.join(repoRoot, packaged ? "dist/github-connect-repo.js" : "src/github-connect-repo.ts");
|
|
97
|
+
const githubAppConnectEntry = path.join(repoRoot, packaged ? "dist/github-app-connect.js" : "src/github-app-connect.ts");
|
|
98
|
+
const githubAppSyncEntry = path.join(repoRoot, packaged ? "dist/github-app-sync-cli.js" : "src/github-app-sync-cli.ts");
|
|
99
|
+
const secretsEntry = path.join(repoRoot, packaged ? "dist/secrets-cli.js" : "src/secrets-cli.ts");
|
|
100
|
+
const sttEntry = path.join(repoRoot, packaged ? "dist/stt-cli.js" : "src/stt-cli.ts");
|
|
101
|
+
const attachEntry = path.join(repoRoot, packaged ? "dist/attach.js" : "src/attach.ts");
|
|
102
|
+
const relayAttachEntry = path.join(repoRoot, packaged ? "dist/relay-attach.js" : "src/relay-attach.ts");
|
|
103
|
+
const execEntry = path.join(repoRoot, packaged ? "dist/exec.js" : "src/exec.ts");
|
|
104
|
+
const mcpProxyEntry = path.join(repoRoot, packaged ? "dist/harness/mcp-proxy-cli.js" : "src/harness/mcp-proxy-cli.ts");
|
|
105
|
+
const qrEntry = path.join(repoRoot, "public", "qr.js");
|
|
106
|
+
const tsxCli = packaged ? "" : path.join(repoRoot, "node_modules", "tsx", "dist", "cli.mjs");
|
|
107
|
+
const nodeBin = process.execPath;
|
|
108
|
+
const nodeScriptArgs = (entry) => (tsxCli ? [tsxCli, entry] : [entry]);
|
|
109
|
+
|
|
110
|
+
// Resolve the baked-in hosted endpoints (app/relay/client URLs). Imported lazily
|
|
111
|
+
// so the packaged-aware path above is only touched when setup actually needs it.
|
|
112
|
+
let _hostedEndpoints;
|
|
113
|
+
async function getHostedEndpoints() {
|
|
114
|
+
if (!_hostedEndpoints) {
|
|
115
|
+
({ hostedEndpoints: _hostedEndpoints } = await import(pathToFileURL(hostedEndpointsEntry).href));
|
|
116
|
+
}
|
|
117
|
+
return _hostedEndpoints();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const SERVICE_LABEL = "dev.bivy";
|
|
121
|
+
const SERVICE_UNIT = "bivy.service";
|
|
122
|
+
|
|
123
|
+
function commandPath(extraPath = "") {
|
|
124
|
+
const parts = [
|
|
125
|
+
path.join(repoRoot, "bin"),
|
|
126
|
+
path.join(os.homedir(), ".local", "bin"),
|
|
127
|
+
extraPath,
|
|
128
|
+
process.env.PATH || "",
|
|
129
|
+
].filter(Boolean);
|
|
130
|
+
return [...new Set(parts.flatMap((part) => String(part).split(path.delimiter)).filter(Boolean))].join(path.delimiter);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
process.env.PATH = commandPath();
|
|
134
|
+
|
|
135
|
+
// Keep redirected output and NO_COLOR consumers clean. FORCE_COLOR remains an
|
|
136
|
+
// explicit opt-in for demos/snapshots; otherwise ANSI belongs only on a TTY.
|
|
137
|
+
const colorEnabled = !Object.hasOwn(process.env, "NO_COLOR")
|
|
138
|
+
&& process.env.TERM !== "dumb"
|
|
139
|
+
&& (Boolean(process.stdout.isTTY) || Boolean(process.env.FORCE_COLOR));
|
|
140
|
+
const color = (open, close) => (s) => colorEnabled ? `\u001b[${open}m${s}\u001b[${close}m` : String(s);
|
|
141
|
+
const c = {
|
|
142
|
+
bold: color(1, 22),
|
|
143
|
+
dim: color(2, 22),
|
|
144
|
+
green: color(32, 39),
|
|
145
|
+
yellow: color(33, 39),
|
|
146
|
+
red: color(31, 39),
|
|
147
|
+
cyan: color(36, 39),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// --- config -----------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
function loadConfig() {
|
|
153
|
+
try {
|
|
154
|
+
const raw = JSON.parse(fs.readFileSync(cliConfigPath, "utf8"));
|
|
155
|
+
return {
|
|
156
|
+
workspace: typeof raw.workspace === "string" ? raw.workspace : repoRoot,
|
|
157
|
+
port: Number(raw.port) || 4317,
|
|
158
|
+
env: raw.env && typeof raw.env === "object" ? raw.env : {},
|
|
159
|
+
service: raw.service === true,
|
|
160
|
+
};
|
|
161
|
+
} catch {
|
|
162
|
+
return { workspace: repoRoot, port: 4317, env: {}, service: false };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function saveConfig(config) {
|
|
167
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
168
|
+
fs.writeFileSync(cliConfigPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
169
|
+
try {
|
|
170
|
+
fs.chmodSync(cliConfigPath, 0o600);
|
|
171
|
+
} catch {
|
|
172
|
+
// best effort
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The daemon's agent-neutral settings file (<dataDir>/settings.json), written by
|
|
177
|
+
// src/. We only ever read it here — never author it — so a missing/garbage file
|
|
178
|
+
// is just "no settings".
|
|
179
|
+
function loadSettings() {
|
|
180
|
+
try {
|
|
181
|
+
const raw = JSON.parse(fs.readFileSync(path.join(appDir, "settings.json"), "utf8"));
|
|
182
|
+
return raw && typeof raw === "object" ? raw : {};
|
|
183
|
+
} catch {
|
|
184
|
+
return {};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The default terminal agent when `bivy` / `bivy run` is invoked without an
|
|
189
|
+
// explicit agent. Pi is no longer privileged: it's only the last-resort fallback.
|
|
190
|
+
// Resolution mirrors the daemon: BIVY_RUNTIME env (process env, then the value
|
|
191
|
+
// persisted in cli.json) -> settings.defaultAgent -> "pi".
|
|
192
|
+
function resolveDefaultAgent() {
|
|
193
|
+
const envRuntime = process.env.BIVY_RUNTIME || loadConfig().env?.BIVY_RUNTIME;
|
|
194
|
+
if (envRuntime && String(envRuntime).trim()) return String(envRuntime).trim().toLowerCase();
|
|
195
|
+
const fromSettings = loadSettings().defaultAgent;
|
|
196
|
+
if (fromSettings && String(fromSettings).trim()) return String(fromSettings).trim().toLowerCase();
|
|
197
|
+
return "pi";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resolveSecretSync(value) {
|
|
201
|
+
const raw = String(value || "").trim();
|
|
202
|
+
if (!raw.startsWith("secret://") && !raw.startsWith("env://") && !raw.startsWith("op://")) return value;
|
|
203
|
+
if (raw.startsWith("env://")) return process.env[raw.slice("env://".length)] || "";
|
|
204
|
+
if (raw.startsWith("op://")) {
|
|
205
|
+
const res = spawnSync("op", ["read", raw], { encoding: "utf8", env: process.env });
|
|
206
|
+
if (res.status !== 0) throw new Error(`Could not resolve 1Password secret ${raw}. Run 'op signin' and check the reference.`);
|
|
207
|
+
return res.stdout.trim();
|
|
208
|
+
}
|
|
209
|
+
const id = raw.slice("secret://".length);
|
|
210
|
+
const secretsFile = path.join(appDir, "secrets.json");
|
|
211
|
+
const keyFile = path.join(appDir, "secrets.key");
|
|
212
|
+
const data = JSON.parse(fs.readFileSync(secretsFile, "utf8"));
|
|
213
|
+
const record = data.records?.[id];
|
|
214
|
+
if (!record) throw new Error(`Secret ${id} is not configured. Run 'bivy secrets list'.`);
|
|
215
|
+
if (record.backend === "env") return resolveSecretSync(record.ref || "");
|
|
216
|
+
if (record.backend === "1password") return resolveSecretSync(record.ref || "");
|
|
217
|
+
if (record.backend !== "local" || !record.iv || !record.tag || !record.ciphertext) throw new Error(`Secret ${id} has an unsupported backend.`);
|
|
218
|
+
const key = Buffer.from(fs.readFileSync(keyFile, "utf8").trim(), "base64");
|
|
219
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(record.iv, "base64"));
|
|
220
|
+
decipher.setAuthTag(Buffer.from(record.tag, "base64"));
|
|
221
|
+
return Buffer.concat([decipher.update(Buffer.from(record.ciphertext, "base64")), decipher.final()]).toString("utf8");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function resolveEnvSecrets(env) {
|
|
225
|
+
const out = {};
|
|
226
|
+
for (const [key, value] of Object.entries(env || {})) out[key] = resolveSecretSync(value);
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// True when a config value is a secret *reference* (resolved at runtime) rather
|
|
231
|
+
// than a raw inline secret we'd be storing in plaintext.
|
|
232
|
+
function isSecretRef(value) {
|
|
233
|
+
const raw = String(value || "").trim();
|
|
234
|
+
return raw.startsWith("secret://") || raw.startsWith("env://") || raw.startsWith("op://");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Store a plaintext secret in the encrypted local vault (`secrets.json` +
|
|
238
|
+
// `secrets.key`) and return its `secret://<id>` reference. Mirrors
|
|
239
|
+
// SecretVault.setLocal (AES-256-GCM) exactly — the same format resolveSecretSync
|
|
240
|
+
// above already decrypts — so the node, TUI and this CLI all resolve it the same
|
|
241
|
+
// way. Kept in the CLI (rather than shelling to the TS vault) so first-run setup
|
|
242
|
+
// never has to pass a token on argv.
|
|
243
|
+
function storeLocalSecretSync(id, plaintext, description) {
|
|
244
|
+
const value = String(plaintext || "");
|
|
245
|
+
if (!value) throw new Error("Secret value cannot be empty.");
|
|
246
|
+
const secretsFile = path.join(appDir, "secrets.json");
|
|
247
|
+
const keyFile = path.join(appDir, "secrets.key");
|
|
248
|
+
fs.mkdirSync(appDir, { recursive: true, mode: 0o700 });
|
|
249
|
+
|
|
250
|
+
// Load the 32-byte key, minting one only when the file genuinely doesn't exist.
|
|
251
|
+
let key;
|
|
252
|
+
try {
|
|
253
|
+
key = Buffer.from(fs.readFileSync(keyFile, "utf8").trim(), "base64");
|
|
254
|
+
if (key.length !== 32) throw new Error(`Local secrets key at ${keyFile} is invalid (expected 32 bytes)`);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (error?.code && error.code !== "ENOENT") throw error;
|
|
257
|
+
if (!error?.code && !/expected 32 bytes/.test(error?.message || "")) throw error;
|
|
258
|
+
key = randomBytes(32);
|
|
259
|
+
fs.writeFileSync(keyFile, `${key.toString("base64")}\n`, { mode: 0o600 });
|
|
260
|
+
try { fs.chmodSync(keyFile, 0o600); } catch { /* best effort */ }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const iv = randomBytes(12);
|
|
264
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
265
|
+
const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
266
|
+
const tag = cipher.getAuthTag();
|
|
267
|
+
|
|
268
|
+
let data = { version: 1, records: {} };
|
|
269
|
+
try {
|
|
270
|
+
const parsed = JSON.parse(fs.readFileSync(secretsFile, "utf8"));
|
|
271
|
+
if (parsed?.records && typeof parsed.records === "object") data = { version: 1, records: parsed.records };
|
|
272
|
+
} catch { /* fresh vault */ }
|
|
273
|
+
const at = new Date().toISOString();
|
|
274
|
+
const prev = data.records[id];
|
|
275
|
+
data.records[id] = {
|
|
276
|
+
id,
|
|
277
|
+
backend: "local",
|
|
278
|
+
description: description ?? prev?.description,
|
|
279
|
+
createdAt: prev?.createdAt ?? at,
|
|
280
|
+
updatedAt: at,
|
|
281
|
+
iv: iv.toString("base64"),
|
|
282
|
+
tag: tag.toString("base64"),
|
|
283
|
+
ciphertext: ciphertext.toString("base64"),
|
|
284
|
+
};
|
|
285
|
+
fs.writeFileSync(secretsFile, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
286
|
+
try { fs.chmodSync(secretsFile, 0o600); } catch { /* best effort */ }
|
|
287
|
+
return `secret://${id}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Rewrite the installed service unit/plist in place (no enable/start) so a
|
|
291
|
+
// migrated `config.env` — e.g. BIVY_GITHUB_TOKEN now a `secret://` ref instead of
|
|
292
|
+
// a raw token — is reflected on disk immediately. Best-effort; the running
|
|
293
|
+
// service keeps its current env until its next restart.
|
|
294
|
+
function writeServiceUnitFileQuietly(config) {
|
|
295
|
+
const { kind, file } = servicePaths();
|
|
296
|
+
if (kind === "unsupported" || !fs.existsSync(file)) return;
|
|
297
|
+
try {
|
|
298
|
+
fs.writeFileSync(file, kind === "launchd" ? plistContent(config) : systemdContent(config));
|
|
299
|
+
if (kind === "systemd") runQuiet("systemctl", ["--user", "daemon-reload"], { env: systemdUserEnv() });
|
|
300
|
+
} catch { /* best effort */ }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// One-shot migration: if BIVY_GITHUB_TOKEN is a raw inline token (older installs
|
|
304
|
+
// stored it in plaintext in cli.json — and therefore in the systemd unit too),
|
|
305
|
+
// move it into the encrypted vault and replace it with a `secret://` reference.
|
|
306
|
+
// The server resolves the ref via resolveGitHubToken, so nothing else changes.
|
|
307
|
+
// Verifies the round-trip before dropping the plaintext, so a vault-write failure
|
|
308
|
+
// never loses GitHub access. Returns true when a migration happened.
|
|
309
|
+
function migrateGithubTokenToVault(config) {
|
|
310
|
+
const raw = String(config?.env?.BIVY_GITHUB_TOKEN || "").trim();
|
|
311
|
+
if (!raw || isSecretRef(raw)) return false;
|
|
312
|
+
try {
|
|
313
|
+
const ref = storeLocalSecretSync("github.repo-token", raw, "GitHub repo/work-queue token");
|
|
314
|
+
if (resolveSecretSync(ref) !== raw) throw new Error("vault round-trip mismatch");
|
|
315
|
+
config.env = { ...config.env, BIVY_GITHUB_TOKEN: ref };
|
|
316
|
+
saveConfig(config);
|
|
317
|
+
writeServiceUnitFileQuietly(config);
|
|
318
|
+
return true;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (process.env.BIVY_DEBUG) console.error(c.dim(`github token migration skipped: ${error?.message || String(error)}`));
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function startEnv(config) {
|
|
326
|
+
return {
|
|
327
|
+
...process.env,
|
|
328
|
+
PORT: String(config.port),
|
|
329
|
+
BIVY_WORKSPACE: config.workspace,
|
|
330
|
+
...resolveEnvSecrets(config.env),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function hasModelConfig(config) {
|
|
335
|
+
return Boolean(
|
|
336
|
+
config.env.ANTHROPIC_API_KEY ||
|
|
337
|
+
config.env.OPENAI_API_KEY ||
|
|
338
|
+
config.env.OPENROUTER_API_KEY ||
|
|
339
|
+
// The shared, agent-neutral credential vault (moved from <dataDir>/pi to
|
|
340
|
+
// <dataDir>/credentials). Its presence means a model credential was ingested.
|
|
341
|
+
fs.existsSync(path.join(appDir, "credentials", "auth.enc")),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const SETUP_AGENT_CHOICES = [
|
|
346
|
+
{ key: "p", label: "Pi (default, sign in to ChatGPT/Claude/Copilot or paste a model key)", runtimeId: "pi", needsBivyModel: true },
|
|
347
|
+
{ key: "c", label: "Claude Code", runtimeId: "claude-code-sdk", needsBivyModel: false, loginHint: "If Claude asks you to sign in, run: claude" },
|
|
348
|
+
{ key: "x", label: "Codex", runtimeId: "codex", needsBivyModel: false, loginHint: "If Codex asks you to sign in, run: codex" },
|
|
349
|
+
{ key: "o", label: "OpenCode", runtimeId: "opencode", needsBivyModel: false },
|
|
350
|
+
{ key: "g", label: "Gemini CLI", runtimeId: "gemini", needsBivyModel: false, loginHint: "If Gemini asks you to sign in, run: gemini" },
|
|
351
|
+
{ key: "q", label: "Qwen Code", runtimeId: "qwen", needsBivyModel: false, loginHint: "If Qwen asks you to sign in, run: qwen" },
|
|
352
|
+
{ key: "a", label: "Aider", runtimeId: "aider", needsBivyModel: true },
|
|
353
|
+
{ key: "l", label: "Cline", runtimeId: "cline", needsBivyModel: false },
|
|
354
|
+
{ key: "r", label: "Crush", runtimeId: "crush", needsBivyModel: false },
|
|
355
|
+
];
|
|
356
|
+
|
|
357
|
+
function setupAgentByRuntime(runtimeId) {
|
|
358
|
+
return SETUP_AGENT_CHOICES.find((choice) => choice.runtimeId === runtimeId);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function url(config) {
|
|
362
|
+
return `http://localhost:${config.port}`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// --- process helpers --------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
function run(cmd, args, opts = {}) {
|
|
368
|
+
return new Promise((resolve) => {
|
|
369
|
+
const child = spawn(cmd, args, { stdio: "inherit", ...opts });
|
|
370
|
+
child.on("exit", (code) => resolve(code ?? 0));
|
|
371
|
+
child.on("error", (error) => {
|
|
372
|
+
console.error(c.red(`Failed to run ${cmd}: ${error.message}`));
|
|
373
|
+
resolve(1);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function runQuiet(cmd, args, opts = {}) {
|
|
379
|
+
const res = spawnSync(cmd, args, { encoding: "utf8", ...opts });
|
|
380
|
+
return { code: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function argValue(args, name) {
|
|
384
|
+
const prefix = `--${name}=`;
|
|
385
|
+
const inline = args.find((arg) => arg.startsWith(prefix));
|
|
386
|
+
if (inline) return inline.slice(prefix.length);
|
|
387
|
+
const i = args.indexOf(`--${name}`);
|
|
388
|
+
return i !== -1 ? args[i + 1] || "" : "";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function commandExists(cmd) {
|
|
392
|
+
return runQuiet("sh", ["-lc", "command -v -- \"$1\" >/dev/null 2>&1", "sh", cmd]).code === 0;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function resolveCommand(cmd) {
|
|
396
|
+
const found = runQuiet("sh", ["-lc", "command -v -- \"$1\"", "sh", cmd]);
|
|
397
|
+
if (found.code === 0 && found.stdout.trim()) return found.stdout.trim().split("\n")[0];
|
|
398
|
+
return "";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function npmGlobalBinCommand(cmd) {
|
|
402
|
+
if (!commandExists("npm")) return "";
|
|
403
|
+
const prefix = runQuiet("npm", ["prefix", "-g"]);
|
|
404
|
+
if (prefix.code !== 0 || !prefix.stdout.trim()) return "";
|
|
405
|
+
const executable = process.platform === "win32" ? `${cmd}.cmd` : cmd;
|
|
406
|
+
const candidate = path.join(prefix.stdout.trim(), "bin", executable);
|
|
407
|
+
return fs.existsSync(candidate) ? candidate : "";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function hasSupportedNode() {
|
|
411
|
+
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
412
|
+
return major > 22 || (major === 22 && minor >= 19);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function ensureDeps() {
|
|
416
|
+
if (!hasSupportedNode()) {
|
|
417
|
+
console.error(c.red(`Node.js 22.19+ is required (found ${process.version}). Please upgrade and try again.`));
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
const dependencyMarker = packaged
|
|
421
|
+
? path.join(repoRoot, "node_modules", "express", "package.json")
|
|
422
|
+
: tsxCli;
|
|
423
|
+
if (fs.existsSync(dependencyMarker)) return true;
|
|
424
|
+
if (!commandExists("npm")) {
|
|
425
|
+
console.error(c.red("npm is required (it ships with Node.js)."));
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
if (process.platform === "linux" && (!commandExists("make") || !commandExists("g++") || !commandExists("python3"))) {
|
|
429
|
+
console.error(c.red("Build tools are missing. On Ubuntu/Debian run: sudo apt-get update && sudo apt-get install -y build-essential python3"));
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
const hasLockfile = fs.existsSync(path.join(repoRoot, "package-lock.json"));
|
|
433
|
+
const args = hasLockfile ? ["ci", "--no-audit", "--no-fund"] : ["install", "--no-audit", "--no-fund"];
|
|
434
|
+
console.log(c.dim(`Installing dependencies (npm ${args.join(" ")})…`));
|
|
435
|
+
const code = await run("npm", args, { cwd: repoRoot });
|
|
436
|
+
if (code !== 0 || !fs.existsSync(dependencyMarker)) {
|
|
437
|
+
console.error(c.red("npm install failed. Install Node.js 22.19+ and build tools (make/g++/python3), then try again."));
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function nodePackageInstalled(packageName) {
|
|
444
|
+
return runQuiet(nodeBin, ["-e", "require.resolve(process.argv[1], { paths: [process.argv[2]] })", packageName, repoRoot]).code === 0;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function ensureNodePackage(packageName) {
|
|
448
|
+
if (nodePackageInstalled(packageName)) return true;
|
|
449
|
+
if (!commandExists("npm")) {
|
|
450
|
+
console.error(c.red(`npm is required to install ${packageName}.`));
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
console.log(c.dim(`Installing ${packageName}…`));
|
|
454
|
+
const code = await run("npm", ["install", packageName, "--no-audit", "--no-fund"], { cwd: repoRoot });
|
|
455
|
+
return code === 0 && nodePackageInstalled(packageName);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const userLocalPrefix = process.env.BIVY_NPM_GLOBAL_PREFIX || path.join(os.homedir(), ".local");
|
|
459
|
+
|
|
460
|
+
async function ensureNpmCommand(command, packageName, label) {
|
|
461
|
+
if (commandExists(command)) return true;
|
|
462
|
+
if (!commandExists("npm")) {
|
|
463
|
+
console.log(c.yellow(`Skipping ${label}: npm is not available.`));
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
fs.mkdirSync(path.join(userLocalPrefix, "bin"), { recursive: true });
|
|
467
|
+
console.log(c.dim(`Installing ${label} (${packageName})…`));
|
|
468
|
+
const code = await run("npm", ["install", "--global", "--prefix", userLocalPrefix, packageName, "--no-audit", "--no-fund"]);
|
|
469
|
+
return code === 0 && commandExists(command);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function ensurePythonCommand(command, packageName, label) {
|
|
473
|
+
if (commandExists(command)) return true;
|
|
474
|
+
if (!commandExists("python3")) {
|
|
475
|
+
console.log(c.yellow(`Skipping ${label}: python3 is not available.`));
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
console.log(c.dim(`Installing ${label} (${packageName})…`));
|
|
479
|
+
const code = await run("python3", ["-m", "pip", "install", "--user", packageName]);
|
|
480
|
+
return code === 0 && commandExists(command);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Single source of truth for what `bivy agents:install` installs, so its help
|
|
484
|
+
// text (see printHelp) can never drift from what it actually does (#113).
|
|
485
|
+
const BUNDLED_AGENTS = [
|
|
486
|
+
{ command: "claude", npmPackage: "@anthropic-ai/claude-code", label: "Claude Code" },
|
|
487
|
+
{ command: "codex", npmPackage: "@openai/codex", label: "Codex" },
|
|
488
|
+
{ command: "opencode", npmPackage: "opencode-ai/opencode", label: "OpenCode" },
|
|
489
|
+
{ command: "aider", pythonPackage: "aider-chat", label: "Aider" },
|
|
490
|
+
{ command: "hermes", npmPackage: "hermes", label: "Hermes" },
|
|
491
|
+
{ command: "gemini", npmPackage: "@google/gemini-cli", label: "Gemini CLI" },
|
|
492
|
+
];
|
|
493
|
+
|
|
494
|
+
async function ensureBundledAgents() {
|
|
495
|
+
if (process.env.BIVY_SKIP_AGENT_PREINSTALL === "1") return true;
|
|
496
|
+
console.log(c.dim("Ensuring bundled agent runtimes are installed…"));
|
|
497
|
+
const results = [await ensureNodePackage("@anthropic-ai/claude-agent-sdk")];
|
|
498
|
+
for (const agent of BUNDLED_AGENTS) {
|
|
499
|
+
results.push(
|
|
500
|
+
agent.pythonPackage
|
|
501
|
+
? await ensurePythonCommand(agent.command, agent.pythonPackage, agent.label)
|
|
502
|
+
: await ensureNpmCommand(agent.command, agent.npmPackage, agent.label),
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
const ok = results.every(Boolean);
|
|
506
|
+
if (!ok) console.log(c.yellow("Some optional agent runtimes could not be installed. Bivy will still run; install them later from the Agents screen or re-run 'bivy agents:install'."));
|
|
507
|
+
return ok;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function ensureSetupAgent(choice) {
|
|
511
|
+
if (!choice || choice.runtimeId === "pi") return true;
|
|
512
|
+
if (choice.runtimeId === "claude-code-sdk") {
|
|
513
|
+
const sdk = await ensureNodePackage("@anthropic-ai/claude-agent-sdk");
|
|
514
|
+
const cli = await ensureNpmCommand("claude", "@anthropic-ai/claude-code", "Claude Code");
|
|
515
|
+
return sdk && cli;
|
|
516
|
+
}
|
|
517
|
+
if (choice.runtimeId === "codex") return ensureNpmCommand("codex", "@openai/codex", "Codex");
|
|
518
|
+
if (choice.runtimeId === "opencode") return ensureNpmCommand("opencode", "opencode-ai/opencode", "OpenCode");
|
|
519
|
+
if (choice.runtimeId === "gemini") return ensureNpmCommand("gemini", "@google/gemini-cli", "Gemini CLI");
|
|
520
|
+
if (choice.runtimeId === "qwen") return ensureNpmCommand("qwen", "@qwen-code/qwen-code", "Qwen Code");
|
|
521
|
+
if (choice.runtimeId === "aider") return ensurePythonCommand("aider", "aider-chat", "Aider");
|
|
522
|
+
if (choice.runtimeId === "cline") return ensureNpmCommand("cline", "cline", "Cline");
|
|
523
|
+
if (choice.runtimeId === "crush") return ensureNpmCommand("crush", "@charmland/crush", "Crush");
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// The CLI-agent rows come from bin/agent-manifest.json — generated from
|
|
528
|
+
// CLI_AGENT_SPECS (`npm run gen:agent-manifest`), so the terminal `bivy run`
|
|
529
|
+
// agents never drift from the web picker's. A sync test guards the JSON. The two
|
|
530
|
+
// native rows (Pi, Claude Code) aren't CLI specs and stay defined here.
|
|
531
|
+
function loadAgentManifest() {
|
|
532
|
+
try {
|
|
533
|
+
const raw = fs.readFileSync(path.join(__dirname, "agent-manifest.json"), "utf8");
|
|
534
|
+
const parsed = JSON.parse(raw);
|
|
535
|
+
return Array.isArray(parsed?.agents) ? parsed.agents : [];
|
|
536
|
+
} catch {
|
|
537
|
+
return []; // shipped alongside this file; empty only in a broken checkout
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Headless "one-shot" tokens for an agent, derived from the manifest — the
|
|
542
|
+
// fallback for any spec that isn't hand-tuned in AGENT_HEADLESS_FLAGS below, so a
|
|
543
|
+
// newly-added agent still gets one-shot detection with no edit here.
|
|
544
|
+
function manifestHeadlessFlags(id) {
|
|
545
|
+
const entry = loadAgentManifest().find((a) => a.id === id);
|
|
546
|
+
return entry && entry.headlessFlags?.length ? entry.headlessFlags : undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const BUILTIN_TERMINAL_AGENTS = new Map([
|
|
550
|
+
["pi", { label: "Pi", type: "native-pi" }],
|
|
551
|
+
["claude", { label: "Claude Code", type: "command", command: "claude", npmPackage: "@anthropic-ai/claude-code" }],
|
|
552
|
+
["openclaw", { label: "OpenClaw", type: "command", command: process.env.BIVY_OPENCLAW_COMMAND || "openclaw" }],
|
|
553
|
+
...loadAgentManifest().map((a) => [
|
|
554
|
+
a.id,
|
|
555
|
+
{
|
|
556
|
+
label: a.label,
|
|
557
|
+
type: "command",
|
|
558
|
+
// The manifest carries each agent's real binary (e.g. Rovo Dev → `acli`,
|
|
559
|
+
// Continue → `cn`, Kilo Code → `kilo`).
|
|
560
|
+
command: a.command,
|
|
561
|
+
// Auto-install only from npm; curl/pip agents resolve if already on PATH.
|
|
562
|
+
...(a.install?.kind === "npm" ? { npmPackage: a.install.pkg } : {}),
|
|
563
|
+
},
|
|
564
|
+
]),
|
|
565
|
+
]);
|
|
566
|
+
|
|
567
|
+
async function ensureTerminalCommand(agent) {
|
|
568
|
+
let command = resolveCommand(agent.command) || npmGlobalBinCommand(agent.command) || agent.command;
|
|
569
|
+
if (commandExists(agent.command) || fs.existsSync(command)) return command;
|
|
570
|
+
|
|
571
|
+
if (!agent.npmPackage) return "";
|
|
572
|
+
if (!commandExists("npm")) {
|
|
573
|
+
console.error(c.red(`npm is required to install ${agent.label}.`));
|
|
574
|
+
return "";
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
console.log(c.dim(`${agent.label} command not found; installing ${agent.npmPackage}…`));
|
|
578
|
+
fs.mkdirSync(path.join(userLocalPrefix, "bin"), { recursive: true });
|
|
579
|
+
const code = await run("npm", ["install", "--global", "--prefix", userLocalPrefix, agent.npmPackage, "--no-audit", "--no-fund"]);
|
|
580
|
+
if (code !== 0) return "";
|
|
581
|
+
|
|
582
|
+
command = resolveCommand(agent.command) || npmGlobalBinCommand(agent.command) || agent.command;
|
|
583
|
+
return commandExists(agent.command) || fs.existsSync(command) ? command : "";
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function customTerminalAgent(agentId) {
|
|
587
|
+
const key = agentId.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
588
|
+
const command = process.env[`BIVY_AGENT_${key}_COMMAND`]?.trim();
|
|
589
|
+
if (!command) return undefined;
|
|
590
|
+
let args = [];
|
|
591
|
+
const rawArgs = process.env[`BIVY_AGENT_${key}_ARGS`]?.trim();
|
|
592
|
+
if (rawArgs) {
|
|
593
|
+
try {
|
|
594
|
+
const parsed = JSON.parse(rawArgs);
|
|
595
|
+
if (Array.isArray(parsed)) args = parsed.map(String);
|
|
596
|
+
else throw new Error("not an array");
|
|
597
|
+
} catch {
|
|
598
|
+
throw new Error(`BIVY_AGENT_${key}_ARGS must be a JSON array, e.g. '["--flag"]'.`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return { label: agentId, type: "command", command, args };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function terminalAgent(agentId) {
|
|
605
|
+
const id = (agentId || resolveDefaultAgent()).toLowerCase();
|
|
606
|
+
return { id, agent: BUILTIN_TERMINAL_AGENTS.get(id) ?? customTerminalAgent(id) };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function waitForNode(config, timeoutMs = 8000) {
|
|
610
|
+
const start = Date.now();
|
|
611
|
+
while (Date.now() - start < timeoutMs) {
|
|
612
|
+
if (await isReachable(config)) return true;
|
|
613
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
614
|
+
}
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function ensureNodeRunning(config) {
|
|
619
|
+
// One-shot: harden a legacy plaintext GitHub token into the vault before the
|
|
620
|
+
// node (re)starts. Operates on a fresh on-disk config so a caller's transient
|
|
621
|
+
// merged env is never persisted; idempotent once migrated.
|
|
622
|
+
migrateGithubTokenToVault(loadConfig());
|
|
623
|
+
if (await isReachable(config)) return true;
|
|
624
|
+
|
|
625
|
+
if (restartService()) {
|
|
626
|
+
console.log(c.dim("Starting Bivy node service…"));
|
|
627
|
+
if (await waitForNode(config, 12000)) return true;
|
|
628
|
+
printNodeStartupDiagnostics();
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
console.log(c.dim("Starting Bivy node in the background…"));
|
|
633
|
+
// Capture stdout/stderr to a log so a crash on startup is diagnosable instead
|
|
634
|
+
// of vanishing into stdio: "ignore". Best effort — fall back to ignoring
|
|
635
|
+
// output if the log can't be opened.
|
|
636
|
+
let logFd;
|
|
637
|
+
try {
|
|
638
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
639
|
+
logFd = fs.openSync(nodeLogPath, "w");
|
|
640
|
+
} catch {
|
|
641
|
+
logFd = undefined;
|
|
642
|
+
}
|
|
643
|
+
const child = spawn(nodeBin, nodeScriptArgs(serverEntry), {
|
|
644
|
+
cwd: repoRoot,
|
|
645
|
+
env: startEnv(config),
|
|
646
|
+
detached: true,
|
|
647
|
+
stdio: logFd === undefined ? "ignore" : ["ignore", logFd, logFd],
|
|
648
|
+
});
|
|
649
|
+
child.unref();
|
|
650
|
+
if (logFd !== undefined) {
|
|
651
|
+
try { fs.closeSync(logFd); } catch {}
|
|
652
|
+
}
|
|
653
|
+
if (await waitForNode(config, 12000)) return true;
|
|
654
|
+
printNodeStartupDiagnostics();
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Resolve an agent id (or a raw `-- command…`) to a spec the node can spawn as a
|
|
659
|
+
// run-terminal: { agent, label, command, args }. Installs the agent binary if we
|
|
660
|
+
// know its package (via ensureTerminalCommand). Returns null if it can't be found.
|
|
661
|
+
async function resolveRunSpec(agentId, extraArgs) {
|
|
662
|
+
if (agentId === "--") {
|
|
663
|
+
const [command, ...args] = extraArgs;
|
|
664
|
+
if (!command) return { error: "Usage: bivy run -- <command> [args…]" };
|
|
665
|
+
return { spec: { agent: path.basename(command), label: path.basename(command), command, args } };
|
|
666
|
+
}
|
|
667
|
+
const { id, agent } = terminalAgent(agentId);
|
|
668
|
+
if (!agent) {
|
|
669
|
+
return { error: `Unknown agent: ${agentId}. Built-ins: ${[...BUILTIN_TERMINAL_AGENTS.keys()].join(", ")}. Or: bivy run -- <command>.` };
|
|
670
|
+
}
|
|
671
|
+
if (agent.type === "native-pi") {
|
|
672
|
+
return { spec: { agent: id, label: agent.label, command: nodeBin, args: [...nodeScriptArgs(nativePiEntry), ...extraArgs] } };
|
|
673
|
+
}
|
|
674
|
+
const command = await ensureTerminalCommand(agent);
|
|
675
|
+
if (!command) {
|
|
676
|
+
return { error: `${agent.label} command not found: ${agent.command}. Install it or use 'bivy run pi'.` };
|
|
677
|
+
}
|
|
678
|
+
return { spec: { agent: id, label: agent.label, command, args: [...(agent.args ?? []), ...extraArgs] } };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Pull bivy's own `--name`/`--model` flags (space or `=` form) out of the run
|
|
682
|
+
// args so they aren't blindly forwarded. Only honored before a `--` separator,
|
|
683
|
+
// past which everything is the raw command the user asked to run.
|
|
684
|
+
// Does a token look like a git remote (URL, scp-style, owner/repo, or a path)
|
|
685
|
+
// rather than an agent id? Lets `--clone <remote>` disambiguate from bare
|
|
686
|
+
// `--clone` (= current folder's repo) without a required `=`.
|
|
687
|
+
function looksLikeRemote(value) {
|
|
688
|
+
const v = String(value || "");
|
|
689
|
+
return /:\/\//.test(v) || /@[^/]+:/.test(v) || /\.git$/.test(v) || v.startsWith("/") || v.startsWith(".") || /^[\w.-]+\/[\w.-]+$/.test(v);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function extractRunFlags(args) {
|
|
693
|
+
const rest = [];
|
|
694
|
+
let name, model, node, workspace;
|
|
695
|
+
let clone; // undefined = no clone; true = current repo; string = explicit remote
|
|
696
|
+
for (let i = 0; i < args.length; i++) {
|
|
697
|
+
const a = args[i];
|
|
698
|
+
if (a === "--") { rest.push(...args.slice(i)); break; }
|
|
699
|
+
if (a === "--name" && args[i + 1] !== undefined) { name = args[++i]; continue; }
|
|
700
|
+
if (a.startsWith("--name=")) { name = a.slice("--name=".length); continue; }
|
|
701
|
+
if (a === "--model" && args[i + 1] !== undefined) { model = args[++i]; continue; }
|
|
702
|
+
if (a.startsWith("--model=")) { model = a.slice("--model=".length); continue; }
|
|
703
|
+
if (a === "--node" && args[i + 1] !== undefined) { node = args[++i]; continue; }
|
|
704
|
+
if (a.startsWith("--node=")) { node = a.slice("--node=".length); continue; }
|
|
705
|
+
if (a === "--workspace" && args[i + 1] !== undefined) { workspace = args[++i]; continue; }
|
|
706
|
+
if (a.startsWith("--workspace=")) { workspace = a.slice("--workspace=".length); continue; }
|
|
707
|
+
if (a.startsWith("--clone=")) { clone = a.slice("--clone=".length); continue; }
|
|
708
|
+
if (a === "--clone") { clone = looksLikeRemote(args[i + 1]) ? args[++i] : true; continue; }
|
|
709
|
+
rest.push(a);
|
|
710
|
+
}
|
|
711
|
+
return { name: name?.trim() || undefined, model: model?.trim() || undefined, node: node?.trim() || undefined, workspace: workspace?.trim() || undefined, clone, rest };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// A safe-ish workspace dir name from a remote or path (basename minus .git).
|
|
715
|
+
function deriveRepoName(remote) {
|
|
716
|
+
const base = String(remote).replace(/\/+$/, "").replace(/\.git$/i, "").split(/[/:]/).pop() || "repo";
|
|
717
|
+
return base.replace(/[^\w.-]+/g, "-").slice(0, 40) || "repo";
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Resolve the workspace directory for a new session. `--workspace <dir>` uses an
|
|
721
|
+
// existing directory; `--clone` makes a fresh checkout under .bivy/workspaces:
|
|
722
|
+
// bare `--clone` clones the current folder's repo (its origin remote, or the
|
|
723
|
+
// local checkout when there's no remote), `--clone <remote>` clones that remote.
|
|
724
|
+
// Returns undefined when neither option was given (use the node's default).
|
|
725
|
+
function resolveWorkspaceDir({ clone, workspace }) {
|
|
726
|
+
if (workspace) {
|
|
727
|
+
const dir = path.resolve(workspace);
|
|
728
|
+
if (!fs.existsSync(dir)) throw new Error(`Workspace does not exist: ${dir}`);
|
|
729
|
+
if (!fs.statSync(dir).isDirectory()) throw new Error(`Workspace is not a directory: ${dir}`);
|
|
730
|
+
return dir;
|
|
731
|
+
}
|
|
732
|
+
if (clone === undefined) return undefined;
|
|
733
|
+
|
|
734
|
+
let remote = typeof clone === "string" ? clone.trim() : "";
|
|
735
|
+
if (!remote) {
|
|
736
|
+
const origin = runQuiet("git", ["-C", process.cwd(), "remote", "get-url", "origin"]).stdout.trim();
|
|
737
|
+
if (origin) remote = origin;
|
|
738
|
+
else {
|
|
739
|
+
const top = runQuiet("git", ["-C", process.cwd(), "rev-parse", "--show-toplevel"]).stdout.trim();
|
|
740
|
+
if (!top) throw new Error("Not inside a git repository. Use 'bivy run <agent> --clone <remote>' to clone a specific repo.");
|
|
741
|
+
remote = top; // clone the local checkout by path
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (!commandExists("git")) throw new Error("git is required for --clone but was not found on PATH.");
|
|
745
|
+
|
|
746
|
+
const root = path.join(appDir, "workspaces");
|
|
747
|
+
fs.mkdirSync(root, { recursive: true });
|
|
748
|
+
const dest = path.join(root, `${deriveRepoName(remote)}-${randomBytes(3).toString("hex")}`);
|
|
749
|
+
console.log(c.dim(`Cloning ${remote} → ${dest}…`));
|
|
750
|
+
const res = runQuiet("git", ["clone", remote, dest]);
|
|
751
|
+
if (res.code !== 0) {
|
|
752
|
+
try { fs.rmSync(dest, { recursive: true, force: true }); } catch {}
|
|
753
|
+
throw new Error(`git clone failed: ${(res.stderr || res.stdout || "").trim().split("\n").slice(-3).join("\n")}`);
|
|
754
|
+
}
|
|
755
|
+
return dest;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Where a `bivy run` with neither --workspace nor --clone should start.
|
|
759
|
+
//
|
|
760
|
+
// The PTY is spawned by the daemon, not by this process, so it has no idea where
|
|
761
|
+
// you typed the command. Without this, running an agent from your checkout would
|
|
762
|
+
// silently root it in the node's configured workspace: a relative command
|
|
763
|
+
// (`bivy run -- ./my-agent`) fails to resolve, and — worse — a relative argument
|
|
764
|
+
// (`--repo .`) resolves to the wrong repo and the agent happily does the wrong
|
|
765
|
+
// work. Adopting the cwd makes the common case ("run an agent on the repo I'm
|
|
766
|
+
// standing in") correct by default.
|
|
767
|
+
//
|
|
768
|
+
// Only when the cwd is inside a git work tree: a bare `bivy` from $HOME or /tmp
|
|
769
|
+
// should still land in the configured workspace rather than turning an agent
|
|
770
|
+
// loose on the home directory. Uses the cwd itself, not the repo root, to match
|
|
771
|
+
// `--workspace .` and to respect an intentional `cd` into a monorepo package.
|
|
772
|
+
function defaultRunWorkspace(config) {
|
|
773
|
+
const fallback = config.workspace || repoRoot;
|
|
774
|
+
const cwd = process.cwd();
|
|
775
|
+
const res = runQuiet("git", ["-C", cwd, "rev-parse", "--is-inside-work-tree"]);
|
|
776
|
+
return res.code === 0 && res.stdout.trim() === "true" ? cwd : fallback;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// --- nodes registry ---------------------------------------------------------
|
|
780
|
+
// Other Bivy nodes this machine can reach directly (LAN, Tailscale, SSH tunnel,
|
|
781
|
+
// VPN). name → { url, token }. `bivy run --node <name>` starts the session on
|
|
782
|
+
// that node instead of the local one; the PTY lives there, so `bivy resume` from
|
|
783
|
+
// the remote node (or a phone/web app) can rejoin it.
|
|
784
|
+
|
|
785
|
+
const nodesConfigPath = path.join(appDir, "nodes.json");
|
|
786
|
+
|
|
787
|
+
function loadNodes() {
|
|
788
|
+
try {
|
|
789
|
+
const data = JSON.parse(fs.readFileSync(nodesConfigPath, "utf8"));
|
|
790
|
+
return data && typeof data.nodes === "object" ? data : { version: 1, nodes: {} };
|
|
791
|
+
} catch {
|
|
792
|
+
return { version: 1, nodes: {} };
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function saveNodes(data) {
|
|
797
|
+
fs.mkdirSync(appDir, { recursive: true, mode: 0o700 });
|
|
798
|
+
const tmp = `${nodesConfigPath}.tmp`;
|
|
799
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
800
|
+
fs.renameSync(tmp, nodesConfigPath);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// --- agent shims ------------------------------------------------------------
|
|
804
|
+
// A shim shadows an agent binary (e.g. `claude`) on PATH so that starting the
|
|
805
|
+
// agent from a terminal transparently launches it inside a Bivy-owned PTY (via
|
|
806
|
+
// `bivy run <agent>`) instead of a bare process. Locally you still get the
|
|
807
|
+
// agent's native TUI; because the daemon owns the PTY, the same live session is
|
|
808
|
+
// visible and drivable from the remote web/PWA ("continue on CLI"), and the
|
|
809
|
+
// session id is pinned at launch so it can later be resumed as a governed chat.
|
|
810
|
+
// Headless invocations (non-TTY stdin, or a one-shot flag like `claude -p`) pass
|
|
811
|
+
// straight through to the real binary, so scripts, pipes, CI, and the agent
|
|
812
|
+
// subprocess a managed resume itself spawns are never intercepted (that same
|
|
813
|
+
// passthrough is the recursion guard). The mechanism is agent-agnostic: the
|
|
814
|
+
// per-agent knowledge is just a small list of "this call is headless" flags and,
|
|
815
|
+
// optionally, the flag used to pin a session id.
|
|
816
|
+
|
|
817
|
+
const shimsConfigPath = path.join(appDir, "shims.json");
|
|
818
|
+
|
|
819
|
+
// Line that marks a file as a Bivy-generated shim. Uninstall refuses to delete
|
|
820
|
+
// any file that lacks it, so a shim can never clobber a user's real binary.
|
|
821
|
+
const SHIM_MARKER = "# bivy-shim v1 — managed by `bivy shim`; do not edit";
|
|
822
|
+
|
|
823
|
+
// Per-agent tokens that mean "this invocation is one-shot / headless" and should
|
|
824
|
+
// bypass Bivy and run the real agent directly. Non-TTY stdin is always treated
|
|
825
|
+
// as headless regardless of this list, so the list only needs to catch a human
|
|
826
|
+
// running a one-shot in their terminal. Unknown agents fall back to DEFAULT.
|
|
827
|
+
const AGENT_HEADLESS_FLAGS = {
|
|
828
|
+
default: ["-p", "--print"],
|
|
829
|
+
claude: ["-p", "--print"],
|
|
830
|
+
codex: ["exec", "--json"],
|
|
831
|
+
gemini: ["-p", "--prompt"],
|
|
832
|
+
qwen: ["-p", "--prompt"],
|
|
833
|
+
aider: ["--message", "--msg"],
|
|
834
|
+
goose: ["run"],
|
|
835
|
+
opencode: ["run"],
|
|
836
|
+
crush: ["run"],
|
|
837
|
+
cline: ["-y", "--yolo", "--no-interactive", "--json"],
|
|
838
|
+
cursor: ["-p", "--print"],
|
|
839
|
+
copilot: ["-p", "--prompt"],
|
|
840
|
+
grok: ["-p", "--prompt"],
|
|
841
|
+
amp: ["-x", "--execute"],
|
|
842
|
+
auggie: ["-p", "--print"],
|
|
843
|
+
droid: ["exec"],
|
|
844
|
+
continue: ["-p"],
|
|
845
|
+
kilocode: ["run"],
|
|
846
|
+
rovodev: ["run"],
|
|
847
|
+
codebuff: ["-p", "--print"],
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
// Flag an agent's CLI accepts to pin a specific session id at launch, so the
|
|
851
|
+
// daemon knows the resume target up front (no transcript-file guessing) and can
|
|
852
|
+
// later resume the session as a governed chat. Only agents whose CLI supports it
|
|
853
|
+
// appear here; others simply run without a pinned id.
|
|
854
|
+
const AGENT_SESSION_ID_FLAG = {
|
|
855
|
+
claude: "--session-id", // `claude --session-id <uuid>` (must be a valid UUID)
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
// Args that mean the caller already chose a session (pin or resume), so we must
|
|
859
|
+
// not inject our own --session-id over the top.
|
|
860
|
+
const SESSION_ID_CONFLICTS = ["--session-id", "--resume", "-r", "-c", "--continue"];
|
|
861
|
+
|
|
862
|
+
// How each agent's native CLI resumes a saved session by id. `bivy resume`/`bivy
|
|
863
|
+
// sessions` reopen a durable session by relaunching it through `bivy run` with
|
|
864
|
+
// these args — i.e. the agent's own resume, in a Bivy-managed, relay-visible
|
|
865
|
+
// PTY. Agents without a known form fall back to `--resume <id>` (the convention
|
|
866
|
+
// most CLIs follow, and the hint `bivy run` already prints when pinning an id).
|
|
867
|
+
const AGENT_RESUME_ARGS = {
|
|
868
|
+
claude: (id) => ["--resume", id],
|
|
869
|
+
codex: (id) => ["resume", id],
|
|
870
|
+
};
|
|
871
|
+
function agentResumeArgs(agentId, sessionRef) {
|
|
872
|
+
const fn = AGENT_RESUME_ARGS[(agentId || "").toLowerCase()];
|
|
873
|
+
return fn ? fn(sessionRef) : ["--resume", sessionRef];
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// If the agent supports id pinning and the caller didn't already pick a session,
|
|
877
|
+
// generate a UUID, prepend the agent's pin flag, and record it on the spec.
|
|
878
|
+
// Returns the pinned id (or undefined).
|
|
879
|
+
function pinRunSessionId(agentId, spec) {
|
|
880
|
+
const flag = AGENT_SESSION_ID_FLAG[agentId];
|
|
881
|
+
if (!flag) return undefined;
|
|
882
|
+
const args = spec.args ?? [];
|
|
883
|
+
const alreadyChosen = args.some((a) => SESSION_ID_CONFLICTS.includes(a) || SESSION_ID_CONFLICTS.some((f) => a.startsWith(`${f}=`)));
|
|
884
|
+
if (alreadyChosen) return undefined;
|
|
885
|
+
const id = randomUUID();
|
|
886
|
+
spec.args = [flag, id, ...args];
|
|
887
|
+
spec.sessionId = id;
|
|
888
|
+
return id;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function loadShims() {
|
|
892
|
+
try {
|
|
893
|
+
const data = JSON.parse(fs.readFileSync(shimsConfigPath, "utf8"));
|
|
894
|
+
return data && typeof data.shims === "object" ? data : { version: 1, shims: {} };
|
|
895
|
+
} catch {
|
|
896
|
+
return { version: 1, shims: {} };
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function saveShims(data) {
|
|
901
|
+
fs.mkdirSync(appDir, { recursive: true, mode: 0o700 });
|
|
902
|
+
const tmp = `${shimsConfigPath}.tmp`;
|
|
903
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
904
|
+
fs.renameSync(tmp, shimsConfigPath);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Directory the shim is installed into. Defaults to the same `~/.local/bin` that
|
|
908
|
+
// npm-installed agent CLIs land in; overridable so it can be placed ahead of a
|
|
909
|
+
// system binary on PATH.
|
|
910
|
+
function defaultShimDir() {
|
|
911
|
+
return path.join(userLocalPrefix, "bin");
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Resolve the REAL agent binary while ignoring `excludeDir` (the shim's own dir),
|
|
915
|
+
// so we never resolve the shim itself. Returns "" when nothing else on PATH
|
|
916
|
+
// provides the command.
|
|
917
|
+
function resolveRealBinary(agentCmd, excludeDir) {
|
|
918
|
+
const cleaned = (process.env.PATH || "")
|
|
919
|
+
.split(path.delimiter)
|
|
920
|
+
.filter((entry) => entry && path.resolve(entry) !== path.resolve(excludeDir))
|
|
921
|
+
.join(path.delimiter);
|
|
922
|
+
// Plain `-c` (not `-lc`): PATH is set explicitly here, and a login shell would
|
|
923
|
+
// source profiles that can print noise onto stdout.
|
|
924
|
+
const found = runQuiet("sh", ["-c", 'PATH="$1" command -v -- "$2" 2>/dev/null', "sh", cleaned, agentCmd]);
|
|
925
|
+
const line = found.code === 0 ? found.stdout.trim().split("\n").pop() : "";
|
|
926
|
+
return line && line.startsWith("/") ? line : "";
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Resolve what a command name currently points to on the full PATH (no login
|
|
930
|
+
// shell, so no profile noise). Used to check whether an installed shim actually
|
|
931
|
+
// wins on PATH. Returns "" when unresolved or resolved to a non-path (builtin).
|
|
932
|
+
function whichOnPath(cmd) {
|
|
933
|
+
const found = runQuiet("sh", ["-c", 'command -v -- "$1" 2>/dev/null', "sh", cmd]);
|
|
934
|
+
const line = found.code === 0 ? found.stdout.trim().split("\n").pop() : "";
|
|
935
|
+
return line && line.startsWith("/") ? line : "";
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// The POSIX-sh shim body. Everything agent-specific is injected as data, so the
|
|
939
|
+
// script itself is identical across agents.
|
|
940
|
+
function renderShim({ agent, agentCmd, shimDir, realFallback, headlessFlags }) {
|
|
941
|
+
const shq = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
942
|
+
return `#!/bin/sh
|
|
943
|
+
${SHIM_MARKER}
|
|
944
|
+
# Launches an interactive '${agent}' as its native TUI inside a Bivy PTY (via
|
|
945
|
+
# 'bivy run'); passes headless invocations straight through to the real binary.
|
|
946
|
+
AGENT=${shq(agent)}
|
|
947
|
+
AGENT_CMD=${shq(agentCmd)}
|
|
948
|
+
SHIM_DIR=${shq(shimDir)}
|
|
949
|
+
REAL_FALLBACK=${shq(realFallback)}
|
|
950
|
+
HEADLESS_FLAGS=${shq(headlessFlags.join(" "))}
|
|
951
|
+
BIVY_NODE=${shq(nodeBin)}
|
|
952
|
+
BIVY_SCRIPT=${shq(selfScript)}
|
|
953
|
+
|
|
954
|
+
# Rebuild PATH without our own directory: used both to find the real binary and
|
|
955
|
+
# to run the session's children, so nothing re-enters this shim.
|
|
956
|
+
clean_path=""
|
|
957
|
+
oldifs=$IFS
|
|
958
|
+
IFS=:
|
|
959
|
+
for p in $PATH; do
|
|
960
|
+
[ "$p" = "$SHIM_DIR" ] && continue
|
|
961
|
+
clean_path="\${clean_path:+$clean_path:}$p"
|
|
962
|
+
done
|
|
963
|
+
IFS=$oldifs
|
|
964
|
+
|
|
965
|
+
REAL=$(PATH="$clean_path" command -v -- "$AGENT_CMD" 2>/dev/null || true)
|
|
966
|
+
[ -n "$REAL" ] || REAL="$REAL_FALLBACK"
|
|
967
|
+
|
|
968
|
+
# Decide headless vs interactive.
|
|
969
|
+
headless=0
|
|
970
|
+
[ -t 0 ] || headless=1
|
|
971
|
+
if [ "$headless" -eq 0 ]; then
|
|
972
|
+
for a in "$@"; do
|
|
973
|
+
for f in $HEADLESS_FLAGS; do
|
|
974
|
+
[ "$a" = "$f" ] && { headless=1; break; }
|
|
975
|
+
done
|
|
976
|
+
[ "$headless" -eq 1 ] && break
|
|
977
|
+
done
|
|
978
|
+
fi
|
|
979
|
+
|
|
980
|
+
# Escape hatch: BIVY_SHIM_DISABLE=1 (all) or =<agent> forces the real binary.
|
|
981
|
+
case "\${BIVY_SHIM_DISABLE:-}" in
|
|
982
|
+
1|"$AGENT") headless=1 ;;
|
|
983
|
+
esac
|
|
984
|
+
|
|
985
|
+
if [ "$headless" -eq 1 ]; then
|
|
986
|
+
if [ -z "$REAL" ]; then
|
|
987
|
+
echo "bivy-shim: could not find the real '$AGENT_CMD' on PATH (excluding $SHIM_DIR)." >&2
|
|
988
|
+
exit 127
|
|
989
|
+
fi
|
|
990
|
+
exec "$REAL" "$@"
|
|
991
|
+
fi
|
|
992
|
+
|
|
993
|
+
# Interactive: launch the agent's native TUI inside a Bivy-owned PTY (via
|
|
994
|
+
# 'bivy run'), so the same live session is drivable from the remote web/PWA and
|
|
995
|
+
# the session id is pinned for later resume-as-chat. PATH is cleaned so the agent
|
|
996
|
+
# process Bivy spawns resolves the real binary, not this shim.
|
|
997
|
+
exec env PATH="$clean_path" "$BIVY_NODE" "$BIVY_SCRIPT" run "$AGENT" "$@"
|
|
998
|
+
`;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Render a path with $HOME abbreviated to `~` for display.
|
|
1002
|
+
function tildify(p) {
|
|
1003
|
+
const home = os.homedir();
|
|
1004
|
+
const resolved = path.resolve(p);
|
|
1005
|
+
if (resolved === home) return "~";
|
|
1006
|
+
if (resolved.startsWith(home + path.sep)) return `~${resolved.slice(home.length)}`;
|
|
1007
|
+
return resolved;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Distinct directories that currently hold Bivy shims (from shims.json).
|
|
1011
|
+
function installedShimDirs() {
|
|
1012
|
+
const dirs = new Set();
|
|
1013
|
+
const data = loadShims();
|
|
1014
|
+
for (const key of Object.keys(data.shims)) {
|
|
1015
|
+
const dir = data.shims[key]?.dir;
|
|
1016
|
+
if (dir) dirs.add(path.resolve(dir));
|
|
1017
|
+
}
|
|
1018
|
+
return [...dirs];
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Idempotently reconcile the managed PATH block in the user's shell rc so that
|
|
1022
|
+
// every installed shim dir wins over version-manager bins. Rebuilds the block
|
|
1023
|
+
// from the current shims.json each call; removes it entirely once no shims
|
|
1024
|
+
// remain. Returns { ok, file?, changed, present, reason? }.
|
|
1025
|
+
function syncManagedPathBlock() {
|
|
1026
|
+
const target = rcFileForShell(process.env.SHELL, os.homedir());
|
|
1027
|
+
if (!target) return { ok: false, changed: false, present: false, reason: "unknown-shell" };
|
|
1028
|
+
const dirs = installedShimDirs();
|
|
1029
|
+
let content = "";
|
|
1030
|
+
let mode = 0o644;
|
|
1031
|
+
try {
|
|
1032
|
+
content = fs.readFileSync(target.file, "utf8");
|
|
1033
|
+
mode = fs.statSync(target.file).mode & 0o777;
|
|
1034
|
+
} catch {
|
|
1035
|
+
// rc file doesn't exist yet — we'll create it.
|
|
1036
|
+
}
|
|
1037
|
+
const next = dirs.length === 0
|
|
1038
|
+
? removeManagedBlock(content)
|
|
1039
|
+
: upsertManagedBlock(content, renderManagedBlock(dirs));
|
|
1040
|
+
if (next === content) {
|
|
1041
|
+
return { ok: true, file: target.file, changed: false, present: dirs.length > 0 };
|
|
1042
|
+
}
|
|
1043
|
+
fs.mkdirSync(path.dirname(target.file), { recursive: true });
|
|
1044
|
+
const tmp = `${target.file}.bivy-tmp`;
|
|
1045
|
+
fs.writeFileSync(tmp, next, { mode });
|
|
1046
|
+
fs.renameSync(tmp, target.file);
|
|
1047
|
+
return { ok: true, file: target.file, changed: true, present: dirs.length > 0 };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Resolve what `cmd` points to in a fresh *interactive* login shell — i.e. after
|
|
1051
|
+
// the user's rc (and its version-manager hooks) have run. This is what the shim
|
|
1052
|
+
// actually competes with, unlike `whichOnPath`, which sees this process's PATH
|
|
1053
|
+
// (doctored at startup to front-load ~/.local/bin, so it would falsely report
|
|
1054
|
+
// "active"). Best-effort: returns "" if the shell can't be run, times out, or
|
|
1055
|
+
// resolves to a non-path builtin.
|
|
1056
|
+
function resolveViaLoginShell(cmd) {
|
|
1057
|
+
const shell = process.env.SHELL;
|
|
1058
|
+
if (!shell || process.platform === "win32") return "";
|
|
1059
|
+
const res = runQuiet(shell, ["-ic", 'command -v -- "$1" 2>/dev/null', shell, cmd], {
|
|
1060
|
+
timeout: 5000,
|
|
1061
|
+
input: "",
|
|
1062
|
+
});
|
|
1063
|
+
// `-i` may print prompt/rc noise; take the last absolute-path line (our
|
|
1064
|
+
// `command -v` runs last).
|
|
1065
|
+
const line = (res.stdout || "").trim().split("\n").filter(Boolean).pop() || "";
|
|
1066
|
+
return line.startsWith("/") ? line : "";
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Where does `agent` actually resolve for the user? Prefer a real interactive
|
|
1070
|
+
// shell (reflects the rc + version managers); fall back to this process's PATH.
|
|
1071
|
+
function activeAgentPath(agent) {
|
|
1072
|
+
return resolveViaLoginShell(agent) || whichOnPath(agent);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function isUrlReachable(baseUrl) {
|
|
1076
|
+
try {
|
|
1077
|
+
const controller = new AbortController();
|
|
1078
|
+
const timer = setTimeout(() => controller.abort(), 1500);
|
|
1079
|
+
const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
|
|
1080
|
+
clearTimeout(timer);
|
|
1081
|
+
return res.ok || res.status < 500;
|
|
1082
|
+
} catch {
|
|
1083
|
+
return false;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Resolve a `--node <name>` target to a reachable { url, token }. Direct registry
|
|
1088
|
+
// entries win; if the name is only known to the account's control plane we can
|
|
1089
|
+
// name it and report online status, but relaying the PTY through the hosted
|
|
1090
|
+
// control plane is a larger follow-up, so we point the user at a direct route.
|
|
1091
|
+
async function resolveNodeTarget(nodeName) {
|
|
1092
|
+
const registry = loadNodes().nodes;
|
|
1093
|
+
const direct = registry[nodeName];
|
|
1094
|
+
if (direct?.url) {
|
|
1095
|
+
return { url: String(direct.url).replace(/\/+$/, ""), token: direct.token, source: "direct" };
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const relay = loadRelayConfig();
|
|
1099
|
+
if (relay?.controlPlaneUrl && relay?.enrollmentToken) {
|
|
1100
|
+
try {
|
|
1101
|
+
const data = await controlPlaneNodeApi(relay, "/nodes");
|
|
1102
|
+
const list = Array.isArray(data) ? data : (data?.nodes || []);
|
|
1103
|
+
const match = list.find((n) => n.name === nodeName || n.id === nodeName);
|
|
1104
|
+
if (match) {
|
|
1105
|
+
// Account node with no direct route: tunnel to it through the relay,
|
|
1106
|
+
// exactly as a phone does. resolveNodeTarget stays synchronous about the
|
|
1107
|
+
// decision; the relay-attach bridge performs the actual pairing.
|
|
1108
|
+
if (!match.online) {
|
|
1109
|
+
throw new Error(`Node "${nodeName}" is registered to your account but is currently offline.`);
|
|
1110
|
+
}
|
|
1111
|
+
return { source: "relay", nodeId: match.id, name: match.name || nodeName };
|
|
1112
|
+
}
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
// A concrete "offline" decision is actionable — surface it. Any other
|
|
1115
|
+
// control-plane failure (lookup unreachable) falls through to the generic
|
|
1116
|
+
// "unknown node" error below.
|
|
1117
|
+
if (error instanceof Error && /is currently offline/.test(error.message)) throw error;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const known = Object.keys(registry);
|
|
1122
|
+
throw new Error(
|
|
1123
|
+
`Unknown node "${nodeName}".` +
|
|
1124
|
+
(known.length ? ` Registered nodes: ${known.join(", ")}.` : ` Add one with 'bivy nodes add <name> <url> --token <token>'.`),
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// `bivy nodes` — list registered direct nodes (and, when relay is configured,
|
|
1129
|
+
// the account's control-plane nodes). `add`/`remove` manage the direct registry.
|
|
1130
|
+
async function cmdNodes(args = []) {
|
|
1131
|
+
const [sub, ...rest] = args;
|
|
1132
|
+
|
|
1133
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
1134
|
+
console.log("Usage: bivy nodes [list] | bivy nodes add <name> <url> [--token <token>] | bivy nodes remove <name>\n\nList directly-registered nodes plus (when relay is configured) your account's control-plane nodes, and add/remove direct routes. Run a session on any of them with 'bivy run --node <name>' — direct nodes connect straight to their URL, account nodes tunnel through the relay.");
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
if (sub && sub !== "list" && sub !== "ls" && sub !== "add" && sub !== "remove" && sub !== "rm") {
|
|
1139
|
+
console.error(c.red(`Unknown nodes subcommand: ${sub}. Usage: bivy nodes [list|add|remove]`));
|
|
1140
|
+
process.exit(1);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if (sub === "add") {
|
|
1145
|
+
const name = rest[0];
|
|
1146
|
+
const nodeUrl = rest[1];
|
|
1147
|
+
if (!name || !nodeUrl) {
|
|
1148
|
+
console.error(c.red("Usage: bivy nodes add <name> <url> [--token <token>]"));
|
|
1149
|
+
process.exit(1);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const token = argValue(rest, "token") || undefined;
|
|
1153
|
+
const data = loadNodes();
|
|
1154
|
+
data.nodes[name] = { url: String(nodeUrl).replace(/\/+$/, ""), token, addedAt: new Date().toISOString() };
|
|
1155
|
+
saveNodes(data);
|
|
1156
|
+
const reachable = await isUrlReachable(data.nodes[name].url);
|
|
1157
|
+
console.log(c.green(`Added node "${name}" → ${data.nodes[name].url} ${reachable ? c.green("● reachable") : c.dim("○ not reachable right now")}`));
|
|
1158
|
+
if (!token) console.log(c.dim("No token stored. If the node requires auth, run 'bivy token' on it and re-add with --token."));
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (sub === "remove" || sub === "rm") {
|
|
1163
|
+
const name = rest[0];
|
|
1164
|
+
const data = loadNodes();
|
|
1165
|
+
if (!name || !data.nodes[name]) { console.error(c.red(`No registered node "${name}".`)); process.exit(1); return; }
|
|
1166
|
+
delete data.nodes[name];
|
|
1167
|
+
saveNodes(data);
|
|
1168
|
+
console.log(c.green(`Removed node "${name}".`));
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// list
|
|
1173
|
+
const registry = loadNodes().nodes;
|
|
1174
|
+
const names = Object.keys(registry);
|
|
1175
|
+
console.log(c.bold("\n Direct nodes") + c.dim(" (bivy run --node <name>)\n"));
|
|
1176
|
+
if (names.length === 0) {
|
|
1177
|
+
console.log(c.dim(" none — add one with 'bivy nodes add <name> <url> --token <token>'"));
|
|
1178
|
+
} else {
|
|
1179
|
+
const reach = await Promise.all(names.map((n) => isUrlReachable(registry[n].url)));
|
|
1180
|
+
names.forEach((n, i) => {
|
|
1181
|
+
console.log(` ${c.cyan(n.padEnd(14))} ${registry[n].url} ${reach[i] ? c.green("● reachable") : c.dim("○ offline")}${registry[n].token ? "" : c.dim(" (no token)")}`);
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const relay = loadRelayConfig();
|
|
1186
|
+
if (relay?.controlPlaneUrl && relay?.enrollmentToken) {
|
|
1187
|
+
try {
|
|
1188
|
+
const data = await controlPlaneNodeApi(relay, "/nodes");
|
|
1189
|
+
const cpNodes = Array.isArray(data) ? data : (data?.nodes || []);
|
|
1190
|
+
console.log(c.bold("\n Account nodes") + c.dim(" (from the control plane — run on any online one with 'bivy run --node <name>')\n"));
|
|
1191
|
+
if (cpNodes.length === 0) console.log(c.dim(" none registered"));
|
|
1192
|
+
else for (const n of cpNodes) {
|
|
1193
|
+
const route = names.includes(n.name)
|
|
1194
|
+
? c.dim(" [direct route configured]")
|
|
1195
|
+
: (n.online ? c.dim(" reachable over the relay") : "");
|
|
1196
|
+
console.log(` ${c.cyan(String(n.name).padEnd(14))} ${n.online ? c.green("● online") : c.dim("○ offline")}${route}`);
|
|
1197
|
+
}
|
|
1198
|
+
} catch {
|
|
1199
|
+
console.log(c.dim("\n (could not reach the control plane to list account nodes)"));
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
console.log("");
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// `bivy agents` — list the agents Bivy can launch (its built-in terminal agents),
|
|
1206
|
+
// showing which are installed on PATH. `bivy run <agent>` starts one; the bundled
|
|
1207
|
+
// ones are installed with `bivy agents:install`. `--json` for machine-readable output.
|
|
1208
|
+
function cmdAgents(args = []) {
|
|
1209
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
1210
|
+
console.log("Usage: bivy agents [--json]\n\nList the agents Bivy can launch ('bivy run <agent>') and which are installed on PATH. 'bivy agents:install' installs the bundled ones.");
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const asJson = args.includes("--json");
|
|
1214
|
+
const rows = [...BUILTIN_TERMINAL_AGENTS.entries()].map(([id, meta]) => {
|
|
1215
|
+
if (meta.type === "native-pi") {
|
|
1216
|
+
return { id, label: meta.label, type: meta.type, command: null, installed: true, path: null };
|
|
1217
|
+
}
|
|
1218
|
+
const command = meta.command || id;
|
|
1219
|
+
const resolved = whichOnPath(command);
|
|
1220
|
+
return { id, label: meta.label, type: meta.type, command, installed: Boolean(resolved), path: resolved || null };
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
if (asJson) {
|
|
1224
|
+
console.log(JSON.stringify({ agents: rows }, null, 2));
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
console.log(c.bold("\n Agents") + c.dim(" (bivy run <agent> — 'bivy agents:install' adds the bundled ones)\n"));
|
|
1229
|
+
for (const row of rows) {
|
|
1230
|
+
const status = row.type === "native-pi"
|
|
1231
|
+
? c.green("● built-in")
|
|
1232
|
+
: row.installed ? c.green("● installed") : c.dim("○ not installed");
|
|
1233
|
+
const where = row.path ? c.dim(` ${row.path}`) : "";
|
|
1234
|
+
console.log(` ${c.cyan(row.id.padEnd(12))} ${String(row.label).padEnd(16)} ${status}${where}`);
|
|
1235
|
+
}
|
|
1236
|
+
console.log("");
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// `bivy token` — mint and print a device token for THIS node. Copy it to another
|
|
1240
|
+
// machine and `bivy nodes add <name> <this-url> --token <token>` to let it run
|
|
1241
|
+
// sessions here over a direct/tunnelled connection.
|
|
1242
|
+
async function cmdToken(args = []) {
|
|
1243
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
1244
|
+
console.log("Usage: bivy token\n\nMint and print a device token for this node. Copy it to another machine and run 'bivy nodes add <name> <this-url> --token <token>' there.");
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
const config = loadConfig();
|
|
1248
|
+
if (!(await ensureNodeRunning(config))) {
|
|
1249
|
+
console.error(c.red(`Could not start the Bivy node at ${url(config)}.`));
|
|
1250
|
+
process.exit(1);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
try {
|
|
1254
|
+
const token = await localDeviceToken(config);
|
|
1255
|
+
console.log(token);
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
console.error(c.red(error?.message || String(error)));
|
|
1258
|
+
process.exit(1);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// `bivy shim install|uninstall|status <agent>` — shadow an agent binary so that
|
|
1263
|
+
// starting it interactively launches the native TUI inside a Bivy PTY. See
|
|
1264
|
+
// the "agent shims" helpers above for the mechanism.
|
|
1265
|
+
async function cmdShim(args = []) {
|
|
1266
|
+
const [sub, ...rest] = args;
|
|
1267
|
+
|
|
1268
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
1269
|
+
console.log("Usage: bivy shim [status] | bivy shim install <agent> [--dir <dir>] [--headless \"<flags>\"] [--force] | bivy shim uninstall <agent>\n\nMake an interactive agent binary launch its native TUI in a Bivy PTY (remote-visible; resumable as chat).");
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
if (sub && sub !== "status" && sub !== "list" && sub !== "install" && sub !== "add" && sub !== "uninstall" && sub !== "remove" && sub !== "rm") {
|
|
1274
|
+
console.error(c.red(`Unknown shim subcommand: ${sub}. Usage: bivy shim install|uninstall|status <agent>`));
|
|
1275
|
+
process.exit(1);
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if (sub === "install" || sub === "add") {
|
|
1280
|
+
const agent = rest.find((a) => !a.startsWith("-"));
|
|
1281
|
+
if (!agent) {
|
|
1282
|
+
console.error(c.red("Usage: bivy shim install <agent> [--dir <dir>] [--headless \"<flags>\"] [--force]"));
|
|
1283
|
+
process.exit(1);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
const builtin = BUILTIN_TERMINAL_AGENTS.get(agent);
|
|
1287
|
+
if (builtin && builtin.type === "native-pi") {
|
|
1288
|
+
console.error(c.red(`"${agent}" is Bivy's own native runtime, not a standalone binary — nothing to shim. Just run 'bivy -a ${agent}'.`));
|
|
1289
|
+
process.exit(1);
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
const agentCmd = builtin?.command || agent;
|
|
1293
|
+
const shimDir = path.resolve(argValue(rest, "dir") || defaultShimDir());
|
|
1294
|
+
const force = rest.includes("--force");
|
|
1295
|
+
const headlessOverride = argValue(rest, "headless");
|
|
1296
|
+
const headlessFlags = headlessOverride
|
|
1297
|
+
? headlessOverride.trim().split(/\s+/).filter(Boolean)
|
|
1298
|
+
: (AGENT_HEADLESS_FLAGS[agent] || manifestHeadlessFlags(agent) || AGENT_HEADLESS_FLAGS.default);
|
|
1299
|
+
|
|
1300
|
+
const shimPath = path.join(shimDir, agent);
|
|
1301
|
+
const real = resolveRealBinary(agentCmd, shimDir);
|
|
1302
|
+
if (!real && !force) {
|
|
1303
|
+
console.error(c.red(`Could not find the real "${agentCmd}" on PATH (outside ${shimDir}).`));
|
|
1304
|
+
console.error(c.dim(`Install it first, or re-run with --force to install the shim anyway (headless passthrough will fail until the binary exists).`));
|
|
1305
|
+
process.exit(1);
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
// Guard against overwriting a non-shim file (e.g. the real binary itself).
|
|
1309
|
+
if (fs.existsSync(shimPath)) {
|
|
1310
|
+
const existing = fs.readFileSync(shimPath, "utf8");
|
|
1311
|
+
if (!existing.includes(SHIM_MARKER) && !force) {
|
|
1312
|
+
console.error(c.red(`${shimPath} already exists and is not a Bivy shim. Refusing to overwrite (use --dir <dir> or --force).`));
|
|
1313
|
+
process.exit(1);
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
1319
|
+
const tmp = `${shimPath}.tmp`;
|
|
1320
|
+
fs.writeFileSync(tmp, renderShim({ agent, agentCmd, shimDir, realFallback: real, headlessFlags }), { mode: 0o755 });
|
|
1321
|
+
fs.renameSync(tmp, shimPath);
|
|
1322
|
+
fs.chmodSync(shimPath, 0o755);
|
|
1323
|
+
|
|
1324
|
+
const data = loadShims();
|
|
1325
|
+
data.shims[agent] = { agent, agentCmd, shimPath, realPath: real, dir: shimDir, headlessFlags, installedAt: new Date().toISOString() };
|
|
1326
|
+
saveShims(data);
|
|
1327
|
+
|
|
1328
|
+
console.log(c.green(`Installed shim: ${c.cyan(agent)} → ${shimPath}`));
|
|
1329
|
+
console.log(c.dim(` real ${agentCmd}: ${real || "(not found — passthrough will fail)"}`));
|
|
1330
|
+
console.log(c.dim(` headless passthrough flags: ${headlessFlags.join(" ") || "(none)"} (plus any non-TTY invocation)`));
|
|
1331
|
+
|
|
1332
|
+
// The shim only fires if its dir wins over the real binary on the user's
|
|
1333
|
+
// interactive PATH. Rather than rely on the user's existing PATH order,
|
|
1334
|
+
// manage a marked block at the END of their shell rc (after version-manager
|
|
1335
|
+
// init) that force-moves the shim dir to the front.
|
|
1336
|
+
const sync = syncManagedPathBlock();
|
|
1337
|
+
if (sync.ok && sync.changed) {
|
|
1338
|
+
console.log(c.green(`\n Updated PATH in ${tildify(sync.file)} so '${agent}' resolves to the shim in new shells.`));
|
|
1339
|
+
} else if (sync.ok) {
|
|
1340
|
+
console.log(c.dim(`\n PATH already managed in ${tildify(sync.file)}.`));
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// Verify against a fresh interactive shell (reflects the rc we just wrote +
|
|
1344
|
+
// any version managers), not this process's doctored PATH.
|
|
1345
|
+
const winner = activeAgentPath(agent);
|
|
1346
|
+
const wins = path.resolve(winner || "") === shimPath;
|
|
1347
|
+
if (wins) {
|
|
1348
|
+
console.log(c.dim(` New shells: '${agent}' launches its native TUI in a Bivy PTY (remote-visible; resumable as chat). 'BIVY_SHIM_DISABLE=1 ${agent}' bypasses it.`));
|
|
1349
|
+
console.log(c.dim(` This shell: run 'hash -r' (zsh: 'rehash'), or restart it, to pick up the change now.`));
|
|
1350
|
+
} else if (!sync.ok) {
|
|
1351
|
+
console.log(c.yellow(`\n ⚠ Couldn't auto-manage your shell rc (shell: ${process.env.SHELL || "unknown"}).`));
|
|
1352
|
+
console.log(c.dim(` Put ${shimDir} at the front of PATH, after your version-manager init, e.g.:`));
|
|
1353
|
+
console.log(c.dim(` export PATH="${shimDir}:$PATH"`));
|
|
1354
|
+
console.log(c.dim(` Then restart your shell (or 'hash -r') and run '${agent}'.`));
|
|
1355
|
+
} else {
|
|
1356
|
+
console.log(c.yellow(`\n ⚠ ${shimDir} still isn't ahead of the real "${agent}" even after updating ${tildify(sync.file)}.`));
|
|
1357
|
+
console.log(c.dim(` Something later in your shell startup re-prepends it; move the Bivy block to the end of ${tildify(sync.file)}.`));
|
|
1358
|
+
}
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
if (sub === "uninstall" || sub === "remove" || sub === "rm") {
|
|
1363
|
+
const agent = rest.find((a) => !a.startsWith("-"));
|
|
1364
|
+
if (!agent) { console.error(c.red("Usage: bivy shim uninstall <agent>")); process.exit(1); return; }
|
|
1365
|
+
const data = loadShims();
|
|
1366
|
+
const entry = data.shims[agent];
|
|
1367
|
+
const shimPath = entry?.shimPath || path.join(argValue(rest, "dir") || defaultShimDir(), agent);
|
|
1368
|
+
let removed = false;
|
|
1369
|
+
try {
|
|
1370
|
+
if (fs.existsSync(shimPath) && fs.readFileSync(shimPath, "utf8").includes(SHIM_MARKER)) {
|
|
1371
|
+
fs.rmSync(shimPath);
|
|
1372
|
+
removed = true;
|
|
1373
|
+
} else if (fs.existsSync(shimPath)) {
|
|
1374
|
+
console.error(c.red(`${shimPath} is not a Bivy shim — leaving it untouched.`));
|
|
1375
|
+
}
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
console.error(c.red(`Could not remove ${shimPath}: ${error?.message || String(error)}`));
|
|
1378
|
+
}
|
|
1379
|
+
if (entry) { delete data.shims[agent]; saveShims(data); }
|
|
1380
|
+
console.log(removed ? c.green(`Removed shim: ${agent} (${shimPath})`) : c.yellow(`No Bivy shim removed for "${agent}".`));
|
|
1381
|
+
// Reconcile the managed PATH block: rebuild it from the remaining shims, or
|
|
1382
|
+
// remove it entirely once none are left.
|
|
1383
|
+
const sync = syncManagedPathBlock();
|
|
1384
|
+
if (sync.ok && sync.changed && !sync.present) {
|
|
1385
|
+
console.log(c.dim(` Removed the Bivy PATH block from ${tildify(sync.file)} (no shims left).`));
|
|
1386
|
+
} else if (sync.ok && sync.changed) {
|
|
1387
|
+
console.log(c.dim(` Updated the Bivy PATH block in ${tildify(sync.file)}.`));
|
|
1388
|
+
}
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// status / list (default)
|
|
1393
|
+
const data = loadShims();
|
|
1394
|
+
const agents = Object.keys(data.shims);
|
|
1395
|
+
console.log(c.bold("\n Agent shims") + c.dim(" (bivy shim install <agent>)\n"));
|
|
1396
|
+
if (agents.length === 0) {
|
|
1397
|
+
console.log(c.dim(" none — install one with 'bivy shim install claude'"));
|
|
1398
|
+
console.log("");
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
for (const agent of agents) {
|
|
1402
|
+
const entry = data.shims[agent];
|
|
1403
|
+
const onDisk = fs.existsSync(entry.shimPath);
|
|
1404
|
+
// Check against a fresh interactive shell so this reflects the user's real
|
|
1405
|
+
// PATH (after their rc + version managers), not this process's doctored one.
|
|
1406
|
+
const active = path.resolve(activeAgentPath(agent) || "") === path.resolve(entry.shimPath);
|
|
1407
|
+
const state = !onDisk ? c.red("○ missing") : active ? c.green("● active") : c.yellow("○ shadowed on PATH");
|
|
1408
|
+
console.log(` ${c.cyan(agent.padEnd(12))} ${state}`);
|
|
1409
|
+
console.log(c.dim(` shim: ${entry.shimPath}`));
|
|
1410
|
+
console.log(c.dim(` real: ${entry.realPath || "(unresolved)"}`));
|
|
1411
|
+
}
|
|
1412
|
+
console.log("");
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// `bivy takeover <termId|session-id>` — "continue as chat": stop the native TUI
|
|
1416
|
+
// running in a pinned run-terminal (started via the shim or `bivy run`) and
|
|
1417
|
+
// reopen its pinned session as a governed chat you can drive from the app.
|
|
1418
|
+
async function cmdTakeover(args = []) {
|
|
1419
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
1420
|
+
console.log("Usage: bivy takeover <termId|session-id>\n\nStop a pinned run-terminal's native TUI (started via the shim or 'bivy run') and reopen its session as a governed chat you can drive from the app.");
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
1424
|
+
const ref = args.find((a) => !a.startsWith("-"));
|
|
1425
|
+
if (!ref) { console.error(c.red("Usage: bivy takeover <termId|session-id>")); process.exit(1); return; }
|
|
1426
|
+
const config = loadConfig();
|
|
1427
|
+
if (!(await ensureNodeRunning(config))) {
|
|
1428
|
+
console.error(c.red(`Could not start the Bivy node at ${url(config)}.`));
|
|
1429
|
+
process.exit(1);
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
let token;
|
|
1433
|
+
try { token = await localDeviceToken(config); }
|
|
1434
|
+
catch (error) { console.error(c.red(error?.message || String(error))); process.exit(1); return; }
|
|
1435
|
+
// A bare UUID is a pinned session id; anything else is a run-terminal id.
|
|
1436
|
+
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ref);
|
|
1437
|
+
const body = isUuid ? { sessionId: ref } : { termId: ref };
|
|
1438
|
+
try {
|
|
1439
|
+
const data = await localApi(config, "/api/terminals/takeover", {
|
|
1440
|
+
method: "POST",
|
|
1441
|
+
headers: { authorization: `Bearer ${token}` },
|
|
1442
|
+
body: JSON.stringify(body),
|
|
1443
|
+
});
|
|
1444
|
+
console.log(c.green(`Took over → chat session ${c.cyan(data.sessionId)} (${data.runtimeId}).`));
|
|
1445
|
+
if (data.resumeCommand) console.log(c.dim(`Back to a terminal later with: ${data.resumeCommand}`));
|
|
1446
|
+
console.log(c.dim(`Open it in the app, or 'bivy resume ${data.sessionId}'.`));
|
|
1447
|
+
} catch (error) {
|
|
1448
|
+
console.error(c.red(error?.message || String(error)));
|
|
1449
|
+
process.exit(1);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// `bivy exec "<prompt>"` — one-shot headless run: create/resume a session, send
|
|
1454
|
+
// one prompt, print the final answer to stdout, exit. Working details go to
|
|
1455
|
+
// stderr so stdout is pipe-clean. Delegates to src/exec.ts (needs the WS client).
|
|
1456
|
+
// Deliberately does NOT intercept -h/--help here (unlike most other
|
|
1457
|
+
// subcommands, #113): the prompt is free text, and 'bivy exec --help' is a
|
|
1458
|
+
// legitimate (if odd) way to ask the agent about the --help flag.
|
|
1459
|
+
async function cmdExec(args = []) {
|
|
1460
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
1461
|
+
const config = loadConfig();
|
|
1462
|
+
if (!(await ensureNodeRunning(config))) {
|
|
1463
|
+
console.error(c.red(`Could not start the Bivy node at ${url(config)}.`));
|
|
1464
|
+
process.exit(1);
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
let token;
|
|
1468
|
+
try { token = await localDeviceToken(config); }
|
|
1469
|
+
catch (error) { console.error(c.red(error?.message || String(error))); process.exit(1); return; }
|
|
1470
|
+
const code = await run(nodeBin, [...nodeScriptArgs(execEntry), "--url", url(config), "--token", token, ...args], {
|
|
1471
|
+
cwd: repoRoot,
|
|
1472
|
+
env: startEnv(config),
|
|
1473
|
+
});
|
|
1474
|
+
process.exit(code);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// `bivy completions <bash|zsh|fish>` — print a shell completion script to eval or
|
|
1478
|
+
// install. Covers the top-level commands and the built-in agent ids.
|
|
1479
|
+
function cmdCompletions(args = []) {
|
|
1480
|
+
const shell = (args[0] || "").toLowerCase();
|
|
1481
|
+
const commands = [
|
|
1482
|
+
"run", "sessions", "ls", "resume", "promote", "nodes", "agents", "agents:install", "shim", "takeover", "token", "exec",
|
|
1483
|
+
"send", "kill", "setup", "start", "stop", "restart", "status", "doctor", "logs", "login",
|
|
1484
|
+
"update", "update:log", "open", "service", "secrets", "voice", "link", "relay:setup",
|
|
1485
|
+
"github:connect", "github:app-create", "github:app-connect", "github:app-sync", "prune", "uninstall", "help", "version",
|
|
1486
|
+
];
|
|
1487
|
+
const agents = [...BUILTIN_TERMINAL_AGENTS.keys()];
|
|
1488
|
+
|
|
1489
|
+
if (shell === "bash") {
|
|
1490
|
+
console.log(`# bivy bash completion — add to ~/.bashrc: eval "$(bivy completions bash)"
|
|
1491
|
+
_bivy_completions() {
|
|
1492
|
+
local cur prev
|
|
1493
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
1494
|
+
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
1495
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
1496
|
+
COMPREPLY=( $(compgen -W "${commands.join(" ")}" -- "$cur") )
|
|
1497
|
+
return
|
|
1498
|
+
fi
|
|
1499
|
+
case "$prev" in
|
|
1500
|
+
run) COMPREPLY=( $(compgen -W "${agents.join(" ")}" -- "$cur") );;
|
|
1501
|
+
esac
|
|
1502
|
+
}
|
|
1503
|
+
complete -F _bivy_completions bivy`);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
if (shell === "zsh") {
|
|
1507
|
+
console.log(`# bivy zsh completion — add to ~/.zshrc: eval "$(bivy completions zsh)"
|
|
1508
|
+
_bivy() {
|
|
1509
|
+
local -a cmds agents
|
|
1510
|
+
cmds=(${commands.map((x) => `'${x}'`).join(" ")})
|
|
1511
|
+
agents=(${agents.map((x) => `'${x}'`).join(" ")})
|
|
1512
|
+
if (( CURRENT == 2 )); then
|
|
1513
|
+
compadd -- $cmds
|
|
1514
|
+
elif [[ \${words[2]} == run ]]; then
|
|
1515
|
+
compadd -- $agents
|
|
1516
|
+
fi
|
|
1517
|
+
}
|
|
1518
|
+
compdef _bivy bivy`);
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
if (shell === "fish") {
|
|
1522
|
+
console.log(`# bivy fish completion — save to ~/.config/fish/completions/bivy.fish
|
|
1523
|
+
complete -c bivy -f
|
|
1524
|
+
complete -c bivy -n '__fish_use_subcommand' -a '${commands.join(" ")}'
|
|
1525
|
+
complete -c bivy -n '__fish_seen_subcommand_from run' -a '${agents.join(" ")}'`);
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
console.error(c.red("Usage: bivy completions <bash|zsh|fish>"));
|
|
1529
|
+
process.exit(1);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// `bivy run <agent>` — launch a native agent in a daemon-owned PTY and bind this
|
|
1533
|
+
// terminal to it. The session lives in the node, so it stays reachable from the
|
|
1534
|
+
// web app (and resumable with `bivy resume`) after you leave. With `--node
|
|
1535
|
+
// <name>` the session is started on another registered node instead.
|
|
1536
|
+
//
|
|
1537
|
+
// Future option: `bivy run --tmux <name>` could bind a pre-existing tmux/zellij/
|
|
1538
|
+
// screen session (one NOT started by Bivy) into a daemon-owned PTY, making it
|
|
1539
|
+
// remote-visible — the terminal entry point the removed `bivy attach --tmux`
|
|
1540
|
+
// used to provide. The server side is still in place (multiplexer discovery +
|
|
1541
|
+
// `terminal.open.mux`, used today by the web app); this would just re-add a CLI
|
|
1542
|
+
// flag on top of it.
|
|
1543
|
+
async function cmdRun(args = []) {
|
|
1544
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
1545
|
+
const { name, model, node, workspace, clone, rest } = extractRunFlags(args);
|
|
1546
|
+
// Bare `bivy` (empty rest) resolves to the configured default agent; an
|
|
1547
|
+
// explicit `bivy run <agent>` keeps that agent verbatim.
|
|
1548
|
+
const [agentIdArg, ...extraArgs] = rest;
|
|
1549
|
+
const agentId = agentIdArg || resolveDefaultAgent();
|
|
1550
|
+
|
|
1551
|
+
// A cloned/explicit workspace lives on THIS machine, so it only applies to the
|
|
1552
|
+
// local node. For a remote --node run the checkout would need to be made there.
|
|
1553
|
+
if (node && (clone !== undefined || workspace)) {
|
|
1554
|
+
console.error(c.red("--clone/--workspace apply to the local node; they can't be combined with --node (the workspace would only exist here)."));
|
|
1555
|
+
process.exit(1);
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
let clonedWorkspace;
|
|
1559
|
+
try { clonedWorkspace = resolveWorkspaceDir({ clone, workspace }); }
|
|
1560
|
+
catch (error) { console.error(c.red(error?.message || String(error))); process.exit(1); return; }
|
|
1561
|
+
// `--model` is recorded as run-terminal metadata AND passed through to the
|
|
1562
|
+
// agent (claude/codex/gemini/aider/… accept `--model <model>`). Not injected
|
|
1563
|
+
// for the raw `-- <command>` form, where the user controls the full command.
|
|
1564
|
+
if (model && agentId !== "--") extraArgs.unshift("--model", model);
|
|
1565
|
+
const resolved = await resolveRunSpec(agentId, extraArgs);
|
|
1566
|
+
if (resolved.error) {
|
|
1567
|
+
console.error(c.red(resolved.error));
|
|
1568
|
+
process.exit(1);
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// Pin a session id at launch when the agent's CLI supports it (and the caller
|
|
1573
|
+
// didn't already choose one), so the on-disk session is a known, deterministic
|
|
1574
|
+
// resume target — the anchor for later "continue as chat" adoption.
|
|
1575
|
+
const pinnedSessionId = pinRunSessionId(agentId, resolved.spec);
|
|
1576
|
+
if (pinnedSessionId) {
|
|
1577
|
+
console.log(c.dim(`session id ${pinnedSessionId} — resume in a terminal with '${agentId} --resume ${pinnedSessionId}'`));
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// Remote target: start the session on another node.
|
|
1581
|
+
if (node) {
|
|
1582
|
+
let target;
|
|
1583
|
+
try { target = await resolveNodeTarget(node); }
|
|
1584
|
+
catch (error) { console.error(c.red(error?.message || String(error))); process.exit(1); return; }
|
|
1585
|
+
|
|
1586
|
+
// Account node with no direct route: tunnel through the relay (the same
|
|
1587
|
+
// path a phone uses). The command must resolve on the REMOTE node's PATH, so
|
|
1588
|
+
// send the agent's bare command rather than this machine's absolute path.
|
|
1589
|
+
if (target.source === "relay") {
|
|
1590
|
+
if (agentId !== "--" && terminalAgent(agentId).agent?.type === "native-pi") {
|
|
1591
|
+
console.error(c.red("Pi runs only on the local node. For --node, pick an installed agent (e.g. claude, codex)."));
|
|
1592
|
+
process.exit(1);
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
const remoteCommand = agentId === "--" ? resolved.spec.command : (terminalAgent(agentId).agent?.command || resolved.spec.command);
|
|
1596
|
+
const spec = { ...resolved.spec, command: remoteCommand, name, model, workspace: undefined };
|
|
1597
|
+
console.log(c.dim(`Starting on ${c.cyan(target.name)} over the relay…`));
|
|
1598
|
+
await run(nodeBin, [
|
|
1599
|
+
...nodeScriptArgs(relayAttachEntry),
|
|
1600
|
+
"--node-id", target.nodeId,
|
|
1601
|
+
"--node-name", target.name,
|
|
1602
|
+
"--relay-config", relayConfigPath,
|
|
1603
|
+
"--attach-cmd", JSON.stringify(nodeScriptArgs(attachEntry)),
|
|
1604
|
+
"--run", JSON.stringify(spec),
|
|
1605
|
+
], { cwd: repoRoot, env: process.env });
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// Direct node: a reachable URL (LAN, Tailscale/VPN, SSH tunnel).
|
|
1610
|
+
if (!(await isUrlReachable(target.url))) {
|
|
1611
|
+
console.error(c.red(`Node "${node}" at ${target.url} is not reachable right now.`));
|
|
1612
|
+
process.exit(1);
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
const spec = { ...resolved.spec, name, model, workspace: undefined }; // workspace is the remote node's, not ours
|
|
1616
|
+
console.log(c.dim(`Starting on ${c.cyan(node)} (${target.url})…`));
|
|
1617
|
+
await run(nodeBin, [...nodeScriptArgs(attachEntry), "--url", target.url, ...(target.token ? ["--token", target.token] : []), "--run", JSON.stringify(spec)], {
|
|
1618
|
+
cwd: repoRoot,
|
|
1619
|
+
env: process.env,
|
|
1620
|
+
});
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
const config = loadConfig();
|
|
1625
|
+
if (!(await ensureNodeRunning(config))) {
|
|
1626
|
+
console.error(c.red(`Could not start the Bivy node at ${url(config)}.`));
|
|
1627
|
+
process.exit(1);
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
// No --clone/--workspace: start in the repo the user is standing in (see
|
|
1631
|
+
// defaultRunWorkspace). Announce it when it isn't the configured workspace, so
|
|
1632
|
+
// where the agent is rooted is never a silent surprise.
|
|
1633
|
+
const workspaceDir = clonedWorkspace || defaultRunWorkspace(config);
|
|
1634
|
+
if (!clonedWorkspace && workspaceDir !== (config.workspace || repoRoot)) {
|
|
1635
|
+
console.log(c.dim(`workspace ${workspaceDir}`));
|
|
1636
|
+
}
|
|
1637
|
+
const spec = { ...resolved.spec, name, model, workspace: workspaceDir };
|
|
1638
|
+
|
|
1639
|
+
let token;
|
|
1640
|
+
try { token = await localDeviceToken(config); }
|
|
1641
|
+
catch (error) { console.error(c.red(error?.message || String(error))); process.exit(1); return; }
|
|
1642
|
+
|
|
1643
|
+
process.exit(await run(nodeBin, [...nodeScriptArgs(attachEntry), "--url", url(config), "--token", token, "--run", JSON.stringify(spec)], {
|
|
1644
|
+
cwd: repoRoot,
|
|
1645
|
+
env: startEnv(config),
|
|
1646
|
+
}));
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// Short human age like "3m", "2h", "5d" from an ISO timestamp.
|
|
1650
|
+
function relativeTime(iso) {
|
|
1651
|
+
const t = new Date(iso).getTime();
|
|
1652
|
+
if (!Number.isFinite(t)) return "";
|
|
1653
|
+
const sec = Math.max(0, Math.round((Date.now() - t) / 1000));
|
|
1654
|
+
if (sec < 60) return `${sec}s`;
|
|
1655
|
+
const min = Math.round(sec / 60);
|
|
1656
|
+
if (min < 60) return `${min}m`;
|
|
1657
|
+
const hr = Math.round(min / 60);
|
|
1658
|
+
if (hr < 24) return `${hr}h`;
|
|
1659
|
+
return `${Math.round(hr / 24)}d`;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function truncate(text, max) {
|
|
1663
|
+
const s = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
1664
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
function statusGlyph(status) {
|
|
1668
|
+
if (status === "needs_action") return c.red("●");
|
|
1669
|
+
if (status === "working") return c.yellow("●");
|
|
1670
|
+
if (status === "idle") return c.green("●");
|
|
1671
|
+
return c.dim("○"); // saved / not open
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
async function fetchJson(baseUrl, pathName, token) {
|
|
1675
|
+
const res = await fetch(`${baseUrl}${pathName}`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
|
|
1676
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1677
|
+
return res.json();
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// `bivy sessions` / `bivy ls` — list ALL durable, resumable sessions (not just
|
|
1681
|
+
// the ones currently live/active) plus live `bivy run` terminals, then resume
|
|
1682
|
+
// the one you pick. Live terminals bind to their running PTY; durable sessions
|
|
1683
|
+
// relaunch through `bivy run` using the agent's own native resume. `bivy resume`
|
|
1684
|
+
// is the same list but jumps straight to a chosen (default: most recent) entry.
|
|
1685
|
+
// --json machine-readable list, no prompt
|
|
1686
|
+
// --limit/-n N cap how many saved sessions to show (default: unlimited — all of them)
|
|
1687
|
+
// <n> | <id> select non-interactively (index in the list, or a session id)
|
|
1688
|
+
async function cmdSessions(args = [], opts = {}) {
|
|
1689
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
1690
|
+
console.log(
|
|
1691
|
+
opts.autoResume
|
|
1692
|
+
? "Usage: bivy resume [n|id] [--json]\n\nResume a session directly (default: most recent). Alias for 'bivy sessions' that jumps straight to a chosen entry."
|
|
1693
|
+
: "Usage: bivy sessions [n|id] [--json] [--limit N]\n\nList recent sessions (live + saved) and resume one. Alias: ls.",
|
|
1694
|
+
);
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
1698
|
+
const json = args.includes("--json");
|
|
1699
|
+
const nArg = argValue(args, "limit") || argValue(args, "n");
|
|
1700
|
+
// No explicit --limit/-n: show every saved session, not just the most recent
|
|
1701
|
+
// 15. Sessions are never "active" vs "inactive" in storage (see listAllSessions
|
|
1702
|
+
// in src/server.ts) — they persist until deleted or pruned — so capping the
|
|
1703
|
+
// default view made older, perfectly resumable sessions invisible and
|
|
1704
|
+
// unresumable by index. (#71)
|
|
1705
|
+
const limit = resolveSessionsLimit(nArg);
|
|
1706
|
+
const selector = args.find((a) => !a.startsWith("-"));
|
|
1707
|
+
|
|
1708
|
+
const config = loadConfig();
|
|
1709
|
+
if (!(await ensureNodeRunning(config))) {
|
|
1710
|
+
console.error(c.red(`Could not start the Bivy node at ${url(config)}.`));
|
|
1711
|
+
process.exit(1);
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
let token;
|
|
1715
|
+
try { token = await localDeviceToken(config); }
|
|
1716
|
+
catch (error) { console.error(c.red(error?.message || String(error))); process.exit(1); return; }
|
|
1717
|
+
|
|
1718
|
+
const base = url(config);
|
|
1719
|
+
const [sessions, terminalsRes] = await Promise.all([
|
|
1720
|
+
fetchJson(base, "/api/sessions", token).catch(() => []),
|
|
1721
|
+
fetchJson(base, "/api/terminals", token).catch(() => ({ terminals: [] })),
|
|
1722
|
+
]);
|
|
1723
|
+
const terminals = Array.isArray(terminalsRes?.terminals) ? terminalsRes.terminals : [];
|
|
1724
|
+
|
|
1725
|
+
// Live PTYs first (attachable right now), then recent durable sessions.
|
|
1726
|
+
const liveItems = terminals.map((t) => ({
|
|
1727
|
+
kind: "live",
|
|
1728
|
+
ref: String(t.termId),
|
|
1729
|
+
agent: String(t.agent || t.label || "agent"),
|
|
1730
|
+
name: t.name || t.label || t.agent || "",
|
|
1731
|
+
model: t.model || "",
|
|
1732
|
+
workspace: t.workspace || "",
|
|
1733
|
+
status: "working",
|
|
1734
|
+
}));
|
|
1735
|
+
const savedItems = truncateSavedSessions(Array.isArray(sessions) ? sessions : [], limit)
|
|
1736
|
+
.map((s) => ({
|
|
1737
|
+
kind: "saved",
|
|
1738
|
+
ref: s.path || s.id,
|
|
1739
|
+
id: s.id,
|
|
1740
|
+
agent: String(s.agent || s.agentName || "agent"),
|
|
1741
|
+
agentName: s.agentName || s.agent || "",
|
|
1742
|
+
name: s.name || s.firstMessage || (s.id ? `session ${String(s.id).slice(0, 8)}` : "session"),
|
|
1743
|
+
model: "",
|
|
1744
|
+
workspace: s.workspace || "",
|
|
1745
|
+
status: s.status || "saved",
|
|
1746
|
+
when: relativeTime(s.lastActivityAt || s.updatedAt),
|
|
1747
|
+
costUsd: s.costUsd,
|
|
1748
|
+
}));
|
|
1749
|
+
const items = [...liveItems, ...savedItems];
|
|
1750
|
+
|
|
1751
|
+
if (json) { console.log(JSON.stringify(items, null, 2)); return; }
|
|
1752
|
+
if (items.length === 0) {
|
|
1753
|
+
console.log(c.dim("No sessions yet. Start one with 'bivy run <agent>'."));
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
const renderRow = (item, i) => {
|
|
1758
|
+
const idx = c.cyan(String(i + 1).padStart(2));
|
|
1759
|
+
const tag = item.kind === "live" ? c.green("live") : c.dim(item.when || "").padStart(4);
|
|
1760
|
+
const agent = c.bold((item.agentName || item.agent || "agent").padEnd(8).slice(0, 8));
|
|
1761
|
+
const meta = [item.model && c.dim(item.model), item.workspace && c.dim(`~${path.basename(item.workspace)}`)].filter(Boolean).join(" ");
|
|
1762
|
+
return ` ${idx} ${statusGlyph(item.status)} ${tag} ${agent} ${truncate(item.name, 48)} ${meta}`;
|
|
1763
|
+
};
|
|
1764
|
+
|
|
1765
|
+
// Non-interactive selection: an index (1-based) or a matching id/termId.
|
|
1766
|
+
let chosen;
|
|
1767
|
+
if (selector) {
|
|
1768
|
+
const asIndex = Number(selector);
|
|
1769
|
+
if (Number.isInteger(asIndex) && asIndex >= 1 && asIndex <= items.length) chosen = items[asIndex - 1];
|
|
1770
|
+
else chosen = items.find((it) => it.ref === selector || it.id === selector);
|
|
1771
|
+
if (!chosen) { console.error(c.red(`No session matching "${selector}".`)); process.exit(1); return; }
|
|
1772
|
+
} else if (opts.autoResume) {
|
|
1773
|
+
chosen = items[0]; // `bivy resume` with no arg → most recent
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
if (!chosen) {
|
|
1777
|
+
console.log(c.bold("\n Sessions") + c.dim(" (live agents + all saved)\n"));
|
|
1778
|
+
items.forEach((item, i) => console.log(renderRow(item, i)));
|
|
1779
|
+
const prompter = createPrompter();
|
|
1780
|
+
const answer = await prompter.ask("Resume which? (number, or Enter to cancel)", "");
|
|
1781
|
+
prompter.close();
|
|
1782
|
+
if (!answer) { console.log(c.dim("Cancelled.")); return; }
|
|
1783
|
+
const idx = Number(answer);
|
|
1784
|
+
if (!Number.isInteger(idx) || idx < 1 || idx > items.length) { console.error(c.red("Not a valid selection.")); process.exit(1); return; }
|
|
1785
|
+
chosen = items[idx - 1];
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
await resumeSessionItem(chosen, config, token);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// `bivy kill <id>` — stop a session or run-terminal by id. A live run-terminal's
|
|
1792
|
+
// PTY is closed; a durable session's current turn is aborted (add --delete to
|
|
1793
|
+
// also remove the saved session). Ids come from `bivy sessions`.
|
|
1794
|
+
async function cmdKill(args = []) {
|
|
1795
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
1796
|
+
console.log("Usage: bivy kill <id> [--delete]\n\nStop a session/terminal. Ids come from 'bivy sessions'. --delete (alias --rm) also removes a saved session.");
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
1800
|
+
const del = args.includes("--delete") || args.includes("--rm");
|
|
1801
|
+
const id = args.find((a) => !a.startsWith("-"));
|
|
1802
|
+
if (!id) { console.error(c.red("Usage: bivy kill <id> [--delete]")); process.exit(1); return; }
|
|
1803
|
+
|
|
1804
|
+
const config = loadConfig();
|
|
1805
|
+
if (!(await ensureNodeRunning(config))) { console.error(c.red(`Could not start the Bivy node at ${url(config)}.`)); process.exit(1); return; }
|
|
1806
|
+
let token;
|
|
1807
|
+
try { token = await localDeviceToken(config); }
|
|
1808
|
+
catch (error) { console.error(c.red(error?.message || String(error))); process.exit(1); return; }
|
|
1809
|
+
|
|
1810
|
+
const base = url(config);
|
|
1811
|
+
const post = (p, body) => fetch(`${base}${p}`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
1812
|
+
|
|
1813
|
+
// Live run-terminal?
|
|
1814
|
+
const terminals = await fetchJson(base, "/api/terminals", token).then((d) => d.terminals || []).catch(() => []);
|
|
1815
|
+
if (terminals.some((t) => String(t.termId) === id)) {
|
|
1816
|
+
const res = await post("/api/terminals/close", { termId: id });
|
|
1817
|
+
console.log(res.ok ? c.green(`Killed run-terminal ${id}.`) : c.red(`Failed to kill ${id} (${res.status}).`));
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// Otherwise treat it as a durable session id: abort the turn, optionally delete.
|
|
1822
|
+
const abort = await post("/api/session/abort", { sessionId: id });
|
|
1823
|
+
if (abort.ok) console.log(c.green(`Aborted session ${id}.`));
|
|
1824
|
+
else if (!del) { console.error(c.red(`No live terminal or session matching "${id}".`)); process.exit(1); return; }
|
|
1825
|
+
if (del) {
|
|
1826
|
+
const res = await post("/api/sessions/delete", { id });
|
|
1827
|
+
console.log(res.ok ? c.green(`Deleted session ${id}.`) : c.yellow(`Abort done; delete returned ${res.status}.`));
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// `bivy promote <id>` — continue a warm-replicated session on THIS node when its
|
|
1832
|
+
// owner went offline (docs/session-replication.md). Runs against the local node,
|
|
1833
|
+
// which does the control-plane epoch compare-and-set and materializes the replica
|
|
1834
|
+
// worktree. Run it on the standby node that holds the replica.
|
|
1835
|
+
async function cmdPromote(args = []) {
|
|
1836
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
1837
|
+
console.log("Usage: bivy promote <session-id>\n\nContinue a warm-replicated session on THIS node when its owner went offline. Run it on the standby node that holds the replica.");
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
1841
|
+
const id = args.find((a) => !a.startsWith("-"));
|
|
1842
|
+
if (!id) { console.error(c.red("Usage: bivy promote <session-id>")); process.exit(1); return; }
|
|
1843
|
+
const config = loadConfig();
|
|
1844
|
+
if (!(await ensureNodeRunning(config))) { console.error(c.red(`Could not start the Bivy node at ${url(config)}.`)); process.exit(1); return; }
|
|
1845
|
+
let token;
|
|
1846
|
+
try { token = await localDeviceToken(config); }
|
|
1847
|
+
catch (error) { console.error(c.red(error?.message || String(error))); process.exit(1); return; }
|
|
1848
|
+
const res = await fetch(`${url(config)}/api/session/promote`, {
|
|
1849
|
+
method: "POST",
|
|
1850
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
1851
|
+
body: JSON.stringify({ sessionId: id }),
|
|
1852
|
+
});
|
|
1853
|
+
if (res.ok) {
|
|
1854
|
+
const data = await res.json().catch(() => ({}));
|
|
1855
|
+
console.log(c.green(`Promoted ${id} to this node (epoch ${data.epoch ?? "?"}). Resume it with: bivy resume ${id}`));
|
|
1856
|
+
} else {
|
|
1857
|
+
const data = await res.json().catch(() => ({}));
|
|
1858
|
+
console.error(c.red(`Promotion failed (${res.status}): ${data.error || "unknown error"}`));
|
|
1859
|
+
process.exit(1);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
// `bivy send <id> "<message>"` — send a prompt to an existing session and stream
|
|
1864
|
+
// the reply. Thin wrapper over the headless exec client with --session.
|
|
1865
|
+
// Deliberately does NOT intercept -h/--help (see cmdExec above) — the message
|
|
1866
|
+
// is free text a caller may legitimately want to send verbatim.
|
|
1867
|
+
async function cmdSend(args = []) {
|
|
1868
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
1869
|
+
const id = args.find((a) => !a.startsWith("-"));
|
|
1870
|
+
if (!id) { console.error(c.red('Usage: bivy send <id> "<message>"')); process.exit(1); return; }
|
|
1871
|
+
const message = args.filter((a) => a !== id);
|
|
1872
|
+
if (message.length === 0) { console.error(c.red('Usage: bivy send <id> "<message>"')); process.exit(1); return; }
|
|
1873
|
+
|
|
1874
|
+
const config = loadConfig();
|
|
1875
|
+
if (!(await ensureNodeRunning(config))) { console.error(c.red(`Could not start the Bivy node at ${url(config)}.`)); process.exit(1); return; }
|
|
1876
|
+
let token;
|
|
1877
|
+
try { token = await localDeviceToken(config); }
|
|
1878
|
+
catch (error) { console.error(c.red(error?.message || String(error))); process.exit(1); return; }
|
|
1879
|
+
const code = await run(nodeBin, [...nodeScriptArgs(execEntry), "--url", url(config), "--token", token, "--session", id, ...message], {
|
|
1880
|
+
cwd: repoRoot,
|
|
1881
|
+
env: startEnv(config),
|
|
1882
|
+
});
|
|
1883
|
+
process.exit(code);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// Map a saved session's runtime id to the `bivy run` agent whose native CLI can
|
|
1887
|
+
// resume it in a terminal. Only agents with a real native resume qualify; other
|
|
1888
|
+
// runtimes (generic-cli, SDK-only) have no terminal resume and open in the web app.
|
|
1889
|
+
function nativeResumeAgent(runtimeId) {
|
|
1890
|
+
const id = (runtimeId || "").toLowerCase();
|
|
1891
|
+
if (id.includes("claude")) return "claude";
|
|
1892
|
+
if (id.includes("codex")) return "codex";
|
|
1893
|
+
return null;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// Resume a chosen session in a Bivy-managed, relay-visible PTY: bind the live PTY
|
|
1897
|
+
// for a running `bivy run` terminal, or relaunch a durable session through
|
|
1898
|
+
// `bivy run` using the agent's own native resume (e.g. `claude --resume <id>`).
|
|
1899
|
+
async function resumeSessionItem(item, config, token) {
|
|
1900
|
+
if (item.kind === "live") {
|
|
1901
|
+
console.log(c.dim(`Attaching to ${c.cyan(item.name || item.ref)}…`));
|
|
1902
|
+
await run(nodeBin, [...nodeScriptArgs(attachEntry), "--url", url(config), "--token", token, "--attach", item.ref], {
|
|
1903
|
+
cwd: repoRoot,
|
|
1904
|
+
env: startEnv(config),
|
|
1905
|
+
});
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
const agentId = nativeResumeAgent(item.agent);
|
|
1909
|
+
if (!agentId) {
|
|
1910
|
+
console.log(c.yellow(`"${item.name}" (${item.agentName || item.agent}) has no native terminal resume; open it in the web app with 'bivy open'.`));
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
const resumeArgs = agentResumeArgs(agentId, item.id || item.ref);
|
|
1914
|
+
const runArgs = [agentId, ...resumeArgs];
|
|
1915
|
+
if (item.workspace) runArgs.push("--workspace", item.workspace); // native resume finds the session by its original cwd
|
|
1916
|
+
console.log(c.dim(`Resuming ${c.cyan(item.name)} with ${agentId} ${resumeArgs.join(" ")}…`));
|
|
1917
|
+
await cmdRun(runArgs);
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// --- prune ------------------------------------------------------------------
|
|
1921
|
+
// `bivy prune` — reclaim disk by removing old data on THIS node: saved sessions
|
|
1922
|
+
// across every agent (the .bivy/metadata.json index + the owning agent's on-disk
|
|
1923
|
+
// transcript — Pi's .bivy/pi/sessions, Claude Code's ~/.claude/projects, Codex's
|
|
1924
|
+
// $CODEX_HOME/sessions), ephemeral `--clone` checkouts (.bivy/workspaces), and git
|
|
1925
|
+
// worktrees (*/.bivy/worktrees). Retention is by
|
|
1926
|
+
// count (--keep N: the newest N of each kind survive) and/or age (--older-than
|
|
1927
|
+
// <spec>: only items older than that are eligible). With both, an item is
|
|
1928
|
+
// removed only when it is BOTH beyond the newest N AND older than the age — the
|
|
1929
|
+
// safe intersection. Paths default to the installed node's data dir (appDir)
|
|
1930
|
+
// and the configured workspace; the primary workspace itself, Docker, and named
|
|
1931
|
+
// volumes are never touched. This is the node-side session/worktree cleanup;
|
|
1932
|
+
// deploy/prune.sh only reclaims Docker cruft on the host.
|
|
1933
|
+
|
|
1934
|
+
// Parse an age spec like "7d", "12h", "30m", "45s", "2w", or a plain number
|
|
1935
|
+
// (interpreted as days). Returns milliseconds, or null when the spec is invalid.
|
|
1936
|
+
function parseAgeSpec(spec) {
|
|
1937
|
+
const m = String(spec || "").trim().match(/^(\d+(?:\.\d+)?)\s*([smhdw]?)$/i);
|
|
1938
|
+
if (!m) return null;
|
|
1939
|
+
const n = Number(m[1]);
|
|
1940
|
+
if (!Number.isFinite(n) || n < 0) return null;
|
|
1941
|
+
const mult = { s: 1e3, m: 60e3, h: 3.6e6, d: 8.64e7, w: 6.048e8 }[(m[2] || "d").toLowerCase()];
|
|
1942
|
+
return n * mult;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// Direct children of `dir` as { path, mtimeMs }. type "dir" keeps only
|
|
1946
|
+
// directories (workspaces/worktrees); "any" also keeps files (session records,
|
|
1947
|
+
// which may be a `<id>.json` file or a `<id>/` directory).
|
|
1948
|
+
function pruneListEntries(dir, type = "any") {
|
|
1949
|
+
let ents;
|
|
1950
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
1951
|
+
const out = [];
|
|
1952
|
+
for (const ent of ents) {
|
|
1953
|
+
if (type === "dir" && !ent.isDirectory()) continue;
|
|
1954
|
+
const full = path.join(dir, ent.name);
|
|
1955
|
+
try { out.push({ path: full, mtimeMs: fs.statSync(full).mtimeMs }); } catch { /* vanished */ }
|
|
1956
|
+
}
|
|
1957
|
+
return out;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
// Bounded search for `*/.bivy/worktrees` roots under each scan root. Skips
|
|
1961
|
+
// node_modules/.git and stops at maxDepth so scanning a big workspace repo stays
|
|
1962
|
+
// cheap, and never descends into a worktrees dir it finds.
|
|
1963
|
+
function findWorktreeRoots(scanRoots, maxDepth = 6) {
|
|
1964
|
+
const roots = new Set();
|
|
1965
|
+
const walk = (dir, depth) => {
|
|
1966
|
+
if (depth > maxDepth) return;
|
|
1967
|
+
let ents;
|
|
1968
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
1969
|
+
for (const ent of ents) {
|
|
1970
|
+
if (!ent.isDirectory() || ent.name === "node_modules" || ent.name === ".git") continue;
|
|
1971
|
+
if (ent.name === "worktrees" && path.basename(dir) === ".bivy") { roots.add(path.join(dir, ent.name)); continue; }
|
|
1972
|
+
walk(path.join(dir, ent.name), depth + 1);
|
|
1973
|
+
}
|
|
1974
|
+
};
|
|
1975
|
+
for (const root of scanRoots) if (root && fs.existsSync(root)) walk(root, 0);
|
|
1976
|
+
return [...roots];
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// Removal set from a list of entries: sort newest-first, then keep those that are
|
|
1980
|
+
// BOTH beyond the newest `keep` AND older than `ageMs`. A null bound disables its
|
|
1981
|
+
// half of the test (so keep-only or age-only both work).
|
|
1982
|
+
function selectStale(entries, keep, ageMs, now) {
|
|
1983
|
+
return [...entries]
|
|
1984
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
1985
|
+
.filter((e, i) => (keep === null || i >= keep) && (ageMs === null || now - e.mtimeMs >= ageMs));
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// --- session pruning -------------------------------------------------------
|
|
1989
|
+
// Sessions are NOT plain files under one directory: since terminal-started
|
|
1990
|
+
// agents were adopted (shim → Bivy PTY → "continue as chat"), the sessions the
|
|
1991
|
+
// app lists come from `.bivy/metadata.json` (the durable, deletion-aware index
|
|
1992
|
+
// that drives the sidebar) plus each agent's own transcript store
|
|
1993
|
+
// (~/.claude/projects/<cwd>/<id>.jsonl, $CODEX_HOME/sessions/**/rollout-*-<id>.jsonl,
|
|
1994
|
+
// .bivy/pi/sessions/*.jsonl for Pi). The old prune only scanned .bivy/pi/sessions,
|
|
1995
|
+
// so it silently no-oped for every shim/native-agent session. These helpers make
|
|
1996
|
+
// `--sessions` operate on the metadata index (the real source of truth) and reclaim
|
|
1997
|
+
// the underlying transcript on disk.
|
|
1998
|
+
|
|
1999
|
+
function metadataFilePath(dataDir) { return path.join(dataDir, "metadata.json"); }
|
|
2000
|
+
|
|
2001
|
+
// All sessions recorded in metadata.json, as an array. Best-effort: a missing or
|
|
2002
|
+
// malformed file yields an empty list (nothing to prune). Selection logic lives
|
|
2003
|
+
// in ./prune-sessions.mjs (pure + unit-tested).
|
|
2004
|
+
function loadMetadataSessions(dataDir) {
|
|
2005
|
+
try {
|
|
2006
|
+
const parsed = JSON.parse(fs.readFileSync(metadataFilePath(dataDir), "utf8"));
|
|
2007
|
+
const sessions = parsed && typeof parsed.sessions === "object" && parsed.sessions ? parsed.sessions : {};
|
|
2008
|
+
return { file: parsed, sessions: Object.values(sessions) };
|
|
2009
|
+
} catch {
|
|
2010
|
+
return { file: null, sessions: [] };
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
// Candidate roots where the Claude Code SDK persists transcripts
|
|
2015
|
+
// (~/.claude/projects/<encoded-cwd>/<id>.jsonl). BIVY_CLAUDE_SESSIONS_DIR, when
|
|
2016
|
+
// set, overrides the store the daemon reads/writes.
|
|
2017
|
+
function claudeProjectRoots() {
|
|
2018
|
+
const roots = [path.join(os.homedir(), ".claude", "projects")];
|
|
2019
|
+
const override = process.env.BIVY_CLAUDE_SESSIONS_DIR?.trim();
|
|
2020
|
+
if (override) roots.push(path.join(override, "projects"), override);
|
|
2021
|
+
return roots;
|
|
2022
|
+
}
|
|
2023
|
+
function codexSessionsRoot() {
|
|
2024
|
+
return path.join(process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex"), "sessions");
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// Every on-disk transcript file for a session, located by id. Deleting these
|
|
2028
|
+
// reclaims the disk AND stops disk-listing adapters (e.g. Codex enumerates its
|
|
2029
|
+
// rollouts from disk) from re-surfacing a session we've forgotten. Best-effort:
|
|
2030
|
+
// a store we can't read is skipped, not fatal.
|
|
2031
|
+
function nativeTranscriptFiles(session) {
|
|
2032
|
+
const files = new Set();
|
|
2033
|
+
const id = String(session.id || "");
|
|
2034
|
+
// Pi and any runtime that records an absolute transcript path.
|
|
2035
|
+
if (session.path && path.isAbsolute(session.path) && fs.existsSync(session.path)) files.add(path.resolve(session.path));
|
|
2036
|
+
if (!id) return [...files];
|
|
2037
|
+
const runtime = String(session.runtimeId || "");
|
|
2038
|
+
if (/claude/i.test(runtime)) {
|
|
2039
|
+
for (const projects of claudeProjectRoots()) {
|
|
2040
|
+
let dirs;
|
|
2041
|
+
try { dirs = fs.readdirSync(projects, { withFileTypes: true }); } catch { continue; }
|
|
2042
|
+
for (const d of dirs) {
|
|
2043
|
+
if (!d.isDirectory()) continue;
|
|
2044
|
+
const f = path.join(projects, d.name, `${id}.jsonl`);
|
|
2045
|
+
if (fs.existsSync(f)) files.add(f);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
if (/codex/i.test(runtime)) {
|
|
2050
|
+
// rollout-<timestamp>-<uuid>.jsonl nested under sessions/YYYY/MM/DD.
|
|
2051
|
+
const stack = [codexSessionsRoot()];
|
|
2052
|
+
while (stack.length) {
|
|
2053
|
+
const dir = stack.pop();
|
|
2054
|
+
let ents;
|
|
2055
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
2056
|
+
for (const e of ents) {
|
|
2057
|
+
const full = path.join(dir, e.name);
|
|
2058
|
+
if (e.isDirectory()) stack.push(full);
|
|
2059
|
+
else if (e.isFile() && /\.jsonl$/.test(e.name) && e.name.includes(id)) files.add(full);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
return [...files];
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// Offline removal of sessions from metadata.json (used when the daemon isn't
|
|
2067
|
+
// reachable, so there's no in-memory store to race with). Rewrites the file
|
|
2068
|
+
// atomically in the same shape MetadataStore.save() uses.
|
|
2069
|
+
function removeSessionsFromMetadata(dataDir, ids) {
|
|
2070
|
+
const filePath = metadataFilePath(dataDir);
|
|
2071
|
+
let parsed;
|
|
2072
|
+
try { parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return; }
|
|
2073
|
+
if (!parsed || typeof parsed.sessions !== "object" || !parsed.sessions) return;
|
|
2074
|
+
let changed = false;
|
|
2075
|
+
for (const id of ids) {
|
|
2076
|
+
if (parsed.sessions[id]) { delete parsed.sessions[id]; changed = true; }
|
|
2077
|
+
}
|
|
2078
|
+
if (!changed) return;
|
|
2079
|
+
const tmp = `${filePath}.tmp`;
|
|
2080
|
+
const fd = fs.openSync(tmp, "w", 0o600);
|
|
2081
|
+
try {
|
|
2082
|
+
fs.writeSync(fd, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
2083
|
+
fs.fsyncSync(fd);
|
|
2084
|
+
} finally {
|
|
2085
|
+
fs.closeSync(fd);
|
|
2086
|
+
}
|
|
2087
|
+
fs.renameSync(tmp, filePath);
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// Delete a selected set of sessions. When the daemon is reachable we POST to
|
|
2091
|
+
// /api/sessions/delete so the server updates its in-memory index, drops sidecars
|
|
2092
|
+
// and broadcasts the removal to connected clients — and it refuses live/busy
|
|
2093
|
+
// sessions, so we only reclaim the native transcript once the server has agreed
|
|
2094
|
+
// to forget the session. When the daemon is down we edit metadata.json directly.
|
|
2095
|
+
// Returns the number of sessions actually removed.
|
|
2096
|
+
async function deletePrunedSessions(sessions, dataDir, { dryRun, json }) {
|
|
2097
|
+
if (dryRun) return sessions.length;
|
|
2098
|
+
|
|
2099
|
+
const config = loadConfig();
|
|
2100
|
+
const base = url(config);
|
|
2101
|
+
const reachable = await isUrlReachable(base);
|
|
2102
|
+
let token = null;
|
|
2103
|
+
if (reachable) {
|
|
2104
|
+
try { token = await localDeviceToken(config); } catch { token = null; }
|
|
2105
|
+
}
|
|
2106
|
+
const useApi = reachable && !!token;
|
|
2107
|
+
|
|
2108
|
+
const short = (id) => String(id ?? "").slice(0, 8);
|
|
2109
|
+
let removed = 0;
|
|
2110
|
+
const offlineIds = [];
|
|
2111
|
+
|
|
2112
|
+
for (const s of sessions) {
|
|
2113
|
+
if (useApi) {
|
|
2114
|
+
let ok = false;
|
|
2115
|
+
try {
|
|
2116
|
+
// Send an absolute transcript path only (Pi) — the server unlinks it and
|
|
2117
|
+
// rejects a non-absolute path with no open record. Native runtimes store
|
|
2118
|
+
// `path` as a bare session id, so delete those by id and reclaim their
|
|
2119
|
+
// transcript ourselves below.
|
|
2120
|
+
const abs = s.path && path.isAbsolute(s.path) ? { path: s.path } : {};
|
|
2121
|
+
const res = await fetch(`${base}/api/sessions/delete`, {
|
|
2122
|
+
method: "POST",
|
|
2123
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2124
|
+
body: JSON.stringify({ id: s.id, ...abs }),
|
|
2125
|
+
});
|
|
2126
|
+
ok = res.ok;
|
|
2127
|
+
if (!ok && !json) console.log(c.yellow(` skipped session ${short(s.id)} (server returned ${res.status})`));
|
|
2128
|
+
} catch (err) {
|
|
2129
|
+
if (!json) console.log(c.yellow(` could not delete session ${short(s.id)}: ${err instanceof Error ? err.message : String(err)}`));
|
|
2130
|
+
continue;
|
|
2131
|
+
}
|
|
2132
|
+
if (!ok) continue; // busy/working session left intact, transcript untouched
|
|
2133
|
+
} else {
|
|
2134
|
+
offlineIds.push(s.id);
|
|
2135
|
+
}
|
|
2136
|
+
// Reclaim the agent's on-disk transcript(s). The server's delete endpoint
|
|
2137
|
+
// deliberately leaves native (Claude/Codex) transcripts in place, and Codex
|
|
2138
|
+
// re-lists sessions from disk — so removing the file is what actually frees
|
|
2139
|
+
// the space and keeps the session from reappearing.
|
|
2140
|
+
for (const file of nativeTranscriptFiles(s)) {
|
|
2141
|
+
try { fs.rmSync(file, { force: true }); } catch { /* best effort */ }
|
|
2142
|
+
}
|
|
2143
|
+
removed++;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
if (offlineIds.length) removeSessionsFromMetadata(dataDir, offlineIds);
|
|
2147
|
+
return removed;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
function shortAge(mtimeMs, now) {
|
|
2151
|
+
const sec = Math.max(0, Math.round((now - mtimeMs) / 1000));
|
|
2152
|
+
if (sec < 60) return `${sec}s`;
|
|
2153
|
+
const min = Math.round(sec / 60);
|
|
2154
|
+
if (min < 60) return `${min}m`;
|
|
2155
|
+
const hr = Math.round(min / 60);
|
|
2156
|
+
return hr < 24 ? `${hr}h` : `${Math.round(hr / 24)}d`;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
// Worktree dirs currently backing a live agent on this node. The daemon owns
|
|
2160
|
+
// every session runtime and run-terminal PTY, so when it's reachable we ask it
|
|
2161
|
+
// which worktrees are in use and never prune those — no matter how the keep/age
|
|
2162
|
+
// policy scores them (a long-running-but-quiet agent can have an old mtime). When
|
|
2163
|
+
// the daemon is down there are no live runtimes at all, so an mtime-only prune is
|
|
2164
|
+
// already safe. Returns { reachable, paths } with paths a Set of resolved dirs.
|
|
2165
|
+
async function liveWorktreePaths(config) {
|
|
2166
|
+
const base = url(config);
|
|
2167
|
+
if (!(await isUrlReachable(base))) return { reachable: false, paths: new Set() };
|
|
2168
|
+
let token = null;
|
|
2169
|
+
try { token = await localDeviceToken(config); } catch { token = null; }
|
|
2170
|
+
const [sessions, terminalsRes] = await Promise.all([
|
|
2171
|
+
fetchJson(base, "/api/sessions", token).catch(() => []),
|
|
2172
|
+
fetchJson(base, "/api/terminals", token).catch(() => ({ terminals: [] })),
|
|
2173
|
+
]);
|
|
2174
|
+
const paths = new Set();
|
|
2175
|
+
const add = (p) => { if (p && typeof p === "string") paths.add(path.resolve(p)); };
|
|
2176
|
+
// Open sessions (a live in-memory runtime) that are backed by a worktree.
|
|
2177
|
+
for (const s of Array.isArray(sessions) ? sessions : []) {
|
|
2178
|
+
if (s && s.open) add(s.bivySession?.worktree || s.worktree);
|
|
2179
|
+
}
|
|
2180
|
+
// Run-terminals (native TUIs / `bivy run`): their workspace is the PTY's cwd,
|
|
2181
|
+
// which for a worktree-backed session is the worktree itself.
|
|
2182
|
+
const terminals = Array.isArray(terminalsRes?.terminals) ? terminalsRes.terminals : [];
|
|
2183
|
+
for (const t of terminals) add(t?.workspace);
|
|
2184
|
+
return { reachable: true, paths };
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
// True when worktree dir `entryPath` is — or contains, or sits inside — a path a
|
|
2188
|
+
// live agent is using, so pruning it would pull the rug from a running session.
|
|
2189
|
+
function isLiveWorktree(entryPath, livePaths) {
|
|
2190
|
+
const e = path.resolve(entryPath);
|
|
2191
|
+
for (const l of livePaths) {
|
|
2192
|
+
if (l === e || l.startsWith(e + path.sep) || e.startsWith(l + path.sep)) return true;
|
|
2193
|
+
}
|
|
2194
|
+
return false;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
function printPruneHelp() {
|
|
2198
|
+
console.log(`
|
|
2199
|
+
${c.bold("bivy prune")} — remove old sessions, --clone workspaces, and git worktrees on this node
|
|
2200
|
+
|
|
2201
|
+
${c.cyan("bivy prune --keep 10")} Keep the newest 10 of each kind, remove the rest
|
|
2202
|
+
${c.cyan("bivy prune --older-than 7d")} Remove anything older than 7 days (also 12h, 30m, 2w, or a bare number = days)
|
|
2203
|
+
${c.cyan("bivy prune --keep 5 --older-than 14d")} Keep newest 5 AND anything newer than 14d (safe intersection)
|
|
2204
|
+
${c.cyan("bivy prune --dry-run")} Show what would be removed, delete nothing
|
|
2205
|
+
|
|
2206
|
+
Scope (default: all three)
|
|
2207
|
+
--sessions saved sessions across all agents (metadata index +
|
|
2208
|
+
the agent's transcript: Pi, Claude Code, Codex, …)
|
|
2209
|
+
--workspaces ephemeral --clone checkouts (.bivy/workspaces)
|
|
2210
|
+
--worktrees git worktrees (*/.bivy/worktrees)
|
|
2211
|
+
|
|
2212
|
+
Sessions: the newest N non-empty sessions (any agent) survive; empty/untitled
|
|
2213
|
+
and live sessions are handled specially — live ones are never removed. Routed
|
|
2214
|
+
through the running node when reachable so its session index stays consistent.
|
|
2215
|
+
|
|
2216
|
+
Worktrees: a worktree backing a live agent (an open session or a run-terminal)
|
|
2217
|
+
is never pruned while the node is running, regardless of --keep/--older-than —
|
|
2218
|
+
only idle worktrees are removed. If the node is down, nothing is live to guard.
|
|
2219
|
+
|
|
2220
|
+
Paths (default to the installed node)
|
|
2221
|
+
--data-dir <dir> node data dir (default: this install's .bivy, or $BIVY_DATA_DIR)
|
|
2222
|
+
--workspace <dir> also scan this workspace for worktrees (default: configured workspace)
|
|
2223
|
+
|
|
2224
|
+
Safety
|
|
2225
|
+
--dry-run list what would be removed and delete nothing
|
|
2226
|
+
-y, --yes skip the confirmation prompt
|
|
2227
|
+
--json machine-readable output (non-interactive; still deletes unless --dry-run)
|
|
2228
|
+
|
|
2229
|
+
With neither --keep nor --older-than, the default keeps the newest 10 sessions
|
|
2230
|
+
but only the newest 3 workspaces and 3 worktrees — those are full checkouts
|
|
2231
|
+
(a ~1GB node_modules each), so keeping 10 reclaims little. Pass --keep N to
|
|
2232
|
+
apply one count to every kind.
|
|
2233
|
+
`);
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
async function cmdPrune(args = []) {
|
|
2237
|
+
if (args.includes("-h") || args.includes("--help")) { printPruneHelp(); return; }
|
|
2238
|
+
|
|
2239
|
+
const json = args.includes("--json");
|
|
2240
|
+
const dryRun = args.includes("--dry-run");
|
|
2241
|
+
const yes = args.includes("-y") || args.includes("--yes") || json;
|
|
2242
|
+
|
|
2243
|
+
// Retention policy: --keep N and/or --older-than <spec>. Default keep 10 when
|
|
2244
|
+
// neither is given, so a bare `bivy prune` can never wipe everything.
|
|
2245
|
+
const keepArg = argValue(args, "keep");
|
|
2246
|
+
const ageArg = argValue(args, "older-than");
|
|
2247
|
+
let keep = null;
|
|
2248
|
+
if (keepArg !== "") {
|
|
2249
|
+
const n = Number(keepArg);
|
|
2250
|
+
if (!Number.isFinite(n) || n < 0) { console.error(c.red("--keep must be a non-negative integer.")); process.exit(1); return; }
|
|
2251
|
+
keep = Math.floor(n);
|
|
2252
|
+
}
|
|
2253
|
+
let ageMs = null;
|
|
2254
|
+
if (ageArg !== "") {
|
|
2255
|
+
ageMs = parseAgeSpec(ageArg);
|
|
2256
|
+
if (ageMs === null) { console.error(c.red("--older-than must be like 7d, 12h, 30m, 2w, or a plain number of days.")); process.exit(1); return; }
|
|
2257
|
+
}
|
|
2258
|
+
// Default retention when the user passes neither --keep nor --older-than.
|
|
2259
|
+
// Worktrees and ephemeral --clone workspaces are full checkouts (each often a
|
|
2260
|
+
// ~1GB node_modules), so a uniform keep-10 reclaims almost nothing on a node
|
|
2261
|
+
// that never held more than 10 of them — the exact case where disk creeps up.
|
|
2262
|
+
// Give the heavy, regenerable kinds a tighter default than cheap session
|
|
2263
|
+
// transcripts; a worktree's branch/commits live in the repo's .git, not the
|
|
2264
|
+
// worktree, and live worktrees are guarded separately, so this only removes
|
|
2265
|
+
// idle, disposable checkouts. An explicit --keep/--older-than still applies
|
|
2266
|
+
// uniformly to every kind.
|
|
2267
|
+
const usingDefaultPolicy = keep === null && ageMs === null;
|
|
2268
|
+
const DEFAULT_KEEP = { sessions: 10, workspaces: 3, worktrees: 3 };
|
|
2269
|
+
const keepFor = (kind) => (usingDefaultPolicy ? DEFAULT_KEEP[kind] : keep);
|
|
2270
|
+
|
|
2271
|
+
// Category selection: default to all three when no category flag is given.
|
|
2272
|
+
const flagged = ["--sessions", "--workspaces", "--worktrees"].filter((f) => args.includes(f));
|
|
2273
|
+
const doSessions = flagged.length === 0 || flagged.includes("--sessions");
|
|
2274
|
+
const doWorkspaces = flagged.length === 0 || flagged.includes("--workspaces");
|
|
2275
|
+
const doWorktrees = flagged.length === 0 || flagged.includes("--worktrees");
|
|
2276
|
+
|
|
2277
|
+
// Paths come from the real install: the node data dir (appDir / $BIVY_DATA_DIR)
|
|
2278
|
+
// and the configured workspace folder (cli.json), unless overridden.
|
|
2279
|
+
const dataDir = argValue(args, "data-dir") || process.env.BIVY_DATA_DIR || appDir;
|
|
2280
|
+
const config = loadConfig();
|
|
2281
|
+
const wsArg = argValue(args, "workspace");
|
|
2282
|
+
const workspace = wsArg ? path.resolve(wsArg.replace(/^~(?=$|\/)/, os.homedir())) : config.workspace;
|
|
2283
|
+
|
|
2284
|
+
const now = Date.now();
|
|
2285
|
+
const plan = [];
|
|
2286
|
+
let worktreeGuard = { reachable: false, protected: 0 };
|
|
2287
|
+
if (doSessions) {
|
|
2288
|
+
// Sessions live in the metadata index (all agents) plus each agent's own
|
|
2289
|
+
// transcript store — not just .bivy/pi/sessions. Select from metadata so
|
|
2290
|
+
// shim/native (Claude Code, Codex, …) sessions are actually covered; the old
|
|
2291
|
+
// pi-sessions-only scan silently no-oped for every terminal-started session.
|
|
2292
|
+
const staleSessions = selectStaleSessions(loadMetadataSessions(dataDir).sessions, keepFor("sessions"), ageMs, now);
|
|
2293
|
+
plan.push({
|
|
2294
|
+
kind: "sessions",
|
|
2295
|
+
root: metadataFilePath(dataDir),
|
|
2296
|
+
remove: staleSessions.map((s) => {
|
|
2297
|
+
// Label honestly: a session with a first message but no name shows that
|
|
2298
|
+
// message; one with no name AND no message is an empty shell, not lost
|
|
2299
|
+
// work — call it "(empty)" rather than "untitled" so a list full of them
|
|
2300
|
+
// reads for what it is.
|
|
2301
|
+
const title = String(s.name || s.firstMessage || "").trim();
|
|
2302
|
+
const label = title || (Number(s.messageCount ?? 0) > 0 ? "untitled" : "(empty)");
|
|
2303
|
+
return {
|
|
2304
|
+
path: `${String(s.id || "?").slice(0, 8)} · ${label.slice(0, 40)}`,
|
|
2305
|
+
mtimeMs: sessionActivityMs(s),
|
|
2306
|
+
session: s,
|
|
2307
|
+
};
|
|
2308
|
+
}),
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
if (doWorkspaces) {
|
|
2312
|
+
const dir = path.join(dataDir, "workspaces");
|
|
2313
|
+
plan.push({ kind: "workspaces", root: dir, remove: selectStale(pruneListEntries(dir, "dir"), keepFor("workspaces"), ageMs, now) });
|
|
2314
|
+
}
|
|
2315
|
+
if (doWorktrees) {
|
|
2316
|
+
// Keep the newest N worktrees overall (across all roots), matching the node
|
|
2317
|
+
// prune script. Scan roots: the data dir plus the configured workspace repo.
|
|
2318
|
+
const roots = findWorktreeRoots([dataDir, workspace].filter(Boolean));
|
|
2319
|
+
const all = roots.flatMap((r) => pruneListEntries(r, "dir"));
|
|
2320
|
+
const stale = selectStale(all, keepFor("worktrees"), ageMs, now);
|
|
2321
|
+
// Guard: never delete a worktree a live agent is using. Ask the daemon what's
|
|
2322
|
+
// live (it owns every runtime and PTY); protected worktrees drop out of the
|
|
2323
|
+
// removal set entirely, so an aggressive --older-than can't nuke a running
|
|
2324
|
+
// agent's checkout. Node down ⇒ nothing live ⇒ policy alone is safe.
|
|
2325
|
+
const live = await liveWorktreePaths(config);
|
|
2326
|
+
const remove = stale.filter((e) => !isLiveWorktree(e.path, live.paths));
|
|
2327
|
+
worktreeGuard = { reachable: live.reachable, protected: stale.length - remove.length };
|
|
2328
|
+
plan.push({ kind: "worktrees", root: roots.join(", ") || "(none found)", remove });
|
|
2329
|
+
}
|
|
2330
|
+
const total = plan.reduce((n, p) => n + p.remove.length, 0);
|
|
2331
|
+
|
|
2332
|
+
const policy = usingDefaultPolicy
|
|
2333
|
+
? `defaults — keep newest ${DEFAULT_KEEP.sessions} sessions · ${DEFAULT_KEEP.worktrees} workspaces/worktrees`
|
|
2334
|
+
: [keep !== null ? `keep newest ${keep}` : null, ageMs !== null ? `older than ${ageArg}` : null].filter(Boolean).join(" & ");
|
|
2335
|
+
|
|
2336
|
+
if (!json) {
|
|
2337
|
+
console.log(c.bold("\n bivy prune") + c.dim(` (${dryRun ? "dry run — " : ""}${policy})\n`));
|
|
2338
|
+
console.log(c.dim(` data dir: ${dataDir}`));
|
|
2339
|
+
console.log(c.dim(` workspace: ${workspace || "(none)"}\n`));
|
|
2340
|
+
for (const p of plan) {
|
|
2341
|
+
const label = p.kind.padEnd(11);
|
|
2342
|
+
if (p.remove.length === 0) { console.log(` ${label}${c.dim("nothing to remove")}`); continue; }
|
|
2343
|
+
console.log(` ${label}${c.yellow(String(p.remove.length))} to remove`);
|
|
2344
|
+
for (const e of p.remove.slice(0, 6)) console.log(` ${c.dim(shortAge(e.mtimeMs, now).padStart(4))} ${c.dim(path.basename(e.path))}`);
|
|
2345
|
+
if (p.remove.length > 6) console.log(c.dim(` …and ${p.remove.length - 6} more`));
|
|
2346
|
+
}
|
|
2347
|
+
console.log("");
|
|
2348
|
+
if (doWorktrees && worktreeGuard.protected > 0) {
|
|
2349
|
+
console.log(c.dim(` Protected ${c.green(String(worktreeGuard.protected))} worktree(s) in use by a live agent — never pruned.`));
|
|
2350
|
+
}
|
|
2351
|
+
if (doWorktrees && !worktreeGuard.reachable) {
|
|
2352
|
+
console.log(c.dim(" Node not reachable — no live agents to guard; worktrees pruned by policy only."));
|
|
2353
|
+
} else {
|
|
2354
|
+
console.log(c.dim(" Live sessions/worktrees are protected automatically; empty/untitled sessions can still go — prune when idle for the cleanest result."));
|
|
2355
|
+
}
|
|
2356
|
+
console.log("");
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
if (total === 0) {
|
|
2360
|
+
if (json) console.log(JSON.stringify({ dataDir, workspace, keep, keepByKind: { sessions: keepFor("sessions"), workspaces: keepFor("workspaces"), worktrees: keepFor("worktrees") }, olderThanMs: ageMs, dryRun, total: 0, removed: 0, worktreesProtected: worktreeGuard.protected }, null, 2));
|
|
2361
|
+
else console.log(c.green("Nothing to prune. ✓"));
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
if (!dryRun && !yes) {
|
|
2366
|
+
const rl = createPrompter();
|
|
2367
|
+
const ok = await rl.askYesNo(`Remove ${total} item(s)? This cannot be undone.`, false);
|
|
2368
|
+
rl.close();
|
|
2369
|
+
if (!ok) { console.log(c.dim("Cancelled.")); return; }
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
let removed = 0;
|
|
2373
|
+
const touchedRepos = new Set();
|
|
2374
|
+
for (const p of plan) {
|
|
2375
|
+
// Sessions are not plain files — they need agent-aware, daemon-consistent
|
|
2376
|
+
// deletion (handled below), so skip them in the generic file-removal loop.
|
|
2377
|
+
if (p.kind === "sessions") continue;
|
|
2378
|
+
for (const e of p.remove) {
|
|
2379
|
+
if (dryRun) { removed++; continue; }
|
|
2380
|
+
try {
|
|
2381
|
+
fs.rmSync(e.path, { recursive: true, force: true });
|
|
2382
|
+
removed++;
|
|
2383
|
+
// A worktree lives at <repoRoot>/.bivy/worktrees/<slug>; remember its repo
|
|
2384
|
+
// root so we can drop the now-dangling git registration afterwards.
|
|
2385
|
+
if (p.kind === "worktrees") touchedRepos.add(path.dirname(path.dirname(path.dirname(e.path))));
|
|
2386
|
+
} catch (err) {
|
|
2387
|
+
if (!json) console.log(c.yellow(` could not remove ${e.path}: ${err instanceof Error ? err.message : String(err)}`));
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
if (!dryRun && touchedRepos.size && commandExists("git")) {
|
|
2392
|
+
for (const repo of touchedRepos) runQuiet("git", ["-C", repo, "worktree", "prune"]);
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// Sessions: route deletion through the running daemon when it's reachable, so
|
|
2396
|
+
// its in-memory metadata store (re-persisted on every event) can't resurrect
|
|
2397
|
+
// rows we delete on disk; fall back to a direct metadata rewrite when the node
|
|
2398
|
+
// is down. Either path also removes the agent's on-disk transcript to reclaim space.
|
|
2399
|
+
const sessionPlan = plan.find((p) => p.kind === "sessions");
|
|
2400
|
+
if (sessionPlan?.remove.length) {
|
|
2401
|
+
removed += await deletePrunedSessions(sessionPlan.remove.map((e) => e.session), dataDir, { dryRun, json });
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
if (json) {
|
|
2405
|
+
console.log(JSON.stringify({ dataDir, workspace, keep, keepByKind: { sessions: keepFor("sessions"), workspaces: keepFor("workspaces"), worktrees: keepFor("worktrees") }, olderThanMs: ageMs, dryRun, total, removed, worktreesProtected: worktreeGuard.protected, plan: plan.map((p) => ({ kind: p.kind, removed: p.remove.length })) }, null, 2));
|
|
2406
|
+
} else if (dryRun) {
|
|
2407
|
+
console.log(c.dim(`Dry run: ${removed} item(s) would be removed. Re-run without --dry-run to delete.`));
|
|
2408
|
+
} else {
|
|
2409
|
+
console.log(c.green(`Removed ${removed} item(s).`));
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
async function isReachable(config) {
|
|
2414
|
+
try {
|
|
2415
|
+
const controller = new AbortController();
|
|
2416
|
+
const timer = setTimeout(() => controller.abort(), 1200);
|
|
2417
|
+
// Probe the dedicated liveness endpoint rather than `/`, whose response
|
|
2418
|
+
// depends on the PWA shell asset being present. Any HTTP response (even a
|
|
2419
|
+
// 404 from an older node without /healthz) means the node is up; only a
|
|
2420
|
+
// connection error or a 5xx counts as unreachable.
|
|
2421
|
+
const res = await fetch(`${url(config)}/healthz`, { signal: controller.signal });
|
|
2422
|
+
clearTimeout(timer);
|
|
2423
|
+
return res.ok || res.status < 500;
|
|
2424
|
+
} catch {
|
|
2425
|
+
return false;
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
/**
|
|
2430
|
+
* Simple line-buffered prompter.
|
|
2431
|
+
*
|
|
2432
|
+
* Important: do NOT give readline an `output` stream / terminal control here.
|
|
2433
|
+
* On SSH terminals that caused readline to redraw and partially erase the
|
|
2434
|
+
* previous prompt, so users could not see which question they were answering.
|
|
2435
|
+
* We print prompts ourselves, one per line, and let the tty echo typed input.
|
|
2436
|
+
*/
|
|
2437
|
+
function createPrompter() {
|
|
2438
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false, crlfDelay: Infinity });
|
|
2439
|
+
const buffer = [];
|
|
2440
|
+
const waiters = [];
|
|
2441
|
+
let closed = false;
|
|
2442
|
+
|
|
2443
|
+
rl.on("line", (line) => {
|
|
2444
|
+
const waiter = waiters.shift();
|
|
2445
|
+
if (waiter) waiter(line);
|
|
2446
|
+
else buffer.push(line);
|
|
2447
|
+
});
|
|
2448
|
+
rl.on("close", () => {
|
|
2449
|
+
closed = true;
|
|
2450
|
+
while (waiters.length) waiters.shift()("");
|
|
2451
|
+
});
|
|
2452
|
+
|
|
2453
|
+
const nextLine = () =>
|
|
2454
|
+
new Promise((resolve) => {
|
|
2455
|
+
if (buffer.length) resolve(buffer.shift());
|
|
2456
|
+
else if (closed) resolve("");
|
|
2457
|
+
else waiters.push(resolve);
|
|
2458
|
+
});
|
|
2459
|
+
|
|
2460
|
+
const ask = async (question, fallback) => {
|
|
2461
|
+
const suffix = fallback ? c.dim(` [default: ${fallback}]`) : "";
|
|
2462
|
+
process.stdout.write(`\n${c.cyan("›")} ${question}${suffix}\n > `);
|
|
2463
|
+
const line = await nextLine();
|
|
2464
|
+
return line.trim() || fallback || "";
|
|
2465
|
+
};
|
|
2466
|
+
|
|
2467
|
+
const askChoice = async (question, choices, fallback) => {
|
|
2468
|
+
const labels = choices.map((choice) => `${choice.key}=${choice.label}`).join(", ");
|
|
2469
|
+
for (;;) {
|
|
2470
|
+
const answer = (await ask(`${question} (${labels})`, fallback)).toLowerCase();
|
|
2471
|
+
const match = choices.find((choice) => answer === choice.key || answer === choice.label.toLowerCase());
|
|
2472
|
+
if (match) return match.key;
|
|
2473
|
+
console.log(c.yellow(`Please choose one of: ${choices.map((choice) => choice.key).join(", ")}`));
|
|
2474
|
+
}
|
|
2475
|
+
};
|
|
2476
|
+
|
|
2477
|
+
const askYesNo = async (question, defaultYes) => {
|
|
2478
|
+
const hint = defaultYes ? "Y/n" : "y/N";
|
|
2479
|
+
for (;;) {
|
|
2480
|
+
const answer = (await ask(`${question} ${c.dim(`(${hint})`)}`, "")).toLowerCase();
|
|
2481
|
+
if (!answer) return defaultYes;
|
|
2482
|
+
if (["y", "yes"].includes(answer)) return true;
|
|
2483
|
+
if (["n", "no"].includes(answer)) return false;
|
|
2484
|
+
console.log(c.yellow("Please answer yes or no."));
|
|
2485
|
+
}
|
|
2486
|
+
};
|
|
2487
|
+
|
|
2488
|
+
return { ask, askChoice, askYesNo, close: () => rl.close(), pause: () => rl.pause(), resume: () => rl.resume() };
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// --- service management -----------------------------------------------------
|
|
2492
|
+
|
|
2493
|
+
function servicePaths() {
|
|
2494
|
+
if (process.platform === "darwin") {
|
|
2495
|
+
return {
|
|
2496
|
+
kind: "launchd",
|
|
2497
|
+
file: path.join(os.homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`),
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
if (process.platform === "linux") {
|
|
2501
|
+
return {
|
|
2502
|
+
kind: "systemd",
|
|
2503
|
+
file: path.join(os.homedir(), ".config", "systemd", "user", SERVICE_UNIT),
|
|
2504
|
+
};
|
|
2505
|
+
}
|
|
2506
|
+
return { kind: "unsupported", file: "" };
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
function plistContent(config) {
|
|
2510
|
+
const envEntries = Object.entries({
|
|
2511
|
+
PORT: String(config.port),
|
|
2512
|
+
BIVY_WORKSPACE: config.workspace,
|
|
2513
|
+
BIVY_DATA_DIR: appDir,
|
|
2514
|
+
...config.env,
|
|
2515
|
+
PATH: commandPath(config.env?.PATH),
|
|
2516
|
+
})
|
|
2517
|
+
.map(([k, v]) => ` <key>${k}</key>\n <string>${escapeXml(String(v))}</string>`)
|
|
2518
|
+
.join("\n");
|
|
2519
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2520
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2521
|
+
<plist version="1.0">
|
|
2522
|
+
<dict>
|
|
2523
|
+
<key>Label</key>
|
|
2524
|
+
<string>${SERVICE_LABEL}</string>
|
|
2525
|
+
<key>WorkingDirectory</key>
|
|
2526
|
+
<string>${escapeXml(repoRoot)}</string>
|
|
2527
|
+
<key>ProgramArguments</key>
|
|
2528
|
+
<array>
|
|
2529
|
+
<string>${escapeXml(nodeBin)}</string>
|
|
2530
|
+
${tsxCli ? `<string>${escapeXml(tsxCli)}</string>\n ` : ""}<string>${escapeXml(serverEntry)}</string>
|
|
2531
|
+
</array>
|
|
2532
|
+
<key>EnvironmentVariables</key>
|
|
2533
|
+
<dict>
|
|
2534
|
+
${envEntries}
|
|
2535
|
+
</dict>
|
|
2536
|
+
<key>RunAtLoad</key>
|
|
2537
|
+
<true/>
|
|
2538
|
+
<key>KeepAlive</key>
|
|
2539
|
+
<true/>
|
|
2540
|
+
<key>StandardOutPath</key>
|
|
2541
|
+
<string>/tmp/bivy.log</string>
|
|
2542
|
+
<key>StandardErrorPath</key>
|
|
2543
|
+
<string>/tmp/bivy.err.log</string>
|
|
2544
|
+
</dict>
|
|
2545
|
+
</plist>
|
|
2546
|
+
`;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
function systemdEscapeValue(value) {
|
|
2550
|
+
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
function systemdContent(config) {
|
|
2554
|
+
const envLines = Object.entries({
|
|
2555
|
+
PORT: String(config.port),
|
|
2556
|
+
BIVY_WORKSPACE: config.workspace,
|
|
2557
|
+
BIVY_DATA_DIR: appDir,
|
|
2558
|
+
...config.env,
|
|
2559
|
+
PATH: commandPath(config.env?.PATH),
|
|
2560
|
+
})
|
|
2561
|
+
.map(([k, v]) => `Environment="${k}=${systemdEscapeValue(v)}"`)
|
|
2562
|
+
.join("\n");
|
|
2563
|
+
return `[Unit]
|
|
2564
|
+
Description=Bivy node
|
|
2565
|
+
After=network.target
|
|
2566
|
+
|
|
2567
|
+
[Service]
|
|
2568
|
+
Type=simple
|
|
2569
|
+
WorkingDirectory=${repoRoot}
|
|
2570
|
+
ExecStart=${nodeBin} ${nodeScriptArgs(serverEntry).map(systemdEscapeValue).join(" ")}
|
|
2571
|
+
Restart=always
|
|
2572
|
+
RestartSec=5
|
|
2573
|
+
${envLines}
|
|
2574
|
+
|
|
2575
|
+
[Install]
|
|
2576
|
+
WantedBy=default.target
|
|
2577
|
+
`;
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
function escapeXml(value) {
|
|
2581
|
+
return value
|
|
2582
|
+
.replace(/&/g, "&")
|
|
2583
|
+
.replace(/</g, "<")
|
|
2584
|
+
.replace(/>/g, ">");
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
async function installService(config) {
|
|
2588
|
+
if (detectInstallKind() === "npx") {
|
|
2589
|
+
console.log(c.yellow("Refusing to install a background service from an ephemeral 'npx bivy' run."));
|
|
2590
|
+
console.log(c.dim(`The service would point at the temporary npx cache (${repoRoot}), which npm can delete at any time, leaving a broken unit.`));
|
|
2591
|
+
console.log(`Install a persistent copy first: ${c.cyan("npm i -g @bivy/bivy")}, then run ${c.cyan("bivy service install")}.`);
|
|
2592
|
+
return false;
|
|
2593
|
+
}
|
|
2594
|
+
const { kind, file } = servicePaths();
|
|
2595
|
+
if (kind === "unsupported") {
|
|
2596
|
+
console.log(c.yellow(`No background-service template for ${process.platform}. Use 'bivy start' instead.`));
|
|
2597
|
+
return false;
|
|
2598
|
+
}
|
|
2599
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2600
|
+
if (kind === "launchd") {
|
|
2601
|
+
fs.writeFileSync(file, plistContent(config));
|
|
2602
|
+
runQuiet("launchctl", ["unload", file]);
|
|
2603
|
+
const res = runQuiet("launchctl", ["load", "-w", file]);
|
|
2604
|
+
if (res.code !== 0) {
|
|
2605
|
+
console.error(c.red(`launchctl load failed: ${res.stderr.trim()}`));
|
|
2606
|
+
return false;
|
|
2607
|
+
}
|
|
2608
|
+
} else {
|
|
2609
|
+
fs.writeFileSync(file, systemdContent(config));
|
|
2610
|
+
const username = os.userInfo().username;
|
|
2611
|
+
// Best effort. This succeeds when run as root, via sudo permissions, or on
|
|
2612
|
+
// systems that allow users to enable their own linger. It is safe to try
|
|
2613
|
+
// before systemctl so a direct SSH-less install has a better chance to work.
|
|
2614
|
+
const linger = runQuiet("loginctl", ["enable-linger", username]);
|
|
2615
|
+
const runtimeDir = process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid?.() ?? ""}`;
|
|
2616
|
+
const systemdEnv = { ...process.env, XDG_RUNTIME_DIR: runtimeDir };
|
|
2617
|
+
runQuiet("systemctl", ["--user", "daemon-reload"], { env: systemdEnv });
|
|
2618
|
+
const res = runQuiet("systemctl", ["--user", "enable", "--now", SERVICE_UNIT], { env: systemdEnv });
|
|
2619
|
+
if (res.code !== 0) {
|
|
2620
|
+
console.error(c.red("Could not start the systemd user service from this shell."));
|
|
2621
|
+
console.error(c.dim((res.stderr || res.stdout || "").trim()));
|
|
2622
|
+
console.log("\nTo finish service setup, SSH in directly as this user and run:");
|
|
2623
|
+
console.log(c.cyan(" bivy service install"));
|
|
2624
|
+
console.log("If it still fails, run once as root:");
|
|
2625
|
+
console.log(c.cyan(` loginctl enable-linger ${username}`));
|
|
2626
|
+
return false;
|
|
2627
|
+
}
|
|
2628
|
+
if (linger.code === 0) {
|
|
2629
|
+
console.log(c.dim("Enabled systemd linger so the node keeps running after SSH logout."));
|
|
2630
|
+
} else {
|
|
2631
|
+
console.log(c.dim(`If the node stops after logout, run as root: loginctl enable-linger ${username}`));
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
config.service = true;
|
|
2635
|
+
saveConfig(config);
|
|
2636
|
+
console.log(c.green(`Background service installed (${kind}).`));
|
|
2637
|
+
return true;
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
function uninstallService() {
|
|
2641
|
+
const { kind, file } = servicePaths();
|
|
2642
|
+
if (kind === "unsupported" || !fs.existsSync(file)) {
|
|
2643
|
+
console.log("No background service installed.");
|
|
2644
|
+
} else if (kind === "launchd") {
|
|
2645
|
+
runQuiet("launchctl", ["unload", file]);
|
|
2646
|
+
fs.rmSync(file, { force: true });
|
|
2647
|
+
console.log(c.green("launchd service removed."));
|
|
2648
|
+
} else {
|
|
2649
|
+
runQuiet("systemctl", ["--user", "disable", "--now", SERVICE_UNIT]);
|
|
2650
|
+
fs.rmSync(file, { force: true });
|
|
2651
|
+
runQuiet("systemctl", ["--user", "daemon-reload"]);
|
|
2652
|
+
console.log(c.green("systemd service removed."));
|
|
2653
|
+
}
|
|
2654
|
+
const config = loadConfig();
|
|
2655
|
+
config.service = false;
|
|
2656
|
+
saveConfig(config);
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
// Environment `systemctl --user` needs when it is not inherited from a login
|
|
2660
|
+
// session — e.g. when `bivy` is invoked via the ~/.local/bin symlink or from a
|
|
2661
|
+
// context where pam_systemd did not export XDG_RUNTIME_DIR. Without this,
|
|
2662
|
+
// `systemctl --user` fails to reach the user manager.
|
|
2663
|
+
function systemdUserEnv() {
|
|
2664
|
+
const runtimeDir = process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid?.() ?? ""}`;
|
|
2665
|
+
return { ...process.env, XDG_RUNTIME_DIR: runtimeDir };
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
function restartService() {
|
|
2669
|
+
const { kind, file } = servicePaths();
|
|
2670
|
+
if (!fs.existsSync(file)) return false;
|
|
2671
|
+
if (kind === "launchd") {
|
|
2672
|
+
runQuiet("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? ""}/${SERVICE_LABEL}`]);
|
|
2673
|
+
// Fallback for older launchctl
|
|
2674
|
+
runQuiet("launchctl", ["unload", file]);
|
|
2675
|
+
runQuiet("launchctl", ["load", "-w", file]);
|
|
2676
|
+
return true;
|
|
2677
|
+
}
|
|
2678
|
+
if (kind === "systemd") {
|
|
2679
|
+
// Report the real outcome: if the user manager can't be reached (no linger,
|
|
2680
|
+
// missing XDG_RUNTIME_DIR, etc.) the restart failed and callers should fall
|
|
2681
|
+
// back to a foreground/background start instead of waiting on a node that
|
|
2682
|
+
// was never (re)started.
|
|
2683
|
+
return runQuiet("systemctl", ["--user", "restart", SERVICE_UNIT], { env: systemdUserEnv() }).code === 0;
|
|
2684
|
+
}
|
|
2685
|
+
return false;
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
// How long a restart triggered by `bivy update`/`bivy restart` will wait for
|
|
2689
|
+
// in-flight agent turns to finish before restarting anyway. Restarting the
|
|
2690
|
+
// service SIGTERMs every open session's agent process; done mid-turn that
|
|
2691
|
+
// kills a running tool call or an in-progress reply outright (issue #474: "bivy
|
|
2692
|
+
// update kills live sessions and tool use"). So we poll the node's own view of
|
|
2693
|
+
// which sessions are busy (`/api/status`.sessions.busy, backed by the same
|
|
2694
|
+
// `sessionBusy` the server uses to protect a session from deletion) and hold
|
|
2695
|
+
// off restarting until nothing is mid-turn — but only up to a bounded grace
|
|
2696
|
+
// period, so a wedged session can't block an update forever. Override with
|
|
2697
|
+
// BIVY_UPDATE_WAIT_TIMEOUT_MS (0 skips waiting entirely).
|
|
2698
|
+
const UPDATE_WAIT_DEFAULT_MS = 30 * 60 * 1000; // 30 minutes
|
|
2699
|
+
const UPDATE_WAIT_POLL_MS = 3000;
|
|
2700
|
+
|
|
2701
|
+
async function waitForIdleSessions(config, { skip = false } = {}) {
|
|
2702
|
+
if (skip) return;
|
|
2703
|
+
const envTimeout = Number(process.env.BIVY_UPDATE_WAIT_TIMEOUT_MS);
|
|
2704
|
+
const timeoutMs = Number.isFinite(envTimeout) && envTimeout >= 0 ? envTimeout : UPDATE_WAIT_DEFAULT_MS;
|
|
2705
|
+
if (timeoutMs === 0) return;
|
|
2706
|
+
if (!(await isReachable(config))) return; // nothing running — nothing to wait for
|
|
2707
|
+
|
|
2708
|
+
const busyCount = async () => {
|
|
2709
|
+
try {
|
|
2710
|
+
const status = await localApi(config, "/api/status");
|
|
2711
|
+
return Number(status?.sessions?.busy) || 0;
|
|
2712
|
+
} catch {
|
|
2713
|
+
return 0; // node went away mid-poll — nothing left to wait for
|
|
2714
|
+
}
|
|
2715
|
+
};
|
|
2716
|
+
|
|
2717
|
+
let busy = await busyCount();
|
|
2718
|
+
if (busy <= 0) return;
|
|
2719
|
+
|
|
2720
|
+
const plural = (n) => (n === 1 ? "" : "s");
|
|
2721
|
+
console.log(c.dim(`Waiting for ${busy} active agent session${plural(busy)} to finish the current turn before restarting…`));
|
|
2722
|
+
const start = Date.now();
|
|
2723
|
+
let lastLogged = busy;
|
|
2724
|
+
while (busy > 0) {
|
|
2725
|
+
if (Date.now() - start > timeoutMs) {
|
|
2726
|
+
console.log(c.yellow(`Still ${busy} session${plural(busy)} busy after ${Math.round(timeoutMs / 1000)}s — restarting anyway. Interrupted sessions can be resumed once the node is back.`));
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
await new Promise((resolve) => setTimeout(resolve, UPDATE_WAIT_POLL_MS));
|
|
2730
|
+
busy = await busyCount();
|
|
2731
|
+
if (busy !== lastLogged) {
|
|
2732
|
+
if (busy > 0) console.log(c.dim(` ${busy} session${plural(busy)} still busy…`));
|
|
2733
|
+
lastLogged = busy;
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
console.log(c.dim("All sessions idle — restarting."));
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
// Where a background (non-service) node start sends its stdout/stderr, so a
|
|
2740
|
+
// crash on startup leaves a trail instead of vanishing into stdio: "ignore".
|
|
2741
|
+
const nodeLogPath = path.join(appDir, "node.log");
|
|
2742
|
+
|
|
2743
|
+
function readTail(file, lines = 30) {
|
|
2744
|
+
try {
|
|
2745
|
+
const text = fs.readFileSync(file, "utf8").replace(/\s+$/, "");
|
|
2746
|
+
if (!text) return "";
|
|
2747
|
+
return text.split("\n").slice(-lines).join("\n");
|
|
2748
|
+
} catch {
|
|
2749
|
+
return "";
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
// When the node never became reachable, gather whatever the failed start
|
|
2754
|
+
// actually printed so the user sees the real error instead of a bare
|
|
2755
|
+
// "Could not start". Pulls from the systemd journal, the launchd log files, or
|
|
2756
|
+
// the background log depending on how the node was launched.
|
|
2757
|
+
function printNodeStartupDiagnostics() {
|
|
2758
|
+
const { kind, file } = servicePaths();
|
|
2759
|
+
let details = "";
|
|
2760
|
+
// Only consult service logs when the node was actually launched via the
|
|
2761
|
+
// service (its unit/plist exists). Otherwise it came from the background
|
|
2762
|
+
// spawn below, whose output we captured to nodeLogPath.
|
|
2763
|
+
if (kind === "systemd" && fs.existsSync(file)) {
|
|
2764
|
+
const res = runQuiet(
|
|
2765
|
+
"journalctl",
|
|
2766
|
+
["--user", "-u", SERVICE_UNIT, "-n", "30", "--no-pager"],
|
|
2767
|
+
{ env: systemdUserEnv() },
|
|
2768
|
+
);
|
|
2769
|
+
const out = (res.stdout || res.stderr || "").replace(/\s+$/, "");
|
|
2770
|
+
// journalctl prints "-- No entries --" (exit 0) when the unit has no logs.
|
|
2771
|
+
if (out && !/^-- No entries --$/.test(out)) details = out;
|
|
2772
|
+
} else if (kind === "launchd" && fs.existsSync(file)) {
|
|
2773
|
+
details = [readTail("/tmp/bivy.err.log"), readTail("/tmp/bivy.log")].filter(Boolean).join("\n");
|
|
2774
|
+
}
|
|
2775
|
+
if (!details) details = readTail(nodeLogPath);
|
|
2776
|
+
if (details) {
|
|
2777
|
+
console.error(c.dim("Recent output from the Bivy node:"));
|
|
2778
|
+
console.error(details);
|
|
2779
|
+
console.error("");
|
|
2780
|
+
}
|
|
2781
|
+
console.error(c.yellow("Run 'bivy start' in the foreground to see the full startup error."));
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
function stopService() {
|
|
2785
|
+
const { kind, file } = servicePaths();
|
|
2786
|
+
if (!fs.existsSync(file)) {
|
|
2787
|
+
console.log("No background service installed (nothing to stop).");
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2790
|
+
if (kind === "launchd") {
|
|
2791
|
+
runQuiet("launchctl", ["unload", file]);
|
|
2792
|
+
} else {
|
|
2793
|
+
runQuiet("systemctl", ["--user", "stop", SERVICE_UNIT], { env: systemdUserEnv() });
|
|
2794
|
+
}
|
|
2795
|
+
console.log(c.green("Node service stopped."));
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
function serviceStatusLine() {
|
|
2799
|
+
const { kind, file } = servicePaths();
|
|
2800
|
+
if (kind === "unsupported") return "service: unsupported platform";
|
|
2801
|
+
if (!fs.existsSync(file)) return "service: not installed";
|
|
2802
|
+
if (kind === "systemd") {
|
|
2803
|
+
const res = runQuiet("systemctl", ["--user", "is-active", SERVICE_UNIT], { env: systemdUserEnv() });
|
|
2804
|
+
return `service: systemd (${res.stdout.trim() || "unknown"})`;
|
|
2805
|
+
}
|
|
2806
|
+
const res = runQuiet("launchctl", ["list", SERVICE_LABEL]);
|
|
2807
|
+
return `service: launchd (${res.code === 0 ? "loaded" : "not loaded"})`;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
// --- browser ----------------------------------------------------------------
|
|
2811
|
+
|
|
2812
|
+
// The daemon writes a per-process bootstrap secret (0600) that the loopback UI
|
|
2813
|
+
// must present to mint its device token. Read it and append to the open URL so
|
|
2814
|
+
// the legitimate launcher works while other local users (who can't read the
|
|
2815
|
+
// file) cannot bootstrap. Falls back to the plain URL if absent.
|
|
2816
|
+
function openBrowser(target) {
|
|
2817
|
+
const opener =
|
|
2818
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
2819
|
+
runQuiet(opener, [target]);
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
// Best-effort guess at whether this machine can actually open a browser. macOS
|
|
2823
|
+
// and Windows always can; a Linux box needs a display server *and* xdg-open.
|
|
2824
|
+
// Headless servers fail both, so callers can print instructions instead of
|
|
2825
|
+
// silently spawning an opener that does nothing.
|
|
2826
|
+
function canOpenBrowser() {
|
|
2827
|
+
if (process.platform === "darwin" || process.platform === "win32") return true;
|
|
2828
|
+
const hasDisplay = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
2829
|
+
return hasDisplay && commandExists("xdg-open");
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
async function localApi(config, pathName, init = {}) {
|
|
2833
|
+
let res;
|
|
2834
|
+
try {
|
|
2835
|
+
const headers = new Headers(init.headers || {});
|
|
2836
|
+
if (init.body && !headers.has("content-type")) headers.set("content-type", "application/json");
|
|
2837
|
+
// Opportunistically attach a device token when the caller didn't set one.
|
|
2838
|
+
// Needed once the daemon requires auth even on loopback (multi-user host
|
|
2839
|
+
// detection, BIVY_REQUIRE_LOCAL_AUTH=1 — see src/auth.ts); a harmless
|
|
2840
|
+
// extra Authorization header otherwise, since the daemon still accepts
|
|
2841
|
+
// bare loopback there. Skip for the bootstrap call itself (it authenticates
|
|
2842
|
+
// with the bootstrap secret, not a token) to avoid recursing.
|
|
2843
|
+
if (!headers.has("authorization") && pathName !== "/api/auth/bootstrap") {
|
|
2844
|
+
try {
|
|
2845
|
+
headers.set("authorization", `Bearer ${await localDeviceToken(config)}`);
|
|
2846
|
+
} catch {
|
|
2847
|
+
// No bootstrap secret on disk, node unreachable for bootstrap, etc. —
|
|
2848
|
+
// fall back to an unauthenticated call, same as before this existed.
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
res = await fetch(`${url(config)}${pathName}`, { ...init, headers });
|
|
2852
|
+
} catch (error) {
|
|
2853
|
+
throw new Error(`Could not reach the local node at ${url(config)}. Start it with 'bivy start' or 'bivy service install'.`);
|
|
2854
|
+
}
|
|
2855
|
+
const data = await res.json().catch(() => ({}));
|
|
2856
|
+
if (!res.ok) throw new Error(data?.error || `Local node request failed (${res.status})`);
|
|
2857
|
+
return data;
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
function readBootstrapSecret() {
|
|
2861
|
+
try {
|
|
2862
|
+
const { secret } = JSON.parse(fs.readFileSync(path.join(appDir, "bootstrap.json"), "utf8"));
|
|
2863
|
+
return typeof secret === "string" ? secret : "";
|
|
2864
|
+
} catch {
|
|
2865
|
+
return "";
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
// Cached for the lifetime of this CLI process: localApi() above now fetches a
|
|
2870
|
+
// token opportunistically for every unauthenticated call, and without caching
|
|
2871
|
+
// that would mint (and register) a brand-new "Bivy CLI" device on each one —
|
|
2872
|
+
// e.g. once per poll iteration in waitForIdleSessions(). One token is reused
|
|
2873
|
+
// for the whole invocation.
|
|
2874
|
+
let cachedDeviceToken = null;
|
|
2875
|
+
|
|
2876
|
+
async function localDeviceToken(config) {
|
|
2877
|
+
if (cachedDeviceToken) return cachedDeviceToken;
|
|
2878
|
+
const secret = readBootstrapSecret();
|
|
2879
|
+
if (!secret) throw new Error("The node is running but no bootstrap secret was found. Restart it with 'bivy restart' or 'bivy start'.");
|
|
2880
|
+
const data = await localApi(config, "/api/auth/bootstrap", {
|
|
2881
|
+
method: "POST",
|
|
2882
|
+
headers: { "x-bivy-bootstrap": secret },
|
|
2883
|
+
body: JSON.stringify({ name: "Bivy CLI" }),
|
|
2884
|
+
});
|
|
2885
|
+
if (!data?.token) throw new Error("Local node did not return a device token.");
|
|
2886
|
+
cachedDeviceToken = data.token;
|
|
2887
|
+
return cachedDeviceToken;
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
function loadRelayConfig() {
|
|
2891
|
+
try {
|
|
2892
|
+
return JSON.parse(fs.readFileSync(relayConfigPath, "utf8"));
|
|
2893
|
+
} catch {
|
|
2894
|
+
return null;
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
async function controlPlaneNodeApi(relay, pathName, init = {}) {
|
|
2899
|
+
const headers = new Headers(init.headers || {});
|
|
2900
|
+
headers.set("authorization", `Bearer ${relay.enrollmentToken}`);
|
|
2901
|
+
if (init.body && !headers.has("content-type")) headers.set("content-type", "application/json");
|
|
2902
|
+
const res = await fetch(`${String(relay.controlPlaneUrl || "").replace(/\/$/, "")}${pathName}`, { ...init, headers });
|
|
2903
|
+
const data = await res.json().catch(() => ({}));
|
|
2904
|
+
if (!res.ok) throw new Error(data?.error || `Control-plane request failed (${res.status})`);
|
|
2905
|
+
return data;
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
let cachedQr;
|
|
2909
|
+
function terminalQr(text) {
|
|
2910
|
+
try {
|
|
2911
|
+
if (!cachedQr) {
|
|
2912
|
+
const sandbox = { window: {} };
|
|
2913
|
+
vm.runInNewContext(fs.readFileSync(qrEntry, "utf8"), sandbox, { filename: qrEntry });
|
|
2914
|
+
cachedQr = sandbox.window.QRCode;
|
|
2915
|
+
}
|
|
2916
|
+
const M = cachedQr.generate(text);
|
|
2917
|
+
const quiet = 2;
|
|
2918
|
+
const rows = [];
|
|
2919
|
+
for (let r = -quiet; r < M.size + quiet; r += 2) {
|
|
2920
|
+
let line = "";
|
|
2921
|
+
for (let col = -quiet; col < M.size + quiet; col++) {
|
|
2922
|
+
const top = r >= 0 && r < M.size && col >= 0 && col < M.size && M.m[r][col];
|
|
2923
|
+
const bottom = r + 1 >= 0 && r + 1 < M.size && col >= 0 && col < M.size && M.m[r + 1][col];
|
|
2924
|
+
line += top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " ";
|
|
2925
|
+
}
|
|
2926
|
+
rows.push(line);
|
|
2927
|
+
}
|
|
2928
|
+
return rows.join("\n");
|
|
2929
|
+
} catch {
|
|
2930
|
+
return "";
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
// --- commands ---------------------------------------------------------------
|
|
2935
|
+
|
|
2936
|
+
async function cmdSetup(args = []) {
|
|
2937
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
2938
|
+
console.log("Usage: bivy setup\n\nFirst-run wizard: workspace, remote access + sign-in, and background service. Safe to re-run later to change the workspace, default agent, or remote access.");
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
console.log(c.bold("\n Bivy — node setup\n"));
|
|
2942
|
+
if (process.getuid?.() === 0) {
|
|
2943
|
+
console.log(c.yellow("You are running setup as root. For a real node, create a normal user (e.g. 'bivy') and install there."));
|
|
2944
|
+
}
|
|
2945
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
2946
|
+
|
|
2947
|
+
const existingConfig = fs.existsSync(cliConfigPath);
|
|
2948
|
+
const config = loadConfig();
|
|
2949
|
+
const rl = createPrompter();
|
|
2950
|
+
const { ask, askChoice, askYesNo } = rl;
|
|
2951
|
+
|
|
2952
|
+
// 1. Workspace + local port — chosen for the user, no prompts. The workspace
|
|
2953
|
+
// defaults to a dedicated ~/bivy-workspace folder that won't collide with the
|
|
2954
|
+
// user's own projects; the local port is for this machine only (remote access
|
|
2955
|
+
// goes through the relay). Both are changeable later in Settings.
|
|
2956
|
+
if (!existingConfig || config.workspace === repoRoot) {
|
|
2957
|
+
const workspace = config.workspace !== repoRoot ? config.workspace : path.join(os.homedir(), "bivy-workspace");
|
|
2958
|
+
if (!fs.existsSync(workspace)) fs.mkdirSync(workspace, { recursive: true });
|
|
2959
|
+
config.workspace = workspace;
|
|
2960
|
+
config.port = Number(config.port) || 4317;
|
|
2961
|
+
saveConfig(config);
|
|
2962
|
+
}
|
|
2963
|
+
console.log(c.dim(`Workspace: ${config.workspace} · local port: ${config.port} (change both in Settings)`));
|
|
2964
|
+
|
|
2965
|
+
// 2. Default agent — stays Pi unless one was already chosen. Pi is the built-in
|
|
2966
|
+
// default and is changeable per-session or in Settings, so setup doesn't ask.
|
|
2967
|
+
// Model/provider sign-in is left to the agent's own CLI/TUI or Settings.
|
|
2968
|
+
const setupAgent = setupAgentByRuntime(String(config.env.BIVY_RUNTIME || "pi")) || setupAgentByRuntime("pi");
|
|
2969
|
+
if (!config.env.BIVY_RUNTIME && setupAgent) {
|
|
2970
|
+
config.env = { ...config.env, BIVY_RUNTIME: setupAgent.runtimeId };
|
|
2971
|
+
saveConfig(config);
|
|
2972
|
+
}
|
|
2973
|
+
if (setupAgent && setupAgent.runtimeId !== "pi") {
|
|
2974
|
+
const installed = await ensureSetupAgent(setupAgent);
|
|
2975
|
+
if (!installed) console.log(c.yellow(`${setupAgent.label} was not fully installed. Install it later from the app or with 'bivy agents:install'.`));
|
|
2976
|
+
}
|
|
2977
|
+
console.log(c.dim(`Default agent: ${setupAgent?.label || "Pi"} (change in Settings; sign into your model from the agent's CLI/TUI or Settings → Keys & OAuth)`));
|
|
2978
|
+
|
|
2979
|
+
// 3. Secure remote web/PWA access is what makes a Bivy-managed CLI useful:
|
|
2980
|
+
// without a relay/control plane it adds nothing over running the agent
|
|
2981
|
+
// directly. Setup therefore requires hosted or self-hosted enrollment.
|
|
2982
|
+
//
|
|
2983
|
+
// Carries the account session from relay:setup to the setup-completion step so
|
|
2984
|
+
// we can open the remote app signed into the whole account (see finishSetupRemote).
|
|
2985
|
+
let setupSession = null;
|
|
2986
|
+
if (!fs.existsSync(relayConfigPath)) {
|
|
2987
|
+
console.log(c.bold("\n Remote access\n"));
|
|
2988
|
+
|
|
2989
|
+
console.log("Bivy uses remote access to make agent sessions visible and steerable from your other devices.");
|
|
2990
|
+
const syncChoice = await askChoice(
|
|
2991
|
+
"Remote access",
|
|
2992
|
+
[
|
|
2993
|
+
{ key: "h", label: "hosted (recommended — one node is free)" },
|
|
2994
|
+
{ key: "s", label: "self-hosted (your own control plane + relay)" },
|
|
2995
|
+
],
|
|
2996
|
+
"h",
|
|
2997
|
+
);
|
|
2998
|
+
const relayArgs = [];
|
|
2999
|
+
if (syncChoice === "s") {
|
|
3000
|
+
const endpoints = await getHostedEndpoints();
|
|
3001
|
+
const controlPlane = await ask(" Control plane URL:", process.env.BIVY_CONTROL_PLANE_URL || endpoints.controlPlane);
|
|
3002
|
+
const relayWs = await ask(" Relay ws(s):// URL:", process.env.BIVY_RELAY_URL || endpoints.relay);
|
|
3003
|
+
if (controlPlane.trim()) relayArgs.push("--control-plane", controlPlane.trim());
|
|
3004
|
+
if (relayWs.trim()) relayArgs.push("--relay", relayWs.trim());
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
const loginChoice = await askChoice(
|
|
3008
|
+
"Remote login",
|
|
3009
|
+
[
|
|
3010
|
+
{ key: "g", label: "GitHub" },
|
|
3011
|
+
{ key: "e", label: "email sign-in link (open or scan on any device)" },
|
|
3012
|
+
],
|
|
3013
|
+
"g",
|
|
3014
|
+
);
|
|
3015
|
+
if (loginChoice === "e") {
|
|
3016
|
+
const email = await ask(" Your account email:", config.env.BIVY_EMAIL || "");
|
|
3017
|
+
if (email.trim()) relayArgs.push("--email", email.trim());
|
|
3018
|
+
else relayArgs.push("--github");
|
|
3019
|
+
} else {
|
|
3020
|
+
relayArgs.push("--github");
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
const useGithub = relayArgs.includes("--github");
|
|
3024
|
+
try { fs.rmSync(setupSessionPath, { force: true }); } catch { /* best effort */ }
|
|
3025
|
+
let relayOk;
|
|
3026
|
+
for (;;) {
|
|
3027
|
+
console.log(c.dim(useGithub
|
|
3028
|
+
? " We'll open GitHub in your browser (or print the URL on a headless server). Authorize, and setup continues automatically."
|
|
3029
|
+
: " We'll email you a sign-in link. Open it in any browser and setup continues automatically."));
|
|
3030
|
+
rl.pause();
|
|
3031
|
+
const code = await run(nodeBin, [...nodeScriptArgs(relaySetupEntry), ...relayArgs, "--emit-session", setupSessionPath], {
|
|
3032
|
+
cwd: repoRoot,
|
|
3033
|
+
env: startEnv(config),
|
|
3034
|
+
});
|
|
3035
|
+
rl.resume();
|
|
3036
|
+
relayOk = code === 0;
|
|
3037
|
+
if (relayOk) break;
|
|
3038
|
+
const retry = await askYesNo("Remote access setup failed. Try again?", true);
|
|
3039
|
+
if (!retry) break;
|
|
3040
|
+
}
|
|
3041
|
+
if (!relayOk) {
|
|
3042
|
+
rl.close();
|
|
3043
|
+
console.error(c.red("\nSetup is incomplete: Bivy could not connect this node to a relay/control plane."));
|
|
3044
|
+
console.error(`Re-run ${c.cyan("bivy setup")} to retry.`);
|
|
3045
|
+
process.exitCode = 1;
|
|
3046
|
+
return;
|
|
3047
|
+
}
|
|
3048
|
+
setupSession = consumeSetupSession();
|
|
3049
|
+
} else {
|
|
3050
|
+
console.log(c.dim("\nRemote access already configured. Re-run 'bivy relay:setup' to change sync or sign-in."));
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
// GitHub App — connect it from the web app (Settings → GitHub App) or with
|
|
3054
|
+
// `bivy github:app-create` / `github:app-connect`. One app covers every repo,
|
|
3055
|
+
// and the node mints its own tokens, so there's no per-repo token to set up here.
|
|
3056
|
+
|
|
3057
|
+
// 4. Background service — always installed so the node keeps running (and stays
|
|
3058
|
+
// reachable remotely) after you close this terminal. No prompt.
|
|
3059
|
+
let started = false;
|
|
3060
|
+
if (config.service) {
|
|
3061
|
+
console.log(c.dim("\nBackground service already configured; restarting it."));
|
|
3062
|
+
started = restartService();
|
|
3063
|
+
} else {
|
|
3064
|
+
console.log(c.dim("\nInstalling the background service so the node keeps running…"));
|
|
3065
|
+
started = await installService(config);
|
|
3066
|
+
}
|
|
3067
|
+
rl.close();
|
|
3068
|
+
|
|
3069
|
+
if (!started) {
|
|
3070
|
+
console.log(c.yellow("\nThe node could not be installed as a background service on this machine."));
|
|
3071
|
+
console.log(`Start it in this terminal with ${c.cyan("bivy start")}, or retry the install with ${c.cyan("bivy service install")}.`);
|
|
3072
|
+
return;
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
console.log(c.bold(c.green("\n ✓ Your node is running.\n")));
|
|
3076
|
+
printFirstRunSteps();
|
|
3077
|
+
await finishSetupRemote(config, setupSession);
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
// Read and delete the one-time account-session handoff written by relay:setup
|
|
3081
|
+
// (see setupSessionPath). Returns { session, nodeId } or null. Deleting on read
|
|
3082
|
+
// keeps the account bearer from lingering on disk.
|
|
3083
|
+
function consumeSetupSession() {
|
|
3084
|
+
try {
|
|
3085
|
+
const raw = fs.readFileSync(setupSessionPath, "utf8");
|
|
3086
|
+
try { fs.rmSync(setupSessionPath, { force: true }); } catch { /* best effort */ }
|
|
3087
|
+
const data = JSON.parse(raw);
|
|
3088
|
+
return data && typeof data === "object" && data.session ? data : null;
|
|
3089
|
+
} catch {
|
|
3090
|
+
return null;
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
// Setup completion: point the user at the *remote* app (e.g. https://app.bivy.sh).
|
|
3095
|
+
// The node is a data plane and no longer hosts a UI, so the web/PWA app always
|
|
3096
|
+
// comes from the control plane — the same experience from any device, and it
|
|
3097
|
+
// works from a headless server.
|
|
3098
|
+
//
|
|
3099
|
+
// The local browser we open is signed into the user's *account* (using the
|
|
3100
|
+
// session they just authenticated with during relay:setup), so it lists ALL
|
|
3101
|
+
// their nodes and pre-selects the one just set up — matching "sign in and see
|
|
3102
|
+
// every node". Opening a node-scoped link grant instead (as this used to) made
|
|
3103
|
+
// `/nodes` return only this one node, so a user with other nodes appeared to land
|
|
3104
|
+
// on a different/empty account until they signed out and back in.
|
|
3105
|
+
//
|
|
3106
|
+
// The separately minted node-scoped paired link (E2E key embedded) is reserved
|
|
3107
|
+
// for the QR below, which is meant to be scanned by *another* device to pair it
|
|
3108
|
+
// with this node — there, node-scoping is the right least-privilege choice.
|
|
3109
|
+
// Everything is also printed as text so servers without a browser can copy it.
|
|
3110
|
+
// Open this node's REMOTE control-plane app in a local browser (best effort) and
|
|
3111
|
+
// return the URLs involved. The node no longer hosts a UI, so the web/PWA app
|
|
3112
|
+
// always comes from the hosted or self-hosted control plane. Prefers, in order:
|
|
3113
|
+
// an account sign-in URL (only available right after `relay:setup`, when a
|
|
3114
|
+
// setupSession is supplied) → a freshly minted node-scoped paired link → the
|
|
3115
|
+
// plain remote base URL. Prints a clean "Opening <base>" line — never the
|
|
3116
|
+
// tokenized fragment, so no session/pairing secret lands in terminal scrollback.
|
|
3117
|
+
// Returns null when no relay is configured (caller should send the user to
|
|
3118
|
+
// `bivy relay:setup`).
|
|
3119
|
+
async function openRemoteApp(config, { setupSession = null, open = true } = {}) {
|
|
3120
|
+
const relay = loadRelayConfig();
|
|
3121
|
+
if (!relay) return null;
|
|
3122
|
+
|
|
3123
|
+
const remoteBase = String(relay.clientBaseUrl || (await getHostedEndpoints()).clientBaseUrl || "").replace(/\/+$/, "");
|
|
3124
|
+
|
|
3125
|
+
// Account sign-in URL: the fragment carries the account session from
|
|
3126
|
+
// relay:setup (so /nodes returns every node) plus this node's id to pre-select
|
|
3127
|
+
// it. Same shape the control plane redirects to after a web GitHub sign-in, so
|
|
3128
|
+
// the app's consumeLinkPayload folds it in the same way.
|
|
3129
|
+
let accountUrl = "";
|
|
3130
|
+
if (setupSession?.session && remoteBase) {
|
|
3131
|
+
const payload = {
|
|
3132
|
+
controlPlane: relay.controlPlaneUrl,
|
|
3133
|
+
relay: relay.url,
|
|
3134
|
+
session: setupSession.session,
|
|
3135
|
+
...(setupSession.nodeId ? { node: { id: setupSession.nodeId } } : {}),
|
|
3136
|
+
};
|
|
3137
|
+
accountUrl = `${remoteBase}/#${Buffer.from(JSON.stringify(payload)).toString("base64url")}`;
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
// Try to mint a node-scoped paired link (safe to embed in a QR another device
|
|
3141
|
+
// scans). Needs the node reachable, so wait briefly first.
|
|
3142
|
+
let pairedUrl = "";
|
|
3143
|
+
await waitForNode(config).catch(() => {});
|
|
3144
|
+
try {
|
|
3145
|
+
const token = await localDeviceToken(config);
|
|
3146
|
+
const data = await localApi(config, "/api/relay/link", {
|
|
3147
|
+
method: "POST",
|
|
3148
|
+
headers: { authorization: `Bearer ${token}` },
|
|
3149
|
+
body: "{}",
|
|
3150
|
+
});
|
|
3151
|
+
if (data?.url) pairedUrl = data.url;
|
|
3152
|
+
} catch {
|
|
3153
|
+
// fall back to the plain remote app URL below
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
const openUrl = accountUrl || pairedUrl || remoteBase;
|
|
3157
|
+
if (open && canOpenBrowser() && openUrl) {
|
|
3158
|
+
console.log(` Opening ${c.cyan(remoteBase || openUrl)} …`);
|
|
3159
|
+
openBrowser(openUrl);
|
|
3160
|
+
}
|
|
3161
|
+
return { relay, remoteBase, accountUrl, pairedUrl, openUrl };
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
function printFirstRunSteps() {
|
|
3165
|
+
console.log(" Run your first task:");
|
|
3166
|
+
console.log(` 1. Model access: ${c.cyan("bivy login")} ${c.dim("(for Pi; other agents use their own login)")}`);
|
|
3167
|
+
console.log(` 2. Start chatting: ${c.cyan("bivy")}`);
|
|
3168
|
+
console.log(` One-shot task: ${c.cyan('bivy exec "explain this repository"')}\n`);
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
async function finishSetupRemote(config, setupSession = null) {
|
|
3172
|
+
const openable = canOpenBrowser();
|
|
3173
|
+
const remote = await openRemoteApp(config, { setupSession });
|
|
3174
|
+
|
|
3175
|
+
if (!remote) {
|
|
3176
|
+
console.log("\n Almost there — enable remote access to open the Bivy app:");
|
|
3177
|
+
console.log(` • Enable remote: ${c.cyan("bivy relay:setup")} (then the app opens automatically)`);
|
|
3178
|
+
console.log(` • Check status: ${c.cyan("bivy status")}\n`);
|
|
3179
|
+
return;
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
const { remoteBase, pairedUrl } = remote;
|
|
3183
|
+
if (pairedUrl) {
|
|
3184
|
+
const qr = terminalQr(pairedUrl);
|
|
3185
|
+
if (qr) console.log(qr + "\n");
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
console.log("\n Access Bivy from anywhere:");
|
|
3189
|
+
if (remoteBase) console.log(` • Remote app: ${c.cyan(remoteBase)} (sign in with the same GitHub/email you just used)`);
|
|
3190
|
+
console.log(` • Link a device: ${c.cyan("bivy link")} (prints a QR to pair a phone/laptop with this node)`);
|
|
3191
|
+
console.log(` • Check status: ${c.cyan("bivy status")}`);
|
|
3192
|
+
if (!openable) {
|
|
3193
|
+
console.log(c.dim("\n No browser on this machine (headless server)? Open the Remote app URL above"));
|
|
3194
|
+
console.log(c.dim(" on your phone or laptop and sign in, or scan the QR above to pair a device with this node."));
|
|
3195
|
+
}
|
|
3196
|
+
console.log("");
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
async function cmdStart(args = []) {
|
|
3200
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
3201
|
+
console.log("Usage: bivy start\n\nRun the daemon in the foreground (Ctrl+C to stop). For a persistent background service, see 'bivy service install' or 'bivy setup'.");
|
|
3202
|
+
return;
|
|
3203
|
+
}
|
|
3204
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
3205
|
+
const config = loadConfig();
|
|
3206
|
+
console.log(c.green(`Starting node at ${url(config)} (Ctrl+C to stop)…`));
|
|
3207
|
+
const code = await run(nodeBin, nodeScriptArgs(serverEntry), { cwd: repoRoot, env: startEnv(config) });
|
|
3208
|
+
process.exit(code);
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
async function cmdStatus(args = []) {
|
|
3212
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
3213
|
+
console.log("Usage: bivy status [--json]\n\nShow config and whether the node is reachable. Exits non-zero when the node is down, so it works as a health gate in scripts.");
|
|
3214
|
+
return;
|
|
3215
|
+
}
|
|
3216
|
+
const json = args.includes("--json");
|
|
3217
|
+
const config = loadConfig();
|
|
3218
|
+
const reachable = await isReachable(config);
|
|
3219
|
+
// Non-zero exit when the node is down so `bivy status` works as a health gate
|
|
3220
|
+
// in scripts and monitoring.
|
|
3221
|
+
if (!reachable) process.exitCode = 1;
|
|
3222
|
+
let status = null;
|
|
3223
|
+
if (reachable) {
|
|
3224
|
+
try { status = await localApi(config, "/api/status"); } catch {}
|
|
3225
|
+
}
|
|
3226
|
+
if (json) {
|
|
3227
|
+
console.log(JSON.stringify({ reachable, url: url(config), workspace: status?.workspace || config.workspace, service: serviceStatusLine(), remoteConfigured: Boolean(status?.relay?.configured || fs.existsSync(relayConfigPath)), status }, null, 2));
|
|
3228
|
+
return;
|
|
3229
|
+
}
|
|
3230
|
+
console.log(c.bold("\n Bivy node\n"));
|
|
3231
|
+
console.log(` url: ${url(config)} ${reachable ? c.green("● reachable") : c.dim("○ not reachable")}`);
|
|
3232
|
+
console.log(` workspace: ${status?.workspace || config.workspace}`);
|
|
3233
|
+
console.log(` ${serviceStatusLine()}`);
|
|
3234
|
+
console.log(` remote: ${status?.relay?.configured || fs.existsSync(relayConfigPath) ? c.green("relay configured") : "local only"}`);
|
|
3235
|
+
const relay = loadRelayConfig();
|
|
3236
|
+
if (relay?.controlPlaneUrl && relay?.enrollmentToken) {
|
|
3237
|
+
try {
|
|
3238
|
+
const acct = await controlPlaneNodeApi(relay, "/node/account");
|
|
3239
|
+
const planName = { free: "Free", pro: "Pro", individual: "Pro", team: "Team" }[acct?.plan] || acct?.plan || "Free";
|
|
3240
|
+
const cap = acct?.entitlements?.maxNodes ?? "∞";
|
|
3241
|
+
const nodeLine = `${acct?.counts?.nodes ?? "?"} / ${cap} nodes`;
|
|
3242
|
+
const extras = acct?.entitlements?.workQueueEnabled ? "" : c.dim(" (Pro: unlimited nodes, push, GitHub queue)");
|
|
3243
|
+
console.log(` plan: ${planName} · ${nodeLine}${extras}`);
|
|
3244
|
+
} catch {
|
|
3245
|
+
// Offline or unenrolled — plan line is best-effort, never blocks status.
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
if (status) {
|
|
3249
|
+
console.log(` sessions: ${status.sessions?.open ?? 0} open, ${status.sessions?.indexed ?? 0} indexed${status.sessions?.active ? `, active ${status.sessions.active}` : ""}`);
|
|
3250
|
+
console.log(` devices: ${status.devices?.paired ?? 0} paired remote, ${status.devices?.localTokens ?? 0} local token(s)`);
|
|
3251
|
+
console.log(` approvals: ${status.approvals?.pending ?? 0} pending`);
|
|
3252
|
+
console.log(` guard: ${status.approvalMode || "autonomous"} (${status.guardrails?.workspaceBoundary ? "workspace boundary on" : "boundary unknown"})`);
|
|
3253
|
+
}
|
|
3254
|
+
console.log("");
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
// `bivy doctor` — one health screen: runtime deps, node reachability, model auth,
|
|
3258
|
+
// remote/relay, and agents on PATH.
|
|
3259
|
+
async function cmdDoctor(args = []) {
|
|
3260
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
3261
|
+
console.log("Usage: bivy doctor\n\nHealth check: runtime deps, node reachability, model auth, remote/relay, and agents on PATH. Exits non-zero if Node is unsupported or the node is unreachable, so it can gate CI/monitoring.");
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
3265
|
+
|
|
3266
|
+
const config = loadConfig();
|
|
3267
|
+
const reachable = await isReachable(config);
|
|
3268
|
+
let status = null;
|
|
3269
|
+
let runtimes = null;
|
|
3270
|
+
if (reachable) {
|
|
3271
|
+
try { status = await localApi(config, "/api/status"); } catch {}
|
|
3272
|
+
try { runtimes = await localApi(config, "/api/runtimes"); } catch {}
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
const ok = c.green("✓");
|
|
3276
|
+
const bad = c.red("✗");
|
|
3277
|
+
const warn = c.yellow("!");
|
|
3278
|
+
const mark = (good, soft = false) => (good ? ok : soft ? warn : bad);
|
|
3279
|
+
|
|
3280
|
+
console.log(c.bold("\n Bivy doctor\n"));
|
|
3281
|
+
console.log(` ${mark(hasSupportedNode())} Node ${process.version}${hasSupportedNode() ? "" : c.dim(" (needs >= 22.19.0)")}`);
|
|
3282
|
+
console.log(` ${mark(commandExists("git"), true)} git${commandExists("git") ? "" : c.dim(" (recommended for repo-backed sessions)")}`);
|
|
3283
|
+
console.log(` ${mark(reachable)} node ${reachable ? c.green("reachable") : c.dim("not reachable — 'bivy start'")} at ${url(config)}`);
|
|
3284
|
+
console.log(` ${mark(/running/.test(serviceStatusLine()), true)} ${serviceStatusLine()}`);
|
|
3285
|
+
const defaultAgent = String(config.env?.BIVY_RUNTIME || runtimes?.current?.id || "pi");
|
|
3286
|
+
const runtimeInfo = Array.isArray(runtimes?.runtimes) ? runtimes.runtimes.find((r) => r?.id === defaultAgent) : null;
|
|
3287
|
+
const agentAvailable = runtimeInfo ? runtimeInfo.status === "available" : defaultAgent === "pi";
|
|
3288
|
+
const setupAgent = setupAgentByRuntime(defaultAgent);
|
|
3289
|
+
const authOwner = runtimeInfo?.authOwner || (setupAgent?.needsBivyModel ? "bivy" : "agent");
|
|
3290
|
+
console.log(` ${mark(agentAvailable, true)} agent ${runtimeInfo?.displayName || defaultAgent}${agentAvailable ? "" : c.dim(" not available — install it or run 'bivy setup'")}`);
|
|
3291
|
+
console.log(` ${mark(hasModelConfig(config), authOwner !== "bivy")} model ${hasModelConfig(config) ? "configured" : authOwner === "bivy" ? c.dim("not configured — run 'bivy login'") : c.dim("agent-native auth — use the agent's CLI login if needed")}`);
|
|
3292
|
+
const relayConfigured = Boolean(status?.relay?.configured || fs.existsSync(relayConfigPath));
|
|
3293
|
+
console.log(` ${relayConfigured ? ok : c.dim("○")} remote ${relayConfigured ? (status?.relay?.connected ? c.green("relay connected") : "relay configured") : c.dim("local only — 'bivy relay:setup' to enable")}`);
|
|
3294
|
+
// Derived from BUILTIN_TERMINAL_AGENTS (the same list 'bivy agents'/'bivy run'
|
|
3295
|
+
// use) rather than a hand-maintained list, so it can't drift out of sync (#113).
|
|
3296
|
+
const agentCommands = [...BUILTIN_TERMINAL_AGENTS.values()].filter((a) => a.type === "command").map((a) => a.command);
|
|
3297
|
+
const agents = agentCommands.filter((a) => commandExists(a));
|
|
3298
|
+
console.log(` ${mark(agents.length > 0, true)} agents on PATH: ${agents.length ? c.cyan(agents.join(", ")) : c.dim("none (built-in Pi still works; 'bivy agents:install')")}`);
|
|
3299
|
+
console.log("");
|
|
3300
|
+
|
|
3301
|
+
// Fail the command when a hard check is red (unsupported Node or an
|
|
3302
|
+
// unreachable node), so `bivy doctor` is usable as a CI/monitoring gate.
|
|
3303
|
+
// Soft checks (git, service, model, agents) only warn.
|
|
3304
|
+
if (!hasSupportedNode() || !reachable) process.exitCode = 1;
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
// `bivy logs [-f] [-n N]` — tail the node's output, wherever it lands: the
|
|
3308
|
+
// systemd journal (Linux service), the launchd log files (macOS service), or the
|
|
3309
|
+
// background node.log captured by `bivy start`.
|
|
3310
|
+
async function cmdLogs(args = []) {
|
|
3311
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
3312
|
+
console.log("Usage: bivy logs [-f|--follow] [-n|--lines N]\n\nTail the node logs (systemd journal, launchd, or the background log from 'bivy start').");
|
|
3313
|
+
return;
|
|
3314
|
+
}
|
|
3315
|
+
const follow = args.includes("-f") || args.includes("--follow");
|
|
3316
|
+
const nArg = argValue(args, "lines") || argValue(args, "n");
|
|
3317
|
+
const lines = Number(nArg) > 0 ? String(Math.floor(Number(nArg))) : "80";
|
|
3318
|
+
const { kind, file } = servicePaths();
|
|
3319
|
+
|
|
3320
|
+
if (kind === "systemd" && fs.existsSync(file)) {
|
|
3321
|
+
const jargs = ["--user", "-u", SERVICE_UNIT, "-n", lines, "--no-pager"];
|
|
3322
|
+
if (follow) jargs.push("-f");
|
|
3323
|
+
await run("journalctl", jargs, { env: systemdUserEnv() });
|
|
3324
|
+
return;
|
|
3325
|
+
}
|
|
3326
|
+
if (kind === "launchd" && fs.existsSync(file)) {
|
|
3327
|
+
await tailFiles(["/tmp/bivy.log", "/tmp/bivy.err.log"], lines, follow);
|
|
3328
|
+
return;
|
|
3329
|
+
}
|
|
3330
|
+
if (fs.existsSync(nodeLogPath)) {
|
|
3331
|
+
await tailFiles([nodeLogPath], lines, follow);
|
|
3332
|
+
return;
|
|
3333
|
+
}
|
|
3334
|
+
console.log(c.yellow("No node logs found yet. Start the node with 'bivy start' or 'bivy service install'."));
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
// Tail one or more log files, optionally following. Uses `tail` when present
|
|
3338
|
+
// (handles -f and multiple files); falls back to a one-shot readTail otherwise.
|
|
3339
|
+
async function tailFiles(files, lines, follow) {
|
|
3340
|
+
const present = files.filter((f) => fs.existsSync(f));
|
|
3341
|
+
if (present.length === 0) { console.log(c.dim("No log output yet.")); return; }
|
|
3342
|
+
if (commandExists("tail")) {
|
|
3343
|
+
const tailArgs = ["-n", lines, ...(follow ? ["-f"] : []), ...present];
|
|
3344
|
+
await run("tail", tailArgs);
|
|
3345
|
+
return;
|
|
3346
|
+
}
|
|
3347
|
+
for (const f of present) {
|
|
3348
|
+
if (present.length > 1) console.log(c.dim(`==> ${f} <==`));
|
|
3349
|
+
console.log(readTail(f, Number(lines)));
|
|
3350
|
+
}
|
|
3351
|
+
if (follow) console.log(c.dim("(install 'tail' to follow logs live)"));
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
// Stream only this update's portion of update.log while the detached process is
|
|
3355
|
+
// alive. Keeping stdout pointed directly at the log lets the update survive the
|
|
3356
|
+
// node restart; polling it here still gives the web terminal live progress up to
|
|
3357
|
+
// the moment its PTY disappears.
|
|
3358
|
+
async function showDetachedUpdateProgress(child, start) {
|
|
3359
|
+
let offset = start;
|
|
3360
|
+
let finished = false;
|
|
3361
|
+
let spawnError = null;
|
|
3362
|
+
const decoder = new StringDecoder("utf8");
|
|
3363
|
+
child.once("exit", () => { finished = true; });
|
|
3364
|
+
child.once("error", (error) => {
|
|
3365
|
+
spawnError = error;
|
|
3366
|
+
finished = true;
|
|
3367
|
+
});
|
|
3368
|
+
|
|
3369
|
+
const copyNewOutput = () => {
|
|
3370
|
+
const fd = fs.openSync(updateLogPath, "r");
|
|
3371
|
+
try {
|
|
3372
|
+
const size = fs.fstatSync(fd).size;
|
|
3373
|
+
if (size < offset) offset = 0;
|
|
3374
|
+
if (size === offset) return;
|
|
3375
|
+
const buf = Buffer.alloc(size - offset);
|
|
3376
|
+
fs.readSync(fd, buf, 0, buf.length, offset);
|
|
3377
|
+
offset = size;
|
|
3378
|
+
process.stdout.write(decoder.write(buf));
|
|
3379
|
+
} finally {
|
|
3380
|
+
fs.closeSync(fd);
|
|
3381
|
+
}
|
|
3382
|
+
};
|
|
3383
|
+
|
|
3384
|
+
while (!finished) {
|
|
3385
|
+
copyNewOutput();
|
|
3386
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
3387
|
+
}
|
|
3388
|
+
copyNewOutput();
|
|
3389
|
+
const remainder = decoder.end();
|
|
3390
|
+
if (remainder) process.stdout.write(remainder);
|
|
3391
|
+
if (spawnError) console.error(c.red(`Could not start update: ${spawnError.message}`));
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
async function cmdUpdate(args = []) {
|
|
3395
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
3396
|
+
console.log("Usage: bivy update [--force|--no-wait]\n\nUpdate Bivy + install deps + restart service. Waits for active sessions to finish a turn first; --force/--no-wait skips the wait. See 'bivy update:log' for the last run's output.");
|
|
3397
|
+
return;
|
|
3398
|
+
}
|
|
3399
|
+
// Inside a Bivy web/PWA terminal the shell is a child of the node's own
|
|
3400
|
+
// process, so `restartService()` — the final step of an update — tears down
|
|
3401
|
+
// this very terminal. Run inline and you lose all output the moment it lands.
|
|
3402
|
+
// Instead, re-exec the update as a detached background process that outlives
|
|
3403
|
+
// the restart, logging to update.log. Mirror that log into this terminal while
|
|
3404
|
+
// the node is still alive; the web client reconnects automatically after the
|
|
3405
|
+
// restart, and `bivy update:log` remains available for the final output.
|
|
3406
|
+
// Outside a Bivy terminal (a normal shell, where the restart can't kill us) we
|
|
3407
|
+
// keep the simple inline flow.
|
|
3408
|
+
if (process.env.BIVY_TERMINAL === "1" && process.env.BIVY_UPDATE_DETACHED !== "1") {
|
|
3409
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
3410
|
+
const logFd = fs.openSync(updateLogPath, "a");
|
|
3411
|
+
const logStart = fs.fstatSync(logFd).size;
|
|
3412
|
+
fs.writeSync(logFd, `\n=== bivy update started ${new Date().toISOString()} ===\n`);
|
|
3413
|
+
const child = spawn(nodeBin, [selfScript, "update", ...args], {
|
|
3414
|
+
cwd: repoRoot,
|
|
3415
|
+
detached: true,
|
|
3416
|
+
stdio: ["ignore", logFd, logFd],
|
|
3417
|
+
env: { ...process.env, BIVY_UPDATE_DETACHED: "1" },
|
|
3418
|
+
});
|
|
3419
|
+
child.unref();
|
|
3420
|
+
fs.closeSync(logFd);
|
|
3421
|
+
console.log(c.green("Update started in the background. Showing progress until the node restarts…"));
|
|
3422
|
+
console.log(c.dim(`The terminal will reconnect automatically. Run ${c.cyan("bivy update:log")} afterward for the final output.`));
|
|
3423
|
+
await showDetachedUpdateProgress(child, logStart);
|
|
3424
|
+
return;
|
|
3425
|
+
}
|
|
3426
|
+
await runUpdate(args);
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3429
|
+
async function runUpdate(args = []) {
|
|
3430
|
+
const skipWait = args.includes("--force") || args.includes("--no-wait");
|
|
3431
|
+
const kind = detectInstallKind();
|
|
3432
|
+
|
|
3433
|
+
if (kind === "npx") {
|
|
3434
|
+
console.log(c.dim("This is an ephemeral 'npx bivy' run."));
|
|
3435
|
+
console.log(`${c.cyan("npx bivy")} always fetches the latest published version, so there is nothing to update.`);
|
|
3436
|
+
console.log(`To install a persistent copy you can manage: ${c.cyan("npm i -g @bivy/bivy")}`);
|
|
3437
|
+
return;
|
|
3438
|
+
}
|
|
3439
|
+
|
|
3440
|
+
if (kind === "npm-global") {
|
|
3441
|
+
console.log(c.dim("Updating the globally-installed bivy package…"));
|
|
3442
|
+
const code = await run("npm", ["install", "-g", "@bivy/bivy@latest", "--no-audit", "--no-fund"]);
|
|
3443
|
+
if (code !== 0) {
|
|
3444
|
+
console.log(c.yellow(`npm reported an issue (exit ${code}). Try: sudo npm i -g @bivy/bivy@latest`));
|
|
3445
|
+
process.exit(code);
|
|
3446
|
+
}
|
|
3447
|
+
await ensureBundledAgents();
|
|
3448
|
+
const config = loadConfig();
|
|
3449
|
+
await waitForIdleSessions(config, { skip: skipWait });
|
|
3450
|
+
if (config.service && restartService()) {
|
|
3451
|
+
console.log(c.green("Updated and restarted the background service."));
|
|
3452
|
+
} else {
|
|
3453
|
+
console.log(c.green("Updated. Run 'bivy start' (or restart your service) to apply."));
|
|
3454
|
+
}
|
|
3455
|
+
console.log(c.dim(`=== bivy update finished ${new Date().toISOString()} ===`));
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
if (kind === "packaged") {
|
|
3460
|
+
console.log(c.dim("Updating packaged Bivy install…"));
|
|
3461
|
+
// The actual restart happens inside install.sh, which shells out to
|
|
3462
|
+
// `bivy restart` (using the freshly-swapped binary) once the new code is in
|
|
3463
|
+
// place — that invocation waits for busy sessions on its own. This earlier
|
|
3464
|
+
// wait just avoids kicking off the download/swap while a turn is running,
|
|
3465
|
+
// so the window between "update starts" and "restart happens" doesn't
|
|
3466
|
+
// surprise anyone mid-turn.
|
|
3467
|
+
const config = loadConfig();
|
|
3468
|
+
await waitForIdleSessions(config, { skip: skipWait });
|
|
3469
|
+
const code = await run("bash", ["-c", "curl -fsSL https://bivy.sh/install.sh | bash"], {
|
|
3470
|
+
cwd: repoRoot,
|
|
3471
|
+
env: { ...process.env, BIVY_HOME: repoRoot },
|
|
3472
|
+
});
|
|
3473
|
+
process.exit(code);
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
console.log(c.dim("Pulling latest code…"));
|
|
3477
|
+
const branch = runQuiet("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot }).stdout.trim();
|
|
3478
|
+
const pull = await run("git", ["pull", "--ff-only", "origin", branch || "main"], { cwd: repoRoot });
|
|
3479
|
+
if (pull !== 0) console.log(c.yellow("git pull reported an issue; continuing."));
|
|
3480
|
+
await run("npm", [fs.existsSync(path.join(repoRoot, "package-lock.json")) ? "ci" : "install", "--no-audit", "--no-fund"], { cwd: repoRoot });
|
|
3481
|
+
await ensureBundledAgents();
|
|
3482
|
+
const config = loadConfig();
|
|
3483
|
+
await waitForIdleSessions(config, { skip: skipWait });
|
|
3484
|
+
if (config.service && restartService()) {
|
|
3485
|
+
console.log(c.green("Updated and restarted the background service."));
|
|
3486
|
+
} else {
|
|
3487
|
+
console.log(c.green("Updated. Run 'bivy start' (or restart your service) to apply."));
|
|
3488
|
+
}
|
|
3489
|
+
console.log(c.dim(`=== bivy update finished ${new Date().toISOString()} ===`));
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3492
|
+
// Print the update log (default: the tail; `-f`/`--follow` streams new output).
|
|
3493
|
+
// After a `bivy update` detaches and this terminal reconnects, this is how you
|
|
3494
|
+
// confirm the background update succeeded.
|
|
3495
|
+
function cmdUpdateLog(args) {
|
|
3496
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
3497
|
+
console.log("Usage: bivy update:log [-f|--follow]\n\nShow output of the last (or in-progress) 'bivy update' run.");
|
|
3498
|
+
return;
|
|
3499
|
+
}
|
|
3500
|
+
if (!fs.existsSync(updateLogPath)) {
|
|
3501
|
+
console.log(c.dim("No update log yet — run 'bivy update' first."));
|
|
3502
|
+
return;
|
|
3503
|
+
}
|
|
3504
|
+
const TAIL_BYTES = 64 * 1024;
|
|
3505
|
+
const readFrom = (start) => {
|
|
3506
|
+
const fd = fs.openSync(updateLogPath, "r");
|
|
3507
|
+
try {
|
|
3508
|
+
const size = fs.fstatSync(fd).size;
|
|
3509
|
+
if (size <= start) return { text: "", end: size };
|
|
3510
|
+
const buf = Buffer.alloc(size - start);
|
|
3511
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
3512
|
+
return { text: buf.toString("utf8"), end: size };
|
|
3513
|
+
} finally {
|
|
3514
|
+
fs.closeSync(fd);
|
|
3515
|
+
}
|
|
3516
|
+
};
|
|
3517
|
+
|
|
3518
|
+
const size = fs.statSync(updateLogPath).size;
|
|
3519
|
+
const first = readFrom(Math.max(0, size - TAIL_BYTES));
|
|
3520
|
+
process.stdout.write(first.text);
|
|
3521
|
+
|
|
3522
|
+
if (!(args.includes("-f") || args.includes("--follow"))) return;
|
|
3523
|
+
console.log(c.dim("\n— following (Ctrl-C to stop) —"));
|
|
3524
|
+
let offset = first.end;
|
|
3525
|
+
fs.watchFile(updateLogPath, { interval: 500 }, (cur) => {
|
|
3526
|
+
if (cur.size < offset) offset = 0; // rotated/truncated
|
|
3527
|
+
if (cur.size > offset) {
|
|
3528
|
+
const next = readFrom(offset);
|
|
3529
|
+
process.stdout.write(next.text);
|
|
3530
|
+
offset = next.end;
|
|
3531
|
+
}
|
|
3532
|
+
});
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
async function cmdLogin(args) {
|
|
3536
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
3537
|
+
console.log("Usage: bivy login [provider]\n\nSign into a model provider (Pi's native /login). With no provider, prompts interactively for the auth method and provider.");
|
|
3538
|
+
return;
|
|
3539
|
+
}
|
|
3540
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
3541
|
+
const config = loadConfig();
|
|
3542
|
+
const code = await run(nodeBin, [...nodeScriptArgs(bivyLoginEntry), ...args], {
|
|
3543
|
+
cwd: repoRoot,
|
|
3544
|
+
env: startEnv(config),
|
|
3545
|
+
});
|
|
3546
|
+
process.exit(code);
|
|
3547
|
+
}
|
|
3548
|
+
|
|
3549
|
+
async function cmdLinkPhone(args = []) {
|
|
3550
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
3551
|
+
console.log("Usage: bivy link\n\nShow a remote web/PWA link (and QR) in the terminal, single-use and short-lived (5 minutes). Requires 'bivy relay:setup' first.");
|
|
3552
|
+
return;
|
|
3553
|
+
}
|
|
3554
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
3555
|
+
const config = loadConfig();
|
|
3556
|
+
if (!(await isReachable(config))) {
|
|
3557
|
+
console.log(c.yellow(`The node is not reachable at ${url(config)}.`));
|
|
3558
|
+
if (config.service) console.log(`Try: ${c.cyan("bivy restart")} then ${c.cyan("bivy link")}`);
|
|
3559
|
+
else console.log(`Try: ${c.cyan("bivy start")} in another terminal, then ${c.cyan("bivy link")}`);
|
|
3560
|
+
return;
|
|
3561
|
+
}
|
|
3562
|
+
try {
|
|
3563
|
+
const token = await localDeviceToken(config);
|
|
3564
|
+
const data = await localApi(config, "/api/relay/link", {
|
|
3565
|
+
method: "POST",
|
|
3566
|
+
headers: { authorization: `Bearer ${token}` },
|
|
3567
|
+
body: "{}",
|
|
3568
|
+
});
|
|
3569
|
+
if (!data?.url) throw new Error("The node did not return a link URL.");
|
|
3570
|
+
console.log(c.bold(c.green("\n Link remote web/PWA\n")));
|
|
3571
|
+
const qr = terminalQr(data.url);
|
|
3572
|
+
if (qr) console.log(qr);
|
|
3573
|
+
console.log(`\nOpen or scan this link with the Bivy remote web/PWA:\n${c.cyan(data.url)}\n`);
|
|
3574
|
+
} catch (error) {
|
|
3575
|
+
console.error(c.red(error instanceof Error ? error.message : String(error)));
|
|
3576
|
+
console.log(c.dim("If hosted relay is not configured yet, run 'bivy relay:setup' first."));
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
function printUninstallHelp() {
|
|
3581
|
+
console.log(`
|
|
3582
|
+
${c.bold("bivy uninstall")} — remove Bivy and all its data from this machine
|
|
3583
|
+
|
|
3584
|
+
${c.cyan("bivy uninstall")} Remove everything (asks first)
|
|
3585
|
+
${c.cyan("bivy uninstall -y")} Skip the confirmation prompt
|
|
3586
|
+
${c.cyan("bivy uninstall --keep-sessions")} Keep sessions, picked back up on your next install
|
|
3587
|
+
${c.cyan("bivy uninstall --keep-worktrees")} Leave the git worktrees in your repos untouched
|
|
3588
|
+
${c.cyan("bivy uninstall --dry-run")} Show what would be removed, delete nothing
|
|
3589
|
+
|
|
3590
|
+
Removes the background service, the running node, the CLI symlink, the app
|
|
3591
|
+
install, and all local state — config, credentials, session transcripts, and
|
|
3592
|
+
--clone workspaces — plus the git worktrees Bivy created in your repos. A
|
|
3593
|
+
source (git) checkout keeps its code; only the .bivy state dir is removed.
|
|
3594
|
+
`);
|
|
3595
|
+
}
|
|
3596
|
+
|
|
3597
|
+
// `bivy uninstall` — remove Bivy from this machine: stop and delete the
|
|
3598
|
+
// background service, kill the running node, drop the CLI symlink, and delete
|
|
3599
|
+
// the app install plus all local state (config, credentials, session
|
|
3600
|
+
// transcripts, --clone workspaces) and the git worktrees Bivy created in your
|
|
3601
|
+
// repos. Deletes everything by default; --keep-sessions / --keep-worktrees opt
|
|
3602
|
+
// those out, --dry-run previews, and -y skips the prompt.
|
|
3603
|
+
async function cmdUninstall(args = []) {
|
|
3604
|
+
if (args.includes("-h") || args.includes("--help")) { printUninstallHelp(); return; }
|
|
3605
|
+
const yes = args.includes("-y") || args.includes("--yes");
|
|
3606
|
+
const dryRun = args.includes("--dry-run");
|
|
3607
|
+
const keepSessions = args.includes("--keep-sessions");
|
|
3608
|
+
const keepWorktrees = args.includes("--keep-worktrees");
|
|
3609
|
+
|
|
3610
|
+
const config = loadConfig();
|
|
3611
|
+
// A source checkout (has .git) keeps its code; we only remove the .bivy state
|
|
3612
|
+
// dir. A packaged install (no .git) is disposable, so the whole app dir goes.
|
|
3613
|
+
const isGitCheckout = fs.existsSync(path.join(repoRoot, ".git"));
|
|
3614
|
+
const symlink = path.join(os.homedir(), ".local", "bin", "bivy");
|
|
3615
|
+
const sessionsDir = path.join(appDir, "pi", "sessions");
|
|
3616
|
+
// The durable, Bivy-scoped session index (src/metadata.ts) — listAllSessions
|
|
3617
|
+
// unions it in for every session a runtime's own listing forgot (closed
|
|
3618
|
+
// sessions, a session started from the PWA, etc.) and backfills names/branch/
|
|
3619
|
+
// PR info. Without keeping this too, --keep-sessions preserves raw pi
|
|
3620
|
+
// transcripts but the CLI and React app still show nothing after reinstall.
|
|
3621
|
+
const metadataPath = path.join(appDir, "metadata.json");
|
|
3622
|
+
|
|
3623
|
+
// Git worktrees Bivy created live in your repos (*/.bivy/worktrees), not in the
|
|
3624
|
+
// app dir, so they must be found and removed explicitly (and their git
|
|
3625
|
+
// registration pruned). Scan the data dir and the configured workspace.
|
|
3626
|
+
const worktrees = keepWorktrees
|
|
3627
|
+
? []
|
|
3628
|
+
: findWorktreeRoots([appDir, config.workspace].filter(Boolean)).flatMap((r) => pruneListEntries(r, "dir"));
|
|
3629
|
+
const sessionCount = keepSessions ? 0 : pruneListEntries(sessionsDir, "any").length;
|
|
3630
|
+
|
|
3631
|
+
console.log(c.bold("\n bivy uninstall") + c.dim(dryRun ? " (dry run — nothing will be removed)\n" : "\n"));
|
|
3632
|
+
console.log(` ${serviceStatusLine()}`);
|
|
3633
|
+
console.log(` install: ${isGitCheckout ? `${repoRoot} ${c.dim("(git checkout — source kept, .bivy state removed)")}` : repoRoot}`);
|
|
3634
|
+
console.log(` state: ${appDir}`);
|
|
3635
|
+
console.log(` workspace: ${config.workspace || c.dim("(none)")} ${c.dim("(scanned for worktrees; the folder itself is kept)")}`);
|
|
3636
|
+
console.log(` sessions: ${keepSessions ? c.green("kept") : c.yellow(`${sessionCount}`) + " to remove"}`);
|
|
3637
|
+
console.log(` worktrees: ${keepWorktrees ? c.green("kept") : c.yellow(`${worktrees.length}`) + " to remove"}`);
|
|
3638
|
+
if (fs.existsSync(symlink)) console.log(` cli link: ${symlink}`);
|
|
3639
|
+
console.log("");
|
|
3640
|
+
|
|
3641
|
+
if (dryRun) {
|
|
3642
|
+
console.log(c.dim("Dry run: nothing was removed. Re-run without --dry-run to uninstall."));
|
|
3643
|
+
return;
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
const rl = createPrompter();
|
|
3647
|
+
try {
|
|
3648
|
+
if (!yes) {
|
|
3649
|
+
const ok = await rl.askYesNo("Uninstall Bivy and delete the above? This cannot be undone.", false);
|
|
3650
|
+
if (!ok) { console.log(c.dim("Cancelled.")); return; }
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
// Remove this node from your Bivy account (hosted control plane).
|
|
3654
|
+
if (fs.existsSync(relayConfigPath) && (yes || await rl.askYesNo("Remove this node from your Bivy account too?", true))) {
|
|
3655
|
+
try {
|
|
3656
|
+
const relayConfig = JSON.parse(fs.readFileSync(relayConfigPath, "utf8"));
|
|
3657
|
+
if (relayConfig.controlPlaneUrl && relayConfig.enrollmentToken) {
|
|
3658
|
+
const res = await fetch(`${String(relayConfig.controlPlaneUrl).replace(/\/$/, "")}/node`, {
|
|
3659
|
+
method: "DELETE",
|
|
3660
|
+
headers: { authorization: `Bearer ${relayConfig.enrollmentToken}` },
|
|
3661
|
+
});
|
|
3662
|
+
console.log(res.ok ? c.green("Removed hosted node registration.") : c.yellow(`Could not remove hosted node registration (${res.status}).`));
|
|
3663
|
+
}
|
|
3664
|
+
} catch (error) {
|
|
3665
|
+
console.log(c.yellow(`Could not remove hosted node registration: ${error instanceof Error ? error.message : String(error)}`));
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
// Stop and remove the background service, then kill any node still running.
|
|
3670
|
+
uninstallService();
|
|
3671
|
+
runQuiet("pkill", ["-f", serverEntry]);
|
|
3672
|
+
runQuiet("pkill", ["-f", path.join(repoRoot, "dist/server.js")]);
|
|
3673
|
+
|
|
3674
|
+
// Remove the git worktrees Bivy created in your repos, then drop their now
|
|
3675
|
+
// dangling git registration (mirrors `bivy prune`).
|
|
3676
|
+
if (worktrees.length) {
|
|
3677
|
+
const touchedRepos = new Set();
|
|
3678
|
+
let removed = 0;
|
|
3679
|
+
for (const w of worktrees) {
|
|
3680
|
+
try {
|
|
3681
|
+
fs.rmSync(w.path, { recursive: true, force: true });
|
|
3682
|
+
removed++;
|
|
3683
|
+
touchedRepos.add(path.dirname(path.dirname(path.dirname(w.path))));
|
|
3684
|
+
} catch (error) {
|
|
3685
|
+
console.log(c.yellow(` could not remove worktree ${w.path}: ${error instanceof Error ? error.message : String(error)}`));
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
if (commandExists("git")) for (const repo of touchedRepos) runQuiet("git", ["-C", repo, "worktree", "prune"]);
|
|
3689
|
+
console.log(c.green(`Removed ${removed} worktree(s).`));
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
// Delete the app + all local state. A git checkout keeps its source (only
|
|
3693
|
+
// the .bivy state dir goes); a packaged install removes the whole app dir.
|
|
3694
|
+
// With --keep-sessions, the pi transcripts and the session index are left
|
|
3695
|
+
// in place rather than deleted (see removeExcept) — everything else in
|
|
3696
|
+
// that state dir (config, credentials, relay enrollment, …) is still
|
|
3697
|
+
// removed as normal.
|
|
3698
|
+
const keepPaths = keepSessions ? [sessionsDir, metadataPath].filter((p) => fs.existsSync(p)) : [];
|
|
3699
|
+
removeExcept(isGitCheckout ? appDir : repoRoot, keepPaths);
|
|
3700
|
+
fs.rmSync(symlink, { force: true });
|
|
3701
|
+
|
|
3702
|
+
console.log(c.green("\nBivy uninstalled from this machine."));
|
|
3703
|
+
if (keepSessions) console.log(c.dim("Your sessions were left in place and will be picked back up the next time Bivy is installed here."));
|
|
3704
|
+
if (keepWorktrees) console.log(c.dim("Your git worktrees were left untouched."));
|
|
3705
|
+
if (isGitCheckout) console.log(c.dim(`Source checkout kept at ${repoRoot} — remove it manually if you no longer need it.`));
|
|
3706
|
+
} finally {
|
|
3707
|
+
rl.close();
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
async function cmdRelaySetup(args) {
|
|
3712
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
3713
|
+
console.log("Usage: bivy relay:setup [--email <email>|--github] [--session-token <token>]\n\nEnable secure remote web/PWA access (sign in once). With no flags, prompts interactively for GitHub or email sign-in.");
|
|
3714
|
+
return;
|
|
3715
|
+
}
|
|
3716
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
3717
|
+
const config = loadConfig();
|
|
3718
|
+
let passthrough = args;
|
|
3719
|
+
if (!args.includes("--email") && !args.includes("--session-token") && !args.includes("--github")) {
|
|
3720
|
+
const rl = createPrompter();
|
|
3721
|
+
const useGithub = await rl.askYesNo("Sign in with GitHub?", true);
|
|
3722
|
+
if (useGithub) {
|
|
3723
|
+
passthrough = [...args, "--github"];
|
|
3724
|
+
} else {
|
|
3725
|
+
const email = await rl.ask("Your account email:", config.env.BIVY_EMAIL || "");
|
|
3726
|
+
if (!email) {
|
|
3727
|
+
rl.close();
|
|
3728
|
+
console.log(c.yellow("No email provided; nothing to do."));
|
|
3729
|
+
return;
|
|
3730
|
+
}
|
|
3731
|
+
passthrough = [...args, "--email", email];
|
|
3732
|
+
}
|
|
3733
|
+
rl.close();
|
|
3734
|
+
}
|
|
3735
|
+
const code = await run(nodeBin, [...nodeScriptArgs(relaySetupEntry), ...passthrough], {
|
|
3736
|
+
cwd: repoRoot,
|
|
3737
|
+
env: startEnv(config),
|
|
3738
|
+
});
|
|
3739
|
+
if (code !== 0) process.exit(code);
|
|
3740
|
+
|
|
3741
|
+
if (restartService()) {
|
|
3742
|
+
console.log(c.green("Service restarted with relay enabled."));
|
|
3743
|
+
return;
|
|
3744
|
+
}
|
|
3745
|
+
|
|
3746
|
+
if (await isReachable(config)) {
|
|
3747
|
+
try {
|
|
3748
|
+
const token = await localDeviceToken(config);
|
|
3749
|
+
await localApi(config, "/api/relay/reload", {
|
|
3750
|
+
method: "POST",
|
|
3751
|
+
headers: { authorization: `Bearer ${token}` },
|
|
3752
|
+
body: "{}",
|
|
3753
|
+
});
|
|
3754
|
+
console.log(c.green("Running node reloaded relay config; no restart needed."));
|
|
3755
|
+
return;
|
|
3756
|
+
} catch (error) {
|
|
3757
|
+
console.log(c.yellow(`Could not hot-reload the running node: ${error instanceof Error ? error.message : String(error)}`));
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3761
|
+
console.log(c.yellow("Relay is configured. Start the node with 'bivy start' to connect."));
|
|
3762
|
+
}
|
|
3763
|
+
|
|
3764
|
+
async function cmdService(args) {
|
|
3765
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
3766
|
+
console.log("Usage: bivy service <install|uninstall|status>\n\nManage the background service (systemd on Linux, launchd on macOS) that keeps the node running across reboots.");
|
|
3767
|
+
return;
|
|
3768
|
+
}
|
|
3769
|
+
const action = args[0];
|
|
3770
|
+
if (action === "install") {
|
|
3771
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
3772
|
+
await installService(loadConfig());
|
|
3773
|
+
} else if (action === "uninstall" || action === "remove") {
|
|
3774
|
+
uninstallService();
|
|
3775
|
+
} else if (action === "status") {
|
|
3776
|
+
console.log(serviceStatusLine());
|
|
3777
|
+
} else {
|
|
3778
|
+
console.error(c.red(`${action ? `Unknown service action: ${action}. ` : ""}Usage: bivy service <install|uninstall|status>`));
|
|
3779
|
+
process.exit(1);
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
|
|
3783
|
+
// Read this CLI's version from the shipped package.json. Best-effort: a missing
|
|
3784
|
+
// or malformed manifest should never crash `bivy --version`.
|
|
3785
|
+
function readSelfVersion() {
|
|
3786
|
+
try {
|
|
3787
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
|
3788
|
+
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
3789
|
+
} catch {
|
|
3790
|
+
return "unknown";
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3794
|
+
function printHelp() {
|
|
3795
|
+
console.log(`
|
|
3796
|
+
${c.bold("bivy")} — Bivy node CLI
|
|
3797
|
+
|
|
3798
|
+
${c.cyan("bivy run claude")} Run a native agent (real CLI/TUI) as a relay-visible session
|
|
3799
|
+
${c.cyan("bivy run <agent>")} ${[...BUILTIN_TERMINAL_AGENTS.keys()].join(" | ")} | -- <command>
|
|
3800
|
+
${c.cyan("bivy run <agent> --name <label>")} Name the session (shown in 'bivy sessions' and the app)
|
|
3801
|
+
${c.cyan("bivy run <agent> --model <model>")} Run with a specific model (passed to the agent, shown in the cockpit)
|
|
3802
|
+
${c.cyan("bivy run <agent> --node <name>")} Start the session on another registered node
|
|
3803
|
+
${c.cyan("bivy run <agent> --clone [remote]")} Start in a fresh clone (current repo, or a given remote)
|
|
3804
|
+
${c.cyan("bivy run <agent> --workspace <dir>")} Start in an existing directory (default: current repo, else the configured workspace)
|
|
3805
|
+
${c.cyan("bivy nodes")} List/add/remove other nodes (add <name> <url> --token <t>)
|
|
3806
|
+
${c.cyan("bivy agents")} List the supported agents and which are installed (--json)
|
|
3807
|
+
${c.cyan("bivy shim install <agent>")} Make interactive '<agent>' launch its native TUI in a Bivy PTY (remote-visible)
|
|
3808
|
+
${c.cyan("bivy shim")} List installed agent shims (install/uninstall/status)
|
|
3809
|
+
${c.cyan("bivy takeover <id>")} Stop a pinned run-terminal's native TUI and continue it as a governed chat
|
|
3810
|
+
${c.cyan("bivy token")} Print a device token for this node (for 'bivy nodes add' elsewhere)
|
|
3811
|
+
${c.cyan("bivy sessions")} List recent sessions (live + saved) and resume one (alias: ls)
|
|
3812
|
+
${c.cyan("bivy resume")} [n|id] Resume a session directly (default: most recent)
|
|
3813
|
+
${c.cyan("bivy send <id>")} "..." Send a prompt to an existing session and stream the reply
|
|
3814
|
+
${c.cyan("bivy kill <id>")} Stop a session/terminal (--delete also removes a saved session)
|
|
3815
|
+
${c.cyan("bivy prune")} Delete old sessions/workspaces/worktrees (--keep N, --older-than 7d, --dry-run)
|
|
3816
|
+
${c.cyan("bivy exec")} "<prompt>" One-shot headless run: prints the answer to stdout (pipe-friendly)
|
|
3817
|
+
${c.cyan("bivy")} Launch the default agent (pi) as a managed, relay-visible session
|
|
3818
|
+
${c.cyan("bivy setup")} First-run wizard: workspace, remote access + sign-in, background service
|
|
3819
|
+
${c.cyan("bivy start")} Run the daemon in the foreground
|
|
3820
|
+
${c.cyan("bivy stop")} Stop the background service
|
|
3821
|
+
${c.cyan("bivy restart")} Restart the background service (waits for active sessions to finish a turn; --force to skip)
|
|
3822
|
+
${c.cyan("bivy status")} Show config and whether the node is reachable
|
|
3823
|
+
${c.cyan("bivy doctor")} Health check: deps, node, model, remote, agents
|
|
3824
|
+
${c.cyan("bivy logs")} [-f] Tail the node logs (systemd journal, launchd, or background log)
|
|
3825
|
+
${c.cyan("bivy login")} Sign into a model provider (Pi /login)
|
|
3826
|
+
${c.cyan("bivy update")} Update Bivy + install deps + restart service (waits for active sessions to finish a turn; --force to skip)
|
|
3827
|
+
${c.cyan("bivy update:log")} Show output of the last (or in-progress) update
|
|
3828
|
+
${c.cyan("bivy agents:install")} Install bundled agents (${BUNDLED_AGENTS.map((a) => a.label).join(", ")})
|
|
3829
|
+
${c.cyan("bivy open")} Open the remote web/PWA app
|
|
3830
|
+
${c.cyan("bivy service")} install | uninstall | status
|
|
3831
|
+
${c.cyan("bivy uninstall")} Remove Bivy and all its data (--keep-sessions, --keep-worktrees, --dry-run)
|
|
3832
|
+
${c.cyan("bivy link")} Show a remote web/PWA link QR in the terminal
|
|
3833
|
+
${c.cyan("bivy relay:setup")} Enable secure remote web/PWA access (sign in once)
|
|
3834
|
+
${c.cyan("bivy github:app-create")} One-click: create + connect a GitHub App
|
|
3835
|
+
${c.cyan("bivy github:app-connect")} Connect an existing GitHub App (--app-id --key)
|
|
3836
|
+
${c.cyan("bivy github:app-sync")} [on|off] Sync connected GitHub App keys to this account's other opted-in nodes
|
|
3837
|
+
${c.cyan("bivy github:connect")} [owner/repo] Authorize repo access for the repo picker (device flow)
|
|
3838
|
+
${c.cyan("bivy secrets")} list | set | ref | delete | doctor | resolve
|
|
3839
|
+
${c.cyan("bivy voice")} Configure speech-to-text: provider | key | remove | status
|
|
3840
|
+
${c.cyan("bivy completions")} <bash|zsh|fish> Print a shell completion script
|
|
3841
|
+
${c.cyan("bivy version")} Print the installed Bivy version (alias: --version, -v)
|
|
3842
|
+
`);
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
async function main() {
|
|
3846
|
+
const argv = process.argv.slice(2);
|
|
3847
|
+
const [command, ...args] = argv;
|
|
3848
|
+
switch (command) {
|
|
3849
|
+
case undefined:
|
|
3850
|
+
// First run opens the guided setup; after setup, bare `bivy` launches the
|
|
3851
|
+
// default agent's native CLI/TUI as a managed, relay-visible `bivy run`.
|
|
3852
|
+
if (fs.existsSync(cliConfigPath)) await cmdRun([]);
|
|
3853
|
+
else await cmdSetup();
|
|
3854
|
+
break;
|
|
3855
|
+
case "setup":
|
|
3856
|
+
case "init":
|
|
3857
|
+
await cmdSetup(args);
|
|
3858
|
+
break;
|
|
3859
|
+
case "start":
|
|
3860
|
+
case "dev":
|
|
3861
|
+
await cmdStart(args);
|
|
3862
|
+
break;
|
|
3863
|
+
case "run":
|
|
3864
|
+
// Only intercept bivy's own '--help'/'-h', and only when no agent was given
|
|
3865
|
+
// ('bivy run --help'/'bivy run -h'). Once an agent is named the rest of the
|
|
3866
|
+
// args (including its own --help) pass straight through to it — e.g.
|
|
3867
|
+
// 'bivy run claude --help' must show Claude's help, not bivy's.
|
|
3868
|
+
if (args[0] === "-h" || args[0] === "--help") {
|
|
3869
|
+
console.log(`Usage: bivy run <agent> [--name <label>] [--model <model>] [--node <name>] [--clone [remote]] [--workspace <dir>] | -- <command>
|
|
3870
|
+
|
|
3871
|
+
Run a native agent (real CLI/TUI) as a relay-visible session.
|
|
3872
|
+
Agents: ${[...BUILTIN_TERMINAL_AGENTS.keys()].join(", ")}, or -- <command> for anything else.
|
|
3873
|
+
An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
|
|
3874
|
+
break;
|
|
3875
|
+
}
|
|
3876
|
+
await cmdRun(args);
|
|
3877
|
+
break;
|
|
3878
|
+
case "sessions":
|
|
3879
|
+
case "ls":
|
|
3880
|
+
await cmdSessions(args);
|
|
3881
|
+
break;
|
|
3882
|
+
case "resume":
|
|
3883
|
+
await cmdSessions(args, { autoResume: true });
|
|
3884
|
+
break;
|
|
3885
|
+
case "promote":
|
|
3886
|
+
await cmdPromote(args);
|
|
3887
|
+
break;
|
|
3888
|
+
case "nodes":
|
|
3889
|
+
await cmdNodes(args);
|
|
3890
|
+
break;
|
|
3891
|
+
case "agents":
|
|
3892
|
+
cmdAgents(args);
|
|
3893
|
+
break;
|
|
3894
|
+
case "shim":
|
|
3895
|
+
case "listen":
|
|
3896
|
+
await cmdShim(args);
|
|
3897
|
+
break;
|
|
3898
|
+
case "takeover":
|
|
3899
|
+
await cmdTakeover(args);
|
|
3900
|
+
break;
|
|
3901
|
+
case "token":
|
|
3902
|
+
await cmdToken(args);
|
|
3903
|
+
break;
|
|
3904
|
+
case "exec":
|
|
3905
|
+
await cmdExec(args);
|
|
3906
|
+
break;
|
|
3907
|
+
case "kill":
|
|
3908
|
+
await cmdKill(args);
|
|
3909
|
+
break;
|
|
3910
|
+
case "prune":
|
|
3911
|
+
case "clean":
|
|
3912
|
+
await cmdPrune(args);
|
|
3913
|
+
break;
|
|
3914
|
+
case "send":
|
|
3915
|
+
await cmdSend(args);
|
|
3916
|
+
break;
|
|
3917
|
+
case "completions":
|
|
3918
|
+
case "completion":
|
|
3919
|
+
cmdCompletions(args);
|
|
3920
|
+
break;
|
|
3921
|
+
case "stop":
|
|
3922
|
+
if (args.includes("-h") || args.includes("--help")) { console.log("Usage: bivy stop\n\nStop the background service."); break; }
|
|
3923
|
+
stopService();
|
|
3924
|
+
break;
|
|
3925
|
+
case "restart":
|
|
3926
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
3927
|
+
console.log("Usage: bivy restart [--force|--no-wait]\n\nRestart the background service. Waits for active sessions to finish a turn first; --force/--no-wait skips the wait.");
|
|
3928
|
+
break;
|
|
3929
|
+
}
|
|
3930
|
+
await waitForIdleSessions(loadConfig(), { skip: args.includes("--force") || args.includes("--no-wait") });
|
|
3931
|
+
if (restartService()) {
|
|
3932
|
+
console.log(c.green("Service restarted."));
|
|
3933
|
+
} else if (fs.existsSync(servicePaths().file)) {
|
|
3934
|
+
console.error(c.red("Failed to restart the background service."));
|
|
3935
|
+
printNodeStartupDiagnostics();
|
|
3936
|
+
} else {
|
|
3937
|
+
console.log(c.yellow("No background service to restart. Use 'bivy start'."));
|
|
3938
|
+
}
|
|
3939
|
+
break;
|
|
3940
|
+
case "status":
|
|
3941
|
+
await cmdStatus(args);
|
|
3942
|
+
break;
|
|
3943
|
+
case "doctor":
|
|
3944
|
+
await cmdDoctor(args);
|
|
3945
|
+
break;
|
|
3946
|
+
case "logs":
|
|
3947
|
+
await cmdLogs(args);
|
|
3948
|
+
break;
|
|
3949
|
+
case "login":
|
|
3950
|
+
await cmdLogin(args);
|
|
3951
|
+
break;
|
|
3952
|
+
case "update":
|
|
3953
|
+
await cmdUpdate(args);
|
|
3954
|
+
break;
|
|
3955
|
+
case "update:log":
|
|
3956
|
+
cmdUpdateLog(args);
|
|
3957
|
+
break;
|
|
3958
|
+
case "agents:install":
|
|
3959
|
+
case "runtimes:install":
|
|
3960
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
3961
|
+
console.log(`Usage: bivy agents:install\n\nInstall bundled agent runtimes (${BUNDLED_AGENTS.map((a) => a.label).join(", ")}).`);
|
|
3962
|
+
break;
|
|
3963
|
+
}
|
|
3964
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
3965
|
+
await ensureBundledAgents();
|
|
3966
|
+
break;
|
|
3967
|
+
case "open": {
|
|
3968
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
3969
|
+
console.log("Usage: bivy open\n\nOpen the remote web/PWA app in your browser (requires 'bivy relay:setup' first).");
|
|
3970
|
+
break;
|
|
3971
|
+
}
|
|
3972
|
+
const config = loadConfig();
|
|
3973
|
+
const remote = await openRemoteApp(config);
|
|
3974
|
+
if (!remote) {
|
|
3975
|
+
console.log("No remote access configured yet.");
|
|
3976
|
+
console.log(`Run ${c.cyan("bivy relay:setup")} to enable the web/PWA app, then ${c.cyan("bivy open")}.`);
|
|
3977
|
+
} else if (!canOpenBrowser()) {
|
|
3978
|
+
console.log(`Open the Bivy app here: ${c.cyan(remote.remoteBase)}`);
|
|
3979
|
+
}
|
|
3980
|
+
break;
|
|
3981
|
+
}
|
|
3982
|
+
case "service":
|
|
3983
|
+
await cmdService(args);
|
|
3984
|
+
break;
|
|
3985
|
+
case "uninstall":
|
|
3986
|
+
await cmdUninstall(args);
|
|
3987
|
+
break;
|
|
3988
|
+
case "link":
|
|
3989
|
+
await cmdLinkPhone(args);
|
|
3990
|
+
break;
|
|
3991
|
+
case "relay:setup":
|
|
3992
|
+
await cmdRelaySetup(args);
|
|
3993
|
+
break;
|
|
3994
|
+
case "github:connect":
|
|
3995
|
+
case "connect-repo":
|
|
3996
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
3997
|
+
console.log("Usage: bivy github:connect [owner/repo]\n\nAuthorize repo access for the repo picker via GitHub's device flow. The resulting repo-scoped token is stored in this node's encrypted local vault.");
|
|
3998
|
+
break;
|
|
3999
|
+
}
|
|
4000
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
4001
|
+
process.exit(await run(nodeBin, [...nodeScriptArgs(githubConnectEntry), ...args], { cwd: repoRoot, env: process.env }));
|
|
4002
|
+
break;
|
|
4003
|
+
case "github:app-connect":
|
|
4004
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
4005
|
+
console.log("Usage: bivy github:app-connect --app-id <id>|--slug <slug> --key <path.pem> [--label <name>] [--node-label <label>] [--rotate-webhook]\n\nConnect an existing GitHub App. The private key stays in this node's local vault.");
|
|
4006
|
+
break;
|
|
4007
|
+
}
|
|
4008
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
4009
|
+
process.exit(await run(nodeBin, [...nodeScriptArgs(githubAppConnectEntry), ...args], { cwd: repoRoot, env: process.env }));
|
|
4010
|
+
break;
|
|
4011
|
+
case "github:app-sync":
|
|
4012
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
4013
|
+
console.log("Usage: bivy github:app-sync [on|off]\n\nSync connected GitHub App keys (E2E-encrypted) to this account's other opted-in nodes. With no argument, prints the current status.");
|
|
4014
|
+
break;
|
|
4015
|
+
}
|
|
4016
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
4017
|
+
process.exit(await run(nodeBin, [...nodeScriptArgs(githubAppSyncEntry), ...args], { cwd: repoRoot, env: process.env }));
|
|
4018
|
+
break;
|
|
4019
|
+
case "github:app-create": {
|
|
4020
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
4021
|
+
console.log("Usage: bivy github:app-create [--org <org>]\n\nOne-click: create + connect a GitHub App. Opens the manifest flow in your browser (or prints instructions on a headless server). The private key never leaves this node.");
|
|
4022
|
+
break;
|
|
4023
|
+
}
|
|
4024
|
+
// One-click: open the node's manifest flow in the browser. The node
|
|
4025
|
+
// creates the app on GitHub and keeps the private key locally.
|
|
4026
|
+
const nodePort = Number(process.env.PORT || 4317);
|
|
4027
|
+
const orgArg = argValue(args, "org");
|
|
4028
|
+
const manifestUrl = `http://localhost:${nodePort}/github/app/manifest/new${orgArg ? `?org=${encodeURIComponent(orgArg)}` : ""}`;
|
|
4029
|
+
if (canOpenBrowser()) {
|
|
4030
|
+
console.log("Opening GitHub to create your Bivy app (the private key stays on this node)…");
|
|
4031
|
+
console.log(` ${manifestUrl}`);
|
|
4032
|
+
console.log(c.dim("If the node isn't running, start it first: bivy start"));
|
|
4033
|
+
openBrowser(manifestUrl);
|
|
4034
|
+
} else {
|
|
4035
|
+
// Headless server: no local browser to open. The manifest flow still
|
|
4036
|
+
// works, but the browser step has to happen elsewhere. The code exchange
|
|
4037
|
+
// always runs on this node, so the private key never leaves it.
|
|
4038
|
+
console.log("This looks like a headless server (no browser to open).\n");
|
|
4039
|
+
console.log("Easiest — set it up from the web app (works remotely):");
|
|
4040
|
+
console.log(c.dim(" Open Bivy in any browser (locally or via your hosted control plane),"));
|
|
4041
|
+
console.log(c.dim(" go to Settings → GitHub issues → GitHub App, and click 'Create GitHub"));
|
|
4042
|
+
console.log(c.dim(" App'. The code is relayed back here for exchange; the key stays local.\n"));
|
|
4043
|
+
console.log("Or — drive this CLI flow from a machine with a browser over an SSH tunnel:");
|
|
4044
|
+
console.log(` ssh -L ${nodePort}:localhost:${nodePort} <this-server>`);
|
|
4045
|
+
console.log(c.dim(" then open in that browser:"));
|
|
4046
|
+
console.log(` ${manifestUrl}\n`);
|
|
4047
|
+
console.log("Or — create the app yourself, then connect it (no browser at all):");
|
|
4048
|
+
console.log(c.dim(" Create a GitHub App (Issues/Contents/Pull requests: RW; events: Issues,"));
|
|
4049
|
+
console.log(c.dim(" Issue comment), download its .pem, then run:"));
|
|
4050
|
+
console.log(" bivy github:app-connect --app-id <id> --key <path.pem>");
|
|
4051
|
+
}
|
|
4052
|
+
break;
|
|
4053
|
+
}
|
|
4054
|
+
case "secrets":
|
|
4055
|
+
case "secret": {
|
|
4056
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
4057
|
+
// secrets-cli.ts only recognizes "--help"/"help" as the FIRST argument, so
|
|
4058
|
+
// a subcommand-position --help (e.g. 'bivy secrets list --help') would
|
|
4059
|
+
// otherwise be ignored and the live action would run anyway (#113).
|
|
4060
|
+
const forwardArgs = (args.includes("-h") || args.includes("--help")) ? ["--help"] : args;
|
|
4061
|
+
process.exit(await run(nodeBin, [...nodeScriptArgs(secretsEntry), ...forwardArgs], { cwd: repoRoot, env: process.env }));
|
|
4062
|
+
break;
|
|
4063
|
+
}
|
|
4064
|
+
case "voice":
|
|
4065
|
+
case "stt": {
|
|
4066
|
+
if (!(await ensureDeps())) process.exit(1);
|
|
4067
|
+
const forwardArgs = (args.includes("-h") || args.includes("--help")) ? ["--help"] : args;
|
|
4068
|
+
process.exit(await run(nodeBin, [...nodeScriptArgs(sttEntry), ...forwardArgs], { cwd: repoRoot, env: process.env }));
|
|
4069
|
+
break;
|
|
4070
|
+
}
|
|
4071
|
+
case "mcp-proxy":
|
|
4072
|
+
// Universal Agent Harness MCP proxy. Launched by an agent in front of its
|
|
4073
|
+
// MCP servers; its stdin/stdout ARE the JSON-RPC stream, so emit nothing
|
|
4074
|
+
// else here (no deps banner) and inherit stdio verbatim. Run in the
|
|
4075
|
+
// agent's cwd so relative server commands resolve.
|
|
4076
|
+
await run(nodeBin, [...nodeScriptArgs(mcpProxyEntry), ...args], { cwd: process.cwd(), env: process.env });
|
|
4077
|
+
break;
|
|
4078
|
+
case "help":
|
|
4079
|
+
case "-h":
|
|
4080
|
+
case "--help":
|
|
4081
|
+
printHelp();
|
|
4082
|
+
break;
|
|
4083
|
+
case "version":
|
|
4084
|
+
case "--version":
|
|
4085
|
+
case "-v":
|
|
4086
|
+
console.log(readSelfVersion());
|
|
4087
|
+
break;
|
|
4088
|
+
default:
|
|
4089
|
+
console.error(c.red(`Unknown command: ${command}`));
|
|
4090
|
+
printHelp();
|
|
4091
|
+
process.exit(1);
|
|
4092
|
+
}
|
|
4093
|
+
}
|
|
4094
|
+
|
|
4095
|
+
main().catch((error) => {
|
|
4096
|
+
// Show a clean message to users; the full stack is only useful with BIVY_DEBUG.
|
|
4097
|
+
console.error(c.red(error?.message || String(error)));
|
|
4098
|
+
if (process.env.BIVY_DEBUG && error?.stack) console.error(c.dim(error.stack));
|
|
4099
|
+
process.exit(1);
|
|
4100
|
+
});
|