@oar-sh/oar 0.9.9 → 0.9.11
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 +561 -1
- package/README.md +162 -909
- package/bin/bat/oar.bat +46 -46
- package/package.json +14 -14
- package/server/README.md +369 -13
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +121 -9
- package/server/claude-worker/claude-session-worker.mjs +9 -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 +79 -12
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +95 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- 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/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +187 -0
- package/server/public/app/api-client.js +312 -16
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +17 -12
- 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 +336 -59
- package/server/public/app/chat-actions-menu-state.mjs +126 -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/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +436 -117
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +13 -4
- package/server/public/app/journal-view.js +930 -45
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +19 -3
- 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/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +93 -9
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +137 -40
- package/server/public/app/store.js +115 -32
- 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 +558 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
- package/server/public/app/transcript-jump-bottom.mjs +210 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +774 -12
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +30 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +698 -114
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +843 -193
- package/server/server-runtime.mjs +559 -160
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +49 -27
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1952 -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 +551 -7
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +695 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -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/status-event-service.mjs +7 -3
- 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/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +160 -6
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
// The sub-agent viewer: one sub-agent's whole record — thoughts, tool calls
|
|
2
|
+
// with their results, reply text, child agents — in the order it happened,
|
|
3
|
+
// live while the agent runs. Opened from a sub-agent row: the composer's
|
|
4
|
+
// background-task panel, a reply bubble, or a child row inside the viewer.
|
|
5
|
+
//
|
|
6
|
+
// The record comes from GET /api/conversation/:id/subagent/:runId (paged by
|
|
7
|
+
// seq) and grows through the `subagent_timeline` socket event, which the
|
|
8
|
+
// relay sends only to pages that watch the run (subagent-viewer-watch.mjs)
|
|
9
|
+
// and drops on a backed-up connection; a missed event shows up as a hole in
|
|
10
|
+
// the seq numbers and is fetched again, and so is the end of a run. Full-screen on
|
|
11
|
+
// phones; Escape, ×, a click outside and the system back close it.
|
|
12
|
+
//
|
|
13
|
+
// A shared view (and the owner's "Preview as shared") opens it read-only: the
|
|
14
|
+
// record comes filtered from the share's sub-agent route, without a socket, so
|
|
15
|
+
// a running agent's record is read again every few seconds; no Stop.
|
|
16
|
+
|
|
17
|
+
import { apiFetch, cancelSubagentRun, loadSharedSubagentRecord } from './api-client.js';
|
|
18
|
+
import { renderMarkdownPreview } from './router.js';
|
|
19
|
+
import { currentConvId, getSubagentRun, IS_SHARED_VIEW, upsertSubagentRun } from './store.js';
|
|
20
|
+
import { createViewerHistory } from './viewer-history.mjs';
|
|
21
|
+
import { getSocket } from './socket-handlers.js';
|
|
22
|
+
import { createSubagentViewerWatch } from './subagent-viewer-watch.mjs';
|
|
23
|
+
import { formatTaskModelLabel, formatTaskTokens } from './task-row-format.mjs';
|
|
24
|
+
import { getConversationBackgroundTasks } from './background-tasks-view.mjs';
|
|
25
|
+
import { formatRunDuration, isRunningSubagentStatus, subagentRowHtml, subagentStatusLabel } from './subagent-row.mjs';
|
|
26
|
+
import {
|
|
27
|
+
createTimelineState,
|
|
28
|
+
headerMetaParts,
|
|
29
|
+
mergeTimelineEntries,
|
|
30
|
+
orderedTimelineEntries,
|
|
31
|
+
timelineEntryHtml,
|
|
32
|
+
} from './subagent-timeline-view.mjs';
|
|
33
|
+
|
|
34
|
+
const PAGE_LIMIT = 500;
|
|
35
|
+
const MAX_PAGES = 10;
|
|
36
|
+
const STOP_PENDING_MS = 15_000;
|
|
37
|
+
const SHARED_POLL_MS = 3_000;
|
|
38
|
+
|
|
39
|
+
const viewer = {
|
|
40
|
+
open: false,
|
|
41
|
+
conversationId: '',
|
|
42
|
+
run: null,
|
|
43
|
+
state: null,
|
|
44
|
+
children: new Map(),
|
|
45
|
+
stack: [],
|
|
46
|
+
fetchToken: 0,
|
|
47
|
+
error: '',
|
|
48
|
+
loading: false,
|
|
49
|
+
stopping: false,
|
|
50
|
+
stopTimer: null,
|
|
51
|
+
tickTimer: null,
|
|
52
|
+
sharedPollTimer: null,
|
|
53
|
+
openFolds: new Set(),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
let viewerHistory = null;
|
|
57
|
+
let initialized = false;
|
|
58
|
+
const viewerWatch = createSubagentViewerWatch({ getSocket });
|
|
59
|
+
|
|
60
|
+
function elements() {
|
|
61
|
+
return {
|
|
62
|
+
modal: document.getElementById('subagent-viewer-modal'),
|
|
63
|
+
title: document.getElementById('subagent-viewer-title'),
|
|
64
|
+
meta: document.getElementById('subagent-viewer-meta'),
|
|
65
|
+
stop: document.getElementById('subagent-viewer-stop'),
|
|
66
|
+
parent: document.getElementById('subagent-viewer-parent'),
|
|
67
|
+
body: document.getElementById('subagent-viewer-body'),
|
|
68
|
+
timeline: document.getElementById('subagent-viewer-timeline'),
|
|
69
|
+
status: document.getElementById('subagent-viewer-status'),
|
|
70
|
+
jump: document.getElementById('subagent-viewer-jump'),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function escHtml(value) {
|
|
75
|
+
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
|
76
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
77
|
+
}[ch]));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderMarkdown(text) {
|
|
81
|
+
try {
|
|
82
|
+
return renderMarkdownPreview(String(text || ''), false);
|
|
83
|
+
} catch {
|
|
84
|
+
return `<p>${escHtml(text)}</p>`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function runIsRunning(run) {
|
|
89
|
+
return isRunningSubagentStatus(run?.status);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The live task a run is (the panel's row): its id is what Stop needs.
|
|
93
|
+
function liveTaskFor(run) {
|
|
94
|
+
const runId = String(run?.subagentRunId || '');
|
|
95
|
+
return getConversationBackgroundTasks(viewer.conversationId).find((task) => String(task?.toolUseId || '') === runId) || null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isAtBottom(body) {
|
|
99
|
+
return body.scrollTop + body.clientHeight >= body.scrollHeight - 48;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Header ──────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function renderHeader() {
|
|
105
|
+
const { title, meta, stop, parent } = elements();
|
|
106
|
+
const run = viewer.run;
|
|
107
|
+
if (title) title.textContent = run?.displayName || 'Sub-agent';
|
|
108
|
+
if (meta) {
|
|
109
|
+
const parts = run ? headerMetaParts(run, {
|
|
110
|
+
formatModel: formatTaskModelLabel,
|
|
111
|
+
formatTokens: formatTaskTokens,
|
|
112
|
+
formatDuration: formatRunDuration,
|
|
113
|
+
statusLabel: subagentStatusLabel,
|
|
114
|
+
}) : [];
|
|
115
|
+
if (run?.truncated) parts.push('record cut at its limit');
|
|
116
|
+
if (run?.legacy) parts.push('recorded before the full record existed');
|
|
117
|
+
meta.textContent = parts.join(' · ');
|
|
118
|
+
}
|
|
119
|
+
if (stop) {
|
|
120
|
+
const stoppable = !IS_SHARED_VIEW && runIsRunning(run) && (run?.stoppable === true || !!liveTaskFor(run));
|
|
121
|
+
stop.hidden = !stoppable;
|
|
122
|
+
stop.disabled = viewer.stopping;
|
|
123
|
+
stop.textContent = viewer.stopping ? 'Stopping…' : 'Stop';
|
|
124
|
+
}
|
|
125
|
+
if (parent) {
|
|
126
|
+
const top = viewer.stack[viewer.stack.length - 1];
|
|
127
|
+
parent.hidden = !top;
|
|
128
|
+
parent.textContent = top ? `‹ ${top.title || 'Parent agent'}` : '';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderStatus() {
|
|
133
|
+
const { status } = elements();
|
|
134
|
+
if (!status) return;
|
|
135
|
+
const count = viewer.state ? viewer.state.entries.size : 0;
|
|
136
|
+
if (viewer.error) status.textContent = viewer.error;
|
|
137
|
+
else if (viewer.loading && !count) status.textContent = 'Loading…';
|
|
138
|
+
else if (!count) status.textContent = runIsRunning(viewer.run) ? 'Nothing recorded yet. Entries appear as the agent works.' : 'Nothing was recorded for this agent.';
|
|
139
|
+
else status.textContent = '';
|
|
140
|
+
status.hidden = !status.textContent;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Timeline ────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
function entryElement(timeline, seq) {
|
|
146
|
+
return timeline.querySelector(`.sav-entry[data-seq="${seq}"]`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function buildEntryNode(entry) {
|
|
150
|
+
const holder = document.createElement('div');
|
|
151
|
+
holder.innerHTML = timelineEntryHtml(entry, {
|
|
152
|
+
renderMarkdown,
|
|
153
|
+
children: viewer.children,
|
|
154
|
+
openDetails: viewer.openFolds.has(entry.seq),
|
|
155
|
+
clickable: true,
|
|
156
|
+
});
|
|
157
|
+
return holder.firstElementChild;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Re-render the given seqs in place (new ones go where their seq says). */
|
|
161
|
+
function renderEntries(seqs) {
|
|
162
|
+
const { timeline, body, jump } = elements();
|
|
163
|
+
if (!timeline || !body || !viewer.state || !seqs.length) {
|
|
164
|
+
renderStatus();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const following = isAtBottom(body);
|
|
168
|
+
let appended = false;
|
|
169
|
+
for (const seq of [...new Set(seqs)].sort((a, b) => a - b)) {
|
|
170
|
+
const entry = viewer.state.entries.get(seq);
|
|
171
|
+
if (!entry) continue;
|
|
172
|
+
const node = buildEntryNode(entry);
|
|
173
|
+
if (!node) continue;
|
|
174
|
+
const existing = entryElement(timeline, seq);
|
|
175
|
+
if (existing) {
|
|
176
|
+
existing.replaceWith(node);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// In seq order: usually the end, but a refetch can fill a hole.
|
|
180
|
+
let before = null;
|
|
181
|
+
for (const child of timeline.children) {
|
|
182
|
+
if (Number(child.dataset.seq) > seq) { before = child; break; }
|
|
183
|
+
}
|
|
184
|
+
timeline.insertBefore(node, before);
|
|
185
|
+
if (!before) appended = true;
|
|
186
|
+
}
|
|
187
|
+
renderStatus();
|
|
188
|
+
if (following) {
|
|
189
|
+
body.scrollTop = body.scrollHeight;
|
|
190
|
+
if (jump) jump.hidden = true;
|
|
191
|
+
} else if (appended && jump) {
|
|
192
|
+
jump.hidden = false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function renderAllEntries() {
|
|
197
|
+
const { timeline } = elements();
|
|
198
|
+
if (!timeline) return;
|
|
199
|
+
timeline.innerHTML = '';
|
|
200
|
+
if (!viewer.state) return;
|
|
201
|
+
const seqs = orderedTimelineEntries(viewer.state).map((entry) => entry.seq);
|
|
202
|
+
renderEntries(seqs);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function rerenderChildEntries(childRunId) {
|
|
206
|
+
if (!viewer.state) return;
|
|
207
|
+
const seqs = orderedTimelineEntries(viewer.state)
|
|
208
|
+
.filter((entry) => entry.kind === 'child' && String(entry.toolUseId || '') === childRunId)
|
|
209
|
+
.map((entry) => entry.seq);
|
|
210
|
+
if (seqs.length) renderEntries(seqs);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Loading ─────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function applyRunHeader(header) {
|
|
216
|
+
if (!header || !viewer.state || String(header.subagentRunId || '') !== viewer.state.runId) return;
|
|
217
|
+
viewer.run = { ...(viewer.run || {}), ...header };
|
|
218
|
+
if (viewer.stopping && !runIsRunning(viewer.run)) {
|
|
219
|
+
viewer.stopping = false;
|
|
220
|
+
clearTimeout(viewer.stopTimer);
|
|
221
|
+
}
|
|
222
|
+
renderHeader();
|
|
223
|
+
syncTicker();
|
|
224
|
+
syncSharedPoll();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Without a socket a running record is read again while it is on screen.
|
|
228
|
+
function syncSharedPoll() {
|
|
229
|
+
const want = IS_SHARED_VIEW && viewer.open && runIsRunning(viewer.run);
|
|
230
|
+
if (want && !viewer.sharedPollTimer) {
|
|
231
|
+
viewer.sharedPollTimer = setInterval(() => {
|
|
232
|
+
if (!viewer.loading && document.visibilityState !== 'hidden') void fetchRecord();
|
|
233
|
+
}, SHARED_POLL_MS);
|
|
234
|
+
}
|
|
235
|
+
if (!want && viewer.sharedPollTimer) {
|
|
236
|
+
clearInterval(viewer.sharedPollTimer);
|
|
237
|
+
viewer.sharedPollTimer = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Fetch the record from `lastSeq` on (or from the start), page by page.
|
|
243
|
+
* A newer fetch supersedes an older one; entries already shown stay.
|
|
244
|
+
*/
|
|
245
|
+
async function fetchRecord({ fromStart = false } = {}) {
|
|
246
|
+
if (!viewer.open || !viewer.state) return;
|
|
247
|
+
const token = ++viewer.fetchToken;
|
|
248
|
+
const runId = viewer.state.runId;
|
|
249
|
+
const conversationId = viewer.conversationId;
|
|
250
|
+
let after = fromStart ? 0 : viewer.state.lastSeq;
|
|
251
|
+
viewer.loading = true;
|
|
252
|
+
renderStatus();
|
|
253
|
+
try {
|
|
254
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
255
|
+
const data = IS_SHARED_VIEW
|
|
256
|
+
? await loadSharedSubagentRecord(runId, { after, limit: PAGE_LIMIT })
|
|
257
|
+
: await apiFetch(`/api/conversation/${encodeURIComponent(conversationId)}/subagent/${encodeURIComponent(runId)}?after=${after}&limit=${PAGE_LIMIT}`);
|
|
258
|
+
if (token !== viewer.fetchToken || !viewer.open || viewer.state?.runId !== runId) return;
|
|
259
|
+
if (!data) {
|
|
260
|
+
viewer.error = viewer.state.entries.size ? '' : 'This agent\'s record could not be loaded.';
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
viewer.error = '';
|
|
264
|
+
for (const child of Array.isArray(data.children) ? data.children : []) {
|
|
265
|
+
if (child?.subagentRunId) viewer.children.set(String(child.subagentRunId), { ...(viewer.children.get(String(child.subagentRunId)) || {}), ...child });
|
|
266
|
+
}
|
|
267
|
+
applyRunHeader(data.run);
|
|
268
|
+
const { changed } = mergeTimelineEntries(viewer.state, data.entries);
|
|
269
|
+
renderEntries(changed);
|
|
270
|
+
if (data.complete !== false) break;
|
|
271
|
+
after = Number(data.nextAfter) || viewer.state.lastSeq;
|
|
272
|
+
}
|
|
273
|
+
} finally {
|
|
274
|
+
if (token === viewer.fetchToken) {
|
|
275
|
+
viewer.loading = false;
|
|
276
|
+
renderStatus();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function loadRun(runId) {
|
|
282
|
+
const id = String(runId || '').trim();
|
|
283
|
+
if (!id) return;
|
|
284
|
+
viewer.state = createTimelineState(id);
|
|
285
|
+
viewer.children = new Map();
|
|
286
|
+
viewer.openFolds = new Set();
|
|
287
|
+
viewer.error = '';
|
|
288
|
+
viewer.stopping = false;
|
|
289
|
+
clearTimeout(viewer.stopTimer);
|
|
290
|
+
const known = getSubagentRun(id);
|
|
291
|
+
viewer.run = known ? { ...known } : { subagentRunId: id, displayName: 'Sub-agent', status: 'running' };
|
|
292
|
+
const { timeline, jump, body } = elements();
|
|
293
|
+
if (timeline) timeline.innerHTML = '';
|
|
294
|
+
if (jump) jump.hidden = true;
|
|
295
|
+
renderHeader();
|
|
296
|
+
renderStatus();
|
|
297
|
+
// Joined before the read, so nothing sent in between is lost.
|
|
298
|
+
if (!IS_SHARED_VIEW) await viewerWatch.watch(viewer.conversationId, id);
|
|
299
|
+
if (!viewer.open || viewer.state?.runId !== id) return;
|
|
300
|
+
await fetchRecord({ fromStart: true });
|
|
301
|
+
// A finished record reads from the top; a running one follows its end.
|
|
302
|
+
if (body && viewer.state?.runId === id) body.scrollTop = runIsRunning(viewer.run) ? body.scrollHeight : 0;
|
|
303
|
+
syncTicker();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ─── Open / close ────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
export async function openSubagentViewer({ conversationId = currentConvId, runId } = {}) {
|
|
309
|
+
const convId = String(conversationId || '').trim();
|
|
310
|
+
const id = String(runId || '').trim();
|
|
311
|
+
if (!convId || !id) return;
|
|
312
|
+
const { modal } = elements();
|
|
313
|
+
if (!modal) return;
|
|
314
|
+
viewer.conversationId = convId;
|
|
315
|
+
viewer.stack = [];
|
|
316
|
+
if (!viewer.open) {
|
|
317
|
+
viewer.open = true;
|
|
318
|
+
modal.classList.add('visible');
|
|
319
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
320
|
+
document.body.classList.add('subagent-viewer-open');
|
|
321
|
+
viewerHistory?.opened();
|
|
322
|
+
}
|
|
323
|
+
await loadRun(id);
|
|
324
|
+
elements().modal?.querySelector('.subagent-viewer-close')?.focus?.({ preventScroll: true });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function closeSubagentViewer({ fromHistory = false } = {}) {
|
|
328
|
+
if (!viewer.open) return;
|
|
329
|
+
viewer.open = false;
|
|
330
|
+
viewer.fetchToken += 1;
|
|
331
|
+
const { modal, timeline } = elements();
|
|
332
|
+
modal?.classList.remove('visible');
|
|
333
|
+
modal?.setAttribute('aria-hidden', 'true');
|
|
334
|
+
document.body.classList.remove('subagent-viewer-open');
|
|
335
|
+
if (timeline) timeline.innerHTML = '';
|
|
336
|
+
viewerWatch.unwatch();
|
|
337
|
+
viewer.state = null;
|
|
338
|
+
viewer.run = null;
|
|
339
|
+
viewer.stack = [];
|
|
340
|
+
viewer.children = new Map();
|
|
341
|
+
clearTimeout(viewer.stopTimer);
|
|
342
|
+
syncTicker();
|
|
343
|
+
syncSharedPoll();
|
|
344
|
+
if (!fromHistory) viewerHistory?.closed();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function isSubagentViewerOpen() {
|
|
348
|
+
return viewer.open;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function openChild(childRunId) {
|
|
352
|
+
if (!viewer.state || childRunId === viewer.state.runId) return;
|
|
353
|
+
viewer.stack.push({ runId: viewer.state.runId, title: viewer.run?.displayName || '' });
|
|
354
|
+
await loadRun(childRunId);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function openParent() {
|
|
358
|
+
const top = viewer.stack.pop();
|
|
359
|
+
if (!top) return;
|
|
360
|
+
await loadRun(top.runId);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function stopRun() {
|
|
364
|
+
const run = viewer.run;
|
|
365
|
+
if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
|
|
366
|
+
viewer.stopping = true;
|
|
367
|
+
renderHeader();
|
|
368
|
+
const task = liveTaskFor(run);
|
|
369
|
+
const taskId = String(task?.taskId || run.taskId || '').trim();
|
|
370
|
+
const convId = viewer.conversationId;
|
|
371
|
+
const result = (run.background || task) && taskId
|
|
372
|
+
? await apiFetch(`/api/conversation/${encodeURIComponent(convId)}/background-task/${encodeURIComponent(taskId)}/stop`, { method: 'POST', body: '{}' })
|
|
373
|
+
: await cancelSubagentRun(convId, run.subagentRunId, { parentMessageId: run.messageId || undefined });
|
|
374
|
+
if (!result) {
|
|
375
|
+
viewer.stopping = false;
|
|
376
|
+
renderHeader();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
// The run's end arrives as a status event; until then the button waits.
|
|
380
|
+
clearTimeout(viewer.stopTimer);
|
|
381
|
+
viewer.stopTimer = setTimeout(() => {
|
|
382
|
+
viewer.stopping = false;
|
|
383
|
+
renderHeader();
|
|
384
|
+
}, STOP_PENDING_MS);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ─── Live updates ────────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
/** `subagent_timeline`: entries of the run on screen. */
|
|
390
|
+
export function handleSubagentTimelineEvent(payload) {
|
|
391
|
+
if (!viewer.open || !viewer.state) return;
|
|
392
|
+
if (String(payload?.subagentRunId || '') !== viewer.state.runId) return;
|
|
393
|
+
if (String(payload?.conversationId || '') && String(payload.conversationId) !== viewer.conversationId) return;
|
|
394
|
+
const { changed, gap } = mergeTimelineEntries(viewer.state, payload.entries);
|
|
395
|
+
renderEntries(changed);
|
|
396
|
+
if (payload.truncated && viewer.run && !viewer.run.truncated) {
|
|
397
|
+
viewer.run = { ...viewer.run, truncated: true };
|
|
398
|
+
renderHeader();
|
|
399
|
+
}
|
|
400
|
+
if (gap) void fetchRecord();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** `subagent_status`: the run on screen, or one of its children, changed. */
|
|
404
|
+
export function handleSubagentStatusEventForViewer(payload) {
|
|
405
|
+
if (!viewer.open || !viewer.state) return;
|
|
406
|
+
const runId = String(payload?.subagentRunId || '');
|
|
407
|
+
if (!runId) return;
|
|
408
|
+
if (runId === viewer.state.runId) {
|
|
409
|
+
const wasRunning = runIsRunning(viewer.run);
|
|
410
|
+
applyRunHeader(payload);
|
|
411
|
+
// The relay did not know this run when the viewer asked for it.
|
|
412
|
+
if (viewerWatch.refused()) void viewerWatch.watch(viewer.conversationId, runId);
|
|
413
|
+
// The last batches of a run can be dropped with nothing after them to
|
|
414
|
+
// show the hole: its end reads the record once more.
|
|
415
|
+
if (wasRunning && !runIsRunning(viewer.run)) void fetchRecord();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (viewer.children.has(runId) || String(payload?.parentSubagentId || '') === viewer.state.runId) {
|
|
419
|
+
viewer.children.set(runId, { ...(viewer.children.get(runId) || {}), ...payload });
|
|
420
|
+
rerenderChildEntries(runId);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The socket came back: watch the run again, then fetch what was missed. */
|
|
425
|
+
export async function handleSubagentViewerReconnect() {
|
|
426
|
+
if (!viewer.open || !viewer.state) return;
|
|
427
|
+
const runId = viewer.state.runId;
|
|
428
|
+
await viewerWatch.watch(viewer.conversationId, runId);
|
|
429
|
+
if (viewer.open && viewer.state?.runId === runId) void fetchRecord();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ─── Rows elsewhere on the page ──────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* A run's status changed: every row showing it (reply bubbles, the viewer)
|
|
436
|
+
* is drawn again from the store, which the status event already updated.
|
|
437
|
+
*/
|
|
438
|
+
export function refreshSubagentRows(runId) {
|
|
439
|
+
const id = String(runId || '').trim();
|
|
440
|
+
if (!id) return;
|
|
441
|
+
const run = getSubagentRun(id);
|
|
442
|
+
if (!run) return;
|
|
443
|
+
for (const row of document.querySelectorAll(`.subagent-row[data-subagent-row="${CSS.escape(id)}"]`)) {
|
|
444
|
+
if (row.closest('#subagent-viewer-modal')) continue;
|
|
445
|
+
const context = row.classList.contains('subagent-row-timeline') ? 'timeline' : 'bubble';
|
|
446
|
+
const clickable = !!row.querySelector('[data-subagent-open]');
|
|
447
|
+
const holder = document.createElement('div');
|
|
448
|
+
holder.innerHTML = subagentRowHtml(run, { context, clickable });
|
|
449
|
+
if (holder.firstElementChild) row.replaceWith(holder.firstElementChild);
|
|
450
|
+
}
|
|
451
|
+
syncTicker();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// One timer for every running row's clock (bubbles and the viewer), only
|
|
455
|
+
// while one is on screen.
|
|
456
|
+
function tick() {
|
|
457
|
+
const now = Date.now();
|
|
458
|
+
let running = 0;
|
|
459
|
+
for (const node of document.querySelectorAll('.subagent-row-time[data-started-at]')) {
|
|
460
|
+
running += 1;
|
|
461
|
+
node.textContent = formatRunDuration(node.getAttribute('data-started-at'), null, now);
|
|
462
|
+
}
|
|
463
|
+
if (viewer.open && runIsRunning(viewer.run)) {
|
|
464
|
+
running += 1;
|
|
465
|
+
renderHeader();
|
|
466
|
+
}
|
|
467
|
+
if (!running) syncTicker(false);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function syncTicker(wanted = null) {
|
|
471
|
+
const want = wanted ?? (
|
|
472
|
+
(viewer.open && runIsRunning(viewer.run))
|
|
473
|
+
|| !!document.querySelector('.subagent-row-time[data-started-at]')
|
|
474
|
+
);
|
|
475
|
+
if (want && !viewer.tickTimer) viewer.tickTimer = setInterval(tick, 1000);
|
|
476
|
+
if (!want && viewer.tickTimer) {
|
|
477
|
+
clearInterval(viewer.tickTimer);
|
|
478
|
+
viewer.tickTimer = null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function syncSubagentRowClock() {
|
|
483
|
+
syncTicker();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ─── Wiring ──────────────────────────────────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
export function initSubagentViewer() {
|
|
489
|
+
if (initialized) return;
|
|
490
|
+
initialized = true;
|
|
491
|
+
viewerHistory = createViewerHistory({
|
|
492
|
+
history: window.history,
|
|
493
|
+
mark: 'oarSubagentViewer',
|
|
494
|
+
onBack: () => closeSubagentViewer({ fromHistory: true }),
|
|
495
|
+
});
|
|
496
|
+
window.addEventListener('popstate', () => viewerHistory.onPopState());
|
|
497
|
+
|
|
498
|
+
// Any sub-agent title on the page opens the viewer; one inside the viewer
|
|
499
|
+
// (a child row) opens that child in place.
|
|
500
|
+
document.addEventListener('click', (event) => {
|
|
501
|
+
const trigger = event.target?.closest?.('[data-subagent-open]');
|
|
502
|
+
if (!trigger) return;
|
|
503
|
+
event.preventDefault();
|
|
504
|
+
event.stopPropagation();
|
|
505
|
+
const runId = trigger.getAttribute('data-subagent-open');
|
|
506
|
+
if (trigger.closest('#subagent-viewer-modal')) void openChild(runId);
|
|
507
|
+
else void openSubagentViewer({ runId });
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const { modal, stop, parent, body, jump, timeline } = elements();
|
|
511
|
+
modal?.addEventListener('click', (event) => {
|
|
512
|
+
if (event.target === modal) closeSubagentViewer();
|
|
513
|
+
});
|
|
514
|
+
modal?.querySelector('.subagent-viewer-close')?.addEventListener('click', () => closeSubagentViewer());
|
|
515
|
+
stop?.addEventListener('click', () => void stopRun());
|
|
516
|
+
parent?.addEventListener('click', () => void openParent());
|
|
517
|
+
jump?.addEventListener('click', () => {
|
|
518
|
+
if (body) body.scrollTop = body.scrollHeight;
|
|
519
|
+
jump.hidden = true;
|
|
520
|
+
});
|
|
521
|
+
body?.addEventListener('scroll', () => {
|
|
522
|
+
if (jump && !jump.hidden && isAtBottom(body)) jump.hidden = true;
|
|
523
|
+
}, { passive: true });
|
|
524
|
+
// A fold the reader opened stays open while the entry keeps changing.
|
|
525
|
+
timeline?.addEventListener('click', (event) => {
|
|
526
|
+
const summary = event.target?.closest?.('summary');
|
|
527
|
+
const entry = summary?.closest?.('.sav-entry');
|
|
528
|
+
if (!entry) return;
|
|
529
|
+
const seq = Number(entry.dataset.seq);
|
|
530
|
+
setTimeout(() => {
|
|
531
|
+
const details = summary.parentElement;
|
|
532
|
+
if (details?.open) viewer.openFolds.add(seq);
|
|
533
|
+
else viewer.openFolds.delete(seq);
|
|
534
|
+
}, 0);
|
|
535
|
+
});
|
|
536
|
+
document.addEventListener('keydown', (event) => {
|
|
537
|
+
if (event.key !== 'Escape' || !viewer.open) return;
|
|
538
|
+
// The file viewer can sit on top (a path in the record); it closes first.
|
|
539
|
+
if (document.getElementById('file-preview-modal')?.classList.contains('visible')) return;
|
|
540
|
+
event.preventDefault();
|
|
541
|
+
closeSubagentViewer();
|
|
542
|
+
});
|
|
543
|
+
syncTicker();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// For a run the store has not seen yet (a reply bubble built from the
|
|
547
|
+
// conversation payload): the row's header goes into the store, so status
|
|
548
|
+
// events merge onto it.
|
|
549
|
+
export function rememberSubagentRuns(runs) {
|
|
550
|
+
for (const run of Array.isArray(runs) ? runs : []) {
|
|
551
|
+
if (!run?.subagentRunId) continue;
|
|
552
|
+
const known = getSubagentRun(run.subagentRunId);
|
|
553
|
+
// The payload is the relay's word: it fills a lane-made entry, and never
|
|
554
|
+
// undoes a newer status event.
|
|
555
|
+
if (known?.statusReported && String(known.updatedAt || '') > String(run.updatedAt || '')) continue;
|
|
556
|
+
upsertSubagentRun({ ...run, timestamp: run.updatedAt || run.startedAt });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Formatting shared by the background-task panel rows and the sub-agent rows
|
|
2
|
+
// (reply bubble, viewer). Pure: no DOM, no store.
|
|
3
|
+
|
|
4
|
+
import { humanizeModelLabel } from './model-selector-options.mjs';
|
|
5
|
+
|
|
6
|
+
// "claude-opus-5-5" → "Opus 5.5". A bare alias ("opus") is what a spawn pin
|
|
7
|
+
// carries until the subagent's first answer names the real model.
|
|
8
|
+
export function formatTaskModelLabel(model) {
|
|
9
|
+
const value = String(model || '').trim();
|
|
10
|
+
if (!value) return '';
|
|
11
|
+
if (/^claude-/i.test(value)) return humanizeModelLabel(value);
|
|
12
|
+
if (/^[a-z]+(\[[^\]]+\])?$/i.test(value)) return value.charAt(0).toUpperCase() + value.slice(1);
|
|
13
|
+
return humanizeModelLabel(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Compact token counts: "812 tok", "62.2k tok", "1.2M tok". Exported for the
|
|
17
|
+
// transcript's finished-workflow card, which shares the panel's formatting.
|
|
18
|
+
export function formatTaskTokens(totalTokens) {
|
|
19
|
+
const tokens = Number(totalTokens);
|
|
20
|
+
if (!Number.isFinite(tokens) || tokens <= 0) return '';
|
|
21
|
+
if (tokens < 1000) return `${tokens} tok`;
|
|
22
|
+
if (tokens < 1_000_000) {
|
|
23
|
+
const thousands = tokens / 1000;
|
|
24
|
+
return `${thousands >= 100 ? Math.round(thousands) : thousands.toFixed(1)}k tok`;
|
|
25
|
+
}
|
|
26
|
+
const millions = tokens / 1_000_000;
|
|
27
|
+
return `${millions >= 100 ? Math.round(millions) : millions.toFixed(1)}M tok`;
|
|
28
|
+
}
|
|
29
|
+
|