borgmcp 2.7.0 → 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.
- package/README.md +18 -13
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +87 -25
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +5 -2
- package/dist/cli-help.js.map +1 -1
- package/dist/cubes.d.ts +10 -5
- package/dist/cubes.d.ts.map +1 -1
- package/dist/cubes.js +54 -3
- package/dist/cubes.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +60 -7
- package/dist/index.js.map +1 -1
- package/dist/log-stream.d.ts +3 -0
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +6 -1
- package/dist/log-stream.js.map +1 -1
- package/dist/opencode-seat-identity.d.ts +22 -0
- package/dist/opencode-seat-identity.d.ts.map +1 -0
- package/dist/opencode-seat-identity.js +69 -0
- package/dist/opencode-seat-identity.js.map +1 -0
- package/dist/repository-cube-init.d.ts +3 -1
- package/dist/repository-cube-init.d.ts.map +1 -1
- package/dist/repository-cube-init.js +22 -11
- package/dist/repository-cube-init.js.map +1 -1
- package/dist/repository-identity.d.ts +1 -1
- package/dist/repository-identity.d.ts.map +1 -1
- package/dist/repository-identity.js +2 -1
- package/dist/repository-identity.js.map +1 -1
- package/dist/startup-services.d.ts +3 -1
- package/dist/startup-services.d.ts.map +1 -1
- package/dist/startup-services.js +7 -2
- package/dist/startup-services.js.map +1 -1
- package/dist/stream-owner.d.ts +9 -0
- package/dist/stream-owner.d.ts.map +1 -1
- package/dist/stream-owner.js +14 -2
- package/dist/stream-owner.js.map +1 -1
- package/dist/stream-status.d.ts.map +1 -1
- package/dist/stream-status.js +8 -1
- package/dist/stream-status.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +3 -3
- package/docs/LOCAL_SERVER.md +11 -9
- package/docs/RELEASING.md +11 -1
- package/package.json +1 -1
- package/src/assimilate-cmd.ts +95 -25
- package/src/cli-help.ts +7 -2
- package/src/cubes.ts +70 -3
- package/src/index.ts +66 -7
- package/src/log-stream.ts +9 -1
- package/src/opencode-seat-identity.ts +111 -0
- package/src/repository-cube-init.ts +27 -10
- package/src/repository-identity.ts +2 -2
- package/src/startup-services.ts +8 -2
- package/src/stream-owner.ts +23 -2
- package/src/stream-status.ts +8 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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:
|
|
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
|
-
{
|
|
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
|
+
}
|
|
@@ -33,6 +33,17 @@ export interface RepositoryCubeCreation {
|
|
|
33
33
|
cube: RepositoryCubeDetail;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
type NewCubeTemplate = Exclude<CubeTemplate, 'default'>;
|
|
37
|
+
|
|
38
|
+
const NEW_CUBE_TEMPLATE_NAMES: readonly NewCubeTemplate[] =
|
|
39
|
+
NEW_CUBE_TEMPLATE_PRESENTATIONS.map(({ name }) => name);
|
|
40
|
+
const NEW_CUBE_TEMPLATE_OPTIONS = NEW_CUBE_TEMPLATE_NAMES.join('|');
|
|
41
|
+
const NEW_CUBE_TEMPLATE_LIST = NEW_CUBE_TEMPLATE_NAMES.join(', ');
|
|
42
|
+
|
|
43
|
+
function parseNewCubeTemplate(value: string): NewCubeTemplate | undefined {
|
|
44
|
+
return NEW_CUBE_TEMPLATE_PRESENTATIONS.find(({ name }) => name === value)?.name;
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
export type RepositoryCubeResolution = ResolveRepositoryCubeResponse;
|
|
37
48
|
|
|
38
49
|
export interface RepositoryCubeInitFlags {
|
|
@@ -63,7 +74,7 @@ export interface RepositoryCubeInitDeps {
|
|
|
63
74
|
name: string;
|
|
64
75
|
workingRepoName: string;
|
|
65
76
|
repository: CreateCubeRepository;
|
|
66
|
-
template:
|
|
77
|
+
template: NewCubeTemplate;
|
|
67
78
|
}): Promise<RepositoryCubeCreation>;
|
|
68
79
|
}
|
|
69
80
|
|
|
@@ -332,16 +343,19 @@ export async function initializeRepositoryCube(input: {
|
|
|
332
343
|
}
|
|
333
344
|
|
|
334
345
|
if (input.flags.noTemplate) {
|
|
335
|
-
deps.write(
|
|
346
|
+
deps.write(`--no-template is not supported for repository cube creation. Use --template ${NEW_CUBE_TEMPLATE_OPTIONS}.\n`);
|
|
336
347
|
return { kind: 'stop', code: 1 };
|
|
337
348
|
}
|
|
338
|
-
|
|
349
|
+
const requestedTemplate = input.flags.template === undefined
|
|
350
|
+
? undefined
|
|
351
|
+
: parseNewCubeTemplate(input.flags.template);
|
|
352
|
+
if (input.flags.template !== undefined && requestedTemplate === undefined) {
|
|
339
353
|
const safe = input.flags.template.replace(/[\u0000-\u001f\u007f]/g, '?').slice(0, 120);
|
|
340
|
-
deps.write(`Unknown template '${safe}'.
|
|
354
|
+
deps.write(`Unknown template '${safe}'. Available templates: ${NEW_CUBE_TEMPLATE_LIST}.\n`);
|
|
341
355
|
return { kind: 'stop', code: 1 };
|
|
342
356
|
}
|
|
343
357
|
if (!deps.isTTY() && !input.flags.yes && (!input.flags.cubeName || !input.flags.template)) {
|
|
344
|
-
deps.write(
|
|
358
|
+
deps.write(`Non-interactive cube creation requires --cube-name <name> and --template ${NEW_CUBE_TEMPLATE_OPTIONS}, or --yes to use repository defaults.\n`);
|
|
345
359
|
return { kind: 'stop', code: 1 };
|
|
346
360
|
}
|
|
347
361
|
|
|
@@ -370,7 +384,7 @@ export async function initializeRepositoryCube(input: {
|
|
|
370
384
|
if (editedNameAdoption) return editedNameAdoption;
|
|
371
385
|
}
|
|
372
386
|
|
|
373
|
-
let template =
|
|
387
|
+
let template = requestedTemplate;
|
|
374
388
|
if (!template && deps.isTTY() && !input.flags.yes) {
|
|
375
389
|
let menu = 'Choose a template:\n';
|
|
376
390
|
for (let i = 0; i < NEW_CUBE_TEMPLATE_PRESENTATIONS.length; i += 1) {
|
|
@@ -384,12 +398,15 @@ export async function initializeRepositoryCube(input: {
|
|
|
384
398
|
const answer = await ask(deps, 'Template [1]: ');
|
|
385
399
|
if ('stop' in answer) return { kind: 'stop', code: answer.stop };
|
|
386
400
|
const selected = answer.value.trim();
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
401
|
+
const selectedIndex = selected === ''
|
|
402
|
+
? 0
|
|
403
|
+
: /^[1-9]\d*$/.test(selected) ? Number(selected) - 1 : -1;
|
|
404
|
+
const selectedPresentation = NEW_CUBE_TEMPLATE_PRESENTATIONS[selectedIndex];
|
|
405
|
+
if (selectedPresentation) template = selectedPresentation.name;
|
|
406
|
+
else deps.write(`Choose 1-${NEW_CUBE_TEMPLATE_PRESENTATIONS.length}.\n`);
|
|
390
407
|
}
|
|
391
408
|
}
|
|
392
|
-
template ??=
|
|
409
|
+
template ??= NEW_CUBE_TEMPLATE_PRESENTATIONS[0].name;
|
|
393
410
|
|
|
394
411
|
if (deps.isTTY() && !input.flags.yes) {
|
|
395
412
|
let confirmed = false;
|
|
@@ -2,7 +2,7 @@ import { createHmac, randomBytes, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
3
|
import { basename, isAbsolute, join } from 'node:path';
|
|
4
4
|
import { realpath } from 'node:fs/promises';
|
|
5
|
-
import type
|
|
5
|
+
import { CUBE_TEMPLATES, type CreateCubeRepository, type CubeTemplate } from 'borgmcp-shared/protocol';
|
|
6
6
|
import { canonicalizeWorkingRepoIdentity } from './working-repo.js';
|
|
7
7
|
import { normalizeCubeName } from './cube-name.js';
|
|
8
8
|
import { borgConfigRoot, ensurePrivateBorgConfigRoot } from './private-root.js';
|
|
@@ -118,7 +118,7 @@ function parseState(raw: string | null): RepositoryIdentityState {
|
|
|
118
118
|
!DISPLAY_NAME_RE.test(value.name) ||
|
|
119
119
|
typeof value.workingRepoName !== 'string' || Buffer.byteLength(value.workingRepoName, 'utf8') > 120 ||
|
|
120
120
|
!DISPLAY_NAME_RE.test(value.workingRepoName) ||
|
|
121
|
-
(
|
|
121
|
+
!CUBE_TEMPLATES.some((template) => template === value.template)
|
|
122
122
|
) {
|
|
123
123
|
throw new Error('Borg repository identity store is malformed or unsupported');
|
|
124
124
|
}
|
package/src/startup-services.ts
CHANGED
|
@@ -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,
|
package/src/stream-owner.ts
CHANGED
|
@@ -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
|
|
package/src/stream-status.ts
CHANGED
|
@@ -161,7 +161,10 @@ export function renderStreamStatus(inputs: RenderInputs): string {
|
|
|
161
161
|
if (orphanedInitialization) {
|
|
162
162
|
summary = '**Stream blocked by an orphaned initialization lock.**';
|
|
163
163
|
} else if (ownedByOther) {
|
|
164
|
-
|
|
164
|
+
const owner = status.ownership!;
|
|
165
|
+
summary = owner.droneLabel && owner.worktree
|
|
166
|
+
? `**Stream owned by seat ${owner.droneLabel} in \`${owner.worktree}\`.**`
|
|
167
|
+
: '**Stream owned by another Borg MCP process.**';
|
|
165
168
|
} else if (isNotStarted) {
|
|
166
169
|
summary = '**Stream not started.**';
|
|
167
170
|
} else if (!status.connected) {
|
|
@@ -245,6 +248,8 @@ export function renderStreamStatus(inputs: RenderInputs): string {
|
|
|
245
248
|
|
|
246
249
|
if (ownedByOther) {
|
|
247
250
|
const owner = status.ownership!;
|
|
251
|
+
lines.push(`- **stream owner seat**: ${owner.droneLabel ?? '_(unknown)_'}`);
|
|
252
|
+
lines.push(`- **stream owner worktree**: ${owner.worktree ?? '_(unknown)_'}`);
|
|
248
253
|
lines.push(`- **stream owner pid**: ${owner.pid ?? '_(unknown)_'}`);
|
|
249
254
|
lines.push(`- **stream owner cwd**: ${owner.cwd ?? '_(unknown)_'}`);
|
|
250
255
|
lines.push(
|
|
@@ -254,6 +259,8 @@ export function renderStreamStatus(inputs: RenderInputs): string {
|
|
|
254
259
|
: '_(unknown)_'
|
|
255
260
|
}`
|
|
256
261
|
);
|
|
262
|
+
lines.push('');
|
|
263
|
+
lines.push('Continue in the owning seat, or close its duplicate agent session before relaunching from the intended worktree. The live owner releases this lock on exit; a stale lock is reclaimed automatically.');
|
|
257
264
|
}
|
|
258
265
|
|
|
259
266
|
if (wakePath.agentKind === 'opencode' && wakePath.openCode) {
|