@oar-sh/oar 0.9.11 → 0.9.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +332 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +201 -12
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +179 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +152 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/acp-host-services.mjs +48 -6
- package/server/grok-worker/grok-background-tasks.mjs +248 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +70 -3
- package/server/public/app/background-tasks-view.mjs +334 -16
- package/server/public/app/bootstrap.js +102 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +462 -8
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +259 -67
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +162 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +401 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +730 -0
- package/server/public/app/task-output-text.mjs +243 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
- package/server/public/app/transcript-jump-bottom.mjs +68 -11
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +20 -1
- package/server/public/index.html +306 -6
- package/server/relay-tools.md +6 -0
- package/server/repositories/message-repository.mjs +11 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +397 -23
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +208 -66
- package/server/server-runtime.mjs +269 -137
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +470 -6
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +9 -2
- package/server/services/relay-event-service.mjs +27 -9
- package/server/services/relay-prompt-sanitizer.mjs +18 -4
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +380 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +316 -20
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +761 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +47 -10
- package/shared/task-output-text.mjs +850 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
|
@@ -2,11 +2,27 @@
|
|
|
2
2
|
// subagents, monitors, workflows) still running inside a conversation's
|
|
3
3
|
// persistent Claude CLI process. Fed by the `background_tasks` socket event
|
|
4
4
|
// (REPLACE semantics per conversation) and the conversation payload's
|
|
5
|
-
// `backgroundTasks` on reload; hidden whenever the set is empty.
|
|
5
|
+
// `backgroundTasks` on reload; hidden whenever the set is empty. Tasks that
|
|
6
|
+
// ended within the relay's hour (`endedTasks` / `endedBackgroundTasks`) stay
|
|
7
|
+
// as dimmed rows; shell and Monitor rows open their output in the task
|
|
8
|
+
// console (task-console.mjs) and show the relay's health: a stall hint and a
|
|
9
|
+
// test runner's pass/fail chip.
|
|
6
10
|
|
|
7
11
|
import { apiFetch } from './api-client.js';
|
|
8
12
|
import { formatTaskModelLabel, formatTaskTokens } from './task-row-format.mjs';
|
|
9
13
|
import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
|
|
14
|
+
import {
|
|
15
|
+
TASK_OUTPUT_NONE_MESSAGES,
|
|
16
|
+
endedOutcome,
|
|
17
|
+
normalizeStallThreshold,
|
|
18
|
+
pipeHoldNotice,
|
|
19
|
+
runnerChip,
|
|
20
|
+
stallHint,
|
|
21
|
+
stepsLabel,
|
|
22
|
+
taskHasConsole,
|
|
23
|
+
taskOutputUnavailable,
|
|
24
|
+
taskPipeHold,
|
|
25
|
+
} from './task-console-render.mjs';
|
|
10
26
|
|
|
11
27
|
// Shared with the sub-agent rows (subagent-row.mjs); re-exported for the
|
|
12
28
|
// transcript's finished-workflow card and the existing importers.
|
|
@@ -18,12 +34,33 @@ import {
|
|
|
18
34
|
} from './preview-cards.mjs';
|
|
19
35
|
|
|
20
36
|
const tasksByConversation = new Map();
|
|
37
|
+
// Tasks that ended within the relay's hour (`endedTasks`): dimmed rows with
|
|
38
|
+
// ✓/✘ whose output still opens. A dismissed one stays hidden on this page.
|
|
39
|
+
const endedByConversation = new Map();
|
|
40
|
+
const dismissedEndedTasks = new Set();
|
|
41
|
+
// The relay's health poller (`background_tasks_health`, and `health` on each
|
|
42
|
+
// task of the payload): last output time and a test runner's counts.
|
|
43
|
+
const healthByConversation = new Map();
|
|
44
|
+
// Minutes of quiet after which a row's stall hint turns amber: the relay's
|
|
45
|
+
// setting, as the conversation payload (`taskStallMinutes`) or Settings
|
|
46
|
+
// last said. One value for the whole relay.
|
|
47
|
+
let stallThresholdMinutes = normalizeStallThreshold(null);
|
|
48
|
+
const changeListeners = new Set();
|
|
49
|
+
// Counts a sub-agent run's activity lines when its row names no `steps`
|
|
50
|
+
// (set by the page; the panel itself does not read the run store).
|
|
51
|
+
let subagentStepCounter = null;
|
|
21
52
|
const stopsInFlight = new Set();
|
|
22
53
|
// Manual chevron choices for workflow progress trees, keyed by taskId so a
|
|
23
54
|
// user's fold survives the ~2s background_tasks replace re-renders.
|
|
24
55
|
const workflowFoldState = new Map();
|
|
25
56
|
let currentConversationId = null;
|
|
26
57
|
let elapsedTimer = null;
|
|
58
|
+
// Counts the `background_tasks` events per conversation. A reload's payload
|
|
59
|
+
// is the set as the relay had it when the request was answered; one that was
|
|
60
|
+
// asked for before the latest event can land after it and would put the
|
|
61
|
+
// older set back (the panel shrinking under the reader, then growing again
|
|
62
|
+
// with the next poll).
|
|
63
|
+
const taskEventCounts = new Map();
|
|
27
64
|
|
|
28
65
|
const TASK_TYPE_ICONS = {
|
|
29
66
|
local_bash: '⌨️',
|
|
@@ -310,10 +347,61 @@ export function buildWorkflowRunCard(run) {
|
|
|
310
347
|
return details;
|
|
311
348
|
}
|
|
312
349
|
|
|
313
|
-
|
|
350
|
+
function normalizeTaskList(tasks) {
|
|
351
|
+
return (Array.isArray(tasks) ? tasks : []).filter((task) => task?.taskId);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function healthMapFor(conversationId, create = false) {
|
|
355
|
+
let map = healthByConversation.get(conversationId);
|
|
356
|
+
if (!map && create) {
|
|
357
|
+
map = new Map();
|
|
358
|
+
healthByConversation.set(conversationId, map);
|
|
359
|
+
}
|
|
360
|
+
return map || null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function rememberTaskHealth(conversationId, tasks) {
|
|
364
|
+
for (const task of tasks) {
|
|
365
|
+
if (task?.health && typeof task.health === 'object') healthMapFor(conversationId, true).set(String(task.taskId), task.health);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function notifyChange(conversationId) {
|
|
370
|
+
for (const listener of changeListeners) {
|
|
371
|
+
try { listener(conversationId); } catch (error) { console.warn('background task listener failed', error?.message || error); }
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Taken before a reload of the conversation is asked for, and handed to
|
|
377
|
+
* isBackgroundTasksSnapshotCurrent() with its answer.
|
|
378
|
+
*/
|
|
379
|
+
export function backgroundTasksSnapshotMark(conversationId) {
|
|
380
|
+
return taskEventCounts.get(String(conversationId || '').trim()) || 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Whether a reload's sets (`backgroundTasks`, `endedBackgroundTasks`) may
|
|
385
|
+
* replace the conversation's on the page: no `background_tasks` event for it
|
|
386
|
+
* arrived since `mark` was taken. Otherwise the event's set is at least as
|
|
387
|
+
* new, and the next reload brings whatever came after it. Without a mark:
|
|
388
|
+
* always.
|
|
389
|
+
*/
|
|
390
|
+
export function isBackgroundTasksSnapshotCurrent(conversationId, mark) {
|
|
391
|
+
return mark === undefined || mark === null || mark === backgroundTasksSnapshotMark(conversationId);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The live set (REPLACE semantics). `endedTasks`, when the event or payload
|
|
396
|
+
* carries it, replaces the ended set too; an older relay's event without it
|
|
397
|
+
* leaves the ended rows alone. `event`: it came as a `background_tasks`
|
|
398
|
+
* event (see isBackgroundTasksSnapshotCurrent).
|
|
399
|
+
*/
|
|
400
|
+
export function setConversationBackgroundTasks(conversationId, tasks, { endedTasks, event = false } = {}) {
|
|
314
401
|
const id = String(conversationId || '').trim();
|
|
315
402
|
if (!id) return;
|
|
316
|
-
|
|
403
|
+
if (event) taskEventCounts.set(id, backgroundTasksSnapshotMark(id) + 1);
|
|
404
|
+
const normalized = normalizeTaskList(tasks);
|
|
317
405
|
// Drop fold choices for tasks that left this conversation's set so the map
|
|
318
406
|
// cannot grow across long sessions.
|
|
319
407
|
const surviving = new Set(normalized.map((task) => String(task.taskId)));
|
|
@@ -322,23 +410,139 @@ export function setConversationBackgroundTasks(conversationId, tasks) {
|
|
|
322
410
|
}
|
|
323
411
|
if (normalized.length) tasksByConversation.set(id, normalized);
|
|
324
412
|
else tasksByConversation.delete(id);
|
|
413
|
+
rememberTaskHealth(id, normalized);
|
|
414
|
+
if (Array.isArray(endedTasks)) storeEndedTasks(id, endedTasks);
|
|
415
|
+
if (id === currentConversationId) renderBackgroundTasksPanel();
|
|
416
|
+
notifyChange(id);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function storeEndedTasks(id, endedTasks) {
|
|
420
|
+
const normalized = normalizeTaskList(endedTasks);
|
|
421
|
+
if (normalized.length) endedByConversation.set(id, normalized);
|
|
422
|
+
else endedByConversation.delete(id);
|
|
423
|
+
rememberTaskHealth(id, normalized);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** The ended set alone (the conversation payload's `endedBackgroundTasks`). */
|
|
427
|
+
export function setConversationEndedBackgroundTasks(conversationId, endedTasks) {
|
|
428
|
+
const id = String(conversationId || '').trim();
|
|
429
|
+
if (!id) return;
|
|
430
|
+
storeEndedTasks(id, endedTasks);
|
|
325
431
|
if (id === currentConversationId) renderBackgroundTasksPanel();
|
|
432
|
+
notifyChange(id);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** `background_tasks_health`: merged per task, the rest stays. */
|
|
436
|
+
export function setConversationTaskHealth(conversationId, health) {
|
|
437
|
+
const id = String(conversationId || '').trim();
|
|
438
|
+
if (!id || !health || typeof health !== 'object') return;
|
|
439
|
+
const map = healthMapFor(id, true);
|
|
440
|
+
for (const [taskId, entry] of Object.entries(health)) {
|
|
441
|
+
if (entry && typeof entry === 'object') map.set(String(taskId), entry);
|
|
442
|
+
}
|
|
443
|
+
if (id === currentConversationId) renderBackgroundTasksPanel();
|
|
444
|
+
notifyChange(id);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function setConversationTaskStallMinutes(conversationId, minutes) {
|
|
448
|
+
setTaskStallThreshold(minutes);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The relay's stall setting changed (a payload, or Settings). */
|
|
452
|
+
export function setTaskStallThreshold(minutes) {
|
|
453
|
+
if (minutes === undefined || minutes === null) return;
|
|
454
|
+
const next = normalizeStallThreshold(minutes);
|
|
455
|
+
if (next === stallThresholdMinutes) return;
|
|
456
|
+
stallThresholdMinutes = next;
|
|
457
|
+
renderBackgroundTasksPanel();
|
|
458
|
+
notifyChange(currentConversationId);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// The conversation is accepted for symmetry with the other getters; the
|
|
462
|
+
// setting is relay-wide.
|
|
463
|
+
export function getTaskStallMinutes(_conversationId) {
|
|
464
|
+
return stallThresholdMinutes;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function getBackgroundTaskHealth(conversationId, taskId) {
|
|
468
|
+
const id = String(conversationId || '').trim();
|
|
469
|
+
const fromMap = healthMapFor(id)?.get(String(taskId || ''));
|
|
470
|
+
if (fromMap) return fromMap;
|
|
471
|
+
const found = findBackgroundTask(id, taskId);
|
|
472
|
+
return found?.task?.health || null;
|
|
326
473
|
}
|
|
327
474
|
|
|
328
475
|
export function getConversationBackgroundTasks(conversationId) {
|
|
329
476
|
return tasksByConversation.get(String(conversationId || '').trim()) || [];
|
|
330
477
|
}
|
|
331
478
|
|
|
479
|
+
export function getConversationEndedBackgroundTasks(conversationId) {
|
|
480
|
+
return endedByConversation.get(String(conversationId || '').trim()) || [];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** A task of the live or the ended set: `{ task, ended }`, or null. */
|
|
484
|
+
export function findBackgroundTask(conversationId, taskId) {
|
|
485
|
+
const id = String(taskId || '');
|
|
486
|
+
if (!id) return null;
|
|
487
|
+
const live = getConversationBackgroundTasks(conversationId).find((task) => String(task.taskId) === id);
|
|
488
|
+
if (live) return { task: live, ended: false };
|
|
489
|
+
const ended = getConversationEndedBackgroundTasks(conversationId).find((task) => String(task.taskId) === id);
|
|
490
|
+
return ended ? { task: ended, ended: true } : null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const panelRenderListeners = new Set();
|
|
494
|
+
|
|
495
|
+
/** Called after the panel drew its rows (the task console keeps above its row). */
|
|
496
|
+
export function onBackgroundTasksPanelRendered(listener) {
|
|
497
|
+
panelRenderListeners.add(listener);
|
|
498
|
+
return () => panelRenderListeners.delete(listener);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function notifyPanelRendered() {
|
|
502
|
+
for (const listener of panelRenderListeners) {
|
|
503
|
+
try { listener(); } catch (error) { console.warn('background task panel listener failed', error?.message || error); }
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Called with the conversation id after any change of tasks or health. */
|
|
508
|
+
export function subscribeBackgroundTasks(listener) {
|
|
509
|
+
changeListeners.add(listener);
|
|
510
|
+
return () => changeListeners.delete(listener);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function setSubagentStepCounter(counter) {
|
|
514
|
+
subagentStepCounter = typeof counter === 'function' ? counter : null;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function dismissEndedBackgroundTask(taskId) {
|
|
518
|
+
const id = String(taskId || '').trim();
|
|
519
|
+
if (!id) return;
|
|
520
|
+
dismissedEndedTasks.add(id);
|
|
521
|
+
renderBackgroundTasksPanel();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function visibleEndedTasks(conversationId) {
|
|
525
|
+
return getConversationEndedBackgroundTasks(conversationId)
|
|
526
|
+
.filter((task) => !dismissedEndedTasks.has(String(task.taskId)));
|
|
527
|
+
}
|
|
528
|
+
|
|
332
529
|
export function setBackgroundTasksConversation(conversationId) {
|
|
333
530
|
currentConversationId = String(conversationId || '').trim() || null;
|
|
334
531
|
renderBackgroundTasksPanel();
|
|
335
532
|
}
|
|
336
533
|
|
|
337
|
-
|
|
534
|
+
export function isBackgroundTaskStopping(conversationId, taskId) {
|
|
535
|
+
return stopsInFlight.has(`${conversationId}:${taskId}`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// The task console's Stop goes through here too, so the row and the console
|
|
539
|
+
// show the same "Stopping…" state.
|
|
540
|
+
export async function stopBackgroundTask(conversationId, taskId) {
|
|
338
541
|
const key = `${conversationId}:${taskId}`;
|
|
339
542
|
if (stopsInFlight.has(key)) return;
|
|
340
543
|
stopsInFlight.add(key);
|
|
341
544
|
renderBackgroundTasksPanel();
|
|
545
|
+
notifyChange(conversationId);
|
|
342
546
|
try {
|
|
343
547
|
await apiFetch(`/api/conversation/${encodeURIComponent(conversationId)}/background-task/${encodeURIComponent(taskId)}/stop`, {
|
|
344
548
|
method: 'POST',
|
|
@@ -349,10 +553,12 @@ async function stopBackgroundTask(conversationId, taskId) {
|
|
|
349
553
|
setTimeout(() => {
|
|
350
554
|
stopsInFlight.delete(key);
|
|
351
555
|
renderBackgroundTasksPanel();
|
|
556
|
+
notifyChange(conversationId);
|
|
352
557
|
}, 15_000);
|
|
353
558
|
} catch (error) {
|
|
354
559
|
stopsInFlight.delete(key);
|
|
355
560
|
renderBackgroundTasksPanel();
|
|
561
|
+
notifyChange(conversationId);
|
|
356
562
|
console.warn('background task stop failed', error?.message || error);
|
|
357
563
|
}
|
|
358
564
|
}
|
|
@@ -367,7 +573,51 @@ export function subagentRunIdForTask(task) {
|
|
|
367
573
|
return type === 'local_agent' || type === 'agent' || type === 'subagent' ? runId : '';
|
|
368
574
|
}
|
|
369
575
|
|
|
370
|
-
|
|
576
|
+
// The row's title: a sub-agent opens its record, a shell or Monitor task its
|
|
577
|
+
// console; a task whose provider publishes no output says so on hover.
|
|
578
|
+
function taskTitleHtml(task) {
|
|
579
|
+
const text = escHtml(task.description || task.taskId);
|
|
580
|
+
const runId = subagentRunIdForTask(task);
|
|
581
|
+
if (runId) {
|
|
582
|
+
return `<button type="button" class="subagent-open bg-task-desc" data-subagent-open="${escHtml(runId)}" title="Open this sub-agent's thoughts, tool calls and reply">${text}</button>`;
|
|
583
|
+
}
|
|
584
|
+
if (taskOutputUnavailable(task)) {
|
|
585
|
+
return `<span class="bg-task-desc bg-task-desc-no-output" title="${escHtml(TASK_OUTPUT_NONE_MESSAGES.provider)}">${text}</span>`;
|
|
586
|
+
}
|
|
587
|
+
if (taskHasConsole(task)) {
|
|
588
|
+
return `<button type="button" class="task-console-open bg-task-desc" data-task-console="${escHtml(task.taskId)}" title="Open this task's output">${text}</button>`;
|
|
589
|
+
}
|
|
590
|
+
return `<span class="bg-task-desc">${text}</span>`;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function chipHtml(health) {
|
|
594
|
+
const chip = runnerChip(health);
|
|
595
|
+
if (!chip) return '';
|
|
596
|
+
return `<span class="bg-task-chip${chip.failed ? ' bg-task-chip-fail' : ''}" title="${escHtml(chip.title)}">${escHtml(chip.text)}</span>`;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Always present on a live row (hidden while output is fresh) so the 1 s
|
|
600
|
+
// timer can update it in place. A command piped into tail is quiet
|
|
601
|
+
// until it ends: its hover title says so.
|
|
602
|
+
function stallHtml(hint, task) {
|
|
603
|
+
const classes = `bg-task-stall${hint?.warn ? ' bg-task-stall-warn' : ''}`;
|
|
604
|
+
const notice = pipeHoldNotice(taskPipeHold(task));
|
|
605
|
+
const title = notice ? ` title="${escHtml(notice.title)}"` : '';
|
|
606
|
+
return `<span class="${classes}"${title}${hint ? '' : ' hidden'}>${hint ? escHtml(hint.text) : ''}</span>`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function taskSteps(task) {
|
|
610
|
+
const label = stepsLabel(task?.steps);
|
|
611
|
+
if (label || !subagentRunIdForTask(task) || !subagentStepCounter) return label;
|
|
612
|
+
try {
|
|
613
|
+
const counted = subagentStepCounter(String(task.toolUseId));
|
|
614
|
+
return Number.isFinite(Number(counted)) && Number(counted) > 0 ? stepsLabel(counted) : '';
|
|
615
|
+
} catch {
|
|
616
|
+
return '';
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export function renderTaskRow(conversationId, task) {
|
|
371
621
|
const key = `${conversationId}:${task.taskId}`;
|
|
372
622
|
const stopping = stopsInFlight.has(key);
|
|
373
623
|
const elapsed = formatElapsed(task.startedAt);
|
|
@@ -377,6 +627,11 @@ function renderTaskRow(conversationId, task) {
|
|
|
377
627
|
const modelLabel = formatTaskModelLabel(task.model);
|
|
378
628
|
const modelTitle = task.modelInherited === true ? `${task.model} (inherited from the session)` : task.model;
|
|
379
629
|
const tokens = formatTaskTokens(task.totalTokens);
|
|
630
|
+
const steps = taskSteps(task);
|
|
631
|
+
const health = getBackgroundTaskHealth(conversationId, task.taskId);
|
|
632
|
+
const stall = taskHasConsole(task)
|
|
633
|
+
? stallHint(health?.lastOutputAt, getTaskStallMinutes(conversationId))
|
|
634
|
+
: null;
|
|
380
635
|
// Workflow rows with a digest get a fold chevron plus an (initially empty)
|
|
381
636
|
// tree container right below the row; renderBackgroundTasksPanel fills the
|
|
382
637
|
// container with DOM nodes after this markup lands, so digest text never
|
|
@@ -393,9 +648,7 @@ function renderTaskRow(conversationId, task) {
|
|
|
393
648
|
<div class="bg-task-row" data-task-id="${escHtml(task.taskId)}">
|
|
394
649
|
${foldButton}<span class="bg-task-icon" title="${escHtml(task.taskType || 'task')}">${taskIcon(task.taskType)}</span>
|
|
395
650
|
<span class="bg-task-main">
|
|
396
|
-
${
|
|
397
|
-
? `<button type="button" class="subagent-open bg-task-desc" data-subagent-open="${escHtml(subagentRunIdForTask(task))}" title="Open this sub-agent's thoughts, tool calls and reply">${escHtml(task.description || task.taskId)}</button>`
|
|
398
|
-
: `<span class="bg-task-desc">${escHtml(task.description || task.taskId)}</span>`}
|
|
651
|
+
${taskTitleHtml(task)}
|
|
399
652
|
${activity.text ? `<span class="bg-task-detail${activity.toolCall ? ' bg-task-toolcall' : ''}" title="${escHtml(activity.text)}">${escHtml(activity.text)}</span>` : ''}
|
|
400
653
|
</span>
|
|
401
654
|
<span class="bg-task-side">
|
|
@@ -405,6 +658,9 @@ function renderTaskRow(conversationId, task) {
|
|
|
405
658
|
</span>
|
|
406
659
|
<span class="bg-task-side-status">
|
|
407
660
|
${tokens ? `<span class="bg-task-tokens">${escHtml(tokens)}</span>` : ''}
|
|
661
|
+
${steps ? `<span class="bg-task-steps">${escHtml(steps)}</span>` : ''}
|
|
662
|
+
${chipHtml(health)}
|
|
663
|
+
${taskHasConsole(task) ? stallHtml(stall, task) : ''}
|
|
408
664
|
<span class="bg-task-elapsed">${escHtml(elapsed)}</span>
|
|
409
665
|
${task.stoppable === false ? '' : `<button type="button" class="bg-task-stop" data-task-id="${escHtml(task.taskId)}" ${stopping ? 'disabled' : ''}>${stopping ? 'Stopping…' : 'Stop'}</button>`}
|
|
410
666
|
</span>
|
|
@@ -412,11 +668,49 @@ function renderTaskRow(conversationId, task) {
|
|
|
412
668
|
</div>${treeHolder}`;
|
|
413
669
|
}
|
|
414
670
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
671
|
+
function endedDuration(task) {
|
|
672
|
+
const started = Number(task.startedAt);
|
|
673
|
+
const endedAt = Number.isFinite(Number(task.endedAt)) ? Number(task.endedAt) : Date.parse(String(task.endedAt || ''));
|
|
674
|
+
if (!Number.isFinite(started) || started <= 0 || !Number.isFinite(endedAt) || endedAt < started) return '';
|
|
675
|
+
const seconds = Math.floor((endedAt - started) / 1000);
|
|
676
|
+
if (seconds < 60) return `${seconds}s`;
|
|
677
|
+
const minutes = Math.floor(seconds / 60);
|
|
678
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
|
679
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** A task that ended within the hour: dimmed, ✓/✘, no Stop, a dismiss ×. */
|
|
683
|
+
export function renderEndedTaskRow(conversationId, task) {
|
|
684
|
+
const outcome = endedOutcome(task);
|
|
685
|
+
const health = getBackgroundTaskHealth(conversationId, task.taskId);
|
|
686
|
+
const duration = endedDuration(task);
|
|
687
|
+
return `
|
|
688
|
+
<div class="bg-task-row bg-task-row-ended" data-task-id="${escHtml(task.taskId)}">
|
|
689
|
+
<span class="bg-task-icon" title="${escHtml(task.taskType || 'task')}">${taskIcon(task.taskType)}</span>
|
|
690
|
+
<span class="bg-task-main">
|
|
691
|
+
${taskTitleHtml(task)}
|
|
692
|
+
</span>
|
|
693
|
+
<span class="bg-task-side">
|
|
694
|
+
<span class="bg-task-side-head">
|
|
695
|
+
<span class="bg-task-badge">${escHtml(taskKindLabel(task))}</span>
|
|
696
|
+
</span>
|
|
697
|
+
<span class="bg-task-side-status">
|
|
698
|
+
${chipHtml(health)}
|
|
699
|
+
<span class="bg-task-outcome${outcome.failed ? ' bg-task-outcome-fail' : outcome.ok ? ' bg-task-outcome-ok' : ''}" title="${escHtml(outcome.label)}">${outcome.icon}</span>
|
|
700
|
+
${duration ? `<span class="bg-task-elapsed">${escHtml(duration)}</span>` : ''}
|
|
701
|
+
<button type="button" class="bg-task-dismiss" data-task-id="${escHtml(task.taskId)}" aria-label="Dismiss" title="Hide this finished task">×</button>
|
|
702
|
+
</span>
|
|
703
|
+
</span>
|
|
704
|
+
</div>`;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// "2 background tasks running · 1 finished", "1 preview", or a mix — the
|
|
708
|
+
// panel is shared, so the summary has to name whatever is actually in it.
|
|
709
|
+
// Only live tasks count as running.
|
|
710
|
+
export function panelSummaryText(taskCount, previewCount, endedCount = 0) {
|
|
418
711
|
const parts = [];
|
|
419
712
|
if (taskCount) parts.push(`${taskCount} background task${taskCount === 1 ? '' : 's'} running`);
|
|
713
|
+
if (endedCount) parts.push(`${endedCount} finished`);
|
|
420
714
|
if (previewCount) parts.push(`${previewCount} preview${previewCount === 1 ? '' : 's'}`);
|
|
421
715
|
return parts.join(' · ');
|
|
422
716
|
}
|
|
@@ -426,13 +720,18 @@ export function renderBackgroundTasksPanel() {
|
|
|
426
720
|
if (!panel) return;
|
|
427
721
|
const conversationId = currentConversationId;
|
|
428
722
|
const tasks = conversationId ? (tasksByConversation.get(conversationId) || []) : [];
|
|
723
|
+
const ended = conversationId ? visibleEndedTasks(conversationId) : [];
|
|
429
724
|
const previews = conversationId ? getPreviewsForConversation(conversationId) : [];
|
|
430
725
|
// Previews outlive the task that started the dev server, so the panel stays
|
|
431
726
|
// up for them even once every background task has finished.
|
|
432
|
-
if (!tasks.length && !previews.length) {
|
|
727
|
+
if (!tasks.length && !ended.length && !previews.length) {
|
|
433
728
|
panel.hidden = true;
|
|
434
729
|
panel.removeAttribute('open');
|
|
730
|
+
// No stale rows behind the hidden panel (a dismissed last row is gone).
|
|
731
|
+
const staleList = panel.querySelector('#background-tasks-list');
|
|
732
|
+
if (staleList) staleList.innerHTML = '';
|
|
435
733
|
stopElapsedTimer();
|
|
734
|
+
notifyPanelRendered();
|
|
436
735
|
return;
|
|
437
736
|
}
|
|
438
737
|
const wasOpen = panel.hasAttribute('open');
|
|
@@ -440,7 +739,7 @@ export function renderBackgroundTasksPanel() {
|
|
|
440
739
|
const summary = panel.querySelector('#background-tasks-summary');
|
|
441
740
|
if (summary) {
|
|
442
741
|
const spinner = tasks.length ? '<span class="bg-task-spinner"></span> ' : '';
|
|
443
|
-
summary.innerHTML = `${spinner}${escHtml(panelSummaryText(tasks.length, previews.length))}`;
|
|
742
|
+
summary.innerHTML = `${spinner}${escHtml(panelSummaryText(tasks.length, previews.length, ended.length))}`;
|
|
444
743
|
}
|
|
445
744
|
const previewList = panel.querySelector('#background-previews-list');
|
|
446
745
|
if (previewList) {
|
|
@@ -450,13 +749,20 @@ export function renderBackgroundTasksPanel() {
|
|
|
450
749
|
}
|
|
451
750
|
const list = panel.querySelector('#background-tasks-list');
|
|
452
751
|
if (list) {
|
|
453
|
-
list.innerHTML = tasks.map((task) => renderTaskRow(conversationId, task)).join('')
|
|
752
|
+
list.innerHTML = tasks.map((task) => renderTaskRow(conversationId, task)).join('')
|
|
753
|
+
+ ended.map((task) => renderEndedTaskRow(conversationId, task)).join('');
|
|
454
754
|
for (const button of list.querySelectorAll('.bg-task-stop')) {
|
|
455
755
|
button.addEventListener('click', (event) => {
|
|
456
756
|
event.preventDefault();
|
|
457
757
|
void stopBackgroundTask(conversationId, button.dataset.taskId);
|
|
458
758
|
});
|
|
459
759
|
}
|
|
760
|
+
for (const button of list.querySelectorAll('.bg-task-dismiss')) {
|
|
761
|
+
button.addEventListener('click', (event) => {
|
|
762
|
+
event.preventDefault();
|
|
763
|
+
dismissEndedBackgroundTask(button.dataset.taskId);
|
|
764
|
+
});
|
|
765
|
+
}
|
|
460
766
|
// Fill expanded workflow trees (collapsed holders stay empty — the toggle
|
|
461
767
|
// re-renders, so they get content the moment they open).
|
|
462
768
|
for (const holder of list.querySelectorAll('.bg-task-tree')) {
|
|
@@ -475,6 +781,7 @@ export function renderBackgroundTasksPanel() {
|
|
|
475
781
|
if (wasOpen) panel.setAttribute('open', '');
|
|
476
782
|
if (tasks.length) startElapsedTimer();
|
|
477
783
|
else stopElapsedTimer();
|
|
784
|
+
notifyPanelRendered();
|
|
478
785
|
}
|
|
479
786
|
|
|
480
787
|
// A preview created, closed, or flipping online/offline re-renders the panel;
|
|
@@ -486,14 +793,25 @@ function startElapsedTimer() {
|
|
|
486
793
|
elapsedTimer = setInterval(() => {
|
|
487
794
|
const panel = document.getElementById('background-tasks-panel');
|
|
488
795
|
if (!panel || panel.hidden || !panel.hasAttribute('open')) return;
|
|
489
|
-
const
|
|
796
|
+
const conversationId = currentConversationId;
|
|
797
|
+
const tasks = conversationId ? (tasksByConversation.get(conversationId) || []) : [];
|
|
798
|
+
const threshold = getTaskStallMinutes(conversationId);
|
|
490
799
|
for (const task of tasks) {
|
|
491
|
-
const
|
|
800
|
+
const rowSelector = `.bg-task-row:not(.bg-task-row-ended)[data-task-id="${CSS.escape(task.taskId)}"]`;
|
|
801
|
+
const row = panel.querySelector(`${rowSelector} .bg-task-elapsed`);
|
|
492
802
|
if (row) row.textContent = formatElapsed(task.startedAt);
|
|
803
|
+
const stallEl = panel.querySelector(`${rowSelector} .bg-task-stall`);
|
|
804
|
+
if (stallEl) applyStallHint(stallEl, stallHint(getBackgroundTaskHealth(conversationId, task.taskId)?.lastOutputAt, threshold));
|
|
493
805
|
}
|
|
494
806
|
}, 1000);
|
|
495
807
|
}
|
|
496
808
|
|
|
809
|
+
function applyStallHint(element, hint) {
|
|
810
|
+
element.hidden = !hint;
|
|
811
|
+
element.textContent = hint ? hint.text : '';
|
|
812
|
+
element.className = `bg-task-stall${hint?.warn ? ' bg-task-stall-warn' : ''}`;
|
|
813
|
+
}
|
|
814
|
+
|
|
497
815
|
function stopElapsedTimer() {
|
|
498
816
|
if (!elapsedTimer) return;
|
|
499
817
|
clearInterval(elapsedTimer);
|