@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,630 @@
1
+ // The task console: a shell or Monitor task's output, opened from its row in
2
+ // the composer's background-task panel. The last lines come from
3
+ // GET /api/conversation/:id/background-task/:taskId/output; new bytes arrive
4
+ // as `task_output_chunk`, which the relay sends only to pages watching the
5
+ // task (task-console-watch.mjs). A chunk that does not continue where the
6
+ // view stands (a dropped chunk, a rewritten file) reads the tail again.
7
+ // "Load earlier" pages back up to 5 MB. A provider that can only give a
8
+ // snapshot is read again every 3 s instead. Full-screen on phones; Escape,
9
+ // ×, a click outside and the system back close it.
10
+
11
+ import { apiFetchOrThrow } from './api-client.js';
12
+ import { currentConvId } from './store.js';
13
+ import { createViewerHistory } from './viewer-history.mjs';
14
+ import { getSocket } from './socket-handlers.js';
15
+ import { createTaskConsoleWatch } from './task-console-watch.mjs';
16
+ import { createLineFolder } from './task-output-text.mjs';
17
+ import {
18
+ findBackgroundTask,
19
+ getBackgroundTaskHealth,
20
+ getTaskStallMinutes,
21
+ isBackgroundTaskStopping,
22
+ stopBackgroundTask,
23
+ subscribeBackgroundTasks,
24
+ } from './background-tasks-view.mjs';
25
+ import {
26
+ buildLineNode,
27
+ chunkAction,
28
+ chunkEndOffset,
29
+ consoleMetaParts,
30
+ findFirstFailureIndex,
31
+ loadEarlierState,
32
+ runnerChip,
33
+ stallHint,
34
+ } from './task-console-render.mjs';
35
+
36
+ const TAIL_LINES = 2000;
37
+ const SNAPSHOT_POLL_MS = 3000;
38
+
39
+ const tc = {
40
+ open: false,
41
+ conversationId: '',
42
+ taskId: '',
43
+ kind: '',
44
+ message: '',
45
+ error: '',
46
+ loading: false,
47
+ loadingEarlier: false,
48
+ fetchToken: 0,
49
+ // Lines paged in by "Load earlier" (completed lines, oldest first) and
50
+ // their nodes; the tail and everything live goes through the folder.
51
+ earlierLines: [],
52
+ earlierNodes: [],
53
+ folder: null,
54
+ folded: { lines: [], partial: '' },
55
+ liveNodes: [],
56
+ partialNode: null,
57
+ startOffset: 0,
58
+ nextOffset: 0,
59
+ size: 0,
60
+ hasMoreBefore: false,
61
+ loadedBytes: 0,
62
+ ended: false,
63
+ status: '',
64
+ exitCode: null,
65
+ health: null,
66
+ following: true,
67
+ pendingChunks: null,
68
+ refetching: false,
69
+ snapshotTimer: null,
70
+ tickTimer: null,
71
+ note: '',
72
+ };
73
+
74
+ let consoleHistory = null;
75
+ let initialized = false;
76
+ const consoleWatch = createTaskConsoleWatch({ getSocket });
77
+
78
+ function elements() {
79
+ return {
80
+ modal: document.getElementById('task-console-modal'),
81
+ title: document.getElementById('task-console-title'),
82
+ meta: document.getElementById('task-console-meta'),
83
+ stall: document.getElementById('task-console-stall'),
84
+ chip: document.getElementById('task-console-chip'),
85
+ failure: document.getElementById('task-console-failure'),
86
+ stop: document.getElementById('task-console-stop'),
87
+ scroll: document.getElementById('task-console-scroll'),
88
+ earlier: document.getElementById('task-console-earlier'),
89
+ body: document.getElementById('task-console-body'),
90
+ status: document.getElementById('task-console-status'),
91
+ jump: document.getElementById('task-console-jump'),
92
+ };
93
+ }
94
+
95
+ function isAtBottom(scroll) {
96
+ return scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 24;
97
+ }
98
+
99
+ function formatDuration(fromMs, toMs) {
100
+ if (!Number.isFinite(fromMs) || fromMs <= 0 || !Number.isFinite(toMs) || toMs < fromMs) return '';
101
+ const seconds = Math.floor((toMs - fromMs) / 1000);
102
+ if (seconds < 60) return `${seconds}s`;
103
+ const minutes = Math.floor(seconds / 60);
104
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
105
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
106
+ }
107
+
108
+ function toMillis(value) {
109
+ const number = Number(value);
110
+ if (Number.isFinite(number)) return number;
111
+ return Date.parse(String(value || ''));
112
+ }
113
+
114
+ function currentTask() {
115
+ return findBackgroundTask(tc.conversationId, tc.taskId);
116
+ }
117
+
118
+ // ─── Header ──────────────────────────────────────────────────────────────────
119
+
120
+ function renderHeader() {
121
+ const { title, meta, stall, chip, failure, stop } = elements();
122
+ const found = currentTask();
123
+ // The panel's row, else what the output answer said about the task.
124
+ const task = found?.task || tc.answerTask || { taskId: tc.taskId };
125
+ const ended = tc.ended || !!found?.ended;
126
+ const status = found?.ended ? (found.task.status ?? tc.status) : tc.status;
127
+ const exitCode = found?.ended ? (found.task.exitCode ?? tc.exitCode) : tc.exitCode;
128
+ if (title) title.textContent = String(task.description || tc.taskId || 'Task');
129
+ const started = toMillis(task.startedAt);
130
+ const endedAt = ended ? toMillis(task.endedAt) : Date.now();
131
+ if (meta) {
132
+ meta.textContent = consoleMetaParts({
133
+ task,
134
+ elapsed: formatDuration(started, Number.isFinite(endedAt) ? endedAt : NaN),
135
+ ended,
136
+ status,
137
+ exitCode,
138
+ kind: tc.kind,
139
+ }).join(' · ');
140
+ }
141
+ const health = getBackgroundTaskHealth(tc.conversationId, tc.taskId) || tc.health;
142
+ if (stall) {
143
+ const hint = ended ? null : stallHint(health?.lastOutputAt, getTaskStallMinutes(tc.conversationId));
144
+ stall.hidden = !hint;
145
+ stall.textContent = hint ? hint.text : '';
146
+ stall.className = hint?.warn ? 'tc-stall tc-stall-warn' : 'tc-stall';
147
+ }
148
+ const chipInfo = runnerChip(health);
149
+ if (chip) {
150
+ chip.hidden = !chipInfo;
151
+ chip.textContent = chipInfo ? chipInfo.text : '';
152
+ chip.title = chipInfo ? chipInfo.title : '';
153
+ chip.className = chipInfo?.failed ? 'bg-task-chip bg-task-chip-fail' : 'bg-task-chip';
154
+ }
155
+ if (failure) failure.hidden = !chipInfo?.failed;
156
+ if (stop) {
157
+ const stoppable = !ended && !!found && task.stoppable !== false;
158
+ const stopping = isBackgroundTaskStopping(tc.conversationId, tc.taskId);
159
+ stop.hidden = !stoppable;
160
+ stop.disabled = stopping;
161
+ stop.textContent = stopping ? 'Stopping…' : 'Stop';
162
+ }
163
+ }
164
+
165
+ function renderStatus() {
166
+ const { status } = elements();
167
+ if (!status) return;
168
+ const hasLines = tc.earlierLines.length || tc.folded.lines.length || tc.folded.partial;
169
+ let text = '';
170
+ if (tc.error) text = tc.error;
171
+ else if (tc.kind === 'none') text = tc.message || 'output not available for this provider';
172
+ else if (tc.loading && !hasLines) text = 'Loading…';
173
+ else if (!hasLines && !tc.note) text = tc.ended ? 'This task wrote no output.' : 'No output yet. Lines appear as the task writes them.';
174
+ if (tc.note) text = text ? `${text} ${tc.note}` : tc.note;
175
+ status.textContent = text;
176
+ status.hidden = !text;
177
+ }
178
+
179
+ function renderEarlierButton() {
180
+ const { earlier } = elements();
181
+ if (!earlier) return;
182
+ const state = loadEarlierState({ hasMoreBefore: tc.hasMoreBefore, loadedBytes: tc.loadedBytes, loading: tc.loadingEarlier });
183
+ earlier.hidden = state.hidden;
184
+ earlier.disabled = state.disabled;
185
+ earlier.textContent = state.text;
186
+ }
187
+
188
+ // ─── Lines ───────────────────────────────────────────────────────────────────
189
+
190
+ function clearLines() {
191
+ const { body, jump } = elements();
192
+ if (body) body.textContent = '';
193
+ tc.earlierLines = [];
194
+ tc.earlierNodes = [];
195
+ tc.liveNodes = [];
196
+ tc.partialNode = null;
197
+ tc.folder = createLineFolder();
198
+ tc.folded = { lines: [], partial: '' };
199
+ if (jump) jump.hidden = true;
200
+ }
201
+
202
+ function afterContentChange(appended) {
203
+ const { scroll, jump } = elements();
204
+ renderStatus();
205
+ if (!scroll) return;
206
+ if (tc.following) {
207
+ scroll.scrollTop = scroll.scrollHeight;
208
+ if (jump) jump.hidden = true;
209
+ } else if (appended && jump) {
210
+ jump.hidden = false;
211
+ }
212
+ }
213
+
214
+ /** Draw what the folder holds: the last known line again (its ×N can grow), then the new ones. */
215
+ function renderLive() {
216
+ const { body } = elements();
217
+ if (!body) return;
218
+ const { lines, partial } = tc.folded;
219
+ const before = tc.liveNodes.length;
220
+ let appended = false;
221
+ const from = Math.max(0, before - 1);
222
+ for (let index = from; index < lines.length; index += 1) {
223
+ const node = buildLineNode(document, lines[index]);
224
+ const existing = tc.liveNodes[index];
225
+ if (existing) {
226
+ existing.replaceWith(node);
227
+ } else {
228
+ body.insertBefore(node, tc.partialNode && tc.partialNode.parentNode === body ? tc.partialNode : null);
229
+ appended = true;
230
+ }
231
+ tc.liveNodes[index] = node;
232
+ }
233
+ if (partial) {
234
+ const node = buildLineNode(document, { text: partial, count: 1 }, { partial: true });
235
+ if (tc.partialNode?.parentNode === body) tc.partialNode.replaceWith(node);
236
+ else body.appendChild(node);
237
+ tc.partialNode = node;
238
+ appended = true;
239
+ } else if (tc.partialNode) {
240
+ tc.partialNode.remove();
241
+ tc.partialNode = null;
242
+ }
243
+ afterContentChange(appended);
244
+ }
245
+
246
+ function feed(text) {
247
+ tc.folded = tc.folder.feed(String(text ?? ''));
248
+ }
249
+
250
+ function prependEarlier(lines) {
251
+ const { body, scroll } = elements();
252
+ if (!body || !lines.length) return;
253
+ const heightBefore = scroll ? scroll.scrollHeight : 0;
254
+ const fragment = document.createDocumentFragment();
255
+ const nodes = lines.map((line) => buildLineNode(document, line));
256
+ for (const node of nodes) fragment.appendChild(node);
257
+ body.insertBefore(fragment, body.firstChild);
258
+ tc.earlierLines = [...lines, ...tc.earlierLines];
259
+ tc.earlierNodes = [...nodes, ...tc.earlierNodes];
260
+ // The reader stays on the line they were reading.
261
+ if (scroll) scroll.scrollTop += scroll.scrollHeight - heightBefore;
262
+ renderStatus();
263
+ }
264
+
265
+ function allLineNodes() {
266
+ return [...tc.earlierNodes, ...tc.liveNodes];
267
+ }
268
+
269
+ // ─── Loading ─────────────────────────────────────────────────────────────────
270
+
271
+ function outputUrl(params = '') {
272
+ return `/api/conversation/${encodeURIComponent(tc.conversationId)}/background-task/${encodeURIComponent(tc.taskId)}/output${params}`;
273
+ }
274
+
275
+ function errorText(error) {
276
+ if (error?.status === 404) return 'This task\'s output is no longer available.';
277
+ if (error?.status === 409) return 'The session\'s worker is not connected, so the output cannot be read now.';
278
+ return error?.message || 'The output could not be loaded.';
279
+ }
280
+
281
+ function applyAnswerMeta(data) {
282
+ tc.kind = String(data?.kind || tc.kind || 'file');
283
+ if (data?.health && typeof data.health === 'object') tc.health = data.health;
284
+ if (data?.ended === true) tc.ended = true;
285
+ if (data?.status !== undefined && data?.status !== null) tc.status = String(data.status);
286
+ if (data?.exitCode !== undefined) tc.exitCode = data.exitCode;
287
+ if (data?.task && typeof data.task === 'object') tc.answerTask = data.task;
288
+ }
289
+
290
+ /**
291
+ * Read the tail and draw it from scratch (first open, a gap, a snapshot
292
+ * tick). With `before`, the tail ends exactly where the task's room stands,
293
+ * so the room's next chunk continues it byte for byte.
294
+ */
295
+ async function fetchTail({ before = null } = {}) {
296
+ if (!tc.open) return;
297
+ const token = ++tc.fetchToken;
298
+ const key = `${tc.conversationId}:${tc.taskId}`;
299
+ tc.loading = true;
300
+ if (!tc.pendingChunks) tc.pendingChunks = [];
301
+ renderStatus();
302
+ let data;
303
+ try {
304
+ const params = Number.isInteger(before) && before >= 0
305
+ ? `?before=${before}&lines=${TAIL_LINES}`
306
+ : `?lines=${TAIL_LINES}`;
307
+ data = await apiFetchOrThrow(outputUrl(params));
308
+ } catch (error) {
309
+ if (token !== tc.fetchToken || !tc.open) return;
310
+ tc.error = errorText(error);
311
+ tc.loading = false;
312
+ tc.pendingChunks = null;
313
+ renderStatus();
314
+ return;
315
+ }
316
+ if (token !== tc.fetchToken || !tc.open || key !== `${tc.conversationId}:${tc.taskId}`) return;
317
+ tc.loading = false;
318
+ tc.error = '';
319
+ const pending = tc.pendingChunks || [];
320
+ tc.pendingChunks = null;
321
+ if (!data) {
322
+ renderStatus();
323
+ return;
324
+ }
325
+ applyAnswerMeta(data);
326
+ clearLines();
327
+ if (tc.kind === 'none') {
328
+ tc.message = String(data.message || '');
329
+ tc.hasMoreBefore = false;
330
+ syncSnapshotPoll();
331
+ renderEarlierButton();
332
+ renderHeader();
333
+ renderStatus();
334
+ return;
335
+ }
336
+ tc.note = data.unavailable
337
+ ? 'The output file can no longer be read.'
338
+ : data.pending ? 'Waiting for the worker to send the output…' : '';
339
+ tc.startOffset = Number(data.startOffset) || 0;
340
+ tc.nextOffset = Number.isFinite(Number(data.endOffset)) ? Number(data.endOffset) : Number(data.size) || 0;
341
+ tc.size = Number(data.size) || tc.nextOffset;
342
+ tc.hasMoreBefore = data.hasMoreBefore === true;
343
+ if (typeof data.text === 'string') {
344
+ feed(data.text);
345
+ tc.loadedBytes = Math.max(0, tc.nextOffset - tc.startOffset) || data.text.length;
346
+ } else {
347
+ // No raw slice (an older relay or a snapshot answer): the line model.
348
+ const lines = Array.isArray(data.lines) ? data.lines : [];
349
+ tc.folded = { lines: lines.map((line) => ({ ...line })), partial: '' };
350
+ tc.loadedBytes = Math.max(0, tc.nextOffset - tc.startOffset);
351
+ }
352
+ renderEarlierButton();
353
+ renderHeader();
354
+ renderLive();
355
+ syncSnapshotPoll();
356
+ // Chunks that came while the tail was read: those past it still count.
357
+ for (const chunk of pending) applyChunk(chunk);
358
+ }
359
+
360
+ async function loadEarlier() {
361
+ if (!tc.open || tc.loadingEarlier || !tc.hasMoreBefore) return;
362
+ if (loadEarlierState({ hasMoreBefore: true, loadedBytes: tc.loadedBytes }).disabled) return;
363
+ const token = tc.fetchToken;
364
+ tc.loadingEarlier = true;
365
+ renderEarlierButton();
366
+ try {
367
+ const data = await apiFetchOrThrow(outputUrl(`?before=${encodeURIComponent(tc.startOffset)}&lines=${TAIL_LINES}`));
368
+ if (token !== tc.fetchToken || !tc.open || !data) return;
369
+ const lines = Array.isArray(data.lines) ? data.lines : [];
370
+ const start = Number(data.startOffset);
371
+ tc.loadedBytes += Number.isFinite(start) ? Math.max(0, tc.startOffset - start) : String(data.text || '').length;
372
+ if (Number.isFinite(start)) tc.startOffset = start;
373
+ tc.hasMoreBefore = data.hasMoreBefore === true;
374
+ prependEarlier(lines);
375
+ } catch (error) {
376
+ if (token === tc.fetchToken) {
377
+ tc.note = errorText(error);
378
+ renderStatus();
379
+ }
380
+ } finally {
381
+ tc.loadingEarlier = false;
382
+ renderEarlierButton();
383
+ }
384
+ }
385
+
386
+ function syncSnapshotPoll() {
387
+ const want = tc.open && tc.kind === 'snapshot' && !tc.ended;
388
+ if (want && !tc.snapshotTimer) {
389
+ tc.snapshotTimer = setInterval(() => {
390
+ if (!tc.loading && document.visibilityState !== 'hidden') void fetchTail();
391
+ }, SNAPSHOT_POLL_MS);
392
+ }
393
+ if (!want && tc.snapshotTimer) {
394
+ clearInterval(tc.snapshotTimer);
395
+ tc.snapshotTimer = null;
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Join the task's room, then read the tail up to the offset the room stands
401
+ * at. Chunks that arrive meanwhile wait and are sorted out after the read
402
+ * (the ones already covered by it are dropped).
403
+ */
404
+ async function syncWithRoom() {
405
+ if (!tc.open) return;
406
+ const { conversationId, taskId } = tc;
407
+ tc.pendingChunks = tc.pendingChunks || [];
408
+ let before = null;
409
+ if (!tc.ended && tc.kind !== 'snapshot' && tc.kind !== 'none') {
410
+ const answer = await consoleWatch.watch(conversationId, taskId);
411
+ if (!tc.open || tc.taskId !== taskId || tc.conversationId !== conversationId) return;
412
+ if (answer?.ok && Number.isInteger(answer.offset)) before = answer.offset;
413
+ }
414
+ await fetchTail({ before });
415
+ }
416
+
417
+ function refetchTail() {
418
+ if (tc.refetching) return;
419
+ tc.refetching = true;
420
+ void syncWithRoom().finally(() => { tc.refetching = false; });
421
+ }
422
+
423
+ function applyChunk(chunk) {
424
+ const action = chunkAction({ nextOffset: tc.nextOffset, size: tc.size }, chunk);
425
+ if (action === 'ignore') return;
426
+ if (action === 'refetch') {
427
+ refetchTail();
428
+ return;
429
+ }
430
+ const text = String(chunk.text ?? '');
431
+ feed(text);
432
+ const end = chunkEndOffset(chunk);
433
+ tc.nextOffset = Number.isFinite(end) ? end : tc.nextOffset + text.length;
434
+ const size = Number(chunk.size);
435
+ if (Number.isFinite(size)) tc.size = Math.max(size, tc.nextOffset);
436
+ tc.loadedBytes += text.length;
437
+ if (chunk.ended === true && !tc.ended) {
438
+ tc.ended = true;
439
+ renderHeader();
440
+ }
441
+ renderLive();
442
+ }
443
+
444
+ // ─── Open / close ────────────────────────────────────────────────────────────
445
+
446
+ function resetState() {
447
+ tc.kind = '';
448
+ tc.message = '';
449
+ tc.error = '';
450
+ tc.note = '';
451
+ tc.loading = false;
452
+ tc.loadingEarlier = false;
453
+ tc.startOffset = 0;
454
+ tc.nextOffset = 0;
455
+ tc.size = 0;
456
+ tc.hasMoreBefore = false;
457
+ tc.loadedBytes = 0;
458
+ tc.health = null;
459
+ tc.status = '';
460
+ tc.exitCode = null;
461
+ tc.ended = false;
462
+ tc.answerTask = null;
463
+ tc.following = true;
464
+ tc.pendingChunks = null;
465
+ tc.refetching = false;
466
+ }
467
+
468
+ export async function openTaskConsole({ conversationId = currentConvId, taskId } = {}) {
469
+ const convId = String(conversationId || '').trim();
470
+ const id = String(taskId || '').trim();
471
+ if (!convId || !id) return;
472
+ const { modal } = elements();
473
+ if (!modal) return;
474
+ if (tc.open && (tc.conversationId !== convId || tc.taskId !== id)) consoleWatch.unwatch();
475
+ tc.conversationId = convId;
476
+ tc.taskId = id;
477
+ resetState();
478
+ tc.ended = !!currentTask()?.ended;
479
+ clearLines();
480
+ if (!tc.open) {
481
+ tc.open = true;
482
+ modal.classList.add('visible');
483
+ modal.setAttribute('aria-hidden', 'false');
484
+ document.body.classList.add('task-console-open');
485
+ consoleHistory?.opened();
486
+ }
487
+ renderHeader();
488
+ renderEarlierButton();
489
+ renderStatus();
490
+ syncTicker();
491
+ modal.querySelector('.task-console-close')?.focus?.({ preventScroll: true });
492
+ // Joined before the read, so nothing written in between is lost. An ended
493
+ // task writes nothing more; its room is not needed.
494
+ await syncWithRoom();
495
+ }
496
+
497
+ export function closeTaskConsole({ fromHistory = false } = {}) {
498
+ if (!tc.open) return;
499
+ tc.open = false;
500
+ tc.fetchToken += 1;
501
+ const { modal } = elements();
502
+ modal?.classList.remove('visible');
503
+ modal?.setAttribute('aria-hidden', 'true');
504
+ document.body.classList.remove('task-console-open');
505
+ consoleWatch.unwatch();
506
+ clearLines();
507
+ resetState();
508
+ syncSnapshotPoll();
509
+ syncTicker();
510
+ if (!fromHistory) consoleHistory?.closed();
511
+ }
512
+
513
+ export function isTaskConsoleOpen() {
514
+ return tc.open;
515
+ }
516
+
517
+ function jumpToFirstFailure() {
518
+ const lines = [...tc.earlierLines, ...tc.folded.lines];
519
+ const index = findFirstFailureIndex(lines);
520
+ const node = index >= 0 ? allLineNodes()[index] : null;
521
+ if (!node) {
522
+ tc.note = tc.hasMoreBefore
523
+ ? 'The first failure is in older output: load earlier lines to reach it.'
524
+ : 'No failing test line found in the loaded output.';
525
+ renderStatus();
526
+ return;
527
+ }
528
+ tc.note = '';
529
+ renderStatus();
530
+ tc.following = false;
531
+ for (const marked of elements().body?.querySelectorAll('.tc-line-target') || []) marked.classList.remove('tc-line-target');
532
+ node.classList.add('tc-line-target');
533
+ node.scrollIntoView({ block: 'center' });
534
+ }
535
+
536
+ async function stopTask() {
537
+ const found = currentTask();
538
+ if (!found || found.ended) return;
539
+ const stopping = stopBackgroundTask(tc.conversationId, tc.taskId);
540
+ renderHeader();
541
+ await stopping;
542
+ renderHeader();
543
+ }
544
+
545
+ // ─── Live updates ────────────────────────────────────────────────────────────
546
+
547
+ /** `task_output_chunk`: new bytes of the task on screen. */
548
+ export function handleTaskOutputChunk(payload) {
549
+ if (!tc.open || tc.kind === 'none' || tc.kind === 'snapshot') return;
550
+ if (String(payload?.taskId || '') !== tc.taskId) return;
551
+ if (String(payload?.conversationId || '') && String(payload.conversationId) !== tc.conversationId) return;
552
+ if (tc.pendingChunks) {
553
+ tc.pendingChunks.push(payload);
554
+ return;
555
+ }
556
+ applyChunk(payload);
557
+ }
558
+
559
+ /** The socket came back: watch the task again and read what was missed. */
560
+ export async function handleTaskConsoleReconnect() {
561
+ if (!tc.open) return;
562
+ refetchTail();
563
+ }
564
+
565
+ // One timer for the header's clock and stall hint, only while open.
566
+ function syncTicker() {
567
+ if (tc.open && !tc.tickTimer) tc.tickTimer = setInterval(renderHeader, 1000);
568
+ if (!tc.open && tc.tickTimer) {
569
+ clearInterval(tc.tickTimer);
570
+ tc.tickTimer = null;
571
+ }
572
+ }
573
+
574
+ // ─── Wiring ──────────────────────────────────────────────────────────────────
575
+
576
+ export function initTaskConsole() {
577
+ if (initialized) return;
578
+ initialized = true;
579
+ consoleHistory = createViewerHistory({
580
+ history: window.history,
581
+ mark: 'oarTaskConsole',
582
+ onBack: () => closeTaskConsole({ fromHistory: true }),
583
+ });
584
+ window.addEventListener('popstate', () => consoleHistory.onPopState());
585
+
586
+ document.addEventListener('click', (event) => {
587
+ const trigger = event.target?.closest?.('[data-task-console]');
588
+ if (!trigger) return;
589
+ event.preventDefault();
590
+ event.stopPropagation();
591
+ void openTaskConsole({ taskId: trigger.getAttribute('data-task-console') });
592
+ });
593
+
594
+ // The row's task moved to the ended set, its health changed, or Stop
595
+ // settled: the header follows.
596
+ subscribeBackgroundTasks((conversationId) => {
597
+ if (!tc.open || String(conversationId || '') !== tc.conversationId) return;
598
+ const found = currentTask();
599
+ if (found?.ended && !tc.ended) {
600
+ tc.ended = true;
601
+ consoleWatch.unwatch();
602
+ syncSnapshotPoll();
603
+ }
604
+ renderHeader();
605
+ });
606
+
607
+ const { modal, stop, scroll, jump, earlier, failure, chip } = elements();
608
+ modal?.addEventListener('click', (event) => {
609
+ if (event.target === modal) closeTaskConsole();
610
+ });
611
+ modal?.querySelector('.task-console-close')?.addEventListener('click', () => closeTaskConsole());
612
+ stop?.addEventListener('click', () => void stopTask());
613
+ earlier?.addEventListener('click', () => void loadEarlier());
614
+ failure?.addEventListener('click', () => jumpToFirstFailure());
615
+ chip?.addEventListener('click', () => jumpToFirstFailure());
616
+ jump?.addEventListener('click', () => {
617
+ tc.following = true;
618
+ if (scroll) scroll.scrollTop = scroll.scrollHeight;
619
+ jump.hidden = true;
620
+ });
621
+ scroll?.addEventListener('scroll', () => {
622
+ tc.following = isAtBottom(scroll);
623
+ if (tc.following && jump) jump.hidden = true;
624
+ }, { passive: true });
625
+ document.addEventListener('keydown', (event) => {
626
+ if (event.key !== 'Escape' || !tc.open) return;
627
+ event.preventDefault();
628
+ closeTaskConsole();
629
+ });
630
+ }