@miraland-labs/conduit-bridge 0.6.1 → 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:
@@ -2,14 +2,23 @@
2
2
  * F-07 slice 1: one Git worktree per attempt. The install-service source checkout is never the
3
3
  * agent cwd; retries must not inherit dirty files from a failed run.
4
4
  */
5
- import { appendFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
- import { join } from "node:path";
5
+ import { access, appendFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
+ import { constants } from "node:fs";
7
+ import { join, resolve } from "node:path";
7
8
  import { execFile } from "node:child_process";
8
9
  import { promisify } from "node:util";
9
10
  const execFileAsync = promisify(execFile);
10
11
  export function attemptWorktreePath(sourceWorkspace, attemptId) {
11
12
  return join(sourceWorkspace, ".conduit", "attempts", attemptId);
12
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
+ }
13
22
  export async function ensureConduitExclude(sourceWorkspace) {
14
23
  const excludePath = join(sourceWorkspace, ".git", "info", "exclude");
15
24
  await mkdir(join(sourceWorkspace, ".git", "info"), { recursive: true });
@@ -39,7 +48,11 @@ export async function createAttemptWorktree(input) {
39
48
  await assertSourceWorkspaceClean(input.sourceWorkspace);
40
49
  const path = attemptWorktreePath(input.sourceWorkspace, input.attemptId);
41
50
  await mkdir(join(input.sourceWorkspace, ".conduit", "attempts"), { recursive: true });
42
- 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 });
43
56
  return path;
44
57
  }
45
58
  export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
@@ -54,6 +67,28 @@ export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
54
67
  }).catch(() => undefined);
55
68
  }
56
69
  }
70
+ /**
71
+ * F-07 safe resume: prove the interrupted attempt still owns this worktree path.
72
+ * Does not check session identity — caller requires config.sessions[taskId] separately.
73
+ */
74
+ export async function proveResumeWorktree(sourceWorkspace, attemptId, worktreePath) {
75
+ if (!worktreePath?.trim())
76
+ return false;
77
+ const expected = attemptWorktreePath(sourceWorkspace, attemptId);
78
+ if (resolve(worktreePath) !== resolve(expected))
79
+ return false;
80
+ try {
81
+ await access(worktreePath, constants.F_OK);
82
+ const { stdout } = await execFileAsync("git", ["-C", worktreePath, "rev-parse", "HEAD"], {
83
+ timeout: 15_000,
84
+ maxBuffer: 1_000_000,
85
+ });
86
+ return /^[0-9a-f]{40,64}$/i.test(stdout.trim());
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
57
92
  /** Hold a crashed attempt tree for local diagnosis; never reused as agent cwd. */
58
93
  export async function quarantineAttemptWorktree(sourceWorkspace, worktreePath, attemptId) {
59
94
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
package/dist/cli.js CHANGED
@@ -6,13 +6,14 @@ import { spawn } from "node:child_process";
6
6
  import { readFileSync } from "node:fs";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { ConduitClient } from "./client.js";
9
- import { BRIDGE_LEASE_CAPACITY, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, savePendingConnection, suggestMachineName, } from "./config.js";
9
+ import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, savePendingConnection, suggestMachineName, } from "./config.js";
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
- import { executeNextAssignment, renewLeases } from "./execution.js";
16
+ import { pumpExecutionSlots, renewLeases } from "./execution.js";
16
17
  import { installRunnerService, uninstallRunnerService } from "./service.js";
17
18
  const [command] = process.argv.slice(2);
18
19
  /** Read our own package version so every runner start logs exactly which build is live. */
@@ -91,10 +92,11 @@ async function join() {
91
92
  const operatorName = values.operator?.trim() || userInfo().username;
92
93
  const machineName = values.machine?.trim() || suggestMachineName(hostname(), installationId);
93
94
  const capabilities = values.capability?.map((item) => item.trim()).filter(Boolean) ?? ["implement"];
94
- if (values.capacity && Number(values.capacity) !== BRIDGE_LEASE_CAPACITY) {
95
- throw new Error("Conduit Bridge currently runs one assignment at a time; --capacity must be 1");
95
+ const requestedCapacity = values.capacity ? Number(values.capacity) : BRIDGE_LEASE_CAPACITY;
96
+ if (!Number.isInteger(requestedCapacity) || requestedCapacity < 1 || requestedCapacity > BRIDGE_MAX_LEASE_CAPACITY) {
97
+ throw new Error(`--capacity must be an integer from 1 to ${BRIDGE_MAX_LEASE_CAPACITY} (concurrent attempt slots)`);
96
98
  }
97
- const leaseCapacity = BRIDGE_LEASE_CAPACITY;
99
+ const leaseCapacity = clampLeaseCapacity(requestedCapacity);
98
100
  console.log("\nRequesting a Conduit connection:");
99
101
  console.log(`Operator: ${operatorName}`);
100
102
  console.log(`Computer: ${machineName}`);
@@ -186,18 +188,19 @@ async function finishConnection(baseUrl, data, fuelSource) {
186
188
  const detected = await detectInstalledClients();
187
189
  const resolvedFuel = fuelSource ?? suggestFuelSource(detected);
188
190
  const fuelAutoLocal = fuelSource === undefined && resolvedFuel === "local";
189
- const config = {
191
+ let config = {
190
192
  baseUrl,
191
193
  organizationId: String(data.organization_id),
192
194
  machineId: String(data.machine_id),
193
195
  runnerKey: String(data.runner_secret),
194
196
  capabilities: data.capabilities,
195
197
  grants: data.grants,
196
- leaseCapacity: Number(data.lease_capacity),
198
+ leaseCapacity: clampLeaseCapacity(data.lease_capacity),
197
199
  activeAttempts: {},
198
200
  fuelSource: resolvedFuel,
199
201
  ...(Object.keys(fuel).length ? { fuel } : {}),
200
202
  };
203
+ config = (await seedDriversFromDetection(config)).config;
201
204
  await saveConfig(config);
202
205
  const client = new ConduitClient(config);
203
206
  let heartbeatOk = true;
@@ -210,19 +213,24 @@ async function finishConnection(baseUrl, data, fuelSource) {
210
213
  console.log(`Connected machine: ${config.machineId}`);
211
214
  console.log(heartbeatOk ? "Runner credential and heartbeat: OK" : "Runner credential saved; heartbeat will retry when the runner starts");
212
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
+ }
213
220
  console.log(`Confirmed capabilities (matching): ${config.capabilities.join(", ") || "none"}`);
214
221
  console.log(`Allowed actions: ${config.grants.join(", ") || "none"}`);
215
222
  console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit pump"}`
216
223
  + (fuelAutoLocal ? " (auto: only Cursor/Kiro/Antigravity detected)" : ""));
217
224
  const localOnly = localFuelOnlyClients(detected);
218
225
  if (config.fuelSource === "conduit" && localOnly.length) {
219
- 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).`);
220
227
  }
221
228
  console.log("Heartbeats report capabilities for diagnostics only; matching uses the Connect confirmation. Detected clients never receive grants automatically.");
222
229
  console.log(`MCP setup: {"mcpServers":{"conduit":{"command":"npx","args":["-y","@miraland-labs/conduit-bridge","mcp"]}}}`);
223
- console.log(`Execute work: ${bridgeUsage("runner", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
224
- console.log(`Keep on shift (macOS/Linux): ${bridgeUsage("install-service", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
225
- 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")})`);
226
234
  console.log("Windows: keep the runner terminal open — install-service is macOS/Linux only.");
227
235
  if (Object.keys(fuel).length) {
228
236
  console.log(`Fleet fueling: ${Object.keys(fuel).length} project key(s) configured for Conduit /v1`);
@@ -245,16 +253,26 @@ function openUrl(url) {
245
253
  }
246
254
  }
247
255
  async function installService() {
248
- await loadConfig();
256
+ let config = await loadConfig();
249
257
  const { values } = parseArgs({ args: process.argv.slice(3), options: {
250
258
  agent: { type: "string" }, workspace: { type: "string" }, interval: { type: "string" }, "agent-timeout-minutes": { type: "string" },
251
259
  "ensure-checkout": { type: "string" },
252
260
  } });
253
- if (!values.agent || !values.workspace) {
254
- 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)`);
255
263
  }
256
- if (!DRIVERS[values.agent]) {
257
- throw new Error(`Unknown agent driver: ${values.agent}. Available: ${Object.keys(DRIVERS).join(", ")}`);
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}).`);
273
+ }
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.`);
258
276
  }
259
277
  const workspace = resolve(values.workspace);
260
278
  if (values["ensure-checkout"]) {
@@ -263,14 +281,15 @@ async function installService() {
263
281
  ? `Cloned ${values["ensure-checkout"]} into ${workspace}`
264
282
  : `Workspace already matches ${values["ensure-checkout"]}`);
265
283
  }
284
+ // Supervisor service: no baked --agent; honors config drivers online set.
266
285
  const result = await installRunnerService({
267
- agent: values.agent,
268
286
  workspace,
269
287
  interval: values.interval,
270
288
  agentTimeoutMinutes: values["agent-timeout-minutes"],
271
289
  ensureCheckout: values["ensure-checkout"],
272
290
  });
273
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", "…")}\`.`);
274
293
  console.log("The runner keeps executing after this terminal closes. Laptop sleep still pauses work.");
275
294
  }
276
295
  async function uninstallService() {
@@ -287,27 +306,83 @@ async function fuelCommand() {
287
306
  await saveConfig(config);
288
307
  const client = new ConduitClient(config);
289
308
  await heartbeat(client, config, null);
290
- 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]", "…")}`);
291
362
  }
292
363
  async function runner() {
293
364
  const { values } = parseArgs({ args: process.argv.slice(3), options: {
294
365
  agent: { type: "string" }, workspace: { type: "string" }, interval: { type: "string" }, once: { type: "boolean" },
295
366
  "agent-timeout-minutes": { type: "string" }, fuel: { type: "string" }, "ensure-checkout": { type: "string" },
296
367
  } });
297
- const config = await loadConfig();
368
+ let config = await loadConfig();
298
369
  const fuelOverride = parseFuelSource(values.fuel);
299
370
  if (fuelOverride) {
300
371
  config.fuelSource = fuelOverride;
301
372
  await saveConfig(config);
302
373
  }
374
+ config = (await seedDriversFromDetection(config)).config;
375
+ await saveConfig(config);
303
376
  const client = new ConduitClient(config);
304
- let driver = null;
377
+ let processDriver = null;
378
+ let processOnlineIds = null;
305
379
  if (values.agent) {
306
- driver = DRIVERS[values.agent] ?? null;
307
- if (!driver)
380
+ processDriver = DRIVERS[values.agent] ?? null;
381
+ if (!processDriver)
308
382
  throw new Error(`Unknown agent driver: ${values.agent}. Available: ${Object.keys(DRIVERS).join(", ")}`);
309
383
  if (!values.workspace)
310
384
  throw new Error("--workspace <repository-path> is required with --agent");
385
+ processOnlineIds = [values.agent];
311
386
  }
312
387
  if (values["ensure-checkout"] && !values.workspace) {
313
388
  throw new Error("--ensure-checkout requires --workspace <repository-path>");
@@ -322,37 +397,53 @@ async function runner() {
322
397
  const brief = workspace ? await buildWorkspaceBrief(workspace) : null;
323
398
  const intervalMs = values.interval ? Math.max(5_000, Number(values.interval)) : 15_000;
324
399
  const timeoutMs = values["agent-timeout-minutes"] ? Number(values["agent-timeout-minutes"]) * 60_000 : undefined;
325
- const fuelLabel = config.fuelSource === "local" ? "local subscription" : "Conduit pump";
326
- if (!driver || !workspace) {
327
- 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)`);
328
406
  }
329
- console.log(`Conduit runner (bridge v${bridgeVersion()}) connected to ${config.baseUrl}${driver ? ` — executing via ${driver.name} in ${workspace}` : " — heartbeat only (no --agent)"} (fuel: ${fuelLabel})`);
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})`);
409
+ const running = new Map();
330
410
  for (;;) {
331
- let executed = false;
411
+ let progressed = false;
332
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;
333
418
  await heartbeat(client, config, workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null);
334
419
  await renewLeases(client, config);
335
- if (driver && workspace) {
336
- executed = await executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, {
420
+ if (workspace && (processDriver || onlineDriverIds(config).length)) {
421
+ progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
337
422
  heartbeat: async () => heartbeat(client, config, await buildWorkspaceBrief(workspace).catch(() => brief)),
338
423
  heartbeatIntervalMs: intervalMs,
339
- });
424
+ }, running, { driver: processDriver, processOnlineIds });
340
425
  }
341
426
  }
342
427
  catch (error) {
343
428
  console.error(`Runner cycle failed; retrying: ${redactSecrets(error instanceof Error ? error.message : "unknown error")}`);
344
429
  }
345
- if (values.once && executed)
430
+ if (values.once && progressed) {
431
+ if (running.size)
432
+ await Promise.allSettled([...running.values()]);
346
433
  return;
434
+ }
347
435
  await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
348
436
  }
349
437
  }
350
438
  async function heartbeat(client, config, brief) {
439
+ const online = onlineDriverIds(config);
440
+ const status = heartbeatStatusForDrivers(online);
351
441
  await client.request("/runner/v1/heartbeat", { method: "POST", body: JSON.stringify({
352
- status: "online",
442
+ status,
353
443
  capabilities: config.capabilities,
354
444
  lease_capacity: config.leaseCapacity,
355
445
  fuel_source: config.fuelSource === "local" ? "local" : "conduit",
446
+ drivers: driversHeartbeatReport(config, config.activeAttempts),
356
447
  ...(brief ? { workspace_brief: brief } : {}),
357
448
  }) });
358
449
  }
@@ -363,6 +454,8 @@ try {
363
454
  await join();
364
455
  else if (command === "fuel")
365
456
  await fuelCommand();
457
+ else if (command === "drivers")
458
+ await driversCommand();
366
459
  else if (command === "mcp")
367
460
  await runMcp();
368
461
  else if (command === "runner")
@@ -372,7 +465,7 @@ try {
372
465
  else if (command === "uninstall-service")
373
466
  await uninstallService();
374
467
  else
375
- 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>`);
376
469
  }
377
470
  catch (error) {
378
471
  console.error(error instanceof Error ? redactSecrets(error.message) : "Conduit command failed");
package/dist/config.js CHANGED
@@ -2,7 +2,16 @@ import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
+ /** Default when join omits --capacity. */
5
6
  export const BRIDGE_LEASE_CAPACITY = 1;
7
+ /** Hard ceiling for truthful concurrent Bridge slots (one agent + worktree each). */
8
+ export const BRIDGE_MAX_LEASE_CAPACITY = 4;
9
+ export function clampLeaseCapacity(value) {
10
+ const n = typeof value === "number" ? value : Number(value);
11
+ if (!Number.isInteger(n) || n < 1)
12
+ return BRIDGE_LEASE_CAPACITY;
13
+ return Math.min(n, BRIDGE_MAX_LEASE_CAPACITY);
14
+ }
6
15
  const directory = process.env.CONDUIT_BRIDGE_CONFIG_DIR?.trim()
7
16
  ? resolve(process.env.CONDUIT_BRIDGE_CONFIG_DIR)
8
17
  : join(homedir(), ".config", "conduit");
@@ -39,9 +48,19 @@ export async function loadConfigIfPresent() {
39
48
  active.phase ??= "agent_running";
40
49
  if (config.fuelSource !== "local" && config.fuelSource !== "conduit")
41
50
  config.fuelSource = "conduit";
42
- // The built-in Bridge runs one agent process synchronously. Keep legacy
43
- // configurations truthful until isolated concurrent slot workers exist.
44
- config.leaseCapacity = BRIDGE_LEASE_CAPACITY;
51
+ // Clamp to what concurrent slot workers can run (1..BRIDGE_MAX_LEASE_CAPACITY).
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
+ }
45
64
  return config;
46
65
  }
47
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,8 +2,9 @@ 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";
6
- import { attemptWorktreePath, createAttemptWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
5
+ import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, parseAgentReport, pickModelCandidate, tierForRisk } from "./driver.js";
6
+ import { pickDriverForClaim, resolveDriverFuel } from "./drivers.js";
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({
9
10
  id: z.string().uuid(),
@@ -66,13 +67,32 @@ export async function renewLeases(client, config) {
66
67
  }
67
68
  }
68
69
  }
69
- export async function recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision) {
70
- const active = Object.values(config.activeAttempts)[0];
70
+ function resolveAttemptDriver(config, active, fallback) {
71
+ if (active.driverId && DRIVERS[active.driverId])
72
+ return DRIVERS[active.driverId];
73
+ return fallback ?? null;
74
+ }
75
+ export async function recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision, taskId) {
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
- // F-07: never resume a dirty shared checkout. Quarantine/remove the attempt worktree first.
75
85
  const worktree = active.worktreePath ?? attemptWorktreePath(workspace, active.attemptId);
86
+ const sessionId = config.sessions?.[active.taskId]?.trim() || "";
87
+ // F-07 safe resume: only when session + worktree identity are both proven. Otherwise interrupt.
88
+ if (sessionId && await proveResumeWorktree(workspace, active.attemptId, worktree)) {
89
+ console.log(`Resuming interrupted attempt ${active.attemptId} with proven worktree and session.`);
90
+ await runClaimedAssignment(client, config, laneDriver, workspace, brief, active.taskId, timeoutMs, supervision, {
91
+ existingWorktree: worktree,
92
+ forceResumeSessionId: sessionId,
93
+ });
94
+ return true;
95
+ }
76
96
  await quarantineAttemptWorktree(workspace, worktree, active.attemptId).catch(() => removeAttemptWorktree(workspace, worktree).catch(() => undefined));
77
97
  await client.updateAttempt(active.taskId, { worktreePath: undefined });
78
98
  await queueTerminal(client, active.taskId, {
@@ -89,20 +109,83 @@ export async function recoverActiveAttempt(client, config, driver, workspace, br
89
109
  await flushTerminal(client, active.taskId);
90
110
  return true;
91
111
  }
92
- await runClaimedAssignment(client, config, driver, workspace, brief, active.taskId, timeoutMs, supervision);
112
+ await runClaimedAssignment(client, config, laneDriver, workspace, brief, active.taskId, timeoutMs, supervision);
93
113
  return true;
94
114
  }
95
- export async function executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, supervision) {
96
- if (Object.keys(config.activeAttempts).length)
97
- return recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision);
115
+ /**
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.
118
+ * `running` is owned by the runner loop and must outlive a single pump call.
119
+ */
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);
126
+ let progressed = running.size > 0;
127
+ for (const id of Object.keys(config.activeAttempts)) {
128
+ if (running.has(id))
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
+ }
136
+ progressed = true;
137
+ const slot = recoverActiveAttempt(client, config, laneDriver, workspace, brief, timeoutMs, supervision, id)
138
+ .catch((error) => {
139
+ console.error(`Slot recovery failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
140
+ })
141
+ .then(() => undefined)
142
+ .finally(() => { running.delete(id); });
143
+ running.set(id, slot);
144
+ }
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;
161
+ const taskId = await claimNextAssignment(client, config, workspace, brief);
162
+ if (!taskId)
163
+ break;
164
+ await client.updateAttempt(taskId, { driverId });
165
+ progressed = true;
166
+ console.log(`Executing ${taskId} via ${laneDriver.name}`);
167
+ const slot = runClaimedAssignment(client, config, laneDriver, workspace, brief, taskId, timeoutMs, supervision)
168
+ .catch((error) => {
169
+ console.error(`Slot execution failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
170
+ })
171
+ .then(() => undefined)
172
+ .finally(() => { running.delete(taskId); });
173
+ running.set(taskId, slot);
174
+ }
175
+ return progressed;
176
+ }
177
+ /** Claim one assignment when under capacity; does not start the agent (multi-slot pump does). */
178
+ export async function claimNextAssignment(client, config, workspace, brief) {
179
+ if (Object.keys(config.activeAttempts).length >= config.leaseCapacity)
180
+ return null;
98
181
  const data = await client.request("/runner/v1/assignments");
99
182
  const assignments = z.array(assignmentSchema).parse(data.assignments ?? []);
100
183
  const assignment = assignments[0];
101
184
  if (!assignment)
102
- return false;
185
+ return null;
103
186
  if (assignment.execution_mode === "human") {
104
187
  console.log(`Human takeover assignment ${assignment.id} — use Bridge MCP tools to claim and submit (agent runner skips).`);
105
- return false;
188
+ return null;
106
189
  }
107
190
  const liveBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
108
191
  const liveRepository = liveBrief?.repository ? normalizeRepositoryUrl(liveBrief.repository) : null;
@@ -122,14 +205,22 @@ export async function executeNextAssignment(client, config, driver, workspace, b
122
205
  body: JSON.stringify({ attempt_id: assignment.attempt_id, reason: rejection, idempotency_key: `bridge:preflight-reject:${assignment.attempt_id}:${rejection}` }),
123
206
  });
124
207
  console.error(`Assignment ${assignment.id} rejected before claim: ${rejection}`);
125
- return true;
208
+ return null;
126
209
  }
127
210
  console.log(`Claiming assignment ${assignment.id} (attempt ${assignment.attempt_id})`);
128
211
  await client.claim(assignment.id, assignment.attempt_id);
129
- await runClaimedAssignment(client, config, driver, workspace, brief, assignment.id, timeoutMs, supervision);
212
+ return assignment.id;
213
+ }
214
+ export async function executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, supervision) {
215
+ if (Object.keys(config.activeAttempts).length)
216
+ return recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision);
217
+ const taskId = await claimNextAssignment(client, config, workspace, brief);
218
+ if (!taskId)
219
+ return false;
220
+ await runClaimedAssignment(client, config, driver, workspace, brief, taskId, timeoutMs, supervision);
130
221
  return true;
131
222
  }
132
- async function runClaimedAssignment(client, config, driver, workspace, brief, taskId, timeoutMs, supervision) {
223
+ async function runClaimedAssignment(client, config, driver, workspace, brief, taskId, timeoutMs, supervision, options = {}) {
133
224
  const active = client.attempt(taskId);
134
225
  const detail = await client.request(`/runner/v1/tasks/${taskId}`);
135
226
  const task = taskDetailSchema.parse(detail.task);
@@ -157,28 +248,45 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
157
248
  return;
158
249
  }
159
250
  let attemptWorkspace;
160
- try {
161
- attemptWorkspace = await createAttemptWorktree({
162
- sourceWorkspace: workspace,
163
- attemptId: active.attemptId,
164
- startCommit,
165
- });
251
+ if (options.existingWorktree) {
252
+ attemptWorkspace = options.existingWorktree;
166
253
  }
167
- catch (error) {
168
- const code = error instanceof Error ? error.message : "worktree_create_failed";
169
- const message = code === "source_workspace_dirty"
170
- ? "Source workspace has uncommitted changes; refuse to start an isolated attempt"
171
- : `Unable to create attempt worktree: ${code}`;
172
- await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: true, idempotency_key: `bridge:worktree:${active.attemptId}:${code}` } });
173
- console.error(`Assignment ${taskId} worktree create failed: ${redactSecrets(message)}`);
174
- return;
254
+ else {
255
+ try {
256
+ attemptWorkspace = await createAttemptWorktree({
257
+ sourceWorkspace: workspace,
258
+ attemptId: active.attemptId,
259
+ startCommit,
260
+ });
261
+ }
262
+ catch (error) {
263
+ const code = error instanceof Error ? error.message : "worktree_create_failed";
264
+ const message = code === "source_workspace_dirty"
265
+ ? "Source workspace has uncommitted changes; refuse to start an isolated attempt"
266
+ : `Unable to create attempt worktree: ${code}`;
267
+ await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: true, idempotency_key: `bridge:worktree:${active.attemptId}:${code}` } });
268
+ console.error(`Assignment ${taskId} worktree create failed: ${redactSecrets(message)}`);
269
+ return;
270
+ }
271
+ await client.updateAttempt(taskId, { worktreePath: attemptWorkspace });
175
272
  }
176
- await client.updateAttempt(taskId, { worktreePath: attemptWorkspace });
177
273
  // Same list the driver hands to the permission contract, so the prompt states exactly what is
178
274
  // executable rather than leaving the agent to guess and hit rejections.
179
275
  const prompt = buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead: startCommit, reworkFeedback, workPackage, verificationCommands: liveBrief?.verification ?? [] });
180
- await client.attemptRequest(taskId, "progress", { phase: "changing", message: `Starting ${driver.name} for this assignment${reworkFeedback ? " with review feedback" : ""}.`, idempotency_key: `bridge:progress:${active.attemptId}:start` });
181
- const fuelSource = config.fuelSource === "local" ? "local" : "conduit";
276
+ const resuming = Boolean(options.forceResumeSessionId);
277
+ await client.attemptRequest(taskId, "progress", {
278
+ phase: "changing",
279
+ message: resuming
280
+ ? `Resuming ${driver.name} after Bridge restart with proven session and worktree.`
281
+ : `Starting ${driver.name} for this assignment${reworkFeedback ? " with review feedback" : ""}.`,
282
+ idempotency_key: resuming ? `bridge:progress:${active.attemptId}:resume` : `bridge:progress:${active.attemptId}:start`,
283
+ });
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);
182
290
  let fuel;
183
291
  if (fuelSource === "conduit") {
184
292
  const gatewayKey = await client.ensureFuel(task.project_id);
@@ -193,10 +301,12 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
193
301
  // that was chosen. updateAttempt alone only moves local runner state, which the database never sees.
194
302
  await client.attemptRequest(taskId, "progress", {
195
303
  phase: "agent_running",
196
- message: `Started ${driver.name}${selection.model ? ` on ${selection.model}` : " on its CLI default"}.`,
197
- idempotency_key: `bridge:progress:${active.attemptId}:agent-start`,
304
+ message: resuming
305
+ ? `Resumed ${driver.name}${selection.model ? ` on ${selection.model}` : " on its CLI default"}.`
306
+ : `Started ${driver.name}${selection.model ? ` on ${selection.model}` : " on its CLI default"}.`,
307
+ idempotency_key: resuming ? `bridge:progress:${active.attemptId}:agent-resume` : `bridge:progress:${active.attemptId}:agent-start`,
198
308
  });
199
- await client.updateAttempt(taskId, { phase: "agent_running", fuelMode: fuelSource });
309
+ await client.updateAttempt(taskId, { phase: "agent_running", fuelMode: fuelSource, worktreePath: attemptWorkspace, driverId });
200
310
  const renewTimer = setInterval(() => { void renewLeases(client, config).catch((error) => console.error(`Lease renewal failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`)); }, 60_000);
201
311
  let heartbeatRunning = false;
202
312
  const heartbeatTimer = supervision ? setInterval(() => {
@@ -208,15 +318,17 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
208
318
  .finally(() => { heartbeatRunning = false; });
209
319
  }, supervision.heartbeatIntervalMs) : null;
210
320
  try {
321
+ // Resume for (1) Bridge restart with proven session+worktree, or (2) review rework.
322
+ // A fresh attempt after a failed delivery must start clean: resuming a session whose last
323
+ // turn already "finished" makes the agent reply conversationally without the report block.
324
+ const resumeSessionId = options.forceResumeSessionId
325
+ ?? (reworkFeedback ? config.sessions?.[taskId] : undefined);
211
326
  const result = await driver.run({
212
327
  prompt,
213
328
  workspace: attemptWorkspace,
214
329
  grants,
215
330
  verificationCommands: liveBrief?.verification ?? [],
216
- // Resume only for review rework, where continuing the prior conversation is the point.
217
- // A fresh attempt after a failed delivery must start clean: resuming a session whose last
218
- // turn already "finished" makes the agent reply conversationally without the report block.
219
- resumeSessionId: reworkFeedback ? config.sessions?.[taskId] : undefined,
331
+ resumeSessionId,
220
332
  timeoutMs,
221
333
  model: selection.model,
222
334
  fuel,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.6.1",
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"