@aragon-agent/cli 0.6.3 → 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 (138) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +203 -13
  3. package/dist/agent/controller.d.ts +158 -2
  4. package/dist/agent/controller.js +400 -1
  5. package/dist/agent/reducer.d.ts +102 -19
  6. package/dist/agent/reducer.js +162 -71
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +2 -0
  9. package/dist/agent/usage.d.ts +34 -0
  10. package/dist/agent/usage.js +43 -0
  11. package/dist/cli.js +132 -23
  12. package/dist/commands/builtins.js +101 -1
  13. package/dist/compaction/child.js +5 -0
  14. package/dist/compaction/command.js +14 -1
  15. package/dist/compaction/compactor.d.ts +94 -24
  16. package/dist/compaction/compactor.js +247 -98
  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 +77 -0
  24. package/dist/compaction/wiring.d.ts +56 -0
  25. package/dist/compaction/wiring.js +107 -25
  26. package/dist/config/cli-commands.js +5 -0
  27. package/dist/config/env.d.ts +1 -1
  28. package/dist/config/env.js +63 -2
  29. package/dist/config/load.d.ts +28 -0
  30. package/dist/config/load.js +83 -1
  31. package/dist/config/schema.d.ts +129 -0
  32. package/dist/config/schema.js +69 -0
  33. package/dist/config/store.js +47 -1
  34. package/dist/exec/events.d.ts +138 -1
  35. package/dist/exec/events.js +42 -0
  36. package/dist/exec/index.js +43 -6
  37. package/dist/exec/progress-reporter.d.ts +23 -0
  38. package/dist/exec/progress-reporter.js +88 -0
  39. package/dist/exec/runner.d.ts +34 -0
  40. package/dist/exec/runner.js +137 -25
  41. package/dist/fast/reviewer.d.ts +19 -1
  42. package/dist/fast/reviewer.js +22 -0
  43. package/dist/fast/types.d.ts +15 -0
  44. package/dist/fast/wiring.d.ts +10 -1
  45. package/dist/fast/wiring.js +18 -1
  46. package/dist/input/limits.d.ts +72 -0
  47. package/dist/input/limits.js +63 -0
  48. package/dist/input/paste-parse.d.ts +71 -0
  49. package/dist/input/paste-parse.js +95 -0
  50. package/dist/input/stdin-filter.d.ts +81 -0
  51. package/dist/input/stdin-filter.js +379 -0
  52. package/dist/logging/install.d.ts +2 -0
  53. package/dist/logging/install.js +53 -1
  54. package/dist/proc/classify.d.ts +52 -0
  55. package/dist/proc/classify.js +143 -0
  56. package/dist/proc/kill-tree.d.ts +42 -0
  57. package/dist/proc/kill-tree.js +119 -0
  58. package/dist/proc/limits.d.ts +84 -0
  59. package/dist/proc/limits.js +84 -0
  60. package/dist/proc/log-ring.d.ts +68 -0
  61. package/dist/proc/log-ring.js +102 -0
  62. package/dist/proc/prompt.d.ts +24 -0
  63. package/dist/proc/prompt.js +49 -0
  64. package/dist/proc/readiness.d.ts +102 -0
  65. package/dist/proc/readiness.js +219 -0
  66. package/dist/proc/supervisor.d.ts +103 -0
  67. package/dist/proc/supervisor.js +465 -0
  68. package/dist/proc/types.d.ts +175 -0
  69. package/dist/proc/types.js +15 -0
  70. package/dist/session/persist.js +36 -1
  71. package/dist/todo/limits.d.ts +4 -17
  72. package/dist/todo/limits.js +4 -17
  73. package/dist/todo/panel-rows.d.ts +8 -6
  74. package/dist/todo/panel-rows.js +19 -6
  75. package/dist/tools/bash-tool.d.ts +51 -2
  76. package/dist/tools/bash-tool.js +321 -51
  77. package/dist/tools/fs-tools.d.ts +17 -0
  78. package/dist/tools/index.d.ts +55 -2
  79. package/dist/tools/index.js +39 -1
  80. package/dist/tools/proc-tools.d.ts +25 -0
  81. package/dist/tools/proc-tools.js +102 -0
  82. package/dist/ui/App.d.ts +15 -2
  83. package/dist/ui/App.js +330 -69
  84. package/dist/ui/AutocompletePopup.d.ts +3 -1
  85. package/dist/ui/AutocompletePopup.js +9 -11
  86. package/dist/ui/BottomStatusRow.d.ts +1 -1
  87. package/dist/ui/Composer.d.ts +56 -1
  88. package/dist/ui/Composer.js +50 -6
  89. package/dist/ui/PromptInput.d.ts +52 -3
  90. package/dist/ui/PromptInput.js +201 -55
  91. package/dist/ui/StatusBar.d.ts +40 -15
  92. package/dist/ui/StatusBar.js +51 -9
  93. package/dist/ui/TeamPanel.d.ts +3 -1
  94. package/dist/ui/TeamPanel.js +13 -9
  95. package/dist/ui/TodoPanel.d.ts +5 -5
  96. package/dist/ui/TodoPanel.js +56 -24
  97. package/dist/ui/Transcript.js +29 -1
  98. package/dist/ui/UpdateLine.d.ts +1 -1
  99. package/dist/ui/capabilities.js +5 -5
  100. package/dist/ui/composer-limits.d.ts +31 -0
  101. package/dist/ui/composer-limits.js +50 -0
  102. package/dist/ui/composer-rows.d.ts +73 -0
  103. package/dist/ui/composer-rows.js +201 -0
  104. package/dist/ui/editor-reducer.d.ts +37 -0
  105. package/dist/ui/editor-reducer.js +88 -12
  106. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  107. package/dist/ui/entries/ServiceCard.js +116 -0
  108. package/dist/ui/entries/UserEntry.d.ts +17 -3
  109. package/dist/ui/entries/UserEntry.js +34 -5
  110. package/dist/ui/exit-snapshot.d.ts +9 -0
  111. package/dist/ui/layout/ScrollViewport.d.ts +1 -1
  112. package/dist/ui/layout/autocomplete.d.ts +18 -0
  113. package/dist/ui/layout/autocomplete.js +26 -0
  114. package/dist/ui/layout/budget.d.ts +34 -6
  115. package/dist/ui/layout/budget.js +32 -7
  116. package/dist/ui/layout/rail.d.ts +4 -53
  117. package/dist/ui/layout/rail.js +10 -62
  118. package/dist/ui/layout/team-panel.d.ts +18 -0
  119. package/dist/ui/layout/team-panel.js +22 -0
  120. package/dist/ui/layout/todo-layout.d.ts +24 -0
  121. package/dist/ui/layout/todo-layout.js +27 -0
  122. package/dist/ui/layout/virtual-window.js +51 -1
  123. package/dist/ui/overlays/HelpOverlay.js +3 -0
  124. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  125. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  126. package/dist/ui/overlays/SettingsScreen.js +14 -1
  127. package/dist/ui/paste-frames.d.ts +87 -0
  128. package/dist/ui/paste-frames.js +112 -0
  129. package/dist/ui/paste-tokens.d.ts +90 -0
  130. package/dist/ui/paste-tokens.js +147 -0
  131. package/dist/ui/screen.d.ts +16 -0
  132. package/dist/ui/screen.js +45 -2
  133. package/dist/ui/selection/selection-controller.d.ts +1 -1
  134. package/dist/ui/transcript-text.js +55 -2
  135. package/dist/ui/use-wheel-routing.d.ts +1 -1
  136. package/package.json +2 -2
  137. package/dist/input/stdin-mouse-filter.d.ts +0 -47
  138. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -20,9 +20,15 @@ import { TeamHumanQueue } from '../team/human-queue.js';
20
20
  import { createTaskTool } from '../team/task-tool.js';
21
21
  import { buildTeamBlock } from '../team/prompt.js';
22
22
  import { buildFastBlock } from '../fast/prompt.js';
23
+ import { ProcSupervisor } from '../proc/supervisor.js';
24
+ import { buildBackgroundServicesBlock } from '../proc/prompt.js';
25
+ import { PROC_LIMITS } from '../proc/limits.js';
26
+ import { makeBashKill, makeBashOutput } from '../tools/proc-tools.js';
27
+ import { addSignalHook } from '../logging/install.js';
23
28
  import { describeFastTierProblem, fastProviderOf, resolveFastTier } from '../fast/resolve.js';
24
29
  import { FastWiring, offFastStatus } from '../fast/wiring.js';
25
30
  import { CompactionWiring, offCompactionSnapshot } from '../compaction/wiring.js';
31
+ import { ContextMeter } from '../compaction/meter.js';
26
32
  import { TodoStore } from '../todo/store.js';
27
33
  import { createTodoTool } from '../todo/todo-tool.js';
28
34
  import { buildTodoBlock } from '../todo/prompt.js';
@@ -33,6 +39,15 @@ import { createNodeSkillHost } from '../skills/node-host.js';
33
39
  import { SkillService } from '../skills/service.js';
34
40
  import { createSkillTools } from '../skills/tools.js';
35
41
  import { buildSystemPrompt } from './system-prompt.js';
42
+ /**
43
+ * Extra budget, on top of `PROC_LIMITS.killGraceMs`, that `prompt()` allows a
44
+ * force-stopped engine to unwind in before it starts a new run anyway (I-8).
45
+ *
46
+ * `bash`'s own kill grace is what a wedged foreground command costs; this covers
47
+ * the loop's own unwinding on top of it. Waiting FOREVER is not an option - a
48
+ * tool that never settles is exactly why rung two exists.
49
+ */
50
+ const ENGINE_UNWIND_GRACE_MS = 3000;
36
51
  /** Approval gate used whenever there is provably no human to ask (headless). */
37
52
  export const DENY_ALL_APPROVAL = {
38
53
  canPrompt: () => false,
@@ -172,6 +187,65 @@ export class AgentController {
172
187
  // to subscribe to the Agent, and this class cannot do both in one step.
173
188
  // -----------------------------------------------------------------------
174
189
  compaction;
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
+ // -----------------------------------------------------------------------
208
+ // Background services (background-service-supervision §3.3 / §3.6)
209
+ //
210
+ // CONSTRUCTED UNCONDITIONALLY, and that is the one thing about this field that
211
+ // is easy to get wrong (P1-6 / D-9). `cfg.bash.background` gates the SERVICE
212
+ // half - the `background` tool param, `bash_output`/`bash_kill`, the prompt
213
+ // block, the status chip - and NEVER the foreground half, because G2 ("bash
214
+ // always settles") and G3 ("Esc, Esc always works, and the session stays
215
+ // usable") are promised unconditionally. Gating the whole supervisor on the
216
+ // flag would make rung two of the Esc ladder a silent no-op for every user who
217
+ // turned background services off.
218
+ //
219
+ // With the flag off it holds foreground pids, nothing else, and allocates no
220
+ // timers.
221
+ // -----------------------------------------------------------------------
222
+ procs;
223
+ /**
224
+ * The host's notice channel, kept so `prompt()`'s totality has somewhere to
225
+ * report to (I-8). Every other consumer reads `deps.notify` through a closure
226
+ * built in the constructor; this one is called from a method, which is why it
227
+ * needs a field.
228
+ */
229
+ notifyHostFn;
230
+ /** Whether the SERVICE half is on: tools, prompt block, `background` param. */
231
+ bashBackgroundRegistered;
232
+ /** Release the exit reaper. Called by `dispose()`. */
233
+ releaseSignalHook;
234
+ /**
235
+ * Bumped by `forceStop()`; captured by `App` when it subscribes to a run.
236
+ *
237
+ * THE VIEW AND THE ENGINE DELIBERATELY DISAGREE AFTER A FORCE-STOP (D-10 /
238
+ * I-7). Rung two dispatches `runEnd` locally so the composer is usable
239
+ * immediately, but the engine keeps unwinding and keeps EMITTING -
240
+ * `turn_start`, `tool_call_start`, `turn_end`, `agent_end` - and
241
+ * `runStart`/`turnStart` would set `status: 'running'` again, bouncing the
242
+ * view straight back out of the `idle` the user was just given and appending
243
+ * entries to a transcript they believe is finished. `App` drops every event
244
+ * whose generation is stale, which is the same shape as `abortRequested`
245
+ * above and exists for the same reason: the loop legitimately emits after an
246
+ * abort lands.
247
+ */
248
+ runGen = 0;
175
249
  /**
176
250
  * Set SYNCHRONOUSLY by `abort()` before `agent.abort()` (§3.5.4a guard 1).
177
251
  * Cleared at the next `agent_start`.
@@ -290,6 +364,19 @@ export class AgentController {
290
364
  // reason the field's own comment gives: the binding below is spread
291
365
  // conditionally on it (§3.1.3 / P1-6).
292
366
  this.toolOutputs = config.liveToolOutput ? createToolOutputStore() : null;
367
+ // ALSO ABOVE `createBuiltinTools`, and UNCONDITIONAL - see the field's own
368
+ // comment. `readyTimeoutMs` is read through a closure rather than captured,
369
+ // so a live config change reaches the next service rather than the next
370
+ // launch of the CLI.
371
+ this.procs = new ProcSupervisor({ readyTimeoutMs: () => this.config.bash.readyTimeoutMs });
372
+ this.notifyHostFn = deps.notify;
373
+ this.bashBackgroundRegistered = config.bash.background;
374
+ // THE EXIT REAPER (G5 / P1-2 / R-10). An APPEND-ONLY hook rather than a
375
+ // second `setSignalTerminator`: that is a single slot `cli.tsx` already owns
376
+ // for the alternate-screen restore, and replacing it is the documented bug
377
+ // that leaves the user staring at a blank alternate screen. `reapSync` is
378
+ // the only entry point it calls, and it is synchronous end to end (I-9).
379
+ this.releaseSignalHook = addSignalHook(() => this.procs.reapSync());
293
380
  this.tools = createBuiltinTools({
294
381
  getCwd: () => this.cwd,
295
382
  // OWNED HERE, NOT IN `cli.tsx` (D-18): `createBuiltinTools` is called from
@@ -306,6 +393,28 @@ export class AgentController {
306
393
  ...(this.toolOutputs
307
394
  ? { recordOutput: (id, chunk) => this.emitToolOutput('lead', id, chunk) }
308
395
  : {}),
396
+ // THE SERVICE HALF, AND ONLY THE SERVICE HALF (P1-6 / I-2). With
397
+ // `bash.background: false` the options bag has no `procs` key at all, so
398
+ // `createBuiltinTools` adds no property to `deps`, `bash` gets its default
399
+ // `{}` second argument, and the tool is the pre-feature one: no
400
+ // `background` in its schema, the pre-feature description string, no
401
+ // supervisor call. The FOREGROUND half of `this.procs` is still live, and
402
+ // `forceStop()` still reaches it.
403
+ ...(this.bashBackgroundRegistered
404
+ ? {
405
+ procs: this.procs,
406
+ // Read LIVE for the reason every other closure in this bag is:
407
+ // `aragon config set bash.autoBackground false` must take effect in
408
+ // the running session.
409
+ autoBackground: () => this.config.bash.autoBackground,
410
+ startupSettleMs: () => this.config.bash.startupSettleMs,
411
+ // D-15 / P1-8. `planInteractive` is the SAME signal `planTools`
412
+ // already uses to decide that no overlay can be rendered, which is
413
+ // exactly the question being asked here: can a card be drawn, and is
414
+ // there a user who could keep a service alive?
415
+ interactive: this.planInteractive,
416
+ }
417
+ : {}),
309
418
  confirmTools: config.confirmTools,
310
419
  // Routed through the same watchdog pause as the plan tools. This is a
311
420
  // pre-existing bug being fixed in passing: a `--confirm` user who took
@@ -345,6 +454,12 @@ export class AgentController {
345
454
  }),
346
455
  ]
347
456
  : [],
457
+ // THROUGH THE FACTORY for the reason `teamTools` records below (P0-2), and
458
+ // empty when background services were off at construction - which leaves
459
+ // the array byte-identical to the pre-feature build (I-2).
460
+ procTools: this.bashBackgroundRegistered
461
+ ? [makeBashOutput({ supervisor: this.procs }), makeBashKill({ supervisor: this.procs })]
462
+ : [],
348
463
  // THROUGH THE FACTORY, NEVER APPENDED AFTERWARDS (D-15 / P0-1).
349
464
  // `tools.test.ts::C7` asserts `HOST_TOOL_NAMES` equals what
350
465
  // `createBuiltinTools` produces, so appending `task` here would turn that
@@ -403,6 +518,28 @@ export class AgentController {
403
518
  modelId: config.model,
404
519
  ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
405
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
+ });
406
543
  // --- Context compaction, part 1: BEFORE the Agent ----------------------
407
544
  //
408
545
  // It needs no agent reference to EXIST — only live config, the key
@@ -420,6 +557,9 @@ export class AgentController {
420
557
  getMessages: () => this.agent.state.messages,
421
558
  getSystemPrompt: () => this.agent.state.systemPrompt,
422
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,
423
563
  })
424
564
  : null;
425
565
  this.agent = new Agent({
@@ -470,6 +610,15 @@ export class AgentController {
470
610
  // subscribed could not run before `new Agent(...)`, and the port has to be
471
611
  // inside its constructor argument (C-12 / P0-2).
472
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));
473
622
  // --- Fast tier, part 2: after the Agent exists --------------------------
474
623
  //
475
624
  // `FastWiring` subscribes to the lead's event stream, so it cannot be built
@@ -612,6 +761,12 @@ export class AgentController {
612
761
  review: this.config.fast.review,
613
762
  })
614
763
  : '',
764
+ // ONE FLAG, unlike team/todo's two: there is no live on/off switch for
765
+ // background services - `bash.background` is read once at construction,
766
+ // and the tools it registers cannot be unregistered (the `team.enabled` /
767
+ // `todo.enabled` rule). So the block is present exactly when the tools are,
768
+ // and can never advertise a capability the model does not have.
769
+ backgroundBlock: this.bashBackgroundRegistered ? buildBackgroundServicesBlock() : '',
615
770
  // Read from the FIELD on every rebuild, for the reason the field's own
616
771
  // comment gives. Empty everywhere but `aragon exec`, and `buildSystemPrompt`
617
772
  // splices it conditionally, so every existing prompt is byte-identical.
@@ -700,7 +855,64 @@ export class AgentController {
700
855
  * underlying promise so headless mode can await completion. `agent.prompt()`
701
856
  * never rejects, so callers must not treat resolution as success.
702
857
  */
703
- prompt(text) {
858
+ async prompt(text) {
859
+ // I-8 - THIS METHOD NEVER REJECTS, AND THAT HAS TO BE A PROPERTY OF THE
860
+ // METHOD RATHER THAN A COMMENT ABOVE IT (P0-1).
861
+ //
862
+ // `App` calls this as `void controller.prompt(...)`, and
863
+ // `logging/install.ts` routes an `unhandledRejection` into `handleFatal`,
864
+ // which EXITS THE PROCESS. `Agent.prompt()` throws SYNCHRONOUSLY while
865
+ // `Agent.running` is true - so before the force-stop ladder existed this was
866
+ // merely nearly-true (the view was only ever idle when the engine was), and
867
+ // rung two makes the view idle while the engine is still unwinding. Without
868
+ // the wait and the guard below, "Esc, Esc, then type" would kill the CLI on
869
+ // the very screen this feature exists to rescue.
870
+ //
871
+ // TWO PARTS, BOTH REQUIRED. First WAIT the engine out, bounded, so the
872
+ // common case (the engine unwinds in a few hundred ms) starts a real run
873
+ // rather than reporting a failure. Then swallow anything that still escapes
874
+ // and surface it through the existing notify channel, because a message that
875
+ // cannot be started must produce a visible sentence - never a crash, and
876
+ // never silence.
877
+ if (this.agent.state.isRunning) {
878
+ this.abort();
879
+ await this.waitForEngineIdle();
880
+ }
881
+ try {
882
+ await this.startPrompt(text);
883
+ }
884
+ catch (err) {
885
+ const reason = err instanceof Error ? err.message : String(err);
886
+ getLogger().warn('agent', 'prompt_failed', { reason });
887
+ this.notifyHostFn?.('error', `Could not start the run: ${reason}`);
888
+ }
889
+ }
890
+ /**
891
+ * Wait for the engine to unwind, bounded.
892
+ *
893
+ * BOUNDED BECAUSE THE UNBOUNDED CASE IS THE ONE THIS FEATURE IS ABOUT: a tool
894
+ * that never settles is exactly why rung two exists, and `Agent.waitForIdle()`
895
+ * alone would hang here for as long as it hangs there. The budget is the two
896
+ * graces a wedged `bash` can legitimately spend, plus the executor's own.
897
+ */
898
+ waitForEngineIdle() {
899
+ const budget = PROC_LIMITS.killGraceMs + ENGINE_UNWIND_GRACE_MS;
900
+ return new Promise((resolve) => {
901
+ let done = false;
902
+ const finish = () => {
903
+ if (done)
904
+ return;
905
+ done = true;
906
+ clearTimeout(timer);
907
+ resolve();
908
+ };
909
+ const timer = setTimeout(finish, budget);
910
+ timer.unref?.();
911
+ void this.agent.waitForIdle().then(finish, finish);
912
+ });
913
+ }
914
+ /** The pre-round body of `prompt`, unchanged apart from being private. */
915
+ startPrompt(text) {
704
916
  // A new user message is a new intent, so the previous turn's tool ceiling
705
917
  // expires here and anything a slash command queued is promoted (D-G1 / D-G2).
706
918
  // This and `steer()` below are the ONLY two places a user message enters the
@@ -746,6 +958,115 @@ export class AgentController {
746
958
  // `App.tsx` records for `cancelPendingHuman()` on `agent_end`.
747
959
  this.teamRuntime?.abortAll();
748
960
  }
961
+ /**
962
+ * Rung two of the Esc ladder: stop the AGENT, whatever it is blocked on
963
+ * (§3.6 / G3).
964
+ *
965
+ * FOUR STEPS, AND THE FOURTH IS AN ABSENCE.
966
+ *
967
+ * 1. `abort()` - everything it does today, unchanged.
968
+ * 2. `killForeground('force')` - hard-kill every tracked foreground `bash`
969
+ * child. THIS is what actually unblocks a wedged `await tool.execute`;
970
+ * an abort signal a tool ignores is advisory, and rung two exists for
971
+ * precisely the tools that ignore it.
972
+ * 3. `runGen += 1` - so `App` can drop the events the engine will keep
973
+ * emitting while it unwinds (I-7). Without it the view bounces straight
974
+ * back out of the `idle` the user was just given.
975
+ * 4. IT DOES NOT TOUCH BACKGROUND SERVICES. `Esc` interrupts the AGENT;
976
+ * `Ctrl+C` stops the SERVICES. That asymmetry is the user's own two
977
+ * sentences and it is exactly the kind of thing a later reader will
978
+ * "fix" by making this kill everything - which would mean a user who
979
+ * pressed Esc twice to stop a runaway turn also lost the dev server they
980
+ * had been working against for an hour. It is invariant I-4.
981
+ *
982
+ * UNCONDITIONAL ON `bash.background` (P1-6 / D-9). The supervisor is always
983
+ * constructed; with services off its foreground registry is all there is, and
984
+ * that is the half this method uses.
985
+ */
986
+ forceStop() {
987
+ this.abort();
988
+ const killed = this.procs.killForeground('force');
989
+ this.runGen += 1;
990
+ getLogger().info('agent', 'force_stop', { generation: this.runGen, killed });
991
+ }
992
+ /**
993
+ * The current run generation (I-7).
994
+ *
995
+ * `App` captures it when it subscribes to a run and drops every engine event
996
+ * whose generation is stale. Same shape as the `abortRequested` guard the fast
997
+ * reviewer already uses, and for the same reason: the loop legitimately emits
998
+ * after an abort lands.
999
+ */
1000
+ get runGeneration() {
1001
+ return this.runGen;
1002
+ }
1003
+ // -----------------------------------------------------------------------
1004
+ // Background services (background-service-supervision §3.5 / §3.6)
1005
+ // -----------------------------------------------------------------------
1006
+ /** Whether `bash_output` / `bash_kill` are in `this.tools`. Fixed at construction. */
1007
+ isBackgroundRegistered() {
1008
+ return this.bashBackgroundRegistered;
1009
+ }
1010
+ /**
1011
+ * Subscribe to the CLI-local `ProcEvent` stream.
1012
+ *
1013
+ * Returns a live unsubscribe even when services are off, because the
1014
+ * supervisor exists either way - the contract `subscribeTeam` /
1015
+ * `subscribeTodos` / `subscribeFast` / `subscribeToolOutput` already set. With
1016
+ * services off nothing is ever emitted, so an unconditional subscription in
1017
+ * `App` costs one `Set` entry.
1018
+ */
1019
+ subscribeProc(listener) {
1020
+ return this.procs.subscribe(listener);
1021
+ }
1022
+ getServiceSnapshot(id) {
1023
+ return this.procs.get(id);
1024
+ }
1025
+ listServices() {
1026
+ return this.procs.list();
1027
+ }
1028
+ /** `starting | ready | running`. Drives the status chip and the Ctrl+C rung. */
1029
+ liveServiceCount() {
1030
+ return this.procs.liveCount();
1031
+ }
1032
+ readServiceLog(id, since) {
1033
+ return this.procs.read(id, since);
1034
+ }
1035
+ /**
1036
+ * Stop one service, or every service when `id` is `'all'`.
1037
+ *
1038
+ * The GRACEFUL ladder (`SIGTERM` -> `SIGKILL` after `stopGraceMs`), because
1039
+ * this path has a live event loop to run it on. `reapServicesSync` is the
1040
+ * other one, and it is the only one an exit path may use.
1041
+ */
1042
+ stopService(id) {
1043
+ return this.procs.stop(id);
1044
+ }
1045
+ /**
1046
+ * Rung one of `Ctrl+C`: stop every live service (§3.6 / G4).
1047
+ *
1048
+ * `force: true` means `reapSync()` SEMANTICS - immediate `SIGKILL`, no
1049
+ * `stopGraceMs` - and is what `doExit` uses, because `doExit` awaits nothing
1050
+ * and must not start (§3.6 step 3). The graceful ladder belongs to `Ctrl+C`,
1051
+ * `bash_kill` and `/bg stop`.
1052
+ */
1053
+ stopAllServices(opts = {}) {
1054
+ if (opts.force) {
1055
+ this.procs.reapSync();
1056
+ return Promise.resolve(this.procs.list());
1057
+ }
1058
+ return this.procs.stop('all');
1059
+ }
1060
+ /**
1061
+ * The synchronous reaper (I-9).
1062
+ *
1063
+ * Exposed so `doExit` can call it without awaiting anything. It is also what
1064
+ * the signal hook registered in the constructor calls, and it is idempotent -
1065
+ * which it has to be, since a normal quit reaches it twice.
1066
+ */
1067
+ reapServicesSync() {
1068
+ this.procs.reapSync();
1069
+ }
749
1070
  steer(text) {
750
1071
  // Mid-run interjection: promote the queue, keep what is already in force
751
1072
  // (D-G3). Clearing here would make "ask a follow-up" the way around the
@@ -938,6 +1259,12 @@ export class AgentController {
938
1259
  this.teamRuntime?.dispose();
939
1260
  this.fast?.dispose();
940
1261
  this.compaction?.dispose();
1262
+ this.contextMeter.dispose();
1263
+ // REAP BEFORE RELEASING THE HOOK, in that order: disposal is a teardown, and
1264
+ // a service that outlives it has nothing left that could ever stop it.
1265
+ this.procs.reapSync();
1266
+ this.releaseSignalHook();
1267
+ this.procs.dispose();
941
1268
  }
942
1269
  // -----------------------------------------------------------------------
943
1270
  // Todo planning (todo-plan-execution §3.6a)
@@ -1063,6 +1390,13 @@ export class AgentController {
1063
1390
  // so the snapshot the chip and `/compact status` read has to be refreshed
1064
1391
  // for the same reason and at the same moments (the RV-3 lesson).
1065
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();
1066
1400
  }
1067
1401
  setThinkingLevel(level) {
1068
1402
  this.config = { ...this.config, thinkingLevel: level };
@@ -1161,7 +1495,14 @@ export class AgentController {
1161
1495
  // INVALIDATION SITE 3 (context-auto-compaction-hardening §3.2.3). The history
1162
1496
  // was CLEARED, not appended to, so the prefix length the last measurement
1163
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.
1164
1504
  this.compaction?.onHistoryReplaced();
1505
+ this.contextMeter.onHistoryReplaced();
1165
1506
  // A new conversation has no loaded skills, so the "already loaded earlier
1166
1507
  // in this conversation" hint would otherwise start lying (P2-9).
1167
1508
  this.skills.getRegistry().clearActive();
@@ -1181,7 +1522,15 @@ export class AgentController {
1181
1522
  // INVALIDATION SITE 4 (§3.2.3) - `/resume`, and any future host-side rewrite.
1182
1523
  // `estimateAppendedTokens` bounds-checks as well, so a MISSED site costs
1183
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).
1184
1532
  this.compaction?.onHistoryReplaced();
1533
+ this.contextMeter.onHistoryReplaced();
1185
1534
  }
1186
1535
  getMessages() {
1187
1536
  return this.agent.state.messages;
@@ -1297,6 +1646,56 @@ export class AgentController {
1297
1646
  return () => { };
1298
1647
  return this.fast.subscribe(listener);
1299
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
+ }
1300
1699
  // -----------------------------------------------------------------------
1301
1700
  // Context compaction (context-auto-compaction §3.2.1) — THIN FORWARDERS
1302
1701
  // -----------------------------------------------------------------------