@aragon-agent/cli 0.5.2 → 0.5.3

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 (83) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +128 -1
  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 +66 -2
  12. package/dist/commands/builtins.js +82 -0
  13. package/dist/config/app-paths.d.ts +17 -0
  14. package/dist/config/app-paths.js +21 -0
  15. package/dist/config/cli-commands.d.ts +26 -2
  16. package/dist/config/cli-commands.js +63 -4
  17. package/dist/config/env.d.ts +2 -2
  18. package/dist/config/env.js +28 -2
  19. package/dist/config/history-commands.d.ts +20 -0
  20. package/dist/config/history-commands.js +71 -0
  21. package/dist/config/load.d.ts +13 -0
  22. package/dist/config/load.js +65 -5
  23. package/dist/config/migrate-state-out-of-config.d.ts +32 -0
  24. package/dist/config/migrate-state-out-of-config.js +85 -0
  25. package/dist/config/prompt-history.d.ts +86 -0
  26. package/dist/config/prompt-history.js +332 -0
  27. package/dist/config/schema.d.ts +112 -16
  28. package/dist/config/schema.js +89 -6
  29. package/dist/config/store.js +13 -4
  30. package/dist/config/ui-state.d.ts +52 -0
  31. package/dist/config/ui-state.js +149 -0
  32. package/dist/logging/install.d.ts +85 -0
  33. package/dist/logging/install.js +94 -0
  34. package/dist/logging/logger.d.ts +1 -1
  35. package/dist/session/persist.d.ts +16 -0
  36. package/dist/session/persist.js +19 -1
  37. package/dist/team/activity.d.ts +67 -0
  38. package/dist/team/activity.js +130 -0
  39. package/dist/team/bus.d.ts +190 -0
  40. package/dist/team/bus.js +366 -0
  41. package/dist/team/comm-tools.d.ts +61 -0
  42. package/dist/team/comm-tools.js +221 -0
  43. package/dist/team/human-queue.d.ts +55 -0
  44. package/dist/team/human-queue.js +72 -0
  45. package/dist/team/limits.d.ts +123 -0
  46. package/dist/team/limits.js +128 -0
  47. package/dist/team/normalize.d.ts +39 -0
  48. package/dist/team/normalize.js +106 -0
  49. package/dist/team/panel-rows.d.ts +48 -0
  50. package/dist/team/panel-rows.js +75 -0
  51. package/dist/team/prompt.d.ts +49 -0
  52. package/dist/team/prompt.js +87 -0
  53. package/dist/team/report.d.ts +58 -0
  54. package/dist/team/report.js +205 -0
  55. package/dist/team/retry.d.ts +48 -0
  56. package/dist/team/retry.js +56 -0
  57. package/dist/team/runtime.d.ts +122 -0
  58. package/dist/team/runtime.js +406 -0
  59. package/dist/team/subagent.d.ts +90 -0
  60. package/dist/team/subagent.js +294 -0
  61. package/dist/team/task-tool.d.ts +47 -0
  62. package/dist/team/task-tool.js +118 -0
  63. package/dist/team/types.d.ts +172 -0
  64. package/dist/team/types.js +13 -0
  65. package/dist/tools/index.d.ts +48 -2
  66. package/dist/tools/index.js +26 -1
  67. package/dist/ui/App.js +97 -13
  68. package/dist/ui/StatusBar.d.ts +21 -0
  69. package/dist/ui/StatusBar.js +5 -2
  70. package/dist/ui/TeamPanel.d.ts +57 -0
  71. package/dist/ui/TeamPanel.js +134 -0
  72. package/dist/ui/Transcript.js +10 -0
  73. package/dist/ui/entries/TeamCard.d.ts +31 -0
  74. package/dist/ui/entries/TeamCard.js +39 -0
  75. package/dist/ui/glyphs.d.ts +10 -0
  76. package/dist/ui/glyphs.js +6 -0
  77. package/dist/ui/layout/AppShell.d.ts +11 -1
  78. package/dist/ui/layout/AppShell.js +3 -3
  79. package/dist/ui/overlays/HelpOverlay.js +12 -0
  80. package/dist/ui/transcript-text.js +20 -0
  81. package/dist/ui/use-startup-notices.d.ts +1 -1
  82. package/dist/ui/use-startup-notices.js +2 -1
  83. package/package.json +2 -2
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Runtime shapes for team mode (team-subagents §5.1 / §5.2).
3
+ *
4
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
5
+ *
6
+ * `TeamEvent` is a CLI-LOCAL stream and must stay that way (D-10 / I-5).
7
+ * `packages/core` freezes its runtime export list (`public-api.test.ts`) and
8
+ * forbids host coupling (`no-host-coupling.test.ts`); adding a member to core's
9
+ * `AgentEvent` union would break the first and teach core about a host shape
10
+ * (`SubagentRun`) it has no business defining. The whole feature therefore
11
+ * changes ZERO files under `packages/core/` (AC-12).
12
+ */
13
+ import type { TokenUsage } from '@aragon-agent/core';
14
+ import type { ActivityArgs } from './activity.js';
15
+ /** One normalized delegation request. Produced only by `normalizeSubagentSpecs`. */
16
+ export interface SubagentSpec {
17
+ /** Slug, unique within the dispatch. Shown in the panel, the report and logs. */
18
+ label: string;
19
+ /** <= TEAM_LIMITS.descriptionChars. What this subagent is for. */
20
+ description: string;
21
+ /** <= TEAM_LIMITS.promptChars. Self-contained: the child sees no history. */
22
+ prompt: string;
23
+ /**
24
+ * Force the plan-mode gate on for this child regardless of session mode.
25
+ * TIGHTENING ONLY (D-13): there is no field anywhere that gives a child more
26
+ * permission than the session it was spawned from.
27
+ */
28
+ readOnly: boolean;
29
+ }
30
+ export type SubagentPhase = 'queued' | 'starting' | 'thinking' | 'tool' | 'waiting' | 'done' | 'failed' | 'aborted';
31
+ /** Live, mutable record of one child. Projected into the UI and the report. */
32
+ export interface SubagentRun {
33
+ label: string;
34
+ description: string;
35
+ phase: SubagentPhase;
36
+ startedAt?: number;
37
+ endedAt?: number;
38
+ turns: number;
39
+ toolCalls: number;
40
+ lastTool?: string;
41
+ usage: TokenUsage;
42
+ /**
43
+ * Paths seen going through `write_file` / `edit_file`, deduped and capped at
44
+ * `TEAM_LIMITS.filesTouchedMax`.
45
+ *
46
+ * BEST-EFFORT BY CONSTRUCTION: a child that writes through `bash` is invisible
47
+ * here. The conflict warning in the report says "review before trusting
48
+ * either" rather than claiming completeness, and that wording is load-bearing
49
+ * (R-2).
50
+ */
51
+ filesTouched: string[];
52
+ messagesSent: number;
53
+ /** The child's final assistant text. The only thing the lead sees of its work. */
54
+ summary?: string;
55
+ error?: string;
56
+ /** Hit `team.maxTurnsPerSubagent` and was stopped mid-work. */
57
+ truncated?: boolean;
58
+ /**
59
+ * Prose the child is writing right now, sanitized and clamped to
60
+ * `TEAM_LIMITS.activityChars`. Set only between tool calls; cleared on
61
+ * `turn_start`, on `tool_execution_start` and on `tool_execution_end` so it
62
+ * can never describe work the child has already finished.
63
+ *
64
+ * THE CLAMP HERE IS A STORAGE CEILING, NOT A COLUMN WIDTH (P0-1). The panel
65
+ * re-clamps to `activityBudget(cols)` at render. Do not "simplify" by clamping
66
+ * to the display budget here: this value has no terminal.
67
+ *
68
+ * NEVER LOGGED and never persisted: it is ephemeral by construction and a
69
+ * resumed session has no live dispatch.
70
+ */
71
+ activity?: string;
72
+ /**
73
+ * The five argument fields any of the nine known tools might want to show,
74
+ * picked at capture time by `pickActivityArgs` and elided to
75
+ * `TEAM_LIMITS.activityArgChars` each.
76
+ *
77
+ * A PICKED SUBSET, NOT `event.args` (P1-4). The run is shallow-copied into the
78
+ * live snapshot and into every `agent_update` event by `publish()`, and again
79
+ * into `DispatchOutcome.runs` by `dispatch()`, which `TeamCard` holds in the
80
+ * transcript for the session. Holding `event.args` would mean holding
81
+ * `write_file`'s `content` - a whole file, unbounded - along all of those
82
+ * paths, and would put a file body one `JSON.stringify` from a log.
83
+ *
84
+ * Stored unformatted because the PANEL owns the wide/narrow decision and it is
85
+ * the only object that knows the terminal width. A formatted string here would
86
+ * bake one terminal's width into a value the report and the headless writer
87
+ * also read.
88
+ *
89
+ * Cleared on `turn_start` and `tool_execution_end` (P1-5).
90
+ */
91
+ activityArgs?: ActivityArgs;
92
+ /** `team_wait` calls refused as unanswerable (F-3). Pulled from the bus on
93
+ * `tool_execution_end`, the same way `messagesSent` is. */
94
+ blockedWaits?: number;
95
+ /** Cold-start retries spent (F-4). 0 or absent for the overwhelming majority
96
+ * of runs; `1` at most under `TEAM_LIMITS.maxColdStartRetries`. */
97
+ retries?: number;
98
+ /**
99
+ * Whether the error that ended this attempt was transport-level and
100
+ * retryable, read structurally off `LLMError.retryable`.
101
+ *
102
+ * NOT RENDERED ANYWHERE. It exists so `TeamRuntime` can decide without
103
+ * re-parsing `run.error`, which is a human-facing sentence produced by
104
+ * `formatStreamError` and must never become a machine-readable contract.
105
+ */
106
+ retryable?: boolean;
107
+ }
108
+ /** One message on the team bus. */
109
+ export interface TeamMessage {
110
+ from: string;
111
+ to: string;
112
+ subject: string;
113
+ body: string;
114
+ at: number;
115
+ }
116
+ /** Everything one dispatch produced. The sole input to `buildDispatchReport`. */
117
+ export interface DispatchOutcome {
118
+ dispatchId: string;
119
+ runs: SubagentRun[];
120
+ /** Specs the model asked for, BEFORE the cap — the "n of m requested" line. */
121
+ requested: number;
122
+ startedAt: number;
123
+ endedAt: number;
124
+ aborted: boolean;
125
+ /** Messages addressed to `lead`; surfaced in their own report section. */
126
+ leadMail: TeamMessage[];
127
+ /** Summed across every child. Real spend; it must reach the status bar (§3.9). */
128
+ usage: TokenUsage;
129
+ }
130
+ /**
131
+ * What the UI renders: a cheap immutable projection, rebuilt per event.
132
+ *
133
+ * Held in `ViewState.team` and NEVER persisted — a resumed session has no live
134
+ * dispatch, and pretending otherwise is what P1-5 is about.
135
+ */
136
+ export interface TeamSnapshot {
137
+ dispatchId: string;
138
+ active: boolean;
139
+ runs: SubagentRun[];
140
+ requested: number;
141
+ startedAt: number;
142
+ messageCount: number;
143
+ lastMessage?: TeamMessage;
144
+ }
145
+ /**
146
+ * The CLI-local event stream. `TeamRuntime` emits; `AgentController.subscribeTeam`
147
+ * forwards; `App`, `runHeadless` and `attachTeamEvents` consume.
148
+ */
149
+ export type TeamEvent = {
150
+ type: 'dispatch_start';
151
+ dispatchId: string;
152
+ requested: number;
153
+ specs: SubagentSpec[];
154
+ } | {
155
+ type: 'agent_update';
156
+ dispatchId: string;
157
+ run: SubagentRun;
158
+ } | {
159
+ type: 'usage';
160
+ dispatchId: string;
161
+ label: string;
162
+ usage: TokenUsage;
163
+ } | {
164
+ type: 'message';
165
+ dispatchId: string;
166
+ message: TeamMessage;
167
+ } | {
168
+ type: 'dispatch_end';
169
+ dispatchId: string;
170
+ outcome: DispatchOutcome;
171
+ };
172
+ export type TeamEventListener = (event: TeamEvent) => void;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Runtime shapes for team mode (team-subagents §5.1 / §5.2).
3
+ *
4
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
5
+ *
6
+ * `TeamEvent` is a CLI-LOCAL stream and must stay that way (D-10 / I-5).
7
+ * `packages/core` freezes its runtime export list (`public-api.test.ts`) and
8
+ * forbids host coupling (`no-host-coupling.test.ts`); adding a member to core's
9
+ * `AgentEvent` union would break the first and teach core about a host shape
10
+ * (`SubagentRun`) it has no business defining. The whole feature therefore
11
+ * changes ZERO files under `packages/core/` (AC-12).
12
+ */
13
+ export {};
@@ -13,6 +13,19 @@ import type { AgentMode } from '../agent/agent-mode.js';
13
13
  export interface ConfirmRequest {
14
14
  tool: string;
15
15
  summary: string;
16
+ /**
17
+ * The tool call's own abort signal, when the executor supplied one.
18
+ *
19
+ * IT IS HERE BECAUSE `withConfirmation` IS THE ONLY PLACE THAT HAS IT. A
20
+ * confirm implementation that has to abandon a pending dialog — the subagent
21
+ * queue in `team/human-queue.ts` is the one that does (team-subagents §3.11 /
22
+ * AC-16) — cannot reach `ctx` any other way, and without it an `Esc` during a
23
+ * dispatch pops a fresh dialog for every request still sitting in the FIFO.
24
+ *
25
+ * Ignored by the interactive and headless confirms, which are resolved by
26
+ * their own channel when a run ends.
27
+ */
28
+ signal?: AbortSignal;
16
29
  }
17
30
  export interface BuiltinToolsOptions {
18
31
  getCwd: () => string;
@@ -72,6 +85,39 @@ export interface BuiltinToolsOptions {
72
85
  * burns a turn.
73
86
  */
74
87
  planTools?: AgentTool[];
88
+ /**
89
+ * Team-mode tools, appended verbatim. The lead gets `[task]`; a subagent gets
90
+ * `[team_send, team_wait]`.
91
+ *
92
+ * THIS OPTION EXISTS BECAUSE A TEST FORBIDS THE ALTERNATIVE (D-15 / P0-1).
93
+ * `tools.test.ts::C7` asserts that `HOST_TOOL_NAMES` equals WHAT THIS FACTORY
94
+ * ACTUALLY PRODUCES, so the moment `task` joined `HOST_TOOL_NAMES` (I-7),
95
+ * appending it in `AgentController` after the factory returned would turn C7
96
+ * red — with a failure message about two lists of names that says nothing
97
+ * about team mode.
98
+ *
99
+ * ONE option serves both sides on purpose: the only two call sites are the
100
+ * lead's controller and `createSubagent`, and a second option would just be a
101
+ * second name for "extra tools this caller supplies".
102
+ *
103
+ * An empty array (the `--no-team` path) leaves the tool list byte-identical to
104
+ * the pre-team build — AC-11, provable by object identity exactly as
105
+ * `--no-skills` is.
106
+ */
107
+ teamTools?: AgentTool[];
108
+ /**
109
+ * Tool names that skip the CEILING wrapper only (never the plan gate, never
110
+ * confirmation).
111
+ *
112
+ * It exists for a subagent's two comm tools and for nothing else (§3.11).
113
+ * `team_send` / `team_wait` are not in the LEAD's registered tool list, so
114
+ * evaluating them against the lead's `allowed-tools` ceiling would refuse them
115
+ * and dead-end the channel — the same dead end `SKILL_TOOL_FLOOR` exists to
116
+ * prevent one level up. Widening this set to anything that can write to disk
117
+ * would turn delegation into a ceiling bypass, which is exactly what §3.11
118
+ * re-applies the lead's policy to children to avoid.
119
+ */
120
+ policyExempt?: ReadonlySet<string>;
75
121
  }
76
122
  /**
77
123
  * Tools the ceiling always permits (D-G6). The ONLY place that knows which of
@@ -88,7 +134,7 @@ export interface BuiltinToolsOptions {
88
134
  * is the whole of what this mechanism is worth, and saying so plainly beats
89
135
  * implying it covers more.
90
136
  */
91
- export declare const SKILL_TOOL_FLOOR: readonly ["read_file", "list_dir", "glob", "grep", "skill", "skill_find", "ask_user", "submit_plan"];
137
+ export declare const SKILL_TOOL_FLOOR: readonly ["read_file", "list_dir", "glob", "grep", "skill", "skill_find", "ask_user", "submit_plan", "task"];
92
138
  /**
93
139
  * The five tools plan mode refuses: exactly the complement of `SKILL_TOOL_FLOOR`
94
140
  * within `HOST_TOOL_NAMES` — "the five that change something", the set this file
@@ -121,5 +167,5 @@ export declare const PLAN_MODE_BLOCKED_TOOLS: ReadonlySet<string>;
121
167
  * declaration MEANS, and that reading must not change with how the CLI was
122
168
  * started.
123
169
  */
124
- export declare const HOST_TOOL_NAMES: readonly ["read_file", "write_file", "edit_file", "list_dir", "glob", "grep", "bash", "skill", "skill_find", "skill_install", "skill_create", "ask_user", "submit_plan"];
170
+ export declare const HOST_TOOL_NAMES: readonly ["read_file", "write_file", "edit_file", "list_dir", "glob", "grep", "bash", "skill", "skill_find", "skill_install", "skill_create", "ask_user", "submit_plan", "task"];
125
171
  export declare function createBuiltinTools(options: BuiltinToolsOptions): AgentTool[];
@@ -46,6 +46,12 @@ export const SKILL_TOOL_FLOOR = [
46
46
  // "read-only".)
47
47
  'ask_user',
48
48
  'submit_plan',
49
+ // `task` joins the floor rather than the blocked set (D-5), and the choice is
50
+ // forced to be conscious by the partition test below. It cannot itself mutate
51
+ // anything: every child mutation passes through this same wrapper stack one
52
+ // level down, with the child inheriting `agentMode` through the same closure.
53
+ // Blocking it would forfeit parallel research, which is plan mode's best use.
54
+ 'task',
49
55
  ];
50
56
  /**
51
57
  * The five tools plan mode refuses: exactly the complement of `SKILL_TOOL_FLOOR`
@@ -99,6 +105,13 @@ export const HOST_TOOL_NAMES = [
99
105
  'skill_create',
100
106
  'ask_user',
101
107
  'submit_plan',
108
+ // `team_send` / `team_wait` are DELIBERATELY ABSENT (P2-5). This constant
109
+ // answers "can a skill's `allowed-tools` declaration ever take effect?", and
110
+ // those two are never registered on a lead — so a skill naming them genuinely
111
+ // IS unenforceable, and `aragon skills doctor` saying so is correct rather
112
+ // than a gap. They reach children through `policyExempt`, a different
113
+ // mechanism answering a different question.
114
+ 'task',
102
115
  ];
103
116
  function summarize(toolName, params) {
104
117
  if (toolName === 'bash')
@@ -118,6 +131,11 @@ function withConfirmation(tool, confirm) {
118
131
  const approved = await confirm({
119
132
  tool: tool.name,
120
133
  summary: summarize(tool.name, params),
134
+ // Forwarded, not consumed, here: this wrapper still awaits the answer,
135
+ // because a confirm that resolved itself on abort would let the tool run
136
+ // unapproved. It is the confirm IMPLEMENTATION that decides what an
137
+ // abort means (see `ConfirmRequest.signal`).
138
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
121
139
  });
122
140
  if (!approved)
123
141
  return errorResult('Cancelled by user.');
@@ -209,6 +227,12 @@ export function createBuiltinTools(options) {
209
227
  if (options.planTools && options.planTools.length > 0) {
210
228
  tools = [...tools, ...options.planTools];
211
229
  }
230
+ // BEFORE both wrappers, so the lead's `task` is covered by the plan gate and
231
+ // the ceiling like everything else, and a child's comm tools are covered by
232
+ // everything except the ceiling (see `policyExempt`).
233
+ if (options.teamTools && options.teamTools.length > 0) {
234
+ tools = [...tools, ...options.teamTools];
235
+ }
212
236
  // No decision provider (`--no-skills`, or a caller that never enabled the
213
237
  // policy) means the ceiling adds nothing.
214
238
  //
@@ -223,7 +247,8 @@ export function createBuiltinTools(options) {
223
247
  // available ordering. The ceiling needs no I/O, so it goes before confirmation.
224
248
  if (options.toolPolicy) {
225
249
  const getDecision = options.toolPolicy;
226
- tools = tools.map((t) => withToolPolicy(t, getDecision, options.onToolPolicyEvent));
250
+ const exempt = options.policyExempt;
251
+ tools = tools.map((t) => exempt?.has(t.name) ? t : withToolPolicy(t, getDecision, options.onToolPolicyEvent));
227
252
  }
228
253
  // TWO OPTIONS, TWO INDEPENDENT GUARDS, NO SHARED EXIT.
229
254
  //
package/dist/ui/App.js CHANGED
@@ -10,8 +10,11 @@ import process from 'node:process';
10
10
  import { Box, Text, useApp, useInput, useStdout } from 'ink';
11
11
  import { initialViewState, reduceEvent, viewReducer, } from '../agent/reducer.js';
12
12
  import { mergeDeltas } from '../agent/coalesce.js';
13
- import { coerceMaxTokens, DEFAULT_TRANSCRIPT_WINDOW, PROMPT_HISTORY_CAP, } from '../config/schema.js';
13
+ import { computeCost } from '../agent/usage.js';
14
+ import { coerceMaxTokens, DEFAULT_TRANSCRIPT_WINDOW, } from '../config/schema.js';
14
15
  import { updatePersistedConfig } from '../config/store.js';
16
+ import { appendPrompt, loadPromptHistory } from '../config/prompt-history.js';
17
+ import { bumpSubmitCount, setMouseNoticeSeen } from '../config/ui-state.js';
15
18
  import { getLogger } from '../logging/logger.js';
16
19
  import { registerSecret } from '../logging/secret-registry.js';
17
20
  import { detectCapabilities } from './capabilities.js';
@@ -25,6 +28,7 @@ import { Transcript, TranscriptList } from './Transcript.js';
25
28
  import { PromptInput } from './PromptInput.js';
26
29
  import { Composer } from './Composer.js';
27
30
  import { StatusBar } from './StatusBar.js';
31
+ import { TeamPanel } from './TeamPanel.js';
28
32
  import { ToastStack } from './ToastStack.js';
29
33
  import { AppShell } from './layout/AppShell.js';
30
34
  import { ScrollViewport } from './layout/ScrollViewport.js';
@@ -58,7 +62,10 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
58
62
  const [confirmState, setConfirmState] = useState(null);
59
63
  /** The request the question / plan overlay is currently rendering. */
60
64
  const [humanRequest, setHumanRequest] = useState(null);
61
- const [promptHistory, setPromptHistory] = useState(() => controller.getConfig().promptHistory ?? []);
65
+ // Seeded from the history file, not from `CliConfig`: the list is no longer
66
+ // part of the resolved config, so every other `loadConfig()` caller is spared
67
+ // reading it (config-state-separation D-4).
68
+ const [promptHistory, setPromptHistory] = useState(() => loadPromptHistory());
62
69
  // Scroll plumbing: intent goes down, one derived display number comes back.
63
70
  const [scrollIntent, setScrollIntent] = useState();
64
71
  /** One prompt-history step per wheel notch over the composer band (§4.7). */
@@ -194,6 +201,62 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
194
201
  unsubscribe();
195
202
  };
196
203
  }, [controller, flushPending, cancelPendingHuman]);
204
+ // --- Team-mode event stream (team-subagents §5.2 / §3.9). --------------
205
+ //
206
+ // A SEPARATE SUBSCRIPTION, not a branch in the one above: `TeamEvent` is
207
+ // deliberately not a member of core's `AgentEvent` union (D-10 / I-5), so it
208
+ // cannot arrive through `controller.subscribe`.
209
+ //
210
+ // These actions are NOT routed through the streaming coalescer. That buffer
211
+ // exists to merge text/thinking deltas; `agent_update` is already coalesced at
212
+ // the source at 120 ms per child (§5.2), and pushing it through a second
213
+ // buffer would only delay the roster.
214
+ useEffect(() => {
215
+ const unsubscribeTeam = controller.subscribeTeam((event) => {
216
+ switch (event.type) {
217
+ case 'dispatch_start':
218
+ dispatch({
219
+ type: 'teamStart',
220
+ dispatchId: event.dispatchId,
221
+ requested: event.requested,
222
+ specs: event.specs,
223
+ });
224
+ break;
225
+ case 'agent_update': {
226
+ const snapshot = controller.getTeamSnapshot();
227
+ if (snapshot)
228
+ dispatch({ type: 'teamUpdate', snapshot });
229
+ break;
230
+ }
231
+ case 'message': {
232
+ const snapshot = controller.getTeamSnapshot();
233
+ if (snapshot)
234
+ dispatch({ type: 'teamUpdate', snapshot });
235
+ break;
236
+ }
237
+ case 'usage':
238
+ // Child spend is REAL SPEND and must reach the status bar, or the
239
+ // session cost readout under-reports by however much the team consumed
240
+ // — the single most misleading possible failure of this feature (R-5).
241
+ dispatch({
242
+ type: 'teamUsage',
243
+ usage: event.usage,
244
+ costDelta: computeCost(event.usage, controller.getModelInfo().cost),
245
+ });
246
+ break;
247
+ case 'dispatch_end':
248
+ dispatch({ type: 'teamEnd', outcome: event.outcome });
249
+ break;
250
+ }
251
+ });
252
+ return () => {
253
+ unsubscribeTeam();
254
+ // Nothing else disposes the runtime on an inline-mode exit or a crash
255
+ // that unmounts the tree, and an orphaned child holds the event loop open
256
+ // (R-15). `cli.tsx` calls this too; `dispose()` is idempotent.
257
+ controller.dispose();
258
+ };
259
+ }, [controller]);
197
260
  // --- Elapsed timer + tokens/sec baseline while running. ----------------
198
261
  useEffect(() => {
199
262
  if (state.status !== 'running') {
@@ -241,7 +304,9 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
241
304
  // the events must never be shown advice about a mode not in effect (§6.1).
242
305
  useStartupNotices(dispatch, {
243
306
  enabled: !!mouseSource,
244
- onSeen: () => persistConfig({ mouseNoticeSeen: true }),
307
+ // `state.json`, not `config.json`: whether a one-shot notice has been shown
308
+ // is bookkeeping, and the comment on the old persisted key said so.
309
+ onSeen: () => setMouseNoticeSeen(true),
245
310
  });
246
311
  // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
247
312
  useEffect(() => {
@@ -399,15 +464,21 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
399
464
  refreshSkills: () => setSkillsNonce((n) => n + 1),
400
465
  applyAgentMode: applyMode,
401
466
  });
467
+ /**
468
+ * Submitting a prompt no longer touches `config.json` at all — it appends one
469
+ * line to the history file and bumps one counter in `state.json`.
470
+ *
471
+ * `setPromptHistory` IS NOT OPTIONAL, and deleting it is the single easiest
472
+ * mistake to make here: `appendPrompt` keeps its own module-level array, but
473
+ * the `history` prop the composer recalls from is THIS React state. Without
474
+ * this line `↑` still works after a restart (the file is correct) and fails
475
+ * only for prompts submitted in the current session.
476
+ */
402
477
  const recordPrompt = (text) => {
403
- const next = [...promptHistory.filter((p) => p !== text), text].slice(-PROMPT_HISTORY_CAP);
404
- setPromptHistory(next);
405
- // The submit counter rides along with the history write rather than opening
406
- // a second I/O path (§4.6). It is read back through `controller.getConfig()`
407
- // and never enters React state — nothing re-renders on its account.
408
- const nextCount = (controller.getConfig().submitCount ?? 0) + 1;
409
- controller.setSubmitCount(nextCount);
410
- persistConfig({ promptHistory: next, submitCount: nextCount });
478
+ setPromptHistory(appendPrompt(text));
479
+ // Read back through `controller.getConfig()` and never entering React
480
+ // state, exactly as before — nothing re-renders on the counter's account.
481
+ controller.setSubmitCount(bumpSubmitCount());
411
482
  };
412
483
  /** `repeat` folds a coalesced wheel burst; the keyboard always sends one. */
413
484
  const scrollBy = useCallback((kind, repeat = 1) => {
@@ -641,7 +712,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
641
712
  return;
642
713
  }
643
714
  if (key.ctrl && (input === 'o' || input === 'O')) {
644
- const last = [...stateRef.current.entries].reverse().find((e) => e.kind === 'tool');
715
+ // `team` joins `tool` here (team-subagents §6.3): a dispatch card collapses
716
+ // its per-agent summaries behind the same key, and `toggleExpand` is keyed
717
+ // on entry id so it needs no change.
718
+ const last = [...stateRef.current.entries]
719
+ .reverse()
720
+ .find((e) => e.kind === 'tool' || e.kind === 'team');
645
721
  if (last)
646
722
  dispatch({ type: 'toggleExpand', id: last.id });
647
723
  else
@@ -724,5 +800,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
724
800
  // `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
725
801
  const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
726
802
  const composer = fullscreen ? (_jsx(Composer, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, popupMaxRows: popupMaxRows, historyIntent: historyIntent, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(PromptInput, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), agentMode: state.agentMode, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }) }));
727
- return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, geometryRef: geometryRef, header: header, viewport: viewport, toast: _jsx(ToastStack, { toasts: state.toasts, theme: theme, mode: mode }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode }) }));
803
+ return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, geometryRef: geometryRef, header: header, viewport: viewport, team: state.team ? (_jsx(TeamPanel, { snapshot: state.team, rows: rows, cols: cols, reducedMotion: reducedMotion, theme: theme, caps: caps })) : null, toast: _jsx(ToastStack, { toasts: state.toasts, theme: theme, mode: mode }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode, ...(state.team
804
+ ? {
805
+ teamActive: {
806
+ running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
807
+ r.phase !== 'failed' && r.phase !== 'aborted').length,
808
+ total: state.team.runs.length,
809
+ },
810
+ }
811
+ : {}) }) }));
728
812
  }
@@ -49,6 +49,27 @@ interface StatusBarProps {
49
49
  agentMode?: AgentMode;
50
50
  /** Non-null while a deferred `plan -> build` waits for `agent_end`. */
51
51
  pendingAgentMode?: AgentMode | null;
52
+ /**
53
+ * Live subagent counts (team-subagents §6.2), or absent when no dispatch is
54
+ * running — an ordinary session's bar is then unchanged.
55
+ *
56
+ * LEFT CLUSTER, and it degrades (D-20 / P2-1). The left cluster is
57
+ * `flexShrink={0}`, so every column it takes comes out of the context gauge
58
+ * and the cost readout opposite it; on an 80-column terminal the full
59
+ * `agents 3/5` visibly squeezes them, and the gauge is how a user notices they
60
+ * are about to run out of context. Below `TEAM_LIMITS.statusCompactCols` this
61
+ * therefore renders as `[3]` — the running count alone.
62
+ *
63
+ * This does NOT contradict the "no `cols >= N` breakpoint" rule above: that
64
+ * rule is written about `agentMode`, whose reason is that the mode must never
65
+ * be unreportable. The team counter has a second, fuller home in `TeamPanel`
66
+ * and a third in `TeamCard`; it is the one readout on this bar that can afford
67
+ * to degrade.
68
+ */
69
+ teamActive?: {
70
+ running: number;
71
+ total: number;
72
+ };
52
73
  }
53
74
  export declare function StatusBar(props: StatusBarProps): React.ReactElement;
54
75
  export {};
@@ -4,6 +4,7 @@ import { pickGlyphs } from './glyphs.js';
4
4
  import { formatCost, formatTokens, formatDuration } from '../agent/usage.js';
5
5
  import { buildGauge } from './gauge.js';
6
6
  import { MODE_LABEL } from '../agent/agent-mode.js';
7
+ import { TEAM_LIMITS } from '../team/limits.js';
7
8
  /**
8
9
  * I-5 — DO NOT "CLEAN THIS UP". Ink short-circuits identical output at TWO
9
10
  * gates (`ink.js:132` and `log-update.js:13`), so a fixed frame whose React
@@ -23,7 +24,7 @@ function redrawChar(nonce) {
23
24
  return nonce % 2 === 0 ? ' ' : NBSP;
24
25
  }
25
26
  export function StatusBar(props) {
26
- const { model, provider, usageTotal, contextTokens, contextWindow, contextWindowKnown, status, elapsedMs, thinkingLevel, tokPerSec, theme, caps, scrolledLines = 0, redrawNonce = 0, agentMode = 'build', pendingAgentMode = null, } = props;
27
+ const { model, provider, usageTotal, contextTokens, contextWindow, contextWindowKnown, status, elapsedMs, thinkingLevel, tokPerSec, theme, caps, scrolledLines = 0, redrawNonce = 0, agentMode = 'build', pendingAgentMode = null, teamActive, } = props;
27
28
  const { stdout } = useStdout();
28
29
  const cols = stdout?.columns ?? 80;
29
30
  const running = status === 'running';
@@ -47,5 +48,7 @@ export function StatusBar(props) {
47
48
  // and "? hel" rather than truncating cleanly at one end. Pinning the left
48
49
  // cluster keeps the run status and model intact and makes the degradation
49
50
  // land in one predictable place.
50
- _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsxs(Text, { color: running ? theme.toolRunning : theme.toolDone, children: [redrawChar(redrawNonce), statusGlyph, " ", running ? 'running' : 'idle'] }), modeWord && (_jsxs(Text, { color: theme.accent, bold: true, children: [' ', modeWord] })), cols >= 60 && (_jsxs(Text, { color: theme.muted, children: [' ', provider, ":", model] })), thinkingLevel !== 'off' && cols >= 72 && (_jsxs(Text, { color: theme.accent, children: [" think:", thinkingLevel] }))] }), _jsxs(Box, { flexDirection: "row", children: [scrolledLines > 0 && (_jsxs(Text, { color: theme.noticeWarn, children: [glyphs.arrowUp, scrolledLines, ' '] })), cols >= 60 && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "[" }), _jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty }), _jsx(Text, { color: theme.muted, children: "] " })] })), _jsx(Text, { color: theme.muted, children: pctLabel }), cols >= 72 && _jsxs(Text, { color: theme.muted, children: [" ", tokens] }), _jsxs(Text, { color: theme.muted, children: [" ", formatCost(usageTotal.costUsd)] }), running && (_jsxs(Text, { color: theme.muted, children: [tokPerSec > 0 && cols >= 72 ? ` ${tokPerSec} tok/s` : '', " ", formatDuration(elapsedMs)] }))] })] }));
51
+ _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsxs(Text, { color: running ? theme.toolRunning : theme.toolDone, children: [redrawChar(redrawNonce), statusGlyph, " ", running ? 'running' : 'idle'] }), modeWord && (_jsxs(Text, { color: theme.accent, bold: true, children: [' ', modeWord] })), teamActive && (_jsxs(Text, { color: theme.toolRunning, bold: true, children: [' ', cols >= TEAM_LIMITS.statusCompactCols
52
+ ? `agents ${teamActive.running}/${teamActive.total}`
53
+ : `[${teamActive.running}]`] })), cols >= 60 && (_jsxs(Text, { color: theme.muted, children: [' ', provider, ":", model] })), thinkingLevel !== 'off' && cols >= 72 && (_jsxs(Text, { color: theme.accent, children: [" think:", thinkingLevel] }))] }), _jsxs(Box, { flexDirection: "row", children: [scrolledLines > 0 && (_jsxs(Text, { color: theme.noticeWarn, children: [glyphs.arrowUp, scrolledLines, ' '] })), cols >= 60 && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "[" }), _jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty }), _jsx(Text, { color: theme.muted, children: "] " })] })), _jsx(Text, { color: theme.muted, children: pctLabel }), cols >= 72 && _jsxs(Text, { color: theme.muted, children: [" ", tokens] }), _jsxs(Text, { color: theme.muted, children: [" ", formatCost(usageTotal.costUsd)] }), running && (_jsxs(Text, { color: theme.muted, children: [tokPerSec > 0 && cols >= 72 ? ` ${tokPerSec} tok/s` : '', " ", formatDuration(elapsedMs)] }))] })] }));
51
54
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * TeamPanel — the live subagent roster (team-subagents §6.1).
3
+ *
4
+ * ASCII ONLY: every glyph comes from `pickGlyphs`; a literal here bypasses the
5
+ * capability probe and shows mojibake on a legacy console (`glyphs.ts` header).
6
+ *
7
+ * RENDERED ONLY WHILE A DISPATCH IS RUNNING (D-11). The requirement says "(dang
8
+ * you de shi hou)" — when there is one — and a permanent zero-row region would
9
+ * be furniture. `App` passes `null` the rest of the time and this component is
10
+ * not mounted at all, so a session that never delegates has zero rows and zero
11
+ * layout shift.
12
+ *
13
+ * IT MUST BE MOUNTED INSIDE `AppShell`'s MEASURED BOTTOM BOX (I-10). The wheel
14
+ * router reads the composer band boundary from `measureElement(bottomRef)`; a
15
+ * panel rendered outside it makes that boundary stale the moment a dispatch
16
+ * starts, and the wheel begins scrolling prompt history over the transcript.
17
+ */
18
+ import React from 'react';
19
+ import type { Theme } from './theme.js';
20
+ import type { TermCapabilities } from './capabilities.js';
21
+ import type { SubagentRun, TeamSnapshot } from '../team/types.js';
22
+ export interface TeamPanelProps {
23
+ snapshot: TeamSnapshot;
24
+ /** Terminal rows, for the one-line collapse below `panelCollapseRows`. */
25
+ rows: number;
26
+ /**
27
+ * Terminal columns. REQUIRED, not optional (D-13): an optional width
28
+ * defaulting to narrow would silently produce the degraded form forever if a
29
+ * call site forgot it, where a required prop fails at compile time.
30
+ */
31
+ cols: number;
32
+ reducedMotion: boolean;
33
+ theme: Theme;
34
+ caps: TermCapabilities;
35
+ /** Injectable clock so the panel test does not depend on wall time. */
36
+ now?: number;
37
+ }
38
+ export declare function activityBudget(cols: number): number;
39
+ /**
40
+ * The middle column: what this child is doing right now, in a few words.
41
+ *
42
+ * Two width policies, answering different questions. `wide` decides WHICH FACT
43
+ * to show - a whole path or its basename, a whole command line or its program
44
+ * name - mirroring `StatusBar`'s `statusCompactCols` degradation rather than
45
+ * inventing a second threshold. `activityBudget(cols)` decides HOW MUCH OF IT
46
+ * FITS, and it is a continuous function of width rather than a threshold,
47
+ * because the failure it prevents - a row wider than the terminal - is
48
+ * continuous.
49
+ *
50
+ * Exported for the same reason `buildSubagentTools` is: the width bound is the
51
+ * property this round has to keep, and asserting it through a rendered frame
52
+ * cannot distinguish "the arithmetic clamped it" from "Ink truncated it" - which
53
+ * is precisely the confusion that let v1's fixed-width column look fine at 100
54
+ * columns and break at 80.
55
+ */
56
+ export declare function activityLine(run: SubagentRun, cols: number): string;
57
+ export declare function TeamPanel({ snapshot, rows, cols, reducedMotion, theme, caps, now, }: TeamPanelProps): React.ReactElement;