@bivy/bivy 0.7.0 → 0.8.0

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 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
- function agentRequest(method, params) {
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
- agent.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
70
- return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
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
- agent.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`);
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
- agent.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } })}\n`);
109
+ try { agentWrite({ jsonrpc: "2.0", id, error: { code, message } }); } catch { /* child gone */ }
77
110
  }
78
111
  function agentNotify(method, params) {
79
- agent.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
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). Models aren't part of the core ACP surface, so we
290
- // don't advertise a picker we can't drive.
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) => {
@@ -20,8 +20,9 @@
20
20
  "label": "OpenCode",
21
21
  "command": "opencode",
22
22
  "hidden": false,
23
- "supportTier": "beta",
24
- "certification": "adapter-tested",
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
- function printFirstRunSteps(modelReady = false) {
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
- if (!modelReady) console.log(` 1. Model access: ${c.cyan("bivy login")} ${c.dim("(for Pi; other agents use their own login)")}`);
3439
- console.log(` ${modelReady ? "1" : "2"}. Start chatting: ${c.cyan("bivy")}`);
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 — but the shape is near-universal. Claude Code, Codex, Cursor,
7
- // Windsurf, and most MCP hosts use the same `{ mcpServers: { name: { command,
8
- // args, env } } }` object (stdio servers) plus optional remote (url) servers.
9
- // This module rewrites that object so every stdio server launches through the
10
- // Bivy MCP proxy instead of directly turning each agent's own MCP config into
11
- // the injection point, with no agent-specific code beyond the file location.
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
- // Only JSON configs are handled (Claude, Gemini, OpenCode, generic .mcp.json).
13
- // TOML/YAML-config agents are skipped they still run and are governed by the
14
- // FS + network channels. Unit-tested in test/harness-mcp-inject.test.ts.
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). Never throws.
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 = routeThroughProxy(parsed, launcher);
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). restore() deletes
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
- test: (r) => /\b402\b|payment required|insufficient\s+(?:credit|quota|balance|funds)|credit balance (?:is )?too low|quota (?:exceeded|exhausted)|billing|(?:usage|session) limit (?:reached|hit)|(?:you(?:'ve| have)\s+)?hit your limit|out of credits|plan (?:limit|allowance)/i.test(r),
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
- const resetsAt = parseResetsAt(raw);
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