borgmcp 2.7.1 → 2.7.2

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 (41) hide show
  1. package/README.md +15 -10
  2. package/dist/assimilate-cmd.d.ts.map +1 -1
  3. package/dist/assimilate-cmd.js +72 -24
  4. package/dist/assimilate-cmd.js.map +1 -1
  5. package/dist/cubes.d.ts +10 -5
  6. package/dist/cubes.d.ts.map +1 -1
  7. package/dist/cubes.js +54 -3
  8. package/dist/cubes.js.map +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +60 -7
  11. package/dist/index.js.map +1 -1
  12. package/dist/log-stream.d.ts +3 -0
  13. package/dist/log-stream.d.ts.map +1 -1
  14. package/dist/log-stream.js +6 -1
  15. package/dist/log-stream.js.map +1 -1
  16. package/dist/opencode-seat-identity.d.ts +22 -0
  17. package/dist/opencode-seat-identity.d.ts.map +1 -0
  18. package/dist/opencode-seat-identity.js +69 -0
  19. package/dist/opencode-seat-identity.js.map +1 -0
  20. package/dist/startup-services.d.ts +3 -1
  21. package/dist/startup-services.d.ts.map +1 -1
  22. package/dist/startup-services.js +7 -2
  23. package/dist/startup-services.js.map +1 -1
  24. package/dist/stream-owner.d.ts +9 -0
  25. package/dist/stream-owner.d.ts.map +1 -1
  26. package/dist/stream-owner.js +14 -2
  27. package/dist/stream-owner.js.map +1 -1
  28. package/dist/stream-status.d.ts.map +1 -1
  29. package/dist/stream-status.js +8 -1
  30. package/dist/stream-status.js.map +1 -1
  31. package/docs/EXTRACTION_PROVENANCE.md +3 -3
  32. package/docs/RELEASING.md +6 -1
  33. package/package.json +1 -1
  34. package/src/assimilate-cmd.ts +81 -24
  35. package/src/cubes.ts +70 -3
  36. package/src/index.ts +66 -7
  37. package/src/log-stream.ts +9 -1
  38. package/src/opencode-seat-identity.ts +111 -0
  39. package/src/startup-services.ts +8 -2
  40. package/src/stream-owner.ts +23 -2
  41. package/src/stream-status.ts +8 -1
package/docs/RELEASING.md CHANGED
@@ -158,8 +158,13 @@ Never move, replace, reuse, or rerun that tag or workflow. The annotated `v2.7.0
158
158
  `a8d3c572dee3b90cf2a898768c45a1f46270fe11`. Workflow run `30440435955`, attempt 1,
159
159
  successfully published that exact source as `borgmcp@2.7.0`; the same-run artifact report records integrity
160
160
  `sha512-qn7ljuq/UwYzQJ5pWliLqD7Q5mlwk287ObOXbSV5FOcs+JdoXjFlb9NVC6bFoQzjHaWixCX600EAEtRZ0UeNPw==`.
161
+ Never move, replace, reuse, or rerun that tag or workflow. The annotated `v2.7.1` tag object
162
+ `fc308b52393edf0ed8299490980207840a69c157` peels to protected-main commit
163
+ `41091978bfe02831863de26e75fdf3bb48d4b038`. Workflow run `30463299198`, attempt 1,
164
+ successfully published that exact source as `borgmcp@2.7.1`; the same-run artifact report records integrity
165
+ `sha512-hgxKTCAocbTsBV/YHIegn/jlG9a9YwdbOHD3s5mL+CsN61/5m180rvGJ8+RlNRd5Sdk408phc+QW2Li46tmTMg==`.
161
166
  Never move, replace, reuse, or rerun that tag or workflow. The next candidate
162
- uses the unused `v2.7.1` identity from a fresh reviewed protected-main commit
167
+ uses the unused `v2.7.2` identity from a fresh reviewed protected-main commit
163
168
  and requires the complete release gate again.
164
169
 
165
170
  ## Release Prerequisites
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borgmcp",
3
- "version": "2.7.1",
3
+ "version": "2.7.2",
4
4
  "description": "Coordinate AI coding agents in shared cubes. Works with Claude Code, Codex, and OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1456,7 +1456,10 @@ export async function runAssimilate(
1456
1456
  }
1457
1457
  }
1458
1458
 
1459
- const repoBase = basename(projectRoot);
1459
+ // The common Git directory identifies the repository across every linked
1460
+ // worktree. Using projectRoot here fragments one repository's siblings when
1461
+ // assimilation starts inside an existing sibling worktree.
1462
+ const repoBase = basename(dirname(repositoryContext.commonDir));
1460
1463
  const suffix = args.flags.worktree ?? roleSlug(assignedRole.name);
1461
1464
  // gh#556 Part 1: empty-suffix guard (CR-binding). roleSlug can yield '' for a
1462
1465
  // pathological all-special-char role name; an empty leaf would let join() collapse
@@ -1473,6 +1476,15 @@ export async function runAssimilate(
1473
1476
  // (was a sibling <parent>/<repo>-<name>). Existing siblings are untouched
1474
1477
  // (absolute git-registered paths). Collision dedup KEPT (<name>-<n>).
1475
1478
  const homeDir = deps.homedir();
1479
+ let registeredWorktrees = listRegisteredWorktrees(deps, projectRoot);
1480
+ if (registeredWorktrees === null) {
1481
+ deps.stderr(
1482
+ 'Borg could not enumerate this repository’s existing worktrees, so it did not risk creating a colliding sibling.\n' +
1483
+ 'Run `git worktree list` from this repository and resolve the reported Git error, then rerun `borg assimilate`.\n' +
1484
+ 'A seat was reserved and remains pending; rerunning after fixing the worktree issue resumes that seat.\n',
1485
+ );
1486
+ return 1;
1487
+ }
1476
1488
  let candidate = computeWorktreePath(homeDir, repoBase, suffix);
1477
1489
  let wtBranch = perWorktreeBranchName(basename(candidate), repoBase);
1478
1490
  let n = 2;
@@ -1485,7 +1497,8 @@ export async function runAssimilate(
1485
1497
  // fresh suffix so we never reuse/clobber its work.
1486
1498
  while (
1487
1499
  deps.pathExists(candidate) ||
1488
- worktreeRegistered(deps, projectRoot, candidate) ||
1500
+ registeredWorktrees.names.has(basename(candidate)) ||
1501
+ registeredWorktrees.branches.has(wtBranch) ||
1489
1502
  (localBranchExists(deps.runSync, projectRoot, wtBranch) &&
1490
1503
  !isMerged(deps.runSync, projectRoot, wtBranch, startRef))
1491
1504
  ) {
@@ -1493,26 +1506,56 @@ export async function runAssimilate(
1493
1506
  wtBranch = perWorktreeBranchName(basename(candidate), repoBase);
1494
1507
  n++;
1495
1508
  }
1496
- // gh#556 Part 1: create the intermediate ~/.borg/worktrees/<repo>/ before
1497
- // `git worktree add` (git creates the leaf, not the parent chain). Plain
1498
- // recursive mkdir — NO chmod of the existing ~/.borg (credentials file).
1499
- deps.mkdirp(dirname(candidate));
1500
- // gh#33 (Q1/Q4): spawn on a named per-worktree branch (wt-<suffix>),
1501
- // NOT detached HEAD. The named branch is current with startRef and is
1502
- // where the drone's feature branches get cut from. Uniform for every
1503
- // role incl. coordinator — main is never a working branch (Q4).
1504
- // Branch naming is UNAFFECTED by the relocation: perWorktreeBranchName's
1505
- // as-is else-branch maps the new basename <suffix> → wt-<suffix> (== old).
1506
- //
1507
- // gh#864: if <wtBranch> already exists here it is MERGED (the loop bumped
1508
- // past any unmerged ref), so ADOPT it — `git worktree add <path> <branch>`
1509
- // (no -b) attaches the merged branch instead of failing on a create-
1510
- // collision. A fresh suffix has no ref create it at startRef with -b.
1511
- const wt = localBranchExists(deps.runSync, projectRoot, wtBranch)
1512
- ? deps.runSync('git', ['worktree', 'add', candidate, wtBranch], projectRoot)
1513
- : deps.runSync('git', ['worktree', 'add', '-b', wtBranch, candidate, startRef], projectRoot);
1509
+ let wt: ReturnType<AssimilateDeps['runSync']>;
1510
+ let residualBranch: string | null = null;
1511
+ while (true) {
1512
+ // gh#556 Part 1: create the intermediate ~/.borg/worktrees/<repo>/ before
1513
+ // `git worktree add` (git creates the leaf, not the parent chain). Plain
1514
+ // recursive mkdir NO chmod of the existing ~/.borg (credentials file).
1515
+ deps.mkdirp(dirname(candidate));
1516
+ const branchExisted = localBranchExists(deps.runSync, projectRoot, wtBranch);
1517
+ wt = branchExisted
1518
+ ? deps.runSync('git', ['worktree', 'add', candidate, wtBranch], projectRoot)
1519
+ : deps.runSync('git', ['worktree', 'add', '-b', wtBranch, candidate, startRef], projectRoot);
1520
+ if (wt.status === 0) break;
1521
+
1522
+ // Another assimilate may claim the name or branch after our first list.
1523
+ // Refresh and suffix-bump instead of surfacing a collision to the operator.
1524
+ const refreshed = listRegisteredWorktrees(deps, projectRoot);
1525
+ const branchAppeared = !branchExisted && localBranchExists(deps.runSync, projectRoot, wtBranch);
1526
+ const collision =
1527
+ deps.pathExists(candidate) ||
1528
+ refreshed?.names.has(basename(candidate)) === true ||
1529
+ refreshed?.branches.has(wtBranch) === true ||
1530
+ (!branchExisted && worktreeAddReportedCollision(wt.stderr));
1531
+ if (!collision || refreshed === null) {
1532
+ if (branchAppeared && refreshed?.branches.has(wtBranch) !== true) {
1533
+ residualBranch = wtBranch;
1534
+ }
1535
+ break;
1536
+ }
1537
+ registeredWorktrees = refreshed;
1538
+ do {
1539
+ candidate = computeWorktreePath(homeDir, repoBase, suffix, n);
1540
+ wtBranch = perWorktreeBranchName(basename(candidate), repoBase);
1541
+ n++;
1542
+ } while (
1543
+ deps.pathExists(candidate) ||
1544
+ registeredWorktrees.names.has(basename(candidate)) ||
1545
+ registeredWorktrees.branches.has(wtBranch) ||
1546
+ localBranchExists(deps.runSync, projectRoot, wtBranch)
1547
+ );
1548
+ }
1514
1549
  if (wt.status !== 0) {
1515
- deps.stderr(`git worktree add failed: ${safeStderr(wt.stderr)}\n`);
1550
+ deps.stderr(
1551
+ `Borg could not create sibling worktree ${candidate} on branch ${wtBranch}. ` +
1552
+ `Git reported: ${safeStderr(wt.stderr)}\n` +
1553
+ (residualBranch
1554
+ ? `Git left branch ${residualBranch} without a registered worktree; Borg preserved it.\n`
1555
+ : '') +
1556
+ 'Run `git worktree list` and `git status` to inspect repository state, resolve the reported Git error, then rerun `borg assimilate`.\n' +
1557
+ 'A seat was reserved and remains pending; rerunning after fixing the worktree issue resumes that seat.\n',
1558
+ );
1516
1559
  return 1;
1517
1560
  }
1518
1561
  deps.stderr(
@@ -1958,10 +2001,24 @@ export function safeStderr(msg: string): string {
1958
2001
  return msg.replace(/[\x00-\x1F\x7F]/g, '');
1959
2002
  }
1960
2003
 
1961
- function worktreeRegistered(deps: AssimilateDeps, projectRoot: string, candidate: string): boolean {
2004
+ function worktreeAddReportedCollision(stderr: string): boolean {
2005
+ const message = safeStderr(stderr);
2006
+ return /(?:branch named .* already exists|already checked out|already registered|already exists at)/i.test(message);
2007
+ }
2008
+
2009
+ function listRegisteredWorktrees(
2010
+ deps: AssimilateDeps,
2011
+ projectRoot: string,
2012
+ ): { names: Set<string>; branches: Set<string> } | null {
1962
2013
  const res = deps.runSync('git', ['worktree', 'list', '--porcelain'], projectRoot);
1963
- if (res.status !== 0) return false;
1964
- return res.stdout.split('\n').some((line) => line === `worktree ${candidate}`);
2014
+ if (res.status !== 0) return null;
2015
+ const names = new Set<string>();
2016
+ const branches = new Set<string>();
2017
+ for (const line of res.stdout.split('\n')) {
2018
+ if (line.startsWith('worktree ')) names.add(basename(line.slice('worktree '.length)));
2019
+ if (line.startsWith('branch refs/heads/')) branches.add(line.slice('branch refs/heads/'.length));
2020
+ }
2021
+ return { names, branches };
1965
2022
  }
1966
2023
 
1967
2024
  /**
package/src/cubes.ts CHANGED
@@ -72,6 +72,8 @@ export interface ActiveCube {
72
72
  roleName?: string;
73
73
  roleClass?: 'queen' | 'worker';
74
74
  isHumanSeat?: boolean;
75
+ /** Canonical worktree bound to this exact durable seat. */
76
+ worktree?: string;
75
77
  }
76
78
 
77
79
  export type ActiveCubeInput = Omit<ActiveCube, 'sessionToken'> & {
@@ -97,7 +99,53 @@ interface CodexWakeTargetsFile {
97
99
  * directory. If not found by filesystem root, return the original cwd.
98
100
  * The returned absolute path is the "project key" used to scope cube state.
99
101
  */
100
- export function findProjectRoot(cwd: string = process.cwd()): string {
102
+ let pinnedMcpProjectRoot: string | null = null;
103
+ let pinnedMcpSeatIdentity: {
104
+ worktree: string;
105
+ cubeId: string;
106
+ droneId: string;
107
+ credentialRef?: string;
108
+ } | null = null;
109
+
110
+ export class McpSeatIdentityChangedError extends Error {
111
+ readonly code = 'SEAT_IDENTITY_CHANGED';
112
+ constructor() {
113
+ super('The saved Borg seat changed after this MCP session pinned its identity. Exit this session and relaunch from the intended worktree.');
114
+ this.name = 'McpSeatIdentityChangedError';
115
+ }
116
+ }
117
+
118
+ export function pinMcpProjectRoot(worktree: string): void {
119
+ const normalized = resolve(worktree);
120
+ if (pinnedMcpProjectRoot !== null && pinnedMcpProjectRoot !== normalized) {
121
+ throw new Error(`Borg MCP session project root is already pinned to ${pinnedMcpProjectRoot}`);
122
+ }
123
+ pinnedMcpProjectRoot = normalized;
124
+ }
125
+
126
+ export function pinMcpSeatIdentity(active: ActiveCube): void {
127
+ if (!active.worktree) throw new McpSeatIdentityChangedError();
128
+ pinMcpProjectRoot(active.worktree);
129
+ const next = {
130
+ worktree: resolve(active.worktree),
131
+ cubeId: active.cubeId,
132
+ droneId: active.droneId,
133
+ ...(active.localSessionCredentialRef
134
+ ? { credentialRef: active.localSessionCredentialRef }
135
+ : {}),
136
+ };
137
+ if (pinnedMcpSeatIdentity && (
138
+ pinnedMcpSeatIdentity.worktree !== next.worktree ||
139
+ pinnedMcpSeatIdentity.cubeId !== next.cubeId ||
140
+ pinnedMcpSeatIdentity.droneId !== next.droneId ||
141
+ pinnedMcpSeatIdentity.credentialRef !== next.credentialRef
142
+ )) {
143
+ throw new McpSeatIdentityChangedError();
144
+ }
145
+ pinnedMcpSeatIdentity = next;
146
+ }
147
+
148
+ export function findProjectRoot(cwd: string = pinnedMcpProjectRoot ?? process.cwd()): string {
101
149
  let dir = resolve(cwd);
102
150
  while (true) {
103
151
  if (existsSync(join(dir, '.git'))) return dir;
@@ -244,9 +292,22 @@ async function writeCodexWakeTargetsFile(data: CodexWakeTargetsFile): Promise<vo
244
292
  * refresh.
245
293
  */
246
294
  export async function getActiveCube(): Promise<ActiveCube | null> {
247
- const record = await getActiveSeatForWorktree(findProjectRoot());
295
+ return getActiveCubeForWorktree(findProjectRoot());
296
+ }
297
+
298
+ export async function getActiveCubeForWorktree(worktree: string): Promise<ActiveCube | null> {
299
+ const record = await getActiveSeatForWorktree(findProjectRoot(worktree));
248
300
  if (!record || !record.cubeId || !record.droneId) return null;
249
- return hydrateActiveCube(record);
301
+ const active = await hydrateActiveCube(record);
302
+ if (active && pinnedMcpSeatIdentity && (
303
+ resolve(active.worktree ?? '') !== pinnedMcpSeatIdentity.worktree ||
304
+ active.cubeId !== pinnedMcpSeatIdentity.cubeId ||
305
+ active.droneId !== pinnedMcpSeatIdentity.droneId ||
306
+ active.localSessionCredentialRef !== pinnedMcpSeatIdentity.credentialRef
307
+ )) {
308
+ throw new McpSeatIdentityChangedError();
309
+ }
310
+ return active;
250
311
  }
251
312
 
252
313
  /**
@@ -282,12 +343,18 @@ async function hydrateActiveCube(record: SeatRecord): Promise<ActiveCube | null>
282
343
  serverTrustIdentity: record.trustIdentity,
283
344
  localSessionCredentialRef: ref,
284
345
  operation: record.operation,
346
+ worktree: record.worktree,
285
347
  ...(record.roleName !== undefined ? { roleName: record.roleName } : {}),
286
348
  ...(record.roleClass !== undefined ? { roleClass: record.roleClass } : {}),
287
349
  ...(record.isHumanSeat !== undefined ? { isHumanSeat: record.isHumanSeat } : {}),
288
350
  };
289
351
  }
290
352
 
353
+ export function __resetPinnedMcpProjectRootForTests(): void {
354
+ pinnedMcpProjectRoot = null;
355
+ pinnedMcpSeatIdentity = null;
356
+ }
357
+
291
358
  /**
292
359
  * Token-free lookup used after an offline reset. A surviving seat is only
293
360
  * described as saved local state; the caller must still revalidate it with the
package/src/index.ts CHANGED
@@ -64,9 +64,11 @@ import {
64
64
  import {
65
65
  activeCubeWithFreshRegenIdentity,
66
66
  getActiveCube,
67
+ getActiveCubeForWorktree,
67
68
  refreshActiveCubeMetadata,
68
69
  findProjectRoot,
69
70
  inboxPathForDrone,
71
+ pinMcpSeatIdentity,
70
72
  } from './cubes.js';
71
73
  import { isEntryInvocation, monitorStateRootForWorktree } from './inbox-monitor.js';
72
74
  import { addSessionStartHook, addUserPromptSubmitHook } from './config-utils.js';
@@ -140,6 +142,11 @@ import {
140
142
  normalizeDirectLogRecipients,
141
143
  } from './direct-log.js';
142
144
  import { formatLocalManageToolResult } from './local-manage-tool-result.js';
145
+ import {
146
+ OpenCodeSeatIdentityError,
147
+ formatOpenCodeSeatIdentityError,
148
+ resolveOpenCodeSeatIdentity,
149
+ } from './opencode-seat-identity.js';
143
150
  import {
144
151
  runEvictDroneTool,
145
152
  runReassignDroneTool,
@@ -216,8 +223,8 @@ export async function main() {
216
223
  // installed client version.
217
224
  handleVersionFlag();
218
225
  const readinessProbe = isMcpReadinessProbe();
219
-
220
- await runMcpStartupServices(readinessProbe, {
226
+ const openCodeRuntime = resolveSessionAgentKind() === 'opencode';
227
+ const startupServices = {
221
228
  // Auto-register the SessionStart hook so existing users get borg-regen
222
229
  // auto-orientation on session start without re-running borg setup. Idempotent.
223
230
  sessionStartHook: () => {
@@ -255,19 +262,22 @@ export async function main() {
255
262
  openCode: async () => {
256
263
  installBorgPlugin();
257
264
  const active = await getActiveCube();
258
- if (active && process.env.BORG_OPENCODE === '1') {
265
+ if (active && openCodeRuntime) {
259
266
  const port = computeOpenCodePort(active.droneId);
260
267
  const serverUrl = `http://127.0.0.1:${port}`;
261
268
  await connectOpenCodeDrone({
262
269
  serverUrl,
263
- directory: process.cwd(),
270
+ directory: active.worktree ?? findProjectRoot(),
264
271
  droneLabel: active.droneLabel,
265
272
  cubeName: active.name,
266
273
  });
267
274
  setModuleInjectOpenCode(injectOpenCodeEntry);
268
275
  }
269
276
  },
270
- });
277
+ };
278
+ // Claude and Codex retain their existing pre-handshake startup behavior.
279
+ // OpenCode must first obtain the session-scoped MCP root from its client.
280
+ if (!openCodeRuntime) await runMcpStartupServices(readinessProbe, startupServices);
271
281
 
272
282
  // Create MCP server. `version` is the installed borgmcp version
273
283
  // (T1.4 of 0.6.0): read at runtime from package.json so Claude
@@ -286,6 +296,16 @@ export async function main() {
286
296
  }
287
297
  );
288
298
 
299
+ let openCodeIdentityFailure: OpenCodeSeatIdentityError | null = null;
300
+ let finishOpenCodeIdentity: (() => void) | null = null;
301
+ const openCodeIdentityReady = openCodeRuntime && !readinessProbe
302
+ ? new Promise<void>((resolveIdentity) => { finishOpenCodeIdentity = resolveIdentity; })
303
+ : null;
304
+ const waitForOpenCodeIdentity = async (): Promise<OpenCodeSeatIdentityError | null> => {
305
+ if (openCodeIdentityReady) await openCodeIdentityReady;
306
+ return openCodeIdentityFailure;
307
+ };
308
+
289
309
  // gh#899: tool definitions built once at setup, then role-scoped per caller
290
310
  // in the ListTools handler below (the dispatcher reaches deferred tools).
291
311
  const allToolDefs: ToolManifestEntry[] = TOOL_MANIFEST;
@@ -294,6 +314,7 @@ export async function main() {
294
314
  // (old cubes.json / pre-assimilate) → full set; deferred tools stay reachable
295
315
  // via borg_tool. Never an auth boundary — live per-client cube grants govern.
296
316
  server.setRequestHandler(ListToolsRequestSchema, async () => {
317
+ await waitForOpenCodeIdentity();
297
318
  let scope: { roleName?: string; roleClass?: 'queen' | 'worker'; isHumanSeat?: boolean } | null = null;
298
319
  try {
299
320
  const active = await getActiveCube();
@@ -310,6 +331,17 @@ export async function main() {
310
331
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
311
332
  let { name, arguments: args } = request.params;
312
333
 
334
+ const identityFailure = await waitForOpenCodeIdentity();
335
+ if (identityFailure) {
336
+ return {
337
+ content: [{
338
+ type: 'text',
339
+ text: formatOpenCodeSeatIdentityError(identityFailure, process.cwd()),
340
+ }],
341
+ isError: true,
342
+ };
343
+ }
344
+
313
345
  // gh#899: borg_describe-tool — schema-only, NEVER executes. Returns the
314
346
  // named tool's def from allToolDefs so a role-scoped session can learn a
315
347
  // deferred tool's arguments before invoking it via borg_tool.
@@ -386,7 +418,7 @@ export async function main() {
386
418
  since,
387
419
  reportedModel,
388
420
  agentKind: resolveReportableSessionAgentKind(),
389
- workingRepo: resolveWorkingRepo(),
421
+ workingRepo: resolveWorkingRepo(active.worktree),
390
422
  serverTrustIdentity: active.serverTrustIdentity,
391
423
  });
392
424
  } catch (error) {
@@ -467,7 +499,7 @@ export async function main() {
467
499
  seedDisplayIdentity(active!);
468
500
  const result = await regen(active!.sessionToken, active!.apiUrl, {
469
501
  agentKind: resolveReportableSessionAgentKind(),
470
- workingRepo: resolveWorkingRepo(),
502
+ workingRepo: resolveWorkingRepo(active!.worktree),
471
503
  serverTrustIdentity: active!.serverTrustIdentity,
472
504
  });
473
505
  const displayIdentity = confirmDisplayIdentity(active!, identityFromRegen(result));
@@ -1259,9 +1291,36 @@ export async function main() {
1259
1291
  // Create stdio transport
1260
1292
  const transport = new StdioServerTransport();
1261
1293
 
1294
+ if (openCodeIdentityReady) {
1295
+ server.oninitialized = () => {
1296
+ void resolveOpenCodeSeatIdentity({
1297
+ listRoots: () => server.listRoots(),
1298
+ findProjectRoot,
1299
+ getActiveCubeForWorktree,
1300
+ pinSeatIdentity: pinMcpSeatIdentity,
1301
+ childCwd: process.cwd(),
1302
+ }).catch((error) => {
1303
+ openCodeIdentityFailure = error instanceof OpenCodeSeatIdentityError
1304
+ ? error
1305
+ : new OpenCodeSeatIdentityError('ROOTS_UNAVAILABLE', String(error));
1306
+ }).finally(() => {
1307
+ finishOpenCodeIdentity?.();
1308
+ });
1309
+ };
1310
+ }
1311
+
1262
1312
  // Connect server to transport
1263
1313
  await server.connect(transport);
1264
1314
 
1315
+ if (openCodeIdentityReady) {
1316
+ await openCodeIdentityReady;
1317
+ if (openCodeIdentityFailure) {
1318
+ console.error(formatOpenCodeSeatIdentityError(openCodeIdentityFailure, process.cwd()));
1319
+ } else {
1320
+ await runMcpStartupServices(false, startupServices, { openCodeFirst: true });
1321
+ }
1322
+ }
1323
+
1265
1324
  // Resolve drone self-identification prefix before any console output
1266
1325
  // (gh#25). Falls back to `[unassimilated · <repo>]` if no cube cached.
1267
1326
  await initConsolePrefix();
package/src/log-stream.ts CHANGED
@@ -496,7 +496,12 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
496
496
  active.cubeId,
497
497
  active.droneId,
498
498
  STREAM_OWNER_STALE_MS,
499
- { isPidAlive: isProcessAlive },
499
+ {
500
+ isPidAlive: isProcessAlive,
501
+ ...(active.worktree ? { worktree: active.worktree } : {}),
502
+ ...(active.droneLabel ? { droneLabel: active.droneLabel } : {}),
503
+ ...(active.name ? { cubeName: active.name } : {}),
504
+ },
500
505
  );
501
506
  leaseKey = lease ? nextLeaseKey : null;
502
507
  }
@@ -642,6 +647,9 @@ export interface ActiveCube {
642
647
  serverTrustIdentity?: string;
643
648
  localSessionCredentialRef?: string;
644
649
  localSessionExpiresAt?: string | null;
650
+ worktree?: string;
651
+ droneLabel?: string;
652
+ name?: string;
645
653
  }
646
654
 
647
655
  export async function streamOnce(
@@ -0,0 +1,111 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { resolve } from 'node:path';
3
+ import type { ActiveCube } from './cubes.js';
4
+
5
+ export type OpenCodeSeatIdentityErrorCode =
6
+ | 'ROOTS_UNAVAILABLE'
7
+ | 'ROOTS_INVALID'
8
+ | 'SEAT_NOT_FOUND'
9
+ | 'SEAT_WORKTREE_MISMATCH';
10
+
11
+ export class OpenCodeSeatIdentityError extends Error {
12
+ constructor(
13
+ public readonly code: OpenCodeSeatIdentityErrorCode,
14
+ message: string,
15
+ public readonly sessionDirectory?: string,
16
+ public readonly seat?: Pick<ActiveCube, 'droneLabel' | 'worktree'>,
17
+ ) {
18
+ super(message);
19
+ this.name = 'OpenCodeSeatIdentityError';
20
+ }
21
+ }
22
+
23
+ export interface OpenCodeSeatIdentityDeps {
24
+ listRoots: () => Promise<{ roots?: Array<{ uri?: string }> }>;
25
+ findProjectRoot: (directory: string) => string;
26
+ getActiveCubeForWorktree: (worktree: string) => Promise<ActiveCube | null>;
27
+ pinSeatIdentity: (active: ActiveCube) => void;
28
+ childCwd: string;
29
+ }
30
+
31
+ export async function resolveOpenCodeSeatIdentity(
32
+ deps: OpenCodeSeatIdentityDeps,
33
+ ): Promise<ActiveCube> {
34
+ let roots: { roots?: Array<{ uri?: string }> };
35
+ try {
36
+ roots = await deps.listRoots();
37
+ } catch {
38
+ throw new OpenCodeSeatIdentityError(
39
+ 'ROOTS_UNAVAILABLE',
40
+ 'OpenCode did not provide its session directory.',
41
+ );
42
+ }
43
+ if (!Array.isArray(roots.roots) || roots.roots.length !== 1) {
44
+ throw new OpenCodeSeatIdentityError(
45
+ 'ROOTS_INVALID',
46
+ 'OpenCode must provide exactly one local session directory.',
47
+ );
48
+ }
49
+ const uri = roots.roots[0]?.uri;
50
+ let sessionDirectory: string;
51
+ try {
52
+ const parsed = new URL(typeof uri === 'string' ? uri : '');
53
+ if (parsed.protocol !== 'file:' || parsed.hostname || parsed.search || parsed.hash) throw new Error();
54
+ sessionDirectory = resolve(fileURLToPath(parsed));
55
+ } catch {
56
+ throw new OpenCodeSeatIdentityError(
57
+ 'ROOTS_INVALID',
58
+ 'OpenCode provided an invalid or non-local session directory.',
59
+ );
60
+ }
61
+
62
+ const sessionWorktree = deps.findProjectRoot(sessionDirectory);
63
+ const cwdWorktree = deps.findProjectRoot(deps.childCwd);
64
+ // Direct `borg` launch keeps cwd as its seat source. When a shared OpenCode
65
+ // server spawns the MCP child elsewhere, the session-scoped root is the
66
+ // launcher-conferred pin and replaces that ambient cwd inference.
67
+ const identityWorktree = cwdWorktree === sessionWorktree
68
+ ? cwdWorktree
69
+ : sessionWorktree;
70
+ const active = await deps.getActiveCubeForWorktree(identityWorktree);
71
+ if (!active) {
72
+ throw new OpenCodeSeatIdentityError(
73
+ 'SEAT_NOT_FOUND',
74
+ 'No active Borg seat is bound to the OpenCode session directory.',
75
+ sessionWorktree,
76
+ );
77
+ }
78
+ if (typeof active.worktree !== 'string' || resolve(active.worktree) !== resolve(sessionWorktree)) {
79
+ throw new OpenCodeSeatIdentityError(
80
+ 'SEAT_WORKTREE_MISMATCH',
81
+ 'The resolved Borg seat belongs to a different worktree than the OpenCode session.',
82
+ sessionWorktree,
83
+ active,
84
+ );
85
+ }
86
+
87
+ deps.pinSeatIdentity(active);
88
+ return active;
89
+ }
90
+
91
+ export function formatOpenCodeSeatIdentityError(
92
+ error: OpenCodeSeatIdentityError,
93
+ childCwd: string,
94
+ ): string {
95
+ const lines = [
96
+ `Borg OpenCode seat identity error [${error.code}]`,
97
+ '',
98
+ error.message,
99
+ `- OpenCode session directory: ${error.sessionDirectory ?? 'unavailable'}`,
100
+ `- Borg MCP child cwd: ${childCwd}`,
101
+ ];
102
+ if (error.seat) {
103
+ lines.push(`- Resolved seat: ${error.seat.droneLabel} (${error.seat.worktree})`);
104
+ }
105
+ lines.push(
106
+ '',
107
+ 'The Borg stream and OpenCode wake injection were not started.',
108
+ 'Exit this session and run `borg --cli opencode` from the intended worktree. If that worktree’s saved seat is stale, run `borg reset-local-seat` from that exact worktree before assimilating again.',
109
+ );
110
+ return lines.join('\n');
111
+ }
@@ -16,10 +16,16 @@ export interface McpStartupServices {
16
16
  */
17
17
  export async function runMcpStartupServices(
18
18
  readinessProbe: boolean,
19
- services: McpStartupServices
19
+ services: McpStartupServices,
20
+ options: { openCodeFirst?: boolean } = {},
20
21
  ): Promise<void> {
21
22
  if (readinessProbe) return;
22
- const tasks = [
23
+ const tasks = options.openCodeFirst ? [
24
+ services.sessionStartHook,
25
+ services.auditHook,
26
+ services.openCode,
27
+ services.sseStream,
28
+ ] : [
23
29
  services.sessionStartHook,
24
30
  services.auditHook,
25
31
  services.sseStream,
@@ -24,6 +24,9 @@ export interface StreamOwnerRecord {
24
24
  cwd: string;
25
25
  startedAt: string;
26
26
  heartbeatAt: string;
27
+ worktree?: string;
28
+ droneLabel?: string;
29
+ cubeName?: string;
27
30
  }
28
31
 
29
32
  export interface StreamOwnershipSnapshot {
@@ -33,6 +36,9 @@ export interface StreamOwnershipSnapshot {
33
36
  cwd?: string;
34
37
  startedAt?: string;
35
38
  heartbeatAt?: string;
39
+ worktree?: string;
40
+ droneLabel?: string;
41
+ cubeName?: string;
36
42
  ageMs?: number;
37
43
  lockPath?: string;
38
44
  /** Opened-directory identity used to bind inspection to later takeover. */
@@ -54,6 +60,9 @@ export interface StreamOwnerDeps {
54
60
  locksDir?: string;
55
61
  processNonce?: string;
56
62
  processStartedAt?: string;
63
+ worktree?: string;
64
+ droneLabel?: string;
65
+ cubeName?: string;
57
66
  isPidAlive?: (pid: number) => boolean;
58
67
  beforeTakeoverVerify?: (takeoverPath: string) => Promise<void>;
59
68
  beforeLeaseRefreshMutation?: (lockPath: string) => Promise<void>;
@@ -177,6 +186,9 @@ export async function readOwnershipSnapshot(
177
186
  cwd: parsed.cwd,
178
187
  startedAt: parsed.startedAt,
179
188
  heartbeatAt: parsed.heartbeatAt,
189
+ worktree: parsed.worktree,
190
+ droneLabel: parsed.droneLabel,
191
+ cubeName: parsed.cubeName,
180
192
  ageMs,
181
193
  lockPath,
182
194
  lockDev: lockStat.dev,
@@ -572,7 +584,10 @@ function sameOwner(left: StreamOwnerRecord, right: StreamOwnerRecord): boolean {
572
584
  left.processNonce === right.processNonce &&
573
585
  left.cwd === right.cwd &&
574
586
  left.startedAt === right.startedAt &&
575
- left.heartbeatAt === right.heartbeatAt;
587
+ left.heartbeatAt === right.heartbeatAt &&
588
+ left.worktree === right.worktree &&
589
+ left.droneLabel === right.droneLabel &&
590
+ left.cubeName === right.cubeName;
576
591
  }
577
592
 
578
593
  async function readOwnershipRecord(lockPath: string): Promise<StreamOwnerRecord | null> {
@@ -655,6 +670,9 @@ function makeRecord(deps: StreamOwnerDeps): StreamOwnerRecord {
655
670
  cwd: deps.cwd ?? process.cwd(),
656
671
  startedAt: deps.processStartedAt ?? processStartedAt,
657
672
  heartbeatAt: now().toISOString(),
673
+ ...(deps.worktree ? { worktree: deps.worktree } : {}),
674
+ ...(deps.droneLabel ? { droneLabel: deps.droneLabel } : {}),
675
+ ...(deps.cubeName ? { cubeName: deps.cubeName } : {}),
658
676
  };
659
677
  }
660
678
 
@@ -668,7 +686,10 @@ function isRecord(value: any): value is StreamOwnerRecord {
668
686
  isSafeLeaseText(value.processNonce, 128) &&
669
687
  isSafeLeaseText(value.cwd, 4096) &&
670
688
  isIsoTimestamp(value.startedAt) &&
671
- isIsoTimestamp(value.heartbeatAt)
689
+ isIsoTimestamp(value.heartbeatAt) &&
690
+ (value.worktree === undefined || isSafeLeaseText(value.worktree, 4096)) &&
691
+ (value.droneLabel === undefined || isSafeLeaseText(value.droneLabel, 256)) &&
692
+ (value.cubeName === undefined || isSafeLeaseText(value.cubeName, 256))
672
693
  );
673
694
  }
674
695