@aragon-agent/cli 0.5.4 → 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 (60) hide show
  1. package/CHANGELOG.md +670 -577
  2. package/README.md +1105 -995
  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 +40 -1
  12. package/dist/commands/builtins.js +166 -0
  13. package/dist/commands/registry.d.ts +27 -1
  14. package/dist/config/env.js +43 -0
  15. package/dist/config/load.d.ts +27 -0
  16. package/dist/config/load.js +25 -1
  17. package/dist/config/schema.d.ts +47 -0
  18. package/dist/config/schema.js +38 -0
  19. package/dist/config/store.js +10 -1
  20. package/dist/session/persist.d.ts +19 -1
  21. package/dist/session/persist.js +21 -2
  22. package/dist/team/subagent.js +11 -0
  23. package/dist/todo/follow-through.d.ts +117 -0
  24. package/dist/todo/follow-through.js +186 -0
  25. package/dist/todo/limits.d.ts +114 -0
  26. package/dist/todo/limits.js +114 -0
  27. package/dist/todo/normalize.d.ts +33 -0
  28. package/dist/todo/normalize.js +117 -0
  29. package/dist/todo/panel-rows.d.ts +53 -0
  30. package/dist/todo/panel-rows.js +103 -0
  31. package/dist/todo/prompt.d.ts +54 -0
  32. package/dist/todo/prompt.js +76 -0
  33. package/dist/todo/store.d.ts +86 -0
  34. package/dist/todo/store.js +183 -0
  35. package/dist/todo/todo-tool.d.ts +38 -0
  36. package/dist/todo/todo-tool.js +134 -0
  37. package/dist/todo/types.d.ts +62 -0
  38. package/dist/todo/types.js +12 -0
  39. package/dist/tools/index.d.ts +16 -2
  40. package/dist/tools/index.js +13 -0
  41. package/dist/ui/App.js +244 -7
  42. package/dist/ui/StatusBar.d.ts +18 -0
  43. package/dist/ui/StatusBar.js +5 -2
  44. package/dist/ui/TodoPanel.d.ts +48 -0
  45. package/dist/ui/TodoPanel.js +74 -0
  46. package/dist/ui/TodoStrip.d.ts +36 -0
  47. package/dist/ui/TodoStrip.js +32 -0
  48. package/dist/ui/Transcript.js +11 -1
  49. package/dist/ui/entries/TodoCard.d.ts +31 -0
  50. package/dist/ui/entries/TodoCard.js +35 -0
  51. package/dist/ui/glyphs.d.ts +12 -0
  52. package/dist/ui/glyphs.js +8 -0
  53. package/dist/ui/layout/AppShell.d.ts +34 -1
  54. package/dist/ui/layout/AppShell.js +3 -3
  55. package/dist/ui/layout/ScrollViewport.d.ts +13 -1
  56. package/dist/ui/layout/ScrollViewport.js +2 -2
  57. package/dist/ui/layout/rail.d.ts +56 -0
  58. package/dist/ui/layout/rail.js +68 -0
  59. package/dist/ui/overlays/HelpOverlay.js +4 -0
  60. 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() {
@@ -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)
@@ -13,6 +13,7 @@
13
13
  import type { AgentEvent, ModelCost, ToolResult, TokenUsage } from '@aragon-agent/core';
14
14
  import type { AgentMode } from './agent-mode.js';
15
15
  import type { DispatchOutcome, SubagentRun, SubagentSpec, TeamSnapshot } from '../team/types.js';
16
+ import type { TodoItem, TodoSnapshot } from '../todo/types.js';
16
17
  export type NoticeLevel = 'info' | 'warn' | 'error';
17
18
  export type ToastLevel = NoticeLevel | 'success';
18
19
  export type Overlay = null | 'settings' | 'model' | 'help' | 'confirm' | 'question' | 'plan';
@@ -78,6 +79,34 @@ export type Entry = {
78
79
  aborted: boolean;
79
80
  durationMs?: number;
80
81
  active: boolean;
82
+ }
83
+ /**
84
+ * The turn's todo checklist (todo-plan-execution §3.8). ONE per user turn,
85
+ * rewritten in place — a 7-item list updated twice per item would otherwise be
86
+ * 14 near-identical tool cards in the one surface the user reads most.
87
+ *
88
+ * JSON-SERIALIZABLE by construction, so `/save` needs no format change.
89
+ *
90
+ * `live: true` is normalized away ON LOAD, in `session/persist.ts`, for the
91
+ * same reason the team entry's `active` is (C-5): `Transcript`'s settled
92
+ * boundary is MONOTONIC, so an entry that never settles never reaches
93
+ * `<Static>` and is re-rendered on every frame for the rest of the session.
94
+ */
95
+ | {
96
+ id: string;
97
+ kind: 'todo';
98
+ items: TodoItem[];
99
+ doneCount: number;
100
+ total: number;
101
+ /** The turn that owns this card is still running, so it may be rewritten. */
102
+ live: boolean;
103
+ /**
104
+ * Set by `normalizeLoadedEntries` on a card saved while its run was live
105
+ * (P2-6). The todo analogue of the team entry's `aborted`, and true for the
106
+ * same reason: the run behind it died with the process. Absent on every
107
+ * card this session produced.
108
+ */
109
+ interrupted?: boolean;
81
110
  };
82
111
  export interface UsageTotal {
83
112
  inputTokens: number;
@@ -112,9 +141,20 @@ export interface ViewState {
112
141
  * live dispatch to show (§6.4).
113
142
  */
114
143
  team: TeamSnapshot | null;
144
+ /**
145
+ * The live todo list, or `null` when there is none.
146
+ *
147
+ * NEVER PERSISTED HERE — `entries` is the history and `SavedSession.todos` is
148
+ * the list (§3.13). `null` is what leaves the rail unmounted entirely, which
149
+ * is why a session that never plans has byte-identical layout to a pre-feature
150
+ * build (AC-24).
151
+ */
152
+ todos: TodoSnapshot | null;
115
153
  seq: number;
116
154
  /** The `kind: 'team'` entry the live dispatch is writing into. */
117
155
  teamEntryId?: string;
156
+ /** The `kind: 'todo'` entry this turn is writing into. */
157
+ todoEntryId?: string;
118
158
  streamingId?: string;
119
159
  turnProduced: boolean;
120
160
  errorNoticed: boolean;
@@ -213,6 +253,11 @@ export type ViewAction = {
213
253
  } | {
214
254
  type: 'teamEnd';
215
255
  outcome: DispatchOutcome;
256
+ } | {
257
+ type: 'todoUpdate';
258
+ snapshot: TodoSnapshot;
259
+ } | {
260
+ type: 'todoCleared';
216
261
  };
217
262
  export declare const STORED_PREVIEW_CHARS = 8000;
218
263
  export declare const STORED_PREVIEW_LINES = 200;
@@ -28,8 +28,10 @@ export function initialViewState() {
28
28
  agentMode: 'build',
29
29
  pendingAgentMode: null,
30
30
  team: null,
31
+ todos: null,
31
32
  seq: 0,
32
33
  teamEntryId: undefined,
34
+ todoEntryId: undefined,
33
35
  streamingId: undefined,
34
36
  turnProduced: false,
35
37
  errorNoticed: false,
@@ -118,6 +120,20 @@ export function reduceEvent(event, cost) {
118
120
  return [];
119
121
  }
120
122
  }
123
+ /**
124
+ * Tools that render their own transcript entry, so the generic tool card would
125
+ * be a duplicate. EXACTLY ONE MEMBER; read todo-plan-execution §3.8 before
126
+ * adding a second.
127
+ *
128
+ * SUPPRESSING ONLY `tool_call_start` IS SUFFICIENT, AND THAT IS THE WHOLE REASON
129
+ * THIS IS SAFE (D-10). Every later handler — `toolCallDelta` / `toolCallEnd` /
130
+ * `toolExecStart` / `toolExecEnd` — resolves its target through
131
+ * `findToolEntryId`, which returns `undefined` when no entry was created, and
132
+ * every one of them already early-returns on that. There is no fifth handler. A
133
+ * second suppression rule would be four more places to get the id matching
134
+ * wrong, for nothing.
135
+ */
136
+ const SELF_RENDERING_TOOLS = new Set(['todo_write']);
121
137
  function reduceStreamEvent(se) {
122
138
  switch (se.type) {
123
139
  case 'thinking_start':
@@ -127,6 +143,8 @@ function reduceStreamEvent(se) {
127
143
  case 'text_delta':
128
144
  return [{ type: 'textDelta', delta: se.delta }];
129
145
  case 'tool_call_start':
146
+ if (SELF_RENDERING_TOOLS.has(se.toolName))
147
+ return [];
130
148
  return [
131
149
  { type: 'toolCallStart', toolCallId: se.toolCallId, toolName: se.toolName },
132
150
  ];
@@ -201,6 +219,10 @@ export function viewReducer(state, action) {
201
219
  seq,
202
220
  entries: [...state.entries, entry],
203
221
  streamingId: undefined,
222
+ // A new turn gets its own card, in that turn's position in the
223
+ // transcript. The PREVIOUS card stays exactly where it is — it is
224
+ // history, and history is not retracted.
225
+ todoEntryId: undefined,
204
226
  turnProduced: false,
205
227
  errorNoticed: false,
206
228
  aborted: false,
@@ -346,6 +368,11 @@ export function viewReducer(state, action) {
346
368
  { id: next.id, kind: 'notice', level: 'error', text: GENERIC_RUN_FAILURE },
347
369
  ];
348
370
  }
371
+ // The card may now reach `<Static>` (C-5 / I-6): the turn that owns it is
372
+ // over, so nothing will rewrite it.
373
+ if (state.todoEntryId) {
374
+ entries = mapEntry(entries, state.todoEntryId, (e) => e.kind === 'todo' ? { ...e, live: false } : e);
375
+ }
349
376
  return { ...state, entries, seq, status: 'idle', streamingId: undefined };
350
377
  }
351
378
  case 'notice': {
@@ -379,6 +406,11 @@ export function viewReducer(state, action) {
379
406
  streamingId: undefined,
380
407
  expandedToolIds: {},
381
408
  teamEntryId: undefined,
409
+ // The entry it pointed at is gone, so the id goes with it. `todos` is
410
+ // DELIBERATELY UNTOUCHED (I-2): `/clear` wipes the transcript and not
411
+ // the conversation, so the model still believes in the plan, and a panel
412
+ // that disagrees with the model is worse than no panel.
413
+ todoEntryId: undefined,
382
414
  };
383
415
  case 'resetConversation':
384
416
  return {
@@ -389,6 +421,11 @@ export function viewReducer(state, action) {
389
421
  contextTokens: 0,
390
422
  usageTotal: { inputTokens: 0, outputTokens: 0, costUsd: 0 },
391
423
  teamEntryId: undefined,
424
+ // `/reset` clears `messages`, so the belief is gone and the list goes
425
+ // with it — the other half of I-2. `controller.clearMessages()` clears
426
+ // the STORE; this clears the mirror.
427
+ todos: null,
428
+ todoEntryId: undefined,
392
429
  };
393
430
  case 'setOverlay':
394
431
  return { ...state, overlay: action.overlay };
@@ -496,13 +533,55 @@ export function viewReducer(state, action) {
496
533
  // "(dang you de shi hou)": it renders only while a dispatch is running.
497
534
  return { ...state, entries, team: null, teamEntryId: undefined };
498
535
  }
536
+ // --- Todo planning (todo-plan-execution §5.2) -------------------------
537
+ case 'todoUpdate': {
538
+ const entry = (id) => ({
539
+ id,
540
+ kind: 'todo',
541
+ items: action.snapshot.items,
542
+ doneCount: action.snapshot.doneCount,
543
+ total: action.snapshot.total,
544
+ live: true,
545
+ });
546
+ // Rewrite the turn's card when there is one; otherwise append and record
547
+ // its id. One `mapEntry` on the KNOWN id, never a scan.
548
+ if (state.todoEntryId) {
549
+ const known = state.todoEntryId;
550
+ return {
551
+ ...state,
552
+ todos: action.snapshot,
553
+ entries: mapEntry(state.entries, known, (e) => e.kind === 'todo' ? { ...entry(known), live: e.live } : e),
554
+ };
555
+ }
556
+ const { id, seq } = nextId(state);
557
+ return {
558
+ ...state,
559
+ seq,
560
+ todos: action.snapshot,
561
+ entries: [...state.entries, entry(id)],
562
+ todoEntryId: id,
563
+ };
564
+ }
565
+ // THE ENTRY STAYS. It is history, and history is not retracted — only the
566
+ // live projection goes away, which is what unmounts the rail.
567
+ case 'todoCleared':
568
+ return { ...state, todos: null, todoEntryId: undefined };
499
569
  case 'restoreEntries': {
500
570
  // Adopt a seq beyond any restored id to keep future ids unique.
501
571
  const maxSeq = action.entries.reduce((acc, e) => {
502
572
  const n = Number.parseInt(e.id.replace(/^e/, ''), 10);
503
573
  return Number.isFinite(n) ? Math.max(acc, n) : acc;
504
574
  }, state.seq);
505
- return { ...state, entries: [...action.entries], seq: maxSeq, teamEntryId: undefined };
575
+ return {
576
+ ...state,
577
+ entries: [...action.entries],
578
+ seq: maxSeq,
579
+ teamEntryId: undefined,
580
+ // As for `team`: the restored cards are history and no turn owns them.
581
+ // The LIST itself arrives separately, through `controller.restoreTodos`
582
+ // and the store's own event (§3.13).
583
+ todoEntryId: undefined,
584
+ };
506
585
  }
507
586
  default:
508
587
  return state;