@miraland-labs/conduit-bridge 0.8.0 → 0.9.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connects a computer to one organization, claims work, and drives a local agent.
4
4
 
5
- **Current npm:** `0.8.0` — **multi-driver lanes** on one Connect computer (`drivers online|offline`), shared concurrent slots **1–4** (sum across online IDEs, not per IDE), per-attempt worktrees, optional `--ensure-checkout`, safe resume.
5
+ **Current npm:** `0.9.1` — **disconnect/disengage**, join org prompt when `--organization` is omitted, multi-driver lanes (`drivers online|offline`), shared concurrent slots **1–4**, worktrees, optional `--ensure-checkout`.
6
6
 
7
7
  ## Prerequisites
8
8
 
@@ -20,6 +20,14 @@ Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connect
20
20
 
21
21
  ```bash
22
22
  npx @miraland-labs/conduit-bridge join --url <https://your-conduit> --organization <slug>
23
+ # If --organization is omitted, the CLI asks: Join which org?
24
+ ```
25
+
26
+ Disengage (leave the organization; then you can join the same or another org):
27
+
28
+ ```bash
29
+ npx @miraland-labs/conduit-bridge disconnect
30
+ # Or non-interactive: disconnect --yes
23
31
  ```
24
32
 
25
33
  After approval, seed detects installed CLIs as **offline lanes**. Bring one or more online (subscription failover / concurrent lanes):
package/dist/brief.js CHANGED
@@ -43,6 +43,41 @@ export async function isBaseCommitAncestor(workspace, baseCommit, headCommit) {
43
43
  throw error;
44
44
  }
45
45
  }
46
+ /** True when `sha` is a local commit object, optionally after fetching it from origin. */
47
+ export async function ensureCommitAvailable(workspace, sha) {
48
+ const present = async () => {
49
+ try {
50
+ await execFileAsync("git", ["-C", workspace, "cat-file", "-e", `${sha}^{commit}`], {
51
+ timeout: 10_000,
52
+ windowsHide: true,
53
+ });
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ };
60
+ if (await present())
61
+ return true;
62
+ try {
63
+ await execFileAsync("git", ["-C", workspace, "fetch", "--no-tags", "--depth=1", "origin", sha], {
64
+ timeout: 120_000,
65
+ windowsHide: true,
66
+ });
67
+ }
68
+ catch {
69
+ try {
70
+ await execFileAsync("git", ["-C", workspace, "fetch", "--no-tags", "origin"], {
71
+ timeout: 120_000,
72
+ windowsHide: true,
73
+ });
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ return present();
80
+ }
46
81
  async function gitRemoteUrl(workspace) {
47
82
  try {
48
83
  const { common } = await gitDirectories(workspace);
package/dist/cli.js CHANGED
@@ -3,10 +3,12 @@ import { parseArgs } from "node:util";
3
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 { createInterface } from "node:readline/promises";
6
7
  import { readFileSync } from "node:fs";
8
+ import { stdin as input, stdout as output } from "node:process";
7
9
  import { fileURLToPath } from "node:url";
8
- import { ConduitClient } from "./client.js";
9
- import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, savePendingConnection, suggestMachineName, } from "./config.js";
10
+ import { ConduitClient, ConduitRequestError } from "./client.js";
11
+ import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearLocalConnection, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, saveConfigPrefs, savePendingConnection, suggestMachineName, } from "./config.js";
10
12
  import { runMcp } from "./mcp.js";
11
13
  import { detectInstalledClients, localFuelOnlyClients, suggestFuelSource } from "./detect.js";
12
14
  import { DRIVERS } from "./driver.js";
@@ -54,6 +56,27 @@ async function connect() {
54
56
  throw new Error(data.error?.message ?? `Connect failed (${response.status})`);
55
57
  await finishConnection(baseUrl, data, parseFuelSource(values.fuel));
56
58
  }
59
+ async function promptLine(question) {
60
+ if (!input.isTTY || !output.isTTY) {
61
+ throw new Error("Interactive input is unavailable; pass the value on the command line");
62
+ }
63
+ const rl = createInterface({ input, output });
64
+ try {
65
+ return (await rl.question(question)).trim();
66
+ }
67
+ finally {
68
+ rl.close();
69
+ }
70
+ }
71
+ async function resolveJoinOrganization(explicit) {
72
+ const raw = explicit?.trim() || await promptLine("Join which org? ");
73
+ const organization = raw.toLowerCase();
74
+ if (!organization)
75
+ throw new Error("Organization slug is required");
76
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(organization))
77
+ throw new Error("Organization must be its lowercase workspace slug");
78
+ return organization;
79
+ }
57
80
  async function join() {
58
81
  const { values } = parseArgs({ args: process.argv.slice(3), options: {
59
82
  url: { type: "string" }, resume: { type: "boolean" }, machine: { type: "string" }, operator: { type: "string" },
@@ -75,13 +98,11 @@ async function join() {
75
98
  if (pending && !values.resume)
76
99
  console.log(`Resuming the pending connection request for ${pending.baseUrl}.`);
77
100
  if (!pending) {
78
- if (!values.url || !values.organization) {
79
- throw new Error(`Usage: ${bridgeUsage("join", "--url", "<worker-url>", "--organization", "<slug>", "[--machine <name>]", "[--capability <role>]", "[--fuel conduit|local]")}`);
101
+ if (!values.url) {
102
+ throw new Error(`Usage: ${bridgeUsage("join", "--url", "<worker-url>", "[--organization <slug>]", "[--machine <name>]", "[--capability <role>]", "[--fuel conduit|local]")}`);
80
103
  }
81
104
  const baseUrl = normalizeBaseUrl(values.url);
82
- const organization = values.organization.trim().toLowerCase();
83
- if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(organization))
84
- throw new Error("Organization must be its lowercase workspace slug");
105
+ const organization = await resolveJoinOrganization(values.organization);
85
106
  // A config that fails validation (for example pre-organization) must not
86
107
  // block rejoining — join replaces it with a complete identity.
87
108
  const connected = await loadConfigIfPresent().catch(() => null);
@@ -219,7 +240,7 @@ async function finishConnection(baseUrl, data, fuelSource) {
219
240
  }
220
241
  console.log(`Confirmed capabilities (matching): ${config.capabilities.join(", ") || "none"}`);
221
242
  console.log(`Allowed actions: ${config.grants.join(", ") || "none"}`);
222
- console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit pump"}`
243
+ console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit /v1 (project owner BYOK on open orgs; org credentials on invite-only)"}`
223
244
  + (fuelAutoLocal ? " (auto: only Cursor/Kiro/Antigravity detected)" : ""));
224
245
  const localOnly = localFuelOnlyClients(detected);
225
246
  if (config.fuelSource === "conduit" && localOnly.length) {
@@ -268,7 +289,7 @@ async function installService() {
268
289
  }
269
290
  config = seedDriverLanes(config, [values.agent]).config;
270
291
  config = setDriversOnline(config, [values.agent], true);
271
- await saveConfig(config);
292
+ await saveConfigPrefs(config);
272
293
  console.log(`Brought ${values.agent} online for this computer (shared capacity ${config.leaseCapacity}).`);
273
294
  }
274
295
  else if (!onlineDriverIds(config).length) {
@@ -303,7 +324,7 @@ async function fuelCommand() {
303
324
  }
304
325
  const config = await loadConfig();
305
326
  config.fuelSource = mode;
306
- await saveConfig(config);
327
+ await saveConfigPrefs(config);
307
328
  const client = new ConduitClient(config);
308
329
  await heartbeat(client, config, null);
309
330
  console.log(`Machine fuel source set to ${mode === "local" ? "local subscription" : "Conduit pump"} and reported on heartbeat.`);
@@ -313,7 +334,7 @@ async function driversCommand() {
313
334
  let config = await loadConfig();
314
335
  config = (await seedDriversFromDetection(config)).config;
315
336
  if (!sub || sub === "list") {
316
- await saveConfig(config);
337
+ await saveConfigPrefs(config);
317
338
  const lanes = listDriverLanes(config);
318
339
  if (!lanes.length) {
319
340
  console.log("No driver lanes registered. Install a supported agent CLI, then re-run this command.");
@@ -339,7 +360,7 @@ async function driversCommand() {
339
360
  }
340
361
  config = seedDriverLanes(config, ids).config;
341
362
  config = setDriversOnline(config, ids, sub === "online");
342
- await saveConfig(config);
363
+ await saveConfigPrefs(config);
343
364
  const client = new ConduitClient(config);
344
365
  await heartbeat(client, config, null).catch(() => undefined);
345
366
  console.log(`${sub === "online" ? "Online" : "Offline"}: ${ids.join(", ")}`);
@@ -354,7 +375,7 @@ async function driversCommand() {
354
375
  }
355
376
  config = seedDriverLanes(config, [id]).config;
356
377
  config = setDriverFuel(config, id, mode);
357
- await saveConfig(config);
378
+ await saveConfigPrefs(config);
358
379
  console.log(`${driverLabel(id)} lane fuel set to ${mode}`);
359
380
  return;
360
381
  }
@@ -369,10 +390,10 @@ async function runner() {
369
390
  const fuelOverride = parseFuelSource(values.fuel);
370
391
  if (fuelOverride) {
371
392
  config.fuelSource = fuelOverride;
372
- await saveConfig(config);
393
+ await saveConfigPrefs(config);
373
394
  }
374
395
  config = (await seedDriversFromDetection(config)).config;
375
- await saveConfig(config);
396
+ await saveConfigPrefs(config);
376
397
  const client = new ConduitClient(config);
377
398
  let processDriver = null;
378
399
  let processOnlineIds = null;
@@ -415,11 +436,11 @@ async function runner() {
415
436
  config.drivers = latest.drivers;
416
437
  config.fuelSource = latest.fuelSource;
417
438
  config.leaseCapacity = latest.leaseCapacity;
418
- await heartbeat(client, config, workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null);
439
+ await heartbeat(client, config, workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null, processOnlineIds);
419
440
  await renewLeases(client, config);
420
441
  if (workspace && (processDriver || onlineDriverIds(config).length)) {
421
442
  progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
422
- heartbeat: async () => heartbeat(client, config, await buildWorkspaceBrief(workspace).catch(() => brief)),
443
+ heartbeat: async () => heartbeat(client, config, await buildWorkspaceBrief(workspace).catch(() => brief), processOnlineIds),
423
444
  heartbeatIntervalMs: intervalMs,
424
445
  }, running, { driver: processDriver, processOnlineIds });
425
446
  }
@@ -435,23 +456,58 @@ async function runner() {
435
456
  await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
436
457
  }
437
458
  }
438
- async function heartbeat(client, config, brief) {
439
- const online = onlineDriverIds(config);
459
+ async function heartbeat(client, config, brief, processOnlineIds) {
460
+ // Process-level `--agent` override must count as online even when saved lanes are offline.
461
+ const online = processOnlineIds?.length ? processOnlineIds : onlineDriverIds(config);
440
462
  const status = heartbeatStatusForDrivers(online);
441
463
  await client.request("/runner/v1/heartbeat", { method: "POST", body: JSON.stringify({
442
464
  status,
443
465
  capabilities: config.capabilities,
444
466
  lease_capacity: config.leaseCapacity,
445
467
  fuel_source: config.fuelSource === "local" ? "local" : "conduit",
446
- drivers: driversHeartbeatReport(config, config.activeAttempts),
468
+ drivers: driversHeartbeatReport(config, config.activeAttempts, processOnlineIds),
447
469
  ...(brief ? { workspace_brief: brief } : {}),
448
470
  }) });
449
471
  }
472
+ async function disconnect() {
473
+ const { values } = parseArgs({ args: process.argv.slice(3), options: { yes: { type: "boolean", short: "y" } } });
474
+ const config = await loadConfigIfPresent().catch(() => null);
475
+ if (!config) {
476
+ await clearLocalConnection();
477
+ console.log("This Bridge is not connected to an organization.");
478
+ return;
479
+ }
480
+ if (!values.yes) {
481
+ const answer = (await promptLine(`Disengage this computer from the organization and clear local credentials? [y/N] `)).toLowerCase();
482
+ if (answer !== "y" && answer !== "yes") {
483
+ console.log("Cancelled.");
484
+ return;
485
+ }
486
+ }
487
+ try {
488
+ await new ConduitClient(config).request("/runner/v1/disengage", { method: "POST", body: "{}" });
489
+ console.log("Conduit disengaged this computer from the organization.");
490
+ }
491
+ catch (error) {
492
+ // Local clear still proceeds when the server already revoked the key (admin disengage).
493
+ if (error instanceof ConduitRequestError && (error.status === 401 || error.status === 404)) {
494
+ console.warn(`Server disengage skipped (${error.status}): ${redactSecrets(error.message)}`);
495
+ }
496
+ else {
497
+ throw error;
498
+ }
499
+ }
500
+ await clearLocalConnection();
501
+ console.log("Local Bridge credentials cleared. Run join to connect again (same or another org).");
502
+ console.log(`Tip: stop a background runner with \`${bridgeUsage("uninstall-service")}\` if one is installed.`);
503
+ }
450
504
  try {
451
505
  if (command === "connect")
452
506
  await connect();
453
507
  else if (command === "join")
454
508
  await join();
509
+ else if (command === "disconnect")
510
+ await disconnect();
455
511
  else if (command === "fuel")
456
512
  await fuelCommand();
457
513
  else if (command === "drivers")
@@ -465,7 +521,7 @@ try {
465
521
  else if (command === "uninstall-service")
466
522
  await uninstallService();
467
523
  else
468
- throw new Error(`Usage: ${BRIDGE_NPX} <join|connect|fuel|drivers|mcp|runner|install-service|uninstall-service>`);
524
+ throw new Error(`Usage: ${BRIDGE_NPX} <join|disconnect|connect|fuel|drivers|mcp|runner|install-service|uninstall-service>`);
469
525
  }
470
526
  catch (error) {
471
527
  console.error(error instanceof Error ? redactSecrets(error.message) : "Conduit command failed");
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { saveConfig } from "./config.js";
1
+ import { removeRuntimeAttempt, saveConfigPrefs, saveRuntime } from "./config.js";
2
2
  export class ConduitRequestError extends Error {
3
3
  status;
4
4
  code;
@@ -10,10 +10,24 @@ export class ConduitRequestError extends Error {
10
10
  }
11
11
  export class ConduitClient {
12
12
  config;
13
- persist;
14
- constructor(config, persist = saveConfig) {
13
+ persistRuntime;
14
+ persistPrefs;
15
+ removeAttempt;
16
+ constructor(config, persist) {
15
17
  this.config = config;
16
- this.persist = persist;
18
+ if (typeof persist === "function") {
19
+ this.persistRuntime = persist;
20
+ this.persistPrefs = persist;
21
+ this.removeAttempt = async (taskId) => {
22
+ delete this.config.activeAttempts[taskId];
23
+ await persist(this.config);
24
+ };
25
+ }
26
+ else {
27
+ this.persistRuntime = persist?.runtime ?? saveRuntime;
28
+ this.persistPrefs = persist?.prefs ?? saveConfigPrefs;
29
+ this.removeAttempt = persist?.removeAttempt ?? removeRuntimeAttempt;
30
+ }
17
31
  }
18
32
  async request(path, init = {}) {
19
33
  const response = await fetch(`${this.config.baseUrl}${path}`, {
@@ -25,11 +39,18 @@ export class ConduitClient {
25
39
  throw new ConduitRequestError(data.error?.message ?? `Conduit request failed (${response.status})`, response.status, data.error?.code);
26
40
  return data;
27
41
  }
28
- async claim(taskId, attemptId) {
42
+ async claim(taskId, attemptId, extras) {
29
43
  const data = await this.request(`/runner/v1/tasks/${taskId}/claim`, { method: "POST", body: JSON.stringify({ attempt_id: attemptId, idempotency_key: `bridge:claim:${attemptId}` }) });
30
- const active = { taskId, attemptId, leaseToken: String(data.lease_token), leaseExpiresAt: String(data.lease_expires_at), phase: "claimed" };
44
+ const active = {
45
+ taskId,
46
+ attemptId,
47
+ leaseToken: String(data.lease_token),
48
+ leaseExpiresAt: String(data.lease_expires_at),
49
+ phase: "claimed",
50
+ ...(extras?.driverId ? { driverId: extras.driverId } : {}),
51
+ };
31
52
  this.config.activeAttempts[taskId] = active;
32
- await this.persist(this.config);
53
+ await this.persistRuntime(this.config);
33
54
  return { ...data, lease_token: "stored by Conduit Bridge" };
34
55
  }
35
56
  attempt(taskId) {
@@ -51,12 +72,15 @@ export class ConduitClient {
51
72
  else
52
73
  Object.assign(active, { [key]: value });
53
74
  }
54
- await this.persist(this.config);
75
+ await this.persistRuntime(this.config);
55
76
  return active;
56
77
  }
57
78
  async clearAttempt(taskId) {
79
+ const active = this.config.activeAttempts[taskId];
58
80
  delete this.config.activeAttempts[taskId];
59
- await this.persist(this.config);
81
+ if (this.config.sessions)
82
+ delete this.config.sessions[taskId];
83
+ await this.removeAttempt(taskId, active?.attemptId);
60
84
  }
61
85
  /**
62
86
  * Ensure a project-scoped gateway fuel key for agent /v1 calls.
@@ -74,7 +98,7 @@ export class ConduitClient {
74
98
  gatewayKey = String(rotated.gateway_secret);
75
99
  }
76
100
  this.config.fuel = { ...this.config.fuel, [projectId]: { gatewayKey } };
77
- await this.persist(this.config);
101
+ await this.persistPrefs(this.config);
78
102
  return gatewayKey;
79
103
  }
80
104
  }
package/dist/config.js CHANGED
@@ -1,7 +1,9 @@
1
- import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
1
+ import { chmod, mkdir, open, readFile, rename, unlink, writeFile, stat } from "node:fs/promises";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
+ /** Serialize config writes in-process so concurrent claim/phase updates cannot clobber each other. */
6
+ let saveChain = Promise.resolve();
5
7
  /** Default when join omits --capacity. */
6
8
  export const BRIDGE_LEASE_CAPACITY = 1;
7
9
  /** Hard ceiling for truthful concurrent Bridge slots (one agent + worktree each). */
@@ -16,6 +18,9 @@ const directory = process.env.CONDUIT_BRIDGE_CONFIG_DIR?.trim()
16
18
  ? resolve(process.env.CONDUIT_BRIDGE_CONFIG_DIR)
17
19
  : join(homedir(), ".config", "conduit");
18
20
  const path = join(directory, "config.json");
21
+ /** Runtime attempt/session state — separate so drivers CLI cannot clobber live claims. */
22
+ const runtimePath = join(directory, "runtime.json");
23
+ const lockPath = join(directory, "config.lock");
19
24
  const pendingPath = join(directory, "pending-connect.json");
20
25
  const installationPath = join(directory, "installation.json");
21
26
  export async function loadConfig() {
@@ -43,7 +48,10 @@ export async function loadConfigIfPresent() {
43
48
  throw new Error(`Bridge configuration is missing ${field}; run \`conduit join --url <worker-url>\` to reconnect this machine.`);
44
49
  }
45
50
  }
46
- config.activeAttempts ??= {};
51
+ const runtime = await loadRuntime();
52
+ // Prefer dedicated runtime file; fall back to legacy fields still present in config.json.
53
+ config.activeAttempts = runtime?.activeAttempts ?? config.activeAttempts ?? {};
54
+ config.sessions = runtime?.sessions ?? config.sessions;
47
55
  for (const active of Object.values(config.activeAttempts))
48
56
  active.phase ??= "agent_running";
49
57
  if (config.fuelSource !== "local" && config.fuelSource !== "conduit")
@@ -63,10 +71,175 @@ export async function loadConfigIfPresent() {
63
71
  }
64
72
  return config;
65
73
  }
66
- export async function saveConfig(config) {
74
+ async function loadRuntime() {
75
+ try {
76
+ const raw = JSON.parse(await readFile(runtimePath, "utf8"));
77
+ return {
78
+ activeAttempts: raw.activeAttempts && typeof raw.activeAttempts === "object" ? raw.activeAttempts : {},
79
+ sessions: raw.sessions && typeof raw.sessions === "object" ? raw.sessions : undefined,
80
+ tombstones: raw.tombstones && typeof raw.tombstones === "object" ? raw.tombstones : undefined,
81
+ };
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ async function withConfigLock(fn) {
67
88
  await mkdir(directory, { recursive: true, mode: 0o700 });
68
- await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
69
- await chmod(path, 0o600);
89
+ const started = Date.now();
90
+ for (;;) {
91
+ try {
92
+ const handle = await open(lockPath, "wx");
93
+ try {
94
+ await handle.writeFile(`${process.pid}\n`);
95
+ return await fn();
96
+ }
97
+ finally {
98
+ await handle.close().catch(() => undefined);
99
+ await unlink(lockPath).catch(() => undefined);
100
+ }
101
+ }
102
+ catch (error) {
103
+ const code = error.code;
104
+ if (code !== "EEXIST")
105
+ throw error;
106
+ try {
107
+ const age = Date.now() - (await stat(lockPath)).mtimeMs;
108
+ if (age > 30_000)
109
+ await unlink(lockPath).catch(() => undefined);
110
+ }
111
+ catch { /* lock raced away */ }
112
+ if (Date.now() - started > 10_000)
113
+ throw new Error("Timed out waiting for Bridge config lock");
114
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, 25));
115
+ }
116
+ }
117
+ }
118
+ async function writeJsonAtomic(filePath, value) {
119
+ const tmp = `${filePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
120
+ try {
121
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
122
+ await rename(tmp, filePath);
123
+ await chmod(filePath, 0o600);
124
+ }
125
+ catch (error) {
126
+ await unlink(tmp).catch(() => undefined);
127
+ throw error;
128
+ }
129
+ }
130
+ /** Merge snapshot onto disk without resurrecting attempts removed under lock. */
131
+ export function mergeRuntimeState(disk, snapshot) {
132
+ const tombstones = { ...(disk?.tombstones ?? {}) };
133
+ const merged = { ...(disk?.activeAttempts ?? {}) };
134
+ for (const [taskId, attempt] of Object.entries(snapshot.activeAttempts ?? {})) {
135
+ if (tombstones[taskId] === attempt.attemptId)
136
+ continue;
137
+ const onDisk = merged[taskId];
138
+ if (onDisk && onDisk.attemptId !== attempt.attemptId)
139
+ continue;
140
+ merged[taskId] = attempt;
141
+ delete tombstones[taskId];
142
+ }
143
+ const sessions = { ...(disk?.sessions ?? {}), ...(snapshot.sessions ?? {}) };
144
+ for (const taskId of Object.keys(tombstones))
145
+ delete sessions[taskId];
146
+ return {
147
+ activeAttempts: merged,
148
+ sessions: Object.keys(sessions).length ? sessions : undefined,
149
+ tombstones: Object.keys(tombstones).length ? tombstones : undefined,
150
+ };
151
+ }
152
+ /**
153
+ * Persist Bridge config. Operator prefs and runtime attempt state are written as separate files under
154
+ * a cross-process lock so `drivers online|offline` cannot erase an in-flight claim (and vice versa).
155
+ */
156
+ export async function saveConfig(config, options = {}) {
157
+ const snapshot = structuredClone(config);
158
+ const run = async () => {
159
+ await withConfigLock(async () => {
160
+ await mkdir(directory, { recursive: true, mode: 0o700 });
161
+ // One-time migrate: if runtime.json is missing, preserve legacy activeAttempts from config.json.
162
+ const existingRuntime = await loadRuntime();
163
+ if (!existingRuntime) {
164
+ let legacyAttempts = {};
165
+ let legacySessions;
166
+ try {
167
+ const legacy = JSON.parse(await readFile(path, "utf8"));
168
+ if (legacy.activeAttempts && typeof legacy.activeAttempts === "object")
169
+ legacyAttempts = legacy.activeAttempts;
170
+ if (legacy.sessions && typeof legacy.sessions === "object")
171
+ legacySessions = legacy.sessions;
172
+ }
173
+ catch { /* no prior config */ }
174
+ if (Object.keys(legacyAttempts).length || legacySessions) {
175
+ await writeJsonAtomic(runtimePath, { activeAttempts: legacyAttempts, sessions: legacySessions });
176
+ }
177
+ }
178
+ const prefs = { ...snapshot };
179
+ delete prefs.activeAttempts;
180
+ delete prefs.sessions;
181
+ const prefsForDisk = { ...prefs, activeAttempts: {}, sessions: undefined };
182
+ await writeJsonAtomic(path, prefsForDisk);
183
+ if (!options.prefsOnly) {
184
+ const disk = await loadRuntime();
185
+ await writeJsonAtomic(runtimePath, mergeRuntimeState(disk, {
186
+ activeAttempts: snapshot.activeAttempts ?? {},
187
+ sessions: snapshot.sessions,
188
+ }));
189
+ }
190
+ });
191
+ };
192
+ const next = saveChain.then(run, run);
193
+ saveChain = next.catch(() => undefined);
194
+ await next;
195
+ }
196
+ /** Operator-pref write that leaves runtime.json (claims/sessions) untouched. */
197
+ export async function saveConfigPrefs(config) {
198
+ await saveConfig(config, { prefsOnly: true });
199
+ }
200
+ /**
201
+ * Claim/session write that leaves prefs untouched and merges with on-disk attempts from other
202
+ * Bridge processes (supervisor vs MCP) instead of replacing the whole runtime snapshot.
203
+ */
204
+ export async function saveRuntime(config) {
205
+ const snapshot = structuredClone(config);
206
+ const run = async () => {
207
+ await withConfigLock(async () => {
208
+ await mkdir(directory, { recursive: true, mode: 0o700 });
209
+ const disk = await loadRuntime();
210
+ await writeJsonAtomic(runtimePath, mergeRuntimeState(disk, {
211
+ activeAttempts: snapshot.activeAttempts ?? {},
212
+ sessions: snapshot.sessions,
213
+ }));
214
+ });
215
+ };
216
+ const next = saveChain.then(run, run);
217
+ saveChain = next.catch(() => undefined);
218
+ await next;
219
+ }
220
+ /** Remove one attempt under the lock; no-op if another process already replaced the claim. */
221
+ export async function removeRuntimeAttempt(taskId, attemptId) {
222
+ const run = async () => {
223
+ await withConfigLock(async () => {
224
+ await mkdir(directory, { recursive: true, mode: 0o700 });
225
+ const disk = await loadRuntime();
226
+ if (!disk)
227
+ return;
228
+ const current = disk.activeAttempts[taskId];
229
+ if (!current)
230
+ return;
231
+ if (attemptId && current.attemptId !== attemptId)
232
+ return;
233
+ delete disk.activeAttempts[taskId];
234
+ if (disk.sessions)
235
+ delete disk.sessions[taskId];
236
+ disk.tombstones = { ...(disk.tombstones ?? {}), [taskId]: current.attemptId };
237
+ await writeJsonAtomic(runtimePath, disk);
238
+ });
239
+ };
240
+ const next = saveChain.then(run, run);
241
+ saveChain = next.catch(() => undefined);
242
+ await next;
70
243
  }
71
244
  export async function loadPendingConnection() {
72
245
  try {
@@ -87,6 +260,17 @@ export async function clearPendingConnection() {
87
260
  throw error;
88
261
  });
89
262
  }
263
+ /** Drop local org credentials after a server-side disengage. Keeps installation.json. */
264
+ export async function clearLocalConnection() {
265
+ await withConfigLock(async () => {
266
+ for (const target of [path, runtimePath, pendingPath]) {
267
+ await unlink(target).catch((error) => {
268
+ if (error.code !== "ENOENT")
269
+ throw error;
270
+ });
271
+ }
272
+ });
273
+ }
90
274
  export async function loadOrCreateInstallationId() {
91
275
  try {
92
276
  const identity = JSON.parse(await readFile(installationPath, "utf8"));
package/dist/driver.js CHANGED
@@ -81,6 +81,7 @@ export function buildAssignmentPrompt(context) {
81
81
  const changeScope = packageContext?.change_scope?.length ? packageContext.change_scope : spec.change_scope;
82
82
  const evidence = packageContext?.required_evidence?.length ? packageContext.required_evidence : spec.required_evidence;
83
83
  const rework = packageContext?.rework_feedback ?? context.reworkFeedback;
84
+ const artifact = (packageContext?.deliverable ?? spec.deliverable) === "artifact";
84
85
  const lines = [
85
86
  `You are completing one delegated Conduit assignment as the ${role} role. Work only inside the current workspace.`,
86
87
  "",
@@ -99,8 +100,17 @@ export function buildAssignmentPrompt(context) {
99
100
  lines.push("", `BOUNDARIES — never violate these\n${boundaries.map((item) => `- ${item}`).join("\n")}`);
100
101
  if (acceptance?.length)
101
102
  lines.push("", `ACCEPTANCE CRITERIA — the delivery is judged against these\n${acceptance.map((item) => `- ${item}`).join("\n")}`);
102
- if (evidence?.length)
103
- lines.push("", `REQUIRED EVIDENCE\n${evidence.map((item) => `- ${item}`).join("\n")}`);
103
+ if (evidence?.length) {
104
+ lines.push("", "REQUIRED EVIDENCE");
105
+ for (const kind of evidence) {
106
+ if (kind === "test") {
107
+ lines.push("- test — include verbatim command stdout/stderr (and exit code) in evidence details; summaries alone are rejected");
108
+ }
109
+ else {
110
+ lines.push(`- ${kind}`);
111
+ }
112
+ }
113
+ }
104
114
  if (changeScope?.length)
105
115
  lines.push("", `CHANGE SCOPE — only modify paths under\n${changeScope.map((item) => `- ${item}`).join("\n")}`);
106
116
  if (spec.repository?.base_commit)
@@ -113,6 +123,7 @@ export function buildAssignmentPrompt(context) {
113
123
  if (rework)
114
124
  lines.push("", `REWORK FEEDBACK — an independent review returned this delivery; address every point\n${rework}`);
115
125
  const mustCommit = context.grants.includes("repo_write") && context.grants.includes("branch_create");
126
+ const mustOpenPr = mustCommit && context.grants.includes("pr_create") && Boolean(changeScope?.length);
116
127
  // Exactly what the driver's permission contract will allow, derived from the same source, so the
117
128
  // prompt cannot drift from the enforcement.
118
129
  const runnableCommands = [
@@ -130,8 +141,24 @@ export function buildAssignmentPrompt(context) {
130
141
  `- These are the ONLY shell commands you may run: ${runnableCommands.join("; ")}. Anything else is rejected — do not try variations, wrappers, or \`echo\`. Run the relevant ones and report their real output.`,
131
142
  ]
132
143
  : ["- You have no shell authority for this assignment. Do not attempt shell commands; verify by reading files and report what you could not verify as unknown."]), ...(mustCommit
133
- ? ["- Your working branch is already checked out. Commit your repository changes on the current branch (git add + git commit) — do not create a new branch — and report the resulting sha as head_commit. A delivery that changed files without a commit is rejected. Local commits are required; pushing is a separate authority."]
134
- : ["- You have no repository write authority for this assignment. Do not create, modify, or commit any file; a delivery reporting repository changes will be rejected. Record all findings, verification output, and conclusions in your final report instead."]), "- Never merge, deploy, push to protected branches, or touch production.", `- Hard-denied commands (all drivers): ${deniedCommands.join("; ")}.`, "- Do not invent evidence. Report unknown when you could not verify a criterion.",
144
+ ? [
145
+ "- Your working branch is already checked out. Commit your repository changes on the current branch (git add + git commit) do not create a new branch and report the resulting sha as head_commit. A delivery that changed files without a commit is rejected.",
146
+ ...(mustOpenPr
147
+ ? [
148
+ "- This assignment includes pr_create: after committing, `git push -u origin HEAD` and `gh pr create` against the repository default branch, then set pull_request_url to that PR URL. Conduit merge-on-accept lands the PR when the owner Accepts — a head_commit without a PR leaves main unchanged and breaks later packages.",
149
+ ]
150
+ : ["- You may commit locally; opening a pull request is outside this assignment's grants."]),
151
+ ]
152
+ : artifact
153
+ ? [
154
+ // repo_write without branch_create: the agent may write, but the output is not repository
155
+ // content. `.conduit/` is already in .git/info/exclude, so this path is ignored by git and
156
+ // `git status` stays clean — which is the point for a content pack, and mandatory for
157
+ // anything binary.
158
+ "- This assignment delivers an artifact, not repository content. Write your output under `.conduit/artifacts/` — that path is excluded from git, so do not commit anything and do not report a head_commit.",
159
+ "- Publish the artifact to its destination and report each output in your evidence with the published URL and a sha256 digest. An artifact nobody can fetch is not a delivery.",
160
+ ]
161
+ : ["- You have no repository write authority for this assignment. Do not create, modify, or commit any file; a delivery reporting repository changes will be rejected. Record all findings, verification output, and conclusions in your final report instead."]), "- Never merge, deploy, force-push, push to protected branches, or touch production.", `- Hard-denied commands (all drivers): ${deniedCommands.join("; ")}.`, "- Do not invent evidence. Report unknown when you could not verify a criterion.",
135
162
  // A met claim with nothing backing it is rejected server-side ("Met acceptance criteria require
136
163
  // mapped evidence"). Observed live: six criteria marked met, evidence mapped to four, whole
137
164
  // delivery lost. Say it here rather than let the agent discover it by failing.
@@ -832,7 +859,7 @@ function boundedEnvironment(fuel, fuelSource = "conduit") {
832
859
  ...(fuelSource === "local" ? LOCAL_VENDOR_ENV : [])];
833
860
  const env = Object.fromEntries(allowed.flatMap((name) => process.env[name] === undefined ? [] : [[name, process.env[name]]]));
834
861
  if (fuelSource === "conduit" && fuel) {
835
- const v1 = `${fuel.baseUrl.replace(/\/+$/, "")}/v1`;
862
+ const v1 = fuelEndpoint(fuel.baseUrl);
836
863
  env.ANTHROPIC_API_KEY = fuel.gatewayKey;
837
864
  env.ANTHROPIC_BASE_URL = v1;
838
865
  env.OPENAI_API_KEY = fuel.gatewayKey;
@@ -841,6 +868,10 @@ function boundedEnvironment(fuel, fuelSource = "conduit") {
841
868
  }
842
869
  return env;
843
870
  }
871
+ /** The `/v1` a fuelled agent is pointed at — and the one its model names are validated against. */
872
+ export function fuelEndpoint(baseUrl) {
873
+ return `${baseUrl.replace(/\/+$/, "")}/v1`;
874
+ }
844
875
  export function hasClaudeLogin(env = process.env) {
845
876
  if (env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN)
846
877
  return true;
package/dist/drivers.js CHANGED
@@ -129,7 +129,8 @@ export function advertiseLeaseCapacity(approved, onlineIds) {
129
129
  return 1; // schema/heartbeat min; status standby prevents matching
130
130
  return approved;
131
131
  }
132
- export function driversHeartbeatReport(config, activeAttempts) {
132
+ export function driversHeartbeatReport(config, activeAttempts, processOnlineIds) {
133
+ const processOnline = new Set(processOnlineIds ?? []);
133
134
  const busyByDriver = new Map();
134
135
  for (const active of Object.values(activeAttempts)) {
135
136
  if (!active.driverId)
@@ -138,7 +139,7 @@ export function driversHeartbeatReport(config, activeAttempts) {
138
139
  }
139
140
  return listDriverLanes(config).map((lane) => ({
140
141
  id: lane.id,
141
- state: lane.state,
142
+ state: processOnline.has(lane.id) ? "online" : lane.state,
142
143
  busy: (busyByDriver.get(lane.id) ?? 0) > 0,
143
144
  }));
144
145
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * After the agent commits scoped work with pr_create, Bridge must land a GitHub PR.
3
+ * Push uses the machine's git credentials; PR creation uses the control-plane GitHub
4
+ * credential via /runner/v1/tasks/:id/open-pull-request so secrets never live on runners.
5
+ */
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ const execFileAsync = promisify(execFile);
9
+ export function needsPullRequest(report, spec, grants) {
10
+ return Boolean(grants.includes("pr_create")
11
+ && (spec.change_scope?.length ?? 0) > 0
12
+ && report.head_commit
13
+ && !report.pull_request_url);
14
+ }
15
+ /** True when reported SHA equals workspace HEAD (allows short SHA prefixes). */
16
+ export function isAbsoluteHttpsUrl(value) {
17
+ try {
18
+ return new URL(value).protocol === "https:";
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ export function commitsMatch(reported, actual) {
25
+ const left = reported.trim().toLowerCase();
26
+ const right = actual.trim().toLowerCase();
27
+ if (!left || !right)
28
+ return false;
29
+ return left === right || left.startsWith(right) || right.startsWith(left);
30
+ }
31
+ async function workspaceHeadCommit(workspace) {
32
+ const { stdout } = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
33
+ timeout: 30_000,
34
+ maxBuffer: 1_000_000,
35
+ });
36
+ const head = stdout.trim().toLowerCase();
37
+ if (!/^[0-9a-f]{7,64}$/.test(head))
38
+ throw new Error("Could not determine workspace HEAD commit");
39
+ return head;
40
+ }
41
+ /**
42
+ * Bind reported head_commit to the checkout HEAD, then open a PR when still needed.
43
+ * Agent-supplied PR URLs skip creation but still require HEAD identity.
44
+ */
45
+ export async function ensureDeliveryPullRequest(input) {
46
+ const readHead = input.readHeadCommit ?? workspaceHeadCommit;
47
+ let report = input.report;
48
+ if (report.head_commit && (input.spec.change_scope?.length ?? 0) > 0) {
49
+ const head = await readHead(input.workspace);
50
+ if (!commitsMatch(report.head_commit, head)) {
51
+ throw new Error(`Delivered head_commit ${report.head_commit} does not match workspace HEAD ${head}`);
52
+ }
53
+ report = { ...report, head_commit: head };
54
+ }
55
+ if (!needsPullRequest(report, input.spec, input.grants))
56
+ return report;
57
+ await execFileAsync("git", ["-C", input.workspace, "push", "-u", "origin", "HEAD"], {
58
+ timeout: 120_000,
59
+ maxBuffer: 2_000_000,
60
+ });
61
+ const headAfterPush = await readHead(input.workspace);
62
+ if (!report.head_commit || !commitsMatch(report.head_commit, headAfterPush)) {
63
+ throw new Error(`Workspace HEAD changed during push (${report.head_commit} → ${headAfterPush})`);
64
+ }
65
+ report = { ...report, head_commit: headAfterPush };
66
+ const { stdout: branchOut } = await execFileAsync("git", ["-C", input.workspace, "branch", "--show-current"], {
67
+ timeout: 30_000,
68
+ maxBuffer: 1_000_000,
69
+ });
70
+ const headBranch = branchOut.trim();
71
+ if (!headBranch)
72
+ throw new Error("Could not determine the attempt branch for pull request creation");
73
+ const response = await input.client.attemptRequest(input.taskId, "open-pull-request", {
74
+ head_branch: headBranch,
75
+ head_commit: report.head_commit,
76
+ title: (input.title ?? "Conduit delivery").slice(0, 200),
77
+ body: [
78
+ "Opened by Conduit Bridge after the agent committed scoped delivery work.",
79
+ "",
80
+ `Head commit: ${report.head_commit}`,
81
+ ].join("\n"),
82
+ idempotency_key: `bridge:open-pr:${input.attemptId}`,
83
+ });
84
+ const pullRequestUrl = typeof response.pull_request_url === "string" ? response.pull_request_url : "";
85
+ // Any https URL: the control plane resolved which forge this project uses and parsed the response
86
+ // with that forge's own rules, so re-asserting a github.com shape here would only reject a valid
87
+ // GitLab merge request — after the branch was already pushed. The head-commit cross-check below is
88
+ // what actually proves the returned change request is this delivery's.
89
+ if (!isAbsoluteHttpsUrl(pullRequestUrl)) {
90
+ throw new Error("Control plane did not return a pull_request_url");
91
+ }
92
+ const openedHead = typeof response.head_commit === "string" ? response.head_commit : "";
93
+ const deliveredHead = report.head_commit ?? "";
94
+ if (openedHead && deliveredHead && !commitsMatch(openedHead, deliveredHead)) {
95
+ throw new Error(`Opened pull request head ${openedHead} does not match delivered commit ${deliveredHead}`);
96
+ }
97
+ return { ...report, pull_request_url: pullRequestUrl };
98
+ }
package/dist/execution.js CHANGED
@@ -2,10 +2,25 @@ import { createHash } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { ConduitRequestError } from "./client.js";
4
4
  import { redactSecrets } from "./config.js";
5
- import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, parseAgentReport, pickModelCandidate, tierForRisk } from "./driver.js";
5
+ import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, fuelEndpoint, parseAgentReport, pickModelCandidate, tierForRisk } from "./driver.js";
6
6
  import { pickDriverForClaim, resolveDriverFuel } from "./drivers.js";
7
7
  import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
8
- import { buildWorkspaceBrief, isBaseCommitAncestor, normalizeRepositoryUrl } from "./brief.js";
8
+ import { buildWorkspaceBrief, ensureCommitAvailable, isBaseCommitAncestor, normalizeRepositoryUrl } from "./brief.js";
9
+ import { ensureDeliveryPullRequest } from "./ensure-pull-request.js";
10
+ /** Feedback text for changes_requested summaries (plain string or `{ feedback }`). */
11
+ function changesRequestedFeedback(summary) {
12
+ if (!summary)
13
+ return null;
14
+ if (!summary.startsWith("{"))
15
+ return summary;
16
+ try {
17
+ const parsed = JSON.parse(summary);
18
+ return typeof parsed.feedback === "string" && parsed.feedback.trim() ? parsed.feedback : null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
9
24
  const assignmentSchema = z.object({
10
25
  id: z.string().uuid(),
11
26
  attempt_id: z.string().uuid(),
@@ -29,6 +44,7 @@ const taskSpecSchema = z.object({
29
44
  change_scope: z.array(z.string()).optional(), work_role: z.string().optional(),
30
45
  repository: z.object({ url: z.string().optional(), base_commit: z.string().optional() }).nullable().optional(),
31
46
  risk_level: z.string().optional(),
47
+ deliverable: z.enum(["repository", "artifact"]).optional().default("repository"),
32
48
  });
33
49
  const executionContractSchema = z.object({
34
50
  repository_fingerprint: z.string().nullable().optional().default(null),
@@ -43,6 +59,7 @@ const workPackageSchema = z.object({
43
59
  acceptance: z.array(z.string()).optional(),
44
60
  change_scope: z.array(z.string()).optional(),
45
61
  required_evidence: z.array(z.string()).optional(),
62
+ deliverable: z.enum(["repository", "artifact"]).optional().default("repository"),
46
63
  initiative: z.object({
47
64
  title: z.string().nullable().optional(),
48
65
  desired_outcome: z.string().nullable().optional(),
@@ -70,7 +87,11 @@ export async function renewLeases(client, config) {
70
87
  function resolveAttemptDriver(config, active, fallback) {
71
88
  if (active.driverId && DRIVERS[active.driverId])
72
89
  return DRIVERS[active.driverId];
73
- return fallback ?? null;
90
+ if (fallback)
91
+ return fallback;
92
+ // Crash between claim and driverId persist: pick any free online lane so recovery is not stranded.
93
+ const picked = pickDriverForClaim(config);
94
+ return picked ? DRIVERS[picked] ?? null : null;
74
95
  }
75
96
  export async function recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision, taskId) {
76
97
  const active = taskId ? config.activeAttempts[taskId] : Object.values(config.activeAttempts)[0];
@@ -81,6 +102,11 @@ export async function recoverActiveAttempt(client, config, driver, workspace, br
81
102
  console.error(`No driver for attempt ${active.attemptId} (driverId=${active.driverId ?? "unset"})`);
82
103
  return false;
83
104
  }
105
+ if (!active.driverId) {
106
+ const assigned = Object.entries(DRIVERS).find(([, d]) => d === laneDriver)?.[0];
107
+ if (assigned)
108
+ await client.updateAttempt(active.taskId, { driverId: assigned });
109
+ }
84
110
  if (active.phase === "agent_running") {
85
111
  const worktree = active.worktreePath ?? attemptWorktreePath(workspace, active.attemptId);
86
112
  const sessionId = config.sessions?.[active.taskId]?.trim() || "";
@@ -158,10 +184,9 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
158
184
  }
159
185
  if (!laneDriver || !driverId)
160
186
  break;
161
- const taskId = await claimNextAssignment(client, config, workspace, brief);
187
+ const taskId = await claimNextAssignment(client, config, workspace, brief, driverId);
162
188
  if (!taskId)
163
189
  break;
164
- await client.updateAttempt(taskId, { driverId });
165
190
  progressed = true;
166
191
  console.log(`Executing ${taskId} via ${laneDriver.name}`);
167
192
  const slot = runClaimedAssignment(client, config, laneDriver, workspace, brief, taskId, timeoutMs, supervision)
@@ -175,7 +200,7 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
175
200
  return progressed;
176
201
  }
177
202
  /** Claim one assignment when under capacity; does not start the agent (multi-slot pump does). */
178
- export async function claimNextAssignment(client, config, workspace, brief) {
203
+ export async function claimNextAssignment(client, config, workspace, brief, driverId) {
179
204
  if (Object.keys(config.activeAttempts).length >= config.leaseCapacity)
180
205
  return null;
181
206
  const data = await client.request("/runner/v1/assignments");
@@ -192,11 +217,15 @@ export async function claimNextAssignment(client, config, workspace, brief) {
192
217
  let rejection = null;
193
218
  if (assignment.repository_fingerprint && liveRepository !== assignment.repository_fingerprint)
194
219
  rejection = "workspace_repository_mismatch";
195
- else if (assignment.repository_fingerprint && liveBrief?.base_commit !== assignment.claimed_head)
196
- rejection = "workspace_head_changed";
197
- else if (assignment.requested_base_commit && assignment.claimed_head) {
198
- const compatible = await isBaseCommitAncestor(workspace, assignment.requested_base_commit, assignment.claimed_head).catch(() => false);
199
- if (!compatible)
220
+ else if (assignment.repository_fingerprint && assignment.claimed_head && liveBrief?.base_commit !== assignment.claimed_head) {
221
+ // Rework / advanced base may claim a head that differs from the source checkout HEAD.
222
+ const reachable = await ensureCommitAvailable(workspace, assignment.claimed_head).catch(() => false);
223
+ if (!reachable)
224
+ rejection = "workspace_head_changed";
225
+ }
226
+ if (!rejection && assignment.requested_base_commit && assignment.claimed_head) {
227
+ const startHead = await resolveStartHead(workspace, assignment.requested_base_commit, assignment.claimed_head);
228
+ if (!startHead)
200
229
  rejection = "base_not_ancestor";
201
230
  }
202
231
  if (rejection) {
@@ -208,9 +237,28 @@ export async function claimNextAssignment(client, config, workspace, brief) {
208
237
  return null;
209
238
  }
210
239
  console.log(`Claiming assignment ${assignment.id} (attempt ${assignment.attempt_id})`);
211
- await client.claim(assignment.id, assignment.attempt_id);
240
+ await client.claim(assignment.id, assignment.attempt_id, driverId ? { driverId } : undefined);
212
241
  return assignment.id;
213
242
  }
243
+ /**
244
+ * When a sibling merge advances task.base_commit past the machine checkout, the required base is a
245
+ * descendant of claimed_head — not an ancestor. Fetch it and start from the advanced base instead of looping.
246
+ */
247
+ async function resolveStartHead(workspace, requestedBase, claimedHead) {
248
+ if (await isBaseCommitAncestor(workspace, requestedBase, claimedHead).catch(() => false))
249
+ return claimedHead;
250
+ await ensureCommitAvailable(workspace, requestedBase).catch(() => false);
251
+ if (await isBaseCommitAncestor(workspace, requestedBase, claimedHead).catch(() => false))
252
+ return claimedHead;
253
+ // Workspace is behind the required base (dependent package after merge).
254
+ if (await isBaseCommitAncestor(workspace, claimedHead, requestedBase).catch(() => false))
255
+ return requestedBase;
256
+ if (await ensureCommitAvailable(workspace, requestedBase).catch(() => false)
257
+ && await isBaseCommitAncestor(workspace, claimedHead, requestedBase).catch(() => false)) {
258
+ return requestedBase;
259
+ }
260
+ return null;
261
+ }
214
262
  export async function executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, supervision) {
215
263
  if (Object.keys(config.activeAttempts).length)
216
264
  return recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision);
@@ -226,28 +274,47 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
226
274
  const task = taskDetailSchema.parse(detail.task);
227
275
  const executionContract = executionContractSchema.parse(detail.execution_contract ?? {});
228
276
  const workPackage = workPackageSchema.parse(detail.work_package) ?? null;
229
- const spec = parseTaskSpec(task.spec_json);
277
+ const parsedSpec = parseTaskSpec(task.spec_json);
278
+ const deliverable = workPackage?.deliverable ?? parsedSpec.deliverable;
279
+ const spec = { ...parsedSpec, deliverable };
280
+ const artifactDelivery = deliverable === "artifact";
230
281
  const grants = z.array(z.string()).parse(task.grants_json ? JSON.parse(task.grants_json) : []);
231
- const reworkFeedback = task.delivery_state === "changes_requested" && task.delivery_summary && !task.delivery_summary.startsWith("{") ? task.delivery_summary : null;
282
+ const reworkFeedback = task.delivery_state === "changes_requested" ? changesRequestedFeedback(task.delivery_summary) : null;
232
283
  // Recompile current state at claim time — earlier packages may have moved the repo.
233
284
  const liveBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
234
285
  const liveRepository = liveBrief?.repository ? normalizeRepositoryUrl(liveBrief.repository) : null;
235
- if (executionContract.repository_fingerprint && (liveRepository !== executionContract.repository_fingerprint
236
- || !liveBrief?.base_commit
237
- || liveBrief.base_commit !== executionContract.claimed_head)) {
238
- const message = liveRepository !== executionContract.repository_fingerprint
239
- ? "Workspace repository changed after dispatch"
240
- : "Workspace HEAD changed after dispatch";
241
- await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: true, idempotency_key: `bridge:workspace-mismatch:${active.attemptId}` } });
242
- console.error(`Assignment ${taskId} preflight failed: ${message}`);
286
+ if (executionContract.repository_fingerprint && liveRepository !== executionContract.repository_fingerprint) {
287
+ await queueTerminal(client, taskId, { action: "fail", body: { error: "Workspace repository changed after dispatch", retryable: true, idempotency_key: `bridge:workspace-mismatch:${active.attemptId}` } });
288
+ console.error(`Assignment ${taskId} preflight failed: Workspace repository changed after dispatch`);
243
289
  return;
244
290
  }
245
- const startCommit = executionContract.claimed_head ?? liveBrief?.base_commit;
291
+ const startCommit = (() => {
292
+ const claimed = executionContract.claimed_head ?? liveBrief?.base_commit ?? null;
293
+ return claimed;
294
+ })();
246
295
  if (!startCommit) {
247
296
  await queueTerminal(client, taskId, { action: "fail", body: { error: "No start commit for attempt worktree", retryable: true, idempotency_key: `bridge:no-start-commit:${active.attemptId}` } });
248
297
  return;
249
298
  }
299
+ let worktreeStart = startCommit;
300
+ if (executionContract.requested_base_commit) {
301
+ const resolved = await resolveStartHead(workspace, executionContract.requested_base_commit, startCommit);
302
+ if (!resolved) {
303
+ await queueTerminal(client, taskId, { action: "fail", body: { error: "Required base commit is not available in this workspace", retryable: true, idempotency_key: `bridge:base-not-ancestor:${active.attemptId}` } });
304
+ return;
305
+ }
306
+ worktreeStart = resolved;
307
+ }
308
+ else if (liveBrief?.base_commit && liveBrief.base_commit !== startCommit) {
309
+ const reachable = await ensureCommitAvailable(workspace, startCommit).catch(() => false);
310
+ if (!reachable) {
311
+ await queueTerminal(client, taskId, { action: "fail", body: { error: "Delivered head is not available in this workspace", retryable: true, idempotency_key: `bridge:workspace-mismatch:${active.attemptId}` } });
312
+ console.error(`Assignment ${taskId} preflight failed: Delivered head is not available in this workspace`);
313
+ return;
314
+ }
315
+ }
250
316
  let attemptWorkspace;
317
+ let deliverySubmitted = false;
251
318
  if (options.existingWorktree) {
252
319
  attemptWorkspace = options.existingWorktree;
253
320
  }
@@ -256,7 +323,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
256
323
  attemptWorkspace = await createAttemptWorktree({
257
324
  sourceWorkspace: workspace,
258
325
  attemptId: active.attemptId,
259
- startCommit,
326
+ startCommit: worktreeStart,
260
327
  });
261
328
  }
262
329
  catch (error) {
@@ -272,7 +339,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
272
339
  }
273
340
  // Same list the driver hands to the permission contract, so the prompt states exactly what is
274
341
  // executable rather than leaving the agent to guess and hit rejections.
275
- const prompt = buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead: startCommit, reworkFeedback, workPackage, verificationCommands: liveBrief?.verification ?? [] });
342
+ const prompt = buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead: worktreeStart, reworkFeedback, workPackage, verificationCommands: liveBrief?.verification ?? [] });
276
343
  const resuming = Boolean(options.forceResumeSessionId);
277
344
  await client.attemptRequest(taskId, "progress", {
278
345
  phase: "changing",
@@ -292,7 +359,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
292
359
  const gatewayKey = await client.ensureFuel(task.project_id);
293
360
  fuel = { baseUrl: config.baseUrl, gatewayKey };
294
361
  }
295
- const selection = await resolveAssignmentModel(driver, config, spec, taskId, attemptWorkspace);
362
+ const selection = await resolveAssignmentModel(driver, config, spec, taskId, attemptWorkspace, fuel);
296
363
  await client.attemptRequest(taskId, "model-selection", {
297
364
  risk: selection.risk, tier: selection.tier, model: selection.model ?? null, driver: driver.name,
298
365
  ...(selection.model ? {} : { note: "cli-default" }), idempotency_key: `model:${active.attemptId}`,
@@ -399,11 +466,25 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
399
466
  }
400
467
  }
401
468
  try {
469
+ // Mechanical land path: agent may forget gh; Bridge pushes and the control plane opens the PR.
470
+ report = await ensureDeliveryPullRequest({
471
+ client,
472
+ taskId,
473
+ attemptId: active.attemptId,
474
+ workspace: attemptWorkspace,
475
+ report,
476
+ spec,
477
+ grants,
478
+ title: task.objective,
479
+ });
402
480
  validateDeliveryReport(report, spec, grants);
403
481
  }
404
482
  catch (error) {
405
483
  const message = error instanceof Error ? error.message : "Agent delivery report was invalid";
406
- await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: false, idempotency_key: `bridge:invalid-delivery:${active.attemptId}` } });
484
+ // Missing PR tooling / credential is retryable once the environment is fixed; other contract
485
+ // failures (scope, evidence mapping) stay non-retryable.
486
+ const retryable = /pull_request|merge_request|forge|GitHub|GitLab|gh |push|credential/i.test(message);
487
+ await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable, idempotency_key: `bridge:invalid-delivery:${active.attemptId}` } });
407
488
  console.error(`Assignment ${taskId} could not produce a valid Delivery: ${redactSecrets(message)}`);
408
489
  const replyTail = reportText.slice(-8_000);
409
490
  console.error(`Assignment ${taskId} agent reply tail (${reportText.length} chars total, redacted): ${redactSecrets(replyTail) || "<empty>"}`);
@@ -411,20 +492,26 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
411
492
  }
412
493
  await client.updateAttempt(taskId, { phase: "agent_finished", delivery: { spec, report } });
413
494
  await submitFinishedDelivery(client, taskId);
495
+ deliverySubmitted = true;
414
496
  console.log(`Assignment ${taskId} delivered for review and acceptance.`);
415
497
  }
416
498
  finally {
417
499
  clearInterval(renewTimer);
418
500
  if (heartbeatTimer)
419
501
  clearInterval(heartbeatTimer);
420
- // F-07: dispose the attempt worktree so the next claim cannot inherit edits.
421
- // Terminal submit may already have cleared activeAttempts never throw from cleanup.
422
- const path = config.activeAttempts[taskId]?.worktreePath ?? attemptWorkspace;
423
- await removeAttemptWorktree(workspace, path).catch((error) => {
424
- console.error(`Attempt worktree cleanup failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
425
- });
426
- if (config.activeAttempts[taskId]) {
427
- await client.updateAttempt(taskId, { worktreePath: undefined }).catch(() => undefined);
502
+ if (artifactDelivery && !deliverySubmitted) {
503
+ console.error(`Artifact delivery was not submitted; retained generated files at ${attemptWorkspace}`);
504
+ }
505
+ else {
506
+ // F-07: dispose the attempt worktree so the next claim cannot inherit edits.
507
+ // Terminal submit may already have cleared activeAttempts — never throw from cleanup.
508
+ const path = config.activeAttempts[taskId]?.worktreePath ?? attemptWorkspace;
509
+ await removeAttemptWorktree(workspace, path).catch((error) => {
510
+ console.error(`Attempt worktree cleanup failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
511
+ });
512
+ if (config.activeAttempts[taskId]) {
513
+ await client.updateAttempt(taskId, { worktreePath: undefined }).catch(() => undefined);
514
+ }
428
515
  }
429
516
  }
430
517
  }
@@ -458,36 +545,77 @@ async function submitFinishedDelivery(client, taskId) {
458
545
  await client.attemptRequest(taskId, "progress", { phase: "preparing_delivery", message: "Agent finished; submitting the Delivery.", idempotency_key: `bridge:progress:${active.attemptId}:delivery` });
459
546
  await queueTerminal(client, taskId, terminal);
460
547
  }
461
- /**
462
- * The live model list per driver, cached with a TTL so tier candidates are
463
- * re-validated against reality as vendors add and retire models — no config
464
- * edit or restart needed for the refresh itself.
465
- */
466
- const modelListCache = new Map();
548
+ export const modelListCache = new Map();
467
549
  const MODEL_LIST_TTL_MS = 6 * 60 * 60 * 1000;
468
- async function availableModels(driver, workspace) {
550
+ /** A failure is remembered only briefly: long enough not to re-pay it per claim, short enough that
551
+ * validation resumes soon after the cause clears. */
552
+ const MODEL_LIST_FAILURE_TTL_MS = 60 * 1000;
553
+ export async function availableModels(driver, workspace, fuel) {
554
+ // Under Conduit fuel the agent's requests go to Conduit's /v1, which resolves an organization alias
555
+ // and rejects a vendor model name outright — so the list that matters is Conduit's, not the CLI's.
556
+ // If it cannot be read, fall back to the CLI's list rather than to no validation at all.
557
+ if (fuel) {
558
+ const aliases = await cachedList(`conduit:${fuel.baseUrl}`, () => conduitAliases(fuel));
559
+ if (aliases)
560
+ return aliases;
561
+ }
469
562
  if (!driver.listModels)
470
563
  return null;
471
- const cached = modelListCache.get(driver.name);
472
- if (cached && Date.now() - cached.at < MODEL_LIST_TTL_MS)
564
+ return cachedList(driver.name, () => driver.listModels(undefined, workspace).catch(() => null));
565
+ }
566
+ export async function cachedList(key, load) {
567
+ const cached = modelListCache.get(key);
568
+ const ttl = cached?.available ? MODEL_LIST_TTL_MS : MODEL_LIST_FAILURE_TTL_MS;
569
+ if (cached && Date.now() - cached.at < ttl)
473
570
  return cached.available;
474
- const available = await driver.listModels(undefined, workspace).catch(() => null);
475
- modelListCache.set(driver.name, { at: Date.now(), available });
571
+ const available = await load();
572
+ modelListCache.set(key, { at: Date.now(), available });
476
573
  return available;
477
574
  }
478
- async function resolveAssignmentModel(driver, config, spec, taskId, workspace) {
575
+ /** The organization's enabled aliases, which is exactly what /v1 will accept as a model. */
576
+ export async function conduitAliases(fuel) {
577
+ try {
578
+ const response = await fetch(`${fuelEndpoint(fuel.baseUrl)}/models`, {
579
+ headers: { authorization: `Bearer ${fuel.gatewayKey}` },
580
+ });
581
+ if (!response.ok)
582
+ return null;
583
+ const body = await response.json();
584
+ const ids = (body.data ?? []).map((row) => row.id).filter((id) => typeof id === "string");
585
+ return ids.length ? new Set(ids) : null;
586
+ }
587
+ catch {
588
+ return null;
589
+ }
590
+ }
591
+ async function resolveAssignmentModel(driver, config, spec, taskId, workspace, fuel) {
479
592
  const tier = tierForRisk(spec.risk_level);
480
593
  const configured = config.models?.[tier];
481
594
  const risk = spec.risk_level === "low" || spec.risk_level === "medium" || spec.risk_level === "high" ? spec.risk_level : "unset";
595
+ let listed;
596
+ const list = async () => (listed === undefined ? (listed = await availableModels(driver, workspace, fuel)) : listed);
597
+ const offered = async () => {
598
+ const available = fuel ? await list() : null;
599
+ return available ? ` Conduit accepts: ${[...available].join(", ")}.` : "";
600
+ };
482
601
  if (!configured || (Array.isArray(configured) && configured.length === 0)) {
483
- console.log(`Assignment ${taskId} intelligence tier ${tier} (risk ${risk}); no model mappingCLI default`);
602
+ // Not fatal on purpose. Conduit's /v1 rejects a vendor model nameproven by `npm run
603
+ // probe:fuel` — so under Conduit fuel the CLI's own default cannot resolve. Whether every driver
604
+ // honours the injected base URL rather than its stored login is not proven per driver, so this
605
+ // says exactly what is wrong and lets the run show it rather than refusing on a half-proven chain.
606
+ const unmapped = `Assignment ${taskId} intelligence tier ${tier} (risk ${risk}); no model mapping — CLI default`;
607
+ if (fuel)
608
+ console.error(`${unmapped}, which Conduit resolves as an organization alias and rejects otherwise.${await offered()}`);
609
+ else
610
+ console.log(unmapped);
484
611
  return { risk, tier };
485
612
  }
486
613
  const candidates = Array.isArray(configured) ? configured : [configured];
487
- const { model, skipped } = pickModelCandidate(candidates, await availableModels(driver, workspace));
614
+ const { model, skipped } = pickModelCandidate(candidates, await list());
488
615
  const skippedNote = skipped.length ? ` (skipped unavailable/unsafe: ${skipped.join(", ")})` : "";
489
616
  if (!model) {
490
- console.error(`Assignment ${taskId} intelligence tier ${tier} (risk ${risk}): no configured candidate available — CLI default${skippedNote}`);
617
+ const none = `Assignment ${taskId} intelligence tier ${tier} (risk ${risk}): no configured candidate available — CLI default${skippedNote}`;
618
+ console.error(fuel ? `${none}. No candidate is a Conduit model alias.${await offered()}` : none);
491
619
  return { risk, tier };
492
620
  }
493
621
  console.log(`Assignment ${taskId} intelligence tier ${tier} (risk ${risk}) → model ${model}${skippedNote}`);
@@ -542,14 +670,40 @@ async function prepareDelivery(client, attemptId, taskId, report) {
542
670
  }
543
671
  export function validateDeliveryReport(report, spec, grants = []) {
544
672
  validateChangeScope(report, spec.change_scope ?? []);
673
+ if (spec.deliverable === "artifact") {
674
+ const published = report.evidence.some((item) => {
675
+ if (!["preview", "research", "documentation"].includes(item.kind))
676
+ return false;
677
+ if (!item.uri || !item.digest || !/^sha256:[0-9a-f]{64}$/i.test(item.digest))
678
+ return false;
679
+ try {
680
+ const protocol = new URL(item.uri).protocol;
681
+ return protocol === "https:" || protocol === "http:";
682
+ }
683
+ catch {
684
+ return false;
685
+ }
686
+ });
687
+ if (!published) {
688
+ throw new Error("Artifact delivery requires a published HTTP(S) URL and sha256 digest");
689
+ }
690
+ }
545
691
  // Invariant 9: grants bound what an attempt may do. Cursor/plan-mode are soft hints the headless
546
692
  // agent can leave, so repository-write authority is enforced here on the reported delivery: a task
547
693
  // without repo_write must not report repository changes.
548
694
  if (!grants.includes("repo_write") && (report.changes.length > 0 || report.evidence.some((item) => item.kind === "change"))) {
549
695
  throw new Error("Agent reported repository changes without the repo_write grant");
550
696
  }
697
+ // Merge-on-accept lands GitHub PRs. Local attempt commits without a PR never reach main.
698
+ if (grants.includes("pr_create")
699
+ && (spec.change_scope?.length ?? 0) > 0
700
+ && report.head_commit
701
+ && !report.pull_request_url) {
702
+ throw new Error("Repository changes require a pull_request_url when pr_create is granted");
703
+ }
704
+ const requiredEvidence = spec.required_evidence ?? [];
551
705
  const supplied = new Set(report.evidence.map((item) => item.kind));
552
- const missing = (spec.required_evidence ?? []).filter((kind) => !supplied.has(kind));
706
+ const missing = requiredEvidence.filter((kind) => !supplied.has(kind));
553
707
  if (missing.length)
554
708
  throw new Error(`Agent report is missing required evidence: ${missing.join(", ")}`);
555
709
  const unsupported = report.acceptance_results
@@ -557,7 +711,17 @@ export function validateDeliveryReport(report, spec, grants = []) {
557
711
  .map((result) => result.criterion);
558
712
  if (unsupported.length)
559
713
  throw new Error(`Met acceptance criteria require mapped evidence: ${unsupported.join("; ")}`);
714
+ // Plan-time normalizeRequiredEvidence puts "test" on verify packages; reject summary-only details.
715
+ if (requiredEvidence.includes("test")) {
716
+ const thin = report.evidence
717
+ .filter((item) => item.kind === "test")
718
+ .filter((item) => item.details.join("\n").trim().length < TEST_EVIDENCE_DETAILS_MIN);
719
+ if (thin.length) {
720
+ throw new Error("test evidence must include verbatim command output in details (not a summary)");
721
+ }
722
+ }
560
723
  }
724
+ const TEST_EVIDENCE_DETAILS_MIN = 32;
561
725
  function referenceKind(kind) {
562
726
  if (kind === "change")
563
727
  return "commit";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.8.0",
4
- "description": "Conduit Bridge CLI — join, connect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
3
+ "version": "0.9.1",
4
+ "description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "conduit": "dist/cli.js"