@bivy/bivy 0.7.0 → 0.8.0-staging.110
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 +128 -14
- package/bin/agent-manifest.json +3 -2
- package/bin/bivy.mjs +36 -4
- package/dist/github-device-auth.js +16 -0
- package/dist/harness/mcp-config.js +89 -6
- package/dist/harness/mcp-inject.js +31 -8
- package/dist/metadata.js +32 -0
- package/dist/policy/conditions.js +54 -3
- package/dist/policy/run-policy.js +2 -1
- package/dist/policy/session-reroute.js +66 -0
- package/dist/repo-workspace.js +19 -0
- package/dist/runtime/index.js +127 -18
- package/dist/runtime/protocol.js +15 -0
- package/dist/secrets.js +6 -2
- package/dist/server.js +372 -23
- package/package.json +1 -1
package/bin/acp-shim.mjs
CHANGED
|
@@ -57,45 +57,112 @@ function bivy(obj) {
|
|
|
57
57
|
// --- ACP agent (child JSON-RPC over its stdio) ------------------------------
|
|
58
58
|
const agent = spawn(agentCmd, agentArgs, { stdio: ["pipe", "pipe", "pipe"] });
|
|
59
59
|
agent.stderr.on("data", (d) => process.stderr.write(`[acp-agent] ${d}`));
|
|
60
|
-
agent.on("error", (e) => bivy({ type: "session.error", error: `acp agent spawn failed: ${e.message}` }));
|
|
61
|
-
agent.on("exit", (code) => {
|
|
62
|
-
if (code && code !== 0) bivy({ type: "session.error", error: `acp agent exited (${code})` });
|
|
63
|
-
});
|
|
64
|
-
|
|
65
60
|
let nextId = 1;
|
|
66
61
|
const pending = new Map(); // jsonrpc id -> {resolve, reject}
|
|
67
|
-
|
|
62
|
+
let agentDead = null; // set to an Error once the child is gone
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The child is gone (spawn failure or exit). Every in-flight request must be
|
|
66
|
+
* rejected: without this, a CLI whose ACP mode doesn't exist leaves `initialize`
|
|
67
|
+
* pending forever and the daemon waits out its whole session.create timeout instead
|
|
68
|
+
* of surfacing the real reason. Fail fast, with the reason.
|
|
69
|
+
*/
|
|
70
|
+
function killPending(reason) {
|
|
71
|
+
if (agentDead) return;
|
|
72
|
+
agentDead = reason instanceof Error ? reason : new Error(String(reason));
|
|
73
|
+
bivy({ type: "session.error", error: agentDead.message });
|
|
74
|
+
for (const [id, p] of [...pending]) { pending.delete(id); p.reject(agentDead); }
|
|
75
|
+
}
|
|
76
|
+
agent.on("error", (e) => killPending(`acp agent spawn failed: ${e.message}`));
|
|
77
|
+
agent.on("exit", (code, signal) => {
|
|
78
|
+
if (code || signal) killPending(`acp agent exited (${code ?? signal})`);
|
|
79
|
+
});
|
|
80
|
+
// Writing to a dead child's stdin raises EPIPE; with no listener that's an uncaught
|
|
81
|
+
// exception that takes the shim down mid-turn instead of reporting the cause.
|
|
82
|
+
agent.stdin.on("error", (e) => killPending(`acp agent stdin closed: ${e.message}`));
|
|
83
|
+
|
|
84
|
+
function agentWrite(payload) {
|
|
85
|
+
if (agentDead) throw agentDead;
|
|
86
|
+
agent.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
87
|
+
}
|
|
88
|
+
function agentRequest(method, params, { timeoutMs } = {}) {
|
|
68
89
|
const id = nextId++;
|
|
69
|
-
|
|
70
|
-
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
let timer;
|
|
92
|
+
pending.set(id, {
|
|
93
|
+
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
94
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
95
|
+
});
|
|
96
|
+
if (timeoutMs) {
|
|
97
|
+
timer = setTimeout(() => {
|
|
98
|
+
if (pending.delete(id)) reject(new Error(`acp ${method} timed out after ${timeoutMs}ms`));
|
|
99
|
+
}, timeoutMs);
|
|
100
|
+
}
|
|
101
|
+
try { agentWrite({ jsonrpc: "2.0", id, method, params }); }
|
|
102
|
+
catch (e) { clearTimeout(timer); pending.delete(id); reject(e); }
|
|
103
|
+
});
|
|
71
104
|
}
|
|
72
105
|
function agentReply(id, result) {
|
|
73
|
-
|
|
106
|
+
try { agentWrite({ jsonrpc: "2.0", id, result }); } catch { /* child gone; killPending already reported it */ }
|
|
74
107
|
}
|
|
75
108
|
function agentReplyError(id, code, message) {
|
|
76
|
-
|
|
109
|
+
try { agentWrite({ jsonrpc: "2.0", id, error: { code, message } }); } catch { /* child gone */ }
|
|
77
110
|
}
|
|
78
111
|
function agentNotify(method, params) {
|
|
79
|
-
|
|
112
|
+
try { agentWrite({ jsonrpc: "2.0", method, params }); } catch { /* child gone */ }
|
|
80
113
|
}
|
|
81
114
|
|
|
82
115
|
// --- session state ----------------------------------------------------------
|
|
83
116
|
let sessionId = null;
|
|
84
117
|
let cwd = process.cwd();
|
|
85
118
|
let initialized = false;
|
|
119
|
+
// The ACP config-option id that selects the model (usually "model"), learned from
|
|
120
|
+
// session/new; used for the session/set_config_option fallback.
|
|
121
|
+
let modelConfigId = "model";
|
|
122
|
+
// A model chosen before the ACP session existed, applied once it does.
|
|
123
|
+
let pendingModel = null;
|
|
86
124
|
// toolCallId -> { requestId, options } so a later bivy tool.decision answers the
|
|
87
125
|
// right ACP permission request with a concrete optionId.
|
|
88
126
|
const permissionRequests = new Map();
|
|
89
127
|
|
|
90
128
|
async function ensureInitialized() {
|
|
91
129
|
if (initialized) return;
|
|
130
|
+
// Bounded: a binary that accepts the launch args but never speaks ACP would
|
|
131
|
+
// otherwise hang here until the daemon's own session timeout, hiding the cause.
|
|
92
132
|
await agentRequest("initialize", {
|
|
93
133
|
protocolVersion: 1,
|
|
94
134
|
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
|
95
|
-
});
|
|
135
|
+
}, { timeoutMs: 20_000 });
|
|
96
136
|
initialized = true;
|
|
97
137
|
}
|
|
98
138
|
|
|
139
|
+
/**
|
|
140
|
+
* ACP exposes a session's selectable models as a `select` config option on the
|
|
141
|
+
* session/new|load result (opencode: `configOptions: [{id:"model", currentValue,
|
|
142
|
+
* options:[{value,name}]}]`). Models are per-NODE — they depend on which providers
|
|
143
|
+
* the user has authenticated in the agent — so a hardcoded list would offer models
|
|
144
|
+
* the agent rejects. Publish what the agent actually reports, as a post-hello
|
|
145
|
+
* `runtime.models` event ProtocolRuntime folds into its picker.
|
|
146
|
+
*/
|
|
147
|
+
function publishModels(result) {
|
|
148
|
+
const options = Array.isArray(result?.configOptions) ? result.configOptions : [];
|
|
149
|
+
const modelOption = options.find((o) => String(o?.id ?? "") === "model" || String(o?.category ?? "") === "model");
|
|
150
|
+
const choices = Array.isArray(modelOption?.options) ? modelOption.options : [];
|
|
151
|
+
const models = choices
|
|
152
|
+
.map((o) => ({ id: String(o?.value ?? ""), name: String(o?.name ?? o?.value ?? "") }))
|
|
153
|
+
.filter((m) => m.id)
|
|
154
|
+
// ACP model ids are `provider/model`; split the provider so Bivy can group and
|
|
155
|
+
// scope provider-specific settings the same way it does for other runtimes.
|
|
156
|
+
.map((m) => ({ ...m, provider: m.id.includes("/") ? m.id.split("/")[0] : "agent" }));
|
|
157
|
+
if (!models.length) return;
|
|
158
|
+
modelConfigId = String(modelOption?.id ?? "model");
|
|
159
|
+
bivy({
|
|
160
|
+
type: "runtime.models",
|
|
161
|
+
models,
|
|
162
|
+
...(modelOption?.currentValue ? { currentModel: String(modelOption.currentValue) } : {}),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
99
166
|
// --- ACP → bivy: streamed session/update notifications ----------------------
|
|
100
167
|
function onSessionUpdate(params) {
|
|
101
168
|
const u = params?.update;
|
|
@@ -211,6 +278,33 @@ createInterface({ input: agent.stdout }).on("line", (line) => {
|
|
|
211
278
|
if (msg.method === "session/update") onSessionUpdate(msg.params);
|
|
212
279
|
});
|
|
213
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Select a model on the live ACP session. `session/set_model` is the direct form;
|
|
283
|
+
* agents that only expose the generic config-option surface take the same choice as
|
|
284
|
+
* `session/set_config_option`. A rejection propagates: ProtocolRuntime only commits
|
|
285
|
+
* the selection once we ack, so a model the agent won't accept must not look applied.
|
|
286
|
+
*/
|
|
287
|
+
async function setAgentModel(model) {
|
|
288
|
+
try {
|
|
289
|
+
await agentRequest("session/set_model", { sessionId, modelId: model }, { timeoutMs: 15_000 });
|
|
290
|
+
} catch (primary) {
|
|
291
|
+
try {
|
|
292
|
+
await agentRequest("session/set_config_option", { sessionId, configId: modelConfigId, value: model }, { timeoutMs: 15_000 });
|
|
293
|
+
} catch {
|
|
294
|
+
throw primary;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function applyPendingModel() {
|
|
300
|
+
if (!pendingModel || !sessionId) return;
|
|
301
|
+
const model = pendingModel;
|
|
302
|
+
pendingModel = null;
|
|
303
|
+
// Best-effort: a stale pick shouldn't block the session from opening.
|
|
304
|
+
try { await setAgentModel(model); }
|
|
305
|
+
catch (e) { bivy({ type: "runtime.debug", message: `acp set_model failed: ${e instanceof Error ? e.message : String(e)}` }); }
|
|
306
|
+
}
|
|
307
|
+
|
|
214
308
|
// --- bivy commands in (daemon → us) -----------------------------------------
|
|
215
309
|
async function onBivyCommand(msg) {
|
|
216
310
|
const type = String(msg.type || "");
|
|
@@ -224,6 +318,8 @@ async function onBivyCommand(msg) {
|
|
|
224
318
|
cwd = String(msg.cwd || msg.workspace || cwd);
|
|
225
319
|
const res = await agentRequest("session/new", { cwd, mcpServers: [] });
|
|
226
320
|
sessionId = res?.sessionId ?? res?.session?.id ?? null;
|
|
321
|
+
publishModels(res);
|
|
322
|
+
await applyPendingModel();
|
|
227
323
|
bivy({ replyTo: id, ok: true, runtimeSessionRef: sessionId });
|
|
228
324
|
return;
|
|
229
325
|
}
|
|
@@ -235,11 +331,14 @@ async function onBivyCommand(msg) {
|
|
|
235
331
|
try {
|
|
236
332
|
const res = await agentRequest("session/load", { sessionId: ref, cwd, mcpServers: [] });
|
|
237
333
|
sessionId = res?.sessionId ?? ref;
|
|
334
|
+
publishModels(res);
|
|
238
335
|
} catch {
|
|
239
336
|
// Agent doesn't support session/load — start fresh so the chat still opens.
|
|
240
337
|
const res = await agentRequest("session/new", { cwd, mcpServers: [] });
|
|
241
338
|
sessionId = res?.sessionId ?? null;
|
|
339
|
+
publishModels(res);
|
|
242
340
|
}
|
|
341
|
+
await applyPendingModel();
|
|
243
342
|
bivy({ replyTo: id, ok: true, runtimeSessionRef: sessionId });
|
|
244
343
|
return;
|
|
245
344
|
}
|
|
@@ -270,6 +369,19 @@ async function onBivyCommand(msg) {
|
|
|
270
369
|
}
|
|
271
370
|
return;
|
|
272
371
|
}
|
|
372
|
+
case "model.set": {
|
|
373
|
+
const model = String(msg.model ?? "").trim();
|
|
374
|
+
if (!model) { bivy({ replyTo: id, ok: true }); return; }
|
|
375
|
+
if (!sessionId) {
|
|
376
|
+
// Chosen before the session exists — remember and apply at session/new.
|
|
377
|
+
pendingModel = model;
|
|
378
|
+
bivy({ replyTo: id, ok: true });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
await setAgentModel(model);
|
|
382
|
+
bivy({ replyTo: id, ok: true });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
273
385
|
case "session.abort": {
|
|
274
386
|
if (sessionId) agentNotify("session/cancel", { sessionId });
|
|
275
387
|
if (id !== undefined) bivy({ replyTo: id, ok: true });
|
|
@@ -286,8 +398,10 @@ async function onBivyCommand(msg) {
|
|
|
286
398
|
}
|
|
287
399
|
|
|
288
400
|
// Announce capabilities: ACP agents are governed (per-tool permission) and
|
|
289
|
-
// resumable (session/load).
|
|
290
|
-
//
|
|
401
|
+
// resumable (session/load). modelSelection starts FALSE and is upgraded later by a
|
|
402
|
+
// `runtime.models` event if the session reports selectable models — the list is
|
|
403
|
+
// per-node (it depends on the providers the user has authenticated in the agent)
|
|
404
|
+
// and only arrives with session/new, so claiming a picker here would be a guess.
|
|
291
405
|
bivy({ type: "hello", runtime: { capabilities: { toolInterception: true, modelSelection: false, resume: true } } });
|
|
292
406
|
|
|
293
407
|
createInterface({ input: process.stdin }).on("line", (line) => {
|
package/bin/agent-manifest.json
CHANGED
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
"label": "OpenCode",
|
|
21
21
|
"command": "opencode",
|
|
22
22
|
"hidden": false,
|
|
23
|
-
"supportTier": "
|
|
24
|
-
"certification": "
|
|
23
|
+
"supportTier": "supported",
|
|
24
|
+
"certification": "release-tested",
|
|
25
|
+
"testedVersion": "1.18.13",
|
|
25
26
|
"headlessFlags": [
|
|
26
27
|
"run",
|
|
27
28
|
"-s"
|
package/bin/bivy.mjs
CHANGED
|
@@ -3343,9 +3343,11 @@ async function cmdSetup(args = []) {
|
|
|
3343
3343
|
console.log(` ${agentReady ? c.green("✓") : c.yellow("!")} runtime ${agentReady ? `${setupAgent?.label || "Pi"} available` : "not installed — run 'bivy agents:install'"}`);
|
|
3344
3344
|
console.log(` ${modelReady ? (setupAgent?.needsBivyModel ? c.green("✓") : c.dim("○")) : c.yellow("!")} model ${modelReady ? (setupAgent?.needsBivyModel ? "credential configured" : "agent-managed — verified by the first task") : "not configured — run 'bivy login'"}`);
|
|
3345
3345
|
console.log(` ${c.dim("○")} repository chosen from the directory where you start Bivy`);
|
|
3346
|
+
const ghReady = githubConnected(finalConfig);
|
|
3347
|
+
console.log(` ${ghReady ? c.green("✓") : c.dim("○")} GitHub ${ghReady ? "connected — your repos will list in the app" : c.dim("not connected — 'bivy github:connect' to list repos (optional)")}`);
|
|
3346
3348
|
console.log(` ${agentReady && modelReady ? c.green("✓") : c.yellow("!")} first task ${agentReady && modelReady ? "ready to try" : "blocked by the stage above"}`);
|
|
3347
3349
|
console.log(` ${fs.existsSync(relayConfigPath) ? c.green("✓") : c.yellow("!")} remote ${fs.existsSync(relayConfigPath) ? "configured" : "not configured — run 'bivy relay:setup'"}\n`);
|
|
3348
|
-
printFirstRunSteps(modelReady);
|
|
3350
|
+
printFirstRunSteps(modelReady, finalConfig);
|
|
3349
3351
|
await finishSetupRemote(finalConfig, setupSession);
|
|
3350
3352
|
}
|
|
3351
3353
|
|
|
@@ -3433,10 +3435,29 @@ async function openRemoteApp(config, { setupSession = null, open = true } = {})
|
|
|
3433
3435
|
return { relay, remoteBase, accountUrl, pairedUrl, openUrl };
|
|
3434
3436
|
}
|
|
3435
3437
|
|
|
3436
|
-
|
|
3438
|
+
// Whether GitHub is connected for repo listing/cloning. Bivy's own connect flow
|
|
3439
|
+
// (`bivy github:connect`, or the app's Connect button) writes BIVY_GITHUB_TOKEN
|
|
3440
|
+
// — usually a `secret://` vault reference — into cli.json's env; an explicit env
|
|
3441
|
+
// token counts too. A `gh auth login` session also works at runtime (the node
|
|
3442
|
+
// falls back to `gh auth token`), but that can't be known without shelling out,
|
|
3443
|
+
// so it's treated as "not connected here" — the hint is optional either way.
|
|
3444
|
+
function githubConnected(config = null) {
|
|
3445
|
+
const token = String(
|
|
3446
|
+
config?.env?.BIVY_GITHUB_TOKEN || process.env.BIVY_GITHUB_TOKEN || process.env.GITHUB_TOKEN || "",
|
|
3447
|
+
).trim();
|
|
3448
|
+
return Boolean(token);
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
function printFirstRunSteps(modelReady = false, config = null) {
|
|
3437
3452
|
console.log(" Run your first task:");
|
|
3438
|
-
|
|
3439
|
-
console.log(` ${
|
|
3453
|
+
let n = 0;
|
|
3454
|
+
if (!modelReady) console.log(` ${++n}. Model access: ${c.cyan("bivy login")} ${c.dim("(for Pi; other agents use their own login)")}`);
|
|
3455
|
+
// GitHub is optional — "No repo" sessions work without it — so this only shows
|
|
3456
|
+
// when nothing is connected yet, and never blocks the flow.
|
|
3457
|
+
if (!githubConnected(config)) {
|
|
3458
|
+
console.log(` ${++n}. GitHub ${c.dim("(optional)")}: ${c.cyan("bivy github:connect")} ${c.dim("— lets the app list your repos; required for private ones")}`);
|
|
3459
|
+
}
|
|
3460
|
+
console.log(` ${++n}. Start chatting: ${c.cyan("bivy")}`);
|
|
3440
3461
|
console.log(` Starter task: ${c.cyan('bivy exec "explain this repository and identify one low-risk improvement"')}\n`);
|
|
3441
3462
|
}
|
|
3442
3463
|
|
|
@@ -3614,6 +3635,17 @@ async function cmdDoctor(args = []) {
|
|
|
3614
3635
|
console.log(c.bold("\n Bivy doctor\n"));
|
|
3615
3636
|
console.log(` ${mark(hasSupportedNode())} Node ${process.version}${hasSupportedNode() ? "" : c.dim(" (needs >= 22.19.0)")}`);
|
|
3616
3637
|
console.log(` ${mark(commandExists("git"), true)} git${commandExists("git") ? "" : c.dim(" (recommended for repo-backed sessions)")}`);
|
|
3638
|
+
// GitHub is optional (a "No repo" session needs none), so this only ever warns.
|
|
3639
|
+
// `gh` is NOT required — it's a token fallback; the primary path is Bivy's own
|
|
3640
|
+
// 'bivy github:connect' (or the app's Connect button). We surface gh only as an
|
|
3641
|
+
// available shortcut when it's installed but nothing is connected yet.
|
|
3642
|
+
const ghConnected = githubConnected(config);
|
|
3643
|
+
const ghHint = ghConnected
|
|
3644
|
+
? c.green("connected")
|
|
3645
|
+
: commandExists("gh")
|
|
3646
|
+
? c.dim("not connected — 'bivy github:connect' (or 'gh auth login')")
|
|
3647
|
+
: c.dim("not connected — 'bivy github:connect' to list/clone private repos");
|
|
3648
|
+
console.log(` ${mark(ghConnected, true)} GitHub ${ghHint}`);
|
|
3617
3649
|
console.log(` ${mark(reachable)} node ${reachable ? c.green("reachable") : c.dim("not reachable — 'bivy start'")} at ${url(config)}`);
|
|
3618
3650
|
console.log(` ${mark(/running/.test(serviceStatusLine()), true)} ${serviceStatusLine()}`);
|
|
3619
3651
|
const defaultAgent = String(config.env?.BIVY_RUNTIME || runtimes?.current?.id || "pi");
|
|
@@ -67,6 +67,22 @@ export function interpretTokenResponse(data) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
70
|
+
/**
|
|
71
|
+
* A SINGLE access-token poll (no internal waiting) — for a caller that drives
|
|
72
|
+
* its own cadence. The web-driven connect flow uses this: the node holds the
|
|
73
|
+
* device code and the browser polls it on GitHub's interval, so the node never
|
|
74
|
+
* blocks a request thread in a poll loop. `pollForAccessToken` is the CLI's
|
|
75
|
+
* self-driving loop built on the same interpretation.
|
|
76
|
+
*/
|
|
77
|
+
export async function pollAccessTokenOnce(clientId, deviceCode) {
|
|
78
|
+
const res = await fetch(ACCESS_TOKEN_URL, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { accept: "application/json", "content-type": "application/json" },
|
|
81
|
+
body: JSON.stringify({ client_id: clientId, device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" }),
|
|
82
|
+
});
|
|
83
|
+
const data = (await res.json().catch(() => ({})));
|
|
84
|
+
return interpretTokenResponse(data);
|
|
85
|
+
}
|
|
70
86
|
/** Step 2: poll until the user authorizes (or the code expires). */
|
|
71
87
|
export async function pollForAccessToken(clientId, device, signal) {
|
|
72
88
|
let intervalMs = device.intervalSec * 1000;
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
// Universal Agent Harness — MCP config rewriting.
|
|
4
4
|
//
|
|
5
5
|
// The one piece of MCP governance that is unavoidably per-agent is *where* the
|
|
6
|
-
// config lives —
|
|
7
|
-
// Windsurf, and most MCP hosts use
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
6
|
+
// config lives — and, for a couple of hosts, the shape. Claude Code, Cursor,
|
|
7
|
+
// Windsurf, and most MCP hosts use `{ mcpServers: { name: { command, args, env
|
|
8
|
+
// } } }` (stdio) plus optional remote (url) servers. OpenCode is the JSON
|
|
9
|
+
// outlier: `{ mcp: { name: { type: "local", command: [bin, ...args],
|
|
10
|
+
// environment } } }` (see opencode.ai/config.json). This module rewrites both
|
|
11
|
+
// shapes so every stdio server launches through the Bivy MCP proxy instead of
|
|
12
|
+
// directly — turning each agent's own MCP config into the injection point.
|
|
12
13
|
//
|
|
13
14
|
// Pure functions, no I/O — unit-tested in test/harness-mcp-config.test.ts. The
|
|
14
15
|
// file-location table for each agent is data (see agentMcpConfigTargets) that
|
|
@@ -109,6 +110,88 @@ export function withBivyToolsServer(config, spec, name = "bivy") {
|
|
|
109
110
|
return { config, added: false };
|
|
110
111
|
return { config: { ...config, mcpServers: { ...servers, [name]: spec } }, added: true };
|
|
111
112
|
}
|
|
113
|
+
/** Convert a universal stdio server spec into OpenCode's local-server shape. */
|
|
114
|
+
export function toOpenCodeLocalServer(spec) {
|
|
115
|
+
const command = [spec.command ?? "", ...(spec.args ?? [])].filter((s, i) => i === 0 || s !== undefined);
|
|
116
|
+
// Drop a leading empty command if somehow absent — callers always pass one.
|
|
117
|
+
const argv = command[0] ? command : command.slice(1);
|
|
118
|
+
const out = { type: "local", command: argv };
|
|
119
|
+
if (spec.env && Object.keys(spec.env).length)
|
|
120
|
+
out.environment = { ...spec.env };
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
/** True when an OpenCode local server already launches through the Bivy proxy. */
|
|
124
|
+
export function isOpenCodeProxied(spec, launcher) {
|
|
125
|
+
if (!Array.isArray(spec.command) || spec.command.length === 0)
|
|
126
|
+
return false;
|
|
127
|
+
if (spec.command[0] !== launcher.command)
|
|
128
|
+
return false;
|
|
129
|
+
return spec.command.includes(PROXY_MARKER);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Rewrite every local stdio server in `config.mcp` to launch through the proxy:
|
|
133
|
+
*
|
|
134
|
+
* original: { type: "local", command: ["mcp-fs", "--root", "/w"], environment: {...} }
|
|
135
|
+
* rewritten: { type: "local",
|
|
136
|
+
* command: ["bivy", "mcp-proxy", "--bivy-mcp", "--server", "<name>", "--",
|
|
137
|
+
* "mcp-fs", "--root", "/w"],
|
|
138
|
+
* environment: {...} }
|
|
139
|
+
*
|
|
140
|
+
* Remote servers and already-proxied locals are left untouched (reported in
|
|
141
|
+
* `skipped`). Idempotent. Does not mutate the input.
|
|
142
|
+
*/
|
|
143
|
+
export function routeOpenCodeThroughProxy(config, launcher) {
|
|
144
|
+
const rewritten = [];
|
|
145
|
+
const skipped = [];
|
|
146
|
+
const servers = config.mcp ?? {};
|
|
147
|
+
const nextServers = {};
|
|
148
|
+
for (const [name, spec] of Object.entries(servers)) {
|
|
149
|
+
if (!spec || typeof spec !== "object") {
|
|
150
|
+
nextServers[name] = spec;
|
|
151
|
+
skipped.push(name);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!Array.isArray(spec.command) || spec.command.length === 0 || typeof spec.command[0] !== "string" || !spec.command[0]) {
|
|
155
|
+
// Remote/url server, enabled-only stub, or malformed — can't wrap via stdio proxy.
|
|
156
|
+
nextServers[name] = spec;
|
|
157
|
+
skipped.push(name);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (isOpenCodeProxied(spec, launcher)) {
|
|
161
|
+
nextServers[name] = spec;
|
|
162
|
+
skipped.push(name);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const prefix = launcher.argsPrefix ?? [];
|
|
166
|
+
const orig = spec.command;
|
|
167
|
+
nextServers[name] = {
|
|
168
|
+
...spec,
|
|
169
|
+
type: "local",
|
|
170
|
+
command: [launcher.command, ...prefix, PROXY_MARKER, "--server", name, "--", ...orig],
|
|
171
|
+
};
|
|
172
|
+
rewritten.push(name);
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
config: { ...config, mcp: nextServers },
|
|
176
|
+
rewritten,
|
|
177
|
+
skipped,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Insert the Bivy tools server under OpenCode's `mcp.<name>` (default "bivy").
|
|
182
|
+
* Idempotent: an existing entry of that name is left untouched.
|
|
183
|
+
*/
|
|
184
|
+
export function withOpenCodeBivyToolsServer(config, spec, name = "bivy") {
|
|
185
|
+
const servers = config.mcp ?? {};
|
|
186
|
+
if (servers[name])
|
|
187
|
+
return { config, added: false };
|
|
188
|
+
return { config: { ...config, mcp: { ...servers, [name]: spec } }, added: true };
|
|
189
|
+
}
|
|
190
|
+
/** Basename check for OpenCode project config files we inject into. */
|
|
191
|
+
export function isOpenCodeConfigFile(filePath) {
|
|
192
|
+
const base = nodePath.basename(filePath).toLowerCase();
|
|
193
|
+
return base === "opencode.json" || base === ".opencode.json" || base === "opencode.jsonc" || base === ".opencode.jsonc";
|
|
194
|
+
}
|
|
112
195
|
/** JSON MCP-config file candidates for an agent, most-specific (safest) first. */
|
|
113
196
|
export function agentMcpConfigTargets(agentId, ctx) {
|
|
114
197
|
const ws = (...parts) => nodePath.join(ctx.workspace, ...parts);
|
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
// session-scoped (workspace-local files preferred) so a failure or a concurrent
|
|
10
10
|
// session can't corrupt config: we snapshot the exact bytes and restore them.
|
|
11
11
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// JSON configs (Claude, Gemini, generic .mcp.json) use the universal
|
|
13
|
+
// `mcpServers` shape; OpenCode's project `opencode.json` uses its own `mcp`
|
|
14
|
+
// shape (see routeOpenCodeThroughProxy). TOML/YAML (Codex, Goose) go through
|
|
15
|
+
// the format-specific writers. Unit-tested in test/harness-mcp-inject.test.ts.
|
|
15
16
|
import fs from "node:fs";
|
|
16
17
|
import path from "node:path";
|
|
17
|
-
import { agentMcpConfigTargets, bivyToolsServerSpec, routeThroughProxy, withBivyToolsServer, } from "./mcp-config.js";
|
|
18
|
+
import { agentMcpConfigTargets, bivyToolsServerSpec, isOpenCodeConfigFile, routeOpenCodeThroughProxy, routeThroughProxy, toOpenCodeLocalServer, withBivyToolsServer, withOpenCodeBivyToolsServer, } from "./mcp-config.js";
|
|
18
19
|
import { injectTomlMcp, injectYamlMcp, insertTomlServer } from "./mcp-config-formats.js";
|
|
19
20
|
/** The proxy launcher Bivy injects — `bivy mcp-proxy …`. */
|
|
20
21
|
export function bivyProxyLauncher(bivyCommand = "bivy") {
|
|
@@ -23,7 +24,9 @@ export function bivyProxyLauncher(bivyCommand = "bivy") {
|
|
|
23
24
|
/**
|
|
24
25
|
* Inject the proxy into a single JSON config file. Returns a restore thunk
|
|
25
26
|
* (a no-op if the file was absent, unreadable, non-JSON, or had no stdio
|
|
26
|
-
* servers to route).
|
|
27
|
+
* servers to route). OpenCode project configs (`opencode.json`) use the
|
|
28
|
+
* OpenCode `mcp` shape; everything else uses the universal `mcpServers` shape.
|
|
29
|
+
* Never throws.
|
|
27
30
|
*/
|
|
28
31
|
export function injectJsonMcpConfig(filePath, launcher) {
|
|
29
32
|
let original;
|
|
@@ -40,7 +43,9 @@ export function injectJsonMcpConfig(filePath, launcher) {
|
|
|
40
43
|
catch {
|
|
41
44
|
return { injected: false, restore: () => { } };
|
|
42
45
|
}
|
|
43
|
-
const result =
|
|
46
|
+
const result = isOpenCodeConfigFile(filePath)
|
|
47
|
+
? routeOpenCodeThroughProxy(parsed, launcher)
|
|
48
|
+
: routeThroughProxy(parsed, launcher);
|
|
44
49
|
if (result.rewritten.length === 0)
|
|
45
50
|
return { injected: false, restore: () => { } };
|
|
46
51
|
// Preserve the file's indentation feel by re-serializing with 2 spaces; the
|
|
@@ -116,7 +121,9 @@ export function injectMcpConfigFile(filePath, launcher) {
|
|
|
116
121
|
* servers a file already has), this CREATES the config when absent so an agent
|
|
117
122
|
* that ships no MCP config still gets the tool. Handles the most-specific JSON
|
|
118
123
|
* config (session-local for claude/gemini/opencode/generic) and Codex's TOML
|
|
119
|
-
* (`~/.codex/config.toml` — Codex has no project-local option).
|
|
124
|
+
* (`~/.codex/config.toml` — Codex has no project-local option). OpenCode gets
|
|
125
|
+
* its native `{ mcp: { bivy: { type: "local", command: [...] } } }` shape — the
|
|
126
|
+
* universal `mcpServers` key is rejected by OpenCode's schema. restore() deletes
|
|
120
127
|
* a file it created and rewrites the exact original bytes of one it modified.
|
|
121
128
|
* Idempotent (a `bivy` server already present is a no-op, so concurrent sessions
|
|
122
129
|
* sharing a global config don't double up). Best-effort; never throws. Goose YAML
|
|
@@ -131,6 +138,7 @@ export function injectBivyToolsForSession(agentId, ctx, bivyCommand = "bivy") {
|
|
|
131
138
|
return { injected: [], restore: () => { } };
|
|
132
139
|
const spec = bivyToolsServerSpec({ sessionId: ctx.sessionId, endpoint: ctx.endpoint, bivyCommand });
|
|
133
140
|
const ext = path.extname(target).toLowerCase();
|
|
141
|
+
const openCode = agentId === "opencode" || isOpenCodeConfigFile(target);
|
|
134
142
|
const existed = fs.existsSync(target);
|
|
135
143
|
let original;
|
|
136
144
|
if (existed) {
|
|
@@ -142,7 +150,22 @@ export function injectBivyToolsForSession(agentId, ctx, bivyCommand = "bivy") {
|
|
|
142
150
|
}
|
|
143
151
|
}
|
|
144
152
|
let nextContent;
|
|
145
|
-
if (ext === ".json") {
|
|
153
|
+
if (ext === ".json" && openCode) {
|
|
154
|
+
let parsed = {};
|
|
155
|
+
if (original !== undefined) {
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(original);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return { injected: [], restore: () => { } };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const { config, added } = withOpenCodeBivyToolsServer(parsed, toOpenCodeLocalServer(spec));
|
|
164
|
+
if (!added)
|
|
165
|
+
return { injected: [], restore: () => { } };
|
|
166
|
+
nextContent = `${JSON.stringify(config, null, 2)}\n`;
|
|
167
|
+
}
|
|
168
|
+
else if (ext === ".json") {
|
|
146
169
|
let parsed = {};
|
|
147
170
|
if (original !== undefined) {
|
|
148
171
|
try {
|
package/dist/metadata.js
CHANGED
|
@@ -169,6 +169,38 @@ export class MetadataStore {
|
|
|
169
169
|
this.data.sessions[id] = { ...prev, resumePending: pending, updatedAt: nowIso() };
|
|
170
170
|
this.save();
|
|
171
171
|
}
|
|
172
|
+
/** Set/clear the durable auto-resume time (rate/usage-limit recovery). Pass
|
|
173
|
+
* null to clear. No-op when the row is missing or already in the requested
|
|
174
|
+
* state, so it never churns the file on the hot turn path. */
|
|
175
|
+
setResumeAt(id, resumeAt) {
|
|
176
|
+
const prev = this.data.sessions[id];
|
|
177
|
+
if (!prev)
|
|
178
|
+
return;
|
|
179
|
+
const next = resumeAt ?? undefined;
|
|
180
|
+
if ((prev.resumeAt ?? undefined) === next)
|
|
181
|
+
return;
|
|
182
|
+
this.data.sessions[id] = { ...prev, resumeAt: next, updatedAt: nowIso() };
|
|
183
|
+
this.save();
|
|
184
|
+
}
|
|
185
|
+
/** Set the durable consecutive auto-resume counter (the restart-safe backstop
|
|
186
|
+
* for the in-memory reroute budget). Pass 0 to clear. No-op when the row is
|
|
187
|
+
* missing or already in the requested state, so a normal turn (counter already
|
|
188
|
+
* 0) never churns the file. */
|
|
189
|
+
setResumeAttempts(id, attempts) {
|
|
190
|
+
const prev = this.data.sessions[id];
|
|
191
|
+
if (!prev)
|
|
192
|
+
return;
|
|
193
|
+
const next = attempts > 0 ? attempts : undefined;
|
|
194
|
+
if ((prev.resumeAttempts ?? undefined) === next)
|
|
195
|
+
return;
|
|
196
|
+
this.data.sessions[id] = { ...prev, resumeAttempts: next, updatedAt: nowIso() };
|
|
197
|
+
this.save();
|
|
198
|
+
}
|
|
199
|
+
/** Sessions with a durable auto-resume time set — the resume sweep re-arms
|
|
200
|
+
* these after a restart. */
|
|
201
|
+
sessionsWithResumeAt() {
|
|
202
|
+
return Object.values(this.data.sessions).filter((s) => typeof s.resumeAt === "string" && s.resumeAt);
|
|
203
|
+
}
|
|
172
204
|
/** Look up a session's durable metadata by id, or by session-file path. */
|
|
173
205
|
getSession(idOrPath) {
|
|
174
206
|
if (!idOrPath)
|
|
@@ -50,6 +50,48 @@ export function parseResetsAt(raw) {
|
|
|
50
50
|
const iso = /(20\d\d-\d\d-\d\dT[\d:.]+(?:Z|[+-]\d\d:?\d\d))/.exec(raw);
|
|
51
51
|
return iso?.[1];
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Parse a bare wall-clock reset time — the shape Claude's subscription limits
|
|
55
|
+
* surface, e.g. `resets 12am (UTC)`, `resets at 3pm UTC`, `resets 09:00 UTC` —
|
|
56
|
+
* into the ISO timestamp of its NEXT occurrence (interpreted as UTC, which is
|
|
57
|
+
* what these messages state). Returns undefined when there's no clear clock
|
|
58
|
+
* time, so a relative phrase ("resets in 2 hours", handled by
|
|
59
|
+
* parseRetryAfterMs) or an unrelated number never masquerades as a reset.
|
|
60
|
+
*
|
|
61
|
+
* NB: a bare time-of-day can't say WHICH day, so for a multi-day window (a
|
|
62
|
+
* "weekly limit") this resolves to the nearest matching midnight, which may be
|
|
63
|
+
* earlier than the real reset. Prefer a structured resetsAtHint when available.
|
|
64
|
+
*/
|
|
65
|
+
export function parseResetClock(raw, nowMs) {
|
|
66
|
+
const m = /reset[a-z]*\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i.exec(raw);
|
|
67
|
+
if (!m)
|
|
68
|
+
return undefined;
|
|
69
|
+
const meridiem = m[3]?.toLowerCase();
|
|
70
|
+
// Require a real clock signal — a meridiem, an explicit minutes field, or a
|
|
71
|
+
// trailing UTC/GMT marker — so a bare "resets 5 nodes" can't parse as 05:00.
|
|
72
|
+
const tzFollows = /\b(?:utc|gmt)\b/i.test(raw.slice(m.index));
|
|
73
|
+
if (!meridiem && m[2] === undefined && !tzFollows)
|
|
74
|
+
return undefined;
|
|
75
|
+
let hour = Number(m[1]);
|
|
76
|
+
const minute = m[2] ? Number(m[2]) : 0;
|
|
77
|
+
if (hour > 23 || minute > 59)
|
|
78
|
+
return undefined;
|
|
79
|
+
if (meridiem === "am") {
|
|
80
|
+
if (hour === 12)
|
|
81
|
+
hour = 0;
|
|
82
|
+
}
|
|
83
|
+
else if (meridiem === "pm") {
|
|
84
|
+
if (hour !== 12)
|
|
85
|
+
hour += 12;
|
|
86
|
+
}
|
|
87
|
+
if (hour > 23)
|
|
88
|
+
return undefined;
|
|
89
|
+
const now = new Date(nowMs);
|
|
90
|
+
let target = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hour, minute, 0, 0);
|
|
91
|
+
if (target <= nowMs)
|
|
92
|
+
target += 86_400_000; // already past today → next day's occurrence
|
|
93
|
+
return new Date(target).toISOString();
|
|
94
|
+
}
|
|
53
95
|
// Ordered classifiers: the FIRST match wins, so more-specific/actionable
|
|
54
96
|
// conditions are tested before broader ones (auth 401 before generic HTTP
|
|
55
97
|
// noise; explicit billing/quota before a bare rate-limit; context-window before
|
|
@@ -58,7 +100,12 @@ const CLASSIFIERS = [
|
|
|
58
100
|
{ condition: "auth_failed", test: (r) => isAnthropicAuthError(r) },
|
|
59
101
|
{
|
|
60
102
|
condition: "credits_exhausted",
|
|
61
|
-
|
|
103
|
+
// Includes subscription usage caps — a Claude "5-hour" or "weekly" window
|
|
104
|
+
// hit reads "you've hit your weekly limit · resets 12am (UTC)". The window
|
|
105
|
+
// qualifier (weekly/daily/5-hour/7-day) is optional and may sit between
|
|
106
|
+
// "your" and "limit", so it must not break the match (it did before — the
|
|
107
|
+
// word "weekly" left these limits classified "unknown" and never resumed).
|
|
108
|
+
test: (r) => /\b402\b|payment required|insufficient\s+(?:credit|quota|balance|funds)|credit balance (?:is )?too low|quota (?:exceeded|exhausted)|billing|(?:usage|session|weekly|daily|monthly|5[\s-]?hour|7[\s-]?day)[\s-]?limit(?:\s+(?:reached|hit))?|(?:you(?:'ve| have)\s+)?hit your (?:(?:weekly|daily|monthly|session|usage|5[\s-]?hour|7[\s-]?day)\s+)?limit|out of credits|plan (?:limit|allowance)/i.test(r),
|
|
62
109
|
},
|
|
63
110
|
{
|
|
64
111
|
condition: "rate_limited",
|
|
@@ -86,7 +133,7 @@ const CLASSIFIERS = [
|
|
|
86
133
|
* recovery metadata. Unmatched failures are `"unknown"` — deliberately left for
|
|
87
134
|
* a human rather than blindly retried.
|
|
88
135
|
*/
|
|
89
|
-
export function classifyFailure(error) {
|
|
136
|
+
export function classifyFailure(error, opts = {}) {
|
|
90
137
|
const raw = rawText(error).slice(0, 2000);
|
|
91
138
|
const condition = CLASSIFIERS.find((c) => c.test(raw))?.condition ?? "unknown";
|
|
92
139
|
const out = { condition, raw };
|
|
@@ -95,7 +142,11 @@ export function classifyFailure(error) {
|
|
|
95
142
|
const retryAfterMs = parseRetryAfterMs(raw);
|
|
96
143
|
if (retryAfterMs !== undefined)
|
|
97
144
|
out.retryAfterMs = retryAfterMs;
|
|
98
|
-
|
|
145
|
+
// Reset time, most-authoritative first: a structured hint the caller
|
|
146
|
+
// supplied (the provider's own usage snapshot), then an ISO stamp in the
|
|
147
|
+
// text, then a bare wall-clock ("resets 12am (UTC)") resolved to its next
|
|
148
|
+
// occurrence.
|
|
149
|
+
const resetsAt = opts.resetsAtHint ?? parseResetsAt(raw) ?? parseResetClock(raw, opts.now ?? Date.now());
|
|
99
150
|
if (resetsAt !== undefined)
|
|
100
151
|
out.resetsAt = resetsAt;
|
|
101
152
|
}
|
|
@@ -34,7 +34,7 @@ export function createRunPolicy(deps = {}) {
|
|
|
34
34
|
const now = deps.now ?? Date.now;
|
|
35
35
|
return {
|
|
36
36
|
decide(ctx) {
|
|
37
|
-
const classified = classifyFailure(ctx.error);
|
|
37
|
+
const classified = classifyFailure(ctx.error, { now: now(), resetsAtHint: ctx.resetsAtHint });
|
|
38
38
|
const { condition } = classified;
|
|
39
39
|
const rule = findRule(ruleset, condition, context);
|
|
40
40
|
if (!rule)
|
|
@@ -73,6 +73,7 @@ export function createRunPolicy(deps = {}) {
|
|
|
73
73
|
delayMs,
|
|
74
74
|
condition,
|
|
75
75
|
summary: `${condition}: transient — retrying (attempt ${nextAttempt}/${rule.maxAttempts})${timing}.`,
|
|
76
|
+
...(resetDelayMs !== undefined && classified.resetsAt ? { resetsAt: classified.resetsAt } : {}),
|
|
76
77
|
};
|
|
77
78
|
}
|
|
78
79
|
// action === "reroute": walk the chain from the current cursor, skipping
|