@aragon-agent/cli 0.6.45 → 0.6.47
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.
- package/CHANGELOG.md +15 -0
- package/README.md +104 -6
- package/dist/agent/coalesce.d.ts +4 -0
- package/dist/agent/coalesce.js +13 -0
- package/dist/agent/controller.d.ts +21 -0
- package/dist/agent/controller.js +56 -3
- package/dist/agent/invocation-card.d.ts +77 -0
- package/dist/agent/invocation-card.js +169 -0
- package/dist/agent/prompt-options.d.ts +6 -0
- package/dist/agent/queued-messages.d.ts +3 -0
- package/dist/agent/queued-messages.js +4 -1
- package/dist/agent/reducer.d.ts +25 -0
- package/dist/agent/reducer.js +43 -5
- package/dist/agent/user-attention.d.ts +17 -0
- package/dist/agent/user-attention.js +32 -0
- package/dist/cli.js +2 -3
- package/dist/commands/builtins.js +12 -0
- package/dist/commands/governance-command.js +13 -1
- package/dist/commands/index-build.d.ts +8 -0
- package/dist/commands/index-build.js +11 -0
- package/dist/commands/registry.d.ts +23 -0
- package/dist/commands/registry.js +20 -7
- package/dist/commands/skills.js +36 -2
- package/dist/commands/subagent-command.js +3 -1
- package/dist/commands/team-command.d.ts +6 -0
- package/dist/commands/team-command.js +40 -3
- package/dist/config/load.js +4 -25
- package/dist/config/schema.d.ts +4 -4
- package/dist/config/schema.js +7 -1
- package/dist/proc/limits.d.ts +27 -13
- package/dist/proc/limits.js +27 -13
- package/dist/proc/progress.d.ts +7 -0
- package/dist/proc/progress.js +35 -7
- package/dist/proc/prompt.js +7 -4
- package/dist/proc/supervisor.js +7 -1
- package/dist/proc/types.d.ts +6 -0
- package/dist/proc/wait-plan.d.ts +39 -0
- package/dist/proc/wait-plan.js +77 -0
- package/dist/proc/wait-view.d.ts +65 -0
- package/dist/proc/wait-view.js +66 -0
- package/dist/proc/wait.d.ts +36 -3
- package/dist/proc/wait.js +67 -4
- package/dist/session/checkpoint.js +2 -0
- package/dist/session/persist.js +29 -2
- package/dist/session/validate-session.js +7 -2
- package/dist/team/forced-mode.d.ts +19 -0
- package/dist/team/forced-mode.js +42 -20
- package/dist/tools/job-tools.d.ts +13 -2
- package/dist/tools/job-tools.js +156 -33
- package/dist/ui/ActivityLine.js +5 -2
- package/dist/ui/App.js +59 -6
- package/dist/ui/StatusBar.d.ts +1 -0
- package/dist/ui/Transcript.js +11 -1
- package/dist/ui/entries/InvocationCard.d.ts +20 -0
- package/dist/ui/entries/InvocationCard.js +32 -0
- package/dist/ui/entries/ToolCard.js +11 -2
- package/dist/ui/entries/WaitCard.d.ts +37 -0
- package/dist/ui/entries/WaitCard.js +92 -0
- package/dist/ui/interaction-copy.d.ts +2 -0
- package/dist/ui/interaction-copy.js +3 -0
- package/dist/ui/layout/invocation-card.d.ts +41 -0
- package/dist/ui/layout/invocation-card.js +91 -0
- package/dist/ui/layout/status-layout.d.ts +1 -0
- package/dist/ui/layout/status-layout.js +10 -1
- package/dist/ui/layout/virtual-window.js +18 -2
- package/dist/ui/layout/wait-bar.d.ts +34 -0
- package/dist/ui/layout/wait-bar.js +48 -0
- package/dist/ui/run-status-row.d.ts +4 -2
- package/dist/ui/scrollback/live-preview.js +12 -0
- package/dist/ui/transcript-text.js +4 -1
- package/package.json +2 -2
package/dist/tools/job-tools.js
CHANGED
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { defineTool, errorResult, textResult } from '@aragon-agent/core';
|
|
19
19
|
import { formatDuration } from '../agent/usage.js';
|
|
20
|
-
import {
|
|
20
|
+
import { PROC_LIMITS } from '../proc/limits.js';
|
|
21
21
|
import { renderJobNotices } from '../proc/notices.js';
|
|
22
22
|
import { isTerminalStatus } from '../proc/types.js';
|
|
23
23
|
import { waitForJob } from '../proc/wait.js';
|
|
24
|
+
import { extractEtaText, parseEtaSeconds, suggestWaitSeconds } from '../proc/wait-plan.js';
|
|
25
|
+
import { buildWaitView } from '../proc/wait-view.js';
|
|
24
26
|
/**
|
|
25
27
|
* Split a page into "completed rows" and "the carry line".
|
|
26
28
|
*
|
|
@@ -38,26 +40,36 @@ function splitCarry(page) {
|
|
|
38
40
|
return last === carry ? { rows: rows.slice(0, -1), carry } : { rows, carry };
|
|
39
41
|
}
|
|
40
42
|
/**
|
|
41
|
-
* Clamp `timeoutSeconds` into `[
|
|
42
|
-
* ceiling (I-4).
|
|
43
|
+
* Clamp `timeoutSeconds` into `[waitMinSeconds, waitMaxSeconds]`.
|
|
43
44
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* `
|
|
45
|
+
* NOT CONVERGED AGAINST `toolTimeoutMs` (extended-job-wait I-4'): the executor
|
|
46
|
+
* gives `job_wait` its own ceiling and the idle watchdog is paused for the wait.
|
|
47
|
+
* Re-introducing a `toolTimeoutMs` term here silently pins every wait to
|
|
48
|
+
* `toolTimeoutMs - 30s` (150 s by default) with no error.
|
|
49
|
+
*
|
|
50
|
+
* A request over the ceiling is REPORTED (`clampedFrom`) rather than clamped in
|
|
51
|
+
* silence: a model that asked for an hour and is not told otherwise believes it
|
|
52
|
+
* waited one.
|
|
48
53
|
*/
|
|
49
54
|
function clampWaitSeconds(deps, requested) {
|
|
50
55
|
const configured = deps.waitSeconds();
|
|
51
56
|
const asked = requested === undefined || !Number.isFinite(requested) || requested < PROC_LIMITS.waitMinSeconds
|
|
52
57
|
? configured
|
|
53
58
|
: requested;
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
const floored = Math.floor(asked);
|
|
60
|
+
// The floor stays: a fixture or a hand-edited config can hand back < 5, and a
|
|
61
|
+
// sub-5-second "wait" is a poll.
|
|
62
|
+
const seconds = Math.max(PROC_LIMITS.waitMinSeconds, Math.min(floored, PROC_LIMITS.waitMaxSeconds));
|
|
63
|
+
return floored > seconds ? { seconds, clampedFrom: floored } : { seconds };
|
|
64
|
+
}
|
|
65
|
+
/** `45s`, `4m12s`, `10m`: whole seconds, no fractions, for prose lines. */
|
|
66
|
+
function spanText(seconds) {
|
|
67
|
+
const whole = Math.max(0, Math.round(seconds));
|
|
68
|
+
if (whole < 60)
|
|
69
|
+
return `${whole}s`;
|
|
70
|
+
const minutes = Math.floor(whole / 60);
|
|
71
|
+
const rest = whole % 60;
|
|
72
|
+
return rest === 0 ? `${minutes}m` : `${minutes}m${String(rest).padStart(2, '0')}s`;
|
|
61
73
|
}
|
|
62
74
|
/** `JOB t3 task running 12m34s` - the one header grammar for every outcome. */
|
|
63
75
|
function headerLine(job, nowMs) {
|
|
@@ -100,6 +112,7 @@ function renderJobBlock(args) {
|
|
|
100
112
|
fixed.push(stalled);
|
|
101
113
|
if (args.truncatedNote !== undefined)
|
|
102
114
|
fixed.push(args.truncatedNote);
|
|
115
|
+
fixed.push(...args.extraNotes);
|
|
103
116
|
const tail = [`[cursor ${cursor}]`];
|
|
104
117
|
if (terminal) {
|
|
105
118
|
tail.push(`[done] the job finished. Read more with bash_output({ service: "${job.id}" }) if you need it.`);
|
|
@@ -107,8 +120,14 @@ function renderJobBlock(args) {
|
|
|
107
120
|
else {
|
|
108
121
|
const quiet = formatDuration(Math.max(0, nowMs - job.lastOutputAt));
|
|
109
122
|
tail.push(`[wait ${args.waitSeconds}s] still running. No output for ${quiet}.`);
|
|
110
|
-
|
|
111
|
-
|
|
123
|
+
// THE SUGGESTION RIDES ONLY WHEN IT DIFFERS FROM THE DEFAULT, so a short job on
|
|
124
|
+
// the default config gets the byte-identical line it always got (AC-20).
|
|
125
|
+
const timeoutClause = args.suggestedSeconds !== undefined ? `, timeoutSeconds: ${args.suggestedSeconds}` : '';
|
|
126
|
+
const etaClause = args.suggestedSeconds !== undefined && args.etaText !== undefined
|
|
127
|
+
? ` (ETA about ${args.etaText})`
|
|
128
|
+
: '';
|
|
129
|
+
tail.push(`[next] job_wait({ job: "${job.id}", since: ${cursor}${timeoutClause} }) ` +
|
|
130
|
+
`to keep waiting${etaClause}, or continue with other work.`);
|
|
112
131
|
}
|
|
113
132
|
// TWELVE LINES, AND THE ROW BLOCK IS THE ONLY ELASTIC PART. Its header costs a
|
|
114
133
|
// line of its own, so the budget is computed with it already deducted; a stalled
|
|
@@ -157,15 +176,21 @@ export function makeJobWait(deps) {
|
|
|
157
176
|
label: 'Wait for job',
|
|
158
177
|
description: 'Wait a bounded time for a background job (service or task) and return its ' +
|
|
159
178
|
'status, one line of progress, and only the output since `since`. Returns ' +
|
|
160
|
-
'immediately when the job is already finished. Waiting is bounded
|
|
161
|
-
|
|
179
|
+
'immediately when the job is already finished. Waiting is bounded ' +
|
|
180
|
+
`(${PROC_LIMITS.waitMinSeconds}-${PROC_LIMITS.waitMaxSeconds}s). The wait ends early ` +
|
|
181
|
+
'when the job finishes or when the user sends a message, so prefer ONE long wait ' +
|
|
182
|
+
'over several short ones.',
|
|
162
183
|
parameters: {
|
|
163
184
|
type: 'object',
|
|
164
185
|
properties: {
|
|
165
|
-
job: {
|
|
186
|
+
job: {
|
|
187
|
+
type: 'string',
|
|
188
|
+
description: '"t3", "s1", "all" (until every live job ends) or "any" (until the first ends).',
|
|
189
|
+
},
|
|
166
190
|
timeoutSeconds: {
|
|
167
191
|
type: 'number',
|
|
168
|
-
description:
|
|
192
|
+
description: `${PROC_LIMITS.waitMinSeconds}-${PROC_LIMITS.waitMaxSeconds}. Omit for the ` +
|
|
193
|
+
'configured default. Use the [next] suggestion or the job\'s ETA for long jobs.',
|
|
169
194
|
},
|
|
170
195
|
since: {
|
|
171
196
|
type: 'number',
|
|
@@ -174,35 +199,113 @@ export function makeJobWait(deps) {
|
|
|
174
199
|
},
|
|
175
200
|
required: ['job'],
|
|
176
201
|
},
|
|
177
|
-
async execute(
|
|
202
|
+
async execute(toolCallId, rawParams, ctx) {
|
|
178
203
|
const params = rawParams;
|
|
179
204
|
const requested = String(params.job);
|
|
180
|
-
const
|
|
205
|
+
const mode = requested === 'any' ? 'any' : 'all';
|
|
206
|
+
const targets = requested === 'all' || requested === 'any'
|
|
181
207
|
? deps.supervisor.list().filter(isLive).map((job) => job.id)
|
|
182
208
|
: [requested];
|
|
183
|
-
if (requested !== 'all' && !deps.supervisor.get(requested)) {
|
|
209
|
+
if (requested !== 'all' && requested !== 'any' && !deps.supervisor.get(requested)) {
|
|
184
210
|
return unknownJobMessage(deps, requested);
|
|
185
211
|
}
|
|
186
212
|
if (targets.length === 0) {
|
|
187
213
|
return textResult('No background jobs are running. Start one with ' +
|
|
188
214
|
'bash({ command, background: true }).');
|
|
189
215
|
}
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
216
|
+
const now = deps.now ?? Date.now;
|
|
217
|
+
const { seconds: waitSeconds, clampedFrom } = clampWaitSeconds(deps, params.timeoutSeconds);
|
|
218
|
+
const waitStartedAt = now();
|
|
219
|
+
const stallSeconds = deps.stallSeconds();
|
|
220
|
+
const snapshotsOf = () => targets.flatMap((id) => deps.supervisor.get(id) ?? []);
|
|
221
|
+
const viewAt = (nowMs) => buildWaitView({
|
|
222
|
+
toolCallId,
|
|
223
|
+
snapshots: snapshotsOf(),
|
|
224
|
+
mode,
|
|
225
|
+
waitStartedAt,
|
|
226
|
+
upToMs: waitSeconds * 1000,
|
|
227
|
+
nowMs,
|
|
228
|
+
stallSeconds,
|
|
196
229
|
});
|
|
230
|
+
const emit = (type, nowMs) => {
|
|
231
|
+
const view = viewAt(nowMs);
|
|
232
|
+
if (view === undefined || deps.onWait === undefined)
|
|
233
|
+
return;
|
|
234
|
+
try {
|
|
235
|
+
deps.onWait({ type, toolCallId, view });
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// A broken view must never break (or even slow) a 20-minute wait (I-6).
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
// `begin` ONLY WHEN THERE IS SOMETHING TO WATCH: a wait whose targets have all
|
|
242
|
+
// ended already returns synchronously, and a card that flashes for zero
|
|
243
|
+
// frames is noise (RV-4). `any` needs only ONE ended target to be over.
|
|
244
|
+
const live = snapshotsOf().filter(isLive);
|
|
245
|
+
const alreadyDone = mode === 'any' ? live.length < targets.length : live.length === 0;
|
|
246
|
+
const announce = deps.onWait !== undefined && live.length > 0 && !alreadyDone;
|
|
247
|
+
if (announce)
|
|
248
|
+
emit('begin', waitStartedAt);
|
|
249
|
+
const hold = deps.holdWatchdog ?? ((fn) => fn());
|
|
250
|
+
let endReason = 'abort';
|
|
251
|
+
let outcome;
|
|
252
|
+
try {
|
|
253
|
+
outcome = await hold(() => waitForJob(deps.supervisor, {
|
|
254
|
+
targets,
|
|
255
|
+
timeoutMs: waitSeconds * 1000,
|
|
256
|
+
mode,
|
|
257
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
258
|
+
...(params.since !== undefined ? { since: params.since } : {}),
|
|
259
|
+
...(deps.attention ? { wakeOn: deps.attention } : {}),
|
|
260
|
+
// ALWAYS INSTALLED, announced or not: the beat is also the wall-clock
|
|
261
|
+
// deadline re-check (proc/wait.ts rule 5), which a headless run needs
|
|
262
|
+
// after a laptop sleep just as much as the TUI does.
|
|
263
|
+
heartbeat: {
|
|
264
|
+
everyMs: PROC_LIMITS.waitHeartbeatMs,
|
|
265
|
+
onBeat: (nowMs) => {
|
|
266
|
+
if (announce)
|
|
267
|
+
emit('update', nowMs);
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
}, now));
|
|
271
|
+
endReason = outcome.aborted
|
|
272
|
+
? 'abort'
|
|
273
|
+
: outcome.woken
|
|
274
|
+
? 'wake'
|
|
275
|
+
: outcome.timedOut
|
|
276
|
+
? 'timeout'
|
|
277
|
+
: 'terminal';
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
// EVERY EXIT, INCLUDING A THROW (I-5): a missed `end` leaves a card that says
|
|
281
|
+
// "waiting" for a wait that is over, forever.
|
|
282
|
+
if (announce && deps.onWait) {
|
|
283
|
+
try {
|
|
284
|
+
deps.onWait({ type: 'end', toolCallId, reason: endReason });
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// see above
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
197
291
|
if (outcome.aborted) {
|
|
198
292
|
return errorResult(`The wait on ${targets.join(', ')} was aborted after ` +
|
|
199
293
|
`${formatDuration(outcome.waitedMs)}. The job was not stopped; ` +
|
|
200
294
|
'call job_wait again when you want to keep waiting.');
|
|
201
295
|
}
|
|
202
|
-
|
|
203
|
-
const
|
|
296
|
+
// `[woke]` and `[note]` describe the WAIT, not a job: one of each per result.
|
|
297
|
+
const waitNotes = [];
|
|
298
|
+
if (outcome.woken) {
|
|
299
|
+
waitNotes.push(`[woke] you sent a message; the wait ended after ${spanText(outcome.waitedMs / 1000)} ` +
|
|
300
|
+
`of ${spanText(waitSeconds)}. The job is still running. Queued input is delivered next.`);
|
|
301
|
+
}
|
|
302
|
+
if (clampedFrom !== undefined) {
|
|
303
|
+
waitNotes.push(`[note] timeoutSeconds ${clampedFrom} exceeds the ${PROC_LIMITS.waitMaxSeconds}s ` +
|
|
304
|
+
`ceiling; waited up to ${PROC_LIMITS.waitMaxSeconds}s.`);
|
|
305
|
+
}
|
|
306
|
+
const nowMs = now();
|
|
204
307
|
const blocks = [];
|
|
205
|
-
for (const job of outcome.jobs) {
|
|
308
|
+
for (const [blockIndex, job] of outcome.jobs.entries()) {
|
|
206
309
|
const page = outcome.pages.get(job.id);
|
|
207
310
|
// THE CARRY LINE IS NOT AN INCREMENT WHEN IT HAS NOT MOVED (RV-P1-5). A
|
|
208
311
|
// carriage-return-rewritten progress bar completes no rows, so the ring
|
|
@@ -227,12 +330,29 @@ export function makeJobWait(deps) {
|
|
|
227
330
|
const progressUnchanged = hasBaseline && current === previous;
|
|
228
331
|
const newRows = shownRows.length;
|
|
229
332
|
const advanced = !hasBaseline || newRows > 0 || !progressUnchanged;
|
|
230
|
-
|
|
333
|
+
// A wait the USER cut short says nothing about the job: it neither advances
|
|
334
|
+
// nor extends the silence count.
|
|
335
|
+
memoryForJob.noChange = advanced
|
|
336
|
+
? 0
|
|
337
|
+
: outcome.woken
|
|
338
|
+
? memoryForJob.noChange
|
|
339
|
+
: memoryForJob.noChange + 1;
|
|
231
340
|
if (current !== undefined)
|
|
232
341
|
memoryForJob.lastProgress = current;
|
|
233
342
|
if (carry.length > 0)
|
|
234
343
|
memoryForJob.lastCarry = carry;
|
|
344
|
+
memoryForJob.lastWaitSeconds = waitSeconds;
|
|
235
345
|
memory.set(job.id, memoryForJob);
|
|
346
|
+
// ADVICE, NEVER ENFORCED (I-13): shown only when it differs from the default.
|
|
347
|
+
const suggested = suggestWaitSeconds({
|
|
348
|
+
jobAgeMs: nowMs - job.startedAt,
|
|
349
|
+
lastWaitSeconds: waitSeconds,
|
|
350
|
+
quietWaits: memoryForJob.noChange,
|
|
351
|
+
etaSeconds: parseEtaSeconds(current),
|
|
352
|
+
configuredSeconds: deps.waitSeconds(),
|
|
353
|
+
minSeconds: PROC_LIMITS.waitMinSeconds,
|
|
354
|
+
maxSeconds: PROC_LIMITS.waitMaxSeconds,
|
|
355
|
+
});
|
|
236
356
|
// THE SPIN GUARD (R-3): counted per job, reset by ANY change. It never
|
|
237
357
|
// refuses the wait; it prepends one line naming four ways out.
|
|
238
358
|
const guard = memoryForJob.noChange >= deps.maxConsecutiveWaits
|
|
@@ -252,6 +372,9 @@ export function makeJobWait(deps) {
|
|
|
252
372
|
stalledSeconds: stallSeconds,
|
|
253
373
|
waitSeconds,
|
|
254
374
|
progressUnchanged,
|
|
375
|
+
extraNotes: blockIndex === 0 ? waitNotes : [],
|
|
376
|
+
suggestedSeconds: suggested !== deps.waitSeconds() ? suggested : undefined,
|
|
377
|
+
etaText: extractEtaText(current),
|
|
255
378
|
truncatedNote: page?.truncated === true
|
|
256
379
|
? '[note] older rows were dropped - the tail keeps the last ' +
|
|
257
380
|
`${PROC_LIMITS.serviceTailRows} rows` +
|
package/dist/ui/ActivityLine.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Box, Text } from 'ink';
|
|
|
3
3
|
import Spinner from 'ink-spinner';
|
|
4
4
|
import { pickGlyphs } from './glyphs.js';
|
|
5
5
|
import { pickActivityPhrase } from './activity-phrases.js';
|
|
6
|
-
import { phaseLabels } from './layout/status-layout.js';
|
|
6
|
+
import { phaseLabels, shortDuration } from './layout/status-layout.js';
|
|
7
7
|
/** Shared animation factory; returns null for ASCII or reduced-motion terminals. */
|
|
8
8
|
export function liveSpinner(reducedMotion, caps) {
|
|
9
9
|
return caps.unicode && !reducedMotion ? _jsx(Spinner, { type: "dots" }) : null;
|
|
@@ -15,7 +15,10 @@ export function resolveActivityLabel(activity, glyphs) {
|
|
|
15
15
|
// situation. It is placed after the phase/outcome branch because a settled run
|
|
16
16
|
// must never claim to be waiting for anything.
|
|
17
17
|
if (activity.phase === undefined && activity.outcome === undefined && activity.wait) {
|
|
18
|
-
|
|
18
|
+
const { jobId, upToSeconds, elapsedSeconds } = activity.wait;
|
|
19
|
+
return elapsedSeconds !== undefined
|
|
20
|
+
? `Waiting on ${jobId} ${shortDuration(elapsedSeconds * 1000)}/${shortDuration(upToSeconds * 1000)}`
|
|
21
|
+
: `Waiting on ${jobId} (up to ${upToSeconds}s)`;
|
|
19
22
|
}
|
|
20
23
|
if (activity.phase !== undefined || activity.activeTool !== undefined || activity.outcome !== undefined) {
|
|
21
24
|
return phaseLabels({
|
package/dist/ui/App.js
CHANGED
|
@@ -92,7 +92,8 @@ import { SettingsScreen, compactionSettingsFrom, fastSettingsFrom, } from './ove
|
|
|
92
92
|
import { ConfirmDialog } from './overlays/ConfirmDialog.js';
|
|
93
93
|
import { QuestionOverlay } from './overlays/QuestionOverlay.js';
|
|
94
94
|
import { PlanReviewOverlay } from './overlays/PlanReviewOverlay.js';
|
|
95
|
-
import { CommandRegistry, runSlashInput } from '../commands/registry.js';
|
|
95
|
+
import { CommandRegistry, parseSlashInput, runSlashInput, } from '../commands/registry.js';
|
|
96
|
+
import { buildInvocationCard, invocationCardsEnabled } from '../agent/invocation-card.js';
|
|
96
97
|
import { registerBuiltinCommands } from '../commands/builtins.js';
|
|
97
98
|
import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js';
|
|
98
99
|
import { MODE_TOGGLE_KEYS } from '../agent/agent-mode.js';
|
|
@@ -1225,6 +1226,30 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1225
1226
|
}
|
|
1226
1227
|
});
|
|
1227
1228
|
}, [controller, flushPending, governorInterval]);
|
|
1229
|
+
// --- The long wait's live view (extended-job-wait §3.8.1). --------------
|
|
1230
|
+
//
|
|
1231
|
+
// THROUGH THE COALESCER, like the tool tail above and for the same reason: a
|
|
1232
|
+
// 1 Hz producer is cheap, but two updates landing in one frame should commit
|
|
1233
|
+
// once. `begin` and `update` are the same action (the reducer only writes onto a
|
|
1234
|
+
// RUNNING matching entry); `end` needs no action of its own - `toolExecEnd` and
|
|
1235
|
+
// every idle path clear the view (I-5 / RV-2), and a second clear here would be
|
|
1236
|
+
// a third copy of that rule.
|
|
1237
|
+
useEffect(() => {
|
|
1238
|
+
// OPTIONAL CALL: a duck-typed controller (a test double, a headless shim) predates
|
|
1239
|
+
// this channel and has nothing to subscribe to - exactly as `subscribeTeam?` is.
|
|
1240
|
+
return controller.subscribeWait?.((event) => {
|
|
1241
|
+
if (event.type === 'end')
|
|
1242
|
+
return;
|
|
1243
|
+
pending.current.push({
|
|
1244
|
+
type: 'waitUpdate',
|
|
1245
|
+
toolCallId: event.toolCallId,
|
|
1246
|
+
view: event.view,
|
|
1247
|
+
});
|
|
1248
|
+
if (!flushTimer.current) {
|
|
1249
|
+
flushTimer.current = setTimeout(() => flushPending(), governorInterval.current);
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
}, [controller, flushPending, governorInterval]);
|
|
1228
1253
|
// Event boundaries own start/end; ticks only refresh the live projection.
|
|
1229
1254
|
useEffect(() => {
|
|
1230
1255
|
if (state.status !== 'running')
|
|
@@ -1942,8 +1967,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1942
1967
|
dispatch({ type: 'taskSummaryReady', view: outcome.view });
|
|
1943
1968
|
});
|
|
1944
1969
|
};
|
|
1970
|
+
const invocationCardsOn = invocationCardsEnabled();
|
|
1945
1971
|
const makeCtx = (args) => ({
|
|
1946
1972
|
args,
|
|
1973
|
+
invocationCards: invocationCardsOn,
|
|
1947
1974
|
requestCopy: copyCoordinator.requestCopy,
|
|
1948
1975
|
controller,
|
|
1949
1976
|
state: stateRef.current,
|
|
@@ -2121,10 +2148,17 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
2121
2148
|
// must be the one that runs, whichever branch it takes.
|
|
2122
2149
|
cancelFollowThrough();
|
|
2123
2150
|
cancelConfirmation();
|
|
2151
|
+
// THE ONE PLACE A CARD IS BUILT (tui-invocation-cards I-3 / I-6): redact -> normalise ->
|
|
2152
|
+
// limit, with the same redactor the `Command:` echo uses. `message` below is the model's
|
|
2153
|
+
// input and is never touched; the card only replaces what the TRANSCRIPT shows.
|
|
2154
|
+
const card = invocationCardsOn && opts.invocation
|
|
2155
|
+
? buildInvocationCard(opts.invocation, (text) => redactText(text, getSecrets()))
|
|
2156
|
+
: undefined;
|
|
2157
|
+
const shown = card?.commandLine ?? message;
|
|
2124
2158
|
if (interactionPhase.current === 'running' || controller.isCompactionBusy?.()) {
|
|
2125
2159
|
try {
|
|
2126
2160
|
const queueId = controller.queueUserMessage(message);
|
|
2127
|
-
dispatch({ type: 'steerQueued', queueId, text:
|
|
2161
|
+
dispatch({ type: 'steerQueued', queueId, text: shown, ...(card ? { invocation: card } : {}) });
|
|
2128
2162
|
return { accepted: true };
|
|
2129
2163
|
}
|
|
2130
2164
|
catch (error) {
|
|
@@ -2143,7 +2177,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
2143
2177
|
// the user has learned, so suppressing the fade is correct rather than
|
|
2144
2178
|
// incidental — which is why the parameter is named for the CAUSE (P2-8).
|
|
2145
2179
|
if (opts.userInitiated !== false) {
|
|
2146
|
-
|
|
2180
|
+
// A card's command line was already recorded by `executeSlashInput`; the payload
|
|
2181
|
+
// behind it must never reach the up-arrow history (I-9: the gate is unchanged).
|
|
2182
|
+
if (!card)
|
|
2183
|
+
recordPrompt(message);
|
|
2147
2184
|
// THE ONE PLACE THE NUDGE BUDGET IS REFILLED (P0-2 / I-10). Same predicate
|
|
2148
2185
|
// as `recordPrompt`, and for the same reason: the question is "did a human
|
|
2149
2186
|
// ask for this?", and only a human may hand the gate another attempt. A
|
|
@@ -2154,7 +2191,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
2154
2191
|
// Mirrors where the reducer clears its own copies, in the same action
|
|
2155
2192
|
// (C-11): a new turn's end reason must not inherit the previous one's.
|
|
2156
2193
|
endReasonRef.current = { aborted: false, errored: false };
|
|
2157
|
-
dispatch({ type: 'submit', text:
|
|
2194
|
+
dispatch({ type: 'submit', text: shown, ...(card ? { invocation: card } : {}) });
|
|
2158
2195
|
const pre = controller.preflight();
|
|
2159
2196
|
if (!pre.ok) {
|
|
2160
2197
|
dispatch({ type: 'runError' });
|
|
@@ -2174,7 +2211,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
2174
2211
|
// model call that describes a turn nobody ran; and below, in the queueing
|
|
2175
2212
|
// branch, the message is steering rather than a new task.
|
|
2176
2213
|
if (opts.userInitiated !== false)
|
|
2177
|
-
ensureTaskSummary(
|
|
2214
|
+
ensureTaskSummary(shown);
|
|
2178
2215
|
void controller.prompt(message, { todoPolicy: opts.todoPolicy ?? 'new-task' }).then((outcome) => {
|
|
2179
2216
|
if (!mounted.current || sequence !== submissionSequence.current)
|
|
2180
2217
|
return;
|
|
@@ -2213,7 +2250,13 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
2213
2250
|
// Retain a recoverable command before any asynchronous handler can fail.
|
|
2214
2251
|
const safe = redactText(raw, getSecrets());
|
|
2215
2252
|
recordPrompt(safe);
|
|
2216
|
-
|
|
2253
|
+
// The echo is suppressed BEFORE the command runs and never deleted afterwards: printed
|
|
2254
|
+
// scrollback rows cannot be rewritten (I-5). The predicate is the command's own (I-8).
|
|
2255
|
+
const parsed = parseSlashInput(raw);
|
|
2256
|
+
const rendersCard = invocationCardsOn && parsed !== null
|
|
2257
|
+
&& registry.get(parsed.name)?.rendersInvocationCard?.(parsed.args) === true;
|
|
2258
|
+
if (!rendersCard)
|
|
2259
|
+
notify('info', `Command: ${safe}`);
|
|
2217
2260
|
try {
|
|
2218
2261
|
await runSlashInput(registry, raw, makeCtx);
|
|
2219
2262
|
}
|
|
@@ -3005,6 +3048,16 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
3005
3048
|
jobId: typeof args.job === 'string' ? args.job : 'job',
|
|
3006
3049
|
upToSeconds: Math.max(0, Math.round(requested)),
|
|
3007
3050
|
};
|
|
3051
|
+
// Once the tool has announced itself (`begin`), its own view is the better
|
|
3052
|
+
// source - the clamped budget and the start stamp - and adds the running
|
|
3053
|
+
// clock. Before that one frame the args-only sentence stands.
|
|
3054
|
+
if (e.wait) {
|
|
3055
|
+
wait = {
|
|
3056
|
+
jobId: wait.jobId,
|
|
3057
|
+
upToSeconds: Math.max(0, Math.round(e.wait.upToMs / 1000)),
|
|
3058
|
+
elapsedSeconds: Math.max(0, Math.floor(Date.now() / 1000) - Math.floor(e.wait.waitStartedAt / 1000)),
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
3008
3061
|
}
|
|
3009
3062
|
break;
|
|
3010
3063
|
}
|
package/dist/ui/StatusBar.d.ts
CHANGED
package/dist/ui/Transcript.js
CHANGED
|
@@ -8,9 +8,11 @@ import { pickGlyphs, toolGlyph } from './glyphs.js';
|
|
|
8
8
|
import { separationRows } from './density.js';
|
|
9
9
|
import { EntryFrame } from './entries/EntryFrame.js';
|
|
10
10
|
import { UserEntry } from './entries/UserEntry.js';
|
|
11
|
+
import { InvocationCard } from './entries/InvocationCard.js';
|
|
11
12
|
import { QueuedEntry } from './entries/QueuedEntry.js';
|
|
12
13
|
import { AssistantEntry } from './entries/AssistantEntry.js';
|
|
13
14
|
import { ToolCard, statusColor, toolEntryGlyph } from './entries/ToolCard.js';
|
|
15
|
+
import { WaitCard } from './entries/WaitCard.js';
|
|
14
16
|
import { TeamCard, teamCardColor } from './entries/TeamCard.js';
|
|
15
17
|
import { GovernanceCard, governanceCardColor } from './entries/GovernanceCard.js';
|
|
16
18
|
import { isLiveTeamCard, rosterCounts } from './team-status.js';
|
|
@@ -78,7 +80,9 @@ function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion,
|
|
|
78
80
|
const frame = (glyph, color, children) => (_jsx(EntryFrame, { glyph: glyph, color: color, continuation: glyphs.railVertical, separation: separation, spacerColor: theme.border, children: children }));
|
|
79
81
|
switch (entry.kind) {
|
|
80
82
|
case 'user':
|
|
81
|
-
return frame(glyphs.user, theme.user,
|
|
83
|
+
return frame(glyphs.user, theme.user, entry.invocation
|
|
84
|
+
? _jsx(InvocationCard, { card: entry.invocation, theme: theme, caps: caps })
|
|
85
|
+
: _jsx(UserEntry, { text: entry.text, theme: theme, caps: caps }));
|
|
82
86
|
case 'queued':
|
|
83
87
|
// `theme.muted`: quieter than a user message, louder than a notice --
|
|
84
88
|
// the row exists to be noticed UNTIL it is accepted, then rewritten in
|
|
@@ -99,6 +103,12 @@ function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion,
|
|
|
99
103
|
thinkingMs: entry.thinkingMs, theme: theme, caps: caps }));
|
|
100
104
|
}
|
|
101
105
|
case 'tool':
|
|
106
|
+
// THE CARD IS CHOSEN FROM THE ENTRY ITSELF, with no extra prop (extended-job-wait
|
|
107
|
+
// RV-3): `LiveTail` mounts this same component for native-scroll mode, and a
|
|
108
|
+
// value only `Transcript` passed would leave that mode on the one-line card.
|
|
109
|
+
if (entry.name === 'job_wait' && entry.status === 'running' && entry.wait) {
|
|
110
|
+
return frame(toolEntryGlyph(entry.name, caps), statusColor(entry.status, theme), _jsx(WaitCard, { view: entry.wait, nowSec: nowSec, reducedMotion: reducedMotion, theme: theme, caps: caps }));
|
|
111
|
+
}
|
|
102
112
|
return frame(toolEntryGlyph(entry.name, caps), statusColor(entry.status, theme), _jsx(ToolCard, { name: entry.name, label: entry.label, argsRaw: entry.argsRaw, args: entry.args, status: entry.status, preview: entry.preview, patch: entry.patch, durationMs: entry.durationMs, isError: entry.isError, expanded: expanded, reducedMotion: reducedMotion,
|
|
103
113
|
// `entry.live` AND NOT `entry.live ?? []` (P2-4): `ToolCard`'s default
|
|
104
114
|
// comparator sees a fresh `[]` as a changed prop, which is why that
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a `user` entry that carries an invocation card (tui-invocation-cards).
|
|
3
|
+
*
|
|
4
|
+
* Like `UserEntry`, this renders CONTENT only: the role glyph, rail and spacing belong
|
|
5
|
+
* to `EntryFrame`, so the card stays "the user's turn" for density, turn counting and
|
|
6
|
+
* the settled boundary. The header text comes from `invocationHeaderSegments` and is
|
|
7
|
+
* never assembled here (RV-10); the row count it implies is `invocationCardRows`.
|
|
8
|
+
*/
|
|
9
|
+
import React from 'react';
|
|
10
|
+
import type { Theme } from '../theme.js';
|
|
11
|
+
import type { TermCapabilities } from '../capabilities.js';
|
|
12
|
+
import type { InvocationCard as InvocationCardData } from '../../agent/invocation-card.js';
|
|
13
|
+
declare function InvocationCardImpl({ card, theme, caps, }: {
|
|
14
|
+
card: InvocationCardData;
|
|
15
|
+
theme: Theme;
|
|
16
|
+
caps: TermCapabilities;
|
|
17
|
+
}): React.ReactElement;
|
|
18
|
+
/** Default comparator: `mapEntry` preserves identity for untouched entries (L2 / R3). */
|
|
19
|
+
export declare const InvocationCard: React.MemoExoticComponent<typeof InvocationCardImpl>;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Render a `user` entry that carries an invocation card (tui-invocation-cards).
|
|
4
|
+
*
|
|
5
|
+
* Like `UserEntry`, this renders CONTENT only: the role glyph, rail and spacing belong
|
|
6
|
+
* to `EntryFrame`, so the card stays "the user's turn" for density, turn counting and
|
|
7
|
+
* the settled boundary. The header text comes from `invocationHeaderSegments` and is
|
|
8
|
+
* never assembled here (RV-10); the row count it implies is `invocationCardRows`.
|
|
9
|
+
*/
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { Box, Text } from 'ink';
|
|
12
|
+
import { pickGlyphs } from '../glyphs.js';
|
|
13
|
+
import { invocationHeaderSegments, invocationHiddenText, invocationInputText, } from '../layout/invocation-card.js';
|
|
14
|
+
import { GUTTER_WIDTH } from '../layout/Gutter.js';
|
|
15
|
+
import { useTerminalSize } from '../layout/useTerminalSize.js';
|
|
16
|
+
import { fitCells } from '../layout/cells.js';
|
|
17
|
+
function toneColor(tone, theme) {
|
|
18
|
+
if (tone === 'label')
|
|
19
|
+
return theme.accent;
|
|
20
|
+
if (tone === 'name')
|
|
21
|
+
return theme.user;
|
|
22
|
+
return theme.muted;
|
|
23
|
+
}
|
|
24
|
+
function InvocationCardImpl({ card, theme, caps, }) {
|
|
25
|
+
const glyphs = pickGlyphs(caps);
|
|
26
|
+
const { cols } = useTerminalSize();
|
|
27
|
+
const usable = Math.max(1, cols - GUTTER_WIDTH);
|
|
28
|
+
const header = invocationHeaderSegments(card, glyphs, usable);
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { wrap: "truncate", children: header.map((part, index) => (_jsx(Text, { color: toneColor(part.tone, theme), bold: part.bold, children: part.text }, index))) }), card.input !== '' && _jsx(Text, { color: theme.user, children: invocationInputText(card) }), card.inputHidden > 0 && (_jsx(Text, { color: theme.muted, wrap: "truncate", children: invocationHiddenText(card, glyphs) })), card.note !== undefined && card.note !== '' && (_jsx(Text, { color: theme.muted, wrap: "truncate", children: fitCells(card.note, usable, glyphs.ellipsis) }))] }));
|
|
30
|
+
}
|
|
31
|
+
/** Default comparator: `mapEntry` preserves identity for untouched entries (L2 / R3). */
|
|
32
|
+
export const InvocationCard = React.memo(InvocationCardImpl);
|
|
@@ -24,6 +24,7 @@ import { formatDuration } from '../../agent/usage.js';
|
|
|
24
24
|
import { ToolPreview, bashBadge } from './ToolPreview.js';
|
|
25
25
|
import { DiffView } from './DiffView.js';
|
|
26
26
|
import { STALL_AFTER_MS } from '../../tools/tool-output-store.js';
|
|
27
|
+
import { shortDuration } from '../layout/status-layout.js';
|
|
27
28
|
/** Number of preview lines shown before collapse. */
|
|
28
29
|
const COLLAPSED_LINES = 8;
|
|
29
30
|
/** Shared empty slice, so an argument-less card allocates nothing per frame. */
|
|
@@ -41,8 +42,16 @@ export function statusColor(status, theme) {
|
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
/** One-line summary of the tool arguments for the card header. */
|
|
44
|
-
function summarizeArgs(name, ellipsis, args, argsRaw) {
|
|
45
|
+
function summarizeArgs(name, ellipsis, args, argsRaw, midDot = '-') {
|
|
45
46
|
if (args) {
|
|
47
|
+
// `t3 - up to 10m00s` reads better in the settled history row than
|
|
48
|
+
// `job=t3 timeoutSeconds=600` (extended-job-wait P2). Needs both fields.
|
|
49
|
+
if (name === 'job_wait' && typeof args.job === 'string') {
|
|
50
|
+
const seconds = args.timeoutSeconds;
|
|
51
|
+
return typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0
|
|
52
|
+
? `${args.job} ${midDot} up to ${shortDuration(seconds * 1000)}`
|
|
53
|
+
: args.job;
|
|
54
|
+
}
|
|
46
55
|
if (name === 'bash' && typeof args.command === 'string')
|
|
47
56
|
return args.command;
|
|
48
57
|
if (typeof args.path === 'string')
|
|
@@ -125,7 +134,7 @@ function ToolCardImpl(props) {
|
|
|
125
134
|
? `no output for ${Math.floor(stalledFor / 1000)}s`
|
|
126
135
|
: '(running)';
|
|
127
136
|
const badge = status === 'running' && !reducedMotion && caps.unicode ? (_jsxs(Text, { color: color, children: [_jsx(Spinner, { type: "dots" }), " running"] })) : (_jsxs(Text, { color: color, children: [status === 'running' ? `${glyphs.spinnerStill} ` : '', badgeLabel(name, status, previewText)] }));
|
|
128
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: name }), _jsxs(Text, { color: theme.muted, children: [" ", summarizeArgs(name, glyphs.ellipsis, args, argsRaw), " "] }), badge, durationMs !== undefined && (_jsxs(Text, { color: theme.muted, children: [" (", formatDuration(durationMs), ")"] }))] }), showBody && (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, isError ? theme.toolError : theme.border), children: showLive ? (_jsxs(_Fragment, { children: [visibleLive.map((row, i) => (
|
|
137
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: name }), _jsxs(Text, { color: theme.muted, children: [" ", summarizeArgs(name, glyphs.ellipsis, args, argsRaw, glyphs.midDot), " "] }), badge, durationMs !== undefined && (_jsxs(Text, { color: theme.muted, children: [" (", formatDuration(durationMs), ")"] }))] }), showBody && (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, isError ? theme.toolError : theme.border), children: showLive ? (_jsxs(_Fragment, { children: [visibleLive.map((row, i) => (
|
|
129
138
|
// The row is already terminal-safe and clipped at
|
|
130
139
|
// `LIVE_ROW_MAX_CHARS` by `sanitizeChunk`, so `wrap="truncate"`
|
|
131
140
|
// is an exact height rather than a hope — which is the coupling
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WaitCard - the five-row card a running `job_wait` is drawn as
|
|
3
|
+
* (extended-job-wait §3.8.2).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: every non-ASCII cell is a `pickGlyphs` result and every sentence the
|
|
6
|
+
* user did not get from the job itself is in `interactionCopy`.
|
|
7
|
+
*
|
|
8
|
+
* FIVE ROWS, IN EVERY STATE (I-10). A branch may change what a row SAYS, never how
|
|
9
|
+
* many rows there are: `estimateEntryRows` charges exactly `separation + 5`, and a
|
|
10
|
+
* card that is one row short makes the viewport jump while one row long makes the
|
|
11
|
+
* height estimate an under-count - the direction `virtual-window.ts` names unsafe.
|
|
12
|
+
* Every row is `wrap="truncate"` for the same reason.
|
|
13
|
+
*
|
|
14
|
+
* ONE CLOCK. Every number is a subtraction between an epoch stamped by
|
|
15
|
+
* `buildWaitView` and the `nowSec` the transcript already ticks at 1 Hz; there is
|
|
16
|
+
* no timer in this file, and the spinner is the app's shared factory, which this
|
|
17
|
+
* card REPLACES `ToolCard`'s badge with rather than adding to.
|
|
18
|
+
*/
|
|
19
|
+
import React from 'react';
|
|
20
|
+
import type { Theme } from '../theme.js';
|
|
21
|
+
import type { TermCapabilities } from '../capabilities.js';
|
|
22
|
+
import type { WaitView } from '../../proc/wait-view.js';
|
|
23
|
+
export interface WaitCardProps {
|
|
24
|
+
view: WaitView;
|
|
25
|
+
/** Wall-clock seconds from the transcript's 1 Hz clock; absent before the first tick. */
|
|
26
|
+
nowSec?: number | undefined;
|
|
27
|
+
reducedMotion?: boolean | undefined;
|
|
28
|
+
theme: Theme;
|
|
29
|
+
caps: TermCapabilities;
|
|
30
|
+
}
|
|
31
|
+
declare function WaitCardImpl(props: WaitCardProps): React.ReactElement;
|
|
32
|
+
/**
|
|
33
|
+
* `React.memo` with the DEFAULT comparator: the reducer writes a fresh `view`
|
|
34
|
+
* object once a second, which is exactly the one change that must re-render.
|
|
35
|
+
*/
|
|
36
|
+
export declare const WaitCard: React.MemoExoticComponent<typeof WaitCardImpl>;
|
|
37
|
+
export {};
|