@oar-sh/oar 0.9.3 → 0.9.4

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 (68) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +67 -0
  4. package/README.md +62 -13
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +92 -25
  10. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  11. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  12. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  13. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  14. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  15. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  16. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  17. package/server/db-schema.mjs +8 -0
  18. package/server/grok-worker/acp-client.mjs +92 -6
  19. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  20. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  21. package/server/mcp/oar-mcp-server.mjs +473 -0
  22. package/server/migrations/0005-remote-relays.mjs +90 -0
  23. package/server/public/app/action-confirmations.js +141 -13
  24. package/server/public/app/api-client.js +103 -0
  25. package/server/public/app/bootstrap.js +21 -6
  26. package/server/public/app/conversation-deep-link.mjs +42 -0
  27. package/server/public/app/conversation-view.js +98 -7
  28. package/server/public/app/host-suspend-ui.js +272 -0
  29. package/server/public/app/journal-view.js +4 -1
  30. package/server/public/app/mention-autocomplete.mjs +298 -0
  31. package/server/public/app/push-settings.js +1 -0
  32. package/server/public/app/pwa-install.js +8 -3
  33. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  34. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  35. package/server/public/app/remote-relay-shared.mjs +85 -0
  36. package/server/public/app/remote-relays-settings.js +618 -0
  37. package/server/public/app/remote-relays-store.mjs +126 -0
  38. package/server/public/app/settings-modal.js +4 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +18 -1
  41. package/server/public/app/thinking-follow.js +260 -0
  42. package/server/public/app/tool-activity-emoji.mjs +4 -0
  43. package/server/public/index.html +257 -8
  44. package/server/relay-tools.md +5 -1
  45. package/server/repositories/message-repository.mjs +7 -0
  46. package/server/repositories/question-repository.mjs +3 -0
  47. package/server/repositories/remote-relay-repository.mjs +75 -0
  48. package/server/repositories/session-repository.mjs +6 -1
  49. package/server/routes/ask-user-routes.mjs +42 -2
  50. package/server/routes/messages-routes.mjs +90 -16
  51. package/server/routes/remote-relay-routes.mjs +133 -0
  52. package/server/routes/sessions-routes.mjs +80 -29
  53. package/server/server-runtime.mjs +238 -0
  54. package/server/services/github-ci-activity-service.mjs +199 -0
  55. package/server/services/host-suspend-command.mjs +38 -0
  56. package/server/services/host-suspend-service.mjs +254 -0
  57. package/server/services/push-dispatch-service.mjs +37 -4
  58. package/server/services/remote-relay-caller-context.mjs +341 -0
  59. package/server/services/remote-relay-client.mjs +332 -0
  60. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  61. package/server/services/remote-relay-inbound.mjs +187 -0
  62. package/server/services/remote-relay-pairing.mjs +189 -0
  63. package/server/services/remote-relay-registry-service.mjs +581 -0
  64. package/server/services/session-worker-launch-service.mjs +131 -12
  65. package/shared/remote-relay-contract.mjs +539 -0
  66. package/shared/remote-relay-mentions.mjs +129 -0
  67. package/shared/remote-relay-tool-core.mjs +314 -0
  68. package/shared/worker-runtime/api-client.mjs +112 -22
@@ -0,0 +1,272 @@
1
+ // Pending host suspend / relay restart, as every device sees it: an app-wide
2
+ // banner with a countdown and Cancel, plus the chat-actions menu labels.
3
+ // State arrives over the socket (`host_suspend_state`, `relay_shutdown_state`)
4
+ // and is also re-read from GET /api/status polls, so a client that missed an
5
+ // event still catches up. The banner ignores the per-device "Show Suspend
6
+ // host action" setting on purpose: a device with the action hidden must still
7
+ // be able to see and cancel a queued suspend.
8
+
9
+ import { escHtml, showTransientRelayNotice } from './store.js';
10
+ import { cancelHostSuspend, cancelRelayRestart } from './api-client.js';
11
+
12
+ const BANNER_ID = 'pending-action-banner';
13
+ const SUSPEND_MENU_ID = 'chat-menu-suspend-host';
14
+ const RESTART_MENU_ID = 'chat-menu-restart-relay';
15
+ const SUSPEND_MENU_LABEL = '💤 Suspend host';
16
+ const SUSPEND_MENU_PENDING_LABEL = '💤 Suspend pending…';
17
+ const RESTART_MENU_LABEL = '🌄 Restart web relay';
18
+ const RESTART_MENU_PENDING_LABEL = '🌄 Restart pending…';
19
+
20
+ let hostSuspendState = null;
21
+ let relayShutdownState = null;
22
+ let countdownTimer = null;
23
+ let cancelInFlight = false;
24
+
25
+ export function getHostSuspendState() {
26
+ return hostSuspendState;
27
+ }
28
+
29
+ export function getRelayShutdownState() {
30
+ return relayShutdownState;
31
+ }
32
+
33
+ export function isHostSuspendPending(state = hostSuspendState) {
34
+ const status = String(state?.status || '').trim();
35
+ return status === 'queued' || status === 'countdown' || status === 'suspending';
36
+ }
37
+
38
+ export function isRelayShutdownPending(state = relayShutdownState) {
39
+ const status = String(state?.status || '').trim();
40
+ return status === 'queued' || status === 'shutting_down';
41
+ }
42
+
43
+ /** "0:27" style remaining time from a fireAt ISO string; null when not counting. */
44
+ export function formatCountdown(fireAt, nowMs = Date.now()) {
45
+ const target = Date.parse(String(fireAt || ''));
46
+ if (!Number.isFinite(target)) return null;
47
+ const remaining = Math.max(0, Math.ceil((target - nowMs) / 1000));
48
+ const minutes = Math.floor(remaining / 60);
49
+ const seconds = remaining % 60;
50
+ return `${minutes}:${String(seconds).padStart(2, '0')}`;
51
+ }
52
+
53
+ /** One human line per blocker, e.g. "sidebar polish — 2 background agents: …". */
54
+ export function describeHostSuspendBlocker(blocker) {
55
+ if (!blocker || typeof blocker !== 'object') return '';
56
+ const title = String(blocker.title || '').trim();
57
+ const detail = String(blocker.detail || '').trim();
58
+ switch (String(blocker.kind || '')) {
59
+ case 'turn':
60
+ return `${title || 'a session'} — ${detail || 'turn running'}`;
61
+ case 'background':
62
+ return `${title || 'a session'} — ${detail || 'background agents running'}`;
63
+ case 'ci':
64
+ return `CI ${title ? `in ${title}` : ''} — ${detail || 'runs open'}`.replace(/\s+—/, ' —');
65
+ case 'ci-unknown':
66
+ return detail || `CI state of ${title || 'a repository'} unknown`;
67
+ case 'ci-checking':
68
+ return detail || 'Checking CI state';
69
+ case 'error':
70
+ return detail || 'Activity check failed';
71
+ default:
72
+ return [title, detail].filter(Boolean).join(' — ');
73
+ }
74
+ }
75
+
76
+ /** Short banner summary: first two blockers, "+N more". */
77
+ export function summarizeHostSuspendBlockers(blockers) {
78
+ const list = (Array.isArray(blockers) ? blockers : []).map(describeHostSuspendBlocker).filter(Boolean);
79
+ if (!list.length) return '';
80
+ const shown = list.slice(0, 2);
81
+ const rest = list.length - shown.length;
82
+ return shown.join('; ') + (rest > 0 ? `; +${rest} more` : '');
83
+ }
84
+
85
+ export function hostSuspendBannerText(state, nowMs = Date.now()) {
86
+ if (!isHostSuspendPending(state)) return '';
87
+ const status = String(state.status || '');
88
+ if (status === 'suspending') return '💤 Suspending host now…';
89
+ if (status === 'countdown') {
90
+ const countdown = formatCountdown(state.fireAt, nowMs);
91
+ return countdown ? `💤 Everything is idle — suspending host in ${countdown}` : '💤 Everything is idle — suspending host shortly';
92
+ }
93
+ const summary = summarizeHostSuspendBlockers(state.blockers);
94
+ return summary
95
+ ? `💤 Suspend queued — waiting for: ${summary}`
96
+ : '💤 Suspend queued — waiting for running agents to finish';
97
+ }
98
+
99
+ export function relayShutdownBannerText(state) {
100
+ if (!isRelayShutdownPending(state)) return '';
101
+ const action = state.action === 'restart' || state.restart ? 'restart' : 'shutdown';
102
+ const icon = action === 'restart' ? '🌄' : '⏻';
103
+ if (String(state.status || '') === 'shutting_down') {
104
+ return `${icon} Relay ${action === 'restart' ? 'restarting' : 'shutting down'} now…`;
105
+ }
106
+ const queue = state.queue || {};
107
+ const turns = Number(queue.pendingCount || 0) + Number(queue.processingCount || 0) + Number(queue.parkedCount || 0);
108
+ const waiting = turns > 0 ? `waiting for ${turns} turn${turns === 1 ? '' : 's'} to finish` : 'queue is idle, going down now';
109
+ return `${icon} Relay ${action} queued — ${waiting}`;
110
+ }
111
+
112
+ function ensureBanner() {
113
+ let banner = document.getElementById(BANNER_ID);
114
+ if (banner) return banner;
115
+ banner = document.createElement('div');
116
+ banner.id = BANNER_ID;
117
+ banner.setAttribute('role', 'status');
118
+ banner.setAttribute('aria-live', 'polite');
119
+ document.body?.appendChild(banner);
120
+ return banner;
121
+ }
122
+
123
+ function bannerRow({ text, cancelLabel, onCancel, key }) {
124
+ const row = document.createElement('div');
125
+ row.className = 'pending-action-row';
126
+ row.dataset.pending = key;
127
+ const label = document.createElement('span');
128
+ label.className = 'pending-action-text';
129
+ label.textContent = text;
130
+ row.appendChild(label);
131
+ if (cancelLabel && typeof onCancel === 'function') {
132
+ const btn = document.createElement('button');
133
+ btn.type = 'button';
134
+ btn.className = 'relay-toast-action pending-action-cancel';
135
+ btn.textContent = cancelLabel;
136
+ btn.disabled = cancelInFlight;
137
+ btn.addEventListener('click', (event) => {
138
+ event.preventDefault();
139
+ event.stopPropagation();
140
+ void onCancel();
141
+ });
142
+ row.appendChild(btn);
143
+ }
144
+ return row;
145
+ }
146
+
147
+ async function runCancelHostSuspend() {
148
+ if (cancelInFlight) return;
149
+ cancelInFlight = true;
150
+ renderPendingActionBanner();
151
+ try {
152
+ const result = await cancelHostSuspend();
153
+ if (!result?.ok) {
154
+ showTransientRelayNotice('Failed to cancel the queued suspend.', 5000);
155
+ return;
156
+ }
157
+ if (result.state) applyHostSuspendState(result.state);
158
+ showTransientRelayNotice(result.cancelled ? 'Queued host suspend cancelled.' : 'No host suspend was pending.', 4000);
159
+ } finally {
160
+ cancelInFlight = false;
161
+ renderPendingActionBanner();
162
+ }
163
+ }
164
+
165
+ async function runCancelRelayShutdown() {
166
+ if (cancelInFlight) return;
167
+ cancelInFlight = true;
168
+ renderPendingActionBanner();
169
+ try {
170
+ const result = await cancelRelayRestart();
171
+ if (!result?.ok) {
172
+ showTransientRelayNotice('Failed to cancel the queued relay restart.', 5000);
173
+ return;
174
+ }
175
+ applyRelayShutdownState(result);
176
+ showTransientRelayNotice(result.cancelled ? 'Queued relay restart cancelled.' : 'No relay restart was pending.', 4000);
177
+ } finally {
178
+ cancelInFlight = false;
179
+ renderPendingActionBanner();
180
+ }
181
+ }
182
+
183
+ function syncCountdownTimer() {
184
+ const needsTicker = isHostSuspendPending() && String(hostSuspendState?.status || '') === 'countdown' && hostSuspendState?.fireAt;
185
+ if (needsTicker && !countdownTimer) {
186
+ countdownTimer = setInterval(() => renderPendingActionBanner(), 1000);
187
+ } else if (!needsTicker && countdownTimer) {
188
+ clearInterval(countdownTimer);
189
+ countdownTimer = null;
190
+ }
191
+ }
192
+
193
+ export function renderPendingActionBanner() {
194
+ if (typeof document === 'undefined') return;
195
+ const rows = [];
196
+ if (isHostSuspendPending()) {
197
+ rows.push(bannerRow({
198
+ key: 'host-suspend',
199
+ text: hostSuspendBannerText(hostSuspendState),
200
+ cancelLabel: String(hostSuspendState?.status || '') === 'suspending' ? '' : 'Cancel',
201
+ onCancel: runCancelHostSuspend,
202
+ }));
203
+ }
204
+ if (isRelayShutdownPending()) {
205
+ const restarting = String(relayShutdownState?.status || '') === 'shutting_down';
206
+ rows.push(bannerRow({
207
+ key: 'relay-shutdown',
208
+ text: relayShutdownBannerText(relayShutdownState),
209
+ cancelLabel: restarting ? '' : 'Cancel',
210
+ onCancel: runCancelRelayShutdown,
211
+ }));
212
+ }
213
+ const banner = document.getElementById(BANNER_ID) || (rows.length ? ensureBanner() : null);
214
+ if (!banner) return;
215
+ banner.replaceChildren(...rows);
216
+ banner.classList.toggle('visible', rows.length > 0);
217
+ banner.hidden = rows.length === 0;
218
+ syncMenuLabels();
219
+ syncCountdownTimer();
220
+ }
221
+
222
+ function syncMenuLabels() {
223
+ const suspendBtn = document.getElementById(SUSPEND_MENU_ID);
224
+ if (suspendBtn) suspendBtn.textContent = isHostSuspendPending() ? SUSPEND_MENU_PENDING_LABEL : SUSPEND_MENU_LABEL;
225
+ const restartBtn = document.getElementById(RESTART_MENU_ID);
226
+ if (restartBtn) restartBtn.textContent = isRelayShutdownPending() ? RESTART_MENU_PENDING_LABEL : RESTART_MENU_LABEL;
227
+ }
228
+
229
+ export function applyHostSuspendState(state) {
230
+ const previous = hostSuspendState;
231
+ hostSuspendState = state && typeof state === 'object' ? state : null;
232
+ if (isHostSuspendPending(previous) && !isHostSuspendPending(hostSuspendState) && hostSuspendState?.lastError) {
233
+ showTransientRelayNotice(`Host suspend failed: ${hostSuspendState.lastError}`, 8000);
234
+ }
235
+ renderPendingActionBanner();
236
+ }
237
+
238
+ export function applyRelayShutdownState(state) {
239
+ relayShutdownState = state && typeof state === 'object' ? state : null;
240
+ renderPendingActionBanner();
241
+ }
242
+
243
+ /** GET /api/status carries both states; the poll keeps late clients honest. */
244
+ export function applyPendingActionsFromStatus(status) {
245
+ if (!status || typeof status !== 'object') return;
246
+ if ('hostSuspend' in status) hostSuspendState = status.hostSuspend || null;
247
+ if ('relayShutdown' in status) {
248
+ const next = status.relayShutdown ? { ...status.relayShutdown } : null;
249
+ if (next && !next.queue) {
250
+ next.queue = {
251
+ pendingCount: Number(status.pendingCount || 0),
252
+ processingCount: Number(status.processingCount || 0),
253
+ parkedCount: Number(status.parkedCount || 0),
254
+ };
255
+ }
256
+ relayShutdownState = next;
257
+ }
258
+ renderPendingActionBanner();
259
+ }
260
+
261
+ export function initHostSuspendUi() {
262
+ renderPendingActionBanner();
263
+ }
264
+
265
+ export function __resetHostSuspendUiForTests() {
266
+ hostSuspendState = null;
267
+ relayShutdownState = null;
268
+ cancelInFlight = false;
269
+ if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
270
+ document.getElementById(BANNER_ID)?.remove();
271
+ syncMenuLabels();
272
+ }
@@ -58,6 +58,7 @@ import {
58
58
  conversationProviderIndicatorLabel,
59
59
  } from './conversation-provider-indicator.mjs';
60
60
  import { leaveStatusView } from './status-view.mjs';
61
+ import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.mjs';
61
62
  import {
62
63
  normalizeConversationFilter,
63
64
  filterConversations,
@@ -349,10 +350,12 @@ export function renderConvList() {
349
350
  const providerIndicatorHtml = providerIndicatorLabel
350
351
  ? `<span class="conv-provider-indicator"${providerIndicatorKey ? ` data-provider="${providerIndicatorKey}"` : ''}>${providerIndicatorLabel}</span>`
351
352
  : '';
353
+ // "↗ win-test": another relay's agent created this conversation.
354
+ const originMarkerHtml = renderConversationOriginMarkerHtml(c);
352
355
  return `
353
356
  <div class="conv-item worker-ui-${view.visualState}${c.id === currentConvId ? ' active' : ''}" onclick="openConversation('${c.id}')">
354
357
  <div class="conv-title">${escHtml(c.title)}${processingDots ? `<span class="conv-processing-dots">${escHtml(` ${processingDots}`)}</span>` : ''}${c.archived ? ' <span style="font-size:0.68rem;color:var(--muted)">(archived)</span>' : ''}${pendingByConversation[c.id] ? ` <span class="conv-open-questions">${pendingByConversation[c.id]} open</span>` : ''}</div>
355
- <div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${providerIndicatorHtml}</div>
358
+ <div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${originMarkerHtml}${providerIndicatorHtml}</div>
356
359
  <button class="conv-delete" onclick="deleteConv(event,'${c.id}')" title="Delete">🗑</button>
357
360
  </div>`;
358
361
  }).join('')}${footerHtml}`;
@@ -0,0 +1,298 @@
1
+ // The composer's @mention popup for remote relays: typing "@" at the start of a
2
+ // word lists the relays this relay is paired with, and picking one inserts
3
+ // "@name " — the mention that unlocks that relay for the conversation's agent
4
+ // (docs/plans/2026-09-27-remote-relays.md, decisions 9 and 10).
5
+ //
6
+ // A sibling of slash-autocomplete.mjs with the same key contract: nothing is
7
+ // highlighted by default, so plain Enter keeps its newline and Ctrl/Cmd+Enter
8
+ // keeps sending; only an arrow-selected row makes Enter accept; Tab accepts
9
+ // the highlighted row or the top one. The popup closes as soon as the typed
10
+ // token matches no relay — "@file:" and friends are never touched — and it
11
+ // never opens while the slash menu is open, so exactly one popup owns the keys.
12
+
13
+ import { mentionQueryAt, mentionTokenEnd } from './remote-relay-shared.mjs';
14
+ import {
15
+ ensureRemoteRelaysLoaded,
16
+ getRemoteRelays,
17
+ getRemoteRelaysSnapshot,
18
+ remoteRelayStatus,
19
+ subscribeRemoteRelays,
20
+ } from './remote-relays-store.mjs';
21
+ import { isSlashAutocompleteOpen } from './slash-autocomplete.mjs';
22
+
23
+ const POPUP_ID = 'mention-autocomplete-popup';
24
+ const OPTION_ID_PREFIX = 'mention-autocomplete-option-';
25
+
26
+ let openState = null; // { items, start } while the popup is visible
27
+ let selectedIndex = -1;
28
+ // The composer that typed "@" before the list was loaded; the load re-runs it.
29
+ let pendingInput = null;
30
+
31
+ function popupEl() {
32
+ return document.getElementById(POPUP_ID);
33
+ }
34
+
35
+ function composerInput() {
36
+ return document.getElementById('msg-input');
37
+ }
38
+
39
+ function hostOf(relay) {
40
+ const explicit = String(relay?.host || '').trim().toLowerCase();
41
+ if (explicit) return explicit;
42
+ try {
43
+ return new URL(String(relay?.url || '')).hostname.toLowerCase();
44
+ } catch {
45
+ return '';
46
+ }
47
+ }
48
+
49
+ /**
50
+ * The popup rows for `query` (the text after "@", lowercase): relays whose name
51
+ * or URL host starts with it. `insert` is what a pick writes: "@name", or
52
+ * "@host" when the name is empty or shared by two relays (a shared name is
53
+ * ambiguous and would unlock nothing).
54
+ */
55
+ export function mentionCandidates(relays, query = '') {
56
+ const list = Array.isArray(relays) ? relays : [];
57
+ const wanted = String(query || '').trim().toLowerCase();
58
+ const nameCounts = new Map();
59
+ for (const relay of list) {
60
+ const key = String(relay?.name || '').trim().toLowerCase();
61
+ if (key) nameCounts.set(key, (nameCounts.get(key) || 0) + 1);
62
+ }
63
+ const items = [];
64
+ for (const relay of list) {
65
+ const id = String(relay?.id || '').trim();
66
+ const name = String(relay?.name || '').trim();
67
+ const host = hostOf(relay);
68
+ if (!id || (!name && !host)) continue;
69
+ if (wanted && !name.toLowerCase().startsWith(wanted) && !host.startsWith(wanted)) continue;
70
+ const ambiguous = !!name && nameCounts.get(name.toLowerCase()) > 1;
71
+ const insertName = (!name || ambiguous) && host ? host : name;
72
+ items.push({
73
+ id,
74
+ name: name || host,
75
+ host,
76
+ status: remoteRelayStatus(relay),
77
+ insert: `@${insertName}`,
78
+ });
79
+ }
80
+ return items;
81
+ }
82
+
83
+ export function isMentionAutocompleteOpen() {
84
+ return openState !== null;
85
+ }
86
+
87
+ function syncInputAria(input) {
88
+ const target = input || composerInput();
89
+ if (!target) return;
90
+ if (!openState) {
91
+ target.removeAttribute('aria-activedescendant');
92
+ target.setAttribute('aria-expanded', 'false');
93
+ return;
94
+ }
95
+ target.setAttribute('aria-controls', POPUP_ID);
96
+ target.setAttribute('aria-expanded', 'true');
97
+ if (selectedIndex >= 0) target.setAttribute('aria-activedescendant', `${OPTION_ID_PREFIX}${selectedIndex}`);
98
+ else target.removeAttribute('aria-activedescendant');
99
+ }
100
+
101
+ export function closeMentionAutocomplete() {
102
+ const wasOpen = openState !== null;
103
+ openState = null;
104
+ selectedIndex = -1;
105
+ const popup = popupEl();
106
+ if (popup) {
107
+ popup.classList.remove('visible');
108
+ popup.setAttribute('aria-hidden', 'true');
109
+ popup.textContent = '';
110
+ }
111
+ if (wasOpen) syncInputAria(null);
112
+ }
113
+
114
+ function renderPopup(input) {
115
+ const popup = popupEl();
116
+ if (!popup || !openState) return;
117
+ popup.textContent = '';
118
+ openState.items.forEach((item, index) => {
119
+ const row = document.createElement('div');
120
+ row.className = 'slash-item mention-item';
121
+ if (index === selectedIndex) row.classList.add('slash-item-selected');
122
+ row.id = `${OPTION_ID_PREFIX}${index}`;
123
+ row.setAttribute('role', 'option');
124
+ row.setAttribute('aria-selected', index === selectedIndex ? 'true' : 'false');
125
+ row.dataset.index = String(index);
126
+ row.dataset.relayId = item.id;
127
+
128
+ const dot = document.createElement('span');
129
+ dot.className = 'remote-relay-dot';
130
+ dot.dataset.status = item.status;
131
+ dot.setAttribute('aria-hidden', 'true');
132
+ row.appendChild(dot);
133
+
134
+ // textContent throughout: names come from other relays.
135
+ const nameEl = document.createElement('span');
136
+ nameEl.className = 'slash-item-name';
137
+ nameEl.textContent = item.name;
138
+ row.appendChild(nameEl);
139
+
140
+ if (item.host && item.host !== item.name.toLowerCase()) {
141
+ const hostEl = document.createElement('span');
142
+ hostEl.className = 'slash-item-desc';
143
+ hostEl.textContent = item.host;
144
+ row.appendChild(hostEl);
145
+ }
146
+ popup.appendChild(row);
147
+ });
148
+ popup.classList.add('visible');
149
+ popup.setAttribute('aria-hidden', 'false');
150
+ syncInputAria(input);
151
+ }
152
+
153
+ /**
154
+ * Recomputes the popup for the composer's text and caret. The composer calls
155
+ * this from its input handler, after the slash menu had its turn.
156
+ */
157
+ export function updateMentionAutocomplete(input) {
158
+ if (!input) return;
159
+ if (isSlashAutocompleteOpen()) {
160
+ pendingInput = null;
161
+ closeMentionAutocomplete();
162
+ return;
163
+ }
164
+ const text = String(input.value || '');
165
+ const caret = Number.isFinite(input.selectionStart) ? input.selectionStart : text.length;
166
+ const selectionEnd = Number.isFinite(input.selectionEnd) ? input.selectionEnd : caret;
167
+ const query = selectionEnd === caret ? mentionQueryAt(text, caret) : null;
168
+ if (!query) {
169
+ pendingInput = null;
170
+ closeMentionAutocomplete();
171
+ return;
172
+ }
173
+ if (!getRemoteRelaysSnapshot()) {
174
+ // First "@" of the page: fetch the list, and come back once it is here.
175
+ pendingInput = input;
176
+ closeMentionAutocomplete();
177
+ void ensureRemoteRelaysLoaded();
178
+ return;
179
+ }
180
+ pendingInput = null;
181
+ const items = mentionCandidates(getRemoteRelays(), query.query);
182
+ if (!items.length) {
183
+ closeMentionAutocomplete();
184
+ return;
185
+ }
186
+ // A rebuild replaces the rows, so a highlight only survives on the same relay.
187
+ const previous = selectedIndex >= 0 ? openState?.items?.[selectedIndex] : null;
188
+ openState = { items, start: query.start };
189
+ selectedIndex = previous ? items.findIndex((item) => item.id === previous.id) : -1;
190
+ renderPopup(input);
191
+ }
192
+
193
+ function acceptItem(input, index) {
194
+ const item = openState?.items?.[index];
195
+ if (!item || !input) return false;
196
+ const value = String(input.value || '');
197
+ const caret = Number.isFinite(input.selectionStart) ? input.selectionStart : value.length;
198
+ // Re-read the token at the caret: the caret may have moved since the popup
199
+ // was drawn, and a stale range must never overwrite unrelated text.
200
+ const query = mentionQueryAt(value, caret);
201
+ if (!query || query.start !== openState.start) {
202
+ closeMentionAutocomplete();
203
+ return false;
204
+ }
205
+ const end = mentionTokenEnd(value, caret);
206
+ const after = value.slice(end);
207
+ const inserted = /^\s/.test(after) ? item.insert : `${item.insert} `;
208
+ input.value = `${value.slice(0, query.start)}${inserted}${after}`;
209
+ const nextCaret = query.start + item.insert.length + 1;
210
+ input.selectionStart = nextCaret;
211
+ input.selectionEnd = nextCaret;
212
+ closeMentionAutocomplete();
213
+ input.focus();
214
+ // Re-runs the composer's input pipeline (autoResize, draft sync, the menus).
215
+ input.dispatchEvent(new Event('input', { bubbles: true }));
216
+ return true;
217
+ }
218
+
219
+ /**
220
+ * Offers a keydown to the popup. Returns true when consumed; the composer's
221
+ * handleKey calls this after the slash menu's handler and stops on true.
222
+ */
223
+ export function handleMentionAutocompleteKey(event, input) {
224
+ if (!openState || event?.isComposing) return false;
225
+
226
+ if (event.key === 'Escape') {
227
+ closeMentionAutocomplete();
228
+ event.preventDefault();
229
+ // Swallowed so document-level Escape handlers (modals, pickers) stay put.
230
+ event.stopPropagation();
231
+ return true;
232
+ }
233
+
234
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
235
+ const count = openState.items.length;
236
+ if (!count) return false;
237
+ if (event.key === 'ArrowDown') {
238
+ selectedIndex = selectedIndex >= count - 1 ? 0 : selectedIndex + 1;
239
+ } else {
240
+ // ↑ from the top row returns to "nothing highlighted", so plain Enter's
241
+ // newline stays reachable without Escape.
242
+ selectedIndex = selectedIndex <= 0 ? -1 : selectedIndex - 1;
243
+ }
244
+ renderPopup(input);
245
+ event.preventDefault();
246
+ return true;
247
+ }
248
+
249
+ if (event.key === 'Tab' && !event.ctrlKey && !event.metaKey && !event.altKey) {
250
+ if (!openState.items.length) return false;
251
+ // A pick the caret no longer supports closes the popup and lets the key through.
252
+ if (!acceptItem(input, selectedIndex >= 0 ? selectedIndex : 0)) return false;
253
+ event.preventDefault();
254
+ return true;
255
+ }
256
+
257
+ if (event.key === 'Enter' && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
258
+ if (selectedIndex < 0) return false;
259
+ if (!acceptItem(input, selectedIndex)) return false;
260
+ event.preventDefault();
261
+ return true;
262
+ }
263
+
264
+ return false;
265
+ }
266
+
267
+ export function initMentionAutocomplete() {
268
+ const popup = popupEl();
269
+ if (!popup || popup.dataset.bound === '1') return;
270
+ popup.dataset.bound = '1';
271
+ // pointerdown, not click: the textarea loses focus on the tap, and a click
272
+ // handler would race the focus-driven close.
273
+ popup.addEventListener('pointerdown', (event) => {
274
+ const row = event.target?.closest?.('.mention-item');
275
+ if (!row || !openState) return;
276
+ event.preventDefault();
277
+ const input = composerInput();
278
+ if (input) acceptItem(input, Number(row.dataset.index));
279
+ });
280
+ document.addEventListener('pointerdown', (event) => {
281
+ if (!openState) return;
282
+ const target = event.target;
283
+ if (popupEl()?.contains(target)) return;
284
+ if (composerInput()?.contains?.(target)) return;
285
+ closeMentionAutocomplete();
286
+ });
287
+ // The list arrived (lazy load) or changed (remote_relays_updated): redraw
288
+ // for the composer that is waiting on it or showing it.
289
+ subscribeRemoteRelays(() => {
290
+ const waiting = pendingInput;
291
+ pendingInput = null;
292
+ if (waiting && document.activeElement === waiting) {
293
+ updateMentionAutocomplete(waiting);
294
+ return;
295
+ }
296
+ if (openState) updateMentionAutocomplete(composerInput());
297
+ });
298
+ }
@@ -10,6 +10,7 @@ const PUSH_EVENT_CONTROL_IDS = {
10
10
  turnFailed: 'push-event-turn-failed-toggle',
11
11
  board: 'push-event-board-toggle',
12
12
  cliOffline: 'push-event-cli-offline-toggle',
13
+ hostSuspend: 'push-event-host-suspend-toggle',
13
14
  };
14
15
 
15
16
  let pushUpdateInFlight = false;
@@ -306,6 +306,8 @@ export function initFullscreenButton() {
306
306
  // The app name is stored on the relay and baked into /manifest.webmanifest by
307
307
  // the server, so every manifest fetch — including Android's out-of-page WebAPK
308
308
  // update checks — sees the same name. The client only edits the setting.
309
+ // Settings shows it as "Relay name": it is also the name paired relays know
310
+ // this one by (and mention it by).
309
311
 
310
312
  function paintPwaAppNameInput() {
311
313
  const input = document.getElementById('pwa-app-name-input');
@@ -330,16 +332,19 @@ export async function updatePwaAppName(rawValue) {
330
332
  const seq = ++pwaAppNameRequestSeq;
331
333
  const result = await updatePwaAppNameSetting(rawValue);
332
334
  if (!result || typeof result.appName !== 'string') {
333
- alert('Failed to update the install app name.');
335
+ alert('Failed to update the relay name.');
334
336
  if (seq === pwaAppNameRequestSeq) paintPwaAppNameInput();
335
337
  return;
336
338
  }
337
339
  if (seq !== pwaAppNameRequestSeq) return; // a newer edit owns the UI now
338
340
  pwaAppName = result.appName;
339
341
  showTransientRelayNotice(result.appName
340
- ? `Install app name updated to "${result.appName}".`
341
- : 'Install app name reset to default.');
342
+ ? `Relay name updated to "${result.appName}".`
343
+ : 'Relay name reset to default.');
342
344
  paintPwaAppNameInput();
345
+ // The same name is what paired relays see (GET /api/relay/identity); the
346
+ // Relays tab re-reads it, including the host-name fallback for an empty one.
347
+ window.dispatchEvent(new CustomEvent('oar:relay-name-changed', { detail: { name: result.appName } }));
343
348
  }
344
349
 
345
350
  /**