borgmcp 3.4.0 → 3.5.1

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 (79) hide show
  1. package/README.md +20 -2
  2. package/dist/backends/{launch-all-windows.d.ts → launch-all-terminals.d.ts} +5 -3
  3. package/dist/backends/launch-all-terminals.d.ts.map +1 -0
  4. package/dist/backends/{launch-all-windows.js → launch-all-terminals.js} +26 -12
  5. package/dist/backends/launch-all-terminals.js.map +1 -0
  6. package/dist/bare-launch-menu.d.ts +53 -16
  7. package/dist/bare-launch-menu.d.ts.map +1 -1
  8. package/dist/bare-launch-menu.js +95 -4
  9. package/dist/bare-launch-menu.js.map +1 -1
  10. package/dist/claude.d.ts +6 -0
  11. package/dist/claude.d.ts.map +1 -1
  12. package/dist/claude.js +83 -8
  13. package/dist/claude.js.map +1 -1
  14. package/dist/cli-help.d.ts +2 -0
  15. package/dist/cli-help.d.ts.map +1 -1
  16. package/dist/cli-help.js +31 -2
  17. package/dist/cli-help.js.map +1 -1
  18. package/dist/config-utils.d.ts +12 -6
  19. package/dist/config-utils.d.ts.map +1 -1
  20. package/dist/config-utils.js +30 -7
  21. package/dist/config-utils.js.map +1 -1
  22. package/dist/cubes.d.ts +17 -0
  23. package/dist/cubes.d.ts.map +1 -1
  24. package/dist/cubes.js +87 -1
  25. package/dist/cubes.js.map +1 -1
  26. package/dist/ensure-mcp-config.d.ts.map +1 -1
  27. package/dist/ensure-mcp-config.js +5 -2
  28. package/dist/ensure-mcp-config.js.map +1 -1
  29. package/dist/launch-all-cmd.d.ts.map +1 -1
  30. package/dist/launch-all-cmd.js +20 -10
  31. package/dist/launch-all-cmd.js.map +1 -1
  32. package/dist/log-stream.d.ts.map +1 -1
  33. package/dist/log-stream.js +5 -8
  34. package/dist/log-stream.js.map +1 -1
  35. package/dist/opencode-drone.d.ts +3 -4
  36. package/dist/opencode-drone.d.ts.map +1 -1
  37. package/dist/opencode-drone.js +15 -10
  38. package/dist/opencode-drone.js.map +1 -1
  39. package/dist/opencode-plugin.d.ts +1 -0
  40. package/dist/opencode-plugin.d.ts.map +1 -1
  41. package/dist/opencode-plugin.js +2 -1
  42. package/dist/opencode-plugin.js.map +1 -1
  43. package/dist/opencode-wake-copy.js +1 -1
  44. package/dist/opencode-wake-copy.js.map +1 -1
  45. package/dist/parse-launch-all-args.d.ts +1 -1
  46. package/dist/parse-launch-all-args.d.ts.map +1 -1
  47. package/dist/parse-launch-all-args.js +3 -3
  48. package/dist/parse-launch-all-args.js.map +1 -1
  49. package/dist/seat-commands.d.ts +58 -0
  50. package/dist/seat-commands.d.ts.map +1 -0
  51. package/dist/seat-commands.js +240 -0
  52. package/dist/seat-commands.js.map +1 -0
  53. package/dist/seats.d.ts +8 -0
  54. package/dist/seats.d.ts.map +1 -1
  55. package/dist/seats.js +14 -0
  56. package/dist/seats.js.map +1 -1
  57. package/dist/unknown-subcommand.d.ts +1 -1
  58. package/dist/unknown-subcommand.d.ts.map +1 -1
  59. package/dist/unknown-subcommand.js +2 -0
  60. package/dist/unknown-subcommand.js.map +1 -1
  61. package/package.json +1 -1
  62. package/src/backends/{launch-all-windows.ts → launch-all-terminals.ts} +33 -15
  63. package/src/bare-launch-menu.ts +141 -18
  64. package/src/claude.ts +127 -7
  65. package/src/cli-help.ts +37 -2
  66. package/src/config-utils.ts +32 -7
  67. package/src/cubes.ts +118 -1
  68. package/src/ensure-mcp-config.ts +5 -2
  69. package/src/launch-all-cmd.ts +24 -11
  70. package/src/log-stream.ts +5 -8
  71. package/src/opencode-drone.ts +24 -10
  72. package/src/opencode-plugin.ts +2 -1
  73. package/src/opencode-wake-copy.ts +1 -1
  74. package/src/parse-launch-all-args.ts +4 -4
  75. package/src/seat-commands.ts +322 -0
  76. package/src/seats.ts +15 -0
  77. package/src/unknown-subcommand.ts +2 -0
  78. package/dist/backends/launch-all-windows.d.ts.map +0 -1
  79. package/dist/backends/launch-all-windows.js.map +0 -1
@@ -1,27 +1,34 @@
1
1
  /**
2
2
  * gh#853 — bare `borg` (no-args) interactive launch menu.
3
3
  *
4
- * When `borg` is run with NO arguments in a TTY, offer a small launch selector
5
- * instead of launching immediately:
6
- * 1. Launch (default) — the configured agent (Enter selects).
7
- * 2. Launch with <other> instead — the OTHER configured agent, ONE-SHOT
8
- * (does NOT persist the preference).
9
- * 3. Launch all — runLaunchAll for the active cube.
4
+ * When `borg` is run with NO arguments in a TTY outside an active seat, offer
5
+ * a small launch selector. In a repository with live sibling drones, those
6
+ * drones come first; otherwise the existing agent choices remain unchanged.
10
7
  *
11
8
  * The option-set, the selection→action mapping, and the show/collapse decision
12
9
  * are pure functions so they're unit-testable without a real TTY. claude.ts
13
- * main() is thin glue: it computes the inputs (default cli, other-configured cli,
14
- * launch-all targets), gates on shouldShowLaunchMenu, runs the orchestrator with
15
- * the real readline prompt, then dispatches the returned action.
10
+ * main() is thin glue: it computes the available candidates and agent choices,
11
+ * gates on shouldShowLaunchMenu, runs the orchestrator with the real readline
12
+ * prompt, then dispatches the returned action.
16
13
  *
17
- * Load-bearing safety: TTY-only + bare-args-only (shouldShowLaunchMenu) so every
18
- * scripted/programmatic `borg` and every explicit subcommand/flag is untouched.
14
+ * Load-bearing safety: TTY-only + bare-args-only + no-active-seat
15
+ * (shouldShowLaunchMenu), so scripted/programmatic invocations and direct
16
+ * worktree resumes are untouched.
19
17
  */
20
- import type { BorgCli } from './cubes.js';
18
+ import type { ActiveCube, BorgCli } from './cubes.js';
19
+ import type { DroneCandidate } from './launch-all-discovery.js';
20
+ import type { SeatStatus } from './seat-probe.js';
21
21
 
22
22
  export type LaunchMenuAction =
23
23
  | { kind: 'launch'; cli: BorgCli }
24
- | { kind: 'launch-all' };
24
+ | { kind: 'launch-seat'; target: string }
25
+ | { kind: 'launch-all'; cubeId?: string };
26
+
27
+ export interface LaunchMenuDroneCandidate {
28
+ droneLabel: string;
29
+ target: string;
30
+ worktree: string;
31
+ }
25
32
 
26
33
  export interface LaunchMenuOption {
27
34
  /** The keystroke that selects this option (sequential: '1', '2', …). */
@@ -35,8 +42,87 @@ export interface LaunchMenuInputs {
35
42
  defaultCli: BorgCli;
36
43
  /** All configured agents that are NOT the default, in display order. */
37
44
  otherConfiguredClis: BorgCli[];
38
- /** True iff there's an active cube with >=1 discoverable drone (option 3). */
45
+ /** True iff the current menu context has launch-all targets. */
39
46
  hasLaunchAllTargets: boolean;
47
+ /** Live sibling drones offered before the unattached launch choices. */
48
+ droneCandidates?: LaunchMenuDroneCandidate[];
49
+ /** Cube selected by the sibling-drone context for its launch-all action. */
50
+ launchAllCubeId?: string;
51
+ }
52
+
53
+ interface LaunchMenuCandidateDeps {
54
+ readAllProjectIdentities: () => Promise<Array<{ projectPath: string; cube: ActiveCube }>>;
55
+ discoverDroneCandidates: (cubeId: string) => Promise<DroneCandidate[]>;
56
+ getActiveSeatForWorktree: (
57
+ worktree: string,
58
+ ) => Promise<{ cubeId: string; droneId?: string } | null>;
59
+ pathExists: (worktree: string) => boolean;
60
+ probeSeat: (candidate: DroneCandidate) => Promise<SeatStatus>;
61
+ }
62
+
63
+ const TERMINAL_SEAT_STATUSES = new Set<SeatStatus>([
64
+ 'evicted',
65
+ 'revoked',
66
+ 'rejected',
67
+ 'credential-rejected',
68
+ 'trust-mismatch',
69
+ ]);
70
+
71
+ /**
72
+ * Find linked sibling worktrees that still own their preferred active seat.
73
+ * Authoritative terminal probe results are omitted; transient/unknown probe
74
+ * results stay visible, matching launch-all's constructive fail-open behavior.
75
+ */
76
+ export async function discoverLiveLaunchMenuCandidates(
77
+ deps: LaunchMenuCandidateDeps,
78
+ ): Promise<{ candidates: LaunchMenuDroneCandidate[]; launchAllCubeId?: string }> {
79
+ const identities = await deps.readAllProjectIdentities();
80
+ const cubeIds = [...new Set(identities.map(({ cube }) => cube.cubeId))];
81
+ const discovered = (
82
+ await Promise.all(cubeIds.map((cubeId) => deps.discoverDroneCandidates(cubeId)))
83
+ ).flat();
84
+
85
+ const candidates: Array<LaunchMenuDroneCandidate & { cubeId: string }> = [];
86
+ const seen = new Set<string>();
87
+ for (const candidate of discovered) {
88
+ if (!deps.pathExists(candidate.worktreeDir)) continue;
89
+ const preferred = await deps.getActiveSeatForWorktree(candidate.worktreeDir);
90
+ if (
91
+ !preferred ||
92
+ preferred.cubeId !== candidate.cubeId ||
93
+ preferred.droneId !== candidate.droneId
94
+ ) continue;
95
+
96
+ let status: SeatStatus;
97
+ try {
98
+ status = await deps.probeSeat(candidate);
99
+ } catch {
100
+ status = 'indeterminate';
101
+ }
102
+ if (TERMINAL_SEAT_STATUSES.has(status)) continue;
103
+
104
+ const key = `${candidate.cubeId}\0${candidate.droneId}\0${candidate.worktreeDir}`;
105
+ if (seen.has(key)) continue;
106
+ seen.add(key);
107
+ candidates.push({
108
+ cubeId: candidate.cubeId,
109
+ droneLabel: candidate.droneLabel,
110
+ target: candidate.droneId,
111
+ worktree: candidate.worktreeDir,
112
+ });
113
+ }
114
+
115
+ candidates.sort((a, b) =>
116
+ a.droneLabel.localeCompare(b.droneLabel) || a.worktree.localeCompare(b.worktree)
117
+ );
118
+ const candidateCubeIds = new Set(candidates.map((candidate) => candidate.cubeId));
119
+ const launchAllCubeId = candidateCubeIds.size === 1
120
+ ? candidates[0].cubeId
121
+ : undefined;
122
+ return {
123
+ candidates: candidates.map(({ cubeId: _cubeId, ...candidate }) => candidate),
124
+ ...(launchAllCubeId ? { launchAllCubeId } : {}),
125
+ };
40
126
  }
41
127
 
42
128
  /**
@@ -59,16 +145,20 @@ export function configureSelectedLaunchCli(
59
145
  const PRETTY: Record<BorgCli, string> = { claude: 'Claude', codex: 'Codex', opencode: 'OpenCode' };
60
146
 
61
147
  /**
62
- * Gate: the menu fires ONLY for bare `borg` (no args) in a TTY. Any explicit
63
- * subcommand/flag, or a non-TTY (piped/scripted/CI) invocation, falls straight
64
- * through to the existing default launch — no menu, no behavior change.
148
+ * Gate: the menu fires ONLY for bare `borg` (no args) in a TTY without an
149
+ * active seat. Explicit invocations, non-TTY launches, and direct worktree
150
+ * resumes fall straight through to the existing launch path.
65
151
  */
66
152
  export function shouldShowLaunchMenu(args: {
67
153
  extraArgs: string[];
68
154
  stdinIsTTY: boolean;
69
155
  stdoutIsTTY: boolean;
156
+ hasActiveSeat?: boolean;
70
157
  }): boolean {
71
- return args.extraArgs.length === 0 && args.stdinIsTTY && args.stdoutIsTTY;
158
+ return args.extraArgs.length === 0
159
+ && args.stdinIsTTY
160
+ && args.stdoutIsTTY
161
+ && !args.hasActiveSeat;
72
162
  }
73
163
 
74
164
  export function explicitCliLaunchHint(args: {
@@ -98,6 +188,39 @@ export function shouldResolveExplicitCliLaunchHintTargets(args: {
98
188
  * middle option never produces a "1) … 3) …" gap menu.
99
189
  */
100
190
  export function buildLaunchMenuOptions(inputs: LaunchMenuInputs): LaunchMenuOption[] {
191
+ if (inputs.droneCandidates && inputs.droneCandidates.length > 0) {
192
+ const options: LaunchMenuOption[] = [...inputs.droneCandidates]
193
+ .sort((a, b) => a.droneLabel.localeCompare(b.droneLabel) || a.worktree.localeCompare(b.worktree))
194
+ .map((candidate, index) => ({
195
+ key: String(index + 1),
196
+ label: `Resume ${candidate.droneLabel} (${candidate.worktree})`,
197
+ action: { kind: 'launch-seat' as const, target: candidate.target },
198
+ }));
199
+ if (inputs.hasLaunchAllTargets) {
200
+ options.push({
201
+ key: String(options.length + 1),
202
+ label: "Launch all (this cube's drone worktrees)",
203
+ action: {
204
+ kind: 'launch-all',
205
+ ...(inputs.launchAllCubeId ? { cubeId: inputs.launchAllCubeId } : {}),
206
+ },
207
+ });
208
+ }
209
+ options.push({
210
+ key: String(options.length + 1),
211
+ label: `Launch ${PRETTY[inputs.defaultCli]} here without a drone`,
212
+ action: { kind: 'launch', cli: inputs.defaultCli },
213
+ });
214
+ for (const cli of inputs.otherConfiguredClis) {
215
+ options.push({
216
+ key: String(options.length + 1),
217
+ label: `Launch with ${PRETTY[cli]} here without a drone (one-shot)`,
218
+ action: { kind: 'launch', cli },
219
+ });
220
+ }
221
+ return options;
222
+ }
223
+
101
224
  const options: LaunchMenuOption[] = [
102
225
  {
103
226
  key: '1',
package/src/claude.ts CHANGED
@@ -22,7 +22,18 @@ import { basename } from 'node:path';
22
22
  import { createInterface } from 'node:readline/promises';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import chalk from 'chalk';
25
- import { findProjectRoot, getActiveCube, inboxPathForDrone, setCodexWakeTarget, pruneDeadCodexWakeTargets, type BorgCli } from './cubes.js';
25
+ import {
26
+ BORG_LAUNCH_EXPECTED_SEAT_ENV,
27
+ codexLaunchSeatExpectationConfigArgs,
28
+ findProjectRoot,
29
+ getActiveCube,
30
+ inboxPathForDrone,
31
+ LaunchSeatIdentityChangedError,
32
+ setCodexWakeTarget,
33
+ pruneDeadCodexWakeTargets,
34
+ type ActiveCube,
35
+ type BorgCli,
36
+ } from './cubes.js';
26
37
  import { monitorStateRootForWorktree } from './inbox-monitor.js';
27
38
  import { formatSeatReattachRefusal, inspectLiveInboxMonitor } from './seat-reattach-guard.js';
28
39
  import { handleVersionFlag, getPackageVersion } from './version.js';
@@ -43,9 +54,17 @@ import { unknownSubcommand } from './unknown-subcommand.js';
43
54
  import { parseRecoverEnrollmentArgs, runRecoverEnrollment } from './recover-enrollment-cmd.js';
44
55
  import { runLaunchAll } from './launch-all-cmd.js';
45
56
  import { buildDefaultLaunchAllDeps } from './launch-all-deps.js';
57
+ import {
58
+ buildDefaultSeatCommandDeps,
59
+ parseLaunchSeatArgs,
60
+ parseSeatsArgs,
61
+ runLaunchSeat,
62
+ runSeats,
63
+ } from './seat-commands.js';
46
64
  import { discoverDroneCandidates } from './launch-all-discovery.js';
47
65
  import {
48
66
  configureSelectedLaunchCli,
67
+ discoverLiveLaunchMenuCandidates,
49
68
  explicitCliLaunchHint,
50
69
  runBareLaunchMenu,
51
70
  shouldResolveExplicitCliLaunchHintTargets,
@@ -90,6 +109,19 @@ import { runDoctor, warnIfAgentIntegrationUnhealthy } from './agent-integration-
90
109
 
91
110
  export type AssimilateDepsBuilder = typeof buildDefaultAssimilateDeps;
92
111
 
112
+ export class OpenCodeTargetedLaunchConfigError extends Error {
113
+ readonly code = 'OPENCODE_TARGETED_LAUNCH_CONFIG';
114
+
115
+ constructor(droneLabel: string, worktree: string) {
116
+ super(
117
+ `borg launch: did not launch '${droneLabel}' — borg could not update or verify the OpenCode configuration ` +
118
+ 'that a targeted launch needs to open the correct drone. Check that the OpenCode configuration file is ' +
119
+ `writable, then try again. As a fallback, run \`borg\` in ${worktree} to resume that worktree's drone directly.`,
120
+ );
121
+ this.name = 'OpenCodeTargetedLaunchConfigError';
122
+ }
123
+ }
124
+
93
125
  export function createOpenCodeLaunchPlan(
94
126
  cwd: string,
95
127
  port: number,
@@ -267,6 +299,27 @@ async function main() {
267
299
  const code = await runCleanup({}, parsed.options);
268
300
  process.exit(code);
269
301
  }
302
+ if (process.argv[2] === 'seats') {
303
+ const parsed = parseSeatsArgs(process.argv.slice(3));
304
+ if (!parsed.ok) {
305
+ process.stderr.write(chalk.red(`${consolePrefix()}◼ borg seats: ${parsed.error}\n`));
306
+ process.stderr.write(`Run \`borg seats --help\` for usage.\n`);
307
+ process.exit(1);
308
+ }
309
+ process.exit(await runSeats(buildDefaultSeatCommandDeps()));
310
+ }
311
+ if (process.argv[2] === 'launch') {
312
+ const parsed = parseLaunchSeatArgs(process.argv.slice(3));
313
+ if (!parsed.ok) {
314
+ process.stderr.write(chalk.red(`${consolePrefix()}◼ borg launch: ${parsed.error}\n`));
315
+ process.stderr.write(`Run \`borg launch --help\` for usage.\n`);
316
+ process.exit(1);
317
+ }
318
+ process.exit(await runLaunchSeat(
319
+ { target: parsed.target, ...(parsed.cube ? { cube: parsed.cube } : {}) },
320
+ buildDefaultSeatCommandDeps(),
321
+ ));
322
+ }
270
323
  if (process.argv[2] === 'launch-all') {
271
324
  const parsed = parseLaunchAllArgs(process.argv.slice(3));
272
325
  if (!parsed.ok) {
@@ -359,19 +412,59 @@ async function main() {
359
412
  extraArgs: process.argv.slice(2),
360
413
  stdinIsTTY,
361
414
  stdoutIsTTY,
415
+ hasActiveSeat: active !== null,
362
416
  })
363
417
  ) {
418
+ const launchAllDeps = buildDefaultLaunchAllDeps();
419
+ const seatCommandDeps = buildDefaultSeatCommandDeps();
420
+ const siblingContext = await discoverLiveLaunchMenuCandidates({
421
+ readAllProjectIdentities: launchAllDeps.readAllProjectIdentities,
422
+ discoverDroneCandidates: (cubeId) => discoverDroneCandidates(
423
+ { targetCubeId: cubeId },
424
+ { ...launchAllDeps, stderr: () => {} },
425
+ ),
426
+ getActiveSeatForWorktree: seatCommandDeps.getActiveSeatForWorktree,
427
+ pathExists: launchAllDeps.pathExists,
428
+ probeSeat: (candidate) => launchAllDeps.probeSeat(
429
+ candidate.sessionToken,
430
+ candidate.apiUrl,
431
+ candidate.serverTrustIdentity,
432
+ ),
433
+ });
364
434
  const otherConfiguredClis = configuredCliNames(
365
435
  detectCliAvailability(),
366
436
  detectCliConfiguration(),
367
437
  ).filter((c) => c !== cli);
368
438
  const action = await runBareLaunchMenu(
369
- { defaultCli: cli, otherConfiguredClis, hasLaunchAllTargets: await hasLaunchAllTargets() },
439
+ {
440
+ defaultCli: cli,
441
+ otherConfiguredClis,
442
+ hasLaunchAllTargets: siblingContext.launchAllCubeId !== undefined,
443
+ droneCandidates: siblingContext.candidates,
444
+ ...(siblingContext.launchAllCubeId
445
+ ? { launchAllCubeId: siblingContext.launchAllCubeId }
446
+ : {}),
447
+ },
370
448
  prompt
371
449
  );
450
+ if (action.kind === 'launch-seat') {
451
+ process.exit(await runLaunchSeat(
452
+ { target: action.target },
453
+ buildDefaultSeatCommandDeps(),
454
+ ));
455
+ }
372
456
  if (action.kind === 'launch-all') {
373
- const parsed = parseLaunchAllArgs([]); // empty args → active cube, auto backend
374
- const code = parsed.ok ? await runLaunchAll(parsed.args, buildDefaultLaunchAllDeps()) : 1;
457
+ const deps = buildDefaultLaunchAllDeps();
458
+ const selectedIdentity = action.cubeId
459
+ ? (await deps.readAllProjectIdentities()).find(({ cube }) => cube.cubeId === action.cubeId)
460
+ : undefined;
461
+ const parsed = parseLaunchAllArgs([]);
462
+ const code = parsed.ok
463
+ ? await runLaunchAll(
464
+ parsed.args,
465
+ selectedIdentity ? { ...deps, getActiveCube: async () => selectedIdentity.cube } : deps,
466
+ )
467
+ : 1;
375
468
  process.exit(code);
376
469
  }
377
470
  // option 1 → configured default; option 2 → the other agent, ONE-SHOT
@@ -382,7 +475,11 @@ async function main() {
382
475
 
383
476
  // Configure only the CLI that will actually launch. This must follow the
384
477
  // one-shot menu: the resolved default can differ from the menu selection.
385
- cli = configureSelectedLaunchCli(cli, launchAction, ensureResolvedCliConfigured);
478
+ cli = configureSelectedLaunchCli(
479
+ cli,
480
+ launchAction,
481
+ (selectedCli) => ensureResolvedCliConfigured(selectedCli, active),
482
+ );
386
483
 
387
484
  if (active && !parsedCli.force) {
388
485
  const inboxPath = inboxPathForDrone(active.cubeId, active.droneId);
@@ -531,6 +628,7 @@ async function main() {
531
628
  ...codexAgentKindConfigArgs(),
532
629
  ...codexRemoteWakeConfigArgs(codexSocketPath !== null),
533
630
  ...codexStateRootConfigArgs(),
631
+ ...codexLaunchSeatExpectationConfigArgs(),
534
632
  ...remoteArgs,
535
633
  ...withCodexCwdArg([...passthroughArgs, kickoff], process.cwd()),
536
634
  ];
@@ -614,11 +712,25 @@ async function main() {
614
712
  });
615
713
  }
616
714
 
617
- function ensureResolvedCliConfigured(cli: BorgCli): void {
715
+ export function ensureResolvedCliConfigured(cli: BorgCli, active: ActiveCube | null = null): void {
618
716
  const label = cli === 'claude' ? 'Claude Code' : cli === 'codex' ? 'Codex' : 'OpenCode';
717
+ const targetedOpenCodeLaunch = cli === 'opencode' &&
718
+ Boolean(process.env[BORG_LAUNCH_EXPECTED_SEAT_ENV]);
619
719
  try {
620
720
  configureResolvedCli(cli, {
621
- ensureMcp: ensureCliMcpConfigured,
721
+ ensureMcp: (selectedCli) => {
722
+ try {
723
+ ensureCliMcpConfigured(selectedCli);
724
+ } catch (error) {
725
+ if (targetedOpenCodeLaunch && selectedCli === 'opencode') {
726
+ throw new OpenCodeTargetedLaunchConfigError(
727
+ active?.droneLabel ?? '<unknown>',
728
+ active?.worktree ?? process.cwd(),
729
+ );
730
+ }
731
+ throw error;
732
+ }
733
+ },
622
734
  addClaudeProjectSessionStartHook: () => {
623
735
  // gh#673 P2 (WI-1): the orientation hook lives PROJECT-LOCAL in
624
736
  // <root>/.claude/settings.local.json — ensured on every bare
@@ -633,6 +745,7 @@ function ensureResolvedCliConfigured(cli: BorgCli): void {
633
745
  installOpenCodePlugin: installBorgPlugin,
634
746
  });
635
747
  } catch (err: any) {
748
+ if (err instanceof OpenCodeTargetedLaunchConfigError) throw err;
636
749
  console.error(`${consolePrefix()}${chalk.yellow(`warning: ${label} integration check failed: ${err?.message ?? err}`)}`);
637
750
  }
638
751
  try {
@@ -656,6 +769,13 @@ function isEntryInvocation(): boolean {
656
769
 
657
770
  if (isEntryInvocation()) {
658
771
  main().catch((error) => {
772
+ if (
773
+ error instanceof LaunchSeatIdentityChangedError ||
774
+ error instanceof OpenCodeTargetedLaunchConfigError
775
+ ) {
776
+ process.stderr.write(`${error.message}\n`);
777
+ process.exit(1);
778
+ }
659
779
  console.error(`${consolePrefix()}${chalk.red(`\n◼ Error: ${error.message}\n`)}`);
660
780
  process.exit(1);
661
781
  });
package/src/cli-help.ts CHANGED
@@ -32,7 +32,7 @@ export function launchAllHelpText(version: string): string {
32
32
  `Usage:\n` +
33
33
  ` borg launch-all [cube] [options]\n\n` +
34
34
  `Options:\n` +
35
- ` --mode <tmux|windows|pastelist> Select the launch backend\n` +
35
+ ` --mode <tmux|terminals|pastelist> Select the launch backend\n` +
36
36
  ` --only <name> Launch one role or drone label\n` +
37
37
  ` --dry-run Show what would launch\n` +
38
38
  ` --cli <claude|codex|opencode> Select the agent CLI\n` +
@@ -40,7 +40,38 @@ export function launchAllHelpText(version: string): string {
40
40
  ` --yes, -y Skip the large-fleet confirmation\n` +
41
41
  ` --force Override live-session skips\n` +
42
42
  ` --launch-delay <ms> Wait between launches\n` +
43
- ` --help, -h Show this help\n`
43
+ ` --help, -h Show this help\n\n` +
44
+ `Modes:\n` +
45
+ ` terminals Open each drone in its own terminal tab, with the drone's name as\n` +
46
+ ` the tab title. Default on macOS when iTerm2 or Terminal.app is\n` +
47
+ ` installed. Terminal.app opens one named window for each drone\n` +
48
+ ` instead; tabs would need macOS automation permissions, which borg\n` +
49
+ ` does not request.\n` +
50
+ ` tmux Open all drones in one shared tmux session. Default on Linux.\n` +
51
+ ` pastelist Print the launch commands so you can run them yourself.\n`
52
+ );
53
+ }
54
+
55
+ export function seatsHelpText(version: string): string {
56
+ return (
57
+ `borg seats (borgmcp ${version}) — list this machine's registered drones\n\n` +
58
+ `Usage:\n` +
59
+ ` borg seats Show drone, cube, worktree, agent CLI, and local state\n` +
60
+ ` borg seats --help Show this help\n\n` +
61
+ `The local registry belongs to this machine only.\n`
62
+ );
63
+ }
64
+
65
+ export function launchSeatHelpText(version: string): string {
66
+ return (
67
+ `borg launch (borgmcp ${version}) — reopen one registered drone from its worktree\n\n` +
68
+ `Usage:\n` +
69
+ ` borg launch <drone-label-or-id-prefix>\n` +
70
+ ` borg launch <drone-label-or-id-prefix> --cube <name>\n` +
71
+ ` borg launch --help\n\n` +
72
+ `Options:\n` +
73
+ ` --cube <name> Disambiguate the same drone label across cubes\n` +
74
+ ` --help, -h Show this help\n`
44
75
  );
45
76
  }
46
77
 
@@ -65,6 +96,8 @@ export function clientSubcommandHelpText(
65
96
  case 'reset-local-connection': return resetLocalSeatHelpText(version);
66
97
  case 'recover-enrollment': return recoverEnrollmentHelpText(version);
67
98
  case 'cleanup': return cleanupHelpText(version);
99
+ case 'seats': return seatsHelpText(version);
100
+ case 'launch': return launchSeatHelpText(version);
68
101
  case 'launch-all': return launchAllHelpText(version);
69
102
  case 'doctor': return doctorHelpText(version);
70
103
  default: return null;
@@ -104,6 +137,8 @@ export function topLevelHelpText(version: string): string {
104
137
  ` borg reset-local-connection Clear ONLY this worktree's saved connection to its cube (offline; after a rejection)\n` +
105
138
  ` borg recover-enrollment Restore or clear ONLY one failed server enrollment transaction\n` +
106
139
  ` borg cleanup [--prune] Report (or --prune) worktrees orphaned by evicted drones\n` +
140
+ ` borg seats List this machine's registered drones and worktrees\n` +
141
+ ` borg launch <drone-label-or-id-prefix> Reopen one registered drone from its worktree\n` +
107
142
  ` borg launch-all [cube] Launch all drone worktrees of a cube (default: active cube)\n` +
108
143
  ` borg launch-all [cube] --cli claude|codex|opencode\n` +
109
144
  ` Launch all drone worktrees with that agent CLI\n` +
@@ -23,6 +23,7 @@ import {
23
23
  import { shellEscape } from './shell-escape.js';
24
24
  import { BORG_STATE_ROOT_ENV, borgAgentConfigEnv, borgHomeRoot } from './private-root.js';
25
25
  import type { LaunchAccessPaths } from './launch-access.js';
26
+ import { BORG_LAUNCH_EXPECTED_SEAT_ENV } from './cubes.js';
26
27
 
27
28
  // Get __dirname equivalent in ESM
28
29
  const __filename = fileURLToPath(import.meta.url);
@@ -36,6 +37,7 @@ const CLEAR_REWAKE_HOOK_COMMAND = 'borg-clear-rewake';
36
37
  const AUDIT_HOOK_COMMAND = 'borg-log-audit';
37
38
  const FOREIGN_PATH_REMINDER_HOOK_COMMAND = 'borg-foreign-path-reminder';
38
39
  const MCP_COMMAND = 'borg-mcp';
40
+ const OPENCODE_LAUNCH_EXPECTED_SEAT_REFERENCE = `{env:${BORG_LAUNCH_EXPECTED_SEAT_ENV}}`;
39
41
 
40
42
  /**
41
43
  * Claude Code CLI config path. The CLI reads `mcpServers.<name>` from
@@ -1183,6 +1185,28 @@ export function isOpenCodeMcpServerConfigured(
1183
1185
  }
1184
1186
  }
1185
1187
 
1188
+ /**
1189
+ * Launch-time OpenCode registration check. Ordinary launches retain the
1190
+ * existing configured/not-configured behavior. A targeted `borg launch`,
1191
+ * identified by its non-empty expected-seat marker, additionally requires the
1192
+ * config substitution that carries that marker into OpenCode's MCP child.
1193
+ */
1194
+ export function isOpenCodeMcpServerConfiguredForLaunch(
1195
+ configPath: string = path.join(borgHomeRoot(), '.config', 'opencode', 'opencode.json'),
1196
+ env: NodeJS.ProcessEnv = process.env,
1197
+ ): boolean {
1198
+ if (!isOpenCodeMcpServerConfigured(configPath)) return false;
1199
+ if (!env[BORG_LAUNCH_EXPECTED_SEAT_ENV]) return true;
1200
+ try {
1201
+ const borgServer = readJsonFile(configPath)?.mcp?.borg;
1202
+ const environment = borgServer?.environment ?? borgServer?.env;
1203
+ return environment?.[BORG_LAUNCH_EXPECTED_SEAT_ENV] ===
1204
+ OPENCODE_LAUNCH_EXPECTED_SEAT_REFERENCE;
1205
+ } catch {
1206
+ return false;
1207
+ }
1208
+ }
1209
+
1186
1210
  /**
1187
1211
  * Pre-authorize the exact worktree + scratch paths in the launch-root
1188
1212
  * OpenCode config. This intentionally writes only the project-local
@@ -1254,12 +1278,11 @@ export function addOpenCodeLaunchAccess(
1254
1278
 
1255
1279
  /**
1256
1280
  * Add borg MCP server to OpenCode using `opencode mcp add` CLI.
1257
- * Pins BORG_SESSION=1, BORG_AGENT_KIND=opencode, the legacy BORG_OPENCODE=1,
1258
- * and BORG_API_URL in the server environment so the MCP child inherits the
1259
- * activation gate + explicit agent-kind signal (same approach as Codex's
1260
- * pinned env OpenCode MCP children only see pinned env, not parent process
1261
- * env). Existing configs with BORG_OPENCODE remain supported by the runtime
1262
- * fallback.
1281
+ * Pins activation and agent-kind signals plus an OpenCode config substitution
1282
+ * for the launch-scoped expected seat. OpenCode resolves `{env:NAME}` from its
1283
+ * own launch environment before starting the MCP child, so `borg launch`
1284
+ * reaches the identity check without persisting one launch's value. Existing
1285
+ * configs with BORG_OPENCODE remain supported by the runtime fallback.
1263
1286
  */
1264
1287
  export function addOpenCodeMcpServer(): void {
1265
1288
  try {
@@ -1270,8 +1293,10 @@ export function addOpenCodeMcpServer(): void {
1270
1293
  const stateRootEnvArg = stateRoot
1271
1294
  ? ` --env ${BORG_STATE_ROOT_ENV}=${shellQuote(stateRoot)}`
1272
1295
  : '';
1296
+ const launchExpectedSeatEnvArg =
1297
+ ` --env ${BORG_LAUNCH_EXPECTED_SEAT_ENV}=${shellQuote(OPENCODE_LAUNCH_EXPECTED_SEAT_REFERENCE)}`;
1273
1298
  execSync(
1274
- `opencode mcp add borg --env BORG_SESSION=1 --env BORG_AGENT_KIND=opencode --env BORG_OPENCODE=1${apiUrlEnvArg}${stateRootEnvArg} -- ${shellQuote(MCP_COMMAND)}`,
1299
+ `opencode mcp add borg --env BORG_SESSION=1 --env BORG_AGENT_KIND=opencode --env BORG_OPENCODE=1${apiUrlEnvArg}${stateRootEnvArg}${launchExpectedSeatEnvArg} -- ${shellQuote(MCP_COMMAND)}`,
1275
1300
  { stdio: 'inherit', env: borgAgentConfigEnv(process.env) }
1276
1301
  );
1277
1302
  } catch (error: any) {