@miraland-labs/conduit-bridge 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/README.md CHANGED
@@ -2,16 +2,18 @@
2
2
 
3
3
  Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connects a computer to one organization, claims work, and drives a local agent.
4
4
 
5
+ **Current npm:** `0.8.0` — **multi-driver lanes** on one Connect computer (`drivers online|offline`), shared concurrent slots **1–4** (sum across online IDEs, not per IDE), per-attempt worktrees, optional `--ensure-checkout`, safe resume.
6
+
5
7
  ## Prerequisites
6
8
 
7
9
  - Node.js 20+
8
- - One supported agent CLI on PATH:
10
+ - One or more supported agent CLIs on PATH (common: install several on one Mac):
9
11
  - **Claude Code** — `claude` (Conduit pump or Anthropic login)
10
12
  - **Codex** — `codex` (Conduit pump or ChatGPT/OpenAI login). Since the July 2026 ChatGPT desktop merge, Bridge also finds the CLI bundled inside ChatGPT.app when `codex` is not on PATH (macOS)
11
13
  - **OpenCode** — `opencode` (Conduit pump or local provider login)
12
- - **Cursor Agent** — `agent` (requires `conduit fuel local` + `CURSOR_API_KEY` / Cursor login)
13
- - **Kiro CLI** — `kiro-cli` (requires `conduit fuel local` + `KIRO_API_KEY` / `kiro-cli login`)
14
- - **Antigravity** — `agy` (requires `conduit fuel local` + `GEMINI_API_KEY` / `agy login`)
14
+ - **Cursor Agent** — `agent` (local fuel; set with `drivers fuel cursor local` or machine `fuel local`)
15
+ - **Kiro CLI** — `kiro-cli` (local fuel)
16
+ - **Antigravity** — `agy` (local fuel)
15
17
  - macOS or Linux for `install-service` (Windows: keep a terminal runner open)
16
18
 
17
19
  ## Install / run
@@ -20,26 +22,36 @@ Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connect
20
22
  npx @miraland-labs/conduit-bridge join --url <https://your-conduit> --organization <slug>
21
23
  ```
22
24
 
23
- After approval:
25
+ After approval, seed detects installed CLIs as **offline lanes**. Bring one or more online (subscription failover / concurrent lanes):
26
+
27
+ ```bash
28
+ npx @miraland-labs/conduit-bridge drivers # list lanes
29
+ npx @miraland-labs/conduit-bridge drivers online claude-code
30
+ npx @miraland-labs/conduit-bridge drivers online claude-code codex # both can run at once
31
+ npx @miraland-labs/conduit-bridge drivers offline claude-code # finish in-flight; no new claims
32
+ npx @miraland-labs/conduit-bridge runner --workspace /path/to/repo
33
+ ```
34
+
35
+ Legacy single-driver process override (does not change saved online set):
24
36
 
25
37
  ```bash
26
38
  npx @miraland-labs/conduit-bridge runner --agent claude-code --workspace /path/to/repo
27
- npx @miraland-labs/conduit-bridge runner --agent codex --workspace /path/to/repo
28
- npx @miraland-labs/conduit-bridge runner --agent opencode --workspace /path/to/repo
29
- npx @miraland-labs/conduit-bridge fuel local # required before cursor / kiro / antigravity
30
- npx @miraland-labs/conduit-bridge runner --agent cursor --workspace /path/to/repo
31
- npx @miraland-labs/conduit-bridge runner --agent kiro --workspace /path/to/repo
32
- npx @miraland-labs/conduit-bridge runner --agent antigravity --workspace /path/to/repo
33
39
  ```
34
40
 
35
- Persist (macOS/Linux):
41
+ Persist (macOS/Linux) — one LaunchAgent; honors the drivers online set:
36
42
 
37
43
  ```bash
38
- npx @miraland-labs/conduit-bridge install-service --agent <claude-code|codex|cursor|opencode|kiro|antigravity> --workspace /path/to/repo
44
+ npx @miraland-labs/conduit-bridge install-service --workspace /path/to/repo
45
+ # optional: bring a lane online at install time
46
+ npx @miraland-labs/conduit-bridge install-service --agent claude-code --workspace /path/to/repo
39
47
  ```
40
48
 
49
+ Reinstall after upgrading from Bridge versions before 0.8 if the old unit baked a single `--agent`.
50
+
41
51
  For a Start-created GitHub repo that is not checked out yet, point `--workspace` at the intended path and add `--ensure-checkout <https://github.com/…>` so Bridge clones with the machine’s Git credentials before the first heartbeat (never the control-plane PAT).
42
52
 
53
+ **Capacity:** Connect-approved `lease_capacity` (1–4) is a **shared pool** for all online lanes on that computer. Claude alone may use all 4 slots; Claude + Codex share those 4 — never 4×N.
54
+
43
55
  ## Grant enforcement
44
56
 
45
57
  Every driver enforces the assignment's granted actions with its CLI's native mechanism — never just the prompt:
@@ -11,6 +11,14 @@ const execFileAsync = promisify(execFile);
11
11
  export function attemptWorktreePath(sourceWorkspace, attemptId) {
12
12
  return join(sourceWorkspace, ".conduit", "attempts", attemptId);
13
13
  }
14
+ /**
15
+ * The branch each attempt works on. Named by attempt id, which is globally unique, so concurrent slots
16
+ * (F-07 multi-slot) never collide in the shared ref namespace and a retry never clashes with a prior
17
+ * attempt's branch. The agent commits on this branch — "the current branch" in the prompt is now true.
18
+ */
19
+ export function attemptBranchName(attemptId) {
20
+ return `conduit/attempt/${attemptId}`;
21
+ }
14
22
  export async function ensureConduitExclude(sourceWorkspace) {
15
23
  const excludePath = join(sourceWorkspace, ".git", "info", "exclude");
16
24
  await mkdir(join(sourceWorkspace, ".git", "info"), { recursive: true });
@@ -40,7 +48,11 @@ export async function createAttemptWorktree(input) {
40
48
  await assertSourceWorkspaceClean(input.sourceWorkspace);
41
49
  const path = attemptWorktreePath(input.sourceWorkspace, input.attemptId);
42
50
  await mkdir(join(input.sourceWorkspace, ".conduit", "attempts"), { recursive: true });
43
- await execFileAsync("git", ["-C", input.sourceWorkspace, "worktree", "add", "--detach", path, input.startCommit], { timeout: 60_000, maxBuffer: 2_000_000 });
51
+ await execFileAsync("git",
52
+ // A named attempt branch, not --detach: it collision-proofs concurrent slots and makes the agent's
53
+ // "commit on the current branch" accurate. -b fails if the branch exists, which is the desired
54
+ // guard — createAttemptWorktree only ever runs for a fresh attempt id (resume reuses its worktree).
55
+ ["-C", input.sourceWorkspace, "worktree", "add", "-b", attemptBranchName(input.attemptId), path, input.startCommit], { timeout: 60_000, maxBuffer: 2_000_000 });
44
56
  return path;
45
57
  }
46
58
  export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
package/dist/cli.js CHANGED
@@ -10,6 +10,7 @@ import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, c
10
10
  import { runMcp } from "./mcp.js";
11
11
  import { detectInstalledClients, localFuelOnlyClients, suggestFuelSource } from "./detect.js";
12
12
  import { DRIVERS } from "./driver.js";
13
+ import { driversHeartbeatReport, driverLabel, heartbeatStatusForDrivers, isSupportedDriverId, listDriverLanes, onlineDriverIds, seedDriversFromDetection, seedDriverLanes, setDriverFuel, setDriversOnline, } from "./drivers.js";
13
14
  import { buildWorkspaceBrief } from "./brief.js";
14
15
  import { ensureCheckout } from "./checkout.js";
15
16
  import { pumpExecutionSlots, renewLeases } from "./execution.js";
@@ -187,7 +188,7 @@ async function finishConnection(baseUrl, data, fuelSource) {
187
188
  const detected = await detectInstalledClients();
188
189
  const resolvedFuel = fuelSource ?? suggestFuelSource(detected);
189
190
  const fuelAutoLocal = fuelSource === undefined && resolvedFuel === "local";
190
- const config = {
191
+ let config = {
191
192
  baseUrl,
192
193
  organizationId: String(data.organization_id),
193
194
  machineId: String(data.machine_id),
@@ -199,6 +200,7 @@ async function finishConnection(baseUrl, data, fuelSource) {
199
200
  fuelSource: resolvedFuel,
200
201
  ...(Object.keys(fuel).length ? { fuel } : {}),
201
202
  };
203
+ config = (await seedDriversFromDetection(config)).config;
202
204
  await saveConfig(config);
203
205
  const client = new ConduitClient(config);
204
206
  let heartbeatOk = true;
@@ -211,19 +213,24 @@ async function finishConnection(baseUrl, data, fuelSource) {
211
213
  console.log(`Connected machine: ${config.machineId}`);
212
214
  console.log(heartbeatOk ? "Runner credential and heartbeat: OK" : "Runner credential saved; heartbeat will retry when the runner starts");
213
215
  console.log(`Detected local clients: ${detected.join(", ") || "none"}`);
216
+ const lanes = listDriverLanes(config);
217
+ if (lanes.length) {
218
+ console.log(`Driver lanes (all offline until you bring one online): ${lanes.map((lane) => lane.id).join(", ")}`);
219
+ }
214
220
  console.log(`Confirmed capabilities (matching): ${config.capabilities.join(", ") || "none"}`);
215
221
  console.log(`Allowed actions: ${config.grants.join(", ") || "none"}`);
216
222
  console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit pump"}`
217
223
  + (fuelAutoLocal ? " (auto: only Cursor/Kiro/Antigravity detected)" : ""));
218
224
  const localOnly = localFuelOnlyClients(detected);
219
225
  if (config.fuelSource === "conduit" && localOnly.length) {
220
- console.log(`Note: ${localOnly.join(", ")} cannot use Conduit pump. Before --agent cursor|kiro|antigravity run: ${bridgeUsage("fuel", "local")}`);
226
+ console.log(`Note: ${localOnly.join(", ")} require local fuel per lane (set automatically for cursor/kiro/antigravity).`);
221
227
  }
222
228
  console.log("Heartbeats report capabilities for diagnostics only; matching uses the Connect confirmation. Detected clients never receive grants automatically.");
223
229
  console.log(`MCP setup: {"mcpServers":{"conduit":{"command":"npx","args":["-y","@miraland-labs/conduit-bridge","mcp"]}}}`);
224
- console.log(`Execute work: ${bridgeUsage("runner", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
225
- console.log(`Keep on shift (macOS/Linux): ${bridgeUsage("install-service", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
226
- console.log(`Flip fuel later: ${bridgeUsage("fuel", "local|conduit")} (cursor/kiro/antigravity require local fuel)`);
230
+ console.log(`Bring a lane online: ${bridgeUsage("drivers", "online", AGENT_PLACEHOLDER)}`);
231
+ console.log(`Execute work: ${bridgeUsage("runner", "--workspace", "<repo>")} (or ${bridgeUsage("runner", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")})`);
232
+ console.log(`Keep on shift (macOS/Linux): ${bridgeUsage("install-service", "--workspace", "<repo>")}`);
233
+ console.log(`Flip machine fuel later: ${bridgeUsage("fuel", "local|conduit")} (per-lane override: ${bridgeUsage("drivers", "fuel", "<id>", "local|conduit")})`);
227
234
  console.log("Windows: keep the runner terminal open — install-service is macOS/Linux only.");
228
235
  if (Object.keys(fuel).length) {
229
236
  console.log(`Fleet fueling: ${Object.keys(fuel).length} project key(s) configured for Conduit /v1`);
@@ -246,16 +253,26 @@ function openUrl(url) {
246
253
  }
247
254
  }
248
255
  async function installService() {
249
- await loadConfig();
256
+ let config = await loadConfig();
250
257
  const { values } = parseArgs({ args: process.argv.slice(3), options: {
251
258
  agent: { type: "string" }, workspace: { type: "string" }, interval: { type: "string" }, "agent-timeout-minutes": { type: "string" },
252
259
  "ensure-checkout": { type: "string" },
253
260
  } });
254
- if (!values.agent || !values.workspace) {
255
- throw new Error(`Usage: ${bridgeUsage("install-service", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repository-path>", "[--ensure-checkout <repository-url>]")} (agent + workspace required; heartbeat-only services are not supported)`);
261
+ if (!values.workspace) {
262
+ throw new Error(`Usage: ${bridgeUsage("install-service", "--workspace", "<repository-path>", `[--agent ${AGENT_PLACEHOLDER}]`, "[--ensure-checkout <repository-url>]")} (workspace required; uses online driver lanes)`);
263
+ }
264
+ config = (await seedDriversFromDetection(config)).config;
265
+ if (values.agent) {
266
+ if (!DRIVERS[values.agent]) {
267
+ throw new Error(`Unknown agent driver: ${values.agent}. Available: ${Object.keys(DRIVERS).join(", ")}`);
268
+ }
269
+ config = seedDriverLanes(config, [values.agent]).config;
270
+ config = setDriversOnline(config, [values.agent], true);
271
+ await saveConfig(config);
272
+ console.log(`Brought ${values.agent} online for this computer (shared capacity ${config.leaseCapacity}).`);
256
273
  }
257
- if (!DRIVERS[values.agent]) {
258
- throw new Error(`Unknown agent driver: ${values.agent}. Available: ${Object.keys(DRIVERS).join(", ")}`);
274
+ else if (!onlineDriverIds(config).length) {
275
+ throw new Error(`No online driver lanes. Run \`${bridgeUsage("drivers", "online", AGENT_PLACEHOLDER)}\` first, or pass --agent to bring one online.`);
259
276
  }
260
277
  const workspace = resolve(values.workspace);
261
278
  if (values["ensure-checkout"]) {
@@ -264,14 +281,15 @@ async function installService() {
264
281
  ? `Cloned ${values["ensure-checkout"]} into ${workspace}`
265
282
  : `Workspace already matches ${values["ensure-checkout"]}`);
266
283
  }
284
+ // Supervisor service: no baked --agent; honors config drivers online set.
267
285
  const result = await installRunnerService({
268
- agent: values.agent,
269
286
  workspace,
270
287
  interval: values.interval,
271
288
  agentTimeoutMinutes: values["agent-timeout-minutes"],
272
289
  ensureCheckout: values["ensure-checkout"],
273
290
  });
274
291
  console.log(`Installed Conduit runner service (${result.platform}): ${result.path}`);
292
+ console.log(`Online lanes: ${onlineDriverIds(config).join(", ")}. Toggle with \`${bridgeUsage("drivers", "online|offline", "…")}\`.`);
275
293
  console.log("The runner keeps executing after this terminal closes. Laptop sleep still pauses work.");
276
294
  }
277
295
  async function uninstallService() {
@@ -288,27 +306,83 @@ async function fuelCommand() {
288
306
  await saveConfig(config);
289
307
  const client = new ConduitClient(config);
290
308
  await heartbeat(client, config, null);
291
- console.log(`Fuel source set to ${mode === "local" ? "local subscription" : "Conduit pump"} and reported on heartbeat.`);
309
+ console.log(`Machine fuel source set to ${mode === "local" ? "local subscription" : "Conduit pump"} and reported on heartbeat.`);
310
+ }
311
+ async function driversCommand() {
312
+ const sub = process.argv[3];
313
+ let config = await loadConfig();
314
+ config = (await seedDriversFromDetection(config)).config;
315
+ if (!sub || sub === "list") {
316
+ await saveConfig(config);
317
+ const lanes = listDriverLanes(config);
318
+ if (!lanes.length) {
319
+ console.log("No driver lanes registered. Install a supported agent CLI, then re-run this command.");
320
+ return;
321
+ }
322
+ console.log(`Computer ${config.machineId} — shared capacity ${config.leaseCapacity} (sum across online lanes, not per IDE)`);
323
+ for (const lane of lanes) {
324
+ console.log(` ${lane.id.padEnd(14)} ${lane.state.padEnd(8)} fuel=${lane.fuel} (${lane.label})`);
325
+ }
326
+ const online = onlineDriverIds(config);
327
+ console.log(online.length
328
+ ? `Online: ${online.join(", ")}. Toggle: ${bridgeUsage("drivers", "online|offline", "<id…>")}`
329
+ : `All offline — no new claims. Bring one online: ${bridgeUsage("drivers", "online", AGENT_PLACEHOLDER)}`);
330
+ return;
331
+ }
332
+ if (sub === "online" || sub === "offline") {
333
+ const ids = process.argv.slice(4).map((id) => id.trim()).filter(Boolean);
334
+ if (!ids.length)
335
+ throw new Error(`Usage: ${bridgeUsage("drivers", sub, AGENT_PLACEHOLDER, "[…]")}`);
336
+ for (const id of ids) {
337
+ if (!isSupportedDriverId(id))
338
+ throw new Error(`Unknown driver: ${id}. Available: ${Object.keys(DRIVERS).join(", ")}`);
339
+ }
340
+ config = seedDriverLanes(config, ids).config;
341
+ config = setDriversOnline(config, ids, sub === "online");
342
+ await saveConfig(config);
343
+ const client = new ConduitClient(config);
344
+ await heartbeat(client, config, null).catch(() => undefined);
345
+ console.log(`${sub === "online" ? "Online" : "Offline"}: ${ids.join(", ")}`);
346
+ console.log(`Now online: ${onlineDriverIds(config).join(", ") || "(none)"}`);
347
+ return;
348
+ }
349
+ if (sub === "fuel") {
350
+ const id = process.argv[4]?.trim();
351
+ const mode = process.argv[5];
352
+ if (!id || (mode !== "local" && mode !== "conduit")) {
353
+ throw new Error(`Usage: ${bridgeUsage("drivers", "fuel", "<driver-id>", "local|conduit")}`);
354
+ }
355
+ config = seedDriverLanes(config, [id]).config;
356
+ config = setDriverFuel(config, id, mode);
357
+ await saveConfig(config);
358
+ console.log(`${driverLabel(id)} lane fuel set to ${mode}`);
359
+ return;
360
+ }
361
+ throw new Error(`Usage: ${bridgeUsage("drivers", "[list|online|offline|fuel]", "…")}`);
292
362
  }
293
363
  async function runner() {
294
364
  const { values } = parseArgs({ args: process.argv.slice(3), options: {
295
365
  agent: { type: "string" }, workspace: { type: "string" }, interval: { type: "string" }, once: { type: "boolean" },
296
366
  "agent-timeout-minutes": { type: "string" }, fuel: { type: "string" }, "ensure-checkout": { type: "string" },
297
367
  } });
298
- const config = await loadConfig();
368
+ let config = await loadConfig();
299
369
  const fuelOverride = parseFuelSource(values.fuel);
300
370
  if (fuelOverride) {
301
371
  config.fuelSource = fuelOverride;
302
372
  await saveConfig(config);
303
373
  }
374
+ config = (await seedDriversFromDetection(config)).config;
375
+ await saveConfig(config);
304
376
  const client = new ConduitClient(config);
305
- let driver = null;
377
+ let processDriver = null;
378
+ let processOnlineIds = null;
306
379
  if (values.agent) {
307
- driver = DRIVERS[values.agent] ?? null;
308
- if (!driver)
380
+ processDriver = DRIVERS[values.agent] ?? null;
381
+ if (!processDriver)
309
382
  throw new Error(`Unknown agent driver: ${values.agent}. Available: ${Object.keys(DRIVERS).join(", ")}`);
310
383
  if (!values.workspace)
311
384
  throw new Error("--workspace <repository-path> is required with --agent");
385
+ processOnlineIds = [values.agent];
312
386
  }
313
387
  if (values["ensure-checkout"] && !values.workspace) {
314
388
  throw new Error("--ensure-checkout requires --workspace <repository-path>");
@@ -323,22 +397,31 @@ async function runner() {
323
397
  const brief = workspace ? await buildWorkspaceBrief(workspace) : null;
324
398
  const intervalMs = values.interval ? Math.max(5_000, Number(values.interval)) : 15_000;
325
399
  const timeoutMs = values["agent-timeout-minutes"] ? Number(values["agent-timeout-minutes"]) * 60_000 : undefined;
326
- const fuelLabel = config.fuelSource === "local" ? "local subscription" : "Conduit pump";
327
- if (!driver || !workspace) {
328
- console.warn(`WARNING: heartbeat only — no work will execute. Use: ${bridgeUsage("runner", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
400
+ const canExecute = Boolean(workspace) && (Boolean(processDriver) || onlineDriverIds(config).length > 0);
401
+ if (!workspace) {
402
+ console.warn(`WARNING: heartbeat only — pass --workspace <repo>. Lanes: ${bridgeUsage("drivers", "online", AGENT_PLACEHOLDER)}`);
403
+ }
404
+ else if (!canExecute) {
405
+ console.warn(`WARNING: no online driver lanes — ${bridgeUsage("drivers", "online", AGENT_PLACEHOLDER)} (or pass --agent)`);
329
406
  }
330
- console.log(`Conduit runner (bridge v${bridgeVersion()}) connected to ${config.baseUrl}${driver ? ` — executing via ${driver.name} in ${workspace}` : " — heartbeat only (no --agent)"} (fuel: ${fuelLabel}; slots: ${config.leaseCapacity})`);
407
+ const onlineLabel = (processOnlineIds?.join(", ") || onlineDriverIds(config).join(", ")) || "(none)";
408
+ console.log(`Conduit runner (bridge v${bridgeVersion()}) connected to ${config.baseUrl}${workspace ? ` — workspace ${workspace}` : " — heartbeat only"} (lanes: ${onlineLabel}; machine fuel: ${config.fuelSource === "local" ? "local" : "conduit"}; slots: ${config.leaseCapacity})`);
331
409
  const running = new Map();
332
410
  for (;;) {
333
411
  let progressed = false;
334
412
  try {
413
+ // Reload drivers online/offline each cycle so CLI toggles apply without restart.
414
+ const latest = await loadConfig();
415
+ config.drivers = latest.drivers;
416
+ config.fuelSource = latest.fuelSource;
417
+ config.leaseCapacity = latest.leaseCapacity;
335
418
  await heartbeat(client, config, workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null);
336
419
  await renewLeases(client, config);
337
- if (driver && workspace) {
338
- progressed = await pumpExecutionSlots(client, config, driver, workspace, brief, timeoutMs, {
420
+ if (workspace && (processDriver || onlineDriverIds(config).length)) {
421
+ progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
339
422
  heartbeat: async () => heartbeat(client, config, await buildWorkspaceBrief(workspace).catch(() => brief)),
340
423
  heartbeatIntervalMs: intervalMs,
341
- }, running);
424
+ }, running, { driver: processDriver, processOnlineIds });
342
425
  }
343
426
  }
344
427
  catch (error) {
@@ -353,11 +436,14 @@ async function runner() {
353
436
  }
354
437
  }
355
438
  async function heartbeat(client, config, brief) {
439
+ const online = onlineDriverIds(config);
440
+ const status = heartbeatStatusForDrivers(online);
356
441
  await client.request("/runner/v1/heartbeat", { method: "POST", body: JSON.stringify({
357
- status: "online",
442
+ status,
358
443
  capabilities: config.capabilities,
359
444
  lease_capacity: config.leaseCapacity,
360
445
  fuel_source: config.fuelSource === "local" ? "local" : "conduit",
446
+ drivers: driversHeartbeatReport(config, config.activeAttempts),
361
447
  ...(brief ? { workspace_brief: brief } : {}),
362
448
  }) });
363
449
  }
@@ -368,6 +454,8 @@ try {
368
454
  await join();
369
455
  else if (command === "fuel")
370
456
  await fuelCommand();
457
+ else if (command === "drivers")
458
+ await driversCommand();
371
459
  else if (command === "mcp")
372
460
  await runMcp();
373
461
  else if (command === "runner")
@@ -377,7 +465,7 @@ try {
377
465
  else if (command === "uninstall-service")
378
466
  await uninstallService();
379
467
  else
380
- throw new Error(`Usage: ${BRIDGE_NPX} <join|connect|fuel|mcp|runner|install-service|uninstall-service>`);
468
+ throw new Error(`Usage: ${BRIDGE_NPX} <join|connect|fuel|drivers|mcp|runner|install-service|uninstall-service>`);
381
469
  }
382
470
  catch (error) {
383
471
  console.error(error instanceof Error ? redactSecrets(error.message) : "Conduit command failed");
package/dist/config.js CHANGED
@@ -50,6 +50,17 @@ export async function loadConfigIfPresent() {
50
50
  config.fuelSource = "conduit";
51
51
  // Clamp to what concurrent slot workers can run (1..BRIDGE_MAX_LEASE_CAPACITY).
52
52
  config.leaseCapacity = clampLeaseCapacity(config.leaseCapacity ?? BRIDGE_LEASE_CAPACITY);
53
+ if (config.drivers && typeof config.drivers === "object") {
54
+ const cleaned = {};
55
+ for (const [id, lane] of Object.entries(config.drivers)) {
56
+ if (!lane || typeof lane !== "object")
57
+ continue;
58
+ const state = lane.state === "online" ? "online" : "offline";
59
+ const fuel = lane.fuel === "local" || lane.fuel === "conduit" ? lane.fuel : undefined;
60
+ cleaned[id] = fuel ? { state, fuel } : { state };
61
+ }
62
+ config.drivers = cleaned;
63
+ }
53
64
  return config;
54
65
  }
55
66
  export async function saveConfig(config) {
package/dist/driver.js CHANGED
@@ -130,7 +130,7 @@ export function buildAssignmentPrompt(context) {
130
130
  `- These are the ONLY shell commands you may run: ${runnableCommands.join("; ")}. Anything else is rejected — do not try variations, wrappers, or \`echo\`. Run the relevant ones and report their real output.`,
131
131
  ]
132
132
  : ["- You have no shell authority for this assignment. Do not attempt shell commands; verify by reading files and report what you could not verify as unknown."]), ...(mustCommit
133
- ? ["- Commit your repository changes locally (git add + git commit on the current branch) and report the resulting sha as head_commit. A delivery that changed files without a commit is rejected. Local commits are required; pushing is a separate authority."]
133
+ ? ["- Your working branch is already checked out. Commit your repository changes on the current branch (git add + git commit) do not create a new branch and report the resulting sha as head_commit. A delivery that changed files without a commit is rejected. Local commits are required; pushing is a separate authority."]
134
134
  : ["- You have no repository write authority for this assignment. Do not create, modify, or commit any file; a delivery reporting repository changes will be rejected. Record all findings, verification output, and conclusions in your final report instead."]), "- Never merge, deploy, push to protected branches, or touch production.", `- Hard-denied commands (all drivers): ${deniedCommands.join("; ")}.`, "- Do not invent evidence. Report unknown when you could not verify a criterion.",
135
135
  // A met claim with nothing backing it is rejected server-side ("Met acceptance criteria require
136
136
  // mapped evidence"). Observed live: six criteria marked met, evidence mapped to four, whole
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Local driver lanes on one Bridge computer: operator online/offline set + shared slot pool.
3
+ * Matching stays computer-scoped; Bridge assigns each new claim to a free online driver.
4
+ */
5
+ import { detectInstalledClients } from "./detect.js";
6
+ import { DRIVERS, SUPPORTED_AGENTS } from "./driver.js";
7
+ /** Display labels from detectInstalledClients → Bridge driver ids. */
8
+ const LABEL_TO_DRIVER = {
9
+ "Claude Code": "claude-code",
10
+ "Codex CLI": "codex",
11
+ OpenCode: "opencode",
12
+ "Cursor Agent": "cursor",
13
+ Cursor: "cursor",
14
+ "Kiro CLI": "kiro",
15
+ Antigravity: "antigravity",
16
+ };
17
+ const LOCAL_FUEL_ONLY_DRIVERS = new Set(["cursor", "kiro", "antigravity"]);
18
+ export function isSupportedDriverId(id) {
19
+ return Object.prototype.hasOwnProperty.call(DRIVERS, id);
20
+ }
21
+ export function driverLabel(id) {
22
+ return SUPPORTED_AGENTS.find((agent) => agent.id === id)?.label ?? id;
23
+ }
24
+ export function localFuelOnlyDriver(id) {
25
+ return LOCAL_FUEL_ONLY_DRIVERS.has(id);
26
+ }
27
+ /** Map PATH probe labels to unique driver ids. */
28
+ export function driverIdsFromDetectedLabels(labels) {
29
+ const ids = new Set();
30
+ for (const label of labels) {
31
+ const id = LABEL_TO_DRIVER[label];
32
+ if (id)
33
+ ids.add(id);
34
+ }
35
+ return [...ids];
36
+ }
37
+ export function normalizeDrivers(raw) {
38
+ const out = {};
39
+ if (!raw || typeof raw !== "object")
40
+ return out;
41
+ for (const [id, lane] of Object.entries(raw)) {
42
+ if (!isSupportedDriverId(id) || !lane || typeof lane !== "object")
43
+ continue;
44
+ const state = lane.state === "online" ? "online" : "offline";
45
+ const fuel = lane.fuel === "local" || lane.fuel === "conduit" ? lane.fuel : undefined;
46
+ out[id] = fuel ? { state, fuel } : { state };
47
+ }
48
+ return out;
49
+ }
50
+ /**
51
+ * Ensure detected (or listed) drivers exist in config. New lanes default offline (fail-closed).
52
+ * Does not flip existing online/offline state.
53
+ */
54
+ export function seedDriverLanes(config, driverIds) {
55
+ const drivers = normalizeDrivers(config.drivers);
56
+ const added = [];
57
+ for (const id of driverIds) {
58
+ if (!isSupportedDriverId(id))
59
+ continue;
60
+ if (drivers[id])
61
+ continue;
62
+ drivers[id] = localFuelOnlyDriver(id) ? { state: "offline", fuel: "local" } : { state: "offline" };
63
+ added.push(id);
64
+ }
65
+ return { config: { ...config, drivers }, added };
66
+ }
67
+ export async function seedDriversFromDetection(config) {
68
+ const labels = await detectInstalledClients();
69
+ return seedDriverLanes(config, driverIdsFromDetectedLabels(labels));
70
+ }
71
+ export function listDriverLanes(config) {
72
+ const drivers = normalizeDrivers(config.drivers);
73
+ return SUPPORTED_AGENTS
74
+ .filter((agent) => drivers[agent.id])
75
+ .map((agent) => {
76
+ const lane = drivers[agent.id];
77
+ return {
78
+ id: agent.id,
79
+ label: agent.label,
80
+ state: lane.state,
81
+ fuel: resolveDriverFuel(config, agent.id),
82
+ };
83
+ });
84
+ }
85
+ export function onlineDriverIds(config) {
86
+ const drivers = normalizeDrivers(config.drivers);
87
+ return SUPPORTED_AGENTS.map((agent) => agent.id).filter((id) => drivers[id]?.state === "online");
88
+ }
89
+ export function setDriversOnline(config, ids, online) {
90
+ const drivers = normalizeDrivers(config.drivers);
91
+ for (const id of ids) {
92
+ if (!isSupportedDriverId(id))
93
+ throw new Error(`Unknown driver: ${id}. Available: ${Object.keys(DRIVERS).join(", ")}`);
94
+ const prev = drivers[id] ?? (localFuelOnlyDriver(id) ? { state: "offline", fuel: "local" } : { state: "offline" });
95
+ drivers[id] = { ...prev, state: online ? "online" : "offline" };
96
+ }
97
+ return { ...config, drivers };
98
+ }
99
+ /** Fuel for a lane: per-driver override → machine default; local-only drivers always local. */
100
+ export function resolveDriverFuel(config, driverId) {
101
+ if (localFuelOnlyDriver(driverId))
102
+ return "local";
103
+ const lane = normalizeDrivers(config.drivers)[driverId];
104
+ if (lane?.fuel === "local" || lane?.fuel === "conduit")
105
+ return lane.fuel;
106
+ return config.fuelSource === "local" ? "local" : "conduit";
107
+ }
108
+ export function setDriverFuel(config, driverId, fuel) {
109
+ if (!isSupportedDriverId(driverId))
110
+ throw new Error(`Unknown driver: ${driverId}`);
111
+ if (localFuelOnlyDriver(driverId) && fuel === "conduit") {
112
+ throw new Error(`${driverLabel(driverId)} requires local fuel`);
113
+ }
114
+ const drivers = normalizeDrivers(config.drivers);
115
+ const prev = drivers[driverId] ?? { state: "offline" };
116
+ drivers[driverId] = { ...prev, fuel };
117
+ return { ...config, drivers };
118
+ }
119
+ /** Heartbeat status: standby when no online lanes (supervisor mode). */
120
+ export function heartbeatStatusForDrivers(onlineIds) {
121
+ return onlineIds.length > 0 ? "online" : "standby";
122
+ }
123
+ /**
124
+ * Effective capacity advertised on heartbeat: full approved pool when any lane is online;
125
+ * 0 (via standby + no claims) when none are. Caller still sends lease_capacity = approved when online.
126
+ */
127
+ export function advertiseLeaseCapacity(approved, onlineIds) {
128
+ if (onlineIds.length === 0)
129
+ return 1; // schema/heartbeat min; status standby prevents matching
130
+ return approved;
131
+ }
132
+ export function driversHeartbeatReport(config, activeAttempts) {
133
+ const busyByDriver = new Map();
134
+ for (const active of Object.values(activeAttempts)) {
135
+ if (!active.driverId)
136
+ continue;
137
+ busyByDriver.set(active.driverId, (busyByDriver.get(active.driverId) ?? 0) + 1);
138
+ }
139
+ return listDriverLanes(config).map((lane) => ({
140
+ id: lane.id,
141
+ state: lane.state,
142
+ busy: (busyByDriver.get(lane.id) ?? 0) > 0,
143
+ }));
144
+ }
145
+ /**
146
+ * Pick an online driver for a new claim: least loaded among online (shared machine pool).
147
+ * `processOnlineIds` overrides config when runner was started with --agent.
148
+ */
149
+ export function pickDriverForClaim(config, processOnlineIds) {
150
+ const online = (processOnlineIds?.length ? processOnlineIds : onlineDriverIds(config))
151
+ .filter((id) => isSupportedDriverId(id));
152
+ if (!online.length)
153
+ return null;
154
+ const load = new Map();
155
+ for (const id of online)
156
+ load.set(id, 0);
157
+ for (const active of Object.values(config.activeAttempts)) {
158
+ if (active.driverId && load.has(active.driverId)) {
159
+ load.set(active.driverId, (load.get(active.driverId) ?? 0) + 1);
160
+ }
161
+ }
162
+ let best = online[0];
163
+ let bestLoad = load.get(best) ?? 0;
164
+ for (const id of online.slice(1)) {
165
+ const n = load.get(id) ?? 0;
166
+ if (n < bestLoad) {
167
+ best = id;
168
+ bestLoad = n;
169
+ }
170
+ }
171
+ return best;
172
+ }
package/dist/execution.js CHANGED
@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { ConduitRequestError } from "./client.js";
4
4
  import { redactSecrets } from "./config.js";
5
- import { agentReportTemplate, buildAssignmentPrompt, evidenceKinds, parseAgentReport, pickModelCandidate, tierForRisk } from "./driver.js";
5
+ import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, parseAgentReport, pickModelCandidate, tierForRisk } from "./driver.js";
6
+ import { pickDriverForClaim, resolveDriverFuel } from "./drivers.js";
6
7
  import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
7
8
  import { buildWorkspaceBrief, isBaseCommitAncestor, normalizeRepositoryUrl } from "./brief.js";
8
9
  const assignmentSchema = z.object({
@@ -66,17 +67,27 @@ export async function renewLeases(client, config) {
66
67
  }
67
68
  }
68
69
  }
70
+ function resolveAttemptDriver(config, active, fallback) {
71
+ if (active.driverId && DRIVERS[active.driverId])
72
+ return DRIVERS[active.driverId];
73
+ return fallback ?? null;
74
+ }
69
75
  export async function recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision, taskId) {
70
76
  const active = taskId ? config.activeAttempts[taskId] : Object.values(config.activeAttempts)[0];
71
77
  if (!active)
72
78
  return false;
79
+ const laneDriver = resolveAttemptDriver(config, active, driver);
80
+ if (!laneDriver) {
81
+ console.error(`No driver for attempt ${active.attemptId} (driverId=${active.driverId ?? "unset"})`);
82
+ return false;
83
+ }
73
84
  if (active.phase === "agent_running") {
74
85
  const worktree = active.worktreePath ?? attemptWorktreePath(workspace, active.attemptId);
75
86
  const sessionId = config.sessions?.[active.taskId]?.trim() || "";
76
87
  // F-07 safe resume: only when session + worktree identity are both proven. Otherwise interrupt.
77
88
  if (sessionId && await proveResumeWorktree(workspace, active.attemptId, worktree)) {
78
89
  console.log(`Resuming interrupted attempt ${active.attemptId} with proven worktree and session.`);
79
- await runClaimedAssignment(client, config, driver, workspace, brief, active.taskId, timeoutMs, supervision, {
90
+ await runClaimedAssignment(client, config, laneDriver, workspace, brief, active.taskId, timeoutMs, supervision, {
80
91
  existingWorktree: worktree,
81
92
  forceResumeSessionId: sessionId,
82
93
  });
@@ -98,20 +109,32 @@ export async function recoverActiveAttempt(client, config, driver, workspace, br
98
109
  await flushTerminal(client, active.taskId);
99
110
  return true;
100
111
  }
101
- await runClaimedAssignment(client, config, driver, workspace, brief, active.taskId, timeoutMs, supervision);
112
+ await runClaimedAssignment(client, config, laneDriver, workspace, brief, active.taskId, timeoutMs, supervision);
102
113
  return true;
103
114
  }
104
115
  /**
105
- * F-07 multi-slot: keep up to leaseCapacity concurrent agent runs (each with its own worktree).
116
+ * F-07 multi-slot + multi-driver supervisor: keep up to leaseCapacity concurrent agent runs
117
+ * (each with its own worktree). Shared pool across online lanes — not 4 per IDE.
106
118
  * `running` is owned by the runner loop and must outlive a single pump call.
107
119
  */
108
- export async function pumpExecutionSlots(client, config, driver, workspace, brief, timeoutMs, supervision, running) {
120
+ export async function pumpExecutionSlots(client, config, workspace, brief, timeoutMs, supervision, running, options = {}) {
121
+ const fallbackDriver = options.driver ?? null;
122
+ const processOnlineIds = options.processOnlineIds
123
+ ?? (fallbackDriver
124
+ ? [Object.entries(DRIVERS).find(([, d]) => d === fallbackDriver)?.[0]].filter((id) => Boolean(id))
125
+ : null);
109
126
  let progressed = running.size > 0;
110
127
  for (const id of Object.keys(config.activeAttempts)) {
111
128
  if (running.has(id))
112
129
  continue;
130
+ const active = config.activeAttempts[id];
131
+ const laneDriver = resolveAttemptDriver(config, active, fallbackDriver);
132
+ if (!laneDriver) {
133
+ console.error(`Slot recovery skipped for ${id}: no driver lane`);
134
+ continue;
135
+ }
113
136
  progressed = true;
114
- const slot = recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision, id)
137
+ const slot = recoverActiveAttempt(client, config, laneDriver, workspace, brief, timeoutMs, supervision, id)
115
138
  .catch((error) => {
116
139
  console.error(`Slot recovery failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
117
140
  })
@@ -120,11 +143,28 @@ export async function pumpExecutionSlots(client, config, driver, workspace, brie
120
143
  running.set(id, slot);
121
144
  }
122
145
  while (running.size < config.leaseCapacity) {
146
+ let laneDriver = null;
147
+ let driverId = null;
148
+ if (fallbackDriver) {
149
+ // Legacy `--agent` / tests: one fixed driver for every new claim.
150
+ laneDriver = fallbackDriver;
151
+ driverId = processOnlineIds?.[0]
152
+ ?? Object.entries(DRIVERS).find(([, d]) => d === fallbackDriver)?.[0]
153
+ ?? fallbackDriver.name;
154
+ }
155
+ else {
156
+ driverId = pickDriverForClaim(config, processOnlineIds);
157
+ laneDriver = driverId ? DRIVERS[driverId] ?? null : null;
158
+ }
159
+ if (!laneDriver || !driverId)
160
+ break;
123
161
  const taskId = await claimNextAssignment(client, config, workspace, brief);
124
162
  if (!taskId)
125
163
  break;
164
+ await client.updateAttempt(taskId, { driverId });
126
165
  progressed = true;
127
- const slot = runClaimedAssignment(client, config, driver, workspace, brief, taskId, timeoutMs, supervision)
166
+ console.log(`Executing ${taskId} via ${laneDriver.name}`);
167
+ const slot = runClaimedAssignment(client, config, laneDriver, workspace, brief, taskId, timeoutMs, supervision)
128
168
  .catch((error) => {
129
169
  console.error(`Slot execution failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
130
170
  })
@@ -241,7 +281,12 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
241
281
  : `Starting ${driver.name} for this assignment${reworkFeedback ? " with review feedback" : ""}.`,
242
282
  idempotency_key: resuming ? `bridge:progress:${active.attemptId}:resume` : `bridge:progress:${active.attemptId}:start`,
243
283
  });
244
- const fuelSource = config.fuelSource === "local" ? "local" : "conduit";
284
+ const driverId = active.driverId
285
+ ?? Object.entries(DRIVERS).find(([, d]) => d === driver)?.[0]
286
+ ?? "unknown";
287
+ if (!active.driverId && driverId !== "unknown")
288
+ await client.updateAttempt(taskId, { driverId });
289
+ const fuelSource = resolveDriverFuel(config, driverId);
245
290
  let fuel;
246
291
  if (fuelSource === "conduit") {
247
292
  const gatewayKey = await client.ensureFuel(task.project_id);
@@ -261,7 +306,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
261
306
  : `Started ${driver.name}${selection.model ? ` on ${selection.model}` : " on its CLI default"}.`,
262
307
  idempotency_key: resuming ? `bridge:progress:${active.attemptId}:agent-resume` : `bridge:progress:${active.attemptId}:agent-start`,
263
308
  });
264
- await client.updateAttempt(taskId, { phase: "agent_running", fuelMode: fuelSource, worktreePath: attemptWorkspace });
309
+ await client.updateAttempt(taskId, { phase: "agent_running", fuelMode: fuelSource, worktreePath: attemptWorkspace, driverId });
265
310
  const renewTimer = setInterval(() => { void renewLeases(client, config).catch((error) => console.error(`Lease renewal failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`)); }, 60_000);
266
311
  let heartbeatRunning = false;
267
312
  const heartbeatTimer = supervision ? setInterval(() => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.7.0",
4
- "description": "Conduit Bridge CLI — join, connect, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
3
+ "version": "0.8.0",
4
+ "description": "Conduit Bridge CLI — join, connect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "conduit": "dist/cli.js"