@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
package/dist/cli.js CHANGED
@@ -44,7 +44,7 @@ import { decideRenderMode } from './ui/layout/frame.js';
44
44
  import { createFrameDiffer } from './ui/frame-differ.js';
45
45
  import { wrapStdoutForFrames } from './ui/stdout-frame-writer.js';
46
46
  import { setFrameStatsProvider } from './commands/perf.js';
47
- import { tryCreateMouseFilter } from './input/stdin-mouse-filter.js';
47
+ import { tryCreateStdinFilter } from './input/stdin-filter.js';
48
48
  import { enterAltScreen, writeExitTranscript } from './ui/screen.js';
49
49
  import { supportsWindowsVtInput } from './ui/win-vt-input.js';
50
50
  import { forceWindowsVtInput } from './ui/win-vt-force.js';
@@ -113,6 +113,11 @@ function toFlags(opts) {
113
113
  // on `CliFlags`, AND a line here; `config.test.ts` is the regression pin that
114
114
  // exists because `--no-mouse` shipped inert once already.
115
115
  mouseSelect: opts.mouseSelect,
116
+ // Site three of three for `--paste` / `--no-paste` -- TEN features, one
117
+ // trap. A flag needs the `.option()` declaration below, a field on
118
+ // `CliFlags`, AND a line here; `config.test.ts` is the regression pin that
119
+ // exists because `--no-mouse` shipped inert once already.
120
+ paste: opts.paste,
116
121
  skills: opts.skills,
117
122
  skill: opts.skill,
118
123
  skillsYes: opts.skillsYes,
@@ -416,7 +421,7 @@ function runInteractive(flags, extras = {}) {
416
421
  // before it reaches this process, so `\x1b[?1000h` is a pure no-op and the
417
422
  // filter has nothing to filter. Gating `wantMouse` — rather than the three
418
423
  // things downstream of it — is what keeps the three in agreement: the
419
- // `?1000h` write (`enterAltScreen(..., { mouse: filter !== null })`), the
424
+ // `?1000h` write (`enterAltScreen(..., { mouse: mouseOn })`), the
420
425
  // filter itself, and the `mouseSource` prop that decides whether the user is
421
426
  // advised about a mode that is not running. Wiring any of them separately
422
427
  // re-opens the gap where the app claims a capability it does not have.
@@ -434,10 +439,36 @@ function runInteractive(flags, extras = {}) {
434
439
  !!process.stdout.isTTY &&
435
440
  !!process.stdin.isTTY &&
436
441
  vtInputSupported;
437
- const mouseFilter = wantMouse
438
- ? tryCreateMouseFilter(process.stdin, (reason) => logger.warn('cli', 'mouse_filter_failed', { reason }))
442
+ // --- Paste handling (tui-paste-handling section 5.1.1 / D-13 / D-17). ----
443
+ //
444
+ // THE GATE IS WIDER THAN THE MOUSE'S, AND `paste` DEFAULTS TO TRUE. Paste
445
+ // needs no `vtInputSupported` and no full-screen: Tier 2 is a heuristic on
446
+ // chunk shape and needs no terminal cooperation at all, which is exactly why
447
+ // it exists for the consoles that never deliver `\x1b[200~` (R-11).
448
+ //
449
+ // WHAT THIS WIDENING BREAKS IF IT IS NOT PAIRED WITH `mouseOn` BELOW (P0-1).
450
+ // `filter !== null` used to be the answer to FIVE questions, only one of which
451
+ // is really about the stream. With paste able to build a filter on its own,
452
+ // `aragon --no-mouse` would get a non-null handle and every one of those five
453
+ // would flip true: `?1000h`/`?1006h`/`?1002h` written, terminal selection taken
454
+ // away (the ONLY reason anyone passes `--no-mouse`), and a startup notice
455
+ // advising the user about a mode they just disabled.
456
+ const wantPaste = config.paste && !!process.stdin.isTTY;
457
+ const pasteBridge = { notify: null };
458
+ const filter = wantMouse || wantPaste
459
+ ? tryCreateStdinFilter(process.stdin, { mouse: wantMouse, paste: wantPaste }, (reason) => logger.warn('cli', 'stdin_filter_failed', { reason }), pasteBridge)
439
460
  : null;
440
- const disposeMouse = () => mouseFilter?.dispose();
461
+ /**
462
+ * THE ONE EXPRESSION EVERY MOUSE SITE READS (I-11 / D-17).
463
+ *
464
+ * `filter !== null` now answers exactly one question -- "is a stream wrapped?"
465
+ * -- and the only site allowed to keep reading it is the `stdin` handed to
466
+ * `render()`.
467
+ */
468
+ const mouseOn = wantMouse && filter !== null;
469
+ /** Same discipline for DEC 2004: already decided here, never re-derived. */
470
+ const pasteOn = wantPaste && filter !== null;
471
+ const disposeStdinFilter = () => filter?.dispose();
441
472
  // --- Drag-select (tui-selection-and-scroll-follow §4.4). ------------------
442
473
  //
443
474
  // ONE EXPRESSION, READ BY FOUR THINGS, and that is the same discipline
@@ -446,7 +477,7 @@ function runInteractive(flags, extras = {}) {
446
477
  // disagree about whether drag-select is running. With it false the session is
447
478
  // byte-identical to a pre-feature build — no extra escape on the wire (AC-8),
448
479
  // no controller, no `decorate`, no mirror.
449
- const wantSelect = mouseFilter !== null && config.mouseSelect;
480
+ const wantSelect = mouseOn && config.mouseSelect;
450
481
  const selectionBridge = {
451
482
  controller: null,
452
483
  onCopied: null,
@@ -526,11 +557,21 @@ function runInteractive(flags, extras = {}) {
526
557
  }));
527
558
  };
528
559
  if (mode === 'fullscreen') {
529
- // `mouse` here means "a filter is installed", NOT "the user wants mouse
530
- // support" — see `AltScreenOptions`. `screen.ts` must never derive it for
531
- // itself, or a later refactor re-opens the gap I-8 closes. `motion` carries
532
- // the same discipline for `?1002h`: already decided, never re-derived.
533
- screen = enterAltScreen(process.stdout, { mouse: mouseFilter !== null, motion: wantSelect });
560
+ // `mouse` here means "a mouse-parsing filter is installed", NOT "the user
561
+ // wants mouse support" — see `AltScreenOptions`. `screen.ts` must never
562
+ // derive it for itself, or a later refactor re-opens the gap I-8 closes.
563
+ // `motion` carries the same discipline for `?1002h`, and `bracketedPaste`
564
+ // for `?2004h`: all three are already decided, none is re-derived.
565
+ //
566
+ // IT IS `mouseOn`, NOT `filter !== null` (P0-1). A `--no-mouse` session with
567
+ // paste at its default `true` still builds a filter, and passing the handle
568
+ // here would write `?1000h` at a user who disabled the mouse precisely to
569
+ // keep their terminal's own selection.
570
+ screen = enterAltScreen(process.stdout, {
571
+ mouse: mouseOn,
572
+ motion: wantSelect,
573
+ bracketedPaste: pasteOn && mode === 'fullscreen',
574
+ });
534
575
  const restore = () => screen?.restore();
535
576
  // Four idempotent restore paths (§4.4). The signal hook is NOT redundant:
536
577
  // - `waitUntilExit().then()` is a microtask and may never be reached when
@@ -613,10 +654,10 @@ function runInteractive(flags, extras = {}) {
613
654
  // than inside `screen.ts`'s closure because `App` needs to READ it and the
614
655
  // handle's own flag is private (it has to be — `restore()` reads it, and
615
656
  // exposing a setter would be a second way to get the two out of step).
616
- let mouseCaptured = mouseFilter !== null;
617
- if (wantSelect && mouseFilter) {
657
+ let mouseCaptured = mouseOn;
658
+ if (wantSelect && filter) {
618
659
  selectionBridge.controller = createSelectionController({
619
- source: mouseFilter.source,
660
+ source: filter.source,
620
661
  // The frame writer is absent under `--no-diff-render`, where there is no
621
662
  // cache to repaint from at all; the controller then falls back to asking
622
663
  // `App` for a React redraw on every drag flush, which is slower and
@@ -656,7 +697,7 @@ function runInteractive(flags, extras = {}) {
656
697
  const terminalBridge = mode === 'fullscreen' || writeForeign
657
698
  ? {
658
699
  mouseSelect: wantSelect,
659
- ...(mouseFilter
700
+ ...(mouseOn
660
701
  ? {
661
702
  setMouseCapture: (on) => {
662
703
  screen?.setMouseCapture(on);
@@ -682,13 +723,21 @@ function runInteractive(flags, extras = {}) {
682
723
  !!process.stdin.isTTY &&
683
724
  !process.env.CI &&
684
725
  config.update.mode !== 'off';
685
- const instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay, mouseSource: mouseFilter?.source,
726
+ const instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay,
727
+ // `mouseOn`, NOT the handle (I-11). `App.tsx:186-188` documents this
728
+ // prop's contract as "absent under `--no-mouse`, inline, or non-TTY", and
729
+ // it feeds `enabled: !!mouseSource` on a wheel subscription that would
730
+ // otherwise be a channel no event can ever arrive on.
731
+ mouseSource: mouseOn && filter ? filter.source : undefined,
686
732
  // Present ONLY when this console swallows `CSI Z` and mouse reports.
687
733
  // Passed in rather than probed inside `App` so that every test renders
688
734
  // the same tree on every machine — reading `process.versions.node` from
689
735
  // a component would make the notice appear or vanish according to which
690
736
  // Node the developer happened to have installed.
691
- vtInputWarning: vtInputSupported ? undefined : { nodeVersion: process.versions.node }, updateBridge: updateEligible ? updateBridge : undefined, terminal: terminalBridge }),
737
+ vtInputWarning: vtInputSupported ? undefined : { nodeVersion: process.versions.node }, updateBridge: updateEligible ? updateBridge : undefined,
738
+ // Absent when nothing can refuse a paste, so a `--no-paste` tree is
739
+ // byte-identical to a build without this feature.
740
+ pasteBridge: pasteOn ? pasteBridge : undefined, terminal: terminalBridge }),
692
741
  // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
693
742
  // straight to stdout and permanently shifts the fixed frame's accounting.
694
743
  {
@@ -696,7 +745,7 @@ function runInteractive(flags, extras = {}) {
696
745
  patchConsole: mode === 'inline',
697
746
  // I-5: with mouse support off the REAL stdin is handed over unwrapped, so
698
747
  // every existing path is provably unchanged.
699
- stdin: mouseFilter?.stdin ?? process.stdin,
748
+ stdin: filter?.stdin ?? process.stdin,
700
749
  // SPREAD, not `frameWriter?.stdout ?? process.stdout`. This call passed no
701
750
  // `stdout` at all before, and Ink defaults it to `process.stdout`; omitting
702
751
  // the key entirely when the writer is off keeps the options object the same
@@ -757,12 +806,12 @@ function runInteractive(flags, extras = {}) {
757
806
  .waitUntilExit()
758
807
  .then(() => {
759
808
  screen?.restore();
760
- // BEFORE `disposeMouse()`, which clears the listener set it subscribed to,
809
+ // BEFORE `disposeStdinFilter()`, which clears the listener set it subscribed to,
761
810
  // and after `restore()` for the reason that call records: the terminal must
762
811
  // stop reporting first, and anything already in flight is still consumed by
763
812
  // a live filter.
764
813
  disposeSelection();
765
- disposeMouse();
814
+ disposeStdinFilter();
766
815
  // BOTH BRANCHES, the same discipline `controller.dispose()` and
767
816
  // `updateService?.dispose()` already follow. It drops the `'resize'`
768
817
  // listener of I-8 and detaches the `/perf` provider so a later `/perf` in a
@@ -786,12 +835,12 @@ function runInteractive(flags, extras = {}) {
786
835
  })
787
836
  .catch(() => {
788
837
  screen?.restore();
789
- // BEFORE `disposeMouse()`, which clears the listener set it subscribed to,
838
+ // BEFORE `disposeStdinFilter()`, which clears the listener set it subscribed to,
790
839
  // and after `restore()` for the reason that call records: the terminal must
791
840
  // stop reporting first, and anything already in flight is still consumed by
792
841
  // a live filter.
793
842
  disposeSelection();
794
- disposeMouse();
843
+ disposeStdinFilter();
795
844
  disposeFrameWriter();
796
845
  controller.dispose();
797
846
  updateService?.dispose();
@@ -911,6 +960,10 @@ const CONFIG_SET_KEYS = new Set([
911
960
  // records: a key listed here but absent there writes nothing and still prints
912
961
  // `Set mouseSelect = false`.
913
962
  'mouseSelect',
963
+ // Listed here AND cased in the switch below, for the reason the note above
964
+ // `mouse` records: a key present here but absent there falls through every
965
+ // case, writes nothing, and still prints `Set paste = false`.
966
+ 'paste',
914
967
  'scrollResumeMs',
915
968
  'historyEnabled',
916
969
  'planModeDefault',
@@ -1109,6 +1162,9 @@ function runConfigSet(key, value) {
1109
1162
  case 'mouseSelect':
1110
1163
  patch.mouseSelect = value === 'true' || value === '1';
1111
1164
  break;
1165
+ case 'paste':
1166
+ patch.paste = value === 'true' || value === '1';
1167
+ break;
1112
1168
  // Clamped rather than rejected, for the same reason `planModeDefault` below
1113
1169
  // records: hardening only the READ path leaves a bad value on disk that
1114
1170
  // reverts to the default on every launch, which presents as "my setting
@@ -1381,6 +1437,12 @@ function buildProgram() {
1381
1437
  // `false` on every run that passes no flag at all.
1382
1438
  .option('--mouse-select', 'Drag with the mouse to select text; releasing copies it')
1383
1439
  .option('--no-mouse-select', 'Keep wheel scrolling, but leave drag-select off')
1440
+ // Positive form FIRST, same tri-state reason as `--mouse-select` above:
1441
+ // `paste` is persisted and defaults to `true`, so a lone `--no-paste` would
1442
+ // make commander default `opts.paste` to `true` -- indistinguishable from
1443
+ // silence, and silently overriding a stored `false` on every run.
1444
+ .option('--paste', 'Collapse large pastes into a placeholder (the default)')
1445
+ .option('--no-paste', 'Treat pasted bytes as keystrokes (pre-0.6.3 behavior)')
1384
1446
  // Declaring `--fullscreen` BEFORE `--no-fullscreen` keeps the default
1385
1447
  // `undefined` instead of `true`, which is what makes the tri-state work:
1386
1448
  // "no opinion" must stay distinguishable from "force it".
@@ -7,7 +7,7 @@ import { existsSync, statSync } from 'node:fs';
7
7
  import { isAbsolute, resolve } from 'node:path';
8
8
  import { resolveOutputTokens } from '@aragon-agent/core';
9
9
  import { clampThinkingLevel, clampTheme, isAdapterProvider, isThemeName, parseMaxTokensInput, ADAPTER_PROVIDERS, DEFAULT_MAX_TOKENS, HARD_MAX_RETRIES, LEGACY_DARK_THEME, THEME_NAMES, THINKING_LEVELS, } from '../config/schema.js';
10
- import { computeCost, formatCost, formatTokens } from '../agent/usage.js';
10
+ import { computeCost, formatCost, formatDuration, formatTokens } from '../agent/usage.js';
11
11
  import { TODO_FOLLOW_LIMITS } from '../todo/limits.js';
12
12
  import { buildContinuationMessage } from '../todo/follow-through.js';
13
13
  import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
@@ -1048,6 +1048,95 @@ const COMMANDS = [
1048
1048
  // render layer, and threading `ui/render-cache.js` into this file would make
1049
1049
  // every other command's import graph pull the renderer in with it.
1050
1050
  perfCommand,
1051
+ {
1052
+ name: 'bg',
1053
+ description: 'Background services: list | logs <id> [n] | stop <id|all> | status',
1054
+ /**
1055
+ * THE USER'S HANDLE ON WHAT THE AGENT LEFT RUNNING (§5.3).
1056
+ *
1057
+ * It is the LIVE view, and that is why it exists at all: the transcript
1058
+ * records EVENTS about a service (D-11) — a card printed into `<Static>`
1059
+ * cannot be rewritten — so "what is running right now" lives here, in the
1060
+ * status chip, and in `bash_output`. Without this command a user whose
1061
+ * server card scrolled away an hour ago has no way to name it.
1062
+ */
1063
+ run: async (ctx) => {
1064
+ const arg = ctx.args.trim();
1065
+ const [verb = 'list', value, count] = arg.split(/\s+/);
1066
+ const controller = ctx.controller;
1067
+ if (!controller.isBackgroundRegistered()) {
1068
+ ctx.notify('info', 'Background services are off for this session (bash.background).');
1069
+ return;
1070
+ }
1071
+ const services = controller.listServices();
1072
+ if (verb === 'status') {
1073
+ const live = controller.liveServiceCount();
1074
+ ctx.notify('info', live === 0
1075
+ ? 'No background services running.'
1076
+ : `${live} background service${live === 1 ? '' : 's'} running.`);
1077
+ return;
1078
+ }
1079
+ if (verb === 'logs') {
1080
+ if (!value) {
1081
+ ctx.notify('warn', 'Usage: /bg logs <id> [n]');
1082
+ return;
1083
+ }
1084
+ const service = controller.getServiceSnapshot(value);
1085
+ if (!service) {
1086
+ ctx.notify('warn', `No service "${value}".`);
1087
+ return;
1088
+ }
1089
+ const page = controller.readServiceLog(value);
1090
+ const n = count ? Number.parseInt(count, 10) : DEFAULT_BG_LOG_ROWS;
1091
+ const rows = page?.rows ?? [];
1092
+ const take = Number.isFinite(n) && n > 0 ? n : DEFAULT_BG_LOG_ROWS;
1093
+ const shown = rows.slice(-take);
1094
+ ctx.notify('info', shown.length === 0
1095
+ ? `service ${service.id}: no output yet.`
1096
+ : `service ${service.id} (last ${shown.length} rows)\n${shown.join('\n')}`);
1097
+ return;
1098
+ }
1099
+ if (verb === 'stop') {
1100
+ if (!value) {
1101
+ ctx.notify('warn', 'Usage: /bg stop <id|all>');
1102
+ return;
1103
+ }
1104
+ // AWAITED, unlike `doExit`'s force path: a slash command runs on a live
1105
+ // event loop, so the graceful SIGTERM -> SIGKILL ladder can run and a
1106
+ // dev server gets its chance to release its port cleanly.
1107
+ const stopped = await controller.stopService(value);
1108
+ if (stopped.length === 0) {
1109
+ ctx.notify('info', 'Nothing to stop.');
1110
+ return;
1111
+ }
1112
+ const leaked = stopped.filter((svc) => svc.killIncomplete);
1113
+ ctx.toast('success', `Stopped ${stopped.length} service${stopped.length === 1 ? '' : 's'}.`);
1114
+ if (leaked.length > 0) {
1115
+ // AN HONEST WARNING BEATS A SILENT LEAK (R-2 / P2-6). `taskkill /t`
1116
+ // cannot reach a grandchild that re-parented itself, and saying so is
1117
+ // the difference between a puzzle and a `netstat` the user knows to run.
1118
+ ctx.notify('warn', `${leaked.map((svc) => svc.id).join(', ')} may have left a detached child - ` +
1119
+ 'check with netstat/lsof if a port is still held.');
1120
+ }
1121
+ return;
1122
+ }
1123
+ if (verb !== 'list' && verb !== '') {
1124
+ ctx.notify('warn', `Unknown argument "${verb}" - use /bg [list|logs|stop|status].`);
1125
+ return;
1126
+ }
1127
+ if (services.length === 0) {
1128
+ ctx.notify('info', 'No background services this session.');
1129
+ return;
1130
+ }
1131
+ const now = Date.now();
1132
+ const lines = services.map((svc) => {
1133
+ const uptime = formatDuration((svc.endedAt ?? now) - svc.startedAt);
1134
+ const where = svc.url ? ` ${svc.url}` : '';
1135
+ return `${svc.id} ${svc.status} ${uptime}${where} $ ${svc.command}`;
1136
+ });
1137
+ ctx.notify('info', `Background services:\n${lines.join('\n')}`);
1138
+ },
1139
+ },
1051
1140
  {
1052
1141
  name: 'exit',
1053
1142
  aliases: ['quit'],
@@ -1055,6 +1144,8 @@ const COMMANDS = [
1055
1144
  run: (ctx) => ctx.exit(),
1056
1145
  },
1057
1146
  ];
1147
+ /** Rows `/bg logs <id>` shows when the user names no count. */
1148
+ const DEFAULT_BG_LOG_ROWS = 40;
1058
1149
  export function registerBuiltinCommands(registry) {
1059
1150
  for (const command of COMMANDS)
1060
1151
  registry.register(command);
@@ -85,6 +85,11 @@ export function createChildContextManager(req, deps) {
85
85
  }
86
86
  if (event.type !== 'compaction_end')
87
87
  return;
88
+ // DEFENCE IN DEPTH SINCE quiet-noop D-8: a child compaction that declines
89
+ // before committing to work no longer emits this event at all, so this
90
+ // filter is now unreachable on that path. It is retained deliberately -
91
+ // it is this tree's own record that a no-op compaction is a non-event, and
92
+ // deleting it would make the child depend on that rule holding forever.
88
93
  if (!event.record.applied && event.record.mode === 'none')
89
94
  return;
90
95
  req.onCompacted();
@@ -66,9 +66,22 @@ export function formatCompactionStatus(ctx) {
66
66
  lines.push(` Summarizer: ${snapshot.model || '(none)'}` +
67
67
  (config.useFastTier ? ' (fast tier preferred)' : ' (fast tier not used)'));
68
68
  lines.push(` On summarize failure: ${config.onFailure}`);
69
+ // THE PARENTHETICAL IS BUILT FROM WHATEVER APPLIES, AND OMITTED WHEN NOTHING
70
+ // DOES (quiet-noop D-9). `declined` counts checkpoints that fired and chose to
71
+ // do nothing - the only human-readable trace of a decline once the transcript
72
+ // stopped carrying a card for one. It renders ONLY when non-zero: a counter
73
+ // that says `(0 checkpoints declined)` in every healthy session would have
74
+ // replaced one piece of noise with another.
75
+ const sessionNotes = [];
76
+ if (snapshot.generation > 0)
77
+ sessionNotes.push(`generation ${snapshot.generation}`);
78
+ const declined = snapshot.declined ?? 0;
79
+ if (declined > 0) {
80
+ sessionNotes.push(`${declined} ${declined === 1 ? 'checkpoint' : 'checkpoints'} declined`);
81
+ }
69
82
  lines.push(` This session: ${snapshot.compactions} ` +
70
83
  `${snapshot.compactions === 1 ? 'compaction' : 'compactions'}` +
71
- (snapshot.generation > 0 ? ` (generation ${snapshot.generation})` : '') +
84
+ (sessionNotes.length > 0 ? ` (${sessionNotes.join(', ')})` : '') +
72
85
  `, ${formatTokens(snapshot.tokensReclaimed)} tokens reclaimed`);
73
86
  const cost = snapshot.pricingUnknown
74
87
  ? `unknown (no price table for ${snapshot.model || 'the summarizer'})`
@@ -78,6 +78,15 @@ export declare class Compactor implements ContextManager {
78
78
  private compactionsThisRun;
79
79
  private lastCompactionTurn;
80
80
  private compactions;
81
+ /**
82
+ * How many `compact()` calls returned WITHOUT announcing (quiet-noop D-9).
83
+ *
84
+ * A SESSION TOTAL, LIKE `compactions`, and reset by neither `onRunStart()` nor
85
+ * `clearSelfDisable()`: `/compact on` overrides a guard, it does not rewrite
86
+ * history. `/compact status` is its only reader, and it is the whole of what
87
+ * keeps a decline reportable once the transcript stops carrying one.
88
+ */
89
+ private declined;
81
90
  private tokensReclaimed;
82
91
  private sessionUsage;
83
92
  private pricingUnknown;
@@ -175,6 +184,29 @@ export declare class Compactor implements ContextManager {
175
184
  */
176
185
  shouldCompact(probe: CompactionProbe): boolean;
177
186
  compact(ctx: CompactionContext): Promise<CompactionOutcome>;
187
+ /**
188
+ * Open the card (quiet-noop §3.2).
189
+ *
190
+ * THE ONLY EMITTER OF `compaction_start`, AND THE ONLY PLACE `this.compactions`
191
+ * ADVANCES. A compaction that never reaches an announce point is a DECLINE: it
192
+ * changed nothing, called nothing and spent nothing, and it is deliberately
193
+ * indistinguishable - on every human surface except `/compact status`'s
194
+ * `declined` count (D-9) - from a turn at which `shouldCompact` returned false.
195
+ * `finish` is the other half of that rule.
196
+ *
197
+ * IT RETURNS THE ANNOUNCEMENT RATHER THAN STORING IT (P1-1), and the caller
198
+ * keeps it in a LOCAL for the lifetime of one `runCompaction` call - the same
199
+ * slot `index` occupied before this change. An instance field would have needed
200
+ * a no-re-entrancy argument, and the obvious one is WRONG: guard 1 lives in
201
+ * `shouldCompact`, while the idle `/compact` path calls `compact()` straight
202
+ * through `wiring.compactNow()` without consulting it. What actually keeps two
203
+ * compactions apart is that `command.ts` QUEUES instead of calling `compactNow`
204
+ * while the loop runs - a property of a different file, which is exactly why
205
+ * this one must not depend on it. A clobbered field would leave `finish` unable
206
+ * to settle the card it opened, and a card that never settles pins
207
+ * `Transcript`'s monotonic boundary for the rest of the session.
208
+ */
209
+ private announce;
178
210
  private runCompaction;
179
211
  /** Tail relief against this session's window (hardening §3.3.3 / W2). */
180
212
  private relieveInPlace;
@@ -237,6 +269,8 @@ export declare class Compactor implements ContextManager {
237
269
  recordApplied(tokensBefore: number, tokensAfter: number): void;
238
270
  sessionTotals(): {
239
271
  compactions: number;
272
+ /** Checkpoints that declined before committing to any work (quiet-noop D-9). */
273
+ declined: number;
240
274
  tokensReclaimed: number;
241
275
  usage: TokenUsage;
242
276
  pricingUnknown: boolean;