@aragon-agent/cli 0.5.4 → 0.5.8

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 (100) hide show
  1. package/CHANGELOG.md +747 -577
  2. package/README.md +1163 -995
  3. package/dist/agent/controller.d.ts +71 -1
  4. package/dist/agent/controller.js +135 -1
  5. package/dist/agent/entry-limits.d.ts +65 -0
  6. package/dist/agent/entry-limits.js +133 -0
  7. package/dist/agent/headless.d.ts +29 -0
  8. package/dist/agent/headless.js +86 -1
  9. package/dist/agent/reducer.d.ts +53 -0
  10. package/dist/agent/reducer.js +145 -14
  11. package/dist/agent/system-prompt.d.ts +8 -0
  12. package/dist/agent/system-prompt.js +2 -0
  13. package/dist/cli.js +76 -3
  14. package/dist/commands/builtins.js +171 -0
  15. package/dist/commands/perf.d.ts +45 -0
  16. package/dist/commands/perf.js +78 -0
  17. package/dist/commands/registry.d.ts +27 -1
  18. package/dist/config/env.d.ts +3 -1
  19. package/dist/config/env.js +74 -1
  20. package/dist/config/load.d.ts +41 -0
  21. package/dist/config/load.js +67 -2
  22. package/dist/config/schema.d.ts +108 -8
  23. package/dist/config/schema.js +81 -6
  24. package/dist/config/store.js +10 -1
  25. package/dist/session/persist.d.ts +19 -1
  26. package/dist/session/persist.js +21 -2
  27. package/dist/team/subagent.js +11 -0
  28. package/dist/todo/follow-through.d.ts +117 -0
  29. package/dist/todo/follow-through.js +186 -0
  30. package/dist/todo/limits.d.ts +114 -0
  31. package/dist/todo/limits.js +114 -0
  32. package/dist/todo/normalize.d.ts +33 -0
  33. package/dist/todo/normalize.js +117 -0
  34. package/dist/todo/panel-rows.d.ts +53 -0
  35. package/dist/todo/panel-rows.js +103 -0
  36. package/dist/todo/prompt.d.ts +54 -0
  37. package/dist/todo/prompt.js +76 -0
  38. package/dist/todo/store.d.ts +86 -0
  39. package/dist/todo/store.js +183 -0
  40. package/dist/todo/todo-tool.d.ts +38 -0
  41. package/dist/todo/todo-tool.js +134 -0
  42. package/dist/todo/types.d.ts +62 -0
  43. package/dist/todo/types.js +12 -0
  44. package/dist/tools/index.d.ts +16 -2
  45. package/dist/tools/index.js +13 -0
  46. package/dist/ui/App.js +328 -14
  47. package/dist/ui/Markdown.d.ts +7 -1
  48. package/dist/ui/Markdown.js +15 -48
  49. package/dist/ui/StatusBar.d.ts +31 -0
  50. package/dist/ui/StatusBar.js +7 -2
  51. package/dist/ui/TodoPanel.d.ts +48 -0
  52. package/dist/ui/TodoPanel.js +74 -0
  53. package/dist/ui/TodoStrip.d.ts +36 -0
  54. package/dist/ui/TodoStrip.js +32 -0
  55. package/dist/ui/Transcript.d.ts +56 -4
  56. package/dist/ui/Transcript.js +123 -11
  57. package/dist/ui/entries/AssistantEntry.d.ts +18 -1
  58. package/dist/ui/entries/AssistantEntry.js +33 -3
  59. package/dist/ui/entries/TeamCard.d.ts +9 -1
  60. package/dist/ui/entries/TeamCard.js +22 -1
  61. package/dist/ui/entries/TodoCard.d.ts +39 -0
  62. package/dist/ui/entries/TodoCard.js +57 -0
  63. package/dist/ui/entries/ToolCard.d.ts +13 -1
  64. package/dist/ui/entries/ToolCard.js +39 -5
  65. package/dist/ui/entries/ToolPreview.d.ts +2 -2
  66. package/dist/ui/entries/UserEntry.d.ts +9 -1
  67. package/dist/ui/entries/UserEntry.js +17 -1
  68. package/dist/ui/exit-snapshot.d.ts +7 -0
  69. package/dist/ui/glyphs.d.ts +12 -0
  70. package/dist/ui/glyphs.js +8 -0
  71. package/dist/ui/layout/AppShell.d.ts +34 -1
  72. package/dist/ui/layout/AppShell.js +3 -3
  73. package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
  74. package/dist/ui/layout/MeasuredEntry.js +30 -0
  75. package/dist/ui/layout/ScrollViewport.d.ts +13 -1
  76. package/dist/ui/layout/ScrollViewport.js +10 -4
  77. package/dist/ui/layout/rail.d.ts +56 -0
  78. package/dist/ui/layout/rail.js +68 -0
  79. package/dist/ui/layout/viewport-geometry.d.ts +28 -0
  80. package/dist/ui/layout/viewport-geometry.js +27 -0
  81. package/dist/ui/layout/virtual-window.d.ts +100 -0
  82. package/dist/ui/layout/virtual-window.js +204 -0
  83. package/dist/ui/live-clamp.d.ts +42 -0
  84. package/dist/ui/live-clamp.js +74 -0
  85. package/dist/ui/markdown-blocks.d.ts +25 -1
  86. package/dist/ui/markdown-blocks.js +43 -0
  87. package/dist/ui/overlays/HelpOverlay.js +4 -0
  88. package/dist/ui/render-cache.d.ts +81 -0
  89. package/dist/ui/render-cache.js +195 -0
  90. package/dist/ui/render-governor.d.ts +54 -0
  91. package/dist/ui/render-governor.js +78 -0
  92. package/dist/ui/transcript-text.d.ts +11 -0
  93. package/dist/ui/transcript-text.js +5 -0
  94. package/dist/ui/use-height-store.d.ts +31 -0
  95. package/dist/ui/use-height-store.js +76 -0
  96. package/dist/ui/use-render-governor.d.ts +39 -0
  97. package/dist/ui/use-render-governor.js +57 -0
  98. package/dist/ui/use-startup-notices.d.ts +9 -1
  99. package/dist/ui/use-startup-notices.js +35 -1
  100. package/package.json +3 -2
@@ -9,10 +9,13 @@ import { isAbsolute, resolve } from 'node:path';
9
9
  import process from 'node:process';
10
10
  import { resolveOutputTokens } from '@aragon-agent/core';
11
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';
12
14
  import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
13
15
  import { loadConfig } from '../config/load.js';
14
16
  import { loadSession, resolveSessionPath, saveSession } from '../session/persist.js';
15
17
  import { getLogger } from '../logging/logger.js';
18
+ import { perfCommand } from './perf.js';
16
19
  const COMMANDS = [
17
20
  {
18
21
  name: 'help',
@@ -220,6 +223,160 @@ const COMMANDS = [
220
223
  ctx.notify('warn', `Unknown argument "${verb}" - use /team [status|on|off|max <n>].`);
221
224
  },
222
225
  },
226
+ {
227
+ name: 'todo',
228
+ description: 'Todo planning: status | on | off | panel on|off | follow <mode> | clear | continue',
229
+ /**
230
+ * `/todo on` CANNOT turn planning on in a session that started with
231
+ * `--no-todo`, for the reason `/team on` above records at length: the tool
232
+ * array is built ONCE and rebuilding it is forbidden (C-1), while
233
+ * `rebuildSystemPrompt()` IS a live path — so the naive reading produces a
234
+ * session where the model is told about a `todo_write` that was never
235
+ * registered.
236
+ *
237
+ * TWO CALLS, IN THIS ORDER, FOR BOTH `on|off` AND `panel on|off`, and
238
+ * neither pair is optional:
239
+ *
240
+ * `setTodoEnabled` re-splices `<todo_planning>` (P1-1). Without it the
241
+ * block survives `/todo off` and every call comes back
242
+ * refused while the instructions to keep calling are
243
+ * still in the model's context.
244
+ * `setTodoConfig` updates the object `App` reads at RENDER time (P1-2).
245
+ * `persistConfig` only writes the file, so without it
246
+ * `/todo panel off` reports success and changes nothing
247
+ * until the next launch.
248
+ * `persistConfig` is what survives the session, in both cases.
249
+ */
250
+ run: (ctx) => {
251
+ const arg = ctx.args.trim().toLowerCase();
252
+ const [verb, value] = arg.split(/\s+/, 2);
253
+ const controller = ctx.controller;
254
+ if (!verb || verb === 'status') {
255
+ const snapshot = controller.getTodoSnapshot();
256
+ const state = !controller.isTodoRegistered()
257
+ ? 'off for this session (started with --no-todo)'
258
+ : controller.isTodoEnabled()
259
+ ? 'on'
260
+ : 'off';
261
+ const todoCfg = controller.getTodoConfig();
262
+ const panel = todoCfg.panel ? 'on' : 'off';
263
+ // THE COUNTERS APPEAR ONLY IN `auto` MODE (§4.3): printing `0/25` under
264
+ // `notify` would advertise a budget nothing is spending.
265
+ const follow = todoCfg.followThrough === 'auto'
266
+ ? `auto (${ctx.followBudget?.used ?? 0}/` +
267
+ `${TODO_FOLLOW_LIMITS.maxAutoContinuesPerList} used)`
268
+ : todoCfg.followThrough;
269
+ if (!snapshot) {
270
+ ctx.notify('info', `No todo list. Todo planning: ${state}. Panel: ${panel}. ` +
271
+ `Follow-through: ${follow}.`);
272
+ return;
273
+ }
274
+ const active = snapshot.activeIndex >= 0
275
+ ? ` In progress: "${snapshot.items[snapshot.activeIndex]?.content ?? ''}".`
276
+ : '';
277
+ ctx.notify('info', `Todos: ${snapshot.doneCount}/${snapshot.total} done.${active} Panel: ${panel}. ` +
278
+ `Follow-through: ${follow}.`);
279
+ return;
280
+ }
281
+ if (verb === 'on' || verb === 'off') {
282
+ const enabled = verb === 'on';
283
+ // A PARTIAL `todo` section, safe only because `updatePersistedConfig`
284
+ // deep-merges it — the contract §4.2 spells out in full.
285
+ ctx.persistConfig({ todo: { enabled } });
286
+ if (!controller.isTodoRegistered()) {
287
+ ctx.notify('info', enabled
288
+ ? 'Todo planning is off for this session (started with --no-todo). Saved for next launch.'
289
+ : 'Todo planning is already off for this session. Saved for next launch.');
290
+ return;
291
+ }
292
+ controller.setTodoEnabled(enabled);
293
+ ctx.toast('success', `Todo planning ${enabled ? 'on' : 'off'}.`);
294
+ return;
295
+ }
296
+ if (verb === 'panel') {
297
+ if (value !== 'on' && value !== 'off') {
298
+ ctx.notify('warn', 'Usage: /todo panel <on|off>');
299
+ return;
300
+ }
301
+ const panel = value === 'on';
302
+ controller.setTodoConfig({ panel });
303
+ ctx.persistConfig({ todo: { panel } });
304
+ ctx.toast('success', `Todo panel ${panel ? 'on' : 'off'}.`);
305
+ return;
306
+ }
307
+ if (verb === 'follow') {
308
+ if (value !== 'notify' && value !== 'auto' && value !== 'off') {
309
+ ctx.notify('warn', 'Usage: /todo follow <notify|auto|off>');
310
+ return;
311
+ }
312
+ const followThrough = value;
313
+ // THE SAME TWO-CALL RULE AS `panel` ABOVE, minus `setTodoEnabled`:
314
+ // nothing in `<todo_planning>` mentions follow-through (D-3), so
315
+ // `todoEnabled` has no bearing here.
316
+ //
317
+ // `setTodoConfig` REBUILDS THE SYSTEM PROMPT UNCONDITIONALLY
318
+ // (`controller.ts`), and that is fine and must be left alone: the
319
+ // rebuild is idempotent for this key (the block does not mention
320
+ // follow-through, so the output is byte-identical), it costs one string
321
+ // composition PER COMMAND INVOCATION rather than per turn, and
322
+ // `setTodoConfig` is the ONLY method that updates the object `App` reads
323
+ // at decision time. A branch that bypassed it to avoid the rebuild would
324
+ // trade a free string concatenation for the exact "reports success,
325
+ // changes nothing" bug the method exists to prevent (P1-1 / P2-10).
326
+ controller.setTodoConfig({ followThrough });
327
+ ctx.persistConfig({ todo: { followThrough } });
328
+ ctx.toast('success', followThrough === 'auto'
329
+ ? `Follow-through: auto (up to ${TODO_FOLLOW_LIMITS.maxAutoContinuesPerList} ` +
330
+ 'continuations per plan).'
331
+ : `Follow-through: ${followThrough}.`);
332
+ return;
333
+ }
334
+ if (verb === 'clear') {
335
+ // REFUSED MID-RUN, which bounds the model/panel disagreement window to
336
+ // zero (§1.2): between turns the model's next `todo_write` is a full
337
+ // replacement anyway.
338
+ if (controller.isRunning()) {
339
+ ctx.notify('warn', 'Cannot clear the todo list while a run is in progress.');
340
+ return;
341
+ }
342
+ if (!controller.getTodoSnapshot()) {
343
+ ctx.notify('info', 'No todo list.');
344
+ return;
345
+ }
346
+ controller.clearTodos();
347
+ ctx.toast('info', 'Todo list cleared.');
348
+ return;
349
+ }
350
+ if (verb === 'continue') {
351
+ // REFUSED WHILE RUNNING TOO (P2-7). `ctx.submit` is the App's
352
+ // `submitMessage`, which routes to `controller.steer()` when the status
353
+ // is `running` — so the un-refused form would inject "continue the
354
+ // remaining items" into the run that is already doing exactly that.
355
+ if (controller.isRunning()) {
356
+ ctx.notify('warn', 'Already running.');
357
+ return;
358
+ }
359
+ const snapshot = controller.getTodoSnapshot();
360
+ if (!snapshot || snapshot.doneCount >= snapshot.total) {
361
+ ctx.notify('info', 'Nothing left to continue.');
362
+ return;
363
+ }
364
+ // THE SAME FUNCTION THE AUTO PATH USES, which is what keeps the two from
365
+ // drifting (D-11 / D-12). Round 1's fixed string "Continue with the
366
+ // remaining todo items." was a REFERENCE to a `todo_write` call that a
367
+ // long session may have scrolled out of view.
368
+ //
369
+ // `{ userInitiated: false }`: the user typed `/todo continue`, which the
370
+ // slash history already has. Putting this canned paragraph under the
371
+ // composer's up-arrow as well would be the same event recorded twice, in
372
+ // the more verbose of the two forms (AC-17).
373
+ ctx.submit(buildContinuationMessage(snapshot), { userInitiated: false });
374
+ return;
375
+ }
376
+ ctx.notify('warn', `Unknown argument "${verb}" - use ` +
377
+ '/todo [status|on|off|panel on|off|follow <mode>|clear|continue].');
378
+ },
379
+ },
223
380
  {
224
381
  name: 'expand',
225
382
  description: 'Expand / collapse a recent tool card (default: last)',
@@ -296,6 +453,9 @@ const COMMANDS = [
296
453
  },
297
454
  messages: ctx.controller.getMessages(),
298
455
  entries: ctx.state.entries,
456
+ // `/resume` restores `messages`, so the belief that justifies the
457
+ // panel comes back — and the panel has to come back with it (§3.13).
458
+ todos: ctx.controller.getTodoSnapshot()?.items ?? [],
299
459
  });
300
460
  ctx.notify('info', `Saved session to ${path}`);
301
461
  }
@@ -312,6 +472,13 @@ const COMMANDS = [
312
472
  const path = resolveSessionPath(ctx.args, ctx.controller.getCwd());
313
473
  const session = loadSession(path);
314
474
  ctx.controller.replaceMessages(session.messages);
475
+ // IN THE SAME `try`, IMMEDIATELY AFTER `replaceMessages`, so a malformed
476
+ // file fails the whole resume rather than half of it — and AN ABSENT
477
+ // `todos` CLEARS RATHER THAN SKIPPING (P0-2 / D-22). `replaceMessages`
478
+ // has just discarded the conversation the current list belonged to, so
479
+ // leaving the rail up produces a panel that disagrees with the model,
480
+ // which §1.2 ranks below having no panel at all.
481
+ ctx.controller.restoreTodos(session.todos ?? []);
315
482
  ctx.dispatch({ type: 'restoreEntries', entries: session.entries });
316
483
  if (session.model?.providerId && session.model?.modelId) {
317
484
  ctx.controller.setModel(session.model.providerId, session.model.modelId, session.model.baseUrl);
@@ -369,6 +536,10 @@ const COMMANDS = [
369
536
  ctx.toast('success', `Reloaded config: ${config.provider}:${config.model}.`);
370
537
  },
371
538
  },
539
+ // Lives in its own module because it is the ONLY built-in that reads the
540
+ // render layer, and threading `ui/render-cache.js` into this file would make
541
+ // every other command's import graph pull the renderer in with it.
542
+ perfCommand,
372
543
  {
373
544
  name: 'exit',
374
545
  aliases: ['quit'],
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `/perf` — what the renderer is actually doing (tui-render-performance §5.4).
3
+ *
4
+ * SILENT DEGRADATION IS THE ONE THING A "robust" CLAIM CANNOT AFFORD. The
5
+ * governor makes the stream chunkier under load, the retain ring drops old
6
+ * entries, and virtualisation mounts a fraction of the transcript. Every one of
7
+ * those is invisible by design, so there has to be one place that names them.
8
+ *
9
+ * The snapshot crosses the React boundary through a module-level single slot,
10
+ * the shape `ui/exit-snapshot.ts` already uses and for the same reason: a
11
+ * command runs outside the render pass and has no other way to reach `App`'s
12
+ * refs.
13
+ *
14
+ * READ-ONLY AND ALLOCATION-LIGHT. It renders as a NOTICE ENTRY rather than a
15
+ * live panel, because a performance readout that costs a repaint per second to
16
+ * display would be self-defeating.
17
+ */
18
+ import type { SlashCommand } from './registry.js';
19
+ export interface PerfSnapshot {
20
+ /** Governor rung, its resolved interval, and the last measured commit cost. */
21
+ rung: number;
22
+ intervalMs: number;
23
+ lastCommitMs: number;
24
+ governorEnabled: boolean;
25
+ /** Transcript accounting. */
26
+ totalEntries: number;
27
+ retain: number;
28
+ droppedEntries: number;
29
+ mountedEntries: number;
30
+ /** Height cache. */
31
+ heightsMeasured: number;
32
+ heightsEstimated: number;
33
+ cols: number;
34
+ /** Geometry. */
35
+ mode: 'fullscreen' | 'inline';
36
+ viewportRows: number;
37
+ offset: number;
38
+ }
39
+ /** Called by `App`. Passing `null` detaches. */
40
+ export declare function setPerfSnapshotProvider(fn: (() => PerfSnapshot | null) | null): void;
41
+ export declare function readPerfSnapshot(): PerfSnapshot | null;
42
+ export declare function setPerfResetHook(fn: (() => void) | null): void;
43
+ /** Format a snapshot as the five-line block documented in §5.4. */
44
+ export declare function formatPerfReport(snap: PerfSnapshot): string;
45
+ export declare const perfCommand: SlashCommand;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `/perf` — what the renderer is actually doing (tui-render-performance §5.4).
3
+ *
4
+ * SILENT DEGRADATION IS THE ONE THING A "robust" CLAIM CANNOT AFFORD. The
5
+ * governor makes the stream chunkier under load, the retain ring drops old
6
+ * entries, and virtualisation mounts a fraction of the transcript. Every one of
7
+ * those is invisible by design, so there has to be one place that names them.
8
+ *
9
+ * The snapshot crosses the React boundary through a module-level single slot,
10
+ * the shape `ui/exit-snapshot.ts` already uses and for the same reason: a
11
+ * command runs outside the render pass and has no other way to reach `App`'s
12
+ * refs.
13
+ *
14
+ * READ-ONLY AND ALLOCATION-LIGHT. It renders as a NOTICE ENTRY rather than a
15
+ * live panel, because a performance readout that costs a repaint per second to
16
+ * display would be self-defeating.
17
+ */
18
+ import { clearRenderCaches, renderCacheStats } from '../ui/render-cache.js';
19
+ /** Cleared on unmount, so `/perf` in a torn-down tree says so instead of lying. */
20
+ let provider = null;
21
+ /** Called by `App`. Passing `null` detaches. */
22
+ export function setPerfSnapshotProvider(fn) {
23
+ provider = fn;
24
+ }
25
+ export function readPerfSnapshot() {
26
+ return provider ? provider() : null;
27
+ }
28
+ /** Reset hooks the App registers alongside the snapshot provider. */
29
+ let resetGovernor = null;
30
+ export function setPerfResetHook(fn) {
31
+ resetGovernor = fn;
32
+ }
33
+ function mb(bytes) {
34
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
35
+ }
36
+ /** Format a snapshot as the five-line block documented in §5.4. */
37
+ export function formatPerfReport(snap) {
38
+ const caches = renderCacheStats();
39
+ const eco = snap.rung > 0 ? ' - eco' : '';
40
+ const governor = snap.governorEnabled ? '' : ' - governor off';
41
+ return [
42
+ `render rung ${snap.rung} - interval ${snap.intervalMs}ms - last commit ` +
43
+ `${Math.round(snap.lastCommitMs)}ms${eco}${governor}`,
44
+ `transcript ${snap.totalEntries} entries - ${snap.retain} retained - ` +
45
+ `${snap.droppedEntries} dropped - ${snap.mountedEntries} mounted`,
46
+ `heights ${snap.heightsMeasured} cached - ${snap.heightsEstimated} estimated - ` +
47
+ `cols ${snap.cols}`,
48
+ `caches md ${caches.markdownEntries} (${mb(caches.markdownBytes)}) - ` +
49
+ `hl ${caches.highlightEntries} (${mb(caches.highlightBytes)}) - ` +
50
+ `lines ${caches.lineEntries}`,
51
+ `mode ${snap.mode} - viewport ${snap.viewportRows} rows - offset ${snap.offset}`,
52
+ ].join('\n');
53
+ }
54
+ export const perfCommand = {
55
+ name: 'perf',
56
+ description: 'Render statistics (/perf reset clears the caches and the governor)',
57
+ run: (ctx) => {
58
+ const arg = ctx.args.trim().toLowerCase();
59
+ if (arg === 'reset') {
60
+ clearRenderCaches();
61
+ resetGovernor?.();
62
+ ctx.toast('success', 'Render caches cleared, governor reset.');
63
+ return;
64
+ }
65
+ if (arg.length > 0) {
66
+ ctx.notify('warn', `Unknown argument "${arg}" - use /perf [reset].`);
67
+ return;
68
+ }
69
+ const snap = readPerfSnapshot();
70
+ if (!snap) {
71
+ // Reachable from a headless / detached tree. Saying so beats printing a
72
+ // block of zeroes that reads like a renderer doing nothing.
73
+ ctx.notify('info', 'No render statistics: the TUI is not mounted.');
74
+ return;
75
+ }
76
+ ctx.notify('info', formatPerfReport(snap));
77
+ },
78
+ };
@@ -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
  /**
@@ -7,7 +7,9 @@
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
8
  * ARAGON_MAX_TOKENS (a number, or `auto` / `0` for the per-model ceiling),
9
9
  * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN, ARAGON_TEAM,
10
- * ARAGON_TEAM_MAX, ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR.
10
+ * ARAGON_TEAM_MAX, ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR,
11
+ * ARAGON_TRANSCRIPT_RETAIN, ARAGON_RENDER_GOVERNOR,
12
+ * ARAGON_MAX_RENDER_INTERVAL_MS.
11
13
  *
12
14
  * NOT here: `ARAGON_HOME`. It is read when `config/app-paths.ts` is first
13
15
  * evaluated, which is long before `loadDotenv()` runs, so a `.env` can never
@@ -7,7 +7,9 @@
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
8
  * ARAGON_MAX_TOKENS (a number, or `auto` / `0` for the per-model ceiling),
9
9
  * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN, ARAGON_TEAM,
10
- * ARAGON_TEAM_MAX, ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR.
10
+ * ARAGON_TEAM_MAX, ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR,
11
+ * ARAGON_TRANSCRIPT_RETAIN, ARAGON_RENDER_GOVERNOR,
12
+ * ARAGON_MAX_RENDER_INTERVAL_MS.
11
13
  *
12
14
  * NOT here: `ARAGON_HOME`. It is read when `config/app-paths.ts` is first
13
15
  * evaluated, which is long before `loadDotenv()` runs, so a `.env` can never
@@ -134,6 +136,77 @@ export function readEnvConfig() {
134
136
  if (Object.keys(section).length > 0)
135
137
  partial.team = section;
136
138
  }
139
+ // ARAGON_TODO=0 turns todo planning off entirely (no tool, no rail). Parsed by
140
+ // the POSITIVE list, exactly as ARAGON_FULLSCREEN / ARAGON_MOUSE / ARAGON_PLAN
141
+ // / ARAGON_TEAM are — NOT by `envBool` below, which uses a negative list. The
142
+ // two readers disagree on values like `ARAGON_TODO=disable`, and this file
143
+ // already records that once for team mode.
144
+ //
145
+ // There is no `ARAGON_TODO_PANEL`: the panel is a per-machine display
146
+ // preference, which is what `config.json` is for, and every env var here
147
+ // exists because a flag and a config file are BOTH unreachable in the channel
148
+ // it serves (containers, SSH, being spawned by another tool).
149
+ //
150
+ // ARAGON_TODO_FOLLOW=notify|auto|off PASSES THAT SAME TEST (D-18), which is
151
+ // why it exists where `ARAGON_TODO_PANEL` does not: follow-through is exactly
152
+ // the knob an unattended `-p` run in a container with a fixed entrypoint needs
153
+ // and can reach no other way.
154
+ //
155
+ // ONE ACCUMULATED SECTION, ASSIGNED ONCE — the shape the `team` branch above
156
+ // already uses, and NOT two `if` blocks each assigning `partial.todo` (P1-4).
157
+ // THE CAST IS WHAT WOULD MAKE THAT MISTAKE SILENT: a second assignment
158
+ // compiles clean and drops `enabled`, so `ARAGON_TODO=0 ARAGON_TODO_FOLLOW=auto`
159
+ // would quietly re-enable the tool the user turned off.
160
+ const todo = process.env.ARAGON_TODO?.trim().toLowerCase();
161
+ const todoFollow = process.env.ARAGON_TODO_FOLLOW?.trim().toLowerCase();
162
+ if ((todo !== undefined && todo.length > 0) ||
163
+ (todoFollow !== undefined && todoFollow.length > 0)) {
164
+ // Keyed by `TodoConfig` but VALUED `unknown`, which is what lets the mode be
165
+ // written through unvalidated (below) while still failing to compile if a
166
+ // future key is misspelled.
167
+ const section = {};
168
+ if (todo !== undefined && todo.length > 0) {
169
+ section.enabled = todo === '1' || todo === 'true' || todo === 'on' || todo === 'yes';
170
+ }
171
+ // Written through unvalidated: `clampTodoConfig` clamps an unrecognized mode
172
+ // to the default rather than rejecting it, so validating here would be a
173
+ // second gate that can disagree with the first.
174
+ if (todoFollow !== undefined && todoFollow.length > 0) {
175
+ section.followThrough = todoFollow;
176
+ }
177
+ // A PARTIAL section, safe only because every consumer deep-merges it — the
178
+ // same contract `partial.skills`, `partial.log` and `partial.team` rely on.
179
+ if (Object.keys(section).length > 0)
180
+ partial.todo = section;
181
+ }
182
+ // Render bounds (tui-render-performance §5.3). Same channel story as
183
+ // ARAGON_FULLSCREEN: a container or a wrapper that owns argv can reach neither
184
+ // a flag nor a config file, and a session that freezes is exactly the case
185
+ // where the user needs a knob they can turn from the outside.
186
+ //
187
+ // ARAGON_TRANSCRIPT_RETAIN / ARAGON_MAX_RENDER_INTERVAL_MS are left ABSENT
188
+ // when unparseable rather than written as a default, so the config file can
189
+ // still win — the rule `ARAGON_TEAM_MAX` above records.
190
+ const retain = process.env.ARAGON_TRANSCRIPT_RETAIN?.trim();
191
+ if (retain !== undefined && retain.length > 0) {
192
+ const parsed = Number.parseInt(retain, 10);
193
+ if (Number.isFinite(parsed) && parsed > 0)
194
+ partial.transcriptRetain = parsed;
195
+ }
196
+ // Parsed by the POSITIVE list, exactly as ARAGON_FULLSCREEN / ARAGON_MOUSE /
197
+ // ARAGON_PLAN / ARAGON_TEAM / ARAGON_TODO are — NOT by `envBool` below, which
198
+ // uses a negative list. The two readers disagree on `ARAGON_RENDER_GOVERNOR=disable`.
199
+ const governor = process.env.ARAGON_RENDER_GOVERNOR?.trim().toLowerCase();
200
+ if (governor !== undefined && governor.length > 0) {
201
+ partial.renderGovernor =
202
+ governor === '1' || governor === 'true' || governor === 'on' || governor === 'yes';
203
+ }
204
+ const renderInterval = process.env.ARAGON_MAX_RENDER_INTERVAL_MS?.trim();
205
+ if (renderInterval !== undefined && renderInterval.length > 0) {
206
+ const parsed = Number.parseInt(renderInterval, 10);
207
+ if (Number.isFinite(parsed) && parsed > 0)
208
+ partial.maxRenderIntervalMs = parsed;
209
+ }
137
210
  const log = readEnvLogConfig();
138
211
  if (log)
139
212
  partial.log = log;
@@ -52,12 +52,53 @@ 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. */
58
85
  logFile?: boolean;
59
86
  /** `--log-dir <dir>` — this run only. */
60
87
  logDir?: string;
88
+ /** `--transcript-retain <n>` — the `ViewState` ring cap for this run (L1). */
89
+ transcriptRetain?: string | number;
90
+ /**
91
+ * `--render-governor` ⇒ true, `--no-render-governor` ⇒ false, absent ⇒
92
+ * undefined (L4).
93
+ *
94
+ * DECLARED AS A PAIR for the reason `--todo-panel` records: `renderGovernor`
95
+ * is PERSISTED, so a lone `--no-render-governor` would make commander default
96
+ * `opts.renderGovernor` to `true` and silently overwrite a stored `false` on
97
+ * every run that passed no flag at all.
98
+ */
99
+ renderGovernor?: boolean;
100
+ /** `--max-render-interval <ms>` — the governor ceiling for this run (L4). */
101
+ maxRenderInterval?: string | number;
61
102
  }
62
103
  /**
63
104
  * Resolve the effective config by merging all layers, then derive the runtime
@@ -6,11 +6,12 @@
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, clampMaxTokens, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, } from './schema.js';
9
+ import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampLogConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampTheme, clampThinkingLevel, clampMaxTokens, clampMaxRenderInterval, clampTranscriptRetain, 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';
13
13
  import { setHistoryEnabled } from './prompt-history.js';
14
+ import { setEntryRetain } from '../agent/entry-limits.js';
14
15
  import { readSubmitCount } from './ui-state.js';
15
16
  import { getLogger } from '../logging/logger.js';
16
17
  import { registerSecret, registerSecretsFrom } from '../logging/secret-registry.js';
@@ -97,6 +98,48 @@ function resolveTeamConfig(flags, env, file) {
97
98
  : {}),
98
99
  });
99
100
  }
101
+ /**
102
+ * Merge the `todo` section across all four layers (defaults › file › env ›
103
+ * flags), todo-plan-execution §4.3.
104
+ *
105
+ * `!== undefined` ON BOTH FLAGS, and that is the whole point (P1-8). `commander`
106
+ * materializes a lone `--no-x` as `opts.x = true` when the flag is absent, so a
107
+ * truthiness check cannot tell `--no-todo-panel` from "not passed" — and both
108
+ * keys here are persisted, so both need the distinction. This is the same rule
109
+ * `resolveTeamConfig` above states, one feature later.
110
+ */
111
+ function resolveTodoConfig(flags, env, file) {
112
+ return clampTodoConfig({
113
+ ...DEFAULT_CONFIG.todo,
114
+ ...(file.todo ?? {}),
115
+ ...(env.todo ?? {}),
116
+ ...(flags.todo !== undefined ? { enabled: flags.todo } : {}),
117
+ ...(flags.todoPanel !== undefined ? { panel: flags.todoPanel } : {}),
118
+ // `!== undefined` for the third time, though for a value flag the risk is
119
+ // milder: commander leaves `opts.todoFollow` absent unless it was passed.
120
+ // Written this way anyway so the three lines read as one rule.
121
+ ...(flags.todoFollow !== undefined ? { followThrough: flags.todoFollow } : {}),
122
+ });
123
+ }
124
+ /**
125
+ * Resolve the adaptive render governor: flag › env › file › default `true`
126
+ * (tui-render-performance L4).
127
+ *
128
+ * Shaped like `resolveMouse` rather than like `resolveFullscreen`: there are no
129
+ * heuristics for an explicit `true` to override, so a persisted `true` is just a
130
+ * value and not an opinion that has to be told apart from silence. `!==
131
+ * undefined` on the flag is what makes `--no-render-governor` beat a config file
132
+ * that turned it on (P1-4's rule, one feature later).
133
+ */
134
+ function resolveRenderGovernor(flags, env, file) {
135
+ if (flags.renderGovernor !== undefined)
136
+ return flags.renderGovernor;
137
+ if (env.renderGovernor !== undefined)
138
+ return env.renderGovernor;
139
+ if (file.renderGovernor !== undefined)
140
+ return file.renderGovernor;
141
+ return DEFAULT_CONFIG.renderGovernor;
142
+ }
100
143
  /** Merge the `log` section across all four layers (defaults › file › env › flags). */
101
144
  function resolveLogConfig(flags, env, file) {
102
145
  const merged = clampLogConfig({
@@ -245,6 +288,23 @@ export function loadConfig(flags = {}) {
245
288
  // The prompt-history store is a module-level singleton, so the resolved value
246
289
  // has to be pushed into it — this is the ONLY place that happens.
247
290
  setHistoryEnabled(historyEnabled);
291
+ // Transcript bounds (tui-render-performance L1 / L3).
292
+ //
293
+ // `transcriptRetain` is RAISED TO `transcriptWindow` when the two conflict:
294
+ // retaining fewer entries than the window can scroll to would silently make
295
+ // part of the window unreachable, and the user would see a horizon they can
296
+ // never actually reach.
297
+ //
298
+ // The raise is REPORTED, not just performed (§5.1). Overriding a value the
299
+ // user typed and saying nothing is the one thing this feature is not allowed
300
+ // to add — every other adjustment it makes is announced somewhere, and a
301
+ // config key is the last place to start making exceptions.
302
+ const transcriptWindow = clampTranscriptWindow(pick(env.partial.transcriptWindow, file.transcriptWindow), DEFAULT_CONFIG.transcriptWindow);
303
+ const requestedRetain = clampTranscriptRetain(pick(flags.transcriptRetain, env.partial.transcriptRetain, file.transcriptRetain), DEFAULT_CONFIG.transcriptRetain);
304
+ const transcriptRetain = Math.max(transcriptWindow, requestedRetain);
305
+ // `viewReducer` is pure and takes no config, so the resolved ring cap reaches
306
+ // it through the same module-singleton channel `setHistoryEnabled` uses.
307
+ setEntryRetain(transcriptRetain);
248
308
  const config = {
249
309
  provider,
250
310
  model,
@@ -257,7 +317,11 @@ export function loadConfig(flags = {}) {
257
317
  exitTranscript: flags.exitTranscript !== undefined
258
318
  ? flags.exitTranscript
259
319
  : file.exitTranscript ?? DEFAULT_CONFIG.exitTranscript,
260
- transcriptWindow: clampTranscriptWindow(file.transcriptWindow, DEFAULT_CONFIG.transcriptWindow),
320
+ transcriptWindow,
321
+ transcriptRetain,
322
+ ...(requestedRetain < transcriptRetain ? { transcriptRetainRequested: requestedRetain } : {}),
323
+ renderGovernor: resolveRenderGovernor(flags, env.partial, file),
324
+ maxRenderIntervalMs: clampMaxRenderInterval(pick(flags.maxRenderInterval, env.partial.maxRenderIntervalMs, file.maxRenderIntervalMs), DEFAULT_CONFIG.maxRenderIntervalMs),
261
325
  confirmTools,
262
326
  toolTimeoutMs,
263
327
  idleTimeoutMs,
@@ -285,6 +349,7 @@ export function loadConfig(flags = {}) {
285
349
  },
286
350
  log: resolveLogConfig(flags, env.partial, file),
287
351
  team: resolveTeamConfig(flags, env.partial, file),
352
+ todo: resolveTodoConfig(flags, env.partial, file),
288
353
  cwd,
289
354
  color,
290
355
  colorLevel,