@aragon-agent/cli 0.6.29 → 0.6.32

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 (123) hide show
  1. package/CHANGELOG.md +2223 -2103
  2. package/README.md +2957 -2759
  3. package/dist/agent/controller.d.ts +46 -0
  4. package/dist/agent/controller.js +175 -7
  5. package/dist/agent/job-notices.d.ts +143 -0
  6. package/dist/agent/job-notices.js +182 -0
  7. package/dist/agent/project-guidance-prompt.js +20 -20
  8. package/dist/agent/reducer.d.ts +21 -1
  9. package/dist/agent/reducer.js +7 -0
  10. package/dist/agent/system-prompt.d.ts +12 -0
  11. package/dist/agent/system-prompt.js +2 -0
  12. package/dist/cli.js +399 -62
  13. package/dist/commands/builtins.js +27 -8
  14. package/dist/commands/perf.d.ts +79 -2
  15. package/dist/commands/perf.js +51 -3
  16. package/dist/commands/registry.d.ts +6 -0
  17. package/dist/config/cli-commands.d.ts +27 -0
  18. package/dist/config/cli-commands.js +54 -1
  19. package/dist/config/env.js +42 -0
  20. package/dist/config/load.d.ts +3 -1
  21. package/dist/config/load.js +61 -1
  22. package/dist/config/schema.d.ts +100 -0
  23. package/dist/config/schema.js +43 -0
  24. package/dist/config/store.js +14 -1
  25. package/dist/config/ui-state.d.ts +9 -0
  26. package/dist/config/ui-state.js +9 -0
  27. package/dist/exec/events.d.ts +29 -3
  28. package/dist/exec/events.js +3 -12
  29. package/dist/exec/runner.d.ts +24 -0
  30. package/dist/exec/runner.js +79 -0
  31. package/dist/input/cursor-report.d.ts +61 -0
  32. package/dist/input/cursor-report.js +97 -0
  33. package/dist/input/stdin-filter.d.ts +22 -0
  34. package/dist/input/stdin-filter.js +48 -3
  35. package/dist/proc/limits.d.ts +76 -2
  36. package/dist/proc/limits.js +83 -2
  37. package/dist/proc/log-mirror.d.ts +59 -0
  38. package/dist/proc/log-mirror.js +203 -0
  39. package/dist/proc/log-ring.d.ts +22 -0
  40. package/dist/proc/log-ring.js +20 -1
  41. package/dist/proc/notices.d.ts +57 -0
  42. package/dist/proc/notices.js +101 -0
  43. package/dist/proc/progress.d.ts +40 -0
  44. package/dist/proc/progress.js +135 -0
  45. package/dist/proc/prompt.d.ts +24 -2
  46. package/dist/proc/prompt.js +51 -2
  47. package/dist/proc/supervisor.d.ts +35 -2
  48. package/dist/proc/supervisor.js +142 -43
  49. package/dist/proc/types.d.ts +89 -1
  50. package/dist/proc/wait.d.ts +57 -0
  51. package/dist/proc/wait.js +133 -0
  52. package/dist/session/persist.js +3 -1
  53. package/dist/session/validate-session.js +17 -0
  54. package/dist/tools/bash-tool.d.ts +12 -1
  55. package/dist/tools/bash-tool.js +100 -13
  56. package/dist/tools/index.d.ts +26 -2
  57. package/dist/tools/index.js +43 -2
  58. package/dist/tools/job-tools.d.ts +54 -0
  59. package/dist/tools/job-tools.js +329 -0
  60. package/dist/tools/proc-tools.d.ts +1 -1
  61. package/dist/tools/proc-tools.js +49 -17
  62. package/dist/ui/ActivityLine.js +7 -0
  63. package/dist/ui/App.d.ts +11 -1
  64. package/dist/ui/App.js +181 -63
  65. package/dist/ui/ScrollbackShell.d.ts +54 -0
  66. package/dist/ui/ScrollbackShell.js +15 -0
  67. package/dist/ui/StatusBar.d.ts +5 -0
  68. package/dist/ui/StatusBar.js +1 -0
  69. package/dist/ui/Transcript.d.ts +14 -0
  70. package/dist/ui/Transcript.js +14 -2
  71. package/dist/ui/entries/ServiceCard.d.ts +17 -1
  72. package/dist/ui/entries/ServiceCard.js +39 -4
  73. package/dist/ui/frame-differ.d.ts +12 -0
  74. package/dist/ui/layout/status-layout.d.ts +13 -1
  75. package/dist/ui/layout/status-layout.js +10 -0
  76. package/dist/ui/layout/virtual-window.js +14 -1
  77. package/dist/ui/overlays/HelpOverlay.d.ts +1 -1
  78. package/dist/ui/overlays/HelpOverlay.js +21 -3
  79. package/dist/ui/run-status-row.d.ts +12 -0
  80. package/dist/ui/screen.d.ts +29 -0
  81. package/dist/ui/screen.js +40 -0
  82. package/dist/ui/scrollback/LiveTail.d.ts +64 -0
  83. package/dist/ui/scrollback/LiveTail.js +27 -0
  84. package/dist/ui/scrollback/band.d.ts +105 -0
  85. package/dist/ui/scrollback/band.js +109 -0
  86. package/dist/ui/scrollback/budget.d.ts +72 -0
  87. package/dist/ui/scrollback/budget.js +81 -0
  88. package/dist/ui/scrollback/canvas.d.ts +99 -0
  89. package/dist/ui/scrollback/canvas.js +113 -0
  90. package/dist/ui/scrollback/cursor-probe.d.ts +71 -0
  91. package/dist/ui/scrollback/cursor-probe.js +133 -0
  92. package/dist/ui/scrollback/drain.d.ts +32 -0
  93. package/dist/ui/scrollback/drain.js +64 -0
  94. package/dist/ui/scrollback/exit-summary.d.ts +23 -0
  95. package/dist/ui/scrollback/exit-summary.js +25 -0
  96. package/dist/ui/scrollback/limits.d.ts +74 -0
  97. package/dist/ui/scrollback/limits.js +74 -0
  98. package/dist/ui/scrollback/live-preview.d.ts +26 -0
  99. package/dist/ui/scrollback/live-preview.js +106 -0
  100. package/dist/ui/scrollback/renderer.d.ts +38 -0
  101. package/dist/ui/scrollback/renderer.js +158 -0
  102. package/dist/ui/scrollback/scrollback-writer.d.ts +162 -0
  103. package/dist/ui/scrollback/scrollback-writer.js +549 -0
  104. package/dist/ui/scrollback/settled-boundary.d.ts +42 -0
  105. package/dist/ui/scrollback/settled-boundary.js +106 -0
  106. package/dist/ui/scrollback/use-scrollback-view.d.ts +103 -0
  107. package/dist/ui/scrollback/use-scrollback-view.js +294 -0
  108. package/dist/ui/scrollback/writer-shapes.d.ts +127 -0
  109. package/dist/ui/scrollback/writer-shapes.js +159 -0
  110. package/dist/ui/stdout-frame-writer.js +12 -2
  111. package/dist/ui/terminal-plan.d.ts +52 -0
  112. package/dist/ui/terminal-plan.js +49 -0
  113. package/dist/ui/transcript-text.d.ts +11 -0
  114. package/dist/ui/transcript-text.js +21 -7
  115. package/dist/ui/use-startup-notices.d.ts +12 -1
  116. package/dist/ui/use-startup-notices.js +28 -2
  117. package/package.json +3 -3
  118. package/skills/project-indexer/SKILL.md +163 -163
  119. package/skills/project-indexer/templates/cleancode-overrides.md +46 -46
  120. package/skills/project-indexer/templates/cleancode-template.md +94 -94
  121. package/skills/project-indexer/templates/config-template.md +69 -69
  122. package/skills/project-indexer/templates/index-template.md +129 -129
  123. package/skills/skill-creator/SKILL.md +96 -96
package/dist/cli.js CHANGED
@@ -25,11 +25,11 @@ import { formatMigrationNotice, migrateLegacyState } from './config/migrate-lega
25
25
  import { formatHomeMigrationNotice, migrateToHome } from './config/migrate-home.js';
26
26
  import { migrateStateOutOfConfig } from './config/migrate-state-out-of-config.js';
27
27
  import { runHistoryCommand } from './config/history-commands.js';
28
- 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';
28
+ import { COMPACTION_CONFIG_SET_KEYS, FAST_CONFIG_SET_KEYS, LOG_CONFIG_SET_KEYS, RETRY_CONFIG_SET_KEYS, TEAM_CONFIG_SET_KEYS, JOBS_CONFIG_SET_KEYS, UPDATE_CONFIG_SET_KEYS, applyCompactionConfigSet, applyFastConfigSet, applyLogConfigSet, applyRetryConfigSet, applyTeamConfigSet, applyJobsConfigSet, applyUpdateConfigSet, runConfigEdit, runConfigGet, runConfigHome, runConfigList, warnIfRedactionDisabled, } from './config/cli-commands.js';
29
29
  import { attachAgentEvents, attachTeamEvents, installLogging, setScreenRestore, setSignalTerminator, } from './logging/install.js';
30
30
  import { getLogger } from './logging/logger.js';
31
31
  import { runLogsCommand } from './logging/cli-commands.js';
32
- import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampContextWindow, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, isAutoToken, 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';
32
+ import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampContextWindow, clampMaxRenderInterval, clampScrollMode, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, isAutoToken, parseMaxTokensInput, DEFAULT_MAX_RENDER_INTERVAL_MS, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SCROLL_MODE, DEFAULT_SCROLL_RESUME_MS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
33
33
  import { makeGetApiKey } from './config/load.js';
34
34
  import { AgentController, DENY_ALL_APPROVAL } from './agent/controller.js';
35
35
  import { runSkillsCommand } from './skills/cli-commands.js';
@@ -49,13 +49,19 @@ import { createBranchLauncher } from './session/branch-launch.js';
49
49
  import { EMPTY_USAGE_TOTAL } from './agent/reducer.js';
50
50
  import { createFrameDiffer } from './ui/frame-differ.js';
51
51
  import { wrapStdoutForFrames } from './ui/stdout-frame-writer.js';
52
- import { setFrameStatsProvider } from './commands/perf.js';
52
+ import { setFrameStatsProvider, setScrollbackStatsProvider } from './commands/perf.js';
53
53
  import { tryCreateStdinFilter } from './input/stdin-filter.js';
54
54
  import { enableKeyboardEnhancement, NOOP_KEYBOARD_ENHANCEMENT, } from './input/keyboard-enhancement.js';
55
55
  import { createScrollbarBridge } from './ui/scrollbar-controller.js';
56
56
  import { createPointerRouter } from './input/pointer-router.js';
57
57
  import { createFrameObserver } from './ui/frame-observer.js';
58
- import { enterAltScreen, writeExitTranscript } from './ui/screen.js';
58
+ import { enterAltScreen, enterNativeScreen, writeExitTranscript, } from './ui/screen.js';
59
+ import { resolveTerminalPlan } from './ui/terminal-plan.js';
60
+ import { createScrollbackDrain } from './ui/scrollback/drain.js';
61
+ import { SCROLLBACK_LIMITS } from './ui/scrollback/limits.js';
62
+ import { createScrollbackWriter } from './ui/scrollback/scrollback-writer.js';
63
+ import { probeAnchorSpaceBelow, setAnchorProbeDisabled } from './ui/scrollback/cursor-probe.js';
64
+ import { createScrollbackBridge, } from './ui/scrollback/use-scrollback-view.js';
59
65
  import { supportsWindowsVtInput } from './ui/win-vt-input.js';
60
66
  import { forceWindowsVtInput } from './ui/win-vt-force.js';
61
67
  import { readExitSnapshot } from './ui/exit-snapshot.js';
@@ -88,6 +94,19 @@ function readVersion() {
88
94
  }
89
95
  }
90
96
  const VERSION = readVersion();
97
+ /**
98
+ * `--scroll-mode` is the one flag validated at parse time (tui-native-terminal-scroll
99
+ * §6.1): a typo must be an exit-2 sentence, not a silent fallback to the default.
100
+ */
101
+ function parseScrollModeFlag(raw) {
102
+ if (raw === undefined)
103
+ return undefined;
104
+ const mode = clampScrollMode(raw, DEFAULT_SCROLL_MODE);
105
+ if (mode === raw.trim().toLowerCase())
106
+ return mode;
107
+ process.stderr.write(`Invalid --scroll-mode "${raw}" (expected native or app).\n`);
108
+ process.exit(2);
109
+ }
91
110
  function toFlags(opts) {
92
111
  return {
93
112
  provider: opts.provider,
@@ -186,6 +205,8 @@ function toFlags(opts) {
186
205
  // user whose terminal renders the diffed frame wrongly, and a user who has
187
206
  // been told to pass it and sees no change has no next move.
188
207
  diffRender: opts.diffRender,
208
+ // Third site for `--scroll-mode`, the same trap the sentence above records.
209
+ scrollMode: parseScrollModeFlag(opts.scrollMode),
189
210
  syncOutput: opts.syncOutput,
190
211
  // Site three of three for `--update` / `--no-update` — SEVEN features, one
191
212
  // trap (`mouse` P1-2, `team` P1-4, `todo` C-9, render L4, retry, fast, now
@@ -393,8 +414,16 @@ function forceVtInputForThisConsole() {
393
414
  * "…" > out.txt`, `aragon config set`, and `aragon --version`, because the
394
415
  * interactive and headless paths are disjoint branches: `runInteractive()`
395
416
  * renders Ink, `runOneShot()` goes to `runHeadless()`, which never does.
417
+ *
418
+ * `async` FOR EXACTLY ONE `await` (tui-bottom-anchored-live-region P1-3): the
419
+ * cursor-position probe before the first frame. Both call sites are inside async
420
+ * Commander actions and use `void runInteractive(...)` — the session's lifetime is
421
+ * owned by `waitUntilExit()` and the four idempotent restore paths, never by the
422
+ * caller, so nothing awaits it. The `await` sits before the FIRST `stdout.write`
423
+ * and between it and `render()` there is no other suspension point, so making the
424
+ * function async introduces no new interleaving.
396
425
  */
397
- function runInteractive(flags, extras = {}) {
426
+ async function runInteractive(flags, extras = {}) {
398
427
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
399
428
  process.stderr.write('Interactive UI requires a TTY. Use -p "<prompt>", exec, or config list / config set.\n');
400
429
  process.exitCode = 2;
@@ -431,8 +460,10 @@ function runInteractive(flags, extras = {}) {
431
460
  const config = controller.getConfig();
432
461
  const caps = resolveTuiCapabilities({}, config.color);
433
462
  const logger = getLogger();
463
+ const scrollMode = config.scrollMode ?? DEFAULT_SCROLL_MODE;
434
464
  logger.info('cli', 'run_interactive', {
435
- mode: 'fullscreen', provider: config.provider, model: config.model,
465
+ mode: scrollMode === 'native' ? 'native' : 'fullscreen',
466
+ provider: config.provider, model: config.model,
436
467
  unicode: caps.unicode, colorLevel: caps.colorLevel,
437
468
  });
438
469
  attachAgentEvents(logger, controller);
@@ -513,6 +544,41 @@ function runInteractive(flags, extras = {}) {
513
544
  };
514
545
  let screen = null;
515
546
  let replayed = false;
547
+ /**
548
+ * Native-scroll exit, shared by EVERY restore path (normal exit, `process.on
549
+ * ('exit')`, the signal terminator, the crash handler, `failInteractive`).
550
+ * Idempotent: the writer's `finalize` returns `''` the second time.
551
+ *
552
+ * Order (RV-9): publish the last unsettled entries to the feed, take the summary
553
+ * (lazily, and a failure only costs the summary), then write the finalize bytes
554
+ * through the REAL stdout. After `finalize` the writer swallows every Ink write,
555
+ * so it does not matter whether Ink has unmounted yet; callers must not add an
556
+ * asynchronous wait for it.
557
+ */
558
+ const finalizeNativeScreen = () => {
559
+ // FIRST, before any bytes: the anchor probe must go quiet here (P1-6). A settle
560
+ // timer that survived this point would write `ESC[6n` at a terminal already handed
561
+ // back to the shell and nobody would strip the reply.
562
+ killAnchorProbe();
563
+ const bridge = scrollbackBridge;
564
+ if (!bridge)
565
+ return;
566
+ try {
567
+ bridge.settleAllNow();
568
+ }
569
+ catch { /* the feed keeps whatever was queued */ }
570
+ let summary = [];
571
+ try {
572
+ summary = bridge.getSummaryRows();
573
+ }
574
+ catch { /* degrade to no summary */ }
575
+ try {
576
+ const bytes = bridge.writer.finalize(summary);
577
+ if (bytes.length > 0)
578
+ process.stdout.write(bytes);
579
+ }
580
+ catch { /* a closed stdout must never turn into a crash on the exit path */ }
581
+ };
516
582
  // --- Mouse wheel region routing (mouse-wheel-region-routing §4.2). -------
517
583
  //
518
584
  // BUILD THE FILTER FIRST, THEN ENABLE REPORTING. The order is invariant I-8
@@ -553,10 +619,23 @@ function runInteractive(flags, extras = {}) {
553
619
  // a console this process just repaired.
554
620
  const vtInputByNode = supportsWindowsVtInput(process.platform, process.versions.node);
555
621
  const vtInputSupported = vtInputByNode || forceVtInputForThisConsole();
556
- const wantMouse = config.mouse &&
557
- !!process.stdout.isTTY &&
558
- !!process.stdin.isTTY &&
559
- vtInputSupported;
622
+ // THE ONE DECISION POINT for screen mode, mouse, paste, key enhancement and the
623
+ // frame writer (tui-native-terminal-scroll D10). In `app` mode every field below
624
+ // is the pre-feature expression term for term (`terminal-plan.test.ts` pins it).
625
+ const plan = resolveTerminalPlan({
626
+ scrollMode,
627
+ configMouse: config.mouse,
628
+ configMouseSelect: config.mouseSelect,
629
+ configPaste: config.paste,
630
+ configKeyboardEnhancement: config.keyboardEnhancement,
631
+ configDiffRender: config.diffRender,
632
+ stdinIsTTY: !!process.stdin.isTTY,
633
+ stdoutIsTTY: !!process.stdout.isTTY,
634
+ platform: process.platform,
635
+ vtInputSupported,
636
+ });
637
+ const native = plan.scrollMode === 'native';
638
+ const wantMouse = plan.mouseReporting;
560
639
  // --- Paste handling (tui-paste-handling section 5.1.1 / D-13 / D-17). ----
561
640
  //
562
641
  // THE GATE IS WIDER THAN THE MOUSE'S, AND `paste` DEFAULTS TO TRUE. Paste
@@ -571,7 +650,7 @@ function runInteractive(flags, extras = {}) {
571
650
  // would flip true: `?1000h`/`?1006h`/`?1002h` written, terminal selection taken
572
651
  // away (the ONLY reason anyone passes `--no-mouse`), and a startup notice
573
652
  // advising the user about a mode they just disabled.
574
- const wantPaste = config.paste && !!process.stdin.isTTY;
653
+ const wantPaste = plan.bracketedPaste;
575
654
  const pasteBridge = { notify: null };
576
655
  // --- Keyboard enhancement (Shift+Enter newline). --------------------------
577
656
  //
@@ -584,11 +663,16 @@ function runInteractive(flags, extras = {}) {
584
663
  // Elsewhere the push is kitty disambiguate + modifyOtherKeys, which a
585
664
  // terminal that implements neither ignores (verified byte-identical on
586
665
  // conhost), so no capability gate is possible or needed there.
587
- const wantEnhancedKeys = config.keyboardEnhancement &&
588
- !!process.stdin.isTTY &&
589
- (process.platform !== 'win32' || vtInputSupported);
666
+ const wantEnhancedKeys = plan.enhancedKeys;
590
667
  const filter = process.stdin.isTTY
591
- ? tryCreateStdinFilter(process.stdin, { mouse: wantMouse, paste: wantPaste, enhancedKeys: wantEnhancedKeys }, (reason) => logger.warn('cli', 'stdin_filter_failed', { reason }), pasteBridge)
668
+ ? tryCreateStdinFilter(process.stdin, {
669
+ mouse: wantMouse,
670
+ paste: wantPaste,
671
+ enhancedKeys: wantEnhancedKeys,
672
+ // TOLD, never derived (I-A8): a native session strips the terminal's
673
+ // answer to `ESC[6n`, an `app` session is byte-identical to before.
674
+ cursorReport: plan.differ === 'scrollback',
675
+ }, (reason) => logger.warn('cli', 'stdin_filter_failed', { reason }), pasteBridge)
592
676
  : null;
593
677
  /**
594
678
  * THE ONE EXPRESSION EVERY MOUSE SITE READS (I-11 / D-17).
@@ -602,11 +686,140 @@ function runInteractive(flags, extras = {}) {
602
686
  const pasteOn = wantPaste && filter !== null;
603
687
  /** Same discipline: the translation runs only when a filter fronts Ink. */
604
688
  const enhancedKeysOn = wantEnhancedKeys && filter !== null;
605
- const disposeStdinFilter = () => filter?.dispose();
689
+ // --- The bottom anchor's measurement pipeline (tui-bottom-anchored-live-region).
690
+ //
691
+ // Three pieces of host state, and every one of them exists to close a specific
692
+ // failure mode the writer cannot close by itself:
693
+ //
694
+ // `anchorProbeDead` — I-A9 / P1-6. After `finalizeNativeScreen()`,
695
+ // `disposeFrameWriter()`, `filter.dispose()` and the process `exit` hook, a
696
+ // query must never be written again: it would hit a terminal already handed
697
+ // back to the shell, where nothing strips the reply and the user's prompt gains
698
+ // a literal `^[[12;1R`.
699
+ // `anchorReprobeQueued` — P0-3. `invalidate()` is called from inside the writer's
700
+ // `transform`, i.e. inside Ink's own `stdout.write`, so the request is pushed out
701
+ // with `setImmediate` and everything inside that window is coalesced into one
702
+ // query instead of becoming a probe storm on every foreign write.
703
+ // `anchorReprobeTimer` — the 200 ms settle window (§4.6). A drag fires dozens of
704
+ // resize events; one CPR each would fight the input channel for bandwidth and
705
+ // make the region jitter.
706
+ let anchorProbeDead = false;
707
+ let anchorReprobeQueued = false;
708
+ let anchorReprobeTimer = null;
709
+ /** Stop measuring for the rest of the process. Idempotent; safe from any exit path. */
710
+ function killAnchorProbe() {
711
+ anchorProbeDead = true;
712
+ anchorReprobeQueued = false;
713
+ if (anchorReprobeTimer) {
714
+ clearTimeout(anchorReprobeTimer);
715
+ anchorReprobeTimer = null;
716
+ }
717
+ // The module-level switch is what makes a probe that is already in flight, or one
718
+ // whose timer fires during teardown, write zero bytes.
719
+ setAnchorProbeDisabled(true);
720
+ }
721
+ /** Ask for one measurement soon; coalesced, deferred out of Ink's write, and dead-safe. */
722
+ function requestAnchorProbe() {
723
+ if (anchorProbeDead || anchorReprobeQueued)
724
+ return;
725
+ anchorReprobeQueued = true;
726
+ setImmediate(() => {
727
+ anchorReprobeQueued = false;
728
+ if (anchorProbeDead)
729
+ return;
730
+ if (anchorReprobeTimer)
731
+ clearTimeout(anchorReprobeTimer);
732
+ anchorReprobeTimer = setTimeout(() => {
733
+ anchorReprobeTimer = null;
734
+ void runAnchorProbe();
735
+ }, SCROLLBACK_LIMITS.anchorReprobeDelayMs);
736
+ anchorReprobeTimer.unref?.();
737
+ });
738
+ }
739
+ /**
740
+ * One query, then consume the answer. Never throws and never fails the session: a
741
+ * terminal that does not answer simply keeps whatever anchor it had.
742
+ */
743
+ async function runAnchorProbe() {
744
+ const bridge = scrollbackBridge;
745
+ if (anchorProbeDead || !bridge || !filter)
746
+ return;
747
+ filter.setExpectingCursorReport(true);
748
+ let spaceBelow = null;
749
+ try {
750
+ spaceBelow = await probeAnchorSpaceBelow({
751
+ stdin: process.stdin,
752
+ stdout: process.stdout,
753
+ rows: () => process.stdout.rows,
754
+ });
755
+ }
756
+ catch (error) {
757
+ logger.warn('cli', 'scroll_anchor_probe_failed', {
758
+ rows: process.stdout.rows ?? null, reason: String(error),
759
+ });
760
+ }
761
+ finally {
762
+ filter.setExpectingCursorReport(false);
763
+ }
764
+ if (anchorProbeDead || spaceBelow === null)
765
+ return;
766
+ // `setAnchorSpaceBelow` records the re-probe count; the repaint is deferred for
767
+ // the same reason `requestRepaint` is (this can run while Ink is writing).
768
+ bridge.writer.setAnchorSpaceBelow(spaceBelow, 'probe');
769
+ logger.info('cli', 'scroll_anchor', {
770
+ source: 'probe', spaceBelow, rows: process.stdout.rows ?? null,
771
+ });
772
+ setImmediate(() => {
773
+ if (!anchorProbeDead)
774
+ frameWriter?.repaint();
775
+ });
776
+ }
777
+ /**
778
+ * THE ORDER HERE IS THE MECHANISM (P0-1 / I-A10). In canonical mode a terminal's
779
+ * reply to `ESC[6n` is line-buffered until a newline AND echoed, so the probe would
780
+ * time out on every Linux/macOS session while printing `^[[12;1R` on the screen.
781
+ * Raising raw first is also the direction `forceVtInputForThisConsole()` already
782
+ * goes (it raises the mode before Ink's own `setRawMode(true)` so libuv does not
783
+ * rewrite the console mode); a second raise is a no-op on both platforms.
784
+ *
785
+ * `false` means "no probe", never "no session": the caller falls back to the fill.
786
+ */
787
+ function raiseRawModeForProbe() {
788
+ if (typeof process.stdin.setRawMode !== 'function')
789
+ return false;
790
+ try {
791
+ process.stdin.setRawMode(true);
792
+ }
793
+ catch (error) {
794
+ logger.warn('cli', 'scroll_anchor_probe_failed', {
795
+ rows: process.stdout.rows ?? null, reason: `raw mode: ${String(error)}`,
796
+ });
797
+ return false;
798
+ }
799
+ // Ink lowers raw mode on unmount, which is the normal restore. A kill, a closed
800
+ // window or a crash never unmounts, so the same restore `forceVtInputForThisConsole`
801
+ // registers is registered here too; it is idempotent and cheap.
802
+ process.on('exit', () => {
803
+ try {
804
+ process.stdin.setRawMode(false);
805
+ }
806
+ catch {
807
+ /* Stream already torn down - nothing left to restore. */
808
+ }
809
+ });
810
+ return true;
811
+ }
812
+ const disposeStdinFilter = () => {
813
+ // Before the filter goes away: a probe settle timer must not fire into a stream
814
+ // nobody is stripping any more.
815
+ killAnchorProbe();
816
+ filter?.dispose();
817
+ };
606
818
  // --- Drag-select (tui-selection-and-scroll-follow §4.4). ------------------
607
819
  //
608
820
  // Selection owns its controller, decoration and notice. Button motion follows
609
821
  // mouseOn independently so --no-mouse-select still permits scrollbar dragging.
822
+ // `mouseOn` is already false in native mode, so this needs no `plan.dragSelect` term.
610
823
  const wantSelect = mouseOn && config.mouseSelect;
611
824
  let mouseCaptured = mouseOn;
612
825
  const scrollbar = createScrollbarBridge(() => mouseCaptured);
@@ -618,59 +831,100 @@ function runInteractive(flags, extras = {}) {
618
831
  onCopied: null,
619
832
  requestRedraw: null,
620
833
  };
621
- const frameWriter = config.diffRender
622
- ? wrapStdoutForFrames(process.stdout, createFrameDiffer({
834
+ // Native scroll: a drain + writer pair replaces the full-screen differ. The bridge
835
+ // is the only object shared with the React tree (tui-native-terminal-scroll §6.2).
836
+ let scrollbackBridge;
837
+ if (plan.differ === 'scrollback') {
838
+ const drain = createScrollbackDrain();
839
+ const writer = createScrollbackWriter({
623
840
  sync: config.syncOutput && process.env.TERM !== 'dumb',
624
841
  rows: () => process.stdout.rows,
625
- // Rows that fill the line skip `CSI K` so the scrollbar's last column
626
- // is not erased on pending-wrap terminals (frame-differ I-4).
627
842
  cols: () => process.stdout.columns,
628
- // The console bridge (`App.tsx`, full-screen only) turns this into
629
- // `dispatch({type:'notice', level:'warn'})`, which is the only legal
630
- // user-visible channel with the TUI mounted — a direct write would
631
- // itself be the foreign write being reported. Latched on the 0->1
632
- // edge inside the differ: a notice per fallback would be a repaint
633
- // storm triggered by a repaint problem (§5.6).
843
+ fullEveryFrame: plan.fullEveryFrame,
844
+ drain,
634
845
  onFirstFallback: () => console.warn(FRAME_FALLBACK_NOTICE),
635
- // ABSENT ENTIRELY without drag-select, not a no-op function: the
636
- // differ's identity path then allocates nothing and copies nothing,
637
- // which is what makes `mouseSelect: false` a statement about the code
638
- // rather than about how cheap an empty function is.
639
- ...(wantSelect
640
- ? {
641
- decorate: (lines) => selectionBridge.controller?.decorate(lines) ?? lines,
642
- // P1-7: a pass-through frame writes the RAW Ink frame verbatim,
643
- // so the highlight is wiped off the terminal while the
644
- // controller still believes it is painted. Deferred out of the
645
- // current stack because this fires from inside `transform`,
646
- // which is inside Ink's own `stdout.write`.
647
- onInvalidate: () => {
648
- const controller = selectionBridge.controller;
649
- if (controller)
650
- setImmediate(() => controller.clear());
651
- },
652
- }
653
- : {}),
654
- }))
655
- : null;
846
+ onGuardTrip: () => scrollbackBridge?.onGuardTrip?.(),
847
+ // Deferred: static rows can be queued from inside a React commit, and the
848
+ // repaint writes to the stream Ink is in the middle of writing to.
849
+ requestRepaint: () => { setImmediate(() => frameWriter?.repaint()); },
850
+ // `invalidate()` calls this from inside `transform` (geometry lost), so the
851
+ // deferral is not optional here either (P0-3). The host coalesces it into one
852
+ // query per settle window and stops it for good once the screen is handed back.
853
+ requestAnchorProbe,
854
+ });
855
+ scrollbackBridge = createScrollbackBridge({
856
+ writer, drain, requestFlush: () => { setImmediate(() => frameWriter?.repaint()); },
857
+ });
858
+ }
859
+ const frameWriter = scrollbackBridge
860
+ ? wrapStdoutForFrames(process.stdout, scrollbackBridge.writer)
861
+ : plan.differ === 'fullscreen'
862
+ ? wrapStdoutForFrames(process.stdout, createFrameDiffer({
863
+ sync: config.syncOutput && process.env.TERM !== 'dumb',
864
+ rows: () => process.stdout.rows,
865
+ // Rows that fill the line skip `CSI K` so the scrollbar's last column
866
+ // is not erased on pending-wrap terminals (frame-differ I-4).
867
+ cols: () => process.stdout.columns,
868
+ // The console bridge (`App.tsx`, full-screen only) turns this into
869
+ // `dispatch({type:'notice', level:'warn'})`, which is the only legal
870
+ // user-visible channel with the TUI mounted — a direct write would
871
+ // itself be the foreign write being reported. Latched on the 0->1
872
+ // edge inside the differ: a notice per fallback would be a repaint
873
+ // storm triggered by a repaint problem (§5.6).
874
+ onFirstFallback: () => console.warn(FRAME_FALLBACK_NOTICE),
875
+ // ABSENT ENTIRELY without drag-select, not a no-op function: the
876
+ // differ's identity path then allocates nothing and copies nothing,
877
+ // which is what makes `mouseSelect: false` a statement about the code
878
+ // rather than about how cheap an empty function is.
879
+ ...(wantSelect
880
+ ? {
881
+ decorate: (lines) => selectionBridge.controller?.decorate(lines) ?? lines,
882
+ // P1-7: a pass-through frame writes the RAW Ink frame verbatim,
883
+ // so the highlight is wiped off the terminal while the
884
+ // controller still believes it is painted. Deferred out of the
885
+ // current stack because this fires from inside `transform`,
886
+ // which is inside Ink's own `stdout.write`.
887
+ onInvalidate: () => {
888
+ const controller = selectionBridge.controller;
889
+ if (controller)
890
+ setImmediate(() => controller.clear());
891
+ },
892
+ }
893
+ : {}),
894
+ }))
895
+ : null;
656
896
  // `null` when no writer was built, so `/perf` can say "diff render off" rather
657
897
  // than printing zeroes that read like a writer doing nothing (§5.4).
658
898
  if (frameWriter)
659
899
  setFrameStatsProvider(frameWriter.stats);
660
- const frameObserver = createFrameObserver({
661
- stdout: frameWriter?.stdout ?? process.stdout, terminal: process.stdout, scrollbar,
662
- });
900
+ if (scrollbackBridge) {
901
+ const { writer } = scrollbackBridge;
902
+ setScrollbackStatsProvider(() => ({
903
+ ...writer.scrollbackStats(), regionRows: writer.regionRows(),
904
+ }));
905
+ }
906
+ // The observer only serves the full-screen scrollbar geometry; native has none.
907
+ const frameObserver = native
908
+ ? null
909
+ : createFrameObserver({
910
+ stdout: frameWriter?.stdout ?? process.stdout, terminal: process.stdout, scrollbar,
911
+ });
663
912
  const disposeFrameWriter = () => {
664
- frameObserver.dispose();
913
+ // Same switch as `finalizeNativeScreen`: this is one of the four teardown paths.
914
+ killAnchorProbe();
915
+ frameObserver?.dispose();
665
916
  pointerRouter?.dispose();
666
917
  scrollbar.controller?.dispose();
667
918
  if (!frameWriter)
668
919
  return;
669
920
  setFrameStatsProvider(null);
921
+ setScrollbackStatsProvider(null);
670
922
  frameWriter.dispose();
671
923
  };
672
924
  const replayTranscript = () => {
673
- if (replayed || !config.exitTranscript)
925
+ // Native mode never replays: the conversation is already in the scrollback, and
926
+ // a replay would print it twice (D9).
927
+ if (replayed || !config.exitTranscript || native)
674
928
  return;
675
929
  replayed = true;
676
930
  const snapshot = readExitSnapshot();
@@ -699,6 +953,49 @@ function runInteractive(flags, extras = {}) {
699
953
  vtInputSupported,
700
954
  })
701
955
  : NOOP_KEYBOARD_ENHANCEMENT;
956
+ // --- Measure the bottom of the screen, THEN build the first frame (§4.4 / §8.1).
957
+ //
958
+ // ORDER, ALL OF IT LOAD-BEARING:
959
+ // 1. the filter exists (it is the only thing that strips the reply — D5);
960
+ // 2. raw mode is raised BEFORE the query (P0-1 / I-A10);
961
+ // 3. the probe is awaited, and any failure is `null`;
962
+ // 4. `enterNativeScreen` writes the one-off fill only when the probe failed;
963
+ // 5. the measurement is handed to the writer before the first batch.
964
+ //
965
+ // This is the only `await` in the whole startup path and it is bounded by
966
+ // `anchorProbeTimeoutMs` (150 ms), which only a terminal that cannot answer pays.
967
+ let anchorSpaceBelow = null;
968
+ if (native && filter && scrollbackBridge) {
969
+ if (raiseRawModeForProbe()) {
970
+ filter.setExpectingCursorReport(true);
971
+ try {
972
+ anchorSpaceBelow = await probeAnchorSpaceBelow({
973
+ stdin: process.stdin,
974
+ stdout: process.stdout,
975
+ rows: () => process.stdout.rows,
976
+ // The first frame is waiting on this: hold the event loop, or a session with
977
+ // nothing else referenced yet can exit before it draws anything.
978
+ holdProcess: true,
979
+ });
980
+ }
981
+ catch (error) {
982
+ // The probe never throws by contract; this is the belt to that braces, and it
983
+ // must not be able to fail the launch.
984
+ logger.warn('cli', 'scroll_anchor_probe_failed', {
985
+ rows: process.stdout.rows ?? null, reason: String(error),
986
+ });
987
+ anchorSpaceBelow = null;
988
+ }
989
+ finally {
990
+ filter.setExpectingCursorReport(false);
991
+ }
992
+ }
993
+ logger.info('cli', anchorSpaceBelow === null ? 'scroll_anchor_probe_failed' : 'scroll_anchor', {
994
+ source: anchorSpaceBelow === null ? 'fill' : 'probe',
995
+ spaceBelow: anchorSpaceBelow,
996
+ rows: process.stdout.rows ?? null,
997
+ });
998
+ }
702
999
  {
703
1000
  // `mouse` here means "a mouse-parsing filter is installed", NOT "the user
704
1001
  // wants mouse support" — see `AltScreenOptions`. `screen.ts` must never
@@ -710,17 +1007,31 @@ function runInteractive(flags, extras = {}) {
710
1007
  // paste at its default `true` still builds a filter, and passing the handle
711
1008
  // here would write `?1000h` at a user who disabled the mouse precisely to
712
1009
  // keep their terminal's own selection.
713
- screen = enterAltScreen(process.stdout, {
714
- mouse: mouseOn,
715
- motion: mouseOn,
716
- bracketedPaste: pasteOn,
717
- });
1010
+ screen = plan.altScreen
1011
+ ? enterAltScreen(process.stdout, {
1012
+ mouse: mouseOn,
1013
+ motion: mouseOn,
1014
+ bracketedPaste: pasteOn,
1015
+ })
1016
+ // Native scroll: no alternate screen, no mouse (D6). DEC 2004, plus the
1017
+ // one-off fill when the cursor could not be measured — before anything else,
1018
+ // so the rows it scrolls away are the shell's, never this session's.
1019
+ : enterNativeScreen(process.stdout, {
1020
+ bracketedPaste: pasteOn,
1021
+ anchorFillRows: anchorSpaceBelow === null && scrollbackBridge
1022
+ ? Math.max(0, (process.stdout.rows ?? 24) - 1)
1023
+ : 0,
1024
+ });
1025
+ // The measurement reaches the writer BEFORE the first batch, so frame one is
1026
+ // already pinned. `fill` says "we know where the bottom is because we went there".
1027
+ scrollbackBridge?.writer.setAnchorSpaceBelow(anchorSpaceBelow ?? 0, anchorSpaceBelow === null ? 'fill' : 'probe');
718
1028
  // Input-encoding modes unwind FIRST, for the same reason the mouse
719
1029
  // reporting does inside `screen.restore()`: the terminal must stop
720
1030
  // sending enhanced encodings before anything else tears the reader
721
1031
  // down, or a keystroke taken in that instant survives into the shell.
722
1032
  const restore = () => {
723
1033
  keyboardEnhancement.restore();
1034
+ finalizeNativeScreen();
724
1035
  screen?.restore();
725
1036
  };
726
1037
  // Four idempotent restore paths (§4.4). The signal hook is NOT redundant:
@@ -838,7 +1149,8 @@ function runInteractive(flags, extras = {}) {
838
1149
  interactiveClosed = true;
839
1150
  logger.error('cli', 'render_failed', { error: String(error) });
840
1151
  for (const cleanup of [
841
- () => screen?.restore(), disposeSelection, disposeStdinFilter, disposeFrameWriter,
1152
+ finalizeNativeScreen, () => screen?.restore(), disposeSelection, disposeStdinFilter,
1153
+ disposeFrameWriter,
842
1154
  () => controller.abort(), () => controller.dispose(), () => updateService?.dispose(),
843
1155
  () => { process.stdin.pause(); process.stdin.unref?.(); },
844
1156
  ]) {
@@ -865,7 +1177,7 @@ function runInteractive(flags, extras = {}) {
865
1177
  vtInputWarning: vtInputSupported ? undefined : { nodeVersion: process.versions.node }, updateBridge: updateEligible ? updateBridge : undefined,
866
1178
  // Absent when nothing can refuse a paste, so a `--no-paste` tree is
867
1179
  // byte-identical to a build without this feature.
868
- pasteBridge: pasteOn ? pasteBridge : undefined, terminal: terminalBridge, session: identity, sessionBridge: sessionBridge, ...(startup.initialEntries.length > 0 ? { initialEntries: startup.initialEntries } : {}), ...(startup.notice ? { sessionNotice: startup.notice } : {}) }),
1180
+ pasteBridge: pasteOn ? pasteBridge : undefined, terminal: terminalBridge, ...(scrollbackBridge ? { scrollMode: plan.scrollMode, scrollback: scrollbackBridge } : {}), session: identity, sessionBridge: sessionBridge, ...(startup.initialEntries.length > 0 ? { initialEntries: startup.initialEntries } : {}), ...(startup.notice ? { sessionNotice: startup.notice } : {}) }),
869
1181
  // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
870
1182
  // straight to stdout and permanently shifts the fixed frame's accounting.
871
1183
  {
@@ -874,7 +1186,7 @@ function runInteractive(flags, extras = {}) {
874
1186
  // I-5: with mouse support off the REAL stdin is handed over unwrapped, so
875
1187
  // every existing path is provably unchanged.
876
1188
  stdin: filter?.stdin ?? process.stdin,
877
- stdout: frameObserver.stdout,
1189
+ stdout: frameObserver?.stdout ?? frameWriter?.stdout ?? process.stdout,
878
1190
  }, caps);
879
1191
  }
880
1192
  catch (error) {
@@ -955,6 +1267,9 @@ function runInteractive(flags, extras = {}) {
955
1267
  // Also cover a successful immediate exit when launched via dist/cli.js,
956
1268
  // which bypasses the launcher's code-zero boot-guard backstop.
957
1269
  markBootHealthy(VERSION);
1270
+ // Native scroll: finalize BEFORE the screen handle (the writer erases the live
1271
+ // region and prints the summary; the handle then shows the cursor again).
1272
+ finalizeNativeScreen();
958
1273
  screen?.restore();
959
1274
  // BEFORE `disposeStdinFilter()`, which clears the listener set it subscribed to,
960
1275
  // and after `restore()` for the reason that call records: the terminal must
@@ -1159,6 +1474,8 @@ const CONFIG_SET_KEYS = new Set([
1159
1474
  // tui-input-flicker-fix §5.1. Listed here AND cased in the switch below, for
1160
1475
  // the reason `transcriptRetain` records three lines up.
1161
1476
  'diffRender',
1477
+ // tui-native-terminal-scroll. Listed here AND cased in the switch below.
1478
+ 'scrollMode',
1162
1479
  'syncOutput',
1163
1480
  // Without these two, `density` and `hints` would be settable only as one-shot
1164
1481
  // flags and never persist -- inconsistent with every other config field.
@@ -1218,6 +1535,11 @@ const CONFIG_SET_KEYS = new Set([
1218
1535
  // a script at all: `config list` enumerates the section dynamically and so
1219
1536
  // shows these keys whether they are here or not, while `config set` does not.
1220
1537
  ...COMPACTION_CONFIG_SET_KEYS,
1538
+ // `jobs.*` keys are handled by `applyJobsConfigSet`, same reasoning again — and
1539
+ // this spread is exactly why `JOBS_CONFIG_SET_KEYS ⊆ CONFIG_SET_KEYS` needs its
1540
+ // own assertion: the static guard in `config.test.ts` matches LITERAL keys in
1541
+ // this file, so a spread set is invisible to it (RV-P2-8).
1542
+ ...JOBS_CONFIG_SET_KEYS,
1221
1543
  ]);
1222
1544
  /**
1223
1545
  * `aragon config set maxTokens <n|auto|default>`.
@@ -1357,6 +1679,17 @@ function runConfigSetValue(key, value) {
1357
1679
  process.stdout.write(`Set ${key} = ${String(merged.compaction[field])}\n`);
1358
1680
  return;
1359
1681
  }
1682
+ const jobsPatch = applyJobsConfigSet(key, value);
1683
+ if (jobsPatch) {
1684
+ // Echoed from the STORED value, so `config set jobs.waitSeconds 1` prints 5
1685
+ // and `jobs.waitSeconds 9999` prints 60 — the rule every section above
1686
+ // states. It matters here too: `waitSeconds` is ALSO converged against the
1687
+ // tool ceiling by I-4, so the stored value is the only honest answer.
1688
+ const merged = updatePersistedConfig(jobsPatch);
1689
+ const field = key.slice('jobs.'.length);
1690
+ process.stdout.write(`Set ${key} = ${String(merged.jobs[field])}\n`);
1691
+ return;
1692
+ }
1360
1693
  const patch = {};
1361
1694
  switch (key) {
1362
1695
  case 'provider':
@@ -1401,6 +1734,9 @@ function runConfigSetValue(key, value) {
1401
1734
  case 'diffRender':
1402
1735
  patch.diffRender = value === 'true' || value === '1';
1403
1736
  break;
1737
+ case 'scrollMode':
1738
+ patch.scrollMode = clampScrollMode(value, DEFAULT_SCROLL_MODE);
1739
+ break;
1404
1740
  case 'syncOutput':
1405
1741
  patch.syncOutput = value === 'true' || value === '1';
1406
1742
  break;
@@ -1790,6 +2126,7 @@ function buildProgram() {
1790
2126
  // one documented escape hatch for a mis-rendering terminal into a no-op.
1791
2127
  .option('--diff-render', 'Repaint only changed rows (default, full-screen only)')
1792
2128
  .option('--no-diff-render', 'Repaint the whole frame every time (pre-fix behavior)')
2129
+ .option('--scroll-mode <mode>', 'Scrolling: native (terminal scrollback, default) or app (full-screen)')
1793
2130
  .option('--sync-output', 'Ask the terminal to present each repaint atomically (the default)')
1794
2131
  .option('--no-sync-output', 'Do not emit DEC 2026 begin/end synchronized update')
1795
2132
  .option('--no-color', 'Disable ANSI color')
@@ -1822,14 +2159,14 @@ function buildProgram() {
1822
2159
  }
1823
2160
  return;
1824
2161
  }
1825
- runInteractive(flags, { initialPrompt: prompt });
2162
+ void runInteractive(flags, { initialPrompt: prompt });
1826
2163
  });
1827
2164
  // aragon config [set <key> <value> | path]
1828
2165
  const configCmd = program
1829
2166
  .command('config')
1830
2167
  .description('Open the settings screen')
1831
2168
  .action(() => {
1832
- runInteractive(toFlags(program.opts()), { initialOverlay: 'settings' });
2169
+ void runInteractive(toFlags(program.opts()), { initialOverlay: 'settings' });
1833
2170
  });
1834
2171
  configCmd
1835
2172
  .command('set <key> <value>')