@aragon-agent/cli 0.6.39 → 0.6.40

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 (69) hide show
  1. package/CHANGELOG.md +2589 -2523
  2. package/README.md +3706 -3614
  3. package/dist/agent/controller.d.ts +103 -9
  4. package/dist/agent/controller.js +196 -19
  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 +14 -2
  13. package/dist/agent/system-prompt.js +7 -4
  14. package/dist/cli.js +21 -5
  15. package/dist/commands/builtins.js +12 -3
  16. package/dist/commands/skills.js +86 -1
  17. package/dist/commands/subagent-command.d.ts +25 -0
  18. package/dist/commands/subagent-command.js +242 -0
  19. package/dist/compaction/command.js +16 -3
  20. package/dist/compaction/compactor.d.ts +25 -0
  21. package/dist/compaction/compactor.js +111 -10
  22. package/dist/compaction/digest.d.ts +24 -1
  23. package/dist/compaction/digest.js +139 -17
  24. package/dist/compaction/limits.d.ts +21 -0
  25. package/dist/compaction/limits.js +21 -0
  26. package/dist/compaction/reason-text.d.ts +36 -0
  27. package/dist/compaction/reason-text.js +78 -0
  28. package/dist/compaction/types.d.ts +82 -14
  29. package/dist/compaction/wiring.d.ts +13 -8
  30. package/dist/compaction/wiring.js +1 -0
  31. package/dist/config/load.js +50 -3
  32. package/dist/config/schema.d.ts +82 -6
  33. package/dist/config/schema.js +107 -9
  34. package/dist/config/store.js +7 -2
  35. package/dist/proc/log-mirror.d.ts +21 -4
  36. package/dist/proc/log-mirror.js +29 -7
  37. package/dist/skills/service.d.ts +41 -0
  38. package/dist/skills/service.js +62 -0
  39. package/dist/skills/triage.d.ts +194 -0
  40. package/dist/skills/triage.js +393 -0
  41. package/dist/team/forced-dispatch.d.ts +95 -0
  42. package/dist/team/forced-dispatch.js +208 -0
  43. package/dist/team/limits.d.ts +21 -1
  44. package/dist/team/limits.js +21 -1
  45. package/dist/team/normalize.d.ts +15 -0
  46. package/dist/team/normalize.js +21 -4
  47. package/dist/team/prompt.d.ts +6 -3
  48. package/dist/team/prompt.js +9 -5
  49. package/dist/team/task-tool.js +11 -7
  50. package/dist/tools/human-input.d.ts +35 -2
  51. package/dist/tools/human-input.js +38 -0
  52. package/dist/tools/plan-tools.d.ts +20 -12
  53. package/dist/tools/plan-tools.js +24 -17
  54. package/dist/ui/App.js +14 -1
  55. package/dist/ui/StatusBar.d.ts +1 -0
  56. package/dist/ui/StatusBar.js +1 -0
  57. package/dist/ui/Transcript.js +1 -1
  58. package/dist/ui/entries/CompactionCard.d.ts +11 -1
  59. package/dist/ui/entries/CompactionCard.js +65 -4
  60. package/dist/ui/layout/status-layout.d.ts +10 -0
  61. package/dist/ui/layout/status-layout.js +18 -1
  62. package/dist/ui/overlays/SettingsScreen.js +29 -5
  63. package/dist/ui/overlays/TeamFanoutPage.d.ts +35 -0
  64. package/dist/ui/overlays/TeamFanoutPage.js +78 -0
  65. package/dist/ui/overlays/model-profile-state.d.ts +19 -1
  66. package/dist/ui/overlays/model-profile-state.js +12 -0
  67. package/dist/ui/overlays/team-fanout-state.d.ts +73 -0
  68. package/dist/ui/overlays/team-fanout-state.js +97 -0
  69. package/package.json +2 -2
@@ -25,18 +25,25 @@ const NO_INTERACTIVE_USER = 'No interactive user is attached (-p/--print mode).
25
25
  'state your assumptions explicitly and continue.';
26
26
  const NO_INTERACTIVE_REVIEWER = 'No interactive reviewer is attached. Emit the full plan as your final answer ' +
27
27
  'in markdown instead.';
28
- function roundCapRefusal(used) {
29
- return (`You have already asked ${used} rounds of questions (the configured maximum). ` +
30
- 'Proceed with your best assumptions and state them in the plan.');
31
- }
32
28
  // ---------------------------------------------------------------------------
33
29
  // ask_user
34
30
  // ---------------------------------------------------------------------------
35
31
  const ASK_USER_DESCRIPTION = 'Ask the user 1-5 multiple-choice questions when a decision would change the work. ' +
36
- 'Give each question a stable id, a header of at most 12 characters, and 2-4 options; ' +
37
- 'exactly one option must set recommended:true, with the reason in its description. ' +
38
- 'Extra questions or options beyond these limits are dropped, so keep within them. ' +
39
- 'Do not ask anything you could answer by reading the workspace.';
32
+ 'There is no limit on how many rounds you ask: call this as many times as the work ' +
33
+ 'needs, one batch at a time. Give each question a stable id, a header of at most 12 ' +
34
+ 'characters, and 2-4 options; exactly one option must set recommended:true, with the ' +
35
+ 'reason in its description. Extra questions or options beyond the per-call limits are ' +
36
+ 'dropped, so keep each round inside them. Do not ask anything you could answer by ' +
37
+ 'reading the workspace, and never re-ask a question this turn already answered.';
38
+ /**
39
+ * The de-duplication notice, as a NON-error result.
40
+ *
41
+ * `textResult`, not `errorResult`: a tool failure invites the model to retry the
42
+ * same call, and its only possible outcome is the same refusal. This is the same
43
+ * reasoning as the cancellation path below.
44
+ */
45
+ const DUPLICATE_ROUND_RESULT = 'You already asked this exact set of questions in this turn and the user answered. ' +
46
+ 'Use those answers; if you need something else, ask a different question.';
40
47
  function makeAskUser(opts) {
41
48
  return {
42
49
  name: 'ask_user',
@@ -91,9 +98,10 @@ function makeAskUser(opts) {
91
98
  if (questions.length === 0) {
92
99
  return errorResult('No usable questions: each needs 2-4 distinct options.');
93
100
  }
94
- const ticket = opts.takeAskRound();
95
- if (!ticket.ok)
96
- return errorResult(roundCapRefusal(ticket.used));
101
+ // BEFORE the wait: a set the user has already answered must not open an
102
+ // overlay a second time. Read-only — nothing is recorded here.
103
+ if (opts.hasAnsweredQuestions(questions))
104
+ return textResult(DUPLICATE_ROUND_RESULT);
97
105
  const response = await opts.withHumanWait(() => opts.gate.request({ kind: 'questions', questions }, ctx.signal));
98
106
  // `null` covers cancel, unmount, abort AND timeout. From the model's point
99
107
  // of view an unanswered question and an abandoned one are the same
@@ -102,12 +110,11 @@ function makeAskUser(opts) {
102
110
  // invites a retry loop.
103
111
  const cancelled = response === null || response.kind !== 'answers' || response.cancelled;
104
112
  const answers = response !== null && response.kind === 'answers' ? response.answers : [];
105
- return textResult(formatAnswersResult({
106
- answers,
107
- cancelled,
108
- roundsUsed: ticket.used,
109
- roundsRemaining: ticket.remaining,
110
- }, questions.length));
113
+ // AFTER the answer, and only for a real answer. A cancelled round leaves
114
+ // the set askable: mis-touch must not cost the user information.
115
+ if (!cancelled)
116
+ opts.noteAnsweredQuestions(questions);
117
+ return textResult(formatAnswersResult({ answers, cancelled }, questions.length));
111
118
  },
112
119
  };
113
120
  }
package/dist/ui/App.js CHANGED
@@ -1014,6 +1014,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1014
1014
  // correct value (P0-1).
1015
1015
  dispatch({ type: 'compactionEnd', record: event.record });
1016
1016
  break;
1017
+ case 'phase':
1018
+ // THE STAGE LINE ON THE LIVE CARD (context-compaction-never-fires C).
1019
+ // Dispatch is unconditional; the reducer is what ignores a phase that
1020
+ // arrives for a card the settle has already closed.
1021
+ dispatch({ type: 'compactionPhase', index: event.index, phase: event.phase });
1022
+ break;
1017
1023
  case 'usage': {
1018
1024
  // Compaction spend is REAL SPEND at the SUMMARIZER's price (§6.4 /
1019
1025
  // AC-15), which may be neither the lead's nor the fast tier's — so the
@@ -3153,7 +3159,14 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
3153
3159
  ? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
3154
3160
  : {}), ...(acceptanceActive ? { acceptanceActive } : {}), ...(liveServices > 0 ? { servicesActive: { live: liveServices } } : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}),
3155
3161
  // Manual compaction starts before its next snapshot is published.
3156
- compactionActive: { inFlight: statusInput.compacting ?? false }, ...(controller.isCompactionEnabled()
3162
+ // THE AUTO-PAUSED CHIP RIDES HERE AND NOWHERE ELSE (never-fires C /
3163
+ // analysis 3.3.1): `selfDisabled` used to live only in `/compact status`,
3164
+ // a command nobody types while the gauge sits at 95 %. `StatusBar` plans
3165
+ // the primary row from its own props, so a second copy of the flag on
3166
+ // `statusInput` would be a field nothing renders - and a fact with two
3167
+ // sources is a fact that drifts.
3168
+ compactionActive: { inFlight: statusInput.compacting ?? false,
3169
+ selfDisabled: state.compaction?.selfDisabled === true }, ...(controller.isCompactionEnabled()
3157
3170
  ? {
3158
3171
  gaugeMarks: {
3159
3172
  warn: cfg.compaction.warnThreshold * 100,
@@ -92,6 +92,7 @@ export interface StatusBarProps {
92
92
  };
93
93
  compactionActive?: {
94
94
  inFlight: boolean;
95
+ selfDisabled?: boolean;
95
96
  };
96
97
  gaugeMarks?: {
97
98
  warn: number;
@@ -39,6 +39,7 @@ export function StatusBar(props) {
39
39
  interruptPhase: props.interruptPhase,
40
40
  waitingForConfirmation: props.waitingForConfirmation, stopping: props.stopping,
41
41
  compacting: props.compactionActive?.inFlight, retryActive: props.retryActive,
42
+ compactionPaused: props.compactionActive?.selfDisabled === true,
42
43
  mode: props.agentMode, pendingMode: props.pendingAgentMode, servicesActive: props.servicesActive,
43
44
  teamActive: props.teamActive, todoActive: props.todoActive,
44
45
  ...(props.acceptanceActive ? { acceptanceActive: props.acceptanceActive } : {}),
@@ -137,7 +137,7 @@ function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion,
137
137
  case 'fast':
138
138
  return frame(entry.status === 'failed' ? glyphs.toolError : glyphs.teamAgent, fastCardColor(entry.status, theme), _jsx(FastCard, { reviewIndex: entry.reviewIndex, model: entry.model, status: entry.status, text: entry.text, detail: entry.detail, turn: entry.turn, durationMs: entry.durationMs, live: entry.live, reducedMotion: reducedMotion, theme: theme, caps: caps }));
139
139
  case 'compaction':
140
- return frame(entry.applied || entry.live ? glyphs.compaction : glyphs.toolError, compactionCardColor(entry, theme), _jsx(CompactionCard, { index: entry.index, decision: entry.decision, memoryVersion: entry.memoryVersion, trigger: entry.trigger, mode: entry.mode, applied: entry.applied, reason: entry.reason, messagesBefore: entry.messagesBefore, messagesAfter: entry.messagesAfter, tokensBefore: entry.tokensBefore, tokensAfter: entry.tokensAfter, summary: entry.summary, model: entry.model, durationMs: entry.durationMs, tailRelief: entry.tailRelief,
140
+ return frame(entry.applied || entry.live ? glyphs.compaction : glyphs.toolError, compactionCardColor(entry, theme), _jsx(CompactionCard, { index: entry.index, decision: entry.decision, memoryVersion: entry.memoryVersion, trigger: entry.trigger, mode: entry.mode, applied: entry.applied, reason: entry.reason, messagesBefore: entry.messagesBefore, messagesAfter: entry.messagesAfter, tokensBefore: entry.tokensBefore, tokensAfter: entry.tokensAfter, summary: entry.summary, model: entry.model, durationMs: entry.durationMs, tailRelief: entry.tailRelief, phase: entry.phase, keepRelaxed: entry.keepRelaxed, retainedTurns: entry.retainedTurns, retainedMessages: entry.retainedMessages, keepRecentTurnsUsed: entry.keepRecentTurnsUsed, elidedMessages: entry.elidedMessages,
141
141
  // ONLY WHILE LIVE, and only when the clock has ticked at least once:
142
142
  // `nowSec` is `undefined` until then, and a settled card must keep
143
143
  // rendering `durationMs` rather than a figure that would drift.
@@ -23,7 +23,7 @@
23
23
  import React from 'react';
24
24
  import type { Theme } from '../theme.js';
25
25
  import type { TermCapabilities } from '../capabilities.js';
26
- import type { CompactionDecision, CompactionMode, CompactionUiTrigger } from '../../compaction/types.js';
26
+ import type { CompactionDecision, CompactionMode, CompactionPhase, CompactionUiTrigger } from '../../compaction/types.js';
27
27
  export interface CompactionCardProps {
28
28
  decision?: CompactionDecision;
29
29
  memoryVersion?: 2;
@@ -39,6 +39,16 @@ export interface CompactionCardProps {
39
39
  summary?: string;
40
40
  model: string;
41
41
  durationMs?: number;
42
+ /** The live card's stage, so "compacting" also says WHAT (never-fires C). */
43
+ phase?: CompactionPhase;
44
+ /** Set when this cut existed only because `keepRecentTurns` was relaxed (B). */
45
+ keepRelaxed?: boolean;
46
+ retainedTurns?: number;
47
+ /** Messages the relaxed cut left verbatim, which is not the same as turns. */
48
+ retainedMessages?: number;
49
+ keepRecentTurnsUsed?: number;
50
+ /** Messages the digest left out and therefore cannot be quoted (A1). */
51
+ elidedMessages?: number;
42
52
  /**
43
53
  * Wall-clock since the card opened, LIVE ONLY
44
54
  * (context-auto-compaction-hardening §3.6 / W5).
@@ -28,6 +28,7 @@ import { pickGlyphs } from '../glyphs.js';
28
28
  import { railBorderProps } from '../layout/Gutter.js';
29
29
  import { formatDuration, formatTokens } from '../../agent/usage.js';
30
30
  import { COMPACTION_LIMITS } from '../../compaction/limits.js';
31
+ import { compactionPhaseLabel, compactionReasonLine } from '../../compaction/reason-text.js';
31
32
  /**
32
33
  * The card's colour.
33
34
  *
@@ -56,12 +57,19 @@ function headline(props, arrow, dot) {
56
57
  // second 45 this was the only changing information on the card, and there was
57
58
  // none.
58
59
  const elapsed = props.elapsedMs === undefined ? '' : `${dot}${formatDuration(props.elapsedMs)}`;
60
+ // THE STAGE, NOT JUST "COMPACTING" (never-fires C). Planning and digesting run
61
+ // before any model call, and a user watching a card for twenty seconds cannot
62
+ // tell "thinking hard" from "stuck in a gate" without it.
63
+ const stage = props.phase === undefined ? '' : `${dot}${compactionPhaseLabel(props.phase)}`;
59
64
  return props.model
60
- ? `compacting context #${index}${dot}${props.model}${elapsed}`
61
- : `compacting context #${index}${elapsed}`;
65
+ ? `compacting context #${index}${dot}${props.model}${stage}${elapsed}`
66
+ : `compacting context #${index}${stage}${elapsed}`;
62
67
  }
63
68
  if (!props.applied || props.tokensAfter >= props.tokensBefore) {
64
- const why = props.reason ? `: ${props.reason}` : '';
69
+ // THE REASON IN WORDS (never-fires C). `digest_budget_exceeded: $.digest` was
70
+ // a code the user could not act on; `compactionReasonLine` renders the same
71
+ // fact as a clause plus, where there is one, the remedy.
72
+ const why = props.reason ? `: ${compactionReasonLine(props.reason)}` : '';
65
73
  return `context not compacted #${index}${why}`;
66
74
  }
67
75
  const messages = `${props.messagesBefore} ${arrow} ${props.messagesAfter} messages`;
@@ -87,8 +95,15 @@ function headline(props, arrow, dot) {
87
95
  */
88
96
  function detailLine(props) {
89
97
  if (props.live) {
98
+ // THE ESCAPE, NOT THE STAGE (never-fires C, as corrected in review): the live
99
+ // headline already carries the stage, and a two-line card that names it twice
100
+ // is the one-fact-one-surface rule this file keeps having to restore. With no
101
+ // model resolved there is no call to cancel, so the whole line goes - the same
102
+ // rule the settled branch applies.
90
103
  if (!props.model)
91
104
  return '';
105
+ if (props.phase !== undefined)
106
+ return 'esc to cancel';
92
107
  return `summarizing with ${props.model} - esc to cancel`;
93
108
  }
94
109
  if (!props.applied || props.tokensAfter >= props.tokensBefore)
@@ -104,6 +119,50 @@ function detailLine(props) {
104
119
  const via = props.trigger === 'overflow' ? ' after a context overflow' : '';
105
120
  return `summarized ${dropped} messages with ${props.model}${via}`;
106
121
  }
122
+ /**
123
+ * The relaxation disclosure (never-fires B).
124
+ *
125
+ * `planCompaction`'s second pass trades "the last N user turns are verbatim" for
126
+ * "a cut exists at all". That trade must be readable, because the difference
127
+ * between the two compactions is exactly what the user asked for: the tail is the
128
+ * part they care most about keeping word for word.
129
+ */
130
+ function relaxedLine(props) {
131
+ if (props.live || !props.applied || props.keepRelaxed !== true)
132
+ return '';
133
+ const turns = props.retainedTurns ?? 0;
134
+ const wanted = props.keepRecentTurnsUsed;
135
+ const messages = props.retainedMessages;
136
+ // THE TAIL IS MESSAGES, NOT TURNS (review). The dominant relaxed shape is one
137
+ // prompt plus a hundred tool calls, so `retainedTurns` is 0 while forty messages
138
+ // were kept verbatim: a line reading "kept 0 recent turns" would report a total
139
+ // loss that did not happen. Whenever the count is available it leads, and the
140
+ // turn arithmetic follows as the explanation rather than as the headline.
141
+ if (messages !== undefined && wanted !== undefined) {
142
+ return `kept the last ${messages} messages - ${turns} of the ${wanted} requested turns, ` +
143
+ 'since this run had too few user turns';
144
+ }
145
+ if (messages !== undefined)
146
+ return `kept the last ${messages} messages verbatim`;
147
+ return wanted === undefined
148
+ ? `kept the last ${turns} turns - this run had too few user turns to keep more`
149
+ : `kept ${turns} of the ${wanted} requested turns - this run had too few user turns`;
150
+ }
151
+ /**
152
+ * The digest-elision disclosure (never-fires A1).
153
+ *
154
+ * The digest never rejects for length any more; it keeps the first and last
155
+ * entries verbatim and elides the middle. The elided messages cannot be quoted by
156
+ * the summarizer, so the card says so rather than presenting a summary as if it
157
+ * had seen everything.
158
+ */
159
+ function elidedLine(props) {
160
+ const n = props.elidedMessages ?? 0;
161
+ if (props.live || n <= 0)
162
+ return '';
163
+ return `elided ${n} ${n === 1 ? 'message' : 'messages'} from the summary input - ` +
164
+ 'the middle is summarized without them';
165
+ }
107
166
  /**
108
167
  * The relief disclosure, on a settled card that ALSO summarized.
109
168
  *
@@ -139,6 +198,8 @@ function CompactionCardImpl(props) {
139
198
  const head = headline(props, glyphs.arrowRight, dot);
140
199
  const detail = detailLine(props);
141
200
  const relief = reliefLine(props);
201
+ const relaxed = relaxedLine(props);
202
+ const elided = elidedLine(props);
142
203
  const showMemory = props.memoryVersion !== 2 || expanded === true;
143
204
  const rows = summary && showMemory ? summaryRows(summary, expanded === true) : [];
144
205
  const totalRows = summary ? summary.split('\n').filter((l) => l.trim().length > 0).length : 0;
@@ -150,7 +211,7 @@ function CompactionCardImpl(props) {
150
211
  // the whole run, losing the status colour along with the motion.
151
212
  const animate = live && reducedMotion !== true && caps.unicode;
152
213
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.accent, bold: true, children: props.trigger === 'manual' ? 'Manual compaction' : props.trigger === 'overflow'
153
- ? 'Overflow: threshold reached' : 'Automatic: threshold reached' }), _jsxs(Text, { color: live ? color : theme.muted, wrap: caps.unicode ? 'truncate' : 'wrap', children: [animate ? (_jsxs(_Fragment, { children: [_jsx(Spinner, { type: "dots" }), ' '] })) : live ? (`${glyphs.spinnerStill} `) : (''), head] })] }), props.memoryVersion === 2 && (_jsxs(Text, { color: theme.muted, children: ["Task memory v2", expanded ? '' : ' (ctrl+o to expand)'] })), detail.length > 0 && (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, children: _jsx(Text, { color: color, wrap: caps.unicode ? 'truncate' : 'wrap', children: detail }) })), relief.length > 0 && (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, children: _jsx(Text, { color: theme.noticeWarn, children: relief }) })), rows.length > 0 && (_jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [rows.map((line, index) => (_jsx(Text, { color: theme.primary, children: line }, index))), hidden > 0 && (_jsxs(Text, { color: theme.muted, children: [glyphs.railEnd, " +", hidden, " more lines (ctrl+o to expand)"] })), hidden === 0 && expanded === true && totalRows > COMPACTION_LIMITS.cardTextRows && (_jsxs(Text, { color: theme.muted, children: [glyphs.railEnd, " ctrl+o to collapse"] }))] }))] }));
214
+ ? 'Overflow: threshold reached' : 'Automatic: threshold reached' }), _jsxs(Text, { color: live ? color : theme.muted, wrap: caps.unicode ? 'truncate' : 'wrap', children: [animate ? (_jsxs(_Fragment, { children: [_jsx(Spinner, { type: "dots" }), ' '] })) : live ? (`${glyphs.spinnerStill} `) : (''), head] })] }), props.memoryVersion === 2 && (_jsxs(Text, { color: theme.muted, children: ["Task memory v2", expanded ? '' : ' (ctrl+o to expand)'] })), detail.length > 0 && (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, children: _jsx(Text, { color: color, wrap: caps.unicode ? 'truncate' : 'wrap', children: detail }) })), relief.length > 0 && (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, children: _jsx(Text, { color: theme.noticeWarn, children: relief }) })), relaxed.length > 0 && (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, children: _jsx(Text, { color: theme.muted, children: relaxed }) })), elided.length > 0 && (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, children: _jsx(Text, { color: theme.muted, children: elided }) })), rows.length > 0 && (_jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [rows.map((line, index) => (_jsx(Text, { color: theme.primary, children: line }, index))), hidden > 0 && (_jsxs(Text, { color: theme.muted, children: [glyphs.railEnd, " +", hidden, " more lines (ctrl+o to expand)"] })), hidden === 0 && expanded === true && totalRows > COMPACTION_LIMITS.cardTextRows && (_jsxs(Text, { color: theme.muted, children: [glyphs.railEnd, " ctrl+o to collapse"] }))] }))] }));
154
215
  }
155
216
  /**
156
217
  * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
@@ -31,6 +31,16 @@ export interface StatusLayoutInput {
31
31
  spinner?: string;
32
32
  waitingForConfirmation?: boolean;
33
33
  stopping?: boolean;
34
+ /**
35
+ * Auto-compaction paused itself after repeated no-progress attempts
36
+ * (context-compaction-never-fires C).
37
+ *
38
+ * A SEPARATE FIELD FROM `compacting`, and it renders as a SUFFIX on the context
39
+ * gauge rather than as a phase word: the fact belongs next to the percentage it
40
+ * explains ("Context 95% auto-paused"), and the phase slot must keep answering
41
+ * "is the agent running".
42
+ */
43
+ compactionPaused?: boolean;
34
44
  compacting?: boolean;
35
45
  retryActive?: {
36
46
  attempt: number;
@@ -53,6 +53,22 @@ function contextPercent(context) {
53
53
  const mark = context.source === 'estimate' || context.deltaTokens > 0 ? '~' : '';
54
54
  return mark + (context.pct > 999 ? '>999' : Math.round(context.pct)) + '%';
55
55
  }
56
+ /**
57
+ * The words that ride on the gauge when auto-compaction has given up (C).
58
+ *
59
+ * IT RIDES ON THE GAUGE RATHER THAN OCCUPYING ITS OWN FIELD. "Auto-paused" is the
60
+ * only fact that explains a stuck percentage, so the two belong in one field for
61
+ * the same reason `/context` prints them on adjacent lines. A second field would
62
+ * also be the first thing the width ladder drops, and this is precisely the state
63
+ * that must survive a narrow terminal.
64
+ *
65
+ * `paused` at the tight rungs: the full phrase costs twelve cells the row does
66
+ * not have at rung 3 and beyond, and dropping the fact entirely would be worse
67
+ * than abbreviating it.
68
+ */
69
+ function pausedSuffix(full) {
70
+ return full ? ' auto-paused' : ' paused';
71
+ }
56
72
  export function shortDuration(ms) {
57
73
  if (!Number.isFinite(ms) || ms <= 0)
58
74
  return '0s';
@@ -208,7 +224,8 @@ export function planPrimaryStatusFields(input) {
208
224
  statusField('phase', glyph + (rung >= 5 ? '' : ' ') + label, phaseTone),
209
225
  ...(notice ? [statusField('feedback', notice, feedbackTone)] : []),
210
226
  ...(modeText ? [statusField('mode', modeText, 'accent')] : []),
211
- statusField('context', (full ? 'Context ' : 'C') + pct),
227
+ statusField('context', (full ? 'Context ' : 'C') + pct
228
+ + (input.compactionPaused === true ? pausedSuffix(full) : '')),
212
229
  statusField('thinking', full ? 'Think ' + (thinkingCodes[input.thinkingLevel] ? input.thinkingLevel : '?')
213
230
  : (rung >= 2 ? '' : 'Th:') + code),
214
231
  statusField('speed', speed + (full ? ' tok/s' : rung >= 3 ? '/s' : 't/s')),
@@ -27,6 +27,8 @@ import { canProbe, editorProbeTarget, visibleProbeViews } from './model-probe-vi
27
27
  import { createModelProfileState, createCustomProfile, modelProfileReducer, profileUsers, draftTeam, draftSubagentProfileId, subagentModelSummary, subagentSelectedId, SUBAGENT_FAST_ROW_ID } from './model-profile-state.js';
28
28
  import { KitEditor } from './KitEditor.js';
29
29
  import { KitManager } from './KitManager.js';
30
+ import { TeamFanoutPage } from './TeamFanoutPage.js';
31
+ import { fanoutRows, fanoutSummary, teamFanoutValues } from './team-fanout-state.js';
30
32
  import { heldSlots, kitManagerRows, kitPackRow, kitReducer } from './kit-settings-state.js';
31
33
  /**
32
34
  * THE ROOT PAGE'S ROW INDEXES, AS NAMES (subagent-model V-5).
@@ -40,10 +42,15 @@ import { heldSlots, kitManagerRows, kitPackRow, kitReducer } from './kit-setting
40
42
  * fifth of these and the "five offsets agreed by accident" defect does not need
41
43
  * a sequel. Order here IS the render order.
42
44
  */
43
- const ROOT_ROWS = ['main', 'fast', 'subagent', 'kit', 'manage'];
45
+ const ROOT_ROWS = ['main', 'fast', 'subagent', 'team', 'kit', 'manage'];
44
46
  const ROOT_MAIN = ROOT_ROWS.indexOf('main');
45
47
  const ROOT_FAST = ROOT_ROWS.indexOf('fast');
46
48
  const ROOT_SUBAGENT = ROOT_ROWS.indexOf('subagent');
49
+ // The fan-out page (subagent-fanout D-5). It is FOURTH, directly under the model
50
+ // row it complements: "which model children run on" and "how many children" are
51
+ // one decision, and splitting them across the page would make the pair harder to
52
+ // keep consistent than the pair of numbers inside it.
53
+ const ROOT_TEAM = ROOT_ROWS.indexOf('team');
47
54
  const ROOT_KIT = ROOT_ROWS.indexOf('kit');
48
55
  const ROOT_MANAGE = ROOT_ROWS.indexOf('manage');
49
56
  const ROOT_FIRST_FIELD = ROOT_ROWS.length;
@@ -583,9 +590,9 @@ function useProfileSettingsState(props) {
583
590
  && ['provider', 'model', 'baseUrl', 'contextWindow', 'apiKey'].includes(item.key)),
584
591
  ...FIELDS.filter((item) => library.fastId === null
585
592
  && ['fastModel', 'fastProvider'].includes(item.key)), ...PROFILE_FIELDS];
586
- // SEVEN FIXED ROWS now (Main, Fast, SUBAGENT, Kit pack, Manage, Save, Reload)
587
- // around the `fields` block - derived from ROOT_ROWS so the count and the
588
- // offsets cannot drift apart (see those constants).
593
+ // EIGHT FIXED ROWS now (Main, Fast, Subagent model, Subagent fan-out, Kit pack,
594
+ // Manage, Save, Reload) around the `fields` block - derived from ROOT_ROWS so
595
+ // the count and the offsets cannot drift apart (see those constants).
589
596
  const count = fields.length + ROOT_ROWS.length + 2;
590
597
  const field = fields[index - ROOT_FIRST_FIELD];
591
598
  const readError = state.draft.readError;
@@ -679,8 +686,15 @@ function activateRootSetting(props, view) {
679
686
  // `draft.patch.team` rather than a `${role}Id` library key.
680
687
  dispatch({ type: 'page', page: 'picker', role: 'subagent' });
681
688
  }
689
+ else if (index === ROOT_TEAM) {
690
+ // MUST STAY ABOVE THE FALLBACK `else` BELOW, WHICH IS `saveProfileSettings`
691
+ // (R-P1-8). A missing or mis-derived constant here does not fail to compile:
692
+ // pressing Enter on this row would silently COMMIT the whole draft, so
693
+ // `team-fanout-page.test.tsx` asserts that Enter on this row saves nothing.
694
+ dispatch({ type: 'page', page: 'team' });
695
+ }
682
696
  else if (index === ROOT_KIT) {
683
- // The fourth root row opens the manager, not a picker: kits are data the user
697
+ // The fifth root row opens the manager, not a picker: kits are data the user
684
698
  // owns here, and `ctrl+K` is where they are CONSUMED (agent-model-kits §4.5).
685
699
  dispatch({ type: 'page', page: 'kits' });
686
700
  }
@@ -836,6 +850,11 @@ function fastTierUnavailable(props, view) {
836
850
  }
837
851
  function renderProfileSubpage(props, view) {
838
852
  const { state, library, dispatch, setConfirmIndex } = view;
853
+ // THE FAN-OUT PAGE (subagent-fanout 3.4.4). Placed before the picker branch
854
+ // because that branch is the unconditional fallback; the page name is unique,
855
+ // so its position among the others is arbitrary.
856
+ if (state.page === 'team')
857
+ return _jsx(TeamFanoutPage, { ...props, view: { rows: fanoutRows(teamFanoutValues(state.draft)), error: state.error }, dispatch: dispatch, onBack: () => dispatch({ type: 'page', page: 'root' }) });
839
858
  // THE KIT PAGES (agent-model-kits §4.5). They are dispatched BEFORE the profile
840
859
  // pages because `'kit-slot'` RENDERS a profile picker while belonging to the
841
860
  // kit editor - the page name, not the component, decides where the user is.
@@ -945,9 +964,14 @@ function buildRootSettingsRows(props, view) {
945
964
  profiles: state.draft.profiles,
946
965
  hasKey: (id) => (props.apiKeys[id] ?? '').length > 0 })}${kitTag}`
947
966
  : subagentLine;
967
+ // THE FAN-OUT SUMMARY (subagent-fanout 3.4.4): a DERIVED row, like the three
968
+ // above it, and deliberately not a control - the numbers are adjusted on the
969
+ // page Enter opens, so the root row cannot change a value by accident.
970
+ const fanout = fanoutSummary(teamFanoutValues(state.draft));
948
971
  const lines = [`Main profile ${mainValue}`,
949
972
  `Fast profile ${fastValue}`,
950
973
  `Subagent model ${subagentValue}`,
974
+ `Subagent fan-out ${fanout}`,
951
975
  `Kit pack ${kitPackRow(state)}`,
952
976
  `Manage profiles ${library.entries.length} saved`,
953
977
  ...fields.map((item) => `${item.label.padEnd(18)}${item.key === 'apiKey'
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Settings > Team fan-out: three arrows-adjustable rows (subagent-fanout 3.4.3).
3
+ *
4
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
5
+ *
6
+ * THE PAGE OWNS ONE PIECE OF STATE: its cursor. Every value comes from
7
+ * `props.view.rows`, which `SettingsScreen` derives from the reducer state - so
8
+ * this page cannot hold a second copy of the draft and then disagree with the
9
+ * root page about what is about to be saved (the discipline
10
+ * `model-profile-state.ts` records at its own head).
11
+ *
12
+ * IT DOES NOT LISTEN FOR Ctrl+S, AND THE FOOTER SAYS SO (R-P1-13).
13
+ * `useRootSettingsInput` gates its `useInput` on `state.page === 'root'`, so a
14
+ * subpage has no save listener at all - the kit page included. A footer
15
+ * promising "Ctrl+S saves here" would therefore be a lie the user acts on. The
16
+ * honest instruction is the one already in their fingers: Esc back, Ctrl+S on the
17
+ * main page.
18
+ */
19
+ import React from 'react';
20
+ import { type ProfileFrameProps } from './ModelProfilePicker.js';
21
+ import type { TeamFanoutField, TeamFanoutView } from './team-fanout-state.js';
22
+ /** What the page may ask the screen to change. */
23
+ export interface TeamFanoutAction {
24
+ type: 'team-fanout';
25
+ field: TeamFanoutField;
26
+ delta: -1 | 1;
27
+ }
28
+ interface TeamFanoutPageProps extends ProfileFrameProps {
29
+ view: TeamFanoutView;
30
+ dispatch: (action: TeamFanoutAction) => void;
31
+ /** Esc: back to the settings root. The draft is NOT discarded here. */
32
+ onBack: () => void;
33
+ }
34
+ export declare function TeamFanoutPage(props: TeamFanoutPageProps): React.ReactElement;
35
+ export {};
@@ -0,0 +1,78 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Settings > Team fan-out: three arrows-adjustable rows (subagent-fanout 3.4.3).
4
+ *
5
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * THE PAGE OWNS ONE PIECE OF STATE: its cursor. Every value comes from
8
+ * `props.view.rows`, which `SettingsScreen` derives from the reducer state - so
9
+ * this page cannot hold a second copy of the draft and then disagree with the
10
+ * root page about what is about to be saved (the discipline
11
+ * `model-profile-state.ts` records at its own head).
12
+ *
13
+ * IT DOES NOT LISTEN FOR Ctrl+S, AND THE FOOTER SAYS SO (R-P1-13).
14
+ * `useRootSettingsInput` gates its `useInput` on `state.page === 'root'`, so a
15
+ * subpage has no save listener at all - the kit page included. A footer
16
+ * promising "Ctrl+S saves here" would therefore be a lie the user acts on. The
17
+ * honest instruction is the one already in their fingers: Esc back, Ctrl+S on the
18
+ * main page.
19
+ */
20
+ import { useState } from 'react';
21
+ import { Text, useInput } from 'ink';
22
+ import { pickGlyphs } from '../glyphs.js';
23
+ import { OverlayFrame } from '../layout/OverlayFrame.js';
24
+ import { clipProfileText, useProfileScroll } from './ModelProfilePicker.js';
25
+ /**
26
+ * Label column, matching the root page's `padEnd(18)` rows above and below this
27
+ * one. `Subagent fan-out` is 16 characters, so two spaces of padding are all it
28
+ * needs to line up with `Subagent model` - measured, not guessed.
29
+ */
30
+ const LABEL_PAD = 18;
31
+ export function TeamFanoutPage(props) {
32
+ const [index, setIndex] = useState(0);
33
+ const rows = props.view.rows;
34
+ const count = Math.max(1, rows.length);
35
+ const cursor = Math.min(index, count - 1);
36
+ const focused = rows[cursor];
37
+ const offset = useProfileScroll(props, cursor);
38
+ const nudge = (delta) => {
39
+ if (!focused)
40
+ return;
41
+ props.dispatch({ type: 'team-fanout', field: focused.field, delta });
42
+ };
43
+ useInput((_input, key) => {
44
+ if (props.isActive === false)
45
+ return;
46
+ if (key.escape) {
47
+ props.onBack();
48
+ return;
49
+ }
50
+ if (key.upArrow || (key.tab && key.shift)) {
51
+ setIndex((cursor - 1 + count) % count);
52
+ return;
53
+ }
54
+ if (key.downArrow || key.tab) {
55
+ setIndex((cursor + 1) % count);
56
+ return;
57
+ }
58
+ if (key.leftArrow || key.rightArrow) {
59
+ nudge(key.leftArrow ? -1 : 1);
60
+ return;
61
+ }
62
+ // `Enter` toggles the Supervisor row and does NOTHING on a numeric row:
63
+ // a stray Return that silently changed a number is indistinguishable from a
64
+ // bug, and the arrow keys are already the obvious control.
65
+ if (key.return && focused?.field === 'overseer')
66
+ nudge(1);
67
+ });
68
+ const lines = rows.map((row) => `${row.label.padEnd(LABEL_PAD)}${row.value}`);
69
+ const glyphs = pickGlyphs(props.caps);
70
+ const body = lines.map((line, at) => (_jsx(Text, { wrap: "truncate", color: at === cursor ? props.theme.accent : props.theme.muted, children: `${at === cursor ? glyphs.caret : ' '} ${clipProfileText(line, props.cols - 8)}` }, at)));
71
+ // The hint explains the FOCUSED row rather than decorating the frame: three
72
+ // numbers whose meaning is not obvious ("per dispatch" versus "at once") are
73
+ // the reason this page exists at all.
74
+ if (focused) {
75
+ body.push(_jsx(Text, { wrap: "truncate", color: props.theme.muted, children: clipProfileText(focused.hint, props.cols - 8) }, "hint"));
76
+ }
77
+ return _jsx(OverlayFrame, { ...props, rows: body, scrollOffset: offset, title: props.view.error ?? 'Team fan-out', hint: "Arrows adjust. Esc goes back; Ctrl+S on the main page saves." });
78
+ }
@@ -3,7 +3,14 @@ import type { AgentKit } from '../../config/model-kits.js';
3
3
  import type { ModelSettingsDraft } from '../../config/model-profile-store.js';
4
4
  import type { SubagentModelMode } from '../../team/model.js';
5
5
  import type { KitDraft } from './kit-settings-state.js';
6
- export type ProfilePage = 'root' | 'picker' | 'manager' | 'editor' | 'models' | 'delete-confirm' | 'discard-confirm' | 'kits' | 'kit-edit' | 'kit-slot' | 'kit-delete';
6
+ import { type TeamFanoutField } from './team-fanout-state.js';
7
+ export type ProfilePage = 'root' | 'picker' | 'manager' | 'editor' | 'models' | 'delete-confirm' | 'discard-confirm' | 'kits' | 'kit-edit' | 'kit-slot' | 'kit-delete' | 'team';
8
+ /**
9
+ * `'team'` is the fan-out page (subagent-fanout D-5), and it is a PAGE rather
10
+ * than three more fields on the root page for a measurable reason: the root page
11
+ * already scrolls on a 24-row terminal, and three arrows-adjustable rows would
12
+ * push `Save` and `Reload` off the first screen on every machine.
13
+ */
7
14
  /**
8
15
  * Which role a PROFILE PICKER page is choosing for.
9
16
  *
@@ -100,6 +107,17 @@ export type ModelProfileAction = {
100
107
  } | {
101
108
  type: 'reload';
102
109
  draft: ModelSettingsDraft;
110
+ }
111
+ /**
112
+ * One arrow press on the fan-out page (subagent-fanout 3.4.2).
113
+ *
114
+ * The reducer, not the page, owns the write: the page holds a cursor and
115
+ * nothing else, so there is exactly one place a draft can change.
116
+ */
117
+ | {
118
+ type: 'team-fanout';
119
+ field: TeamFanoutField;
120
+ delta: -1 | 1;
103
121
  };
104
122
  /** Keep the original secret separate from the empty replacement field. */
105
123
  export declare function createProfileEditor(profile: ModelProfile): ProfileEditorDraft;
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { emptyModelProfiles, duplicateModelProfile, normalizeModelProfiles, validateModelProfiles } from '../../config/model-profiles.js';
3
3
  import { parseContextWindowInput } from '../../config/schema.js';
4
+ import { stepFanoutValue, teamFanoutValues } from './team-fanout-state.js';
4
5
  /** The picker's built-in `Fast tier model` row; never a profile id on disk. */
5
6
  export const SUBAGENT_FAST_ROW_ID = '__fast__';
6
7
  /** Keep the original secret separate from the empty replacement field. */
@@ -221,6 +222,17 @@ export function modelProfileReducer(state, action) {
221
222
  }
222
223
  case 'cancel-editor': return { ...state, editor: undefined, page: state.returnPage,
223
224
  error: undefined };
225
+ case 'team-fanout': {
226
+ // THE FRAGMENT IS THE ONLY THING WRITTEN (D-17 / R-P0-1). `stepFanoutValue`
227
+ // returns `Partial<TeamFanoutPatch>`, so `model` and `modelProfileId` - the
228
+ // two keys `releasedSlots` reads as "the user re-decided the subagent slot" -
229
+ // cannot enter the patch from here. `teamFanoutValues` is the CLAMPED view
230
+ // (R-P0-2), so a step starts from the number the user is looking at.
231
+ const step = stepFanoutValue(teamFanoutValues(state.draft), action.field, action.delta);
232
+ return { ...state, dirty: true, error: undefined,
233
+ draft: { ...state.draft,
234
+ patch: { ...state.draft.patch, team: { ...state.draft.patch.team, ...step } } } };
235
+ }
224
236
  case 'duplicate': {
225
237
  if (library.entries.length >= 200)
226
238
  return { ...state,