@oar-sh/oar 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +67 -0
  4. package/README.md +62 -13
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +92 -25
  10. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  11. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  12. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  13. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  14. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  15. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  16. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  17. package/server/db-schema.mjs +8 -0
  18. package/server/grok-worker/acp-client.mjs +92 -6
  19. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  20. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  21. package/server/mcp/oar-mcp-server.mjs +473 -0
  22. package/server/migrations/0005-remote-relays.mjs +90 -0
  23. package/server/public/app/action-confirmations.js +141 -13
  24. package/server/public/app/api-client.js +103 -0
  25. package/server/public/app/bootstrap.js +21 -6
  26. package/server/public/app/conversation-deep-link.mjs +42 -0
  27. package/server/public/app/conversation-view.js +98 -7
  28. package/server/public/app/host-suspend-ui.js +272 -0
  29. package/server/public/app/journal-view.js +4 -1
  30. package/server/public/app/mention-autocomplete.mjs +298 -0
  31. package/server/public/app/push-settings.js +1 -0
  32. package/server/public/app/pwa-install.js +8 -3
  33. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  34. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  35. package/server/public/app/remote-relay-shared.mjs +85 -0
  36. package/server/public/app/remote-relays-settings.js +618 -0
  37. package/server/public/app/remote-relays-store.mjs +126 -0
  38. package/server/public/app/settings-modal.js +4 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +18 -1
  41. package/server/public/app/thinking-follow.js +260 -0
  42. package/server/public/app/tool-activity-emoji.mjs +4 -0
  43. package/server/public/index.html +257 -8
  44. package/server/relay-tools.md +5 -1
  45. package/server/repositories/message-repository.mjs +7 -0
  46. package/server/repositories/question-repository.mjs +3 -0
  47. package/server/repositories/remote-relay-repository.mjs +75 -0
  48. package/server/repositories/session-repository.mjs +6 -1
  49. package/server/routes/ask-user-routes.mjs +42 -2
  50. package/server/routes/messages-routes.mjs +90 -16
  51. package/server/routes/remote-relay-routes.mjs +133 -0
  52. package/server/routes/sessions-routes.mjs +80 -29
  53. package/server/server-runtime.mjs +238 -0
  54. package/server/services/github-ci-activity-service.mjs +199 -0
  55. package/server/services/host-suspend-command.mjs +38 -0
  56. package/server/services/host-suspend-service.mjs +254 -0
  57. package/server/services/push-dispatch-service.mjs +37 -4
  58. package/server/services/remote-relay-caller-context.mjs +341 -0
  59. package/server/services/remote-relay-client.mjs +332 -0
  60. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  61. package/server/services/remote-relay-inbound.mjs +187 -0
  62. package/server/services/remote-relay-pairing.mjs +189 -0
  63. package/server/services/remote-relay-registry-service.mjs +581 -0
  64. package/server/services/session-worker-launch-service.mjs +131 -12
  65. package/shared/remote-relay-contract.mjs +539 -0
  66. package/shared/remote-relay-mentions.mjs +129 -0
  67. package/shared/remote-relay-tool-core.mjs +314 -0
  68. package/shared/worker-runtime/api-client.mjs +112 -22
@@ -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.',
@@ -118,12 +120,36 @@ const STEER_NOTE_PATTERN = /^\s*\[Sent while you were still working on my previo
118
120
  // it is matched on its own, and only where it guards a "/".
119
121
  const SLASH_COMMAND_GUARD_PATTERN = /^\u200B(?=\s*\/)/;
120
122
 
121
- function stripLeadingRelayNotes(text) {
122
- const stripped = String(text || '').replace(STEER_NOTE_PATTERN, '').replace(SLASH_COMMAND_GUARD_PATTERN, '');
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, '');
123
144
  return stripped.trim() ? stripped.trim() : String(text || '').trim();
124
145
  }
125
146
 
126
- export function stripRelayPromptContext(text, relayMode = '') {
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;
127
153
  const value = stripAttachmentPromptArtifacts(text)
128
154
  .replace(MEDIA_EMBED_BLOCK_PATTERN, '')
129
155
  .trim();
@@ -131,7 +157,24 @@ export function stripRelayPromptContext(text, relayMode = '') {
131
157
  const patterns = buildPromptPrefixPatterns(relayMode);
132
158
  for (const pattern of patterns) {
133
159
  const stripped = value.replace(pattern, '').trim();
134
- if (stripped && stripped !== value) return stripLeadingRelayNotes(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;
135
177
  }
136
- return stripLeadingRelayNotes(value);
178
+ if (next === value) return value;
179
+ return next.trim() ? next.trim() : value.trim();
137
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
+ }