@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
@@ -57,6 +57,12 @@ export interface PressureInput {
57
57
  contextWindow: number;
58
58
  /** False when the window is `buildRuntimeModel`'s 128k placeholder. */
59
59
  windowKnown: boolean;
60
+ /**
61
+ * Whether `contextWindow` came from the user's `contextWindow` override
62
+ * (context-usage-gauge-accuracy I-6). Defaults to `false`, so every existing
63
+ * construction site is unchanged and the arithmetic below is untouched.
64
+ */
65
+ windowOverridden?: boolean;
60
66
  /** Carried from the last `turn_end`; applied to every ESTIMATED figure. */
61
67
  estimateOffset?: number;
62
68
  /**
@@ -138,6 +138,10 @@ export function computePressure(input) {
138
138
  headroom: Math.max(0, window - occupied),
139
139
  source,
140
140
  windowKnown: input.windowKnown,
141
+ // PURE PASS-THROUGH. This function's arithmetic is unchanged by
142
+ // context-usage-gauge-accuracy; the flag only travels with the number so
143
+ // `/context` can name the denominator's source (I-6).
144
+ windowOverridden: input.windowOverridden ?? false,
141
145
  ...(input.estimateOffset !== undefined ? { estimateOffset: input.estimateOffset } : {}),
142
146
  deltaTokens,
143
147
  };
@@ -60,6 +60,18 @@ export interface Pressure {
60
60
  source: PressureSource;
61
61
  /** False when the window came from `buildRuntimeModel`'s 128k placeholder. */
62
62
  windowKnown: boolean;
63
+ /**
64
+ * Whether the denominator came from the user's `contextWindow` override
65
+ * (context-usage-gauge-accuracy I-6).
66
+ *
67
+ * SEPARATE FROM `windowKnown` RATHER THAN FOLDED INTO IT. An override makes
68
+ * the window KNOWN - the user asserted it, so the `~` has no business staying
69
+ * on the percentage - but `/context` still has to be able to say WHO supplied
70
+ * the number. Collapsing the two into one boolean makes "found in the model
71
+ * table" and "typed by hand" indistinguishable in the report, and the second
72
+ * one being wrong is exactly the case that most needs to be visible.
73
+ */
74
+ windowOverridden: boolean;
63
75
  /**
64
76
  * `occupiedTokens(lastUsage) - estimateOf(the history that produced it)`,
65
77
  * clamped to `>= 0`, or `undefined` before the first `turn_end`.
@@ -89,6 +101,35 @@ export interface Pressure {
89
101
  */
90
102
  deltaTokens: number;
91
103
  }
104
+ /**
105
+ * The ONE occupancy projection the status bar and `/context` read
106
+ * (context-usage-gauge-accuracy §4.1).
107
+ *
108
+ * DELIBERATELY CARRIES NO TIMESTAMP. Its only consumer is the reducer's identity
109
+ * short-circuit (§5.2), and a field that changes on every tick would make that
110
+ * short-circuit never hold - turning a permanently visible row into a 2.5 Hz
111
+ * re-render source and pushing the render governor up its ladder for nothing.
112
+ *
113
+ * `Pressure` is the compaction subsystem's shape and carries a headroom and a
114
+ * ratio the UI does not want; this is the projection of it that crosses into
115
+ * view state, and `toContextUsage` in `meter.ts` is the ONLY converter.
116
+ */
117
+ export interface ContextUsageSnapshot {
118
+ /** Occupied tokens (measured, plus an estimate of anything appended since). */
119
+ occupied: number;
120
+ /** The denominator actually used. */
121
+ window: number;
122
+ /** `occupied / window` as a percentage, clamped to [0, 100] and rounded. */
123
+ pct: number;
124
+ /** Where the BASE came from. */
125
+ source: PressureSource;
126
+ /** How much of `occupied` is an estimate of appended messages; `> 0` means approximate. */
127
+ deltaTokens: number;
128
+ /** Whether the denominator is trustworthy (in the model table, or overridden). */
129
+ windowKnown: boolean;
130
+ /** Whether the denominator came from the user's `contextWindow` (I-6). */
131
+ windowOverridden: boolean;
132
+ }
92
133
  /** One compaction, in whatever state it reached. Rendered by `CompactionCard`. */
93
134
  export interface CompactionRecord {
94
135
  /** 1-based within the session, so the card can name itself. */
@@ -39,6 +39,7 @@ import { type AgentEvent, type ContextManager, type Message, type ModelInfo, typ
39
39
  import type { CliConfig } from '../config/schema.js';
40
40
  import type { NoticeLevel } from '../agent/reducer.js';
41
41
  import { type ChildContextManagerFactory } from './child.js';
42
+ import { ContextMeter } from './meter.js';
42
43
  import type { CompactionEventListener, CompactionSnapshot } from './types.js';
43
44
  export interface CompactionWiringDeps {
44
45
  /** Read LIVE on every call - the controller replaces the object on mutation. */
@@ -54,6 +55,17 @@ export interface CompactionWiringDeps {
54
55
  getMessages: () => readonly Message[];
55
56
  getSystemPrompt: () => string;
56
57
  notify: (level: NoticeLevel, text: string) => void;
58
+ /**
59
+ * The controller's `ContextMeter`, forwarded to the compactor
60
+ * (context-usage-gauge-accuracy W1).
61
+ *
62
+ * OPTIONAL SO EVERY EXISTING CONSTRUCTION SITE COMPILES (R-1); production
63
+ * always injects, and `AgentController` owns the instance because it builds
64
+ * one UNCONDITIONALLY while this class only exists when `compaction.enabled`
65
+ * is true. That asymmetry is the whole point: occupancy has to be measurable
66
+ * in a session that turned compaction off.
67
+ */
68
+ meter?: ContextMeter;
57
69
  /**
58
70
  * How the summarizer's own registry is built. Defaults to `initProviders`.
59
71
  *
@@ -80,6 +92,8 @@ export declare class CompactionWiring {
80
92
  private readonly log;
81
93
  private readonly listeners;
82
94
  private readonly compactor;
95
+ /** The occupancy meter, injected or private (R-1). Forwarded to the compactor. */
96
+ private readonly meter;
83
97
  /** The compactor's OWN transport (D-10). Lazy - see `getRegistry()`. */
84
98
  private registry;
85
99
  private unsubscribe;
@@ -152,6 +166,20 @@ export declare class CompactionWiring {
152
166
  * `/clear`, `/resume`, and any future host-side rewrite. A recorded prefix
153
167
  * length indexes an array that no longer exists, so the delta must be
154
168
  * abandoned rather than applied to a different history.
169
+ *
170
+ * THE **DEEP** RESET (I-8). `/resume` can change the model in the same breath,
171
+ * so `estimateOffset` - a calibration for one toolset on one model - goes too.
172
+ * The SHALLOW reset is `settlePending`'s splice branch, and the two are not
173
+ * interchangeable: swapping them produces silent errors in OPPOSITE
174
+ * directions (a splice that drops the offset reads too low and climbs back; a
175
+ * resume that keeps it reads too high and never self-corrects).
176
+ *
177
+ * DUPLICATED BY `AgentController` ON PURPOSE. The controller calls
178
+ * `contextMeter.onHistoryReplaced()` directly at the same two sites, because
179
+ * this class does not exist at all when `compaction.enabled` is false - and
180
+ * that unconditional call is the only thing that keeps `/resume` re-measuring
181
+ * for such a session. A deep reset is idempotent, so both firing costs
182
+ * nothing; deleting the controller's copy "to de-duplicate" reintroduces P0-2.
155
183
  */
156
184
  onHistoryReplaced(): void;
157
185
  /**
@@ -201,6 +229,17 @@ export declare class CompactionWiring {
201
229
  ok: false;
202
230
  reason: string;
203
231
  }>;
232
+ /**
233
+ * The status a reader gets right now.
234
+ *
235
+ * IT READS `meter.current()`, WHICH RE-MEASURES WHEN DIRTY - and that is the
236
+ * fix for P0-1 (§3.3). This method is called one statement after
237
+ * `settlePending` marks the meter spliced, so the `snapshot` event emitted
238
+ * immediately after `compaction_end` carries the POST-compaction occupancy.
239
+ * The old body preferred `compactor.lastMeasured()`, i.e. the measurement that
240
+ * TRIGGERED this compaction, so the gauge fell and was bounced straight back
241
+ * to the pre-compaction figure inside the same synchronous emit loop.
242
+ */
204
243
  snapshot(): CompactionSnapshot;
205
244
  /**
206
245
  * `snapshot()` with the pressure argument substituted for the re-measurement.
@@ -214,6 +253,14 @@ export declare class CompactionWiring {
214
253
  /** The summarizer that would be used right now, for `/compact status`. */
215
254
  summarizerRef(): ModelRef | null;
216
255
  subscribe(listener: CompactionEventListener): () => void;
256
+ /**
257
+ * The main model, for the FALLBACK meter's deps only (R-1).
258
+ *
259
+ * Production injects a meter built by `AgentController`, which resolves the
260
+ * same ref from the same live config; this exists so a test that constructs a
261
+ * bare wiring still gets a working denominator.
262
+ */
263
+ private mainRef;
217
264
  /**
218
265
  * The transport one summarization call goes through - separate from the lead's,
219
266
  * and fail-fast (D-10).
@@ -40,6 +40,7 @@ import { getLogger } from '../logging/logger.js';
40
40
  import { mintRunId, writeArchive } from './archive.js';
41
41
  import { createChildContextManager } from './child.js';
42
42
  import { Compactor } from './compactor.js';
43
+ import { ContextMeter } from './meter.js';
43
44
  /**
44
45
  * One retry, not ten and not zero - the `fast/wiring.ts` argument unchanged
45
46
  * (D-10).
@@ -68,6 +69,7 @@ export function offCompactionSnapshot() {
68
69
  headroom: 0,
69
70
  source: 'estimate',
70
71
  windowKnown: false,
72
+ windowOverridden: false,
71
73
  deltaTokens: 0,
72
74
  },
73
75
  };
@@ -77,6 +79,8 @@ export class CompactionWiring {
77
79
  log = getLogger().child('compaction');
78
80
  listeners = new Set();
79
81
  compactor;
82
+ /** The occupancy meter, injected or private (R-1). Forwarded to the compactor. */
83
+ meter;
80
84
  /** The compactor's OWN transport (D-10). Lazy - see `getRegistry()`. */
81
85
  registry = null;
82
86
  unsubscribe = null;
@@ -125,6 +129,15 @@ export class CompactionWiring {
125
129
  constructor(deps) {
126
130
  this.deps = deps;
127
131
  this.enabled = deps.getConfig().compaction.enabled;
132
+ this.meter =
133
+ deps.meter ??
134
+ new ContextMeter({
135
+ getMessages: deps.getMessages,
136
+ getSystemPrompt: deps.getSystemPrompt,
137
+ getModelInfo: () => deps.getModelInfoFor(this.mainRef()),
138
+ isWindowKnown: () => deps.isPricedModel(this.mainRef()),
139
+ getWindowOverride: () => deps.getConfig().contextWindow,
140
+ });
128
141
  this.compactor = new Compactor({
129
142
  getConfig: deps.getConfig,
130
143
  hasKey: deps.hasKey,
@@ -139,6 +152,9 @@ export class CompactionWiring {
139
152
  complete: (providerId, request) => this.getRegistry().complete(providerId, request),
140
153
  emit: (event) => this.onCompactorEvent(event),
141
154
  notify: deps.notify,
155
+ // ONE METER PER PROCESS. The compactor must not build its own fallback
156
+ // here, or the trigger would read a different number from the gauge.
157
+ meter: this.meter,
142
158
  // ONE NUMBER ON SCREEN AND IN THE TRIGGER (R-11 / DH-15). The checkpoint
143
159
  // has already measured; publishing THAT pressure rather than re-measuring
144
160
  // is what keeps the two from disagreeing by one turn's tool results.
@@ -205,9 +221,23 @@ export class CompactionWiring {
205
221
  * `/clear`, `/resume`, and any future host-side rewrite. A recorded prefix
206
222
  * length indexes an array that no longer exists, so the delta must be
207
223
  * abandoned rather than applied to a different history.
224
+ *
225
+ * THE **DEEP** RESET (I-8). `/resume` can change the model in the same breath,
226
+ * so `estimateOffset` - a calibration for one toolset on one model - goes too.
227
+ * The SHALLOW reset is `settlePending`'s splice branch, and the two are not
228
+ * interchangeable: swapping them produces silent errors in OPPOSITE
229
+ * directions (a splice that drops the offset reads too low and climbs back; a
230
+ * resume that keeps it reads too high and never self-corrects).
231
+ *
232
+ * DUPLICATED BY `AgentController` ON PURPOSE. The controller calls
233
+ * `contextMeter.onHistoryReplaced()` directly at the same two sites, because
234
+ * this class does not exist at all when `compaction.enabled` is false - and
235
+ * that unconditional call is the only thing that keeps `/resume` re-measuring
236
+ * for such a session. A deep reset is idempotent, so both firing costs
237
+ * nothing; deleting the controller's copy "to de-duplicate" reintroduces P0-2.
208
238
  */
209
239
  onHistoryReplaced() {
210
- this.compactor.invalidateMeasurement();
240
+ this.meter.onHistoryReplaced();
211
241
  }
212
242
  /**
213
243
  * The per-child manager factory, or `null` when children get none
@@ -314,13 +344,19 @@ export class CompactionWiring {
314
344
  // -----------------------------------------------------------------------
315
345
  // Status and events
316
346
  // -----------------------------------------------------------------------
347
+ /**
348
+ * The status a reader gets right now.
349
+ *
350
+ * IT READS `meter.current()`, WHICH RE-MEASURES WHEN DIRTY - and that is the
351
+ * fix for P0-1 (§3.3). This method is called one statement after
352
+ * `settlePending` marks the meter spliced, so the `snapshot` event emitted
353
+ * immediately after `compaction_end` carries the POST-compaction occupancy.
354
+ * The old body preferred `compactor.lastMeasured()`, i.e. the measurement that
355
+ * TRIGGERED this compaction, so the gauge fell and was bounced straight back
356
+ * to the pre-compaction figure inside the same synchronous emit loop.
357
+ */
317
358
  snapshot() {
318
- const pressure = this.compactor.lastMeasured() ??
319
- this.compactor.measure({
320
- messages: this.deps.getMessages(),
321
- systemPrompt: this.deps.getSystemPrompt(),
322
- });
323
- return this.snapshotWith(pressure);
359
+ return this.snapshotWith(this.meter.current());
324
360
  }
325
361
  /**
326
362
  * `snapshot()` with the pressure argument substituted for the re-measurement.
@@ -362,6 +398,17 @@ export class CompactionWiring {
362
398
  // -----------------------------------------------------------------------
363
399
  // Internals
364
400
  // -----------------------------------------------------------------------
401
+ /**
402
+ * The main model, for the FALLBACK meter's deps only (R-1).
403
+ *
404
+ * Production injects a meter built by `AgentController`, which resolves the
405
+ * same ref from the same live config; this exists so a test that constructs a
406
+ * bare wiring still gets a working denominator.
407
+ */
408
+ mainRef() {
409
+ const config = this.deps.getConfig();
410
+ return { providerId: config.provider, modelId: config.model };
411
+ }
365
412
  /**
366
413
  * The transport one summarization call goes through - separate from the lead's,
367
414
  * and fail-fast (D-10).
@@ -408,19 +455,16 @@ export class CompactionWiring {
408
455
  return;
409
456
  }
410
457
  if (event.type === 'turn_end') {
411
- // The calibration offset, re-measured every turn (D-23). Read the history
412
- // AFTER the turn's assistant message is pushed? No: `turn_end` is emitted
413
- // BEFORE the push, so this is exactly the history the reported `usage`
414
- // was measured against, which is what makes the difference meaningful.
415
- this.compactor.onTurnEnd(event.usage, this.deps.getMessages(), this.deps.getSystemPrompt());
416
- // The gauge and the trigger read one function (R-11 / AC-3), so the
417
- // snapshot's pressure is refreshed from the same measurement the status bar
418
- // is about to render.
419
- this.compactor.measure({
420
- lastUsage: event.usage,
421
- messages: this.deps.getMessages(),
422
- systemPrompt: this.deps.getSystemPrompt(),
423
- });
458
+ // THE MEASUREMENT IS THE METER'S (context-usage-gauge-accuracy §3.3.4). It
459
+ // subscribes to `turn_end` on the same stream and recomputes the offset,
460
+ // the prefix length and the pressure there; the two calls that used to
461
+ // live here - `compactor.onTurnEnd(...)` and `compactor.measure(...)` -
462
+ // were both forwarders to it and doing the same work twice.
463
+ //
464
+ // THE SNAPSHOT EMIT STAYS. The other fields on it - `inFlight`, the
465
+ // session counts, the summarizer - are this class's own and still need
466
+ // refreshing on a turn boundary, and `snapshot()` picks up the meter's
467
+ // freshly published pressure for free.
424
468
  this.emit({ type: 'snapshot', snapshot: this.snapshot() });
425
469
  return;
426
470
  }
@@ -462,9 +506,13 @@ export class CompactionWiring {
462
506
  // A SILENT DECLINE (quiet-noop §3.6). No card - but the gauge and the chip
463
507
  // are still refreshed: `inFlight` has just gone false and `/compact status`
464
508
  // reads this same snapshot, which is where the `declined` count surfaces.
465
- // `snapshot()` uses the CACHED pressure (`compactor.lastMeasured()`), so
466
- // this costs no re-measurement, and guard 4 caps the number of declines per
467
- // session at `stuckLimit`.
509
+ //
510
+ // `snapshot()` NOW GOES THROUGH `meter.current()`, so this path DOES pay a
511
+ // re-measurement when the history has moved since the last one (RV-11 -
512
+ // the previous sentence here claimed the opposite and was left stale by
513
+ // context-usage-gauge-accuracy). The cost is bounded the same way it always
514
+ // was: guard 4 caps declines per session at `stuckLimit`, and a re-measure
515
+ // on the measured branch walks one turn's worth of appended messages.
468
516
  this.emit({ type: 'snapshot', snapshot: this.snapshot() });
469
517
  return;
470
518
  }
@@ -478,10 +526,23 @@ export class CompactionWiring {
478
526
  };
479
527
  if (verdict.applied) {
480
528
  this.compactor.recordApplied(settled.tokensBefore, settled.tokensAfter);
481
- // SITE 1 OF THE FOUR INVALIDATION SITES (hardening §3.2.3). The in-loop
482
- // splice replaced the history, so every recorded index means nothing; the
483
- // manual path settles through here too, so one call covers both.
484
- this.compactor.invalidateMeasurement();
529
+ // SITE 1 OF THE FOUR INVALIDATION SITES (hardening §3.2.3), and THE LOAD-
530
+ // BEARING LINE OF THE P0-1 FIX (context-usage-gauge-accuracy I-9).
531
+ //
532
+ // ITS POSITION IS THE WHOLE ARGUMENT: it runs in this class's own
533
+ // synchronous code, BEFORE both `emit` calls below, so the `snapshot()`
534
+ // two statements down is guaranteed to see a dirty meter and re-measure -
535
+ // no matter which of the two agent-stream listeners the emitter reached
536
+ // first. Deleting it and relying on the meter's own `compaction_end`
537
+ // subscription instead moves correctness onto subscription ORDER, which is
538
+ // true in every unit test and false in production import order.
539
+ //
540
+ // THE **SHALLOW** RESET (I-8). A splice changes the history and nothing
541
+ // else, so `estimateOffset` - a calibration for this toolset on this model
542
+ // - is still valid and is the only thing keeping the post-compaction
543
+ // estimate honest (I-5). `onHistoryReplaced()` is the other depth and is
544
+ // NOT interchangeable with this one.
545
+ this.meter.onHistorySpliced();
485
546
  const path = this.archive(settled, dropped);
486
547
  if (path)
487
548
  settled.archivePath = path;
@@ -405,6 +405,11 @@ function maskConfig(config) {
405
405
  function displayOverride(key, value) {
406
406
  if (key === 'maxTokens' && value === null)
407
407
  return 'auto';
408
+ // The SECOND tri-state key, and it needs this for the same reason (§4.5):
409
+ // `contextWindow: null` is AUTO ("use the model table"), not "unset", and a
410
+ // bare `null` sends the user looking for a bug that is not there.
411
+ if (key === 'contextWindow' && value === null)
412
+ return 'auto';
408
413
  return undefined;
409
414
  }
410
415
  /** Resolve a flat or one-level-dotted key against the persisted config. */
@@ -37,7 +37,7 @@ import process from 'node:process';
37
37
  import dotenv from 'dotenv';
38
38
  import { registerSecretsFrom } from '../logging/secret-registry.js';
39
39
  import { clampLogLevel } from '../logging/levels.js';
40
- import { clampMaxTokens, clampTheme, clampThinkingLevel, isAutoToken, parseThresholdInput, } from './schema.js';
40
+ import { clampContextWindow, clampMaxTokens, clampTheme, clampThinkingLevel, isAutoToken, parseThresholdInput, } from './schema.js';
41
41
  /**
42
42
  * Load a project `.env` from the given directory into `process.env` (existing
43
43
  * process env wins — dotenv never overrides already-set variables).
@@ -113,6 +113,20 @@ export function readEnvConfig() {
113
113
  partial.maxTokens = clamped;
114
114
  }
115
115
  }
116
+ // `auto` / `0` mean AUTO (`null`); a number is clamped. Anything else is LEFT
117
+ // ABSENT rather than written as `null`, the rule `ARAGON_MAX_TOKENS` states one
118
+ // block up: a typo must not silently discard the number in the config file.
119
+ const contextWindow = process.env.ARAGON_CONTEXT_WINDOW?.trim();
120
+ if (contextWindow !== undefined && contextWindow.length > 0) {
121
+ if (isAutoToken(contextWindow)) {
122
+ partial.contextWindow = null;
123
+ }
124
+ else {
125
+ const clamped = clampContextWindow(contextWindow, null);
126
+ if (clamped !== null)
127
+ partial.contextWindow = clamped;
128
+ }
129
+ }
116
130
  if (process.env.ARAGON_THEME) {
117
131
  partial.theme = clampTheme(process.env.ARAGON_THEME.trim(), 'auto');
118
132
  }
@@ -185,6 +185,23 @@ export interface CliFlags {
185
185
  * to keep in step with `toFlags`.
186
186
  */
187
187
  fastReview?: string;
188
+ /**
189
+ * `--fast-delegate` ⇒ true, `--no-fast-delegate` ⇒ false, absent ⇒ undefined
190
+ * (web-use-tier-cooperation-and-control-closure §4.1.5).
191
+ *
192
+ * DECLARED AS A PAIR — MANDATORY, for the reason `--team`, `--todo-panel`,
193
+ * `--render-governor`, `--retry` and `--fast` each record in turn, and here
194
+ * with the SAME polarity as `--update`: `fast.delegate` is PERSISTED and
195
+ * defaults to `true`, so a lone `--no-fast-delegate` would make commander
196
+ * default `opts.fastDelegate` to `true` and silently overwrite a stored
197
+ * `false` on every run that passed no flag at all.
198
+ *
199
+ * It exists because it is the ONLY `fast.*` sub-switch with no channel a
200
+ * wrapper can reach: `--fast-review <n|off>` already covers `review` and
201
+ * `reviewEveryTurns`, and `enabled` / `model` / `provider` have both a flag
202
+ * and an environment variable.
203
+ */
204
+ fastDelegate?: boolean;
188
205
  /**
189
206
  * `--update` ⇒ true, `--no-update` ⇒ false, absent ⇒ undefined
190
207
  * (cli-auto-update §4.2).
@@ -6,7 +6,7 @@
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
8
  import process from 'node:process';
9
- import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampMaxTokens, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, parseThresholdInput, } from './schema.js';
9
+ import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, parseThresholdInput, } from './schema.js';
10
10
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
11
11
  import { readConfigFile } from './store.js';
12
12
  import { getConfigPath } from './app-paths.js';
@@ -250,6 +250,12 @@ function resolveFastConfig(flags, env, file) {
250
250
  ...(flags.fastProvider !== undefined && flags.fastProvider !== ''
251
251
  ? { provider: flags.fastProvider }
252
252
  : {}),
253
+ // `!== undefined`, for the reason stated on `flags.fast` above: commander
254
+ // materialises a lone `--no-fast-delegate` as `opts.fastDelegate = true`
255
+ // when the flag is absent, and `delegate` defaults to TRUE — so a truthiness
256
+ // check would force it on for every run that passed no flag at all,
257
+ // overwriting a stored `false` with nothing to show for it.
258
+ ...(flags.fastDelegate !== undefined ? { delegate: flags.fastDelegate } : {}),
253
259
  ...reviewPatch,
254
260
  });
255
261
  }
@@ -410,6 +416,42 @@ function resolveMaxTokens(flags, env, file) {
410
416
  }
411
417
  return DEFAULT_CONFIG.maxTokens ?? undefined;
412
418
  }
419
+ /**
420
+ * Resolve the context-window override: env > file > default, where an EXPLICIT
421
+ * `null` at either layer means AUTO (context-usage-gauge-accuracy §3.6).
422
+ *
423
+ * IT CANNOT USE `pick()` FOR THE REASON `resolveMaxTokens` GIVES: `pick` skips
424
+ * `null`, and `null` is exactly the value being looked for. NO CLI FLAG feeds
425
+ * this - it is a per-installation correction for a model table entry, not a
426
+ * per-invocation choice, so `config set` and the environment variable are the
427
+ * two surfaces the design names.
428
+ */
429
+ function resolveContextWindow(env, file) {
430
+ const layers = [
431
+ {
432
+ name: 'ARAGON_CONTEXT_WINDOW',
433
+ value: hasOwn(env, 'contextWindow') ? env.contextWindow : undefined,
434
+ },
435
+ {
436
+ name: 'config.json contextWindow',
437
+ value: hasOwn(file, 'contextWindow') ? file.contextWindow : undefined,
438
+ },
439
+ ];
440
+ for (const layer of layers) {
441
+ if (layer.value === undefined)
442
+ continue; // this layer said nothing
443
+ if (layer.value === null || isAutoToken(layer.value))
444
+ return null; // AUTO
445
+ const n = clampContextWindow(layer.value, null);
446
+ if (n !== null)
447
+ return n;
448
+ getLogger().warn('config', 'context_window_unusable', {
449
+ source: layer.name,
450
+ value: String(layer.value),
451
+ });
452
+ }
453
+ return DEFAULT_CONFIG.contextWindow;
454
+ }
413
455
  /**
414
456
  * Resolve the effective config by merging all layers, then derive the runtime
415
457
  * (non-persisted) fields and enforce the timeout invariant.
@@ -445,6 +487,7 @@ export function loadConfig(flags = {}) {
445
487
  const liveToolOutput = pick(flags.liveToolOutput, env.partial.liveToolOutput, file.liveToolOutput) ??
446
488
  DEFAULT_CONFIG.liveToolOutput;
447
489
  const maxTokens = resolveMaxTokens(flags, env.partial, file);
490
+ const contextWindow = resolveContextWindow(env.partial, file);
448
491
  // `clampTheme` is also where the v0.3.0 `dark` name is migrated to `cool`,
449
492
  // so reading a legacy config file needs no extra step here (§4.5 / R-4).
450
493
  const theme = clampTheme(pick(flags.theme, env.partial.theme, file.theme), DEFAULT_CONFIG.theme);
@@ -534,6 +577,7 @@ export function loadConfig(flags = {}) {
534
577
  showThinking,
535
578
  liveToolOutput,
536
579
  maxTokens,
580
+ contextWindow,
537
581
  theme,
538
582
  reducedMotion,
539
583
  fullscreen: resolveFullscreen(flags, env.partial, file),
@@ -78,6 +78,27 @@ export declare const DEFAULT_TRANSCRIPT_WINDOW = 1000;
78
78
  export declare const MIN_TRANSCRIPT_WINDOW = 50;
79
79
  export declare const MAX_TRANSCRIPT_WINDOW = 20000;
80
80
  export declare function clampTranscriptWindow(v: unknown, fallback: number): number;
81
+ /**
82
+ * Bounds for a hand-supplied context window.
83
+ *
84
+ * The floor is roughly the smallest window any current model ships with; the
85
+ * ceiling is well above the largest, and both exist to keep a typo (a missing
86
+ * or an extra zero) from producing a gauge that is permanently red or
87
+ * permanently flat.
88
+ */
89
+ export declare const MIN_CONTEXT_WINDOW = 8000;
90
+ export declare const MAX_CONTEXT_WINDOW = 5000000;
91
+ /**
92
+ * THE single gate for every `contextWindow` read AND write.
93
+ *
94
+ * TRI-STATE, AND `null` MUST PASS STRAIGHT THROUGH (RV-12). `null` means AUTO -
95
+ * take the model table's number, or the 128k placeholder. Clamping it into
96
+ * `[8000, ...]` yields 8000, which removes "auto" from the config permanently
97
+ * and presents to the user as an 8k denominator with a permanently red bar and
98
+ * no visible source. `maxTokens` above is the existing tri-state precedent and
99
+ * this deliberately copies its shape rather than inventing a second convention.
100
+ */
101
+ export declare function clampContextWindow(v: unknown, fallback: number | null): number | null;
81
102
  /**
82
103
  * Entries kept in `ViewState` at all (tui-render-performance L1 / R5).
83
104
  *
@@ -692,6 +713,18 @@ export interface PersistedConfig {
692
713
  * `null`), which left the user no way to express "let the model decide".
693
714
  */
694
715
  maxTokens: number | null;
716
+ /**
717
+ * The context window to measure occupancy against, or `null` for AUTO
718
+ * (context-usage-gauge-accuracy §3.6).
719
+ *
720
+ * `null` MEANS AUTO - the model table's window, or `buildRuntimeModel`'s 128k
721
+ * placeholder for a model the table has never seen. It exists because that
722
+ * placeholder is a FABRICATED denominator: on a custom `baseUrl` or an
723
+ * unlisted model id the gauge is a percentage of a number nobody supplied, and
724
+ * before this key there was no way to correct it. Setting it also removes the
725
+ * `?` marker from the absolute pair (I-6).
726
+ */
727
+ contextWindow: number | null;
695
728
  theme: ThemeName;
696
729
  /** Replace spinners with a static glyph for calmer, low-motion output. */
697
730
  reducedMotion: boolean;
@@ -835,6 +868,17 @@ export interface CliConfig {
835
868
  liveToolOutput: boolean;
836
869
  /** The resolved cap, or `undefined` for AUTO (per-model ceiling, ≤ 64000). */
837
870
  maxTokens?: number;
871
+ /**
872
+ * The user's context-window override, or `null` for AUTO.
873
+ *
874
+ * IT MUST EXIST HERE AND NOT ONLY IN `PersistedConfig`, for the reason
875
+ * `showThinking` / `liveToolOutput` / `diffRender` each record above: the
876
+ * runtime reads `config.*`, where `config` is a `CliConfig`, so a key added to
877
+ * the persisted shape alone would round-trip through `config set`, appear in
878
+ * `config list` - and never reach `ContextMeter`. `ARAGON_CONTEXT_WINDOW`
879
+ * would parse and be discarded, with nothing failing.
880
+ */
881
+ contextWindow: number | null;
838
882
  theme: ThemeName;
839
883
  reducedMotion: boolean;
840
884
  /**
@@ -115,6 +115,39 @@ export function clampTranscriptWindow(v, fallback) {
115
115
  const n = coercePositiveInt(v, fallback);
116
116
  return Math.min(MAX_TRANSCRIPT_WINDOW, Math.max(MIN_TRANSCRIPT_WINDOW, n));
117
117
  }
118
+ // ---------------------------------------------------------------------------
119
+ // Context window override (context-usage-gauge-accuracy §3.6 / W5)
120
+ // ---------------------------------------------------------------------------
121
+ /**
122
+ * Bounds for a hand-supplied context window.
123
+ *
124
+ * The floor is roughly the smallest window any current model ships with; the
125
+ * ceiling is well above the largest, and both exist to keep a typo (a missing
126
+ * or an extra zero) from producing a gauge that is permanently red or
127
+ * permanently flat.
128
+ */
129
+ export const MIN_CONTEXT_WINDOW = 8_000;
130
+ export const MAX_CONTEXT_WINDOW = 5_000_000;
131
+ /**
132
+ * THE single gate for every `contextWindow` read AND write.
133
+ *
134
+ * TRI-STATE, AND `null` MUST PASS STRAIGHT THROUGH (RV-12). `null` means AUTO -
135
+ * take the model table's number, or the 128k placeholder. Clamping it into
136
+ * `[8000, ...]` yields 8000, which removes "auto" from the config permanently
137
+ * and presents to the user as an 8k denominator with a permanently red bar and
138
+ * no visible source. `maxTokens` above is the existing tri-state precedent and
139
+ * this deliberately copies its shape rather than inventing a second convention.
140
+ */
141
+ export function clampContextWindow(v, fallback) {
142
+ if (v === null)
143
+ return null;
144
+ if (v === undefined || v === '')
145
+ return fallback;
146
+ const n = typeof v === 'number' ? v : Number.parseInt(String(v), 10);
147
+ if (!Number.isFinite(n) || n <= 0)
148
+ return fallback;
149
+ return Math.min(MAX_CONTEXT_WINDOW, Math.max(MIN_CONTEXT_WINDOW, Math.floor(n)));
150
+ }
118
151
  /**
119
152
  * Entries kept in `ViewState` at all (tui-render-performance L1 / R5).
120
153
  *
@@ -841,6 +874,9 @@ export const DEFAULT_CONFIG = {
841
874
  // The engine owns this number; spelling it here again is how the CLI and the
842
875
  // provider adapters end up disagreeing about what "the default" is.
843
876
  maxTokens: DEFAULT_MAX_OUTPUT_TOKENS,
877
+ // AUTO. The model table is right for every listed model, and a default number
878
+ // here would be the same fabricated denominator this key exists to correct.
879
+ contextWindow: null,
844
880
  theme: 'auto',
845
881
  reducedMotion: false,
846
882
  fullscreen: true,