@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.
Files changed (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  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 +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. 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
- export function setConversationBackgroundTasks(conversationId, tasks) {
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
- const normalized = (Array.isArray(tasks) ? tasks : []).filter((task) => task?.taskId);
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
- async function stopBackgroundTask(conversationId, taskId) {
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
- function renderTaskRow(conversationId, task) {
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
- ${subagentRunIdForTask(task)
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
- // "2 background tasks running", "1 preview", or both — the panel is shared, so
416
- // the summary has to name whatever is actually in it.
417
- function panelSummaryText(taskCount, previewCount) {
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 tasks = currentConversationId ? (tasksByConversation.get(currentConversationId) || []) : [];
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 row = panel.querySelector(`.bg-task-row[data-task-id="${CSS.escape(task.taskId)}"] .bg-task-elapsed`);
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);