@miraland-labs/conduit-bridge 0.16.25 → 0.16.27

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
- **Package version:** `0.16.21` — mutate_repo with `pr_create` opens a pull request even when `change_scope` is empty. `ops disconnect` now asks before it removes the runner service and clears credentials; pass `--yes` to skip the question. `ops.env` is enrollment/bootstrap defaults only: an explicit workspace does not inherit an old repository, and project switches do not rewrite global project state. `ops enroll` exchanges a single-use token, registers the machine, binds project affinity, provisions fuel keys, brings detected drivers online, and starts the runner in one command. On Linux, `ops install` refuses any effective systemd drop-in. A switch intent remains queued until the target runner heartbeat proves Bound. After every Bridge publish, operators must re-run `ops install` (LaunchAgent pins an absolute `cli.js`).
5
+ **Package version:** `0.16.27` — work-package execution budgets reach every agent turn, are bounded by the machine ceiling, and expose their effective source for operations. Local-fuel provider refusals make the affected lane unavailable instead of repeatedly spending attempts; an operator who changes the lane's account or plan can invalidate that observation with `ops quota-clear <driver>`. Bridge discovers bounded repository verification commands, including Make targets `test`, `check`, `verify`, `replay`, `typecheck`, `lint`, and `build`. `ops disconnect` asks before it removes the runner service and clears credentials; pass `--yes` to skip the question. `ops.env` is enrollment/bootstrap defaults only: an explicit workspace does not inherit an old repository, and project switches do not rewrite global project state. `ops enroll` exchanges a single-use token, registers the machine, binds project affinity, provisions fuel keys, brings detected drivers online, and starts the runner in one command. On Linux, `ops install` refuses any effective systemd drop-in. A switch intent remains queued until the target runner heartbeat proves Bound. After every Bridge publish, operators must re-run `ops install` (LaunchAgent pins an absolute `cli.js`).
6
6
 
7
7
  ## Prerequisites
8
8
 
@@ -48,6 +48,7 @@ Later:
48
48
  ```bash
49
49
  npx @miraland-labs/conduit-bridge@latest ops online cursor
50
50
  npx @miraland-labs/conduit-bridge@latest ops offline cursor
51
+ npx @miraland-labs/conduit-bridge@latest ops quota-clear cursor # after changing its account or plan
51
52
  npx @miraland-labs/conduit-bridge@latest ops disconnect # then join again to change organizations
52
53
  ```
53
54
 
@@ -78,8 +79,12 @@ npx @miraland-labs/conduit-bridge disconnect --yes
78
79
  Before agent spend, Bridge checks that the workspace is readable, matches the expected repository,
79
80
  and is clean; each online driver must be installed, compatible with its configured fuel, and signed
80
81
  in when local fuel is used. The same bounded report is sent on heartbeat so Conduit can place an
81
- environment failure on Hold without consuming an execution attempt. Fix the named issue and let a
82
- fresh heartbeat land before choosing **Recheck** in Activity.
82
+ environment failure on Hold without consuming an execution attempt. A local provider refusal is
83
+ persisted and excludes only that lane; `online`/`offline` does not erase it. `ops doctor` proves CLI
84
+ installation and authentication, not remaining vendor allowance. After deliberately changing an
85
+ account or plan, run `ops quota-clear <driver>` to clear the old observation; this does not probe the
86
+ provider, and the next real claim records another refusal if the new allowance is still unavailable.
87
+ Fix the named issue and let a fresh heartbeat land before choosing **Recheck** in Activity.
83
88
 
84
89
  Conduit labels an exact driver/CLI version **Certified** only after two consecutive real canaries:
85
90
  repository delivery with PR/evidence and a successful rework cycle. All other lanes are
package/dist/brief.js CHANGED
@@ -2,6 +2,7 @@ import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
+ import { isBoundedVerificationCommand } from "./execution-class.js";
5
6
  const execFileAsync = promisify(execFile);
6
7
  const MANIFESTS = [
7
8
  "package.json", "wrangler.jsonc", "wrangler.toml", "tsconfig.json",
@@ -10,6 +11,7 @@ const MANIFESTS = [
10
11
  ];
11
12
  /** Prefer test before typecheck/lint so discovery order matches what pickVerificationCommand wants. */
12
13
  const VERIFICATION_SCRIPTS = ["test", "verify", "typecheck", "lint", "build"];
14
+ const MAKE_VERIFICATION_TARGETS = ["test", "check", "verify", "replay", "typecheck", "lint", "build"];
13
15
  const EXCLUDED_DIRECTORIES = new Set(["node_modules", "dist", "build", "target", "coverage", ".venv", "venv"]);
14
16
  export async function buildWorkspaceBrief(workspace) {
15
17
  const entries = await readdir(workspace, { withFileTypes: true });
@@ -197,8 +199,40 @@ async function gitDirectories(workspace) {
197
199
  }
198
200
  }
199
201
  /** Exported for tests — discovers bounded verification commands from workspace manifests. */
202
+ /**
203
+ * Commands a project declares for itself, in `.conduit/verification` — one per line, `#` comments
204
+ * ignored.
205
+ *
206
+ * Discovery below recognises the conventional names (`test`, `check`, `npm run verify`, …). A project
207
+ * whose gate is named anything else had no way to say so, and an acceptance criterion naming that
208
+ * gate was unrunnable and therefore unverifiable — which is how four agent attempts died on a target
209
+ * that was declared in the very Makefile Bridge had already read.
210
+ *
211
+ * The file is repository content and therefore UNTRUSTED. Every line must pass the same shape check
212
+ * every discovered command passes, so a declaration can name a gate but can never smuggle a command:
213
+ * one tool, one identifier, no shell metacharacters. That check is the boundary, not the name — a
214
+ * recipe body was always repository-authored.
215
+ */
216
+ const MAX_DECLARED_VERIFICATION = 8;
217
+ export function parseDeclaredVerification(text) {
218
+ const declared = [];
219
+ for (const raw of text.split("\n")) {
220
+ const line = raw.split("#")[0].trim();
221
+ if (!line || !isBoundedVerificationCommand(line))
222
+ continue;
223
+ if (!declared.includes(line))
224
+ declared.push(line);
225
+ if (declared.length >= MAX_DECLARED_VERIFICATION)
226
+ break;
227
+ }
228
+ return declared;
229
+ }
200
230
  export async function discoverVerificationCommands(workspace, files) {
201
231
  const commands = [];
232
+ try {
233
+ commands.push(...parseDeclaredVerification(await readFile(join(workspace, ".conduit", "verification"), "utf8")));
234
+ }
235
+ catch { /* no declaration, or unreadable: discovery below still applies */ }
202
236
  if (files.has("package.json")) {
203
237
  try {
204
238
  const manifest = JSON.parse(await readFile(join(workspace, "package.json"), "utf8"));
@@ -209,9 +243,10 @@ export async function discoverVerificationCommands(workspace, files) {
209
243
  if (files.has("Makefile")) {
210
244
  try {
211
245
  const makefile = await readFile(join(workspace, "Makefile"), "utf8");
212
- for (const target of ["test", "check"])
246
+ for (const target of MAKE_VERIFICATION_TARGETS) {
213
247
  if (new RegExp(`^${target}\\s*:`, "m").test(makefile))
214
248
  commands.push(`make ${target}`);
249
+ }
215
250
  }
216
251
  catch { /* unreadable Makefiles do not broaden execution */ }
217
252
  }
package/dist/cli.js CHANGED
@@ -8,12 +8,13 @@ import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
8
8
  import { stdin as input, stdout as output } from "node:process";
9
9
  import { fileURLToPath } from "node:url";
10
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";
11
+ import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearLocalConnection, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, saveConfigPrefs, saveDriverQuota, savePendingConnection, suggestMachineName, } from "./config.js";
12
12
  import { runMcp } from "./mcp.js";
13
13
  import { detectInstalledClients, localFuelOnlyClients, suggestFuelSource } from "./detect.js";
14
14
  import { DRIVERS } from "./driver.js";
15
- import { driversHeartbeatReport, driverLabel, heartbeatStatusForDrivers, isSupportedDriverId, listDriverLanes, onlineDriverIds, seedDriversFromDetection, seedDriverLanes, setDriverFuel, setDriversOnline, } from "./drivers.js";
15
+ import { driversHeartbeatReport, driverLabel, heartbeatStatusForDrivers, isSupportedDriverId, listDriverLanes, onlineDriverIds, seedDriversFromDetection, seedDriverLanes, setDriverFuel, setDriversOnline, laneDispatchBlock, laneStatuses, } from "./drivers.js";
16
16
  import { buildWorkspaceBrief } from "./brief.js";
17
+ import { parseAgentTimeoutMinutes } from "./execution-budget.js";
17
18
  import { BOOTSTRAP_RESULTS, buildSovereignExecutionFacts } from "./execution-facts.js";
18
19
  import { ensureCheckout } from "./checkout.js";
19
20
  import { buildOnShiftIntentProof, maybeApplyOnShiftIntent } from "./on-shift-apply.js";
@@ -382,7 +383,7 @@ async function initOps() {
382
383
  console.log(`Config file (separate): ${envPath}`);
383
384
  console.log("");
384
385
  console.log("Works the same on macOS, Linux, and Windows via:");
385
- console.log(` ${bridgeUsage("ops", "<connect|install|switch|online|offline|status|doctor|disconnect|uninstall>")}`);
386
+ console.log(` ${bridgeUsage("ops", "<connect|install|switch|online|offline|quota-clear|status|doctor|disconnect|uninstall>")}`);
386
387
  console.log("");
387
388
  console.log("1. Set enrollment/bootstrap defaults once:");
388
389
  console.log(` Create folder: ${configDir}`);
@@ -401,12 +402,12 @@ async function initOps() {
401
402
  console.log("3. Install runner:");
402
403
  console.log(` ${pathJoin(target, "install.sh")}`);
403
404
  }
404
- console.log("Later: status · online · offline · disconnect (same names .sh / .cmd)");
405
+ console.log("Later: status · online · offline · quota-clear · disconnect (same names .sh / .cmd)");
405
406
  }
406
407
  async function opsCommand() {
407
408
  const verb = process.argv[3];
408
409
  if (!verb || !OPS_VERBS.includes(verb)) {
409
- throw new Error(`Usage: ${bridgeUsage("ops", "<connect|install|switch|online|offline|status|doctor|disconnect|uninstall>", "[driver…]")} (install also takes --workspace <path> [--repo <url>])`);
410
+ throw new Error(`Usage: ${bridgeUsage("ops", "<connect|install|switch|online|offline|quota-clear|status|doctor|disconnect|uninstall>", "[driver…]")} (install also takes --workspace <path> [--repo <url>])`);
410
411
  }
411
412
  await runOps(verb, process.argv.slice(4));
412
413
  }
@@ -433,12 +434,21 @@ async function driversCommand() {
433
434
  return;
434
435
  }
435
436
  console.log(`Computer ${config.machineId} — shared capacity ${config.leaseCapacity} (sum across online lanes, not per IDE)`);
436
- for (const lane of lanes) {
437
- console.log(` ${lane.id.padEnd(14)} ${lane.state.padEnd(8)} fuel=${lane.fuel} (${lane.label})`);
437
+ // Allowance is printed beside state because the two disagree exactly when it matters: a lane can
438
+ // be installed, logged in and online while the provider is refusing it, and reporting only the
439
+ // first three reads as "Ready" for a lane dispatch is skipping.
440
+ for (const lane of laneStatuses(config)) {
441
+ const when = lane.observed_at ? ` observed=${lane.observed_at}` : "";
442
+ const reset = lane.resets_at ? ` resets=${lane.resets_at}` : lane.allowance === "unavailable" ? " resets=unknown" : "";
443
+ console.log(` ${lane.id.padEnd(14)} ${lane.state.padEnd(8)} fuel=${lane.fuel} allowance=${lane.allowance}${when}${reset} (${lane.label})`);
438
444
  }
445
+ const blocked = laneDispatchBlock(laneStatuses(config));
446
+ console.log(blocked
447
+ ? `Dispatch: BLOCKED — ${blocked}.`
448
+ : `Dispatch: ready — eligible ${laneStatuses(config).filter((lane) => lane.eligible).map((lane) => lane.id).join(", ")}.`);
439
449
  const online = onlineDriverIds(config);
440
450
  console.log(online.length
441
- ? `Online: ${online.join(", ")}. Toggle: ${bridgeUsage("drivers", "online|offline", "<id…>")}`
451
+ ? `Toggle a lane: ${bridgeUsage("drivers", "online|offline", "<id…>")}`
442
452
  : `All offline — no new claims. Bring one online: ${bridgeUsage("drivers", "online", AGENT_PLACEHOLDER)}`);
443
453
  return;
444
454
  }
@@ -458,6 +468,23 @@ async function driversCommand() {
458
468
  console.log(`Now online: ${onlineDriverIds(config).join(", ") || "(none)"}`);
459
469
  return;
460
470
  }
471
+ if (sub === "quota") {
472
+ const action = process.argv[4]?.trim();
473
+ const id = process.argv[5]?.trim();
474
+ if (action !== "clear" || !id || process.argv[6]) {
475
+ throw new Error(`Usage: ${bridgeUsage("drivers", "quota", "clear", AGENT_PLACEHOLDER)}`);
476
+ }
477
+ if (!isSupportedDriverId(id))
478
+ throw new Error(`Unknown driver: ${id}. Available: ${Object.keys(DRIVERS).join(", ")}`);
479
+ if (!listDriverLanes(config).some((lane) => lane.id === id))
480
+ throw new Error(`Driver lane is not registered: ${id}`);
481
+ // A login or plan change happens outside Bridge, so only the operator can invalidate the last
482
+ // observed refusal. This intentionally does not toggle the lane or synthesize a successful probe:
483
+ // the next real claim is the proof, and will record another refusal if allowance is still spent.
484
+ await saveDriverQuota(id, undefined);
485
+ console.log(`Cleared recorded provider allowance refusal for ${id}. The next claim will recheck this lane.`);
486
+ return;
487
+ }
461
488
  if (sub === "fuel") {
462
489
  const id = process.argv[4]?.trim();
463
490
  const mode = process.argv[5];
@@ -470,7 +497,7 @@ async function driversCommand() {
470
497
  console.log(`${driverLabel(id)} lane fuel set to ${mode}`);
471
498
  return;
472
499
  }
473
- throw new Error(`Usage: ${bridgeUsage("drivers", "[list|online|offline|fuel]", "…")}`);
500
+ throw new Error(`Usage: ${bridgeUsage("drivers", "[list|online|offline|fuel|quota]", "…")}`);
474
501
  }
475
502
  async function runner() {
476
503
  applyRunnerToolPath();
@@ -503,7 +530,17 @@ async function runner() {
503
530
  }
504
531
  const brief = workspace ? await buildWorkspaceBrief(workspace) : null;
505
532
  const intervalMs = values.interval ? Math.max(5_000, Number(values.interval)) : 15_000;
506
- const timeoutMs = values["agent-timeout-minutes"] ? Number(values["agent-timeout-minutes"]) * 60_000 : undefined;
533
+ // Validated at startup rather than at use. Number("abc") is NaN, NaN is not nullish, and the
534
+ // unvalidated value flowed straight past `input.timeoutMs ?? DEFAULT` to become the timer itself.
535
+ let timeoutMs;
536
+ try {
537
+ timeoutMs = parseAgentTimeoutMinutes(values["agent-timeout-minutes"]);
538
+ }
539
+ catch (error) {
540
+ console.error(error instanceof Error ? error.message : String(error));
541
+ process.exitCode = 1;
542
+ return;
543
+ }
507
544
  const canExecute = Boolean(workspace) && onlineDriverIds(config).length > 0;
508
545
  if (!workspace) {
509
546
  console.warn(`WARNING: heartbeat only — pass --workspace <repo>. Lanes: ${bridgeUsage("drivers", "online", AGENT_PLACEHOLDER)}`);
package/dist/driver.js CHANGED
@@ -1,9 +1,20 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { resolveAgentTimeout } from "./execution-budget.js";
2
3
  import { existsSync } from "node:fs";
3
4
  import { mkdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
4
5
  import { join } from "node:path";
5
6
  import { z } from "zod";
6
7
  import { deniedCommands, executionClassPromptRules, parsePiJsonl, projectClaude, projectCoarseMode, projectCodex, projectCursor, projectGrok, projectKiroTools, projectPi, requireStampedExecutionClass, } from "./execution-class.js";
8
+ /**
9
+ * The bound for this turn.
10
+ *
11
+ * One place, so the eight call sites cannot drift apart, and so a package budget has a single seam to
12
+ * arrive through when the contract carries one. `input.timeoutMs` is the machine ceiling the operator
13
+ * configured; `input.maxDurationMs` is what the package asked for and is treated as payload.
14
+ */
15
+ function agentTurnTimeoutMs(input) {
16
+ return resolveAgentTimeout({ machineCeilingMs: input.timeoutMs, packageBudgetMs: input.maxDurationMs }).timeoutMs;
17
+ }
7
18
  export { branchCreateCommands, deniedCommands, isBoundedVerificationCommand, prCreateCommands, requireStampedExecutionClass, } from "./execution-class.js";
8
19
  function deliveryLanguageRule(language) {
9
20
  if (language === "zh") {
@@ -502,7 +513,7 @@ export const claudeCodeDriver = {
502
513
  args.push("--disallowedTools", projected.disallowedTools.join(","));
503
514
  if (projected.acceptEdits)
504
515
  args.push("--permission-mode", "acceptEdits");
505
- const { code, stdout, stderr } = await execute(input.executable ?? "claude", args, input.workspace, input.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource);
516
+ const { code, stdout, stderr } = await execute(input.executable ?? "claude", args, input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource);
506
517
  let message = null;
507
518
  try {
508
519
  message = JSON.parse(stdout);
@@ -609,7 +620,7 @@ export const codexDriver = {
609
620
  diagnosis: input.workRole === "diagnose",
610
621
  });
611
622
  // Prompt via stdin avoids ARG_MAX limits on large assignment contracts.
612
- const { code, stdout, stderr } = await execute(input.executable ?? resolveCodexExecutable(), codexExecArgs({ ...input, networkAccess: projected.networkAccess }, projected.sandbox), input.workspace, input.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource, input.prompt);
623
+ const { code, stdout, stderr } = await execute(input.executable ?? resolveCodexExecutable(), codexExecArgs({ ...input, networkAccess: projected.networkAccess }, projected.sandbox), input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource, input.prompt);
613
624
  const parsed = parseCodexJsonl(stdout);
614
625
  const resultText = parsed.resultText ?? (stdout || null);
615
626
  if (code !== 0) {
@@ -741,7 +752,7 @@ export const cursorDriver = {
741
752
  capabilities: input.capabilities ?? [],
742
753
  diagnosis: input.workRole === "diagnose",
743
754
  });
744
- const configured = await withCursorPermissions(input.workspace, { allow: projected.allow, deny: projected.deny }, () => execute(executable, cursorRunArgs({ ...input, trustWorkspace, executionClass, force: projected.force }), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local"));
755
+ const configured = await withCursorPermissions(input.workspace, { allow: projected.allow, deny: projected.deny }, () => execute(executable, cursorRunArgs({ ...input, trustWorkspace, executionClass, force: projected.force }), input.workspace, agentTurnTimeoutMs(input), undefined, "local"));
745
756
  const { code, stdout, stderr } = configured;
746
757
  const parsed = parseCursorOutput(stdout);
747
758
  if (code !== 0 || parsed.isError) {
@@ -909,7 +920,7 @@ export const openCodeDriver = {
909
920
  }
910
921
  const args = openCodeRunArgs(input, agent);
911
922
  args.push(input.prompt);
912
- const { code, stdout, stderr } = await withOpenCodePermissions(input.workspace, () => execute(input.executable ?? "opencode", args, input.workspace, input.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource));
923
+ const { code, stdout, stderr } = await withOpenCodePermissions(input.workspace, () => execute(input.executable ?? "opencode", args, input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource));
913
924
  const parsed = parseOpenCodeOutput(stdout);
914
925
  if (code !== 0 || parsed.isError) {
915
926
  return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: (stderr || parsed.resultText || `opencode exited with code ${code}`).slice(0, 20_000) };
@@ -975,7 +986,7 @@ export const kiroDriver = {
975
986
  }
976
987
  const args = kiroChatArgs(input, trusted);
977
988
  args.push(input.prompt);
978
- const { code, stdout, stderr } = await execute(executable, args, input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local");
989
+ const { code, stdout, stderr } = await execute(executable, args, input.workspace, agentTurnTimeoutMs(input), undefined, "local");
979
990
  if (code !== 0) {
980
991
  return { status: "failed", resultText: stdout || null, sessionId: null, error: (stderr || stdout || `kiro-cli exited with code ${code}`).slice(0, 20_000) };
981
992
  }
@@ -1023,7 +1034,7 @@ export const antigravityDriver = {
1023
1034
  return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped Antigravity mode for active grants; refusing to start agent" };
1024
1035
  }
1025
1036
  // agy print mode emits plain text and does not surface a resumable id, so rework resume is not wired.
1026
- const { code, stdout, stderr } = await execute(input.executable ?? "agy", antigravityRunArgs({ prompt: input.prompt, grants: input.grants }, mode), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local");
1037
+ const { code, stdout, stderr } = await execute(input.executable ?? "agy", antigravityRunArgs({ prompt: input.prompt, grants: input.grants }, mode), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
1027
1038
  if (code !== 0) {
1028
1039
  return { status: "failed", resultText: stdout || null, sessionId: null, error: (stderr || stdout || `agy exited with code ${code}`).slice(0, 20_000) };
1029
1040
  }
@@ -1071,7 +1082,7 @@ export const piDriver = {
1071
1082
  if (version.code !== 0 || !(version.stdout || version.stderr).trim()) {
1072
1083
  return { status: "failed", resultText: null, sessionId: null, error: "pi preflight could not verify the installed CLI version" };
1073
1084
  }
1074
- const { code, stdout, stderr } = await execute(executable, piRunArgs({ prompt: input.prompt, tools: projected.tools, model: input.model }), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local");
1085
+ const { code, stdout, stderr } = await execute(executable, piRunArgs({ prompt: input.prompt, tools: projected.tools, model: input.model }), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
1075
1086
  const parsed = parsePiJsonl(stdout);
1076
1087
  if (code !== 0) {
1077
1088
  return {
@@ -1207,7 +1218,7 @@ export const grokDriver = {
1207
1218
  if (input.grants.includes("test_run") && !(input.verificationCommands?.length)) {
1208
1219
  return { status: "failed", resultText: null, sessionId: null, error: "grok preflight found no bounded verification command for the test_run grant" };
1209
1220
  }
1210
- const { code, stdout, stderr } = await execute(executable, grokRunArgs(input), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local");
1221
+ const { code, stdout, stderr } = await execute(executable, grokRunArgs(input), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
1211
1222
  const parsed = parseGrokOutput(stdout);
1212
1223
  if (code !== 0 || parsed.isError) {
1213
1224
  return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: (stderr || parsed.resultText || `grok exited with code ${code}`).slice(0, 20_000) };
@@ -1247,7 +1258,12 @@ function execute(executable, args, cwd, timeoutMs, fuel, fuelSource = "conduit",
1247
1258
  });
1248
1259
  let stdout = "";
1249
1260
  let stderr = "";
1250
- const timer = setTimeout(() => { child.kill("SIGTERM"); setTimeout(() => child.kill("SIGKILL"), 10_000).unref(); }, timeoutMs);
1261
+ let timedOut = false;
1262
+ const timer = setTimeout(() => {
1263
+ timedOut = true;
1264
+ child.kill("SIGTERM");
1265
+ setTimeout(() => child.kill("SIGKILL"), 10_000).unref();
1266
+ }, timeoutMs);
1251
1267
  // Bounded: a noisy or hostile local agent could otherwise grow these strings until the runner
1252
1268
  // dies. The report is the last fenced JSON block, so keeping the tail preserves what is parsed
1253
1269
  // while discarding the transcript ahead of it. Truncation is recorded, never silent.
@@ -1269,7 +1285,13 @@ function execute(executable, args, cwd, timeoutMs, fuel, fuelSource = "conduit",
1269
1285
  clearTimeout(timer);
1270
1286
  if (stdoutTruncated)
1271
1287
  console.error(`Agent output exceeded ${MAX_AGENT_OUTPUT} bytes; kept the tail for report parsing.`);
1272
- resolve({ code, stdout, stderr: stderr.slice(0, 20_000) });
1288
+ resolve({
1289
+ code,
1290
+ stdout,
1291
+ // Never let vendor stderr appended before SIGTERM turn a local timer into a quota refusal.
1292
+ // This stable prefix is classified by Bridge and the control plane as execution timeout.
1293
+ stderr: timedOut ? `agent_turn_timeout: agent turn exceeded ${timeoutMs}ms` : stderr.slice(0, 20_000),
1294
+ });
1273
1295
  });
1274
1296
  if (stdinText !== undefined && child.stdin) {
1275
1297
  // An agent that exits before reading its prompt closes this pipe, and the write then raises
package/dist/drivers.js CHANGED
@@ -150,6 +150,34 @@ export function listDriverLanes(config) {
150
150
  };
151
151
  });
152
152
  }
153
+ export function laneStatuses(config, now = Date.now()) {
154
+ return listDriverLanes(config).map((lane) => {
155
+ const quota = laneQuota(config, lane.id, now);
156
+ // A conduit-fuelled lane bills through the control plane, so a local refusal record says nothing
157
+ // about whether the next claim can run.
158
+ const local = lane.fuel === "local";
159
+ const allowance = !local || !quota
160
+ ? "unknown"
161
+ : quota.exhausted ? "unavailable" : "available";
162
+ return {
163
+ ...lane,
164
+ allowance,
165
+ observed_at: quota?.observed_at,
166
+ resets_at: quota?.resets_at ?? undefined,
167
+ eligible: lane.state === "online" && allowance !== "unavailable",
168
+ };
169
+ });
170
+ }
171
+ /** Why this machine cannot take a claim, or null when at least one lane can. */
172
+ export function laneDispatchBlock(statuses) {
173
+ if (statuses.some((lane) => lane.eligible))
174
+ return null;
175
+ if (!statuses.length)
176
+ return "no driver lane is registered";
177
+ if (!statuses.some((lane) => lane.state === "online"))
178
+ return "every lane is offline";
179
+ return "every online lane has an allowance the provider refused";
180
+ }
153
181
  export function onlineDriverIds(config) {
154
182
  const drivers = normalizeDrivers(config.drivers);
155
183
  return SUPPORTED_AGENTS.map((agent) => agent.id).filter((id) => drivers[id]?.state === "online");
@@ -0,0 +1,77 @@
1
+ /**
2
+ * How long one agent turn may run.
3
+ *
4
+ * Twenty minutes is a safety default for ordinary work, not a statement that longer work is
5
+ * ill-formed. A package should be split when its deliverables or authority boundaries are
6
+ * independent — never merely to fit a timer — so a cohesive task that genuinely needs thirty or
7
+ * sixty minutes must be able to say so.
8
+ *
9
+ * Two sources, and the smaller wins: the package asks for what the work needs, the machine caps what
10
+ * this host will tolerate. The operator's ceiling is the one that cannot be argued with by a payload.
11
+ */
12
+ export const DEFAULT_AGENT_TIMEOUT_MS = 20 * 60_000;
13
+ /**
14
+ * The longest turn any configuration may request.
15
+ *
16
+ * A bound this generous is not a scheduling opinion; it is the point past which a value is far more
17
+ * likely to be a mistake — a millisecond figure pasted where minutes were wanted — than an intention.
18
+ */
19
+ export const MAX_AGENT_TIMEOUT_MS = 4 * 60 * 60_000;
20
+ export const MIN_AGENT_TIMEOUT_MS = 60_000;
21
+ export class InvalidAgentTimeoutError extends Error {
22
+ }
23
+ /**
24
+ * Minutes from a command line, validated.
25
+ *
26
+ * `Number("abc")` is `NaN`, and `NaN` is not nullish — so an unvalidated value flowed straight past
27
+ * `input.timeoutMs ?? DEFAULT` and became the timer itself. A typo must fail loudly at startup, not
28
+ * silently produce a turn that never times out or one that fires at once.
29
+ */
30
+ export function parseAgentTimeoutMinutes(value) {
31
+ if (value === undefined)
32
+ return undefined;
33
+ const minutes = Number(value);
34
+ if (!Number.isFinite(minutes) || minutes <= 0) {
35
+ throw new InvalidAgentTimeoutError(`--agent-timeout-minutes must be a positive number of minutes, got ${JSON.stringify(value)}`);
36
+ }
37
+ const ms = Math.round(minutes * 60_000);
38
+ if (ms < MIN_AGENT_TIMEOUT_MS) {
39
+ throw new InvalidAgentTimeoutError(`--agent-timeout-minutes must be at least ${MIN_AGENT_TIMEOUT_MS / 60_000} minute`);
40
+ }
41
+ if (ms > MAX_AGENT_TIMEOUT_MS) {
42
+ throw new InvalidAgentTimeoutError(`--agent-timeout-minutes must not exceed ${MAX_AGENT_TIMEOUT_MS / 60_000} minutes`);
43
+ }
44
+ return ms;
45
+ }
46
+ /**
47
+ * The effective turn budget, and where it came from.
48
+ *
49
+ * The source is returned rather than inferred later because an operator reading a timed-out attempt
50
+ * needs to know which side set the bound; "it timed out" without that is not actionable.
51
+ *
52
+ * A package budget is payload, so it is bounded on both ends before use. A package asking for more
53
+ * than the machine allows is not an error — the machine simply wins, and the decision records that
54
+ * it was clamped so the difference is visible rather than mysterious.
55
+ */
56
+ export function resolveAgentTimeout(inputs = {}) {
57
+ const ceiling = usableMs(inputs.machineCeilingMs);
58
+ const requested = usableMs(inputs.packageBudgetMs);
59
+ if (requested === undefined) {
60
+ return ceiling === undefined
61
+ ? { timeoutMs: DEFAULT_AGENT_TIMEOUT_MS, source: "default" }
62
+ : { timeoutMs: ceiling, source: "machine" };
63
+ }
64
+ // With no operator ceiling the default is the ceiling: a payload must not be able to lengthen a
65
+ // turn on a host whose operator never opted into longer runs.
66
+ const limit = ceiling ?? DEFAULT_AGENT_TIMEOUT_MS;
67
+ if (requested > limit)
68
+ return { timeoutMs: limit, source: "clamped_to_machine" };
69
+ return { timeoutMs: requested, source: "package" };
70
+ }
71
+ function usableMs(value) {
72
+ if (typeof value !== "number" || !Number.isFinite(value))
73
+ return undefined;
74
+ if (value < MIN_AGENT_TIMEOUT_MS || value > MAX_AGENT_TIMEOUT_MS)
75
+ return undefined;
76
+ return Math.round(value);
77
+ }
@@ -5,7 +5,12 @@
5
5
  /** Bounded verification commands a `test_run` grant may execute. */
6
6
  export function isBoundedVerificationCommand(command) {
7
7
  // Exact forms only — a trailing `.*` on pytest would admit `pytest ; curl … | sh`.
8
- return /^(npm run (verify|typecheck|lint|test|build)|pnpm (verify|typecheck|lint|test|build)|yarn (verify|typecheck|lint|test|build)|cargo (test|check)|go test(?: \.\/\.\.\.)?|make (test|check)|python -m pytest|pytest(?: [\w./=-]+)?|\.\/gradlew test)$/.test(command);
8
+ // A target or script NAME is not a trust boundary: `make check` already runs whatever recipe the
9
+ // repository authored, so restricting the name to check/test grants no safety — it only stops a
10
+ // project from naming its own gate, which is what forced four attempts to die on a command they
11
+ // could not run. The boundary that matters is the SHAPE: one tool, one identifier, no shell
12
+ // metacharacters, so nothing can be smuggled in through the name.
13
+ return /^(npm run [A-Za-z0-9][A-Za-z0-9._:-]*|pnpm [A-Za-z0-9][A-Za-z0-9._:-]*|yarn [A-Za-z0-9][A-Za-z0-9._:-]*|cargo (test|check)|go test(?: \.\/\.\.\.)?|make [A-Za-z0-9][A-Za-z0-9._-]*|python -m pytest|pytest(?: [\w./=-]+)?|\.\/gradlew test)$/.test(command);
9
14
  }
10
15
  /** Shell commands each grant authorizes — mapped per driver so they cannot drift. */
11
16
  export const branchCreateCommands = [
@@ -76,7 +81,9 @@ export function executionClassPromptRules(input) {
76
81
  const artifact = executionClass === "publish_artifact";
77
82
  const rules = [];
78
83
  const grantShell = [
79
- ...(grants.includes("test_run") ? verificationCommands.filter(isBoundedVerificationCommand) : []),
84
+ ...(grants.includes("test_run")
85
+ ? verificationCommands.filter((command) => isBoundedVerificationCommand(command) && !deniedCommands.includes(command))
86
+ : []),
80
87
  ...(grants.includes("branch_create") ? branchCreateCommands : []),
81
88
  ...(grants.includes("pr_create") ? prCreateCommands : []),
82
89
  ];
@@ -123,5 +123,7 @@ export async function buildSovereignExecutionFacts(input) {
123
123
  verification_commands_digest: verificationCommandsDigest(input.brief?.verification ?? []),
124
124
  workspace_clean: input.workspaceClean,
125
125
  final_commit: commitOrNull(input.finalCommit),
126
+ ...(input.agentTimeoutMs !== undefined ? { agent_timeout_ms: input.agentTimeoutMs } : {}),
127
+ ...(input.agentTimeoutSource ? { agent_timeout_source: input.agentTimeoutSource } : {}),
126
128
  };
127
129
  }
package/dist/execution.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { z } from "zod";
4
+ import { resolveAgentTimeout } from "./execution-budget.js";
4
5
  import { ConduitRequestError } from "./client.js";
5
6
  import { redactSecrets, saveDriverQuota } from "./config.js";
6
7
  import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, extractAgentReportJsonText, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseJsonObjectCandidate, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
@@ -79,7 +80,7 @@ export function forgeTransportFailure(message) {
79
80
  * costs an evening. Deliberately narrow — an unmatched refusal degrades to an ordinary agent
80
81
  * failure, which is exactly today's behaviour.
81
82
  */
82
- export const SUBSCRIPTION_EXHAUSTED_PATTERN = /\b(?:usage|rate)[ _-]?limit(?:ed|s)?\b|\bquota (?:exceeded|exhausted|reached)\b|\b(?:http|status|code)\W{0,3}429\b|\b429\b(?=\W{0,3}too many)|too many requests|out of (?:credits|usage)/i;
83
+ export const SUBSCRIPTION_EXHAUSTED_PATTERN = /\b(?:usage|rate|session)[ _-]?limit(?:ed|s)?\b|\b(?:daily|weekly|monthly) limit\b|\bquota (?:exceeded|exhausted|reached)\b|\b(?:http|status|code)\W{0,3}429\b|\b429\b(?=\W{0,3}too many)|too many requests|out of (?:credits|usage)/i;
83
84
  /**
84
85
  * Does this failure mean the plan is spent, rather than the prompt being wrong?
85
86
  *
@@ -94,8 +95,43 @@ export const SUBSCRIPTION_EXHAUSTED_PATTERN = /\b(?:usage|rate)[ _-]?limit(?:ed|
94
95
  * sentence in the same string.
95
96
  */
96
97
  export function subscriptionExhausted(message) {
98
+ if (agentTurnTimedOut(message))
99
+ return false;
97
100
  return SUBSCRIPTION_EXHAUSTED_PATTERN.test(message);
98
101
  }
102
+ export function agentTurnTimedOut(message) {
103
+ return /^agent_turn_timeout:/i.test(message.trim());
104
+ }
105
+ function timeoutFailureBody(resolution, attemptId) {
106
+ const machineBound = resolution.source === "default"
107
+ || resolution.source === "machine"
108
+ || resolution.source === "clamped_to_machine";
109
+ const minutes = Math.round(resolution.timeoutMs / 60_000);
110
+ return {
111
+ failure: machineBound ? {
112
+ code: "agent_turn_timeout",
113
+ class: "environment",
114
+ disposition: "hold",
115
+ responsible_party: "computer_operator",
116
+ message: `The agent turn reached this computer's ${minutes}-minute execution limit.`,
117
+ next_action: "The computer operator can raise the Bridge agent timeout when this cohesive package needs longer. The product owner does not need to write technical constraints or split the package merely to fit the timer.",
118
+ diagnostic_detail: `agent_turn_timeout: ${resolution.timeoutMs}ms; source=${resolution.source}`,
119
+ } : {
120
+ code: "agent_turn_timeout",
121
+ class: "platform",
122
+ disposition: "stop",
123
+ responsible_party: "conduit",
124
+ message: `The agent turn reached Conduit's ${minutes}-minute package budget.`,
125
+ next_action: "Conduit must revise the mechanically derived execution budget or continue from a bounded checkpoint. The product owner does not need to author implementation constraints.",
126
+ diagnostic_detail: `agent_turn_timeout: ${resolution.timeoutMs}ms; source=${resolution.source}`,
127
+ },
128
+ error: `agent_turn_timeout: agent turn exceeded ${resolution.timeoutMs}ms`,
129
+ retryable: false,
130
+ idempotency_key: `bridge:agent-timeout:${attemptId}`,
131
+ };
132
+ }
133
+ class AgentTurnTimeoutError extends Error {
134
+ }
99
135
  /**
100
136
  * When the window refills, if the vendor said so.
101
137
  *
@@ -302,6 +338,7 @@ const workPackageSchema = z.object({
302
338
  failure_output: z.string().min(1).max(12_000),
303
339
  }).optional(),
304
340
  working_language: z.enum(["en", "zh"]).optional(),
341
+ max_duration_ms: z.number().int().min(60_000).max(4 * 60 * 60_000).optional(),
305
342
  }).nullable().optional();
306
343
  const diagnosticRepairBriefSchema = z.object({
307
344
  root_cause: z.string().trim().min(1).max(2_000),
@@ -728,6 +765,10 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
728
765
  const task = taskDetailSchema.parse(detail.task);
729
766
  const executionContract = executionContractSchema.parse(detail.execution_contract ?? {});
730
767
  const workPackage = workPackageSchema.parse(detail.work_package) ?? null;
768
+ const agentTimeout = resolveAgentTimeout({
769
+ machineCeilingMs: timeoutMs,
770
+ packageBudgetMs: workPackage?.max_duration_ms,
771
+ });
731
772
  const executionKind = task.execution_kind;
732
773
  const diagnosis = executionKind === "diagnosis";
733
774
  const sourceAttemptId = task.source_attempt_id ?? executionContract.source_attempt_id ?? active.sourceAttemptId ?? null;
@@ -992,6 +1033,8 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
992
1033
  executionClass,
993
1034
  claimedHead: executionContract.claimed_head ?? startCommit,
994
1035
  bootstrapResult: await bootstrapResultForWorkspace(workspace),
1036
+ agentTimeoutMs: agentTimeout.timeoutMs,
1037
+ agentTimeoutSource: agentTimeout.source,
995
1038
  });
996
1039
  await client.updateAttempt(taskId, { executionFacts });
997
1040
  await client.attemptRequest(taskId, "progress", {
@@ -1067,6 +1110,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1067
1110
  executionClass,
1068
1111
  resumeSessionId,
1069
1112
  timeoutMs,
1113
+ maxDurationMs: workPackage?.max_duration_ms,
1070
1114
  model: selection.model,
1071
1115
  fuel,
1072
1116
  fuelSource,
@@ -1153,12 +1197,25 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1153
1197
  }
1154
1198
  if (result.status === "failed") {
1155
1199
  const agentMessage = result.error ?? "Agent execution failed";
1200
+ if (agentTurnTimedOut(agentMessage)) {
1201
+ retainAttemptWorktree = true;
1202
+ const response = await queueTerminal(client, taskId, {
1203
+ action: "fail",
1204
+ body: timeoutFailureBody(agentTimeout, active.attemptId),
1205
+ });
1206
+ retainAttemptWorktree = response.retain_worktree === true;
1207
+ console.error(`Assignment ${taskId} timed out: ${redactSecrets(agentMessage)}`);
1208
+ return;
1209
+ }
1156
1210
  // The agent verifies inside its own tool loop, so its compiler/test output never reaches
1157
1211
  // result.error — production saw a bare git SHA and echoed source arrive as the "failure
1158
1212
  // reason", leaving Conductor nothing to diagnose. Re-run the project's bounded verification
1159
1213
  // in the attempt worktree to recover the real errors. Best-effort: keep the agent's own
1160
1214
  // message when there is no command, the command cannot run, or the tree actually verifies.
1161
- const verificationDetail = grants.includes("test_run")
1215
+ // A vendor allowance refusal is already a precise environment diagnosis. Running repository
1216
+ // verification after it can surface an unrelated pre-existing failure and overwrite the lane
1217
+ // Hold with a false source-repair brief.
1218
+ const verificationDetail = grants.includes("test_run") && !subscriptionExhausted(agentMessage)
1162
1219
  ? await captureVerificationFailure({
1163
1220
  workspace: attemptWorkspace,
1164
1221
  verificationCommands: attemptBrief?.verification ?? [],
@@ -1218,6 +1275,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1218
1275
  fuel,
1219
1276
  fuelSource,
1220
1277
  timeoutMs,
1278
+ maxDurationMs: workPackage?.max_duration_ms,
1221
1279
  resumeSessionId: agentSessionId,
1222
1280
  reason: "Agent claimed repository work but git shows none on the attempt branch; starting one land-only continuation turn instead of report-only repair.",
1223
1281
  idempotencyKey: `bridge:progress:${active.attemptId}:claims-git-gate`,
@@ -1254,6 +1312,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1254
1312
  fuel,
1255
1313
  fuelSource,
1256
1314
  timeoutMs,
1315
+ maxDurationMs: workPackage?.max_duration_ms,
1257
1316
  resumeSessionId: agentSessionId,
1258
1317
  reason: "Agent claimed repository work but git shows none; starting land-only continuation instead of read-only report repair.",
1259
1318
  idempotencyKey: `bridge:progress:${active.attemptId}:claims-git-gate-parse`,
@@ -1328,6 +1387,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1328
1387
  executionClass: "observe",
1329
1388
  resumeSessionId,
1330
1389
  timeoutMs,
1390
+ maxDurationMs: workPackage?.max_duration_ms,
1331
1391
  model: selection.model,
1332
1392
  fuel,
1333
1393
  fuelSource,
@@ -1416,6 +1476,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1416
1476
  fuel,
1417
1477
  fuelSource,
1418
1478
  timeoutMs,
1479
+ maxDurationMs: workPackage?.max_duration_ms,
1419
1480
  resumeSessionId: agentSessionId,
1420
1481
  reason: "Parsed Delivery claims repository work but git shows none; starting land-only continuation.",
1421
1482
  idempotencyKey: `bridge:progress:${active.attemptId}:claims-git-gate-report`,
@@ -1463,6 +1524,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1463
1524
  fuel,
1464
1525
  fuelSource,
1465
1526
  timeoutMs,
1527
+ maxDurationMs: workPackage?.max_duration_ms,
1466
1528
  resumeSessionId: agentSessionId,
1467
1529
  landContinuationUsed,
1468
1530
  });
@@ -1549,6 +1611,17 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1549
1611
  else
1550
1612
  console.error(`Assignment ${taskId} Delivery was rejected and routed to Conductor repair.`);
1551
1613
  }
1614
+ catch (error) {
1615
+ if (!(error instanceof AgentTurnTimeoutError))
1616
+ throw error;
1617
+ retainAttemptWorktree = true;
1618
+ const response = await queueTerminal(client, taskId, {
1619
+ action: "fail",
1620
+ body: timeoutFailureBody(agentTimeout, active.attemptId),
1621
+ });
1622
+ retainAttemptWorktree = response.retain_worktree === true;
1623
+ console.error(`Assignment ${taskId} timed out: ${redactSecrets(error.message)}`);
1624
+ }
1552
1625
  finally {
1553
1626
  releaseIdleSleep();
1554
1627
  clearInterval(renewTimer);
@@ -1616,12 +1689,15 @@ async function runLandContinuationTurn(input) {
1616
1689
  executionClass: "mutate_repo",
1617
1690
  resumeSessionId: input.resumeSessionId ?? undefined,
1618
1691
  timeoutMs: input.timeoutMs,
1692
+ maxDurationMs: input.maxDurationMs,
1619
1693
  model: input.selection.model ?? undefined,
1620
1694
  fuel: input.fuel,
1621
1695
  fuelSource: input.fuelSource,
1622
1696
  });
1623
1697
  await learnDriverFuel(input.config, input.driver.name, input.fuelSource ?? "conduit", continuation);
1624
1698
  if (continuation.status === "failed") {
1699
+ if (agentTurnTimedOut(continuation.error ?? ""))
1700
+ throw new AgentTurnTimeoutError(continuation.error);
1625
1701
  throw new AgentNoLandCommitError(input.spec.repository?.base_commit ?? "unknown");
1626
1702
  }
1627
1703
  return {
@@ -1676,6 +1752,7 @@ async function finalizeRepositoryLand(input) {
1676
1752
  fuel: input.fuel,
1677
1753
  fuelSource: input.fuelSource,
1678
1754
  timeoutMs: input.timeoutMs,
1755
+ maxDurationMs: input.maxDurationMs,
1679
1756
  resumeSessionId: input.resumeSessionId,
1680
1757
  reason: "Agent finished without landing repository changes; starting one land-only continuation turn.",
1681
1758
  idempotencyKey: `bridge:progress:${input.attemptId}:land-continuation`,
package/dist/ops.js CHANGED
@@ -13,13 +13,13 @@ import { ConduitClient } from "./client.js";
13
13
  import { loadConfig } from "./config.js";
14
14
  import { ensureCheckout } from "./checkout.js";
15
15
  import { detectInstalledClients, probeAgentHealth } from "./detect.js";
16
- import { driverIdsFromDetectedLabels } from "./drivers.js";
16
+ import { driverIdsFromDetectedLabels, laneDispatchBlock, laneStatuses } from "./drivers.js";
17
17
  import { BRIDGE_PROTOCOL_VERSION, describePreflightIssue, runBridgePreflight } from "./preflight.js";
18
18
  import { applyRunnerToolPath, readActiveRunnerServiceWorkspace, readStoredRunnerServiceOptions, runnerServiceWorkspaceWarnings } from "./service.js";
19
19
  import { bridgeVersion } from "./version.js";
20
20
  import { bootstrapManagedWorkspace } from "./workspace-bootstrap.js";
21
21
  export const OPS_VERBS = [
22
- "connect", "enroll", "install", "switch", "online", "offline", "status", "doctor", "disconnect", "uninstall",
22
+ "connect", "enroll", "install", "switch", "online", "offline", "quota-clear", "status", "doctor", "disconnect", "uninstall",
23
23
  ];
24
24
  const LOCAL_FUEL_DRIVERS = new Set(["cursor", "pi", "kiro", "antigravity", "grok"]);
25
25
  /**
@@ -245,6 +245,24 @@ export async function runOps(verb, argv = [], deps = {}) {
245
245
  if (verb === "status") {
246
246
  const packageVersion = bridgeVersion();
247
247
  console.log(`Bridge: v${packageVersion} (protocol ${BRIDGE_PROTOCOL_VERSION})`);
248
+ // Print lanes through the same projection `drivers list` uses. These two commands disagreeing
249
+ // about whether a lane was usable is what sent an operator looking for a product fault when the
250
+ // real answer was a spent provider allowance.
251
+ try {
252
+ const laneConfig = await (deps.loadBridgeConfig ?? loadConfig)();
253
+ const statuses = laneStatuses(laneConfig);
254
+ for (const lane of statuses) {
255
+ const when = lane.observed_at ? `, observed ${lane.observed_at}` : "";
256
+ const reset = lane.resets_at ? `, resets ${lane.resets_at}` : lane.allowance === "unavailable" ? ", reset unknown" : "";
257
+ console.log(`Lane ${lane.id}: ${lane.state}, fuel ${lane.fuel}, allowance ${lane.allowance}${when}${reset}`);
258
+ }
259
+ const blocked = laneDispatchBlock(statuses);
260
+ // Never say READY on the strength of a login alone; dispatch is what the operator is asking about.
261
+ console.log(blocked ? `Machine dispatch: BLOCKED — ${blocked}` : "Machine dispatch: ready");
262
+ }
263
+ catch {
264
+ console.log("Lanes: unavailable (no local Bridge configuration)");
265
+ }
248
266
  try {
249
267
  const config = await (deps.loadBridgeConfig ?? loadConfig)();
250
268
  const controller = new AbortController();
@@ -343,12 +361,18 @@ export async function runOps(verb, argv = [], deps = {}) {
343
361
  console.log(`- ${describePreflightIssue(issue)}`);
344
362
  throw new Error("Fix the preflight issues before retrying Conduit work");
345
363
  }
346
- // Lane toggles only need Bridge config + optional driver ids — not a full ops.env.
364
+ // Lane controls only need Bridge config + optional driver ids — not a full ops.env.
347
365
  if (verb === "online" || verb === "offline") {
348
366
  const drivers = await resolveDrivers(env, argv);
349
367
  runBridge(["drivers", verb, ...drivers]);
350
368
  return;
351
369
  }
370
+ if (verb === "quota-clear") {
371
+ if (argv.length !== 1)
372
+ throw new Error("Usage: ops quota-clear <driver>");
373
+ runBridge(["drivers", "quota", "clear", argv[0]]);
374
+ return;
375
+ }
352
376
  // Explicit install arguments are active-run inputs, not machine-wide defaults. Enrollment may
353
377
  // seed ops.env once; later project changes must not mutate it.
354
378
  let installEnv = env;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.16.25",
3
+ "version": "0.16.27",
4
4
  "description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity / Grok Build agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {