@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
|
@@ -18,6 +18,7 @@ import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-win
|
|
|
18
18
|
import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
|
|
19
19
|
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
20
20
|
import { createClaudeSessionRunner } from './claude-session-process.mjs';
|
|
21
|
+
import { createRunnerLinkBridge, failSettlingRowsOnShutdown } from './claude-worker-link-wiring.mjs';
|
|
21
22
|
|
|
22
23
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
24
|
const HEARTBEAT_MS = 10_000;
|
|
@@ -82,6 +83,9 @@ async function main() {
|
|
|
82
83
|
onAbortSubagent: (subagentRunId) => turnRunner.stopBackgroundTaskByToolUseId(subagentRunId),
|
|
83
84
|
dbg,
|
|
84
85
|
});
|
|
86
|
+
const linkBridge = createRunnerLinkBridge();
|
|
87
|
+
// Late-bound: the heartbeat controller is built after the runner.
|
|
88
|
+
let heartbeat = null;
|
|
85
89
|
const turnRunner = createClaudeSessionRunner({
|
|
86
90
|
api,
|
|
87
91
|
sdkSessionId,
|
|
@@ -102,11 +106,19 @@ async function main() {
|
|
|
102
106
|
getBackgroundTaskTimeoutMs: () => backgroundTaskTimeoutMs,
|
|
103
107
|
getAutoCompactWindow: () => autoCompactWindow,
|
|
104
108
|
getThinking: () => thinking,
|
|
109
|
+
// A readiness flip is a hold starting or ending (question card,
|
|
110
|
+
// compaction, a turn-opening delivery): the steering snapshot changed
|
|
111
|
+
// with it, so it goes out now rather than on the next 10 s heartbeat.
|
|
112
|
+
onDeliveryReadinessChange: (ready) => {
|
|
113
|
+
linkBridge.onDeliveryReadinessChange(ready);
|
|
114
|
+
heartbeat?.requestPulse();
|
|
115
|
+
},
|
|
116
|
+
canHandBackHeldDelivery: () => linkBridge.canHandBackHeldDelivery(),
|
|
105
117
|
dbg,
|
|
106
118
|
});
|
|
107
119
|
|
|
108
120
|
let heartbeatTimer = null;
|
|
109
|
-
|
|
121
|
+
heartbeat = createHeartbeatController({
|
|
110
122
|
api,
|
|
111
123
|
pollMs: HEARTBEAT_MS,
|
|
112
124
|
getSessionReady: () => true,
|
|
@@ -118,6 +130,13 @@ async function main() {
|
|
|
118
130
|
// the crash guard below takes the entries whole so its requeues stay
|
|
119
131
|
// fenced to this attempt.
|
|
120
132
|
getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds().map((entry) => entry.id),
|
|
133
|
+
// Composer steering snapshot. Holds reach the relay within a pulse window
|
|
134
|
+
// (onDeliveryReadinessChange above) and a relay that just came up gets it
|
|
135
|
+
// on its first hello; the periodic beat only backstops the rest.
|
|
136
|
+
getSteeringState: () => turnRunner.steeringState(),
|
|
137
|
+
// Consumed rows the runner could not fail itself; the relay fails them.
|
|
138
|
+
getSettleFailed: () => turnRunner.getSettleFailed(),
|
|
139
|
+
onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
|
|
121
140
|
});
|
|
122
141
|
|
|
123
142
|
const wsLink = createWorkerWebSocketLink({
|
|
@@ -125,6 +144,18 @@ async function main() {
|
|
|
125
144
|
token,
|
|
126
145
|
dbg,
|
|
127
146
|
getSessionReady: () => true,
|
|
147
|
+
// Mid-turn steering: while a turn is running and the runner can absorb
|
|
148
|
+
// another message into it, the link keeps signalling readiness, so a
|
|
149
|
+
// message sent while Claude is thinking is pushed into the live turn
|
|
150
|
+
// instead of waiting for the result (docs/plans/2026-09-19-claude-mid-
|
|
151
|
+
// turn-steering.md).
|
|
152
|
+
getSteeringReady: () => turnRunner.canAcceptSteering(),
|
|
153
|
+
// A hold (open question card, compaction, adoption) suppresses readiness
|
|
154
|
+
// even between deliveries — a delivery drawn into it is only handed back.
|
|
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(),
|
|
128
159
|
getSessionId: () => sdkSessionId,
|
|
129
160
|
getPid: () => process.pid,
|
|
130
161
|
onDeliver: async (pending, reason) => {
|
|
@@ -158,20 +189,30 @@ async function main() {
|
|
|
158
189
|
dbg('unknown worker control ignored', type || '(none)');
|
|
159
190
|
},
|
|
160
191
|
});
|
|
192
|
+
linkBridge.attach(wsLink);
|
|
161
193
|
|
|
162
|
-
|
|
194
|
+
let shuttingDown = false;
|
|
195
|
+
const shutdown = async (signal) => {
|
|
196
|
+
if (shuttingDown) return;
|
|
197
|
+
shuttingDown = true;
|
|
163
198
|
dbg(`shutting down (${signal})`);
|
|
164
199
|
try { wsLink.stop(); } catch {}
|
|
165
200
|
try { heartbeat.stopHeartbeat(); } catch {}
|
|
166
201
|
try { controlPoller.stop(); } catch {}
|
|
202
|
+
// Consumed steers still settling must fail, not be left for dead-worker
|
|
203
|
+
// recovery to requeue and run twice (same 2 s cap as the crash guard).
|
|
204
|
+
try {
|
|
205
|
+
const failed = await failSettlingRowsOnShutdown({ api, runner: turnRunner, timeoutMs: 2_000 });
|
|
206
|
+
if (failed) dbg(`failed ${failed} settling row(s) before exit`);
|
|
207
|
+
} catch {}
|
|
167
208
|
// Hard-close the persistent CLI process; a worker teardown cannot save
|
|
168
209
|
// its background tasks, and the orphan-notification replay on the next
|
|
169
210
|
// resume reports whatever they left behind.
|
|
170
211
|
try { turnRunner.shutdown({ graceful: false }); } catch {}
|
|
171
212
|
process.exit(0);
|
|
172
213
|
};
|
|
173
|
-
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
174
|
-
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
214
|
+
process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
|
|
215
|
+
process.on('SIGINT', () => { void shutdown('SIGINT'); });
|
|
175
216
|
installWorkerCrashGuard({
|
|
176
217
|
api,
|
|
177
218
|
workerName: 'claude-session-worker',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
3
3
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
4
|
+
import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
|
|
4
5
|
|
|
5
6
|
const PLAN_BOARD_ACTIONS = [
|
|
6
7
|
{ id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
|
|
@@ -25,6 +26,9 @@ export function isStaleAttemptError(error) {
|
|
|
25
26
|
return detail.includes('stale_attempt');
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
export { buildSteerSettleFailure, steerSettleFailureText };
|
|
30
|
+
export const STEER_SETTLE_FAILED_TEXT = steerSettleFailureText('folded');
|
|
31
|
+
|
|
28
32
|
export function buildClaudePlanReadyBoardPayload({ message, planText = '' } = {}) {
|
|
29
33
|
const summary = String(planText || '').trim();
|
|
30
34
|
if (!summary) return null;
|
|
@@ -55,6 +59,11 @@ export function buildClaudePlanReadyBoardPayload({ message, planText = '' } = {}
|
|
|
55
59
|
* (delivered or background-continuation) through one publisher.
|
|
56
60
|
*/
|
|
57
61
|
export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRuns = null } = {}) {
|
|
62
|
+
function drainWorkflowRuns({ terminal = false } = {}) {
|
|
63
|
+
if (terminal || typeof takeWorkflowRuns !== 'function') return null;
|
|
64
|
+
return takeWorkflowRuns();
|
|
65
|
+
}
|
|
66
|
+
|
|
58
67
|
async function postActivity(message, text, subagentRunId = null, metadata = null) {
|
|
59
68
|
if (!text) return;
|
|
60
69
|
await api('POST', '/api/activity', {
|
|
@@ -180,36 +189,86 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
180
189
|
}).catch(() => {});
|
|
181
190
|
}
|
|
182
191
|
|
|
183
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Returns the outcome: 'published' (the relay took it), 'stale' (another
|
|
194
|
+
* attempt owns the row), 'failed' (not taken and NOT requeued), or
|
|
195
|
+
* 'requeued'. A `kind` marks a row whose prompt the CLI already consumed —
|
|
196
|
+
* 'absorbed' (the reply continues through the next, steered message),
|
|
197
|
+
* 'folded' (answered by the turn it was steered into) or 'stopped' (steered
|
|
198
|
+
* into a turn the user stopped) — and such a row is never
|
|
199
|
+
* requeued, since re-delivery would run the prompt a second time; the
|
|
200
|
+
* caller owns retrying it. `requeueOnFailure: false` opts any other publish
|
|
201
|
+
* into the same contract.
|
|
202
|
+
*/
|
|
203
|
+
async function publishResponse(message, {
|
|
204
|
+
text,
|
|
205
|
+
model,
|
|
206
|
+
terminalError = null,
|
|
207
|
+
modelOrigin,
|
|
208
|
+
absorbed = false,
|
|
209
|
+
kind = null,
|
|
210
|
+
requeueOnFailure = true,
|
|
211
|
+
// Ids of steered queue rows pushed into the turn this response finishes
|
|
212
|
+
// and still unsettled: the relay marks them consumed in the same
|
|
213
|
+
// transaction, so no recovery path ever re-runs them (at-most-once even
|
|
214
|
+
// if this worker dies before settling them).
|
|
215
|
+
consumedSteerIds = null,
|
|
216
|
+
// A caller retrying one publish drains the settled workflow digests ONCE
|
|
217
|
+
// and passes them to every attempt, so a failed attempt cannot lose them.
|
|
218
|
+
workflowRuns: presetWorkflowRuns,
|
|
219
|
+
}) {
|
|
220
|
+
const responseKind = kind || (absorbed ? 'absorbed' : null);
|
|
184
221
|
// Final digests of workflows that settled since the last response ride the
|
|
185
222
|
// next successful response publish — the summarizing turn is the natural
|
|
186
223
|
// transcript anchor for the "Finished background task" card. Terminal
|
|
187
224
|
// failures skip the drain so the digests stay buffered for a later turn;
|
|
188
225
|
// if this POST fails (→ requeue) or the process dies before any response
|
|
189
226
|
// publishes, the drained digests are lost — acceptable v1.
|
|
190
|
-
const workflowRuns =
|
|
191
|
-
?
|
|
192
|
-
:
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
227
|
+
const workflowRuns = presetWorkflowRuns !== undefined
|
|
228
|
+
? presetWorkflowRuns
|
|
229
|
+
: drainWorkflowRuns({ terminal: Boolean(terminalError) });
|
|
230
|
+
// Entries are { id, attemptId } (the relay fences each mark on its
|
|
231
|
+
// attempt) or bare ids.
|
|
232
|
+
const consumed = (Array.isArray(consumedSteerIds) ? consumedSteerIds : [])
|
|
233
|
+
.map((entry) => (entry && typeof entry === 'object'
|
|
234
|
+
? { id: String(entry.id || '').trim(), attemptId: entry.attemptId || null }
|
|
235
|
+
: { id: String(entry || '').trim(), attemptId: null }))
|
|
236
|
+
.filter((entry) => entry.id);
|
|
237
|
+
try {
|
|
238
|
+
await api('POST', '/api/response', {
|
|
239
|
+
messageId: message.id,
|
|
240
|
+
conversationId: message.conversationId,
|
|
241
|
+
text: String(text || ''),
|
|
242
|
+
model: model || null,
|
|
243
|
+
modelOrigin: modelOrigin
|
|
244
|
+
|| (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
|
|
245
|
+
...(Array.isArray(workflowRuns) && workflowRuns.length ? { workflowRuns } : {}),
|
|
246
|
+
...(terminalError ? { terminalError } : {}),
|
|
247
|
+
// The relay stamps the assistant message's kind from these: 'absorbed'
|
|
248
|
+
// renders as merged into the next (steered) user message, 'folded' as
|
|
249
|
+
// a compact marker on its own message, 'stopped' as a steer the
|
|
250
|
+
// user's Stop cut off unanswered.
|
|
251
|
+
...(responseKind === 'absorbed' ? { absorbed: true } : {}),
|
|
252
|
+
...(responseKind && responseKind !== 'absorbed' ? { kind: responseKind } : {}),
|
|
253
|
+
...(consumed.length ? { consumedSteerIds: consumed } : {}),
|
|
254
|
+
...attemptFields(message),
|
|
255
|
+
});
|
|
256
|
+
return 'published';
|
|
257
|
+
} catch (error) {
|
|
204
258
|
// A stale_attempt 409 means the row already moved on to another attempt
|
|
205
259
|
// (requeued and re-delivered); the requeue would 409 the same way, and
|
|
206
260
|
// the newer attempt owes the row its answer — drop this one.
|
|
207
261
|
if (isStaleAttemptError(error)) {
|
|
208
262
|
dbg('response refused as stale_attempt; dropping', message.id);
|
|
209
|
-
return;
|
|
263
|
+
return 'stale';
|
|
264
|
+
}
|
|
265
|
+
if (responseKind || !requeueOnFailure) {
|
|
266
|
+
dbg('response publish failed; not requeuing a consumed prompt', message.id, error?.message || String(error));
|
|
267
|
+
return 'failed';
|
|
210
268
|
}
|
|
211
269
|
await api('POST', '/api/requeue', { messageId: message.id, ...attemptFields(message) }).catch(() => {});
|
|
212
|
-
|
|
270
|
+
return 'requeued';
|
|
271
|
+
}
|
|
213
272
|
}
|
|
214
273
|
|
|
215
274
|
/**
|
|
@@ -244,7 +303,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
244
303
|
* plan-mode fallback board, response row. Mirrors the pre-session-process
|
|
245
304
|
* turn runner's tail exactly.
|
|
246
305
|
*/
|
|
247
|
-
async function publishCompletedTurn({ message, state, responseModel, planBoardPosted }) {
|
|
306
|
+
async function publishCompletedTurn({ message, state, responseModel, planBoardPosted, consumedSteerIds = null }) {
|
|
248
307
|
const result = state.result;
|
|
249
308
|
const finalText = String(
|
|
250
309
|
state.resultTexts.join('\n\n') || result?.text || state.lastStreamedText || '',
|
|
@@ -266,10 +325,10 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
266
325
|
// fails the message with a misleading "Relay timeout".
|
|
267
326
|
const publishedText = finalText || EMPTY_TURN_COMPLETION_NOTE;
|
|
268
327
|
await publishFinalStream(message, publishedText);
|
|
269
|
-
await publishResponse(message, { text: publishedText, model: responseModel });
|
|
328
|
+
await publishResponse(message, { text: publishedText, model: responseModel, consumedSteerIds });
|
|
270
329
|
}
|
|
271
330
|
|
|
272
|
-
async function publishErrorResult({ message, state, responseModel }) {
|
|
331
|
+
async function publishErrorResult({ message, state, responseModel, consumedSteerIds = null }) {
|
|
273
332
|
const result = state.result;
|
|
274
333
|
const errorText = result.text
|
|
275
334
|
|| `Claude turn failed (${result.subtype || 'unknown error'}).`;
|
|
@@ -285,6 +344,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
285
344
|
failedAt: new Date().toISOString(),
|
|
286
345
|
queueMessageId: String(message.id || '') || null,
|
|
287
346
|
},
|
|
347
|
+
consumedSteerIds,
|
|
288
348
|
});
|
|
289
349
|
}
|
|
290
350
|
|
|
@@ -308,6 +368,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
308
368
|
|
|
309
369
|
return {
|
|
310
370
|
postActivity,
|
|
371
|
+
drainWorkflowRuns,
|
|
311
372
|
dispatchAction,
|
|
312
373
|
publishContextUsage,
|
|
313
374
|
publishPlanUsage,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The Claude worker's glue between its session runner and the relay socket
|
|
2
|
+
// link. The implementation moved to shared/worker-runtime/runner-link-wiring.mjs
|
|
3
|
+
// when the Copilot SDK worker adopted the same delivery gate; this module is
|
|
4
|
+
// the import path the Claude worker and its tests still use.
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
createRunnerLinkBridge,
|
|
8
|
+
failSettlingRowsOnShutdown,
|
|
9
|
+
} from '../../shared/worker-runtime/runner-link-wiring.mjs';
|
|
@@ -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,89 @@
|
|
|
1
|
+
// The relay's own `ask_user` tool for Copilot SDK sessions.
|
|
2
|
+
//
|
|
3
|
+
// The runtime's built-in `ask_user` offers the model only `question` and
|
|
4
|
+
// `choices` (captured from the request body, runtime 1.0.88), so a model has
|
|
5
|
+
// no way to say "the user may pick several of these" — and the relay could
|
|
6
|
+
// only guess from the wording. The SDK lets a host register a tool that
|
|
7
|
+
// overrides a built-in one of the same name (`overridesBuiltInTool`), so the
|
|
8
|
+
// worker registers this superset instead: the same name and fields every
|
|
9
|
+
// model already knows, plus `multi_select`. Verified with the fake-provider
|
|
10
|
+
// probe (2026-09-25): the model sees exactly one `ask_user` with this schema,
|
|
11
|
+
// the handler receives the call, and the built-in path is never used.
|
|
12
|
+
//
|
|
13
|
+
// Pure: schema, argument parsing and the result wording. The session process
|
|
14
|
+
// wires the handler to the relay question bridge.
|
|
15
|
+
|
|
16
|
+
import { normalizeUserInputChoices } from './copilot-question-bridge.mjs';
|
|
17
|
+
|
|
18
|
+
export const RELAY_ASK_USER_TOOL_NAME = 'ask_user';
|
|
19
|
+
|
|
20
|
+
export const RELAY_ASK_USER_TOOL_DESCRIPTION = [
|
|
21
|
+
'Ask the user a question and wait for their response.',
|
|
22
|
+
'Use this tool when you need to ask the user questions during execution. This allows you to:',
|
|
23
|
+
'1. Gather user preferences or requirements',
|
|
24
|
+
'2. Clarify ambiguous instructions',
|
|
25
|
+
'3. Get decisions on implementation choices as you work',
|
|
26
|
+
'4. Offer choices to the user about what direction to take',
|
|
27
|
+
'The question is shown to the user as a card with a button per choice.',
|
|
28
|
+
'Set multi_select to true when the user may pick several of the choices (the choices are not',
|
|
29
|
+
'mutually exclusive); the answer then lists every pick, separated by ", ". Leave it false or',
|
|
30
|
+
'omit it when exactly one choice applies.',
|
|
31
|
+
].join('\n');
|
|
32
|
+
|
|
33
|
+
export const RELAY_ASK_USER_TOOL_PARAMETERS = Object.freeze({
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
question: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: 'The question to ask the user. Ensure only one question is asked at a time - do not bundle multiple questions together.',
|
|
39
|
+
},
|
|
40
|
+
choices: {
|
|
41
|
+
type: 'array',
|
|
42
|
+
items: { type: 'string' },
|
|
43
|
+
description: 'Optional list of choices for a multiple choice question. Prefer providing choices when possible.',
|
|
44
|
+
},
|
|
45
|
+
multi_select: {
|
|
46
|
+
type: 'boolean',
|
|
47
|
+
description: 'true when the user may pick several of the choices; false or omitted when exactly one applies.',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
required: ['question'],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** The call's arguments, tolerating the camelCase spelling some models emit. */
|
|
54
|
+
export function parseAskUserToolArguments(args) {
|
|
55
|
+
const source = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
|
|
56
|
+
return {
|
|
57
|
+
question: String(source.question || '').trim(),
|
|
58
|
+
choices: normalizeUserInputChoices(source.choices),
|
|
59
|
+
multiSelect: source.multi_select === true || source.multiSelect === true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The tool result the model reads, worded like the built-in's ("User
|
|
65
|
+
* selected: b" for a choice, "User responded: …" for anything else) so a
|
|
66
|
+
* model's habits carry over. A timed-out card returns the bridge's
|
|
67
|
+
* continuation text as it is.
|
|
68
|
+
*/
|
|
69
|
+
export function formatAskUserToolResult({ answer, wasFreeform = true, timedOut = false } = {}) {
|
|
70
|
+
const text = String(answer ?? '').trim();
|
|
71
|
+
if (timedOut) return text;
|
|
72
|
+
if (!text) return 'User responded with an empty answer.';
|
|
73
|
+
// `wasFreeform` comes from the question bridge (`deriveWasFreeform`), the
|
|
74
|
+
// single owner of "was this one of the offered choices".
|
|
75
|
+
return wasFreeform === false ? `User selected: ${text}` : `User responded: ${text}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether a session create/resume failure is the runtime refusing the
|
|
80
|
+
* override (an older runtime without `overridesBuiltInTool`, or one that
|
|
81
|
+
* rejects replacing this built-in). Anything else is a real failure.
|
|
82
|
+
*/
|
|
83
|
+
export function isToolOverrideRejection(error) {
|
|
84
|
+
const message = String(error?.message || error || '');
|
|
85
|
+
if (/overridesBuiltInTool/i.test(message)) return true;
|
|
86
|
+
// The tool named together with an override/clash complaint — a message that
|
|
87
|
+
// merely mentions ask_user (a replayed call in history, say) is not one.
|
|
88
|
+
return /\bask_user\b/i.test(message) && /(built-?in|clash|conflict|overrid|duplicate)/i.test(message);
|
|
89
|
+
}
|
|
@@ -54,6 +54,51 @@ export const SHELL_SETTLED_NOTIFICATION_KINDS = Object.freeze([
|
|
|
54
54
|
/** Tools that address an already-running shell by id. */
|
|
55
55
|
export const SHELL_HANDLE_TOOLS = Object.freeze(['read_bash', 'write_bash', 'stop_bash']);
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* `system.notification` kinds that mean "a background agent this session
|
|
59
|
+
* started is done" — `agent_idle` for a multi-turn agent that finished its
|
|
60
|
+
* prompt and now waits for messages, `agent_completed` for a single-shot one.
|
|
61
|
+
* Both make the runtime re-invoke the model (`read_agent` + a reply under the
|
|
62
|
+
* spawning interaction), so both herald a continuation exactly like the shell
|
|
63
|
+
* kinds do (live-probed on runtime 1.0.88, 2026-09-25).
|
|
64
|
+
*/
|
|
65
|
+
export const AGENT_SETTLED_NOTIFICATION_KINDS = Object.freeze([
|
|
66
|
+
'agent_completed',
|
|
67
|
+
'agent_idle',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The background agent a `system.notification` reports as settled, or null.
|
|
72
|
+
* `{ agentId, agentType, displayName, description, status }` — `status` is
|
|
73
|
+
* 'completed' | 'failed' for `agent_completed`, 'idle' for `agent_idle`.
|
|
74
|
+
*/
|
|
75
|
+
export function settledAgentFromNotification(event) {
|
|
76
|
+
if (String(event?.type || '') !== 'system.notification') return null;
|
|
77
|
+
const data = eventData(event);
|
|
78
|
+
const kind = data.kind && typeof data.kind === 'object' ? data.kind : {};
|
|
79
|
+
const type = String(kind.type || '');
|
|
80
|
+
if (!AGENT_SETTLED_NOTIFICATION_KINDS.includes(type)) return null;
|
|
81
|
+
const agentId = String(kind.agentId || '').trim();
|
|
82
|
+
if (!agentId) return null;
|
|
83
|
+
return {
|
|
84
|
+
agentId,
|
|
85
|
+
agentType: String(kind.agentType || '').trim(),
|
|
86
|
+
displayName: String(kind.displayName || '').trim(),
|
|
87
|
+
description: String(kind.description || '').trim(),
|
|
88
|
+
status: type === 'agent_idle' ? 'idle' : (String(kind.status || '').trim() || 'completed'),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A one-line transcript note for a settled background agent, posted into its continuation. */
|
|
93
|
+
export function describeSettledAgent(agent) {
|
|
94
|
+
if (!agent?.agentId) return '';
|
|
95
|
+
const name = agent.displayName || agent.description || agent.agentType || 'agent';
|
|
96
|
+
const verb = agent.status === 'failed' ? 'failed' : 'finished';
|
|
97
|
+
return agent.description && agent.description !== name
|
|
98
|
+
? `Background agent ${name} ${verb}: ${agent.description}`
|
|
99
|
+
: `Background agent ${name} ${verb}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
57
102
|
// `<command started in detached background with shellId: 1>`
|
|
58
103
|
const SHELL_OPENED_RE = /detached\s+(?:background|command)[^<>]*?shellId:\s*([^\s>,]+)/i;
|
|
59
104
|
// `<detached command with shellId: 1 completed with exit code 0>`
|
|
@@ -118,7 +163,17 @@ export const CONTINUATION_OPENING_EVENT_TYPES = Object.freeze([
|
|
|
118
163
|
|
|
119
164
|
const OPENING_TYPES = new Set(CONTINUATION_OPENING_EVENT_TYPES);
|
|
120
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Only ROOT-agent events open a turn. A background subagent keeps emitting
|
|
168
|
+
* `agentId`-tagged `tool.execution_start` / `assistant.message` / its own
|
|
169
|
+
* `user.message` after the turn that spawned it has settled (live-probed on
|
|
170
|
+
* runtime 1.0.88: the subagent's whole life happens under the main loop's
|
|
171
|
+
* idle), and none of that is the main agent starting work. The root agent's
|
|
172
|
+
* follow-up when the subagent finishes (`read_agent` + a reply) carries no
|
|
173
|
+
* `agentId` and opens the continuation as before.
|
|
174
|
+
*/
|
|
121
175
|
export function isContinuationOpeningEvent(event) {
|
|
176
|
+
if (String(event?.agentId || '').trim()) return false;
|
|
122
177
|
return OPENING_TYPES.has(String(event?.type || ''));
|
|
123
178
|
}
|
|
124
179
|
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// question array. Composing the waiter instead of copying it keeps one
|
|
24
24
|
// implementation of the abort semantics.
|
|
25
25
|
import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
|
|
26
|
+
import { looksLikeMultiSelectQuestion } from '../../shared/question-multi-select.mjs';
|
|
26
27
|
import {
|
|
27
28
|
DEFAULT_QUESTION_TIMEOUT_MS,
|
|
28
29
|
QUESTION_TIMEOUT_CONTINUATION_TEXT,
|
|
@@ -103,8 +104,12 @@ export function createCopilotQuestionBridge({
|
|
|
103
104
|
await api('POST', `/api/relay-question/${questionId}/timeout`, {}).catch(() => {});
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
async function createQuestion({ prompt, choices, allowFreeform, source, rationale, requestedSchema, timeoutMs, extra = {} }) {
|
|
107
|
-
|
|
107
|
+
async function createQuestion({ prompt, choices, allowFreeform, source, rationale, requestedSchema, timeoutMs, extra = {} }, owner) {
|
|
108
|
+
// A caller that knows which row the card belongs to names it (`message`);
|
|
109
|
+
// by the time the card is created the "active" row can be another one.
|
|
110
|
+
const activeMsg = owner !== undefined
|
|
111
|
+
? owner
|
|
112
|
+
: (typeof getActiveMessage === 'function' ? getActiveMessage() : null);
|
|
108
113
|
const payload = {
|
|
109
114
|
// The relay 409s ("No active relay turn") unless this queue row is
|
|
110
115
|
// `processing`, which is exactly the state a blocking handler runs in.
|
|
@@ -140,13 +145,13 @@ export function createCopilotQuestionBridge({
|
|
|
140
145
|
return questionId;
|
|
141
146
|
}
|
|
142
147
|
|
|
143
|
-
async function ask(spec, { signal } = {}) {
|
|
148
|
+
async function ask(spec, { signal, message } = {}) {
|
|
144
149
|
// Shutdown already began: nothing will ever poll an answer, so minting a
|
|
145
150
|
// card would only strand it in the UI. Shaped like a timeout so the caller
|
|
146
151
|
// degrades exactly as it does for an unanswered card.
|
|
147
152
|
if (closing) return { answer: QUESTION_TIMEOUT_CONTINUATION_TEXT, timedOut: true };
|
|
148
153
|
const create = (async () => {
|
|
149
|
-
const questionId = await createQuestion(spec);
|
|
154
|
+
const questionId = await createQuestion(spec, message);
|
|
150
155
|
if (closing) {
|
|
151
156
|
// The create raced `cancelPendingQuestions` and lost: its card was
|
|
152
157
|
// born after the shutdown snapshot, so it is expired here, at birth —
|
|
@@ -188,7 +193,7 @@ export function createCopilotQuestionBridge({
|
|
|
188
193
|
* comes back with `structuredAnswer: null` — the caller declines in that
|
|
189
194
|
* case, so a half-valid submission can never be forced into a form result.
|
|
190
195
|
*/
|
|
191
|
-
async function askStructured({ prompt, requestedSchema, timeoutMs }, { signal } = {}) {
|
|
196
|
+
async function askStructured({ prompt, requestedSchema, timeoutMs }, { signal, message } = {}) {
|
|
192
197
|
const result = await ask({
|
|
193
198
|
prompt: String(prompt || '').trim() || 'Copilot needs structured input to continue this turn.',
|
|
194
199
|
choices: [],
|
|
@@ -197,7 +202,7 @@ export function createCopilotQuestionBridge({
|
|
|
197
202
|
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
198
203
|
source: 'onElicitationRequest',
|
|
199
204
|
rationale: 'Copilot requested a structured form answer to continue this turn.',
|
|
200
|
-
}, { signal });
|
|
205
|
+
}, { signal, message });
|
|
201
206
|
const structuredAnswer = result?.structuredAnswer && typeof result.structuredAnswer === 'object'
|
|
202
207
|
&& !Array.isArray(result.structuredAnswer)
|
|
203
208
|
? result.structuredAnswer
|
|
@@ -217,20 +222,37 @@ export function createCopilotQuestionBridge({
|
|
|
217
222
|
* silently. A relay that is unreachable therefore degrades to the same
|
|
218
223
|
* in-band note phase 1 always returned, which lets the model continue.
|
|
219
224
|
*/
|
|
220
|
-
async function askUserInput(request, { signal } = {}) {
|
|
225
|
+
async function askUserInput(request, { signal, message } = {}) {
|
|
221
226
|
const question = String(request?.question || '').trim();
|
|
222
227
|
const choices = normalizeUserInputChoices(request?.choices);
|
|
223
228
|
// `allowFreeform` is only meaningful alongside choices; with none, the card
|
|
224
229
|
// must accept free text or it cannot be answered.
|
|
225
230
|
const allowFreeform = choices.length ? request?.allowFreeform !== false : true;
|
|
231
|
+
// Multi-select, in order of trust: the model's own flag (the relay's
|
|
232
|
+
// `ask_user` tool has `multi_select`; the runtime's built-in one has no
|
|
233
|
+
// such field), then the wording ("select all that apply"). Either way
|
|
234
|
+
// every Copilot choice card also offers the "Select several" switch
|
|
235
|
+
// (`allowMultiSelect`), because a model can forget to say so. The answer
|
|
236
|
+
// comes back as the labels joined with ", ", which the model reads as a
|
|
237
|
+
// freeform reply.
|
|
238
|
+
// A request that demands exactly one of the offered choices
|
|
239
|
+
// (`allowFreeform: false`) gets neither: a joined answer would be outside
|
|
240
|
+
// the set the runtime asked for.
|
|
241
|
+
const canPickSeveral = choices.length >= 2 && allowFreeform;
|
|
242
|
+
const multiSelect = canPickSeveral
|
|
243
|
+
&& (request?.multiSelect === true || looksLikeMultiSelectQuestion(question, choices));
|
|
226
244
|
const result = await ask({
|
|
227
245
|
prompt: question || 'Copilot asked for input to continue this turn.',
|
|
228
246
|
choices,
|
|
229
247
|
allowFreeform,
|
|
230
|
-
source: 'onUserInputRequest',
|
|
248
|
+
source: String(request?.source || '').trim() || 'onUserInputRequest',
|
|
231
249
|
rationale: 'Copilot requested clarification to continue this turn.',
|
|
232
|
-
extra: {
|
|
233
|
-
|
|
250
|
+
extra: {
|
|
251
|
+
requestId: String(request?.requestId || '') || undefined,
|
|
252
|
+
...(multiSelect ? { multiSelect: true } : {}),
|
|
253
|
+
...(canPickSeveral ? { allowMultiSelect: true } : {}),
|
|
254
|
+
},
|
|
255
|
+
}, { signal, message });
|
|
234
256
|
const answer = String(result?.answer ?? '');
|
|
235
257
|
return {
|
|
236
258
|
answer,
|
|
@@ -246,7 +268,7 @@ export function createCopilotQuestionBridge({
|
|
|
246
268
|
* so a human can type *why* they are refusing and the model receives it as
|
|
247
269
|
* feedback instead of a bare refusal.
|
|
248
270
|
*/
|
|
249
|
-
async function askToolApproval(request, { signal } = {}) {
|
|
271
|
+
async function askToolApproval(request, { signal, message } = {}) {
|
|
250
272
|
const description = describePermissionRequest(request);
|
|
251
273
|
const result = await ask({
|
|
252
274
|
prompt: `Copilot wants to run:\n\n${description}\n\nApprove this action?`,
|
|
@@ -258,7 +280,7 @@ export function createCopilotQuestionBridge({
|
|
|
258
280
|
requestId: String(request?.requestId || '') || undefined,
|
|
259
281
|
permissionKind: String(request?.kind || '') || undefined,
|
|
260
282
|
},
|
|
261
|
-
}, { signal });
|
|
283
|
+
}, { signal, message });
|
|
262
284
|
const answer = String(result?.answer ?? '').trim();
|
|
263
285
|
const approved = !result?.timedOut && answer.toLowerCase() === PERMISSION_APPROVE_CHOICE.toLowerCase();
|
|
264
286
|
// The denial note is composed HERE rather than in the permission handler so
|
|
@@ -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
|
+
}
|