@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.
- package/README.md +58 -5
- package/build/commands.generated.js +1 -1
- package/build/conductor/bridge-api-client.js +262 -35
- package/build/conductor/cli.js +22 -1
- package/build/conductor/doctor.js +34 -1
- package/build/conductor/done-gate.js +301 -58
- package/build/conductor/epic-reconcile.js +121 -4
- package/build/conductor/epic-runtime.js +299 -13
- package/build/conductor/epic-state.js +108 -9
- package/build/conductor/git-ci-types.js +6 -0
- package/build/conductor/pr-ci-producer.js +114 -15
- package/build/conductor/pr-review-producer.js +116 -0
- package/build/conductor/store.js +8 -1
- package/build/conductor/supervisor-message-relay.js +31 -0
- package/build/conductor/taxonomy.js +3 -0
- package/build/conductor/tools.js +2 -2
- package/build/index.js +356 -1086
- package/build/init.js +481 -0
- package/build/install-bridge.js +692 -0
- package/build/mcp-profile.js +43 -0
- package/build/readme.generated.js +1 -1
- package/build/start-tickets-conductor.js +1 -0
- package/build/start-tickets.js +328 -36
- package/build/upgrade-cli.js +154 -0
- package/build/version.generated.js +1 -1
- package/package.json +3 -2
package/build/start-tickets.js
CHANGED
|
@@ -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
|
-
"
|
|
119
|
-
" hook injection (into .claude/settings.local.json) and emit
|
|
120
|
-
" into the conductor ledger. Each
|
|
121
|
-
" events by worker_id, ticket key, and worktree path
|
|
122
|
-
"
|
|
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
|
-
|
|
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.
|
|
843
|
-
*
|
|
844
|
-
* (BAPI-397)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2365
|
-
//
|
|
2366
|
-
//
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
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}`);
|