@miraland-labs/conduit-bridge 0.4.0 → 0.6.1

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:
@@ -46,8 +48,8 @@ Every driver enforces the assignment's granted actions with its CLI's native mec
46
48
  | --- | --- | --- |
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
- | `opencode` | pump or local | `plan` agent (no edit/bash) for read-only, `build` for write; `--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` |
51
+ | `opencode` | pump or local | `plan`/`build` agents; per-run `opencode.json` bash deny list (shared `deniedCommands`); `--session` resume; `--format json` |
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,81 @@
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 { appendFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ const execFileAsync = promisify(execFile);
10
+ export function attemptWorktreePath(sourceWorkspace, attemptId) {
11
+ return join(sourceWorkspace, ".conduit", "attempts", attemptId);
12
+ }
13
+ export async function ensureConduitExclude(sourceWorkspace) {
14
+ const excludePath = join(sourceWorkspace, ".git", "info", "exclude");
15
+ await mkdir(join(sourceWorkspace, ".git", "info"), { recursive: true });
16
+ let existing = "";
17
+ try {
18
+ existing = await readFile(excludePath, "utf8");
19
+ }
20
+ catch {
21
+ existing = "";
22
+ }
23
+ if (existing.split("\n").some((line) => line.trim() === ".conduit/" || line.trim() === ".conduit"))
24
+ return;
25
+ const prefix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
26
+ await appendFile(excludePath, `${prefix}# Conduit Bridge attempt worktrees (F-07)\n.conduit/\n`, "utf8");
27
+ }
28
+ export async function assertSourceWorkspaceClean(sourceWorkspace) {
29
+ const { stdout } = await execFileAsync("git", ["-C", sourceWorkspace, "status", "--porcelain"], {
30
+ timeout: 30_000,
31
+ maxBuffer: 2_000_000,
32
+ });
33
+ if (stdout.trim().length > 0) {
34
+ throw new Error("source_workspace_dirty");
35
+ }
36
+ }
37
+ export async function createAttemptWorktree(input) {
38
+ await ensureConduitExclude(input.sourceWorkspace);
39
+ await assertSourceWorkspaceClean(input.sourceWorkspace);
40
+ const path = attemptWorktreePath(input.sourceWorkspace, input.attemptId);
41
+ await mkdir(join(input.sourceWorkspace, ".conduit", "attempts"), { recursive: true });
42
+ await execFileAsync("git", ["-C", input.sourceWorkspace, "worktree", "add", "--detach", path, input.startCommit], { timeout: 60_000, maxBuffer: 2_000_000 });
43
+ return path;
44
+ }
45
+ export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
46
+ try {
47
+ await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "remove", "--force", worktreePath], { timeout: 60_000, maxBuffer: 2_000_000 });
48
+ }
49
+ catch {
50
+ await rm(worktreePath, { recursive: true, force: true }).catch(() => undefined);
51
+ await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "prune"], {
52
+ timeout: 30_000,
53
+ maxBuffer: 1_000_000,
54
+ }).catch(() => undefined);
55
+ }
56
+ }
57
+ /** Hold a crashed attempt tree for local diagnosis; never reused as agent cwd. */
58
+ export async function quarantineAttemptWorktree(sourceWorkspace, worktreePath, attemptId) {
59
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
60
+ const dest = join(sourceWorkspace, ".conduit", "quarantine", `${attemptId}-${stamp}`);
61
+ await mkdir(join(sourceWorkspace, ".conduit", "quarantine"), { recursive: true });
62
+ try {
63
+ await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "remove", "--force", worktreePath], { timeout: 60_000, maxBuffer: 2_000_000 });
64
+ return null;
65
+ }
66
+ catch {
67
+ try {
68
+ await rename(worktreePath, dest);
69
+ await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "prune"], {
70
+ timeout: 30_000,
71
+ maxBuffer: 1_000_000,
72
+ }).catch(() => undefined);
73
+ await writeFile(join(dest, ".conduit-quarantine.json"), `${JSON.stringify({ attemptId, at: stamp })}\n`, "utf8");
74
+ return dest;
75
+ }
76
+ catch {
77
+ await removeAttemptWorktree(sourceWorkspace, worktreePath);
78
+ return null;
79
+ }
80
+ }
81
+ }
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, 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";
14
+ import { ensureCheckout } from "./checkout.js";
12
15
  import { executeNextAssignment, 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,19 @@ 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
+ if (values.capacity && Number(values.capacity) !== BRIDGE_LEASE_CAPACITY) {
95
+ throw new Error("Conduit Bridge currently runs one assignment at a time; --capacity must be 1");
96
+ }
97
+ const leaseCapacity = BRIDGE_LEASE_CAPACITY;
82
98
  console.log("\nRequesting a Conduit connection:");
83
99
  console.log(`Operator: ${operatorName}`);
84
100
  console.log(`Computer: ${machineName}`);
101
+ const plannedFuel = fuelSource ?? suggestFuelSource(detected);
85
102
  console.log(`Detected local clients: ${detected.join(", ") || "none"}`);
86
103
  console.log(`Proposed roles: ${capabilities.join(", ")}`);
87
104
  console.log(`Assignments at once: ${leaseCapacity}`);
88
- if (fuelSource)
89
- console.log(`Fuel source: ${fuelSource === "local" ? "local subscription" : "Conduit pump"}`);
105
+ console.log(`Fuel source: ${plannedFuel === "local" ? "local subscription" : "Conduit pump"}`
106
+ + (!fuelSource && plannedFuel === "local" ? " (auto: only Cursor/Kiro/Antigravity detected)" : ""));
90
107
  console.log(`Execution drivers: ${Object.keys(DRIVERS).join(", ")} (cursor/kiro/antigravity require local fuel).`);
91
108
  console.log("Use --machine <name> to override the computer label.\n");
92
109
  const response = await fetch(`${baseUrl}/runner/v1/connect/requests`, {
@@ -121,6 +138,7 @@ async function join() {
121
138
  console.log("Waiting for a project owner to approve. You may close this terminal and run `conduit join --resume` later.\n");
122
139
  if (!values["no-open"])
123
140
  openUrl(pending.verificationUrl);
141
+ // Explicit --fuel wins; otherwise finishConnection re-detects and may default to local.
124
142
  await waitForConnection(pending, fuelSource);
125
143
  }
126
144
  function normalizeBaseUrl(value) {
@@ -165,6 +183,9 @@ async function finishConnection(baseUrl, data, fuelSource) {
165
183
  if (row.project_id && row.gateway_secret)
166
184
  fuel[row.project_id] = { gatewayKey: row.gateway_secret };
167
185
  }
186
+ const detected = await detectInstalledClients();
187
+ const resolvedFuel = fuelSource ?? suggestFuelSource(detected);
188
+ const fuelAutoLocal = fuelSource === undefined && resolvedFuel === "local";
168
189
  const config = {
169
190
  baseUrl,
170
191
  organizationId: String(data.organization_id),
@@ -174,7 +195,7 @@ async function finishConnection(baseUrl, data, fuelSource) {
174
195
  grants: data.grants,
175
196
  leaseCapacity: Number(data.lease_capacity),
176
197
  activeAttempts: {},
177
- fuelSource: fuelSource ?? "conduit",
198
+ fuelSource: resolvedFuel,
178
199
  ...(Object.keys(fuel).length ? { fuel } : {}),
179
200
  };
180
201
  await saveConfig(config);
@@ -186,13 +207,17 @@ async function finishConnection(baseUrl, data, fuelSource) {
186
207
  catch {
187
208
  heartbeatOk = false;
188
209
  }
189
- const detected = await detectInstalledClients();
190
210
  console.log(`Connected machine: ${config.machineId}`);
191
211
  console.log(heartbeatOk ? "Runner credential and heartbeat: OK" : "Runner credential saved; heartbeat will retry when the runner starts");
192
212
  console.log(`Detected local clients: ${detected.join(", ") || "none"}`);
193
213
  console.log(`Confirmed capabilities (matching): ${config.capabilities.join(", ") || "none"}`);
194
214
  console.log(`Allowed actions: ${config.grants.join(", ") || "none"}`);
195
- console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit pump"}`);
215
+ console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit pump"}`
216
+ + (fuelAutoLocal ? " (auto: only Cursor/Kiro/Antigravity detected)" : ""));
217
+ const localOnly = localFuelOnlyClients(detected);
218
+ if (config.fuelSource === "conduit" && localOnly.length) {
219
+ console.log(`Note: ${localOnly.join(", ")} cannot use Conduit pump. Before --agent cursor|kiro|antigravity run: ${bridgeUsage("fuel", "local")}`);
220
+ }
196
221
  console.log("Heartbeats report capabilities for diagnostics only; matching uses the Connect confirmation. Detected clients never receive grants automatically.");
197
222
  console.log(`MCP setup: {"mcpServers":{"conduit":{"command":"npx","args":["-y","@miraland-labs/conduit-bridge","mcp"]}}}`);
198
223
  console.log(`Execute work: ${bridgeUsage("runner", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
@@ -223,18 +248,27 @@ async function installService() {
223
248
  await loadConfig();
224
249
  const { values } = parseArgs({ args: process.argv.slice(3), options: {
225
250
  agent: { type: "string" }, workspace: { type: "string" }, interval: { type: "string" }, "agent-timeout-minutes": { type: "string" },
251
+ "ensure-checkout": { type: "string" },
226
252
  } });
227
253
  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)`);
254
+ throw new Error(`Usage: ${bridgeUsage("install-service", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repository-path>", "[--ensure-checkout <repository-url>]")} (agent + workspace required; heartbeat-only services are not supported)`);
229
255
  }
230
256
  if (!DRIVERS[values.agent]) {
231
257
  throw new Error(`Unknown agent driver: ${values.agent}. Available: ${Object.keys(DRIVERS).join(", ")}`);
232
258
  }
259
+ const workspace = resolve(values.workspace);
260
+ if (values["ensure-checkout"]) {
261
+ const result = await ensureCheckout(workspace, values["ensure-checkout"]);
262
+ console.log(result === "cloned"
263
+ ? `Cloned ${values["ensure-checkout"]} into ${workspace}`
264
+ : `Workspace already matches ${values["ensure-checkout"]}`);
265
+ }
233
266
  const result = await installRunnerService({
234
267
  agent: values.agent,
235
- workspace: resolve(values.workspace),
268
+ workspace,
236
269
  interval: values.interval,
237
270
  agentTimeoutMinutes: values["agent-timeout-minutes"],
271
+ ensureCheckout: values["ensure-checkout"],
238
272
  });
239
273
  console.log(`Installed Conduit runner service (${result.platform}): ${result.path}`);
240
274
  console.log("The runner keeps executing after this terminal closes. Laptop sleep still pauses work.");
@@ -258,7 +292,7 @@ async function fuelCommand() {
258
292
  async function runner() {
259
293
  const { values } = parseArgs({ args: process.argv.slice(3), options: {
260
294
  agent: { type: "string" }, workspace: { type: "string" }, interval: { type: "string" }, once: { type: "boolean" },
261
- "agent-timeout-minutes": { type: "string" }, fuel: { type: "string" },
295
+ "agent-timeout-minutes": { type: "string" }, fuel: { type: "string" }, "ensure-checkout": { type: "string" },
262
296
  } });
263
297
  const config = await loadConfig();
264
298
  const fuelOverride = parseFuelSource(values.fuel);
@@ -275,7 +309,16 @@ async function runner() {
275
309
  if (!values.workspace)
276
310
  throw new Error("--workspace <repository-path> is required with --agent");
277
311
  }
312
+ if (values["ensure-checkout"] && !values.workspace) {
313
+ throw new Error("--ensure-checkout requires --workspace <repository-path>");
314
+ }
278
315
  const workspace = values.workspace ? resolve(values.workspace) : null;
316
+ if (workspace && values["ensure-checkout"]) {
317
+ const result = await ensureCheckout(workspace, values["ensure-checkout"]);
318
+ console.log(result === "cloned"
319
+ ? `Cloned ${values["ensure-checkout"]} into ${workspace}`
320
+ : `Workspace already matches ${values["ensure-checkout"]}`);
321
+ }
279
322
  const brief = workspace ? await buildWorkspaceBrief(workspace) : null;
280
323
  const intervalMs = values.interval ? Math.max(5_000, Number(values.interval)) : 15_000;
281
324
  const timeoutMs = values["agent-timeout-minutes"] ? Number(values["agent-timeout-minutes"]) * 60_000 : undefined;
@@ -283,14 +326,17 @@ async function runner() {
283
326
  if (!driver || !workspace) {
284
327
  console.warn(`WARNING: heartbeat only — no work will execute. Use: ${bridgeUsage("runner", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
285
328
  }
286
- console.log(`Conduit runner connected to ${config.baseUrl}${driver ? ` — executing via ${driver.name} in ${workspace}` : " — heartbeat only (no --agent)"} (fuel: ${fuelLabel})`);
329
+ console.log(`Conduit runner (bridge v${bridgeVersion()}) connected to ${config.baseUrl}${driver ? ` — executing via ${driver.name} in ${workspace}` : " — heartbeat only (no --agent)"} (fuel: ${fuelLabel})`);
287
330
  for (;;) {
288
331
  let executed = false;
289
332
  try {
290
333
  await heartbeat(client, config, workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null);
291
334
  await renewLeases(client, config);
292
335
  if (driver && workspace) {
293
- executed = await executeNextAssignment(client, config, driver, workspace, brief, timeoutMs);
336
+ executed = await executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, {
337
+ heartbeat: async () => heartbeat(client, config, await buildWorkspaceBrief(workspace).catch(() => brief)),
338
+ heartbeatIntervalMs: intervalMs,
339
+ });
294
340
  }
295
341
  }
296
342
  catch (error) {
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
  }
@@ -52,13 +58,21 @@ export class ConduitClient {
52
58
  delete this.config.activeAttempts[taskId];
53
59
  await this.persist(this.config);
54
60
  }
55
- /** Rotate and cache a project-scoped gateway fuel key for agent /v1 calls. */
61
+ /**
62
+ * Ensure a project-scoped gateway fuel key for agent /v1 calls.
63
+ * GET creates once (returns secret) or reports provisioned; cache miss after
64
+ * that uses POST …/rotate so heartbeats never rotate on every claim.
65
+ */
56
66
  async ensureFuel(projectId) {
57
67
  const cached = this.config.fuel?.[projectId]?.gatewayKey;
58
68
  if (cached)
59
69
  return cached;
60
- const data = await this.request(`/runner/v1/fuel/${projectId}`);
61
- const gatewayKey = String(data.gateway_secret);
70
+ const peeked = await this.request(`/runner/v1/fuel/${projectId}`);
71
+ let gatewayKey = typeof peeked.gateway_secret === "string" ? peeked.gateway_secret : "";
72
+ if (!gatewayKey) {
73
+ const rotated = await this.request(`/runner/v1/fuel/${projectId}/rotate`, { method: "POST", body: "{}" });
74
+ gatewayKey = String(rotated.gateway_secret);
75
+ }
62
76
  this.config.fuel = { ...this.config.fuel, [projectId]: { gatewayKey } };
63
77
  await this.persist(this.config);
64
78
  return gatewayKey;
package/dist/config.js CHANGED
@@ -1,8 +1,11 @@
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
+ export const BRIDGE_LEASE_CAPACITY = 1;
6
+ const directory = process.env.CONDUIT_BRIDGE_CONFIG_DIR?.trim()
7
+ ? resolve(process.env.CONDUIT_BRIDGE_CONFIG_DIR)
8
+ : join(homedir(), ".config", "conduit");
6
9
  const path = join(directory, "config.json");
7
10
  const pendingPath = join(directory, "pending-connect.json");
8
11
  const installationPath = join(directory, "installation.json");
@@ -36,6 +39,9 @@ export async function loadConfigIfPresent() {
36
39
  active.phase ??= "agent_running";
37
40
  if (config.fuelSource !== "local" && config.fuelSource !== "conduit")
38
41
  config.fuelSource = "conduit";
42
+ // The built-in Bridge runs one agent process synchronously. Keep legacy
43
+ // configurations truthful until isolated concurrent slot workers exist.
44
+ config.leaseCapacity = BRIDGE_LEASE_CAPACITY;
39
45
  return config;
40
46
  }
41
47
  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", ""] : [""];