@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.
- package/.github/extensions/web-relay/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +336 -0
- package/README.md +455 -423
- package/bin/oar.js +25 -10
- package/package.json +11 -5
- package/server/README.md +44 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +1073 -69
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +28 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +125 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +55 -14
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1016 -200
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +149 -6
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +12 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -1
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +40 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +463 -17
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +7 -1
- package/server/repositories/message-repository.mjs +68 -18
- package/server/repositories/question-repository.mjs +18 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +13 -32
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +572 -56
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +378 -84
- package/server/server-runtime.mjs +303 -25
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
// The shared contract of the remote-relay feature: one OAR relay lets the
|
|
2
|
+
// agents of its sessions work on OTHER OAR relays it is paired with.
|
|
3
|
+
//
|
|
4
|
+
// Everything that more than one side has to agree on lives here — the tool
|
|
5
|
+
// name, description and schema every provider adapter registers, the action
|
|
6
|
+
// → permission map the relay enforces, the limits, the error codes, the
|
|
7
|
+
// provenance ("origin") shape a relay attaches when its agent prompts a remote
|
|
8
|
+
// session, the prompt header line, and the URL policy for remote addresses.
|
|
9
|
+
//
|
|
10
|
+
// SDK-free and side-effect free: imported by the server, the session workers,
|
|
11
|
+
// the stdio MCP server and (for the header/URL helpers) the browser.
|
|
12
|
+
|
|
13
|
+
export const REMOTE_RELAY_TOOL_NAME = 'remote_relay';
|
|
14
|
+
|
|
15
|
+
// Bumped when a relay starts relying on a newer inbound behaviour of its peers.
|
|
16
|
+
// 0 = a relay without this feature (reachable read/prompt only, no pairing).
|
|
17
|
+
export const REMOTE_RELAY_PROTOCOL = 1;
|
|
18
|
+
|
|
19
|
+
// Every agent path converges on this one local endpoint; the relay does the
|
|
20
|
+
// gating, approval, forwarding and waiting.
|
|
21
|
+
export const REMOTE_RELAY_TOOL_ENDPOINT = '/api/remote-relays/tool';
|
|
22
|
+
|
|
23
|
+
export const REMOTE_RELAY_ACTIONS = Object.freeze([
|
|
24
|
+
'list_relays',
|
|
25
|
+
'relay_info',
|
|
26
|
+
'list_sessions',
|
|
27
|
+
'read_session',
|
|
28
|
+
'wait',
|
|
29
|
+
'send',
|
|
30
|
+
'create_session',
|
|
31
|
+
'answer_question',
|
|
32
|
+
'stop',
|
|
33
|
+
'archive',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export const REMOTE_RELAY_PERMISSIONS = Object.freeze(['read', 'prompt', 'full']);
|
|
37
|
+
export const REMOTE_RELAY_DEFAULT_PERMISSION = 'full';
|
|
38
|
+
|
|
39
|
+
// null = allowed without a mention and without a permission (local data only).
|
|
40
|
+
export const REMOTE_RELAY_ACTION_PERMISSION = Object.freeze({
|
|
41
|
+
list_relays: null,
|
|
42
|
+
relay_info: 'read',
|
|
43
|
+
list_sessions: 'read',
|
|
44
|
+
read_session: 'read',
|
|
45
|
+
wait: 'read',
|
|
46
|
+
send: 'prompt',
|
|
47
|
+
create_session: 'full',
|
|
48
|
+
answer_question: 'full',
|
|
49
|
+
stop: 'full',
|
|
50
|
+
archive: 'full',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Actions that change something on the remote relay: they need approval when
|
|
54
|
+
// the calling session runs in ask or plan mode.
|
|
55
|
+
export const REMOTE_RELAY_WRITE_ACTIONS = Object.freeze(['send', 'create_session', 'answer_question', 'stop', 'archive']);
|
|
56
|
+
|
|
57
|
+
export const REMOTE_RELAY_APPROVAL_MODES = Object.freeze(['ask', 'plan']);
|
|
58
|
+
|
|
59
|
+
export const REMOTE_RELAY_SESSION_SCOPES = Object.freeze(['active', 'recent', 'all']);
|
|
60
|
+
export const REMOTE_RELAY_PROVIDERS = Object.freeze(['github', 'openai', 'claude', 'cursor', 'grok']);
|
|
61
|
+
export const REMOTE_RELAY_MODES = Object.freeze(['plan', 'ask', 'agent', 'autopilot']);
|
|
62
|
+
export const REMOTE_RELAY_IF_BUSY = Object.freeze(['queue', 'fail']);
|
|
63
|
+
|
|
64
|
+
export const REMOTE_RELAY_RESULT_STATUSES = Object.freeze([
|
|
65
|
+
'queued',
|
|
66
|
+
'running',
|
|
67
|
+
'waiting_for_answer',
|
|
68
|
+
'done',
|
|
69
|
+
'failed',
|
|
70
|
+
'stopped',
|
|
71
|
+
'cancelled',
|
|
72
|
+
'duplicate',
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
export const REMOTE_RELAY_LIMITS = Object.freeze({
|
|
76
|
+
waitDefaultSeconds: 120,
|
|
77
|
+
waitMaxSeconds: 600,
|
|
78
|
+
listDefault: 20,
|
|
79
|
+
listMax: 100,
|
|
80
|
+
readDefault: 10,
|
|
81
|
+
readMax: 50,
|
|
82
|
+
maxCharsDefault: 12000,
|
|
83
|
+
maxCharsMax: 50000,
|
|
84
|
+
perMessageChars: 4000,
|
|
85
|
+
textMax: 100000,
|
|
86
|
+
hopLimit: 2,
|
|
87
|
+
callsPerMinute: 60,
|
|
88
|
+
writesPerMinute: 10,
|
|
89
|
+
healthIntervalMs: 60_000,
|
|
90
|
+
requestTimeoutMs: 15_000,
|
|
91
|
+
responseMaxBytes: 5 * 1024 * 1024,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const REMOTE_RELAY_ERROR_CODES = Object.freeze({
|
|
95
|
+
invalidInput: 'REMOTE_RELAY_INVALID_INPUT',
|
|
96
|
+
unknown: 'REMOTE_RELAY_UNKNOWN',
|
|
97
|
+
locked: 'REMOTE_RELAY_LOCKED',
|
|
98
|
+
forbidden: 'REMOTE_RELAY_FORBIDDEN',
|
|
99
|
+
hopLimit: 'REMOTE_RELAY_HOP_LIMIT',
|
|
100
|
+
approvalDenied: 'REMOTE_RELAY_APPROVAL_DENIED',
|
|
101
|
+
rateLimited: 'REMOTE_RELAY_RATE_LIMITED',
|
|
102
|
+
offline: 'REMOTE_RELAY_OFFLINE',
|
|
103
|
+
unauthorized: 'REMOTE_RELAY_UNAUTHORIZED',
|
|
104
|
+
inboundDisabled: 'REMOTE_INBOUND_DISABLED',
|
|
105
|
+
unsupported: 'REMOTE_RELAY_UNSUPPORTED',
|
|
106
|
+
notFound: 'REMOTE_RELAY_NOT_FOUND',
|
|
107
|
+
providerUnavailable: 'REMOTE_RELAY_PROVIDER_UNAVAILABLE',
|
|
108
|
+
busy: 'REMOTE_RELAY_BUSY',
|
|
109
|
+
noTurn: 'REMOTE_RELAY_NO_ACTIVE_TURN',
|
|
110
|
+
internal: 'REMOTE_RELAY_INTERNAL',
|
|
111
|
+
// Prefix: the remote answered with this HTTP status, e.g. REMOTE_RELAY_HTTP_409.
|
|
112
|
+
httpPrefix: 'REMOTE_RELAY_HTTP_',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// app_settings keys (JSON values). `remote_relays` holds tokens for remotes
|
|
116
|
+
// that use their own token: never return that value through an API.
|
|
117
|
+
export const REMOTE_RELAY_SETTING_KEYS = Object.freeze({
|
|
118
|
+
instanceId: 'relay_instance_id',
|
|
119
|
+
relays: 'remote_relays',
|
|
120
|
+
publicUrl: 'relay_public_url',
|
|
121
|
+
inboundEnabled: 'remote_relay_inbound_enabled',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Headers a relay adds to every call it forwards to a remote. The remote uses
|
|
125
|
+
// them for the inbound switch and to carry the hop count; never the X-Relay-*
|
|
126
|
+
// worker identity headers.
|
|
127
|
+
export const REMOTE_RELAY_HEADERS = Object.freeze({
|
|
128
|
+
origin: 'x-oar-remote-origin',
|
|
129
|
+
hops: 'x-oar-remote-hops',
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
export const REMOTE_RELAY_SOCKET_EVENT = 'remote_relays_updated';
|
|
133
|
+
|
|
134
|
+
export const REMOTE_RELAY_TOOL_DESCRIPTION =
|
|
135
|
+
'Work with sessions on other OAR relays this relay is paired with ("remote relays"): '
|
|
136
|
+
+ 'list the relays, list or read their sessions, prompt a session, start a new one, '
|
|
137
|
+
+ 'wait for its reply, and answer or stop it. Use it only when the user asks for work '
|
|
138
|
+
+ 'on another relay or mentions one. Start with {action:"list_relays"}. A relay stays '
|
|
139
|
+
+ 'locked until the user mentions it in this conversation (for example @name); a '
|
|
140
|
+
+ 'locked relay refuses everything except list_relays, so ask the user to mention it. '
|
|
141
|
+
+ 'send and create_session queue the prompt and wait up to wait_seconds (default 120, '
|
|
142
|
+
+ 'max 600) for the reply. If the remote turn is still running you get status '
|
|
143
|
+
+ '"running" with progress: call {action:"wait", relay, session, message_id} to keep '
|
|
144
|
+
+ 'waiting. A remote agent that answers but keeps working in the background also comes '
|
|
145
|
+
+ 'back as "running", with its latest reply so far; "done" means it has finished. '
|
|
146
|
+
+ 'If a result lists pendingQuestions, the remote agent is waiting for an '
|
|
147
|
+
+ 'answer: ask the user (unless they told you to decide yourself) and pass the answer '
|
|
148
|
+
+ 'with answer_question. Write actions can first need the user\'s approval. Always '
|
|
149
|
+
+ 'tell the user which relay and session you worked on.';
|
|
150
|
+
|
|
151
|
+
export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
152
|
+
type: 'object',
|
|
153
|
+
properties: {
|
|
154
|
+
action: {
|
|
155
|
+
type: 'string',
|
|
156
|
+
enum: [...REMOTE_RELAY_ACTIONS],
|
|
157
|
+
description: 'list_relays: paired relays and whether each is unlocked here. relay_info: providers, models, '
|
|
158
|
+
+ 'workspaces of a relay. list_sessions / read_session: discover and read sessions. send: prompt an '
|
|
159
|
+
+ 'existing session. create_session: start a new session with a first prompt. wait: keep waiting for '
|
|
160
|
+
+ 'a reply. answer_question: answer a question the remote agent asked. stop: stop the running turn. '
|
|
161
|
+
+ 'archive: archive a session.',
|
|
162
|
+
},
|
|
163
|
+
relay: { type: 'string', description: 'Relay name (or its host) from list_relays. Required except for list_relays.' },
|
|
164
|
+
session: { type: 'string', description: 'Remote session (conversation) id from list_sessions or create_session.' },
|
|
165
|
+
text: { type: 'string', description: 'send / create_session: the prompt for the remote agent.' },
|
|
166
|
+
message_id: { type: 'string', description: 'wait: the message_id that send or create_session returned.' },
|
|
167
|
+
wait_seconds: {
|
|
168
|
+
type: 'integer',
|
|
169
|
+
minimum: 0,
|
|
170
|
+
maximum: REMOTE_RELAY_LIMITS.waitMaxSeconds,
|
|
171
|
+
description: 'send / create_session / wait: how long to wait for the reply (default 120, 0 = do not wait).',
|
|
172
|
+
},
|
|
173
|
+
scope: {
|
|
174
|
+
type: 'string',
|
|
175
|
+
enum: [...REMOTE_RELAY_SESSION_SCOPES],
|
|
176
|
+
description: 'list_sessions: active = working now, recent = newest first (default), all = page through everything.',
|
|
177
|
+
},
|
|
178
|
+
query: { type: 'string', description: 'list_sessions: only sessions whose title contains this text.' },
|
|
179
|
+
limit: { type: 'integer', minimum: 1, maximum: REMOTE_RELAY_LIMITS.listMax, description: 'list_sessions: page size (default 20).' },
|
|
180
|
+
cursor: { type: 'string', description: 'list_sessions: nextCursor from the previous page.' },
|
|
181
|
+
last: { type: 'integer', minimum: 1, maximum: REMOTE_RELAY_LIMITS.readMax, description: 'read_session: how many recent messages (default 10).' },
|
|
182
|
+
before: { type: 'string', description: 'read_session: olderCursor from the previous read, to page back.' },
|
|
183
|
+
include_activity: { type: 'boolean', description: 'read_session: include the tool activity lines of each reply.' },
|
|
184
|
+
max_chars: {
|
|
185
|
+
type: 'integer',
|
|
186
|
+
minimum: 500,
|
|
187
|
+
maximum: REMOTE_RELAY_LIMITS.maxCharsMax,
|
|
188
|
+
description: 'read_session: total text budget (default 12000).',
|
|
189
|
+
},
|
|
190
|
+
provider: {
|
|
191
|
+
type: 'string',
|
|
192
|
+
enum: [...REMOTE_RELAY_PROVIDERS],
|
|
193
|
+
description: 'create_session: provider (default: the same provider as this session).',
|
|
194
|
+
},
|
|
195
|
+
model: {
|
|
196
|
+
type: 'string',
|
|
197
|
+
description: 'create_session: model id (default: this session\'s model when the remote offers it, else the remote\'s default). '
|
|
198
|
+
+ 'send: switch the remote session to this model (default: it keeps its own).',
|
|
199
|
+
},
|
|
200
|
+
cwd: { type: 'string', description: 'create_session: working directory on the remote relay (default: its default workspace).' },
|
|
201
|
+
mode: {
|
|
202
|
+
type: 'string',
|
|
203
|
+
enum: [...REMOTE_RELAY_MODES],
|
|
204
|
+
description: 'send / create_session: relay mode for the remote turn (default: this session\'s mode).',
|
|
205
|
+
},
|
|
206
|
+
title: { type: 'string', description: 'create_session: session title (default: from the prompt).' },
|
|
207
|
+
question_id: { type: 'string', description: 'answer_question: id from pendingQuestions.' },
|
|
208
|
+
answer: { type: 'string', description: 'answer_question: free-text answer.' },
|
|
209
|
+
choices: {
|
|
210
|
+
type: 'array',
|
|
211
|
+
items: { type: 'string' },
|
|
212
|
+
description: 'answer_question: selected option labels, for questions with options.',
|
|
213
|
+
},
|
|
214
|
+
if_busy: {
|
|
215
|
+
type: 'string',
|
|
216
|
+
enum: [...REMOTE_RELAY_IF_BUSY],
|
|
217
|
+
description: 'send: queue (default; steers into a running turn where the remote supports it) or fail when the session is busy.',
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
required: ['action'],
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
function toText(value) {
|
|
224
|
+
return String(value ?? '').trim();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function clampInteger(value, { min, max, fallback }) {
|
|
228
|
+
if (value === undefined || value === null || toText(value) === '') return fallback;
|
|
229
|
+
const number = Number(value);
|
|
230
|
+
if (!Number.isFinite(number)) return null;
|
|
231
|
+
return Math.min(max, Math.max(min, Math.trunc(number)));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function invalid(error) {
|
|
235
|
+
return { ok: false, code: REMOTE_RELAY_ERROR_CODES.invalidInput, error };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const SESSION_ACTIONS = new Set(['read_session', 'wait', 'send', 'stop', 'archive']);
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Validates and normalises one tool call. Adapters call it for a crisp early
|
|
242
|
+
* message; the relay calls it again authoritatively. Returns
|
|
243
|
+
* `{ ok:true, action, args }` with only the fields the action uses, defaults
|
|
244
|
+
* filled in and numbers clamped, or `{ ok:false, code, error }`.
|
|
245
|
+
*/
|
|
246
|
+
export function validateRemoteRelayToolInput(input = {}) {
|
|
247
|
+
const action = toText(input?.action).toLowerCase();
|
|
248
|
+
if (!REMOTE_RELAY_ACTIONS.includes(action)) {
|
|
249
|
+
return invalid(`action must be one of: ${REMOTE_RELAY_ACTIONS.join(', ')}`);
|
|
250
|
+
}
|
|
251
|
+
if (action === 'list_relays') return { ok: true, action, args: {} };
|
|
252
|
+
|
|
253
|
+
const args = {};
|
|
254
|
+
const relay = toText(input?.relay);
|
|
255
|
+
if (!relay) return invalid(`${action} needs relay (a name from list_relays)`);
|
|
256
|
+
args.relay = relay;
|
|
257
|
+
|
|
258
|
+
if (SESSION_ACTIONS.has(action)) {
|
|
259
|
+
const session = toText(input?.session);
|
|
260
|
+
if (!session) return invalid(`${action} needs session (an id from list_sessions)`);
|
|
261
|
+
args.session = session;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const waitSeconds = clampInteger(input?.wait_seconds, {
|
|
265
|
+
min: 0,
|
|
266
|
+
max: REMOTE_RELAY_LIMITS.waitMaxSeconds,
|
|
267
|
+
fallback: REMOTE_RELAY_LIMITS.waitDefaultSeconds,
|
|
268
|
+
});
|
|
269
|
+
if (waitSeconds === null) return invalid('wait_seconds must be a number of seconds');
|
|
270
|
+
|
|
271
|
+
const optionalEnum = (key, allowed) => {
|
|
272
|
+
const value = toText(input?.[key]).toLowerCase();
|
|
273
|
+
if (!value) return { ok: true };
|
|
274
|
+
if (!allowed.includes(value)) return invalid(`${key} must be one of: ${allowed.join(', ')}`);
|
|
275
|
+
args[key] = value;
|
|
276
|
+
return { ok: true };
|
|
277
|
+
};
|
|
278
|
+
const optionalText = (key, max = 500) => {
|
|
279
|
+
const value = toText(input?.[key]);
|
|
280
|
+
if (value) args[key] = value.slice(0, max);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
if (action === 'list_sessions') {
|
|
284
|
+
const scopeCheck = optionalEnum('scope', REMOTE_RELAY_SESSION_SCOPES);
|
|
285
|
+
if (!scopeCheck.ok) return scopeCheck;
|
|
286
|
+
args.scope = args.scope || 'recent';
|
|
287
|
+
const limit = clampInteger(input?.limit, { min: 1, max: REMOTE_RELAY_LIMITS.listMax, fallback: REMOTE_RELAY_LIMITS.listDefault });
|
|
288
|
+
if (limit === null) return invalid('limit must be a number');
|
|
289
|
+
args.limit = limit;
|
|
290
|
+
optionalText('query', 200);
|
|
291
|
+
optionalText('cursor', 1000);
|
|
292
|
+
} else if (action === 'read_session') {
|
|
293
|
+
const last = clampInteger(input?.last, { min: 1, max: REMOTE_RELAY_LIMITS.readMax, fallback: REMOTE_RELAY_LIMITS.readDefault });
|
|
294
|
+
if (last === null) return invalid('last must be a number');
|
|
295
|
+
args.last = last;
|
|
296
|
+
const maxChars = clampInteger(input?.max_chars, { min: 500, max: REMOTE_RELAY_LIMITS.maxCharsMax, fallback: REMOTE_RELAY_LIMITS.maxCharsDefault });
|
|
297
|
+
if (maxChars === null) return invalid('max_chars must be a number');
|
|
298
|
+
args.max_chars = maxChars;
|
|
299
|
+
args.include_activity = input?.include_activity === true || toText(input?.include_activity).toLowerCase() === 'true';
|
|
300
|
+
optionalText('before', 1000);
|
|
301
|
+
} else if (action === 'wait') {
|
|
302
|
+
const messageId = toText(input?.message_id);
|
|
303
|
+
if (!messageId) return invalid('wait needs message_id (from send or create_session)');
|
|
304
|
+
args.message_id = messageId;
|
|
305
|
+
args.wait_seconds = waitSeconds;
|
|
306
|
+
} else if (action === 'send' || action === 'create_session') {
|
|
307
|
+
const text = toText(input?.text);
|
|
308
|
+
if (!text) return invalid(`${action} needs text (the prompt)`);
|
|
309
|
+
if (text.length > REMOTE_RELAY_LIMITS.textMax) return invalid(`text is longer than ${REMOTE_RELAY_LIMITS.textMax} characters`);
|
|
310
|
+
args.text = text;
|
|
311
|
+
args.wait_seconds = waitSeconds;
|
|
312
|
+
optionalText('model', 200);
|
|
313
|
+
const modeCheck = optionalEnum('mode', REMOTE_RELAY_MODES);
|
|
314
|
+
if (!modeCheck.ok) return modeCheck;
|
|
315
|
+
if (action === 'send') {
|
|
316
|
+
const busyCheck = optionalEnum('if_busy', REMOTE_RELAY_IF_BUSY);
|
|
317
|
+
if (!busyCheck.ok) return busyCheck;
|
|
318
|
+
args.if_busy = args.if_busy || 'queue';
|
|
319
|
+
} else {
|
|
320
|
+
const providerCheck = optionalEnum('provider', REMOTE_RELAY_PROVIDERS);
|
|
321
|
+
if (!providerCheck.ok) return providerCheck;
|
|
322
|
+
optionalText('cwd', 1000);
|
|
323
|
+
optionalText('title', 200);
|
|
324
|
+
}
|
|
325
|
+
} else if (action === 'answer_question') {
|
|
326
|
+
const questionId = toText(input?.question_id);
|
|
327
|
+
if (!questionId) return invalid('answer_question needs question_id (from pendingQuestions)');
|
|
328
|
+
args.question_id = questionId;
|
|
329
|
+
const answer = toText(input?.answer);
|
|
330
|
+
const choices = Array.isArray(input?.choices)
|
|
331
|
+
? input.choices.map((choice) => toText(choice)).filter(Boolean).slice(0, 50)
|
|
332
|
+
: [];
|
|
333
|
+
if (!answer && choices.length === 0) return invalid('answer_question needs answer or choices');
|
|
334
|
+
if (answer) args.answer = answer.slice(0, REMOTE_RELAY_LIMITS.textMax);
|
|
335
|
+
if (choices.length) args.choices = choices;
|
|
336
|
+
}
|
|
337
|
+
return { ok: true, action, args };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const PERMISSION_RANK = { read: 1, prompt: 2, full: 3 };
|
|
341
|
+
|
|
342
|
+
export function normalizeRemoteRelayPermission(value) {
|
|
343
|
+
const permission = toText(value).toLowerCase();
|
|
344
|
+
return REMOTE_RELAY_PERMISSIONS.includes(permission) ? permission : REMOTE_RELAY_DEFAULT_PERMISSION;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** True when a remote with `permission` allows `action`. */
|
|
348
|
+
export function remoteRelayPermissionAllows(permission, action) {
|
|
349
|
+
if (!Object.prototype.hasOwnProperty.call(REMOTE_RELAY_ACTION_PERMISSION, action)) return false;
|
|
350
|
+
const needed = REMOTE_RELAY_ACTION_PERMISSION[action];
|
|
351
|
+
if (needed === null) return true;
|
|
352
|
+
return (PERMISSION_RANK[normalizeRemoteRelayPermission(permission)] || 0) >= PERMISSION_RANK[needed];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function isRemoteRelayWriteAction(action) {
|
|
356
|
+
return REMOTE_RELAY_WRITE_ACTIONS.includes(action);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ─── Approval cards ──────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
// The Allow / Deny card a relay puts on its own turn before a write action on
|
|
362
|
+
// another relay (ask / plan mode) is an ordinary relay question whose
|
|
363
|
+
// `context.source` is this. Only that relay's user may answer it: its question
|
|
364
|
+
// routes hide the card from other relays' agents and refuse their answers, and
|
|
365
|
+
// a dispatcher never offers one to its agent.
|
|
366
|
+
export const REMOTE_RELAY_APPROVAL_SOURCE = 'remote_relay';
|
|
367
|
+
|
|
368
|
+
/** True for such a card: a formatted question, or a stored request envelope (both carry `context`). */
|
|
369
|
+
export function isRemoteRelayApprovalQuestion(question) {
|
|
370
|
+
const context = question && typeof question === 'object' ? question.context : null;
|
|
371
|
+
return !!context && typeof context === 'object' && toText(context.source) === REMOTE_RELAY_APPROVAL_SOURCE;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ─── Provenance ──────────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
const ORIGIN_CAPS = {
|
|
377
|
+
relayId: 100,
|
|
378
|
+
relayName: 60,
|
|
379
|
+
relayUrl: 300,
|
|
380
|
+
conversationId: 100,
|
|
381
|
+
conversationTitle: 200,
|
|
382
|
+
provider: 40,
|
|
383
|
+
model: 100,
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Sanitises an `origin` object received on /api/message or bootstrap (or built
|
|
388
|
+
* locally before forwarding). Returns null when it does not describe an agent
|
|
389
|
+
* on another relay. Every field is a capped string except `hops`.
|
|
390
|
+
*/
|
|
391
|
+
export function normalizeRemoteRelayOrigin(raw) {
|
|
392
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
393
|
+
const origin = { kind: 'agent' };
|
|
394
|
+
for (const [key, cap] of Object.entries(ORIGIN_CAPS)) {
|
|
395
|
+
const value = toText(raw[key]).replace(/[\r\n]+/g, ' ');
|
|
396
|
+
origin[key] = value ? value.slice(0, cap) : '';
|
|
397
|
+
}
|
|
398
|
+
if (!origin.relayId && !origin.relayName) return null;
|
|
399
|
+
if (origin.relayUrl && !/^https?:\/\//i.test(origin.relayUrl)) origin.relayUrl = '';
|
|
400
|
+
const hops = Number(raw.hops);
|
|
401
|
+
origin.hops = Number.isInteger(hops) && hops >= 0 ? Math.min(hops, 10) : 1;
|
|
402
|
+
return origin;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function quoteForHeader(value) {
|
|
406
|
+
return toText(value).replace(/["\]\r\n]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The one line a relay puts at the top of a prompt it forwards, so the remote
|
|
411
|
+
* AGENT knows another agent is talking to it. The remote UI hides it when the
|
|
412
|
+
* message carries `origin` (the badge shows the same facts).
|
|
413
|
+
*/
|
|
414
|
+
export function formatRemotePromptHeader(origin) {
|
|
415
|
+
const parts = [`[Remote prompt from an agent on relay "${quoteForHeader(origin?.relayName) || 'unknown'}"`];
|
|
416
|
+
const title = quoteForHeader(origin?.conversationTitle);
|
|
417
|
+
if (title) parts.push(`session "${title}"`);
|
|
418
|
+
const model = quoteForHeader(origin?.model);
|
|
419
|
+
if (model) parts.push(model);
|
|
420
|
+
parts.push('acting for the user]');
|
|
421
|
+
return parts.join(' · ');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export const REMOTE_PROMPT_HEADER_PATTERN = /^\[Remote prompt from an agent on relay "[^"\r\n]*"[^\r\n]*\](?:\r?\n){1,2}/;
|
|
425
|
+
|
|
426
|
+
export function withRemotePromptHeader(text, origin) {
|
|
427
|
+
return `${formatRemotePromptHeader(origin)}\n\n${String(text ?? '')}`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function stripRemotePromptHeader(text) {
|
|
431
|
+
return String(text ?? '').replace(REMOTE_PROMPT_HEADER_PATTERN, '');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ─── Remote addresses ────────────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
function ipv4Parts(host) {
|
|
437
|
+
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
438
|
+
if (!match) return null;
|
|
439
|
+
const parts = match.slice(1).map(Number);
|
|
440
|
+
return parts.every((part) => part <= 255) ? parts : null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function isLoopbackHost(host) {
|
|
444
|
+
if (host === 'localhost' || host === '::1' || host === '[::1]') return true;
|
|
445
|
+
const parts = ipv4Parts(host);
|
|
446
|
+
return !!parts && parts[0] === 127;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function isPrivateHost(host) {
|
|
450
|
+
const parts = ipv4Parts(host);
|
|
451
|
+
if (parts) {
|
|
452
|
+
const [a, b] = parts;
|
|
453
|
+
return a === 10
|
|
454
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
455
|
+
|| (a === 192 && b === 168)
|
|
456
|
+
|| (a === 100 && b >= 64 && b <= 127);
|
|
457
|
+
}
|
|
458
|
+
const bare = host.replace(/^\[|\]$/g, '').toLowerCase();
|
|
459
|
+
return /^f[cd][0-9a-f]{2}:/.test(bare);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* The address policy: https anywhere; plain http only for loopback and private
|
|
464
|
+
* ranges (the token travels with every call). Returns `{ ok, warning? }` or
|
|
465
|
+
* `{ ok:false, error }`.
|
|
466
|
+
*/
|
|
467
|
+
export function checkRemoteRelayUrlPolicy(url) {
|
|
468
|
+
let parsed;
|
|
469
|
+
try {
|
|
470
|
+
parsed = url instanceof URL ? url : new URL(String(url));
|
|
471
|
+
} catch {
|
|
472
|
+
return { ok: false, error: 'Not a valid URL' };
|
|
473
|
+
}
|
|
474
|
+
if (parsed.protocol === 'https:') return { ok: true };
|
|
475
|
+
if (parsed.protocol !== 'http:') return { ok: false, error: 'Only https:// (or http:// on loopback and private networks) is supported' };
|
|
476
|
+
const host = parsed.hostname.toLowerCase();
|
|
477
|
+
if (isLoopbackHost(host)) return { ok: true, warning: 'Plain http on loopback (for example an SSH port forward).' };
|
|
478
|
+
if (isPrivateHost(host)) return { ok: true, warning: 'Plain http: the token crosses your network unencrypted.' };
|
|
479
|
+
return { ok: false, error: 'Plain http is only allowed for loopback and private network addresses; use https://' };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Turns whatever the user pasted — a web-client URL, possibly with ?token=,
|
|
484
|
+
* a #hash, a push_conv/conv deep link or a trailing index.html — into the
|
|
485
|
+
* relay's base URL (origin + remotePath, no trailing slash) and the token the
|
|
486
|
+
* link carried, if any.
|
|
487
|
+
*/
|
|
488
|
+
export function normalizeRemoteRelayLink(link) {
|
|
489
|
+
let raw = toText(link);
|
|
490
|
+
if (!raw) return { ok: false, error: 'Paste the web address of the other relay' };
|
|
491
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) raw = `https://${raw}`;
|
|
492
|
+
let parsed;
|
|
493
|
+
try {
|
|
494
|
+
parsed = new URL(raw);
|
|
495
|
+
} catch {
|
|
496
|
+
return { ok: false, error: 'Not a valid URL' };
|
|
497
|
+
}
|
|
498
|
+
const policy = checkRemoteRelayUrlPolicy(parsed);
|
|
499
|
+
if (!policy.ok) return policy;
|
|
500
|
+
const token = toText(parsed.searchParams.get('token')) || null;
|
|
501
|
+
let pathname = parsed.pathname || '/';
|
|
502
|
+
const segments = pathname.split('/');
|
|
503
|
+
const lastSegment = segments[segments.length - 1];
|
|
504
|
+
if (lastSegment && lastSegment.includes('.')) segments.pop();
|
|
505
|
+
pathname = segments.join('/').replace(/\/+$/, '');
|
|
506
|
+
const baseUrl = `${parsed.protocol}//${parsed.host}${pathname}`;
|
|
507
|
+
return {
|
|
508
|
+
ok: true,
|
|
509
|
+
baseUrl,
|
|
510
|
+
host: parsed.hostname.toLowerCase(),
|
|
511
|
+
token,
|
|
512
|
+
...(policy.warning ? { warning: policy.warning } : {}),
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Link that opens one conversation in a relay's web UI. */
|
|
517
|
+
export function remoteConversationUrl(baseUrl, conversationId) {
|
|
518
|
+
const base = toText(baseUrl).replace(/\/+$/, '');
|
|
519
|
+
const id = toText(conversationId);
|
|
520
|
+
if (!base) return '';
|
|
521
|
+
return id ? `${base}/?conv=${encodeURIComponent(id)}` : `${base}/`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ─── Activity line ───────────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
function clip(value, max) {
|
|
527
|
+
const text = toText(value).replace(/\s+/g, ' ');
|
|
528
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The one-line summary the workers use for the tool's activity line. */
|
|
532
|
+
export function summarizeRemoteRelayCall(input = {}) {
|
|
533
|
+
const action = toText(input?.action).toLowerCase() || 'call';
|
|
534
|
+
const relay = toText(input?.relay);
|
|
535
|
+
const session = toText(input?.session);
|
|
536
|
+
const target = [relay, session ? `session ${session.slice(0, 8)}` : ''].filter(Boolean).join(' ');
|
|
537
|
+
const text = input?.text ? `: “${clip(input.text, 60)}”` : '';
|
|
538
|
+
return target ? `${action} → ${target}${text}` : `${action}${text}`;
|
|
539
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Finds the remote relays a user message mentions.
|
|
2
|
+
//
|
|
3
|
+
// A mention unlocks that relay for the conversation, so the rules are strict
|
|
4
|
+
// about token boundaries: a relay is mentioned by its name or by the DNS host
|
|
5
|
+
// of its URL, as a whole token, case-insensitively, with or without a leading @.
|
|
6
|
+
//
|
|
7
|
+
// "ask @linux-test to run the suite" → linux-test
|
|
8
|
+
// "what is relay-b.example.test doing?" → the relay whose host that is
|
|
9
|
+
// "https://relay-b.example.test/x" → same (URLs count)
|
|
10
|
+
// "a.relay-b.example.test" → nothing for a relay named
|
|
11
|
+
// "relay-b.example.test" (a longer host)
|
|
12
|
+
// "relay-b.example.test.backup" → nothing (a longer name)
|
|
13
|
+
// "curl localhost:3000" → nothing for a relay at localhost:8123
|
|
14
|
+
//
|
|
15
|
+
// A loopback name or an IP literal says where a relay runs, not which one it
|
|
16
|
+
// is, and turns up in everyday text ("curl localhost:3000", "ping 10.0.0.1")
|
|
17
|
+
// without its port: a URL host like that is no alias, and a relay whose name
|
|
18
|
+
// is one (it gave none, so its host stood in) needs the leading @.
|
|
19
|
+
//
|
|
20
|
+
// Shared by the server (which unlocks) and the browser (which highlights).
|
|
21
|
+
|
|
22
|
+
const NAME_CHAR = /[a-z0-9._-]/;
|
|
23
|
+
const TAIL_CHAR = /[a-z0-9_-]/;
|
|
24
|
+
const ALNUM = /[a-z0-9]/;
|
|
25
|
+
|
|
26
|
+
function toText(value) {
|
|
27
|
+
return String(value ?? '').trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hostOf(url) {
|
|
31
|
+
try {
|
|
32
|
+
return new URL(String(url)).hostname.toLowerCase();
|
|
33
|
+
} catch {
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** localhost (and *.localhost), an IPv4 literal, or an IPv6 literal (bracketed as URL hosts are). */
|
|
39
|
+
export function isAddressHost(host) {
|
|
40
|
+
const value = toText(host).toLowerCase();
|
|
41
|
+
if (!value) return false;
|
|
42
|
+
if (value === 'localhost' || value.endsWith('.localhost')) return true;
|
|
43
|
+
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(value)) return true;
|
|
44
|
+
return value.startsWith('[') || value.includes(':');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The lowercase aliases a relay can be mentioned by: its name and its URL host, unless that is an address. */
|
|
48
|
+
export function remoteRelayAliases(relay) {
|
|
49
|
+
const aliases = new Set();
|
|
50
|
+
const name = toText(relay?.name).toLowerCase();
|
|
51
|
+
if (name) aliases.add(name);
|
|
52
|
+
const host = hostOf(relay?.url);
|
|
53
|
+
if (host && !isAddressHost(host)) aliases.add(host);
|
|
54
|
+
return [...aliases];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function startsToken(lower, index) {
|
|
58
|
+
if (index === 0) return true;
|
|
59
|
+
return !NAME_CHAR.test(lower[index - 1]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** An @ right before `index` that starts a token itself ("ask @x", not "mail@x"). */
|
|
63
|
+
function atMention(lower, index) {
|
|
64
|
+
return index > 0 && lower[index - 1] === '@' && startsToken(lower, index - 1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function endsToken(lower, end) {
|
|
68
|
+
if (end >= lower.length) return true;
|
|
69
|
+
const next = lower[end];
|
|
70
|
+
if (TAIL_CHAR.test(next)) return false;
|
|
71
|
+
if (next === '.' && end + 1 < lower.length && ALNUM.test(lower[end + 1])) return false;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Returns `[{ relayId, alias, index }]`, one entry per mentioned relay (the
|
|
77
|
+
* first occurrence), in order of appearance. `relays` are `{ id, name, url }`.
|
|
78
|
+
* Aliases equal to one of `selfNames` (this relay's own name) are ignored, and
|
|
79
|
+
* an alias shared by two relays is ambiguous and ignored too.
|
|
80
|
+
*/
|
|
81
|
+
export function findRemoteRelayMentions(text, relays = [], { selfNames = [] } = {}) {
|
|
82
|
+
const lower = String(text ?? '').toLowerCase();
|
|
83
|
+
if (!lower || !Array.isArray(relays) || relays.length === 0) return [];
|
|
84
|
+
const self = new Set(selfNames.map((name) => toText(name).toLowerCase()).filter(Boolean));
|
|
85
|
+
|
|
86
|
+
const owners = new Map();
|
|
87
|
+
for (const relay of relays) {
|
|
88
|
+
const id = toText(relay?.id);
|
|
89
|
+
if (!id) continue;
|
|
90
|
+
for (const alias of remoteRelayAliases(relay)) {
|
|
91
|
+
if (self.has(alias)) continue;
|
|
92
|
+
const owner = owners.get(alias);
|
|
93
|
+
owners.set(alias, owner && owner !== id ? null : id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const found = new Map();
|
|
98
|
+
for (const [alias, relayId] of owners) {
|
|
99
|
+
if (!relayId) continue;
|
|
100
|
+
const needsAt = isAddressHost(alias);
|
|
101
|
+
let from = 0;
|
|
102
|
+
while (from <= lower.length - alias.length) {
|
|
103
|
+
const index = lower.indexOf(alias, from);
|
|
104
|
+
if (index === -1) break;
|
|
105
|
+
if (startsToken(lower, index) && endsToken(lower, index + alias.length) && (!needsAt || atMention(lower, index))) {
|
|
106
|
+
const previous = found.get(relayId);
|
|
107
|
+
if (!previous || index < previous.index) found.set(relayId, { relayId, alias, index });
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
from = index + 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return [...found.values()].sort((a, b) => a.index - b.index);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* For the composer: the partial `@word` token ending at `caret`, or null.
|
|
118
|
+
* `{ start, end, query }` where start is the index of the `@`.
|
|
119
|
+
*/
|
|
120
|
+
export function mentionQueryAt(text, caret) {
|
|
121
|
+
const value = String(text ?? '');
|
|
122
|
+
const end = Number.isInteger(caret) ? Math.min(Math.max(caret, 0), value.length) : value.length;
|
|
123
|
+
let start = end;
|
|
124
|
+
while (start > 0 && NAME_CHAR.test(value[start - 1].toLowerCase())) start -= 1;
|
|
125
|
+
if (start === 0 || value[start - 1] !== '@') return null;
|
|
126
|
+
const at = start - 1;
|
|
127
|
+
if (at > 0 && !/\s|[([{"'`,;]/.test(value[at - 1])) return null;
|
|
128
|
+
return { start: at, end, query: value.slice(start, end).toLowerCase() };
|
|
129
|
+
}
|