@oar-sh/oar 0.9.9 → 0.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/README.md +78 -9
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +2 -2
  5. package/server/README.md +286 -3
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +35 -1
  8. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  9. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  10. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  12. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  13. package/server/db-schema.mjs +36 -0
  14. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  15. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  16. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  17. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  18. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  19. package/server/public/app/api-client.js +61 -0
  20. package/server/public/app/archive-confirm.mjs +28 -0
  21. package/server/public/app/ask-user-view.js +3 -0
  22. package/server/public/app/attachments-view.js +516 -6
  23. package/server/public/app/background-tasks-view.mjs +18 -26
  24. package/server/public/app/bootstrap.js +186 -35
  25. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  26. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  27. package/server/public/app/composer-control-state.mjs +16 -0
  28. package/server/public/app/composer-keyboard.mjs +54 -0
  29. package/server/public/app/conversation-view.js +361 -85
  30. package/server/public/app/explorer-edit-state.mjs +68 -0
  31. package/server/public/app/host-suspend-ui.js +6 -3
  32. package/server/public/app/journal-view.js +146 -26
  33. package/server/public/app/no-conversation-state.mjs +58 -0
  34. package/server/public/app/provider-readiness.mjs +120 -0
  35. package/server/public/app/relay-board-view.js +3 -0
  36. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  37. package/server/public/app/relay-event-view.mjs +234 -0
  38. package/server/public/app/settings-modal.js +53 -1
  39. package/server/public/app/socket-handlers.js +59 -28
  40. package/server/public/app/store.js +19 -2
  41. package/server/public/app/subagent-row.mjs +138 -0
  42. package/server/public/app/subagent-timeline-view.mjs +186 -0
  43. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  44. package/server/public/app/subagent-viewer.mjs +534 -0
  45. package/server/public/app/task-row-format.mjs +29 -0
  46. package/server/public/app/thinking-follow.js +4 -0
  47. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  48. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  49. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  50. package/server/public/app/update-ui.js +1 -1
  51. package/server/public/app/viewer-history.mjs +5 -3
  52. package/server/public/index.html +396 -5
  53. package/server/repositories/message-repository.mjs +29 -3
  54. package/server/repositories/question-repository.mjs +9 -3
  55. package/server/repositories/remote-relay-repository.mjs +6 -2
  56. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  57. package/server/routes/ask-user-routes.mjs +3 -0
  58. package/server/routes/messages-routes.mjs +515 -50
  59. package/server/routes/relay-event-routes.mjs +157 -0
  60. package/server/routes/remote-relay-routes.mjs +15 -0
  61. package/server/routes/sessions-routes.mjs +72 -0
  62. package/server/server-runtime.mjs +245 -62
  63. package/server/services/explorer-entry-name.mjs +26 -0
  64. package/server/services/explorer-mutations.mjs +122 -0
  65. package/server/services/preview-proxy-server.mjs +41 -23
  66. package/server/services/push-dispatch-service.mjs +18 -0
  67. package/server/services/relay-event-link-service.mjs +289 -0
  68. package/server/services/relay-event-service.mjs +1935 -0
  69. package/server/services/relay-shutdown-gate.mjs +31 -0
  70. package/server/services/remote-relay-caller-context.mjs +52 -7
  71. package/server/services/remote-relay-dispatcher.mjs +494 -2
  72. package/server/services/remote-relay-registry-service.mjs +11 -1
  73. package/server/services/session-activity-tracker.mjs +487 -0
  74. package/server/services/session-history-refresh-service.mjs +7 -0
  75. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  76. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  77. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  78. package/server/services/subagent-record-retention.mjs +164 -0
  79. package/server/services/subagent-timeline-service.mjs +328 -0
  80. package/server/services/subagent-viewer-rooms.mjs +119 -0
  81. package/server/services/upgrade-socket-guard.mjs +16 -0
  82. package/shared/relay-events.mjs +371 -0
  83. package/shared/remote-relay-contract.mjs +157 -5
  84. package/shared/subagent-record-retention.mjs +31 -0
  85. package/shared/subagent-timeline.mjs +79 -0
@@ -0,0 +1,184 @@
1
+ // Relay event subscriptions on the page: the header's "watching N sessions"
2
+ // chip with its list and Unsubscribe buttons (on the watcher), and the
3
+ // "watched" badge (on a watched session, in the header and the sidebar).
4
+ // The relay sends the whole active list on every change (socket
5
+ // `relay_event_subscriptions`); the page loads it once at start.
6
+
7
+ import { apiFetch } from './api-client.js';
8
+ import { conversations, currentConvId, escHtml, IS_SHARED_VIEW, showTransientRelayNotice } from './store.js';
9
+ import {
10
+ renderWatchedBadgeHtml,
11
+ renderWatchingPanelHtml,
12
+ watchersOf,
13
+ watchingChipText,
14
+ watchingSummary,
15
+ } from './relay-event-view.mjs';
16
+
17
+ let subscriptions = [];
18
+ let renderedChipKey = '';
19
+ let panelOpen = false;
20
+ let panelConversationId = '';
21
+ const changeListeners = new Set();
22
+
23
+ export function getRelayEventSubscriptions() {
24
+ return subscriptions;
25
+ }
26
+
27
+ /** Runs after every change of the list (the sidebar re-renders its badges). */
28
+ export function onRelayEventSubscriptionsChanged(listener) {
29
+ changeListeners.add(listener);
30
+ return () => changeListeners.delete(listener);
31
+ }
32
+
33
+ export function applyRelayEventSubscriptions(list) {
34
+ subscriptions = Array.isArray(list) ? list.filter((sub) => sub && typeof sub === 'object') : [];
35
+ syncRelayEventHeader();
36
+ for (const listener of changeListeners) {
37
+ try { listener(subscriptions); } catch {}
38
+ }
39
+ }
40
+
41
+ export async function loadRelayEventSubscriptions() {
42
+ if (IS_SHARED_VIEW) return;
43
+ const response = await apiFetch('/api/relay-events/subscriptions');
44
+ if (response && Array.isArray(response.subscriptions)) applyRelayEventSubscriptions(response.subscriptions);
45
+ }
46
+
47
+ /** The sidebar's badge for one conversation: listed sessions only. */
48
+ export function relayEventWatchedMarkerHtml(conversationId) {
49
+ if (IS_SHARED_VIEW) return '';
50
+ return renderWatchedBadgeHtml(watchersOf(subscriptions, conversationId, { includeAll: false }), { conversations, subscriptions, escHtml });
51
+ }
52
+
53
+ async function unsubscribe(subscriptionId, button) {
54
+ if (!subscriptionId) return;
55
+ if (button) button.disabled = true;
56
+ const response = await apiFetch(`/api/relay-events/subscriptions/${encodeURIComponent(subscriptionId)}`, { method: 'DELETE' });
57
+ if (!response?.ok) {
58
+ if (button) button.disabled = false;
59
+ showTransientRelayNotice?.('The subscription could not be ended. Try again in a moment.');
60
+ return;
61
+ }
62
+ // The socket brings the new list; this page does not wait for it.
63
+ applyRelayEventSubscriptions(subscriptions.filter((sub) => sub.id !== subscriptionId));
64
+ }
65
+
66
+ // The list opens as a fixed popover on <body>, placed under the chip: the
67
+ // title slot clips its overflow (that keeps the title's ellipsis), so a panel
68
+ // inside it would be cut off.
69
+ function ensurePanel() {
70
+ let panel = document.getElementById('relay-watch-panel');
71
+ if (panel) return panel;
72
+ panel = document.createElement('div');
73
+ panel.id = 'relay-watch-panel';
74
+ panel.className = 'relay-watch-panel';
75
+ panel.hidden = true;
76
+ document.body.appendChild(panel);
77
+ panel.addEventListener('click', (event) => {
78
+ const target = event.target instanceof Element ? event.target : null;
79
+ const unsubscribeButton = target?.closest?.('.relay-watch-unsubscribe');
80
+ if (!unsubscribeButton) return;
81
+ void unsubscribe(unsubscribeButton.getAttribute('data-subscription-id'), unsubscribeButton);
82
+ });
83
+ document.addEventListener('click', (event) => {
84
+ if (!panelOpen) return;
85
+ // composedPath: an Unsubscribe click may have re-rendered the panel already.
86
+ const path = event.composedPath();
87
+ if (path.includes(panel) || path.some((node) => node instanceof Element && node.classList.contains('relay-watch-chip'))) return;
88
+ setPanelOpen(false);
89
+ });
90
+ document.addEventListener('keydown', (event) => {
91
+ if (!panelOpen || event.key !== 'Escape') return;
92
+ event.stopPropagation();
93
+ setPanelOpen(false);
94
+ document.querySelector('#chat-title-relay-events .relay-watch-chip')?.focus?.();
95
+ }, true);
96
+ window.addEventListener('resize', () => { if (panelOpen) placePanel(); });
97
+ return panel;
98
+ }
99
+
100
+ function setPanelOpen(open) {
101
+ panelOpen = Boolean(open);
102
+ renderedChipKey = '';
103
+ syncRelayEventHeader();
104
+ }
105
+
106
+ function placePanel() {
107
+ const panel = document.getElementById('relay-watch-panel');
108
+ const chip = document.querySelector('#chat-title-relay-events .relay-watch-chip');
109
+ if (!panel || panel.hidden) return;
110
+ const rect = chip?.getBoundingClientRect?.();
111
+ if (!rect || (!rect.width && !rect.height)) {
112
+ panel.hidden = true;
113
+ return;
114
+ }
115
+ const margin = 8;
116
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
117
+ const top = Math.round(rect.bottom + 4);
118
+ panel.style.top = `${top}px`;
119
+ panel.style.maxHeight = `${Math.max(120, window.innerHeight - top - margin)}px`;
120
+ panel.style.left = '0px';
121
+ const width = panel.offsetWidth;
122
+ const left = Math.max(margin, Math.min(rect.left, viewportWidth - width - margin));
123
+ panel.style.left = `${Math.round(left)}px`;
124
+ }
125
+
126
+ function ensureHeaderElements() {
127
+ const slot = document.getElementById('chat-title-slot');
128
+ if (!slot) return null;
129
+ let line = document.getElementById('chat-title-relay-events');
130
+ if (!line) {
131
+ line = document.createElement('span');
132
+ line.id = 'chat-title-relay-events';
133
+ line.hidden = true;
134
+ const origin = document.getElementById('chat-title-origin');
135
+ if (origin?.nextSibling) slot.insertBefore(line, origin.nextSibling);
136
+ else slot.appendChild(line);
137
+ line.addEventListener('click', (event) => {
138
+ const target = event.target instanceof Element ? event.target : null;
139
+ if (target?.closest?.('.relay-watch-chip')) {
140
+ event.stopPropagation();
141
+ setPanelOpen(!panelOpen);
142
+ }
143
+ });
144
+ }
145
+ return line;
146
+ }
147
+
148
+ /** The header's chip and badge for the conversation on screen. Idempotent. */
149
+ export function syncRelayEventHeader() {
150
+ const line = ensureHeaderElements();
151
+ if (!line) return;
152
+ const panel = ensurePanel();
153
+ const id = String(currentConvId || '').trim();
154
+ // Another conversation on screen closes the list.
155
+ if (id !== panelConversationId) panelOpen = false;
156
+ panelConversationId = id;
157
+ if (IS_SHARED_VIEW || !id) {
158
+ line.hidden = true;
159
+ line.innerHTML = '';
160
+ panel.hidden = true;
161
+ panel.innerHTML = '';
162
+ renderedChipKey = '';
163
+ return;
164
+ }
165
+ const summary = watchingSummary(subscriptions, id);
166
+ const chipText = watchingChipText(summary);
167
+ const watchers = watchersOf(subscriptions, id, { includeAll: true });
168
+ if (!chipText) panelOpen = false;
169
+ const key = JSON.stringify([id, chipText, panelOpen, summary.subscriptions.map((sub) => [sub.id, sub.sessions, sub.events]), watchers]);
170
+ if (key === renderedChipKey) return;
171
+ renderedChipKey = key;
172
+ const parts = [];
173
+ if (chipText) {
174
+ const where = summary.relays.length ? ` (also on ${summary.relays.join(', ')})` : '';
175
+ parts.push(`<button type="button" class="relay-watch-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-watch-panel" title="${escHtml(`Event subscriptions of this conversation${where}`)}">👁 ${escHtml(chipText)}</button>`);
176
+ }
177
+ const badge = renderWatchedBadgeHtml(watchers, { conversations, subscriptions, escHtml });
178
+ if (badge) parts.push(badge);
179
+ line.innerHTML = parts.join('');
180
+ line.hidden = parts.length === 0;
181
+ panel.innerHTML = panelOpen ? renderWatchingPanelHtml(summary, { conversations, escHtml }) : '';
182
+ panel.hidden = !panelOpen;
183
+ if (panelOpen) placePanel();
184
+ }
@@ -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, '&amp;')
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
+ }
@@ -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();