@oar-sh/oar 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +67 -0
  4. package/README.md +62 -13
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +92 -25
  10. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  11. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  12. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  13. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  14. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  15. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  16. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  17. package/server/db-schema.mjs +8 -0
  18. package/server/grok-worker/acp-client.mjs +92 -6
  19. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  20. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  21. package/server/mcp/oar-mcp-server.mjs +473 -0
  22. package/server/migrations/0005-remote-relays.mjs +90 -0
  23. package/server/public/app/action-confirmations.js +141 -13
  24. package/server/public/app/api-client.js +103 -0
  25. package/server/public/app/bootstrap.js +21 -6
  26. package/server/public/app/conversation-deep-link.mjs +42 -0
  27. package/server/public/app/conversation-view.js +98 -7
  28. package/server/public/app/host-suspend-ui.js +272 -0
  29. package/server/public/app/journal-view.js +4 -1
  30. package/server/public/app/mention-autocomplete.mjs +298 -0
  31. package/server/public/app/push-settings.js +1 -0
  32. package/server/public/app/pwa-install.js +8 -3
  33. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  34. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  35. package/server/public/app/remote-relay-shared.mjs +85 -0
  36. package/server/public/app/remote-relays-settings.js +618 -0
  37. package/server/public/app/remote-relays-store.mjs +126 -0
  38. package/server/public/app/settings-modal.js +4 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +18 -1
  41. package/server/public/app/thinking-follow.js +260 -0
  42. package/server/public/app/tool-activity-emoji.mjs +4 -0
  43. package/server/public/index.html +257 -8
  44. package/server/relay-tools.md +5 -1
  45. package/server/repositories/message-repository.mjs +7 -0
  46. package/server/repositories/question-repository.mjs +3 -0
  47. package/server/repositories/remote-relay-repository.mjs +75 -0
  48. package/server/repositories/session-repository.mjs +6 -1
  49. package/server/routes/ask-user-routes.mjs +42 -2
  50. package/server/routes/messages-routes.mjs +90 -16
  51. package/server/routes/remote-relay-routes.mjs +133 -0
  52. package/server/routes/sessions-routes.mjs +80 -29
  53. package/server/server-runtime.mjs +238 -0
  54. package/server/services/github-ci-activity-service.mjs +199 -0
  55. package/server/services/host-suspend-command.mjs +38 -0
  56. package/server/services/host-suspend-service.mjs +254 -0
  57. package/server/services/push-dispatch-service.mjs +37 -4
  58. package/server/services/remote-relay-caller-context.mjs +341 -0
  59. package/server/services/remote-relay-client.mjs +332 -0
  60. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  61. package/server/services/remote-relay-inbound.mjs +187 -0
  62. package/server/services/remote-relay-pairing.mjs +189 -0
  63. package/server/services/remote-relay-registry-service.mjs +581 -0
  64. package/server/services/session-worker-launch-service.mjs +131 -12
  65. package/shared/remote-relay-contract.mjs +539 -0
  66. package/shared/remote-relay-mentions.mjs +129 -0
  67. package/shared/remote-relay-tool-core.mjs +314 -0
  68. package/shared/worker-runtime/api-client.mjs +112 -22
@@ -35,6 +35,7 @@ import {
35
35
  isStaleAttemptError,
36
36
  } from './claude-turn-publisher.mjs';
37
37
  import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
38
+ import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
38
39
  import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
39
40
  import { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT } from '../../shared/steer-settle-markers.mjs';
40
41
  import { stripSteerNote, withSteerNoteContent } from '../../shared/steer-note.mjs';
@@ -336,6 +337,34 @@ function isTaskNotificationReplay(sdkMessage) {
336
337
  return text !== null && text.trimStart().startsWith(TASK_NOTIFICATION_TAG_PREFIX);
337
338
  }
338
339
 
340
+ /**
341
+ * The CLI's own record of a command that ran locally, replayed on the stream
342
+ * as a user-role message. The one the relay causes is the model switch:
343
+ * `set_model` on a conversation another model already answered writes the
344
+ * "/model" breadcrumbs into the transcript and replays the
345
+ * `<local-command-stdout>Set model to …` one (`isReplay: true`, string
346
+ * content, no `origin`) BEFORE it answers the control request — read in the
347
+ * CLI binaries 2.1.261 to 2.1.283. A session that was never answered gets no
348
+ * breadcrumb, which is why a probe on a fresh session only ever shows the
349
+ * re-init. Only the two output tags count, and only on a replay the CLI wrote
350
+ * itself (no origin, not inside a subagent): output is never a prompt, so it
351
+ * never opens a turn. A `<command-name>` record is left alone on purpose: it
352
+ * heads prompt-type slash commands and skills, which do open one.
353
+ */
354
+ const LOCAL_COMMAND_OUTPUT_TAG_PREFIXES = Object.freeze([
355
+ '<local-command-stdout>',
356
+ '<local-command-stderr>',
357
+ ]);
358
+
359
+ function isLocalCommandReplay(sdkMessage) {
360
+ if (sdkMessage?.isReplay !== true) return false;
361
+ if (sdkMessage.origin || sdkMessage.parent_tool_use_id) return false;
362
+ const text = firstTextBlock(sdkMessage);
363
+ if (text === null) return false;
364
+ const trimmed = text.trimStart();
365
+ return LOCAL_COMMAND_OUTPUT_TAG_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
366
+ }
367
+
339
368
  /**
340
369
  * Whether a turn the CLI opened by itself may be the compaction re-opening a
341
370
  * delivered turn, rather than something else the CLI decided to say.
@@ -484,6 +513,9 @@ export function createClaudeSessionRunner({
484
513
  // the socket link: a hold withdraws the relay's readiness, and its end
485
514
  // re-arms it at once so a held message steers into the resumed turn.
486
515
  onDeliveryReadinessChange = () => {},
516
+ // Whether CLI processes carry the relay's `remote_relay` tool
517
+ // (shared/remote-relay-tool-core.mjs). Default: ask the relay.
518
+ remoteRelayToolGate = null,
487
519
  dbg = () => {},
488
520
  } = {}) {
489
521
  const publisher = createClaudeTurnPublisher({
@@ -491,6 +523,13 @@ export function createClaudeSessionRunner({
491
523
  dbg,
492
524
  takeWorkflowRuns: () => drainSettledWorkflowRuns(),
493
525
  });
526
+ // `remote_relay` is registered only while this relay has paired remotes.
527
+ // Asked once when the worker starts, and again whenever a CLI process winds
528
+ // down, so a remote paired later reaches the next process. A cold spawn
529
+ // waits for the pending answer (handlePendingPayload): the tool list is
530
+ // fixed per process, and the first delivery can beat the startup GET.
531
+ const remoteRelayGate = remoteRelayToolGate || createRemoteRelayToolGate({ api });
532
+ void remoteRelayGate.refresh();
494
533
  const resolveWorkflowSessionDir = resolveWorkflowSessionDirImpl || createDefaultWorkflowSessionDirResolver();
495
534
  let claudeNativeSessionId = '';
496
535
  let proc = null;
@@ -1060,6 +1099,18 @@ export function createClaudeSessionRunner({
1060
1099
  if (openingText !== null) {
1061
1100
  const matched = proc.pendingDelivered.findIndex((entry) => entry.expectedText === openingText);
1062
1101
  if (matched >= 0) return attachDeliveredContext(matched);
1102
+ // The CLI's record of a local command is not a turn, and no traffic
1103
+ // follows it. Read as a self-opened boundary, the "/model" breadcrumb a
1104
+ // setModel leaves stayed armed through the push that followed: the
1105
+ // delivered message's answer opened a continuation row, and its own row
1106
+ // sat unattached, holding every later message out until the watchdog
1107
+ // failed it or the session was killed (seen live 2026-09-27; the
1108
+ // re-init handling alone cannot cover it). Checked after the match, so
1109
+ // a user who sends such a text still gets their turn.
1110
+ if (isLocalCommandReplay(sdkMessage)) {
1111
+ dbg('local command replay ignored as a turn boundary');
1112
+ return null;
1113
+ }
1063
1114
  // A compaction just replayed the conversation: the CLI re-opens the turn
1064
1115
  // with its own summary message ("This session is being continued…"),
1065
1116
  // whose text matches no delivered entry, and then answers the delivered
@@ -2672,6 +2723,8 @@ export function createClaudeSessionRunner({
2672
2723
 
2673
2724
  async function cleanupProcess(processRef, streamError) {
2674
2725
  const wasCurrent = proc === processRef;
2726
+ // The next spawn reads the registration decision; refresh it now.
2727
+ if (wasCurrent) void remoteRelayGate.refresh();
2675
2728
  stopLifecycleTimer(processRef);
2676
2729
  if (processRef.taskPublishTimer) {
2677
2730
  clearTimeout(processRef.taskPublishTimer);
@@ -2903,6 +2956,7 @@ export function createClaudeSessionRunner({
2903
2956
  || sdkSessionId
2904
2957
  || '',
2905
2958
  ),
2959
+ remoteRelayTool: remoteRelayGate.isEnabled(),
2906
2960
  dbg,
2907
2961
  });
2908
2962
  processRef.lifecycleTimer = setInterval(() => evaluateLifecycle(), lifecyclePollMs);
@@ -2940,18 +2994,10 @@ export function createClaudeSessionRunner({
2940
2994
  // A mode change that would alter the spawn-time system prompt append gets
2941
2995
  // a fresh process — but only when nothing lives in the old one.
2942
2996
  if (modeAppendClass(relayMode) !== proc.appendClass && !hasLiveWork() && !proc.liveTasks.size) {
2943
- const previous = proc;
2944
2997
  gracefulShutdown('mode-change');
2945
- await Promise.race([
2946
- previous.consumer,
2947
- new Promise((resolve) => setTimeout(resolve, 10_000)),
2948
- ]);
2949
- if (proc === previous) {
2950
- // The drain timed out: force the old CLI down so it cannot keep
2951
- // streaming next to the replacement the caller is about to spawn.
2952
- try { previous.turn.close?.(); } catch {}
2953
- proc = null;
2954
- }
2998
+ // A drain that times out forces the old CLI down so it cannot keep
2999
+ // streaming next to the replacement the caller is about to spawn.
3000
+ await drainClosingProcess(10_000);
2955
3001
  return null;
2956
3002
  }
2957
3003
 
@@ -3173,6 +3219,25 @@ export function createClaudeSessionRunner({
3173
3219
  return { turnActive, canSteer, holdReason, messageId, supported: true };
3174
3220
  }
3175
3221
 
3222
+ /**
3223
+ * A closing process drains on its own; wait (bounded) so two CLIs never
3224
+ * share the native session transcript, then make sure it is gone.
3225
+ */
3226
+ async function drainClosingProcess(timeoutMs = 15_000) {
3227
+ const previous = proc;
3228
+ if (!previous) return;
3229
+ let timer = null;
3230
+ await Promise.race([
3231
+ previous.consumer,
3232
+ new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); }),
3233
+ ]);
3234
+ clearTimeout(timer);
3235
+ if (proc === previous) {
3236
+ try { previous.turn.close?.(); } catch {}
3237
+ proc = null;
3238
+ }
3239
+ }
3240
+
3176
3241
  async function handlePendingPayload(pending) {
3177
3242
  const message = pending?.message || null;
3178
3243
  if (!message) return false;
@@ -3204,20 +3269,22 @@ export function createClaudeSessionRunner({
3204
3269
  await adaptProcess(message);
3205
3270
  }
3206
3271
  if (!proc || proc.closing || proc.aborted) {
3207
- // A closing process drains on its own; wait so two CLIs never share
3208
- // the native session transcript.
3209
- const previous = proc;
3210
- if (previous) {
3211
- await Promise.race([
3212
- previous.consumer,
3213
- new Promise((resolve) => setTimeout(resolve, 15_000)),
3214
- ]);
3215
- if (proc === previous) {
3216
- try { previous.turn.close?.(); } catch {}
3217
- proc = null;
3218
- }
3219
- }
3220
- spawnProcess(message);
3272
+ // Awaited only when there is one: a cold spawn with the decision
3273
+ // already in pushes synchronously.
3274
+ if (proc) await drainClosingProcess();
3275
+ // The tool set is fixed per CLI process: let the remote_relay
3276
+ // decision settle first. The worker's first delivery can arrive
3277
+ // before its startup GET has answered (seen live: 180 ms after the
3278
+ // spawn). ready() never rejects and is bounded by the gate's timeout.
3279
+ if (!remoteRelayGate.isSettled?.()) await remoteRelayGate.ready();
3280
+ // Another delivery spawned while this one waited: join that process
3281
+ // like any delivery (between turns: adapt; mid-turn: steer below).
3282
+ if (proc && !proc.closing && !proc.aborted && !proc.activeCtx) await adaptProcess(message);
3283
+ // What it joined may be gone by now: that adapt can recycle the
3284
+ // process (a mode change), and a process can wind down under any of
3285
+ // these waits. Only a live one takes the push below.
3286
+ if (proc && (proc.closing || proc.aborted)) await drainClosingProcess();
3287
+ if (!proc) spawnProcess(message);
3221
3288
  // A cold start is the one moment a turn can sit silent for many
3222
3289
  // seconds (transcript load + first request); say so instead of
3223
3290
  // showing bare dots. Posted once here — not in spawnProcess — so a
@@ -1,5 +1,6 @@
1
1
  import { capThought } from '../../shared/thought-cap.mjs';
2
2
  import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
3
+ import { isRemoteRelayToolName, remoteRelayActivitySummary } from '../../shared/remote-relay-tool-core.mjs';
3
4
 
4
5
  const MAX_TOOL_DETAIL_LENGTH = 140;
5
6
  const REDACTED_THINKING_PLACEHOLDER = '[Reasoning redacted by the model provider]';
@@ -89,6 +90,12 @@ export function isSubagentToolName(name) {
89
90
 
90
91
  export function summarizeToolInput(toolName, input) {
91
92
  if (!input || typeof input !== 'object') return '';
93
+ // `mcp__relay__remote_relay` reads as what it does on which relay
94
+ // (`send → linux-test session 01234567: “…”`), not as its first argument.
95
+ if (isRemoteRelayToolName(toolName)) {
96
+ const summary = remoteRelayActivitySummary(input);
97
+ if (summary) return summary;
98
+ }
92
99
  const candidates = [
93
100
  input.command,
94
101
  input.file_path,
@@ -0,0 +1,59 @@
1
+ // The relay's `remote_relay` tool for Copilot SDK sessions: a plain SDK
2
+ // `Tool` (`{ name, description, parameters, skipPermission, handler }`) built
3
+ // from the shared contract and forwarded through the shared tool core. The
4
+ // module never imports the SDK; the session process hands the definition to
5
+ // `SessionConfig.tools`.
6
+ //
7
+ // `skipPermission`: the relay does the approving. A write action in ask or
8
+ // plan mode raises its own question card server-side, so a runtime permission
9
+ // prompt on top would ask the user twice for one action.
10
+
11
+ import {
12
+ REMOTE_RELAY_CALL_FAILED,
13
+ REMOTE_RELAY_TOOL_DESCRIPTION,
14
+ REMOTE_RELAY_TOOL_NAME,
15
+ cloneRemoteRelayToolInputSchema,
16
+ executeRemoteRelayTool,
17
+ formatRemoteRelayToolResult,
18
+ } from '../../shared/remote-relay-tool-core.mjs';
19
+
20
+ /**
21
+ * `runCall(fn)` wraps every execution — the session process uses it to hold
22
+ * its stall watchdog and idle shutdown while the relay waits on a remote turn.
23
+ * The handler never throws: a failure answers in-band as JSON the model can
24
+ * read, which is what the runtime passes back to it anyway.
25
+ *
26
+ * `getSignal(invocation)` is the call's cancellation. By default the SDK's
27
+ * own `invocation.signal`, which aborts when the runtime completes the
28
+ * request or the session disconnects; the session process adds the turn's
29
+ * Stop to it.
30
+ */
31
+ export function buildCopilotRemoteRelayTool({
32
+ api,
33
+ getConversationId = () => '',
34
+ runCall = (fn) => fn(),
35
+ getSignal = (invocation) => invocation?.signal || null,
36
+ dbg = () => {},
37
+ } = {}) {
38
+ return {
39
+ name: REMOTE_RELAY_TOOL_NAME,
40
+ description: REMOTE_RELAY_TOOL_DESCRIPTION,
41
+ parameters: cloneRemoteRelayToolInputSchema(),
42
+ skipPermission: true,
43
+ handler: async (args, invocation) => {
44
+ let result;
45
+ try {
46
+ result = await runCall(() => executeRemoteRelayTool(args, {
47
+ api,
48
+ conversationId: String(getConversationId() || ''),
49
+ signal: getSignal(invocation) || null,
50
+ }));
51
+ } catch (error) {
52
+ const message = error?.message || String(error);
53
+ dbg('remote_relay tool failed', message);
54
+ result = { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: message };
55
+ }
56
+ return formatRemoteRelayToolResult(result);
57
+ },
58
+ };
59
+ }
@@ -22,6 +22,11 @@
22
22
  import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
23
23
  import { capThought } from '../../shared/thought-cap.mjs';
24
24
  import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
25
+ import {
26
+ REMOTE_RELAY_TOOL_NAME,
27
+ isRemoteRelayToolName,
28
+ remoteRelayActivitySummary,
29
+ } from '../../shared/remote-relay-tool-core.mjs';
25
30
 
26
31
  const MAX_TOOL_DETAIL_LENGTH = 140;
27
32
 
@@ -78,8 +83,13 @@ export function summarizeToolInput(input) {
78
83
  }
79
84
 
80
85
  export function formatToolActivityText(toolName, input) {
81
- const name = String(toolName || '').trim() || 'tool';
82
- const summary = summarizeToolInput(input);
86
+ const rawName = String(toolName || '').trim() || 'tool';
87
+ // `remote_relay` (bare, or namespaced by an MCP server) reads as what it
88
+ // does on which relay (`send → linux-test session 01234567: “…”`); its
89
+ // arguments may arrive as a JSON string.
90
+ const isRemoteRelay = isRemoteRelayToolName(rawName);
91
+ const name = isRemoteRelay ? REMOTE_RELAY_TOOL_NAME : rawName;
92
+ const summary = (isRemoteRelay && remoteRelayActivitySummary(input)) || summarizeToolInput(input);
83
93
  return truncate(summary ? `Tool (${name}): ${summary}` : `Tool (${name})`);
84
94
  }
85
95
 
@@ -84,6 +84,8 @@ import {
84
84
  isToolOverrideRejection,
85
85
  parseAskUserToolArguments,
86
86
  } from './copilot-ask-user-tool.mjs';
87
+ import { buildCopilotRemoteRelayTool } from './copilot-remote-relay-tool.mjs';
88
+ import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
87
89
  import {
88
90
  EXIT_PLAN_BOARD_POSTED_FEEDBACK,
89
91
  EXIT_PLAN_NO_BOARD_FEEDBACK,
@@ -351,6 +353,9 @@ export function createCopilotSdkSessionRunner({
351
353
  // runtime's built-in one. A runtime that refuses the override falls back
352
354
  // to the built-in for the rest of this worker's life.
353
355
  relayAskUserTool = true,
356
+ // Whether sessions carry the relay's `remote_relay` tool
357
+ // (shared/remote-relay-tool-core.mjs). Default: ask the relay.
358
+ remoteRelayToolGate = null,
354
359
  taskRefreshMs = DEFAULT_TASK_REFRESH_MS,
355
360
  taskPublishThrottleMs = DEFAULT_TASK_PUBLISH_THROTTLE_MS,
356
361
  // Called with `true`/`false` whenever the runner flips between accepting
@@ -518,6 +523,17 @@ export function createCopilotSdkSessionRunner({
518
523
  // the human's eventual answer to a runtime whose row is already settled.
519
524
  // Same guard the Cursor worker's `hasPendingClientWork` provides.
520
525
  let pendingHumanRequests = 0;
526
+ // Running `remote_relay` calls. The runtime is just as silent while one
527
+ // runs (the relay may wait up to 600 s for a remote turn, plus an approval
528
+ // card), so they hold the stall watchdog and the idle shutdown too — but
529
+ // not steering: nobody is answering a card here.
530
+ let pendingRelayToolCalls = 0;
531
+ // `remote_relay` is registered only while this relay has paired remotes.
532
+ // Asked once when the worker starts; a session build waits for that first
533
+ // answer (it is async anyway), and every runtime stop asks again so the
534
+ // next session picks up a remote paired in the meantime.
535
+ const remoteRelayGate = remoteRelayToolGate || createRemoteRelayToolGate({ api });
536
+ void remoteRelayGate.refresh();
521
537
  // The `SessionConfig.provider` block this session was BUILT with, or null for
522
538
  // a hosted (`github`) session. Set by `buildSessionConfig` rather than by a
523
539
  // second throwaway resolve here, so "is this BYOK?" and "what did the runtime
@@ -1787,6 +1803,32 @@ export function createCopilotSdkSessionRunner({
1787
1803
  };
1788
1804
  }
1789
1805
 
1806
+ /**
1807
+ * The relay's `remote_relay` tool (copilot-remote-relay-tool.mjs). Every call
1808
+ * is counted while it runs: the stall watchdog re-arms instead of failing the
1809
+ * row, and the lifecycle keeps the runtime up. The clock restarts from the
1810
+ * answer, like after a card. A call is cancelled by the runtime (its
1811
+ * invocation signal) or by a Stop of the turn it started under.
1812
+ */
1813
+ function buildRemoteRelayTool() {
1814
+ return buildCopilotRemoteRelayTool({
1815
+ api,
1816
+ getConversationId: () => activeTurn?.message?.conversationId || sdkSessionId || '',
1817
+ getSignal: (invocation) => combineSignals(activeTurn?.remoteRelayStop?.signal, invocation?.signal),
1818
+ runCall: async (fn) => {
1819
+ pendingRelayToolCalls += 1;
1820
+ try {
1821
+ return await fn();
1822
+ } finally {
1823
+ pendingRelayToolCalls -= 1;
1824
+ touch();
1825
+ activeTurn?.armStall?.();
1826
+ }
1827
+ },
1828
+ dbg,
1829
+ });
1830
+ }
1831
+
1790
1832
  /** One signal that aborts when any of the given ones does (nulls ignored). */
1791
1833
  function combineSignals(...signals) {
1792
1834
  const live = signals.filter((signal) => signal && typeof signal.aborted === 'boolean');
@@ -1804,20 +1846,35 @@ export function createCopilotSdkSessionRunner({
1804
1846
  * Open a session, retrying once without the relay's `ask_user` when the
1805
1847
  * runtime refuses the override (an older runtime). The refusal is
1806
1848
  * remembered until the runtime is stopped — the next runtime (possibly
1807
- * auto-updated under the relay) gets another try.
1849
+ * auto-updated under the relay) gets another try. Only the override goes:
1850
+ * the relay's other tools (`remote_relay`) override nothing and stay.
1808
1851
  */
1809
1852
  async function openSession(open, config) {
1810
1853
  try {
1811
1854
  return await open(config);
1812
1855
  } catch (error) {
1813
- if (!config?.tools?.length || !isToolOverrideRejection(error) || isSessionNotFoundError(error)) throw error;
1856
+ const tools = Array.isArray(config?.tools) ? config.tools : [];
1857
+ const isAskUserOverride = (tool) => tool?.name === RELAY_ASK_USER_TOOL_NAME && tool?.overridesBuiltInTool === true;
1858
+ if (!tools.some(isAskUserOverride) || !isToolOverrideRejection(error) || isSessionNotFoundError(error)) throw error;
1814
1859
  dbg('the runtime refused the relay ask_user override; using its built-in ask_user', error?.message || String(error));
1815
1860
  askUserOverrideRejected = true;
1816
1861
  const { tools: _tools, ...withoutTools } = config;
1817
- return open(withoutTools);
1862
+ const kept = tools.filter((tool) => !isAskUserOverride(tool));
1863
+ return open(kept.length ? { ...withoutTools, tools: kept } : withoutTools);
1818
1864
  }
1819
1865
  }
1820
1866
 
1867
+ /** The relay's own session tools; each is added only when it applies. */
1868
+ function relaySessionTools() {
1869
+ return [
1870
+ // The relay's `ask_user` (see copilot-ask-user-tool.mjs): the built-in's
1871
+ // fields plus `multi_select`, so a model can declare a multi-select
1872
+ // question instead of the relay guessing from its wording.
1873
+ ...(relayAskUserTool && !askUserOverrideRejected ? [buildRelayAskUserTool()] : []),
1874
+ ...(remoteRelayGate.isEnabled() ? [buildRemoteRelayTool()] : []),
1875
+ ];
1876
+ }
1877
+
1821
1878
  function buildSessionConfig(model, reasoningEffort = null) {
1822
1879
  // Built once per session, not per request. Reads the mode off the LIVE turn
1823
1880
  // rather than closing over the one the session was built with, because one
@@ -1857,6 +1914,7 @@ export function createCopilotSdkSessionRunner({
1857
1914
  const provider = resolveProviderConfigImpl({ env, model: model || defaultModel, dbg });
1858
1915
  byokProvider = provider;
1859
1916
  const effort = normalizeRelayEffort(reasoningEffort);
1917
+ const tools = relaySessionTools();
1860
1918
  return {
1861
1919
  // The relay session id IS the SDK session id, so the runtime's own state
1862
1920
  // under ~/.copilot/session-state/<id> is addressable by conversation and
@@ -1879,10 +1937,8 @@ export function createCopilotSdkSessionRunner({
1879
1937
  // The runtime's own defaults, pinned. See DEFAULT_INFINITE_SESSION_CONFIG.
1880
1938
  ...(infiniteSessionConfig ? { infiniteSessions: { ...infiniteSessionConfig } } : {}),
1881
1939
  onEvent: routeEvent,
1882
- // The relay's `ask_user` (see copilot-ask-user-tool.mjs): the built-in's
1883
- // fields plus `multi_select`, so a model can declare a multi-select
1884
- // question instead of the relay guessing from its wording.
1885
- ...(relayAskUserTool && !askUserOverrideRejected ? { tools: [buildRelayAskUserTool()] } : {}),
1940
+ // The relay's own tools (`relaySessionTools`); omitted when there are none.
1941
+ ...(tools.length ? { tools } : {}),
1886
1942
  // Permission policy follows the conversation's relay mode: agent and
1887
1943
  // autopilot auto-approve, plan denies non-read tools with feedback, and
1888
1944
  // ask asks the human through a relay question card. The handler reads the
@@ -2119,6 +2175,9 @@ export function createCopilotSdkSessionRunner({
2119
2175
  async function ensureSession(model, effort) {
2120
2176
  await ensureClient();
2121
2177
  if (!session) {
2178
+ // The tool list is part of the session: wait for the worker's first
2179
+ // registration answer (bounded, and it fails closed).
2180
+ await remoteRelayGate.ready();
2122
2181
  const config = buildSessionConfig(model, effort);
2123
2182
  // Resume first, always. On a brand-new conversation this costs one
2124
2183
  // failed RPC; on every other path (worker restart, idle shutdown,
@@ -2185,6 +2244,8 @@ export function createCopilotSdkSessionRunner({
2185
2244
 
2186
2245
  async function stopRuntime(reason) {
2187
2246
  stopLifecycleTimer();
2247
+ // The next session is built from a fresh registration answer.
2248
+ void remoteRelayGate.refresh();
2188
2249
  try { detachRuntimeExit(); } catch { /* the observer is best-effort */ }
2189
2250
  detachRuntimeExit = () => {};
2190
2251
  const closingSession = session;
@@ -2320,7 +2381,9 @@ export function createCopilotSdkSessionRunner({
2320
2381
  // A pending question card means a human is mid-answer; tearing the runtime
2321
2382
  // down under them would discard the session the answer belongs to. A turn
2322
2383
  // that is still publishing counts too: it settled, but its rows are live.
2323
- if (disposed || activeTurn || publishingTurns.size > 0 || pendingHumanRequests > 0 || !client) return;
2384
+ if (
2385
+ disposed || activeTurn || publishingTurns.size > 0 || pendingHumanRequests > 0 || pendingRelayToolCalls > 0 || !client
2386
+ ) return;
2324
2387
  if (!(idleShutdownMs > 0)) return;
2325
2388
  // Evaluated before the idle clock so the caps still expire on a runtime
2326
2389
  // that has been quiet far longer than the idle window.
@@ -2441,6 +2504,10 @@ export function createCopilotSdkSessionRunner({
2441
2504
  // Cancels any relay question card this turn is blocked on, so an aborted
2442
2505
  // turn does not leave a human answering into the void.
2443
2506
  abortController: new AbortController(),
2507
+ // Cancels the `remote_relay` calls that started under this turn — on the
2508
+ // user's Stop only. Not `abortController`: that one also fires when the
2509
+ // turn settles normally, and a background agent's call must outlive that.
2510
+ remoteRelayStop: new AbortController(),
2444
2511
  // Cards (questions, ask-mode approvals, elicitations) open on this
2445
2512
  // turn's row; `humansIdle` resolves when the last one is done. A turn
2446
2513
  // that settles with one still open publishes only after it (driveTurn).
@@ -2546,7 +2613,9 @@ export function createCopilotSdkSessionRunner({
2546
2613
  // A human staring at a question card is not a stalled runtime. Re-arm
2547
2614
  // rather than fail: the card has its own (much longer) timeout, and
2548
2615
  // failing the row here would settle it while the answer is still coming.
2549
- if (pendingHumanRequests > 0) {
2616
+ // A remote_relay call waiting on another relay is the same kind of
2617
+ // silence; the relay bounds that wait itself.
2618
+ if (pendingHumanRequests > 0 || pendingRelayToolCalls > 0) {
2550
2619
  turn.armStall();
2551
2620
  return;
2552
2621
  }
@@ -3010,6 +3079,10 @@ export function createCopilotSdkSessionRunner({
3010
3079
  }
3011
3080
  turn.aborted = true;
3012
3081
  turn.abortedRowId = rowId;
3082
+ // A pending `remote_relay` call ends now rather than when the runtime gets
3083
+ // round to it: the relay drops the request, withdraws its approval card
3084
+ // and stops waiting on the remote, and the watchdog hold is released.
3085
+ turn.remoteRelayStop?.abort();
3013
3086
  // While `ensureSession` is still connecting there is no session to abort
3014
3087
  // and this would be a silent no-op; `runTurn` re-checks `turn.aborted`
3015
3088
  // once the session exists and settles there instead.
@@ -3937,6 +4010,7 @@ export function createCopilotSdkSessionRunner({
3937
4010
  continuationDueSince,
3938
4011
  compacting: isCompacting(),
3939
4012
  pendingHumanRequests,
4013
+ pendingRelayToolCalls,
3940
4014
  sessionRpc: { ...sessionRpc },
3941
4015
  activeEntries: activeTurn
3942
4016
  ? activeTurn.entries.map((entry) => ({
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The relay's `remote_relay` custom tool for the Cursor worker. SDK-free: the
3
+ * turn runner passes the returned `{ description, inputSchema, execute }` into
4
+ * `customTools.remote_relay`, and every call goes through the shared tool core
5
+ * to the local relay endpoint.
6
+ *
7
+ * A call can legitimately run for many minutes (a remote turn plus an approval
8
+ * card), during which the SDK emits nothing. `onCallStart`/`onCallEnd` bracket
9
+ * every execution — errors included — so the runner can count it as pending
10
+ * client work and hold its stall watchdog, exactly as it does for ask_user.
11
+ *
12
+ * The SDK hands `execute` no cancellation of its own (the context is only
13
+ * `{ toolCallId }`), so `getAbortSignal` supplies the running turn's: a Stop
14
+ * aborts it, which cancels the pending relay request and ends the hold.
15
+ */
16
+
17
+ import {
18
+ REMOTE_RELAY_CALL_FAILED,
19
+ REMOTE_RELAY_TOOL_DESCRIPTION,
20
+ REMOTE_RELAY_TOOL_NAME,
21
+ cloneRemoteRelayToolInputSchema,
22
+ executeRemoteRelayTool,
23
+ formatRemoteRelayToolResult,
24
+ } from '../../shared/remote-relay-tool-core.mjs';
25
+
26
+ export function createRemoteRelayTool({
27
+ api,
28
+ getConversationId = () => '',
29
+ onCallStart = () => {},
30
+ onCallEnd = () => {},
31
+ getAbortSignal = () => null,
32
+ dbg = () => {},
33
+ } = {}) {
34
+ async function execute(args) {
35
+ onCallStart();
36
+ try {
37
+ const result = await executeRemoteRelayTool(args, {
38
+ api,
39
+ conversationId: String(getConversationId() || ''),
40
+ signal: getAbortSignal?.() || null,
41
+ });
42
+ return {
43
+ structuredContent: result,
44
+ content: [{ type: 'text', text: formatRemoteRelayToolResult(result) }],
45
+ };
46
+ } catch (error) {
47
+ // A tool exception must never kill the run.
48
+ const message = error?.message || String(error);
49
+ dbg('remote_relay tool failed', message);
50
+ const result = { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: message };
51
+ return {
52
+ structuredContent: result,
53
+ content: [{ type: 'text', text: formatRemoteRelayToolResult(result) }],
54
+ };
55
+ } finally {
56
+ onCallEnd();
57
+ }
58
+ }
59
+ return {
60
+ name: REMOTE_RELAY_TOOL_NAME,
61
+ description: REMOTE_RELAY_TOOL_DESCRIPTION,
62
+ inputSchema: cloneRemoteRelayToolInputSchema(),
63
+ execute,
64
+ };
65
+ }
@@ -12,6 +12,8 @@ import {
12
12
  } from './cursor-sdk-adapter.mjs';
13
13
  import { createAskUserTool } from './cursor-ask-user-tool.mjs';
14
14
  import { createPreviewTool } from './cursor-preview-tool.mjs';
15
+ import { createRemoteRelayTool } from './cursor-remote-relay-tool.mjs';
16
+ import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
15
17
  import {
16
18
  buildCursorSubagentAgents,
17
19
  cursorSubagentRosterFingerprint,
@@ -120,6 +122,9 @@ export function createCursorTurnRunner({
120
122
  resolveModelParamsImpl = resolveCursorReasoningParams,
121
123
  createNormalizerImpl = createSdkMessageNormalizer,
122
124
  buildUserMessageImpl = buildCursorUserMessage,
125
+ // Whether agent handles carry the relay's `remote_relay` tool
126
+ // (shared/remote-relay-tool-core.mjs). Default: ask the relay.
127
+ remoteRelayToolGate = null,
123
128
  dbg = () => {},
124
129
  } = {}) {
125
130
  // Unlike the Claude worker, the agent handle (and the custom tools bound
@@ -140,6 +145,9 @@ export function createCursorTurnRunner({
140
145
  // Open ask_user round-trips: the merged-stream stall watchdog defers while
141
146
  // a human is thinking about a question card.
142
147
  let pendingAskUserCalls = 0;
148
+ // Running remote_relay calls, same family: the relay may be waiting up to
149
+ // 600 s for a remote turn (plus an approval card) with no SDK traffic.
150
+ let pendingRemoteRelayCalls = 0;
143
151
  // Mirrors the Copilot extension's lastPromptedRelayMode: full mode
144
152
  // instructions ride on the first message of a mode and on mode changes only.
145
153
  let lastNudgedRelayMode = '';
@@ -188,6 +196,39 @@ export function createCursorTurnRunner({
188
196
  execute: previewTool.execute,
189
197
  },
190
198
  };
199
+ // `remote_relay` joins the set only while this relay has paired remotes.
200
+ // Asked once when the worker starts; the first handle waits for the answer
201
+ // (it is created asynchronously anyway), and every handle creation refreshes
202
+ // it so a later rebuild picks up a remote paired in the meantime.
203
+ const remoteRelayGate = remoteRelayToolGate || createRemoteRelayToolGate({ api });
204
+ void remoteRelayGate.refresh();
205
+ const remoteRelayTool = createRemoteRelayTool({
206
+ api,
207
+ getConversationId: () => activeMessage?.conversationId || sdkSessionId || '',
208
+ onCallStart: () => { pendingRemoteRelayCalls += 1; },
209
+ onCallEnd: () => { pendingRemoteRelayCalls -= 1; },
210
+ // The running turn's signal, read when the call starts: a Stop (or the
211
+ // auth retry burning a dead attempt) cancels the pending relay request.
212
+ // Between turns there is none — the last turn's may be aborted already.
213
+ getAbortSignal: () => (waitingForTurn ? currentAbortController?.signal || null : null),
214
+ dbg,
215
+ });
216
+
217
+ function customToolsForHandle() {
218
+ if (!remoteRelayGate.isEnabled()) return customTools;
219
+ return {
220
+ ...customTools,
221
+ [remoteRelayTool.name]: {
222
+ description: remoteRelayTool.description,
223
+ inputSchema: remoteRelayTool.inputSchema,
224
+ execute: remoteRelayTool.execute,
225
+ },
226
+ };
227
+ }
228
+
229
+ function hasPendingClientWork() {
230
+ return pendingAskUserCalls > 0 || pendingRemoteRelayCalls > 0;
231
+ }
191
232
 
192
233
  function getActiveQueueMessageId() {
193
234
  return waitingForTurn ? String(activeMessage?.id || '') : '';
@@ -262,7 +303,9 @@ export function createCursorTurnRunner({
262
303
  if (!agentHandle) {
263
304
  const durableId = String(message.cursorAgentId || '').trim() || cursorAgentId;
264
305
  const agents = buildCursorSubagentAgents(rosterModels);
265
- const options = { apiKey, model, cwd, storeDir, sdkSessionId, customTools, agents, dbg };
306
+ await remoteRelayGate.ready();
307
+ const options = { apiKey, model, cwd, storeDir, sdkSessionId, customTools: customToolsForHandle(), agents, dbg };
308
+ void remoteRelayGate.refresh();
266
309
  try {
267
310
  agentHandle = await createAgentHandleImpl({ ...options, agentId: durableId });
268
311
  } catch (error) {
@@ -565,7 +608,7 @@ export function createCursorTurnRunner({
565
608
  // The SDK's documented recovery for a wedged persisted run —
566
609
  // armed by the second busy retry below.
567
610
  sendLocal: forceNextSend ? { force: true } : null,
568
- hasPendingClientWork: () => pendingAskUserCalls > 0,
611
+ hasPendingClientWork,
569
612
  dbg,
570
613
  });
571
614
  forceNextSend = false;