@aragon-agent/cli 0.6.31 → 0.6.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +2957 -2826
  3. package/dist/agent/controller.d.ts +46 -0
  4. package/dist/agent/controller.js +175 -7
  5. package/dist/agent/job-notices.d.ts +143 -0
  6. package/dist/agent/job-notices.js +182 -0
  7. package/dist/agent/reducer.d.ts +21 -1
  8. package/dist/agent/reducer.js +7 -0
  9. package/dist/agent/system-prompt.d.ts +12 -0
  10. package/dist/agent/system-prompt.js +2 -0
  11. package/dist/cli.js +232 -8
  12. package/dist/commands/builtins.js +23 -8
  13. package/dist/commands/perf.d.ts +54 -1
  14. package/dist/commands/perf.js +28 -4
  15. package/dist/config/cli-commands.d.ts +27 -0
  16. package/dist/config/cli-commands.js +54 -1
  17. package/dist/config/env.js +38 -0
  18. package/dist/config/load.js +50 -1
  19. package/dist/config/schema.d.ts +73 -0
  20. package/dist/config/schema.js +34 -0
  21. package/dist/config/store.js +14 -1
  22. package/dist/exec/events.d.ts +29 -3
  23. package/dist/exec/events.js +3 -12
  24. package/dist/exec/runner.d.ts +24 -0
  25. package/dist/exec/runner.js +79 -0
  26. package/dist/input/cursor-report.d.ts +61 -0
  27. package/dist/input/cursor-report.js +97 -0
  28. package/dist/input/stdin-filter.d.ts +22 -0
  29. package/dist/input/stdin-filter.js +48 -3
  30. package/dist/proc/limits.d.ts +76 -2
  31. package/dist/proc/limits.js +83 -2
  32. package/dist/proc/log-mirror.d.ts +59 -0
  33. package/dist/proc/log-mirror.js +203 -0
  34. package/dist/proc/log-ring.d.ts +22 -0
  35. package/dist/proc/log-ring.js +20 -1
  36. package/dist/proc/notices.d.ts +57 -0
  37. package/dist/proc/notices.js +101 -0
  38. package/dist/proc/progress.d.ts +40 -0
  39. package/dist/proc/progress.js +135 -0
  40. package/dist/proc/prompt.d.ts +24 -2
  41. package/dist/proc/prompt.js +51 -2
  42. package/dist/proc/supervisor.d.ts +35 -2
  43. package/dist/proc/supervisor.js +142 -43
  44. package/dist/proc/types.d.ts +89 -1
  45. package/dist/proc/wait.d.ts +57 -0
  46. package/dist/proc/wait.js +133 -0
  47. package/dist/session/persist.js +3 -1
  48. package/dist/session/validate-session.js +17 -0
  49. package/dist/tools/bash-tool.d.ts +12 -1
  50. package/dist/tools/bash-tool.js +100 -13
  51. package/dist/tools/index.d.ts +26 -2
  52. package/dist/tools/index.js +43 -2
  53. package/dist/tools/job-tools.d.ts +54 -0
  54. package/dist/tools/job-tools.js +329 -0
  55. package/dist/tools/proc-tools.d.ts +1 -1
  56. package/dist/tools/proc-tools.js +49 -17
  57. package/dist/ui/ActivityLine.js +7 -0
  58. package/dist/ui/App.js +64 -6
  59. package/dist/ui/ScrollbackShell.d.ts +30 -4
  60. package/dist/ui/ScrollbackShell.js +8 -3
  61. package/dist/ui/StatusBar.d.ts +5 -0
  62. package/dist/ui/StatusBar.js +1 -0
  63. package/dist/ui/Transcript.d.ts +14 -0
  64. package/dist/ui/Transcript.js +14 -2
  65. package/dist/ui/entries/ServiceCard.d.ts +17 -1
  66. package/dist/ui/entries/ServiceCard.js +39 -4
  67. package/dist/ui/layout/status-layout.d.ts +13 -1
  68. package/dist/ui/layout/status-layout.js +10 -0
  69. package/dist/ui/layout/virtual-window.js +14 -1
  70. package/dist/ui/run-status-row.d.ts +12 -0
  71. package/dist/ui/screen.d.ts +17 -4
  72. package/dist/ui/screen.js +14 -6
  73. package/dist/ui/scrollback/LiveTail.d.ts +64 -0
  74. package/dist/ui/scrollback/LiveTail.js +27 -0
  75. package/dist/ui/scrollback/band.d.ts +105 -0
  76. package/dist/ui/scrollback/band.js +109 -0
  77. package/dist/ui/scrollback/budget.d.ts +32 -3
  78. package/dist/ui/scrollback/budget.js +43 -6
  79. package/dist/ui/scrollback/canvas.d.ts +99 -0
  80. package/dist/ui/scrollback/canvas.js +113 -0
  81. package/dist/ui/scrollback/cursor-probe.d.ts +71 -0
  82. package/dist/ui/scrollback/cursor-probe.js +133 -0
  83. package/dist/ui/scrollback/limits.d.ts +49 -0
  84. package/dist/ui/scrollback/limits.js +49 -0
  85. package/dist/ui/scrollback/scrollback-writer.d.ts +104 -16
  86. package/dist/ui/scrollback/scrollback-writer.js +368 -82
  87. package/dist/ui/scrollback/use-scrollback-view.d.ts +11 -0
  88. package/dist/ui/scrollback/use-scrollback-view.js +37 -6
  89. package/dist/ui/scrollback/writer-shapes.d.ts +68 -2
  90. package/dist/ui/scrollback/writer-shapes.js +53 -8
  91. package/package.json +3 -3
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The optional on-disk log mirror — the escape hatch (long-task-jobs §4.8).
3
+ *
4
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
5
+ *
6
+ * WHY IT EXISTS: the ring holds 200 rows, so the `ModuleNotFoundError` from eight
7
+ * minutes ago is gone exactly when the model needs it. The answer is not a bigger
8
+ * ring (that is a bigger context) and not a new tool (the model already has
9
+ * `read_file` and `grep`): it is the full log on disk plus the PATH in the
10
+ * truncation note.
11
+ *
12
+ * THREE RULES, each of which is about not becoming a new failure mode:
13
+ *
14
+ * - ASYNC AND COALESCED. `appendFile` with a <=4 writes/second buffer, so a
15
+ * webpack build emitting thousands of lines cannot turn logging into the
16
+ * bottleneck of the build it is logging.
17
+ * - BOUNDED. `PROC_LIMITS.logMirrorMaxBytes` (2 MB) per job, then one
18
+ * `[mirror truncated]` line and silence.
19
+ * - FAILURES ARE DEBUG LOGS, NEVER THROWS. A full disk must not kill a training
20
+ * run; the ring, the tools and the job state machine do not depend on this
21
+ * module at all.
22
+ */
23
+ import { appendFile, appendFileSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
24
+ import { existsSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
26
+ import { getLogger } from '../logging/logger.js';
27
+ import { PROC_LIMITS } from './limits.js';
28
+ /**
29
+ * Delete all but the newest `PROC_LIMITS.mirrorRetainDirs` session directories.
30
+ *
31
+ * BEST EFFORT AND SILENT. This runs once per process at first use, and its whole
32
+ * job is to stop `~/.aragon-agent/jobs/` from growing without bound; a directory
33
+ * that cannot be read is not a reason to refuse to start.
34
+ */
35
+ export function pruneJobLogSessions(jobsRoot) {
36
+ try {
37
+ if (!existsSync(jobsRoot))
38
+ return;
39
+ const dirs = readdirSync(jobsRoot, { withFileTypes: true })
40
+ .filter((entry) => entry.isDirectory())
41
+ .map((entry) => {
42
+ const path = join(jobsRoot, entry.name);
43
+ try {
44
+ return { path, mtime: statSync(path).mtimeMs };
45
+ }
46
+ catch {
47
+ return { path, mtime: 0 };
48
+ }
49
+ })
50
+ .sort((a, b) => b.mtime - a.mtime);
51
+ for (const stale of dirs.slice(PROC_LIMITS.mirrorRetainDirs)) {
52
+ try {
53
+ rmSync(stale.path, { recursive: true, force: true });
54
+ }
55
+ catch (err) {
56
+ getLogger().debug('tool', 'job_mirror_prune_failed', {
57
+ path: stale.path,
58
+ error: err instanceof Error ? err.message : String(err),
59
+ });
60
+ }
61
+ }
62
+ }
63
+ catch (err) {
64
+ getLogger().debug('tool', 'job_mirror_prune_failed', {
65
+ root: jobsRoot,
66
+ error: err instanceof Error ? err.message : String(err),
67
+ });
68
+ }
69
+ }
70
+ /**
71
+ * The longest prefix of `text` whose UTF-8 encoding fits in `maxBytes`.
72
+ *
73
+ * THE CAP IS IN BYTES AND THE BUFFER IS IN CHARACTERS, so slicing by character
74
+ * count silently overshoots for any non-ASCII output - a log that writes more than
75
+ * its documented ceiling while claiming to enforce it. A partial trailing sequence
76
+ * is dropped rather than written as a replacement character, so `grep` sees a short
77
+ * final line instead of a corrupted one. The ASCII fast path costs one length check.
78
+ */
79
+ function truncateToBytes(text, maxBytes) {
80
+ if (Buffer.byteLength(text, 'utf8') <= maxBytes)
81
+ return text;
82
+ const decoded = Buffer.from(text, 'utf8').subarray(0, maxBytes).toString('utf8');
83
+ // A cut inside a multi-byte sequence decodes to U+FFFD, which must not be
84
+ // written: strip the tail of replacement characters the cut produced.
85
+ return decoded.replace(/\uFFFD+$/u, '');
86
+ }
87
+ export class JobLogMirror {
88
+ opts;
89
+ files = new Map();
90
+ buffers = new Map();
91
+ flushTimer;
92
+ pruned = false;
93
+ disposed = false;
94
+ constructor(opts) {
95
+ this.opts = opts;
96
+ }
97
+ /** Lazy creation: the directory appears only when a job actually prints. */
98
+ open(jobId) {
99
+ if (this.disposed || this.files.has(jobId))
100
+ return;
101
+ const path = join(this.opts.root(), `${jobId}.log`);
102
+ try {
103
+ mkdirSync(dirname(path), { recursive: true });
104
+ // SEEDED FROM DISK, because a resumed session reuses nothing but the
105
+ // directory name: without this the byte cap restarts at zero every launch.
106
+ const bytes = existsSync(path) ? statSync(path).size : 0;
107
+ this.files.set(jobId, { path, bytes, capped: bytes >= this.maxBytes() });
108
+ if (!this.pruned) {
109
+ this.pruned = true;
110
+ pruneJobLogSessions(dirname(this.opts.root()));
111
+ }
112
+ }
113
+ catch (err) {
114
+ getLogger().debug('tool', 'job_mirror_open_failed', {
115
+ job: jobId,
116
+ error: err instanceof Error ? err.message : String(err),
117
+ });
118
+ }
119
+ }
120
+ /** Buffer one chunk. Never throws, never awaits, never blocks the child. */
121
+ write(jobId, chunk) {
122
+ if (this.disposed)
123
+ return;
124
+ if (!this.files.has(jobId))
125
+ this.open(jobId);
126
+ const file = this.files.get(jobId);
127
+ if (!file || file.capped)
128
+ return;
129
+ this.buffers.set(jobId, (this.buffers.get(jobId) ?? '') + chunk);
130
+ this.scheduleFlush();
131
+ }
132
+ pathOf(jobId) {
133
+ return this.files.get(jobId)?.path;
134
+ }
135
+ /** Flush what is buffered and release the timer. Idempotent. */
136
+ dispose() {
137
+ this.disposed = true;
138
+ if (this.flushTimer)
139
+ clearTimeout(this.flushTimer);
140
+ this.flushTimer = undefined;
141
+ // SYNCHRONOUS AT TEARDOWN. The async path is right during a run (it must never
142
+ // block the child's stdout callback), but a dispose that returns before the
143
+ // last chunk has landed is a log with a hole in the one place it matters -
144
+ // the end of the job.
145
+ this.flushAll(true);
146
+ }
147
+ maxBytes() {
148
+ return this.opts.maxBytes ?? PROC_LIMITS.logMirrorMaxBytes;
149
+ }
150
+ flushMs() {
151
+ return this.opts.flushMs ?? PROC_LIMITS.mirrorFlushMs;
152
+ }
153
+ scheduleFlush() {
154
+ if (this.flushTimer)
155
+ return;
156
+ this.flushTimer = setTimeout(() => {
157
+ this.flushTimer = undefined;
158
+ this.flushAll();
159
+ }, this.flushMs());
160
+ this.flushTimer.unref?.();
161
+ }
162
+ flushAll(sync = false) {
163
+ for (const [jobId, chunk] of [...this.buffers]) {
164
+ this.buffers.delete(jobId);
165
+ this.flushOne(jobId, chunk, sync);
166
+ }
167
+ }
168
+ flushOne(jobId, chunk, sync) {
169
+ const file = this.files.get(jobId);
170
+ if (!file || chunk.length === 0)
171
+ return;
172
+ const room = this.maxBytes() - file.bytes;
173
+ const accepted = room <= 0 ? '' : truncateToBytes(chunk, room);
174
+ if (accepted.length > 0) {
175
+ file.bytes += Buffer.byteLength(accepted, 'utf8');
176
+ this.writeOut(file.path, accepted, jobId, sync);
177
+ }
178
+ if (file.bytes >= this.maxBytes() && !file.capped) {
179
+ file.capped = true;
180
+ this.writeOut(file.path, '\n[mirror truncated]\n', jobId, sync);
181
+ }
182
+ }
183
+ /** One place that writes, so the sync and async paths cannot drift. */
184
+ writeOut(path, text, jobId, sync) {
185
+ if (sync) {
186
+ try {
187
+ appendFileSync(path, text, 'utf8');
188
+ }
189
+ catch (err) {
190
+ getLogger().debug('tool', 'job_mirror_write_failed', {
191
+ job: jobId,
192
+ error: err instanceof Error ? err.message : String(err),
193
+ });
194
+ }
195
+ return;
196
+ }
197
+ appendFile(path, text, 'utf8', (err) => {
198
+ if (err) {
199
+ getLogger().debug('tool', 'job_mirror_write_failed', { job: jobId, error: err.message });
200
+ }
201
+ });
202
+ }
203
+ }
@@ -24,6 +24,17 @@ export interface LogRingPage {
24
24
  cursor: number;
25
25
  /** Rows between `since` and the first row returned were evicted. */
26
26
  truncated: boolean;
27
+ /**
28
+ * The not-yet-terminated line, or an empty string when the last chunk ended
29
+ * with a newline.
30
+ *
31
+ * THE ROWS ARRAY ENDS WITH THE CARRY LINE when one exists, so from the array
32
+ * alone a consumer cannot tell "the newest completed row" from "the progress bar
33
+ * being redrawn right now" - and that difference is the whole content of
34
+ * `JobProgress.source`. It is also what lets `job_wait` decline to reprint an
35
+ * unchanged, carriage-return-rewritten bar on every single wait (RV-P1-5).
36
+ */
37
+ carryRow: string;
27
38
  }
28
39
  /**
29
40
  * A per-service ring.
@@ -51,6 +62,17 @@ export declare class LogRing {
51
62
  append(chunk: string): readonly string[];
52
63
  /** The last `n` display rows, in-progress line included when non-empty. */
53
64
  tail(n: number): readonly string[];
65
+ /**
66
+ * The not-yet-terminated line, exactly as the display would show it.
67
+ *
68
+ * THE PROGRESS EXTRACTOR NEEDS THIS SEPARATELY FROM `tail()`, and that is the
69
+ * only reason it is public: `tail()` appends the carry line as its last
70
+ * element, so a consumer that is handed the array cannot tell whether that last
71
+ * element is a completed row or the bar currently being redrawn. A tqdm run and
72
+ * a silent job would look identical, and the difference is precisely the
73
+ * information `JobProgress.source` carries.
74
+ */
75
+ get carryRow(): string;
54
76
  /** Every completed row plus the in-progress line. */
55
77
  all(): readonly string[];
56
78
  /** Completed rows ever appended — the paging cursor and the revision term. */
@@ -65,6 +65,19 @@ export class LogRing {
65
65
  const all = live.length > 0 ? [...this.rows, live] : this.rows;
66
66
  return Object.freeze(all.length > n ? all.slice(-n) : [...all]);
67
67
  }
68
+ /**
69
+ * The not-yet-terminated line, exactly as the display would show it.
70
+ *
71
+ * THE PROGRESS EXTRACTOR NEEDS THIS SEPARATELY FROM `tail()`, and that is the
72
+ * only reason it is public: `tail()` appends the carry line as its last
73
+ * element, so a consumer that is handed the array cannot tell whether that last
74
+ * element is a completed row or the bar currently being redrawn. A tqdm run and
75
+ * a silent job would look identical, and the difference is precisely the
76
+ * information `JobProgress.source` carries.
77
+ */
78
+ get carryRow() {
79
+ return toDisplayRow(this.carry);
80
+ }
68
81
  /** Every completed row plus the in-progress line. */
69
82
  all() {
70
83
  return this.tail(this.capacity + 1);
@@ -87,7 +100,12 @@ export class LogRing {
87
100
  page(since) {
88
101
  const oldest = this.seen - this.rows.length;
89
102
  if (since === undefined || since < 0) {
90
- return { rows: this.all(), cursor: this.seen, truncated: this.truncated };
103
+ return {
104
+ rows: this.all(),
105
+ cursor: this.seen,
106
+ truncated: this.truncated,
107
+ carryRow: toDisplayRow(this.carry),
108
+ };
91
109
  }
92
110
  const from = Math.max(since, oldest);
93
111
  const rows = this.rows.slice(from - oldest);
@@ -97,6 +115,7 @@ export class LogRing {
97
115
  rows: Object.freeze(withLive),
98
116
  cursor: this.seen,
99
117
  truncated: since < oldest,
118
+ carryRow: live,
100
119
  };
101
120
  }
102
121
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The completion-notice queue and its one renderer (long-task-jobs §4.6).
3
+ *
4
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
5
+ *
6
+ * A NOTICE IS NOT A MESSAGE. It is a bounded, factual sentence about a job that
7
+ * ended while the model was busy, and it reaches the model through three
8
+ * mutually exclusive paths that all take from THIS queue - the tail of the next
9
+ * tool result (pull), a `steer()` at the one safe point (push), and a
10
+ * `steer()` before the next user turn (fill). One `drain()` behind all three is
11
+ * what makes "at most once" a property of the data structure rather than a
12
+ * promise in three call sites (I-6).
13
+ *
14
+ * THE QUEUE IS BOUNDED AND DEDUPLICATED BY ID. A job can only end once, so a
15
+ * second notice for the same id is always the same fact restated - which is what
16
+ * makes the relay's re-queueing after a queue wipe idempotent (I-11).
17
+ */
18
+ import type { JobKind, ServiceStatus } from './types.js';
19
+ export interface JobNotice {
20
+ /** The job: `t3` / `s1`. */
21
+ id: string;
22
+ kind: JobKind;
23
+ /** Always terminal - a notice is never queued for a running job. */
24
+ status: Extract<ServiceStatus, 'exited' | 'failed' | 'stopped'>;
25
+ exitCode: number | null;
26
+ signal: NodeJS.Signals | null;
27
+ elapsedMs: number;
28
+ /** The last extracted progress line, when there was one. */
29
+ progressText?: string;
30
+ /** At most `PROC_LIMITS.noticeTailRows`, and only for a failure. */
31
+ tailRows: readonly string[];
32
+ at: number;
33
+ }
34
+ /**
35
+ * Insertion-ordered, deduplicated by id, bounded by `PROC_LIMITS.maxNotices`.
36
+ *
37
+ * INSERTION ORDER IS KEPT because "the oldest thing I have not told you about"
38
+ * is the one that has been waiting longest; the RENDERER then puts failures
39
+ * first, which is a display decision rather than a queue decision.
40
+ */
41
+ export declare class JobNoticeQueue {
42
+ private readonly entries;
43
+ /** Same id twice keeps ONE entry: a job's terminal fact does not change. */
44
+ push(notice: JobNotice): void;
45
+ /** Take everything and reset. The one way a notice leaves this object. */
46
+ drain(): JobNotice[];
47
+ pending(): number;
48
+ clear(): void;
49
+ }
50
+ /**
51
+ * Render the `<job_notices>` block, or `''` when there is nothing to say.
52
+ *
53
+ * EMPTY IN, EMPTY OUT, and the wrapper that appends this to a tool result relies
54
+ * on it: an unconditional block would add two lines to every tool call in every
55
+ * session, which is the context growth this feature exists to avoid.
56
+ */
57
+ export declare function renderJobNotices(notices: readonly JobNotice[]): string;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The completion-notice queue and its one renderer (long-task-jobs §4.6).
3
+ *
4
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
5
+ *
6
+ * A NOTICE IS NOT A MESSAGE. It is a bounded, factual sentence about a job that
7
+ * ended while the model was busy, and it reaches the model through three
8
+ * mutually exclusive paths that all take from THIS queue - the tail of the next
9
+ * tool result (pull), a `steer()` at the one safe point (push), and a
10
+ * `steer()` before the next user turn (fill). One `drain()` behind all three is
11
+ * what makes "at most once" a property of the data structure rather than a
12
+ * promise in three call sites (I-6).
13
+ *
14
+ * THE QUEUE IS BOUNDED AND DEDUPLICATED BY ID. A job can only end once, so a
15
+ * second notice for the same id is always the same fact restated - which is what
16
+ * makes the relay's re-queueing after a queue wipe idempotent (I-11).
17
+ */
18
+ import { formatDuration } from '../agent/usage.js';
19
+ import { PROC_LIMITS } from './limits.js';
20
+ /**
21
+ * Insertion-ordered, deduplicated by id, bounded by `PROC_LIMITS.maxNotices`.
22
+ *
23
+ * INSERTION ORDER IS KEPT because "the oldest thing I have not told you about"
24
+ * is the one that has been waiting longest; the RENDERER then puts failures
25
+ * first, which is a display decision rather than a queue decision.
26
+ */
27
+ export class JobNoticeQueue {
28
+ entries = new Map();
29
+ /** Same id twice keeps ONE entry: a job's terminal fact does not change. */
30
+ push(notice) {
31
+ if (this.entries.has(notice.id))
32
+ return;
33
+ this.entries.set(notice.id, notice);
34
+ // Oldest first out when the bound is exceeded. Dropping the newest instead
35
+ // would bias the block towards facts the model already knows.
36
+ while (this.entries.size > PROC_LIMITS.maxNotices) {
37
+ const oldest = this.entries.keys().next();
38
+ if (oldest.done === true)
39
+ break;
40
+ this.entries.delete(oldest.value);
41
+ }
42
+ }
43
+ /** Take everything and reset. The one way a notice leaves this object. */
44
+ drain() {
45
+ if (this.entries.size === 0)
46
+ return [];
47
+ const out = [...this.entries.values()];
48
+ this.entries.clear();
49
+ return out;
50
+ }
51
+ pending() {
52
+ return this.entries.size;
53
+ }
54
+ clear() {
55
+ this.entries.clear();
56
+ }
57
+ }
58
+ /** `exited code 0` / `exited signal SIGKILL` / `stopped` - the same grammar as a card. */
59
+ function outcomeOf(notice) {
60
+ if (notice.status === 'stopped')
61
+ return 'stopped';
62
+ if (notice.exitCode === null) {
63
+ return notice.signal === null ? notice.status : `${notice.status} on ${notice.signal}`;
64
+ }
65
+ return `${notice.status} code ${notice.exitCode}`;
66
+ }
67
+ /** Failures first, then non-zero exits, then the rest - in queue order within a group. */
68
+ function rank(notice) {
69
+ if (notice.status === 'failed')
70
+ return 0;
71
+ if (notice.status === 'exited' && notice.exitCode !== 0)
72
+ return 1;
73
+ return 2;
74
+ }
75
+ /**
76
+ * Render the `<job_notices>` block, or `''` when there is nothing to say.
77
+ *
78
+ * EMPTY IN, EMPTY OUT, and the wrapper that appends this to a tool result relies
79
+ * on it: an unconditional block would add two lines to every tool call in every
80
+ * session, which is the context growth this feature exists to avoid.
81
+ */
82
+ export function renderJobNotices(notices) {
83
+ if (notices.length === 0)
84
+ return '';
85
+ const sorted = [...notices].sort((a, b) => rank(a) - rank(b));
86
+ const lines = ['<job_notices>'];
87
+ for (const notice of sorted.slice(0, PROC_LIMITS.maxNotices)) {
88
+ const progress = notice.progressText ? ` (${notice.progressText} at last check)` : '';
89
+ lines.push(`${notice.kind} ${notice.id} ${outcomeOf(notice)} after ` +
90
+ `${formatDuration(notice.elapsedMs)}${progress}`);
91
+ // ONLY A FAILURE EARNS A TAIL: a successful job's last rows are noise, and
92
+ // two lines per success is the difference between a bounded block and a log.
93
+ if (rank(notice) < 2) {
94
+ for (const row of notice.tailRows.slice(0, PROC_LIMITS.noticeTailRows)) {
95
+ lines.push(` last: ${row}`);
96
+ }
97
+ }
98
+ }
99
+ lines.push('</job_notices>');
100
+ return lines.join('\n');
101
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Progress extraction for a long-running job (long-task-jobs §4.5).
3
+ *
4
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
5
+ *
6
+ * PURE: no IO, no clock, no process. That is what lets `progress.test.ts`
7
+ * enumerate the whole table, and it is why the cost of a progress line is one
8
+ * function call inside an already-coalesced output tick rather than a second
9
+ * subscription to the child's stdio.
10
+ *
11
+ * THE POINT IS EXTRACTION, NOT TRANSPORT. The alternative - handing the model
12
+ * the last 200 rows so it can notice the `47%` itself - costs 4k-8k tokens per
13
+ * check and buries the one number that matters. Here the ring keeps the rows and
14
+ * the model gets `47%`.
15
+ *
16
+ * NOTHING DECIDES ANYTHING FROM THIS (R-4). Progress is a REPORT. `status`,
17
+ * `exitCode` and `signal` are the only things a job's outcome is ever read from,
18
+ * so a false `100%` in `[100%] built target foo` costs one confusing word rather
19
+ * than a wrong conclusion.
20
+ */
21
+ export type ProgressKind = 'percent' | 'ratio' | 'epoch' | 'bytes' | 'text';
22
+ export interface JobProgress {
23
+ /** Already clipped to `PROC_LIMITS.waitProgressChars`. */
24
+ text: string;
25
+ kind: ProgressKind;
26
+ /** `carry` = the not-yet-newline-terminated line, where every progress bar lives. */
27
+ source: 'row' | 'carry';
28
+ /** Index into the scanned window; `-1` for the carry line. Diagnostics and tests only. */
29
+ rowIndex: number;
30
+ }
31
+ /**
32
+ * Extract one line of progress from the tail of a job's output.
33
+ *
34
+ * `rows` is the scan window (the caller passes at most
35
+ * `PROC_LIMITS.progressScanRows`); `carry` is the not-yet-terminated line, which
36
+ * is where a `\r`-rewritten bar keeps its whole life. Returns `undefined` when
37
+ * nothing but decoration has been printed - an honest "there is no progress to
38
+ * show", which the callers render as absence rather than as a fake `0%`.
39
+ */
40
+ export declare function extractProgress(rows: readonly string[], carry: string): JobProgress | undefined;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Progress extraction for a long-running job (long-task-jobs §4.5).
3
+ *
4
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
5
+ *
6
+ * PURE: no IO, no clock, no process. That is what lets `progress.test.ts`
7
+ * enumerate the whole table, and it is why the cost of a progress line is one
8
+ * function call inside an already-coalesced output tick rather than a second
9
+ * subscription to the child's stdio.
10
+ *
11
+ * THE POINT IS EXTRACTION, NOT TRANSPORT. The alternative - handing the model
12
+ * the last 200 rows so it can notice the `47%` itself - costs 4k-8k tokens per
13
+ * check and buries the one number that matters. Here the ring keeps the rows and
14
+ * the model gets `47%`.
15
+ *
16
+ * NOTHING DECIDES ANYTHING FROM THIS (R-4). Progress is a REPORT. `status`,
17
+ * `exitCode` and `signal` are the only things a job's outcome is ever read from,
18
+ * so a false `100%` in `[100%] built target foo` costs one confusing word rather
19
+ * than a wrong conclusion.
20
+ */
21
+ import { PROC_LIMITS } from './limits.js';
22
+ /**
23
+ * The ordered table (first match wins PER ROW, newest row scanned first).
24
+ *
25
+ * BYTES SITS ABOVE THE GENERIC RATIO, and that rewrites the order the design
26
+ * document lists. `1.2/3.4 GiB` satisfies the generic ratio pattern as `2/3`
27
+ * (the digits either side of the slash, with `.` a word boundary), so a table
28
+ * that tried `\d+/\d+` first would report every download as `2/3`. The document's
29
+ * constraint - ordered, anchored, single-line, no backtracking blowup - is kept;
30
+ * only the relative order of those two rows changed. Recorded in
31
+ * `docs/plans/long-task-jobs/spec.md` under `## 实施过程发现的方案缺陷` (I-1).
32
+ */
33
+ const PERCENT = /(\d{1,3}(?:\.\d+)?)\s*%/;
34
+ const EPOCH_STEP = /\b(epoch|step)\s+(\d{1,7})\s*\/\s*(\d{1,7})/i;
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
+ 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;
38
+ /** A row that is decoration, not information: `...`, `|`, `-`, `===`, a bare path separator. */
39
+ const NOISE = /^[\s.\-_=|\\/+*#>~]+$/;
40
+ /** Clip to the one-line budget, ASCII ellipsis, never mid-surrogate. */
41
+ function clip(text, limit = PROC_LIMITS.waitProgressChars) {
42
+ // VERTICAL whitespace only is collapsed: a stray `\r` or `\n` would break the
43
+ // one-row contract, while the DOUBLE SPACE before `ETA` is how the row is meant
44
+ // to read (`47% ETA 13m`) and collapsing it would make the table's rendering
45
+ // depend on which function ran last.
46
+ const flat = text.replace(/[\r\n\t]+/g, ' ').trim();
47
+ if (flat.length <= limit)
48
+ return flat;
49
+ return `${flat.slice(0, Math.max(0, limit - 3)).trimEnd()}...`;
50
+ }
51
+ /**
52
+ * The structured signals for one line, in table order.
53
+ *
54
+ * ORDER WITHIN A LINE IS THE TABLE'S ORDER; the caller decides which LINE wins
55
+ * (newest first), because a live `47%` must beat a stale `100%` from the phase
56
+ * above it.
57
+ */
58
+ function signalsOf(line) {
59
+ const percent = PERCENT.exec(line);
60
+ if (percent?.[1])
61
+ return { text: `${percent[1]}%`, kind: 'percent' };
62
+ const epoch = EPOCH_STEP.exec(line);
63
+ if (epoch?.[1] && epoch[2] !== undefined && epoch[3] !== undefined) {
64
+ return { text: `${epoch[1].toLowerCase()} ${epoch[2]}/${epoch[3]}`, kind: 'epoch' };
65
+ }
66
+ const bytes = BYTES.exec(line);
67
+ if (bytes?.[1] && bytes[3]) {
68
+ // Normalised to the TOTAL's unit, which is the one the user's own command
69
+ // printed: `1.2/3.4 GiB` reads, `1.2 GiB/3.4 GiB` is noise. The numerator's
70
+ // unit is optional because the common form writes it once, at the end.
71
+ return { text: `${bytes[1]}/${bytes[3]} ${bytes[4]}`, kind: 'bytes' };
72
+ }
73
+ const ratio = RATIO.exec(line);
74
+ if (ratio?.[1] && ratio[2] !== undefined) {
75
+ return { text: `${ratio[1]}/${ratio[2]}`, kind: 'ratio' };
76
+ }
77
+ return undefined;
78
+ }
79
+ /** Append the ETA once, when the window names one. */
80
+ function withEta(signal, window) {
81
+ if (signal.text.includes('ETA'))
82
+ return signal;
83
+ for (const line of window) {
84
+ const eta = ETA.exec(line);
85
+ if (eta?.[1])
86
+ return { text: `${signal.text} ETA ${eta[1]}`, kind: signal.kind };
87
+ }
88
+ return signal;
89
+ }
90
+ /**
91
+ * Extract one line of progress from the tail of a job's output.
92
+ *
93
+ * `rows` is the scan window (the caller passes at most
94
+ * `PROC_LIMITS.progressScanRows`); `carry` is the not-yet-terminated line, which
95
+ * is where a `\r`-rewritten bar keeps its whole life. Returns `undefined` when
96
+ * nothing but decoration has been printed - an honest "there is no progress to
97
+ * show", which the callers render as absence rather than as a fake `0%`.
98
+ */
99
+ export function extractProgress(rows, carry) {
100
+ const displayCarry = carry.trim();
101
+ // NEWEST FIRST, INSIDE ONE WINDOW. The carry line is the newest of all by
102
+ // definition, so it is tested before every completed row.
103
+ const candidates = [];
104
+ if (displayCarry.length > 0 && !NOISE.test(displayCarry)) {
105
+ candidates.push({ line: displayCarry, source: 'carry', rowIndex: -1 });
106
+ }
107
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
108
+ const line = rows[i] ?? '';
109
+ if (line.trim().length === 0 || NOISE.test(line))
110
+ continue;
111
+ candidates.push({ line, source: 'row', rowIndex: i });
112
+ }
113
+ const window = candidates.map((c) => c.line);
114
+ for (const candidate of candidates) {
115
+ const signal = signalsOf(candidate.line);
116
+ if (!signal)
117
+ continue;
118
+ return {
119
+ text: clip(withEta(signal, window).text),
120
+ kind: signal.kind,
121
+ source: candidate.source,
122
+ rowIndex: candidate.rowIndex,
123
+ };
124
+ }
125
+ // Fallback: the newest line that at least says something.
126
+ const last = candidates[0];
127
+ if (!last)
128
+ return undefined;
129
+ return {
130
+ text: clip(last.line),
131
+ kind: 'text',
132
+ source: last.source,
133
+ rowIndex: last.rowIndex,
134
+ };
135
+ }
@@ -19,6 +19,28 @@
19
19
  * `BACKGROUND_SERVICES_BLOCK_VERSION` exists so a change to this wording is
20
20
  * greppable from a behaviour report.
21
21
  */
22
- import { BACKGROUND_SERVICES_BLOCK_VERSION } from './limits.js';
23
- export { BACKGROUND_SERVICES_BLOCK_VERSION };
22
+ /**
23
+ * The `<background_jobs>` system-prompt block (long-task-jobs §12).
24
+ *
25
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope, and this string
26
+ * also reaches a model running in a `cmd.exe` terminal.
27
+ *
28
+ * SPLICED CONDITIONALLY by `buildSystemPrompt`, which is what preserves I-3: with
29
+ * `jobs.enabled: false` the prompt is BYTE-IDENTICAL to the pre-feature output.
30
+ *
31
+ * EVERY SENTENCE HERE ANSWERS A DECISION THE MODEL ACTUALLY HAS TO MAKE:
32
+ * - "do not poll" is the behaviour this feature exists to replace (six
33
+ * `bash_output` calls per 30 seconds, six LLM turns, six 200-row tails);
34
+ * - "a bounded wait is the only thing that tells you it finished" prevents the
35
+ * opposite failure, a model that starts a job and then guesses;
36
+ * - "`<job_notices>` is the notification, not an error" stops it apologising for
37
+ * a block that is good news;
38
+ * - "`[stalled]` is not a failure" stops it killing a job that is merely quiet.
39
+ *
40
+ * `JOBS_BLOCK_VERSION` exists so a change to this wording is greppable from a
41
+ * behaviour report.
42
+ */
43
+ import { JOBS_BLOCK_VERSION } from './limits.js';
44
+ export { JOBS_BLOCK_VERSION };
45
+ export declare function buildBackgroundJobsBlock(): string;
24
46
  export declare function buildBackgroundServicesBlock(): string;