@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,73 @@
1
+ // ⋯ → About OAR: the menu entry's label and the modal (about-view.mjs builds
2
+ // its HTML from GET /api/about).
3
+
4
+ import {
5
+ closeSummaryModal,
6
+ escHtml,
7
+ getUpdateState,
8
+ openSummaryModal,
9
+ renderSummaryModalContent,
10
+ setSummaryModalLoading,
11
+ } from './store.js';
12
+ import { apiFetchOrThrow } from './api-client.js';
13
+ import { openSettingsModal } from './settings-modal.js';
14
+ import { aboutMenuLabel, buildAboutModalHtml } from './about-view.mjs';
15
+
16
+ const ABOUT_KIND = 'about';
17
+ const ABOUT_TITLE = 'About OAR';
18
+
19
+ /** The entry carries the running version; /api/status brings it on load. */
20
+ export function syncAboutMenuLabel(version = getUpdateState()?.runningVersion) {
21
+ const button = document.getElementById('chat-menu-about');
22
+ if (button) button.textContent = aboutMenuLabel(version);
23
+ }
24
+
25
+ function bindAboutModalActions() {
26
+ const body = document.getElementById('summary-modal-body');
27
+ const button = body?.querySelector('[data-about-open-settings]');
28
+ if (!button) return;
29
+ button.addEventListener('click', (event) => {
30
+ event.preventDefault();
31
+ closeSummaryModal();
32
+ openSettingsModal('general');
33
+ document.getElementById('update-auto-check-toggle')?.scrollIntoView?.({ block: 'center' });
34
+ });
35
+ }
36
+
37
+ async function loadAboutAndRender() {
38
+ const about = await apiFetchOrThrow('/api/about');
39
+ if (!about) throw new Error('The relay is not reachable right now.');
40
+ renderSummaryModalContent({
41
+ title: ABOUT_TITLE,
42
+ subtitle: about.version ? `Version ${about.version}` : '',
43
+ bodyHtml: buildAboutModalHtml(about),
44
+ refresh: loadAboutAndRender,
45
+ kind: ABOUT_KIND,
46
+ });
47
+ bindAboutModalActions();
48
+ syncAboutMenuLabel(about.version);
49
+ }
50
+
51
+ export async function showAbout() {
52
+ openSummaryModal({
53
+ title: ABOUT_TITLE,
54
+ subtitle: 'Loading…',
55
+ bodyHtml: '<div class="summary-loading">Reading the relay…</div>',
56
+ refresh: loadAboutAndRender,
57
+ kind: ABOUT_KIND,
58
+ });
59
+ setSummaryModalLoading(true);
60
+ try {
61
+ await loadAboutAndRender();
62
+ } catch (error) {
63
+ renderSummaryModalContent({
64
+ title: ABOUT_TITLE,
65
+ subtitle: 'Unable to load',
66
+ bodyHtml: `<div class="summary-error">${escHtml(error?.message || 'Unknown error')}</div>`,
67
+ refresh: loadAboutAndRender,
68
+ kind: ABOUT_KIND,
69
+ });
70
+ } finally {
71
+ setSummaryModalLoading(false);
72
+ }
73
+ }
@@ -0,0 +1,234 @@
1
+ // The About OAR modal, as HTML from a GET /api/about payload. No DOM here;
2
+ // about-ui.js opens the modal and wires it.
3
+
4
+ export const OAR_REPO_URL = 'https://github.com/oar-sh/oar';
5
+ export const OAR_SITE_URL = 'https://oar.sh';
6
+ export const OAR_RELEASES_URL = 'https://github.com/oar-sh/oar/releases';
7
+
8
+ const STATE_LABELS = Object.freeze({
9
+ online: 'online',
10
+ offline: 'offline',
11
+ idle: 'idle',
12
+ });
13
+
14
+ const USAGE_LABELS = Object.freeze({
15
+ github: 'GitHub Copilot',
16
+ cursor: 'Cursor',
17
+ grok: 'Grok',
18
+ 'claude-cloud': 'Claude Cloud',
19
+ });
20
+
21
+ function esc(value) {
22
+ return String(value ?? '')
23
+ .replace(/&/g, '&amp;')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;')
27
+ .replace(/'/g, '&#39;');
28
+ }
29
+
30
+ function finite(value) {
31
+ const number = Number(value);
32
+ return Number.isFinite(number) ? number : null;
33
+ }
34
+
35
+ export function aboutMenuLabel(version) {
36
+ const text = String(version || '').trim();
37
+ return text && text !== '0.0.0' ? `ℹ️ About OAR · ${text}` : 'ℹ️ About OAR';
38
+ }
39
+
40
+ export function formatCount(value) {
41
+ const number = finite(value);
42
+ return number === null ? '–' : Math.round(number).toLocaleString('en-US');
43
+ }
44
+
45
+ /** Tokens and other large amounts: 1 234, 12.3 k, 4.56 M. */
46
+ export function formatAmount(value) {
47
+ const number = finite(value);
48
+ if (number === null) return '–';
49
+ if (Math.abs(number) >= 1e9) return `${(number / 1e9).toFixed(2)} B`;
50
+ if (Math.abs(number) >= 1e6) return `${(number / 1e6).toFixed(2)} M`;
51
+ if (Math.abs(number) >= 1e4) return `${(number / 1e3).toFixed(1)} k`;
52
+ return Number.isInteger(number) ? number.toLocaleString('en-US') : number.toFixed(2);
53
+ }
54
+
55
+ export function formatUsd(value) {
56
+ const number = finite(value);
57
+ return number === null ? '–' : `USD ${number.toFixed(2)}`;
58
+ }
59
+
60
+ export function formatBytes(value) {
61
+ const number = finite(value);
62
+ if (number === null || number < 0) return '–';
63
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
64
+ let size = number;
65
+ let unit = 0;
66
+ while (size >= 1024 && unit < units.length - 1) {
67
+ size /= 1024;
68
+ unit += 1;
69
+ }
70
+ return unit === 0 ? `${Math.round(size)} B` : `${size.toFixed(size >= 100 ? 0 : 1)} ${units[unit]}`;
71
+ }
72
+
73
+ export function formatUptime(seconds) {
74
+ const total = finite(seconds);
75
+ if (total === null || total < 0) return '–';
76
+ const days = Math.floor(total / 86_400);
77
+ const hours = Math.floor((total % 86_400) / 3_600);
78
+ const minutes = Math.floor((total % 3_600) / 60);
79
+ if (days > 0) return `${days} d ${hours} h`;
80
+ if (hours > 0) return `${hours} h ${minutes} min`;
81
+ if (minutes > 0) return `${minutes} min`;
82
+ return `${Math.floor(total)} s`;
83
+ }
84
+
85
+ function defaultFormatDateTime(iso) {
86
+ const at = Date.parse(iso);
87
+ if (!Number.isFinite(at)) return '–';
88
+ return new Date(at).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
89
+ }
90
+
91
+ function defaultFormatDate(iso) {
92
+ const at = Date.parse(iso);
93
+ if (!Number.isFinite(at)) return '–';
94
+ return new Date(at).toLocaleDateString(undefined, { dateStyle: 'medium' });
95
+ }
96
+
97
+ function row(label, value, { hint = '', title = '' } = {}) {
98
+ return `<tr><td><div class="plan-usage-detail-label"><span>${esc(label)}</span>${hint ? `<span class="plan-usage-detail-hint">${esc(hint)}</span>` : ''}</div></td>`
99
+ + `<td class="plan-usage-detail-value"${title ? ` title="${esc(title)}"` : ''}>${value}</td></tr>`;
100
+ }
101
+
102
+ function section(id, title, inner, { subtitle = '' } = {}) {
103
+ return `<section class="plan-usage-card about-section" data-about-section="${esc(id)}">`
104
+ + `<div class="plan-usage-card-head"><div class="plan-usage-card-titles"><span class="plan-usage-card-title">${esc(title)}</span>`
105
+ + `${subtitle ? `<span class="plan-usage-account">${esc(subtitle)}</span>` : ''}</div></div>${inner}</section>`;
106
+ }
107
+
108
+ function table(rows) {
109
+ return rows.length ? `<table class="plan-usage-detail-table"><tbody>${rows.join('')}</tbody></table>` : '';
110
+ }
111
+
112
+ function note(text) {
113
+ return `<div class="plan-usage-detail-note">${text}</div>`;
114
+ }
115
+
116
+ function externalLink(href, text) {
117
+ return `<a class="plan-usage-link" href="${esc(href)}" target="_blank" rel="noopener">${esc(text)}</a>`;
118
+ }
119
+
120
+ function providerDetails(provider) {
121
+ const parts = [];
122
+ if (provider.engine === 'extension') parts.push('extension engine');
123
+ if (provider.engine === 'sdk') parts.push('SDK engine');
124
+ if (provider.cliVersion) parts.push(`CLI ${provider.cliVersion}`);
125
+ if (provider.sdkVersion) parts.push(`SDK ${provider.sdkVersion}`);
126
+ return parts.join(' · ');
127
+ }
128
+
129
+ function providerState(provider) {
130
+ const state = STATE_LABELS[provider.state] ? provider.state : 'idle';
131
+ const workers = finite(provider.runningWorkers) || 0;
132
+ const count = workers > 0 ? ` · ${workers} ${workers === 1 ? 'worker' : 'workers'}` : '';
133
+ return `<span class="about-state" data-state="${esc(state)}">${esc(STATE_LABELS[state])}</span>${esc(count)}`;
134
+ }
135
+
136
+ function usageRows(id, usage) {
137
+ if (id === 'github') {
138
+ return [row('Premium requests', esc(formatAmount(usage.premiumRequests)), { hint: `over ${formatCount(usage.turns)} replies` })];
139
+ }
140
+ if (id === 'cursor') {
141
+ return [
142
+ row('Tokens', esc(formatAmount(usage.totalTokens)), {
143
+ hint: `${formatAmount(usage.inputTokens)} in · ${formatAmount(usage.outputTokens)} out · ${formatAmount((finite(usage.cacheReadTokens) || 0) + (finite(usage.cacheWriteTokens) || 0))} cache`,
144
+ }),
145
+ row('Cost', esc(formatUsd(usage.rawCostUsd)), { hint: `model price over ${formatCount(usage.runs)} runs; charged ${formatUsd(usage.chargedUsd)}` }),
146
+ ];
147
+ }
148
+ if (id === 'grok') {
149
+ return [
150
+ row('Tokens', esc(formatAmount(usage.totalTokens)), { hint: `${formatAmount(usage.inputTokens)} in · ${formatAmount(usage.outputTokens)} out` }),
151
+ row('Cost', esc(formatUsd(usage.costUsd)), { hint: `over ${formatCount(usage.turns)} turns` }),
152
+ ];
153
+ }
154
+ if (id === 'claude-cloud') {
155
+ return [row('Cost', esc(formatUsd(usage.costUsd)), { hint: `over ${formatCount(usage.sessions)} cloud sessions` })];
156
+ }
157
+ return [];
158
+ }
159
+
160
+ function buildUpdateSection(update, formatDateTime) {
161
+ if (update.checkEnabled) {
162
+ if (update.available && update.latestVersion) {
163
+ return section('update', 'Updates', note(`Latest: ${esc(update.latestVersion)} · ${externalLink(update.notesUrl || OAR_RELEASES_URL, 'release notes')}`));
164
+ }
165
+ const checked = update.lastCheckedAt ? `, checked ${esc(formatDateTime(update.lastCheckedAt))}` : '';
166
+ return section('update', 'Updates', note(update.lastCheckedAt ? `Up to date${checked}.` : 'Not checked yet.'));
167
+ }
168
+ if (update.checkKilled) {
169
+ return section('update', 'Updates', note('Update checks are turned off on this relay (OAR_NO_UPDATE_CHECK).'));
170
+ }
171
+ return section('update', 'Updates', note(
172
+ 'Update checks are off. Turn on <button type="button" class="plan-usage-link about-inline-button" data-about-open-settings>Settings › General › Check for updates automatically</button> to see the latest version here.',
173
+ ));
174
+ }
175
+
176
+ /**
177
+ * @param {object} about the GET /api/about payload
178
+ * @param {object} [options]
179
+ * @param {(iso: string) => string} [options.formatDateTime]
180
+ * @param {(iso: string) => string} [options.formatDate]
181
+ */
182
+ export function buildAboutModalHtml(about = {}, {
183
+ formatDateTime = defaultFormatDateTime,
184
+ formatDate = defaultFormatDate,
185
+ } = {}) {
186
+ const sections = [];
187
+
188
+ const commit = String(about.commit || '').trim();
189
+ const install = about.installMethod === 'git-checkout' ? 'git checkout' : (about.installMethod === 'npm-global' ? 'npm package' : '');
190
+ sections.push(section('build', 'OAR', table([
191
+ row('Version', esc(about.version || '–'), install ? { hint: install } : {}),
192
+ ...(commit ? [row('Commit', `<code>${esc(commit.slice(0, 7))}</code>`, { title: commit })] : []),
193
+ row('Started', esc(about.startedAt ? formatDateTime(about.startedAt) : '–')),
194
+ row('Uptime', esc(formatUptime(about.uptimeSeconds))),
195
+ ])));
196
+
197
+ const providers = Array.isArray(about.providers) ? about.providers : [];
198
+ sections.push(section('providers', 'Providers', providers.length
199
+ ? table(providers.map((provider) => row(provider.label || provider.id, providerState(provider), { hint: providerDetails(provider) })))
200
+ + (providers.some((provider) => provider.state === 'idle')
201
+ ? note('Idle: on, with no worker running right now; one starts with the next turn.')
202
+ : '')
203
+ : note('No provider is turned on.')));
204
+
205
+ const stats = about.stats || {};
206
+ const turnsByProvider = Array.isArray(stats.turnsByProvider) ? stats.turnsByProvider : [];
207
+ const archived = finite(stats.archivedConversations) || 0;
208
+ sections.push(section('activity', 'Activity', table([
209
+ row('Conversations', esc(formatCount(stats.conversations)), archived ? { hint: `${formatCount(archived)} archived` } : {}),
210
+ row('Turns', esc(formatCount(stats.turns)), { hint: 'replies written by an agent' }),
211
+ row('Messages', esc(formatCount(stats.messages))),
212
+ ...turnsByProvider.map((entry) => row(`Turns on ${entry.label || entry.id}`, esc(formatCount(entry.turns)))),
213
+ ]), { subtitle: stats.firstConversationAt ? `since ${formatDate(stats.firstConversationAt)}` : '' }));
214
+
215
+ const usage = about.usage && typeof about.usage === 'object' ? about.usage : {};
216
+ const usageIds = Object.keys(USAGE_LABELS).filter((id) => usage[id]);
217
+ sections.push(section('usage', 'Usage', (usageIds.length
218
+ ? usageIds.map((id) => `<div class="about-usage-provider">${esc(USAGE_LABELS[id])}</div>${table(usageRows(id, usage[id]))}`).join('')
219
+ : note('No provider has reported usage yet.'))
220
+ + note('What the providers report and this relay keeps: Copilot premium requests per reply, Cursor and Grok tokens and cost per billing cycle, Claude Cloud cost per session. Claude Code and OpenAI turns keep no usage here.')));
221
+
222
+ const relay = about.relay || {};
223
+ sections.push(section('relay', 'Relay', table([
224
+ row('Uptime', esc(formatUptime(relay.uptimeSeconds))),
225
+ row('Database', esc(formatBytes(relay.dbBytes)), { hint: `${formatBytes(relay.dbFileBytes)} + ${formatBytes(relay.walBytes)} WAL` }),
226
+ row('Running workers', esc(formatCount(relay.runningWorkers))),
227
+ ])));
228
+
229
+ sections.push(buildUpdateSection(about.update || {}, formatDateTime));
230
+
231
+ sections.push(`<div class="about-links" data-about-section="links">${externalLink(OAR_REPO_URL, 'github.com/oar-sh/oar')}${externalLink(OAR_SITE_URL, 'oar.sh')}</div>`);
232
+
233
+ return `<div class="about-modal">${sections.join('')}</div>`;
234
+ }
@@ -19,6 +19,7 @@ import {
19
19
  refreshWorkspaceRootHints,
20
20
  } from './api-client.js';
21
21
  import { isSuspendHostActionVisible } from './settings-modal.js';
22
+ import { describeRelayRestartError } from './relay-restart-error.mjs';
22
23
  import {
23
24
  applyHostSuspendState,
24
25
  describeHostSuspendBlocker,
@@ -311,10 +312,17 @@ export async function confirmEmptyQueue() {
311
312
  emptyQueueInFlight = true;
312
313
  setSummaryModalLoading(true);
313
314
  try {
314
- const result = await requestQueueEmpty({
315
- reason: 'manual-empty-queue',
316
- requestedBy: 'localhost-api',
317
- });
315
+ let result = null;
316
+ try {
317
+ result = await requestQueueEmpty({
318
+ reason: 'manual-empty-queue',
319
+ requestedBy: 'localhost-api',
320
+ });
321
+ } catch (error) {
322
+ closeSummaryModal();
323
+ alert(`Failed to empty queue: ${error?.message || 'unknown error'}`);
324
+ return;
325
+ }
318
326
  closeSummaryModal();
319
327
  if (!result?.ok) {
320
328
  alert('Failed to empty queue');
@@ -334,23 +342,32 @@ export async function confirmEmptyQueue() {
334
342
  }
335
343
  }
336
344
 
345
+ /** A failed restart request: a refusal as an alert, no answer as a notice. */
346
+ export function showRelayRestartRequestError(error) {
347
+ const { kind, text } = describeRelayRestartError(error);
348
+ if (kind === 'refused') alert(text);
349
+ else showTransientRelayNotice(text, 9000);
350
+ }
351
+
337
352
  export async function confirmRestartWebRelay() {
338
353
  if (restartRelayInFlight) return;
339
354
  restartRelayInFlight = true;
340
355
  setSummaryModalLoading(true);
341
356
  try {
342
- const result = await requestRelayRestart({
343
- reason: 'manual-restart',
344
- requestedBy: 'localhost-api',
345
- restart: true,
346
- });
347
- closeSummaryModal();
348
- // Restart can close the connection before the browser receives JSON.
349
- if (!result) {
350
- showTransientRelayNotice('Relay restart requested. Connection may briefly drop while it restarts.', 7000);
357
+ let result = null;
358
+ try {
359
+ result = await requestRelayRestart({
360
+ reason: 'manual-restart',
361
+ requestedBy: 'localhost-api',
362
+ restart: true,
363
+ });
364
+ } catch (error) {
365
+ closeSummaryModal();
366
+ showRelayRestartRequestError(error);
351
367
  return;
352
368
  }
353
- if (!result.ok) {
369
+ closeSummaryModal();
370
+ if (!result?.ok) {
354
371
  alert('Failed to queue relay restart');
355
372
  return;
356
373
  }
@@ -0,0 +1,187 @@
1
+ // Agent-to-agent exchanges in the transcript: a prompt another agent sent this
2
+ // session (another relay's agent, or an agent on this relay) folds into ONE
3
+ // row, "note from win-test · report builder · answered below", which opens to
4
+ // the prompt as it renders otherwise.
5
+ //
6
+ // DOM-free on purpose, like relay-event-view.mjs: conversation-view.js reads
7
+ // the rows off the transcript, plans with this module and applies the plan.
8
+ // Presentation only: the transcript data is never touched.
9
+ //
10
+ // Nothing addressed to the user is folded away. The fold covers the agent's
11
+ // prompt and nothing else: this session's reply to it can carry a line for
12
+ // the user, so the reply, the live bubble of the running turn and every card
13
+ // of the turn always show, as they do for a message of the user's own. The
14
+ // row still says how the turn stands.
15
+ //
16
+ // One prompt is left as it is: an agent's prompt that was steered into a
17
+ // running turn. It renders as part of that turn's flow (steered-turn-merge.mjs:
18
+ // the dashed bubble, the marker attached under it), and a row in its place
19
+ // would break that flow.
20
+
21
+ import { isRelayEventMessage } from './relay-event-view.mjs';
22
+ import { relayErrorCodeFromText } from './relay-error-ctas.mjs';
23
+ import { isLocalAgentOrigin, localAgentOriginModel, normalizeRemoteOrigin } from './remote-relay-origin-view.mjs';
24
+
25
+ export const AGENT_EXCHANGE_HEAD_CLASS = 'agent-exchange-head';
26
+ export const AGENT_EXCHANGE_CLOSED_CLASS = 'agent-exchange-closed';
27
+
28
+ export const AGENT_EXCHANGE_STATUS_TEXT = Object.freeze({
29
+ working: 'working…',
30
+ replied: 'answered below',
31
+ failed: 'failed',
32
+ stopped: 'stopped',
33
+ none: 'no reply yet',
34
+ });
35
+
36
+ // The code a turn the user stopped ends with (shared/relay-stop-failure.mjs).
37
+ const STOPPED_ERROR_CODE = 'turn-aborted';
38
+ // A steer's settle markers (steered-turn-merge.mjs): the message was consumed
39
+ // by another turn, folded into it or stopped with it.
40
+ const STEER_MARKER_KINDS = new Set(['folded', 'stopped']);
41
+
42
+ function defaultEscape(value) {
43
+ return String(value ?? '')
44
+ .replace(/&/g, '&amp;')
45
+ .replace(/</g, '&lt;')
46
+ .replace(/>/g, '&gt;')
47
+ .replace(/"/g, '&quot;')
48
+ .replace(/'/g, '&#39;');
49
+ }
50
+
51
+ function oneLine(value) {
52
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
53
+ }
54
+
55
+ /** True for an `origin` that names an agent: one on this relay or another relay's. */
56
+ export function isAgentOrigin(origin) {
57
+ return isLocalAgentOrigin(origin) || !!normalizeRemoteOrigin(origin);
58
+ }
59
+
60
+ /** A prompt an agent sent: a user row with an agent's origin, never a relay event. */
61
+ export function isAgentPromptMessage(message) {
62
+ return message?.role === 'user' && !isRelayEventMessage(message) && isAgentOrigin(message?.origin);
63
+ }
64
+
65
+ /**
66
+ * How a reply ended, from its text: 'failed' or 'stopped' for a failure note
67
+ * of the relay, '' for an answer. The note goes below what the turn had
68
+ * written before it failed, so only the last paragraph is read.
69
+ */
70
+ export function replyOutcome(text) {
71
+ const paragraphs = String(text ?? '').trim().split(/\r?\n\s*\r?\n/);
72
+ const code = relayErrorCodeFromText(paragraphs[paragraphs.length - 1]);
73
+ if (!code) return '';
74
+ return code === STOPPED_ERROR_CODE ? 'stopped' : 'failed';
75
+ }
76
+
77
+ /**
78
+ * What the plan needs of one message, built when its row is: an agent
79
+ * prompt's origin, an assistant row's kind, the prompt it answers and how it
80
+ * ended.
81
+ */
82
+ export function agentExchangeRowOf(message, { id = message?.id } = {}) {
83
+ const role = message?.role === 'assistant' ? 'assistant' : (message?.role === 'user' ? 'user' : '');
84
+ return {
85
+ id: String(id ?? '').trim(),
86
+ role,
87
+ relayEvent: isRelayEventMessage(message),
88
+ origin: isAgentPromptMessage(message) ? message.origin : null,
89
+ kind: String(message?.kind || '').trim(),
90
+ sourceMessageId: role === 'assistant' ? String(message?.sourceMessageId || '').trim() : '',
91
+ outcome: role === 'assistant' ? replyOutcome(message?.text) : '',
92
+ };
93
+ }
94
+
95
+ /**
96
+ * The plan for a transcript: one entry per agent prompt, in transcript order.
97
+ *
98
+ * `rows` are the transcript's rows in order (agentExchangeRowOf, plus
99
+ * `steered` for a user row steered-turn-merge.mjs classed as steered), and
100
+ * the live bubble of a running turn as `{ id: <its message id>, role:
101
+ * 'assistant', live: true }`. `runningIds` names turns that run without a
102
+ * live bubble on the page.
103
+ *
104
+ * Each entry: `{ promptId, prompt, status, fold }`. The prompt's row is the
105
+ * only one a fold covers; the replies and the live bubble are read for the
106
+ * status alone. `fold` is false for a prompt steered into a running turn.
107
+ */
108
+ export function planAgentExchangeFolds(rows, { runningIds = [] } = {}) {
109
+ const list = (Array.isArray(rows) ? rows : []).filter((row) => row && String(row.id || '').trim());
110
+ const running = new Set(Array.from(runningIds || [], (value) => String(value ?? '').trim()).filter(Boolean));
111
+
112
+ const turns = [];
113
+ const turnById = new Map();
114
+ for (const row of list) {
115
+ if (row.role !== 'user' || !row.origin || turnById.has(row.id)) continue;
116
+ const turn = { prompt: row, replies: [], live: false };
117
+ turns.push(turn);
118
+ turnById.set(row.id, turn);
119
+ }
120
+ for (const row of list) {
121
+ if (row.role !== 'assistant') continue;
122
+ const turn = turnById.get(row.live ? row.id : row.sourceMessageId);
123
+ if (!turn) continue;
124
+ if (row.live) turn.live = true;
125
+ else turn.replies.push(row);
126
+ }
127
+
128
+ return turns.map((turn) => {
129
+ const markers = turn.replies.filter((row) => STEER_MARKER_KINDS.has(row.kind));
130
+ // An answer, or the marker that the answer is on a continuation row
131
+ // further down.
132
+ const answers = turn.replies.filter((row) => !STEER_MARKER_KINDS.has(row.kind));
133
+ const last = answers[answers.length - 1] || null;
134
+ let status = 'none';
135
+ if (last) status = last.outcome || 'replied';
136
+ else if (turn.live || running.has(turn.prompt.id)) status = 'working';
137
+ return {
138
+ promptId: turn.prompt.id,
139
+ prompt: turn.prompt,
140
+ status,
141
+ fold: !turn.prompt.steered && !markers.length,
142
+ };
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Whether a folded prompt shows open. `choice` is the user's own toggle of
148
+ * it (true opened, false closed, undefined none) and always wins; without
149
+ * one it is open while the turn works and closes by itself once it is
150
+ * replied, failed or stopped.
151
+ */
152
+ export function agentExchangeFoldOpen(status, choice) {
153
+ if (typeof choice === 'boolean') return choice;
154
+ return status === 'working';
155
+ }
156
+
157
+ /**
158
+ * The folded row's words: who sent the prompt (the relay's name; for an agent
159
+ * on this relay the name the origin carries, else "this relay"), from which
160
+ * session (for a local one its current title when `conversations` knows it),
161
+ * and how the turn stands.
162
+ */
163
+ export function agentExchangeSummary(origin, status, { conversations = null } = {}) {
164
+ const local = localAgentOriginModel(origin, { conversations, linkable: false });
165
+ const remote = local ? null : normalizeRemoteOrigin(origin);
166
+ const from = local ? (oneLine(origin?.relayName) || 'this relay') : (remote?.relayName || 'another relay');
167
+ const title = local ? local.fullTitle : (remote?.conversationTitle || '');
168
+ const known = Object.prototype.hasOwnProperty.call(AGENT_EXCHANGE_STATUS_TEXT, status) ? status : 'none';
169
+ const head = [`note from ${from}`, title].filter(Boolean).join(' · ');
170
+ const statusText = AGENT_EXCHANGE_STATUS_TEXT[known];
171
+ return { from, title, status: known, statusText, head, text: `${head} · ${statusText}` };
172
+ }
173
+
174
+ /**
175
+ * The folded row's markup: a <details> whose summary is the whole row. Its
176
+ * body stays empty — the prompt's bubble is its sibling in the prompt's own
177
+ * node, shown while it is open.
178
+ */
179
+ export function renderAgentExchangeRowHtml(summary, { escHtml = defaultEscape, timeText = '', open = false } = {}) {
180
+ return `<details class="agent-exchange-row agent-exchange-${escHtml(summary?.status || 'none')}"${open ? ' open' : ''}>`
181
+ + `<summary class="agent-exchange-summary" title="${escHtml(summary?.text)}">`
182
+ + `<span class="agent-exchange-text">${escHtml(summary?.head)}</span>`
183
+ + `<span class="agent-exchange-status">${escHtml(summary?.statusText)}</span>`
184
+ + (timeText ? `<span class="agent-exchange-time">${escHtml(timeText)}</span>` : '')
185
+ + '</summary>'
186
+ + '</details>';
187
+ }