@aragon-agent/cli 0.6.31 → 0.6.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +2957 -2826
  3. package/dist/agent/controller.d.ts +46 -0
  4. package/dist/agent/controller.js +175 -7
  5. package/dist/agent/job-notices.d.ts +143 -0
  6. package/dist/agent/job-notices.js +182 -0
  7. package/dist/agent/reducer.d.ts +21 -1
  8. package/dist/agent/reducer.js +7 -0
  9. package/dist/agent/system-prompt.d.ts +12 -0
  10. package/dist/agent/system-prompt.js +2 -0
  11. package/dist/cli.js +232 -8
  12. package/dist/commands/builtins.js +23 -8
  13. package/dist/commands/perf.d.ts +54 -1
  14. package/dist/commands/perf.js +28 -4
  15. package/dist/config/cli-commands.d.ts +27 -0
  16. package/dist/config/cli-commands.js +54 -1
  17. package/dist/config/env.js +38 -0
  18. package/dist/config/load.js +50 -1
  19. package/dist/config/schema.d.ts +73 -0
  20. package/dist/config/schema.js +34 -0
  21. package/dist/config/store.js +14 -1
  22. package/dist/exec/events.d.ts +29 -3
  23. package/dist/exec/events.js +3 -12
  24. package/dist/exec/runner.d.ts +24 -0
  25. package/dist/exec/runner.js +79 -0
  26. package/dist/input/cursor-report.d.ts +61 -0
  27. package/dist/input/cursor-report.js +97 -0
  28. package/dist/input/stdin-filter.d.ts +22 -0
  29. package/dist/input/stdin-filter.js +48 -3
  30. package/dist/proc/limits.d.ts +76 -2
  31. package/dist/proc/limits.js +83 -2
  32. package/dist/proc/log-mirror.d.ts +59 -0
  33. package/dist/proc/log-mirror.js +203 -0
  34. package/dist/proc/log-ring.d.ts +22 -0
  35. package/dist/proc/log-ring.js +20 -1
  36. package/dist/proc/notices.d.ts +57 -0
  37. package/dist/proc/notices.js +101 -0
  38. package/dist/proc/progress.d.ts +40 -0
  39. package/dist/proc/progress.js +135 -0
  40. package/dist/proc/prompt.d.ts +24 -2
  41. package/dist/proc/prompt.js +51 -2
  42. package/dist/proc/supervisor.d.ts +35 -2
  43. package/dist/proc/supervisor.js +142 -43
  44. package/dist/proc/types.d.ts +89 -1
  45. package/dist/proc/wait.d.ts +57 -0
  46. package/dist/proc/wait.js +133 -0
  47. package/dist/session/persist.js +3 -1
  48. package/dist/session/validate-session.js +17 -0
  49. package/dist/tools/bash-tool.d.ts +12 -1
  50. package/dist/tools/bash-tool.js +100 -13
  51. package/dist/tools/index.d.ts +26 -2
  52. package/dist/tools/index.js +43 -2
  53. package/dist/tools/job-tools.d.ts +54 -0
  54. package/dist/tools/job-tools.js +329 -0
  55. package/dist/tools/proc-tools.d.ts +1 -1
  56. package/dist/tools/proc-tools.js +49 -17
  57. package/dist/ui/ActivityLine.js +7 -0
  58. package/dist/ui/App.js +64 -6
  59. package/dist/ui/ScrollbackShell.d.ts +30 -4
  60. package/dist/ui/ScrollbackShell.js +8 -3
  61. package/dist/ui/StatusBar.d.ts +5 -0
  62. package/dist/ui/StatusBar.js +1 -0
  63. package/dist/ui/Transcript.d.ts +14 -0
  64. package/dist/ui/Transcript.js +14 -2
  65. package/dist/ui/entries/ServiceCard.d.ts +17 -1
  66. package/dist/ui/entries/ServiceCard.js +39 -4
  67. package/dist/ui/layout/status-layout.d.ts +13 -1
  68. package/dist/ui/layout/status-layout.js +10 -0
  69. package/dist/ui/layout/virtual-window.js +14 -1
  70. package/dist/ui/run-status-row.d.ts +12 -0
  71. package/dist/ui/screen.d.ts +17 -4
  72. package/dist/ui/screen.js +14 -6
  73. package/dist/ui/scrollback/LiveTail.d.ts +64 -0
  74. package/dist/ui/scrollback/LiveTail.js +27 -0
  75. package/dist/ui/scrollback/band.d.ts +105 -0
  76. package/dist/ui/scrollback/band.js +109 -0
  77. package/dist/ui/scrollback/budget.d.ts +32 -3
  78. package/dist/ui/scrollback/budget.js +43 -6
  79. package/dist/ui/scrollback/canvas.d.ts +99 -0
  80. package/dist/ui/scrollback/canvas.js +113 -0
  81. package/dist/ui/scrollback/cursor-probe.d.ts +71 -0
  82. package/dist/ui/scrollback/cursor-probe.js +133 -0
  83. package/dist/ui/scrollback/limits.d.ts +49 -0
  84. package/dist/ui/scrollback/limits.js +49 -0
  85. package/dist/ui/scrollback/scrollback-writer.d.ts +104 -16
  86. package/dist/ui/scrollback/scrollback-writer.js +368 -82
  87. package/dist/ui/scrollback/use-scrollback-view.d.ts +11 -0
  88. package/dist/ui/scrollback/use-scrollback-view.js +37 -6
  89. package/dist/ui/scrollback/writer-shapes.d.ts +68 -2
  90. package/dist/ui/scrollback/writer-shapes.js +53 -8
  91. package/package.json +3 -3
@@ -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'),
package/dist/ui/App.js CHANGED
@@ -41,6 +41,9 @@ import { AppShell } from './layout/AppShell.js';
41
41
  import { ScrollbackShell } from './ScrollbackShell.js';
42
42
  import { useScrollbackView } from './scrollback/use-scrollback-view.js';
43
43
  import { buildExitSummary } from './scrollback/exit-summary.js';
44
+ import { LiveTail } from './scrollback/LiveTail.js';
45
+ import { SCROLLBACK_LIMITS } from './scrollback/limits.js';
46
+ import { TODO_LIMITS } from '../todo/limits.js';
44
47
  import { ScrollViewport } from './layout/ScrollViewport.js';
45
48
  import { buildTodoRailLayout } from './layout/todo-layout.js';
46
49
  import { buildTeamPanelLayout } from './layout/team-panel.js';
@@ -2247,11 +2250,27 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2247
2250
  // there is the exact cost `tui-render-performance` exists to remove. The
2248
2251
  // running tool, when there is one, is within a few entries of the end.
2249
2252
  let runningTool;
2253
+ // THE WAIT PROJECTION (long-task-jobs §4.9 / AC-28). Derived here, in the same
2254
+ // reverse loop, because both answers come from the same place: which tool is in
2255
+ // flight, and — when that tool is `job_wait` — which job it is waiting for and
2256
+ // for how long. The timeout is read from the CALL'S OWN ARGUMENTS rather than
2257
+ // from the config, so a `timeoutSeconds` the model chose is what the row says.
2258
+ let wait;
2250
2259
  if (running) {
2251
2260
  for (let i = state.entries.length - 1; i >= 0; i -= 1) {
2252
2261
  const e = state.entries[i];
2253
2262
  if (e.kind === 'tool' && e.status === 'running') {
2254
2263
  runningTool = e.name;
2264
+ if (e.name === 'job_wait' && e.args) {
2265
+ const args = e.args;
2266
+ const requested = typeof args.timeoutSeconds === 'number'
2267
+ ? args.timeoutSeconds
2268
+ : cfg.jobs.waitSeconds;
2269
+ wait = {
2270
+ jobId: typeof args.job === 'string' ? args.job : 'job',
2271
+ upToSeconds: Math.max(0, Math.round(requested)),
2272
+ };
2273
+ }
2255
2274
  break;
2256
2275
  }
2257
2276
  }
@@ -2284,8 +2303,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2284
2303
  teamRows: teamLayout.rowCount, queueRows: queueLayout.rows, popupRows: 0, composerBaseRows,
2285
2304
  });
2286
2305
  const { visible: railVisible, width: railWidth, rows: railRows, contentCols } = railLayout;
2287
- // No fixed frame means no side rail in native mode (N4); the status chip carries it.
2288
- const showRail = railVisible && !native;
2306
+ // The FULL-SCREEN rail only: native mode draws its rail inside the live region's
2307
+ // panel band (`nativeRail` below), because there is no fixed frame to hang a column
2308
+ // on. This name says which of the two it is; the old `showRail` read like a global
2309
+ // switch and that is how native mode ended up with no rail at all.
2310
+ const fullscreenRailVisible = railVisible && !native;
2289
2311
  const transcriptEntries = useMemo(() => selectTranscriptEntries(state.entries, state.pendingSteering), [state.entries, state.pendingSteering]);
2290
2312
  // --- Native scroll (tui-native-terminal-scroll). -----------------------------
2291
2313
  //
@@ -2303,6 +2325,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2303
2325
  reducedMotion, rows, cols, headerNode: header, openerNode: opener,
2304
2326
  overlayOpen: overlay !== null, draftRows, popupRows: overlay ? 0 : popupRows,
2305
2327
  statusExpanded, forceInactive: native && guardTripped,
2328
+ // The band's inputs. `teamRowsNatural` is the roster layout computed above, so the
2329
+ // band clamps a number this frame already trusts instead of measuring the roster a
2330
+ // second time (`buildTeamPanelLayout` stays the single authority).
2331
+ itemCount: state.todos?.items.length ?? 0,
2332
+ panelEnabled: cfg.todo.panel,
2333
+ teamRowsNatural: teamLayout.rowCount,
2306
2334
  });
2307
2335
  scrollbackViewRef.current = scrollbackView;
2308
2336
  // The two hooks `cli.tsx` calls from OUTSIDE React: the exit summary (every exit
@@ -2384,6 +2412,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2384
2412
  mode: native ? 'native' : 'fullscreen',
2385
2413
  viewportRows: nativeBudget ? nativeBudget.liveRows : viewportBudget,
2386
2414
  offset: scrolledLines,
2415
+ // `null` outside native mode, and `/perf` prints `n/a` for it: a band row of
2416
+ // zeroes in full-screen mode would read like a panel that failed to show.
2417
+ band: nativeBudget
2418
+ ? { ...nativeBudget.band, contentCols: nativeBudget.contentCols }
2419
+ : null,
2387
2420
  // A getter: the bridge's `frameReady` and geometry change outside React renders.
2388
2421
  get scrollbar() {
2389
2422
  return readScrollbarPerf({
@@ -2434,6 +2467,9 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2434
2467
  columns: viewCols, phase: state.runPhase, pendingCount: state.pendingSteering.length,
2435
2468
  context: state.context, usageTotal: state.usageTotal, thinkingLevel: cfg.thinkingLevel,
2436
2469
  elapsedMs, tokPerSec, speedKnown, activeTool: state.activeTool, runOutcome: state.runOutcome,
2470
+ // The bounded-wait label, when `job_wait` is what is holding the run. Absent
2471
+ // for every other tool, which keeps every existing plan byte-identical.
2472
+ ...(wait ? { wait } : {}),
2437
2473
  waitingForConfirmation: !!humanRequest || !!confirmState,
2438
2474
  stopping: interruptGesture.current.phase === 'stopping',
2439
2475
  interruptPhase: interruptGesture.current.phase,
@@ -2456,10 +2492,25 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2456
2492
  updateAvailable: !!updateSnapshot && shouldRenderUpdateLine(updateSnapshot) } });
2457
2493
  const composer = (_jsx(Composer, { deleteDisambiguated: terminal?.deleteDisambiguated ?? false, onCompletionContextChange: setCompletion, cols: frameBudget.composerCols, terminalRows: rows, statusExpanded: statusExpanded, onInteraction: returnToComposer, reducedMotion: cfg.reducedMotion ?? false, onEscape: handleComposerEscape, onEscapeDismiss: cancelConfirmation, isActive: overlay === null && !tooSmall, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: false, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, services: liveServices, modeToggleKey: modeToggleKey, popupMaxRows: popupMaxRows, popupMaxHeight: frameBudget.popupMaxHeight, onPopupRowsChange: onPopupRowsChange, onDraftRows: onDraftRows, onDraftPresence: onDraftPresence, draftClearNonce: draftClearNonce, onNotice: notify, theme: theme, caps: caps, onSubmit: handleSubmit, onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }));
2458
2494
  const team = state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: teamLayout, rows: rows, cols: contentCols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2459
- const rail = showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2495
+ // --- Native panel band (release-gate-and-native-panels §6.3/§6.4) -------------
2496
+ //
2497
+ // SEPARATE NODES, NOT THE TWO ABOVE (RV-P1-7). Those carry `cols={contentCols}` for
2498
+ // the full-screen layout, which has to stay byte-identical (I-3); reusing them here
2499
+ // would silently narrow it. Every number below comes from `budget.band`, and the
2500
+ // number that decides whether a panel mounts IS the number it lays out against - a
2501
+ // panel that mounts on one value and lays out against another renders nothing and
2502
+ // reports nothing (TodoPanel's own I-10 / P1-3 note).
2503
+ const nativeBand = nativeBudget ? nativeBudget.band : null;
2504
+ const nativeRail = state.todos && nativeBand && nativeBand.railRows >= TODO_LIMITS.panelMinRows
2505
+ ? (_jsx(TodoPanel, { snapshot: state.todos, width: nativeBand.railCols, rows: nativeBand.railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2506
+ // `cols` is the band's left column, not `contentCols`: `TeamPanel.activityBudget` is
2507
+ // continuous in `cols`, so a roster laid out against a column it is not in is exactly
2508
+ // the bug that pushes the elapsed column off an 80-column terminal.
2509
+ const nativeTeam = state.team && nativeBand ? (_jsx(TeamPanel, { snapshot: state.team, layout: teamLayout, rows: rows, cols: nativeBand.previewCols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2510
+ const rail = fullscreenRailVisible && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2460
2511
  const viewport = (_jsxs(ScrollViewport, { active: overlay === null && !tooSmall, overlay: overlayNode, footer: _jsxs(_Fragment, { children: [team, _jsx(QueuePanel, { layout: queueLayout, theme: theme })] }), rail: rail, scrollbar: terminal?.scrollbar, resetKey: scrollResetKey, intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, onViewportShiftChange: onViewportShiftChange, onGeometryChange: onGeometryChange, geometryKey: `${rows}:${cols}:${viewportBudget}:${frameBudget.composerSlotRows}:${frameBudget.statusRows}:${contentCols}:${overlay}:${tooSmall}`, tailRowsRef: tailSink, hold: selectionHold, resumeMs: cfg.scrollResumeMs, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: transcriptEntries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: density, nowSec: nowSec, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount, tailSink: tailSink })] }));
2461
2512
  const detailsNode = _jsx(BottomStatusRow, { plan: details, columns: viewCols, theme: theme });
2462
- const statusNode = (_jsx(StatusBar, { columns: viewCols, speedKnown: speedKnown, ...feedbackProjection, interruptPhase: interruptGesture.current.phase, pendingSteering: state.pendingSteering, runPhase: state.runPhase, activeTool: state.activeTool, runOutcome: state.runOutcome, reducedMotion: reducedMotion, ...(sessionId ? { sessionId } : {}), waitingForConfirmation: !!humanRequest || !!confirmState, stopping: interruptGesture.current.phase === 'stopping', interruptHint: interruptGesture.current.phase === 'stopping'
2513
+ const statusNode = (_jsx(StatusBar, { columns: viewCols, speedKnown: speedKnown, ...feedbackProjection, interruptPhase: interruptGesture.current.phase, pendingSteering: state.pendingSteering, runPhase: state.runPhase, activeTool: state.activeTool, ...(wait ? { wait } : {}), runOutcome: state.runOutcome, reducedMotion: reducedMotion, ...(sessionId ? { sessionId } : {}), waitingForConfirmation: !!humanRequest || !!confirmState, stopping: interruptGesture.current.phase === 'stopping', interruptHint: interruptGesture.current.phase === 'stopping'
2463
2514
  ? 'Esc force-stop' : 'Esc x2 interrupt', model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2464
2515
  // ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
2465
2516
  // projection of "many sources of truth" into the props layer, and a
@@ -2475,7 +2526,9 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2475
2526
  total: state.team.runs.length,
2476
2527
  },
2477
2528
  }
2478
- : {}), ...(state.todos && !showRail
2529
+ : {}), ...(state.todos && !(native
2530
+ ? (nativeBudget?.band.railRows ?? 0) > 0
2531
+ : fullscreenRailVisible)
2479
2532
  ? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
2480
2533
  : {}), ...(liveServices > 0 ? { servicesActive: { live: liveServices } } : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}),
2481
2534
  // Manual compaction starts before its next snapshot is published.
@@ -2499,7 +2552,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2499
2552
  }
2500
2553
  : {}) }));
2501
2554
  if (nativeBudget) {
2502
- return (_jsx(ScrollbackShell, { budget: nativeBudget, overlay: overlayNode, liveTailLines: scrollbackView.liveTailLines, composer: composer, status: statusNode, details: detailsNode, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), theme: theme }));
2555
+ return (_jsx(ScrollbackShell, { budget: nativeBudget, overlay: overlayNode, liveTailLines: scrollbackView.liveTailLines,
2556
+ // Real components for the unfinished tail (I-7); the plain rows above stay as
2557
+ // the fallback for a frame with no preview rows.
2558
+ tail: nativeBudget.band.previewRows > 0
2559
+ ? (_jsx(LiveTail, { unsettled: scrollbackView.unsettledEntries, maxEntries: SCROLLBACK_LIMITS.liveTailMaxEntries, rows: nativeBudget.band.previewRows, cols: nativeBudget.previewCols, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: cfg.density, nowSec: nowSec, theme: theme, caps: caps }))
2560
+ : undefined, team: nativeTeam, rail: nativeRail, composer: composer, status: statusNode, details: detailsNode, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), theme: theme }));
2503
2561
  }
2504
2562
  return (_jsx(AppShell, { inactive: tooSmall, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), rows: rows, cols: cols, header: header, viewport: viewport, viewportRows: frameBudget.viewportRows, composer: composer, composerSlotRows: frameBudget.composerSlotRows, statusRows: frameBudget.statusRows, details: detailsNode, status: statusNode }));
2505
2563
  }