@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
package/dist/cli.js CHANGED
@@ -14,23 +14,31 @@ import { readFileSync } from 'node:fs';
14
14
  import { dirname, join } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import process from 'node:process';
17
+ import '../runtime/insecure-tls-warning.cjs';
17
18
  import { render } from 'ink';
18
19
  import { Command } from 'commander';
19
20
  import { loadConfig } from './config/load.js';
20
- import { getConfigPath, updatePersistedConfig } from './config/store.js';
21
+ import { getConfigPath, readConfigFile, updatePersistedConfig } from './config/store.js';
21
22
  import { formatMigrationNotice, migrateLegacyState } from './config/migrate-legacy-state.js';
22
- import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampSkillsIntegrity, clampSkillsToolPolicy, clampTranscriptWindow, coerceMaxTokens, coercePositiveInt, isAdapterProvider, DEFAULT_SKILLS_CONFIG, } from './config/schema.js';
23
+ import { formatHomeMigrationNotice, migrateToHome } from './config/migrate-home.js';
24
+ import { LOG_CONFIG_SET_KEYS, applyLogConfigSet, runConfigEdit, runConfigGet, runConfigHome, runConfigList, warnIfRedactionDisabled, } from './config/cli-commands.js';
25
+ import { attachAgentEvents, installLogging, setScreenRestore, setSignalTerminator, } from './logging/install.js';
26
+ import { getLogger } from './logging/logger.js';
27
+ import { runLogsCommand } from './logging/cli-commands.js';
28
+ import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampTranscriptWindow, coerceMaxTokens, coercePositiveInt, isAdapterProvider, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SKILLS_CONFIG, } from './config/schema.js';
23
29
  import { makeGetApiKey } from './config/load.js';
24
30
  import { AgentController, DENY_ALL_APPROVAL } from './agent/controller.js';
25
31
  import { runSkillsCommand } from './skills/cli-commands.js';
26
32
  import { runHeadless } from './agent/headless.js';
27
33
  import { App } from './ui/App.js';
28
34
  import { decideRenderMode } from './ui/layout/frame.js';
35
+ import { tryCreateMouseFilter } from './input/stdin-mouse-filter.js';
29
36
  import { enterAltScreen, writeExitTranscript } from './ui/screen.js';
30
37
  import { readExitSnapshot } from './ui/exit-snapshot.js';
31
38
  import { renderTranscriptText } from './ui/transcript-text.js';
32
39
  import { detectCapabilities } from './ui/capabilities.js';
33
40
  import { pickGlyphs } from './ui/glyphs.js';
41
+ import { DENY_ALL_HUMAN_INPUT } from './tools/human-input.js';
34
42
  // ---------------------------------------------------------------------------
35
43
  // Version (read from the shipped package.json next to dist/)
36
44
  // ---------------------------------------------------------------------------
@@ -63,10 +71,19 @@ function toFlags(opts) {
63
71
  exitTranscript: opts.exitTranscript,
64
72
  compact: opts.compact,
65
73
  hints: opts.hints,
74
+ // Without this line the flag never reaches `CliFlags` and `--no-mouse` is
75
+ // inert — a failure with no compile error and no runtime error (P1-2).
76
+ mouse: opts.mouse,
66
77
  skills: opts.skills,
67
78
  skill: opts.skill,
68
79
  skillsYes: opts.skillsYes,
69
80
  skillToolPolicy: opts.skillToolPolicy,
81
+ plan: opts.plan,
82
+ // `--verbose` is just a shorthand for `--log-level debug`; an explicit
83
+ // `--log-level` still wins, so the two can be combined without surprise.
84
+ logLevel: opts.logLevel ?? (opts.verbose ? 'debug' : undefined),
85
+ logFile: opts.logFile,
86
+ logDir: opts.logDir,
70
87
  };
71
88
  }
72
89
  // ---------------------------------------------------------------------------
@@ -92,7 +109,14 @@ function toFlags(opts) {
92
109
  */
93
110
  function makeController(flags, opts = { interactive: true }) {
94
111
  const config = loadConfig(flags);
112
+ // The authoritative log level lands here (§4.4.5). `installLogging()` only
113
+ // resolved a bootstrap level — it ran before commander and before `.env` were
114
+ // readable — and without this hand-off the fully resolved one never applies.
115
+ getLogger().reconfigure(config.log);
95
116
  const confirmBridge = { handler: null };
117
+ // `cancelPending` is replaced by the App's own implementation on mount; this
118
+ // no-op keeps the object total so nothing has to null-check it.
119
+ const humanInputBridge = { handler: null, cancelPending: () => { } };
96
120
  const controller = new AgentController(config, {
97
121
  version: VERSION,
98
122
  confirm: (req) => confirmBridge.handler ? confirmBridge.handler(req) : Promise.resolve(true),
@@ -109,11 +133,23 @@ function makeController(flags, opts = { interactive: true }) {
109
133
  if (!opts.interactive)
110
134
  process.stderr.write(`[skills] ${level}: ${text}\n`);
111
135
  },
136
+ // A THIRD approval channel, and it must not be conflated with the other two.
137
+ // `confirm` auto-approves when unattached and `approval` denies; neither can
138
+ // render a question wizard or a plan card. Headless gets the fail-closed
139
+ // gate, whose STATIC `neverPrompts` is also what stops `createPlanTools`
140
+ // registering two tools the model could never use there.
141
+ humanInput: opts.interactive
142
+ ? {
143
+ // Live probe, never cached — same contract as `approval` above.
144
+ canPrompt: () => humanInputBridge.handler !== null,
145
+ request: (req, signal) => humanInputBridge.handler
146
+ ? humanInputBridge.handler(req, signal)
147
+ : Promise.resolve(null),
148
+ }
149
+ : DENY_ALL_HUMAN_INPUT,
112
150
  });
113
- return { controller, confirmBridge };
151
+ return { controller, confirmBridge, humanInputBridge };
114
152
  }
115
- /** POSIX signal numbers for the `128 + signo` exit-code convention. */
116
- const SIGNAL_NUMBERS = { SIGINT: 2, SIGTERM: 15, SIGHUP: 1 };
117
153
  /**
118
154
  * The interactive TUI — and the ONLY place that may touch the screen.
119
155
  *
@@ -124,11 +160,39 @@ const SIGNAL_NUMBERS = { SIGINT: 2, SIGTERM: 15, SIGHUP: 1 };
124
160
  * renders Ink, `runOneShot()` goes to `runHeadless()`, which never does.
125
161
  */
126
162
  function runInteractive(flags, extras = {}) {
127
- const { controller, confirmBridge } = makeController(flags);
163
+ const { controller, confirmBridge, humanInputBridge } = makeController(flags);
128
164
  const config = controller.getConfig();
129
165
  const mode = decideRenderMode({ fullscreen: config.fullscreen }, process.env, process.stdout);
166
+ const logger = getLogger();
167
+ logger.info('cli', 'run_interactive', { mode, provider: config.provider, model: config.model });
168
+ attachAgentEvents(logger, controller);
130
169
  let screen = null;
131
170
  let replayed = false;
171
+ // --- Mouse wheel region routing (mouse-wheel-region-routing §4.2). -------
172
+ //
173
+ // BUILD THE FILTER FIRST, THEN ENABLE REPORTING. The order is invariant I-8
174
+ // and it is not negotiable: `\x1b[?1000h` tells the terminal to send SGR
175
+ // reports, and Ink 5.2.1 has no mouse branch at all, so any report that
176
+ // reaches its key parser is INSERTED INTO THE USER'S MESSAGE (`[` is 0x5B, so
177
+ // `PromptInput`'s `isControlSeq` guard passes it). A window in which
178
+ // reporting is on and Ink holds the raw stream is not a degraded feature — it
179
+ // is every click typing `[<0;12;5M` into the composer, strictly worse than
180
+ // the bug being fixed.
181
+ //
182
+ // `tryCreateMouseFilter` returns `null` rather than throwing, so a
183
+ // construction failure degrades to "the wheel is inert" (rung 3 of the
184
+ // fail-safe ladder), never to "reporting on, no filter".
185
+ // BOTH streams have to be terminals. `stdout.isTTY` is what decides whether
186
+ // `enterAltScreen` writes anything at all, but the reports come back on
187
+ // STDIN, so a TTY stdout with a piped stdin (`aragon config < file`, which
188
+ // reaches `runInteractive` without the stdin check the default action makes)
189
+ // would wrap a stream no report can ever arrive on and ask the terminal to
190
+ // start reporting anyway. §4.2's "non-TTY ⇒ no filter" covers both ends.
191
+ const wantMouse = mode === 'fullscreen' && config.mouse && !!process.stdout.isTTY && !!process.stdin.isTTY;
192
+ const mouseFilter = wantMouse
193
+ ? tryCreateMouseFilter(process.stdin, (reason) => logger.warn('cli', 'mouse_filter_failed', { reason }))
194
+ : null;
195
+ const disposeMouse = () => mouseFilter?.dispose();
132
196
  const replayTranscript = () => {
133
197
  if (replayed || mode !== 'fullscreen' || !config.exitTranscript)
134
198
  return;
@@ -146,7 +210,10 @@ function runInteractive(flags, extras = {}) {
146
210
  }));
147
211
  };
148
212
  if (mode === 'fullscreen') {
149
- screen = enterAltScreen(process.stdout);
213
+ // `mouse` here means "a filter is installed", NOT "the user wants mouse
214
+ // support" — see `AltScreenOptions`. `screen.ts` must never derive it for
215
+ // itself, or a later refactor re-opens the gap I-8 closes.
216
+ screen = enterAltScreen(process.stdout, { mouse: mouseFilter !== null });
150
217
  const restore = () => screen?.restore();
151
218
  // Four idempotent restore paths (§4.4). The signal hook is NOT redundant:
152
219
  // - `waitUntilExit().then()` is a microtask and may never be reached when
@@ -158,27 +225,50 @@ function runInteractive(flags, extras = {}) {
158
225
  // parent all leave the user staring at a blank alternate screen with `reset`
159
226
  // as their only way out.
160
227
  process.on('exit', restore);
161
- for (const [name, signo] of Object.entries(SIGNAL_NUMBERS)) {
162
- process.on(name, () => {
163
- restore(); // Synchronous: stdout.write on a TTY is sync on every platform.
164
- process.exit(128 + signo);
165
- });
166
- }
228
+ // The signal listeners themselves now belong to `installLogging()`, which
229
+ // registered them before this function ever ran — and a second listener per
230
+ // signal would race the first. This branch owns the SCREEN, so it supplies
231
+ // the two screen-shaped pieces and nothing else: the crash handler calls
232
+ // `screenRestore` before printing a stack (otherwise the stack vanishes with
233
+ // the alternate screen), and the signal path restores before exiting.
234
+ setScreenRestore(restore);
235
+ setSignalTerminator((signo) => {
236
+ restore(); // Synchronous: stdout.write on a TTY is sync on every platform.
237
+ process.exit(128 + signo);
238
+ });
167
239
  }
168
- const instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay }),
240
+ const instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay, mouseSource: mouseFilter?.source }),
169
241
  // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
170
242
  // straight to stdout and permanently shifts the fixed frame's accounting.
171
- { exitOnCtrlC: false, patchConsole: mode === 'inline' });
243
+ {
244
+ exitOnCtrlC: false,
245
+ patchConsole: mode === 'inline',
246
+ // I-5: with mouse support off the REAL stdin is handed over unwrapped, so
247
+ // every existing path is provably unchanged.
248
+ stdin: mouseFilter?.stdin ?? process.stdin,
249
+ });
172
250
  // The return value MUST be captured — `waitUntilExit()` is the normal-exit
173
- // restore path and it cannot be registered otherwise.
251
+ // restore path and it cannot be registered otherwise. `dispose()` runs on
252
+ // BOTH branches (I-7): a live `'data'` listener on stdin keeps the event loop
253
+ // alive and `aragon` would never return to the shell. The signal path does
254
+ // not need it — it calls `process.exit()`.
255
+ //
256
+ // RESTORE BEFORE DISPOSE. `restore()` writes `?1000l`, so the terminal stops
257
+ // reporting first and anything already in flight is still consumed by a live
258
+ // filter. Disposing first leaves a window — narrow, but real — in which the
259
+ // terminal is still reporting and nothing is draining stdin, so a notch taken
260
+ // in that instant survives in the OS buffer and is handed to the shell as
261
+ // `[<64;12;5M` on exit. That is the I-2 failure mode, just smaller.
174
262
  void instance
175
263
  .waitUntilExit()
176
264
  .then(() => {
177
265
  screen?.restore();
266
+ disposeMouse();
178
267
  replayTranscript();
179
268
  })
180
269
  .catch(() => {
181
270
  screen?.restore();
271
+ disposeMouse();
182
272
  });
183
273
  }
184
274
  async function runOneShot(flags, prompt, quiet) {
@@ -186,11 +276,23 @@ async function runOneShot(flags, prompt, quiet) {
186
276
  // install. The gate must know that up front rather than discovering a null
187
277
  // handler and defaulting to "yes" (§7.4 / AC-13).
188
278
  const { controller } = makeController(flags, { interactive: false });
279
+ const logger = getLogger();
280
+ logger.info('cli', 'run_oneshot', { quiet });
281
+ // Headless has no frame, so a logging failure can safely reach stderr — the
282
+ // toast channel `App.tsx` uses does not exist here.
283
+ logger.onFailure((reason) => process.stderr.write(`[log] ${reason}\n`));
284
+ attachAgentEvents(logger, controller);
189
285
  const untrusted = controller.getSkillService().untrustedDirs();
190
286
  for (const dir of untrusted) {
191
287
  process.stderr.write(`[skills] project skills in ${dir} skipped (untrusted). Run: aragon skills trust ${dir}\n`);
192
288
  }
193
289
  const code = await runHeadless(controller, prompt, { quiet });
290
+ // Flushed HERE and not inside `runHeadless`: that module is deliberately
291
+ // injectable (its own controller interface, its own stdout/stderr) and has an
292
+ // early `return 2`, so a singleton dependency in there would be both a new
293
+ // coupling and a branch that skips the flush. `process.on('exit')` remains the
294
+ // real backstop; this just makes the common path immediate.
295
+ getLogger().flushSync();
194
296
  process.exitCode = code;
195
297
  }
196
298
  async function readStdin() {
@@ -247,6 +349,14 @@ const CONFIG_SET_KEYS = new Set([
247
349
  // flags and never persist -- inconsistent with every other config field.
248
350
  'density',
249
351
  'hints',
352
+ // MEMBERSHIP HERE ONLY DECIDES THAT THE KEY IS NOT REJECTED. The `switch`
353
+ // below is what builds the patch, and a key present here but absent there
354
+ // falls through every case, writes nothing, and still prints
355
+ // `Set mouse = false` (P1-2) -- exactly what happened to `density`/`hints`.
356
+ 'mouse',
357
+ 'planModeDefault',
358
+ 'planModeMaxAskRounds',
359
+ 'planModeHumanTimeoutMs',
250
360
  // Dotted keys route into the nested `skills` object; see the switch below.
251
361
  'skills.enabled',
252
362
  'skills.requireApproval',
@@ -255,6 +365,9 @@ const CONFIG_SET_KEYS = new Set([
255
365
  'skills.integrity',
256
366
  'skills.usageTracking',
257
367
  'skills.toolPolicy',
368
+ // `log.*` keys are handled by `applyLogConfigSet` below, NOT by the switch:
369
+ // that switch is already past this package's complexity ceiling.
370
+ ...LOG_CONFIG_SET_KEYS,
258
371
  ]);
259
372
  function runConfigSet(key, value) {
260
373
  if (!CONFIG_SET_KEYS.has(key)) {
@@ -262,6 +375,13 @@ function runConfigSet(key, value) {
262
375
  process.exitCode = 2;
263
376
  return;
264
377
  }
378
+ const logPatch = applyLogConfigSet(key, value);
379
+ if (logPatch) {
380
+ updatePersistedConfig(logPatch);
381
+ warnIfRedactionDisabled(key, value);
382
+ process.stdout.write(`Set ${key} = ${value}\n`);
383
+ return;
384
+ }
265
385
  const patch = {};
266
386
  switch (key) {
267
387
  case 'provider':
@@ -306,6 +426,22 @@ function runConfigSet(key, value) {
306
426
  case 'hints':
307
427
  patch.hints = value === 'true' || value === '1';
308
428
  break;
429
+ case 'mouse':
430
+ patch.mouse = value === 'true' || value === '1';
431
+ break;
432
+ // Clamped rather than rejected, for the same reason as `theme` and
433
+ // `skills.integrity`: hardening only the READ path leaves a bad value on
434
+ // disk that reverts to the default on every launch, which presents as
435
+ // "my setting won't stick".
436
+ case 'planModeDefault':
437
+ patch.planModeDefault = value === 'true' || value === '1';
438
+ break;
439
+ case 'planModeMaxAskRounds':
440
+ patch.planModeMaxAskRounds = clampAskRounds(value, DEFAULT_PLAN_MAX_ASK_ROUNDS);
441
+ break;
442
+ case 'planModeHumanTimeoutMs':
443
+ patch.planModeHumanTimeoutMs = clampHumanTimeout(value, DEFAULT_PLAN_HUMAN_TIMEOUT_MS);
444
+ break;
309
445
  // The dotted keys write a PARTIAL `skills` object. That is safe only
310
446
  // because `updatePersistedConfig` deep-merges this section (§10.3.2) —
311
447
  // with the old shallow merge each of these would wipe trustedProjectDirs.
@@ -374,16 +510,34 @@ function buildProgram() {
374
510
  .option('--no-compact', 'Comfortable transcript density')
375
511
  .option('--hints', 'Always show the composer hint row')
376
512
  .option('--no-hints', 'Hide the composer hint row')
513
+ // Positive form first, same tri-state reason as `--fullscreen` above.
514
+ .option('--mouse', 'Wheel scrolls the region under the pointer (full-screen mode)')
515
+ .option('--no-mouse', 'Leave the mouse to the terminal (the wheel does nothing)')
377
516
  // Declaring `--fullscreen` BEFORE `--no-fullscreen` keeps the default
378
517
  // `undefined` instead of `true`, which is what makes the tri-state work:
379
518
  // "no opinion" must stay distinguishable from "force it".
380
519
  .option('--fullscreen', 'Force the full-screen TUI (overrides auto-downgrade, except non-TTY)')
381
520
  .option('--no-fullscreen', 'Force the inline renderer (v0.2.0 behavior)')
382
521
  .option('--no-exit-transcript', 'Do not replay the session summary after exiting')
522
+ // Same tri-state shape as `--fullscreen`: the positive form is declared
523
+ // FIRST so the default stays `undefined` and "no opinion" remains
524
+ // distinguishable from an explicit `--no-plan`, which is what lets the
525
+ // config layer win when neither flag was given.
526
+ .option('--plan', 'Start the session in PLAN mode (read-only research + review)')
527
+ .option('--no-plan', 'Start the session in BUILD mode (overrides planModeDefault)')
383
528
  .option('--no-skills', 'Disable the skill system entirely (no catalog, no skill tools)')
384
529
  .option('--skill <name>', 'Force-load a skill at Level 2 for this run (repeatable)', (value, previous = []) => [...previous, value])
385
530
  .option('--skills-yes', 'Approve skill installs for this run (CI / headless)')
386
531
  .option('--skill-tool-policy <mode>', 'Tool ceiling from allowed-tools for this run: off | warn | enforce (not persisted)')
532
+ // `--log-file` is declared BEFORE `--no-log-file` for the tri-state reason
533
+ // recorded twice above: negative-only would default it to `true` and make a
534
+ // persisted `log.toFile: false` permanently unreachable.
535
+ .option('--log-file', 'Write this run to a log file')
536
+ .option('--no-log-file', 'Do not write this run to a log file')
537
+ .option('--log-level <level>', 'silent|error|warn|info|debug|trace (this run only)')
538
+ // No short form: `-v` is already `--version`.
539
+ .option('--verbose', 'Shorthand for --log-level debug')
540
+ .option('--log-dir <dir>', 'Absolute log directory for this run')
387
541
  .option('--no-color', 'Disable ANSI color')
388
542
  .option('--quiet', '(print mode) suppress tool/usage lines on stderr')
389
543
  .action(async (prompt, opts) => {
@@ -433,6 +587,37 @@ function buildProgram() {
433
587
  .action(() => {
434
588
  process.stdout.write(`${getConfigPath()}\n`);
435
589
  });
590
+ configCmd
591
+ .command('get <key>')
592
+ .description('Print a single config value (secrets are masked)')
593
+ .action((key) => runConfigGet(key));
594
+ configCmd
595
+ .command('list')
596
+ .description('Print the whole config (secrets are masked)')
597
+ .option('--json', 'Machine-readable output')
598
+ .action((opts) => runConfigList(opts));
599
+ configCmd
600
+ .command('edit')
601
+ .description('Open config.json in $VISUAL / $EDITOR (backs it up first)')
602
+ .action(async () => {
603
+ await runConfigEdit();
604
+ });
605
+ configCmd
606
+ .command('home')
607
+ .description('Print the user-state root directory')
608
+ .action(() => runConfigHome());
609
+ // aragon logs <sub> [-n N] [--follow] [--level lv] [--json] [--yes]
610
+ program
611
+ .command('logs [subcommand]')
612
+ .description('Inspect the log files: path | list | tail | clear | open')
613
+ .option('-n, --lines <n>', 'Lines for `logs tail` (default 100)')
614
+ .option('--follow', 'Keep printing new records (`logs tail`)')
615
+ .option('--level <level>', 'Only show records at this level or more severe')
616
+ .option('--json', 'Print raw JSONL instead of rendered lines')
617
+ .option('--yes', 'Confirm destructive actions (required by `logs clear`)')
618
+ .action(async (subcommand, opts) => {
619
+ process.exitCode = await runLogsCommand(subcommand ?? 'path', opts, toFlags(program.opts()));
620
+ });
436
621
  // aragon skills <sub> [arg] [--scope] [--name] [--description] [--yes] [--json]
437
622
  program
438
623
  .command('skills [subcommand] [argument]')
@@ -466,16 +651,55 @@ function buildProgram() {
466
651
  return program;
467
652
  }
468
653
  async function main() {
469
- // First statement on purpose: everything below reads config or skills, and
470
- // both resolve through `envPaths`. Runs before `loadDotenv()` for the same
471
- // reason. Never throws — see migrate-legacy-state.ts.
472
- const migration = migrateLegacyState();
473
- const notice = formatMigrationNotice(migration);
474
- if (notice)
475
- process.stderr.write(`${notice}\n`);
654
+ // ① and ② are the first two statements on purpose: everything below reads
655
+ // config or skills. THE ORDER IS NOT INTERCHANGEABLE — ① moves 0.4.x data out
656
+ // of the pre-rename env-paths tree, and only then does ② have anything to
657
+ // move into `~/.aragon-agent`. Run the other way round, `config.json` is
658
+ // stranded permanently (see migrate-home.ts). Neither ever throws.
659
+ const legacy = migrateLegacyState();
660
+ const home = migrateToHome();
661
+ for (const notice of [formatMigrationNotice(legacy), formatHomeMigrationNotice(home)]) {
662
+ if (notice)
663
+ process.stderr.write(`${notice}\n`);
664
+ }
665
+ // ③ comes after both because the log directory lives inside a home that ②
666
+ // may just have created. What ① and ② did is replayed from `pending` rather
667
+ // than lost.
668
+ installLogging({ pending: migrationNotes(legacy, home) });
669
+ // An unparseable config.json still falls back to defaults — the CLI has to
670
+ // start — but it must not do so in silence, or a stray comma presents as
671
+ // "every setting I ever chose is gone". stderr, never stdout, so
672
+ // `aragon -p "…" > out.txt` keeps carrying model output and nothing else.
673
+ const { parseError } = readConfigFile();
674
+ if (parseError) {
675
+ process.stderr.write(`config: using defaults, could not parse ${parseError}\n`);
676
+ }
476
677
  const program = buildProgram();
477
678
  await program.parseAsync(process.argv);
478
679
  }
680
+ /** The ①/② outcomes, as records for `installLogging` to write once it exists. */
681
+ function migrationNotes(legacy, home) {
682
+ const notes = [
683
+ { level: 'info', scope: 'cli', msg: 'cli_start', data: { version: VERSION } },
684
+ ];
685
+ if (legacy.mode !== 'none' || legacy.error) {
686
+ notes.push({
687
+ level: legacy.error ? 'warn' : 'info',
688
+ scope: 'migrate',
689
+ msg: 'migrate_brand',
690
+ data: { ...legacy },
691
+ });
692
+ }
693
+ if (home.mode !== 'none' || home.error) {
694
+ notes.push({
695
+ level: home.error ? 'warn' : 'info',
696
+ scope: 'migrate',
697
+ msg: 'migrate_home',
698
+ data: { ...home },
699
+ });
700
+ }
701
+ return notes;
702
+ }
479
703
  main().catch((err) => {
480
704
  process.stderr.write(`${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
481
705
  process.exitCode = 1;
@@ -8,7 +8,10 @@ import { existsSync, statSync } from 'node:fs';
8
8
  import { isAbsolute, resolve } from 'node:path';
9
9
  import process from 'node:process';
10
10
  import { clampThinkingLevel, clampTheme, isThemeName, LEGACY_DARK_THEME, THEME_NAMES, THINKING_LEVELS, } from '../config/schema.js';
11
+ import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
12
+ import { loadConfig } from '../config/load.js';
11
13
  import { loadSession, resolveSessionPath, saveSession } from '../session/persist.js';
14
+ import { getLogger } from '../logging/logger.js';
12
15
  const COMMANDS = [
13
16
  {
14
17
  name: 'help',
@@ -63,6 +66,30 @@ const COMMANDS = [
63
66
  ctx.toast('success', `Theme set to ${name}.`);
64
67
  },
65
68
  },
69
+ {
70
+ name: 'plan',
71
+ description: 'Toggle plan mode (on | off | status)',
72
+ // Exists next to Shift+Tab for two reasons: a handful of terminal
73
+ // multiplexer and remote-desktop stacks never send `CSI Z` at all (R-P1),
74
+ // and slash commands are the discoverable surface — `/` opens the palette.
75
+ run: (ctx) => {
76
+ const arg = ctx.args.trim().toLowerCase();
77
+ if (arg === 'status') {
78
+ const s = ctx.controller.getPlanStatus();
79
+ const pending = s.pending ? ` (pending ${MODE_LABEL[s.pending]} after this run)` : '';
80
+ ctx.notify('info', `Mode: ${MODE_LABEL[s.effective]}${pending}. ` +
81
+ `Ask rounds used: ${s.askRoundsUsed}/${s.maxAskRounds}.`);
82
+ return;
83
+ }
84
+ if (arg.length > 0 && arg !== 'on' && arg !== 'off') {
85
+ ctx.notify('warn', `Unknown argument "${arg}" - use /plan [on|off|status].`);
86
+ return;
87
+ }
88
+ const current = ctx.controller.getAgentMode();
89
+ const target = arg === 'on' ? 'plan' : arg === 'off' ? 'build' : nextMode(current);
90
+ ctx.applyAgentMode(target);
91
+ },
92
+ },
66
93
  {
67
94
  name: 'expand',
68
95
  description: 'Expand / collapse a recent tool card (default: last)',
@@ -179,6 +206,39 @@ const COMMANDS = [
179
206
  ctx.toast(ok ? 'success' : 'warn', ok ? 'Copied to clipboard.' : 'Clipboard not available.');
180
207
  },
181
208
  },
209
+ {
210
+ name: 'logs',
211
+ description: 'Show where this session is being logged',
212
+ run: (ctx) => {
213
+ const logger = getLogger();
214
+ const suffix = logger.sinkDisabled ? `\nLogging is OFF: ${logger.sinkError}` : '';
215
+ ctx.notify('info', `Log file: ${logger.currentLogPath()}\nLevel: ${logger.level}` +
216
+ ' (debug/trace record prompt and tool content)' +
217
+ suffix);
218
+ },
219
+ },
220
+ {
221
+ name: 'reload',
222
+ description: 'Re-read config.json (after editing it in another window)',
223
+ run: (ctx) => {
224
+ // REFUSED WHILE RUNNING, and not out of caution: swapping provider or
225
+ // model mid-turn would let the second half of an answer be produced under
226
+ // different settings than the first, with nothing afterwards able to
227
+ // explain why that answer looks the way it does.
228
+ if (ctx.state.status === 'running') {
229
+ ctx.notify('warn', 'Cannot reload the config while a run is in progress.');
230
+ return;
231
+ }
232
+ const config = loadConfig({ cwd: ctx.controller.getCwd() });
233
+ ctx.controller.setModel(config.provider, config.model, config.baseUrl);
234
+ ctx.controller.setThinkingLevel(config.thinkingLevel);
235
+ ctx.controller.setMaxTokens(config.maxTokens);
236
+ ctx.controller.setTheme(config.theme);
237
+ getLogger().reconfigure(config.log);
238
+ getLogger().info('config', 'reload', { provider: config.provider, model: config.model });
239
+ ctx.toast('success', `Reloaded config: ${config.provider}:${config.model}.`);
240
+ },
241
+ },
182
242
  {
183
243
  name: 'exit',
184
244
  aliases: ['quit'],
@@ -3,6 +3,7 @@
3
3
  * input is routed here instead of to the agent; `//` is the literal-`/` escape.
4
4
  */
5
5
  import type { AgentController } from '../agent/controller.js';
6
+ import type { AgentMode } from '../agent/agent-mode.js';
6
7
  import type { NoticeLevel, Overlay, ToastLevel, ViewAction, ViewState } from '../agent/reducer.js';
7
8
  import type { PersistedConfig } from '../config/schema.js';
8
9
  export interface CommandContext {
@@ -27,6 +28,22 @@ export interface CommandContext {
27
28
  submit: (text: string) => void;
28
29
  /** Rebuild the system prompt and the dynamic command list after a skill change. */
29
30
  refreshSkills: () => void;
31
+ /**
32
+ * Apply a mode change through the App's SINGLE write path (§3.1 / R-P7).
33
+ *
34
+ * `controller` and `dispatch` are both already in this context, so `/plan`
35
+ * COULD write the controller and dispatch the mirror itself — and that would
36
+ * be a second mode write path, which is the one thing the `setAgentMode`
37
+ * signature was shaped to forbid. One line here instead keeps `/plan` and
38
+ * `Shift+Tab` literally the same code.
39
+ *
40
+ * Returns the ADOPTED state, because `plan -> build` may be deferred and the
41
+ * command has to report what actually happened, not what it asked for.
42
+ */
43
+ applyAgentMode: (next: AgentMode) => {
44
+ effective: AgentMode;
45
+ pending: AgentMode | null;
46
+ };
30
47
  }
31
48
  export interface SlashCommand {
32
49
  name: string;
@@ -1,11 +1,78 @@
1
1
  /**
2
- * The `env-paths` application name, in one place.
2
+ * The user-state root, in one place.
3
3
  *
4
4
  * `config/store.ts` and `skills/paths.ts` each used to carry their own copy of
5
- * this string literal. Two copies of a name is exactly the shape that lets a
5
+ * the path arithmetic. Two copies of a name is exactly the shape that lets a
6
6
  * rename land in one of them and not the other, and the failure is silent: the
7
7
  * config file and the skills directory simply stop agreeing about where the
8
- * user's data lives.
8
+ * user's data lives. Everything below derives from `homeRoot`.
9
+ *
10
+ * LAYOUT (aragon-home-config-and-logging §4.1):
11
+ *
12
+ * ~/.aragon-agent/
13
+ * ├── config.json persisted config (0600 on POSIX)
14
+ * ├── config.json.bak `aragon config edit` pre-edit backup
15
+ * ├── logs/ JSONL logs (0700 on POSIX)
16
+ * ├── sessions/ /save · /resume
17
+ * ├── skills/ `aragon skills install` target
18
+ * └── skill-usage.json
19
+ *
20
+ * The same path on all three platforms, deliberately. Claude Code, git and npm
21
+ * all put their dot-directory in the user's home; one path means documentation,
22
+ * support scripts and community answers have exactly one thing to say. A user
23
+ * who wants XDG has `ARAGON_HOME=$XDG_CONFIG_HOME/aragon-agent`.
24
+ *
25
+ * ══════════════════════════════════════════════════════════════════════════
26
+ * TEST-ISOLATION CONTRACT — read before editing anything here or in the tests.
27
+ *
28
+ * `getHomeRoot()` returns the WHOLE user root, not just a config directory.
29
+ * Under the old `env-paths` layout the config directory held one file; here it
30
+ * holds the API key, every saved session and every installed skill. That makes
31
+ * a careless `rmSync(<root>, { recursive: true })` in a test an unrecoverable
32
+ * data loss on the developer's own machine, at a moment when nobody expects the
33
+ * filesystem to be touched at all. Four rules keep that impossible:
34
+ *
35
+ * 1. There is NO `getConfigDir()`. A function whose name says "config dir"
36
+ * must never hand back the user root. Code that needs the root calls
37
+ * `getHomeRoot()`, where the size of what you are holding is obvious.
38
+ * 2. `config.test.ts::clearEnv()` must EXEMPT `ARAGON_HOME` — deleting it
39
+ * would send any later, non-module-level resolution at the developer's
40
+ * real home.
41
+ * 3. Tests never recursively delete a path a function returned. Delete a
42
+ * single file (`rmSync(getConfigPath(), { force: true })`), or delete a
43
+ * directory the test itself built under `os.tmpdir()`.
44
+ * 4. The VITEST branch below. In a test process with no explicit
45
+ * `ARAGON_HOME`, the root is redirected into `os.tmpdir()`. Redirecting
46
+ * rather than throwing is deliberate: throwing would turn every unrelated
47
+ * test that merely imports this module red, and that cost is what tempts
48
+ * someone to delete the guard.
49
+ * ══════════════════════════════════════════════════════════════════════════
9
50
  */
10
51
  export declare const APP_NAME = "aragon-agent";
11
- export declare const appPaths: import("env-paths").Paths;
52
+ export declare const HOME_DIR_NAME = ".aragon-agent";
53
+ /**
54
+ * The pre-0.6.0 `env-paths` roots.
55
+ *
56
+ * ONLY the two migration modules may read this. Any other reference is a bug:
57
+ * it would point new code at the directory the migration exists to empty.
58
+ */
59
+ export declare const legacyEnvPaths: import("env-paths").Paths;
60
+ /** `~/.aragon-agent` (or `$ARAGON_HOME`). The root of everything below. */
61
+ export declare function getHomeRoot(): string;
62
+ export declare function getConfigPath(): string;
63
+ /** Written by `aragon config edit` before spawning the editor. */
64
+ export declare function getConfigBackupPath(): string;
65
+ export declare function getLogsDir(): string;
66
+ export declare function getSessionsDir(): string;
67
+ /** `<home>` — parent of `skills/`, and where sidecar state such as usage lives. */
68
+ export declare function getUserDataDir(): string;
69
+ export declare function getUserSkillsDir(): string;
70
+ /** Whether `ARAGON_HOME` was honoured. Captured at load — see `resolution`. */
71
+ export declare function isHomeOverridden(): boolean;
72
+ /**
73
+ * Why an `ARAGON_HOME` override was ignored, if it was.
74
+ *
75
+ * Path resolution happens before the logger exists, so the reason is parked
76
+ * here and written out by `installLogging()` once there is somewhere to put it.
77
+ */
78
+ export declare function getHomeResolutionWarning(): string | undefined;