@miraland-labs/conduit-bridge 0.4.1 → 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 +3 -1
- package/dist/attempt-worktree.js +81 -0
- package/dist/brief.js +55 -20
- package/dist/checkout.js +95 -0
- package/dist/cli.js +60 -14
- package/dist/client.js +7 -1
- package/dist/config.js +8 -2
- package/dist/detect.js +18 -0
- package/dist/driver.js +222 -33
- package/dist/execution.js +249 -16
- package/dist/service.js +2 -0
- package/package.json +1 -1
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`
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
}
|
package/dist/checkout.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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,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
|
|
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", ""] : [""];
|