@bridge_gpt/mcp-server 0.2.55 → 0.2.57

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.
@@ -56,10 +56,12 @@
56
56
  import { execFile } from "child_process";
57
57
  import { readFile, writeFile, mkdir, mkdtemp, stat, lstat, symlink, unlink, copyFile, readdir, rm, } from "fs/promises";
58
58
  import os from "node:os";
59
+ import { createHash } from "node:crypto";
59
60
  import path from "path";
61
+ import { validateHttpBaseUrl } from "./base-url.js";
60
62
  import { VERSION } from "./version.generated.js";
61
63
  import { resolveBapiCredentials, getPrimaryCredentialStorePath, } from "./credential-store.js";
62
- import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
64
+ import { LEGACY_SERVER_NAMES, MCP_PACKAGE_NAME, MCP_SERVER_NAME } from "./mcp-identity.js";
63
65
  // BAPI-941: the shared drift diagnostic formatter. The leaf drift module imports
64
66
  // nothing from here, so this direction stays acyclic.
65
67
  import { formatClaudeReviewWorkflowDriftDiagnostic } from "./claude-review-workflow-drift.js";
@@ -74,7 +76,10 @@ import { TICKET_KEY_PATTERN as SHARED_TICKET_KEY_PATTERN, TICKET_KEY_USAGE_SUMMA
74
76
  // can never drift. `start-tickets.ts` imports VALUES from there; the prereqs
75
77
  // module imports only TYPES back, so the runtime graph stays acyclic.
76
78
  import { WORKTRUNK_BINARY_OVERRIDE_ENV, WINDOWS_TERMINAL_COMMAND, WINDOWS_POWERSHELL_CANDIDATES, DEFAULT_WINDOWS_WORKTRUNK_BINARY, DEFAULT_POSIX_WORKTRUNK_BINARY, TMUX_COMMAND, GIT_FOR_WINDOWS_BASH_HINT, isSupportedStartTicketsPlatform, unsupportedPlatformMessage, resolveWorktrunkBinary, commandSucceeded, getCommandProbe, isCommandOnPath, resolveFirstCommandOnPath, enforcePreflightPrerequisites, appendDoctorHint, } from "./start-tickets-prereqs.js";
77
- import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, resolveModelAlias, isValidModelAlias, isModelTier, } from "./agent-registry.js";
79
+ import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, resolveModelAlias, isValidModelAlias, isModelTier, } from "./agent-registry.js";
80
+ import { agentForPlatform, getHostTarget, getWorktreeHostTargets, isRunnableHostTarget, isHostPlatformId } from "./mcp-host-targets.js";
81
+ import { resolveCodexLaunchScope } from "./codex-launch-scope.js";
82
+ import { tmpdir } from "node:os";
78
83
  import { createStartTicketsConductorContext, provisionConductorHooksForRows, emitStartTicketsRunStarted, injectConductorEnvIntoShellCommand, buildSupervisorTabCommand, isSupervisorLaunchEnabled, supervisorSpawnKey, } from "./start-tickets-conductor.js";
79
84
  import { transitionEpicDispatch, resolveConductorBridgeApiAccess, } from "./conductor/bridge-api-client.js";
80
85
  import { PR_BASE_BRANCH_ENV_VAR, buildPrBaseContractLaunchInstruction, } from "./pr-base-contract.js";
@@ -125,9 +130,11 @@ export function getStartTicketsUsage() {
125
130
  ` npx -y ${MCP_PACKAGE_NAME} start-tickets [flags] KEY [KEY ...]`,
126
131
  "",
127
132
  "Flags:",
128
- " --agent claude|cursor-agent Agent command to launch in each worktree (default: claude)",
129
- " --workflow implement|review-and-implement Slash command each spawned worktree runs (default: implement). review-and-implement runs /review-ticket then, after a per-ticket halt gate, /implement-ticket in the same session; --auto applies to the selected workflow.",
130
- " --tier cheap|basic|premium Coarse model-routing override: bypasses the per-ticket difficulty/tier lookup and applies this tier to every ticket. It is still mapped to a model through the agent registry and any configured difficulty_model_tier_overrides, then validated — it is NOT a raw --model alias, and never carries an API key or credential. A malformed value fails open to premium routing.",
133
+ " --agent claude|cursor-agent|codex Agent command to launch in each worktree (overrides --host; default: claude)",
134
+ " --host claude-code|cursor|codex|copilot-vscode|windsurf Current host when --agent is omitted; Claude Code, Cursor, and Codex can launch sessions",
135
+ " Cursor with --auto launches headlessly after a bounded readiness check; without --auto it stays interactive for the review gate.",
136
+ " --workflow implement|review-and-implement Command each spawned worktree runs (default: implement). review-and-implement runs review-ticket then, after a per-ticket halt gate, hands off to a fresh implement-ticket session; --auto applies to both phases.",
137
+ " --tier cheap|basic|premium Coarse model-routing override: bypasses the per-ticket difficulty/tier lookup and applies this tier to every ticket. Claude/Cursor use registry aliases and repository overrides; Codex uses the authenticated Bridge catalog. It is NOT a raw --model alias and never carries an API key or credential. A malformed value requests premium routing when available.",
131
138
  " --rounds 1|2 Review round count forwarded to the review phase; review-only, valid only with --workflow review-and-implement",
132
139
  " --terminal terminal|iterm Override the macOS terminal app (default: auto-detect via $TERM_PROGRAM); honored on macOS only",
133
140
  " --dry-run Print intended actions; creates no worktrees and opens no tabs, but DOES resolve model routing read-only (may compute+cache a ticket's difficulty) to preview the --model each tab would use",
@@ -163,6 +170,13 @@ export function getStartTicketsUsage() {
163
170
  TICKET_KEY_USAGE_SUMMARY,
164
171
  ].join("\n");
165
172
  }
173
+ /** Agent names backed by a runnable, worktree-scoped host registration. */
174
+ function formatStartTicketsAgentNames() {
175
+ return getWorktreeHostTargets()
176
+ .filter(isRunnableHostTarget)
177
+ .map((target) => target.launchAgent)
178
+ .join(", ");
179
+ }
166
180
  /**
167
181
  * Parse argv strictly and without an arg-parser dependency. Returns a
168
182
  * discriminated result: help, a validation error, or validated options.
@@ -177,7 +191,8 @@ export function parseStartTicketsArgs(argv) {
177
191
  let autoApprove = false;
178
192
  let refreshMain = true;
179
193
  let maxParallelRaw;
180
- let agentName = DEFAULT_AGENT_NAME;
194
+ let agentName;
195
+ let hostPlatform;
181
196
  let baseBranch = "main";
182
197
  let conductorEnabled = false;
183
198
  // BAPI-948: `--guard-stale-branch` (and `--conductor`, which implies it) opt the
@@ -217,12 +232,20 @@ export function parseStartTicketsArgs(argv) {
217
232
  if (!isAgentName(value)) {
218
233
  return {
219
234
  status: "error",
220
- message: `Invalid --agent value: '${value}' (allowed agents: ${formatValidAgentNames()}).`,
235
+ message: `Invalid --agent value: '${value}' (launchable agents: ${formatStartTicketsAgentNames()}).`,
221
236
  };
222
237
  }
223
238
  agentName = value;
224
239
  continue;
225
240
  }
241
+ if (arg === "--host" || arg.startsWith("--host=")) {
242
+ const value = arg.startsWith("--host=") ? arg.slice("--host=".length) : takeValue();
243
+ if (!value || !isHostPlatformId(value)) {
244
+ return { status: "error", message: `Invalid --host value: '${value ?? ""}'.` };
245
+ }
246
+ hostPlatform = value;
247
+ continue;
248
+ }
226
249
  if (arg === "--workflow" || arg.startsWith("--workflow=")) {
227
250
  let value;
228
251
  if (arg.startsWith("--workflow=")) {
@@ -452,6 +475,14 @@ export function parseStartTicketsArgs(argv) {
452
475
  }
453
476
  reviewRounds = reviewRoundsRaw === "1" ? 1 : 2;
454
477
  }
478
+ const target = hostPlatform ? getHostTarget(hostPlatform) : undefined;
479
+ if (!agentName && target && !isRunnableHostTarget(target)) {
480
+ return {
481
+ status: "error",
482
+ message: `${target.label} supports MCP configuration but cannot spawn a Bridge session. Continue the workflow manually or pass an explicit --agent override.`,
483
+ };
484
+ }
485
+ const resolvedAgentName = agentName ?? (hostPlatform ? agentForPlatform(hostPlatform) : DEFAULT_AGENT_NAME);
455
486
  return {
456
487
  status: "ok",
457
488
  options: {
@@ -462,7 +493,7 @@ export function parseStartTicketsArgs(argv) {
462
493
  refreshMain,
463
494
  maxParallel,
464
495
  branchOverrides,
465
- agentName,
496
+ agentName: resolvedAgentName,
466
497
  baseBranch,
467
498
  conductorEnabled,
468
499
  workflow,
@@ -530,7 +561,7 @@ resumeMode = false, workflow = "implement", reviewRounds,
530
561
  // conductor runs always, and (BAPI-801/BAPI-847) for every plain run whose base
531
562
  // resolved, `main` included — and threaded (BAPI-593) into the spawned workflow
532
563
  // command.
533
- baseBranch) {
564
+ baseBranch, bridgeApiBaseUrlOverride) {
534
565
  if (!isSupportedStartTicketsPlatform(deps.platform)) {
535
566
  return { ok: false, error: unsupportedPlatformMessage(deps.platform) };
536
567
  }
@@ -541,6 +572,7 @@ baseBranch) {
541
572
  // conductor-only — a plain worker's prompt stays bare and learns the base from
542
573
  // the env plus the create-PR instruction files.
543
574
  const prBaseBranch = resolvePrBaseBranchEnvValue(conductorEnabled, baseBranch);
575
+ const bridgeApiBaseUrl = bridgeApiBaseUrlOverride ?? resolveSpawnBridgeApiBaseUrl(deps.env);
544
576
  return {
545
577
  ok: true,
546
578
  config: {
@@ -550,7 +582,7 @@ baseBranch) {
550
582
  // falls back to the basename-derived repo name (the 403 root cause), and
551
583
  // (BAPI-586/BAPI-801) the run base so the spawned worker opens its PR
552
584
  // against it. The base assignment stays outermost.
553
- buildAgentShellCommand: (key, worktreePath, modelAlias) => prependBaseBranchEnvAssignment(prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch), repoName, platform), prBaseBranch, platform),
585
+ buildAgentShellCommand: (key, worktreePath, modelAlias, launchArgs) => prependBaseBranchEnvAssignment(prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl), repoName, platform), prBaseBranch, platform),
554
586
  spawnTerminalTab: deps.spawnTerminalTab,
555
587
  },
556
588
  };
@@ -580,6 +612,51 @@ export function prependRepoNameEnvAssignment(command, repoName, platform = "darw
580
612
  }
581
613
  return `export BAPI_REPO_NAME='${shSquoteInner(repoName)}' && ${command}`;
582
614
  }
615
+ /** Carry the parent's non-secret Bridge endpoint into a newly opened terminal. */
616
+ export function prependBridgeApiBaseUrlEnvAssignment(command, baseUrl, platform = "darwin") {
617
+ if (!baseUrl)
618
+ return command;
619
+ if (platform === "win32") {
620
+ return `$env:BAPI_BASE_URL = ${powershellSquote(baseUrl)}; ${command}`;
621
+ }
622
+ return `export BAPI_BASE_URL='${shSquoteInner(baseUrl)}' && ${command}`;
623
+ }
624
+ /** Validate before placing an endpoint in a launch script or MCP registration. */
625
+ export function resolveSpawnBridgeApiBaseUrl(env) {
626
+ const raw = env.BAPI_BASE_URL;
627
+ if (!raw || !raw.trim())
628
+ return undefined;
629
+ const result = validateHttpBaseUrl(raw);
630
+ return result.ok ? result.baseUrl : undefined;
631
+ }
632
+ /**
633
+ * A shell-spawned CLI cannot see the parent MCP process's environment. Read only
634
+ * the non-secret endpoint from the selected host's project registration when the
635
+ * shell did not inherit BAPI_BASE_URL (common for Cursor launched from the GUI).
636
+ */
637
+ export async function resolveStartTicketsBridgeApiBaseUrl(deps, agent) {
638
+ const inherited = resolveSpawnBridgeApiBaseUrl(deps.env);
639
+ if (inherited)
640
+ return inherited;
641
+ const configPath = agent.name === "cursor-agent"
642
+ ? path.join(deps.cwd, ".cursor", "mcp.json")
643
+ : path.join(deps.cwd, ".mcp.json");
644
+ try {
645
+ const raw = await (deps.readFile ?? ((filePath) => readFile(filePath, "utf-8")))(configPath);
646
+ const parsed = JSON.parse(raw);
647
+ const bridge = parsed.mcpServers?.[MCP_SERVER_NAME] ?? parsed.mcpServers?.[LEGACY_SERVER_NAMES[0]];
648
+ if (!bridge)
649
+ return undefined;
650
+ const args = Array.isArray(bridge.args) ? bridge.args : [];
651
+ const flagIndex = args.indexOf("--base-url");
652
+ const candidate = bridge.env?.BAPI_BASE_URL ?? (flagIndex >= 0 ? args[flagIndex + 1] : undefined);
653
+ const validated = validateHttpBaseUrl(candidate);
654
+ return validated.ok ? validated.baseUrl : undefined;
655
+ }
656
+ catch {
657
+ return undefined;
658
+ }
659
+ }
583
660
  /**
584
661
  * BAPI-801/BAPI-847: decide the `BAPI_BASE_BRANCH` value a spawned worker's shell
585
662
  * should carry — the single rule shared by the real spawn builder
@@ -775,6 +852,11 @@ export async function runPreflight(deps, options, warn = (message) => console.wa
775
852
  const enforceLiveSourceGuard = options.nonMutatingBase === true || options.epic !== undefined;
776
853
  const result = await enforcePreflightPrerequisites(deps, { enforceLiveSourceGuard });
777
854
  if (result.ok) {
855
+ if (options.agentName === "cursor-agent" && options.autoApprove) {
856
+ const cursorReady = await preflightHeadlessCursor(deps);
857
+ if (!cursorReady.ok)
858
+ return cursorReady;
859
+ }
778
860
  // Overridden collision: not silent — surface the warning through the channel.
779
861
  if (result.warning)
780
862
  warn(result.warning);
@@ -786,6 +868,52 @@ export async function runPreflight(deps, options, warn = (message) => console.wa
786
868
  // Both missing-prerequisite and live-source-collision are doctor-diagnosable.
787
869
  return { ok: false, error: appendDoctorHint(result.error) };
788
870
  }
871
+ /** Validate the actual headless path before creating any ticket worktree. */
872
+ async function preflightHeadlessCursor(deps) {
873
+ let help;
874
+ try {
875
+ help = await deps.runCommand("cursor-agent", ["--help"], { timeoutMs: 5000 });
876
+ }
877
+ catch {
878
+ return { ok: false, error: "Cursor Agent is unavailable. Install cursor-agent and ensure it is on PATH." };
879
+ }
880
+ if (help.exitCode !== 0) {
881
+ return { ok: false, error: "Cursor Agent is unavailable. Install cursor-agent and ensure it is on PATH." };
882
+ }
883
+ for (const flag of ["--print", "--output-format", "--trust", "--workspace", "--force", "--approve-mcps"]) {
884
+ if (!help.stdout.includes(flag)) {
885
+ return { ok: false, error: `Cursor Agent does not support ${flag}. Update cursor-agent before using --auto.` };
886
+ }
887
+ }
888
+ let probeDir;
889
+ try {
890
+ probeDir = await mkdtemp(path.join(tmpdir(), "bridge-cursor-ready-"));
891
+ }
892
+ catch {
893
+ return { ok: false, error: "Could not create a temporary workspace for the Cursor Agent readiness check." };
894
+ }
895
+ try {
896
+ const probe = await deps.runCommand("cursor-agent", [
897
+ "-p", "--output-format", "text", "--trust", "--workspace", probeDir,
898
+ "Reply with exactly BAPI_CURSOR_READY. Do not use tools.",
899
+ ], { cwd: probeDir, timeoutMs: 60000 });
900
+ if (probe.exitCode !== 0 || !probe.stdout.includes("BAPI_CURSOR_READY")) {
901
+ return { ok: false, error: "Cursor Agent could not complete a headless authentication check. Run cursor-agent login or configure CURSOR_API_KEY, then retry." };
902
+ }
903
+ return { ok: true };
904
+ }
905
+ catch {
906
+ return { ok: false, error: "Cursor Agent headless readiness check failed. Verify authentication and retry." };
907
+ }
908
+ finally {
909
+ try {
910
+ await rm(probeDir, { recursive: true, force: true });
911
+ }
912
+ catch {
913
+ // Cleanup failure must not turn a bounded readiness result into a raw error.
914
+ }
915
+ }
916
+ }
789
917
  // ---------------------------------------------------------------------------
790
918
  // main refresh + worktree parsing
791
919
  // ---------------------------------------------------------------------------
@@ -1192,17 +1320,23 @@ export function buildAgentPrompt(key, opts = {}) {
1192
1320
  // injected as a `--model` flag (see buildAgentInvocationArgv), never embedded
1193
1321
  // in the prompt text.
1194
1322
  const workflow = opts.workflow ?? "implement";
1195
- const head = workflow === "review-and-implement" ? "/review-and-implement" : "/implement-ticket";
1323
+ const commandName = workflow === "review-and-implement" ? "review-and-implement" : "implement-ticket";
1324
+ // Codex skills use explicit invocation names; Claude and Cursor use commands.
1325
+ const head = opts.agentName === "codex" ? `$bridge-${commandName}` : `/${commandName}`;
1196
1326
  let command = `${head} ${key}${opts.autoApprove ? " --auto" : ""}`;
1197
1327
  // Review-only arguments (`--rounds`, non-default `--base-branch`) must never
1198
1328
  // leak into the legacy `/implement-ticket` prompt.
1199
1329
  if (workflow === "review-and-implement") {
1330
+ if (opts.agentName === "cursor-agent")
1331
+ command += " --agent=cursor-agent";
1200
1332
  if (opts.reviewRounds !== undefined) {
1201
1333
  command += ` --rounds=${opts.reviewRounds}`;
1202
1334
  }
1203
1335
  if (opts.baseBranch !== undefined && opts.baseBranch !== "main") {
1204
1336
  command += ` --base-branch='${shSquoteInner(opts.baseBranch)}'`;
1205
1337
  }
1338
+ if (opts.agentName === "codex")
1339
+ command += " --agent codex --host codex";
1206
1340
  }
1207
1341
  const parts = [command];
1208
1342
  if (opts.conductorEnabled) {
@@ -1224,8 +1358,13 @@ export function buildAgentPrompt(key, opts = {}) {
1224
1358
  * otherwise they are omitted entirely (fail-open). The prompt is always the
1225
1359
  * final argument. Pure and never throws on an invalid alias.
1226
1360
  */
1227
- export function buildAgentInvocationArgv(agent, prompt, modelAlias) {
1361
+ export function buildAgentInvocationArgv(agent, prompt, modelAlias, launchArgsOrCursorWorkspace = []) {
1228
1362
  const argv = [agent.command];
1363
+ if (agent.name === "codex" && Array.isArray(launchArgsOrCursorWorkspace))
1364
+ argv.push(...launchArgsOrCursorWorkspace);
1365
+ if (agent.name === "cursor-agent" && typeof launchArgsOrCursorWorkspace === "string" && launchArgsOrCursorWorkspace) {
1366
+ argv.push("-p", "--output-format", "text", "--trust", "--approve-mcps", "--force", "--workspace", launchArgsOrCursorWorkspace);
1367
+ }
1229
1368
  if (agent.supportsModelOverride &&
1230
1369
  typeof modelAlias === "string" &&
1231
1370
  isValidModelAlias(modelAlias)) {
@@ -1241,10 +1380,10 @@ export function buildAgentInvocationArgv(agent, prompt, modelAlias) {
1241
1380
  * `--model <alias>` pair and the prompt — is run through the platform-correct
1242
1381
  * `quote`.
1243
1382
  */
1244
- export function buildAgentInvocation(agent, prompt, quote, modelAlias) {
1383
+ export function buildAgentInvocation(agent, prompt, quote, modelAlias, launchArgsOrCursorWorkspace = []) {
1245
1384
  switch (agent.promptArgStyle) {
1246
1385
  case "positional": {
1247
- const [command, ...rest] = buildAgentInvocationArgv(agent, prompt, modelAlias);
1386
+ const [command, ...rest] = buildAgentInvocationArgv(agent, prompt, modelAlias, launchArgsOrCursorWorkspace);
1248
1387
  const quotedRest = rest.map(quote);
1249
1388
  return [command, ...quotedRest].join(" ");
1250
1389
  }
@@ -1255,14 +1394,14 @@ export function buildAgentInvocation(agent, prompt, quote, modelAlias) {
1255
1394
  }
1256
1395
  }
1257
1396
  /** POSIX agent shell command: `cd '<path>' && <agent> [--model '<alias>'] '<prompt>'`. */
1258
- export function buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch) {
1259
- const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch }), (p) => `'${shSquoteInner(p)}'`, modelAlias);
1260
- return `cd '${shSquoteInner(worktreePath)}' && ${invocation}`;
1397
+ export function buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl) {
1398
+ const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, agentName: agent.name }), (p) => `'${shSquoteInner(p)}'`, modelAlias, agent.name === "cursor-agent" && autoApprove ? worktreePath : launchArgs);
1399
+ return `cd '${shSquoteInner(worktreePath)}' && ${prependBridgeApiBaseUrlEnvAssignment(invocation, bridgeApiBaseUrl, "darwin")}`;
1261
1400
  }
1262
1401
  /** PowerShell agent shell command: `Set-Location -LiteralPath '<path>'; <agent> [--model '<alias>'] '<prompt>'`. */
1263
- export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch) {
1264
- const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch }), powershellSquote, modelAlias);
1265
- return `Set-Location -LiteralPath ${powershellSquote(worktreePath)}; ${invocation}`;
1402
+ export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl) {
1403
+ const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, agentName: agent.name }), powershellSquote, modelAlias, agent.name === "cursor-agent" && autoApprove ? worktreePath : launchArgs);
1404
+ return `Set-Location -LiteralPath ${powershellSquote(worktreePath)}; ${prependBridgeApiBaseUrlEnvAssignment(invocation, bridgeApiBaseUrl, "win32")}`;
1266
1405
  }
1267
1406
  /**
1268
1407
  * Build the shell command run inside each spawned tab/session, dispatched by
@@ -1272,10 +1411,10 @@ export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoA
1272
1411
  * injected as `--model` at the spawn boundary. `conductorEnabled` appends the
1273
1412
  * BAPI-586 PR-base contract instruction to the prompt (opt-in via `--conductor`).
1274
1413
  */
1275
- export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch) {
1414
+ export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl) {
1276
1415
  if (platform === "win32")
1277
- return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch);
1278
- return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch);
1416
+ return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl);
1417
+ return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl);
1279
1418
  }
1280
1419
  /**
1281
1420
  * Build the shell command run inside a spawned tab/session for an ARBITRARY
@@ -1857,7 +1996,7 @@ export async function spawnTabsForCreatedWorktrees(deps, rows, terminal, buildSh
1857
1996
  out.push(row);
1858
1997
  continue;
1859
1998
  }
1860
- const baseShellCommand = buildShellCommand(row.key, row.path, row.modelAlias ?? null);
1999
+ const baseShellCommand = buildShellCommand(row.key, row.path, row.modelAlias ?? null, row.codexLaunchArgs);
1861
2000
  // Scope the row's conductor identity env to this terminal/tab/session only
1862
2001
  // (BAPI-394). No-op when the row carries no conductorEnv (dry-run, non-Claude
1863
2002
  // agents, or conductor disabled). Never mutates process/global env.
@@ -1912,9 +2051,11 @@ export function getDryRunPlatformDetails(agent, platform = process.platform, env
1912
2051
  // and (BAPI-801/BAPI-847) the PR base as a BAPI_BASE_BRANCH prefix through the
1913
2052
  // SAME resolvePrBaseBranchEnvValue rule — including the ordinary `main` case —
1914
2053
  // and the same outermost ordering the real spawn builder uses; dry-run shares
1915
- // both environment renderings with real spawning. Reuses the same buildAgentShellCommand/buildAgentPrompt path as a
1916
- // real spawn — dry-run is never special-cased — so the preview is exact.
1917
- buildAgentShellCommand: (key, worktreePath, modelAlias) => prependBaseBranchEnvAssignment(prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled, false, workflow, reviewRounds, baseBranch), repoName, platform), resolvePrBaseBranchEnvValue(conductorEnabled, baseBranch), platform),
2054
+ // both environment renderings with real spawning. Reuses the same
2055
+ // buildAgentShellCommand/buildAgentPrompt path as a real spawn. A Codex
2056
+ // dry-run has no created worktree to bind, so its preview omits the
2057
+ // process-local -C/-c MCP scope that the real spawn resolves later.
2058
+ buildAgentShellCommand: (key, worktreePath, modelAlias, launchArgs) => prependBaseBranchEnvAssignment(prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled, false, workflow, reviewRounds, baseBranch, launchArgs, resolveSpawnBridgeApiBaseUrl(env)), repoName, platform), resolvePrBaseBranchEnvValue(conductorEnabled, baseBranch), platform),
1918
2059
  };
1919
2060
  }
1920
2061
  /**
@@ -1923,7 +2064,7 @@ export function getDryRunPlatformDetails(agent, platform = process.platform, env
1923
2064
  * the `bapi` target. Lists both registration files and the version-pinned shim
1924
2065
  * command. Pure formatting — implies no credentials and writes no files.
1925
2066
  */
1926
- export function buildDryRunMcpProvisioningLines(worktreePath, platform = process.platform, mcpServerInvocation) {
2067
+ export function buildDryRunMcpProvisioningLines(worktreePath, platform = process.platform, mcpServerInvocation, bridgeApiBaseUrl) {
1927
2068
  const api = platform === "win32" ? path.win32 : path.posix;
1928
2069
  const mcpJson = api.join(worktreePath, ".mcp.json");
1929
2070
  const cursorJson = api.join(worktreePath, ".cursor", "mcp.json");
@@ -1944,6 +2085,7 @@ export function buildDryRunMcpProvisioningLines(worktreePath, platform = process
1944
2085
  `DRY-RUN: ${mcpJson}`,
1945
2086
  `DRY-RUN: ${cursorJson}`,
1946
2087
  `DRY-RUN: ${shim}`,
2088
+ ...(bridgeApiBaseUrl ? [`DRY-RUN: bapi shim adds --base-url ${bridgeApiBaseUrl}`] : []),
1947
2089
  ];
1948
2090
  }
1949
2091
  /**
@@ -1968,7 +2110,7 @@ export function buildDryRunDetailLines(agent, key, branch, platform = process.pl
1968
2110
  `DRY-RUN: ${worktrunkBinary} ${wtArgs.join(" ")}`,
1969
2111
  `DRY-RUN: ${agentInvocation}`,
1970
2112
  ...(prBaseBranch ? [`DRY-RUN: PR base: ${prBaseBranch}`] : []),
1971
- ...buildDryRunMcpProvisioningLines("<worktree-path>", platform, mcpServerInvocation),
2113
+ ...buildDryRunMcpProvisioningLines("<worktree-path>", platform, mcpServerInvocation, resolveSpawnBridgeApiBaseUrl(env)),
1972
2114
  ];
1973
2115
  }
1974
2116
  /**
@@ -2042,13 +2184,15 @@ export function formatSummaryReport(rows) {
2042
2184
  * Provisioning needs real `fs/promises` ops (not part of the command-runner DI
2043
2185
  * boundary), but inherits the platform and cwd from the orchestration deps.
2044
2186
  */
2045
- export function buildMcpProvisioningDeps(deps) {
2187
+ export function buildMcpProvisioningDeps(deps, bridgeApiBaseUrlOverride) {
2046
2188
  return {
2047
- readFile: (filePath) => readFile(filePath, "utf-8"),
2048
- writeFile: (filePath, data) => writeFile(filePath, data, "utf-8"),
2049
- mkdir: (dirPath, options) => mkdir(dirPath, options),
2189
+ env: deps.env,
2190
+ readFile: deps.readFile ?? ((filePath) => readFile(filePath, "utf-8")),
2191
+ writeFile: deps.writeFile ?? ((filePath, data) => writeFile(filePath, data, "utf-8")),
2192
+ mkdir: deps.mkdir ?? ((dirPath, options) => mkdir(dirPath, options)),
2050
2193
  platform: deps.platform,
2051
2194
  cwd: deps.cwd,
2195
+ bridgeApiBaseUrl: bridgeApiBaseUrlOverride ?? resolveSpawnBridgeApiBaseUrl(deps.env),
2052
2196
  // Resolve how the worker MCP shim launches at dispatch time from the running
2053
2197
  // conductor's own on-disk build (absolute-build-path primary, npm-channel
2054
2198
  // fallback) — never from process.cwd(). `nodeExecutable` is kept as "node"
@@ -2127,7 +2271,9 @@ export function formatModelRoutingDiagnosticLine(d) {
2127
2271
  const repo = d.repoName ?? "<unknown>";
2128
2272
  const target = d.storeTarget ?? (d.repoName ? `bapi:${d.repoName}` : "bapi:<repo>");
2129
2273
  const storePath = d.storePath ?? "~/.config/bridge/credentials.json";
2130
- const failOpen = "Assuming hard ticket; defaulting to premium/Opus when available.";
2274
+ const failOpen = d.agentName === "codex"
2275
+ ? "Codex continues with its configured default model."
2276
+ : "Assuming hard ticket; defaulting to premium/Opus when available.";
2131
2277
  const ticketSuffix = d.ticketKey ? ` for ${d.ticketKey}` : "";
2132
2278
  const statusSuffix = typeof d.status === "number" ? ` (HTTP ${d.status})` : "";
2133
2279
  switch (d.kind) {
@@ -2165,6 +2311,12 @@ export function formatModelRoutingDiagnosticLine(d) {
2165
2311
  case "no-tier":
2166
2312
  return (`model routing: Bridge API returned no usable tier${ticketSuffix}. Ticket spawning ` +
2167
2313
  `continues fail-open. ${failOpen}`);
2314
+ case "catalog-unavailable":
2315
+ return `model routing degraded: Codex model catalog unavailable. ${failOpen}`;
2316
+ case "catalog-invalid":
2317
+ return `model routing degraded: Codex model catalog failed validation. ${failOpen}`;
2318
+ case "codex-entitlement-unverified":
2319
+ return "model routing: Bridge permits the selected model; local Codex account entitlement cannot be verified non-interactively.";
2168
2320
  default:
2169
2321
  return `model routing degraded: ${d.message} ${failOpen}`;
2170
2322
  }
@@ -2176,6 +2328,7 @@ export function formatModelRoutingDiagnosticLine(d) {
2176
2328
  export const START_TICKETS_DEFAULT_BASE_URL = "https://bridgegpt-api.com";
2177
2329
  const TICKET_MODEL_TIER_FETCH_TIMEOUT_MS = 75_000;
2178
2330
  const CONFIG_FIELD_FETCH_TIMEOUT_MS = 30_000;
2331
+ const CODEX_CATALOG_FETCH_TIMEOUT_MS = 15_000;
2179
2332
  const CURSOR_MODEL_LIST_TIMEOUT_MS = 15_000;
2180
2333
  /** GET auth headers for the CLI's Bridge API calls. */
2181
2334
  function startTicketsGetHeaders(access) {
@@ -2388,10 +2541,12 @@ export async function fetchStartTicketsConfigField(access, fieldName) {
2388
2541
  * Any fetch failure returns a structured failure (with a classified diagnostic)
2389
2542
  * so the caller fails open to premium/Opus.
2390
2543
  */
2391
- export async function fetchDifficultyModelRoutingConfig(access) {
2544
+ export async function fetchDifficultyModelRoutingConfig(access, includeOverrides = true) {
2392
2545
  try {
2393
2546
  const enabledValue = await fetchStartTicketsConfigField(access, "difficulty_model_routing_enabled");
2394
- const overridesValue = await fetchStartTicketsConfigField(access, "difficulty_model_tier_overrides");
2547
+ const overridesValue = includeOverrides
2548
+ ? await fetchStartTicketsConfigField(access, "difficulty_model_tier_overrides")
2549
+ : undefined;
2395
2550
  const enabled = enabledValue === false ? false : true;
2396
2551
  return { ok: true, config: { enabled, overrides: normalizeDifficultyModelTierOverrides(overridesValue) } };
2397
2552
  }
@@ -2407,6 +2562,57 @@ export async function fetchDifficultyModelRoutingConfig(access) {
2407
2562
  };
2408
2563
  }
2409
2564
  }
2565
+ /** One bounded authenticated fetch; reject partial, unsafe, or stale wire data. */
2566
+ export async function fetchCodexModelCatalog(access) {
2567
+ let body;
2568
+ try {
2569
+ const url = buildStartTicketsJiraUrl(access.baseUrl, "/codex-model-catalog", {
2570
+ repo_name: access.repoName,
2571
+ });
2572
+ body = await fetchJsonWithTimeout(url, startTicketsGetHeaders(access), CODEX_CATALOG_FETCH_TIMEOUT_MS);
2573
+ }
2574
+ catch (err) {
2575
+ return {
2576
+ ok: false,
2577
+ warning: "model routing: Codex catalog unavailable; using configured default",
2578
+ diagnostic: {
2579
+ ...diagnosticFromFetchError(err, {
2580
+ repoName: access.repoName,
2581
+ fallbackKind: "catalog-unavailable",
2582
+ message: "Codex model catalog unavailable",
2583
+ }),
2584
+ agentName: "codex",
2585
+ },
2586
+ };
2587
+ }
2588
+ const invalid = () => ({
2589
+ ok: false,
2590
+ warning: "model routing: Codex catalog invalid; using configured default",
2591
+ diagnostic: makeModelRoutingDiagnostic({
2592
+ kind: "catalog-invalid",
2593
+ repoName: access.repoName,
2594
+ agentName: "codex",
2595
+ message: "Codex model catalog failed validation",
2596
+ }),
2597
+ });
2598
+ if (!body || typeof body !== "object" || Array.isArray(body))
2599
+ return invalid();
2600
+ const obj = body;
2601
+ if (!obj.models || typeof obj.models !== "object" || Array.isArray(obj.models))
2602
+ return invalid();
2603
+ const models = obj.models;
2604
+ const tiers = ["cheap", "basic", "premium"];
2605
+ if (Object.keys(models).length !== tiers.length)
2606
+ return invalid();
2607
+ if (!tiers.every((tier) => typeof models[tier] === "string" && isValidModelAlias(models[tier]))) {
2608
+ return invalid();
2609
+ }
2610
+ const canonical = tiers.map((tier) => `${tier}=${models[tier]}`).join("\n");
2611
+ const revision = `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`;
2612
+ if (obj.revision !== revision)
2613
+ return invalid();
2614
+ return { ok: true, catalog: { models: models, revision } };
2615
+ }
2410
2616
  /**
2411
2617
  * Fetch the coarse tier for one ticket with a bounded timeout. Never throws to
2412
2618
  * the batch orchestrator: failures/timeouts return a structured warning plus a
@@ -2532,6 +2738,24 @@ export function applyDefaultModelRoutingMetadata(row, reason, diagnostic) {
2532
2738
  ...(diagnostic ? { modelRoutingDiagnostic: diagnostic } : {}),
2533
2739
  };
2534
2740
  }
2741
+ function resolveRoutingAlias(agent, tier, overrides, catalog) {
2742
+ return agent.name === "codex"
2743
+ ? catalog?.models[tier] ?? null
2744
+ : resolveModelAlias(agent, tier, overrides);
2745
+ }
2746
+ function codexEntitlementDiagnostic(repoName) {
2747
+ return makeModelRoutingDiagnostic({
2748
+ kind: "codex-entitlement-unverified",
2749
+ agentName: "codex",
2750
+ repoName,
2751
+ message: "Local Codex account entitlement cannot be verified non-interactively",
2752
+ });
2753
+ }
2754
+ function codexDefaultRows(rows, isEligible, warning, diagnostic) {
2755
+ return rows.map((row) => isEligible(row)
2756
+ ? appendSummaryRowWarning(applyDefaultModelRoutingMetadata(row, warning, diagnostic ? { ...diagnostic, agentName: "codex" } : undefined), warning)
2757
+ : row);
2758
+ }
2535
2759
  /**
2536
2760
  * Resolve + validate the premium (Opus) alias ONCE and return a reusable applier.
2537
2761
  *
@@ -2547,12 +2771,14 @@ export function applyDefaultModelRoutingMetadata(row, reason, diagnostic) {
2547
2771
  * (e.g. it doesn't support --model, or verification fails) the applier falls back
2548
2772
  * to the true no-routing default so we never emit an invalid --model.
2549
2773
  */
2550
- async function makePremiumFallbackApplier(deps, agent, overrides) {
2551
- const alias = resolveModelAlias(agent, "premium", overrides);
2774
+ async function makePremiumFallbackApplier(deps, agent, overrides, catalog = null, repoName) {
2775
+ const alias = resolveRoutingAlias(agent, "premium", overrides, catalog);
2552
2776
  const validation = alias ? await validateResolvedModelAliasForAgent(deps, agent, alias) : null;
2553
2777
  return (row, warning, diagnostic) => {
2554
2778
  if (validation && validation.ok) {
2555
- const reason = `${warning}; defaulting to premium/Opus (model=${validation.alias})`;
2779
+ const reason = agent.name === "codex"
2780
+ ? `${warning}; defaulting to premium (model=${validation.alias})`
2781
+ : `${warning}; defaulting to premium/Opus (model=${validation.alias})`;
2556
2782
  return appendSummaryRowWarning({
2557
2783
  ...row,
2558
2784
  difficulty: row.difficulty ?? null,
@@ -2560,7 +2786,11 @@ async function makePremiumFallbackApplier(deps, agent, overrides) {
2560
2786
  modelAlias: validation.alias,
2561
2787
  modelRoutingSource: "fallback",
2562
2788
  modelRoutingReason: reason,
2563
- ...(diagnostic ? { modelRoutingDiagnostic: diagnostic } : {}),
2789
+ ...(diagnostic
2790
+ ? { modelRoutingDiagnostic: agent.name === "codex" ? { ...diagnostic, agentName: "codex" } : diagnostic }
2791
+ : agent.name === "codex" && repoName
2792
+ ? { modelRoutingDiagnostic: codexEntitlementDiagnostic(repoName) }
2793
+ : {}),
2564
2794
  }, warning);
2565
2795
  }
2566
2796
  // Premium alias unavailable for this agent — fall back to agent default.
@@ -2591,14 +2821,14 @@ const isDryRunRoutingEligible = (r) => r.status === "dry-run";
2591
2821
  * validated ONCE (identical for every row) so a cursor-agent batch fires at most
2592
2822
  * one advertised-model probe.
2593
2823
  */
2594
- async function applyInjectedTierRoutingToEligibleRows(deps, rows, isEligible, agent, overrides, tier) {
2824
+ async function applyInjectedTierRoutingToEligibleRows(deps, rows, isEligible, agent, overrides, tier, catalog = null, repoName) {
2595
2825
  const stampInjected = (row) => ({
2596
2826
  ...row,
2597
2827
  difficulty: null,
2598
2828
  modelTier: tier,
2599
2829
  modelRoutingSource: "injected",
2600
2830
  });
2601
- const alias = resolveModelAlias(agent, tier, overrides);
2831
+ const alias = resolveRoutingAlias(agent, tier, overrides, catalog);
2602
2832
  if (!alias) {
2603
2833
  const warning = `model routing: no valid alias for injected tier=${tier}; using agent default`;
2604
2834
  return rows.map((r) => isEligible(r)
@@ -2616,15 +2846,17 @@ async function applyInjectedTierRoutingToEligibleRows(deps, rows, isEligible, ag
2616
2846
  ...stampInjected(r),
2617
2847
  modelAlias: validation.alias,
2618
2848
  modelRoutingReason: `injected tier=${tier} model=${validation.alias}`,
2849
+ ...(agent.name === "codex" && repoName
2850
+ ? { modelRoutingDiagnostic: codexEntitlementDiagnostic(repoName) }
2851
+ : {}),
2619
2852
  }
2620
2853
  : r);
2621
2854
  }
2622
2855
  /**
2623
2856
  * Shared fail-open routing resolution. `isEligible` selects which rows get a
2624
- * resolved tier/alias; every failure mode (unsupported agent, credential/config/
2625
- * tier fetch failure, backend fallback, invalid/unavailable alias) is converted
2626
- * to `modelAlias: null` plus a row warning, and never throws. Non-eligible rows
2627
- * pass through untouched.
2857
+ * resolved tier/alias. Codex fails open to its configured default with no
2858
+ * `--model`; Claude/Cursor retain their premium fallback. Non-eligible rows
2859
+ * pass through untouched, and routing failures never abort worktree creation.
2628
2860
  */
2629
2861
  async function resolveModelRoutingForEligible(deps, rows, options, agent, isEligible) {
2630
2862
  const eligible = rows.filter(isEligible);
@@ -2636,13 +2868,19 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
2636
2868
  }
2637
2869
  const accessResult = await resolveStartTicketsBridgeApiAccess(deps);
2638
2870
  if (!accessResult.ok) {
2871
+ if (agent.name === "codex") {
2872
+ return codexDefaultRows(rows, isEligible, accessResult.warning, accessResult.diagnostic);
2873
+ }
2639
2874
  // Genuine error reaching the backend — default unknown difficulty to premium/Opus,
2640
2875
  // stamping the access diagnostic onto every eligible row.
2641
2876
  return applyPremiumFallbackToEligibleRows(deps, rows, isEligible, agent, null, accessResult.warning, accessResult.diagnostic);
2642
2877
  }
2643
2878
  const access = accessResult.access;
2644
- const configResult = await fetchDifficultyModelRoutingConfig(access);
2879
+ const configResult = await fetchDifficultyModelRoutingConfig(access, agent.name !== "codex");
2645
2880
  if (!configResult.ok) {
2881
+ if (agent.name === "codex") {
2882
+ return codexDefaultRows(rows, isEligible, configResult.warning, configResult.diagnostic);
2883
+ }
2646
2884
  // Can't read routing config — treat as error and default to premium/Opus.
2647
2885
  return applyPremiumFallbackToEligibleRows(deps, rows, isEligible, agent, null, configResult.warning, configResult.diagnostic);
2648
2886
  }
@@ -2651,6 +2889,14 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
2651
2889
  const reason = "model routing: disabled for this repo; using agent default";
2652
2890
  return rows.map((r) => (isEligible(r) ? applyDefaultModelRoutingMetadata(r, reason) : r));
2653
2891
  }
2892
+ let catalog = null;
2893
+ if (agent.name === "codex") {
2894
+ const result = await fetchCodexModelCatalog(access);
2895
+ if (!result.ok) {
2896
+ return codexDefaultRows(rows, isEligible, result.warning, result.diagnostic);
2897
+ }
2898
+ catalog = result.catalog;
2899
+ }
2654
2900
  // BAPI-642: an explicitly injected `--tier` short-circuits the per-ticket
2655
2901
  // difficulty/tier lookup — `fetchTicketModelTiersForRows` is NEVER called for
2656
2902
  // any injected value. A recognized tier is applied to every eligible row; a
@@ -2660,11 +2906,11 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
2660
2906
  const injected = options.injectedTier;
2661
2907
  if (injected === INJECTED_TIER_UNRESOLVED) {
2662
2908
  const warning = "model routing: --tier was not one of cheap|basic|premium; defaulting to premium routing";
2663
- const apply = await makePremiumFallbackApplier(deps, agent, overrides);
2909
+ const apply = await makePremiumFallbackApplier(deps, agent, overrides, catalog, access.repoName);
2664
2910
  return rows.map((r) => (isEligible(r) ? apply(r, warning) : r));
2665
2911
  }
2666
2912
  if (injected) {
2667
- return applyInjectedTierRoutingToEligibleRows(deps, rows, isEligible, agent, overrides, injected);
2913
+ return applyInjectedTierRoutingToEligibleRows(deps, rows, isEligible, agent, overrides, injected, catalog, access.repoName);
2668
2914
  }
2669
2915
  const tierMap = await fetchTicketModelTiersForRows(access, eligible, options.maxParallel, isEligible);
2670
2916
  // Lazily resolve+validate the premium fallback alias at most once for this run
@@ -2672,7 +2918,7 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
2672
2918
  let premiumApplier = null;
2673
2919
  const applyPremiumFallback = async (row, warning, diagnostic) => {
2674
2920
  if (!premiumApplier)
2675
- premiumApplier = await makePremiumFallbackApplier(deps, agent, overrides);
2921
+ premiumApplier = await makePremiumFallbackApplier(deps, agent, overrides, catalog, access.repoName);
2676
2922
  return premiumApplier(row, warning, diagnostic);
2677
2923
  };
2678
2924
  const out = [];
@@ -2697,6 +2943,10 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
2697
2943
  ticketKey: row.key,
2698
2944
  message: `no tier resolved for ${row.key}`,
2699
2945
  });
2946
+ if (agent.name === "codex") {
2947
+ out.push(codexDefaultRows([row], () => true, warning, diagnostic)[0]);
2948
+ continue;
2949
+ }
2700
2950
  out.push(await applyPremiumFallback(row, warning, diagnostic));
2701
2951
  continue;
2702
2952
  }
@@ -2718,10 +2968,14 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
2718
2968
  ticketKey: row.key,
2719
2969
  message: `${row.key} resolved no tier (source=${source})`,
2720
2970
  });
2971
+ if (agent.name === "codex") {
2972
+ out.push(codexDefaultRows([baseRow], () => true, warning, diagnostic)[0]);
2973
+ continue;
2974
+ }
2721
2975
  out.push(await applyPremiumFallback(baseRow, warning, diagnostic));
2722
2976
  continue;
2723
2977
  }
2724
- const alias = resolveModelAlias(agent, tier, overrides);
2978
+ const alias = resolveRoutingAlias(agent, tier, overrides, catalog);
2725
2979
  if (!alias) {
2726
2980
  const warning = `model routing: no valid alias for tier=${tier}; using agent default`;
2727
2981
  out.push(appendSummaryRowWarning({ ...baseRow, modelAlias: null, modelRoutingReason: warning }, warning));
@@ -2736,6 +2990,9 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
2736
2990
  ...baseRow,
2737
2991
  modelAlias: validation.alias,
2738
2992
  modelRoutingReason: `tier=${tier} model=${validation.alias} (source=${source})`,
2993
+ ...(agent.name === "codex"
2994
+ ? { modelRoutingDiagnostic: codexEntitlementDiagnostic(access.repoName) }
2995
+ : {}),
2739
2996
  });
2740
2997
  }
2741
2998
  return out;
@@ -2978,7 +3235,7 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
2978
3235
  if (!agent) {
2979
3236
  return {
2980
3237
  ok: false,
2981
- error: `Unknown agent: '${options.agentName}'. Valid agents: ${formatValidAgentNames()}.`,
3238
+ error: `Unknown agent: '${options.agentName}'. Launchable agents: ${formatStartTicketsAgentNames()}.`,
2982
3239
  };
2983
3240
  }
2984
3241
  const preflight = await runPreflight(deps, options, overrides.liveSourceWarningLog);
@@ -2992,6 +3249,7 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
2992
3249
  // Fail-open: an unresolved name is warned about loudly but never aborts the run.
2993
3250
  const resolveRepoNameFn = overrides.resolveRepoName ?? resolveStartTicketsRepoName;
2994
3251
  const resolvedRepoName = await resolveRepoNameFn(deps);
3252
+ const bridgeApiBaseUrl = await resolveStartTicketsBridgeApiBaseUrl(deps, agent);
2995
3253
  if (!resolvedRepoName) {
2996
3254
  overrides.repoNameWarningLog?.("Warning: could not resolve the repo name (set BAPI_REPO_NAME or add a valid " +
2997
3255
  ".bridge/config). Spawned worktrees may fall back to the directory name and " +
@@ -3004,7 +3262,7 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
3004
3262
  // epic.base_branch override applied above) so the spawned worker gets
3005
3263
  // BAPI_BASE_BRANCH — every conductor worker, and a plain worker whose base
3006
3264
  // is non-default.
3007
- options.baseBranch);
3265
+ options.baseBranch, bridgeApiBaseUrl);
3008
3266
  if (!platformConfig.ok)
3009
3267
  return { ok: false, error: platformConfig.error };
3010
3268
  // BAPI-527: resolve the base start point new worktrees are cut from BEFORE any
@@ -3052,9 +3310,9 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
3052
3310
  await emitClaudeReviewWorkflowDriftAdvisory(deps, options.baseBranch, overrides);
3053
3311
  const createWorktreesFn = overrides.createWorktrees ?? createWorktrees;
3054
3312
  const provisionCommandsFn = overrides.provisionCommands ??
3055
- ((rows, d) => provisionCommandsForCreatedWorktrees(rows, buildCommandProvisioningDeps(d)));
3313
+ ((rows, d) => provisionCommandsForCreatedWorktrees(rows, buildCommandProvisioningDeps(d), agent.name === "codex"));
3056
3314
  const provisionFn = overrides.provisionMcpRegistrations ??
3057
- ((rows, d) => provisionMcpRegistrationsForCreatedWorktrees(rows, buildMcpProvisioningDeps(d)));
3315
+ ((rows, d) => provisionMcpRegistrationsForCreatedWorktrees(rows, buildMcpProvisioningDeps(d, bridgeApiBaseUrl)));
3058
3316
  const materializeFn = overrides.materializeFileCredentials ?? materializeFileCredentialsForCreatedWorktrees;
3059
3317
  const detectTerminalFn = overrides.detectTerminal ?? detectTerminal;
3060
3318
  const spawnTabsFn = overrides.spawnTabsForCreatedWorktrees ?? spawnTabsForCreatedWorktrees;
@@ -3166,7 +3424,18 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
3166
3424
  // BAPI-844: stamp the validated scope on the spawnable rows immediately before
3167
3425
  // the spawn, so the value the tab is launched with is the one this run
3168
3426
  // declared. A run with no scope gets the identical `routed` array back.
3169
- const scoped = stampIndexScopeOnRows(routed, indexScope);
3427
+ let scoped = stampIndexScopeOnRows(routed, indexScope);
3428
+ if (agent.name === "codex") {
3429
+ const mcpDeps = buildMcpProvisioningDeps(deps);
3430
+ scoped = await Promise.all(scoped.map(async (row) => {
3431
+ if (row.status !== "created" || !row.path)
3432
+ return row;
3433
+ const scope = await (overrides.resolveCodexScope ?? resolveCodexLaunchScope)(row.path, mcpDeps);
3434
+ return scope.ok
3435
+ ? { ...row, codexLaunchArgs: scope.argv }
3436
+ : { ...row, status: "spawn-failed", error: scope.error };
3437
+ }));
3438
+ }
3170
3439
  const rows = await spawnTabsFn(deps, scoped, terminal, platformConfig.config.buildAgentShellCommand);
3171
3440
  return { ok: true, rows };
3172
3441
  }
@@ -3209,15 +3478,23 @@ export async function runStartTicketsCli(argv, overrides = {}) {
3209
3478
  return 1;
3210
3479
  }
3211
3480
  const options = parsed.options;
3212
- const deps = overrides.deps ?? createDefaultStartTicketsDeps();
3481
+ const rawDeps = overrides.deps ?? createDefaultStartTicketsDeps();
3213
3482
  const orchestrate = overrides.orchestrate ?? orchestrateStartTickets;
3214
3483
  // The parser validates `--agent`, so this resolves to a real spec; the guard
3215
3484
  // is defensive only.
3216
3485
  const agent = resolveAgentSpec(options.agentName);
3217
3486
  if (!agent) {
3218
- errorLog(`Error: Unknown agent: '${options.agentName}'. Valid agents: ${formatValidAgentNames()}.`);
3487
+ errorLog(`Error: Unknown agent: '${options.agentName}'. Launchable agents: ${formatStartTicketsAgentNames()}.`);
3219
3488
  return 1;
3220
3489
  }
3490
+ // The editor's MCP registration can have a local endpoint that its Bash tool
3491
+ // does not inherit. Use that endpoint for the CLI's own routing as well as for
3492
+ // its generated worktree registration and terminal command. Never mutate the
3493
+ // caller's environment or read/copy its API key from the registration.
3494
+ const bridgeApiBaseUrl = await resolveStartTicketsBridgeApiBaseUrl(rawDeps, agent);
3495
+ const deps = bridgeApiBaseUrl
3496
+ ? { ...rawDeps, env: { ...rawDeps.env, BAPI_BASE_URL: bridgeApiBaseUrl } }
3497
+ : rawDeps;
3221
3498
  // BAPI-642: a malformed `--tier` value is fail-open — emit ONE concise,
3222
3499
  // secret-free warning and continue (routing applies the premium fallback). It
3223
3500
  // never aborts the launch or changes the numeric exit code.