@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,329 @@
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 { defineTool, errorResult, textResult } from '@aragon-agent/core';
19
+ import { formatDuration } from '../agent/usage.js';
20
+ import { JOB_WAIT_MARGIN_MS, PROC_LIMITS } from '../proc/limits.js';
21
+ import { renderJobNotices } from '../proc/notices.js';
22
+ import { isTerminalStatus } from '../proc/types.js';
23
+ import { waitForJob } from '../proc/wait.js';
24
+ /**
25
+ * Split a page into "completed rows" and "the carry line".
26
+ *
27
+ * THE PAGE NAMES ITS CARRY: `LogRingPage.carryRow` is the not-yet-terminated line,
28
+ * and `rows` ends with it when it is non-empty. A page from a caller that does not
29
+ * provide it (a test double, `bash_output`) is treated as all-completed, which
30
+ * keeps this a pure refinement rather than a new requirement.
31
+ */
32
+ function splitCarry(page) {
33
+ const rows = page?.rows ?? [];
34
+ const carry = page?.carryRow ?? '';
35
+ if (carry.length === 0)
36
+ return { rows, carry };
37
+ const last = rows[rows.length - 1];
38
+ return last === carry ? { rows: rows.slice(0, -1), carry } : { rows, carry };
39
+ }
40
+ /**
41
+ * Clamp `timeoutSeconds` into `[jobs.waitSeconds, 60]` and then into the tool
42
+ * ceiling (I-4).
43
+ *
44
+ * TWO CONVERGENCES, BOTH REQUIRED. The first honours `WAIT_SECONDS_RANGE`; the
45
+ * second is what makes it impossible for a wait to outlive the tool that is
46
+ * hosting it, which is the difference between "a bounded wait" and "another
47
+ * `Command timed out after 180000ms`".
48
+ */
49
+ function clampWaitSeconds(deps, requested) {
50
+ const configured = deps.waitSeconds();
51
+ const asked = requested === undefined || !Number.isFinite(requested) || requested < PROC_LIMITS.waitMinSeconds
52
+ ? configured
53
+ : requested;
54
+ const bounded = Math.max(PROC_LIMITS.waitMinSeconds, Math.min(Math.floor(asked), PROC_LIMITS.waitMaxSeconds));
55
+ const ceiling = Math.floor((deps.toolTimeoutMs() - JOB_WAIT_MARGIN_MS) / 1000);
56
+ // `Math.max(1, ...)` rather than `Math.max(waitMinSeconds, ...)`: when an
57
+ // operator sets a tool ceiling below the margin, "the wait fits inside the
58
+ // ceiling" is the invariant that must win. Nothing here ever silently produces
59
+ // a wait longer than the tool call that hosts it.
60
+ return Math.max(1, Math.min(bounded, ceiling));
61
+ }
62
+ /** `JOB t3 task running 12m34s` - the one header grammar for every outcome. */
63
+ function headerLine(job, nowMs) {
64
+ const elapsed = formatDuration((job.endedAt ?? nowMs) - job.startedAt);
65
+ const code = job.exitCode !== null
66
+ ? ` code ${job.exitCode}`
67
+ : job.signal !== null
68
+ ? ` signal ${job.signal}`
69
+ : '';
70
+ return `JOB ${job.id} ${job.kind} ${job.status}${code} ${elapsed}`;
71
+ }
72
+ /** `[stalled] ...` - only for a live job whose silence passed the threshold. */
73
+ function stalledLine(job, nowMs, stallSeconds) {
74
+ // A FINISHED JOB CANNOT BE STALLED. `snapshot()` derives the flag only for a
75
+ // live job, so this guard is about the OTHER direction: a snapshot captured
76
+ // while the job was quiet and delivered after it ended must not claim silence.
77
+ if (job.stalled !== true || isTerminalStatus(job.status))
78
+ return undefined;
79
+ const quiet = formatDuration(Math.max(0, nowMs - job.lastOutputAt));
80
+ return (`[stalled] no output for ${quiet} (threshold ${stallSeconds}s). Keep waiting, ` +
81
+ `stop it with bash_kill({ job: "${job.id}" }), or read the tail with bash_output.`);
82
+ }
83
+ /**
84
+ * Assemble one job's block.
85
+ *
86
+ * THE ROW BLOCK IS THE ONLY ELASTIC PART, and it shrinks first when the fixed
87
+ * lines grow - which is how "at most twelve lines" survives a job that is both
88
+ * stalled and truncated without either sentence being dropped.
89
+ */
90
+ function renderJobBlock(args) {
91
+ const { job, rows, cursor, since, nowMs } = args;
92
+ const terminal = isTerminalStatus(job.status);
93
+ const fixed = [headerLine(job, nowMs)];
94
+ const progress = job.progress?.text;
95
+ if (progress !== undefined) {
96
+ fixed.push(`[progress] ${progress}${args.progressUnchanged ? ' (unchanged)' : ''}`);
97
+ }
98
+ const stalled = stalledLine(job, nowMs, args.stalledSeconds);
99
+ if (stalled !== undefined)
100
+ fixed.push(stalled);
101
+ if (args.truncatedNote !== undefined)
102
+ fixed.push(args.truncatedNote);
103
+ const tail = [`[cursor ${cursor}]`];
104
+ if (terminal) {
105
+ tail.push(`[done] the job finished. Read more with bash_output({ service: "${job.id}" }) if you need it.`);
106
+ }
107
+ else {
108
+ const quiet = formatDuration(Math.max(0, nowMs - job.lastOutputAt));
109
+ tail.push(`[wait ${args.waitSeconds}s] still running. No output for ${quiet}.`);
110
+ tail.push(`[next] job_wait({ job: "${job.id}", since: ${cursor} }) to keep waiting, ` +
111
+ 'or continue with other work.');
112
+ }
113
+ // TWELVE LINES, AND THE ROW BLOCK IS THE ONLY ELASTIC PART. Its header costs a
114
+ // line of its own, so the budget is computed with it already deducted; a stalled
115
+ // or truncated job therefore loses output rows rather than the sentence that
116
+ // says what is wrong.
117
+ const rowLineHint = rows.length > 0 ? 1 : 0;
118
+ const budget = Math.max(0, 12 - fixed.length - tail.length - rowLineHint);
119
+ const shown = rows.slice(-Math.min(PROC_LIMITS.waitTailRows, budget));
120
+ const sinceClause = since === undefined ? '' : ` since ${since}`;
121
+ return [
122
+ ...fixed,
123
+ ...(shown.length > 0 ? [`[... ${shown.length} row(s)${sinceClause} ...]`] : []),
124
+ ...shown.map((row) => ` ${row}`),
125
+ ...tail,
126
+ ];
127
+ }
128
+ /** The three-state "no such id" answer (RV-P2-7). */
129
+ function unknownJobMessage(deps, requested) {
130
+ // RECLAIMED FIRST, AND THAT ORDER IS THE POINT (RV-P2-7). State two is the one
131
+ // that costs money if it is missed: a model told `No job "t9"` about a
132
+ // forty-minute training run that FINISHED will start it again. The live-id list
133
+ // is only useful once we know the id never named a job this session.
134
+ const reclaimed = deps.terminalRecord?.(requested);
135
+ if (reclaimed) {
136
+ return errorResult(`Job "${requested}" already finished (${reclaimed.kind} ${reclaimed.status}) and was ` +
137
+ 'reclaimed; its outcome was delivered in a <job_notices> block. Do not re-run it.');
138
+ }
139
+ const live = deps.supervisor
140
+ .list()
141
+ .filter((job) => !isTerminalStatus(job.status))
142
+ .slice(0, PROC_LIMITS.listRows)
143
+ .map((job) => `${job.id} (${job.status} ${formatDuration(Date.now() - job.startedAt)})`);
144
+ if (live.length > 0) {
145
+ return errorResult(`No job "${requested}". Live: ${live.join(', ')}. ` +
146
+ 'Call job_list for detail.');
147
+ }
148
+ return errorResult(`No job "${requested}". Start one with bash({ command, background: true }).`);
149
+ }
150
+ function isLive(job) {
151
+ return !isTerminalStatus(job.status);
152
+ }
153
+ export function makeJobWait(deps) {
154
+ const memory = new Map();
155
+ return defineTool({
156
+ name: 'job_wait',
157
+ label: 'Wait for job',
158
+ description: 'Wait a bounded time for a background job (service or task) and return its ' +
159
+ 'status, one line of progress, and only the output since `since`. Returns ' +
160
+ 'immediately when the job is already finished. Waiting is bounded (5-60s), ' +
161
+ 'so call it again only after deciding what to do.',
162
+ parameters: {
163
+ type: 'object',
164
+ properties: {
165
+ job: { type: 'string', description: '"t3", "s1", or "all".' },
166
+ timeoutSeconds: {
167
+ type: 'number',
168
+ description: '5-60. Omit for the configured default.',
169
+ },
170
+ since: {
171
+ type: 'number',
172
+ description: 'Cursor from a previous call; omit for the smallest useful window.',
173
+ },
174
+ },
175
+ required: ['job'],
176
+ },
177
+ async execute(_id, rawParams, ctx) {
178
+ const params = rawParams;
179
+ const requested = String(params.job);
180
+ const targets = requested === 'all'
181
+ ? deps.supervisor.list().filter(isLive).map((job) => job.id)
182
+ : [requested];
183
+ if (requested !== 'all' && !deps.supervisor.get(requested)) {
184
+ return unknownJobMessage(deps, requested);
185
+ }
186
+ if (targets.length === 0) {
187
+ return textResult('No background jobs are running. Start one with ' +
188
+ 'bash({ command, background: true }).');
189
+ }
190
+ const waitSeconds = clampWaitSeconds(deps, params.timeoutSeconds);
191
+ const outcome = await waitForJob(deps.supervisor, {
192
+ targets,
193
+ timeoutMs: waitSeconds * 1000,
194
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
195
+ ...(params.since !== undefined ? { since: params.since } : {}),
196
+ });
197
+ if (outcome.aborted) {
198
+ return errorResult(`The wait on ${targets.join(', ')} was aborted after ` +
199
+ `${formatDuration(outcome.waitedMs)}. The job was not stopped; ` +
200
+ 'call job_wait again when you want to keep waiting.');
201
+ }
202
+ const nowMs = Date.now();
203
+ const stallSeconds = deps.stallSeconds();
204
+ const blocks = [];
205
+ for (const job of outcome.jobs) {
206
+ const page = outcome.pages.get(job.id);
207
+ // THE CARRY LINE IS NOT AN INCREMENT WHEN IT HAS NOT MOVED (RV-P1-5). A
208
+ // carriage-return-rewritten progress bar completes no rows, so the ring
209
+ // hands the same line back on every wait: reprinting it would spend the
210
+ // six-row budget on one unchanging sentence, and counting it as new output
211
+ // would reset the spin guard so a genuinely stalled job never earns its
212
+ // `[guard]` line. It still reports through `[progress] (unchanged)`.
213
+ const { rows: rowsNew, carry } = splitCarry(page);
214
+ const carryUnchanged = carry.length > 0 && carry === memory.get(job.id)?.lastCarry;
215
+ const carryMoved = carry.length > 0 && !carryUnchanged;
216
+ const shownRows = carryMoved ? [...rowsNew, carry] : rowsNew;
217
+ const memoryForJob = memory.get(job.id) ?? { noChange: 0 };
218
+ const previous = memoryForJob.lastProgress;
219
+ const current = job.progress?.text;
220
+ // TWO DIFFERENT QUESTIONS, deliberately kept apart:
221
+ // - `progressUnchanged` is a RENDERING decision (print `(unchanged)`);
222
+ // - `advanced` is the SPIN-GUARD decision, and the first wait of a job is
223
+ // always "advanced" because it establishes the baseline. Counting it
224
+ // otherwise would put the guard line on the sixth wait instead of the
225
+ // seventh (AC-20).
226
+ const hasBaseline = previous !== undefined;
227
+ const progressUnchanged = hasBaseline && current === previous;
228
+ const newRows = shownRows.length;
229
+ const advanced = !hasBaseline || newRows > 0 || !progressUnchanged;
230
+ memoryForJob.noChange = advanced ? 0 : memoryForJob.noChange + 1;
231
+ if (current !== undefined)
232
+ memoryForJob.lastProgress = current;
233
+ if (carry.length > 0)
234
+ memoryForJob.lastCarry = carry;
235
+ memory.set(job.id, memoryForJob);
236
+ // THE SPIN GUARD (R-3): counted per job, reset by ANY change. It never
237
+ // refuses the wait; it prepends one line naming four ways out.
238
+ const guard = memoryForJob.noChange >= deps.maxConsecutiveWaits
239
+ ? [
240
+ `[guard] ${memoryForJob.noChange} waits with no change. This job may be stalled. ` +
241
+ 'Options: keep waiting, stop it (bash_kill), read the tail (bash_output), or ask the user.',
242
+ ]
243
+ : [];
244
+ blocks.push([
245
+ ...guard,
246
+ ...renderJobBlock({
247
+ job,
248
+ rows: shownRows,
249
+ cursor: page?.cursor ?? job.rowsSeen,
250
+ since: params.since,
251
+ nowMs,
252
+ stalledSeconds: stallSeconds,
253
+ waitSeconds,
254
+ progressUnchanged,
255
+ truncatedNote: page?.truncated === true
256
+ ? '[note] older rows were dropped - the tail keeps the last ' +
257
+ `${PROC_LIMITS.serviceTailRows} rows` +
258
+ (job.logPath ? `. Full log: ${job.logPath}` : '.')
259
+ : undefined,
260
+ }),
261
+ ]);
262
+ }
263
+ const shown = blocks.slice(0, PROC_LIMITS.listRows);
264
+ const lines = shown.flat();
265
+ if (blocks.length > shown.length) {
266
+ lines.push(`[... ${blocks.length - shown.length} more job(s): job_list ...]`);
267
+ }
268
+ return textResult(lines.join('\n'));
269
+ },
270
+ });
271
+ }
272
+ export function makeJobList(deps) {
273
+ return defineTool({
274
+ name: 'job_list',
275
+ label: 'List jobs',
276
+ description: 'One line per background job: id, kind, status, elapsed, progress. Use it ' +
277
+ 'when you need the id you started.',
278
+ parameters: { type: 'object', properties: {} },
279
+ async execute() {
280
+ const jobs = deps.supervisor.list();
281
+ if (jobs.length === 0) {
282
+ return textResult('No background jobs. Start one with bash({ command, background: true }).');
283
+ }
284
+ const nowMs = Date.now();
285
+ // LIVE FIRST, then by start time - the same trade `selectPanelRows` makes
286
+ // for the team panel: what is running is what the reader wants.
287
+ const ordered = [...jobs].sort((a, b) => {
288
+ const liveDelta = Number(isLive(b)) - Number(isLive(a));
289
+ return liveDelta !== 0 ? liveDelta : a.startedAt - b.startedAt;
290
+ });
291
+ const rows = ordered.slice(0, PROC_LIMITS.listRows).map((job) => oneRow(job, nowMs));
292
+ const liveCount = jobs.filter(isLive).length;
293
+ const serviceCount = jobs.filter((job) => job.kind === 'service').length;
294
+ const taskCount = jobs.length - serviceCount;
295
+ const summary = `${liveCount} live of ${jobs.length} (${taskCount} task, ${serviceCount} service). ` +
296
+ 'Wait: job_wait({ job: "<id>" }). Full tail: bash_output({ service: "<id>", since: <cursor> }).';
297
+ if (ordered.length > rows.length)
298
+ rows.push(`[+${ordered.length - rows.length} more]`);
299
+ return textResult([...rows, summary].join('\n'));
300
+ },
301
+ });
302
+ }
303
+ /** `t3 task running 12m34s 47%` - one line, fixed column count. */
304
+ function oneRow(job, nowMs) {
305
+ const elapsed = formatDuration((job.endedAt ?? nowMs) - job.startedAt);
306
+ const aside = job.progress?.text ?? job.url ?? '';
307
+ return `${job.id} ${job.kind} ${job.status} ${elapsed}${aside ? ` ${aside}` : ''}`;
308
+ }
309
+ /**
310
+ * Append pending `<job_notices>` to every tool result (the PULL channel).
311
+ *
312
+ * SAME SHAPE AS `withMailboxTail`, AND FOR THE SAME REASON: the engine is told
313
+ * nothing, the wrapper rides `{...tool, execute}`, and the notice is delivered on
314
+ * the next tool call whatever that call is. The queue is drained EVEN WHEN the
315
+ * wrapped tool errored - a refused `bash` is still a delivery opportunity.
316
+ */
317
+ export function withJobNoticeTail(tool, drain) {
318
+ const original = tool.execute;
319
+ return {
320
+ ...tool,
321
+ async execute(id, params, ctx) {
322
+ const result = await original(id, params, ctx);
323
+ const block = renderJobNotices(drain());
324
+ if (block.length === 0)
325
+ return result;
326
+ return { ...result, content: [...result.content, { type: 'text', text: block }] };
327
+ },
328
+ };
329
+ }
@@ -17,7 +17,7 @@
17
17
  * command is still arbitrary.
18
18
  */
19
19
  import { type AgentTool } from '@aragon-agent/core';
20
- import type { ProcSupervisorPort } from '../proc/types.js';
20
+ import { type ProcSupervisorPort } from '../proc/types.js';
21
21
  export interface ProcToolDeps {
22
22
  supervisor: ProcSupervisorPort;
23
23
  }
@@ -18,9 +18,16 @@
18
18
  */
19
19
  import { defineTool, errorResult, textResult } from '@aragon-agent/core';
20
20
  import { PROC_LIMITS } from '../proc/limits.js';
21
- /** One line of status, shared by both tools so the two can never disagree. */
21
+ import { isTerminalStatus } from '../proc/types.js';
22
+ /**
23
+ * One line of status, shared by both tools so the two can never disagree.
24
+ *
25
+ * IT NAMES THE KIND, because `s1` and `t3` are two different things to be holding
26
+ * and the model has to know which one it is looking at to decide whether waiting
27
+ * is even meaningful.
28
+ */
22
29
  function statusLine(service) {
23
- const bits = [`service ${service.id} ${service.status}`];
30
+ const bits = [`${service.kind} ${service.id} ${service.status}`];
24
31
  if (service.url)
25
32
  bits.push(service.url);
26
33
  if (service.exitCode !== null)
@@ -31,18 +38,30 @@ function statusLine(service) {
31
38
  bits.push('(may have left a detached child)');
32
39
  return `${bits.join(' ')}\n$ ${service.command}`;
33
40
  }
41
+ /** `[progress] 47%` and `[stalled] ...` - the two lines worth adding to a tail. */
42
+ function progressLines(service) {
43
+ const lines = [];
44
+ if (service.progress)
45
+ lines.push(`[progress] ${service.progress.text}`);
46
+ if (service.stalled === true) {
47
+ lines.push('[stalled] no output for a while - it has not failed. Keep waiting, stop it ' +
48
+ `with bash_kill({ service: "${service.id}" }), or read the tail.`);
49
+ }
50
+ return lines;
51
+ }
34
52
  export function makeBashOutput(deps) {
35
53
  return defineTool({
36
54
  name: 'bash_output',
37
- label: 'Service log',
38
- description: 'Read a background service log tail and its current status. Pass the ' +
55
+ label: 'Job log',
56
+ description: 'Read a background job log tail and its current status. Pass the ' +
39
57
  '`since` cursor returned by a previous call to read only what is new. ' +
40
- 'Use this to verify a service actually started, and to read a build or ' +
41
- 'test watcher as it runs.',
58
+ 'Accepts both ids: "s1" for a service and "t3" for a task. Use this to ' +
59
+ 'verify a service actually started, and to read a build or test watcher ' +
60
+ 'as it runs.',
42
61
  parameters: {
43
62
  type: 'object',
44
63
  properties: {
45
- service: { type: 'string', description: 'The service id, e.g. "s1".' },
64
+ service: { type: 'string', description: 'The job id: "s1" or "t3".' },
46
65
  since: {
47
66
  type: 'number',
48
67
  description: 'Cursor from a previous call; omit to read the whole tail.',
@@ -54,13 +73,18 @@ export function makeBashOutput(deps) {
54
73
  const params = rawParams;
55
74
  const service = deps.supervisor.get(params.service);
56
75
  if (!service) {
57
- return errorResult(`No service "${params.service}". Start one with bash({ command, background: true }).`);
76
+ return errorResult(`No job "${params.service}". Start one with bash({ command, background: true }).`);
58
77
  }
59
78
  const page = deps.supervisor.read(params.service, params.since);
60
- const lines = [statusLine(service)];
79
+ const lines = [statusLine(service), ...progressLines(service)];
61
80
  if (page?.truncated) {
81
+ // THE PATH IS THE ESCAPE HATCH (long-task-jobs §4.8). Past 200 rows the
82
+ // ring has forgotten the error the model needs, and the full log is on
83
+ // disk; naming it lets `grep`/`read_file` answer a question this buffer
84
+ // no longer can.
62
85
  lines.push(`[note] older rows were dropped - the tail keeps the last ` +
63
- `${PROC_LIMITS.serviceTailRows} rows.`);
86
+ `${PROC_LIMITS.serviceTailRows} rows` +
87
+ (service.logPath ? `. Full log: ${service.logPath}` : '.'));
64
88
  }
65
89
  if (!page || page.rows.length === 0) {
66
90
  lines.push('(no new output)');
@@ -76,27 +100,35 @@ export function makeBashOutput(deps) {
76
100
  export function makeBashKill(deps) {
77
101
  return defineTool({
78
102
  name: 'bash_kill',
79
- label: 'Stop service',
80
- description: 'Stop a background service by id, or every service with "all". Stop the ' +
81
- 'services you started once you are finished with them, unless the user ' +
82
- 'asked for them to stay up.',
103
+ label: 'Stop job',
104
+ description: 'Stop a background job (service or task) by id, or every job with "all". ' +
105
+ 'Stop the jobs you started once you are finished with them, unless the ' +
106
+ 'user asked them to stay up.',
83
107
  parameters: {
84
108
  type: 'object',
85
109
  properties: {
86
- service: { type: 'string', description: 'The service id, or "all".' },
110
+ service: { type: 'string', description: 'The job id ("s1" or "t3"), or "all".' },
87
111
  },
88
112
  required: ['service'],
89
113
  },
90
114
  async execute(_id, rawParams) {
91
115
  const params = rawParams;
92
116
  if (params.service !== 'all' && !deps.supervisor.get(params.service)) {
93
- return errorResult(`No service "${params.service}".`);
117
+ return errorResult(`No job "${params.service}".`);
94
118
  }
95
119
  const stopped = await deps.supervisor.stop(params.service);
96
120
  if (stopped.length === 0)
97
121
  return textResult('Nothing to stop.');
98
122
  const lines = stopped.map((s) => statusLine(s));
99
- return textResult(`Stopped ${stopped.length} service${stopped.length === 1 ? '' : 's'}.\n${lines.join('\n')}`);
123
+ // A JOB THAT HAD ALREADY FINISHED IS NOT A STOP (AC-37). `stop()` returns
124
+ // the snapshot either way, so the count has to exclude terminal records or
125
+ // `bash_kill` reports having killed something that ended ten minutes ago.
126
+ const ended = stopped.filter((s) => isTerminalStatus(s.status)).length;
127
+ const killed = stopped.length - ended;
128
+ const summary = killed === 0
129
+ ? `Nothing to stop - ${stopped.length === 1 ? 'it had' : 'they had'} already finished.`
130
+ : `Stopped ${killed} job${killed === 1 ? '' : 's'}.`;
131
+ return textResult(`${summary}\n${lines.join('\n')}`);
100
132
  },
101
133
  });
102
134
  }
@@ -10,6 +10,13 @@ export function liveSpinner(reducedMotion, caps) {
10
10
  }
11
11
  /** Explicit execution facts outrank compatibility tool names and rotating phrases. */
12
12
  export function resolveActivityLabel(activity, glyphs) {
13
+ // A WAIT OUTRANKS EVERYTHING BELOW IT, and sits ABOVE `runningTool` on purpose:
14
+ // "Running job_wait" names the tool, "Waiting on t3 (up to 30s)" names the
15
+ // situation. It is placed after the phase/outcome branch because a settled run
16
+ // must never claim to be waiting for anything.
17
+ if (activity.phase === undefined && activity.outcome === undefined && activity.wait) {
18
+ return `Waiting on ${activity.wait.jobId} (up to ${activity.wait.upToSeconds}s)`;
19
+ }
13
20
  if (activity.phase !== undefined || activity.activeTool !== undefined || activity.outcome !== undefined) {
14
21
  return phaseLabels({
15
22
  phase: activity.phase ?? (activity.activeTool ? 'tool' : 'idle'),