@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
@@ -52,6 +52,33 @@ export interface CliFlags {
52
52
  team?: boolean;
53
53
  /** `--team-max <n>` — override `team.maxSubagents` for this run (still clamped). */
54
54
  teamMax?: string | number;
55
+ /**
56
+ * `--todo` ⇒ true, `--no-todo` ⇒ false, absent ⇒ undefined
57
+ * (todo-plan-execution §4.3).
58
+ */
59
+ todo?: boolean;
60
+ /**
61
+ * `--todo-panel` ⇒ true, `--no-todo-panel` ⇒ false, absent ⇒ undefined.
62
+ *
63
+ * DECLARED AS A PAIR, like `--todo` above, and unlike `--no-skills` (P1-8).
64
+ * `panel` is a PERSISTED setting, so "not passed" has to stay distinguishable
65
+ * from `true` — otherwise commander's lone-`--no-x` default silently
66
+ * overwrites a config-file `panel: false` on every run that passes no flag at
67
+ * all. `--no-skills` escapes the rule only because nothing needs to
68
+ * distinguish `--skills` from silence.
69
+ */
70
+ todoPanel?: boolean;
71
+ /**
72
+ * `--todo-follow <mode>` — `notify` | `auto` | `off`
73
+ * (todo-plan-followthrough §4.2).
74
+ *
75
+ * A VALUE FLAG, NOT A PAIR, so commander materializes it only when passed and
76
+ * the `!== undefined` rule above applies to its presence rather than to a
77
+ * synthesized `true`. Typed `string` and not `FollowThroughMode`: an
78
+ * unrecognized value is CLAMPED to the default by `clampTodoConfig`, never
79
+ * rejected, which is what every other key in this file does.
80
+ */
81
+ todoFollow?: string;
55
82
  /** `--log-level <lv>` — this run only, never persisted. */
56
83
  logLevel?: string;
57
84
  /** `--log-file` ⇒ true, `--no-log-file` ⇒ false, absent ⇒ undefined. */
@@ -6,7 +6,7 @@
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
8
  import process from 'node:process';
9
- import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampLogConfig, clampSkillsConfig, clampTeamConfig, clampTheme, clampThinkingLevel, clampTranscriptWindow, coerceMaxTokens, coercePositiveInt, isSkillsToolPolicyMode, } from './schema.js';
9
+ import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampLogConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampTheme, clampThinkingLevel, clampMaxTokens, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, } from './schema.js';
10
10
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
11
11
  import { readConfigFile } from './store.js';
12
12
  import { getConfigPath } from './app-paths.js';
@@ -97,6 +97,29 @@ function resolveTeamConfig(flags, env, file) {
97
97
  : {}),
98
98
  });
99
99
  }
100
+ /**
101
+ * Merge the `todo` section across all four layers (defaults › file › env ›
102
+ * flags), todo-plan-execution §4.3.
103
+ *
104
+ * `!== undefined` ON BOTH FLAGS, and that is the whole point (P1-8). `commander`
105
+ * materializes a lone `--no-x` as `opts.x = true` when the flag is absent, so a
106
+ * truthiness check cannot tell `--no-todo-panel` from "not passed" — and both
107
+ * keys here are persisted, so both need the distinction. This is the same rule
108
+ * `resolveTeamConfig` above states, one feature later.
109
+ */
110
+ function resolveTodoConfig(flags, env, file) {
111
+ return clampTodoConfig({
112
+ ...DEFAULT_CONFIG.todo,
113
+ ...(file.todo ?? {}),
114
+ ...(env.todo ?? {}),
115
+ ...(flags.todo !== undefined ? { enabled: flags.todo } : {}),
116
+ ...(flags.todoPanel !== undefined ? { panel: flags.todoPanel } : {}),
117
+ // `!== undefined` for the third time, though for a value flag the risk is
118
+ // milder: commander leaves `opts.todoFollow` absent unless it was passed.
119
+ // Written this way anyway so the three lines read as one rule.
120
+ ...(flags.todoFollow !== undefined ? { followThrough: flags.todoFollow } : {}),
121
+ });
122
+ }
100
123
  /** Merge the `log` section across all four layers (defaults › file › env › flags). */
101
124
  function resolveLogConfig(flags, env, file) {
102
125
  const merged = clampLogConfig({
@@ -118,6 +141,44 @@ function pick(...vals) {
118
141
  }
119
142
  return undefined;
120
143
  }
144
+ function hasOwn(obj, key) {
145
+ return Object.prototype.hasOwnProperty.call(obj, key);
146
+ }
147
+ /**
148
+ * Resolve the output cap: flag › env › file › default, where an EXPLICIT `null`
149
+ * at any layer means AUTO (`undefined` — let the resolver pick per model).
150
+ *
151
+ * THIS CANNOT USE `pick()`, and that is the whole point. `pick` skips `null`, so
152
+ * a config file holding `"maxTokens": null` silently resolves to 64000 and AUTO
153
+ * is unreachable through every supported write path. Detecting it needs an
154
+ * own-property test, not a truthiness test — `null` is exactly the value we are
155
+ * looking for.
156
+ *
157
+ * An unparseable layer falls through to the next one rather than becoming AUTO:
158
+ * a typo in `ARAGON_MAX_TOKENS` must not silently discard the number in the
159
+ * config file underneath it.
160
+ */
161
+ function resolveMaxTokens(flags, env, file) {
162
+ const layers = [
163
+ { name: '--max-tokens', value: flags.maxTokens },
164
+ { name: 'ARAGON_MAX_TOKENS', value: hasOwn(env, 'maxTokens') ? env.maxTokens : undefined },
165
+ { name: 'config.json maxTokens', value: hasOwn(file, 'maxTokens') ? file.maxTokens : undefined },
166
+ ];
167
+ for (const layer of layers) {
168
+ if (layer.value === undefined)
169
+ continue; // this layer said nothing
170
+ if (layer.value === null || isAutoToken(layer.value))
171
+ return undefined; // AUTO
172
+ const n = clampMaxTokens(layer.value);
173
+ if (n !== undefined)
174
+ return n;
175
+ getLogger().warn('config', 'max_tokens_unusable', {
176
+ source: layer.name,
177
+ value: String(layer.value),
178
+ });
179
+ }
180
+ return DEFAULT_CONFIG.maxTokens ?? undefined;
181
+ }
121
182
  /**
122
183
  * Resolve the effective config by merging all layers, then derive the runtime
123
184
  * (non-persisted) fields and enforce the timeout invariant.
@@ -142,7 +203,7 @@ export function loadConfig(flags = {}) {
142
203
  const model = pick(flags.model, env.partial.model, file.model) ?? DEFAULT_CONFIG.model;
143
204
  const baseUrl = pick(flags.baseUrl, env.partial.baseUrl ?? undefined, file.baseUrl ?? undefined);
144
205
  const thinkingLevel = clampThinkingLevel(pick(flags.thinking, env.partial.thinkingLevel, file.thinkingLevel), DEFAULT_CONFIG.thinkingLevel);
145
- const maxTokens = coerceMaxTokens(pick(flags.maxTokens, env.partial.maxTokens, file.maxTokens));
206
+ const maxTokens = resolveMaxTokens(flags, env.partial, file);
146
207
  // `clampTheme` is also where the v0.3.0 `dark` name is migrated to `cool`,
147
208
  // so reading a legacy config file needs no extra step here (§4.5 / R-4).
148
209
  const theme = clampTheme(pick(flags.theme, env.partial.theme, file.theme), DEFAULT_CONFIG.theme);
@@ -247,6 +308,7 @@ export function loadConfig(flags = {}) {
247
308
  },
248
309
  log: resolveLogConfig(flags, env.partial, file),
249
310
  team: resolveTeamConfig(flags, env.partial, file),
311
+ todo: resolveTodoConfig(flags, env.partial, file),
250
312
  cwd,
251
313
  color,
252
314
  colorLevel,
@@ -274,6 +336,9 @@ function recordResolvedConfig(config) {
274
336
  model: config.model,
275
337
  baseUrl: config.baseUrl,
276
338
  thinkingLevel: config.thinkingLevel,
339
+ // Four layers feed this one and none of them used to leave a trace, so
340
+ // "why is my output truncated" was unanswerable after the fact.
341
+ maxTokens: config.maxTokens ?? 'auto',
277
342
  theme: config.theme,
278
343
  fullscreen: config.fullscreen,
279
344
  toolTimeoutMs: config.toolTimeoutMs,
@@ -5,8 +5,9 @@
5
5
  * See spec §3.5 (resolution pipeline), §6.1 (config file), and §3.8 (timeout
6
6
  * invariant).
7
7
  */
8
- import type { ThinkingLevel } from '@aragon-agent/core';
8
+ import { type ThinkingLevel } from '@aragon-agent/core';
9
9
  import { type LogLevelName } from '../logging/levels.js';
10
+ import type { FollowThroughMode } from '../todo/types.js';
10
11
  /**
11
12
  * `ModelRegistry` seeds builtin lists for five providers, but `initProviders()`
12
13
  * registers only three adapters. Running a model whose provider has no adapter
@@ -239,12 +240,58 @@ export declare const DEFAULT_TEAM_CONFIG: TeamConfig;
239
240
  * the two disagree would make the panel's `n/m` readout confusing for no gain.
240
241
  */
241
242
  export declare function clampTeamConfig(raw: unknown): TeamConfig;
243
+ export interface TodoConfig {
244
+ /** Register `todo_write` at all. Default TRUE. */
245
+ enabled: boolean;
246
+ /**
247
+ * Render the right rail.
248
+ *
249
+ * INDEPENDENT OF `enabled` ON PURPOSE (D-16): a screen-reader user wants the
250
+ * planning discipline without the column, and the prompt block varies one
251
+ * sentence rather than disappearing (§3.7).
252
+ */
253
+ panel: boolean;
254
+ /**
255
+ * What happens when a run ends with unfinished steps
256
+ * (todo-plan-followthrough §4.1). Default `'notify'`, which reproduces round
257
+ * 1's behaviour byte for byte.
258
+ *
259
+ * THE THIRD SCALAR, and it stays a scalar for the reason the section header
260
+ * above gives: `store.ts` merges this section BY HAND, and both of its merges
261
+ * spread the whole object (`{ ...DEFAULT_CONFIG.todo, ...(partial.todo ?? {}) }`
262
+ * and `{ ...current.todo, ...(patch.todo ?? {}) }`), so a third scalar key is
263
+ * carried automatically and needs no change there. A NESTED field would not
264
+ * be, and would mean replacing both merges first.
265
+ */
266
+ followThrough: FollowThroughMode;
267
+ }
268
+ export declare const DEFAULT_TODO_CONFIG: TodoConfig;
269
+ /**
270
+ * THE single gate for every todo-config read AND write, mirroring
271
+ * `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` for the reason
272
+ * those record: hardening only the read path leaves a bad value on disk that
273
+ * reverts to the default on every launch, which presents to the user as "my
274
+ * setting won't stick".
275
+ */
276
+ export declare function clampTodoConfig(raw: unknown): TodoConfig;
242
277
  export interface PersistedConfig {
243
278
  version: number;
244
279
  provider: string;
245
280
  model: string;
246
281
  baseUrl: string | null;
247
282
  thinkingLevel: ThinkingLevel;
283
+ /**
284
+ * Output token cap.
285
+ *
286
+ * a number — an explicit cap, clamped to `[256, 200000]` on the way in AND
287
+ * on the way out, then clamped again at request time down to the
288
+ * target model's real ceiling when that ceiling is lower.
289
+ * `null` — AUTO: use each model's own ceiling, never above 64000.
290
+ * absent — the product default, 64000.
291
+ *
292
+ * `null` MEANS AUTO. It used to resolve to 64000 by accident (`pick()` skips
293
+ * `null`), which left the user no way to express "let the model decide".
294
+ */
248
295
  maxTokens: number | null;
249
296
  theme: ThemeName;
250
297
  /** Replace spinners with a static glyph for calmer, low-motion output. */
@@ -293,6 +340,8 @@ export interface PersistedConfig {
293
340
  log: LogConfig;
294
341
  /** Team mode. The FOURTH nested object; scalars only (see TeamConfig). */
295
342
  team: TeamConfig;
343
+ /** Todo planning. The FIFTH nested object; scalars only (see TodoConfig). */
344
+ todo: TodoConfig;
296
345
  }
297
346
  export declare const DEFAULT_CONFIG: PersistedConfig;
298
347
  /**
@@ -324,6 +373,7 @@ export interface CliConfig {
324
373
  model: string;
325
374
  baseUrl?: string;
326
375
  thinkingLevel: ThinkingLevel;
376
+ /** The resolved cap, or `undefined` for AUTO (per-model ceiling, ≤ 64000). */
327
377
  maxTokens?: number;
328
378
  theme: ThemeName;
329
379
  reducedMotion: boolean;
@@ -386,6 +436,16 @@ export interface CliConfig {
386
436
  * `/team` flips is a separate flag on the controller.
387
437
  */
388
438
  team: TeamConfig;
439
+ /**
440
+ * Resolved todo-planning settings (todo-plan-execution §4.2).
441
+ *
442
+ * `enabled` here decides whether `todo_write` is REGISTERED, which happens
443
+ * once at construction and can never be undone (C-1). The live on/off switch
444
+ * `/todo` flips is a separate flag on the controller; `panel` is read at
445
+ * RENDER time through `controller.getConfig()`, which is why `/todo panel`
446
+ * must call `setTodoConfig` as well as persisting (P1-2).
447
+ */
448
+ todo: TodoConfig;
389
449
  /**
390
450
  * Lifetime submit count driving the composer hint fade (`HINT_FADE_AFTER`).
391
451
  *
@@ -406,8 +466,57 @@ export interface CliConfig {
406
466
  /** One-shot `--api-key` override; applies to the active provider only. */
407
467
  apiKeyOverride?: string;
408
468
  }
409
- /** Coerce an arbitrary value into a positive integer, or `undefined`. */
469
+ /**
470
+ * Coerce an arbitrary value into a positive integer, or `undefined`.
471
+ *
472
+ * Kept because callers outside this feature still use it, and deliberately
473
+ * WITHOUT the range clamp so its behaviour for in-range values is byte-identical
474
+ * to what it always was. New code wants `clampMaxTokens`.
475
+ */
410
476
  export declare function coerceMaxTokens(v: unknown): number | undefined;
477
+ /**
478
+ * The accepted range for an explicit output cap.
479
+ *
480
+ * The floor is the engine's: below it a turn cannot produce a usable tool call.
481
+ * The ceiling is a sanity bound on a hand-edited file, not a model limit — the
482
+ * real per-model ceiling is applied later, by `resolveOutputTokens`.
483
+ */
484
+ export declare const MAX_TOKENS_RANGE: {
485
+ min: number;
486
+ max: number;
487
+ };
488
+ /** `auto`, an empty string and `0` all mean AUTO — "let the model decide". */
489
+ export declare function isAutoToken(v: unknown): boolean;
490
+ /**
491
+ * THE single gate for every `maxTokens` read AND write, in the shape
492
+ * `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` already use.
493
+ *
494
+ * Both directions on purpose: hardening only the read path leaves a bad value on
495
+ * disk that reverts to a default on every launch, which presents to the user as
496
+ * "my setting won't stick".
497
+ *
498
+ * Returns `undefined` for AUTO and for anything unusable — the caller decides
499
+ * which of the two it was, because they mean different things.
500
+ */
501
+ export declare function clampMaxTokens(v: unknown): number | undefined;
502
+ /**
503
+ * Parse text typed into the settings screen, a slash command, or `config set`.
504
+ *
505
+ * Distinguishes AUTO from "the user typed nonsense", which `clampMaxTokens`
506
+ * cannot: both come back as `undefined` there, and treating a typo as AUTO would
507
+ * silently discard the number the user meant to set.
508
+ */
509
+ export declare function parseMaxTokensInput(raw: string): {
510
+ kind: 'auto';
511
+ } | {
512
+ kind: 'value';
513
+ value: number;
514
+ clamped: boolean;
515
+ } | {
516
+ kind: 'invalid';
517
+ };
518
+ /** The product default, re-exported so the CLI never spells the number itself. */
519
+ export declare const DEFAULT_MAX_TOKENS = 64000;
411
520
  /** Coerce a value into a positive integer with a fallback (for timeouts). */
412
521
  export declare function coercePositiveInt(v: unknown, fallback: number): number;
413
522
  /**
@@ -6,6 +6,7 @@
6
6
  * invariant).
7
7
  */
8
8
  import { isAbsolute } from 'node:path';
9
+ import { ABSOLUTE_MAX_OUTPUT_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS, MIN_MAX_OUTPUT_TOKENS, } from '@aragon-agent/core';
9
10
  import { clampLogLevel } from '../logging/levels.js';
10
11
  import { TEAM_LIMITS } from '../team/limits.js';
11
12
  // ---------------------------------------------------------------------------
@@ -311,13 +312,49 @@ export function clampTeamConfig(raw) {
311
312
  maxTurnsPerSubagent: clampInt(src.maxTurnsPerSubagent, DEFAULT_TEAM_CONFIG.maxTurnsPerSubagent, TURNS_RANGE),
312
313
  };
313
314
  }
315
+ export const DEFAULT_TODO_CONFIG = {
316
+ enabled: true,
317
+ panel: true,
318
+ followThrough: 'notify',
319
+ };
320
+ /**
321
+ * There is no enum helper in this file, so this follows the `isThinkingLevel`
322
+ * shape already here rather than inventing a second idiom.
323
+ */
324
+ const FOLLOW_THROUGH_MODES = ['notify', 'auto', 'off'];
325
+ function followMode(value, fallback) {
326
+ return typeof value === 'string' && FOLLOW_THROUGH_MODES.includes(value)
327
+ ? value
328
+ : fallback;
329
+ }
330
+ /**
331
+ * THE single gate for every todo-config read AND write, mirroring
332
+ * `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` for the reason
333
+ * those record: hardening only the read path leaves a bad value on disk that
334
+ * reverts to the default on every launch, which presents to the user as "my
335
+ * setting won't stick".
336
+ */
337
+ export function clampTodoConfig(raw) {
338
+ const src = (raw && typeof raw === 'object' ? raw : {});
339
+ return {
340
+ enabled: bool(src.enabled, DEFAULT_TODO_CONFIG.enabled),
341
+ panel: bool(src.panel, DEFAULT_TODO_CONFIG.panel),
342
+ // CLAMPED TO THE DEFAULT, NEVER REJECTED — the discipline every other key in
343
+ // this file follows, and the trade round 1 already made for `--thinking`.
344
+ // `ARAGON_TODO_FOLLOW=garbage` therefore resolves to `'notify'` and throws
345
+ // nothing (AC-5).
346
+ followThrough: followMode(src.followThrough, DEFAULT_TODO_CONFIG.followThrough),
347
+ };
348
+ }
314
349
  export const DEFAULT_CONFIG = {
315
350
  version: CONFIG_VERSION,
316
351
  provider: 'anthropic',
317
352
  model: DEFAULT_MODEL,
318
353
  baseUrl: null,
319
354
  thinkingLevel: 'off',
320
- maxTokens: null,
355
+ // The engine owns this number; spelling it here again is how the CLI and the
356
+ // provider adapters end up disagreeing about what "the default" is.
357
+ maxTokens: DEFAULT_MAX_OUTPUT_TOKENS,
321
358
  theme: 'auto',
322
359
  reducedMotion: false,
323
360
  fullscreen: true,
@@ -337,6 +374,10 @@ export const DEFAULT_CONFIG = {
337
374
  skills: DEFAULT_SKILLS_CONFIG,
338
375
  log: DEFAULT_LOG_CONFIG,
339
376
  team: DEFAULT_TEAM_CONFIG,
377
+ // The load path's first spread is what supplies the section to a user with no
378
+ // `todo` key at all, so this line is as required as the two merges in
379
+ // `store.ts` (P0-1).
380
+ todo: DEFAULT_TODO_CONFIG,
340
381
  };
341
382
  /**
342
383
  * Runtime state that used to be stored in `config.json` by mistake
@@ -376,7 +417,7 @@ export function stripLegacyStateKeys(raw) {
376
417
  // Coercion / validation helpers
377
418
  // ---------------------------------------------------------------------------
378
419
  /** Coerce an arbitrary value into a positive integer, or `undefined`. */
379
- export function coerceMaxTokens(v) {
420
+ function coerceTokenCount(v) {
380
421
  if (v === null || v === undefined || v === '')
381
422
  return undefined;
382
423
  const n = typeof v === 'number' ? v : Number.parseInt(String(v), 10);
@@ -384,6 +425,74 @@ export function coerceMaxTokens(v) {
384
425
  return undefined;
385
426
  return Math.floor(n);
386
427
  }
428
+ /**
429
+ * Coerce an arbitrary value into a positive integer, or `undefined`.
430
+ *
431
+ * Kept because callers outside this feature still use it, and deliberately
432
+ * WITHOUT the range clamp so its behaviour for in-range values is byte-identical
433
+ * to what it always was. New code wants `clampMaxTokens`.
434
+ */
435
+ export function coerceMaxTokens(v) {
436
+ return coerceTokenCount(v);
437
+ }
438
+ /**
439
+ * The accepted range for an explicit output cap.
440
+ *
441
+ * The floor is the engine's: below it a turn cannot produce a usable tool call.
442
+ * The ceiling is a sanity bound on a hand-edited file, not a model limit — the
443
+ * real per-model ceiling is applied later, by `resolveOutputTokens`.
444
+ */
445
+ export const MAX_TOKENS_RANGE = {
446
+ min: MIN_MAX_OUTPUT_TOKENS,
447
+ max: ABSOLUTE_MAX_OUTPUT_TOKENS,
448
+ };
449
+ /** `auto`, an empty string and `0` all mean AUTO — "let the model decide". */
450
+ export function isAutoToken(v) {
451
+ if (v === 0)
452
+ return true;
453
+ if (typeof v !== 'string')
454
+ return false;
455
+ const trimmed = v.trim().toLowerCase();
456
+ return trimmed === '' || trimmed === 'auto' || trimmed === '0';
457
+ }
458
+ /**
459
+ * THE single gate for every `maxTokens` read AND write, in the shape
460
+ * `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` already use.
461
+ *
462
+ * Both directions on purpose: hardening only the read path leaves a bad value on
463
+ * disk that reverts to a default on every launch, which presents to the user as
464
+ * "my setting won't stick".
465
+ *
466
+ * Returns `undefined` for AUTO and for anything unusable — the caller decides
467
+ * which of the two it was, because they mean different things.
468
+ */
469
+ export function clampMaxTokens(v) {
470
+ const n = coerceTokenCount(v);
471
+ if (n === undefined)
472
+ return undefined;
473
+ return Math.min(MAX_TOKENS_RANGE.max, Math.max(MAX_TOKENS_RANGE.min, n));
474
+ }
475
+ /**
476
+ * Parse text typed into the settings screen, a slash command, or `config set`.
477
+ *
478
+ * Distinguishes AUTO from "the user typed nonsense", which `clampMaxTokens`
479
+ * cannot: both come back as `undefined` there, and treating a typo as AUTO would
480
+ * silently discard the number the user meant to set.
481
+ */
482
+ export function parseMaxTokensInput(raw) {
483
+ const trimmed = String(raw ?? '').trim();
484
+ if (isAutoToken(trimmed))
485
+ return { kind: 'auto' };
486
+ if (!/^\d+$/.test(trimmed))
487
+ return { kind: 'invalid' };
488
+ const parsed = Number.parseInt(trimmed, 10);
489
+ if (!Number.isFinite(parsed) || parsed <= 0)
490
+ return { kind: 'invalid' };
491
+ const value = Math.min(MAX_TOKENS_RANGE.max, Math.max(MAX_TOKENS_RANGE.min, parsed));
492
+ return { kind: 'value', value, clamped: value !== parsed };
493
+ }
494
+ /** The product default, re-exported so the CLI never spells the number itself. */
495
+ export const DEFAULT_MAX_TOKENS = DEFAULT_MAX_OUTPUT_TOKENS;
387
496
  /** Coerce a value into a positive integer with a fallback (for timeouts). */
388
497
  export function coercePositiveInt(v, fallback) {
389
498
  if (v === null || v === undefined || v === '')
@@ -14,7 +14,23 @@ import { dirname } from 'node:path';
14
14
  import process from 'node:process';
15
15
  import { getConfigPath, getSessionsDir } from './app-paths.js';
16
16
  import { registerSecretsFrom } from '../logging/secret-registry.js';
17
- import { CONFIG_VERSION, DEFAULT_CONFIG, clampLogConfig, clampSkillsConfig, clampTeamConfig, stripLegacyStateKeys, } from './schema.js';
17
+ import { CONFIG_VERSION, DEFAULT_CONFIG, clampLogConfig, clampMaxTokens, clampSkillsConfig, clampTeamConfig, clampTodoConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
18
+ /**
19
+ * Normalize `maxTokens` for both directions of the config file.
20
+ *
21
+ * `null` MEANS AUTO and must survive untouched; a number is clamped to the
22
+ * accepted range; anything else on a hand-edited file (a string, a float, a
23
+ * negative) becomes AUTO rather than a silent default. Same discipline as
24
+ * `clampSkillsConfig` — read AND write pass through one gate, or a bad value on
25
+ * disk reverts on every launch and presents as "my setting won't stick".
26
+ */
27
+ function normalizeMaxTokens(v) {
28
+ if (v === undefined)
29
+ return DEFAULT_CONFIG.maxTokens;
30
+ if (v === null || isAutoToken(v))
31
+ return null;
32
+ return clampMaxTokens(v) ?? null;
33
+ }
18
34
  // Re-exported so the many existing importers of `store.js` keep working; the
19
35
  // definitions live in `app-paths.ts` because they are pure path arithmetic.
20
36
  export { getConfigPath, getSessionsDir };
@@ -92,6 +108,12 @@ export function loadPersistedConfig() {
92
108
  ...DEFAULT_CONFIG,
93
109
  ...partial,
94
110
  version: CONFIG_VERSION,
111
+ // Own-property aware, because `null` is the value we need to preserve: a
112
+ // spread cannot tell "the key is absent" (⇒ the default) from "the key is
113
+ // null" (⇒ AUTO), and those are different settings.
114
+ maxTokens: Object.prototype.hasOwnProperty.call(partial, 'maxTokens')
115
+ ? normalizeMaxTokens(partial.maxTokens)
116
+ : DEFAULT_CONFIG.maxTokens,
95
117
  apiKeys: { ...DEFAULT_CONFIG.apiKeys, ...(partial.apiKeys ?? {}) },
96
118
  // `skills` is the second nested object in this file and needs the same
97
119
  // treatment `apiKeys` gets — see the note on updatePersistedConfig below.
@@ -100,6 +122,10 @@ export function loadPersistedConfig() {
100
122
  log: clampLogConfig({ ...DEFAULT_CONFIG.log, ...(partial.log ?? {}) }),
101
123
  // `team` is the fourth. Same one-level rule, same merge.
102
124
  team: clampTeamConfig({ ...DEFAULT_CONFIG.team, ...(partial.team ?? {}) }),
125
+ // `todo` is the fifth. Same one-level rule, same merge — and this line is
126
+ // the READ half of the pair that keeps `/todo panel off` from silently
127
+ // unregistering the tool on the next launch (P0-1 / AC-35).
128
+ todo: clampTodoConfig({ ...DEFAULT_CONFIG.todo, ...(partial.todo ?? {}) }),
103
129
  };
104
130
  }
105
131
  /**
@@ -131,6 +157,11 @@ export function updatePersistedConfig(patch) {
131
157
  ...current,
132
158
  ...patch,
133
159
  version: CONFIG_VERSION,
160
+ // The write half of the gate: without it `config set maxTokens 900000` puts
161
+ // a number on disk that the read path silently rewrites on every launch.
162
+ maxTokens: Object.prototype.hasOwnProperty.call(patch, 'maxTokens')
163
+ ? normalizeMaxTokens(patch.maxTokens)
164
+ : current.maxTokens,
134
165
  apiKeys: { ...current.apiKeys, ...(patch.apiKeys ?? {}) },
135
166
  skills: clampSkillsConfig({ ...current.skills, ...(patch.skills ?? {}) }),
136
167
  // Without this line, changing the log level from the settings screen would
@@ -141,6 +172,11 @@ export function updatePersistedConfig(patch) {
141
172
  // 4` sends `{ team: { maxSubagents: 4 } }`, which a shallow spread would
142
173
  // turn into "changing the fan-out width silently reverted every timeout".
143
174
  team: clampTeamConfig({ ...current.team, ...(patch.team ?? {}) }),
175
+ // The fifth section, and its omission is the worst of the five (P0-1):
176
+ // `/todo panel off` sends `{ todo: { panel: false } }`, which a shallow
177
+ // spread turns into "turning the panel off silently unregisters the tool",
178
+ // permanently, from the next launch onward, with nothing anywhere saying so.
179
+ todo: clampTodoConfig({ ...current.todo, ...(patch.todo ?? {}) }),
144
180
  };
145
181
  writeConfigFile(merged);
146
182
  return merged;
@@ -7,22 +7,40 @@
7
7
  */
8
8
  import type { Message, ModelRef } from '@aragon-agent/core';
9
9
  import type { Entry } from '../agent/reducer.js';
10
+ import type { TodoItem } from '../todo/types.js';
10
11
  export interface SavedSession {
11
12
  version: number;
12
13
  savedAt: number;
13
14
  model: ModelRef;
14
15
  messages: Message[];
15
16
  entries: Entry[];
17
+ /**
18
+ * The live todo list at save time (todo-plan-execution §3.13).
19
+ *
20
+ * `SESSION_VERSION` STAYS 1: the field is additive and optional, `loadSession`
21
+ * validates only that `messages` and `entries` are arrays, and an older file
22
+ * simply yields `undefined` — which `restoreTodos` treats as "clear", the
23
+ * right answer for a session that predates the feature (D-20 / D-22).
24
+ */
25
+ todos?: TodoItem[];
16
26
  }
17
27
  /** Resolve a `/save` target: absolute/relative path, or a name in the data dir. */
18
28
  export declare function resolveSessionPath(nameOrPath: string | undefined, cwd: string): string;
29
+ /**
30
+ * `data` GAINS `todos`, AND WIDENING `SavedSession` ALONE IS NOT ENOUGH (P0-2):
31
+ * the writer below builds its payload from THIS parameter, not from the type, so
32
+ * a `todos?` field on the interface with no field here would ship a key nothing
33
+ * ever writes.
34
+ */
19
35
  export declare function saveSession(filePath: string, data: {
20
36
  model: ModelRef;
21
37
  messages: Message[];
22
38
  entries: Entry[];
39
+ todos: TodoItem[];
23
40
  }): void;
24
41
  /**
25
- * Settle any team entry that was saved mid-dispatch (team-subagents §5.3 / P1-5).
42
+ * Settle any team entry that was saved mid-dispatch (team-subagents §5.3 / P1-5),
43
+ * and any todo card that was saved mid-run (todo-plan-execution §3.8 / C-5).
26
44
  *
27
45
  * `saveSession` writes `entries` verbatim and `loadSession` validates only
28
46
  * array-ness, so a session saved while a dispatch was in flight resumes with a
@@ -28,6 +28,12 @@ function defaultStamp() {
28
28
  // Filesystem-safe timestamp without punctuation that breaks on Windows.
29
29
  return new Date().toISOString().replace(/[:.]/g, '-');
30
30
  }
31
+ /**
32
+ * `data` GAINS `todos`, AND WIDENING `SavedSession` ALONE IS NOT ENOUGH (P0-2):
33
+ * the writer below builds its payload from THIS parameter, not from the type, so
34
+ * a `todos?` field on the interface with no field here would ship a key nothing
35
+ * ever writes.
36
+ */
31
37
  export function saveSession(filePath, data) {
32
38
  const dir = dirname(filePath);
33
39
  if (!existsSync(dir))
@@ -38,11 +44,13 @@ export function saveSession(filePath, data) {
38
44
  model: data.model,
39
45
  messages: data.messages,
40
46
  entries: data.entries,
47
+ todos: data.todos,
41
48
  };
42
49
  writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
43
50
  }
44
51
  /**
45
- * Settle any team entry that was saved mid-dispatch (team-subagents §5.3 / P1-5).
52
+ * Settle any team entry that was saved mid-dispatch (team-subagents §5.3 / P1-5),
53
+ * and any todo card that was saved mid-run (todo-plan-execution §3.8 / C-5).
46
54
  *
47
55
  * `saveSession` writes `entries` verbatim and `loadSession` validates only
48
56
  * array-ness, so a session saved while a dispatch was in flight resumes with a
@@ -57,7 +65,18 @@ export function saveSession(filePath, data) {
57
65
  * never legitimately sees a stale-active entry — only the file does.
58
66
  */
59
67
  export function normalizeLoadedEntries(entries) {
60
- return entries.map((entry) => entry.kind === 'team' && entry.active ? { ...entry, active: false, aborted: true } : entry);
68
+ return entries.map((entry) => {
69
+ if (entry.kind === 'team' && entry.active) {
70
+ return { ...entry, active: false, aborted: true };
71
+ }
72
+ // `interrupted: true` is the todo analogue of the team entry's `aborted`,
73
+ // and true for the same reason: the run behind the card died with the
74
+ // process (P2-6). Without it a resumed `2/7` reads as a run still in flight.
75
+ if (entry.kind === 'todo' && entry.live) {
76
+ return { ...entry, live: false, interrupted: true };
77
+ }
78
+ return entry;
79
+ });
61
80
  }
62
81
  export function loadSession(filePath) {
63
82
  const raw = readFileSync(filePath, 'utf-8');
@@ -89,6 +89,17 @@ export function buildSubagentTools(spec, deps, getAgent) {
89
89
  // registering tools it could never use burns a turn — the same argument that
90
90
  // keeps them out of headless mode.
91
91
  planTools: [],
92
+ // NO `todo_write` FOR CHILDREN (todo-plan-execution §3.11 / D-14 / AC-34).
93
+ // Children run concurrently against ONE list under a full-replacement
94
+ // protocol: the last writer would win and the lead's plan would be destroyed
95
+ // by a subagent's private checklist. A list PER child would be a second
96
+ // panel and a second scroll model for work the lead already tracks as one
97
+ // item.
98
+ //
99
+ // EXPLICIT, WITH THIS COMMENT, even though the option's absence already
100
+ // yields an empty array: a silent default is not a decision anyone can find
101
+ // later.
102
+ todoTools: [],
92
103
  teamTools: commTools,
93
104
  // `spec.readOnly` forces the plan gate on for this child even in a BUILD
94
105
  // session. TIGHTENING ONLY (D-13): there is no field anywhere that gives a