@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,581 @@
1
+ 'use strict';
2
+
3
+ import { randomUUID as nodeRandomUUID } from 'crypto';
4
+
5
+ import {
6
+ REMOTE_RELAY_LIMITS,
7
+ REMOTE_RELAY_PERMISSIONS,
8
+ REMOTE_RELAY_PROTOCOL,
9
+ REMOTE_RELAY_SETTING_KEYS,
10
+ REMOTE_RELAY_SOCKET_EVENT,
11
+ REMOTE_RELAY_ERROR_CODES,
12
+ checkRemoteRelayUrlPolicy,
13
+ normalizeRemoteRelayLink,
14
+ normalizeRemoteRelayPermission,
15
+ } from '../../shared/remote-relay-contract.mjs';
16
+ import { remoteRelayAliases } from '../../shared/remote-relay-mentions.mjs';
17
+
18
+ // The list of OTHER OAR relays this relay is paired with, and this relay's own
19
+ // identity towards them (plan §4, §5.1, §5.2). Everything lives in
20
+ // app_settings as JSON values, so there is no table to migrate:
21
+ //
22
+ // relay_instance_id this relay's stable id ("is that myself?")
23
+ // remote_relays the entries, tokens included
24
+ // relay_public_url how other relays reach this one
25
+ // remote_relay_inbound_enabled accept prompts from other relays' agents
26
+ //
27
+ // Tokens never leave through listPublic(), the socket event or any route; only
28
+ // the outbound client reads them (list()/get()/resolve() are server-internal).
29
+ //
30
+ // A health loop re-reads every remote's identity once a minute, so status,
31
+ // version and renames propagate. Unlocks are keyed by the entry `id`, which
32
+ // never changes, so a renamed remote keeps them.
33
+
34
+ export const REMOTE_RELAY_STATUSES = Object.freeze(['unknown', 'online', 'offline', 'unauthorized', 'error']);
35
+
36
+ // Plan §5.2: the periodic identity check gets a shorter budget than a tool call.
37
+ const HEALTH_CHECK_TIMEOUT_MS = 10_000;
38
+ // Cloudflare and reverse-proxy answers that mean "the relay behind me is down".
39
+ const GATEWAY_STATUSES = new Set([502, 503, 504, 520, 521, 522, 523, 524, 530]);
40
+ const LAST_ERROR_MAX = 300;
41
+
42
+ function toText(value) {
43
+ return String(value ?? '').trim();
44
+ }
45
+
46
+ function hostOf(url) {
47
+ try {
48
+ return new URL(String(url)).hostname.toLowerCase();
49
+ } catch {
50
+ return '';
51
+ }
52
+ }
53
+
54
+ function capOrNull(value, max) {
55
+ const text = toText(value);
56
+ return text ? text.slice(0, max) : null;
57
+ }
58
+
59
+ function statusForError(error) {
60
+ if (error?.code === REMOTE_RELAY_ERROR_CODES.offline) return 'offline';
61
+ if (error?.code === REMOTE_RELAY_ERROR_CODES.unauthorized) return 'unauthorized';
62
+ if (GATEWAY_STATUSES.has(Number(error?.status))) return 'offline';
63
+ return 'error';
64
+ }
65
+
66
+ // lastError is shown on the relay's own row, and a probe does not know the
67
+ // relay's name (its message says `Relay "<host>" is not reachable …`): keep
68
+ // only the predicate, "Not reachable (ECONNREFUSED)".
69
+ function describeFailure(error) {
70
+ const text = toText(error?.message || error);
71
+ const predicate = text.replace(/^(?:Relay "[^"]*"|The remote relay)\s+/, '');
72
+ if (predicate === text) return text;
73
+ return predicate.replace(/^is\s+/, '').replace(/^\S/, (first) => first.toUpperCase());
74
+ }
75
+
76
+ /** A stored entry, defensively normalised (the setting may have been hand-edited). */
77
+ function normalizeEntry(raw) {
78
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
79
+ const id = toText(raw.id);
80
+ const url = toText(raw.url).replace(/\/+$/, '');
81
+ if (!id || !url) return null;
82
+ const token = toText(raw.token);
83
+ const tokenMode = raw.tokenMode === 'custom' && token ? 'custom' : 'own';
84
+ const protocol = Number(raw.protocol);
85
+ return {
86
+ id,
87
+ relayId: capOrNull(raw.relayId, 100),
88
+ name: capOrNull(raw.name, 60) || hostOf(url) || id,
89
+ url,
90
+ tokenMode,
91
+ ...(tokenMode === 'custom' ? { token } : {}),
92
+ permission: normalizeRemoteRelayPermission(raw.permission),
93
+ addedBy: raw.addedBy === 'pairing' ? 'pairing' : 'user',
94
+ addedAt: capOrNull(raw.addedAt, 40),
95
+ version: capOrNull(raw.version, 40),
96
+ platform: capOrNull(raw.platform, 20),
97
+ lastSeenAt: capOrNull(raw.lastSeenAt, 40),
98
+ lastStatus: REMOTE_RELAY_STATUSES.includes(raw.lastStatus) ? raw.lastStatus : 'unknown',
99
+ lastError: capOrNull(raw.lastError, LAST_ERROR_MAX),
100
+ protocol: Number.isInteger(protocol) && protocol >= 0 ? protocol : 0,
101
+ };
102
+ }
103
+
104
+ /** The shape any API or socket payload may carry: no token, plus display helpers. */
105
+ function toPublic(entry) {
106
+ if (!entry) return null;
107
+ const { token: _token, ...rest } = entry;
108
+ const policy = checkRemoteRelayUrlPolicy(entry.url);
109
+ return {
110
+ ...rest,
111
+ host: hostOf(entry.url),
112
+ httpWarning: policy.ok ? policy.warning || null : policy.error,
113
+ };
114
+ }
115
+
116
+ /** Change detection ignores lastSeenAt: a healthy remote must not emit every minute. */
117
+ function fingerprint(entry) {
118
+ if (!entry) return '';
119
+ const { lastSeenAt: _seen, ...rest } = entry;
120
+ return JSON.stringify(rest);
121
+ }
122
+
123
+ function invalid(error, status = 400) {
124
+ return { ok: false, status, error };
125
+ }
126
+
127
+ export function createRemoteRelayRegistry({
128
+ readSetting,
129
+ writeSetting,
130
+ client,
131
+ repository = null,
132
+ // getOwnToken is accepted for symmetry with the client; the client owns
133
+ // token selection (tokenFor), so the registry never reads a token itself.
134
+ getSelfName = () => '',
135
+ hostname = '',
136
+ platform = '',
137
+ version = '',
138
+ now = () => new Date(),
139
+ emit = () => {},
140
+ logger = console,
141
+ setIntervalImpl = setInterval,
142
+ clearIntervalImpl = clearInterval,
143
+ randomUUID = nodeRandomUUID,
144
+ } = {}) {
145
+ let cachedInstanceId = '';
146
+ let healthTimer = null;
147
+ let sweeping = null;
148
+
149
+ function nowIso() {
150
+ return now().toISOString();
151
+ }
152
+
153
+ /** app_settings values are JSON; a bare legacy string still reads as itself. */
154
+ function readJsonSetting(key) {
155
+ const raw = toText(readSetting(key));
156
+ if (!raw) return undefined;
157
+ try {
158
+ return JSON.parse(raw);
159
+ } catch {
160
+ return raw;
161
+ }
162
+ }
163
+
164
+ function writeJsonSetting(key, value) {
165
+ writeSetting(key, JSON.stringify(value));
166
+ }
167
+
168
+ function readList() {
169
+ const stored = readJsonSetting(REMOTE_RELAY_SETTING_KEYS.relays);
170
+ if (!Array.isArray(stored)) return [];
171
+ const seen = new Set();
172
+ const entries = [];
173
+ for (const raw of stored) {
174
+ const entry = normalizeEntry(raw);
175
+ if (!entry || seen.has(entry.id)) continue;
176
+ seen.add(entry.id);
177
+ entries.push(entry);
178
+ }
179
+ return entries;
180
+ }
181
+
182
+ function writeList(entries) {
183
+ writeJsonSetting(REMOTE_RELAY_SETTING_KEYS.relays, entries);
184
+ }
185
+
186
+ // ─── This relay ────────────────────────────────────────────────────────────
187
+
188
+ function instanceId() {
189
+ if (cachedInstanceId) return cachedInstanceId;
190
+ const stored = toText(readJsonSetting(REMOTE_RELAY_SETTING_KEYS.instanceId));
191
+ if (stored) {
192
+ cachedInstanceId = stored;
193
+ return stored;
194
+ }
195
+ const created = String(randomUUID());
196
+ writeJsonSetting(REMOTE_RELAY_SETTING_KEYS.instanceId, created);
197
+ cachedInstanceId = created;
198
+ return created;
199
+ }
200
+
201
+ function selfName() {
202
+ return toText(getSelfName?.()) || toText(hostname) || 'OAR';
203
+ }
204
+
205
+ function getSelfSettings() {
206
+ const publicUrl = toText(readJsonSetting(REMOTE_RELAY_SETTING_KEYS.publicUrl));
207
+ const inbound = readJsonSetting(REMOTE_RELAY_SETTING_KEYS.inboundEnabled);
208
+ const disabled = inbound === false || inbound === 0 || ['false', '0', 'off'].includes(toText(inbound).toLowerCase());
209
+ return { publicUrl, inboundEnabled: !disabled };
210
+ }
211
+
212
+ function selfIdentity() {
213
+ const settings = getSelfSettings();
214
+ return {
215
+ relayId: instanceId(),
216
+ name: selfName(),
217
+ version: toText(version) || null,
218
+ platform: toText(platform) || null,
219
+ publicUrl: settings.publicUrl || null,
220
+ remoteRelays: { protocol: REMOTE_RELAY_PROTOCOL, inbound: settings.inboundEnabled },
221
+ };
222
+ }
223
+
224
+ function selfPayload() {
225
+ return { ...selfIdentity(), ...getSelfSettings() };
226
+ }
227
+
228
+ function emitChange() {
229
+ try {
230
+ emit(REMOTE_RELAY_SOCKET_EVENT, { relays: listPublic(), self: selfPayload() });
231
+ } catch (error) {
232
+ logger?.warn?.(`[remote-relays] update event failed: ${error?.message || error}`);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * `{ publicUrl?, inboundEnabled? }`. The public URL is stored as a bare
238
+ * base URL (a pasted ?token= is dropped: this address is handed to other
239
+ * relays); an empty string clears it.
240
+ */
241
+ function setSelfSettings(patch = {}) {
242
+ const input = patch && typeof patch === 'object' ? patch : {};
243
+ let nextPublicUrl;
244
+ if (input.publicUrl !== undefined) {
245
+ const raw = toText(input.publicUrl);
246
+ if (!raw) {
247
+ nextPublicUrl = '';
248
+ } else {
249
+ const link = normalizeRemoteRelayLink(raw);
250
+ if (!link.ok) return invalid(link.error);
251
+ nextPublicUrl = link.baseUrl;
252
+ }
253
+ }
254
+ if (input.inboundEnabled !== undefined && typeof input.inboundEnabled !== 'boolean') {
255
+ return invalid('inboundEnabled must be true or false');
256
+ }
257
+ if (nextPublicUrl !== undefined) writeJsonSetting(REMOTE_RELAY_SETTING_KEYS.publicUrl, nextPublicUrl);
258
+ if (input.inboundEnabled !== undefined) writeJsonSetting(REMOTE_RELAY_SETTING_KEYS.inboundEnabled, input.inboundEnabled);
259
+ emitChange();
260
+ return { ok: true, ...getSelfSettings() };
261
+ }
262
+
263
+ // ─── Remote relays ─────────────────────────────────────────────────────────
264
+
265
+ function list() {
266
+ return readList();
267
+ }
268
+
269
+ function listPublic() {
270
+ return readList().map(toPublic);
271
+ }
272
+
273
+ function get(id) {
274
+ const key = toText(id);
275
+ if (!key) return null;
276
+ return readList().find((entry) => entry.id === key) || null;
277
+ }
278
+
279
+ /**
280
+ * `{ relay }` for a unique case-insensitive match on a name or URL host (a
281
+ * leading @ and a pasted URL are accepted), else `{ error, names }`. Unlike
282
+ * a chat mention, an agent naming a relay may use a loopback or IP host too.
283
+ */
284
+ function resolve(nameOrAlias) {
285
+ const entries = readList();
286
+ let query = toText(nameOrAlias).replace(/^@/, '').toLowerCase();
287
+ if (/^[a-z][a-z0-9+.-]*:\/\//.test(query)) query = hostOf(query);
288
+ const matches = query
289
+ ? entries.filter((entry) => remoteRelayAliases(entry).includes(query) || hostOf(entry.url) === query)
290
+ : [];
291
+ if (matches.length === 1) return { relay: matches[0] };
292
+ if (matches.length > 1) return { error: 'ambiguous', names: matches.map((entry) => entry.name) };
293
+ return { error: 'unknown', names: entries.map((entry) => entry.name) };
294
+ }
295
+
296
+ /** Adds a validated entry (pairing does the probing and deduplication). */
297
+ function add(input = {}) {
298
+ const url = toText(input.url).replace(/\/+$/, '');
299
+ const policy = checkRemoteRelayUrlPolicy(url);
300
+ if (!url || !policy.ok) {
301
+ const error = new Error(policy.error || 'A remote relay needs a URL');
302
+ error.status = 400;
303
+ throw error;
304
+ }
305
+ const token = toText(input.token);
306
+ const tokenMode = token && (input.tokenMode === 'custom' || input.tokenMode === undefined) ? 'custom' : 'own';
307
+ const entry = normalizeEntry({
308
+ relayId: input.relayId,
309
+ name: input.name,
310
+ version: input.version,
311
+ platform: input.platform,
312
+ protocol: input.protocol,
313
+ lastSeenAt: input.lastSeenAt,
314
+ lastStatus: input.lastStatus,
315
+ lastError: input.lastError,
316
+ id: `rr_${randomUUID()}`,
317
+ url,
318
+ tokenMode,
319
+ token,
320
+ permission: input.permission,
321
+ addedBy: input.addedBy,
322
+ addedAt: nowIso(),
323
+ });
324
+ writeList([...readList(), entry]);
325
+ emitChange();
326
+ return entry;
327
+ }
328
+
329
+ /**
330
+ * Internal read-modify-write by id. `fields` are merged over the stored
331
+ * entry; a `token: undefined` field removes the token. Returns
332
+ * `{ before, after }` (null when the entry is gone, e.g. removed while a
333
+ * probe was in flight).
334
+ */
335
+ function patchStored(id, fields) {
336
+ const entries = readList();
337
+ const index = entries.findIndex((entry) => entry.id === id);
338
+ if (index === -1) return null;
339
+ const before = entries[index];
340
+ const merged = { ...before, ...fields };
341
+ if (Object.prototype.hasOwnProperty.call(fields, 'token') && fields.token === undefined) delete merged.token;
342
+ const after = normalizeEntry(merged);
343
+ entries[index] = after;
344
+ writeList(entries);
345
+ return { before, after };
346
+ }
347
+
348
+ /** User edits: `{ permission, url, token, tokenMode }` (PATCH /api/remote-relays/:id). */
349
+ function update(id, patch = {}) {
350
+ const current = get(id);
351
+ if (!current) return invalid('Unknown remote relay', 404);
352
+ const input = patch && typeof patch === 'object' ? patch : {};
353
+ const fields = {};
354
+
355
+ if (input.permission !== undefined) {
356
+ const permission = toText(input.permission).toLowerCase();
357
+ if (!REMOTE_RELAY_PERMISSIONS.includes(permission)) {
358
+ return invalid(`permission must be one of: ${REMOTE_RELAY_PERMISSIONS.join(', ')}`);
359
+ }
360
+ fields.permission = normalizeRemoteRelayPermission(permission);
361
+ }
362
+
363
+ if (input.url !== undefined) {
364
+ const link = normalizeRemoteRelayLink(input.url);
365
+ if (!link.ok) return invalid(link.error);
366
+ if (link.baseUrl !== current.url) {
367
+ fields.url = link.baseUrl;
368
+ fields.lastStatus = 'unknown';
369
+ fields.lastError = null;
370
+ }
371
+ }
372
+
373
+ if (input.tokenMode !== undefined && !['own', 'custom'].includes(input.tokenMode)) {
374
+ return invalid('tokenMode must be own or custom');
375
+ }
376
+ if (input.token !== undefined || input.tokenMode !== undefined) {
377
+ const token = input.token !== undefined ? toText(input.token) : toText(current.token);
378
+ const tokenMode = input.tokenMode !== undefined ? input.tokenMode : (token ? 'custom' : 'own');
379
+ if (tokenMode === 'custom' && !token) return invalid('A custom token cannot be empty');
380
+ const nextToken = tokenMode === 'custom' ? token : undefined;
381
+ if (tokenMode !== current.tokenMode || nextToken !== current.token) {
382
+ fields.tokenMode = tokenMode;
383
+ fields.token = nextToken;
384
+ fields.lastStatus = 'unknown';
385
+ fields.lastError = null;
386
+ }
387
+ }
388
+
389
+ const result = Object.keys(fields).length ? patchStored(current.id, fields) : { before: current, after: current };
390
+ if (!result) return invalid('Unknown remote relay', 404);
391
+ if (fingerprint(result.before) !== fingerprint(result.after)) emitChange();
392
+ return { ok: true, relay: toPublic(result.after) };
393
+ }
394
+
395
+ /** Local only: the other side keeps its own entry for this relay. */
396
+ function remove(id) {
397
+ const key = toText(id);
398
+ const entries = readList();
399
+ const remaining = entries.filter((entry) => entry.id !== key);
400
+ if (!key || remaining.length === entries.length) return invalid('Unknown remote relay', 404);
401
+ writeList(remaining);
402
+ try {
403
+ repository?.forgetRelay?.(key);
404
+ } catch (error) {
405
+ logger?.warn?.(`[remote-relays] forgetting unlocks failed: ${error?.message || error}`);
406
+ }
407
+ emitChange();
408
+ return { ok: true };
409
+ }
410
+
411
+ function findByUrl(entries, url) {
412
+ const target = toText(url).replace(/\/+$/, '').toLowerCase();
413
+ return target ? entries.find((entry) => entry.url.toLowerCase() === target) || null : null;
414
+ }
415
+
416
+ /**
417
+ * The inbound side of pairing: another relay introduced itself. Deduplicated
418
+ * by relayId, else by URL. For a new entry a token means "use this one for
419
+ * me"; none means our own token works there too. A known entry keeps the
420
+ * address and token stored here (the user set them, or saw them set): an
421
+ * introduction only refreshes what the relay says about itself, and a
422
+ * different address or token it offers is logged, not taken.
423
+ */
424
+ function upsertFromPairing({ relayId, name, url, token, version: offeredVersion, platform: offeredPlatform, protocol } = {}) {
425
+ const entries = readList();
426
+ const id = toText(relayId);
427
+ const baseUrl = toText(url).replace(/\/+$/, '');
428
+ const existing = (id && entries.find((entry) => entry.relayId === id)) || findByUrl(entries, baseUrl);
429
+ const secret = toText(token);
430
+ const selfDescription = {
431
+ ...(toText(name) ? { name } : {}),
432
+ ...(toText(offeredVersion) ? { version: offeredVersion } : {}),
433
+ ...(toText(offeredPlatform) ? { platform: offeredPlatform } : {}),
434
+ ...(Number.isInteger(protocol) && protocol >= 0 ? { protocol } : {}),
435
+ };
436
+ if (existing) {
437
+ const offeredOtherUrl = !!baseUrl && baseUrl.toLowerCase() !== existing.url.toLowerCase();
438
+ const offeredOtherToken = secret
439
+ ? existing.tokenMode !== 'custom' || secret !== existing.token
440
+ : existing.tokenMode === 'custom';
441
+ if (offeredOtherUrl || offeredOtherToken) {
442
+ const changes = [
443
+ offeredOtherUrl ? `address ${baseUrl} (kept ${existing.url})` : '',
444
+ offeredOtherToken ? 'token (kept the stored one)' : '',
445
+ ].filter(Boolean).join(' and a different ');
446
+ const label = (toText(name) || existing.name).replace(/[\r\n"]+/g, ' ');
447
+ try {
448
+ logger?.warn?.(`[remote-relays] pairing from "${label}" offered a different ${changes}; change it in Settings → Relays if that is right`);
449
+ } catch {}
450
+ }
451
+ const result = patchStored(existing.id, { relayId: id || existing.relayId, ...selfDescription });
452
+ if (result && fingerprint(result.before) !== fingerprint(result.after)) emitChange();
453
+ return result?.after || existing;
454
+ }
455
+ const tokenFields = secret ? { tokenMode: 'custom', token: secret } : { tokenMode: 'own' };
456
+ return add({
457
+ ...selfDescription,
458
+ relayId: id,
459
+ name: toText(name) || hostOf(baseUrl),
460
+ url: baseUrl,
461
+ ...tokenFields,
462
+ addedBy: 'pairing',
463
+ });
464
+ }
465
+
466
+ /** Stores what a successful probe said. Returns `{ changed, relay }` (full entry). */
467
+ function applyIdentity(id, identity, { emitOnChange = true } = {}) {
468
+ let fields;
469
+ if (identity?.relayId && identity.relayId === instanceId()) {
470
+ // An address that turns out to be this relay itself must not look healthy.
471
+ fields = { lastStatus: 'error', lastError: 'This address points at this relay itself' };
472
+ } else {
473
+ fields = { lastStatus: 'online', lastSeenAt: nowIso(), lastError: null };
474
+ if (identity?.relayId) fields.relayId = identity.relayId;
475
+ if (toText(identity?.name)) fields.name = identity.name;
476
+ if (identity?.version) fields.version = identity.version;
477
+ if (identity?.platform) fields.platform = identity.platform;
478
+ if (Number.isInteger(identity?.protocol)) fields.protocol = identity.protocol;
479
+ }
480
+ const result = patchStored(id, fields);
481
+ if (!result) return { changed: false, relay: null };
482
+ const changed = fingerprint(result.before) !== fingerprint(result.after);
483
+ if (changed && emitOnChange) emitChange();
484
+ return { changed, relay: result.after };
485
+ }
486
+
487
+ /** Public for pairing: the probe it already made counts as a check. */
488
+ function recordIdentity(id, identity) {
489
+ return applyIdentity(id, identity).relay;
490
+ }
491
+
492
+ function applyFailure(id, error, { emitOnChange = true } = {}) {
493
+ const result = patchStored(id, {
494
+ lastStatus: statusForError(error),
495
+ lastError: describeFailure(error).slice(0, LAST_ERROR_MAX) || 'Check failed',
496
+ });
497
+ if (!result) return { changed: false, relay: null };
498
+ const changed = fingerprint(result.before) !== fingerprint(result.after);
499
+ if (changed && emitOnChange) emitChange();
500
+ return { changed, relay: result.after };
501
+ }
502
+
503
+ async function checkEntry(id, { timeoutMs, emitOnChange = true } = {}) {
504
+ const entry = get(id);
505
+ if (!entry) return { changed: false, relay: null };
506
+ try {
507
+ const identity = await client.probe(entry.url, client.tokenFor(entry), { timeoutMs });
508
+ return applyIdentity(entry.id, identity, { emitOnChange });
509
+ } catch (error) {
510
+ return applyFailure(entry.id, error, { emitOnChange });
511
+ }
512
+ }
513
+
514
+ /** Probes one remote now; the public entry, or null for an unknown id. */
515
+ async function check(id, { timeoutMs } = {}) {
516
+ const { relay } = await checkEntry(toText(id), { timeoutMs });
517
+ return toPublic(relay);
518
+ }
519
+
520
+ /** One pass over every remote; a single event when anything changed. */
521
+ function checkAll() {
522
+ if (sweeping) return sweeping;
523
+ sweeping = (async () => {
524
+ const ids = readList().map((entry) => entry.id);
525
+ if (ids.length === 0) return false;
526
+ const results = await Promise.all(ids.map((id) => checkEntry(id, {
527
+ timeoutMs: HEALTH_CHECK_TIMEOUT_MS,
528
+ emitOnChange: false,
529
+ }).catch(() => ({ changed: false }))));
530
+ const changed = results.some((result) => result?.changed);
531
+ if (changed) emitChange();
532
+ return changed;
533
+ })().finally(() => {
534
+ sweeping = null;
535
+ });
536
+ return sweeping;
537
+ }
538
+
539
+ function startHealthLoop({ immediate = true } = {}) {
540
+ if (healthTimer) return;
541
+ healthTimer = setIntervalImpl(() => {
542
+ checkAll().catch((error) => logger?.warn?.(`[remote-relays] health check failed: ${error?.message || error}`));
543
+ }, REMOTE_RELAY_LIMITS.healthIntervalMs);
544
+ // An idle relay must not be held awake by the health loop.
545
+ if (typeof healthTimer?.unref === 'function') healthTimer.unref();
546
+ if (immediate) void checkAll().catch(() => {});
547
+ }
548
+
549
+ function stopHealthLoop() {
550
+ if (!healthTimer) return;
551
+ clearIntervalImpl(healthTimer);
552
+ healthTimer = null;
553
+ }
554
+
555
+ function tokenFor(relay) {
556
+ return client.tokenFor(relay);
557
+ }
558
+
559
+ return {
560
+ instanceId,
561
+ selfName,
562
+ selfIdentity,
563
+ getSelfSettings,
564
+ setSelfSettings,
565
+ list,
566
+ listPublic,
567
+ get,
568
+ resolve,
569
+ add,
570
+ update,
571
+ remove,
572
+ upsertFromPairing,
573
+ recordIdentity,
574
+ check,
575
+ checkAll,
576
+ startHealthLoop,
577
+ stopHealthLoop,
578
+ tokenFor,
579
+ get healthLoopRunning() { return Boolean(healthTimer); },
580
+ };
581
+ }
@@ -77,7 +77,7 @@ export function createSdkSessionImportService({
77
77
  logger = console,
78
78
  } = {}) {
79
79
  if (!db || !stmts || typeof createClient !== 'function') throw new Error('SDK session importer requires database, statements, and a client factory');
80
- let runtime = null;
80
+ let runtimeCreation = null;
81
81
  let activeRun = null;
82
82
  let closing = false;
83
83
  const countConversationMessages = db.prepare(`
@@ -149,8 +149,18 @@ export function createSdkSessionImportService({
149
149
  // Refusing after dispose() is what stops a mid-shutdown import from
150
150
  // resurrecting a fresh SDK client the server would never tear down.
151
151
  if (closing) throw new Error('SDK session importer is shutting down');
152
- if (!runtime) runtime = await createClient();
153
- return runtime;
152
+ // Callers share the start in flight: parallel deletes, or a delete during
153
+ // the startup import, each started a runtime of their own, and only the
154
+ // last one was ever disposed. A start that fails is dropped, so the next
155
+ // call tries again.
156
+ if (!runtimeCreation) {
157
+ const creation = Promise.resolve().then(() => createClient());
158
+ runtimeCreation = creation;
159
+ creation.catch(() => {
160
+ if (runtimeCreation === creation) runtimeCreation = null;
161
+ });
162
+ }
163
+ return runtimeCreation;
154
164
  }
155
165
 
156
166
  function isTombstoned(sdkSessionId) {
@@ -329,6 +339,34 @@ export function createSdkSessionImportService({
329
339
  return activeRun;
330
340
  }
331
341
 
342
+ /**
343
+ * Remove a Copilot session from the runtime's own store, for a conversation
344
+ * deleted in the relay. Goes through the SDK rather than the session-state
345
+ * folder so the runtime's bookkeeping stays consistent. Throws when the
346
+ * runtime cannot do it; the caller decides what that means.
347
+ */
348
+ async function deleteSession(sdkSessionId) {
349
+ const sid = text(sdkSessionId);
350
+ if (!sid) throw new Error('Missing session id');
351
+ const client = await getRuntime();
352
+ if (typeof client?.client?.deleteSession !== 'function') {
353
+ throw new Error('deleteSession() is unavailable in this Copilot runtime');
354
+ }
355
+ await client.client.deleteSession(sid);
356
+ }
357
+
358
+ /**
359
+ * Whether the relay only imported this session and never ran it: the ledger
360
+ * says the importer brought it in, and no queued turn or relay worker says
361
+ * the relay has run it since. Continuing an imported conversation does not
362
+ * touch the ledger, so that evidence is checked here too, and it flips the
363
+ * ledger to 'relay' for good, as it does on import.
364
+ */
365
+ function isImportedOnly(sdkSessionId) {
366
+ const sid = text(sdkSessionId);
367
+ return !!sid && !relayOwnsSession(sid);
368
+ }
369
+
332
370
  async function refreshConversation(conversation) {
333
371
  const sdkSessionId = text(conversation?.sdk_session_id || conversation?.sdkSessionId || conversation?.id);
334
372
  if (!sdkSessionId || isTombstoned(sdkSessionId)) {
@@ -343,14 +381,18 @@ export function createSdkSessionImportService({
343
381
  runStartupImport,
344
382
  importSession,
345
383
  refreshConversation,
384
+ deleteSession,
385
+ isImportedOnly,
346
386
  async dispose() {
347
387
  // Flag first: getRuntime() must refuse before the client goes away, or a
348
- // concurrent import observes runtime = null and creates a replacement
349
- // while the server is exiting.
388
+ // concurrent import finds no runtime and starts a replacement while the
389
+ // server is exiting.
350
390
  closing = true;
351
391
  try { await activeRun; } catch {}
352
- const current = runtime;
353
- runtime = null;
392
+ // A start still in flight is waited for, so its runtime is disposed too.
393
+ const creation = runtimeCreation;
394
+ runtimeCreation = null;
395
+ const current = await creation?.catch(() => null);
354
396
  await current?.dispose?.();
355
397
  },
356
398
  };