@aragon-agent/cli 0.6.2 → 0.6.4

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 (167) hide show
  1. package/CHANGELOG.md +321 -0
  2. package/README.md +297 -2
  3. package/dist/agent/controller.d.ts +193 -1
  4. package/dist/agent/controller.js +475 -2
  5. package/dist/agent/headless.d.ts +8 -0
  6. package/dist/agent/headless.js +47 -0
  7. package/dist/agent/reducer.d.ts +189 -0
  8. package/dist/agent/reducer.js +301 -2
  9. package/dist/agent/system-prompt.d.ts +14 -0
  10. package/dist/agent/system-prompt.js +2 -0
  11. package/dist/cli.js +297 -16
  12. package/dist/commands/builtins.js +165 -24
  13. package/dist/commands/registry.d.ts +27 -0
  14. package/dist/compaction/archive.d.ts +175 -0
  15. package/dist/compaction/archive.js +267 -0
  16. package/dist/compaction/child.d.ts +97 -0
  17. package/dist/compaction/child.js +118 -0
  18. package/dist/compaction/command.d.ts +41 -0
  19. package/dist/compaction/command.js +355 -0
  20. package/dist/compaction/compactor.d.ts +302 -0
  21. package/dist/compaction/compactor.js +810 -0
  22. package/dist/compaction/digest.d.ts +62 -0
  23. package/dist/compaction/digest.js +164 -0
  24. package/dist/compaction/limits.d.ts +165 -0
  25. package/dist/compaction/limits.js +172 -0
  26. package/dist/compaction/pressure.d.ts +135 -0
  27. package/dist/compaction/pressure.js +167 -0
  28. package/dist/compaction/summarize-call.d.ts +70 -0
  29. package/dist/compaction/summarize-call.js +86 -0
  30. package/dist/compaction/summary-prompt.d.ts +85 -0
  31. package/dist/compaction/summary-prompt.js +197 -0
  32. package/dist/compaction/tail-budget.d.ts +52 -0
  33. package/dist/compaction/tail-budget.js +67 -0
  34. package/dist/compaction/types.d.ts +215 -0
  35. package/dist/compaction/types.js +22 -0
  36. package/dist/compaction/wiring.d.ts +264 -0
  37. package/dist/compaction/wiring.js +577 -0
  38. package/dist/config/app-paths.d.ts +14 -0
  39. package/dist/config/app-paths.js +16 -0
  40. package/dist/config/cli-commands.d.ts +41 -0
  41. package/dist/config/cli-commands.js +77 -1
  42. package/dist/config/env.d.ts +5 -2
  43. package/dist/config/env.js +140 -3
  44. package/dist/config/load.d.ts +42 -0
  45. package/dist/config/load.js +101 -1
  46. package/dist/config/schema.d.ts +244 -0
  47. package/dist/config/schema.js +170 -0
  48. package/dist/config/store.js +37 -1
  49. package/dist/config/ui-state.d.ts +28 -1
  50. package/dist/config/ui-state.js +9 -0
  51. package/dist/exec/events.d.ts +50 -1
  52. package/dist/exec/runner.js +42 -0
  53. package/dist/input/limits.d.ts +72 -0
  54. package/dist/input/limits.js +63 -0
  55. package/dist/input/mouse-events.d.ts +31 -5
  56. package/dist/input/mouse-events.js +86 -27
  57. package/dist/input/paste-parse.d.ts +71 -0
  58. package/dist/input/paste-parse.js +95 -0
  59. package/dist/input/stdin-filter.d.ts +81 -0
  60. package/dist/input/stdin-filter.js +379 -0
  61. package/dist/logging/install.d.ts +53 -1
  62. package/dist/logging/install.js +81 -1
  63. package/dist/logging/logger.d.ts +1 -1
  64. package/dist/proc/classify.d.ts +52 -0
  65. package/dist/proc/classify.js +143 -0
  66. package/dist/proc/kill-tree.d.ts +42 -0
  67. package/dist/proc/kill-tree.js +119 -0
  68. package/dist/proc/limits.d.ts +84 -0
  69. package/dist/proc/limits.js +84 -0
  70. package/dist/proc/log-ring.d.ts +68 -0
  71. package/dist/proc/log-ring.js +102 -0
  72. package/dist/proc/prompt.d.ts +24 -0
  73. package/dist/proc/prompt.js +49 -0
  74. package/dist/proc/readiness.d.ts +102 -0
  75. package/dist/proc/readiness.js +219 -0
  76. package/dist/proc/supervisor.d.ts +103 -0
  77. package/dist/proc/supervisor.js +465 -0
  78. package/dist/proc/types.d.ts +175 -0
  79. package/dist/proc/types.js +15 -0
  80. package/dist/session/persist.js +55 -1
  81. package/dist/team/report.js +7 -1
  82. package/dist/team/runtime.d.ts +10 -0
  83. package/dist/team/runtime.js +7 -0
  84. package/dist/team/subagent.d.ts +31 -1
  85. package/dist/team/subagent.js +42 -15
  86. package/dist/team/types.d.ts +8 -0
  87. package/dist/tools/bash-tool.d.ts +51 -2
  88. package/dist/tools/bash-tool.js +321 -51
  89. package/dist/tools/fs-tools.d.ts +17 -0
  90. package/dist/tools/index.d.ts +55 -2
  91. package/dist/tools/index.js +39 -1
  92. package/dist/tools/proc-tools.d.ts +25 -0
  93. package/dist/tools/proc-tools.js +102 -0
  94. package/dist/ui/ActivityLine.d.ts +19 -1
  95. package/dist/ui/ActivityLine.js +10 -2
  96. package/dist/ui/App.d.ts +62 -2
  97. package/dist/ui/App.js +546 -16
  98. package/dist/ui/Composer.d.ts +66 -1
  99. package/dist/ui/Composer.js +50 -6
  100. package/dist/ui/PromptInput.d.ts +82 -3
  101. package/dist/ui/PromptInput.js +232 -49
  102. package/dist/ui/StatusBar.d.ts +59 -0
  103. package/dist/ui/StatusBar.js +16 -4
  104. package/dist/ui/TeamPanel.js +7 -1
  105. package/dist/ui/Transcript.d.ts +21 -1
  106. package/dist/ui/Transcript.js +61 -3
  107. package/dist/ui/clipboard.d.ts +52 -0
  108. package/dist/ui/clipboard.js +91 -0
  109. package/dist/ui/composer-limits.d.ts +31 -0
  110. package/dist/ui/composer-limits.js +50 -0
  111. package/dist/ui/composer-rows.d.ts +73 -0
  112. package/dist/ui/composer-rows.js +201 -0
  113. package/dist/ui/editor-reducer.d.ts +37 -0
  114. package/dist/ui/editor-reducer.js +88 -12
  115. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  116. package/dist/ui/entries/CompactionCard.js +158 -0
  117. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  118. package/dist/ui/entries/ServiceCard.js +116 -0
  119. package/dist/ui/entries/UserEntry.d.ts +17 -3
  120. package/dist/ui/entries/UserEntry.js +34 -5
  121. package/dist/ui/frame-differ.d.ts +46 -0
  122. package/dist/ui/frame-differ.js +94 -10
  123. package/dist/ui/gauge.d.ts +16 -1
  124. package/dist/ui/gauge.js +25 -6
  125. package/dist/ui/glyphs.d.ts +11 -0
  126. package/dist/ui/glyphs.js +2 -0
  127. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  128. package/dist/ui/layout/ScrollViewport.js +128 -21
  129. package/dist/ui/layout/budget.d.ts +44 -9
  130. package/dist/ui/layout/budget.js +42 -10
  131. package/dist/ui/layout/follow-state.d.ts +137 -0
  132. package/dist/ui/layout/follow-state.js +150 -0
  133. package/dist/ui/layout/virtual-window.d.ts +11 -0
  134. package/dist/ui/layout/virtual-window.js +85 -1
  135. package/dist/ui/overlays/HelpOverlay.js +9 -1
  136. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  137. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  138. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  139. package/dist/ui/overlays/SettingsScreen.js +74 -2
  140. package/dist/ui/palettes.d.ts +16 -0
  141. package/dist/ui/palettes.js +6 -0
  142. package/dist/ui/paste-frames.d.ts +87 -0
  143. package/dist/ui/paste-frames.js +112 -0
  144. package/dist/ui/paste-tokens.d.ts +90 -0
  145. package/dist/ui/paste-tokens.js +147 -0
  146. package/dist/ui/screen.d.ts +38 -0
  147. package/dist/ui/screen.js +113 -8
  148. package/dist/ui/selection/highlight.d.ts +39 -0
  149. package/dist/ui/selection/highlight.js +64 -0
  150. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  151. package/dist/ui/selection/screen-mirror.js +35 -0
  152. package/dist/ui/selection/selection-controller.d.ts +88 -0
  153. package/dist/ui/selection/selection-controller.js +282 -0
  154. package/dist/ui/selection/selection.d.ts +90 -0
  155. package/dist/ui/selection/selection.js +223 -0
  156. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  157. package/dist/ui/stdout-frame-writer.js +15 -0
  158. package/dist/ui/theme.d.ts +11 -0
  159. package/dist/ui/theme.js +2 -0
  160. package/dist/ui/transcript-text.js +72 -0
  161. package/dist/ui/use-startup-notices.d.ts +27 -1
  162. package/dist/ui/use-startup-notices.js +43 -11
  163. package/dist/ui/use-wheel-routing.d.ts +1 -1
  164. package/dist/ui/use-wheel-routing.js +8 -0
  165. package/package.json +4 -2
  166. package/dist/input/stdin-mouse-filter.d.ts +0 -42
  167. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -167,6 +167,52 @@ export async function runHeadless(controller, prompt, options = {}) {
167
167
  total.inputTokens += event.usage.inputTokens;
168
168
  total.outputTokens += event.usage.outputTokens;
169
169
  });
170
+ // Context compaction (context-auto-compaction §4.7).
171
+ //
172
+ // TWO STDERR LINES, AND UNLIKE THE FAST TIER THEY ARE WRITTEN (D-12's
173
+ // reasoning does not carry over). A review is an unrequested second opinion
174
+ // that changes nothing; a compaction PERMANENTLY DROPS MESSAGES from the
175
+ // conversation, and a `-p` run whose middle third vanished with no line
176
+ // anywhere saying so is not a compact run, it is an unexplained one — the same
177
+ // argument the retry lines above make for a three-minute pause.
178
+ //
179
+ // stderr, never stdout: stdout is the machine-readable answer and has to stay
180
+ // parseable by `aragon -p | jq`. Not `--quiet`-gated, for the reason the retry
181
+ // line is not.
182
+ //
183
+ // The summary text is NOT written (D-18): it can be tens of thousands of
184
+ // characters, and no consumer of `-p` asked for it.
185
+ const unsubscribeCompaction = controller.subscribeCompaction?.((event) => {
186
+ if (event.type === 'usage') {
187
+ total.inputTokens += event.usage.inputTokens;
188
+ total.outputTokens += event.usage.outputTokens;
189
+ return;
190
+ }
191
+ if (event.type !== 'compaction_end')
192
+ return;
193
+ const r = event.record;
194
+ if (!r.applied) {
195
+ err.write(`\n[compaction] skipped: ${r.reason ?? 'unknown'}\n`);
196
+ return;
197
+ }
198
+ const secs = (r.durationMs / 1000).toFixed(1);
199
+ const how = r.mode === 'truncated'
200
+ ? ' WITHOUT a summary (summarize failed)'
201
+ : r.mode === 'relieved'
202
+ ? ' by clipping the retained turns (nothing could be dropped)'
203
+ : '';
204
+ err.write(`\n[compaction] ${formatTokens(r.tokensBefore)} -> ${formatTokens(r.tokensAfter)} tokens, ` +
205
+ `${r.messagesBefore} -> ${r.messagesAfter} messages${how} (${secs}s)\n`);
206
+ // ONE MORE LINE, AND ONLY WHEN RELIEF FIRED (§4.5). A clip inside the turns
207
+ // the run is about to continue from is a real data loss, and a `-p` run whose
208
+ // recent tool output was silently shortened is an unexplained run - the same
209
+ // argument the compaction lines above make for a vanished middle third.
210
+ if (r.tailRelief) {
211
+ err.write(`[compaction] clipped ${r.tailRelief.messages} tool ` +
212
+ `${r.tailRelief.messages === 1 ? 'result' : 'results'} in the retained turns ` +
213
+ `(${formatTokens(r.tailRelief.charsRemoved)} chars)\n`);
214
+ }
215
+ });
170
216
  // --- The bounded continuation loop (todo-plan-followthrough §3.6). --------
171
217
  //
172
218
  // THE THREE `unsubscribe` CALLS STAY OUTSIDE IT (rule 4). The loop wraps the
@@ -227,6 +273,7 @@ export async function runHeadless(controller, prompt, options = {}) {
227
273
  unsubscribeTeam?.();
228
274
  unsubscribeTodos?.();
229
275
  unsubscribeFast?.();
276
+ unsubscribeCompaction?.();
230
277
  }
231
278
  // Ensure a trailing newline so piped output is well-formed.
232
279
  if (wroteText)
@@ -14,7 +14,9 @@ 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
18
  import type { TodoItem, TodoSnapshot } from '../todo/types.js';
19
+ import type { ServiceSnapshot, ServiceStatus } from '../proc/types.js';
18
20
  import type { RetryPhase } from './retry-view.js';
19
21
  import type { FilePatch } from '../tools/patch.js';
20
22
  export type NoticeLevel = 'info' | 'warn' | 'error';
@@ -222,6 +224,107 @@ export type Entry = {
222
224
  turn: number;
223
225
  durationMs?: number;
224
226
  live: boolean;
227
+ }
228
+ /**
229
+ * One context compaction (context-auto-compaction §5.5). ONE CARD PER
230
+ * COMPACTION, rewritten once when the call returns — like the fast card, and
231
+ * unlike the todo card's per-turn checklist.
232
+ *
233
+ * JSON-SERIALIZABLE by construction, so `/save` and `/resume` need no format
234
+ * change.
235
+ *
236
+ * `live: true` is normalized away ON LOAD, in `session/persist.ts`, next to
237
+ * `team.active`, `todo.live`, the retry card's phase and `fast.live`, and for
238
+ * the identical reason (C-8): `Transcript`'s settled boundary is MONOTONIC, so
239
+ * an entry that never settles never reaches `<Static>` and is re-rendered on
240
+ * every frame for the rest of the session.
241
+ *
242
+ * EVERY STRING ON THIS CARD DESCRIBES THE HISTORY, NEVER THE SCREEN (P2-9).
243
+ * `/clear` empties the transcript view but not `messages`, so a compaction can
244
+ * legitimately fire against a visually empty transcript and report
245
+ * "112 -> 9 messages" with nine visible rows.
246
+ */
247
+ | {
248
+ id: string;
249
+ kind: 'compaction';
250
+ /** 1-based within the session, so the card can name itself. */
251
+ index: number;
252
+ trigger: CompactionUiTrigger;
253
+ mode: CompactionMode;
254
+ applied: boolean;
255
+ reason?: string;
256
+ messagesBefore: number;
257
+ messagesAfter: number;
258
+ tokensBefore: number;
259
+ tokensAfter: number;
260
+ summary?: string;
261
+ model: string;
262
+ durationMs?: number;
263
+ /**
264
+ * When the live card opened, so the elapsed counter has an origin
265
+ * (context-auto-compaction-hardening §3.6 / W5).
266
+ *
267
+ * JSON-SERIALIZABLE like every other field here, and harmless on reload:
268
+ * `session/persist.ts` normalizes `live` to false, and a settled card
269
+ * renders `durationMs` rather than an elapsed figure.
270
+ */
271
+ startedAt?: number;
272
+ /** Set when tail relief clipped the retained turns (W2). */
273
+ tailRelief?: {
274
+ messages: number;
275
+ charsRemoved: number;
276
+ };
277
+ live: boolean;
278
+ }
279
+ /**
280
+ * One supervised background service (background-service-supervision §3.10).
281
+ *
282
+ * JSON-SERIALIZABLE by construction, so `/save` needs no format change.
283
+ *
284
+ * IT IS THE ONE LIVE CARD THAT DOES **NOT** BLOCK THE SETTLED BOUNDARY ONCE IT
285
+ * IS UP (D-11 / P0-4). Every other live clause in
286
+ * `Transcript.tsx::computeSettledCount` is bounded by an OPERATION — a
287
+ * dispatch, a turn, one LLM call, one countdown — and each carries a comment
288
+ * saying that an entry which never settles is re-rendered on every frame for
289
+ * the rest of the session. A service is bounded by the USER'S INTENT: a dev
290
+ * server left up for an hour is the normal case. So only `starting` blocks
291
+ * (bounded by `readyTimeoutMs`), a `ready`/`running` card commits its body to
292
+ * `<Static>`, and a later terminal transition appends a NEW one-row entry
293
+ * rather than trying to rewrite a card `<Static>` has already printed.
294
+ *
295
+ * `terminal: true` marks that second entry. `session/persist.ts` rewrites any
296
+ * restored non-terminal card to `stopped`, next to `team.active`, `todo.live`,
297
+ * the retry phase, `fast.live` and `compaction.live`, and for the identical
298
+ * reason.
299
+ */
300
+ | {
301
+ id: string;
302
+ kind: 'service';
303
+ /** `s1`, `s2`, ... — the id the model quotes back. */
304
+ serviceId: string;
305
+ command: string;
306
+ status: ServiceStatus;
307
+ url?: string;
308
+ port?: number;
309
+ exitCode: number | null;
310
+ startedAt: number;
311
+ readyAt?: number;
312
+ endedAt?: number;
313
+ rows: readonly string[];
314
+ /**
315
+ * MONOTONIC row cursor — the revision term, NEVER `rows.length` (P0-3).
316
+ *
317
+ * The tail is a FIXED-SIZE RING: it evicts its oldest row while appending
318
+ * a new one, so the joined length is unchanged while the content is not.
319
+ * That is verbatim the non-append mutation the `tool` branch's `liveSeq`
320
+ * comment was written for, and a length term would go on matching while
321
+ * the card changed underneath it.
322
+ */
323
+ rowsSeen: number;
324
+ /** The one-row entry a terminal transition appends (D-11). */
325
+ terminal?: boolean;
326
+ /** The stop reported the pid still alive afterwards (R-2 / P2-6). */
327
+ killIncomplete?: boolean;
225
328
  };
226
329
  export interface UsageTotal {
227
330
  inputTokens: number;
@@ -296,6 +399,25 @@ export interface ViewState {
296
399
  * what leaves an ordinary session's status bar byte-identical (AC-4).
297
400
  */
298
401
  fast: FastSnapshot | null;
402
+ /**
403
+ * The live compaction projection, or `null` when the feature is off for this
404
+ * session (context-auto-compaction §5.5).
405
+ *
406
+ * NEVER PERSISTED — `entries` is the history and the settled cards are the
407
+ * record. It drives the status chip, the gauge's marks and `/compact status`'s
408
+ * totals. `null` is what leaves an ordinary session's status bar unchanged.
409
+ */
410
+ 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;
299
421
  seq: number;
300
422
  /** The `kind: 'team'` entry the live dispatch is writing into. */
301
423
  teamEntryId?: string;
@@ -305,6 +427,8 @@ export interface ViewState {
305
427
  retryEntryId?: string;
306
428
  /** The `kind: 'fast'` entry the in-flight review is writing into. */
307
429
  fastEntryId?: string;
430
+ /** The `kind: 'compaction'` entry the in-flight compaction is writing into. */
431
+ compactionEntryId?: string;
308
432
  streamingId?: string;
309
433
  turnProduced: boolean;
310
434
  errorNoticed: boolean;
@@ -497,6 +621,60 @@ export type ViewAction = {
497
621
  } | {
498
622
  type: 'fastTier';
499
623
  snapshot: FastSnapshot;
624
+ } | {
625
+ type: 'compactionStart';
626
+ index: number;
627
+ trigger: CompactionUiTrigger;
628
+ model: string;
629
+ } | {
630
+ type: 'compactionEnd';
631
+ record: CompactionRecord;
632
+ }
633
+ /**
634
+ * Compaction spend.
635
+ *
636
+ * `costDelta` IS PRECOMPUTED BY THE CALLER, exactly as `teamUsage`'s and
637
+ * `fastUsage`'s are: the reducer stays pure and cost-table-free. It matters
638
+ * MORE here than for either of those, because the summarizer may be a
639
+ * different model from the lead AND from the fast tier, so a reducer that
640
+ * computed its own would have to know about three price tables.
641
+ */
642
+ | {
643
+ type: 'compactionUsage';
644
+ usage: TokenUsage;
645
+ costDelta: number;
646
+ } | {
647
+ type: 'compactionSnapshot';
648
+ snapshot: CompactionSnapshot;
649
+ }
650
+ /**
651
+ * Overwrite the gauge with a DERIVED occupancy (§6.2).
652
+ *
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.
656
+ */
657
+ | {
658
+ type: 'contextTokensEstimated';
659
+ tokens: number;
660
+ } | {
661
+ type: 'serviceStart';
662
+ service: ServiceSnapshot;
663
+ } | {
664
+ type: 'serviceUpdate';
665
+ service: ServiceSnapshot;
666
+ }
667
+ /**
668
+ * A service reached a terminal state.
669
+ *
670
+ * IT APPENDS A NEW ONE-ROW ENTRY rather than rewriting the card (D-11). The
671
+ * card may already have been printed into `<Static>`, and `<Static>` cannot
672
+ * un-print — so any design that rewrites a printed card is wrong whatever it
673
+ * claims. A second event at a second time is also simply more honest.
674
+ */
675
+ | {
676
+ type: 'serviceEnd';
677
+ service: ServiceSnapshot;
500
678
  };
501
679
  export declare const STORED_PREVIEW_CHARS = 8000;
502
680
  export declare const STORED_PREVIEW_LINES = 200;
@@ -532,6 +710,17 @@ export interface PatchSource {
532
710
  * it — every existing test, and headless mode — produces byte-identical actions.
533
711
  */
534
712
  export declare function reduceEvent(event: AgentEvent, cost?: ModelCost, patches?: PatchSource): ViewAction[];
713
+ /**
714
+ * The opening words of the `context_overflow` banner.
715
+ *
716
+ * A SHARED CONSTANT RATHER THAN TWO LITERALS, because `compactionStart` matches
717
+ * on it to rewrite the notice a successful reactive recovery would otherwise
718
+ * leave behind (P1-4). Two spellings that must agree, with nothing checking that
719
+ * they do, is how that rewrite silently stops matching — at which point the red
720
+ * "start over" banner comes back and only on the recovery path, which is the one
721
+ * place nobody re-tests.
722
+ */
723
+ export declare const CONTEXT_OVERFLOW_NOTICE_PREFIX = "Context length exceeded";
535
724
  /** Turn a provider error into a friendly banner, mapping LLMError.errorType. */
536
725
  export declare function formatStreamError(error: Error): string;
537
726
  export declare function viewReducer(state: ViewState, action: ViewAction): ViewState;
@@ -11,6 +11,7 @@
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';
14
15
  import { computeCost } from './usage.js';
15
16
  import { ENTRY_LIMITS, appendBounded, entryRetain, trimEntries } from './entry-limits.js';
16
17
  /** Default auto-dismiss window for a toast. */
@@ -46,11 +47,14 @@ export function initialViewState(seed = {}) {
46
47
  todos: null,
47
48
  retry: null,
48
49
  fast: null,
50
+ compaction: null,
51
+ contextTokensEstimated: false,
49
52
  seq: 0,
50
53
  teamEntryId: undefined,
51
54
  todoEntryId: undefined,
52
55
  retryEntryId: undefined,
53
56
  fastEntryId: undefined,
57
+ compactionEntryId: undefined,
54
58
  streamingId: undefined,
55
59
  turnProduced: false,
56
60
  errorNoticed: false,
@@ -223,6 +227,17 @@ function reduceStreamEvent(se) {
223
227
  return [];
224
228
  }
225
229
  }
230
+ /**
231
+ * The opening words of the `context_overflow` banner.
232
+ *
233
+ * A SHARED CONSTANT RATHER THAN TWO LITERALS, because `compactionStart` matches
234
+ * on it to rewrite the notice a successful reactive recovery would otherwise
235
+ * leave behind (P1-4). Two spellings that must agree, with nothing checking that
236
+ * they do, is how that rewrite silently stops matching — at which point the red
237
+ * "start over" banner comes back and only on the recovery path, which is the one
238
+ * place nobody re-tests.
239
+ */
240
+ export const CONTEXT_OVERFLOW_NOTICE_PREFIX = 'Context length exceeded';
226
241
  /** Turn a provider error into a friendly banner, mapping LLMError.errorType. */
227
242
  export function formatStreamError(error) {
228
243
  const errorType = error.errorType;
@@ -235,7 +250,13 @@ export function formatStreamError(error) {
235
250
  case 'overloaded':
236
251
  return `Provider overloaded - please retry shortly. (${base})`;
237
252
  case 'context_overflow':
238
- return `Context length exceeded - start a new conversation with /reset. (${base})`;
253
+ // `/compact` IS NAMED FIRST, and adding it is the second half of P1-4's
254
+ // fix. This remedy list predates context compaction and was, until it
255
+ // existed, complete: throwing the conversation away really was the only
256
+ // exit. It is not any more, and a banner that offers only `/reset` now
257
+ // tells the user to discard a session the very next line of code may be
258
+ // about to save.
259
+ return `${CONTEXT_OVERFLOW_NOTICE_PREFIX} - try /compact, or start over with /reset. (${base})`;
239
260
  case 'network_error':
240
261
  return `Network error reaching the provider. (${base})`;
241
262
  case 'timeout':
@@ -295,6 +316,11 @@ function appendEntry(state, entries, entry) {
295
316
  // `fastEntryId` IS PART OF THIS LIST for the reason the note above gives:
296
317
  // a fifth live card left out of it is a card I-L1-1 does not cover.
297
318
  state.fastEntryId,
319
+ // And `compactionEntryId` is the sixth. Same reason, and it is the one
320
+ // whose absence would be hardest to notice: a compaction card is rare, so
321
+ // a `mapEntry` that silently stopped matching would be found by a user
322
+ // rather than by a test.
323
+ state.compactionEntryId,
298
324
  ].filter((id) => typeof id === 'string');
299
325
  for (const id of live) {
300
326
  if (!trimmed.entries.some((e) => e.id === id)) {
@@ -584,7 +610,17 @@ export function viewReducer(state, action) {
584
610
  ...state,
585
611
  streamingId: undefined,
586
612
  turnProduced: true,
587
- contextTokens: action.usage.inputTokens + action.usage.outputTokens,
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,
588
624
  usageTotal: {
589
625
  inputTokens: state.usageTotal.inputTokens + action.usage.inputTokens,
590
626
  outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
@@ -713,6 +749,12 @@ export function viewReducer(state, action) {
713
749
  // holds, it is a projection of an entry that has just been erased.
714
750
  retryEntryId: undefined,
715
751
  retry: null,
752
+ // Same reasoning again for the compaction card: the entry is gone, so the
753
+ // id must go with it or `compactionEnd` would `mapEntry` an id that no
754
+ // longer exists. `compaction` STAYS — unlike `retry` it is not a
755
+ // projection of an entry, it is the live state of a subsystem that is
756
+ // still running, and `/clear` clears the screen rather than the session.
757
+ compactionEntryId: undefined,
716
758
  };
717
759
  case 'resetConversation':
718
760
  return {
@@ -731,6 +773,11 @@ export function viewReducer(state, action) {
731
773
  todoEntryId: undefined,
732
774
  retryEntryId: undefined,
733
775
  retry: null,
776
+ 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,
734
781
  };
735
782
  case 'setOverlay':
736
783
  return { ...state, overlay: action.overlay };
@@ -957,6 +1004,164 @@ export function viewReducer(state, action) {
957
1004
  };
958
1005
  case 'fastTier':
959
1006
  return { ...state, fast: action.snapshot };
1007
+ // --- Context compaction (context-auto-compaction §5.5) ----------------
1008
+ // NOT A TOTAL FUNCTION OVER COMPACTION ATTEMPTS (quiet-noop §3.1). A
1009
+ // `pressure` compaction that declines before calling a model or changing the
1010
+ // history dispatches NOTHING - no `compactionStart`, no `compactionEnd`, no
1011
+ // entry - exactly as a checkpoint below the threshold dispatches nothing.
1012
+ // Counting cards here counts compactions that RAN, never checkpoints that
1013
+ // fired; `/compact status` and the diagnostic log are where the attempts are.
1014
+ case 'compactionStart': {
1015
+ const { id, seq } = nextId(state);
1016
+ let entries = state.entries;
1017
+ // RESPONSIBILITY 1 — SEAL ANY LIVE `streamingId` ENTRY, defensively.
1018
+ // C-8's failure mode, and reachable here: `provider.ts` records that
1019
+ // Anthropic can deliver an error IN-STREAM, after the connection is open
1020
+ // and therefore after text deltas have already been emitted. An entry that
1021
+ // never settles pins `Transcript`'s monotonic boundary and re-renders the
1022
+ // tail forever.
1023
+ if (state.streamingId) {
1024
+ entries = mapEntry(entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false, ...sealThinkingMs(e) } : e);
1025
+ }
1026
+ // RESPONSIBILITY 2 — REWRITE A TRAILING `context_overflow` ERROR NOTICE
1027
+ // (P1-4). `agent-loop.ts` emits `message_update` for EVERY stream event
1028
+ // before the `error` branch throws, and `reduceStreamEvent`'s `case 'error'`
1029
+ // turns that into a `level: 'error'` notice reading "Context length
1030
+ // exceeded". So the HAPPY PATH of the reactive recovery would otherwise end
1031
+ // with a red banner telling the user to throw away the session we are one
1032
+ // frame from saving.
1033
+ //
1034
+ // IT REWRITES RATHER THAN REMOVES, because `<Static>` cannot un-print an
1035
+ // entry it has already drawn — and it matches ONLY when that notice is the
1036
+ // TAIL entry, so it can never touch an older, genuine error. If the
1037
+ // recovery then fails, the ladder's own notice or the re-thrown 400 is what
1038
+ // the user is left with, so neither direction lies.
1039
+ if (action.trigger === 'overflow') {
1040
+ const tail = entries[entries.length - 1];
1041
+ if (tail &&
1042
+ tail.kind === 'notice' &&
1043
+ tail.level === 'error' &&
1044
+ tail.text.startsWith(CONTEXT_OVERFLOW_NOTICE_PREFIX)) {
1045
+ entries = mapEntry(entries, tail.id, (e) => e.kind === 'notice'
1046
+ ? { ...e, level: 'info', text: 'Context window exceeded - compacting and retrying.' }
1047
+ : e);
1048
+ }
1049
+ }
1050
+ const entry = {
1051
+ id,
1052
+ kind: 'compaction',
1053
+ index: action.index,
1054
+ trigger: action.trigger,
1055
+ mode: 'none',
1056
+ applied: false,
1057
+ messagesBefore: 0,
1058
+ messagesAfter: 0,
1059
+ tokensBefore: 0,
1060
+ tokensAfter: 0,
1061
+ model: action.model,
1062
+ startedAt: Date.now(),
1063
+ live: true,
1064
+ };
1065
+ return {
1066
+ ...state,
1067
+ seq,
1068
+ ...appendEntry(state, entries, entry),
1069
+ compactionEntryId: id,
1070
+ streamingId: undefined,
1071
+ };
1072
+ }
1073
+ case 'compactionEnd': {
1074
+ const { record } = action;
1075
+ const settle = (e) => e.kind === 'compaction'
1076
+ ? {
1077
+ ...e,
1078
+ index: record.index,
1079
+ trigger: record.trigger,
1080
+ mode: record.mode,
1081
+ applied: record.applied,
1082
+ ...(record.reason !== undefined ? { reason: record.reason } : {}),
1083
+ messagesBefore: record.messagesBefore,
1084
+ messagesAfter: record.messagesAfter,
1085
+ tokensBefore: record.tokensBefore,
1086
+ tokensAfter: record.tokensAfter,
1087
+ ...(record.summary !== undefined ? { summary: record.summary } : {}),
1088
+ ...(record.tailRelief ? { tailRelief: record.tailRelief } : {}),
1089
+ model: record.model,
1090
+ durationMs: record.durationMs,
1091
+ // SETTLED. `Transcript`'s boundary is monotonic, so a card that
1092
+ // never settles is re-rendered on every frame for the rest of the
1093
+ // session (C-8).
1094
+ live: false,
1095
+ }
1096
+ : e;
1097
+ // THE SECOND BRANCH IS NOT DEFENSIVE PADDING, exactly as `fastEnd`'s is
1098
+ // not: the IDLE `/compact` path settles a record for a card this session
1099
+ // may never have opened, and "it did nothing" must never be the observable
1100
+ // outcome (R-8 / §6.4).
1101
+ if (state.compactionEntryId) {
1102
+ return {
1103
+ ...state,
1104
+ entries: mapEntry(state.entries, state.compactionEntryId, settle),
1105
+ compactionEntryId: undefined,
1106
+ };
1107
+ }
1108
+ const { id, seq } = nextId(state);
1109
+ const entry = {
1110
+ id,
1111
+ kind: 'compaction',
1112
+ index: record.index,
1113
+ trigger: record.trigger,
1114
+ mode: record.mode,
1115
+ applied: record.applied,
1116
+ ...(record.reason !== undefined ? { reason: record.reason } : {}),
1117
+ messagesBefore: record.messagesBefore,
1118
+ messagesAfter: record.messagesAfter,
1119
+ tokensBefore: record.tokensBefore,
1120
+ tokensAfter: record.tokensAfter,
1121
+ ...(record.summary !== undefined ? { summary: record.summary } : {}),
1122
+ ...(record.tailRelief ? { tailRelief: record.tailRelief } : {}),
1123
+ model: record.model,
1124
+ durationMs: record.durationMs,
1125
+ live: false,
1126
+ };
1127
+ return { ...state, seq, ...appendEntry(state, state.entries, entry) };
1128
+ }
1129
+ case 'compactionUsage':
1130
+ // `usageTotal` AND NOTHING ELSE, exactly as `teamUsage` and `fastUsage`. In
1131
+ // particular NOT `contextTokens`: that gauge shows the LEAD's context
1132
+ // occupancy against the lead model's window, and a summarization is a
1133
+ // 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
+ };
1142
+ case 'compactionSnapshot':
1143
+ return { ...state, compaction: action.snapshot };
1144
+ /**
1145
+ * THE GAUGE MUST FALL IMMEDIATELY (§6.2 / AC-5).
1146
+ *
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.
1152
+ *
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.
1158
+ */
1159
+ case 'contextTokensEstimated':
1160
+ return {
1161
+ ...state,
1162
+ contextTokens: Math.max(0, Math.round(action.tokens)),
1163
+ contextTokensEstimated: true,
1164
+ };
960
1165
  // --- API retry (llm-api-retry-backoff §6.4) ---------------------------
961
1166
  case 'retryScheduled': {
962
1167
  const snapshot = {
@@ -1118,10 +1323,104 @@ export function viewReducer(state, action) {
1118
1323
  fastEntryId: undefined,
1119
1324
  };
1120
1325
  }
1326
+ // --- Background services (§3.10 / D-11) -----------------------------
1327
+ case 'serviceStart': {
1328
+ const { id, seq } = nextId(state);
1329
+ const entry = { id, ...serviceFields(action.service) };
1330
+ const appended = appendEntry(state, state.entries, entry);
1331
+ return {
1332
+ ...state,
1333
+ seq,
1334
+ entries: appended.entries,
1335
+ droppedEntries: appended.droppedEntries,
1336
+ };
1337
+ }
1338
+ /**
1339
+ * Rewrite the LIVE card in place.
1340
+ *
1341
+ * SAFE ONLY BECAUSE OF D-11'S OTHER HALF: a `ready` / `running` card has
1342
+ * reached `<Static>` and cannot be re-printed, so `serviceEnd` appends a new
1343
+ * entry instead of routing through here. What this handles is the window in
1344
+ * which the card is still in the live region — `starting`, and the one
1345
+ * transition out of it — plus the output tail while it is there.
1346
+ *
1347
+ * The lookup is by `serviceId` and SKIPS terminal entries, so a stray update
1348
+ * cannot rewrite the one-row record `serviceEnd` appended.
1349
+ */
1350
+ case 'serviceUpdate': {
1351
+ const target = findServiceEntryId(state, action.service.id);
1352
+ if (!target)
1353
+ return state;
1354
+ return {
1355
+ ...state,
1356
+ entries: mapEntry(state.entries, target, (e) => e.kind === 'service' ? { ...e, ...serviceFields(action.service) } : e),
1357
+ };
1358
+ }
1359
+ /**
1360
+ * A service reached a terminal state.
1361
+ *
1362
+ * TWO WRITES, AND BOTH ARE REQUIRED. The live card is settled in place so
1363
+ * `computeSettledCount` can move past it and `estimateEntryRows` stops
1364
+ * charging for a tail nothing will add to; the NEW one-row entry is what the
1365
+ * user actually reads, because the card may already be printed into
1366
+ * `<Static>` and `<Static>` cannot un-print (D-11 / R-13).
1367
+ */
1368
+ case 'serviceEnd': {
1369
+ const target = findServiceEntryId(state, action.service.id);
1370
+ const settled = target
1371
+ ? mapEntry(state.entries, target, (e) => e.kind === 'service' ? { ...e, ...serviceFields(action.service) } : e)
1372
+ : state.entries;
1373
+ const { id, seq } = nextId(state);
1374
+ const appended = appendEntry(state, settled, {
1375
+ id,
1376
+ ...serviceFields(action.service),
1377
+ terminal: true,
1378
+ });
1379
+ return {
1380
+ ...state,
1381
+ seq,
1382
+ entries: appended.entries,
1383
+ droppedEntries: appended.droppedEntries,
1384
+ };
1385
+ }
1121
1386
  default:
1122
1387
  return state;
1123
1388
  }
1124
1389
  }
1390
+ /**
1391
+ * Project a supervisor snapshot onto the entry's own fields.
1392
+ *
1393
+ * ONE PROJECTION FOR ALL THREE ACTIONS, so a field that reaches the card through
1394
+ * `serviceStart` can never fail to reach it through `serviceUpdate` — the kind of
1395
+ * asymmetry that produces a card frozen at its first values with nothing
1396
+ * reporting it.
1397
+ */
1398
+ function serviceFields(service) {
1399
+ return {
1400
+ kind: 'service',
1401
+ serviceId: service.id,
1402
+ command: service.command,
1403
+ status: service.status,
1404
+ ...(service.url !== undefined ? { url: service.url } : {}),
1405
+ ...(service.port !== undefined ? { port: service.port } : {}),
1406
+ exitCode: service.exitCode,
1407
+ startedAt: service.startedAt,
1408
+ ...(service.readyAt !== undefined ? { readyAt: service.readyAt } : {}),
1409
+ ...(service.endedAt !== undefined ? { endedAt: service.endedAt } : {}),
1410
+ rows: service.rows,
1411
+ rowsSeen: service.rowsSeen,
1412
+ ...(service.killIncomplete ? { killIncomplete: true } : {}),
1413
+ };
1414
+ }
1415
+ /** The LIVE card for a service id, newest first; terminal records are skipped. */
1416
+ function findServiceEntryId(state, serviceId) {
1417
+ for (let i = state.entries.length - 1; i >= 0; i -= 1) {
1418
+ const e = state.entries[i];
1419
+ if (e && e.kind === 'service' && e.serviceId === serviceId && !e.terminal)
1420
+ return e.id;
1421
+ }
1422
+ return undefined;
1423
+ }
1125
1424
  function safeStringify(value) {
1126
1425
  try {
1127
1426
  return JSON.stringify(value);