@bivy/bivy 0.0.0 → 0.1.0-staging.10
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 +4215 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/port-picker.mjs +40 -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 +372 -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 +1408 -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 +279 -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 +1552 -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 +539 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +646 -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 +9435 -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 +90 -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 +195 -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
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import readline from "node:readline";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { NodeIdentity } from "./identity.js";
|
|
8
|
+
import { hostedEndpoints } from "./hosted-endpoints.mjs";
|
|
9
|
+
import { openBrowser } from "./browser-open.js";
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
/**
|
|
12
|
+
* One-time node relay setup.
|
|
13
|
+
*
|
|
14
|
+
* Signs into the control plane (hands-free magic-link by default), enrolls THIS
|
|
15
|
+
* node (using its stable nodeId), generates an E2E key, and writes
|
|
16
|
+
* `.bivy/relay.json`. After this the daemon dials the relay automatically
|
|
17
|
+
* on next start.
|
|
18
|
+
*
|
|
19
|
+
* Endpoints default to the baked-in hosted service (see hosted-endpoints.mjs);
|
|
20
|
+
* a user never has to type a URL. Everything is overridable for self-hosting:
|
|
21
|
+
*
|
|
22
|
+
* npm run relay:setup -- --email you@example.com
|
|
23
|
+
* BIVY_HOSTED_DOMAIN=bivy.sh npm run relay:setup -- --email you@…
|
|
24
|
+
* npm run relay:setup -- --control-plane https://app.x --relay wss://relay.x \
|
|
25
|
+
* --email you@example.com
|
|
26
|
+
*
|
|
27
|
+
* Sign-in is hands-free: we email a link, you click it in a browser, and this
|
|
28
|
+
* command detects completion by polling. Pass `--session-token` to skip sign-in
|
|
29
|
+
* when you already have an account session.
|
|
30
|
+
*/
|
|
31
|
+
function arg(name, fallback) {
|
|
32
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
33
|
+
if (i !== -1 && process.argv[i + 1])
|
|
34
|
+
return process.argv[i + 1];
|
|
35
|
+
return fallback;
|
|
36
|
+
}
|
|
37
|
+
function sleep(ms) {
|
|
38
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
39
|
+
}
|
|
40
|
+
async function fetchJson(url, init) {
|
|
41
|
+
let response;
|
|
42
|
+
try {
|
|
43
|
+
response = await fetch(url, init);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
47
|
+
throw new Error(`Could not reach ${url}: ${message}`);
|
|
48
|
+
}
|
|
49
|
+
const data = await response.json().catch(() => ({}));
|
|
50
|
+
return { ok: response.ok, status: response.status, data };
|
|
51
|
+
}
|
|
52
|
+
async function askYesNo(question, defaultYes) {
|
|
53
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
54
|
+
const hint = defaultYes ? "Y/n" : "y/N";
|
|
55
|
+
try {
|
|
56
|
+
for (;;) {
|
|
57
|
+
const answer = await new Promise((resolve) => rl.question(`\n${question} (${hint})\n > `, resolve));
|
|
58
|
+
const v = answer.trim().toLowerCase();
|
|
59
|
+
if (!v)
|
|
60
|
+
return defaultYes;
|
|
61
|
+
if (v === "y" || v === "yes")
|
|
62
|
+
return true;
|
|
63
|
+
if (v === "n" || v === "no")
|
|
64
|
+
return false;
|
|
65
|
+
console.log("Please answer yes or no.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
rl.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function checkControlPlane(controlPlaneUrl) {
|
|
73
|
+
// /me returns 401 when healthy and unauthenticated. That is good enough to
|
|
74
|
+
// catch DNS/TLS/reverse-proxy mistakes before the user waits for email.
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetch(`${controlPlaneUrl}/me`);
|
|
77
|
+
if (response.status === 401 || response.ok)
|
|
78
|
+
return;
|
|
79
|
+
throw new Error(`health check returned HTTP ${response.status}`);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
+
throw new Error(`Hosted control plane is not reachable at ${controlPlaneUrl}. ${message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Poll a started device login until it completes; returns the session token. */
|
|
87
|
+
async function pollDevice(controlPlaneUrl, start) {
|
|
88
|
+
const intervalMs = Number(start.intervalMs) || 2000;
|
|
89
|
+
const deadline = Date.now() + (Number(start.expiresInMs) || 15 * 60_000);
|
|
90
|
+
process.stdout.write("Waiting for you to finish sign-in");
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
await sleep(intervalMs);
|
|
93
|
+
process.stdout.write(".");
|
|
94
|
+
const pollResult = await fetchJson(`${controlPlaneUrl}/auth/device/poll`, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "content-type": "application/json" },
|
|
97
|
+
body: JSON.stringify({ deviceId: start.deviceId, deviceSecret: start.deviceSecret }),
|
|
98
|
+
}).catch(() => null);
|
|
99
|
+
const poll = pollResult?.data;
|
|
100
|
+
if (poll?.status === "complete" && poll.token) {
|
|
101
|
+
process.stdout.write("\n");
|
|
102
|
+
return poll.token;
|
|
103
|
+
}
|
|
104
|
+
if (poll?.status === "expired")
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
process.stdout.write("\n");
|
|
108
|
+
throw new Error("Sign-in timed out or expired. Run setup again.");
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Hands-free magic-link sign-in. Emails a link, opens/prints it, polls until
|
|
112
|
+
* clicked. Returns an account session token.
|
|
113
|
+
*/
|
|
114
|
+
async function deviceLogin(controlPlaneUrl, email) {
|
|
115
|
+
const started = await fetchJson(`${controlPlaneUrl}/auth/device/start`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "content-type": "application/json" },
|
|
118
|
+
body: JSON.stringify({ email }),
|
|
119
|
+
});
|
|
120
|
+
const start = started.data;
|
|
121
|
+
if (!started.ok || !start?.deviceId || !start?.deviceSecret) {
|
|
122
|
+
throw new Error(`Sign-in could not start (${started.status}): ${JSON.stringify(start)}`);
|
|
123
|
+
}
|
|
124
|
+
if (start.devLink) {
|
|
125
|
+
const opened = openBrowser(start.devLink);
|
|
126
|
+
console.log(opened
|
|
127
|
+
? `\nOpening sign-in link (no email configured on the server):\n ${start.devLink}`
|
|
128
|
+
: `\nNo email configured on the server — open this sign-in link in a browser (this machine has none):\n ${start.devLink}`);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
console.log(`\nWe emailed a sign-in link to ${email}. Open it in your browser to continue.`);
|
|
132
|
+
}
|
|
133
|
+
return pollDevice(controlPlaneUrl, start);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Hands-free GitHub sign-in (primary). Opens the GitHub authorize URL and polls
|
|
137
|
+
* until the OAuth callback completes the device login. Returns a session token.
|
|
138
|
+
*/
|
|
139
|
+
async function githubDeviceLogin(controlPlaneUrl) {
|
|
140
|
+
const started = await fetchJson(`${controlPlaneUrl}/auth/device/github/start`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "content-type": "application/json" },
|
|
143
|
+
body: "{}",
|
|
144
|
+
});
|
|
145
|
+
const start = started.data;
|
|
146
|
+
if (started.status === 501)
|
|
147
|
+
throw new Error("GitHub sign-in is not enabled on this control plane. Use --email instead.");
|
|
148
|
+
if (!started.ok || !start?.deviceId || !start?.authorizeUrl) {
|
|
149
|
+
throw new Error(`GitHub sign-in could not start (${started.status}): ${JSON.stringify(start)}`);
|
|
150
|
+
}
|
|
151
|
+
const opened = openBrowser(start.authorizeUrl);
|
|
152
|
+
console.log(opened
|
|
153
|
+
? `\nSign in with GitHub in your browser:\n ${start.authorizeUrl}`
|
|
154
|
+
: `\nSign in with GitHub — open this link in a browser on any device (this machine has none):\n ${start.authorizeUrl}`);
|
|
155
|
+
return pollDevice(controlPlaneUrl, start);
|
|
156
|
+
}
|
|
157
|
+
async function main() {
|
|
158
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
159
|
+
// Honor the same override as every other entry point (server.ts, native-pi.ts,
|
|
160
|
+
// bivy-login.ts, secrets-cli.ts, …) so a global/packaged install writes
|
|
161
|
+
// relay.json and the node identity into the real data dir instead of a
|
|
162
|
+
// package directory that gets wiped on update. See issue #2.
|
|
163
|
+
const appDir = process.env.BIVY_DATA_DIR ?? path.join(repoRoot, ".bivy");
|
|
164
|
+
const endpoints = hostedEndpoints();
|
|
165
|
+
const controlPlaneUrl = (arg("control-plane", process.env.BIVY_CONTROL_PLANE_URL) ?? endpoints.controlPlane).replace(/\/$/, "");
|
|
166
|
+
const relayUrl = (arg("relay", process.env.BIVY_RELAY_URL) ?? endpoints.relay).replace(/\/$/, "");
|
|
167
|
+
const clientBaseUrl = (arg("client", process.env.BIVY_CLIENT_BASE_URL) ?? endpoints.clientBaseUrl).replace(/\/$/, "") || controlPlaneUrl;
|
|
168
|
+
const email = arg("email", process.env.BIVY_EMAIL);
|
|
169
|
+
const sessionToken = arg("session-token", process.env.BIVY_SESSION_TOKEN);
|
|
170
|
+
// GitHub is the primary sign-in: used when --github is passed, or by default
|
|
171
|
+
// when neither an email nor an existing session token is supplied.
|
|
172
|
+
const useGithub = process.argv.includes("--github") || process.env.BIVY_AUTH === "github" || (!email && !sessionToken);
|
|
173
|
+
const identity = NodeIdentity.load(appDir);
|
|
174
|
+
console.log(`Node: ${identity.name} (${identity.nodeId})`);
|
|
175
|
+
console.log(`Control plane: ${controlPlaneUrl}`);
|
|
176
|
+
console.log(`Relay: ${relayUrl}`);
|
|
177
|
+
await checkControlPlane(controlPlaneUrl);
|
|
178
|
+
const token = sessionToken ?? (useGithub ? await githubDeviceLogin(controlPlaneUrl) : await deviceLogin(controlPlaneUrl, email));
|
|
179
|
+
async function enrollNode() {
|
|
180
|
+
return fetchJson(`${controlPlaneUrl}/nodes/enroll`, {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
183
|
+
body: JSON.stringify({ nodeId: identity.nodeId, name: identity.name }),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
let enrolled = await enrollNode();
|
|
187
|
+
let enroll = enrolled.data;
|
|
188
|
+
if (!enrolled.ok && enrolled.status === 402 && /node limit/i.test(String(enroll?.error ?? ""))) {
|
|
189
|
+
const listed = await fetchJson(`${controlPlaneUrl}/nodes`, { headers: { authorization: `Bearer ${token}` } });
|
|
190
|
+
const nodes = Array.isArray(listed.data) ? listed.data : [];
|
|
191
|
+
if (nodes.length) {
|
|
192
|
+
// Lead with the upgrade paths — subscribing or increasing the plan is the
|
|
193
|
+
// intended way to add a node. Removing an existing node is the fallback.
|
|
194
|
+
const accountUrl = `${clientBaseUrl}/?account=1`;
|
|
195
|
+
console.log("\nYour plan's node limit is reached.");
|
|
196
|
+
console.log(`To connect more nodes, subscribe or increase your plan:\n ${accountUrl}`);
|
|
197
|
+
console.log("\nOr free a slot by removing an existing node:");
|
|
198
|
+
for (const [i, node] of nodes.entries()) {
|
|
199
|
+
console.log(` ${i + 1}. ${node.name ?? "Node"} — ${node.online ? "online" : "offline"} — ${node.id}`);
|
|
200
|
+
}
|
|
201
|
+
const replace = await askYesNo(`Remove ${nodes[0].name ?? nodes[0].id} and enroll this node instead?`, !nodes[0].online);
|
|
202
|
+
if (replace) {
|
|
203
|
+
const removed = await fetchJson(`${controlPlaneUrl}/nodes/${encodeURIComponent(nodes[0].id)}`, {
|
|
204
|
+
method: "DELETE",
|
|
205
|
+
headers: { authorization: `Bearer ${token}` },
|
|
206
|
+
});
|
|
207
|
+
if (!removed.ok)
|
|
208
|
+
throw new Error(`Could not remove old node (${removed.status}): ${JSON.stringify(removed.data)}`);
|
|
209
|
+
enrolled = await enrollNode();
|
|
210
|
+
enroll = enrolled.data;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (!enrolled.ok || !enroll?.enrollmentToken)
|
|
215
|
+
throw new Error(`Enroll failed (${enrolled.status}): ${JSON.stringify(enroll)}`);
|
|
216
|
+
// The control plane keeps node names unique per account, so a colliding name may
|
|
217
|
+
// have been auto-suffixed (e.g. "Mac-2"). Adopt the assigned name locally so the
|
|
218
|
+
// node serves the matching `bivy/<name>` label (and the UI agrees on the name).
|
|
219
|
+
const assignedName = typeof enroll?.node?.name === "string" ? enroll.node.name : undefined;
|
|
220
|
+
if (assignedName && assignedName !== identity.name) {
|
|
221
|
+
try {
|
|
222
|
+
identity.setName(assignedName);
|
|
223
|
+
console.log(`Node name adjusted to "${assignedName}" (kept unique on your account).`);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* non-fatal — routing still works via the shared bivy label */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const config = {
|
|
230
|
+
url: relayUrl,
|
|
231
|
+
controlPlaneUrl,
|
|
232
|
+
clientBaseUrl,
|
|
233
|
+
enrollmentToken: enroll.enrollmentToken,
|
|
234
|
+
};
|
|
235
|
+
const filePath = path.join(appDir, "relay.json");
|
|
236
|
+
fs.writeFileSync(filePath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
237
|
+
fs.chmodSync(filePath, 0o600);
|
|
238
|
+
// Optional handoff: when `bivy setup` runs us with --emit-session, drop the
|
|
239
|
+
// account session we just obtained (plus this node's id) into a 0600 file so
|
|
240
|
+
// setup can open the remote app signed into the whole account — not a
|
|
241
|
+
// node-scoped grant that would show only this node. The account bearer never
|
|
242
|
+
// goes into relay.json (the node keeps only its enrollment token); this file is
|
|
243
|
+
// read once and deleted by setup. We skip it when the caller supplied a session
|
|
244
|
+
// token only via --session-token/env with no --emit-session, i.e. non-setup use.
|
|
245
|
+
const emitSession = arg("emit-session");
|
|
246
|
+
if (emitSession) {
|
|
247
|
+
try {
|
|
248
|
+
const handoff = JSON.stringify({ session: token, nodeId: identity.nodeId });
|
|
249
|
+
fs.writeFileSync(emitSession, `${handoff}\n`, { mode: 0o600 });
|
|
250
|
+
fs.chmodSync(emitSession, 0o600);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// best effort — setup falls back to opening the plain remote app URL
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
console.log(`\n✓ Signed in and enrolled this node. Wrote ${filePath}`);
|
|
257
|
+
console.log('Run "bivy link" to pair a phone, or use "Link remote device" in the app (bivy open).');
|
|
258
|
+
}
|
|
259
|
+
main().catch((error) => {
|
|
260
|
+
console.error(error);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { resolveSecret } from "./secrets.js";
|
|
8
|
+
import { cleanRemoteUrl, credConfigArgs, gitNonInteractiveEnv, configureRepoCredentialHelper } from "./git-auth.js";
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
/** Parse "owner/repo" (also tolerates a full github.com URL or trailing .git). */
|
|
11
|
+
export function parseRepo(input) {
|
|
12
|
+
const cleaned = String(input)
|
|
13
|
+
.trim()
|
|
14
|
+
.replace(/^https?:\/\/github\.com\//i, "")
|
|
15
|
+
.replace(/^git@github\.com:/i, "")
|
|
16
|
+
.replace(/\.git$/i, "")
|
|
17
|
+
.replace(/\/+$/, "");
|
|
18
|
+
const m = cleaned.match(/^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/);
|
|
19
|
+
return m ? { owner: m[1], repo: m[2], slug: `${m[1]}/${m[2]}` } : undefined;
|
|
20
|
+
}
|
|
21
|
+
/** Parse a GitHub owner/repo slug from a git remote URL. */
|
|
22
|
+
export function parseGitHubRemote(input) {
|
|
23
|
+
const value = String(input).trim();
|
|
24
|
+
const https = value.match(/^https?:\/\/(?:[^/@]+(?::[^/@]*)?@)?github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/i);
|
|
25
|
+
if (https)
|
|
26
|
+
return parseRepo(`${https[1]}/${https[2]}`);
|
|
27
|
+
const ssh = value.match(/^(?:ssh:\/\/)?git@github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/i);
|
|
28
|
+
if (ssh)
|
|
29
|
+
return parseRepo(`${ssh[1]}/${ssh[2]}`);
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
/** Infer owner/repo from a workspace's origin remote, if it is a GitHub checkout. */
|
|
33
|
+
export async function inferGitHubRepoFromWorkspace(workspace) {
|
|
34
|
+
try {
|
|
35
|
+
const { stdout } = await exec("git", ["-C", workspace, "remote", "get-url", "origin"]);
|
|
36
|
+
return parseGitHubRemote(stdout);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** A GitHub token from env or the local `gh` login, or undefined (public only). */
|
|
43
|
+
export async function resolveGitHubToken(env = process.env) {
|
|
44
|
+
const fromEnv = env.BIVY_GITHUB_TOKEN?.trim();
|
|
45
|
+
if (fromEnv) {
|
|
46
|
+
if (fromEnv.startsWith("secret://") || fromEnv.startsWith("op://") || fromEnv.startsWith("env://"))
|
|
47
|
+
return resolveSecret(fromEnv);
|
|
48
|
+
return fromEnv;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const { stdout } = await exec("gh", ["auth", "token"]);
|
|
52
|
+
return stdout.trim() || undefined;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Refresh the remote-tracking refs so a session branches off the CURRENT state
|
|
60
|
+
* of `origin`, not whatever the local checkout last saw. Best-effort: an offline
|
|
61
|
+
* node (or a checkout with no reachable origin) keeps its existing refs and the
|
|
62
|
+
* caller branches off those. `cloneOrUpdateRepo` already fetches on the clone
|
|
63
|
+
* path, so this is for the "workspace is an existing local checkout" path, which
|
|
64
|
+
* otherwise never pulls the latest origin before cutting a branch.
|
|
65
|
+
*/
|
|
66
|
+
export async function fetchOrigin(repoDir) {
|
|
67
|
+
try {
|
|
68
|
+
await exec("git", ["-C", repoDir, "fetch", "origin", "--prune"], { timeout: 120_000 });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// offline / no origin / no rights — branch off the refs we already have.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the ref a new branch should be cut from: the remote's default branch
|
|
76
|
+
* (`origin/main`, `origin/master`, …). Falls back to `origin/main` when the
|
|
77
|
+
* remote HEAD isn't recorded. Used so a repo-backed session branches off the
|
|
78
|
+
* upstream default rather than whatever the shared checkout happens to be on.
|
|
79
|
+
*/
|
|
80
|
+
export async function resolveDefaultBaseRef(repoDir) {
|
|
81
|
+
// origin/HEAD points at the remote's default branch when it's been recorded.
|
|
82
|
+
try {
|
|
83
|
+
const { stdout } = await exec("git", ["-C", repoDir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
84
|
+
const ref = stdout.trim();
|
|
85
|
+
if (ref)
|
|
86
|
+
return ref; // e.g. "origin/main"
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// origin/HEAD is often unset on a fresh clone — record it, then retry.
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
await exec("git", ["-C", repoDir, "remote", "set-head", "origin", "--auto"]);
|
|
93
|
+
const { stdout } = await exec("git", ["-C", repoDir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
94
|
+
const ref = stdout.trim();
|
|
95
|
+
if (ref)
|
|
96
|
+
return ref;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// best effort
|
|
100
|
+
}
|
|
101
|
+
return "origin/main";
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Resolve the ref to branch a new session from when the caller (the composer's
|
|
105
|
+
* branch pill) requested a SPECIFIC remote branch instead of the repo's
|
|
106
|
+
* default. `cloneOrUpdateRepo`/`fetchOrigin` have already brought every remote
|
|
107
|
+
* branch's tracking ref down before this runs, so this only needs to verify
|
|
108
|
+
* `origin/<branch>` actually exists — surfacing a clear error instead of
|
|
109
|
+
* silently falling back to the default when the requested branch is missing
|
|
110
|
+
* (e.g. typo'd, deleted upstream since the picker's list was fetched).
|
|
111
|
+
*/
|
|
112
|
+
export async function resolveBranchBaseRef(repoDir, branch) {
|
|
113
|
+
const ref = `origin/${branch}`;
|
|
114
|
+
try {
|
|
115
|
+
await exec("git", ["-C", repoDir, "rev-parse", "--verify", "--quiet", ref]);
|
|
116
|
+
return ref;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw new Error(`Branch "${branch}" was not found on the remote.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Whether an existing Bivy-owned checkout at `dest` can be reused as-is, i.e. it
|
|
124
|
+
* has a `.git` entry AND `git rev-parse` accepts it as a real repository. A
|
|
125
|
+
* `.git` can survive an interrupted/corrupt clone, so presence alone is not
|
|
126
|
+
* enough — callers that reuse on presence alone hand a broken directory to the
|
|
127
|
+
* worktree flow, which then fails with "Not a git repository". Exported for
|
|
128
|
+
* tests.
|
|
129
|
+
*/
|
|
130
|
+
export async function isReusableCheckout(dest) {
|
|
131
|
+
if (!fs.existsSync(path.join(dest, ".git")))
|
|
132
|
+
return false;
|
|
133
|
+
try {
|
|
134
|
+
await exec("git", ["-C", dest, "rev-parse", "--show-toplevel"]);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Ensure a working clone of the repo exists under `root` and return its path.
|
|
143
|
+
* Clones on first use; on later use fetches the latest. One checkout per repo.
|
|
144
|
+
*/
|
|
145
|
+
// The remote URL is now token-free — auth goes through the daemon credential
|
|
146
|
+
// helper (see src/git-auth.ts), so no token is written into `.git/config`.
|
|
147
|
+
// Still tighten `.git/config` to 0600 (git writes it 0644 by default): it holds
|
|
148
|
+
// the credential-helper config and other local settings, and a least-readable
|
|
149
|
+
// posture is cheap insurance. Best-effort.
|
|
150
|
+
function hardenGitConfigPerms(dest) {
|
|
151
|
+
try {
|
|
152
|
+
fs.chmodSync(path.join(dest, ".git", "config"), 0o600);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// no .git/config yet, or a filesystem without POSIX modes — ignore
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export async function cloneOrUpdateRepo(opts) {
|
|
159
|
+
const dest = path.join(opts.root, `${opts.owner}__${opts.repo}`);
|
|
160
|
+
// Keep the remote URL token-free; auth flows through the daemon credential
|
|
161
|
+
// helper (see src/git-auth.ts), which fetches a fresh token on demand — so no
|
|
162
|
+
// token is written into this clone's `.git/config`, where an agent (or
|
|
163
|
+
// `git remote -v` / a log / a screenshot) could read it.
|
|
164
|
+
const url = cleanRemoteUrl(opts.owner, opts.repo);
|
|
165
|
+
const env = gitNonInteractiveEnv();
|
|
166
|
+
const cc = credConfigArgs();
|
|
167
|
+
// Reuse the existing checkout only if it is a REAL git repository. A `.git`
|
|
168
|
+
// entry can survive a clone that was interrupted (network drop, killed
|
|
169
|
+
// process) or otherwise left corrupt — the directory exists and has a `.git`,
|
|
170
|
+
// but `git rev-parse` rejects it. Trusting `.git`'s mere presence took the
|
|
171
|
+
// fetch path and returned that broken directory, so every later "new session
|
|
172
|
+
// on this repo" failed downstream in createWorktree with "Not a git
|
|
173
|
+
// repository" — permanently, because the wipe-and-reclone repair below only
|
|
174
|
+
// ran when `.git` was entirely absent. Validate first; if it's broken, fall
|
|
175
|
+
// through and rebuild it.
|
|
176
|
+
if (await isReusableCheckout(dest)) {
|
|
177
|
+
try {
|
|
178
|
+
// Rewrite origin to the clean URL. This also MIGRATES any pre-existing
|
|
179
|
+
// clone whose remote still carries an embedded token from before this fix.
|
|
180
|
+
await exec("git", ["-C", dest, "remote", "set-url", "origin", url]);
|
|
181
|
+
await configureRepoCredentialHelper((a) => exec("git", a), dest);
|
|
182
|
+
await exec("git", ["-C", dest, ...cc, "fetch", "--all", "--prune"], { timeout: 120_000, env });
|
|
183
|
+
// Re-point origin/HEAD at the remote's CURRENT default branch. Without
|
|
184
|
+
// this a cached checkout keeps whatever default it recorded at first
|
|
185
|
+
// clone, so if the repo's default later changed (e.g. main → master) a new
|
|
186
|
+
// session would branch off the stale remote default. --auto is best-effort
|
|
187
|
+
// and only rewrites the local origin/HEAD pointer, never a branch.
|
|
188
|
+
await exec("git", ["-C", dest, "remote", "set-head", "origin", "--auto"]);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// offline / fetch failed — reuse the existing checkout as-is
|
|
192
|
+
}
|
|
193
|
+
hardenGitConfigPerms(dest);
|
|
194
|
+
return dest;
|
|
195
|
+
}
|
|
196
|
+
fs.mkdirSync(opts.root, { recursive: true });
|
|
197
|
+
// A failed/interrupted/corrupt clone can leave a non-repository (or broken
|
|
198
|
+
// repository) directory behind; because this path is Bivy-owned
|
|
199
|
+
// (<root>/<owner>__<repo>), repair it instead of letting every later "new repo
|
|
200
|
+
// session" fail with "destination exists" or "Not a git repository".
|
|
201
|
+
if (fs.existsSync(dest))
|
|
202
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
203
|
+
await exec("git", [...cc, "clone", url, dest], { timeout: 600_000, env });
|
|
204
|
+
// Persist the helper config so agent-run git in this clone authenticates too.
|
|
205
|
+
await configureRepoCredentialHelper((a) => exec("git", a), dest);
|
|
206
|
+
hardenGitConfigPerms(dest);
|
|
207
|
+
return dest;
|
|
208
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
/**
|
|
4
|
+
* The agent service's `attach` op replies "No detached session to attach: <id>"
|
|
5
|
+
* (see agent-service.ts handleStart) when it is reachable but has no such live
|
|
6
|
+
* session — the only DEFINITIVE "gone" signal. Every other failure (connection
|
|
7
|
+
* refused / reset / closed-during-start / timeout) is transient: the service may
|
|
8
|
+
* be down or restarting, so the child may well still exist.
|
|
9
|
+
*/
|
|
10
|
+
export function classifyAttachFailure(err) {
|
|
11
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
12
|
+
return /no detached session to attach/i.test(message) ? "gone" : "transient";
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Attempt to re-attach to each adoptable session concurrently, applying the
|
|
16
|
+
* failure policy. Never throws — a single session's failure only affects its own
|
|
17
|
+
* classification. Callers should PRE-RECORD every row's address into the durable
|
|
18
|
+
* location registry BEFORE calling this, so a racing `advertiseSessions()` (the
|
|
19
|
+
* replace-all-per-node POST) preserves the addresses even while attaches are still
|
|
20
|
+
* in flight — otherwise the first advert would wipe the very column adoption reads.
|
|
21
|
+
*/
|
|
22
|
+
export async function attachAdoptedSessions(rows, deps) {
|
|
23
|
+
const adopted = [];
|
|
24
|
+
const kept = [];
|
|
25
|
+
const forgotten = [];
|
|
26
|
+
await Promise.all(rows.map(async (location) => {
|
|
27
|
+
try {
|
|
28
|
+
await deps.attach(location);
|
|
29
|
+
adopted.push(location.sessionId);
|
|
30
|
+
deps.log?.(`adopted ${location.sessionId} at ${location.agentServiceAddress}`);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (classifyAttachFailure(error) === "gone") {
|
|
34
|
+
forgotten.push(location.sessionId);
|
|
35
|
+
await deps.forget(location.sessionId).catch(() => { });
|
|
36
|
+
deps.log?.(`forgot definitively-gone session ${location.sessionId}`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
kept.push(location.sessionId);
|
|
40
|
+
deps.log?.(`kept unreachable session ${location.sessionId} (${location.agentServiceAddress}) — will retry`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}));
|
|
44
|
+
return { adopted, kept, forgotten };
|
|
45
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
/**
|
|
4
|
+
* Agent service entry point — runs the real agent runtimes in a process
|
|
5
|
+
* SEPARATE from the node daemon and exposes them over the RPC protocol
|
|
6
|
+
* (src/runtime/rpc-protocol.ts). Launch it beside a daemon and point the daemon
|
|
7
|
+
* at it with BIVY_REMOTE_RUNTIME=1 + BIVY_REMOTE_RUNTIME_ADDR (see
|
|
8
|
+
* docs/agent-runtime-rpc.md).
|
|
9
|
+
*
|
|
10
|
+
* BIVY_AGENT_SERVICE_LISTEN where to listen: "unix:/path.sock" | "PORT" | "host:PORT"
|
|
11
|
+
* BIVY_DATA_DIR data dir (defaults to <cwd>/.bivy), same as the daemon
|
|
12
|
+
* BIVY_REMOTE_RUNTIME_DETACH_REAP_MS optional: reap a detached, idle session after this many ms (off by default)
|
|
13
|
+
*
|
|
14
|
+
* Dev: tsx src/runtime/agent-service-bin.ts
|
|
15
|
+
* Prod: node dist/runtime/agent-service-bin.js
|
|
16
|
+
*
|
|
17
|
+
* The service must have the same agent binaries on PATH and credential access
|
|
18
|
+
* the in-process daemon would (the Claude SDK execs the `claude` CLI internally),
|
|
19
|
+
* because the child now runs HERE, not in the daemon.
|
|
20
|
+
*/
|
|
21
|
+
import net from "node:net";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { pathToFileURL } from "node:url";
|
|
25
|
+
import { AgentService } from "./agent-service.js";
|
|
26
|
+
import { encodeFrame, FrameDecoder } from "./rpc-protocol.js";
|
|
27
|
+
import { parseRemoteAddress } from "./remote.js";
|
|
28
|
+
import { makeRuntime } from "./index.js";
|
|
29
|
+
import { defaultDataDir } from "../data-dir.js";
|
|
30
|
+
/** Adapt a raw socket to the transport-agnostic ServiceConnection. */
|
|
31
|
+
export function socketConnection(socket) {
|
|
32
|
+
const decoder = new FrameDecoder();
|
|
33
|
+
let onMessage;
|
|
34
|
+
let onClose;
|
|
35
|
+
socket.on("data", (chunk) => {
|
|
36
|
+
let messages;
|
|
37
|
+
try {
|
|
38
|
+
messages = decoder.push(chunk);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
socket.destroy();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (onMessage)
|
|
45
|
+
for (const message of messages)
|
|
46
|
+
onMessage(message);
|
|
47
|
+
});
|
|
48
|
+
socket.on("error", () => {
|
|
49
|
+
// A 'close' event always follows; teardown happens there.
|
|
50
|
+
});
|
|
51
|
+
socket.on("close", () => onClose?.());
|
|
52
|
+
return {
|
|
53
|
+
send(message) {
|
|
54
|
+
try {
|
|
55
|
+
socket.write(encodeFrame(message));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// peer went away mid-write; 'close' will reap
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
onMessage(handler) {
|
|
62
|
+
onMessage = handler;
|
|
63
|
+
},
|
|
64
|
+
onClose(handler) {
|
|
65
|
+
onClose = handler;
|
|
66
|
+
},
|
|
67
|
+
close() {
|
|
68
|
+
socket.destroy();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Start a socket server that hands each connection to the AgentService. */
|
|
73
|
+
export function startAgentServiceServer(options) {
|
|
74
|
+
const server = net.createServer((socket) => options.service.accept(socketConnection(socket)));
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
server.once("error", reject);
|
|
77
|
+
server.listen(parseRemoteAddress(options.listen), () => {
|
|
78
|
+
server.removeListener("error", reject);
|
|
79
|
+
resolve(server);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/** Build the default runtime provider backed by the real runtime registry. */
|
|
84
|
+
export function defaultRuntimeProvider() {
|
|
85
|
+
const appDir = defaultDataDir();
|
|
86
|
+
const piDir = path.join(appDir, "pi");
|
|
87
|
+
const sessionsDir = path.join(piDir, "sessions");
|
|
88
|
+
// The shared, agent-neutral credential vault (not inside any agent's dir).
|
|
89
|
+
const credsDir = path.join(appDir, "credentials");
|
|
90
|
+
// Build the runtime the daemon asked for directly via makeRuntime — NOT
|
|
91
|
+
// through RuntimeHost.resolveRuntimeId, whose picker/availability gate is the
|
|
92
|
+
// daemon's UI concern. The daemon already chose the id; the service just hosts
|
|
93
|
+
// it (makeRuntime still throws if the agent binary/SDK is missing here).
|
|
94
|
+
// Node-hosted tools (Bivy integrations / MCP) reach the runtime here via the
|
|
95
|
+
// per-session ToolProvider the daemon supplies over RPC (agent-agnostic), so
|
|
96
|
+
// the service needs no runtime-specific tool wiring of its own.
|
|
97
|
+
const factory = { credsDir, piDir, sessionsDir };
|
|
98
|
+
const cache = new Map();
|
|
99
|
+
return (runtimeId, sandbox) => {
|
|
100
|
+
const key = sandbox ? `${runtimeId}::sandbox=${sandbox}` : runtimeId;
|
|
101
|
+
let rt = cache.get(key);
|
|
102
|
+
if (!rt) {
|
|
103
|
+
rt = makeRuntime({ ...factory, runtime: runtimeId, sandbox: sandbox });
|
|
104
|
+
cache.set(key, rt);
|
|
105
|
+
}
|
|
106
|
+
return rt;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
async function main() {
|
|
110
|
+
const listen = process.env.BIVY_AGENT_SERVICE_LISTEN?.trim();
|
|
111
|
+
if (!listen) {
|
|
112
|
+
console.error("[agent-service] BIVY_AGENT_SERVICE_LISTEN is required (e.g. unix:/tmp/bivy-agent.sock or 4711)");
|
|
113
|
+
process.exit(2);
|
|
114
|
+
}
|
|
115
|
+
// Idle-reaper for DETACHED sessions (Stage 3). Off by default; set
|
|
116
|
+
// BIVY_REMOTE_RUNTIME_DETACH_REAP_MS to bound how long a session the daemon
|
|
117
|
+
// evicted/lost persists here with no bound connection.
|
|
118
|
+
const detachReapMs = Number(process.env.BIVY_REMOTE_RUNTIME_DETACH_REAP_MS) || 0;
|
|
119
|
+
const service = new AgentService({ runtimeProvider: defaultRuntimeProvider(), log: (message) => console.error(`[agent-service] ${message}`), detachReapMs });
|
|
120
|
+
if (detachReapMs > 0)
|
|
121
|
+
console.error(`[agent-service] detached-session idle reaper: ${detachReapMs}ms`);
|
|
122
|
+
const server = await startAgentServiceServer({ service, listen });
|
|
123
|
+
console.error(`[agent-service] listening on ${listen} (pid ${process.pid}, host ${os.hostname()})`);
|
|
124
|
+
let shuttingDown = false;
|
|
125
|
+
const shutdown = (signal) => {
|
|
126
|
+
if (shuttingDown)
|
|
127
|
+
return;
|
|
128
|
+
shuttingDown = true;
|
|
129
|
+
console.error(`[agent-service] ${signal} — reaping ${service.sessionCount} session(s)`);
|
|
130
|
+
service.disposeAll(); // reap every child so the service leaves no orphans
|
|
131
|
+
server.close(() => process.exit(0));
|
|
132
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
133
|
+
};
|
|
134
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
135
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
136
|
+
// Crash net: this service process hosts detached agent sessions, so a stray
|
|
137
|
+
// unhandled rejection/exception must not tear all of them down. Log and keep
|
|
138
|
+
// running; a real supervisor restart remains the fallback.
|
|
139
|
+
process.on("unhandledRejection", (reason) => {
|
|
140
|
+
console.error("[bivy agent-service] unhandledRejection (kept running):", reason);
|
|
141
|
+
});
|
|
142
|
+
process.on("uncaughtException", (error) => {
|
|
143
|
+
console.error("[bivy agent-service] uncaughtException (kept running):", error);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// Run only when executed directly (not when imported by a test).
|
|
147
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
148
|
+
void main();
|
|
149
|
+
}
|