@aragon-agent/cli 0.6.3 → 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 (94) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +95 -2
  3. package/dist/agent/controller.d.ts +119 -0
  4. package/dist/agent/controller.js +275 -1
  5. package/dist/agent/reducer.d.ts +69 -0
  6. package/dist/agent/reducer.js +100 -0
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +2 -0
  9. package/dist/cli.js +84 -22
  10. package/dist/commands/builtins.js +92 -1
  11. package/dist/compaction/child.js +5 -0
  12. package/dist/compaction/command.js +14 -1
  13. package/dist/compaction/compactor.d.ts +34 -0
  14. package/dist/compaction/compactor.js +158 -37
  15. package/dist/compaction/types.d.ts +36 -0
  16. package/dist/compaction/wiring.d.ts +9 -0
  17. package/dist/compaction/wiring.js +22 -1
  18. package/dist/config/env.d.ts +1 -1
  19. package/dist/config/env.js +48 -1
  20. package/dist/config/load.d.ts +11 -0
  21. package/dist/config/load.js +39 -1
  22. package/dist/config/schema.d.ts +85 -0
  23. package/dist/config/schema.js +33 -0
  24. package/dist/config/store.js +21 -1
  25. package/dist/input/limits.d.ts +72 -0
  26. package/dist/input/limits.js +63 -0
  27. package/dist/input/paste-parse.d.ts +71 -0
  28. package/dist/input/paste-parse.js +95 -0
  29. package/dist/input/stdin-filter.d.ts +81 -0
  30. package/dist/input/stdin-filter.js +379 -0
  31. package/dist/logging/install.d.ts +2 -0
  32. package/dist/logging/install.js +53 -1
  33. package/dist/proc/classify.d.ts +52 -0
  34. package/dist/proc/classify.js +143 -0
  35. package/dist/proc/kill-tree.d.ts +42 -0
  36. package/dist/proc/kill-tree.js +119 -0
  37. package/dist/proc/limits.d.ts +84 -0
  38. package/dist/proc/limits.js +84 -0
  39. package/dist/proc/log-ring.d.ts +68 -0
  40. package/dist/proc/log-ring.js +102 -0
  41. package/dist/proc/prompt.d.ts +24 -0
  42. package/dist/proc/prompt.js +49 -0
  43. package/dist/proc/readiness.d.ts +102 -0
  44. package/dist/proc/readiness.js +219 -0
  45. package/dist/proc/supervisor.d.ts +103 -0
  46. package/dist/proc/supervisor.js +465 -0
  47. package/dist/proc/types.d.ts +175 -0
  48. package/dist/proc/types.js +15 -0
  49. package/dist/session/persist.js +36 -1
  50. package/dist/tools/bash-tool.d.ts +51 -2
  51. package/dist/tools/bash-tool.js +321 -51
  52. package/dist/tools/fs-tools.d.ts +17 -0
  53. package/dist/tools/index.d.ts +55 -2
  54. package/dist/tools/index.js +39 -1
  55. package/dist/tools/proc-tools.d.ts +25 -0
  56. package/dist/tools/proc-tools.js +102 -0
  57. package/dist/ui/App.d.ts +15 -2
  58. package/dist/ui/App.js +256 -7
  59. package/dist/ui/Composer.d.ts +54 -1
  60. package/dist/ui/Composer.js +50 -6
  61. package/dist/ui/PromptInput.d.ts +49 -3
  62. package/dist/ui/PromptInput.js +184 -52
  63. package/dist/ui/StatusBar.d.ts +19 -0
  64. package/dist/ui/StatusBar.js +5 -2
  65. package/dist/ui/Transcript.js +29 -1
  66. package/dist/ui/composer-limits.d.ts +31 -0
  67. package/dist/ui/composer-limits.js +50 -0
  68. package/dist/ui/composer-rows.d.ts +73 -0
  69. package/dist/ui/composer-rows.js +201 -0
  70. package/dist/ui/editor-reducer.d.ts +37 -0
  71. package/dist/ui/editor-reducer.js +88 -12
  72. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  73. package/dist/ui/entries/ServiceCard.js +116 -0
  74. package/dist/ui/entries/UserEntry.d.ts +17 -3
  75. package/dist/ui/entries/UserEntry.js +34 -5
  76. package/dist/ui/layout/budget.d.ts +34 -6
  77. package/dist/ui/layout/budget.js +32 -7
  78. package/dist/ui/layout/virtual-window.js +51 -1
  79. package/dist/ui/overlays/HelpOverlay.js +3 -0
  80. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  81. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  82. package/dist/ui/overlays/SettingsScreen.js +14 -1
  83. package/dist/ui/paste-frames.d.ts +87 -0
  84. package/dist/ui/paste-frames.js +112 -0
  85. package/dist/ui/paste-tokens.d.ts +90 -0
  86. package/dist/ui/paste-tokens.js +147 -0
  87. package/dist/ui/screen.d.ts +16 -0
  88. package/dist/ui/screen.js +45 -2
  89. package/dist/ui/selection/selection-controller.d.ts +1 -1
  90. package/dist/ui/transcript-text.js +49 -0
  91. package/dist/ui/use-wheel-routing.d.ts +1 -1
  92. package/package.json +2 -2
  93. package/dist/input/stdin-mouse-filter.d.ts +0 -47
  94. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -20,6 +20,11 @@ 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';
@@ -33,6 +38,15 @@ import { createNodeSkillHost } from '../skills/node-host.js';
33
38
  import { SkillService } from '../skills/service.js';
34
39
  import { createSkillTools } from '../skills/tools.js';
35
40
  import { buildSystemPrompt } from './system-prompt.js';
41
+ /**
42
+ * Extra budget, on top of `PROC_LIMITS.killGraceMs`, that `prompt()` allows a
43
+ * force-stopped engine to unwind in before it starts a new run anyway (I-8).
44
+ *
45
+ * `bash`'s own kill grace is what a wedged foreground command costs; this covers
46
+ * the loop's own unwinding on top of it. Waiting FOREVER is not an option - a
47
+ * tool that never settles is exactly why rung two exists.
48
+ */
49
+ const ENGINE_UNWIND_GRACE_MS = 3000;
36
50
  /** Approval gate used whenever there is provably no human to ask (headless). */
37
51
  export const DENY_ALL_APPROVAL = {
38
52
  canPrompt: () => false,
@@ -172,6 +186,48 @@ export class AgentController {
172
186
  // to subscribe to the Agent, and this class cannot do both in one step.
173
187
  // -----------------------------------------------------------------------
174
188
  compaction;
189
+ // -----------------------------------------------------------------------
190
+ // Background services (background-service-supervision §3.3 / §3.6)
191
+ //
192
+ // CONSTRUCTED UNCONDITIONALLY, and that is the one thing about this field that
193
+ // is easy to get wrong (P1-6 / D-9). `cfg.bash.background` gates the SERVICE
194
+ // half - the `background` tool param, `bash_output`/`bash_kill`, the prompt
195
+ // block, the status chip - and NEVER the foreground half, because G2 ("bash
196
+ // always settles") and G3 ("Esc, Esc always works, and the session stays
197
+ // usable") are promised unconditionally. Gating the whole supervisor on the
198
+ // flag would make rung two of the Esc ladder a silent no-op for every user who
199
+ // turned background services off.
200
+ //
201
+ // With the flag off it holds foreground pids, nothing else, and allocates no
202
+ // timers.
203
+ // -----------------------------------------------------------------------
204
+ procs;
205
+ /**
206
+ * The host's notice channel, kept so `prompt()`'s totality has somewhere to
207
+ * report to (I-8). Every other consumer reads `deps.notify` through a closure
208
+ * built in the constructor; this one is called from a method, which is why it
209
+ * needs a field.
210
+ */
211
+ notifyHostFn;
212
+ /** Whether the SERVICE half is on: tools, prompt block, `background` param. */
213
+ bashBackgroundRegistered;
214
+ /** Release the exit reaper. Called by `dispose()`. */
215
+ releaseSignalHook;
216
+ /**
217
+ * Bumped by `forceStop()`; captured by `App` when it subscribes to a run.
218
+ *
219
+ * THE VIEW AND THE ENGINE DELIBERATELY DISAGREE AFTER A FORCE-STOP (D-10 /
220
+ * I-7). Rung two dispatches `runEnd` locally so the composer is usable
221
+ * immediately, but the engine keeps unwinding and keeps EMITTING -
222
+ * `turn_start`, `tool_call_start`, `turn_end`, `agent_end` - and
223
+ * `runStart`/`turnStart` would set `status: 'running'` again, bouncing the
224
+ * view straight back out of the `idle` the user was just given and appending
225
+ * entries to a transcript they believe is finished. `App` drops every event
226
+ * whose generation is stale, which is the same shape as `abortRequested`
227
+ * above and exists for the same reason: the loop legitimately emits after an
228
+ * abort lands.
229
+ */
230
+ runGen = 0;
175
231
  /**
176
232
  * Set SYNCHRONOUSLY by `abort()` before `agent.abort()` (§3.5.4a guard 1).
177
233
  * Cleared at the next `agent_start`.
@@ -290,6 +346,19 @@ export class AgentController {
290
346
  // reason the field's own comment gives: the binding below is spread
291
347
  // conditionally on it (§3.1.3 / P1-6).
292
348
  this.toolOutputs = config.liveToolOutput ? createToolOutputStore() : null;
349
+ // ALSO ABOVE `createBuiltinTools`, and UNCONDITIONAL - see the field's own
350
+ // comment. `readyTimeoutMs` is read through a closure rather than captured,
351
+ // so a live config change reaches the next service rather than the next
352
+ // launch of the CLI.
353
+ this.procs = new ProcSupervisor({ readyTimeoutMs: () => this.config.bash.readyTimeoutMs });
354
+ this.notifyHostFn = deps.notify;
355
+ this.bashBackgroundRegistered = config.bash.background;
356
+ // THE EXIT REAPER (G5 / P1-2 / R-10). An APPEND-ONLY hook rather than a
357
+ // second `setSignalTerminator`: that is a single slot `cli.tsx` already owns
358
+ // for the alternate-screen restore, and replacing it is the documented bug
359
+ // that leaves the user staring at a blank alternate screen. `reapSync` is
360
+ // the only entry point it calls, and it is synchronous end to end (I-9).
361
+ this.releaseSignalHook = addSignalHook(() => this.procs.reapSync());
293
362
  this.tools = createBuiltinTools({
294
363
  getCwd: () => this.cwd,
295
364
  // OWNED HERE, NOT IN `cli.tsx` (D-18): `createBuiltinTools` is called from
@@ -306,6 +375,28 @@ export class AgentController {
306
375
  ...(this.toolOutputs
307
376
  ? { recordOutput: (id, chunk) => this.emitToolOutput('lead', id, chunk) }
308
377
  : {}),
378
+ // THE SERVICE HALF, AND ONLY THE SERVICE HALF (P1-6 / I-2). With
379
+ // `bash.background: false` the options bag has no `procs` key at all, so
380
+ // `createBuiltinTools` adds no property to `deps`, `bash` gets its default
381
+ // `{}` second argument, and the tool is the pre-feature one: no
382
+ // `background` in its schema, the pre-feature description string, no
383
+ // supervisor call. The FOREGROUND half of `this.procs` is still live, and
384
+ // `forceStop()` still reaches it.
385
+ ...(this.bashBackgroundRegistered
386
+ ? {
387
+ procs: this.procs,
388
+ // Read LIVE for the reason every other closure in this bag is:
389
+ // `aragon config set bash.autoBackground false` must take effect in
390
+ // the running session.
391
+ autoBackground: () => this.config.bash.autoBackground,
392
+ startupSettleMs: () => this.config.bash.startupSettleMs,
393
+ // D-15 / P1-8. `planInteractive` is the SAME signal `planTools`
394
+ // already uses to decide that no overlay can be rendered, which is
395
+ // exactly the question being asked here: can a card be drawn, and is
396
+ // there a user who could keep a service alive?
397
+ interactive: this.planInteractive,
398
+ }
399
+ : {}),
309
400
  confirmTools: config.confirmTools,
310
401
  // Routed through the same watchdog pause as the plan tools. This is a
311
402
  // pre-existing bug being fixed in passing: a `--confirm` user who took
@@ -345,6 +436,12 @@ export class AgentController {
345
436
  }),
346
437
  ]
347
438
  : [],
439
+ // THROUGH THE FACTORY for the reason `teamTools` records below (P0-2), and
440
+ // empty when background services were off at construction - which leaves
441
+ // the array byte-identical to the pre-feature build (I-2).
442
+ procTools: this.bashBackgroundRegistered
443
+ ? [makeBashOutput({ supervisor: this.procs }), makeBashKill({ supervisor: this.procs })]
444
+ : [],
348
445
  // THROUGH THE FACTORY, NEVER APPENDED AFTERWARDS (D-15 / P0-1).
349
446
  // `tools.test.ts::C7` asserts `HOST_TOOL_NAMES` equals what
350
447
  // `createBuiltinTools` produces, so appending `task` here would turn that
@@ -612,6 +709,12 @@ export class AgentController {
612
709
  review: this.config.fast.review,
613
710
  })
614
711
  : '',
712
+ // ONE FLAG, unlike team/todo's two: there is no live on/off switch for
713
+ // background services - `bash.background` is read once at construction,
714
+ // and the tools it registers cannot be unregistered (the `team.enabled` /
715
+ // `todo.enabled` rule). So the block is present exactly when the tools are,
716
+ // and can never advertise a capability the model does not have.
717
+ backgroundBlock: this.bashBackgroundRegistered ? buildBackgroundServicesBlock() : '',
615
718
  // Read from the FIELD on every rebuild, for the reason the field's own
616
719
  // comment gives. Empty everywhere but `aragon exec`, and `buildSystemPrompt`
617
720
  // splices it conditionally, so every existing prompt is byte-identical.
@@ -700,7 +803,64 @@ export class AgentController {
700
803
  * underlying promise so headless mode can await completion. `agent.prompt()`
701
804
  * never rejects, so callers must not treat resolution as success.
702
805
  */
703
- prompt(text) {
806
+ async prompt(text) {
807
+ // I-8 - THIS METHOD NEVER REJECTS, AND THAT HAS TO BE A PROPERTY OF THE
808
+ // METHOD RATHER THAN A COMMENT ABOVE IT (P0-1).
809
+ //
810
+ // `App` calls this as `void controller.prompt(...)`, and
811
+ // `logging/install.ts` routes an `unhandledRejection` into `handleFatal`,
812
+ // which EXITS THE PROCESS. `Agent.prompt()` throws SYNCHRONOUSLY while
813
+ // `Agent.running` is true - so before the force-stop ladder existed this was
814
+ // merely nearly-true (the view was only ever idle when the engine was), and
815
+ // rung two makes the view idle while the engine is still unwinding. Without
816
+ // the wait and the guard below, "Esc, Esc, then type" would kill the CLI on
817
+ // the very screen this feature exists to rescue.
818
+ //
819
+ // TWO PARTS, BOTH REQUIRED. First WAIT the engine out, bounded, so the
820
+ // common case (the engine unwinds in a few hundred ms) starts a real run
821
+ // rather than reporting a failure. Then swallow anything that still escapes
822
+ // and surface it through the existing notify channel, because a message that
823
+ // cannot be started must produce a visible sentence - never a crash, and
824
+ // never silence.
825
+ if (this.agent.state.isRunning) {
826
+ this.abort();
827
+ await this.waitForEngineIdle();
828
+ }
829
+ try {
830
+ await this.startPrompt(text);
831
+ }
832
+ catch (err) {
833
+ const reason = err instanceof Error ? err.message : String(err);
834
+ getLogger().warn('agent', 'prompt_failed', { reason });
835
+ this.notifyHostFn?.('error', `Could not start the run: ${reason}`);
836
+ }
837
+ }
838
+ /**
839
+ * Wait for the engine to unwind, bounded.
840
+ *
841
+ * BOUNDED BECAUSE THE UNBOUNDED CASE IS THE ONE THIS FEATURE IS ABOUT: a tool
842
+ * that never settles is exactly why rung two exists, and `Agent.waitForIdle()`
843
+ * alone would hang here for as long as it hangs there. The budget is the two
844
+ * graces a wedged `bash` can legitimately spend, plus the executor's own.
845
+ */
846
+ waitForEngineIdle() {
847
+ const budget = PROC_LIMITS.killGraceMs + ENGINE_UNWIND_GRACE_MS;
848
+ return new Promise((resolve) => {
849
+ let done = false;
850
+ const finish = () => {
851
+ if (done)
852
+ return;
853
+ done = true;
854
+ clearTimeout(timer);
855
+ resolve();
856
+ };
857
+ const timer = setTimeout(finish, budget);
858
+ timer.unref?.();
859
+ void this.agent.waitForIdle().then(finish, finish);
860
+ });
861
+ }
862
+ /** The pre-round body of `prompt`, unchanged apart from being private. */
863
+ startPrompt(text) {
704
864
  // A new user message is a new intent, so the previous turn's tool ceiling
705
865
  // expires here and anything a slash command queued is promoted (D-G1 / D-G2).
706
866
  // This and `steer()` below are the ONLY two places a user message enters the
@@ -746,6 +906,115 @@ export class AgentController {
746
906
  // `App.tsx` records for `cancelPendingHuman()` on `agent_end`.
747
907
  this.teamRuntime?.abortAll();
748
908
  }
909
+ /**
910
+ * Rung two of the Esc ladder: stop the AGENT, whatever it is blocked on
911
+ * (§3.6 / G3).
912
+ *
913
+ * FOUR STEPS, AND THE FOURTH IS AN ABSENCE.
914
+ *
915
+ * 1. `abort()` - everything it does today, unchanged.
916
+ * 2. `killForeground('force')` - hard-kill every tracked foreground `bash`
917
+ * child. THIS is what actually unblocks a wedged `await tool.execute`;
918
+ * an abort signal a tool ignores is advisory, and rung two exists for
919
+ * precisely the tools that ignore it.
920
+ * 3. `runGen += 1` - so `App` can drop the events the engine will keep
921
+ * emitting while it unwinds (I-7). Without it the view bounces straight
922
+ * back out of the `idle` the user was just given.
923
+ * 4. IT DOES NOT TOUCH BACKGROUND SERVICES. `Esc` interrupts the AGENT;
924
+ * `Ctrl+C` stops the SERVICES. That asymmetry is the user's own two
925
+ * sentences and it is exactly the kind of thing a later reader will
926
+ * "fix" by making this kill everything - which would mean a user who
927
+ * pressed Esc twice to stop a runaway turn also lost the dev server they
928
+ * had been working against for an hour. It is invariant I-4.
929
+ *
930
+ * UNCONDITIONAL ON `bash.background` (P1-6 / D-9). The supervisor is always
931
+ * constructed; with services off its foreground registry is all there is, and
932
+ * that is the half this method uses.
933
+ */
934
+ forceStop() {
935
+ this.abort();
936
+ const killed = this.procs.killForeground('force');
937
+ this.runGen += 1;
938
+ getLogger().info('agent', 'force_stop', { generation: this.runGen, killed });
939
+ }
940
+ /**
941
+ * The current run generation (I-7).
942
+ *
943
+ * `App` captures it when it subscribes to a run and drops every engine event
944
+ * whose generation is stale. Same shape as the `abortRequested` guard the fast
945
+ * reviewer already uses, and for the same reason: the loop legitimately emits
946
+ * after an abort lands.
947
+ */
948
+ get runGeneration() {
949
+ return this.runGen;
950
+ }
951
+ // -----------------------------------------------------------------------
952
+ // Background services (background-service-supervision §3.5 / §3.6)
953
+ // -----------------------------------------------------------------------
954
+ /** Whether `bash_output` / `bash_kill` are in `this.tools`. Fixed at construction. */
955
+ isBackgroundRegistered() {
956
+ return this.bashBackgroundRegistered;
957
+ }
958
+ /**
959
+ * Subscribe to the CLI-local `ProcEvent` stream.
960
+ *
961
+ * Returns a live unsubscribe even when services are off, because the
962
+ * supervisor exists either way - the contract `subscribeTeam` /
963
+ * `subscribeTodos` / `subscribeFast` / `subscribeToolOutput` already set. With
964
+ * services off nothing is ever emitted, so an unconditional subscription in
965
+ * `App` costs one `Set` entry.
966
+ */
967
+ subscribeProc(listener) {
968
+ return this.procs.subscribe(listener);
969
+ }
970
+ getServiceSnapshot(id) {
971
+ return this.procs.get(id);
972
+ }
973
+ listServices() {
974
+ return this.procs.list();
975
+ }
976
+ /** `starting | ready | running`. Drives the status chip and the Ctrl+C rung. */
977
+ liveServiceCount() {
978
+ return this.procs.liveCount();
979
+ }
980
+ readServiceLog(id, since) {
981
+ return this.procs.read(id, since);
982
+ }
983
+ /**
984
+ * Stop one service, or every service when `id` is `'all'`.
985
+ *
986
+ * The GRACEFUL ladder (`SIGTERM` -> `SIGKILL` after `stopGraceMs`), because
987
+ * this path has a live event loop to run it on. `reapServicesSync` is the
988
+ * other one, and it is the only one an exit path may use.
989
+ */
990
+ stopService(id) {
991
+ return this.procs.stop(id);
992
+ }
993
+ /**
994
+ * Rung one of `Ctrl+C`: stop every live service (§3.6 / G4).
995
+ *
996
+ * `force: true` means `reapSync()` SEMANTICS - immediate `SIGKILL`, no
997
+ * `stopGraceMs` - and is what `doExit` uses, because `doExit` awaits nothing
998
+ * and must not start (§3.6 step 3). The graceful ladder belongs to `Ctrl+C`,
999
+ * `bash_kill` and `/bg stop`.
1000
+ */
1001
+ stopAllServices(opts = {}) {
1002
+ if (opts.force) {
1003
+ this.procs.reapSync();
1004
+ return Promise.resolve(this.procs.list());
1005
+ }
1006
+ return this.procs.stop('all');
1007
+ }
1008
+ /**
1009
+ * The synchronous reaper (I-9).
1010
+ *
1011
+ * Exposed so `doExit` can call it without awaiting anything. It is also what
1012
+ * the signal hook registered in the constructor calls, and it is idempotent -
1013
+ * which it has to be, since a normal quit reaches it twice.
1014
+ */
1015
+ reapServicesSync() {
1016
+ this.procs.reapSync();
1017
+ }
749
1018
  steer(text) {
750
1019
  // Mid-run interjection: promote the queue, keep what is already in force
751
1020
  // (D-G3). Clearing here would make "ask a follow-up" the way around the
@@ -938,6 +1207,11 @@ export class AgentController {
938
1207
  this.teamRuntime?.dispose();
939
1208
  this.fast?.dispose();
940
1209
  this.compaction?.dispose();
1210
+ // REAP BEFORE RELEASING THE HOOK, in that order: disposal is a teardown, and
1211
+ // a service that outlives it has nothing left that could ever stop it.
1212
+ this.procs.reapSync();
1213
+ this.releaseSignalHook();
1214
+ this.procs.dispose();
941
1215
  }
942
1216
  // -----------------------------------------------------------------------
943
1217
  // Todo planning (todo-plan-execution §3.6a)
@@ -16,6 +16,7 @@ import type { DispatchOutcome, SubagentRun, SubagentSpec, TeamSnapshot } from '.
16
16
  import type { FastReview, FastSnapshot } from '../fast/types.js';
17
17
  import type { CompactionMode, CompactionRecord, CompactionSnapshot, CompactionUiTrigger } from '../compaction/types.js';
18
18
  import type { TodoItem, TodoSnapshot } from '../todo/types.js';
19
+ import type { ServiceSnapshot, ServiceStatus } from '../proc/types.js';
19
20
  import type { RetryPhase } from './retry-view.js';
20
21
  import type { FilePatch } from '../tools/patch.js';
21
22
  export type NoticeLevel = 'info' | 'warn' | 'error';
@@ -274,6 +275,56 @@ export type Entry = {
274
275
  charsRemoved: number;
275
276
  };
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;
277
328
  };
278
329
  export interface UsageTotal {
279
330
  inputTokens: number;
@@ -606,6 +657,24 @@ export type ViewAction = {
606
657
  | {
607
658
  type: 'contextTokensEstimated';
608
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;
609
678
  };
610
679
  export declare const STORED_PREVIEW_CHARS = 8000;
611
680
  export declare const STORED_PREVIEW_LINES = 200;
@@ -1005,6 +1005,12 @@ export function viewReducer(state, action) {
1005
1005
  case 'fastTier':
1006
1006
  return { ...state, fast: action.snapshot };
1007
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.
1008
1014
  case 'compactionStart': {
1009
1015
  const { id, seq } = nextId(state);
1010
1016
  let entries = state.entries;
@@ -1317,10 +1323,104 @@ export function viewReducer(state, action) {
1317
1323
  fastEntryId: undefined,
1318
1324
  };
1319
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
+ }
1320
1386
  default:
1321
1387
  return state;
1322
1388
  }
1323
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
+ }
1324
1424
  function safeStringify(value) {
1325
1425
  try {
1326
1426
  return JSON.stringify(value);
@@ -88,6 +88,20 @@ export interface SystemPromptParams {
88
88
  * for a fixed `tools` array. Every existing caller passes nothing.
89
89
  */
90
90
  appendSystemPrompt?: string;
91
+ /**
92
+ * The rendered `<background_services>` block, or `''`
93
+ * (background-service-supervision §5.2).
94
+ *
95
+ * Spliced conditionally exactly as `skillsBlock`, the plan block, `teamBlock`,
96
+ * `todoBlock` and `fastBlock` are, which is what keeps I-2 true:
97
+ * `bash.background: false` produces the pre-feature prompt BYTE FOR BYTE for a
98
+ * fixed tool array.
99
+ *
100
+ * A CHILD'S PROMPT NEVER CARRIES IT: subagents build tools from the same
101
+ * factory but are given no supervisor, so telling one about `bash_output` /
102
+ * `bash_kill` would describe tools it does not have.
103
+ */
104
+ backgroundBlock?: string;
91
105
  }
92
106
  /** Describe the active shell so the model does not emit incompatible syntax. */
93
107
  export declare function describeShell(): {
@@ -41,6 +41,7 @@ export function buildSystemPrompt(params) {
41
41
  const subagentBlock = params.subagentBlock ?? '';
42
42
  const todoBlock = params.todoBlock ?? '';
43
43
  const fastBlock = params.fastBlock ?? '';
44
+ const backgroundBlock = params.backgroundBlock ?? '';
44
45
  const appended = (params.appendSystemPrompt ?? '').trim();
45
46
  return [
46
47
  'You are AragonAgent, an autonomous coding assistant operating inside a terminal (TUI).',
@@ -59,6 +60,7 @@ export function buildSystemPrompt(params) {
59
60
  ...(subagentBlock ? ['', subagentBlock] : []),
60
61
  ...(todoBlock ? ['', todoBlock] : []),
61
62
  ...(fastBlock ? ['', fastBlock] : []),
63
+ ...(backgroundBlock ? ['', backgroundBlock] : []),
62
64
  '',
63
65
  'Operating guidance:',
64
66
  '- Prefer reading files and inspecting the workspace before making changes.',