@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
package/bin/oar.js
CHANGED
|
@@ -17,7 +17,7 @@ function resolvePackageRoot(metaUrl = import.meta.url) {
|
|
|
17
17
|
return path.resolve(path.dirname(fileURLToPath(metaUrl)), '..');
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function parsePort(argv = []) {
|
|
20
|
+
function parsePort(argv = [], fallback = 3333) {
|
|
21
21
|
const args = Array.isArray(argv) ? argv : [];
|
|
22
22
|
for (let index = 0; index < args.length; index += 1) {
|
|
23
23
|
const arg = String(args[index] || '');
|
|
@@ -30,7 +30,12 @@ function parsePort(argv = []) {
|
|
|
30
30
|
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) return parsed;
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
return
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizePort(value) {
|
|
37
|
+
const parsed = Number.parseInt(String(value ?? ''), 10);
|
|
38
|
+
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : null;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
function readRelayLock(lockPath) {
|
|
@@ -184,7 +189,7 @@ function createDefaultConfig({ port = 3333, token = '' } = {}) {
|
|
|
184
189
|
};
|
|
185
190
|
}
|
|
186
191
|
|
|
187
|
-
function
|
|
192
|
+
function resolveConfigPaths({ packageRoot, env = process.env, layout = null } = {}) {
|
|
188
193
|
const envConfigPath = String(env.COPILOT_WEB_RELAY_CONFIG || '').trim();
|
|
189
194
|
const repoConfigPath = path.join(packageRoot, 'server', 'config.json');
|
|
190
195
|
const oarConfigPath = layout && !layout.checkout ? path.join(layout.configDir, 'config.json') : null;
|
|
@@ -195,7 +200,11 @@ function resolveLauncherConfig({ packageRoot, env = process.env, relayToken = ''
|
|
|
195
200
|
|| null
|
|
196
201
|
);
|
|
197
202
|
const managedPath = oarConfigPath || path.join(getLegacyManagedConfigDir(env), 'config.json');
|
|
198
|
-
|
|
203
|
+
return { seedPath, configPath: seedPath || managedPath };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function resolveLauncherConfig({ packageRoot, env = process.env, relayToken = '', relayPort = 3333, portOverride = null, layout = null } = {}) {
|
|
207
|
+
const { seedPath, configPath } = resolveConfigPaths({ packageRoot, env, layout });
|
|
199
208
|
const seedConfig = readJsonFile(seedPath) || {};
|
|
200
209
|
const runtimeConfig = {
|
|
201
210
|
...createDefaultConfig({ port: relayPort }),
|
|
@@ -203,10 +212,9 @@ function resolveLauncherConfig({ packageRoot, env = process.env, relayToken = ''
|
|
|
203
212
|
};
|
|
204
213
|
if (relayToken) runtimeConfig.authToken = relayToken;
|
|
205
214
|
if (!String(runtimeConfig.authToken || '').trim()) runtimeConfig.authToken = randomUUID();
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
: 3333;
|
|
215
|
+
// An explicit --port wins over the config for this run: the server gets it
|
|
216
|
+
// on argv, and only a config this command creates saves it.
|
|
217
|
+
runtimeConfig.port = normalizePort(portOverride) ?? normalizePort(runtimeConfig.port) ?? normalizePort(relayPort) ?? 3333;
|
|
210
218
|
return {
|
|
211
219
|
configPath,
|
|
212
220
|
managed: !seedPath,
|
|
@@ -262,7 +270,7 @@ export async function launchRelay({
|
|
|
262
270
|
const forwardedArgs = separatorIdx === -1 ? [] : args.slice(separatorIdx + 1);
|
|
263
271
|
if (args.includes('--help') || args.includes('-h')) {
|
|
264
272
|
logger.log([
|
|
265
|
-
'Usage: oar [--port <port>] [--
|
|
273
|
+
'Usage: oar [--port <port>] [--migrate-from <old-checkout>] [--install-extension] [--no-install-extension] [-- [gh copilot args...]]',
|
|
266
274
|
'',
|
|
267
275
|
'Starts the web relay server if needed, then launches gh copilot in the current shell.',
|
|
268
276
|
'--install-extension installs/updates a user-global web-relay wrapper extension and exits.',
|
|
@@ -284,7 +292,7 @@ export async function launchRelay({
|
|
|
284
292
|
return { code: 0, reason: 'extension-installed' };
|
|
285
293
|
}
|
|
286
294
|
|
|
287
|
-
const
|
|
295
|
+
const cliPort = parsePort(launcherArgs, null);
|
|
288
296
|
const serverDir = path.join(packageRoot, 'server');
|
|
289
297
|
const layout = resolveStateLayout({ packageRoot, env });
|
|
290
298
|
|
|
@@ -311,6 +319,12 @@ export async function launchRelay({
|
|
|
311
319
|
const lockPath = layout.checkout
|
|
312
320
|
? path.join(serverDir, 'data', 'relay-server.lock')
|
|
313
321
|
: path.join(layout.dataDir, 'relay-server.lock');
|
|
322
|
+
// One port finds, launches and probes the relay: an explicit --port for this
|
|
323
|
+
// run, else the config's, else 3333. The probe used to take --port (or 3333)
|
|
324
|
+
// while the server started on the config's port, so any mismatch waited out
|
|
325
|
+
// the readiness deadline and killed the relay it had just started.
|
|
326
|
+
const { seedPath } = resolveConfigPaths({ packageRoot, env, layout });
|
|
327
|
+
const port = cliPort ?? normalizePort(readJsonFile(seedPath)?.port) ?? 3333;
|
|
314
328
|
const statusUrl = `http://localhost:${port}/api/status`;
|
|
315
329
|
const running = await detectRunningRelay({
|
|
316
330
|
lockPath,
|
|
@@ -323,6 +337,7 @@ export async function launchRelay({
|
|
|
323
337
|
env,
|
|
324
338
|
relayToken: running?.lock?.token || '',
|
|
325
339
|
relayPort: port,
|
|
340
|
+
portOverride: cliPort,
|
|
326
341
|
layout,
|
|
327
342
|
});
|
|
328
343
|
if (runtimeConfig.managed || !fs.existsSync(runtimeConfig.configPath)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oar-sh/oar",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
"bin/",
|
|
21
21
|
"server/",
|
|
22
22
|
"!server/**/*.test.mjs",
|
|
23
|
+
"!server/**/*-test-harness.mjs",
|
|
24
|
+
"!server/**/fixtures/",
|
|
23
25
|
"!server/data/",
|
|
24
26
|
"!server/logs/",
|
|
25
27
|
"!server/config.json",
|
|
@@ -28,6 +30,7 @@
|
|
|
28
30
|
"server/uploads/image_input.md",
|
|
29
31
|
"shared/",
|
|
30
32
|
"!shared/**/*.test.mjs",
|
|
33
|
+
"!shared/**/fixtures/",
|
|
31
34
|
".github/extensions/web-relay/",
|
|
32
35
|
"!.github/extensions/web-relay/**/*.test.mjs",
|
|
33
36
|
"README.md",
|
|
@@ -37,15 +40,18 @@
|
|
|
37
40
|
"scripts": {
|
|
38
41
|
"start": "node server/server.js",
|
|
39
42
|
"copilot:relay": "gh copilot -- --allow-all",
|
|
40
|
-
"test": "node --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\"",
|
|
43
|
+
"test": "node --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\" \"scripts/**/*.test.mjs\"",
|
|
41
44
|
"test:agents:smoke": "node --test \"tests/agents/**/*.test.mjs\"",
|
|
42
45
|
"test:e2e": "node tests/run-e2e.mjs",
|
|
46
|
+
"setup:git": "node scripts/setup-git.mjs",
|
|
47
|
+
"land": "node scripts/land.mjs",
|
|
48
|
+
"prune:local": "node scripts/prune-local.mjs",
|
|
43
49
|
"test:e2e:raw": "node ./node_modules/@playwright/test/cli.js test --config tests/playwright.config.mjs"
|
|
44
50
|
},
|
|
45
51
|
"dependencies": {
|
|
46
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.
|
|
47
|
-
"@cursor/sdk": "^1.0.
|
|
48
|
-
"@github/copilot-sdk": "^1.0.
|
|
52
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.283",
|
|
53
|
+
"@cursor/sdk": "^1.0.32",
|
|
54
|
+
"@github/copilot-sdk": "^1.0.14",
|
|
49
55
|
"better-sqlite3": "^12.10.0",
|
|
50
56
|
"express": "^4.18.2",
|
|
51
57
|
"qrcode-terminal": "^0.12.0",
|
package/server/README.md
CHANGED
|
@@ -193,7 +193,7 @@ where supported (with standalone fallback), and hides install/fullscreen header
|
|
|
193
193
|
When opened in a regular browser tab, the in-app **Install** button remains available
|
|
194
194
|
in the chat header (shown as `⬇` on small screens).
|
|
195
195
|
|
|
196
|
-
You can rename the installed app label from **⚙️ Settings →
|
|
196
|
+
You can rename the installed app label from **⚙️ Settings → Relay name** (paired relays know this relay by the same name).
|
|
197
197
|
The name is stored on the relay (app settings) and served in the web app manifest
|
|
198
198
|
to every browser and origin (LAN and tunnel agree), so future installs pick it up
|
|
199
199
|
immediately and already-installed Android apps receive it through Chrome's normal
|
|
@@ -201,9 +201,34 @@ manifest-update flow — one confirmation prompt showing the new name, then the
|
|
|
201
201
|
launcher label updates. A legacy per-browser name is adopted by the relay the
|
|
202
202
|
first time that browser loads the app.
|
|
203
203
|
|
|
204
|
+
**💤 Suspend host** (Windows relay hosts) puts the PC into suspend-to-RAM once
|
|
205
|
+
nothing is running any more. The confirmation modal first lists what is still
|
|
206
|
+
active — sessions with a running turn, sessions with live background agents, and
|
|
207
|
+
open GitHub Actions runs in the workspaces of those sessions (read through the
|
|
208
|
+
host's `gh` CLI; an unreadable CI state blocks for 15 minutes, then is ignored).
|
|
209
|
+
Confirming queues the request: the relay polls every second and suspends after
|
|
210
|
+
everything has been idle for 2 minutes (or 30 seconds when nothing was running
|
|
211
|
+
at confirm time). While a suspend is queued every connected client shows a
|
|
212
|
+
banner with the blockers or the countdown and a **Cancel** button, the menu entry
|
|
213
|
+
reads **💤 Suspend pending…**, and reopening the modal offers to cancel. A relay
|
|
214
|
+
restart drops the queued suspend; a push notification (event **Host suspend**)
|
|
215
|
+
reports both the actual suspend and such a drop. A queued **🌄 Restart web relay**
|
|
216
|
+
gets the same banner and can be cancelled from it too.
|
|
217
|
+
|
|
218
|
+
API: `GET /api/host/suspend` (state plus current blockers), `POST /api/host/suspend`,
|
|
219
|
+
`POST /api/host/suspend/cancel`, and `POST /api/relay/shutdown/cancel`
|
|
220
|
+
(localhost-only). Set `OAR_HOST_SUSPEND_DRY_RUN=1` to log instead of sleeping.
|
|
221
|
+
|
|
204
222
|
You can also hide the **💤 Suspend host** action from **⚙️ Settings** with the
|
|
205
|
-
**Show Suspend host action** checkbox. This only controls
|
|
206
|
-
|
|
223
|
+
**Show Suspend host action** checkbox. This only controls the menu entry; the
|
|
224
|
+
banner for an already queued suspend still shows on every device.
|
|
225
|
+
|
|
226
|
+
The live thinking bubble's header carries three follow buttons on the right —
|
|
227
|
+
**💭** thoughts, **📃** streamed answer, **🛠️** tool list. Arming one keeps that
|
|
228
|
+
section's last line pinned just above the composer as the reply grows; scrolling
|
|
229
|
+
the transcript by hand (wheel, touch, scroll keys) disarms it, typing in the
|
|
230
|
+
composer does not. The armed mode is remembered per conversation for the life of
|
|
231
|
+
the page. **Stop** now asks for confirmation before cancelling the turn.
|
|
207
232
|
|
|
208
233
|
If you host the relay behind a subpath, set `remotePath` in `server/config.json` to that public path prefix and open the URL with a trailing slash so the PWA scope matches correctly for install prompts. The relay serves a path-relative manifest identity (`id`, `start_url`, `scope`) so each install stays bound to its own URL subtree and avoids cross-app collisions on shared origins.
|
|
209
234
|
|
|
@@ -773,7 +798,7 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
|
|
|
773
798
|
| GET | `/api/search/messages` | Search message text across all conversations (`q`, `limit`, `offset`) |
|
|
774
799
|
| PATCH | `/api/conversation/:id` | Update a conversation title |
|
|
775
800
|
| POST | `/api/conversation/:id/compact` | Compact a conversation into a new one with carry-over summary seed |
|
|
776
|
-
| DELETE | `/api/conversation/:id` | Delete a conversation |
|
|
801
|
+
| DELETE | `/api/conversation/:id` | Delete a conversation: stops its session worker, deletes its CLI session, removes its rows. A Copilot session the relay only imported and never ran is hidden and tombstoned instead: nothing is stopped and its CLI session is kept. `409 conversation-active` (`reason: turn-running` / `background-tasks`) while it is still working; `409 worker-stop-failed` when its worker survives the stop (nothing deleted) |
|
|
777
802
|
| GET | `/api/sdk-session-delete/pending` | (CLI relay) Fetch next pending SDK session delete request |
|
|
778
803
|
| POST | `/api/sdk-session-delete/result` | (CLI relay) Report SDK session delete result |
|
|
779
804
|
| POST | `/api/session-sync` | (CLI relay) Sync conversation↔SDK binding and optionally confirm orchestrator rebind completion |
|
|
@@ -821,6 +846,17 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
|
|
|
821
846
|
| POST | `/api/openai/images/generate` | Generate images for an OpenAI Image conversation |
|
|
822
847
|
| POST | `/api/image-operations/:operationId/execute` | Execute a queued image generation/edit operation |
|
|
823
848
|
| GET | `/api/generated-image/:conversationId/:messageId/:imageId/content` | Stream a generated image blob |
|
|
849
|
+
| GET | `/api/relay/identity` | This relay's identity for paired relays: `relayId`, `name` (the PWA app name, else the hostname), `version`, `platform`, `publicUrl`, `remoteRelays.{protocol, inbound}` |
|
|
850
|
+
| GET | `/api/remote-relays` | Paired remote relays with their status (`relays[]`, never a token) plus `self` |
|
|
851
|
+
| POST | `/api/remote-relays` | Pair a relay from a pasted web address (`url`, optional `token`, `pairBack`, `selfUrl`); `{ needsToken: true }` when neither token works |
|
|
852
|
+
| PATCH | `/api/remote-relays/:id` | Change a paired relay's `permission` (`read` / `prompt` / `full`), URL or token |
|
|
853
|
+
| DELETE | `/api/remote-relays/:id` | Remove a paired relay here (the other side keeps its entry) |
|
|
854
|
+
| POST | `/api/remote-relays/:id/check` | Probe a paired relay now |
|
|
855
|
+
| POST | `/api/remote-relays/pair` | (Relay to relay) Another relay introduces itself after the user paired it there |
|
|
856
|
+
| GET | `/api/remote-relays/summary` | (Worker) Number of paired relays; workers register `remote_relay` only when it is above zero |
|
|
857
|
+
| POST | `/api/remote-relays/tool` | (Worker) One `remote_relay` tool call: `{ conversationId, action, args }`. The relay checks the mention unlock, the permission, the hop limit and (ask/plan mode) the user's approval, then forwards the call to the remote |
|
|
858
|
+
| GET | `/api/remote-relays/inflight` | (Worker) Number of `remote_relay` calls running for `conversationId` |
|
|
859
|
+
| GET/POST | `/api/settings/remote-relays` | This relay's `publicUrl` and `inboundEnabled` (accept prompts from other relays' agents) |
|
|
824
860
|
|
|
825
861
|
### Manual relay shutdown / self-restart
|
|
826
862
|
|
|
@@ -1378,6 +1414,10 @@ and on `messages`:
|
|
|
1378
1414
|
- `hidden_from_shares` / `share_hidden_at` — per-message share visibility (see
|
|
1379
1415
|
[Conversation sharing](#conversation-sharing-and-per-message-visibility))
|
|
1380
1416
|
|
|
1417
|
+
`0005-remote-relays.mjs` adds `origin_json` to `messages` and `conversations` (provenance of
|
|
1418
|
+
prompts and sessions another relay's agent created) and the table
|
|
1419
|
+
`conversation_remote_relay_unlocks` (the remote relays the user mentioned per conversation).
|
|
1420
|
+
|
|
1381
1421
|
New tables are likewise created at startup with `CREATE TABLE IF NOT EXISTS` in both the fresh-schema
|
|
1382
1422
|
and migration blocks — most recently `workflow_runs` (final workflow digest per assistant message,
|
|
1383
1423
|
keyed on `response_message_id`, cascade-deleted with the conversation; see
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relay's `remote_relay` tool for the Claude worker, shaped as an SDK MCP
|
|
3
|
+
* tool definition (`{ name, description, inputSchema, handler }`) so the
|
|
4
|
+
* adapter can hand it to `createSdkMcpServer` next to the preview tool. On the
|
|
5
|
+
* wire it is `mcp__relay__remote_relay`; the module never imports the SDK.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as z from 'zod';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
REMOTE_RELAY_ACTIONS,
|
|
12
|
+
REMOTE_RELAY_IF_BUSY,
|
|
13
|
+
REMOTE_RELAY_MODES,
|
|
14
|
+
REMOTE_RELAY_PROVIDERS,
|
|
15
|
+
REMOTE_RELAY_SESSION_SCOPES,
|
|
16
|
+
} from '../../shared/remote-relay-contract.mjs';
|
|
17
|
+
import {
|
|
18
|
+
REMOTE_RELAY_CALL_FAILED,
|
|
19
|
+
REMOTE_RELAY_TOOL_DESCRIPTION,
|
|
20
|
+
REMOTE_RELAY_TOOL_INPUT_SCHEMA,
|
|
21
|
+
REMOTE_RELAY_TOOL_NAME,
|
|
22
|
+
executeRemoteRelayTool,
|
|
23
|
+
formatRemoteRelayToolResult,
|
|
24
|
+
} from '../../shared/remote-relay-tool-core.mjs';
|
|
25
|
+
|
|
26
|
+
const FIELDS = REMOTE_RELAY_TOOL_INPUT_SCHEMA.properties;
|
|
27
|
+
|
|
28
|
+
function text(field) {
|
|
29
|
+
return z.string().optional().describe(FIELDS[field].description);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function choice(field, values) {
|
|
33
|
+
return z.enum([...values]).optional().describe(FIELDS[field].description);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function number(field) {
|
|
37
|
+
return z.number().optional().describe(FIELDS[field].description);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The contract's JSON schema mirrored as a zod raw shape (the SDK's MCP layer
|
|
42
|
+
* accepts zod only). Descriptions and enum values are read from the contract,
|
|
43
|
+
* so the model sees the same field docs on every provider. Numeric bounds are
|
|
44
|
+
* left off on purpose, as on the preview tool: the contract's validator clamps
|
|
45
|
+
* wait_seconds / limit / last / max_chars instead of failing the call.
|
|
46
|
+
*/
|
|
47
|
+
export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
|
|
48
|
+
action: z.enum([...REMOTE_RELAY_ACTIONS]).describe(FIELDS.action.description),
|
|
49
|
+
relay: text('relay'),
|
|
50
|
+
session: text('session'),
|
|
51
|
+
text: text('text'),
|
|
52
|
+
message_id: text('message_id'),
|
|
53
|
+
wait_seconds: number('wait_seconds'),
|
|
54
|
+
scope: choice('scope', REMOTE_RELAY_SESSION_SCOPES),
|
|
55
|
+
query: text('query'),
|
|
56
|
+
limit: number('limit'),
|
|
57
|
+
cursor: text('cursor'),
|
|
58
|
+
last: number('last'),
|
|
59
|
+
before: text('before'),
|
|
60
|
+
include_activity: z.boolean().optional().describe(FIELDS.include_activity.description),
|
|
61
|
+
max_chars: number('max_chars'),
|
|
62
|
+
provider: choice('provider', REMOTE_RELAY_PROVIDERS),
|
|
63
|
+
model: text('model'),
|
|
64
|
+
cwd: text('cwd'),
|
|
65
|
+
mode: choice('mode', REMOTE_RELAY_MODES),
|
|
66
|
+
title: text('title'),
|
|
67
|
+
question_id: text('question_id'),
|
|
68
|
+
answer: text('answer'),
|
|
69
|
+
choices: z.array(z.string()).optional().describe(FIELDS.choices.description),
|
|
70
|
+
if_busy: choice('if_busy', REMOTE_RELAY_IF_BUSY),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function createRemoteRelayToolDefinition({ api, getConversationId = () => '', dbg = () => {} } = {}) {
|
|
74
|
+
return {
|
|
75
|
+
name: REMOTE_RELAY_TOOL_NAME,
|
|
76
|
+
description: REMOTE_RELAY_TOOL_DESCRIPTION,
|
|
77
|
+
inputSchema: REMOTE_RELAY_TOOL_ZOD_SHAPE,
|
|
78
|
+
// `extra.signal` is the MCP request's: the SDK aborts it when the CLI
|
|
79
|
+
// cancels the call (a Stop interrupting the turn) or the process's MCP
|
|
80
|
+
// transport closes, and the pending relay request goes with it.
|
|
81
|
+
handler: async (args, extra) => {
|
|
82
|
+
let result;
|
|
83
|
+
try {
|
|
84
|
+
result = await executeRemoteRelayTool(args, {
|
|
85
|
+
api,
|
|
86
|
+
conversationId: String(getConversationId() || ''),
|
|
87
|
+
signal: extra?.signal || null,
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
// A tool exception would surface as a failed MCP call and can abort
|
|
91
|
+
// the turn; a refusal the model can read cannot.
|
|
92
|
+
const message = error?.message || String(error);
|
|
93
|
+
dbg('remote_relay tool failed', message);
|
|
94
|
+
result = { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: message };
|
|
95
|
+
}
|
|
96
|
+
return { content: [{ type: 'text', text: formatRemoteRelayToolResult(result) }] };
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -2,12 +2,23 @@ import { createSdkMcpServer, query } from '@anthropic-ai/claude-agent-sdk';
|
|
|
2
2
|
|
|
3
3
|
import { parseThinkingDisplay } from '../../shared/claude-thinking.mjs';
|
|
4
4
|
import { MEDIA_EMBED_INSTRUCTION_TEXT } from '../../shared/media-embed-instructions.mjs';
|
|
5
|
+
import { REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS } from '../../shared/remote-relay-tool-core.mjs';
|
|
5
6
|
import { createPreviewToolDefinition } from './claude-preview-tool.mjs';
|
|
7
|
+
import { createRemoteRelayToolDefinition } from './claude-remote-relay-tool.mjs';
|
|
6
8
|
|
|
7
9
|
// The name the in-process MCP server is registered under; it prefixes every
|
|
8
|
-
// tool it carries on the wire (`mcp__relay__preview`).
|
|
10
|
+
// tool it carries on the wire (`mcp__relay__preview`, `mcp__relay__remote_relay`).
|
|
9
11
|
export const RELAY_MCP_SERVER_NAME = 'relay';
|
|
10
12
|
|
|
13
|
+
// The per-server tool-call timeout the CLI applies to the relay's tools.
|
|
14
|
+
// Measured against CLI 2.1.283: an SDK server's calls are bounded by this
|
|
15
|
+
// value, else by the MCP_TOOL_TIMEOUT environment variable, else by 1e8 ms —
|
|
16
|
+
// and SDK servers are exempt from the idle timeout. Pinning it here keeps a
|
|
17
|
+
// host-wide MCP_TOOL_TIMEOUT (set for some other server) from cutting off a
|
|
18
|
+
// remote_relay wait: those run up to wait_seconds (≤ 600 s) plus however long
|
|
19
|
+
// an approval card sits unanswered.
|
|
20
|
+
export const RELAY_MCP_TOOL_TIMEOUT_MS = REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS;
|
|
21
|
+
|
|
11
22
|
const MODE_SYSTEM_PROMPT_APPEND = {
|
|
12
23
|
ask: 'Prioritize clarification questions (AskUserQuestion) before implementation work; do not make broad assumptions when a question would materially change the result.',
|
|
13
24
|
autopilot: 'Keep moving unless user input is truly blocking; avoid unnecessary questions.',
|
|
@@ -143,11 +154,19 @@ export function systemPromptForRelayMode(relayMode) {
|
|
|
143
154
|
* which beats inventing a capability flag to plumb through the delivery
|
|
144
155
|
* payload. `getConversationId` is called per tool call because a process
|
|
145
156
|
* outlives the turn it was spawned for.
|
|
157
|
+
*
|
|
158
|
+
* `remote_relay` is the exception: it rides along only when the worker found
|
|
159
|
+
* at least one paired remote (`remoteRelayTool`), because a tool that can do
|
|
160
|
+
* nothing on a relay without remotes would only cost context on every turn.
|
|
146
161
|
*/
|
|
147
|
-
export function createRelayMcpServer({ api, getConversationId, dbg } = {}) {
|
|
162
|
+
export function createRelayMcpServer({ api, getConversationId, dbg, remoteRelayTool = false } = {}) {
|
|
148
163
|
return createSdkMcpServer({
|
|
149
164
|
name: RELAY_MCP_SERVER_NAME,
|
|
150
|
-
|
|
165
|
+
timeout: RELAY_MCP_TOOL_TIMEOUT_MS,
|
|
166
|
+
tools: [
|
|
167
|
+
createPreviewToolDefinition({ api, getConversationId, dbg }),
|
|
168
|
+
...(remoteRelayTool ? [createRemoteRelayToolDefinition({ api, getConversationId, dbg })] : []),
|
|
169
|
+
],
|
|
151
170
|
});
|
|
152
171
|
}
|
|
153
172
|
|
|
@@ -215,6 +234,9 @@ export function startClaudeSession({
|
|
|
215
234
|
pathToClaudeCodeExecutable = '',
|
|
216
235
|
api = null,
|
|
217
236
|
getConversationId = () => '',
|
|
237
|
+
// Whether this process carries `remote_relay` (the worker's registration
|
|
238
|
+
// decision). Fixed for the process: the tool list is part of the spawn.
|
|
239
|
+
remoteRelayTool = false,
|
|
218
240
|
queryImpl = query,
|
|
219
241
|
dbg = () => {},
|
|
220
242
|
} = {}) {
|
|
@@ -230,7 +252,7 @@ export function startClaudeSession({
|
|
|
230
252
|
// Relay tools need the worker's authenticated API helper; a caller that
|
|
231
253
|
// passes none (tests, probes) gets a session without them.
|
|
232
254
|
const relayMcpServer = typeof api === 'function'
|
|
233
|
-
? createRelayMcpServer({ api, getConversationId, dbg })
|
|
255
|
+
? createRelayMcpServer({ api, getConversationId, dbg, remoteRelayTool: remoteRelayTool === true })
|
|
234
256
|
: null;
|
|
235
257
|
const options = {
|
|
236
258
|
cwd,
|