@aragon-agent/cli 0.5.0 → 0.5.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/README.md +254 -7
  3. package/dist/agent/agent-mode.d.ts +53 -0
  4. package/dist/agent/agent-mode.js +69 -0
  5. package/dist/agent/controller.d.ts +69 -0
  6. package/dist/agent/controller.js +154 -1
  7. package/dist/agent/plan-prompt.d.ts +18 -0
  8. package/dist/agent/plan-prompt.js +93 -0
  9. package/dist/agent/reducer.d.ts +14 -1
  10. package/dist/agent/reducer.js +9 -0
  11. package/dist/agent/system-prompt.d.ts +15 -0
  12. package/dist/agent/system-prompt.js +8 -0
  13. package/dist/cli.d.ts +1 -1
  14. package/dist/cli.js +247 -23
  15. package/dist/commands/builtins.js +60 -0
  16. package/dist/commands/registry.d.ts +17 -0
  17. package/dist/config/app-paths.d.ts +71 -4
  18. package/dist/config/app-paths.js +139 -8
  19. package/dist/config/cli-commands.d.ts +43 -0
  20. package/dist/config/cli-commands.js +196 -0
  21. package/dist/config/env.d.ts +7 -1
  22. package/dist/config/env.js +53 -1
  23. package/dist/config/load.d.ts +10 -0
  24. package/dist/config/load.js +82 -2
  25. package/dist/config/migrate-home.d.ts +63 -0
  26. package/dist/config/migrate-home.js +165 -0
  27. package/dist/config/migrate-legacy-state.d.ts +10 -0
  28. package/dist/config/migrate-legacy-state.js +15 -6
  29. package/dist/config/schema.d.ts +81 -0
  30. package/dist/config/schema.js +85 -0
  31. package/dist/config/store.d.ts +30 -12
  32. package/dist/config/store.js +47 -35
  33. package/dist/input/mouse-events.d.ts +61 -0
  34. package/dist/input/mouse-events.js +153 -0
  35. package/dist/input/stdin-mouse-filter.d.ts +42 -0
  36. package/dist/input/stdin-mouse-filter.js +153 -0
  37. package/dist/logging/cli-commands.d.ts +25 -0
  38. package/dist/logging/cli-commands.js +221 -0
  39. package/dist/logging/file-sink.d.ts +114 -0
  40. package/dist/logging/file-sink.js +326 -0
  41. package/dist/logging/install.d.ts +163 -0
  42. package/dist/logging/install.js +302 -0
  43. package/dist/logging/levels.d.ts +40 -0
  44. package/dist/logging/levels.js +59 -0
  45. package/dist/logging/logger.d.ts +110 -0
  46. package/dist/logging/logger.js +237 -0
  47. package/dist/logging/redact.d.ts +28 -0
  48. package/dist/logging/redact.js +90 -0
  49. package/dist/logging/secret-registry.d.ts +30 -0
  50. package/dist/logging/secret-registry.js +55 -0
  51. package/dist/skills/paths.d.ts +2 -4
  52. package/dist/skills/paths.js +7 -9
  53. package/dist/tools/human-input.d.ts +179 -0
  54. package/dist/tools/human-input.js +227 -0
  55. package/dist/tools/index.d.ts +35 -3
  56. package/dist/tools/index.js +96 -9
  57. package/dist/tools/plan-tools.d.ts +64 -0
  58. package/dist/tools/plan-tools.js +198 -0
  59. package/dist/ui/App.d.ts +12 -1
  60. package/dist/ui/App.js +223 -17
  61. package/dist/ui/Composer.d.ts +9 -1
  62. package/dist/ui/Composer.js +26 -7
  63. package/dist/ui/ModeChip.d.ts +29 -0
  64. package/dist/ui/ModeChip.js +15 -0
  65. package/dist/ui/PromptInput.d.ts +22 -1
  66. package/dist/ui/PromptInput.js +49 -2
  67. package/dist/ui/StatusBar.d.ts +15 -0
  68. package/dist/ui/StatusBar.js +10 -2
  69. package/dist/ui/entries/ToolPreview.d.ts +2 -0
  70. package/dist/ui/entries/ToolPreview.js +64 -0
  71. package/dist/ui/glyphs.d.ts +21 -0
  72. package/dist/ui/glyphs.js +12 -0
  73. package/dist/ui/layout/AppShell.d.ts +10 -1
  74. package/dist/ui/layout/AppShell.js +38 -3
  75. package/dist/ui/layout/ScrollIndicator.d.ts +31 -0
  76. package/dist/ui/layout/ScrollIndicator.js +15 -0
  77. package/dist/ui/layout/ScrollViewport.d.ts +8 -2
  78. package/dist/ui/layout/ScrollViewport.js +24 -4
  79. package/dist/ui/layout/regions.d.ts +55 -0
  80. package/dist/ui/layout/regions.js +52 -0
  81. package/dist/ui/layout/scroll-indicator.d.ts +27 -0
  82. package/dist/ui/layout/scroll-indicator.js +35 -0
  83. package/dist/ui/layout/scroll.d.ts +9 -0
  84. package/dist/ui/layout/scroll.js +23 -0
  85. package/dist/ui/layout/wrap-rows.d.ts +24 -0
  86. package/dist/ui/layout/wrap-rows.js +74 -0
  87. package/dist/ui/overlays/HelpOverlay.js +15 -0
  88. package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
  89. package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
  90. package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
  91. package/dist/ui/overlays/QuestionOverlay.js +193 -0
  92. package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
  93. package/dist/ui/overlays/SettingsScreen.js +8 -1
  94. package/dist/ui/screen.d.ts +14 -1
  95. package/dist/ui/screen.js +37 -3
  96. package/dist/ui/use-startup-notices.d.ts +25 -0
  97. package/dist/ui/use-startup-notices.js +64 -0
  98. package/dist/ui/use-wheel-routing.d.ts +68 -0
  99. package/dist/ui/use-wheel-routing.js +139 -0
  100. package/package.json +3 -2
  101. package/runtime/insecure-tls-warning.cjs +62 -0
  102. package/runtime/insecure-tls-warning.d.cts +5 -0
@@ -1,51 +1,51 @@
1
1
  /**
2
- * Config store — read/atomic-write the user config JSON and resolve OS-specific
3
- * paths via `env-paths`. The file may hold secrets, so it is written with
4
- * `0600` permissions on POSIX (spec §3.5 / §6.1).
2
+ * Config store — read/atomic-write the user config JSON. Paths come from
3
+ * `app-paths.ts`; nothing here does its own path arithmetic. The file may hold
4
+ * secrets, so it is written with `0600` permissions on POSIX (spec §3.5 / §6.1).
5
+ *
6
+ * There is deliberately no `getConfigDir()` / `getSkillsDir()` here any more.
7
+ * The first would now return the entire user root under a name that says
8
+ * otherwise (see the test-isolation contract in `app-paths.ts`); the second was
9
+ * a second name for `skills/paths.ts::getUserSkillsDir()`, which is precisely
10
+ * the drift the header of that module warns about.
5
11
  */
6
12
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from 'node:fs';
7
- import { dirname, join } from 'node:path';
13
+ import { dirname } from 'node:path';
8
14
  import process from 'node:process';
9
- import { appPaths as paths } from './app-paths.js';
10
- import { CONFIG_VERSION, DEFAULT_CONFIG, clampSkillsConfig, } from './schema.js';
11
- // ---------------------------------------------------------------------------
12
- // Locations
13
- // ---------------------------------------------------------------------------
14
- export function getConfigDir() {
15
- return paths.config;
16
- }
17
- export function getConfigPath() {
18
- return join(paths.config, 'config.json');
19
- }
20
- export function getSessionsDir() {
21
- return join(paths.data, 'sessions');
22
- }
23
- /** User-scope skills root — `aragon skills install`'s default target. */
24
- export function getSkillsDir() {
25
- return join(paths.data, 'skills');
26
- }
27
- // ---------------------------------------------------------------------------
28
- // Read
29
- // ---------------------------------------------------------------------------
15
+ import { getConfigPath, getSessionsDir } from './app-paths.js';
16
+ import { registerSecretsFrom } from '../logging/secret-registry.js';
17
+ import { CONFIG_VERSION, DEFAULT_CONFIG, clampLogConfig, clampSkillsConfig, } from './schema.js';
18
+ // Re-exported so the many existing importers of `store.js` keep working; the
19
+ // definitions live in `app-paths.ts` because they are pure path arithmetic.
20
+ export { getConfigPath, getSessionsDir };
30
21
  /**
31
- * Read the persisted config file. Returns a partial config (only the keys the
32
- * user actually wrote) or `null` when the file is absent or unparseable.
22
+ * Read the persisted config file.
23
+ *
24
+ * A corrupt config file still must never crash the CLI — the fallback to
25
+ * defaults is unchanged. What changed is that it is no longer SILENT: a single
26
+ * stray comma used to reset the model, theme, timeouts, skills and (as far as
27
+ * the user could tell) the API key, with nothing anywhere saying why. Now the
28
+ * failure travels out of band in `parseError`, and `loadConfig()` turns it into
29
+ * a log record plus one visible line. Hand-editing this file is a supported
30
+ * workflow, so the failure mode had to become visible.
33
31
  */
34
32
  export function readConfigFile() {
35
33
  const file = getConfigPath();
36
34
  if (!existsSync(file))
37
- return null;
35
+ return { config: null };
38
36
  try {
39
37
  const raw = readFileSync(file, 'utf-8');
40
38
  const parsed = JSON.parse(raw);
41
39
  if (parsed && typeof parsed === 'object') {
42
- return parsed;
40
+ return { config: parsed };
43
41
  }
44
- return null;
42
+ return { config: null, parseError: `${file} does not contain a JSON object` };
45
43
  }
46
- catch {
47
- // A corrupt config file must never crash the CLI — fall back to defaults.
48
- return null;
44
+ catch (err) {
45
+ return {
46
+ config: null,
47
+ parseError: `${file}: ${err instanceof Error ? err.message : String(err)}`,
48
+ };
49
49
  }
50
50
  }
51
51
  // ---------------------------------------------------------------------------
@@ -82,7 +82,7 @@ export function writeConfigFile(config) {
82
82
  * Unlike `readConfigFile`, this always returns a complete `PersistedConfig`.
83
83
  */
84
84
  export function loadPersistedConfig() {
85
- const partial = readConfigFile() ?? {};
85
+ const partial = readConfigFile().config ?? {};
86
86
  return {
87
87
  ...DEFAULT_CONFIG,
88
88
  ...partial,
@@ -93,6 +93,8 @@ export function loadPersistedConfig() {
93
93
  // `skills` is the second nested object in this file and needs the same
94
94
  // treatment `apiKeys` gets — see the note on updatePersistedConfig below.
95
95
  skills: clampSkillsConfig({ ...DEFAULT_CONFIG.skills, ...(partial.skills ?? {}) }),
96
+ // `log` is the third. Same one-level rule, same merge.
97
+ log: clampLogConfig({ ...DEFAULT_CONFIG.log, ...(partial.log ?? {}) }),
96
98
  };
97
99
  }
98
100
  /**
@@ -110,9 +112,15 @@ export function loadPersistedConfig() {
110
112
  *
111
113
  * `SkillsConfig` is therefore capped at ONE level of nesting (scalars and
112
114
  * string arrays only). A third level requires replacing this hand-written merge
113
- * with a real deep-merge utility first, or the same bug returns.
115
+ * with a real deep-merge utility first, or the same bug returns. `LogConfig` is
116
+ * a third SECTION, but it is still one level deep (scalars only), so the same
117
+ * hand-written merge covers it — and it must stay that way for that reason.
114
118
  */
115
119
  export function updatePersistedConfig(patch) {
120
+ // Registration site 5. Whatever route a key took to get here, this is the one
121
+ // function that writes it to disk, so this is the last place it can be added
122
+ // to the redactor's backstop before it also starts appearing in log records.
123
+ registerSecretsFrom(patch.apiKeys);
116
124
  const current = loadPersistedConfig();
117
125
  const merged = {
118
126
  ...current,
@@ -120,6 +128,10 @@ export function updatePersistedConfig(patch) {
120
128
  version: CONFIG_VERSION,
121
129
  apiKeys: { ...current.apiKeys, ...(patch.apiKeys ?? {}) },
122
130
  skills: clampSkillsConfig({ ...current.skills, ...(patch.skills ?? {}) }),
131
+ // Without this line, changing the log level from the settings screen would
132
+ // replace the whole section and quietly revert `maxFiles`, `previewChars`
133
+ // and — worst of all — `redactSecrets`.
134
+ log: clampLogConfig({ ...current.log, ...(patch.log ?? {}) }),
123
135
  };
124
136
  writeConfigFile(merged);
125
137
  return merged;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Mouse-report parser (mouse-wheel-region-routing §5.1) — pure, stream-free.
3
+ *
4
+ * The alternate screen has no native scrollback, so every modern terminal turns
5
+ * on DEC private mode 1007 (*alternate scroll*) and translates each wheel notch
6
+ * into a burst of arrow keys. Arrow keys carry NO POINTER POSITION, which is
7
+ * why region routing is impossible from that channel and why the CLI asks for
8
+ * real SGR mouse reporting instead (`\x1b[?1000h\x1b[?1006h`).
9
+ *
10
+ * Asking for it is only half the job. Ink 5.2.1 has no mouse branch at all:
11
+ * `parse-keypress.js` fails to match `\x1b[<64;40;12M`, `use-input.js` strips
12
+ * the leading ESC, and `PromptInput`'s `isControlSeq` guard passes the
13
+ * remainder (`[` is 0x5B, >= 0x20) straight into `insert()`. So an unfiltered
14
+ * mouse report is not ignored — it is TYPED INTO THE USER'S MESSAGE. This
15
+ * module exists so `stdin-mouse-filter.ts` can remove every byte of every
16
+ * report before Ink ever sees the chunk (invariant I-1).
17
+ *
18
+ * Non-wheel reports (press / release / drag) are recognized and DISCARDED
19
+ * rather than passed through, for the same reason.
20
+ */
21
+ export interface WheelEvent {
22
+ readonly kind: 'wheel';
23
+ readonly dir: 'up' | 'down';
24
+ /** 1-based terminal column, as reported. */
25
+ readonly x: number;
26
+ /** 1-based terminal row, as reported. */
27
+ readonly y: number;
28
+ readonly shift: boolean;
29
+ readonly alt: boolean;
30
+ readonly ctrl: boolean;
31
+ }
32
+ export interface MouseSplit {
33
+ /** Wheel events, in arrival order. Non-wheel reports are dropped, not returned. */
34
+ readonly events: WheelEvent[];
35
+ /** Everything that was not a mouse report — forward this to Ink verbatim. */
36
+ readonly text: string;
37
+ /** Trailing strict prefix of a possible report; feed it back in with the next chunk. */
38
+ readonly pending: string;
39
+ }
40
+ /**
41
+ * Ceiling on the held-back tail. A malformed sequence that looks like a prefix
42
+ * forever must never be able to wedge input: past this length the tail is
43
+ * flushed as ordinary text and parsing resumes from the next byte.
44
+ */
45
+ export declare const MAX_PENDING_MOUSE_CHARS = 32;
46
+ /**
47
+ * True when `s` is a PROPER prefix of a possible mouse report (never a full
48
+ * one). Nothing a keyboard produces matches beyond `\x1b` and `\x1b[`, which is
49
+ * why the filter's flush timer is a rare path rather than a latency tax on
50
+ * typing: ordinary text and pasted blocks contain no `\x1b[<`.
51
+ */
52
+ export declare function isMousePrefix(s: string): boolean;
53
+ /**
54
+ * Split a decoded stdin chunk into wheel events, passthrough text, and a
55
+ * trailing prefix to carry into the next chunk.
56
+ *
57
+ * The caller is expected to feed `pending + nextChunk` back in; ConPTY does
58
+ * split writes mid-sequence, and a report torn across two chunks would
59
+ * otherwise leak its tail into the composer (R-6).
60
+ */
61
+ export declare function splitMouseEvents(buffer: string): MouseSplit;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Mouse-report parser (mouse-wheel-region-routing §5.1) — pure, stream-free.
3
+ *
4
+ * The alternate screen has no native scrollback, so every modern terminal turns
5
+ * on DEC private mode 1007 (*alternate scroll*) and translates each wheel notch
6
+ * into a burst of arrow keys. Arrow keys carry NO POINTER POSITION, which is
7
+ * why region routing is impossible from that channel and why the CLI asks for
8
+ * real SGR mouse reporting instead (`\x1b[?1000h\x1b[?1006h`).
9
+ *
10
+ * Asking for it is only half the job. Ink 5.2.1 has no mouse branch at all:
11
+ * `parse-keypress.js` fails to match `\x1b[<64;40;12M`, `use-input.js` strips
12
+ * the leading ESC, and `PromptInput`'s `isControlSeq` guard passes the
13
+ * remainder (`[` is 0x5B, >= 0x20) straight into `insert()`. So an unfiltered
14
+ * mouse report is not ignored — it is TYPED INTO THE USER'S MESSAGE. This
15
+ * module exists so `stdin-mouse-filter.ts` can remove every byte of every
16
+ * report before Ink ever sees the chunk (invariant I-1).
17
+ *
18
+ * Non-wheel reports (press / release / drag) are recognized and DISCARDED
19
+ * rather than passed through, for the same reason.
20
+ */
21
+ /**
22
+ * Ceiling on the held-back tail. A malformed sequence that looks like a prefix
23
+ * forever must never be able to wedge input: past this length the tail is
24
+ * flushed as ordinary text and parsing resumes from the next byte.
25
+ */
26
+ export const MAX_PENDING_MOUSE_CHARS = 32;
27
+ const ESC = '\x1b';
28
+ /** `\x1b[<b;x;yM` (press) or `…m` (release) — the only form we request. */
29
+ const SGR_REPORT = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/;
30
+ /** A strict prefix of an SGR report: `\x1b[<` plus digits and semicolons. */
31
+ const SGR_PREFIX = /^\x1b\[<[\d;]*$/;
32
+ /**
33
+ * True when `s` is a PROPER prefix of a possible mouse report (never a full
34
+ * one). Nothing a keyboard produces matches beyond `\x1b` and `\x1b[`, which is
35
+ * why the filter's flush timer is a rare path rather than a latency tax on
36
+ * typing: ordinary text and pasted blocks contain no `\x1b[<`.
37
+ */
38
+ export function isMousePrefix(s) {
39
+ return /^\x1B(\[(<[\d;]*|M[\s\S]{0,2})?)?$/.test(s);
40
+ }
41
+ /**
42
+ * Decode an SGR button byte into a wheel event, or `null` when the report is
43
+ * not a wheel notch we act on.
44
+ *
45
+ * THE ORDER OF THESE TESTS IS THE SPECIFICATION, not a presentation choice.
46
+ * Button 66 (wheel-left) has `b & 1 === 0` and would decode as `'up'`, so the
47
+ * horizontal test has to precede the direction decode.
48
+ */
49
+ function decodeWheel(b, x, y, final) {
50
+ // 1. Not a wheel report at all (press / release / drag) — consume, emit nothing.
51
+ if ((b & 64) === 0)
52
+ return null;
53
+ // 2. Horizontal wheel (66 = left, 67 = right). Consumed and dropped (non-goal).
54
+ if ((b & 3) >= 2)
55
+ return null;
56
+ // 3. Wheel buttons are press-only; a terminal that ever sent a release for one
57
+ // must not produce a second step for the same physical notch.
58
+ if (final !== 'M')
59
+ return null;
60
+ return {
61
+ kind: 'wheel',
62
+ dir: (b & 1) === 1 ? 'down' : 'up',
63
+ x,
64
+ y,
65
+ shift: (b & 4) !== 0,
66
+ alt: (b & 8) !== 0,
67
+ ctrl: (b & 16) !== 0,
68
+ };
69
+ }
70
+ /**
71
+ * X10 legacy reports (`\x1b[M` + three chars, each `code - 32`).
72
+ *
73
+ * We never request this encoding — `?1006h` is over a decade old and every
74
+ * terminal that implements `?1000` implements it — but a terminal that ignored
75
+ * the SGR request would fall back to it, and a leaked `\x1b[M` + garbage is
76
+ * exactly the draft corruption I-1 exists to prevent. So it is consumed
77
+ * unconditionally; the coordinates are only trusted when all three bytes are
78
+ * plausible, because `setEncoding('utf8')` mangles raw bytes above 0x7F.
79
+ */
80
+ function matchX10(s) {
81
+ if (s.length < 6)
82
+ return { kind: 'partial' };
83
+ const codes = [s.charCodeAt(3), s.charCodeAt(4), s.charCodeAt(5)];
84
+ if (codes.some((c) => c < 32 || c > 255))
85
+ return { kind: 'complete', length: 6, event: null };
86
+ const [b, x, y] = codes;
87
+ return { kind: 'complete', length: 6, event: decodeWheel(b - 32, x - 32, y - 32, 'M') };
88
+ }
89
+ /** Classify the escape sequence starting at `s[0] === ESC`. */
90
+ function matchReport(s) {
91
+ if (s.length === 1)
92
+ return { kind: 'partial' }; // lone ESC so far
93
+ if (s[1] !== '[')
94
+ return { kind: 'none' };
95
+ if (s.length === 2)
96
+ return { kind: 'partial' }; // `\x1b[`
97
+ if (s[2] === '<') {
98
+ const m = SGR_REPORT.exec(s);
99
+ if (m) {
100
+ const b = Number(m[1]);
101
+ const x = Number(m[2]);
102
+ const y = Number(m[3]);
103
+ return { kind: 'complete', length: m[0].length, event: decodeWheel(b, x, y, m[4]) };
104
+ }
105
+ return SGR_PREFIX.test(s) ? { kind: 'partial' } : { kind: 'none' };
106
+ }
107
+ if (s[2] === 'M')
108
+ return matchX10(s);
109
+ return { kind: 'none' };
110
+ }
111
+ /**
112
+ * Split a decoded stdin chunk into wheel events, passthrough text, and a
113
+ * trailing prefix to carry into the next chunk.
114
+ *
115
+ * The caller is expected to feed `pending + nextChunk` back in; ConPTY does
116
+ * split writes mid-sequence, and a report torn across two chunks would
117
+ * otherwise leak its tail into the composer (R-6).
118
+ */
119
+ export function splitMouseEvents(buffer) {
120
+ const events = [];
121
+ let text = '';
122
+ let i = 0;
123
+ while (i < buffer.length) {
124
+ const esc = buffer.indexOf(ESC, i);
125
+ if (esc === -1) {
126
+ text += buffer.slice(i);
127
+ return { events, text, pending: '' };
128
+ }
129
+ text += buffer.slice(i, esc);
130
+ const rest = buffer.slice(esc);
131
+ const m = matchReport(rest);
132
+ if (m.kind === 'complete') {
133
+ if (m.event)
134
+ events.push(m.event);
135
+ i = esc + m.length;
136
+ continue;
137
+ }
138
+ if (m.kind === 'partial') {
139
+ // A tail that never resolves must not hold input hostage: past the
140
+ // ceiling it is ordinary text, and parsing continues after the ESC.
141
+ if (rest.length > MAX_PENDING_MOUSE_CHARS) {
142
+ text += rest;
143
+ return { events, text, pending: '' };
144
+ }
145
+ return { events, text, pending: rest };
146
+ }
147
+ // Not a mouse report — an arrow key, a paste, anything. Emit the ESC and
148
+ // resume scanning after it so the rest of the sequence reaches Ink intact.
149
+ text += ESC;
150
+ i = esc + 1;
151
+ }
152
+ return { events, text, pending: '' };
153
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Stdin mouse filter (mouse-wheel-region-routing §4.2 / §5.2).
3
+ *
4
+ * WHY A WRAPPER AND NOT A SECOND LISTENER. Ink 5.2.1 owns stdin in a way that
5
+ * forbids a second reader: `components/App.js` calls `setEncoding('utf8')`,
6
+ * `ref()`, `setRawMode(true)`, subscribes to `'readable'` and drains with
7
+ * `read()`. Attaching our own `'data'` listener to the same `process.stdin`
8
+ * would switch the stream into flowing mode and RACE Ink's `read()` for chunks
9
+ * — some keystrokes would reach Ink, some would not, non-deterministically
10
+ * (D-3). So the filter REPLACES the stream Ink is given: it reads the real
11
+ * stdin, strips every mouse report, and writes the remainder into a
12
+ * `PassThrough` that carries the surface Ink requires.
13
+ *
14
+ * ORDERING IS AN INVARIANT, NOT A CONVENTION (I-8). The caller must build this
15
+ * filter BEFORE anything writes `\x1b[?1000h`. Reporting enabled while Ink
16
+ * still holds the raw stream does not degrade the feature — it types
17
+ * `[<0;12;5M` into the user's message on every click, which is strictly worse
18
+ * than the bug this feature fixes. `cli.tsx::runInteractive` is the only caller
19
+ * and passes `filter !== null` to `enterAltScreen`.
20
+ */
21
+ import { type WheelEvent } from './mouse-events.js';
22
+ export interface MouseSource {
23
+ /** Returns an unsubscribe function. Multiple subscribers are allowed. */
24
+ subscribe(listener: (event: WheelEvent) => void): () => void;
25
+ }
26
+ export interface MouseFilter {
27
+ /** Hand this to Ink: `render(<App/>, { stdin: filter.stdin })`. */
28
+ readonly stdin: NodeJS.ReadStream;
29
+ readonly source: MouseSource;
30
+ /** Idempotent. Detaches from the real stdin and clears the flush timer (I-7). */
31
+ dispose(): void;
32
+ }
33
+ export declare function createMouseFilter(real: NodeJS.ReadStream): MouseFilter;
34
+ /**
35
+ * Build a filter, or `null` if construction throws.
36
+ *
37
+ * The `null` return is the whole point (P0-1): `enterAltScreen` derives whether
38
+ * to enable mouse reporting from `filter !== null`, so a construction failure
39
+ * degrades to "the wheel is inert" — a supported, tested rung of the fail-safe
40
+ * ladder — rather than leaving reporting on with no filter in front of Ink.
41
+ */
42
+ export declare function tryCreateMouseFilter(real: NodeJS.ReadStream, onError?: (reason: string) => void): MouseFilter | null;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Stdin mouse filter (mouse-wheel-region-routing §4.2 / §5.2).
3
+ *
4
+ * WHY A WRAPPER AND NOT A SECOND LISTENER. Ink 5.2.1 owns stdin in a way that
5
+ * forbids a second reader: `components/App.js` calls `setEncoding('utf8')`,
6
+ * `ref()`, `setRawMode(true)`, subscribes to `'readable'` and drains with
7
+ * `read()`. Attaching our own `'data'` listener to the same `process.stdin`
8
+ * would switch the stream into flowing mode and RACE Ink's `read()` for chunks
9
+ * — some keystrokes would reach Ink, some would not, non-deterministically
10
+ * (D-3). So the filter REPLACES the stream Ink is given: it reads the real
11
+ * stdin, strips every mouse report, and writes the remainder into a
12
+ * `PassThrough` that carries the surface Ink requires.
13
+ *
14
+ * ORDERING IS AN INVARIANT, NOT A CONVENTION (I-8). The caller must build this
15
+ * filter BEFORE anything writes `\x1b[?1000h`. Reporting enabled while Ink
16
+ * still holds the raw stream does not degrade the feature — it types
17
+ * `[<0;12;5M` into the user's message on every click, which is strictly worse
18
+ * than the bug this feature fixes. `cli.tsx::runInteractive` is the only caller
19
+ * and passes `filter !== null` to `enterAltScreen`.
20
+ */
21
+ import { PassThrough } from 'node:stream';
22
+ import { splitMouseEvents } from './mouse-events.js';
23
+ /**
24
+ * How long a possible-prefix tail is held before being flushed as text.
25
+ *
26
+ * This is what stops a lone `\x1b` — the Esc key, which aborts a run — from
27
+ * being held hostage waiting for a `[` that will never arrive. Worst-case Esc
28
+ * latency becomes 12 ms, and only for the keystroke that is literally a bare
29
+ * ESC; every other key resolves `isMousePrefix` to false immediately.
30
+ */
31
+ const PENDING_FLUSH_MS = 12;
32
+ /** Assign a method onto the PassThrough without widening its public type. */
33
+ function define(target, name, value) {
34
+ Object.defineProperty(target, name, { value, writable: true, configurable: true });
35
+ }
36
+ export function createMouseFilter(real) {
37
+ const wrapper = new PassThrough();
38
+ const listeners = new Set();
39
+ let pending = '';
40
+ let flushTimer = null;
41
+ let disposed = false;
42
+ // --- The surface Ink reads off the stream it is handed. ------------------
43
+ // `isRawModeSupported()` reads `props.stdin.isTTY` (App.js:34), so this has
44
+ // to be a DELEGATING GETTER rather than a snapshot: a stdin that is not a TTY
45
+ // must still report so through the wrapper, or Ink throws instead of falling
46
+ // back.
47
+ Object.defineProperty(wrapper, 'isTTY', {
48
+ get: () => real.isTTY,
49
+ configurable: true,
50
+ });
51
+ define(wrapper, 'setRawMode', (mode) => {
52
+ real.setRawMode?.(mode);
53
+ return wrapper;
54
+ });
55
+ define(wrapper, 'ref', () => {
56
+ real.ref?.();
57
+ return wrapper;
58
+ });
59
+ define(wrapper, 'unref', () => {
60
+ real.unref?.();
61
+ return wrapper;
62
+ });
63
+ const emit = (event) => {
64
+ for (const listener of [...listeners])
65
+ listener(event);
66
+ };
67
+ const clearFlushTimer = () => {
68
+ if (!flushTimer)
69
+ return;
70
+ clearTimeout(flushTimer);
71
+ flushTimer = null;
72
+ };
73
+ const flushPending = () => {
74
+ clearFlushTimer();
75
+ if (pending.length === 0 || disposed)
76
+ return;
77
+ const text = pending;
78
+ pending = '';
79
+ wrapper.write(text);
80
+ };
81
+ const onData = (chunk) => {
82
+ if (disposed)
83
+ return;
84
+ clearFlushTimer();
85
+ const decoded = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
86
+ const split = splitMouseEvents(pending + decoded);
87
+ pending = split.pending;
88
+ if (split.text.length > 0)
89
+ wrapper.write(split.text);
90
+ for (const event of split.events)
91
+ emit(event);
92
+ if (pending.length > 0) {
93
+ // `.unref()` so a held tail can never keep the process alive on its own.
94
+ flushTimer = setTimeout(flushPending, PENDING_FLUSH_MS);
95
+ flushTimer.unref?.();
96
+ }
97
+ };
98
+ // P2-7: a closed stdin must still terminate Ink's reader, or it waits forever
99
+ // on a stream that will never produce another byte.
100
+ const onEnd = () => {
101
+ flushPending();
102
+ if (!wrapper.writableEnded)
103
+ wrapper.end();
104
+ };
105
+ // `setEncoding('utf8')` on the REAL stream so Node's StringDecoder handles
106
+ // multi-byte boundaries for us — the parser works on characters, and a UTF-8
107
+ // sequence split across two chunks would otherwise become two mojibake
108
+ // characters before it ever reached `splitMouseEvents`.
109
+ real.setEncoding?.('utf8');
110
+ real.on('data', onData);
111
+ real.once('end', onEnd);
112
+ real.once('close', onEnd);
113
+ return {
114
+ stdin: wrapper,
115
+ source: {
116
+ subscribe(listener) {
117
+ listeners.add(listener);
118
+ return () => listeners.delete(listener);
119
+ },
120
+ },
121
+ dispose() {
122
+ if (disposed)
123
+ return;
124
+ disposed = true;
125
+ clearFlushTimer();
126
+ real.removeListener('data', onData);
127
+ real.removeListener('end', onEnd);
128
+ real.removeListener('close', onEnd);
129
+ listeners.clear();
130
+ // I-7: a live `'data'` listener on stdin keeps the event loop alive, and
131
+ // `aragon` would hang on exit instead of returning to the shell.
132
+ real.pause?.();
133
+ real.unref?.();
134
+ },
135
+ };
136
+ }
137
+ /**
138
+ * Build a filter, or `null` if construction throws.
139
+ *
140
+ * The `null` return is the whole point (P0-1): `enterAltScreen` derives whether
141
+ * to enable mouse reporting from `filter !== null`, so a construction failure
142
+ * degrades to "the wheel is inert" — a supported, tested rung of the fail-safe
143
+ * ladder — rather than leaving reporting on with no filter in front of Ink.
144
+ */
145
+ export function tryCreateMouseFilter(real, onError) {
146
+ try {
147
+ return createMouseFilter(real);
148
+ }
149
+ catch (err) {
150
+ onError?.(err instanceof Error ? err.message : String(err));
151
+ return null;
152
+ }
153
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `aragon logs path | list | tail | clear | open` — the non-interactive surface.
3
+ *
4
+ * Exit codes follow the existing convention: 0 success · 1 run-time failure ·
5
+ * 2 usage error.
6
+ *
7
+ * THE WHOLE GROUP IS EXEMPT from the "every subcommand records one info line"
8
+ * rule, and that is a correctness requirement rather than tidiness. Recording a
9
+ * line opens today's log file, and Node opens files without
10
+ * `FILE_SHARE_DELETE`; on Windows — the platform this feature was asked for —
11
+ * `aragon logs clear --yes` would then be unable to delete the very file its own
12
+ * announcement had just created. These commands are the log's operations
13
+ * surface; giving them observable side effects on the log is backwards.
14
+ */
15
+ import { type CliFlags } from '../config/load.js';
16
+ export interface LogsCliOptions {
17
+ /** `-n <N>` — how many trailing lines `tail` prints (default 100). */
18
+ lines?: string;
19
+ follow?: boolean;
20
+ level?: string;
21
+ json?: boolean;
22
+ /** Required by `clear`; without it the command refuses. */
23
+ yes?: boolean;
24
+ }
25
+ export declare function runLogsCommand(subcommand: string, opts: LogsCliOptions, flags: CliFlags): Promise<number>;