@oar-sh/oar 0.9.3 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +98 -0
- package/README.md +76 -14
- package/package.json +5 -2
- package/server/README.md +43 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +100 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +385 -43
- package/server/claude-worker/claude-session-worker.mjs +5 -0
- 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/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-session-worker.mjs +5 -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-session-worker.mjs +5 -0
- 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 +100 -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/steered-turn-merge.mjs +10 -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 +264 -11
- 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 +98 -19
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +84 -31
- 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 +346 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1743 -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 +148 -14
- package/shared/remote-relay-contract.mjs +556 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/steer-settle-failure.mjs +6 -0
- package/shared/steer-settle-markers.mjs +16 -0
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/worker-log-file.mjs +97 -0
|
@@ -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) => ({
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
21
21
|
import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
|
|
22
22
|
import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
|
|
23
|
+
import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
|
|
23
24
|
import {
|
|
24
25
|
createRunnerLinkBridge,
|
|
25
26
|
failSettlingRowsOnShutdown,
|
|
@@ -33,6 +34,10 @@ import {
|
|
|
33
34
|
} from '../../shared/worker-bootstrap.mjs';
|
|
34
35
|
import { createCopilotSdkSessionRunner } from './copilot-sdk-session-process.mjs';
|
|
35
36
|
|
|
37
|
+
// Windows console launch: the launcher names the worker log, this copies
|
|
38
|
+
// stdout/stderr into it. A no-op wherever the launcher redirects instead.
|
|
39
|
+
installWorkerLogFile();
|
|
40
|
+
|
|
36
41
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
37
42
|
const HEARTBEAT_MS = 10_000;
|
|
38
43
|
|
|
@@ -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
|
+
}
|
|
@@ -13,10 +13,15 @@ import {
|
|
|
13
13
|
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
14
14
|
import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
|
|
15
15
|
import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
|
|
16
|
+
import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
|
|
16
17
|
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
17
18
|
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
18
19
|
import { createCursorTurnRunner } from './cursor-turn-runner.mjs';
|
|
19
20
|
|
|
21
|
+
// Windows console launch: the launcher names the worker log, this copies
|
|
22
|
+
// stdout/stderr into it. A no-op wherever the launcher redirects instead.
|
|
23
|
+
installWorkerLogFile();
|
|
24
|
+
|
|
20
25
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
26
|
const HEARTBEAT_MS = 10_000;
|
|
22
27
|
|
|
@@ -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
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { rebuildRecentWorkspaceRootsTable } from './migrations/0002-recent-workspace-roots-path-key.mjs';
|
|
11
11
|
import { ensurePushSubscriptionsTable } from './migrations/0003-push-subscriptions.mjs';
|
|
12
12
|
import { migrateSteerSettleMarkers } from './migrations/0004-steer-settle-markers.mjs';
|
|
13
|
+
import { migrateRemoteRelays } from './migrations/0005-remote-relays.mjs';
|
|
13
14
|
import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
|
|
14
15
|
|
|
15
16
|
// Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
|
|
@@ -702,6 +703,13 @@ try {
|
|
|
702
703
|
} catch (error) {
|
|
703
704
|
console.warn(`[steering] migration 0004 failed; retrying on next boot: ${error?.message || error}`);
|
|
704
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
|
+
}
|
|
705
713
|
|
|
706
714
|
const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
|
|
707
715
|
if (runtimeSessionColumns.length) {
|