@loucompanion/forge-bridge 0.1.1-dev.03b8ba168599 → 0.1.1-dev.040867847f42

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.
Files changed (35) hide show
  1. package/dist/bridge/bridge.service.d.ts +1 -0
  2. package/dist/bridge/bridge.service.js +133 -24
  3. package/dist/bridge/bridge.types.d.ts +13 -2
  4. package/dist/cli/cli.service.d.ts +4 -0
  5. package/dist/cli/cli.service.js +111 -10
  6. package/dist/cli/local-config.d.ts +1 -0
  7. package/dist/cli/local-config.js +22 -3
  8. package/dist/config.js +1 -1
  9. package/dist/session/index.d.ts +2 -0
  10. package/dist/session/index.js +1 -0
  11. package/dist/session/internals.d.ts +3 -1
  12. package/dist/session/internals.js +1 -0
  13. package/dist/session/phase-completion.d.ts +115 -0
  14. package/dist/session/phase-completion.js +2773 -0
  15. package/dist/session/provider-cli/provider-cli.base.d.ts +4 -3
  16. package/dist/session/provider-cli/provider-cli.service.d.ts +14 -5
  17. package/dist/session/provider-cli/provider-cli.service.js +2154 -242
  18. package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
  19. package/dist/session/provider-cli/provider-usage.d.ts +25 -0
  20. package/dist/session/provider-cli/provider-usage.js +64 -0
  21. package/dist/session/qa-sidecar-validation.d.ts +3 -0
  22. package/dist/session/qa-sidecar-validation.js +44 -0
  23. package/dist/session/session.service.js +56 -16
  24. package/dist/session/session.types.d.ts +128 -0
  25. package/dist/session/session.types.js +261 -2
  26. package/dist/shared/cursor-agent/cursor-agent.launch.d.ts +9 -0
  27. package/dist/shared/cursor-agent/cursor-agent.launch.js +66 -0
  28. package/dist/version.d.ts +1 -1
  29. package/dist/version.js +1 -1
  30. package/dist/worktree/artifact-injector.d.ts +2 -1
  31. package/dist/worktree/artifact-injector.js +86 -9
  32. package/dist/worktree/worktree.service.d.ts +1 -0
  33. package/dist/worktree/worktree.service.js +132 -6
  34. package/dist/worktree/worktree.types.d.ts +8 -1
  35. package/package.json +8 -3
@@ -15,6 +15,7 @@ export declare class ForgeBridgeService implements BridgeService {
15
15
  run(signal?: AbortSignal): Promise<void>;
16
16
  stop(): Promise<void>;
17
17
  private handleDispatch;
18
+ private parseDispatchOrReport;
18
19
  private handleCancel;
19
20
  private abortActiveSessions;
20
21
  private normalizeDispatch;
@@ -51,7 +51,7 @@ export class ForgeBridgeService {
51
51
  attempt = 0;
52
52
  await this.waitForClose(signal);
53
53
  }
54
- catch (error) {
54
+ catch {
55
55
  if (this.stopped || signal?.aborted) {
56
56
  break;
57
57
  }
@@ -73,10 +73,14 @@ export class ForgeBridgeService {
73
73
  this.abortActiveSessions("bridge stopping");
74
74
  await this.stopConnection();
75
75
  }
76
- async handleDispatch(dispatch) {
76
+ async handleDispatch(rawDispatch) {
77
+ const dispatch = this.parseDispatchOrReport(rawDispatch);
78
+ if (!dispatch) {
79
+ return;
80
+ }
77
81
  const leaseId = dispatch.lease.id;
78
82
  const fencingToken = dispatch.lease.fencingToken;
79
- const redactionSecrets = exactSecrets(dispatch);
83
+ const redactionSecrets = exactSecrets(dispatch, this.config);
80
84
  if (this.activeSessions.has(leaseId)) {
81
85
  await this.sendResult(leaseId, fencingToken, failureResult("Dispatch is already active on this bridge.")).catch(() => undefined);
82
86
  return;
@@ -87,11 +91,20 @@ export class ForgeBridgeService {
87
91
  const normalized = this.normalizeDispatch(dispatch);
88
92
  const result = await this.sessions.runOnce(normalized, {
89
93
  emit: async (event) => {
90
- await this.invokeAck("DispatchEvent", {
91
- leaseId,
92
- fencingToken,
93
- event: redactSecrets(event, redactionSecrets)
94
- });
94
+ try {
95
+ await this.invokeAck("DispatchEvent", {
96
+ leaseId,
97
+ fencingToken,
98
+ event: redactSecrets(event, redactionSecrets)
99
+ });
100
+ }
101
+ catch (error) {
102
+ if (isDispatchNoLongerPending(error)) {
103
+ controller.abort(error instanceof Error ? error.message : "Dispatch is no longer pending.");
104
+ return;
105
+ }
106
+ throw error;
107
+ }
95
108
  }
96
109
  }, controller.signal);
97
110
  await this.sendResult(leaseId, fencingToken, redactSecrets(result, redactionSecrets));
@@ -106,6 +119,27 @@ export class ForgeBridgeService {
106
119
  this.activeSessions.delete(leaseId);
107
120
  }
108
121
  }
122
+ parseDispatchOrReport(rawDispatch) {
123
+ try {
124
+ return parseRunOnceDispatchPacket(rawDispatch);
125
+ }
126
+ catch (error) {
127
+ const identifiers = readDispatchIdentifiers(rawDispatch);
128
+ if (!identifiers) {
129
+ return null;
130
+ }
131
+ const summary = invalidDispatchSummary(error);
132
+ void this.sendResult(identifiers.leaseId, identifiers.fencingToken, failureResult(summary, [
133
+ {
134
+ role: "runner",
135
+ kind: "error",
136
+ content: summary,
137
+ eventType: "invalid_dispatch"
138
+ }
139
+ ])).catch(() => undefined);
140
+ return null;
141
+ }
142
+ }
109
143
  async handleCancel(request) {
110
144
  const active = this.activeSessions.get(request.leaseId);
111
145
  if (!active || active.fencingToken !== request.fencingToken) {
@@ -123,6 +157,7 @@ export class ForgeBridgeService {
123
157
  const mapping = findRepoMapping(dispatch, this.config.repoMappings);
124
158
  const localPath = mapping?.localPath?.trim();
125
159
  const repoRoot = localPath ? path.dirname(path.resolve(localPath)) : this.config.defaultRepoRoot;
160
+ const qaLaunchTarget = normalizeQaAppLaunchTarget(mapping?.qaAppLaunchTarget) ?? dispatch.qaBrowser?.launchTarget ?? null;
126
161
  return {
127
162
  ...dispatch,
128
163
  repo: {
@@ -133,7 +168,13 @@ export class ForgeBridgeService {
133
168
  serverLocal: {
134
169
  repoRoot,
135
170
  cleanupWorktrees: this.config.cleanupWorktrees
136
- }
171
+ },
172
+ qaBrowser: dispatch.qaBrowser
173
+ ? {
174
+ ...dispatch.qaBrowser,
175
+ launchTarget: qaLaunchTarget
176
+ }
177
+ : dispatch.qaBrowser
137
178
  };
138
179
  }
139
180
  async sendResult(leaseId, fencingToken, result) {
@@ -142,7 +183,15 @@ export class ForgeBridgeService {
142
183
  fencingToken,
143
184
  result
144
185
  };
145
- await this.invokeAck("DispatchResult", payload);
186
+ try {
187
+ await this.invokeAck("DispatchResult", payload);
188
+ }
189
+ catch (error) {
190
+ if (isDispatchNoLongerPending(error)) {
191
+ return;
192
+ }
193
+ throw error;
194
+ }
146
195
  }
147
196
  startHeartbeat() {
148
197
  this.stopHeartbeat();
@@ -179,7 +228,8 @@ export class ForgeBridgeService {
179
228
  supportsApiSecretGrant: this.config.supportsApiSecretGrant,
180
229
  supportsManagedWorktrees: this.config.supportsManagedWorktrees,
181
230
  supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
182
- supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts
231
+ supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts,
232
+ supportsDraftRunOnce: this.config.supportsDraftRunOnce
183
233
  };
184
234
  }
185
235
  async invokeAck(methodName, payload) {
@@ -189,7 +239,7 @@ export class ForgeBridgeService {
189
239
  }
190
240
  const ack = await connection.invoke(methodName, payload);
191
241
  if (!ack.accepted) {
192
- throw new Error(ack.message ?? ack.code ?? `${methodName} callback was rejected.`);
242
+ throw new BridgeCallbackRejectedError(ack.code ?? null, ack.message ?? ack.code ?? `${methodName} callback was rejected.`);
193
243
  }
194
244
  return ack;
195
245
  }
@@ -207,6 +257,20 @@ export class ForgeBridgeService {
207
257
  }
208
258
  }
209
259
  }
260
+ class BridgeCallbackRejectedError extends Error {
261
+ code;
262
+ constructor(code, message) {
263
+ super(message);
264
+ this.code = code;
265
+ this.name = "BridgeCallbackRejectedError";
266
+ }
267
+ }
268
+ function isDispatchNoLongerPending(error) {
269
+ if (error instanceof BridgeCallbackRejectedError) {
270
+ return error.code === "bridge.dispatch_not_pending" || error.code === "bridge.stale_dispatch";
271
+ }
272
+ return error instanceof Error && error.message === "Dispatch is no longer pending.";
273
+ }
210
274
  export class SignalRBridgeConnectionFactory {
211
275
  create(config) {
212
276
  return new SignalRBridgeConnection(config);
@@ -237,14 +301,7 @@ export class SignalRBridgeConnection {
237
301
  }
238
302
  onDispatch(handler) {
239
303
  this.connection.on("Dispatch", (raw) => {
240
- let dispatch;
241
- try {
242
- dispatch = parseRunOnceDispatchPacket(raw);
243
- }
244
- catch {
245
- return;
246
- }
247
- void handler(dispatch).catch(() => undefined);
304
+ void handler(raw).catch(() => undefined);
248
305
  });
249
306
  }
250
307
  onCancel(handler) {
@@ -271,11 +328,12 @@ export function defaultBridgeServiceConfig(overrides = {}) {
271
328
  projectLabels: splitCsv(process.env.FORGE_PROJECT_LABELS ?? ""),
272
329
  repoMappings: [],
273
330
  os: process.platform,
274
- sandboxProfiles: ["default"],
331
+ sandboxProfiles: overrides.sandboxProfiles,
275
332
  supportsApiSecretGrant: true,
276
333
  supportsManagedWorktrees: true,
277
334
  supportsManagedForgeArtifacts: true,
278
335
  supportsRunnerAuthoredPhaseArtifacts: true,
336
+ supportsDraftRunOnce: true,
279
337
  version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
280
338
  protocolVersion: 1,
281
339
  minServerVersion: null,
@@ -285,6 +343,7 @@ export function defaultBridgeServiceConfig(overrides = {}) {
285
343
  };
286
344
  return {
287
345
  ...config,
346
+ sandboxProfiles: config.sandboxProfiles ?? defaultSandboxProfiles(config.cliProviders),
288
347
  heartbeatIntervalMs: positiveFiniteNumber(config.heartbeatIntervalMs, 30_000)
289
348
  };
290
349
  }
@@ -303,6 +362,11 @@ function positiveFiniteNumber(value, fallback) {
303
362
  function providerStatusesFor(providers) {
304
363
  return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
305
364
  }
365
+ function defaultSandboxProfiles(providers) {
366
+ return providers.some((provider) => provider.toLowerCase() === "codex")
367
+ ? ["provider-workspace-write"]
368
+ : ["none"];
369
+ }
306
370
  function configuredModelsFor(provider) {
307
371
  const envName = `FORGE_${provider.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}_MODELS`;
308
372
  return splitCsv(process.env[envName] ?? "");
@@ -323,16 +387,58 @@ function findRepoMapping(dispatch, mappings) {
323
387
  (!!mapping.remoteUrl?.trim() && equalsIgnoreCase(mapping.remoteUrl, dispatch.repo.remoteUrl)));
324
388
  });
325
389
  }
390
+ function normalizeQaAppLaunchTarget(target) {
391
+ if (!target) {
392
+ return null;
393
+ }
394
+ const routes = [...new Set((target.routes ?? []).map((route) => route.trim()).filter(Boolean))];
395
+ const normalized = {
396
+ url: cleanString(target.url),
397
+ command: cleanString(target.command),
398
+ working_directory: cleanString(target.working_directory),
399
+ ready_url: cleanString(target.ready_url),
400
+ routes,
401
+ notes: cleanString(target.notes)
402
+ };
403
+ return normalized.url || normalized.command || normalized.ready_url || routes.length > 0
404
+ ? normalized
405
+ : null;
406
+ }
407
+ function cleanString(value) {
408
+ const clean = value?.trim();
409
+ return clean ? clean : null;
410
+ }
326
411
  function equalsIgnoreCase(left, right) {
327
412
  return !!left?.trim() && !!right?.trim() && left.trim().toLowerCase() === right.trim().toLowerCase();
328
413
  }
414
+ function readDispatchIdentifiers(rawDispatch) {
415
+ if (!rawDispatch || typeof rawDispatch !== "object" || Array.isArray(rawDispatch)) {
416
+ return null;
417
+ }
418
+ const packet = rawDispatch;
419
+ const rawLease = packet.lease ?? packet.Lease;
420
+ if (!rawLease || typeof rawLease !== "object" || Array.isArray(rawLease)) {
421
+ return null;
422
+ }
423
+ const lease = rawLease;
424
+ const leaseId = readString(lease, "id", "Id");
425
+ const fencingToken = readString(lease, "fencingToken", "FencingToken");
426
+ return leaseId && fencingToken ? { leaseId, fencingToken } : null;
427
+ }
428
+ function readString(source, camelKey, pascalKey) {
429
+ const value = source[camelKey] ?? source[pascalKey];
430
+ return typeof value === "string" && value.trim() ? value : null;
431
+ }
432
+ function invalidDispatchSummary(error) {
433
+ const detail = error instanceof Error ? error.message : String(error);
434
+ return `Invalid bridge dispatch packet: ${detail}`;
435
+ }
329
436
  function delay(ms, signal) {
330
437
  if (signal?.aborted) {
331
438
  return Promise.resolve();
332
439
  }
333
440
  return new Promise((resolve) => {
334
441
  const timeout = setTimeout(resolve, ms);
335
- timeout.unref?.();
336
442
  signal?.addEventListener("abort", () => {
337
443
  clearTimeout(timeout);
338
444
  resolve();
@@ -348,6 +454,9 @@ function abortPromise(signal) {
348
454
  }
349
455
  return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
350
456
  }
351
- function exactSecrets(dispatch) {
352
- return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
457
+ function exactSecrets(dispatch, config) {
458
+ return [
459
+ dispatch.apiKeySecretGrant?.secret,
460
+ config.deviceToken
461
+ ].filter((secret) => typeof secret === "string" && secret.length > 0);
353
462
  }
@@ -1,4 +1,4 @@
1
- import type { RunOnceDispatchPacket, RunOnceResultPacket, RunnerDispatchEvent } from "../session/session.types.js";
1
+ import type { RunOnceResultPacket, RunnerDispatchEvent } from "../session/session.types.js";
2
2
  export interface BridgeServiceConfig {
3
3
  readonly serverUrl: string;
4
4
  readonly runnerId: string;
@@ -16,6 +16,7 @@ export interface BridgeServiceConfig {
16
16
  readonly supportsManagedWorktrees: boolean;
17
17
  readonly supportsManagedForgeArtifacts: boolean;
18
18
  readonly supportsRunnerAuthoredPhaseArtifacts: boolean;
19
+ readonly supportsDraftRunOnce: boolean;
19
20
  readonly version: string;
20
21
  readonly protocolVersion: number;
21
22
  readonly minServerVersion?: string | null;
@@ -27,6 +28,15 @@ export interface BridgeRepoMapping {
27
28
  readonly name?: string | null;
28
29
  readonly remoteUrl?: string | null;
29
30
  readonly localPath?: string | null;
31
+ readonly qaAppLaunchTarget?: QaAppLaunchTarget | null;
32
+ }
33
+ export interface QaAppLaunchTarget {
34
+ readonly url?: string | null;
35
+ readonly command?: string | null;
36
+ readonly working_directory?: string | null;
37
+ readonly ready_url?: string | null;
38
+ readonly routes?: readonly string[] | null;
39
+ readonly notes?: string | null;
30
40
  }
31
41
  export interface BridgeProviderStatus {
32
42
  readonly signedIn?: boolean | null;
@@ -46,6 +56,7 @@ export interface BridgeCapabilities {
46
56
  readonly supportsManagedWorktrees?: boolean | null;
47
57
  readonly supportsManagedForgeArtifacts?: boolean | null;
48
58
  readonly supportsRunnerAuthoredPhaseArtifacts?: boolean | null;
59
+ readonly supportsDraftRunOnce?: boolean | null;
49
60
  }
50
61
  export interface BridgeHelloRequest {
51
62
  readonly version: string;
@@ -79,5 +90,5 @@ export interface BridgeCancelRequest {
79
90
  readonly fencingToken: string;
80
91
  readonly reason?: string | null;
81
92
  }
82
- export type BridgeDispatchHandler = (dispatch: RunOnceDispatchPacket) => Promise<void>;
93
+ export type BridgeDispatchHandler = (dispatch: unknown) => Promise<void>;
83
94
  export type BridgeCancelHandler = (request: BridgeCancelRequest) => Promise<void>;
@@ -14,6 +14,10 @@ export declare function parseArgs(argv: readonly string[]): {
14
14
  readonly command: "run-once";
15
15
  readonly dispatchPath: string;
16
16
  readonly resultPath: string;
17
+ } | {
18
+ readonly ok: true;
19
+ readonly command: "mcp";
20
+ readonly argv: readonly string[];
17
21
  } | {
18
22
  readonly ok: true;
19
23
  readonly command: "connect";
@@ -4,10 +4,13 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { promisify } from "node:util";
7
+ import { resolveCursorAgentDirectLaunch } from "../shared/cursor-agent/cursor-agent.launch.js";
7
8
  import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
8
9
  import { defaultBridgeServiceConfig, ForgeBridgeService } from "../bridge/bridge.service.js";
10
+ import { forgeBridgeVersion } from "../version.js";
9
11
  import { addRepoMapping, applyLocalConfigDefaults, providerStatusesFor, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath, writeLocalConfig } from "./local-config.js";
10
12
  import { BridgeSessionService } from "../session/session.service.js";
13
+ import { parseCompletionMcpArgs, runCompletionMcpServer } from "../session/phase-completion.js";
11
14
  import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
12
15
  const apiKeySecretGrantEnv = "FORGE_API_KEY_SECRET_GRANT";
13
16
  const execFileAsync = promisify(execFile);
@@ -21,6 +24,14 @@ export class ForgeBridgeCliService {
21
24
  this.pairRunner = pairRunner;
22
25
  }
23
26
  async run(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
27
+ if (argv.length === 0 || isHelpArg(argv[0])) {
28
+ io.stdout.write(helpText());
29
+ return 0;
30
+ }
31
+ if (argv[0] === "version" || argv[0] === "--version" || argv[0] === "-v") {
32
+ io.stdout.write(`forge-bridge ${defaultBridgeServiceConfig().version ?? forgeBridgeVersion}\n`);
33
+ return 0;
34
+ }
24
35
  const args = parseArgs(argv);
25
36
  if (!args.ok) {
26
37
  io.stderr.write(`${args.error}\n`);
@@ -42,6 +53,7 @@ export class ForgeBridgeCliService {
42
53
  process.once("SIGINT", stop);
43
54
  process.once("SIGTERM", stop);
44
55
  try {
56
+ io.stdout.write(connectStartupText(config));
45
57
  await this.bridgeFactory(config).run(controller.signal);
46
58
  return 0;
47
59
  }
@@ -102,6 +114,15 @@ export class ForgeBridgeCliService {
102
114
  if (args.command === "cleanup") {
103
115
  return await cleanupRepos(args.config, args.mode, args.force, io);
104
116
  }
117
+ if (args.command === "mcp") {
118
+ const parsed = parseCompletionMcpArgs(args.argv);
119
+ if (!parsed.ok) {
120
+ io.stderr.write(`${parsed.error}\n`);
121
+ return 2;
122
+ }
123
+ await runCompletionMcpServer(parsed.config);
124
+ return 0;
125
+ }
105
126
  let redactionSecrets = readEnvSecretValuesForRedaction();
106
127
  try {
107
128
  const dispatchJson = await fs.readFile(args.dispatchPath, "utf8");
@@ -165,19 +186,23 @@ function parseEnvSecretGrant(raw) {
165
186
  };
166
187
  }
167
188
  function exactSecrets(dispatch) {
168
- return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
189
+ return exactSecretValues(dispatch.apiKeySecretGrant?.secret, process.env.FORGE_DEVICE_TOKEN);
169
190
  }
170
191
  function readEnvSecretValuesForRedaction() {
192
+ const secrets = [];
171
193
  const raw = process.env[apiKeySecretGrantEnv];
172
- if (!raw) {
173
- return [];
174
- }
175
- try {
176
- return [parseEnvSecretGrant(raw).secret];
177
- }
178
- catch {
179
- return [];
194
+ if (raw) {
195
+ try {
196
+ secrets.push(parseEnvSecretGrant(raw).secret);
197
+ }
198
+ catch {
199
+ // Ignore malformed grant env here; command handling will report the parse error.
200
+ }
180
201
  }
202
+ return exactSecretValues(...secrets, process.env.FORGE_DEVICE_TOKEN);
203
+ }
204
+ function exactSecretValues(...values) {
205
+ return values.filter((value) => typeof value === "string" && value.length > 0);
181
206
  }
182
207
  export function parseArgs(argv) {
183
208
  if (argv[0] === "connect") {
@@ -214,6 +239,9 @@ export function parseArgs(argv) {
214
239
  if (argv[0] === "cleanup") {
215
240
  return parseCleanupArgs(argv);
216
241
  }
242
+ if (argv[0] === "mcp") {
243
+ return { ok: true, command: "mcp", argv };
244
+ }
217
245
  if (argv[0] !== "run-once") {
218
246
  return {
219
247
  ok: false,
@@ -460,6 +488,7 @@ function pairedConfig(current, args, claim) {
460
488
  supportsManagedWorktrees: current.supportsManagedWorktrees ?? true,
461
489
  supportsManagedForgeArtifacts: current.supportsManagedForgeArtifacts ?? true,
462
490
  supportsRunnerAuthoredPhaseArtifacts: current.supportsRunnerAuthoredPhaseArtifacts ?? true,
491
+ supportsDraftRunOnce: current.supportsDraftRunOnce ?? true,
463
492
  repoMappings: current.repoMappings ?? []
464
493
  };
465
494
  }
@@ -507,8 +536,74 @@ function normalizePairingCode(code) {
507
536
  function normalizeServerUrl(serverUrl) {
508
537
  return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
509
538
  }
539
+ function isHelpArg(arg) {
540
+ return arg === "help" || arg === "--help" || arg === "-h";
541
+ }
510
542
  function usage() {
511
- return "Usage: forge-bridge run-once --dispatch <dispatch-json-path> --result <result-json-path> | 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]";
543
+ return helpText();
544
+ }
545
+ function helpText() {
546
+ const version = defaultBridgeServiceConfig().version ?? forgeBridgeVersion;
547
+ return [
548
+ `Forge Bridge ${version}`,
549
+ "Connect this machine to Forge as a local runner for agent dispatch.",
550
+ "",
551
+ "Usage:",
552
+ " forge-bridge <command> [options]",
553
+ "",
554
+ "Options:",
555
+ " -h, --help Show this help.",
556
+ " -v, --version Print the bridge version.",
557
+ "",
558
+ "Commands:",
559
+ " pair [code] Pair this machine with Forge and save runner credentials.",
560
+ " connect Start the bridge and wait for Forge dispatches.",
561
+ " status Show saved config, runner identity, repos, and detected CLIs.",
562
+ " add-repo Map a Forge project/repo label to a local checkout path.",
563
+ " remove-repo Remove a saved repo mapping.",
564
+ " cleanup Prune or remove managed bridge worktrees.",
565
+ " run-once Internal worker entrypoint used by Forge dispatch.",
566
+ " mcp Internal MCP server for phase completion.",
567
+ "",
568
+ "Examples:",
569
+ " npm install -g @loucompanion/forge-bridge@next",
570
+ " forge-bridge pair <code> --server https://forge.example.com --repo-root \"%USERPROFILE%\\ForgeRepos\"",
571
+ " forge-bridge connect",
572
+ " forge-bridge status --check-updates",
573
+ " forge-bridge add-repo Forge C:\\Users\\you\\src\\Forge",
574
+ "",
575
+ "Setup:",
576
+ " 1. In Forge, open Profile Settings -> Runners -> Pair runner.",
577
+ " 2. Copy the pairing code and command shown there.",
578
+ " 3. Run forge-bridge connect and keep the terminal open.",
579
+ "",
580
+ "Docs:",
581
+ " https://github.com/coleman399/Forge/tree/develop/tools/forge-bridge#readme",
582
+ "",
583
+ ].join("\n");
584
+ }
585
+ function connectStartupText(config) {
586
+ const runner = config.runnerId
587
+ ? `${config.runnerId}${config.deviceName ? ` (${config.deviceName})` : ""}`
588
+ : "not configured";
589
+ return [
590
+ `Forge Bridge ${config.version}`,
591
+ "Starting runner bridge...",
592
+ "",
593
+ "Startup:",
594
+ ` server: ${config.serverUrl || "not configured"}`,
595
+ ` runner: ${runner}`,
596
+ ` config: ${resolveLocalConfigPath()}`,
597
+ ` repo folder: ${config.defaultRepoRoot || "not configured"}`,
598
+ ` providers: ${formatList(config.cliProviders)}`,
599
+ ` cleanup worktrees: ${config.cleanupWorktrees ? "enabled" : "disabled"}`,
600
+ "",
601
+ "Press Ctrl+C to stop.",
602
+ "",
603
+ ].join("\n");
604
+ }
605
+ function formatList(values) {
606
+ return values.length > 0 ? values.join(", ") : "none";
512
607
  }
513
608
  async function writeJsonAtomic(outputPath, value) {
514
609
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
@@ -620,6 +715,12 @@ function cliProbeCommandsFor(provider) {
620
715
  ];
621
716
  }
622
717
  async function execVersionCommand(probe, timeout = 2000) {
718
+ const directLaunch = process.platform === "win32"
719
+ ? await resolveCursorAgentDirectLaunch(probe.command, probe.args)
720
+ : null;
721
+ if (directLaunch) {
722
+ return await execFileAsync(directLaunch.command, [...directLaunch.args], { timeout });
723
+ }
623
724
  try {
624
725
  return await execFileAsync(probe.command, probe.args, { timeout });
625
726
  }
@@ -16,6 +16,7 @@ export interface BridgeLocalConfig {
16
16
  readonly supportsManagedWorktrees?: boolean;
17
17
  readonly supportsManagedForgeArtifacts?: boolean;
18
18
  readonly supportsRunnerAuthoredPhaseArtifacts?: boolean;
19
+ readonly supportsDraftRunOnce?: boolean;
19
20
  }
20
21
  export interface LocalRepoMapping extends BridgeRepoMapping {
21
22
  readonly label: string;
@@ -53,7 +53,8 @@ export function applyLocalConfigDefaults(config, local) {
53
53
  supportsApiSecretGrant: local.supportsApiSecretGrant ?? config.supportsApiSecretGrant,
54
54
  supportsManagedWorktrees: local.supportsManagedWorktrees ?? config.supportsManagedWorktrees,
55
55
  supportsManagedForgeArtifacts: local.supportsManagedForgeArtifacts ?? config.supportsManagedForgeArtifacts,
56
- supportsRunnerAuthoredPhaseArtifacts: local.supportsRunnerAuthoredPhaseArtifacts ?? config.supportsRunnerAuthoredPhaseArtifacts
56
+ supportsRunnerAuthoredPhaseArtifacts: local.supportsRunnerAuthoredPhaseArtifacts ?? config.supportsRunnerAuthoredPhaseArtifacts,
57
+ supportsDraftRunOnce: local.supportsDraftRunOnce ?? config.supportsDraftRunOnce
57
58
  };
58
59
  }
59
60
  export async function addRepoMapping(label, repoPath) {
@@ -111,7 +112,8 @@ function normalizeLocalConfig(config) {
111
112
  supportsApiSecretGrant: config.supportsApiSecretGrant,
112
113
  supportsManagedWorktrees: config.supportsManagedWorktrees,
113
114
  supportsManagedForgeArtifacts: config.supportsManagedForgeArtifacts,
114
- supportsRunnerAuthoredPhaseArtifacts: config.supportsRunnerAuthoredPhaseArtifacts
115
+ supportsRunnerAuthoredPhaseArtifacts: config.supportsRunnerAuthoredPhaseArtifacts,
116
+ supportsDraftRunOnce: config.supportsDraftRunOnce
115
117
  };
116
118
  }
117
119
  function emptyConfig() {
@@ -159,7 +161,8 @@ function normalizeRepoMappings(values) {
159
161
  repoId: cleanString(mapping.repoId),
160
162
  name: cleanString(mapping.name) ?? label,
161
163
  remoteUrl: cleanString(mapping.remoteUrl),
162
- localPath: path.resolve(mapping.localPath)
164
+ localPath: path.resolve(mapping.localPath),
165
+ qaAppLaunchTarget: normalizeQaAppLaunchTarget(mapping.qaAppLaunchTarget)
163
166
  };
164
167
  })
165
168
  .sort((left, right) => left.label.localeCompare(right.label));
@@ -171,6 +174,22 @@ function normalizeLabel(value) {
171
174
  }
172
175
  return label;
173
176
  }
177
+ function normalizeQaAppLaunchTarget(value) {
178
+ if (!value)
179
+ return undefined;
180
+ const routes = cleanList(value.routes);
181
+ const normalized = {
182
+ url: cleanString(value.url),
183
+ command: cleanString(value.command),
184
+ working_directory: cleanString(value.working_directory),
185
+ ready_url: cleanString(value.ready_url),
186
+ routes,
187
+ notes: cleanString(value.notes)
188
+ };
189
+ return normalized.url || normalized.command || normalized.ready_url || routes?.length
190
+ ? normalized
191
+ : undefined;
192
+ }
174
193
  function cleanString(value) {
175
194
  const clean = value?.trim();
176
195
  return clean ? clean : undefined;
package/dist/config.js CHANGED
@@ -2,7 +2,7 @@ import os from "node:os";
2
2
  import path from "node:path";
3
3
  export function defaultForgeBridgeConfig() {
4
4
  return {
5
- defaultRepoRoot: path.join(os.tmpdir(), "forge-phase5-repos"),
5
+ defaultRepoRoot: path.join(os.tmpdir(), "forge-repos"),
6
6
  cleanupWorktrees: true
7
7
  };
8
8
  }
@@ -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";
@@ -1,6 +1,8 @@
1
1
  export { parseRunOnceDispatchPacket, failureResult } from "./session.types.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 { SessionEventSink, SessionService } from "./session.base.js";
3
- export type { ApiKeySecretGrant, DispatchCycleRun, DispatchExecution, DispatchLease, DispatchSession, DispatchRepo, DispatchServerLocalConfig, DispatchWorkItem, RunOnceDispatchPacket, RunOnceResultPacket, RunnerDispatchArtifact, RunnerDispatchEvent, RunnerDispatchSpec, RunnerEventLine } from "./session.types.js";
5
+ export type { ApiKeySecretGrant, DispatchCycleRun, DispatchExecution, DispatchLease, DispatchPullRequestContext, DispatchSession, DispatchRepo, DispatchServerLocalConfig, DispatchWorkItem, RunOnceDispatchPacket, RunOnceResultPacket, RunnerDispatchArtifact, RunnerDispatchEvent, RunnerDispatchSpec, RunnerEventLine } from "./session.types.js";
4
6
  export { DeterministicProviderCliService, ProcessBackedProviderCliService, RoutingProviderCliService, buildProviderCliCommand } from "./provider-cli/provider-cli.service.js";
5
7
  export type { ProviderCliService } from "./provider-cli/provider-cli.base.js";
6
8
  export type { ProviderCliCommand, ProviderProcessRunner, ProviderProcessRunRequest, ProviderProcessRunResult } from "./provider-cli/provider-cli.service.js";
@@ -1,2 +1,3 @@
1
1
  export { parseRunOnceDispatchPacket, failureResult } from "./session.types.js";
2
+ export { buildCompletionToolServerArgs, codexMcpConfigArgs, completionPhaseFromDispatch, expectedCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, writeCompletionArtifact } from "./phase-completion.js";
2
3
  export { DeterministicProviderCliService, ProcessBackedProviderCliService, RoutingProviderCliService, buildProviderCliCommand } from "./provider-cli/provider-cli.service.js";