borgmcp 2.5.0 → 2.6.0

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 (82) hide show
  1. package/README.md +12 -7
  2. package/dist/assimilate-cmd.d.ts +1 -0
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +58 -30
  5. package/dist/assimilate-cmd.js.map +1 -1
  6. package/dist/assimilate-deps.d.ts.map +1 -1
  7. package/dist/assimilate-deps.js +2 -0
  8. package/dist/assimilate-deps.js.map +1 -1
  9. package/dist/claude.js +9 -3
  10. package/dist/claude.js.map +1 -1
  11. package/dist/cli-help.d.ts +1 -1
  12. package/dist/cli-help.d.ts.map +1 -1
  13. package/dist/cli-help.js +7 -4
  14. package/dist/cli-help.js.map +1 -1
  15. package/dist/codex-launch.d.ts.map +1 -1
  16. package/dist/codex-launch.js +2 -1
  17. package/dist/codex-launch.js.map +1 -1
  18. package/dist/first-run-server.d.ts +31 -0
  19. package/dist/first-run-server.d.ts.map +1 -0
  20. package/dist/first-run-server.js +117 -0
  21. package/dist/first-run-server.js.map +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +25 -27
  24. package/dist/index.js.map +1 -1
  25. package/dist/log-stream.d.ts +4 -8
  26. package/dist/log-stream.d.ts.map +1 -1
  27. package/dist/log-stream.js +22 -15
  28. package/dist/log-stream.js.map +1 -1
  29. package/dist/opencode-drone.d.ts +10 -5
  30. package/dist/opencode-drone.d.ts.map +1 -1
  31. package/dist/opencode-drone.js +241 -48
  32. package/dist/opencode-drone.js.map +1 -1
  33. package/dist/opencode-wake-copy.d.ts +2 -0
  34. package/dist/opencode-wake-copy.d.ts.map +1 -0
  35. package/dist/opencode-wake-copy.js +8 -0
  36. package/dist/opencode-wake-copy.js.map +1 -0
  37. package/dist/regen-format.d.ts.map +1 -1
  38. package/dist/regen-format.js +2 -3
  39. package/dist/regen-format.js.map +1 -1
  40. package/dist/repository-cube-init.d.ts +2 -0
  41. package/dist/repository-cube-init.d.ts.map +1 -1
  42. package/dist/repository-cube-init.js +14 -3
  43. package/dist/repository-cube-init.js.map +1 -1
  44. package/dist/server-facade.d.ts +2 -0
  45. package/dist/server-facade.d.ts.map +1 -1
  46. package/dist/server-facade.js +25 -3
  47. package/dist/server-facade.js.map +1 -1
  48. package/dist/setup.js +10 -0
  49. package/dist/setup.js.map +1 -1
  50. package/dist/stream-status.d.ts +9 -8
  51. package/dist/stream-status.d.ts.map +1 -1
  52. package/dist/stream-status.js +67 -17
  53. package/dist/stream-status.js.map +1 -1
  54. package/dist/update-cmd.d.ts +11 -1
  55. package/dist/update-cmd.d.ts.map +1 -1
  56. package/dist/update-cmd.js +68 -4
  57. package/dist/update-cmd.js.map +1 -1
  58. package/dist/wake-path-health.d.ts +27 -0
  59. package/dist/wake-path-health.d.ts.map +1 -0
  60. package/dist/wake-path-health.js +51 -0
  61. package/dist/wake-path-health.js.map +1 -0
  62. package/docs/EXTRACTION_PROVENANCE.md +3 -3
  63. package/docs/LOCAL_SERVER.md +12 -3
  64. package/docs/RELEASING.md +6 -1
  65. package/package.json +1 -1
  66. package/src/assimilate-cmd.ts +66 -27
  67. package/src/assimilate-deps.ts +9 -0
  68. package/src/claude.ts +10 -3
  69. package/src/cli-help.ts +7 -4
  70. package/src/codex-launch.ts +2 -1
  71. package/src/first-run-server.ts +178 -0
  72. package/src/index.ts +24 -28
  73. package/src/log-stream.ts +36 -25
  74. package/src/opencode-drone.ts +295 -47
  75. package/src/opencode-wake-copy.ts +8 -0
  76. package/src/regen-format.ts +2 -5
  77. package/src/repository-cube-init.ts +16 -3
  78. package/src/server-facade.ts +34 -3
  79. package/src/setup.ts +11 -0
  80. package/src/stream-status.ts +85 -21
  81. package/src/update-cmd.ts +88 -5
  82. package/src/wake-path-health.ts +85 -0
@@ -189,6 +189,9 @@ export interface AssimilateDeps {
189
189
  prompt: (message: string) => Promise<string>;
190
190
  promptSecret: (message: string) => Promise<string>;
191
191
  isTTY: () => boolean;
192
+ ensureLocalServerInstalled: (connectCommand: string) => Promise<
193
+ 'present' | 'installed' | 'declined' | 'non-interactive' | 'failed'
194
+ >;
192
195
  /** Selected-harness approval inspection/consent (client#20). */
193
196
  resolveCliApprovals?: (cli: BorgCli, cwd: string) => Promise<LaunchApprovalDecision>;
194
197
 
@@ -360,6 +363,7 @@ function affirmative(answer: string): boolean {
360
363
  async function selectAssimilationAuthority(
361
364
  flags: AssimilateFlags,
362
365
  deps: AssimilateDeps,
366
+ mode: 'assimilate' | 'cube-init',
363
367
  ): Promise<AssimilationAuthority | null> {
364
368
  if (flags.server !== undefined) {
365
369
  try {
@@ -374,7 +378,10 @@ async function selectAssimilationAuthority(
374
378
  // NOT infer an authority — fail closed with actionable guidance.
375
379
  if (!deps.isTTY() || flags.yes) {
376
380
  if (deps.defaultAuthority) return deps.defaultAuthority;
377
- deps.stderr('No local server selected. Use `borg assimilate --host <host> --here` to select a local server.\n');
381
+ const command = mode === 'cube-init'
382
+ ? '`borg server cube init --host <host>`'
383
+ : '`borg assimilate --host <host> --here`';
384
+ deps.stderr(`No local server selected. Use ${command} to select a local server.\n`);
378
385
  return null;
379
386
  }
380
387
 
@@ -403,8 +410,13 @@ async function selectAssimilationAuthority(
403
410
  }
404
411
  }
405
412
 
406
- function localAssimilateCommand(apiUrl: string, enroll = false): string {
407
- return `\`borg assimilate --host ${apiUrl}${enroll ? ' --enroll' : ''}\``;
413
+ function localAssimilateCommand(
414
+ apiUrl: string,
415
+ enroll = false,
416
+ mode: 'assimilate' | 'cube-init' = 'assimilate',
417
+ ): string {
418
+ const command = mode === 'cube-init' ? 'borg server cube init' : 'borg assimilate';
419
+ return `\`${command} --host ${apiUrl}${enroll ? ' --enroll' : ''}\``;
408
420
  }
409
421
 
410
422
  function localAssimilateRoleCommand(apiUrl: string): string {
@@ -420,36 +432,44 @@ function reportServerFailure(
420
432
  apiUrl: string,
421
433
  error: unknown,
422
434
  enroll = false,
435
+ mode: 'assimilate' | 'cube-init' = 'assimilate',
423
436
  ): number {
424
437
  const message = error instanceof Error ? error.message : String(error);
425
- const retryCommand = localAssimilateCommand(apiUrl, enroll);
438
+ const retryCommand = localAssimilateCommand(apiUrl, enroll, mode);
426
439
  if (error instanceof BorgServerError && error.code === 'CREATE_CUBE_DENIED') {
427
440
  deps.stderr(
428
441
  `This enrolled client cannot create a cube on ${apiUrl}. ` +
429
442
  'Ask the server operator to grant access to a cube, then rerun ' +
430
- `${localAssimilateCommand(apiUrl)}.\n`,
443
+ `${localAssimilateCommand(apiUrl, false, mode)}.\n`,
431
444
  );
432
445
  return 1;
433
446
  }
434
447
  if (error instanceof BorgServerError && error.code === 'NOT_ENROLLED') {
435
448
  deps.stderr(
436
- `No saved enrollment for ${apiUrl}. Run ` +
437
- `${localAssimilateCommand(apiUrl, true)} from the operator’s terminal.\n`,
449
+ `Borg could not find a saved enrollment for ${apiUrl}. ` +
450
+ `This can mean that this client has not enrolled with the server, or that its enrollment ` +
451
+ `is saved for a different endpoint. Confirm that the host, port, and IPv4 or IPv6 ` +
452
+ `loopback form in ${apiUrl} match the endpoint used during enrollment. ` +
453
+ `If this client has never enrolled with that server, run ` +
454
+ `${localAssimilateCommand(apiUrl, true, mode)} from the operator’s terminal.\n`,
438
455
  );
439
456
  return 1;
440
457
  }
441
458
  if (error instanceof BorgServerError && error.code === 'CREDENTIAL_REJECTED') {
442
459
  deps.stderr(
443
460
  `The saved enrollment for ${apiUrl} was rejected. Re-run ` +
444
- `${localAssimilateCommand(apiUrl, true)} from the operator’s terminal.\n`,
461
+ `${localAssimilateCommand(apiUrl, true, mode)} from the operator’s terminal.\n`,
445
462
  );
446
463
  return 1;
447
464
  }
448
465
  if (error instanceof LegacySessionCredentialCollisionError) {
466
+ const recovery = mode === 'cube-init'
467
+ ? localAssimilateCommand(error.origin, true, mode)
468
+ : `borg assimilate --host ${error.origin} --enroll`;
449
469
  deps.stderr(
450
470
  `Local session credential collision detected.\n` +
451
471
  `No local credentials were changed.\n` +
452
- `Next: run borg assimilate --host ${error.origin} --enroll.\n`,
472
+ `Next: run ${recovery}.\n`,
453
473
  );
454
474
  return 1;
455
475
  }
@@ -460,10 +480,10 @@ function reportServerFailure(
460
480
  // this worktree — no server/trust-anchor/cube/other-worktree reset, no restart
461
481
  // or version-alignment advice (#1082).
462
482
  if (error instanceof BorgServerError && error.code === 'SESSION_REVOKED') {
463
- return diagnoseSessionTermination(deps, apiUrl, 'revoked');
483
+ return diagnoseSessionTermination(deps, apiUrl, 'revoked', mode);
464
484
  }
465
485
  if (error instanceof BorgServerError && error.code === 'SESSION_REJECTED') {
466
- return diagnoseSessionTermination(deps, apiUrl, 'superseded');
486
+ return diagnoseSessionTermination(deps, apiUrl, 'superseded', mode);
467
487
  }
468
488
  if (error instanceof BorgServerError && error.code === 'INVITATION_REJECTED') {
469
489
  deps.stderr(
@@ -471,14 +491,14 @@ function reportServerFailure(
471
491
  'Ask the server operator for a replacement invitation — the server can stay running: ' +
472
492
  'for an unclaimed owner client run `borg-mcp-server owner-invite`; for an ordinary ' +
473
493
  'client run `borg-mcp-server client-invite`. Then rerun ' +
474
- `${localAssimilateCommand(apiUrl, true)}.\n`,
494
+ `${localAssimilateCommand(apiUrl, true, mode)}.\n`,
475
495
  );
476
496
  return 1;
477
497
  }
478
498
  if (/HTTP 40[13]|auth(?:entication|orization)|credential.*(?:invalid|rejected)/i.test(message)) {
479
499
  deps.stderr(
480
500
  `The saved enrollment for ${apiUrl} was rejected. Re-run ` +
481
- `${localAssimilateCommand(apiUrl, true)} from the operator’s terminal.\n`,
501
+ `${localAssimilateCommand(apiUrl, true, mode)} from the operator’s terminal.\n`,
482
502
  );
483
503
  return 1;
484
504
  }
@@ -545,13 +565,17 @@ function diagnoseSessionTermination(
545
565
  deps: AssimilateDeps,
546
566
  apiUrl: string,
547
567
  outcome: 'revoked' | 'superseded',
568
+ mode: 'assimilate' | 'cube-init' = 'assimilate',
548
569
  ): number {
549
570
  const message = outcome === 'revoked'
550
571
  ? 'Local session was revoked.'
551
572
  : 'Local session was superseded by a newer enrollment.';
573
+ const recovery = mode === 'cube-init'
574
+ ? localAssimilateCommand(apiUrl, true, mode)
575
+ : `borg assimilate --host ${apiUrl} --enroll`;
552
576
  deps.stderr(
553
577
  `${message}\n` +
554
- `Next: run borg reset-local-seat, then borg assimilate --host ${apiUrl} --enroll.\n`,
578
+ `Next: run borg reset-local-seat, then ${recovery}.\n`,
555
579
  );
556
580
  return 1;
557
581
  }
@@ -586,7 +610,8 @@ export async function runAssimilate(
586
610
  repositoryContext = await deps.resolveRepositoryContext(deps.cwd());
587
611
  } catch (error) {
588
612
  if (error instanceof Error && error.message === 'BARE_REPOSITORY') {
589
- deps.stderr('borg assimilate requires a non-bare repository worktree. Clone or check out the repository, then retry.\n');
613
+ const command = mode === 'cube-init' ? 'borg server cube init' : 'borg assimilate';
614
+ deps.stderr(`${command} requires a non-bare repository worktree. Clone or check out the repository, then retry.\n`);
590
615
  return 1;
591
616
  }
592
617
  repositoryContext = null;
@@ -600,6 +625,19 @@ export async function runAssimilate(
600
625
  return 1;
601
626
  }
602
627
 
628
+ if (args.flags.server === undefined && deps.defaultAuthority === undefined) {
629
+ const connectCommand = mode === 'cube-init'
630
+ ? 'borg server cube init --host <host>'
631
+ : 'borg assimilate --host <host>';
632
+ const serverInstall = await deps.ensureLocalServerInstalled(connectCommand);
633
+ if (serverInstall !== 'present') {
634
+ // A newly installed server still needs its explicit setup/start journey.
635
+ // Decline, non-interactive, and failure paths have already printed exact
636
+ // recovery commands. None may continue into private-state mutation.
637
+ return serverInstall === 'installed' ? 0 : 1;
638
+ }
639
+ }
640
+
603
641
  try {
604
642
  await deps.preparePrivateRoot();
605
643
  } catch {
@@ -618,16 +656,16 @@ export async function runAssimilate(
618
656
  hasPersistedIdentity = existing !== null || await deps.hasPersistedActiveCube();
619
657
  } catch (error) {
620
658
  if (error instanceof LegacySessionCredentialCollisionError) {
621
- return reportServerFailure(deps, error.origin, error);
659
+ return reportServerFailure(deps, error.origin, error, false, mode);
622
660
  }
623
661
  localSeatReadError = error;
624
662
  }
625
663
 
626
664
  // ----- Step 1: Select and authenticate the local server -----
627
- const authority = await selectAssimilationAuthority(args.flags, deps);
665
+ const authority = await selectAssimilationAuthority(args.flags, deps, mode);
628
666
  if (!authority) return 1;
629
667
  if (localSeatReadError !== undefined) {
630
- return reportServerFailure(deps, authority.apiUrl, localSeatReadError);
668
+ return reportServerFailure(deps, authority.apiUrl, localSeatReadError, false, mode);
631
669
  }
632
670
 
633
671
  const projectRoot = repositoryContext.root;
@@ -644,7 +682,7 @@ export async function runAssimilate(
644
682
  if (!deps.isTTY()) {
645
683
  deps.stderr(
646
684
  'Local enrollment requires an interactive operator terminal. ' +
647
- `Re-run ${localAssimilateCommand(authority.apiUrl, true)} from the operator’s terminal.\n`,
685
+ `Re-run ${localAssimilateCommand(authority.apiUrl, true, mode)} from the operator’s terminal.\n`,
648
686
  );
649
687
  return 1;
650
688
  }
@@ -663,7 +701,7 @@ export async function runAssimilate(
663
701
  if (!invitation) {
664
702
  deps.stderr(
665
703
  `No enrollment invitation was entered for ${authority.apiUrl}. ` +
666
- `Ask the server operator for one, then rerun ${localAssimilateCommand(authority.apiUrl, true)}.\n`,
704
+ `Ask the server operator for one, then rerun ${localAssimilateCommand(authority.apiUrl, true, mode)}.\n`,
667
705
  );
668
706
  return 1;
669
707
  }
@@ -697,7 +735,7 @@ export async function runAssimilate(
697
735
  serverCapabilities: serverAuth.serverCapabilities ?? [],
698
736
  };
699
737
  } catch (error) {
700
- return reportServerFailure(deps, authority.apiUrl, error, args.flags.enroll === true);
738
+ return reportServerFailure(deps, authority.apiUrl, error, args.flags.enroll === true, mode);
701
739
  }
702
740
  }
703
741
 
@@ -728,6 +766,8 @@ export async function runAssimilate(
728
766
  isTTY: deps.isTTY,
729
767
  prompt: deps.prompt,
730
768
  write: deps.stderr,
769
+ ...(mode === 'cube-init' ? { writeResult: deps.stdout } : {}),
770
+ useColor: () => deps.isTTY() && !process.env.NO_COLOR && !process.env.CI,
731
771
  getIdentity: deps.getRepositoryIdentity,
732
772
  getAssociation: (repository) => deps.getRepositoryAssociation(auth.serverTrustIdentity, repository),
733
773
  saveAssociation: (repository, association) =>
@@ -815,12 +855,12 @@ export async function runAssimilate(
815
855
  if (error instanceof BorgServerError && error.code === 'CREATE_CUBE_DENIED') {
816
856
  deps.stderr(
817
857
  `This enrolled client cannot create a cube on ${auth.apiUrl}. ` +
818
- `Ask the server operator to grant access to a cube, then rerun ${localAssimilateCommand(auth.apiUrl)}.\n`,
858
+ `Ask the server operator to grant access to a cube, then rerun ${localAssimilateCommand(auth.apiUrl, false, mode)}.\n`,
819
859
  );
820
860
  return 1;
821
861
  }
822
862
  if (error instanceof BorgServerError) {
823
- return reportServerFailure(deps, auth.apiUrl, error);
863
+ return reportServerFailure(deps, auth.apiUrl, error, false, mode);
824
864
  }
825
865
  deps.stderr(
826
866
  'Repository cube initialization failed.\n' +
@@ -1800,7 +1840,7 @@ export async function runAssimilate(
1800
1840
  // OpenCode assimilate launch: start TUI with the kickoff passed via
1801
1841
  // --prompt (auto-submits it as the first message). BORG_SESSION is
1802
1842
  // pinned in opencode.json. A unique port is assigned so the MCP child
1803
- // can connect via the HTTP API for context-streaming (injectOpenCodeEntry).
1843
+ // can connect to OpenCode's local HTTP API for durable entry injection.
1804
1844
  dronePort = computeOpenCodePort(result.drone_id);
1805
1845
  installBorgPlugin();
1806
1846
  const cwd = agentCwd;
@@ -1824,9 +1864,8 @@ export async function runAssimilate(
1824
1864
  launchedAtSeconds: Math.floor(Date.now() / 1000),
1825
1865
  });
1826
1866
  }
1827
- // gh#opencode: inject the kickoff prompt into the TUI's first session via
1828
- // the SDK. OpenCode doesn't accept a prompt as a CLI arg, so we do it
1829
- // programmatically once the HTTP server is ready. Best-effort.
1867
+ // gh#opencode: bind to the kickoff-bearing session through OpenCode's local
1868
+ // HTTP API after the TUI auto-submits --prompt. Best-effort.
1830
1869
  if (cli === 'opencode' && openCodeKickoff) {
1831
1870
  const launchKickoff = openCodeKickoff;
1832
1871
  const serverUrl = `http://127.0.0.1:${dronePort}`;
@@ -18,6 +18,10 @@ import { readinessProbeEnv } from './readiness-probe.js';
18
18
  import { resolveMcpBinaryPath } from './self-path.js';
19
19
  import { buildRuntimeMetadataReport } from './runtime-metadata.js';
20
20
  import { inspectLiveInboxMonitor } from './seat-reattach-guard.js';
21
+ import {
22
+ buildDefaultFirstRunServerInstallDeps,
23
+ offerFirstRunServerInstall,
24
+ } from './first-run-server.js';
21
25
 
22
26
  import type { AssimilateDeps } from './assimilate-cmd.js';
23
27
  import {
@@ -149,6 +153,11 @@ export function buildDefaultAssimilateDeps(
149
153
  return typeof result.invitation === 'string' ? result.invitation : '';
150
154
  },
151
155
  isTTY: () => process.stdin.isTTY === true,
156
+ ensureLocalServerInstalled: async (connectCommand) =>
157
+ (await offerFirstRunServerInstall(
158
+ buildDefaultFirstRunServerInstallDeps(),
159
+ connectCommand,
160
+ )).kind,
152
161
  resolveCliApprovals: (cli, cwd) => resolveLaunchBorgApprovals(
153
162
  cli,
154
163
  defaultApprovalIo(
package/src/claude.ts CHANGED
@@ -82,7 +82,7 @@ import { ensureCliMcpConfigured } from './ensure-mcp-config.js';
82
82
  import { installBorgPlugin } from './opencode-plugin.js';
83
83
  import { connectOpenCodeDrone, computeOpenCodePort, createOpenCodeLaunchKickoff, injectInitialKickoff } from './opencode-drone.js';
84
84
  import { buildOpenCodeLaunchArgs, defaultApprovalIo, resolveLaunchBorgApprovals } from './cli-tool-approval.js';
85
- import { runEarlyServerFacade } from './server-facade.js';
85
+ import { isClientOwnedCubeInitArgv, runEarlyServerFacade } from './server-facade.js';
86
86
  import { runEarlyUpdate } from './update-cmd.js';
87
87
 
88
88
  export type AssimilateDepsBuilder = typeof buildDefaultAssimilateDeps;
@@ -106,6 +106,13 @@ async function main() {
106
106
  const updateExitCode = await runEarlyUpdate(process.argv);
107
107
  if (updateExitCode !== null) process.exit(updateExitCode);
108
108
 
109
+ // Cube initialization is client-owned, so enable debug before its early
110
+ // facade dispatch. Server lifecycle commands keep their argv verbatim:
111
+ // their separate executable owns and parses `--debug`.
112
+ if (isClientOwnedCubeInitArgv(process.argv)) {
113
+ initDebugFromArgv(process.argv);
114
+ }
115
+
109
116
  const serverExitCode = await runEarlyServerFacade(process.argv);
110
117
  if (serverExitCode !== null) process.exit(serverExitCode);
111
118
 
@@ -462,7 +469,7 @@ async function main() {
462
469
  // OpenCode launch: start TUI with the kickoff passed via --prompt
463
470
  // (auto-submits it as the first message). BORG_SESSION is pinned in
464
471
  // opencode.json. A unique port is assigned so the MCP child can connect
465
- // via the HTTP API for context-streaming (injectOpenCodeEntry).
472
+ // to OpenCode's local HTTP API for durable entry injection.
466
473
  const dronePort = active
467
474
  ? computeOpenCodePort(active.droneId)
468
475
  : 14096;
@@ -500,7 +507,7 @@ async function main() {
500
507
  }
501
508
 
502
509
  // gh#857 WI-2: wake-target recording is codex-only (app-server bridge).
503
- // OpenCode has no remote-wake mechanisms yet; claude uses the inbox Monitor.
510
+ // OpenCode uses HTTP entry injection; Claude uses the inbox Monitor.
504
511
  if (cli === 'codex' && active && codexSocketPath) {
505
512
  void recordCodexWakeTarget({
506
513
  deps: { setCodexWakeTarget, findLoadedCodexThread },
package/src/cli-help.ts CHANGED
@@ -31,7 +31,7 @@ export function topLevelHelpText(version: string): string {
31
31
  ` borg assimilate [role] Join or create a cube\n` +
32
32
  ` borg assimilate --host <host> Join or create on an explicit server\n` +
33
33
  ` borg assimilate --worktree <name> Spawn a worktree drone (in ~/.borg/worktrees/<repo>/<name>)\n` +
34
- ` borg server cube init Initialize this repository's cube without creating a drone\n` +
34
+ ` borg server cube init Initialize this repository's cube without creating a drone\n` +
35
35
  ` borg reset-local-seat Clear ONLY this worktree's saved local seat (offline; after a rejection)\n` +
36
36
  ` borg sync [--prune] Sync this worktree's branch to origin/main\n` +
37
37
  ` borg cleanup [--prune] Report (or --prune) worktrees orphaned by evicted drones\n` +
@@ -82,9 +82,9 @@ export function serverHelpText(): string {
82
82
  }
83
83
 
84
84
  /** Client-owned help for repository cube initialization without a drone. */
85
- export function cubeInitHelpText(): string {
85
+ export function cubeInitHelpText(version: string): string {
86
86
  return (
87
- `borg server cube init — initialize this Git repository's cube without creating a drone\n\n` +
87
+ `borg server cube init (borgmcp ${version}) — initialize this Git repository's cube without creating a drone\n\n` +
88
88
  `Usage:\n` +
89
89
  ` borg server cube init [options]\n\n` +
90
90
  `Options:\n` +
@@ -93,7 +93,10 @@ export function cubeInitHelpText(): string {
93
93
  ` --cube-name <name> Repository cube name (otherwise edit the proposed name)\n` +
94
94
  ` --template software-dev|starter New-cube template (default: software-dev)\n` +
95
95
  ` --yes, -y Accept new-cube defaults; never adopt by name\n` +
96
- ` --help, -h Show this help\n`
96
+ ` --help, -h Show this help\n\n` +
97
+ `An existing repository association skips all prompts. One accessible exact-name legacy\n` +
98
+ `cube requires explicit interactive adoption; ambiguous matches fail closed. An enrolled\n` +
99
+ `owner client may create a repository cube; ordinary clients require an explicit cube grant.\n`
97
100
  );
98
101
  }
99
102
 
@@ -1,5 +1,6 @@
1
1
  import type { BorgCli } from './cubes.js';
2
2
  import { wakePathArming, type AgentKind } from './regen-format.js';
3
+ import { OPENCODE_WAKE_PATH_GUIDANCE } from './opencode-wake-copy.js';
3
4
 
4
5
  /**
5
6
  * The claude kickoff prompt's wake-path section (gh#929) — the SAME shared
@@ -60,7 +61,7 @@ export function buildAgentKickoffPrompt(options: {
60
61
  const codexWakePathClause =
61
62
  options.codexWakePathClause ??
62
63
  `Codex Borg wakeups use remote-control when available; if no wake arrives, run borg_regen manually when returning to the session.`;
63
- const opencodeWakePathClause = `OpenCode wakes: the inbox Monitor is not available yet for OpenCode; check activity by calling borg_read-log periodically.`;
64
+ const opencodeWakePathClause = OPENCODE_WAKE_PATH_GUIDANCE;
64
65
  const loopOrEmpty = options.cli === 'claude' ? '/loop ' : '';
65
66
  const wakeClause = options.cli === 'claude'
66
67
  ? options.monitorClause
@@ -0,0 +1,178 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import {
5
+ buildDefaultUpdateDeps,
6
+ isExactSemver,
7
+ resolveCompatibleServerTarget,
8
+ type InstalledPackage,
9
+ type PublishedPackage,
10
+ type UpdateDeps,
11
+ } from './update-cmd.js';
12
+
13
+ const CLIENT_PACKAGE = 'borgmcp';
14
+ const SERVER_PACKAGE = 'borgmcp-server';
15
+ const SHARED_PACKAGE = 'borgmcp-shared';
16
+ const DEFAULT_CONNECT_COMMAND = 'borg assimilate --host <host>';
17
+
18
+ export type FirstRunServerInstallResult =
19
+ | { kind: 'present'; server: InstalledPackage }
20
+ | { kind: 'installed'; server: InstalledPackage }
21
+ | { kind: 'declined' | 'non-interactive' | 'failed' };
22
+
23
+ export interface FirstRunServerInstallDeps {
24
+ currentServer(): Promise<InstalledPackage | null>;
25
+ publishedPackage(
26
+ name: 'borgmcp' | 'borgmcp-server',
27
+ version: string,
28
+ ): Promise<PublishedPackage>;
29
+ publishedVersions(name: 'borgmcp' | 'borgmcp-server'): Promise<string[]>;
30
+ installGlobal(
31
+ name: 'borgmcp' | 'borgmcp-server',
32
+ version: string,
33
+ options?: { ignoreScripts?: boolean },
34
+ ): Promise<void>;
35
+ confirm(message: string): Promise<'yes' | 'no' | 'eof' | 'interrupted'>;
36
+ isTTY(): boolean;
37
+ stdout(text: string): void;
38
+ stderr(text: string): void;
39
+ clientSharedVersion(): string;
40
+ }
41
+
42
+ function readClientSharedVersion(): string {
43
+ const here = fileURLToPath(import.meta.url);
44
+ const manifestPath = join(dirname(here), '..', 'package.json');
45
+ const parsed: unknown = JSON.parse(readFileSync(manifestPath, 'utf8'));
46
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
47
+ throw new Error('installed borgmcp package manifest is invalid');
48
+ }
49
+ const dependencies = (parsed as Record<string, unknown>).dependencies;
50
+ if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) {
51
+ throw new Error('installed borgmcp dependency manifest is invalid');
52
+ }
53
+ const version = (dependencies as Record<string, unknown>)[SHARED_PACKAGE];
54
+ if (!isExactSemver(version)) {
55
+ throw new Error(`installed ${CLIENT_PACKAGE} does not declare an exact ${SHARED_PACKAGE} pin`);
56
+ }
57
+ return version;
58
+ }
59
+
60
+ function exactInstallCommand(version: string): string {
61
+ return `npm install --global --ignore-scripts ${SERVER_PACKAGE}@${version}`;
62
+ }
63
+
64
+ function assertInstalledServer(
65
+ installed: InstalledPackage | null,
66
+ published: PublishedPackage,
67
+ ): asserts installed is InstalledPackage {
68
+ if (
69
+ installed?.name !== SERVER_PACKAGE ||
70
+ installed.version !== published.version ||
71
+ installed.sharedVersion !== published.sharedVersion
72
+ ) {
73
+ throw new Error(
74
+ `installation verification expected ${SERVER_PACKAGE}@${published.version} with ` +
75
+ `${SHARED_PACKAGE}@${published.sharedVersion}`,
76
+ );
77
+ }
78
+ }
79
+
80
+ export function buildDefaultFirstRunServerInstallDeps(): FirstRunServerInstallDeps {
81
+ const update: UpdateDeps = buildDefaultUpdateDeps();
82
+ return {
83
+ currentServer: update.currentServer,
84
+ publishedPackage: update.publishedPackage,
85
+ publishedVersions: update.publishedVersions,
86
+ installGlobal: update.installGlobal,
87
+ confirm: update.confirm,
88
+ isTTY: update.isTTY,
89
+ stdout: update.stdout,
90
+ stderr: update.stderr,
91
+ clientSharedVersion: readClientSharedVersion,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Offer the exact compatible server before first-run setup mutates state.
97
+ * Returning anything except `present`/`installed` means no caller-owned setup
98
+ * or assimilation work should continue.
99
+ */
100
+ export async function offerFirstRunServerInstall(
101
+ deps: FirstRunServerInstallDeps = buildDefaultFirstRunServerInstallDeps(),
102
+ connectCommand = DEFAULT_CONNECT_COMMAND,
103
+ ): Promise<FirstRunServerInstallResult> {
104
+ let installed: InstalledPackage | null;
105
+ try {
106
+ installed = await deps.currentServer();
107
+ } catch (error) {
108
+ deps.stderr(
109
+ `The local ${SERVER_PACKAGE} installation could not be verified. No installation was attempted.\n` +
110
+ `Run \`borg server status\` to inspect it, or repair the npm-global installation and run this command again.\n` +
111
+ `${error instanceof Error ? error.message : String(error)}\n`,
112
+ );
113
+ return { kind: 'failed' };
114
+ }
115
+ if (installed) return { kind: 'present', server: installed };
116
+
117
+ if (!deps.isTTY()) {
118
+ deps.stderr(
119
+ `No local ${SERVER_PACKAGE} installation was found. No installation was attempted because this terminal is non-interactive.\n` +
120
+ `Run \`borg setup\` in an interactive terminal, or connect to an existing server with ` +
121
+ `\`${connectCommand}\`.\n`,
122
+ );
123
+ return { kind: 'non-interactive' };
124
+ }
125
+
126
+ let target: PublishedPackage;
127
+ try {
128
+ const sharedVersion = deps.clientSharedVersion();
129
+ target = await resolveCompatibleServerTarget(sharedVersion, deps);
130
+ } catch (error) {
131
+ deps.stderr(
132
+ `Borg could not resolve a compatible local server. No installation was attempted.\n` +
133
+ `${error instanceof Error ? error.message : String(error)}\n` +
134
+ `Run \`borg update\`, then run \`borg setup\` again. Or connect to an existing server with ` +
135
+ `\`${connectCommand}\`.\n`,
136
+ );
137
+ return { kind: 'failed' };
138
+ }
139
+
140
+ const command = exactInstallCommand(target.version);
141
+ const decision = await deps.confirm(
142
+ `No local ${SERVER_PACKAGE} installation was found. ` +
143
+ `Install ${SERVER_PACKAGE}@${target.version}, which uses ${SHARED_PACKAGE}@${target.sharedVersion}, now?\n` +
144
+ `Command: ${command}\n` +
145
+ `Continue? [y/N] `,
146
+ );
147
+ if (decision !== 'yes') {
148
+ const reason = decision === 'interrupted'
149
+ ? 'Installation was cancelled by SIGINT.'
150
+ : decision === 'eof'
151
+ ? 'Installation was cancelled because confirmation input ended.'
152
+ : 'Installation was declined.';
153
+ deps.stderr(
154
+ `${reason} No server package or server state was changed.\n` +
155
+ `To install it later, run \`${command}\`. Then run \`borg server setup\` and \`borg server start\`.\n`,
156
+ );
157
+ return { kind: 'declined' };
158
+ }
159
+
160
+ try {
161
+ await deps.installGlobal(SERVER_PACKAGE, target.version, { ignoreScripts: true });
162
+ installed = await deps.currentServer();
163
+ assertInstalledServer(installed, target);
164
+ } catch (error) {
165
+ deps.stderr(
166
+ `The ${SERVER_PACKAGE}@${target.version} installation could not be completed and verified.\n` +
167
+ `${error instanceof Error ? error.message : String(error)}\n` +
168
+ `Run \`${command}\` to retry. Then run \`borg server setup\` and \`borg server start\`.\n`,
169
+ );
170
+ return { kind: 'failed' };
171
+ }
172
+
173
+ deps.stdout(
174
+ `Installed ${SERVER_PACKAGE}@${target.version} with ${SHARED_PACKAGE}@${target.sharedVersion}.\n` +
175
+ `Next, run \`borg server setup\`, then run \`borg server start\` in a terminal you keep open.\n`,
176
+ );
177
+ return { kind: 'installed', server: installed };
178
+ }
package/src/index.ts CHANGED
@@ -90,10 +90,10 @@ import { filterToolsForRole } from './tool-scope.js';
90
90
  import { getPackageVersion, getOnDiskVersion, handleVersionFlag } from './version.js';
91
91
  import {
92
92
  renderStreamStatus,
93
- checkInboxMonitorHealthy,
94
93
  formatWakePathPrefix,
95
94
  shouldShowWakePathWarning,
96
95
  } from './stream-status.js';
96
+ import { inspectWakePath } from './wake-path-health.js';
97
97
  import {
98
98
  RUNTIME_METADATA_ADVISORY,
99
99
  renderRoster,
@@ -128,7 +128,6 @@ import {
128
128
  connectOpenCodeDrone,
129
129
  injectOpenCodeEntry,
130
130
  computeOpenCodePort,
131
- getOpenCodeConnectionState,
132
131
  } from './opencode-drone.js';
133
132
  import { installBorgPlugin } from './opencode-plugin.js';
134
133
  import { setModuleInjectOpenCode } from './log-stream.js';
@@ -401,31 +400,28 @@ export async function main() {
401
400
  await refreshActiveCubeMetadata(freshActive);
402
401
  }
403
402
 
404
- // Wake-path self-heal (gh#43): SSE delivery to the inbox file
405
- // is independent from Claude Code waking on file writes. The
406
- // latter requires a `tail -F` Monitor against the inbox path;
407
- // if that Monitor dies (or was never armed across a session
408
- // boundary), the drone misses every incoming entry until the
409
- // /loop fallback heartbeat. Because regen runs on every /loop
410
- // iteration, surfacing the breakage here gives self-healing at
411
- // worst-case latency = the heartbeat interval. Mirrors the
412
- // State-5 self-arm instruction in stream-status.ts.
403
+ // Wake-path self-heal (gh#43): SSE delivery to the durable inbox
404
+ // is distinct from the runtime-specific mechanism that wakes the
405
+ // agent. Inspect that real mechanism for Claude, Codex, or OpenCode
406
+ // and surface a runtime-specific recovery prefix when it is broken.
413
407
  const streamStatus = getStreamStatus();
414
408
  const inboxPath = inboxPathForDrone(freshActive.cubeId, freshActive.droneId);
415
409
  const monitorStateRoot = monitorStateRootForWorktree(findProjectRoot());
416
- // Non-Claude CLIs do not use the Claude inbox Monitor. Keep the
417
- // agent CLI distinction independent from whether Codex's optional
418
- // remote-wake transport is currently armed.
419
410
  const agentKind = resolveSessionAgentKind();
420
- const inboxMonitorHealthy = agentKind === 'claude'
421
- ? checkInboxMonitorHealthy(inboxPath, monitorStateRoot)
422
- : true;
411
+ const wakePath = await inspectWakePath({
412
+ agentKind,
413
+ active: freshActive,
414
+ inboxPath,
415
+ monitorStateRoot,
416
+ });
417
+ const inboxMonitorHealthy = wakePath.healthy;
423
418
  const prefix = shouldShowWakePathWarning(streamStatus, inboxMonitorHealthy)
424
419
  ? formatWakePathPrefix({
425
420
  inboxPath,
426
421
  monitorStateRoot,
427
422
  droneLabel: regenWakePathDroneLabel(displayedResult, displayIdentity.droneLabel),
428
423
  cubeName: displayIdentity.cubeName,
424
+ wakePath,
429
425
  })
430
426
  : '';
431
427
 
@@ -686,9 +682,9 @@ export async function main() {
686
682
  // Probe the in-process SSE consumer state. Does NOT require
687
683
  // an active cube — if the consumer hasn't started or is
688
684
  // between cubes, the snapshot still reports current values.
689
- // Also probes wake-path completeness (T1.2): is anyone tailing
690
- // the inbox file? Without that, SSE delivery still works but
691
- // the harness `/loop` never wakes on the file write.
685
+ // Also probes runtime-specific wake-path completeness: Claude's
686
+ // inbox Monitor, Codex's remote-control bridge, or OpenCode's
687
+ // durable injection delivery state.
692
688
  const status = getStreamStatus();
693
689
  const active = await getActiveCube();
694
690
  const inboxPath = active
@@ -697,14 +693,13 @@ export async function main() {
697
693
  const monitorStateRoot = active
698
694
  ? monitorStateRootForWorktree(findProjectRoot())
699
695
  : null;
700
- // Non-Claude CLIs have their own wake mechanism, so this Claude-only
701
- // Monitor diagnostic must not be inferred from Codex transport state.
702
- const nonClaudeSession = active && resolveSessionAgentKind() !== 'claude';
703
- const inboxMonitorHealthy = active
704
- ? nonClaudeSession
705
- ? true
706
- : checkInboxMonitorHealthy(inboxPath, monitorStateRoot)
707
- : null;
696
+ const wakePath = await inspectWakePath({
697
+ agentKind: resolveSessionAgentKind(),
698
+ active,
699
+ inboxPath,
700
+ monitorStateRoot,
701
+ });
702
+ const inboxMonitorHealthy = wakePath.healthy;
708
703
  let silentInertWarning = '';
709
704
  if (status.runLoopHealth === 'silent-inert') {
710
705
  silentInertWarning = '## ⚠ SSE stream loop silent-inert — run /mcp and reconnect to restart\n\n' +
@@ -713,6 +708,7 @@ export async function main() {
713
708
  const text = renderStreamStatus({
714
709
  status,
715
710
  inboxMonitorHealthy,
711
+ wakePath,
716
712
  inboxPath,
717
713
  monitorStateRoot,
718
714
  droneLabel: active?.droneLabel ?? null,