@aragon-agent/cli 0.6.40 → 0.6.43

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 (51) hide show
  1. package/CHANGELOG.md +2632 -2589
  2. package/README.md +3720 -3706
  3. package/dist/agent/controller.d.ts +36 -5
  4. package/dist/agent/controller.js +112 -33
  5. package/dist/agent/headless.js +6 -2
  6. package/dist/agent/system-prompt.d.ts +19 -0
  7. package/dist/agent/system-prompt.js +5 -0
  8. package/dist/commands/builtins.js +16 -182
  9. package/dist/commands/subagent-command.d.ts +21 -16
  10. package/dist/commands/subagent-command.js +22 -225
  11. package/dist/commands/team-command.d.ts +49 -0
  12. package/dist/commands/team-command.js +259 -0
  13. package/dist/config/model-kits.d.ts +2 -1
  14. package/dist/config/store.js +3 -2
  15. package/dist/exec/runner.js +2 -1
  16. package/dist/team/bus.d.ts +4 -0
  17. package/dist/team/bus.js +12 -2
  18. package/dist/team/comm-tools.js +3 -0
  19. package/dist/team/dispatch-gate.d.ts +86 -0
  20. package/dist/team/dispatch-gate.js +75 -0
  21. package/dist/team/forced-mode.d.ts +116 -0
  22. package/dist/team/forced-mode.js +156 -0
  23. package/dist/team/forced-prompt.d.ts +48 -0
  24. package/dist/team/forced-prompt.js +84 -0
  25. package/dist/team/limits.d.ts +41 -1
  26. package/dist/team/limits.js +49 -1
  27. package/dist/team/model.d.ts +22 -6
  28. package/dist/team/model.js +19 -5
  29. package/dist/team/overseer.d.ts +4 -0
  30. package/dist/team/overseer.js +38 -6
  31. package/dist/team/prompt.js +3 -3
  32. package/dist/team/report.d.ts +21 -0
  33. package/dist/team/report.js +40 -9
  34. package/dist/team/runtime.d.ts +3 -0
  35. package/dist/team/runtime.js +61 -5
  36. package/dist/team/subagent.js +25 -7
  37. package/dist/ui/App.js +140 -20
  38. package/dist/ui/StatusBar.d.ts +14 -0
  39. package/dist/ui/StatusBar.js +1 -0
  40. package/dist/ui/layout/status-layout.d.ts +17 -0
  41. package/dist/ui/layout/status-layout.js +9 -0
  42. package/dist/ui/overlays/HelpOverlay.js +8 -4
  43. package/dist/ui/overlays/SettingsScreen.d.ts +14 -0
  44. package/dist/ui/overlays/SettingsScreen.js +17 -7
  45. package/dist/ui/overlays/TeamFanoutPage.d.ts +17 -1
  46. package/dist/ui/overlays/TeamFanoutPage.js +23 -11
  47. package/dist/ui/overlays/kit-settings-state.js +3 -2
  48. package/dist/ui/overlays/model-profile-state.d.ts +1 -1
  49. package/dist/ui/overlays/team-fanout-state.d.ts +46 -13
  50. package/dist/ui/overlays/team-fanout-state.js +78 -20
  51. package/package.json +81 -81
@@ -25,6 +25,7 @@ import type { ToolPermission } from '../exec/permission.js';
25
25
  import { type ToolOutputListener } from '../tools/tool-output-store.js';
26
26
  import type { FilePatch } from '../tools/patch.js';
27
27
  import type { SubagentModelResolution } from '../team/model.js';
28
+ import type { ForcedTeamState } from '../team/forced-mode.js';
28
29
  import type { TeamEvent, TeamSnapshot } from '../team/types.js';
29
30
  import { ProcSupervisor } from '../proc/supervisor.js';
30
31
  import type { ProcEventListener, ServiceSnapshot } from '../proc/types.js';
@@ -295,8 +296,8 @@ export declare class AgentController {
295
296
  /** Static: whether this session could ever render a question overlay. */
296
297
  private readonly planInteractive;
297
298
  private readonly teamRegistered;
298
- private teamEnabled;
299
299
  private readonly teamRuntime;
300
+ private forcedTeam;
300
301
  private readonly todoRegistered;
301
302
  private todoEnabled;
302
303
  private readonly todos;
@@ -704,17 +705,47 @@ export declare class AgentController {
704
705
  withHumanWait<T>(fn: () => Promise<T>): Promise<T>;
705
706
  /** Whether `task` is in `this.tools`. Fixed at construction; see the fields. */
706
707
  isTeamRegistered(): boolean;
707
- /** Whether `task` will actually dispatch. Flipped live by `/team on|off`. */
708
+ /**
709
+ * Whether `task` will actually dispatch - a DERIVED value.
710
+ *
711
+ * One truth (`config.team.enabled`) plus one immutable snapshot
712
+ * (`teamRegistered`). The previous version read a private copy that `/team
713
+ * on|off` hand-synchronised, which worked only while that command was the sole
714
+ * writer; the settings screen writes the config directly, so the copy would
715
+ * have kept saying `on` (P1-4 / AC-25).
716
+ */
708
717
  isTeamEnabled(): boolean;
709
718
  /**
710
719
  * Flip the live switch and re-render the prompt.
711
720
  *
721
+ * THE NAME IS KEPT (three call sites plus the settings path) but the body is
722
+ * one write: `setTeamConfig()` already clamps, bumps the revision and rebuilds,
723
+ * so there is no second place where a half-applied state can exist.
724
+ *
712
725
  * Turning it ON in a session that started with `--no-team` deliberately does
713
726
  * NOT register the tool (D-17): the array is immutable, and advertising an
714
727
  * unregistered tool is the dead end P0-2 is about. `/team` reports that
715
728
  * honestly by checking `isTeamRegistered()` first.
716
729
  */
717
730
  setTeamEnabled(enabled: boolean): void;
731
+ /**
732
+ * Arm forced team mode for this session (team-command-forced-dispatch §3.5).
733
+ *
734
+ * Returns the count actually adopted. The clamp is `[1, team.maxSubagents]`:
735
+ * `0`, a negative and a fraction are refused by the parser upstream (`-n 0` is
736
+ * a syntax error, not a clamp), so this is the boundary check of a value that
737
+ * crossed a module line - which is the one place `CLAUDE.md` asks for it, not a
738
+ * second validation of the same input.
739
+ *
740
+ * THE COUNT IS FROZEN for the rest of the session: `/team -n 2 <brief>` then a
741
+ * plain message keeps two children per turn, so the user's one expressed intent
742
+ * does not silently widen back to four.
743
+ */
744
+ armForcedTeam(count: number): number;
745
+ /** Disarm. IDEMPOTENT: disarming an unarmed session rewrites nothing. */
746
+ disarmForcedTeam(): void;
747
+ /** The armed state, read at render time by the chip and by `/team`'s status. */
748
+ getForcedTeam(): ForcedTeamState | null;
718
749
  getTeamConfig(): TeamConfig;
719
750
  /**
720
751
  * Adopt a new team config for this session and re-render the prompt (the block
@@ -728,8 +759,8 @@ export declare class AgentController {
728
759
  * One authority for the `task` report's disclosure line and its price
729
760
  * table: both read through here, so the header can never name one model
730
761
  * while pricing another. Re-resolved on every call for the reason
731
- * `resolveFastTier` records - `/fast off`, a settings save and `/team model`
732
- * all rewrite the inputs mid-session.
762
+ * `resolveFastTier` records - `/fast off`, a settings save and the `Subagent
763
+ * model` row all rewrite the inputs mid-session.
733
764
  */
734
765
  subagentModelResolution(): SubagentModelResolution;
735
766
  /** Refused mid-dispatch by `/team`; half a dispatch under two ceilings is not
@@ -771,7 +802,7 @@ export declare class AgentController {
771
802
  * THIS EXISTS BECAUSE `persistConfig` DOES NOT TOUCH THE RUNTIME (P1-2 /
772
803
  * AC-38). `App` reads `controller.getConfig()` on every render, and that
773
804
  * returns THIS object; the App's `persistConfig` only writes the file. So
774
- * `/todo panel off` must call both, exactly as `/team max` does — with only
805
+ * `/todo panel off` must call both, exactly as settings' `Team` page does — with
775
806
  * the persist call the command reports success and changes nothing until the
776
807
  * next launch.
777
808
  *
@@ -21,6 +21,9 @@ import { createFastOverseerProvider } from '../team/overseer.js';
21
21
  import { createTaskTool } from '../team/task-tool.js';
22
22
  import { resolveSubagentModel } from '../team/model.js';
23
23
  import { buildTeamBlock } from '../team/prompt.js';
24
+ // THE THIRD LAYER's prompt half (team-command-forced-dispatch §3.6): spliced
25
+ // after `<team_mode>` and before the subagent block, only while armed.
26
+ import { buildForcedTeamBlock } from '../team/forced-prompt.js';
24
27
  import { buildFastBlock } from '../fast/prompt.js';
25
28
  import { ProcSupervisor } from '../proc/supervisor.js';
26
29
  import { buildBackgroundJobsBlock, buildBackgroundServicesBlock } from '../proc/prompt.js';
@@ -172,18 +175,32 @@ export class AgentController {
172
175
  // immutable for the life of the session (see the note in the constructor),
173
176
  // so `/team on` in a session started with `--no-team` CANNOT add it.
174
177
  //
175
- // `teamEnabled` is live. `/team off` flips it, which makes `task` refuse
176
- // through its own guard and drops `<team_mode>` from the next prompt rebuild
177
- // — `rebuildSystemPrompt()` IS a live path, unlike the tool array.
178
+ // The LIVE switch is NOT a second field (team-command-forced-dispatch §3.5 /
179
+ // P1-4). It used to be one — `teamEnabled`, flipped by `/team on|off` and
180
+ // hand-synchronised with `config.team.enabled` — which was a second truth
181
+ // waiting to separate. The moment the settings screen grew a `Team mode` row,
182
+ // `applyModelSettingsSnapshot()` replaced `this.config` WITHOUT touching that
183
+ // copy, and the screen would have said `on` while `task` still answered
184
+ // TEAM_OFF_REFUSAL. `isTeamEnabled()` is now one derivation over
185
+ // `config.team.enabled`, and `setTeamEnabled()` is one write to it.
178
186
  //
179
- // Conflating them is P0-2: the prompt would advertise a tool that was never
180
- // registered, every call would come back "unknown tool", and the model would
181
- // have no way to discover why (D-17 / R-18).
187
+ // Conflating the two flags was P0-2 in the original feature: the prompt would
188
+ // advertise a tool that was never registered, every call would come back
189
+ // "unknown tool", and the model would have no way to discover why (D-17 / R-18).
182
190
  // -----------------------------------------------------------------------
183
191
  teamRegistered;
184
- teamEnabled;
185
192
  teamRuntime;
186
193
  // -----------------------------------------------------------------------
194
+ // Forced team mode (team-command-forced-dispatch §3.5 / D-4).
195
+ //
196
+ // SESSION STATE, NEVER PERSISTED: `null` means "not armed". `/team <brief>`
197
+ // and `/team on` set it, `/team off` clears it, and the process exiting is the
198
+ // third way it ends. It is not a `config.json` key on purpose — a persisted
199
+ // "always force a fan-out" would be inherited by `aragon exec` and by every
200
+ // launch after one experiment, at four children a turn (D-4 / AC-24).
201
+ // -----------------------------------------------------------------------
202
+ forcedTeam = null;
203
+ // -----------------------------------------------------------------------
187
204
  // Todo planning (todo-plan-execution §3.6a) — THE SAME TWO FLAGS, FOR THE
188
205
  // SAME REASON.
189
206
  //
@@ -426,8 +443,10 @@ export class AgentController {
426
443
  const fastTier = resolveFastTier(config, (id, role) => this.hasApiKey(id, role));
427
444
  const fastRegistered = fastTier.ok;
428
445
  // --- Team mode ---------------------------------------------------------
446
+ // ONE FLAG HERE (team-command-forced-dispatch P1-4): this is the IMMUTABLE
447
+ // snapshot of "is `task` in this.tools at all". The live switch is
448
+ // `config.team.enabled`, read through `isTeamEnabled()` — no second copy.
429
449
  this.teamRegistered = config.team.enabled;
430
- this.teamEnabled = config.team.enabled;
431
450
  this.teamRuntime = this.teamRegistered
432
451
  ? new TeamRuntime({
433
452
  getConfig: () => this.config,
@@ -711,7 +730,10 @@ export class AgentController {
711
730
  createTaskTool({
712
731
  runtime: this.teamRuntime,
713
732
  // Read LIVE: `/team off` flips the flag, it cannot unregister.
714
- isTeamEnabled: () => this.teamEnabled,
733
+ // THROUGH THE METHOD, not the config: one derivation, so the
734
+ // task tool, `<team_mode>` and the settings screen can never
735
+ // disagree about whether this session may dispatch (P1-4).
736
+ isTeamEnabled: () => this.isTeamEnabled(),
715
737
  maxSubagents: () => this.config.team.maxSubagents,
716
738
  hasApiKey: () => this.hasApiKey(),
717
739
  activeProvider: () => this.config.provider,
@@ -878,18 +900,15 @@ export class AgentController {
878
900
  // executor's timeout is cooperative, so an override is inert for any
879
901
  // tool that does not listen (I-2).
880
902
  //
881
- // `dispatchTimeoutMs: 0` (the default) reaches the executor as an
882
- // override of `0` = NO TIMER AT ALL: the dispatch lasts as long as its
883
- // slowest child, and only an Esc ends it early (main-agent parity).
884
- // D-4, THE SECOND ARMING SITE: the runtime arms its own dispatch
885
- // timer under the same gate, because the executor timeout being
886
- // cooperative means this override must never be the ONLY ceiling.
887
- // With the supervisor gate open the ceiling disarms HERE too, so
888
- // team.overseer can never leave two kill paths alive behind one
889
- // config. Read once at construction, exactly as before.
890
- ...(this.teamRegistered
891
- ? { task: config.team.overseer ? 0 : config.team.dispatchTimeoutMs }
892
- : {}),
903
+ // ALWAYS `0` = NO EXECUTOR TIMER, and the override itself must stay:
904
+ // deleting it would drop `task` back onto the executor's default tool
905
+ // timeout and kill every long fan-out at that default. The dispatch
906
+ // ceiling lives in ONE place, `TeamRuntime.dispatch()`, which re-reads
907
+ // `team.overseer` / `team.dispatchTimeoutMs` on every dispatch. The
908
+ // executor's table is frozen at construction, so a ceiling armed here
909
+ // outlived a mid-session `Settings > Team` switch to the supervisor
910
+ // and killed the dispatch the supervisor was meant to look after.
911
+ ...(this.teamRegistered ? { task: 0 } : {}),
893
912
  },
894
913
  },
895
914
  });
@@ -1056,16 +1075,31 @@ export class AgentController {
1056
1075
  skillTriageBlock: triageBlock,
1057
1076
  agentMode: this.effectiveMode,
1058
1077
  planInteractive: this.planInteractive,
1059
- // BOTH flags, and both are required (§4.5). `teamRegistered` false means
1060
- // there is no `task` tool to advertise; `teamEnabled` false means the user
1061
- // turned it off for this session. Advertising in either case would
1062
- // describe a capability the model does not have.
1063
- teamBlock: this.teamRegistered && this.teamEnabled
1078
+ // BOTH halves, and both are required (§4.5). `teamRegistered` false means
1079
+ // there is no `task` tool to advertise; `config.team.enabled` false means
1080
+ // the user turned the capability off (in settings — the only writer).
1081
+ // Advertising in either case would describe a capability the model does not
1082
+ // have. Read through the field rather than `this.isTeamEnabled()` so the
1083
+ // sentence and the code agree, as the two flags always have here (P1-4).
1084
+ teamBlock: this.teamRegistered && this.config.team.enabled
1064
1085
  ? buildTeamBlock({
1065
1086
  maxSubagents: config.team.maxSubagents,
1066
1087
  maxConcurrent: config.team.maxConcurrent,
1067
1088
  })
1068
1089
  : '',
1090
+ // THE THIRD LAYER (team-command-forced-dispatch §3.6): "you MUST dispatch"
1091
+ // on top of `<team_mode>`'s "you may". THREE conditions and all three are
1092
+ // load-bearing - armed for this session, the tool actually registered, and
1093
+ // the capability still on. The last two are `<team_mode>`'s own pair, so a
1094
+ // session can never be told to use a tool the prompt above it does not
1095
+ // mention. Empty everywhere else, which is what keeps I-2 (byte-identical
1096
+ // prompt) true for every session that never types `/team`.
1097
+ forcedTeamBlock: this.forcedTeam && this.teamRegistered && this.config.team.enabled
1098
+ ? buildForcedTeamBlock({
1099
+ count: this.forcedTeam.count,
1100
+ planMode: this.effectiveMode === 'plan',
1101
+ })
1102
+ : '',
1069
1103
  // BOTH FLAGS, exactly as team mode uses both, and for the identical
1070
1104
  // reason: `todoRegistered` false means there is no `todo_write` to
1071
1105
  // advertise, `todoEnabled` false means the user turned it off for this
@@ -1723,22 +1757,67 @@ export class AgentController {
1723
1757
  isTeamRegistered() {
1724
1758
  return this.teamRegistered;
1725
1759
  }
1726
- /** Whether `task` will actually dispatch. Flipped live by `/team on|off`. */
1760
+ /**
1761
+ * Whether `task` will actually dispatch - a DERIVED value.
1762
+ *
1763
+ * One truth (`config.team.enabled`) plus one immutable snapshot
1764
+ * (`teamRegistered`). The previous version read a private copy that `/team
1765
+ * on|off` hand-synchronised, which worked only while that command was the sole
1766
+ * writer; the settings screen writes the config directly, so the copy would
1767
+ * have kept saying `on` (P1-4 / AC-25).
1768
+ */
1727
1769
  isTeamEnabled() {
1728
- return this.teamEnabled;
1770
+ return this.teamRegistered && this.config.team.enabled;
1729
1771
  }
1730
1772
  /**
1731
1773
  * Flip the live switch and re-render the prompt.
1732
1774
  *
1775
+ * THE NAME IS KEPT (three call sites plus the settings path) but the body is
1776
+ * one write: `setTeamConfig()` already clamps, bumps the revision and rebuilds,
1777
+ * so there is no second place where a half-applied state can exist.
1778
+ *
1733
1779
  * Turning it ON in a session that started with `--no-team` deliberately does
1734
1780
  * NOT register the tool (D-17): the array is immutable, and advertising an
1735
1781
  * unregistered tool is the dead end P0-2 is about. `/team` reports that
1736
1782
  * honestly by checking `isTeamRegistered()` first.
1737
1783
  */
1738
1784
  setTeamEnabled(enabled) {
1739
- this.advanceSettingsRevision();
1740
- this.teamEnabled = enabled;
1785
+ this.setTeamConfig({ enabled });
1786
+ }
1787
+ /**
1788
+ * Arm forced team mode for this session (team-command-forced-dispatch §3.5).
1789
+ *
1790
+ * Returns the count actually adopted. The clamp is `[1, team.maxSubagents]`:
1791
+ * `0`, a negative and a fraction are refused by the parser upstream (`-n 0` is
1792
+ * a syntax error, not a clamp), so this is the boundary check of a value that
1793
+ * crossed a module line - which is the one place `CLAUDE.md` asks for it, not a
1794
+ * second validation of the same input.
1795
+ *
1796
+ * THE COUNT IS FROZEN for the rest of the session: `/team -n 2 <brief>` then a
1797
+ * plain message keeps two children per turn, so the user's one expressed intent
1798
+ * does not silently widen back to four.
1799
+ */
1800
+ armForcedTeam(count) {
1801
+ const ceiling = Math.max(1, this.config.team.maxSubagents);
1802
+ const adopted = Math.max(1, Math.min(Math.floor(count), ceiling));
1803
+ this.forcedTeam = { count: adopted };
1804
+ // RE-RENDER: the `<forced_team_mode>` block has to be in the very next
1805
+ // request, and `rebuildSystemPrompt()` is the live path that does it (it is
1806
+ // also why the command body prints a notice: this produces no AgentEvent, so
1807
+ // without one React would not repaint the `Team N` chip - P1-6).
1741
1808
  this.rebuildSystemPrompt();
1809
+ return adopted;
1810
+ }
1811
+ /** Disarm. IDEMPOTENT: disarming an unarmed session rewrites nothing. */
1812
+ disarmForcedTeam() {
1813
+ if (!this.forcedTeam)
1814
+ return;
1815
+ this.forcedTeam = null;
1816
+ this.rebuildSystemPrompt();
1817
+ }
1818
+ /** The armed state, read at render time by the chip and by `/team`'s status. */
1819
+ getForcedTeam() {
1820
+ return this.forcedTeam;
1742
1821
  }
1743
1822
  getTeamConfig() {
1744
1823
  return this.config.team;
@@ -1761,8 +1840,8 @@ export class AgentController {
1761
1840
  * One authority for the `task` report's disclosure line and its price
1762
1841
  * table: both read through here, so the header can never name one model
1763
1842
  * while pricing another. Re-resolved on every call for the reason
1764
- * `resolveFastTier` records - `/fast off`, a settings save and `/team model`
1765
- * all rewrite the inputs mid-session.
1843
+ * `resolveFastTier` records - `/fast off`, a settings save and the `Subagent
1844
+ * model` row all rewrite the inputs mid-session.
1766
1845
  */
1767
1846
  subagentModelResolution() {
1768
1847
  return resolveSubagentModel(this.config, (id, role) => this.hasApiKey(id, role));
@@ -1847,7 +1926,7 @@ export class AgentController {
1847
1926
  * THIS EXISTS BECAUSE `persistConfig` DOES NOT TOUCH THE RUNTIME (P1-2 /
1848
1927
  * AC-38). `App` reads `controller.getConfig()` on every render, and that
1849
1928
  * returns THIS object; the App's `persistConfig` only writes the file. So
1850
- * `/todo panel off` must call both, exactly as `/team max` does — with only
1929
+ * `/todo panel off` must call both, exactly as settings' `Team` page does — with
1851
1930
  * the persist call the command reports success and changes nothing until the
1852
1931
  * next launch.
1853
1932
  *
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { formatCost, formatTokens, formatDuration, computeCost } from './usage.js';
8
8
  import { formatStreamError } from './reducer.js';
9
+ import { runDurationMs } from '../team/report.js';
9
10
  import { derivePlanPhase } from '../todo/acceptance.js';
10
11
  import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
11
12
  /** Run a single prompt headlessly and resolve with the process exit code. */
@@ -99,19 +100,22 @@ export async function runHeadless(controller, prompt, options = {}) {
99
100
  if (quiet)
100
101
  return;
101
102
  if (event.type === 'dispatch_start') {
103
+ // Labels default to a1, a2... on every dispatch, so "once only" is per dispatch.
104
+ started.clear();
105
+ finished.clear();
102
106
  err.write(`\n[team] dispatch ${event.specs.length} subagents\n`);
103
107
  return;
104
108
  }
105
109
  if (event.type === 'agent_update') {
106
110
  const { run } = event;
107
- if (run.phase !== 'queued' && !started.has(run.label)) {
111
+ if (run.startedAt !== undefined && !started.has(run.label)) {
108
112
  started.add(run.label);
109
113
  err.write(`[team] ${run.label} start "${run.description}"\n`);
110
114
  }
111
115
  const terminal = run.phase === 'done' || run.phase === 'failed' || run.phase === 'aborted';
112
116
  if (terminal && !finished.has(run.label)) {
113
117
  finished.add(run.label);
114
- const ms = Math.max(0, (run.endedAt ?? 0) - (run.startedAt ?? 0));
118
+ const ms = runDurationMs(run);
115
119
  const verdict = run.phase === 'done' && !run.error ? 'ok' : run.phase;
116
120
  err.write(`[team] ${run.label} ${verdict} ${formatDuration(ms)} ${run.toolCalls} tools\n`);
117
121
  }
@@ -56,6 +56,25 @@ export interface SystemPromptParams {
56
56
  * produce the pre-team prompt BYTE FOR BYTE.
57
57
  */
58
58
  teamBlock?: string;
59
+ /**
60
+ * The rendered `<forced_team_mode>` block while the session is ARMED, or `''`
61
+ * (team-command-forced-dispatch §3.6 / I-2).
62
+ *
63
+ * A SEPARATE PARAMETER FROM `teamBlock`, not a sentence appended to it: that
64
+ * block says the lead MAY dispatch, this one says it MUST, and merging them
65
+ * would make the obligation inseparable from the permission - so an ordinary
66
+ * team session would start inheriting the forced tone, and switching `/team
67
+ * off` could only be done by rebuilding the team block itself.
68
+ *
69
+ * SPLICED AFTER `teamBlock` (it is the tightening of it) and BEFORE
70
+ * `subagentBlock` (a child must never read it: children have no `task` tool, so
71
+ * the block would order a call that cannot succeed - the same reason
72
+ * `system-prompt.ts:68` gives for keeping the two role blocks apart).
73
+ *
74
+ * Absent or `''` keeps the prompt BYTE-IDENTICAL to the pre-feature output,
75
+ * which is what I-2 asserts for every session that never types `/team`.
76
+ */
77
+ forcedTeamBlock?: string;
59
78
  /**
60
79
  * The rendered `<subagent_role>` block for a CHILD, or `''`.
61
80
  *
@@ -39,6 +39,7 @@ export function buildSystemPrompt(params) {
39
39
  // keeps this block's absence byte-identical to the pre-feature prompt (I-T1).
40
40
  const skillTriageBlock = params.skillTriageBlock ?? '';
41
41
  const teamBlock = params.teamBlock ?? '';
42
+ const forcedTeamBlock = params.forcedTeamBlock ?? '';
42
43
  const subagentBlock = params.subagentBlock ?? '';
43
44
  const todoBlock = params.todoBlock ?? '';
44
45
  const fastBlock = params.fastBlock ?? '';
@@ -66,6 +67,10 @@ export function buildSystemPrompt(params) {
66
67
  ...(skillTriageBlock ? ['', skillTriageBlock] : []),
67
68
  ...(planBlock ? ['', planBlock] : []),
68
69
  ...(teamBlock ? ['', teamBlock] : []),
70
+ // IMMEDIATELY AFTER `<team_mode>`: "can" then "must". BEFORE the subagent
71
+ // block, because a child reading an order to dispatch would be ordered to use
72
+ // a tool it does not have (§3.6).
73
+ ...(forcedTeamBlock ? ['', forcedTeamBlock] : []),
69
74
  ...(subagentBlock ? ['', subagentBlock] : []),
70
75
  ...(todoBlock ? ['', todoBlock] : []),
71
76
  ...(fastBlock ? ['', fastBlock] : []),
@@ -21,13 +21,15 @@ import { applyRestoredSession } from '../session/startup-session.js';
21
21
  import { runSessionCommand } from './session-command.js';
22
22
  import { runBranchCommand } from './branch-command.js';
23
23
  import { getLogger } from '../logging/logger.js';
24
- import { resolveFastTier } from '../fast/resolve.js';
25
- import { subagentModelSummary } from '../ui/overlays/model-profile-state.js';
26
24
  import { registerQueueCommand } from './queue-command.js';
27
25
  import { registerPromptCommands } from '../prompts/command.js';
28
26
  import { registerKitCommand } from './kit-command.js';
29
27
  import { registerAgentsCommand } from './agents-command.js';
30
28
  import { registerSubagentCommand } from './subagent-command.js';
29
+ // `/team` (team-command-forced-dispatch §3.10). The BODY is `commands/team-command.ts`
30
+ // and this pair of lines is the whole registration; the `team` entry that used to
31
+ // live in `COMMANDS` above was a CONFIG command and now lives in `Settings > Team`.
32
+ import { registerTeamCommand } from './team-command.js';
31
33
  // THE DRAWER AND THE HEADER LABEL (tui-shortcut-drawer-and-task-summary §4.7):
32
34
  // two single-purpose commands in their own modules, registered from here. Both are
33
35
  // UNCONDITIONAL, even with `taskSummary.enabled=false`, because they are the only
@@ -108,66 +110,6 @@ function formatUpdateStatus(port, override) {
108
110
  lines.push(` next ${next ? new Date(next).toLocaleTimeString() : 'not scheduled'}`);
109
111
  return lines.join('\n');
110
112
  }
111
- /**
112
- * `/team model [main|fast|<profile-name>]` (subagent-model §3.6 / 5.2).
113
- *
114
- * The user-owned switch's command-line surface. `main` and `fast` are verbs
115
- * compared case-insensitively; a NAME is matched EXACTLY, case and all,
116
- * against the library - the same identity the picker shows - and a miss lists
117
- * the names that would work rather than failing silently. The summary reuses
118
- * the exact wording the settings root row renders, so the two surfaces can
119
- * never describe the same setting differently.
120
- */
121
- function runTeamModelCommand(ctx, subject) {
122
- const controller = ctx.controller;
123
- const config = controller.getConfig();
124
- const tier = resolveFastTier(config, (id, role) => controller.hasApiKey(id, role));
125
- const summary = subagentModelSummary({
126
- mode: config.team.model,
127
- modelProfileId: config.team.modelProfileId,
128
- mainConnection: `${config.provider}:${config.model}`,
129
- fastConnection: tier.ok ? `${tier.ref.providerId}:${tier.ref.modelId}` : null,
130
- profiles: config.modelProfiles,
131
- hasKey: (id) => controller.hasApiKey(id),
132
- });
133
- if (!subject) {
134
- // The status readout: mode + the connection actually in effect + the
135
- // disclosed reason when the request had to fall back (D-4).
136
- const resolution = controller.subagentModelResolution();
137
- const note = resolution.fallbackReason
138
- ? ` Unavailable (${resolution.fallbackReason}); children run on the main model.`
139
- : resolution.effective === 'main'
140
- ? ''
141
- : ` Children run on ${resolution.ref.providerId}:${resolution.ref.modelId}.`;
142
- ctx.notify('info', `Subagent model: ${summary}.${note}`);
143
- return;
144
- }
145
- if (subject.toLowerCase() === 'main' || subject.toLowerCase() === 'fast') {
146
- const mode = subject.toLowerCase();
147
- const applied = controller.setTeamConfig({ model: mode });
148
- ctx.persistConfig({ team: { model: applied.model } });
149
- if (mode === 'fast' && !tier.ok) {
150
- ctx.notify('warn', 'The fast tier is not available now; subagents will fall back to the main model.');
151
- }
152
- ctx.toast('success', `Subagent model: ${mode === 'main' ? 'same as main' : 'fast tier'}.`);
153
- return;
154
- }
155
- const entries = config.modelProfiles?.entries ?? [];
156
- const matches = entries.filter((entry) => entry.name === subject);
157
- if (matches.length === 0) {
158
- const names = entries.slice(0, 8).map((entry) => entry.name);
159
- ctx.notify('warn', `No profile named "${subject}".`
160
- + (names.length ? ` Available: ${names.join(', ')}` : ' Create one in /settings first.'));
161
- return;
162
- }
163
- const entry = matches[0];
164
- const disambiguation = matches.length > 1 ? ` (#${entry.id.slice(0, 8)})` : '';
165
- const applied = controller.setTeamConfig({ model: 'profile', modelProfileId: entry.id });
166
- const teamPatch = { model: applied.model,
167
- modelProfileId: applied.modelProfileId };
168
- ctx.persistConfig({ team: teamPatch });
169
- ctx.toast('success', `Subagent model: ${entry.name}${disambiguation} (${entry.provider}:${entry.model}).`);
170
- }
171
113
  /** Rows `/todo status` prints before it says how many it did not. */
172
114
  const ACCEPT_STATUS_ROWS = 8;
173
115
  /**
@@ -428,126 +370,6 @@ const COMMANDS = [
428
370
  ctx.applyAgentMode(target);
429
371
  },
430
372
  },
431
- {
432
- name: 'team',
433
- description: 'Team subagents: status | on | off | max <n> | overseer on|off | model [main|fast|<profile-name>]',
434
- /**
435
- * `/team on` CANNOT turn team mode on in a session that started without it,
436
- * and this command must not pretend otherwise (§4.5 / D-17 / P0-2).
437
- *
438
- * `controller.ts` builds the tool array ONCE and its own comment forbids
439
- * rebuilding it: `Agent.setTools()` mutates the live `ToolRegistry`, and
440
- * `submit_plan` flips the session mode from INSIDE a tool execution, so a
441
- * rebuild at that moment would mutate the registry mid-iteration. But
442
- * `rebuildSystemPrompt()` IS a live path — so the naive reading of `/team on`
443
- * (flip a flag, re-splice `<team_mode>`) produces a session where the model
444
- * is told it has a `task` tool that was never registered. Every call comes
445
- * back "unknown tool", and the model has no way to discover why.
446
- *
447
- * So the answer splits by what was decided at construction, and the honest
448
- * branch — persist it, say the session is unchanged — is the whole point.
449
- */
450
- run: (ctx) => {
451
- const arg = ctx.args.trim().toLowerCase();
452
- const [verb, value] = arg.split(/\s+/, 2);
453
- const controller = ctx.controller;
454
- // RAW TOKENS, KEPT BESIDE THE LOWERCASED PAIR (V-2): `model` matches a
455
- // profile NAME case-sensitively - like the picker and the library -
456
- // and the whole-args toLowerCase() above would have made
457
- // `/team model MyProfile` silently unmatchable. Only the VERB token is
458
- // compared through the lowercased pair.
459
- const rawTokens = ctx.args.trim().split(/\s+/).filter((token) => token.length > 0);
460
- // Refused mid-dispatch for the reason `/reload` already records: half a
461
- // dispatch under one ceiling and half under another produces a result
462
- // nothing can afterwards explain.
463
- if (verb && verb !== 'status' && controller.isTeamBusy()) {
464
- ctx.notify('warn', 'Cannot change team settings mid-dispatch.');
465
- return;
466
- }
467
- if (!verb || verb === 'status') {
468
- const cfg = controller.getTeamConfig();
469
- const config = controller.getConfig();
470
- const state = !controller.isTeamRegistered()
471
- ? 'off for this session (started with --no-team)'
472
- : controller.isTeamEnabled()
473
- ? 'on'
474
- : 'off';
475
- const snapshot = controller.getTeamSnapshot();
476
- const roster = snapshot
477
- ? `\nRunning: ${snapshot.runs.map((r) => `${r.label} (${r.phase})`).join(', ')}`
478
- : '';
479
- const tier = resolveFastTier(controller.getConfig(), (id, role) => controller.hasApiKey(id, role));
480
- const subagent = subagentModelSummary({
481
- mode: cfg.model,
482
- modelProfileId: cfg.modelProfileId,
483
- mainConnection: `${config.provider}:${config.model}`,
484
- fastConnection: tier.ok ? `${tier.ref.providerId}:${tier.ref.modelId}` : null,
485
- profiles: config.modelProfiles,
486
- hasKey: (id) => controller.hasApiKey(id),
487
- });
488
- ctx.notify('info', `Team mode: ${state}. Max subagents: ${cfg.maxSubagents}, ` +
489
- `concurrent: ${cfg.maxConcurrent}, supervisor: ${cfg.overseer ? 'on' : 'off'}` +
490
- `${cfg.overseer ? ' (never kills; checks the fast tier each tick)' : ' (legacy hard timeouts)'}.` +
491
- `\nSubagent model: ${subagent}` +
492
- `${roster}`);
493
- return;
494
- }
495
- if (verb === 'on' || verb === 'off') {
496
- const enabled = verb === 'on';
497
- // A PARTIAL `team` section, safe only because `updatePersistedConfig`
498
- // deep-merges it — the same contract `/skills disable` relies on.
499
- ctx.persistConfig({ team: { enabled } });
500
- if (!controller.isTeamRegistered()) {
501
- // The bottom-right cell of §4.5's table. It is honest, it is one
502
- // sentence, and it keeps AC-11 intact: a `--no-team` session never
503
- // grows a wrapper or an entry, whatever the user types afterwards.
504
- ctx.notify('info', enabled
505
- ? 'Team mode is off for this session (started with --no-team). Saved for next launch.'
506
- : 'Team mode is already off for this session. Saved for next launch.');
507
- return;
508
- }
509
- controller.setTeamEnabled(enabled);
510
- ctx.toast('success', `Team mode ${enabled ? 'on' : 'off'}.`);
511
- return;
512
- }
513
- if (verb === 'max') {
514
- const n = Number.parseInt(value ?? '', 10);
515
- if (!Number.isFinite(n) || n <= 0) {
516
- ctx.notify('warn', 'Usage: /team max <n>');
517
- return;
518
- }
519
- // Clamped by `setTeamConfig` and again on the persist path, so a value
520
- // above the requirement's ceiling can reach neither the runtime nor the
521
- // file (R-g).
522
- const applied = controller.setTeamConfig({ maxSubagents: n });
523
- ctx.persistConfig({
524
- team: { maxSubagents: applied.maxSubagents },
525
- });
526
- ctx.toast('success', `Max subagents set to ${applied.maxSubagents}.`);
527
- return;
528
- }
529
- if (verb === 'model') {
530
- runTeamModelCommand(ctx, rawTokens[1]);
531
- return;
532
- }
533
- if (verb === 'overseer') {
534
- if (value !== 'on' && value !== 'off') {
535
- ctx.notify('warn', 'Usage: /team overseer on|off');
536
- return;
537
- }
538
- // Read LIVE per dispatch by the runtime, so no rebuild is needed - the
539
- // same reason this command does not touch the tool array (its own header
540
- // comment). Clamped by `setTeamConfig` and again on the persist path.
541
- const applied = controller.setTeamConfig({ overseer: value === 'on' });
542
- ctx.persistConfig({
543
- team: { overseer: applied.overseer },
544
- });
545
- ctx.toast('success', `Dispatch supervisor ${applied.overseer ? 'on' : 'off'}.`);
546
- return;
547
- }
548
- ctx.notify('warn', `Unknown argument "${verb}" - use /team [status|on|off|max <n>|overseer on|off|model main|fast|<profile-name>].`);
549
- },
550
- },
551
373
  {
552
374
  name: 'fast',
553
375
  description: 'Fast model tier: status | on | off | model <id> | provider <id> | same | ' +
@@ -1515,7 +1337,15 @@ export function registerBuiltinCommands(registry) {
1515
1337
  // and for the same reason: a `--no-team` session must still be able to ask the
1516
1338
  // command why it cannot force anything (I-7 / AC-15) - so registering it may
1517
1339
  // not read config, build a connection or send a request.
1340
+ // It is now a DEPRECATED ALIAS of `/team` (team-command-forced-dispatch D-12):
1341
+ // same implementation, one extra toast, no second grammar.
1518
1342
  registerSubagentCommand(registry);
1343
+ // `/team` (team-command-forced-dispatch §3.10). Registered HERE rather than from
1344
+ // `COMMANDS` because the entry that used to sit in that array was the config
1345
+ // command (`status | on | off | max n | overseer | model`) - those four verbs
1346
+ // now live in the settings screen, and this command is the ACT: arm forced mode
1347
+ // and start work. Unconditional, for the reason `/subagent` above records.
1348
+ registerTeamCommand(registry);
1519
1349
  // The keyboard drawer (`Ctrl+G` / `/keys`) and the task label (`/task`). Two
1520
1350
  // registrations, no new mechanism: `/keys` sets the panel's pre-filter and opens
1521
1351
  // the same overlay `Ctrl+G` opens, and `/task` reports what the header cell says.
@@ -1531,6 +1361,10 @@ export function registerBuiltinCommands(registry) {
1531
1361
  * correct when commands are registered dynamically (§7.1 / P1-2).
1532
1362
  */
1533
1363
  export const BUILTIN_COMMAND_NAMES = ['queue', 'prompts', 'agents', 'subagent',
1364
+ // `team` is listed here because it is no longer in `COMMANDS` (it moved to
1365
+ // `commands/team-command.ts`), and this array is the documentation/tests surface
1366
+ // for every built-in name - the `flatMap` below only sees `COMMANDS`.
1367
+ 'team',
1534
1368
  'keys', 'task',
1535
1369
  ...COMMANDS.flatMap((c) => [
1536
1370
  c.name,