@aragon-agent/cli 0.6.4 → 0.6.7

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 (72) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +108 -11
  3. package/dist/agent/controller.d.ts +39 -2
  4. package/dist/agent/controller.js +125 -0
  5. package/dist/agent/reducer.d.ts +33 -19
  6. package/dist/agent/reducer.js +62 -71
  7. package/dist/agent/usage.d.ts +34 -0
  8. package/dist/agent/usage.js +43 -0
  9. package/dist/cli.js +48 -1
  10. package/dist/commands/builtins.js +9 -0
  11. package/dist/compaction/compactor.d.ts +60 -24
  12. package/dist/compaction/compactor.js +89 -61
  13. package/dist/compaction/context-command.d.ts +33 -0
  14. package/dist/compaction/context-command.js +166 -0
  15. package/dist/compaction/meter.d.ts +251 -0
  16. package/dist/compaction/meter.js +428 -0
  17. package/dist/compaction/pressure.d.ts +6 -0
  18. package/dist/compaction/pressure.js +4 -0
  19. package/dist/compaction/types.d.ts +41 -0
  20. package/dist/compaction/wiring.d.ts +47 -0
  21. package/dist/compaction/wiring.js +88 -27
  22. package/dist/config/cli-commands.js +5 -0
  23. package/dist/config/env.js +15 -1
  24. package/dist/config/load.d.ts +17 -0
  25. package/dist/config/load.js +45 -1
  26. package/dist/config/schema.d.ts +44 -0
  27. package/dist/config/schema.js +36 -0
  28. package/dist/config/store.js +27 -1
  29. package/dist/exec/events.d.ts +138 -1
  30. package/dist/exec/events.js +42 -0
  31. package/dist/exec/index.js +43 -6
  32. package/dist/exec/progress-reporter.d.ts +23 -0
  33. package/dist/exec/progress-reporter.js +88 -0
  34. package/dist/exec/runner.d.ts +34 -0
  35. package/dist/exec/runner.js +137 -25
  36. package/dist/fast/reviewer.d.ts +19 -1
  37. package/dist/fast/reviewer.js +22 -0
  38. package/dist/fast/types.d.ts +15 -0
  39. package/dist/fast/wiring.d.ts +10 -1
  40. package/dist/fast/wiring.js +18 -1
  41. package/dist/todo/limits.d.ts +4 -17
  42. package/dist/todo/limits.js +4 -17
  43. package/dist/todo/panel-rows.d.ts +8 -6
  44. package/dist/todo/panel-rows.js +19 -6
  45. package/dist/ui/App.js +75 -63
  46. package/dist/ui/AutocompletePopup.d.ts +3 -1
  47. package/dist/ui/AutocompletePopup.js +9 -11
  48. package/dist/ui/BottomStatusRow.d.ts +1 -1
  49. package/dist/ui/Composer.d.ts +3 -1
  50. package/dist/ui/Composer.js +2 -2
  51. package/dist/ui/PromptInput.d.ts +4 -1
  52. package/dist/ui/PromptInput.js +19 -5
  53. package/dist/ui/StatusBar.d.ts +21 -15
  54. package/dist/ui/StatusBar.js +48 -9
  55. package/dist/ui/TeamPanel.d.ts +3 -1
  56. package/dist/ui/TeamPanel.js +13 -9
  57. package/dist/ui/TodoPanel.d.ts +5 -5
  58. package/dist/ui/TodoPanel.js +56 -24
  59. package/dist/ui/UpdateLine.d.ts +1 -1
  60. package/dist/ui/capabilities.js +5 -5
  61. package/dist/ui/exit-snapshot.d.ts +9 -0
  62. package/dist/ui/layout/ScrollViewport.d.ts +1 -1
  63. package/dist/ui/layout/autocomplete.d.ts +18 -0
  64. package/dist/ui/layout/autocomplete.js +26 -0
  65. package/dist/ui/layout/rail.d.ts +4 -53
  66. package/dist/ui/layout/rail.js +10 -62
  67. package/dist/ui/layout/team-panel.d.ts +18 -0
  68. package/dist/ui/layout/team-panel.js +22 -0
  69. package/dist/ui/layout/todo-layout.d.ts +24 -0
  70. package/dist/ui/layout/todo-layout.js +27 -0
  71. package/dist/ui/transcript-text.js +6 -2
  72. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -9,6 +9,20 @@ were written.
9
9
 
10
10
  ### Added
11
11
 
12
+ - **`system/init` now lists what this build can do.** A new optional
13
+ `capabilities: string[]` field, carrying `["interrupt"]` for this release.
14
+
15
+ A wrapper cannot tell by observation whether an interrupt will settle
16
+ gracefully or kill the process outright, and guessing wrong costs the user
17
+ their conversation. Rather than have callers compare version numbers — which
18
+ breaks on forks, prereleases, and version shapes nobody anticipated — the
19
+ build now says so itself, in an event that already arrives on every start.
20
+ Absent on older builds, and absence should be read as "no", never as
21
+ "probably".
22
+
23
+ Additive, so `schemaVersion` does not move. Consumers that do not know the
24
+ field ignore it, exactly as the schema contract asks.
25
+
12
26
  - **The composer understands pasting.** Until now it did not have the concept at
13
27
  all: every byte the terminal delivered on stdin was a keystroke, and a
14
28
  clipboard block is thousands of bytes arriving in an unpredictable number of
@@ -58,6 +72,29 @@ were written.
58
72
 
59
73
  ### Fixed
60
74
 
75
+ - **`{"type":"interrupt"}` now really does settle the run — on every platform.**
76
+ The stdin interrupt frame used to raise a SIGINT at our own process. On POSIX
77
+ that reaches the runner's terminator and the run winds down properly: it
78
+ breaks out of the loop, persists the session, and emits `result` with the
79
+ usage and cost. On Windows, Node does not deliver a self-directed SIGINT to
80
+ its own handlers — the process simply terminated with exit code 1, having run
81
+ none of it.
82
+
83
+ For anyone using `aragon exec` interactively that difference was invisible,
84
+ because an interrupt there is usually the last thing that happens. It is not
85
+ invisible to a **wrapper** that interrupts a turn and then respawns on the same
86
+ `--session-id` to keep the conversation going: on Windows it silently got back
87
+ a session missing everything since the previous save. `exec/index.ts` has
88
+ carried a comment about that caller for some time ("PERSIST FIRST, ANNOUNCE
89
+ SECOND … a caller that reads `result` and immediately respawns with the same
90
+ `--session-id` must not race the writer"), so the intent was there; the path
91
+ just could not be reached on Windows.
92
+
93
+ The frame now calls the runner directly instead of going through a signal.
94
+ POSIX behaviour is byte-for-byte unchanged — the same code runs, with the same
95
+ `stopReason`, the same exit code 130, and the same two-second window in which a
96
+ second signal still forces an immediate exit.
97
+
61
98
  - **A pasted API key is stored correctly again.** Ink broadcasts each stdin chunk
62
99
  to every mounted input handler, so the settings screen, the question overlay
63
100
  and the plan-review feedback field all see what the composer sees. Without
package/README.md CHANGED
@@ -413,6 +413,7 @@ signal ends the run (`130` SIGINT, `143` SIGTERM, `129` SIGHUP).
413
413
  | `/retry [show\|on\|off\|max <n>]` | API retry: the effective ladder, the on/off switch, the retry count. Takes effect **in the running session**, children included |
414
414
  | `/fast [on\|off\|model <id>\|provider <id>\|same\|review <n\|off>\|delegate on\|off]` | Fast model tier: status and this session's totals, the on/off switch, which model, the review cadence |
415
415
  | `/compact [status\|on\|off\|threshold <n>\|keep <n>\|history\|show <n>\|<instructions>]` | Context compaction: occupancy and this session's totals, the on/off switch, when it fires, how much it keeps. With free text, compacts now and tells the summarizer what to pay attention to |
416
+ | `/context` | What the context gauge is showing: occupancy split into measured and estimated, the window and **where it came from**, how stale the measurement is, compaction's actual state, and what the session-spend readout includes |
416
417
  | `/bg [list\|logs <id> [n]\|stop <id\|all>\|status]` | Background services: what is running, its log tail, and how to stop it. `list` is the default |
417
418
  | `/update [status\|now\|skip\|off]` | Auto-update: what is running, what is available, where it came from, when it looks again. `now` forces a check past the throttle; `skip` declines the offered version until a newer one appears |
418
419
  | `/theme <auto\|warm\|cool\|light>` | Switch the color theme live (persisted) |
@@ -799,7 +800,8 @@ ask — and the agent writes the finished plan as its final message in markdown.
799
800
  For work with three or more distinct steps the agent keeps a **visible plan**:
800
801
  it writes a checklist with the `todo_write` tool, marks exactly one item as in
801
802
  progress, and ticks it off before starting the next. In full-screen mode that
802
- list is rendered as a right-hand rail, about a fifth of the terminal wide.
803
+ list is rendered as a right-hand rail, targeting 15% of the terminal width
804
+ (14–36 columns, including its separator and padding).
803
805
 
804
806
  ```
805
807
  transcript ... | TODO 2/7
@@ -807,8 +809,7 @@ list is rendered as a right-hand rail, about a fifth of the terminal wide.
807
809
  |
808
810
  | v 1 Read the reducer
809
811
  | v 2 Design the store
810
- | > 3 Wiring the rail into
811
- | AppShell
812
+ | > 3 Wiring the rail...
812
813
  | o 4 Add the panel-rows test
813
814
  | o 5 Update the README
814
815
  | +2 more
@@ -832,7 +833,7 @@ not also tell the model may change it — except you, saying so:
832
833
  conversation has just been replaced.
833
834
  - A finished plan is dropped at the START of your next message, so you still get
834
835
  to see `7/7 done`. An unfinished one survives "continue" / "now do the rest",
835
- and is dropped after three unrelated turns rather than holding a fifth of the
836
+ and is dropped after three unrelated turns rather than holding part of the
836
837
  screen for the rest of the session.
837
838
 
838
839
  ### When a run ends with steps left
@@ -888,11 +889,19 @@ planning discipline and hides the column — which is what you want with a scree
888
889
  reader, and what `--no-todo-panel` does for a whole session. Under `-p` the plan
889
890
  is written to stderr as `[todo] 3/7 <step>` lines, suppressed by `--quiet`.
890
891
 
891
- The rail is not mounted below 80 columns, while an overlay is open, in inline
892
- mode, or when the viewport is too short to say anything useful; the transcript
893
- keeps at least 62 columns at every width. In those cases the counter appears in
894
- the status bar instead, and the checklist is still written into the transcript
895
- as one card per turn.
892
+ 右栏从 76 列开始显示,正文至少保留 62 列;100 列终端使用 15 列右栏。
893
+ 已有任务时,团队面板按实际行数占用空间,补全菜单的边框和提示也计入预算。
894
+ 例如 100×20、团队占 8 行时,TODO 仍以 4 行紧凑视图显示。
895
+
896
+ 剩余 3–5 行时保留标题、完成计数、当前任务与合并提示 `-A +B`:
897
+ `-9 +10` 表示上方隐藏 9 项、下方隐藏 10 项。6 行起使用常规视图;
898
+ 所有任务各占一行,全文仍可在正文任务卡片中查看。进度条需要至少 20 列、8 行。
899
+ 输入增长导致空间不足时团队先折叠为摘要;菜单不足 3 行时隐藏,其候选不会拦截输入。
900
+
901
+ 低于 76 列、共享区域不足 3 行、打开 overlay、显式关闭面板、没有任务或
902
+ inline 模式时不显示右栏。低于 12 行使用原有小终端提示;扩大窗口后自动恢复。
903
+ inline 仍使用上文的单行任务条。`/todo status` 中的 `Panel: on` 仅表示配置开启,
904
+ 实际可见性还取决于数据和窗口空间;可以通过该命令区分未生成计划与暂时隐藏。
896
905
 
897
906
  ## Built-in tools
898
907
 
@@ -1211,6 +1220,84 @@ it for a background advisory is how an unrequested call ends up competing with
1211
1220
  you for a rate-limited provider's quota. The review's own wall-clock bound is
1212
1221
  20 s.
1213
1222
 
1223
+ ## The context gauge
1224
+
1225
+ The right end of the status bar answers one question: **how full is the context,
1226
+ out of how much.**
1227
+
1228
+ ```
1229
+ [####----] 43% 86.0k/200.0k total 1.2M^ 48.0k v $3.21 12 tok/s 1m02s
1230
+ ^ ^ ^
1231
+ | | +-- session spend, prompt side / output side
1232
+ | +-- occupied / window
1233
+ +-- the two together, as a percentage
1234
+ ```
1235
+
1236
+ The readouts drop as the terminal narrows, in that order of priority:
1237
+
1238
+ | Columns | Right cluster |
1239
+ |---|---|
1240
+ | >= 96 | `[####----] 43% 86.0k/200.0k total 1.2M^ 48.0k v $3.21` |
1241
+ | 72-95 | `[####----] 43% 86.0k/200.0k $3.21` |
1242
+ | 60-71 | `[####----] 43% $3.21` |
1243
+ | < 60 | `43% $3.21` |
1244
+
1245
+ **Two different markers, because there are two different things to doubt.**
1246
+
1247
+ - `~` sits on the **percentage** and means the *numerator* is partly a guess:
1248
+ either no completed turn has reported usage yet (right after `/resume`, or
1249
+ right after a compaction), or tool results have been appended since the last
1250
+ measurement and their size is estimated.
1251
+ - `?` sits on the **window** and means the *denominator* is a guess: the model is
1252
+ not in the built-in table, so a flat 128 000 was substituted. See
1253
+ `contextWindow` below.
1254
+
1255
+ **The percentage keeps moving during a turn.** Every tool result nudges it,
1256
+ whether or not compaction is enabled — it is not a compaction feature, and a
1257
+ session started with `--no-compaction` gets the same live reading. After
1258
+ `/resume` the gauge shows the restored session's real occupancy immediately,
1259
+ without sending anything.
1260
+
1261
+ ### `/context`
1262
+
1263
+ The gauge is a bar and a rounded percentage; `/context` is the full answer, at
1264
+ any width, in any mode:
1265
+
1266
+ ```
1267
+ Context
1268
+ Occupancy 43% 86.2k of 200.0k [measured 85.1k + 1.1k estimated]
1269
+ Window 200000 from the model table
1270
+ Since measured 1.1k estimated tokens appended since the last provider-reported usage
1271
+ Compaction on - triggers at 90% (amber at 75%), 2 this session, 118.0k reclaimed
1272
+ Session spend 1.2M in (incl. 940.0k cache read, 12.0k cache write) / 48.2k out, $3.21
1273
+ includes subagent, fast-tier and compaction spend,
1274
+ not just this conversation
1275
+ ```
1276
+
1277
+ Two lines are worth reading closely. **`Window`** always names where the number
1278
+ came from — the model table, your own `contextWindow`, or the invented
1279
+ placeholder — because those are indistinguishable everywhere else. And
1280
+ **`Compaction`** reports what is actually true of this session, including
1281
+ `off - not registered for this session (started with --no-compaction)`; it never
1282
+ promises a threshold that nothing will act on.
1283
+
1284
+ ### `contextWindow`
1285
+
1286
+ When the model is not in the built-in table — a custom `baseUrl`, a self-hosted
1287
+ id, something released last week — the denominator is a **fabricated** 128 000
1288
+ and the percentage is meaningless. Correct it:
1289
+
1290
+ ```bash
1291
+ aragon config set contextWindow 1000000 # persisted; prints "Set contextWindow = 1000000"
1292
+ aragon config set contextWindow auto # back to the model table / placeholder
1293
+ ARAGON_CONTEXT_WINDOW=1000000 aragon # one session
1294
+ ```
1295
+
1296
+ Values are clamped to `[8000, 5000000]`; `auto` (the default) is stored as
1297
+ `null`. Setting it removes the `?` marker, because you have asserted the number —
1298
+ and `/context` still shows that it came from you, which is what makes a wrong
1299
+ value findable.
1300
+
1214
1301
  ## Context compaction
1215
1302
 
1216
1303
  A long agentic run fills the model's context window. Before this existed, the
@@ -1307,7 +1394,10 @@ Three surfaces, each answering something the others cannot:
1307
1394
  - **As state** — a `compacting` chip in the status bar, and the context gauge's
1308
1395
  colours aligned to your own thresholds. The gauge **falls immediately** after a
1309
1396
  compaction rather than waiting for the next turn, with a `~` while the value is
1310
- derived rather than measured.
1397
+ derived rather than measured. The colours follow your configured thresholds
1398
+ whenever compaction is **enabled**, even if no summarizer model currently
1399
+ resolves; after `/compact off` they fall back to the generic 60 / 85, because
1400
+ colouring by a rescue that is not coming is worse than not colouring at all.
1311
1401
  - **As a record** — a transcript card with the before/after message and token
1312
1402
  counts, the summarizer, the duration, and the summary itself. `Ctrl+O` expands
1313
1403
  it.
@@ -1320,7 +1410,9 @@ self-disabled, or the ladder ran out.
1320
1410
 
1321
1411
  `/compact status` is the guaranteed surface: the chip drops on a narrow terminal
1322
1412
  and the card scrolls away, but the command answers at any width, in any mode,
1323
- after the fact.
1413
+ after the fact. For the gauge specifically, [`/context`](#the-context-gauge) is
1414
+ the equivalent — and it works in a session that never registered compaction at
1415
+ all.
1324
1416
 
1325
1417
  ### When the RECENT turns are what does not fit
1326
1418
 
@@ -1701,6 +1793,8 @@ env / `.env` → CLI flags**.
1701
1793
  reasoning blocks), `ARAGON_LIVE_TOOL_OUTPUT` (`0` keeps a running tool card to
1702
1794
  one line),
1703
1795
  `ARAGON_MAX_TOKENS` (a number, or `auto` / `0` for the per-model ceiling),
1796
+ `ARAGON_CONTEXT_WINDOW` (a number, or `auto`; the denominator the context
1797
+ gauge measures against),
1704
1798
  `ARAGON_THEME`,
1705
1799
  `ARAGON_FULLSCREEN`, `ARAGON_PLAN` (`1` starts in plan mode),
1706
1800
  `ARAGON_TEAM` (`0` disables team subagents), `ARAGON_TEAM_MAX` (fan-out width),
@@ -1728,6 +1822,7 @@ env / `.env` → CLI flags**.
1728
1822
  | Config key | Default | Meaning |
1729
1823
  | --- | --- | --- |
1730
1824
  | `maxTokens` | `64000` | Output token cap, clamped to `[256, 200000]`. `null` means **auto** — use each model's own ceiling, never above 64000. Absent means the default. See [Output token limits](#output-token-limits). |
1825
+ | `contextWindow` | `null` | The window the context gauge measures against, clamped to `[8000, 5000000]`. `null` means **auto** — the built-in model table, or a fabricated 128000 for a model it has never seen. Set it when the gauge shows `?` on the denominator. See [The context gauge](#the-context-gauge). |
1731
1826
  | `showThinking` | `false` | Draw the reasoning the model returns. `thinkingLevel` is the effort the provider is asked to **spend**; this is whether the terminal **shows** it. Off by default: a settled turn that thought leaves one muted `thought for 12s` row in its place, so nothing is hidden silently. |
1732
1827
  | `liveToolOutput` | `true` | Draw up to eight sanitised rows of a **running** tool's output on its card, plus a `no output for Ns` row when the child goes quiet. On by default, unlike `showThinking`: this ADDS the information a long `bash` call otherwise hides, and its cost is bounded by construction — eight rows per call, sixteen calls, whatever the command emits. Turn it off and the card is a single `running` row again, with no store allocated and no recorder attached. |
1733
1828
  | `fullscreen` | `true` | Use the full-screen TUI (still subject to the automatic downgrades). Setting it to `false` opts out permanently; leaving it `true` is *not* a force — only `--fullscreen` / `ARAGON_FULLSCREEN=1` override the heuristics. |
@@ -1805,6 +1900,8 @@ ARAGON_MAX_TOKENS=32000 aragon # env layer, between the flag and the file
1805
1900
  aragon config set maxTokens 32000 # persisted; prints "Set maxTokens = 32000"
1806
1901
  aragon config set maxTokens auto # persisted as null
1807
1902
  aragon config set maxTokens default # back to 64000
1903
+ aragon config set contextWindow 1000000 # the gauge's denominator
1904
+ aragon config set contextWindow auto # back to the model table
1808
1905
  aragon config get maxTokens # "64000" | "auto"
1809
1906
  ```
1810
1907
 
@@ -18,8 +18,9 @@ import type { TeamEvent, TeamSnapshot } from '../team/types.js';
18
18
  import { ProcSupervisor } from '../proc/supervisor.js';
19
19
  import type { ProcEventListener, ServiceSnapshot } from '../proc/types.js';
20
20
  import { type FastStatus } from '../fast/wiring.js';
21
- import type { FastEventListener } from '../fast/types.js';
22
- import type { CompactionEventListener, CompactionSnapshot } from '../compaction/types.js';
21
+ import type { FastEventListener, FastSnapshot } from '../fast/types.js';
22
+ import { ContextMeter, type ContextUsageListener } from '../compaction/meter.js';
23
+ import type { CompactionEventListener, CompactionSnapshot, ContextUsageSnapshot } from '../compaction/types.js';
23
24
  import type { TodoEventListener, TodoSnapshot } from '../todo/types.js';
24
25
  import { type HumanInputGate } from '../tools/human-input.js';
25
26
  import { SkillService, type ApprovalGate } from '../skills/service.js';
@@ -178,6 +179,7 @@ export declare class AgentController {
178
179
  private readonly outputListeners;
179
180
  private readonly fast;
180
181
  private readonly compaction;
182
+ private readonly contextMeter;
181
183
  private readonly procs;
182
184
  /**
183
185
  * The host's notice channel, kept so `prompt()`'s totality has somewhere to
@@ -612,6 +614,41 @@ export declare class AgentController {
612
614
  * `subscribeTodos` already set.
613
615
  */
614
616
  subscribeFast(listener: FastEventListener): () => void;
617
+ /**
618
+ * The FIFTH thin forwarder: the current fast-tier snapshot, or `null` when
619
+ * this session never registered a tier at all
620
+ * (web-use-tier-cooperation-and-control-closure §4.2.1).
621
+ *
622
+ * `null` and "a snapshot with `live: false`" are different answers and callers
623
+ * depend on the difference: the first means "there is no tier here", the
624
+ * second means "there is one and it is not usable right now".
625
+ *
626
+ * Deliberately NOT folded into the existing `review_end` event: that shape is
627
+ * consumed by `App` too, and this round promised to be purely additive.
628
+ */
629
+ fastSnapshot(): FastSnapshot | null;
630
+ /**
631
+ * Subscribe to occupancy publications. Returns an unsubscribe function.
632
+ *
633
+ * THE SOLE UPSTREAM OF `ViewState.context` (I-1). The gauge used to have two
634
+ * writers - `turnEnd` and a `contextTokensEstimated` dispatch fired from two
635
+ * places in `App` - which is the entire cause of P0-1: both wrote in the same
636
+ * synchronous fan-out and the second one won, restoring the pre-compaction
637
+ * figure one statement after the bar had correctly fallen. One writer makes
638
+ * that class of bug unrepresentable rather than merely fixed.
639
+ */
640
+ subscribeContextUsage(listener: ContextUsageListener): () => void;
641
+ /**
642
+ * Occupancy right now, re-measuring if the history has moved.
643
+ *
644
+ * `/context` READS THIS AND NOT `getCompactionSnapshot().pressure` (RV-8). The
645
+ * latter returns `offCompactionSnapshot()`'s hardcoded zero pressure when
646
+ * compaction was never registered, so a report built on it would read 0 % for
647
+ * precisely the sessions this feature exists to fix.
648
+ */
649
+ getContextUsage(): ContextUsageSnapshot;
650
+ /** The meter itself, for the wiring's single-instance assertion and tests. */
651
+ getContextMeter(): ContextMeter;
615
652
  /** Whether a `ContextManager` exists at all. Fixed at construction. */
616
653
  isCompactionRegistered(): boolean;
617
654
  /** Whether it will actually fire. Flipped live by `/compact on|off`. */
@@ -28,6 +28,7 @@ import { addSignalHook } from '../logging/install.js';
28
28
  import { describeFastTierProblem, fastProviderOf, resolveFastTier } from '../fast/resolve.js';
29
29
  import { FastWiring, offFastStatus } from '../fast/wiring.js';
30
30
  import { CompactionWiring, offCompactionSnapshot } from '../compaction/wiring.js';
31
+ import { ContextMeter } from '../compaction/meter.js';
31
32
  import { TodoStore } from '../todo/store.js';
32
33
  import { createTodoTool } from '../todo/todo-tool.js';
33
34
  import { buildTodoBlock } from '../todo/prompt.js';
@@ -187,6 +188,23 @@ export class AgentController {
187
188
  // -----------------------------------------------------------------------
188
189
  compaction;
189
190
  // -----------------------------------------------------------------------
191
+ // Context occupancy (context-usage-gauge-accuracy §3.2 / W1)
192
+ //
193
+ // CONSTRUCTED UNCONDITIONALLY, unlike its neighbour, and that asymmetry IS the
194
+ // feature. `compaction` above is `null` for a session started with
195
+ // `--no-compaction` - and before this field existed, that took the only
196
+ // measuring code in the process with it: such a session's gauge had exactly
197
+ // one sample per turn (the provider's `turn_end` usage) and read `0 %` after a
198
+ // `/resume`. Occupancy is not a compaction concern; it is what compaction
199
+ // READS.
200
+ //
201
+ // IT COSTS A HEADLESS HOST NOTHING (I-11). `aragon exec` builds an
202
+ // `AgentController` too, and nothing there reads this number; the meter arms
203
+ // no timer while it has no subscribers, so the extra object is a few scalars
204
+ // and a `Set`.
205
+ // -----------------------------------------------------------------------
206
+ contextMeter;
207
+ // -----------------------------------------------------------------------
190
208
  // Background services (background-service-supervision §3.3 / §3.6)
191
209
  //
192
210
  // CONSTRUCTED UNCONDITIONALLY, and that is the one thing about this field that
@@ -500,6 +518,28 @@ export class AgentController {
500
518
  modelId: config.model,
501
519
  ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
502
520
  };
521
+ // --- Context occupancy, part 1: BEFORE the Agent, UNCONDITIONALLY ------
522
+ //
523
+ // Built here for the same C-12 reason the wiring is - lazy `getMessages` /
524
+ // `getSystemPrompt` closures, `attach` after `new Agent` - and built for
525
+ // EVERY session, including one with compaction off, because that is the one
526
+ // whose gauge was broken (P1-4 / P0-2).
527
+ this.contextMeter = new ContextMeter({
528
+ getMessages: () => this.agent.state.messages,
529
+ getSystemPrompt: () => this.agent.state.systemPrompt,
530
+ getModelInfo: () => this.getModelInfo(),
531
+ // ONE PREDICATE FOR "IS THE DENOMINATOR REAL". `isPricedModel` is
532
+ // `modelRegistry.getModel(...) !== undefined`, and `getModelInfoFor` falls
533
+ // back to `buildRuntimeModel` - a 128k placeholder AND a zero cost table -
534
+ // for exactly the models it answers `false` for. The two are the same
535
+ // question about the same static table, not a coincidence.
536
+ isWindowKnown: () => this.isPricedModel({
537
+ providerId: this.config.provider,
538
+ modelId: this.config.model,
539
+ }),
540
+ // LIVE, so a settings-screen edit moves the denominator without a relaunch.
541
+ getWindowOverride: () => this.config.contextWindow,
542
+ });
503
543
  // --- Context compaction, part 1: BEFORE the Agent ----------------------
504
544
  //
505
545
  // It needs no agent reference to EXIST — only live config, the key
@@ -517,6 +557,9 @@ export class AgentController {
517
557
  getMessages: () => this.agent.state.messages,
518
558
  getSystemPrompt: () => this.agent.state.systemPrompt,
519
559
  notify: (level, text) => deps.notify?.(level, text),
560
+ // ONE METER PER PROCESS (R-1). The wiring forwards it to the compactor,
561
+ // so the trigger and the gauge cannot read two different numbers.
562
+ meter: this.contextMeter,
520
563
  })
521
564
  : null;
522
565
  this.agent = new Agent({
@@ -567,6 +610,15 @@ export class AgentController {
567
610
  // subscribed could not run before `new Agent(...)`, and the port has to be
568
611
  // inside its constructor argument (C-12 / P0-2).
569
612
  this.compaction?.attach((listener) => this.agent.subscribe(listener));
613
+ // --- Context occupancy, part 2: after the Agent exists -----------------
614
+ //
615
+ // ORDER RELATIVE TO THE LINE ABOVE IS DELIBERATELY IRRELEVANT (I-9 / T7).
616
+ // The append direction is order-free because the meter marks itself dirty
617
+ // and re-measures on read; the replace direction is order-free because the
618
+ // splice is announced from `settlePending`'s own synchronous code rather
619
+ // than from a listener. If a future change makes this ordering matter, the
620
+ // fix is at those two sites, not here.
621
+ this.contextMeter.attach((listener) => this.agent.subscribe(listener));
570
622
  // --- Fast tier, part 2: after the Agent exists --------------------------
571
623
  //
572
624
  // `FastWiring` subscribes to the lead's event stream, so it cannot be built
@@ -1207,6 +1259,7 @@ export class AgentController {
1207
1259
  this.teamRuntime?.dispose();
1208
1260
  this.fast?.dispose();
1209
1261
  this.compaction?.dispose();
1262
+ this.contextMeter.dispose();
1210
1263
  // REAP BEFORE RELEASING THE HOOK, in that order: disposal is a teardown, and
1211
1264
  // a service that outlives it has nothing left that could ever stop it.
1212
1265
  this.procs.reapSync();
@@ -1337,6 +1390,13 @@ export class AgentController {
1337
1390
  // so the snapshot the chip and `/compact status` read has to be refreshed
1338
1391
  // for the same reason and at the same moments (the RV-3 lesson).
1339
1392
  this.compaction?.onConfigChanged();
1393
+ // THE DENOMINATOR MOVED, NOT THE HISTORY (context-usage-gauge-accuracy §3.4).
1394
+ // A re-measure, deliberately NOT a reset: the measured base is still a true
1395
+ // statement about the messages, and throwing it away would swap a correct
1396
+ // numerator for a whole-history estimate on a common operation. The
1397
+ // numerator's calibration is one turn stale until the next `turn_end`
1398
+ // (RV-13), which is the trade this call names.
1399
+ this.contextMeter.onWindowChanged();
1340
1400
  }
1341
1401
  setThinkingLevel(level) {
1342
1402
  this.config = { ...this.config, thinkingLevel: level };
@@ -1435,7 +1495,14 @@ export class AgentController {
1435
1495
  // INVALIDATION SITE 3 (context-auto-compaction-hardening §3.2.3). The history
1436
1496
  // was CLEARED, not appended to, so the prefix length the last measurement
1437
1497
  // covered indexes an array that no longer exists.
1498
+ //
1499
+ // BOTH LINES, AND THE SECOND IS NOT REDUNDANT (context-usage-gauge-accuracy
1500
+ // I-9). The wiring is `null` for a `--no-compaction` session, so it is the
1501
+ // meter call that guarantees a re-measure - deleting it "to de-duplicate"
1502
+ // reintroduces P0-2 for exactly the sessions this feature was written for. A
1503
+ // deep reset is idempotent, so both firing costs nothing.
1438
1504
  this.compaction?.onHistoryReplaced();
1505
+ this.contextMeter.onHistoryReplaced();
1439
1506
  // A new conversation has no loaded skills, so the "already loaded earlier
1440
1507
  // in this conversation" hint would otherwise start lying (P2-9).
1441
1508
  this.skills.getRegistry().clearActive();
@@ -1455,7 +1522,15 @@ export class AgentController {
1455
1522
  // INVALIDATION SITE 4 (§3.2.3) - `/resume`, and any future host-side rewrite.
1456
1523
  // `estimateAppendedTokens` bounds-checks as well, so a MISSED site costs
1457
1524
  // accuracy and never correctness; that belt does not make this call optional.
1525
+ //
1526
+ // THE SECOND LINE IS THE P0-2 FIX. Before it, `/resume` of a 180k-token
1527
+ // session left the gauge and `/compact status` reading 0 %: nothing
1528
+ // re-measured and nothing published, so the mount-time "empty history"
1529
+ // reading stood until the next completed turn. The DEEP reset also drops
1530
+ // `estimateOffset`, because `/resume` can change the model in the same
1531
+ // breath (I-8).
1458
1532
  this.compaction?.onHistoryReplaced();
1533
+ this.contextMeter.onHistoryReplaced();
1459
1534
  }
1460
1535
  getMessages() {
1461
1536
  return this.agent.state.messages;
@@ -1571,6 +1646,56 @@ export class AgentController {
1571
1646
  return () => { };
1572
1647
  return this.fast.subscribe(listener);
1573
1648
  }
1649
+ /**
1650
+ * The FIFTH thin forwarder: the current fast-tier snapshot, or `null` when
1651
+ * this session never registered a tier at all
1652
+ * (web-use-tier-cooperation-and-control-closure §4.2.1).
1653
+ *
1654
+ * `null` and "a snapshot with `live: false`" are different answers and callers
1655
+ * depend on the difference: the first means "there is no tier here", the
1656
+ * second means "there is one and it is not usable right now".
1657
+ *
1658
+ * Deliberately NOT folded into the existing `review_end` event: that shape is
1659
+ * consumed by `App` too, and this round promised to be purely additive.
1660
+ */
1661
+ fastSnapshot() {
1662
+ return this.fast?.snapshot() ?? null;
1663
+ }
1664
+ // -----------------------------------------------------------------------
1665
+ // Context occupancy (context-usage-gauge-accuracy §4.2) — THREE FORWARDERS
1666
+ //
1667
+ // ALL THREE ARE AVAILABLE UNCONDITIONALLY, which is the whole difference
1668
+ // between this block and the compaction one below it: there is no
1669
+ // `?? offSomething()` fallback here, because the meter always exists.
1670
+ // -----------------------------------------------------------------------
1671
+ /**
1672
+ * Subscribe to occupancy publications. Returns an unsubscribe function.
1673
+ *
1674
+ * THE SOLE UPSTREAM OF `ViewState.context` (I-1). The gauge used to have two
1675
+ * writers - `turnEnd` and a `contextTokensEstimated` dispatch fired from two
1676
+ * places in `App` - which is the entire cause of P0-1: both wrote in the same
1677
+ * synchronous fan-out and the second one won, restoring the pre-compaction
1678
+ * figure one statement after the bar had correctly fallen. One writer makes
1679
+ * that class of bug unrepresentable rather than merely fixed.
1680
+ */
1681
+ subscribeContextUsage(listener) {
1682
+ return this.contextMeter.subscribe(listener);
1683
+ }
1684
+ /**
1685
+ * Occupancy right now, re-measuring if the history has moved.
1686
+ *
1687
+ * `/context` READS THIS AND NOT `getCompactionSnapshot().pressure` (RV-8). The
1688
+ * latter returns `offCompactionSnapshot()`'s hardcoded zero pressure when
1689
+ * compaction was never registered, so a report built on it would read 0 % for
1690
+ * precisely the sessions this feature exists to fix.
1691
+ */
1692
+ getContextUsage() {
1693
+ return this.contextMeter.currentUsage();
1694
+ }
1695
+ /** The meter itself, for the wiring's single-instance assertion and tests. */
1696
+ getContextMeter() {
1697
+ return this.contextMeter;
1698
+ }
1574
1699
  // -----------------------------------------------------------------------
1575
1700
  // Context compaction (context-auto-compaction §3.2.1) — THIN FORWARDERS
1576
1701
  // -----------------------------------------------------------------------
@@ -14,7 +14,7 @@ import type { AgentEvent, ModelCost, ToolResult, TokenUsage } from '@aragon-agen
14
14
  import type { AgentMode } from './agent-mode.js';
15
15
  import type { DispatchOutcome, SubagentRun, SubagentSpec, TeamSnapshot } from '../team/types.js';
16
16
  import type { FastReview, FastSnapshot } from '../fast/types.js';
17
- import type { CompactionMode, CompactionRecord, CompactionSnapshot, CompactionUiTrigger } from '../compaction/types.js';
17
+ import type { CompactionMode, CompactionRecord, CompactionSnapshot, CompactionUiTrigger, ContextUsageSnapshot } from '../compaction/types.js';
18
18
  import type { TodoItem, TodoSnapshot } from '../todo/types.js';
19
19
  import type { ServiceSnapshot, ServiceStatus } from '../proc/types.js';
20
20
  import type { RetryPhase } from './retry-view.js';
@@ -329,6 +329,16 @@ export type Entry = {
329
329
  export interface UsageTotal {
330
330
  inputTokens: number;
331
331
  outputTokens: number;
332
+ /**
333
+ * Cache read hits (context-usage-gauge-accuracy §3.6 / P1-3).
334
+ *
335
+ * `occupiedTokens` counts them and `computeCost` PRICES them, so a session
336
+ * total that omits them makes the status bar's `^` read lower than the `$`
337
+ * next to it - on the same row, from the same turns. That was three units on
338
+ * one line, and this field plus its sibling are what collapse them to one.
339
+ */
340
+ cacheReadTokens: number;
341
+ cacheWriteTokens: number;
332
342
  costUsd: number;
333
343
  }
334
344
  /**
@@ -349,8 +359,20 @@ export interface ViewState {
349
359
  entries: Entry[];
350
360
  status: 'idle' | 'running';
351
361
  usageTotal: UsageTotal;
352
- /** Running input size of the most recent turn vs. the model context window. */
353
- contextTokens: number;
362
+ /**
363
+ * Context occupancy, as published by `AgentController`'s `ContextMeter`.
364
+ *
365
+ * ITS ONLY WRITER IS `case 'contextUsage'` (I-1), and that is the structural
366
+ * half of the P0-1 fix. It replaced `contextTokens` + `contextTokensEstimated`,
367
+ * which between them had THREE writing branches (`turnEnd`,
368
+ * `contextTokensEstimated`, `resetConversation`) fed by two competing
369
+ * upstreams. Two writers in one synchronous fan-out means the later one wins,
370
+ * and the later one was the stale `snapshot` carrying the PRE-compaction
371
+ * figure - so the bar fell and bounced straight back, every time, with nothing
372
+ * logging a fault. `context-one-writer.test.ts` scans this file to keep it
373
+ * that way.
374
+ */
375
+ context: ContextUsageSnapshot;
354
376
  overlay: Overlay;
355
377
  thinkingVisible: boolean;
356
378
  /** Ephemeral acks; auto-dismissed by App (spec §3.9). */
@@ -408,16 +430,6 @@ export interface ViewState {
408
430
  * totals. `null` is what leaves an ordinary session's status bar unchanged.
409
431
  */
410
432
  compaction: CompactionSnapshot | null;
411
- /**
412
- * Whether `contextTokens` is currently a DERIVED figure rather than a measured
413
- * one (§6.2 / P1-11).
414
- *
415
- * Set by `compactionEnd`, cleared by the next `turnEnd`. The status bar widens
416
- * its leading `~` to cover it: today `~` means "the window is a guess", and
417
- * this makes it also mean "the occupancy is a guess". Honesty about a guess is
418
- * cheaper than a support thread about a wrong percentage.
419
- */
420
- contextTokensEstimated: boolean;
421
433
  seq: number;
422
434
  /** The `kind: 'team'` entry the live dispatch is writing into. */
423
435
  teamEntryId?: string;
@@ -648,15 +660,17 @@ export type ViewAction = {
648
660
  snapshot: CompactionSnapshot;
649
661
  }
650
662
  /**
651
- * Overwrite the gauge with a DERIVED occupancy (§6.2).
663
+ * The occupancy reading, from `AgentController.subscribeContextUsage`.
652
664
  *
653
- * Separate from `compactionEnd` because the value is not always available at
654
- * the same instant, and because the flag it sets — `contextTokensEstimated` —
655
- * has to be clearable by the next `turnEnd` independently.
665
+ * THE ONLY ACTION THAT WRITES `state.context`, AND IT IS DISPATCHED FROM
666
+ * EXACTLY ONE PLACE (I-1 / T11). It replaced `contextTokensEstimated`, which
667
+ * `App` fired from two branches of the compaction stream while `turnEnd` wrote
668
+ * the same field from the agent stream - three writers, two upstreams, one
669
+ * number, and the P0-1 bounce as the result.
656
670
  */
657
671
  | {
658
- type: 'contextTokensEstimated';
659
- tokens: number;
672
+ type: 'contextUsage';
673
+ snapshot: ContextUsageSnapshot;
660
674
  } | {
661
675
  type: 'serviceStart';
662
676
  service: ServiceSnapshot;