@aragon-agent/cli 0.6.46 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +53 -6
  3. package/dist/agent/coalesce.d.ts +4 -0
  4. package/dist/agent/coalesce.js +13 -0
  5. package/dist/agent/controller.d.ts +21 -0
  6. package/dist/agent/controller.js +56 -3
  7. package/dist/agent/reducer.d.ts +20 -0
  8. package/dist/agent/reducer.js +31 -2
  9. package/dist/agent/user-attention.d.ts +17 -0
  10. package/dist/agent/user-attention.js +32 -0
  11. package/dist/cli.js +2 -3
  12. package/dist/config/load.js +4 -25
  13. package/dist/config/schema.d.ts +4 -4
  14. package/dist/config/schema.js +7 -1
  15. package/dist/proc/limits.d.ts +27 -13
  16. package/dist/proc/limits.js +27 -13
  17. package/dist/proc/progress.d.ts +7 -0
  18. package/dist/proc/progress.js +35 -7
  19. package/dist/proc/prompt.js +7 -4
  20. package/dist/proc/supervisor.js +7 -1
  21. package/dist/proc/types.d.ts +6 -0
  22. package/dist/proc/wait-plan.d.ts +39 -0
  23. package/dist/proc/wait-plan.js +77 -0
  24. package/dist/proc/wait-view.d.ts +65 -0
  25. package/dist/proc/wait-view.js +66 -0
  26. package/dist/proc/wait.d.ts +36 -3
  27. package/dist/proc/wait.js +67 -4
  28. package/dist/session/checkpoint.js +2 -0
  29. package/dist/session/persist.js +19 -2
  30. package/dist/session/validate-session.js +3 -0
  31. package/dist/tools/job-tools.d.ts +13 -2
  32. package/dist/tools/job-tools.js +156 -33
  33. package/dist/ui/ActivityLine.js +5 -2
  34. package/dist/ui/App.js +34 -0
  35. package/dist/ui/StatusBar.d.ts +1 -0
  36. package/dist/ui/Transcript.js +7 -0
  37. package/dist/ui/entries/ToolCard.js +11 -2
  38. package/dist/ui/entries/WaitCard.d.ts +37 -0
  39. package/dist/ui/entries/WaitCard.js +92 -0
  40. package/dist/ui/interaction-copy.d.ts +2 -0
  41. package/dist/ui/interaction-copy.js +3 -0
  42. package/dist/ui/layout/status-layout.d.ts +1 -0
  43. package/dist/ui/layout/status-layout.js +10 -1
  44. package/dist/ui/layout/virtual-window.js +7 -1
  45. package/dist/ui/layout/wait-bar.d.ts +34 -0
  46. package/dist/ui/layout/wait-bar.js +48 -0
  47. package/dist/ui/run-status-row.d.ts +4 -2
  48. package/dist/ui/scrollback/live-preview.js +12 -0
  49. package/package.json +2 -2
package/dist/proc/wait.js CHANGED
@@ -23,6 +23,20 @@
23
23
  * nothing at all (the supervisor now emits `stopped` from it), and
24
24
  * `dispose()` clears its listener set without emitting; the self-timeout is
25
25
  * therefore a real second path, not belt-and-braces.
26
+ * 5. THE HEARTBEAT RE-CHECKS THE DEADLINE AGAINST `now()`, NOT AGAINST THE
27
+ * TIMER (extended-job-wait). A 20-minute wait spans a laptop lid: Node's
28
+ * monotonic `setTimeout` does not count the sleep, so a "10 minute" wait can
29
+ * run 40 minutes of wall clock. Each beat compares `now() - startedAt` with
30
+ * the budget and settles on the first beat after resume.
31
+ *
32
+ * THE WAKE SOURCE IS EDGE-TRIGGERED ONLY - there is no "is something pending"
33
+ * probe. The engine drains steering before EVERY tool execution
34
+ * (`core/src/engine/agent-loop.ts`), so input that arrived before this wait began
35
+ * has already been accepted; only input that arrives AFTER `subscribe()` needs to
36
+ * end the wait. Between the engine's decision to run `job_wait` and the
37
+ * `subscribe()` below there is no `await`, so no notification can fall into the
38
+ * gap. A level-triggered probe would add a stale-state hot loop (every wait
39
+ * returning at once) for no reachable gain.
26
40
  */
27
41
  import { isTerminalStatus } from './types.js';
28
42
  /** Read one bounded page per target; `read()` returns `undefined` for a vanished id. */
@@ -49,24 +63,41 @@ function allSettled(port, targets) {
49
63
  return snapshot === undefined || isTerminalStatus(snapshot.status);
50
64
  });
51
65
  }
66
+ /** Has any target ended? Same unknown-id rule as `allSettled`. */
67
+ function anySettled(port, targets) {
68
+ return targets.some((id) => {
69
+ const snapshot = port.get(id);
70
+ return snapshot === undefined || isTerminalStatus(snapshot.status);
71
+ });
72
+ }
52
73
  /**
53
- * Wait up to `timeoutMs` for every target to reach a terminal status.
74
+ * THE ONE PREDICATE for "is this wait over", shared by the entry fast path and the
75
+ * `finish` funnel. Using it in only one of them makes `mode: 'any'` subscribe and
76
+ * burn the whole timeout when a target had already ended.
77
+ */
78
+ function isDone(port, targets, mode) {
79
+ return mode === 'any' ? anySettled(port, targets) : allSettled(port, targets);
80
+ }
81
+ /**
82
+ * Wait up to `timeoutMs` for the targets to reach a terminal status.
54
83
  *
55
84
  * Resolves - never rejects - and always after at most `timeoutMs`. `now` is
56
85
  * injectable so a test can assert `waitedMs` without depending on wall clock.
57
86
  */
58
87
  export async function waitForJob(port, req, now = Date.now) {
59
88
  const { targets, signal } = req;
89
+ const mode = req.mode ?? 'all';
60
90
  const startedAt = now();
61
91
  // RULE 1. Nothing to wait for at all, or everything already ended: no
62
92
  // subscription, no timer, `waitedMs: 0`.
63
- if (targets.length === 0 || allSettled(port, targets)) {
93
+ if (targets.length === 0 || isDone(port, targets, mode)) {
64
94
  return {
65
95
  jobs: collect(port, targets),
66
96
  terminal: true,
67
97
  waitedMs: 0,
68
98
  timedOut: false,
69
99
  aborted: false,
100
+ woken: false,
70
101
  pages: readPages(port, targets, req.since),
71
102
  };
72
103
  }
@@ -79,6 +110,8 @@ export async function waitForJob(port, req, now = Date.now) {
79
110
  // from inside the listener instead of resolving the wait.
80
111
  let unsubscribe = () => { };
81
112
  let timer;
113
+ let beat;
114
+ let unwake = () => { };
82
115
  /** Assigned below; declared here so `finish` never reads an uninitialised binding. */
83
116
  let onAbort = () => { };
84
117
  // RULE 3: the one funnel. Idempotent, and it releases every reference before
@@ -88,15 +121,19 @@ export async function waitForJob(port, req, now = Date.now) {
88
121
  return;
89
122
  done = true;
90
123
  unsubscribe();
124
+ unwake();
91
125
  if (timer !== undefined)
92
126
  clearTimeout(timer);
127
+ if (beat !== undefined)
128
+ clearInterval(beat);
93
129
  signal?.removeEventListener('abort', onAbort);
94
130
  resolve({
95
131
  jobs: collect(port, targets),
96
- terminal: reason === 'terminal' || allSettled(port, targets),
132
+ terminal: reason === 'terminal' || isDone(port, targets, mode),
97
133
  waitedMs: now() - startedAt,
98
134
  timedOut: reason === 'timeout',
99
135
  aborted: reason === 'abort',
136
+ woken: reason === 'wake',
100
137
  pages: readPages(port, targets, req.since),
101
138
  });
102
139
  };
@@ -105,12 +142,38 @@ export async function waitForJob(port, req, now = Date.now) {
105
142
  return;
106
143
  if (!targets.includes(event.service.id))
107
144
  return;
108
- if (allSettled(port, targets))
145
+ if (isDone(port, targets, mode))
109
146
  finish('terminal');
110
147
  });
148
+ // The user spoke. Edge-triggered: only a notification after this line counts.
149
+ unwake = req.wakeOn?.subscribe(() => finish('wake')) ?? (() => { });
150
+ if (done) {
151
+ // A source that notifies synchronously inside `subscribe` already settled us.
152
+ unwake();
153
+ return;
154
+ }
111
155
  // RULE 2: `unref`, so a pending wait never keeps a quitting process alive.
112
156
  timer = setTimeout(() => finish('timeout'), Math.max(0, req.timeoutMs));
113
157
  timer.unref?.();
158
+ // RULE 5. Also the view's 1 Hz clock. A throwing `onBeat` is a renderer bug and
159
+ // must never kill a 20-minute wait.
160
+ if (req.heartbeat) {
161
+ const { everyMs, onBeat } = req.heartbeat;
162
+ beat = setInterval(() => {
163
+ const current = now();
164
+ if (current - startedAt >= req.timeoutMs) {
165
+ finish('timeout');
166
+ return;
167
+ }
168
+ try {
169
+ onBeat(current);
170
+ }
171
+ catch {
172
+ // Deliberately swallowed: see above.
173
+ }
174
+ }, Math.max(1, everyMs));
175
+ beat.unref?.();
176
+ }
114
177
  onAbort = () => finish('abort');
115
178
  // `{ once: true }` AND the explicit removal in `finish`: the option alone
116
179
  // leaves the listener registered on a signal that outlives this wait.
@@ -101,6 +101,8 @@ export function settleRunningToolCards(entries) {
101
101
  live: undefined,
102
102
  liveSeq: undefined,
103
103
  lastOutputAt: undefined,
104
+ wait: undefined,
105
+ waitSeq: undefined,
104
106
  };
105
107
  });
106
108
  return { entries: out, settled };
@@ -60,8 +60,20 @@ function defaultStamp() {
60
60
  */
61
61
  function stripLiveToolOutput(entries) {
62
62
  return entries.map((e) => e.kind === 'tool' &&
63
- (e.live !== undefined || e.liveSeq !== undefined || e.lastOutputAt !== undefined)
64
- ? { ...e, live: undefined, liveSeq: undefined, lastOutputAt: undefined }
63
+ (e.live !== undefined ||
64
+ e.liveSeq !== undefined ||
65
+ e.lastOutputAt !== undefined ||
66
+ e.wait !== undefined ||
67
+ e.waitSeq !== undefined)
68
+ ? {
69
+ ...e,
70
+ live: undefined,
71
+ liveSeq: undefined,
72
+ lastOutputAt: undefined,
73
+ // `wait` / `waitSeq` die with `live` (extended-job-wait I-9).
74
+ wait: undefined,
75
+ waitSeq: undefined,
76
+ }
65
77
  : e);
66
78
  }
67
79
  /**
@@ -146,6 +158,11 @@ export function saveSession(filePath, data) {
146
158
  */
147
159
  export function normalizeLoadedEntries(entries) {
148
160
  return entries.map((entry) => {
161
+ // A runtime-only wait view that reached disk anyway (an older or hand-edited
162
+ // file) describes a wait that died with the session: drop it (I-9).
163
+ if (entry.kind === 'tool' && (entry.wait !== undefined || entry.waitSeq !== undefined)) {
164
+ return { ...entry, wait: undefined, waitSeq: undefined };
165
+ }
149
166
  if (entry.kind === 'team' && entry.active) {
150
167
  return { ...entry, active: false, aborted: true };
151
168
  }
@@ -166,6 +166,9 @@ const ENTRY_FIELDS = {
166
166
  status: enumeration('pending', 'running', 'done', 'error'), preview: optional(string),
167
167
  durationMs: optional(number), isError: optional(boolean), patch: optional(patch),
168
168
  live: optional(array(string)), liveSeq: optional(number), lastOutputAt: optional(number),
169
+ // Runtime-only (extended-job-wait I-9): never written, but tolerated on read so a
170
+ // session saved mid-wait by any path still resumes; `normalizeLoadedEntries` drops it.
171
+ wait: optional(object), waitSeq: optional(number),
169
172
  },
170
173
  team: { dispatchId: string, requested: number, runs: array(subagent), aborted: boolean,
171
174
  durationMs: optional(number), active: boolean },
@@ -18,14 +18,14 @@
18
18
  import { type AgentTool } from '@aragon-agent/core';
19
19
  import { type JobNotice } from '../proc/notices.js';
20
20
  import type { JobKind, ProcSupervisorPort, ServiceStatus } from '../proc/types.js';
21
+ import { type WakeSource } from '../proc/wait.js';
22
+ import { type WaitListener } from '../proc/wait-view.js';
21
23
  export interface JobToolDeps {
22
24
  supervisor: ProcSupervisorPort;
23
25
  /** `cfg.jobs.waitSeconds`, read LIVE (same rule as `startupSettleMs`). */
24
26
  waitSeconds: () => number;
25
27
  /** `cfg.jobs.stallSeconds`, read LIVE; only the `[stalled]` sentence uses it. */
26
28
  stallSeconds: () => number;
27
- /** `cfg.toolTimeoutMs`, read LIVE: I-4's second convergence. */
28
- toolTimeoutMs: () => number;
29
29
  /** `PROC_LIMITS.maxConsecutiveWaits`. */
30
30
  maxConsecutiveWaits: number;
31
31
  /**
@@ -40,6 +40,17 @@ export interface JobToolDeps {
40
40
  kind: JobKind;
41
41
  status: ServiceStatus;
42
42
  } | undefined;
43
+ /** Live view of the wait (`begin` / 1 Hz `update` / `end`); absent for a sub-agent. */
44
+ onWait?: WaitListener;
45
+ /**
46
+ * Runs the whole wait with the idle watchdog paused (`controller.withPausedWatchdog`).
47
+ * Absent in a sub-agent or a fixture: the wait then runs unwrapped, as before.
48
+ */
49
+ holdWatchdog?: <T>(fn: () => Promise<T>) => Promise<T>;
50
+ /** Ends a wait when the user sends a message (edge-triggered; see `proc/wait.ts`). */
51
+ attention?: WakeSource;
52
+ /** Test seam; defaults to `Date.now`. */
53
+ now?: () => number;
43
54
  }
44
55
  export declare function makeJobWait(deps: JobToolDeps): AgentTool;
45
56
  export declare function makeJobList(deps: JobToolDeps): AgentTool;
@@ -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 { JOB_WAIT_MARGIN_MS, PROC_LIMITS } from '../proc/limits.js';
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 `[jobs.waitSeconds, 60]` and then into the tool
42
- * ceiling (I-4).
43
+ * Clamp `timeoutSeconds` into `[waitMinSeconds, waitMaxSeconds]`.
43
44
  *
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`".
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 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));
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
- tail.push(`[next] job_wait({ job: "${job.id}", since: ${cursor} }) to keep waiting, ` +
111
- 'or continue with other work.');
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 (5-60s), ' +
161
- 'so call it again only after deciding what to do.',
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: { type: 'string', description: '"t3", "s1", or "all".' },
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: '5-60. Omit for the configured default.',
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(_id, rawParams, ctx) {
202
+ async execute(toolCallId, rawParams, ctx) {
178
203
  const params = rawParams;
179
204
  const requested = String(params.job);
180
- const targets = requested === 'all'
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 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 } : {}),
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
- const nowMs = Date.now();
203
- const stallSeconds = deps.stallSeconds();
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
- memoryForJob.noChange = advanced ? 0 : memoryForJob.noChange + 1;
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` +
@@ -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
- return `Waiting on ${activity.wait.jobId} (up to ${activity.wait.upToSeconds}s)`;
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
@@ -1226,6 +1226,30 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1226
1226
  }
1227
1227
  });
1228
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]);
1229
1253
  // Event boundaries own start/end; ticks only refresh the live projection.
1230
1254
  useEffect(() => {
1231
1255
  if (state.status !== 'running')
@@ -3024,6 +3048,16 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
3024
3048
  jobId: typeof args.job === 'string' ? args.job : 'job',
3025
3049
  upToSeconds: Math.max(0, Math.round(requested)),
3026
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
+ }
3027
3061
  }
3028
3062
  break;
3029
3063
  }
@@ -25,6 +25,7 @@ export interface StatusBarProps {
25
25
  wait?: {
26
26
  jobId: string;
27
27
  upToSeconds: number;
28
+ elapsedSeconds?: number;
28
29
  };
29
30
  runOutcome?: "none" | "ended" | "interrupted" | "failed";
30
31
  reducedMotion?: boolean;
@@ -12,6 +12,7 @@ import { InvocationCard } from './entries/InvocationCard.js';
12
12
  import { QueuedEntry } from './entries/QueuedEntry.js';
13
13
  import { AssistantEntry } from './entries/AssistantEntry.js';
14
14
  import { ToolCard, statusColor, toolEntryGlyph } from './entries/ToolCard.js';
15
+ import { WaitCard } from './entries/WaitCard.js';
15
16
  import { TeamCard, teamCardColor } from './entries/TeamCard.js';
16
17
  import { GovernanceCard, governanceCardColor } from './entries/GovernanceCard.js';
17
18
  import { isLiveTeamCard, rosterCounts } from './team-status.js';
@@ -102,6 +103,12 @@ function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion,
102
103
  thinkingMs: entry.thinkingMs, theme: theme, caps: caps }));
103
104
  }
104
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
+ }
105
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,
106
113
  // `entry.live` AND NOT `entry.live ?? []` (P2-4): `ToolCard`'s default
107
114
  // comparator sees a fresh `[]` as a changed prop, which is why that
@@ -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