borgmcp 2.7.3 → 2.9.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 (78) hide show
  1. package/README.md +96 -230
  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 +18 -4
  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 +3 -0
  8. package/dist/assimilate-deps.js.map +1 -1
  9. package/dist/cli-help.d.ts.map +1 -1
  10. package/dist/cli-help.js +2 -3
  11. package/dist/cli-help.js.map +1 -1
  12. package/dist/config.d.ts +1 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +3 -0
  15. package/dist/config.js.map +1 -1
  16. package/dist/drone-lifecycle.d.ts +8 -0
  17. package/dist/drone-lifecycle.d.ts.map +1 -1
  18. package/dist/drone-lifecycle.js +19 -0
  19. package/dist/drone-lifecycle.js.map +1 -1
  20. package/dist/first-run-server.d.ts +10 -2
  21. package/dist/first-run-server.d.ts.map +1 -1
  22. package/dist/first-run-server.js +54 -7
  23. package/dist/first-run-server.js.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +14 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/log-stream.d.ts +4 -0
  28. package/dist/log-stream.d.ts.map +1 -1
  29. package/dist/log-stream.js +28 -4
  30. package/dist/log-stream.js.map +1 -1
  31. package/dist/remote-client.d.ts +2 -2
  32. package/dist/remote-client.d.ts.map +1 -1
  33. package/dist/remote-client.js +43 -8
  34. package/dist/remote-client.js.map +1 -1
  35. package/dist/runtime-metadata.d.ts +1 -1
  36. package/dist/runtime-metadata.d.ts.map +1 -1
  37. package/dist/runtime-metadata.js +3 -3
  38. package/dist/runtime-metadata.js.map +1 -1
  39. package/dist/server-errors.d.ts +9 -1
  40. package/dist/server-errors.d.ts.map +1 -1
  41. package/dist/server-errors.js +19 -0
  42. package/dist/server-errors.js.map +1 -1
  43. package/dist/server-handshake.d.ts +5 -1
  44. package/dist/server-handshake.d.ts.map +1 -1
  45. package/dist/server-handshake.js +18 -0
  46. package/dist/server-handshake.js.map +1 -1
  47. package/dist/server-trust.d.ts.map +1 -1
  48. package/dist/server-trust.js +10 -0
  49. package/dist/server-trust.js.map +1 -1
  50. package/dist/setup.js +5 -2
  51. package/dist/setup.js.map +1 -1
  52. package/dist/tool-manifest.d.ts.map +1 -1
  53. package/dist/tool-manifest.js +8 -5
  54. package/dist/tool-manifest.js.map +1 -1
  55. package/dist/update-cmd.d.ts +1 -0
  56. package/dist/update-cmd.d.ts.map +1 -1
  57. package/dist/update-cmd.js +130 -19
  58. package/dist/update-cmd.js.map +1 -1
  59. package/docs/EXTRACTION_PROVENANCE.md +7 -7
  60. package/docs/LOCAL_SERVER.md +3 -3
  61. package/docs/RELEASING.md +16 -6
  62. package/package.json +2 -2
  63. package/src/assimilate-cmd.ts +25 -6
  64. package/src/assimilate-deps.ts +3 -0
  65. package/src/cli-help.ts +2 -3
  66. package/src/config.ts +4 -0
  67. package/src/drone-lifecycle.ts +25 -0
  68. package/src/first-run-server.ts +71 -8
  69. package/src/index.ts +15 -1
  70. package/src/log-stream.ts +26 -5
  71. package/src/remote-client.ts +58 -7
  72. package/src/runtime-metadata.ts +4 -3
  73. package/src/server-errors.ts +25 -0
  74. package/src/server-handshake.ts +30 -1
  75. package/src/server-trust.ts +11 -0
  76. package/src/setup.ts +5 -2
  77. package/src/tool-manifest.ts +8 -5
  78. package/src/update-cmd.ts +153 -19
@@ -277,7 +277,7 @@ export interface AssimilateDeps {
277
277
  detectLocalServer: () => Promise<string | null>;
278
278
  connectServer: (
279
279
  apiUrl: string,
280
- enrollment?: { invitation: string },
280
+ enrollment?: { invitation: string; confirmReplacement?: () => Promise<boolean> },
281
281
  ) => Promise<{
282
282
  token: string;
283
283
  trustIdentity: string;
@@ -361,6 +361,11 @@ function affirmative(answer: string): boolean {
361
361
  return normalized === '' || normalized === 'y' || normalized === 'yes';
362
362
  }
363
363
 
364
+ function strictAffirmative(answer: string): boolean {
365
+ const normalized = answer.trim().toLowerCase();
366
+ return normalized === 'y' || normalized === 'yes';
367
+ }
368
+
364
369
  async function selectAssimilationAuthority(
365
370
  flags: AssimilateFlags,
366
371
  deps: AssimilateDeps,
@@ -475,6 +480,14 @@ function reportServerFailure(
475
480
  );
476
481
  return 1;
477
482
  }
483
+ if (error instanceof BorgServerError && error.code === 'LOCAL_CREDENTIAL_EXISTS') {
484
+ deps.stderr(
485
+ `A local enrollment for ${apiUrl} already exists and was not replaced. ` +
486
+ `Re-run ${localAssimilateCommand(apiUrl, true, mode)} and explicitly confirm replacement ` +
487
+ 'only if the first enrolled client should be abandoned.\n',
488
+ );
489
+ return 1;
490
+ }
478
491
  if (error instanceof LegacySessionCredentialCollisionError) {
479
492
  const recovery = mode === 'cube-init'
480
493
  ? localAssimilateCommand(error.origin, true, mode)
@@ -729,8 +742,14 @@ export async function runAssimilate(
729
742
  );
730
743
  return 1;
731
744
  }
732
- try {
733
- serverAuth = await deps.connectServer(authority.apiUrl, { invitation });
745
+ try {
746
+ serverAuth = await deps.connectServer(authority.apiUrl, {
747
+ invitation,
748
+ confirmReplacement: async () => strictAffirmative(await deps.prompt(
749
+ `A local enrollment for ${authority.apiUrl} already exists. Replacing it will orphan ` +
750
+ 'the first enrolled client. Replace it? [y/N]: ',
751
+ )),
752
+ });
734
753
  } finally {
735
754
  // Strings cannot be zeroized in JavaScript, but drop this command's
736
755
  // reference immediately after the exchange instead of retaining the
@@ -1492,7 +1511,7 @@ export async function runAssimilate(
1492
1511
  deps.stderr(
1493
1512
  'Borg could not enumerate this repository’s existing worktrees, so it did not risk creating a colliding sibling.\n' +
1494
1513
  'Run `git worktree list` from this repository and resolve the reported Git error, then rerun `borg assimilate`.\n' +
1495
- 'A seat was reserved and remains pending; rerunning after fixing the worktree issue resumes that seat.\n',
1514
+ 'A local drone reservation was created and remains pending; rerunning after fixing the worktree issue resumes that reservation.\n',
1496
1515
  );
1497
1516
  return 1;
1498
1517
  }
@@ -1565,13 +1584,13 @@ export async function runAssimilate(
1565
1584
  ? `Git left branch ${residualBranch} without a registered worktree; Borg preserved it.\n`
1566
1585
  : '') +
1567
1586
  'Run `git worktree list` and `git status` to inspect repository state, resolve the reported Git error, then rerun `borg assimilate`.\n' +
1568
- 'A seat was reserved and remains pending; rerunning after fixing the worktree issue resumes that seat.\n',
1587
+ 'A local drone reservation was created and remains pending; rerunning after fixing the worktree issue resumes that reservation.\n',
1569
1588
  );
1570
1589
  return 1;
1571
1590
  }
1572
1591
  deps.stderr(
1573
1592
  `spawned sibling worktree at ${candidate} on branch ${wtBranch} (${startRef}); ` +
1574
- `the original dir keeps its active seat — run \`borg reset-local-seat\` there if that binding is stale.\n`
1593
+ `the original dir keeps its active drone binding — run \`borg reset-local-seat\` there if that binding is stale.\n`
1575
1594
  );
1576
1595
  deps.chdir(candidate);
1577
1596
  deps.stderr(renderWorktreeSteeringNote(candidate, wtBranch, projectRoot));
@@ -241,6 +241,9 @@ export function buildDefaultAssimilateDeps(
241
241
  connectServer: async (apiUrl, enrollment) => {
242
242
  if (enrollment) {
243
243
  return enrollLocalBorgServer(apiUrl, enrollment.invitation, {
244
+ ...(enrollment.confirmReplacement === undefined
245
+ ? {}
246
+ : { confirmReplacement: enrollment.confirmReplacement }),
244
247
  clientName: osHostname(),
245
248
  });
246
249
  }
package/src/cli-help.ts CHANGED
@@ -74,9 +74,8 @@ export function clientSubcommandHelpText(
74
74
  export function setupNextStepsText(): string {
75
75
  return (
76
76
  `◼ Next steps:\n` +
77
- `1. Run \`borg server setup\` to prepare the local server.\n` +
78
- `2. Run \`borg server start\` and leave that terminal open.\n` +
79
- `3. In a second terminal, cd into your project and run \`borg assimilate\`.\n`
77
+ `1. Run \`borg server start\` and leave that terminal open.\n` +
78
+ `2. In a second terminal, cd into your project's Git repository and run \`borg assimilate\`.\n`
80
79
  );
81
80
  }
82
81
 
package/src/config.ts CHANGED
@@ -447,6 +447,7 @@ export async function activatePendingServerEnrollment(
447
447
  credential: string;
448
448
  clientId: string;
449
449
  serverCapabilities: ServerCapability[];
450
+ allowReplacement?: boolean;
450
451
  },
451
452
  ): Promise<void> {
452
453
  validateServerCredentialBinding(input.origin, input.trustIdentity);
@@ -471,6 +472,9 @@ export async function activatePendingServerEnrollment(
471
472
  } catch {
472
473
  throw new Error('pending Borg server enrollment does not match the verified response');
473
474
  }
475
+ if (!input.allowReplacement && await backend.get(serverCredentialAccount(input.origin, input.trustIdentity))) {
476
+ throw new Error('local Borg server enrollment already exists');
477
+ }
474
478
  // Already inside the single store lock — use the UNLOCKED write body so we do
475
479
  // not re-acquire (and self-deadlock on) CREDENTIALS_LOCK (CR3b).
476
480
  await writeServerCredentialRecord(backend, {
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  export const DRONE_EVICTED_CODE = 'DRONE_EVICTED';
15
+ export const CUBE_DELETED_CODE = 'CUBE_DELETED';
15
16
 
16
17
  export class DroneEvictedError extends Error {
17
18
  constructor(
@@ -22,11 +23,19 @@ export class DroneEvictedError extends Error {
22
23
  }
23
24
  }
24
25
 
26
+ export class CubeDeletedError extends Error {
27
+ constructor(public readonly cubeName?: string) {
28
+ super('This cube was deleted.');
29
+ this.name = 'CubeDeletedError';
30
+ }
31
+ }
32
+
25
33
  /**
26
34
  * Marker the agent's /loop + role playbook branch on. Single-sourced so the
27
35
  * SSE wake sentinel (log-stream) and the tool-result funnel (index) agree.
28
36
  */
29
37
  export const EVICTED_RESULT_MARKER = '[CUBE-EVICTED]';
38
+ export const CUBE_DELETED_RESULT_MARKER = '[CUBE-DELETED]';
30
39
 
31
40
  /**
32
41
  * The recognizable tool RESULT the agent sees when an authed call returns the
@@ -43,6 +52,22 @@ export function formatEvictedToolResult(cubeName?: string): string {
43
52
  );
44
53
  }
45
54
 
55
+ export function formatCubeDeletedToolResult(cubeName?: string): string {
56
+ const cube = cubeName ?? 'the selected cube';
57
+ return (
58
+ `${CUBE_DELETED_RESULT_MARKER} Cube ${cube} was deleted.\n\n` +
59
+ 'Borg has stopped listening for activity for this cube. Do not retry this request or restart the loop.\n\n' +
60
+ 'Your worktree and project files are unchanged. Finish any local file safety checks, then end this agent session.'
61
+ );
62
+ }
63
+
64
+ export function formatCubeDeletedErrorToolResult(
65
+ error: CubeDeletedError,
66
+ activeCubeName?: string,
67
+ ): string {
68
+ return formatCubeDeletedToolResult(error.cubeName ?? activeCubeName);
69
+ }
70
+
46
71
  /**
47
72
  * Extract the structured error code from a worker error body. The worker error
48
73
  * funnel (sanitizeError → createHttpError) emits `{ code, message }`; some
@@ -1,3 +1,4 @@
1
+ import { spawn } from 'node:child_process';
1
2
  import { readFileSync } from 'node:fs';
2
3
  import { dirname, join } from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
@@ -14,10 +15,12 @@ const CLIENT_PACKAGE = 'borgmcp';
14
15
  const SERVER_PACKAGE = 'borgmcp-server';
15
16
  const SHARED_PACKAGE = 'borgmcp-shared';
16
17
  const DEFAULT_CONNECT_COMMAND = 'borg assimilate --host <host>';
18
+ const CLIENT_ONBOARDING_ENV = 'BORG_CLIENT_ONBOARDING';
19
+ export const HINT_SUPPORTED_FROM = '0.8.1';
17
20
 
18
21
  export type FirstRunServerInstallResult =
19
- | { kind: 'present'; server: InstalledPackage }
20
- | { kind: 'installed'; server: InstalledPackage }
22
+ | { kind: 'present'; server: InstalledPackage; suppressClientNextSteps?: boolean }
23
+ | { kind: 'installed'; server: InstalledPackage; suppressClientNextSteps?: boolean }
21
24
  | { kind: 'declined' | 'non-interactive' | 'failed' };
22
25
 
23
26
  export interface FirstRunServerInstallDeps {
@@ -32,7 +35,8 @@ export interface FirstRunServerInstallDeps {
32
35
  version: string,
33
36
  options?: { ignoreScripts?: boolean },
34
37
  ): Promise<void>;
35
- confirm(message: string): Promise<'yes' | 'no' | 'eof' | 'interrupted'>;
38
+ runServerSetup(server: InstalledPackage, options?: { onboardingHint?: boolean }): Promise<number>;
39
+ confirm(message: string, defaultYes?: boolean): Promise<'yes' | 'no' | 'eof' | 'interrupted'>;
36
40
  isTTY(): boolean;
37
41
  stdout(text: string): void;
38
42
  stderr(text: string): void;
@@ -84,6 +88,18 @@ export function buildDefaultFirstRunServerInstallDeps(): FirstRunServerInstallDe
84
88
  publishedPackage: update.publishedPackage,
85
89
  publishedVersions: update.publishedVersions,
86
90
  installGlobal: update.installGlobal,
91
+ runServerSetup: (server, options = {}) => new Promise((resolve) => {
92
+ const childEnv = { ...process.env };
93
+ delete childEnv[CLIENT_ONBOARDING_ENV];
94
+ if (options.onboardingHint) childEnv[CLIENT_ONBOARDING_ENV] = '1';
95
+ const child = spawn(server.binPath, ['setup'], {
96
+ stdio: 'inherit',
97
+ shell: false,
98
+ env: childEnv,
99
+ });
100
+ child.once('error', () => resolve(1));
101
+ child.once('exit', (code) => resolve(code ?? 1));
102
+ }),
87
103
  confirm: update.confirm,
88
104
  isTTY: update.isTTY,
89
105
  stdout: update.stdout,
@@ -100,6 +116,7 @@ export function buildDefaultFirstRunServerInstallDeps(): FirstRunServerInstallDe
100
116
  export async function offerFirstRunServerInstall(
101
117
  deps: FirstRunServerInstallDeps = buildDefaultFirstRunServerInstallDeps(),
102
118
  connectCommand = DEFAULT_CONNECT_COMMAND,
119
+ options: { initializeServer?: boolean } = {},
103
120
  ): Promise<FirstRunServerInstallResult> {
104
121
  let installed: InstalledPackage | null;
105
122
  try {
@@ -112,7 +129,15 @@ export async function offerFirstRunServerInstall(
112
129
  );
113
130
  return { kind: 'failed' };
114
131
  }
115
- if (installed) return { kind: 'present', server: installed };
132
+ if (installed) {
133
+ return initializeServerIfRequested(
134
+ installed,
135
+ deps,
136
+ options.initializeServer,
137
+ 'present',
138
+ shouldSuppressClientNextSteps(installed.version),
139
+ );
140
+ }
116
141
 
117
142
  if (!deps.isTTY()) {
118
143
  deps.stderr(
@@ -140,9 +165,11 @@ export async function offerFirstRunServerInstall(
140
165
  const command = exactInstallCommand(target.version);
141
166
  const decision = await deps.confirm(
142
167
  `No local ${SERVER_PACKAGE} installation was found. ` +
143
- `Install ${SERVER_PACKAGE}@${target.version}, which uses ${SHARED_PACKAGE}@${target.sharedVersion}, now?\n` +
168
+ `Install ${SERVER_PACKAGE}@${target.version}, the local coordination server, ` +
169
+ `which uses ${SHARED_PACKAGE}@${target.sharedVersion}, now?\n` +
144
170
  `Command: ${command}\n` +
145
- `Continue? [y/N] `,
171
+ `Install it now? [Y/n] `,
172
+ true,
146
173
  );
147
174
  if (decision !== 'yes') {
148
175
  const reason = decision === 'interrupted'
@@ -172,7 +199,43 @@ export async function offerFirstRunServerInstall(
172
199
 
173
200
  deps.stdout(
174
201
  `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`,
202
+ (options.initializeServer
203
+ ? `The local server package is ready.\n`
204
+ : `Next, run \`borg server setup\`, then run \`borg server start\` in a terminal you keep open.\n`),
205
+ );
206
+ return initializeServerIfRequested(installed, deps, options.initializeServer, 'installed');
207
+ }
208
+
209
+ async function initializeServerIfRequested(
210
+ server: InstalledPackage,
211
+ deps: FirstRunServerInstallDeps,
212
+ requested: boolean | undefined,
213
+ kind: 'present' | 'installed',
214
+ suppressClientNextSteps = false,
215
+ ): Promise<FirstRunServerInstallResult> {
216
+ if (!requested) return { kind, server, ...(suppressClientNextSteps ? { suppressClientNextSteps: true } : {}) };
217
+ try {
218
+ const exitCode = await deps.runServerSetup(server, { onboardingHint: !suppressClientNextSteps });
219
+ if (exitCode === 0) {
220
+ return { kind, server, ...(suppressClientNextSteps ? { suppressClientNextSteps: true } : {}) };
221
+ }
222
+ } catch (error) {
223
+ deps.stderr(`Local server setup could not be completed. ${error instanceof Error ? error.message : String(error)}\n`);
224
+ }
225
+ deps.stderr(
226
+ 'Run `borg server setup` to retry local server initialization, then run `borg server start`.\n',
176
227
  );
177
- return { kind: 'installed', server: installed };
228
+ return { kind: 'failed' };
229
+ }
230
+
231
+ function shouldSuppressClientNextSteps(version: unknown): boolean {
232
+ if (!isExactSemver(version)) return false;
233
+ const installedParts = version.split('.').slice(0, 3).map(Number);
234
+ const supportedParts = HINT_SUPPORTED_FROM.split('.').map(Number);
235
+ for (let index = 0; index < 3; index += 1) {
236
+ if (installedParts[index] !== supportedParts[index]) {
237
+ return installedParts[index] < supportedParts[index];
238
+ }
239
+ }
240
+ return false;
178
241
  }
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  } from '@modelcontextprotocol/sdk/types.js';
21
21
 
22
22
  import { assertRoleMatches } from './role-match.js';
23
+ import { CubeDeletionConfirmationError } from './server-errors.js';
23
24
 
24
25
  import {
25
26
  getCubeInfo,
@@ -103,7 +104,9 @@ import {
103
104
  } from './roster-render.js';
104
105
  import { resolveWorkingRepo } from './working-repo.js';
105
106
  import {
107
+ CubeDeletedError,
106
108
  DroneEvictedError,
109
+ formatCubeDeletedErrorToolResult,
107
110
  formatEvictedToolResult,
108
111
  } from './drone-lifecycle.js';
109
112
  import {
@@ -1048,7 +1051,11 @@ export async function main() {
1048
1051
  case 'borg_delete-cube': {
1049
1052
  const cubeId = args?.cube_id as string;
1050
1053
  if (!cubeId) throw new Error('cube_id is required');
1051
- await deleteCube(cubeId);
1054
+ const confirmCubeId = args?.confirm_cube_id as string | undefined;
1055
+ if (confirmCubeId !== cubeId) {
1056
+ throw new CubeDeletionConfirmationError(cubeId, confirmCubeId);
1057
+ }
1058
+ await deleteCube(cubeId, confirmCubeId);
1052
1059
  return { content: [{ type: 'text', text: `Deleted cube ${cubeId} (and all its roles, drones, log entries).` }] };
1053
1060
  }
1054
1061
 
@@ -1261,6 +1268,13 @@ export async function main() {
1261
1268
  isError: true,
1262
1269
  };
1263
1270
  }
1271
+ if (error instanceof CubeDeletedError) {
1272
+ const active = await getActiveCube();
1273
+ return {
1274
+ content: [{ type: 'text', text: formatCubeDeletedErrorToolResult(error, active?.name) }],
1275
+ isError: true,
1276
+ };
1277
+ }
1264
1278
 
1265
1279
  const localManageResult = formatLocalManageToolResult(error);
1266
1280
  if (localManageResult) return localManageResult;
package/src/log-stream.ts CHANGED
@@ -40,6 +40,8 @@ import {
40
40
  type LocalServerCursor,
41
41
  } from './local-server-cursor.js';
42
42
  import {
43
+ CubeDeletedError,
44
+ CUBE_DELETED_CODE,
43
45
  DroneEvictedError,
44
46
  DRONE_EVICTED_CODE,
45
47
  EVICTED_RESULT_MARKER,
@@ -573,7 +575,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
573
575
  // loop quiesces cleanly. The agent's graceful shutdown (TaskStop Monitor,
574
576
  // no /loop reschedule) is driven separately by the EVICTED tool-result it
575
577
  // already received on the authed call that produced this verdict.
576
- if (err instanceof DroneEvictedError || err instanceof BorgServerError) {
578
+ if (err instanceof CubeDeletedError || err instanceof DroneEvictedError || err instanceof BorgServerError) {
577
579
  if (active.localSessionCredentialRef) {
578
580
  markSeatRejected(active.localSessionCredentialRef);
579
581
  }
@@ -591,15 +593,15 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
591
593
  streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
592
594
  continue;
593
595
  }
594
- if (err instanceof DroneEvictedError) {
596
+ if (err instanceof DroneEvictedError || err instanceof CubeDeletedError) {
595
597
  if (lease) await lease.release().catch(() => {});
596
598
  lease = null;
597
599
  leaseKey = null;
598
600
  streamState.connected = false;
599
601
  streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
600
- process.stderr.write(
601
- `[borg-mcp log stream] drone evicted — stream terminated (no reconnect).\n`
602
- );
602
+ process.stderr.write(err instanceof CubeDeletedError
603
+ ? '[borg-mcp log stream] cube deleted — stream terminated (no reconnect).\n'
604
+ : '[borg-mcp log stream] drone evicted — stream terminated (no reconnect).\n');
603
605
  }
604
606
  throw new TerminalStreamError();
605
607
  }
@@ -939,6 +941,10 @@ export async function streamOnce(
939
941
  if (active.localSessionCredentialRef) markSeatRejected(active.localSessionCredentialRef);
940
942
  throw new DroneEvictedError();
941
943
  }
944
+ if (code === CUBE_DELETED_CODE) {
945
+ if (active.localSessionCredentialRef) markSeatRejected(active.localSessionCredentialRef);
946
+ throw new CubeDeletedError();
947
+ }
942
948
  // client#42: an expired resume cursor is RECOVERABLE, not terminal. The
943
949
  // pointed-at entry was pruned server-side, so retrying the SAME cursor
944
950
  // 410s forever (a wedged, silently-dead wake path). Reset the stream's
@@ -1010,6 +1016,13 @@ export async function streamOnce(
1010
1016
  break;
1011
1017
  }
1012
1018
 
1019
+ if (event.type === 'error') {
1020
+ if (active.localSessionCredentialRef) markSeatRejected(active.localSessionCredentialRef);
1021
+ if (event.code === CUBE_DELETED_CODE) throw new CubeDeletedError();
1022
+ if (event.code === DRONE_EVICTED_CODE) throw new DroneEvictedError();
1023
+ throw new BorgServerError('CREDENTIAL_REJECTED', 'Borg server terminated the stream');
1024
+ }
1025
+
1013
1026
  if (event.type === 'heartbeat') {
1014
1027
  streamState.lastHeartbeatAt = nowIso;
1015
1028
  // First/baseline heartbeat absorb: until this session has seen
@@ -1198,6 +1211,7 @@ export type ParsedEvent =
1198
1211
  | { type: 'bookmark'; as_of: string | null }
1199
1212
  // gh#877 Path-A: terminal eviction control frame (wake hint, zero authority).
1200
1213
  | { type: 'eviction'; id: string | null; cube_id: string | null; reason: string | null }
1214
+ | { type: 'error'; code: ErrorCode }
1201
1215
  | { type: 'unknown'; raw: string };
1202
1216
 
1203
1217
  /**
@@ -1357,6 +1371,13 @@ function parseEventBlock(block: string): ParsedEvent | null {
1357
1371
  }
1358
1372
  return { type: 'eviction', id: id || null, cube_id, reason };
1359
1373
  }
1374
+ if (eventName === 'error') {
1375
+ try {
1376
+ return { type: 'error', code: decodeProtocolErrorEnvelope(JSON.parse(dataStr)).error.code };
1377
+ } catch {
1378
+ return { type: 'unknown', raw: block };
1379
+ }
1380
+ }
1360
1381
  return { type: 'unknown', raw: block };
1361
1382
  }
1362
1383
 
@@ -16,6 +16,7 @@ import {
16
16
  import { randomUUID } from 'node:crypto';
17
17
  import {
18
18
  createProtocolEnvelope,
19
+ decodeDeleteCubeResponse,
19
20
  decodeDroneRuntimeMetadataState,
20
21
  decodeEvictDroneResult,
21
22
  decodeProtocolEnvelope,
@@ -23,6 +24,7 @@ import {
23
24
  decodeReassignDroneResult,
24
25
  decodeUpdateDroneRuntimeMetadataResponse,
25
26
  ErrorCode,
27
+ ProtocolContractError,
26
28
  type AgentKind,
27
29
  type EvictDroneResult,
28
30
  type ReassignDroneResult,
@@ -30,7 +32,12 @@ import {
30
32
  import { consolePrefix } from './console-prefix.js';
31
33
  import { debugLog } from './debug.js';
32
34
  import { assertUuidShape } from './evict-drone.js';
33
- import { DroneEvictedError, DRONE_EVICTED_CODE } from './drone-lifecycle.js';
35
+ import {
36
+ CubeDeletedError,
37
+ CUBE_DELETED_CODE,
38
+ DroneEvictedError,
39
+ DRONE_EVICTED_CODE,
40
+ } from './drone-lifecycle.js';
34
41
  import type { MessageTaxonomy, MessageTaxonomyClass } from 'borgmcp-shared/templates';
35
42
  import { getTemplate, type Template, type TemplateRole } from 'borgmcp-shared/templates';
36
43
  import { parseRoleSections } from 'borgmcp-shared/role-section';
@@ -41,8 +48,10 @@ import { loadBorgServerTrust, type ServerFetch } from './server-trust.js';
41
48
  import {
42
49
  BorgServerError,
43
50
  BorgServerHttpError,
51
+ BorgProtocolMismatchError,
44
52
  BorgServerTrustError,
45
53
  BorgServerUnreachableError,
54
+ CubeDeletionConfirmationError,
46
55
  LocalManageCredentialUnavailableError,
47
56
  LocalManageRequiredError,
48
57
  } from './server-errors.js';
@@ -226,6 +235,12 @@ async function decodeLocalProtocolResponse<T>(
226
235
  // CR5: a TYPED transport-timeout verdict (message kept for call-site parity).
227
236
  throw new BorgServerUnreachableError('Local Borg server request timed out');
228
237
  }
238
+ if (
239
+ error instanceof ProtocolContractError &&
240
+ error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
241
+ ) {
242
+ throw new BorgProtocolMismatchError();
243
+ }
229
244
  throw error;
230
245
  } finally {
231
246
  clearTimeout(timeout);
@@ -329,6 +344,9 @@ async function localManageRequest<T>(
329
344
  }),
330
345
  }), true, decodePayload);
331
346
  } catch (error) {
347
+ if (error instanceof CubeDeletedError) {
348
+ throw new CubeDeletedError(operation.cubeName);
349
+ }
332
350
  if (
333
351
  error instanceof BorgServerHttpError &&
334
352
  error.status === 403 &&
@@ -704,6 +722,7 @@ async function authedFetch(
704
722
  // terminal controls. Decode only the bounded protocol error code for typed
705
723
  // branching; never surface the server-provided message or details.
706
724
  let code: ErrorCode | undefined;
725
+ let protocolMismatch = false;
707
726
  try {
708
727
  const body = await readBoundedResponseBody(
709
728
  response,
@@ -713,7 +732,13 @@ async function authedFetch(
713
732
  const parsed = JSON.parse(body);
714
733
  try {
715
734
  code = decodeProtocolErrorEnvelope(parsed).error.code;
716
- } catch {
735
+ } catch (error) {
736
+ if (
737
+ error instanceof ProtocolContractError &&
738
+ error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
739
+ ) {
740
+ protocolMismatch = true;
741
+ }
717
742
  if (
718
743
  parsed !== null && typeof parsed === 'object' &&
719
744
  parsed.error !== null && typeof parsed.error === 'object' &&
@@ -738,10 +763,15 @@ async function authedFetch(
738
763
  code = undefined;
739
764
  }
740
765
  debugLog(`✗ ${response.status} ${method} ${path}`);
766
+ if (protocolMismatch) throw new BorgProtocolMismatchError();
741
767
  if (droneSession !== undefined && response.status === 410 && code === DRONE_EVICTED_CODE) {
742
768
  if (localSessionCredentialRef !== undefined) markSeatRejected(localSessionCredentialRef);
743
769
  throw new DroneEvictedError();
744
770
  }
771
+ if (response.status === 410 && code === CUBE_DELETED_CODE) {
772
+ if (localSessionCredentialRef !== undefined) markSeatRejected(localSessionCredentialRef);
773
+ throw new CubeDeletedError();
774
+ }
745
775
  throw new BorgServerHttpError(
746
776
  response.status,
747
777
  `Borg server request failed (HTTP ${response.status})`,
@@ -1011,7 +1041,7 @@ export async function regen(
1011
1041
  opts: {
1012
1042
  since?: string;
1013
1043
  /** Advisory self-report from the running agent; never model-routing config. */
1014
- reportedModel?: string;
1044
+ reportedModel?: string | null;
1015
1045
  /** Positively identified running Agent CLI; null means explicitly unknown. */
1016
1046
  agentKind?: AgentKind | null;
1017
1047
  /** Current cwd-derived identity; refreshed each regen to avoid stale routing data. */
@@ -1047,7 +1077,7 @@ export async function regen(
1047
1077
  ) {
1048
1078
  const patch = buildRuntimeMetadataPatch({
1049
1079
  agentKind: opts.agentKind ?? null,
1050
- reportedModel: opts.reportedModel,
1080
+ reportedModel: opts.reportedModel ?? null,
1051
1081
  workingRepo: opts.workingRepo,
1052
1082
  });
1053
1083
  try {
@@ -1316,9 +1346,30 @@ export async function patchTaxonomyClass(
1316
1346
  * Delete a cube. Cascade-deletes all roles, drones, and log entries.
1317
1347
  * Requires a live cube-manage grant on the selected local client.
1318
1348
  */
1319
- export async function deleteCube(cubeId: string): Promise<void> {
1320
- void cubeId;
1321
- localUnsupported('cube deletion');
1349
+ export async function deleteCube(cubeId: string, confirmCubeId: string): Promise<void> {
1350
+ if (confirmCubeId !== cubeId) {
1351
+ throw new CubeDeletionConfirmationError(cubeId, confirmCubeId);
1352
+ }
1353
+ assertUuidShape(cubeId, 'cube_id');
1354
+ const active = await getActiveCube();
1355
+ if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1356
+ const cubeName = cubeId === active.cubeId ? active.name : cubeId;
1357
+ const result = await localManageRequest(
1358
+ active,
1359
+ `/api/cubes/${cubeId}`,
1360
+ 'DELETE',
1361
+ {
1362
+ operation: `delete cube ${manageCopyValue(cubeName)}`,
1363
+ cubeName,
1364
+ noMutation: 'The cube was not deleted.',
1365
+ },
1366
+ {},
1367
+ decodeDeleteCubeResponse,
1368
+ );
1369
+ if (!result) throw new Error('Local Borg server returned an empty cube deletion response');
1370
+ if (result.cube_id !== cubeId) {
1371
+ throw new Error('Local Borg server returned a deletion response for an unexpected cube');
1372
+ }
1322
1373
  }
1323
1374
 
1324
1375
  /**
@@ -53,13 +53,14 @@ export function buildRuntimeMetadataReport(input: {
53
53
 
54
54
  export function buildRuntimeMetadataPatch(input: {
55
55
  agentKind: AgentKind | null;
56
- reportedModel?: string;
56
+ reportedModel?: string | null;
57
57
  workingRepo?: WorkingRepo;
58
58
  }): DroneRuntimeMetadataPatch {
59
59
  const patch: DroneRuntimeMetadataPatch = { agent_kind: input.agentKind };
60
60
  if (input.reportedModel !== undefined) {
61
- const model = safeReportedModel(input.reportedModel);
62
- if (model !== null) patch.reported_model = model;
61
+ patch.reported_model = input.reportedModel === null
62
+ ? null
63
+ : validateReportedModel(input.reportedModel);
63
64
  }
64
65
  const repository = reportableRepository(input.workingRepo);
65
66
  if (repository) Object.assign(patch, repository);
@@ -2,6 +2,7 @@ export type BorgServerErrorCode =
2
2
  | 'NOT_ENROLLED'
3
3
  | 'CREDENTIAL_REJECTED'
4
4
  | 'INVITATION_REJECTED'
5
+ | 'LOCAL_CREDENTIAL_EXISTS'
5
6
  | 'CREATE_CUBE_DENIED'
6
7
  | 'ATTACH_CONFLICT'
7
8
  | 'SESSION_REJECTED'
@@ -35,6 +36,16 @@ export class BorgServerHttpError extends Error {
35
36
  }
36
37
  }
37
38
 
39
+ export class BorgProtocolMismatchError extends Error {
40
+ constructor() {
41
+ super(
42
+ 'This client and the selected Borg server use different protocol versions. ' +
43
+ 'Update `borgmcp-server` and `borgmcp` to matching releases, server first and then client.',
44
+ );
45
+ this.name = 'BorgProtocolMismatchError';
46
+ }
47
+ }
48
+
38
49
  export class LocalManageRequiredError extends Error {
39
50
  constructor(
40
51
  public readonly operation: string,
@@ -106,6 +117,20 @@ export class CubeCreationConfirmationError extends Error {
106
117
  }
107
118
  }
108
119
 
120
+ export class CubeDeletionConfirmationError extends Error {
121
+ constructor(
122
+ public readonly cubeId: string,
123
+ public readonly confirmCubeId: string | undefined,
124
+ ) {
125
+ const supplied = confirmCubeId === undefined ? '(missing)' : `"${confirmCubeId}"`;
126
+ super(
127
+ `Cube deletion is irreversible. The confirmation cube ID ${supplied} must exactly match ` +
128
+ `the requested cube ID "${cubeId}". No cube was deleted.`,
129
+ );
130
+ this.name = 'CubeDeletionConfirmationError';
131
+ }
132
+ }
133
+
109
134
  export type RepositoryAssociationFailure =
110
135
  | 'repository-already-associated'
111
136
  | 'cube-already-associated'