@oar-sh/oar 0.9.9 → 0.9.10
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 +247 -0
- package/README.md +78 -9
- package/bin/bat/oar.bat +46 -46
- package/package.json +2 -2
- package/server/README.md +286 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +36 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +61 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +186 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +361 -85
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +146 -26
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +59 -28
- package/server/public/app/store.js +19 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +396 -5
- package/server/repositories/message-repository.mjs +29 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +515 -50
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +72 -0
- package/server/server-runtime.mjs +245 -62
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -16,14 +16,13 @@ import {
|
|
|
16
16
|
setRelayOnline,
|
|
17
17
|
setCliOnline,
|
|
18
18
|
setCloudflaredTunnelState,
|
|
19
|
-
setCurrentConv,
|
|
20
19
|
updateWorkspaceRootHints,
|
|
21
20
|
updateCompactButton,
|
|
22
|
-
updateSessionPill,
|
|
23
21
|
clearPendingUserMessage,
|
|
24
22
|
hasPendingUserMessageDuplicate,
|
|
25
23
|
isMessagesAtBottom,
|
|
26
24
|
upsertSubagentRun,
|
|
25
|
+
getSubagentRun,
|
|
27
26
|
addSubagentActivity,
|
|
28
27
|
addSubagentThought,
|
|
29
28
|
clearSubagentCancelInFlight,
|
|
@@ -36,7 +35,8 @@ import {
|
|
|
36
35
|
import { scheduleContextUsageRefresh } from './api-client.js';
|
|
37
36
|
import { capLiveActivityEntries } from './activity-replay-state.mjs';
|
|
38
37
|
import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
|
|
39
|
-
import { renderConvList, refreshConversations, openConversation } from './journal-view.js';
|
|
38
|
+
import { clearOpenConversation, renderConvList, refreshConversations, openConversation } from './journal-view.js';
|
|
39
|
+
import { applyRelayEventSubscriptions, loadRelayEventSubscriptions } from './relay-event-subscriptions-ui.js';
|
|
40
40
|
import {
|
|
41
41
|
upsertRelayQuestion,
|
|
42
42
|
loadRelayQuestions,
|
|
@@ -54,6 +54,7 @@ import { applyClaudeAuthState } from './claude-auth-ui.js';
|
|
|
54
54
|
import { applyGrokAuthState } from './grok-auth-ui.js';
|
|
55
55
|
import { applyClaudeCloudSettingsState, refreshClaudeCloudSettingsState } from './claude-cloud-settings-ui.js';
|
|
56
56
|
import { applyClaudeCloudSessionEvent } from './claude-cloud-conversation-ui.js';
|
|
57
|
+
import { updateProviderReadinessInputs } from './provider-readiness.mjs';
|
|
57
58
|
import { applyCliInstallState } from './cli-install-ui.js';
|
|
58
59
|
import { renderUpdateSection } from './update-ui.js';
|
|
59
60
|
import { applyHostSuspendState, applyRelayShutdownState } from './host-suspend-ui.js';
|
|
@@ -74,6 +75,7 @@ import {
|
|
|
74
75
|
renderMessages,
|
|
75
76
|
appendMessage,
|
|
76
77
|
replaceRenderedMessage,
|
|
78
|
+
noteTranscriptLiveRow,
|
|
77
79
|
applyIncomingConversationDraftUpdate,
|
|
78
80
|
getRenderedConversationMessageFingerprints,
|
|
79
81
|
clearBubbleCancelState,
|
|
@@ -88,6 +90,12 @@ import { clearMessageSearchRuntimeState } from './message-search-view.js';
|
|
|
88
90
|
import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
|
|
89
91
|
import { isLikelyLiveDuplicateMessage } from './live-message-dedupe.mjs';
|
|
90
92
|
import { mergeRelayThoughts } from './relay-thoughts.mjs';
|
|
93
|
+
import {
|
|
94
|
+
handleSubagentStatusEventForViewer,
|
|
95
|
+
handleSubagentTimelineEvent,
|
|
96
|
+
handleSubagentViewerReconnect,
|
|
97
|
+
refreshSubagentRows,
|
|
98
|
+
} from './subagent-viewer.mjs';
|
|
91
99
|
|
|
92
100
|
const FALLBACK_MODE = 'agent';
|
|
93
101
|
|
|
@@ -327,6 +335,10 @@ export async function connectSocket(overrideDeps) {
|
|
|
327
335
|
resyncAfterConnect();
|
|
328
336
|
refreshSessionWorkerStatus().catch(() => {});
|
|
329
337
|
refreshModelCatalog().catch(() => {});
|
|
338
|
+
// An open sub-agent viewer fetches whatever it missed while away.
|
|
339
|
+
handleSubagentViewerReconnect();
|
|
340
|
+
// Subscriptions that changed while away.
|
|
341
|
+
void loadRelayEventSubscriptions();
|
|
330
342
|
});
|
|
331
343
|
socket.on('connect_error', (e) => {
|
|
332
344
|
setRelayOnline(false);
|
|
@@ -357,8 +369,11 @@ export async function connectSocket(overrideDeps) {
|
|
|
357
369
|
updateModelCatalogState(payload || {});
|
|
358
370
|
void reconcileOpenModelVariantModal();
|
|
359
371
|
});
|
|
372
|
+
// Each settings event also feeds provider readiness (provider-readiness.mjs),
|
|
373
|
+
// so a Settings save changes the empty screen without a reload.
|
|
360
374
|
socket.on('openai_settings_updated', (payload) => {
|
|
361
|
-
deps?.applyOpenAISettingsState?.(payload || {});
|
|
375
|
+
const state = deps?.applyOpenAISettingsState?.(payload || {});
|
|
376
|
+
updateProviderReadinessInputs({ openai: state || payload || null });
|
|
362
377
|
if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
|
|
363
378
|
void Promise.resolve()
|
|
364
379
|
.then(() => deps?.refreshCurrentView?.())
|
|
@@ -371,7 +386,8 @@ export async function connectSocket(overrideDeps) {
|
|
|
371
386
|
deps?.applyCopilotSettingsState?.(payload || {});
|
|
372
387
|
});
|
|
373
388
|
socket.on('claude_settings_updated', (payload) => {
|
|
374
|
-
deps?.applyClaudeSettingsState?.(payload || {});
|
|
389
|
+
const state = deps?.applyClaudeSettingsState?.(payload || {});
|
|
390
|
+
updateProviderReadinessInputs({ claude: state || payload || null });
|
|
375
391
|
if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
|
|
376
392
|
void Promise.resolve()
|
|
377
393
|
.then(() => deps?.refreshCurrentView?.())
|
|
@@ -421,8 +437,13 @@ export async function connectSocket(overrideDeps) {
|
|
|
421
437
|
// carries the same shape as GET /api/settings/claude-cloud; one without a
|
|
422
438
|
// body is read as "something changed" and answered with a fresh read.
|
|
423
439
|
socket.on('claude_cloud_settings_updated', (payload) => {
|
|
424
|
-
if (payload && typeof payload === 'object')
|
|
425
|
-
|
|
440
|
+
if (payload && typeof payload === 'object') {
|
|
441
|
+
updateProviderReadinessInputs({ claudeCloud: applyClaudeCloudSettingsState(payload) || null });
|
|
442
|
+
} else {
|
|
443
|
+
void refreshClaudeCloudSettingsState().then((state) => {
|
|
444
|
+
updateProviderReadinessInputs({ claudeCloud: state || null });
|
|
445
|
+
});
|
|
446
|
+
}
|
|
426
447
|
});
|
|
427
448
|
// A cloud session got its id, pushed a branch or reported a new cost: the
|
|
428
449
|
// conversation's `cloud` field changed, and with it the cloud line.
|
|
@@ -430,7 +451,8 @@ export async function connectSocket(overrideDeps) {
|
|
|
430
451
|
if (applyClaudeCloudSessionEvent(payload || null)) deps?.syncChatTitleControls?.();
|
|
431
452
|
});
|
|
432
453
|
socket.on('grok_settings_updated', (payload) => {
|
|
433
|
-
deps?.applyGrokSettingsState?.(payload || {});
|
|
454
|
+
const state = deps?.applyGrokSettingsState?.(payload || {});
|
|
455
|
+
updateProviderReadinessInputs({ grok: state || payload || null });
|
|
434
456
|
if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
|
|
435
457
|
void Promise.resolve()
|
|
436
458
|
.then(() => deps?.refreshCurrentView?.())
|
|
@@ -438,7 +460,8 @@ export async function connectSocket(overrideDeps) {
|
|
|
438
460
|
}
|
|
439
461
|
});
|
|
440
462
|
socket.on('cursor_settings_updated', (payload) => {
|
|
441
|
-
deps?.applyCursorSettingsState?.(payload || {});
|
|
463
|
+
const state = deps?.applyCursorSettingsState?.(payload || {});
|
|
464
|
+
updateProviderReadinessInputs({ cursor: state || payload || null });
|
|
442
465
|
if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
|
|
443
466
|
void Promise.resolve()
|
|
444
467
|
.then(() => deps?.refreshCurrentView?.())
|
|
@@ -485,7 +508,11 @@ export async function connectSocket(overrideDeps) {
|
|
|
485
508
|
})) {
|
|
486
509
|
return;
|
|
487
510
|
}
|
|
488
|
-
|
|
511
|
+
// Another client's message (an agent's prompt included) follows the
|
|
512
|
+
// end like any live row: a reader scrolled up stays where they are,
|
|
513
|
+
// and the jump-to-bottom badge counts it.
|
|
514
|
+
appendMessage(normalizedMessage, isMessagesAtBottom(), messageId);
|
|
515
|
+
noteTranscriptLiveRow();
|
|
489
516
|
}
|
|
490
517
|
});
|
|
491
518
|
socket.on('assistant_message', ({ conversationId, message, messageId, sourceMessageId }) => {
|
|
@@ -512,6 +539,7 @@ export async function connectSocket(overrideDeps) {
|
|
|
512
539
|
if (messageId && seenMessageIds?.has(messageId)) return;
|
|
513
540
|
if (isCurrentConversation) {
|
|
514
541
|
appendMessage(message, autoScroll, messageId || null, false, sourceMessageId || null);
|
|
542
|
+
noteTranscriptLiveRow();
|
|
515
543
|
scheduleContextUsageRefresh(conversationId, 120);
|
|
516
544
|
}
|
|
517
545
|
if (sourceMessageId) relayActivities.delete(sourceMessageId);
|
|
@@ -532,6 +560,7 @@ export async function connectSocket(overrideDeps) {
|
|
|
532
560
|
if (!replaceRenderedMessage(message, messageId)) {
|
|
533
561
|
appendMessage(message, isMessagesAtBottom(), messageId, false);
|
|
534
562
|
}
|
|
563
|
+
noteTranscriptLiveRow();
|
|
535
564
|
});
|
|
536
565
|
socket.on('relay_question', ({ question }) => upsertRelayQuestion(question));
|
|
537
566
|
socket.on('relay_question_updated', ({ question }) => upsertRelayQuestion(question));
|
|
@@ -543,6 +572,10 @@ export async function connectSocket(overrideDeps) {
|
|
|
543
572
|
socket.on('relay_board_changed', () => {
|
|
544
573
|
loadRelayBoards();
|
|
545
574
|
});
|
|
575
|
+
// Relay event subscriptions: the whole active list, after every change.
|
|
576
|
+
socket.on('relay_event_subscriptions', ({ subscriptions }) => {
|
|
577
|
+
applyRelayEventSubscriptions(subscriptions);
|
|
578
|
+
});
|
|
546
579
|
socket.on('background_tasks', ({ conversationId, tasks }) => {
|
|
547
580
|
setConversationBackgroundTasks(conversationId, tasks);
|
|
548
581
|
});
|
|
@@ -623,24 +656,28 @@ export async function connectSocket(overrideDeps) {
|
|
|
623
656
|
appendThinkingThought(key, String(text || ''), !!done, subagentRunId, autoScroll);
|
|
624
657
|
}
|
|
625
658
|
});
|
|
626
|
-
socket.on('subagent_status', (
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
status,
|
|
635
|
-
timestamp,
|
|
636
|
-
});
|
|
659
|
+
socket.on('subagent_status', (payload = {}) => {
|
|
660
|
+
const { conversationId, subagentRunId, status, stopUnsupported } = payload;
|
|
661
|
+
if (!subagentRunId) return;
|
|
662
|
+
// An event without its turn is placed on the run the store already
|
|
663
|
+
// knows; one for an unknown run without a turn cannot be placed.
|
|
664
|
+
const messageId = payload.messageId || getSubagentRun(subagentRunId)?.messageId || null;
|
|
665
|
+
if (!messageId) return;
|
|
666
|
+
upsertSubagentRun({ ...payload, messageId });
|
|
637
667
|
clearSubagentCancelInFlight(subagentRunId);
|
|
638
668
|
// The provider answered "not supported": pin the state so the button does
|
|
639
669
|
// not re-arm into an endless click-and-fail loop.
|
|
640
670
|
if (stopUnsupported) markSubagentStopUnsupported(subagentRunId);
|
|
641
671
|
if (conversationId === currentConvId) {
|
|
642
672
|
updateSubagentBubbleFromStatus(subagentRunId, status);
|
|
673
|
+
refreshSubagentRows(subagentRunId);
|
|
643
674
|
}
|
|
675
|
+
handleSubagentStatusEventForViewer({ ...payload, messageId });
|
|
676
|
+
});
|
|
677
|
+
// Entries of a sub-agent's record; the relay sends them only while this
|
|
678
|
+
// page's viewer watches that run.
|
|
679
|
+
socket.on('subagent_timeline', (payload) => {
|
|
680
|
+
handleSubagentTimelineEvent(payload);
|
|
644
681
|
});
|
|
645
682
|
socket.on('conversation_compacted', async ({ sourceConversationId, targetConversationId }) => {
|
|
646
683
|
if (!sourceConversationId || !targetConversationId) return;
|
|
@@ -811,13 +848,7 @@ export async function connectSocket(overrideDeps) {
|
|
|
811
848
|
renderRelayBoards();
|
|
812
849
|
renderConvList();
|
|
813
850
|
if (currentConvId === conversationId) {
|
|
814
|
-
|
|
815
|
-
renderMessages([]);
|
|
816
|
-
document.getElementById('chat-title').textContent = 'Select or start a conversation';
|
|
817
|
-
syncChatTitleControls();
|
|
818
|
-
updateSessionPill(null, null);
|
|
819
|
-
updateCompactButton();
|
|
820
|
-
scheduleContextUsageRefresh(null);
|
|
851
|
+
clearOpenConversation();
|
|
821
852
|
} else {
|
|
822
853
|
updateCompactButton();
|
|
823
854
|
}
|
|
@@ -1498,6 +1498,17 @@ export function isSubagentCancelInFlight(subagentRunId) {
|
|
|
1498
1498
|
return id ? subagentCancelInFlight.has(id) : false;
|
|
1499
1499
|
}
|
|
1500
1500
|
|
|
1501
|
+
const SUBAGENT_HEADER_FIELDS = ['completedAt', 'model', 'modelInherited', 'subagentType', 'taskId', 'totalTokens', 'background', 'truncated', 'stoppable'];
|
|
1502
|
+
|
|
1503
|
+
function subagentHeaderFields(payload, existing) {
|
|
1504
|
+
const fields = {};
|
|
1505
|
+
for (const name of SUBAGENT_HEADER_FIELDS) {
|
|
1506
|
+
const value = payload?.[name];
|
|
1507
|
+
fields[name] = value === undefined ? (existing?.[name] ?? null) : value;
|
|
1508
|
+
}
|
|
1509
|
+
return fields;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1501
1512
|
export function upsertSubagentRun(payload) {
|
|
1502
1513
|
const subagentRunId = normalizeSubagentRunId(payload?.subagentRunId);
|
|
1503
1514
|
if (!subagentRunId) return null;
|
|
@@ -1511,8 +1522,14 @@ export function upsertSubagentRun(payload) {
|
|
|
1511
1522
|
parentSubagentId: normalizeSubagentRunId(payload?.parentSubagentId) || existing?.parentSubagentId || null,
|
|
1512
1523
|
displayName: String(payload?.displayName || existing?.displayName || '').trim() || null,
|
|
1513
1524
|
status: nextSubagentStatus({ reported: payload?.status, known: existing?.status, normalize: normalizeSubagentStatus }),
|
|
1514
|
-
startedAt: existing?.startedAt || payload?.timestamp || now,
|
|
1515
|
-
updatedAt: payload?.timestamp || now,
|
|
1525
|
+
startedAt: payload?.startedAt || existing?.startedAt || payload?.timestamp || now,
|
|
1526
|
+
updatedAt: payload?.updatedAt || payload?.timestamp || now,
|
|
1527
|
+
// The run header (relay 0009): what the sub-agent rows and the viewer
|
|
1528
|
+
// show. An event that does not name a field keeps the known value.
|
|
1529
|
+
...subagentHeaderFields(payload, existing),
|
|
1530
|
+
// Whether `status` came from the relay rather than the default a live
|
|
1531
|
+
// lane's first frame starts with.
|
|
1532
|
+
statusReported: Boolean(String(payload?.status || '').trim()) || existing?.statusReported === true,
|
|
1516
1533
|
activities: existing?.activities || [],
|
|
1517
1534
|
thoughts: existing?.thoughts || [],
|
|
1518
1535
|
// Cumulative streamed text for this run, replayed when its bubble is
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// One sub-agent as a row: the same look as a background-task row in the
|
|
2
|
+
// composer panel (icon, title, detail line, model, type badge, tokens, time),
|
|
3
|
+
// used in the finished reply bubble and inside the sub-agent viewer for child
|
|
4
|
+
// agents. The title opens the agent's record in the viewer.
|
|
5
|
+
//
|
|
6
|
+
// Pure: markup in, markup out, so the bubble renderer and the viewer share it
|
|
7
|
+
// and tests run without a DOM.
|
|
8
|
+
|
|
9
|
+
import { formatTaskModelLabel, formatTaskTokens } from './task-row-format.mjs';
|
|
10
|
+
|
|
11
|
+
const STATUS_LABELS = {
|
|
12
|
+
running: 'Running',
|
|
13
|
+
pending: 'Starting',
|
|
14
|
+
processing: 'Running',
|
|
15
|
+
completed: 'Completed',
|
|
16
|
+
failed: 'Failed',
|
|
17
|
+
cancelled: 'Stopped',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const STATUS_ICONS = {
|
|
21
|
+
completed: '✓',
|
|
22
|
+
failed: '✗',
|
|
23
|
+
cancelled: '■',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function escHtml(value) {
|
|
27
|
+
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
|
28
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
29
|
+
}[ch]));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseTime(value) {
|
|
33
|
+
if (value === null || value === undefined || value === '') return NaN;
|
|
34
|
+
if (typeof value === 'number') return value;
|
|
35
|
+
return Date.parse(String(value));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** "42s", "3m 5s", "1h 4m": how long a run ran, or has been running. */
|
|
39
|
+
export function formatRunDuration(startedAt, endedAt = null, now = Date.now()) {
|
|
40
|
+
const start = parseTime(startedAt);
|
|
41
|
+
if (!Number.isFinite(start)) return '';
|
|
42
|
+
const end = Number.isFinite(parseTime(endedAt)) ? parseTime(endedAt) : now;
|
|
43
|
+
const seconds = Math.max(0, Math.floor((end - start) / 1000));
|
|
44
|
+
if (seconds < 60) return `${seconds}s`;
|
|
45
|
+
const minutes = Math.floor(seconds / 60);
|
|
46
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
|
47
|
+
const hours = Math.floor(minutes / 60);
|
|
48
|
+
return `${hours}h ${minutes % 60}m`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function subagentStatusLabel(status) {
|
|
52
|
+
const key = String(status || 'running').trim().toLowerCase();
|
|
53
|
+
return STATUS_LABELS[key] || (key ? key.charAt(0).toUpperCase() + key.slice(1) : 'Running');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isRunningSubagentStatus(status) {
|
|
57
|
+
const key = String(status || 'running').trim().toLowerCase();
|
|
58
|
+
return key === 'running' || key === 'pending' || key === 'processing';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The row for one run. `clickable` makes the title the button that opens the
|
|
63
|
+
* viewer (`data-subagent-open`); a shared view, which cannot read the record,
|
|
64
|
+
* passes false. `context` lands in a class so the bubble and the viewer can
|
|
65
|
+
* size the row differently.
|
|
66
|
+
*/
|
|
67
|
+
export function subagentRowHtml(run, { context = 'bubble', clickable = true, now = Date.now() } = {}) {
|
|
68
|
+
const runId = String(run?.subagentRunId || '').trim();
|
|
69
|
+
if (!runId) return '';
|
|
70
|
+
const status = String(run?.status || 'running').trim().toLowerCase() || 'running';
|
|
71
|
+
const running = isRunningSubagentStatus(status);
|
|
72
|
+
const title = String(run?.displayName || '').trim() || `Subagent ${runId.slice(0, 8)}`;
|
|
73
|
+
const modelLabel = formatTaskModelLabel(run?.model);
|
|
74
|
+
const modelTitle = run?.modelInherited === true ? `${run.model} (inherited from the session)` : (run?.model || '');
|
|
75
|
+
const kind = String(run?.subagentType || '').trim() || 'Subagent';
|
|
76
|
+
const tokens = formatTaskTokens(run?.totalTokens);
|
|
77
|
+
const time = formatRunDuration(run?.startedAt, running ? null : (run?.completedAt || run?.updatedAt), now);
|
|
78
|
+
const statusIcon = STATUS_ICONS[status] ? `${STATUS_ICONS[status]} ` : '';
|
|
79
|
+
const detailParts = [`${statusIcon}${subagentStatusLabel(status)}`];
|
|
80
|
+
if (run?.background) detailParts.push('in the background');
|
|
81
|
+
if (run?.truncated) detailParts.push('record cut');
|
|
82
|
+
const titleHtml = clickable
|
|
83
|
+
? `<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">${escHtml(title)}</button>`
|
|
84
|
+
: `<span class="bg-task-desc">${escHtml(title)}</span>`;
|
|
85
|
+
return `
|
|
86
|
+
<div class="bg-task-row subagent-row subagent-row-${escHtml(context)}${running ? ' is-running' : ''}" data-subagent-row="${escHtml(runId)}" data-status="${escHtml(status)}">
|
|
87
|
+
<span class="bg-task-icon" title="Sub-agent">🤖</span>
|
|
88
|
+
<span class="bg-task-main">
|
|
89
|
+
${titleHtml}
|
|
90
|
+
<span class="bg-task-detail subagent-row-status subagent-status-${escHtml(status)}">${escHtml(detailParts.join(' · '))}</span>
|
|
91
|
+
</span>
|
|
92
|
+
<span class="bg-task-side">
|
|
93
|
+
<span class="bg-task-side-head">
|
|
94
|
+
${modelLabel ? `<span class="bg-task-model" title="${escHtml(modelTitle)}">${escHtml(modelLabel)}</span>` : ''}
|
|
95
|
+
<span class="bg-task-badge">${escHtml(kind)}</span>
|
|
96
|
+
</span>
|
|
97
|
+
<span class="bg-task-side-status">
|
|
98
|
+
${tokens ? `<span class="bg-task-tokens">${escHtml(tokens)}</span>` : ''}
|
|
99
|
+
${time ? `<span class="bg-task-elapsed subagent-row-time"${running ? ` data-started-at="${escHtml(run?.startedAt || '')}"` : ''}>${escHtml(time)}</span>` : ''}
|
|
100
|
+
</span>
|
|
101
|
+
</span>
|
|
102
|
+
</div>`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A run from the conversation payload, brought up to date with what the page
|
|
107
|
+
* has learned since (the store, fed by status events). Only what the store
|
|
108
|
+
* actually knows wins: an entry a live lane created has no header yet, and
|
|
109
|
+
* its status is a guess unless a status event reported it.
|
|
110
|
+
*/
|
|
111
|
+
export function mergeLiveSubagentRun(base, live) {
|
|
112
|
+
if (!live) return base;
|
|
113
|
+
const merged = { ...base };
|
|
114
|
+
for (const [key, value] of Object.entries(live)) {
|
|
115
|
+
if (key === 'status' || key === 'statusReported' || key === 'activities' || key === 'thoughts' || key === 'streamText') continue;
|
|
116
|
+
if (value === null || value === undefined || value === '') continue;
|
|
117
|
+
merged[key] = value;
|
|
118
|
+
}
|
|
119
|
+
if (live.statusReported && live.status) merged.status = live.status;
|
|
120
|
+
return merged;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The runs a reply bubble lists: top-level runs only (a child shows inside
|
|
125
|
+
* its parent's record), in the order they were spawned.
|
|
126
|
+
*/
|
|
127
|
+
export function topLevelSubagentRuns(runs) {
|
|
128
|
+
return (Array.isArray(runs) ? runs : [])
|
|
129
|
+
.filter((run) => run && String(run.subagentRunId || '').trim() && !run.parentSubagentId)
|
|
130
|
+
.sort((a, b) => (parseTime(a.startedAt) || 0) - (parseTime(b.startedAt) || 0));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The bubble's list of sub-agent rows, or '' when it spawned none. */
|
|
134
|
+
export function subagentRowsMarkup(runs, { clickable = true, now = Date.now() } = {}) {
|
|
135
|
+
const roots = topLevelSubagentRuns(runs);
|
|
136
|
+
if (!roots.length) return '';
|
|
137
|
+
return `<div class="subagent-rows" data-subagent-rows>${roots.map((run) => subagentRowHtml(run, { context: 'bubble', clickable, now })).join('')}</div>`;
|
|
138
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// The sub-agent viewer's timeline, without a DOM: the merge of fetched and
|
|
2
|
+
// live entries into one ordered record, gap detection, and the markup of each
|
|
3
|
+
// entry. subagent-viewer.mjs owns the modal and calls into this.
|
|
4
|
+
|
|
5
|
+
import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
|
|
6
|
+
import { subagentRowHtml } from './subagent-row.mjs';
|
|
7
|
+
|
|
8
|
+
function escHtml(value) {
|
|
9
|
+
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
|
10
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
11
|
+
}[ch]));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One run's record as the viewer holds it. */
|
|
15
|
+
export function createTimelineState(runId) {
|
|
16
|
+
return { runId: String(runId || ''), entries: new Map(), lastSeq: 0, maxSeq: 0 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fold entries (a fetched page or a live event) into the record. An entry the
|
|
21
|
+
* record already has is replaced (open entries grow in place). Returns the
|
|
22
|
+
* seqs that changed and whether the record now has a hole: the relay numbers
|
|
23
|
+
* a run's entries 1, 2, 3…, so a missing number means an event was missed and
|
|
24
|
+
* the viewer should fetch from `lastSeq`.
|
|
25
|
+
*/
|
|
26
|
+
export function mergeTimelineEntries(state, entries) {
|
|
27
|
+
const changed = [];
|
|
28
|
+
for (const raw of Array.isArray(entries) ? entries : []) {
|
|
29
|
+
const seq = Number(raw?.seq);
|
|
30
|
+
if (!Number.isInteger(seq) || seq <= 0) continue;
|
|
31
|
+
const previous = state.entries.get(seq);
|
|
32
|
+
// A closed entry never reopens: a late open snapshot is stale.
|
|
33
|
+
if (previous?.done && raw.done === false) continue;
|
|
34
|
+
state.entries.set(seq, { ...raw, seq });
|
|
35
|
+
changed.push(seq);
|
|
36
|
+
if (seq > state.maxSeq) state.maxSeq = seq;
|
|
37
|
+
}
|
|
38
|
+
while (state.entries.has(state.lastSeq + 1)) state.lastSeq += 1;
|
|
39
|
+
return { changed, gap: state.lastSeq < state.maxSeq };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function orderedTimelineEntries(state) {
|
|
43
|
+
return [...state.entries.values()].sort((a, b) => a.seq - b.seq);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseJsonObject(text) {
|
|
47
|
+
const value = String(text || '').trim();
|
|
48
|
+
if (!value.startsWith('{')) return null;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(value);
|
|
51
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Same list as shared/subagent-timeline.mjs (the relay's retention sweep
|
|
58
|
+
// keeps this line when it strips a call's input); a test keeps them in step.
|
|
59
|
+
export const SUMMARY_FIELDS = ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'description', 'prompt', 'notebook_path', 'skill'];
|
|
60
|
+
|
|
61
|
+
/** The one line a tool call is known by: its command, path, pattern… */
|
|
62
|
+
export function toolCallSummary(text) {
|
|
63
|
+
const input = parseJsonObject(text);
|
|
64
|
+
if (input) {
|
|
65
|
+
for (const field of SUMMARY_FIELDS) {
|
|
66
|
+
const value = input[field];
|
|
67
|
+
if (typeof value === 'string' && value.trim()) return value.replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
68
|
+
}
|
|
69
|
+
const keys = Object.keys(input);
|
|
70
|
+
return keys.length ? keys.slice(0, 4).join(', ') : '';
|
|
71
|
+
}
|
|
72
|
+
return String(text || '').split('\n')[0].trim().slice(0, 200);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A tool call's input, readable: pretty JSON, or the text as it came. */
|
|
76
|
+
export function formatToolInput(text) {
|
|
77
|
+
const input = parseJsonObject(text);
|
|
78
|
+
return input ? JSON.stringify(input, null, 2) : String(text || '');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatByteSize(length) {
|
|
82
|
+
const n = Number(length);
|
|
83
|
+
if (!Number.isFinite(n) || n < 0) return '';
|
|
84
|
+
if (n < 1024) return `${n} chars`;
|
|
85
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB`;
|
|
86
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** "removed after 90 days": what the relay's retention sweep left a note of. */
|
|
90
|
+
function prunedLabel(entry, what) {
|
|
91
|
+
const days = Number(entry?.metadata?.retentionDays);
|
|
92
|
+
return Number.isFinite(days) && days > 0 ? `${what} removed after ${days} days` : `${what} removed`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function firstLine(text, max = 140) {
|
|
96
|
+
const line = String(text || '').split('\n').map((part) => part.trim()).find(Boolean) || '';
|
|
97
|
+
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One entry's markup. `renderMarkdown(text)` renders reply text (and
|
|
102
|
+
* thoughts); `children` maps a child run id to its header so a child entry
|
|
103
|
+
* renders as a clickable row; `openDetails` keeps a fold the reader opened
|
|
104
|
+
* open across re-renders.
|
|
105
|
+
*/
|
|
106
|
+
export function timelineEntryHtml(entry, { renderMarkdown = (text) => `<p>${escHtml(text)}</p>`, children = new Map(), openDetails = false, clickable = true, now = Date.now() } = {}) {
|
|
107
|
+
const kind = String(entry?.kind || '');
|
|
108
|
+
const seq = Number(entry?.seq) || 0;
|
|
109
|
+
const open = entry?.done === false;
|
|
110
|
+
const text = String(entry?.text || '');
|
|
111
|
+
const wrap = (inner, extraClass = '') => `<div class="sav-entry sav-entry-${escHtml(kind)}${open ? ' is-open' : ''}${extraClass}" data-seq="${seq}">${inner}</div>`;
|
|
112
|
+
const openAttr = openDetails ? ' open' : '';
|
|
113
|
+
|
|
114
|
+
if (kind === 'thought') {
|
|
115
|
+
const label = open && !text.trim() ? 'Thinking…' : (firstLine(text, 110) || 'Thought');
|
|
116
|
+
return wrap(`<details class="sav-fold"${open || openDetails ? ' open' : ''}><summary><span class="sav-kind">💭</span> <span class="sav-summary-text">${escHtml(label)}</span></summary><div class="sav-thought-body markdown-body">${renderMarkdown(text)}</div></details>`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (kind === 'text') {
|
|
120
|
+
return wrap(`<div class="sav-text markdown-body">${text.trim() ? renderMarkdown(text) : '<span class="sav-muted">Writing…</span>'}</div>`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (kind === 'tool_call') {
|
|
124
|
+
const toolName = String(entry?.toolName || 'tool');
|
|
125
|
+
const headline = prefixToolActivityEmoji(`Tool (${toolName}): x`).split(' Tool (')[0] || '🔧';
|
|
126
|
+
const summary = toolCallSummary(text);
|
|
127
|
+
const input = formatToolInput(text);
|
|
128
|
+
const hasMore = input.trim() && input.trim() !== summary;
|
|
129
|
+
const legacy = entry?.metadata?.legacy === true;
|
|
130
|
+
if (entry?.metadata?.pruned === true) {
|
|
131
|
+
return wrap(`<div class="sav-call-line"><span class="sav-kind">${escHtml(headline)}</span> <span class="sav-tool-name">${escHtml(toolName)}</span> <span class="sav-tool-summary">${escHtml(summary)}</span> <span class="sav-muted sav-pruned">· ${escHtml(prunedLabel(entry, 'Input'))}</span></div>`, ' is-pruned');
|
|
132
|
+
}
|
|
133
|
+
if (legacy || !hasMore) {
|
|
134
|
+
return wrap(`<div class="sav-call-line"><span class="sav-kind">${escHtml(headline)}</span> <span class="sav-tool-name">${escHtml(toolName)}</span> <span class="sav-tool-summary">${escHtml(legacy ? text.replace(/^Tool \([^)]+\):?\s*/, '') : summary)}</span></div>`);
|
|
135
|
+
}
|
|
136
|
+
return wrap(`<details class="sav-fold"${openAttr}><summary><span class="sav-kind">${escHtml(headline)}</span> <span class="sav-tool-name">${escHtml(toolName)}</span> <span class="sav-tool-summary">${escHtml(summary)}</span></summary><pre class="sav-pre">${escHtml(input)}</pre></details>`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (kind === 'tool_result') {
|
|
140
|
+
const isError = entry?.metadata?.isError === true;
|
|
141
|
+
const cut = Number(entry?.metadata?.cut);
|
|
142
|
+
const parts = [isError ? 'Error' : 'Result'];
|
|
143
|
+
if (text) parts.push(formatByteSize(text.length));
|
|
144
|
+
if (Number.isFinite(cut) && cut > text.length) parts.push(`cut from ${formatByteSize(cut)}`);
|
|
145
|
+
const preview = firstLine(text, 120);
|
|
146
|
+
if (entry?.metadata?.pruned === true) {
|
|
147
|
+
const size = formatByteSize(entry.metadata.originalChars);
|
|
148
|
+
const label = [isError ? 'Error' : 'Result', size, prunedLabel(entry, 'output')].filter(Boolean).join(' · ');
|
|
149
|
+
return wrap(`<div class="sav-result-line"><span class="sav-kind">↳</span> <span class="sav-muted sav-pruned">${escHtml(label)}</span></div>`, `${isError ? ' is-error' : ''} is-pruned`);
|
|
150
|
+
}
|
|
151
|
+
if (!text.trim()) {
|
|
152
|
+
return wrap(`<div class="sav-result-line"><span class="sav-kind">↳</span> <span class="sav-muted">${escHtml(parts.join(' · '))} · empty</span></div>`, isError ? ' is-error' : '');
|
|
153
|
+
}
|
|
154
|
+
return wrap(`<details class="sav-fold"${openAttr}><summary><span class="sav-kind">↳</span> <span class="sav-result-label">${escHtml(parts.join(' · '))}</span>${preview ? ` <span class="sav-summary-text sav-muted">${escHtml(preview)}</span>` : ''}</summary><pre class="sav-pre">${escHtml(text)}</pre></details>`, isError ? ' is-error' : '');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (kind === 'child') {
|
|
158
|
+
const childId = String(entry?.toolUseId || '');
|
|
159
|
+
const child = children.get(childId) || {
|
|
160
|
+
subagentRunId: childId,
|
|
161
|
+
displayName: text || 'Subagent',
|
|
162
|
+
subagentType: entry?.metadata?.subagentType || null,
|
|
163
|
+
status: 'running',
|
|
164
|
+
};
|
|
165
|
+
return wrap(childId ? subagentRowHtml(child, { context: 'timeline', clickable, now }) : `<div class="sav-muted">🤖 ${escHtml(text)}</div>`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// note
|
|
169
|
+
const status = String(entry?.metadata?.status || '');
|
|
170
|
+
return wrap(`<div class="sav-note${status ? ` sav-note-${escHtml(status)}` : ''}">${escHtml(text)}</div>`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The header's meta line: model · type · status · tokens · time. */
|
|
174
|
+
export function headerMetaParts(run, { formatModel, formatTokens, formatDuration, statusLabel, now = Date.now() } = {}) {
|
|
175
|
+
const parts = [];
|
|
176
|
+
if (run?.model) parts.push(formatModel ? formatModel(run.model) : run.model);
|
|
177
|
+
parts.push(String(run?.subagentType || 'Subagent'));
|
|
178
|
+
parts.push(statusLabel ? statusLabel(run?.status) : String(run?.status || 'running'));
|
|
179
|
+
if (run?.background) parts.push('background');
|
|
180
|
+
const tokens = formatTokens ? formatTokens(run?.totalTokens) : '';
|
|
181
|
+
if (tokens) parts.push(tokens);
|
|
182
|
+
const running = ['running', 'pending', 'processing'].includes(String(run?.status || 'running'));
|
|
183
|
+
const time = formatDuration ? formatDuration(run?.startedAt, running ? null : (run?.completedAt || run?.updatedAt), now) : '';
|
|
184
|
+
if (time) parts.push(time);
|
|
185
|
+
return parts.filter(Boolean);
|
|
186
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Tells the relay which run the sub-agent viewer shows, so its record
|
|
2
|
+
// arrives live on this page only (`subagent_viewer_watch` / `_unwatch`). The
|
|
3
|
+
// viewer waits for the watch's answer before it reads the record: a batch
|
|
4
|
+
// sent between the read and the join would otherwise be lost with no later
|
|
5
|
+
// hole in `seq` to show it. The relay's rooms may or may not survive a
|
|
6
|
+
// reconnect, so the viewer watches again on every connect.
|
|
7
|
+
|
|
8
|
+
const WATCH_TIMEOUT_MS = 3000;
|
|
9
|
+
|
|
10
|
+
export function createSubagentViewerWatch({ getSocket, timeoutMs = WATCH_TIMEOUT_MS } = {}) {
|
|
11
|
+
let watched = null;
|
|
12
|
+
// The relay did not know the run yet (a panel row can come before its run
|
|
13
|
+
// row): the next status event of that run asks again.
|
|
14
|
+
let refusedNotFound = false;
|
|
15
|
+
|
|
16
|
+
async function watch(conversationId, runId) {
|
|
17
|
+
watched = { conversationId: String(conversationId || ''), runId: String(runId || '') };
|
|
18
|
+
refusedNotFound = false;
|
|
19
|
+
const socket = getSocket?.();
|
|
20
|
+
if (!socket?.connected || !watched.conversationId || !watched.runId) return { ok: false, code: 'offline' };
|
|
21
|
+
let result;
|
|
22
|
+
try {
|
|
23
|
+
result = await socket.timeout(timeoutMs).emitWithAck('subagent_viewer_watch', {
|
|
24
|
+
conversationId: watched.conversationId,
|
|
25
|
+
subagentRunId: watched.runId,
|
|
26
|
+
});
|
|
27
|
+
} catch {
|
|
28
|
+
return { ok: false, code: 'timeout' };
|
|
29
|
+
}
|
|
30
|
+
if (watched?.runId === String(runId || '') && result?.ok !== true && result?.code === 'not-found') refusedNotFound = true;
|
|
31
|
+
return result && typeof result === 'object' ? result : { ok: false, code: 'bad-answer' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function unwatch() {
|
|
35
|
+
if (!watched) return;
|
|
36
|
+
watched = null;
|
|
37
|
+
refusedNotFound = false;
|
|
38
|
+
const socket = getSocket?.();
|
|
39
|
+
if (socket?.connected) socket.emit('subagent_viewer_unwatch', {});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
watch,
|
|
44
|
+
unwatch,
|
|
45
|
+
/** The run the relay should be sending, or null. */
|
|
46
|
+
current: () => (watched ? { ...watched } : null),
|
|
47
|
+
/** True while the relay refused the run on screen as unknown. */
|
|
48
|
+
refused: () => refusedNotFound,
|
|
49
|
+
};
|
|
50
|
+
}
|