@miraland-labs/conduit-bridge 0.4.1 → 0.7.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
@@ -38,6 +38,8 @@ Persist (macOS/Linux):
38
38
  npx @miraland-labs/conduit-bridge install-service --agent <claude-code|codex|cursor|opencode|kiro|antigravity> --workspace /path/to/repo
39
39
  ```
40
40
 
41
+ 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
+
41
43
  ## Grant enforcement
42
44
 
43
45
  Every driver enforces the assignment's granted actions with its CLI's native mechanism — never just the prompt:
@@ -47,7 +49,7 @@ Every driver enforces the assignment's granted actions with its CLI's native mec
47
49
  | `claude-code` | pump or local | `--allowedTools` / `--disallowedTools` per grant; bounded verification commands |
48
50
  | `codex` | pump or local | sandbox `read-only` / `workspace-write`; network enabled only with `pr_create`; resumes by thread id |
49
51
  | `opencode` | pump or local | `plan`/`build` agents; per-run `opencode.json` bash deny list (shared `deniedCommands`); `--session` resume; `--format json` |
50
- | `cursor` | local only | per-run `.cursor/cli.json` allowlist (`approvalMode: allowlist`, shared deny list, no `--force`); read-only runs in `--mode plan` |
52
+ | `cursor` | local only | per-run `.cursor/cli.json` `permissions` allow/deny (no `--force`); read-only runs in `--mode plan` |
51
53
  | `kiro` | local only | `--trust-tools=fs_read,fs_write,execute_bash` allowlist; `--no-interactive`; `--resume-id`. Tool-level only — no per-command scoping, so a bash grant trusts `execute_bash` wholesale (Antigravity-tier) |
52
54
  | `antigravity` | local only | `-p --mode plan` (read-only) / `--mode accept-edits --sandbox` (write). Mode-level, coarser than per-tool; plain-text output; resume not surfaced |
53
55
 
@@ -0,0 +1,104 @@
1
+ /**
2
+ * F-07 slice 1: one Git worktree per attempt. The install-service source checkout is never the
3
+ * agent cwd; retries must not inherit dirty files from a failed run.
4
+ */
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";
8
+ import { execFile } from "node:child_process";
9
+ import { promisify } from "node:util";
10
+ const execFileAsync = promisify(execFile);
11
+ export function attemptWorktreePath(sourceWorkspace, attemptId) {
12
+ return join(sourceWorkspace, ".conduit", "attempts", attemptId);
13
+ }
14
+ export async function ensureConduitExclude(sourceWorkspace) {
15
+ const excludePath = join(sourceWorkspace, ".git", "info", "exclude");
16
+ await mkdir(join(sourceWorkspace, ".git", "info"), { recursive: true });
17
+ let existing = "";
18
+ try {
19
+ existing = await readFile(excludePath, "utf8");
20
+ }
21
+ catch {
22
+ existing = "";
23
+ }
24
+ if (existing.split("\n").some((line) => line.trim() === ".conduit/" || line.trim() === ".conduit"))
25
+ return;
26
+ const prefix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
27
+ await appendFile(excludePath, `${prefix}# Conduit Bridge attempt worktrees (F-07)\n.conduit/\n`, "utf8");
28
+ }
29
+ export async function assertSourceWorkspaceClean(sourceWorkspace) {
30
+ const { stdout } = await execFileAsync("git", ["-C", sourceWorkspace, "status", "--porcelain"], {
31
+ timeout: 30_000,
32
+ maxBuffer: 2_000_000,
33
+ });
34
+ if (stdout.trim().length > 0) {
35
+ throw new Error("source_workspace_dirty");
36
+ }
37
+ }
38
+ export async function createAttemptWorktree(input) {
39
+ await ensureConduitExclude(input.sourceWorkspace);
40
+ await assertSourceWorkspaceClean(input.sourceWorkspace);
41
+ const path = attemptWorktreePath(input.sourceWorkspace, input.attemptId);
42
+ 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 });
44
+ return path;
45
+ }
46
+ export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
47
+ try {
48
+ await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "remove", "--force", worktreePath], { timeout: 60_000, maxBuffer: 2_000_000 });
49
+ }
50
+ catch {
51
+ await rm(worktreePath, { recursive: true, force: true }).catch(() => undefined);
52
+ await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "prune"], {
53
+ timeout: 30_000,
54
+ maxBuffer: 1_000_000,
55
+ }).catch(() => undefined);
56
+ }
57
+ }
58
+ /**
59
+ * F-07 safe resume: prove the interrupted attempt still owns this worktree path.
60
+ * Does not check session identity — caller requires config.sessions[taskId] separately.
61
+ */
62
+ export async function proveResumeWorktree(sourceWorkspace, attemptId, worktreePath) {
63
+ if (!worktreePath?.trim())
64
+ return false;
65
+ const expected = attemptWorktreePath(sourceWorkspace, attemptId);
66
+ if (resolve(worktreePath) !== resolve(expected))
67
+ return false;
68
+ try {
69
+ await access(worktreePath, constants.F_OK);
70
+ const { stdout } = await execFileAsync("git", ["-C", worktreePath, "rev-parse", "HEAD"], {
71
+ timeout: 15_000,
72
+ maxBuffer: 1_000_000,
73
+ });
74
+ return /^[0-9a-f]{40,64}$/i.test(stdout.trim());
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ /** Hold a crashed attempt tree for local diagnosis; never reused as agent cwd. */
81
+ export async function quarantineAttemptWorktree(sourceWorkspace, worktreePath, attemptId) {
82
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
83
+ const dest = join(sourceWorkspace, ".conduit", "quarantine", `${attemptId}-${stamp}`);
84
+ await mkdir(join(sourceWorkspace, ".conduit", "quarantine"), { recursive: true });
85
+ try {
86
+ await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "remove", "--force", worktreePath], { timeout: 60_000, maxBuffer: 2_000_000 });
87
+ return null;
88
+ }
89
+ catch {
90
+ try {
91
+ await rename(worktreePath, dest);
92
+ await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "prune"], {
93
+ timeout: 30_000,
94
+ maxBuffer: 1_000_000,
95
+ }).catch(() => undefined);
96
+ await writeFile(join(dest, ".conduit-quarantine.json"), `${JSON.stringify({ attemptId, at: stamp })}\n`, "utf8");
97
+ return dest;
98
+ }
99
+ catch {
100
+ await removeAttemptWorktree(sourceWorkspace, worktreePath);
101
+ return null;
102
+ }
103
+ }
104
+ }
package/dist/brief.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ const execFileAsync = promisify(execFile);
3
6
  const MANIFESTS = ["package.json", "wrangler.jsonc", "wrangler.toml", "tsconfig.json", "Cargo.toml", "pyproject.toml", "go.mod", "Makefile"];
4
7
  const VERIFICATION_SCRIPTS = ["verify", "typecheck", "lint", "test", "build"];
5
8
  const EXCLUDED_DIRECTORIES = new Set(["node_modules", "dist", "build", "target", "coverage"]);
@@ -25,6 +28,21 @@ export function normalizeRepositoryUrl(url) {
25
28
  value = `https://${ssh[1]}/${ssh[2]}`;
26
29
  return value.replace(/^https?:\/\//, "").replace(/^ssh:\/\/(git@)?/, "");
27
30
  }
31
+ export async function isBaseCommitAncestor(workspace, baseCommit, headCommit) {
32
+ try {
33
+ await execFileAsync("git", ["-C", workspace, "merge-base", "--is-ancestor", baseCommit, headCommit], {
34
+ timeout: 10_000,
35
+ windowsHide: true,
36
+ });
37
+ return true;
38
+ }
39
+ catch (error) {
40
+ const code = error.code;
41
+ if (code === 1 || code === "1")
42
+ return false;
43
+ throw error;
44
+ }
45
+ }
28
46
  async function gitRemoteUrl(workspace) {
29
47
  try {
30
48
  const { common } = await gitDirectories(workspace);
@@ -39,20 +57,28 @@ async function gitRemoteUrl(workspace) {
39
57
  async function gitHeadCommit(workspace) {
40
58
  try {
41
59
  const { worktree, common } = await gitDirectories(workspace);
42
- const head = (await readFile(join(worktree, "HEAD"), "utf8")).trim();
43
- if (/^[0-9a-f]{40,64}$/i.test(head))
44
- return head;
45
- const ref = head.match(/^ref:\s*(\S+)$/)?.[1];
46
- if (!ref)
47
- return null;
48
- try {
49
- return (await readFile(join(common, ref), "utf8")).trim();
50
- }
51
- catch {
52
- const packed = await readFile(join(common, "packed-refs"), "utf8");
53
- const line = packed.split("\n").find((entry) => entry.endsWith(` ${ref}`));
54
- return line?.split(" ")[0] ?? null;
60
+ let head = String(await readFile(join(worktree, "HEAD"), "utf8")).trim();
61
+ // Follow one level of symbolic refs (HEAD → refs/heads/main, or a nested ref file).
62
+ for (let hop = 0; hop < 3; hop++) {
63
+ if (/^[0-9a-f]{40,64}$/i.test(head))
64
+ return head.toLowerCase();
65
+ const ref = head.match(/^ref:\s*(\S+)$/)?.[1];
66
+ if (!ref)
67
+ return null;
68
+ try {
69
+ head = String(await readFile(join(common, ref), "utf8")).trim();
70
+ }
71
+ catch {
72
+ const packed = String(await readFile(join(common, "packed-refs"), "utf8"));
73
+ const line = packed.split("\n").find((entry) => {
74
+ const trimmed = entry.trim();
75
+ return trimmed.length > 0 && !trimmed.startsWith("#") && !trimmed.startsWith("^") && trimmed.endsWith(` ${ref}`);
76
+ });
77
+ const sha = line?.split(/\s+/)[0]?.trim() ?? null;
78
+ return sha && /^[0-9a-f]{40,64}$/i.test(sha) ? sha.toLowerCase() : null;
79
+ }
55
80
  }
81
+ return null;
56
82
  }
57
83
  catch {
58
84
  return null;
@@ -73,13 +99,22 @@ async function gitDirectories(workspace) {
73
99
  }
74
100
  }
75
101
  async function verificationCommands(workspace, files) {
76
- if (!files.has("package.json"))
77
- return [];
78
- try {
79
- const manifest = JSON.parse(await readFile(join(workspace, "package.json"), "utf8"));
80
- return VERIFICATION_SCRIPTS.filter((name) => manifest.scripts?.[name]).map((name) => `npm run ${name}`);
102
+ const commands = [];
103
+ if (files.has("package.json")) {
104
+ try {
105
+ const manifest = JSON.parse(await readFile(join(workspace, "package.json"), "utf8"));
106
+ commands.push(...VERIFICATION_SCRIPTS.filter((name) => manifest.scripts?.[name]).map((name) => `npm run ${name}`));
107
+ }
108
+ catch { /* malformed manifests do not broaden execution */ }
81
109
  }
82
- catch {
83
- return [];
110
+ if (files.has("Makefile")) {
111
+ try {
112
+ const makefile = await readFile(join(workspace, "Makefile"), "utf8");
113
+ for (const target of ["check", "test"])
114
+ if (new RegExp(`^${target}\\s*:`, "m").test(makefile))
115
+ commands.push(`make ${target}`);
116
+ }
117
+ catch { /* unreadable Makefiles do not broaden execution */ }
84
118
  }
119
+ return commands;
85
120
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Explicit Bridge checkout provisioning (F-07 ensure-checkout).
3
+ *
4
+ * Clones a remote into --workspace when the path is absent, using the operator machine's
5
+ * existing Git credentials (SSH agent / credential helper). Never uses the control-plane
6
+ * GitHub PAT. Does not overwrite an existing checkout.
7
+ */
8
+ import { access, mkdir, readdir } from "node:fs/promises";
9
+ import { constants } from "node:fs";
10
+ import { dirname } from "node:path";
11
+ import { execFile } from "node:child_process";
12
+ import { promisify } from "node:util";
13
+ import { normalizeRepositoryUrl } from "./brief.js";
14
+ const execFileAsync = promisify(execFile);
15
+ async function pathExists(path, accessFn) {
16
+ try {
17
+ await accessFn(path, constants.F_OK);
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ async function gitOriginUrl(workspace, exec) {
25
+ try {
26
+ const { stdout } = await exec("git", ["-C", workspace, "config", "--get", "remote.origin.url"], {
27
+ timeout: 15_000,
28
+ maxBuffer: 1_000_000,
29
+ });
30
+ const value = stdout.trim();
31
+ return value.length > 0 ? value : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ /**
38
+ * Ensure `workspace` is a checkout of `repositoryUrl`.
39
+ *
40
+ * - Missing path → `git clone` into that path (parent dirs created).
41
+ * - Existing matching origin → no-op.
42
+ * - Existing mismatched or non-git path → throw (never overwrite).
43
+ */
44
+ export async function ensureCheckout(workspace, repositoryUrl, deps = {}) {
45
+ const exec = deps.execFile ?? execFileAsync;
46
+ const accessFn = deps.access ?? access;
47
+ const mkdirFn = deps.mkdir ?? mkdir;
48
+ const readdirFn = deps.readdir ?? readdir;
49
+ const wanted = normalizeRepositoryUrl(repositoryUrl);
50
+ if (!wanted)
51
+ throw new Error("ensure_checkout_invalid_url");
52
+ const exists = await pathExists(workspace, accessFn);
53
+ if (!exists) {
54
+ await mkdirFn(dirname(workspace), { recursive: true });
55
+ try {
56
+ await exec("git", ["clone", "--", repositoryUrl, workspace], {
57
+ timeout: 300_000,
58
+ maxBuffer: 2_000_000,
59
+ });
60
+ }
61
+ catch (error) {
62
+ const detail = error instanceof Error ? error.message : "clone_failed";
63
+ throw new Error(`ensure_checkout_clone_failed:${detail}`);
64
+ }
65
+ const origin = await gitOriginUrl(workspace, exec);
66
+ if (!origin || normalizeRepositoryUrl(origin) !== wanted) {
67
+ throw new Error("ensure_checkout_clone_mismatch");
68
+ }
69
+ return "cloned";
70
+ }
71
+ const entries = await readdirFn(workspace).catch(() => null);
72
+ if (entries === null)
73
+ throw new Error("ensure_checkout_path_unreadable");
74
+ if (entries.length === 0) {
75
+ try {
76
+ await exec("git", ["clone", "--", repositoryUrl, workspace], {
77
+ timeout: 300_000,
78
+ maxBuffer: 2_000_000,
79
+ });
80
+ }
81
+ catch (error) {
82
+ const detail = error instanceof Error ? error.message : "clone_failed";
83
+ throw new Error(`ensure_checkout_clone_failed:${detail}`);
84
+ }
85
+ return "cloned";
86
+ }
87
+ const origin = await gitOriginUrl(workspace, exec);
88
+ if (!origin) {
89
+ throw new Error("ensure_checkout_path_occupied");
90
+ }
91
+ if (normalizeRepositoryUrl(origin) !== wanted) {
92
+ throw new Error(`ensure_checkout_origin_mismatch:${normalizeRepositoryUrl(origin)}`);
93
+ }
94
+ return "already_present";
95
+ }
package/dist/cli.js CHANGED
@@ -1,17 +1,30 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
- import { resolve } from "node:path";
3
+ import { resolve, dirname, join as pathJoin } from "node:path";
4
4
  import { hostname, userInfo } from "node:os";
5
5
  import { spawn } from "node:child_process";
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
6
8
  import { ConduitClient } from "./client.js";
7
- import { 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";
8
10
  import { runMcp } from "./mcp.js";
9
- import { detectInstalledClients } from "./detect.js";
11
+ import { detectInstalledClients, localFuelOnlyClients, suggestFuelSource } from "./detect.js";
10
12
  import { DRIVERS } from "./driver.js";
11
13
  import { buildWorkspaceBrief } from "./brief.js";
12
- import { executeNextAssignment, renewLeases } from "./execution.js";
14
+ import { ensureCheckout } from "./checkout.js";
15
+ import { pumpExecutionSlots, renewLeases } from "./execution.js";
13
16
  import { installRunnerService, uninstallRunnerService } from "./service.js";
14
17
  const [command] = process.argv.slice(2);
18
+ /** Read our own package version so every runner start logs exactly which build is live. */
19
+ function bridgeVersion() {
20
+ try {
21
+ const packagePath = pathJoin(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
22
+ return JSON.parse(readFileSync(packagePath, "utf8")).version ?? "unknown";
23
+ }
24
+ catch {
25
+ return "unknown";
26
+ }
27
+ }
15
28
  /** Install-free form shown in help/output so clean laptops never need `conduit` on PATH. */
16
29
  const BRIDGE_NPX = "npx @miraland-labs/conduit-bridge";
17
30
  function bridgeUsage(...args) {
@@ -78,15 +91,20 @@ async function join() {
78
91
  const operatorName = values.operator?.trim() || userInfo().username;
79
92
  const machineName = values.machine?.trim() || suggestMachineName(hostname(), installationId);
80
93
  const capabilities = values.capability?.map((item) => item.trim()).filter(Boolean) ?? ["implement"];
81
- const leaseCapacity = values.capacity ? Number(values.capacity) : 1;
94
+ const requestedCapacity = values.capacity ? Number(values.capacity) : BRIDGE_LEASE_CAPACITY;
95
+ if (!Number.isInteger(requestedCapacity) || requestedCapacity < 1 || requestedCapacity > BRIDGE_MAX_LEASE_CAPACITY) {
96
+ throw new Error(`--capacity must be an integer from 1 to ${BRIDGE_MAX_LEASE_CAPACITY} (concurrent attempt slots)`);
97
+ }
98
+ const leaseCapacity = clampLeaseCapacity(requestedCapacity);
82
99
  console.log("\nRequesting a Conduit connection:");
83
100
  console.log(`Operator: ${operatorName}`);
84
101
  console.log(`Computer: ${machineName}`);
102
+ const plannedFuel = fuelSource ?? suggestFuelSource(detected);
85
103
  console.log(`Detected local clients: ${detected.join(", ") || "none"}`);
86
104
  console.log(`Proposed roles: ${capabilities.join(", ")}`);
87
105
  console.log(`Assignments at once: ${leaseCapacity}`);
88
- if (fuelSource)
89
- console.log(`Fuel source: ${fuelSource === "local" ? "local subscription" : "Conduit pump"}`);
106
+ console.log(`Fuel source: ${plannedFuel === "local" ? "local subscription" : "Conduit pump"}`
107
+ + (!fuelSource && plannedFuel === "local" ? " (auto: only Cursor/Kiro/Antigravity detected)" : ""));
90
108
  console.log(`Execution drivers: ${Object.keys(DRIVERS).join(", ")} (cursor/kiro/antigravity require local fuel).`);
91
109
  console.log("Use --machine <name> to override the computer label.\n");
92
110
  const response = await fetch(`${baseUrl}/runner/v1/connect/requests`, {
@@ -121,6 +139,7 @@ async function join() {
121
139
  console.log("Waiting for a project owner to approve. You may close this terminal and run `conduit join --resume` later.\n");
122
140
  if (!values["no-open"])
123
141
  openUrl(pending.verificationUrl);
142
+ // Explicit --fuel wins; otherwise finishConnection re-detects and may default to local.
124
143
  await waitForConnection(pending, fuelSource);
125
144
  }
126
145
  function normalizeBaseUrl(value) {
@@ -165,6 +184,9 @@ async function finishConnection(baseUrl, data, fuelSource) {
165
184
  if (row.project_id && row.gateway_secret)
166
185
  fuel[row.project_id] = { gatewayKey: row.gateway_secret };
167
186
  }
187
+ const detected = await detectInstalledClients();
188
+ const resolvedFuel = fuelSource ?? suggestFuelSource(detected);
189
+ const fuelAutoLocal = fuelSource === undefined && resolvedFuel === "local";
168
190
  const config = {
169
191
  baseUrl,
170
192
  organizationId: String(data.organization_id),
@@ -172,9 +194,9 @@ async function finishConnection(baseUrl, data, fuelSource) {
172
194
  runnerKey: String(data.runner_secret),
173
195
  capabilities: data.capabilities,
174
196
  grants: data.grants,
175
- leaseCapacity: Number(data.lease_capacity),
197
+ leaseCapacity: clampLeaseCapacity(data.lease_capacity),
176
198
  activeAttempts: {},
177
- fuelSource: fuelSource ?? "conduit",
199
+ fuelSource: resolvedFuel,
178
200
  ...(Object.keys(fuel).length ? { fuel } : {}),
179
201
  };
180
202
  await saveConfig(config);
@@ -186,13 +208,17 @@ async function finishConnection(baseUrl, data, fuelSource) {
186
208
  catch {
187
209
  heartbeatOk = false;
188
210
  }
189
- const detected = await detectInstalledClients();
190
211
  console.log(`Connected machine: ${config.machineId}`);
191
212
  console.log(heartbeatOk ? "Runner credential and heartbeat: OK" : "Runner credential saved; heartbeat will retry when the runner starts");
192
213
  console.log(`Detected local clients: ${detected.join(", ") || "none"}`);
193
214
  console.log(`Confirmed capabilities (matching): ${config.capabilities.join(", ") || "none"}`);
194
215
  console.log(`Allowed actions: ${config.grants.join(", ") || "none"}`);
195
- console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit pump"}`);
216
+ console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit pump"}`
217
+ + (fuelAutoLocal ? " (auto: only Cursor/Kiro/Antigravity detected)" : ""));
218
+ const localOnly = localFuelOnlyClients(detected);
219
+ 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")}`);
221
+ }
196
222
  console.log("Heartbeats report capabilities for diagnostics only; matching uses the Connect confirmation. Detected clients never receive grants automatically.");
197
223
  console.log(`MCP setup: {"mcpServers":{"conduit":{"command":"npx","args":["-y","@miraland-labs/conduit-bridge","mcp"]}}}`);
198
224
  console.log(`Execute work: ${bridgeUsage("runner", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
@@ -223,18 +249,27 @@ async function installService() {
223
249
  await loadConfig();
224
250
  const { values } = parseArgs({ args: process.argv.slice(3), options: {
225
251
  agent: { type: "string" }, workspace: { type: "string" }, interval: { type: "string" }, "agent-timeout-minutes": { type: "string" },
252
+ "ensure-checkout": { type: "string" },
226
253
  } });
227
254
  if (!values.agent || !values.workspace) {
228
- throw new Error(`Usage: ${bridgeUsage("install-service", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repository-path>")} (agent + workspace required; heartbeat-only services are not supported)`);
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)`);
229
256
  }
230
257
  if (!DRIVERS[values.agent]) {
231
258
  throw new Error(`Unknown agent driver: ${values.agent}. Available: ${Object.keys(DRIVERS).join(", ")}`);
232
259
  }
260
+ const workspace = resolve(values.workspace);
261
+ if (values["ensure-checkout"]) {
262
+ const result = await ensureCheckout(workspace, values["ensure-checkout"]);
263
+ console.log(result === "cloned"
264
+ ? `Cloned ${values["ensure-checkout"]} into ${workspace}`
265
+ : `Workspace already matches ${values["ensure-checkout"]}`);
266
+ }
233
267
  const result = await installRunnerService({
234
268
  agent: values.agent,
235
- workspace: resolve(values.workspace),
269
+ workspace,
236
270
  interval: values.interval,
237
271
  agentTimeoutMinutes: values["agent-timeout-minutes"],
272
+ ensureCheckout: values["ensure-checkout"],
238
273
  });
239
274
  console.log(`Installed Conduit runner service (${result.platform}): ${result.path}`);
240
275
  console.log("The runner keeps executing after this terminal closes. Laptop sleep still pauses work.");
@@ -258,7 +293,7 @@ async function fuelCommand() {
258
293
  async function runner() {
259
294
  const { values } = parseArgs({ args: process.argv.slice(3), options: {
260
295
  agent: { type: "string" }, workspace: { type: "string" }, interval: { type: "string" }, once: { type: "boolean" },
261
- "agent-timeout-minutes": { type: "string" }, fuel: { type: "string" },
296
+ "agent-timeout-minutes": { type: "string" }, fuel: { type: "string" }, "ensure-checkout": { type: "string" },
262
297
  } });
263
298
  const config = await loadConfig();
264
299
  const fuelOverride = parseFuelSource(values.fuel);
@@ -275,7 +310,16 @@ async function runner() {
275
310
  if (!values.workspace)
276
311
  throw new Error("--workspace <repository-path> is required with --agent");
277
312
  }
313
+ if (values["ensure-checkout"] && !values.workspace) {
314
+ throw new Error("--ensure-checkout requires --workspace <repository-path>");
315
+ }
278
316
  const workspace = values.workspace ? resolve(values.workspace) : null;
317
+ if (workspace && values["ensure-checkout"]) {
318
+ const result = await ensureCheckout(workspace, values["ensure-checkout"]);
319
+ console.log(result === "cloned"
320
+ ? `Cloned ${values["ensure-checkout"]} into ${workspace}`
321
+ : `Workspace already matches ${values["ensure-checkout"]}`);
322
+ }
279
323
  const brief = workspace ? await buildWorkspaceBrief(workspace) : null;
280
324
  const intervalMs = values.interval ? Math.max(5_000, Number(values.interval)) : 15_000;
281
325
  const timeoutMs = values["agent-timeout-minutes"] ? Number(values["agent-timeout-minutes"]) * 60_000 : undefined;
@@ -283,21 +327,28 @@ async function runner() {
283
327
  if (!driver || !workspace) {
284
328
  console.warn(`WARNING: heartbeat only — no work will execute. Use: ${bridgeUsage("runner", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
285
329
  }
286
- console.log(`Conduit runner connected to ${config.baseUrl}${driver ? ` — executing via ${driver.name} in ${workspace}` : " — heartbeat only (no --agent)"} (fuel: ${fuelLabel})`);
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})`);
331
+ const running = new Map();
287
332
  for (;;) {
288
- let executed = false;
333
+ let progressed = false;
289
334
  try {
290
335
  await heartbeat(client, config, workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null);
291
336
  await renewLeases(client, config);
292
337
  if (driver && workspace) {
293
- executed = await executeNextAssignment(client, config, driver, workspace, brief, timeoutMs);
338
+ progressed = await pumpExecutionSlots(client, config, driver, workspace, brief, timeoutMs, {
339
+ heartbeat: async () => heartbeat(client, config, await buildWorkspaceBrief(workspace).catch(() => brief)),
340
+ heartbeatIntervalMs: intervalMs,
341
+ }, running);
294
342
  }
295
343
  }
296
344
  catch (error) {
297
345
  console.error(`Runner cycle failed; retrying: ${redactSecrets(error instanceof Error ? error.message : "unknown error")}`);
298
346
  }
299
- if (values.once && executed)
347
+ if (values.once && progressed) {
348
+ if (running.size)
349
+ await Promise.allSettled([...running.values()]);
300
350
  return;
351
+ }
301
352
  await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
302
353
  }
303
354
  }
package/dist/client.js CHANGED
@@ -44,7 +44,13 @@ export class ConduitClient {
44
44
  }
45
45
  async updateAttempt(taskId, patch) {
46
46
  const active = this.attempt(taskId);
47
- Object.assign(active, patch);
47
+ for (const key of Object.keys(patch)) {
48
+ const value = patch[key];
49
+ if (value === undefined)
50
+ delete active[key];
51
+ else
52
+ Object.assign(active, { [key]: value });
53
+ }
48
54
  await this.persist(this.config);
49
55
  return active;
50
56
  }
package/dist/config.js CHANGED
@@ -1,8 +1,20 @@
1
1
  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
- import { join } from "node:path";
5
- const directory = join(homedir(), ".config", "conduit");
4
+ import { join, resolve } from "node:path";
5
+ /** Default when join omits --capacity. */
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
+ }
15
+ const directory = process.env.CONDUIT_BRIDGE_CONFIG_DIR?.trim()
16
+ ? resolve(process.env.CONDUIT_BRIDGE_CONFIG_DIR)
17
+ : join(homedir(), ".config", "conduit");
6
18
  const path = join(directory, "config.json");
7
19
  const pendingPath = join(directory, "pending-connect.json");
8
20
  const installationPath = join(directory, "installation.json");
@@ -36,6 +48,8 @@ export async function loadConfigIfPresent() {
36
48
  active.phase ??= "agent_running";
37
49
  if (config.fuelSource !== "local" && config.fuelSource !== "conduit")
38
50
  config.fuelSource = "conduit";
51
+ // Clamp to what concurrent slot workers can run (1..BRIDGE_MAX_LEASE_CAPACITY).
52
+ config.leaseCapacity = clampLeaseCapacity(config.leaseCapacity ?? BRIDGE_LEASE_CAPACITY);
39
53
  return config;
40
54
  }
41
55
  export async function saveConfig(config) {
package/dist/detect.js CHANGED
@@ -11,6 +11,24 @@ const CLIENTS = [
11
11
  { command: "agy", label: "Antigravity" },
12
12
  { command: "code", label: "Visual Studio Code" },
13
13
  ];
14
+ /** Agents Conduit /v1 pump fuel can drive today. */
15
+ const PUMP_CAPABLE_LABELS = new Set(["Claude Code", "Codex CLI", "OpenCode"]);
16
+ /** Agents that only accept vendor login — Bridge refuses Conduit pump for these drivers. */
17
+ const LOCAL_FUEL_ONLY_LABELS = new Set(["Cursor Agent", "Cursor", "Kiro CLI", "Antigravity"]);
18
+ export function localFuelOnlyClients(detected) {
19
+ return detected.filter((label) => LOCAL_FUEL_ONLY_LABELS.has(label));
20
+ }
21
+ /**
22
+ * Default join fuel when `--fuel` is omitted.
23
+ * Pump when any pump-capable client is present; local when only Cursor/Kiro/Antigravity are.
24
+ */
25
+ export function suggestFuelSource(detected) {
26
+ const hasPumpCapable = detected.some((label) => PUMP_CAPABLE_LABELS.has(label));
27
+ const hasLocalOnly = localFuelOnlyClients(detected).length > 0;
28
+ if (!hasPumpCapable && hasLocalOnly)
29
+ return "local";
30
+ return "conduit";
31
+ }
14
32
  export async function detectInstalledClients(pathValue = process.env.PATH ?? "", platform = process.platform) {
15
33
  const directories = pathValue.split(delimiter).filter(Boolean);
16
34
  const extensions = platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];