@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.
- package/CHANGELOG.md +126 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +17 -3
- 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 +128 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -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 +9 -0
- package/server/grok-worker/acp-host-services.mjs +42 -3
- package/server/grok-worker/grok-background-tasks.mjs +241 -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/public/app/api-client.js +13 -0
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +95 -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-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +433 -3
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/journal-view.js +252 -66
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/settings-modal.js +110 -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 +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
- package/server/public/app/transcript-jump-bottom.mjs +48 -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 +239 -6
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +8 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/routes/messages-routes.mjs +249 -3
- package/server/routes/sessions-routes.mjs +133 -63
- package/server/server-runtime.mjs +77 -0
- package/server/services/background-task-store.mjs +240 -3
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +130 -2
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/remote-relay-contract.mjs +32 -2
- package/shared/task-output-text.mjs +737 -0
- 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
|
-
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
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
|
|
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);
|