@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.
Files changed (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. 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, '&lt;')
52
+ .replace(/>/g, '&gt;')
53
+ .replace(/"/g, '&quot;')
54
+ .replace(/'/g, '&#39;');
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
- export function driveFileHrefFromPath(rawPath) {
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 queue is idle.');
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') applyClaudeCloudSettingsState(payload);
424
- else void refreshClaudeCloudSettingsState();
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
- appendMessage(normalizedMessage, true, messageId);
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', ({ conversationId, messageId, subagentRunId, parentSubagentId, displayName, status, timestamp, stopUnsupported }) => {
621
- if (!messageId || !subagentRunId) return;
622
- upsertSubagentRun({
623
- subagentRunId,
624
- messageId,
625
- conversationId,
626
- parentSubagentId,
627
- displayName,
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
- setCurrentConv(null);
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