@aragon-agent/cli 0.6.25 → 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 (48) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +165 -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 +155 -0
  16. package/dist/proc/terminal-plan.js +360 -0
  17. package/dist/session/branch-invocation.d.ts +83 -0
  18. package/dist/session/branch-invocation.js +109 -0
  19. package/dist/session/branch-launch.d.ts +70 -0
  20. package/dist/session/branch-launch.js +248 -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,109 @@
1
+ /**
2
+ * `/session` - report this session's identity, and the two things a user does
3
+ * with it (tui-session-id-resume §3.11 / D-13 / D-14).
4
+ *
5
+ * ASCII ONLY - `src/commands/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHY THE BODY LIVES HERE AND THE REGISTRATION STAYS IN `builtins.ts`: the same
8
+ * reason `/clear` and `/compact` do. `builtins.ts` is already past the
9
+ * 1000-line guideline, and this command is a report, not a one-liner.
10
+ *
11
+ * COPYING THE RESUME COMMAND IS THE POINT, NOT A CONVENIENCE (D-14). Typing 6
12
+ * characters off a status bar is the only step of this whole feature that can
13
+ * fail for a human, and `/session copy` removes it: one keystroke, the exact
14
+ * command in the clipboard, no transcription.
15
+ */
16
+ import { sessionPathFor } from '../session/store.js';
17
+ /**
18
+ * The one path an id maps to.
19
+ *
20
+ * THROUGH `sessionPathFor` RATHER THAN A SECOND `join` (§2.2): that function is
21
+ * the single choke point where an id becomes a file name, and a report that
22
+ * built its own path would be the first thing to disagree with the writer about
23
+ * where a session lives.
24
+ */
25
+ export function sessionFilePath(id) {
26
+ return sessionPathFor(id);
27
+ }
28
+ /**
29
+ * Run `/session`, `/session copy` or `/session pin`.
30
+ *
31
+ * ONE NOTICE FOR THE WHOLE REPORT (§3.11 / R-20): six separate notices would put
32
+ * six cards in the transcript and replay them as six fragments on exit. The
33
+ * `file` line is NOT truncated even when it overflows: it is a path the user
34
+ * needs to be able to copy, and it is in the same family as `/save`'s
35
+ * `Saved session to <path>`.
36
+ */
37
+ export function runSessionCommand(ctx) {
38
+ const port = ctx.session;
39
+ if (!port) {
40
+ // HONEST RATHER THAN SILENT: a build without the feature should say so,
41
+ // because "nothing happened" reads as a bug.
42
+ ctx.notify('warn', 'This session has no id (session recording is off for this build).');
43
+ return;
44
+ }
45
+ const verb = ctx.args.trim().toLowerCase();
46
+ const identity = port.identity();
47
+ if (!identity) {
48
+ ctx.notify('warn', 'This session has no id (session recording is off for this build).');
49
+ return;
50
+ }
51
+ if (verb === 'copy') {
52
+ copyCommand(ctx, identity);
53
+ return;
54
+ }
55
+ if (verb === 'pin') {
56
+ pinSession(ctx, port, identity);
57
+ return;
58
+ }
59
+ if (verb !== '') {
60
+ // Rejected rather than ignored: a command that silently discards an argument
61
+ // is a surface that lies about what it understood.
62
+ ctx.notify('warn', `Unknown argument "${ctx.args.trim()}" - use /session [copy|pin].`);
63
+ return;
64
+ }
65
+ report(ctx, identity);
66
+ }
67
+ /** The six-line report. Pure read: no checkpoint, no toast, no dispatch. */
68
+ function report(ctx, identity) {
69
+ const resume = `aragon --resume ${identity.id}`;
70
+ const lines = [
71
+ `id ${identity.id}`,
72
+ `file ${sessionFilePath(identity.id)}`,
73
+ identity.parentId
74
+ ? `branch ${identity.parentId} (this session does not write to it)`
75
+ : 'branch (none - this session started here)',
76
+ `pinned ${identity.pinned ? 'yes' : 'no'}`,
77
+ `resume ${resume}`,
78
+ 'save as aragon --save <new-id>',
79
+ ];
80
+ ctx.notify('info', lines.join('\n'));
81
+ }
82
+ /** `/session copy` - the exact command, through the SAME door `/copy` uses. */
83
+ function copyCommand(ctx, identity) {
84
+ const text = `aragon --resume ${identity.id}`;
85
+ if (!ctx.requestCopy) {
86
+ ctx.notify('warn', 'Clipboard coordinator unavailable.');
87
+ return;
88
+ }
89
+ void ctx.requestCopy({ text, lines: 1 }).then((result) => {
90
+ if (result.status === 'confirmed' || result.status === 'sent') {
91
+ ctx.toast('info', `Copied ${text}`);
92
+ return;
93
+ }
94
+ // A failed or refused copy must not look like a successful one: the command
95
+ // is short enough to read off the report above, so naming the failure is
96
+ // enough to recover.
97
+ ctx.notify('warn', result.status === 'busy' ? 'Clipboard is busy - try /session copy again.' :
98
+ `Copy failed: ${result.reason}`);
99
+ });
100
+ }
101
+ /** `/session pin` - write once and stop `sessions prune` from ever touching it. */
102
+ function pinSession(ctx, port, identity) {
103
+ const result = port.checkpoint({ pin: true });
104
+ if (!result.ok) {
105
+ ctx.notify('warn', `Could not pin this session: ${result.message}`);
106
+ return;
107
+ }
108
+ ctx.toast('info', `Session ${identity.id} pinned: aragon sessions prune will not delete it.`);
109
+ }
@@ -228,6 +228,21 @@ export interface CliFlags {
228
228
  compaction?: boolean;
229
229
  /** `--compaction-threshold <0..1|N%>` — session override for `threshold`. */
230
230
  compactionThreshold?: string;
231
+ /**
232
+ * `--resume <id|path>` - continue a saved session under a NEW id
233
+ * (tui-session-id-resume 4).
234
+ *
235
+ * A FLAG FIELD AND NOT A CONFIG KEY (D-19). There is no persisted "always
236
+ * resume this session", and no switch that shows an id without writing it:
237
+ * that state - an id a user can copy and cannot use - is worse than having no
238
+ * id at all.
239
+ *
240
+ * Carried through `toFlags` like every other flag; a field that reaches only
241
+ * `CliFlags` is inert with no compile error and no runtime error.
242
+ */
243
+ resumeSession?: string;
244
+ /** `--save <id>` - name THIS run's persistent session and pin it. */
245
+ saveSessionAs?: string;
231
246
  }
232
247
  /**
233
248
  * Resolve the effective config by merging all layers, then derive the runtime
@@ -418,7 +418,16 @@ function planSession(options, cwd, err) {
418
418
  if (options.sessionId !== undefined) {
419
419
  // THE UPSERT. A wrapper that mints its own id must not have to ask whether
420
420
  // this is the first turn; that is the whole reason this form exists.
421
- return { id: options.sessionId, saved: readSessionFile(sessionPathFor(options.sessionId)), saving, cwd };
421
+ //
422
+ // AND IT IS A RESUME FORM, so the same write-back rule applies (tui-session-
423
+ // id-resume D-20 / I-3). When a file already exists under that id,
424
+ // `resumedSessionId` decides which id this run writes - and for a TUI
425
+ // checkpoint that answer is a NEW uuid. Without this line the wrapper-friendly
426
+ // upsert was the one remaining way to reach a TUI checkpoint and replace it,
427
+ // which is silent, unrecoverable loss of the user's live conversation.
428
+ const path = sessionPathFor(options.sessionId);
429
+ const saved = readSessionFile(path);
430
+ return { id: saved ? resumedSessionId(path, saved) : options.sessionId, saved, saving, cwd };
422
431
  }
423
432
  return { id: randomUUID(), saved: null, saving, cwd };
424
433
  }
@@ -23,7 +23,7 @@
23
23
  import type { LogConfig } from '../config/schema.js';
24
24
  import { type LogLevelName } from './levels.js';
25
25
  /** Closed set, not free text: a typo'd scope is a record nobody can filter on. */
26
- export type LogScope = 'cli' | 'config' | 'agent' | 'tool' | 'skills' | 'llm' | 'migrate' | 'log' | 'fast' | 'history' | 'update' | 'compaction';
26
+ export type LogScope = 'cli' | 'config' | 'agent' | 'tool' | 'skills' | 'llm' | 'migrate' | 'log' | 'fast' | 'history' | 'update' | 'compaction' | 'session' | 'branch';
27
27
  export interface LogRecord {
28
28
  ts: string;
29
29
  lv: LogLevelName;
@@ -0,0 +1,155 @@
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
+ /** The three ladders, collapsed from `NodeJS.Platform` (win32 / darwin / everything else). */
30
+ export type TerminalPlatform = 'win32' | 'darwin' | 'posix';
31
+ export interface ShellInvocation {
32
+ /** Executable: an absolute path or a name resolved through PATH. */
33
+ path: string;
34
+ /** Which command syntax the aragon command has to be encoded for. */
35
+ kind: 'powershell' | 'posix';
36
+ }
37
+ export interface TerminalLaunchPlan {
38
+ /** The program we spawn. */
39
+ file: string;
40
+ args: string[];
41
+ /** True only for the `cmd` rung, whose command line `cmd.exe` must see verbatim. */
42
+ windowsVerbatimArguments: boolean;
43
+ /** Ladder name, for the log and for the success toast's debug detail. */
44
+ rung: string;
45
+ }
46
+ export type TerminalPlanResult = {
47
+ ok: true;
48
+ plans: TerminalLaunchPlan[];
49
+ shell: ShellInvocation;
50
+ manual: string;
51
+ } | {
52
+ ok: false;
53
+ reason: 'no-launcher';
54
+ shell: ShellInvocation;
55
+ manual: string;
56
+ };
57
+ export interface TerminalPlanInput {
58
+ platform: NodeJS.Platform;
59
+ env: Record<string, string | undefined>;
60
+ pathDirs: readonly string[];
61
+ isExecutable?: (name: string) => boolean;
62
+ shell: ShellInvocation;
63
+ /** The aragon command's argv pieces (`session/branch-invocation.ts`'s product). */
64
+ command: readonly string[];
65
+ /** New window title, ASCII, already truncated to the cell budget. */
66
+ title: string;
67
+ cwd: string;
68
+ /** `joinCommandLine(command)` - the pasteable fallback shown to the user. */
69
+ manual: string;
70
+ }
71
+ /** The env var that overrides the shell the new window runs (D-7). */
72
+ export declare const ARAGON_BRANCH_SHELL_ENV = "ARAGON_BRANCH_SHELL";
73
+ /**
74
+ * Build the ladder for this platform, most-preferred rung first.
75
+ *
76
+ * The return value is a LIST because the failures this feature can actually hit
77
+ * are synchronous argument rejections (ENOENT on `wt.exe`, no X display), and a
78
+ * list lets `branch-launch.ts` walk down it without knowing anything about
79
+ * terminals. Rung COUNT is bounded by the caller (`BRANCH_TERMINAL_LIMITS.maxRungs`),
80
+ * so there is exactly one place that number lives.
81
+ */
82
+ export declare function planTerminalLaunch(input: TerminalPlanInput): TerminalPlanResult;
83
+ /** PowerShell: the whole command as one base64(UTF-16LE) blob (D-20). */
84
+ export declare function powerShellScript(parts: readonly string[]): string;
85
+ /** Bare words stay bare; everything else becomes a single-quoted literal. */
86
+ export declare function powerShellQuote(value: string): string;
87
+ /**
88
+ * `-EncodedCommand` takes base64 of UTF-16LE, which needs no shell quoting at all.
89
+ *
90
+ * The alphabet is `A-Za-z0-9+/=` - no spaces, no quotes, no `cmd` metacharacters -
91
+ * so the blob survives all three of Windows' parsers as an ordinary word. This is
92
+ * the same trick `ui/win-vt-force.ts` uses, for the same reason: encode the fragile
93
+ * thing into a form nothing can argue with, at construction time. Our command line
94
+ * necessarily contains spaced paths (`C:\Program Files\nodejs\node.exe`), so the
95
+ * fragile thing is not hypothetical.
96
+ */
97
+ export declare function encodePowerShellCommand(script: string): string;
98
+ /** POSIX single-quote escaping: `'` becomes `'\''`. */
99
+ export declare function posixQuote(value: string): string;
100
+ export declare function posixScript(parts: readonly string[]): string;
101
+ /** AppleScript string literals only need `\` and `"` escaped. */
102
+ export declare function escapeAppleScriptText(value: string): string;
103
+ /**
104
+ * Win32 argv quoting for the `cmd` rung's title and shell path: quote when it
105
+ * contains whitespace, otherwise leave it alone.
106
+ *
107
+ * THE INPUT DOMAIN EXCLUDES `"` BY CONSTRUCTION (R-17/T-48): Windows paths cannot
108
+ * contain it, and the title is `aragon <id>` where an id is `[A-Za-z0-9._-]`. If a
109
+ * future source can contain a quote, this must switch to `cmd`'s rule (`""`) and
110
+ * not to the POSIX `\"` written here - the two are not interchangeable.
111
+ */
112
+ export declare function quoteWin32Arg(value: string): string;
113
+ /** PATH entries for `name` probing: both spellings on Windows, `:` on POSIX. */
114
+ export declare function pathDirsOf(env: Record<string, string | undefined>, platform: NodeJS.Platform): string[];
115
+ /**
116
+ * The default probe: is this a file we could execute?
117
+ *
118
+ * An absolute or separator-bearing value is stat'd directly (that is the
119
+ * `ARAGON_BRANCH_SHELL=/usr/local/bin/fish` case); a bare name is looked up in the
120
+ * PATH directories, trying `name` and - on Windows - `name + '.exe'`, because a
121
+ * user typing `pwsh` and a probe looking for `pwsh.exe` are the same request.
122
+ *
123
+ * EVERY EXCEPTION RETURNS FALSE: one failed stat means "this candidate is not
124
+ * here", not "the feature is broken", and a missing terminal must degrade to the
125
+ * manual command rather than throw out of a key handler.
126
+ */
127
+ export declare function defaultIsExecutable(platform: NodeJS.Platform, pathDirs?: readonly string[]): (name: string) => boolean;
128
+ /**
129
+ * Which shell the new window runs (D-7), first match wins.
130
+ *
131
+ * The requirement says "PowerShell / bash", and both platforms have two answers
132
+ * worth distinguishing: PowerShell 7 (`pwsh.exe`) when the user installed it,
133
+ * Windows PowerShell 5.1 otherwise; `$SHELL` when the user's login shell is not
134
+ * bash, then bash, then `/bin/sh` as the one path that exists everywhere.
135
+ * `ARAGON_BRANCH_SHELL` is the escape hatch - an ENV VALUE, not a config key
136
+ * (D-17): a "Ctrl+L opens no terminal" switch would create the one state this
137
+ * feature must never have.
138
+ */
139
+ export declare function resolveShell(input: {
140
+ platform: NodeJS.Platform;
141
+ env: Record<string, string | undefined>;
142
+ isExecutable?: (name: string) => boolean;
143
+ pathDirs: readonly string[];
144
+ }): ShellInvocation;
145
+ /**
146
+ * Fit a window title into `maxCells` columns (R-7 / T-47).
147
+ *
148
+ * THE TITLE IS THE ONLY THING THIS TOUCHES. An id can be 64 characters long, so
149
+ * `aragon <id>` exceeds the 60-cell budget by construction for the longest ids -
150
+ * and truncating the id anywhere else (the command line, `--resume`) would produce
151
+ * a branch that cannot find its own parent. Code points, not bytes: the title is
152
+ * ASCII today, and a future non-ASCII title must not be cut through a surrogate
153
+ * pair.
154
+ */
155
+ export declare function truncateTitle(title: string, maxCells: number): string;
@@ -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
+ }