@aragon-agent/cli 0.6.25 → 0.6.27

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 (48) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +194 -13
  3. package/dist/agent/reducer.d.ts +22 -0
  4. package/dist/agent/reducer.js +4 -1
  5. package/dist/cli.js +214 -2
  6. package/dist/commands/branch-command.d.ts +61 -0
  7. package/dist/commands/branch-command.js +117 -0
  8. package/dist/commands/builtins.js +48 -6
  9. package/dist/commands/registry.d.ts +28 -0
  10. package/dist/commands/session-command.d.ts +54 -0
  11. package/dist/commands/session-command.js +109 -0
  12. package/dist/config/load.d.ts +15 -0
  13. package/dist/exec/index.js +10 -1
  14. package/dist/logging/logger.d.ts +1 -1
  15. package/dist/proc/terminal-plan.d.ts +173 -0
  16. package/dist/proc/terminal-plan.js +393 -0
  17. package/dist/session/branch-invocation.d.ts +127 -0
  18. package/dist/session/branch-invocation.js +153 -0
  19. package/dist/session/branch-launch.d.ts +79 -0
  20. package/dist/session/branch-launch.js +265 -0
  21. package/dist/session/checkpoint.d.ts +257 -0
  22. package/dist/session/checkpoint.js +512 -0
  23. package/dist/session/cli-commands.d.ts +2 -0
  24. package/dist/session/cli-commands.js +36 -5
  25. package/dist/session/identity.d.ts +72 -0
  26. package/dist/session/identity.js +148 -0
  27. package/dist/session/limits.d.ts +66 -0
  28. package/dist/session/limits.js +66 -0
  29. package/dist/session/persist.d.ts +30 -5
  30. package/dist/session/startup-session.d.ts +107 -0
  31. package/dist/session/startup-session.js +315 -0
  32. package/dist/session/store.d.ts +60 -1
  33. package/dist/session/store.js +95 -6
  34. package/dist/ui/App.d.ts +54 -2
  35. package/dist/ui/App.js +115 -21
  36. package/dist/ui/Header.js +8 -16
  37. package/dist/ui/StatusBar.d.ts +8 -0
  38. package/dist/ui/StatusBar.js +12 -3
  39. package/dist/ui/layout/header-hints.d.ts +68 -0
  40. package/dist/ui/layout/header-hints.js +138 -0
  41. package/dist/ui/layout/session-chip.d.ts +52 -0
  42. package/dist/ui/layout/session-chip.js +64 -0
  43. package/dist/ui/layout/status-layout.d.ts +10 -0
  44. package/dist/ui/layout/status-layout.js +8 -1
  45. package/dist/ui/overlays/HelpOverlay.js +9 -3
  46. package/dist/ui/session-bridge.d.ts +26 -0
  47. package/dist/ui/session-bridge.js +32 -0
  48. package/package.json +2 -2
@@ -0,0 +1,393 @@
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
+ /**
54
+ * A bare word is one PowerShell will not read as anything else.
55
+ *
56
+ * THE FIRST CHARACTER IS THE LOAD-BEARING PART (D-3). `@name` in argument position
57
+ * is the SPLAT operator: `& node.exe -p 'process.argv.length' @foo` hands node an
58
+ * EMPTY argument list, exits 0 and prints nothing (measured on Windows PowerShell
59
+ * 5.1, branch-terminal-powershell-fix appendix B-11). As the only word on the line
60
+ * it is not even parseable - `error col=1 id=SplattingNotPermitted`. Nothing after
61
+ * the first character can do that, which is why the class differs in the first
62
+ * position. `-` stays allowed there so that every flag this feature emits
63
+ * (`--resume`) keeps its bare form, and quoting it would change nothing except the
64
+ * look of the fallback line (appendix B-6 A/B, quoting is transparent to a native
65
+ * exe).
66
+ */
67
+ const BARE_POWER_SHELL_WORD_RE = /^[A-Za-z0-9._\\/:+=\-][A-Za-z0-9._\\/:+=@\-]*$/;
68
+ /**
69
+ * Build the ladder for this platform, most-preferred rung first.
70
+ *
71
+ * The return value is a LIST because the failures this feature can actually hit
72
+ * are synchronous argument rejections (ENOENT on `wt.exe`, no X display), and a
73
+ * list lets `branch-launch.ts` walk down it without knowing anything about
74
+ * terminals. Rung COUNT is bounded by the caller (`BRANCH_TERMINAL_LIMITS.maxRungs`),
75
+ * so there is exactly one place that number lives.
76
+ */
77
+ export function planTerminalLaunch(input) {
78
+ const isExecutable = input.isExecutable ?? defaultIsExecutable(input.platform, input.pathDirs);
79
+ const platform = normalizePlatform(input.platform);
80
+ if (platform === 'win32') {
81
+ return {
82
+ ok: true,
83
+ plans: windowsPlans(input, isExecutable),
84
+ shell: input.shell,
85
+ manual: input.manual,
86
+ };
87
+ }
88
+ const script = posixWindowScript(input.cwd, input.shell, input.command);
89
+ const plans = platform === 'darwin'
90
+ ? darwinPlans(isExecutable, script)
91
+ : posixPlans(input, isExecutable, script);
92
+ if (plans.length === 0) {
93
+ // NOT AN ERROR STATE TO APOLOGISE FOR (K-2): an SSH session or a container has
94
+ // no terminal emulator, and the honest answer - one pasteable command - is the
95
+ // whole reason the manual line exists (I-4).
96
+ return { ok: false, reason: 'no-launcher', shell: input.shell, manual: input.manual };
97
+ }
98
+ return { ok: true, plans, shell: input.shell, manual: input.manual };
99
+ }
100
+ function normalizePlatform(platform) {
101
+ if (platform === 'win32')
102
+ return 'win32';
103
+ if (platform === 'darwin')
104
+ return 'darwin';
105
+ return 'posix';
106
+ }
107
+ /**
108
+ * Windows: `wt` when we are demonstrably inside Windows Terminal, then `cmd`.
109
+ *
110
+ * THE `WT_SESSION` TEST IS NOT A CAPABILITY TEST, IT IS THE ONLY HONEST ONE WE
111
+ * HAVE. Probing for `wt.exe` proves the binary exists, not that a Windows Terminal
112
+ * is listening; `WT_SESSION` is set for every process a pane starts, so its
113
+ * presence is the one signal that a new tab will land somewhere.
114
+ */
115
+ function windowsPlans(input, isExecutable) {
116
+ const plans = [];
117
+ const encoded = encodePowerShellCommand(powerShellScript(input.command));
118
+ const inWindowsTerminal = (input.env.WT_SESSION ?? '').length > 0;
119
+ if (inWindowsTerminal && isExecutable('wt.exe')) {
120
+ plans.push({
121
+ file: 'wt.exe',
122
+ // `-d` IS THE CWD (D-16). `-w 0` reuses the current window, `nt` opens a new
123
+ // tab, and `--` stops `wt` parsing the single-dash switches that belong to
124
+ // PowerShell. This failure is UNOBSERVABLE: `wt.exe` forwards the request and
125
+ // exits, so a rejected argument still produces a successful `'spawn'` event
126
+ // and the ladder cannot fall back (K-1 / D-23).
127
+ args: ['-w', '0', 'nt', '-d', input.cwd, '--title', input.title, '--', input.shell.path,
128
+ '-NoExit', '-EncodedCommand', encoded],
129
+ windowsVerbatimArguments: false,
130
+ rung: 'wt',
131
+ });
132
+ }
133
+ plans.push({
134
+ file: 'cmd.exe',
135
+ // `start`'s FIRST argument is the window title, and it must be present: an
136
+ // empty string is what most snippets use, but naming the window is how a user
137
+ // tells three branches apart in the taskbar (AC-1). The cwd needs no argument
138
+ // at all - `cmd.exe` inherits the cwd we pass to `spawn` (I-9).
139
+ args: ['/d', '/s', '/c', cmdStartLine(input.title, input.shell.path, encoded)],
140
+ windowsVerbatimArguments: true,
141
+ rung: 'cmd',
142
+ });
143
+ return plans;
144
+ }
145
+ /** `start "<title>" <shell> -NoExit -EncodedCommand <base64>`, one verbatim `cmd` line. */
146
+ function cmdStartLine(title, shellPath, encoded) {
147
+ return `start ${quoteWin32Arg(title)} ${quoteWin32Arg(shellPath)}` +
148
+ ` -NoExit -EncodedCommand ${encoded}`;
149
+ }
150
+ /** macOS: Terminal.app via AppleScript; the script owns the cwd and the held-open shell. */
151
+ function darwinPlans(isExecutable, script) {
152
+ // `osascript` IS PROBED, unlike `cmd.exe`. It lives at `/usr/bin/osascript` on
153
+ // every macOS install, so a failed probe means a broken PATH or a system without
154
+ // the tool - and in that case "no terminal could be opened here" plus a pasteable
155
+ // command is the honest answer (T-29), not a spawn that is certain to fail.
156
+ if (!isExecutable('osascript'))
157
+ return [];
158
+ return [{
159
+ file: 'osascript',
160
+ args: [
161
+ '-e',
162
+ `tell application "Terminal" to do script "${escapeAppleScriptText(script)}"`,
163
+ '-e',
164
+ 'tell application "Terminal" to activate',
165
+ ],
166
+ windowsVerbatimArguments: false,
167
+ rung: 'osascript',
168
+ }];
169
+ }
170
+ /** Every POSIX candidate that exists, in ladder order. */
171
+ function posixPlans(input, isExecutable, script) {
172
+ const plans = [];
173
+ for (const candidate of POSIX_CANDIDATES) {
174
+ const file = resolveCandidate(candidate, input.env);
175
+ if (file === null || !isExecutable(file))
176
+ continue;
177
+ plans.push({
178
+ file,
179
+ // The `cd` lives INSIDE the script, not in the emulator's argv: `-e` / `--` /
180
+ // `-x` conventions all pass the rest of the argv to the shell unchanged, so a
181
+ // script that starts with `cd` is correct for every rung - including rungs
182
+ // that do not exist yet (D-16 / D-23).
183
+ args: [...candidate.execArgs, input.shell.path, '-c', script],
184
+ windowsVerbatimArguments: false,
185
+ rung: candidate.name,
186
+ });
187
+ }
188
+ return plans;
189
+ }
190
+ /**
191
+ * The `$TERMINAL` escape hatch, with the D-7 trade-off spelled out: a value with
192
+ * spaces (`"/usr/bin/code --reuse-window"`) is used as ONE executable path and
193
+ * never split, because splitting needs a shell parser and a shell parser is what
194
+ * re-introduces the quoting problem. Not found means "skip this rung".
195
+ */
196
+ function resolveCandidate(candidate, env) {
197
+ if (candidate.probe !== '$TERMINAL')
198
+ return candidate.probe;
199
+ const value = env.TERMINAL;
200
+ if (value === undefined || value.length === 0)
201
+ return null;
202
+ return value;
203
+ }
204
+ /**
205
+ * The window script, used verbatim by both the AppleScript payload and `-c`.
206
+ *
207
+ * `cd` FIRST, THEN THE COMMAND, THEN `exec <shell> -i`. The trailing `exec` keeps
208
+ * the window alive after `aragon` exits: the TUI writes its final transcript on
209
+ * exit (`ui/exit-snapshot.ts`), and a window that closes at that instant swallows
210
+ * the last screenful the user was about to read. `-c`, not `-lc` (R-15): a LOGIN
211
+ * shell sources `~/.bash_profile` / `~/.zprofile`, which can be slow, noisy, or
212
+ * PATH-changing, and holding the window open is already `exec ... -i`'s job.
213
+ */
214
+ function posixWindowScript(cwd, shell, command) {
215
+ return `cd ${posixQuote(cwd)} && ${posixScript(command)} ; exec ${posixQuote(shell.path)} -i`;
216
+ }
217
+ /**
218
+ * The PowerShell statement that runs the aragon command (D-1).
219
+ *
220
+ * THE LEADING `&` IS NOT DECORATION. In command position PowerShell reads a quoted
221
+ * string as an EXPRESSION, so `'C:\Program Files\nodejs\node.exe' <script>` is a
222
+ * parse error - "unexpected token ..." at column 36 - and the child is never
223
+ * created, while the parent still believes the window opened (K-1: `wt`/`start`
224
+ * forward and exit). That is exactly the report this branch exists to fix.
225
+ * `&` is the call operator: it takes the following word as a command name whatever
226
+ * its shape, and it does not change name resolution (`& 'npm.cmd'` still finds
227
+ * `npm` through PATH/PATHEXT, appendix B-8).
228
+ *
229
+ * `posixScript` MUST NOT be given the same treatment: in sh `&` means run in the
230
+ * background, so the window would reach the trailing `exec <shell> -i` immediately
231
+ * and the branch would start behind a prompt that looks idle (I-3 / K-1).
232
+ */
233
+ export function powerShellScript(parts) {
234
+ // TOTAL, NOT THROWING (D-4): an empty argv cannot arise from
235
+ // `resolveAragonCommand` (T-3 pins that), and an empty script is a parse-valid
236
+ // no-op rather than a throw inside a key handler.
237
+ if (parts.length === 0)
238
+ return '';
239
+ return ['&', ...parts.map(powerShellQuote)].join(' ');
240
+ }
241
+ /** Bare words stay bare; everything else becomes a single-quoted literal. */
242
+ export function powerShellQuote(value) {
243
+ if (BARE_POWER_SHELL_WORD_RE.test(value))
244
+ return value;
245
+ return `'${value.replace(/'/g, "''")}'`;
246
+ }
247
+ /**
248
+ * `-EncodedCommand` takes base64 of UTF-16LE, which needs no shell quoting at all.
249
+ *
250
+ * The alphabet is `A-Za-z0-9+/=` - no spaces, no quotes, no `cmd` metacharacters -
251
+ * so the blob survives all three of Windows' parsers as an ordinary word. This is
252
+ * the same trick `ui/win-vt-force.ts` uses, for the same reason: encode the fragile
253
+ * thing into a form nothing can argue with, at construction time. Our command line
254
+ * necessarily contains spaced paths (`C:\Program Files\nodejs\node.exe`), so the
255
+ * fragile thing is not hypothetical.
256
+ */
257
+ export function encodePowerShellCommand(script) {
258
+ return Buffer.from(script, 'utf16le').toString('base64');
259
+ }
260
+ /** POSIX single-quote escaping: `'` becomes `'\''`. */
261
+ export function posixQuote(value) {
262
+ return `'${value.replace(/'/g, "'\\''")}'`;
263
+ }
264
+ export function posixScript(parts) {
265
+ return parts.map(posixQuote).join(' ');
266
+ }
267
+ /** AppleScript string literals only need `\` and `"` escaped. */
268
+ export function escapeAppleScriptText(value) {
269
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
270
+ }
271
+ /**
272
+ * Win32 argv quoting for the `cmd` rung's title and shell path: quote when it
273
+ * contains whitespace, otherwise leave it alone.
274
+ *
275
+ * THE INPUT DOMAIN EXCLUDES `"` BY CONSTRUCTION (R-17/T-48): Windows paths cannot
276
+ * contain it, and the title is `aragon <id>` where an id is `[A-Za-z0-9._-]`. If a
277
+ * future source can contain a quote, this must switch to `cmd`'s rule (`""`) and
278
+ * not to the POSIX `\"` written here - the two are not interchangeable.
279
+ */
280
+ export function quoteWin32Arg(value) {
281
+ return /\s/.test(value) ? `"${value}"` : value;
282
+ }
283
+ /** PATH entries for `name` probing: both spellings on Windows, `:` on POSIX. */
284
+ export function pathDirsOf(env, platform) {
285
+ if (normalizePlatform(platform) === 'win32') {
286
+ // `Path` and `PATH` genuinely coexist in a Windows env object (Node copies what
287
+ // it was given), and reading only one of them is how a probe silently finds
288
+ // nothing. Both are read, in that order, deduplicated.
289
+ const dirs = [];
290
+ for (const key of ['Path', 'PATH']) {
291
+ const value = env[key];
292
+ if (value === undefined || value.length === 0)
293
+ continue;
294
+ for (const dir of value.split(';')) {
295
+ if (dir.length > 0 && !dirs.includes(dir))
296
+ dirs.push(dir);
297
+ }
298
+ }
299
+ return dirs;
300
+ }
301
+ const value = env.PATH ?? '';
302
+ return value.split(':').filter((dir) => dir.length > 0);
303
+ }
304
+ /**
305
+ * The default probe: is this a file we could execute?
306
+ *
307
+ * An absolute or separator-bearing value is stat'd directly (that is the
308
+ * `ARAGON_BRANCH_SHELL=/usr/local/bin/fish` case); a bare name is looked up in the
309
+ * PATH directories, trying `name` and - on Windows - `name + '.exe'`, because a
310
+ * user typing `pwsh` and a probe looking for `pwsh.exe` are the same request.
311
+ *
312
+ * EVERY EXCEPTION RETURNS FALSE: one failed stat means "this candidate is not
313
+ * here", not "the feature is broken", and a missing terminal must degrade to the
314
+ * manual command rather than throw out of a key handler.
315
+ */
316
+ export function defaultIsExecutable(platform, pathDirs) {
317
+ const windows = normalizePlatform(platform) === 'win32';
318
+ const dirs = pathDirs ?? pathDirsOf(process.env, platform);
319
+ return (name) => {
320
+ if (name.length === 0)
321
+ return false;
322
+ const suffixes = windows && !/\.exe$/i.test(name) ? [name, `${name}.exe`] : [name];
323
+ if (isAbsolute(name) || /[\\/]/.test(name)) {
324
+ return suffixes.some((suffix) => isFile(suffix));
325
+ }
326
+ return dirs.some((dir) => suffixes.some((suffix) => isFile(join(dir, suffix))));
327
+ };
328
+ }
329
+ function isFile(path) {
330
+ try {
331
+ return statSync(path).isFile();
332
+ }
333
+ catch {
334
+ return false;
335
+ }
336
+ }
337
+ /**
338
+ * Which shell the new window runs (D-7), first match wins.
339
+ *
340
+ * The requirement says "PowerShell / bash", and both platforms have two answers
341
+ * worth distinguishing: PowerShell 7 (`pwsh.exe`) when the user installed it,
342
+ * Windows PowerShell 5.1 otherwise; `$SHELL` when the user's login shell is not
343
+ * bash, then bash, then `/bin/sh` as the one path that exists everywhere.
344
+ * `ARAGON_BRANCH_SHELL` is the escape hatch - an ENV VALUE, not a config key
345
+ * (D-17): a "Ctrl+L opens no terminal" switch would create the one state this
346
+ * feature must never have.
347
+ */
348
+ export function resolveShell(input) {
349
+ // `pathDirs` is FORWARDED, not dropped: a caller that supplies its own PATH
350
+ // (a test, or `branch-launch.ts` with the injected env) must not have the shell
351
+ // probe silently fall back to `process.env`'s PATH.
352
+ const isExecutable = input.isExecutable ?? defaultIsExecutable(input.platform, input.pathDirs);
353
+ const override = input.env[ARAGON_BRANCH_SHELL_ENV];
354
+ const platform = normalizePlatform(input.platform);
355
+ if (platform === 'win32') {
356
+ if (override !== undefined && override.length > 0 && isExecutable(override)) {
357
+ return { path: override, kind: 'powershell' };
358
+ }
359
+ if (isExecutable('pwsh.exe'))
360
+ return { path: 'pwsh.exe', kind: 'powershell' };
361
+ return { path: 'powershell.exe', kind: 'powershell' };
362
+ }
363
+ if (override !== undefined && override.length > 0) {
364
+ return { path: override, kind: 'posix' };
365
+ }
366
+ const loginShell = input.env.SHELL;
367
+ if (loginShell !== undefined && isAbsolute(loginShell)) {
368
+ return { path: loginShell, kind: 'posix' };
369
+ }
370
+ if (isExecutable('bash'))
371
+ return { path: 'bash', kind: 'posix' };
372
+ return { path: '/bin/sh', kind: 'posix' };
373
+ }
374
+ /**
375
+ * Fit a window title into `maxCells` columns (R-7 / T-47).
376
+ *
377
+ * THE TITLE IS THE ONLY THING THIS TOUCHES. An id can be 64 characters long, so
378
+ * `aragon <id>` exceeds the 60-cell budget by construction for the longest ids -
379
+ * and truncating the id anywhere else (the command line, `--resume`) would produce
380
+ * a branch that cannot find its own parent. Code points, not bytes: the title is
381
+ * ASCII today, and a future non-ASCII title must not be cut through a surrogate
382
+ * pair.
383
+ */
384
+ export function truncateTitle(title, maxCells) {
385
+ const chars = Array.from(title);
386
+ // A budget that is not a usable number is a caller bug, not a reason to destroy
387
+ // the title: clamping to one character would be silently worse than not cutting.
388
+ if (!Number.isFinite(maxCells) || maxCells < 1)
389
+ return title;
390
+ if (chars.length <= maxCells)
391
+ return title;
392
+ return chars.slice(0, Math.floor(maxCells)).join('');
393
+ }
@@ -0,0 +1,127 @@
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 PRODUCES NO IO ITSELF. 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
+ * IT DOES IMPORT `proc/terminal-plan.ts` (D-5), and that is still the right
16
+ * direction: `session -> proc`. The manual line needs both quoting dialects, and
17
+ * the dialect owner is `proc/terminal-plan.ts`; putting it in `proc/` would mean
18
+ * `proc/` importing `session/`, which is the cycle the header above forbids.
19
+ * What that import brings with it is one `statSync` probe used by the PATH lookup -
20
+ * not a process, not a write - so "no IO on purpose" still describes this module's
21
+ * own behaviour.
22
+ *
23
+ * NAMED `branch-invocation`, NOT `branch-command` (R-22): `commands/branch-command.ts`
24
+ * is the command body, and two files with one basename make every import, stack
25
+ * trace and grep ambiguous. What this module exports is an *invocation*.
26
+ */
27
+ /** Which of the three resolution rules produced the command (D-8). */
28
+ export type AragonCommandKind = 'override' | 'script' | 'bin';
29
+ export interface AragonCommandResolution {
30
+ /** argv pieces, ready to hand to a shell or a terminal emulator. */
31
+ parts: string[];
32
+ kind: AragonCommandKind;
33
+ /** Human-readable note for the log and the error copy. Never part of the command. */
34
+ origin: string;
35
+ }
36
+ export interface BranchInvocation {
37
+ /** `[...aragon.parts, '--resume', id]`. */
38
+ parts: string[];
39
+ /**
40
+ * One line, for the toast and the log.
41
+ *
42
+ * NOT THE PASTEABLE FORM ON WIN32 - and the defect that produced this comment is
43
+ * the proof: a double-quoted command word is a string EXPRESSION to PowerShell, so
44
+ * pasting this line into the shell Windows Terminal opens by default yields the
45
+ * same `ParserError` the new window used to. `renderManualCommand(parts,
46
+ * platform)` is the paste-ready form; on win32 it is two labelled lines.
47
+ */
48
+ command: string;
49
+ }
50
+ export declare const BRANCH_RESUME_FLAG = "--resume";
51
+ /** The env var that overrides "what executable the new window runs" (D-7 sibling). */
52
+ export declare const ARAGON_BRANCH_COMMAND_ENV = "ARAGON_BRANCH_COMMAND";
53
+ /**
54
+ * Decide what to execute in the new window (D-8), first match wins.
55
+ *
56
+ * The precedence exists because `aragon` is NOT on PATH in three of this
57
+ * repository's own four ways of running: `npm run dev:cli`, `npx
58
+ * @aragon-agent/cli`, and `node packages/cli/dist/cli.js`. Rule 2 recovers all
59
+ * three from `process.execPath` + `process.argv[1]` without consulting PATH at
60
+ * all, which is the only way "press the key and something starts" holds on a
61
+ * machine where the global install is missing.
62
+ *
63
+ * The override is treated as ONE executable path and never split into arguments:
64
+ * splitting needs a shell parser, and a shell parser is what re-introduces the
65
+ * quoting problem this feature spends a whole module avoiding. The documented
66
+ * escape hatch is a `.cmd` / `.sh` wrapper.
67
+ */
68
+ export declare function resolveAragonCommand(input: {
69
+ argv: readonly string[];
70
+ execPath: string;
71
+ env: Record<string, string | undefined>;
72
+ }): AragonCommandResolution;
73
+ /**
74
+ * `parts` + `--resume <id>`, plus the ONE-LINE form for the toast and the log.
75
+ *
76
+ * The paste-ready form is NOT this field (see `BranchInvocation.command` and
77
+ * `renderManualCommand`): "one line for every shell" was the claim this branch
78
+ * disproved, and leaving it here would keep the next reader believing it.
79
+ */
80
+ export declare function buildResumeInvocation(input: {
81
+ id: string;
82
+ aragon: AragonCommandResolution;
83
+ }): BranchInvocation;
84
+ /**
85
+ * Join argv pieces into one line a human can read at a glance.
86
+ *
87
+ * NOT A SHELL ESCAPER, and no longer claimed to be a universal paste line: it is
88
+ * the RIGHT line for `cmd.exe` and for POSIX shells, and the WRONG one for
89
+ * PowerShell on win32 (a double-quoted command word is an expression there). Its
90
+ * product is what a POSIX/darwin user pastes, and what the `cmd.exe` half of the
91
+ * win32 manual block shows; the PowerShell half comes from `powerShellScript` via
92
+ * `renderManualCommand`. The real command lines are built by
93
+ * `proc/terminal-plan.ts`, which quotes for the exact parser each rung lands in.
94
+ */
95
+ export declare function joinCommandLine(parts: readonly string[]): string;
96
+ /**
97
+ * The paste-ready form of the command, per platform (D-5 / D-6 / I-4).
98
+ *
99
+ * ONE LINE CANNOT BE CORRECT FOR BOTH WINDOWS PARSERS. A double-quoted command word
100
+ * is a string EXPRESSION to PowerShell (the defect this branch fixes) while it is
101
+ * exactly right for `cmd.exe`; `& 'x'` is right for PowerShell and wrong for
102
+ * `cmd.exe` (which keeps the single quotes and tries to run `'x'`). So Windows gets
103
+ * two labelled lines and the user copies the one matching the terminal they opened;
104
+ * POSIX and macOS keep the single line they have always printed.
105
+ *
106
+ * The first line is `powerShellScript(parts)` VERBATIM, which is what makes I-1
107
+ * ("one command, one artefact") testable: the fallback the user pastes is the same
108
+ * text the new window receives in its `-EncodedCommand` payload.
109
+ *
110
+ * @param parts argv pieces of the aragon invocation, `--resume <id>` included.
111
+ * @param platform the platform whose parsers the two lines have to satisfy.
112
+ * @returns one line off Windows; two ASCII lines labelled `PowerShell:` and
113
+ * `cmd.exe :` on win32. Both labels are 11 columns wide on purpose.
114
+ */
115
+ export declare function renderManualCommand(parts: readonly string[], platform: NodeJS.Platform): string;
116
+ /**
117
+ * The second gate on D-9: an id is about to be concatenated into a command line.
118
+ *
119
+ * The command body checks first (it owns the user-facing sentence). This check
120
+ * exists because a future caller can go straight to `buildResumeInvocation`, and
121
+ * the injection defence belongs at the boundary that builds the string rather
122
+ * than at the one call site that happens to exist today.
123
+ *
124
+ * Returns `null` when the id is usable, otherwise one sentence naming the actual
125
+ * value (`<= 64 characters`, first 24 of it) so the user can see what was wrong.
126
+ */
127
+ export declare function validateBranchId(id: string): string | null;
@@ -0,0 +1,153 @@
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 PRODUCES NO IO ITSELF. 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
+ * IT DOES IMPORT `proc/terminal-plan.ts` (D-5), and that is still the right
16
+ * direction: `session -> proc`. The manual line needs both quoting dialects, and
17
+ * the dialect owner is `proc/terminal-plan.ts`; putting it in `proc/` would mean
18
+ * `proc/` importing `session/`, which is the cycle the header above forbids.
19
+ * What that import brings with it is one `statSync` probe used by the PATH lookup -
20
+ * not a process, not a write - so "no IO on purpose" still describes this module's
21
+ * own behaviour.
22
+ *
23
+ * NAMED `branch-invocation`, NOT `branch-command` (R-22): `commands/branch-command.ts`
24
+ * is the command body, and two files with one basename make every import, stack
25
+ * trace and grep ambiguous. What this module exports is an *invocation*.
26
+ */
27
+ import { isAbsolute } from 'node:path';
28
+ import { powerShellScript } from '../proc/terminal-plan.js';
29
+ import { isValidSessionId } from './store.js';
30
+ export const BRANCH_RESUME_FLAG = '--resume';
31
+ /** The env var that overrides "what executable the new window runs" (D-7 sibling). */
32
+ export const ARAGON_BRANCH_COMMAND_ENV = 'ARAGON_BRANCH_COMMAND';
33
+ /** Longest prefix of a rejected id that reaches the user's screen. */
34
+ const REJECTED_ID_PREVIEW_CELLS = 24;
35
+ /**
36
+ * A path that could be a script we are already running (`node <script.js>`).
37
+ *
38
+ * Anchored and case-insensitive: Windows writes `.JS` in the wild, and an
39
+ * unanchored variant would also match `cli.js.bak` - which is not something node
40
+ * could have been invoked with.
41
+ */
42
+ const SCRIPT_PATH_RE = /\.(?:js|cjs|mjs)$/i;
43
+ /**
44
+ * Decide what to execute in the new window (D-8), first match wins.
45
+ *
46
+ * The precedence exists because `aragon` is NOT on PATH in three of this
47
+ * repository's own four ways of running: `npm run dev:cli`, `npx
48
+ * @aragon-agent/cli`, and `node packages/cli/dist/cli.js`. Rule 2 recovers all
49
+ * three from `process.execPath` + `process.argv[1]` without consulting PATH at
50
+ * all, which is the only way "press the key and something starts" holds on a
51
+ * machine where the global install is missing.
52
+ *
53
+ * The override is treated as ONE executable path and never split into arguments:
54
+ * splitting needs a shell parser, and a shell parser is what re-introduces the
55
+ * quoting problem this feature spends a whole module avoiding. The documented
56
+ * escape hatch is a `.cmd` / `.sh` wrapper.
57
+ */
58
+ export function resolveAragonCommand(input) {
59
+ const override = input.env[ARAGON_BRANCH_COMMAND_ENV];
60
+ if (override !== undefined && override.length > 0) {
61
+ return {
62
+ parts: [override],
63
+ kind: 'override',
64
+ origin: `${ARAGON_BRANCH_COMMAND_ENV}=${override}`,
65
+ };
66
+ }
67
+ const script = input.argv[1];
68
+ if (script !== undefined && isAbsolute(script) && SCRIPT_PATH_RE.test(script)) {
69
+ return {
70
+ parts: [input.execPath, script],
71
+ kind: 'script',
72
+ origin: 'process.execPath + process.argv[1]',
73
+ };
74
+ }
75
+ return { parts: ['aragon'], kind: 'bin', origin: 'aragon on PATH' };
76
+ }
77
+ /**
78
+ * `parts` + `--resume <id>`, plus the ONE-LINE form for the toast and the log.
79
+ *
80
+ * The paste-ready form is NOT this field (see `BranchInvocation.command` and
81
+ * `renderManualCommand`): "one line for every shell" was the claim this branch
82
+ * disproved, and leaving it here would keep the next reader believing it.
83
+ */
84
+ export function buildResumeInvocation(input) {
85
+ const parts = [...input.aragon.parts, BRANCH_RESUME_FLAG, input.id];
86
+ return { parts, command: joinCommandLine(parts) };
87
+ }
88
+ /**
89
+ * Join argv pieces into one line a human can read at a glance.
90
+ *
91
+ * NOT A SHELL ESCAPER, and no longer claimed to be a universal paste line: it is
92
+ * the RIGHT line for `cmd.exe` and for POSIX shells, and the WRONG one for
93
+ * PowerShell on win32 (a double-quoted command word is an expression there). Its
94
+ * product is what a POSIX/darwin user pastes, and what the `cmd.exe` half of the
95
+ * win32 manual block shows; the PowerShell half comes from `powerShellScript` via
96
+ * `renderManualCommand`. The real command lines are built by
97
+ * `proc/terminal-plan.ts`, which quotes for the exact parser each rung lands in.
98
+ */
99
+ export function joinCommandLine(parts) {
100
+ return parts.map(quoteForDisplay).join(' ');
101
+ }
102
+ /**
103
+ * The paste-ready form of the command, per platform (D-5 / D-6 / I-4).
104
+ *
105
+ * ONE LINE CANNOT BE CORRECT FOR BOTH WINDOWS PARSERS. A double-quoted command word
106
+ * is a string EXPRESSION to PowerShell (the defect this branch fixes) while it is
107
+ * exactly right for `cmd.exe`; `& 'x'` is right for PowerShell and wrong for
108
+ * `cmd.exe` (which keeps the single quotes and tries to run `'x'`). So Windows gets
109
+ * two labelled lines and the user copies the one matching the terminal they opened;
110
+ * POSIX and macOS keep the single line they have always printed.
111
+ *
112
+ * The first line is `powerShellScript(parts)` VERBATIM, which is what makes I-1
113
+ * ("one command, one artefact") testable: the fallback the user pastes is the same
114
+ * text the new window receives in its `-EncodedCommand` payload.
115
+ *
116
+ * @param parts argv pieces of the aragon invocation, `--resume <id>` included.
117
+ * @param platform the platform whose parsers the two lines have to satisfy.
118
+ * @returns one line off Windows; two ASCII lines labelled `PowerShell:` and
119
+ * `cmd.exe :` on win32. Both labels are 11 columns wide on purpose.
120
+ */
121
+ export function renderManualCommand(parts, platform) {
122
+ if (platform !== 'win32')
123
+ return joinCommandLine(parts);
124
+ return [
125
+ `PowerShell: ${powerShellScript(parts)}`,
126
+ `cmd.exe : ${joinCommandLine(parts)}`,
127
+ ].join('\n');
128
+ }
129
+ /** Empty and whitespace-or-quote-bearing pieces get double quotes; the rest stay bare. */
130
+ function quoteForDisplay(part) {
131
+ if (part.length === 0)
132
+ return '""';
133
+ if (!/[\s"]/.test(part))
134
+ return part;
135
+ return `"${part.replace(/"/g, '\\"')}"`;
136
+ }
137
+ /**
138
+ * The second gate on D-9: an id is about to be concatenated into a command line.
139
+ *
140
+ * The command body checks first (it owns the user-facing sentence). This check
141
+ * exists because a future caller can go straight to `buildResumeInvocation`, and
142
+ * the injection defence belongs at the boundary that builds the string rather
143
+ * than at the one call site that happens to exist today.
144
+ *
145
+ * Returns `null` when the id is usable, otherwise one sentence naming the actual
146
+ * value (`<= 64 characters`, first 24 of it) so the user can see what was wrong.
147
+ */
148
+ export function validateBranchId(id) {
149
+ if (isValidSessionId(id))
150
+ return null;
151
+ const preview = id.slice(0, REJECTED_ID_PREVIEW_CELLS);
152
+ return `Session id "${preview}" is not a legal file name, so it cannot be resumed.`;
153
+ }