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/claude.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  import { spawn } from 'child_process';
19
19
  import { randomUUID } from 'node:crypto';
20
20
  import { realpathSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
21
22
  import { basename } from 'node:path';
22
23
  import { createInterface } from 'node:readline/promises';
23
24
  import { fileURLToPath } from 'node:url';
@@ -102,6 +103,7 @@ import {
102
103
  addCodexUserPromptSubmitHook,
103
104
  addProjectSessionStartHook,
104
105
  addUserPromptSubmitHook,
106
+ provisionLaunchAccess,
105
107
  removeSessionStartHook,
106
108
  } from './config-utils.js';
107
109
  import { ensureCliMcpConfigured } from './ensure-mcp-config.js';
@@ -118,6 +120,7 @@ import { buildOpenCodeLaunchArgs, defaultApprovalIo, resolveLaunchBorgApprovals
118
120
  import { isClientOwnedCubeInitArgv, runEarlyServerFacade } from './server-facade.js';
119
121
  import { runEarlyUpdate } from './update-cmd.js';
120
122
  import { runDoctor, warnIfAgentIntegrationUnhealthy } from './agent-integration-health.js';
123
+ import { scratchRootForSeat } from './launch-access.js';
121
124
 
122
125
  export type AssimilateDepsBuilder = typeof buildDefaultAssimilateDeps;
123
126
 
@@ -148,7 +151,7 @@ export function createOpenCodeLaunchPlan(
148
151
  };
149
152
  }
150
153
 
151
- export function launchOpenCodeProcess(options: {
154
+ export async function launchOpenCodeProcess(options: {
152
155
  cwd: string;
153
156
  port: number;
154
157
  prompt: string;
@@ -159,36 +162,76 @@ export function launchOpenCodeProcess(options: {
159
162
  kickoff: ReturnType<typeof createOpenCodeLaunchKickoff>;
160
163
  spawnProcess?: typeof spawn;
161
164
  connect?: typeof connectOpenCodeDrone;
162
- }): {
165
+ injectKickoff?: typeof injectInitialKickoff;
166
+ allocatePort?: typeof allocateOpenCodePort;
167
+ }): Promise<{
163
168
  launchArgs: string[];
164
169
  launchEnv: NodeJS.ProcessEnv;
165
170
  process: ReturnType<typeof spawn>;
166
- } {
167
- const plan = createOpenCodeLaunchPlan(options.cwd, options.port, options.prompt, options.passthroughArgs);
168
- const launchEnv = {
169
- ...options.env,
170
- BORG_OPENCODE_PORT: plan.envPort,
171
- [OPENCODE_SERVER_USERNAME_ENV]: OPENCODE_SERVER_USERNAME,
172
- [OPENCODE_SERVER_PASSWORD_ENV]: options.kickoff.apiPassword,
173
- [BORG_OPENCODE_LAUNCH_CORRELATION_ENV]: options.kickoff.correlationIdentity,
174
- };
175
- // OpenCode's bind can still race this allocation; client#298 tracks the
176
- // residual pre-bind window outside this slice.
177
- const child = (options.spawnProcess ?? spawn)('opencode', plan.launchArgs, {
178
- stdio: 'inherit',
179
- shell: false,
180
- env: launchEnv,
181
- });
182
- (options.connect ?? connectOpenCodeDrone)({
183
- serverUrl: plan.serverUrl,
184
- apiPassword: options.kickoff.apiPassword,
185
- directory: options.cwd,
186
- droneLabel: options.droneLabel,
187
- cubeName: options.cubeName,
188
- })
189
- .then(() => injectInitialKickoff(options.kickoff))
190
- .catch(() => {});
191
- return { launchArgs: plan.launchArgs, launchEnv, process: child };
171
+ }> {
172
+ let port = options.port;
173
+ let lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
174
+ // Each child gets independent trust so a retry cannot bind a session or
175
+ // durable binding left by an earlier attempt.
176
+ for (let attempt = 0; attempt < 3; attempt++) {
177
+ const attemptKickoff = attempt === 0
178
+ ? options.kickoff
179
+ : createOpenCodeLaunchKickoff(options.prompt);
180
+ const plan = createOpenCodeLaunchPlan(options.cwd, port, options.prompt, options.passthroughArgs);
181
+ const launchEnv = {
182
+ ...options.env,
183
+ BORG_OPENCODE_PORT: plan.envPort,
184
+ [OPENCODE_SERVER_USERNAME_ENV]: OPENCODE_SERVER_USERNAME,
185
+ [OPENCODE_SERVER_PASSWORD_ENV]: attemptKickoff.apiPassword,
186
+ [BORG_OPENCODE_LAUNCH_CORRELATION_ENV]: attemptKickoff.correlationIdentity,
187
+ };
188
+ const child = (options.spawnProcess ?? spawn)('opencode', plan.launchArgs, {
189
+ stdio: 'inherit',
190
+ shell: false,
191
+ env: launchEnv,
192
+ });
193
+ type Stopped =
194
+ | { kind: 'error'; error: NodeJS.ErrnoException }
195
+ | { kind: 'exit'; code: number | null; signal: NodeJS.Signals | null };
196
+ let markStopped: (outcome: Stopped) => void;
197
+ const stopped = new Promise<Stopped>((resolve) => { markStopped = resolve; });
198
+ const onError = (error: NodeJS.ErrnoException) => markStopped({ kind: 'error', error });
199
+ const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
200
+ markStopped({ kind: 'exit', code, signal });
201
+ };
202
+ child.once('error', onError);
203
+ child.once('exit', onExit);
204
+ await (options.connect ?? connectOpenCodeDrone)({
205
+ serverUrl: plan.serverUrl,
206
+ apiPassword: attemptKickoff.apiPassword,
207
+ directory: options.cwd,
208
+ droneLabel: options.droneLabel,
209
+ cubeName: options.cubeName,
210
+ launchIdentity: attemptKickoff.correlationIdentity,
211
+ });
212
+ const outcome = await Promise.race([
213
+ (options.injectKickoff ?? injectInitialKickoff)(attemptKickoff).then(
214
+ (bound) => bound ? { kind: 'ready' as const } : { kind: 'unbound' as const },
215
+ ),
216
+ stopped,
217
+ ]);
218
+ child.off('error', onError);
219
+ child.off('exit', onExit);
220
+ if (outcome.kind === 'ready') {
221
+ return { launchArgs: plan.launchArgs, launchEnv, process: child };
222
+ }
223
+ if (outcome.kind === 'unbound') {
224
+ child.kill();
225
+ throw new Error('OpenCode started but its launch session could not be identified');
226
+ }
227
+ if (outcome.kind === 'error') throw outcome.error;
228
+ lastExit = outcome;
229
+ if (attempt < 2) port = await (options.allocatePort ?? allocateOpenCodePort)();
230
+ }
231
+ throw new Error(
232
+ `OpenCode exited before launch readiness after 3 attempts `
233
+ + `(code=${lastExit?.code ?? 'none'}, signal=${lastExit?.signal ?? 'none'})`,
234
+ );
192
235
  }
193
236
 
194
237
  export async function runAssimilateEntry(
@@ -510,7 +553,19 @@ async function main() {
510
553
  cli = configureSelectedLaunchCli(
511
554
  cli,
512
555
  launchAction,
513
- (selectedCli) => ensureResolvedCliConfigured(selectedCli, active),
556
+ (selectedCli) => {
557
+ ensureResolvedCliConfigured(selectedCli, active);
558
+ if (selectedCli === 'opencode' && active) {
559
+ const worktree = findProjectRoot(process.cwd());
560
+ provisionLaunchAccess(selectedCli, worktree, {
561
+ worktree,
562
+ scratch: scratchRootForSeat(homedir(), active.droneLabel, active.droneId),
563
+ // OpenCode does not grant Git internals; this field is used only by
564
+ // the Codex launch-argument path.
565
+ commonDir: worktree,
566
+ });
567
+ }
568
+ },
514
569
  );
515
570
 
516
571
  if (active && !parsedCli.force) {
@@ -682,7 +737,7 @@ async function main() {
682
737
  console.error(`${consolePrefix()}${chalk.blue(`◼ Launching ${cliDisplayName}…`)}`);
683
738
 
684
739
  const agentProcess = cli === 'opencode' && openCodeKickoff && openCodePort !== undefined
685
- ? launchOpenCodeProcess({
740
+ ? (await launchOpenCodeProcess({
686
741
  cwd: process.cwd(),
687
742
  port: openCodePort,
688
743
  prompt: openCodeKickoff.prompt,
@@ -691,7 +746,7 @@ async function main() {
691
746
  droneLabel: active?.droneLabel ?? 'opencode',
692
747
  cubeName: active?.name ?? 'borg',
693
748
  kickoff: openCodeKickoff,
694
- }).process
749
+ })).process
695
750
  : spawn(cli, launchArgs, { stdio: 'inherit', shell: false, env: launchEnv });
696
751
 
697
752
  // gh#857 WI-2: wake-target recording is codex-only (app-server bridge).
@@ -807,6 +862,11 @@ if (isEntryInvocation()) {
807
862
  process.stderr.write(`${error.message}\n`);
808
863
  process.exit(1);
809
864
  }
865
+ if (error?.code === 'ENOENT' && error?.path === 'opencode') {
866
+ console.error(`${consolePrefix()}${chalk.red('\n◼ Failed to launch opencode')}`);
867
+ console.error(`${consolePrefix()}${chalk.gray('Make sure opencode is installed.\n')}`);
868
+ process.exit(1);
869
+ }
810
870
  console.error(`${consolePrefix()}${chalk.red(`\n◼ Error: ${error.message}\n`)}`);
811
871
  process.exit(1);
812
872
  });
package/src/cli-help.ts CHANGED
@@ -172,6 +172,7 @@ export function topLevelHelpText(version: string): string {
172
172
  ` borg Show the launch menu in a repository root; resume directly in a linked worktree\n` +
173
173
  ` borg setup Set up borg MCP server + agent CLI integration\n` +
174
174
  ` borg update Update the client and installed local server together\n` +
175
+ ` borg upgrade Alias for borg update\n` +
175
176
  ` borg doctor Check agent hook commands, OpenCode integration, and startup diagnostics\n` +
176
177
  ` borg clone <url> [dir] Clone a repository, then create and launch its cube\n` +
177
178
  ` borg quickstart Create a cube and a drone for every role, then launch them\n` +
@@ -1,5 +1,6 @@
1
1
  import type { BorgCli } from './cubes.js';
2
- import { wakePathArming, type AgentKind } from './regen-format.js';
2
+ import type { AgentKind } from './agent-runtime.js';
3
+ import { wakePathArming } from './regen-format.js';
3
4
  import { OPENCODE_WAKE_PATH_GUIDANCE } from './opencode-wake-copy.js';
4
5
 
5
6
  /**
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { execSync } from 'child_process';
8
+ import { randomBytes } from 'node:crypto';
8
9
  import fs from 'fs';
9
10
  import path from 'path';
10
11
  import { fileURLToPath } from 'url';
@@ -23,7 +24,7 @@ import {
23
24
  import { shellEscape } from './shell-escape.js';
24
25
  import { BORG_STATE_ROOT_ENV, borgAgentConfigEnv, borgHomeRoot } from './private-root.js';
25
26
  import type { LaunchAccessPaths } from './launch-access.js';
26
- import { BORG_LAUNCH_EXPECTED_SEAT_ENV } from './cubes.js';
27
+ import { BORG_LAUNCH_EXPECTED_SEAT_ENV, type BorgCli } from './cubes.js';
27
28
  import {
28
29
  OPENCODE_SERVER_PASSWORD_ENV,
29
30
  OPENCODE_SERVER_PASSWORD_REFERENCE,
@@ -209,6 +210,132 @@ function writeJsonFile(p: string, data: any): void {
209
210
  fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', 'utf-8');
210
211
  }
211
212
 
213
+ interface OpenCodeProjectConfigSnapshot {
214
+ config: unknown;
215
+ identity: { dev: number; ino: number } | null;
216
+ mode: number;
217
+ }
218
+
219
+ function unsafeOpenCodeConfigPath(configPath: string, detail: string): Error {
220
+ return new Error(`OpenCode config path ${configPath} is unsafe: ${detail}`);
221
+ }
222
+
223
+ function assertOpenCodeProjectConfigDirectory(projectRoot: string, configPath: string): string {
224
+ const root = path.resolve(projectRoot);
225
+ let rootStat: fs.Stats;
226
+ try {
227
+ rootStat = fs.lstatSync(root);
228
+ } catch {
229
+ throw unsafeOpenCodeConfigPath(configPath, 'project root is missing or unreadable');
230
+ }
231
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory() || fs.realpathSync(root) !== root) {
232
+ throw unsafeOpenCodeConfigPath(configPath, 'project root must be a canonical real directory');
233
+ }
234
+
235
+ const configDir = path.join(root, '.opencode');
236
+ try {
237
+ fs.mkdirSync(configDir, { mode: 0o700 });
238
+ } catch (error) {
239
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
240
+ }
241
+ let directoryStat: fs.Stats;
242
+ try {
243
+ directoryStat = fs.lstatSync(configDir);
244
+ } catch {
245
+ throw unsafeOpenCodeConfigPath(configPath, '.opencode is missing or unreadable');
246
+ }
247
+ if (
248
+ directoryStat.isSymbolicLink()
249
+ || !directoryStat.isDirectory()
250
+ || fs.realpathSync(configDir) !== configDir
251
+ ) {
252
+ throw unsafeOpenCodeConfigPath(configPath, '.opencode must be a canonical real directory, not a symlink');
253
+ }
254
+ return configDir;
255
+ }
256
+
257
+ function inspectOpenCodeProjectConfigFile(configPath: string): fs.Stats | null {
258
+ let metadata: fs.Stats;
259
+ try {
260
+ metadata = fs.lstatSync(configPath);
261
+ } catch (error) {
262
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
263
+ throw error;
264
+ }
265
+ if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.nlink !== 1) {
266
+ throw unsafeOpenCodeConfigPath(configPath, 'final path must be one regular file, not a symlink');
267
+ }
268
+ return metadata;
269
+ }
270
+
271
+ function readOpenCodeProjectConfig(projectRoot: string, configPath: string): OpenCodeProjectConfigSnapshot {
272
+ assertOpenCodeProjectConfigDirectory(projectRoot, configPath);
273
+ const metadata = inspectOpenCodeProjectConfigFile(configPath);
274
+ if (!metadata) return { config: {}, identity: null, mode: 0o600 };
275
+
276
+ let descriptor: number | null = null;
277
+ try {
278
+ descriptor = fs.openSync(configPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
279
+ const opened = fs.fstatSync(descriptor);
280
+ if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== metadata.dev || opened.ino !== metadata.ino) {
281
+ throw unsafeOpenCodeConfigPath(configPath, 'final file changed while opening');
282
+ }
283
+ const text = fs.readFileSync(descriptor, 'utf8');
284
+ return {
285
+ config: text.trim() ? JSON.parse(text) : {},
286
+ identity: { dev: opened.dev, ino: opened.ino },
287
+ mode: opened.mode & 0o777,
288
+ };
289
+ } finally {
290
+ if (descriptor !== null) fs.closeSync(descriptor);
291
+ }
292
+ }
293
+
294
+ function writeOpenCodeProjectConfig(
295
+ projectRoot: string,
296
+ configPath: string,
297
+ data: unknown,
298
+ snapshot: OpenCodeProjectConfigSnapshot,
299
+ ): void {
300
+ const configDir = assertOpenCodeProjectConfigDirectory(projectRoot, configPath);
301
+ const temporary = path.join(
302
+ configDir,
303
+ `.opencode.json.${process.pid}.${randomBytes(12).toString('hex')}.tmp`,
304
+ );
305
+ let descriptor: number | null = null;
306
+ try {
307
+ descriptor = fs.openSync(
308
+ temporary,
309
+ fs.constants.O_WRONLY
310
+ | fs.constants.O_CREAT
311
+ | fs.constants.O_EXCL
312
+ | (fs.constants.O_NOFOLLOW ?? 0),
313
+ snapshot.mode,
314
+ );
315
+ fs.writeFileSync(descriptor, JSON.stringify(data, null, 2) + '\n', 'utf8');
316
+ fs.fsyncSync(descriptor);
317
+ fs.closeSync(descriptor);
318
+ descriptor = null;
319
+
320
+ assertOpenCodeProjectConfigDirectory(projectRoot, configPath);
321
+ const current = inspectOpenCodeProjectConfigFile(configPath);
322
+ const unchanged = snapshot.identity === null
323
+ ? current === null
324
+ : current !== null
325
+ && current.dev === snapshot.identity.dev
326
+ && current.ino === snapshot.identity.ino;
327
+ if (!unchanged) {
328
+ throw unsafeOpenCodeConfigPath(configPath, 'final file changed before replacement');
329
+ }
330
+ fs.renameSync(temporary, configPath);
331
+ } finally {
332
+ if (descriptor !== null) {
333
+ try { fs.closeSync(descriptor); } catch { /* Preserve the primary failure. */ }
334
+ }
335
+ try { fs.unlinkSync(temporary); } catch { /* Renamed or never created. */ }
336
+ }
337
+ }
338
+
212
339
  /**
213
340
  * Register a Claude Code SessionStart hook that runs `borg-regen` at the
214
341
  * start of every session. Idempotent: re-running won't add duplicates.
@@ -1339,11 +1466,13 @@ export function addOpenCodeLaunchAccess(
1339
1466
  paths: LaunchAccessPaths,
1340
1467
  ): boolean {
1341
1468
  const configPath = path.join(projectRoot, '.opencode', 'opencode.json');
1469
+ let snapshot: OpenCodeProjectConfigSnapshot;
1342
1470
  let config: any;
1343
1471
  try {
1344
- config = readJsonFile(configPath);
1472
+ snapshot = readOpenCodeProjectConfig(projectRoot, configPath);
1473
+ config = snapshot.config;
1345
1474
  } catch (err: any) {
1346
- throw new Error(`Could not parse ${configPath}: ${err.message}`);
1475
+ throw new Error(`Could not safely read OpenCode config ${configPath}: ${err.message}`);
1347
1476
  }
1348
1477
  if (!config || typeof config !== 'object' || Array.isArray(config)) {
1349
1478
  throw new Error(`OpenCode config ${configPath} is not an object`);
@@ -1394,10 +1523,33 @@ export function addOpenCodeLaunchAccess(
1394
1523
  config.permission = permissionObject;
1395
1524
 
1396
1525
  const changed = JSON.stringify(config) !== before;
1397
- if (changed) writeJsonFile(configPath, config);
1526
+ if (changed) {
1527
+ try {
1528
+ writeOpenCodeProjectConfig(projectRoot, configPath, config, snapshot);
1529
+ } catch (err: any) {
1530
+ throw new Error(`Could not safely update OpenCode config ${configPath}: ${err.message}`);
1531
+ }
1532
+ }
1398
1533
  return changed;
1399
1534
  }
1400
1535
 
1536
+ /** Provision the project-local path access required by the selected CLI. */
1537
+ export function provisionLaunchAccess(
1538
+ cli: BorgCli,
1539
+ projectRoot: string,
1540
+ paths: LaunchAccessPaths,
1541
+ ): void {
1542
+ if (cli === 'claude') {
1543
+ addClaudeLaunchAccess(projectRoot, paths);
1544
+ } else if (cli === 'codex') {
1545
+ // Codex receives path grants on its launch command. Its global hook reads
1546
+ // the scoped launch environment and only supplies the reminder.
1547
+ addCodexForeignPathReminderHook();
1548
+ } else {
1549
+ addOpenCodeLaunchAccess(projectRoot, paths);
1550
+ }
1551
+ }
1552
+
1401
1553
  /**
1402
1554
  * Add borg MCP server to OpenCode using `opencode mcp add` CLI.
1403
1555
  * Pins activation and agent-kind signals plus OpenCode config substitutions
package/src/config.ts CHANGED
@@ -31,7 +31,8 @@ const SERVER_CREDENTIAL_RECORD_VERSION = 2 as const;
31
31
  const SERVER_PENDING_ENROLLMENT_RECORD_VERSION = 3 as const;
32
32
  const LEGACY_SERVER_PENDING_ENROLLMENT_RECORD_VERSION = 1 as const;
33
33
  const SERVER_ACCEPTED_ENROLLMENT_MARKER_VERSION = 1 as const;
34
- const SERVER_CUBE_RETRY_RECORD_VERSION = 2 as const;
34
+ const SERVER_CUBE_RETRY_RECORD_VERSION = 3 as const;
35
+ const LEGACY_SERVER_CUBE_RETRY_RECORD_VERSION = 2 as const;
35
36
  // The 0600 credential store (Queen rescope: replaces the OS keychain). A single
36
37
  // file holds every parent credential/enrollment record; a single flock
37
38
  // serializes every mutator + observer that must (SR-seven #4).
@@ -326,7 +327,7 @@ async function writeServerCredentialRecord(
326
327
  }
327
328
  if (backend.entries) {
328
329
  const accounts = await backend.entries();
329
- const conflicts = Object.entries(accounts).filter(([account, value]) => {
330
+ const conflicts = Object.entries(accounts).filter(([, value]) => {
330
331
  try {
331
332
  const parsed = JSON.parse(value) as { version?: unknown; origin?: unknown };
332
333
  return parsed.version === SERVER_CREDENTIAL_RECORD_VERSION && parsed.origin === record.origin;
@@ -1106,39 +1107,74 @@ export async function getOrCreatePendingServerCubeCreation(
1106
1107
  return withCredentialStoreLock(async () => {
1107
1108
  const stored = await backend.get(account);
1108
1109
  if (stored) {
1110
+ let record: Omit<Partial<PendingServerCubeCreationRecord>, 'template'> & {
1111
+ version?: unknown;
1112
+ state?: unknown;
1113
+ template?: unknown;
1114
+ };
1109
1115
  try {
1110
- const record = JSON.parse(stored) as Partial<PendingServerCubeCreationRecord> & {
1111
- version?: unknown;
1112
- state?: unknown;
1116
+ const parsed: unknown = JSON.parse(stored);
1117
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid record');
1118
+ record = parsed as typeof record;
1119
+ } catch {
1120
+ throw new Error('pending Borg server cube creation does not match this repository');
1121
+ }
1122
+ const retryKey = typeof record.retryKey === 'string' && UUID_RE.test(record.retryKey)
1123
+ ? record.retryKey
1124
+ : null;
1125
+ const sameOperation =
1126
+ record.state === 'pending' &&
1127
+ record.origin === input.origin &&
1128
+ record.trustIdentity === input.trustIdentity &&
1129
+ record.clientId === input.clientId &&
1130
+ record.repositoryBinding === repositoryBinding &&
1131
+ record.name === input.name &&
1132
+ record.workingRepoName === input.workingRepoName &&
1133
+ retryKey !== null;
1134
+ if (sameOperation && record.version === SERVER_CUBE_RETRY_RECORD_VERSION && record.template === input.template) {
1135
+ return {
1136
+ origin: input.origin,
1137
+ trustIdentity: input.trustIdentity,
1138
+ clientId: input.clientId,
1139
+ repositoryBinding,
1140
+ retryKey,
1141
+ name: input.name,
1142
+ workingRepoName: input.workingRepoName,
1143
+ repository: input.repository,
1144
+ template: input.template,
1113
1145
  };
1114
- if (
1115
- record.version !== SERVER_CUBE_RETRY_RECORD_VERSION ||
1116
- record.state !== 'pending' ||
1117
- record.origin !== input.origin ||
1118
- record.trustIdentity !== input.trustIdentity ||
1119
- record.clientId !== input.clientId ||
1120
- record.repositoryBinding !== repositoryBinding ||
1121
- record.name !== input.name ||
1122
- record.workingRepoName !== input.workingRepoName ||
1123
- record.template !== input.template ||
1124
- typeof record.retryKey !== 'string' || !UUID_RE.test(record.retryKey)
1125
- ) {
1126
- throw new Error('mismatch');
1146
+ }
1147
+ if (sameOperation && record.version === LEGACY_SERVER_CUBE_RETRY_RECORD_VERSION) {
1148
+ const preserveRetryKey = record.template === input.template;
1149
+ if (!preserveRetryKey && record.template !== 'default') {
1150
+ throw new Error('pending Borg server cube creation does not match this repository');
1127
1151
  }
1128
- return {
1152
+ const replacement: PendingServerCubeCreationRecord = {
1129
1153
  origin: input.origin,
1130
1154
  trustIdentity: input.trustIdentity,
1131
1155
  clientId: input.clientId,
1132
1156
  repositoryBinding,
1133
- retryKey: record.retryKey,
1157
+ retryKey: preserveRetryKey ? retryKey : randomUUID(),
1134
1158
  name: input.name,
1135
1159
  workingRepoName: input.workingRepoName,
1136
1160
  repository: input.repository,
1137
1161
  template: input.template,
1138
1162
  };
1139
- } catch {
1140
- throw new Error('pending Borg server cube creation does not match this repository');
1163
+ await backend.set(account, JSON.stringify({
1164
+ version: SERVER_CUBE_RETRY_RECORD_VERSION,
1165
+ state: 'pending',
1166
+ origin: replacement.origin,
1167
+ trustIdentity: replacement.trustIdentity,
1168
+ clientId: replacement.clientId,
1169
+ repositoryBinding: replacement.repositoryBinding,
1170
+ retryKey: replacement.retryKey,
1171
+ name: replacement.name,
1172
+ workingRepoName: replacement.workingRepoName,
1173
+ template: replacement.template,
1174
+ }));
1175
+ return replacement;
1141
1176
  }
1177
+ throw new Error('pending Borg server cube creation does not match this repository');
1142
1178
  }
1143
1179
  const record: PendingServerCubeCreationRecord = {
1144
1180
  origin: input.origin,
package/src/cubes.ts CHANGED
@@ -498,16 +498,6 @@ export async function findRemainingActiveSeatForWorktree(worktree: string): Prom
498
498
  return record ? { apiUrl: record.origin, operation: record.operation } : null;
499
499
  }
500
500
 
501
- /**
502
- * Legacy binding-only writer. In the collapsed single-store model an ACTIVE seat is
503
- * created ONLY by the atomic mint→activate+bind path in seats.ts (driven by the
504
- * attach FINALIZE); there is no standalone binding write. Retained solely as a
505
- * fail-closed guard for incomplete session metadata.
506
- */
507
- export async function setActiveCube(_active: ActiveCubeInput): Promise<void> {
508
- throw new Error('local Borg server session metadata is incomplete');
509
- }
510
-
511
501
  export function activeCubeWithFreshRegenIdentity(
512
502
  active: ActiveCube,
513
503
  result: {
@@ -14,7 +14,7 @@
14
14
  * always err toward KEEPING.
15
15
  */
16
16
  import { spawnSync } from 'node:child_process';
17
- import { readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
17
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
19
  import {
20
20
  pidfilePathFor,