@aragon-agent/cli 0.6.39 → 0.6.42

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 +109 -0
  2. package/README.md +126 -20
  3. package/dist/agent/controller.d.ts +139 -14
  4. package/dist/agent/controller.js +299 -40
  5. package/dist/agent/model-profile-settings.js +12 -1
  6. package/dist/agent/plan-prompt.d.ts +1 -3
  7. package/dist/agent/plan-prompt.js +13 -7
  8. package/dist/agent/reducer.d.ts +29 -1
  9. package/dist/agent/reducer.js +34 -6
  10. package/dist/agent/skill-triage-prompt.d.ts +42 -0
  11. package/dist/agent/skill-triage-prompt.js +74 -0
  12. package/dist/agent/system-prompt.d.ts +33 -2
  13. package/dist/agent/system-prompt.js +12 -4
  14. package/dist/cli.js +21 -5
  15. package/dist/commands/builtins.js +28 -185
  16. package/dist/commands/skills.js +86 -1
  17. package/dist/commands/subagent-command.d.ts +30 -0
  18. package/dist/commands/subagent-command.js +39 -0
  19. package/dist/commands/team-command.d.ts +49 -0
  20. package/dist/commands/team-command.js +259 -0
  21. package/dist/compaction/command.js +16 -3
  22. package/dist/compaction/compactor.d.ts +25 -0
  23. package/dist/compaction/compactor.js +111 -10
  24. package/dist/compaction/digest.d.ts +24 -1
  25. package/dist/compaction/digest.js +139 -17
  26. package/dist/compaction/limits.d.ts +21 -0
  27. package/dist/compaction/limits.js +21 -0
  28. package/dist/compaction/reason-text.d.ts +36 -0
  29. package/dist/compaction/reason-text.js +78 -0
  30. package/dist/compaction/types.d.ts +82 -14
  31. package/dist/compaction/wiring.d.ts +13 -8
  32. package/dist/compaction/wiring.js +1 -0
  33. package/dist/config/load.js +50 -3
  34. package/dist/config/model-kits.d.ts +2 -1
  35. package/dist/config/schema.d.ts +82 -6
  36. package/dist/config/schema.js +107 -9
  37. package/dist/config/store.js +10 -4
  38. package/dist/proc/log-mirror.d.ts +21 -4
  39. package/dist/proc/log-mirror.js +29 -7
  40. package/dist/skills/service.d.ts +41 -0
  41. package/dist/skills/service.js +62 -0
  42. package/dist/skills/triage.d.ts +194 -0
  43. package/dist/skills/triage.js +393 -0
  44. package/dist/team/dispatch-gate.d.ts +86 -0
  45. package/dist/team/dispatch-gate.js +75 -0
  46. package/dist/team/forced-dispatch.d.ts +95 -0
  47. package/dist/team/forced-dispatch.js +208 -0
  48. package/dist/team/forced-mode.d.ts +116 -0
  49. package/dist/team/forced-mode.js +156 -0
  50. package/dist/team/forced-prompt.d.ts +48 -0
  51. package/dist/team/forced-prompt.js +84 -0
  52. package/dist/team/limits.d.ts +61 -1
  53. package/dist/team/limits.js +69 -1
  54. package/dist/team/model.d.ts +5 -5
  55. package/dist/team/model.js +5 -5
  56. package/dist/team/normalize.d.ts +15 -0
  57. package/dist/team/normalize.js +21 -4
  58. package/dist/team/prompt.d.ts +6 -3
  59. package/dist/team/prompt.js +9 -5
  60. package/dist/team/task-tool.js +11 -7
  61. package/dist/tools/human-input.d.ts +35 -2
  62. package/dist/tools/human-input.js +38 -0
  63. package/dist/tools/plan-tools.d.ts +20 -12
  64. package/dist/tools/plan-tools.js +24 -17
  65. package/dist/ui/App.js +130 -11
  66. package/dist/ui/StatusBar.d.ts +15 -0
  67. package/dist/ui/StatusBar.js +2 -0
  68. package/dist/ui/Transcript.js +1 -1
  69. package/dist/ui/entries/CompactionCard.d.ts +11 -1
  70. package/dist/ui/entries/CompactionCard.js +65 -4
  71. package/dist/ui/layout/status-layout.d.ts +27 -0
  72. package/dist/ui/layout/status-layout.js +27 -1
  73. package/dist/ui/overlays/HelpOverlay.js +8 -4
  74. package/dist/ui/overlays/SettingsScreen.d.ts +14 -0
  75. package/dist/ui/overlays/SettingsScreen.js +39 -5
  76. package/dist/ui/overlays/TeamFanoutPage.d.ts +51 -0
  77. package/dist/ui/overlays/TeamFanoutPage.js +90 -0
  78. package/dist/ui/overlays/kit-settings-state.js +3 -2
  79. package/dist/ui/overlays/model-profile-state.d.ts +20 -2
  80. package/dist/ui/overlays/model-profile-state.js +12 -0
  81. package/dist/ui/overlays/team-fanout-state.d.ts +106 -0
  82. package/dist/ui/overlays/team-fanout-state.js +155 -0
  83. package/package.json +81 -81
@@ -1134,12 +1134,11 @@ export function viewReducer(state, action) {
1134
1134
  case 'fastTier':
1135
1135
  return { ...state, fast: action.snapshot };
1136
1136
  // --- Context compaction (context-auto-compaction §5.5) ----------------
1137
- // NOT A TOTAL FUNCTION OVER COMPACTION ATTEMPTS (quiet-noop §3.1). A
1138
- // `pressure` compaction that declines before calling a model or changing the
1139
- // history dispatches NOTHING - no `compactionStart`, no `compactionEnd`, no
1140
- // entry - exactly as a checkpoint below the threshold dispatches nothing.
1141
- // Counting cards here counts compactions that RAN, never checkpoints that
1142
- // fired; `/compact status` and the diagnostic log are where the attempts are.
1137
+ // THE TRANSCRIPT NOW CARRIES ONE CARD PER ATTEMPT (context-compaction-never-
1138
+ // fires C). Before this change a declined `pressure` checkpoint dispatched
1139
+ // NOTHING, which is what made "95 % and nothing happens" unreportable: the
1140
+ // feature was trying, failing and saying nothing. The flood the old rule
1141
+ // feared is bounded by `maxPerRun` / `stuckLimit` in `compaction/limits.ts`.
1143
1142
  case 'compactionStart': {
1144
1143
  const { id, seq } = nextId(state);
1145
1144
  let entries = state.entries;
@@ -1189,6 +1188,7 @@ export function viewReducer(state, action) {
1189
1188
  tokensAfter: 0,
1190
1189
  model: action.model,
1191
1190
  startedAt: Date.now(),
1191
+ phase: 'planning',
1192
1192
  live: true,
1193
1193
  };
1194
1194
  return {
@@ -1217,6 +1217,16 @@ export function viewReducer(state, action) {
1217
1217
  ...(record.tailRelief ? { tailRelief: record.tailRelief } : {}),
1218
1218
  ...(record.decision ? { decision: record.decision } : {}),
1219
1219
  ...(record.memoryVersion ? { memoryVersion: record.memoryVersion } : {}),
1220
+ ...(record.keepRelaxed ? { keepRelaxed: record.keepRelaxed } : {}),
1221
+ ...(record.retainedTurns !== undefined
1222
+ ? { retainedTurns: record.retainedTurns } : {}),
1223
+ ...(record.retainedMessages !== undefined
1224
+ ? { retainedMessages: record.retainedMessages } : {}),
1225
+ ...(record.retainedMessages !== undefined
1226
+ ? { retainedMessages: record.retainedMessages } : {}),
1227
+ ...(record.keepRecentTurnsUsed !== undefined
1228
+ ? { keepRecentTurnsUsed: record.keepRecentTurnsUsed } : {}),
1229
+ ...(record.elidedMessages ? { elidedMessages: record.elidedMessages } : {}),
1220
1230
  model: record.model,
1221
1231
  durationMs: record.durationMs,
1222
1232
  // SETTLED. `Transcript`'s boundary is monotonic, so a card that
@@ -1253,12 +1263,30 @@ export function viewReducer(state, action) {
1253
1263
  ...(record.tailRelief ? { tailRelief: record.tailRelief } : {}),
1254
1264
  ...(record.decision ? { decision: record.decision } : {}),
1255
1265
  ...(record.memoryVersion ? { memoryVersion: record.memoryVersion } : {}),
1266
+ ...(record.keepRelaxed ? { keepRelaxed: record.keepRelaxed } : {}),
1267
+ ...(record.retainedTurns !== undefined ? { retainedTurns: record.retainedTurns } : {}),
1268
+ ...(record.keepRecentTurnsUsed !== undefined
1269
+ ? { keepRecentTurnsUsed: record.keepRecentTurnsUsed } : {}),
1270
+ ...(record.elidedMessages ? { elidedMessages: record.elidedMessages } : {}),
1256
1271
  model: record.model,
1257
1272
  durationMs: record.durationMs,
1258
1273
  live: false,
1259
1274
  };
1260
1275
  return { ...state, seq, ...appendEntry(state, state.entries, entry) };
1261
1276
  }
1277
+ case 'compactionPhase': {
1278
+ // THE LIVE CARD'S STAGE, AND NOTHING ELSE. It must not open a card (a phase
1279
+ // can arrive for an entry that a fast settle already closed) and must not
1280
+ // resurrect a settled one: `live` is left untouched, so a late phase on a
1281
+ // settled card changes nothing the renderer shows.
1282
+ if (!state.compactionEntryId)
1283
+ return state;
1284
+ const phase = action.phase;
1285
+ return {
1286
+ ...state,
1287
+ entries: mapEntry(state.entries, state.compactionEntryId, (e) => e.kind === 'compaction' && e.live ? { ...e, phase } : e),
1288
+ };
1289
+ }
1262
1290
  case 'compactionUsage':
1263
1291
  // `usageTotal` AND NOTHING ELSE, exactly as `teamUsage` and `fastUsage`. In
1264
1292
  // particular NOT `context`: that gauge shows the LEAD's context
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The `<skill_triage>` system-prompt block (skill-triage-and-unbounded-ask §4.2.3).
3
+ *
4
+ * WHY A PROTOCOL BLOCK AT ALL. The capability this feature fixes already existed:
5
+ * the Level 1 catalog is in every prompt, `skill(name)` loads a body, and
6
+ * `README.md` has claimed since the Skills feature landed that "the model decides
7
+ * on its own whether a task matches a skill". What was missing is any instruction
8
+ * that the decision has to HAPPEN, before the work rather than after it. The
9
+ * catalog's own opening paragraph is a STATEMENT ("if a task matches one, call the
10
+ * skill tool"), not a step in an order of operations, and a statement the model
11
+ * has read forty times is the one it skims.
12
+ *
13
+ * WHY IT IS THIS SHORT. The protocol is paid for on EVERY LLM request: the system
14
+ * prompt is re-sent each time and nothing in this repository uses provider-side
15
+ * prefix caching. An earlier draft carried a 1.9 KB version whose second half
16
+ * repeated the disclosure ladder that `<available_skills>` already states three
17
+ * lines above it. Repeating a rule is not emphasis, it is noise — and noise about
18
+ * a rule teaches the model that these blocks are skimmable. Measured size of the
19
+ * block below is 1273 bytes; `skill-triage-prompt.test.ts` asserts the ceiling of
20
+ * 1500 so an edit has 227 bytes of room, not unlimited scope.
21
+ *
22
+ * ASCII ONLY — `agent/` is inside the glyph scanner's scope.
23
+ */
24
+ import type { SkillCandidate } from '../skills/triage.js';
25
+ /** Bump when the wording changes, so a transcript can be dated. */
26
+ export declare const SKILL_TRIAGE_BLOCK_VERSION = "v1-2026-10-26";
27
+ export interface SkillTriageBlockOptions {
28
+ /** This turn's candidates, best first. Empty is normal and renders no data block. */
29
+ candidates: SkillCandidate[];
30
+ }
31
+ /**
32
+ * Protocol first, candidates second — in that order, always.
33
+ *
34
+ * The order is the meaning: the protocol says what to do, the data block says with
35
+ * what. Reversing them would put a list of suggestions above the rules for reading
36
+ * it, which is how a suggestion turns into an instruction.
37
+ */
38
+ export declare function buildSkillTriageBlock(options: SkillTriageBlockOptions): string;
39
+ /** Byte size of the protocol half, for the budget assertion in the tests. */
40
+ export declare function skillTriageProtocolBytes(): number;
41
+ /** The ceiling the protocol half must stay under, in bytes. */
42
+ export declare const SKILL_TRIAGE_PROTOCOL_MAX_BYTES: number;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The `<skill_triage>` system-prompt block (skill-triage-and-unbounded-ask §4.2.3).
3
+ *
4
+ * WHY A PROTOCOL BLOCK AT ALL. The capability this feature fixes already existed:
5
+ * the Level 1 catalog is in every prompt, `skill(name)` loads a body, and
6
+ * `README.md` has claimed since the Skills feature landed that "the model decides
7
+ * on its own whether a task matches a skill". What was missing is any instruction
8
+ * that the decision has to HAPPEN, before the work rather than after it. The
9
+ * catalog's own opening paragraph is a STATEMENT ("if a task matches one, call the
10
+ * skill tool"), not a step in an order of operations, and a statement the model
11
+ * has read forty times is the one it skims.
12
+ *
13
+ * WHY IT IS THIS SHORT. The protocol is paid for on EVERY LLM request: the system
14
+ * prompt is re-sent each time and nothing in this repository uses provider-side
15
+ * prefix caching. An earlier draft carried a 1.9 KB version whose second half
16
+ * repeated the disclosure ladder that `<available_skills>` already states three
17
+ * lines above it. Repeating a rule is not emphasis, it is noise — and noise about
18
+ * a rule teaches the model that these blocks are skimmable. Measured size of the
19
+ * block below is 1273 bytes; `skill-triage-prompt.test.ts` asserts the ceiling of
20
+ * 1500 so an edit has 227 bytes of room, not unlimited scope.
21
+ *
22
+ * ASCII ONLY — `agent/` is inside the glyph scanner's scope.
23
+ */
24
+ import { renderSkillCandidates, SKILL_TRIAGE_LIMITS } from '../skills/triage.js';
25
+ /** Bump when the wording changes, so a transcript can be dated. */
26
+ export const SKILL_TRIAGE_BLOCK_VERSION = 'v1-2026-10-26';
27
+ /**
28
+ * The protocol text. Kept as a literal array so a diff of the prompt is a diff of
29
+ * the source, and so the grader/test can see line boundaries.
30
+ */
31
+ const PROTOCOL_LINES = [
32
+ '<skill_triage>',
33
+ `Version: ${SKILL_TRIAGE_BLOCK_VERSION}`,
34
+ '',
35
+ 'Triage every task that touches the workspace: AFTER the project guidance and the',
36
+ 'directory listing, BEFORE you plan or change anything. A user-typed /<skill-name>',
37
+ 'is an override, not the only way in.',
38
+ '',
39
+ '1. Read <available_skills> above, and <skill_candidates> when present. A candidate',
40
+ ' is a computed guess about your message, not a verdict - check its one-line',
41
+ ' description before trusting it.',
42
+ '2. If the catalog says some skills were not shown, or nothing listed plainly',
43
+ ' covers the task, call skill_find(query) once before concluding there is no',
44
+ ' skill.',
45
+ '3. If a skill covers the task, call skill(name="...") BEFORE the first source read',
46
+ ' that would change code, then follow it. Say why in one line, not a paragraph.',
47
+ '4. If nothing covers the task, say so in one clause and continue. Do not load a',
48
+ ' skill to look diligent, and never load several "to be safe".',
49
+ '',
50
+ 'A loaded skill is reference material, not permission to ignore the user, the',
51
+ 'plan-mode rules, or the tools you actually have. If a skill narrows your tool set,',
52
+ 'that is its allowed-tools declaration working: report it, do not work around it.',
53
+ 'Never install or author a skill because a task looks like it needs one; that',
54
+ 'decision belongs to the user.',
55
+ '</skill_triage>',
56
+ ];
57
+ const PROTOCOL = PROTOCOL_LINES.join('\n');
58
+ /**
59
+ * Protocol first, candidates second — in that order, always.
60
+ *
61
+ * The order is the meaning: the protocol says what to do, the data block says with
62
+ * what. Reversing them would put a list of suggestions above the rules for reading
63
+ * it, which is how a suggestion turns into an instruction.
64
+ */
65
+ export function buildSkillTriageBlock(options) {
66
+ const candidates = renderSkillCandidates(options.candidates);
67
+ return candidates.length > 0 ? `${PROTOCOL}\n${candidates}` : PROTOCOL;
68
+ }
69
+ /** Byte size of the protocol half, for the budget assertion in the tests. */
70
+ export function skillTriageProtocolBytes() {
71
+ return new TextEncoder().encode(PROTOCOL).length;
72
+ }
73
+ /** The ceiling the protocol half must stay under, in bytes. */
74
+ export const SKILL_TRIAGE_PROTOCOL_MAX_BYTES = SKILL_TRIAGE_LIMITS.maxProtocolBytes;
@@ -34,8 +34,20 @@ export interface SystemPromptParams {
34
34
  * plan as its final message instead of calling tools it does not have.
35
35
  */
36
36
  planInteractive?: boolean;
37
- /** `planModeMaxAskRounds`, substituted into the block so the two agree. */
38
- planMaxAskRounds?: number;
37
+ /**
38
+ * The rendered `<skill_triage>` block plus (when there are candidates) the
39
+ * `<skill_candidates>` block, or `''`.
40
+ *
41
+ * Spliced conditionally exactly as `skillsBlock` and the plan block are, which
42
+ * is what keeps invariant I-T1 true: `skills.triage: false`, a zero-skill
43
+ * install and `--no-skills` all produce the pre-feature prompt BYTE FOR BYTE.
44
+ *
45
+ * POSITION IS THE SEMANTICS. It goes immediately AFTER `skillsBlock`, because
46
+ * its first instruction refers to `<available_skills> above`, and a block
47
+ * separated from what it points at reads as a dangling reference. It also goes
48
+ * BEFORE the plan block, whose first step defers to it.
49
+ */
50
+ skillTriageBlock?: string;
39
51
  /**
40
52
  * The rendered `<team_mode>` block for a LEAD with team mode on, or `''`.
41
53
  *
@@ -44,6 +56,25 @@ export interface SystemPromptParams {
44
56
  * produce the pre-team prompt BYTE FOR BYTE.
45
57
  */
46
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;
47
78
  /**
48
79
  * The rendered `<subagent_role>` block for a CHILD, or `''`.
49
80
  *
@@ -33,12 +33,13 @@ export function buildSystemPrompt(params) {
33
33
  const skillsBlock = params.skillsBlock ?? '';
34
34
  const hasSkills = skillsBlock.length > 0;
35
35
  const planBlock = params.agentMode === 'plan'
36
- ? buildPlanModeBlock({
37
- interactive: params.planInteractive !== false,
38
- maxAskRounds: params.planMaxAskRounds ?? 4,
39
- })
36
+ ? buildPlanModeBlock({ interactive: params.planInteractive !== false })
40
37
  : '';
38
+ // `''` when the caller has no candidates or the feature is off — which is what
39
+ // keeps this block's absence byte-identical to the pre-feature prompt (I-T1).
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 ?? '';
@@ -61,8 +62,15 @@ export function buildSystemPrompt(params) {
61
62
  '',
62
63
  buildProjectGuidanceBlock(),
63
64
  ...(hasSkills ? ['', skillsBlock] : []),
65
+ // AFTER <available_skills> (it refers to it as "above") and BEFORE <plan_mode>
66
+ // (whose step 1 defers to it). Conditional like every other optional block.
67
+ ...(skillTriageBlock ? ['', skillTriageBlock] : []),
64
68
  ...(planBlock ? ['', planBlock] : []),
65
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] : []),
66
74
  ...(subagentBlock ? ['', subagentBlock] : []),
67
75
  ...(todoBlock ? ['', todoBlock] : []),
68
76
  ...(fastBlock ? ['', fastBlock] : []),
package/dist/cli.js CHANGED
@@ -29,7 +29,7 @@ import { COMPACTION_CONFIG_SET_KEYS, FAST_CONFIG_SET_KEYS, LOG_CONFIG_SET_KEYS,
29
29
  import { attachAgentEvents, attachTeamEvents, installLogging, setScreenRestore, setSignalTerminator, } from './logging/install.js';
30
30
  import { getLogger } from './logging/logger.js';
31
31
  import { runLogsCommand } from './logging/cli-commands.js';
32
- import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampContextWindow, clampMaxRenderInterval, clampScrollMode, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, isAutoToken, parseMaxTokensInput, DEFAULT_MAX_RENDER_INTERVAL_MS, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SCROLL_MODE, DEFAULT_SCROLL_RESUME_MS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
32
+ import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampContextWindow, clampMaxRenderInterval, clampScrollMode, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, isAutoToken, parseMaxTokensInput, RETIRED_CONFIG_KEYS, DEFAULT_MAX_RENDER_INTERVAL_MS, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_SCROLL_MODE, DEFAULT_SCROLL_RESUME_MS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
33
33
  import { makeGetApiKey } from './config/load.js';
34
34
  import { AgentController, DENY_ALL_APPROVAL } from './agent/controller.js';
35
35
  import { runSkillsCommand } from './skills/cli-commands.js';
@@ -1559,7 +1559,6 @@ const CONFIG_SET_KEYS = new Set([
1559
1559
  'scrollResumeMs',
1560
1560
  'historyEnabled',
1561
1561
  'planModeDefault',
1562
- 'planModeMaxAskRounds',
1563
1562
  'planModeHumanTimeoutMs',
1564
1563
  // Dotted keys route into the nested `skills` object; see the switch below.
1565
1564
  'skills.enabled',
@@ -1569,6 +1568,7 @@ const CONFIG_SET_KEYS = new Set([
1569
1568
  'skills.integrity',
1570
1569
  'skills.usageTracking',
1571
1570
  'skills.toolPolicy',
1571
+ 'skills.triage',
1572
1572
  // `log.*` keys are handled by `applyLogConfigSet` below, NOT by the switch:
1573
1573
  // that switch is already past this package's complexity ceiling.
1574
1574
  ...LOG_CONFIG_SET_KEYS,
@@ -1660,6 +1660,18 @@ function runConfigSet(key, value) {
1660
1660
  }
1661
1661
  }
1662
1662
  function runConfigSetValue(key, value) {
1663
+ // RETIRED KEYS ARE REFUSED BY NAME, BEFORE THE UNKNOWN-KEY CHECK. Falling
1664
+ // through to `Unknown config key` would be technically true and practically
1665
+ // useless: a script that has run `config set planModeMaxAskRounds 5` for months
1666
+ // needs to be told what happened to it, not that it never existed. Exit 2 for
1667
+ // the same reason a bad value is 2 — the caller's argument is wrong, the file
1668
+ // is not.
1669
+ const retired = RETIRED_CONFIG_KEYS[key];
1670
+ if (retired !== undefined) {
1671
+ process.stderr.write(`"${key}" was removed: ${retired}\n`);
1672
+ process.exitCode = 2;
1673
+ return;
1674
+ }
1663
1675
  if (!CONFIG_SET_KEYS.has(key)) {
1664
1676
  process.stderr.write(`Unknown config key "${key}". Known keys: ${[...CONFIG_SET_KEYS].join(', ')}.\n`);
1665
1677
  process.exitCode = 2;
@@ -1852,9 +1864,6 @@ function runConfigSetValue(key, value) {
1852
1864
  case 'planModeDefault':
1853
1865
  patch.planModeDefault = value === 'true' || value === '1';
1854
1866
  break;
1855
- case 'planModeMaxAskRounds':
1856
- patch.planModeMaxAskRounds = clampAskRounds(value, DEFAULT_PLAN_MAX_ASK_ROUNDS);
1857
- break;
1858
1867
  case 'planModeHumanTimeoutMs':
1859
1868
  patch.planModeHumanTimeoutMs = clampHumanTimeout(value, DEFAULT_PLAN_HUMAN_TIMEOUT_MS);
1860
1869
  break;
@@ -1893,6 +1902,13 @@ function runConfigSetValue(key, value) {
1893
1902
  toolPolicy: clampSkillsToolPolicy(value, DEFAULT_SKILLS_CONFIG.toolPolicy),
1894
1903
  };
1895
1904
  break;
1905
+ // The triage master switch. `false` restores the pre-feature prompt BYTE FOR
1906
+ // BYTE (invariant I-T1), which is the reason it is a key rather than a flag.
1907
+ case 'skills.triage':
1908
+ patch.skills = {
1909
+ triage: value === 'true' || value === '1',
1910
+ };
1911
+ break;
1896
1912
  }
1897
1913
  updatePersistedConfig(patch);
1898
1914
  process.stdout.write(`Set ${key} = ${value}\n`);
@@ -21,12 +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';
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';
30
33
  // THE DRAWER AND THE HEADER LABEL (tui-shortcut-drawer-and-task-summary §4.7):
31
34
  // two single-purpose commands in their own modules, registered from here. Both are
32
35
  // UNCONDITIONAL, even with `taskSummary.enabled=false`, because they are the only
@@ -107,66 +110,6 @@ function formatUpdateStatus(port, override) {
107
110
  lines.push(` next ${next ? new Date(next).toLocaleTimeString() : 'not scheduled'}`);
108
111
  return lines.join('\n');
109
112
  }
110
- /**
111
- * `/team model [main|fast|<profile-name>]` (subagent-model §3.6 / 5.2).
112
- *
113
- * The user-owned switch's command-line surface. `main` and `fast` are verbs
114
- * compared case-insensitively; a NAME is matched EXACTLY, case and all,
115
- * against the library - the same identity the picker shows - and a miss lists
116
- * the names that would work rather than failing silently. The summary reuses
117
- * the exact wording the settings root row renders, so the two surfaces can
118
- * never describe the same setting differently.
119
- */
120
- function runTeamModelCommand(ctx, subject) {
121
- const controller = ctx.controller;
122
- const config = controller.getConfig();
123
- const tier = resolveFastTier(config, (id, role) => controller.hasApiKey(id, role));
124
- const summary = subagentModelSummary({
125
- mode: config.team.model,
126
- modelProfileId: config.team.modelProfileId,
127
- mainConnection: `${config.provider}:${config.model}`,
128
- fastConnection: tier.ok ? `${tier.ref.providerId}:${tier.ref.modelId}` : null,
129
- profiles: config.modelProfiles,
130
- hasKey: (id) => controller.hasApiKey(id),
131
- });
132
- if (!subject) {
133
- // The status readout: mode + the connection actually in effect + the
134
- // disclosed reason when the request had to fall back (D-4).
135
- const resolution = controller.subagentModelResolution();
136
- const note = resolution.fallbackReason
137
- ? ` Unavailable (${resolution.fallbackReason}); children run on the main model.`
138
- : resolution.effective === 'main'
139
- ? ''
140
- : ` Children run on ${resolution.ref.providerId}:${resolution.ref.modelId}.`;
141
- ctx.notify('info', `Subagent model: ${summary}.${note}`);
142
- return;
143
- }
144
- if (subject.toLowerCase() === 'main' || subject.toLowerCase() === 'fast') {
145
- const mode = subject.toLowerCase();
146
- const applied = controller.setTeamConfig({ model: mode });
147
- ctx.persistConfig({ team: { model: applied.model } });
148
- if (mode === 'fast' && !tier.ok) {
149
- ctx.notify('warn', 'The fast tier is not available now; subagents will fall back to the main model.');
150
- }
151
- ctx.toast('success', `Subagent model: ${mode === 'main' ? 'same as main' : 'fast tier'}.`);
152
- return;
153
- }
154
- const entries = config.modelProfiles?.entries ?? [];
155
- const matches = entries.filter((entry) => entry.name === subject);
156
- if (matches.length === 0) {
157
- const names = entries.slice(0, 8).map((entry) => entry.name);
158
- ctx.notify('warn', `No profile named "${subject}".`
159
- + (names.length ? ` Available: ${names.join(', ')}` : ' Create one in /settings first.'));
160
- return;
161
- }
162
- const entry = matches[0];
163
- const disambiguation = matches.length > 1 ? ` (#${entry.id.slice(0, 8)})` : '';
164
- const applied = controller.setTeamConfig({ model: 'profile', modelProfileId: entry.id });
165
- const teamPatch = { model: applied.model,
166
- modelProfileId: applied.modelProfileId };
167
- ctx.persistConfig({ team: teamPatch });
168
- ctx.toast('success', `Subagent model: ${entry.name}${disambiguation} (${entry.provider}:${entry.model}).`);
169
- }
170
113
  /** Rows `/todo status` prints before it says how many it did not. */
171
114
  const ACCEPT_STATUS_ROWS = 8;
172
115
  /**
@@ -412,8 +355,10 @@ const COMMANDS = [
412
355
  if (arg === 'status') {
413
356
  const s = ctx.controller.getPlanStatus();
414
357
  const pending = s.pending ? ` (pending ${MODE_LABEL[s.pending]} after this run)` : '';
415
- ctx.notify('info', `Mode: ${MODE_LABEL[s.effective]}${pending}. ` +
416
- `Ask rounds used: ${s.askRoundsUsed}/${s.maxAskRounds}.`);
358
+ // NO ROUND COUNT. The old line ended with `Ask rounds used: 3/4.`, which
359
+ // told the user their agent was rationing questions - a limit that no
360
+ // longer exists. Nothing replaced it: "0 of unlimited" is noise.
361
+ ctx.notify('info', `Mode: ${MODE_LABEL[s.effective]}${pending}.`);
417
362
  return;
418
363
  }
419
364
  if (arg.length > 0 && arg !== 'on' && arg !== 'off') {
@@ -425,126 +370,6 @@ const COMMANDS = [
425
370
  ctx.applyAgentMode(target);
426
371
  },
427
372
  },
428
- {
429
- name: 'team',
430
- description: 'Team subagents: status | on | off | max <n> | overseer on|off | model [main|fast|<profile-name>]',
431
- /**
432
- * `/team on` CANNOT turn team mode on in a session that started without it,
433
- * and this command must not pretend otherwise (§4.5 / D-17 / P0-2).
434
- *
435
- * `controller.ts` builds the tool array ONCE and its own comment forbids
436
- * rebuilding it: `Agent.setTools()` mutates the live `ToolRegistry`, and
437
- * `submit_plan` flips the session mode from INSIDE a tool execution, so a
438
- * rebuild at that moment would mutate the registry mid-iteration. But
439
- * `rebuildSystemPrompt()` IS a live path — so the naive reading of `/team on`
440
- * (flip a flag, re-splice `<team_mode>`) produces a session where the model
441
- * is told it has a `task` tool that was never registered. Every call comes
442
- * back "unknown tool", and the model has no way to discover why.
443
- *
444
- * So the answer splits by what was decided at construction, and the honest
445
- * branch — persist it, say the session is unchanged — is the whole point.
446
- */
447
- run: (ctx) => {
448
- const arg = ctx.args.trim().toLowerCase();
449
- const [verb, value] = arg.split(/\s+/, 2);
450
- const controller = ctx.controller;
451
- // RAW TOKENS, KEPT BESIDE THE LOWERCASED PAIR (V-2): `model` matches a
452
- // profile NAME case-sensitively - like the picker and the library -
453
- // and the whole-args toLowerCase() above would have made
454
- // `/team model MyProfile` silently unmatchable. Only the VERB token is
455
- // compared through the lowercased pair.
456
- const rawTokens = ctx.args.trim().split(/\s+/).filter((token) => token.length > 0);
457
- // Refused mid-dispatch for the reason `/reload` already records: half a
458
- // dispatch under one ceiling and half under another produces a result
459
- // nothing can afterwards explain.
460
- if (verb && verb !== 'status' && controller.isTeamBusy()) {
461
- ctx.notify('warn', 'Cannot change team settings mid-dispatch.');
462
- return;
463
- }
464
- if (!verb || verb === 'status') {
465
- const cfg = controller.getTeamConfig();
466
- const config = controller.getConfig();
467
- const state = !controller.isTeamRegistered()
468
- ? 'off for this session (started with --no-team)'
469
- : controller.isTeamEnabled()
470
- ? 'on'
471
- : 'off';
472
- const snapshot = controller.getTeamSnapshot();
473
- const roster = snapshot
474
- ? `\nRunning: ${snapshot.runs.map((r) => `${r.label} (${r.phase})`).join(', ')}`
475
- : '';
476
- const tier = resolveFastTier(controller.getConfig(), (id, role) => controller.hasApiKey(id, role));
477
- const subagent = subagentModelSummary({
478
- mode: cfg.model,
479
- modelProfileId: cfg.modelProfileId,
480
- mainConnection: `${config.provider}:${config.model}`,
481
- fastConnection: tier.ok ? `${tier.ref.providerId}:${tier.ref.modelId}` : null,
482
- profiles: config.modelProfiles,
483
- hasKey: (id) => controller.hasApiKey(id),
484
- });
485
- ctx.notify('info', `Team mode: ${state}. Max subagents: ${cfg.maxSubagents}, ` +
486
- `concurrent: ${cfg.maxConcurrent}, supervisor: ${cfg.overseer ? 'on' : 'off'}` +
487
- `${cfg.overseer ? ' (never kills; checks the fast tier each tick)' : ' (legacy hard timeouts)'}.` +
488
- `\nSubagent model: ${subagent}` +
489
- `${roster}`);
490
- return;
491
- }
492
- if (verb === 'on' || verb === 'off') {
493
- const enabled = verb === 'on';
494
- // A PARTIAL `team` section, safe only because `updatePersistedConfig`
495
- // deep-merges it — the same contract `/skills disable` relies on.
496
- ctx.persistConfig({ team: { enabled } });
497
- if (!controller.isTeamRegistered()) {
498
- // The bottom-right cell of §4.5's table. It is honest, it is one
499
- // sentence, and it keeps AC-11 intact: a `--no-team` session never
500
- // grows a wrapper or an entry, whatever the user types afterwards.
501
- ctx.notify('info', enabled
502
- ? 'Team mode is off for this session (started with --no-team). Saved for next launch.'
503
- : 'Team mode is already off for this session. Saved for next launch.');
504
- return;
505
- }
506
- controller.setTeamEnabled(enabled);
507
- ctx.toast('success', `Team mode ${enabled ? 'on' : 'off'}.`);
508
- return;
509
- }
510
- if (verb === 'max') {
511
- const n = Number.parseInt(value ?? '', 10);
512
- if (!Number.isFinite(n) || n <= 0) {
513
- ctx.notify('warn', 'Usage: /team max <n>');
514
- return;
515
- }
516
- // Clamped by `setTeamConfig` and again on the persist path, so a value
517
- // above the requirement's ceiling can reach neither the runtime nor the
518
- // file (R-g).
519
- const applied = controller.setTeamConfig({ maxSubagents: n });
520
- ctx.persistConfig({
521
- team: { maxSubagents: applied.maxSubagents },
522
- });
523
- ctx.toast('success', `Max subagents set to ${applied.maxSubagents}.`);
524
- return;
525
- }
526
- if (verb === 'model') {
527
- runTeamModelCommand(ctx, rawTokens[1]);
528
- return;
529
- }
530
- if (verb === 'overseer') {
531
- if (value !== 'on' && value !== 'off') {
532
- ctx.notify('warn', 'Usage: /team overseer on|off');
533
- return;
534
- }
535
- // Read LIVE per dispatch by the runtime, so no rebuild is needed - the
536
- // same reason this command does not touch the tool array (its own header
537
- // comment). Clamped by `setTeamConfig` and again on the persist path.
538
- const applied = controller.setTeamConfig({ overseer: value === 'on' });
539
- ctx.persistConfig({
540
- team: { overseer: applied.overseer },
541
- });
542
- ctx.toast('success', `Dispatch supervisor ${applied.overseer ? 'on' : 'off'}.`);
543
- return;
544
- }
545
- ctx.notify('warn', `Unknown argument "${verb}" - use /team [status|on|off|max <n>|overseer on|off|model main|fast|<profile-name>].`);
546
- },
547
- },
548
373
  {
549
374
  name: 'fast',
550
375
  description: 'Fast model tier: status | on | off | model <id> | provider <id> | same | ' +
@@ -1508,6 +1333,19 @@ export function registerBuiltinCommands(registry) {
1508
1333
  // The read-only subagent board (tui-subagent-panel §6.4). Same shape again: the
1509
1334
  // body is `commands/agents-command.ts` and the registration is this one line.
1510
1335
  registerAgentsCommand(registry);
1336
+ // `/subagent` (subagent-fanout 3.5). REGISTERED UNCONDITIONALLY, like `/prompts`
1337
+ // and for the same reason: a `--no-team` session must still be able to ask the
1338
+ // command why it cannot force anything (I-7 / AC-15) - so registering it may
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.
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);
1511
1349
  // The keyboard drawer (`Ctrl+G` / `/keys`) and the task label (`/task`). Two
1512
1350
  // registrations, no new mechanism: `/keys` sets the panel's pre-filter and opens
1513
1351
  // the same overlay `Ctrl+G` opens, and `/task` reports what the header cell says.
@@ -1522,7 +1360,12 @@ export function registerBuiltinCommands(registry) {
1522
1360
  * `registry.get(name)` instead, because that is the only check that stays
1523
1361
  * correct when commands are registered dynamically (§7.1 / P1-2).
1524
1362
  */
1525
- export const BUILTIN_COMMAND_NAMES = ['queue', 'prompts', 'agents', 'keys', 'task',
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',
1368
+ 'keys', 'task',
1526
1369
  ...COMMANDS.flatMap((c) => [
1527
1370
  c.name,
1528
1371
  ...(c.aliases ?? []),