borgmcp 3.3.0 → 3.5.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 (94) hide show
  1. package/README.md +30 -5
  2. package/dist/agent-integration-health.d.ts +9 -5
  3. package/dist/agent-integration-health.d.ts.map +1 -1
  4. package/dist/agent-integration-health.js +104 -5
  5. package/dist/agent-integration-health.js.map +1 -1
  6. package/dist/backends/{launch-all-windows.d.ts → launch-all-terminals.d.ts} +5 -3
  7. package/dist/backends/launch-all-terminals.d.ts.map +1 -0
  8. package/dist/backends/{launch-all-windows.js → launch-all-terminals.js} +26 -12
  9. package/dist/backends/launch-all-terminals.js.map +1 -0
  10. package/dist/bare-launch-menu.d.ts +53 -16
  11. package/dist/bare-launch-menu.d.ts.map +1 -1
  12. package/dist/bare-launch-menu.js +95 -4
  13. package/dist/bare-launch-menu.js.map +1 -1
  14. package/dist/claude.d.ts +6 -0
  15. package/dist/claude.d.ts.map +1 -1
  16. package/dist/claude.js +84 -8
  17. package/dist/claude.js.map +1 -1
  18. package/dist/cli-help.d.ts +2 -0
  19. package/dist/cli-help.d.ts.map +1 -1
  20. package/dist/cli-help.js +31 -2
  21. package/dist/cli-help.js.map +1 -1
  22. package/dist/config-utils.d.ts +12 -6
  23. package/dist/config-utils.d.ts.map +1 -1
  24. package/dist/config-utils.js +31 -8
  25. package/dist/config-utils.js.map +1 -1
  26. package/dist/cubes.d.ts +17 -0
  27. package/dist/cubes.d.ts.map +1 -1
  28. package/dist/cubes.js +87 -1
  29. package/dist/cubes.js.map +1 -1
  30. package/dist/ensure-mcp-config.d.ts.map +1 -1
  31. package/dist/ensure-mcp-config.js +5 -2
  32. package/dist/ensure-mcp-config.js.map +1 -1
  33. package/dist/launch-all-cmd.d.ts.map +1 -1
  34. package/dist/launch-all-cmd.js +20 -10
  35. package/dist/launch-all-cmd.js.map +1 -1
  36. package/dist/log-audit-core.d.ts +7 -0
  37. package/dist/log-audit-core.d.ts.map +1 -0
  38. package/dist/log-audit-core.js +80 -0
  39. package/dist/log-audit-core.js.map +1 -0
  40. package/dist/log-audit.d.ts +3 -3
  41. package/dist/log-audit.js +7 -110
  42. package/dist/log-audit.js.map +1 -1
  43. package/dist/opencode-drone.d.ts.map +1 -1
  44. package/dist/opencode-drone.js +6 -1
  45. package/dist/opencode-drone.js.map +1 -1
  46. package/dist/opencode-plugin.d.ts +47 -7
  47. package/dist/opencode-plugin.d.ts.map +1 -1
  48. package/dist/opencode-plugin.js +187 -23
  49. package/dist/opencode-plugin.js.map +1 -1
  50. package/dist/parse-launch-all-args.d.ts +1 -1
  51. package/dist/parse-launch-all-args.d.ts.map +1 -1
  52. package/dist/parse-launch-all-args.js +3 -3
  53. package/dist/parse-launch-all-args.js.map +1 -1
  54. package/dist/regen-format.js +1 -1
  55. package/dist/regen-format.js.map +1 -1
  56. package/dist/resolved-cli-config.d.ts +1 -0
  57. package/dist/resolved-cli-config.d.ts.map +1 -1
  58. package/dist/resolved-cli-config.js +1 -0
  59. package/dist/resolved-cli-config.js.map +1 -1
  60. package/dist/seat-commands.d.ts +58 -0
  61. package/dist/seat-commands.d.ts.map +1 -0
  62. package/dist/seat-commands.js +240 -0
  63. package/dist/seat-commands.js.map +1 -0
  64. package/dist/seats.d.ts +8 -0
  65. package/dist/seats.d.ts.map +1 -1
  66. package/dist/seats.js +14 -0
  67. package/dist/seats.js.map +1 -1
  68. package/dist/unknown-subcommand.d.ts +1 -1
  69. package/dist/unknown-subcommand.d.ts.map +1 -1
  70. package/dist/unknown-subcommand.js +2 -0
  71. package/dist/unknown-subcommand.js.map +1 -1
  72. package/docs/LOCAL_SERVER.md +17 -4
  73. package/package.json +1 -1
  74. package/src/agent-integration-health.ts +121 -6
  75. package/src/backends/{launch-all-windows.ts → launch-all-terminals.ts} +33 -15
  76. package/src/bare-launch-menu.ts +141 -18
  77. package/src/claude.ts +128 -7
  78. package/src/cli-help.ts +37 -2
  79. package/src/config-utils.ts +33 -8
  80. package/src/cubes.ts +118 -1
  81. package/src/ensure-mcp-config.ts +5 -2
  82. package/src/launch-all-cmd.ts +24 -11
  83. package/src/log-audit-core.ts +81 -0
  84. package/src/log-audit.ts +6 -117
  85. package/src/opencode-drone.ts +6 -1
  86. package/src/opencode-plugin.ts +216 -22
  87. package/src/parse-launch-all-args.ts +4 -4
  88. package/src/regen-format.ts +1 -1
  89. package/src/resolved-cli-config.ts +2 -0
  90. package/src/seat-commands.ts +322 -0
  91. package/src/seats.ts +15 -0
  92. package/src/unknown-subcommand.ts +2 -0
  93. package/dist/backends/launch-all-windows.d.ts.map +0 -1
  94. package/dist/backends/launch-all-windows.js.map +0 -1
@@ -3,12 +3,18 @@ import path from 'node:path';
3
3
  import which from 'which';
4
4
  import {
5
5
  inspectManagedAgentHookConfigs,
6
+ isOpenCodeMcpServerConfigured,
6
7
  type ManagedAgentHookConfigHealth,
7
8
  refreshManagedAgentHookConfigs,
8
9
  refreshManagedAgentMcpConfigs,
9
10
  } from './config-utils.js';
10
- import { borgHomeRoot } from './private-root.js';
11
+ import { borgHomeRoot, isCanonicalPath } from './private-root.js';
11
12
  import { getPackageVersion } from './version.js';
13
+ import {
14
+ buildBorgPluginSource,
15
+ installBorgPlugin,
16
+ openCodePluginPath,
17
+ } from './opencode-plugin.js';
12
18
 
13
19
  export const AGENT_HOOK_BINS = [
14
20
  'borg-regen',
@@ -32,9 +38,17 @@ export interface AgentHookBinHealth {
32
38
  export interface AgentIntegrationHealth {
33
39
  expectedVersion: string;
34
40
  bins: AgentHookBinHealth[];
35
- issues: Array<AgentHookBinHealth | ManagedAgentHookConfigHealth>;
41
+ issues: Array<AgentHookBinHealth | ManagedAgentHookConfigHealth | OpenCodePluginHealth>;
36
42
  hookConfigs: ManagedAgentHookConfigHealth[];
37
- openCodePlugin: { path: string; present: boolean };
43
+ openCodePlugin: OpenCodePluginHealth;
44
+ }
45
+
46
+ export interface OpenCodePluginHealth {
47
+ path: string;
48
+ configured: boolean;
49
+ status: 'ok' | 'present' | 'absent' | 'missing' | 'outdated' | 'unreadable' | 'refused';
50
+ version?: string;
51
+ detail?: string;
38
52
  }
39
53
 
40
54
  export interface InspectAgentIntegrationHealthOptions {
@@ -70,6 +84,72 @@ function defaultResolveBin(name: AgentHookBinName, searchPath: string | undefine
70
84
  }
71
85
  }
72
86
 
87
+ function inspectOpenCodePlugin(homeDir: string, expectedVersion: string): OpenCodePluginHealth {
88
+ const pluginPath = openCodePluginPath(homeDir);
89
+ const configured = isOpenCodeMcpServerConfigured(
90
+ path.join(homeDir, '.config', 'opencode', 'opencode.json'),
91
+ );
92
+ try {
93
+ let metadata: fs.Stats;
94
+ try {
95
+ metadata = fs.lstatSync(pluginPath);
96
+ } catch (error) {
97
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
98
+ if (!isCanonicalPath(pluginPath)) {
99
+ return {
100
+ path: pluginPath,
101
+ configured,
102
+ status: 'refused',
103
+ detail: 'plugin path contains a symlink',
104
+ };
105
+ }
106
+ return { path: pluginPath, configured, status: configured ? 'missing' : 'absent' };
107
+ }
108
+ throw error;
109
+ }
110
+ if (metadata.isSymbolicLink()) {
111
+ return {
112
+ path: pluginPath,
113
+ configured,
114
+ status: 'refused',
115
+ detail: 'plugin path is a symlink',
116
+ };
117
+ }
118
+ if (!isCanonicalPath(pluginPath)) {
119
+ return {
120
+ path: pluginPath,
121
+ configured,
122
+ status: 'refused',
123
+ detail: 'plugin path contains a symlink',
124
+ };
125
+ }
126
+ if (!metadata.isFile()) {
127
+ return {
128
+ path: pluginPath,
129
+ configured,
130
+ status: 'refused',
131
+ detail: 'plugin path is not a regular file',
132
+ };
133
+ }
134
+ const source = fs.readFileSync(pluginPath, 'utf8');
135
+ const marker = source.match(/borgmcp-opencode-plugin:([^;\s]+);opencode=/)?.[1];
136
+ if (!configured) {
137
+ return { path: pluginPath, configured, status: 'present', version: marker ?? 'unknown' };
138
+ }
139
+ if (source === buildBorgPluginSource(expectedVersion)) {
140
+ return { path: pluginPath, configured, status: 'ok', version: expectedVersion };
141
+ }
142
+ return { path: pluginPath, configured, status: 'outdated', version: marker ?? 'unknown' };
143
+ } catch (error) {
144
+ return {
145
+ path: pluginPath,
146
+ configured,
147
+ status: 'unreadable',
148
+ detail: error instanceof Error ? error.message : String(error),
149
+ };
150
+ }
151
+ }
152
+
73
153
  export function inspectAgentIntegrationHealth(
74
154
  options: InspectAgentIntegrationHealthOptions = {},
75
155
  ): AgentIntegrationHealth {
@@ -105,17 +185,22 @@ export function inspectAgentIntegrationHealth(
105
185
  }
106
186
  return { name, status: 'ok', resolvedPath, owner: owner.name, version: owner.version };
107
187
  });
108
- const pluginPath = path.join(homeDir, '.config', 'opencode', 'plugins', 'borg-orient.js');
109
188
  const hookConfigs = inspectManagedAgentHookConfigs(homeDir);
189
+ const openCodePlugin = inspectOpenCodePlugin(homeDir, expectedVersion);
110
190
  return {
111
191
  expectedVersion,
112
192
  bins,
113
193
  issues: [
114
194
  ...bins.filter((bin) => bin.status !== 'ok'),
115
195
  ...hookConfigs.filter((config) => config.status === 'stale' || config.status === 'invalid'),
196
+ ...(openCodePlugin.status === 'unreadable' ||
197
+ (openCodePlugin.status === 'refused' && openCodePlugin.configured) ||
198
+ (openCodePlugin.configured && openCodePlugin.status !== 'ok')
199
+ ? [openCodePlugin]
200
+ : []),
116
201
  ],
117
202
  hookConfigs,
118
- openCodePlugin: { path: pluginPath, present: fs.existsSync(pluginPath) },
203
+ openCodePlugin,
119
204
  };
120
205
  }
121
206
 
@@ -129,6 +214,22 @@ function renderBin(bin: AgentHookBinHealth, expectedVersion: string): string {
129
214
  }
130
215
  }
131
216
 
217
+ function renderOpenCodePlugin(
218
+ plugin: OpenCodePluginHealth,
219
+ expectedVersion: string,
220
+ ): string {
221
+ const prefix = 'OpenCode borg-orient.js plugin:';
222
+ switch (plugin.status) {
223
+ case 'ok': return `${prefix} ok (${plugin.version})`;
224
+ case 'present': return `${prefix} present`;
225
+ case 'absent': return `${prefix} absent`;
226
+ case 'missing': return `${prefix} missing at ${plugin.path}`;
227
+ case 'outdated': return `${prefix} version ${plugin.version}, expected ${expectedVersion} at ${plugin.path}`;
228
+ case 'unreadable': return `${prefix} unreadable at ${plugin.path}${plugin.detail ? ` (${plugin.detail})` : ''}`;
229
+ case 'refused': return `${prefix} refused at ${plugin.path}${plugin.detail ? ` (${plugin.detail})` : ''}`;
230
+ }
231
+ }
232
+
132
233
  export function renderAgentIntegrationHealth(report: AgentIntegrationHealth): string {
133
234
  const lines = [
134
235
  `Borg agent integration (borgmcp ${report.expectedVersion})`,
@@ -137,7 +238,7 @@ export function renderAgentIntegrationHealth(report: AgentIntegrationHealth): st
137
238
  ...report.hookConfigs.map((item) => (
138
239
  ` ${item.status}: ${item.path}${item.detail ? ` (${item.detail})` : ''}`
139
240
  )),
140
- `OpenCode borg-orient.js plugin: ${report.openCodePlugin.present ? 'present' : 'absent'} (${report.openCodePlugin.path})`,
241
+ renderOpenCodePlugin(report.openCodePlugin, report.expectedVersion),
141
242
  ];
142
243
  for (const bin of report.bins) {
143
244
  switch (bin.status) {
@@ -168,6 +269,19 @@ export function renderAgentIntegrationHealth(report: AgentIntegrationHealth): st
168
269
  : `Repair invalid managed hook config ${config.path}, then run: borg update --yes`);
169
270
  }
170
271
  }
272
+ if (report.openCodePlugin.status === 'missing') {
273
+ lines.push('Repair missing OpenCode plugin: borg update --yes');
274
+ } else if (report.openCodePlugin.status === 'outdated' && report.openCodePlugin.configured) {
275
+ lines.push('Repair OpenCode plugin: borg update --yes');
276
+ } else if (report.openCodePlugin.status === 'unreadable') {
277
+ lines.push(report.openCodePlugin.configured
278
+ ? `Fix or replace unreadable OpenCode plugin ${report.openCodePlugin.path}, then run: borg update --yes`
279
+ : `Fix or remove unreadable OpenCode plugin ${report.openCodePlugin.path}`);
280
+ } else if (report.openCodePlugin.status === 'refused' && report.openCodePlugin.configured) {
281
+ lines.push(
282
+ `Remove or replace the OpenCode plugin path ${report.openCodePlugin.path}, then run: borg update --yes`,
283
+ );
284
+ }
171
285
  return `${lines.join('\n')}\n`;
172
286
  }
173
287
 
@@ -223,6 +337,7 @@ export function refreshAndVerifyManagedAgentIntegrations(): void {
223
337
  'Repair the named managed hook config, then rerun borg update --yes',
224
338
  );
225
339
  }
340
+ if (isOpenCodeMcpServerConfigured()) installBorgPlugin();
226
341
  try {
227
342
  assertAgentIntegrationHealthy(inspectAgentIntegrationHealth());
228
343
  } catch (error) {
@@ -1,4 +1,4 @@
1
- // gh#556 Part 2 windows backend (explicit `--mode windows` only, spec §4.2).
1
+ // client#400terminals backend (`--mode terminals`; macOS auto default).
2
2
  // Opens each worktree as a new terminal window/tab. macOS: iTerm/Terminal via
3
3
  // osascript. Linux: $BORG_TERMINAL / $TERMINAL / probed emulator. No shared
4
4
  // session, so no post-launch attach. Hard-fails if no compatible terminal found.
@@ -7,10 +7,20 @@ import type { DroneCandidate } from '../launch-all-discovery.js';
7
7
  import type { LaunchAllDeps } from '../launch-all-deps.js';
8
8
  import { buildLaunchCommand } from '../launch-all-command.js';
9
9
  import { writeLockMarker } from '../launch-all-locks.js';
10
+ import { composeTerminalTitle } from '../terminal-title.js';
11
+ import { shellEscape } from '../shell-escape.js';
10
12
 
11
- export interface WindowsOpts {
13
+ const ITERM_APP = '/Applications/iTerm.app';
14
+ const TERMINAL_APPS = [
15
+ '/System/Applications/Utilities/Terminal.app',
16
+ '/Applications/Utilities/Terminal.app',
17
+ '/Applications/Terminal.app',
18
+ ] as const;
19
+
20
+ export interface TerminalsOpts {
12
21
  borgPath: string;
13
22
  platform: NodeJS.Platform;
23
+ cubeName: string;
14
24
  launchedAtISO: string;
15
25
  /** Stagger between drone launches (ms) to avoid the rate limiter; 0 disables. */
16
26
  launchDelayMs: number;
@@ -25,13 +35,17 @@ function appleScriptEscape(s: string): string {
25
35
 
26
36
  class NoTerminalError extends Error {}
27
37
 
38
+ export function hasMacOSTerminalApp(deps: Pick<LaunchAllDeps, 'pathExists'>): boolean {
39
+ return deps.pathExists(ITERM_APP) || TERMINAL_APPS.some((path) => deps.pathExists(path));
40
+ }
41
+
28
42
  /** macOS: open each candidate in iTerm or Terminal via osascript. */
29
- async function launchMacOS(candidates: DroneCandidate[], opts: WindowsOpts, deps: LaunchAllDeps): Promise<void> {
30
- const hasITerm = deps.pathExists('/Applications/iTerm.app');
31
- const hasTerminal = deps.pathExists('/Applications/Terminal.app');
43
+ async function launchMacOS(candidates: DroneCandidate[], opts: TerminalsOpts, deps: LaunchAllDeps): Promise<void> {
44
+ const hasITerm = deps.pathExists(ITERM_APP);
45
+ const hasTerminal = TERMINAL_APPS.some((path) => deps.pathExists(path));
32
46
  if (!hasITerm && !hasTerminal) {
33
47
  throw new NoTerminalError(
34
- 'borg launch-all: --mode windows requires a compatible terminal app.\n' +
48
+ 'borg launch-all: --mode terminals requires a compatible terminal app.\n' +
35
49
  'Not found: iTerm.app, Terminal.app\n' +
36
50
  'Install iTerm2 (https://iterm2.com) or use --mode tmux (brew install tmux).\n'
37
51
  );
@@ -40,16 +54,17 @@ async function launchMacOS(candidates: DroneCandidate[], opts: WindowsOpts, deps
40
54
  const c = candidates[i];
41
55
  if (i > 0 && opts.launchDelayMs > 0) await opts.sleep(opts.launchDelayMs); // rate-limit stagger
42
56
  const cmd = buildLaunchCommand(c.worktreeDir, opts.borgPath);
57
+ const title = composeTerminalTitle({ label: c.droneLabel, cubeName: opts.cubeName }, '');
43
58
  const script = hasITerm
44
- ? `tell application "iTerm"\n tell current window to create tab with default profile command "${appleScriptEscape(cmd)}"\nend tell`
45
- : `tell application "Terminal"\n do script "${appleScriptEscape(cmd)}"\n activate\nend tell`;
59
+ ? `tell application "iTerm"\n if (count of windows) = 0 then\n set launchedWindow to (create window with default profile command "${appleScriptEscape(cmd)}")\n tell current session of launchedWindow to set name to "${appleScriptEscape(title)}"\n else\n tell current window\n set launchedTab to (create tab with default profile command "${appleScriptEscape(cmd)}")\n tell current session of launchedTab to set name to "${appleScriptEscape(title)}"\n end tell\n end if\nend tell`
60
+ : `tell application "Terminal"\n set launchedTab to do script "${appleScriptEscape(cmd)}"\n set custom title of launchedTab to "${appleScriptEscape(title)}"\n set title displays custom title of launchedTab to true\n activate\nend tell`;
46
61
  deps.runSync('osascript', ['-e', script]);
47
62
  writeLockMarker(deps, c.cubeId, c.droneLabel, c.worktreeDir, opts.launchedAtISO);
48
63
  }
49
64
  }
50
65
 
51
66
  /** Linux: open each candidate in the first available terminal emulator. */
52
- async function launchLinux(candidates: DroneCandidate[], opts: WindowsOpts, deps: LaunchAllDeps): Promise<void> {
67
+ async function launchLinux(candidates: DroneCandidate[], opts: TerminalsOpts, deps: LaunchAllDeps): Promise<void> {
53
68
  const explicit = deps.getEnv('BORG_TERMINAL') || deps.getEnv('TERMINAL');
54
69
  const probe = ['gnome-terminal', 'konsole', 'kitty', 'wezterm', 'xterm'];
55
70
  let term: string | undefined = explicit;
@@ -63,14 +78,17 @@ async function launchLinux(candidates: DroneCandidate[], opts: WindowsOpts, deps
63
78
  }
64
79
  if (!term) {
65
80
  throw new NoTerminalError(
66
- 'borg launch-all: --mode windows requires a terminal emulator.\n' +
81
+ 'borg launch-all: --mode terminals requires a terminal emulator.\n' +
67
82
  'Not found. Set $BORG_TERMINAL=<path> or use --mode tmux.\n'
68
83
  );
69
84
  }
70
85
  for (let i = 0; i < candidates.length; i++) {
71
86
  const c = candidates[i];
72
87
  if (i > 0 && opts.launchDelayMs > 0) await opts.sleep(opts.launchDelayMs); // rate-limit stagger
73
- const cmd = buildLaunchCommand(c.worktreeDir, opts.borgPath, { keepOpenOnFail: true });
88
+ const title = composeTerminalTitle({ label: c.droneLabel, cubeName: opts.cubeName }, '');
89
+ const cmd =
90
+ `printf '\\033]0;%s\\007' ${shellEscape(title)}; ` +
91
+ buildLaunchCommand(c.worktreeDir, opts.borgPath, { keepOpenOnFail: true });
74
92
  // `<term> -e sh -c '<cmd>'` opens a new window running the command.
75
93
  deps.runSync(term, ['-e', 'sh', '-c', cmd]);
76
94
  writeLockMarker(deps, c.cubeId, c.droneLabel, c.worktreeDir, opts.launchedAtISO);
@@ -83,20 +101,20 @@ async function launchLinux(candidates: DroneCandidate[], opts: WindowsOpts, deps
83
101
  * string literal (parse error). Reject such paths up front (the user's own path;
84
102
  * pathological) — fail-loud + skip, never silently mangle. Applies to both
85
103
  * platforms defensively (the tmux primary handles control chars fine via
86
- * shellEscape + array-args, so this guard is windows-backend-specific).
104
+ * shellEscape + array-args, so this guard is terminals-backend-specific).
87
105
  */
88
106
  const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
89
107
 
90
- export async function runWindowsBackend(
108
+ export async function runTerminalsBackend(
91
109
  candidates: DroneCandidate[],
92
- opts: WindowsOpts,
110
+ opts: TerminalsOpts,
93
111
  deps: LaunchAllDeps
94
112
  ): Promise<void> {
95
113
  const safe = candidates.filter((c) => {
96
114
  if (CONTROL_CHAR_RE.test(c.worktreeDir)) {
97
115
  deps.stderr(
98
116
  `skipping ${c.droneLabel} (${JSON.stringify(c.worktreeDir)}): worktree path contains a control ` +
99
- `character — unsafe for --mode windows; use --mode tmux instead.\n`
117
+ `character — unsafe for --mode terminals; use --mode tmux instead.\n`
100
118
  );
101
119
  return false;
102
120
  }
@@ -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',