@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
@@ -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';
@@ -1122,6 +1123,10 @@ const COMMANDS = [
1122
1123
  name: 'mouse',
1123
1124
  description: 'Release the mouse to the terminal, or take it back',
1124
1125
  run: (ctx) => {
1126
+ if (ctx.nativeScroll) {
1127
+ ctx.notify('info', 'Mouse capture is not used in native scroll mode; your terminal handles scrolling and selection.');
1128
+ return;
1129
+ }
1125
1130
  const port = ctx.mouse;
1126
1131
  if (!port) {
1127
1132
  // Inline mode, `--no-mouse`, a non-TTY, or a console that cannot deliver
@@ -1252,7 +1257,7 @@ const COMMANDS = [
1252
1257
  perfCommand,
1253
1258
  {
1254
1259
  name: 'bg',
1255
- description: 'Background services: list | logs <id> [n] | stop <id|all> | status',
1260
+ description: 'Background jobs: list | logs <id> [n] | stop <id|all> | status',
1256
1261
  /**
1257
1262
  * THE USER'S HANDLE ON WHAT THE AGENT LEFT RUNNING (§5.3).
1258
1263
  *
@@ -1273,9 +1278,18 @@ const COMMANDS = [
1273
1278
  const services = controller.listServices();
1274
1279
  if (verb === 'status') {
1275
1280
  const live = controller.liveServiceCount();
1276
- ctx.notify('info', live === 0
1277
- ? 'No background services running.'
1278
- : `${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).`);
1279
1293
  return;
1280
1294
  }
1281
1295
  if (verb === 'logs') {
@@ -1285,7 +1299,7 @@ const COMMANDS = [
1285
1299
  }
1286
1300
  const service = controller.getServiceSnapshot(value);
1287
1301
  if (!service) {
1288
- ctx.notify('warn', `No service "${value}".`);
1302
+ ctx.notify('warn', `No job "${value}".`);
1289
1303
  return;
1290
1304
  }
1291
1305
  const page = controller.readServiceLog(value);
@@ -1331,12 +1345,17 @@ const COMMANDS = [
1331
1345
  return;
1332
1346
  }
1333
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.
1334
1351
  const lines = services.map((svc) => {
1335
1352
  const uptime = formatDuration((svc.endedAt ?? now) - svc.startedAt);
1336
- const where = svc.url ? ` ${svc.url}` : '';
1337
- 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}`;
1338
1357
  });
1339
- ctx.notify('info', `Background services:\n${lines.join('\n')}`);
1358
+ ctx.notify('info', `Background jobs:\n${lines.join('\n')}`);
1340
1359
  },
1341
1360
  },
1342
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. */
@@ -34,11 +35,38 @@ export interface PerfSnapshot {
34
35
  heightsEstimated: number;
35
36
  cols: number;
36
37
  /** Geometry. */
37
- mode: 'fullscreen';
38
+ mode: 'fullscreen' | 'native';
39
+ /** Full-screen: the transcript viewport. Native: the live region height. */
38
40
  viewportRows: number;
39
41
  offset: number;
40
42
  /** Right-edge scrollbar diagnostics; read lazily so it reflects the live bridge. */
41
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;
42
70
  }
43
71
  /**
44
72
  * Why the right-edge scrollbar is or is not usable, in one record
@@ -74,6 +102,55 @@ export declare function setPerfResetHook(fn: (() => void) | null): void;
74
102
  /** Called by `cli.tsx` after building the writer. Passing `null` detaches. */
75
103
  export declare function setFrameStatsProvider(fn: (() => FrameWriterStats) | null): void;
76
104
  export declare function readFrameStats(): FrameWriterStats | null;
77
- /** Format a snapshot as the six-line block documented in §5.4 (plus the scrollbar line). */
105
+ /**
106
+ * Native-scroll writer counters (tui-native-terminal-scroll §5.1). A third
107
+ * module-level slot for the reason the one above records: the writer lives outside
108
+ * React. `guardHitsOutsideResize > 0` on a session with no resize is the defect
109
+ * signal (a live region reached the terminal height); `clippedRows > 0` means Ink
110
+ * and the terminal disagreed about a character's width (I-S13).
111
+ */
112
+ export interface ScrollbackPerf {
113
+ /** LIVE REGION height (the Ink frame), NOT the canvas — see `anchorCanvasRows`. */
114
+ regionRows: number;
115
+ framesSeed: number;
116
+ framesDiff: number;
117
+ framesFull: number;
118
+ staticRowsWritten: number;
119
+ elidedEntries: number;
120
+ guardHits: number;
121
+ guardHitsOutsideResize: number;
122
+ clippedRows: number;
123
+ fallbacks: number;
124
+ resizes: number;
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;
147
+ }
148
+ /** Called by `cli.tsx` in native mode. Passing `null` detaches. */
149
+ export declare function setScrollbackStatsProvider(fn: (() => ScrollbackPerf) | null): void;
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
+ */
78
155
  export declare function formatPerfReport(snap: PerfSnapshot): string;
79
156
  export declare const perfCommand: SlashCommand;
@@ -68,6 +68,28 @@ export function setFrameStatsProvider(fn) {
68
68
  export function readFrameStats() {
69
69
  return frameStats ? frameStats() : null;
70
70
  }
71
+ let scrollbackStats = null;
72
+ /** Called by `cli.tsx` in native mode. Passing `null` detaches. */
73
+ export function setScrollbackStatsProvider(fn) {
74
+ scrollbackStats = fn;
75
+ }
76
+ function formatScrollbackLine() {
77
+ const stats = scrollbackStats?.();
78
+ if (!stats)
79
+ return [];
80
+ return [
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}, ` +
86
+ `diff ${stats.framesDiff}, full ${stats.framesFull}, repaint ${stats.repaints} - ` +
87
+ `static rows ${stats.staticRowsWritten} - elided ${stats.elidedEntries} - ` +
88
+ `guardHits ${stats.guardHits} (outside resize ${stats.guardHitsOutsideResize}) - ` +
89
+ `clippedRows ${stats.clippedRows} - fallbacks ${stats.fallbacks} - ` +
90
+ `resizes ${stats.resizes}`,
91
+ ];
92
+ }
71
93
  function mb(bytes) {
72
94
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
73
95
  }
@@ -102,12 +124,34 @@ function formatScrollbarLine(bar) {
102
124
  ? `${bar.trackCol}x${bar.trackRows + 4}` : 'the terminal size';
103
125
  return `${line}\n waiting for a full frame matching ${size}`;
104
126
  }
105
- /** 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
+ */
106
147
  export function formatPerfReport(snap) {
107
148
  const caches = renderCacheStats();
108
149
  const eco = snap.rung > 0 ? ' - eco' : '';
109
150
  const governor = snap.governorEnabled ? '' : ' - governor off';
110
- const scrollbar = snap.scrollbar ? [formatScrollbarLine(snap.scrollbar)] : [];
151
+ const native = snap.mode === 'native';
152
+ const scrollbar = native
153
+ ? ['scrollbar n/a (native scroll)']
154
+ : snap.scrollbar ? [formatScrollbarLine(snap.scrollbar)] : [];
111
155
  return [
112
156
  `render rung ${snap.rung} - interval ${snap.intervalMs}ms - last commit ` +
113
157
  `${Math.round(snap.lastCommitMs)}ms${eco}${governor}`,
@@ -118,8 +162,12 @@ export function formatPerfReport(snap) {
118
162
  `caches md ${caches.markdownEntries} (${mb(caches.markdownBytes)}) - ` +
119
163
  `hl ${caches.highlightEntries} (${mb(caches.highlightBytes)}) - ` +
120
164
  `lines ${caches.lineEntries}`,
121
- `mode ${snap.mode} - viewport ${snap.viewportRows} rows - offset ${snap.offset}`,
165
+ native
166
+ ? `mode native - live region ${snap.viewportRows} rows`
167
+ : `mode ${snap.mode} - viewport ${snap.viewportRows} rows - offset ${snap.offset}`,
168
+ formatBandLine(snap.band),
122
169
  formatWriterLine(),
170
+ ...formatScrollbackLine(),
123
171
  ...scrollbar,
124
172
  ].join('\n');
125
173
  }
@@ -100,6 +100,12 @@ export interface CommandContext {
100
100
  * missing, which is the same reason `update` above it is optional.
101
101
  */
102
102
  mouse?: MouseCommandPort;
103
+ /**
104
+ * True in native-scroll mode (tui-native-terminal-scroll): the terminal owns
105
+ * scrolling and selection, so there is no capture to toggle. Lets `/mouse` say so
106
+ * instead of the generic "not captured" answer.
107
+ */
108
+ nativeScroll?: boolean;
103
109
  /**
104
110
  * The foreign-write door, for `/copy`'s OSC 52 half (§4.4.5).
105
111
  *
@@ -21,6 +21,19 @@ export declare const LOG_CONFIG_SET_KEYS: readonly ["log.level", "log.toFile", "
21
21
  * `Set team.enabled = false`.
22
22
  */
23
23
  export declare const TEAM_CONFIG_SET_KEYS: readonly ["team.enabled", "team.maxSubagents", "team.maxConcurrent", "team.subagentTimeoutMs", "team.dispatchTimeoutMs", "team.maxTurnsPerSubagent", "team.overseer", "team.overseerIntervalMs", "team.model", "team.modelProfileId"];
24
+ /**
25
+ * Config keys under `jobs.` that `aragon config set` accepts
26
+ * (long-task-jobs §6.5).
27
+ *
28
+ * THE FIRST NESTED SECTION WHOSE KEYS ARE ALL WRITABLE, so it is also the first
29
+ * one where `JOBS_CONFIG_SET_KEYS ⊆ CONFIG_SET_KEYS` needs its own assertion:
30
+ * `__tests__/config.test.ts`'s static guard matches LITERAL keys in `cli.tsx`, so
31
+ * a set that is spread in is invisible to it (RV-P2-8).
32
+ *
33
+ * Every key is ONE level deep on purpose — `runConfigGet` splits on a single dot,
34
+ * so `jobs.waitSeconds` resolves and `jobs.mirror.enabled` would not.
35
+ */
36
+ export declare const JOBS_CONFIG_SET_KEYS: readonly ["jobs.enabled", "jobs.waitSeconds", "jobs.stallSeconds", "jobs.persistLog", "jobs.noticeSteer"];
24
37
  /**
25
38
  * Config keys under `retry.` that `aragon config set` accepts
26
39
  * (llm-api-retry-backoff §8.2).
@@ -81,6 +94,20 @@ export declare const COMPACTION_CONFIG_SET_KEYS: readonly ["compaction.enabled",
81
94
  * included.
82
95
  */
83
96
  export declare function applyLogConfigSet(key: string, value: string): Partial<PersistedConfig> | null;
97
+ /**
98
+ * Translate a `jobs.*` dotted key into a patch, or `null` for anything else.
99
+ *
100
+ * THE TEMPLATE IS `applyTeamConfigSet`, NOT an `applyBashConfigSet` — no such
101
+ * function exists, and `bash.*` is not `config set`-able to this day (RV-P2-8).
102
+ * Every numeric key routes through `clampJobsConfig` rather than a local
103
+ * coercion, so the caller echoes the STORED value and an out-of-range input is
104
+ * visible: `config set jobs.waitSeconds 1` prints `5`.
105
+ *
106
+ * The patch is a PARTIAL section, which is only safe because
107
+ * `updatePersistedConfig` deep-merges `jobs` — the sixth time that trap is
108
+ * recorded in `store.ts`.
109
+ */
110
+ export declare function applyJobsConfigSet(key: string, value: string): Partial<PersistedConfig> | null;
84
111
  /**
85
112
  * Translate a `team.*` dotted key into a patch, or `null` for anything else.
86
113
  *
@@ -14,7 +14,7 @@ import { getConfigBackupPath, getConfigPath, getHomeRoot, getLogsDir, getPromptH
14
14
  import { getHomeMigrationBreadcrumbPath } from './migrate-home.js';
15
15
  import { loadPersistedConfig, updatePersistedConfig } from './store.js';
16
16
  import { normalizeModelProfiles, validateModelProfiles } from './model-profiles.js';
17
- import { clampFastConfig, clampRetryConfig, clampCompactionConfig, clampTeamConfig, parseThresholdInput, clampUpdateConfig, coercePositiveInt, maskSecret, DEFAULT_FAST_CONFIG, DEFAULT_RETRY_CONFIG, DEFAULT_TEAM_CONFIG, DEFAULT_COMPACTION_CONFIG, DEFAULT_UPDATE_CONFIG, } from './schema.js';
17
+ import { clampFastConfig, clampRetryConfig, clampCompactionConfig, clampTeamConfig, parseThresholdInput, clampUpdateConfig, coercePositiveInt, maskSecret, DEFAULT_FAST_CONFIG, DEFAULT_RETRY_CONFIG, DEFAULT_TEAM_CONFIG, DEFAULT_COMPACTION_CONFIG, DEFAULT_UPDATE_CONFIG, DEFAULT_JOBS_CONFIG, clampJobsConfig, } from './schema.js';
18
18
  import { clampLogLevel } from '../logging/levels.js';
19
19
  /** Config keys under `log.` that `aragon config set` accepts. */
20
20
  export const LOG_CONFIG_SET_KEYS = [
@@ -48,6 +48,25 @@ export const TEAM_CONFIG_SET_KEYS = [
48
48
  'team.model',
49
49
  'team.modelProfileId',
50
50
  ];
51
+ /**
52
+ * Config keys under `jobs.` that `aragon config set` accepts
53
+ * (long-task-jobs §6.5).
54
+ *
55
+ * THE FIRST NESTED SECTION WHOSE KEYS ARE ALL WRITABLE, so it is also the first
56
+ * one where `JOBS_CONFIG_SET_KEYS ⊆ CONFIG_SET_KEYS` needs its own assertion:
57
+ * `__tests__/config.test.ts`'s static guard matches LITERAL keys in `cli.tsx`, so
58
+ * a set that is spread in is invisible to it (RV-P2-8).
59
+ *
60
+ * Every key is ONE level deep on purpose — `runConfigGet` splits on a single dot,
61
+ * so `jobs.waitSeconds` resolves and `jobs.mirror.enabled` would not.
62
+ */
63
+ export const JOBS_CONFIG_SET_KEYS = [
64
+ 'jobs.enabled',
65
+ 'jobs.waitSeconds',
66
+ 'jobs.stallSeconds',
67
+ 'jobs.persistLog',
68
+ 'jobs.noticeSteer',
69
+ ];
51
70
  /**
52
71
  * Config keys under `retry.` that `aragon config set` accepts
53
72
  * (llm-api-retry-backoff §8.2).
@@ -166,6 +185,40 @@ export function applyLogConfigSet(key, value) {
166
185
  return null;
167
186
  }
168
187
  }
188
+ /**
189
+ * Translate a `jobs.*` dotted key into a patch, or `null` for anything else.
190
+ *
191
+ * THE TEMPLATE IS `applyTeamConfigSet`, NOT an `applyBashConfigSet` — no such
192
+ * function exists, and `bash.*` is not `config set`-able to this day (RV-P2-8).
193
+ * Every numeric key routes through `clampJobsConfig` rather than a local
194
+ * coercion, so the caller echoes the STORED value and an out-of-range input is
195
+ * visible: `config set jobs.waitSeconds 1` prints `5`.
196
+ *
197
+ * The patch is a PARTIAL section, which is only safe because
198
+ * `updatePersistedConfig` deep-merges `jobs` — the sixth time that trap is
199
+ * recorded in `store.ts`.
200
+ */
201
+ export function applyJobsConfigSet(key, value) {
202
+ const patch = (jobs) => ({ jobs });
203
+ const clampOne = (field) => {
204
+ const merged = clampJobsConfig({ ...DEFAULT_JOBS_CONFIG, [field]: value });
205
+ return { [field]: merged[field] };
206
+ };
207
+ switch (key) {
208
+ case 'jobs.enabled':
209
+ return patch({ enabled: isTrue(value) });
210
+ case 'jobs.waitSeconds':
211
+ return patch(clampOne('waitSeconds'));
212
+ case 'jobs.stallSeconds':
213
+ return patch(clampOne('stallSeconds'));
214
+ case 'jobs.persistLog':
215
+ return patch({ persistLog: isTrue(value) });
216
+ case 'jobs.noticeSteer':
217
+ return patch({ noticeSteer: isTrue(value) });
218
+ default:
219
+ return null;
220
+ }
221
+ }
169
222
  /**
170
223
  * Translate a `team.*` dotted key into a patch, or `null` for anything else.
171
224
  *
@@ -254,6 +254,44 @@ export function readEnvConfig() {
254
254
  if (Object.keys(section).length > 0)
255
255
  partial.bash = section;
256
256
  }
257
+ const jobsEnabled = process.env.ARAGON_JOBS_ENABLED?.trim().toLowerCase();
258
+ const jobsWait = process.env.ARAGON_JOBS_WAIT_SECONDS?.trim();
259
+ const jobsStall = process.env.ARAGON_JOBS_STALL_SECONDS?.trim();
260
+ const jobsPersist = process.env.ARAGON_JOBS_PERSIST_LOG?.trim().toLowerCase();
261
+ const jobsSteer = process.env.ARAGON_JOBS_NOTICE_STEER?.trim().toLowerCase();
262
+ if ((jobsEnabled !== undefined && jobsEnabled.length > 0) ||
263
+ (jobsWait !== undefined && jobsWait.length > 0) ||
264
+ (jobsStall !== undefined && jobsStall.length > 0) ||
265
+ (jobsPersist !== undefined && jobsPersist.length > 0) ||
266
+ (jobsSteer !== undefined && jobsSteer.length > 0)) {
267
+ const section = {};
268
+ // ONE TRUTHINESS LADDER FOR ALL THREE BOOLEANS, because three slightly
269
+ // different spellings of "is this true" is how `ARAGON_JOBS_ENABLED=off`
270
+ // ends up enabling the feature.
271
+ const flag = (value) => value === '1' || value === 'true' || value === 'on' || value === 'yes';
272
+ if (jobsEnabled !== undefined && jobsEnabled.length > 0)
273
+ section.enabled = flag(jobsEnabled);
274
+ if (jobsPersist !== undefined && jobsPersist.length > 0)
275
+ section.persistLog = flag(jobsPersist);
276
+ if (jobsSteer !== undefined && jobsSteer.length > 0)
277
+ section.noticeSteer = flag(jobsSteer);
278
+ if (jobsWait !== undefined && jobsWait.length > 0) {
279
+ const parsed = Number.parseInt(jobsWait, 10);
280
+ // LEFT ABSENT WHEN UNPARSEABLE, so `clampJobsConfig` supplies the default
281
+ // rather than an env var that silently means 0.
282
+ if (Number.isFinite(parsed))
283
+ section.waitSeconds = parsed;
284
+ }
285
+ if (jobsStall !== undefined && jobsStall.length > 0) {
286
+ const parsed = Number.parseInt(jobsStall, 10);
287
+ if (Number.isFinite(parsed))
288
+ section.stallSeconds = parsed;
289
+ }
290
+ // A PARTIAL section, safe only because every consumer deep-merges it - the
291
+ // same contract `partial.skills` / `partial.log` / `partial.team` rely on.
292
+ if (Object.keys(section).length > 0)
293
+ partial.jobs = section;
294
+ }
257
295
  const retry = process.env.ARAGON_RETRY?.trim().toLowerCase();
258
296
  const retryMax = process.env.ARAGON_RETRY_MAX?.trim();
259
297
  if ((retry !== undefined && retry.length > 0) || (retryMax !== undefined && retryMax.length > 0)) {
@@ -392,6 +430,10 @@ export function readEnvConfig() {
392
430
  partial.diffRender =
393
431
  diffRender === '1' || diffRender === 'true' || diffRender === 'on' || diffRender === 'yes';
394
432
  }
433
+ // Invalid values are ignored, like every other ARAGON_* key.
434
+ const scrollMode = process.env.ARAGON_SCROLL_MODE?.trim().toLowerCase();
435
+ if (scrollMode === 'native' || scrollMode === 'app')
436
+ partial.scrollMode = scrollMode;
395
437
  const syncOutput = process.env.ARAGON_SYNC_OUTPUT?.trim().toLowerCase();
396
438
  if (syncOutput !== undefined && syncOutput.length > 0) {
397
439
  partial.syncOutput =
@@ -5,7 +5,7 @@
5
5
  * layers, `idleTimeout` is re-derived to be strictly ≥ `toolTimeout` so a
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
- import { type CliConfig } from './schema.js';
8
+ import { type CliConfig, type ScrollMode } from './schema.js';
9
9
  import type { ModelRole } from './model-profiles.js';
10
10
  export interface CliFlags {
11
11
  provider?: string;
@@ -149,6 +149,8 @@ export interface CliFlags {
149
149
  * all — i.e. the documented escape hatch would quietly un-set itself.
150
150
  */
151
151
  diffRender?: boolean;
152
+ /** `--scroll-mode <native|app>`; already validated by `cli.tsx` (exit 2 on junk). */
153
+ scrollMode?: ScrollMode;
152
154
  /** `--sync-output` ⇒ true, `--no-sync-output` ⇒ false, absent ⇒ undefined (§5.2). */
153
155
  syncOutput?: boolean;
154
156
  /**
@@ -6,7 +6,8 @@
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
8
  import process from 'node:process';
9
- import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, legacyTeamCeilingPatch, parseThresholdInput, } from './schema.js';
9
+ import { JOB_WAIT_MARGIN_MS } from '../proc/limits.js';
10
+ import { DEFAULT_CONFIG, DEFAULT_SCROLL_MODE, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampJobsConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollMode, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, legacyTeamCeilingPatch, parseThresholdInput, } from './schema.js';
10
11
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
11
12
  import { readConfigFile, updatePersistedConfig } from './store.js';
12
13
  import { projectModelProfiles, resolveModelProfileState, resolveModelRoleKey, } from './model-profile-resolution.js';
@@ -182,6 +183,23 @@ function resolveBashConfig(env, file) {
182
183
  ...(env.bash ?? {}),
183
184
  });
184
185
  }
186
+ /**
187
+ * Merge the `jobs` section across the three layers that supply it
188
+ * (defaults > file > env), long-task-jobs §6.5.
189
+ *
190
+ * THREE LAYERS, NOT FOUR, and that is the same decision `resolveBashConfig`
191
+ * records: both switches this section carries are already reachable from
192
+ * `config.json` (a per-machine preference) and from the environment (the channel
193
+ * a container entrypoint has). A fourth spelling would be a fourth place for the
194
+ * answer to differ.
195
+ */
196
+ function resolveJobsConfig(env, file) {
197
+ return clampJobsConfig({
198
+ ...DEFAULT_CONFIG.jobs,
199
+ ...(file.jobs ?? {}),
200
+ ...(env.jobs ?? {}),
201
+ });
202
+ }
185
203
  /**
186
204
  * Merge the `retry` section across all four layers (defaults › file › env ›
187
205
  * flags), llm-api-retry-backoff §6.2.
@@ -334,6 +352,16 @@ function resolveDiffRender(flags, env, file) {
334
352
  return file.diffRender;
335
353
  return DEFAULT_CONFIG.diffRender;
336
354
  }
355
+ /** flags > env > file > default, like `resolveDiffRender` (tui-native-terminal-scroll §6.1). */
356
+ function resolveScrollMode(flags, env, file) {
357
+ if (flags.scrollMode !== undefined)
358
+ return flags.scrollMode;
359
+ if (env.scrollMode !== undefined)
360
+ return env.scrollMode;
361
+ if (file.scrollMode !== undefined)
362
+ return clampScrollMode(file.scrollMode, DEFAULT_SCROLL_MODE);
363
+ return DEFAULT_SCROLL_MODE;
364
+ }
337
365
  /** Same ladder for the DEC 2026 envelope (§5.1). */
338
366
  function resolveSyncOutput(flags, env, file) {
339
367
  if (flags.syncOutput !== undefined)
@@ -544,10 +572,32 @@ export function loadConfig(flags = {}, options = {}) {
544
572
  const planModeMaxAskRounds = clampAskRounds(file.planModeMaxAskRounds, DEFAULT_CONFIG.planModeMaxAskRounds);
545
573
  const planModeHumanTimeoutMs = clampHumanTimeout(file.planModeHumanTimeoutMs, DEFAULT_CONFIG.planModeHumanTimeoutMs);
546
574
  const toolTimeoutMs = coercePositiveInt(pick(flags.toolTimeout, file.toolTimeoutMs), DEFAULT_CONFIG.toolTimeoutMs);
575
+ // THE `jobs` SECTION IS RESOLVED HERE, ABOVE THE TIMEOUT BLOCK, and the
576
+ // position is the whole of RV-P1-2. The design's first draft put the I-4 clamp
577
+ // next to the `idleTimeoutMs` invariant below, but `jobs` is parsed inside the
578
+ // big config literal a hundred lines further down — a reference to it here would
579
+ // be a use-before-declaration, and "resolve it later and patch the object" is
580
+ // how the invariant gets silently skipped. Resolving it first makes the clamp
581
+ // impossible to omit.
582
+ const jobs = resolveJobsConfig(env.partial, file);
547
583
  let idleTimeoutMs = coercePositiveInt(pick(flags.idleTimeout, file.idleTimeoutMs), DEFAULT_CONFIG.idleTimeoutMs);
548
584
  // Timeout invariant (R1): idle must be strictly greater than the tool ceiling
549
585
  // so a long single tool run is never aborted by the idle watchdog.
550
586
  idleTimeoutMs = Math.max(idleTimeoutMs, toolTimeoutMs + IDLE_TIMEOUT_MARGIN_MS);
587
+ // INVARIANT I-4 (long-task-jobs): a wait must fit inside the tool call that
588
+ // hosts it, `jobs.waitSeconds * 1000 + JOB_WAIT_MARGIN_MS <= toolTimeoutMs`.
589
+ //
590
+ // `Math.min`, AND THE DIRECTION IS THE POINT. The invariant above RAISES the
591
+ // idle window to accommodate the tool; this one LOWERS the wait to accommodate
592
+ // the tool. Copying `Math.max` here would produce a wait that outlives its own
593
+ // tool call — which is exactly the `Command timed out` failure the feature
594
+ // exists to remove, re-created by its own config check.
595
+ //
596
+ // With an operator-set ceiling below the margin the floor of 1s applies rather
597
+ // than a negative wait: the invariant wins over the minimum, because a wait
598
+ // longer than its host is worse than a short wait.
599
+ const waitCeilingSeconds = Math.floor((toolTimeoutMs - JOB_WAIT_MARGIN_MS) / 1000);
600
+ jobs.waitSeconds = Math.max(1, Math.min(jobs.waitSeconds, waitCeilingSeconds));
551
601
  // Resolve API keys: config-file key wins over the provider env var.
552
602
  const fileKeys = file.apiKeys ?? {};
553
603
  const apiKeys = {};
@@ -638,6 +688,7 @@ export function loadConfig(flags = {}, options = {}) {
638
688
  renderGovernor: resolveRenderGovernor(flags, env.partial, file),
639
689
  maxRenderIntervalMs: clampMaxRenderInterval(pick(flags.maxRenderInterval, env.partial.maxRenderIntervalMs, file.maxRenderIntervalMs), DEFAULT_CONFIG.maxRenderIntervalMs),
640
690
  diffRender: resolveDiffRender(flags, env.partial, file),
691
+ scrollMode: resolveScrollMode(flags, env.partial, file),
641
692
  syncOutput: resolveSyncOutput(flags, env.partial, file),
642
693
  confirmTools,
643
694
  toolTimeoutMs,
@@ -676,6 +727,10 @@ export function loadConfig(flags = {}, options = {}) {
676
727
  update: resolveUpdateConfig(flags, env.partial, file),
677
728
  compaction: resolveCompactionConfig(flags, env.partial, file),
678
729
  bash: resolveBashConfig(env.partial, file),
730
+ // The already-resolved-and-converged section from the timeout block above.
731
+ // NOT `resolveJobsConfig(...)` a second time: that would re-apply the
732
+ // un-converged `waitSeconds` and silently undo I-4.
733
+ jobs,
679
734
  cwd,
680
735
  color,
681
736
  colorLevel,
@@ -719,6 +774,11 @@ function recordResolvedConfig(config) {
719
774
  // trace — so "why did my failing call sit there for three minutes" (or
720
775
  // "why did it not retry at all") has an answer after the fact.
721
776
  retry: config.retry.enabled ? config.retry.maxRetries : 'off',
777
+ // The CONVERGED value, not the configured one: after I-4 they can differ, and
778
+ // "my 30s wait became 10s" is answerable only if the resolved number is
779
+ // recorded rather than the one on disk.
780
+ jobWaitSeconds: config.jobs.waitSeconds,
781
+ jobsEnabled: config.jobs.enabled,
722
782
  hasApiKey: Boolean(config.apiKeys[config.provider]),
723
783
  configPath: getConfigPath(),
724
784
  });