@aragon-agent/cli 0.5.3 → 0.5.6

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 (66) hide show
  1. package/CHANGELOG.md +670 -540
  2. package/README.md +1105 -932
  3. package/dist/agent/controller.d.ts +71 -1
  4. package/dist/agent/controller.js +135 -1
  5. package/dist/agent/headless.d.ts +29 -0
  6. package/dist/agent/headless.js +86 -1
  7. package/dist/agent/reducer.d.ts +45 -0
  8. package/dist/agent/reducer.js +80 -1
  9. package/dist/agent/system-prompt.d.ts +8 -0
  10. package/dist/agent/system-prompt.js +2 -0
  11. package/dist/cli.js +72 -6
  12. package/dist/commands/builtins.d.ts +2 -2
  13. package/dist/commands/builtins.js +217 -3
  14. package/dist/commands/registry.d.ts +27 -1
  15. package/dist/config/cli-commands.js +20 -2
  16. package/dist/config/env.d.ts +2 -1
  17. package/dist/config/env.js +60 -5
  18. package/dist/config/load.d.ts +27 -0
  19. package/dist/config/load.js +67 -2
  20. package/dist/config/schema.d.ts +111 -2
  21. package/dist/config/schema.js +111 -2
  22. package/dist/config/store.js +37 -1
  23. package/dist/session/persist.d.ts +19 -1
  24. package/dist/session/persist.js +21 -2
  25. package/dist/team/subagent.js +11 -0
  26. package/dist/todo/follow-through.d.ts +117 -0
  27. package/dist/todo/follow-through.js +186 -0
  28. package/dist/todo/limits.d.ts +114 -0
  29. package/dist/todo/limits.js +114 -0
  30. package/dist/todo/normalize.d.ts +33 -0
  31. package/dist/todo/normalize.js +117 -0
  32. package/dist/todo/panel-rows.d.ts +53 -0
  33. package/dist/todo/panel-rows.js +103 -0
  34. package/dist/todo/prompt.d.ts +54 -0
  35. package/dist/todo/prompt.js +76 -0
  36. package/dist/todo/store.d.ts +86 -0
  37. package/dist/todo/store.js +183 -0
  38. package/dist/todo/todo-tool.d.ts +38 -0
  39. package/dist/todo/todo-tool.js +134 -0
  40. package/dist/todo/types.d.ts +62 -0
  41. package/dist/todo/types.js +12 -0
  42. package/dist/tools/index.d.ts +16 -2
  43. package/dist/tools/index.js +13 -0
  44. package/dist/ui/App.d.ts +22 -1
  45. package/dist/ui/App.js +292 -13
  46. package/dist/ui/StatusBar.d.ts +18 -0
  47. package/dist/ui/StatusBar.js +5 -2
  48. package/dist/ui/TodoPanel.d.ts +48 -0
  49. package/dist/ui/TodoPanel.js +74 -0
  50. package/dist/ui/TodoStrip.d.ts +36 -0
  51. package/dist/ui/TodoStrip.js +32 -0
  52. package/dist/ui/Transcript.js +11 -1
  53. package/dist/ui/entries/TodoCard.d.ts +31 -0
  54. package/dist/ui/entries/TodoCard.js +35 -0
  55. package/dist/ui/glyphs.d.ts +12 -0
  56. package/dist/ui/glyphs.js +8 -0
  57. package/dist/ui/layout/AppShell.d.ts +34 -1
  58. package/dist/ui/layout/AppShell.js +3 -3
  59. package/dist/ui/layout/ScrollViewport.d.ts +13 -1
  60. package/dist/ui/layout/ScrollViewport.js +2 -2
  61. package/dist/ui/layout/rail.d.ts +56 -0
  62. package/dist/ui/layout/rail.js +68 -0
  63. package/dist/ui/overlays/HelpOverlay.js +5 -0
  64. package/dist/ui/overlays/SettingsScreen.d.ts +12 -1
  65. package/dist/ui/overlays/SettingsScreen.js +38 -1
  66. package/package.json +3 -2
@@ -53,6 +53,14 @@ export interface SystemPromptParams {
53
53
  * mixing them a one-character mistake.
54
54
  */
55
55
  subagentBlock?: string;
56
+ /**
57
+ * The rendered `<todo_planning>` block, or `''` (todo-plan-execution §3.7).
58
+ *
59
+ * Spliced conditionally exactly as `skillsBlock`, the plan block and
60
+ * `teamBlock` are, which is what keeps C-10 / I-5 true: `todo.enabled: false`
61
+ * (and `--no-todo`) produce the pre-todo prompt BYTE FOR BYTE.
62
+ */
63
+ todoBlock?: string;
56
64
  }
57
65
  /** Describe the active shell so the model does not emit incompatible syntax. */
58
66
  export declare function describeShell(): {
@@ -39,6 +39,7 @@ export function buildSystemPrompt(params) {
39
39
  : '';
40
40
  const teamBlock = params.teamBlock ?? '';
41
41
  const subagentBlock = params.subagentBlock ?? '';
42
+ const todoBlock = params.todoBlock ?? '';
42
43
  return [
43
44
  'You are AragonAgent, an autonomous coding assistant operating inside a terminal (TUI).',
44
45
  'You help the user accomplish software-engineering tasks by reasoning step by step and using the provided tools.',
@@ -54,6 +55,7 @@ export function buildSystemPrompt(params) {
54
55
  ...(planBlock ? ['', planBlock] : []),
55
56
  ...(teamBlock ? ['', teamBlock] : []),
56
57
  ...(subagentBlock ? ['', subagentBlock] : []),
58
+ ...(todoBlock ? ['', todoBlock] : []),
57
59
  '',
58
60
  'Operating guidance:',
59
61
  '- Prefer reading files and inspecting the workspace before making changes.',
package/dist/cli.js CHANGED
@@ -27,7 +27,7 @@ import { LOG_CONFIG_SET_KEYS, TEAM_CONFIG_SET_KEYS, applyLogConfigSet, applyTeam
27
27
  import { attachAgentEvents, attachTeamEvents, installLogging, setScreenRestore, setSignalTerminator, } from './logging/install.js';
28
28
  import { getLogger } from './logging/logger.js';
29
29
  import { runLogsCommand } from './logging/cli-commands.js';
30
- 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';
30
+ import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, parseMaxTokensInput, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SKILLS_CONFIG, } from './config/schema.js';
31
31
  import { makeGetApiKey } from './config/load.js';
32
32
  import { AgentController, DENY_ALL_APPROVAL } from './agent/controller.js';
33
33
  import { runSkillsCommand } from './skills/cli-commands.js';
@@ -86,6 +86,17 @@ function toFlags(opts) {
86
86
  // reach `CliFlags` and are inert, with no compile error and no runtime error.
87
87
  team: opts.team,
88
88
  teamMax: opts.teamMax,
89
+ // Site three of three for `--todo` / `--todo-panel`, and the one this
90
+ // package has now paid for twice (`mouse` P1-2, `team` P1-4): a flag needs
91
+ // the `.option()` declaration below, a field on `CliFlags`, AND a line here.
92
+ // Miss this one and the flag is inert with no compile error and no runtime
93
+ // error.
94
+ todo: opts.todo,
95
+ todoPanel: opts.todoPanel,
96
+ // Site three of three for `--todo-follow` as well (todo-plan-followthrough
97
+ // C-9). A value flag is no less inert than a boolean one when this line is
98
+ // missing.
99
+ todoFollow: opts.todoFollow,
89
100
  // `--verbose` is just a shorthand for `--log-level debug`; an explicit
90
101
  // `--log-level` still wins, so the two can be combined without surprise.
91
102
  logLevel: opts.logLevel ?? (opts.verbose ? 'debug' : undefined),
@@ -120,12 +131,21 @@ function makeController(flags, opts = { interactive: true }) {
120
131
  // resolved a bootstrap level — it ran before commander and before `.env` were
121
132
  // readable — and without this hand-off the fully resolved one never applies.
122
133
  getLogger().reconfigure(config.log);
134
+ // Whether this session could EVER show the todo rail (todo-plan-execution
135
+ // §3.7 / P1-6). It varies ONE sentence of `<todo_planning>`; `decideRenderMode`
136
+ // is pure and `runInteractive` calls it again with the same inputs, so
137
+ // computing it here rather than threading it back costs nothing and keeps the
138
+ // controller's construction self-contained.
139
+ const todoPanelCapable = opts.interactive &&
140
+ decideRenderMode({ fullscreen: config.fullscreen }, process.env, process.stdout) ===
141
+ 'fullscreen';
123
142
  const confirmBridge = { handler: null };
124
143
  // `cancelPending` is replaced by the App's own implementation on mount; this
125
144
  // no-op keeps the object total so nothing has to null-check it.
126
145
  const humanInputBridge = { handler: null, cancelPending: () => { } };
127
146
  const controller = new AgentController(config, {
128
147
  version: VERSION,
148
+ todoPanelCapable,
129
149
  confirm: (req) => confirmBridge.handler ? confirmBridge.handler(req) : Promise.resolve(true),
130
150
  approval: opts.interactive
131
151
  ? {
@@ -308,7 +328,13 @@ async function runOneShot(flags, prompt, quiet) {
308
328
  for (const dir of untrusted) {
309
329
  process.stderr.write(`[skills] project skills in ${dir} skipped (untrusted). Run: aragon skills trust ${dir}\n`);
310
330
  }
311
- const code = await runHeadless(controller, prompt, { quiet });
331
+ // `followThrough` is read off the REAL controller here rather than through
332
+ // `HeadlessController`, which is deliberately the minimal surface a test can
333
+ // satisfy with an object literal (todo-plan-followthrough §3.6).
334
+ const code = await runHeadless(controller, prompt, {
335
+ quiet,
336
+ followThrough: controller.getTodoConfig().followThrough,
337
+ });
312
338
  // Flushed HERE and not inside `runHeadless`: that module is deliberately
313
339
  // injectable (its own controller interface, its own stdout/stderr) and has an
314
340
  // early `return 2`, so a singleton dependency in there would be both a new
@@ -397,6 +423,29 @@ const CONFIG_SET_KEYS = new Set([
397
423
  // still printing `Set team.enabled = false`.
398
424
  ...TEAM_CONFIG_SET_KEYS,
399
425
  ]);
426
+ /**
427
+ * `aragon config set maxTokens <n|auto|default>`.
428
+ *
429
+ * REJECTS nonsense rather than clamping it, unlike `theme` and `thinkingLevel`.
430
+ * Those two clamp because their bad values are out-of-VOCABULARY; here a
431
+ * non-numeric argument is almost always a typo for a number the user meant, and
432
+ * storing 64000 for `3200O` would quietly discard their intent. An out-of-RANGE
433
+ * number is still clamped, in both directions, by the store's single gate.
434
+ */
435
+ function runConfigSetMaxTokens(value) {
436
+ const raw = value.trim().toLowerCase();
437
+ const parsed = raw === 'default'
438
+ ? { kind: 'value', value: DEFAULT_MAX_TOKENS, clamped: false }
439
+ : parseMaxTokensInput(value);
440
+ if (parsed.kind === 'invalid') {
441
+ process.stderr.write(`Invalid maxTokens "${value}". Use a number, "auto", or "default".\n`);
442
+ process.exitCode = 2;
443
+ return;
444
+ }
445
+ const stored = parsed.kind === 'auto' ? null : parsed.value;
446
+ const merged = updatePersistedConfig({ maxTokens: stored });
447
+ process.stdout.write(`Set maxTokens = ${merged.maxTokens ?? 'auto'}\n`);
448
+ }
400
449
  function runConfigSet(key, value) {
401
450
  if (!CONFIG_SET_KEYS.has(key)) {
402
451
  process.stderr.write(`Unknown config key "${key}". Known keys: ${[...CONFIG_SET_KEYS].join(', ')}.\n`);
@@ -410,6 +459,13 @@ function runConfigSet(key, value) {
410
459
  process.stdout.write(`Set ${key} = ${value}\n`);
411
460
  return;
412
461
  }
462
+ // Handled ahead of the switch because it needs to echo the STORED value, not
463
+ // the typed one: `config set maxTokens 900000` must not report a number that
464
+ // was never written. Same reason `team.*` has its own branch.
465
+ if (key === 'maxTokens') {
466
+ runConfigSetMaxTokens(value);
467
+ return;
468
+ }
413
469
  const teamPatch = applyTeamConfigSet(key, value);
414
470
  if (teamPatch) {
415
471
  // The written value is echoed from the CLAMPED result rather than from the
@@ -434,9 +490,6 @@ function runConfigSet(key, value) {
434
490
  case 'thinkingLevel':
435
491
  patch.thinkingLevel = clampThinkingLevel(value, 'off');
436
492
  break;
437
- case 'maxTokens':
438
- patch.maxTokens = coerceMaxTokens(value) ?? null;
439
- break;
440
493
  case 'theme':
441
494
  patch.theme = clampTheme(value, 'auto');
442
495
  break;
@@ -540,7 +593,7 @@ function buildProgram() {
540
593
  .option('--base-url <url>', 'Override the provider base URL')
541
594
  .option('--api-key <key>', 'One-shot API key override (not persisted)')
542
595
  .option('--thinking <level>', 'off|minimal|low|medium|high|xhigh')
543
- .option('--max-tokens <n>', 'Output token cap')
596
+ .option('--max-tokens <n|auto>', 'Output token cap: a number, or "auto" for the model ceiling (default 64000)')
544
597
  .option('--cwd <dir>', 'Working directory for tools')
545
598
  .option('--confirm', 'Confirm each mutating tool call')
546
599
  .option('--tool-timeout <ms>', 'Per-tool executor ceiling (default 180000)')
@@ -575,6 +628,19 @@ function buildProgram() {
575
628
  .option('--team', 'Enable team subagents for this session (the default)')
576
629
  .option('--no-team', 'Disable team subagents (the task tool is not registered at all)')
577
630
  .option('--team-max <n>', 'Max subagents per dispatch for this run (1-10)')
631
+ // BOTH PAIRS, positive first (todo-plan-execution §4.3 / P1-8). `--todo` is
632
+ // the `--team` story verbatim; `--todo-panel` needs the pair for a second
633
+ // reason on top of it — `panel` is PERSISTED, so a lone `--no-todo-panel`
634
+ // would make commander default `opts.todoPanel` to `true` and silently
635
+ // overwrite a stored `panel: false` on every run that passed no flag.
636
+ .option('--todo', 'Enable todo planning for this session (the default)')
637
+ .option('--no-todo', 'Disable todo planning (the todo_write tool is not registered at all)')
638
+ .option('--todo-panel', 'Show the todo plan in a right-hand rail (the default)')
639
+ .option('--no-todo-panel', 'Keep todo planning but do not render the rail')
640
+ // NOT A PAIR, and it does not need to be: a value flag is absent unless
641
+ // passed, so `!== undefined` in `resolveTodoConfig` already distinguishes it
642
+ // from silence. An unrecognized mode is clamped to `notify`, not rejected.
643
+ .option('--todo-follow <mode>', 'What to do when a run ends with unfinished steps: notify (default) | auto | off')
578
644
  .option('--no-skills', 'Disable the skill system entirely (no catalog, no skill tools)')
579
645
  .option('--skill <name>', 'Force-load a skill at Level 2 for this run (repeatable)', (value, previous = []) => [...previous, value])
580
646
  .option('--skills-yes', 'Approve skill installs for this run (CI / headless)')
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Built-in slash commands (spec §5.2):
3
- * /help /model /settings /thinking /tools /clear /reset /cwd /save /resume
4
- * /copy /exit (/quit).
3
+ * /help /model /settings /thinking /max-tokens /tools /clear /reset /cwd /save
4
+ * /resume /copy /exit (/quit).
5
5
  */
6
6
  import { CommandRegistry } from './registry.js';
7
7
  export declare function registerBuiltinCommands(registry: CommandRegistry): void;
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * Built-in slash commands (spec §5.2):
3
- * /help /model /settings /thinking /tools /clear /reset /cwd /save /resume
4
- * /copy /exit (/quit).
3
+ * /help /model /settings /thinking /max-tokens /tools /clear /reset /cwd /save
4
+ * /resume /copy /exit (/quit).
5
5
  */
6
6
  import { spawn } from 'node:child_process';
7
7
  import { existsSync, statSync } from 'node:fs';
8
8
  import { isAbsolute, resolve } from 'node:path';
9
9
  import process from 'node:process';
10
- import { clampThinkingLevel, clampTheme, isThemeName, LEGACY_DARK_THEME, THEME_NAMES, THINKING_LEVELS, } from '../config/schema.js';
10
+ import { resolveOutputTokens } from '@aragon-agent/core';
11
+ import { clampThinkingLevel, clampTheme, isThemeName, parseMaxTokensInput, DEFAULT_MAX_TOKENS, LEGACY_DARK_THEME, THEME_NAMES, THINKING_LEVELS, } from '../config/schema.js';
12
+ import { TODO_FOLLOW_LIMITS } from '../todo/limits.js';
13
+ import { buildContinuationMessage } from '../todo/follow-through.js';
11
14
  import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
12
15
  import { loadConfig } from '../config/load.js';
13
16
  import { loadSession, resolveSessionPath, saveSession } from '../session/persist.js';
@@ -43,6 +46,53 @@ const COMMANDS = [
43
46
  ctx.toast('info', `Thinking level set to "${clamped}".`);
44
47
  },
45
48
  },
49
+ {
50
+ name: 'max-tokens',
51
+ description: 'Show or set the output token cap (<n> | auto | default)',
52
+ /**
53
+ * Exists next to the settings screen for the reason `/theme` and `/plan` do:
54
+ * the slash palette is the discoverable surface, and this is one keystroke
55
+ * instead of five arrow presses.
56
+ *
57
+ * The no-argument branch reports the EFFECTIVE cap, not just the setting.
58
+ * "I set 64000" and "this model will produce 8192" are different facts, and
59
+ * only the second one explains a truncated answer.
60
+ */
61
+ run: (ctx) => {
62
+ const arg = ctx.args.trim();
63
+ const cfg = ctx.controller.getConfig();
64
+ if (!arg) {
65
+ const resolution = resolveOutputTokens({
66
+ providerId: cfg.provider,
67
+ modelId: cfg.model,
68
+ ...(cfg.maxTokens !== undefined ? { requested: cfg.maxTokens } : {}),
69
+ });
70
+ const setting = cfg.maxTokens === undefined ? 'auto' : String(cfg.maxTokens);
71
+ const why = resolution.clampedBy === 'ceiling' ? ` (${cfg.model} ceiling)` : '';
72
+ ctx.notify('info', `Max tokens: ${setting}. Effective for ${cfg.provider}:${cfg.model}: ` +
73
+ `${resolution.value}${why}.\nUse /max-tokens <n|auto|default>.`);
74
+ return;
75
+ }
76
+ const parsed = arg.toLowerCase() === 'default'
77
+ ? { kind: 'value', value: DEFAULT_MAX_TOKENS, clamped: false }
78
+ : parseMaxTokensInput(arg);
79
+ if (parsed.kind === 'invalid') {
80
+ // Change NOTHING on a typo: silently resolving it to a default would
81
+ // discard the number the user meant to type.
82
+ ctx.notify('warn', `Invalid value "${arg}" - use /max-tokens <n|auto|default>.`);
83
+ return;
84
+ }
85
+ if (parsed.kind === 'auto') {
86
+ ctx.controller.setMaxTokens(undefined);
87
+ ctx.persistConfig({ maxTokens: null });
88
+ ctx.toast('success', 'Max tokens: auto (per-model ceiling).');
89
+ return;
90
+ }
91
+ ctx.controller.setMaxTokens(parsed.value);
92
+ ctx.persistConfig({ maxTokens: parsed.value });
93
+ ctx.toast('success', `Max tokens set to ${parsed.value}.`);
94
+ },
95
+ },
46
96
  {
47
97
  name: 'theme',
48
98
  description: 'Switch the color theme (auto|warm|cool|light)',
@@ -172,6 +222,160 @@ const COMMANDS = [
172
222
  ctx.notify('warn', `Unknown argument "${verb}" - use /team [status|on|off|max <n>].`);
173
223
  },
174
224
  },
225
+ {
226
+ name: 'todo',
227
+ description: 'Todo planning: status | on | off | panel on|off | follow <mode> | clear | continue',
228
+ /**
229
+ * `/todo on` CANNOT turn planning on in a session that started with
230
+ * `--no-todo`, for the reason `/team on` above records at length: the tool
231
+ * array is built ONCE and rebuilding it is forbidden (C-1), while
232
+ * `rebuildSystemPrompt()` IS a live path — so the naive reading produces a
233
+ * session where the model is told about a `todo_write` that was never
234
+ * registered.
235
+ *
236
+ * TWO CALLS, IN THIS ORDER, FOR BOTH `on|off` AND `panel on|off`, and
237
+ * neither pair is optional:
238
+ *
239
+ * `setTodoEnabled` re-splices `<todo_planning>` (P1-1). Without it the
240
+ * block survives `/todo off` and every call comes back
241
+ * refused while the instructions to keep calling are
242
+ * still in the model's context.
243
+ * `setTodoConfig` updates the object `App` reads at RENDER time (P1-2).
244
+ * `persistConfig` only writes the file, so without it
245
+ * `/todo panel off` reports success and changes nothing
246
+ * until the next launch.
247
+ * `persistConfig` is what survives the session, in both cases.
248
+ */
249
+ run: (ctx) => {
250
+ const arg = ctx.args.trim().toLowerCase();
251
+ const [verb, value] = arg.split(/\s+/, 2);
252
+ const controller = ctx.controller;
253
+ if (!verb || verb === 'status') {
254
+ const snapshot = controller.getTodoSnapshot();
255
+ const state = !controller.isTodoRegistered()
256
+ ? 'off for this session (started with --no-todo)'
257
+ : controller.isTodoEnabled()
258
+ ? 'on'
259
+ : 'off';
260
+ const todoCfg = controller.getTodoConfig();
261
+ const panel = todoCfg.panel ? 'on' : 'off';
262
+ // THE COUNTERS APPEAR ONLY IN `auto` MODE (§4.3): printing `0/25` under
263
+ // `notify` would advertise a budget nothing is spending.
264
+ const follow = todoCfg.followThrough === 'auto'
265
+ ? `auto (${ctx.followBudget?.used ?? 0}/` +
266
+ `${TODO_FOLLOW_LIMITS.maxAutoContinuesPerList} used)`
267
+ : todoCfg.followThrough;
268
+ if (!snapshot) {
269
+ ctx.notify('info', `No todo list. Todo planning: ${state}. Panel: ${panel}. ` +
270
+ `Follow-through: ${follow}.`);
271
+ return;
272
+ }
273
+ const active = snapshot.activeIndex >= 0
274
+ ? ` In progress: "${snapshot.items[snapshot.activeIndex]?.content ?? ''}".`
275
+ : '';
276
+ ctx.notify('info', `Todos: ${snapshot.doneCount}/${snapshot.total} done.${active} Panel: ${panel}. ` +
277
+ `Follow-through: ${follow}.`);
278
+ return;
279
+ }
280
+ if (verb === 'on' || verb === 'off') {
281
+ const enabled = verb === 'on';
282
+ // A PARTIAL `todo` section, safe only because `updatePersistedConfig`
283
+ // deep-merges it — the contract §4.2 spells out in full.
284
+ ctx.persistConfig({ todo: { enabled } });
285
+ if (!controller.isTodoRegistered()) {
286
+ ctx.notify('info', enabled
287
+ ? 'Todo planning is off for this session (started with --no-todo). Saved for next launch.'
288
+ : 'Todo planning is already off for this session. Saved for next launch.');
289
+ return;
290
+ }
291
+ controller.setTodoEnabled(enabled);
292
+ ctx.toast('success', `Todo planning ${enabled ? 'on' : 'off'}.`);
293
+ return;
294
+ }
295
+ if (verb === 'panel') {
296
+ if (value !== 'on' && value !== 'off') {
297
+ ctx.notify('warn', 'Usage: /todo panel <on|off>');
298
+ return;
299
+ }
300
+ const panel = value === 'on';
301
+ controller.setTodoConfig({ panel });
302
+ ctx.persistConfig({ todo: { panel } });
303
+ ctx.toast('success', `Todo panel ${panel ? 'on' : 'off'}.`);
304
+ return;
305
+ }
306
+ if (verb === 'follow') {
307
+ if (value !== 'notify' && value !== 'auto' && value !== 'off') {
308
+ ctx.notify('warn', 'Usage: /todo follow <notify|auto|off>');
309
+ return;
310
+ }
311
+ const followThrough = value;
312
+ // THE SAME TWO-CALL RULE AS `panel` ABOVE, minus `setTodoEnabled`:
313
+ // nothing in `<todo_planning>` mentions follow-through (D-3), so
314
+ // `todoEnabled` has no bearing here.
315
+ //
316
+ // `setTodoConfig` REBUILDS THE SYSTEM PROMPT UNCONDITIONALLY
317
+ // (`controller.ts`), and that is fine and must be left alone: the
318
+ // rebuild is idempotent for this key (the block does not mention
319
+ // follow-through, so the output is byte-identical), it costs one string
320
+ // composition PER COMMAND INVOCATION rather than per turn, and
321
+ // `setTodoConfig` is the ONLY method that updates the object `App` reads
322
+ // at decision time. A branch that bypassed it to avoid the rebuild would
323
+ // trade a free string concatenation for the exact "reports success,
324
+ // changes nothing" bug the method exists to prevent (P1-1 / P2-10).
325
+ controller.setTodoConfig({ followThrough });
326
+ ctx.persistConfig({ todo: { followThrough } });
327
+ ctx.toast('success', followThrough === 'auto'
328
+ ? `Follow-through: auto (up to ${TODO_FOLLOW_LIMITS.maxAutoContinuesPerList} ` +
329
+ 'continuations per plan).'
330
+ : `Follow-through: ${followThrough}.`);
331
+ return;
332
+ }
333
+ if (verb === 'clear') {
334
+ // REFUSED MID-RUN, which bounds the model/panel disagreement window to
335
+ // zero (§1.2): between turns the model's next `todo_write` is a full
336
+ // replacement anyway.
337
+ if (controller.isRunning()) {
338
+ ctx.notify('warn', 'Cannot clear the todo list while a run is in progress.');
339
+ return;
340
+ }
341
+ if (!controller.getTodoSnapshot()) {
342
+ ctx.notify('info', 'No todo list.');
343
+ return;
344
+ }
345
+ controller.clearTodos();
346
+ ctx.toast('info', 'Todo list cleared.');
347
+ return;
348
+ }
349
+ if (verb === 'continue') {
350
+ // REFUSED WHILE RUNNING TOO (P2-7). `ctx.submit` is the App's
351
+ // `submitMessage`, which routes to `controller.steer()` when the status
352
+ // is `running` — so the un-refused form would inject "continue the
353
+ // remaining items" into the run that is already doing exactly that.
354
+ if (controller.isRunning()) {
355
+ ctx.notify('warn', 'Already running.');
356
+ return;
357
+ }
358
+ const snapshot = controller.getTodoSnapshot();
359
+ if (!snapshot || snapshot.doneCount >= snapshot.total) {
360
+ ctx.notify('info', 'Nothing left to continue.');
361
+ return;
362
+ }
363
+ // THE SAME FUNCTION THE AUTO PATH USES, which is what keeps the two from
364
+ // drifting (D-11 / D-12). Round 1's fixed string "Continue with the
365
+ // remaining todo items." was a REFERENCE to a `todo_write` call that a
366
+ // long session may have scrolled out of view.
367
+ //
368
+ // `{ userInitiated: false }`: the user typed `/todo continue`, which the
369
+ // slash history already has. Putting this canned paragraph under the
370
+ // composer's up-arrow as well would be the same event recorded twice, in
371
+ // the more verbose of the two forms (AC-17).
372
+ ctx.submit(buildContinuationMessage(snapshot), { userInitiated: false });
373
+ return;
374
+ }
375
+ ctx.notify('warn', `Unknown argument "${verb}" - use ` +
376
+ '/todo [status|on|off|panel on|off|follow <mode>|clear|continue].');
377
+ },
378
+ },
175
379
  {
176
380
  name: 'expand',
177
381
  description: 'Expand / collapse a recent tool card (default: last)',
@@ -248,6 +452,9 @@ const COMMANDS = [
248
452
  },
249
453
  messages: ctx.controller.getMessages(),
250
454
  entries: ctx.state.entries,
455
+ // `/resume` restores `messages`, so the belief that justifies the
456
+ // panel comes back — and the panel has to come back with it (§3.13).
457
+ todos: ctx.controller.getTodoSnapshot()?.items ?? [],
251
458
  });
252
459
  ctx.notify('info', `Saved session to ${path}`);
253
460
  }
@@ -264,6 +471,13 @@ const COMMANDS = [
264
471
  const path = resolveSessionPath(ctx.args, ctx.controller.getCwd());
265
472
  const session = loadSession(path);
266
473
  ctx.controller.replaceMessages(session.messages);
474
+ // IN THE SAME `try`, IMMEDIATELY AFTER `replaceMessages`, so a malformed
475
+ // file fails the whole resume rather than half of it — and AN ABSENT
476
+ // `todos` CLEARS RATHER THAN SKIPPING (P0-2 / D-22). `replaceMessages`
477
+ // has just discarded the conversation the current list belonged to, so
478
+ // leaving the rail up produces a panel that disagrees with the model,
479
+ // which §1.2 ranks below having no panel at all.
480
+ ctx.controller.restoreTodos(session.todos ?? []);
267
481
  ctx.dispatch({ type: 'restoreEntries', entries: session.entries });
268
482
  if (session.model?.providerId && session.model?.modelId) {
269
483
  ctx.controller.setModel(session.model.providerId, session.model.modelId, session.model.baseUrl);
@@ -6,6 +6,7 @@ import type { AgentController } from '../agent/controller.js';
6
6
  import type { AgentMode } from '../agent/agent-mode.js';
7
7
  import type { NoticeLevel, Overlay, ToastLevel, ViewAction, ViewState } from '../agent/reducer.js';
8
8
  import type { PersistedConfig } from '../config/schema.js';
9
+ import type { FollowThroughBudget } from '../todo/follow-through.js';
9
10
  export interface CommandContext {
10
11
  /** Raw argument string after the command word (may be empty). */
11
12
  args: string;
@@ -24,8 +25,33 @@ export interface CommandContext {
24
25
  * idle). Dynamic skill commands need this: they turn `/pdf-forms a.pdf` into a
25
26
  * real user message. It is a separate entry point from `handleSubmit` on
26
27
  * purpose — routing back through that would re-enter slash-command parsing.
28
+ *
29
+ * `userInitiated: false` suppresses `recordPrompt` (todo-plan-followthrough
30
+ * C-12 / AC-17), which skips TWO things and both are wanted: the prompt-history
31
+ * append, and the `submitCount` bump that drives the composer's hint fade. A
32
+ * canned string the user did not type belongs in neither. The parameter is
33
+ * named for the CAUSE rather than for one of its effects, because a flag
34
+ * called `recordHistory` would have made the second one look like a bug.
35
+ *
36
+ * WIDENING THIS SIGNATURE IS THE WHOLE REASON THIS FILE IS IN THE CHANGE PLAN
37
+ * (P1-5): `builtins.ts` reaches `submitMessage` only through here, so
38
+ * `/todo continue` cannot opt out of prompt history without it.
39
+ */
40
+ submit: (text: string, opts?: {
41
+ userInitiated?: boolean;
42
+ }) => void;
43
+ /**
44
+ * The live auto-continue budget, for `/todo status`'s `(2/25 used)` clause
45
+ * (todo-plan-followthrough §4.3).
46
+ *
47
+ * READ-ONLY AND OPTIONAL. It lives in an `App` ref rather than on the
48
+ * controller or in `ViewState` (§4.4 / §5.2) because it counts a SESSION'S
49
+ * INTERACTION WITH THE USER, not the model's plan — so this is the only way a
50
+ * command can see it, and `makeCtx` is the only place that supplies it.
51
+ * Optional so the many test contexts that build this object by literal need no
52
+ * change; absent means the clause degrades to naming the ceiling alone.
27
53
  */
28
- submit: (text: string) => void;
54
+ followBudget?: FollowThroughBudget;
29
55
  /** Rebuild the system prompt and the dynamic command list after a skill change. */
30
56
  refreshSkills: () => void;
31
57
  /**
@@ -135,6 +135,20 @@ function maskConfig(config) {
135
135
  }
136
136
  return { ...config, apiKeys };
137
137
  }
138
+ /**
139
+ * The human-readable label for a value whose raw form would mislead, or
140
+ * `undefined` to leave the caller's own formatting alone.
141
+ *
142
+ * `maxTokens: null` is AUTO, not "unset". Printing a bare `null` reads as a
143
+ * missing setting and sends the user looking for a bug that is not there.
144
+ * `get` and `list` format everything else differently (unquoted vs. JSON), so
145
+ * this deliberately overrides one case rather than replacing both renderers.
146
+ */
147
+ function displayOverride(key, value) {
148
+ if (key === 'maxTokens' && value === null)
149
+ return 'auto';
150
+ return undefined;
151
+ }
138
152
  /** Resolve a flat or one-level-dotted key against the persisted config. */
139
153
  function readKey(config, key) {
140
154
  const [head, tail] = key.split('.', 2);
@@ -153,10 +167,14 @@ export function runConfigGet(key) {
153
167
  process.exitCode = 2;
154
168
  return;
155
169
  }
156
- process.stdout.write(`${typeof value === 'string' ? value : JSON.stringify(value)}\n`);
170
+ const shown = displayOverride(key, value)
171
+ ?? (typeof value === 'string' ? value : JSON.stringify(value));
172
+ process.stdout.write(`${shown}\n`);
157
173
  }
158
174
  export function runConfigList(opts = {}) {
159
175
  const masked = maskConfig(loadPersistedConfig());
176
+ // `--json` stays RAW on purpose: it mirrors the file, and a consumer parsing
177
+ // it must see the `null` that is actually on disk.
160
178
  if (opts.json) {
161
179
  process.stdout.write(`${JSON.stringify(masked, null, 2)}\n`);
162
180
  return;
@@ -168,7 +186,7 @@ export function runConfigList(opts = {}) {
168
186
  }
169
187
  continue;
170
188
  }
171
- process.stdout.write(`${key} = ${JSON.stringify(value)}\n`);
189
+ process.stdout.write(`${key} = ${displayOverride(key, value) ?? JSON.stringify(value)}\n`);
172
190
  }
173
191
  }
174
192
  // ---------------------------------------------------------------------------
@@ -5,7 +5,8 @@
5
5
  * - Provider secrets: ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY /
6
6
  * GEMINI_API_KEY.
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
- * ARAGON_MAX_TOKENS, ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN, ARAGON_TEAM,
8
+ * ARAGON_MAX_TOKENS (a number, or `auto` / `0` for the per-model ceiling),
9
+ * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN, ARAGON_TEAM,
9
10
  * ARAGON_TEAM_MAX, ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR.
10
11
  *
11
12
  * NOT here: `ARAGON_HOME`. It is read when `config/app-paths.ts` is first
@@ -5,7 +5,8 @@
5
5
  * - Provider secrets: ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY /
6
6
  * GEMINI_API_KEY.
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
- * ARAGON_MAX_TOKENS, ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN, ARAGON_TEAM,
8
+ * ARAGON_MAX_TOKENS (a number, or `auto` / `0` for the per-model ceiling),
9
+ * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN, ARAGON_TEAM,
9
10
  * ARAGON_TEAM_MAX, ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR.
10
11
  *
11
12
  * NOT here: `ARAGON_HOME`. It is read when `config/app-paths.ts` is first
@@ -18,7 +19,7 @@ import process from 'node:process';
18
19
  import dotenv from 'dotenv';
19
20
  import { registerSecretsFrom } from '../logging/secret-registry.js';
20
21
  import { clampLogLevel } from '../logging/levels.js';
21
- import { clampTheme, clampThinkingLevel, coerceMaxTokens, } from './schema.js';
22
+ import { clampMaxTokens, clampTheme, clampThinkingLevel, isAutoToken, } from './schema.js';
22
23
  /**
23
24
  * Load a project `.env` from the given directory into `process.env` (existing
24
25
  * process env wins — dotenv never overrides already-set variables).
@@ -58,9 +59,20 @@ export function readEnvConfig() {
58
59
  if (process.env.ARAGON_THINKING) {
59
60
  partial.thinkingLevel = clampThinkingLevel(process.env.ARAGON_THINKING.trim(), 'off');
60
61
  }
61
- if (process.env.ARAGON_MAX_TOKENS) {
62
- const mt = coerceMaxTokens(process.env.ARAGON_MAX_TOKENS);
63
- partial.maxTokens = mt ?? null;
62
+ // `auto` / `0` mean AUTO (`null`); a number is clamped to the accepted range.
63
+ // Anything else is LEFT ABSENT rather than written as `null`: a typo here used
64
+ // to become AUTO and silently discard the number in the config file under it,
65
+ // which is the opposite of what the user asked for.
66
+ const maxTokens = process.env.ARAGON_MAX_TOKENS?.trim();
67
+ if (maxTokens !== undefined && maxTokens.length > 0) {
68
+ if (isAutoToken(maxTokens)) {
69
+ partial.maxTokens = null;
70
+ }
71
+ else {
72
+ const clamped = clampMaxTokens(maxTokens);
73
+ if (clamped !== undefined)
74
+ partial.maxTokens = clamped;
75
+ }
64
76
  }
65
77
  if (process.env.ARAGON_THEME) {
66
78
  partial.theme = clampTheme(process.env.ARAGON_THEME.trim(), 'auto');
@@ -122,6 +134,49 @@ export function readEnvConfig() {
122
134
  if (Object.keys(section).length > 0)
123
135
  partial.team = section;
124
136
  }
137
+ // ARAGON_TODO=0 turns todo planning off entirely (no tool, no rail). Parsed by
138
+ // the POSITIVE list, exactly as ARAGON_FULLSCREEN / ARAGON_MOUSE / ARAGON_PLAN
139
+ // / ARAGON_TEAM are — NOT by `envBool` below, which uses a negative list. The
140
+ // two readers disagree on values like `ARAGON_TODO=disable`, and this file
141
+ // already records that once for team mode.
142
+ //
143
+ // There is no `ARAGON_TODO_PANEL`: the panel is a per-machine display
144
+ // preference, which is what `config.json` is for, and every env var here
145
+ // exists because a flag and a config file are BOTH unreachable in the channel
146
+ // it serves (containers, SSH, being spawned by another tool).
147
+ //
148
+ // ARAGON_TODO_FOLLOW=notify|auto|off PASSES THAT SAME TEST (D-18), which is
149
+ // why it exists where `ARAGON_TODO_PANEL` does not: follow-through is exactly
150
+ // the knob an unattended `-p` run in a container with a fixed entrypoint needs
151
+ // and can reach no other way.
152
+ //
153
+ // ONE ACCUMULATED SECTION, ASSIGNED ONCE — the shape the `team` branch above
154
+ // already uses, and NOT two `if` blocks each assigning `partial.todo` (P1-4).
155
+ // THE CAST IS WHAT WOULD MAKE THAT MISTAKE SILENT: a second assignment
156
+ // compiles clean and drops `enabled`, so `ARAGON_TODO=0 ARAGON_TODO_FOLLOW=auto`
157
+ // would quietly re-enable the tool the user turned off.
158
+ const todo = process.env.ARAGON_TODO?.trim().toLowerCase();
159
+ const todoFollow = process.env.ARAGON_TODO_FOLLOW?.trim().toLowerCase();
160
+ if ((todo !== undefined && todo.length > 0) ||
161
+ (todoFollow !== undefined && todoFollow.length > 0)) {
162
+ // Keyed by `TodoConfig` but VALUED `unknown`, which is what lets the mode be
163
+ // written through unvalidated (below) while still failing to compile if a
164
+ // future key is misspelled.
165
+ const section = {};
166
+ if (todo !== undefined && todo.length > 0) {
167
+ section.enabled = todo === '1' || todo === 'true' || todo === 'on' || todo === 'yes';
168
+ }
169
+ // Written through unvalidated: `clampTodoConfig` clamps an unrecognized mode
170
+ // to the default rather than rejecting it, so validating here would be a
171
+ // second gate that can disagree with the first.
172
+ if (todoFollow !== undefined && todoFollow.length > 0) {
173
+ section.followThrough = todoFollow;
174
+ }
175
+ // A PARTIAL section, safe only because every consumer deep-merges it — the
176
+ // same contract `partial.skills`, `partial.log` and `partial.team` rely on.
177
+ if (Object.keys(section).length > 0)
178
+ partial.todo = section;
179
+ }
125
180
  const log = readEnvLogConfig();
126
181
  if (log)
127
182
  partial.log = log;