@loucompanion/forge-bridge 0.1.1-dev.8dcbc6245e51 → 0.1.1-dev.ec3c3e74a648

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.
@@ -176,7 +176,10 @@ export class ForgeBridgeService {
176
176
  repoMappings: this.config.repoMappings,
177
177
  os: this.config.os,
178
178
  sandboxProfiles: this.config.sandboxProfiles,
179
- supportsApiSecretGrant: this.config.supportsApiSecretGrant
179
+ supportsApiSecretGrant: this.config.supportsApiSecretGrant,
180
+ supportsManagedWorktrees: this.config.supportsManagedWorktrees,
181
+ supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
182
+ supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts
180
183
  };
181
184
  }
182
185
  async invokeAck(methodName, payload) {
@@ -270,6 +273,9 @@ export function defaultBridgeServiceConfig(overrides = {}) {
270
273
  os: process.platform,
271
274
  sandboxProfiles: ["default"],
272
275
  supportsApiSecretGrant: true,
276
+ supportsManagedWorktrees: true,
277
+ supportsManagedForgeArtifacts: true,
278
+ supportsRunnerAuthoredPhaseArtifacts: true,
273
279
  version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
274
280
  protocolVersion: 1,
275
281
  minServerVersion: null,
@@ -13,6 +13,9 @@ export interface BridgeServiceConfig {
13
13
  readonly os: string;
14
14
  readonly sandboxProfiles: readonly string[];
15
15
  readonly supportsApiSecretGrant: boolean;
16
+ readonly supportsManagedWorktrees: boolean;
17
+ readonly supportsManagedForgeArtifacts: boolean;
18
+ readonly supportsRunnerAuthoredPhaseArtifacts: boolean;
16
19
  readonly version: string;
17
20
  readonly protocolVersion: number;
18
21
  readonly minServerVersion?: string | null;
@@ -40,6 +43,9 @@ export interface BridgeCapabilities {
40
43
  readonly os?: string | null;
41
44
  readonly sandboxProfiles?: readonly string[] | null;
42
45
  readonly supportsApiSecretGrant?: boolean | null;
46
+ readonly supportsManagedWorktrees?: boolean | null;
47
+ readonly supportsManagedForgeArtifacts?: boolean | null;
48
+ readonly supportsRunnerAuthoredPhaseArtifacts?: boolean | null;
43
49
  }
44
50
  export interface BridgeHelloRequest {
45
51
  readonly version: string;
@@ -5,7 +5,8 @@ import type { CliIo, CliService } from "./cli.base.js";
5
5
  export declare class ForgeBridgeCliService implements CliService {
6
6
  private readonly sessions;
7
7
  private readonly bridgeFactory;
8
- constructor(sessions?: SessionService, bridgeFactory?: (config: BridgeServiceConfig) => BridgeService);
8
+ private readonly pairRunner;
9
+ constructor(sessions?: SessionService, bridgeFactory?: (config: BridgeServiceConfig) => BridgeService, pairRunner?: PairRunnerClient);
9
10
  run(argv: readonly string[], io?: CliIo): Promise<number>;
10
11
  }
11
12
  export declare function parseArgs(argv: readonly string[]): {
@@ -13,11 +14,18 @@ export declare function parseArgs(argv: readonly string[]): {
13
14
  readonly command: "run-once";
14
15
  readonly dispatchPath: string;
15
16
  readonly resultPath: string;
17
+ } | {
18
+ readonly ok: true;
19
+ readonly command: "mcp";
20
+ readonly argv: readonly string[];
16
21
  } | {
17
22
  readonly ok: true;
18
23
  readonly command: "connect";
19
24
  readonly config: BridgeServiceConfig;
20
- } | {
25
+ } | ({
26
+ readonly ok: true;
27
+ readonly command: "pair";
28
+ } & PairArgs) | {
21
29
  readonly ok: true;
22
30
  readonly command: "add-repo";
23
31
  readonly label: string;
@@ -41,5 +49,22 @@ export declare function parseArgs(argv: readonly string[]): {
41
49
  readonly ok: false;
42
50
  readonly error: string;
43
51
  };
52
+ interface PairArgs {
53
+ readonly serverUrl: string;
54
+ readonly code?: string;
55
+ readonly name: string;
56
+ readonly fingerprint: string;
57
+ readonly repoRoot?: string;
58
+ readonly cleanupWorktrees: boolean;
59
+ readonly cliProviders: readonly string[];
60
+ readonly projectLabels: readonly string[];
61
+ }
62
+ interface PairRunnerClaim {
63
+ readonly runnerId: string;
64
+ readonly deviceToken: string;
65
+ }
66
+ type PairRunnerClient = (args: PairArgs) => Promise<PairRunnerClaim>;
67
+ export declare function parseCodexModelCatalog(raw: string): string[];
68
+ export declare function parseCursorModelList(raw: string): string[];
44
69
  type CleanupMode = "stale" | "merged" | "all";
45
70
  export {};
@@ -1,20 +1,26 @@
1
1
  import fs from "node:fs/promises";
2
2
  import { execFile } from "node:child_process";
3
+ import os from "node:os";
3
4
  import path from "node:path";
5
+ import { createInterface } from "node:readline/promises";
4
6
  import { promisify } from "node:util";
7
+ import { resolveCursorAgentDirectLaunch } from "../shared/cursor-agent/cursor-agent.launch.js";
5
8
  import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
6
9
  import { defaultBridgeServiceConfig, ForgeBridgeService } from "../bridge/bridge.service.js";
7
- import { addRepoMapping, applyLocalConfigDefaults, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath } from "./local-config.js";
10
+ import { addRepoMapping, applyLocalConfigDefaults, providerStatusesFor, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath, writeLocalConfig } from "./local-config.js";
8
11
  import { BridgeSessionService } from "../session/session.service.js";
12
+ import { parseCompletionMcpArgs, runCompletionMcpServer } from "../session/phase-completion.js";
9
13
  import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
10
14
  const apiKeySecretGrantEnv = "FORGE_API_KEY_SECRET_GRANT";
11
15
  const execFileAsync = promisify(execFile);
12
16
  export class ForgeBridgeCliService {
13
17
  sessions;
14
18
  bridgeFactory;
15
- constructor(sessions = new BridgeSessionService(), bridgeFactory = (config) => new ForgeBridgeService(config)) {
19
+ pairRunner;
20
+ constructor(sessions = new BridgeSessionService(), bridgeFactory = (config) => new ForgeBridgeService(config), pairRunner = claimBridgeRunner) {
16
21
  this.sessions = sessions;
17
22
  this.bridgeFactory = bridgeFactory;
23
+ this.pairRunner = pairRunner;
18
24
  }
19
25
  async run(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
20
26
  const args = parseArgs(argv);
@@ -23,10 +29,11 @@ export class ForgeBridgeCliService {
23
29
  return 2;
24
30
  }
25
31
  if (args.command === "connect") {
32
+ const config = await withDetectedCliProviders(args.config);
26
33
  const missing = [
27
- ["server", args.config.serverUrl],
28
- ["runner id", args.config.runnerId],
29
- ["device token", args.config.deviceToken]
34
+ ["server", config.serverUrl],
35
+ ["runner id", config.runnerId],
36
+ ["device token", config.deviceToken]
30
37
  ].find(([, value]) => !value);
31
38
  if (missing) {
32
39
  io.stderr.write(`forge-bridge connect requires ${missing[0]}.\n`);
@@ -37,7 +44,7 @@ export class ForgeBridgeCliService {
37
44
  process.once("SIGINT", stop);
38
45
  process.once("SIGTERM", stop);
39
46
  try {
40
- await this.bridgeFactory(args.config).run(controller.signal);
47
+ await this.bridgeFactory(config).run(controller.signal);
41
48
  return 0;
42
49
  }
43
50
  finally {
@@ -58,6 +65,27 @@ export class ForgeBridgeCliService {
58
65
  return 2;
59
66
  }
60
67
  }
68
+ if (args.command === "pair") {
69
+ try {
70
+ const current = readLocalConfigSync();
71
+ const code = await resolvePairingCode(args.code);
72
+ const repoRoot = await resolvePairRepoRoot(args.repoRoot, current.defaultRepoRoot);
73
+ await fs.mkdir(repoRoot, { recursive: true });
74
+ const pairArgs = await withDetectedPairCliProviders({ ...args, code, repoRoot });
75
+ const claim = await this.pairRunner(pairArgs);
76
+ const next = pairedConfig(current, pairArgs, claim);
77
+ await writeLocalConfig(next);
78
+ io.stdout.write(`Paired runner ${claim.runnerId}.\n`);
79
+ io.stdout.write(`Repo folder: ${repoRoot}\n`);
80
+ io.stdout.write(`Config: ${resolveLocalConfigPath()}\n`);
81
+ io.stdout.write("Start the bridge with: forge-bridge connect\n");
82
+ return 0;
83
+ }
84
+ catch (error) {
85
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
86
+ return 1;
87
+ }
88
+ }
61
89
  if (args.command === "remove-repo") {
62
90
  try {
63
91
  const result = await removeRepoMapping(args.label);
@@ -76,6 +104,15 @@ export class ForgeBridgeCliService {
76
104
  if (args.command === "cleanup") {
77
105
  return await cleanupRepos(args.config, args.mode, args.force, io);
78
106
  }
107
+ if (args.command === "mcp") {
108
+ const parsed = parseCompletionMcpArgs(args.argv);
109
+ if (!parsed.ok) {
110
+ io.stderr.write(`${parsed.error}\n`);
111
+ return 2;
112
+ }
113
+ await runCompletionMcpServer(parsed.config);
114
+ return 0;
115
+ }
79
116
  let redactionSecrets = readEnvSecretValuesForRedaction();
80
117
  try {
81
118
  const dispatchJson = await fs.readFile(args.dispatchPath, "utf8");
@@ -157,6 +194,9 @@ export function parseArgs(argv) {
157
194
  if (argv[0] === "connect") {
158
195
  return parseConnectArgs(argv);
159
196
  }
197
+ if (argv[0] === "pair") {
198
+ return parsePairArgs(argv);
199
+ }
160
200
  if (argv[0] === "add-repo") {
161
201
  if (!argv[1] || !argv[2] || argv.length > 3) {
162
202
  return { ok: false, error: "Usage: forge-bridge add-repo <label> <path>" };
@@ -185,10 +225,13 @@ export function parseArgs(argv) {
185
225
  if (argv[0] === "cleanup") {
186
226
  return parseCleanupArgs(argv);
187
227
  }
228
+ if (argv[0] === "mcp") {
229
+ return { ok: true, command: "mcp", argv };
230
+ }
188
231
  if (argv[0] !== "run-once") {
189
232
  return {
190
233
  ok: false,
191
- error: "Usage: forge-bridge run-once --dispatch <dispatch-json-path> --result <result-json-path> | forge-bridge connect --server <url> --runner-id <id> --device-token <token> | forge-bridge add-repo <label> <path> | forge-bridge remove-repo <label> | forge-bridge status [--check-updates] | forge-bridge cleanup [--stale|--merged|--all|--force]"
234
+ error: usage()
192
235
  };
193
236
  }
194
237
  let dispatchPath = null;
@@ -223,7 +266,7 @@ function parseConnectArgs(argv) {
223
266
  let heartbeatIntervalMs = null;
224
267
  const cliProviders = [];
225
268
  const projectLabels = [];
226
- let cleanupWorktrees = true;
269
+ let cleanupWorktrees = null;
227
270
  for (let index = 1; index < argv.length; index += 1) {
228
271
  const arg = argv[index];
229
272
  if (arg === "--server") {
@@ -253,6 +296,9 @@ function parseConnectArgs(argv) {
253
296
  else if (arg === "--no-cleanup-worktrees") {
254
297
  cleanupWorktrees = false;
255
298
  }
299
+ else if (arg === "--cleanup-worktrees") {
300
+ cleanupWorktrees = true;
301
+ }
256
302
  else {
257
303
  return { ok: false, error: `Unknown argument: ${arg}` };
258
304
  }
@@ -261,7 +307,7 @@ function parseConnectArgs(argv) {
261
307
  if (serverUrl === null && !process.env.FORGE_URL?.trim() && !localConfig.serverUrl?.trim()) {
262
308
  return { ok: false, error: "forge-bridge connect requires --server or FORGE_URL." };
263
309
  }
264
- const overrides = { cleanupWorktrees };
310
+ const overrides = {};
265
311
  if (serverUrl !== null)
266
312
  overrides.serverUrl = serverUrl;
267
313
  if (runnerId !== null)
@@ -276,12 +322,79 @@ function parseConnectArgs(argv) {
276
322
  overrides.projectLabels = projectLabels;
277
323
  if (heartbeatIntervalMs !== null)
278
324
  overrides.heartbeatIntervalMs = heartbeatIntervalMs;
325
+ if (cleanupWorktrees !== null)
326
+ overrides.cleanupWorktrees = cleanupWorktrees;
279
327
  return {
280
328
  ok: true,
281
329
  command: "connect",
282
330
  config: configuredBridgeServiceConfig(overrides, localConfig)
283
331
  };
284
332
  }
333
+ function parsePairArgs(argv) {
334
+ const localConfig = readLocalConfigSync();
335
+ const defaultConfig = configuredBridgeServiceConfig({}, localConfig);
336
+ let serverUrl = defaultConfig.serverUrl;
337
+ let code = null;
338
+ let name = null;
339
+ let fingerprint = null;
340
+ let repoRoot = null;
341
+ let cleanupWorktrees = null;
342
+ const cliProviders = [];
343
+ const projectLabels = [];
344
+ for (let index = 1; index < argv.length; index += 1) {
345
+ const arg = argv[index];
346
+ if (arg === "--server") {
347
+ serverUrl = argv[++index] ?? "";
348
+ }
349
+ else if (arg === "--code") {
350
+ code = argv[++index] ?? null;
351
+ }
352
+ else if (arg === "--name") {
353
+ name = argv[++index] ?? null;
354
+ }
355
+ else if (arg === "--fingerprint") {
356
+ fingerprint = argv[++index] ?? null;
357
+ }
358
+ else if (arg === "--repo-root") {
359
+ repoRoot = argv[++index] ?? null;
360
+ }
361
+ else if (arg === "--cli-provider") {
362
+ cliProviders.push(...splitCsv(argv[++index] ?? ""));
363
+ }
364
+ else if (arg === "--project-label") {
365
+ projectLabels.push(...splitCsv(argv[++index] ?? ""));
366
+ }
367
+ else if (arg === "--cleanup-worktrees") {
368
+ cleanupWorktrees = true;
369
+ }
370
+ else if (arg === "--no-cleanup-worktrees") {
371
+ cleanupWorktrees = false;
372
+ }
373
+ else if (!arg.startsWith("-") && code === null) {
374
+ code = arg;
375
+ }
376
+ else {
377
+ return { ok: false, error: `Unknown argument: ${arg}` };
378
+ }
379
+ }
380
+ if (!serverUrl.trim()) {
381
+ return { ok: false, error: "forge-bridge pair requires --server or configured serverUrl." };
382
+ }
383
+ const defaultName = `Local bridge ${os.hostname()}`;
384
+ const user = os.userInfo().username;
385
+ return {
386
+ ok: true,
387
+ command: "pair",
388
+ serverUrl,
389
+ code: normalizePairingCode(code),
390
+ name: name?.trim() || defaultName,
391
+ fingerprint: fingerprint?.trim() || `local-bridge:${os.hostname()}:${user}`,
392
+ repoRoot: repoRoot?.trim() ? path.resolve(repoRoot) : undefined,
393
+ cleanupWorktrees: cleanupWorktrees ?? localConfig.cleanupWorktrees ?? false,
394
+ cliProviders: cliProviders.length > 0 ? cliProviders : localConfig.cliProviders && localConfig.cliProviders.length > 0 ? [...localConfig.cliProviders] : ["codex"],
395
+ projectLabels: projectLabels.length > 0 ? projectLabels : localConfig.projectLabels ? [...localConfig.projectLabels] : []
396
+ };
397
+ }
285
398
  function parseCleanupArgs(argv) {
286
399
  let mode = "stale";
287
400
  let force = false;
@@ -317,6 +430,100 @@ function splitCsv(value) {
317
430
  .map((entry) => entry.trim())
318
431
  .filter((entry) => entry.length > 0);
319
432
  }
433
+ async function claimBridgeRunner(args) {
434
+ if (!args.code) {
435
+ throw new Error("Pairing code is required.");
436
+ }
437
+ const response = await fetch(new URL("/api/runners/claim", normalizeServerUrl(args.serverUrl)), {
438
+ method: "POST",
439
+ headers: { "Content-Type": "application/json" },
440
+ body: JSON.stringify({
441
+ code: args.code,
442
+ name: args.name,
443
+ fingerprint: args.fingerprint,
444
+ cliProviders: args.cliProviders,
445
+ projectLabels: args.projectLabels
446
+ })
447
+ });
448
+ if (!response.ok) {
449
+ throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
450
+ }
451
+ const value = await response.json();
452
+ if (!value.runnerId || !value.deviceToken) {
453
+ throw new Error("Pairing response did not include runner credentials.");
454
+ }
455
+ return {
456
+ runnerId: value.runnerId,
457
+ deviceToken: value.deviceToken
458
+ };
459
+ }
460
+ function pairedConfig(current, args, claim) {
461
+ return {
462
+ ...current,
463
+ schemaVersion: 2,
464
+ serverUrl: args.serverUrl,
465
+ runnerId: claim.runnerId,
466
+ deviceToken: claim.deviceToken,
467
+ deviceName: args.name,
468
+ defaultRepoRoot: args.repoRoot ?? current.defaultRepoRoot,
469
+ cleanupWorktrees: args.cleanupWorktrees,
470
+ cliProviders: [...args.cliProviders],
471
+ providerStatuses: current.providerStatuses ?? providerStatusesFor(args.cliProviders),
472
+ projectLabels: [...args.projectLabels],
473
+ supportsApiSecretGrant: current.supportsApiSecretGrant ?? true,
474
+ supportsManagedWorktrees: current.supportsManagedWorktrees ?? true,
475
+ supportsManagedForgeArtifacts: current.supportsManagedForgeArtifacts ?? true,
476
+ supportsRunnerAuthoredPhaseArtifacts: current.supportsRunnerAuthoredPhaseArtifacts ?? true,
477
+ repoMappings: current.repoMappings ?? []
478
+ };
479
+ }
480
+ async function resolvePairingCode(code) {
481
+ const normalized = normalizePairingCode(code);
482
+ if (normalized) {
483
+ return normalized;
484
+ }
485
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
486
+ throw new Error("Pairing code is required. Run `forge-bridge pair <code>` or use an interactive terminal.");
487
+ }
488
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
489
+ try {
490
+ const answer = await prompt.question("Pairing code: ");
491
+ const clean = normalizePairingCode(answer);
492
+ if (!clean) {
493
+ throw new Error("Pairing code is required.");
494
+ }
495
+ return clean;
496
+ }
497
+ finally {
498
+ prompt.close();
499
+ }
500
+ }
501
+ async function resolvePairRepoRoot(repoRoot, configuredRoot) {
502
+ const fallback = path.resolve(repoRoot ?? configuredRoot ?? path.join(os.homedir(), "ForgeRepos"));
503
+ if (repoRoot?.trim()) {
504
+ return fallback;
505
+ }
506
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
507
+ return fallback;
508
+ }
509
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
510
+ try {
511
+ const answer = await prompt.question(`Repo folder [${fallback}]: `);
512
+ return path.resolve(answer.trim() || fallback);
513
+ }
514
+ finally {
515
+ prompt.close();
516
+ }
517
+ }
518
+ function normalizePairingCode(code) {
519
+ return code?.replace(/[\s-]/g, "").trim() ?? "";
520
+ }
521
+ function normalizeServerUrl(serverUrl) {
522
+ return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
523
+ }
524
+ function usage() {
525
+ return "Usage: forge-bridge run-once --dispatch <dispatch-json-path> --result <result-json-path> | forge-bridge mcp --worktree <path> --work-item-id <id> --phase <phase> | forge-bridge pair [code] [--server <url>] | forge-bridge connect --server <url> --runner-id <id> --device-token <token> | forge-bridge add-repo <label> <path> | forge-bridge remove-repo <label> | forge-bridge status [--check-updates] | forge-bridge cleanup [--stale|--merged|--all|--force]";
526
+ }
320
527
  async function writeJsonAtomic(outputPath, value) {
321
528
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
322
529
  const tempPath = `${outputPath}.${process.pid}.${Date.now()}.tmp`;
@@ -343,7 +550,8 @@ async function writeStatus(config, checkUpdates, io) {
343
550
  }
344
551
  io.stdout.write("Detected CLIs:\n");
345
552
  for (const cli of detected) {
346
- io.stdout.write(` ${cli.provider}: ${cli.found ? cli.version || "found" : "not found"}\n`);
553
+ const models = cli.models.length > 0 ? `, ${cli.models.length} models` : "";
554
+ io.stdout.write(` ${cli.provider}: ${cli.found ? `${cli.version || "found"}${models}` : "not found"}\n`);
347
555
  }
348
556
  if (checkUpdates) {
349
557
  io.stdout.write("Update check: no update channel configured.\n");
@@ -385,16 +593,198 @@ async function cleanupRepos(config, mode, force, io) {
385
593
  async function detectCliProviders(providers) {
386
594
  const results = [];
387
595
  for (const provider of providers) {
388
- try {
389
- const result = await execFileAsync(provider, ["--version"], { timeout: 2000 });
390
- results.push({ provider, found: true, version: `${result.stdout || result.stderr}`.trim().split(/\r?\n/, 1)[0] ?? "" });
391
- }
392
- catch {
393
- results.push({ provider, found: false, version: "" });
596
+ let detected = null;
597
+ for (const probe of cliProbeCommandsFor(provider)) {
598
+ try {
599
+ detected = await execVersionCommand(probe);
600
+ break;
601
+ }
602
+ catch {
603
+ // Try the next provider-specific probe.
604
+ }
394
605
  }
606
+ results.push({
607
+ provider,
608
+ found: detected !== null,
609
+ version: detected ? versionLine(`${detected.stdout || detected.stderr}`) : "",
610
+ models: detected ? await detectCliModels(provider) : []
611
+ });
395
612
  }
396
613
  return results;
397
614
  }
615
+ function cliProbeCommandsFor(provider) {
616
+ const normalized = provider.toLowerCase();
617
+ if (normalized === "codex") {
618
+ return [
619
+ { command: "codex", args: ["-V"] },
620
+ { command: "codex", args: ["--version"] }
621
+ ];
622
+ }
623
+ if (normalized === "cursor") {
624
+ return [
625
+ { command: "cursor-agent", args: ["-v"] },
626
+ { command: "agent", args: ["-v"] },
627
+ { command: "cursor-agent", args: ["--version"] },
628
+ { command: "agent", args: ["--version"] }
629
+ ];
630
+ }
631
+ return [
632
+ { command: provider, args: ["--version"] },
633
+ { command: provider, args: ["-V"] }
634
+ ];
635
+ }
636
+ async function execVersionCommand(probe, timeout = 2000) {
637
+ const directLaunch = process.platform === "win32"
638
+ ? await resolveCursorAgentDirectLaunch(probe.command, probe.args)
639
+ : null;
640
+ if (directLaunch) {
641
+ return await execFileAsync(directLaunch.command, [...directLaunch.args], { timeout });
642
+ }
643
+ try {
644
+ return await execFileAsync(probe.command, probe.args, { timeout });
645
+ }
646
+ catch (error) {
647
+ if (process.platform !== "win32") {
648
+ throw error;
649
+ }
650
+ return await execFileAsync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", windowsCommandLine(probe)], { timeout });
651
+ }
652
+ }
653
+ function windowsCommandLine(probe) {
654
+ return [probe.command, ...probe.args].map(quoteWindowsCommandLineArg).join(" ");
655
+ }
656
+ function quoteWindowsCommandLineArg(value) {
657
+ if (value.length > 0 && !/[\s"]/.test(value)) {
658
+ return value;
659
+ }
660
+ return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, "$1$1")}"`;
661
+ }
662
+ function versionLine(output) {
663
+ const lines = output
664
+ .trim()
665
+ .split(/\r?\n/)
666
+ .map((line) => line.trim())
667
+ .filter((line) => line.length > 0);
668
+ return lines.at(-1) ?? "";
669
+ }
670
+ async function detectCliModels(provider) {
671
+ const normalized = provider.toLowerCase();
672
+ try {
673
+ if (normalized === "codex") {
674
+ const result = await execFileAsync("codex", ["debug", "models"], { timeout: 5000, maxBuffer: 10 * 1024 * 1024 });
675
+ return parseCodexModelCatalog(result.stdout);
676
+ }
677
+ if (normalized === "cursor") {
678
+ const result = await execVersionCommand({ command: "cursor-agent", args: ["models"] }, 10_000);
679
+ return parseCursorModelList(`${result.stdout || result.stderr}`);
680
+ }
681
+ }
682
+ catch {
683
+ return [];
684
+ }
685
+ return [];
686
+ }
687
+ export function parseCodexModelCatalog(raw) {
688
+ try {
689
+ const parsed = JSON.parse(raw);
690
+ if (!isRecord(parsed) || !Array.isArray(parsed.models)) {
691
+ return [];
692
+ }
693
+ const entries = parsed.models
694
+ .map((entry, index) => {
695
+ if (!isRecord(entry) || typeof entry.slug !== "string") {
696
+ return null;
697
+ }
698
+ const visibility = typeof entry.visibility === "string" ? entry.visibility.toLowerCase() : "list";
699
+ if (visibility !== "list") {
700
+ return null;
701
+ }
702
+ const priority = typeof entry.priority === "number" && Number.isFinite(entry.priority) ? entry.priority : index;
703
+ return { slug: entry.slug, priority, index };
704
+ })
705
+ .filter((entry) => entry !== null)
706
+ .sort((left, right) => left.priority - right.priority || left.index - right.index)
707
+ .map((entry) => entry.slug);
708
+ return uniqueModels(entries);
709
+ }
710
+ catch {
711
+ return [];
712
+ }
713
+ }
714
+ export function parseCursorModelList(raw) {
715
+ return uniqueModels(raw
716
+ .split(/\r?\n/)
717
+ .map((line) => line.trim())
718
+ .filter((line) => line.length > 0 && !line.toLowerCase().startsWith("available models"))
719
+ .map((line) => line.split(/\s+-\s+/, 1)[0]?.trim() ?? "")
720
+ .filter((model) => model.length > 0));
721
+ }
722
+ function uniqueModels(models) {
723
+ const seen = new Set();
724
+ const result = [];
725
+ for (const model of models) {
726
+ const trimmed = model.trim();
727
+ const key = trimmed.toLowerCase();
728
+ if (!trimmed || seen.has(key)) {
729
+ continue;
730
+ }
731
+ seen.add(key);
732
+ result.push(trimmed);
733
+ }
734
+ return result;
735
+ }
736
+ function isRecord(value) {
737
+ return typeof value === "object" && value !== null;
738
+ }
739
+ async function withDetectedCliProviders(config) {
740
+ const detected = await detectCliProviders(configuredProviderCandidates(config.cliProviders));
741
+ const found = detected.filter((result) => result.found);
742
+ if (found.length === 0) {
743
+ return config;
744
+ }
745
+ const cliProviders = uniqueProviders([...config.cliProviders, ...found.map((result) => result.provider)]);
746
+ const providerStatuses = { ...config.providerStatuses };
747
+ for (const result of found) {
748
+ const existing = providerStatuses[result.provider];
749
+ providerStatuses[result.provider] = {
750
+ signedIn: existing?.signedIn ?? true,
751
+ version: result.version || existing?.version || "",
752
+ models: result.models.length > 0
753
+ ? result.models
754
+ : existing?.models ?? providerStatusesFor([result.provider])[result.provider]?.models ?? []
755
+ };
756
+ }
757
+ return {
758
+ ...config,
759
+ cliProviders,
760
+ providerStatuses
761
+ };
762
+ }
763
+ async function withDetectedPairCliProviders(args) {
764
+ const detectedConfig = await withDetectedCliProviders({
765
+ ...defaultBridgeServiceConfig({
766
+ serverUrl: args.serverUrl,
767
+ cliProviders: [...args.cliProviders],
768
+ providerStatuses: providerStatusesFor(args.cliProviders),
769
+ projectLabels: [...args.projectLabels],
770
+ cleanupWorktrees: args.cleanupWorktrees,
771
+ defaultRepoRoot: args.repoRoot ?? defaultBridgeServiceConfig().defaultRepoRoot
772
+ })
773
+ });
774
+ return {
775
+ ...args,
776
+ cliProviders: detectedConfig.cliProviders
777
+ };
778
+ }
779
+ function configuredProviderCandidates(providers) {
780
+ return uniqueProviders([...providers, "codex", "cursor"]);
781
+ }
782
+ function uniqueProviders(providers) {
783
+ return providers
784
+ .map((provider) => provider.trim())
785
+ .filter((provider) => provider.length > 0)
786
+ .filter((provider, index, all) => all.findIndex((candidate) => candidate.toLowerCase() === provider.toLowerCase()) === index);
787
+ }
398
788
  async function runGit(cwd, args) {
399
789
  await execFileAsync("git", args, { cwd, timeout: 10_000 });
400
790
  }
@@ -13,6 +13,9 @@ export interface BridgeLocalConfig {
13
13
  readonly repoMappings?: readonly LocalRepoMapping[];
14
14
  readonly sandboxProfiles?: readonly string[];
15
15
  readonly supportsApiSecretGrant?: boolean;
16
+ readonly supportsManagedWorktrees?: boolean;
17
+ readonly supportsManagedForgeArtifacts?: boolean;
18
+ readonly supportsRunnerAuthoredPhaseArtifacts?: boolean;
16
19
  }
17
20
  export interface LocalRepoMapping extends BridgeRepoMapping {
18
21
  readonly label: string;
@@ -50,7 +50,10 @@ export function applyLocalConfigDefaults(config, local) {
50
50
  projectLabels: local.projectLabels ?? config.projectLabels,
51
51
  repoMappings: local.repoMappings ?? config.repoMappings,
52
52
  sandboxProfiles: local.sandboxProfiles ?? config.sandboxProfiles,
53
- supportsApiSecretGrant: local.supportsApiSecretGrant ?? config.supportsApiSecretGrant
53
+ supportsApiSecretGrant: local.supportsApiSecretGrant ?? config.supportsApiSecretGrant,
54
+ supportsManagedWorktrees: local.supportsManagedWorktrees ?? config.supportsManagedWorktrees,
55
+ supportsManagedForgeArtifacts: local.supportsManagedForgeArtifacts ?? config.supportsManagedForgeArtifacts,
56
+ supportsRunnerAuthoredPhaseArtifacts: local.supportsRunnerAuthoredPhaseArtifacts ?? config.supportsRunnerAuthoredPhaseArtifacts
54
57
  };
55
58
  }
56
59
  export async function addRepoMapping(label, repoPath) {
@@ -105,7 +108,10 @@ function normalizeLocalConfig(config) {
105
108
  projectLabels: cleanList(config.projectLabels),
106
109
  repoMappings: normalizeRepoMappings(config.repoMappings),
107
110
  sandboxProfiles: cleanList(config.sandboxProfiles),
108
- supportsApiSecretGrant: config.supportsApiSecretGrant
111
+ supportsApiSecretGrant: config.supportsApiSecretGrant,
112
+ supportsManagedWorktrees: config.supportsManagedWorktrees,
113
+ supportsManagedForgeArtifacts: config.supportsManagedForgeArtifacts,
114
+ supportsRunnerAuthoredPhaseArtifacts: config.supportsRunnerAuthoredPhaseArtifacts
109
115
  };
110
116
  }
111
117
  function emptyConfig() {
@@ -1,2 +1,4 @@
1
1
  export { BridgeSessionService } from "./session.service.js";
2
+ export { buildCompletionToolServerArgs, codexMcpConfigArgs, completionPhaseFromDispatch, expectedCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, writeCompletionArtifact } from "./phase-completion.js";
3
+ export type { CompletionArtifactReference, CompletionPhase, CompletionToolRuntime } from "./phase-completion.js";
2
4
  export type { SessionService } from "./session.base.js";
@@ -1 +1,2 @@
1
1
  export { BridgeSessionService } from "./session.service.js";
2
+ export { buildCompletionToolServerArgs, codexMcpConfigArgs, completionPhaseFromDispatch, expectedCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, writeCompletionArtifact } from "./phase-completion.js";