@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
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 3333;
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 resolveLauncherConfig({ packageRoot, env = process.env, relayToken = '', relayPort = 3333, layout = null } = {}) {
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
- const configPath = seedPath || managedPath;
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
- const parsedPort = Number.parseInt(String(runtimeConfig.port || relayPort || 3333), 10);
207
- runtimeConfig.port = Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535
208
- ? parsedPort
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>] [--token <token>] [--migrate-from <old-checkout>] [--install-extension] [--no-install-extension] [-- [gh copilot args...]]',
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 port = parsePort(launcherArgs);
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.2",
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.261",
47
- "@cursor/sdk": "^1.0.27",
48
- "@github/copilot-sdk": "^1.0.13",
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 → Install app name**.
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 UI visibility; it does not
206
- change host suspend implementation behavior.
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
- tools: [createPreviewToolDefinition({ api, getConversationId, dbg })],
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,