@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 +3 -1
- package/dist/attempt-worktree.js +104 -0
- package/dist/brief.js +55 -20
- package/dist/checkout.js +95 -0
- package/dist/cli.js +69 -18
- package/dist/client.js +7 -1
- package/dist/config.js +16 -2
- package/dist/detect.js +18 -0
- package/dist/driver.js +222 -33
- package/dist/execution.js +321 -21
- 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,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
|
-
|
|
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, 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 {
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
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:
|
|
197
|
+
leaseCapacity: clampLeaseCapacity(data.lease_capacity),
|
|
176
198
|
activeAttempts: {},
|
|
177
|
-
fuelSource:
|
|
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
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
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.
|
|
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
|
-
|
|
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", ""] : [""];
|