@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +1924 -1786
  2. package/README.md +2730 -2562
  3. package/dist/agent/controller.d.ts +49 -3
  4. package/dist/agent/controller.js +78 -3
  5. package/dist/agent/conversation-reset.d.ts +91 -0
  6. package/dist/agent/conversation-reset.js +66 -0
  7. package/dist/agent/model-probe-limits.d.ts +39 -0
  8. package/dist/agent/model-probe-limits.js +25 -0
  9. package/dist/agent/model-probe.d.ts +104 -0
  10. package/dist/agent/model-probe.js +391 -0
  11. package/dist/agent/reducer.d.ts +22 -0
  12. package/dist/agent/reducer.js +49 -14
  13. package/dist/cli.js +214 -2
  14. package/dist/commands/branch-command.d.ts +61 -0
  15. package/dist/commands/branch-command.js +117 -0
  16. package/dist/commands/builtins.js +62 -40
  17. package/dist/commands/clear-command.d.ts +27 -0
  18. package/dist/commands/clear-command.js +107 -0
  19. package/dist/commands/registry.d.ts +48 -1
  20. package/dist/commands/session-command.d.ts +54 -0
  21. package/dist/commands/session-command.js +109 -0
  22. package/dist/config/load.d.ts +15 -0
  23. package/dist/config/model-profiles.d.ts +8 -0
  24. package/dist/config/model-profiles.js +10 -0
  25. package/dist/exec/index.js +10 -1
  26. package/dist/logging/logger.d.ts +1 -1
  27. package/dist/proc/terminal-plan.d.ts +155 -0
  28. package/dist/proc/terminal-plan.js +360 -0
  29. package/dist/session/branch-invocation.d.ts +83 -0
  30. package/dist/session/branch-invocation.js +109 -0
  31. package/dist/session/branch-launch.d.ts +70 -0
  32. package/dist/session/branch-launch.js +248 -0
  33. package/dist/session/checkpoint.d.ts +257 -0
  34. package/dist/session/checkpoint.js +512 -0
  35. package/dist/session/clear-archive.d.ts +51 -0
  36. package/dist/session/clear-archive.js +160 -0
  37. package/dist/session/cli-commands.d.ts +2 -0
  38. package/dist/session/cli-commands.js +36 -5
  39. package/dist/session/identity.d.ts +72 -0
  40. package/dist/session/identity.js +148 -0
  41. package/dist/session/limits.d.ts +66 -0
  42. package/dist/session/limits.js +66 -0
  43. package/dist/session/persist.d.ts +30 -5
  44. package/dist/session/startup-session.d.ts +107 -0
  45. package/dist/session/startup-session.js +315 -0
  46. package/dist/session/store.d.ts +60 -1
  47. package/dist/session/store.js +95 -6
  48. package/dist/todo/types.d.ts +5 -3
  49. package/dist/ui/App.d.ts +54 -2
  50. package/dist/ui/App.js +154 -28
  51. package/dist/ui/Header.js +8 -16
  52. package/dist/ui/StatusBar.d.ts +8 -0
  53. package/dist/ui/StatusBar.js +12 -3
  54. package/dist/ui/entries/CompactionCard.d.ts +5 -4
  55. package/dist/ui/entries/CompactionCard.js +5 -4
  56. package/dist/ui/layout/header-hints.d.ts +68 -0
  57. package/dist/ui/layout/header-hints.js +138 -0
  58. package/dist/ui/layout/session-chip.d.ts +52 -0
  59. package/dist/ui/layout/session-chip.js +64 -0
  60. package/dist/ui/layout/status-layout.d.ts +10 -0
  61. package/dist/ui/layout/status-layout.js +8 -1
  62. package/dist/ui/overlays/HelpOverlay.js +17 -5
  63. package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
  64. package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
  65. package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
  66. package/dist/ui/overlays/ModelProfileEditor.js +117 -68
  67. package/dist/ui/overlays/SettingsScreen.d.ts +8 -0
  68. package/dist/ui/overlays/SettingsScreen.js +34 -2
  69. package/dist/ui/overlays/model-probe-view.d.ts +128 -0
  70. package/dist/ui/overlays/model-probe-view.js +225 -0
  71. package/dist/ui/overlays/model-profile-state.d.ts +9 -1
  72. package/dist/ui/overlays/model-profile-state.js +10 -0
  73. package/dist/ui/overlays/use-model-probe.d.ts +30 -0
  74. package/dist/ui/overlays/use-model-probe.js +109 -0
  75. package/dist/ui/session-bridge.d.ts +26 -0
  76. package/dist/ui/session-bridge.js +32 -0
  77. 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;