@aragon-agent/cli 0.6.4 → 0.6.8

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 (119) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +163 -37
  3. package/dist/agent/controller.d.ts +49 -8
  4. package/dist/agent/controller.js +160 -48
  5. package/dist/agent/headless.d.ts +2 -1
  6. package/dist/agent/prompt-options.d.ts +15 -0
  7. package/dist/agent/prompt-options.js +1 -0
  8. package/dist/agent/reducer.d.ts +33 -19
  9. package/dist/agent/reducer.js +62 -71
  10. package/dist/agent/usage.d.ts +34 -0
  11. package/dist/agent/usage.js +43 -0
  12. package/dist/cli.js +131 -54
  13. package/dist/commands/builtins.js +12 -1
  14. package/dist/commands/registry.d.ts +2 -3
  15. package/dist/compaction/compactor.d.ts +60 -24
  16. package/dist/compaction/compactor.js +89 -61
  17. package/dist/compaction/context-command.d.ts +33 -0
  18. package/dist/compaction/context-command.js +166 -0
  19. package/dist/compaction/meter.d.ts +251 -0
  20. package/dist/compaction/meter.js +428 -0
  21. package/dist/compaction/pressure.d.ts +6 -0
  22. package/dist/compaction/pressure.js +4 -0
  23. package/dist/compaction/types.d.ts +41 -0
  24. package/dist/compaction/wiring.d.ts +47 -0
  25. package/dist/compaction/wiring.js +88 -27
  26. package/dist/config/cli-commands.js +5 -0
  27. package/dist/config/env.js +15 -1
  28. package/dist/config/load.d.ts +17 -0
  29. package/dist/config/load.js +45 -1
  30. package/dist/config/schema.d.ts +44 -0
  31. package/dist/config/schema.js +36 -0
  32. package/dist/config/store.js +27 -1
  33. package/dist/exec/events.d.ts +138 -1
  34. package/dist/exec/events.js +42 -0
  35. package/dist/exec/index.js +43 -6
  36. package/dist/exec/progress-reporter.d.ts +23 -0
  37. package/dist/exec/progress-reporter.js +88 -0
  38. package/dist/exec/runner.d.ts +34 -0
  39. package/dist/exec/runner.js +137 -25
  40. package/dist/fast/reviewer.d.ts +19 -1
  41. package/dist/fast/reviewer.js +22 -0
  42. package/dist/fast/types.d.ts +15 -0
  43. package/dist/fast/wiring.d.ts +10 -1
  44. package/dist/fast/wiring.js +18 -1
  45. package/dist/input/interrupt-gesture.d.ts +18 -0
  46. package/dist/input/interrupt-gesture.js +19 -0
  47. package/dist/input/pointer-router.d.ts +11 -0
  48. package/dist/input/pointer-router.js +14 -0
  49. package/dist/todo/limits.d.ts +4 -17
  50. package/dist/todo/limits.js +4 -17
  51. package/dist/todo/panel-rows.d.ts +8 -6
  52. package/dist/todo/panel-rows.js +19 -6
  53. package/dist/todo/store.d.ts +8 -13
  54. package/dist/todo/store.js +13 -14
  55. package/dist/todo/types.d.ts +2 -1
  56. package/dist/ui/App.d.ts +2 -0
  57. package/dist/ui/App.js +233 -183
  58. package/dist/ui/AutocompletePopup.d.ts +3 -1
  59. package/dist/ui/AutocompletePopup.js +9 -11
  60. package/dist/ui/BottomStatusRow.d.ts +1 -1
  61. package/dist/ui/Composer.d.ts +13 -5
  62. package/dist/ui/Composer.js +7 -11
  63. package/dist/ui/PromptCaret.d.ts +11 -0
  64. package/dist/ui/PromptCaret.js +31 -0
  65. package/dist/ui/PromptInput.d.ts +12 -6
  66. package/dist/ui/PromptInput.js +50 -16
  67. package/dist/ui/StatusBar.d.ts +22 -15
  68. package/dist/ui/StatusBar.js +61 -16
  69. package/dist/ui/TeamPanel.d.ts +3 -1
  70. package/dist/ui/TeamPanel.js +13 -9
  71. package/dist/ui/TodoPanel.d.ts +5 -5
  72. package/dist/ui/TodoPanel.js +56 -24
  73. package/dist/ui/Transcript.js +1 -0
  74. package/dist/ui/UpdateLine.d.ts +1 -1
  75. package/dist/ui/capabilities.js +5 -5
  76. package/dist/ui/composer-rows.js +50 -9
  77. package/dist/ui/exit-snapshot.d.ts +9 -0
  78. package/dist/ui/frame-differ.js +2 -19
  79. package/dist/ui/frame-observer.d.ts +17 -0
  80. package/dist/ui/frame-observer.js +81 -0
  81. package/dist/ui/frame-parser.d.ts +7 -0
  82. package/dist/ui/frame-parser.js +18 -0
  83. package/dist/ui/layout/AppShell.d.ts +7 -52
  84. package/dist/ui/layout/AppShell.js +2 -2
  85. package/dist/ui/layout/ScrollIndicator.d.ts +4 -3
  86. package/dist/ui/layout/ScrollIndicator.js +4 -4
  87. package/dist/ui/layout/ScrollViewport.d.ts +13 -66
  88. package/dist/ui/layout/ScrollViewport.js +183 -185
  89. package/dist/ui/layout/autocomplete.d.ts +18 -0
  90. package/dist/ui/layout/autocomplete.js +26 -0
  91. package/dist/ui/layout/budget.d.ts +3 -51
  92. package/dist/ui/layout/budget.js +4 -53
  93. package/dist/ui/layout/document-layout.d.ts +13 -0
  94. package/dist/ui/layout/document-layout.js +11 -0
  95. package/dist/ui/layout/follow-state.d.ts +1 -0
  96. package/dist/ui/layout/follow-state.js +1 -3
  97. package/dist/ui/layout/rail.d.ts +4 -53
  98. package/dist/ui/layout/rail.js +10 -62
  99. package/dist/ui/layout/scroll-indicator.d.ts +1 -1
  100. package/dist/ui/layout/scroll-indicator.js +3 -2
  101. package/dist/ui/layout/scrollbar-geometry.d.ts +17 -0
  102. package/dist/ui/layout/scrollbar-geometry.js +13 -0
  103. package/dist/ui/layout/team-panel.d.ts +18 -0
  104. package/dist/ui/layout/team-panel.js +22 -0
  105. package/dist/ui/layout/todo-layout.d.ts +25 -0
  106. package/dist/ui/layout/todo-layout.js +28 -0
  107. package/dist/ui/layout/viewport-geometry.d.ts +1 -0
  108. package/dist/ui/layout/viewport-geometry.js +1 -0
  109. package/dist/ui/layout/virtual-window.d.ts +1 -0
  110. package/dist/ui/layout/virtual-window.js +1 -1
  111. package/dist/ui/overlays/HelpOverlay.js +5 -2
  112. package/dist/ui/screen.d.ts +1 -1
  113. package/dist/ui/screen.js +4 -6
  114. package/dist/ui/scrollbar-controller.d.ts +30 -0
  115. package/dist/ui/scrollbar-controller.js +124 -0
  116. package/dist/ui/transcript-text.js +6 -2
  117. package/dist/ui/use-wheel-routing.d.ts +2 -0
  118. package/dist/ui/use-wheel-routing.js +6 -0
  119. package/package.json +2 -2
@@ -11,11 +11,25 @@
11
11
  * synthesizes a generic error notice (a swallowed throw is never a no-op).
12
12
  */
13
13
  import process from 'node:process';
14
- import { occupiedTokens } from '../compaction/pressure.js';
15
- import { computeCost } from './usage.js';
14
+ import { emptyContextUsage } from '../compaction/meter.js';
15
+ import { addUsage, computeCost } from './usage.js';
16
16
  import { ENTRY_LIMITS, appendBounded, entryRetain, trimEntries } from './entry-limits.js';
17
17
  /** Default auto-dismiss window for a toast. */
18
18
  export const DEFAULT_TOAST_TTL_MS = 2500;
19
+ /**
20
+ * The zero session total.
21
+ *
22
+ * ONE LITERAL FOR TWO SITES (`initialViewState` and `resetConversation`). They
23
+ * used to spell the object out twice, which is how `cacheReadTokens` would have
24
+ * been added to one of them and not the other.
25
+ */
26
+ const EMPTY_USAGE_TOTAL = {
27
+ inputTokens: 0,
28
+ outputTokens: 0,
29
+ cacheReadTokens: 0,
30
+ cacheWriteTokens: 0,
31
+ costUsd: 0,
32
+ };
19
33
  /**
20
34
  * The initial view state, optionally seeded from config.
21
35
  *
@@ -33,8 +47,8 @@ export function initialViewState(seed = {}) {
33
47
  return {
34
48
  entries: [],
35
49
  status: 'idle',
36
- usageTotal: { inputTokens: 0, outputTokens: 0, costUsd: 0 },
37
- contextTokens: 0,
50
+ usageTotal: EMPTY_USAGE_TOTAL,
51
+ context: emptyContextUsage(),
38
52
  overlay: null,
39
53
  thinkingVisible: seed.thinkingVisible ?? false,
40
54
  toasts: [],
@@ -48,7 +62,6 @@ export function initialViewState(seed = {}) {
48
62
  retry: null,
49
63
  fast: null,
50
64
  compaction: null,
51
- contextTokensEstimated: false,
52
65
  seq: 0,
53
66
  teamEntryId: undefined,
54
67
  todoEntryId: undefined,
@@ -610,22 +623,12 @@ export function viewReducer(state, action) {
610
623
  ...state,
611
624
  streamingId: undefined,
612
625
  turnProduced: true,
613
- // ONE FUNCTION, SHARED WITH THE COMPACTION TRIGGER (§3.4.1 / R-11 /
614
- // AC-3). The old expression — `inputTokens + outputTokens` — silently
615
- // UNDER-reports for anyone behind a caching gateway, because Anthropic
616
- // reports `input_tokens` EXCLUDING cached tokens and puts them in two
617
- // separate fields. That was a live defect in the number this gauge shows;
618
- // it becomes a much worse one the moment a trigger fires on a different
619
- // number from the one the user is watching.
620
- contextTokens: occupiedTokens(action.usage),
621
- // A MEASUREMENT SUPERSEDES A DERIVATION (§6.2). `compactionEnd` writes an
622
- // estimate so the bar falls immediately; this is where it stops being one.
623
- contextTokensEstimated: false,
624
- usageTotal: {
625
- inputTokens: state.usageTotal.inputTokens + action.usage.inputTokens,
626
- outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
627
- costUsd: state.usageTotal.costUsd + action.costDelta,
628
- },
626
+ // `contextTokens` IS NOT WRITTEN HERE ANY MORE (I-1). The occupancy this
627
+ // turn produced reaches the view through `ContextMeter`, which subscribes
628
+ // to the same `turn_end` and publishes the measured pressure - window,
629
+ // percentage and approximation markers included, none of which this
630
+ // branch ever had. Two branches writing one number is what P0-1 was.
631
+ usageTotal: addUsage(state.usageTotal, action.usage, action.costDelta),
629
632
  // The turn produced an answer, so whatever it had to retry, it recovered.
630
633
  ...settleRetryCard(state, entries, 'recovered'),
631
634
  };
@@ -763,8 +766,13 @@ export function viewReducer(state, action) {
763
766
  droppedEntries: 0,
764
767
  streamingId: undefined,
765
768
  expandedToolIds: {},
766
- contextTokens: 0,
767
- usageTotal: { inputTokens: 0, outputTokens: 0, costUsd: 0 },
769
+ // `context` IS DELIBERATELY UNTOUCHED (I-1). `builtins.ts` calls
770
+ // `controller.clearMessages()` BEFORE dispatching this, and that marks
771
+ // the meter dirty and schedules a publication; zeroing here would make
772
+ // the final state `0` rather than the meter's real answer - which for an
773
+ // emptied conversation is the system prompt's own couple of percent, not
774
+ // nothing. `usageTotal` DOES reset: that is session spend, not occupancy.
775
+ usageTotal: EMPTY_USAGE_TOTAL,
768
776
  teamEntryId: undefined,
769
777
  // `/reset` clears `messages`, so the belief is gone and the list goes
770
778
  // with it — the other half of I-2. `controller.clearMessages()` clears
@@ -774,10 +782,6 @@ export function viewReducer(state, action) {
774
782
  retryEntryId: undefined,
775
783
  retry: null,
776
784
  compactionEntryId: undefined,
777
- // `contextTokens` is zeroed above, so the flag that says it is a
778
- // DERIVATION has to go with it — otherwise a fresh conversation renders
779
- // `~0%` and the tilde is a lie about a number that is simply zero.
780
- contextTokensEstimated: false,
781
785
  };
782
786
  case 'setOverlay':
783
787
  return { ...state, overlay: action.overlay };
@@ -860,18 +864,11 @@ export function viewReducer(state, action) {
860
864
  }
861
865
  case 'teamUsage':
862
866
  // `usageTotal` AND NOTHING ELSE (§3.9 / AC-15). In particular NOT
863
- // `contextTokens`: that gauge shows the LEAD's context occupancy against
867
+ // `context`: that gauge shows the LEAD's context occupancy against
864
868
  // the model's window, and folding five children into it would read 180%
865
869
  // on a perfectly healthy session. Child spend is real money and has to
866
870
  // appear in the cost readout; it is not the lead's context.
867
- return {
868
- ...state,
869
- usageTotal: {
870
- inputTokens: state.usageTotal.inputTokens + action.usage.inputTokens,
871
- outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
872
- costUsd: state.usageTotal.costUsd + action.costDelta,
873
- },
874
- };
871
+ return { ...state, usageTotal: addUsage(state.usageTotal, action.usage, action.costDelta) };
875
872
  case 'teamEnd': {
876
873
  const { outcome } = action;
877
874
  const entries = state.teamEntryId
@@ -991,17 +988,10 @@ export function viewReducer(state, action) {
991
988
  }
992
989
  case 'fastUsage':
993
990
  // `usageTotal` AND NOTHING ELSE, exactly as `teamUsage` (§3.6 / AC-18). In
994
- // particular NOT `contextTokens`: that gauge shows the LEAD's context
991
+ // particular NOT `context`: that gauge shows the LEAD's context
995
992
  // occupancy against the lead model's window, and a review is a separate
996
993
  // conversation with a different model entirely.
997
- return {
998
- ...state,
999
- usageTotal: {
1000
- inputTokens: state.usageTotal.inputTokens + action.usage.inputTokens,
1001
- outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
1002
- costUsd: state.usageTotal.costUsd + action.costDelta,
1003
- },
1004
- };
994
+ return { ...state, usageTotal: addUsage(state.usageTotal, action.usage, action.costDelta) };
1005
995
  case 'fastTier':
1006
996
  return { ...state, fast: action.snapshot };
1007
997
  // --- Context compaction (context-auto-compaction §5.5) ----------------
@@ -1128,40 +1118,41 @@ export function viewReducer(state, action) {
1128
1118
  }
1129
1119
  case 'compactionUsage':
1130
1120
  // `usageTotal` AND NOTHING ELSE, exactly as `teamUsage` and `fastUsage`. In
1131
- // particular NOT `contextTokens`: that gauge shows the LEAD's context
1121
+ // particular NOT `context`: that gauge shows the LEAD's context
1132
1122
  // occupancy against the lead model's window, and a summarization is a
1133
1123
  // separate conversation with a possibly different model entirely.
1134
- return {
1135
- ...state,
1136
- usageTotal: {
1137
- inputTokens: state.usageTotal.inputTokens + action.usage.inputTokens,
1138
- outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
1139
- costUsd: state.usageTotal.costUsd + action.costDelta,
1140
- },
1141
- };
1124
+ return { ...state, usageTotal: addUsage(state.usageTotal, action.usage, action.costDelta) };
1142
1125
  case 'compactionSnapshot':
1143
1126
  return { ...state, compaction: action.snapshot };
1144
1127
  /**
1145
- * THE GAUGE MUST FALL IMMEDIATELY (§6.2 / AC-5).
1128
+ * THE ONE WRITER OF `state.context` (I-1).
1146
1129
  *
1147
- * `contextTokens` is otherwise only written at `turnEnd`, so a compaction
1148
- * that took the session from 92 % to 24 % would leave the bar at 92 % until
1149
- * the next turn completes — and the user would reasonably conclude that
1150
- * nothing happened. This is a small change and it is the difference between
1151
- * the feature feeling real and feeling broken.
1130
+ * THE IDENTITY SHORT-CIRCUIT BELOW IS LOAD-BEARING, NOT A MICRO-OPTIMISATION
1131
+ * (§5.2 / R-3). The meter publishes on a 400 ms tick throughout a turn and
1132
+ * most of those ticks measure the same number - the history did not change
1133
+ * between two tool calls of the same size, or the percentage rounded to the
1134
+ * same integer. Returning the SAME state object lets React skip the whole
1135
+ * subtree; returning a fresh one would make a permanently visible row into a
1136
+ * 2.5 Hz re-render source and walk the render governor up its ladder for no
1137
+ * visible change at all.
1152
1138
  *
1153
- * The value arrives ALREADY CALIBRATED from `App` (§6.2 / P1-11): it replaces
1154
- * a MEASUREMENT that included the tool schemas, and core's raw estimator
1155
- * never counts them, so writing the raw figure would make the bar fall too
1156
- * far and then visibly climb again on the next `turn_end` for no reason the
1157
- * user can see.
1139
+ * `ContextUsageSnapshot` carries no timestamp for exactly this reason: a
1140
+ * field that moves every tick would make this comparison never hold.
1158
1141
  */
1159
- case 'contextTokensEstimated':
1160
- return {
1161
- ...state,
1162
- contextTokens: Math.max(0, Math.round(action.tokens)),
1163
- contextTokensEstimated: true,
1164
- };
1142
+ case 'contextUsage': {
1143
+ const prev = state.context;
1144
+ const next = action.snapshot;
1145
+ if (prev.occupied === next.occupied &&
1146
+ prev.window === next.window &&
1147
+ prev.pct === next.pct &&
1148
+ prev.source === next.source &&
1149
+ prev.deltaTokens === next.deltaTokens &&
1150
+ prev.windowKnown === next.windowKnown &&
1151
+ prev.windowOverridden === next.windowOverridden) {
1152
+ return state;
1153
+ }
1154
+ return { ...state, context: next };
1155
+ }
1165
1156
  // --- API retry (llm-api-retry-backoff §6.4) ---------------------------
1166
1157
  case 'retryScheduled': {
1167
1158
  const snapshot = {
@@ -3,8 +3,42 @@
3
3
  * (which is expressed in USD per 1M tokens). See spec §6.2 status bar.
4
4
  */
5
5
  import type { ModelCost, TokenUsage } from '@aragon-agent/core';
6
+ import type { UsageTotal } from './reducer.js';
6
7
  /** Compute the USD cost of a single `TokenUsage` given a model's cost table. */
7
8
  export declare function computeCost(usage: TokenUsage, cost?: ModelCost): number;
9
+ /**
10
+ * Fold one `TokenUsage` into a running session total
11
+ * (context-usage-gauge-accuracy §3.6 / W5).
12
+ *
13
+ * THE ONE ADDITION SITE, and collapsing four hand-written copies into it is the
14
+ * point rather than a tidy-up. The reducer had the same three lines in four
15
+ * branches - `turnEnd`, `teamUsage`, `fastUsage`, `compactionUsage` - and all
16
+ * four silently omitted the cache terms, so the status bar's `^` read LOWER than
17
+ * the `$` beside it on any session behind a caching gateway. A fifth source
18
+ * added later would have made the identical omission; now it cannot.
19
+ *
20
+ * `costDelta` IS PRECOMPUTED BY THE CALLER, exactly as the four branches already
21
+ * required: the summarizer and the fast tier may be different models with
22
+ * different price tables, and a reducer that priced its own would have to know
23
+ * about three of them.
24
+ */
25
+ export declare function addUsage(total: UsageTotal, usage: TokenUsage, costDelta: number): UsageTotal;
26
+ /**
27
+ * The number the status bar's `^` shows: the PROMPT side of the session total.
28
+ *
29
+ * DELIBERATELY THE SAME TERMS AS `occupiedTokens` MINUS `outputTokens`
30
+ * (`compaction/pressure.ts`). Anthropic reports `input_tokens` EXCLUDING cached
31
+ * tokens and puts them in two separate fields, so `inputTokens` alone
32
+ * under-reports for anyone behind a caching gateway - while `computeCost` above
33
+ * has always priced all three. That is the "three units on one row" defect
34
+ * (P1-3): the percentage counted cache, the dollars counted cache, and the arrow
35
+ * did not.
36
+ *
37
+ * `$` IS STILL NOT THE SAME TOTAL, and the distinction is worth keeping straight
38
+ * (RV-14): `computeCost` also prices OUTPUT tokens. What this function asserts is
39
+ * narrower and exact - both cache terms are counted, on both sides.
40
+ */
41
+ export declare function promptTokensOf(total: UsageTotal): number;
8
42
  /** Format a USD amount for the status bar (`$0.0000`, or `$1.23` when large). */
9
43
  export declare function formatCost(usd: number): string;
10
44
  /** Format a token count compactly (`1.2k`, `3.4M`). */
@@ -12,6 +12,49 @@ export function computeCost(usage, cost) {
12
12
  perM(usage.cacheReadTokens ?? 0, cost.cacheRead) +
13
13
  perM(usage.cacheWriteTokens ?? 0, cost.cacheWrite));
14
14
  }
15
+ /**
16
+ * Fold one `TokenUsage` into a running session total
17
+ * (context-usage-gauge-accuracy §3.6 / W5).
18
+ *
19
+ * THE ONE ADDITION SITE, and collapsing four hand-written copies into it is the
20
+ * point rather than a tidy-up. The reducer had the same three lines in four
21
+ * branches - `turnEnd`, `teamUsage`, `fastUsage`, `compactionUsage` - and all
22
+ * four silently omitted the cache terms, so the status bar's `^` read LOWER than
23
+ * the `$` beside it on any session behind a caching gateway. A fifth source
24
+ * added later would have made the identical omission; now it cannot.
25
+ *
26
+ * `costDelta` IS PRECOMPUTED BY THE CALLER, exactly as the four branches already
27
+ * required: the summarizer and the fast tier may be different models with
28
+ * different price tables, and a reducer that priced its own would have to know
29
+ * about three of them.
30
+ */
31
+ export function addUsage(total, usage, costDelta) {
32
+ return {
33
+ inputTokens: total.inputTokens + usage.inputTokens,
34
+ outputTokens: total.outputTokens + usage.outputTokens,
35
+ cacheReadTokens: total.cacheReadTokens + (usage.cacheReadTokens ?? 0),
36
+ cacheWriteTokens: total.cacheWriteTokens + (usage.cacheWriteTokens ?? 0),
37
+ costUsd: total.costUsd + costDelta,
38
+ };
39
+ }
40
+ /**
41
+ * The number the status bar's `^` shows: the PROMPT side of the session total.
42
+ *
43
+ * DELIBERATELY THE SAME TERMS AS `occupiedTokens` MINUS `outputTokens`
44
+ * (`compaction/pressure.ts`). Anthropic reports `input_tokens` EXCLUDING cached
45
+ * tokens and puts them in two separate fields, so `inputTokens` alone
46
+ * under-reports for anyone behind a caching gateway - while `computeCost` above
47
+ * has always priced all three. That is the "three units on one row" defect
48
+ * (P1-3): the percentage counted cache, the dollars counted cache, and the arrow
49
+ * did not.
50
+ *
51
+ * `$` IS STILL NOT THE SAME TOTAL, and the distinction is worth keeping straight
52
+ * (RV-14): `computeCost` also prices OUTPUT tokens. What this function asserts is
53
+ * narrower and exact - both cache terms are counted, on both sides.
54
+ */
55
+ export function promptTokensOf(total) {
56
+ return total.inputTokens + total.cacheReadTokens + total.cacheWriteTokens;
57
+ }
15
58
  /** Format a USD amount for the status bar (`$0.0000`, or `$1.23` when large). */
16
59
  export function formatCost(usd) {
17
60
  if (usd <= 0)
package/dist/cli.js CHANGED
@@ -27,7 +27,7 @@ import { COMPACTION_CONFIG_SET_KEYS, FAST_CONFIG_SET_KEYS, LOG_CONFIG_SET_KEYS,
27
27
  import { attachAgentEvents, attachTeamEvents, installLogging, setScreenRestore, setSignalTerminator, } from './logging/install.js';
28
28
  import { getLogger } from './logging/logger.js';
29
29
  import { runLogsCommand } from './logging/cli-commands.js';
30
- import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, parseMaxTokensInput, DEFAULT_MAX_RENDER_INTERVAL_MS, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SCROLL_RESUME_MS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
30
+ import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampContextWindow, clampMaxRenderInterval, 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_RESUME_MS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
31
31
  import { makeGetApiKey } from './config/load.js';
32
32
  import { AgentController, DENY_ALL_APPROVAL } from './agent/controller.js';
33
33
  import { runSkillsCommand } from './skills/cli-commands.js';
@@ -45,6 +45,9 @@ import { createFrameDiffer } from './ui/frame-differ.js';
45
45
  import { wrapStdoutForFrames } from './ui/stdout-frame-writer.js';
46
46
  import { setFrameStatsProvider } from './commands/perf.js';
47
47
  import { tryCreateStdinFilter } from './input/stdin-filter.js';
48
+ import { createScrollbarBridge } from './ui/scrollbar-controller.js';
49
+ import { createPointerRouter } from './input/pointer-router.js';
50
+ import { createFrameObserver } from './ui/frame-observer.js';
48
51
  import { enterAltScreen, writeExitTranscript } from './ui/screen.js';
49
52
  import { supportsWindowsVtInput } from './ui/win-vt-input.js';
50
53
  import { forceWindowsVtInput } from './ui/win-vt-force.js';
@@ -153,6 +156,11 @@ function toFlags(opts) {
153
156
  fastModel: opts.fastModel,
154
157
  fastProvider: opts.fastProvider,
155
158
  fastReview: opts.fastReview,
159
+ // …and again for `--fast-delegate` / `--no-fast-delegate`, one feature
160
+ // later — NINE features, one trap. It is `fast.delegate`'s only channel:
161
+ // there is no `ARAGON_FAST_DELEGATE`, so missing this line leaves a wrapper
162
+ // with no way at all to say "do not delegate on this run".
163
+ fastDelegate: opts.fastDelegate,
156
164
  // `--verbose` is just a shorthand for `--log-level debug`; an explicit
157
165
  // `--log-level` still wins, so the two can be combined without surprise.
158
166
  logLevel: opts.logLevel ?? (opts.verbose ? 'debug' : undefined),
@@ -471,13 +479,14 @@ function runInteractive(flags, extras = {}) {
471
479
  const disposeStdinFilter = () => filter?.dispose();
472
480
  // --- Drag-select (tui-selection-and-scroll-follow §4.4). ------------------
473
481
  //
474
- // ONE EXPRESSION, READ BY FOUR THINGS, and that is the same discipline
475
- // `wantMouse` above follows for the same reason: the `?1002h` write, the
476
- // controller, the differ's `decorate` hook and the startup notice must never
477
- // disagree about whether drag-select is running. With it false the session is
478
- // byte-identical to a pre-feature build — no extra escape on the wire (AC-8),
479
- // no controller, no `decorate`, no mirror.
482
+ // Selection owns its controller, decoration and notice. Button motion follows
483
+ // mouseOn independently so --no-mouse-select still permits scrollbar dragging.
480
484
  const wantSelect = mouseOn && config.mouseSelect;
485
+ let mouseCaptured = mouseOn;
486
+ const scrollbar = createScrollbarBridge(() => mouseCaptured);
487
+ const pointerRouter = mouseOn && filter ? createPointerRouter({
488
+ source: filter.source, handle: (event) => scrollbar.controller?.handle(event) ?? false,
489
+ }) : null;
481
490
  const selectionBridge = {
482
491
  controller: null,
483
492
  onCopied: null,
@@ -530,7 +539,13 @@ function runInteractive(flags, extras = {}) {
530
539
  // than printing zeroes that read like a writer doing nothing (§5.4).
531
540
  if (frameWriter)
532
541
  setFrameStatsProvider(frameWriter.stats);
542
+ const frameObserver = mode === 'fullscreen' ? createFrameObserver({
543
+ stdout: frameWriter?.stdout ?? process.stdout, terminal: process.stdout, scrollbar,
544
+ }) : null;
533
545
  const disposeFrameWriter = () => {
546
+ frameObserver?.dispose();
547
+ pointerRouter?.dispose();
548
+ scrollbar.controller?.dispose();
534
549
  if (!frameWriter)
535
550
  return;
536
551
  setFrameStatsProvider(null);
@@ -569,7 +584,7 @@ function runInteractive(flags, extras = {}) {
569
584
  // keep their terminal's own selection.
570
585
  screen = enterAltScreen(process.stdout, {
571
586
  mouse: mouseOn,
572
- motion: wantSelect,
587
+ motion: mouseOn,
573
588
  bracketedPaste: pasteOn && mode === 'fullscreen',
574
589
  });
575
590
  const restore = () => screen?.restore();
@@ -632,7 +647,7 @@ function runInteractive(flags, extras = {}) {
632
647
  // attempted. Naming the mechanism honestly is the entire reason that return
633
648
  // value exists (R-7), and a door that lies is worse than no door.
634
649
  const writeForeign = frameWriter
635
- ? (text) => frameWriter.writeForeign(text)
650
+ ? (text) => { frameObserver?.invalidate(); frameWriter.writeForeign(text); }
636
651
  : process.stdout.isTTY
637
652
  ? (text) => {
638
653
  try {
@@ -654,10 +669,9 @@ function runInteractive(flags, extras = {}) {
654
669
  // than inside `screen.ts`'s closure because `App` needs to READ it and the
655
670
  // handle's own flag is private (it has to be — `restore()` reads it, and
656
671
  // exposing a setter would be a second way to get the two out of step).
657
- let mouseCaptured = mouseOn;
658
672
  if (wantSelect && filter) {
659
673
  selectionBridge.controller = createSelectionController({
660
- source: filter.source,
674
+ source: pointerRouter?.selectionSource ?? filter.source,
661
675
  // The frame writer is absent under `--no-diff-render`, where there is no
662
676
  // cache to repaint from at all; the controller then falls back to asking
663
677
  // `App` for a React redraw on every drag flush, which is slower and
@@ -697,11 +711,14 @@ function runInteractive(flags, extras = {}) {
697
711
  const terminalBridge = mode === 'fullscreen' || writeForeign
698
712
  ? {
699
713
  mouseSelect: wantSelect,
714
+ ...(mode === 'fullscreen' ? { scrollbar } : {}),
700
715
  ...(mouseOn
701
716
  ? {
702
717
  setMouseCapture: (on) => {
703
718
  screen?.setMouseCapture(on);
704
719
  mouseCaptured = on;
720
+ if (!on)
721
+ scrollbar.controller?.cancel();
705
722
  // A released mouse cannot be dragging, and a selection left
706
723
  // painted over a screen the terminal is now selecting on its
707
724
  // own would be two highlights claiming the same rows.
@@ -719,41 +736,67 @@ function runInteractive(flags, extras = {}) {
719
736
  : undefined;
720
737
  const updateBridge = { service: null, onAttach: null };
721
738
  let updateService = null;
739
+ let interactiveClosed = false;
740
+ const failInteractive = (error) => {
741
+ // Ink consumes render exceptions; retain failure even if a disposer throws.
742
+ process.exitCode = 1;
743
+ interactiveClosed = true;
744
+ logger.error('cli', 'render_failed', { error: String(error) });
745
+ for (const cleanup of [
746
+ () => screen?.restore(), disposeSelection, disposeStdinFilter, disposeFrameWriter,
747
+ () => controller.abort(), () => controller.dispose(), () => updateService?.dispose(),
748
+ () => { process.stdin.pause(); process.stdin.unref?.(); },
749
+ ]) {
750
+ try {
751
+ cleanup();
752
+ }
753
+ catch (cleanupError) {
754
+ logger.error('cli', 'render_cleanup_failed', { error: String(cleanupError) });
755
+ }
756
+ }
757
+ };
722
758
  const updateEligible = !!process.stdout.isTTY &&
723
759
  !!process.stdin.isTTY &&
724
760
  !process.env.CI &&
725
761
  config.update.mode !== 'off';
726
- const instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay,
727
- // `mouseOn`, NOT the handle (I-11). `App.tsx:186-188` documents this
728
- // prop's contract as "absent under `--no-mouse`, inline, or non-TTY", and
729
- // it feeds `enabled: !!mouseSource` on a wheel subscription that would
730
- // otherwise be a channel no event can ever arrive on.
731
- mouseSource: mouseOn && filter ? filter.source : undefined,
732
- // Present ONLY when this console swallows `CSI Z` and mouse reports.
733
- // Passed in rather than probed inside `App` so that every test renders
734
- // the same tree on every machine — reading `process.versions.node` from
735
- // a component would make the notice appear or vanish according to which
736
- // Node the developer happened to have installed.
737
- vtInputWarning: vtInputSupported ? undefined : { nodeVersion: process.versions.node }, updateBridge: updateEligible ? updateBridge : undefined,
738
- // Absent when nothing can refuse a paste, so a `--no-paste` tree is
739
- // byte-identical to a build without this feature.
740
- pasteBridge: pasteOn ? pasteBridge : undefined, terminal: terminalBridge }),
741
- // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
742
- // straight to stdout and permanently shifts the fixed frame's accounting.
743
- {
744
- exitOnCtrlC: false,
745
- patchConsole: mode === 'inline',
746
- // I-5: with mouse support off the REAL stdin is handed over unwrapped, so
747
- // every existing path is provably unchanged.
748
- stdin: filter?.stdin ?? process.stdin,
749
- // SPREAD, not `frameWriter?.stdout ?? process.stdout`. This call passed no
750
- // `stdout` at all before, and Ink defaults it to `process.stdout`; omitting
751
- // the key entirely when the writer is off keeps the options object the same
752
- // SHAPE it has always had, which is what makes AC-5 / AC-6 ("byte-identical
753
- // with --no-diff-render, inline mode and `aragon -p`") a statement about
754
- // the code rather than about Ink's defaulting.
755
- ...(frameWriter ? { stdout: frameWriter.stdout } : {}),
756
- });
762
+ let instance;
763
+ try {
764
+ instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay,
765
+ // `mouseOn`, NOT the handle (I-11). `App.tsx:186-188` documents this
766
+ // prop's contract as "absent under `--no-mouse`, inline, or non-TTY", and
767
+ // it feeds `enabled: !!mouseSource` on a wheel subscription that would
768
+ // otherwise be a channel no event can ever arrive on.
769
+ mouseSource: mouseOn && filter ? filter.source : undefined,
770
+ // Present ONLY when this console swallows `CSI Z` and mouse reports.
771
+ // Passed in rather than probed inside `App` so that every test renders
772
+ // the same tree on every machine — reading `process.versions.node` from
773
+ // a component would make the notice appear or vanish according to which
774
+ // Node the developer happened to have installed.
775
+ vtInputWarning: vtInputSupported ? undefined : { nodeVersion: process.versions.node }, updateBridge: updateEligible ? updateBridge : undefined,
776
+ // Absent when nothing can refuse a paste, so a `--no-paste` tree is
777
+ // byte-identical to a build without this feature.
778
+ pasteBridge: pasteOn ? pasteBridge : undefined, terminal: terminalBridge }),
779
+ // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
780
+ // straight to stdout and permanently shifts the fixed frame's accounting.
781
+ {
782
+ exitOnCtrlC: false,
783
+ patchConsole: mode === 'inline',
784
+ // I-5: with mouse support off the REAL stdin is handed over unwrapped, so
785
+ // every existing path is provably unchanged.
786
+ stdin: filter?.stdin ?? process.stdin,
787
+ // SPREAD, not `frameWriter?.stdout ?? process.stdout`. This call passed no
788
+ // `stdout` at all before, and Ink defaults it to `process.stdout`; omitting
789
+ // the key entirely when the writer is off keeps the options object the same
790
+ // SHAPE it has always had, which is what makes AC-5 / AC-6 ("byte-identical
791
+ // with --no-diff-render, inline mode and `aragon -p`") a statement about
792
+ // the code rather than about Ink's defaulting.
793
+ ...(frameObserver ? { stdout: frameObserver.stdout } : {}),
794
+ });
795
+ }
796
+ catch (error) {
797
+ failInteractive(error);
798
+ throw error;
799
+ }
757
800
  // H1's primary disarm, and this is the earliest moment at which "this build
758
801
  // STARTS" is proven: the module graph loaded, config resolved, Ink mounted
759
802
  // (cli-auto-update-hardening section 5.1.4). It is what makes a long session
@@ -775,6 +818,8 @@ function runInteractive(flags, extras = {}) {
775
818
  // must degrade to "no updater", never to a crashed TUI.
776
819
  void import('./update/service.js')
777
820
  .then(({ UpdateService }) => {
821
+ if (interactiveClosed)
822
+ return;
778
823
  const service = new UpdateService({ config: config.update, currentVersion: VERSION });
779
824
  updateService = service;
780
825
  updateBridge.service = service;
@@ -805,6 +850,7 @@ function runInteractive(flags, extras = {}) {
805
850
  void instance
806
851
  .waitUntilExit()
807
852
  .then(() => {
853
+ interactiveClosed = true;
808
854
  screen?.restore();
809
855
  // BEFORE `disposeStdinFilter()`, which clears the listener set it subscribed to,
810
856
  // and after `restore()` for the reason that call records: the terminal must
@@ -833,18 +879,7 @@ function runInteractive(flags, extras = {}) {
833
879
  updateService?.dispose();
834
880
  replayTranscript();
835
881
  })
836
- .catch(() => {
837
- screen?.restore();
838
- // BEFORE `disposeStdinFilter()`, which clears the listener set it subscribed to,
839
- // and after `restore()` for the reason that call records: the terminal must
840
- // stop reporting first, and anything already in flight is still consumed by
841
- // a live filter.
842
- disposeSelection();
843
- disposeStdinFilter();
844
- disposeFrameWriter();
845
- controller.dispose();
846
- updateService?.dispose();
847
- });
882
+ .catch(failInteractive);
848
883
  }
849
884
  async function runOneShot(flags, prompt, quiet) {
850
885
  // Headless: no App is ever rendered, so there is no human to approve a skill
@@ -924,6 +959,11 @@ const CONFIG_SET_KEYS = new Set([
924
959
  'baseUrl',
925
960
  'thinkingLevel',
926
961
  'maxTokens',
962
+ // context-usage-gauge-accuracy §3.6. Listed here AND given its own branch in
963
+ // `runConfigSet` below - a key present here but absent there falls through
964
+ // every case, writes nothing, and still prints `Set contextWindow = 1000000`
965
+ // (P1-2).
966
+ 'contextWindow',
927
967
  'theme',
928
968
  'confirmTools',
929
969
  'toolTimeoutMs',
@@ -1020,6 +1060,30 @@ function runConfigSetMaxTokens(value) {
1020
1060
  const merged = updatePersistedConfig({ maxTokens: stored });
1021
1061
  process.stdout.write(`Set maxTokens = ${merged.maxTokens ?? 'auto'}\n`);
1022
1062
  }
1063
+ /**
1064
+ * `aragon config set contextWindow <n|auto>`.
1065
+ *
1066
+ * ITS OWN BRANCH, AHEAD OF THE SWITCH, for the reason `maxTokens` has one: it
1067
+ * must echo the STORED value rather than the typed one, so
1068
+ * `config set contextWindow 99999999` reports the clamped 5000000 instead of a
1069
+ * number that was never written. It also REJECTS nonsense rather than clamping
1070
+ * it - a non-numeric argument here is almost always a typo for a number, and
1071
+ * storing AUTO for `20O000` would quietly discard the user's intent.
1072
+ */
1073
+ function runConfigSetContextWindow(value) {
1074
+ const trimmed = value.trim();
1075
+ const auto = isAutoToken(trimmed);
1076
+ if (!auto && !/^\d+$/.test(trimmed)) {
1077
+ process.stderr.write(`Invalid contextWindow "${value}". Use a number of tokens or "auto".\n`);
1078
+ process.exitCode = 2;
1079
+ return;
1080
+ }
1081
+ // `null` GOES IN UNCLAMPED (RV-12). Running AUTO through the clamp yields
1082
+ // 8000 and removes "auto" from the config permanently.
1083
+ const stored = auto ? null : clampContextWindow(trimmed, null);
1084
+ const merged = updatePersistedConfig({ contextWindow: stored });
1085
+ process.stdout.write(`Set contextWindow = ${merged.contextWindow ?? 'auto'}\n`);
1086
+ }
1023
1087
  function runConfigSet(key, value) {
1024
1088
  if (!CONFIG_SET_KEYS.has(key)) {
1025
1089
  process.stderr.write(`Unknown config key "${key}". Known keys: ${[...CONFIG_SET_KEYS].join(', ')}.\n`);
@@ -1040,6 +1104,11 @@ function runConfigSet(key, value) {
1040
1104
  runConfigSetMaxTokens(value);
1041
1105
  return;
1042
1106
  }
1107
+ // The second tri-state key, and it needs its own branch for the same reason.
1108
+ if (key === 'contextWindow') {
1109
+ runConfigSetContextWindow(value);
1110
+ return;
1111
+ }
1043
1112
  const teamPatch = applyTeamConfigSet(key, value);
1044
1113
  if (teamPatch) {
1045
1114
  // The written value is echoed from the CLAMPED result rather than from the
@@ -1491,6 +1560,14 @@ function buildProgram() {
1491
1560
  .option('--fast-model <id>', 'The fast model id (implies nothing about --fast)')
1492
1561
  .option('--fast-provider <id>', 'Provider for the fast model (default: the main provider)')
1493
1562
  .option('--fast-review <n|off>', 'Turns between automatic fast reviews, or "off"')
1563
+ // BOTH FORMS, POSITIVE FIRST, and here it matters more than anywhere above:
1564
+ // `fast.delegate` is PERSISTED and defaults to TRUE, so declaring only
1565
+ // `--no-fast-delegate` would make commander default `opts.fastDelegate` to
1566
+ // `true` and force `delegate: true` on EVERY run that passed no flag at all
1567
+ // — silently overwriting a user's stored `false`. Same polarity, same trap
1568
+ // and same fix as `--update` / `--compaction`.
1569
+ .option('--fast-delegate', 'Let the fast tier take mechanical sub-steps (the default)')
1570
+ .option('--no-fast-delegate', 'Run every sub-step on the main model')
1494
1571
  // BOTH FORMS, positive first, for the reason recorded seven times above —
1495
1572
  // and this is the pair whose omission would be worst. `update.mode` is
1496
1573
  // PERSISTED and defaults to `'auto'`, so a lone `--no-update` would make