@bivy/bivy 0.16.25-staging.3 → 0.16.25-staging.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/acp-shim.mjs +7 -0
- package/bin/agent-manifest.json +1 -1
- package/bin/codex-app-server-shim.mjs +10 -0
- package/bin/patch-pi-dependencies.mjs +2 -2
- package/dist/agents/claude-code/integration.js +9 -1
- package/dist/agents/codex/integration.js +9 -1
- package/dist/agents/pi/integration.js +1 -1
- package/dist/agents/pi/runtime.js +27 -3
- package/dist/agents/profiles.js +13 -5
- package/dist/certification/generated.js +4 -4
- package/dist/controllers/credential-commands.js +8 -3
- package/dist/credentials/document.js +35 -10
- package/dist/credentials/store.js +17 -0
- package/dist/hosted-custody.js +30 -0
- package/dist/runtime/cli-parsers.js +29 -2
- package/dist/runtime/index.js +30 -1
- package/dist/runtime/protocol.js +32 -2
- package/dist/server.js +54 -17
- package/dist/session/fork-matrix.js +12 -0
- package/dist/worktree.js +9 -1
- package/package.json +15 -14
package/bin/acp-shim.mjs
CHANGED
|
@@ -313,6 +313,13 @@ function onSessionUpdate(params) {
|
|
|
313
313
|
if (!content) return "";
|
|
314
314
|
if (typeof content === "string") return content;
|
|
315
315
|
if (Array.isArray(content)) return content.map(textOf).join("");
|
|
316
|
+
// ACP's ToolCallContent wraps a ContentBlock: `{ type: "content", content:
|
|
317
|
+
// { type: "text", text } }`. Unwrap it (recursively) so a tool's real output
|
|
318
|
+
// — a command's stdout, a fetched document, a read's text — isn't dropped,
|
|
319
|
+
// which collapsed the tool_result to the bare status ("completed"). opencode
|
|
320
|
+
// and most spec-compliant ACP agents deliver tool output in this wrapped
|
|
321
|
+
// shape, so reading only the bare `{type:"text"}` form lost it in production.
|
|
322
|
+
if (content.type === "content" && content.content != null) return textOf(content.content);
|
|
316
323
|
if (content.type === "text" && typeof content.text === "string") return content.text;
|
|
317
324
|
if (typeof content.text === "string") return content.text;
|
|
318
325
|
return "";
|
package/bin/agent-manifest.json
CHANGED
|
@@ -415,6 +415,16 @@ function onNotification(m) {
|
|
|
415
415
|
bivy({ type: "session.status", status: "working" });
|
|
416
416
|
return;
|
|
417
417
|
case "turn/completed": {
|
|
418
|
+
// The completion event is authoritative about a terminal failure: a turn
|
|
419
|
+
// that hit a usage limit (or any provider error) arrives here as
|
|
420
|
+
// `status: "failed"` carrying `turn.error`. Capture it directly instead of
|
|
421
|
+
// depending on a separate `error` notification landing first — otherwise a
|
|
422
|
+
// completion that omits that notification seals as session.done and the
|
|
423
|
+
// user sees a silent empty turn (fail loud, never swallow the error).
|
|
424
|
+
const turnError =
|
|
425
|
+
params?.turn?.error?.message ||
|
|
426
|
+
(params?.turn?.status === "failed" ? params?.error?.message || params?.message || "Codex turn failed" : undefined);
|
|
427
|
+
if (turnError && !sawError) sawError = turnError;
|
|
418
428
|
// Codex 0.147 can publish turn/completed just before the final
|
|
419
429
|
// collabAgentToolCall item/completed notification. Sealing immediately
|
|
420
430
|
// leaves the sub-agent card running forever and drops its persisted
|
|
@@ -37,8 +37,8 @@ const piPackage = findDependency(path.join("@earendil-works", "pi-coding-agent")
|
|
|
37
37
|
if (!piPackage) process.exit(0);
|
|
38
38
|
|
|
39
39
|
const patches = [
|
|
40
|
-
{ name: "brace-expansion", version: "5.0.
|
|
41
|
-
{ name: "undici", version: "8.10.
|
|
40
|
+
{ name: "brace-expansion", version: "5.0.12" },
|
|
41
|
+
{ name: "undici", version: "8.10.2" },
|
|
42
42
|
];
|
|
43
43
|
|
|
44
44
|
for (const patch of patches) {
|
|
@@ -4,13 +4,21 @@ import { defineAgentIntegration } from "../definition.js";
|
|
|
4
4
|
import { withExactCapabilitySurface } from "../../runtime/types.js";
|
|
5
5
|
import { createCredentialStore } from "../../runtime/credentials.js";
|
|
6
6
|
import { ClaudeCodeRuntime, claudeRuntimeFromEnv, claudeSdkInstalled, } from "./runtime.js";
|
|
7
|
-
export const CLAUDE_TESTED_VERSION = "0.3.
|
|
7
|
+
export const CLAUDE_TESTED_VERSION = "0.3.278";
|
|
8
8
|
const CLAUDE_CAPABILITIES = withExactCapabilitySurface({
|
|
9
9
|
toolInterception: true,
|
|
10
10
|
modelSelection: true,
|
|
11
11
|
packages: false,
|
|
12
12
|
resume: true,
|
|
13
13
|
fork: true,
|
|
14
|
+
// Must mirror ClaudeCodeRuntime.capabilities (runtime.ts): the catalog is a
|
|
15
|
+
// consumed source of truth (the fork-fidelity matrix and any fork-target
|
|
16
|
+
// fidelity preview read these), so under-reporting here makes Claude look
|
|
17
|
+
// like a seeded-only fork target when its runtime actually delivers a
|
|
18
|
+
// byte-exact same-runtime fork (forkTransport) and a full cross-runtime
|
|
19
|
+
// transcript replay INTO it (forkHistoryImport, via importHistoryForFork).
|
|
20
|
+
forkTransport: true,
|
|
21
|
+
forkHistoryImport: true,
|
|
14
22
|
usageReporting: true,
|
|
15
23
|
nativeSessionDiscovery: true,
|
|
16
24
|
nativeSessionAdoption: true,
|
|
@@ -9,7 +9,7 @@ import { codexCredentialPreflight } from "../../runtime/codex-preflight.js";
|
|
|
9
9
|
import { deleteCodexSession, discoverNativeCodexSessions, loadCodexTranscript, writeCodexRollout, exportCodexRollout, importCodexRollout, } from "../../runtime/codex-sessions.js";
|
|
10
10
|
import { ProtocolRuntime } from "../../runtime/protocol.js";
|
|
11
11
|
import { codexSlashCommands } from "../../runtime/slash-commands.js";
|
|
12
|
-
export const CODEX_TESTED_VERSION = "0.
|
|
12
|
+
export const CODEX_TESTED_VERSION = "0.155.1";
|
|
13
13
|
const CODEX_AVAILABLE_CACHE = new Map();
|
|
14
14
|
function codexCommand() {
|
|
15
15
|
return process.env.BIVY_CODEX_BIN?.trim() || "codex";
|
|
@@ -146,6 +146,14 @@ export function codexIntegration(origin) {
|
|
|
146
146
|
resume: true,
|
|
147
147
|
packages: false,
|
|
148
148
|
fork: true,
|
|
149
|
+
// Mirror the ProtocolRuntime this integration builds (createCodexRuntime
|
|
150
|
+
// above wires exportForFork+importForFork and writeHistory, which flip
|
|
151
|
+
// capabilities.forkTransport/forkHistoryImport on). The catalog is a
|
|
152
|
+
// consumed source of truth (fork-fidelity matrix / fork-target preview),
|
|
153
|
+
// so omitting these made Codex look seeded-only when its runtime does a
|
|
154
|
+
// byte-exact codex→codex fork and a full transcript replay INTO it.
|
|
155
|
+
forkTransport: true,
|
|
156
|
+
forkHistoryImport: true,
|
|
149
157
|
usageReporting: true,
|
|
150
158
|
interactiveTui: installed,
|
|
151
159
|
nativeSessionDiscovery: true,
|
|
@@ -5,7 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { defineAgentIntegration } from "../definition.js";
|
|
7
7
|
import { PI_CAPABILITIES } from "./capabilities.js";
|
|
8
|
-
export const PI_TESTED_VERSION = "0.
|
|
8
|
+
export const PI_TESTED_VERSION = "0.86.1";
|
|
9
9
|
export function piAgentDir() {
|
|
10
10
|
return process.env.PI_CODING_AGENT_DIR?.trim() || path.join(os.homedir(), ".pi", "agent");
|
|
11
11
|
}
|
|
@@ -268,8 +268,29 @@ class PiSession {
|
|
|
268
268
|
* reload. Async because that resolution reads the store.
|
|
269
269
|
*/
|
|
270
270
|
async getModels() {
|
|
271
|
-
const
|
|
272
|
-
|
|
271
|
+
const runtime = this.session.modelRuntime;
|
|
272
|
+
try {
|
|
273
|
+
const models = await runtime.getAvailable();
|
|
274
|
+
return models.map((model) => toModelInfo(model, true));
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
// The runtime's availability refresh is a Promise.all across every
|
|
278
|
+
// provider's auth check, so one broken provider (an unreachable custom
|
|
279
|
+
// endpoint, a single failing credential read) rejects the whole catalog
|
|
280
|
+
// and the picker shows nothing. Degrade to per-provider listing and
|
|
281
|
+
// skip only the broken ones; rethrow only when truly nothing survives.
|
|
282
|
+
const models = [];
|
|
283
|
+
for (const provider of runtime.getProviders()) {
|
|
284
|
+
try {
|
|
285
|
+
for (const model of await runtime.getAvailable(provider.id))
|
|
286
|
+
models.push(toModelInfo(model, true));
|
|
287
|
+
}
|
|
288
|
+
catch { /* this provider is broken — the rest still list */ }
|
|
289
|
+
}
|
|
290
|
+
if (models.length)
|
|
291
|
+
return models;
|
|
292
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
293
|
+
}
|
|
273
294
|
}
|
|
274
295
|
/**
|
|
275
296
|
* Every model Pi knows about — built-in and custom, regardless of whether
|
|
@@ -281,7 +302,10 @@ class PiSession {
|
|
|
281
302
|
async getAllModels() {
|
|
282
303
|
const runtime = this.session.modelRuntime;
|
|
283
304
|
// Refresh availability first so `hasConfiguredAuth` reflects current creds.
|
|
284
|
-
|
|
305
|
+
// Best-effort: a failed refresh (one broken provider rejects the shared
|
|
306
|
+
// Promise.all) must not blank the full catalog — the flags then reflect
|
|
307
|
+
// the last successful refresh, which the picker can still act on.
|
|
308
|
+
await runtime.getAvailable().catch(() => { });
|
|
285
309
|
return runtime.getModels().map((model) => toModelInfo(model, runtime.hasConfiguredAuth(model.provider)));
|
|
286
310
|
}
|
|
287
311
|
/** Reload the projected models.json in-place so a live session immediately
|
package/dist/agents/profiles.js
CHANGED
|
@@ -43,7 +43,7 @@ export const AGENT_PROFILES = {
|
|
|
43
43
|
// Approve/Deny + session/load resume + a real model picker), the same bar Pi,
|
|
44
44
|
// Claude Code, and Codex clear. See `acp` below for the version fallback.
|
|
45
45
|
supportTier: "supported",
|
|
46
|
-
testedVersion: "1.18.
|
|
46
|
+
testedVersion: "1.18.31",
|
|
47
47
|
blurb: "The most widely used open-source coding harness (OpenCode CLI).",
|
|
48
48
|
// `opencode run -s <id> "<prompt>"` continues a prior session by its own id
|
|
49
49
|
// (`-s, --session session id to continue`, per `opencode run --help`).
|
|
@@ -398,13 +398,21 @@ export const AGENT_PROFILES = {
|
|
|
398
398
|
flag: "-m",
|
|
399
399
|
models: [
|
|
400
400
|
// Official Grok CLI (1.x) advertises grok-4.6 as the default subscription
|
|
401
|
-
// model (verified against
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
401
|
+
// model (verified against the authenticated `~/.grok/models_cache.json`,
|
|
402
|
+
// origin cli-chat-proxy.grok.com/v1/models); the older grok-4.5 /
|
|
403
|
+
// grok-4-latest / grok-code-fast-1 ids now return "unknown model id".
|
|
404
|
+
// Keep the list honest; operators can override with BIVY_GROK_MODELS if
|
|
405
|
+
// their install exposes more.
|
|
405
406
|
{ id: "grok-4.6", name: "Grok 4.6", provider: "xai" },
|
|
406
407
|
],
|
|
407
408
|
},
|
|
409
|
+
// Grok's reasoning models expose a `--reasoning-effort <EFFORT>` flag
|
|
410
|
+
// (verified against `grok --help`, 1.0.0); the authenticated model catalog
|
|
411
|
+
// lists low/medium/high/xhigh with `high` as the default. `{level}` fills
|
|
412
|
+
// the value; insertAt: 0 keeps the trailing `-p` prompt flag last so the
|
|
413
|
+
// prompt still lands as its value. Threads through the resume path too, so a
|
|
414
|
+
// continued turn keeps the chosen effort. Override with BIVY_GROK_THINKING.
|
|
415
|
+
thinking: { levels: ["low", "medium", "high", "xhigh"], default: "high", template: ["--reasoning-effort", "{level}"], insertAt: 0 },
|
|
408
416
|
promptMode: "argv",
|
|
409
417
|
install: {
|
|
410
418
|
kind: "curl",
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
export const CERTIFICATION_MATRIX = {
|
|
4
4
|
schemaVersion: 1,
|
|
5
5
|
agents: [
|
|
6
|
-
{ id: "claude-code-sdk", status: "active", executionMode: "protocol", pinnedVersion: "0.3.
|
|
7
|
-
{ id: "codex-approvals", status: "active", executionMode: "protocol", pinnedVersion: "0.
|
|
8
|
-
{ id: "pi", status: "active", executionMode: "protocol", pinnedVersion: "0.
|
|
9
|
-
{ id: "opencode", status: "active", executionMode: "protocol", pinnedVersion: "1.18.
|
|
6
|
+
{ id: "claude-code-sdk", status: "active", executionMode: "protocol", pinnedVersion: "0.3.278", capabilities: ["toolInterception", "modelSelection", "resume"] },
|
|
7
|
+
{ id: "codex-approvals", status: "active", executionMode: "protocol", pinnedVersion: "0.155.1", capabilities: ["toolInterception", "modelSelection", "resume"] },
|
|
8
|
+
{ id: "pi", status: "active", executionMode: "protocol", pinnedVersion: "0.86.1", capabilities: ["toolInterception", "modelSelection", "resume"] },
|
|
9
|
+
{ id: "opencode", status: "active", executionMode: "protocol", pinnedVersion: "1.18.31", capabilities: ["toolInterception", "modelSelection", "resume"] },
|
|
10
10
|
]
|
|
11
11
|
};
|
|
@@ -95,7 +95,8 @@ export function createCredentialCommands(deps) {
|
|
|
95
95
|
async "credential.remove"(msg, ctx) {
|
|
96
96
|
try {
|
|
97
97
|
await removeProviderCredential(credsDir, String(msg.provider ?? ""), String(msg.label ?? ""));
|
|
98
|
-
|
|
98
|
+
// Deleting a granted credential must also remove its escrowed cloud copy.
|
|
99
|
+
await deps.pushModelAuthToControlPlane({ allowEmptyHostedEscrow: true });
|
|
99
100
|
await deps.refreshSessionAfterAuth();
|
|
100
101
|
deps.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
|
|
101
102
|
deps.broadcast({ type: "providers.list", providers: await deps.listProvidersUnified() });
|
|
@@ -111,7 +112,8 @@ export function createCredentialCommands(deps) {
|
|
|
111
112
|
try {
|
|
112
113
|
const sync = msg.sync === "node" ? "node" : "account";
|
|
113
114
|
await setCredentialSync(credsDir, String(msg.provider ?? ""), String(msg.label ?? ""), sync);
|
|
114
|
-
|
|
115
|
+
// Demoting to machine-only revokes any custody grant; let it clear the escrow.
|
|
116
|
+
await deps.pushModelAuthToControlPlane({ allowEmptyHostedEscrow: sync === "node" });
|
|
115
117
|
deps.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
|
|
116
118
|
ctx.reply({ type: "credential.sync.set.ok", requestId: msg.requestId });
|
|
117
119
|
}
|
|
@@ -127,7 +129,10 @@ export function createCredentialCommands(deps) {
|
|
|
127
129
|
const previous = (await listCredentialRecords(credsDir)).find((record) => record.provider === provider.trim().toLowerCase() && record.label === label)?.unattended === true;
|
|
128
130
|
try {
|
|
129
131
|
await setCredentialUnattended(credsDir, provider, label, msg.unattended === true);
|
|
130
|
-
|
|
132
|
+
// Fail loud: the grant reply must reflect whether the encrypted cloud
|
|
133
|
+
// copy actually reached the control plane. An explicit disable is the
|
|
134
|
+
// one caller allowed to publish an empty escrow snapshot.
|
|
135
|
+
await deps.pushModelAuthToControlPlane({ throwOnFailure: true, allowEmptyHostedEscrow: msg.unattended !== true });
|
|
131
136
|
deps.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
|
|
132
137
|
ctx.reply({ type: "credential.unattended.set.ok", requestId: msg.requestId });
|
|
133
138
|
}
|
|
@@ -87,17 +87,22 @@ function sameRecordContent(a, b) {
|
|
|
87
87
|
const strip = ({ updatedAt: _drop, ...rest }) => rest;
|
|
88
88
|
return JSON.stringify(strip(a)) === JSON.stringify(strip(b));
|
|
89
89
|
}
|
|
90
|
+
const stable = (value) => {
|
|
91
|
+
if (Array.isArray(value))
|
|
92
|
+
return value.map(stable);
|
|
93
|
+
if (!value || typeof value !== "object")
|
|
94
|
+
return value;
|
|
95
|
+
return Object.fromEntries(Object.entries(value)
|
|
96
|
+
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
97
|
+
.map(([key, child]) => [key, stable(child)]));
|
|
98
|
+
};
|
|
90
99
|
function canonicalCredential(credential) {
|
|
91
100
|
const { updatedAt: _drop, ...content } = credential;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return Object.fromEntries(Object.entries(value)
|
|
98
|
-
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
99
|
-
.map(([key, child]) => [key, stable(child)]));
|
|
100
|
-
};
|
|
101
|
+
return JSON.stringify(stable(content));
|
|
102
|
+
}
|
|
103
|
+
/** Stable record content sans the store-owned stamp — the final merge tie-break. */
|
|
104
|
+
function canonicalRecord(record) {
|
|
105
|
+
const { updatedAt: _drop, ...content } = record;
|
|
101
106
|
return JSON.stringify(stable(content));
|
|
102
107
|
}
|
|
103
108
|
/**
|
|
@@ -132,6 +137,19 @@ export function preferIncomingRecord(local, incoming) {
|
|
|
132
137
|
const incomingOauth = oauthOf(incoming);
|
|
133
138
|
if (!localOauth || !incomingOauth)
|
|
134
139
|
return true;
|
|
140
|
+
// Identical token content means this is a metadata-only change (an
|
|
141
|
+
// unattended-runs grant/revoke, a sync-tier move). Token freshness cannot
|
|
142
|
+
// order those, so the newer record write wins — without this, a grant made
|
|
143
|
+
// on one machine never reaches its peers, and a peer's next hosted-escrow
|
|
144
|
+
// push would read as "nothing granted". Equal stamps fall back to canonical
|
|
145
|
+
// record content so both merge directions still converge.
|
|
146
|
+
if (canonicalCredential(localOauth) === canonicalCredential(incomingOauth)) {
|
|
147
|
+
const localAt = Number(local.updatedAt) || 0;
|
|
148
|
+
const incomingAt = Number(incoming.updatedAt) || 0;
|
|
149
|
+
if (incomingAt !== localAt)
|
|
150
|
+
return incomingAt > localAt;
|
|
151
|
+
return canonicalRecord(incoming) > canonicalRecord(local);
|
|
152
|
+
}
|
|
135
153
|
return preferIncomingOAuthCredential(localOauth, incomingOauth);
|
|
136
154
|
}
|
|
137
155
|
/** A tombstone wins only when it is newer than the record it would remove. */
|
|
@@ -251,7 +269,14 @@ export function mergeDocuments(local, incomingCredentials, incomingDeletedAt = {
|
|
|
251
269
|
continue;
|
|
252
270
|
if (!(key in credentials))
|
|
253
271
|
imported += 1;
|
|
254
|
-
|
|
272
|
+
// An incoming record with no opinion on unattended custody (undefined — the
|
|
273
|
+
// v2 wire and plaintext ingest never carry the flag) must not strip an
|
|
274
|
+
// explicit local grant/revoke: agent-side token refreshes flow through this
|
|
275
|
+
// merge, and dropping the flag would make the node's next escrow push
|
|
276
|
+
// silently revoke the encrypted cloud copy the user still relies on.
|
|
277
|
+
credentials[key] = localRecord && incoming.unattended === undefined && localRecord.unattended !== undefined
|
|
278
|
+
? { ...incoming, unattended: localRecord.unattended }
|
|
279
|
+
: incoming;
|
|
255
280
|
const incomingUpdatedAt = Number(incoming.updatedAt);
|
|
256
281
|
if (!Number.isFinite(deletedAt[key]) || incomingUpdatedAt > deletedAt[key])
|
|
257
282
|
delete deletedAt[key];
|
|
@@ -554,6 +554,23 @@ export class BivyCredentialStore {
|
|
|
554
554
|
// engine (merge-never-destroy, freshest-OAuth-wins, rotation-safe,
|
|
555
555
|
// tombstone-newer-wins — see document.ts).
|
|
556
556
|
const incoming = migrateToV3({ v: 2, providers: snapshot, deletedAt });
|
|
557
|
+
// The v2 wire carries no record metadata, so migrateToV3 synthesizes
|
|
558
|
+
// defaults (sync: "account", origin: "bivy", no unattended flag). Those
|
|
559
|
+
// defaults must not clobber local intent — a machine-only sync tier or an
|
|
560
|
+
// unattended-runs custody grant — when only the token content is newer
|
|
561
|
+
// (e.g. an agent's own TUI refreshed the OAuth token set). Inherit the
|
|
562
|
+
// existing record's metadata; the merge engine still decides freshness.
|
|
563
|
+
for (const [key, record] of Object.entries(incoming.credentials)) {
|
|
564
|
+
const existing = document.credentials[key];
|
|
565
|
+
if (!existing)
|
|
566
|
+
continue;
|
|
567
|
+
incoming.credentials[key] = {
|
|
568
|
+
...record,
|
|
569
|
+
sync: existing.sync,
|
|
570
|
+
origin: existing.origin,
|
|
571
|
+
...(existing.unattended !== undefined ? { unattended: existing.unattended } : {}),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
557
574
|
const result = mergeDocuments(document, incoming.credentials, incoming.deletedAt);
|
|
558
575
|
if (result.changed)
|
|
559
576
|
this.writeDocument(result.document);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// One pure policy decision: does THIS daemon hold only control-plane-custodied
|
|
4
|
+
// credentials (the separately escrowed unattended snapshot), or is it a normal
|
|
5
|
+
// personal node that owns its vault?
|
|
6
|
+
//
|
|
7
|
+
// The distinction matters everywhere model-auth sync branches: a custody node
|
|
8
|
+
// never pushes/pulls the account vault, never publishes the unattended escrow,
|
|
9
|
+
// and treats the control plane's filtered snapshot as an authoritative set
|
|
10
|
+
// (reconcile deletes what a newer snapshot omits). Getting this wrong on a
|
|
11
|
+
// personal node silently breaks credential sync AND makes the unattended-runs
|
|
12
|
+
// toggle a no-op — the "Allowed — encrypted cloud copy enabled" UI then lies,
|
|
13
|
+
// and every Bivy Cloud session fails with "credential didn't reach Bivy Cloud".
|
|
14
|
+
/**
|
|
15
|
+
* True when this daemon must run with hosted-custody semantics:
|
|
16
|
+
* - `BIVY_HOSTED_CREDENTIAL_CUSTODY` — the dedicated flag the provisioner sets
|
|
17
|
+
* on managed (Bivy Cloud) machines; always authoritative.
|
|
18
|
+
* - `BIVY_GITHUB_HOSTED_TASKS` + `BIVY_EPHEMERAL` — back-compat for
|
|
19
|
+
* provisioned user-owned queue runners that predate the dedicated flag.
|
|
20
|
+
*
|
|
21
|
+
* `BIVY_GITHUB_HOSTED_TASKS` ALONE is not custody: it is persisted onto any
|
|
22
|
+
* long-lived personal node when a GitHub App is connected (it only means "poll
|
|
23
|
+
* the hosted task queue"). Such a node owns its own logins and must keep full
|
|
24
|
+
* personal-vault semantics — account sync, escrow publishing, loud toggles.
|
|
25
|
+
*/
|
|
26
|
+
export function hostedCustodyNode(env = process.env) {
|
|
27
|
+
if (env.BIVY_HOSTED_CREDENTIAL_CUSTODY)
|
|
28
|
+
return true;
|
|
29
|
+
return Boolean(env.BIVY_GITHUB_HOSTED_TASKS && env.BIVY_EPHEMERAL);
|
|
30
|
+
}
|
|
@@ -696,6 +696,25 @@ function rawExitCode(raw) {
|
|
|
696
696
|
const exit = o.exit_code ?? o.exitCode ?? o.code;
|
|
697
697
|
return typeof exit === "number" ? exit : undefined;
|
|
698
698
|
}
|
|
699
|
+
/** Decode a UTF-8 byte array carried as a JSON `number[]` (Grok's
|
|
700
|
+
* `rawOutput.output` transports command output this way). Returns the decoded
|
|
701
|
+
* string, or undefined when the value isn't a plausible byte array. Used only as
|
|
702
|
+
* a LAST resort — a clean ACP `content` block is always preferred — so a result
|
|
703
|
+
* frame with no display content still renders readable text instead of a raw
|
|
704
|
+
* number array (`[97,46,116,120,116]`). */
|
|
705
|
+
function decodeByteArray(v) {
|
|
706
|
+
if (!Array.isArray(v) || v.length === 0)
|
|
707
|
+
return undefined;
|
|
708
|
+
if (!v.every((n) => typeof n === "number" && Number.isInteger(n) && n >= 0 && n <= 255))
|
|
709
|
+
return undefined;
|
|
710
|
+
try {
|
|
711
|
+
const text = Buffer.from(v).toString("utf8");
|
|
712
|
+
return text.includes("\uFFFD") ? undefined : text;
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
return undefined;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
699
718
|
/** Extract an ACP-style tool update. Two transports converge here:
|
|
700
719
|
* 1. A JSON-RPC `session/update` notification (`params.update`), the true ACP
|
|
701
720
|
* wire form used by Gemini/Qwen/Copilot etc.
|
|
@@ -735,8 +754,16 @@ function acpToolUpdate(msg) {
|
|
|
735
754
|
// the exit code from the raw payload when we take the content text instead.
|
|
736
755
|
const contentText = flattenAcpContent(update.content);
|
|
737
756
|
const exit = rawExitCode(raw);
|
|
738
|
-
|
|
739
|
-
|
|
757
|
+
// Last-resort text when no clean ACP `content` block is present: decode a
|
|
758
|
+
// byte-array payload (Grok's rawOutput.output) so the card shows readable
|
|
759
|
+
// output instead of a raw `[97,46,…]` number array. Never overrides a real
|
|
760
|
+
// content block.
|
|
761
|
+
const decoded = contentText === undefined
|
|
762
|
+
? (decodeByteArray(raw) ?? (raw && typeof raw === "object" && !Array.isArray(raw) ? decodeByteArray(raw.output) : undefined))
|
|
763
|
+
: undefined;
|
|
764
|
+
const text = contentText ?? decoded;
|
|
765
|
+
const output = text !== undefined
|
|
766
|
+
? (exit !== undefined ? { content: text, exit_code: exit } : text)
|
|
740
767
|
: (raw ?? update.content);
|
|
741
768
|
return { kind: "result", id, output, error: status === "failed" || status === "error" };
|
|
742
769
|
}
|
package/dist/runtime/index.js
CHANGED
|
@@ -53,6 +53,7 @@ import { ProtocolRuntime, protocolRuntimeFromEnv, protocolCommandsFromEnv } from
|
|
|
53
53
|
import { codexSlashCommands, opencodeSlashCommands } from "./slash-commands.js";
|
|
54
54
|
import { withExactCapabilitySurface } from "./types.js";
|
|
55
55
|
import { installedAgentContributions } from "../plugins/store.js";
|
|
56
|
+
import { forkCapsFromInfo } from "../session/fork-matrix.js";
|
|
56
57
|
import { currentBivyVersion } from "../app-version.js";
|
|
57
58
|
const PREFLIGHT_BEHAVIORS = {
|
|
58
59
|
codex: (env) => codexCredentialPreflight(env),
|
|
@@ -552,6 +553,17 @@ function cliAgentInfo(id, spec) {
|
|
|
552
553
|
}
|
|
553
554
|
const authOwner = spec.authOwner ?? "agent";
|
|
554
555
|
const credentialProviders = [...new Set((spec.model?.models ?? []).flatMap((model) => model.provider ? [model.provider] : []))];
|
|
556
|
+
// Fork fidelity, mirrored from what makeCliRuntime actually wires up. An
|
|
557
|
+
// ACP-promoted agent with a portable session store (OpenCode's SQLite) gets
|
|
558
|
+
// export/import + writeHistory hooks in acpRuntimeOptions, so its runtime
|
|
559
|
+
// advertises a byte-exact same-runtime fork (forkTransport) AND a full
|
|
560
|
+
// cross-runtime transcript replay INTO it (forkHistoryImport). Reporting a
|
|
561
|
+
// blanket fork:false here hid a full-fidelity fork target from the catalog
|
|
562
|
+
// and the generated fork matrix. Gated on the shipped default (acp.preferred)
|
|
563
|
+
// rather than a live binary probe so the capability is install-independent —
|
|
564
|
+
// the honest "what this adapter does by default", matching how resume/model
|
|
565
|
+
// are described before the optional BIVY_AGENT_PROBE refinement.
|
|
566
|
+
const nativeStoreFork = spec.behaviors?.sessionStore === "opencode" && spec.acp?.preferred === true;
|
|
555
567
|
return {
|
|
556
568
|
id,
|
|
557
569
|
executionMode,
|
|
@@ -570,7 +582,8 @@ function cliAgentInfo(id, spec) {
|
|
|
570
582
|
modelSelection,
|
|
571
583
|
resume,
|
|
572
584
|
packages: false,
|
|
573
|
-
fork:
|
|
585
|
+
fork: nativeStoreFork,
|
|
586
|
+
...(nativeStoreFork ? { forkTransport: true, forkHistoryImport: true } : {}),
|
|
574
587
|
usageReporting,
|
|
575
588
|
// Any agent with a readable on-disk session store (Codex, OpenCode) or
|
|
576
589
|
// native-session behavior (Grok) can locate the session a `bivy run`
|
|
@@ -1009,6 +1022,22 @@ function agentRegistry(pluginDataDir) {
|
|
|
1009
1022
|
export function listRegisteredAgents() {
|
|
1010
1023
|
return agentRegistry().list().map((entry) => applyCertification(entry.describe()));
|
|
1011
1024
|
}
|
|
1025
|
+
// Meta/host rows that are not concrete coding agents (they describe the generic
|
|
1026
|
+
// transports themselves), plus the hidden plain-`codex` exec runtime, which is a
|
|
1027
|
+
// duplicate of the governed `codex-approvals` we surface as "Codex".
|
|
1028
|
+
const FORK_MATRIX_EXCLUDE = new Set(["generic-cli", "bivy-agent-protocol", "acp", "codex"]);
|
|
1029
|
+
/** The built-in coding agents and their fork capabilities, derived from the live
|
|
1030
|
+
* registry, for docs/fork-matrix.md and its drift-guard test. Restricted to the
|
|
1031
|
+
* maintained (distribution) integrations so the generated doc is deterministic
|
|
1032
|
+
* regardless of which agents happen to be installed or which plugins are present. */
|
|
1033
|
+
export function forkMatrixAgents() {
|
|
1034
|
+
return listRegisteredAgents()
|
|
1035
|
+
.filter((info) => info.status !== "planned" &&
|
|
1036
|
+
!FORK_MATRIX_EXCLUDE.has(info.id) &&
|
|
1037
|
+
info.source?.kind === "package" &&
|
|
1038
|
+
info.source.location === "distribution")
|
|
1039
|
+
.map(forkCapsFromInfo);
|
|
1040
|
+
}
|
|
1012
1041
|
/** Resolve an id or historical alias through the authoritative registry. */
|
|
1013
1042
|
export function canonicalAgentId(id) {
|
|
1014
1043
|
return agentRegistry().get(id.toLowerCase())?.id;
|
package/dist/runtime/protocol.js
CHANGED
|
@@ -711,14 +711,41 @@ class ProtocolSession {
|
|
|
711
711
|
return;
|
|
712
712
|
}
|
|
713
713
|
if (type === "session.error") {
|
|
714
|
+
const errorText = String(msg.error || "Protocol agent error");
|
|
715
|
+
// Preserve whatever the failed turn already streamed — a partial assistant
|
|
716
|
+
// reply and its tool calls/results — persisted exactly as session.done
|
|
717
|
+
// persists a clean turn. The old handler wiped turnContent here, so a mid-
|
|
718
|
+
// turn failure dropped all of that work from the reopened transcript.
|
|
719
|
+
const hadTools = this.turnContent.length > 0 || this.turnToolResults.length > 0;
|
|
720
|
+
if (hadTools) {
|
|
721
|
+
this.flushPendingTurnText();
|
|
722
|
+
if (this.turnContent.length)
|
|
723
|
+
this.messages.push({ role: "assistant", content: this.turnContent, timestamp: Date.now() });
|
|
724
|
+
if (this.turnToolResults.length)
|
|
725
|
+
this.messages.push({ role: "user", content: this.turnToolResults, timestamp: Date.now() });
|
|
726
|
+
}
|
|
727
|
+
else if (this.assistantText) {
|
|
728
|
+
this.messages.push({ role: "assistant", content: this.assistantText, timestamp: Date.now() });
|
|
729
|
+
}
|
|
730
|
+
// Append a terminal error marker in the shape store-render + terminalTurnError
|
|
731
|
+
// recognize (stopReason "error" + errorMessage), so the failure itself
|
|
732
|
+
// survives a reload as an inline error instead of the turn reopening blank
|
|
733
|
+
// ("looks done, no reply"). Matches the live session.error emitted just below.
|
|
734
|
+
const errored = { role: "assistant", content: "", stopReason: "error", errorMessage: errorText, timestamp: Date.now() };
|
|
735
|
+
this.messages.push(errored);
|
|
714
736
|
this.streaming = false;
|
|
737
|
+
this.assistantText = "";
|
|
738
|
+
this.assistantItemBoundary = false;
|
|
715
739
|
this.reasoningText = "";
|
|
716
740
|
this.turnContent = [];
|
|
717
741
|
this.turnTextFlushed = "";
|
|
718
742
|
this.turnToolResults = [];
|
|
719
743
|
this.toolDetailsByCallId.clear();
|
|
720
744
|
this.openToolIds = [];
|
|
721
|
-
|
|
745
|
+
// message_end drives the daemon's base-transcript snapshot (agent_end does
|
|
746
|
+
// not), so the preserved content + error marker are durably persisted.
|
|
747
|
+
this.emit({ type: "message_end", message: errored });
|
|
748
|
+
this.emit({ type: "session.error", error: errorText });
|
|
722
749
|
this.emit({ type: "agent_end" });
|
|
723
750
|
return;
|
|
724
751
|
}
|
|
@@ -872,7 +899,10 @@ class ProtocolSession {
|
|
|
872
899
|
const preflightError = await this.preparePreflight();
|
|
873
900
|
if (preflightError) {
|
|
874
901
|
this.streaming = false;
|
|
875
|
-
|
|
902
|
+
// stopReason "error" is what store-render keys on to re-render this as an
|
|
903
|
+
// inline error on reload (without it the turn reopened blank); it also
|
|
904
|
+
// matches the terminal-error message shape every other failure path uses.
|
|
905
|
+
const message = { role: "assistant", content: "", stopReason: "error", errorMessage: preflightError };
|
|
876
906
|
this.messages.push(message);
|
|
877
907
|
this.emit({ type: "message_start", message: { role: "assistant", content: "" } });
|
|
878
908
|
this.emit({ type: "session.error", error: preflightError });
|
package/dist/server.js
CHANGED
|
@@ -80,6 +80,7 @@ import { forceAbortTurn } from "./session/abort-recovery.js";
|
|
|
80
80
|
import { runRequiredAutomationChecks } from "./automation-checks.js";
|
|
81
81
|
import { configToLegacySettings, mergeLegacyIntoNodeConfig, readNodeConfig, writeNodeConfig } from "./node-config.js";
|
|
82
82
|
import { loadProjectPolicy, resolveProjectSafety } from "./project-policy.js";
|
|
83
|
+
import { hostedCustodyNode } from "./hosted-custody.js";
|
|
83
84
|
import { PolicyEngine } from "./policy/policy-engine.js";
|
|
84
85
|
import { SessionAllowRules } from "./policy/session-allow.js";
|
|
85
86
|
import { TerminalManager } from "./terminal.js";
|
|
@@ -1966,7 +1967,7 @@ const RELAY_COMMANDS = {
|
|
|
1966
1967
|
credsDir,
|
|
1967
1968
|
sendEvent: (event) => relay?.sendEvent(event),
|
|
1968
1969
|
broadcast,
|
|
1969
|
-
pushModelAuthToControlPlane,
|
|
1970
|
+
pushModelAuthToControlPlane: (opts) => pushModelAuthToControlPlane(false, opts?.throwOnFailure === true, opts?.allowEmptyHostedEscrow === true),
|
|
1970
1971
|
refreshSessionAfterAuth,
|
|
1971
1972
|
listProvidersUnified,
|
|
1972
1973
|
}),
|
|
@@ -2407,7 +2408,19 @@ const RELAY_COMMANDS = {
|
|
|
2407
2408
|
// this a models.list answered for one agent (e.g. Codex's models) could
|
|
2408
2409
|
// linger on the composer/picker after the user switched to another agent
|
|
2409
2410
|
// (e.g. Claude) — the "Claude shows Codex models" bug.
|
|
2410
|
-
|
|
2411
|
+
let event;
|
|
2412
|
+
try {
|
|
2413
|
+
event = await modelsListEventFor(record);
|
|
2414
|
+
}
|
|
2415
|
+
catch (error) {
|
|
2416
|
+
// A catalog read failure must never answer with silence: the generic
|
|
2417
|
+
// relay catch would swallow the rejection and the client (notably a
|
|
2418
|
+
// Cloud launch waiting to validate its saved model) could only guess
|
|
2419
|
+
// via timeout. Name the real reason on the session instead.
|
|
2420
|
+
relay?.sendEvent({ type: "session.error", sessionId: requestedSessionId ?? record.id, error: `Couldn't read this machine's model catalog: ${error instanceof Error ? error.message : String(error)}` });
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
relay?.sendEvent(event);
|
|
2411
2424
|
},
|
|
2412
2425
|
"models.prefetch"(msg) {
|
|
2413
2426
|
// The composer's agent picker opened: warm the scratch session for each
|
|
@@ -2569,7 +2582,8 @@ const RELAY_COMMANDS = {
|
|
|
2569
2582
|
}
|
|
2570
2583
|
}
|
|
2571
2584
|
await removeProvider(credsDir, id);
|
|
2572
|
-
|
|
2585
|
+
// Deleting a granted credential must also remove its escrowed cloud copy.
|
|
2586
|
+
await pushModelAuthToControlPlane(false, false, true);
|
|
2573
2587
|
await refreshSessionAfterAuth();
|
|
2574
2588
|
broadcast({ type: "provider.oauth.reset", provider: id, ok: true, providers: await listProvidersUnified() });
|
|
2575
2589
|
broadcast({ type: "providers.list", providers: await listProvidersUnified() });
|
|
@@ -2581,7 +2595,8 @@ const RELAY_COMMANDS = {
|
|
|
2581
2595
|
async "provider.remove"(msg) {
|
|
2582
2596
|
try {
|
|
2583
2597
|
await removeProvider(credsDir, String(msg.provider ?? msg.id ?? ""));
|
|
2584
|
-
|
|
2598
|
+
// Deleting a granted credential must also remove its escrowed cloud copy.
|
|
2599
|
+
await pushModelAuthToControlPlane(false, false, true);
|
|
2585
2600
|
await refreshSessionAfterAuth();
|
|
2586
2601
|
broadcast({ type: "providers.list", providers: await listProvidersUnified() });
|
|
2587
2602
|
}
|
|
@@ -3065,7 +3080,11 @@ const hostedImportedRecordsPath = path.join(appDir, "model-auth-hosted-records.j
|
|
|
3065
3080
|
let lastPushedModelAuthCiphertext = "";
|
|
3066
3081
|
let lastPushedHostedModelAuthCiphertext = "";
|
|
3067
3082
|
let lastPushedHostedModelAuthRevision = -1;
|
|
3068
|
-
|
|
3083
|
+
// Custody semantics are keyed on the dedicated flag (plus an ephemeral
|
|
3084
|
+
// back-compat case) — see src/hosted-custody.ts. A persistent personal node
|
|
3085
|
+
// that polls the hosted GitHub queue is NOT a custody guest: it owns its
|
|
3086
|
+
// logins and must keep account sync and unattended-escrow publishing.
|
|
3087
|
+
const isHostedCustodyNode = () => hostedCustodyNode();
|
|
3069
3088
|
function readLocalModelAuthVaultKey() {
|
|
3070
3089
|
try {
|
|
3071
3090
|
const parsed = JSON.parse(fs.readFileSync(modelAuthVaultKeyPath, "utf8"));
|
|
@@ -3376,12 +3395,19 @@ async function processModelAuthKeyRequests(requests) {
|
|
|
3376
3395
|
});
|
|
3377
3396
|
}
|
|
3378
3397
|
}
|
|
3379
|
-
async function pushHostedModelAuthToControlPlane() {
|
|
3398
|
+
async function pushHostedModelAuthToControlPlane(allowEmpty = false) {
|
|
3380
3399
|
const [records, revision] = await Promise.all([exportUnattendedRecords(credsDir), unattendedCredentialRevision(credsDir)]);
|
|
3381
3400
|
// A setup guest must not establish an empty snapshot when the credential is
|
|
3382
3401
|
// first saved (before the explicit grant command follows). Otherwise its
|
|
3383
3402
|
// one allowed initial write would be consumed by an unusable vault.
|
|
3384
|
-
|
|
3403
|
+
//
|
|
3404
|
+
// And an IMPLICIT empty push must never revoke the escrow: an empty snapshot
|
|
3405
|
+
// is how "disable unattended runs" removes the cloud copy, but every vault
|
|
3406
|
+
// change funnels through here — so a node whose copy simply lacks the grant
|
|
3407
|
+
// (not yet synced, or granted elsewhere) would otherwise wipe the encrypted
|
|
3408
|
+
// cloud copy the user's other machines just published. Only an explicit
|
|
3409
|
+
// local revoke/delete (allowEmpty) may publish an empty snapshot.
|
|
3410
|
+
if (Object.keys(records).length === 0 && (!allowEmpty || isHostedCustodyNode()))
|
|
3385
3411
|
return;
|
|
3386
3412
|
if (revision === lastPushedHostedModelAuthRevision)
|
|
3387
3413
|
return;
|
|
@@ -3413,7 +3439,7 @@ async function pushHostedModelAuthToControlPlane() {
|
|
|
3413
3439
|
throw new Error(`hosted model-auth push failed (${response?.status ?? "offline"})`);
|
|
3414
3440
|
}
|
|
3415
3441
|
}
|
|
3416
|
-
async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = false) {
|
|
3442
|
+
async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = false, allowEmptyHostedEscrow = false) {
|
|
3417
3443
|
if (!sessionAdvertiseTarget)
|
|
3418
3444
|
return;
|
|
3419
3445
|
// Piggyback the (plaintext, non-secret) provider status summary on every
|
|
@@ -3448,7 +3474,7 @@ async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = f
|
|
|
3448
3474
|
writeLocalModelAuthVaultKey(vaultKeyB64);
|
|
3449
3475
|
const ciphertext = encryptModelAuthProviders(providers, deletedAt, localModels, vaultKeyB64, records, recordsDeletedAt);
|
|
3450
3476
|
if (!rotateKey && ciphertext === lastPushedModelAuthCiphertext) {
|
|
3451
|
-
await pushHostedModelAuthToControlPlane();
|
|
3477
|
+
await pushHostedModelAuthToControlPlane(allowEmptyHostedEscrow);
|
|
3452
3478
|
return;
|
|
3453
3479
|
}
|
|
3454
3480
|
const push = await modelAuthFetch("/node/model-auth-vault", { method: "PUT", body: JSON.stringify({ ciphertext, rotated: rotateKey }) });
|
|
@@ -3469,7 +3495,7 @@ async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = f
|
|
|
3469
3495
|
// Publish a DIFFERENT ciphertext under a DIFFERENT key containing only
|
|
3470
3496
|
// records with `unattended:true`. Escrowing this key cannot decrypt the E2E
|
|
3471
3497
|
// account vault, which is the critical custody separation.
|
|
3472
|
-
await pushHostedModelAuthToControlPlane();
|
|
3498
|
+
await pushHostedModelAuthToControlPlane(allowEmptyHostedEscrow);
|
|
3473
3499
|
}
|
|
3474
3500
|
catch (error) {
|
|
3475
3501
|
console.warn("[auth-sync] could not push model auth:", error.message);
|
|
@@ -9295,7 +9321,7 @@ async function modelsListEventFor(record) {
|
|
|
9295
9321
|
const current = session.getCurrentModel();
|
|
9296
9322
|
const models = await publicModelsList(session, current);
|
|
9297
9323
|
const thinking = publicThinkingInfo(session);
|
|
9298
|
-
return { type: "models.list", sessionId: record.id, runtimeId: record.runtimeId, current: current ? publicModel(current, current) : null, models, thinking };
|
|
9324
|
+
return { type: "models.list", sessionId: record.id, runtimeId: record.runtimeId, modelSelection: getRuntime(record.runtimeId).capabilities.modelSelection !== false, current: current ? publicModel(current, current) : null, models, thinking };
|
|
9299
9325
|
}
|
|
9300
9326
|
function publicThinkingInfo(session) {
|
|
9301
9327
|
const supports = typeof session.supportsThinking === "function" ? session.supportsThinking() : false;
|
|
@@ -9322,7 +9348,7 @@ app.get("/api/models", async (req, res, next) => {
|
|
|
9322
9348
|
const current = session.getCurrentModel();
|
|
9323
9349
|
const models = await publicModelsList(session, current);
|
|
9324
9350
|
const thinking = publicThinkingInfo(session);
|
|
9325
|
-
res.json({ sessionId: record.id, current: current ? publicModel(current, current) : null, models, thinking });
|
|
9351
|
+
res.json({ sessionId: record.id, modelSelection: getRuntime(record.runtimeId).capabilities.modelSelection !== false, current: current ? publicModel(current, current) : null, models, thinking });
|
|
9326
9352
|
}
|
|
9327
9353
|
catch (error) {
|
|
9328
9354
|
next(error);
|
|
@@ -9678,7 +9704,8 @@ app.post("/api/auth/credentials", async (req, res, next) => {
|
|
|
9678
9704
|
app.delete("/api/auth/credentials/:provider/:label", async (req, res, next) => {
|
|
9679
9705
|
try {
|
|
9680
9706
|
await removeProviderCredential(credsDir, String(req.params.provider), String(req.params.label));
|
|
9681
|
-
|
|
9707
|
+
// Deleting a granted credential must also remove its escrowed cloud copy.
|
|
9708
|
+
await pushModelAuthToControlPlane(false, false, true);
|
|
9682
9709
|
await refreshSessionAfterAuth();
|
|
9683
9710
|
res.json({ ok: true, records: await listCredentialRecords(credsDir), providers: await listProvidersUnified() });
|
|
9684
9711
|
}
|
|
@@ -9690,7 +9717,8 @@ app.post("/api/auth/credentials/:provider/:label/availability", async (req, res,
|
|
|
9690
9717
|
try {
|
|
9691
9718
|
const sync = req.body?.sync === "node" ? "node" : "account";
|
|
9692
9719
|
await setCredentialSync(credsDir, String(req.params.provider), String(req.params.label), sync);
|
|
9693
|
-
|
|
9720
|
+
// Demoting to machine-only revokes any custody grant; let that clear the escrow.
|
|
9721
|
+
await pushModelAuthToControlPlane(false, false, sync === "node");
|
|
9694
9722
|
res.json({ ok: true, records: await listCredentialRecords(credsDir) });
|
|
9695
9723
|
}
|
|
9696
9724
|
catch (error) {
|
|
@@ -9698,12 +9726,21 @@ app.post("/api/auth/credentials/:provider/:label/availability", async (req, res,
|
|
|
9698
9726
|
}
|
|
9699
9727
|
});
|
|
9700
9728
|
app.post("/api/auth/credentials/:provider/:label/unattended", async (req, res, next) => {
|
|
9701
|
-
|
|
9702
|
-
|
|
9703
|
-
|
|
9729
|
+
const provider = String(req.params.provider);
|
|
9730
|
+
const label = String(req.params.label);
|
|
9731
|
+
const enable = req.body?.unattended === true;
|
|
9732
|
+
const previous = (await listCredentialRecords(credsDir).catch(() => []))
|
|
9733
|
+
.find((record) => record.provider === provider.trim().toLowerCase() && record.label === label)?.unattended === true;
|
|
9734
|
+
try {
|
|
9735
|
+
await setCredentialUnattended(credsDir, provider, label, enable);
|
|
9736
|
+
// Fail loud (mirrors the relay command): the UI must never claim an
|
|
9737
|
+
// encrypted cloud copy exists when custody publication failed. An explicit
|
|
9738
|
+
// disable is the one caller allowed to publish an empty escrow snapshot.
|
|
9739
|
+
await pushModelAuthToControlPlane(false, true, !enable);
|
|
9704
9740
|
res.json({ ok: true, records: await listCredentialRecords(credsDir) });
|
|
9705
9741
|
}
|
|
9706
9742
|
catch (error) {
|
|
9743
|
+
await setCredentialUnattended(credsDir, provider, label, previous).catch(() => { });
|
|
9707
9744
|
next(error);
|
|
9708
9745
|
}
|
|
9709
9746
|
});
|
|
@@ -28,6 +28,18 @@ export function forkTier(source, dest) {
|
|
|
28
28
|
return "replayed";
|
|
29
29
|
return "seeded";
|
|
30
30
|
}
|
|
31
|
+
/** Project a registry RuntimeInfo onto the two flags that decide fork fidelity.
|
|
32
|
+
* Keeps the registry the single source of truth: an agent that declares its
|
|
33
|
+
* real fork capabilities (see the maintained integrations + cliAgentInfo)
|
|
34
|
+
* appears with the correct tier automatically. */
|
|
35
|
+
export function forkCapsFromInfo(info) {
|
|
36
|
+
return {
|
|
37
|
+
id: info.id,
|
|
38
|
+
...(info.displayName ? { displayName: info.displayName } : {}),
|
|
39
|
+
forkTransport: info.capabilities.forkTransport === true,
|
|
40
|
+
forkHistoryImport: info.capabilities.forkHistoryImport === true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
31
43
|
/** Every ordered (source, dest) pair and its tier. */
|
|
32
44
|
export function forkMatrix(agents) {
|
|
33
45
|
const cells = [];
|
package/dist/worktree.js
CHANGED
|
@@ -94,7 +94,15 @@ export async function createWorktree(opts) {
|
|
|
94
94
|
else if (base !== "HEAD" && !(await refExists(repoRoot, base)))
|
|
95
95
|
base = "HEAD";
|
|
96
96
|
// Fail closed on path collisions; never delete an unrelated directory.
|
|
97
|
-
|
|
97
|
+
if (base === "HEAD" && !(await refExists(repoRoot, "HEAD"))) {
|
|
98
|
+
// Empty repo (e.g. a freshly created GitHub repo with no commits): HEAD is
|
|
99
|
+
// unborn, so it can't seed a worktree. Start the branch as an orphan —
|
|
100
|
+
// the session's first commit becomes the repo's root commit.
|
|
101
|
+
await exec("git", ["-C", repoRoot, "worktree", "add", "--orphan", "-b", branch, wtPath]);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, wtPath, base]);
|
|
105
|
+
}
|
|
98
106
|
}
|
|
99
107
|
// Opportunistically reuse a sibling worktree's installed deps (node_modules,
|
|
100
108
|
// target, .venv) via copy-on-write when the filesystem supports it, so a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bivy/bivy",
|
|
3
|
-
"version": "0.16.25-staging.
|
|
3
|
+
"version": "0.16.25-staging.30",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",
|
|
@@ -39,21 +39,21 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
42
|
-
"brace-expansion": "5.0.
|
|
42
|
+
"brace-expansion": "5.0.12",
|
|
43
43
|
"express": "^5.2.1",
|
|
44
|
-
"express-rate-limit": "^8.
|
|
44
|
+
"express-rate-limit": "^8.7.0",
|
|
45
45
|
"node-pty": "^1.1.0",
|
|
46
46
|
"semver": "^7.7.2",
|
|
47
|
-
"typebox": "^1.3.
|
|
48
|
-
"undici": "8.10.
|
|
47
|
+
"typebox": "^1.3.34",
|
|
48
|
+
"undici": "8.10.2",
|
|
49
49
|
"ws": "^8.21.3",
|
|
50
|
-
"yaml": "^2.9.
|
|
51
|
-
"zod": "^4.
|
|
50
|
+
"yaml": "^2.9.1",
|
|
51
|
+
"zod": "^4.6.5"
|
|
52
52
|
},
|
|
53
53
|
"optionalDependencies": {
|
|
54
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.
|
|
55
|
-
"@earendil-works/pi-ai": "0.
|
|
56
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
54
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.278",
|
|
55
|
+
"@earendil-works/pi-ai": "0.86.1",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "0.86.1"
|
|
57
57
|
},
|
|
58
58
|
"overrides": {
|
|
59
59
|
"fast-uri": "3.1.6",
|
|
@@ -62,12 +62,13 @@
|
|
|
62
62
|
"@hono/node-server": "2.0.12",
|
|
63
63
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
64
64
|
"@earendil-works/pi-coding-agent": {
|
|
65
|
-
"brace-expansion": "5.0.
|
|
66
|
-
"undici": "8.10.
|
|
65
|
+
"brace-expansion": "5.0.12",
|
|
66
|
+
"undici": "8.10.2"
|
|
67
67
|
},
|
|
68
|
-
"brace-expansion": "5.0.
|
|
68
|
+
"brace-expansion": "5.0.12",
|
|
69
69
|
"nanoid": "3.3.18",
|
|
70
|
-
"
|
|
70
|
+
"lodash-es": "4.18.1",
|
|
71
|
+
"undici": "8.10.2"
|
|
71
72
|
},
|
|
72
73
|
"readme": "# Bivy\n\n[](https://www.npmjs.com/package/@bivy/bivy)\n[](LICENSE)\n[](https://nodejs.org)\n\n**Run coding agents on your machines and use them from anywhere — from a phone,\nbrowser, terminal, GitHub issue, Slack message, schedule, or webhook.**\n\nBivy is an open-source workspace for coding-agent work. Turn prompts, GitHub\nissues, CI failures, Slack messages, and schedules into live sessions on your\nmachines. Choose the agent and model, sync supported API keys and OAuth logins,\nand steer and review the work from your browser, phone, or terminal.\n\nKeep Claude Code, Codex, Pi, OpenCode, or another supported agent. Keep your\nrepos, tools, and development environment. Bivy connects them into a workflow\nthat doesn't end when you leave your desk.\n\n**[Start free on Bivy Cloud](https://app.bivy.sh)** ·\n**[Quickstart](docs/quickstart.md)** ·\n**[Documentation](docs/README.md)** ·\n**[Self-host](docs/deploy-images.md)** ·\n**[Website](https://bivy.sh)**\n\n**Recommended:** sign in at [app.bivy.sh](https://app.bivy.sh), then copy your\npersonalized **Connect a Machine** command into a terminal on your Mac or Linux\ncomputer. It installs and enrolls the machine without another Bivy login. The\nbrowser connects automatically; choose a repository and send your first task\nright there.\n\nPrefer starting from the terminal?\n\n```bash\ncurl -fsSL https://bivy.sh/install.sh | bash # install + guided setup\ncd your-repo\nbivy run claude # or codex, pi, opencode\nbivy open # continue in the web app (needs remote setup)\n```\n\nBivy Cloud hosts the app, control plane, and relay—not the machines running your\nagents. Connect a Mac, Linux computer, or existing server and bring your own\nagent subscription, model API key, or local model. You can also self-host the\nentire remote-access stack.\n\n> **Bivy is 0.x software.** Claude Code, Codex, Pi, and OpenCode are the\n> release-tested paths. Credential sync, resume, handoffs, approvals, and\n> sandboxing depend on the runtime. See the\n> [runtime support matrix](docs/runtime-support-matrix.md).\n\n## More than remote access\n\nRemote access lets you reach an agent. Bivy also connects **what starts the\nwork, where it runs, which agent and credentials it uses, and how you review\nwhat happened**.\n\n| Capability | What it means for you |\n|---|---|\n| **One workspace, multiple agents** | Use different agents and models for different tasks without maintaining a separate workflow for each. |\n| **Your machines and environment** | Work beside your existing repos, dev servers, databases, private networks, toolchains, and GPUs. |\n| **Automations and triggers** | Let issues, failed CI, messages, schedules, and webhooks start work instead of copying requests into a chat. |\n| **Encrypted key and OAuth sync** | Reuse Bivy-managed provider credentials across enrolled machines and compatible runtimes, with less repeated setup. |\n| **Live sessions from anywhere** | Start at your desk, answer a question or approve an action from your phone, then return to the terminal. |\n| **Reviewable results** | See changes, declared checks, artifacts, and pull requests—not just an agent's claim that it finished. |\n| **Hosted convenience or self-hosting** | Use Bivy Cloud for managed remote access, or run the same open-source core yourself. |\n\n## One workflow, from trigger to review\n\n```text\nPrompt · GitHub issue · CI failure · Linear · Slack · Schedule · Webhook\n │\n ▼\n Choose machine + agent + model\n + supported credentials\n │\n ▼\n Live agent session\n Join · steer · approve · stop\n │\n ▼\n Changes · checks · artifacts · PR\n```\n\nA **Machine** is a computer or server you connect. A **Session** is live agent\nwork on that machine. A **Run** is delegated background work that creates a\nsession and tracks its outcome. An **Automation** is a reusable definition that\ncreates runs when an event matches.\n\nManual and automated work use the same kind of live session. You can join a run\nwhen it needs help rather than wait for a black-box job to finish.\n\n### Work in the environment you already have\n\nA clean cloud sandbox isn't always enough. Your agent may need the database\nrunning on localhost, an uncommitted change, an internal API behind your VPN,\nor a model running on your GPU. Bivy runs the agent where those things already\nexist, subject to that machine's permissions and the runtime's protection.\n\nConnect several machines to the same account: a laptop for interactive work,\na Linux server for background jobs, or a GPU box for local inference. Choose\nthe machine for each session or pin it in an automation. Repository runs can\nuse isolated Git worktrees without rebuilding the whole development environment.\n\n**The execution machine must stay awake and online.** To close your laptop and\nleave work running, run the agent on a different, always-on machine.\n\n[Environment and multi-machine recipes →](docs/capability-recipes.md)\n\n### Use multiple agents, not multiple disconnected workflows\n\nRun Claude Code for one task, Codex for another, and Pi or OpenCode where they\nfit. Bivy supplies the shared session, remote-access, automation, and review\nsurfaces; your chosen agent still does the coding and uses your model provider.\n\n- Choose an agent and, where supported, a model for each session or run.\n- Import existing Claude Code and Codex sessions.\n- Fork or move work to another agent or machine when a different setup fits\n better. Continuation fidelity varies: some paths preserve native history,\n while others replay portable turns or seed the destination with context.\n- Use agent-native logins, Bivy-managed credentials, or local inference.\n Bivy's custom OpenAI-compatible endpoint registry currently feeds Pi;\n other agents may need their own provider configuration.\n- Register your own ACP or headless process agent with `bivy agent add`.\n\nBivy does not replace your agent, provide model inference, or make every agent's\nfeatures identical. Consult the [support matrix](docs/runtime-support-matrix.md)\nand [handoff recipes](docs/capability-recipes.md#fork-or-move-a-session).\n\n### Less signing in. Less copying secrets.\n\nBivy syncs **Bivy-managed API keys and supported OAuth credentials** across\nenrolled machines for compatible runtimes. Connect supported credentials once\nand reuse them where you run work, rather than manually distributing keys to\neach machine.\n\nFor ordinary account sync, credentials are encrypted on the node before upload.\nThe control plane stores ciphertext and wrapped-key metadata; enrolled nodes\nshare access by wrapping the vault key to one another. Bivy Cloud does not\nreceive plaintext credentials through this sync path.\n\nYou can also keep credentials local, use labeled keys and project presets, or\nreference environment variables and 1Password instead of embedding secrets in\nconfiguration:\n\n```bash\nbivy provider login\nbivy credentials add anthropic work\nbivy secrets ref github.repo-token op://Bivy/GitHub/repo-token\n```\n\n**Not every CLI login syncs.** Native agent logins may still be per-machine;\nGitHub App private-key sync is separately opt-in. If you lose every node and\ndevice able to unwrap a vault, you must sign in to providers again. Explicit\nhosted-provisioning custody grants are separate from ordinary encrypted sync.\n\n[Credential sync and runtime coverage →](docs/credential-sync.md) ·\n[Credentials guide →](docs/credentials-guide.md) ·\n[Key storage →](docs/key-management.md)\n\n### Let events start the work\n\nAutomations turn recurring or incoming work into sessions you can join,\nsupervise, and review. Choose the repository, machine, agent, model, approval\nmode, sandbox setting, and maximum attempts.\n\n| Trigger | Example workflow |\n|---|---|\n| **GitHub issues and mentions** | Label an issue `bivy` or `bivy/<machine>`, or mention your Bivy GitHub App, to work toward a pull request. |\n| **Failed CI** | Match a failed workflow, ask the agent to reproduce it, make a fix, and run the affected checks. |\n| **Linear** | Label an issue to start work without copying its description into an agent. |\n| **Slack** | Send a request from the conversation where the work came up. |\n| **Schedules** | Run a weekly dependency review, recurring maintenance, or a one-time task. |\n| **Signed webhooks** | Connect alerts, internal tools, or your own event sources. |\n\nConfigure automations in the app or version them with your repository in\n`.bivy/automations.yaml`:\n\n```bash\nbivy automation init\n# Edit the generated definition for your repository and workflow.\nbivy automation validate\nbivy automation test --event .bivy/events/failed-ci.yaml # supply a local event fixture\nbivy automation apply\n```\n\nOr delegate a one-off job without creating an automation:\n\n```bash\nbivy runs start \"Review outdated dependencies and propose a small, tested update.\"\nbivy runs wait <id>\n```\n\nRuns keep routing and lifecycle evidence, check results, and output references\nin a reviewable Receipt. For unattended issue work, Bivy runs declared repository\nchecks after the agent's turn; failed required checks fail the run even if the\nagent reports success. A completed process alone is not proof that the task\nsucceeded.\n\n[Automation recipes →](docs/capability-recipes.md#let-events-start-runs) ·\n[Automations as code →](docs/automations-as-code.md) ·\n[Run outcomes and reliability limits →](docs/automation-runs.md)\n\n### Start at your desk. Continue anywhere.\n\nOpen the same session in the browser, phone PWA, or terminal. Watch work live,\nanswer questions, approve supported tool calls, or stop the agent.\n\n- Send screenshots, images, logs, and other files from your phone.\n- Download reports and artifacts the agent creates.\n- Use voice input and read-aloud where supported; provider-backed voice may\n send audio or text to the selected provider.\n- Keep a native terminal workflow or use structured chat, depending on the agent.\n\n```bash\nbivy run claude --no-follow # start without attaching\nbivy open # open the web app\nbivy resume # return to the session in your terminal\nbivy link # pair a device directly via QR\n```\n\nNo phone app installation is required. Open [app.bivy.sh](https://app.bivy.sh)\nin your browser; adding it to your home screen is optional.\n\n[Remote access →](docs/remote-access.md) ·\n[Voice, files, and terminal recipes →](docs/capability-recipes.md)\n\n## Get started\n\n### Install\n\nBivy supports **macOS and Linux with Node.js 20+**. The installer installs the\n`@bivy/bivy` package, runs guided setup, and starts a launchd or systemd service:\n\n```bash\ncurl -fsSL https://bivy.sh/install.sh | bash\n```\n\nSetup helps you choose an agent and configure remote access. Existing agents\nkeep their command, login, and configuration. The installer may use `sudo` to\ninstall missing Node.js or Debian/Ubuntu build tools, but never for `npm install`. To inspect it first,\ndownload it with `curl -fsSL https://bivy.sh/install.sh -o install.sh`.\n\nAlready have Node.js and want to avoid sudo?\n\n```bash\nnpm install -g @bivy/bivy\nbivy setup\n```\n\nThen try one small task:\n\n```bash\ncd your-repo\nbivy run claude\n# Ask: \"Explain this repo and make one small, safe improvement. Run the relevant checks.\"\nbivy open\n```\n\nOpen that same session on your phone while it runs. Once that works, connect\nanother machine or add your first automation.\n\n**Local-only works too.** `bivy run`, `bivy resume`, and `bivy sessions` need no\naccount or server. Choose **local only for now** during setup; use `bivy login`\nlater. Browser and phone access need a hosted or self-hosted control plane;\nthe node itself does not serve a web UI.\n\n[Full quickstart →](docs/quickstart.md) ·\n[Installer options, service management, and uninstall →](docs/install.md)\n\n### Choose hosted or self-hosted\n\n| Option | What you get |\n|---|---|\n| **Free Cloud — $0** | Every launch feature, including automations; 10 new remote sessions per rolling seven days. No credit card required. |\n| **Cloud — $15/month** | The same features with unlimited remote sessions. |\n| **Self-hosted Core** | Operate the app, control plane, and relay yourself, with no Bivy usage limits. |\n\nManual and automated sessions share the Cloud allowance. Resuming existing\nsessions and viewing history do not consume it. Agent subscriptions and model\nprovider charges are separate. See [current pricing](https://bivy.sh#pricing).\n\n**Self-host anywhere:** deploy the public control-plane (including the web app)\nand relay images with Postgres and [a small set of environment variables](docs/deploy-images.md).\nYour server or container platform handles HTTPS. Set up owner access in the\nbrowser—no SSH, external authentication provider, or Bivy Cloud account required.\nFor a bare VPS, the [Compose installer](docs/self-host-quickstart.md) automates the\nsame stack. These onboarding features require a release containing them.\n\nStart on Cloud and self-host later if you prefer. Deploy the stack, reconnect\nmachines with `bivy relay:setup`, and pair devices to your server. This is not a\none-click migration of your Cloud account; your local repos and agent\nconfiguration stay in place.\n\n```bash\nbivy relay:setup \\\n --control-plane https://bivy.example.com \\\n --relay wss://relay.example.com\n```\n\nSelf-hosting is community-supported: you own TLS, backups, upgrades, and\nhardening. Public multi-architecture images are available as\n`ghcr.io/bivysh/bivy-control-plane` and `ghcr.io/bivysh/bivy-relay`; pin a release\nversion or full commit SHA.\n\n[Deploy the images anywhere →](docs/deploy-images.md) ·\n[Optional VPS installer →](docs/self-host-quickstart.md) ·\n[Operations reference →](docs/self-host.md)\n\n## Architecture\n\nYour environment, with clear security boundaries:\n\n```text\nYour machine Hosted or self-hosted\n┌──────────────────────┐ ┌──────────────────────┐\n│ Node daemon │──outbound──▶│ Relay │\n│ Agents, repos, tools │ │ Encrypted frames │\n│ Local credentials │ └──────────┬───────────┘\n└──────────────────────┘ │\n Browser / phone\n + control plane\n (app, accounts, metadata)\n```\n\n- **Execution stays on your machine.** Bivy Cloud does not run your agents.\n Your model provider still sees whatever the agent sends it.\n- **Interactive traffic is end-to-end encrypted** between the node and paired\n devices. The relay forwards opaque frames; your node dials out, so no inbound\n public port is required.\n- **Ordinary credential sync uploads ciphertext, not plaintext keys.**\n Supported credentials and recovery limits are documented separately.\n- **Encryption is not universal across integrations.** Slack commands and\n generic webhook instructions reach the control plane in plaintext. Do not\n put secrets in them. Routing and bounded run metadata are also visible there.\n- **Device authorization matters.** QR pairing authorizes a device directly\n through the node. Hosted account pairing trusts the control plane to authorize\n devices and serve the web app that holds client keys.\n- **Bivy is not an OS-level sandbox.** The default approval mode is\n `autonomous`; protection depends on the runtime. Some agents enforce sandbox\n tiers, while process agents may run with your full user permissions.\n Heuristic tool checks help prevent accidents but are not isolation.\n\nReview the runtime's Protection label and configure approval/sandbox settings\nfor the task, especially before enabling unattended work.\n\n[Security model and known limitations →](docs/security-model.md) ·\n[Runtime protection matrix →](docs/runtime-support-matrix.md) ·\n[Configuration →](docs/configuration.md)\n\n## Agents and everyday commands\n\n**Claude Code, Codex, Pi, and OpenCode are release-tested.** Additional adapters\ninclude Gemini CLI, Qwen Code, Goose, Aider, Cline, Crush, Cursor, GitHub Copilot,\nGrok, Amp, Auggie, Droid, Continue, Kilo Code, and Rovo Dev. Installation,\nresume, model selection, and tool protection vary—see the\n[support matrix](docs/runtime-support-matrix.md) and [agent guides](docs/agents/README.md).\n\nRun an arbitrary command with `bivy run -- ./your-agent --flags`, register a\nreusable entry with `bivy agent add`, or package a declarative integration with\nexperimental [plugins](docs/plugins.md).\n\n```bash\nbivy run claude # launch a durable session; also codex, pi, opencode\nbivy sessions # list live and saved sessions\nbivy resume # resume the most recent session\nbivy open # open the web app (requires remote setup)\nbivy nodes # list connected account machines\nbivy runs list # inspect delegated work\nbivy automation init # scaffold repo-owned automations\nbivy provider login # connect supported model credentials\nbivy agent add # register an ACP or process agent\nbivy doctor # check installation and connectivity\nbivy logs -f # follow node logs\nbivy update # update and restart the service\n```\n\n`bivy update` uses your original installation method and waits for an active\nturn to finish before restarting. Use `--force` to skip that wait.\n\n[CLI reference →](docs/cli-reference.md) ·\n[Node and project configuration →](docs/config-as-code.md) ·\n[GitHub setup →](docs/github-setup.md) ·\n[Linear setup →](docs/linear-work-queue.md)\n\n## Development and contributions\n\n```bash\npnpm install\npnpm run dev # node daemon on http://localhost:4317\npnpm run dev:web # web client dev server\n```\n\n| Directory | Contents |\n|---|---|\n| `src/`, `bin/` | Node daemon, CLI, runtime adapters, sessions, approvals, secrets |\n| `packages/core/` | Shared protocol, pairing, and wire format |\n| `packages/web/`, `packages/ui/` | React PWA and shared design system |\n| `services/relay/` | Self-hostable encrypted relay |\n| `services/control-plane/` | Self-hostable control plane |\n| `deploy/` | Deployment examples |\n\n```bash\npnpm run typecheck\npnpm run typecheck:web\npnpm run lint\npnpm run test:unit\npnpm run test:core\npnpm run check:licenses\npnpm run check:secrets\n```\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow. Releases\nare published from CI with provenance attestations; see\n[release verification](docs/releasing.md).\n\n**Found a security issue?** Use\n[GitHub private vulnerability reporting](https://github.com/bivysh/bivy/security/advisories/new),\nnot a public issue. See [SECURITY.md](SECURITY.md).\n\n### In development—not available at launch\n\nAutomatically provisioned, short-lived **ephemeral machines** are in development\nfor hosted and bring-your-own-cloud deployments. Neither path is ready or\nsupported for this launch. Use an existing computer or server you operate.\nExperimental provisioning has different credential-custody and encryption\nboundaries; see the [provisioning trust model](docs/hosted-provisioning-trust-model.md).\n\n## License\n\nEverything in this repository—node, CLI, web/PWA, relay, and control plane—is\nfree and open-source **AGPL-3.0-only Core**, with no Bivy usage limits. You may\nuse, modify, and self-host it under that license. If users interact with your\nmodified version over a network, section 13 requires you to offer its\ncorresponding source. See [LICENSE](LICENSE).\n\nBivy Cloud is the hosted operation of that stack plus billing and plans, in a\nseparate private repository. Contributions use the\n[DCO](CONTRIBUTING.md#certificate-of-origin); there is no CLA.\n",
|
|
73
74
|
"readmeFilename": "README.md"
|