@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,89 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: two indexes for loading the history of long conversations.
5
+ *
6
+ * - `idx_queue_response_message` on `queue(response_message_id)`: the
7
+ * sub-agent records of a reply are found through the queue row that
8
+ * produced it, one lookup per assistant message on every history page.
9
+ * Without the index each lookup scanned the whole queue.
10
+ * - `idx_messages_conv_order` on `messages(conversation_id, timestamp, id)`:
11
+ * a history page is selected in SQL in conversation order (timestamp, then
12
+ * id); the index serves that order and the check that a conversation's
13
+ * rows can be paged that way, without reading the message rows.
14
+ *
15
+ * Additive and idempotent. Safe to run on every boot. An older build ignores
16
+ * both indexes.
17
+ *
18
+ * Usage: node server/migrations/0017-conversation-history-indexes.mjs [path/to/copilot.db]
19
+ */
20
+
21
+ import { fileURLToPath } from 'url';
22
+ import path from 'path';
23
+ import process from 'process';
24
+ import Database from 'better-sqlite3';
25
+
26
+ function tableExists(db, name) {
27
+ return !!db
28
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
29
+ .get(name);
30
+ }
31
+
32
+ function indexExists(db, name) {
33
+ return !!db
34
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`)
35
+ .get(name);
36
+ }
37
+
38
+ function columnNames(db, table) {
39
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
40
+ }
41
+
42
+ export function migrateConversationHistoryIndexes(db) {
43
+ if (!tableExists(db, 'messages') || !tableExists(db, 'queue')) {
44
+ throw new Error('messages or queue is missing (the base schema has not run)');
45
+ }
46
+ if (!columnNames(db, 'queue').has('response_message_id')) {
47
+ throw new Error('queue.response_message_id is missing (the base schema has not run)');
48
+ }
49
+ let indexesAdded = 0;
50
+ if (!indexExists(db, 'idx_queue_response_message')) {
51
+ db.exec(`CREATE INDEX idx_queue_response_message ON queue(response_message_id)`);
52
+ indexesAdded += 1;
53
+ }
54
+ if (!indexExists(db, 'idx_messages_conv_order')) {
55
+ db.exec(`CREATE INDEX idx_messages_conv_order ON messages(conversation_id, timestamp, id)`);
56
+ indexesAdded += 1;
57
+ }
58
+ return { applied: indexesAdded > 0, indexesAdded };
59
+ }
60
+
61
+ function defaultDbPath() {
62
+ const here = path.dirname(fileURLToPath(import.meta.url));
63
+ return path.resolve(here, '..', 'data', 'copilot.db');
64
+ }
65
+
66
+ export function migrate(dbPath, options = {}) {
67
+ const db = new Database(dbPath, options);
68
+ try {
69
+ return migrateConversationHistoryIndexes(db);
70
+ } finally {
71
+ db.close();
72
+ }
73
+ }
74
+
75
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
76
+ if (isMain) {
77
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
78
+ try {
79
+ const result = migrate(dbPath);
80
+ console.log(`Migration 0017 on ${dbPath}`);
81
+ console.log(result.applied
82
+ ? ` conversation history indexes: added ${result.indexesAdded}`
83
+ : ' skipped: nothing to change');
84
+ process.exit(0);
85
+ } catch (err) {
86
+ console.error(`Migration 0017 failed: ${err?.message || err}`);
87
+ process.exit(1);
88
+ }
89
+ }
@@ -328,12 +328,14 @@ export async function updateClaudeSettings({
328
328
  model = undefined,
329
329
  enabledModels = undefined,
330
330
  attributionMode = undefined,
331
+ backgroundCommandLimitMinutes = undefined,
331
332
  } = {}) {
332
333
  const payload = {};
333
334
  if (typeof enabled === 'boolean') payload.enabled = enabled;
334
335
  if (typeof model === 'string' && model.trim()) payload.model = model.trim();
335
336
  if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
336
337
  if (typeof attributionMode === 'string' && attributionMode.trim()) payload.attributionMode = attributionMode.trim();
338
+ if (Number.isFinite(backgroundCommandLimitMinutes)) payload.backgroundCommandLimitMinutes = backgroundCommandLimitMinutes;
337
339
  return settingsRequest('/api/settings/claude', payload, 'Failed to update Claude settings');
338
340
  }
339
341
 
@@ -572,6 +574,17 @@ export async function updateBackgroundTaskTimeoutSetting(timeoutMinutes) {
572
574
  });
573
575
  }
574
576
 
577
+ export async function loadTaskStallMinutesSetting() {
578
+ return apiFetch('/api/settings/task-stall-minutes');
579
+ }
580
+
581
+ export async function updateTaskStallMinutesSetting(minutes) {
582
+ return apiFetch('/api/settings/task-stall-minutes', {
583
+ method: 'POST',
584
+ body: JSON.stringify({ minutes: Number(minutes) }),
585
+ });
586
+ }
587
+
575
588
  export async function loadPwaAppNameSetting() {
576
589
  return apiFetch('/api/settings/pwa-app-name');
577
590
  }
@@ -2,11 +2,24 @@
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
+ endedOutcome,
16
+ normalizeStallThreshold,
17
+ runnerChip,
18
+ stallHint,
19
+ stepsLabel,
20
+ taskHasConsole,
21
+ taskOutputUnavailable,
22
+ } from './task-console-render.mjs';
10
23
 
11
24
  // Shared with the sub-agent rows (subagent-row.mjs); re-exported for the
12
25
  // transcript's finished-workflow card and the existing importers.
@@ -18,6 +31,21 @@ import {
18
31
  } from './preview-cards.mjs';
19
32
 
20
33
  const tasksByConversation = new Map();
34
+ // Tasks that ended within the relay's hour (`endedTasks`): dimmed rows with
35
+ // ✓/✘ whose output still opens. A dismissed one stays hidden on this page.
36
+ const endedByConversation = new Map();
37
+ const dismissedEndedTasks = new Set();
38
+ // The relay's health poller (`background_tasks_health`, and `health` on each
39
+ // task of the payload): last output time and a test runner's counts.
40
+ const healthByConversation = new Map();
41
+ // Minutes of quiet after which a row's stall hint turns amber: the relay's
42
+ // setting, as the conversation payload (`taskStallMinutes`) or Settings
43
+ // last said. One value for the whole relay.
44
+ let stallThresholdMinutes = normalizeStallThreshold(null);
45
+ const changeListeners = new Set();
46
+ // Counts a sub-agent run's activity lines when its row names no `steps`
47
+ // (set by the page; the panel itself does not read the run store).
48
+ let subagentStepCounter = null;
21
49
  const stopsInFlight = new Set();
22
50
  // Manual chevron choices for workflow progress trees, keyed by taskId so a
23
51
  // user's fold survives the ~2s background_tasks replace re-renders.
@@ -310,10 +338,40 @@ export function buildWorkflowRunCard(run) {
310
338
  return details;
311
339
  }
312
340
 
313
- export function setConversationBackgroundTasks(conversationId, tasks) {
341
+ function normalizeTaskList(tasks) {
342
+ return (Array.isArray(tasks) ? tasks : []).filter((task) => task?.taskId);
343
+ }
344
+
345
+ function healthMapFor(conversationId, create = false) {
346
+ let map = healthByConversation.get(conversationId);
347
+ if (!map && create) {
348
+ map = new Map();
349
+ healthByConversation.set(conversationId, map);
350
+ }
351
+ return map || null;
352
+ }
353
+
354
+ function rememberTaskHealth(conversationId, tasks) {
355
+ for (const task of tasks) {
356
+ if (task?.health && typeof task.health === 'object') healthMapFor(conversationId, true).set(String(task.taskId), task.health);
357
+ }
358
+ }
359
+
360
+ function notifyChange(conversationId) {
361
+ for (const listener of changeListeners) {
362
+ try { listener(conversationId); } catch (error) { console.warn('background task listener failed', error?.message || error); }
363
+ }
364
+ }
365
+
366
+ /**
367
+ * The live set (REPLACE semantics). `endedTasks`, when the event or payload
368
+ * carries it, replaces the ended set too; an older relay's event without it
369
+ * leaves the ended rows alone.
370
+ */
371
+ export function setConversationBackgroundTasks(conversationId, tasks, { endedTasks } = {}) {
314
372
  const id = String(conversationId || '').trim();
315
373
  if (!id) return;
316
- const normalized = (Array.isArray(tasks) ? tasks : []).filter((task) => task?.taskId);
374
+ const normalized = normalizeTaskList(tasks);
317
375
  // Drop fold choices for tasks that left this conversation's set so the map
318
376
  // cannot grow across long sessions.
319
377
  const surviving = new Set(normalized.map((task) => String(task.taskId)));
@@ -322,23 +380,125 @@ export function setConversationBackgroundTasks(conversationId, tasks) {
322
380
  }
323
381
  if (normalized.length) tasksByConversation.set(id, normalized);
324
382
  else tasksByConversation.delete(id);
383
+ rememberTaskHealth(id, normalized);
384
+ if (Array.isArray(endedTasks)) storeEndedTasks(id, endedTasks);
325
385
  if (id === currentConversationId) renderBackgroundTasksPanel();
386
+ notifyChange(id);
387
+ }
388
+
389
+ function storeEndedTasks(id, endedTasks) {
390
+ const normalized = normalizeTaskList(endedTasks);
391
+ if (normalized.length) endedByConversation.set(id, normalized);
392
+ else endedByConversation.delete(id);
393
+ rememberTaskHealth(id, normalized);
394
+ }
395
+
396
+ /** The ended set alone (the conversation payload's `endedBackgroundTasks`). */
397
+ export function setConversationEndedBackgroundTasks(conversationId, endedTasks) {
398
+ const id = String(conversationId || '').trim();
399
+ if (!id) return;
400
+ storeEndedTasks(id, endedTasks);
401
+ if (id === currentConversationId) renderBackgroundTasksPanel();
402
+ notifyChange(id);
403
+ }
404
+
405
+ /** `background_tasks_health`: merged per task, the rest stays. */
406
+ export function setConversationTaskHealth(conversationId, health) {
407
+ const id = String(conversationId || '').trim();
408
+ if (!id || !health || typeof health !== 'object') return;
409
+ const map = healthMapFor(id, true);
410
+ for (const [taskId, entry] of Object.entries(health)) {
411
+ if (entry && typeof entry === 'object') map.set(String(taskId), entry);
412
+ }
413
+ if (id === currentConversationId) renderBackgroundTasksPanel();
414
+ notifyChange(id);
415
+ }
416
+
417
+ export function setConversationTaskStallMinutes(conversationId, minutes) {
418
+ setTaskStallThreshold(minutes);
419
+ }
420
+
421
+ /** The relay's stall setting changed (a payload, or Settings). */
422
+ export function setTaskStallThreshold(minutes) {
423
+ if (minutes === undefined || minutes === null) return;
424
+ const next = normalizeStallThreshold(minutes);
425
+ if (next === stallThresholdMinutes) return;
426
+ stallThresholdMinutes = next;
427
+ renderBackgroundTasksPanel();
428
+ notifyChange(currentConversationId);
429
+ }
430
+
431
+ // The conversation is accepted for symmetry with the other getters; the
432
+ // setting is relay-wide.
433
+ export function getTaskStallMinutes(_conversationId) {
434
+ return stallThresholdMinutes;
435
+ }
436
+
437
+ export function getBackgroundTaskHealth(conversationId, taskId) {
438
+ const id = String(conversationId || '').trim();
439
+ const fromMap = healthMapFor(id)?.get(String(taskId || ''));
440
+ if (fromMap) return fromMap;
441
+ const found = findBackgroundTask(id, taskId);
442
+ return found?.task?.health || null;
326
443
  }
327
444
 
328
445
  export function getConversationBackgroundTasks(conversationId) {
329
446
  return tasksByConversation.get(String(conversationId || '').trim()) || [];
330
447
  }
331
448
 
449
+ export function getConversationEndedBackgroundTasks(conversationId) {
450
+ return endedByConversation.get(String(conversationId || '').trim()) || [];
451
+ }
452
+
453
+ /** A task of the live or the ended set: `{ task, ended }`, or null. */
454
+ export function findBackgroundTask(conversationId, taskId) {
455
+ const id = String(taskId || '');
456
+ if (!id) return null;
457
+ const live = getConversationBackgroundTasks(conversationId).find((task) => String(task.taskId) === id);
458
+ if (live) return { task: live, ended: false };
459
+ const ended = getConversationEndedBackgroundTasks(conversationId).find((task) => String(task.taskId) === id);
460
+ return ended ? { task: ended, ended: true } : null;
461
+ }
462
+
463
+ /** Called with the conversation id after any change of tasks or health. */
464
+ export function subscribeBackgroundTasks(listener) {
465
+ changeListeners.add(listener);
466
+ return () => changeListeners.delete(listener);
467
+ }
468
+
469
+ export function setSubagentStepCounter(counter) {
470
+ subagentStepCounter = typeof counter === 'function' ? counter : null;
471
+ }
472
+
473
+ export function dismissEndedBackgroundTask(taskId) {
474
+ const id = String(taskId || '').trim();
475
+ if (!id) return;
476
+ dismissedEndedTasks.add(id);
477
+ renderBackgroundTasksPanel();
478
+ }
479
+
480
+ function visibleEndedTasks(conversationId) {
481
+ return getConversationEndedBackgroundTasks(conversationId)
482
+ .filter((task) => !dismissedEndedTasks.has(String(task.taskId)));
483
+ }
484
+
332
485
  export function setBackgroundTasksConversation(conversationId) {
333
486
  currentConversationId = String(conversationId || '').trim() || null;
334
487
  renderBackgroundTasksPanel();
335
488
  }
336
489
 
337
- async function stopBackgroundTask(conversationId, taskId) {
490
+ export function isBackgroundTaskStopping(conversationId, taskId) {
491
+ return stopsInFlight.has(`${conversationId}:${taskId}`);
492
+ }
493
+
494
+ // The task console's Stop goes through here too, so the row and the console
495
+ // show the same "Stopping…" state.
496
+ export async function stopBackgroundTask(conversationId, taskId) {
338
497
  const key = `${conversationId}:${taskId}`;
339
498
  if (stopsInFlight.has(key)) return;
340
499
  stopsInFlight.add(key);
341
500
  renderBackgroundTasksPanel();
501
+ notifyChange(conversationId);
342
502
  try {
343
503
  await apiFetch(`/api/conversation/${encodeURIComponent(conversationId)}/background-task/${encodeURIComponent(taskId)}/stop`, {
344
504
  method: 'POST',
@@ -349,10 +509,12 @@ async function stopBackgroundTask(conversationId, taskId) {
349
509
  setTimeout(() => {
350
510
  stopsInFlight.delete(key);
351
511
  renderBackgroundTasksPanel();
512
+ notifyChange(conversationId);
352
513
  }, 15_000);
353
514
  } catch (error) {
354
515
  stopsInFlight.delete(key);
355
516
  renderBackgroundTasksPanel();
517
+ notifyChange(conversationId);
356
518
  console.warn('background task stop failed', error?.message || error);
357
519
  }
358
520
  }
@@ -367,7 +529,48 @@ export function subagentRunIdForTask(task) {
367
529
  return type === 'local_agent' || type === 'agent' || type === 'subagent' ? runId : '';
368
530
  }
369
531
 
370
- function renderTaskRow(conversationId, task) {
532
+ // The row's title: a sub-agent opens its record, a shell or Monitor task its
533
+ // console; a task whose provider publishes no output says so on hover.
534
+ function taskTitleHtml(task) {
535
+ const text = escHtml(task.description || task.taskId);
536
+ const runId = subagentRunIdForTask(task);
537
+ if (runId) {
538
+ 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>`;
539
+ }
540
+ if (taskOutputUnavailable(task)) {
541
+ return `<span class="bg-task-desc bg-task-desc-no-output" title="output not available for this provider">${text}</span>`;
542
+ }
543
+ if (taskHasConsole(task)) {
544
+ 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>`;
545
+ }
546
+ return `<span class="bg-task-desc">${text}</span>`;
547
+ }
548
+
549
+ function chipHtml(health) {
550
+ const chip = runnerChip(health);
551
+ if (!chip) return '';
552
+ return `<span class="bg-task-chip${chip.failed ? ' bg-task-chip-fail' : ''}" title="${escHtml(chip.title)}">${escHtml(chip.text)}</span>`;
553
+ }
554
+
555
+ // Always present on a live row (hidden while output is fresh) so the 1 s
556
+ // timer can update it in place.
557
+ function stallHtml(hint) {
558
+ const classes = `bg-task-stall${hint?.warn ? ' bg-task-stall-warn' : ''}`;
559
+ return `<span class="${classes}"${hint ? '' : ' hidden'}>${hint ? escHtml(hint.text) : ''}</span>`;
560
+ }
561
+
562
+ function taskSteps(task) {
563
+ const label = stepsLabel(task?.steps);
564
+ if (label || !subagentRunIdForTask(task) || !subagentStepCounter) return label;
565
+ try {
566
+ const counted = subagentStepCounter(String(task.toolUseId));
567
+ return Number.isFinite(Number(counted)) && Number(counted) > 0 ? stepsLabel(counted) : '';
568
+ } catch {
569
+ return '';
570
+ }
571
+ }
572
+
573
+ export function renderTaskRow(conversationId, task) {
371
574
  const key = `${conversationId}:${task.taskId}`;
372
575
  const stopping = stopsInFlight.has(key);
373
576
  const elapsed = formatElapsed(task.startedAt);
@@ -377,6 +580,11 @@ function renderTaskRow(conversationId, task) {
377
580
  const modelLabel = formatTaskModelLabel(task.model);
378
581
  const modelTitle = task.modelInherited === true ? `${task.model} (inherited from the session)` : task.model;
379
582
  const tokens = formatTaskTokens(task.totalTokens);
583
+ const steps = taskSteps(task);
584
+ const health = getBackgroundTaskHealth(conversationId, task.taskId);
585
+ const stall = taskHasConsole(task)
586
+ ? stallHint(health?.lastOutputAt, getTaskStallMinutes(conversationId))
587
+ : null;
380
588
  // Workflow rows with a digest get a fold chevron plus an (initially empty)
381
589
  // tree container right below the row; renderBackgroundTasksPanel fills the
382
590
  // container with DOM nodes after this markup lands, so digest text never
@@ -393,9 +601,7 @@ function renderTaskRow(conversationId, task) {
393
601
  <div class="bg-task-row" data-task-id="${escHtml(task.taskId)}">
394
602
  ${foldButton}<span class="bg-task-icon" title="${escHtml(task.taskType || 'task')}">${taskIcon(task.taskType)}</span>
395
603
  <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>`}
604
+ ${taskTitleHtml(task)}
399
605
  ${activity.text ? `<span class="bg-task-detail${activity.toolCall ? ' bg-task-toolcall' : ''}" title="${escHtml(activity.text)}">${escHtml(activity.text)}</span>` : ''}
400
606
  </span>
401
607
  <span class="bg-task-side">
@@ -405,6 +611,9 @@ function renderTaskRow(conversationId, task) {
405
611
  </span>
406
612
  <span class="bg-task-side-status">
407
613
  ${tokens ? `<span class="bg-task-tokens">${escHtml(tokens)}</span>` : ''}
614
+ ${steps ? `<span class="bg-task-steps">${escHtml(steps)}</span>` : ''}
615
+ ${chipHtml(health)}
616
+ ${taskHasConsole(task) ? stallHtml(stall) : ''}
408
617
  <span class="bg-task-elapsed">${escHtml(elapsed)}</span>
409
618
  ${task.stoppable === false ? '' : `<button type="button" class="bg-task-stop" data-task-id="${escHtml(task.taskId)}" ${stopping ? 'disabled' : ''}>${stopping ? 'Stopping…' : 'Stop'}</button>`}
410
619
  </span>
@@ -412,11 +621,49 @@ function renderTaskRow(conversationId, task) {
412
621
  </div>${treeHolder}`;
413
622
  }
414
623
 
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) {
624
+ function endedDuration(task) {
625
+ const started = Number(task.startedAt);
626
+ const endedAt = Number.isFinite(Number(task.endedAt)) ? Number(task.endedAt) : Date.parse(String(task.endedAt || ''));
627
+ if (!Number.isFinite(started) || started <= 0 || !Number.isFinite(endedAt) || endedAt < started) return '';
628
+ const seconds = Math.floor((endedAt - started) / 1000);
629
+ if (seconds < 60) return `${seconds}s`;
630
+ const minutes = Math.floor(seconds / 60);
631
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
632
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
633
+ }
634
+
635
+ /** A task that ended within the hour: dimmed, ✓/✘, no Stop, a dismiss ×. */
636
+ export function renderEndedTaskRow(conversationId, task) {
637
+ const outcome = endedOutcome(task);
638
+ const health = getBackgroundTaskHealth(conversationId, task.taskId);
639
+ const duration = endedDuration(task);
640
+ return `
641
+ <div class="bg-task-row bg-task-row-ended" data-task-id="${escHtml(task.taskId)}">
642
+ <span class="bg-task-icon" title="${escHtml(task.taskType || 'task')}">${taskIcon(task.taskType)}</span>
643
+ <span class="bg-task-main">
644
+ ${taskTitleHtml(task)}
645
+ </span>
646
+ <span class="bg-task-side">
647
+ <span class="bg-task-side-head">
648
+ <span class="bg-task-badge">${escHtml(taskKindLabel(task))}</span>
649
+ </span>
650
+ <span class="bg-task-side-status">
651
+ ${chipHtml(health)}
652
+ <span class="bg-task-outcome${outcome.failed ? ' bg-task-outcome-fail' : outcome.ok ? ' bg-task-outcome-ok' : ''}" title="${escHtml(outcome.label)}">${outcome.icon}</span>
653
+ ${duration ? `<span class="bg-task-elapsed">${escHtml(duration)}</span>` : ''}
654
+ <button type="button" class="bg-task-dismiss" data-task-id="${escHtml(task.taskId)}" aria-label="Dismiss" title="Hide this finished task">×</button>
655
+ </span>
656
+ </span>
657
+ </div>`;
658
+ }
659
+
660
+ // "2 background tasks running · 1 finished", "1 preview", or a mix — the
661
+ // panel is shared, so the summary has to name whatever is actually in it.
662
+ // Only live tasks count as running.
663
+ export function panelSummaryText(taskCount, previewCount, endedCount = 0) {
418
664
  const parts = [];
419
665
  if (taskCount) parts.push(`${taskCount} background task${taskCount === 1 ? '' : 's'} running`);
666
+ if (endedCount) parts.push(`${endedCount} finished`);
420
667
  if (previewCount) parts.push(`${previewCount} preview${previewCount === 1 ? '' : 's'}`);
421
668
  return parts.join(' · ');
422
669
  }
@@ -426,12 +673,16 @@ export function renderBackgroundTasksPanel() {
426
673
  if (!panel) return;
427
674
  const conversationId = currentConversationId;
428
675
  const tasks = conversationId ? (tasksByConversation.get(conversationId) || []) : [];
676
+ const ended = conversationId ? visibleEndedTasks(conversationId) : [];
429
677
  const previews = conversationId ? getPreviewsForConversation(conversationId) : [];
430
678
  // Previews outlive the task that started the dev server, so the panel stays
431
679
  // up for them even once every background task has finished.
432
- if (!tasks.length && !previews.length) {
680
+ if (!tasks.length && !ended.length && !previews.length) {
433
681
  panel.hidden = true;
434
682
  panel.removeAttribute('open');
683
+ // No stale rows behind the hidden panel (a dismissed last row is gone).
684
+ const staleList = panel.querySelector('#background-tasks-list');
685
+ if (staleList) staleList.innerHTML = '';
435
686
  stopElapsedTimer();
436
687
  return;
437
688
  }
@@ -440,7 +691,7 @@ export function renderBackgroundTasksPanel() {
440
691
  const summary = panel.querySelector('#background-tasks-summary');
441
692
  if (summary) {
442
693
  const spinner = tasks.length ? '<span class="bg-task-spinner"></span> ' : '';
443
- summary.innerHTML = `${spinner}${escHtml(panelSummaryText(tasks.length, previews.length))}`;
694
+ summary.innerHTML = `${spinner}${escHtml(panelSummaryText(tasks.length, previews.length, ended.length))}`;
444
695
  }
445
696
  const previewList = panel.querySelector('#background-previews-list');
446
697
  if (previewList) {
@@ -450,13 +701,20 @@ export function renderBackgroundTasksPanel() {
450
701
  }
451
702
  const list = panel.querySelector('#background-tasks-list');
452
703
  if (list) {
453
- list.innerHTML = tasks.map((task) => renderTaskRow(conversationId, task)).join('');
704
+ list.innerHTML = tasks.map((task) => renderTaskRow(conversationId, task)).join('')
705
+ + ended.map((task) => renderEndedTaskRow(conversationId, task)).join('');
454
706
  for (const button of list.querySelectorAll('.bg-task-stop')) {
455
707
  button.addEventListener('click', (event) => {
456
708
  event.preventDefault();
457
709
  void stopBackgroundTask(conversationId, button.dataset.taskId);
458
710
  });
459
711
  }
712
+ for (const button of list.querySelectorAll('.bg-task-dismiss')) {
713
+ button.addEventListener('click', (event) => {
714
+ event.preventDefault();
715
+ dismissEndedBackgroundTask(button.dataset.taskId);
716
+ });
717
+ }
460
718
  // Fill expanded workflow trees (collapsed holders stay empty — the toggle
461
719
  // re-renders, so they get content the moment they open).
462
720
  for (const holder of list.querySelectorAll('.bg-task-tree')) {
@@ -486,14 +744,25 @@ function startElapsedTimer() {
486
744
  elapsedTimer = setInterval(() => {
487
745
  const panel = document.getElementById('background-tasks-panel');
488
746
  if (!panel || panel.hidden || !panel.hasAttribute('open')) return;
489
- const tasks = currentConversationId ? (tasksByConversation.get(currentConversationId) || []) : [];
747
+ const conversationId = currentConversationId;
748
+ const tasks = conversationId ? (tasksByConversation.get(conversationId) || []) : [];
749
+ const threshold = getTaskStallMinutes(conversationId);
490
750
  for (const task of tasks) {
491
- const row = panel.querySelector(`.bg-task-row[data-task-id="${CSS.escape(task.taskId)}"] .bg-task-elapsed`);
751
+ const rowSelector = `.bg-task-row:not(.bg-task-row-ended)[data-task-id="${CSS.escape(task.taskId)}"]`;
752
+ const row = panel.querySelector(`${rowSelector} .bg-task-elapsed`);
492
753
  if (row) row.textContent = formatElapsed(task.startedAt);
754
+ const stallEl = panel.querySelector(`${rowSelector} .bg-task-stall`);
755
+ if (stallEl) applyStallHint(stallEl, stallHint(getBackgroundTaskHealth(conversationId, task.taskId)?.lastOutputAt, threshold));
493
756
  }
494
757
  }, 1000);
495
758
  }
496
759
 
760
+ function applyStallHint(element, hint) {
761
+ element.hidden = !hint;
762
+ element.textContent = hint ? hint.text : '';
763
+ element.className = `bg-task-stall${hint?.warn ? ' bg-task-stall-warn' : ''}`;
764
+ }
765
+
497
766
  function stopElapsedTimer() {
498
767
  if (!elapsedTimer) return;
499
768
  clearInterval(elapsedTimer);