@aragon-agent/cli 0.6.31 → 0.6.33

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 (95) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +2970 -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 +236 -8
  12. package/dist/commands/builtins.js +23 -8
  13. package/dist/commands/perf.d.ts +77 -1
  14. package/dist/commands/perf.js +37 -5
  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 +98 -12
  59. package/dist/ui/ScrollbackShell.d.ts +62 -11
  60. package/dist/ui/ScrollbackShell.js +11 -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 +79 -0
  74. package/dist/ui/scrollback/LiveTail.js +32 -0
  75. package/dist/ui/scrollback/band.d.ts +114 -0
  76. package/dist/ui/scrollback/band.js +130 -0
  77. package/dist/ui/scrollback/budget.d.ts +51 -10
  78. package/dist/ui/scrollback/budget.js +69 -20
  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 +61 -4
  84. package/dist/ui/scrollback/limits.js +61 -4
  85. package/dist/ui/scrollback/live-preview.d.ts +18 -10
  86. package/dist/ui/scrollback/live-preview.js +14 -11
  87. package/dist/ui/scrollback/scrollback-writer.d.ts +104 -16
  88. package/dist/ui/scrollback/scrollback-writer.js +368 -82
  89. package/dist/ui/scrollback/tail-window.d.ts +60 -0
  90. package/dist/ui/scrollback/tail-window.js +73 -0
  91. package/dist/ui/scrollback/use-scrollback-view.d.ts +34 -15
  92. package/dist/ui/scrollback/use-scrollback-view.js +110 -59
  93. package/dist/ui/scrollback/writer-shapes.d.ts +68 -2
  94. package/dist/ui/scrollback/writer-shapes.js +53 -8
  95. package/package.json +3 -3
@@ -17,6 +17,7 @@
17
17
  import { formatStreamError } from '../agent/reducer.js';
18
18
  import { setSignalTerminator } from '../logging/install.js';
19
19
  import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
20
+ import { EXEC_SCHEMA_VERSION } from './events.js';
20
21
  import { ExecProgressReporter } from './progress-reporter.js';
21
22
  export function singlePrompt(text) {
22
23
  let served = false;
@@ -44,6 +45,10 @@ export class ExecRunner {
44
45
  progress;
45
46
  controller = null;
46
47
  unsubscribers = [];
48
+ /** Per-job progress throttle state: the last text emitted, and when. */
49
+ jobProgressAt = new Map();
50
+ /** Per-job `lastOutputAt` at which `stalled` was last reported. */
51
+ jobStalledAt = new Map();
47
52
  turns = 0;
48
53
  usage = { inputTokens: 0, outputTokens: 0 };
49
54
  compactionBilling;
@@ -218,8 +223,82 @@ export class ExecRunner {
218
223
  });
219
224
  if (compaction)
220
225
  this.unsubscribers.push(compaction);
226
+ // Background jobs (long-task-jobs §4.10). SUBSCRIBED HERE, IN `attach()`, NOT
227
+ // IN `start()`: the follow-through chain enters `run()` more than once, and a
228
+ // subscription made there would be re-made every time and never released —
229
+ // one leaked listener per turn for the life of the process.
230
+ //
231
+ // The four-stream shape is deliberate: `?:` optional call, `unsubscribers`
232
+ // push, `detach()` releases. Identical to `subscribeTeam` / `subscribeTodos` /
233
+ // `subscribeFast` / `subscribeCompaction`, so a host without a supervisor
234
+ // simply emits nothing.
235
+ const proc = controller.subscribeProc?.((event) => this.onProcEvent(event));
236
+ if (proc)
237
+ this.unsubscribers.push(proc);
221
238
  this.installTerminator();
222
239
  }
240
+ /**
241
+ * Map a supervisor event onto the `job` channel.
242
+ *
243
+ * THROTTLING IS THE WHOLE DISCIPLINE HERE. `output` is already coalesced to
244
+ * <=10 Hz inside the supervisor, which is still far too fast for a machine
245
+ * consumer reading a 40-minute training run; `progress` is therefore emitted
246
+ * only when the extracted TEXT CHANGES and never more than once a second. The
247
+ * other three subtypes are one-shot per job, so they pass straight through.
248
+ */
249
+ onProcEvent(event) {
250
+ const job = event.service;
251
+ if (event.type === 'started') {
252
+ this.emitJob(job, 'started');
253
+ return;
254
+ }
255
+ if (event.type === 'exited' || event.type === 'stopped') {
256
+ this.jobProgressAt.delete(job.id);
257
+ this.jobStalledAt.delete(job.id);
258
+ this.emitJob(job, 'exited');
259
+ return;
260
+ }
261
+ // `ready` is deliberately NOT mapped onto a second `started`: a service emits
262
+ // both, and a consumer that counted `started` events would see two. The
263
+ // readiness fact is already carried by `status` on any later event, and the
264
+ // four subtypes this channel publishes are one-shot per job.
265
+ if (event.type !== 'output')
266
+ return;
267
+ if (job.stalled === true) {
268
+ if (this.jobStalledAt.get(job.id) === job.lastOutputAt)
269
+ return;
270
+ this.jobStalledAt.set(job.id, job.lastOutputAt);
271
+ this.emitJob(job, 'stalled');
272
+ return;
273
+ }
274
+ const text = job.progress?.text;
275
+ if (text === undefined)
276
+ return;
277
+ if (this.jobProgressAt.get(job.id)?.text === text)
278
+ return;
279
+ const now = Date.now();
280
+ const last = this.jobProgressAt.get(job.id)?.at ?? 0;
281
+ // ONE SECOND IS THE FLOOR, and the first change of a job is always emitted
282
+ // (no previous entry), so a consumer sees the initial figure immediately.
283
+ if (last !== 0 && now - last < 1000)
284
+ return;
285
+ this.jobProgressAt.set(job.id, { text, at: now });
286
+ this.emitJob(job, 'progress', text);
287
+ }
288
+ emitJob(job, subtype, progress) {
289
+ this.emit({
290
+ type: 'job',
291
+ schemaVersion: EXEC_SCHEMA_VERSION,
292
+ sessionId: this.sessionId,
293
+ subtype,
294
+ jobId: job.id,
295
+ kind: job.kind,
296
+ status: job.status,
297
+ elapsedMs: Math.max(0, (job.endedAt ?? Date.now()) - job.startedAt),
298
+ ...(progress !== undefined ? { progress } : {}),
299
+ ...(subtype === 'exited' ? { exitCode: job.exitCode } : {}),
300
+ });
301
+ }
223
302
  /**
224
303
  * Drop the subscriptions and the timer.
225
304
  *
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Cursor Position Report parsing (tui-bottom-anchored-live-region §4.5).
3
+ *
4
+ * The bottom anchor needs one number the writer cannot compute: how many rows are
5
+ * left below the cursor. `ESC[6n` is the only way to ask, and the terminal answers
6
+ * with `ESC[<row>;<col>R` (DECXCPR adds a `?`: `ESC[?<row>;<col>R`) on STDIN —
7
+ * the same stream the user types on. Two things follow, and both live here:
8
+ *
9
+ * 1. THE ANSWER MUST BE STRIPPED BEFORE INK SEES IT. A report that reaches Ink
10
+ * travels the keystroke path: `[` is 0x5B, so `PromptInput`'s control-sequence
11
+ * guard passes it through and the user's draft gets `[12;1R` typed into it (the
12
+ * same class of accident the mouse filter exists to prevent).
13
+ * 2. THE ANSWER CAN ARRIVE IN TWO CHUNKS. ConPTY, tmux and a loaded SSH session
14
+ * all split reads at arbitrary points, and a half report (`ESC[12`) is worse
15
+ * than a whole one: `parse-keypress` has no branch for it.
16
+ *
17
+ * Pure functions only, mirroring `mouse-events.ts`: the filter owns the buffering
18
+ * and the timers, this module owns the shapes.
19
+ */
20
+ /** The query for a Cursor Position Report (DSR 6). ASCII by construction. */
21
+ export declare const CURSOR_REPORT_QUERY = "\u001B[6n";
22
+ /** One parsed report. `row`/`col` are 1-based, exactly as the terminal sent them. */
23
+ export interface CursorReport {
24
+ row: number;
25
+ col: number;
26
+ }
27
+ export interface CursorReportSplit {
28
+ /** Text that survives stripping — everything that is not a report. */
29
+ text: string;
30
+ /** Reports found in this chunk, in order. */
31
+ reports: CursorReport[];
32
+ /** Trailing bytes that could still become a report; hand them to the next chunk. */
33
+ pending: string;
34
+ }
35
+ /** True when `s` is a non-empty proper prefix of a possible report. */
36
+ export declare function isCursorReportPrefix(s: string): boolean;
37
+ /**
38
+ * Split the reports out of one chunk.
39
+ *
40
+ * `pending` carries the tail that might still complete into a report. It is only
41
+ * ever the LAST fragment: the scan is left to right, so anything before a complete
42
+ * report is settled text.
43
+ */
44
+ export declare function splitCursorReports(text: string): CursorReportSplit;
45
+ /**
46
+ * Length of the trailing run of `text` that is a proper prefix of a report, or 0.
47
+ *
48
+ * LONGEST MATCH WINS, unlike the mouse family's shortest-wins rule. The families are
49
+ * combined with `max()`, so only the longest answer ever survives, and a longer hold
50
+ * is never wrong here: `ESC[1` can still become `ESC[12;1R`, while the shorter
51
+ * answer `ESC` could not describe it. The bound is the longest report the terminals
52
+ * we target emit (DECXCPR with a `?` and three-digit coordinates).
53
+ */
54
+ export declare function trailingCprPrefixLength(text: string): number;
55
+ /** The query bytes, as a function so call sites read as an action rather than a literal. */
56
+ export declare function formatCursorReportQuery(): string;
57
+ /**
58
+ * Rows left below a reported cursor row on a terminal of `rows` rows.
59
+ * `0` means the cursor is already on the last row; `null` when the height is unknown.
60
+ */
61
+ export declare function spaceBelowFromReport(report: CursorReport, rows: number | undefined): number | null;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Cursor Position Report parsing (tui-bottom-anchored-live-region §4.5).
3
+ *
4
+ * The bottom anchor needs one number the writer cannot compute: how many rows are
5
+ * left below the cursor. `ESC[6n` is the only way to ask, and the terminal answers
6
+ * with `ESC[<row>;<col>R` (DECXCPR adds a `?`: `ESC[?<row>;<col>R`) on STDIN —
7
+ * the same stream the user types on. Two things follow, and both live here:
8
+ *
9
+ * 1. THE ANSWER MUST BE STRIPPED BEFORE INK SEES IT. A report that reaches Ink
10
+ * travels the keystroke path: `[` is 0x5B, so `PromptInput`'s control-sequence
11
+ * guard passes it through and the user's draft gets `[12;1R` typed into it (the
12
+ * same class of accident the mouse filter exists to prevent).
13
+ * 2. THE ANSWER CAN ARRIVE IN TWO CHUNKS. ConPTY, tmux and a loaded SSH session
14
+ * all split reads at arbitrary points, and a half report (`ESC[12`) is worse
15
+ * than a whole one: `parse-keypress` has no branch for it.
16
+ *
17
+ * Pure functions only, mirroring `mouse-events.ts`: the filter owns the buffering
18
+ * and the timers, this module owns the shapes.
19
+ */
20
+ /** The query for a Cursor Position Report (DSR 6). ASCII by construction. */
21
+ export const CURSOR_REPORT_QUERY = '\x1b[6n';
22
+ /**
23
+ * `ESC[<row>;<col>R`, with the DECXCPR `?` accepted. Anchored on `ESC[` plus digits
24
+ * and one `;`, which is what keeps it from matching a private-mode write such as
25
+ * `ESC[?25l` (that one ends in a letter this pattern cannot accept).
26
+ *
27
+ * Built per call rather than shared: a module-level `/g` regex carries `lastIndex`
28
+ * between calls, which turns a pure function into a stateful one.
29
+ */
30
+ const reportPattern = () => /\x1b\[\??(\d+);(\d+)R/g;
31
+ /**
32
+ * A PROPER prefix of a report: `ESC`, `ESC[`, then an optional `?`, then digits, at
33
+ * most one `;`, then more digits. Nothing a keyboard produces matches past `ESC[`
34
+ * (arrow keys end in a letter), so the filter's 12 ms hold stays a rare path.
35
+ */
36
+ const PREFIX = /^\x1b(\[(\?(\d*(;\d*)?)?|\d*(;\d*)?)?)?$/;
37
+ /** True when `s` is a non-empty proper prefix of a possible report. */
38
+ export function isCursorReportPrefix(s) {
39
+ return s.length > 0 && s.length < 24 && PREFIX.test(s);
40
+ }
41
+ /**
42
+ * Split the reports out of one chunk.
43
+ *
44
+ * `pending` carries the tail that might still complete into a report. It is only
45
+ * ever the LAST fragment: the scan is left to right, so anything before a complete
46
+ * report is settled text.
47
+ */
48
+ export function splitCursorReports(text) {
49
+ const reports = [];
50
+ const pattern = reportPattern();
51
+ let out = '';
52
+ let index = 0;
53
+ for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) {
54
+ out += text.slice(index, match.index);
55
+ reports.push({ row: Number(match[1]), col: Number(match[2]) });
56
+ index = match.index + match[0].length;
57
+ }
58
+ out += text.slice(index);
59
+ const pendingLength = trailingCprPrefixLength(out);
60
+ if (pendingLength === 0)
61
+ return { text: out, reports, pending: '' };
62
+ return {
63
+ text: out.slice(0, out.length - pendingLength),
64
+ reports,
65
+ pending: out.slice(out.length - pendingLength),
66
+ };
67
+ }
68
+ /**
69
+ * Length of the trailing run of `text` that is a proper prefix of a report, or 0.
70
+ *
71
+ * LONGEST MATCH WINS, unlike the mouse family's shortest-wins rule. The families are
72
+ * combined with `max()`, so only the longest answer ever survives, and a longer hold
73
+ * is never wrong here: `ESC[1` can still become `ESC[12;1R`, while the shorter
74
+ * answer `ESC` could not describe it. The bound is the longest report the terminals
75
+ * we target emit (DECXCPR with a `?` and three-digit coordinates).
76
+ */
77
+ export function trailingCprPrefixLength(text) {
78
+ const max = Math.min(text.length, 24);
79
+ for (let k = max; k >= 1; k -= 1) {
80
+ if (isCursorReportPrefix(text.slice(text.length - k)))
81
+ return k;
82
+ }
83
+ return 0;
84
+ }
85
+ /** The query bytes, as a function so call sites read as an action rather than a literal. */
86
+ export function formatCursorReportQuery() {
87
+ return CURSOR_REPORT_QUERY;
88
+ }
89
+ /**
90
+ * Rows left below a reported cursor row on a terminal of `rows` rows.
91
+ * `0` means the cursor is already on the last row; `null` when the height is unknown.
92
+ */
93
+ export function spaceBelowFromReport(report, rows) {
94
+ if (rows === undefined || !Number.isFinite(rows) || rows < 1)
95
+ return null;
96
+ return Math.max(0, Math.floor(rows) - 1 - (report.row - 1));
97
+ }
@@ -69,11 +69,33 @@ export interface StdinFilterFeatures {
69
69
  * translates for a session whose mode push actually happened.
70
70
  */
71
71
  readonly enhancedKeys?: boolean;
72
+ /**
73
+ * Strip the Cursor Position Report a terminal sends back for `ESC[6n`
74
+ * (tui-bottom-anchored-live-region §4.5).
75
+ *
76
+ * TOLD, never derived, like the three above: `cli.tsx` passes
77
+ * `plan.differ === 'scrollback'`, so an `app`-mode session stays byte-identical to
78
+ * the build without this feature (I-A8). The report MUST NOT reach Ink -- `[` is
79
+ * 0x5B, so `PromptInput`'s control-sequence guard passes it and the reply gets
80
+ * typed into the draft -- and a report split across two reads is worse than a
81
+ * whole one, because `parse-keypress` has no branch for a half sequence.
82
+ */
83
+ readonly cursorReport?: boolean;
72
84
  }
73
85
  export interface StdinFilter {
74
86
  /** Hand this to Ink: `render(<App/>, { stdin: filter.stdin })`. */
75
87
  readonly stdin: NodeJS.ReadStream;
76
88
  readonly source: MouseSource;
89
+ /**
90
+ * The probe is waiting for an answer: hold a possible report prefix until it
91
+ * settles instead of flushing it after `PENDING_FLUSH_MS`.
92
+ *
93
+ * ConPTY can split `ESC[12;1R` across two reads further apart than 12 ms, and a
94
+ * half report that reaches Ink lands on an undefined branch of its key parser.
95
+ * Turning this off flushes whatever is still held as ordinary text -- the
96
+ * correct degradation for a terminal that answered with something else (R3).
97
+ */
98
+ setExpectingCursorReport(on: boolean): void;
77
99
  /** Idempotent. Detaches from the real stdin and clears every timer (I-7). */
78
100
  dispose(): void;
79
101
  }
@@ -36,6 +36,7 @@ import { MAX_PENDING_MOUSE_CHARS, splitMouseEvents, isMousePrefix, } from './mou
36
36
  import { ENTER_NEWLINE_FRAME, INDEX_KEY_FRAME, PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MARKER_RESCAN_TTL_MS, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
37
37
  import { PASTE_BEGIN_MARK, PASTE_END_MARK, classifyChunk, isPastePrefix, sanitisePaste, trailingPastePrefixLength, } from './paste-parse.js';
38
38
  import { hasPasteOnlySequence } from './control-sequences.js';
39
+ import { splitCursorReports, trailingCprPrefixLength } from './cursor-report.js';
39
40
  import { splitEnterSequences, trailingEnterPrefixLength } from './enter-sequences.js';
40
41
  import { splitCsiUKeys, trailingCsiUPrefixLength, translateCsiUKey } from './csiu-keys.js';
41
42
  import { splitWin32KeySequences, trailingWin32PrefixLength, translateWin32Key, } from './win32-input-mode.js';
@@ -95,6 +96,12 @@ export function createStdinFilter(real, features, bridge, onWarn) {
95
96
  let disposed = false;
96
97
  let finished = false;
97
98
  let paste = { kind: 'idle' };
99
+ /**
100
+ * The probe window (R3). While true a possible report prefix is held until the
101
+ * probe settles instead of being flushed after `PENDING_FLUSH_MS`, because a
102
+ * half `ESC[12` reaching Ink hits an undefined branch of its key parser.
103
+ */
104
+ let expectingCursorReport = false;
98
105
  let discardBracketed = false;
99
106
  let outputBatch = null;
100
107
  /**
@@ -266,6 +273,26 @@ export function createStdinFilter(real, features, bridge, onWarn) {
266
273
  burstTimer = arm(flushBurst, PASTE_BURST_MS);
267
274
  return;
268
275
  }
276
+ // FOURTH PREFIX FAMILY: the terminal's answer to `ESC[6n`
277
+ // (tui-bottom-anchored-live-region §4.5). AFTER the paste tests, and that order
278
+ // is the specification: a user who pastes a terminal log containing `ESC[12;1R`
279
+ // is pasting DATA, and a paste body is the one place such a sequence is kept
280
+ // verbatim (I-3, the same rule that suspends mouse parsing inside a body).
281
+ if (features.cursorReport) {
282
+ const report = splitCursorReports(text);
283
+ text = report.text;
284
+ if (report.pending.length > 0) {
285
+ // Not text yet: it joins the cross-chunk buffer the mouse family uses.
286
+ // `holdTail` false means a paste marker follows in this very chunk, so no
287
+ // later byte can complete the prefix and holding it would reorder it (I-5).
288
+ if (holdTail)
289
+ pending += report.pending;
290
+ else
291
+ writeInput(report.pending);
292
+ }
293
+ if (text.length === 0)
294
+ return;
295
+ }
269
296
  // Ink conflates DEL Backspace with CSI Delete. Normalize only keyboard
270
297
  // text, after burst classification, preserving ESC for Alt+Backspace.
271
298
  text = text.replace(/\x7f/g, '\x08');
@@ -414,7 +441,7 @@ export function createStdinFilter(real, features, bridge, onWarn) {
414
441
  // the ESC / ESC-[ head bytes, which the max keeps for whoever needs
415
442
  // them. The existing 12 ms flush and the 32-char ceiling bound the
416
443
  // hold, so no new timer is needed.
417
- const keep = Math.max(trailingPastePrefixLength(rest), features.mouse ? trailingMousePrefixLength(rest) : 0, trailingEnterPrefixLength(rest), enhancedKeys ? trailingWin32PrefixLength(rest) : 0, enhancedKeys ? trailingCsiUPrefixLength(rest) : 0);
444
+ const keep = Math.max(trailingPastePrefixLength(rest), features.mouse ? trailingMousePrefixLength(rest) : 0, features.cursorReport ? trailingCprPrefixLength(rest) : 0, trailingEnterPrefixLength(rest), enhancedKeys ? trailingWin32PrefixLength(rest) : 0, enhancedKeys ? trailingCsiUPrefixLength(rest) : 0);
418
445
  const tail = rest.slice(rest.length - keep);
419
446
  handleOutsidePaste(rest.slice(0, rest.length - keep), true);
420
447
  pending += tail;
@@ -450,6 +477,10 @@ export function createStdinFilter(real, features, bridge, onWarn) {
450
477
  flushTimer = clearTimer(flushTimer);
451
478
  if (pending.length === 0 || disposed)
452
479
  return;
480
+ // The probe owes us an answer: releasing a half report now would type it into
481
+ // the draft. `setExpectingCursorReport(false)` flushes whatever is left.
482
+ if (expectingCursorReport)
483
+ return;
453
484
  const text = pending;
454
485
  pending = '';
455
486
  // A tail that never completed is ordinary text; it goes through the same
@@ -526,9 +557,12 @@ export function createStdinFilter(real, features, bridge, onWarn) {
526
557
  wrapper.write(text);
527
558
  if (paste.kind === 'bracketed' || pending.length === 0)
528
559
  return;
529
- if (pending.length > MAX_PENDING_CHARS) {
560
+ if (pending.length > MAX_PENDING_CHARS && !expectingCursorReport) {
530
561
  // A malformed sequence that looks like a prefix forever must never wedge
531
- // input: past the ceiling the tail becomes ordinary text.
562
+ // input: past the ceiling the tail becomes ordinary text. NOT DURING THE
563
+ // PROBE WINDOW THOUGH (L8): there the ceiling could push half a report into
564
+ // Ink, which is the failure this whole family exists to prevent. The window
565
+ // is bounded by the probe timeout, after which the ceiling applies again.
532
566
  const text = pending;
533
567
  pending = '';
534
568
  flushTailAsText(text);
@@ -574,6 +608,17 @@ export function createStdinFilter(real, features, bridge, onWarn) {
574
608
  return () => listeners.delete(listener);
575
609
  },
576
610
  },
611
+ setExpectingCursorReport(on) {
612
+ if (expectingCursorReport === on)
613
+ return;
614
+ expectingCursorReport = on;
615
+ if (on || disposed)
616
+ return;
617
+ // Settled: nothing that is still held was a report. Release it as text, so a
618
+ // probe that never got an answer costs its own timeout and not a dead keyboard.
619
+ flushTimer = clearTimer(flushTimer);
620
+ flushPending();
621
+ },
577
622
  dispose() {
578
623
  if (disposed)
579
624
  return;
@@ -34,14 +34,19 @@ export declare const PROC_LIMITS: {
34
34
  /** Rows a snapshot (and therefore a card) carries. */
35
35
  readonly cardTailRows: 8;
36
36
  /**
37
- * Service RECORDS one session may hold.
37
+ * JOB RECORDS one session may hold — services AND tasks share the number.
38
+ *
39
+ * NAMED `maxJobs`, NOT `maxServices` (long-task-jobs D-7). A constant that
40
+ * counts two kinds of job while being called `maxServices` is the next edit's
41
+ * bug: the limit and its refusal message would go on saying "service" while
42
+ * the model is holding a training run.
38
43
  *
39
44
  * Eviction only ever removes a record in a TERMINAL state
40
45
  * (`exited | failed | stopped`). When all 16 are live `start()` refuses and
41
46
  * names the oldest ids rather than evicting: evicting a live record would
42
47
  * orphan a running server that nothing can any longer name or stop.
43
48
  */
44
- readonly maxServices: 16;
49
+ readonly maxJobs: 16;
45
50
  /**
46
51
  * How long `bash` waits after `'exit'` for the stdio streams to close before
47
52
  * settling anyway.
@@ -76,9 +81,78 @@ export declare const PROC_LIMITS: {
76
81
  readonly stopGraceMs: 3000;
77
82
  /** Below this many terminal columns the status chip degrades to `[N]`. */
78
83
  readonly statusCompactCols: 92;
84
+ /**
85
+ * Rows of new output one `job_wait` result carries, in addition to its status,
86
+ * progress, cursor, `[note]`/`[stalled]` and `[next]` lines.
87
+ *
88
+ * THE NUMBER IS THE FEATURE. One wait must cost ~150 tokens, not the ~6k a
89
+ * 200-row tail costs - that economy is the whole reason `job_wait` exists
90
+ * instead of a seventh `bash_output` poll.
91
+ */
92
+ readonly waitTailRows: 6;
93
+ /** Seconds a single wait may block, as a STRUCTURAL floor and ceiling (`jobs.waitSeconds` is clamped into this range). */
94
+ readonly waitMinSeconds: 5;
95
+ readonly waitMaxSeconds: 60;
96
+ /**
97
+ * Characters one extracted progress line may occupy.
98
+ *
99
+ * Also the width the card's progress row is assumed to take, which is why
100
+ * `ui/layout/virtual-window.ts` can count it as exactly one row.
101
+ */
102
+ readonly waitProgressChars: 80;
103
+ /**
104
+ * Rows `extractProgress` scans, from the tail, before giving up.
105
+ *
106
+ * Eight because a tqdm bar plus its ETA plus the loss line above it is the
107
+ * realistic worst case, and because a wider window is how a stale `100%` from
108
+ * an earlier phase wins over the live one.
109
+ */
110
+ readonly progressScanRows: 8;
111
+ /** Notices one `<job_notices>` block may render. Bounded so the block stays ~8 lines. */
112
+ readonly maxNotices: 8;
113
+ /** Log rows a failed job's notice carries. Two: the message and the frame above it. */
114
+ readonly noticeTailRows: 2;
115
+ /** Rows `job_list` prints before it collapses the rest into `+N more`. */
116
+ readonly listRows: 8;
117
+ /**
118
+ * Consecutive waits with no new row and no progress change before the
119
+ * `[guard]` line is prepended (R-3).
120
+ *
121
+ * A HINT, NEVER A REFUSAL: the seventh wait still waits. Refusing would take
122
+ * the decision away from the model at exactly the point where it has the most
123
+ * context.
124
+ */
125
+ readonly maxConsecutiveWaits: 6;
126
+ /** Bytes one mirrored job log may occupy before it stops appending. */
127
+ readonly logMirrorMaxBytes: 2000000;
128
+ /** Coalescing window for the log mirror's appends (<=4 writes/second). */
129
+ readonly mirrorFlushMs: 250;
130
+ /** Session directories the mirror keeps, newest by mtime. */
131
+ readonly mirrorRetainDirs: 5;
79
132
  };
80
133
  /**
81
134
  * Bumped whenever the wording of the `<background_services>` block changes, so a
82
135
  * behaviour report can be tied to a block revision with one grep.
83
136
  */
84
137
  export declare const BACKGROUND_SERVICES_BLOCK_VERSION = "v1";
138
+ /**
139
+ * The same discipline for the `<background_jobs>` block. A SEPARATE CONSTANT,
140
+ * not a bump of the one above: the two blocks are spliced by different flags
141
+ * (`bash.background` and `jobs.enabled`), so one version number covering both
142
+ * would make "which block changed" unanswerable from a report.
143
+ */
144
+ export declare const JOBS_BLOCK_VERSION = "v1-2026-10";
145
+ /**
146
+ * Head-room a single wait leaves below the tool ceiling (I-4).
147
+ *
148
+ * `jobs.waitSeconds * 1000 + JOB_WAIT_MARGIN_MS <= toolTimeoutMs` is the whole
149
+ * reason a wait never needs to pause the idle watchdog (D-3): a 60 s wait inside
150
+ * a 180 s tool call in a 210 s idle window cannot be mistaken for a hang.
151
+ *
152
+ * IT LIVES HERE RATHER THAN IN `config/schema.ts`, where the design document put
153
+ * it, because the TOOL layer needs the same number for its call-time convergence
154
+ * (AC-14) and nothing in `tools/` may import `config/` - a tool takes its policy
155
+ * through closures. One constant, two readers, no second copy to drift. Recorded
156
+ * in `docs/plans/long-task-jobs/spec.md` under `## 实施过程发现的方案缺陷` (I-2).
157
+ */
158
+ export declare const JOB_WAIT_MARGIN_MS = 30000;
@@ -34,14 +34,19 @@ export const PROC_LIMITS = {
34
34
  /** Rows a snapshot (and therefore a card) carries. */
35
35
  cardTailRows: 8,
36
36
  /**
37
- * Service RECORDS one session may hold.
37
+ * JOB RECORDS one session may hold — services AND tasks share the number.
38
+ *
39
+ * NAMED `maxJobs`, NOT `maxServices` (long-task-jobs D-7). A constant that
40
+ * counts two kinds of job while being called `maxServices` is the next edit's
41
+ * bug: the limit and its refusal message would go on saying "service" while
42
+ * the model is holding a training run.
38
43
  *
39
44
  * Eviction only ever removes a record in a TERMINAL state
40
45
  * (`exited | failed | stopped`). When all 16 are live `start()` refuses and
41
46
  * names the oldest ids rather than evicting: evicting a live record would
42
47
  * orphan a running server that nothing can any longer name or stop.
43
48
  */
44
- maxServices: 16,
49
+ maxJobs: 16,
45
50
  /**
46
51
  * How long `bash` waits after `'exit'` for the stdio streams to close before
47
52
  * settling anyway.
@@ -76,9 +81,85 @@ export const PROC_LIMITS = {
76
81
  stopGraceMs: 3000,
77
82
  /** Below this many terminal columns the status chip degrades to `[N]`. */
78
83
  statusCompactCols: 92,
84
+ // --- long-task-jobs: bounded waiting, progress, notices, log mirror --------
85
+ //
86
+ // EVERY ONE OF THESE IS STRUCTURAL, not policy. The user-tunable knobs are the
87
+ // five `jobs.*` keys in `config/schema.ts` (how many seconds to wait, when
88
+ // silence becomes `[stalled]`, whether to mirror the log, whether notices may
89
+ // steer). What a result can PHYSICALLY carry lives here, for the reason this
90
+ // file's header records at length.
91
+ /**
92
+ * Rows of new output one `job_wait` result carries, in addition to its status,
93
+ * progress, cursor, `[note]`/`[stalled]` and `[next]` lines.
94
+ *
95
+ * THE NUMBER IS THE FEATURE. One wait must cost ~150 tokens, not the ~6k a
96
+ * 200-row tail costs - that economy is the whole reason `job_wait` exists
97
+ * instead of a seventh `bash_output` poll.
98
+ */
99
+ waitTailRows: 6,
100
+ /** Seconds a single wait may block, as a STRUCTURAL floor and ceiling (`jobs.waitSeconds` is clamped into this range). */
101
+ waitMinSeconds: 5,
102
+ waitMaxSeconds: 60,
103
+ /**
104
+ * Characters one extracted progress line may occupy.
105
+ *
106
+ * Also the width the card's progress row is assumed to take, which is why
107
+ * `ui/layout/virtual-window.ts` can count it as exactly one row.
108
+ */
109
+ waitProgressChars: 80,
110
+ /**
111
+ * Rows `extractProgress` scans, from the tail, before giving up.
112
+ *
113
+ * Eight because a tqdm bar plus its ETA plus the loss line above it is the
114
+ * realistic worst case, and because a wider window is how a stale `100%` from
115
+ * an earlier phase wins over the live one.
116
+ */
117
+ progressScanRows: 8,
118
+ /** Notices one `<job_notices>` block may render. Bounded so the block stays ~8 lines. */
119
+ maxNotices: 8,
120
+ /** Log rows a failed job's notice carries. Two: the message and the frame above it. */
121
+ noticeTailRows: 2,
122
+ /** Rows `job_list` prints before it collapses the rest into `+N more`. */
123
+ listRows: 8,
124
+ /**
125
+ * Consecutive waits with no new row and no progress change before the
126
+ * `[guard]` line is prepended (R-3).
127
+ *
128
+ * A HINT, NEVER A REFUSAL: the seventh wait still waits. Refusing would take
129
+ * the decision away from the model at exactly the point where it has the most
130
+ * context.
131
+ */
132
+ maxConsecutiveWaits: 6,
133
+ /** Bytes one mirrored job log may occupy before it stops appending. */
134
+ logMirrorMaxBytes: 2_000_000,
135
+ /** Coalescing window for the log mirror's appends (<=4 writes/second). */
136
+ mirrorFlushMs: 250,
137
+ /** Session directories the mirror keeps, newest by mtime. */
138
+ mirrorRetainDirs: 5,
79
139
  };
80
140
  /**
81
141
  * Bumped whenever the wording of the `<background_services>` block changes, so a
82
142
  * behaviour report can be tied to a block revision with one grep.
83
143
  */
84
144
  export const BACKGROUND_SERVICES_BLOCK_VERSION = 'v1';
145
+ /**
146
+ * The same discipline for the `<background_jobs>` block. A SEPARATE CONSTANT,
147
+ * not a bump of the one above: the two blocks are spliced by different flags
148
+ * (`bash.background` and `jobs.enabled`), so one version number covering both
149
+ * would make "which block changed" unanswerable from a report.
150
+ */
151
+ export const JOBS_BLOCK_VERSION = 'v1-2026-10';
152
+ /**
153
+ * Head-room a single wait leaves below the tool ceiling (I-4).
154
+ *
155
+ * `jobs.waitSeconds * 1000 + JOB_WAIT_MARGIN_MS <= toolTimeoutMs` is the whole
156
+ * reason a wait never needs to pause the idle watchdog (D-3): a 60 s wait inside
157
+ * a 180 s tool call in a 210 s idle window cannot be mistaken for a hang.
158
+ *
159
+ * IT LIVES HERE RATHER THAN IN `config/schema.ts`, where the design document put
160
+ * it, because the TOOL layer needs the same number for its call-time convergence
161
+ * (AC-14) and nothing in `tools/` may import `config/` - a tool takes its policy
162
+ * through closures. One constant, two readers, no second copy to drift. Recorded
163
+ * in `docs/plans/long-task-jobs/spec.md` under `## 实施过程发现的方案缺陷` (I-2).
164
+ */
165
+ export const JOB_WAIT_MARGIN_MS = 30_000;
@@ -0,0 +1,59 @@
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
+ export interface JobLogMirrorOptions {
24
+ /** `${aragonHome}/jobs/${sessionTag}`. A closure so a test can hand in a temp dir. */
25
+ root: () => string;
26
+ maxBytes?: number;
27
+ flushMs?: number;
28
+ }
29
+ /**
30
+ * Delete all but the newest `PROC_LIMITS.mirrorRetainDirs` session directories.
31
+ *
32
+ * BEST EFFORT AND SILENT. This runs once per process at first use, and its whole
33
+ * job is to stop `~/.aragon-agent/jobs/` from growing without bound; a directory
34
+ * that cannot be read is not a reason to refuse to start.
35
+ */
36
+ export declare function pruneJobLogSessions(jobsRoot: string): void;
37
+ export declare class JobLogMirror {
38
+ private readonly opts;
39
+ private readonly files;
40
+ private readonly buffers;
41
+ private flushTimer;
42
+ private pruned;
43
+ private disposed;
44
+ constructor(opts: JobLogMirrorOptions);
45
+ /** Lazy creation: the directory appears only when a job actually prints. */
46
+ open(jobId: string): void;
47
+ /** Buffer one chunk. Never throws, never awaits, never blocks the child. */
48
+ write(jobId: string, chunk: string): void;
49
+ pathOf(jobId: string): string | undefined;
50
+ /** Flush what is buffered and release the timer. Idempotent. */
51
+ dispose(): void;
52
+ private maxBytes;
53
+ private flushMs;
54
+ private scheduleFlush;
55
+ private flushAll;
56
+ private flushOne;
57
+ /** One place that writes, so the sync and async paths cannot drift. */
58
+ private writeOut;
59
+ }