@oar-sh/oar 0.9.9 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -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 +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -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
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * What to tell the user when a restart request failed. An HTTP answer is a
3
+ * refusal and says so (`refused`, shown as an alert); no answer at all can
4
+ * mean the relay took the request and began restarting before it could
5
+ * reply (`unanswered`, shown as a notice).
6
+ */
7
+ export function describeRelayRestartError(error) {
8
+ const message = String(error?.message || '').trim();
9
+ if (Number(error?.status) > 0) {
10
+ return { kind: 'refused', text: `The relay refused the restart: ${message || `HTTP ${Number(error.status)}`}` };
11
+ }
12
+ return {
13
+ kind: 'unanswered',
14
+ text: `${message || 'The relay did not answer.'} If it took the restart, the connection comes back once it has restarted.`,
15
+ };
16
+ }
@@ -29,11 +29,14 @@ 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,
35
37
  } from './api-client.js';
36
38
  import { DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES } from './background-task-timeout-options.mjs';
39
+ import { describeRelayRestartError } from './relay-restart-error.mjs';
37
40
  import { syncFontScaleSelect } from './font-scaling.js';
38
41
  import { syncInstalledAppUiState, syncPwaAppNameInput } from './pwa-install.js';
39
42
  import { normalizeKnownCwdPath } from './cwd-picker.js';
@@ -46,6 +49,8 @@ import { refreshCliInstallSections } from './cli-install-ui.js';
46
49
  import { refreshUpdateSection } from './update-ui.js';
47
50
  import { refreshRemoteRelaysSection } from './remote-relays-settings.js';
48
51
  import { refreshAgentSessionsSection } from './agent-sessions-settings-ui.js';
52
+ import { refreshSharedConversationsSection } from './shared-conversations-settings.js';
53
+ import { syncScrollOnSendToggle } from './transcript-jump-bottom.mjs';
49
54
  import {
50
55
  getPreviews,
51
56
  renderPreviewRowsInto,
@@ -197,6 +202,54 @@ export async function refreshBackgroundTaskTimeoutSetting() {
197
202
  }
198
203
  }
199
204
 
205
+ // Mirrors shared/subagent-record-retention.mjs (the browser cannot import it):
206
+ // the select starts at the relay's default until the relay's value loads.
207
+ const DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS = 90;
208
+ let subagentRecordRetentionDays = DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS;
209
+ let subagentRecordRetentionUpdateInFlight = false;
210
+
211
+ function syncSubagentRecordRetentionSelect() {
212
+ const select = document.getElementById('subagent-record-retention-select');
213
+ if (!select || select.tagName !== 'SELECT') return;
214
+ select.value = String(subagentRecordRetentionDays);
215
+ select.disabled = subagentRecordRetentionUpdateInFlight;
216
+ }
217
+
218
+ export async function refreshSubagentRecordRetentionSetting() {
219
+ syncSubagentRecordRetentionSelect();
220
+ try {
221
+ const result = await loadSubagentRecordRetentionSetting();
222
+ if (result && Number.isFinite(Number(result.retentionDays))) subagentRecordRetentionDays = Number(result.retentionDays);
223
+ } catch {
224
+ // Keep the last known value; the setting is not critical.
225
+ } finally {
226
+ syncSubagentRecordRetentionSelect();
227
+ }
228
+ }
229
+
230
+ export async function updateSubagentRecordRetentionSetting(value) {
231
+ if (subagentRecordRetentionUpdateInFlight) {
232
+ syncSubagentRecordRetentionSelect();
233
+ return;
234
+ }
235
+ subagentRecordRetentionUpdateInFlight = true;
236
+ syncSubagentRecordRetentionSelect();
237
+ try {
238
+ const result = await requestSubagentRecordRetentionSetting(Number(value));
239
+ if (result && Number.isFinite(Number(result.retentionDays))) subagentRecordRetentionDays = Number(result.retentionDays);
240
+ showTransientRelayNotice(
241
+ subagentRecordRetentionDays === 0
242
+ ? 'Sub-agent tool output is kept for good.'
243
+ : `Sub-agent tool output is removed after ${subagentRecordRetentionDays} days.`,
244
+ );
245
+ } catch (error) {
246
+ alert(error?.message || 'Failed to update how long sub-agent tool output is kept.');
247
+ } finally {
248
+ subagentRecordRetentionUpdateInFlight = false;
249
+ syncSubagentRecordRetentionSelect();
250
+ }
251
+ }
252
+
200
253
  export async function updateBackgroundTaskTimeoutSetting(value) {
201
254
  if (backgroundTaskTimeoutUpdateInFlight) {
202
255
  syncBackgroundTaskTimeoutSlider();
@@ -1287,15 +1340,33 @@ export async function removeOpenAISettings() {
1287
1340
  }
1288
1341
  }
1289
1342
 
1343
+ const WINDOWS_BOOT_CUSTOMISATION_LABELS = Object.freeze({
1344
+ action: 'what it runs',
1345
+ 'working-directory': 'working folder',
1346
+ 'logon-type': 'logon type',
1347
+ trigger: 'trigger',
1348
+ 'boot-delay': 'boot delay',
1349
+ launcher: 'launcher edited or from an older OAR',
1350
+ 'launcher-missing': 'launcher missing',
1351
+ });
1352
+
1290
1353
  function windowsAutostartStatusText() {
1291
1354
  const boot = windowsAutostartState?.boot;
1292
1355
  if (boot?.pendingElevation) {
1293
1356
  return 'Confirm the admin prompt on the PC… (it only appears on the physical desktop)';
1294
1357
  }
1295
1358
  if (boot?.lastError) return boot.lastError;
1359
+ if (boot?.readError) return `The boot task could not be read: ${boot.readError}`;
1296
1360
  if (windowsAutostartState?.mode === 'boot' && windowsAutostartState?.signinEnabled) {
1297
1361
  return 'A sign-in Startup entry also exists and can double-start the relay. Re-select "At system startup" to clean it up.';
1298
1362
  }
1363
+ if (windowsAutostartState?.mode === 'boot' && boot?.customised) {
1364
+ const labels = (boot.customisations || []).map((name) => WINDOWS_BOOT_CUSTOMISATION_LABELS[name] || name);
1365
+ return `The boot task or its launcher differs from what OAR generates (${labels.join(', ')}). Re-selecting "At system startup" regenerates both and keeps a changed launcher as a .bak copy.`;
1366
+ }
1367
+ if (windowsAutostartState?.mode === 'boot' && boot?.launcherBackupPath) {
1368
+ return `The previous launcher was replaced and kept as ${boot.launcherBackupPath}.`;
1369
+ }
1299
1370
  if (boot?.taskStatus === 'foreign') {
1300
1371
  return `A task named "${boot.taskName}" exists but was not created by OAR. Selecting "At system startup" replaces it.`;
1301
1372
  }
@@ -1375,7 +1446,7 @@ export async function refreshWindowsAutostartSetting() {
1375
1446
  }
1376
1447
  windowsAutostartState = result;
1377
1448
  } catch (error) {
1378
- alert(error?.message || 'Failed to read the Windows autostart setting.');
1449
+ alert(error?.message ? `Failed to read the Windows autostart setting: ${error.message}` : 'Failed to read the Windows autostart setting.');
1379
1450
  } finally {
1380
1451
  windowsAutostartUpdateInFlight = false;
1381
1452
  syncWindowsAutostartSetting();
@@ -1409,7 +1480,7 @@ export async function updateWindowsAutostartMode(mode) {
1409
1480
  );
1410
1481
  }
1411
1482
  } catch (error) {
1412
- alert(error?.message || 'Failed to update the Windows autostart setting.');
1483
+ alert(error?.message ? `Failed to update the Windows autostart setting: ${error.message}` : 'Failed to update the Windows autostart setting.');
1413
1484
  } finally {
1414
1485
  windowsAutostartUpdateInFlight = false;
1415
1486
  syncWindowsAutostartSetting();
@@ -1529,12 +1600,20 @@ async function restartRelayForFeatureFlags() {
1529
1600
  featureFlagsUpdateInFlight = true;
1530
1601
  syncFeatureFlagsSection();
1531
1602
  try {
1532
- const result = await requestRelayRestart({ reason: 'feature-flags-changed' });
1533
- if (!result) {
1534
- alert('Failed to request a relay restart. Restarts can only be requested from the relay host itself.');
1603
+ let result = null;
1604
+ try {
1605
+ result = await requestRelayRestart({ reason: 'feature-flags-changed' });
1606
+ } catch (error) {
1607
+ const { kind, text } = describeRelayRestartError(error);
1608
+ if (kind === 'refused') alert(text);
1609
+ else showTransientRelayNotice(text, 9000);
1610
+ return;
1611
+ }
1612
+ if (!result?.ok) {
1613
+ alert('Failed to request a relay restart.');
1535
1614
  return;
1536
1615
  }
1537
- showTransientRelayNotice('Relay restart requested. It will restart once the queue is idle.');
1616
+ showTransientRelayNotice('Relay restart requested. It will restart once the running turns finish; queued messages wait for it.');
1538
1617
  } finally {
1539
1618
  featureFlagsUpdateInFlight = false;
1540
1619
  syncFeatureFlagsSection();
@@ -1634,14 +1713,15 @@ export async function updateDefaultSessionWorkspaceRootSetting(rawValue) {
1634
1713
  }
1635
1714
 
1636
1715
  // "Live previews": every preview across every session, not just this
1637
- // conversation's. Previews never expire, so this list is the one place that can
1638
- // close a link left behind in a conversation you have moved on from.
1716
+ // conversation's. Previews never expire and survive a relay restart, so this
1717
+ // list is the one place that can close a link left behind in a conversation
1718
+ // you have moved on from; age and last use show which ones are stale.
1639
1719
  export function renderPreviewsSection() {
1640
1720
  const list = document.getElementById('settings-previews-list');
1641
1721
  const empty = document.getElementById('settings-previews-empty');
1642
1722
  if (!list) return;
1643
1723
  const previews = getPreviews();
1644
- renderPreviewRowsInto(list, previews, { withConversation: true });
1724
+ renderPreviewRowsInto(list, previews, { withConversation: true, withAge: true });
1645
1725
  if (empty) {
1646
1726
  empty.hidden = previews.length > 0;
1647
1727
  empty.textContent = 'No previews are published right now.';
@@ -1676,6 +1756,7 @@ export function openSettingsModal(tab, providerTab) {
1676
1756
  themeSelect.value = readLocalStorage(THEME_STORAGE_KEY) === 'light' ? 'light' : 'dark';
1677
1757
  }
1678
1758
  syncSuspendHostVisibility();
1759
+ syncScrollOnSendToggle();
1679
1760
  syncFontScaleSelect();
1680
1761
  syncPwaAppNameInput();
1681
1762
  syncDefaultSessionWorkspaceRootInput();
@@ -1708,8 +1789,11 @@ export function openSettingsModal(tab, providerTab) {
1708
1789
  void refreshTurnCeilingSetting();
1709
1790
  syncBackgroundTaskTimeoutSlider();
1710
1791
  void refreshBackgroundTaskTimeoutSetting();
1792
+ syncSubagentRecordRetentionSelect();
1793
+ void refreshSubagentRecordRetentionSetting();
1711
1794
  void refreshPushSettingsSection();
1712
1795
  void refreshPreviewsSection();
1796
+ void refreshSharedConversationsSection();
1713
1797
  void refreshRemoteRelaysSection();
1714
1798
  void refreshAgentSessionsSection();
1715
1799
  void refreshFeatureFlagsSection();
@@ -1,7 +1,7 @@
1
1
  const SETTINGS_TAB_STORAGE_KEY = 'copilot_settings_tab';
2
2
  const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
3
3
 
4
- const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', 'notifications', 'features'];
4
+ const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', 'shares', 'notifications', 'features'];
5
5
  // Copilot leads: it is the default provider, and `SETTINGS_PROVIDER_TABS[0]`
6
6
  // is also the sub-tab a first-time visitor lands on.
7
7
  const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'claude-cloud', 'grok', 'cursor'];