@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.
- package/.github/extensions/web-relay/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +336 -0
- package/README.md +455 -423
- package/bin/oar.js +25 -10
- package/package.json +11 -5
- package/server/README.md +44 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +1073 -69
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +28 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +125 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +55 -14
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1016 -200
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +149 -6
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +12 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -1
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +40 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +463 -17
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +7 -1
- package/server/repositories/message-repository.mjs +68 -18
- package/server/repositories/question-repository.mjs +18 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +13 -32
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +572 -56
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +378 -84
- package/server/server-runtime.mjs +303 -25
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- 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
|
-
//
|
|
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
|
-
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/server/db-schema.mjs
CHANGED
|
@@ -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 -
|
|
344
|
-
|
|
345
|
-
|
|
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
|
}
|