@miraland-labs/conduit-bridge 0.7.0 → 0.9.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/brief.js CHANGED
@@ -43,6 +43,41 @@ export async function isBaseCommitAncestor(workspace, baseCommit, headCommit) {
43
43
  throw error;
44
44
  }
45
45
  }
46
+ /** True when `sha` is a local commit object, optionally after fetching it from origin. */
47
+ export async function ensureCommitAvailable(workspace, sha) {
48
+ const present = async () => {
49
+ try {
50
+ await execFileAsync("git", ["-C", workspace, "cat-file", "-e", `${sha}^{commit}`], {
51
+ timeout: 10_000,
52
+ windowsHide: true,
53
+ });
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ };
60
+ if (await present())
61
+ return true;
62
+ try {
63
+ await execFileAsync("git", ["-C", workspace, "fetch", "--no-tags", "--depth=1", "origin", sha], {
64
+ timeout: 120_000,
65
+ windowsHide: true,
66
+ });
67
+ }
68
+ catch {
69
+ try {
70
+ await execFileAsync("git", ["-C", workspace, "fetch", "--no-tags", "origin"], {
71
+ timeout: 120_000,
72
+ windowsHide: true,
73
+ });
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ return present();
80
+ }
46
81
  async function gitRemoteUrl(workspace) {
47
82
  try {
48
83
  const { common } = await gitDirectories(workspace);
package/dist/cli.js CHANGED
@@ -6,10 +6,11 @@ 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, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, 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, saveConfigPrefs, 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
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
- console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit pump"}`
222
+ console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit /v1 (project owner BYOK on open orgs; org credentials on invite-only)"}`
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 saveConfigPrefs(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() {
@@ -285,30 +303,86 @@ async function fuelCommand() {
285
303
  }
286
304
  const config = await loadConfig();
287
305
  config.fuelSource = mode;
288
- await saveConfig(config);
306
+ await saveConfigPrefs(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 saveConfigPrefs(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 saveConfigPrefs(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 saveConfigPrefs(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
- await saveConfig(config);
372
+ await saveConfigPrefs(config);
303
373
  }
374
+ config = (await seedDriversFromDetection(config)).config;
375
+ await saveConfigPrefs(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 {
335
- await heartbeat(client, config, workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null);
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;
418
+ await heartbeat(client, config, workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null, processOnlineIds);
336
419
  await renewLeases(client, config);
337
- if (driver && workspace) {
338
- progressed = await pumpExecutionSlots(client, config, driver, workspace, brief, timeoutMs, {
339
- heartbeat: async () => heartbeat(client, config, await buildWorkspaceBrief(workspace).catch(() => brief)),
420
+ if (workspace && (processDriver || onlineDriverIds(config).length)) {
421
+ progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
422
+ heartbeat: async () => heartbeat(client, config, await buildWorkspaceBrief(workspace).catch(() => brief), processOnlineIds),
340
423
  heartbeatIntervalMs: intervalMs,
341
- }, running);
424
+ }, running, { driver: processDriver, processOnlineIds });
342
425
  }
343
426
  }
344
427
  catch (error) {
@@ -352,12 +435,16 @@ async function runner() {
352
435
  await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
353
436
  }
354
437
  }
355
- async function heartbeat(client, config, brief) {
438
+ async function heartbeat(client, config, brief, processOnlineIds) {
439
+ // Process-level `--agent` override must count as online even when saved lanes are offline.
440
+ const online = processOnlineIds?.length ? processOnlineIds : onlineDriverIds(config);
441
+ const status = heartbeatStatusForDrivers(online);
356
442
  await client.request("/runner/v1/heartbeat", { method: "POST", body: JSON.stringify({
357
- status: "online",
443
+ status,
358
444
  capabilities: config.capabilities,
359
445
  lease_capacity: config.leaseCapacity,
360
446
  fuel_source: config.fuelSource === "local" ? "local" : "conduit",
447
+ drivers: driversHeartbeatReport(config, config.activeAttempts, processOnlineIds),
361
448
  ...(brief ? { workspace_brief: brief } : {}),
362
449
  }) });
363
450
  }
@@ -368,6 +455,8 @@ try {
368
455
  await join();
369
456
  else if (command === "fuel")
370
457
  await fuelCommand();
458
+ else if (command === "drivers")
459
+ await driversCommand();
371
460
  else if (command === "mcp")
372
461
  await runMcp();
373
462
  else if (command === "runner")
@@ -377,7 +466,7 @@ try {
377
466
  else if (command === "uninstall-service")
378
467
  await uninstallService();
379
468
  else
380
- throw new Error(`Usage: ${BRIDGE_NPX} <join|connect|fuel|mcp|runner|install-service|uninstall-service>`);
469
+ throw new Error(`Usage: ${BRIDGE_NPX} <join|connect|fuel|drivers|mcp|runner|install-service|uninstall-service>`);
381
470
  }
382
471
  catch (error) {
383
472
  console.error(error instanceof Error ? redactSecrets(error.message) : "Conduit command failed");
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { saveConfig } from "./config.js";
1
+ import { removeRuntimeAttempt, saveConfigPrefs, saveRuntime } from "./config.js";
2
2
  export class ConduitRequestError extends Error {
3
3
  status;
4
4
  code;
@@ -10,10 +10,24 @@ export class ConduitRequestError extends Error {
10
10
  }
11
11
  export class ConduitClient {
12
12
  config;
13
- persist;
14
- constructor(config, persist = saveConfig) {
13
+ persistRuntime;
14
+ persistPrefs;
15
+ removeAttempt;
16
+ constructor(config, persist) {
15
17
  this.config = config;
16
- this.persist = persist;
18
+ if (typeof persist === "function") {
19
+ this.persistRuntime = persist;
20
+ this.persistPrefs = persist;
21
+ this.removeAttempt = async (taskId) => {
22
+ delete this.config.activeAttempts[taskId];
23
+ await persist(this.config);
24
+ };
25
+ }
26
+ else {
27
+ this.persistRuntime = persist?.runtime ?? saveRuntime;
28
+ this.persistPrefs = persist?.prefs ?? saveConfigPrefs;
29
+ this.removeAttempt = persist?.removeAttempt ?? removeRuntimeAttempt;
30
+ }
17
31
  }
18
32
  async request(path, init = {}) {
19
33
  const response = await fetch(`${this.config.baseUrl}${path}`, {
@@ -25,11 +39,18 @@ export class ConduitClient {
25
39
  throw new ConduitRequestError(data.error?.message ?? `Conduit request failed (${response.status})`, response.status, data.error?.code);
26
40
  return data;
27
41
  }
28
- async claim(taskId, attemptId) {
42
+ async claim(taskId, attemptId, extras) {
29
43
  const data = await this.request(`/runner/v1/tasks/${taskId}/claim`, { method: "POST", body: JSON.stringify({ attempt_id: attemptId, idempotency_key: `bridge:claim:${attemptId}` }) });
30
- const active = { taskId, attemptId, leaseToken: String(data.lease_token), leaseExpiresAt: String(data.lease_expires_at), phase: "claimed" };
44
+ const active = {
45
+ taskId,
46
+ attemptId,
47
+ leaseToken: String(data.lease_token),
48
+ leaseExpiresAt: String(data.lease_expires_at),
49
+ phase: "claimed",
50
+ ...(extras?.driverId ? { driverId: extras.driverId } : {}),
51
+ };
31
52
  this.config.activeAttempts[taskId] = active;
32
- await this.persist(this.config);
53
+ await this.persistRuntime(this.config);
33
54
  return { ...data, lease_token: "stored by Conduit Bridge" };
34
55
  }
35
56
  attempt(taskId) {
@@ -51,12 +72,15 @@ export class ConduitClient {
51
72
  else
52
73
  Object.assign(active, { [key]: value });
53
74
  }
54
- await this.persist(this.config);
75
+ await this.persistRuntime(this.config);
55
76
  return active;
56
77
  }
57
78
  async clearAttempt(taskId) {
79
+ const active = this.config.activeAttempts[taskId];
58
80
  delete this.config.activeAttempts[taskId];
59
- await this.persist(this.config);
81
+ if (this.config.sessions)
82
+ delete this.config.sessions[taskId];
83
+ await this.removeAttempt(taskId, active?.attemptId);
60
84
  }
61
85
  /**
62
86
  * Ensure a project-scoped gateway fuel key for agent /v1 calls.
@@ -74,7 +98,7 @@ export class ConduitClient {
74
98
  gatewayKey = String(rotated.gateway_secret);
75
99
  }
76
100
  this.config.fuel = { ...this.config.fuel, [projectId]: { gatewayKey } };
77
- await this.persist(this.config);
101
+ await this.persistPrefs(this.config);
78
102
  return gatewayKey;
79
103
  }
80
104
  }