@aragon-agent/cli 0.5.2 → 0.5.4

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 (87) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +193 -3
  3. package/dist/agent/controller.d.ts +51 -1
  4. package/dist/agent/controller.js +177 -2
  5. package/dist/agent/headless.d.ts +12 -0
  6. package/dist/agent/headless.js +41 -1
  7. package/dist/agent/reducer.d.ts +48 -0
  8. package/dist/agent/reducer.js +91 -2
  9. package/dist/agent/system-prompt.d.ts +17 -0
  10. package/dist/agent/system-prompt.js +4 -0
  11. package/dist/cli.js +98 -7
  12. package/dist/commands/builtins.d.ts +2 -2
  13. package/dist/commands/builtins.js +133 -3
  14. package/dist/config/app-paths.d.ts +17 -0
  15. package/dist/config/app-paths.js +21 -0
  16. package/dist/config/cli-commands.d.ts +26 -2
  17. package/dist/config/cli-commands.js +83 -6
  18. package/dist/config/env.d.ts +3 -2
  19. package/dist/config/env.js +44 -6
  20. package/dist/config/history-commands.d.ts +20 -0
  21. package/dist/config/history-commands.js +71 -0
  22. package/dist/config/load.d.ts +13 -0
  23. package/dist/config/load.js +107 -6
  24. package/dist/config/migrate-state-out-of-config.d.ts +32 -0
  25. package/dist/config/migrate-state-out-of-config.js +85 -0
  26. package/dist/config/prompt-history.d.ts +86 -0
  27. package/dist/config/prompt-history.js +332 -0
  28. package/dist/config/schema.d.ts +176 -18
  29. package/dist/config/schema.js +162 -8
  30. package/dist/config/store.js +40 -4
  31. package/dist/config/ui-state.d.ts +52 -0
  32. package/dist/config/ui-state.js +149 -0
  33. package/dist/logging/install.d.ts +85 -0
  34. package/dist/logging/install.js +94 -0
  35. package/dist/logging/logger.d.ts +1 -1
  36. package/dist/session/persist.d.ts +16 -0
  37. package/dist/session/persist.js +19 -1
  38. package/dist/team/activity.d.ts +67 -0
  39. package/dist/team/activity.js +130 -0
  40. package/dist/team/bus.d.ts +190 -0
  41. package/dist/team/bus.js +366 -0
  42. package/dist/team/comm-tools.d.ts +61 -0
  43. package/dist/team/comm-tools.js +221 -0
  44. package/dist/team/human-queue.d.ts +55 -0
  45. package/dist/team/human-queue.js +72 -0
  46. package/dist/team/limits.d.ts +123 -0
  47. package/dist/team/limits.js +128 -0
  48. package/dist/team/normalize.d.ts +39 -0
  49. package/dist/team/normalize.js +106 -0
  50. package/dist/team/panel-rows.d.ts +48 -0
  51. package/dist/team/panel-rows.js +75 -0
  52. package/dist/team/prompt.d.ts +49 -0
  53. package/dist/team/prompt.js +87 -0
  54. package/dist/team/report.d.ts +58 -0
  55. package/dist/team/report.js +205 -0
  56. package/dist/team/retry.d.ts +48 -0
  57. package/dist/team/retry.js +56 -0
  58. package/dist/team/runtime.d.ts +122 -0
  59. package/dist/team/runtime.js +406 -0
  60. package/dist/team/subagent.d.ts +90 -0
  61. package/dist/team/subagent.js +294 -0
  62. package/dist/team/task-tool.d.ts +47 -0
  63. package/dist/team/task-tool.js +118 -0
  64. package/dist/team/types.d.ts +172 -0
  65. package/dist/team/types.js +13 -0
  66. package/dist/tools/index.d.ts +48 -2
  67. package/dist/tools/index.js +26 -1
  68. package/dist/ui/App.d.ts +22 -1
  69. package/dist/ui/App.js +144 -18
  70. package/dist/ui/StatusBar.d.ts +21 -0
  71. package/dist/ui/StatusBar.js +5 -2
  72. package/dist/ui/TeamPanel.d.ts +57 -0
  73. package/dist/ui/TeamPanel.js +134 -0
  74. package/dist/ui/Transcript.js +10 -0
  75. package/dist/ui/entries/TeamCard.d.ts +31 -0
  76. package/dist/ui/entries/TeamCard.js +39 -0
  77. package/dist/ui/glyphs.d.ts +10 -0
  78. package/dist/ui/glyphs.js +6 -0
  79. package/dist/ui/layout/AppShell.d.ts +11 -1
  80. package/dist/ui/layout/AppShell.js +3 -3
  81. package/dist/ui/overlays/HelpOverlay.js +13 -0
  82. package/dist/ui/overlays/SettingsScreen.d.ts +12 -1
  83. package/dist/ui/overlays/SettingsScreen.js +38 -1
  84. package/dist/ui/transcript-text.js +20 -0
  85. package/dist/ui/use-startup-notices.d.ts +1 -1
  86. package/dist/ui/use-startup-notices.js +2 -1
  87. package/package.json +2 -2
@@ -9,10 +9,14 @@
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 { isAdapterProvider } from '../config/schema.js';
12
+ import { clampTeamConfig, 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';
16
+ import { TeamRuntime } from '../team/runtime.js';
17
+ import { TeamHumanQueue } from '../team/human-queue.js';
18
+ import { createTaskTool } from '../team/task-tool.js';
19
+ import { buildTeamBlock } from '../team/prompt.js';
16
20
  import { createPlanTools } from '../tools/plan-tools.js';
17
21
  import { DENY_ALL_HUMAN_INPUT } from '../tools/human-input.js';
18
22
  import { createNodeSkillHost } from '../skills/node-host.js';
@@ -48,6 +52,25 @@ export class AgentController {
48
52
  askRounds = 0;
49
53
  /** Static: whether this session could ever render a question overlay. */
50
54
  planInteractive;
55
+ // -----------------------------------------------------------------------
56
+ // Team mode (team-subagents §4.5) — TWO FLAGS, AND THEY ARE NOT THE SAME.
57
+ //
58
+ // `teamRegistered` is decided ONCE, at construction, and can never change:
59
+ // it is whether the `task` tool is in `this.tools` at all. The array is
60
+ // immutable for the life of the session (see the note in the constructor),
61
+ // so `/team on` in a session started with `--no-team` CANNOT add it.
62
+ //
63
+ // `teamEnabled` is live. `/team off` flips it, which makes `task` refuse
64
+ // through its own guard and drops `<team_mode>` from the next prompt rebuild
65
+ // — `rebuildSystemPrompt()` IS a live path, unlike the tool array.
66
+ //
67
+ // Conflating them is P0-2: the prompt would advertise a tool that was never
68
+ // registered, every call would come back "unknown tool", and the model would
69
+ // have no way to discover why (D-17 / R-18).
70
+ // -----------------------------------------------------------------------
71
+ teamRegistered;
72
+ teamEnabled;
73
+ teamRuntime;
51
74
  constructor(config, deps = {}) {
52
75
  this.config = config;
53
76
  this.cwd = config.cwd;
@@ -81,6 +104,40 @@ export class AgentController {
81
104
  this.skills.reportUnknownForcedSkills();
82
105
  }
83
106
  const confirm = deps.confirm;
107
+ // --- Team mode ---------------------------------------------------------
108
+ this.teamRegistered = config.team.enabled;
109
+ this.teamEnabled = config.team.enabled;
110
+ this.teamRuntime = this.teamRegistered
111
+ ? new TeamRuntime({
112
+ getConfig: () => this.config,
113
+ providerRegistry: this.providerRegistry,
114
+ getCwd: () => this.cwd,
115
+ getMode: () => this.effectiveMode,
116
+ getApiKey: (id) => this.resolveKey(id),
117
+ // `skill_find` only, and the lead's always-on bodies. See the long
118
+ // note in `subagent.ts` for why `skill` itself is withheld (D-16).
119
+ ...(this.skillsEnabled
120
+ ? {
121
+ skillRegistry: this.skills.getRegistry(),
122
+ alwaysBlock: () => this.skills.alwaysBlock(),
123
+ toolPolicy: () => this.skills.toolPolicyDecision(() => this.toolNames()),
124
+ // Label-decorating, and it DOES NOT advance the lead's
125
+ // escalation counter (P1-3). That counter exists to tell ONE
126
+ // agent to stop retrying ONE tool; a child cannot retry the
127
+ // lead's turn, and letting children spend the lead's budget
128
+ // makes the escalation fire on the lead's next legitimate call.
129
+ onChildToolPolicyEvent: (e) => deps.notify?.('warn', `[${e.label}] ${e.verdict.message ?? `Blocked ${e.tool}`}`),
130
+ }
131
+ : {}),
132
+ // NOT routed through `withHumanWait` (I-3, and this one is subtle):
133
+ // the lead's watchdog is ALREADY paused for the whole dispatch, and
134
+ // `IdleWatchdog.pause()` is a boolean rather than a counter — so a
135
+ // nested resume when one child's dialog closed would re-arm the lead
136
+ // mid-dispatch and abort it at `idleTimeoutMs`. The queue pauses the
137
+ // CHILD's watchdog instead, which is the one that needs it (P0-4).
138
+ ...(confirm ? { humanQueue: new TeamHumanQueue(confirm) } : {}),
139
+ })
140
+ : null;
84
141
  this.tools = createBuiltinTools({
85
142
  getCwd: () => this.cwd,
86
143
  confirmTools: config.confirmTools,
@@ -110,6 +167,27 @@ export class AgentController {
110
167
  withHumanWait: (fn) => this.withHumanWait(fn),
111
168
  onPlanApproved: () => this.setAgentMode('build', { force: true }),
112
169
  }),
170
+ // THROUGH THE FACTORY, NEVER APPENDED AFTERWARDS (D-15 / P0-1).
171
+ // `tools.test.ts::C7` asserts `HOST_TOOL_NAMES` equals what
172
+ // `createBuiltinTools` produces, so appending `task` here would turn that
173
+ // test red with a message about two lists of names.
174
+ //
175
+ // Empty when team mode was off at construction, which leaves the array
176
+ // byte-identical to the pre-team build (AC-11).
177
+ teamTools: this.teamRuntime
178
+ ? [
179
+ createTaskTool({
180
+ runtime: this.teamRuntime,
181
+ // Read LIVE: `/team off` flips the flag, it cannot unregister.
182
+ isTeamEnabled: () => this.teamEnabled,
183
+ maxSubagents: () => this.config.team.maxSubagents,
184
+ hasApiKey: () => this.hasApiKey(),
185
+ activeProvider: () => this.config.provider,
186
+ modelCost: () => this.getModelInfo().cost,
187
+ withPausedWatchdog: (fn) => this.withPausedWatchdog(fn),
188
+ }),
189
+ ]
190
+ : [],
113
191
  // Read at CALL time, never cached: the mode flips mid-run when the user
114
192
  // approves a plan, and a cached 'plan' would refuse the work they just
115
193
  // authorized.
@@ -149,6 +227,11 @@ export class AgentController {
149
227
  toolTimeoutOverrides: {
150
228
  ask_user: config.planModeHumanTimeoutMs,
151
229
  submit_plan: config.planModeHumanTimeoutMs,
230
+ // A fan-out is not a hung tool either. Like the two above, this
231
+ // number only bites BECAUSE `task` races `ctx.signal` — the
232
+ // executor's timeout is cooperative, so an override is inert for any
233
+ // tool that does not listen (I-2).
234
+ ...(this.teamRegistered ? { task: config.team.dispatchTimeoutMs } : {}),
152
235
  },
153
236
  },
154
237
  });
@@ -193,6 +276,16 @@ export class AgentController {
193
276
  agentMode: this.effectiveMode,
194
277
  planInteractive: this.planInteractive,
195
278
  planMaxAskRounds: this.config.planModeMaxAskRounds,
279
+ // BOTH flags, and both are required (§4.5). `teamRegistered` false means
280
+ // there is no `task` tool to advertise; `teamEnabled` false means the user
281
+ // turned it off for this session. Advertising in either case would
282
+ // describe a capability the model does not have.
283
+ teamBlock: this.teamRegistered && this.teamEnabled
284
+ ? buildTeamBlock({
285
+ maxSubagents: this.config.team.maxSubagents,
286
+ maxConcurrent: this.config.team.maxConcurrent,
287
+ })
288
+ : '',
196
289
  });
197
290
  }
198
291
  rebuildSystemPrompt() {
@@ -293,6 +386,13 @@ export class AgentController {
293
386
  }
294
387
  abort() {
295
388
  this.agent.abort();
389
+ // DELIBERATE REDUNDANCY (§3.8). Aborting the lead already aborts the
390
+ // in-flight `task` call's `ctx.signal`, which the runtime listens to — that
391
+ // path is correct today. Calling it directly as well makes "no child
392
+ // survives its dispatch" a property of THIS class rather than a consequence
393
+ // of signal plumbing three modules away, which is the same argument
394
+ // `App.tsx` records for `cancelPendingHuman()` on `agent_end`.
395
+ this.teamRuntime?.abortAll();
296
396
  }
297
397
  steer(text) {
298
398
  // Mid-run interjection: promote the queue, keep what is already in force
@@ -393,8 +493,19 @@ export class AgentController {
393
493
  * path must not leave the watchdog disarmed for the rest of the run. Also used
394
494
  * by the `--confirm` gate, which had exactly this bug — a user who took four
395
495
  * minutes over `Proceed? (y/N)` had their run killed by the watchdog.
496
+ *
497
+ * RENAMED FROM `withHumanWait`, WITH THE OLD NAME KEPT AS AN ALIAS (D-12). The
498
+ * mechanism is not about humans any more — a `task` dispatch is a long wait
499
+ * with no human in it at all — but the plan tools and the `--confirm` gate read
500
+ * correctly with the old name at their call sites, and renaming those would be
501
+ * churn in files this change should barely touch.
502
+ *
503
+ * NOT RE-ENTRANT, and that matters here: `IdleWatchdog.pause()` sets a boolean
504
+ * rather than a counter, so a nested call's `finally` re-arms the watchdog
505
+ * while the outer wait is still going. That is why a child's `--confirm`
506
+ * dialog does NOT route through this (see the constructor's `humanQueue`).
396
507
  */
397
- async withHumanWait(fn) {
508
+ async withPausedWatchdog(fn) {
398
509
  this.agent.pauseIdleWatchdog();
399
510
  try {
400
511
  return await fn();
@@ -403,6 +514,70 @@ export class AgentController {
403
514
  this.agent.resumeIdleWatchdog();
404
515
  }
405
516
  }
517
+ /** @see withPausedWatchdog — the name the plan tools and `--confirm` use. */
518
+ withHumanWait(fn) {
519
+ return this.withPausedWatchdog(fn);
520
+ }
521
+ // -----------------------------------------------------------------------
522
+ // Team mode (team-subagents §4.5)
523
+ // -----------------------------------------------------------------------
524
+ /** Whether `task` is in `this.tools`. Fixed at construction; see the fields. */
525
+ isTeamRegistered() {
526
+ return this.teamRegistered;
527
+ }
528
+ /** Whether `task` will actually dispatch. Flipped live by `/team on|off`. */
529
+ isTeamEnabled() {
530
+ return this.teamEnabled;
531
+ }
532
+ /**
533
+ * Flip the live switch and re-render the prompt.
534
+ *
535
+ * Turning it ON in a session that started with `--no-team` deliberately does
536
+ * NOT register the tool (D-17): the array is immutable, and advertising an
537
+ * unregistered tool is the dead end P0-2 is about. `/team` reports that
538
+ * honestly by checking `isTeamRegistered()` first.
539
+ */
540
+ setTeamEnabled(enabled) {
541
+ this.teamEnabled = enabled;
542
+ this.rebuildSystemPrompt();
543
+ }
544
+ getTeamConfig() {
545
+ return this.config.team;
546
+ }
547
+ /**
548
+ * Adopt a new team config for this session and re-render the prompt (the block
549
+ * quotes the numbers). Clamped here as well as on the persist path, so a bad
550
+ * value can never reach the runtime.
551
+ */
552
+ setTeamConfig(patch) {
553
+ const team = clampTeamConfig({ ...this.config.team, ...patch });
554
+ this.config = { ...this.config, team };
555
+ this.rebuildSystemPrompt();
556
+ return team;
557
+ }
558
+ /** Refused mid-dispatch by `/team`; half a dispatch under two ceilings is not
559
+ * a result anything can afterwards explain (the reasoning `/reload` uses). */
560
+ isTeamBusy() {
561
+ return this.teamRuntime?.isBusy() ?? false;
562
+ }
563
+ getTeamSnapshot() {
564
+ return this.teamRuntime?.snapshot() ?? null;
565
+ }
566
+ /**
567
+ * Subscribe to the CLI-local team event stream.
568
+ *
569
+ * Returns a no-op unsubscribe when team mode was off at construction, so every
570
+ * caller can subscribe unconditionally.
571
+ */
572
+ subscribeTeam(listener) {
573
+ if (!this.teamRuntime)
574
+ return () => { };
575
+ return this.teamRuntime.subscribe(listener);
576
+ }
577
+ /** Abort every child and clear timers. Idempotent; safe to call twice. */
578
+ dispose() {
579
+ this.teamRuntime?.dispose();
580
+ }
406
581
  // -----------------------------------------------------------------------
407
582
  // Mutators
408
583
  // -----------------------------------------------------------------------
@@ -6,12 +6,24 @@
6
6
  */
7
7
  import type { AgentEvent, ModelInfo } from '@aragon-agent/core';
8
8
  import type { PreflightResult } from './controller.js';
9
+ import type { TeamEvent } from '../team/types.js';
9
10
  /** The minimal controller surface headless mode needs (real or stubbed). */
10
11
  export interface HeadlessController {
11
12
  preflight(): PreflightResult;
12
13
  subscribe(listener: (event: AgentEvent) => void): () => void;
13
14
  prompt(text: string): Promise<void>;
14
15
  getModelInfo(): ModelInfo;
16
+ /**
17
+ * The CLI-local team stream — OPTIONAL, deliberately (team-subagents §3.12 /
18
+ * P1-6).
19
+ *
20
+ * This interface is minimal so tests can satisfy it with an object literal.
21
+ * Making the member REQUIRED would break every existing stub for no benefit,
22
+ * and the subscription genuinely is optional: a controller with no team
23
+ * runtime has no team events. `runHeadless` calls it with `?.`, which keeps
24
+ * the `--no-team` headless path byte-identical to today's.
25
+ */
26
+ subscribeTeam?(listener: (event: TeamEvent) => void): () => void;
15
27
  }
16
28
  export interface HeadlessOptions {
17
29
  quiet?: boolean;
@@ -4,7 +4,7 @@
4
4
  * derived from the event stream (R2), not from the resolved promise:
5
5
  * 0 = success, 1 = agent error (incl. a silently-swallowed throw), 2 = config.
6
6
  */
7
- import { formatCost, formatTokens, computeCost } from './usage.js';
7
+ import { formatCost, formatTokens, formatDuration, computeCost } from './usage.js';
8
8
  import { formatStreamError } from './reducer.js';
9
9
  /** Run a single prompt headlessly and resolve with the process exit code. */
10
10
  export async function runHeadless(controller, prompt, options = {}) {
@@ -57,11 +57,51 @@ export async function runHeadless(controller, prompt, options = {}) {
57
57
  break;
58
58
  }
59
59
  });
60
+ // Team mode is ON in headless: a fan-out needs no human (§3.12). What the
61
+ // children's spend must NOT do is vanish from the footer — the `[usage]` line
62
+ // is the only cost readout `-p` has, and a dispatch is where most of the
63
+ // tokens go.
64
+ const started = new Set();
65
+ const finished = new Set();
66
+ const unsubscribeTeam = controller.subscribeTeam?.((event) => {
67
+ if (event.type === 'usage') {
68
+ total.inputTokens += event.usage.inputTokens;
69
+ total.outputTokens += event.usage.outputTokens;
70
+ return;
71
+ }
72
+ if (quiet)
73
+ return;
74
+ if (event.type === 'dispatch_start') {
75
+ err.write(`\n[team] dispatch ${event.specs.length} subagents\n`);
76
+ return;
77
+ }
78
+ if (event.type === 'agent_update') {
79
+ const { run } = event;
80
+ if (run.phase !== 'queued' && !started.has(run.label)) {
81
+ started.add(run.label);
82
+ err.write(`[team] ${run.label} start "${run.description}"\n`);
83
+ }
84
+ const terminal = run.phase === 'done' || run.phase === 'failed' || run.phase === 'aborted';
85
+ if (terminal && !finished.has(run.label)) {
86
+ finished.add(run.label);
87
+ const ms = Math.max(0, (run.endedAt ?? 0) - (run.startedAt ?? 0));
88
+ const verdict = run.phase === 'done' && !run.error ? 'ok' : run.phase;
89
+ err.write(`[team] ${run.label} ${verdict} ${formatDuration(ms)} ${run.toolCalls} tools\n`);
90
+ }
91
+ return;
92
+ }
93
+ if (event.type === 'dispatch_end') {
94
+ const ok = event.outcome.runs.filter((r) => r.phase === 'done' && !r.error).length;
95
+ err.write(`[team] done ${ok}/${event.outcome.runs.length} ok` +
96
+ `${event.outcome.aborted ? ' (aborted)' : ''}\n`);
97
+ }
98
+ });
60
99
  try {
61
100
  await controller.prompt(prompt);
62
101
  }
63
102
  finally {
64
103
  unsubscribe();
104
+ unsubscribeTeam?.();
65
105
  }
66
106
  // Ensure a trailing newline so piped output is well-formed.
67
107
  if (wroteText)
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import type { AgentEvent, ModelCost, ToolResult, TokenUsage } from '@aragon-agent/core';
14
14
  import type { AgentMode } from './agent-mode.js';
15
+ import type { DispatchOutcome, SubagentRun, SubagentSpec, TeamSnapshot } from '../team/types.js';
15
16
  export type NoticeLevel = 'info' | 'warn' | 'error';
16
17
  export type ToastLevel = NoticeLevel | 'success';
17
18
  export type Overlay = null | 'settings' | 'model' | 'help' | 'confirm' | 'question' | 'plan';
@@ -55,6 +56,28 @@ export type Entry = {
55
56
  kind: 'notice';
56
57
  level: NoticeLevel;
57
58
  text: string;
59
+ }
60
+ /**
61
+ * A team dispatch (team-subagents §5.3). JSON-SERIALIZABLE by construction, so
62
+ * `/save` and `/resume` need no format change.
63
+ *
64
+ * `active: true` is normalized away ON LOAD, in `session/persist.ts`, not here
65
+ * (P1-5). A session saved mid-dispatch would otherwise resume with a card that
66
+ * claims to be running while nothing is: it spins forever, and because
67
+ * `Transcript`'s settled boundary is MONOTONIC an entry that never settles
68
+ * never reaches `<Static>` and is re-rendered on every frame for the rest of
69
+ * the session. The reducer never legitimately sees a stale-active entry — only
70
+ * the file does — which is why the fix belongs in the load path.
71
+ */
72
+ | {
73
+ id: string;
74
+ kind: 'team';
75
+ dispatchId: string;
76
+ requested: number;
77
+ runs: SubagentRun[];
78
+ aborted: boolean;
79
+ durationMs?: number;
80
+ active: boolean;
58
81
  };
59
82
  export interface UsageTotal {
60
83
  inputTokens: number;
@@ -81,7 +104,17 @@ export interface ViewState {
81
104
  agentMode: AgentMode;
82
105
  /** Set only by a DEFERRED `plan -> build`; drives the `PLAN -> BUILD` readout. */
83
106
  pendingAgentMode: AgentMode | null;
107
+ /**
108
+ * The live team roster, or `null` when no dispatch is running.
109
+ *
110
+ * NEVER PERSISTED. `/save` writes `entries`, and the settled `team` entry is
111
+ * the history; this field is the ephemeral panel, and a resumed session has no
112
+ * live dispatch to show (§6.4).
113
+ */
114
+ team: TeamSnapshot | null;
84
115
  seq: number;
116
+ /** The `kind: 'team'` entry the live dispatch is writing into. */
117
+ teamEntryId?: string;
85
118
  streamingId?: string;
86
119
  turnProduced: boolean;
87
120
  errorNoticed: boolean;
@@ -165,6 +198,21 @@ export type ViewAction = {
165
198
  type: 'setAgentMode';
166
199
  mode: AgentMode;
167
200
  pending: AgentMode | null;
201
+ } | {
202
+ type: 'teamStart';
203
+ dispatchId: string;
204
+ requested: number;
205
+ specs: SubagentSpec[];
206
+ } | {
207
+ type: 'teamUpdate';
208
+ snapshot: TeamSnapshot;
209
+ } | {
210
+ type: 'teamUsage';
211
+ usage: TokenUsage;
212
+ costDelta: number;
213
+ } | {
214
+ type: 'teamEnd';
215
+ outcome: DispatchOutcome;
168
216
  };
169
217
  export declare const STORED_PREVIEW_CHARS = 8000;
170
218
  export declare const STORED_PREVIEW_LINES = 200;
@@ -27,7 +27,9 @@ export function initialViewState() {
27
27
  // `controller.getAgentMode()` so `--plan` renders on the first frame.
28
28
  agentMode: 'build',
29
29
  pendingAgentMode: null,
30
+ team: null,
30
31
  seq: 0,
32
+ teamEntryId: undefined,
31
33
  streamingId: undefined,
32
34
  turnProduced: false,
33
35
  errorNoticed: false,
@@ -367,8 +369,17 @@ export function viewReducer(state, action) {
367
369
  entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false, aborted: true } : e),
368
370
  };
369
371
  }
372
+ // `teamEntryId` is dropped with the entries it pointed at. Keeping it would
373
+ // leave `teamUpdate` mapping an id that no longer exists — harmless today,
374
+ // and a dangling reference the next reader has to reason about.
370
375
  case 'clearTranscript':
371
- return { ...state, entries: [], streamingId: undefined, expandedToolIds: {} };
376
+ return {
377
+ ...state,
378
+ entries: [],
379
+ streamingId: undefined,
380
+ expandedToolIds: {},
381
+ teamEntryId: undefined,
382
+ };
372
383
  case 'resetConversation':
373
384
  return {
374
385
  ...state,
@@ -377,6 +388,7 @@ export function viewReducer(state, action) {
377
388
  expandedToolIds: {},
378
389
  contextTokens: 0,
379
390
  usageTotal: { inputTokens: 0, outputTokens: 0, costUsd: 0 },
391
+ teamEntryId: undefined,
380
392
  };
381
393
  case 'setOverlay':
382
394
  return { ...state, overlay: action.overlay };
@@ -407,13 +419,90 @@ export function viewReducer(state, action) {
407
419
  next[action.id] = true;
408
420
  return { ...state, expandedToolIds: next };
409
421
  }
422
+ // --- Team mode (team-subagents §5.3) ---------------------------------
423
+ case 'teamStart': {
424
+ const { id, seq } = nextId(state);
425
+ const runs = action.specs.map((spec) => ({
426
+ label: spec.label,
427
+ description: spec.description,
428
+ phase: 'queued',
429
+ turns: 0,
430
+ toolCalls: 0,
431
+ usage: { inputTokens: 0, outputTokens: 0 },
432
+ filesTouched: [],
433
+ messagesSent: 0,
434
+ }));
435
+ const entry = {
436
+ id,
437
+ kind: 'team',
438
+ dispatchId: action.dispatchId,
439
+ requested: action.requested,
440
+ runs,
441
+ aborted: false,
442
+ active: true,
443
+ };
444
+ return {
445
+ ...state,
446
+ seq,
447
+ entries: [...state.entries, entry],
448
+ teamEntryId: id,
449
+ team: {
450
+ dispatchId: action.dispatchId,
451
+ active: true,
452
+ runs,
453
+ requested: action.requested,
454
+ startedAt: Date.now(),
455
+ messageCount: 0,
456
+ },
457
+ };
458
+ }
459
+ case 'teamUpdate': {
460
+ // One `mapEntry` on the KNOWN id, never a scan: this action arrives at up
461
+ // to `maxConcurrent` x 8/s and a reverse search over a long transcript on
462
+ // every one of them is exactly the cost the coalescer exists to avoid.
463
+ const entries = state.teamEntryId
464
+ ? mapEntry(state.entries, state.teamEntryId, (e) => e.kind === 'team' ? { ...e, runs: action.snapshot.runs } : e)
465
+ : state.entries;
466
+ return { ...state, entries, team: action.snapshot };
467
+ }
468
+ case 'teamUsage':
469
+ // `usageTotal` AND NOTHING ELSE (§3.9 / AC-15). In particular NOT
470
+ // `contextTokens`: that gauge shows the LEAD's context occupancy against
471
+ // the model's window, and folding five children into it would read 180%
472
+ // on a perfectly healthy session. Child spend is real money and has to
473
+ // appear in the cost readout; it is not the lead's context.
474
+ return {
475
+ ...state,
476
+ usageTotal: {
477
+ inputTokens: state.usageTotal.inputTokens + action.usage.inputTokens,
478
+ outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
479
+ costUsd: state.usageTotal.costUsd + action.costDelta,
480
+ },
481
+ };
482
+ case 'teamEnd': {
483
+ const { outcome } = action;
484
+ const entries = state.teamEntryId
485
+ ? mapEntry(state.entries, state.teamEntryId, (e) => e.kind === 'team'
486
+ ? {
487
+ ...e,
488
+ runs: outcome.runs,
489
+ aborted: outcome.aborted,
490
+ durationMs: Math.max(0, outcome.endedAt - outcome.startedAt),
491
+ active: false,
492
+ }
493
+ : e)
494
+ : state.entries;
495
+ // `team: null` is what makes the panel disappear — the requirement's
496
+ // "(dang you de shi hou)": it renders only while a dispatch is running.
497
+ return { ...state, entries, team: null, teamEntryId: undefined };
498
+ }
410
499
  case 'restoreEntries': {
411
500
  // Adopt a seq beyond any restored id to keep future ids unique.
412
501
  const maxSeq = action.entries.reduce((acc, e) => {
413
502
  const n = Number.parseInt(e.id.replace(/^e/, ''), 10);
414
503
  return Number.isFinite(n) ? Math.max(acc, n) : acc;
415
504
  }, state.seq);
416
- return { ...state, entries: [...action.entries], seq: maxSeq };
505
+ return { ...state, entries: [...action.entries], seq: maxSeq, teamEntryId: undefined };
417
506
  }
418
507
  default:
419
508
  return state;
@@ -36,6 +36,23 @@ export interface SystemPromptParams {
36
36
  planInteractive?: boolean;
37
37
  /** `planModeMaxAskRounds`, substituted into the block so the two agree. */
38
38
  planMaxAskRounds?: number;
39
+ /**
40
+ * The rendered `<team_mode>` block for a LEAD with team mode on, or `''`.
41
+ *
42
+ * Spliced conditionally exactly as `skillsBlock` and the plan block are, which
43
+ * is what keeps invariant I-8 true: `team.enabled: false` (and `--no-team`)
44
+ * produce the pre-team prompt BYTE FOR BYTE.
45
+ */
46
+ teamBlock?: string;
47
+ /**
48
+ * The rendered `<subagent_role>` block for a CHILD, or `''`.
49
+ *
50
+ * Never set together with `teamBlock`: a child has no `task` tool (D-3), and a
51
+ * lead is not a subagent. Two fields rather than one because the two blocks
52
+ * carry opposite instructions and a single "team text" parameter would make
53
+ * mixing them a one-character mistake.
54
+ */
55
+ subagentBlock?: string;
39
56
  }
40
57
  /** Describe the active shell so the model does not emit incompatible syntax. */
41
58
  export declare function describeShell(): {
@@ -37,6 +37,8 @@ export function buildSystemPrompt(params) {
37
37
  maxAskRounds: params.planMaxAskRounds ?? 4,
38
38
  })
39
39
  : '';
40
+ const teamBlock = params.teamBlock ?? '';
41
+ const subagentBlock = params.subagentBlock ?? '';
40
42
  return [
41
43
  'You are AragonAgent, an autonomous coding assistant operating inside a terminal (TUI).',
42
44
  'You help the user accomplish software-engineering tasks by reasoning step by step and using the provided tools.',
@@ -50,6 +52,8 @@ export function buildSystemPrompt(params) {
50
52
  toolList,
51
53
  ...(hasSkills ? ['', skillsBlock] : []),
52
54
  ...(planBlock ? ['', planBlock] : []),
55
+ ...(teamBlock ? ['', teamBlock] : []),
56
+ ...(subagentBlock ? ['', subagentBlock] : []),
53
57
  '',
54
58
  'Operating guidance:',
55
59
  '- Prefer reading files and inspecting the workspace before making changes.',