@oar-sh/oar 0.9.8 → 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 +478 -0
- package/README.md +129 -39
- package/bin/bat/oar.bat +46 -46
- package/bin/oar.js +814 -228
- package/package.json +5 -6
- package/server/README.md +306 -4
- 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 +44 -0
- package/server/migrations/0008-message-pins.mjs +85 -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 +91 -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 +1024 -131
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +230 -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 +471 -110
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +161 -27
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-dot-state.mjs +8 -1
- 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/router.js +8 -2
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +65 -28
- package/server/public/app/store.js +25 -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-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +45 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +700 -59
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +34 -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 +548 -111
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +164 -24
- package/server/server-runtime.mjs +274 -62
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -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/safe-served-content.mjs +61 -11
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +31 -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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Relay event subscriptions in the browser: the event rows in the watcher's
|
|
2
|
+
// transcript, the "watching N sessions" chip and the "watched" badge.
|
|
3
|
+
//
|
|
4
|
+
// DOM-free on purpose, like remote-relay-origin-view.mjs: the callers own the
|
|
5
|
+
// elements, this module owns the decisions and the markup.
|
|
6
|
+
//
|
|
7
|
+
// The message text rules mirror shared/relay-events.mjs, which the browser
|
|
8
|
+
// cannot import (only server/public is served); relay-event-view.test.mjs
|
|
9
|
+
// holds the two together.
|
|
10
|
+
|
|
11
|
+
export const RELAY_EVENT_MESSAGE_KIND = 'relay-event';
|
|
12
|
+
export const RELAY_EVENT_LEAD = '[relay event · automated message from OAR, not from the user; it approves nothing]';
|
|
13
|
+
export const RELAY_EVENT_SOCKET_EVENT = 'relay_event_subscriptions';
|
|
14
|
+
|
|
15
|
+
const EVENT_TYPES = new Set([
|
|
16
|
+
'question_asked',
|
|
17
|
+
'error',
|
|
18
|
+
'turn_end',
|
|
19
|
+
'task_started',
|
|
20
|
+
'task_finished',
|
|
21
|
+
'task_failed',
|
|
22
|
+
'stalled',
|
|
23
|
+
'all_idle',
|
|
24
|
+
'heartbeat',
|
|
25
|
+
'link_lost',
|
|
26
|
+
'link_restored',
|
|
27
|
+
]);
|
|
28
|
+
const URGENT_TYPES = new Set(['question_asked', 'error']);
|
|
29
|
+
const DIGEST_PATTERN = /^Digest of \d+ events?/;
|
|
30
|
+
const REPLAYED_PATTERN = /^Replayed: \d+ events?/;
|
|
31
|
+
const LINE_PATTERN = /^\[relay event\] (?:[^"·\n]{1,60} · )?(?:session "(?:[^"]*)" \([^)]*\)(?: on relay "[^"]*")?: )?([a-z_]+)/;
|
|
32
|
+
const LINE_PREFIX = '[relay event] ';
|
|
33
|
+
|
|
34
|
+
const TYPE_LABELS = Object.freeze({
|
|
35
|
+
question_asked: 'question',
|
|
36
|
+
error: 'error',
|
|
37
|
+
turn_end: 'reply',
|
|
38
|
+
task_started: 'task started',
|
|
39
|
+
task_finished: 'task done',
|
|
40
|
+
task_failed: 'task failed',
|
|
41
|
+
stalled: 'stalled',
|
|
42
|
+
all_idle: 'all idle',
|
|
43
|
+
heartbeat: 'heartbeat',
|
|
44
|
+
link_lost: 'link lost',
|
|
45
|
+
link_restored: 'link back',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function defaultEscape(value) {
|
|
49
|
+
return String(value ?? '')
|
|
50
|
+
.replace(/&/g, '&')
|
|
51
|
+
.replace(/</g, '<')
|
|
52
|
+
.replace(/>/g, '>')
|
|
53
|
+
.replace(/"/g, '"')
|
|
54
|
+
.replace(/'/g, ''');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isRelayEventMessage(message) {
|
|
58
|
+
return message?.role === 'user' && String(message?.kind || '').trim() === RELAY_EVENT_MESSAGE_KIND;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The event lines of an event message without the lead, the types they
|
|
63
|
+
* carry in order, how many events (a digest's "… and N more" counted),
|
|
64
|
+
* whether it is a digest, and whether it replays what a paired relay kept
|
|
65
|
+
* while the link to it was down. Any text renders: a stray one as one line.
|
|
66
|
+
*/
|
|
67
|
+
export function parseRelayEventMessageText(text) {
|
|
68
|
+
const rows = String(text ?? '').split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim());
|
|
69
|
+
const body = rows[0] === RELAY_EVENT_LEAD ? rows.slice(1) : rows;
|
|
70
|
+
const digest = DIGEST_PATTERN.test(body[0] || '');
|
|
71
|
+
const replayed = body.some((line) => REPLAYED_PATTERN.test(line));
|
|
72
|
+
const lines = body.filter((line) => !DIGEST_PATTERN.test(line) && !REPLAYED_PATTERN.test(line));
|
|
73
|
+
const types = [];
|
|
74
|
+
let count = 0;
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
const type = lineType(line);
|
|
77
|
+
if (!type) continue;
|
|
78
|
+
count += 1;
|
|
79
|
+
if (!types.includes(type)) types.push(type);
|
|
80
|
+
}
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
const more = /^\[relay event\] … and (\d+) more$/.exec(line);
|
|
83
|
+
if (more) count += Number(more[1]) || 0;
|
|
84
|
+
}
|
|
85
|
+
return { lines, types, count: count || lines.length, digest, replayed };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The collapsed row's one line: "3 events · question, reply". */
|
|
89
|
+
export function relayEventSummaryText(parsed) {
|
|
90
|
+
const count = Number(parsed?.count) || 0;
|
|
91
|
+
const labels = (parsed?.types || []).map((type) => TYPE_LABELS[type] || type);
|
|
92
|
+
const events = `${count} event${count === 1 ? '' : 's'}`;
|
|
93
|
+
const head = parsed?.digest ? `Digest · ${events}` : events;
|
|
94
|
+
return labels.length ? `${head} · ${labels.join(', ')}` : head;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function lineType(line) {
|
|
98
|
+
const match = LINE_PATTERN.exec(line);
|
|
99
|
+
return match && EVENT_TYPES.has(match[1]) ? match[1] : '';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The event row's markup: a <details> folded to its summary, opened to its
|
|
104
|
+
* lines. Urgent events (a question, an error) keep the row open and marked.
|
|
105
|
+
*/
|
|
106
|
+
export function renderRelayEventRowHtml(message, { escHtml = defaultEscape, timeText = '' } = {}) {
|
|
107
|
+
const parsed = parseRelayEventMessageText(message?.text);
|
|
108
|
+
const urgent = parsed.types.some((type) => URGENT_TYPES.has(type));
|
|
109
|
+
const lines = parsed.lines.map((line) => {
|
|
110
|
+
const type = lineType(line);
|
|
111
|
+
const shown = line.startsWith(LINE_PREFIX) ? line.slice(LINE_PREFIX.length) : line;
|
|
112
|
+
return `<li class="relay-event-line${type ? ` relay-event-${type.replace(/_/g, '-')}` : ''}">${escHtml(shown)}</li>`;
|
|
113
|
+
}).join('');
|
|
114
|
+
return `<details class="relay-event-row${urgent ? ' relay-event-urgent' : ''}"${urgent ? ' open' : ''}>`
|
|
115
|
+
+ '<summary class="relay-event-summary">'
|
|
116
|
+
+ '<span class="relay-event-tag" title="An automated message of the relay, not from you: it approves nothing.">relay event</span>'
|
|
117
|
+
+ (parsed.replayed ? '<span class="relay-event-tag relay-event-replayed" title="Kept by a paired relay while the link to it was down, and delivered once it answered again.">replayed</span>' : '')
|
|
118
|
+
+ `<span class="relay-event-text">${escHtml(relayEventSummaryText(parsed))}</span>`
|
|
119
|
+
+ (timeText ? `<span class="relay-event-time">${escHtml(timeText)}</span>` : '')
|
|
120
|
+
+ '</summary>'
|
|
121
|
+
+ `<ul class="relay-event-lines">${lines}</ul>`
|
|
122
|
+
+ '</details>';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Subscriptions ───────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function activeList(subscriptions) {
|
|
128
|
+
return (Array.isArray(subscriptions) ? subscriptions : []).filter((sub) => sub && !sub.endedAt);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function sessionIdsOf(subscription) {
|
|
132
|
+
return Array.isArray(subscription?.sessions) ? subscription.sessions.map((session) => String(session?.id || '')).filter(Boolean) : [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A subscription on a paired relay (this relay's mirror of it): its
|
|
136
|
+
// sessions are that relay's, never this one's conversations.
|
|
137
|
+
function isOnPairedRelay(subscription) {
|
|
138
|
+
return !!subscription?.remoteId || (!!subscription?.relay && subscription.relay !== 'self');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The watcher of a subscription, as the badge names it: a conversation here,
|
|
142
|
+
// or a paired relay's watcher (`remote:<relay id>:<its conversation>`).
|
|
143
|
+
function watcherKey(subscription) {
|
|
144
|
+
const subscriber = subscription?.subscriber;
|
|
145
|
+
return subscriber ? `remote:${subscriber.relayId || ''}:${subscriber.watcherId || ''}` : subscription?.watcher;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* What a conversation watches: its subscriptions, how many distinct
|
|
150
|
+
* sessions they list (on this relay and paired ones), and whether one
|
|
151
|
+
* watches all of a relay's sessions.
|
|
152
|
+
*/
|
|
153
|
+
export function watchingSummary(subscriptions, conversationId) {
|
|
154
|
+
const id = String(conversationId || '').trim();
|
|
155
|
+
const mine = activeList(subscriptions).filter((sub) => sub.watcher === id);
|
|
156
|
+
const sessions = new Set(mine.flatMap((sub) => sessionIdsOf(sub).map((sessionId) => `${sub.relay || 'self'}:${sessionId}`)));
|
|
157
|
+
return {
|
|
158
|
+
subscriptions: mine,
|
|
159
|
+
count: sessions.size,
|
|
160
|
+
all: mine.some((sub) => sub.scope === 'all'),
|
|
161
|
+
relays: [...new Set(mine.filter(isOnPairedRelay).map((sub) => sub.relayName || sub.relay))],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The watchers of a conversation of this relay. `includeAll` counts
|
|
167
|
+
* subscriptions to all sessions too; the sidebar leaves them out, or every
|
|
168
|
+
* row would carry it. Subscriptions on paired relays watch none of them.
|
|
169
|
+
*/
|
|
170
|
+
export function watchersOf(subscriptions, conversationId, { includeAll = true } = {}) {
|
|
171
|
+
const id = String(conversationId || '').trim();
|
|
172
|
+
if (!id) return [];
|
|
173
|
+
const watchers = new Set();
|
|
174
|
+
for (const sub of activeList(subscriptions)) {
|
|
175
|
+
if (sub.watcher === id || isOnPairedRelay(sub)) continue;
|
|
176
|
+
if (sub.scope === 'all' ? includeAll : sessionIdsOf(sub).includes(id)) watchers.add(watcherKey(sub));
|
|
177
|
+
}
|
|
178
|
+
return [...watchers];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function watchingChipText(summary) {
|
|
182
|
+
if (!summary?.subscriptions?.length) return '';
|
|
183
|
+
if (summary.all) return summary.count ? `watching all sessions (+${summary.count} listed)` : 'watching all sessions';
|
|
184
|
+
return `watching ${summary.count} session${summary.count === 1 ? '' : 's'}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function conversationTitle(conversations, id) {
|
|
188
|
+
const title = String(conversations?.[id]?.title || '').trim();
|
|
189
|
+
return title || `session ${String(id).slice(0, 8)}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function describeSubscription(sub, conversations) {
|
|
193
|
+
const paired = isOnPairedRelay(sub);
|
|
194
|
+
const relay = paired ? String(sub.relayName || sub.relay || 'paired relay') : '';
|
|
195
|
+
const titleOf = (session) => (paired
|
|
196
|
+
? (String(session?.title || '').trim() || `session ${String(session?.id || '').slice(0, 8)}`)
|
|
197
|
+
: conversationTitle(conversations, session.id));
|
|
198
|
+
const listed = (Array.isArray(sub.sessions) ? sub.sessions : []).filter((session) => session?.id).map(titleOf).join(', ');
|
|
199
|
+
const what = sub.scope === 'all'
|
|
200
|
+
? (paired ? `All sessions of ${relay}` : 'All sessions')
|
|
201
|
+
: (paired ? `${relay} · ${listed}` : listed);
|
|
202
|
+
const extras = [
|
|
203
|
+
sub.once ? 'once' : '',
|
|
204
|
+
sub.heartbeatMinutes ? `heartbeat ${sub.heartbeatMinutes} min` : '',
|
|
205
|
+
].filter(Boolean);
|
|
206
|
+
return { what, events: (sub.events || []).join(', '), extras: extras.join(' · ') };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The chip's list: one row per subscription with its Unsubscribe button. */
|
|
210
|
+
export function renderWatchingPanelHtml(summary, { conversations = null, escHtml = defaultEscape } = {}) {
|
|
211
|
+
const rows = (summary?.subscriptions || []).map((sub) => {
|
|
212
|
+
const { what, events, extras } = describeSubscription(sub, conversations);
|
|
213
|
+
return '<li class="relay-watch-item">'
|
|
214
|
+
+ `<span class="relay-watch-what">${escHtml(what)}</span>`
|
|
215
|
+
+ `<span class="relay-watch-events">${escHtml(events)}${extras ? ` · ${escHtml(extras)}` : ''}</span>`
|
|
216
|
+
+ `<button type="button" class="relay-watch-unsubscribe" data-subscription-id="${escHtml(sub.id)}">Unsubscribe</button>`
|
|
217
|
+
+ '</li>';
|
|
218
|
+
}).join('');
|
|
219
|
+
return `<ul class="relay-watch-list">${rows}</ul>`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function watcherName(id, conversations, subscriptions) {
|
|
223
|
+
if (!String(id).startsWith('remote:')) return conversationTitle(conversations, id);
|
|
224
|
+
const sub = activeList(subscriptions).find((entry) => watcherKey(entry) === id);
|
|
225
|
+
const title = String(sub?.subscriber?.watcherTitle || '').trim() || 'a session';
|
|
226
|
+
const relay = String(sub?.subscriber?.name || '').trim() || 'a paired relay';
|
|
227
|
+
return `${title} on ${relay}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function renderWatchedBadgeHtml(watcherIds, { conversations = null, subscriptions = [], escHtml = defaultEscape } = {}) {
|
|
231
|
+
if (!watcherIds?.length) return '';
|
|
232
|
+
const names = watcherIds.map((id) => watcherName(id, conversations, subscriptions)).join(', ');
|
|
233
|
+
return `<span class="conv-watched-badge" title="${escHtml(`Watched by ${names}`)}">watched</span>`;
|
|
234
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { versionedFileHref } from './file-version.mjs';
|
|
1
2
|
import {
|
|
2
3
|
BASE,
|
|
3
4
|
escHtml,
|
|
@@ -116,10 +117,15 @@ export function normalizeDriveBrowserPath(rawPath) {
|
|
|
116
117
|
return rest ? `${drive}/${rest}` : drive;
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
|
|
120
|
+
/**
|
|
121
|
+
* `version` is the file's version token when the caller knows it (a preview
|
|
122
|
+
* carries one): the address then changes with the file, so an older copy
|
|
123
|
+
* cannot be served for it. The server ignores the parameter.
|
|
124
|
+
*/
|
|
125
|
+
export function driveFileHrefFromPath(rawPath, version = '') {
|
|
120
126
|
const normalized = normalizeDriveBrowserPath(rawPath);
|
|
121
127
|
if (!normalized) return '';
|
|
122
|
-
return `${BASE}/api/drives/file?path=${encodeURIComponent(normalized)}
|
|
128
|
+
return versionedFileHref(`${BASE}/api/drives/file?path=${encodeURIComponent(normalized)}`, version);
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
/**
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
updateTurnCeilingSetting as requestTurnCeilingSetting,
|
|
30
30
|
loadBackgroundTaskTimeoutSetting,
|
|
31
31
|
updateBackgroundTaskTimeoutSetting as requestBackgroundTaskTimeoutSetting,
|
|
32
|
+
loadSubagentRecordRetentionSetting,
|
|
33
|
+
updateSubagentRecordRetentionSetting as requestSubagentRecordRetentionSetting,
|
|
32
34
|
loadFeatureFlagSettings,
|
|
33
35
|
updateFeatureFlagSetting as requestFeatureFlagSetting,
|
|
34
36
|
requestRelayRestart,
|
|
@@ -197,6 +199,54 @@ export async function refreshBackgroundTaskTimeoutSetting() {
|
|
|
197
199
|
}
|
|
198
200
|
}
|
|
199
201
|
|
|
202
|
+
// Mirrors shared/subagent-record-retention.mjs (the browser cannot import it):
|
|
203
|
+
// the select starts at the relay's default until the relay's value loads.
|
|
204
|
+
const DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS = 90;
|
|
205
|
+
let subagentRecordRetentionDays = DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS;
|
|
206
|
+
let subagentRecordRetentionUpdateInFlight = false;
|
|
207
|
+
|
|
208
|
+
function syncSubagentRecordRetentionSelect() {
|
|
209
|
+
const select = document.getElementById('subagent-record-retention-select');
|
|
210
|
+
if (!select || select.tagName !== 'SELECT') return;
|
|
211
|
+
select.value = String(subagentRecordRetentionDays);
|
|
212
|
+
select.disabled = subagentRecordRetentionUpdateInFlight;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function refreshSubagentRecordRetentionSetting() {
|
|
216
|
+
syncSubagentRecordRetentionSelect();
|
|
217
|
+
try {
|
|
218
|
+
const result = await loadSubagentRecordRetentionSetting();
|
|
219
|
+
if (result && Number.isFinite(Number(result.retentionDays))) subagentRecordRetentionDays = Number(result.retentionDays);
|
|
220
|
+
} catch {
|
|
221
|
+
// Keep the last known value; the setting is not critical.
|
|
222
|
+
} finally {
|
|
223
|
+
syncSubagentRecordRetentionSelect();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function updateSubagentRecordRetentionSetting(value) {
|
|
228
|
+
if (subagentRecordRetentionUpdateInFlight) {
|
|
229
|
+
syncSubagentRecordRetentionSelect();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
subagentRecordRetentionUpdateInFlight = true;
|
|
233
|
+
syncSubagentRecordRetentionSelect();
|
|
234
|
+
try {
|
|
235
|
+
const result = await requestSubagentRecordRetentionSetting(Number(value));
|
|
236
|
+
if (result && Number.isFinite(Number(result.retentionDays))) subagentRecordRetentionDays = Number(result.retentionDays);
|
|
237
|
+
showTransientRelayNotice(
|
|
238
|
+
subagentRecordRetentionDays === 0
|
|
239
|
+
? 'Sub-agent tool output is kept for good.'
|
|
240
|
+
: `Sub-agent tool output is removed after ${subagentRecordRetentionDays} days.`,
|
|
241
|
+
);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
alert(error?.message || 'Failed to update how long sub-agent tool output is kept.');
|
|
244
|
+
} finally {
|
|
245
|
+
subagentRecordRetentionUpdateInFlight = false;
|
|
246
|
+
syncSubagentRecordRetentionSelect();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
200
250
|
export async function updateBackgroundTaskTimeoutSetting(value) {
|
|
201
251
|
if (backgroundTaskTimeoutUpdateInFlight) {
|
|
202
252
|
syncBackgroundTaskTimeoutSlider();
|
|
@@ -1534,7 +1584,7 @@ async function restartRelayForFeatureFlags() {
|
|
|
1534
1584
|
alert('Failed to request a relay restart. Restarts can only be requested from the relay host itself.');
|
|
1535
1585
|
return;
|
|
1536
1586
|
}
|
|
1537
|
-
showTransientRelayNotice('Relay restart requested. It will restart once the
|
|
1587
|
+
showTransientRelayNotice('Relay restart requested. It will restart once the running turns finish; queued messages wait for it.');
|
|
1538
1588
|
} finally {
|
|
1539
1589
|
featureFlagsUpdateInFlight = false;
|
|
1540
1590
|
syncFeatureFlagsSection();
|
|
@@ -1708,6 +1758,8 @@ export function openSettingsModal(tab, providerTab) {
|
|
|
1708
1758
|
void refreshTurnCeilingSetting();
|
|
1709
1759
|
syncBackgroundTaskTimeoutSlider();
|
|
1710
1760
|
void refreshBackgroundTaskTimeoutSetting();
|
|
1761
|
+
syncSubagentRecordRetentionSelect();
|
|
1762
|
+
void refreshSubagentRecordRetentionSetting();
|
|
1711
1763
|
void refreshPushSettingsSection();
|
|
1712
1764
|
void refreshPreviewsSection();
|
|
1713
1765
|
void refreshRemoteRelaysSection();
|
|
@@ -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,
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
} from './ask-user-view.js';
|
|
45
45
|
import { upsertRelayBoard, loadRelayBoards, renderRelayBoards } from './relay-board-view.js';
|
|
46
46
|
import { setConversationBackgroundTasks } from './background-tasks-view.mjs';
|
|
47
|
+
import { setConversationPins } from './pinned-messages-view.mjs';
|
|
47
48
|
import { setPreviews } from './preview-cards.mjs';
|
|
48
49
|
import { setRemoteRelaysSnapshot } from './remote-relays-store.mjs';
|
|
49
50
|
import { REMOTE_RELAY_SOCKET_EVENT } from './remote-relay-shared.mjs';
|
|
@@ -53,6 +54,7 @@ import { applyClaudeAuthState } from './claude-auth-ui.js';
|
|
|
53
54
|
import { applyGrokAuthState } from './grok-auth-ui.js';
|
|
54
55
|
import { applyClaudeCloudSettingsState, refreshClaudeCloudSettingsState } from './claude-cloud-settings-ui.js';
|
|
55
56
|
import { applyClaudeCloudSessionEvent } from './claude-cloud-conversation-ui.js';
|
|
57
|
+
import { updateProviderReadinessInputs } from './provider-readiness.mjs';
|
|
56
58
|
import { applyCliInstallState } from './cli-install-ui.js';
|
|
57
59
|
import { renderUpdateSection } from './update-ui.js';
|
|
58
60
|
import { applyHostSuspendState, applyRelayShutdownState } from './host-suspend-ui.js';
|
|
@@ -73,6 +75,7 @@ import {
|
|
|
73
75
|
renderMessages,
|
|
74
76
|
appendMessage,
|
|
75
77
|
replaceRenderedMessage,
|
|
78
|
+
noteTranscriptLiveRow,
|
|
76
79
|
applyIncomingConversationDraftUpdate,
|
|
77
80
|
getRenderedConversationMessageFingerprints,
|
|
78
81
|
clearBubbleCancelState,
|
|
@@ -87,6 +90,12 @@ import { clearMessageSearchRuntimeState } from './message-search-view.js';
|
|
|
87
90
|
import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
|
|
88
91
|
import { isLikelyLiveDuplicateMessage } from './live-message-dedupe.mjs';
|
|
89
92
|
import { mergeRelayThoughts } from './relay-thoughts.mjs';
|
|
93
|
+
import {
|
|
94
|
+
handleSubagentStatusEventForViewer,
|
|
95
|
+
handleSubagentTimelineEvent,
|
|
96
|
+
handleSubagentViewerReconnect,
|
|
97
|
+
refreshSubagentRows,
|
|
98
|
+
} from './subagent-viewer.mjs';
|
|
90
99
|
|
|
91
100
|
const FALLBACK_MODE = 'agent';
|
|
92
101
|
|
|
@@ -326,6 +335,10 @@ export async function connectSocket(overrideDeps) {
|
|
|
326
335
|
resyncAfterConnect();
|
|
327
336
|
refreshSessionWorkerStatus().catch(() => {});
|
|
328
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();
|
|
329
342
|
});
|
|
330
343
|
socket.on('connect_error', (e) => {
|
|
331
344
|
setRelayOnline(false);
|
|
@@ -356,8 +369,11 @@ export async function connectSocket(overrideDeps) {
|
|
|
356
369
|
updateModelCatalogState(payload || {});
|
|
357
370
|
void reconcileOpenModelVariantModal();
|
|
358
371
|
});
|
|
372
|
+
// Each settings event also feeds provider readiness (provider-readiness.mjs),
|
|
373
|
+
// so a Settings save changes the empty screen without a reload.
|
|
359
374
|
socket.on('openai_settings_updated', (payload) => {
|
|
360
|
-
deps?.applyOpenAISettingsState?.(payload || {});
|
|
375
|
+
const state = deps?.applyOpenAISettingsState?.(payload || {});
|
|
376
|
+
updateProviderReadinessInputs({ openai: state || payload || null });
|
|
361
377
|
if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
|
|
362
378
|
void Promise.resolve()
|
|
363
379
|
.then(() => deps?.refreshCurrentView?.())
|
|
@@ -370,7 +386,8 @@ export async function connectSocket(overrideDeps) {
|
|
|
370
386
|
deps?.applyCopilotSettingsState?.(payload || {});
|
|
371
387
|
});
|
|
372
388
|
socket.on('claude_settings_updated', (payload) => {
|
|
373
|
-
deps?.applyClaudeSettingsState?.(payload || {});
|
|
389
|
+
const state = deps?.applyClaudeSettingsState?.(payload || {});
|
|
390
|
+
updateProviderReadinessInputs({ claude: state || payload || null });
|
|
374
391
|
if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
|
|
375
392
|
void Promise.resolve()
|
|
376
393
|
.then(() => deps?.refreshCurrentView?.())
|
|
@@ -420,8 +437,13 @@ export async function connectSocket(overrideDeps) {
|
|
|
420
437
|
// carries the same shape as GET /api/settings/claude-cloud; one without a
|
|
421
438
|
// body is read as "something changed" and answered with a fresh read.
|
|
422
439
|
socket.on('claude_cloud_settings_updated', (payload) => {
|
|
423
|
-
if (payload && typeof payload === 'object')
|
|
424
|
-
|
|
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
|
+
}
|
|
425
447
|
});
|
|
426
448
|
// A cloud session got its id, pushed a branch or reported a new cost: the
|
|
427
449
|
// conversation's `cloud` field changed, and with it the cloud line.
|
|
@@ -429,7 +451,8 @@ export async function connectSocket(overrideDeps) {
|
|
|
429
451
|
if (applyClaudeCloudSessionEvent(payload || null)) deps?.syncChatTitleControls?.();
|
|
430
452
|
});
|
|
431
453
|
socket.on('grok_settings_updated', (payload) => {
|
|
432
|
-
deps?.applyGrokSettingsState?.(payload || {});
|
|
454
|
+
const state = deps?.applyGrokSettingsState?.(payload || {});
|
|
455
|
+
updateProviderReadinessInputs({ grok: state || payload || null });
|
|
433
456
|
if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
|
|
434
457
|
void Promise.resolve()
|
|
435
458
|
.then(() => deps?.refreshCurrentView?.())
|
|
@@ -437,7 +460,8 @@ export async function connectSocket(overrideDeps) {
|
|
|
437
460
|
}
|
|
438
461
|
});
|
|
439
462
|
socket.on('cursor_settings_updated', (payload) => {
|
|
440
|
-
deps?.applyCursorSettingsState?.(payload || {});
|
|
463
|
+
const state = deps?.applyCursorSettingsState?.(payload || {});
|
|
464
|
+
updateProviderReadinessInputs({ cursor: state || payload || null });
|
|
441
465
|
if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
|
|
442
466
|
void Promise.resolve()
|
|
443
467
|
.then(() => deps?.refreshCurrentView?.())
|
|
@@ -484,7 +508,11 @@ export async function connectSocket(overrideDeps) {
|
|
|
484
508
|
})) {
|
|
485
509
|
return;
|
|
486
510
|
}
|
|
487
|
-
|
|
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();
|
|
488
516
|
}
|
|
489
517
|
});
|
|
490
518
|
socket.on('assistant_message', ({ conversationId, message, messageId, sourceMessageId }) => {
|
|
@@ -511,6 +539,7 @@ export async function connectSocket(overrideDeps) {
|
|
|
511
539
|
if (messageId && seenMessageIds?.has(messageId)) return;
|
|
512
540
|
if (isCurrentConversation) {
|
|
513
541
|
appendMessage(message, autoScroll, messageId || null, false, sourceMessageId || null);
|
|
542
|
+
noteTranscriptLiveRow();
|
|
514
543
|
scheduleContextUsageRefresh(conversationId, 120);
|
|
515
544
|
}
|
|
516
545
|
if (sourceMessageId) relayActivities.delete(sourceMessageId);
|
|
@@ -531,6 +560,7 @@ export async function connectSocket(overrideDeps) {
|
|
|
531
560
|
if (!replaceRenderedMessage(message, messageId)) {
|
|
532
561
|
appendMessage(message, isMessagesAtBottom(), messageId, false);
|
|
533
562
|
}
|
|
563
|
+
noteTranscriptLiveRow();
|
|
534
564
|
});
|
|
535
565
|
socket.on('relay_question', ({ question }) => upsertRelayQuestion(question));
|
|
536
566
|
socket.on('relay_question_updated', ({ question }) => upsertRelayQuestion(question));
|
|
@@ -542,9 +572,18 @@ export async function connectSocket(overrideDeps) {
|
|
|
542
572
|
socket.on('relay_board_changed', () => {
|
|
543
573
|
loadRelayBoards();
|
|
544
574
|
});
|
|
575
|
+
// Relay event subscriptions: the whole active list, after every change.
|
|
576
|
+
socket.on('relay_event_subscriptions', ({ subscriptions }) => {
|
|
577
|
+
applyRelayEventSubscriptions(subscriptions);
|
|
578
|
+
});
|
|
545
579
|
socket.on('background_tasks', ({ conversationId, tasks }) => {
|
|
546
580
|
setConversationBackgroundTasks(conversationId, tasks);
|
|
547
581
|
});
|
|
582
|
+
// A message was pinned or unpinned (any client): the conversation's whole
|
|
583
|
+
// pin list, replacing what this page had.
|
|
584
|
+
socket.on('conversation_pins_updated', ({ conversationId, pins, revision }) => {
|
|
585
|
+
setConversationPins(conversationId, pins, revision);
|
|
586
|
+
});
|
|
548
587
|
// REPLACE semantics for the whole set: the preview registry is relay-owned
|
|
549
588
|
// and global, so every change ships the full list.
|
|
550
589
|
socket.on('previews', ({ previews }) => {
|
|
@@ -617,24 +656,28 @@ export async function connectSocket(overrideDeps) {
|
|
|
617
656
|
appendThinkingThought(key, String(text || ''), !!done, subagentRunId, autoScroll);
|
|
618
657
|
}
|
|
619
658
|
});
|
|
620
|
-
socket.on('subagent_status', (
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
status,
|
|
629
|
-
timestamp,
|
|
630
|
-
});
|
|
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 });
|
|
631
667
|
clearSubagentCancelInFlight(subagentRunId);
|
|
632
668
|
// The provider answered "not supported": pin the state so the button does
|
|
633
669
|
// not re-arm into an endless click-and-fail loop.
|
|
634
670
|
if (stopUnsupported) markSubagentStopUnsupported(subagentRunId);
|
|
635
671
|
if (conversationId === currentConvId) {
|
|
636
672
|
updateSubagentBubbleFromStatus(subagentRunId, status);
|
|
673
|
+
refreshSubagentRows(subagentRunId);
|
|
637
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);
|
|
638
681
|
});
|
|
639
682
|
socket.on('conversation_compacted', async ({ sourceConversationId, targetConversationId }) => {
|
|
640
683
|
if (!sourceConversationId || !targetConversationId) return;
|
|
@@ -805,13 +848,7 @@ export async function connectSocket(overrideDeps) {
|
|
|
805
848
|
renderRelayBoards();
|
|
806
849
|
renderConvList();
|
|
807
850
|
if (currentConvId === conversationId) {
|
|
808
|
-
|
|
809
|
-
renderMessages([]);
|
|
810
|
-
document.getElementById('chat-title').textContent = 'Select or start a conversation';
|
|
811
|
-
syncChatTitleControls();
|
|
812
|
-
updateSessionPill(null, null);
|
|
813
|
-
updateCompactButton();
|
|
814
|
-
scheduleContextUsageRefresh(null);
|
|
851
|
+
clearOpenConversation();
|
|
815
852
|
} else {
|
|
816
853
|
updateCompactButton();
|
|
817
854
|
}
|
|
@@ -133,6 +133,8 @@ export let filePreviewState = {
|
|
|
133
133
|
preload: 'metadata',
|
|
134
134
|
autoplay: false,
|
|
135
135
|
},
|
|
136
|
+
// The files the viewer can step through (see viewer-gallery.mjs), or null.
|
|
137
|
+
gallery: null,
|
|
136
138
|
};
|
|
137
139
|
// Toolbar filters are sticky per browser (all default off); the toggles in
|
|
138
140
|
// attachments-view.js are the only writers.
|
|
@@ -1418,6 +1420,9 @@ export function renderSummaryModalContent({ title, subtitle = '', bodyHtml = '',
|
|
|
1418
1420
|
runSummaryModalLeave(String(kind || '').trim());
|
|
1419
1421
|
summaryModalState.kind = String(kind || '').trim();
|
|
1420
1422
|
summaryModalState.refresh = typeof refresh === 'function' ? refresh : null;
|
|
1423
|
+
// Lets the stylesheet tell one kind of this shared modal from another.
|
|
1424
|
+
const modalEl = document.getElementById('summary-modal');
|
|
1425
|
+
if (modalEl?.dataset) modalEl.dataset.kind = summaryModalState.kind;
|
|
1421
1426
|
|
|
1422
1427
|
const titleEl = document.getElementById('summary-modal-title');
|
|
1423
1428
|
const subtitleEl = document.getElementById('summary-modal-subtitle');
|
|
@@ -1445,6 +1450,7 @@ export function closeSummaryModal() {
|
|
|
1445
1450
|
const modal = document.getElementById('summary-modal');
|
|
1446
1451
|
modal?.classList.remove('visible');
|
|
1447
1452
|
modal?.setAttribute('aria-hidden', 'true');
|
|
1453
|
+
if (modal?.dataset) delete modal.dataset.kind;
|
|
1448
1454
|
const titleEl = document.getElementById('summary-modal-title');
|
|
1449
1455
|
const subtitleEl = document.getElementById('summary-modal-subtitle');
|
|
1450
1456
|
const bodyEl = document.getElementById('summary-modal-body');
|
|
@@ -1492,6 +1498,17 @@ export function isSubagentCancelInFlight(subagentRunId) {
|
|
|
1492
1498
|
return id ? subagentCancelInFlight.has(id) : false;
|
|
1493
1499
|
}
|
|
1494
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
|
+
|
|
1495
1512
|
export function upsertSubagentRun(payload) {
|
|
1496
1513
|
const subagentRunId = normalizeSubagentRunId(payload?.subagentRunId);
|
|
1497
1514
|
if (!subagentRunId) return null;
|
|
@@ -1505,8 +1522,14 @@ export function upsertSubagentRun(payload) {
|
|
|
1505
1522
|
parentSubagentId: normalizeSubagentRunId(payload?.parentSubagentId) || existing?.parentSubagentId || null,
|
|
1506
1523
|
displayName: String(payload?.displayName || existing?.displayName || '').trim() || null,
|
|
1507
1524
|
status: nextSubagentStatus({ reported: payload?.status, known: existing?.status, normalize: normalizeSubagentStatus }),
|
|
1508
|
-
startedAt: existing?.startedAt || payload?.timestamp || now,
|
|
1509
|
-
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,
|
|
1510
1533
|
activities: existing?.activities || [],
|
|
1511
1534
|
thoughts: existing?.thoughts || [],
|
|
1512
1535
|
// Cumulative streamed text for this run, replayed when its bubble is
|