@miraland-labs/conduit-bridge 0.9.6 → 0.9.8

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,7 +2,7 @@
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.9.6` — **ops** helpers for macOS / Linux / Windows, LaunchAgent/systemd **PATH** for `~/.local/bin`, disconnect/disengage, multi-driver lanes, shared slots **1–4**, worktrees, optional `--ensure-checkout`.
5
+ **Package version:** `0.9.8` — heartbeat protocol 2 requires a clean workspace plus a ready, versioned driver lane before dispatch. It also includes **ops** helpers for macOS / Linux / Windows, LaunchAgent/systemd **PATH** for `~/.local/bin`, disconnect/disengage, multi-driver lanes, shared slots **1–4**, worktrees, and optional `--ensure-checkout`. Protocol 1 heartbeats remain compatible for presence but cannot receive work.
6
6
 
7
7
  ## Prerequisites
8
8
 
@@ -63,6 +63,18 @@ npx @miraland-labs/conduit-bridge disconnect --yes
63
63
 
64
64
  **Capacity:** Connect-approved `lease_capacity` (1–4) is a **shared pool** for all online lanes on that computer.
65
65
 
66
+ ## Readiness and lane status
67
+
68
+ Before agent spend, Bridge checks that the workspace is readable, matches the expected repository,
69
+ and is clean; each online driver must be installed, compatible with its configured fuel, and signed
70
+ in when local fuel is used. The same bounded report is sent on heartbeat so Conduit can place an
71
+ environment failure on Hold without consuming an execution attempt. Fix the named issue and let a
72
+ fresh heartbeat land before choosing **Recheck** in Activity.
73
+
74
+ Conduit labels an exact driver/CLI version **Certified** only after two consecutive real canaries:
75
+ repository delivery with PR/evidence and a successful rework cycle. All other lanes are
76
+ **Experimental**. A lane runs only when its computer operator explicitly brings it online.
77
+
66
78
  ## Grant enforcement
67
79
 
68
80
  | Driver | Fuel | Mechanism |
package/dist/cli.js CHANGED
@@ -18,6 +18,7 @@ import { ensureCheckout } from "./checkout.js";
18
18
  import { pumpExecutionSlots, renewLeases } from "./execution.js";
19
19
  import { installRunnerService, uninstallRunnerService } from "./service.js";
20
20
  import { OPS_VERBS, runOps } from "./ops.js";
21
+ import { BRIDGE_PROTOCOL_VERSION, cachedBridgePreflight } from "./preflight.js";
21
22
  const [command] = process.argv.slice(2);
22
23
  /** Read our own package version so every runner start logs exactly which build is live. */
23
24
  function bridgeVersion() {
@@ -367,7 +368,7 @@ async function initOps() {
367
368
  async function opsCommand() {
368
369
  const verb = process.argv[3];
369
370
  if (!verb || !OPS_VERBS.includes(verb)) {
370
- throw new Error(`Usage: ${bridgeUsage("ops", "<connect|install|online|offline|status|disconnect|uninstall>", "[driver…]")}`);
371
+ throw new Error(`Usage: ${bridgeUsage("ops", "<connect|install|online|offline|status|doctor|disconnect|uninstall>", "[driver…]")}`);
371
372
  }
372
373
  await runOps(verb, process.argv.slice(4));
373
374
  }
@@ -490,11 +491,17 @@ async function runner() {
490
491
  config.drivers = latest.drivers;
491
492
  config.fuelSource = latest.fuelSource;
492
493
  config.leaseCapacity = latest.leaseCapacity;
493
- await heartbeat(client, config, workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null, processOnlineIds);
494
+ const currentBrief = workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null;
495
+ const preflight = workspace ? await cachedBridgePreflight({ config, workspace, processOnlineIds, brief: currentBrief ?? undefined }) : undefined;
496
+ await heartbeat(client, config, currentBrief, processOnlineIds, preflight);
494
497
  await renewLeases(client, config);
495
498
  if (workspace && (processDriver || onlineDriverIds(config).length)) {
496
499
  progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
497
- heartbeat: async () => heartbeat(client, config, await buildWorkspaceBrief(workspace).catch(() => brief), processOnlineIds),
500
+ heartbeat: async () => {
501
+ const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
502
+ const preflight = await cachedBridgePreflight({ config, workspace, processOnlineIds, brief: currentBrief ?? undefined });
503
+ await heartbeat(client, config, currentBrief, processOnlineIds, preflight);
504
+ },
498
505
  heartbeatIntervalMs: intervalMs,
499
506
  }, running, { driver: processDriver, processOnlineIds });
500
507
  }
@@ -510,7 +517,7 @@ async function runner() {
510
517
  await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
511
518
  }
512
519
  }
513
- async function heartbeat(client, config, brief, processOnlineIds) {
520
+ async function heartbeat(client, config, brief, processOnlineIds, preflight) {
514
521
  // Process-level `--agent` override must count as online even when saved lanes are offline.
515
522
  const online = processOnlineIds?.length ? processOnlineIds : onlineDriverIds(config);
516
523
  const status = heartbeatStatusForDrivers(online);
@@ -519,7 +526,10 @@ async function heartbeat(client, config, brief, processOnlineIds) {
519
526
  capabilities: config.capabilities,
520
527
  lease_capacity: config.leaseCapacity,
521
528
  fuel_source: config.fuelSource === "local" ? "local" : "conduit",
529
+ bridge_version: bridgeVersion(),
530
+ bridge_protocol: BRIDGE_PROTOCOL_VERSION,
522
531
  drivers: driversHeartbeatReport(config, config.activeAttempts, processOnlineIds),
532
+ ...(preflight ? { preflight } : {}),
523
533
  ...(brief ? { workspace_brief: brief } : {}),
524
534
  }) });
525
535
  }
package/dist/driver.js CHANGED
@@ -345,7 +345,7 @@ export function codexSandboxForGrants(grants) {
345
345
  }
346
346
  /**
347
347
  * Codex exec arguments. The sandbox is the enforcement boundary: network stays
348
- * off inside workspace-write unless the assignment actually holds pr_create.
348
+ * off inside workspace-write unless the canonical contract requires external_network.
349
349
  */
350
350
  export function codexExecArgs(input, sandbox) {
351
351
  // exec is non-interactive by design (no approval flag), and `exec resume`
@@ -355,7 +355,7 @@ export function codexExecArgs(input, sandbox) {
355
355
  : ["exec", "--json", "--sandbox", sandbox];
356
356
  if (input.model)
357
357
  args.push("-m", input.model);
358
- if (sandbox === "workspace-write" && input.grants.includes("pr_create")) {
358
+ if (sandbox === "workspace-write" && input.capabilities?.includes("external_network")) {
359
359
  args.push("-c", "sandbox_workspace_write.network_access=true");
360
360
  }
361
361
  if (input.resumeSessionId)
@@ -453,10 +453,14 @@ export function cursorPermissionsForGrants(grants, verificationCommands = []) {
453
453
  * block. Real authority boundaries are the `.cursor/cli.json` shell allow-list and the delivery
454
454
  * contract (validateDeliveryReport rejects reported repository changes without repo_write, Invariant 9).
455
455
  */
456
+ export function cursorSupportsWorkspaceTrust(helpOutput) {
457
+ return /(?:^|\s)--trust(?:\s|$)/m.test(helpOutput);
458
+ }
456
459
  export function cursorRunArgs(input) {
457
- // Do not pass --trust: current Cursor Agent CLI rejects it ("unknown option '--trust'").
458
460
  // Workspace authority is the operator-configured Bridge workspace; tool authority is .cursor/cli.json.
459
461
  const args = ["-p", "--output-format", "json", "--workspace", input.workspace];
462
+ if (input.trustWorkspace)
463
+ args.push("--trust");
460
464
  if (input.model)
461
465
  args.push("--model", input.model);
462
466
  if (input.resumeSessionId)
@@ -526,11 +530,15 @@ export const cursorDriver = {
526
530
  // `agent status` exits 0 either way, so the text is the signal.
527
531
  if (!process.env.CURSOR_API_KEY) {
528
532
  const status = await execute(executable, ["status"], input.workspace, 15_000, undefined, "local");
529
- if (status.code !== 0 || /not logged in/i.test(status.stdout)) {
533
+ if (status.code !== 0 || !/logged in as/i.test(`${status.stdout}\n${status.stderr}`)) {
530
534
  return { status: "failed", resultText: null, sessionId: null, error: "cursor has no login (set CURSOR_API_KEY or run `agent login`)" };
531
535
  }
532
536
  }
533
- const configured = await withCursorPermissions(input.workspace, cursorPermissionsForGrants(input.grants, input.verificationCommands), () => execute(executable, cursorRunArgs(input), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local"));
537
+ // Cursor has alternated between accepting and rejecting --trust across releases. Detect the
538
+ // local contract instead of making either version fail every attempt worktree.
539
+ const help = await execute(executable, ["--help"], input.workspace, 15_000, undefined, "local");
540
+ const trustWorkspace = help.code === 0 && cursorSupportsWorkspaceTrust(`${help.stdout}\n${help.stderr}`);
541
+ const configured = await withCursorPermissions(input.workspace, cursorPermissionsForGrants(input.grants, input.verificationCommands), () => execute(executable, cursorRunArgs({ ...input, trustWorkspace }), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local"));
534
542
  const { code, stdout, stderr } = configured;
535
543
  const parsed = parseCursorOutput(stdout);
536
544
  if (code !== 0 || parsed.isError) {
package/dist/execution.js CHANGED
@@ -21,6 +21,10 @@ function changesRequestedFeedback(summary) {
21
21
  return null;
22
22
  }
23
23
  }
24
+ /** Failures that require an operator/configuration change must never burn the remaining attempts. */
25
+ export function retryableAgentFailure(message) {
26
+ return !/unknown (?:option|argument)|unrecognized (?:option|argument)|not logged in|no login|not authenticated|login required|\bENOENT\b|could not verify the installed CLI|requires local fuel|No Bridge-mapped|preflight/i.test(message);
27
+ }
24
28
  const assignmentSchema = z.object({
25
29
  id: z.string().uuid(),
26
30
  attempt_id: z.string().uuid(),
@@ -41,6 +45,7 @@ const taskDetailSchema = z.object({
41
45
  const taskSpecSchema = z.object({
42
46
  goal: z.string().optional(), scope: z.array(z.string()).optional(), boundaries: z.array(z.string()).optional(),
43
47
  acceptance: z.array(z.string()).optional(), required_evidence: z.array(z.enum(evidenceKinds)).optional(),
48
+ required_capabilities: z.array(z.string()).optional(),
44
49
  change_scope: z.array(z.string()).optional(), work_role: z.string().optional(),
45
50
  repository: z.object({ url: z.string().optional(), base_commit: z.string().optional() }).nullable().optional(),
46
51
  risk_level: z.string().optional(),
@@ -395,6 +400,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
395
400
  prompt,
396
401
  workspace: attemptWorkspace,
397
402
  grants,
403
+ capabilities: spec.required_capabilities ?? [],
398
404
  verificationCommands: liveBrief?.verification ?? [],
399
405
  resumeSessionId,
400
406
  timeoutMs,
@@ -405,8 +411,9 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
405
411
  if (result.sessionId)
406
412
  config.sessions = { ...config.sessions, [taskId]: result.sessionId };
407
413
  if (result.status === "failed") {
408
- await queueTerminal(client, taskId, { action: "fail", body: { error: result.error ?? "Agent execution failed", idempotency_key: `bridge:fail:${active.attemptId}` } });
409
- console.error(`Assignment ${taskId} failed: ${redactSecrets(result.error ?? "unknown")}`);
414
+ const message = result.error ?? "Agent execution failed";
415
+ await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: retryableAgentFailure(message), idempotency_key: `bridge:fail:${active.attemptId}` } });
416
+ console.error(`Assignment ${taskId} failed: ${redactSecrets(message)}`);
410
417
  return;
411
418
  }
412
419
  let report;
@@ -431,6 +438,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
431
438
  // Preserve only existing read authority. No write, test, branch, or push
432
439
  // capability is available while the agent repairs the response envelope.
433
440
  grants: grants.filter((grant) => grant === "repo_read"),
441
+ capabilities: [],
434
442
  verificationCommands: [],
435
443
  resumeSessionId: result.sessionId ?? undefined,
436
444
  timeoutMs,
@@ -441,7 +449,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
441
449
  }
442
450
  catch (repairError) {
443
451
  const message = repairError instanceof Error ? repairError.message : "Delivery report repair failed";
444
- await queueTerminal(client, taskId, { action: "fail", body: { error: `Delivery report repair failed: ${message}`, retryable: true, idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
452
+ await queueTerminal(client, taskId, { action: "fail", body: { error: `Delivery report repair failed: ${message}`, retryable: retryableAgentFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
445
453
  console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
446
454
  return;
447
455
  }
@@ -449,7 +457,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
449
457
  config.sessions = { ...config.sessions, [taskId]: repaired.sessionId };
450
458
  if (repaired.status === "failed") {
451
459
  const message = repaired.error ?? "Delivery report repair failed";
452
- await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: true, idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
460
+ await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: retryableAgentFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
453
461
  console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
454
462
  return;
455
463
  }
@@ -459,7 +467,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
459
467
  }
460
468
  catch (repairError) {
461
469
  const message = repairError instanceof Error ? repairError.message : "Repaired delivery report was invalid";
462
- await queueTerminal(client, taskId, { action: "fail", body: { error: `Delivery report repair exhausted: ${message}`, retryable: true, idempotency_key: `bridge:delivery-repair-invalid:${active.attemptId}` } });
470
+ await queueTerminal(client, taskId, { action: "fail", body: { error: `Delivery report repair exhausted: ${message}`, retryable: false, idempotency_key: `bridge:delivery-repair-invalid:${active.attemptId}` } });
463
471
  console.error(`Assignment ${taskId} exhausted its report-only repair: ${redactSecrets(message)}`);
464
472
  const replyTail = (repaired.resultText ?? "").slice(-8_000);
465
473
  console.error(`Assignment ${taskId} repaired reply tail (${(repaired.resultText ?? "").length} chars total, redacted): ${redactSecrets(replyTail) || "<empty>"}`);
@@ -482,10 +490,9 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
482
490
  }
483
491
  catch (error) {
484
492
  const message = error instanceof Error ? error.message : "Agent delivery report was invalid";
485
- // Missing PR tooling / credential is retryable once the environment is fixed; other contract
486
- // failures (scope, evidence mapping) stay non-retryable.
487
- const retryable = /pull_request|merge_request|forge|GitHub|GitLab|gh |push|credential/i.test(message);
488
- await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable, idempotency_key: `bridge:invalid-delivery:${active.attemptId}` } });
493
+ // Contract/tooling/credential failures need a human fix. Automatic retry would repeat the same
494
+ // state immediately and spend another attempt before the operator can change anything.
495
+ await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: false, idempotency_key: `bridge:invalid-delivery:${active.attemptId}` } });
489
496
  console.error(`Assignment ${taskId} could not produce a valid Delivery: ${redactSecrets(message)}`);
490
497
  const replyTail = reportText.slice(-8_000);
491
498
  console.error(`Assignment ${taskId} agent reply tail (${reportText.length} chars total, redacted): ${redactSecrets(replyTail) || "<empty>"}`);
package/dist/ops.js CHANGED
@@ -6,8 +6,10 @@ import { spawnSync } from "node:child_process";
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { homedir, platform } from "node:os";
8
8
  import { join, resolve } from "node:path";
9
+ import { loadConfig } from "./config.js";
10
+ import { describePreflightIssue, runBridgePreflight } from "./preflight.js";
9
11
  export const OPS_VERBS = [
10
- "connect", "install", "online", "offline", "status", "disconnect", "uninstall",
12
+ "connect", "install", "online", "offline", "status", "doctor", "disconnect", "uninstall",
11
13
  ];
12
14
  const LOCAL_FUEL_DRIVERS = new Set(["cursor", "kiro", "antigravity"]);
13
15
  export function defaultOpsEnvPath(home = homedir()) {
@@ -147,6 +149,25 @@ export async function runOps(verb, argv = [], deps = {}) {
147
149
  runBridge(["drivers"]);
148
150
  return;
149
151
  }
152
+ if (verb === "doctor") {
153
+ requireOpsEnv(env);
154
+ if (!env.CONDUIT_WORKSPACE)
155
+ throw new Error(`Set CONDUIT_WORKSPACE in ${defaultOpsEnvPath()}`);
156
+ const config = await (deps.loadBridgeConfig ?? loadConfig)();
157
+ const report = await (deps.preflight ?? runBridgePreflight)({
158
+ config,
159
+ workspace: resolve(expandOpsValue(env.CONDUIT_WORKSPACE)),
160
+ expectedRepository: env.CONDUIT_REPO || undefined,
161
+ });
162
+ console.log(`Bridge preflight: ${report.ready ? "READY" : "BLOCKED"}`);
163
+ if (report.ready) {
164
+ console.log("Workspace, online lanes, local fuel, CLI availability, and authentication are ready.");
165
+ return;
166
+ }
167
+ for (const issue of report.issues)
168
+ console.log(`- ${describePreflightIssue(issue)}`);
169
+ throw new Error("Fix the preflight issues before retrying Conduit work");
170
+ }
150
171
  // Lane toggles only need Bridge config + optional driver ids — not a full ops.env.
151
172
  if (verb === "online" || verb === "offline") {
152
173
  const drivers = resolveDrivers(env, argv);
@@ -177,6 +198,8 @@ export async function runOps(verb, argv = [], deps = {}) {
177
198
  runBridge(["drivers", "fuel", id, "local"]);
178
199
  }
179
200
  runBridge(["drivers", "online", ...drivers]);
201
+ // Prove the exact local environment before installing a service that advertises availability.
202
+ runBridge(["ops", "doctor"]);
180
203
  if (host === "win32") {
181
204
  const runnerArgs = ["runner", "--workspace", workspace];
182
205
  if (env.CONDUIT_REPO)
@@ -0,0 +1,126 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { buildWorkspaceBrief, normalizeRepositoryUrl } from "./brief.js";
4
+ import { hasAntigravityLogin, hasClaudeLogin, hasOpenAiLogin, hasOpenCodeLogin, resolveCodexExecutable, } from "./driver.js";
5
+ import { localFuelOnlyDriver, onlineDriverIds, resolveDriverFuel } from "./drivers.js";
6
+ export const BRIDGE_PROTOCOL_VERSION = 2;
7
+ const execFileAsync = promisify(execFile);
8
+ async function defaultCommandRunner(command, args, cwd) {
9
+ try {
10
+ const result = await execFileAsync(command, args, { cwd, timeout: 15_000, windowsHide: true });
11
+ return { code: 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
12
+ }
13
+ catch (error) {
14
+ const failure = error;
15
+ const code = typeof failure.code === "number" ? failure.code : 1;
16
+ return { code, stdout: typeof failure.stdout === "string" ? failure.stdout : "", stderr: typeof failure.stderr === "string" ? failure.stderr : "" };
17
+ }
18
+ }
19
+ const DRIVER_COMMAND = {
20
+ "claude-code": "claude",
21
+ codex: "codex",
22
+ cursor: "agent",
23
+ opencode: "opencode",
24
+ kiro: "kiro-cli",
25
+ antigravity: "agy",
26
+ };
27
+ function executableFor(driver) {
28
+ return driver === "codex" ? resolveCodexExecutable() : DRIVER_COMMAND[driver] ?? driver;
29
+ }
30
+ async function localAuthenticationReady(driver, workspace, run) {
31
+ if (driver === "claude-code")
32
+ return hasClaudeLogin();
33
+ if (driver === "codex")
34
+ return hasOpenAiLogin();
35
+ if (driver === "opencode")
36
+ return hasOpenCodeLogin();
37
+ if (driver === "antigravity")
38
+ return hasAntigravityLogin();
39
+ if (driver === "cursor") {
40
+ if (process.env.CURSOR_API_KEY)
41
+ return true;
42
+ const status = await run(executableFor(driver), ["status"], workspace);
43
+ return status.code === 0 && /logged in as/i.test(`${status.stdout}\n${status.stderr}`);
44
+ }
45
+ if (driver === "kiro") {
46
+ if (process.env.KIRO_API_KEY)
47
+ return true;
48
+ const status = await run(executableFor(driver), ["whoami"], workspace);
49
+ return status.code === 0 && !/not logged in|not authenticated|login required/i.test(`${status.stdout}\n${status.stderr}`);
50
+ }
51
+ return false;
52
+ }
53
+ export async function runBridgePreflight(input, deps = {}) {
54
+ const issues = [];
55
+ const online = input.processOnlineIds?.length ? input.processOnlineIds : onlineDriverIds(input.config);
56
+ const run = deps.runCommand ?? defaultCommandRunner;
57
+ const makeBrief = deps.buildBrief ?? buildWorkspaceBrief;
58
+ let brief = input.brief;
59
+ if (!online.length)
60
+ issues.push({ code: "no_online_driver" });
61
+ try {
62
+ brief ??= await makeBrief(input.workspace);
63
+ }
64
+ catch {
65
+ issues.push({ code: "workspace_unavailable" });
66
+ }
67
+ if (brief && !brief.repository)
68
+ issues.push({ code: "workspace_repository_missing" });
69
+ if (brief?.repository && input.expectedRepository
70
+ && normalizeRepositoryUrl(brief.repository) !== normalizeRepositoryUrl(input.expectedRepository)) {
71
+ issues.push({ code: "workspace_repository_mismatch" });
72
+ }
73
+ let workspaceClean = false;
74
+ if (brief) {
75
+ const status = await run("git", ["status", "--porcelain", "--untracked-files=normal"], input.workspace);
76
+ workspaceClean = status.code === 0 && status.stdout.trim().length === 0;
77
+ if (!workspaceClean)
78
+ issues.push({ code: status.code === 0 ? "workspace_dirty" : "workspace_unavailable" });
79
+ }
80
+ const driverChecks = await Promise.all(online.map(async (driver) => {
81
+ const fuel = resolveDriverFuel(input.config, driver);
82
+ if (localFuelOnlyDriver(driver) && fuel !== "local") {
83
+ return { issue: { code: "driver_fuel_mismatch", driver }, snapshot: { id: driver, version: null, ready: false } };
84
+ }
85
+ const version = await run(executableFor(driver), ["--version"], input.workspace);
86
+ const versionText = `${version.stdout}${version.stderr}`.trim().slice(0, 200) || null;
87
+ if (version.code !== 0 || !`${version.stdout}${version.stderr}`.trim()) {
88
+ return { issue: { code: "driver_missing", driver }, snapshot: { id: driver, version: versionText, ready: false } };
89
+ }
90
+ if (fuel === "local" && !await localAuthenticationReady(driver, input.workspace, run)) {
91
+ return { issue: { code: "driver_not_authenticated", driver }, snapshot: { id: driver, version: versionText, ready: false } };
92
+ }
93
+ return { issue: null, snapshot: { id: driver, version: versionText, ready: true } };
94
+ }));
95
+ issues.push(...driverChecks.map((check) => check.issue).filter((issue) => issue !== null));
96
+ return { ready: issues.length === 0, checked_at: new Date().toISOString(), workspace_clean: workspaceClean, drivers: driverChecks.map((check) => check.snapshot), issues };
97
+ }
98
+ let cached = null;
99
+ /** Keep auth probes off the 15-second heartbeat hot path while still expiring readiness promptly. */
100
+ export async function cachedBridgePreflight(input) {
101
+ const online = input.processOnlineIds?.length ? input.processOnlineIds : onlineDriverIds(input.config);
102
+ const key = JSON.stringify([input.workspace, input.expectedRepository ?? "", online, input.config.fuelSource, input.config.drivers]);
103
+ if (cached?.key === key && Date.now() - cached.at < 5 * 60_000)
104
+ return cached.report;
105
+ const report = await runBridgePreflight(input);
106
+ cached = { key, at: Date.now(), report };
107
+ return report;
108
+ }
109
+ export function describePreflightIssue(issue) {
110
+ const lane = issue.driver ? ` (${issue.driver})` : "";
111
+ if (issue.code === "no_online_driver")
112
+ return "No agent lane is online";
113
+ if (issue.code === "workspace_unavailable")
114
+ return "Workspace cannot be read";
115
+ if (issue.code === "workspace_repository_missing")
116
+ return "Workspace has no origin repository";
117
+ if (issue.code === "workspace_repository_mismatch")
118
+ return "Workspace origin does not match CONDUIT_REPO";
119
+ if (issue.code === "workspace_dirty")
120
+ return "Workspace has uncommitted or untracked changes";
121
+ if (issue.code === "driver_missing")
122
+ return `Agent CLI is missing from PATH${lane}`;
123
+ if (issue.code === "driver_not_authenticated")
124
+ return `Agent CLI is not logged in${lane}`;
125
+ return `Agent lane requires local fuel${lane}`;
126
+ }
package/ops/conduit-ops CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bash
2
- # Usage: ./conduit-ops <connect|install|online|offline|status|disconnect|uninstall> [args…]
2
+ # Usage: ./conduit-ops <connect|install|online|offline|status|doctor|disconnect|uninstall> [args…]
3
3
  set -euo pipefail
4
4
  if [[ $# -lt 1 ]]; then
5
- echo "Usage: $0 <connect|install|online|offline|status|disconnect|uninstall> [args…]" >&2
5
+ echo "Usage: $0 <connect|install|online|offline|status|doctor|disconnect|uninstall> [args…]" >&2
6
6
  exit 1
7
7
  fi
8
8
  verb=$1
@@ -1,7 +1,7 @@
1
1
  @echo off
2
- REM Usage: conduit-ops.cmd <connect|install|online|offline|status|disconnect|uninstall> [args…]
2
+ REM Usage: conduit-ops.cmd <connect|install|online|offline|status|doctor|disconnect|uninstall> [args…]
3
3
  if "%~1"=="" (
4
- echo Usage: %~nx0 ^<connect^|install^|online^|offline^|status^|disconnect^|uninstall^> [args…]
4
+ echo Usage: %~nx0 ^<connect^|install^|online^|offline^|status^|doctor^|disconnect^|uninstall^> [args…]
5
5
  exit /b 1
6
6
  )
7
7
  npx --yes @miraland-labs/conduit-bridge@latest ops %*
package/ops/doctor.cmd ADDED
@@ -0,0 +1,3 @@
1
+ @echo off
2
+ npx --yes @miraland-labs/conduit-bridge@latest ops doctor %*
3
+ if errorlevel 1 exit /b %errorlevel%
package/ops/doctor.sh ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ exec npx --yes @miraland-labs/conduit-bridge@latest ops doctor "$@"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {