@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
@@ -3,9 +3,12 @@ import {
3
3
  startGrokTurn,
4
4
  classifyGrokError,
5
5
  } from './grok-sdk-adapter.mjs';
6
+ import { AcpClient, toAcpStdioMcpServer, withAcpMcpServers } from './acp-client.mjs';
6
7
  import { buildGrokContextUsage, resolveGrokContextWindow } from './grok-context-usage.mjs';
7
8
  import { extractGrokUsageFromPromptResult, normalizeGrokTurnUsage } from '../services/plan-usage-grok.mjs';
9
+ import { OAR_MCP_SERVER_NAME, buildOarMcpServerLaunch, isOarMcpServerDisabled } from '../mcp/oar-mcp-server.mjs';
8
10
  import { createPreviewInstructionsProvider } from '../../shared/preview-instructions.mjs';
11
+ import { fetchRemoteRelayInflight } from '../../shared/remote-relay-tool-core.mjs';
9
12
  import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
10
13
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
11
14
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
@@ -110,8 +113,30 @@ export function createGrokTurnRunner({
110
113
  startGrokTurnImpl = startGrokTurn,
111
114
  classifyErrorImpl = classifyGrokError,
112
115
  getPreviewInstructions = createPreviewInstructionsProvider({ api }),
116
+ env = process.env,
117
+ nodePath = process.execPath,
118
+ // The OAR MCP server launch (`{ command, args, env }`) every Grok session
119
+ // gets through ACP `mcpServers`; undefined builds the default for this
120
+ // worker's conversation, null leaves Grok without it.
121
+ oarMcpServer = undefined,
113
122
  dbg = () => {},
114
123
  } = {}) {
124
+ // The OAR MCP server gives Grok the relay's real tools — `preview`, and
125
+ // `remote_relay` when remotes are paired — bound to this conversation.
126
+ // `OAR_MCP_SERVER=0` is the operator's way back to the preview instruction
127
+ // block, should an agent build turn out to ignore ACP `mcpServers`.
128
+ const oarMcpLaunch = oarMcpServer !== undefined
129
+ ? (oarMcpServer || null)
130
+ : (() => {
131
+ if (isOarMcpServerDisabled(env)) return null;
132
+ try {
133
+ return buildOarMcpServerLaunch({ nodePath, conversationId: sdkSessionId, env });
134
+ } catch (error) {
135
+ dbg('oar mcp server unavailable', error?.message || String(error));
136
+ return null;
137
+ }
138
+ })();
139
+ const acpMcpServers = oarMcpLaunch ? [toAcpStdioMcpServer(oarMcpLaunch, OAR_MCP_SERVER_NAME)] : [];
115
140
  let agentHandle = null;
116
141
  let grokNativeSessionId = '';
117
142
  let activeMessage = null;
@@ -175,6 +200,12 @@ export function createGrokTurnRunner({
175
200
  alwaysApprove,
176
201
  nativeSessionId: resumeId,
177
202
  model,
203
+ // The adapter builds the ACP client and opens the session in one step,
204
+ // so the client it builds must already carry the MCP servers — for
205
+ // session/new and for session/load alike.
206
+ ...(acpMcpServers.length
207
+ ? { AcpClientImpl: withAcpMcpServers(AcpClient, acpMcpServers), mcpServers: acpMcpServers }
208
+ : {}),
178
209
  dbg,
179
210
  });
180
211
  if (agentHandle.sessionId) {
@@ -188,6 +219,20 @@ export function createGrokTurnRunner({
188
219
  return agentHandle;
189
220
  }
190
221
 
222
+ /**
223
+ * The inactivity hold. A remote_relay call runs inside the MCP server, which
224
+ * this worker cannot see: Grok just goes quiet for as long as the relay
225
+ * waits on the remote turn. Before the ACP watchdog calls that a stall, the
226
+ * relay is asked whether a call of this conversation is still in flight.
227
+ * Keyed by the MCP server's own conversation id, which is what its calls
228
+ * carry.
229
+ */
230
+ async function remoteRelayCallInFlight() {
231
+ const inflight = await fetchRemoteRelayInflight({ api, conversationId: sdkSessionId });
232
+ if (inflight > 0) dbg('remote_relay call in flight; holding the inactivity watchdog', String(inflight));
233
+ return inflight > 0;
234
+ }
235
+
191
236
  async function postActivity(message, text, subagentRunId = null) {
192
237
  if (!text) return;
193
238
  await api('POST', '/api/activity', {
@@ -369,7 +414,9 @@ export function createGrokTurnRunner({
369
414
  const modeNudge = grokModeNudge(message.relayMode, lastNudgedRelayMode);
370
415
  const userText = buildGrokUserText(message);
371
416
  // Advisory: a preview lookup that fails costs the guidance, never the turn.
372
- const previewBlock = previewInstructionsSent
417
+ // With the OAR MCP server wired in, `preview` is a real tool and the HTTP
418
+ // instruction block (which says there is none) would contradict it.
419
+ const previewBlock = previewInstructionsSent || acpMcpServers.length
373
420
  ? ''
374
421
  : await Promise.resolve()
375
422
  .then(() => getPreviewInstructions?.())
@@ -378,6 +425,16 @@ export function createGrokTurnRunner({
378
425
  const promptText = [previewBlock, mediaBlock, modeNudge, userText].filter(Boolean).join('\n\n');
379
426
  const pendingNudgedRelayMode = String(message.relayMode || 'agent').trim().toLowerCase();
380
427
 
428
+ // The user's max-turn-duration setting overrides the worker-local
429
+ // absolute cap: "No limit" (0) must not be silently trimmed to the
430
+ // 30-minute default. The inactivity watchdog stays — it detects a dead
431
+ // transport, not a long turn — but asks the relay before it fires while
432
+ // Grok may be blocked on a remote_relay call.
433
+ const watchdog = {
434
+ ...(turnCeilingMs !== null ? { maxTurnMs: turnCeilingMs > 0 ? turnCeilingMs : 0 } : {}),
435
+ ...(acpMcpServers.length ? { holdStall: remoteRelayCallInFlight } : {}),
436
+ };
437
+
381
438
  let turn = null;
382
439
  let planBoardPosted = false;
383
440
  const controlState = controlPoller?.start?.({
@@ -399,13 +456,7 @@ export function createGrokTurnRunner({
399
456
  text: promptText,
400
457
  reasoningEffort: message.reasoningEffort || '',
401
458
  abortSignal: abortController.signal,
402
- // The user's max-turn-duration setting overrides the worker-local
403
- // absolute cap: "No limit" (0) must not be silently trimmed to
404
- // the 30-minute default. The inactivity watchdog stays — it
405
- // detects a dead transport, not a long turn.
406
- watchdog: turnCeilingMs !== null
407
- ? { maxTurnMs: turnCeilingMs > 0 ? turnCeilingMs : 0 }
408
- : null,
459
+ watchdog: Object.keys(watchdog).length ? watchdog : null,
409
460
  dbg,
410
461
  });
411
462
  for await (const action of turn) {
@@ -4,6 +4,11 @@
4
4
  */
5
5
  import { capThought } from '../../shared/thought-cap.mjs';
6
6
  import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
7
+ import {
8
+ REMOTE_RELAY_TOOL_NAME,
9
+ isRemoteRelayToolName,
10
+ remoteRelayActivitySummary,
11
+ } from '../../shared/remote-relay-tool-core.mjs';
7
12
 
8
13
  const MAX_TOOL_DETAIL_LENGTH = 140;
9
14
  const SUBAGENT_TOOL_NAMES = new Set(['task', 'agent', 'subagent']);
@@ -59,6 +64,14 @@ export function summarizeToolInput(input) {
59
64
  }
60
65
 
61
66
  export function formatToolActivityText(toolName, input, title = '') {
67
+ // The OAR MCP server's `remote_relay`, however the agent labels it (bare,
68
+ // or namespaced by the server), reads as what it does on which relay.
69
+ if (isRemoteRelayToolName(toolName) || isRemoteRelayToolName(title)) {
70
+ const remoteSummary = remoteRelayActivitySummary(input);
71
+ return truncate(remoteSummary
72
+ ? `Tool (${REMOTE_RELAY_TOOL_NAME}): ${remoteSummary}`
73
+ : `Tool (${REMOTE_RELAY_TOOL_NAME})`);
74
+ }
62
75
  const name = String(toolName || title || 'tool').trim() || 'tool';
63
76
  const summary = summarizeToolInput(input) || String(title || '').trim();
64
77
  return truncate(summary ? `Tool (${name}): ${summary}` : `Tool (${name})`);
@@ -0,0 +1,473 @@
1
+ #!/usr/bin/env node
2
+ // OAR's own stdio MCP server. It gives the agents OAR launches without an
3
+ // in-process tool surface — Grok (ACP `mcpServers`) and the Copilot CLI
4
+ // extension engine (`--additional-mcp-config`) — the relay's real tools:
5
+ // `remote_relay` and `preview`, bound to the one conversation it was started
6
+ // for, so the relay's mention gate and approval apply exactly as they do for
7
+ // the Claude, Cursor and Copilot SDK workers.
8
+ //
9
+ // Hand-written on purpose (no MCP SDK dependency): JSON-RPC 2.0 as
10
+ // newline-delimited JSON on stdin/stdout. stdout carries protocol frames ONLY;
11
+ // every diagnostic goes to stderr.
12
+ //
13
+ // node server/mcp/oar-mcp-server.mjs --conversation-id <id>
14
+ //
15
+ // The relay is found the way the session workers find it: the config at
16
+ // COPILOT_WEB_RELAY_CONFIG (else server/config.json) supplies the token and
17
+ // port, COPILOT_WEB_RELAY_SERVER_URL overrides the address.
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import readline from 'node:readline';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
25
+ import { loadTokenFromConfig, resolveRelayServerUrl } from '../../shared/worker-runtime/config-loader.mjs';
26
+ import {
27
+ PREVIEW_TOOL_DESCRIPTION,
28
+ PREVIEW_TOOL_INPUT_SCHEMA,
29
+ PREVIEW_TOOL_NAME,
30
+ executePreviewTool,
31
+ } from '../../shared/preview-tool-core.mjs';
32
+ import {
33
+ REMOTE_RELAY_CALL_FAILED,
34
+ REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS,
35
+ REMOTE_RELAY_TOOL_DESCRIPTION,
36
+ REMOTE_RELAY_TOOL_NAME,
37
+ cloneRemoteRelayToolInputSchema,
38
+ executeRemoteRelayTool,
39
+ formatRemoteRelayToolResult,
40
+ shouldRegisterRemoteRelayTool,
41
+ } from '../../shared/remote-relay-tool-core.mjs';
42
+
43
+ const MODULE_PATH = fileURLToPath(import.meta.url);
44
+
45
+ /** The server name hosts register it under (`oar`), and its `serverInfo.name`. */
46
+ export const OAR_MCP_SERVER_NAME = 'oar';
47
+ export const OAR_MCP_SERVER_SCRIPT_PATH = MODULE_PATH;
48
+
49
+ export const OAR_MCP_DEFAULT_PROTOCOL_VERSION = '2025-06-18';
50
+ // Everything this server speaks is the tools subset every revision shares, so
51
+ // a client's requested revision is echoed back when it is one of these.
52
+ export const OAR_MCP_SUPPORTED_PROTOCOL_VERSIONS = Object.freeze([
53
+ '2025-11-25',
54
+ '2025-06-18',
55
+ '2025-03-26',
56
+ '2024-11-05',
57
+ ]);
58
+
59
+ // A host may start MCP servers with a trimmed environment, so the variables
60
+ // that locate the relay travel explicitly with the launch. None is a secret:
61
+ // the token stays in the config file the path points at.
62
+ export const OAR_MCP_RELAY_ENV_KEYS = Object.freeze([
63
+ 'COPILOT_WEB_RELAY_CONFIG',
64
+ 'COPILOT_WEB_RELAY_SERVER_URL',
65
+ 'COPILOT_WEB_RELAY_ROOT',
66
+ 'COPILOT_WEB_RELAY_SERVER_DIR',
67
+ ]);
68
+
69
+ // How long a host should let one tool call run (for hosts that take a
70
+ // per-server timeout, like the Copilot CLI whose default is 180 s).
71
+ export const OAR_MCP_TOOL_TIMEOUT_MS = REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS;
72
+
73
+ // Progress heartbeats for a long call, sent only when the client asked for
74
+ // progress. Hosts that reset their timeout on progress keep waiting.
75
+ const DEFAULT_PROGRESS_INTERVAL_MS = 25_000;
76
+
77
+ // After the host closes stdin: how long a call still waiting on the relay may
78
+ // keep the process up before it is cut off.
79
+ const SHUTDOWN_GRACE_MS = 2_000;
80
+
81
+ const JSON_RPC = Object.freeze({
82
+ parseError: -32700,
83
+ invalidRequest: -32600,
84
+ methodNotFound: -32601,
85
+ invalidParams: -32602,
86
+ internalError: -32603,
87
+ });
88
+
89
+ function toText(value) {
90
+ return String(value ?? '').trim();
91
+ }
92
+
93
+ /**
94
+ * The operator's kill switch: `OAR_MCP_SERVER=0|false|off|no` keeps this
95
+ * server out of every session OAR launches (Grok falls back to its preview
96
+ * instruction block, the extension engine starts without the extra config).
97
+ */
98
+ export function isOarMcpServerDisabled(env = {}) {
99
+ return /^(0|false|off|no)$/i.test(String(env?.OAR_MCP_SERVER || '').trim());
100
+ }
101
+
102
+ /** `--conversation-id <id>` or `--conversation-id=<id>`; '' when absent. */
103
+ export function parseConversationIdArg(argv = []) {
104
+ const args = Array.isArray(argv) ? argv.map((arg) => String(arg ?? '')) : [];
105
+ const index = args.indexOf('--conversation-id');
106
+ if (index !== -1) return toText(args[index + 1]);
107
+ const inline = args.find((arg) => arg.startsWith('--conversation-id='));
108
+ return inline ? toText(inline.slice('--conversation-id='.length)) : '';
109
+ }
110
+
111
+ /**
112
+ * How to start this server for one conversation: `{ command, args, env }`,
113
+ * the neutral shape the Grok ACP wiring and the Copilot extension-engine
114
+ * config are built from. `env` holds only the relay-locating variables that
115
+ * are set in `env`.
116
+ */
117
+ export function buildOarMcpServerLaunch({
118
+ nodePath = process.execPath,
119
+ conversationId = '',
120
+ env = process.env,
121
+ scriptPath = MODULE_PATH,
122
+ } = {}) {
123
+ const id = toText(conversationId);
124
+ if (!id) throw new Error('oar-mcp-server: a conversation id is required');
125
+ const command = toText(nodePath);
126
+ if (!command) throw new Error('oar-mcp-server: a node executable is required');
127
+ const launchEnv = {};
128
+ for (const key of OAR_MCP_RELAY_ENV_KEYS) {
129
+ const value = toText(env?.[key]);
130
+ if (value) launchEnv[key] = value;
131
+ }
132
+ return { command, args: [toText(scriptPath), '--conversation-id', id], env: launchEnv };
133
+ }
134
+
135
+ function readPackageVersion() {
136
+ try {
137
+ const packagePath = path.resolve(path.dirname(MODULE_PATH), '..', '..', 'package.json');
138
+ return toText(JSON.parse(fs.readFileSync(packagePath, 'utf8'))?.version) || '0.0.0';
139
+ } catch {
140
+ return '0.0.0';
141
+ }
142
+ }
143
+
144
+ function toolDefinitions({ remoteRelay }) {
145
+ return [
146
+ ...(remoteRelay ? [{
147
+ name: REMOTE_RELAY_TOOL_NAME,
148
+ description: REMOTE_RELAY_TOOL_DESCRIPTION,
149
+ inputSchema: cloneRemoteRelayToolInputSchema(),
150
+ }] : []),
151
+ {
152
+ name: PREVIEW_TOOL_NAME,
153
+ description: PREVIEW_TOOL_DESCRIPTION,
154
+ inputSchema: JSON.parse(JSON.stringify(PREVIEW_TOOL_INPUT_SCHEMA)),
155
+ },
156
+ ];
157
+ }
158
+
159
+ function requestKey(id) {
160
+ return `${typeof id}:${String(id)}`;
161
+ }
162
+
163
+ /**
164
+ * The protocol core, transport-free: feed it parsed JSON-RPC messages
165
+ * (`handleMessage`) or raw lines (`handleLine`) and it answers through `send`.
166
+ * `remoteRelay` is the registration decision — a boolean or a promise of one;
167
+ * the tool list waits for it, `initialize` and `ping` do not.
168
+ */
169
+ export function createOarMcpServer({
170
+ api,
171
+ conversationId = '',
172
+ remoteRelay = false,
173
+ version = '0.0.0',
174
+ send = () => {},
175
+ log = () => {},
176
+ progressIntervalMs = DEFAULT_PROGRESS_INTERVAL_MS,
177
+ } = {}) {
178
+ const conversation = toText(conversationId);
179
+ const remoteRelayDecision = Promise.resolve(remoteRelay).then((value) => value === true, () => false);
180
+ const inflight = new Map();
181
+
182
+ function reply(id, result) {
183
+ return { jsonrpc: '2.0', id, result };
184
+ }
185
+
186
+ function fail(id, code, message, data) {
187
+ return { jsonrpc: '2.0', id: id ?? null, error: { code, message, ...(data === undefined ? {} : { data }) } };
188
+ }
189
+
190
+ async function listTools() {
191
+ return toolDefinitions({ remoteRelay: await remoteRelayDecision });
192
+ }
193
+
194
+ async function runTool(name, args, signal) {
195
+ if (name === REMOTE_RELAY_TOOL_NAME) {
196
+ const result = await executeRemoteRelayTool(args, { api, conversationId: conversation, signal });
197
+ return formatRemoteRelayToolResult(result);
198
+ }
199
+ const result = await executePreviewTool(args, { api, conversationId: conversation });
200
+ return JSON.stringify(result);
201
+ }
202
+
203
+ function startProgress(progressToken) {
204
+ if (progressToken === undefined || progressToken === null || !(Number(progressIntervalMs) > 0)) return () => {};
205
+ let progress = 0;
206
+ const timer = setInterval(() => {
207
+ progress += 1;
208
+ send({
209
+ jsonrpc: '2.0',
210
+ method: 'notifications/progress',
211
+ params: { progressToken, progress, message: 'Still waiting for the relay…' },
212
+ });
213
+ }, progressIntervalMs);
214
+ timer.unref?.();
215
+ return () => clearInterval(timer);
216
+ }
217
+
218
+ async function callTool(id, params) {
219
+ const name = toText(params?.name);
220
+ const tools = await listTools();
221
+ if (!tools.some((tool) => tool.name === name)) {
222
+ return fail(id, JSON_RPC.invalidParams, `Unknown tool: ${name || '(none)'}`);
223
+ }
224
+ const args = params?.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments)
225
+ ? params.arguments
226
+ : {};
227
+ const entry = { cancelled: false, controller: new AbortController() };
228
+ const key = requestKey(id);
229
+ inflight.set(key, entry);
230
+ const stopProgress = startProgress(params?._meta?.progressToken);
231
+ try {
232
+ const text = await runTool(name, args, entry.controller.signal);
233
+ return entry.cancelled ? null : reply(id, { content: [{ type: 'text', text }] });
234
+ } catch (error) {
235
+ // The cores never throw by contract; this is the belt to their braces,
236
+ // and still an answer the model can read rather than a protocol error.
237
+ const message = error?.message || String(error);
238
+ log(`tool ${name} failed: ${message}`);
239
+ if (entry.cancelled) return null;
240
+ return reply(id, {
241
+ content: [{ type: 'text', text: formatRemoteRelayToolResult({ ok: false, code: REMOTE_RELAY_CALL_FAILED, error: message }) }],
242
+ isError: true,
243
+ });
244
+ } finally {
245
+ stopProgress();
246
+ inflight.delete(key);
247
+ }
248
+ }
249
+
250
+ async function handleRequest(message) {
251
+ const { id, method, params } = message;
252
+ switch (method) {
253
+ case 'initialize': {
254
+ const requested = toText(params?.protocolVersion);
255
+ const protocolVersion = OAR_MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
256
+ ? requested
257
+ : OAR_MCP_DEFAULT_PROTOCOL_VERSION;
258
+ return reply(id, {
259
+ protocolVersion,
260
+ capabilities: { tools: {} },
261
+ serverInfo: { name: OAR_MCP_SERVER_NAME, version },
262
+ });
263
+ }
264
+ case 'ping':
265
+ return reply(id, {});
266
+ case 'tools/list':
267
+ return reply(id, { tools: await listTools() });
268
+ case 'tools/call':
269
+ return callTool(id, params);
270
+ default:
271
+ return fail(id, JSON_RPC.methodNotFound, `Method not found: ${method}`);
272
+ }
273
+ }
274
+
275
+ // A cancelled call gets no answer (the spec's rule), and its relay request is
276
+ // aborted: the relay then withdraws an open approval card and stops waiting
277
+ // on the remote instead of working for nobody.
278
+ function cancel(entry) {
279
+ entry.cancelled = true;
280
+ entry.controller.abort();
281
+ }
282
+
283
+ function handleNotification(message) {
284
+ if (message.method === 'notifications/cancelled') {
285
+ const entry = inflight.get(requestKey(message.params?.requestId));
286
+ if (entry) cancel(entry);
287
+ }
288
+ // notifications/initialized and everything else need no answer.
289
+ }
290
+
291
+ /** Cancels every call still running (the host went away); returns how many. */
292
+ function cancelAll() {
293
+ let count = 0;
294
+ for (const entry of inflight.values()) {
295
+ if (entry.cancelled) continue;
296
+ cancel(entry);
297
+ count += 1;
298
+ }
299
+ return count;
300
+ }
301
+
302
+ /** One parsed message → its response, or null when none is due. */
303
+ async function handleMessage(message) {
304
+ if (Array.isArray(message)) {
305
+ // Batches (2025-03-26): answer what needs answering, as one array.
306
+ if (message.length === 0) return fail(null, JSON_RPC.invalidRequest, 'Empty batch');
307
+ const responses = (await Promise.all(message.map((entry) => handleMessage(entry)))).filter(Boolean);
308
+ return responses.length ? responses : null;
309
+ }
310
+ if (!message || typeof message !== 'object' || message.jsonrpc !== '2.0') {
311
+ return fail(message?.id, JSON_RPC.invalidRequest, 'Invalid Request');
312
+ }
313
+ const hasId = message.id !== undefined && message.id !== null;
314
+ if (typeof message.method !== 'string') {
315
+ // A response to a request this server never sends: nothing to do.
316
+ if (hasId && ('result' in message || 'error' in message)) return null;
317
+ return fail(message.id, JSON_RPC.invalidRequest, 'Invalid Request');
318
+ }
319
+ if (!hasId) {
320
+ handleNotification(message);
321
+ return null;
322
+ }
323
+ try {
324
+ return await handleRequest(message);
325
+ } catch (error) {
326
+ log(`${message.method} failed: ${error?.message || error}`);
327
+ return fail(message.id, JSON_RPC.internalError, error?.message || 'Internal error');
328
+ }
329
+ }
330
+
331
+ /** One raw stdin line; answers are sent as they are ready (ids correlate). */
332
+ async function handleLine(line) {
333
+ const text = String(line ?? '').trim();
334
+ if (!text) return;
335
+ let message;
336
+ try {
337
+ message = JSON.parse(text);
338
+ } catch {
339
+ send(fail(null, JSON_RPC.parseError, 'Parse error'));
340
+ return;
341
+ }
342
+ const response = await handleMessage(message);
343
+ if (response) send(response);
344
+ }
345
+
346
+ return { handleLine, handleMessage, listTools, cancelAll };
347
+ }
348
+
349
+ /**
350
+ * The stdio process: parse the arguments, reach the relay like a worker does,
351
+ * decide whether `remote_relay` is listed (asked once, at startup), then serve
352
+ * until stdin closes. Returns the server, or null after a startup failure.
353
+ */
354
+ export async function runOarMcpServer({
355
+ argv = process.argv.slice(2),
356
+ env = process.env,
357
+ stdin = process.stdin,
358
+ stdout = process.stdout,
359
+ stderr = process.stderr,
360
+ createApiClientImpl = createApiClient,
361
+ shouldRegisterRemoteRelayToolImpl = shouldRegisterRemoteRelayTool,
362
+ exit = (code) => process.exit(code),
363
+ } = {}) {
364
+ const log = (message) => {
365
+ try {
366
+ stderr.write(`[oar-mcp ${new Date().toISOString()}] ${message}\n`);
367
+ } catch {
368
+ // stderr gone: nothing else to tell.
369
+ }
370
+ };
371
+ const conversationId = parseConversationIdArg(argv);
372
+ if (!conversationId) {
373
+ log('missing --conversation-id <id>; this server serves exactly one conversation');
374
+ exit(2);
375
+ return null;
376
+ }
377
+
378
+ let api;
379
+ try {
380
+ const configPath = toText(env?.COPILOT_WEB_RELAY_CONFIG)
381
+ || path.resolve(path.dirname(MODULE_PATH), '..', 'config.json');
382
+ const serverUrl = resolveRelayServerUrl({ configPath, env });
383
+ const token = loadTokenFromConfig(configPath);
384
+ api = createApiClientImpl({
385
+ serverUrl,
386
+ token,
387
+ // The conversation this server is bound to, named the way the workers
388
+ // name theirs. No pid headers: this process is not a session worker and
389
+ // must never be taken for one.
390
+ getHeaders: () => ({
391
+ 'X-Relay-Session-Id': conversationId,
392
+ 'X-Relay-Conversation-Id': conversationId,
393
+ }),
394
+ });
395
+ } catch (error) {
396
+ log(`cannot reach the relay: ${error?.message || error}`);
397
+ exit(1);
398
+ return null;
399
+ }
400
+
401
+ let closed = false;
402
+ const send = (message) => {
403
+ if (closed) return;
404
+ try {
405
+ stdout.write(`${JSON.stringify(message)}\n`);
406
+ } catch (error) {
407
+ log(`stdout write failed: ${error?.message || error}`);
408
+ }
409
+ };
410
+ const server = createOarMcpServer({
411
+ api,
412
+ conversationId,
413
+ remoteRelay: Promise.resolve()
414
+ .then(() => shouldRegisterRemoteRelayToolImpl({ api }))
415
+ .then((enabled) => {
416
+ log(`serving conversation ${conversationId.slice(0, 8)}; remote_relay ${enabled ? 'on' : 'off'}`);
417
+ return enabled;
418
+ }),
419
+ version: readPackageVersion(),
420
+ send,
421
+ log,
422
+ });
423
+
424
+ const lines = readline.createInterface({ input: stdin, crlfDelay: Infinity });
425
+ // The host ends the session by closing stdin (or by vanishing: EPIPE). The
426
+ // process then leaves through the event loop rather than process.exit():
427
+ // on Windows an exit in the instant a fetch settles trips a libuv assertion
428
+ // (src\win\async.c, `!(handle->flags & UV_HANDLE_CLOSING)`) and dies with
429
+ // 0xC0000409. Calls still waiting on the relay are cancelled (nobody is
430
+ // left to read their answer, and the relay should stop working for them),
431
+ // which empties the loop; the grace timer is the backstop.
432
+ let shuttingDown = false;
433
+ const shutdown = () => {
434
+ if (shuttingDown) return;
435
+ shuttingDown = true;
436
+ closed = true;
437
+ const cancelled = server.cancelAll();
438
+ if (cancelled) log(`host hung up: cancelled ${cancelled} call(s) still waiting on the relay`);
439
+ lines.close();
440
+ const timer = setTimeout(() => exit(0), SHUTDOWN_GRACE_MS);
441
+ timer.unref?.();
442
+ };
443
+ stdout.on?.('error', (error) => {
444
+ log(`stdout closed: ${error?.message || error}`);
445
+ shutdown();
446
+ });
447
+ lines.on('line', (line) => {
448
+ server.handleLine(line).catch((error) => log(`message handling failed: ${error?.message || error}`));
449
+ });
450
+ lines.on('close', shutdown);
451
+ return server;
452
+ }
453
+
454
+ function isMainModule() {
455
+ const entry = process.argv[1];
456
+ if (!entry) return false;
457
+ try {
458
+ const normalize = (value) => {
459
+ const resolved = fs.realpathSync(path.resolve(value));
460
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
461
+ };
462
+ return normalize(entry) === normalize(MODULE_PATH);
463
+ } catch {
464
+ return false;
465
+ }
466
+ }
467
+
468
+ if (isMainModule()) {
469
+ runOarMcpServer().catch((error) => {
470
+ process.stderr.write(`oar-mcp-server fatal: ${error?.stack || error?.message || error}\n`);
471
+ process.exit(1);
472
+ });
473
+ }