@aragon-agent/cli 0.6.31 → 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 (91) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +2957 -2826
  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/reducer.d.ts +21 -1
  8. package/dist/agent/reducer.js +7 -0
  9. package/dist/agent/system-prompt.d.ts +12 -0
  10. package/dist/agent/system-prompt.js +2 -0
  11. package/dist/cli.js +232 -8
  12. package/dist/commands/builtins.js +23 -8
  13. package/dist/commands/perf.d.ts +54 -1
  14. package/dist/commands/perf.js +28 -4
  15. package/dist/config/cli-commands.d.ts +27 -0
  16. package/dist/config/cli-commands.js +54 -1
  17. package/dist/config/env.js +38 -0
  18. package/dist/config/load.js +50 -1
  19. package/dist/config/schema.d.ts +73 -0
  20. package/dist/config/schema.js +34 -0
  21. package/dist/config/store.js +14 -1
  22. package/dist/exec/events.d.ts +29 -3
  23. package/dist/exec/events.js +3 -12
  24. package/dist/exec/runner.d.ts +24 -0
  25. package/dist/exec/runner.js +79 -0
  26. package/dist/input/cursor-report.d.ts +61 -0
  27. package/dist/input/cursor-report.js +97 -0
  28. package/dist/input/stdin-filter.d.ts +22 -0
  29. package/dist/input/stdin-filter.js +48 -3
  30. package/dist/proc/limits.d.ts +76 -2
  31. package/dist/proc/limits.js +83 -2
  32. package/dist/proc/log-mirror.d.ts +59 -0
  33. package/dist/proc/log-mirror.js +203 -0
  34. package/dist/proc/log-ring.d.ts +22 -0
  35. package/dist/proc/log-ring.js +20 -1
  36. package/dist/proc/notices.d.ts +57 -0
  37. package/dist/proc/notices.js +101 -0
  38. package/dist/proc/progress.d.ts +40 -0
  39. package/dist/proc/progress.js +135 -0
  40. package/dist/proc/prompt.d.ts +24 -2
  41. package/dist/proc/prompt.js +51 -2
  42. package/dist/proc/supervisor.d.ts +35 -2
  43. package/dist/proc/supervisor.js +142 -43
  44. package/dist/proc/types.d.ts +89 -1
  45. package/dist/proc/wait.d.ts +57 -0
  46. package/dist/proc/wait.js +133 -0
  47. package/dist/session/persist.js +3 -1
  48. package/dist/session/validate-session.js +17 -0
  49. package/dist/tools/bash-tool.d.ts +12 -1
  50. package/dist/tools/bash-tool.js +100 -13
  51. package/dist/tools/index.d.ts +26 -2
  52. package/dist/tools/index.js +43 -2
  53. package/dist/tools/job-tools.d.ts +54 -0
  54. package/dist/tools/job-tools.js +329 -0
  55. package/dist/tools/proc-tools.d.ts +1 -1
  56. package/dist/tools/proc-tools.js +49 -17
  57. package/dist/ui/ActivityLine.js +7 -0
  58. package/dist/ui/App.js +64 -6
  59. package/dist/ui/ScrollbackShell.d.ts +30 -4
  60. package/dist/ui/ScrollbackShell.js +8 -3
  61. package/dist/ui/StatusBar.d.ts +5 -0
  62. package/dist/ui/StatusBar.js +1 -0
  63. package/dist/ui/Transcript.d.ts +14 -0
  64. package/dist/ui/Transcript.js +14 -2
  65. package/dist/ui/entries/ServiceCard.d.ts +17 -1
  66. package/dist/ui/entries/ServiceCard.js +39 -4
  67. package/dist/ui/layout/status-layout.d.ts +13 -1
  68. package/dist/ui/layout/status-layout.js +10 -0
  69. package/dist/ui/layout/virtual-window.js +14 -1
  70. package/dist/ui/run-status-row.d.ts +12 -0
  71. package/dist/ui/screen.d.ts +17 -4
  72. package/dist/ui/screen.js +14 -6
  73. package/dist/ui/scrollback/LiveTail.d.ts +64 -0
  74. package/dist/ui/scrollback/LiveTail.js +27 -0
  75. package/dist/ui/scrollback/band.d.ts +105 -0
  76. package/dist/ui/scrollback/band.js +109 -0
  77. package/dist/ui/scrollback/budget.d.ts +32 -3
  78. package/dist/ui/scrollback/budget.js +43 -6
  79. package/dist/ui/scrollback/canvas.d.ts +99 -0
  80. package/dist/ui/scrollback/canvas.js +113 -0
  81. package/dist/ui/scrollback/cursor-probe.d.ts +71 -0
  82. package/dist/ui/scrollback/cursor-probe.js +133 -0
  83. package/dist/ui/scrollback/limits.d.ts +49 -0
  84. package/dist/ui/scrollback/limits.js +49 -0
  85. package/dist/ui/scrollback/scrollback-writer.d.ts +104 -16
  86. package/dist/ui/scrollback/scrollback-writer.js +368 -82
  87. package/dist/ui/scrollback/use-scrollback-view.d.ts +11 -0
  88. package/dist/ui/scrollback/use-scrollback-view.js +37 -6
  89. package/dist/ui/scrollback/writer-shapes.d.ts +68 -2
  90. package/dist/ui/scrollback/writer-shapes.js +53 -8
  91. package/package.json +3 -3
@@ -17,7 +17,7 @@ import type { DispatchOutcome, SubagentRun, SubagentSpec, TeamSnapshot } from '.
17
17
  import type { FastReview, FastSnapshot } from '../fast/types.js';
18
18
  import type { CompactionMode, CompactionRecord, CompactionSnapshot, CompactionUiTrigger, ContextUsageSnapshot } from '../compaction/types.js';
19
19
  import type { TodoItem, TodoSnapshot } from '../todo/types.js';
20
- import type { ServiceSnapshot, ServiceStatus } from '../proc/types.js';
20
+ import type { JobKind, ServiceSnapshot, ServiceStatus } from '../proc/types.js';
21
21
  import type { RetryPhase } from './retry-view.js';
22
22
  import type { FilePatch } from '../tools/patch.js';
23
23
  export type NoticeLevel = 'info' | 'warn' | 'error';
@@ -247,6 +247,26 @@ export type Entry = {
247
247
  terminal?: boolean;
248
248
  /** The stop reported the pid still alive afterwards (R-2 / P2-6). */
249
249
  killIncomplete?: boolean;
250
+ /**
251
+ * WHICH KIND OF JOB THIS CARD DRAWS (long-task-jobs §4.9 / I-2).
252
+ *
253
+ * It changes the NOUN and the status wording only. Renaming the entry kind
254
+ * from `service` to `job` would be a pure regression: ordinary assertions pin
255
+ * `[background] service s1 started` word for word, `bg-command.test.ts` pins
256
+ * `/bg stop`'s `Stopped N services` toast, and there are no snapshot files in
257
+ * this package to absorb the churn. The few strings that DID move to "job"
258
+ * are the model-facing unknown-id messages in `proc-tools.ts`, which name an
259
+ * id space that now holds both kinds.
260
+ */
261
+ jobKind: JobKind;
262
+ /** Latest extracted progress line; one extra row when present. */
263
+ progress?: string;
264
+ /** When output last arrived, so `stalled` can be evaluated at render time. */
265
+ lastOutputAt: number;
266
+ /** Derived at snapshot time by the supervisor, carried here so it survives a save. */
267
+ stalled?: boolean;
268
+ /** Where the on-disk mirror wrote this job's full log. */
269
+ logPath?: string;
250
270
  };
251
271
  export interface UsageTotal {
252
272
  inputTokens: number;
@@ -1479,6 +1479,7 @@ function serviceFields(service) {
1479
1479
  return {
1480
1480
  kind: 'service',
1481
1481
  serviceId: service.id,
1482
+ jobKind: service.kind,
1482
1483
  command: service.command,
1483
1484
  status: service.status,
1484
1485
  ...(service.url !== undefined ? { url: service.url } : {}),
@@ -1490,6 +1491,12 @@ function serviceFields(service) {
1490
1491
  rows: service.rows,
1491
1492
  rowsSeen: service.rowsSeen,
1492
1493
  ...(service.killIncomplete ? { killIncomplete: true } : {}),
1494
+ // The four fields long-task-jobs adds, in the SAME projection for all three
1495
+ // actions - which is the property this function exists to guarantee.
1496
+ lastOutputAt: service.lastOutputAt,
1497
+ ...(service.progress ? { progress: service.progress.text } : {}),
1498
+ ...(service.stalled ? { stalled: true } : {}),
1499
+ ...(service.logPath !== undefined ? { logPath: service.logPath } : {}),
1493
1500
  };
1494
1501
  }
1495
1502
  /** The LIVE card for a service id, newest first; terminal records are skipped. */
@@ -102,6 +102,18 @@ export interface SystemPromptParams {
102
102
  * `bash_kill` would describe tools it does not have.
103
103
  */
104
104
  backgroundBlock?: string;
105
+ /**
106
+ * The rendered `<background_jobs>` block, or `''` (long-task-jobs §12 step 13).
107
+ *
108
+ * Spliced conditionally exactly as every block above is, which is what keeps
109
+ * I-3 true: `jobs.enabled: false` produces the pre-feature prompt BYTE FOR BYTE
110
+ * for a fixed tool array.
111
+ *
112
+ * A CHILD'S PROMPT NEVER CARRIES IT: subagents are built with no supervisor and
113
+ * no `job_*` tools, so the block's first sentence would describe a capability
114
+ * the child does not have.
115
+ */
116
+ jobsBlock?: string;
105
117
  }
106
118
  /** Describe the active shell so the model does not emit incompatible syntax. */
107
119
  export declare function describeShell(): {
@@ -43,6 +43,7 @@ export function buildSystemPrompt(params) {
43
43
  const todoBlock = params.todoBlock ?? '';
44
44
  const fastBlock = params.fastBlock ?? '';
45
45
  const backgroundBlock = params.backgroundBlock ?? '';
46
+ const jobsBlock = params.jobsBlock ?? '';
46
47
  const appended = (params.appendSystemPrompt ?? '').trim();
47
48
  return [
48
49
  'You are AragonAgent, an autonomous coding assistant operating inside a terminal (TUI).',
@@ -65,6 +66,7 @@ export function buildSystemPrompt(params) {
65
66
  ...(todoBlock ? ['', todoBlock] : []),
66
67
  ...(fastBlock ? ['', fastBlock] : []),
67
68
  ...(backgroundBlock ? ['', backgroundBlock] : []),
69
+ ...(jobsBlock ? ['', jobsBlock] : []),
68
70
  '',
69
71
  'Operating guidance:',
70
72
  '- Prefer reading files and inspecting the workspace before making changes.',
package/dist/cli.js CHANGED
@@ -25,7 +25,7 @@ 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';
@@ -58,7 +58,9 @@ import { createFrameObserver } from './ui/frame-observer.js';
58
58
  import { enterAltScreen, enterNativeScreen, writeExitTranscript, } from './ui/screen.js';
59
59
  import { resolveTerminalPlan } from './ui/terminal-plan.js';
60
60
  import { createScrollbackDrain } from './ui/scrollback/drain.js';
61
+ import { SCROLLBACK_LIMITS } from './ui/scrollback/limits.js';
61
62
  import { createScrollbackWriter } from './ui/scrollback/scrollback-writer.js';
63
+ import { probeAnchorSpaceBelow, setAnchorProbeDisabled } from './ui/scrollback/cursor-probe.js';
62
64
  import { createScrollbackBridge, } from './ui/scrollback/use-scrollback-view.js';
63
65
  import { supportsWindowsVtInput } from './ui/win-vt-input.js';
64
66
  import { forceWindowsVtInput } from './ui/win-vt-force.js';
@@ -412,8 +414,16 @@ function forceVtInputForThisConsole() {
412
414
  * "…" > out.txt`, `aragon config set`, and `aragon --version`, because the
413
415
  * interactive and headless paths are disjoint branches: `runInteractive()`
414
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.
415
425
  */
416
- function runInteractive(flags, extras = {}) {
426
+ async function runInteractive(flags, extras = {}) {
417
427
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
418
428
  process.stderr.write('Interactive UI requires a TTY. Use -p "<prompt>", exec, or config list / config set.\n');
419
429
  process.exitCode = 2;
@@ -546,6 +556,10 @@ function runInteractive(flags, extras = {}) {
546
556
  * asynchronous wait for it.
547
557
  */
548
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();
549
563
  const bridge = scrollbackBridge;
550
564
  if (!bridge)
551
565
  return;
@@ -651,7 +665,14 @@ function runInteractive(flags, extras = {}) {
651
665
  // conhost), so no capability gate is possible or needed there.
652
666
  const wantEnhancedKeys = plan.enhancedKeys;
653
667
  const filter = process.stdin.isTTY
654
- ? 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)
655
676
  : null;
656
677
  /**
657
678
  * THE ONE EXPRESSION EVERY MOUSE SITE READS (I-11 / D-17).
@@ -665,7 +686,135 @@ function runInteractive(flags, extras = {}) {
665
686
  const pasteOn = wantPaste && filter !== null;
666
687
  /** Same discipline: the translation runs only when a filter fronts Ink. */
667
688
  const enhancedKeysOn = wantEnhancedKeys && filter !== null;
668
- 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
+ };
669
818
  // --- Drag-select (tui-selection-and-scroll-follow §4.4). ------------------
670
819
  //
671
820
  // Selection owns its controller, decoration and notice. Button motion follows
@@ -698,6 +847,10 @@ function runInteractive(flags, extras = {}) {
698
847
  // Deferred: static rows can be queued from inside a React commit, and the
699
848
  // repaint writes to the stream Ink is in the middle of writing to.
700
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,
701
854
  });
702
855
  scrollbackBridge = createScrollbackBridge({
703
856
  writer, drain, requestFlush: () => { setImmediate(() => frameWriter?.repaint()); },
@@ -757,6 +910,8 @@ function runInteractive(flags, extras = {}) {
757
910
  stdout: frameWriter?.stdout ?? process.stdout, terminal: process.stdout, scrollbar,
758
911
  });
759
912
  const disposeFrameWriter = () => {
913
+ // Same switch as `finalizeNativeScreen`: this is one of the four teardown paths.
914
+ killAnchorProbe();
760
915
  frameObserver?.dispose();
761
916
  pointerRouter?.dispose();
762
917
  scrollbar.controller?.dispose();
@@ -798,6 +953,49 @@ function runInteractive(flags, extras = {}) {
798
953
  vtInputSupported,
799
954
  })
800
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
+ }
801
999
  {
802
1000
  // `mouse` here means "a mouse-parsing filter is installed", NOT "the user
803
1001
  // wants mouse support" — see `AltScreenOptions`. `screen.ts` must never
@@ -815,8 +1013,18 @@ function runInteractive(flags, extras = {}) {
815
1013
  motion: mouseOn,
816
1014
  bracketedPaste: pasteOn,
817
1015
  })
818
- // Native scroll: no alternate screen, no mouse (D6). Only DEC 2004.
819
- : enterNativeScreen(process.stdout, { bracketedPaste: pasteOn });
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');
820
1028
  // Input-encoding modes unwind FIRST, for the same reason the mouse
821
1029
  // reporting does inside `screen.restore()`: the terminal must stop
822
1030
  // sending enhanced encodings before anything else tears the reader
@@ -1327,6 +1535,11 @@ const CONFIG_SET_KEYS = new Set([
1327
1535
  // a script at all: `config list` enumerates the section dynamically and so
1328
1536
  // shows these keys whether they are here or not, while `config set` does not.
1329
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,
1330
1543
  ]);
1331
1544
  /**
1332
1545
  * `aragon config set maxTokens <n|auto|default>`.
@@ -1466,6 +1679,17 @@ function runConfigSetValue(key, value) {
1466
1679
  process.stdout.write(`Set ${key} = ${String(merged.compaction[field])}\n`);
1467
1680
  return;
1468
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
+ }
1469
1693
  const patch = {};
1470
1694
  switch (key) {
1471
1695
  case 'provider':
@@ -1935,14 +2159,14 @@ function buildProgram() {
1935
2159
  }
1936
2160
  return;
1937
2161
  }
1938
- runInteractive(flags, { initialPrompt: prompt });
2162
+ void runInteractive(flags, { initialPrompt: prompt });
1939
2163
  });
1940
2164
  // aragon config [set <key> <value> | path]
1941
2165
  const configCmd = program
1942
2166
  .command('config')
1943
2167
  .description('Open the settings screen')
1944
2168
  .action(() => {
1945
- runInteractive(toFlags(program.opts()), { initialOverlay: 'settings' });
2169
+ void runInteractive(toFlags(program.opts()), { initialOverlay: 'settings' });
1946
2170
  });
1947
2171
  configCmd
1948
2172
  .command('set <key> <value>')
@@ -9,6 +9,7 @@ import { existsSync, statSync } from 'node:fs';
9
9
  import { isAbsolute, resolve } from 'node:path';
10
10
  import { resolveOutputTokens } from '@aragon-agent/core';
11
11
  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
+ import { isTerminalStatus } from '../proc/types.js';
12
13
  import { computeCost, formatCost, formatDuration, formatTokens } from '../agent/usage.js';
13
14
  import { mergePendingEntries } from '../agent/queued-messages.js';
14
15
  import { TODO_FOLLOW_LIMITS } from '../todo/limits.js';
@@ -1256,7 +1257,7 @@ const COMMANDS = [
1256
1257
  perfCommand,
1257
1258
  {
1258
1259
  name: 'bg',
1259
- description: 'Background services: list | logs <id> [n] | stop <id|all> | status',
1260
+ description: 'Background jobs: list | logs <id> [n] | stop <id|all> | status',
1260
1261
  /**
1261
1262
  * THE USER'S HANDLE ON WHAT THE AGENT LEFT RUNNING (§5.3).
1262
1263
  *
@@ -1277,9 +1278,18 @@ const COMMANDS = [
1277
1278
  const services = controller.listServices();
1278
1279
  if (verb === 'status') {
1279
1280
  const live = controller.liveServiceCount();
1280
- ctx.notify('info', live === 0
1281
- ? 'No background services running.'
1282
- : `${live} background service${live === 1 ? '' : 's'} running.`);
1281
+ if (live === 0) {
1282
+ ctx.notify('info', 'No background jobs running.');
1283
+ return;
1284
+ }
1285
+ // THE KIND SPLIT IS THE INFORMATION (long-task-jobs §4.9): "2 jobs
1286
+ // running" is not the same as "1 task, 1 service". A task will end on its
1287
+ // own; a service will not, and that difference decides whether the user
1288
+ // has to do anything.
1289
+ const running = services.filter((job) => !isTerminalStatus(job.status));
1290
+ const tasks = running.filter((job) => job.kind === 'task').length;
1291
+ ctx.notify('info', `${live} background job${live === 1 ? '' : 's'} running ` +
1292
+ `(${tasks} task, ${running.length - tasks} service).`);
1283
1293
  return;
1284
1294
  }
1285
1295
  if (verb === 'logs') {
@@ -1289,7 +1299,7 @@ const COMMANDS = [
1289
1299
  }
1290
1300
  const service = controller.getServiceSnapshot(value);
1291
1301
  if (!service) {
1292
- ctx.notify('warn', `No service "${value}".`);
1302
+ ctx.notify('warn', `No job "${value}".`);
1293
1303
  return;
1294
1304
  }
1295
1305
  const page = controller.readServiceLog(value);
@@ -1335,12 +1345,17 @@ const COMMANDS = [
1335
1345
  return;
1336
1346
  }
1337
1347
  const now = Date.now();
1348
+ // `id kind status elapsed progress|url $ command` - the same column
1349
+ // grammar `job_list` uses for the model, so a user reading `/bg list` and a
1350
+ // model reading `job_list` see the same facts in the same order.
1338
1351
  const lines = services.map((svc) => {
1339
1352
  const uptime = formatDuration((svc.endedAt ?? now) - svc.startedAt);
1340
- const where = svc.url ? ` ${svc.url}` : '';
1341
- return `${svc.id} ${svc.status} ${uptime}${where} $ ${svc.command}`;
1353
+ const aside = svc.progress?.text ?? svc.url;
1354
+ const where = aside ? ` ${aside}` : '';
1355
+ const stalled = svc.stalled === true ? ' [no output]' : '';
1356
+ return `${svc.id} ${svc.kind} ${svc.status} ${uptime}${where}${stalled} $ ${svc.command}`;
1342
1357
  });
1343
- ctx.notify('info', `Background services:\n${lines.join('\n')}`);
1358
+ ctx.notify('info', `Background jobs:\n${lines.join('\n')}`);
1344
1359
  },
1345
1360
  },
1346
1361
  {
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import type { FrameWriterStats } from '../ui/frame-differ.js';
19
19
  import type { ScrollbarBridge } from '../ui/scrollbar-controller.js';
20
+ import type { AnchorSource } from '../ui/scrollback/canvas.js';
20
21
  import type { SlashCommand } from './registry.js';
21
22
  export interface PerfSnapshot {
22
23
  /** Governor rung, its resolved interval, and the last measured commit cost. */
@@ -40,6 +41,32 @@ export interface PerfSnapshot {
40
41
  offset: number;
41
42
  /** Right-edge scrollbar diagnostics; read lazily so it reflects the live bridge. */
42
43
  scrollbar?: ScrollbarPerf;
44
+ /**
45
+ * The native panel band, or `null` outside native mode
46
+ * (release-gate-and-native-panels §6.10).
47
+ *
48
+ * REQUIRED, WITH AN EXPLICIT `null`. This feature has six ways to show nothing and
49
+ * four of them are invisible, so "the rail is gone" has to be answerable from one
50
+ * line. An optional field would let a future caller forget it and leave `/perf`
51
+ * silently unable to explain the case the user is reporting.
52
+ */
53
+ band: BandPerf | null;
54
+ }
55
+ /**
56
+ * The band's projection, as `/perf` reads it. Counts only - the allocation itself lives
57
+ * in `ui/scrollback/band.ts` (`LiveBand`), and this type exists so the command layer
58
+ * does not have to import a UI module to describe it.
59
+ */
60
+ export interface BandPerf {
61
+ reason: 'visible' | 'empty' | 'disabled' | 'overlay' | 'narrow' | 'short';
62
+ railRows: number;
63
+ railCols: number;
64
+ teamRows: number;
65
+ previewRows: number;
66
+ contentCols: number;
67
+ previewCols: number;
68
+ heldRailRows: number;
69
+ heldTeamRows: number;
43
70
  }
44
71
  /**
45
72
  * Why the right-edge scrollbar is or is not usable, in one record
@@ -83,6 +110,7 @@ export declare function readFrameStats(): FrameWriterStats | null;
83
110
  * and the terminal disagreed about a character's width (I-S13).
84
111
  */
85
112
  export interface ScrollbackPerf {
113
+ /** LIVE REGION height (the Ink frame), NOT the canvas — see `anchorCanvasRows`. */
86
114
  regionRows: number;
87
115
  framesSeed: number;
88
116
  framesDiff: number;
@@ -95,9 +123,34 @@ export interface ScrollbackPerf {
95
123
  fallbacks: number;
96
124
  resizes: number;
97
125
  repaints: number;
126
+ /** `probe` = measured by CPR; `fill` = the one-off screen fill; `none` = unmeasured. */
127
+ anchorSource: AnchorSource;
128
+ /**
129
+ * Canvas height of the most recent batch, which is also the next erase distance.
130
+ *
131
+ * A SEPARATE FIELD RATHER THAN A RENAMED `regionRows` (P1-4): `/perf`'s
132
+ * "scrollback region N rows" is the first number anyone reads when the frame
133
+ * looks wrong, and quietly turning it into the canvas height would make a 6-row
134
+ * region report 24 during a short conversation.
135
+ */
136
+ anchorCanvasRows: number;
137
+ /** Content rows inside that canvas — how much of the conversation is on screen. */
138
+ anchorHeldRows: number;
139
+ /** Blank rows above the content. Non-zero is expected on a short conversation. */
140
+ anchorGapRows: number;
141
+ /** Rows this session pushed into the terminal's own scrollback. */
142
+ anchorScrollRows: number;
143
+ /** Times the geometry was dropped on purpose (column change, shrink, geometry lost). */
144
+ anchorResets: number;
145
+ /** Measurements accepted after the first: the re-probe count, not the resize count. */
146
+ anchorReprobes: number;
98
147
  }
99
148
  /** Called by `cli.tsx` in native mode. Passing `null` detaches. */
100
149
  export declare function setScrollbackStatsProvider(fn: (() => ScrollbackPerf) | null): void;
101
- /** Format a snapshot as the six-line block documented in §5.4 (plus the scrollbar line). */
150
+ /**
151
+ * Format a snapshot as the seven-line block documented in §5.4 (plus the scrollbar
152
+ * line). The `band:` line is the newest one and the only place that answers "why is
153
+ * that panel not on screen".
154
+ */
102
155
  export declare function formatPerfReport(snap: PerfSnapshot): string;
103
156
  export declare const perfCommand: SlashCommand;
@@ -78,10 +78,14 @@ function formatScrollbackLine() {
78
78
  if (!stats)
79
79
  return [];
80
80
  return [
81
- `scrollback region ${stats.regionRows} rows - batches seed ${stats.framesSeed}, ` +
81
+ `scrollback region ${stats.regionRows} rows - canvas ${stats.anchorCanvasRows} rows - ` +
82
+ `content ${stats.anchorHeldRows} rows - gap ${stats.anchorGapRows} rows - ` +
83
+ `anchor bottom via ${stats.anchorSource} - scrolled ${stats.anchorScrollRows} rows - ` +
84
+ `anchor resets ${stats.anchorResets} - reprobes ${stats.anchorReprobes}`,
85
+ ` batches seed ${stats.framesSeed}, ` +
82
86
  `diff ${stats.framesDiff}, full ${stats.framesFull}, repaint ${stats.repaints} - ` +
83
- `static rows ${stats.staticRowsWritten} - elided ${stats.elidedEntries}`,
84
- ` guardHits ${stats.guardHits} (outside resize ${stats.guardHitsOutsideResize}) - ` +
87
+ `static rows ${stats.staticRowsWritten} - elided ${stats.elidedEntries} - ` +
88
+ `guardHits ${stats.guardHits} (outside resize ${stats.guardHitsOutsideResize}) - ` +
85
89
  `clippedRows ${stats.clippedRows} - fallbacks ${stats.fallbacks} - ` +
86
90
  `resizes ${stats.resizes}`,
87
91
  ];
@@ -120,7 +124,26 @@ function formatScrollbarLine(bar) {
120
124
  ? `${bar.trackCol}x${bar.trackRows + 4}` : 'the terminal size';
121
125
  return `${line}\n waiting for a full frame matching ${size}`;
122
126
  }
123
- /** Format a snapshot as the six-line block documented in §5.4 (plus the scrollbar line). */
127
+ /**
128
+ * `band:` - who got which rows, and why a panel is not on screen.
129
+ *
130
+ * `rail=<rows>x<cols>` reads `0x0` when the rail is not drawn, which is the only
131
+ * unambiguous "there is no rail" - a conservative width would look like a rail that
132
+ * merely has no height. One line, because `/perf` is a notice entry and a wrapping
133
+ * readout costs the repaint it exists to explain.
134
+ */
135
+ function formatBandLine(band) {
136
+ if (!band)
137
+ return 'band n/a (full-screen layout)';
138
+ return `band rail=${band.railRows}x${band.railCols} team=${band.teamRows} ` +
139
+ `preview=${band.previewRows} cols=${band.contentCols}/${band.previewCols} ` +
140
+ `reason=${band.reason} held=${band.heldRailRows}/${band.heldTeamRows}`;
141
+ }
142
+ /**
143
+ * Format a snapshot as the seven-line block documented in §5.4 (plus the scrollbar
144
+ * line). The `band:` line is the newest one and the only place that answers "why is
145
+ * that panel not on screen".
146
+ */
124
147
  export function formatPerfReport(snap) {
125
148
  const caches = renderCacheStats();
126
149
  const eco = snap.rung > 0 ? ' - eco' : '';
@@ -142,6 +165,7 @@ export function formatPerfReport(snap) {
142
165
  native
143
166
  ? `mode native - live region ${snap.viewportRows} rows`
144
167
  : `mode ${snap.mode} - viewport ${snap.viewportRows} rows - offset ${snap.offset}`,
168
+ formatBandLine(snap.band),
145
169
  formatWriterLine(),
146
170
  ...formatScrollbackLine(),
147
171
  ...scrollbar,