@aragon-agent/cli 0.4.3 → 0.5.1

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 (82) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/README.md +227 -3
  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 +183 -20
  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 +45 -1
  23. package/dist/config/load.d.ts +8 -0
  24. package/dist/config/load.js +59 -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 +61 -0
  30. package/dist/config/schema.js +83 -0
  31. package/dist/config/store.d.ts +30 -12
  32. package/dist/config/store.js +47 -35
  33. package/dist/logging/cli-commands.d.ts +25 -0
  34. package/dist/logging/cli-commands.js +221 -0
  35. package/dist/logging/file-sink.d.ts +114 -0
  36. package/dist/logging/file-sink.js +326 -0
  37. package/dist/logging/install.d.ts +163 -0
  38. package/dist/logging/install.js +302 -0
  39. package/dist/logging/levels.d.ts +40 -0
  40. package/dist/logging/levels.js +59 -0
  41. package/dist/logging/logger.d.ts +110 -0
  42. package/dist/logging/logger.js +237 -0
  43. package/dist/logging/redact.d.ts +28 -0
  44. package/dist/logging/redact.js +90 -0
  45. package/dist/logging/secret-registry.d.ts +30 -0
  46. package/dist/logging/secret-registry.js +55 -0
  47. package/dist/skills/paths.d.ts +2 -4
  48. package/dist/skills/paths.js +7 -9
  49. package/dist/tools/human-input.d.ts +179 -0
  50. package/dist/tools/human-input.js +227 -0
  51. package/dist/tools/index.d.ts +35 -3
  52. package/dist/tools/index.js +96 -9
  53. package/dist/tools/plan-tools.d.ts +64 -0
  54. package/dist/tools/plan-tools.js +198 -0
  55. package/dist/ui/App.d.ts +4 -1
  56. package/dist/ui/App.js +182 -11
  57. package/dist/ui/Composer.d.ts +4 -1
  58. package/dist/ui/Composer.js +26 -7
  59. package/dist/ui/ModeChip.d.ts +29 -0
  60. package/dist/ui/ModeChip.js +15 -0
  61. package/dist/ui/PromptInput.d.ts +8 -1
  62. package/dist/ui/PromptInput.js +12 -2
  63. package/dist/ui/StatusBar.d.ts +15 -0
  64. package/dist/ui/StatusBar.js +10 -2
  65. package/dist/ui/entries/ToolPreview.d.ts +2 -0
  66. package/dist/ui/entries/ToolPreview.js +64 -0
  67. package/dist/ui/glyphs.d.ts +9 -0
  68. package/dist/ui/glyphs.js +8 -0
  69. package/dist/ui/layout/wrap-rows.d.ts +24 -0
  70. package/dist/ui/layout/wrap-rows.js +74 -0
  71. package/dist/ui/overlays/HelpOverlay.js +13 -0
  72. package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
  73. package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
  74. package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
  75. package/dist/ui/overlays/QuestionOverlay.js +193 -0
  76. package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
  77. package/dist/ui/overlays/SettingsScreen.js +8 -1
  78. package/dist/ui/use-startup-notices.d.ts +15 -0
  79. package/dist/ui/use-startup-notices.js +38 -0
  80. package/package.json +3 -2
  81. package/runtime/insecure-tls-warning.cjs +62 -0
  82. package/runtime/insecure-tls-warning.d.cts +5 -0
package/dist/cli.js CHANGED
@@ -14,12 +14,18 @@ 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';
@@ -31,6 +37,7 @@ import { readExitSnapshot } from './ui/exit-snapshot.js';
31
37
  import { renderTranscriptText } from './ui/transcript-text.js';
32
38
  import { detectCapabilities } from './ui/capabilities.js';
33
39
  import { pickGlyphs } from './ui/glyphs.js';
40
+ import { DENY_ALL_HUMAN_INPUT } from './tools/human-input.js';
34
41
  // ---------------------------------------------------------------------------
35
42
  // Version (read from the shipped package.json next to dist/)
36
43
  // ---------------------------------------------------------------------------
@@ -67,6 +74,12 @@ function toFlags(opts) {
67
74
  skill: opts.skill,
68
75
  skillsYes: opts.skillsYes,
69
76
  skillToolPolicy: opts.skillToolPolicy,
77
+ plan: opts.plan,
78
+ // `--verbose` is just a shorthand for `--log-level debug`; an explicit
79
+ // `--log-level` still wins, so the two can be combined without surprise.
80
+ logLevel: opts.logLevel ?? (opts.verbose ? 'debug' : undefined),
81
+ logFile: opts.logFile,
82
+ logDir: opts.logDir,
70
83
  };
71
84
  }
72
85
  // ---------------------------------------------------------------------------
@@ -92,7 +105,14 @@ function toFlags(opts) {
92
105
  */
93
106
  function makeController(flags, opts = { interactive: true }) {
94
107
  const config = loadConfig(flags);
108
+ // The authoritative log level lands here (§4.4.5). `installLogging()` only
109
+ // resolved a bootstrap level — it ran before commander and before `.env` were
110
+ // readable — and without this hand-off the fully resolved one never applies.
111
+ getLogger().reconfigure(config.log);
95
112
  const confirmBridge = { handler: null };
113
+ // `cancelPending` is replaced by the App's own implementation on mount; this
114
+ // no-op keeps the object total so nothing has to null-check it.
115
+ const humanInputBridge = { handler: null, cancelPending: () => { } };
96
116
  const controller = new AgentController(config, {
97
117
  version: VERSION,
98
118
  confirm: (req) => confirmBridge.handler ? confirmBridge.handler(req) : Promise.resolve(true),
@@ -109,11 +129,23 @@ function makeController(flags, opts = { interactive: true }) {
109
129
  if (!opts.interactive)
110
130
  process.stderr.write(`[skills] ${level}: ${text}\n`);
111
131
  },
132
+ // A THIRD approval channel, and it must not be conflated with the other two.
133
+ // `confirm` auto-approves when unattached and `approval` denies; neither can
134
+ // render a question wizard or a plan card. Headless gets the fail-closed
135
+ // gate, whose STATIC `neverPrompts` is also what stops `createPlanTools`
136
+ // registering two tools the model could never use there.
137
+ humanInput: opts.interactive
138
+ ? {
139
+ // Live probe, never cached — same contract as `approval` above.
140
+ canPrompt: () => humanInputBridge.handler !== null,
141
+ request: (req, signal) => humanInputBridge.handler
142
+ ? humanInputBridge.handler(req, signal)
143
+ : Promise.resolve(null),
144
+ }
145
+ : DENY_ALL_HUMAN_INPUT,
112
146
  });
113
- return { controller, confirmBridge };
147
+ return { controller, confirmBridge, humanInputBridge };
114
148
  }
115
- /** POSIX signal numbers for the `128 + signo` exit-code convention. */
116
- const SIGNAL_NUMBERS = { SIGINT: 2, SIGTERM: 15, SIGHUP: 1 };
117
149
  /**
118
150
  * The interactive TUI — and the ONLY place that may touch the screen.
119
151
  *
@@ -124,9 +156,12 @@ const SIGNAL_NUMBERS = { SIGINT: 2, SIGTERM: 15, SIGHUP: 1 };
124
156
  * renders Ink, `runOneShot()` goes to `runHeadless()`, which never does.
125
157
  */
126
158
  function runInteractive(flags, extras = {}) {
127
- const { controller, confirmBridge } = makeController(flags);
159
+ const { controller, confirmBridge, humanInputBridge } = makeController(flags);
128
160
  const config = controller.getConfig();
129
161
  const mode = decideRenderMode({ fullscreen: config.fullscreen }, process.env, process.stdout);
162
+ const logger = getLogger();
163
+ logger.info('cli', 'run_interactive', { mode, provider: config.provider, model: config.model });
164
+ attachAgentEvents(logger, controller);
130
165
  let screen = null;
131
166
  let replayed = false;
132
167
  const replayTranscript = () => {
@@ -158,14 +193,19 @@ function runInteractive(flags, extras = {}) {
158
193
  // parent all leave the user staring at a blank alternate screen with `reset`
159
194
  // as their only way out.
160
195
  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
- }
196
+ // The signal listeners themselves now belong to `installLogging()`, which
197
+ // registered them before this function ever ran — and a second listener per
198
+ // signal would race the first. This branch owns the SCREEN, so it supplies
199
+ // the two screen-shaped pieces and nothing else: the crash handler calls
200
+ // `screenRestore` before printing a stack (otherwise the stack vanishes with
201
+ // the alternate screen), and the signal path restores before exiting.
202
+ setScreenRestore(restore);
203
+ setSignalTerminator((signo) => {
204
+ restore(); // Synchronous: stdout.write on a TTY is sync on every platform.
205
+ process.exit(128 + signo);
206
+ });
167
207
  }
168
- const instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay }),
208
+ const instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay }),
169
209
  // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
170
210
  // straight to stdout and permanently shifts the fixed frame's accounting.
171
211
  { exitOnCtrlC: false, patchConsole: mode === 'inline' });
@@ -186,11 +226,23 @@ async function runOneShot(flags, prompt, quiet) {
186
226
  // install. The gate must know that up front rather than discovering a null
187
227
  // handler and defaulting to "yes" (§7.4 / AC-13).
188
228
  const { controller } = makeController(flags, { interactive: false });
229
+ const logger = getLogger();
230
+ logger.info('cli', 'run_oneshot', { quiet });
231
+ // Headless has no frame, so a logging failure can safely reach stderr — the
232
+ // toast channel `App.tsx` uses does not exist here.
233
+ logger.onFailure((reason) => process.stderr.write(`[log] ${reason}\n`));
234
+ attachAgentEvents(logger, controller);
189
235
  const untrusted = controller.getSkillService().untrustedDirs();
190
236
  for (const dir of untrusted) {
191
237
  process.stderr.write(`[skills] project skills in ${dir} skipped (untrusted). Run: aragon skills trust ${dir}\n`);
192
238
  }
193
239
  const code = await runHeadless(controller, prompt, { quiet });
240
+ // Flushed HERE and not inside `runHeadless`: that module is deliberately
241
+ // injectable (its own controller interface, its own stdout/stderr) and has an
242
+ // early `return 2`, so a singleton dependency in there would be both a new
243
+ // coupling and a branch that skips the flush. `process.on('exit')` remains the
244
+ // real backstop; this just makes the common path immediate.
245
+ getLogger().flushSync();
194
246
  process.exitCode = code;
195
247
  }
196
248
  async function readStdin() {
@@ -247,6 +299,9 @@ const CONFIG_SET_KEYS = new Set([
247
299
  // flags and never persist -- inconsistent with every other config field.
248
300
  'density',
249
301
  'hints',
302
+ 'planModeDefault',
303
+ 'planModeMaxAskRounds',
304
+ 'planModeHumanTimeoutMs',
250
305
  // Dotted keys route into the nested `skills` object; see the switch below.
251
306
  'skills.enabled',
252
307
  'skills.requireApproval',
@@ -255,6 +310,9 @@ const CONFIG_SET_KEYS = new Set([
255
310
  'skills.integrity',
256
311
  'skills.usageTracking',
257
312
  'skills.toolPolicy',
313
+ // `log.*` keys are handled by `applyLogConfigSet` below, NOT by the switch:
314
+ // that switch is already past this package's complexity ceiling.
315
+ ...LOG_CONFIG_SET_KEYS,
258
316
  ]);
259
317
  function runConfigSet(key, value) {
260
318
  if (!CONFIG_SET_KEYS.has(key)) {
@@ -262,6 +320,13 @@ function runConfigSet(key, value) {
262
320
  process.exitCode = 2;
263
321
  return;
264
322
  }
323
+ const logPatch = applyLogConfigSet(key, value);
324
+ if (logPatch) {
325
+ updatePersistedConfig(logPatch);
326
+ warnIfRedactionDisabled(key, value);
327
+ process.stdout.write(`Set ${key} = ${value}\n`);
328
+ return;
329
+ }
265
330
  const patch = {};
266
331
  switch (key) {
267
332
  case 'provider':
@@ -306,6 +371,19 @@ function runConfigSet(key, value) {
306
371
  case 'hints':
307
372
  patch.hints = value === 'true' || value === '1';
308
373
  break;
374
+ // Clamped rather than rejected, for the same reason as `theme` and
375
+ // `skills.integrity`: hardening only the READ path leaves a bad value on
376
+ // disk that reverts to the default on every launch, which presents as
377
+ // "my setting won't stick".
378
+ case 'planModeDefault':
379
+ patch.planModeDefault = value === 'true' || value === '1';
380
+ break;
381
+ case 'planModeMaxAskRounds':
382
+ patch.planModeMaxAskRounds = clampAskRounds(value, DEFAULT_PLAN_MAX_ASK_ROUNDS);
383
+ break;
384
+ case 'planModeHumanTimeoutMs':
385
+ patch.planModeHumanTimeoutMs = clampHumanTimeout(value, DEFAULT_PLAN_HUMAN_TIMEOUT_MS);
386
+ break;
309
387
  // The dotted keys write a PARTIAL `skills` object. That is safe only
310
388
  // because `updatePersistedConfig` deep-merges this section (§10.3.2) —
311
389
  // with the old shallow merge each of these would wipe trustedProjectDirs.
@@ -380,10 +458,25 @@ function buildProgram() {
380
458
  .option('--fullscreen', 'Force the full-screen TUI (overrides auto-downgrade, except non-TTY)')
381
459
  .option('--no-fullscreen', 'Force the inline renderer (v0.2.0 behavior)')
382
460
  .option('--no-exit-transcript', 'Do not replay the session summary after exiting')
461
+ // Same tri-state shape as `--fullscreen`: the positive form is declared
462
+ // FIRST so the default stays `undefined` and "no opinion" remains
463
+ // distinguishable from an explicit `--no-plan`, which is what lets the
464
+ // config layer win when neither flag was given.
465
+ .option('--plan', 'Start the session in PLAN mode (read-only research + review)')
466
+ .option('--no-plan', 'Start the session in BUILD mode (overrides planModeDefault)')
383
467
  .option('--no-skills', 'Disable the skill system entirely (no catalog, no skill tools)')
384
468
  .option('--skill <name>', 'Force-load a skill at Level 2 for this run (repeatable)', (value, previous = []) => [...previous, value])
385
469
  .option('--skills-yes', 'Approve skill installs for this run (CI / headless)')
386
470
  .option('--skill-tool-policy <mode>', 'Tool ceiling from allowed-tools for this run: off | warn | enforce (not persisted)')
471
+ // `--log-file` is declared BEFORE `--no-log-file` for the tri-state reason
472
+ // recorded twice above: negative-only would default it to `true` and make a
473
+ // persisted `log.toFile: false` permanently unreachable.
474
+ .option('--log-file', 'Write this run to a log file')
475
+ .option('--no-log-file', 'Do not write this run to a log file')
476
+ .option('--log-level <level>', 'silent|error|warn|info|debug|trace (this run only)')
477
+ // No short form: `-v` is already `--version`.
478
+ .option('--verbose', 'Shorthand for --log-level debug')
479
+ .option('--log-dir <dir>', 'Absolute log directory for this run')
387
480
  .option('--no-color', 'Disable ANSI color')
388
481
  .option('--quiet', '(print mode) suppress tool/usage lines on stderr')
389
482
  .action(async (prompt, opts) => {
@@ -433,6 +526,37 @@ function buildProgram() {
433
526
  .action(() => {
434
527
  process.stdout.write(`${getConfigPath()}\n`);
435
528
  });
529
+ configCmd
530
+ .command('get <key>')
531
+ .description('Print a single config value (secrets are masked)')
532
+ .action((key) => runConfigGet(key));
533
+ configCmd
534
+ .command('list')
535
+ .description('Print the whole config (secrets are masked)')
536
+ .option('--json', 'Machine-readable output')
537
+ .action((opts) => runConfigList(opts));
538
+ configCmd
539
+ .command('edit')
540
+ .description('Open config.json in $VISUAL / $EDITOR (backs it up first)')
541
+ .action(async () => {
542
+ await runConfigEdit();
543
+ });
544
+ configCmd
545
+ .command('home')
546
+ .description('Print the user-state root directory')
547
+ .action(() => runConfigHome());
548
+ // aragon logs <sub> [-n N] [--follow] [--level lv] [--json] [--yes]
549
+ program
550
+ .command('logs [subcommand]')
551
+ .description('Inspect the log files: path | list | tail | clear | open')
552
+ .option('-n, --lines <n>', 'Lines for `logs tail` (default 100)')
553
+ .option('--follow', 'Keep printing new records (`logs tail`)')
554
+ .option('--level <level>', 'Only show records at this level or more severe')
555
+ .option('--json', 'Print raw JSONL instead of rendered lines')
556
+ .option('--yes', 'Confirm destructive actions (required by `logs clear`)')
557
+ .action(async (subcommand, opts) => {
558
+ process.exitCode = await runLogsCommand(subcommand ?? 'path', opts, toFlags(program.opts()));
559
+ });
436
560
  // aragon skills <sub> [arg] [--scope] [--name] [--description] [--yes] [--json]
437
561
  program
438
562
  .command('skills [subcommand] [argument]')
@@ -466,16 +590,55 @@ function buildProgram() {
466
590
  return program;
467
591
  }
468
592
  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`);
593
+ // ① and ② are the first two statements on purpose: everything below reads
594
+ // config or skills. THE ORDER IS NOT INTERCHANGEABLE — ① moves 0.4.x data out
595
+ // of the pre-rename env-paths tree, and only then does ② have anything to
596
+ // move into `~/.aragon-agent`. Run the other way round, `config.json` is
597
+ // stranded permanently (see migrate-home.ts). Neither ever throws.
598
+ const legacy = migrateLegacyState();
599
+ const home = migrateToHome();
600
+ for (const notice of [formatMigrationNotice(legacy), formatHomeMigrationNotice(home)]) {
601
+ if (notice)
602
+ process.stderr.write(`${notice}\n`);
603
+ }
604
+ // ③ comes after both because the log directory lives inside a home that ②
605
+ // may just have created. What ① and ② did is replayed from `pending` rather
606
+ // than lost.
607
+ installLogging({ pending: migrationNotes(legacy, home) });
608
+ // An unparseable config.json still falls back to defaults — the CLI has to
609
+ // start — but it must not do so in silence, or a stray comma presents as
610
+ // "every setting I ever chose is gone". stderr, never stdout, so
611
+ // `aragon -p "…" > out.txt` keeps carrying model output and nothing else.
612
+ const { parseError } = readConfigFile();
613
+ if (parseError) {
614
+ process.stderr.write(`config: using defaults, could not parse ${parseError}\n`);
615
+ }
476
616
  const program = buildProgram();
477
617
  await program.parseAsync(process.argv);
478
618
  }
619
+ /** The ①/② outcomes, as records for `installLogging` to write once it exists. */
620
+ function migrationNotes(legacy, home) {
621
+ const notes = [
622
+ { level: 'info', scope: 'cli', msg: 'cli_start', data: { version: VERSION } },
623
+ ];
624
+ if (legacy.mode !== 'none' || legacy.error) {
625
+ notes.push({
626
+ level: legacy.error ? 'warn' : 'info',
627
+ scope: 'migrate',
628
+ msg: 'migrate_brand',
629
+ data: { ...legacy },
630
+ });
631
+ }
632
+ if (home.mode !== 'none' || home.error) {
633
+ notes.push({
634
+ level: home.error ? 'warn' : 'info',
635
+ scope: 'migrate',
636
+ msg: 'migrate_home',
637
+ data: { ...home },
638
+ });
639
+ }
640
+ return notes;
641
+ }
479
642
  main().catch((err) => {
480
643
  process.stderr.write(`${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
481
644
  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;