@aragon-agent/cli 0.6.31 → 0.6.33

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 (95) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +2970 -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 +236 -8
  12. package/dist/commands/builtins.js +23 -8
  13. package/dist/commands/perf.d.ts +77 -1
  14. package/dist/commands/perf.js +37 -5
  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 +98 -12
  59. package/dist/ui/ScrollbackShell.d.ts +62 -11
  60. package/dist/ui/ScrollbackShell.js +11 -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 +79 -0
  74. package/dist/ui/scrollback/LiveTail.js +32 -0
  75. package/dist/ui/scrollback/band.d.ts +114 -0
  76. package/dist/ui/scrollback/band.js +130 -0
  77. package/dist/ui/scrollback/budget.d.ts +51 -10
  78. package/dist/ui/scrollback/budget.js +69 -20
  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 +61 -4
  84. package/dist/ui/scrollback/limits.js +61 -4
  85. package/dist/ui/scrollback/live-preview.d.ts +18 -10
  86. package/dist/ui/scrollback/live-preview.js +14 -11
  87. package/dist/ui/scrollback/scrollback-writer.d.ts +104 -16
  88. package/dist/ui/scrollback/scrollback-writer.js +368 -82
  89. package/dist/ui/scrollback/tail-window.d.ts +60 -0
  90. package/dist/ui/scrollback/tail-window.js +73 -0
  91. package/dist/ui/scrollback/use-scrollback-view.d.ts +34 -15
  92. package/dist/ui/scrollback/use-scrollback-view.js +110 -59
  93. package/dist/ui/scrollback/writer-shapes.d.ts +68 -2
  94. package/dist/ui/scrollback/writer-shapes.js +53 -8
  95. package/package.json +3 -3
@@ -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,12 +124,38 @@ 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
+ `tail=${band.previewRows} pool=${band.bandRows} ` +
140
+ `cols=${band.contentCols}/${band.previewCols} ` +
141
+ `reason=${band.reason} held=${band.heldRailRows}/${band.heldTeamRows} ` +
142
+ `window=${band.windowStart}/${band.windowEntries}`;
143
+ }
144
+ /**
145
+ * Format a snapshot as the seven-line block documented in §5.4 (plus the scrollbar
146
+ * line). The `band:` line is the newest one and the only place that answers "why is
147
+ * that panel not on screen".
148
+ */
124
149
  export function formatPerfReport(snap) {
125
150
  const caches = renderCacheStats();
126
151
  const eco = snap.rung > 0 ? ' - eco' : '';
127
152
  const governor = snap.governorEnabled ? '' : ' - governor off';
128
153
  const native = snap.mode === 'native';
154
+ // The canvas top lives in the writer's counters, not in the snapshot: it is the direct
155
+ // explanation for "the app starts halfway down", so it belongs on the mode line next
156
+ // to the region height. `n/a` rather than 0 when no writer is attached, because 0 is a
157
+ // real and healthy answer.
158
+ const anchor = native ? scrollbackStats?.() ?? null : null;
129
159
  const scrollbar = native
130
160
  ? ['scrollbar n/a (native scroll)']
131
161
  : snap.scrollbar ? [formatScrollbarLine(snap.scrollbar)] : [];
@@ -140,8 +170,10 @@ export function formatPerfReport(snap) {
140
170
  `hl ${caches.highlightEntries} (${mb(caches.highlightBytes)}) - ` +
141
171
  `lines ${caches.lineEntries}`,
142
172
  native
143
- ? `mode native - live region ${snap.viewportRows} rows`
173
+ ? `mode native - live region ${snap.viewportRows} rows - ` +
174
+ `top ${anchor ? anchor.anchorTop : 'n/a'}`
144
175
  : `mode ${snap.mode} - viewport ${snap.viewportRows} rows - offset ${snap.offset}`,
176
+ formatBandLine(snap.band),
145
177
  formatWriterLine(),
146
178
  ...formatScrollbackLine(),
147
179
  ...scrollbar,
@@ -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)) {
@@ -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, DEFAULT_SCROLL_MODE, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollMode, 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.
@@ -554,10 +572,32 @@ export function loadConfig(flags = {}, options = {}) {
554
572
  const planModeMaxAskRounds = clampAskRounds(file.planModeMaxAskRounds, DEFAULT_CONFIG.planModeMaxAskRounds);
555
573
  const planModeHumanTimeoutMs = clampHumanTimeout(file.planModeHumanTimeoutMs, DEFAULT_CONFIG.planModeHumanTimeoutMs);
556
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);
557
583
  let idleTimeoutMs = coercePositiveInt(pick(flags.idleTimeout, file.idleTimeoutMs), DEFAULT_CONFIG.idleTimeoutMs);
558
584
  // Timeout invariant (R1): idle must be strictly greater than the tool ceiling
559
585
  // so a long single tool run is never aborted by the idle watchdog.
560
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));
561
601
  // Resolve API keys: config-file key wins over the provider env var.
562
602
  const fileKeys = file.apiKeys ?? {};
563
603
  const apiKeys = {};
@@ -687,6 +727,10 @@ export function loadConfig(flags = {}, options = {}) {
687
727
  update: resolveUpdateConfig(flags, env.partial, file),
688
728
  compaction: resolveCompactionConfig(flags, env.partial, file),
689
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,
690
734
  cwd,
691
735
  color,
692
736
  colorLevel,
@@ -730,6 +774,11 @@ function recordResolvedConfig(config) {
730
774
  // trace — so "why did my failing call sit there for three minutes" (or
731
775
  // "why did it not retry at all") has an answer after the fact.
732
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,
733
782
  hasApiKey: Boolean(config.apiKeys[config.provider]),
734
783
  configPath: getConfigPath(),
735
784
  });
@@ -795,6 +795,62 @@ export declare const DEFAULT_BASH_CONFIG: BashConfig;
795
795
  * A garbage `startupSettleMs` resolves to 4000 and throws nothing.
796
796
  */
797
797
  export declare function clampBashConfig(raw: unknown): BashConfig;
798
+ export interface JobsConfig {
799
+ /**
800
+ * Register `job_wait` / `job_list`, splice the `<background_jobs>` block,
801
+ * mirror job logs. Default TRUE.
802
+ *
803
+ * READ ONCE AT CONSTRUCTION, the `bash.background` / `team.enabled` rule. Its
804
+ * job is to be the single switch under which the tool array, the tool
805
+ * descriptions and the system prompt are BYTE-IDENTICAL to the pre-feature
806
+ * build (I-3) - which is why it is a registration switch and not a behaviour
807
+ * flag.
808
+ */
809
+ enabled: boolean;
810
+ /**
811
+ * Seconds one `job_wait` blocks by default. Default 30, range 5..60.
812
+ *
813
+ * ALSO CONVERGED AGAINST THE TOOL CEILING at load time and again at call time
814
+ * (I-4): a wait that outlives the tool call hosting it would turn every long
815
+ * job into `Command timed out`, which is the defect this feature removes.
816
+ */
817
+ waitSeconds: number;
818
+ /**
819
+ * Seconds without output before a job is reported `[stalled]`. Default 300,
820
+ * range 30..3600.
821
+ *
822
+ * A REPORT, NEVER AN ACTION: nothing kills or retries on this (R-13). The
823
+ * default is deliberately generous, because a training epoch that prints every
824
+ * five minutes is healthy.
825
+ */
826
+ stallSeconds: number;
827
+ /**
828
+ * Mirror each job's full output to `<home>/jobs/<session>/<id>.log`. Default
829
+ * TRUE; `false` creates no directory at all, for privacy- or IO-sensitive
830
+ * environments (D-6).
831
+ */
832
+ persistLog: boolean;
833
+ /**
834
+ * Whether a completion notice may STEER the running turn. Default TRUE.
835
+ *
836
+ * NOT A NOTICE SWITCH (§6.5). With this off, notices still arrive at the end of
837
+ * the next tool result and before the next user turn; only the active
838
+ * interruption stops. Turning notifications off entirely would require running
839
+ * no jobs.
840
+ */
841
+ noticeSteer: boolean;
842
+ }
843
+ export declare const DEFAULT_JOBS_CONFIG: JobsConfig;
844
+ /**
845
+ * THE single gate for every `jobs` read AND write, mirroring `clampBashConfig`
846
+ * and the four sections before it.
847
+ *
848
+ * THE ELEVENTH SECTION, AND THE FIRST ONE THAT `config set` CAN REACH BY
849
+ * NESTED KEY (§6.5). Every key is a SCALAR and the section is ONE level deep,
850
+ * because `runConfigGet` splits on a single dot - a `jobs.mirror.enabled` would
851
+ * resolve to `undefined` and the setting would silently never apply.
852
+ */
853
+ export declare function clampJobsConfig(raw: unknown): JobsConfig;
798
854
  /**
799
855
  * Parse `0.9`, `90%` or `90` into a ratio, or `null` when it is neither.
800
856
  *
@@ -955,6 +1011,8 @@ export interface PersistedConfig {
955
1011
  compaction: CompactionConfig;
956
1012
  /** Background services. The TENTH nested object; scalars only (see BashConfig). */
957
1013
  bash: BashConfig;
1014
+ /** Background jobs and bounded waiting. The ELEVENTH; scalars only (see JobsConfig). */
1015
+ jobs: JobsConfig;
958
1016
  }
959
1017
  export declare const DEFAULT_CONFIG: PersistedConfig;
960
1018
  /**
@@ -1262,6 +1320,21 @@ export interface CliConfig {
1262
1320
  * either of them.
1263
1321
  */
1264
1322
  bash: BashConfig;
1323
+ /**
1324
+ * Background jobs and bounded waiting (long-task-jobs §6.5). The ELEVENTH
1325
+ * nested object; five scalar keys, one level deep.
1326
+ *
1327
+ * ON `CliConfig` AS WELL AS `PersistedConfig`, for the reason every section
1328
+ * above records: `AgentController` reads `config.jobs.waitSeconds` through a
1329
+ * closure and `job_wait` reads it again at call time, so a key persisted and
1330
+ * never projected would round-trip through `config set`, show up in
1331
+ * `config list`, and change nothing.
1332
+ *
1333
+ * ONE LEVEL DEEP IS A HARD REQUIREMENT, NOT A PREFERENCE: `runConfigGet` splits
1334
+ * `jobs.waitSeconds` on a single dot, so a nested sub-object would resolve to
1335
+ * `undefined` and report an empty value for a setting that IS on disk.
1336
+ */
1337
+ jobs: JobsConfig;
1265
1338
  /**
1266
1339
  * Lifetime submit count driving the composer hint fade (`HINT_FADE_AFTER`).
1267
1340
  *
@@ -930,6 +930,34 @@ export function clampBashConfig(raw) {
930
930
  readyTimeoutMs: clampInt(src.readyTimeoutMs, DEFAULT_BASH_CONFIG.readyTimeoutMs, READY_TIMEOUT_RANGE),
931
931
  };
932
932
  }
933
+ export const DEFAULT_JOBS_CONFIG = {
934
+ enabled: true,
935
+ waitSeconds: 30,
936
+ stallSeconds: 300,
937
+ persistLog: true,
938
+ noticeSteer: true,
939
+ };
940
+ const WAIT_SECONDS_RANGE = { min: 5, max: 60 };
941
+ const STALL_SECONDS_RANGE = { min: 30, max: 3600 };
942
+ /**
943
+ * THE single gate for every `jobs` read AND write, mirroring `clampBashConfig`
944
+ * and the four sections before it.
945
+ *
946
+ * THE ELEVENTH SECTION, AND THE FIRST ONE THAT `config set` CAN REACH BY
947
+ * NESTED KEY (§6.5). Every key is a SCALAR and the section is ONE level deep,
948
+ * because `runConfigGet` splits on a single dot - a `jobs.mirror.enabled` would
949
+ * resolve to `undefined` and the setting would silently never apply.
950
+ */
951
+ export function clampJobsConfig(raw) {
952
+ const src = (raw && typeof raw === 'object' ? raw : {});
953
+ return {
954
+ enabled: bool(src.enabled, DEFAULT_JOBS_CONFIG.enabled),
955
+ waitSeconds: clampInt(src.waitSeconds, DEFAULT_JOBS_CONFIG.waitSeconds, WAIT_SECONDS_RANGE),
956
+ stallSeconds: clampInt(src.stallSeconds, DEFAULT_JOBS_CONFIG.stallSeconds, STALL_SECONDS_RANGE),
957
+ persistLog: bool(src.persistLog, DEFAULT_JOBS_CONFIG.persistLog),
958
+ noticeSteer: bool(src.noticeSteer, DEFAULT_JOBS_CONFIG.noticeSteer),
959
+ };
960
+ }
933
961
  /**
934
962
  * Parse `0.9`, `90%` or `90` into a ratio, or `null` when it is neither.
935
963
  *
@@ -1028,6 +1056,12 @@ export const DEFAULT_CONFIG = {
1028
1056
  // `undefined` would arrive with background services OFF for every existing
1029
1057
  // user — the same direction the compaction line above must never fail in.
1030
1058
  bash: DEFAULT_BASH_CONFIG,
1059
+ // And one section later again, for the sixth time, and this one would fail
1060
+ // in the WORSE direction: every key in `jobs` is `true` or positive by
1061
+ // default, so a `config.json` predating this feature resolving `config.jobs`
1062
+ // to `undefined` would throw on `config.jobs.waitSeconds` at construction
1063
+ // rather than quietly disabling something.
1064
+ jobs: DEFAULT_JOBS_CONFIG,
1031
1065
  };
1032
1066
  /**
1033
1067
  * Runtime state that used to be stored in `config.json` by mistake
@@ -17,7 +17,7 @@ import { getConfigPath, getSessionsDir } from './app-paths.js';
17
17
  import { registerProfileSecrets, registerSecretsFrom } from '../logging/secret-registry.js';
18
18
  import { adaptLegacyConnectionPatch, ModelProfileConfigError, readModelSettingsDisk } from './model-profile-store.js';
19
19
  import { normalizeModelProfiles } from './model-profiles.js';
20
- import { clampBashConfig, CONFIG_VERSION, DEFAULT_CONFIG, clampCompactionConfig, clampFastConfig, clampLogConfig, clampMaxTokens, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampContextWindow, clampUpdateConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
20
+ import { clampBashConfig, clampJobsConfig, CONFIG_VERSION, DEFAULT_CONFIG, clampCompactionConfig, clampFastConfig, clampLogConfig, clampMaxTokens, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampContextWindow, clampUpdateConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
21
21
  /**
22
22
  * Normalize `maxTokens` for both directions of the config file.
23
23
  *
@@ -189,6 +189,12 @@ export function normalizePersistedConfig(input) {
189
189
  // and NOT merged over the defaults, which is verbatim the failure the
190
190
  // paragraph above says this line prevents.
191
191
  bash: clampBashConfig({ ...DEFAULT_CONFIG.bash, ...(partial.bash ?? {}) }),
192
+ // And the eleventh, on the same line and for the same reason. `jobs.enabled`
193
+ // is `true` by default, so a user whose `config.json` predates this feature
194
+ // must not resolve it to `undefined` — and `waitSeconds` would be `undefined`
195
+ // outright, which is a crash on the first `job_wait` rather than a silently
196
+ // wrong setting.
197
+ jobs: clampJobsConfig({ ...DEFAULT_CONFIG.jobs, ...(partial.jobs ?? {}) }),
192
198
  };
193
199
  }
194
200
  /**
@@ -289,6 +295,13 @@ export function mergePersistedConfig(current, patch) {
289
295
  // top level and write `background` absent, silently unregistering
290
296
  // `bash_output` / `bash_kill` from the next launch onward.
291
297
  bash: clampBashConfig({ ...current.bash, ...(patch.bash ?? {}) }),
298
+ // The eleventh section, and the sixth time this file records the same trap.
299
+ // `aragon config set jobs.waitSeconds 45` sends `{ jobs: { waitSeconds: 45 } }`;
300
+ // without this line the shallow top-level spread REPLACES the section and
301
+ // `persistLog` / `noticeSteer` / `enabled` / `stallSeconds` are written
302
+ // absent — with `noticeSteer`'s default masking it on the next load, so the
303
+ // user would never learn that their privacy switch had been turned back on.
304
+ jobs: clampJobsConfig({ ...current.jobs, ...(patch.jobs ?? {}) }),
292
305
  };
293
306
  return merged;
294
307
  }
@@ -9,8 +9,11 @@
9
9
  *
10
10
  * PURE TYPES AND BUILDERS. No I/O, no process access, no imports outside
11
11
  * `node:`-free type space, so a consumer of this module cannot accidentally
12
- * evaluate the runner.
13
- *
12
+ * evaluate the runner. The one import below is a TYPE from `proc/types.ts` for
13
+ * the same reason: a shape, erased at compile time.
14
+ */
15
+ import type { ServiceStatus } from '../proc/types.js';
16
+ /**
14
17
  * `EXEC_SCHEMA_VERSION` appears on `system` and on `result` and NOWHERE ELSE.
15
18
  * ADDING A FIELD OR AN EVENT TYPE NEVER BUMPS IT; renaming or removing one does.
16
19
  * That rule only holds if consumers keep their side of it, so the README states
@@ -370,7 +373,30 @@ export interface ExecResultEvent {
370
373
  todos: ExecTodoCounts | null;
371
374
  error: ExecErrorInfo | null;
372
375
  }
373
- export type ExecEvent = ExecSystemInitEvent | ExecUserEvent | ExecTurnStateEvent | ExecProgressEvent | ExecTextDeltaEvent | ExecThinkingEvent | ExecAssistantEvent | ExecToolCallEvent | ExecToolResultEvent | ExecTodoEvent | ExecTeamEvent | ExecFastReviewEvent | ExecFastTierEvent | ExecCompactionEvent | ExecRetryEvent | ExecErrorEvent | ExecResultEvent;
376
+ /**
377
+ * A background job's lifecycle (long-task-jobs §4.10).
378
+ *
379
+ * A PURE ADDITION to schema v1: a consumer that does not know this `type`
380
+ * ignores it, which is the forward-compatibility contract this file's header
381
+ * states. `progress` is emitted only when the extracted text CHANGES and at most
382
+ * once a second - a 40-minute training run would otherwise write thousands of
383
+ * lines to a machine consumer's stdout.
384
+ */
385
+ export interface ExecJobEvent {
386
+ type: 'job';
387
+ schemaVersion: number;
388
+ sessionId: string;
389
+ subtype: 'started' | 'progress' | 'exited' | 'stalled';
390
+ /** `t3` / `s1`. */
391
+ jobId: string;
392
+ kind: 'service' | 'task';
393
+ status: ServiceStatus;
394
+ elapsedMs: number;
395
+ /** Only on `subtype === 'progress'`, and only when the text changed. */
396
+ progress?: string;
397
+ exitCode?: number | null;
398
+ }
399
+ export type ExecEvent = ExecSystemInitEvent | ExecUserEvent | ExecTurnStateEvent | ExecProgressEvent | ExecTextDeltaEvent | ExecThinkingEvent | ExecAssistantEvent | ExecToolCallEvent | ExecToolResultEvent | ExecTodoEvent | ExecTeamEvent | ExecFastReviewEvent | ExecFastTierEvent | ExecCompactionEvent | ExecRetryEvent | ExecJobEvent | ExecErrorEvent | ExecResultEvent;
374
400
  /** Everything `system/init` needs that the runner cannot derive for itself. */
375
401
  export interface ExecInitParams {
376
402
  sessionId: string;
@@ -1,16 +1,4 @@
1
1
  /**
2
- * THE CONTRACT (cli-integration-surface section 5.1).
3
- *
4
- * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope
5
- * (`glyphs.test.ts::inScope`, R-3 / AC-25). Every string here can reach a
6
- * consumer's terminal through `--output-format text` fallbacks or a stderr
7
- * warning, and the whole point of the scanner is that a legacy `cmd.exe` shows
8
- * mojibake for anything else.
9
- *
10
- * PURE TYPES AND BUILDERS. No I/O, no process access, no imports outside
11
- * `node:`-free type space, so a consumer of this module cannot accidentally
12
- * evaluate the runner.
13
- *
14
2
  * `EXEC_SCHEMA_VERSION` appears on `system` and on `result` and NOWHERE ELSE.
15
3
  * ADDING A FIELD OR AN EVENT TYPE NEVER BUMPS IT; renaming or removing one does.
16
4
  * That rule only holds if consumers keep their side of it, so the README states
@@ -53,6 +41,9 @@ export const EXEC_CAPABILITIES = [
53
41
  'session-file',
54
42
  'turn-lifecycle',
55
43
  'execution-progress',
44
+ // Background job lifecycle events (long-task-jobs §4.10). A consumer that does
45
+ // not know the `job` type ignores it; this string is how it asks first.
46
+ 'background-jobs',
56
47
  ];
57
48
  export function buildInitEvent(params) {
58
49
  return {
@@ -20,6 +20,7 @@ import { type FollowThroughMode } from '../todo/follow-through.js';
20
20
  import type { ExecEmitter } from './emitter.js';
21
21
  import type { FastSnapshot } from '../fast/types.js';
22
22
  import type { ExecStopReason } from './events.js';
23
+ import type { ProcEvent } from '../proc/types.js';
23
24
  /**
24
25
  * The controller surface the runner needs.
25
26
  *
@@ -45,6 +46,14 @@ export interface ExecRunnerController extends HeadlessController {
45
46
  * it simply emits no `fast_tier` events.
46
47
  */
47
48
  fastSnapshot?(): FastSnapshot | null;
49
+ /**
50
+ * The CLI-local process stream, when this session supervises processes.
51
+ *
52
+ * OPTIONAL AND CALLED WITH `?.()`, exactly like the three optional streams
53
+ * above: this interface is minimal so a test can satisfy it with an object
54
+ * literal, and a host with no supervisor has nothing to report.
55
+ */
56
+ subscribeProc?(listener: (event: ProcEvent) => void): () => void;
48
57
  }
49
58
  /** Where the caller's messages come from. One turn, or an NDJSON stdin stream. */
50
59
  export interface ExecPromptSource {
@@ -97,6 +106,10 @@ export declare class ExecRunner {
97
106
  private readonly progress;
98
107
  private controller;
99
108
  private unsubscribers;
109
+ /** Per-job progress throttle state: the last text emitted, and when. */
110
+ private readonly jobProgressAt;
111
+ /** Per-job `lastOutputAt` at which `stalled` was last reported. */
112
+ private readonly jobStalledAt;
100
113
  private turns;
101
114
  private readonly usage;
102
115
  private compactionBilling;
@@ -123,6 +136,17 @@ export declare class ExecRunner {
123
136
  * multiple subscribers.
124
137
  */
125
138
  attach(controller: ExecRunnerController): void;
139
+ /**
140
+ * Map a supervisor event onto the `job` channel.
141
+ *
142
+ * THROTTLING IS THE WHOLE DISCIPLINE HERE. `output` is already coalesced to
143
+ * <=10 Hz inside the supervisor, which is still far too fast for a machine
144
+ * consumer reading a 40-minute training run; `progress` is therefore emitted
145
+ * only when the extracted TEXT CHANGES and never more than once a second. The
146
+ * other three subtypes are one-shot per job, so they pass straight through.
147
+ */
148
+ private onProcEvent;
149
+ private emitJob;
126
150
  /**
127
151
  * Drop the subscriptions and the timer.
128
152
  *