@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/config/schema.d.ts
CHANGED
|
@@ -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..
|
|
881
|
+
* Seconds one `job_wait` blocks by default. Default 30, range 5..1200.
|
|
882
882
|
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
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
|
/**
|
package/dist/config/schema.js
CHANGED
|
@@ -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
|
-
|
|
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`
|
package/dist/proc/limits.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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:
|
|
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 = "
|
|
203
|
+
export declare const JOBS_BLOCK_VERSION = "v2-2026-10";
|
|
187
204
|
/**
|
|
188
|
-
* Head-room
|
|
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
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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;
|
package/dist/proc/limits.js
CHANGED
|
@@ -139,9 +139,26 @@ export const PROC_LIMITS = {
|
|
|
139
139
|
* instead of a seventh `bash_output` poll.
|
|
140
140
|
*/
|
|
141
141
|
waitTailRows: 6,
|
|
142
|
-
/**
|
|
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:
|
|
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 = '
|
|
210
|
+
export const JOBS_BLOCK_VERSION = 'v2-2026-10';
|
|
194
211
|
/**
|
|
195
|
-
* Head-room
|
|
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
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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;
|
package/dist/proc/progress.d.ts
CHANGED
|
@@ -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.
|
package/dist/proc/progress.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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]}
|
|
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
|
}
|
package/dist/proc/prompt.js
CHANGED
|
@@ -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
|
-
'
|
|
58
|
-
' your last cursor.
|
|
59
|
-
'
|
|
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.',
|
package/dist/proc/supervisor.js
CHANGED
|
@@ -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
|
-
? {
|
|
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 } : {}),
|
package/dist/proc/types.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/proc/wait.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
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.
|