@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.
- package/CHANGELOG.md +670 -577
- package/README.md +1105 -995
- package/dist/agent/controller.d.ts +71 -1
- package/dist/agent/controller.js +135 -1
- package/dist/agent/headless.d.ts +29 -0
- package/dist/agent/headless.js +86 -1
- package/dist/agent/reducer.d.ts +45 -0
- package/dist/agent/reducer.js +80 -1
- package/dist/agent/system-prompt.d.ts +8 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/cli.js +40 -1
- package/dist/commands/builtins.js +166 -0
- package/dist/commands/registry.d.ts +27 -1
- package/dist/config/env.js +43 -0
- package/dist/config/load.d.ts +27 -0
- package/dist/config/load.js +25 -1
- package/dist/config/schema.d.ts +47 -0
- package/dist/config/schema.js +38 -0
- package/dist/config/store.js +10 -1
- package/dist/session/persist.d.ts +19 -1
- package/dist/session/persist.js +21 -2
- package/dist/team/subagent.js +11 -0
- package/dist/todo/follow-through.d.ts +117 -0
- package/dist/todo/follow-through.js +186 -0
- package/dist/todo/limits.d.ts +114 -0
- package/dist/todo/limits.js +114 -0
- package/dist/todo/normalize.d.ts +33 -0
- package/dist/todo/normalize.js +117 -0
- package/dist/todo/panel-rows.d.ts +53 -0
- package/dist/todo/panel-rows.js +103 -0
- package/dist/todo/prompt.d.ts +54 -0
- package/dist/todo/prompt.js +76 -0
- package/dist/todo/store.d.ts +86 -0
- package/dist/todo/store.js +183 -0
- package/dist/todo/todo-tool.d.ts +38 -0
- package/dist/todo/todo-tool.js +134 -0
- package/dist/todo/types.d.ts +62 -0
- package/dist/todo/types.js +12 -0
- package/dist/tools/index.d.ts +16 -2
- package/dist/tools/index.js +13 -0
- package/dist/ui/App.js +244 -7
- package/dist/ui/StatusBar.d.ts +18 -0
- package/dist/ui/StatusBar.js +5 -2
- package/dist/ui/TodoPanel.d.ts +48 -0
- package/dist/ui/TodoPanel.js +74 -0
- package/dist/ui/TodoStrip.d.ts +36 -0
- package/dist/ui/TodoStrip.js +32 -0
- package/dist/ui/Transcript.js +11 -1
- package/dist/ui/entries/TodoCard.d.ts +31 -0
- package/dist/ui/entries/TodoCard.js +35 -0
- package/dist/ui/glyphs.d.ts +12 -0
- package/dist/ui/glyphs.js +8 -0
- package/dist/ui/layout/AppShell.d.ts +34 -1
- package/dist/ui/layout/AppShell.js +3 -3
- package/dist/ui/layout/ScrollViewport.d.ts +13 -1
- package/dist/ui/layout/ScrollViewport.js +2 -2
- package/dist/ui/layout/rail.d.ts +56 -0
- package/dist/ui/layout/rail.js +68 -0
- package/dist/ui/overlays/HelpOverlay.js +4 -0
- 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;
|
package/dist/agent/controller.js
CHANGED
|
@@ -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() {
|
package/dist/agent/headless.d.ts
CHANGED
|
@@ -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>;
|
package/dist/agent/headless.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/agent/reducer.d.ts
CHANGED
|
@@ -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;
|
package/dist/agent/reducer.js
CHANGED
|
@@ -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 {
|
|
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;
|