@aragon-agent/cli 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,10 +14,14 @@ steer / configure the session — all without leaving the terminal.
14
14
 
15
15
  Node **≥ 18**, any platform.
16
16
 
17
- **On Windows, `Shift+Tab` and the mouse wheel additionally need Node ≥ 22.17.0
18
- (or ≥ 24.2.0).** Everything else — the agent loop, every tool, skills, teams,
19
- `aragon -p` — works exactly the same on Node 18 and 20, which is why `engines`
20
- stays at `>=18` rather than locking those users out.
17
+ **On Windows with Node < 22.17.0 (or 24.0/24.1), aragon turns the missing
18
+ console flag on itself at startup**, which restores `Shift+Tab` on most
19
+ machines. Where that is blocked — a locked-down PowerShell, an old console
20
+ host — **`Ctrl+P` toggles the mode instead**; it is bound on every platform and
21
+ does not depend on the console mode, the Node version, or anything else.
22
+ Everything else — the agent loop, every tool, skills, teams, `aragon -p` — works
23
+ exactly the same on Node 18 and 20, which is why `engines` stays at `>=18`
24
+ rather than locking those users out.
21
25
 
22
26
  The reason is not the terminal emulator and not a setting: on Windows both
23
27
  `Shift+Tab` (`CSI Z`) and mouse reporting exist only while the console input
@@ -27,9 +31,14 @@ itself, and that translation has no entry for `Shift+Tab` (it arrives as a
27
31
  plain `Tab`) and discards mouse records outright. Node 20 is the current
28
32
  Windows default LTS, so this is a common configuration rather than an exotic
29
33
  one — it is why the same build behaves differently on two machines. Below that
30
- version aragon says so at startup and turns mouse reporting off rather than
31
- asking for reports that can never arrive; use `/plan` to switch mode and
32
- `Shift+↑` / `Shift+↓` to scroll until you upgrade.
34
+ version aragon sets `ENABLE_VIRTUAL_TERMINAL_INPUT` itself, from a short
35
+ PowerShell helper, right after it puts the console into raw mode — the flag is a
36
+ property of the console, not of Node, so any process attached to it can turn it
37
+ on. When that works, `Shift+Tab` arrives as `CSI Z` again and mouse reporting
38
+ goes back on with it — the wheel should follow by the same route, though only
39
+ the key has been measured. When it does not work, aragon says so at startup and
40
+ turns mouse reporting off rather than asking for reports that can never arrive;
41
+ `Ctrl+P` and `/plan` switch mode, and `Shift+↑` / `Shift+↓` scroll.
33
42
 
34
43
  ## Install
35
44
 
@@ -422,7 +431,8 @@ completes, `Up` / `Down` moves the selection, `Esc` closes it.
422
431
  | --- | --- |
423
432
  | `Enter` | Submit (idle) / queue a steering message (running) |
424
433
  | `Alt+Enter` / `Shift+Enter` | Insert a newline |
425
- | `Shift+Tab` | Toggle **plan mode** (`BUILD` <-> `PLAN`). Equivalent: `/plan`. On Windows needs Node ≥ 22.17 — see [Requirements](#requirements) |
434
+ | `Shift+Tab` | Toggle **plan mode** (`BUILD` <-> `PLAN`). Equivalent: `/plan`. On Windows this needs a console flag Node < 22.17 does not set; aragon sets it at startup — see [Requirements](#requirements) |
435
+ | `Ctrl+P` | The same toggle, over a channel no Windows console can swallow. Use it if `Shift+Tab` does nothing |
426
436
  | `Esc` | Abort the run / close an overlay / close a popup |
427
437
  | `Ctrl+C` ×2 | Exit (first press warns) |
428
438
  | `Ctrl+L` | Redraw the frame (clear the screen in inline mode) |
@@ -747,17 +757,28 @@ it is an informed act rather than a stray keypress.
747
757
 
748
758
  ### If `Shift+Tab` does nothing
749
759
 
750
- **On Windows, check your Node version first: `node -v`.** Below 22.17.0 (or on
751
- 24.0/24.1) the key never reaches aragon as `Shift+Tab` at all — it arrives as an
752
- ordinary `Tab`, so it does not switch mode, and with the `/` palette or an `@`
753
- popup open it completes that entry over your draft instead. The mouse wheel is
754
- dead for the same reason. See [Requirements](#requirements); aragon reports this
755
- once at startup when it detects it.
756
-
757
- Otherwise: some multiplexers and remote-desktop stacks never send `CSI Z`.
758
- Either way `/plan`, `/plan on`, `/plan off` and `/plan status` are first-class
759
- equivalents, and `--plan` / `ARAGON_PLAN=1` / `planModeDefault` cover the
760
- startup case.
760
+ **Press `Ctrl+P`.** It is the same toggle and it does not depend on any of the
761
+ machinery below.
762
+
763
+ To find out why, run this in the same terminal, press `Shift+Tab` once, then
764
+ `Ctrl+C`:
765
+
766
+ ```powershell
767
+ node -e "process.stdin.setRawMode(true);process.stdin.resume();process.stdin.on('data',b=>{console.log(b.toString('hex'));if(b[0]===3)process.exit(0)})"
768
+ ```
769
+
770
+ | It prints | What it means |
771
+ | --- | --- |
772
+ | `09` | The console is swallowing the modifier — the key is arriving as an ordinary `Tab`. Check `node -v`: below 22.17.0 (or on 24.0/24.1) this is expected, and aragon's own attempt to fix it was blocked. See [Requirements](#requirements) |
773
+ | `1b5b5a` | The key reaches the process fine, so something inside aragon is eating it — most often an overlay is open, or the run is still going and the switch is pending (the status bar shows `BUILD → PLAN`) |
774
+ | nothing | Your terminal, multiplexer or remote-desktop stack never sent it; some never send `CSI Z` at all |
775
+
776
+ On the first row the wheel is dead for the same reason, and with the `/` palette
777
+ or an `@` popup open the stray `Tab` completes that entry over your draft.
778
+
779
+ `/plan`, `/plan on`, `/plan off` and `/plan status` are first-class equivalents
780
+ in every case, and `--plan` / `ARAGON_PLAN=1` / `planModeDefault` cover the
781
+ startup case. `aragon doctor` reports this machine's answer as `vt-input`.
761
782
 
762
783
  ### Headless
763
784
 
@@ -28,6 +28,45 @@ export declare const MODE_LABEL: Record<AgentMode, string>;
28
28
  * and nothing else. Two ship today (§1 non-goals).
29
29
  */
30
30
  export declare function nextMode(current: AgentMode): AgentMode;
31
+ /**
32
+ * The keys that toggle the mode, as the user sees them written
33
+ * (shift-tab-mode-toggle-still-dead-on-windows, C2).
34
+ *
35
+ * `fallback` exists because `primary` CANNOT be made to work everywhere. On a
36
+ * Windows console without `ENABLE_VIRTUAL_TERMINAL_INPUT`, libuv takes the
37
+ * character branch for `VK_TAB` and drops the modifier wholesale, so `Shift+Tab`
38
+ * and `Ctrl+Tab` both arrive as a bare `0x09` - byte-identical to `Tab`. C1
39
+ * turns that bit back on where it can, but it has three known ways to fail that
40
+ * are outside this package (an old conhost refusing the bit, a locked-down
41
+ * PowerShell, a foreign process resetting the mode), so a rung that depends on
42
+ * NOTHING has to exist underneath it.
43
+ *
44
+ * `ctrl+p` IS MEASURED, not guessed. On the lossy path it arrives as `0x10`,
45
+ * because Windows generates a real control character for `Ctrl+<letter>` and
46
+ * libuv's character branch forwards it untouched; `Alt+M` produces no bytes at
47
+ * all and `Ctrl+Tab` collapses exactly like `Shift+Tab` (analysis section 3.6).
48
+ * Any idle `Ctrl+<letter>` in the `0x01-0x1A` range would work; this one is free
49
+ * throughout the package - `keymap.ts` claims a/e/w/u/k, `App` claims c/l/t/o,
50
+ * and `PromptInput` returns early on every `key.ctrl` - so binding it takes no
51
+ * behaviour away from anyone. `Ctrl+B` survives the same path but is tmux's
52
+ * default prefix, and this binding is unconditional and cross-platform, so it
53
+ * would be silently dead for a whole class of macOS and Linux users.
54
+ *
55
+ * BOUND UNCONDITIONALLY, on every platform. A binding that only exists where it
56
+ * is needed cannot be documented in one place, cannot be taught by muscle
57
+ * memory, and is missing on precisely the machine whose capability probe was
58
+ * wrong.
59
+ *
60
+ * THESE ARE THE LABELS, NOT THE BINDING. Editing a value here changes only what
61
+ * the user is TOLD to press - the hint row, the startup notice, `doctor`. The
62
+ * key that is actually listened for is `input === 'p'` in `App`'s `useInput`,
63
+ * and the help overlay and README spell both keys out in their own title case.
64
+ * Change one without the others and the product advertises a key it ignores.
65
+ */
66
+ export declare const MODE_TOGGLE_KEYS: {
67
+ readonly primary: "shift+tab";
68
+ readonly fallback: "ctrl+p";
69
+ };
31
70
  /** The two tools that only exist because a human is attached. */
32
71
  export declare const PLAN_TOOL_NAMES: readonly ["ask_user", "submit_plan"];
33
72
  /**
@@ -30,6 +30,42 @@ export function nextMode(current) {
30
30
  const index = AGENT_MODES.indexOf(current);
31
31
  return AGENT_MODES[(index + 1) % AGENT_MODES.length];
32
32
  }
33
+ /**
34
+ * The keys that toggle the mode, as the user sees them written
35
+ * (shift-tab-mode-toggle-still-dead-on-windows, C2).
36
+ *
37
+ * `fallback` exists because `primary` CANNOT be made to work everywhere. On a
38
+ * Windows console without `ENABLE_VIRTUAL_TERMINAL_INPUT`, libuv takes the
39
+ * character branch for `VK_TAB` and drops the modifier wholesale, so `Shift+Tab`
40
+ * and `Ctrl+Tab` both arrive as a bare `0x09` - byte-identical to `Tab`. C1
41
+ * turns that bit back on where it can, but it has three known ways to fail that
42
+ * are outside this package (an old conhost refusing the bit, a locked-down
43
+ * PowerShell, a foreign process resetting the mode), so a rung that depends on
44
+ * NOTHING has to exist underneath it.
45
+ *
46
+ * `ctrl+p` IS MEASURED, not guessed. On the lossy path it arrives as `0x10`,
47
+ * because Windows generates a real control character for `Ctrl+<letter>` and
48
+ * libuv's character branch forwards it untouched; `Alt+M` produces no bytes at
49
+ * all and `Ctrl+Tab` collapses exactly like `Shift+Tab` (analysis section 3.6).
50
+ * Any idle `Ctrl+<letter>` in the `0x01-0x1A` range would work; this one is free
51
+ * throughout the package - `keymap.ts` claims a/e/w/u/k, `App` claims c/l/t/o,
52
+ * and `PromptInput` returns early on every `key.ctrl` - so binding it takes no
53
+ * behaviour away from anyone. `Ctrl+B` survives the same path but is tmux's
54
+ * default prefix, and this binding is unconditional and cross-platform, so it
55
+ * would be silently dead for a whole class of macOS and Linux users.
56
+ *
57
+ * BOUND UNCONDITIONALLY, on every platform. A binding that only exists where it
58
+ * is needed cannot be documented in one place, cannot be taught by muscle
59
+ * memory, and is missing on precisely the machine whose capability probe was
60
+ * wrong.
61
+ *
62
+ * THESE ARE THE LABELS, NOT THE BINDING. Editing a value here changes only what
63
+ * the user is TOLD to press - the hint row, the startup notice, `doctor`. The
64
+ * key that is actually listened for is `input === 'p'` in `App`'s `useInput`,
65
+ * and the help overlay and README spell both keys out in their own title case.
66
+ * Change one without the others and the product advertises a key it ignores.
67
+ */
68
+ export const MODE_TOGGLE_KEYS = { primary: 'shift+tab', fallback: 'ctrl+p' };
33
69
  /** The two tools that only exist because a human is attached. */
34
70
  export const PLAN_TOOL_NAMES = ['ask_user', 'submit_plan'];
35
71
  /**
package/dist/cli.js CHANGED
@@ -47,6 +47,7 @@ import { setFrameStatsProvider } from './commands/perf.js';
47
47
  import { tryCreateMouseFilter } from './input/stdin-mouse-filter.js';
48
48
  import { enterAltScreen, writeExitTranscript } from './ui/screen.js';
49
49
  import { supportsWindowsVtInput } from './ui/win-vt-input.js';
50
+ import { forceWindowsVtInput } from './ui/win-vt-force.js';
50
51
  import { readExitSnapshot } from './ui/exit-snapshot.js';
51
52
  import { renderTranscriptText } from './ui/transcript-text.js';
52
53
  import { detectCapabilities } from './ui/capabilities.js';
@@ -285,6 +286,69 @@ function makeExecController(flags, deps) {
285
286
  */
286
287
  const FRAME_FALLBACK_NOTICE = 'Frame diffing lost sync with the terminal and fell back to a full repaint. ' +
287
288
  'If the display looks wrong, restart with `--no-diff-render`.';
289
+ /**
290
+ * Make this console deliver `CSI Z`, on a Node that will not do it for us
291
+ * (shift-tab-mode-toggle-still-dead-on-windows, C1).
292
+ *
293
+ * Returns whether the console now reports; never throws. `false` means nothing
294
+ * changed and the session runs exactly as it did before this feature existed,
295
+ * with `MODE_TOGGLE_KEYS.fallback` carrying the mode toggle instead.
296
+ *
297
+ * RAW MODE IS RAISED HERE, AND THE ORDER IS THE ENTIRE MECHANISM. libuv rewrites
298
+ * the whole console mode on every raw-mode TRANSITION, and its pre-22.17 path
299
+ * does not know about this bit - so setting the bit first and letting Ink raise
300
+ * raw mode afterwards clears it again, measured (analysis section 3.3). Raising
301
+ * it first inverts that: `uv_tty_set_mode` returns early when the mode is
302
+ * unchanged, so Ink's own `setRawMode(true)` at mount is a no-op and the bit
303
+ * survives to the first keypress.
304
+ *
305
+ * That same asymmetry is why there is no restore call anywhere. Ink lowers raw
306
+ * mode when it unmounts, and that transition IS the restore - it is the only
307
+ * thing that ever clears the bit, since neither Windows nor the console does
308
+ * (section 3.5b). The `exit` hook exists for the paths that never unmount
309
+ * (`taskkill`, a closed terminal window, a crash), which are exactly the
310
+ * unattended ones where a console left in VT-input mode has nobody to notice.
311
+ *
312
+ * WHY THIS RUNS BEFORE `render()` rather than from an effect inside `App`: the
313
+ * answer gates `wantMouse`, which decides the `?1000h` write and the filter
314
+ * BEFORE Ink mounts. Learning the truth one tick too late would mean the wheel
315
+ * stays off for the whole session on a console we just repaired.
316
+ */
317
+ function forceVtInputForThisConsole() {
318
+ if (process.platform !== 'win32' || !process.stdin.isTTY)
319
+ return false;
320
+ const logger = getLogger();
321
+ try {
322
+ process.stdin.setRawMode(true);
323
+ }
324
+ catch (err) {
325
+ // A TTY that refuses raw mode is not a console we can repair; C2 carries it.
326
+ logger.warn('cli', 'vt_input_rawmode_failed', { error: err.message });
327
+ return false;
328
+ }
329
+ process.on('exit', () => {
330
+ try {
331
+ process.stdin.setRawMode(false);
332
+ }
333
+ catch {
334
+ /* Stream already torn down — nothing left to restore. */
335
+ }
336
+ });
337
+ const result = forceWindowsVtInput();
338
+ if (result.ok) {
339
+ logger.info('cli', 'vt_input_forced', { before: result.before, after: result.after });
340
+ return true;
341
+ }
342
+ // Constrained Language Mode kills `Add-Type`, an old conhost drops the bit it
343
+ // does not implement, and `wrong_console` means we configured somebody else's
344
+ // console. All three are ordinary on the fleets this bug keeps landing on, so
345
+ // they degrade in silence and only the log says which one happened.
346
+ logger.warn('cli', 'vt_input_force_failed', {
347
+ reason: result.reason,
348
+ ...(result.detail ? { detail: result.detail } : {}),
349
+ });
350
+ return false;
351
+ }
288
352
  /**
289
353
  * The interactive TUI — and the ONLY place that may touch the screen.
290
354
  *
@@ -339,7 +403,15 @@ function runInteractive(flags, extras = {}) {
339
403
  // filter itself, and the `mouseSource` prop that decides whether the user is
340
404
  // advised about a mode that is not running. Wiring any of them separately
341
405
  // re-opens the gap where the app claims a capability it does not have.
342
- const vtInputSupported = supportsWindowsVtInput(process.platform, process.versions.node);
406
+ //
407
+ // AND `vtInputSupported` IS NOW A MEASUREMENT, NOT A PREDICTION
408
+ // (shift-tab-mode-toggle-still-dead-on-windows, C1). It used to be exactly
409
+ // `supportsWindowsVtInput(...)` - "is this Node new enough to turn the bit on
410
+ // for us?". We now turn it on ourselves when it is not, so continuing to gate
411
+ // on the Node version would switch the wheel off, and keep the warning up, on
412
+ // a console this process just repaired.
413
+ const vtInputByNode = supportsWindowsVtInput(process.platform, process.versions.node);
414
+ const vtInputSupported = vtInputByNode || forceVtInputForThisConsole();
343
415
  const wantMouse = mode === 'fullscreen' &&
344
416
  config.mouse &&
345
417
  !!process.stdout.isTTY &&
@@ -35,8 +35,8 @@ export interface UiState {
35
35
  /** Whether the one-shot "hold Shift to select text" notice has been shown. */
36
36
  mouseNoticeSeen: boolean;
37
37
  /**
38
- * Whether the one-shot "this Windows console cannot deliver Shift+Tab or the
39
- * wheel" notice has been shown.
38
+ * Which revision of the "this Windows console cannot deliver Shift+Tab or the
39
+ * wheel" notice this user has already been shown (`0` = none).
40
40
  *
41
41
  * A SEPARATE KEY FROM `mouseNoticeSeen`, AND THAT IS THE WHOLE POINT. The
42
42
  * people this notice exists for are precisely the ones who have already
@@ -44,12 +44,22 @@ export interface UiState {
44
44
  * them, and therefore already have `mouseNoticeSeen: true` on disk. Folding
45
45
  * the two would make the fix inert for its entire audience, in silence.
46
46
  *
47
+ * A VERSION RATHER THAN A BOOLEAN, for the second turn of that same screw.
48
+ * The predecessor of this field was `vtInputNoticeSeen: boolean`, written by
49
+ * builds whose text knew nothing about `MODE_TOGGLE_KEYS.fallback` — and the
50
+ * audience for the text that DOES name it is, once again, exactly the set of
51
+ * users who already have the old flag set. A boolean can only say "shown",
52
+ * never "shown WHAT", so the only way to reach them without re-showing the
53
+ * notice on every launch is to let the wording carry a number. That old key
54
+ * is deliberately not read: it is absent from the shape below, so a file
55
+ * written by 0.6.1 reads as `0` and replays exactly once.
56
+ *
47
57
  * NOT ADDED TO `UI_STATE_SCHEMA`: bumping the schema makes `readFromDisk`
48
58
  * discard the whole object, which would reset everyone's submit counter and
49
59
  * re-show the mouse notice. A new field simply reads as its default on an
50
60
  * older file, which is the behaviour wanted here anyway.
51
61
  */
52
- vtInputNoticeSeen: boolean;
62
+ vtInputNoticeVersion: number;
53
63
  }
54
64
  /** Read the state, caching it for the life of the process. Never throws. */
55
65
  export declare function loadUiState(): UiState;
@@ -58,8 +68,9 @@ export declare function readSubmitCount(): number;
58
68
  export declare function bumpSubmitCount(): number;
59
69
  export declare function getMouseNoticeSeen(): boolean;
60
70
  export declare function setMouseNoticeSeen(seen: boolean): void;
61
- export declare function getVtInputNoticeSeen(): boolean;
62
- export declare function setVtInputNoticeSeen(seen: boolean): void;
71
+ /** `0` on a file written before the field existed — see the field's comment. */
72
+ export declare function getVtInputNoticeVersion(): number;
73
+ export declare function setVtInputNoticeVersion(version: number): void;
63
74
  /**
64
75
  * Migration-only: fill in fields that are still at their default, and never
65
76
  * overwrite a value already here. A second import — after a half-finished
@@ -35,13 +35,14 @@ const DEFAULT_UI_STATE = {
35
35
  schema: UI_STATE_SCHEMA,
36
36
  submitCount: 0,
37
37
  mouseNoticeSeen: false,
38
- vtInputNoticeSeen: false,
38
+ vtInputNoticeVersion: 0,
39
39
  };
40
40
  let memory = null;
41
- function coerceCount(value) {
41
+ /** Non-negative integer or `fallback`; shared by both numeric fields. */
42
+ function coerceCount(value, fallback) {
42
43
  const n = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
43
44
  if (!Number.isFinite(n) || n < 0)
44
- return DEFAULT_UI_STATE.submitCount;
45
+ return fallback;
45
46
  return Math.floor(n);
46
47
  }
47
48
  /**
@@ -61,13 +62,11 @@ function readFromDisk() {
61
62
  return { ...DEFAULT_UI_STATE };
62
63
  return {
63
64
  schema: UI_STATE_SCHEMA,
64
- submitCount: coerceCount(parsed.submitCount),
65
+ submitCount: coerceCount(parsed.submitCount, DEFAULT_UI_STATE.submitCount),
65
66
  mouseNoticeSeen: typeof parsed.mouseNoticeSeen === 'boolean'
66
67
  ? parsed.mouseNoticeSeen
67
68
  : DEFAULT_UI_STATE.mouseNoticeSeen,
68
- vtInputNoticeSeen: typeof parsed.vtInputNoticeSeen === 'boolean'
69
- ? parsed.vtInputNoticeSeen
70
- : DEFAULT_UI_STATE.vtInputNoticeSeen,
69
+ vtInputNoticeVersion: coerceCount(parsed.vtInputNoticeVersion, DEFAULT_UI_STATE.vtInputNoticeVersion),
71
70
  };
72
71
  }
73
72
  catch {
@@ -127,11 +126,12 @@ export function getMouseNoticeSeen() {
127
126
  export function setMouseNoticeSeen(seen) {
128
127
  update({ mouseNoticeSeen: seen });
129
128
  }
130
- export function getVtInputNoticeSeen() {
131
- return loadUiState().vtInputNoticeSeen;
129
+ /** `0` on a file written before the field existed — see the field's comment. */
130
+ export function getVtInputNoticeVersion() {
131
+ return loadUiState().vtInputNoticeVersion;
132
132
  }
133
- export function setVtInputNoticeSeen(seen) {
134
- update({ vtInputNoticeSeen: seen });
133
+ export function setVtInputNoticeVersion(version) {
134
+ update({ vtInputNoticeVersion: version });
135
135
  }
136
136
  /**
137
137
  * Migration-only: fill in fields that are still at their default, and never
@@ -143,7 +143,7 @@ export function importLegacyUiState(patch) {
143
143
  const next = {};
144
144
  if (patch.submitCount !== undefined &&
145
145
  current.submitCount === DEFAULT_UI_STATE.submitCount) {
146
- next.submitCount = coerceCount(patch.submitCount);
146
+ next.submitCount = coerceCount(patch.submitCount, DEFAULT_UI_STATE.submitCount);
147
147
  }
148
148
  if (patch.mouseNoticeSeen !== undefined &&
149
149
  current.mouseNoticeSeen === DEFAULT_UI_STATE.mouseNoticeSeen) {
@@ -17,9 +17,11 @@ import { getHomeResolutionWarning, getSessionsDir, getUserDataDir } from '../con
17
17
  import { readConfigFile } from '../config/store.js';
18
18
  import { loadConfig, makeGetApiKey } from '../config/load.js';
19
19
  import { isAdapterProvider, toRetryPolicy } from '../config/schema.js';
20
+ import { MODE_TOGGLE_KEYS } from '../agent/agent-mode.js';
21
+ import { supportsWindowsVtInput } from '../ui/win-vt-input.js';
20
22
  const MIN_NODE_MAJOR = 18;
21
23
  export async function runDoctorChecks(flags, opts = {}) {
22
- const checks = [checkNode(), checkHome(), checkConfigFile()];
24
+ const checks = [checkNode(), ...checkVtInput(), checkHome(), checkConfigFile()];
23
25
  const config = loadConfig(flags);
24
26
  checks.push(checkProviderKey(config));
25
27
  checks.push(checkModel(config));
@@ -42,6 +44,44 @@ function checkNode() {
42
44
  remedy: `Install Node ${MIN_NODE_MAJOR} or newer.`,
43
45
  };
44
46
  }
47
+ /**
48
+ * Whether this console can deliver `Shift+Tab`
49
+ * (shift-tab-mode-toggle-still-dead-on-windows, C3-2).
50
+ *
51
+ * `MIN_NODE_MAJOR` STAYS AT 18 and this is a separate check on purpose. The
52
+ * install floor is about whether aragon runs; this is about one key, and raising
53
+ * the floor to 22.17 would lock out a large, otherwise perfectly healthy Node 20
54
+ * population over it.
55
+ *
56
+ * `warn`, never `fail`: aragon turns the console bit on itself at startup, and
57
+ * `MODE_TOGGLE_KEYS.fallback` works whether or not that succeeded, so nothing
58
+ * here is actually broken - the exit code must not say it is.
59
+ *
60
+ * WHY IT EXISTS AT ALL: `checkNode` passes every version this bug affects, so a
61
+ * user whose mode toggle is dead ran the tool built to explain their machine and
62
+ * was told it was fine (analysis section 3.7). A diagnostic that is confidently
63
+ * wrong is worse than one that is silent - it ends the investigation.
64
+ *
65
+ * Returned as a list so the row simply does not exist off Windows, where there
66
+ * is no such thing as a console input mode to report on.
67
+ */
68
+ function checkVtInput() {
69
+ if (process.platform !== 'win32')
70
+ return [];
71
+ if (supportsWindowsVtInput(process.platform, process.versions.node)) {
72
+ return [pass('vt-input', `v${process.versions.node} delivers Shift+Tab and mouse reports`)];
73
+ }
74
+ return [
75
+ {
76
+ name: 'vt-input',
77
+ verdict: 'warn',
78
+ detail: `Node v${process.versions.node} never enables ENABLE_VIRTUAL_TERMINAL_INPUT, so this ` +
79
+ 'console reports Shift+Tab as a plain Tab and sends no mouse events',
80
+ remedy: `aragon turns that bit on at startup; if a policy blocks it, ${MODE_TOGGLE_KEYS.fallback} ` +
81
+ 'and /plan toggle the mode too. Node 22.17+ (or 24.2+) removes the problem.',
82
+ },
83
+ ];
84
+ }
45
85
  function checkHome() {
46
86
  const home = getUserDataDir();
47
87
  const warning = getHomeResolutionWarning();
@@ -22,7 +22,7 @@ import process from 'node:process';
22
22
  import { runHeadless } from '../agent/headless.js';
23
23
  import { computeCost } from '../agent/usage.js';
24
24
  import { loadConfig } from '../config/load.js';
25
- import { latestSessionForCwd, readSessionFile, resolveSessionRef, sessionPathFor, tryAcquireSessionLock, writeSession, } from '../session/store.js';
25
+ import { latestSessionForCwd, readSessionFile, resolveSessionRef, resumedSessionId, sessionPathFor, tryAcquireSessionLock, writeSession, } from '../session/store.js';
26
26
  import { createEmitter } from './emitter.js';
27
27
  import { resolveExecOptions } from './options.js';
28
28
  import { resolvePermission } from './permission.js';
@@ -352,11 +352,15 @@ function planSession(options, cwd, err) {
352
352
  if (options.resume !== undefined) {
353
353
  const path = resolveSessionRef(options.resume, cwd);
354
354
  const saved = path ? readSessionFile(path) : null;
355
- if (!saved) {
355
+ if (!path || !saved) {
356
356
  err.write(`session_not_found: no readable session for --resume "${options.resume}".\n`);
357
357
  return null;
358
358
  }
359
- return { id: saved.meta?.id ?? options.resume, saved, saving, cwd };
359
+ // NOT `options.resume` AND NOT `meta.id` UNCHECKED (R-7). The flag accepts
360
+ // `<id|path>`, so the value the caller typed is frequently a path, and
361
+ // `meta.id` comes out of a file - neither is usable as a filename without
362
+ // being checked. `resumedSessionId` is the one place that decision is made.
363
+ return { id: resumedSessionId(path, saved), saved, saving, cwd };
360
364
  }
361
365
  if (options.continueSession) {
362
366
  const entry = latestSessionForCwd(cwd);
@@ -365,7 +369,7 @@ function planSession(options, cwd, err) {
365
369
  err.write(`no_session_for_cwd: no previous aragon exec session for ${cwd}.\n`);
366
370
  return null;
367
371
  }
368
- return { id: entry.id, saved, saving, cwd };
372
+ return { id: resumedSessionId(entry.path, saved), saved, saving, cwd };
369
373
  }
370
374
  if (options.sessionId !== undefined) {
371
375
  // THE UPSERT. A wrapper that mints its own id must not have to ask whether
@@ -193,8 +193,8 @@ export class ExecRunner {
193
193
  const followThrough = this.opts.followThrough ?? 'notify';
194
194
  for (;;) {
195
195
  const next = await source.next();
196
- // EXHAUSTION IS CHECKED BEFORE THE SOURCE, SO A RUN THAT ENDS ON ITS OWN
197
- // TERMS IS NEVER MISREPORTED (IF-3). `next === null` first is what makes
196
+ // THE SOURCE IS POLLED BEFORE THE EXHAUSTION CHECK, SO A RUN THAT ENDS ON
197
+ // ITS OWN TERMS IS NEVER MISREPORTED (IF-3). `next === null` first is what makes
198
198
  // that true: a conversation of exactly `--max-turns` turns whose caller has
199
199
  // nothing more to say exits 0 with `end_turn`, not 3 with `max_turns`.
200
200
  if (next === null)
@@ -16,7 +16,17 @@
16
16
  * should make on its own.
17
17
  */
18
18
  import process from 'node:process';
19
- import { listSessions, pruneSessions, readSessionFile, removeSession, sessionPathFor, sessionsDir, } from './store.js';
19
+ import { isValidSessionId, listSessions, pruneSessions, readSessionFile, removeSession, sessionPathFor, sessionsDir, } from './store.js';
20
+ /**
21
+ * One wording for one rule, shared by `show` and `rm`.
22
+ *
23
+ * It is deliberately the same sentence `--session-id` produces: a caller who
24
+ * meets the rule once should recognise it everywhere, and two spellings of the
25
+ * same refusal read as two different rules.
26
+ */
27
+ const INVALID_ID_MESSAGE = (id) => `Invalid session id "${id}". Use 1-64 characters matching ` +
28
+ '[A-Za-z0-9][A-Za-z0-9._-]* (no "/", "\\", ".." or a leading dot). ' +
29
+ 'Pass a path to `aragon exec --resume` if you mean a file elsewhere.';
20
30
  const SUBCOMMANDS = ['list', 'show', 'rm', 'prune', 'path'];
21
31
  export function registerSessionsCommand(program) {
22
32
  program
@@ -74,6 +84,10 @@ function runShow(argument, opts) {
74
84
  process.stderr.write('Usage: aragon sessions show <id> [--json]\n');
75
85
  return 2;
76
86
  }
87
+ if (!isValidSessionId(argument)) {
88
+ process.stderr.write(`${INVALID_ID_MESSAGE(argument)}\n`);
89
+ return 2;
90
+ }
77
91
  const path = sessionPathFor(argument);
78
92
  const session = readSessionFile(path);
79
93
  if (!session) {
@@ -112,6 +126,14 @@ function runRemove(argument, opts) {
112
126
  process.stderr.write('Refusing to delete without --yes.\n');
113
127
  return 2;
114
128
  }
129
+ // BEFORE `--yes` IS HONOURED AS PERMISSION TO DELETE. `sessions rm` is
130
+ // documented to accept any file IN THE SESSIONS DIRECTORY, because the user
131
+ // named it - not any file anywhere, which is what an unvalidated id joined to
132
+ // that directory would reach (R-7).
133
+ if (!isValidSessionId(argument)) {
134
+ process.stderr.write(`${INVALID_ID_MESSAGE(argument)}\n`);
135
+ return 2;
136
+ }
115
137
  if (!removeSession(argument)) {
116
138
  process.stderr.write(`session_not_found: no session "${argument}".\n`);
117
139
  return 2;
@@ -54,6 +54,24 @@ export interface SessionListEntry {
54
54
  }
55
55
  export declare function sessionsDir(): string;
56
56
  export declare function sessionPathFor(id: string): string;
57
+ /**
58
+ * The id a RESUMED session writes back under.
59
+ *
60
+ * `--resume` accepts `<id|path>`, so the value the caller typed is NOT usable as
61
+ * an id: `--resume ./sessions/hand-saved.json` would otherwise make the path
62
+ * itself the id, and every later `sessionPathFor` / lock call would build a
63
+ * filename out of it - outside the sessions directory whenever the path was
64
+ * relative. That is the same write primitive `--session-id` is validated to
65
+ * prevent, arriving through the flag next to it. `meta.id` is no safer: it comes
66
+ * out of a file, and files are inputs.
67
+ *
68
+ * The order is the one that keeps every legitimate resume landing where the user
69
+ * expects: an exec-written session names itself, a TUI `/save` is named by its
70
+ * file, and anything else gets a fresh id rather than a guess - reported back in
71
+ * `system.init.sessionId` and `result.sessionId`, so the caller is never left
72
+ * wondering which session it just wrote.
73
+ */
74
+ export declare function resumedSessionId(path: string, saved: SavedSession): string;
57
75
  /** Read one file, or `null` for anything unreadable. NEVER THROWS. */
58
76
  export declare function readSessionFile(path: string): SavedSession | null;
59
77
  /**
@@ -53,8 +53,58 @@ const POINTER_FILE = '.last.json';
53
53
  export function sessionsDir() {
54
54
  return getSessionsDir();
55
55
  }
56
+ /**
57
+ * THE CHOKE POINT. Every string that becomes a session filename passes here.
58
+ *
59
+ * `isValidSessionId` is applied HERE rather than only at `--session-id`, because
60
+ * that flag is not the only way a caller-supplied string reaches this function:
61
+ * `--resume <id|path>` derives an id from the resumed FILE, `sessions rm` /
62
+ * `sessions show` take one straight from argv, and a session file can carry any
63
+ * `meta.id` at all. Guarding each caller instead would mean the boundary holds
64
+ * only for the callers someone remembered - and `join()` silently normalises
65
+ * `../..` away, so an escape leaves no trace to notice later. Validating at the
66
+ * single point that builds the path is what makes R-7 a property of the module
67
+ * rather than a habit of its callers.
68
+ *
69
+ * THROWS rather than coercing to a "safe" name: a caller who asked for one file
70
+ * and silently got another is a worse failure than a loud one, and every caller
71
+ * that can be handed untrusted input validates first and reports exit 2 itself.
72
+ */
73
+ function assertSessionId(id) {
74
+ if (!isValidSessionId(id)) {
75
+ throw new Error(`Invalid session id "${id}": expected 1-64 characters matching ` +
76
+ '[A-Za-z0-9][A-Za-z0-9._-]* (no "/", "\\", ".." or a leading dot).');
77
+ }
78
+ return id;
79
+ }
56
80
  export function sessionPathFor(id) {
57
- return join(getSessionsDir(), `${id}.json`);
81
+ return join(getSessionsDir(), `${assertSessionId(id)}.json`);
82
+ }
83
+ /**
84
+ * The id a RESUMED session writes back under.
85
+ *
86
+ * `--resume` accepts `<id|path>`, so the value the caller typed is NOT usable as
87
+ * an id: `--resume ./sessions/hand-saved.json` would otherwise make the path
88
+ * itself the id, and every later `sessionPathFor` / lock call would build a
89
+ * filename out of it - outside the sessions directory whenever the path was
90
+ * relative. That is the same write primitive `--session-id` is validated to
91
+ * prevent, arriving through the flag next to it. `meta.id` is no safer: it comes
92
+ * out of a file, and files are inputs.
93
+ *
94
+ * The order is the one that keeps every legitimate resume landing where the user
95
+ * expects: an exec-written session names itself, a TUI `/save` is named by its
96
+ * file, and anything else gets a fresh id rather than a guess - reported back in
97
+ * `system.init.sessionId` and `result.sessionId`, so the caller is never left
98
+ * wondering which session it just wrote.
99
+ */
100
+ export function resumedSessionId(path, saved) {
101
+ const declared = saved.meta?.id;
102
+ if (typeof declared === 'string' && isValidSessionId(declared))
103
+ return declared;
104
+ const base = basenameId(path);
105
+ if (isValidSessionId(base))
106
+ return base;
107
+ return randomUUID();
58
108
  }
59
109
  function ensureDir() {
60
110
  const dir = getSessionsDir();
@@ -117,6 +167,11 @@ export function writeSession(input) {
117
167
  return path;
118
168
  }
119
169
  export function removeSession(id) {
170
+ // An id that cannot name a session in this directory has not "failed to be
171
+ // deleted" - there was never a file of that name to delete. `sessions rm`
172
+ // validates first and reports the reason, so this stays a boolean.
173
+ if (!isValidSessionId(id))
174
+ return false;
120
175
  const path = sessionPathFor(id);
121
176
  try {
122
177
  unlinkSync(path);
@@ -329,7 +384,9 @@ export function tryAcquireSessionLock(id, opts = {}) {
329
384
  const now = opts.now ?? Date.now;
330
385
  const isAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
331
386
  ensureDir();
332
- const path = join(getSessionsDir(), `${id}.lock`);
387
+ // Same boundary as `sessionPathFor` - a lock is a file in this directory too,
388
+ // and an id that may not name a session may not name its lock either.
389
+ const path = join(getSessionsDir(), `${assertSessionId(id)}.lock`);
333
390
  const payload = {
334
391
  pid: process.pid,
335
392
  host: hostname(),