@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.
- package/CHANGELOG.md +158 -0
- package/README.md +254 -7
- package/dist/agent/agent-mode.d.ts +53 -0
- package/dist/agent/agent-mode.js +69 -0
- package/dist/agent/controller.d.ts +69 -0
- package/dist/agent/controller.js +154 -1
- package/dist/agent/plan-prompt.d.ts +18 -0
- package/dist/agent/plan-prompt.js +93 -0
- package/dist/agent/reducer.d.ts +14 -1
- package/dist/agent/reducer.js +9 -0
- package/dist/agent/system-prompt.d.ts +15 -0
- package/dist/agent/system-prompt.js +8 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +247 -23
- package/dist/commands/builtins.js +60 -0
- package/dist/commands/registry.d.ts +17 -0
- package/dist/config/app-paths.d.ts +71 -4
- package/dist/config/app-paths.js +139 -8
- package/dist/config/cli-commands.d.ts +43 -0
- package/dist/config/cli-commands.js +196 -0
- package/dist/config/env.d.ts +7 -1
- package/dist/config/env.js +53 -1
- package/dist/config/load.d.ts +10 -0
- package/dist/config/load.js +82 -2
- package/dist/config/migrate-home.d.ts +63 -0
- package/dist/config/migrate-home.js +165 -0
- package/dist/config/migrate-legacy-state.d.ts +10 -0
- package/dist/config/migrate-legacy-state.js +15 -6
- package/dist/config/schema.d.ts +81 -0
- package/dist/config/schema.js +85 -0
- package/dist/config/store.d.ts +30 -12
- package/dist/config/store.js +47 -35
- package/dist/input/mouse-events.d.ts +61 -0
- package/dist/input/mouse-events.js +153 -0
- package/dist/input/stdin-mouse-filter.d.ts +42 -0
- package/dist/input/stdin-mouse-filter.js +153 -0
- package/dist/logging/cli-commands.d.ts +25 -0
- package/dist/logging/cli-commands.js +221 -0
- package/dist/logging/file-sink.d.ts +114 -0
- package/dist/logging/file-sink.js +326 -0
- package/dist/logging/install.d.ts +163 -0
- package/dist/logging/install.js +302 -0
- package/dist/logging/levels.d.ts +40 -0
- package/dist/logging/levels.js +59 -0
- package/dist/logging/logger.d.ts +110 -0
- package/dist/logging/logger.js +237 -0
- package/dist/logging/redact.d.ts +28 -0
- package/dist/logging/redact.js +90 -0
- package/dist/logging/secret-registry.d.ts +30 -0
- package/dist/logging/secret-registry.js +55 -0
- package/dist/skills/paths.d.ts +2 -4
- package/dist/skills/paths.js +7 -9
- package/dist/tools/human-input.d.ts +179 -0
- package/dist/tools/human-input.js +227 -0
- package/dist/tools/index.d.ts +35 -3
- package/dist/tools/index.js +96 -9
- package/dist/tools/plan-tools.d.ts +64 -0
- package/dist/tools/plan-tools.js +198 -0
- package/dist/ui/App.d.ts +12 -1
- package/dist/ui/App.js +223 -17
- package/dist/ui/Composer.d.ts +9 -1
- package/dist/ui/Composer.js +26 -7
- package/dist/ui/ModeChip.d.ts +29 -0
- package/dist/ui/ModeChip.js +15 -0
- package/dist/ui/PromptInput.d.ts +22 -1
- package/dist/ui/PromptInput.js +49 -2
- package/dist/ui/StatusBar.d.ts +15 -0
- package/dist/ui/StatusBar.js +10 -2
- package/dist/ui/entries/ToolPreview.d.ts +2 -0
- package/dist/ui/entries/ToolPreview.js +64 -0
- package/dist/ui/glyphs.d.ts +21 -0
- package/dist/ui/glyphs.js +12 -0
- package/dist/ui/layout/AppShell.d.ts +10 -1
- package/dist/ui/layout/AppShell.js +38 -3
- package/dist/ui/layout/ScrollIndicator.d.ts +31 -0
- package/dist/ui/layout/ScrollIndicator.js +15 -0
- package/dist/ui/layout/ScrollViewport.d.ts +8 -2
- package/dist/ui/layout/ScrollViewport.js +24 -4
- package/dist/ui/layout/regions.d.ts +55 -0
- package/dist/ui/layout/regions.js +52 -0
- package/dist/ui/layout/scroll-indicator.d.ts +27 -0
- package/dist/ui/layout/scroll-indicator.js +35 -0
- package/dist/ui/layout/scroll.d.ts +9 -0
- package/dist/ui/layout/scroll.js +23 -0
- package/dist/ui/layout/wrap-rows.d.ts +24 -0
- package/dist/ui/layout/wrap-rows.js +74 -0
- package/dist/ui/overlays/HelpOverlay.js +15 -0
- package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
- package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
- package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
- package/dist/ui/overlays/QuestionOverlay.js +193 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
- package/dist/ui/overlays/SettingsScreen.js +8 -1
- package/dist/ui/screen.d.ts +14 -1
- package/dist/ui/screen.js +37 -3
- package/dist/ui/use-startup-notices.d.ts +25 -0
- package/dist/ui/use-startup-notices.js +64 -0
- package/dist/ui/use-wheel-routing.d.ts +68 -0
- package/dist/ui/use-wheel-routing.js +139 -0
- package/package.json +3 -2
- package/runtime/insecure-tls-warning.cjs +62 -0
- 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
|
+
}
|
package/dist/skills/paths.d.ts
CHANGED
|
@@ -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
|
-
|
|
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). */
|
package/dist/skills/paths.js
CHANGED
|
@@ -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 {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
export
|
|
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;
|