@aragon-agent/cli 0.6.1 → 0.6.3

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 (127) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/README.md +241 -18
  3. package/dist/agent/agent-mode.d.ts +39 -0
  4. package/dist/agent/agent-mode.js +36 -0
  5. package/dist/agent/controller.d.ts +74 -1
  6. package/dist/agent/controller.js +200 -1
  7. package/dist/agent/headless.d.ts +8 -0
  8. package/dist/agent/headless.js +47 -0
  9. package/dist/agent/reducer.d.ts +120 -0
  10. package/dist/agent/reducer.js +201 -2
  11. package/dist/cli.js +297 -6
  12. package/dist/commands/builtins.js +73 -23
  13. package/dist/commands/registry.d.ts +27 -0
  14. package/dist/compaction/archive.d.ts +175 -0
  15. package/dist/compaction/archive.js +267 -0
  16. package/dist/compaction/child.d.ts +97 -0
  17. package/dist/compaction/child.js +113 -0
  18. package/dist/compaction/command.d.ts +41 -0
  19. package/dist/compaction/command.js +342 -0
  20. package/dist/compaction/compactor.d.ts +268 -0
  21. package/dist/compaction/compactor.js +689 -0
  22. package/dist/compaction/digest.d.ts +62 -0
  23. package/dist/compaction/digest.js +164 -0
  24. package/dist/compaction/limits.d.ts +165 -0
  25. package/dist/compaction/limits.js +172 -0
  26. package/dist/compaction/pressure.d.ts +135 -0
  27. package/dist/compaction/pressure.js +167 -0
  28. package/dist/compaction/summarize-call.d.ts +70 -0
  29. package/dist/compaction/summarize-call.js +86 -0
  30. package/dist/compaction/summary-prompt.d.ts +85 -0
  31. package/dist/compaction/summary-prompt.js +197 -0
  32. package/dist/compaction/tail-budget.d.ts +52 -0
  33. package/dist/compaction/tail-budget.js +67 -0
  34. package/dist/compaction/types.d.ts +179 -0
  35. package/dist/compaction/types.js +22 -0
  36. package/dist/compaction/wiring.d.ts +255 -0
  37. package/dist/compaction/wiring.js +556 -0
  38. package/dist/config/app-paths.d.ts +14 -0
  39. package/dist/config/app-paths.js +16 -0
  40. package/dist/config/cli-commands.d.ts +41 -0
  41. package/dist/config/cli-commands.js +77 -1
  42. package/dist/config/env.d.ts +4 -1
  43. package/dist/config/env.js +92 -2
  44. package/dist/config/load.d.ts +31 -0
  45. package/dist/config/load.js +63 -1
  46. package/dist/config/schema.d.ts +159 -0
  47. package/dist/config/schema.js +137 -0
  48. package/dist/config/store.js +17 -1
  49. package/dist/config/ui-state.d.ts +44 -6
  50. package/dist/config/ui-state.js +21 -12
  51. package/dist/diagnostics/doctor.js +41 -1
  52. package/dist/exec/events.d.ts +50 -1
  53. package/dist/exec/index.js +8 -4
  54. package/dist/exec/runner.js +44 -2
  55. package/dist/input/mouse-events.d.ts +31 -5
  56. package/dist/input/mouse-events.js +86 -27
  57. package/dist/input/stdin-mouse-filter.d.ts +8 -3
  58. package/dist/logging/install.d.ts +51 -1
  59. package/dist/logging/install.js +28 -0
  60. package/dist/logging/logger.d.ts +1 -1
  61. package/dist/session/cli-commands.js +23 -1
  62. package/dist/session/persist.js +19 -0
  63. package/dist/session/store.d.ts +18 -0
  64. package/dist/session/store.js +59 -2
  65. package/dist/team/report.js +7 -1
  66. package/dist/team/runtime.d.ts +10 -0
  67. package/dist/team/runtime.js +7 -0
  68. package/dist/team/subagent.d.ts +31 -1
  69. package/dist/team/subagent.js +42 -15
  70. package/dist/team/types.d.ts +8 -0
  71. package/dist/ui/ActivityLine.d.ts +19 -1
  72. package/dist/ui/ActivityLine.js +10 -2
  73. package/dist/ui/App.d.ts +48 -1
  74. package/dist/ui/App.js +317 -17
  75. package/dist/ui/Composer.d.ts +26 -1
  76. package/dist/ui/Composer.js +6 -6
  77. package/dist/ui/PromptInput.d.ts +34 -1
  78. package/dist/ui/PromptInput.js +54 -3
  79. package/dist/ui/StatusBar.d.ts +40 -0
  80. package/dist/ui/StatusBar.js +13 -4
  81. package/dist/ui/TeamPanel.js +7 -1
  82. package/dist/ui/Transcript.d.ts +21 -1
  83. package/dist/ui/Transcript.js +32 -2
  84. package/dist/ui/clipboard.d.ts +52 -0
  85. package/dist/ui/clipboard.js +91 -0
  86. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  87. package/dist/ui/entries/CompactionCard.js +158 -0
  88. package/dist/ui/frame-differ.d.ts +46 -0
  89. package/dist/ui/frame-differ.js +94 -10
  90. package/dist/ui/gauge.d.ts +16 -1
  91. package/dist/ui/gauge.js +25 -6
  92. package/dist/ui/glyphs.d.ts +11 -0
  93. package/dist/ui/glyphs.js +2 -0
  94. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  95. package/dist/ui/layout/ScrollViewport.js +128 -21
  96. package/dist/ui/layout/budget.d.ts +10 -3
  97. package/dist/ui/layout/budget.js +10 -3
  98. package/dist/ui/layout/follow-state.d.ts +137 -0
  99. package/dist/ui/layout/follow-state.js +150 -0
  100. package/dist/ui/layout/virtual-window.d.ts +11 -0
  101. package/dist/ui/layout/virtual-window.js +34 -0
  102. package/dist/ui/overlays/HelpOverlay.js +18 -2
  103. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  104. package/dist/ui/overlays/SettingsScreen.js +60 -1
  105. package/dist/ui/palettes.d.ts +16 -0
  106. package/dist/ui/palettes.js +6 -0
  107. package/dist/ui/screen.d.ts +22 -0
  108. package/dist/ui/screen.js +70 -8
  109. package/dist/ui/selection/highlight.d.ts +39 -0
  110. package/dist/ui/selection/highlight.js +64 -0
  111. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  112. package/dist/ui/selection/screen-mirror.js +35 -0
  113. package/dist/ui/selection/selection-controller.d.ts +88 -0
  114. package/dist/ui/selection/selection-controller.js +282 -0
  115. package/dist/ui/selection/selection.d.ts +90 -0
  116. package/dist/ui/selection/selection.js +223 -0
  117. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  118. package/dist/ui/stdout-frame-writer.js +15 -0
  119. package/dist/ui/theme.d.ts +11 -0
  120. package/dist/ui/theme.js +2 -0
  121. package/dist/ui/transcript-text.js +23 -0
  122. package/dist/ui/use-startup-notices.d.ts +62 -5
  123. package/dist/ui/use-startup-notices.js +81 -17
  124. package/dist/ui/use-wheel-routing.js +8 -0
  125. package/dist/ui/win-vt-force.d.ts +136 -0
  126. package/dist/ui/win-vt-force.js +237 -0
  127. package/package.json +4 -2
package/dist/cli.js CHANGED
@@ -23,11 +23,11 @@ import { formatMigrationNotice, migrateLegacyState } from './config/migrate-lega
23
23
  import { formatHomeMigrationNotice, migrateToHome } from './config/migrate-home.js';
24
24
  import { migrateStateOutOfConfig } from './config/migrate-state-out-of-config.js';
25
25
  import { runHistoryCommand } from './config/history-commands.js';
26
- import { FAST_CONFIG_SET_KEYS, LOG_CONFIG_SET_KEYS, RETRY_CONFIG_SET_KEYS, TEAM_CONFIG_SET_KEYS, UPDATE_CONFIG_SET_KEYS, applyFastConfigSet, applyLogConfigSet, applyRetryConfigSet, applyTeamConfigSet, applyUpdateConfigSet, runConfigEdit, runConfigGet, runConfigHome, runConfigList, warnIfRedactionDisabled, } from './config/cli-commands.js';
26
+ import { COMPACTION_CONFIG_SET_KEYS, FAST_CONFIG_SET_KEYS, LOG_CONFIG_SET_KEYS, RETRY_CONFIG_SET_KEYS, TEAM_CONFIG_SET_KEYS, UPDATE_CONFIG_SET_KEYS, applyCompactionConfigSet, applyFastConfigSet, applyLogConfigSet, applyRetryConfigSet, applyTeamConfigSet, applyUpdateConfigSet, runConfigEdit, runConfigGet, runConfigHome, runConfigList, warnIfRedactionDisabled, } from './config/cli-commands.js';
27
27
  import { attachAgentEvents, attachTeamEvents, installLogging, setScreenRestore, setSignalTerminator, } from './logging/install.js';
28
28
  import { getLogger } from './logging/logger.js';
29
29
  import { runLogsCommand } from './logging/cli-commands.js';
30
- import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampMaxRenderInterval, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, parseMaxTokensInput, DEFAULT_MAX_RENDER_INTERVAL_MS, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
30
+ import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, parseMaxTokensInput, DEFAULT_MAX_RENDER_INTERVAL_MS, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SCROLL_RESUME_MS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
31
31
  import { makeGetApiKey } from './config/load.js';
32
32
  import { AgentController, DENY_ALL_APPROVAL } from './agent/controller.js';
33
33
  import { runSkillsCommand } from './skills/cli-commands.js';
@@ -47,10 +47,14 @@ import { setFrameStatsProvider } from './commands/perf.js';
47
47
  import { tryCreateMouseFilter } from './input/stdin-mouse-filter.js';
48
48
  import { enterAltScreen, writeExitTranscript } from './ui/screen.js';
49
49
  import { supportsWindowsVtInput } from './ui/win-vt-input.js';
50
+ import { forceWindowsVtInput } from './ui/win-vt-force.js';
50
51
  import { readExitSnapshot } from './ui/exit-snapshot.js';
51
52
  import { renderTranscriptText } from './ui/transcript-text.js';
52
53
  import { detectCapabilities } from './ui/capabilities.js';
53
54
  import { pickGlyphs } from './ui/glyphs.js';
55
+ import { getTheme } from './ui/theme.js';
56
+ import { copyText } from './ui/clipboard.js';
57
+ import { createSelectionController, } from './ui/selection/selection-controller.js';
54
58
  import { DENY_ALL_HUMAN_INPUT } from './tools/human-input.js';
55
59
  // A VALUE import, and the one deliberate exception to the paragraph above
56
60
  // (cli-auto-update-hardening P2-7). `boot/guard.js` statically pulls
@@ -104,6 +108,11 @@ function toFlags(opts) {
104
108
  // Without this line the flag never reaches `CliFlags` and `--no-mouse` is
105
109
  // inert — a failure with no compile error and no runtime error (P1-2).
106
110
  mouse: opts.mouse,
111
+ // Site three of three for `--mouse-select` / `--no-mouse-select` — NINE
112
+ // features, one trap. A flag needs the `.option()` declaration below, a field
113
+ // on `CliFlags`, AND a line here; `config.test.ts` is the regression pin that
114
+ // exists because `--no-mouse` shipped inert once already.
115
+ mouseSelect: opts.mouseSelect,
107
116
  skills: opts.skills,
108
117
  skill: opts.skill,
109
118
  skillsYes: opts.skillsYes,
@@ -170,6 +179,15 @@ function toFlags(opts) {
170
179
  // means `--no-update` reports nothing, does nothing, and leaves the updater
171
180
  // running.
172
181
  update: opts.update,
182
+ // Site three of three for `--compaction` / `--no-compaction` — NINE
183
+ // features, one trap. A flag needs the `.option()` declaration below, a field
184
+ // on `CliFlags`, AND a line here; miss this one and the flag is inert with no
185
+ // compile error and no runtime error. It bites here for the same reason it
186
+ // does one field up: this is the KILL SWITCH for a feature that spends the
187
+ // user's money on a summarizer, so a missing line means `--no-compaction`
188
+ // reports nothing, does nothing, and leaves compaction armed.
189
+ compaction: opts.compaction,
190
+ compactionThreshold: opts.compactionThreshold,
173
191
  };
174
192
  }
175
193
  // ---------------------------------------------------------------------------
@@ -285,6 +303,69 @@ function makeExecController(flags, deps) {
285
303
  */
286
304
  const FRAME_FALLBACK_NOTICE = 'Frame diffing lost sync with the terminal and fell back to a full repaint. ' +
287
305
  'If the display looks wrong, restart with `--no-diff-render`.';
306
+ /**
307
+ * Make this console deliver `CSI Z`, on a Node that will not do it for us
308
+ * (shift-tab-mode-toggle-still-dead-on-windows, C1).
309
+ *
310
+ * Returns whether the console now reports; never throws. `false` means nothing
311
+ * changed and the session runs exactly as it did before this feature existed,
312
+ * with `MODE_TOGGLE_KEYS.fallback` carrying the mode toggle instead.
313
+ *
314
+ * RAW MODE IS RAISED HERE, AND THE ORDER IS THE ENTIRE MECHANISM. libuv rewrites
315
+ * the whole console mode on every raw-mode TRANSITION, and its pre-22.17 path
316
+ * does not know about this bit - so setting the bit first and letting Ink raise
317
+ * raw mode afterwards clears it again, measured (analysis section 3.3). Raising
318
+ * it first inverts that: `uv_tty_set_mode` returns early when the mode is
319
+ * unchanged, so Ink's own `setRawMode(true)` at mount is a no-op and the bit
320
+ * survives to the first keypress.
321
+ *
322
+ * That same asymmetry is why there is no restore call anywhere. Ink lowers raw
323
+ * mode when it unmounts, and that transition IS the restore - it is the only
324
+ * thing that ever clears the bit, since neither Windows nor the console does
325
+ * (section 3.5b). The `exit` hook exists for the paths that never unmount
326
+ * (`taskkill`, a closed terminal window, a crash), which are exactly the
327
+ * unattended ones where a console left in VT-input mode has nobody to notice.
328
+ *
329
+ * WHY THIS RUNS BEFORE `render()` rather than from an effect inside `App`: the
330
+ * answer gates `wantMouse`, which decides the `?1000h` write and the filter
331
+ * BEFORE Ink mounts. Learning the truth one tick too late would mean the wheel
332
+ * stays off for the whole session on a console we just repaired.
333
+ */
334
+ function forceVtInputForThisConsole() {
335
+ if (process.platform !== 'win32' || !process.stdin.isTTY)
336
+ return false;
337
+ const logger = getLogger();
338
+ try {
339
+ process.stdin.setRawMode(true);
340
+ }
341
+ catch (err) {
342
+ // A TTY that refuses raw mode is not a console we can repair; C2 carries it.
343
+ logger.warn('cli', 'vt_input_rawmode_failed', { error: err.message });
344
+ return false;
345
+ }
346
+ process.on('exit', () => {
347
+ try {
348
+ process.stdin.setRawMode(false);
349
+ }
350
+ catch {
351
+ /* Stream already torn down — nothing left to restore. */
352
+ }
353
+ });
354
+ const result = forceWindowsVtInput();
355
+ if (result.ok) {
356
+ logger.info('cli', 'vt_input_forced', { before: result.before, after: result.after });
357
+ return true;
358
+ }
359
+ // Constrained Language Mode kills `Add-Type`, an old conhost drops the bit it
360
+ // does not implement, and `wrong_console` means we configured somebody else's
361
+ // console. All three are ordinary on the fleets this bug keeps landing on, so
362
+ // they degrade in silence and only the log says which one happened.
363
+ logger.warn('cli', 'vt_input_force_failed', {
364
+ reason: result.reason,
365
+ ...(result.detail ? { detail: result.detail } : {}),
366
+ });
367
+ return false;
368
+ }
288
369
  /**
289
370
  * The interactive TUI — and the ONLY place that may touch the screen.
290
371
  *
@@ -339,7 +420,15 @@ function runInteractive(flags, extras = {}) {
339
420
  // filter itself, and the `mouseSource` prop that decides whether the user is
340
421
  // advised about a mode that is not running. Wiring any of them separately
341
422
  // re-opens the gap where the app claims a capability it does not have.
342
- const vtInputSupported = supportsWindowsVtInput(process.platform, process.versions.node);
423
+ //
424
+ // AND `vtInputSupported` IS NOW A MEASUREMENT, NOT A PREDICTION
425
+ // (shift-tab-mode-toggle-still-dead-on-windows, C1). It used to be exactly
426
+ // `supportsWindowsVtInput(...)` - "is this Node new enough to turn the bit on
427
+ // for us?". We now turn it on ourselves when it is not, so continuing to gate
428
+ // on the Node version would switch the wheel off, and keep the warning up, on
429
+ // a console this process just repaired.
430
+ const vtInputByNode = supportsWindowsVtInput(process.platform, process.versions.node);
431
+ const vtInputSupported = vtInputByNode || forceVtInputForThisConsole();
343
432
  const wantMouse = mode === 'fullscreen' &&
344
433
  config.mouse &&
345
434
  !!process.stdout.isTTY &&
@@ -349,6 +438,20 @@ function runInteractive(flags, extras = {}) {
349
438
  ? tryCreateMouseFilter(process.stdin, (reason) => logger.warn('cli', 'mouse_filter_failed', { reason }))
350
439
  : null;
351
440
  const disposeMouse = () => mouseFilter?.dispose();
441
+ // --- Drag-select (tui-selection-and-scroll-follow §4.4). ------------------
442
+ //
443
+ // ONE EXPRESSION, READ BY FOUR THINGS, and that is the same discipline
444
+ // `wantMouse` above follows for the same reason: the `?1002h` write, the
445
+ // controller, the differ's `decorate` hook and the startup notice must never
446
+ // disagree about whether drag-select is running. With it false the session is
447
+ // byte-identical to a pre-feature build — no extra escape on the wire (AC-8),
448
+ // no controller, no `decorate`, no mirror.
449
+ const wantSelect = mouseFilter !== null && config.mouseSelect;
450
+ const selectionBridge = {
451
+ controller: null,
452
+ onCopied: null,
453
+ requestRedraw: null,
454
+ };
352
455
  // --- Frame differ (tui-input-flicker-fix §3.2 / §4.2). -------------------
353
456
  //
354
457
  // FULL-SCREEN ONLY, and that is invariant I-3 rather than caution: inline mode
@@ -371,6 +474,25 @@ function runInteractive(flags, extras = {}) {
371
474
  // edge inside the differ: a notice per fallback would be a repaint
372
475
  // storm triggered by a repaint problem (§5.6).
373
476
  onFirstFallback: () => console.warn(FRAME_FALLBACK_NOTICE),
477
+ // ABSENT ENTIRELY without drag-select, not a no-op function: the
478
+ // differ's identity path then allocates nothing and copies nothing,
479
+ // which is what makes `mouseSelect: false` a statement about the code
480
+ // rather than about how cheap an empty function is.
481
+ ...(wantSelect
482
+ ? {
483
+ decorate: (lines) => selectionBridge.controller?.decorate(lines) ?? lines,
484
+ // P1-7: a pass-through frame writes the RAW Ink frame verbatim,
485
+ // so the highlight is wiped off the terminal while the
486
+ // controller still believes it is painted. Deferred out of the
487
+ // current stack because this fires from inside `transform`,
488
+ // which is inside Ink's own `stdout.write`.
489
+ onInvalidate: () => {
490
+ const controller = selectionBridge.controller;
491
+ if (controller)
492
+ setImmediate(() => controller.clear());
493
+ },
494
+ }
495
+ : {}),
374
496
  }))
375
497
  : null;
376
498
  // `null` when no writer was built, so `/perf` can say "diff render off" rather
@@ -406,8 +528,9 @@ function runInteractive(flags, extras = {}) {
406
528
  if (mode === 'fullscreen') {
407
529
  // `mouse` here means "a filter is installed", NOT "the user wants mouse
408
530
  // support" — see `AltScreenOptions`. `screen.ts` must never derive it for
409
- // itself, or a later refactor re-opens the gap I-8 closes.
410
- screen = enterAltScreen(process.stdout, { mouse: mouseFilter !== null });
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 });
411
534
  const restore = () => screen?.restore();
412
535
  // Four idempotent restore paths (§4.4). The signal hook is NOT redundant:
413
536
  // - `waitUntilExit().then()` is a microtask and may never be reached when
@@ -444,6 +567,115 @@ function runInteractive(flags, extras = {}) {
444
567
  // `CI` is excluded on top of the two TTY tests because a CI runner with a pty
445
568
  // allocated is otherwise a machine that silently mutates its own global
446
569
  // toolchain mid-pipeline (D-11).
570
+ // --- The foreign-write door (§4.4.5). ------------------------------------
571
+ //
572
+ // Where bytes that are OURS but are NOT a frame go — today, the OSC 52
573
+ // clipboard sequence. THREE SHAPES, ONE DOOR, and the third one is the reason
574
+ // this is a resolved value rather than an inline arrow:
575
+ //
576
+ // * with the differ in front of stdout it MUST be `frameWriter.writeForeign`,
577
+ // which invalidates the cache and skips the fallback accounting — through
578
+ // the proxy the same bytes are an unrecognised chunk, which is
579
+ // `passThrough(true)`, which prints `FRAME_FALLBACK_NOTICE` at a user who
580
+ // did nothing but copy something (P1-6);
581
+ // * without one — `--no-diff-render`, and inline mode, which never builds a
582
+ // differ at all — stdout IS the real stream, so a direct write is both safe
583
+ // and what §4.4.5 prescribes. OSC 52 emits no newline, so Ink's own line
584
+ // accounting is untouched and there is nothing to invalidate;
585
+ // * on a non-TTY there is no terminal to accept it, so the door is ABSENT.
586
+ //
587
+ // It must never be a function that accepts a chunk and writes NOWHERE.
588
+ // `copyText` reports `'osc52'` whenever its door took the text, so a silent
589
+ // no-op door — which `frameWriter?.writeForeign(chunk)` is under
590
+ // `--no-diff-render` — makes the toast name a mechanism that was never
591
+ // attempted. Naming the mechanism honestly is the entire reason that return
592
+ // value exists (R-7), and a door that lies is worse than no door.
593
+ const writeForeign = frameWriter
594
+ ? (text) => frameWriter.writeForeign(text)
595
+ : process.stdout.isTTY
596
+ ? (text) => {
597
+ try {
598
+ process.stdout.write(text);
599
+ }
600
+ catch {
601
+ // A closed or broken stdout must never turn a copy into a crash.
602
+ }
603
+ }
604
+ : null;
605
+ // --- The selection controller (§4.4). ------------------------------------
606
+ //
607
+ // BUILT AFTER `enterAltScreen` AND BEFORE `render()`. After, because it must
608
+ // not exist on a session that never asked the terminal to report motion;
609
+ // before, because `decorate` is read by the differ from the very first frame
610
+ // Ink produces.
611
+ //
612
+ // `mouseCaptured` is the run-time answer for `/mouse`, and it lives here rather
613
+ // than inside `screen.ts`'s closure because `App` needs to READ it and the
614
+ // handle's own flag is private (it has to be — `restore()` reads it, and
615
+ // exposing a setter would be a second way to get the two out of step).
616
+ let mouseCaptured = mouseFilter !== null;
617
+ if (wantSelect && mouseFilter) {
618
+ selectionBridge.controller = createSelectionController({
619
+ source: mouseFilter.source,
620
+ // The frame writer is absent under `--no-diff-render`, where there is no
621
+ // cache to repaint from at all; the controller then falls back to asking
622
+ // `App` for a React redraw on every drag flush, which is slower and
623
+ // correct. That is rung 2 of the fail-safe ladder doing its job.
624
+ repaint: () => frameWriter?.repaint() ?? false,
625
+ requestRedraw: () => selectionBridge.requestRedraw?.(),
626
+ // `?? undefined` and not a wrapper: `copyText` skips OSC 52 entirely when
627
+ // it has no door, and reports `'native'`/`'none'` accordingly.
628
+ copy: (text) => copyText(text, writeForeign ?? undefined),
629
+ onCopied: (via, lines, chars) => selectionBridge.onCopied?.(via, lines, chars),
630
+ // Read at PAINT time, not captured: `/theme` rebuilds the theme mid-session.
631
+ theme: () => getTheme(config.theme, detectCapabilities(process.env, process.stdout)),
632
+ caps: detectCapabilities(process.env, process.stdout),
633
+ cols: () => process.stdout.columns ?? 80,
634
+ // The STATIC half of the gate. The dynamic half — "no overlay is open" —
635
+ // belongs to `App`, which owns that state and calls `setEnabled`.
636
+ isSelectable: () => mouseCaptured,
637
+ });
638
+ }
639
+ const disposeSelection = () => {
640
+ selectionBridge.controller?.dispose();
641
+ selectionBridge.controller = null;
642
+ };
643
+ /**
644
+ * The terminal-facing services `App` cannot build for itself (§4.4).
645
+ *
646
+ * PRESENT IN INLINE MODE TOO, and only for the foreign-write door: §4.4.5
647
+ * routes `/copy` through `ui/clipboard.ts` precisely so it gains OSC 52 — the
648
+ * one mechanism that reaches the clipboard of the machine the USER is sitting
649
+ * at over SSH — and gating the whole bridge on full-screen would have handed
650
+ * that back only to full-screen users while the command is available in both.
651
+ * Every OTHER field stays absent there, so the inline tree is unchanged: there
652
+ * is no filter, hence no `setMouseCapture` and no `ctx.mouse`; `mouseSelect`
653
+ * is `wantSelect`, which is already false without one; and there is no
654
+ * controller, so no `selection`.
655
+ */
656
+ const terminalBridge = mode === 'fullscreen' || writeForeign
657
+ ? {
658
+ mouseSelect: wantSelect,
659
+ ...(mouseFilter
660
+ ? {
661
+ setMouseCapture: (on) => {
662
+ screen?.setMouseCapture(on);
663
+ mouseCaptured = on;
664
+ // A released mouse cannot be dragging, and a selection left
665
+ // painted over a screen the terminal is now selecting on its
666
+ // own would be two highlights claiming the same rows.
667
+ if (!on)
668
+ selectionBridge.controller?.clear();
669
+ },
670
+ isMouseCaptured: () => mouseCaptured,
671
+ }
672
+ : {}),
673
+ // The same door the controller copies through, so `/copy` and a
674
+ // drag-release can never disagree about which stream OSC 52 goes to.
675
+ ...(writeForeign ? { writeForeign } : {}),
676
+ ...(selectionBridge.controller ? { selection: selectionBridge } : {}),
677
+ }
678
+ : undefined;
447
679
  const updateBridge = { service: null, onAttach: null };
448
680
  let updateService = null;
449
681
  const updateEligible = !!process.stdout.isTTY &&
@@ -456,7 +688,7 @@ function runInteractive(flags, extras = {}) {
456
688
  // the same tree on every machine — reading `process.versions.node` from
457
689
  // a component would make the notice appear or vanish according to which
458
690
  // Node the developer happened to have installed.
459
- vtInputWarning: vtInputSupported ? undefined : { nodeVersion: process.versions.node }, updateBridge: updateEligible ? updateBridge : undefined }),
691
+ vtInputWarning: vtInputSupported ? undefined : { nodeVersion: process.versions.node }, updateBridge: updateEligible ? updateBridge : undefined, terminal: terminalBridge }),
460
692
  // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
461
693
  // straight to stdout and permanently shifts the fixed frame's accounting.
462
694
  {
@@ -525,6 +757,11 @@ function runInteractive(flags, extras = {}) {
525
757
  .waitUntilExit()
526
758
  .then(() => {
527
759
  screen?.restore();
760
+ // BEFORE `disposeMouse()`, which clears the listener set it subscribed to,
761
+ // and after `restore()` for the reason that call records: the terminal must
762
+ // stop reporting first, and anything already in flight is still consumed by
763
+ // a live filter.
764
+ disposeSelection();
528
765
  disposeMouse();
529
766
  // BOTH BRANCHES, the same discipline `controller.dispose()` and
530
767
  // `updateService?.dispose()` already follow. It drops the `'resize'`
@@ -549,6 +786,11 @@ function runInteractive(flags, extras = {}) {
549
786
  })
550
787
  .catch(() => {
551
788
  screen?.restore();
789
+ // BEFORE `disposeMouse()`, which clears the listener set it subscribed to,
790
+ // and after `restore()` for the reason that call records: the terminal must
791
+ // stop reporting first, and anything already in flight is still consumed by
792
+ // a live filter.
793
+ disposeSelection();
552
794
  disposeMouse();
553
795
  disposeFrameWriter();
554
796
  controller.dispose();
@@ -665,6 +907,11 @@ const CONFIG_SET_KEYS = new Set([
665
907
  // falls through every case, writes nothing, and still prints
666
908
  // `Set mouse = false` (P1-2) -- exactly what happened to `density`/`hints`.
667
909
  'mouse',
910
+ // Both cased in the switch below, for the reason the note above `mouse`
911
+ // records: a key listed here but absent there writes nothing and still prints
912
+ // `Set mouseSelect = false`.
913
+ 'mouseSelect',
914
+ 'scrollResumeMs',
668
915
  'historyEnabled',
669
916
  'planModeDefault',
670
917
  'planModeMaxAskRounds',
@@ -691,6 +938,11 @@ const CONFIG_SET_KEYS = new Set([
691
938
  ...FAST_CONFIG_SET_KEYS,
692
939
  // `update.*` keys are handled by `applyUpdateConfigSet`, same reasoning again.
693
940
  ...UPDATE_CONFIG_SET_KEYS,
941
+ // `compaction.*` keys are handled by `applyCompactionConfigSet`, same
942
+ // reasoning again — and this set is what makes the kill switch reachable from
943
+ // a script at all: `config list` enumerates the section dynamically and so
944
+ // shows these keys whether they are here or not, while `config set` does not.
945
+ ...COMPACTION_CONFIG_SET_KEYS,
694
946
  ]);
695
947
  /**
696
948
  * `aragon config set maxTokens <n|auto|default>`.
@@ -777,6 +1029,18 @@ function runConfigSet(key, value) {
777
1029
  process.stdout.write(`Set ${key} = ${String(merged.update[field])}\n`);
778
1030
  return;
779
1031
  }
1032
+ const compactionPatch = applyCompactionConfigSet(key, value);
1033
+ if (compactionPatch) {
1034
+ // Echoed from the STORED value, so `config set compaction.threshold 0.2`
1035
+ // prints the clamped 0.5 rather than reporting a number that was never
1036
+ // written — the rule `team.*`, `retry.*`, `fast.*` and `update.*` each state
1037
+ // above. It matters most for `warnThreshold`, which is clamped against the
1038
+ // stored `threshold` and so can move for a reason the caller cannot see.
1039
+ const merged = updatePersistedConfig(compactionPatch);
1040
+ const field = key.slice('compaction.'.length);
1041
+ process.stdout.write(`Set ${key} = ${String(merged.compaction[field])}\n`);
1042
+ return;
1043
+ }
780
1044
  const patch = {};
781
1045
  switch (key) {
782
1046
  case 'provider':
@@ -842,6 +1106,16 @@ function runConfigSet(key, value) {
842
1106
  case 'mouse':
843
1107
  patch.mouse = value === 'true' || value === '1';
844
1108
  break;
1109
+ case 'mouseSelect':
1110
+ patch.mouseSelect = value === 'true' || value === '1';
1111
+ break;
1112
+ // Clamped rather than rejected, for the same reason `planModeDefault` below
1113
+ // records: hardening only the READ path leaves a bad value on disk that
1114
+ // reverts to the default on every launch, which presents as "my setting
1115
+ // won't stick". `0` is a legal value here and its documented off switch.
1116
+ case 'scrollResumeMs':
1117
+ patch.scrollResumeMs = clampScrollResumeMs(value, DEFAULT_SCROLL_RESUME_MS);
1118
+ break;
845
1119
  // Turning this off stops new prompts being recorded; it does NOT delete
846
1120
  // what is already in `prompt-history.jsonl` (that is `aragon history clear`).
847
1121
  case 'historyEnabled':
@@ -1100,6 +1374,13 @@ function buildProgram() {
1100
1374
  // Positive form first, same tri-state reason as `--fullscreen` above.
1101
1375
  .option('--mouse', 'Wheel scrolls the transcript (full-screen mode)')
1102
1376
  .option('--no-mouse', 'Leave the mouse to the terminal (the wheel does nothing)')
1377
+ // Positive form FIRST, for the tri-state reason `--fullscreen` records
1378
+ // below: `mouseSelect` is persisted and defaults to `true`, so a lone
1379
+ // `--no-mouse-select` would make commander default `opts.mouseSelect` to
1380
+ // `true` — indistinguishable from silence, and silently overriding a stored
1381
+ // `false` on every run that passes no flag at all.
1382
+ .option('--mouse-select', 'Drag with the mouse to select text; releasing copies it')
1383
+ .option('--no-mouse-select', 'Keep wheel scrolling, but leave drag-select off')
1103
1384
  // Declaring `--fullscreen` BEFORE `--no-fullscreen` keeps the default
1104
1385
  // `undefined` instead of `true`, which is what makes the tri-state work:
1105
1386
  // "no opinion" must stay distinguishable from "force it".
@@ -1156,6 +1437,16 @@ function buildProgram() {
1156
1437
  // feature that installs software would quietly un-set itself (C-11).
1157
1438
  .option('--update', 'Check for and install updates in the background (the default)')
1158
1439
  .option('--no-update', 'Disable auto-update for this session')
1440
+ // BOTH FORMS, positive first, for the reason recorded eight times above —
1441
+ // and this pair shares `--update`'s polarity, which is the worst one.
1442
+ // `compaction.enabled` is PERSISTED and defaults to `true`, so a lone
1443
+ // `--no-compaction` would make commander default `opts.compaction` to `true`
1444
+ // and silently overwrite a stored `false` on EVERY run that passed no flag at
1445
+ // all: the kill switch for a feature that spends the user's money on a
1446
+ // summarizer would quietly un-set itself (C-13 / P1-5).
1447
+ .option('--compaction', 'Compact the conversation when the context window fills (the default)')
1448
+ .option('--no-compaction', 'Never compact; a full context window ends the run as before')
1449
+ .option('--compaction-threshold <n>', 'Occupancy that triggers compaction for this run: 0.9 or 90% (0.5-0.95)')
1159
1450
  .option('--no-skills', 'Disable the skill system entirely (no catalog, no skill tools)')
1160
1451
  .option('--skill <name>', 'Force-load a skill at Level 2 for this run (repeatable)', (value, previous = []) => [...previous, value])
1161
1452
  .option('--skills-yes', 'Approve skill installs for this run (CI / headless)')
@@ -3,10 +3,8 @@
3
3
  * /help /model /settings /thinking /max-tokens /tools /clear /reset /cwd /save
4
4
  * /resume /copy /exit (/quit), plus the /team, /fast and /todo subsystems.
5
5
  */
6
- import { spawn } from 'node:child_process';
7
6
  import { existsSync, statSync } from 'node:fs';
8
7
  import { isAbsolute, resolve } from 'node:path';
9
- import process from 'node:process';
10
8
  import { resolveOutputTokens } from '@aragon-agent/core';
11
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';
12
10
  import { computeCost, formatCost, formatTokens } from '../agent/usage.js';
@@ -16,7 +14,9 @@ import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
16
14
  import { loadConfig } from '../config/load.js';
17
15
  import { loadSession, resolveSessionPath, saveSession } from '../session/persist.js';
18
16
  import { getLogger } from '../logging/logger.js';
17
+ import { copyText } from '../ui/clipboard.js';
19
18
  import { perfCommand } from './perf.js';
19
+ import { runCompactCommand } from '../compaction/command.js';
20
20
  /**
21
21
  * `/fast status` — THE GUARANTEED REPORTING SURFACE for the tier (§4.4 / R-8).
22
22
  *
@@ -801,8 +801,21 @@ const COMMANDS = [
801
801
  ctx.notify('warn', `Unknown argument "${verb}" - use /retry [show|on|off|max <n>].`);
802
802
  },
803
803
  },
804
+ {
805
+ // REGISTRATION ONLY. The body lives in `compaction/command.ts` because this
806
+ // file is already past the 1000-line guideline and `/compact` has six forms
807
+ // plus the longest status report in the package (C-16 / P1-12).
808
+ name: 'compact',
809
+ description: 'Context compaction: status | on | off | threshold <n> | keep <n> | <instructions>',
810
+ run: (ctx) => runCompactCommand(ctx),
811
+ },
804
812
  {
805
813
  name: 'expand',
814
+ // DELIBERATELY LEFT TOOL-ONLY, and the asymmetry with Ctrl+O is a decision
815
+ // rather than an oversight (§6.3). Its description says "a recent tool card",
816
+ // it takes an N-from-last index over tool cards specifically, and widening it
817
+ // would change what `/expand 3` means for every existing user. Ctrl+O is the
818
+ // general expansion surface — that is where `'compaction'` was added.
806
819
  description: 'Expand / collapse a recent tool card (default: last)',
807
820
  run: (ctx) => {
808
821
  const toolEntries = ctx.state.entries.filter((e) => e.kind === 'tool');
@@ -942,8 +955,60 @@ const COMMANDS = [
942
955
  ctx.notify('warn', 'No assistant message to copy.');
943
956
  return;
944
957
  }
945
- const ok = copyToClipboard(last.text);
946
- ctx.toast(ok ? 'success' : 'warn', ok ? 'Copied to clipboard.' : 'Clipboard not available.');
958
+ // ROUTED THROUGH `ui/clipboard.ts` (§4.4.5). The duplicate spawn helper this
959
+ // command used to own is gone, and the command gained SSH support on the
960
+ // way: OSC 52 reaches the clipboard of the machine the USER is sitting at,
961
+ // which the local `pbcopy` never could.
962
+ //
963
+ // `ctx.writeForeign` and not `stdout.write` — see the field's comment.
964
+ const via = copyText(last.text, ctx.writeForeign);
965
+ if (via === 'none') {
966
+ ctx.toast('warn', 'Clipboard not available.');
967
+ return;
968
+ }
969
+ // NAMES THE MECHANISM RATHER THAN CLAIMING SUCCESS: neither path is
970
+ // detectable, and tmux without `set -g set-clipboard on` swallows OSC 52
971
+ // silently (R-7).
972
+ ctx.toast('success', via === 'osc52' ? 'Sent to the terminal clipboard.' : 'Sent to the system clipboard.');
973
+ },
974
+ },
975
+ {
976
+ name: 'mouse',
977
+ description: 'Release the mouse to the terminal, or take it back',
978
+ run: (ctx) => {
979
+ const port = ctx.mouse;
980
+ if (!port) {
981
+ // Inline mode, `--no-mouse`, a non-TTY, or a console that cannot deliver
982
+ // reports. Saying so beats pretending to toggle something that is not on.
983
+ ctx.notify('info', 'The mouse is not captured in this session, so there is nothing to release.');
984
+ return;
985
+ }
986
+ const arg = ctx.args.trim().toLowerCase();
987
+ if (arg === 'off') {
988
+ port.setCapture(false);
989
+ // NAMING THE COST IS THE HONEST THING TO DO, and it is one clause. The
990
+ // wheel goes INERT rather than back to scrolling: alternate scroll is
991
+ // saved and disabled alongside the capture, because otherwise every notch
992
+ // becomes a burst of arrow keys that `PromptInput` reads as prompt-history
993
+ // recall (I-10 / P1-1).
994
+ ctx.toast('info', 'Mouse released to the terminal - the wheel will not scroll. /mouse on gives it back.');
995
+ return;
996
+ }
997
+ if (arg === 'on') {
998
+ port.setCapture(true);
999
+ ctx.toast('info', 'Mouse captured - the wheel scrolls the transcript again.');
1000
+ return;
1001
+ }
1002
+ const captured = port.captured();
1003
+ const lines = [
1004
+ `Mouse capture: ${captured ? 'on' : 'off'}`,
1005
+ ` drag-select ${port.selectEnabled() ? 'on' : 'off (mouseSelect is false)'}`,
1006
+ ' /mouse off hand the mouse to your terminal for this session',
1007
+ ' /mouse on take it back',
1008
+ ' --no-mouse never capture it (also ARAGON_MOUSE=0)',
1009
+ ' aragon config set mouseSelect false keep the wheel, drop drag-select',
1010
+ ];
1011
+ ctx.notify('info', lines.join('\n'));
947
1012
  },
948
1013
  },
949
1014
  {
@@ -1006,22 +1071,7 @@ export const BUILTIN_COMMAND_NAMES = COMMANDS.flatMap((c) => [
1006
1071
  c.name,
1007
1072
  ...(c.aliases ?? []),
1008
1073
  ]).sort();
1009
- /** Best-effort clipboard copy via the platform's clipboard CLI. */
1010
- function copyToClipboard(text) {
1011
- const cmd = process.platform === 'win32'
1012
- ? { file: 'clip', args: [] }
1013
- : process.platform === 'darwin'
1014
- ? { file: 'pbcopy', args: [] }
1015
- : { file: 'xclip', args: ['-selection', 'clipboard'] };
1016
- try {
1017
- const child = spawn(cmd.file, cmd.args, { stdio: ['pipe', 'ignore', 'ignore'] });
1018
- child.on('error', () => {
1019
- /* swallow — clipboard is best-effort */
1020
- });
1021
- child.stdin?.end(text);
1022
- return true;
1023
- }
1024
- catch {
1025
- return false;
1026
- }
1027
- }
1074
+ // The `copyToClipboard` helper that used to live here moved into
1075
+ // `ui/clipboard.ts` (tui-selection-and-scroll-follow §4.4.5), unchanged, so that
1076
+ // `/copy` and the drag-select release share ONE clipboard path instead of two
1077
+ // that can drift.
@@ -87,6 +87,33 @@ export interface CommandContext {
87
87
  effective: AgentMode;
88
88
  pending: AgentMode | null;
89
89
  };
90
+ /**
91
+ * Mouse capture, for `/mouse` (tui-selection-and-scroll-follow §6.2 / G3).
92
+ *
93
+ * OPTIONAL, and legitimately absent: inline mode, `--no-mouse`, a non-TTY, a
94
+ * Windows console that cannot deliver reports, and every test that builds this
95
+ * context by literal. `/mouse` has to have something honest to say when it is
96
+ * missing, which is the same reason `update` above it is optional.
97
+ */
98
+ mouse?: MouseCommandPort;
99
+ /**
100
+ * The foreign-write door, for `/copy`'s OSC 52 half (§4.4.5).
101
+ *
102
+ * NOT `process.stdout.write`. In full-screen mode stdout is a `Proxy` in front
103
+ * of the frame differ, and an unrecognised chunk there is counted as a foreign
104
+ * write and prints a diagnostic at the user the first time they copy anything
105
+ * (P1-6). Absent in inline mode and with `--no-diff-render`, where the real
106
+ * stream is handed over directly.
107
+ */
108
+ writeForeign?: (text: string) => void;
109
+ }
110
+ /** What `/mouse` can see and do. Read at command time, never captured. */
111
+ export interface MouseCommandPort {
112
+ /** Whether the CLI is holding the mouse right now. */
113
+ captured(): boolean;
114
+ /** Whether drag-select is on for this session (`mouseSelect`). */
115
+ selectEnabled(): boolean;
116
+ setCapture(on: boolean): void;
90
117
  }
91
118
  export interface SlashCommand {
92
119
  name: string;