@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,9 +9,10 @@
9
9
  * from the event stream by the reducer (TUI) and the headless writer.
10
10
  */
11
11
  import { ModelRegistry, type AgentEvent, type AgentTool, type Message, type ModelInfo, type ProviderRegistry, type ThinkingLevel } from '@aragon-agent/core';
12
- import type { CliConfig, TeamConfig, ThemeName } from '../config/schema.js';
12
+ import type { CliConfig, TeamConfig, ThemeName, TodoConfig } from '../config/schema.js';
13
13
  import { type ConfirmRequest } from '../tools/index.js';
14
14
  import type { TeamEvent, TeamSnapshot } from '../team/types.js';
15
+ import type { TodoEventListener, TodoSnapshot } from '../todo/types.js';
15
16
  import { type HumanInputGate } from '../tools/human-input.js';
16
17
  import { SkillService, type ApprovalGate } from '../skills/service.js';
17
18
  import type { AgentMode } from './agent-mode.js';
@@ -48,6 +49,23 @@ export interface ControllerDeps {
48
49
  * wizard or a plan card.
49
50
  */
50
51
  humanInput?: HumanInputGate;
52
+ /**
53
+ * Whether this session could EVER render the todo rail (todo-plan-execution
54
+ * §3.7 / P1-6). Full-screen and interactive; `-p` and inline mode are false.
55
+ *
56
+ * It varies ONE SENTENCE of the `<todo_planning>` block, and nothing else.
57
+ * Telling the model "the user sees this list in a panel beside the
58
+ * conversation" under `-p` is the same error `composeSystemPrompt` already
59
+ * guards against for team mode with its two flags. Defaults to `false` so a
60
+ * caller that forgets it gets the honest sentence rather than a false claim
61
+ * about the user's screen.
62
+ *
63
+ * Deliberately STATIC (D-25). The two dynamic causes — a resize below
64
+ * `TODO_RAIL_MIN_TOTAL_COLS` and an open overlay — do NOT re-compose the
65
+ * prompt: rebuilding it on every resize would rewrite the system prompt
66
+ * mid-run, and the sentence is guidance rather than a contract.
67
+ */
68
+ todoPanelCapable?: boolean;
51
69
  }
52
70
  /** What `setAgentMode` actually adopted — see `AgentController.setAgentMode`. */
53
71
  export interface AgentModeState {
@@ -84,6 +102,11 @@ export declare class AgentController {
84
102
  private readonly teamRegistered;
85
103
  private teamEnabled;
86
104
  private readonly teamRuntime;
105
+ private readonly todoRegistered;
106
+ private todoEnabled;
107
+ private readonly todos;
108
+ /** Static: whether this session could ever render the rail. See the dep. */
109
+ private readonly todoPanelCapable;
87
110
  constructor(config: CliConfig, deps?: ControllerDeps);
88
111
  private resolveKey;
89
112
  /**
@@ -229,6 +252,53 @@ export declare class AgentController {
229
252
  subscribeTeam(listener: (event: TeamEvent) => void): () => void;
230
253
  /** Abort every child and clear timers. Idempotent; safe to call twice. */
231
254
  dispose(): void;
255
+ /** Whether `todo_write` is in `this.tools`. Fixed at construction (C-1). */
256
+ isTodoRegistered(): boolean;
257
+ /** Whether `todo_write` will actually write. Flipped live by `/todo on|off`. */
258
+ isTodoEnabled(): boolean;
259
+ /**
260
+ * Flip the live switch and RE-RENDER THE PROMPT.
261
+ *
262
+ * THE REBUILD IS NOT OPTIONAL (P1-1 / AC-37). `composeSystemPrompt()` gates
263
+ * `todoBlock` on `todoRegistered && todoEnabled`; without the rebuild
264
+ * `<todo_planning>` survives `/todo off`, every call comes back with
265
+ * `TODO_OFF_REFUSAL`, and the instructions telling the model to keep calling
266
+ * are still in its own context — a refusal loop it cannot diagnose.
267
+ *
268
+ * Turning it ON in a session that started with `--no-todo` deliberately does
269
+ * NOT register the tool: the array is immutable (C-1), and advertising an
270
+ * unregistered tool is the dead end `/team on` already documents. `/todo`
271
+ * reports that honestly by checking `isTodoRegistered()` first.
272
+ */
273
+ setTodoEnabled(enabled: boolean): void;
274
+ getTodoConfig(): TodoConfig;
275
+ /**
276
+ * Adopt a new todo config for this session.
277
+ *
278
+ * THIS EXISTS BECAUSE `persistConfig` DOES NOT TOUCH THE RUNTIME (P1-2 /
279
+ * AC-38). `App` reads `controller.getConfig()` on every render, and that
280
+ * returns THIS object; the App's `persistConfig` only writes the file. So
281
+ * `/todo panel off` must call both, exactly as `/team max` does — with only
282
+ * the persist call the command reports success and changes nothing until the
283
+ * next launch.
284
+ *
285
+ * Clamped here as well as on the persist path, so a bad value can reach
286
+ * neither the runtime nor the file. The prompt rebuild carries §3.7's
287
+ * `panelVisible` sentence, which is part of the block.
288
+ */
289
+ setTodoConfig(patch: Partial<TodoConfig>): TodoConfig;
290
+ getTodoSnapshot(): TodoSnapshot | null;
291
+ /**
292
+ * Subscribe to the CLI-local todo event stream.
293
+ *
294
+ * Returns a no-op unsubscribe when todo planning was off at construction, so
295
+ * every caller can subscribe unconditionally.
296
+ */
297
+ subscribeTodos(listener: TodoEventListener): () => void;
298
+ /** `/resume`. An absent or empty list CLEARS — see `TodoStore.restore` (P0-2). */
299
+ restoreTodos(items: unknown): void;
300
+ /** `/todo clear`. A user override of a projection; refused mid-run by `/todo`. */
301
+ clearTodos(): void;
232
302
  setModel(provider: string, model: string, baseUrl?: string): void;
233
303
  setThinkingLevel(level: ThinkingLevel): void;
234
304
  setMaxTokens(value: number | undefined): void;
@@ -9,7 +9,7 @@
9
9
  * from the event stream by the reducer (TUI) and the headless writer.
10
10
  */
11
11
  import { Agent, initProviders, ModelRegistry, } from '@aragon-agent/core';
12
- import { clampTeamConfig, isAdapterProvider } from '../config/schema.js';
12
+ import { clampTeamConfig, clampTodoConfig, isAdapterProvider } from '../config/schema.js';
13
13
  import { makeGetApiKey } from '../config/load.js';
14
14
  import { updatePersistedConfig } from '../config/store.js';
15
15
  import { createBuiltinTools } from '../tools/index.js';
@@ -17,6 +17,9 @@ import { TeamRuntime } from '../team/runtime.js';
17
17
  import { TeamHumanQueue } from '../team/human-queue.js';
18
18
  import { createTaskTool } from '../team/task-tool.js';
19
19
  import { buildTeamBlock } from '../team/prompt.js';
20
+ import { TodoStore } from '../todo/store.js';
21
+ import { createTodoTool } from '../todo/todo-tool.js';
22
+ import { buildTodoBlock } from '../todo/prompt.js';
20
23
  import { createPlanTools } from '../tools/plan-tools.js';
21
24
  import { DENY_ALL_HUMAN_INPUT } from '../tools/human-input.js';
22
25
  import { createNodeSkillHost } from '../skills/node-host.js';
@@ -71,6 +74,26 @@ export class AgentController {
71
74
  teamRegistered;
72
75
  teamEnabled;
73
76
  teamRuntime;
77
+ // -----------------------------------------------------------------------
78
+ // Todo planning (todo-plan-execution §3.6a) — THE SAME TWO FLAGS, FOR THE
79
+ // SAME REASON.
80
+ //
81
+ // `todoRegistered` is decided ONCE, at construction: whether `todo_write` is
82
+ // in `this.tools` at all. The array is immutable for the life of the session
83
+ // (C-1), so `/todo on` in a session started with `--no-todo` CANNOT add it.
84
+ //
85
+ // `todoEnabled` is live. `/todo off` flips it, which makes `todo_write` refuse
86
+ // through its own closure AND drops `<todo_planning>` from the next prompt
87
+ // rebuild — and BOTH halves are required (P1-1). Flip the flag without the
88
+ // rebuild and the block survives `/todo off`: every call comes back with the
89
+ // refusal while the instructions telling the model to keep calling are still
90
+ // in its own context, so it cannot diagnose the loop.
91
+ // -----------------------------------------------------------------------
92
+ todoRegistered;
93
+ todoEnabled;
94
+ todos;
95
+ /** Static: whether this session could ever render the rail. See the dep. */
96
+ todoPanelCapable;
74
97
  constructor(config, deps = {}) {
75
98
  this.config = config;
76
99
  this.cwd = config.cwd;
@@ -104,6 +127,11 @@ export class AgentController {
104
127
  this.skills.reportUnknownForcedSkills();
105
128
  }
106
129
  const confirm = deps.confirm;
130
+ // --- Todo planning -----------------------------------------------------
131
+ this.todoRegistered = config.todo.enabled;
132
+ this.todoEnabled = config.todo.enabled;
133
+ this.todoPanelCapable = deps.todoPanelCapable === true;
134
+ this.todos = this.todoRegistered ? new TodoStore() : null;
107
135
  // --- Team mode ---------------------------------------------------------
108
136
  this.teamRegistered = config.team.enabled;
109
137
  this.teamEnabled = config.team.enabled;
@@ -167,6 +195,18 @@ export class AgentController {
167
195
  withHumanWait: (fn) => this.withHumanWait(fn),
168
196
  onPlanApproved: () => this.setAgentMode('build', { force: true }),
169
197
  }),
198
+ // THROUGH THE FACTORY for the reason `teamTools` records below (C-2), and
199
+ // empty when todo planning was off at construction — which leaves the
200
+ // array byte-identical to the pre-todo build (AC-30).
201
+ todoTools: this.todos
202
+ ? [
203
+ createTodoTool({
204
+ store: this.todos,
205
+ // Read LIVE: `/todo off` flips the flag, it cannot unregister.
206
+ isEnabled: () => this.todoEnabled,
207
+ }),
208
+ ]
209
+ : [],
170
210
  // THROUGH THE FACTORY, NEVER APPENDED AFTERWARDS (D-15 / P0-1).
171
211
  // `tools.test.ts::C7` asserts `HOST_TOOL_NAMES` equals what
172
212
  // `createBuiltinTools` produces, so appending `task` here would turn that
@@ -286,6 +326,16 @@ export class AgentController {
286
326
  maxConcurrent: this.config.team.maxConcurrent,
287
327
  })
288
328
  : '',
329
+ // BOTH FLAGS, exactly as team mode uses both, and for the identical
330
+ // reason: `todoRegistered` false means there is no `todo_write` to
331
+ // advertise, `todoEnabled` false means the user turned it off for this
332
+ // session, and advertising in either case would describe a capability the
333
+ // model does not have.
334
+ todoBlock: this.todoRegistered && this.todoEnabled
335
+ ? buildTodoBlock({
336
+ panelVisible: this.todoPanelCapable && this.config.todo.panel,
337
+ })
338
+ : '',
289
339
  });
290
340
  }
291
341
  rebuildSystemPrompt() {
@@ -382,6 +432,11 @@ export class AgentController {
382
432
  // steering "actually, use Postgres" into a running plan is answering the
383
433
  // questions, not buying four more rounds of them.
384
434
  this.askRounds = 0;
435
+ // Same asymmetry, same reason (§3.5): a completed plan is dropped HERE
436
+ // rather than at `agent_end`, so the user gets their `7/7 done` moment, and
437
+ // an unfinished one survives "continue" / "now do the rest" but not four
438
+ // unrelated turns.
439
+ this.todos?.beginUserTurn();
385
440
  return this.agent.prompt(text);
386
441
  }
387
442
  abort() {
@@ -579,6 +634,80 @@ export class AgentController {
579
634
  this.teamRuntime?.dispose();
580
635
  }
581
636
  // -----------------------------------------------------------------------
637
+ // Todo planning (todo-plan-execution §3.6a)
638
+ // -----------------------------------------------------------------------
639
+ /** Whether `todo_write` is in `this.tools`. Fixed at construction (C-1). */
640
+ isTodoRegistered() {
641
+ return this.todoRegistered;
642
+ }
643
+ /** Whether `todo_write` will actually write. Flipped live by `/todo on|off`. */
644
+ isTodoEnabled() {
645
+ return this.todoEnabled;
646
+ }
647
+ /**
648
+ * Flip the live switch and RE-RENDER THE PROMPT.
649
+ *
650
+ * THE REBUILD IS NOT OPTIONAL (P1-1 / AC-37). `composeSystemPrompt()` gates
651
+ * `todoBlock` on `todoRegistered && todoEnabled`; without the rebuild
652
+ * `<todo_planning>` survives `/todo off`, every call comes back with
653
+ * `TODO_OFF_REFUSAL`, and the instructions telling the model to keep calling
654
+ * are still in its own context — a refusal loop it cannot diagnose.
655
+ *
656
+ * Turning it ON in a session that started with `--no-todo` deliberately does
657
+ * NOT register the tool: the array is immutable (C-1), and advertising an
658
+ * unregistered tool is the dead end `/team on` already documents. `/todo`
659
+ * reports that honestly by checking `isTodoRegistered()` first.
660
+ */
661
+ setTodoEnabled(enabled) {
662
+ this.todoEnabled = enabled;
663
+ this.rebuildSystemPrompt();
664
+ }
665
+ getTodoConfig() {
666
+ return this.config.todo;
667
+ }
668
+ /**
669
+ * Adopt a new todo config for this session.
670
+ *
671
+ * THIS EXISTS BECAUSE `persistConfig` DOES NOT TOUCH THE RUNTIME (P1-2 /
672
+ * AC-38). `App` reads `controller.getConfig()` on every render, and that
673
+ * returns THIS object; the App's `persistConfig` only writes the file. So
674
+ * `/todo panel off` must call both, exactly as `/team max` does — with only
675
+ * the persist call the command reports success and changes nothing until the
676
+ * next launch.
677
+ *
678
+ * Clamped here as well as on the persist path, so a bad value can reach
679
+ * neither the runtime nor the file. The prompt rebuild carries §3.7's
680
+ * `panelVisible` sentence, which is part of the block.
681
+ */
682
+ setTodoConfig(patch) {
683
+ const todo = clampTodoConfig({ ...this.config.todo, ...patch });
684
+ this.config = { ...this.config, todo };
685
+ this.rebuildSystemPrompt();
686
+ return todo;
687
+ }
688
+ getTodoSnapshot() {
689
+ return this.todos?.snapshot() ?? null;
690
+ }
691
+ /**
692
+ * Subscribe to the CLI-local todo event stream.
693
+ *
694
+ * Returns a no-op unsubscribe when todo planning was off at construction, so
695
+ * every caller can subscribe unconditionally.
696
+ */
697
+ subscribeTodos(listener) {
698
+ if (!this.todos)
699
+ return () => { };
700
+ return this.todos.subscribe(listener);
701
+ }
702
+ /** `/resume`. An absent or empty list CLEARS — see `TodoStore.restore` (P0-2). */
703
+ restoreTodos(items) {
704
+ this.todos?.restore(items);
705
+ }
706
+ /** `/todo clear`. A user override of a projection; refused mid-run by `/todo`. */
707
+ clearTodos() {
708
+ this.todos?.clear('user');
709
+ }
710
+ // -----------------------------------------------------------------------
582
711
  // Mutators
583
712
  // -----------------------------------------------------------------------
584
713
  setModel(provider, model, baseUrl) {
@@ -620,6 +749,11 @@ export class AgentController {
620
749
  // A new conversation has no loaded skills, so the "already loaded earlier
621
750
  // in this conversation" hint would otherwise start lying (P2-9).
622
751
  this.skills.getRegistry().clearActive();
752
+ // `/reset` clears `messages`, so the belief that justifies the panel is gone
753
+ // with them and the list must go too (I-2). `/clear` reaches the transcript
754
+ // and NOT this method, which is the whole distinction: it tells the model
755
+ // nothing, so it may not change the list.
756
+ this.todos?.clear('reset');
623
757
  }
624
758
  /** Read-only view of the live system prompt (regression tests, /debug). */
625
759
  getSystemPrompt() {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Bounded view state (tui-render-performance L1 / R5).
3
+ *
4
+ * `ViewState.entries` used to grow without any ceiling: `argsRaw`, assistant
5
+ * `text` and `thinking` all concatenated forever, and the entry array was never
6
+ * trimmed. That makes the per-frame cost of the renderer proportional to the
7
+ * total number of characters the session has ever produced, because Ink's
8
+ * `renderNodeToOutput` walks and measures every mounted text node whether or not
9
+ * `overflow: hidden` clips it.
10
+ *
11
+ * Two bounds live here, both pure and React-free:
12
+ * - `appendBounded` caps one field by eliding its MIDDLE, and
13
+ * - `trimEntries` ring-trims the entry array itself.
14
+ *
15
+ * ASCII ONLY. This module is produced outside `src/ui/**`, where no terminal
16
+ * capabilities are available, so the elision marker has to be safe on a legacy
17
+ * `cmd.exe` -- the same rule `PREVIEW_TRUNCATION_MARK` in `reducer.ts` records.
18
+ */
19
+ import type { Entry } from './reducer.js';
20
+ export interface EntryLimits {
21
+ /** Assistant answer text ceiling, in characters. */
22
+ text: number;
23
+ /** Assistant thinking ceiling. */
24
+ thinking: number;
25
+ /** Streamed tool-argument JSON ceiling. */
26
+ argsRaw: number;
27
+ }
28
+ /**
29
+ * The per-field ceilings.
30
+ *
31
+ * `text` is the generous one on purpose: a 256 KiB answer is already far past
32
+ * anything a terminal can usefully show, and the middle is what gets elided, so
33
+ * both the opening and the live cursor survive. `argsRaw` is the tight one --
34
+ * it is a streamed JSON fragment rendered as a 60-character card summary, so
35
+ * anything past a few KiB is invisible by construction.
36
+ */
37
+ export declare const ENTRY_LIMITS: EntryLimits;
38
+ /** Marker inserted at the elision point. ASCII -- produced outside `src/ui/**`. */
39
+ export declare const ELISION_MARK_PREFIX = "\n[... ";
40
+ export declare const ELISION_MARK_SUFFIX = " characters elided ...]\n";
41
+ /**
42
+ * Append `delta` to `prev`, keeping the result at or below `cap` by eliding the
43
+ * MIDDLE. Head and tail are both preserved: the head is the answer's opening,
44
+ * the tail is where the cursor is. Idempotent -- re-eliding an already elided
45
+ * string collapses the two markers into one.
46
+ *
47
+ * The returned string is NEVER longer than `cap` (AC-7). The marker's own digit
48
+ * count is bounded before the tail budget is computed, so the arithmetic cannot
49
+ * overshoot by a digit the way a naive `cap - marker.length` would.
50
+ */
51
+ export declare function appendBounded(prev: string, delta: string, cap: number): string;
52
+ export interface TrimResult {
53
+ entries: Entry[];
54
+ dropped: number;
55
+ }
56
+ /**
57
+ * Ring-trim to `retain` entries; returns the SAME array reference when no-op.
58
+ *
59
+ * The identity return is load-bearing: the reducer spreads this result into a
60
+ * new `ViewState` on every append, and a fresh array on every action would
61
+ * defeat the `React.memo` boundaries L2 installs on the transcript.
62
+ */
63
+ export declare function trimEntries(entries: Entry[], retain: number): TrimResult;
64
+ export declare function setEntryRetain(value: number): void;
65
+ export declare function entryRetain(): number;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Bounded view state (tui-render-performance L1 / R5).
3
+ *
4
+ * `ViewState.entries` used to grow without any ceiling: `argsRaw`, assistant
5
+ * `text` and `thinking` all concatenated forever, and the entry array was never
6
+ * trimmed. That makes the per-frame cost of the renderer proportional to the
7
+ * total number of characters the session has ever produced, because Ink's
8
+ * `renderNodeToOutput` walks and measures every mounted text node whether or not
9
+ * `overflow: hidden` clips it.
10
+ *
11
+ * Two bounds live here, both pure and React-free:
12
+ * - `appendBounded` caps one field by eliding its MIDDLE, and
13
+ * - `trimEntries` ring-trims the entry array itself.
14
+ *
15
+ * ASCII ONLY. This module is produced outside `src/ui/**`, where no terminal
16
+ * capabilities are available, so the elision marker has to be safe on a legacy
17
+ * `cmd.exe` -- the same rule `PREVIEW_TRUNCATION_MARK` in `reducer.ts` records.
18
+ */
19
+ import { DEFAULT_TRANSCRIPT_RETAIN } from '../config/schema.js';
20
+ /**
21
+ * The per-field ceilings.
22
+ *
23
+ * `text` is the generous one on purpose: a 256 KiB answer is already far past
24
+ * anything a terminal can usefully show, and the middle is what gets elided, so
25
+ * both the opening and the live cursor survive. `argsRaw` is the tight one --
26
+ * it is a streamed JSON fragment rendered as a 60-character card summary, so
27
+ * anything past a few KiB is invisible by construction.
28
+ */
29
+ export const ENTRY_LIMITS = {
30
+ text: 262_144,
31
+ thinking: 65_536,
32
+ argsRaw: 16_384,
33
+ };
34
+ /** Marker inserted at the elision point. ASCII -- produced outside `src/ui/**`. */
35
+ export const ELISION_MARK_PREFIX = '\n[... ';
36
+ export const ELISION_MARK_SUFFIX = ' characters elided ...]\n';
37
+ /** Matches a marker this module wrote, so re-elision collapses rather than nests. */
38
+ const ELISION_RE = /\n\[\.\.\. (\d+) characters elided \.\.\.\]\n/;
39
+ /** Fixed cost of the marker, before the digits of the count. */
40
+ const MARKER_FIXED = ELISION_MARK_PREFIX.length + ELISION_MARK_SUFFIX.length;
41
+ /**
42
+ * Fraction of the cap reserved for the HEAD. The tail gets the rest because that
43
+ * is where the cursor is; the head exists so the answer still starts where the
44
+ * model started it.
45
+ */
46
+ const HEAD_FRACTION = 4;
47
+ /** Undo one elision, so a second one merges into it rather than nesting. */
48
+ function splitElided(prev) {
49
+ const m = ELISION_RE.exec(prev);
50
+ if (!m)
51
+ return { head: '', elided: 0, tail: prev };
52
+ const index = m.index;
53
+ return {
54
+ head: prev.slice(0, index),
55
+ elided: Number.parseInt(m[1], 10) || 0,
56
+ tail: prev.slice(index + m[0].length),
57
+ };
58
+ }
59
+ /**
60
+ * Append `delta` to `prev`, keeping the result at or below `cap` by eliding the
61
+ * MIDDLE. Head and tail are both preserved: the head is the answer's opening,
62
+ * the tail is where the cursor is. Idempotent -- re-eliding an already elided
63
+ * string collapses the two markers into one.
64
+ *
65
+ * The returned string is NEVER longer than `cap` (AC-7). The marker's own digit
66
+ * count is bounded before the tail budget is computed, so the arithmetic cannot
67
+ * overshoot by a digit the way a naive `cap - marker.length` would.
68
+ */
69
+ export function appendBounded(prev, delta, cap) {
70
+ if (!Number.isFinite(cap) || cap <= 0)
71
+ return '';
72
+ const split = splitElided(prev);
73
+ let head = split.head;
74
+ let tail = split.tail + delta;
75
+ let elided = split.elided;
76
+ // Fast path: nothing has ever been elided and it still fits. This is the path
77
+ // every ordinary answer takes, so it must allocate nothing beyond the concat.
78
+ if (elided === 0 && tail.length <= cap)
79
+ return tail;
80
+ const headBudget = Math.max(0, Math.min(Math.floor(cap / HEAD_FRACTION), cap - MARKER_FIXED - 1));
81
+ if (elided === 0) {
82
+ // First elision: the head has to be carved out of the accumulated text.
83
+ head = tail.slice(0, headBudget);
84
+ tail = tail.slice(headBudget);
85
+ }
86
+ else if (head.length > headBudget) {
87
+ // A later `cap` reduction can leave the stored head oversized; charge the
88
+ // difference to the elision count rather than silently keeping it.
89
+ elided += head.length - headBudget;
90
+ head = head.slice(0, headBudget);
91
+ }
92
+ if (head.length + MARKER_FIXED >= cap) {
93
+ // Degenerate cap (only reachable from a hand-written test): keep the tail.
94
+ return tail.slice(Math.max(0, tail.length - cap));
95
+ }
96
+ // `elided + tail.length` is an UPPER BOUND on the final count, so its digit
97
+ // count is an upper bound on the marker's width. Budgeting against the bound
98
+ // is what makes the `<= cap` guarantee hold without a second pass.
99
+ const digitBound = String(elided + tail.length).length;
100
+ const markerBound = MARKER_FIXED + digitBound;
101
+ const tailKeep = Math.max(0, Math.min(tail.length, cap - head.length - markerBound));
102
+ const total = elided + (tail.length - tailKeep);
103
+ return `${head}${ELISION_MARK_PREFIX}${total}${ELISION_MARK_SUFFIX}${tail.slice(tail.length - tailKeep)}`;
104
+ }
105
+ /**
106
+ * Ring-trim to `retain` entries; returns the SAME array reference when no-op.
107
+ *
108
+ * The identity return is load-bearing: the reducer spreads this result into a
109
+ * new `ViewState` on every append, and a fresh array on every action would
110
+ * defeat the `React.memo` boundaries L2 installs on the transcript.
111
+ */
112
+ export function trimEntries(entries, retain) {
113
+ const keep = Math.max(1, Math.floor(retain));
114
+ if (entries.length <= keep)
115
+ return { entries, dropped: 0 };
116
+ return { entries: entries.slice(entries.length - keep), dropped: entries.length - keep };
117
+ }
118
+ // ---------------------------------------------------------------------------
119
+ // The resolved retain ceiling
120
+ // ---------------------------------------------------------------------------
121
+ /**
122
+ * `viewReducer` is pure and takes no config, so the resolved `transcriptRetain`
123
+ * reaches it through a module-level singleton -- the shape `prompt-history.ts`
124
+ * already uses for `setHistoryEnabled`. `load.ts` is the ONLY writer.
125
+ */
126
+ let resolvedRetain = DEFAULT_TRANSCRIPT_RETAIN;
127
+ export function setEntryRetain(value) {
128
+ if (Number.isFinite(value) && value > 0)
129
+ resolvedRetain = Math.floor(value);
130
+ }
131
+ export function entryRetain() {
132
+ return resolvedRetain;
133
+ }
@@ -7,6 +7,8 @@
7
7
  import type { AgentEvent, ModelInfo } from '@aragon-agent/core';
8
8
  import type { PreflightResult } from './controller.js';
9
9
  import type { TeamEvent } from '../team/types.js';
10
+ import type { TodoEvent, TodoSnapshot } from '../todo/types.js';
11
+ import { type FollowThroughMode } from '../todo/follow-through.js';
10
12
  /** The minimal controller surface headless mode needs (real or stubbed). */
11
13
  export interface HeadlessController {
12
14
  preflight(): PreflightResult;
@@ -24,11 +26,38 @@ export interface HeadlessController {
24
26
  * the `--no-team` headless path byte-identical to today's.
25
27
  */
26
28
  subscribeTeam?(listener: (event: TeamEvent) => void): () => void;
29
+ /**
30
+ * The CLI-local todo stream — OPTIONAL, for the identical reason
31
+ * `subscribeTeam` is (todo-plan-execution §3.12). This interface is minimal so
32
+ * tests can satisfy it with an object literal; making the member REQUIRED
33
+ * would break every existing stub for no benefit, and a controller with no
34
+ * store genuinely has no todo events. `runHeadless` calls it with `?.`, which
35
+ * keeps a `--no-todo` headless run byte-identical to today's.
36
+ */
37
+ subscribeTodos?(listener: (event: TodoEvent) => void): () => void;
38
+ /**
39
+ * The current plan — OPTIONAL, for the identical reason the two subscriptions
40
+ * above are (todo-plan-followthrough §3.6 rule 2). ABSENT MEANS THE
41
+ * CONTINUATION LOOP NEVER RUNS, which is what keeps a stubbed controller's
42
+ * stdout, stderr and exit code byte-identical to today's (AC-23).
43
+ */
44
+ getTodoSnapshot?(): TodoSnapshot | null;
27
45
  }
28
46
  export interface HeadlessOptions {
29
47
  quiet?: boolean;
30
48
  stdout?: NodeJS.WritableStream;
31
49
  stderr?: NodeJS.WritableStream;
50
+ /**
51
+ * Follow-through mode for `-p` (todo-plan-followthrough §3.6). Defaults to
52
+ * `'notify'`, so an omitted option behaves exactly as the resolved default
53
+ * config does.
54
+ *
55
+ * PASSED IN RATHER THAN READ OFF THE CONTROLLER, because `HeadlessController`
56
+ * is deliberately the minimal surface a test can satisfy with an object
57
+ * literal; `runOneShot` holds the real `AgentController` and reads
58
+ * `getTodoConfig().followThrough` there.
59
+ */
60
+ followThrough?: FollowThroughMode;
32
61
  }
33
62
  /** Run a single prompt headlessly and resolve with the process exit code. */
34
63
  export declare function runHeadless(controller: HeadlessController, prompt: string, options?: HeadlessOptions): Promise<number>;
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { formatCost, formatTokens, formatDuration, computeCost } from './usage.js';
8
8
  import { formatStreamError } from './reducer.js';
9
+ import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
9
10
  /** Run a single prompt headlessly and resolve with the process exit code. */
10
11
  export async function runHeadless(controller, prompt, options = {}) {
11
12
  const out = options.stdout ?? process.stdout;
@@ -96,12 +97,96 @@ export async function runHeadless(controller, prompt, options = {}) {
96
97
  `${event.outcome.aborted ? ' (aborted)' : ''}\n`);
97
98
  }
98
99
  });
100
+ // ONE LINE PER TRANSITION OF THE ACTIVE ITEM, not one per call: a model that
101
+ // rewrites the list without moving the cursor produces no output at all.
102
+ //
103
+ // These lines are interleaved with the generic `> todo_write (1ms)` pairs
104
+ // above, because that loop keys on the EVENT and not on the tool name (P2-2).
105
+ // `SELF_RENDERING_TOOLS` is a TRANSCRIPT concept and teaching this writer
106
+ // about it would put the same name in two places for a cosmetic gain on a
107
+ // stream that is already `--quiet`-able in full.
108
+ let announcedTotal = 0;
109
+ let lastActive = -1;
110
+ const unsubscribeTodos = controller.subscribeTodos?.((event) => {
111
+ if (quiet || event.type !== 'updated')
112
+ return;
113
+ const { total, doneCount, activeIndex, items } = event.snapshot;
114
+ if (total !== announcedTotal) {
115
+ announcedTotal = total;
116
+ err.write(`\n[todo] ${total} ${total === 1 ? 'step' : 'steps'} planned\n`);
117
+ lastActive = -1;
118
+ }
119
+ if (activeIndex < 0) {
120
+ if (lastActive !== -2) {
121
+ lastActive = -2;
122
+ err.write(`[todo] ${doneCount}/${total} done\n`);
123
+ }
124
+ return;
125
+ }
126
+ if (activeIndex === lastActive)
127
+ return;
128
+ lastActive = activeIndex;
129
+ err.write(`[todo] ${activeIndex + 1}/${total} ${items[activeIndex]?.content ?? ''}\n`);
130
+ });
131
+ // --- The bounded continuation loop (todo-plan-followthrough §3.6). --------
132
+ //
133
+ // THE THREE `unsubscribe` CALLS STAY OUTSIDE IT (rule 4). The loop wraps the
134
+ // whole `try/finally` rather than sitting inside it, because unsubscribing per
135
+ // iteration would silently cost iteration 2 onwards its `[todo]` progress
136
+ // lines, its team usage accounting and — worst — the `agent_end` handler that
137
+ // computes `errored`, so a failing second iteration would exit 0.
138
+ //
139
+ // Deciding the moment `prompt()` resolves is safe: `agent.prompt()` awaits
140
+ // `runLoopWithLifecycle()`, whose `finally` emits `agent_end` BEFORE resolving,
141
+ // and `emit` is synchronous — so `errored` and `sawTurnEnd` are already final.
142
+ const followThrough = options.followThrough ?? 'notify';
143
+ let budget = emptyBudget();
144
+ let wasAutoContinuation = false;
99
145
  try {
100
- await controller.prompt(prompt);
146
+ let message = prompt;
147
+ for (;;) {
148
+ // PER-ITERATION RESET (rule 1). `sawTurnEnd` is the silent-failure
149
+ // detector; left sticky, a swallowed throw on iteration 2 is invisible
150
+ // because iteration 1 set the flag. `errored` is deliberately NOT reset —
151
+ // once a run has errored the exit code is 1 and the loop must stop.
152
+ sawTurnEnd = false;
153
+ await controller.prompt(message);
154
+ const snapshot = controller.getTodoSnapshot?.() ?? null;
155
+ budget = advanceBudget(budget, snapshot, wasAutoContinuation);
156
+ wasAutoContinuation = false;
157
+ const decision = decideFollowThrough({
158
+ mode: followThrough,
159
+ snapshot,
160
+ // `aborted` is STRUCTURALLY false here (C-2): `-p` has no Esc and
161
+ // installs no signal handler that marks one, so there is nothing to
162
+ // derive and nothing to plumb. An errored run takes the `notify` branch
163
+ // below and ends the loop, which is what keeps exit code 1 reachable.
164
+ runEnd: { aborted: false, errored },
165
+ budget,
166
+ interactive: false,
167
+ });
168
+ if (decision.kind === 'none')
169
+ break;
170
+ if (decision.kind === 'notify') {
171
+ if (!quiet)
172
+ err.write(`\n[todo] ${decision.text}\n`);
173
+ break;
174
+ }
175
+ // CHARGED BEFORE THE RE-PROMPT, and this line is the whole of the headless
176
+ // cap (P1-2 / I-5 / AC-37). The TUI charges `used` in its grace-timer
177
+ // callback; headless has no timer, so without this `maxAutoContinuesPerList`
178
+ // would never bind in the one environment with no human to press Esc.
179
+ budget = { ...budget, used: budget.used + 1 };
180
+ wasAutoContinuation = true;
181
+ if (!quiet)
182
+ err.write(`\n[todo] ${decision.notice}\n`);
183
+ message = decision.message;
184
+ }
101
185
  }
102
186
  finally {
103
187
  unsubscribe();
104
188
  unsubscribeTeam?.();
189
+ unsubscribeTodos?.();
105
190
  }
106
191
  // Ensure a trailing newline so piped output is well-formed.
107
192
  if (wroteText)