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.
- package/README.md +30 -5
- package/dist/agent-integration-health.d.ts +9 -5
- package/dist/agent-integration-health.d.ts.map +1 -1
- package/dist/agent-integration-health.js +104 -5
- package/dist/agent-integration-health.js.map +1 -1
- package/dist/backends/{launch-all-windows.d.ts → launch-all-terminals.d.ts} +5 -3
- package/dist/backends/launch-all-terminals.d.ts.map +1 -0
- package/dist/backends/{launch-all-windows.js → launch-all-terminals.js} +26 -12
- package/dist/backends/launch-all-terminals.js.map +1 -0
- package/dist/bare-launch-menu.d.ts +53 -16
- package/dist/bare-launch-menu.d.ts.map +1 -1
- package/dist/bare-launch-menu.js +95 -4
- package/dist/bare-launch-menu.js.map +1 -1
- package/dist/claude.d.ts +6 -0
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +84 -8
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts +2 -0
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +31 -2
- package/dist/cli-help.js.map +1 -1
- package/dist/config-utils.d.ts +12 -6
- package/dist/config-utils.d.ts.map +1 -1
- package/dist/config-utils.js +31 -8
- package/dist/config-utils.js.map +1 -1
- package/dist/cubes.d.ts +17 -0
- package/dist/cubes.d.ts.map +1 -1
- package/dist/cubes.js +87 -1
- package/dist/cubes.js.map +1 -1
- package/dist/ensure-mcp-config.d.ts.map +1 -1
- package/dist/ensure-mcp-config.js +5 -2
- package/dist/ensure-mcp-config.js.map +1 -1
- package/dist/launch-all-cmd.d.ts.map +1 -1
- package/dist/launch-all-cmd.js +20 -10
- package/dist/launch-all-cmd.js.map +1 -1
- package/dist/log-audit-core.d.ts +7 -0
- package/dist/log-audit-core.d.ts.map +1 -0
- package/dist/log-audit-core.js +80 -0
- package/dist/log-audit-core.js.map +1 -0
- package/dist/log-audit.d.ts +3 -3
- package/dist/log-audit.js +7 -110
- package/dist/log-audit.js.map +1 -1
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js +6 -1
- package/dist/opencode-drone.js.map +1 -1
- package/dist/opencode-plugin.d.ts +47 -7
- package/dist/opencode-plugin.d.ts.map +1 -1
- package/dist/opencode-plugin.js +187 -23
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/parse-launch-all-args.d.ts +1 -1
- package/dist/parse-launch-all-args.d.ts.map +1 -1
- package/dist/parse-launch-all-args.js +3 -3
- package/dist/parse-launch-all-args.js.map +1 -1
- package/dist/regen-format.js +1 -1
- package/dist/regen-format.js.map +1 -1
- package/dist/resolved-cli-config.d.ts +1 -0
- package/dist/resolved-cli-config.d.ts.map +1 -1
- package/dist/resolved-cli-config.js +1 -0
- package/dist/resolved-cli-config.js.map +1 -1
- package/dist/seat-commands.d.ts +58 -0
- package/dist/seat-commands.d.ts.map +1 -0
- package/dist/seat-commands.js +240 -0
- package/dist/seat-commands.js.map +1 -0
- package/dist/seats.d.ts +8 -0
- package/dist/seats.d.ts.map +1 -1
- package/dist/seats.js +14 -0
- package/dist/seats.js.map +1 -1
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -1
- package/dist/unknown-subcommand.js +2 -0
- package/dist/unknown-subcommand.js.map +1 -1
- package/docs/LOCAL_SERVER.md +17 -4
- package/package.json +1 -1
- package/src/agent-integration-health.ts +121 -6
- package/src/backends/{launch-all-windows.ts → launch-all-terminals.ts} +33 -15
- package/src/bare-launch-menu.ts +141 -18
- package/src/claude.ts +128 -7
- package/src/cli-help.ts +37 -2
- package/src/config-utils.ts +33 -8
- package/src/cubes.ts +118 -1
- package/src/ensure-mcp-config.ts +5 -2
- package/src/launch-all-cmd.ts +24 -11
- package/src/log-audit-core.ts +81 -0
- package/src/log-audit.ts +6 -117
- package/src/opencode-drone.ts +6 -1
- package/src/opencode-plugin.ts +216 -22
- package/src/parse-launch-all-args.ts +4 -4
- package/src/regen-format.ts +1 -1
- package/src/resolved-cli-config.ts +2 -0
- package/src/seat-commands.ts +322 -0
- package/src/seats.ts +15 -0
- package/src/unknown-subcommand.ts +2 -0
- package/dist/backends/launch-all-windows.d.ts.map +0 -1
- 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:
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1
|
+
// client#400 — terminals 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
|
-
|
|
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:
|
|
30
|
-
const hasITerm = deps.pathExists(
|
|
31
|
-
const hasTerminal = deps.pathExists(
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
108
|
+
export async function runTerminalsBackend(
|
|
91
109
|
candidates: DroneCandidate[],
|
|
92
|
-
opts:
|
|
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
|
|
117
|
+
`character — unsafe for --mode terminals; use --mode tmux instead.\n`
|
|
100
118
|
);
|
|
101
119
|
return false;
|
|
102
120
|
}
|
package/src/bare-launch-menu.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
18
|
-
* scripted/programmatic
|
|
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-
|
|
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
|
|
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
|
|
63
|
-
*
|
|
64
|
-
* through to the existing
|
|
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
|
|
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',
|