@bridge_gpt/mcp-server 0.2.6 → 0.2.10

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.
@@ -54,7 +54,7 @@
54
54
  * unit-testable on Linux CI without spawning real commands or terminals.
55
55
  */
56
56
  import { execFile } from "child_process";
57
- import { readFile, writeFile, mkdir, stat } from "fs/promises";
57
+ import { readFile, writeFile, mkdir, mkdtemp, stat, readdir, rm } from "fs/promises";
58
58
  import os from "node:os";
59
59
  import path from "path";
60
60
  import { VERSION } from "./version.generated.js";
@@ -105,6 +105,7 @@ export function getStartTicketsUsage() {
105
105
  " --base-branch BRANCH Cut new worktrees from BRANCH and refresh origin/BRANCH (default: main)",
106
106
  " --no-refresh-main Skip refresh of the configured base branch (default main); historical name retained for backward compatibility",
107
107
  " --max-parallel N Max worktrees to create concurrently (default: 3)",
108
+ " --conductor Enable the Conductor system: per-worker hook injection + BAPI_CONDUCTOR_* env, a supervisor peer tab, and check_messages message-relay polling (default: off — a plain run just spawns cd <worktree> && <agent> '/implement-ticket <KEY>')",
108
109
  " -h, --help Show this help",
109
110
  "",
110
111
  "Environment:",
@@ -114,12 +115,14 @@ export function getStartTicketsUsage() {
114
115
  " BAPI_CONDUCTOR_SUPERVISOR_MODE Conductor supervisor mode (default: auto when --auto, else interactive)",
115
116
  " BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE Set 1/true to also register a PreToolUse conductor hook",
116
117
  "",
117
- "Conductor observability:",
118
- " Real Claude Code workers launched by start-tickets receive per-worktree conductor",
119
- " hook injection (into .claude/settings.local.json) and emit local lifecycle events",
120
- " into the conductor ledger. Each real run mints one run_id and attributes worker",
121
- " events by worker_id, ticket key, and worktree path. Inspect the ledger with the",
122
- " `conductor` CLI.",
118
+ "Conductor observability (opt-in via --conductor):",
119
+ " With --conductor, real Claude Code workers launched by start-tickets receive",
120
+ " per-worktree conductor hook injection (into .claude/settings.local.json) and emit",
121
+ " local lifecycle events into the conductor ledger. Each such run mints one run_id",
122
+ " and attributes worker events by worker_id, ticket key, and worktree path, and a",
123
+ " supervisor peer tab is opened. Without --conductor none of this happens. Inspect",
124
+ " the ledger with the `conductor` CLI. The BAPI_CONDUCTOR_* env vars above apply",
125
+ " only when --conductor is set.",
123
126
  "",
124
127
  "Prerequisites:",
125
128
  " macOS wt, git, osascript",
@@ -145,6 +148,7 @@ export function parseStartTicketsArgs(argv) {
145
148
  let maxParallelRaw;
146
149
  let agentName = DEFAULT_AGENT_NAME;
147
150
  let baseBranch = "main";
151
+ let conductorEnabled = false;
148
152
  const branchEntries = [];
149
153
  const keys = [];
150
154
  for (let i = 0; i < argv.length; i++) {
@@ -254,6 +258,10 @@ export function parseStartTicketsArgs(argv) {
254
258
  autoApprove = true;
255
259
  continue;
256
260
  }
261
+ if (arg === "--conductor") {
262
+ conductorEnabled = true;
263
+ continue;
264
+ }
257
265
  if (arg === "--no-refresh-main") {
258
266
  refreshMain = false;
259
267
  continue;
@@ -329,7 +337,7 @@ export function parseStartTicketsArgs(argv) {
329
337
  }
330
338
  return {
331
339
  status: "ok",
332
- options: { keys, terminal, dryRun, autoApprove, refreshMain, maxParallel, branchOverrides, agentName, baseBranch },
340
+ options: { keys, terminal, dryRun, autoApprove, refreshMain, maxParallel, branchOverrides, agentName, baseBranch, conductorEnabled },
333
341
  };
334
342
  }
335
343
  /** Returns an error string for an unsafe branch name, or null when valid. */
@@ -397,7 +405,7 @@ export function getDefaultSpawnTerminalTabForPlatform(platform) {
397
405
  * are always honored). Returns a structured error for unsupported platforms;
398
406
  * never throws.
399
407
  */
400
- export function resolveStartTicketsPlatformConfig(deps, agent, autoApprove = false) {
408
+ export function resolveStartTicketsPlatformConfig(deps, agent, autoApprove = false, conductorEnabled = false, repoName = null) {
401
409
  if (!isSupportedStartTicketsPlatform(deps.platform)) {
402
410
  return { ok: false, error: unsupportedPlatformMessage(deps.platform) };
403
411
  }
@@ -407,7 +415,9 @@ export function resolveStartTicketsPlatformConfig(deps, agent, autoApprove = fal
407
415
  config: {
408
416
  platform,
409
417
  worktrunkBinary: resolveWorktrunkBinary(platform, deps.env),
410
- buildAgentShellCommand: (key, worktreePath, modelAlias) => buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias),
418
+ // Inject the resolved repo identity so the spawned worktree session never
419
+ // falls back to the basename-derived repo name (the 403 root cause).
420
+ buildAgentShellCommand: (key, worktreePath, modelAlias) => prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled), repoName, platform),
411
421
  spawnTerminalTab: deps.spawnTerminalTab,
412
422
  },
413
423
  };
@@ -415,6 +425,28 @@ export function resolveStartTicketsPlatformConfig(deps, agent, autoApprove = fal
415
425
  // ---------------------------------------------------------------------------
416
426
  // Pure escaping + slug helpers
417
427
  // ---------------------------------------------------------------------------
428
+ /**
429
+ * Prepend a `BAPI_REPO_NAME` environment assignment to a spawned shell command so
430
+ * the agent (and any MCP server it launches) resolves the repo identity the SAME
431
+ * way `start-tickets` did — instead of falling back to `path.basename(cwd)`, which
432
+ * yields the git/dir name (e.g. `bridge-gpt`) when the server-side project is
433
+ * registered under a different normalization (e.g. `bridge_gpt`). That mismatch
434
+ * is the documented cause of sporadic 403 "no API key configured" failures in
435
+ * freshly-spawned worktrees that lack a `.bridge/config`.
436
+ *
437
+ * Fail-open: a null/empty `repoName` returns the command unchanged (the inherited
438
+ * env / `.mcp.json` provisioning still applies). The assignment is platform
439
+ * correct — `export VAR='…' && …` on POSIX, `$env:VAR = '…'; …` on PowerShell —
440
+ * and the value is quoted with the same escaper used for the rest of the command.
441
+ */
442
+ export function prependRepoNameEnvAssignment(command, repoName, platform = "darwin") {
443
+ if (!repoName)
444
+ return command;
445
+ if (platform === "win32") {
446
+ return `$env:BAPI_REPO_NAME = ${powershellSquote(repoName)}; ${command}`;
447
+ }
448
+ return `export BAPI_REPO_NAME='${shSquoteInner(repoName)}' && ${command}`;
449
+ }
418
450
  /**
419
451
  * Escape a string for inclusion inside a single-quoted shell context: each
420
452
  * embedded single-quote becomes the sequence `'\''`. Returns only the inner
@@ -503,6 +535,10 @@ export function createDefaultStartTicketsDeps() {
503
535
  cwd: process.cwd(),
504
536
  // The platform router is the single source of truth for spawner selection.
505
537
  spawnTerminalTab: getDefaultSpawnTerminalTabForPlatform(process.platform),
538
+ // Deliver each worker command via a launch-script file rather than embedding
539
+ // a multi-KB command in the terminal spawn (robust on macOS AppleScript /
540
+ // Windows wt.exe paths). Tests omit this seam to keep the legacy inline path.
541
+ writeWorkerLaunchScript: defaultWriteWorkerLaunchScript,
506
542
  };
507
543
  return deps;
508
544
  }
@@ -808,6 +844,47 @@ export async function createWorktreeForTicket(deps, key, branchOverrides, worktr
808
844
  export async function createWorktrees(deps, options, worktrunkBinary) {
809
845
  return runWithConcurrency(options.keys, options.maxParallel, (key) => createWorktreeForTicket(deps, key, options.branchOverrides, worktrunkBinary, options.baseBranch));
810
846
  }
847
+ /**
848
+ * Resume-mode worktree resolution (BAPI-441). Instead of creating worktrees,
849
+ * locate each ticket's EXISTING worktree from `git worktree list --porcelain` and
850
+ * synthesize `created` rows pointing at the found path so the rest of the
851
+ * orchestration pipeline (MCP provisioning → conductor context → spawn) runs
852
+ * unchanged. A ticket is matched by exact branch (`resolveBranchForTicket`) first,
853
+ * then by any worktree branch that contains the ticket key (so an enriched
854
+ * `feature/<KEY>-<slug>` branch still resolves). Unmatched tickets become
855
+ * `create-failed` and are skipped downstream. Never throws.
856
+ */
857
+ export async function resumeWorktrees(deps, options) {
858
+ const list = await deps.runCommand("git", ["worktree", "list", "--porcelain"], {
859
+ cwd: deps.cwd,
860
+ });
861
+ if (!commandSucceeded(list)) {
862
+ return options.keys.map((key) => ({
863
+ key,
864
+ branch: resolveBranchForTicket(key, options.branchOverrides),
865
+ status: "create-failed",
866
+ error: "resume mode: git worktree list --porcelain failed; cannot locate existing worktree.",
867
+ }));
868
+ }
869
+ const entries = parseGitWorktreeList(list.stdout);
870
+ return options.keys.map((key) => {
871
+ const branch = resolveBranchForTicket(key, options.branchOverrides);
872
+ let entry = entries.find((e) => e.branch === branch);
873
+ if (!entry) {
874
+ const needle = key.toLowerCase();
875
+ entry = entries.find((e) => (e.branch ?? "").toLowerCase().includes(needle));
876
+ }
877
+ if (entry) {
878
+ return { key, branch: entry.branch ?? branch, status: "created", path: entry.path };
879
+ }
880
+ return {
881
+ key,
882
+ branch,
883
+ status: "create-failed",
884
+ error: `resume mode: no existing worktree found for ticket ${key} (branch '${branch}').`,
885
+ };
886
+ });
887
+ }
811
888
  // ---------------------------------------------------------------------------
812
889
  // Per-platform shell-command construction
813
890
  // ---------------------------------------------------------------------------
@@ -839,16 +916,22 @@ export function buildConductorMessageRelayLaunchInstruction() {
839
916
  /**
840
917
  * The starter prompt handed to the selected agent. Identical for every agent.
841
918
  * When `autoApprove` is set, the implementation agent runs hands-off
842
- * (`/implement-ticket <KEY> --auto`) — used by full-automation chains. The
843
- * conductor message-relay polling instruction is appended after the slash command
844
- * (BAPI-397) so launched workers actually poll `check_messages`.
919
+ * (`/implement-ticket <KEY> --auto`) — used by full-automation chains.
920
+ *
921
+ * The conductor message-relay polling instruction (BAPI-397) is appended after
922
+ * the slash command ONLY when `conductorEnabled` is set (the `--conductor`
923
+ * flag). A plain run returns the bare `/implement-ticket <KEY> [--auto]` so the
924
+ * worker is not told to poll `check_messages` for a conductor that is not
925
+ * running.
845
926
  */
846
927
  export function buildAgentPrompt(key, opts = {}) {
847
928
  // `modelAlias` is accepted for signature consistency only — the model is
848
929
  // injected as a `--model` flag (see buildAgentInvocationArgv), never embedded
849
930
  // in the prompt text.
850
931
  const command = `/implement-ticket ${key}${opts.autoApprove ? " --auto" : ""}`;
851
- return `${command} ${buildConductorMessageRelayLaunchInstruction()}`;
932
+ return opts.conductorEnabled
933
+ ? `${command} ${buildConductorMessageRelayLaunchInstruction()}`
934
+ : command;
852
935
  }
853
936
  /**
854
937
  * Build the ordered argv for an agent invocation:
@@ -887,13 +970,13 @@ export function buildAgentInvocation(agent, prompt, quote, modelAlias) {
887
970
  }
888
971
  }
889
972
  /** POSIX agent shell command: `cd '<path>' && <agent> [--model '<alias>'] '<prompt>'`. */
890
- export function buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias) {
891
- const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove }), (p) => `'${shSquoteInner(p)}'`, modelAlias);
973
+ export function buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false) {
974
+ const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled }), (p) => `'${shSquoteInner(p)}'`, modelAlias);
892
975
  return `cd '${shSquoteInner(worktreePath)}' && ${invocation}`;
893
976
  }
894
977
  /** PowerShell agent shell command: `Set-Location -LiteralPath '<path>'; <agent> [--model '<alias>'] '<prompt>'`. */
895
- export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias) {
896
- const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove }), powershellSquote, modelAlias);
978
+ export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false) {
979
+ const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled }), powershellSquote, modelAlias);
897
980
  return `Set-Location -LiteralPath ${powershellSquote(worktreePath)}; ${invocation}`;
898
981
  }
899
982
  /**
@@ -901,12 +984,32 @@ export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoA
901
984
  * platform. PowerShell on Windows; POSIX everywhere else (incl. the unsupported
902
985
  * dry-run fallback). The selected `agent` (never a module-level constant)
903
986
  * determines the launched command. An optional validated `modelAlias` is
904
- * injected as `--model` at the spawn boundary.
987
+ * injected as `--model` at the spawn boundary. `conductorEnabled` appends the
988
+ * BAPI-397 message-relay instruction to the prompt (opt-in via `--conductor`).
905
989
  */
906
- export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false, modelAlias) {
990
+ export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false, modelAlias, conductorEnabled = false) {
907
991
  if (platform === "win32")
908
- return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias);
909
- return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias);
992
+ return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled);
993
+ return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled);
994
+ }
995
+ /**
996
+ * Build the shell command run inside a spawned tab/session for an ARBITRARY
997
+ * prompt string, dispatched by platform. This is the command-agnostic seam used
998
+ * by callers other than `start-tickets` (e.g. the `install-bridge` subcommand)
999
+ * that need to launch the selected agent against a custom prompt rather than the
1000
+ * hard-coded `/implement-ticket <KEY>` prompt. PowerShell on Windows; POSIX
1001
+ * everywhere else. The directory-change and agent invocation are quoted with the
1002
+ * platform-correct quoter (`Set-Location -LiteralPath …;` on Windows, `cd '…' &&`
1003
+ * on POSIX). An optional validated `modelAlias` is injected as `--model` at the
1004
+ * spawn boundary, exactly like `buildAgentShellCommand`.
1005
+ */
1006
+ export function buildGenericAgentShellCommand(agent, prompt, cwd, platform = "darwin", modelAlias) {
1007
+ if (platform === "win32") {
1008
+ const invocation = buildAgentInvocation(agent, prompt, powershellSquote, modelAlias);
1009
+ return `Set-Location -LiteralPath ${powershellSquote(cwd)}; ${invocation}`;
1010
+ }
1011
+ const invocation = buildAgentInvocation(agent, prompt, (p) => `'${shSquoteInner(p)}'`, modelAlias);
1012
+ return `cd '${shSquoteInner(cwd)}' && ${invocation}`;
910
1013
  }
911
1014
  // ---------------------------------------------------------------------------
912
1015
  // Spawned-terminal titling (shared across platforms)
@@ -1183,6 +1286,151 @@ export async function spawnUnsupportedPlatformTerminalTab(deps, _terminal, _shel
1183
1286
  return { ok: false, error: unsupportedPlatformMessage(deps.platform) };
1184
1287
  }
1185
1288
  // ---------------------------------------------------------------------------
1289
+ // Per-worker launch script (robust spawn delivery)
1290
+ // ---------------------------------------------------------------------------
1291
+ /*
1292
+ * Why launch via a script file instead of embedding the command in the spawn?
1293
+ *
1294
+ * Each worker command grew from ~100 chars to >1 KB once the conductor identity
1295
+ * env (BAPI-394) and the message-relay launch instruction (BAPI-397) were added.
1296
+ * On macOS that whole string is escaped into an AppleScript literal and injected
1297
+ * via a `keystroke "t"` (Cmd-T) new-tab dance — a delivery path that is
1298
+ * length-, quoting-, and timing-sensitive (a long command racing a not-yet-ready
1299
+ * new-tab shell can land mangled / unexecuted). Windows (`wt.exe`) additionally
1300
+ * has to escape `;` to stop its own arg-splitter from truncating the command.
1301
+ *
1302
+ * Writing the full command to a tiny script and handing the terminal a short
1303
+ * `source <path>` runner sidesteps all of it: the spawned payload is a constant
1304
+ * ~40 chars regardless of how large the underlying command is, and the env +
1305
+ * `cd` + agent invocation live verbatim in the file. The runner is `source`d
1306
+ * (not exec'd) so the tab is left in the worktree after the agent exits, exactly
1307
+ * like the legacy inline path.
1308
+ */
1309
+ /** Filesystem-safe per-key launch-script basename fragment. */
1310
+ export function sanitizeKeyForLaunchScript(key) {
1311
+ const cleaned = key.replace(/[^A-Za-z0-9._-]/g, "_");
1312
+ return cleaned.length > 0 ? cleaned : "worker";
1313
+ }
1314
+ /**
1315
+ * Script body for the resolved platform. POSIX scripts carry a `bash` shebang
1316
+ * (harmless when the file is `source`d); Windows scripts are plain PowerShell.
1317
+ * A trailing newline keeps the final statement well-formed.
1318
+ */
1319
+ export function buildLaunchScriptContent(platform, fullCommand) {
1320
+ if (platform === "win32")
1321
+ return `${fullCommand}\n`;
1322
+ return `#!/usr/bin/env bash\n${fullCommand}\n`;
1323
+ }
1324
+ /** The short, escaping-immune runner the terminal executes: `source <path>`. */
1325
+ export function buildLaunchScriptRunnerCommand(platform, scriptPath) {
1326
+ // `.` (POSIX) / `.` (PowerShell) dot-source the script into the tab's shell so
1327
+ // `cd` persists and the user is left in the worktree once the agent exits.
1328
+ if (platform === "win32")
1329
+ return `. ${powershellSquote(scriptPath)}`;
1330
+ return `. '${shSquoteInner(scriptPath)}'`;
1331
+ }
1332
+ /**
1333
+ * Maximum age (ms) a `bridge-start-tickets/w-*` launch-script temp directory may
1334
+ * reach before `pruneStaleLaunchScripts` removes it. 24 hours is a sane ceiling
1335
+ * well past any live `start-tickets` session.
1336
+ */
1337
+ export const STALE_LAUNCH_SCRIPT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
1338
+ const defaultPruneStaleLaunchScriptsDeps = {
1339
+ readdir: (dir) => readdir(dir),
1340
+ stat: (p) => stat(p),
1341
+ rm: (p, options) => rm(p, options),
1342
+ now: () => Date.now(),
1343
+ };
1344
+ /**
1345
+ * IH-2 (PR #552 review): best-effort, age-based prune of stale `w-*` launch-script
1346
+ * temp directories left under `os.tmpdir()/bridge-start-tickets/` by
1347
+ * {@link defaultWriteWorkerLaunchScript}. Nothing else ever removes them, so on a
1348
+ * long-lived dev machine that runs `start-tickets` regularly they accumulate
1349
+ * indefinitely.
1350
+ *
1351
+ * Only directories whose name starts with `"w-"` are considered (the `mkdtemp`
1352
+ * prefix); anything older than {@link STALE_LAUNCH_SCRIPT_MAX_AGE_MS} is removed
1353
+ * recursively. Newer entries and unrelated files/dirs are left untouched.
1354
+ *
1355
+ * Fully fail-open: any error (missing parent dir, unreadable entry, stat/unlink
1356
+ * failure) is swallowed and never blocks or aborts a spawn — the same fail-open
1357
+ * discipline as the launch-script writer fallback in
1358
+ * {@link materializeWorkerLaunchCommand}.
1359
+ */
1360
+ export async function pruneStaleLaunchScripts(deps = defaultPruneStaleLaunchScriptsDeps) {
1361
+ try {
1362
+ const parent = path.join(os.tmpdir(), "bridge-start-tickets");
1363
+ let entries;
1364
+ try {
1365
+ entries = await deps.readdir(parent);
1366
+ }
1367
+ catch {
1368
+ // Parent dir missing/unreadable — nothing to prune.
1369
+ return;
1370
+ }
1371
+ const cutoff = deps.now() - STALE_LAUNCH_SCRIPT_MAX_AGE_MS;
1372
+ for (const entry of entries) {
1373
+ if (!entry.startsWith("w-"))
1374
+ continue;
1375
+ const full = path.join(parent, entry);
1376
+ try {
1377
+ const info = await deps.stat(full);
1378
+ if (info.mtimeMs < cutoff) {
1379
+ await deps.rm(full, { recursive: true, force: true });
1380
+ }
1381
+ }
1382
+ catch {
1383
+ // Per-entry stat/rm failure must never block orchestration.
1384
+ }
1385
+ }
1386
+ }
1387
+ catch {
1388
+ // Absolutely never throw — cleanup is best-effort only.
1389
+ }
1390
+ }
1391
+ /** Default real-filesystem launch-script writer (used in production). */
1392
+ export const defaultWriteWorkerLaunchScript = async ({ platform, key, content, }) => {
1393
+ const parent = path.join(os.tmpdir(), "bridge-start-tickets");
1394
+ await mkdir(parent, { recursive: true });
1395
+ // Atomically allocate a UNIQUE per-write subdirectory. Two workers can share a
1396
+ // sanitized key (a duplicate CLI arg like `BAPI-1 BAPI-1`, or two keys that
1397
+ // collide after sanitization); a fixed `launch-<key>.<ext>` path would let the
1398
+ // second write silently clobber the first before either tab `source`s it,
1399
+ // leaving the first worker running the second's command. `mkdtemp` gives each
1400
+ // write its own directory (collision-proof across workers AND concurrent
1401
+ // processes), so the readable per-key basename can stay for debuggability.
1402
+ const dir = await mkdtemp(path.join(parent, "w-"));
1403
+ const ext = platform === "win32" ? "ps1" : "sh";
1404
+ const file = path.join(dir, `launch-${sanitizeKeyForLaunchScript(key)}.${ext}`);
1405
+ // 0600: the file carries no secrets (conductor env is secret-free) but there is
1406
+ // no reason to make it world-readable; `source` only needs read.
1407
+ await writeFile(file, content, { mode: 0o600 });
1408
+ return file;
1409
+ };
1410
+ /**
1411
+ * Resolve the command actually handed to the terminal spawner for one worker.
1412
+ * When `deps.writeWorkerLaunchScript` is provided, the full command is persisted
1413
+ * to a script and a short `source <path>` runner is returned; if writing fails
1414
+ * for any reason the original inline command is returned (fail-open — a launch
1415
+ * never aborts because a temp file could not be written). When the seam is
1416
+ * absent, the inline command is returned unchanged (legacy behaviour).
1417
+ */
1418
+ export async function materializeWorkerLaunchCommand(deps, key, fullCommand) {
1419
+ if (!deps.writeWorkerLaunchScript)
1420
+ return fullCommand;
1421
+ try {
1422
+ const scriptPath = await deps.writeWorkerLaunchScript({
1423
+ platform: deps.platform,
1424
+ key,
1425
+ content: buildLaunchScriptContent(deps.platform, fullCommand),
1426
+ });
1427
+ return buildLaunchScriptRunnerCommand(deps.platform, scriptPath);
1428
+ }
1429
+ catch {
1430
+ return fullCommand;
1431
+ }
1432
+ }
1433
+ // ---------------------------------------------------------------------------
1186
1434
  // Tab spawning across created worktrees
1187
1435
  // ---------------------------------------------------------------------------
1188
1436
  /**
@@ -1204,7 +1452,11 @@ export async function spawnTabsForCreatedWorktrees(deps, rows, terminal, buildSh
1204
1452
  // (BAPI-394). No-op when the row carries no conductorEnv (dry-run, non-Claude
1205
1453
  // agents, or conductor disabled). Never mutates process/global env.
1206
1454
  const shellCommand = injectConductorEnvIntoShellCommand(deps.platform, baseShellCommand, row.conductorEnv);
1207
- const result = await deps.spawnTerminalTab(deps, terminal, shellCommand, {
1455
+ // Deliver the (potentially multi-KB) command via a launch-script file so the
1456
+ // terminal spawn payload stays tiny and escaping-immune. Fail-open / no-op
1457
+ // when no writer seam is configured (see materializeWorkerLaunchCommand).
1458
+ const runnableCommand = await materializeWorkerLaunchCommand(deps, row.key, shellCommand);
1459
+ const result = await deps.spawnTerminalTab(deps, terminal, runnableCommand, {
1208
1460
  key: row.key,
1209
1461
  worktreePath: row.path,
1210
1462
  });
@@ -1234,12 +1486,14 @@ export function buildDryRunResults(keys, overrides) {
1234
1486
  * PowerShell on Windows, `wt` + POSIX on macOS/Linux, and a non-throwing `wt` +
1235
1487
  * POSIX fallback for unsupported platforms.
1236
1488
  */
1237
- export function getDryRunPlatformDetails(agent, platform = process.platform, env = process.env, autoApprove = false) {
1489
+ export function getDryRunPlatformDetails(agent, platform = process.platform, env = process.env, autoApprove = false, conductorEnabled = false, repoName = null) {
1238
1490
  return {
1239
1491
  worktrunkBinary: resolveWorktrunkBinary(platform, env),
1240
1492
  // The builder accepts an optional resolved modelAlias; the dry-run caller
1241
1493
  // now passes the previewed tier's alias so `--model` shows in the preview.
1242
- buildAgentShellCommand: (key, worktreePath, modelAlias) => buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias),
1494
+ // The resolved repo name (when known) is injected as a BAPI_REPO_NAME prefix
1495
+ // so the dry-run preview matches the real spawn command exactly.
1496
+ buildAgentShellCommand: (key, worktreePath, modelAlias) => prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled), repoName, platform),
1243
1497
  };
1244
1498
  }
1245
1499
  /**
@@ -1268,8 +1522,8 @@ export function buildDryRunMcpProvisioningLines(worktreePath, platform = process
1268
1522
  * the secret-free MCP provisioning preview. Pure platform formatting only — no
1269
1523
  * preflight, no routing failures.
1270
1524
  */
1271
- export function buildDryRunDetailLines(agent, key, branch, platform = process.platform, env = process.env, baseBranch = "main", autoApprove = false, modelAlias = null) {
1272
- const { worktrunkBinary, buildAgentShellCommand: build } = getDryRunPlatformDetails(agent, platform, env, autoApprove);
1525
+ export function buildDryRunDetailLines(agent, key, branch, platform = process.platform, env = process.env, baseBranch = "main", autoApprove = false, modelAlias = null, conductorEnabled = false, repoName = null) {
1526
+ const { worktrunkBinary, buildAgentShellCommand: build } = getDryRunPlatformDetails(agent, platform, env, autoApprove, conductorEnabled, repoName);
1273
1527
  const wtArgs = buildWtSwitchArgs(branch, false, baseBranch);
1274
1528
  const agentInvocation = build(key, "<worktree-path>", modelAlias);
1275
1529
  return [
@@ -2155,6 +2409,11 @@ async function defaultClaimEpicDispatch(dispatchKey, runId, deps) {
2155
2409
  });
2156
2410
  }
2157
2411
  export async function orchestrateStartTickets(deps, options, overrides = {}) {
2412
+ // IH-2 (PR #552 review): best-effort prune of stale launch-script temp dirs
2413
+ // before doing anything else. Awaited (to avoid concurrent cleanup races) but
2414
+ // fully fail-open — it never throws, so a dry-run also benefits from cleanup
2415
+ // and a real run is never blocked or delayed into failure by I/O latency.
2416
+ await pruneStaleLaunchScripts();
2158
2417
  // Thread dependency-derived base branch from epic identity through the
2159
2418
  // existing --base-branch path (no new branch-cutting or refresh logic).
2160
2419
  if (options.epic?.base_branch) {
@@ -2175,7 +2434,20 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
2175
2434
  const preflight = await runPreflight(deps, options);
2176
2435
  if (!preflight.ok)
2177
2436
  return { ok: false, error: preflight.error };
2178
- const platformConfig = resolveStartTicketsPlatformConfig(deps, agent, options.autoApprove);
2437
+ // Resolve the run-level repo identity ONCE (BAPI_REPO_NAME, else .bridge/config)
2438
+ // and inject it into every spawned session. This stops a freshly-created
2439
+ // worktree from falling back to `path.basename(cwd)` — which yields the git/dir
2440
+ // name (e.g. `bridge-gpt`) and produces sporadic 403s when the server-side
2441
+ // project is registered under a different normalization (e.g. `bridge_gpt`).
2442
+ // Fail-open: an unresolved name is warned about loudly but never aborts the run.
2443
+ const resolveRepoNameFn = overrides.resolveRepoName ?? resolveStartTicketsRepoName;
2444
+ const resolvedRepoName = await resolveRepoNameFn(deps);
2445
+ if (!resolvedRepoName) {
2446
+ overrides.repoNameWarningLog?.("Warning: could not resolve the repo name (set BAPI_REPO_NAME or add a valid " +
2447
+ ".bridge/config). Spawned worktrees may fall back to the directory name and " +
2448
+ "hit 403 'repository not registered' if it differs from the Bridge API project name.");
2449
+ }
2450
+ const platformConfig = resolveStartTicketsPlatformConfig(deps, agent, options.autoApprove, options.conductorEnabled ?? false, resolvedRepoName);
2179
2451
  if (!platformConfig.ok)
2180
2452
  return { ok: false, error: platformConfig.error };
2181
2453
  const refresh = await refreshBaseBranch(deps, {
@@ -2190,7 +2462,14 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
2190
2462
  const materializeFn = overrides.materializeFileCredentials ?? materializeFileCredentialsForCreatedWorktrees;
2191
2463
  const detectTerminalFn = overrides.detectTerminal ?? detectTerminal;
2192
2464
  const spawnTabsFn = overrides.spawnTabsForCreatedWorktrees ?? spawnTabsForCreatedWorktrees;
2193
- const created = await createWorktreesFn(deps, options, platformConfig.config.worktrunkBinary);
2465
+ // BAPI-441: in resume mode, reuse the ticket's existing branch/worktree instead
2466
+ // of creating a new one (the re-dispatched worker continues the original
2467
+ // implementation). The synthesized `created` rows flow through the unchanged
2468
+ // provisioning → conductor → spawn pipeline below.
2469
+ const resumeWorktreesFn = overrides.resumeWorktrees ?? resumeWorktrees;
2470
+ const created = options.resumeMode
2471
+ ? await resumeWorktreesFn(deps, options)
2472
+ : await createWorktreesFn(deps, options, platformConfig.config.worktrunkBinary);
2194
2473
  // Synchronously provision secret-free worktree MCP registrations after
2195
2474
  // worktree creation and before launching the agent tab. Per-worktree
2196
2475
  // provisioning failures mark only that row `spawn-failed` (skipped by the
@@ -2331,6 +2610,11 @@ export async function runStartTicketsCli(argv, overrides = {}) {
2331
2610
  return 1;
2332
2611
  }
2333
2612
  if (options.dryRun) {
2613
+ // Resolve the repo identity for the preview so the dry-run command matches
2614
+ // what the real spawn injects (see prependRepoNameEnvAssignment). The real
2615
+ // run resolves it independently inside orchestrateStartTickets, so this is
2616
+ // only needed on the dry-run path.
2617
+ const dryRunRepoName = await resolveStartTicketsRepoName(deps);
2334
2618
  // Preview model routing too: resolve tiers read-only (fail-open) so the
2335
2619
  // dry-run shows the exact `--model` each tab would launch with, plus a
2336
2620
  // per-ticket routing decision line.
@@ -2342,7 +2626,7 @@ export async function runStartTicketsCli(argv, overrides = {}) {
2342
2626
  const branch = resolveBranchForTicket(key, options.branchOverrides);
2343
2627
  const routedRow = routedByKey.get(key);
2344
2628
  const modelAlias = routedRow?.modelAlias ?? null;
2345
- for (const line of buildDryRunDetailLines(agent, key, branch, deps.platform, deps.env, options.baseBranch, options.autoApprove, modelAlias)) {
2629
+ for (const line of buildDryRunDetailLines(agent, key, branch, deps.platform, deps.env, options.baseBranch, options.autoApprove, modelAlias, options.conductorEnabled ?? false, dryRunRepoName)) {
2346
2630
  log(line);
2347
2631
  }
2348
2632
  log(`DRY-RUN: model routing: ${formatModelRoutingLine(routedRow ?? { key, branch, status: "dry-run" }, agent)}`);
@@ -2361,12 +2645,20 @@ export async function runStartTicketsCli(argv, overrides = {}) {
2361
2645
  const result = await orchestrate(deps, options, {
2362
2646
  modelRoutingLog: log,
2363
2647
  modelRoutingWarningLog: errorLog,
2364
- // BAPI-394: opt the packaged CLI into conductor observability. Supplying the
2365
- // context seam activates the conductor stage inside orchestrate; the other
2366
- // two seams fall back to their real module implementations.
2367
- createConductorContext: createStartTicketsConductorContext,
2368
- provisionConductorHooksForRows,
2369
- emitStartTicketsRunStarted,
2648
+ repoNameWarningLog: errorLog,
2649
+ // Conductor is OPT-IN (BAPI-394): the three seams are supplied ONLY when the
2650
+ // user passes `--conductor`. Supplying `createConductorContext` activates the
2651
+ // conductor stage inside orchestrate (env injection + supervisor tab); the
2652
+ // other two seams fall back to their real module implementations. Omitting
2653
+ // them (the default) leaves rows untouched, so a plain run spawns the bare
2654
+ // `cd <worktree> && <agent> '/implement-ticket <KEY>'`.
2655
+ ...(options.conductorEnabled
2656
+ ? {
2657
+ createConductorContext: createStartTicketsConductorContext,
2658
+ provisionConductorHooksForRows,
2659
+ emitStartTicketsRunStarted,
2660
+ }
2661
+ : {}),
2370
2662
  });
2371
2663
  if (!result.ok) {
2372
2664
  errorLog(`Error: ${result.error}`);