@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
@@ -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.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  import React from 'react';
8
8
  import type { AgentController } from '../agent/controller.js';
9
9
  import type { ConfirmRequest } from '../tools/index.js';
10
- import { type Overlay } from '../agent/reducer.js';
10
+ import { type Overlay, type ToastLevel } from '../agent/reducer.js';
11
11
  import { type RenderMode } from './layout/frame.js';
12
12
  import { type FrameGeometry } from './layout/regions.js';
13
13
  import type { MouseSource } from '../input/stdin-mouse-filter.js';
@@ -15,6 +15,27 @@ import type { HumanInputBridge } from '../tools/human-input.js';
15
15
  export interface ConfirmBridge {
16
16
  handler: ((req: ConfirmRequest) => Promise<boolean>) | null;
17
17
  }
18
+ /** What the settings screen's `Max tokens` field resolved to. */
19
+ export interface SettingsMaxTokens {
20
+ /** Pushed into the live controller. `undefined` is AUTO. */
21
+ applied: number | undefined;
22
+ /** Written to the config file, or `'skip'` to leave the stored value alone. */
23
+ persist: number | null | 'skip';
24
+ /** Replaces the generic "Settings saved." acknowledgement when present. */
25
+ toast?: [ToastLevel, string];
26
+ }
27
+ /**
28
+ * Decide what the `Max tokens` field means, WITHOUT letting a bad value in it
29
+ * discard the rest of the form.
30
+ *
31
+ * The three outcomes are genuinely different and must stay distinguishable:
32
+ * `auto` is a setting, a clamped number is a setting the user should be told
33
+ * about, and nonsense is not a setting at all — it keeps the previous value and
34
+ * says so, rather than silently resolving to a default the user never chose.
35
+ *
36
+ * Exported for the tests; there is no other caller.
37
+ */
38
+ export declare function resolveSettingsMaxTokens(raw: string, previous: number | undefined): SettingsMaxTokens;
18
39
  export interface AppProps {
19
40
  controller: AgentController;
20
41
  version: string;
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 { parseMaxTokensInput, 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';
@@ -46,6 +50,41 @@ import { CommandRegistry, runSlashInput } from '../commands/registry.js';
46
50
  import { registerBuiltinCommands } from '../commands/builtins.js';
47
51
  import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js';
48
52
  import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
53
+ /**
54
+ * Decide what the `Max tokens` field means, WITHOUT letting a bad value in it
55
+ * discard the rest of the form.
56
+ *
57
+ * The three outcomes are genuinely different and must stay distinguishable:
58
+ * `auto` is a setting, a clamped number is a setting the user should be told
59
+ * about, and nonsense is not a setting at all — it keeps the previous value and
60
+ * says so, rather than silently resolving to a default the user never chose.
61
+ *
62
+ * Exported for the tests; there is no other caller.
63
+ */
64
+ export function resolveSettingsMaxTokens(raw, previous) {
65
+ const parsed = parseMaxTokensInput(raw);
66
+ if (parsed.kind === 'auto') {
67
+ return {
68
+ applied: undefined,
69
+ persist: null,
70
+ toast: ['info', 'Max tokens: auto (per-model ceiling).'],
71
+ };
72
+ }
73
+ if (parsed.kind === 'value') {
74
+ return {
75
+ applied: parsed.value,
76
+ persist: parsed.value,
77
+ ...(parsed.clamped
78
+ ? { toast: ['warn', `Max tokens clamped to ${parsed.value}.`] }
79
+ : {}),
80
+ };
81
+ }
82
+ return {
83
+ applied: previous,
84
+ persist: 'skip',
85
+ toast: ['warn', 'Max tokens must be a number or "auto".'],
86
+ };
87
+ }
49
88
  /** Merge pending delta actions ~33 ms and flush the coalescer buffer. */
50
89
  const COALESCE_MS = 33;
51
90
  export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, geometryOverride, }) {
@@ -58,7 +97,10 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
58
97
  const [confirmState, setConfirmState] = useState(null);
59
98
  /** The request the question / plan overlay is currently rendering. */
60
99
  const [humanRequest, setHumanRequest] = useState(null);
61
- const [promptHistory, setPromptHistory] = useState(() => controller.getConfig().promptHistory ?? []);
100
+ // Seeded from the history file, not from `CliConfig`: the list is no longer
101
+ // part of the resolved config, so every other `loadConfig()` caller is spared
102
+ // reading it (config-state-separation D-4).
103
+ const [promptHistory, setPromptHistory] = useState(() => loadPromptHistory());
62
104
  // Scroll plumbing: intent goes down, one derived display number comes back.
63
105
  const [scrollIntent, setScrollIntent] = useState();
64
106
  /** One prompt-history step per wheel notch over the composer band (§4.7). */
@@ -194,6 +236,62 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
194
236
  unsubscribe();
195
237
  };
196
238
  }, [controller, flushPending, cancelPendingHuman]);
239
+ // --- Team-mode event stream (team-subagents §5.2 / §3.9). --------------
240
+ //
241
+ // A SEPARATE SUBSCRIPTION, not a branch in the one above: `TeamEvent` is
242
+ // deliberately not a member of core's `AgentEvent` union (D-10 / I-5), so it
243
+ // cannot arrive through `controller.subscribe`.
244
+ //
245
+ // These actions are NOT routed through the streaming coalescer. That buffer
246
+ // exists to merge text/thinking deltas; `agent_update` is already coalesced at
247
+ // the source at 120 ms per child (§5.2), and pushing it through a second
248
+ // buffer would only delay the roster.
249
+ useEffect(() => {
250
+ const unsubscribeTeam = controller.subscribeTeam((event) => {
251
+ switch (event.type) {
252
+ case 'dispatch_start':
253
+ dispatch({
254
+ type: 'teamStart',
255
+ dispatchId: event.dispatchId,
256
+ requested: event.requested,
257
+ specs: event.specs,
258
+ });
259
+ break;
260
+ case 'agent_update': {
261
+ const snapshot = controller.getTeamSnapshot();
262
+ if (snapshot)
263
+ dispatch({ type: 'teamUpdate', snapshot });
264
+ break;
265
+ }
266
+ case 'message': {
267
+ const snapshot = controller.getTeamSnapshot();
268
+ if (snapshot)
269
+ dispatch({ type: 'teamUpdate', snapshot });
270
+ break;
271
+ }
272
+ case 'usage':
273
+ // Child spend is REAL SPEND and must reach the status bar, or the
274
+ // session cost readout under-reports by however much the team consumed
275
+ // — the single most misleading possible failure of this feature (R-5).
276
+ dispatch({
277
+ type: 'teamUsage',
278
+ usage: event.usage,
279
+ costDelta: computeCost(event.usage, controller.getModelInfo().cost),
280
+ });
281
+ break;
282
+ case 'dispatch_end':
283
+ dispatch({ type: 'teamEnd', outcome: event.outcome });
284
+ break;
285
+ }
286
+ });
287
+ return () => {
288
+ unsubscribeTeam();
289
+ // Nothing else disposes the runtime on an inline-mode exit or a crash
290
+ // that unmounts the tree, and an orphaned child holds the event loop open
291
+ // (R-15). `cli.tsx` calls this too; `dispose()` is idempotent.
292
+ controller.dispose();
293
+ };
294
+ }, [controller]);
197
295
  // --- Elapsed timer + tokens/sec baseline while running. ----------------
198
296
  useEffect(() => {
199
297
  if (state.status !== 'running') {
@@ -241,7 +339,9 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
241
339
  // the events must never be shown advice about a mode not in effect (§6.1).
242
340
  useStartupNotices(dispatch, {
243
341
  enabled: !!mouseSource,
244
- onSeen: () => persistConfig({ mouseNoticeSeen: true }),
342
+ // `state.json`, not `config.json`: whether a one-shot notice has been shown
343
+ // is bookkeeping, and the comment on the old persisted key said so.
344
+ onSeen: () => setMouseNoticeSeen(true),
245
345
  });
246
346
  // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
247
347
  useEffect(() => {
@@ -399,15 +499,21 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
399
499
  refreshSkills: () => setSkillsNonce((n) => n + 1),
400
500
  applyAgentMode: applyMode,
401
501
  });
502
+ /**
503
+ * Submitting a prompt no longer touches `config.json` at all — it appends one
504
+ * line to the history file and bumps one counter in `state.json`.
505
+ *
506
+ * `setPromptHistory` IS NOT OPTIONAL, and deleting it is the single easiest
507
+ * mistake to make here: `appendPrompt` keeps its own module-level array, but
508
+ * the `history` prop the composer recalls from is THIS React state. Without
509
+ * this line `↑` still works after a restart (the file is correct) and fails
510
+ * only for prompts submitted in the current session.
511
+ */
402
512
  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 });
513
+ setPromptHistory(appendPrompt(text));
514
+ // Read back through `controller.getConfig()` and never entering React
515
+ // state, exactly as before — nothing re-renders on the counter's account.
516
+ controller.setSubmitCount(bumpSubmitCount());
411
517
  };
412
518
  /** `repeat` folds a coalesced wheel burst; the keyboard always sends one. */
413
519
  const scrollBy = useCallback((kind, repeat = 1) => {
@@ -447,10 +553,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
447
553
  submitMessage(raw.startsWith('//') ? raw.slice(1) : raw);
448
554
  };
449
555
  const handleSettingsSave = (values) => {
450
- const maxTokens = coerceMaxTokens(values.maxTokens);
556
+ // An unusable cap must NOT discard the rest of the form: the same Enter
557
+ // press is often carrying a freshly pasted API key, and losing that to a
558
+ // typo in an unrelated field is the worst outcome available here.
559
+ const cap = resolveSettingsMaxTokens(values.maxTokens, controller.getConfig().maxTokens);
451
560
  controller.setModel(values.provider, values.model, values.baseUrl || undefined);
452
561
  controller.setThinkingLevel(values.thinkingLevel);
453
- controller.setMaxTokens(maxTokens);
562
+ controller.setMaxTokens(cap.applied);
454
563
  const key = values.apiKey.trim();
455
564
  if (key.length > 0) {
456
565
  // Registration site 4 (§4.4.3), and the one that matters most: this is how
@@ -465,7 +574,9 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
465
574
  model: values.model,
466
575
  baseUrl: values.baseUrl.trim() ? values.baseUrl.trim() : null,
467
576
  thinkingLevel: values.thinkingLevel,
468
- maxTokens: maxTokens ?? null,
577
+ // Omitted entirely when the field was unusable, so the previous value
578
+ // survives instead of being overwritten with a guess.
579
+ ...(cap.persist !== 'skip' ? { maxTokens: cap.persist } : {}),
469
580
  };
470
581
  if (key.length > 0)
471
582
  patch.apiKeys = { [values.provider]: key };
@@ -476,7 +587,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
476
587
  persistConfig(patch);
477
588
  getLogger().reconfigure({ ...controller.getConfig().log, level: values.logLevel });
478
589
  dispatch({ type: 'setOverlay', overlay: null });
479
- toast('success', 'Settings saved.');
590
+ toast(cap.toast?.[0] ?? 'success', cap.toast?.[1] ?? 'Settings saved.');
480
591
  };
481
592
  const handleModelSelect = (provider, model) => {
482
593
  controller.setModel(provider, model);
@@ -641,7 +752,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
641
752
  return;
642
753
  }
643
754
  if (key.ctrl && (input === 'o' || input === 'O')) {
644
- const last = [...stateRef.current.entries].reverse().find((e) => e.kind === 'tool');
755
+ // `team` joins `tool` here (team-subagents §6.3): a dispatch card collapses
756
+ // its per-agent summaries behind the same key, and `toggleExpand` is keyed
757
+ // on entry id so it needs no change.
758
+ const last = [...stateRef.current.entries]
759
+ .reverse()
760
+ .find((e) => e.kind === 'tool' || e.kind === 'team');
645
761
  if (last)
646
762
  dispatch({ type: 'toggleExpand', id: last.id });
647
763
  else
@@ -691,7 +807,9 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
691
807
  model: cfg.model,
692
808
  baseUrl: cfg.baseUrl ?? '',
693
809
  thinkingLevel: cfg.thinkingLevel,
694
- maxTokens: cfg.maxTokens ? String(cfg.maxTokens) : '',
810
+ // `auto`, not an empty string: an empty field reads as "unset", which
811
+ // is indistinguishable from AUTO and is not a state this can be in.
812
+ maxTokens: cfg.maxTokens ? String(cfg.maxTokens) : 'auto',
695
813
  apiKey: '',
696
814
  logLevel: cfg.log.level,
697
815
  }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onSave: handleSettingsSave })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { questions: humanRequest.questions, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSubmit: (answers) => resolveHuman({ kind: 'answers', answers, cancelled: false }) })) : overlay === 'plan' && humanRequest?.kind === 'plan' ? (_jsx(PlanReviewOverlay, { plan: humanRequest.plan, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
@@ -724,5 +842,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
724
842
  // `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
725
843
  const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
726
844
  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 }) }));
845
+ 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
846
+ ? {
847
+ teamActive: {
848
+ running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
849
+ r.phase !== 'failed' && r.phase !== 'aborted').length,
850
+ total: state.team.runs.length,
851
+ },
852
+ }
853
+ : {}) }) }));
728
854
  }
@@ -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;