@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,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
+ }
@@ -2,7 +2,7 @@ import { tokenLabel } from './context-tier-options.mjs';
2
2
 
3
3
  /**
4
4
  * The composer placeholder, decided by the MODEL FAMILY of the current
5
- * selection (Simon's rule: the hint names who answers, whatever runtime serves
5
+ * selection (maintainer's rule: the hint names who answers, whatever runtime serves
6
6
  * it — claude-* through Cursor's catalog still reads "Message Claude…").
7
7
  * Provider only breaks ties: Auto/empty follows the conversation's bound
8
8
  * provider, unknown families on the OpenAI BYOK provider read OpenAI, and
@@ -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
  /**
@@ -0,0 +1,52 @@
1
+ // Multi-select question cards: the pure rules the card renderer and its
2
+ // submit path share, kept DOM-free so they are unit-testable.
3
+ //
4
+ // Two card hints ride the question's `context` (kept by the relay's
5
+ // services/relay-question-context.mjs allow-list):
6
+ // - `multiSelect: true` — several answers are allowed. Claude states this on
7
+ // every AskUserQuestion question; Copilot models declare it through the
8
+ // relay's own `ask_user` (`multi_select`), and the worker falls back to the
9
+ // wording ("select all that apply").
10
+ // - `allowMultiSelect: true` — the provider cannot always tell (Copilot), so
11
+ // the card offers a "Select several" switch. Claude cards never carry it:
12
+ // they follow Claude's flag strictly (maintainer decision, 2026-09-25).
13
+ // A multi-select card offers checkmarks and one "Reply with selection"; the
14
+ // answer is the selected labels joined with ", " — plus whatever the user
15
+ // typed as an extra answer — which is Claude's own multi-select answer format.
16
+
17
+ function choiceCount(question) {
18
+ return Array.isArray(question?.choices) ? question.choices.length : 0;
19
+ }
20
+
21
+ /** Whether the card offers the "Select several" switch. */
22
+ export function offersMultiSelectToggle(question) {
23
+ if (!question || typeof question !== 'object') return false;
24
+ return choiceCount(question) >= 2 && question?.context?.allowMultiSelect === true;
25
+ }
26
+
27
+ /**
28
+ * Whether the card renders checkmarks. `override` is the user's switch
29
+ * position for this card (true/false), or undefined while untouched — then the
30
+ * provider's flag decides. The switch only counts on cards that offer it.
31
+ */
32
+ export function isMultiSelectQuestion(question, override = undefined) {
33
+ if (!question || typeof question !== 'object') return false;
34
+ if (choiceCount(question) < 2) return false;
35
+ if (typeof override === 'boolean' && offersMultiSelectToggle(question)) return override;
36
+ return question?.context?.multiSelect === true;
37
+ }
38
+
39
+ /**
40
+ * The answer string for a multi-select card: the selected labels in offer
41
+ * order, then the free-text answer (if any) as one more item. Empty when
42
+ * nothing was selected or typed.
43
+ */
44
+ export function composeMultiSelectAnswer(selectedLabels, freeText = '') {
45
+ const labels = (Array.isArray(selectedLabels) ? selectedLabels : [])
46
+ .map((label) => String(label ?? '').trim())
47
+ .filter(Boolean)
48
+ .filter((label, index, all) => all.indexOf(label) === index);
49
+ const extra = String(freeText ?? '').trim();
50
+ if (extra && !labels.includes(extra)) labels.push(extra);
51
+ return labels.join(', ');
52
+ }
@@ -1,3 +1,5 @@
1
+ import { stripRemotePromptHeader } from './remote-relay-shared.mjs';
2
+
1
3
  const RELAY_TOOL_GUIDANCE = [
2
4
  '# Relay Tool Guidance',
3
5
  'For any user-facing question or clarification, use the ask_user tool so the web relay can render question cards and buttons. Never ask questions in plain assistant text.',
@@ -108,7 +110,46 @@ function stripAttachmentPromptArtifacts(text) {
108
110
  // structurally — the heading plus its single paragraph — rather than verbatim.
109
111
  const MEDIA_EMBED_BLOCK_PATTERN = /##\s*Embedding media in replies\s+[\s\S]*?(?=\n\s*\n|$)/gi;
110
112
 
111
- export function stripRelayPromptContext(text, relayMode = '') {
113
+ // A steered message's hidden note (shared/steer-note.mjs) leads the prompt the
114
+ // runtime stored, right after any mode marker. Matched by its stable opening
115
+ // words, because the browser copy of this module cannot import shared/.
116
+ const STEER_NOTE_PATTERN = /^\s*\[Sent while you were still working on my previous message\.[^\]\n]*\]\s*/;
117
+
118
+ // The Claude worker's slash-command guard (shared/slash-command-guard.mjs): a
119
+ // zero-width space in front of a "/x" it delivers as text. trim() keeps it, so
120
+ // it is matched on its own, and only where it guards a "/".
121
+ const SLASH_COMMAND_GUARD_PATTERN = /^\u200B(?=\s*\/)/;
122
+
123
+ // The hint the relay adds to a turn whose user message mentions a remote relay
124
+ // (docs/plans/2026-09-27-remote-relays.md §5.5). stripAttachmentPromptArtifacts
125
+ // already drops every <system_reminder>; this narrower pattern is for text that
126
+ // did not come through that path (a stored bubble the server already sanitised).
127
+ const REMOTE_RELAY_MENTION_HINT_PATTERN = /<system_reminder>\s*The user mentioned the remote OAR relay[\s\S]*?<\/system_reminder>\s*/gi;
128
+
129
+ function hasRemoteOrigin(origin) {
130
+ return !!origin && typeof origin === 'object';
131
+ }
132
+
133
+ function stripLeadingRelayNotes(text, { origin = null } = {}) {
134
+ const remote = hasRemoteOrigin(origin);
135
+ let stripped = String(text || '');
136
+ // A prompt another relay's agent sent starts with a header line naming it;
137
+ // the bubble's provenance badge carries the same facts, so it is hidden —
138
+ // but only when the message really carries `origin`, never on a lookalike a
139
+ // human typed. A steer note may sit in front of it.
140
+ if (remote) stripped = stripRemotePromptHeader(stripped.trimStart());
141
+ stripped = stripped.replace(STEER_NOTE_PATTERN, '');
142
+ if (remote) stripped = stripRemotePromptHeader(stripped.trimStart());
143
+ stripped = stripped.replace(SLASH_COMMAND_GUARD_PATTERN, '');
144
+ return stripped.trim() ? stripped.trim() : String(text || '').trim();
145
+ }
146
+
147
+ /**
148
+ * `options.origin`: the message's remote-relay provenance, when it has one.
149
+ * Only then is the remote prompt header line stripped.
150
+ */
151
+ export function stripRelayPromptContext(text, relayMode = '', options = {}) {
152
+ const origin = options && typeof options === 'object' ? options.origin : null;
112
153
  const value = stripAttachmentPromptArtifacts(text)
113
154
  .replace(MEDIA_EMBED_BLOCK_PATTERN, '')
114
155
  .trim();
@@ -116,7 +157,24 @@ export function stripRelayPromptContext(text, relayMode = '') {
116
157
  const patterns = buildPromptPrefixPatterns(relayMode);
117
158
  for (const pattern of patterns) {
118
159
  const stripped = value.replace(pattern, '').trim();
119
- if (stripped && stripped !== value) return stripped;
160
+ if (stripped && stripped !== value) return stripLeadingRelayNotes(stripped, { origin });
161
+ }
162
+ return stripLeadingRelayNotes(value, { origin });
163
+ }
164
+
165
+ /**
166
+ * For a user bubble's text as it is about to render (already sanitised by the
167
+ * relay): drops a stray remote-relay mention hint, and the remote prompt header
168
+ * when the message carries `origin`. Text without either is returned untouched.
169
+ */
170
+ export function stripRemoteRelayDisplayArtifacts(text, { origin = null } = {}) {
171
+ const value = String(text ?? '');
172
+ let next = value.replace(REMOTE_RELAY_MENTION_HINT_PATTERN, '');
173
+ if (hasRemoteOrigin(origin)) {
174
+ const leading = next.trimStart();
175
+ const withoutHeader = stripRemotePromptHeader(leading);
176
+ if (withoutHeader !== leading) next = withoutHeader;
120
177
  }
121
- return value;
178
+ if (next === value) return value;
179
+ return next.trim() ? next.trim() : value.trim();
122
180
  }
@@ -0,0 +1,76 @@
1
+ // Provenance of work another relay's agent started here: the badge on a user
2
+ // bubble ("↗ from win-test · “report builder” · claude-sonnet-5", linking back
3
+ // to the source session) and the "↗ win-test" marker on the sidebar row of a
4
+ // conversation such an agent created. Both read the `origin` the relay stores
5
+ // with the message or conversation (docs/plans/2026-09-27-remote-relays.md §4).
6
+
7
+ import { remoteConversationUrl } from './remote-relay-shared.mjs';
8
+
9
+ function escapeHtml(value) {
10
+ return String(value ?? '')
11
+ .replace(/&/g, '&amp;')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;')
15
+ .replace(/'/g, '&#39;');
16
+ }
17
+
18
+ function oneLine(value) {
19
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
20
+ }
21
+
22
+ /** The display fields of an `origin`, or null when it names no relay. */
23
+ export function normalizeRemoteOrigin(raw) {
24
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
25
+ const relayName = oneLine(raw.relayName);
26
+ const relayId = oneLine(raw.relayId);
27
+ if (!relayName && !relayId) return null;
28
+ const relayUrl = oneLine(raw.relayUrl);
29
+ return {
30
+ relayName: relayName || relayId,
31
+ relayUrl: /^https?:\/\//i.test(relayUrl) ? relayUrl : '',
32
+ conversationId: oneLine(raw.conversationId),
33
+ conversationTitle: oneLine(raw.conversationTitle),
34
+ model: oneLine(raw.model),
35
+ };
36
+ }
37
+
38
+ export function remoteOriginBadgeText(origin) {
39
+ const normalized = normalizeRemoteOrigin(origin);
40
+ if (!normalized) return '';
41
+ const parts = [`↗ from ${normalized.relayName}`];
42
+ if (normalized.conversationTitle) parts.push(`“${normalized.conversationTitle}”`);
43
+ if (normalized.model) parts.push(normalized.model);
44
+ return parts.join(' · ');
45
+ }
46
+
47
+ /** `<relayUrl>/?conv=<conversationId>`, or '' when the origin has no URL. */
48
+ export function remoteOriginLink(origin) {
49
+ const normalized = normalizeRemoteOrigin(origin);
50
+ if (!normalized?.relayUrl) return '';
51
+ return remoteConversationUrl(normalized.relayUrl, normalized.conversationId);
52
+ }
53
+
54
+ /**
55
+ * The badge row that sits above a user bubble's text. `linkable: false` (the
56
+ * shared read-only view) renders it without the link to the other relay.
57
+ */
58
+ export function renderRemoteOriginBadgeHtml(origin, { linkable = true } = {}) {
59
+ const normalized = normalizeRemoteOrigin(origin);
60
+ if (!normalized) return '';
61
+ const label = escapeHtml(remoteOriginBadgeText(normalized));
62
+ const href = linkable ? remoteOriginLink(normalized) : '';
63
+ const name = escapeHtml(normalized.relayName);
64
+ if (href) {
65
+ return `<div class="msg-origin"><a class="msg-origin-badge" href="${escapeHtml(href)}" target="_blank" rel="noopener" title="Sent by an agent on relay ${name}. Opens its session there.">${label}</a></div>`;
66
+ }
67
+ return `<div class="msg-origin"><span class="msg-origin-badge" title="Sent by an agent on relay ${name}.">${label}</span></div>`;
68
+ }
69
+
70
+ /** The sidebar marker for a conversation another relay's agent created. */
71
+ export function renderConversationOriginMarkerHtml(conversation) {
72
+ const normalized = normalizeRemoteOrigin(conversation?.origin);
73
+ if (!normalized) return '';
74
+ const name = escapeHtml(normalized.relayName);
75
+ return `<span class="conv-origin-marker" title="Started by an agent on relay ${name}">↗ ${name}</span>`;
76
+ }
@@ -0,0 +1,85 @@
1
+ // Browser mirror of the pieces of shared/remote-relay-contract.mjs and
2
+ // shared/remote-relay-mentions.mjs the web UI needs. The browser cannot import
3
+ // shared/ (only server/public is served), so these are hand-kept copies;
4
+ // remote-relay-shared.test.mjs asserts they behave exactly like the originals.
5
+
6
+ export const REMOTE_RELAY_PERMISSIONS = Object.freeze(['read', 'prompt', 'full']);
7
+ export const REMOTE_RELAY_DEFAULT_PERMISSION = 'full';
8
+ export const REMOTE_RELAY_SOCKET_EVENT = 'remote_relays_updated';
9
+
10
+ // The header line a relay puts at the top of a prompt it forwards. The UI
11
+ // hides it on bubbles that carry `origin` (the badge shows the same facts).
12
+ export const REMOTE_PROMPT_HEADER_PATTERN = /^\[Remote prompt from an agent on relay "[^"\r\n]*"[^\r\n]*\](?:\r?\n){1,2}/;
13
+
14
+ const NAME_CHAR = /[a-z0-9._-]/;
15
+
16
+ function toText(value) {
17
+ return String(value ?? '').trim();
18
+ }
19
+
20
+ function hostOf(url) {
21
+ try {
22
+ return new URL(String(url)).hostname.toLowerCase();
23
+ } catch {
24
+ return '';
25
+ }
26
+ }
27
+
28
+ export function normalizeRemoteRelayPermission(value) {
29
+ const permission = toText(value).toLowerCase();
30
+ return REMOTE_RELAY_PERMISSIONS.includes(permission) ? permission : REMOTE_RELAY_DEFAULT_PERMISSION;
31
+ }
32
+
33
+ export function stripRemotePromptHeader(text) {
34
+ return String(text ?? '').replace(REMOTE_PROMPT_HEADER_PATTERN, '');
35
+ }
36
+
37
+ /** Link that opens one conversation in a relay's web UI. */
38
+ export function remoteConversationUrl(baseUrl, conversationId) {
39
+ const base = toText(baseUrl).replace(/\/+$/, '');
40
+ const id = toText(conversationId);
41
+ if (!base) return '';
42
+ return id ? `${base}/?conv=${encodeURIComponent(id)}` : `${base}/`;
43
+ }
44
+
45
+ /** localhost (and *.localhost), an IPv4 literal, or an IPv6 literal (bracketed as URL hosts are). */
46
+ export function isAddressHost(host) {
47
+ const value = toText(host).toLowerCase();
48
+ if (!value) return false;
49
+ if (value === 'localhost' || value.endsWith('.localhost')) return true;
50
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(value)) return true;
51
+ return value.startsWith('[') || value.includes(':');
52
+ }
53
+
54
+ /** The lowercase aliases a relay can be mentioned by: its name and its URL host, unless that is an address. */
55
+ export function remoteRelayAliases(relay) {
56
+ const aliases = new Set();
57
+ const name = toText(relay?.name).toLowerCase();
58
+ if (name) aliases.add(name);
59
+ const host = hostOf(relay?.url);
60
+ if (host && !isAddressHost(host)) aliases.add(host);
61
+ return [...aliases];
62
+ }
63
+
64
+ /**
65
+ * For the composer: the partial `@word` token ending at `caret`, or null.
66
+ * `{ start, end, query }` where start is the index of the `@`.
67
+ */
68
+ export function mentionQueryAt(text, caret) {
69
+ const value = String(text ?? '');
70
+ const end = Number.isInteger(caret) ? Math.min(Math.max(caret, 0), value.length) : value.length;
71
+ let start = end;
72
+ while (start > 0 && NAME_CHAR.test(value[start - 1].toLowerCase())) start -= 1;
73
+ if (start === 0 || value[start - 1] !== '@') return null;
74
+ const at = start - 1;
75
+ if (at > 0 && !/\s|[([{"'`,;]/.test(value[at - 1])) return null;
76
+ return { start: at, end, query: value.slice(start, end).toLowerCase() };
77
+ }
78
+
79
+ /** How far the mention token that starts at `from` runs (the rest of a word the caret sits in). */
80
+ export function mentionTokenEnd(text, from) {
81
+ const value = String(text ?? '');
82
+ let end = Math.min(Math.max(Number(from) || 0, 0), value.length);
83
+ while (end < value.length && NAME_CHAR.test(value[end].toLowerCase())) end += 1;
84
+ return end;
85
+ }