@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
@@ -4,8 +4,7 @@
4
4
  // control polling, activity channels) but executes turns through the Copilot
5
5
  // SDK's headless runtime instead of the `copilot` TUI under a PTY.
6
6
  //
7
- // Dormant until phase 3 registers a worker kind for it. Run it by hand against
8
- // a live relay with:
7
+ // Run it by hand against a live relay with:
9
8
  //
10
9
  // COPILOT_WEB_RELAY_CONFIG=server/config.json \
11
10
  // COPILOT_WORKSPACE_ROOT=/path/to/workspace \
@@ -21,6 +20,10 @@ import {
21
20
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
22
21
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
23
22
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
23
+ import {
24
+ createRunnerLinkBridge,
25
+ failSettlingRowsOnShutdown,
26
+ } from '../../shared/worker-runtime/runner-link-wiring.mjs';
24
27
  import { createControlPoller } from '../../shared/control-poller.mjs';
25
28
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
26
29
  import {
@@ -69,8 +72,29 @@ async function main() {
69
72
  api,
70
73
  sdkSessionId,
71
74
  abortAckNote: 'copilot session aborted',
75
+ // A subagent lane's Stop. Lanes are keyed on the envelope agentId, which
76
+ // is also the runtime's task id, so `rpc.tasks.cancel` is tried; the
77
+ // runtime refuses ids it does not track (then the control answers "not
78
+ // supported" and the whole-turn Stop remains). Background agents have no
79
+ // lane — their Stop is the task panel's. (Late-bound: controls only poll
80
+ // while a turn is live.)
81
+ onAbortSubagent: (subagentRunId) => turnRunner.stopBackgroundTask(subagentRunId),
72
82
  dbg,
73
83
  });
84
+ // The relay's *Background task timeout* slider (0 = unlimited) rides every
85
+ // delivery payload, exactly as for the Claude worker: background agents and
86
+ // shells now have cards and a Stop, so the slider governs them too and a cap
87
+ // expiry CANCELS them rather than forgetting them. The env var is an
88
+ // emergency override only.
89
+ const backgroundTaskTimeoutOverrideMs = readOptionalMs('COPILOT_SDK_RELAY_BACKGROUND_TASK_TIMEOUT_MS');
90
+ let backgroundTaskTimeoutMs = backgroundTaskTimeoutOverrideMs ?? 0;
91
+ // The runner reports every flip of its delivery gate; the bridge turns them
92
+ // into the link's worker.unready / worker.ready frames, so a message queued
93
+ // while a question card is open is held in the relay queue and steers into
94
+ // the resumed turn one round trip after the answer.
95
+ const linkBridge = createRunnerLinkBridge();
96
+ // Late-bound: the heartbeat controller is built after the runner.
97
+ let heartbeat = null;
74
98
  const turnRunner = createCopilotSdkSessionRunner({
75
99
  api,
76
100
  sdkSessionId,
@@ -82,23 +106,22 @@ async function main() {
82
106
  // How long a deferred model switch may wait for its drain before the
83
107
  // explicit selection fails the row (default 10s).
84
108
  modelSwitchTimeoutMs: readOptionalMs('COPILOT_SDK_RELAY_MODEL_SWITCH_TIMEOUT_MS'),
85
- // How long live detached shells alone may hold the runtime open (0 = no
86
- // limit). Deliberately NOT the relay's `background_task_timeout_minutes`
87
- // slider that rides every delivery payload: that slider defaults to
88
- // 0/unlimited and governs Claude's background tasks, which the composer
89
- // lists and can stop. A Copilot detached shell has neither, so consuming it
90
- // would make "a forgotten shell pins a runtime forever" the default with
91
- // nothing in the UI to reveal it. The runner's own 30-minute cap applies
92
- // unless this says otherwise.
93
- ...(() => {
94
- const override = readOptionalMs('COPILOT_SDK_RELAY_BACKGROUND_TASK_TIMEOUT_MS');
95
- return override === undefined ? {} : { getBackgroundTaskTimeoutMs: () => override };
96
- })(),
109
+ getBackgroundTaskTimeoutMs: () => backgroundTaskTimeoutMs,
110
+ // A readiness flip is a hold starting or ending (question card, approval,
111
+ // compaction): the composer's steering snapshot changed with it.
112
+ onDeliveryReadinessChange: (ready) => {
113
+ linkBridge.onDeliveryReadinessChange(ready);
114
+ heartbeat?.requestPulse();
115
+ },
116
+ // A message became (or stopped being) cancellable: report it now rather
117
+ // than on the next 10 s heartbeat (coalesced by the controller).
118
+ onCancellableChange: () => heartbeat?.requestPulse(),
119
+ canHandBackHeldDelivery: () => linkBridge.canHandBackHeldDelivery(),
97
120
  dbg,
98
121
  });
99
122
 
100
123
  let heartbeatTimer = null;
101
- const heartbeat = createHeartbeatController({
124
+ heartbeat = createHeartbeatController({
102
125
  api,
103
126
  pollMs: HEARTBEAT_MS,
104
127
  getSessionReady: () => true,
@@ -110,6 +133,12 @@ async function main() {
110
133
  // the crash guard below takes the entries whole so its requeues stay
111
134
  // fenced to this attempt.
112
135
  getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds().map((entry) => entry.id),
136
+ // The composer's steering snapshot (Steer / Queue + the hold reason, the
137
+ // un-steerable rows) and the consumed steers whose settle the runner gave
138
+ // up on — the relay fails those itself, never re-running them.
139
+ getSteeringState: () => turnRunner.steeringState(),
140
+ getSettleFailed: () => turnRunner.getSettleFailed(),
141
+ onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
113
142
  });
114
143
 
115
144
  const wsLink = createWorkerWebSocketLink({
@@ -119,8 +148,22 @@ async function main() {
119
148
  getSessionReady: () => true,
120
149
  getSessionId: () => sdkSessionId,
121
150
  getPid: () => process.pid,
151
+ // Mid-turn steering opt-in: the link keeps signalling readiness while a
152
+ // delivery is in flight as long as the runner can absorb another message
153
+ // into the live turn, and withdraws it while the runner holds.
154
+ getSteeringReady: () => turnRunner.canAcceptSteering(),
155
+ getDeliveryHeld: () => turnRunner.isDeliveryHeld(),
156
+ // A relay that just (re)started knows nothing of this worker until it
157
+ // heartbeats; the composer's steering snapshot rides that beat.
158
+ onServerHello: () => heartbeat.requestPulse(),
122
159
  onDeliver: async (pending, reason) => {
123
160
  dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
161
+ // Slider changes reach a running worker on its next delivery; an
162
+ // explicit env override pins the value regardless.
163
+ const deliveredTimeout = Number(pending?.settings?.backgroundTaskTimeoutMs);
164
+ if (backgroundTaskTimeoutOverrideMs === undefined && Number.isFinite(deliveredTimeout) && deliveredTimeout >= 0) {
165
+ backgroundTaskTimeoutMs = deliveredTimeout;
166
+ }
124
167
  try {
125
168
  return await turnRunner.handlePendingPayload(pending);
126
169
  } catch (error) {
@@ -129,19 +172,38 @@ async function main() {
129
172
  }
130
173
  },
131
174
  onControl: (control) => {
132
- // Background-task controls have no Copilot SDK equivalent yet: the
133
- // runtime's `session.background_tasks_changed` carries an empty payload
134
- // (23 of them fire during a single bash call), so there is no task id to
135
- // stop. Logged rather than silently dropped.
136
- dbg('worker control ignored (unsupported by the SDK worker)', String(control?.type || '(none)'));
175
+ const type = String(control?.type || '').trim();
176
+ if (type === 'cancel_pushed_message') {
177
+ // Un-steer: pull a pushed-but-unconsumed message back out of the
178
+ // runtime's queue and cancel its row.
179
+ void turnRunner.cancelPushedMessage(control?.messageId).catch((error) => {
180
+ dbg('un-steer failed', error?.message || String(error));
181
+ });
182
+ return;
183
+ }
184
+ if (type === 'stop_background_task') {
185
+ // The task panel's per-card Stop → rpc.tasks.cancel.
186
+ const taskId = String(control?.taskId || '').trim();
187
+ dbg('stop background task control', taskId);
188
+ void turnRunner.stopBackgroundTask(taskId).catch((error) => {
189
+ dbg('stop background task failed', error?.message || String(error));
190
+ });
191
+ return;
192
+ }
193
+ dbg('worker control ignored (unsupported by the SDK worker)', type || '(none)');
137
194
  },
138
195
  });
196
+ linkBridge.attach(wsLink);
139
197
 
140
198
  const shutdown = async (signal) => {
141
199
  dbg(`shutting down (${signal})`);
142
200
  try { wsLink.stop(); } catch {}
143
201
  try { heartbeat.stopHeartbeat(); } catch {}
144
202
  try { controlPoller.stop(); } catch {}
203
+ // Rows whose prompt the runtime consumed and whose settle marker has not
204
+ // landed are failed terminally, never left for dead-worker recovery to
205
+ // requeue (that would run the prompt twice). Bounded.
206
+ try { await failSettlingRowsOnShutdown({ api, runner: turnRunner }); } catch {}
145
207
  // Bounded, like the Cursor worker: stopping the runtime is an RPC, and a
146
208
  // hung stop must never make the process ignore the supervisor's SIGTERM.
147
209
  try {
@@ -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;
@@ -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
  }
@@ -9,6 +9,8 @@
9
9
  // final schema either way.
10
10
  import { rebuildRecentWorkspaceRootsTable } from './migrations/0002-recent-workspace-roots-path-key.mjs';
11
11
  import { ensurePushSubscriptionsTable } from './migrations/0003-push-subscriptions.mjs';
12
+ import { migrateSteerSettleMarkers } from './migrations/0004-steer-settle-markers.mjs';
13
+ import { migrateRemoteRelays } from './migrations/0005-remote-relays.mjs';
12
14
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
13
15
 
14
16
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -637,6 +639,13 @@ if (!queueColumns.includes('parked_at')) {
637
639
  if (!queueColumns.includes('parked_target_session_id')) {
638
640
  db.exec(`ALTER TABLE queue ADD COLUMN parked_target_session_id TEXT`);
639
641
  }
642
+ if (!queueColumns.includes('consumed_at')) {
643
+ // Set when the Claude CLI has provably taken a row's prompt (a steer still
644
+ // pending when the turn it was pushed into finished). A consumed row is
645
+ // at-most-once: every recovery path fails it terminally instead of
646
+ // requeueing it, because a re-delivery would run the prompt a second time.
647
+ db.exec(`ALTER TABLE queue ADD COLUMN consumed_at TEXT`);
648
+ }
640
649
  if (!queueColumns.includes('kind')) {
641
650
  // 'continuation' rows are synthetic turns for the Claude worker's
642
651
  // background-task continuations: born 'processing', never deliverable, and
@@ -682,6 +691,25 @@ db.exec(`CREATE INDEX IF NOT EXISTS idx_queue_owner_pending ON queue(status, own
682
691
  db.exec(`CREATE INDEX IF NOT EXISTS idx_queue_parked_release ON queue(status, parked_transaction_id, parked_target_session_id, parked_at, timestamp)`);
683
692
  db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_queue_continuation_op ON queue(continuation_op_id) WHERE continuation_op_id IS NOT NULL`);
684
693
  migrateImageConversationSchema(db);
694
+ // After the queue's response_message_id exists: the source-link backfill
695
+ // reads it. Not boot-critical — every reader of its columns tolerates their
696
+ // absence — so a failure (e.g. SQLITE_BUSY) is logged and retried next boot
697
+ // instead of keeping the relay down.
698
+ try {
699
+ const steerSettleMarkersMigration = migrateSteerSettleMarkers(db);
700
+ if (steerSettleMarkersMigration.foldStubsRekinded) {
701
+ console.log(`[steering] re-kinded ${steerSettleMarkersMigration.foldStubsRekinded} fold stub(s) as 'folded'`);
702
+ }
703
+ } catch (error) {
704
+ console.warn(`[steering] migration 0004 failed; retrying on next boot: ${error?.message || error}`);
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
+ }
685
713
 
686
714
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
687
715
  if (runtimeSessionColumns.length) {
@@ -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
  }