borgmcp 2.5.0 → 2.6.1

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 (87) 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 +2 -2
  16. package/dist/codex-launch.d.ts.map +1 -1
  17. package/dist/codex-launch.js +7 -7
  18. package/dist/codex-launch.js.map +1 -1
  19. package/dist/first-run-server.d.ts +31 -0
  20. package/dist/first-run-server.d.ts.map +1 -0
  21. package/dist/first-run-server.js +117 -0
  22. package/dist/first-run-server.js.map +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +25 -27
  25. package/dist/index.js.map +1 -1
  26. package/dist/log-stream.d.ts +4 -8
  27. package/dist/log-stream.d.ts.map +1 -1
  28. package/dist/log-stream.js +22 -15
  29. package/dist/log-stream.js.map +1 -1
  30. package/dist/opencode-drone.d.ts +11 -5
  31. package/dist/opencode-drone.d.ts.map +1 -1
  32. package/dist/opencode-drone.js +236 -48
  33. package/dist/opencode-drone.js.map +1 -1
  34. package/dist/opencode-wake-copy.d.ts +2 -0
  35. package/dist/opencode-wake-copy.d.ts.map +1 -0
  36. package/dist/opencode-wake-copy.js +8 -0
  37. package/dist/opencode-wake-copy.js.map +1 -0
  38. package/dist/regen-format.d.ts +4 -6
  39. package/dist/regen-format.d.ts.map +1 -1
  40. package/dist/regen-format.js +10 -14
  41. package/dist/regen-format.js.map +1 -1
  42. package/dist/repository-cube-init.d.ts +2 -0
  43. package/dist/repository-cube-init.d.ts.map +1 -1
  44. package/dist/repository-cube-init.js +14 -3
  45. package/dist/repository-cube-init.js.map +1 -1
  46. package/dist/server-facade.d.ts +2 -0
  47. package/dist/server-facade.d.ts.map +1 -1
  48. package/dist/server-facade.js +25 -3
  49. package/dist/server-facade.js.map +1 -1
  50. package/dist/setup.js +10 -0
  51. package/dist/setup.js.map +1 -1
  52. package/dist/stream-status.d.ts +9 -8
  53. package/dist/stream-status.d.ts.map +1 -1
  54. package/dist/stream-status.js +68 -18
  55. package/dist/stream-status.js.map +1 -1
  56. package/dist/tool-manifest.js +2 -2
  57. package/dist/tool-manifest.js.map +1 -1
  58. package/dist/update-cmd.d.ts +11 -1
  59. package/dist/update-cmd.d.ts.map +1 -1
  60. package/dist/update-cmd.js +68 -4
  61. package/dist/update-cmd.js.map +1 -1
  62. package/dist/wake-path-health.d.ts +27 -0
  63. package/dist/wake-path-health.d.ts.map +1 -0
  64. package/dist/wake-path-health.js +51 -0
  65. package/dist/wake-path-health.js.map +1 -0
  66. package/docs/EXTRACTION_PROVENANCE.md +3 -3
  67. package/docs/LOCAL_SERVER.md +12 -3
  68. package/docs/RELEASING.md +11 -1
  69. package/package.json +1 -1
  70. package/src/assimilate-cmd.ts +66 -27
  71. package/src/assimilate-deps.ts +9 -0
  72. package/src/claude.ts +10 -3
  73. package/src/cli-help.ts +7 -4
  74. package/src/codex-launch.ts +7 -7
  75. package/src/first-run-server.ts +178 -0
  76. package/src/index.ts +24 -28
  77. package/src/log-stream.ts +36 -25
  78. package/src/opencode-drone.ts +283 -47
  79. package/src/opencode-wake-copy.ts +8 -0
  80. package/src/regen-format.ts +10 -16
  81. package/src/repository-cube-init.ts +16 -3
  82. package/src/server-facade.ts +34 -3
  83. package/src/setup.ts +11 -0
  84. package/src/stream-status.ts +86 -22
  85. package/src/tool-manifest.ts +2 -2
  86. package/src/update-cmd.ts +88 -5
  87. 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
@@ -7,8 +8,8 @@ import { wakePathArming, type AgentKind } from './regen-format.js';
7
8
  * not three), plus a one-line NEVER-TaskStop safety reminder preserved from
8
9
  * the pre-gh#929 monitorClause. Built by both launch call sites
9
10
  * (claude.ts + assimilate-cmd.ts) and passed to `buildAgentKickoffPrompt` as
10
- * `monitorClause`. Codex wakes via the app-server (no tail-Monitor / `/loop`
11
- * to arm) → empty; no active cube (no inboxPath) → empty.
11
+ * `monitorClause`. Codex wakes via the app-server (no tail-Monitor to arm) →
12
+ * empty; no active cube (no inboxPath) → empty.
12
13
  */
13
14
  export function buildKickoffWakePathClause(
14
15
  agentKind: AgentKind,
@@ -19,8 +20,8 @@ export function buildKickoffWakePathClause(
19
20
  return (
20
21
  wakePathArming('claude', inboxPath, monitorStateRoot) +
21
22
  '\nKeep this Monitor armed for the whole session — NEVER TaskStop it (the generic ' +
22
- '/loop "TaskStop any Monitor you armed" step does NOT apply to the cube inbox Monitor; ' +
23
- 'it targets throwaway loop-scratch watches only). The sole exception is a confirmed ' +
23
+ '"TaskStop any Monitor you armed" guidance does NOT apply to the cube inbox Monitor; ' +
24
+ 'it targets throwaway scratch watches only). The sole exception is a confirmed ' +
24
25
  'terminal eviction (410 DRONE_EVICTED). '
25
26
  );
26
27
  }
@@ -60,15 +61,14 @@ 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 loopOrEmpty = options.cli === 'claude' ? '/loop ' : '';
64
+ const opencodeWakePathClause = OPENCODE_WAKE_PATH_GUIDANCE;
65
65
  const wakeClause = options.cli === 'claude'
66
66
  ? options.monitorClause
67
67
  : options.cli === 'codex'
68
68
  ? codexWakePathClause
69
69
  : opencodeWakePathClause;
70
70
  return (
71
- `${loopOrEmpty}Call borg_regen and follow the playbook in its response. ` +
71
+ `Call borg_regen and follow the playbook in its response. ` +
72
72
  codexNonceClause +
73
73
  `Note: at session start the borg MCP server is still spinning up in ` +
74
74
  `parallel — if a system reminder claims "MCP server disconnected" or ` +
@@ -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
+ }