@aragon-agent/cli 0.6.24 → 0.6.26
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/CHANGELOG.md +1924 -1786
- package/README.md +2730 -2562
- package/dist/agent/controller.d.ts +49 -3
- package/dist/agent/controller.js +78 -3
- package/dist/agent/conversation-reset.d.ts +91 -0
- package/dist/agent/conversation-reset.js +66 -0
- package/dist/agent/model-probe-limits.d.ts +39 -0
- package/dist/agent/model-probe-limits.js +25 -0
- package/dist/agent/model-probe.d.ts +104 -0
- package/dist/agent/model-probe.js +391 -0
- package/dist/agent/reducer.d.ts +22 -0
- package/dist/agent/reducer.js +49 -14
- package/dist/cli.js +214 -2
- package/dist/commands/branch-command.d.ts +61 -0
- package/dist/commands/branch-command.js +117 -0
- package/dist/commands/builtins.js +62 -40
- package/dist/commands/clear-command.d.ts +27 -0
- package/dist/commands/clear-command.js +107 -0
- package/dist/commands/registry.d.ts +48 -1
- package/dist/commands/session-command.d.ts +54 -0
- package/dist/commands/session-command.js +109 -0
- package/dist/config/load.d.ts +15 -0
- package/dist/config/model-profiles.d.ts +8 -0
- package/dist/config/model-profiles.js +10 -0
- package/dist/exec/index.js +10 -1
- package/dist/logging/logger.d.ts +1 -1
- package/dist/proc/terminal-plan.d.ts +155 -0
- package/dist/proc/terminal-plan.js +360 -0
- package/dist/session/branch-invocation.d.ts +83 -0
- package/dist/session/branch-invocation.js +109 -0
- package/dist/session/branch-launch.d.ts +70 -0
- package/dist/session/branch-launch.js +248 -0
- package/dist/session/checkpoint.d.ts +257 -0
- package/dist/session/checkpoint.js +512 -0
- package/dist/session/clear-archive.d.ts +51 -0
- package/dist/session/clear-archive.js +160 -0
- package/dist/session/cli-commands.d.ts +2 -0
- package/dist/session/cli-commands.js +36 -5
- package/dist/session/identity.d.ts +72 -0
- package/dist/session/identity.js +148 -0
- package/dist/session/limits.d.ts +66 -0
- package/dist/session/limits.js +66 -0
- package/dist/session/persist.d.ts +30 -5
- package/dist/session/startup-session.d.ts +107 -0
- package/dist/session/startup-session.js +315 -0
- package/dist/session/store.d.ts +60 -1
- package/dist/session/store.js +95 -6
- package/dist/todo/types.d.ts +5 -3
- package/dist/ui/App.d.ts +54 -2
- package/dist/ui/App.js +154 -28
- package/dist/ui/Header.js +8 -16
- package/dist/ui/StatusBar.d.ts +8 -0
- package/dist/ui/StatusBar.js +12 -3
- package/dist/ui/entries/CompactionCard.d.ts +5 -4
- package/dist/ui/entries/CompactionCard.js +5 -4
- package/dist/ui/layout/header-hints.d.ts +68 -0
- package/dist/ui/layout/header-hints.js +138 -0
- package/dist/ui/layout/session-chip.d.ts +52 -0
- package/dist/ui/layout/session-chip.js +64 -0
- package/dist/ui/layout/status-layout.d.ts +10 -0
- package/dist/ui/layout/status-layout.js +8 -1
- package/dist/ui/overlays/HelpOverlay.js +17 -5
- package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
- package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
- package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
- package/dist/ui/overlays/ModelProfileEditor.js +117 -68
- package/dist/ui/overlays/SettingsScreen.d.ts +8 -0
- package/dist/ui/overlays/SettingsScreen.js +34 -2
- package/dist/ui/overlays/model-probe-view.d.ts +128 -0
- package/dist/ui/overlays/model-probe-view.js +225 -0
- package/dist/ui/overlays/model-profile-state.d.ts +9 -1
- package/dist/ui/overlays/model-profile-state.js +10 -0
- package/dist/ui/overlays/use-model-probe.d.ts +30 -0
- package/dist/ui/overlays/use-model-probe.js +109 -0
- package/dist/ui/session-bridge.d.ts +26 -0
- package/dist/ui/session-bridge.js +32 -0
- package/package.json +2 -2
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a new terminal window looks like from the outside (tui-branch-terminal-shortcut
|
|
3
|
+
* 3.7).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/proc/**` is inside `glyphs.test.ts::inScope`.
|
|
6
|
+
*
|
|
7
|
+
* PURE, AND THAT IS THE POINT. Three platforms, at least ten terminal emulators,
|
|
8
|
+
* four exec-argument conventions and Windows' three layers of parsing (we -> `cmd`
|
|
9
|
+
* -> `PowerShell`) all collapse into "what should we spawn, with which argv". A
|
|
10
|
+
* command body that called `spawn('cmd', ['/c','start', ...])` inline would bury
|
|
11
|
+
* the only testable part of this feature - the command line itself - inside
|
|
12
|
+
* untestable IO. `proc/readiness.ts` and `proc/classify.ts` set the same precedent
|
|
13
|
+
* inside this directory.
|
|
14
|
+
*
|
|
15
|
+
* WHY SESSION CODE MAY DEPEND ON THIS MODULE: it imports nothing from
|
|
16
|
+
* `node:child_process`, reads no configuration and creates no process. The single
|
|
17
|
+
* place a process is created stays in `session/branch-launch.ts`, so `proc/` keeps
|
|
18
|
+
* meaning "processes" and `session/` keeps meaning "sessions".
|
|
19
|
+
*
|
|
20
|
+
* EVERY RUNG CARRIES ITS OWN WORKING DIRECTORY (D-16 / D-23 / I-9). This is the
|
|
21
|
+
* one thing a reviewer will assume is free and is not: `spawn({ cwd })` decides the
|
|
22
|
+
* cwd of the process WE start, and for `wt -w 0 nt` (a new tab in an already
|
|
23
|
+
* running Windows Terminal) or `osascript` (a new Terminal.app window) that
|
|
24
|
+
* process merely forwards the request - the window opens in the terminal's own
|
|
25
|
+
* default directory. So `wt` passes `-d`, and both script rungs start with
|
|
26
|
+
* `cd <cwd> &&`. `--resume` does not restore a cwd, so a window that opens in the
|
|
27
|
+
* wrong place can never be corrected from inside the branch.
|
|
28
|
+
*/
|
|
29
|
+
import { statSync } from 'node:fs';
|
|
30
|
+
import { isAbsolute, join } from 'node:path';
|
|
31
|
+
/**
|
|
32
|
+
* The POSIX ladder (D-6), most-specific first.
|
|
33
|
+
*
|
|
34
|
+
* `--` TERMINATES THE EMULATOR'S OWN OPTION PARSING for `gnome-terminal`, `kitty`
|
|
35
|
+
* and `wezterm` (which spells it `start --`). Without it their parsers eat the
|
|
36
|
+
* `-c` that belongs to the shell we are starting, and the window opens with the
|
|
37
|
+
* emulator's own error instead of our command - the same class of defect the
|
|
38
|
+
* `wt` rung has (D-23 / R-16).
|
|
39
|
+
*/
|
|
40
|
+
const POSIX_CANDIDATES = Object.freeze([
|
|
41
|
+
{ name: 'terminal-env', probe: '$TERMINAL', execArgs: ['-e'] },
|
|
42
|
+
{ name: 'x-terminal-emulator', probe: 'x-terminal-emulator', execArgs: ['-e'] },
|
|
43
|
+
{ name: 'gnome-terminal', probe: 'gnome-terminal', execArgs: ['--'] },
|
|
44
|
+
{ name: 'konsole', probe: 'konsole', execArgs: ['-e'] },
|
|
45
|
+
{ name: 'xfce4-terminal', probe: 'xfce4-terminal', execArgs: ['-x'] },
|
|
46
|
+
{ name: 'kitty', probe: 'kitty', execArgs: ['--'] },
|
|
47
|
+
{ name: 'wezterm', probe: 'wezterm', execArgs: ['start', '--'] },
|
|
48
|
+
{ name: 'alacritty', probe: 'alacritty', execArgs: ['-e'] },
|
|
49
|
+
{ name: 'xterm', probe: 'xterm', execArgs: ['-e'] },
|
|
50
|
+
]);
|
|
51
|
+
/** The env var that overrides the shell the new window runs (D-7). */
|
|
52
|
+
export const ARAGON_BRANCH_SHELL_ENV = 'ARAGON_BRANCH_SHELL';
|
|
53
|
+
/** `PowerShell` and `cmd` both stop being reliable far below the 32767 OS limit. */
|
|
54
|
+
const BARE_POWER_SHELL_WORD_RE = /^[A-Za-z0-9._\\/:+=@-]+$/;
|
|
55
|
+
/**
|
|
56
|
+
* Build the ladder for this platform, most-preferred rung first.
|
|
57
|
+
*
|
|
58
|
+
* The return value is a LIST because the failures this feature can actually hit
|
|
59
|
+
* are synchronous argument rejections (ENOENT on `wt.exe`, no X display), and a
|
|
60
|
+
* list lets `branch-launch.ts` walk down it without knowing anything about
|
|
61
|
+
* terminals. Rung COUNT is bounded by the caller (`BRANCH_TERMINAL_LIMITS.maxRungs`),
|
|
62
|
+
* so there is exactly one place that number lives.
|
|
63
|
+
*/
|
|
64
|
+
export function planTerminalLaunch(input) {
|
|
65
|
+
const isExecutable = input.isExecutable ?? defaultIsExecutable(input.platform, input.pathDirs);
|
|
66
|
+
const platform = normalizePlatform(input.platform);
|
|
67
|
+
if (platform === 'win32') {
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
plans: windowsPlans(input, isExecutable),
|
|
71
|
+
shell: input.shell,
|
|
72
|
+
manual: input.manual,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const script = posixWindowScript(input.cwd, input.shell, input.command);
|
|
76
|
+
const plans = platform === 'darwin'
|
|
77
|
+
? darwinPlans(isExecutable, script)
|
|
78
|
+
: posixPlans(input, isExecutable, script);
|
|
79
|
+
if (plans.length === 0) {
|
|
80
|
+
// NOT AN ERROR STATE TO APOLOGISE FOR (K-2): an SSH session or a container has
|
|
81
|
+
// no terminal emulator, and the honest answer - one pasteable command - is the
|
|
82
|
+
// whole reason the manual line exists (I-4).
|
|
83
|
+
return { ok: false, reason: 'no-launcher', shell: input.shell, manual: input.manual };
|
|
84
|
+
}
|
|
85
|
+
return { ok: true, plans, shell: input.shell, manual: input.manual };
|
|
86
|
+
}
|
|
87
|
+
function normalizePlatform(platform) {
|
|
88
|
+
if (platform === 'win32')
|
|
89
|
+
return 'win32';
|
|
90
|
+
if (platform === 'darwin')
|
|
91
|
+
return 'darwin';
|
|
92
|
+
return 'posix';
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Windows: `wt` when we are demonstrably inside Windows Terminal, then `cmd`.
|
|
96
|
+
*
|
|
97
|
+
* THE `WT_SESSION` TEST IS NOT A CAPABILITY TEST, IT IS THE ONLY HONEST ONE WE
|
|
98
|
+
* HAVE. Probing for `wt.exe` proves the binary exists, not that a Windows Terminal
|
|
99
|
+
* is listening; `WT_SESSION` is set for every process a pane starts, so its
|
|
100
|
+
* presence is the one signal that a new tab will land somewhere.
|
|
101
|
+
*/
|
|
102
|
+
function windowsPlans(input, isExecutable) {
|
|
103
|
+
const plans = [];
|
|
104
|
+
const encoded = encodePowerShellCommand(powerShellScript(input.command));
|
|
105
|
+
const inWindowsTerminal = (input.env.WT_SESSION ?? '').length > 0;
|
|
106
|
+
if (inWindowsTerminal && isExecutable('wt.exe')) {
|
|
107
|
+
plans.push({
|
|
108
|
+
file: 'wt.exe',
|
|
109
|
+
// `-d` IS THE CWD (D-16). `-w 0` reuses the current window, `nt` opens a new
|
|
110
|
+
// tab, and `--` stops `wt` parsing the single-dash switches that belong to
|
|
111
|
+
// PowerShell. This failure is UNOBSERVABLE: `wt.exe` forwards the request and
|
|
112
|
+
// exits, so a rejected argument still produces a successful `'spawn'` event
|
|
113
|
+
// and the ladder cannot fall back (K-1 / D-23).
|
|
114
|
+
args: ['-w', '0', 'nt', '-d', input.cwd, '--title', input.title, '--', input.shell.path,
|
|
115
|
+
'-NoExit', '-EncodedCommand', encoded],
|
|
116
|
+
windowsVerbatimArguments: false,
|
|
117
|
+
rung: 'wt',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
plans.push({
|
|
121
|
+
file: 'cmd.exe',
|
|
122
|
+
// `start`'s FIRST argument is the window title, and it must be present: an
|
|
123
|
+
// empty string is what most snippets use, but naming the window is how a user
|
|
124
|
+
// tells three branches apart in the taskbar (AC-1). The cwd needs no argument
|
|
125
|
+
// at all - `cmd.exe` inherits the cwd we pass to `spawn` (I-9).
|
|
126
|
+
args: ['/d', '/s', '/c', cmdStartLine(input.title, input.shell.path, encoded)],
|
|
127
|
+
windowsVerbatimArguments: true,
|
|
128
|
+
rung: 'cmd',
|
|
129
|
+
});
|
|
130
|
+
return plans;
|
|
131
|
+
}
|
|
132
|
+
/** `start "<title>" <shell> -NoExit -EncodedCommand <base64>`, one verbatim `cmd` line. */
|
|
133
|
+
function cmdStartLine(title, shellPath, encoded) {
|
|
134
|
+
return `start ${quoteWin32Arg(title)} ${quoteWin32Arg(shellPath)}` +
|
|
135
|
+
` -NoExit -EncodedCommand ${encoded}`;
|
|
136
|
+
}
|
|
137
|
+
/** macOS: Terminal.app via AppleScript; the script owns the cwd and the held-open shell. */
|
|
138
|
+
function darwinPlans(isExecutable, script) {
|
|
139
|
+
// `osascript` IS PROBED, unlike `cmd.exe`. It lives at `/usr/bin/osascript` on
|
|
140
|
+
// every macOS install, so a failed probe means a broken PATH or a system without
|
|
141
|
+
// the tool - and in that case "no terminal could be opened here" plus a pasteable
|
|
142
|
+
// command is the honest answer (T-29), not a spawn that is certain to fail.
|
|
143
|
+
if (!isExecutable('osascript'))
|
|
144
|
+
return [];
|
|
145
|
+
return [{
|
|
146
|
+
file: 'osascript',
|
|
147
|
+
args: [
|
|
148
|
+
'-e',
|
|
149
|
+
`tell application "Terminal" to do script "${escapeAppleScriptText(script)}"`,
|
|
150
|
+
'-e',
|
|
151
|
+
'tell application "Terminal" to activate',
|
|
152
|
+
],
|
|
153
|
+
windowsVerbatimArguments: false,
|
|
154
|
+
rung: 'osascript',
|
|
155
|
+
}];
|
|
156
|
+
}
|
|
157
|
+
/** Every POSIX candidate that exists, in ladder order. */
|
|
158
|
+
function posixPlans(input, isExecutable, script) {
|
|
159
|
+
const plans = [];
|
|
160
|
+
for (const candidate of POSIX_CANDIDATES) {
|
|
161
|
+
const file = resolveCandidate(candidate, input.env);
|
|
162
|
+
if (file === null || !isExecutable(file))
|
|
163
|
+
continue;
|
|
164
|
+
plans.push({
|
|
165
|
+
file,
|
|
166
|
+
// The `cd` lives INSIDE the script, not in the emulator's argv: `-e` / `--` /
|
|
167
|
+
// `-x` conventions all pass the rest of the argv to the shell unchanged, so a
|
|
168
|
+
// script that starts with `cd` is correct for every rung - including rungs
|
|
169
|
+
// that do not exist yet (D-16 / D-23).
|
|
170
|
+
args: [...candidate.execArgs, input.shell.path, '-c', script],
|
|
171
|
+
windowsVerbatimArguments: false,
|
|
172
|
+
rung: candidate.name,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return plans;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The `$TERMINAL` escape hatch, with the D-7 trade-off spelled out: a value with
|
|
179
|
+
* spaces (`"/usr/bin/code --reuse-window"`) is used as ONE executable path and
|
|
180
|
+
* never split, because splitting needs a shell parser and a shell parser is what
|
|
181
|
+
* re-introduces the quoting problem. Not found means "skip this rung".
|
|
182
|
+
*/
|
|
183
|
+
function resolveCandidate(candidate, env) {
|
|
184
|
+
if (candidate.probe !== '$TERMINAL')
|
|
185
|
+
return candidate.probe;
|
|
186
|
+
const value = env.TERMINAL;
|
|
187
|
+
if (value === undefined || value.length === 0)
|
|
188
|
+
return null;
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* The window script, used verbatim by both the AppleScript payload and `-c`.
|
|
193
|
+
*
|
|
194
|
+
* `cd` FIRST, THEN THE COMMAND, THEN `exec <shell> -i`. The trailing `exec` keeps
|
|
195
|
+
* the window alive after `aragon` exits: the TUI writes its final transcript on
|
|
196
|
+
* exit (`ui/exit-snapshot.ts`), and a window that closes at that instant swallows
|
|
197
|
+
* the last screenful the user was about to read. `-c`, not `-lc` (R-15): a LOGIN
|
|
198
|
+
* shell sources `~/.bash_profile` / `~/.zprofile`, which can be slow, noisy, or
|
|
199
|
+
* PATH-changing, and holding the window open is already `exec ... -i`'s job.
|
|
200
|
+
*/
|
|
201
|
+
function posixWindowScript(cwd, shell, command) {
|
|
202
|
+
return `cd ${posixQuote(cwd)} && ${posixScript(command)} ; exec ${posixQuote(shell.path)} -i`;
|
|
203
|
+
}
|
|
204
|
+
/** PowerShell: the whole command as one base64(UTF-16LE) blob (D-20). */
|
|
205
|
+
export function powerShellScript(parts) {
|
|
206
|
+
return parts.map(powerShellQuote).join(' ');
|
|
207
|
+
}
|
|
208
|
+
/** Bare words stay bare; everything else becomes a single-quoted literal. */
|
|
209
|
+
export function powerShellQuote(value) {
|
|
210
|
+
if (BARE_POWER_SHELL_WORD_RE.test(value))
|
|
211
|
+
return value;
|
|
212
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* `-EncodedCommand` takes base64 of UTF-16LE, which needs no shell quoting at all.
|
|
216
|
+
*
|
|
217
|
+
* The alphabet is `A-Za-z0-9+/=` - no spaces, no quotes, no `cmd` metacharacters -
|
|
218
|
+
* so the blob survives all three of Windows' parsers as an ordinary word. This is
|
|
219
|
+
* the same trick `ui/win-vt-force.ts` uses, for the same reason: encode the fragile
|
|
220
|
+
* thing into a form nothing can argue with, at construction time. Our command line
|
|
221
|
+
* necessarily contains spaced paths (`C:\Program Files\nodejs\node.exe`), so the
|
|
222
|
+
* fragile thing is not hypothetical.
|
|
223
|
+
*/
|
|
224
|
+
export function encodePowerShellCommand(script) {
|
|
225
|
+
return Buffer.from(script, 'utf16le').toString('base64');
|
|
226
|
+
}
|
|
227
|
+
/** POSIX single-quote escaping: `'` becomes `'\''`. */
|
|
228
|
+
export function posixQuote(value) {
|
|
229
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
230
|
+
}
|
|
231
|
+
export function posixScript(parts) {
|
|
232
|
+
return parts.map(posixQuote).join(' ');
|
|
233
|
+
}
|
|
234
|
+
/** AppleScript string literals only need `\` and `"` escaped. */
|
|
235
|
+
export function escapeAppleScriptText(value) {
|
|
236
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Win32 argv quoting for the `cmd` rung's title and shell path: quote when it
|
|
240
|
+
* contains whitespace, otherwise leave it alone.
|
|
241
|
+
*
|
|
242
|
+
* THE INPUT DOMAIN EXCLUDES `"` BY CONSTRUCTION (R-17/T-48): Windows paths cannot
|
|
243
|
+
* contain it, and the title is `aragon <id>` where an id is `[A-Za-z0-9._-]`. If a
|
|
244
|
+
* future source can contain a quote, this must switch to `cmd`'s rule (`""`) and
|
|
245
|
+
* not to the POSIX `\"` written here - the two are not interchangeable.
|
|
246
|
+
*/
|
|
247
|
+
export function quoteWin32Arg(value) {
|
|
248
|
+
return /\s/.test(value) ? `"${value}"` : value;
|
|
249
|
+
}
|
|
250
|
+
/** PATH entries for `name` probing: both spellings on Windows, `:` on POSIX. */
|
|
251
|
+
export function pathDirsOf(env, platform) {
|
|
252
|
+
if (normalizePlatform(platform) === 'win32') {
|
|
253
|
+
// `Path` and `PATH` genuinely coexist in a Windows env object (Node copies what
|
|
254
|
+
// it was given), and reading only one of them is how a probe silently finds
|
|
255
|
+
// nothing. Both are read, in that order, deduplicated.
|
|
256
|
+
const dirs = [];
|
|
257
|
+
for (const key of ['Path', 'PATH']) {
|
|
258
|
+
const value = env[key];
|
|
259
|
+
if (value === undefined || value.length === 0)
|
|
260
|
+
continue;
|
|
261
|
+
for (const dir of value.split(';')) {
|
|
262
|
+
if (dir.length > 0 && !dirs.includes(dir))
|
|
263
|
+
dirs.push(dir);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return dirs;
|
|
267
|
+
}
|
|
268
|
+
const value = env.PATH ?? '';
|
|
269
|
+
return value.split(':').filter((dir) => dir.length > 0);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The default probe: is this a file we could execute?
|
|
273
|
+
*
|
|
274
|
+
* An absolute or separator-bearing value is stat'd directly (that is the
|
|
275
|
+
* `ARAGON_BRANCH_SHELL=/usr/local/bin/fish` case); a bare name is looked up in the
|
|
276
|
+
* PATH directories, trying `name` and - on Windows - `name + '.exe'`, because a
|
|
277
|
+
* user typing `pwsh` and a probe looking for `pwsh.exe` are the same request.
|
|
278
|
+
*
|
|
279
|
+
* EVERY EXCEPTION RETURNS FALSE: one failed stat means "this candidate is not
|
|
280
|
+
* here", not "the feature is broken", and a missing terminal must degrade to the
|
|
281
|
+
* manual command rather than throw out of a key handler.
|
|
282
|
+
*/
|
|
283
|
+
export function defaultIsExecutable(platform, pathDirs) {
|
|
284
|
+
const windows = normalizePlatform(platform) === 'win32';
|
|
285
|
+
const dirs = pathDirs ?? pathDirsOf(process.env, platform);
|
|
286
|
+
return (name) => {
|
|
287
|
+
if (name.length === 0)
|
|
288
|
+
return false;
|
|
289
|
+
const suffixes = windows && !/\.exe$/i.test(name) ? [name, `${name}.exe`] : [name];
|
|
290
|
+
if (isAbsolute(name) || /[\\/]/.test(name)) {
|
|
291
|
+
return suffixes.some((suffix) => isFile(suffix));
|
|
292
|
+
}
|
|
293
|
+
return dirs.some((dir) => suffixes.some((suffix) => isFile(join(dir, suffix))));
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function isFile(path) {
|
|
297
|
+
try {
|
|
298
|
+
return statSync(path).isFile();
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Which shell the new window runs (D-7), first match wins.
|
|
306
|
+
*
|
|
307
|
+
* The requirement says "PowerShell / bash", and both platforms have two answers
|
|
308
|
+
* worth distinguishing: PowerShell 7 (`pwsh.exe`) when the user installed it,
|
|
309
|
+
* Windows PowerShell 5.1 otherwise; `$SHELL` when the user's login shell is not
|
|
310
|
+
* bash, then bash, then `/bin/sh` as the one path that exists everywhere.
|
|
311
|
+
* `ARAGON_BRANCH_SHELL` is the escape hatch - an ENV VALUE, not a config key
|
|
312
|
+
* (D-17): a "Ctrl+L opens no terminal" switch would create the one state this
|
|
313
|
+
* feature must never have.
|
|
314
|
+
*/
|
|
315
|
+
export function resolveShell(input) {
|
|
316
|
+
// `pathDirs` is FORWARDED, not dropped: a caller that supplies its own PATH
|
|
317
|
+
// (a test, or `branch-launch.ts` with the injected env) must not have the shell
|
|
318
|
+
// probe silently fall back to `process.env`'s PATH.
|
|
319
|
+
const isExecutable = input.isExecutable ?? defaultIsExecutable(input.platform, input.pathDirs);
|
|
320
|
+
const override = input.env[ARAGON_BRANCH_SHELL_ENV];
|
|
321
|
+
const platform = normalizePlatform(input.platform);
|
|
322
|
+
if (platform === 'win32') {
|
|
323
|
+
if (override !== undefined && override.length > 0 && isExecutable(override)) {
|
|
324
|
+
return { path: override, kind: 'powershell' };
|
|
325
|
+
}
|
|
326
|
+
if (isExecutable('pwsh.exe'))
|
|
327
|
+
return { path: 'pwsh.exe', kind: 'powershell' };
|
|
328
|
+
return { path: 'powershell.exe', kind: 'powershell' };
|
|
329
|
+
}
|
|
330
|
+
if (override !== undefined && override.length > 0) {
|
|
331
|
+
return { path: override, kind: 'posix' };
|
|
332
|
+
}
|
|
333
|
+
const loginShell = input.env.SHELL;
|
|
334
|
+
if (loginShell !== undefined && isAbsolute(loginShell)) {
|
|
335
|
+
return { path: loginShell, kind: 'posix' };
|
|
336
|
+
}
|
|
337
|
+
if (isExecutable('bash'))
|
|
338
|
+
return { path: 'bash', kind: 'posix' };
|
|
339
|
+
return { path: '/bin/sh', kind: 'posix' };
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Fit a window title into `maxCells` columns (R-7 / T-47).
|
|
343
|
+
*
|
|
344
|
+
* THE TITLE IS THE ONLY THING THIS TOUCHES. An id can be 64 characters long, so
|
|
345
|
+
* `aragon <id>` exceeds the 60-cell budget by construction for the longest ids -
|
|
346
|
+
* and truncating the id anywhere else (the command line, `--resume`) would produce
|
|
347
|
+
* a branch that cannot find its own parent. Code points, not bytes: the title is
|
|
348
|
+
* ASCII today, and a future non-ASCII title must not be cut through a surrogate
|
|
349
|
+
* pair.
|
|
350
|
+
*/
|
|
351
|
+
export function truncateTitle(title, maxCells) {
|
|
352
|
+
const chars = Array.from(title);
|
|
353
|
+
// A budget that is not a usable number is a caller bug, not a reason to destroy
|
|
354
|
+
// the title: clamping to one character would be silently worse than not cutting.
|
|
355
|
+
if (!Number.isFinite(maxCells) || maxCells < 1)
|
|
356
|
+
return title;
|
|
357
|
+
if (chars.length <= maxCells)
|
|
358
|
+
return title;
|
|
359
|
+
return chars.slice(0, Math.floor(maxCells)).join('');
|
|
360
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What command the new terminal runs (tui-branch-terminal-shortcut 3.4).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
|
|
5
|
+
*
|
|
6
|
+
* THIS MODULE HAS NO IO ON PURPOSE. It answers two questions and nothing else:
|
|
7
|
+
* "which argv pieces make up the aragon command" and "how does that sentence read
|
|
8
|
+
* when printed to a human". Spawning lives in `session/branch-launch.ts`, and the
|
|
9
|
+
* terminal ladder lives in `proc/terminal-plan.ts`, so the one part of this
|
|
10
|
+
* feature that a unit test can pin exactly - the command line - is not buried
|
|
11
|
+
* inside an `spawn()` call. The repository already draws the boundary this way:
|
|
12
|
+
* `proc/readiness.ts` and `proc/classify.ts` are pure, `proc/supervisor.ts` is the
|
|
13
|
+
* part that touches processes.
|
|
14
|
+
*
|
|
15
|
+
* NAMED `branch-invocation`, NOT `branch-command` (R-22): `commands/branch-command.ts`
|
|
16
|
+
* is the command body, and two files with one basename make every import, stack
|
|
17
|
+
* trace and grep ambiguous. What this module exports is an *invocation*.
|
|
18
|
+
*/
|
|
19
|
+
/** Which of the three resolution rules produced the command (D-8). */
|
|
20
|
+
export type AragonCommandKind = 'override' | 'script' | 'bin';
|
|
21
|
+
export interface AragonCommandResolution {
|
|
22
|
+
/** argv pieces, ready to hand to a shell or a terminal emulator. */
|
|
23
|
+
parts: string[];
|
|
24
|
+
kind: AragonCommandKind;
|
|
25
|
+
/** Human-readable note for the log and the error copy. Never part of the command. */
|
|
26
|
+
origin: string;
|
|
27
|
+
}
|
|
28
|
+
export interface BranchInvocation {
|
|
29
|
+
/** `[...aragon.parts, '--resume', id]`. */
|
|
30
|
+
parts: string[];
|
|
31
|
+
/** One line, pasteable into cmd / PowerShell / sh (quoted where needed). */
|
|
32
|
+
command: string;
|
|
33
|
+
}
|
|
34
|
+
export declare const BRANCH_RESUME_FLAG = "--resume";
|
|
35
|
+
/** The env var that overrides "what executable the new window runs" (D-7 sibling). */
|
|
36
|
+
export declare const ARAGON_BRANCH_COMMAND_ENV = "ARAGON_BRANCH_COMMAND";
|
|
37
|
+
/**
|
|
38
|
+
* Decide what to execute in the new window (D-8), first match wins.
|
|
39
|
+
*
|
|
40
|
+
* The precedence exists because `aragon` is NOT on PATH in three of this
|
|
41
|
+
* repository's own four ways of running: `npm run dev:cli`, `npx
|
|
42
|
+
* @aragon-agent/cli`, and `node packages/cli/dist/cli.js`. Rule 2 recovers all
|
|
43
|
+
* three from `process.execPath` + `process.argv[1]` without consulting PATH at
|
|
44
|
+
* all, which is the only way "press the key and something starts" holds on a
|
|
45
|
+
* machine where the global install is missing.
|
|
46
|
+
*
|
|
47
|
+
* The override is treated as ONE executable path and never split into arguments:
|
|
48
|
+
* splitting needs a shell parser, and a shell parser is what re-introduces the
|
|
49
|
+
* quoting problem this feature spends a whole module avoiding. The documented
|
|
50
|
+
* escape hatch is a `.cmd` / `.sh` wrapper.
|
|
51
|
+
*/
|
|
52
|
+
export declare function resolveAragonCommand(input: {
|
|
53
|
+
argv: readonly string[];
|
|
54
|
+
execPath: string;
|
|
55
|
+
env: Record<string, string | undefined>;
|
|
56
|
+
}): AragonCommandResolution;
|
|
57
|
+
/** `parts` + `--resume <id>`, plus the one-line form for toasts and manual fallback. */
|
|
58
|
+
export declare function buildResumeInvocation(input: {
|
|
59
|
+
id: string;
|
|
60
|
+
aragon: AragonCommandResolution;
|
|
61
|
+
}): BranchInvocation;
|
|
62
|
+
/**
|
|
63
|
+
* Join argv pieces into one line a human can paste into any of the three shells.
|
|
64
|
+
*
|
|
65
|
+
* NOT A SHELL ESCAPER. This text goes to the toast, to the notice and to the
|
|
66
|
+
* README, never back through a parser: the three platforms agree on what double
|
|
67
|
+
* quotes mean around a path with spaces, and that is the whole requirement. The
|
|
68
|
+
* real command lines are built by `proc/terminal-plan.ts`, which quotes for the
|
|
69
|
+
* exact parser each rung lands in.
|
|
70
|
+
*/
|
|
71
|
+
export declare function joinCommandLine(parts: readonly string[]): string;
|
|
72
|
+
/**
|
|
73
|
+
* The second gate on D-9: an id is about to be concatenated into a command line.
|
|
74
|
+
*
|
|
75
|
+
* The command body checks first (it owns the user-facing sentence). This check
|
|
76
|
+
* exists because a future caller can go straight to `buildResumeInvocation`, and
|
|
77
|
+
* the injection defence belongs at the boundary that builds the string rather
|
|
78
|
+
* than at the one call site that happens to exist today.
|
|
79
|
+
*
|
|
80
|
+
* Returns `null` when the id is usable, otherwise one sentence naming the actual
|
|
81
|
+
* value (`<= 64 characters`, first 24 of it) so the user can see what was wrong.
|
|
82
|
+
*/
|
|
83
|
+
export declare function validateBranchId(id: string): string | null;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What command the new terminal runs (tui-branch-terminal-shortcut 3.4).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
|
|
5
|
+
*
|
|
6
|
+
* THIS MODULE HAS NO IO ON PURPOSE. It answers two questions and nothing else:
|
|
7
|
+
* "which argv pieces make up the aragon command" and "how does that sentence read
|
|
8
|
+
* when printed to a human". Spawning lives in `session/branch-launch.ts`, and the
|
|
9
|
+
* terminal ladder lives in `proc/terminal-plan.ts`, so the one part of this
|
|
10
|
+
* feature that a unit test can pin exactly - the command line - is not buried
|
|
11
|
+
* inside an `spawn()` call. The repository already draws the boundary this way:
|
|
12
|
+
* `proc/readiness.ts` and `proc/classify.ts` are pure, `proc/supervisor.ts` is the
|
|
13
|
+
* part that touches processes.
|
|
14
|
+
*
|
|
15
|
+
* NAMED `branch-invocation`, NOT `branch-command` (R-22): `commands/branch-command.ts`
|
|
16
|
+
* is the command body, and two files with one basename make every import, stack
|
|
17
|
+
* trace and grep ambiguous. What this module exports is an *invocation*.
|
|
18
|
+
*/
|
|
19
|
+
import { isAbsolute } from 'node:path';
|
|
20
|
+
import { isValidSessionId } from './store.js';
|
|
21
|
+
export const BRANCH_RESUME_FLAG = '--resume';
|
|
22
|
+
/** The env var that overrides "what executable the new window runs" (D-7 sibling). */
|
|
23
|
+
export const ARAGON_BRANCH_COMMAND_ENV = 'ARAGON_BRANCH_COMMAND';
|
|
24
|
+
/** Longest prefix of a rejected id that reaches the user's screen. */
|
|
25
|
+
const REJECTED_ID_PREVIEW_CELLS = 24;
|
|
26
|
+
/**
|
|
27
|
+
* A path that could be a script we are already running (`node <script.js>`).
|
|
28
|
+
*
|
|
29
|
+
* Anchored and case-insensitive: Windows writes `.JS` in the wild, and an
|
|
30
|
+
* unanchored variant would also match `cli.js.bak` - which is not something node
|
|
31
|
+
* could have been invoked with.
|
|
32
|
+
*/
|
|
33
|
+
const SCRIPT_PATH_RE = /\.(?:js|cjs|mjs)$/i;
|
|
34
|
+
/**
|
|
35
|
+
* Decide what to execute in the new window (D-8), first match wins.
|
|
36
|
+
*
|
|
37
|
+
* The precedence exists because `aragon` is NOT on PATH in three of this
|
|
38
|
+
* repository's own four ways of running: `npm run dev:cli`, `npx
|
|
39
|
+
* @aragon-agent/cli`, and `node packages/cli/dist/cli.js`. Rule 2 recovers all
|
|
40
|
+
* three from `process.execPath` + `process.argv[1]` without consulting PATH at
|
|
41
|
+
* all, which is the only way "press the key and something starts" holds on a
|
|
42
|
+
* machine where the global install is missing.
|
|
43
|
+
*
|
|
44
|
+
* The override is treated as ONE executable path and never split into arguments:
|
|
45
|
+
* splitting needs a shell parser, and a shell parser is what re-introduces the
|
|
46
|
+
* quoting problem this feature spends a whole module avoiding. The documented
|
|
47
|
+
* escape hatch is a `.cmd` / `.sh` wrapper.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveAragonCommand(input) {
|
|
50
|
+
const override = input.env[ARAGON_BRANCH_COMMAND_ENV];
|
|
51
|
+
if (override !== undefined && override.length > 0) {
|
|
52
|
+
return {
|
|
53
|
+
parts: [override],
|
|
54
|
+
kind: 'override',
|
|
55
|
+
origin: `${ARAGON_BRANCH_COMMAND_ENV}=${override}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const script = input.argv[1];
|
|
59
|
+
if (script !== undefined && isAbsolute(script) && SCRIPT_PATH_RE.test(script)) {
|
|
60
|
+
return {
|
|
61
|
+
parts: [input.execPath, script],
|
|
62
|
+
kind: 'script',
|
|
63
|
+
origin: 'process.execPath + process.argv[1]',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return { parts: ['aragon'], kind: 'bin', origin: 'aragon on PATH' };
|
|
67
|
+
}
|
|
68
|
+
/** `parts` + `--resume <id>`, plus the one-line form for toasts and manual fallback. */
|
|
69
|
+
export function buildResumeInvocation(input) {
|
|
70
|
+
const parts = [...input.aragon.parts, BRANCH_RESUME_FLAG, input.id];
|
|
71
|
+
return { parts, command: joinCommandLine(parts) };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Join argv pieces into one line a human can paste into any of the three shells.
|
|
75
|
+
*
|
|
76
|
+
* NOT A SHELL ESCAPER. This text goes to the toast, to the notice and to the
|
|
77
|
+
* README, never back through a parser: the three platforms agree on what double
|
|
78
|
+
* quotes mean around a path with spaces, and that is the whole requirement. The
|
|
79
|
+
* real command lines are built by `proc/terminal-plan.ts`, which quotes for the
|
|
80
|
+
* exact parser each rung lands in.
|
|
81
|
+
*/
|
|
82
|
+
export function joinCommandLine(parts) {
|
|
83
|
+
return parts.map(quoteForDisplay).join(' ');
|
|
84
|
+
}
|
|
85
|
+
/** Empty and whitespace-or-quote-bearing pieces get double quotes; the rest stay bare. */
|
|
86
|
+
function quoteForDisplay(part) {
|
|
87
|
+
if (part.length === 0)
|
|
88
|
+
return '""';
|
|
89
|
+
if (!/[\s"]/.test(part))
|
|
90
|
+
return part;
|
|
91
|
+
return `"${part.replace(/"/g, '\\"')}"`;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The second gate on D-9: an id is about to be concatenated into a command line.
|
|
95
|
+
*
|
|
96
|
+
* The command body checks first (it owns the user-facing sentence). This check
|
|
97
|
+
* exists because a future caller can go straight to `buildResumeInvocation`, and
|
|
98
|
+
* the injection defence belongs at the boundary that builds the string rather
|
|
99
|
+
* than at the one call site that happens to exist today.
|
|
100
|
+
*
|
|
101
|
+
* Returns `null` when the id is usable, otherwise one sentence naming the actual
|
|
102
|
+
* value (`<= 64 characters`, first 24 of it) so the user can see what was wrong.
|
|
103
|
+
*/
|
|
104
|
+
export function validateBranchId(id) {
|
|
105
|
+
if (isValidSessionId(id))
|
|
106
|
+
return null;
|
|
107
|
+
const preview = id.slice(0, REJECTED_ID_PREVIEW_CELLS);
|
|
108
|
+
return `Session id "${preview}" is not a legal file name, so it cannot be resumed.`;
|
|
109
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/branch` port: flush, plan, spawn (tui-branch-terminal-shortcut 3.6).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
|
|
5
|
+
*
|
|
6
|
+
* ORDER IS CORRECTNESS (I-1). The child process's first act is to read the parent
|
|
7
|
+
* session file, and the parent's checkpoint is debounced and fingerprint-
|
|
8
|
+
* short-circuited (`checkpoint.ts`). Spawning before a successful FORCED write
|
|
9
|
+
* means the branch can read the previous turn - or, after `aragon sessions rm
|
|
10
|
+
* <id>`, no file at all - and the user sees "the branch opened but lost the last
|
|
11
|
+
* ten turns". That is a data defect, not a UX one, so `checkpoint({force:true})`
|
|
12
|
+
* returns `ok` before any terminal is created.
|
|
13
|
+
*
|
|
14
|
+
* THE CHILD IS NOT A SERVICE (D-5 / I-3): `detached: true`, `stdio: 'ignore'`,
|
|
15
|
+
* `unref()`, and deliberately no `ProcSupervisor` registration. Registering it
|
|
16
|
+
* would make the parent's exit kill a session running in another window.
|
|
17
|
+
*
|
|
18
|
+
* EVERY FAILURE CARRIES A PASTEABLE COMMAND (I-4). "Could not open a terminal" is
|
|
19
|
+
* a dead end; `aragon --resume a4k2m9` is 22 characters the user can always use.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import { type ScopedLogger } from '../logging/logger.js';
|
|
23
|
+
import { type BranchCommandPort } from '../commands/branch-command.js';
|
|
24
|
+
export interface BranchRequest {
|
|
25
|
+
id: string;
|
|
26
|
+
cwd: string;
|
|
27
|
+
/** Late `'error'` events after the window opened (I-10). */
|
|
28
|
+
onLateError?: (message: string) => void;
|
|
29
|
+
}
|
|
30
|
+
export type BranchOpenResult = {
|
|
31
|
+
ok: true;
|
|
32
|
+
command: string;
|
|
33
|
+
shell: string;
|
|
34
|
+
rung: string;
|
|
35
|
+
} | {
|
|
36
|
+
ok: false;
|
|
37
|
+
reason: 'cooldown' | 'checkpoint-failed' | 'no-launcher' | 'spawn-failed';
|
|
38
|
+
/**
|
|
39
|
+
* A complete, user-facing sentence - never an internal reason code.
|
|
40
|
+
* `checkpoint-failed` in particular has two very different causes
|
|
41
|
+
* (`not-sendable` vs `no-identity`), and only the launcher knows which one it
|
|
42
|
+
* hit, so the sentence is composed HERE from `BRANCH_COPY`.
|
|
43
|
+
*/
|
|
44
|
+
message: string;
|
|
45
|
+
/** Always present, so no failure is a dead end (I-4). */
|
|
46
|
+
command: string;
|
|
47
|
+
retryInMs?: number;
|
|
48
|
+
};
|
|
49
|
+
export interface BranchLauncherDeps {
|
|
50
|
+
/** Injected by `cli.tsx`: a thin wrapper over `checkpointWriter.writeNow`, with `force`. */
|
|
51
|
+
checkpoint(opts: {
|
|
52
|
+
force: true;
|
|
53
|
+
}): {
|
|
54
|
+
ok: boolean;
|
|
55
|
+
message?: string;
|
|
56
|
+
};
|
|
57
|
+
platform: NodeJS.Platform;
|
|
58
|
+
env: Record<string, string | undefined>;
|
|
59
|
+
argv: readonly string[];
|
|
60
|
+
execPath: string;
|
|
61
|
+
spawn?: typeof spawn;
|
|
62
|
+
isExecutable?: (name: string) => boolean;
|
|
63
|
+
now?: () => number;
|
|
64
|
+
logger?: ScopedLogger;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Build the port. The only mutable state is this closure's four values, none of
|
|
68
|
+
* which outlives the process (no persistence, 6 in the spec).
|
|
69
|
+
*/
|
|
70
|
+
export declare function createBranchLauncher(deps: BranchLauncherDeps): BranchCommandPort;
|