@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 +40 -19
- package/dist/agent/agent-mode.d.ts +39 -0
- package/dist/agent/agent-mode.js +36 -0
- package/dist/cli.js +73 -1
- package/dist/config/ui-state.d.ts +16 -5
- package/dist/config/ui-state.js +12 -12
- package/dist/diagnostics/doctor.js +41 -1
- package/dist/exec/index.js +8 -4
- package/dist/exec/runner.js +2 -2
- package/dist/session/cli-commands.js +23 -1
- package/dist/session/store.d.ts +18 -0
- package/dist/session/store.js +59 -2
- package/dist/ui/App.js +26 -7
- package/dist/ui/Composer.d.ts +14 -1
- package/dist/ui/Composer.js +5 -5
- package/dist/ui/overlays/HelpOverlay.js +12 -1
- package/dist/ui/use-startup-notices.d.ts +35 -4
- package/dist/ui/use-startup-notices.js +39 -7
- package/dist/ui/win-vt-force.d.ts +136 -0
- package/dist/ui/win-vt-force.js +237 -0
- package/package.json +2 -2
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
**
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
/**
|
package/dist/agent/agent-mode.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
export declare function
|
|
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
|
package/dist/config/ui-state.js
CHANGED
|
@@ -35,13 +35,14 @@ const DEFAULT_UI_STATE = {
|
|
|
35
35
|
schema: UI_STATE_SCHEMA,
|
|
36
36
|
submitCount: 0,
|
|
37
37
|
mouseNoticeSeen: false,
|
|
38
|
-
|
|
38
|
+
vtInputNoticeVersion: 0,
|
|
39
39
|
};
|
|
40
40
|
let memory = null;
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
|
134
|
-
update({
|
|
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();
|
package/dist/exec/index.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
package/dist/exec/runner.js
CHANGED
|
@@ -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
|
-
//
|
|
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;
|
package/dist/session/store.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/session/store.js
CHANGED
|
@@ -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
|
-
|
|
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(),
|