@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +104 -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/invocation-card.d.ts +77 -0
  8. package/dist/agent/invocation-card.js +169 -0
  9. package/dist/agent/prompt-options.d.ts +6 -0
  10. package/dist/agent/queued-messages.d.ts +3 -0
  11. package/dist/agent/queued-messages.js +4 -1
  12. package/dist/agent/reducer.d.ts +25 -0
  13. package/dist/agent/reducer.js +43 -5
  14. package/dist/agent/user-attention.d.ts +17 -0
  15. package/dist/agent/user-attention.js +32 -0
  16. package/dist/cli.js +2 -3
  17. package/dist/commands/builtins.js +12 -0
  18. package/dist/commands/governance-command.js +13 -1
  19. package/dist/commands/index-build.d.ts +8 -0
  20. package/dist/commands/index-build.js +11 -0
  21. package/dist/commands/registry.d.ts +23 -0
  22. package/dist/commands/registry.js +20 -7
  23. package/dist/commands/skills.js +36 -2
  24. package/dist/commands/subagent-command.js +3 -1
  25. package/dist/commands/team-command.d.ts +6 -0
  26. package/dist/commands/team-command.js +40 -3
  27. package/dist/config/load.js +4 -25
  28. package/dist/config/schema.d.ts +4 -4
  29. package/dist/config/schema.js +7 -1
  30. package/dist/proc/limits.d.ts +27 -13
  31. package/dist/proc/limits.js +27 -13
  32. package/dist/proc/progress.d.ts +7 -0
  33. package/dist/proc/progress.js +35 -7
  34. package/dist/proc/prompt.js +7 -4
  35. package/dist/proc/supervisor.js +7 -1
  36. package/dist/proc/types.d.ts +6 -0
  37. package/dist/proc/wait-plan.d.ts +39 -0
  38. package/dist/proc/wait-plan.js +77 -0
  39. package/dist/proc/wait-view.d.ts +65 -0
  40. package/dist/proc/wait-view.js +66 -0
  41. package/dist/proc/wait.d.ts +36 -3
  42. package/dist/proc/wait.js +67 -4
  43. package/dist/session/checkpoint.js +2 -0
  44. package/dist/session/persist.js +29 -2
  45. package/dist/session/validate-session.js +7 -2
  46. package/dist/team/forced-mode.d.ts +19 -0
  47. package/dist/team/forced-mode.js +42 -20
  48. package/dist/tools/job-tools.d.ts +13 -2
  49. package/dist/tools/job-tools.js +156 -33
  50. package/dist/ui/ActivityLine.js +5 -2
  51. package/dist/ui/App.js +59 -6
  52. package/dist/ui/StatusBar.d.ts +1 -0
  53. package/dist/ui/Transcript.js +11 -1
  54. package/dist/ui/entries/InvocationCard.d.ts +20 -0
  55. package/dist/ui/entries/InvocationCard.js +32 -0
  56. package/dist/ui/entries/ToolCard.js +11 -2
  57. package/dist/ui/entries/WaitCard.d.ts +37 -0
  58. package/dist/ui/entries/WaitCard.js +92 -0
  59. package/dist/ui/interaction-copy.d.ts +2 -0
  60. package/dist/ui/interaction-copy.js +3 -0
  61. package/dist/ui/layout/invocation-card.d.ts +41 -0
  62. package/dist/ui/layout/invocation-card.js +91 -0
  63. package/dist/ui/layout/status-layout.d.ts +1 -0
  64. package/dist/ui/layout/status-layout.js +10 -1
  65. package/dist/ui/layout/virtual-window.js +18 -2
  66. package/dist/ui/layout/wait-bar.d.ts +34 -0
  67. package/dist/ui/layout/wait-bar.js +48 -0
  68. package/dist/ui/run-status-row.d.ts +4 -2
  69. package/dist/ui/scrollback/live-preview.js +12 -0
  70. package/dist/ui/transcript-text.js +4 -1
  71. package/package.json +2 -2
@@ -878,11 +878,11 @@ export interface JobsConfig {
878
878
  */
879
879
  enabled: boolean;
880
880
  /**
881
- * Seconds one `job_wait` blocks by default. Default 30, range 5..60.
881
+ * Seconds one `job_wait` blocks by default. Default 30, range 5..1200.
882
882
  *
883
- * ALSO CONVERGED AGAINST THE TOOL CEILING at load time and again at call time
884
- * (I-4): a wait that outlives the tool call hosting it would turn every long
885
- * job into `Command timed out`, which is the defect this feature removes.
883
+ * NOT CONVERGED AGAINST `toolTimeoutMs` ANY MORE (extended-job-wait I-4'): the
884
+ * executor gives `job_wait` its own ceiling and the idle watchdog is paused for
885
+ * the whole wait, so a long default no longer risks `Command timed out`.
886
886
  */
887
887
  waitSeconds: number;
888
888
  /**
@@ -8,6 +8,7 @@
8
8
  import { isAbsolute } from 'node:path';
9
9
  import { ABSOLUTE_MAX_OUTPUT_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_RETRY_POLICY, MIN_MAX_OUTPUT_TOKENS, RETRY_LIMITS, } from '@aragon-agent/core';
10
10
  import { clampLogLevel } from '../logging/levels.js';
11
+ import { PROC_LIMITS } from '../proc/limits.js';
11
12
  import { TEAM_LIMITS } from '../team/limits.js';
12
13
  import { DEFAULT_GOVERNANCE_CONFIG } from './governance-config.js';
13
14
  // Governance mode (governance-mode 11.1): defined in its own file so this one does not grow.
@@ -1013,7 +1014,12 @@ export const DEFAULT_JOBS_CONFIG = {
1013
1014
  persistLog: true,
1014
1015
  noticeSteer: true,
1015
1016
  };
1016
- const WAIT_SECONDS_RANGE = { min: 5, max: 60 };
1017
+ // DERIVED, NOT RESTATED (extended-job-wait I-2): a second copy of the ceiling is
1018
+ // how the config layer and the tool layer end up disagreeing without an error.
1019
+ const WAIT_SECONDS_RANGE = {
1020
+ min: PROC_LIMITS.waitMinSeconds,
1021
+ max: PROC_LIMITS.waitMaxSeconds,
1022
+ };
1017
1023
  const STALL_SECONDS_RANGE = { min: 30, max: 3600 };
1018
1024
  /**
1019
1025
  * THE single gate for every `jobs` read AND write, mirroring `clampBashConfig`
@@ -132,9 +132,26 @@ export declare const PROC_LIMITS: {
132
132
  * instead of a seventh `bash_output` poll.
133
133
  */
134
134
  readonly waitTailRows: 6;
135
- /** Seconds a single wait may block, as a STRUCTURAL floor and ceiling (`jobs.waitSeconds` is clamped into this range). */
135
+ /**
136
+ * Seconds a single wait may block, as a STRUCTURAL floor and ceiling (`jobs.waitSeconds` is clamped into this range).
137
+ *
138
+ * THE ONE AUTHORITY FOR THE CEILING (extended-job-wait I-2): the executor
139
+ * override (`JOB_WAIT_TOOL_CEILING_MS`), `WAIT_SECONDS_RANGE.max`, the tool
140
+ * description and the prompt block all derive from, or are asserted equal to,
141
+ * `waitMaxSeconds`. Changing it here and missing another reader is how a wait
142
+ * is silently cut short by a layer nobody remembered.
143
+ */
136
144
  readonly waitMinSeconds: 5;
137
- readonly waitMaxSeconds: 60;
145
+ readonly waitMaxSeconds: 1200;
146
+ /**
147
+ * Cadence of the in-wait heartbeat, in ms.
148
+ *
149
+ * ONE HZ because the reader needs "a few seconds ago" precision, not a stream:
150
+ * subscribing to the supervisor's 100 ms output coalescing would mean ~12000
151
+ * pointless view updates across a 20-minute wait. The same beat also carries the
152
+ * wall-clock deadline re-check (see `proc/wait.ts`).
153
+ */
154
+ readonly waitHeartbeatMs: 1000;
138
155
  /**
139
156
  * Characters one extracted progress line may occupy.
140
157
  *
@@ -183,18 +200,15 @@ export declare const BACKGROUND_SERVICES_BLOCK_VERSION = "v1";
183
200
  * (`bash.background` and `jobs.enabled`), so one version number covering both
184
201
  * would make "which block changed" unanswerable from a report.
185
202
  */
186
- export declare const JOBS_BLOCK_VERSION = "v1-2026-10";
203
+ export declare const JOBS_BLOCK_VERSION = "v2-2026-10";
187
204
  /**
188
- * Head-room a single wait leaves below the tool ceiling (I-4).
189
- *
190
- * `jobs.waitSeconds * 1000 + JOB_WAIT_MARGIN_MS <= toolTimeoutMs` is the whole
191
- * reason a wait never needs to pause the idle watchdog (D-3): a 60 s wait inside
192
- * a 180 s tool call in a 210 s idle window cannot be mistaken for a hang.
205
+ * Head-room the executor's own `job_wait` timer leaves above the longest wait.
193
206
  *
194
- * IT LIVES HERE RATHER THAN IN `config/schema.ts`, where the design document put
195
- * it, because the TOOL layer needs the same number for its call-time convergence
196
- * (AC-14) and nothing in `tools/` may import `config/` - a tool takes its policy
197
- * through closures. One constant, two readers, no second copy to drift. Recorded
198
- * in `docs/plans/long-task-jobs/spec.md` under `## 实施过程发现的方案缺陷` (I-2).
207
+ * It is a SECOND, INDEPENDENT clock, not a coupling to `toolTimeoutMs`
208
+ * (extended-job-wait I-4'): the idle watchdog is paused for the whole wait, so
209
+ * when `waitForJob` itself misbehaves this is the only thing left that can end
210
+ * the call. `toolTimeoutMs` no longer constrains a wait at all.
199
211
  */
200
212
  export declare const JOB_WAIT_MARGIN_MS = 30000;
213
+ /** The executor ceiling for `job_wait`: the longest wait plus its margin (1230 s). */
214
+ export declare const JOB_WAIT_TOOL_CEILING_MS: number;
@@ -139,9 +139,26 @@ export const PROC_LIMITS = {
139
139
  * instead of a seventh `bash_output` poll.
140
140
  */
141
141
  waitTailRows: 6,
142
- /** Seconds a single wait may block, as a STRUCTURAL floor and ceiling (`jobs.waitSeconds` is clamped into this range). */
142
+ /**
143
+ * Seconds a single wait may block, as a STRUCTURAL floor and ceiling (`jobs.waitSeconds` is clamped into this range).
144
+ *
145
+ * THE ONE AUTHORITY FOR THE CEILING (extended-job-wait I-2): the executor
146
+ * override (`JOB_WAIT_TOOL_CEILING_MS`), `WAIT_SECONDS_RANGE.max`, the tool
147
+ * description and the prompt block all derive from, or are asserted equal to,
148
+ * `waitMaxSeconds`. Changing it here and missing another reader is how a wait
149
+ * is silently cut short by a layer nobody remembered.
150
+ */
143
151
  waitMinSeconds: 5,
144
- waitMaxSeconds: 60,
152
+ waitMaxSeconds: 1200,
153
+ /**
154
+ * Cadence of the in-wait heartbeat, in ms.
155
+ *
156
+ * ONE HZ because the reader needs "a few seconds ago" precision, not a stream:
157
+ * subscribing to the supervisor's 100 ms output coalescing would mean ~12000
158
+ * pointless view updates across a 20-minute wait. The same beat also carries the
159
+ * wall-clock deadline re-check (see `proc/wait.ts`).
160
+ */
161
+ waitHeartbeatMs: 1000,
145
162
  /**
146
163
  * Characters one extracted progress line may occupy.
147
164
  *
@@ -190,18 +207,15 @@ export const BACKGROUND_SERVICES_BLOCK_VERSION = 'v1';
190
207
  * (`bash.background` and `jobs.enabled`), so one version number covering both
191
208
  * would make "which block changed" unanswerable from a report.
192
209
  */
193
- export const JOBS_BLOCK_VERSION = 'v1-2026-10';
210
+ export const JOBS_BLOCK_VERSION = 'v2-2026-10';
194
211
  /**
195
- * Head-room a single wait leaves below the tool ceiling (I-4).
196
- *
197
- * `jobs.waitSeconds * 1000 + JOB_WAIT_MARGIN_MS <= toolTimeoutMs` is the whole
198
- * reason a wait never needs to pause the idle watchdog (D-3): a 60 s wait inside
199
- * a 180 s tool call in a 210 s idle window cannot be mistaken for a hang.
212
+ * Head-room the executor's own `job_wait` timer leaves above the longest wait.
200
213
  *
201
- * IT LIVES HERE RATHER THAN IN `config/schema.ts`, where the design document put
202
- * it, because the TOOL layer needs the same number for its call-time convergence
203
- * (AC-14) and nothing in `tools/` may import `config/` - a tool takes its policy
204
- * through closures. One constant, two readers, no second copy to drift. Recorded
205
- * in `docs/plans/long-task-jobs/spec.md` under `## 实施过程发现的方案缺陷` (I-2).
214
+ * It is a SECOND, INDEPENDENT clock, not a coupling to `toolTimeoutMs`
215
+ * (extended-job-wait I-4'): the idle watchdog is paused for the whole wait, so
216
+ * when `waitForJob` itself misbehaves this is the only thing left that can end
217
+ * the call. `toolTimeoutMs` no longer constrains a wait at all.
206
218
  */
207
219
  export const JOB_WAIT_MARGIN_MS = 30_000;
220
+ /** The executor ceiling for `job_wait`: the longest wait plus its margin (1230 s). */
221
+ export const JOB_WAIT_TOOL_CEILING_MS = PROC_LIMITS.waitMaxSeconds * 1000 + JOB_WAIT_MARGIN_MS;
@@ -27,6 +27,13 @@ export interface JobProgress {
27
27
  source: 'row' | 'carry';
28
28
  /** Index into the scanned window; `-1` for the carry line. Diagnostics and tests only. */
29
29
  rowIndex: number;
30
+ /**
31
+ * 0..1 completion when the text carries a usable ratio, else `null`.
32
+ *
33
+ * A REPORT FOR THE PROGRESS BAR ONLY (extended-job-wait I-12): it never decides
34
+ * `status`, `[done]` or the spin guard.
35
+ */
36
+ fraction: number | null;
30
37
  }
31
38
  /**
32
39
  * Extract one line of progress from the tail of a job's output.
@@ -34,7 +34,7 @@ const PERCENT = /(\d{1,3}(?:\.\d+)?)\s*%/;
34
34
  const EPOCH_STEP = /\b(epoch|step)\s+(\d{1,7})\s*\/\s*(\d{1,7})/i;
35
35
  const BYTES = /(\d+(?:\.\d+)?)\s*(GiB|MiB|KiB|GB|MB|KB|gb|mb|kb|gib|mib)?\s*\/\s*(\d+(?:\.\d+)?)\s*(GiB|MiB|KiB|GB|MB|KB|gb|mb|kb|gib|mib)/;
36
36
  const RATIO = /[\[(]?\b(\d{1,7})\s*\/\s*(\d{1,7})\b[\])]?/;
37
- const ETA = /ETA[^0-9]{0,4}([0-9:]+[a-z]*)/i;
37
+ const ETA = /ETA[^0-9]{0,4}([0-9][0-9a-z:]*)/i;
38
38
  /** A row that is decoration, not information: `...`, `|`, `-`, `===`, a bare path separator. */
39
39
  const NOISE = /^[\s.\-_=|\\/+*#>~]+$/;
40
40
  /** Clip to the one-line budget, ASCII ellipsis, never mid-surrogate. */
@@ -48,6 +48,12 @@ function clip(text, limit = PROC_LIMITS.waitProgressChars) {
48
48
  return flat;
49
49
  return `${flat.slice(0, Math.max(0, limit - 3)).trimEnd()}...`;
50
50
  }
51
+ /** `cur / total` as a fraction, or `null` when the total says nothing (`0/0`). */
52
+ function ratioFraction(current, total) {
53
+ if (!Number.isFinite(current) || !Number.isFinite(total) || total <= 0)
54
+ return null;
55
+ return Math.min(1, Math.max(0, current / total));
56
+ }
51
57
  /**
52
58
  * The structured signals for one line, in table order.
53
59
  *
@@ -57,22 +63,42 @@ function clip(text, limit = PROC_LIMITS.waitProgressChars) {
57
63
  */
58
64
  function signalsOf(line) {
59
65
  const percent = PERCENT.exec(line);
60
- if (percent?.[1])
61
- return { text: `${percent[1]}%`, kind: 'percent' };
66
+ if (percent?.[1]) {
67
+ return {
68
+ text: `${percent[1]}%`,
69
+ kind: 'percent',
70
+ fraction: ratioFraction(Number(percent[1]), 100),
71
+ };
72
+ }
62
73
  const epoch = EPOCH_STEP.exec(line);
63
74
  if (epoch?.[1] && epoch[2] !== undefined && epoch[3] !== undefined) {
64
- return { text: `${epoch[1].toLowerCase()} ${epoch[2]}/${epoch[3]}`, kind: 'epoch' };
75
+ return {
76
+ text: `${epoch[1].toLowerCase()} ${epoch[2]}/${epoch[3]}`,
77
+ kind: 'epoch',
78
+ fraction: ratioFraction(Number(epoch[2]), Number(epoch[3])),
79
+ };
65
80
  }
66
81
  const bytes = BYTES.exec(line);
67
82
  if (bytes?.[1] && bytes[3]) {
68
83
  // Normalised to the TOTAL's unit, which is the one the user's own command
69
84
  // printed: `1.2/3.4 GiB` reads, `1.2 GiB/3.4 GiB` is noise. The numerator's
70
85
  // unit is optional because the common form writes it once, at the end.
71
- return { text: `${bytes[1]}/${bytes[3]} ${bytes[4]}`, kind: 'bytes' };
86
+ // TWO UNITS THAT DIFFER ARE NOT COMPARED (no unit conversion): `512 MiB/3 GiB`
87
+ // would need a table, and a wrong bar is worse than an honest absence.
88
+ const sameUnit = !bytes[2] || bytes[2].toLowerCase() === bytes[4]?.toLowerCase();
89
+ return {
90
+ text: `${bytes[1]}/${bytes[3]} ${bytes[4]}`,
91
+ kind: 'bytes',
92
+ fraction: sameUnit ? ratioFraction(Number(bytes[1]), Number(bytes[3])) : null,
93
+ };
72
94
  }
73
95
  const ratio = RATIO.exec(line);
74
96
  if (ratio?.[1] && ratio[2] !== undefined) {
75
- return { text: `${ratio[1]}/${ratio[2]}`, kind: 'ratio' };
97
+ return {
98
+ text: `${ratio[1]}/${ratio[2]}`,
99
+ kind: 'ratio',
100
+ fraction: ratioFraction(Number(ratio[1]), Number(ratio[2])),
101
+ };
76
102
  }
77
103
  return undefined;
78
104
  }
@@ -83,7 +109,7 @@ function withEta(signal, window) {
83
109
  for (const line of window) {
84
110
  const eta = ETA.exec(line);
85
111
  if (eta?.[1])
86
- return { text: `${signal.text} ETA ${eta[1]}`, kind: signal.kind };
112
+ return { ...signal, text: `${signal.text} ETA ${eta[1]}` };
87
113
  }
88
114
  return signal;
89
115
  }
@@ -120,6 +146,7 @@ export function extractProgress(rows, carry) {
120
146
  kind: signal.kind,
121
147
  source: candidate.source,
122
148
  rowIndex: candidate.rowIndex,
149
+ fraction: signal.fraction,
123
150
  };
124
151
  }
125
152
  // Fallback: the newest line that at least says something.
@@ -131,5 +158,6 @@ export function extractProgress(rows, carry) {
131
158
  kind: 'text',
132
159
  source: last.source,
133
160
  rowIndex: last.rowIndex,
161
+ fraction: null,
134
162
  };
135
163
  }
@@ -53,10 +53,13 @@ export function buildBackgroundJobsBlock() {
53
53
  '- Start one with bash({ command, background: true }). You get a job id: `t1` for a',
54
54
  ' task (it will finish) or `s1` for a service (it will not). The call returns in a',
55
55
  ' few seconds with the first lines of output.',
56
- '- Wait with job_wait({ job: "t1" }). Every wait is bounded (default 30s, at most',
57
- ' 60s) and returns one status line, one progress line, and only the output since',
58
- ' your last cursor. Call it again only after deciding something; do other work in',
59
- ' between if you have any.',
56
+ '- Wait with job_wait({ job: "t1" }). Every wait is bounded (default 30s, at most 20',
57
+ ' minutes = timeoutSeconds 1200) and returns one status line, one progress line, and',
58
+ ' only the output since your last cursor. Prefer ONE long wait over several short',
59
+ ' ones: follow the [next] suggestion or the job\'s ETA. A wait ends the moment the job',
60
+ ' finishes or the user sends a message. Use job "any" to wake on the first of several',
61
+ ' jobs, "all" for the last. Call it again only after deciding something; do other',
62
+ ' work in between if you have any.',
60
63
  '- Nothing but a bounded wait tells you a job finished. When one finishes while you',
61
64
  ' are busy, a <job_notices> block appears at the end of a tool result - that is the',
62
65
  ' notification, not an error.',
@@ -590,7 +590,13 @@ export class ProcSupervisor {
590
590
  // covered by AC-26 / AC-38 instead.
591
591
  lastOutputAt: r.lastOutputAt,
592
592
  ...(r.progress
593
- ? { progress: { text: r.progress.text, kind: r.progress.kind } }
593
+ ? {
594
+ progress: {
595
+ text: r.progress.text,
596
+ kind: r.progress.kind,
597
+ fraction: r.progress.fraction,
598
+ },
599
+ }
594
600
  : {}),
595
601
  ...(this.isStalled(r) ? { stalled: true } : {}),
596
602
  ...(r.logPath !== undefined ? { logPath: r.logPath } : {}),
@@ -33,6 +33,12 @@ export type JobKind = 'service' | 'task';
33
33
  export interface ServiceSnapshotProgress {
34
34
  text: string;
35
35
  kind: ProgressKind;
36
+ /**
37
+ * 0..1 completion for the wait card's bar, or `null`. REQUIRED, not optional:
38
+ * `snapshot()` hand-copies this literal, and a required field turns a forgotten
39
+ * copy into a compile error instead of a silently missing bar.
40
+ */
41
+ fraction: number | null;
36
42
  }
37
43
  export type ServiceStatus =
38
44
  /** Spawned, not yet ready, still alive. */
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Wait-length advice for `job_wait` (extended-job-wait §3.5).
3
+ *
4
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
5
+ *
6
+ * PURE: no IO, no clock. The output is TEXT for the model (the `[next]` line) and
7
+ * nothing else (I-13): the tool never refuses or rewrites a wait because it
8
+ * differs from the suggestion, for the reason `[guard]` only hints. The aim is
9
+ * fewer, longer waits - one 10-minute wait costs one LLM round trip, twenty
10
+ * 30-second waits cost twenty.
11
+ */
12
+ /** Sizes a suggestion snaps UP to, so the model sees round numbers it can reuse. */
13
+ export declare const NICE_WAIT_SECONDS: readonly number[];
14
+ /** The raw token after `ETA` in a progress line (`13m`, `01:23:45`), if any. */
15
+ export declare function extractEtaText(progressText: string | undefined): string | undefined;
16
+ /**
17
+ * Remaining seconds from the text after `ETA` in a progress line.
18
+ *
19
+ * Accepts `13m`, `45s`, `2h`, `13m20s`, `1h02m`, `01:23:45`, `12:30`, `0:45`.
20
+ * Anything else - including no `ETA` at all - is `undefined`; never throws.
21
+ */
22
+ export declare function parseEtaSeconds(progressText: string | undefined): number | undefined;
23
+ export interface SuggestWaitInput {
24
+ jobAgeMs: number;
25
+ /** The clamped request of the previous wait (not the time it actually ran). */
26
+ lastWaitSeconds: number;
27
+ /** Consecutive waits with no new row and no progress change. */
28
+ quietWaits: number;
29
+ etaSeconds?: number | undefined;
30
+ /** `jobs.waitSeconds`. */
31
+ configuredSeconds: number;
32
+ minSeconds: number;
33
+ maxSeconds: number;
34
+ }
35
+ /**
36
+ * The wait length to suggest next, deterministic and always within
37
+ * `[minSeconds, maxSeconds]`.
38
+ */
39
+ export declare function suggestWaitSeconds(input: SuggestWaitInput): number;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Wait-length advice for `job_wait` (extended-job-wait §3.5).
3
+ *
4
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
5
+ *
6
+ * PURE: no IO, no clock. The output is TEXT for the model (the `[next]` line) and
7
+ * nothing else (I-13): the tool never refuses or rewrites a wait because it
8
+ * differs from the suggestion, for the reason `[guard]` only hints. The aim is
9
+ * fewer, longer waits - one 10-minute wait costs one LLM round trip, twenty
10
+ * 30-second waits cost twenty.
11
+ */
12
+ /** Sizes a suggestion snaps UP to, so the model sees round numbers it can reuse. */
13
+ export const NICE_WAIT_SECONDS = [30, 60, 120, 180, 300, 420, 600, 900, 1200];
14
+ const UNIT_SECONDS = { h: 3600, m: 60, s: 1 };
15
+ /** The raw token after `ETA` in a progress line (`13m`, `01:23:45`), if any. */
16
+ export function extractEtaText(progressText) {
17
+ if (progressText === undefined)
18
+ return undefined;
19
+ return /ETA[^0-9]{0,4}([0-9][0-9a-z:]*)/i.exec(progressText)?.[1];
20
+ }
21
+ /**
22
+ * Remaining seconds from the text after `ETA` in a progress line.
23
+ *
24
+ * Accepts `13m`, `45s`, `2h`, `13m20s`, `1h02m`, `01:23:45`, `12:30`, `0:45`.
25
+ * Anything else - including no `ETA` at all - is `undefined`; never throws.
26
+ */
27
+ export function parseEtaSeconds(progressText) {
28
+ const value = extractEtaText(progressText)?.toLowerCase();
29
+ if (value === undefined)
30
+ return undefined;
31
+ if (value.includes(':')) {
32
+ const parts = value.split(':');
33
+ if (parts.length < 2 || parts.length > 3 || !parts.every((p) => /^\d{1,3}$/.test(p))) {
34
+ return undefined;
35
+ }
36
+ const numbers = parts.map(Number);
37
+ return numbers.length === 3
38
+ ? numbers[0] * 3600 + numbers[1] * 60 + numbers[2]
39
+ : numbers[0] * 60 + numbers[1];
40
+ }
41
+ const units = /^(?:\d+[hms])+$/.exec(value);
42
+ if (!units)
43
+ return undefined;
44
+ let total = 0;
45
+ for (const part of value.matchAll(/(\d+)([hms])/g)) {
46
+ total += Number(part[1]) * (UNIT_SECONDS[part[2]] ?? 0);
47
+ }
48
+ return total > 0 ? total : undefined;
49
+ }
50
+ /** Age ladder: the older the job, the longer one wait should be. */
51
+ function ladderSeconds(jobAgeMs) {
52
+ if (jobAgeMs < 2 * 60_000)
53
+ return 30;
54
+ if (jobAgeMs < 10 * 60_000)
55
+ return 120;
56
+ if (jobAgeMs < 30 * 60_000)
57
+ return 300;
58
+ return 600;
59
+ }
60
+ /**
61
+ * The wait length to suggest next, deterministic and always within
62
+ * `[minSeconds, maxSeconds]`.
63
+ */
64
+ export function suggestWaitSeconds(input) {
65
+ const finite = (n, fallback) => (Number.isFinite(n) ? n : fallback);
66
+ const ladder = ladderSeconds(finite(input.jobAgeMs, 0));
67
+ const last = finite(input.lastWaitSeconds, 0);
68
+ let candidate = input.quietWaits >= 2 ? Math.max(ladder, last * 2) : ladder;
69
+ // The ETA outranks the ladder: wait until just after the job says it is done.
70
+ // Early ETAs swing wildly, which is fine - the next result re-suggests.
71
+ if (input.etaSeconds !== undefined && Number.isFinite(input.etaSeconds)) {
72
+ candidate = input.etaSeconds + 15;
73
+ }
74
+ candidate = Math.max(candidate, finite(input.configuredSeconds, 0));
75
+ const snapped = NICE_WAIT_SECONDS.find((n) => n >= candidate) ?? input.maxSeconds;
76
+ return Math.max(input.minSeconds, Math.min(snapped, input.maxSeconds));
77
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The view model behind the wait card (extended-job-wait §3.8.1).
3
+ *
4
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
5
+ *
6
+ * PURE: `buildWaitView` reads nothing but its arguments. The card and the status
7
+ * bar are projections of the object it returns, which is why every clock in the
8
+ * UI can be a subtraction between an epoch stamped here and the one `nowSec` the
9
+ * renderer already has (no second timer, no second spinner).
10
+ */
11
+ import type { ProgressKind } from './progress.js';
12
+ import type { ServiceSnapshot, ServiceStatus } from './types.js';
13
+ export interface WaitView {
14
+ toolCallId: string;
15
+ /** `t3` for one job, `3 jobs` for several. */
16
+ jobLabel: string;
17
+ /** The single job's command, clipped; empty when several jobs are awaited. */
18
+ command: string;
19
+ /** Epoch ms the wait began. */
20
+ waitStartedAt: number;
21
+ /** The clamped budget of this wait, in ms. */
22
+ upToMs: number;
23
+ /** Epoch ms of the beat that produced this view; lets a pure consumer tell "how long" without a clock. */
24
+ beatAt: number;
25
+ mode: 'all' | 'any';
26
+ liveJobs: number;
27
+ /** Earliest `startedAt` among the targets (epoch ms). */
28
+ jobStartedAt: number;
29
+ /** The single job's status; `running` when several are awaited. */
30
+ status: ServiceStatus;
31
+ progress?: {
32
+ text: string;
33
+ kind: ProgressKind;
34
+ fraction: number | null;
35
+ };
36
+ /** Newest non-empty output row, sanitised and clipped. */
37
+ lastRow?: string;
38
+ /** Latest `lastOutputAt` among the targets (epoch ms). */
39
+ lastOutputAt: number;
40
+ stalled: boolean;
41
+ stallSeconds: number;
42
+ }
43
+ export type WaitEndReason = 'terminal' | 'timeout' | 'abort' | 'wake';
44
+ export type WaitEvent = {
45
+ type: 'begin' | 'update';
46
+ toolCallId: string;
47
+ view: WaitView;
48
+ } | {
49
+ type: 'end';
50
+ toolCallId: string;
51
+ reason: WaitEndReason;
52
+ };
53
+ export type WaitListener = (event: WaitEvent) => void;
54
+ export interface BuildWaitViewInput {
55
+ toolCallId: string;
56
+ /** The awaited jobs' current snapshots. Empty yields `undefined`. */
57
+ snapshots: readonly ServiceSnapshot[];
58
+ mode: 'all' | 'any';
59
+ waitStartedAt: number;
60
+ upToMs: number;
61
+ nowMs: number;
62
+ stallSeconds: number;
63
+ }
64
+ /** Project the awaited jobs into one card-sized view; `undefined` when none remain. */
65
+ export declare function buildWaitView(input: BuildWaitViewInput): WaitView | undefined;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The view model behind the wait card (extended-job-wait §3.8.1).
3
+ *
4
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
5
+ *
6
+ * PURE: `buildWaitView` reads nothing but its arguments. The card and the status
7
+ * bar are projections of the object it returns, which is why every clock in the
8
+ * UI can be a subtraction between an epoch stamped here and the one `nowSec` the
9
+ * renderer already has (no second timer, no second spinner).
10
+ */
11
+ import { isTerminalStatus } from './types.js';
12
+ const COMMAND_CHARS = 80;
13
+ const LAST_ROW_CHARS = 200;
14
+ function clip(text, limit) {
15
+ // Control characters become spaces so a stray escape can never reach the card.
16
+ const flat = text.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
17
+ return flat.length <= limit ? flat : `${flat.slice(0, Math.max(0, limit - 3)).trimEnd()}...`;
18
+ }
19
+ function newestRow(rows) {
20
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
21
+ const row = clip(rows[i] ?? '', LAST_ROW_CHARS);
22
+ if (row.length > 0)
23
+ return row;
24
+ }
25
+ return undefined;
26
+ }
27
+ /** Project the awaited jobs into one card-sized view; `undefined` when none remain. */
28
+ export function buildWaitView(input) {
29
+ const { snapshots } = input;
30
+ const first = snapshots[0];
31
+ if (first === undefined)
32
+ return undefined;
33
+ const single = snapshots.length === 1;
34
+ // The job that spoke last supplies the "latest output" row of a multi-job wait.
35
+ const freshest = snapshots.reduce((a, b) => (b.lastOutputAt > a.lastOutputAt ? b : a), first);
36
+ const lastOutputAt = freshest.lastOutputAt;
37
+ const lastRow = newestRow(freshest.rows);
38
+ const live = snapshots.filter((job) => !isTerminalStatus(job.status));
39
+ const progress = single ? first.progress : undefined;
40
+ const quietMs = Math.max(0, input.nowMs - lastOutputAt);
41
+ return {
42
+ toolCallId: input.toolCallId,
43
+ jobLabel: single ? first.id : `${snapshots.length} jobs`,
44
+ command: single ? clip(first.command, COMMAND_CHARS) : '',
45
+ waitStartedAt: input.waitStartedAt,
46
+ upToMs: input.upToMs,
47
+ beatAt: input.nowMs,
48
+ mode: input.mode,
49
+ liveJobs: live.length,
50
+ jobStartedAt: Math.min(...snapshots.map((job) => job.startedAt)),
51
+ status: single ? first.status : 'running',
52
+ ...(progress
53
+ ? {
54
+ progress: {
55
+ text: progress.text,
56
+ kind: progress.kind,
57
+ fraction: progress.fraction,
58
+ },
59
+ }
60
+ : {}),
61
+ ...(lastRow !== undefined ? { lastRow } : {}),
62
+ lastOutputAt,
63
+ stalled: live.length > 0 && quietMs > input.stallSeconds * 1000,
64
+ stallSeconds: input.stallSeconds,
65
+ };
66
+ }
@@ -23,33 +23,66 @@
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 type { ProcSupervisorPort, ServiceLogPage, ServiceSnapshot } from './types.js';
28
42
  /** The port surface this module needs. Deliberately narrower than the full port. */
29
43
  export type WaitPort = Pick<ProcSupervisorPort, 'get' | 'read' | 'subscribe'>;
44
+ /** Anything that can say "the user just spoke". Edge-triggered; see rule 5 above. */
45
+ export interface WakeSource {
46
+ /** Calls `listener` once per new user input after this call; returns the unsubscribe. */
47
+ subscribe(listener: () => void): () => void;
48
+ }
30
49
  export interface WaitForJobsRequest {
31
50
  /** Ids already resolved by the caller. An empty list returns immediately. */
32
51
  targets: readonly string[];
33
- /** Already clamped by the caller (`jobs.waitSeconds`, I-4 and `WAIT_SECONDS_RANGE`). */
52
+ /** Already clamped by the caller (`jobs.waitSeconds` and `WAIT_SECONDS_RANGE`). */
34
53
  timeoutMs: number;
35
54
  /** The tool call's `ctx.signal`. Aborting resolves the wait with `aborted: true`. */
36
55
  signal?: AbortSignal;
37
56
  /** Cursor from a previous `job_wait` / `bash_output`; omit for the whole tail. */
38
57
  since?: number;
58
+ /** 'all' (default): every target must end. 'any': the first terminal target ends the wait. */
59
+ mode?: 'all' | 'any';
60
+ /** User-input wake source; absent for a sub-agent, a test or an old caller. */
61
+ wakeOn?: WakeSource;
62
+ /** Beat that drives the live view and re-checks the deadline against `now()`. */
63
+ heartbeat?: {
64
+ everyMs: number;
65
+ onBeat: (nowMs: number) => void;
66
+ };
39
67
  }
40
68
  export interface JobWaitOutcome {
41
69
  /** One snapshot per target that still exists, in the requested order. */
42
70
  jobs: ServiceSnapshot[];
43
- /** Every target reached a terminal status. */
71
+ /**
72
+ * `mode: 'all'` (default): every target reached a terminal status.
73
+ * `mode: 'any'`: at least one did.
74
+ */
44
75
  terminal: boolean;
45
76
  waitedMs: number;
46
77
  timedOut: boolean;
47
78
  aborted: boolean;
79
+ /** The wait ended because the user sent a message. */
80
+ woken: boolean;
48
81
  /** Incremental page per target, read at settle time. */
49
82
  pages: ReadonlyMap<string, ServiceLogPage>;
50
83
  }
51
84
  /**
52
- * Wait up to `timeoutMs` for every target to reach a terminal status.
85
+ * Wait up to `timeoutMs` for the targets to reach a terminal status.
53
86
  *
54
87
  * Resolves - never rejects - and always after at most `timeoutMs`. `now` is
55
88
  * injectable so a test can assert `waitedMs` without depending on wall clock.