@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 +13 -1
- package/dist/cli.js +14 -4
- package/dist/driver.js +13 -5
- package/dist/execution.js +16 -9
- package/dist/ops.js +24 -1
- package/dist/preflight.js +126 -0
- package/ops/conduit-ops +2 -2
- package/ops/conduit-ops.cmd +2 -2
- package/ops/doctor.cmd +3 -0
- package/ops/doctor.sh +3 -0
- package/package.json +1 -1
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
|
-
**
|
|
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
|
-
|
|
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 () =>
|
|
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
|
|
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.
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
486
|
-
//
|
|
487
|
-
|
|
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
|
package/ops/conduit-ops.cmd
CHANGED
|
@@ -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
package/ops/doctor.sh
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraland-labs/conduit-bridge",
|
|
3
|
-
"version": "0.9.
|
|
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": {
|