@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
@@ -17,6 +17,40 @@ function whichCommand(command = 'grok') {
17
17
  // ceiling bounds a single prompt absolutely. Both are overridable per call.
18
18
  export const ACP_PROMPT_INACTIVITY_MS = 120_000;
19
19
  export const ACP_PROMPT_MAX_TURN_MS = 1_800_000;
20
+ // How long the watchdog waits for its `holdStall` question before treating
21
+ // the silence as a stall after all.
22
+ export const ACP_HOLD_CHECK_TIMEOUT_MS = 10_000;
23
+
24
+ /**
25
+ * An MCP server launch (`{ command, args, env }`, see
26
+ * server/mcp/oar-mcp-server.mjs `buildOarMcpServerLaunch`) in the ACP stdio
27
+ * shape `session/new` and `session/load` take: no `type` field (that marks the
28
+ * http/sse transports), `args` and `env` always present, `env` as a list of
29
+ * `{ name, value }` pairs.
30
+ */
31
+ export function toAcpStdioMcpServer(launch, name = 'oar') {
32
+ const env = launch?.env && typeof launch.env === 'object' ? launch.env : {};
33
+ return {
34
+ name: String(name || '').trim() || 'oar',
35
+ command: String(launch?.command || ''),
36
+ args: Array.isArray(launch?.args) ? launch.args.map((arg) => String(arg)) : [],
37
+ env: Object.entries(env).map(([key, value]) => ({ name: key, value: String(value ?? '') })),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * An AcpClient class whose sessions carry `mcpServers` unless the caller
43
+ * passes its own. Lets the turn runner hand the adapter (which builds the
44
+ * client and opens the session in one step) a client that already knows them.
45
+ */
46
+ export function withAcpMcpServers(BaseClient, mcpServers = []) {
47
+ const servers = Array.isArray(mcpServers) ? mcpServers : [];
48
+ return class AcpClientWithMcpServers extends BaseClient {
49
+ constructor(opts = {}) {
50
+ super({ ...opts, mcpServers: Array.isArray(opts?.mcpServers) ? opts.mcpServers : servers });
51
+ }
52
+ };
53
+ }
20
54
 
21
55
  /**
22
56
  * Pick the one-shot allow option from an ACP permission request. A blanket
@@ -47,10 +81,13 @@ export class AcpClient extends EventEmitter {
47
81
  * @param {string} [opts.cwd]
48
82
  * @param {NodeJS.ProcessEnv} [opts.env]
49
83
  * @param {boolean} [opts.alwaysApprove]
84
+ * @param {Array<object>} [opts.mcpServers] ACP McpServer entries every
85
+ * session/new and session/load carries (see toAcpStdioMcpServer)
50
86
  * @param {typeof spawn} [opts.spawnImpl]
51
87
  */
52
88
  constructor(opts = {}) {
53
89
  super();
90
+ this.mcpServers = Array.isArray(opts.mcpServers) ? opts.mcpServers.map((server) => ({ ...server })) : [];
54
91
  this.command = whichCommand(opts.command || 'grok');
55
92
  this.args = Array.isArray(opts.args) ? [...opts.args] : ['agent', '--no-leader', 'stdio'];
56
93
  if (opts.alwaysApprove && !this.args.includes('--always-approve') && !this.args.includes('--yolo')) {
@@ -276,10 +313,12 @@ export class AcpClient extends EventEmitter {
276
313
  return this.initializeResult;
277
314
  }
278
315
 
316
+ // MCP servers are per session in ACP, and a loaded session does not keep the
317
+ // ones it was created with — both calls pass them.
279
318
  async sessionNew(cwd, extra = {}) {
280
319
  return this.request('session/new', {
281
320
  cwd,
282
- mcpServers: [],
321
+ mcpServers: this.mcpServers,
283
322
  ...extra,
284
323
  });
285
324
  }
@@ -288,7 +327,7 @@ export class AcpClient extends EventEmitter {
288
327
  return this.request('session/load', {
289
328
  sessionId,
290
329
  cwd,
291
- mcpServers: [],
330
+ mcpServers: this.mcpServers,
292
331
  ...extra,
293
332
  });
294
333
  }
@@ -305,8 +344,15 @@ export class AcpClient extends EventEmitter {
305
344
  * @param {string} sessionId
306
345
  * @param {Array} prompt content blocks
307
346
  * @param {(update: object) => void} [onUpdate]
347
+ * `holdStall` (async, optional) is asked once the inactivity window has run
348
+ * out: `true` re-arms the full window instead of failing the prompt. It is
349
+ * for work the agent is blocked on that this client cannot see — a
350
+ * remote_relay call the agent runs through its MCP server, which the relay
351
+ * reports as in flight. A question that fails or takes longer than
352
+ * `holdCheckTimeoutMs` counts as `false`.
353
+ *
308
354
  * @param {object} [extra] additional session/prompt params (e.g. `_meta`)
309
- * @param {object} [watchdog] { inactivityMs, maxTurnMs, hasPendingWork }
355
+ * @param {object} [watchdog] { inactivityMs, maxTurnMs, hasPendingWork, holdStall, holdCheckTimeoutMs }
310
356
  */
311
357
  async sessionPrompt(sessionId, prompt, onUpdate, extra = {}, watchdog = {}) {
312
358
  const inactivityMs = Number(watchdog?.inactivityMs) > 0
@@ -322,6 +368,10 @@ export class AcpClient extends EventEmitter {
322
368
  const hasPendingWork = typeof watchdog?.hasPendingWork === 'function'
323
369
  ? watchdog.hasPendingWork
324
370
  : () => false;
371
+ const holdStall = typeof watchdog?.holdStall === 'function' ? watchdog.holdStall : null;
372
+ const holdCheckTimeoutMs = Number(watchdog?.holdCheckTimeoutMs) > 0
373
+ ? Number(watchdog.holdCheckTimeoutMs)
374
+ : ACP_HOLD_CHECK_TIMEOUT_MS;
325
375
  const handler = (msg) => {
326
376
  if (msg.method !== 'session/update') return;
327
377
  if (msg.params?.sessionId && msg.params.sessionId !== sessionId) return;
@@ -329,8 +379,40 @@ export class AcpClient extends EventEmitter {
329
379
  };
330
380
  this.on('notification', handler);
331
381
  let watchdogTimer = null;
382
+ let settled = false;
383
+ // A granted hold restarts the window without faking ACP traffic.
384
+ let heldAt = 0;
385
+ let holdCheck = null;
386
+ const quietSince = () => Math.max(this.lastActivityAt, heldAt);
332
387
  const startedAt = Date.now();
333
388
  const stallPromise = new Promise((_, reject) => {
389
+ const stall = () => {
390
+ if (settled) return;
391
+ try { this.sessionCancel(sessionId); } catch { /* ignore */ }
392
+ reject(new Error(`grok turn stalled: no ACP activity for ${Math.round(inactivityMs / 1000)}s`));
393
+ };
394
+ const askForHold = () => {
395
+ let timer = null;
396
+ holdCheck = Promise.race([
397
+ Promise.resolve().then(() => holdStall()),
398
+ new Promise((resolve) => {
399
+ timer = setTimeout(() => resolve(false), holdCheckTimeoutMs);
400
+ timer.unref?.();
401
+ }),
402
+ ])
403
+ .then((hold) => hold === true, () => false)
404
+ .then((hold) => {
405
+ if (timer) clearTimeout(timer);
406
+ holdCheck = null;
407
+ if (settled) return;
408
+ if (hold) {
409
+ heldAt = Date.now();
410
+ return;
411
+ }
412
+ // Traffic that arrived while the question ran re-armed it anyway.
413
+ if (Date.now() - quietSince() >= inactivityMs) stall();
414
+ });
415
+ };
334
416
  const pollMs = Math.max(250, Math.min(5000, Math.floor(inactivityMs / 4)));
335
417
  watchdogTimer = setInterval(() => {
336
418
  const now = Date.now();
@@ -340,10 +422,13 @@ export class AcpClient extends EventEmitter {
340
422
  return;
341
423
  }
342
424
  if (hasPendingWork()) return;
343
- if (now - this.lastActivityAt >= inactivityMs) {
344
- try { this.sessionCancel(sessionId); } catch { /* ignore */ }
345
- reject(new Error(`grok turn stalled: no ACP activity for ${Math.round(inactivityMs / 1000)}s`));
425
+ if (now - quietSince() < inactivityMs) return;
426
+ if (!holdStall) {
427
+ stall();
428
+ return;
346
429
  }
430
+ // Before the abort: is the agent blocked on work only the relay sees?
431
+ if (!holdCheck) askForHold();
347
432
  }, pollMs);
348
433
  if (typeof watchdogTimer.unref === 'function') watchdogTimer.unref();
349
434
  });
@@ -360,6 +445,7 @@ export class AcpClient extends EventEmitter {
360
445
  stallPromise,
361
446
  ]);
362
447
  } finally {
448
+ settled = true;
363
449
  clearInterval(watchdogTimer);
364
450
  this.off('notification', handler);
365
451
  }
@@ -12,10 +12,15 @@ import {
12
12
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
13
13
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
14
14
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
15
+ import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
15
16
  import { createControlPoller } from '../../shared/control-poller.mjs';
16
17
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
17
18
  import { createGrokTurnRunner } from './grok-turn-runner.mjs';
18
19
 
20
+ // Windows console launch: the launcher names the worker log, this copies
21
+ // stdout/stderr into it. A no-op wherever the launcher redirects instead.
22
+ installWorkerLogFile();
23
+
19
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
25
  const HEARTBEAT_MS = 10_000;
21
26
 
@@ -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})`);