@loucompanion/forge-bridge 0.1.1-dev.4f31cdd58cac → 0.1.1-dev.5b0a0fe4307c

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.
@@ -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;
@@ -48,8 +48,14 @@ export class ForgeBridgeService {
48
48
  while (!this.stopped && !signal?.aborted) {
49
49
  try {
50
50
  await this.connectOnce();
51
+ if (attempt > 0) {
52
+ logBridgeStatus("Connected to Forge. Waiting for dispatches...");
53
+ }
51
54
  attempt = 0;
52
55
  await this.waitForClose(signal);
56
+ if (!this.stopped && !signal?.aborted) {
57
+ logBridgeStatus("Connection closed. Reconnecting...");
58
+ }
53
59
  }
54
60
  catch (error) {
55
61
  if (this.stopped || signal?.aborted) {
@@ -57,6 +63,7 @@ export class ForgeBridgeService {
57
63
  }
58
64
  const backoff = this.config.reconnectBackoffMs[Math.min(attempt, this.config.reconnectBackoffMs.length - 1)] ?? 5000;
59
65
  attempt += 1;
66
+ logBridgeStatus(`Connect attempt ${attempt} failed: ${errorMessage(error)}. Retrying in ${formatDelay(backoff)}...`);
60
67
  await delay(backoff, signal);
61
68
  }
62
69
  }
@@ -73,10 +80,14 @@ export class ForgeBridgeService {
73
80
  this.abortActiveSessions("bridge stopping");
74
81
  await this.stopConnection();
75
82
  }
76
- async handleDispatch(dispatch) {
83
+ async handleDispatch(rawDispatch) {
84
+ const dispatch = this.parseDispatchOrReport(rawDispatch);
85
+ if (!dispatch) {
86
+ return;
87
+ }
77
88
  const leaseId = dispatch.lease.id;
78
89
  const fencingToken = dispatch.lease.fencingToken;
79
- const redactionSecrets = exactSecrets(dispatch);
90
+ const redactionSecrets = exactSecrets(dispatch, this.config);
80
91
  if (this.activeSessions.has(leaseId)) {
81
92
  await this.sendResult(leaseId, fencingToken, failureResult("Dispatch is already active on this bridge.")).catch(() => undefined);
82
93
  return;
@@ -87,11 +98,20 @@ export class ForgeBridgeService {
87
98
  const normalized = this.normalizeDispatch(dispatch);
88
99
  const result = await this.sessions.runOnce(normalized, {
89
100
  emit: async (event) => {
90
- await this.invokeAck("DispatchEvent", {
91
- leaseId,
92
- fencingToken,
93
- event: redactSecrets(event, redactionSecrets)
94
- });
101
+ try {
102
+ await this.invokeAck("DispatchEvent", {
103
+ leaseId,
104
+ fencingToken,
105
+ event: redactSecrets(event, redactionSecrets)
106
+ });
107
+ }
108
+ catch (error) {
109
+ if (isDispatchNoLongerPending(error)) {
110
+ controller.abort(error instanceof Error ? error.message : "Dispatch is no longer pending.");
111
+ return;
112
+ }
113
+ throw error;
114
+ }
95
115
  }
96
116
  }, controller.signal);
97
117
  await this.sendResult(leaseId, fencingToken, redactSecrets(result, redactionSecrets));
@@ -106,6 +126,27 @@ export class ForgeBridgeService {
106
126
  this.activeSessions.delete(leaseId);
107
127
  }
108
128
  }
129
+ parseDispatchOrReport(rawDispatch) {
130
+ try {
131
+ return parseRunOnceDispatchPacket(rawDispatch);
132
+ }
133
+ catch (error) {
134
+ const identifiers = readDispatchIdentifiers(rawDispatch);
135
+ if (!identifiers) {
136
+ return null;
137
+ }
138
+ const summary = invalidDispatchSummary(error);
139
+ void this.sendResult(identifiers.leaseId, identifiers.fencingToken, failureResult(summary, [
140
+ {
141
+ role: "runner",
142
+ kind: "error",
143
+ content: summary,
144
+ eventType: "invalid_dispatch"
145
+ }
146
+ ])).catch(() => undefined);
147
+ return null;
148
+ }
149
+ }
109
150
  async handleCancel(request) {
110
151
  const active = this.activeSessions.get(request.leaseId);
111
152
  if (!active || active.fencingToken !== request.fencingToken) {
@@ -123,6 +164,7 @@ export class ForgeBridgeService {
123
164
  const mapping = findRepoMapping(dispatch, this.config.repoMappings);
124
165
  const localPath = mapping?.localPath?.trim();
125
166
  const repoRoot = localPath ? path.dirname(path.resolve(localPath)) : this.config.defaultRepoRoot;
167
+ const qaLaunchTarget = normalizeQaAppLaunchTarget(mapping?.qaAppLaunchTarget) ?? dispatch.qaBrowser?.launchTarget ?? null;
126
168
  return {
127
169
  ...dispatch,
128
170
  repo: {
@@ -133,7 +175,13 @@ export class ForgeBridgeService {
133
175
  serverLocal: {
134
176
  repoRoot,
135
177
  cleanupWorktrees: this.config.cleanupWorktrees
136
- }
178
+ },
179
+ qaBrowser: dispatch.qaBrowser
180
+ ? {
181
+ ...dispatch.qaBrowser,
182
+ launchTarget: qaLaunchTarget
183
+ }
184
+ : dispatch.qaBrowser
137
185
  };
138
186
  }
139
187
  async sendResult(leaseId, fencingToken, result) {
@@ -142,7 +190,15 @@ export class ForgeBridgeService {
142
190
  fencingToken,
143
191
  result
144
192
  };
145
- await this.invokeAck("DispatchResult", payload);
193
+ try {
194
+ await this.invokeAck("DispatchResult", payload);
195
+ }
196
+ catch (error) {
197
+ if (isDispatchNoLongerPending(error)) {
198
+ return;
199
+ }
200
+ throw error;
201
+ }
146
202
  }
147
203
  startHeartbeat() {
148
204
  this.stopHeartbeat();
@@ -179,7 +235,8 @@ export class ForgeBridgeService {
179
235
  supportsApiSecretGrant: this.config.supportsApiSecretGrant,
180
236
  supportsManagedWorktrees: this.config.supportsManagedWorktrees,
181
237
  supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
182
- supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts
238
+ supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts,
239
+ supportsDraftRunOnce: this.config.supportsDraftRunOnce
183
240
  };
184
241
  }
185
242
  async invokeAck(methodName, payload) {
@@ -189,7 +246,7 @@ export class ForgeBridgeService {
189
246
  }
190
247
  const ack = await connection.invoke(methodName, payload);
191
248
  if (!ack.accepted) {
192
- throw new Error(ack.message ?? ack.code ?? `${methodName} callback was rejected.`);
249
+ throw new BridgeCallbackRejectedError(ack.code ?? null, ack.message ?? ack.code ?? `${methodName} callback was rejected.`);
193
250
  }
194
251
  return ack;
195
252
  }
@@ -207,6 +264,20 @@ export class ForgeBridgeService {
207
264
  }
208
265
  }
209
266
  }
267
+ class BridgeCallbackRejectedError extends Error {
268
+ code;
269
+ constructor(code, message) {
270
+ super(message);
271
+ this.code = code;
272
+ this.name = "BridgeCallbackRejectedError";
273
+ }
274
+ }
275
+ function isDispatchNoLongerPending(error) {
276
+ if (error instanceof BridgeCallbackRejectedError) {
277
+ return error.code === "bridge.dispatch_not_pending" || error.code === "bridge.stale_dispatch";
278
+ }
279
+ return error instanceof Error && error.message === "Dispatch is no longer pending.";
280
+ }
210
281
  export class SignalRBridgeConnectionFactory {
211
282
  create(config) {
212
283
  return new SignalRBridgeConnection(config);
@@ -237,14 +308,7 @@ export class SignalRBridgeConnection {
237
308
  }
238
309
  onDispatch(handler) {
239
310
  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);
311
+ void handler(raw).catch(() => undefined);
248
312
  });
249
313
  }
250
314
  onCancel(handler) {
@@ -271,11 +335,12 @@ export function defaultBridgeServiceConfig(overrides = {}) {
271
335
  projectLabels: splitCsv(process.env.FORGE_PROJECT_LABELS ?? ""),
272
336
  repoMappings: [],
273
337
  os: process.platform,
274
- sandboxProfiles: ["default"],
338
+ sandboxProfiles: overrides.sandboxProfiles,
275
339
  supportsApiSecretGrant: true,
276
340
  supportsManagedWorktrees: true,
277
341
  supportsManagedForgeArtifacts: true,
278
342
  supportsRunnerAuthoredPhaseArtifacts: true,
343
+ supportsDraftRunOnce: true,
279
344
  version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
280
345
  protocolVersion: 1,
281
346
  minServerVersion: null,
@@ -285,6 +350,7 @@ export function defaultBridgeServiceConfig(overrides = {}) {
285
350
  };
286
351
  return {
287
352
  ...config,
353
+ sandboxProfiles: config.sandboxProfiles ?? defaultSandboxProfiles(config.cliProviders),
288
354
  heartbeatIntervalMs: positiveFiniteNumber(config.heartbeatIntervalMs, 30_000)
289
355
  };
290
356
  }
@@ -303,6 +369,11 @@ function positiveFiniteNumber(value, fallback) {
303
369
  function providerStatusesFor(providers) {
304
370
  return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
305
371
  }
372
+ function defaultSandboxProfiles(providers) {
373
+ return providers.some((provider) => provider.toLowerCase() === "codex")
374
+ ? ["provider-workspace-write"]
375
+ : ["none"];
376
+ }
306
377
  function configuredModelsFor(provider) {
307
378
  const envName = `FORGE_${provider.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}_MODELS`;
308
379
  return splitCsv(process.env[envName] ?? "");
@@ -323,16 +394,70 @@ function findRepoMapping(dispatch, mappings) {
323
394
  (!!mapping.remoteUrl?.trim() && equalsIgnoreCase(mapping.remoteUrl, dispatch.repo.remoteUrl)));
324
395
  });
325
396
  }
397
+ function normalizeQaAppLaunchTarget(target) {
398
+ if (!target) {
399
+ return null;
400
+ }
401
+ const routes = [...new Set((target.routes ?? []).map((route) => route.trim()).filter(Boolean))];
402
+ const normalized = {
403
+ url: cleanString(target.url),
404
+ command: cleanString(target.command),
405
+ working_directory: cleanString(target.working_directory),
406
+ ready_url: cleanString(target.ready_url),
407
+ routes,
408
+ notes: cleanString(target.notes)
409
+ };
410
+ return normalized.url || normalized.command || normalized.ready_url || routes.length > 0
411
+ ? normalized
412
+ : null;
413
+ }
414
+ function cleanString(value) {
415
+ const clean = value?.trim();
416
+ return clean ? clean : null;
417
+ }
326
418
  function equalsIgnoreCase(left, right) {
327
419
  return !!left?.trim() && !!right?.trim() && left.trim().toLowerCase() === right.trim().toLowerCase();
328
420
  }
421
+ function readDispatchIdentifiers(rawDispatch) {
422
+ if (!rawDispatch || typeof rawDispatch !== "object" || Array.isArray(rawDispatch)) {
423
+ return null;
424
+ }
425
+ const packet = rawDispatch;
426
+ const rawLease = packet.lease ?? packet.Lease;
427
+ if (!rawLease || typeof rawLease !== "object" || Array.isArray(rawLease)) {
428
+ return null;
429
+ }
430
+ const lease = rawLease;
431
+ const leaseId = readString(lease, "id", "Id");
432
+ const fencingToken = readString(lease, "fencingToken", "FencingToken");
433
+ return leaseId && fencingToken ? { leaseId, fencingToken } : null;
434
+ }
435
+ function readString(source, camelKey, pascalKey) {
436
+ const value = source[camelKey] ?? source[pascalKey];
437
+ return typeof value === "string" && value.trim() ? value : null;
438
+ }
439
+ function invalidDispatchSummary(error) {
440
+ const detail = error instanceof Error ? error.message : String(error);
441
+ return `Invalid bridge dispatch packet: ${detail}`;
442
+ }
443
+ function errorMessage(error) {
444
+ return error instanceof Error ? error.message : String(error);
445
+ }
446
+ function formatDelay(ms) {
447
+ if (ms >= 1000 && ms % 1000 === 0) {
448
+ return `${ms / 1000}s`;
449
+ }
450
+ return `${ms}ms`;
451
+ }
452
+ function logBridgeStatus(message) {
453
+ process.stderr.write(`[${new Date().toISOString()}] ${message}\n`);
454
+ }
329
455
  function delay(ms, signal) {
330
456
  if (signal?.aborted) {
331
457
  return Promise.resolve();
332
458
  }
333
459
  return new Promise((resolve) => {
334
460
  const timeout = setTimeout(resolve, ms);
335
- timeout.unref?.();
336
461
  signal?.addEventListener("abort", () => {
337
462
  clearTimeout(timeout);
338
463
  resolve();
@@ -348,6 +473,9 @@ function abortPromise(signal) {
348
473
  }
349
474
  return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
350
475
  }
351
- function exactSecrets(dispatch) {
352
- return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
476
+ function exactSecrets(dispatch, config) {
477
+ return [
478
+ dispatch.apiKeySecretGrant?.secret,
479
+ config.deviceToken
480
+ ].filter((secret) => typeof secret === "string" && secret.length > 0);
353
481
  }
@@ -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>;
@@ -7,6 +7,7 @@ import { promisify } from "node:util";
7
7
  import { resolveCursorAgentDirectLaunch } from "../shared/cursor-agent/cursor-agent.launch.js";
8
8
  import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
9
9
  import { defaultBridgeServiceConfig, ForgeBridgeService } from "../bridge/bridge.service.js";
10
+ import { forgeBridgeVersion } from "../version.js";
10
11
  import { addRepoMapping, applyLocalConfigDefaults, providerStatusesFor, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath, writeLocalConfig } from "./local-config.js";
11
12
  import { BridgeSessionService } from "../session/session.service.js";
12
13
  import { parseCompletionMcpArgs, runCompletionMcpServer } from "../session/phase-completion.js";
@@ -23,6 +24,22 @@ export class ForgeBridgeCliService {
23
24
  this.pairRunner = pairRunner;
24
25
  }
25
26
  async run(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
27
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
28
+ io.stdout.write(helpText());
29
+ return 0;
30
+ }
31
+ if (argv[0] === "help") {
32
+ io.stdout.write(argv[1] ? commandHelpText(argv[1]) : helpText());
33
+ return 0;
34
+ }
35
+ if (argv[1] === "--help" || argv[1] === "-h") {
36
+ io.stdout.write(commandHelpText(argv[0]));
37
+ return 0;
38
+ }
39
+ if (argv[0] === "version" || argv[0] === "--version" || argv[0] === "-v") {
40
+ io.stdout.write(`forge-bridge ${defaultBridgeServiceConfig().version ?? forgeBridgeVersion}\n`);
41
+ return 0;
42
+ }
26
43
  const args = parseArgs(argv);
27
44
  if (!args.ok) {
28
45
  io.stderr.write(`${args.error}\n`);
@@ -44,6 +61,7 @@ export class ForgeBridgeCliService {
44
61
  process.once("SIGINT", stop);
45
62
  process.once("SIGTERM", stop);
46
63
  try {
64
+ io.stdout.write(connectStartupText(config));
47
65
  await this.bridgeFactory(config).run(controller.signal);
48
66
  return 0;
49
67
  }
@@ -176,19 +194,23 @@ function parseEnvSecretGrant(raw) {
176
194
  };
177
195
  }
178
196
  function exactSecrets(dispatch) {
179
- return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
197
+ return exactSecretValues(dispatch.apiKeySecretGrant?.secret, process.env.FORGE_DEVICE_TOKEN);
180
198
  }
181
199
  function readEnvSecretValuesForRedaction() {
200
+ const secrets = [];
182
201
  const raw = process.env[apiKeySecretGrantEnv];
183
- if (!raw) {
184
- return [];
185
- }
186
- try {
187
- return [parseEnvSecretGrant(raw).secret];
188
- }
189
- catch {
190
- return [];
202
+ if (raw) {
203
+ try {
204
+ secrets.push(parseEnvSecretGrant(raw).secret);
205
+ }
206
+ catch {
207
+ // Ignore malformed grant env here; command handling will report the parse error.
208
+ }
191
209
  }
210
+ return exactSecretValues(...secrets, process.env.FORGE_DEVICE_TOKEN);
211
+ }
212
+ function exactSecretValues(...values) {
213
+ return values.filter((value) => typeof value === "string" && value.length > 0);
192
214
  }
193
215
  export function parseArgs(argv) {
194
216
  if (argv[0] === "connect") {
@@ -474,6 +496,7 @@ function pairedConfig(current, args, claim) {
474
496
  supportsManagedWorktrees: current.supportsManagedWorktrees ?? true,
475
497
  supportsManagedForgeArtifacts: current.supportsManagedForgeArtifacts ?? true,
476
498
  supportsRunnerAuthoredPhaseArtifacts: current.supportsRunnerAuthoredPhaseArtifacts ?? true,
499
+ supportsDraftRunOnce: current.supportsDraftRunOnce ?? true,
477
500
  repoMappings: current.repoMappings ?? []
478
501
  };
479
502
  }
@@ -521,8 +544,220 @@ function normalizePairingCode(code) {
521
544
  function normalizeServerUrl(serverUrl) {
522
545
  return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
523
546
  }
547
+ function isHelpArg(arg) {
548
+ return arg === "help" || arg === "--help" || arg === "-h";
549
+ }
524
550
  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]";
551
+ return helpText();
552
+ }
553
+ function helpText() {
554
+ const version = defaultBridgeServiceConfig().version ?? forgeBridgeVersion;
555
+ return [
556
+ `Forge Bridge ${version}`,
557
+ "Local runner bridge for Forge agents.",
558
+ "",
559
+ "Forge Bridge lets this machine run provider CLIs like Codex or Cursor for Forge.",
560
+ "Keep `forge-bridge connect` open while Forge dispatches work.",
561
+ "",
562
+ "Usage:",
563
+ " forge-bridge <command> [options]",
564
+ " forge-bridge help <command>",
565
+ "",
566
+ "Options:",
567
+ " -h, --help Show help.",
568
+ " -v, --version Print the bridge version.",
569
+ "",
570
+ "Setup commands:",
571
+ " pair [code] Pair this machine with Forge and save runner credentials.",
572
+ " connect Start the bridge and wait for Forge dispatches.",
573
+ " status Show config, runner identity, repos, provider CLIs, and updates.",
574
+ "",
575
+ "Repo commands:",
576
+ " add-repo Map a Forge repo label/name to a local checkout path.",
577
+ " remove-repo Remove a saved repo mapping.",
578
+ " cleanup Prune managed bridge worktrees.",
579
+ "",
580
+ "Internal commands:",
581
+ " run-once Run one dispatch packet. Used by Forge and tests.",
582
+ " mcp Run the phase-completion MCP server for provider CLIs.",
583
+ "",
584
+ "Common flow:",
585
+ " 1. Install or update: npm install -g @loucompanion/forge-bridge@next",
586
+ " 2. In Forge: Profile Settings -> Runners -> Pair runner.",
587
+ " 3. Run the pairing command Forge shows you.",
588
+ " 4. Start the bridge: forge-bridge connect",
589
+ "",
590
+ "Examples:",
591
+ " forge-bridge version",
592
+ " forge-bridge pair 1234-5678 --server https://srv1493013.tail66073d.ts.net:8443 --repo-root \"%USERPROFILE%\\ForgeRepos\"",
593
+ " forge-bridge connect",
594
+ " forge-bridge status --check-updates",
595
+ " forge-bridge add-repo Forge D:\\Programming\\repos\\Forge",
596
+ " forge-bridge cleanup --stale",
597
+ "",
598
+ "Troubleshooting:",
599
+ " - If Forge says the bridge is old, run npm install -g @loucompanion/forge-bridge@next.",
600
+ " - If connect shows 502/500, leave it open; it reconnects with backoff while dev restarts.",
601
+ " - If a provider is missing, run forge-bridge status --check-updates and verify the CLI login.",
602
+ "",
603
+ "Command help:",
604
+ " forge-bridge help pair",
605
+ " forge-bridge connect --help",
606
+ "",
607
+ "Docs:",
608
+ " https://github.com/coleman399/Forge/tree/develop/tools/forge-bridge#readme",
609
+ "",
610
+ ].join("\n");
611
+ }
612
+ function commandHelpText(command) {
613
+ switch (command) {
614
+ case "pair":
615
+ return [
616
+ "Forge Bridge — pair",
617
+ "Pair this machine with Forge and save runner credentials.",
618
+ "",
619
+ "Usage:",
620
+ " forge-bridge pair [code] [options]",
621
+ "",
622
+ "Options:",
623
+ " --server <url> Forge server URL.",
624
+ " --repo-root <path> Folder where bridge-managed repos/worktrees live.",
625
+ " --name <name> Display name for this runner.",
626
+ " --fingerprint <value> Stable device fingerprint override.",
627
+ " --cli-provider <list> Comma-separated providers, e.g. codex,cursor.",
628
+ " --cleanup-worktrees Enable managed worktree cleanup.",
629
+ " --no-cleanup-worktrees Disable managed worktree cleanup.",
630
+ "",
631
+ "Example:",
632
+ " forge-bridge pair 1234-5678 --server https://forge.example.com --repo-root \"%USERPROFILE%\\ForgeRepos\"",
633
+ "",
634
+ ].join("\n");
635
+ case "connect":
636
+ return [
637
+ "Forge Bridge — connect",
638
+ "Start the runner bridge and keep it connected for Forge dispatches.",
639
+ "",
640
+ "Usage:",
641
+ " forge-bridge connect [options]",
642
+ "",
643
+ "Options:",
644
+ " --server <url> Forge server URL; saved config is used when omitted.",
645
+ " --runner-id <id> Runner id; saved config is used when omitted.",
646
+ " --device-token <token> Device token; saved config is used when omitted.",
647
+ " --repo-root <path> Override repo/worktree folder for this run.",
648
+ " --cli-provider <list> Comma-separated providers, e.g. codex,cursor.",
649
+ " --heartbeat-interval-ms <n> Heartbeat interval in milliseconds.",
650
+ " --cleanup-worktrees Enable managed worktree cleanup for this run.",
651
+ " --no-cleanup-worktrees Disable managed worktree cleanup for this run.",
652
+ "",
653
+ "Example:",
654
+ " forge-bridge connect",
655
+ "",
656
+ ].join("\n");
657
+ case "status":
658
+ return [
659
+ "Forge Bridge — status",
660
+ "Show saved configuration, paired runner identity, repo mappings, detected provider CLIs, and optional update info.",
661
+ "",
662
+ "Usage:",
663
+ " forge-bridge status [--check-updates]",
664
+ "",
665
+ "Example:",
666
+ " forge-bridge status --check-updates",
667
+ "",
668
+ ].join("\n");
669
+ case "add-repo":
670
+ return [
671
+ "Forge Bridge — add-repo",
672
+ "Map a Forge repo label/name to an existing local checkout.",
673
+ "",
674
+ "Usage:",
675
+ " forge-bridge add-repo <label> <path>",
676
+ "",
677
+ "Example:",
678
+ " forge-bridge add-repo Forge D:\\Programming\\repos\\Forge",
679
+ "",
680
+ ].join("\n");
681
+ case "remove-repo":
682
+ return [
683
+ "Forge Bridge — remove-repo",
684
+ "Remove a saved local repo mapping.",
685
+ "",
686
+ "Usage:",
687
+ " forge-bridge remove-repo <label>",
688
+ "",
689
+ ].join("\n");
690
+ case "cleanup":
691
+ return [
692
+ "Forge Bridge — cleanup",
693
+ "Prune managed bridge worktrees under the configured repo folder.",
694
+ "",
695
+ "Usage:",
696
+ " forge-bridge cleanup [--stale|--merged|--all] [--force]",
697
+ "",
698
+ "Examples:",
699
+ " forge-bridge cleanup --stale",
700
+ " forge-bridge cleanup --all --force",
701
+ "",
702
+ ].join("\n");
703
+ case "run-once":
704
+ return [
705
+ "Forge Bridge — run-once",
706
+ "Internal command used by Forge to run a single dispatch packet.",
707
+ "",
708
+ "Usage:",
709
+ " forge-bridge run-once --dispatch <dispatch-json-path> --result <result-json-path>",
710
+ "",
711
+ ].join("\n");
712
+ case "mcp":
713
+ return [
714
+ "Forge Bridge — mcp",
715
+ "Internal MCP server used by provider CLIs to report Forge phase artifacts.",
716
+ "",
717
+ "Usage:",
718
+ " forge-bridge mcp --worktree <path> --work-item-id <id> --phase <phase>",
719
+ "",
720
+ ].join("\n");
721
+ case "version":
722
+ return [
723
+ "Forge Bridge — version",
724
+ "Print the installed bridge package version.",
725
+ "",
726
+ "Usage:",
727
+ " forge-bridge version",
728
+ "",
729
+ ].join("\n");
730
+ default:
731
+ return [
732
+ `Unknown forge-bridge command: ${command}`,
733
+ "",
734
+ "Run `forge-bridge help` to see available commands.",
735
+ "",
736
+ ].join("\n");
737
+ }
738
+ }
739
+ function connectStartupText(config) {
740
+ const runner = config.runnerId
741
+ ? `${config.runnerId}${config.deviceName ? ` (${config.deviceName})` : ""}`
742
+ : "not configured";
743
+ return [
744
+ `Forge Bridge ${config.version}`,
745
+ "Starting runner bridge...",
746
+ "",
747
+ "Startup:",
748
+ ` server: ${config.serverUrl || "not configured"}`,
749
+ ` runner: ${runner}`,
750
+ ` config: ${resolveLocalConfigPath()}`,
751
+ ` repo folder: ${config.defaultRepoRoot || "not configured"}`,
752
+ ` providers: ${formatList(config.cliProviders)}`,
753
+ ` cleanup worktrees: ${config.cleanupWorktrees ? "enabled" : "disabled"}`,
754
+ "",
755
+ "Press Ctrl+C to stop.",
756
+ "",
757
+ ].join("\n");
758
+ }
759
+ function formatList(values) {
760
+ return values.length > 0 ? values.join(", ") : "none";
526
761
  }
527
762
  async function writeJsonAtomic(outputPath, value) {
528
763
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
@@ -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;