borgmcp 4.3.0 → 4.5.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 (73) hide show
  1. package/dist/agent-integration-health.d.ts +2 -0
  2. package/dist/agent-integration-health.d.ts.map +1 -1
  3. package/dist/agent-integration-health.js +18 -1
  4. package/dist/agent-integration-health.js.map +1 -1
  5. package/dist/assimilate-cmd.d.ts +3 -0
  6. package/dist/assimilate-cmd.d.ts.map +1 -1
  7. package/dist/assimilate-cmd.js +12 -0
  8. package/dist/assimilate-cmd.js.map +1 -1
  9. package/dist/assimilate-deps.d.ts.map +1 -1
  10. package/dist/assimilate-deps.js +4 -2
  11. package/dist/assimilate-deps.js.map +1 -1
  12. package/dist/backends/launch-all-terminals.d.ts.map +1 -1
  13. package/dist/backends/launch-all-terminals.js +6 -3
  14. package/dist/backends/launch-all-terminals.js.map +1 -1
  15. package/dist/cli-help.d.ts.map +1 -1
  16. package/dist/cli-help.js +7 -4
  17. package/dist/cli-help.js.map +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +27 -4
  20. package/dist/index.js.map +1 -1
  21. package/dist/opencode-drone.d.ts +19 -0
  22. package/dist/opencode-drone.d.ts.map +1 -1
  23. package/dist/opencode-drone.js +374 -67
  24. package/dist/opencode-drone.js.map +1 -1
  25. package/dist/opencode-seat-identity.d.ts +1 -1
  26. package/dist/opencode-seat-identity.d.ts.map +1 -1
  27. package/dist/opencode-seat-identity.js.map +1 -1
  28. package/dist/private-root.d.ts +2 -0
  29. package/dist/private-root.d.ts.map +1 -1
  30. package/dist/private-root.js +35 -1
  31. package/dist/private-root.js.map +1 -1
  32. package/dist/roster-render.d.ts.map +1 -1
  33. package/dist/roster-render.js +2 -3
  34. package/dist/roster-render.js.map +1 -1
  35. package/dist/seats.d.ts +5 -0
  36. package/dist/seats.d.ts.map +1 -1
  37. package/dist/seats.js +10 -0
  38. package/dist/seats.js.map +1 -1
  39. package/dist/server-errors.d.ts +22 -0
  40. package/dist/server-errors.d.ts.map +1 -1
  41. package/dist/server-errors.js +32 -0
  42. package/dist/server-errors.js.map +1 -1
  43. package/dist/server-handshake.d.ts.map +1 -1
  44. package/dist/server-handshake.js +1 -0
  45. package/dist/server-handshake.js.map +1 -1
  46. package/dist/stream-status.d.ts.map +1 -1
  47. package/dist/stream-status.js +7 -2
  48. package/dist/stream-status.js.map +1 -1
  49. package/dist/terminal-title.d.ts.map +1 -1
  50. package/dist/terminal-title.js +3 -2
  51. package/dist/terminal-title.js.map +1 -1
  52. package/dist/update-cmd.d.ts +2 -1
  53. package/dist/update-cmd.d.ts.map +1 -1
  54. package/dist/update-cmd.js +110 -41
  55. package/dist/update-cmd.js.map +1 -1
  56. package/docs/LOCAL_SERVER.md +21 -10
  57. package/package.json +1 -1
  58. package/src/agent-integration-health.ts +20 -1
  59. package/src/assimilate-cmd.ts +17 -0
  60. package/src/assimilate-deps.ts +4 -1
  61. package/src/backends/launch-all-terminals.ts +6 -3
  62. package/src/cli-help.ts +7 -4
  63. package/src/index.ts +30 -3
  64. package/src/opencode-drone.ts +461 -73
  65. package/src/opencode-seat-identity.ts +1 -0
  66. package/src/private-root.ts +38 -1
  67. package/src/roster-render.ts +2 -3
  68. package/src/seats.ts +15 -0
  69. package/src/server-errors.ts +50 -0
  70. package/src/server-handshake.ts +1 -0
  71. package/src/stream-status.ts +7 -2
  72. package/src/terminal-title.ts +4 -2
  73. package/src/update-cmd.ts +118 -41
@@ -15,6 +15,7 @@ import {
15
15
  installBorgPlugin,
16
16
  openCodePluginPath,
17
17
  } from './opencode-plugin.js';
18
+ import { openCodeStartupDiagnosticLogPath } from './opencode-drone.js';
18
19
 
19
20
  export const AGENT_HOOK_BINS = [
20
21
  'borg-regen',
@@ -292,12 +293,30 @@ export function assertAgentIntegrationHealthy(report: AgentIntegrationHealth): v
292
293
 
293
294
  export function runDoctor(options: InspectAgentIntegrationHealthOptions & {
294
295
  stdout?: (text: string) => void;
296
+ openCodeStartupLogPath?: string;
295
297
  } = {}): number {
296
298
  const report = inspectAgentIntegrationHealth(options);
297
- (options.stdout ?? ((text) => process.stdout.write(text)))(renderAgentIntegrationHealth(report));
299
+ const output = renderAgentIntegrationHealth(report) + renderOpenCodeStartupDiagnostics(
300
+ options.openCodeStartupLogPath ?? openCodeStartupDiagnosticLogPath(),
301
+ );
302
+ (options.stdout ?? ((text) => process.stdout.write(text)))(output);
298
303
  return report.issues.length === 0 ? 0 : 1;
299
304
  }
300
305
 
306
+ export function renderOpenCodeStartupDiagnostics(logPath: string): string {
307
+ try {
308
+ const metadata = fs.lstatSync(logPath);
309
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
310
+ return `OpenCode startup diagnostics: refused at ${logPath} (path is not a regular file)\n`;
311
+ }
312
+ const contents = fs.readFileSync(logPath, 'utf8');
313
+ return `OpenCode startup diagnostics (${logPath}):\n${contents.trimEnd()}\n`;
314
+ } catch (error) {
315
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return '';
316
+ return `OpenCode startup diagnostics: unreadable at ${logPath} (${error instanceof Error ? error.message : String(error)})\n`;
317
+ }
318
+ }
319
+
301
320
  export function warnIfAgentIntegrationUnhealthy(
302
321
  options: InspectAgentIntegrationHealthOptions & { stderr?: (text: string) => void } = {},
303
322
  ): boolean {
@@ -299,10 +299,13 @@ export interface AssimilateDeps {
299
299
  * production; absent from unit stubs that fully mock `assimilate`. */
300
300
  finalizeServerSeat?: (input: {
301
301
  active: ActiveCube;
302
+ commonDir: string;
302
303
  expected: ExpectedBinding;
303
304
  activate: (binding: SeatBinding) => Promise<unknown>;
304
305
  scrubPending: () => Promise<unknown>;
305
306
  }) => Promise<FinalizeServerSeatOutcome>;
307
+ /** Advisory clone-family comparison for public repositories. */
308
+ hasActiveSeatInDifferentCloneFamily?: (cubeId: string, commonDir: string) => Promise<boolean>;
306
309
  findProjectRoot: (cwd: string) => string;
307
310
  resolveRepositoryContext: (cwd: string) => Promise<GitRepositoryContext | null>;
308
311
  getRepositoryIdentity: (context: GitRepositoryContext) => Promise<CreateCubeRepository>;
@@ -1938,6 +1941,7 @@ export async function runAssimilate(
1938
1941
  try {
1939
1942
  outcome = await deps.finalizeServerSeat({
1940
1943
  active: activeCube,
1944
+ commonDir: repositoryContext.commonDir,
1941
1945
  expected: sessionExpected,
1942
1946
  activate: result.finalize.activate,
1943
1947
  scrubPending: result.finalize.scrubPending,
@@ -2030,6 +2034,19 @@ export async function runAssimilate(
2030
2034
  }
2031
2035
  }
2032
2036
 
2037
+ if (repositoryContext.publicRepository && deps.hasActiveSeatInDifferentCloneFamily) {
2038
+ try {
2039
+ if (await deps.hasActiveSeatInDifferentCloneFamily(result.cube_id, repositoryContext.commonDir)) {
2040
+ deps.stderr(
2041
+ 'warning: this cube already has a seat from a different clone family. ' +
2042
+ 'All seats for a repository use worktrees from the same clone family, sharing its object database and refs.\n',
2043
+ );
2044
+ }
2045
+ } catch {
2046
+ // This comparison is advisory and must never block assimilation.
2047
+ }
2048
+ }
2049
+
2033
2050
  // gh#793: best-effort GC of orphaned inbox files (evicted/dead drones) in the
2034
2051
  // cube just joined — lazy-on-assimilate, no cron/new command. NEVER blocks or
2035
2052
  // fails the assimilate (whole call swallowed). Local-only signal (CubeDetail
@@ -43,6 +43,7 @@ import {
43
43
  import { advanceLocalServerCursor } from './local-server-cursor.js';
44
44
  import {
45
45
  findIncompleteSiblingAttempt,
46
+ hasActiveSeatInDifferentCloneFamily,
46
47
  observeSeat,
47
48
  prepareSeat,
48
49
  seatRef,
@@ -214,9 +215,10 @@ export function buildDefaultAssimilateDeps(
214
215
  // missing/replaced/throw → the pending record is the rerunnable locator; the
215
216
  // caller PRESERVES the worktree). expectation-mismatch is produced upstream at
216
217
  // PREPARE (result.prepareAborted), never here.
217
- finalizeServerSeat: async ({ active, activate }) => {
218
+ finalizeServerSeat: async ({ active, commonDir, activate }) => {
218
219
  const binding = {
219
220
  worktree: cubesFindProjectRoot(process.cwd()),
221
+ commonDir,
220
222
  name: active.name,
221
223
  droneLabel: active.droneLabel,
222
224
  ...(active.roleName !== undefined ? { roleName: active.roleName } : {}),
@@ -233,6 +235,7 @@ export function buildDefaultAssimilateDeps(
233
235
  ? { committed: true }
234
236
  : { committed: false, reason: 'activation-failed' };
235
237
  },
238
+ hasActiveSeatInDifferentCloneFamily,
236
239
  findProjectRoot: (cwd) => cubesFindProjectRoot(cwd),
237
240
  resolveRepositoryContext: resolveGitRepositoryContext,
238
241
  getRepositoryIdentity: getOrCreateRepositoryIdentity,
@@ -96,14 +96,17 @@ async function launchLinux(candidates: DroneCandidate[], opts: TerminalsOpts, de
96
96
  }
97
97
 
98
98
  /**
99
- * Control char (incl. newline) in a worktree path. SR f94bb3fe + Coordinator
99
+ * Control char (incl. newline) in a worktree path. A path cannot be escaped
100
+ * without changing the launch target, so it remains a fail-loud rejection.
101
+ * Server-derived title fields are escaped separately by composeTerminalTitle.
102
+ * SR f94bb3fe + Coordinator
100
103
  * c6370c41 fold-in: a newline in the path breaks the macOS osascript AppleScript
101
104
  * string literal (parse error). Reject such paths up front (the user's own path;
102
105
  * pathological) — fail-loud + skip, never silently mangle. Applies to both
103
106
  * platforms defensively (the tmux primary handles control chars fine via
104
107
  * shellEscape + array-args, so this guard is terminals-backend-specific).
105
108
  */
106
- const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
109
+ const WORKTREE_CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
107
110
 
108
111
  export async function runTerminalsBackend(
109
112
  candidates: DroneCandidate[],
@@ -111,7 +114,7 @@ export async function runTerminalsBackend(
111
114
  deps: LaunchAllDeps
112
115
  ): Promise<void> {
113
116
  const safe = candidates.filter((c) => {
114
- if (CONTROL_CHAR_RE.test(c.worktreeDir)) {
117
+ if (WORKTREE_CONTROL_CHAR_RE.test(c.worktreeDir)) {
115
118
  deps.stderr(
116
119
  `skipping ${c.droneLabel} (${JSON.stringify(c.worktreeDir)}): worktree path contains a control ` +
117
120
  `character — unsafe for --mode terminals; use --mode tmux instead.\n`
package/src/cli-help.ts CHANGED
@@ -117,7 +117,7 @@ export function doctorHelpText(version: string): string {
117
117
  return (
118
118
  `borg doctor (borgmcp ${version}) — inspect Borg agent integrations without changing them\n\n` +
119
119
  `Usage:\n` +
120
- ` borg doctor Run read-only checks for hook commands, hook configuration, and the OpenCode plugin\n` +
120
+ ` borg doctor Run read-only checks for hook commands, OpenCode integration, and startup diagnostics\n` +
121
121
  ` borg doctor --help Show this help\n`
122
122
  );
123
123
  }
@@ -172,7 +172,7 @@ export function topLevelHelpText(version: string): string {
172
172
  ` borg Show the launch menu in a repository root; resume directly in a linked worktree\n` +
173
173
  ` borg setup Set up borg MCP server + agent CLI integration\n` +
174
174
  ` borg update Update the client and installed local server together\n` +
175
- ` borg doctor Check agent hook commands, versions, configs, and the OpenCode plugin\n` +
175
+ ` borg doctor Check agent hook commands, OpenCode integration, and startup diagnostics\n` +
176
176
  ` borg clone <url> [dir] Clone a repository, then create and launch its cube\n` +
177
177
  ` borg quickstart Create a cube and a drone for every role, then launch them\n` +
178
178
  ` borg assimilate [role] Join or create a cube with one drone under one role\n` +
@@ -210,12 +210,15 @@ export function updateHelpText(version: string): string {
210
210
  `Usage:\n` +
211
211
  ` borg update Confirm interactively, then update\n` +
212
212
  ` borg update --yes Update without prompting (required outside a TTY)\n` +
213
+ ` borg update --registry <url>\n` +
214
+ ` Acknowledge this exact configured npm registry for one update\n` +
213
215
  ` borg update --help Show this help\n\n` +
214
216
  `Before changing anything, Borg reads the exact published client and server manifests,\n` +
215
217
  `requires matching exact borgmcp-shared pins, and verifies canonical npm ownership. The client is\n` +
216
218
  `installed first and the update continues under that new client before the server controller\n` +
217
- `and runtime are updated. Alternate registries and unsupported or ambiguous package-manager\n` +
218
- `provenance fail closed with manual-update guidance.\n\n` +
219
+ `and runtime are updated. An alternate configured registry requires an exact per-invocation\n` +
220
+ `--registry acknowledgement; registry changes refuse before any subsequent package mutation,\n` +
221
+ `and unsupported or ambiguous package-manager provenance fails closed.\n\n` +
219
222
  `If no local server is installed, the server phase is skipped. A failure after the client\n` +
220
223
  `succeeds is reported as partial completion with status-specific recovery. Borg never starts\n` +
221
224
  `a stopped server. After package verification, Borg replaces stale borgmcp package launch paths\n` +
package/src/index.ts CHANGED
@@ -150,6 +150,7 @@ import {
150
150
  configuredOpenCodePort,
151
151
  OPEN_CODE_PORT_MISSING_DIAGNOSTIC,
152
152
  openCodeLaunchBinding,
153
+ writeOpenCodeStartupDiagnostic,
153
154
  } from './opencode-drone.js';
154
155
  import { installBorgPlugin } from './opencode-plugin.js';
155
156
  import { openCodeApiPasswordFromEnv } from './opencode-launch-trust.js';
@@ -172,6 +173,9 @@ import {
172
173
  runEvictDroneTool,
173
174
  runReassignDroneTool,
174
175
  } from './drone-management.js';
176
+
177
+ const OPEN_CODE_IDENTITY_HANDSHAKE_TIMEOUT_MS = 5_000;
178
+
175
179
  /**
176
180
  * Apply a template's roles + message_taxonomy to a cube.
177
181
  *
@@ -392,8 +396,25 @@ export async function main() {
392
396
 
393
397
  let openCodeIdentityFailure: OpenCodeSeatIdentityError | null = null;
394
398
  let finishOpenCodeIdentity: (() => void) | null = null;
399
+ let openCodeIdentityTimeout: ReturnType<typeof setTimeout> | null = null;
400
+ const settleOpenCodeIdentity = () => {
401
+ if (openCodeIdentityTimeout) clearTimeout(openCodeIdentityTimeout);
402
+ openCodeIdentityTimeout = null;
403
+ const finish = finishOpenCodeIdentity;
404
+ finishOpenCodeIdentity = null;
405
+ finish?.();
406
+ };
395
407
  const openCodeIdentityReady = openCodeRuntime && !readinessProbe
396
- ? new Promise<void>((resolveIdentity) => { finishOpenCodeIdentity = resolveIdentity; })
408
+ ? new Promise<void>((resolveIdentity) => {
409
+ finishOpenCodeIdentity = resolveIdentity;
410
+ openCodeIdentityTimeout = setTimeout(() => {
411
+ openCodeIdentityFailure = new OpenCodeSeatIdentityError(
412
+ 'IDENTITY_HANDSHAKE_TIMEOUT',
413
+ 'OpenCode identity handshake did not complete within 5 seconds.',
414
+ );
415
+ settleOpenCodeIdentity();
416
+ }, OPEN_CODE_IDENTITY_HANDSHAKE_TIMEOUT_MS);
417
+ })
397
418
  : null;
398
419
  const waitForOpenCodeIdentity = async (): Promise<OpenCodeSeatIdentityError | null> => {
399
420
  if (openCodeIdentityReady) await openCodeIdentityReady;
@@ -1663,7 +1684,7 @@ export async function main() {
1663
1684
  ? error
1664
1685
  : new OpenCodeSeatIdentityError('ROOTS_UNAVAILABLE', String(error));
1665
1686
  }).finally(() => {
1666
- finishOpenCodeIdentity?.();
1687
+ settleOpenCodeIdentity();
1667
1688
  });
1668
1689
  };
1669
1690
  }
@@ -1674,7 +1695,13 @@ export async function main() {
1674
1695
  if (openCodeIdentityReady) {
1675
1696
  await openCodeIdentityReady;
1676
1697
  if (openCodeIdentityFailure) {
1677
- console.error(formatOpenCodeSeatIdentityError(openCodeIdentityFailure, process.cwd()));
1698
+ const diagnostic = formatOpenCodeSeatIdentityError(openCodeIdentityFailure, process.cwd());
1699
+ try {
1700
+ writeOpenCodeStartupDiagnostic(diagnostic);
1701
+ } catch {
1702
+ // Diagnostic persistence is best-effort; the original identity failure must terminate startup.
1703
+ }
1704
+ throw new Error(diagnostic);
1678
1705
  } else {
1679
1706
  await runMcpStartupServices(false, startupServices, { openCodeFirst: true });
1680
1707
  }