@loucompanion/forge-bridge 0.1.1-dev.9acab970e5d1 → 0.1.1-dev.ac656b52f9c6

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;
@@ -1,4 +1,5 @@
1
1
  import { HubConnectionBuilder, LogLevel } from "@microsoft/signalr";
2
+ import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { defaultForgeBridgeConfig } from "../config.js";
4
5
  import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
@@ -48,8 +49,14 @@ export class ForgeBridgeService {
48
49
  while (!this.stopped && !signal?.aborted) {
49
50
  try {
50
51
  await this.connectOnce();
52
+ if (attempt > 0) {
53
+ logBridgeStatus("Connected to Forge. Waiting for dispatches...");
54
+ }
51
55
  attempt = 0;
52
56
  await this.waitForClose(signal);
57
+ if (!this.stopped && !signal?.aborted) {
58
+ logBridgeStatus("Connection closed. Reconnecting...");
59
+ }
53
60
  }
54
61
  catch (error) {
55
62
  if (this.stopped || signal?.aborted) {
@@ -57,6 +64,7 @@ export class ForgeBridgeService {
57
64
  }
58
65
  const backoff = this.config.reconnectBackoffMs[Math.min(attempt, this.config.reconnectBackoffMs.length - 1)] ?? 5000;
59
66
  attempt += 1;
67
+ logBridgeStatus(`Connect attempt ${attempt} failed: ${errorMessage(error)}. Retrying in ${formatDelay(backoff)}...`);
60
68
  await delay(backoff, signal);
61
69
  }
62
70
  }
@@ -73,10 +81,14 @@ export class ForgeBridgeService {
73
81
  this.abortActiveSessions("bridge stopping");
74
82
  await this.stopConnection();
75
83
  }
76
- async handleDispatch(dispatch) {
84
+ async handleDispatch(rawDispatch) {
85
+ const dispatch = this.parseDispatchOrReport(rawDispatch);
86
+ if (!dispatch) {
87
+ return;
88
+ }
77
89
  const leaseId = dispatch.lease.id;
78
90
  const fencingToken = dispatch.lease.fencingToken;
79
- const redactionSecrets = exactSecrets(dispatch);
91
+ const redactionSecrets = exactSecrets(dispatch, this.config);
80
92
  if (this.activeSessions.has(leaseId)) {
81
93
  await this.sendResult(leaseId, fencingToken, failureResult("Dispatch is already active on this bridge.")).catch(() => undefined);
82
94
  return;
@@ -115,6 +127,27 @@ export class ForgeBridgeService {
115
127
  this.activeSessions.delete(leaseId);
116
128
  }
117
129
  }
130
+ parseDispatchOrReport(rawDispatch) {
131
+ try {
132
+ return parseRunOnceDispatchPacket(rawDispatch);
133
+ }
134
+ catch (error) {
135
+ const identifiers = readDispatchIdentifiers(rawDispatch);
136
+ if (!identifiers) {
137
+ return null;
138
+ }
139
+ const summary = invalidDispatchSummary(error);
140
+ void this.sendResult(identifiers.leaseId, identifiers.fencingToken, failureResult(summary, [
141
+ {
142
+ role: "runner",
143
+ kind: "error",
144
+ content: summary,
145
+ eventType: "invalid_dispatch"
146
+ }
147
+ ])).catch(() => undefined);
148
+ return null;
149
+ }
150
+ }
118
151
  async handleCancel(request) {
119
152
  const active = this.activeSessions.get(request.leaseId);
120
153
  if (!active || active.fencingToken !== request.fencingToken) {
@@ -129,7 +162,7 @@ export class ForgeBridgeService {
129
162
  this.activeSessions.clear();
130
163
  }
131
164
  normalizeDispatch(dispatch) {
132
- const mapping = findRepoMapping(dispatch, this.config.repoMappings);
165
+ const mapping = findRepoMapping(dispatch, repoMappingsFor(this.config));
133
166
  const localPath = mapping?.localPath?.trim();
134
167
  const repoRoot = localPath ? path.dirname(path.resolve(localPath)) : this.config.defaultRepoRoot;
135
168
  const qaLaunchTarget = normalizeQaAppLaunchTarget(mapping?.qaAppLaunchTarget) ?? dispatch.qaBrowser?.launchTarget ?? null;
@@ -197,13 +230,14 @@ export class ForgeBridgeService {
197
230
  cliProviders: this.config.cliProviders,
198
231
  providerStatuses: this.config.providerStatuses,
199
232
  projectLabels: this.config.projectLabels,
200
- repoMappings: this.config.repoMappings,
233
+ repoMappings: repoMappingsFor(this.config),
201
234
  os: this.config.os,
202
235
  sandboxProfiles: this.config.sandboxProfiles,
203
236
  supportsApiSecretGrant: this.config.supportsApiSecretGrant,
204
237
  supportsManagedWorktrees: this.config.supportsManagedWorktrees,
205
238
  supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
206
- supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts
239
+ supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts,
240
+ supportsDraftRunOnce: this.config.supportsDraftRunOnce
207
241
  };
208
242
  }
209
243
  async invokeAck(methodName, payload) {
@@ -275,14 +309,7 @@ export class SignalRBridgeConnection {
275
309
  }
276
310
  onDispatch(handler) {
277
311
  this.connection.on("Dispatch", (raw) => {
278
- let dispatch;
279
- try {
280
- dispatch = parseRunOnceDispatchPacket(raw);
281
- }
282
- catch {
283
- return;
284
- }
285
- void handler(dispatch).catch(() => undefined);
312
+ void handler(raw).catch(() => undefined);
286
313
  });
287
314
  }
288
315
  onCancel(handler) {
@@ -309,11 +336,12 @@ export function defaultBridgeServiceConfig(overrides = {}) {
309
336
  projectLabels: splitCsv(process.env.FORGE_PROJECT_LABELS ?? ""),
310
337
  repoMappings: [],
311
338
  os: process.platform,
312
- sandboxProfiles: ["default"],
339
+ sandboxProfiles: overrides.sandboxProfiles,
313
340
  supportsApiSecretGrant: true,
314
341
  supportsManagedWorktrees: true,
315
342
  supportsManagedForgeArtifacts: true,
316
343
  supportsRunnerAuthoredPhaseArtifacts: true,
344
+ supportsDraftRunOnce: true,
317
345
  version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
318
346
  protocolVersion: 1,
319
347
  minServerVersion: null,
@@ -323,6 +351,7 @@ export function defaultBridgeServiceConfig(overrides = {}) {
323
351
  };
324
352
  return {
325
353
  ...config,
354
+ sandboxProfiles: config.sandboxProfiles ?? defaultSandboxProfiles(config.cliProviders),
326
355
  heartbeatIntervalMs: positiveFiniteNumber(config.heartbeatIntervalMs, 30_000)
327
356
  };
328
357
  }
@@ -341,6 +370,14 @@ function positiveFiniteNumber(value, fallback) {
341
370
  function providerStatusesFor(providers) {
342
371
  return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
343
372
  }
373
+ function defaultSandboxProfiles(providers, platform = process.platform) {
374
+ if (!providers.some((provider) => provider.toLowerCase() === "codex")) {
375
+ return ["none"];
376
+ }
377
+ return platform === "win32"
378
+ ? ["danger-full-access"]
379
+ : ["provider-workspace-write"];
380
+ }
344
381
  function configuredModelsFor(provider) {
345
382
  const envName = `FORGE_${provider.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}_MODELS`;
346
383
  return splitCsv(process.env[envName] ?? "");
@@ -351,6 +388,62 @@ function splitCsv(value) {
351
388
  .map((entry) => entry.trim())
352
389
  .filter((entry) => entry.length > 0);
353
390
  }
391
+ function repoMappingsFor(config) {
392
+ const explicit = [...config.repoMappings];
393
+ const discovered = discoverRepoRootMappings(config.defaultRepoRoot);
394
+ if (explicit.length === 0) {
395
+ return discovered;
396
+ }
397
+ const explicitKeys = new Set(explicit.flatMap(mappingKeys));
398
+ return [
399
+ ...explicit,
400
+ ...discovered.filter((mapping) => !mappingKeys(mapping).some((key) => explicitKeys.has(key)))
401
+ ];
402
+ }
403
+ function discoverRepoRootMappings(defaultRepoRoot) {
404
+ const root = defaultRepoRoot.trim();
405
+ if (!root) {
406
+ return [];
407
+ }
408
+ const resolvedRoot = path.resolve(root);
409
+ const mappings = [];
410
+ if (isGitCheckout(resolvedRoot)) {
411
+ mappings.push({ name: path.basename(resolvedRoot), localPath: resolvedRoot });
412
+ }
413
+ let entries;
414
+ try {
415
+ entries = fs.readdirSync(resolvedRoot, { withFileTypes: true });
416
+ }
417
+ catch {
418
+ return mappings;
419
+ }
420
+ for (const entry of entries) {
421
+ if (!entry.isDirectory()) {
422
+ continue;
423
+ }
424
+ const localPath = path.join(resolvedRoot, entry.name);
425
+ if (isGitCheckout(localPath)) {
426
+ mappings.push({ name: entry.name, localPath });
427
+ }
428
+ }
429
+ return mappings.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
430
+ }
431
+ function isGitCheckout(localPath) {
432
+ try {
433
+ return fs.existsSync(path.join(localPath, ".git"));
434
+ }
435
+ catch {
436
+ return false;
437
+ }
438
+ }
439
+ function mappingKeys(mapping) {
440
+ return [
441
+ mapping.repoId ? `id:${mapping.repoId.toLowerCase()}` : null,
442
+ mapping.name ? `name:${mapping.name.trim().toLowerCase()}` : null,
443
+ mapping.remoteUrl ? `remote:${mapping.remoteUrl.trim().toLowerCase()}` : null,
444
+ mapping.localPath ? `path:${path.resolve(mapping.localPath).toLowerCase()}` : null
445
+ ].filter((key) => Boolean(key));
446
+ }
354
447
  function findRepoMapping(dispatch, mappings) {
355
448
  return mappings.find((mapping) => {
356
449
  if (!mapping.localPath?.trim()) {
@@ -385,13 +478,46 @@ function cleanString(value) {
385
478
  function equalsIgnoreCase(left, right) {
386
479
  return !!left?.trim() && !!right?.trim() && left.trim().toLowerCase() === right.trim().toLowerCase();
387
480
  }
481
+ function readDispatchIdentifiers(rawDispatch) {
482
+ if (!rawDispatch || typeof rawDispatch !== "object" || Array.isArray(rawDispatch)) {
483
+ return null;
484
+ }
485
+ const packet = rawDispatch;
486
+ const rawLease = packet.lease ?? packet.Lease;
487
+ if (!rawLease || typeof rawLease !== "object" || Array.isArray(rawLease)) {
488
+ return null;
489
+ }
490
+ const lease = rawLease;
491
+ const leaseId = readString(lease, "id", "Id");
492
+ const fencingToken = readString(lease, "fencingToken", "FencingToken");
493
+ return leaseId && fencingToken ? { leaseId, fencingToken } : null;
494
+ }
495
+ function readString(source, camelKey, pascalKey) {
496
+ const value = source[camelKey] ?? source[pascalKey];
497
+ return typeof value === "string" && value.trim() ? value : null;
498
+ }
499
+ function invalidDispatchSummary(error) {
500
+ const detail = error instanceof Error ? error.message : String(error);
501
+ return `Invalid bridge dispatch packet: ${detail}`;
502
+ }
503
+ function errorMessage(error) {
504
+ return error instanceof Error ? error.message : String(error);
505
+ }
506
+ function formatDelay(ms) {
507
+ if (ms >= 1000 && ms % 1000 === 0) {
508
+ return `${ms / 1000}s`;
509
+ }
510
+ return `${ms}ms`;
511
+ }
512
+ function logBridgeStatus(message) {
513
+ process.stderr.write(`[${new Date().toISOString()}] ${message}\n`);
514
+ }
388
515
  function delay(ms, signal) {
389
516
  if (signal?.aborted) {
390
517
  return Promise.resolve();
391
518
  }
392
519
  return new Promise((resolve) => {
393
520
  const timeout = setTimeout(resolve, ms);
394
- timeout.unref?.();
395
521
  signal?.addEventListener("abort", () => {
396
522
  clearTimeout(timeout);
397
523
  resolve();
@@ -407,6 +533,9 @@ function abortPromise(signal) {
407
533
  }
408
534
  return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
409
535
  }
410
- function exactSecrets(dispatch) {
411
- return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
536
+ function exactSecrets(dispatch, config) {
537
+ return [
538
+ dispatch.apiKeySecretGrant?.secret,
539
+ config.deviceToken
540
+ ].filter((secret) => typeof secret === "string" && secret.length > 0);
412
541
  }
@@ -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;
@@ -55,6 +56,7 @@ export interface BridgeCapabilities {
55
56
  readonly supportsManagedWorktrees?: boolean | null;
56
57
  readonly supportsManagedForgeArtifacts?: boolean | null;
57
58
  readonly supportsRunnerAuthoredPhaseArtifacts?: boolean | null;
59
+ readonly supportsDraftRunOnce?: boolean | null;
58
60
  }
59
61
  export interface BridgeHelloRequest {
60
62
  readonly version: string;
@@ -88,5 +90,5 @@ export interface BridgeCancelRequest {
88
90
  readonly fencingToken: string;
89
91
  readonly reason?: string | null;
90
92
  }
91
- export type BridgeDispatchHandler = (dispatch: RunOnceDispatchPacket) => Promise<void>;
93
+ export type BridgeDispatchHandler = (dispatch: unknown) => Promise<void>;
92
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
  }
@@ -522,7 +545,199 @@ function normalizeServerUrl(serverUrl) {
522
545
  return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
523
546
  }
524
547
  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]";
548
+ return helpText();
549
+ }
550
+ function helpText() {
551
+ const version = defaultBridgeServiceConfig().version ?? forgeBridgeVersion;
552
+ return [
553
+ `🛠 Forge Bridge ${version} — Local provider runners for Forge.`,
554
+ "",
555
+ "Usage: forge-bridge [options] [command]",
556
+ "",
557
+ "Options:",
558
+ " -h, --help Display help for command",
559
+ " -v, --version Output the version number",
560
+ "",
561
+ "Commands:",
562
+ " Hint: commands suffixed with * have detailed help. Run <command> --help for details.",
563
+ " connect * Start the runner bridge and wait for Forge dispatches",
564
+ " pair * Pair this machine with Forge and save runner credentials",
565
+ " status * Show config, runner identity, repos, provider CLIs, and updates",
566
+ " add-repo * Map a Forge repo label/name to a local checkout path",
567
+ " remove-repo * Remove a saved repo mapping",
568
+ " cleanup * Prune managed bridge worktrees",
569
+ " version * Print the installed bridge package version",
570
+ " help * Display help for command",
571
+ " run-once * Internal: run one dispatch packet from JSON",
572
+ " mcp * Internal: run the phase-completion MCP server",
573
+ "",
574
+ "Examples:",
575
+ " npm install -g @loucompanion/forge-bridge@next",
576
+ " forge-bridge pair 1234-5678 --server https://srv1493013.tail66073d.ts.net:8443 --repo-root \"%USERPROFILE%\\ForgeRepos\"",
577
+ " forge-bridge connect",
578
+ " forge-bridge status --check-updates",
579
+ " forge-bridge add-repo Forge D:\\Programming\\repos\\Forge",
580
+ " forge-bridge cleanup --stale",
581
+ "",
582
+ "Troubleshooting:",
583
+ " If Forge says the bridge is old: npm install -g @loucompanion/forge-bridge@next",
584
+ " If connect shows 502/500: leave it open; it reconnects with backoff while dev restarts.",
585
+ " If a provider is missing: run forge-bridge status --check-updates and verify CLI login.",
586
+ "",
587
+ "Docs:",
588
+ " https://github.com/coleman399/Forge/tree/develop/tools/forge-bridge#readme",
589
+ "",
590
+ ].join("\n");
591
+ }
592
+ function commandHelpText(command) {
593
+ switch (command) {
594
+ case "pair":
595
+ return [
596
+ "Forge Bridge — pair",
597
+ "Pair this machine with Forge and save runner credentials.",
598
+ "",
599
+ "Usage:",
600
+ " forge-bridge pair [code] [options]",
601
+ "",
602
+ "Options:",
603
+ " --server <url> Forge server URL.",
604
+ " --repo-root <path> Folder where bridge-managed repos/worktrees live.",
605
+ " --name <name> Display name for this runner.",
606
+ " --fingerprint <value> Stable device fingerprint override.",
607
+ " --cli-provider <list> Comma-separated providers, e.g. codex,cursor.",
608
+ " --cleanup-worktrees Enable managed worktree cleanup.",
609
+ " --no-cleanup-worktrees Disable managed worktree cleanup.",
610
+ "",
611
+ "Example:",
612
+ " forge-bridge pair 1234-5678 --server https://forge.example.com --repo-root \"%USERPROFILE%\\ForgeRepos\"",
613
+ "",
614
+ ].join("\n");
615
+ case "connect":
616
+ return [
617
+ "Forge Bridge — connect",
618
+ "Start the runner bridge and keep it connected for Forge dispatches.",
619
+ "",
620
+ "Usage:",
621
+ " forge-bridge connect [options]",
622
+ "",
623
+ "Options:",
624
+ " --server <url> Forge server URL; saved config is used when omitted.",
625
+ " --runner-id <id> Runner id; saved config is used when omitted.",
626
+ " --device-token <token> Device token; saved config is used when omitted.",
627
+ " --repo-root <path> Override repo/worktree folder for this run.",
628
+ " --cli-provider <list> Comma-separated providers, e.g. codex,cursor.",
629
+ " --heartbeat-interval-ms <n> Heartbeat interval in milliseconds.",
630
+ " --cleanup-worktrees Enable managed worktree cleanup for this run.",
631
+ " --no-cleanup-worktrees Disable managed worktree cleanup for this run.",
632
+ "",
633
+ "Example:",
634
+ " forge-bridge connect",
635
+ "",
636
+ ].join("\n");
637
+ case "status":
638
+ return [
639
+ "Forge Bridge — status",
640
+ "Show saved configuration, paired runner identity, repo mappings, detected provider CLIs, and optional update info.",
641
+ "",
642
+ "Usage:",
643
+ " forge-bridge status [--check-updates]",
644
+ "",
645
+ "Example:",
646
+ " forge-bridge status --check-updates",
647
+ "",
648
+ ].join("\n");
649
+ case "add-repo":
650
+ return [
651
+ "Forge Bridge — add-repo",
652
+ "Map a Forge repo label/name to an existing local checkout.",
653
+ "",
654
+ "Usage:",
655
+ " forge-bridge add-repo <label> <path>",
656
+ "",
657
+ "Example:",
658
+ " forge-bridge add-repo Forge D:\\Programming\\repos\\Forge",
659
+ "",
660
+ ].join("\n");
661
+ case "remove-repo":
662
+ return [
663
+ "Forge Bridge — remove-repo",
664
+ "Remove a saved local repo mapping.",
665
+ "",
666
+ "Usage:",
667
+ " forge-bridge remove-repo <label>",
668
+ "",
669
+ ].join("\n");
670
+ case "cleanup":
671
+ return [
672
+ "Forge Bridge — cleanup",
673
+ "Prune managed bridge worktrees under the configured repo folder.",
674
+ "",
675
+ "Usage:",
676
+ " forge-bridge cleanup [--stale|--merged|--all] [--force]",
677
+ "",
678
+ "Examples:",
679
+ " forge-bridge cleanup --stale",
680
+ " forge-bridge cleanup --all --force",
681
+ "",
682
+ ].join("\n");
683
+ case "run-once":
684
+ return [
685
+ "Forge Bridge — run-once",
686
+ "Internal command used by Forge to run a single dispatch packet.",
687
+ "",
688
+ "Usage:",
689
+ " forge-bridge run-once --dispatch <dispatch-json-path> --result <result-json-path>",
690
+ "",
691
+ ].join("\n");
692
+ case "mcp":
693
+ return [
694
+ "Forge Bridge — mcp",
695
+ "Internal MCP server used by provider CLIs to report Forge phase artifacts.",
696
+ "",
697
+ "Usage:",
698
+ " forge-bridge mcp --worktree <path> --work-item-id <id> --phase <phase>",
699
+ "",
700
+ ].join("\n");
701
+ case "version":
702
+ return [
703
+ "Forge Bridge — version",
704
+ "Print the installed bridge package version.",
705
+ "",
706
+ "Usage:",
707
+ " forge-bridge version",
708
+ "",
709
+ ].join("\n");
710
+ default:
711
+ return [
712
+ `Unknown forge-bridge command: ${command}`,
713
+ "",
714
+ "Run `forge-bridge help` to see available commands.",
715
+ "",
716
+ ].join("\n");
717
+ }
718
+ }
719
+ function connectStartupText(config) {
720
+ const runner = config.runnerId
721
+ ? `${config.runnerId}${config.deviceName ? ` (${config.deviceName})` : ""}`
722
+ : "not configured";
723
+ return [
724
+ `Forge Bridge ${config.version}`,
725
+ "Starting runner bridge...",
726
+ "",
727
+ "Startup:",
728
+ ` server: ${config.serverUrl || "not configured"}`,
729
+ ` runner: ${runner}`,
730
+ ` config: ${resolveLocalConfigPath()}`,
731
+ ` repo folder: ${config.defaultRepoRoot || "not configured"}`,
732
+ ` providers: ${formatList(config.cliProviders)}`,
733
+ ` cleanup worktrees: ${config.cleanupWorktrees ? "enabled" : "disabled"}`,
734
+ "",
735
+ "Press Ctrl+C to stop.",
736
+ "",
737
+ ].join("\n");
738
+ }
739
+ function formatList(values) {
740
+ return values.length > 0 ? values.join(", ") : "none";
526
741
  }
527
742
  async function writeJsonAtomic(outputPath, value) {
528
743
  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;
@@ -49,11 +49,12 @@ export function applyLocalConfigDefaults(config, local) {
49
49
  providerStatuses: normalizeProviderStatuses(local.providerStatuses) ?? providerStatusesFor(cliProviders),
50
50
  projectLabels: local.projectLabels ?? config.projectLabels,
51
51
  repoMappings: local.repoMappings ?? config.repoMappings,
52
- sandboxProfiles: local.sandboxProfiles ?? config.sandboxProfiles,
52
+ sandboxProfiles: normalizeSandboxProfiles(local.sandboxProfiles ?? config.sandboxProfiles, cliProviders) ?? config.sandboxProfiles,
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() {
@@ -149,6 +151,19 @@ function splitCsv(value) {
149
151
  .map((entry) => entry.trim())
150
152
  .filter((entry) => entry.length > 0);
151
153
  }
154
+ function normalizeSandboxProfiles(profiles, cliProviders) {
155
+ const normalized = cleanList(profiles);
156
+ if (process.platform === "win32"
157
+ && cliProviders.some((provider) => provider.toLowerCase() === "codex")
158
+ && normalized?.some((profile) => profile.toLowerCase() === "provider-workspace-write")
159
+ && !normalized.some((profile) => profile.toLowerCase() === "danger-full-access")) {
160
+ return [
161
+ ...normalized.filter((profile) => profile.toLowerCase() !== "provider-workspace-write"),
162
+ "danger-full-access"
163
+ ];
164
+ }
165
+ return normalized;
166
+ }
152
167
  function normalizeRepoMappings(values) {
153
168
  return (values ?? [])
154
169
  .filter((mapping) => !!mapping.label?.trim() && !!mapping.localPath?.trim())
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
  }