@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
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The bounded wait primitive (long-task-jobs §4.4.1).
3
+ *
4
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
5
+ *
6
+ * A FREE FUNCTION OVER THREE PORT METHODS, NOT A METHOD ON THE PORT (D-9).
7
+ * `Pick<ProcSupervisorPort, 'get' | 'read' | 'subscribe'>` is enough to answer
8
+ * "has it finished yet, and what did it print since my cursor", so no test double
9
+ * has to grow a `wait()` and the registry keeps owning exactly what it owns.
10
+ *
11
+ * FOUR RULES, and each one closes a way this turns into a hang:
12
+ *
13
+ * 1. TERMINAL FIRST, SUBSCRIBE SECOND. A job that already ended resolves
14
+ * synchronously with `waitedMs: 0` and registers nothing - no listener, no
15
+ * timer. `runBackground` carries the same defence for the same reason: a
16
+ * 1 ms crash is not hypothetical.
17
+ * 2. EVERY TIMER IS `unref`ed AND EVERY LISTENER IS REMOVED at every exit. A
18
+ * wait that leaks a timer is a process that will not exit, which is the
19
+ * failure mode this feature exists to remove.
20
+ * 3. ONE IDEMPOTENT `finish` FUNNEL. Terminal event, timeout and abort race, and
21
+ * exactly one of them may win.
22
+ * 4. IT DOES NOT DEPEND ON A SINGLE WAKE SOURCE. `reapSync()` used to emit
23
+ * nothing at all (the supervisor now emits `stopped` from it), and
24
+ * `dispose()` clears its listener set without emitting; the self-timeout is
25
+ * therefore a real second path, not belt-and-braces.
26
+ */
27
+ import { isTerminalStatus } from './types.js';
28
+ /** Read one bounded page per target; `read()` returns `undefined` for a vanished id. */
29
+ function readPages(port, targets, since) {
30
+ const pages = new Map();
31
+ for (const id of targets) {
32
+ const page = port.read(id, since);
33
+ if (page)
34
+ pages.set(id, page);
35
+ }
36
+ return pages;
37
+ }
38
+ /**
39
+ * Does every target exist and sit in a terminal state?
40
+ *
41
+ * AN UNKNOWN ID COUNTS AS SETTLED. The supervisor evicts terminal records, so a
42
+ * job whose outcome the caller is asking about may have been reclaimed; waiting
43
+ * for it would burn the whole timeout to learn nothing. The caller distinguishes
44
+ * the two cases from `jobs.length` and the supervisor's own ledger.
45
+ */
46
+ function allSettled(port, targets) {
47
+ return targets.every((id) => {
48
+ const snapshot = port.get(id);
49
+ return snapshot === undefined || isTerminalStatus(snapshot.status);
50
+ });
51
+ }
52
+ /**
53
+ * Wait up to `timeoutMs` for every target to reach a terminal status.
54
+ *
55
+ * Resolves - never rejects - and always after at most `timeoutMs`. `now` is
56
+ * injectable so a test can assert `waitedMs` without depending on wall clock.
57
+ */
58
+ export async function waitForJob(port, req, now = Date.now) {
59
+ const { targets, signal } = req;
60
+ const startedAt = now();
61
+ // RULE 1. Nothing to wait for at all, or everything already ended: no
62
+ // subscription, no timer, `waitedMs: 0`.
63
+ if (targets.length === 0 || allSettled(port, targets)) {
64
+ return {
65
+ jobs: collect(port, targets),
66
+ terminal: true,
67
+ waitedMs: 0,
68
+ timedOut: false,
69
+ aborted: false,
70
+ pages: readPages(port, targets, req.since),
71
+ };
72
+ }
73
+ return new Promise((resolve) => {
74
+ let done = false;
75
+ // DECLARED BEFORE `finish` SO THE FUNNEL CANNOT READ A HALF-INITIALISED BINDING.
76
+ // `port.subscribe()` is free to call its listener synchronously (a test double
77
+ // is the obvious case), and a `finish` that closed over a `const` assigned
78
+ // later in the same block would then throw a temporal-dead-zone ReferenceError
79
+ // from inside the listener instead of resolving the wait.
80
+ let unsubscribe = () => { };
81
+ let timer;
82
+ /** Assigned below; declared here so `finish` never reads an uninitialised binding. */
83
+ let onAbort = () => { };
84
+ // RULE 3: the one funnel. Idempotent, and it releases every reference before
85
+ // resolving so a finished wait cannot hold the event loop open.
86
+ const finish = (reason) => {
87
+ if (done)
88
+ return;
89
+ done = true;
90
+ unsubscribe();
91
+ if (timer !== undefined)
92
+ clearTimeout(timer);
93
+ signal?.removeEventListener('abort', onAbort);
94
+ resolve({
95
+ jobs: collect(port, targets),
96
+ terminal: reason === 'terminal' || allSettled(port, targets),
97
+ waitedMs: now() - startedAt,
98
+ timedOut: reason === 'timeout',
99
+ aborted: reason === 'abort',
100
+ pages: readPages(port, targets, req.since),
101
+ });
102
+ };
103
+ unsubscribe = port.subscribe((event) => {
104
+ if (!isTerminalStatus(event.service.status))
105
+ return;
106
+ if (!targets.includes(event.service.id))
107
+ return;
108
+ if (allSettled(port, targets))
109
+ finish('terminal');
110
+ });
111
+ // RULE 2: `unref`, so a pending wait never keeps a quitting process alive.
112
+ timer = setTimeout(() => finish('timeout'), Math.max(0, req.timeoutMs));
113
+ timer.unref?.();
114
+ onAbort = () => finish('abort');
115
+ // `{ once: true }` AND the explicit removal in `finish`: the option alone
116
+ // leaves the listener registered on a signal that outlives this wait.
117
+ if (signal?.aborted === true) {
118
+ finish('abort');
119
+ return;
120
+ }
121
+ signal?.addEventListener('abort', onAbort, { once: true });
122
+ });
123
+ }
124
+ /** Snapshots for the targets that still exist, in the requested order. */
125
+ function collect(port, targets) {
126
+ const jobs = [];
127
+ for (const id of targets) {
128
+ const snapshot = port.get(id);
129
+ if (snapshot)
130
+ jobs.push(snapshot);
131
+ }
132
+ return jobs;
133
+ }
@@ -201,7 +201,9 @@ export function normalizeLoadedEntries(entries) {
201
201
  // may also carry is already terminal and is left alone.
202
202
  if (entry.kind === 'service' &&
203
203
  (entry.status === 'starting' || entry.status === 'ready' || entry.status === 'running')) {
204
- return { ...entry, status: 'stopped', rows: [] };
204
+ // `stalled: false` for the same reason: nothing is running, so a silence
205
+ // badge restored from disk would describe a process that no longer exists.
206
+ return { ...entry, status: 'stopped', rows: [], stalled: false };
205
207
  }
206
208
  // THE SEVENTH CLAUSE (tui-shift-enter-copy-queue 5.2.5). Core's steering
207
209
  // queue is memory-only, so a `queued` entry on disk is a promise this
@@ -190,6 +190,23 @@ const ENTRY_FIELDS = {
190
190
  number(value, path); },
191
191
  startedAt: number, readyAt: optional(number), endedAt: optional(number), rows: array(string),
192
192
  rowsSeen: number, terminal: optional(boolean), killIncomplete: optional(boolean),
193
+ // THE FOUR FIELDS long-task-jobs adds (AC-38), and the reason this validator
194
+ // has to learn them: it is STRICT — an unknown field fails the whole load — so
195
+ // a session saved with a task in it would refuse to resume, which is a P0 in
196
+ // R-8's terms.
197
+ //
198
+ // ALL FOUR ARE OPTIONAL HERE, DELIBERATELY, and that is a correction to the
199
+ // design. The reducer always writes `jobKind` and `lastOutputAt` for a NEW
200
+ // save, so they are required on `ServiceSnapshot` (that is what turns a
201
+ // forgotten field into a compile error). But a session file written by any
202
+ // build BEFORE this feature has neither, and a strict validator that demanded
203
+ // them would make every existing session unresumable — a far worse failure
204
+ // than a missing progress line. Recorded in the spec's implementation notes.
205
+ jobKind: optional(enumeration('service', 'task')),
206
+ progress: optional(string),
207
+ lastOutputAt: optional(number),
208
+ stalled: optional(boolean),
209
+ logPath: optional(string),
193
210
  },
194
211
  };
195
212
  function validateEntries(value) {
@@ -30,7 +30,7 @@
30
30
  * `bash` assertions meaningful rather than rewritten.
31
31
  */
32
32
  import { type AgentTool } from '@aragon-agent/core';
33
- import type { ProcSupervisorPort } from '../proc/types.js';
33
+ import { type ProcSupervisorPort } from '../proc/types.js';
34
34
  import type { ToolDeps } from './fs-tools.js';
35
35
  export interface BashToolOptions {
36
36
  /**
@@ -57,5 +57,16 @@ export interface BashToolOptions {
57
57
  * `/bg`, no event and nothing in the result saying so.
58
58
  */
59
59
  interactive?: boolean;
60
+ /**
61
+ * `cfg.jobs.enabled`, read LIVE: whether `job_wait` / `job_list` exist in this
62
+ * session (long-task-jobs §4.3).
63
+ *
64
+ * TWO DECISIONS DEPEND ON IT AND NEITHER IS COSMETIC: the tool description only
65
+ * advertises tasks when the tools that manage them are registered, and a
66
+ * `[note]` telling the model to call `job_wait` is only printed when
67
+ * `job_wait` is callable. A workaround the model cannot execute reads as a
68
+ * broken tool.
69
+ */
70
+ jobsAvailable?: () => boolean;
60
71
  }
61
72
  export declare function makeBash(deps: ToolDeps, options?: BashToolOptions): AgentTool;
@@ -35,6 +35,7 @@ import { defineTool, errorResult, textResult, } from '@aragon-agent/core';
35
35
  import { looksLongRunning } from '../proc/classify.js';
36
36
  import { killTree } from '../proc/kill-tree.js';
37
37
  import { PROC_LIMITS } from '../proc/limits.js';
38
+ import { isTerminalStatus } from '../proc/types.js';
38
39
  /**
39
40
  * The two sentences appended to the tool description when background launches
40
41
  * are available.
@@ -50,6 +51,17 @@ const BACKGROUND_DESCRIPTION = ' Set `background: true` for anything that does n
50
51
  '`nohup` or `screen` to detach a command yourself; the runtime supervises ' +
51
52
  'background commands, and a self-detached process cannot be reported on or ' +
52
53
  'stopped.';
54
+ /**
55
+ * The task half, appended only when the job tools are registered.
56
+ *
57
+ * APPENDED CONDITIONALLY FOR THE SAME REASON AS THE BLOCK ABOVE: with
58
+ * `jobs.enabled: false` this sentence would describe a `task` id and a
59
+ * `job_wait` the model does not have, which is worse than saying nothing.
60
+ */
61
+ const TASK_DESCRIPTION = ' For a command that DOES finish but takes far longer than one tool call - a ' +
62
+ 'training run, a model download, a release build - `background: true` returns a ' +
63
+ 'task id (`t1`, `t2`) instead of a service id, and you wait for it with ' +
64
+ '`job_wait` instead of polling `bash_output`.';
53
65
  /**
54
66
  * The settle window used when a supervisor is supplied WITHOUT one.
55
67
  *
@@ -70,22 +82,63 @@ const FALLBACK_STARTUP_SETTLE_MS = 4000;
70
82
  function trackForeground(supervisor, pid) {
71
83
  return supervisor ? supervisor.trackForeground(pid) : () => { };
72
84
  }
73
- /** Render a background launch's result block for the model (§3.5). */
85
+ /**
86
+ * The sentence appended to a timed-out `bash` result when backgrounding is
87
+ * available.
88
+ *
89
+ * THE HIGHEST-VALUE LINE IN THIS FEATURE. Today a long job dies with
90
+ * `Command timed out after 120000ms` and nothing else: no pid, no handle, no way
91
+ * to continue - so the model reaches for a way to detach the process by hand,
92
+ * which is what produced the self-detached, unreportable children this runtime
93
+ * spent a whole feature removing. Naming the exact re-run command turns that into
94
+ * a plan the model can execute with tools it already has.
95
+ */
96
+ function backgroundRetryNote(command, cwd) {
97
+ return [
98
+ '[note] this command does not finish within the tool ceiling. If it is a long',
99
+ 'job, re-run it in the background:',
100
+ ` bash({ command: ${JSON.stringify(command)}, background: true, cwd: ${JSON.stringify(cwd)} })`,
101
+ 'then wait with job_wait({ job: "t1" }).',
102
+ ].join('\n');
103
+ }
104
+ /**
105
+ * Render a background launch's result block for the model (§3.5, long-task-jobs
106
+ * §4.2.1).
107
+ *
108
+ * THE STATUS WORDS TELL THE TRUTH ABOUT THE KIND. A `task` is `running` from the
109
+ * spawn, so "still alive, nothing to connect to (a watcher, most likely)" would
110
+ * describe a training run as if it were a watcher; and a job that WE stopped is
111
+ * neither `starting` nor "still coming up". Both sentences cost the model a
112
+ * wrong decision rather than a word.
113
+ */
74
114
  function renderServiceResult(service, opts) {
115
+ const noun = service.kind === 'task' ? 'task' : 'service';
75
116
  const lines = [`$ ${service.command}`];
76
- lines.push(`[background] service ${service.id} started (pid ${service.pid ?? '?'})`);
117
+ lines.push(`[background] ${noun} ${service.id} started (pid ${service.pid ?? '?'})`);
77
118
  const elapsed = ((service.readyAt ?? service.endedAt ?? Date.now()) - service.startedAt) / 1000;
119
+ let terminal = false;
78
120
  if (service.status === 'ready') {
79
121
  const how = service.detectedBy === 'probe' ? 'port probe' : 'output';
80
122
  const where = service.url ?? (service.port ? `port ${service.port}` : '');
81
123
  lines.push(`[ready] ${where} (detected from ${how} after ${elapsed.toFixed(1)}s)`);
82
124
  }
83
125
  else if (service.status === 'running') {
84
- lines.push(`[running] still alive, nothing to connect to (a watcher, most likely)`);
126
+ lines.push(service.kind === 'task'
127
+ ? `[running] still running after ${elapsed.toFixed(1)}s - it has not finished yet`
128
+ : '[running] still alive, nothing to connect to (a watcher, most likely)');
85
129
  }
86
130
  else if (service.status === 'exited' || service.status === 'failed') {
87
131
  const code = service.exitCode === null ? `signal ${service.signal}` : `code ${service.exitCode}`;
88
132
  lines.push(`[exited] ${code} after ${elapsed.toFixed(1)}s`);
133
+ terminal = true;
134
+ }
135
+ else if (service.status === 'stopped') {
136
+ // BEFORE long-task-jobs this fell into the `else` below and told the model
137
+ // "[starting] not ready yet - still coming up" about a job that had already
138
+ // been killed or reaped. That is a false statement in the one field the model
139
+ // reads first.
140
+ lines.push(`[stopped] before it finished, after ${elapsed.toFixed(1)}s`);
141
+ terminal = true;
89
142
  }
90
143
  else {
91
144
  lines.push(`[starting] not ready yet after ${elapsed.toFixed(1)}s - still coming up`);
@@ -93,6 +146,8 @@ function renderServiceResult(service, opts) {
93
146
  if (service.portPreoccupied && service.port !== undefined) {
94
147
  lines.push(`[note] port ${service.port} was already in use before start`);
95
148
  }
149
+ if (service.progress)
150
+ lines.push(`[progress] ${service.progress.text}`);
96
151
  if (service.rows.length > 0) {
97
152
  lines.push('[log tail]');
98
153
  for (const row of service.rows)
@@ -101,7 +156,13 @@ function renderServiceResult(service, opts) {
101
156
  if (opts.ephemeral) {
102
157
  // D-15 / P1-8: headless has no user who could keep it alive, and saying so
103
158
  // is the difference between a surprise and a documented behaviour.
104
- lines.push('[note] this service will be stopped when the run ends.');
159
+ lines.push(`[note] this ${noun} will be stopped when the run ends.`);
160
+ }
161
+ // A FINISHED JOB EARNS NO ADVICE TO WAIT FOR IT (AC-5). Suggesting `job_wait`
162
+ // for something that already exited is how a model learns to poll.
163
+ if (!terminal && opts.jobsAvailable && service.kind === 'task') {
164
+ lines.push(`[note] wait for it with job_wait({ job: "${service.id}" }) - a bounded wait ` +
165
+ 'returns its status and only the output since your cursor.');
105
166
  }
106
167
  lines.push(`Read more with bash_output({ service: "${service.id}" }); ` +
107
168
  `stop it with bash_kill({ service: "${service.id}" }).`);
@@ -118,6 +179,7 @@ function renderServiceResult(service, opts) {
118
179
  export function makeBash(deps, options = {}) {
119
180
  const supervisor = options.supervisor;
120
181
  const backgroundAvailable = supervisor !== undefined;
182
+ const taskAvailable = backgroundAvailable && options.jobsAvailable?.() === true;
121
183
  return defineTool({
122
184
  name: 'bash',
123
185
  label: 'Shell',
@@ -125,7 +187,8 @@ export function makeBash(deps, options = {}) {
125
187
  'stdout+stderr and exit code. `timeout` (ms) may only shrink within the ' +
126
188
  'agent tool-timeout ceiling; `cwd` defaults to the working directory. ' +
127
189
  'Emit shell syntax compatible with the OS/shell stated in the system prompt.' +
128
- (backgroundAvailable ? BACKGROUND_DESCRIPTION : ''),
190
+ (backgroundAvailable ? BACKGROUND_DESCRIPTION : '') +
191
+ (taskAvailable ? TASK_DESCRIPTION : ''),
129
192
  parameters: {
130
193
  type: 'object',
131
194
  properties: {
@@ -160,16 +223,23 @@ export function makeBash(deps, options = {}) {
160
223
  // `false` -> foreground, always. The escape hatch; nothing overrides it.
161
224
  // absent -> the classifier, and only where a card can be rendered.
162
225
  if (supervisor && params.background !== false) {
226
+ const longRunning = looksLongRunning(command);
163
227
  const auto = options.interactive !== false &&
164
228
  options.autoBackground?.() !== false &&
165
- looksLongRunning(command);
229
+ longRunning;
166
230
  if (params.background === true || auto) {
231
+ // THE KIND IS DECIDED HERE, ONCE (long-task-jobs §4.3). An explicit
232
+ // `background: true` on a command the classifier does not recognise is
233
+ // a TASK; the automatic path can only ever produce a service, because
234
+ // the classifier firing is what set it in motion.
167
235
  return runBackground(supervisor, {
168
236
  command,
169
237
  cwd,
170
238
  toolCallId: id,
239
+ kind: longRunning ? 'service' : 'task',
171
240
  settleMs: options.startupSettleMs?.() ?? FALLBACK_STARTUP_SETTLE_MS,
172
241
  ephemeral: options.interactive === false,
242
+ jobsAvailable: taskAvailable,
173
243
  });
174
244
  }
175
245
  }
@@ -225,8 +295,13 @@ export function makeBash(deps, options = {}) {
225
295
  const buildBody = (opts) => {
226
296
  const body = captured();
227
297
  if (killReason === 'timeout') {
228
- // BYTE-IDENTICAL TO THE PRE-CHANGE BUILD for the `timeout` param.
229
- return errorResult(`Command timed out after ${params.timeout}ms.\n${body}`);
298
+ // BYTE-IDENTICAL TO THE PRE-CHANGE BUILD when background launches are
299
+ // unavailable; with them available the note below is the single
300
+ // highest-value sentence in this feature, because it is what turns
301
+ // "the tool ceiling killed my training run" into "re-run it in the
302
+ // background, then wait for it".
303
+ const timedOut = `Command timed out after ${params.timeout}ms.\n${body}`;
304
+ return errorResult(backgroundAvailable ? `${timedOut}\n${backgroundRetryNote(command, cwd)}` : timedOut);
230
305
  }
231
306
  if (killReason === 'abort') {
232
307
  // P1-5. The captured output is the evidence the model needs, and
@@ -383,9 +458,10 @@ async function runBackground(supervisor, args) {
383
458
  command: args.command,
384
459
  cwd: args.cwd,
385
460
  toolCallId: args.toolCallId,
461
+ kind: args.kind,
386
462
  });
387
463
  if (!started.ok || !started.service) {
388
- return errorResult(started.error ?? 'Could not start the background service.');
464
+ return errorResult(started.error ?? 'Could not start the background job.');
389
465
  }
390
466
  const id = started.service.id;
391
467
  const settled = await new Promise((resolve) => {
@@ -407,11 +483,22 @@ async function runBackground(supervisor, args) {
407
483
  });
408
484
  const timer = setTimeout(() => finish(supervisor.get(id) ?? started.service), args.settleMs);
409
485
  timer.unref?.();
410
- // A service that finished between `start()` and this subscription (a crash
411
- // inside 1 ms is not hypothetical) would otherwise wait out the whole window.
486
+ // A job that finished between `start()` and this subscription (a crash inside
487
+ // 1 ms is not hypothetical) would otherwise wait out the whole window.
488
+ //
489
+ // THE PREDICATE IS TERMINAL, NOT `!== 'starting'` (long-task-jobs RV-P0-1 /
490
+ // I-12). For a service the two happened to agree, because `starting` was its
491
+ // only non-terminal state on this path. A `task` is `running` from the spawn,
492
+ // so `!== 'starting'` was true on the FIRST tick: `bash` returned `[running]`
493
+ // in ~0 ms, the settle window never applied, and a training script that
494
+ // crashed in 300 ms was reported as still running with no output and no exit
495
+ // code - the exact defect this feature exists to remove.
412
496
  const now = supervisor.get(id);
413
- if (now && now.status !== 'starting')
497
+ if (now && isTerminalStatus(now.status))
414
498
  finish(now);
415
499
  });
416
- return renderServiceResult(settled, { ephemeral: args.ephemeral });
500
+ return renderServiceResult(settled, {
501
+ ephemeral: args.ephemeral,
502
+ jobsAvailable: args.jobsAvailable,
503
+ });
417
504
  }
@@ -13,6 +13,7 @@ import type { AgentMode } from '../agent/agent-mode.js';
13
13
  import type { ToolPermission } from '../exec/permission.js';
14
14
  import type { FilePatch } from './patch.js';
15
15
  import type { ProcSupervisorPort } from '../proc/types.js';
16
+ import type { JobNotice } from '../proc/notices.js';
16
17
  export interface ConfirmRequest {
17
18
  tool: string;
18
19
  summary: string;
@@ -179,6 +180,29 @@ export interface BuiltinToolsOptions {
179
180
  * exactly as `--no-skills` and `--no-team` are.
180
181
  */
181
182
  procTools?: AgentTool[];
183
+ /**
184
+ * `job_wait` / `job_list`, appended verbatim (long-task-jobs §4.3 / D-10).
185
+ *
186
+ * THROUGH THE FACTORY FOR THE REASON `teamTools`, `todoTools` AND `procTools`
187
+ * EACH RECORD IN CAPITALS: `tools.test.ts::C7` asserts `HOST_TOOL_NAMES` equals
188
+ * what this factory PRODUCES, so appending them in `AgentController` after the
189
+ * factory returned would turn that test red with a message about two lists of
190
+ * names. An empty array (`jobs.enabled: false`) leaves the array byte-identical
191
+ * to the pre-feature build.
192
+ */
193
+ jobTools?: AgentTool[];
194
+ /**
195
+ * The completion-notice queue's read side, for the PULL channel (§4.6).
196
+ *
197
+ * A PORT, NOT THE CLASS: this file may not import `proc/notices.ts` at runtime
198
+ * (the same rule `procs` follows), and all the wrapper needs is "take
199
+ * everything and give it to me". Omitting it leaves every tool object
200
+ * untouched, provable by identity, which is how `-p`, the TUI and every
201
+ * subagent are shown to be unchanged.
202
+ */
203
+ jobNotices?: {
204
+ drain(): JobNotice[];
205
+ };
182
206
  /**
183
207
  * `cfg.bash.autoBackground`, READ AT CALL TIME.
184
208
  *
@@ -252,7 +276,7 @@ export interface BuiltinToolsOptions {
252
276
  * is the whole of what this mechanism is worth, and saying so plainly beats
253
277
  * implying it covers more.
254
278
  */
255
- export declare const SKILL_TOOL_FLOOR: readonly ["read_file", "list_dir", "glob", "grep", "skill", "skill_find", "ask_user", "submit_plan", "task", "todo_write", "bash_output"];
279
+ export declare const SKILL_TOOL_FLOOR: readonly ["read_file", "list_dir", "glob", "grep", "skill", "skill_find", "ask_user", "submit_plan", "task", "todo_write", "bash_output", "job_wait", "job_list"];
256
280
  /**
257
281
  * The five tools plan mode refuses: exactly the complement of `SKILL_TOOL_FLOOR`
258
282
  * within `HOST_TOOL_NAMES` — "the five that change something", the set this file
@@ -285,5 +309,5 @@ export declare const PLAN_MODE_BLOCKED_TOOLS: ReadonlySet<string>;
285
309
  * declaration MEANS, and that reading must not change with how the CLI was
286
310
  * started.
287
311
  */
288
- export declare const HOST_TOOL_NAMES: readonly ["read_file", "write_file", "edit_file", "list_dir", "glob", "grep", "bash", "skill", "skill_find", "skill_install", "skill_create", "ask_user", "submit_plan", "todo_write", "bash_output", "bash_kill", "task"];
312
+ export declare const HOST_TOOL_NAMES: readonly ["read_file", "write_file", "edit_file", "list_dir", "glob", "grep", "bash", "skill", "skill_find", "skill_install", "skill_create", "ask_user", "submit_plan", "todo_write", "bash_output", "bash_kill", "job_wait", "job_list", "task"];
289
313
  export declare function createBuiltinTools(options: BuiltinToolsOptions): AgentTool[];
@@ -13,6 +13,7 @@ import { planRefusal, submitPlanRefusal } from '../agent/agent-mode.js';
13
13
  import { makeReadFile, makeWriteFile, makeEditFile, makeListDir } from './fs-tools.js';
14
14
  import { makeGlob, makeGrep } from './search-tools.js';
15
15
  import { makeBash } from './bash-tool.js';
16
+ import { withJobNoticeTail } from './job-tools.js';
16
17
  const MUTATING_TOOLS = new Set(['write_file', 'edit_file', 'bash']);
17
18
  /**
18
19
  * Tools the ceiling always permits (D-G6). The ONLY place that knows which of
@@ -65,6 +66,14 @@ export const SKILL_TOOL_FLOOR = [
65
66
  // membership test. Blocking it in plan mode would also let the agent start a
66
67
  // service it then could not read — the dead end the floor exists to prevent.
67
68
  'bash_output',
69
+ // `job_wait` / `job_list` join the FLOOR for the same reason, one feature
70
+ // later (long-task-jobs D-12). Waiting is READING: neither can start, stop or
71
+ // mutate anything, and plan mode's whole purpose - read the code, ask, then
72
+ // propose - is served by being able to wait for a job that was already
73
+ // running. Blocking them would also make a running job unreachable from the
74
+ // mode the user is actually in.
75
+ 'job_wait',
76
+ 'job_list',
68
77
  ];
69
78
  /**
70
79
  * The five tools plan mode refuses: exactly the complement of `SKILL_TOOL_FLOOR`
@@ -131,6 +140,12 @@ export const HOST_TOOL_NAMES = [
131
140
  // `skills doctor` already has with `--no-skills`.
132
141
  'bash_output',
133
142
  'bash_kill',
143
+ // The two job names join the SUPERSET for exactly the reason the two above
144
+ // did: a skill's `allowed-tools: [job_wait]` must be answerable by `doctor`
145
+ // even in a session where `jobs.enabled` is false and neither is registered
146
+ // (long-task-jobs §4.3).
147
+ 'job_wait',
148
+ 'job_list',
134
149
  // `team_send` / `team_wait` are DELIBERATELY ABSENT (P2-5). This constant
135
150
  // answers "can a skill's `allowed-tools` declaration ever take effect?", and
136
151
  // those two are never registered on a lead — so a skill naming them genuinely
@@ -261,6 +276,10 @@ export function createBuiltinTools(options) {
261
276
  ...(options.autoBackground ? { autoBackground: options.autoBackground } : {}),
262
277
  ...(options.startupSettleMs ? { startupSettleMs: options.startupSettleMs } : {}),
263
278
  ...(options.interactive !== undefined ? { interactive: options.interactive } : {}),
279
+ // `jobs.enabled`, spread conditionally: with the job tools off the
280
+ // description and the `[note]` are the ones the pre-feature build
281
+ // produced, byte for byte (I-3).
282
+ ...(options.jobTools && options.jobTools.length > 0 ? { jobsAvailable: () => true } : {}),
264
283
  }
265
284
  : {}),
266
285
  ];
@@ -284,6 +303,12 @@ export function createBuiltinTools(options) {
284
303
  if (options.procTools && options.procTools.length > 0) {
285
304
  tools = [...tools, ...options.procTools];
286
305
  }
306
+ // AFTER `procTools` (long-task-jobs §5.2): the job tools are the third group of
307
+ // server-side handles and belong with the two they extend. A lead's `task` is
308
+ // still last.
309
+ if (options.jobTools && options.jobTools.length > 0) {
310
+ tools = [...tools, ...options.jobTools];
311
+ }
287
312
  // BEFORE both wrappers, so the lead's `task` is covered by the plan gate and
288
313
  // the ceiling like everything else, and a child's comm tools are covered by
289
314
  // everything except the ceiling (see `policyExempt`).
@@ -345,8 +370,24 @@ export function createBuiltinTools(options) {
345
370
  //
346
371
  // The plan gate is applied LAST, which makes it OUTERMOST at call time: it
347
372
  // needs no I/O and no state, so it must not sit behind a human wait.
373
+ //
374
+ // THE NOTICE TAIL IS THE ONE EXCEPTION TO "both tail returns are identical",
375
+ // and it is deliberately the OUTERMOST wrapper of all (long-task-jobs §4.6):
376
+ // whatever a tool did - succeeded, errored, or was refused by the plan gate -
377
+ // its result is a delivery opportunity for a `<job_notices>` block, so the
378
+ // wrapper has to run after every other decision has been made.
379
+ //
380
+ // `withJobNotices` RETURNS ITS ARGUMENT when no queue was supplied, so the
381
+ // identity claim the `AC-G17` tests make about `--no-skills` and about every
382
+ // headless and subagent toolset survives verbatim.
383
+ const withJobNotices = (list) => {
384
+ const queue = options.jobNotices;
385
+ if (!queue)
386
+ return list;
387
+ return list.map((tool) => withJobNoticeTail(tool, () => queue.drain()));
388
+ };
348
389
  if (!options.agentMode)
349
- return tools;
390
+ return withJobNotices(tools);
350
391
  const getMode = options.agentMode;
351
- return tools.map((t) => withPlanModeGate(t, getMode));
392
+ return withJobNotices(tools.map((t) => withPlanModeGate(t, getMode)));
352
393
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `job_wait` and `job_list`, plus the notice tail every tool result carries
3
+ * (long-task-jobs §4.4.2 / §4.4.3 / §4.6).
4
+ *
5
+ * ASCII ONLY: `src/tools/**` is inside the glyph scanner's scope.
6
+ *
7
+ * WHY TWO NEW TOOLS RATHER THAN A `block` PARAMETER ON `bash_output` (D-10):
8
+ * the tool list IS the discoverable capability surface. A model that can SEE
9
+ * `job_wait` knows that waiting is a legal step in a plan; a hidden `block: 30`
10
+ * parameter is a feature it will never use, and extending `bash_output`'s output
11
+ * would also rewrite assertions that pin its current grammar word for word.
12
+ *
13
+ * EVERY RESULT IS A FIXED GRAMMAR OF AT MOST TWELVE LINES. That is not tidiness:
14
+ * the entire economic argument for this feature is that one wait costs ~150
15
+ * tokens instead of a 200-row tail, so an unbounded result would delete the
16
+ * reason the feature exists.
17
+ */
18
+ import { type AgentTool } from '@aragon-agent/core';
19
+ import { type JobNotice } from '../proc/notices.js';
20
+ import type { JobKind, ProcSupervisorPort, ServiceStatus } from '../proc/types.js';
21
+ export interface JobToolDeps {
22
+ supervisor: ProcSupervisorPort;
23
+ /** `cfg.jobs.waitSeconds`, read LIVE (same rule as `startupSettleMs`). */
24
+ waitSeconds: () => number;
25
+ /** `cfg.jobs.stallSeconds`, read LIVE; only the `[stalled]` sentence uses it. */
26
+ stallSeconds: () => number;
27
+ /** `cfg.toolTimeoutMs`, read LIVE: I-4's second convergence. */
28
+ toolTimeoutMs: () => number;
29
+ /** `PROC_LIMITS.maxConsecutiveWaits`. */
30
+ maxConsecutiveWaits: number;
31
+ /**
32
+ * The terminal record for an id this session has already reported and then
33
+ * lost to `evictTerminal()` (RV-P2-7).
34
+ *
35
+ * WITHOUT IT "reclaimed" and "never existed" are the same message, and a model
36
+ * told `No job "t9"` about a 40-minute training run that finished successfully
37
+ * will start it again.
38
+ */
39
+ terminalRecord?: (id: string) => {
40
+ kind: JobKind;
41
+ status: ServiceStatus;
42
+ } | undefined;
43
+ }
44
+ export declare function makeJobWait(deps: JobToolDeps): AgentTool;
45
+ export declare function makeJobList(deps: JobToolDeps): AgentTool;
46
+ /**
47
+ * Append pending `<job_notices>` to every tool result (the PULL channel).
48
+ *
49
+ * SAME SHAPE AS `withMailboxTail`, AND FOR THE SAME REASON: the engine is told
50
+ * nothing, the wrapper rides `{...tool, execute}`, and the notice is delivered on
51
+ * the next tool call whatever that call is. The queue is drained EVEN WHEN the
52
+ * wrapped tool errored - a refused `bash` is still a delivery opportunity.
53
+ */
54
+ export declare function withJobNoticeTail(tool: AgentTool, drain: () => JobNotice[]): AgentTool;