@bivy/bivy 0.16.10-staging.1 → 0.16.10-staging.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/bivy.mjs +18 -1
- package/dist/control-plane-tasks.js +14 -0
- package/dist/runtime/host.js +19 -4
- package/dist/server.js +18 -20
- package/dist/session/fork-standup.js +2 -2
- package/dist/session/remote-session-admission.js +41 -0
- package/package.json +1 -1
package/bin/bivy.mjs
CHANGED
|
@@ -1791,13 +1791,30 @@ async function cmdExec(args = []) {
|
|
|
1791
1791
|
let token;
|
|
1792
1792
|
try { token = await localDeviceToken(config); }
|
|
1793
1793
|
catch (error) { console.error(c.red(error?.message || String(error))); process.exit(1); return; }
|
|
1794
|
-
|
|
1794
|
+
// Translate the friendly bridge names to their governed runtime ids (the same
|
|
1795
|
+
// mapping `bivy run <agent> --chat` uses), so `bivy exec --agent codex` opens
|
|
1796
|
+
// the governed Codex app-server session instead of the plain exec fallback.
|
|
1797
|
+
// Other aliases (claude, gemini-cli, …) resolve server-side via the registry.
|
|
1798
|
+
const execArgs = translateExecAgent(args);
|
|
1799
|
+
const code = await run(nodeBin, [...nodeScriptArgs(execEntry), "--url", url(config), "--token", token, ...execArgs], {
|
|
1795
1800
|
cwd: repoRoot,
|
|
1796
1801
|
env: startEnv(config),
|
|
1797
1802
|
});
|
|
1798
1803
|
process.exit(code);
|
|
1799
1804
|
}
|
|
1800
1805
|
|
|
1806
|
+
// Rewrite a `bivy exec` arg list's --agent/-a value through governedChatAgentId,
|
|
1807
|
+
// leaving everything else (the free-text prompt, --session, flags) untouched.
|
|
1808
|
+
function translateExecAgent(args = []) {
|
|
1809
|
+
const out = [...args];
|
|
1810
|
+
for (let i = 0; i < out.length; i++) {
|
|
1811
|
+
const a = out[i];
|
|
1812
|
+
if ((a === "--agent" || a === "-a") && out[i + 1]) { out[i + 1] = governedChatAgentId(out[i + 1]); i++; }
|
|
1813
|
+
else if (typeof a === "string" && a.startsWith("--agent=")) out[i] = `--agent=${governedChatAgentId(a.slice("--agent=".length))}`;
|
|
1814
|
+
}
|
|
1815
|
+
return out;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1801
1818
|
// `bivy completions <bash|zsh|fish>` — print a shell completion script to eval or
|
|
1802
1819
|
// install. Covers the top-level commands and known integration ids.
|
|
1803
1820
|
function cmdCompletions(args = []) {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* issues (and Slack) across many repos/nodes. Pure HTTP + claim/loop logic lives
|
|
17
17
|
* here; the actual run is injected so the daemon keeps the agent wiring.
|
|
18
18
|
*/
|
|
19
|
+
import { RemoteSessionAdmissionError } from "./session/remote-session-admission.js";
|
|
19
20
|
/** True when every required tag is among this node's declared capabilities.
|
|
20
21
|
* A node that fails this must never attempt to claim the item — the hard
|
|
21
22
|
* block happens locally, before any claim race, so an ineligible node never
|
|
@@ -351,6 +352,19 @@ export class ControlPlaneTaskPoller {
|
|
|
351
352
|
// A cancelled/lost Run has no node-side terminal transition or retry.
|
|
352
353
|
if (run.state !== "active")
|
|
353
354
|
return;
|
|
355
|
+
// Deployment admission is not an agent failure. Never reroute/retry it
|
|
356
|
+
// through a provider policy or mark the work successfully completed.
|
|
357
|
+
if (error instanceof RemoteSessionAdmissionError) {
|
|
358
|
+
const now = new Date().toISOString();
|
|
359
|
+
await report({
|
|
360
|
+
events: [{ at: now, kind: "policy_denial", summary: error.message, reasonCode: error.code, status: "denied", attempt }],
|
|
361
|
+
attention: { severity: "warning", reason: error.message, since: now },
|
|
362
|
+
});
|
|
363
|
+
if (run.state !== "active")
|
|
364
|
+
return;
|
|
365
|
+
await needsAttentionWork(this.cfg, item.id);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
354
368
|
const policy = typeof this.policy === "function" ? this.policy(current) : this.policy;
|
|
355
369
|
const decision = policy?.decide({
|
|
356
370
|
routing: { runtimeId: current.runtimeId, model: current.model },
|
package/dist/runtime/host.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
-
import { listRuntimes, makeRuntime } from "./index.js";
|
|
3
|
+
import { canonicalAgentId, listRuntimes, makeRuntime } from "./index.js";
|
|
4
4
|
import { RemoteRuntime, connectSocketTransport } from "./remote.js";
|
|
5
5
|
import { withExactCapabilitySurface } from "./types.js";
|
|
6
6
|
function remoteRuntimeSelection() {
|
|
@@ -50,12 +50,24 @@ export class RuntimeHost {
|
|
|
50
50
|
}
|
|
51
51
|
resolveRuntimeId(requested, fallback) {
|
|
52
52
|
const wantId = (requested || fallback).toLowerCase();
|
|
53
|
-
|
|
53
|
+
// Resolve declared aliases and historical ids through the one authoritative
|
|
54
|
+
// registry (e.g. `claude` → claude-code-sdk, `gemini-cli` → gemini,
|
|
55
|
+
// `open-code` → opencode) so the session/exec API accepts the same friendly
|
|
56
|
+
// names the picker and `bivy run` use — not only exact canonical ids. This
|
|
57
|
+
// honors the aliases integrations already declare as data, instead of making
|
|
58
|
+
// every caller know each canonical id (previously only the CLI's `--chat`
|
|
59
|
+
// path translated a couple by hand, so `bivy exec --agent claude` failed).
|
|
60
|
+
// Unknown names fall through to wantId and still raise "Unknown agent" below.
|
|
61
|
+
const canonical = canonicalAgentId(wantId) ?? wantId;
|
|
62
|
+
// Pass the resolved id as the "current" runtime so a hidden-but-runnable
|
|
63
|
+
// contribution is still resolvable to back a session (the profiles' "hidden
|
|
64
|
+
// from the picker, still runnable" contract; parity with makeRuntime).
|
|
65
|
+
const info = this.list(canonical).find((candidate) => candidate.id === canonical);
|
|
54
66
|
if (!info)
|
|
55
67
|
throw new Error(`Unknown agent: ${wantId}`);
|
|
56
68
|
if (info.status !== "available")
|
|
57
69
|
throw new Error(`${info.displayName} is not available on this node yet.`);
|
|
58
|
-
return
|
|
70
|
+
return canonical;
|
|
59
71
|
}
|
|
60
72
|
get(requested, fallback, sandbox) {
|
|
61
73
|
// Remote path (opt-in, reversible): return a RemoteRuntime that drives the
|
|
@@ -63,7 +75,10 @@ export class RuntimeHost {
|
|
|
63
75
|
// runtime the daemon can't run locally (e.g. the Claude SDK isn't installed
|
|
64
76
|
// here) is still offloadable — remote availability is the service's business.
|
|
65
77
|
const remote = remoteRuntimeSelection();
|
|
66
|
-
const
|
|
78
|
+
const requestedId = (requested || fallback).toLowerCase();
|
|
79
|
+
// Canonicalize before the remote check too, so an alias (e.g. `claude`) is
|
|
80
|
+
// matched against BIVY_REMOTE_RUNTIME and drives the correct remote facade.
|
|
81
|
+
const wantId = canonicalAgentId(requestedId) ?? requestedId;
|
|
67
82
|
if (remote && remote.enabled(wantId))
|
|
68
83
|
return this.getRemote(wantId, remote.addr, sandbox);
|
|
69
84
|
const id = this.resolveRuntimeId(requested, fallback);
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
3
|
import fs from "node:fs";
|
|
4
|
+
import { createRemoteSessionAdmission, RemoteSessionAdmissionError } from "./session/remote-session-admission.js";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import os from "node:os";
|
|
6
7
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -2911,17 +2912,6 @@ const RELAY_COMMANDS = {
|
|
|
2911
2912
|
});
|
|
2912
2913
|
return;
|
|
2913
2914
|
}
|
|
2914
|
-
const remoteSessionRequestId = requestId ?? randomUUID();
|
|
2915
|
-
const sessionAdmission = await admitRelaySessionCreate(remoteSessionRequestId);
|
|
2916
|
-
if (!sessionAdmission.allowed) {
|
|
2917
|
-
relay?.sendEvent({
|
|
2918
|
-
type: "session.error",
|
|
2919
|
-
code: sessionAdmission.code || "remote_session_limit",
|
|
2920
|
-
error: sessionAdmission.error,
|
|
2921
|
-
requestId,
|
|
2922
|
-
});
|
|
2923
|
-
return;
|
|
2924
|
-
}
|
|
2925
2915
|
let record;
|
|
2926
2916
|
try {
|
|
2927
2917
|
// Deduped by requestId so a client's post-reconnect retry adopts the
|
|
@@ -2948,7 +2938,7 @@ const RELAY_COMMANDS = {
|
|
|
2948
2938
|
});
|
|
2949
2939
|
}
|
|
2950
2940
|
catch (error) {
|
|
2951
|
-
relay?.sendEvent({ type: "session.error", error: error instanceof Error ? error.message : String(error) });
|
|
2941
|
+
relay?.sendEvent({ type: "session.error", requestId, code: error instanceof RemoteSessionAdmissionError ? error.code : undefined, error: error instanceof Error ? error.message : String(error) });
|
|
2952
2942
|
return;
|
|
2953
2943
|
}
|
|
2954
2944
|
// Resolve once from the session's actual, now-known launch facts (final
|
|
@@ -2977,7 +2967,8 @@ const relayCtx = { reply: (event) => relay?.sendEvent(event), broadcast };
|
|
|
2977
2967
|
const RELAY_CLIENT_ID = "relay";
|
|
2978
2968
|
async function handleRelayMessage(msg) {
|
|
2979
2969
|
try {
|
|
2980
|
-
const
|
|
2970
|
+
const requestId = typeof msg.requestId === "string" && msg.requestId.trim() ? msg.requestId : randomUUID();
|
|
2971
|
+
const dispatched = await remoteSessionAdmission.run(JSON.stringify(["relay", identity.nodeId, msg.kind, requestId]), () => clientCommands.dispatch(msg.kind, msg, relayCtx));
|
|
2981
2972
|
if (dispatched.handled)
|
|
2982
2973
|
return;
|
|
2983
2974
|
// Fallthrough for kinds not in RELAY_COMMANDS: terminal.* frames go to the
|
|
@@ -3188,24 +3179,25 @@ async function modelAuthFetch(pathname, init = {}) {
|
|
|
3188
3179
|
headers.set("content-type", "application/json");
|
|
3189
3180
|
return fetch(`${sessionAdvertiseTarget.controlPlaneUrl.replace(/\/$/, "")}${pathname}`, { ...init, headers });
|
|
3190
3181
|
}
|
|
3191
|
-
|
|
3192
|
-
//
|
|
3193
|
-
//
|
|
3182
|
+
const remoteSessionAdmission = createRemoteSessionAdmission(async (idempotencyKey) => {
|
|
3183
|
+
// Only remote scopes reach this callback. Self-hosted control planes answer
|
|
3184
|
+
// allowed without an extension; losing enrollment mid-launch must fail closed.
|
|
3194
3185
|
const res = await modelAuthFetch("/node/policy/check", {
|
|
3195
3186
|
method: "POST",
|
|
3196
3187
|
body: JSON.stringify({ operation: "session.create", idempotencyKey }),
|
|
3188
|
+
signal: AbortSignal.timeout(10_000),
|
|
3197
3189
|
});
|
|
3198
3190
|
if (!res)
|
|
3199
|
-
return { allowed:
|
|
3191
|
+
return { allowed: false, code: "extension_unavailable", reason: "Reconnect this machine before starting a new remote session." };
|
|
3200
3192
|
const decision = await res.json().catch(() => ({}));
|
|
3201
|
-
if (res.ok && decision.allowed
|
|
3193
|
+
if (res.ok && decision.allowed === true)
|
|
3202
3194
|
return { allowed: true };
|
|
3203
3195
|
return {
|
|
3204
3196
|
allowed: false,
|
|
3205
3197
|
code: decision.code,
|
|
3206
|
-
|
|
3198
|
+
reason: decision.reason || decision.error || "This account has reached its remote session allowance.",
|
|
3207
3199
|
};
|
|
3208
|
-
}
|
|
3200
|
+
});
|
|
3209
3201
|
// Debounced model-auth sync trigger. A relay wake (`work.available`) fires this
|
|
3210
3202
|
// so peers answer a new node's vault-key request promptly (event-driven) instead
|
|
3211
3203
|
// of on the steady 30s poll. Coalesces a burst of wakes into one sync.
|
|
@@ -4926,6 +4918,11 @@ function lastUserMessageText(record) {
|
|
|
4926
4918
|
return "";
|
|
4927
4919
|
}
|
|
4928
4920
|
async function runWorkItem(item, report, signal) {
|
|
4921
|
+
// Stable across delivery/lease retries and node changes. Follow-ups that resume
|
|
4922
|
+
// a session do not consume a slot; any fallback that creates one does.
|
|
4923
|
+
return remoteSessionAdmission.run(JSON.stringify(["automation", item.id]), () => executeWorkItem(item, report, signal));
|
|
4924
|
+
}
|
|
4925
|
+
async function executeWorkItem(item, report, signal) {
|
|
4929
4926
|
if (signal.aborted)
|
|
4930
4927
|
throw signal.reason ?? new Error("Run cancelled");
|
|
4931
4928
|
// Scheduled, manual, and webhook-triggered automations carry the operator's
|
|
@@ -7953,6 +7950,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
|
|
|
7953
7950
|
if (allowedAgents?.length && !allowedAgents.includes(rt.id)) {
|
|
7954
7951
|
throw new Error(`Repository policy does not allow agent ${rt.id}`);
|
|
7955
7952
|
}
|
|
7953
|
+
await remoteSessionAdmission.admit({ resume: Boolean(requestedSessionFile) && !opts.newSession, internal: opts.ephemeral });
|
|
7956
7954
|
// Optional git-worktree isolation (fresh sessions only). The agent then runs in
|
|
7957
7955
|
// the worktree, and the A1 boundary confines writes there.
|
|
7958
7956
|
let worktree = restoredWorktree;
|
|
@@ -215,8 +215,8 @@ export function createForkStandUp(deps) {
|
|
|
215
215
|
const targetModel = opts.model ?? (targetRuntimeId === bundle.record.runtimeId ? bundle.record.modelRef : undefined);
|
|
216
216
|
const plan = await deps.materializeFork({ bundle, targetRuntime, ctx: { workspace, cwd, model: targetModel }, seed: { transcriptUrl: opts.transcriptUrl } });
|
|
217
217
|
const record = plan.kind === "resume"
|
|
218
|
-
? await deps.createSession(cwd, plan.sessionFile, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false })
|
|
219
|
-
: await deps.createSession(cwd, undefined, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false });
|
|
218
|
+
? await deps.createSession(cwd, plan.sessionFile, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false, newSession: true })
|
|
219
|
+
: await deps.createSession(cwd, undefined, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false, newSession: true });
|
|
220
220
|
// Mark the new session as a fork of its source, so the run card can show
|
|
221
221
|
// "Forked from …" and the lineage survives a reload (persisted below).
|
|
222
222
|
record.forkedFrom = bundle.record.sourceSessionId;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
export class RemoteSessionAdmissionError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(message, code = "deployment_policy") {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = "RemoteSessionAdmissionError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Scope admission to remote commands and hosted work, not local sessions or
|
|
13
|
+
* history publication. The scope follows async creation helpers (including
|
|
14
|
+
* GitHub/Linear), so a new path cannot forget to forward a billing option.
|
|
15
|
+
* Each creation gets its own slot; retrying the same request reuses those keys.
|
|
16
|
+
* No plan names or allowances belong in Core.
|
|
17
|
+
*/
|
|
18
|
+
export function createRemoteSessionAdmission(authorize) {
|
|
19
|
+
const context = new AsyncLocalStorage();
|
|
20
|
+
return {
|
|
21
|
+
run(key, work) {
|
|
22
|
+
return context.run({ key, next: 0 }, work);
|
|
23
|
+
},
|
|
24
|
+
async admit(options = {}) {
|
|
25
|
+
const scope = context.getStore();
|
|
26
|
+
if (!scope || options.resume || options.internal)
|
|
27
|
+
return;
|
|
28
|
+
const key = JSON.stringify([scope.key, scope.next++]);
|
|
29
|
+
let decision;
|
|
30
|
+
try {
|
|
31
|
+
decision = await authorize(key);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new RemoteSessionAdmissionError("Remote session admission is temporarily unavailable. Try again shortly.", "extension_unavailable");
|
|
35
|
+
}
|
|
36
|
+
if (decision.allowed !== true) {
|
|
37
|
+
throw new RemoteSessionAdmissionError(decision.reason || "New remote sessions are unavailable for this account.", decision.code);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
package/package.json
CHANGED