@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
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The logger: level filtering, record shape, redaction, and the module-level
3
+ * singleton every other file reaches through `getLogger()`.
4
+ *
5
+ * WIRE FORMAT — one `JSON.stringify(record)` per line, UTF-8, no BOM:
6
+ *
7
+ * {"ts":"2026-07-27T12:11:02.913+08:00","lv":"info","sid":"7f3a91c2",
8
+ * "pid":24188,"scope":"agent","msg":"turn_end",
9
+ * "data":{"provider":"anthropic","in":1284,"out":377,"ms":4210}}
10
+ *
11
+ * THE SHORT KEYS ARE DELIBERATE AND MUST NOT BE "FIXED". The package's clean-code
12
+ * guide forbids names like `data`, and rightly so for TypeScript identifiers.
13
+ * These are not identifiers: they are the on-disk contract, repeated on every
14
+ * line of a file that reaches megabytes, and already parsed by whatever `jq`
15
+ * one-liner a user has written. Renaming them breaks every existing log and
16
+ * every existing script for a readability gain nobody experiences. Variable and
17
+ * function names in this file get no such exemption.
18
+ *
19
+ * `sid` and `pid` both exist because several `aragon` processes append to the
20
+ * same day's file: `sid` separates their interleaved lines, and `pid` is what
21
+ * still matches up with what the OS recorded after a crash.
22
+ */
23
+ import { randomUUID } from 'node:crypto';
24
+ import { join } from 'node:path';
25
+ import process from 'node:process';
26
+ import { getLogsDir } from '../config/app-paths.js';
27
+ import { FileSink, currentLogFileName } from './file-sink.js';
28
+ import { isLevelEnabled } from './levels.js';
29
+ import { redactRecord } from './redact.js';
30
+ import { getSecrets } from './secret-registry.js';
31
+ const DISABLED_CONFIG = {
32
+ level: 'silent',
33
+ toFile: false,
34
+ dir: '',
35
+ maxFileBytes: 5 * 1024 * 1024,
36
+ maxFiles: 10,
37
+ redactSecrets: true,
38
+ previewChars: 512,
39
+ };
40
+ /**
41
+ * ISO 8601 with the LOCAL offset, matching the local date in the file name.
42
+ * `toISOString()` would put an evening record west of UTC into the next day
43
+ * while the file it lands in is named for the previous one.
44
+ */
45
+ export function localIsoTimestamp(date = new Date()) {
46
+ const pad = (n, width = 2) => String(n).padStart(width, '0');
47
+ const offsetMinutes = -date.getTimezoneOffset();
48
+ const sign = offsetMinutes >= 0 ? '+' : '-';
49
+ const abs = Math.abs(offsetMinutes);
50
+ return (`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
51
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
52
+ `.${pad(date.getMilliseconds(), 3)}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`);
53
+ }
54
+ export class Logger {
55
+ config;
56
+ sink = null;
57
+ sid = randomUUID().slice(0, 8);
58
+ failureHandler = null;
59
+ constructor(config = DISABLED_CONFIG) {
60
+ this.config = { ...config };
61
+ }
62
+ error(scope, msg, data) {
63
+ this.emit('error', scope, msg, data);
64
+ }
65
+ warn(scope, msg, data) {
66
+ this.emit('warn', scope, msg, data);
67
+ }
68
+ info(scope, msg, data) {
69
+ this.emit('info', scope, msg, data);
70
+ }
71
+ debug(scope, msg, data) {
72
+ this.emit('debug', scope, msg, data);
73
+ }
74
+ trace(scope, msg, data) {
75
+ this.emit('trace', scope, msg, data);
76
+ }
77
+ child(scope) {
78
+ return {
79
+ error: (msg, data) => this.error(scope, msg, data),
80
+ warn: (msg, data) => this.warn(scope, msg, data),
81
+ info: (msg, data) => this.info(scope, msg, data),
82
+ debug: (msg, data) => this.debug(scope, msg, data),
83
+ trace: (msg, data) => this.trace(scope, msg, data),
84
+ };
85
+ }
86
+ /** Whether a record at `level` would be written — lets callers skip the work. */
87
+ isEnabled(level) {
88
+ return isLevelEnabled(level, this.config.level);
89
+ }
90
+ /** Truncation budget for user content at `debug`. `trace` ignores it. */
91
+ get previewChars() {
92
+ return this.config.previewChars;
93
+ }
94
+ get level() {
95
+ return this.config.level;
96
+ }
97
+ setLevel(level) {
98
+ this.config = { ...this.config, level };
99
+ }
100
+ /**
101
+ * THE authoritative-level hand-off (§4.4.5).
102
+ *
103
+ * `installLogging()` resolves a bootstrap level from `process.env`, the config
104
+ * file and a tiny argv scan, because it has to run before commander exists and
105
+ * before `loadDotenv()`. That bootstrap level covers migrations and early
106
+ * crashes and nothing else. Once `loadConfig()` has produced the real answer,
107
+ * every path calls this exactly once. Without it the fully-resolved level —
108
+ * including anything from `.env` — would simply never take effect.
109
+ *
110
+ * Idempotent, and safe after writing has already begun.
111
+ */
112
+ reconfigure(config) {
113
+ this.config = { ...config };
114
+ if (!this.sink)
115
+ return;
116
+ if (!config.toFile) {
117
+ this.sink.close();
118
+ this.sink = null;
119
+ return;
120
+ }
121
+ // `FileSink.reconfigure` handles a changed directory itself: it flushes and
122
+ // closes, and the next record lazily opens the new one.
123
+ this.sink.reconfigure(this.sinkOptions());
124
+ }
125
+ /** Registered by whoever owns a user-visible channel (toast, or stderr). */
126
+ onFailure(cb) {
127
+ this.failureHandler = cb;
128
+ }
129
+ /** Idempotent; `exit`, the signal hook and the crash hooks all call it. */
130
+ flushSync() {
131
+ this.sink?.flushSync();
132
+ }
133
+ /**
134
+ * Where records are going, for `aragon logs path`.
135
+ *
136
+ * Answers with a FILE path even before the sink has been created — the
137
+ * question is "where would I look?", and a bare directory is not that answer.
138
+ */
139
+ currentLogPath() {
140
+ if (this.sink)
141
+ return this.sink.currentPath;
142
+ return join(this.resolveDir(this.config), currentLogFileName());
143
+ }
144
+ get sinkError() {
145
+ return this.sink?.lastError;
146
+ }
147
+ get sinkDisabled() {
148
+ return this.sink?.disabled ?? false;
149
+ }
150
+ /** Closes the fd so a caller may delete the files (`aragon logs clear`). */
151
+ closeSink() {
152
+ this.sink?.close();
153
+ this.sink = null;
154
+ }
155
+ // -------------------------------------------------------------------------
156
+ // Internals
157
+ // -------------------------------------------------------------------------
158
+ emit(level, scope, msg, data) {
159
+ if (!isLevelEnabled(level, this.config.level))
160
+ return;
161
+ if (!this.config.toFile)
162
+ return;
163
+ const record = {
164
+ ts: localIsoTimestamp(),
165
+ lv: level,
166
+ sid: this.sid,
167
+ pid: process.pid,
168
+ scope,
169
+ msg,
170
+ // An empty `data` is omitted rather than written as `{}` — noise on every
171
+ // line of a file measured in megabytes.
172
+ ...(data && Object.keys(data).length > 0 ? { data } : {}),
173
+ };
174
+ const safe = this.config.redactSecrets ? redactRecord(record, getSecrets()) : record;
175
+ this.ensureSink()?.write(`${JSON.stringify(safe)}\n`);
176
+ }
177
+ resolveDir(config) {
178
+ return config.dir.trim().length > 0 ? config.dir : getLogsDir();
179
+ }
180
+ sinkOptions() {
181
+ return {
182
+ dir: this.resolveDir(this.config),
183
+ maxFileBytes: this.config.maxFileBytes,
184
+ maxFiles: this.config.maxFiles,
185
+ formatDropNotice: (count) => this.renderDropNotice(count),
186
+ onFailure: (reason) => this.failureHandler?.(reason),
187
+ };
188
+ }
189
+ /**
190
+ * Created on the FIRST record that actually passes the filters, never in
191
+ * `installLogging()`. `aragon --version`, `aragon config path` and anyone
192
+ * running with `--no-log-file` must not leave an empty directory behind.
193
+ */
194
+ ensureSink() {
195
+ if (!this.config.toFile)
196
+ return null;
197
+ if (!this.sink)
198
+ this.sink = new FileSink(this.sinkOptions());
199
+ return this.sink;
200
+ }
201
+ renderDropNotice(count) {
202
+ const record = {
203
+ ts: localIsoTimestamp(),
204
+ lv: 'warn',
205
+ sid: this.sid,
206
+ pid: process.pid,
207
+ scope: 'log',
208
+ msg: 'records_dropped',
209
+ data: { count },
210
+ };
211
+ return `${JSON.stringify(record)}\n`;
212
+ }
213
+ }
214
+ // ---------------------------------------------------------------------------
215
+ // Module singleton
216
+ // ---------------------------------------------------------------------------
217
+ /**
218
+ * The pre-install logger: silent, file-less, allocates nothing.
219
+ *
220
+ * Its existence is what lets any module call `getLogger().info(...)`
221
+ * unconditionally — no null checks scattered across the codebase, and no import
222
+ * order that can accidentally create a log directory.
223
+ */
224
+ const NOOP_LOGGER = new Logger(DISABLED_CONFIG);
225
+ let active = null;
226
+ export function getLogger() {
227
+ return active ?? NOOP_LOGGER;
228
+ }
229
+ /** Called by `installLogging()`; not part of the general API. */
230
+ export function setActiveLogger(logger) {
231
+ active = logger;
232
+ }
233
+ /** Tests only — returns the singleton to its pre-install state. */
234
+ export function resetLoggerForTest() {
235
+ active?.closeSink();
236
+ active = null;
237
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Secret redaction — two passes, both mandatory.
3
+ *
4
+ * 1. STRUCTURAL. Walk `data` and blank any value whose KEY looks like a
5
+ * credential, whatever the value contains.
6
+ * 2. TEXTUAL. Scan `msg` and every string leaf for known key formats and for
7
+ * every literal in the secret registry.
8
+ *
9
+ * Neither pass subsumes the other. Structure alone misses a key pasted into a
10
+ * URL or echoed back inside an error message. Text alone misses a
11
+ * custom-endpoint key that matches none of the vendor formats — which is
12
+ * exactly why the registry backstop exists, and exactly why it must be live
13
+ * rather than a startup snapshot (see `secret-registry.ts`).
14
+ *
15
+ * This module is pure: same input, same output, no I/O, no clock. It is the one
16
+ * part of the logging subsystem whose correctness can be established outright,
17
+ * and it guards the one failure that cannot be undone once it reaches disk.
18
+ */
19
+ import type { LogRecord } from './logger.js';
20
+ export declare const REDACTED = "\u00ABredacted\u00BB";
21
+ export declare const TRUNCATED = "\u00ABtruncated\u00BB";
22
+ export declare function redactText(text: string, secrets: readonly string[]): string;
23
+ /**
24
+ * Redact a record in place of serialising it raw. Returns a new record; the
25
+ * caller's object is untouched so a failed write cannot leave a half-redacted
26
+ * value behind for a retry to publish.
27
+ */
28
+ export declare function redactRecord(record: LogRecord, secrets: readonly string[]): LogRecord;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Secret redaction — two passes, both mandatory.
3
+ *
4
+ * 1. STRUCTURAL. Walk `data` and blank any value whose KEY looks like a
5
+ * credential, whatever the value contains.
6
+ * 2. TEXTUAL. Scan `msg` and every string leaf for known key formats and for
7
+ * every literal in the secret registry.
8
+ *
9
+ * Neither pass subsumes the other. Structure alone misses a key pasted into a
10
+ * URL or echoed back inside an error message. Text alone misses a
11
+ * custom-endpoint key that matches none of the vendor formats — which is
12
+ * exactly why the registry backstop exists, and exactly why it must be live
13
+ * rather than a startup snapshot (see `secret-registry.ts`).
14
+ *
15
+ * This module is pure: same input, same output, no I/O, no clock. It is the one
16
+ * part of the logging subsystem whose correctness can be established outright,
17
+ * and it guards the one failure that cannot be undone once it reaches disk.
18
+ */
19
+ export const REDACTED = '«redacted»';
20
+ export const TRUNCATED = '«truncated»';
21
+ /** Value keys that are credentials regardless of what they hold. */
22
+ const SECRET_KEY_PATTERN = /(api[-_]?key|token|secret|password|authorization|credential)/i;
23
+ /**
24
+ * Known key shapes. Anthropic, OpenAI (and compatible endpoints), Google, plus
25
+ * any `Authorization: Bearer …` header value that survived pass 1.
26
+ */
27
+ const SECRET_VALUE_PATTERNS = [
28
+ /sk-ant-[A-Za-z0-9_-]{16,}/g,
29
+ /sk-[A-Za-z0-9_-]{20,}/g,
30
+ /AIza[A-Za-z0-9_-]{28,}/g,
31
+ /Bearer\s+[A-Za-z0-9._~+/-]{16,}=*/g,
32
+ ];
33
+ /**
34
+ * Bounds on the structural walk. A tool result can be an arbitrarily deep and
35
+ * wide object; without a ceiling, redacting one log record could stall the CLI.
36
+ * Exceeding either bound yields `«truncated»`, never the raw value.
37
+ */
38
+ const MAX_DEPTH = 6;
39
+ const MAX_NODES = 500;
40
+ export function redactText(text, secrets) {
41
+ let out = text;
42
+ for (const pattern of SECRET_VALUE_PATTERNS) {
43
+ out = out.replace(pattern, REDACTED);
44
+ }
45
+ // `getSecrets()` returns longest-first, so a key that is a prefix of another
46
+ // cannot leave its tail behind. Split/join rather than a built regex: the
47
+ // literals are arbitrary and escaping them is one more thing to get wrong.
48
+ for (const secret of secrets) {
49
+ if (secret.length > 0 && out.includes(secret))
50
+ out = out.split(secret).join(REDACTED);
51
+ }
52
+ return out;
53
+ }
54
+ function redactValue(value, secrets, depth, budget) {
55
+ if (depth > MAX_DEPTH)
56
+ return TRUNCATED;
57
+ if (budget.nodes <= 0)
58
+ return TRUNCATED;
59
+ budget.nodes -= 1;
60
+ if (typeof value === 'string')
61
+ return redactText(value, secrets);
62
+ if (value === null || typeof value !== 'object')
63
+ return value;
64
+ if (Array.isArray(value)) {
65
+ return value.map((item) => redactValue(item, secrets, depth + 1, budget));
66
+ }
67
+ const out = {};
68
+ for (const [key, item] of Object.entries(value)) {
69
+ out[key] = SECRET_KEY_PATTERN.test(key)
70
+ ? REDACTED
71
+ : redactValue(item, secrets, depth + 1, budget);
72
+ }
73
+ return out;
74
+ }
75
+ /**
76
+ * Redact a record in place of serialising it raw. Returns a new record; the
77
+ * caller's object is untouched so a failed write cannot leave a half-redacted
78
+ * value behind for a retry to publish.
79
+ */
80
+ export function redactRecord(record, secrets) {
81
+ const budget = { nodes: MAX_NODES };
82
+ const data = record.data
83
+ ? redactValue(record.data, secrets, 1, budget)
84
+ : undefined;
85
+ return {
86
+ ...record,
87
+ msg: redactText(record.msg, secrets),
88
+ ...(data ? { data } : {}),
89
+ };
90
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The live set of secret literals the redactor must never let reach disk.
3
+ *
4
+ * WHY A REGISTRY AND NOT A SNAPSHOT (aragon-home-config-and-logging P0-3):
5
+ *
6
+ * The format regexes in `redact.ts` catch the three vendors we know about. This
7
+ * registry is the backstop for everything else — a custom endpoint whose key
8
+ * looks like nothing in particular. That backstop only works if it holds the
9
+ * keys we ACTUALLY have, which is a moving target: `installLogging()` runs
10
+ * before commander has parsed `--api-key`, and a key the user types into the
11
+ * settings screen mid-session arrives later still. A set captured at startup
12
+ * would miss the single most common path by which a key enters the process, and
13
+ * would miss it silently, because the vendor regexes happen to cover the keys
14
+ * we test with.
15
+ *
16
+ * Process-lifetime, append-only, never serialised, never written anywhere.
17
+ */
18
+ export declare function registerSecret(value: string | undefined | null): void;
19
+ /** Convenience for the `apiKeys` maps every registration site happens to hold. */
20
+ export declare function registerSecretsFrom(keys: Record<string, string | null | undefined> | undefined): void;
21
+ /**
22
+ * Every known secret, LONGEST FIRST.
23
+ *
24
+ * Order matters: when one key is a prefix of another, replacing the short one
25
+ * first leaves the remainder of the long one in the file — a partial key is
26
+ * still a leak, and a more convincing one for being partial.
27
+ */
28
+ export declare function getSecrets(): readonly string[];
29
+ /** Tests only. Production code has no reason to forget a secret. */
30
+ export declare function clearSecretsForTest(): void;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The live set of secret literals the redactor must never let reach disk.
3
+ *
4
+ * WHY A REGISTRY AND NOT A SNAPSHOT (aragon-home-config-and-logging P0-3):
5
+ *
6
+ * The format regexes in `redact.ts` catch the three vendors we know about. This
7
+ * registry is the backstop for everything else — a custom endpoint whose key
8
+ * looks like nothing in particular. That backstop only works if it holds the
9
+ * keys we ACTUALLY have, which is a moving target: `installLogging()` runs
10
+ * before commander has parsed `--api-key`, and a key the user types into the
11
+ * settings screen mid-session arrives later still. A set captured at startup
12
+ * would miss the single most common path by which a key enters the process, and
13
+ * would miss it silently, because the vendor regexes happen to cover the keys
14
+ * we test with.
15
+ *
16
+ * Process-lifetime, append-only, never serialised, never written anywhere.
17
+ */
18
+ const secrets = new Set();
19
+ /**
20
+ * Minimum length for a value to count as a secret.
21
+ *
22
+ * Registering `"1"` or `"off"` would blank out every occurrence of that
23
+ * substring in ordinary log text, which destroys the log's usefulness while
24
+ * protecting nothing.
25
+ */
26
+ const MIN_SECRET_LENGTH = 8;
27
+ export function registerSecret(value) {
28
+ if (typeof value !== 'string')
29
+ return;
30
+ const trimmed = value.trim();
31
+ if (trimmed.length < MIN_SECRET_LENGTH)
32
+ return;
33
+ secrets.add(trimmed);
34
+ }
35
+ /** Convenience for the `apiKeys` maps every registration site happens to hold. */
36
+ export function registerSecretsFrom(keys) {
37
+ if (!keys)
38
+ return;
39
+ for (const value of Object.values(keys))
40
+ registerSecret(value);
41
+ }
42
+ /**
43
+ * Every known secret, LONGEST FIRST.
44
+ *
45
+ * Order matters: when one key is a prefix of another, replacing the short one
46
+ * first leaves the remainder of the long one in the file — a partial key is
47
+ * still a leak, and a more convincing one for being partial.
48
+ */
49
+ export function getSecrets() {
50
+ return [...secrets].sort((a, b) => b.length - a.length);
51
+ }
52
+ /** Tests only. Production code has no reason to forget a secret. */
53
+ export function clearSecretsForTest() {
54
+ secrets.clear();
55
+ }
@@ -10,16 +10,14 @@
10
10
  * flags), so there is no second mental model to learn.
11
11
  */
12
12
  import type { SkillScope } from '@aragon-agent/core';
13
+ import { getUserDataDir, getUserSkillsDir } from '../config/app-paths.js';
13
14
  export interface SkillRoot {
14
15
  dir: string;
15
16
  scope: SkillScope;
16
17
  /** False for bundled / env / `.claude/skills` — install and remove refuse these. */
17
18
  writable: boolean;
18
19
  }
19
- /** `<data>` — the per-user data root. Parent of `skills/`, and where sidecar state lives. */
20
- export declare function getUserDataDir(): string;
21
- /** `<data>/skills` — the default install target. */
22
- export declare function getUserSkillsDir(): string;
20
+ export { getUserDataDir, getUserSkillsDir };
23
21
  /** `<data>/skills/.staging` — scratch space for in-flight installs (§8.3). */
24
22
  export declare function getStagingDir(): string;
25
23
  /** `<data>/skills/.staging/.trash` — where a replaced skill is parked (P1-6). */
@@ -13,15 +13,13 @@ import { existsSync, realpathSync, statSync } from 'node:fs';
13
13
  import { delimiter, dirname, join, resolve, sep } from 'node:path';
14
14
  import process from 'node:process';
15
15
  import { fileURLToPath } from 'node:url';
16
- import { appPaths as paths } from '../config/app-paths.js';
17
- /** `<data>` — the per-user data root. Parent of `skills/`, and where sidecar state lives. */
18
- export function getUserDataDir() {
19
- return paths.data;
20
- }
21
- /** `<data>/skills` — the default install target. */
22
- export function getUserSkillsDir() {
23
- return join(paths.data, 'skills');
24
- }
16
+ import { getUserDataDir, getUserSkillsDir } from '../config/app-paths.js';
17
+ // `<data>` is `~/.aragon-agent` and `<data>/skills` its `skills/` child. Both
18
+ // are defined in `config/app-paths.ts` and only re-exported here: the skill
19
+ // modules (and their test mocks) import them from this path, and two separate
20
+ // definitions of the same directory is the drift this module's header warns
21
+ // about.
22
+ export { getUserDataDir, getUserSkillsDir };
25
23
  /** `<data>/skills/.staging` — scratch space for in-flight installs (§8.3). */
26
24
  export function getStagingDir() {
27
25
  return join(getUserSkillsDir(), '.staging');
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The human-input channel (plan-mode §3.5 / §5.1) — types, the fail-closed
3
+ * default gate, and the two pure normalizers that repair whatever the model
4
+ * actually sent.
5
+ *
6
+ * Everything here is pure except the type declarations: no Ink, no React, no
7
+ * `node:*`. `tools/` is inside the glyph scanner's scope, so every literal is
8
+ * ASCII.
9
+ *
10
+ * WHY THE NORMALIZERS ARE THE ONLY AUTHORITY ON THE LIMITS (P0-4). The
11
+ * model-facing schemas in `plan-tools.ts` declare SHAPE only — `type`,
12
+ * `required`, `default` — and put every bound in the `description`. They used to
13
+ * carry `minItems` / `maxItems` / `maxLength`, and that had two failure modes at
14
+ * once: `ToolExecutor` rejected a six-question call outright with `Invalid
15
+ * parameters: ...` before any repair could run, wasting a full research turn on
16
+ * a cosmetic defect; and `ajv` is an OPTIONAL dependency of core, so without it
17
+ * the fallback validator checks `required` plus primitive types only and the
18
+ * same call sailed through. One design, two behaviours, selected by whether an
19
+ * optional install step succeeded. Bounds live here instead, where they are
20
+ * deterministic and unit-tested.
21
+ */
22
+ export interface NormalizedOption {
23
+ label: string;
24
+ /** `''` when the model supplied none. */
25
+ description: string;
26
+ /** Exactly one per question is true after repair. */
27
+ recommended: boolean;
28
+ /** The synthetic free-text option. */
29
+ isOther: boolean;
30
+ }
31
+ export interface NormalizedQuestion {
32
+ id: string;
33
+ /** <= 12 chars, truncated. Rendered as a chip above the question. */
34
+ header: string;
35
+ question: string;
36
+ /** 3..5 entries: the 2..4 the model gave, plus the synthetic `Other`. */
37
+ options: NormalizedOption[];
38
+ allowMultiple: boolean;
39
+ }
40
+ export interface Answer {
41
+ id: string;
42
+ question: string;
43
+ /** Option labels. Length 1 unless `allowMultiple`. */
44
+ selected: string[];
45
+ /** Set only when `Other` was chosen. */
46
+ custom: string | null;
47
+ }
48
+ export interface NormalizedPlan {
49
+ title: string;
50
+ summary: string;
51
+ steps: {
52
+ title: string;
53
+ detail: string;
54
+ }[];
55
+ filesTouched: string[];
56
+ risks: string[];
57
+ openQuestions: string[];
58
+ }
59
+ export type HumanRequest = {
60
+ kind: 'questions';
61
+ questions: NormalizedQuestion[];
62
+ } | {
63
+ kind: 'plan';
64
+ plan: NormalizedPlan;
65
+ };
66
+ export type HumanResponse = {
67
+ kind: 'answers';
68
+ answers: Answer[];
69
+ cancelled: boolean;
70
+ } | {
71
+ kind: 'planDecision';
72
+ decision: 'approved' | 'revise' | 'dismissed';
73
+ feedback: string;
74
+ };
75
+ export interface HumanInputGate {
76
+ /**
77
+ * STATIC property of the channel: `true` only for a gate that can never
78
+ * acquire a human at all. Set on `DENY_ALL_HUMAN_INPUT` and nowhere else.
79
+ *
80
+ * This — not `canPrompt()` — is what `createPlanTools()` keys registration
81
+ * off. `canPrompt()` is a LIVE probe and is legitimately `false` for the whole
82
+ * window between `makeController()` and the App's first effect, because the
83
+ * App attaches the bridge handler later. A registration decision taken from it
84
+ * would be "no plan tools", forever, in every interactive session, with no
85
+ * error anywhere. The two predicates answer different questions and must not
86
+ * be merged.
87
+ */
88
+ readonly neverPrompts?: boolean;
89
+ /** True only while a TUI is mounted and able to render an overlay. */
90
+ canPrompt(): boolean;
91
+ /**
92
+ * Resolves with `null` when cancelled / unmounted / aborted. NEVER rejects.
93
+ *
94
+ * `signal` is the tool's `ctx.signal` and is REQUIRED in practice. It is the
95
+ * only ceiling the wait has: `ToolExecutor`'s timeout is cooperative — it
96
+ * aborts the context signal and keeps awaiting the tool's promise, with no
97
+ * `Promise.race` anywhere — and the idle watchdog is deliberately paused for
98
+ * the duration of the wait. Passing `undefined` therefore gives a wait with no
99
+ * ceiling of any kind, and the process wedges behind an overlay nobody is
100
+ * going to answer.
101
+ */
102
+ request(req: HumanRequest, signal?: AbortSignal): Promise<HumanResponse | null>;
103
+ }
104
+ export interface HumanInputBridge {
105
+ handler: ((req: HumanRequest, signal?: AbortSignal) => Promise<HumanResponse | null>) | null;
106
+ /**
107
+ * Resolve every outstanding request with `null`.
108
+ *
109
+ * Called on App unmount AND when a run ends. `ConfirmBridge` nulls its handler
110
+ * on unmount but leaves any in-flight promise dangling forever; with a
111
+ * three-second `y/N` that never mattered, but a question wizard the user
112
+ * abandons with `Ctrl+C` twice would hang the tool until its ceiling.
113
+ */
114
+ cancelPending(): void;
115
+ }
116
+ /**
117
+ * Fails CLOSED. The headless default — forgetting to pass a gate must refuse,
118
+ * never hang and never assume yes.
119
+ */
120
+ export declare const DENY_ALL_HUMAN_INPUT: HumanInputGate;
121
+ export declare const QUESTION_LIMITS: {
122
+ readonly maxQuestions: 5;
123
+ readonly minOptions: 2;
124
+ readonly maxOptions: 4;
125
+ readonly headerChars: 12;
126
+ readonly questionChars: 300;
127
+ readonly labelChars: 60;
128
+ readonly optionDescriptionChars: 120;
129
+ };
130
+ export declare const PLAN_LIMITS: {
131
+ readonly titleChars: 80;
132
+ readonly summaryChars: 600;
133
+ readonly maxSteps: 20;
134
+ readonly stepTitleChars: 100;
135
+ readonly stepDetailChars: 400;
136
+ readonly maxFiles: 30;
137
+ readonly maxRisks: 10;
138
+ readonly maxOpenQuestions: 10;
139
+ };
140
+ /**
141
+ * Trim to `max` characters, preferring a word boundary within the last
142
+ * `WORD_BOUNDARY_WINDOW` characters. Cutting a sentence mid-word reads as
143
+ * corruption; cutting it at a space reads as a summary.
144
+ */
145
+ export declare function clampText(raw: unknown, max: number): string;
146
+ /**
147
+ * Repair whatever the model sent into something renderable.
148
+ *
149
+ * Returns `[]` when nothing survives, which the tool turns into the one hard
150
+ * failure this path has. Every rule below is reachable precisely BECAUSE the
151
+ * schema stopped declaring the bounds (P0-4); with `minItems` / `maxItems` in
152
+ * the schema, `ajv` rejected the call first and rules 1 and 4 were dead code.
153
+ */
154
+ export declare function normalizeQuestions(raw: unknown): NormalizedQuestion[];
155
+ /**
156
+ * Repair a submitted plan. Returns `null` only when no step survives — the one
157
+ * hard failure, because there is then nothing left to review.
158
+ *
159
+ * A plan that is one step over the cap is still a plan, and rejecting it costs a
160
+ * full research turn to regain nothing. Truncation is silent to the model but
161
+ * VISIBLE to the user: the review overlay's position indicator counts the real
162
+ * rows, so a clipped plan cannot masquerade as complete.
163
+ */
164
+ export declare function normalizePlan(raw: unknown): NormalizedPlan | null;
165
+ export interface AnswersResultShape {
166
+ answers: Answer[];
167
+ cancelled: boolean;
168
+ roundsUsed: number;
169
+ roundsRemaining: number;
170
+ }
171
+ /**
172
+ * Render the `ask_user` result: one human-readable line so the transcript
173
+ * preview is legible without parsing, then the JSON the model reads.
174
+ *
175
+ * `asked` is passed separately rather than derived from `answers.length`
176
+ * because a cancelled wizard returns fewer answers than it asked for, and
177
+ * "answered 1 of 3" is the whole information content of that line.
178
+ */
179
+ export declare function formatAnswersResult(shape: AnswersResultShape, asked: number): string;