@oar-sh/oar 0.9.11 → 0.9.12

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 (63) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +17 -3
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +128 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -3
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +9 -0
  15. package/server/grok-worker/acp-host-services.mjs +42 -3
  16. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  17. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  18. package/server/grok-worker/grok-session-worker.mjs +33 -1
  19. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  20. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  21. package/server/public/app/api-client.js +13 -0
  22. package/server/public/app/background-tasks-view.mjs +285 -16
  23. package/server/public/app/bootstrap.js +95 -5
  24. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  25. package/server/public/app/conversation-payload-cache.mjs +357 -0
  26. package/server/public/app/conversation-turn-state.mjs +1 -1
  27. package/server/public/app/conversation-view.js +433 -3
  28. package/server/public/app/conversation-window-retention.mjs +96 -0
  29. package/server/public/app/journal-view.js +252 -66
  30. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  31. package/server/public/app/settings-modal.js +110 -2
  32. package/server/public/app/socket-handlers.js +59 -7
  33. package/server/public/app/store.js +25 -0
  34. package/server/public/app/subagent-viewer.mjs +7 -1
  35. package/server/public/app/task-console-render.mjs +237 -0
  36. package/server/public/app/task-console-watch.mjs +42 -0
  37. package/server/public/app/task-console.mjs +630 -0
  38. package/server/public/app/task-output-text.mjs +234 -0
  39. package/server/public/app/transcript-island.mjs +73 -0
  40. package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
  41. package/server/public/app/transcript-jump-bottom.mjs +48 -11
  42. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  43. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  44. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  45. package/server/public/index.html +239 -6
  46. package/server/relay-tools.md +1 -0
  47. package/server/repositories/message-repository.mjs +8 -0
  48. package/server/repositories/question-repository.mjs +4 -0
  49. package/server/routes/messages-routes.mjs +249 -3
  50. package/server/routes/sessions-routes.mjs +133 -63
  51. package/server/server-runtime.mjs +77 -0
  52. package/server/services/background-task-store.mjs +240 -3
  53. package/server/services/cli-process-runner.mjs +4 -21
  54. package/server/services/conversation-history-page.mjs +199 -0
  55. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  56. package/server/services/remote-relay-dispatcher.mjs +130 -2
  57. package/server/services/session-activity-tracker.mjs +6 -2
  58. package/server/services/task-output-rooms.mjs +259 -0
  59. package/server/services/task-output-service.mjs +562 -0
  60. package/shared/claude-background-command-limit.mjs +83 -0
  61. package/shared/remote-relay-contract.mjs +32 -2
  62. package/shared/task-output-text.mjs +737 -0
  63. package/shared/worker-runtime/task-output-spool.mjs +181 -0
@@ -0,0 +1,562 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Reading a background task's output for the console: the file the worker
5
+ * names as the task's `outputPath` (the Claude CLI's task file, a Copilot
6
+ * shell log, the worker's own spool), read by the relay itself.
7
+ *
8
+ * - readTail / readBefore / readRange: byte windows of the file, cut on line
9
+ * and character boundaries, returned cleaned (control codes out, colours
10
+ * kept, credentials scrubbed) with the processed line model.
11
+ * - the path guard: an absolute path of at most 1,024 characters, a regular
12
+ * file that is not a symlink (opened with O_NOFOLLOW where the platform has
13
+ * it), reads capped per request.
14
+ * - snapshots: the 8 KiB a worker posts for a task with no file (Claude's
15
+ * getTaskOutput), cached per task.
16
+ * - the health poller: every 10 s, for live tasks with an output file (and
17
+ * ended ones, read once more after the end), the time of the last output
18
+ * and the test runner's counts from the last 64 KB, sent to the pages as
19
+ * `background_tasks_health` when they change.
20
+ *
21
+ * The worker is trusted (it runs on this host as this user); the guard stops
22
+ * accidents, not an attacker.
23
+ */
24
+
25
+ import fs from 'node:fs';
26
+
27
+ import {
28
+ TASK_OUTPUT_LIMITS,
29
+ cleanTaskOutput,
30
+ foldTaskOutput,
31
+ normalizeTaskOutputKind,
32
+ normalizeTaskOutputPath,
33
+ parseRunnerSummary,
34
+ stripControlKeepSgr,
35
+ } from '../../shared/task-output-text.mjs';
36
+
37
+ const NEWLINE = 0x0a;
38
+
39
+ function clampInt(value, { min, max, fallback }) {
40
+ if (value === undefined || value === null || value === '') return fallback;
41
+ const number = Number(value);
42
+ if (!Number.isFinite(number)) return fallback;
43
+ return Math.min(max, Math.max(min, Math.trunc(number)));
44
+ }
45
+
46
+ /** The file kinds the relay reads itself. */
47
+ export function isFileOutputKind(kind) {
48
+ return kind === 'file' || kind === 'spool';
49
+ }
50
+
51
+ /** A task row's output as `{ kind, path }` (`path` '' when there is no readable one). */
52
+ export function taskOutputSource(task) {
53
+ const path = normalizeTaskOutputPath(task?.outputPath);
54
+ const kind = normalizeTaskOutputKind(task?.outputKind, path);
55
+ return { kind, path: isFileOutputKind(kind) ? path : '' };
56
+ }
57
+
58
+ /**
59
+ * What the file at `filePath` is: `{ ok: true, size, mtimeMs }`, or
60
+ * `{ ok: false, code }` with code `invalid-path`, `missing`, `symlink`,
61
+ * `not-file` or `unreadable`.
62
+ */
63
+ export async function inspectOutputFile(filePath, { fsImpl = fs } = {}) {
64
+ const path = normalizeTaskOutputPath(filePath);
65
+ if (!path) return { ok: false, code: 'invalid-path' };
66
+ let stats;
67
+ try {
68
+ stats = await fsImpl.promises.lstat(path);
69
+ } catch (error) {
70
+ return { ok: false, code: error?.code === 'ENOENT' ? 'missing' : 'unreadable' };
71
+ }
72
+ if (stats.isSymbolicLink()) return { ok: false, code: 'symlink' };
73
+ if (!stats.isFile()) return { ok: false, code: 'not-file' };
74
+ return { ok: true, size: stats.size, mtimeMs: stats.mtimeMs };
75
+ }
76
+
77
+ // Reads [start, end) of the file. Opened without following a symlink where
78
+ // the platform can, and checked again on the open handle: a path swapped
79
+ // between the lstat and the open is refused.
80
+ async function readBytes(path, start, end, { fsImpl = fs } = {}) {
81
+ // O_NONBLOCK: a FIFO swapped in after the lstat must not hold a pool
82
+ // thread until a writer appears (no effect on a regular file).
83
+ const flags = fsImpl.constants.O_RDONLY | (fsImpl.constants.O_NOFOLLOW || 0) | (fsImpl.constants.O_NONBLOCK || 0);
84
+ let handle;
85
+ try {
86
+ handle = await fsImpl.promises.open(path, flags);
87
+ } catch (error) {
88
+ return { ok: false, code: error?.code === 'ENOENT' ? 'missing' : (error?.code === 'ELOOP' ? 'symlink' : 'unreadable') };
89
+ }
90
+ try {
91
+ const stats = await handle.stat();
92
+ if (!stats.isFile()) return { ok: false, code: 'not-file' };
93
+ const size = stats.size;
94
+ const from = Math.max(0, Math.min(start, size));
95
+ const to = Math.max(from, Math.min(end, size));
96
+ const buffer = Buffer.alloc(to - from);
97
+ let read = 0;
98
+ while (read < buffer.length) {
99
+ const { bytesRead } = await handle.read(buffer, read, buffer.length - read, from + read);
100
+ if (!bytesRead) break;
101
+ read += bytesRead;
102
+ }
103
+ return { ok: true, buffer: buffer.subarray(0, read), start: from, size, mtimeMs: stats.mtimeMs };
104
+ } catch {
105
+ return { ok: false, code: 'unreadable' };
106
+ } finally {
107
+ try { await handle.close(); } catch {}
108
+ }
109
+ }
110
+
111
+ /**
112
+ * How many bytes of `buffer` can be decoded and cleaned now without cutting
113
+ * a UTF-8 character or an escape sequence in two: the rest waits for the
114
+ * next read (the writer is mid-write).
115
+ */
116
+ export function safeChunkLength(buffer) {
117
+ let end = buffer.length;
118
+ // An incomplete UTF-8 sequence at the end.
119
+ let lead = end - 1;
120
+ let continuation = 0;
121
+ while (lead >= 0 && continuation < 3 && (buffer[lead] & 0xc0) === 0x80) {
122
+ lead -= 1;
123
+ continuation += 1;
124
+ }
125
+ if (lead >= 0) {
126
+ const byte = buffer[lead];
127
+ const width = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1;
128
+ if (width > 1 && continuation + 1 < width) end = lead;
129
+ }
130
+ // An escape sequence still open at the end (ESC, ESC [ 3 1, ESC ] title…).
131
+ const tailStart = Math.max(0, end - 256);
132
+ const tail = buffer.subarray(tailStart, end).toString('latin1');
133
+ const esc = tail.lastIndexOf('\x1b');
134
+ if (esc !== -1) {
135
+ const rest = tail.slice(esc);
136
+ const complete = /^\x1b\[[0-?]*[ -/]*[@-~]/.test(rest)
137
+ || /^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/.test(rest)
138
+ || /^\x1b[^[\]]/.test(rest);
139
+ // An OSC that never ends within 256 bytes is garbage; let it through.
140
+ const hopelessOsc = rest.startsWith('\x1b]') && rest.length >= 256;
141
+ if (!complete && !hopelessOsc) end = tailStart + esc;
142
+ }
143
+ return end;
144
+ }
145
+
146
+ // The byte offset just after the first newline at or after `from`, or -1.
147
+ function afterNewline(buffer, from = 0) {
148
+ const at = buffer.indexOf(NEWLINE, from);
149
+ return at === -1 ? -1 : at + 1;
150
+ }
151
+
152
+ // The start of the last `count` lines of `buffer` (a line ends with "\n";
153
+ // an unterminated last line counts as one).
154
+ function startOfLastLines(buffer, count) {
155
+ let lines = 0;
156
+ let position = buffer.length;
157
+ if (position > 0 && buffer[position - 1] === NEWLINE) position -= 1;
158
+ while (position > 0) {
159
+ const at = buffer.lastIndexOf(NEWLINE, position - 1);
160
+ lines += 1;
161
+ if (lines >= count || at === -1) return lines >= count ? at + 1 : 0;
162
+ position = at;
163
+ }
164
+ return 0;
165
+ }
166
+
167
+ function skipContinuationBytes(buffer, from) {
168
+ let start = from;
169
+ while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start += 1;
170
+ return start;
171
+ }
172
+
173
+ function shapeWindow(buffer, windowStart, size, mtimeMs) {
174
+ const text = cleanTaskOutput(buffer.toString('utf8'));
175
+ return {
176
+ ok: true,
177
+ text,
178
+ startOffset: windowStart,
179
+ endOffset: windowStart + buffer.length,
180
+ size,
181
+ mtime: mtimeMs,
182
+ hasMoreBefore: windowStart > 0,
183
+ lines: foldTaskOutput(text),
184
+ };
185
+ }
186
+
187
+ // The last `lines` lines of the bytes before `end`, within `maxBytes`.
188
+ async function readLinesEndingAt(filePath, end, { lines, maxBytes, trimEnd, fsImpl }) {
189
+ const windowStart = Math.max(0, end - maxBytes);
190
+ const read = await readBytes(filePath, windowStart, end, { fsImpl });
191
+ if (!read.ok) return read;
192
+ let buffer = read.buffer;
193
+ let start = 0;
194
+ // A window that starts mid-file starts on the next line; one long line
195
+ // with no newline at all starts on the next character.
196
+ if (read.start > 0) {
197
+ const next = afterNewline(buffer);
198
+ start = next === -1 || next >= buffer.length ? skipContinuationBytes(buffer, 0) : next;
199
+ }
200
+ buffer = buffer.subarray(start);
201
+ const fromLines = startOfLastLines(buffer, lines);
202
+ buffer = buffer.subarray(fromLines);
203
+ const firstByte = read.start + start + fromLines;
204
+ if (trimEnd) buffer = buffer.subarray(0, safeChunkLength(buffer));
205
+ return shapeWindow(buffer, firstByte, read.size, read.mtimeMs);
206
+ }
207
+
208
+ /**
209
+ * The end of a task's output: the last `lines` lines (default 2,000, at most
210
+ * 5,000) within `maxBytes` (at most 256 KB). Returns `{ ok, text,
211
+ * startOffset, endOffset, size, mtime, hasMoreBefore, lines }`: `text` the
212
+ * cleaned slice (for a viewer's own folder), `lines` the processed line
213
+ * model, offsets in bytes of the file. The end stops short of a character or
214
+ * escape the writer has not finished; `endOffset` is where the live chunks
215
+ * continue. `{ ok: false, code }` when the file cannot be read.
216
+ */
217
+ export async function readTail(filePath, { lines, maxBytes, fsImpl = fs } = {}) {
218
+ const info = await inspectOutputFile(filePath, { fsImpl });
219
+ if (!info.ok) return info;
220
+ return readLinesEndingAt(normalizeTaskOutputPath(filePath), info.size, {
221
+ lines: clampInt(lines, { min: 1, max: TASK_OUTPUT_LIMITS.tailLinesMax, fallback: TASK_OUTPUT_LIMITS.tailLinesDefault }),
222
+ maxBytes: clampInt(maxBytes, { min: 1, max: TASK_OUTPUT_LIMITS.requestBytesMax, fallback: TASK_OUTPUT_LIMITS.requestBytesMax }),
223
+ trimEnd: true,
224
+ fsImpl,
225
+ });
226
+ }
227
+
228
+ /** The `lines` lines that end at byte offset `before` (a `startOffset` an earlier read returned). */
229
+ export async function readBefore(filePath, { before, lines, maxBytes, fsImpl = fs } = {}) {
230
+ const info = await inspectOutputFile(filePath, { fsImpl });
231
+ if (!info.ok) return info;
232
+ const end = clampInt(before, { min: 0, max: info.size, fallback: info.size });
233
+ return readLinesEndingAt(normalizeTaskOutputPath(filePath), end, {
234
+ lines: clampInt(lines, { min: 1, max: TASK_OUTPUT_LIMITS.tailLinesMax, fallback: TASK_OUTPUT_LIMITS.tailLinesDefault }),
235
+ maxBytes: clampInt(maxBytes, { min: 1, max: TASK_OUTPUT_LIMITS.requestBytesMax, fallback: TASK_OUTPUT_LIMITS.requestBytesMax }),
236
+ trimEnd: false,
237
+ fsImpl,
238
+ });
239
+ }
240
+
241
+ /**
242
+ * The bytes from `start` on, at most `maxBytes` (64 KB), for a live chunk:
243
+ * `{ ok, offset, endOffset, text, size, mtime }` with `text` cleaned and the
244
+ * end on a safe boundary. `endOffset === offset` when nothing complete is
245
+ * there yet.
246
+ */
247
+ export async function readRange(filePath, {
248
+ start,
249
+ maxBytes = TASK_OUTPUT_LIMITS.chunkBytes,
250
+ // Stop after the last line break (`\n` or `\r`): credentials are scrubbed
251
+ // per piece, so a piece must not end inside one. An open line goes out
252
+ // only when it is `longLineBytes` long, or with a later call without this.
253
+ holdOpenLine = false,
254
+ longLineBytes = TASK_OUTPUT_LIMITS.chunkBytes,
255
+ fsImpl = fs,
256
+ } = {}) {
257
+ const path = normalizeTaskOutputPath(filePath);
258
+ if (!path) return { ok: false, code: 'invalid-path' };
259
+ const from = Math.max(0, Math.trunc(Number(start) || 0));
260
+ const read = await readBytes(path, from, from + Math.max(1, maxBytes), { fsImpl });
261
+ if (!read.ok) return read;
262
+ let length = safeChunkLength(read.buffer);
263
+ if (holdOpenLine && length > 0) {
264
+ let cut = -1;
265
+ for (let index = length - 1; index >= 0; index -= 1) {
266
+ const byte = read.buffer[index];
267
+ if (byte === NEWLINE || byte === 0x0d) {
268
+ cut = index + 1;
269
+ break;
270
+ }
271
+ }
272
+ if (cut > 0) length = cut;
273
+ else if (length < longLineBytes) length = 0;
274
+ }
275
+ return {
276
+ ok: true,
277
+ offset: read.start,
278
+ endOffset: read.start + length,
279
+ text: cleanTaskOutput(read.buffer.subarray(0, length).toString('utf8')),
280
+ size: read.size,
281
+ mtime: read.mtimeMs,
282
+ };
283
+ }
284
+
285
+ // ─── Snapshots ───────────────────────────────────────────────────────────────
286
+
287
+ /**
288
+ * The output a worker posted for a task with no file (`output-snapshot`),
289
+ * per store key and task; kept an hour like the ended rows.
290
+ */
291
+ export function createTaskOutputSnapshots({ now = () => Date.now(), keepMs = TASK_OUTPUT_LIMITS.endedKeepMs, max = 200 } = {}) {
292
+ const snapshots = new Map();
293
+ const keyOf = (key, taskId) => `${String(key || '').trim()}\u0000${String(taskId || '').trim()}`;
294
+
295
+ function prune() {
296
+ const cutoff = now() - keepMs;
297
+ for (const [key, entry] of snapshots) if (entry.at < cutoff) snapshots.delete(key);
298
+ while (snapshots.size > max) snapshots.delete(snapshots.keys().next().value);
299
+ }
300
+
301
+ return {
302
+ set(key, taskId, { output, totalBytes, truncated } = {}) {
303
+ const text = String(output ?? '').slice(0, 64 * 1024);
304
+ const bytes = Buffer.byteLength(text, 'utf8');
305
+ const total = Number.isInteger(Number(totalBytes)) && Number(totalBytes) >= bytes ? Number(totalBytes) : bytes;
306
+ const id = keyOf(key, taskId);
307
+ snapshots.delete(id);
308
+ snapshots.set(id, { output: text, totalBytes: total, truncated: truncated === true || total > bytes, at: now() });
309
+ prune();
310
+ },
311
+ get(...args) {
312
+ prune();
313
+ const [keys, taskId] = args;
314
+ for (const key of Array.isArray(keys) ? keys : [keys]) {
315
+ const entry = snapshots.get(keyOf(key, taskId));
316
+ if (entry) return entry;
317
+ }
318
+ return null;
319
+ },
320
+ forget(key, taskId) {
321
+ snapshots.delete(keyOf(key, taskId));
322
+ },
323
+ };
324
+ }
325
+
326
+ /** A snapshot as the output route answers it. */
327
+ export function shapeSnapshot(snapshot) {
328
+ const text = cleanTaskOutput(snapshot.output);
329
+ const bytes = Buffer.byteLength(snapshot.output, 'utf8');
330
+ return {
331
+ text,
332
+ startOffset: Math.max(0, snapshot.totalBytes - bytes),
333
+ endOffset: snapshot.totalBytes,
334
+ size: snapshot.totalBytes,
335
+ mtime: snapshot.at,
336
+ hasMoreBefore: false,
337
+ truncated: !!snapshot.truncated,
338
+ lines: foldTaskOutput(text),
339
+ };
340
+ }
341
+
342
+ // ─── Health ──────────────────────────────────────────────────────────────────
343
+
344
+ /**
345
+ * Runner counts of the window `buffer` that starts at byte `windowStart` of
346
+ * the file: `{ counts, runner, final, firstFailureOffset }`, the offset the
347
+ * byte where the first failure's line starts (null when there is none in the
348
+ * window).
349
+ */
350
+ export function healthOfWindow(buffer, windowStart) {
351
+ let start = 0;
352
+ if (windowStart > 0) {
353
+ const next = afterNewline(buffer);
354
+ start = next === -1 ? buffer.length : next;
355
+ }
356
+ const texts = [];
357
+ const offsets = [];
358
+ let position = start;
359
+ while (position < buffer.length) {
360
+ const at = buffer.indexOf(NEWLINE, position);
361
+ const end = at === -1 ? buffer.length : at;
362
+ const raw = buffer.subarray(position, end).toString('utf8');
363
+ // A `\r` redraw keeps its last state, as the console shows it.
364
+ const body = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
365
+ texts.push(stripControlKeepSgr(body.slice(body.lastIndexOf('\r') + 1)));
366
+ offsets.push(windowStart + position);
367
+ position = end + 1;
368
+ }
369
+ const summary = parseRunnerSummary(texts);
370
+ if (!summary) return { counts: null, runner: null, final: false, firstFailureOffset: null };
371
+ return {
372
+ counts: { passed: summary.passed, failed: summary.failed, skipped: summary.skipped, ...(summary.flaky ? { flaky: summary.flaky } : {}) },
373
+ runner: summary.runner,
374
+ final: summary.final,
375
+ firstFailureOffset: Number.isInteger(summary.firstFailureLine) ? offsets[summary.firstFailureLine] : null,
376
+ };
377
+ }
378
+
379
+ function sameHealthFacts(a, b) {
380
+ if (!a || !b) return false;
381
+ return a.runner === b.runner
382
+ && a.final === b.final
383
+ && a.firstFailureOffset === b.firstFailureOffset
384
+ && JSON.stringify(a.counts) === JSON.stringify(b.counts);
385
+ }
386
+
387
+ /**
388
+ * Every `intervalMs` (10 s) over the live tasks with an output file: `stat`,
389
+ * and when the size or time changed, the last 64 KB parsed for runner
390
+ * counts. Readings go into the store (`setHealth`); a key's readings go to
391
+ * the pages as `background_tasks_health { conversationId, health }` when the
392
+ * counts change, and when the last output moved on by a minute or more (the
393
+ * stall hint counts minutes). `conversationId` is the store key, as in
394
+ * `background_tasks`.
395
+ *
396
+ * Ended rows are read too, until a reading finds the file as the last one
397
+ * did: a task that ends between two ticks has its last output (a runner's
398
+ * summary) read after the end, onto the ended row and to the pages. `poke`
399
+ * runs a tick soon, for the moment a task ends.
400
+ */
401
+ export function createTaskHealthPoller({
402
+ store,
403
+ io,
404
+ intervalMs = 10_000,
405
+ windowBytes = TASK_OUTPUT_LIMITS.healthBytes,
406
+ fsImpl = fs,
407
+ logger = console,
408
+ setIntervalImpl = setInterval,
409
+ clearIntervalImpl = clearInterval,
410
+ setTimeoutImpl = setTimeout,
411
+ pokeDelayMs = 250,
412
+ } = {}) {
413
+ let timer = null;
414
+ let running = false;
415
+ let again = false;
416
+ let poked = null;
417
+ // key → taskId → the reading last sent to the pages.
418
+ const sent = new Map();
419
+ // key → taskId → the `endedAt` of the ended row whose file was read after
420
+ // the end and found unchanged: nothing more to read for it.
421
+ const settledEnded = new Map();
422
+
423
+ async function readHealth(task, previous) {
424
+ const { path } = taskOutputSource(task);
425
+ if (!path) return null;
426
+ const info = await inspectOutputFile(path, { fsImpl });
427
+ // A file not there yet (a spool is named before its first byte): no
428
+ // output yet, quiet since the task started.
429
+ if (!info.ok && info.code === 'missing' && !previous?.size) {
430
+ const startedAt = Number(task?.startedAt) || null;
431
+ return { lastOutputAt: startedAt, size: 0, counts: null, runner: null, final: false, firstFailureOffset: null };
432
+ }
433
+ if (!info.ok) return previous || null;
434
+ if (previous && previous.size === info.size && previous.lastOutputAt === info.mtimeMs) return previous;
435
+ const windowStart = Math.max(0, info.size - windowBytes);
436
+ const read = await readBytes(path, windowStart, info.size, { fsImpl });
437
+ if (!read.ok) return previous || null;
438
+ return {
439
+ lastOutputAt: info.mtimeMs,
440
+ size: info.size,
441
+ ...healthOfWindow(read.buffer, read.start),
442
+ };
443
+ }
444
+
445
+ // The same file as the last reading saw, with the same facts.
446
+ function sameReading(a, b) {
447
+ if (a === b) return true;
448
+ return !!a && !!b && a.size === b.size && a.lastOutputAt === b.lastOutputAt && sameHealthFacts(a, b);
449
+ }
450
+
451
+ // The ended rows of `key` read after their end: `{ taskId: reading }` of
452
+ // those whose reading changed.
453
+ async function readEnded(key) {
454
+ const changed = {};
455
+ const rows = store.ended?.(key) || [];
456
+ const done = settledEnded.get(key) || new Map();
457
+ const kept = new Map();
458
+ for (const row of rows) {
459
+ const taskId = String(row?.taskId || '').trim();
460
+ if (!taskId || !taskOutputSource(row).path) continue;
461
+ if (done.get(taskId) === row.endedAt) {
462
+ kept.set(taskId, row.endedAt);
463
+ continue;
464
+ }
465
+ let reading = null;
466
+ try {
467
+ reading = await readHealth(row, row.health || null);
468
+ } catch (error) {
469
+ logger?.warn?.(`[task-output] health read failed for ended task ${taskId.slice(0, 12)}: ${error?.message || error}`);
470
+ }
471
+ if (!reading) continue;
472
+ if (sameReading(row.health, reading)) {
473
+ kept.set(taskId, row.endedAt);
474
+ continue;
475
+ }
476
+ if (store.setEndedHealth?.(key, taskId, reading)) changed[taskId] = reading;
477
+ }
478
+ if (kept.size) settledEnded.set(key, kept);
479
+ else settledEnded.delete(key);
480
+ return changed;
481
+ }
482
+
483
+ async function tick({ poked: fromPoke = false } = {}) {
484
+ if (!store?.sets) return;
485
+ if (running) {
486
+ if (fromPoke) again = true;
487
+ return;
488
+ }
489
+ running = true;
490
+ try {
491
+ // Ended rows of conversations nobody opens again expire here.
492
+ store.pruneAll?.();
493
+ const keys = new Set([...store.sets.keys(), ...(store.endedKeys?.() || [])]);
494
+ for (const key of keys) {
495
+ const tasks = Array.isArray(store.sets.get(key)?.tasks) ? store.sets.get(key).tasks : [];
496
+ const changed = {};
497
+ let anyChange = false;
498
+ const current = {};
499
+ for (const task of tasks) {
500
+ const taskId = String(task?.taskId || '').trim();
501
+ if (!taskId || !taskOutputSource(task).path) continue;
502
+ let reading = null;
503
+ try {
504
+ reading = await readHealth(task, store.health(key, taskId));
505
+ } catch (error) {
506
+ logger?.warn?.(`[task-output] health read failed for task ${taskId.slice(0, 12)}: ${error?.message || error}`);
507
+ }
508
+ if (!reading) continue;
509
+ store.setHealth(key, taskId, reading);
510
+ current[taskId] = reading;
511
+ const last = sent.get(key)?.get(taskId);
512
+ if (!last || !sameHealthFacts(last, reading) || reading.lastOutputAt - last.lastOutputAt >= 60_000) {
513
+ changed[taskId] = reading;
514
+ anyChange = true;
515
+ }
516
+ }
517
+ const endedChanged = await readEnded(key);
518
+ if (Object.keys(endedChanged).length) anyChange = true;
519
+ if (!anyChange) continue;
520
+ const record = new Map(sent.get(key) || []);
521
+ for (const [taskId, reading] of Object.entries(changed)) record.set(taskId, reading);
522
+ // Tasks gone from the set leave the record.
523
+ for (const taskId of [...record.keys()]) if (!current[taskId]) record.delete(taskId);
524
+ sent.set(key, record);
525
+ try {
526
+ io?.emit?.('background_tasks_health', { conversationId: key, health: { ...current, ...endedChanged } });
527
+ } catch {}
528
+ }
529
+ for (const key of [...sent.keys()]) if (!store.sets.has(key)) sent.delete(key);
530
+ for (const key of [...settledEnded.keys()]) if (!keys.has(key)) settledEnded.delete(key);
531
+ } finally {
532
+ running = false;
533
+ }
534
+ // A poke during the tick: once more, for what it may have missed.
535
+ if (again) {
536
+ again = false;
537
+ await tick();
538
+ }
539
+ }
540
+
541
+ return {
542
+ tick,
543
+ /** A task ended: a tick soon rather than within 10 s. */
544
+ poke() {
545
+ if (poked) return;
546
+ poked = setTimeoutImpl(() => {
547
+ poked = null;
548
+ tick({ poked: true }).catch(() => {});
549
+ }, pokeDelayMs);
550
+ poked?.unref?.();
551
+ },
552
+ start() {
553
+ if (timer) return;
554
+ timer = setIntervalImpl(() => { tick().catch(() => {}); }, intervalMs);
555
+ timer?.unref?.();
556
+ },
557
+ stop() {
558
+ if (timer) clearIntervalImpl(timer);
559
+ timer = null;
560
+ },
561
+ };
562
+ }
@@ -0,0 +1,83 @@
1
+ // How long a backgrounded Bash command of a Claude session may run before
2
+ // Claude Code stops it, when the agent passes no `timeout` of its own. The
3
+ // CLI's own default is 30 minutes; long test or GPU runs were cut off there.
4
+ //
5
+ // Shared so the settings slider, the relay that stores the value and the
6
+ // worker that hands it to the CLI agree on bounds, steps and formatting. The
7
+ // browser mirrors the default in
8
+ // server/public/app/claude-background-command-limit-options.mjs (only
9
+ // server/public is served), and the slider's static markup in
10
+ // server/public/index.html starts at it; unit tests pin both to this module.
11
+
12
+ export const CLAUDE_BACKGROUND_COMMAND_LIMIT_MIN_MINUTES = 30;
13
+ export const CLAUDE_BACKGROUND_COMMAND_LIMIT_MAX_MINUTES = 480;
14
+ export const CLAUDE_BACKGROUND_COMMAND_LIMIT_STEP_MINUTES = 30;
15
+ export const DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES = 120;
16
+
17
+ /** Clamped to 30 min … 8 h and snapped to the slider's 30-minute steps. */
18
+ export function normalizeClaudeBackgroundCommandLimitMinutes(value, fallback = DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES) {
19
+ if (value === null || value === undefined) return fallback;
20
+ if (typeof value === 'string' && value.trim() === '') return fallback;
21
+ if (typeof value !== 'number' && typeof value !== 'string') return fallback;
22
+ const numeric = Number(value);
23
+ if (!Number.isFinite(numeric)) return fallback;
24
+ const step = CLAUDE_BACKGROUND_COMMAND_LIMIT_STEP_MINUTES;
25
+ const snapped = Math.round(numeric / step) * step;
26
+ return Math.min(
27
+ CLAUDE_BACKGROUND_COMMAND_LIMIT_MAX_MINUTES,
28
+ Math.max(CLAUDE_BACKGROUND_COMMAND_LIMIT_MIN_MINUTES, snapped),
29
+ );
30
+ }
31
+
32
+ export function claudeBackgroundCommandLimitMinutesToMs(minutes) {
33
+ return normalizeClaudeBackgroundCommandLimitMinutes(minutes) * 60_000;
34
+ }
35
+
36
+ export const DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS = DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES * 60_000;
37
+
38
+ /** An unset setting means "never configured", which is the default. */
39
+ export function readClaudeBackgroundCommandLimitSetting(storedValue) {
40
+ const text = storedValue === null || storedValue === undefined ? '' : String(storedValue).trim();
41
+ return normalizeClaudeBackgroundCommandLimitMinutes(text);
42
+ }
43
+
44
+ export function parseClaudeBackgroundCommandLimitUpdate(value) {
45
+ const minutes = normalizeClaudeBackgroundCommandLimitMinutes(value, null);
46
+ if (minutes === null) return { ok: false, error: 'backgroundCommandLimitMinutes must be a number' };
47
+ return { ok: true, minutes };
48
+ }
49
+
50
+ export function formatClaudeBackgroundCommandLimitLabel(minutes) {
51
+ const normalized = normalizeClaudeBackgroundCommandLimitMinutes(minutes);
52
+ if (normalized < 60) return `${normalized} min`;
53
+ const hours = Math.floor(normalized / 60);
54
+ const remainder = normalized % 60;
55
+ return remainder ? `${hours} h ${remainder} min` : `${hours} h`;
56
+ }
57
+
58
+ /** The fields every Claude settings payload carries for the slider. */
59
+ export function claudeBackgroundCommandLimitSettingsFields(settings = {}) {
60
+ return {
61
+ backgroundCommandLimitMinutes: normalizeClaudeBackgroundCommandLimitMinutes(settings?.backgroundCommandLimitMinutes),
62
+ backgroundCommandLimitMinMinutes: CLAUDE_BACKGROUND_COMMAND_LIMIT_MIN_MINUTES,
63
+ backgroundCommandLimitMaxMinutes: CLAUDE_BACKGROUND_COMMAND_LIMIT_MAX_MINUTES,
64
+ backgroundCommandLimitStepMinutes: CLAUDE_BACKGROUND_COMMAND_LIMIT_STEP_MINUTES,
65
+ backgroundCommandLimitDefaultMinutes: DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * What a delivery's `settings` does to the worker's limit (ms): an absent or
71
+ * junk key (an older relay) keeps the last known value; a present one is
72
+ * clamped like the setting itself.
73
+ */
74
+ export function resolveDeliveredClaudeBackgroundCommandLimitMs(current, settings) {
75
+ const kept = Number.isFinite(Number(current)) && Number(current) > 0
76
+ ? claudeBackgroundCommandLimitMinutesToMs(Number(current) / 60_000)
77
+ : DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS;
78
+ if (!settings || typeof settings !== 'object') return kept;
79
+ if (!Object.prototype.hasOwnProperty.call(settings, 'claudeBackgroundCommandLimitMs')) return kept;
80
+ const delivered = Number(settings.claudeBackgroundCommandLimitMs);
81
+ if (settings.claudeBackgroundCommandLimitMs === null || !Number.isFinite(delivered) || delivered <= 0) return kept;
82
+ return claudeBackgroundCommandLimitMinutesToMs(delivered / 60_000);
83
+ }