borgmcp 4.6.1 → 4.7.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 (117) hide show
  1. package/dist/assimilate-cmd.d.ts +8 -23
  2. package/dist/assimilate-cmd.d.ts.map +1 -1
  3. package/dist/assimilate-cmd.js +9 -5
  4. package/dist/assimilate-cmd.js.map +1 -1
  5. package/dist/assimilate-deps.d.ts.map +1 -1
  6. package/dist/assimilate-deps.js +3 -16
  7. package/dist/assimilate-deps.js.map +1 -1
  8. package/dist/claude.d.ts +5 -3
  9. package/dist/claude.d.ts.map +1 -1
  10. package/dist/claude.js +82 -30
  11. package/dist/claude.js.map +1 -1
  12. package/dist/cli-help.d.ts.map +1 -1
  13. package/dist/cli-help.js +1 -0
  14. package/dist/cli-help.js.map +1 -1
  15. package/dist/codex-launch.d.ts +1 -1
  16. package/dist/codex-launch.d.ts.map +1 -1
  17. package/dist/codex-launch.js.map +1 -1
  18. package/dist/config-utils.d.ts +3 -0
  19. package/dist/config-utils.d.ts.map +1 -1
  20. package/dist/config-utils.js +141 -4
  21. package/dist/config-utils.js.map +1 -1
  22. package/dist/config.d.ts.map +1 -1
  23. package/dist/config.js +55 -18
  24. package/dist/config.js.map +1 -1
  25. package/dist/cubes.d.ts +0 -7
  26. package/dist/cubes.d.ts.map +1 -1
  27. package/dist/cubes.js +0 -9
  28. package/dist/cubes.js.map +1 -1
  29. package/dist/gc-orphan-inboxes.js.map +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +24 -47
  32. package/dist/index.js.map +1 -1
  33. package/dist/log-stream.d.ts +6 -16
  34. package/dist/log-stream.d.ts.map +1 -1
  35. package/dist/log-stream.js.map +1 -1
  36. package/dist/opencode-drone.d.ts +2 -0
  37. package/dist/opencode-drone.d.ts.map +1 -1
  38. package/dist/opencode-drone.js +101 -28
  39. package/dist/opencode-drone.js.map +1 -1
  40. package/dist/opencode-launch-trust.d.ts +1 -0
  41. package/dist/opencode-launch-trust.d.ts.map +1 -1
  42. package/dist/opencode-launch-trust.js +4 -0
  43. package/dist/opencode-launch-trust.js.map +1 -1
  44. package/dist/quickstart-cmd.d.ts.map +1 -1
  45. package/dist/quickstart-cmd.js +9 -2
  46. package/dist/quickstart-cmd.js.map +1 -1
  47. package/dist/regen-format.d.ts +1 -2
  48. package/dist/regen-format.d.ts.map +1 -1
  49. package/dist/regen-format.js.map +1 -1
  50. package/dist/regen.js +1 -1
  51. package/dist/regen.js.map +1 -1
  52. package/dist/remote-client.d.ts.map +1 -1
  53. package/dist/remote-client.js +8 -8
  54. package/dist/remote-client.js.map +1 -1
  55. package/dist/repository-cube-init.d.ts +1 -1
  56. package/dist/repository-cube-init.d.ts.map +1 -1
  57. package/dist/repository-cube-init.js +2 -5
  58. package/dist/repository-cube-init.js.map +1 -1
  59. package/dist/repository-identity.d.ts +14 -4
  60. package/dist/repository-identity.d.ts.map +1 -1
  61. package/dist/repository-identity.js +86 -18
  62. package/dist/repository-identity.js.map +1 -1
  63. package/dist/self-path.d.ts.map +1 -1
  64. package/dist/self-path.js.map +1 -1
  65. package/dist/server-handshake.d.ts +1 -1
  66. package/dist/server-handshake.d.ts.map +1 -1
  67. package/dist/server-handshake.js +0 -1
  68. package/dist/server-handshake.js.map +1 -1
  69. package/dist/tool-manifest.js +3 -3
  70. package/dist/tool-manifest.js.map +1 -1
  71. package/dist/unknown-subcommand.d.ts +1 -1
  72. package/dist/unknown-subcommand.d.ts.map +1 -1
  73. package/dist/unknown-subcommand.js +1 -0
  74. package/dist/unknown-subcommand.js.map +1 -1
  75. package/dist/update-cmd.js +1 -1
  76. package/dist/update-cmd.js.map +1 -1
  77. package/docs/RELEASING.md +5 -4
  78. package/package.json +2 -2
  79. package/src/assimilate-cmd.ts +29 -37
  80. package/src/assimilate-deps.ts +2 -18
  81. package/src/claude.ts +91 -31
  82. package/src/cli-help.ts +1 -0
  83. package/src/codex-launch.ts +2 -1
  84. package/src/config-utils.ts +156 -4
  85. package/src/config.ts +58 -22
  86. package/src/cubes.ts +0 -10
  87. package/src/gc-orphan-inboxes.ts +1 -1
  88. package/src/index.ts +24 -51
  89. package/src/log-stream.ts +12 -17
  90. package/src/opencode-drone.ts +112 -30
  91. package/src/opencode-launch-trust.ts +5 -0
  92. package/src/quickstart-cmd.ts +10 -2
  93. package/src/regen-format.ts +1 -3
  94. package/src/regen.ts +2 -3
  95. package/src/remote-client.ts +12 -9
  96. package/src/repository-cube-init.ts +3 -6
  97. package/src/repository-identity.ts +125 -20
  98. package/src/self-path.ts +0 -1
  99. package/src/server-handshake.ts +1 -2
  100. package/src/tool-manifest.ts +3 -3
  101. package/src/unknown-subcommand.ts +1 -0
  102. package/src/update-cmd.ts +1 -1
  103. package/dist/postinstall-banner.d.ts +0 -2
  104. package/dist/postinstall-banner.d.ts.map +0 -1
  105. package/dist/postinstall-banner.js +0 -27
  106. package/dist/postinstall-banner.js.map +0 -1
  107. package/dist/postinstall.d.ts +0 -11
  108. package/dist/postinstall.d.ts.map +0 -1
  109. package/dist/postinstall.js +0 -33
  110. package/dist/postinstall.js.map +0 -1
  111. package/dist/types.d.ts +0 -5
  112. package/dist/types.d.ts.map +0 -1
  113. package/dist/types.js +0 -5
  114. package/dist/types.js.map +0 -1
  115. package/src/postinstall-banner.ts +0 -37
  116. package/src/postinstall.ts +0 -36
  117. package/src/types.ts +0 -5
package/src/index.ts CHANGED
@@ -67,6 +67,7 @@ import {
67
67
  formatDocumentMetadata,
68
68
  } from './document-render.js';
69
69
  import {
70
+ NEW_CUBE_TEMPLATE_PRESENTATIONS,
70
71
  getTemplate,
71
72
  listTemplateNames,
72
73
  resolveCubeDirectiveForCreate,
@@ -153,7 +154,7 @@ import {
153
154
  writeOpenCodeStartupDiagnostic,
154
155
  } from './opencode-drone.js';
155
156
  import { installBorgPlugin } from './opencode-plugin.js';
156
- import { openCodeApiPasswordFromEnv } from './opencode-launch-trust.js';
157
+ import { openCodeApiPasswordFromEnv, openCodeLaunchCorrelationFromEnv } from './opencode-launch-trust.js';
157
158
  import { setModuleInjectOpenCode } from './log-stream.js';
158
159
  import {
159
160
  lifecycleSignalForMessage,
@@ -176,27 +177,6 @@ import {
176
177
 
177
178
  const OPEN_CODE_IDENTITY_HANDSHAKE_TIMEOUT_MS = 5_000;
178
179
 
179
- /**
180
- * Apply a template's roles + message_taxonomy to a cube.
181
- *
182
- * Client-orchestrated through the server's non-clobbering role and taxonomy
183
- * primitives. New roles are inserted; existing template-named roles get ADD
184
- * fragments (template sections/classes the cube lacks) auto-applied, but
185
- * EVOLVED (conflicting) fragments are kept — never silently overwritten. The
186
- * old per-role blanket `updateRole`/whole-taxonomy `updateCube` overwrite path
187
- * is removed. Operators who want to take the template version of a conflicting
188
- * fragment use `borg_sync-roles` with a `decisions` map. Primitive operations
189
- * are sequential, so a later failure can leave earlier changes committed.
190
- * Returns `{ created, updated }` for the caller's toast.
191
- */
192
- async function applyTemplateToCube(
193
- cubeId: string,
194
- template: Template,
195
- authority?: LocalManageAuthority,
196
- ): Promise<{ created: number; updated: number }> {
197
- return await applyTemplate(cubeId, template.name, authority);
198
- }
199
-
200
180
  export async function runApplyTemplateTool(
201
181
  cubeId: string,
202
182
  template: Template,
@@ -312,6 +292,11 @@ export async function connectOpenCodeRuntime(
312
292
  console.error('OpenCode API credential is missing or unverifiable; skipping OpenCode entry injection. Relaunch through borg.');
313
293
  return false;
314
294
  }
295
+ const launchIdentity = openCodeLaunchCorrelationFromEnv(env);
296
+ if (launchIdentity === null) {
297
+ console.error('OpenCode launch identity is missing or unverifiable; skipping OpenCode entry injection. Relaunch through borg.');
298
+ return false;
299
+ }
315
300
  const binding = openCodeLaunchBinding(configuredPort);
316
301
  await (deps.connect ?? connectOpenCodeDrone)({
317
302
  serverUrl: binding.serverUrl,
@@ -319,6 +304,7 @@ export async function connectOpenCodeRuntime(
319
304
  directory: active.worktree ?? findProjectRoot(),
320
305
  droneLabel: active.droneLabel,
321
306
  cubeName: active.name,
307
+ launchIdentity,
322
308
  });
323
309
  return true;
324
310
  }
@@ -1251,18 +1237,15 @@ export async function main() {
1251
1237
  case 'borg_create-cube': {
1252
1238
  const name = args?.name as string;
1253
1239
  const cubeDirective = args?.cube_directive as string;
1254
- const templateName = args?.template as string | undefined;
1240
+ const templateName = (args?.template as string | undefined) ?? NEW_CUBE_TEMPLATE_PRESENTATIONS[0].name;
1255
1241
  if (!name) throw new Error('name is required');
1256
1242
  if (cubeDirective === undefined) throw new Error('cube_directive is required (pass empty string if none)');
1257
1243
 
1258
1244
  // Resolve template (validates name early so the cube isn't
1259
1245
  // created in a partial state if the template name is wrong).
1260
- let template = null;
1261
- if (templateName) {
1262
- template = getTemplate(templateName);
1263
- if (!template) {
1264
- throw new Error(`Unknown template "${templateName}". Available: ${listTemplateNames().join(', ')}`);
1265
- }
1246
+ const template = getTemplate(templateName);
1247
+ if (!template) {
1248
+ throw new Error(`Unknown template "${templateName}". Available: ${listTemplateNames().join(', ')}`);
1266
1249
  }
1267
1250
 
1268
1251
  // client#499: the cube binds to an EXPLICIT repository (no cwd
@@ -1280,6 +1263,7 @@ export async function main() {
1280
1263
  const resolvedCubeDirective = resolveCubeDirectiveForCreate(cubeDirective, template);
1281
1264
  const resolvedMessageTaxonomy = resolveMessageTaxonomyForCreate(undefined, template);
1282
1265
  const { result, cube } = await createCube(name, resolvedCubeDirective, {
1266
+ template: template.name,
1283
1267
  message_taxonomy: resolvedMessageTaxonomy,
1284
1268
  repository,
1285
1269
  workingRepoName,
@@ -1296,30 +1280,19 @@ export async function main() {
1296
1280
  };
1297
1281
  }
1298
1282
 
1299
- // Apply template roles if requested. Merges by name: any role the
1300
- // server auto-seeded (e.g. "Drone") that the template doesn't
1301
- // also include stays put; templated roles upsert.
1302
- if (template) {
1303
- const summary = await applyTemplateToCube(cube.id, template);
1304
- const cubeDirectiveNote = resolvedCubeDirective !== cubeDirective
1305
- ? ' Template cube directive applied (operator passed empty).'
1306
- : '';
1307
- const text = `Created cube **${cube.name}** (id: ${cube.id}) with template **${templateName}** applied — ${summary.created} role(s) created, ${summary.updated} updated.${cubeDirectiveNote} Use borg_assimilate ${cube.name} to join as a drone.`;
1308
- return {
1309
- content: [{ type: 'text', text }],
1310
- structuredContent: {
1311
- cube,
1312
- result: 'created',
1313
- template: templateName,
1314
- roles_created: summary.created,
1315
- roles_updated: summary.updated,
1316
- },
1317
- };
1318
- }
1319
- const text = `Created cube **${cube.name}** (id: ${cube.id}). A default "Drone" role was seeded — rename or replace it via borg_update-role / borg_create-role / borg_delete-role. Use borg_assimilate ${cube.name} to join as a drone.`;
1283
+ const cubeDirectiveNote = resolvedCubeDirective !== cubeDirective
1284
+ ? ' Template cube directive applied (operator passed empty).'
1285
+ : '';
1286
+ const text = `Created cube **${cube.name}** (id: ${cube.id}) with template **${templateName}** applied atomically — ${cube.roles.length} role(s) created.${cubeDirectiveNote} Use borg_assimilate ${cube.name} to join as a drone.`;
1320
1287
  return {
1321
1288
  content: [{ type: 'text', text }],
1322
- structuredContent: { cube, result: 'created', template: null, roles_created: null, roles_updated: null },
1289
+ structuredContent: {
1290
+ cube,
1291
+ result: 'created',
1292
+ template: templateName,
1293
+ roles_created: cube.roles.length,
1294
+ roles_updated: 0,
1295
+ },
1323
1296
  };
1324
1297
  }
1325
1298
 
package/src/log-stream.ts CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  ErrorCode,
35
35
  type DocumentCitation,
36
36
  } from 'borgmcp-shared/protocol';
37
- import { getActiveCube, inboxPathForDrone } from './cubes.js';
37
+ import { getActiveCube, inboxPathForDrone, type ActiveCube } from './cubes.js';
38
38
  import { assertUuidShape } from './evict-drone.js';
39
39
  import { loadBorgServerTrust } from './server-trust.js';
40
40
  import {
@@ -56,7 +56,6 @@ import {
56
56
  CODEX_HEARTBEAT_CADENCE_MS,
57
57
  fireCodexHeartbeatTick,
58
58
  formatCodexWakePrompt,
59
- resolveSessionAgentKind,
60
59
  startCodexHeartbeat,
61
60
  wakeCodexViaAppServer,
62
61
  } from './codex-app-wake.js';
@@ -420,7 +419,7 @@ export interface StreamDeps {
420
419
  isSourcePending?: () => Promise<boolean>,
421
420
  ) => Promise<boolean>;
422
421
  /** Inspect whether one retry source remains beyond the durable unread cursor. */
423
- hasPendingWakeEntry?: (active: ActiveCube, entryId: string) => Promise<boolean>;
422
+ hasPendingWakeEntry?: (active: StreamActiveCube, entryId: string) => Promise<boolean>;
424
423
  /** Clear in-process retry diagnostics after the durable entry is consumed. */
425
424
  settleOpenCodeEntry?: (sourceEntryId: string) => void;
426
425
  }
@@ -671,21 +670,17 @@ export function __runLoopForTest(testDeps: RunLoopTestDeps): Promise<void> {
671
670
  // Single SSE session (exported for tests)
672
671
  // ------------------------------------------------------------------
673
672
 
674
- export interface ActiveCube {
675
- cubeId: string;
676
- droneId: string;
677
- sessionToken: string;
678
- apiUrl: string;
679
- serverTrustIdentity?: string;
680
- localSessionCredentialRef?: string;
681
- localSessionExpiresAt?: string | null;
682
- worktree?: string;
683
- droneLabel?: string;
684
- name?: string;
685
- }
673
+ /** Direct stream controls may omit metadata or omit trust to exercise fail-closed validation. */
674
+ export type StreamActiveCube = Pick<
675
+ ActiveCube,
676
+ 'cubeId' | 'droneId' | 'sessionToken' | 'apiUrl'
677
+ > & Partial<Pick<
678
+ ActiveCube,
679
+ 'serverTrustIdentity' | 'localSessionCredentialRef' | 'worktree' | 'droneLabel' | 'name'
680
+ >>;
686
681
 
687
682
  export async function streamOnce(
688
- active: ActiveCube,
683
+ active: StreamActiveCube,
689
684
  lastEventId: string | null,
690
685
  onEventId: (id: string) => void,
691
686
  deps: StreamDeps = {}
@@ -1271,7 +1266,7 @@ export async function streamOnce(
1271
1266
  }
1272
1267
 
1273
1268
  export async function streamOnceIfOwner(
1274
- active: ActiveCube,
1269
+ active: StreamActiveCube,
1275
1270
  lastEventId: string | null,
1276
1271
  onEventId: (id: string) => void,
1277
1272
  deps: StreamDeps = {}
@@ -14,7 +14,7 @@ import {
14
14
  } from 'fs';
15
15
  import { createHash, randomUUID } from 'crypto';
16
16
  import { createServer } from 'node:net';
17
- import { join } from 'path';
17
+ import { join, resolve } from 'path';
18
18
  import { tmpdir } from 'os';
19
19
  import {
20
20
  borgConfigRoot,
@@ -44,8 +44,9 @@ const OPEN_CODE_DIAGNOSTIC_LOG_MAX_BYTES = 64 * 1024;
44
44
  const diagnosticLogPathsForTests = new Set<string>();
45
45
 
46
46
  function stateIdentityDigest(current: OpenCodeDroneState): string {
47
- const key = [current.directory, current.cubeName, current.droneLabel].join('\0');
48
- return createHash('sha256').update(key).digest('hex').slice(0, 24);
47
+ // Diagnostic files follow the stable worktree location; launch placeholders
48
+ // must not select a second log before session identity resolves.
49
+ return createHash('sha256').update(resolve(current.directory)).digest('hex').slice(0, 24);
49
50
  }
50
51
 
51
52
  export function openCodeStartupDiagnosticLogPath(): string {
@@ -166,6 +167,7 @@ interface OpenCodeDroneState {
166
167
  directory: string;
167
168
  droneLabel: string;
168
169
  cubeName: string;
170
+ launchIdentity: string;
169
171
  connected: boolean;
170
172
  totalEntriesInjected: number;
171
173
  totalEntriesRetried: number;
@@ -204,6 +206,7 @@ interface ConnectDeps {
204
206
  directory: string;
205
207
  droneLabel: string;
206
208
  cubeName: string;
209
+ launchIdentity: string;
207
210
  }
208
211
 
209
212
  interface OCSession {
@@ -270,7 +273,7 @@ const OPEN_CODE_RECONCILIATION_ATTEMPTS = 20;
270
273
  const OPEN_CODE_DELIVERY_HISTORY_LIMIT = 256;
271
274
 
272
275
  interface SessionBinding {
273
- version: 4;
276
+ version: 5;
274
277
  sessionId: string;
275
278
  sessionCreatedAt: number;
276
279
  knownRootSessionIds: string[];
@@ -278,6 +281,7 @@ interface SessionBinding {
278
281
  directory: string;
279
282
  droneLabel: string;
280
283
  cubeName: string;
284
+ launchIdentity: string;
281
285
  pendingSubmissions: Array<{
282
286
  entryId: string;
283
287
  sourceEntryId: string;
@@ -285,6 +289,13 @@ interface SessionBinding {
285
289
  }>;
286
290
  }
287
291
 
292
+ interface SessionBindingClaim {
293
+ version: 1;
294
+ directory: string;
295
+ droneLabel: string;
296
+ cubeName: string;
297
+ }
298
+
288
299
  export interface OpenCodeLaunchKickoff {
289
300
  prompt: string;
290
301
  apiPassword: string;
@@ -323,6 +334,9 @@ export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
323
334
  if (!isOpenCode256BitIdentity(deps.apiPassword)) {
324
335
  throw new OpenCodeAuthenticationError('OpenCode API password is missing or unverifiable');
325
336
  }
337
+ if (!isOpenCode256BitIdentity(deps.launchIdentity)) {
338
+ throw new OpenCodeAuthenticationError('OpenCode launch identity is missing or unverifiable');
339
+ }
326
340
  await ensurePrivateBorgConfigRoot(borgConfigRoot());
327
341
  abandonOpenCodeDeliveries(state);
328
342
  state = {
@@ -334,6 +348,7 @@ export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
334
348
  directory: deps.directory,
335
349
  droneLabel: deps.droneLabel,
336
350
  cubeName: deps.cubeName,
351
+ launchIdentity: deps.launchIdentity,
337
352
  connected: true,
338
353
  totalEntriesInjected: 0,
339
354
  totalEntriesRetried: 0,
@@ -546,17 +561,28 @@ async function promptSession(id: string, bodyObj: Record<string, unknown>): Prom
546
561
 
547
562
  function bindingPath(): string {
548
563
  const current = state!;
549
- const path = join(tmpdir(), `borg-opencode-session-${stateIdentityDigest(current)}.json`);
564
+ const identity = createHash('sha256').update(current.launchIdentity).digest('hex').slice(0, 24);
565
+ const path = join(tmpdir(), `borg-opencode-session-${identity}.json`);
550
566
  bindingPathsForTests.add(path);
551
567
  return path;
552
568
  }
553
569
 
570
+ function bindingClaimPath(): string {
571
+ return `${bindingPath()}.claim`;
572
+ }
573
+
554
574
  function bindingMatchesState(binding: SessionBinding): boolean {
555
575
  const current = state!;
556
- return binding.version === 4
576
+ const sameResolvedSeat = binding.droneLabel === current.droneLabel
577
+ && binding.cubeName === current.cubeName;
578
+ // The 256-bit launch identity lets the resolved MCP child claim only its
579
+ // launcher's placeholder binding; resolved ownership still requires labels.
580
+ const unclaimedLaunch = binding.droneLabel === 'opencode'
581
+ && binding.cubeName === 'borg';
582
+ return binding.version === 5
557
583
  && binding.directory === current.directory
558
- && binding.droneLabel === current.droneLabel
559
- && binding.cubeName === current.cubeName
584
+ && binding.launchIdentity === current.launchIdentity
585
+ && (sameResolvedSeat || unclaimedLaunch)
560
586
  && typeof binding.sessionId === 'string'
561
587
  && typeof binding.sessionCreatedAt === 'number'
562
588
  && Array.isArray(binding.knownRootSessionIds)
@@ -599,15 +625,14 @@ function clearBinding(): void {
599
625
 
600
626
  function writeBinding(binding: SessionBinding): boolean {
601
627
  const current = state!;
602
- current.sessionId = binding.sessionId;
603
- current.sessionCreatedAt = binding.sessionCreatedAt;
604
- current.knownRootSessionIds = binding.knownRootSessionIds;
605
-
606
628
  try {
607
629
  const path = bindingPath();
608
630
  const temporary = `${path}.${process.pid}.tmp`;
609
631
  writeFileSync(temporary, JSON.stringify(binding), { mode: 0o600 });
610
632
  renameSync(temporary, path);
633
+ current.sessionId = binding.sessionId;
634
+ current.sessionCreatedAt = binding.sessionCreatedAt;
635
+ current.knownRootSessionIds = binding.knownRootSessionIds;
611
636
  return true;
612
637
  } catch (err) {
613
638
  log(`session binding write failed: ${err}`);
@@ -615,10 +640,46 @@ function writeBinding(binding: SessionBinding): boolean {
615
640
  }
616
641
  }
617
642
 
643
+ function claimPlaceholderBinding(): boolean {
644
+ const current = state!;
645
+ const claim: SessionBindingClaim = {
646
+ version: 1,
647
+ directory: current.directory,
648
+ droneLabel: current.droneLabel,
649
+ cubeName: current.cubeName,
650
+ };
651
+ const path = bindingClaimPath();
652
+ try {
653
+ writeFileSync(path, JSON.stringify(claim), { mode: 0o600, flag: 'wx' });
654
+ return true;
655
+ } catch (error) {
656
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
657
+ log(`session binding claim failed: ${error}`);
658
+ return false;
659
+ }
660
+ }
661
+ try {
662
+ const existing = JSON.parse(readFileSync(path, 'utf8')) as SessionBindingClaim;
663
+ return existing.version === 1
664
+ && existing.directory === claim.directory
665
+ && existing.droneLabel === claim.droneLabel
666
+ && existing.cubeName === claim.cubeName;
667
+ } catch {
668
+ return false;
669
+ }
670
+ }
671
+
672
+ function dropCachedBinding(): void {
673
+ if (!state) return;
674
+ state.sessionId = null;
675
+ state.sessionCreatedAt = null;
676
+ state.knownRootSessionIds = [];
677
+ }
678
+
618
679
  function saveBinding(session: OCSession, knownRootSessionIds: string[]): void {
619
680
  const current = state!;
620
681
  const binding: SessionBinding = {
621
- version: 4,
682
+ version: 5,
622
683
  sessionId: session.id,
623
684
  sessionCreatedAt: session.time.created,
624
685
  knownRootSessionIds,
@@ -626,6 +687,7 @@ function saveBinding(session: OCSession, knownRootSessionIds: string[]): void {
626
687
  directory: current.directory,
627
688
  droneLabel: current.droneLabel,
628
689
  cubeName: current.cubeName,
690
+ launchIdentity: current.launchIdentity,
629
691
  pendingSubmissions: [...current.pendingSubmissions].map(([entryId, pending]) => ({
630
692
  entryId,
631
693
  sourceEntryId: pending.sourceEntryId,
@@ -639,7 +701,7 @@ function persistCurrentBinding(): boolean {
639
701
  const current = state;
640
702
  if (!current?.sessionId || current.sessionCreatedAt === null) return false;
641
703
  return writeBinding({
642
- version: 4,
704
+ version: 5,
643
705
  sessionId: current.sessionId,
644
706
  sessionCreatedAt: current.sessionCreatedAt,
645
707
  knownRootSessionIds: current.knownRootSessionIds,
@@ -647,6 +709,7 @@ function persistCurrentBinding(): boolean {
647
709
  directory: current.directory,
648
710
  droneLabel: current.droneLabel,
649
711
  cubeName: current.cubeName,
712
+ launchIdentity: current.launchIdentity,
650
713
  pendingSubmissions: [...current.pendingSubmissions].map(([entryId, pending]) => ({
651
714
  entryId,
652
715
  sourceEntryId: pending.sourceEntryId,
@@ -658,25 +721,34 @@ function persistCurrentBinding(): boolean {
658
721
  function restoreBinding(): SessionBinding | null {
659
722
  if (!state) return null;
660
723
  if (state.sessionId && state.sessionCreatedAt !== null) {
661
- return {
662
- version: 4,
663
- sessionId: state.sessionId,
664
- sessionCreatedAt: state.sessionCreatedAt,
665
- knownRootSessionIds: state.knownRootSessionIds,
666
- serverUrl: state.serverUrl,
667
- directory: state.directory,
668
- droneLabel: state.droneLabel,
669
- cubeName: state.cubeName,
670
- pendingSubmissions: [...state.pendingSubmissions].map(([entryId, pending]) => ({
671
- entryId,
672
- sourceEntryId: pending.sourceEntryId,
673
- sessionId: pending.sessionId,
674
- })),
675
- };
724
+ const persisted = readBinding();
725
+ if (
726
+ persisted?.droneLabel === state.droneLabel
727
+ && persisted.cubeName === state.cubeName
728
+ ) return persisted;
729
+ dropCachedBinding();
730
+ return null;
676
731
  }
677
732
 
678
- const binding = readBinding();
733
+ let binding = readBinding();
679
734
  if (!binding) return null;
735
+ if (binding.droneLabel === 'opencode' && binding.cubeName === 'borg') {
736
+ if (!claimPlaceholderBinding()) return null;
737
+ if (!writeBinding({
738
+ ...binding,
739
+ droneLabel: state.droneLabel,
740
+ cubeName: state.cubeName,
741
+ })) return null;
742
+ binding = readBinding();
743
+ if (
744
+ !binding
745
+ || binding.droneLabel !== state.droneLabel
746
+ || binding.cubeName !== state.cubeName
747
+ ) {
748
+ dropCachedBinding();
749
+ return null;
750
+ }
751
+ }
680
752
  state.sessionId = binding.sessionId;
681
753
  state.sessionCreatedAt = binding.sessionCreatedAt;
682
754
  state.knownRootSessionIds = binding.knownRootSessionIds;
@@ -1560,6 +1632,11 @@ export function __getOpenCodeDiagnosticLogPathForTests(): string {
1560
1632
  return diagnosticLogPath(state);
1561
1633
  }
1562
1634
 
1635
+ export function __getOpenCodeBindingPathForTests(): string {
1636
+ if (!state) throw new Error('OpenCode drone is not connected');
1637
+ return bindingPath();
1638
+ }
1639
+
1563
1640
  export function __getOpenCodeLastObservationForTests(): OpenCodeLastObservation {
1564
1641
  if (!state) throw new Error('OpenCode drone is not connected');
1565
1642
  return { ...state.lastObservation };
@@ -1649,6 +1726,11 @@ export function __resetOpenCodeDroneForTests(): void {
1649
1726
  } catch {
1650
1727
  // Already removed.
1651
1728
  }
1729
+ try {
1730
+ unlinkSync(`${path}.claim`);
1731
+ } catch {
1732
+ // Already removed.
1733
+ }
1652
1734
  }
1653
1735
  bindingPathsForTests.clear();
1654
1736
  for (const path of diagnosticLogPathsForTests) {
@@ -44,3 +44,8 @@ export function openCodeApiPasswordFromEnv(env: NodeJS.ProcessEnv): string | nul
44
44
  const password = env[OPENCODE_SERVER_PASSWORD_ENV];
45
45
  return isOpenCode256BitIdentity(password) ? password : null;
46
46
  }
47
+
48
+ export function openCodeLaunchCorrelationFromEnv(env: NodeJS.ProcessEnv): string | null {
49
+ const identity = env[BORG_OPENCODE_LAUNCH_CORRELATION_ENV];
50
+ return isOpenCode256BitIdentity(identity) ? identity : null;
51
+ }
@@ -13,6 +13,7 @@ import { LAUNCH_ALL_NO_DISPATCH_EXIT_CODE, runLaunchAll } from './launch-all-cmd
13
13
  import type { QuickstartArgs } from './parse-quickstart-args.js';
14
14
  import { roleSlug, type Role } from './role-resolver.js';
15
15
  import { DEFAULT_LOCAL_SERVER_ORIGIN } from './server-handshake.js';
16
+ import { repositoryDiscoveryFailureMessage } from './repository-identity.js';
16
17
 
17
18
  interface PlannedRole {
18
19
  name: string;
@@ -178,8 +179,15 @@ export async function runQuickstart(
178
179
  let context;
179
180
  try {
180
181
  context = await assimilate.resolveRepositoryContext(assimilate.cwd());
181
- } catch {
182
- context = null;
182
+ } catch (error) {
183
+ if (error instanceof Error && error.message === 'BARE_REPOSITORY') {
184
+ context = null;
185
+ } else {
186
+ deps.stderr(
187
+ `borg quickstart: could not inspect this Git repository: ${repositoryDiscoveryFailureMessage(error)}\n`,
188
+ );
189
+ return 1;
190
+ }
183
191
  }
184
192
  if (!context) {
185
193
  deps.stderr('borg quickstart: run this command inside a non-bare Git repository.\n');
@@ -21,6 +21,7 @@ import { formatDocumentCitations } from './document-render.js';
21
21
  import { shellEscape } from './shell-escape.js';
22
22
  import { OPENCODE_WAKE_PATH_GUIDANCE } from './opencode-wake-copy.js';
23
23
  import { isBorgSession } from './launch-gate.js';
24
+ import type { AgentKind } from './agent-runtime.js';
24
25
 
25
26
  /**
26
27
  * Extract the SessionStart `source` from a Claude Code hook payload (gh#926).
@@ -72,9 +73,6 @@ export function shouldRelayPlainSessionReminder(args: {
72
73
  );
73
74
  }
74
75
 
75
- /** The agent runtime a session runs under — drives the wake-path branch. */
76
- export type AgentKind = 'claude' | 'codex' | 'opencode';
77
-
78
76
  /**
79
77
  * The agent-branched WAKE-PATH ARMING sub-block (gh#929/gh#927) — the single
80
78
  * shared "re-establish your wake path" instruction reused by the launch
package/src/regen.ts CHANGED
@@ -24,10 +24,9 @@ import {
24
24
  formatLeanOrientation,
25
25
  formatPlainSessionReminder,
26
26
  shouldRelayPlainSessionReminder,
27
- type AgentKind,
28
27
  } from './regen-format.js';
29
28
  import { resolveSessionAgentKind } from './codex-app-wake.js';
30
- import { resolveReportableSessionAgentKind } from './agent-runtime.js';
29
+ import { resolveReportableSessionAgentKind, type AgentKind } from './agent-runtime.js';
31
30
  import { handleVersionFlag } from './version.js';
32
31
  import {
33
32
  BORG_LAUNCH_REMINDER_DISABLED_ENV,
@@ -177,7 +176,7 @@ async function emitUnassimilatedNotice(): Promise<void> {
177
176
  lines.push('');
178
177
  lines.push('## Tools you can call once the user has chosen');
179
178
  lines.push('- Join an existing cube: `borg_assimilate cube_name="<their choice>"`');
180
- lines.push('- Create a new cube: `borg_create-cube name="<name>" cube_directive="<markdown>"` (optionally `template="software-dev"`)');
179
+ lines.push('- Create a new cube: `borg_create-cube name="<name>" cube_directive="<markdown>"` (defaults to `template="software-dev"`)');
181
180
  lines.push('- See available templates: `borg_list-templates`');
182
181
  process.stdout.write(lines.join('\n') + '\n');
183
182
  }
@@ -60,7 +60,6 @@ import {
60
60
  } from 'borgmcp-shared/protocol';
61
61
  import { Buffer } from 'node:buffer';
62
62
  import { canonicalizeWorkingRepoIdentity } from './working-repo.js';
63
- import { consolePrefix } from './console-prefix.js';
64
63
  import { debugLog } from './debug.js';
65
64
  import { assertUuidShape } from './evict-drone.js';
66
65
  import {
@@ -70,7 +69,11 @@ import {
70
69
  DRONE_EVICTED_CODE,
71
70
  } from './drone-lifecycle.js';
72
71
  import type { MessageTaxonomy, MessageTaxonomyClass } from 'borgmcp-shared/templates';
73
- import { getTemplate, type Template, type TemplateRole } from 'borgmcp-shared/templates';
72
+ import {
73
+ NEW_CUBE_TEMPLATE_PRESENTATIONS,
74
+ getTemplate,
75
+ type TemplateRole,
76
+ } from 'borgmcp-shared/templates';
74
77
  import { parseRoleSections } from 'borgmcp-shared/role-section';
75
78
  import type { FragmentView, NonClobberSyncResult } from './sync-roles-render.js';
76
79
  import type { WorkingRepo } from './working-repo.js';
@@ -1521,10 +1524,8 @@ export async function listCubes(connection?: RemoteConnection): Promise<{ cubes:
1521
1524
  * to surface the interactive template prompt on first-drone bootstrap.
1522
1525
  */
1523
1526
  /**
1524
- * Create a new cube. Server-side seeds a default "Drone" role atomically
1525
- * so the cube is assimilatable immediately, OR applies the named template
1526
- * atomically when `opts.template` is set (single-withUserId transaction —
1527
- * skips the auto-Drone insert to avoid is_default partial-index conflict).
1527
+ * Create a new cube. Server-side seeds the selected named template atomically
1528
+ * so the cube is assimilatable immediately.
1528
1529
  *
1529
1530
  * Returns `{ cube, roles }` — the roles array lets the assimilate
1530
1531
  * orchestrator pick a default role without a follow-up `getCube` call.
@@ -1612,8 +1613,10 @@ export async function createCube(
1612
1613
  connection?: RemoteConnection,
1613
1614
  ): Promise<{ result: 'created' | 'resolved'; cube: { id: string; name: string; cube_directive?: string; roles: any[]; drones?: any[]; [k: string]: any } }> {
1614
1615
  if (!name?.trim()) throw new Error('Local Borg server cube creation requires a cube name');
1615
- if (opts?.template !== undefined && opts.template !== 'default') {
1616
- throw new Error('Local Borg server supports only the default cube seed');
1616
+ const templateName = opts?.template ?? NEW_CUBE_TEMPLATE_PRESENTATIONS[0].name;
1617
+ const template = getTemplate(templateName);
1618
+ if (!template) {
1619
+ throw new Error(`Unknown template "${templateName}". Available: ${NEW_CUBE_TEMPLATE_PRESENTATIONS.map(({ name }) => name).join(', ')}`);
1617
1620
  }
1618
1621
  if (!opts?.repository || !opts?.workingRepoName) {
1619
1622
  throw new Error('Local Borg server cube creation requires an explicit repository identity');
@@ -1628,7 +1631,7 @@ export async function createCube(
1628
1631
  name: name.trim(),
1629
1632
  working_repo_name: opts.workingRepoName,
1630
1633
  repository: opts.repository,
1631
- template: 'default',
1634
+ template: template.name,
1632
1635
  }));
1633
1636
  // client#499: the server homes one cube per repository. A 'resolved' result
1634
1637
  // means this repository already has a cube — report it honestly and DO NOT
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  NEW_CUBE_TEMPLATE_PRESENTATIONS,
3
- LEGACY_DEFAULT_TEMPLATE_LABEL,
3
+ getTemplate,
4
4
  } from 'borgmcp-shared/templates';
5
5
  import type {
6
6
  AssociateRepositoryCubeResponse,
@@ -33,7 +33,7 @@ export interface RepositoryCubeCreation {
33
33
  cube: RepositoryCubeDetail;
34
34
  }
35
35
 
36
- type NewCubeTemplate = Exclude<CubeTemplate, 'default'>;
36
+ type NewCubeTemplate = CubeTemplate;
37
37
 
38
38
  const NEW_CUBE_TEMPLATE_NAMES: readonly NewCubeTemplate[] =
39
39
  NEW_CUBE_TEMPLATE_PRESENTATIONS.map(({ name }) => name);
@@ -110,10 +110,7 @@ export function validRepositoryCubeName(value: string): boolean {
110
110
  }
111
111
 
112
112
  function presentation(template: CubeTemplate) {
113
- if (template === 'default') {
114
- return { name: template, label: LEGACY_DEFAULT_TEMPLATE_LABEL };
115
- }
116
- return NEW_CUBE_TEMPLATE_PRESENTATIONS.find((candidate) => candidate.name === template)!;
113
+ return getTemplate(template)!;
117
114
  }
118
115
 
119
116
  async function ask(