@oar-sh/oar 0.9.3 → 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 +67 -0
- package/README.md +62 -13
- package/package.json +5 -2
- package/server/README.md +43 -3
- 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 +92 -25
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
- 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 +8 -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/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +103 -0
- package/server/public/app/bootstrap.js +21 -6
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-view.js +98 -7
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +4 -1
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
- 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 +4 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +18 -1
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +4 -0
- package/server/public/index.html +257 -8
- package/server/relay-tools.md +5 -1
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/question-repository.mjs +3 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +90 -16
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +80 -29
- package/server/server-runtime.mjs +238 -0
- 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/push-dispatch-service.mjs +37 -4
- 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/session-worker-launch-service.mjs +131 -12
- 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/worker-runtime/api-client.mjs +112 -22
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
isStaleAttemptError,
|
|
36
36
|
} from './claude-turn-publisher.mjs';
|
|
37
37
|
import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
|
|
38
|
+
import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
|
|
38
39
|
import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
|
|
39
40
|
import { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT } from '../../shared/steer-settle-markers.mjs';
|
|
40
41
|
import { stripSteerNote, withSteerNoteContent } from '../../shared/steer-note.mjs';
|
|
@@ -336,6 +337,34 @@ function isTaskNotificationReplay(sdkMessage) {
|
|
|
336
337
|
return text !== null && text.trimStart().startsWith(TASK_NOTIFICATION_TAG_PREFIX);
|
|
337
338
|
}
|
|
338
339
|
|
|
340
|
+
/**
|
|
341
|
+
* The CLI's own record of a command that ran locally, replayed on the stream
|
|
342
|
+
* as a user-role message. The one the relay causes is the model switch:
|
|
343
|
+
* `set_model` on a conversation another model already answered writes the
|
|
344
|
+
* "/model" breadcrumbs into the transcript and replays the
|
|
345
|
+
* `<local-command-stdout>Set model to …` one (`isReplay: true`, string
|
|
346
|
+
* content, no `origin`) BEFORE it answers the control request — read in the
|
|
347
|
+
* CLI binaries 2.1.261 to 2.1.283. A session that was never answered gets no
|
|
348
|
+
* breadcrumb, which is why a probe on a fresh session only ever shows the
|
|
349
|
+
* re-init. Only the two output tags count, and only on a replay the CLI wrote
|
|
350
|
+
* itself (no origin, not inside a subagent): output is never a prompt, so it
|
|
351
|
+
* never opens a turn. A `<command-name>` record is left alone on purpose: it
|
|
352
|
+
* heads prompt-type slash commands and skills, which do open one.
|
|
353
|
+
*/
|
|
354
|
+
const LOCAL_COMMAND_OUTPUT_TAG_PREFIXES = Object.freeze([
|
|
355
|
+
'<local-command-stdout>',
|
|
356
|
+
'<local-command-stderr>',
|
|
357
|
+
]);
|
|
358
|
+
|
|
359
|
+
function isLocalCommandReplay(sdkMessage) {
|
|
360
|
+
if (sdkMessage?.isReplay !== true) return false;
|
|
361
|
+
if (sdkMessage.origin || sdkMessage.parent_tool_use_id) return false;
|
|
362
|
+
const text = firstTextBlock(sdkMessage);
|
|
363
|
+
if (text === null) return false;
|
|
364
|
+
const trimmed = text.trimStart();
|
|
365
|
+
return LOCAL_COMMAND_OUTPUT_TAG_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
|
|
366
|
+
}
|
|
367
|
+
|
|
339
368
|
/**
|
|
340
369
|
* Whether a turn the CLI opened by itself may be the compaction re-opening a
|
|
341
370
|
* delivered turn, rather than something else the CLI decided to say.
|
|
@@ -484,6 +513,9 @@ export function createClaudeSessionRunner({
|
|
|
484
513
|
// the socket link: a hold withdraws the relay's readiness, and its end
|
|
485
514
|
// re-arms it at once so a held message steers into the resumed turn.
|
|
486
515
|
onDeliveryReadinessChange = () => {},
|
|
516
|
+
// Whether CLI processes carry the relay's `remote_relay` tool
|
|
517
|
+
// (shared/remote-relay-tool-core.mjs). Default: ask the relay.
|
|
518
|
+
remoteRelayToolGate = null,
|
|
487
519
|
dbg = () => {},
|
|
488
520
|
} = {}) {
|
|
489
521
|
const publisher = createClaudeTurnPublisher({
|
|
@@ -491,6 +523,13 @@ export function createClaudeSessionRunner({
|
|
|
491
523
|
dbg,
|
|
492
524
|
takeWorkflowRuns: () => drainSettledWorkflowRuns(),
|
|
493
525
|
});
|
|
526
|
+
// `remote_relay` is registered only while this relay has paired remotes.
|
|
527
|
+
// Asked once when the worker starts, and again whenever a CLI process winds
|
|
528
|
+
// down, so a remote paired later reaches the next process. A cold spawn
|
|
529
|
+
// waits for the pending answer (handlePendingPayload): the tool list is
|
|
530
|
+
// fixed per process, and the first delivery can beat the startup GET.
|
|
531
|
+
const remoteRelayGate = remoteRelayToolGate || createRemoteRelayToolGate({ api });
|
|
532
|
+
void remoteRelayGate.refresh();
|
|
494
533
|
const resolveWorkflowSessionDir = resolveWorkflowSessionDirImpl || createDefaultWorkflowSessionDirResolver();
|
|
495
534
|
let claudeNativeSessionId = '';
|
|
496
535
|
let proc = null;
|
|
@@ -1060,6 +1099,18 @@ export function createClaudeSessionRunner({
|
|
|
1060
1099
|
if (openingText !== null) {
|
|
1061
1100
|
const matched = proc.pendingDelivered.findIndex((entry) => entry.expectedText === openingText);
|
|
1062
1101
|
if (matched >= 0) return attachDeliveredContext(matched);
|
|
1102
|
+
// The CLI's record of a local command is not a turn, and no traffic
|
|
1103
|
+
// follows it. Read as a self-opened boundary, the "/model" breadcrumb a
|
|
1104
|
+
// setModel leaves stayed armed through the push that followed: the
|
|
1105
|
+
// delivered message's answer opened a continuation row, and its own row
|
|
1106
|
+
// sat unattached, holding every later message out until the watchdog
|
|
1107
|
+
// failed it or the session was killed (seen live 2026-09-27; the
|
|
1108
|
+
// re-init handling alone cannot cover it). Checked after the match, so
|
|
1109
|
+
// a user who sends such a text still gets their turn.
|
|
1110
|
+
if (isLocalCommandReplay(sdkMessage)) {
|
|
1111
|
+
dbg('local command replay ignored as a turn boundary');
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1063
1114
|
// A compaction just replayed the conversation: the CLI re-opens the turn
|
|
1064
1115
|
// with its own summary message ("This session is being continued…"),
|
|
1065
1116
|
// whose text matches no delivered entry, and then answers the delivered
|
|
@@ -2672,6 +2723,8 @@ export function createClaudeSessionRunner({
|
|
|
2672
2723
|
|
|
2673
2724
|
async function cleanupProcess(processRef, streamError) {
|
|
2674
2725
|
const wasCurrent = proc === processRef;
|
|
2726
|
+
// The next spawn reads the registration decision; refresh it now.
|
|
2727
|
+
if (wasCurrent) void remoteRelayGate.refresh();
|
|
2675
2728
|
stopLifecycleTimer(processRef);
|
|
2676
2729
|
if (processRef.taskPublishTimer) {
|
|
2677
2730
|
clearTimeout(processRef.taskPublishTimer);
|
|
@@ -2903,6 +2956,7 @@ export function createClaudeSessionRunner({
|
|
|
2903
2956
|
|| sdkSessionId
|
|
2904
2957
|
|| '',
|
|
2905
2958
|
),
|
|
2959
|
+
remoteRelayTool: remoteRelayGate.isEnabled(),
|
|
2906
2960
|
dbg,
|
|
2907
2961
|
});
|
|
2908
2962
|
processRef.lifecycleTimer = setInterval(() => evaluateLifecycle(), lifecyclePollMs);
|
|
@@ -2940,18 +2994,10 @@ export function createClaudeSessionRunner({
|
|
|
2940
2994
|
// A mode change that would alter the spawn-time system prompt append gets
|
|
2941
2995
|
// a fresh process — but only when nothing lives in the old one.
|
|
2942
2996
|
if (modeAppendClass(relayMode) !== proc.appendClass && !hasLiveWork() && !proc.liveTasks.size) {
|
|
2943
|
-
const previous = proc;
|
|
2944
2997
|
gracefulShutdown('mode-change');
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
]);
|
|
2949
|
-
if (proc === previous) {
|
|
2950
|
-
// The drain timed out: force the old CLI down so it cannot keep
|
|
2951
|
-
// streaming next to the replacement the caller is about to spawn.
|
|
2952
|
-
try { previous.turn.close?.(); } catch {}
|
|
2953
|
-
proc = null;
|
|
2954
|
-
}
|
|
2998
|
+
// A drain that times out forces the old CLI down so it cannot keep
|
|
2999
|
+
// streaming next to the replacement the caller is about to spawn.
|
|
3000
|
+
await drainClosingProcess(10_000);
|
|
2955
3001
|
return null;
|
|
2956
3002
|
}
|
|
2957
3003
|
|
|
@@ -3173,6 +3219,25 @@ export function createClaudeSessionRunner({
|
|
|
3173
3219
|
return { turnActive, canSteer, holdReason, messageId, supported: true };
|
|
3174
3220
|
}
|
|
3175
3221
|
|
|
3222
|
+
/**
|
|
3223
|
+
* A closing process drains on its own; wait (bounded) so two CLIs never
|
|
3224
|
+
* share the native session transcript, then make sure it is gone.
|
|
3225
|
+
*/
|
|
3226
|
+
async function drainClosingProcess(timeoutMs = 15_000) {
|
|
3227
|
+
const previous = proc;
|
|
3228
|
+
if (!previous) return;
|
|
3229
|
+
let timer = null;
|
|
3230
|
+
await Promise.race([
|
|
3231
|
+
previous.consumer,
|
|
3232
|
+
new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); }),
|
|
3233
|
+
]);
|
|
3234
|
+
clearTimeout(timer);
|
|
3235
|
+
if (proc === previous) {
|
|
3236
|
+
try { previous.turn.close?.(); } catch {}
|
|
3237
|
+
proc = null;
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3176
3241
|
async function handlePendingPayload(pending) {
|
|
3177
3242
|
const message = pending?.message || null;
|
|
3178
3243
|
if (!message) return false;
|
|
@@ -3204,20 +3269,22 @@ export function createClaudeSessionRunner({
|
|
|
3204
3269
|
await adaptProcess(message);
|
|
3205
3270
|
}
|
|
3206
3271
|
if (!proc || proc.closing || proc.aborted) {
|
|
3207
|
-
//
|
|
3208
|
-
//
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3272
|
+
// Awaited only when there is one: a cold spawn with the decision
|
|
3273
|
+
// already in pushes synchronously.
|
|
3274
|
+
if (proc) await drainClosingProcess();
|
|
3275
|
+
// The tool set is fixed per CLI process: let the remote_relay
|
|
3276
|
+
// decision settle first. The worker's first delivery can arrive
|
|
3277
|
+
// before its startup GET has answered (seen live: 180 ms after the
|
|
3278
|
+
// spawn). ready() never rejects and is bounded by the gate's timeout.
|
|
3279
|
+
if (!remoteRelayGate.isSettled?.()) await remoteRelayGate.ready();
|
|
3280
|
+
// Another delivery spawned while this one waited: join that process
|
|
3281
|
+
// like any delivery (between turns: adapt; mid-turn: steer below).
|
|
3282
|
+
if (proc && !proc.closing && !proc.aborted && !proc.activeCtx) await adaptProcess(message);
|
|
3283
|
+
// What it joined may be gone by now: that adapt can recycle the
|
|
3284
|
+
// process (a mode change), and a process can wind down under any of
|
|
3285
|
+
// these waits. Only a live one takes the push below.
|
|
3286
|
+
if (proc && (proc.closing || proc.aborted)) await drainClosingProcess();
|
|
3287
|
+
if (!proc) spawnProcess(message);
|
|
3221
3288
|
// A cold start is the one moment a turn can sit silent for many
|
|
3222
3289
|
// seconds (transcript load + first request); say so instead of
|
|
3223
3290
|
// showing bare dots. Posted once here — not in spawnProcess — so a
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { capThought } from '../../shared/thought-cap.mjs';
|
|
2
2
|
import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
|
|
3
|
+
import { isRemoteRelayToolName, remoteRelayActivitySummary } from '../../shared/remote-relay-tool-core.mjs';
|
|
3
4
|
|
|
4
5
|
const MAX_TOOL_DETAIL_LENGTH = 140;
|
|
5
6
|
const REDACTED_THINKING_PLACEHOLDER = '[Reasoning redacted by the model provider]';
|
|
@@ -89,6 +90,12 @@ export function isSubagentToolName(name) {
|
|
|
89
90
|
|
|
90
91
|
export function summarizeToolInput(toolName, input) {
|
|
91
92
|
if (!input || typeof input !== 'object') return '';
|
|
93
|
+
// `mcp__relay__remote_relay` reads as what it does on which relay
|
|
94
|
+
// (`send → linux-test session 01234567: “…”`), not as its first argument.
|
|
95
|
+
if (isRemoteRelayToolName(toolName)) {
|
|
96
|
+
const summary = remoteRelayActivitySummary(input);
|
|
97
|
+
if (summary) return summary;
|
|
98
|
+
}
|
|
92
99
|
const candidates = [
|
|
93
100
|
input.command,
|
|
94
101
|
input.file_path,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The relay's `remote_relay` tool for Copilot SDK sessions: a plain SDK
|
|
2
|
+
// `Tool` (`{ name, description, parameters, skipPermission, handler }`) built
|
|
3
|
+
// from the shared contract and forwarded through the shared tool core. The
|
|
4
|
+
// module never imports the SDK; the session process hands the definition to
|
|
5
|
+
// `SessionConfig.tools`.
|
|
6
|
+
//
|
|
7
|
+
// `skipPermission`: the relay does the approving. A write action in ask or
|
|
8
|
+
// plan mode raises its own question card server-side, so a runtime permission
|
|
9
|
+
// prompt on top would ask the user twice for one action.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
REMOTE_RELAY_CALL_FAILED,
|
|
13
|
+
REMOTE_RELAY_TOOL_DESCRIPTION,
|
|
14
|
+
REMOTE_RELAY_TOOL_NAME,
|
|
15
|
+
cloneRemoteRelayToolInputSchema,
|
|
16
|
+
executeRemoteRelayTool,
|
|
17
|
+
formatRemoteRelayToolResult,
|
|
18
|
+
} from '../../shared/remote-relay-tool-core.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `runCall(fn)` wraps every execution — the session process uses it to hold
|
|
22
|
+
* its stall watchdog and idle shutdown while the relay waits on a remote turn.
|
|
23
|
+
* The handler never throws: a failure answers in-band as JSON the model can
|
|
24
|
+
* read, which is what the runtime passes back to it anyway.
|
|
25
|
+
*
|
|
26
|
+
* `getSignal(invocation)` is the call's cancellation. By default the SDK's
|
|
27
|
+
* own `invocation.signal`, which aborts when the runtime completes the
|
|
28
|
+
* request or the session disconnects; the session process adds the turn's
|
|
29
|
+
* Stop to it.
|
|
30
|
+
*/
|
|
31
|
+
export function buildCopilotRemoteRelayTool({
|
|
32
|
+
api,
|
|
33
|
+
getConversationId = () => '',
|
|
34
|
+
runCall = (fn) => fn(),
|
|
35
|
+
getSignal = (invocation) => invocation?.signal || null,
|
|
36
|
+
dbg = () => {},
|
|
37
|
+
} = {}) {
|
|
38
|
+
return {
|
|
39
|
+
name: REMOTE_RELAY_TOOL_NAME,
|
|
40
|
+
description: REMOTE_RELAY_TOOL_DESCRIPTION,
|
|
41
|
+
parameters: cloneRemoteRelayToolInputSchema(),
|
|
42
|
+
skipPermission: true,
|
|
43
|
+
handler: async (args, invocation) => {
|
|
44
|
+
let result;
|
|
45
|
+
try {
|
|
46
|
+
result = await runCall(() => executeRemoteRelayTool(args, {
|
|
47
|
+
api,
|
|
48
|
+
conversationId: String(getConversationId() || ''),
|
|
49
|
+
signal: getSignal(invocation) || null,
|
|
50
|
+
}));
|
|
51
|
+
} catch (error) {
|
|
52
|
+
const message = error?.message || String(error);
|
|
53
|
+
dbg('remote_relay tool failed', message);
|
|
54
|
+
result = { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: message };
|
|
55
|
+
}
|
|
56
|
+
return formatRemoteRelayToolResult(result);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
|
|
23
23
|
import { capThought } from '../../shared/thought-cap.mjs';
|
|
24
24
|
import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
|
|
25
|
+
import {
|
|
26
|
+
REMOTE_RELAY_TOOL_NAME,
|
|
27
|
+
isRemoteRelayToolName,
|
|
28
|
+
remoteRelayActivitySummary,
|
|
29
|
+
} from '../../shared/remote-relay-tool-core.mjs';
|
|
25
30
|
|
|
26
31
|
const MAX_TOOL_DETAIL_LENGTH = 140;
|
|
27
32
|
|
|
@@ -78,8 +83,13 @@ export function summarizeToolInput(input) {
|
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
export function formatToolActivityText(toolName, input) {
|
|
81
|
-
const
|
|
82
|
-
|
|
86
|
+
const rawName = String(toolName || '').trim() || 'tool';
|
|
87
|
+
// `remote_relay` (bare, or namespaced by an MCP server) reads as what it
|
|
88
|
+
// does on which relay (`send → linux-test session 01234567: “…”`); its
|
|
89
|
+
// arguments may arrive as a JSON string.
|
|
90
|
+
const isRemoteRelay = isRemoteRelayToolName(rawName);
|
|
91
|
+
const name = isRemoteRelay ? REMOTE_RELAY_TOOL_NAME : rawName;
|
|
92
|
+
const summary = (isRemoteRelay && remoteRelayActivitySummary(input)) || summarizeToolInput(input);
|
|
83
93
|
return truncate(summary ? `Tool (${name}): ${summary}` : `Tool (${name})`);
|
|
84
94
|
}
|
|
85
95
|
|
|
@@ -84,6 +84,8 @@ import {
|
|
|
84
84
|
isToolOverrideRejection,
|
|
85
85
|
parseAskUserToolArguments,
|
|
86
86
|
} from './copilot-ask-user-tool.mjs';
|
|
87
|
+
import { buildCopilotRemoteRelayTool } from './copilot-remote-relay-tool.mjs';
|
|
88
|
+
import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
|
|
87
89
|
import {
|
|
88
90
|
EXIT_PLAN_BOARD_POSTED_FEEDBACK,
|
|
89
91
|
EXIT_PLAN_NO_BOARD_FEEDBACK,
|
|
@@ -351,6 +353,9 @@ export function createCopilotSdkSessionRunner({
|
|
|
351
353
|
// runtime's built-in one. A runtime that refuses the override falls back
|
|
352
354
|
// to the built-in for the rest of this worker's life.
|
|
353
355
|
relayAskUserTool = true,
|
|
356
|
+
// Whether sessions carry the relay's `remote_relay` tool
|
|
357
|
+
// (shared/remote-relay-tool-core.mjs). Default: ask the relay.
|
|
358
|
+
remoteRelayToolGate = null,
|
|
354
359
|
taskRefreshMs = DEFAULT_TASK_REFRESH_MS,
|
|
355
360
|
taskPublishThrottleMs = DEFAULT_TASK_PUBLISH_THROTTLE_MS,
|
|
356
361
|
// Called with `true`/`false` whenever the runner flips between accepting
|
|
@@ -518,6 +523,17 @@ export function createCopilotSdkSessionRunner({
|
|
|
518
523
|
// the human's eventual answer to a runtime whose row is already settled.
|
|
519
524
|
// Same guard the Cursor worker's `hasPendingClientWork` provides.
|
|
520
525
|
let pendingHumanRequests = 0;
|
|
526
|
+
// Running `remote_relay` calls. The runtime is just as silent while one
|
|
527
|
+
// runs (the relay may wait up to 600 s for a remote turn, plus an approval
|
|
528
|
+
// card), so they hold the stall watchdog and the idle shutdown too — but
|
|
529
|
+
// not steering: nobody is answering a card here.
|
|
530
|
+
let pendingRelayToolCalls = 0;
|
|
531
|
+
// `remote_relay` is registered only while this relay has paired remotes.
|
|
532
|
+
// Asked once when the worker starts; a session build waits for that first
|
|
533
|
+
// answer (it is async anyway), and every runtime stop asks again so the
|
|
534
|
+
// next session picks up a remote paired in the meantime.
|
|
535
|
+
const remoteRelayGate = remoteRelayToolGate || createRemoteRelayToolGate({ api });
|
|
536
|
+
void remoteRelayGate.refresh();
|
|
521
537
|
// The `SessionConfig.provider` block this session was BUILT with, or null for
|
|
522
538
|
// a hosted (`github`) session. Set by `buildSessionConfig` rather than by a
|
|
523
539
|
// second throwaway resolve here, so "is this BYOK?" and "what did the runtime
|
|
@@ -1787,6 +1803,32 @@ export function createCopilotSdkSessionRunner({
|
|
|
1787
1803
|
};
|
|
1788
1804
|
}
|
|
1789
1805
|
|
|
1806
|
+
/**
|
|
1807
|
+
* The relay's `remote_relay` tool (copilot-remote-relay-tool.mjs). Every call
|
|
1808
|
+
* is counted while it runs: the stall watchdog re-arms instead of failing the
|
|
1809
|
+
* row, and the lifecycle keeps the runtime up. The clock restarts from the
|
|
1810
|
+
* answer, like after a card. A call is cancelled by the runtime (its
|
|
1811
|
+
* invocation signal) or by a Stop of the turn it started under.
|
|
1812
|
+
*/
|
|
1813
|
+
function buildRemoteRelayTool() {
|
|
1814
|
+
return buildCopilotRemoteRelayTool({
|
|
1815
|
+
api,
|
|
1816
|
+
getConversationId: () => activeTurn?.message?.conversationId || sdkSessionId || '',
|
|
1817
|
+
getSignal: (invocation) => combineSignals(activeTurn?.remoteRelayStop?.signal, invocation?.signal),
|
|
1818
|
+
runCall: async (fn) => {
|
|
1819
|
+
pendingRelayToolCalls += 1;
|
|
1820
|
+
try {
|
|
1821
|
+
return await fn();
|
|
1822
|
+
} finally {
|
|
1823
|
+
pendingRelayToolCalls -= 1;
|
|
1824
|
+
touch();
|
|
1825
|
+
activeTurn?.armStall?.();
|
|
1826
|
+
}
|
|
1827
|
+
},
|
|
1828
|
+
dbg,
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1790
1832
|
/** One signal that aborts when any of the given ones does (nulls ignored). */
|
|
1791
1833
|
function combineSignals(...signals) {
|
|
1792
1834
|
const live = signals.filter((signal) => signal && typeof signal.aborted === 'boolean');
|
|
@@ -1804,20 +1846,35 @@ export function createCopilotSdkSessionRunner({
|
|
|
1804
1846
|
* Open a session, retrying once without the relay's `ask_user` when the
|
|
1805
1847
|
* runtime refuses the override (an older runtime). The refusal is
|
|
1806
1848
|
* remembered until the runtime is stopped — the next runtime (possibly
|
|
1807
|
-
* auto-updated under the relay) gets another try.
|
|
1849
|
+
* auto-updated under the relay) gets another try. Only the override goes:
|
|
1850
|
+
* the relay's other tools (`remote_relay`) override nothing and stay.
|
|
1808
1851
|
*/
|
|
1809
1852
|
async function openSession(open, config) {
|
|
1810
1853
|
try {
|
|
1811
1854
|
return await open(config);
|
|
1812
1855
|
} catch (error) {
|
|
1813
|
-
|
|
1856
|
+
const tools = Array.isArray(config?.tools) ? config.tools : [];
|
|
1857
|
+
const isAskUserOverride = (tool) => tool?.name === RELAY_ASK_USER_TOOL_NAME && tool?.overridesBuiltInTool === true;
|
|
1858
|
+
if (!tools.some(isAskUserOverride) || !isToolOverrideRejection(error) || isSessionNotFoundError(error)) throw error;
|
|
1814
1859
|
dbg('the runtime refused the relay ask_user override; using its built-in ask_user', error?.message || String(error));
|
|
1815
1860
|
askUserOverrideRejected = true;
|
|
1816
1861
|
const { tools: _tools, ...withoutTools } = config;
|
|
1817
|
-
|
|
1862
|
+
const kept = tools.filter((tool) => !isAskUserOverride(tool));
|
|
1863
|
+
return open(kept.length ? { ...withoutTools, tools: kept } : withoutTools);
|
|
1818
1864
|
}
|
|
1819
1865
|
}
|
|
1820
1866
|
|
|
1867
|
+
/** The relay's own session tools; each is added only when it applies. */
|
|
1868
|
+
function relaySessionTools() {
|
|
1869
|
+
return [
|
|
1870
|
+
// The relay's `ask_user` (see copilot-ask-user-tool.mjs): the built-in's
|
|
1871
|
+
// fields plus `multi_select`, so a model can declare a multi-select
|
|
1872
|
+
// question instead of the relay guessing from its wording.
|
|
1873
|
+
...(relayAskUserTool && !askUserOverrideRejected ? [buildRelayAskUserTool()] : []),
|
|
1874
|
+
...(remoteRelayGate.isEnabled() ? [buildRemoteRelayTool()] : []),
|
|
1875
|
+
];
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1821
1878
|
function buildSessionConfig(model, reasoningEffort = null) {
|
|
1822
1879
|
// Built once per session, not per request. Reads the mode off the LIVE turn
|
|
1823
1880
|
// rather than closing over the one the session was built with, because one
|
|
@@ -1857,6 +1914,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
1857
1914
|
const provider = resolveProviderConfigImpl({ env, model: model || defaultModel, dbg });
|
|
1858
1915
|
byokProvider = provider;
|
|
1859
1916
|
const effort = normalizeRelayEffort(reasoningEffort);
|
|
1917
|
+
const tools = relaySessionTools();
|
|
1860
1918
|
return {
|
|
1861
1919
|
// The relay session id IS the SDK session id, so the runtime's own state
|
|
1862
1920
|
// under ~/.copilot/session-state/<id> is addressable by conversation and
|
|
@@ -1879,10 +1937,8 @@ export function createCopilotSdkSessionRunner({
|
|
|
1879
1937
|
// The runtime's own defaults, pinned. See DEFAULT_INFINITE_SESSION_CONFIG.
|
|
1880
1938
|
...(infiniteSessionConfig ? { infiniteSessions: { ...infiniteSessionConfig } } : {}),
|
|
1881
1939
|
onEvent: routeEvent,
|
|
1882
|
-
// The relay's `
|
|
1883
|
-
|
|
1884
|
-
// question instead of the relay guessing from its wording.
|
|
1885
|
-
...(relayAskUserTool && !askUserOverrideRejected ? { tools: [buildRelayAskUserTool()] } : {}),
|
|
1940
|
+
// The relay's own tools (`relaySessionTools`); omitted when there are none.
|
|
1941
|
+
...(tools.length ? { tools } : {}),
|
|
1886
1942
|
// Permission policy follows the conversation's relay mode: agent and
|
|
1887
1943
|
// autopilot auto-approve, plan denies non-read tools with feedback, and
|
|
1888
1944
|
// ask asks the human through a relay question card. The handler reads the
|
|
@@ -2119,6 +2175,9 @@ export function createCopilotSdkSessionRunner({
|
|
|
2119
2175
|
async function ensureSession(model, effort) {
|
|
2120
2176
|
await ensureClient();
|
|
2121
2177
|
if (!session) {
|
|
2178
|
+
// The tool list is part of the session: wait for the worker's first
|
|
2179
|
+
// registration answer (bounded, and it fails closed).
|
|
2180
|
+
await remoteRelayGate.ready();
|
|
2122
2181
|
const config = buildSessionConfig(model, effort);
|
|
2123
2182
|
// Resume first, always. On a brand-new conversation this costs one
|
|
2124
2183
|
// failed RPC; on every other path (worker restart, idle shutdown,
|
|
@@ -2185,6 +2244,8 @@ export function createCopilotSdkSessionRunner({
|
|
|
2185
2244
|
|
|
2186
2245
|
async function stopRuntime(reason) {
|
|
2187
2246
|
stopLifecycleTimer();
|
|
2247
|
+
// The next session is built from a fresh registration answer.
|
|
2248
|
+
void remoteRelayGate.refresh();
|
|
2188
2249
|
try { detachRuntimeExit(); } catch { /* the observer is best-effort */ }
|
|
2189
2250
|
detachRuntimeExit = () => {};
|
|
2190
2251
|
const closingSession = session;
|
|
@@ -2320,7 +2381,9 @@ export function createCopilotSdkSessionRunner({
|
|
|
2320
2381
|
// A pending question card means a human is mid-answer; tearing the runtime
|
|
2321
2382
|
// down under them would discard the session the answer belongs to. A turn
|
|
2322
2383
|
// that is still publishing counts too: it settled, but its rows are live.
|
|
2323
|
-
if (
|
|
2384
|
+
if (
|
|
2385
|
+
disposed || activeTurn || publishingTurns.size > 0 || pendingHumanRequests > 0 || pendingRelayToolCalls > 0 || !client
|
|
2386
|
+
) return;
|
|
2324
2387
|
if (!(idleShutdownMs > 0)) return;
|
|
2325
2388
|
// Evaluated before the idle clock so the caps still expire on a runtime
|
|
2326
2389
|
// that has been quiet far longer than the idle window.
|
|
@@ -2441,6 +2504,10 @@ export function createCopilotSdkSessionRunner({
|
|
|
2441
2504
|
// Cancels any relay question card this turn is blocked on, so an aborted
|
|
2442
2505
|
// turn does not leave a human answering into the void.
|
|
2443
2506
|
abortController: new AbortController(),
|
|
2507
|
+
// Cancels the `remote_relay` calls that started under this turn — on the
|
|
2508
|
+
// user's Stop only. Not `abortController`: that one also fires when the
|
|
2509
|
+
// turn settles normally, and a background agent's call must outlive that.
|
|
2510
|
+
remoteRelayStop: new AbortController(),
|
|
2444
2511
|
// Cards (questions, ask-mode approvals, elicitations) open on this
|
|
2445
2512
|
// turn's row; `humansIdle` resolves when the last one is done. A turn
|
|
2446
2513
|
// that settles with one still open publishes only after it (driveTurn).
|
|
@@ -2546,7 +2613,9 @@ export function createCopilotSdkSessionRunner({
|
|
|
2546
2613
|
// A human staring at a question card is not a stalled runtime. Re-arm
|
|
2547
2614
|
// rather than fail: the card has its own (much longer) timeout, and
|
|
2548
2615
|
// failing the row here would settle it while the answer is still coming.
|
|
2549
|
-
|
|
2616
|
+
// A remote_relay call waiting on another relay is the same kind of
|
|
2617
|
+
// silence; the relay bounds that wait itself.
|
|
2618
|
+
if (pendingHumanRequests > 0 || pendingRelayToolCalls > 0) {
|
|
2550
2619
|
turn.armStall();
|
|
2551
2620
|
return;
|
|
2552
2621
|
}
|
|
@@ -3010,6 +3079,10 @@ export function createCopilotSdkSessionRunner({
|
|
|
3010
3079
|
}
|
|
3011
3080
|
turn.aborted = true;
|
|
3012
3081
|
turn.abortedRowId = rowId;
|
|
3082
|
+
// A pending `remote_relay` call ends now rather than when the runtime gets
|
|
3083
|
+
// round to it: the relay drops the request, withdraws its approval card
|
|
3084
|
+
// and stops waiting on the remote, and the watchdog hold is released.
|
|
3085
|
+
turn.remoteRelayStop?.abort();
|
|
3013
3086
|
// While `ensureSession` is still connecting there is no session to abort
|
|
3014
3087
|
// and this would be a silent no-op; `runTurn` re-checks `turn.aborted`
|
|
3015
3088
|
// once the session exists and settles there instead.
|
|
@@ -3937,6 +4010,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
3937
4010
|
continuationDueSince,
|
|
3938
4011
|
compacting: isCompacting(),
|
|
3939
4012
|
pendingHumanRequests,
|
|
4013
|
+
pendingRelayToolCalls,
|
|
3940
4014
|
sessionRpc: { ...sessionRpc },
|
|
3941
4015
|
activeEntries: activeTurn
|
|
3942
4016
|
? activeTurn.entries.map((entry) => ({
|
|
@@ -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;
|