@oar-sh/oar 0.9.3 → 0.9.5

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 (76) 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 +98 -0
  4. package/README.md +76 -14
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +100 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +385 -43
  10. package/server/claude-worker/claude-session-worker.mjs +5 -0
  11. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  12. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
  16. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  17. package/server/cursor-worker/cursor-session-worker.mjs +5 -0
  18. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  19. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  20. package/server/db-schema.mjs +8 -0
  21. package/server/grok-worker/acp-client.mjs +92 -6
  22. package/server/grok-worker/grok-session-worker.mjs +5 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  24. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  25. package/server/mcp/oar-mcp-server.mjs +473 -0
  26. package/server/migrations/0005-remote-relays.mjs +90 -0
  27. package/server/public/app/action-confirmations.js +141 -13
  28. package/server/public/app/api-client.js +103 -0
  29. package/server/public/app/bootstrap.js +21 -6
  30. package/server/public/app/conversation-deep-link.mjs +42 -0
  31. package/server/public/app/conversation-view.js +100 -7
  32. package/server/public/app/host-suspend-ui.js +272 -0
  33. package/server/public/app/journal-view.js +4 -1
  34. package/server/public/app/mention-autocomplete.mjs +298 -0
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/pwa-install.js +8 -3
  37. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  38. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  39. package/server/public/app/remote-relay-shared.mjs +85 -0
  40. package/server/public/app/remote-relays-settings.js +618 -0
  41. package/server/public/app/remote-relays-store.mjs +126 -0
  42. package/server/public/app/settings-modal.js +4 -2
  43. package/server/public/app/settings-tabs.js +1 -1
  44. package/server/public/app/socket-handlers.js +18 -1
  45. package/server/public/app/steered-turn-merge.mjs +10 -1
  46. package/server/public/app/thinking-follow.js +260 -0
  47. package/server/public/app/tool-activity-emoji.mjs +4 -0
  48. package/server/public/index.html +264 -11
  49. package/server/relay-tools.md +5 -1
  50. package/server/repositories/message-repository.mjs +7 -0
  51. package/server/repositories/question-repository.mjs +3 -0
  52. package/server/repositories/remote-relay-repository.mjs +75 -0
  53. package/server/repositories/session-repository.mjs +6 -1
  54. package/server/routes/ask-user-routes.mjs +42 -2
  55. package/server/routes/messages-routes.mjs +98 -19
  56. package/server/routes/remote-relay-routes.mjs +133 -0
  57. package/server/routes/sessions-routes.mjs +84 -31
  58. package/server/server-runtime.mjs +238 -0
  59. package/server/services/github-ci-activity-service.mjs +199 -0
  60. package/server/services/host-suspend-command.mjs +38 -0
  61. package/server/services/host-suspend-service.mjs +254 -0
  62. package/server/services/push-dispatch-service.mjs +37 -4
  63. package/server/services/remote-relay-caller-context.mjs +346 -0
  64. package/server/services/remote-relay-client.mjs +332 -0
  65. package/server/services/remote-relay-dispatcher.mjs +1743 -0
  66. package/server/services/remote-relay-inbound.mjs +187 -0
  67. package/server/services/remote-relay-pairing.mjs +189 -0
  68. package/server/services/remote-relay-registry-service.mjs +581 -0
  69. package/server/services/session-worker-launch-service.mjs +148 -14
  70. package/shared/remote-relay-contract.mjs +556 -0
  71. package/shared/remote-relay-mentions.mjs +129 -0
  72. package/shared/remote-relay-tool-core.mjs +314 -0
  73. package/shared/steer-settle-failure.mjs +6 -0
  74. package/shared/steer-settle-markers.mjs +16 -0
  75. package/shared/worker-runtime/api-client.mjs +112 -22
  76. package/shared/worker-runtime/worker-log-file.mjs +97 -0
@@ -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) => ({
@@ -20,6 +20,7 @@ import {
20
20
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
21
21
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
22
22
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
23
+ import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
23
24
  import {
24
25
  createRunnerLinkBridge,
25
26
  failSettlingRowsOnShutdown,
@@ -33,6 +34,10 @@ import {
33
34
  } from '../../shared/worker-bootstrap.mjs';
34
35
  import { createCopilotSdkSessionRunner } from './copilot-sdk-session-process.mjs';
35
36
 
37
+ // Windows console launch: the launcher names the worker log, this copies
38
+ // stdout/stderr into it. A no-op wherever the launcher redirects instead.
39
+ installWorkerLogFile();
40
+
36
41
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
37
42
  const HEARTBEAT_MS = 10_000;
38
43
 
@@ -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
+ }
@@ -13,10 +13,15 @@ import {
13
13
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
14
14
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
15
15
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
16
+ import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
16
17
  import { createControlPoller } from '../../shared/control-poller.mjs';
17
18
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
18
19
  import { createCursorTurnRunner } from './cursor-turn-runner.mjs';
19
20
 
21
+ // Windows console launch: the launcher names the worker log, this copies
22
+ // stdout/stderr into it. A no-op wherever the launcher redirects instead.
23
+ installWorkerLogFile();
24
+
20
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
26
  const HEARTBEAT_MS = 10_000;
22
27
 
@@ -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;
@@ -1,6 +1,11 @@
1
1
  import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
2
2
  import { capThought } from '../../shared/thought-cap.mjs';
3
3
  import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
4
+ import {
5
+ REMOTE_RELAY_TOOL_NAME,
6
+ isRemoteRelayToolName,
7
+ remoteRelayActivitySummary,
8
+ } from '../../shared/remote-relay-tool-core.mjs';
4
9
 
5
10
  const MAX_TOOL_DETAIL_LENGTH = 140;
6
11
  const SUBAGENT_TOOL_NAMES = new Set(['task', 'agent']);
@@ -39,7 +44,27 @@ export function summarizeToolInput(toolName, input) {
39
44
  }
40
45
  }
41
46
 
47
+ /**
48
+ * The relay's custom tools reach the stream as the SDK's generic MCP call
49
+ * (`name: 'mcp'`, args `{ providerIdentifier: 'custom-user-tools', toolName,
50
+ * args }`). `remote_relay` is shown as itself with the contract's one-line
51
+ * summary, so the transcript reads `Tool (remote_relay): send → …` like every
52
+ * other provider's; null for any other tool.
53
+ */
54
+ function remoteRelayCallArgs(toolName, input) {
55
+ if (isRemoteRelayToolName(toolName)) return input || {};
56
+ if (String(toolName || '').trim().toLowerCase() !== 'mcp' || !input || typeof input !== 'object') return null;
57
+ return isRemoteRelayToolName(input.toolName) ? (input.args || {}) : null;
58
+ }
59
+
42
60
  export function formatToolActivityText(toolName, input) {
61
+ const remoteArgs = remoteRelayCallArgs(toolName, input);
62
+ if (remoteArgs) {
63
+ const remoteSummary = remoteRelayActivitySummary(remoteArgs);
64
+ return truncate(remoteSummary
65
+ ? `Tool (${REMOTE_RELAY_TOOL_NAME}): ${remoteSummary}`
66
+ : `Tool (${REMOTE_RELAY_TOOL_NAME})`);
67
+ }
43
68
  const summary = summarizeToolInput(toolName, input);
44
69
  return truncate(summary ? `Tool (${toolName}): ${summary}` : `Tool (${toolName})`);
45
70
  }
@@ -10,6 +10,7 @@
10
10
  import { rebuildRecentWorkspaceRootsTable } from './migrations/0002-recent-workspace-roots-path-key.mjs';
11
11
  import { ensurePushSubscriptionsTable } from './migrations/0003-push-subscriptions.mjs';
12
12
  import { migrateSteerSettleMarkers } from './migrations/0004-steer-settle-markers.mjs';
13
+ import { migrateRemoteRelays } from './migrations/0005-remote-relays.mjs';
13
14
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
14
15
 
15
16
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -702,6 +703,13 @@ try {
702
703
  } catch (error) {
703
704
  console.warn(`[steering] migration 0004 failed; retrying on next boot: ${error?.message || error}`);
704
705
  }
706
+ // Remote relays: additive columns and the unlock table. Readers tolerate their
707
+ // absence, so a failure is logged and retried next boot like 0004.
708
+ try {
709
+ migrateRemoteRelays(db);
710
+ } catch (error) {
711
+ console.warn(`[remote-relays] migration 0005 failed; retrying on next boot: ${error?.message || error}`);
712
+ }
705
713
 
706
714
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
707
715
  if (runtimeSessionColumns.length) {