@oar-sh/oar 0.9.2 → 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 (138) 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 +336 -0
  4. package/README.md +455 -423
  5. package/bin/oar.js +25 -10
  6. package/package.json +11 -5
  7. package/server/README.md +44 -4
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  10. package/server/claude-worker/claude-session-process.mjs +1073 -69
  11. package/server/claude-worker/claude-session-worker.mjs +45 -4
  12. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  13. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  15. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  16. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  17. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  18. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  19. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
  21. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  22. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  23. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  24. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  25. package/server/db-schema.mjs +28 -0
  26. package/server/grok-worker/acp-client.mjs +92 -6
  27. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  28. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  29. package/server/mcp/oar-mcp-server.mjs +473 -0
  30. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  31. package/server/migrations/0005-remote-relays.mjs +90 -0
  32. package/server/public/app/action-confirmations.js +141 -13
  33. package/server/public/app/api-client.js +125 -2
  34. package/server/public/app/ask-user-view.js +102 -13
  35. package/server/public/app/attachments-view.js +37 -2
  36. package/server/public/app/background-task-timeout-options.mjs +9 -0
  37. package/server/public/app/background-tasks-view.mjs +33 -18
  38. package/server/public/app/bootstrap.js +55 -14
  39. package/server/public/app/composer-control-state.mjs +52 -34
  40. package/server/public/app/conversation-deep-link.mjs +42 -0
  41. package/server/public/app/conversation-draft-sync.mjs +187 -0
  42. package/server/public/app/conversation-list-filter.mjs +86 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  44. package/server/public/app/conversation-view.js +1016 -200
  45. package/server/public/app/emoji-view.js +2 -0
  46. package/server/public/app/font-scaling.js +4 -37
  47. package/server/public/app/host-suspend-ui.js +272 -0
  48. package/server/public/app/journal-view.js +149 -6
  49. package/server/public/app/mention-autocomplete.mjs +298 -0
  50. package/server/public/app/model-selector-options.mjs +1 -1
  51. package/server/public/app/push-settings.js +1 -0
  52. package/server/public/app/pwa-install.js +8 -3
  53. package/server/public/app/question-multi-select.mjs +52 -0
  54. package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
  55. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  56. package/server/public/app/remote-relay-shared.mjs +85 -0
  57. package/server/public/app/remote-relays-settings.js +618 -0
  58. package/server/public/app/remote-relays-store.mjs +126 -0
  59. package/server/public/app/settings-modal.js +12 -8
  60. package/server/public/app/settings-tabs.js +1 -1
  61. package/server/public/app/socket-handlers.js +37 -1
  62. package/server/public/app/steered-turn-merge.mjs +100 -0
  63. package/server/public/app/store.js +126 -15
  64. package/server/public/app/sync-outbox.mjs +16 -8
  65. package/server/public/app/thinking-follow.js +260 -0
  66. package/server/public/app/tool-activity-emoji.mjs +40 -0
  67. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  68. package/server/public/index.html +463 -17
  69. package/server/public/sw.js +7 -0
  70. package/server/relay-tools.md +7 -1
  71. package/server/repositories/message-repository.mjs +68 -18
  72. package/server/repositories/question-repository.mjs +18 -0
  73. package/server/repositories/remote-relay-repository.mjs +75 -0
  74. package/server/repositories/session-repository.mjs +13 -32
  75. package/server/routes/ask-user-routes.mjs +42 -2
  76. package/server/routes/messages-routes.mjs +572 -56
  77. package/server/routes/remote-relay-routes.mjs +133 -0
  78. package/server/routes/sessions-routes.mjs +378 -84
  79. package/server/server-runtime.mjs +303 -25
  80. package/server/services/github-ci-activity-service.mjs +199 -0
  81. package/server/services/host-suspend-command.mjs +38 -0
  82. package/server/services/host-suspend-service.mjs +254 -0
  83. package/server/services/live-turn-picker.mjs +47 -0
  84. package/server/services/push-dispatch-service.mjs +37 -4
  85. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  86. package/server/services/relay-question-context.mjs +46 -0
  87. package/server/services/remote-relay-caller-context.mjs +341 -0
  88. package/server/services/remote-relay-client.mjs +332 -0
  89. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  90. package/server/services/remote-relay-inbound.mjs +187 -0
  91. package/server/services/remote-relay-pairing.mjs +189 -0
  92. package/server/services/remote-relay-registry-service.mjs +581 -0
  93. package/server/services/sdk-session-import-service.mjs +49 -7
  94. package/server/services/session-worker-launch-service.mjs +131 -12
  95. package/server/services/session-worker-registry-service.mjs +75 -0
  96. package/server/services/session-worker-supervisor-service.mjs +7 -1
  97. package/server/services/session-worker-websocket-service.mjs +23 -1
  98. package/server/services/steer-resend.mjs +45 -0
  99. package/shared/background-task-timeout.mjs +12 -4
  100. package/shared/control-poller.mjs +4 -1
  101. package/shared/question-multi-select.mjs +16 -0
  102. package/shared/relay-stop-failure.mjs +15 -0
  103. package/shared/remote-relay-contract.mjs +539 -0
  104. package/shared/remote-relay-mentions.mjs +129 -0
  105. package/shared/remote-relay-tool-core.mjs +314 -0
  106. package/shared/slash-command-guard.mjs +76 -0
  107. package/shared/steer-note.mjs +66 -0
  108. package/shared/steer-settle-failure.mjs +89 -0
  109. package/shared/steer-settle-markers.mjs +18 -0
  110. package/shared/worker-crash-guard.mjs +40 -25
  111. package/shared/worker-runtime/api-client.mjs +112 -22
  112. package/shared/worker-runtime/heartbeat.mjs +55 -1
  113. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  114. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  115. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  116. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  117. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  118. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  119. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  120. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  121. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  122. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  123. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  124. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  125. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  126. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  127. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  128. package/server/routes/messages-routes-test-harness.mjs +0 -111
  129. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  130. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  131. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  132. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  133. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  134. package/server/services/fixtures/cli-install-stub.sh +0 -4
  135. package/server/services/fixtures/grok-stub.cmd +0 -1
  136. package/server/services/fixtures/grok-stub.mjs +0 -89
  137. package/server/services/fixtures/grok-stub.sh +0 -4
  138. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -0,0 +1,618 @@
1
+ // Settings → Relays: this relay's identity for its peers (name, public
2
+ // address, the inbound switch), the list of paired remote relays, and the form
3
+ // that adds one (docs/plans/2026-09-27-remote-relays.md §8, decisions 3, 4, 12,
4
+ // 15 and 23). Rows are dynamic, so their listeners are attached during render
5
+ // (the Features tab does the same); the static controls use inline handlers
6
+ // that bootstrap.js exposes on window.
7
+
8
+ import { BASE, showTransientRelayNotice } from './store.js';
9
+ import {
10
+ addRemoteRelay,
11
+ checkRemoteRelay,
12
+ loadRemoteRelaySettings,
13
+ removeRemoteRelay,
14
+ updateRemoteRelay,
15
+ updateRemoteRelaySettings,
16
+ } from './api-client.js';
17
+ import {
18
+ getRemoteRelaysSnapshot,
19
+ refreshRemoteRelays,
20
+ remoteRelayStatus,
21
+ removeRemoteRelayFromSnapshot,
22
+ subscribeRemoteRelays,
23
+ upsertRemoteRelay,
24
+ } from './remote-relays-store.mjs';
25
+ import { REMOTE_RELAY_PERMISSIONS, normalizeRemoteRelayPermission } from './remote-relay-shared.mjs';
26
+
27
+ export const REMOTE_RELAY_PERMISSION_LABELS = Object.freeze({
28
+ read: 'Read only',
29
+ prompt: 'Read and prompt',
30
+ full: 'Full',
31
+ });
32
+
33
+ const STATUS_LABELS = Object.freeze({
34
+ online: 'Online',
35
+ offline: 'Offline',
36
+ unauthorized: 'Token not accepted',
37
+ error: 'Error',
38
+ unknown: 'Not checked yet',
39
+ });
40
+
41
+ // Fired by pwa-install.js when the relay name (the PWA app name) changes.
42
+ export const RELAY_NAME_CHANGED_EVENT = 'oar:relay-name-changed';
43
+
44
+ let relaySettings = { publicUrl: '', inboundEnabled: true, loaded: false };
45
+ let publicUrlSaveInFlight = false;
46
+ // What the user entered while it is being saved, or after the relay refused
47
+ // it: shown instead of the stored value so a refused address can be fixed.
48
+ let publicUrlPending = null;
49
+ let inboundSaveInFlight = false;
50
+ let addInFlight = false;
51
+ let listLoadFailed = false;
52
+ // Per-row action in flight ('check' | 'remove' | 'permission'), kept across
53
+ // re-renders a socket update can trigger in the middle of one.
54
+ const rowBusy = new Map();
55
+ let listRenderDeferred = false;
56
+ let listFocusBound = false;
57
+
58
+ function el(id) {
59
+ return document.getElementById(id);
60
+ }
61
+
62
+ // ─── Pure helpers (unit-tested) ──────────────────────────────────────────────
63
+
64
+ /** "just now", "5 min ago", "3 h ago", "2 d ago"; "never" without a time. */
65
+ export function formatRelativeTime(value, now = Date.now()) {
66
+ if (!value) return 'never';
67
+ const at = Date.parse(String(value));
68
+ if (!Number.isFinite(at)) return 'unknown';
69
+ const seconds = Math.max(0, Math.round((now - at) / 1000));
70
+ if (seconds < 45) return 'just now';
71
+ const minutes = Math.round(seconds / 60);
72
+ if (minutes < 60) return `${minutes} min ago`;
73
+ const hours = Math.round(minutes / 60);
74
+ if (hours < 24) return `${hours} h ago`;
75
+ return `${Math.round(hours / 24)} d ago`;
76
+ }
77
+
78
+ function isLoopbackHost(hostname) {
79
+ const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
80
+ return host === 'localhost' || host === '::1' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host) || host.endsWith('.localhost');
81
+ }
82
+
83
+ /**
84
+ * How this relay looks from the browser — origin plus the relay's base path —
85
+ * or '' when the page was opened on loopback (another relay could not use a
86
+ * localhost address; the user has to enter the public one).
87
+ */
88
+ export function browserSelfUrl(location = window.location, basePath = BASE) {
89
+ const origin = String(location?.origin || '').trim();
90
+ if (!/^https?:\/\//i.test(origin)) return '';
91
+ if (isLoopbackHost(location?.hostname)) return '';
92
+ return `${origin}${String(basePath || '').replace(/\/+$/, '')}`;
93
+ }
94
+
95
+ /**
96
+ * Maps the add call's answer onto the form's status line.
97
+ * `state` is the line's data-state: added | paired | pair-failed | needs-token
98
+ * | self | error. `added` says the relay is now in the list; `showToken` that
99
+ * the token field has to come up.
100
+ */
101
+ export function describeAddRemoteRelayResult(result, { pairBack = true, usedToken = false } = {}) {
102
+ if (!result) {
103
+ return { state: 'error', text: 'This relay is not reachable right now. Try again in a moment.', added: false, showToken: false };
104
+ }
105
+ if (result.needsToken) {
106
+ return {
107
+ state: 'needs-token',
108
+ text: usedToken
109
+ ? 'The other relay did not accept that token either. Check it and press Add again.'
110
+ : 'The other relay did not accept this relay\'s token. Paste the other relay\'s token and press Add again.',
111
+ added: false,
112
+ showToken: true,
113
+ };
114
+ }
115
+ if (result.ok) {
116
+ const name = String(result.relay?.name || '').trim() || 'the relay';
117
+ const warning = String(result.warning || '').trim();
118
+ const suffix = warning ? ` ${warning}` : '';
119
+ const verb = result.updated === true ? 'Updated' : 'Added';
120
+ if (result.pairedBack === true) {
121
+ return { state: 'paired', text: `${verb} ${name}. It added this relay too.${suffix}`, added: true, showToken: false };
122
+ }
123
+ if (pairBack) {
124
+ const reason = String(result.pairBackError || '').trim() || 'the other relay did not confirm';
125
+ return {
126
+ state: 'pair-failed',
127
+ text: `${verb} ${name}, but adding this relay there failed: ${reason}${suffix}`,
128
+ added: true,
129
+ showToken: false,
130
+ };
131
+ }
132
+ return { state: 'added', text: `${verb} ${name}.${suffix}`, added: true, showToken: false };
133
+ }
134
+ if (String(result.code || '').toUpperCase() === 'SELF') {
135
+ return { state: 'self', text: 'That address is this relay. Paste the address of another relay.', added: false, showToken: false };
136
+ }
137
+ return {
138
+ state: 'error',
139
+ text: String(result.error || '').trim() || 'Could not add the relay.',
140
+ added: false,
141
+ showToken: false,
142
+ };
143
+ }
144
+
145
+ // ─── This relay ──────────────────────────────────────────────────────────────
146
+
147
+ function applyRelaySettingsPayload(payload) {
148
+ if (!payload || typeof payload !== 'object') return relaySettings;
149
+ relaySettings = {
150
+ publicUrl: typeof payload.publicUrl === 'string' ? payload.publicUrl : (relaySettings.publicUrl || ''),
151
+ inboundEnabled: typeof payload.inboundEnabled === 'boolean' ? payload.inboundEnabled : relaySettings.inboundEnabled,
152
+ loaded: true,
153
+ };
154
+ return relaySettings;
155
+ }
156
+
157
+ function setStatusLine(id, state, text) {
158
+ const line = el(id);
159
+ if (!line) return;
160
+ line.textContent = text || '';
161
+ line.hidden = !text;
162
+ if (state) line.dataset.state = state;
163
+ else delete line.dataset.state;
164
+ }
165
+
166
+ function renderSelfSection() {
167
+ const snapshot = getRemoteRelaysSnapshot();
168
+ const nameEl = el('remote-relays-self-name');
169
+ if (nameEl) {
170
+ const name = String(snapshot?.self?.name || '').trim();
171
+ nameEl.textContent = name || '—';
172
+ }
173
+ const input = el('remote-relays-public-url-input');
174
+ if (input) {
175
+ input.placeholder = browserSelfUrl() || 'https://…';
176
+ // Never clobber what the user is typing.
177
+ if (document.activeElement !== input) input.value = publicUrlPending ?? (relaySettings.publicUrl || '');
178
+ input.disabled = publicUrlSaveInFlight;
179
+ }
180
+ const toggle = el('remote-relays-inbound-toggle');
181
+ if (toggle) {
182
+ toggle.checked = relaySettings.inboundEnabled !== false;
183
+ toggle.disabled = inboundSaveInFlight || !relaySettings.loaded;
184
+ }
185
+ }
186
+
187
+ export async function saveRemoteRelayPublicUrl(rawValue) {
188
+ const value = String(rawValue ?? '').trim();
189
+ if (publicUrlSaveInFlight) return;
190
+ if (relaySettings.loaded && publicUrlPending === null && value === (relaySettings.publicUrl || '')) return;
191
+ publicUrlSaveInFlight = true;
192
+ publicUrlPending = value;
193
+ setStatusLine('remote-relays-self-status', 'saved', 'Saving…');
194
+ renderSelfSection();
195
+ let result = null;
196
+ try {
197
+ result = await updateRemoteRelaySettings({ publicUrl: value });
198
+ } finally {
199
+ publicUrlSaveInFlight = false;
200
+ }
201
+ if (result?.ok) {
202
+ publicUrlPending = null;
203
+ applyRelaySettingsPayload({ publicUrl: value, ...result });
204
+ setStatusLine(
205
+ 'remote-relays-self-status',
206
+ 'active',
207
+ relaySettings.publicUrl ? `Other relays reach this one at ${relaySettings.publicUrl}.` : 'Public address cleared.',
208
+ );
209
+ } else {
210
+ // The typed value stays (publicUrlPending) so it can be fixed.
211
+ setStatusLine('remote-relays-self-status', 'error', result?.error || 'Failed to save the public address.');
212
+ }
213
+ renderSelfSection();
214
+ }
215
+
216
+ export async function toggleRemoteRelayInbound(checked) {
217
+ if (inboundSaveInFlight) return;
218
+ const enabled = checked === true;
219
+ inboundSaveInFlight = true;
220
+ renderSelfSection();
221
+ let result = null;
222
+ try {
223
+ result = await updateRemoteRelaySettings({ inboundEnabled: enabled });
224
+ } finally {
225
+ inboundSaveInFlight = false;
226
+ }
227
+ if (result?.ok) {
228
+ applyRelaySettingsPayload({ inboundEnabled: enabled, ...result });
229
+ showTransientRelayNotice(relaySettings.inboundEnabled
230
+ ? 'Agents on other relays may send prompts here again.'
231
+ : 'Prompts from other relays\' agents are refused now.');
232
+ } else {
233
+ alert(result?.error || 'Failed to update the setting.');
234
+ }
235
+ renderSelfSection();
236
+ }
237
+
238
+ // ─── Remote relay list ───────────────────────────────────────────────────────
239
+
240
+ function statusTooltip(relay, status) {
241
+ if (status === 'online') return STATUS_LABELS.online;
242
+ const error = String(relay?.lastError || '').trim();
243
+ return error || STATUS_LABELS[status] || STATUS_LABELS.unknown;
244
+ }
245
+
246
+ function relayDetailText(relay) {
247
+ const parts = [];
248
+ const host = String(relay?.host || '').trim();
249
+ if (host) parts.push(host);
250
+ const version = String(relay?.version || '').trim();
251
+ if (version) parts.push(`OAR ${version}`);
252
+ parts.push(relay?.lastSeenAt ? `seen ${formatRelativeTime(relay.lastSeenAt)}` : 'not reached yet');
253
+ return parts.join(' · ');
254
+ }
255
+
256
+ function safeRelayUrl(relay) {
257
+ const url = String(relay?.url || '').trim();
258
+ return /^https?:\/\//i.test(url) ? url : '';
259
+ }
260
+
261
+ function buildRelayRow(relay) {
262
+ const status = remoteRelayStatus(relay);
263
+ const busy = rowBusy.get(relay.id) || '';
264
+ const name = String(relay.name || relay.host || relay.id);
265
+
266
+ const row = document.createElement('div');
267
+ row.className = 'preview-row remote-relay-row';
268
+ row.dataset.relayId = relay.id;
269
+ row.dataset.status = status;
270
+
271
+ const dot = document.createElement('span');
272
+ dot.className = 'remote-relay-dot';
273
+ dot.dataset.status = status;
274
+ dot.title = statusTooltip(relay, status);
275
+ dot.setAttribute('role', 'img');
276
+ dot.setAttribute('aria-label', STATUS_LABELS[status] || STATUS_LABELS.unknown);
277
+ row.appendChild(dot);
278
+
279
+ const main = document.createElement('span');
280
+ main.className = 'preview-main';
281
+ row.appendChild(main);
282
+
283
+ // textContent throughout: names, hosts and errors come from other relays.
284
+ const label = document.createElement('span');
285
+ label.className = 'preview-label remote-relay-name';
286
+ label.textContent = name;
287
+ if (relay.addedBy === 'pairing') {
288
+ const tag = document.createElement('span');
289
+ tag.className = 'remote-relay-tag';
290
+ tag.textContent = 'Paired automatically';
291
+ tag.title = 'That relay added this one and asked to be added back.';
292
+ label.appendChild(document.createTextNode(' '));
293
+ label.appendChild(tag);
294
+ }
295
+ main.appendChild(label);
296
+
297
+ const detail = document.createElement('span');
298
+ detail.className = 'preview-detail remote-relay-detail';
299
+ detail.textContent = relayDetailText(relay);
300
+ main.appendChild(detail);
301
+
302
+ const lastError = String(relay.lastError || '').trim();
303
+ if (status !== 'online' && status !== 'unknown' && lastError) {
304
+ const error = document.createElement('span');
305
+ error.className = 'preview-detail remote-relay-error';
306
+ error.textContent = lastError;
307
+ main.appendChild(error);
308
+ }
309
+
310
+ const httpWarning = String(relay.httpWarning || '').trim();
311
+ if (httpWarning) {
312
+ const warning = document.createElement('span');
313
+ warning.className = 'preview-warning remote-relay-http-warning';
314
+ warning.textContent = `⚠ ${httpWarning}`;
315
+ main.appendChild(warning);
316
+ }
317
+
318
+ const side = document.createElement('span');
319
+ side.className = 'preview-side';
320
+ row.appendChild(side);
321
+
322
+ const permissionLabel = document.createElement('label');
323
+ permissionLabel.className = 'remote-relay-permission';
324
+ permissionLabel.appendChild(document.createTextNode('Agents may '));
325
+ const select = document.createElement('select');
326
+ select.className = 'remote-relay-permission-select';
327
+ select.setAttribute('aria-label', `What agents may do on ${name}`);
328
+ for (const permission of REMOTE_RELAY_PERMISSIONS) {
329
+ const option = document.createElement('option');
330
+ option.value = permission;
331
+ option.textContent = REMOTE_RELAY_PERMISSION_LABELS[permission] || permission;
332
+ select.appendChild(option);
333
+ }
334
+ select.value = normalizeRemoteRelayPermission(relay.permission);
335
+ select.disabled = !!busy;
336
+ select.addEventListener('change', () => {
337
+ void changeRemoteRelayPermission(relay, select.value, select);
338
+ });
339
+ permissionLabel.appendChild(select);
340
+ side.appendChild(permissionLabel);
341
+
342
+ const actions = document.createElement('span');
343
+ actions.className = 'preview-actions';
344
+ side.appendChild(actions);
345
+
346
+ const check = document.createElement('button');
347
+ check.type = 'button';
348
+ check.className = 'preview-copy remote-relay-check';
349
+ check.textContent = busy === 'check' ? 'Checking…' : 'Check';
350
+ check.disabled = !!busy;
351
+ check.addEventListener('click', () => {
352
+ void checkRemoteRelayRow(relay);
353
+ });
354
+ actions.appendChild(check);
355
+
356
+ const url = safeRelayUrl(relay);
357
+ if (url) {
358
+ const open = document.createElement('a');
359
+ open.className = 'preview-open remote-relay-open';
360
+ open.href = url;
361
+ open.target = '_blank';
362
+ open.rel = 'noopener';
363
+ open.textContent = 'Open';
364
+ open.title = `Open ${name} in a new tab`;
365
+ actions.appendChild(open);
366
+ }
367
+
368
+ const remove = document.createElement('button');
369
+ remove.type = 'button';
370
+ remove.className = 'preview-close remote-relay-remove';
371
+ remove.textContent = busy === 'remove' ? 'Removing…' : 'Remove';
372
+ remove.disabled = !!busy;
373
+ remove.addEventListener('click', () => {
374
+ void removeRemoteRelayRow(relay);
375
+ });
376
+ actions.appendChild(remove);
377
+
378
+ return row;
379
+ }
380
+
381
+ function bindListFocusTracking(list) {
382
+ if (listFocusBound) return;
383
+ listFocusBound = true;
384
+ // A re-render while a permission dropdown is open would close it under the
385
+ // user's finger; hold the render until focus leaves the select.
386
+ list.addEventListener('focusout', () => {
387
+ if (!listRenderDeferred) return;
388
+ setTimeout(() => renderRelayList(), 0);
389
+ });
390
+ }
391
+
392
+ function renderRelayList() {
393
+ const list = el('remote-relays-list');
394
+ if (!list) return;
395
+ bindListFocusTracking(list);
396
+ const active = document.activeElement;
397
+ if (active && active.tagName === 'SELECT' && list.contains(active)) {
398
+ listRenderDeferred = true;
399
+ return;
400
+ }
401
+ listRenderDeferred = false;
402
+ const snapshot = getRemoteRelaysSnapshot();
403
+ const relays = snapshot?.relays || [];
404
+ list.textContent = '';
405
+ for (const relay of relays) list.appendChild(buildRelayRow(relay));
406
+ const empty = el('remote-relays-empty');
407
+ if (empty) {
408
+ empty.hidden = relays.length > 0;
409
+ empty.textContent = snapshot
410
+ ? 'No remote relays yet. Add one below.'
411
+ : (listLoadFailed ? 'Could not load the remote relays.' : 'Loading remote relays…');
412
+ }
413
+ }
414
+
415
+ export function renderRemoteRelaysSection() {
416
+ renderSelfSection();
417
+ renderRelayList();
418
+ }
419
+
420
+ async function changeRemoteRelayPermission(relay, value, select) {
421
+ const previous = normalizeRemoteRelayPermission(relay.permission);
422
+ const next = normalizeRemoteRelayPermission(value);
423
+ if (next === previous || rowBusy.has(relay.id)) return;
424
+ rowBusy.set(relay.id, 'permission');
425
+ if (select) select.disabled = true;
426
+ let result = null;
427
+ try {
428
+ result = await updateRemoteRelay(relay.id, { permission: next });
429
+ } finally {
430
+ rowBusy.delete(relay.id);
431
+ }
432
+ if (select) select.disabled = false;
433
+ if (result?.ok) {
434
+ const updated = result.relay && typeof result.relay === 'object' ? result.relay : { ...relay, permission: next };
435
+ // Blur first so the deferred-render guard does not hold the new state back.
436
+ select?.blur?.();
437
+ upsertRemoteRelay(updated);
438
+ showTransientRelayNotice(`Agents may now use "${REMOTE_RELAY_PERMISSION_LABELS[next]}" on ${relay.name || relay.host}.`);
439
+ } else {
440
+ if (select) select.value = previous;
441
+ alert(result?.error || 'Failed to change what agents may do there.');
442
+ }
443
+ renderRelayList();
444
+ }
445
+
446
+ async function checkRemoteRelayRow(relay) {
447
+ if (rowBusy.has(relay.id)) return;
448
+ rowBusy.set(relay.id, 'check');
449
+ renderRelayList();
450
+ let result = null;
451
+ try {
452
+ result = await checkRemoteRelay(relay.id);
453
+ } finally {
454
+ rowBusy.delete(relay.id);
455
+ }
456
+ if (result?.relay && typeof result.relay === 'object') {
457
+ upsertRemoteRelay(result.relay);
458
+ } else if (!result?.ok) {
459
+ alert(result?.error || 'The check could not run.');
460
+ }
461
+ renderRelayList();
462
+ }
463
+
464
+ async function removeRemoteRelayRow(relay) {
465
+ if (rowBusy.has(relay.id)) return;
466
+ const name = relay.name || relay.host || 'this relay';
467
+ const confirmed = confirm(
468
+ `Remove ${name}? Agents here can no longer work on it. ${name} keeps its own entry for this relay; remove it there too to unpair both ways.`,
469
+ );
470
+ if (!confirmed) return;
471
+ rowBusy.set(relay.id, 'remove');
472
+ renderRelayList();
473
+ let result = null;
474
+ try {
475
+ result = await removeRemoteRelay(relay.id);
476
+ } finally {
477
+ rowBusy.delete(relay.id);
478
+ }
479
+ if (result?.ok) {
480
+ removeRemoteRelayFromSnapshot(relay.id);
481
+ showTransientRelayNotice(`Removed ${name}.`);
482
+ } else {
483
+ alert(result?.error || 'Failed to remove the relay.');
484
+ }
485
+ renderRelayList();
486
+ }
487
+
488
+ // ─── Add form ────────────────────────────────────────────────────────────────
489
+
490
+ function setTokenFieldVisible(visible) {
491
+ const row = el('remote-relays-add-token-row');
492
+ if (row) row.hidden = !visible;
493
+ const link = el('remote-relays-use-token-btn');
494
+ if (link) link.hidden = !!visible;
495
+ const input = el('remote-relays-add-token-input');
496
+ if (!visible && input) input.value = '';
497
+ }
498
+
499
+ function setAddControlsDisabled(disabled) {
500
+ for (const id of [
501
+ 'remote-relays-add-url-input',
502
+ 'remote-relays-add-token-input',
503
+ 'remote-relays-pair-back-toggle',
504
+ 'remote-relays-add-btn',
505
+ 'remote-relays-use-token-btn',
506
+ ]) {
507
+ const control = el(id);
508
+ if (control) control.disabled = disabled;
509
+ }
510
+ }
511
+
512
+ /** "Use a different token": brings the token field up without a failed add first. */
513
+ export function showRemoteRelayTokenField() {
514
+ setTokenFieldVisible(true);
515
+ el('remote-relays-add-token-input')?.focus?.();
516
+ }
517
+
518
+ export async function addRemoteRelayFromForm() {
519
+ if (addInFlight) return null;
520
+ const urlInput = el('remote-relays-add-url-input');
521
+ const tokenInput = el('remote-relays-add-token-input');
522
+ const pairBackToggle = el('remote-relays-pair-back-toggle');
523
+ const url = String(urlInput?.value || '').trim();
524
+ if (!url) {
525
+ setStatusLine('remote-relays-add-status', 'error', 'Paste the other relay\'s web address first.');
526
+ urlInput?.focus?.();
527
+ return null;
528
+ }
529
+ const tokenVisible = el('remote-relays-add-token-row')?.hidden === false;
530
+ const token = tokenVisible ? String(tokenInput?.value || '').trim() : '';
531
+ const pairBack = pairBackToggle ? pairBackToggle.checked !== false : true;
532
+
533
+ addInFlight = true;
534
+ setAddControlsDisabled(true);
535
+ setStatusLine('remote-relays-add-status', 'working', 'Adding…');
536
+ let result = null;
537
+ try {
538
+ result = await addRemoteRelay({ url, token, pairBack, selfUrl: browserSelfUrl() });
539
+ } finally {
540
+ addInFlight = false;
541
+ setAddControlsDisabled(false);
542
+ // The token is sent once and never kept in the page.
543
+ if (tokenInput) tokenInput.value = '';
544
+ }
545
+
546
+ const outcome = describeAddRemoteRelayResult(result, { pairBack, usedToken: !!token });
547
+ setStatusLine('remote-relays-add-status', outcome.state, outcome.text);
548
+ if (outcome.showToken) {
549
+ setTokenFieldVisible(true);
550
+ tokenInput?.focus?.();
551
+ }
552
+ if (outcome.added) {
553
+ if (urlInput) urlInput.value = '';
554
+ setTokenFieldVisible(false);
555
+ if (result?.relay && typeof result.relay === 'object' && getRemoteRelaysSnapshot()) upsertRemoteRelay(result.relay);
556
+ void refreshRemoteRelays();
557
+ }
558
+ return outcome;
559
+ }
560
+
561
+ // ─── Refresh ─────────────────────────────────────────────────────────────────
562
+
563
+ /** Called when the settings modal opens (and by the tab deep link). */
564
+ export async function refreshRemoteRelaysSection() {
565
+ // Reopening the modal shows what the relay has, not an old refused entry.
566
+ if (!publicUrlSaveInFlight) publicUrlPending = null;
567
+ setStatusLine('remote-relays-self-status', '', '');
568
+ renderRemoteRelaysSection();
569
+ const [snapshot, settings] = await Promise.all([
570
+ refreshRemoteRelays(),
571
+ loadRemoteRelaySettings(),
572
+ ]);
573
+ listLoadFailed = !snapshot;
574
+ if (settings) applyRelaySettingsPayload(settings);
575
+ setStatusLine(
576
+ 'remote-relays-status',
577
+ snapshot ? '' : 'error',
578
+ snapshot ? '' : 'Could not load the remote relays from this relay.',
579
+ );
580
+ renderRemoteRelaysSection();
581
+ return snapshot;
582
+ }
583
+
584
+ /** Test hook. */
585
+ export function resetRemoteRelaysSettingsForTests() {
586
+ relaySettings = { publicUrl: '', inboundEnabled: true, loaded: false };
587
+ publicUrlSaveInFlight = false;
588
+ publicUrlPending = null;
589
+ inboundSaveInFlight = false;
590
+ addInFlight = false;
591
+ listLoadFailed = false;
592
+ listRenderDeferred = false;
593
+ rowBusy.clear();
594
+ }
595
+
596
+ // The relay's own settings ride along in the list payload's `self` (GET and
597
+ // remote_relays_updated alike), so a change made on another device shows here
598
+ // too — except while this page is saving the same field.
599
+ function applySelfSettingsFromSnapshot(snapshot) {
600
+ const self = snapshot?.self;
601
+ if (!self || typeof self !== 'object') return;
602
+ const next = {};
603
+ if (typeof self.publicUrl === 'string' && !publicUrlSaveInFlight) next.publicUrl = self.publicUrl;
604
+ if (typeof self.inboundEnabled === 'boolean' && !inboundSaveInFlight) next.inboundEnabled = self.inboundEnabled;
605
+ if (Object.keys(next).length) applyRelaySettingsPayload(next);
606
+ }
607
+
608
+ // Live: a remote_relays_updated event (or any other store change) repaints.
609
+ subscribeRemoteRelays((snapshot) => {
610
+ applySelfSettingsFromSnapshot(snapshot);
611
+ renderRemoteRelaysSection();
612
+ });
613
+
614
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
615
+ window.addEventListener(RELAY_NAME_CHANGED_EVENT, () => {
616
+ void refreshRemoteRelays();
617
+ });
618
+ }