@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
package/dist/ui/App.js CHANGED
@@ -50,6 +50,11 @@ import { acceptanceChip, acceptanceRung, buildTodoRailLayout } from './layout/to
50
50
  import { buildTeamPanelLayout } from './layout/team-panel.js';
51
51
  import { isLiveTeamCard, lastTeamEntry, rosterCounts } from './team-status.js';
52
52
  import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
53
+ // THE THIRD LAYER OF FORCED TEAM MODE (team-command-forced-dispatch §3.7): the
54
+ // decision and its two strings live in a pure module, so `App` gains wiring and
55
+ // no policy. Imported beside the todo decision it shares one timer with.
56
+ import { decideForcedDispatch, FORCED_NUDGE_NOTICE } from '../team/dispatch-gate.js';
57
+ import { FORCED_NUDGE_GRACE_MS } from '../team/limits.js';
53
58
  import { useTerminalSize } from './layout/useTerminalSize.js';
54
59
  import { useHeightStore } from './use-height-store.js';
55
60
  import { useRenderGovernor } from './use-render-governor.js';
@@ -435,6 +440,32 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
435
440
  const updateToastPushed = useRef(false);
436
441
  const budgetRef = useRef(emptyBudget());
437
442
  const followTimer = useRef(null);
443
+ /**
444
+ * The forced-dispatch gate's three counters (team-command-forced-dispatch
445
+ * §3.7). REFS, for the reason the follow-through block above records: every
446
+ * field added to `ViewState` is a field `restoreEntries`, `/clear`, `/reset`
447
+ * and the session round-trip each have to have an opinion about, and none of
448
+ * them has an opinion about a counter that lives for one turn.
449
+ *
450
+ * `toolsUsed` and `dispatchAttempted` are cleared at `agent_start`; `nudges`
451
+ * is NOT - see its own comment.
452
+ */
453
+ const toolsUsedRef = useRef(0);
454
+ const dispatchAttemptedRef = useRef(false);
455
+ /**
456
+ * Fallback nudges spent. CLEARED BY A USER SUBMIT, NEVER BY `agent_start`
457
+ * (P0-2 / I-10).
458
+ *
459
+ * The nudge itself starts a NEW TURN, so a reset at `agent_start` would zero
460
+ * this counter at the top of every continuation and "at most one per turn"
461
+ * would become "one per turn, forever" - an unbounded loop of N-child
462
+ * dispatches that only ever fires while the model is being disobedient, which
463
+ * is precisely the state no manual test reproduces. The one reset lives in
464
+ * `submitMessage` under `opts.userInitiated !== false`, beside the
465
+ * `recordPrompt` call that answers the same question ("did a human ask for
466
+ * this?").
467
+ */
468
+ const nudgesRef = useRef(0);
438
469
  /** True while the run in flight was started by the grace timer, not the user. */
439
470
  const autoContinuationRef = useRef(false);
440
471
  /**
@@ -526,8 +557,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
526
557
  * Disarm an armed continuation. Idempotent; NEVER touches the budget — a
527
558
  * cancelled continuation was never issued, so it must not be charged (AC-18).
528
559
  *
529
- * Three callers and there must be no fourth: `Esc` in the idle branch, the
530
- * first line of `submitMessage`, and the subscription effect's cleanup.
560
+ * ONE SLOT: `Esc`, `submitMessage`, the reset/restore port, the effect cleanup
561
+ * and both follow-through arms (todo, then the gate) all cancel before arming.
531
562
  */
532
563
  const cancelFollowThrough = useCallback(() => {
533
564
  if (followTimer.current) {
@@ -639,6 +670,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
639
670
  if (event.type === 'agent_start') {
640
671
  interactionPhase.current = 'running';
641
672
  manualMetrics.current = createRunMetrics();
673
+ // THE TWO PER-TURN COUNTERS ONLY (I-10). `nudgesRef` is deliberately
674
+ // absent: a nudge continuation arrives here too, and clearing it would
675
+ // delete the bound that keeps a disobedient model from looping.
676
+ toolsUsedRef.current = 0;
677
+ dispatchAttemptedRef.current = false;
642
678
  }
643
679
  if (event.type === 'agent_end') {
644
680
  interactionPhase.current = 'idle';
@@ -654,6 +690,17 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
654
690
  if (action.type === 'notice' && action.level === 'error') {
655
691
  endReasonRef.current.errored = true;
656
692
  }
693
+ // THE GATE'S TWO FACTS, counted where the reducer's own actions already
694
+ // flow (no second subscription to keep in step). `toolExecStart` carries
695
+ // the tool NAME, so a `task` call that was REFUSED - team off, dispatch
696
+ // busy, no key - still counts as an attempt (P1-7): the upstream already
697
+ // said no, and urging a re-dispatch would reproduce that refusal one
698
+ // turn later.
699
+ if (action.type === 'toolExecStart') {
700
+ toolsUsedRef.current += 1;
701
+ if (action.toolName === 'task')
702
+ dispatchAttemptedRef.current = true;
703
+ }
657
704
  if (action.type === 'textDelta' || action.type === 'thinkingDelta') {
658
705
  pending.current.push(action);
659
706
  if (!flushTimer.current) {
@@ -688,13 +735,36 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
688
735
  const todos = controller.getTodoSnapshot();
689
736
  budgetRef.current = advanceBudget(budgetRef.current, todos, autoContinuationRef.current);
690
737
  autoContinuationRef.current = false;
691
- const decision = decideFollowThrough({
692
- mode: controller.getTodoConfig().followThrough,
693
- snapshot: todos,
738
+ // --- Forced-dispatch gate (team-command-forced-dispatch §3.7). --------
739
+ //
740
+ // THE THIRD LAYER, AND THE ONLY MECHANICAL ONE: if a whole turn went by
741
+ // with tools used and nothing dispatched, ask ONCE for a dispatch. It is
742
+ // computed BEFORE the todo decision and DOWN-GRADES that decision to
743
+ // `none` rather than returning early (P0-3 / I-9): the two statements below
744
+ // - the `endReasonRef` reset and `applyPendingMode()` - belong to EVERY
745
+ // turn, and a `return` here would silently defer a pending `plan -> build`
746
+ // by a whole turn and carry `errored` into the next one. It also keeps
747
+ // `cancelFollowThrough()`'s callers at three: the gate reuses the timer
748
+ // instead of adding a fourth canceller.
749
+ const forced = controller.getForcedTeam();
750
+ const gate = decideForcedDispatch({
751
+ armed: forced !== null,
752
+ count: forced?.count ?? 0,
753
+ dispatchAttempted: dispatchAttemptedRef.current,
754
+ toolsUsed: toolsUsedRef.current,
755
+ nudgesUsed: nudgesRef.current,
694
756
  runEnd: { ...endReasonRef.current },
695
- budget: budgetRef.current,
696
- interactive: true,
697
757
  });
758
+ const gated = gate.kind === 'nudge' && forced !== null;
759
+ const decision = gated
760
+ ? { kind: 'none' }
761
+ : decideFollowThrough({
762
+ mode: controller.getTodoConfig().followThrough,
763
+ snapshot: todos,
764
+ runEnd: { ...endReasonRef.current },
765
+ budget: budgetRef.current,
766
+ interactive: true,
767
+ });
698
768
  endReasonRef.current = { aborted: false, errored: false };
699
769
  if (decision.kind === 'notify') {
700
770
  dispatch({ type: 'notice', level: decision.level, text: decision.text });
@@ -739,6 +809,22 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
739
809
  submitRef.current(decision.message, { userInitiated: false, todoPolicy: 'continue' });
740
810
  }, decision.graceMs);
741
811
  }
812
+ // THE GATE'S OWN CONTINUATION: after the todo branch (which it has
813
+ // already silenced) and BEFORE `applyPendingMode()`, on the SAME timer and
814
+ // with the same re-checks. `level: 'warn'` and not `'error'` - the error
815
+ // level sets `endReasonRef.errored`, which would make the next turn's gate
816
+ // read "the run failed" and switch itself off for the rest of the session.
817
+ if (gated && gate.kind === 'nudge') {
818
+ nudgesRef.current += 1;
819
+ dispatch({ type: 'notice', level: 'warn', text: FORCED_NUDGE_NOTICE });
820
+ cancelFollowThrough(); // I-9 / I-3: at most one armed continuation, ever
821
+ followTimer.current = setTimeout(() => {
822
+ followTimer.current = null;
823
+ if (controller.isRunning() || controller.isCompactionBusy?.())
824
+ return;
825
+ submitRef.current(gate.message, { userInitiated: false, todoPolicy: 'continue' });
826
+ }, FORCED_NUDGE_GRACE_MS);
827
+ }
742
828
  // A deferred `plan -> build` becomes real here and nowhere else (§3.2).
743
829
  const applied = controller.applyPendingMode();
744
830
  if (applied) {
@@ -1014,6 +1100,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1014
1100
  // correct value (P0-1).
1015
1101
  dispatch({ type: 'compactionEnd', record: event.record });
1016
1102
  break;
1103
+ case 'phase':
1104
+ // THE STAGE LINE ON THE LIVE CARD (context-compaction-never-fires C).
1105
+ // Dispatch is unconditional; the reducer is what ignores a phase that
1106
+ // arrives for a card the settle has already closed.
1107
+ dispatch({ type: 'compactionPhase', index: event.index, phase: event.phase });
1108
+ break;
1017
1109
  case 'usage': {
1018
1110
  // Compaction spend is REAL SPEND at the SUMMARIZER's price (§6.4 /
1019
1111
  // AC-15), which may be neither the lead's nor the fast tier's — so the
@@ -1950,8 +2042,15 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1950
2042
  // composer's hint fade. An auto-continuation demonstrates nothing about what
1951
2043
  // the user has learned, so suppressing the fade is correct rather than
1952
2044
  // incidental — which is why the parameter is named for the CAUSE (P2-8).
1953
- if (opts.userInitiated !== false)
2045
+ if (opts.userInitiated !== false) {
1954
2046
  recordPrompt(message);
2047
+ // THE ONE PLACE THE NUDGE BUDGET IS REFILLED (P0-2 / I-10). Same predicate
2048
+ // as `recordPrompt`, and for the same reason: the question is "did a human
2049
+ // ask for this?", and only a human may hand the gate another attempt. A
2050
+ // nudge continuation arrives here with `userInitiated: false`, so it cannot
2051
+ // refill its own budget.
2052
+ nudgesRef.current = 0;
2053
+ }
1955
2054
  // Mirrors where the reducer clears its own copies, in the same action
1956
2055
  // (C-11): a new turn's end reason must not inherit the previous one's.
1957
2056
  endReasonRef.current = { aborted: false, errored: false };
@@ -2960,7 +3059,13 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2960
3059
  // the save handler cannot disagree about what `(inherit)` means.
2961
3060
  ...fastSettingsFrom(cfg.fast),
2962
3061
  ...compactionSettingsFrom(cfg.compaction),
2963
- }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: viewCols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, draft: settingsDraft, probe: probe, onProfileSave: handleProfileSave, onReload: () => controller.createModelSettingsDraft(), onClose: () => dispatch({ type: 'setOverlay', overlay: null }),
3062
+ }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: viewCols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, draft: settingsDraft,
3063
+ // THE MISSING LINK (P1-5): without it the `Team` page cannot render
3064
+ // `on (next launch)` for a `--no-team` session, because the subpage is
3065
+ // mounted with `{...props}` and an undeclared prop arrives as
3066
+ // `undefined`. A construction-time snapshot, so reading it per frame is
3067
+ // honest (I-7).
3068
+ teamRegistered: controller.isTeamRegistered(), probe: probe, onProfileSave: handleProfileSave, onReload: () => controller.createModelSettingsDraft(), onClose: () => dispatch({ type: 'setOverlay', overlay: null }),
2964
3069
  // Read from `cfg` (render scope, re-read every frame), so `/retry off`
2965
3070
  // while the screen is open shows the new value on the next paint.
2966
3071
  retrySummary: cfg.retry.enabled && cfg.retry.maxRetries > 0 ? String(cfg.retry.maxRetries) : 'off' })) : overlay === 'agents' ? (
@@ -3070,6 +3175,13 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
3070
3175
  railRows: native ? (nativeBudget?.band.railRows ?? 0) : (fullscreenRailVisible ? railRows : 0),
3071
3176
  width: native ? (nativeBudget?.band.railCols ?? 0) : railWidth,
3072
3177
  });
3178
+ // THE FORCED-MODE CHIP'S SOURCE (team-command-forced-dispatch §3.8 / D-9),
3179
+ // read from the controller on every frame: `armForcedTeam()` writes no
3180
+ // `ViewState`, so a mirror would have to be kept in step by hand - which is the
3181
+ // double-truth this design deletes elsewhere. It is handed to `StatusBar`, which
3182
+ // plans the primary row from its own props (a copy on `statusInput` above would
3183
+ // be a field nothing renders).
3184
+ const forcedTeam = controller.getForcedTeam();
3073
3185
  const statusInput = {
3074
3186
  columns: viewCols, phase: state.runPhase, pendingCount: state.pendingSteering.length,
3075
3187
  context: state.context, usageTotal: state.usageTotal, thinkingLevel: cfg.thinkingLevel,
@@ -3138,7 +3250,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
3138
3250
  // partially stale. The window and its `known` flag now travel INSIDE
3139
3251
  // it, resolved by the meter - which is what makes the user's
3140
3252
  // `contextWindow` override reach the bar.
3141
- context: state.context, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode, ecoRung: governor.rung, ...(state.team
3253
+ context: state.context, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode, ...(forcedTeam ? { forcedTeam } : {}), ecoRung: governor.rung, ...(state.team
3142
3254
  ? {
3143
3255
  // `rosterCounts`, NOT a fourth inline phase filter (A-1): the chip and
3144
3256
  // the roster header above it must never disagree.
@@ -3153,7 +3265,14 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
3153
3265
  ? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
3154
3266
  : {}), ...(acceptanceActive ? { acceptanceActive } : {}), ...(liveServices > 0 ? { servicesActive: { live: liveServices } } : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}),
3155
3267
  // Manual compaction starts before its next snapshot is published.
3156
- compactionActive: { inFlight: statusInput.compacting ?? false }, ...(controller.isCompactionEnabled()
3268
+ // THE AUTO-PAUSED CHIP RIDES HERE AND NOWHERE ELSE (never-fires C /
3269
+ // analysis 3.3.1): `selfDisabled` used to live only in `/compact status`,
3270
+ // a command nobody types while the gauge sits at 95 %. `StatusBar` plans
3271
+ // the primary row from its own props, so a second copy of the flag on
3272
+ // `statusInput` would be a field nothing renders - and a fact with two
3273
+ // sources is a fact that drifts.
3274
+ compactionActive: { inFlight: statusInput.compacting ?? false,
3275
+ selfDisabled: state.compaction?.selfDisabled === true }, ...(controller.isCompactionEnabled()
3157
3276
  ? {
3158
3277
  gaugeMarks: {
3159
3278
  warn: cfg.compaction.warnThreshold * 100,
@@ -65,6 +65,20 @@ export interface StatusBarProps {
65
65
  } | null;
66
66
  agentMode?: AgentMode;
67
67
  pendingAgentMode?: AgentMode | null;
68
+ /**
69
+ * Forced team mode is armed, with the count it was armed with
70
+ * (team-command-forced-dispatch §3.8 / D-9). ABSENT = not armed, and every
71
+ * existing caller renders the row it always did.
72
+ *
73
+ * IT RIDES THIS COMPONENT'S PROPS AND NOT THE APP'S `statusInput`, by the rule
74
+ * the `compactionActive` chip records where `App` builds it: this component
75
+ * plans the primary row from its OWN props, so a chip left on `statusInput`
76
+ * would be a field nothing renders - which is exactly how the only persistently
77
+ * visible signal of `/team` (D-9) can ship without ever appearing on screen.
78
+ */
79
+ forcedTeam?: {
80
+ count: number;
81
+ };
68
82
  teamActive?: {
69
83
  running: number;
70
84
  total: number;
@@ -92,6 +106,7 @@ export interface StatusBarProps {
92
106
  };
93
107
  compactionActive?: {
94
108
  inFlight: boolean;
109
+ selfDisabled?: boolean;
95
110
  };
96
111
  gaugeMarks?: {
97
112
  warn: number;
@@ -39,7 +39,9 @@ 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,
44
+ ...(props.forcedTeam ? { forcedTeam: props.forcedTeam } : {}),
43
45
  teamActive: props.teamActive, todoActive: props.todoActive,
44
46
  ...(props.acceptanceActive ? { acceptanceActive: props.acceptanceActive } : {}),
45
47
  ecoRung: props.ecoRung, fastActive: props.fastActive, scrolledLines: props.scrolledLines,
@@ -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;
@@ -43,6 +53,23 @@ export interface StatusLayoutInput {
43
53
  servicesActive?: {
44
54
  live: number;
45
55
  };
56
+ /**
57
+ * Forced team mode is ARMED, with the fan-out count it was armed with
58
+ * (team-command-forced-dispatch §3.8 / D-9).
59
+ *
60
+ * THE ONLY PERSISTENTLY VISIBLE SIGNAL that `/team` happened. The directive is
61
+ * one turn, the prompt block is invisible, and the panel only appears while a
62
+ * dispatch runs - so without this chip the answer to "why is my agent
63
+ * dispatching?" is "remember what you typed". It is read at RENDER time from
64
+ * `controller.getForcedTeam()`, never mirrored into `ViewState`: a mirror is a
65
+ * second copy that can disagree, the exact defect §2.4 is about.
66
+ *
67
+ * ABSENT means "not armed" and keeps every existing caller's plan BYTE-
68
+ * IDENTICAL (the same discipline `reserveRight` records below).
69
+ */
70
+ forcedTeam?: {
71
+ count: number;
72
+ };
46
73
  teamActive?: {
47
74
  running: number;
48
75
  total: 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';
@@ -204,11 +220,21 @@ export function planPrimaryStatusFields(input) {
204
220
  const label = (notification ? undefined : feedbackLabel(input, form))
205
221
  ?? (full ? labels[1] : rung >= 4 ? labels[0].slice(0, 4) : labels[0]);
206
222
  const modeText = modeFieldText(input.mode, input.pendingMode, full);
223
+ // IMMEDIATELY AFTER `mode`, and kept at EVERY rung: both describe a state
224
+ // the USER armed, so the row's emphasis budget goes on them and nothing
225
+ // else (`accent`). It shortens to `T4` instead of disappearing because a
226
+ // chip that vanishes on a narrow terminal is indistinguishable from a
227
+ // disarmed session - the one question it exists to answer.
228
+ const forcedText = input.forcedTeam
229
+ ? (rung >= 3 ? `T${input.forcedTeam.count}` : `Team ${input.forcedTeam.count}`)
230
+ : null;
207
231
  return statusLinePlan([
208
232
  statusField('phase', glyph + (rung >= 5 ? '' : ' ') + label, phaseTone),
209
233
  ...(notice ? [statusField('feedback', notice, feedbackTone)] : []),
210
234
  ...(modeText ? [statusField('mode', modeText, 'accent')] : []),
211
- statusField('context', (full ? 'Context ' : 'C') + pct),
235
+ ...(forcedText ? [statusField('forcedTeam', forcedText, 'accent')] : []),
236
+ statusField('context', (full ? 'Context ' : 'C') + pct
237
+ + (input.compactionPaused === true ? pausedSuffix(full) : '')),
212
238
  statusField('thinking', full ? 'Think ' + (thinkingCodes[input.thinkingLevel] ? input.thinkingLevel : '?')
213
239
  : (rung >= 2 ? '' : 'Th:') + code),
214
240
  statusField('speed', speed + (full ? ' tok/s' : rung >= 3 ? '/s' : 't/s')),
@@ -121,7 +121,7 @@ const COMMANDS = [
121
121
  ['/theme <name>', 'auto | warm | cool | light'],
122
122
  ['/thinking <level>', 'Set thinking level'],
123
123
  ['/max-tokens [n|auto]', 'Output token cap; no argument reports the effective one'],
124
- ['/team [on|off|max n]', 'Team subagents: status, switch, fan-out width'],
124
+ ['/team [on|off|<brief>]', 'Force team mode and start now: /team <brief> fans out'],
125
125
  ['/fast [on|off|model|review]', 'Fast model tier: status, switch, model, review cadence'],
126
126
  ['/kit [list|use <name>]', 'Agent kits: what is in effect, list them, use one for this session'],
127
127
  [
@@ -179,12 +179,16 @@ const PLAN_ROWS = [
179
179
  ];
180
180
  const TEAM_ROWS = [
181
181
  ['task', 'The tool the agent uses to run 2-5 subagents in parallel'],
182
- ['/team', 'Status: on/off, max subagents, and the live roster'],
183
- ['/team on | off', 'Switch team mode for this session (and save it)'],
184
- ['/team max <n>', 'Fan-out width, 1-10 (5 by default)'],
182
+ ['/team <brief>', 'Force it: arms forced mode and starts that work as a turn'],
183
+ ['/team on | off', 'Arm it without starting, or leave it; the capability is untouched'],
184
+ ['/team', 'Report the forced state, capacity, the model, and where to change it'],
185
+ ['-n <n> / --parts a,b', 'Fix the fan-out, or name the split (narrowing is disclosed)'],
186
+ ['--read-only', 'Every child refuses writes and shell, for research-only fan-outs'],
187
+ ['Team 4 (T4)', 'The status-bar chip - only while the session is armed'],
185
188
  ['--team / --no-team', 'Start a session with team mode on or off (also ARAGON_TEAM=0)'],
186
189
  ['team_send / team_wait', 'How subagents message each other; 6 each, one every 15s'],
187
190
  ['Ctrl+B / /agents', 'The board: every child, its full facts, its 24-event timeline'],
191
+ ['Settings > Team', 'Team mode, width (1-10, 8 by default), concurrency, supervisor'],
188
192
  ];
189
193
  const FAST_ROWS = [
190
194
  ['/fast', 'Status: on/off, the model, the cadence, and this session\'s totals'],
@@ -132,6 +132,20 @@ interface SettingsScreenProps {
132
132
  * screen. Absent = omit the row entirely.
133
133
  */
134
134
  retrySummary?: string;
135
+ /**
136
+ * Whether `task` is in this SESSION's tool list, for the `Team` page's
137
+ * `on (next launch)` disclosure (team-command-forced-dispatch §3.9 / P1-5).
138
+ *
139
+ * WITHOUT THIS LINK THE DISCLOSURE CANNOT RENDER. The page is mounted with
140
+ * `{...props}`, so a prop the screen does not declare reaches it as `undefined`
141
+ * and `--no-team` users are silently told their saved `on` took effect. The
142
+ * value is a CONSTRUCTION-TIME snapshot (`--no-team` cannot be undone), which is
143
+ * why reading it every frame does not conflict with I-7.
144
+ *
145
+ * OPTIONAL, like `probe` / `retrySummary` above: every fixture builds this by
146
+ * literal, and absent honestly means "unknown, so do not disclose".
147
+ */
148
+ teamRegistered?: boolean;
135
149
  }
136
150
  /**
137
151
  * The derived, non-editable line under the five fast rows — the sibling of