@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
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// The provider-facing side of the `remote_relay` tool, in one place.
|
|
2
|
+
//
|
|
3
|
+
// Every adapter (Claude's in-process MCP server, Cursor's customTools, the
|
|
4
|
+
// Copilot SDK engine's session tools, and the OAR stdio MCP server that Grok
|
|
5
|
+
// and the Copilot extension engine reach) registers the SAME name, description
|
|
6
|
+
// and schema from the contract and forwards every call to the one local relay
|
|
7
|
+
// endpoint. The relay does the gating, the approval, the forwarding and the
|
|
8
|
+
// waiting; nothing here knows about remote relays beyond the wire shape.
|
|
9
|
+
//
|
|
10
|
+
// SDK-free and dependency-injected, following preview-tool-core.mjs: all relay
|
|
11
|
+
// traffic goes through the worker's injected `api(method, path, body, options)`
|
|
12
|
+
// helper (shared/worker-runtime/api-client.mjs).
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
REMOTE_RELAY_ERROR_CODES,
|
|
16
|
+
REMOTE_RELAY_TOOL_DESCRIPTION,
|
|
17
|
+
REMOTE_RELAY_TOOL_ENDPOINT,
|
|
18
|
+
REMOTE_RELAY_TOOL_INPUT_SCHEMA,
|
|
19
|
+
REMOTE_RELAY_TOOL_NAME,
|
|
20
|
+
summarizeRemoteRelayCall,
|
|
21
|
+
validateRemoteRelayToolInput,
|
|
22
|
+
} from './remote-relay-contract.mjs';
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
REMOTE_RELAY_TOOL_DESCRIPTION,
|
|
26
|
+
REMOTE_RELAY_TOOL_ENDPOINT,
|
|
27
|
+
REMOTE_RELAY_TOOL_INPUT_SCHEMA,
|
|
28
|
+
REMOTE_RELAY_TOOL_NAME,
|
|
29
|
+
summarizeRemoteRelayCall,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// The adapters' crisp early check; the relay validates again authoritatively.
|
|
33
|
+
export const validateRemoteRelayTool = validateRemoteRelayToolInput;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A private deep copy of the contract schema for SDKs that take ownership of
|
|
37
|
+
* the object they are handed (the contract's is frozen at the top level, and
|
|
38
|
+
* an SDK that annotates its input would throw on it).
|
|
39
|
+
*/
|
|
40
|
+
export function cloneRemoteRelayToolInputSchema() {
|
|
41
|
+
return JSON.parse(JSON.stringify(REMOTE_RELAY_TOOL_INPUT_SCHEMA));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// How many remotes this relay knows (`{ count }`): the registration decision.
|
|
45
|
+
export const REMOTE_RELAY_SUMMARY_PATH = '/api/remote-relays/summary';
|
|
46
|
+
// Calls of one conversation the relay is still working on (`{ inflight }`):
|
|
47
|
+
// the Grok inactivity hold, which cannot see the tool call itself.
|
|
48
|
+
export const REMOTE_RELAY_INFLIGHT_PATH = '/api/remote-relays/inflight';
|
|
49
|
+
|
|
50
|
+
// The local relay could not be asked at all, or answered without a body the
|
|
51
|
+
// model could read (a transport error, a crash page). Distinct from every
|
|
52
|
+
// contract code, which describe a refusal the relay itself made.
|
|
53
|
+
export const REMOTE_RELAY_CALL_FAILED = 'REMOTE_RELAY_CALL_FAILED';
|
|
54
|
+
|
|
55
|
+
// The provider cancelled the call (the user's Stop, a host's cancel, a
|
|
56
|
+
// runtime going away) before the relay answered. The relay sees the request
|
|
57
|
+
// close and stops too: it withdraws an open approval card and stops waiting
|
|
58
|
+
// on the remote. A prompt already handed over is NOT taken back.
|
|
59
|
+
export const REMOTE_RELAY_CANCELLED = 'REMOTE_RELAY_CANCELLED';
|
|
60
|
+
|
|
61
|
+
// How long the registration probe may take before the tool is left out.
|
|
62
|
+
export const REMOTE_RELAY_DECISION_TIMEOUT_MS = 5_000;
|
|
63
|
+
|
|
64
|
+
// A hard ceiling for one tool call on the providers that impose their own
|
|
65
|
+
// (MCP clients). The relay already bounds the wait (wait_seconds ≤ 600) but not
|
|
66
|
+
// the time an approval card may sit unanswered, so this only has to outlast a
|
|
67
|
+
// human, never a remote turn.
|
|
68
|
+
export const REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS = 8 * 60 * 60_000;
|
|
69
|
+
|
|
70
|
+
function toText(value) {
|
|
71
|
+
return String(value ?? '').trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function withTimeout(promise, timeoutMs, label) {
|
|
75
|
+
if (!(Number(timeoutMs) > 0)) return promise;
|
|
76
|
+
let timer = null;
|
|
77
|
+
return Promise.race([
|
|
78
|
+
promise,
|
|
79
|
+
new Promise((_, reject) => {
|
|
80
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
81
|
+
timer.unref?.();
|
|
82
|
+
}),
|
|
83
|
+
]).finally(() => { if (timer) clearTimeout(timer); });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function errorBodyOf(error) {
|
|
87
|
+
// `body` is the parsed JSON error body when the api client kept it; a
|
|
88
|
+
// client that parsed it into `detail` instead is tolerated too.
|
|
89
|
+
for (const candidate of [error?.body, error?.detail]) {
|
|
90
|
+
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) return candidate;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Maps an api-client failure onto the result shape the model reads. The relay
|
|
97
|
+
* answers a refusal with `{ ok:false, code, error }` and a 4xx/5xx status; that
|
|
98
|
+
* body IS the answer (LOCKED, FORBIDDEN, OFFLINE, …), so it is passed through
|
|
99
|
+
* as-is. Anything without such a body becomes REMOTE_RELAY_CALL_FAILED.
|
|
100
|
+
*/
|
|
101
|
+
export function remoteRelayErrorResult(error) {
|
|
102
|
+
const status = Number(error?.status) || 0;
|
|
103
|
+
const body = errorBodyOf(error);
|
|
104
|
+
const detail = typeof error?.detail === 'string' ? toText(error.detail) : '';
|
|
105
|
+
if (body && (body.code || body.error)) {
|
|
106
|
+
return {
|
|
107
|
+
...body,
|
|
108
|
+
ok: false,
|
|
109
|
+
code: toText(body.code) || REMOTE_RELAY_CALL_FAILED,
|
|
110
|
+
error: toText(body.error || body.message) || detail || `HTTP ${status}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (status === 404) {
|
|
114
|
+
// A relay that predates the feature (or runs without its routes).
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
code: REMOTE_RELAY_ERROR_CODES.unsupported,
|
|
118
|
+
error: 'This relay does not offer remote relays (the tool endpoint is missing). Tell the user.',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
code: REMOTE_RELAY_CALL_FAILED,
|
|
124
|
+
...(status ? { status } : {}),
|
|
125
|
+
error: detail || toText(error?.message || error) || 'The relay could not be reached',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isAbortError(error) {
|
|
130
|
+
return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The answer for a call its provider cancelled before the relay answered. */
|
|
134
|
+
export function remoteRelayCancelledResult() {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
code: REMOTE_RELAY_CANCELLED,
|
|
138
|
+
error: 'The call was cancelled before the relay answered. A message it had already handed over may still run on the remote.',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Executes one `remote_relay` call: validate, then forward
|
|
144
|
+
* `{ conversationId, action, args }` to the local relay and hand back its JSON.
|
|
145
|
+
* Never throws — a refusal reads as an answer the model can act on, the same
|
|
146
|
+
* contract as executePreviewTool.
|
|
147
|
+
*
|
|
148
|
+
* The call goes out as a long call (`{ longCall: true }`): no client-side
|
|
149
|
+
* timeout at all, because the relay bounds the wait itself (wait_seconds plus
|
|
150
|
+
* approval time) and fetch's 300 s header limit would cut both short.
|
|
151
|
+
* `signal` (the provider's cancellation) is the only way to end it early.
|
|
152
|
+
*/
|
|
153
|
+
export async function executeRemoteRelayTool(input, { api, conversationId = '', signal = null } = {}) {
|
|
154
|
+
if (typeof api !== 'function') {
|
|
155
|
+
return { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: 'Remote relay API unavailable in this worker' };
|
|
156
|
+
}
|
|
157
|
+
const conversation = toText(conversationId);
|
|
158
|
+
if (!conversation) {
|
|
159
|
+
return { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: 'No conversation is bound to this call' };
|
|
160
|
+
}
|
|
161
|
+
const parsed = validateRemoteRelayToolInput(input);
|
|
162
|
+
if (!parsed.ok) return parsed;
|
|
163
|
+
if (signal?.aborted) return remoteRelayCancelledResult();
|
|
164
|
+
try {
|
|
165
|
+
const response = await api('POST', REMOTE_RELAY_TOOL_ENDPOINT, {
|
|
166
|
+
conversationId: conversation,
|
|
167
|
+
action: parsed.action,
|
|
168
|
+
args: parsed.args,
|
|
169
|
+
}, { longCall: true, ...(signal ? { signal } : {}) });
|
|
170
|
+
if (response && typeof response === 'object' && !Array.isArray(response)) return response;
|
|
171
|
+
return { ok: true, result: response ?? null };
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (signal?.aborted || isAbortError(error)) return remoteRelayCancelledResult();
|
|
174
|
+
return remoteRelayErrorResult(error);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Whether a worker should register the tool: at least one remote is paired.
|
|
180
|
+
* Fails closed — any error, a malformed answer or a slow relay leaves the tool
|
|
181
|
+
* out, and the next worker start asks again.
|
|
182
|
+
*/
|
|
183
|
+
export async function shouldRegisterRemoteRelayTool({ api, timeoutMs = REMOTE_RELAY_DECISION_TIMEOUT_MS } = {}) {
|
|
184
|
+
if (typeof api !== 'function') return false;
|
|
185
|
+
try {
|
|
186
|
+
const response = await withTimeout(
|
|
187
|
+
Promise.resolve().then(() => api('GET', REMOTE_RELAY_SUMMARY_PATH)),
|
|
188
|
+
timeoutMs,
|
|
189
|
+
'remote relay summary',
|
|
190
|
+
);
|
|
191
|
+
return Number(response?.count) > 0;
|
|
192
|
+
} catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The registration decision as a worker holds it: asked once when the worker
|
|
199
|
+
* starts (`refresh()`), read synchronously where a tool set is fixed
|
|
200
|
+
* (`isEnabled()`), and awaitable where the first build may wait for it
|
|
201
|
+
* (`ready()`, which never rejects). `fixed` pins the answer (tests, kill
|
|
202
|
+
* switches) without asking the relay.
|
|
203
|
+
*/
|
|
204
|
+
export function createRemoteRelayToolGate({
|
|
205
|
+
api,
|
|
206
|
+
fixed = undefined,
|
|
207
|
+
decide = shouldRegisterRemoteRelayTool,
|
|
208
|
+
timeoutMs = REMOTE_RELAY_DECISION_TIMEOUT_MS,
|
|
209
|
+
} = {}) {
|
|
210
|
+
if (typeof fixed === 'boolean') {
|
|
211
|
+
return {
|
|
212
|
+
isEnabled: () => fixed,
|
|
213
|
+
isSettled: () => true,
|
|
214
|
+
refresh: () => Promise.resolve(fixed),
|
|
215
|
+
ready: () => Promise.resolve(fixed),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
let enabled = false;
|
|
219
|
+
let settledOnce = false;
|
|
220
|
+
let pending = null;
|
|
221
|
+
function refresh() {
|
|
222
|
+
if (!pending) {
|
|
223
|
+
pending = Promise.resolve()
|
|
224
|
+
.then(() => decide({ api, timeoutMs }))
|
|
225
|
+
.then((value) => { enabled = value === true; }, () => { enabled = false; })
|
|
226
|
+
.then(() => {
|
|
227
|
+
settledOnce = true;
|
|
228
|
+
pending = null;
|
|
229
|
+
return enabled;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return pending;
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
isEnabled: () => enabled,
|
|
236
|
+
// True once an answer is in and no refresh is running: a caller can then
|
|
237
|
+
// read isEnabled() without awaiting ready().
|
|
238
|
+
isSettled: () => settledOnce && !pending,
|
|
239
|
+
refresh,
|
|
240
|
+
ready: () => (settledOnce && !pending ? Promise.resolve(enabled) : (pending || refresh())),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* How many remote_relay calls of this conversation the relay is still working
|
|
246
|
+
* on. 0 on any failure: the caller's watchdog then does what it would have
|
|
247
|
+
* done without the question.
|
|
248
|
+
*/
|
|
249
|
+
export async function fetchRemoteRelayInflight({ api, conversationId = '', timeoutMs = 10_000 } = {}) {
|
|
250
|
+
const conversation = toText(conversationId);
|
|
251
|
+
if (typeof api !== 'function' || !conversation) return 0;
|
|
252
|
+
try {
|
|
253
|
+
const response = await withTimeout(
|
|
254
|
+
Promise.resolve().then(() => api(
|
|
255
|
+
'GET',
|
|
256
|
+
`${REMOTE_RELAY_INFLIGHT_PATH}?conversationId=${encodeURIComponent(conversation)}`,
|
|
257
|
+
)),
|
|
258
|
+
timeoutMs,
|
|
259
|
+
'remote relay inflight',
|
|
260
|
+
);
|
|
261
|
+
const count = Number(response?.inflight);
|
|
262
|
+
return Number.isFinite(count) && count > 0 ? count : 0;
|
|
263
|
+
} catch {
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The text every adapter hands the model: the relay's JSON, pretty-printed. */
|
|
269
|
+
export function formatRemoteRelayToolResult(result) {
|
|
270
|
+
try {
|
|
271
|
+
const text = JSON.stringify(result ?? null, null, 2);
|
|
272
|
+
return text === undefined ? 'null' : text;
|
|
273
|
+
} catch {
|
|
274
|
+
return String(result);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ─── Activity lines ──────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
// A provider may report the tool under its bare name or namespaced by the MCP
|
|
281
|
+
// server that carries it (`mcp__relay__remote_relay`, `oar__remote_relay`,
|
|
282
|
+
// `oar/remote_relay`, `oar-remote_relay`); all of them are this tool.
|
|
283
|
+
const REMOTE_RELAY_NAME_PATTERN = new RegExp(`(?:^|__|[^A-Za-z0-9_])${REMOTE_RELAY_TOOL_NAME}$`, 'i');
|
|
284
|
+
|
|
285
|
+
export function isRemoteRelayToolName(name) {
|
|
286
|
+
return REMOTE_RELAY_NAME_PATTERN.test(toText(name));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The arguments of a tool call as an object, whether the provider delivered
|
|
291
|
+
* them parsed or as a JSON string. Null when there is nothing usable.
|
|
292
|
+
*/
|
|
293
|
+
export function remoteRelayCallInput(rawInput) {
|
|
294
|
+
let input = rawInput;
|
|
295
|
+
if (typeof input === 'string') {
|
|
296
|
+
try {
|
|
297
|
+
input = JSON.parse(input);
|
|
298
|
+
} catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return input && typeof input === 'object' && !Array.isArray(input) ? input : null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The summary part of the activity line (`send → linux-test session 01234567:
|
|
307
|
+
* “…”`), or '' when the call carries no action yet (a streaming frame before
|
|
308
|
+
* its arguments arrived) so the caller keeps its generic line.
|
|
309
|
+
*/
|
|
310
|
+
export function remoteRelayActivitySummary(rawInput) {
|
|
311
|
+
const input = remoteRelayCallInput(rawInput);
|
|
312
|
+
if (!input || !toText(input.action)) return '';
|
|
313
|
+
return summarizeRemoteRelayCall(input);
|
|
314
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Keeps a "/x" message the relay delivers to the Claude CLI plain text.
|
|
2
|
+
//
|
|
3
|
+
// The relay itself runs only /compact and /preview as commands, and neither
|
|
4
|
+
// reaches a worker as text: the composer (and the /api/message route, for
|
|
5
|
+
// /compact) intercept them. Any other "/x" the composer sends only after its
|
|
6
|
+
// warn-once guard ("press send again to send as text"), and the API accepts
|
|
7
|
+
// it as-is — so it must reach the model as the text the user typed. The Claude
|
|
8
|
+
// CLI, however, runs a prompt as a slash command when the string, or its LAST
|
|
9
|
+
// text block trimmed, starts with "/". Between turns that ran "/help what is
|
|
10
|
+
// 3 + 4?" as the CLI's /help (a `local_command` with "isn't available in this
|
|
11
|
+
// environment" as its output): the model never saw the question, and the
|
|
12
|
+
// relay attributed the next turn's reply to that row (live, 2026-09-26).
|
|
13
|
+
//
|
|
14
|
+
// The guard is a zero-width space in front of the "/". JavaScript's trim()
|
|
15
|
+
// does not strip U+200B, so neither the CLI's command check nor its queue's
|
|
16
|
+
// "is this a command" check fires, and the message is handled exactly like
|
|
17
|
+
// any other plain-text prompt (live probe, CLI 2.1.283: no local_command, the
|
|
18
|
+
// model answered the question, the transcript keeps the character). The
|
|
19
|
+
// relay stores and shows the user's own text; only the CLI sees the guard.
|
|
20
|
+
//
|
|
21
|
+
// A steered message needs none: the steer note (shared/steer-note.mjs) already
|
|
22
|
+
// leads its text, so the guard only applies where the text itself starts
|
|
23
|
+
// with "/".
|
|
24
|
+
//
|
|
25
|
+
// Not the SDK's per-message `client_composed: true`: that suppresses the
|
|
26
|
+
// dispatch too (same probe), but older CLIs ignore the field, it also turns
|
|
27
|
+
// off `@path` expansion and drops part of the turn-start context for that
|
|
28
|
+
// message (the probe lost the token-budget reminder), and per the CLI bundle
|
|
29
|
+
// its queue still classifies the text by the leading "/" (e.g. never merges
|
|
30
|
+
// it with other queued prompts) — the guard makes it plain text everywhere.
|
|
31
|
+
|
|
32
|
+
export const SLASH_COMMAND_GUARD = '\u200B';
|
|
33
|
+
|
|
34
|
+
function readsAsSlashCommand(text) {
|
|
35
|
+
return typeof text === 'string' && text.trim().startsWith('/');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Prefix the guard when the CLI would read the text as a slash command. */
|
|
39
|
+
export function withSlashCommandGuard(text) {
|
|
40
|
+
return readsAsSlashCommand(text) ? `${SLASH_COMMAND_GUARD}${text}` : text;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The guard applied to Claude user content: a string, or every text block of a
|
|
45
|
+
* content-block array (the CLI inspects the last block; guarding each keeps
|
|
46
|
+
* that true whatever the block order). Returns the input itself when nothing
|
|
47
|
+
* needed a guard; never mutates it.
|
|
48
|
+
*/
|
|
49
|
+
export function withSlashCommandGuardContent(content) {
|
|
50
|
+
if (typeof content === 'string') return withSlashCommandGuard(content);
|
|
51
|
+
if (!Array.isArray(content)) return content;
|
|
52
|
+
if (!content.some((block) => block?.type === 'text' && readsAsSlashCommand(block.text))) return content;
|
|
53
|
+
return content.map((block) => (block?.type === 'text' && readsAsSlashCommand(block.text)
|
|
54
|
+
? { ...block, text: withSlashCommandGuard(block.text) }
|
|
55
|
+
: block));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The user's own text from a guarded prompt: the leading guard is dropped only
|
|
60
|
+
* where it stands in front of a "/", so other text is returned unchanged.
|
|
61
|
+
*/
|
|
62
|
+
export function stripSlashCommandGuard(text) {
|
|
63
|
+
const value = String(text ?? '');
|
|
64
|
+
return value.startsWith(SLASH_COMMAND_GUARD) && readsAsSlashCommand(value.slice(SLASH_COMMAND_GUARD.length))
|
|
65
|
+
? value.slice(SLASH_COMMAND_GUARD.length)
|
|
66
|
+
: value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** stripSlashCommandGuard over a string or each text block of a content array. */
|
|
70
|
+
export function stripSlashCommandGuardContent(content) {
|
|
71
|
+
if (typeof content === 'string') return stripSlashCommandGuard(content);
|
|
72
|
+
if (!Array.isArray(content)) return content;
|
|
73
|
+
return content.map((block) => (block?.type === 'text' && typeof block.text === 'string'
|
|
74
|
+
? { ...block, text: stripSlashCommandGuard(block.text) }
|
|
75
|
+
: block));
|
|
76
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The hidden note a steered message carries to the model.
|
|
2
|
+
//
|
|
3
|
+
// A message the user sends while a turn is running is pushed into that turn
|
|
4
|
+
// (steering). When it lands before the model has produced anything, the model
|
|
5
|
+
// sees two user messages at once — and in the 2026-09-25 burn-in gpt-5.4-mini
|
|
6
|
+
// answered only the newest and silently dropped the original request (the
|
|
7
|
+
// runtime's own event log confirmed nothing was lost by the relay). The note
|
|
8
|
+
// tells the model the new message is an addition, not a replacement, unless
|
|
9
|
+
// the user says otherwise. Only the model sees it: the relay stores and shows
|
|
10
|
+
// the user's own text.
|
|
11
|
+
//
|
|
12
|
+
// Shared by the Claude and Copilot SDK workers (maintainer decision, 2026-09-25: both).
|
|
13
|
+
|
|
14
|
+
// Worded to stay true whether the runtime folds the message into the running
|
|
15
|
+
// turn or runs it as a turn of its own after that one finished: "if not
|
|
16
|
+
// finished yet" never asks the model to redo work it already completed. The
|
|
17
|
+
// opening words are the stable handle the prompt sanitizers match on
|
|
18
|
+
// (services/relay-prompt-sanitizer.mjs and its browser copy).
|
|
19
|
+
export const STEERED_MESSAGE_NOTE = '[Sent while you were still working on my previous message. '
|
|
20
|
+
+ 'If that request is not finished yet, finish it too; do not drop it unless this message says so.]';
|
|
21
|
+
|
|
22
|
+
// The note LEADS the user's text, inside the same text block, and that
|
|
23
|
+
// position is load-bearing beyond the replay's shape. The Claude CLI runs a
|
|
24
|
+
// prompt as a slash command when the string, or its LAST text block trimmed,
|
|
25
|
+
// starts with "/" — and one dequeued mid-turn is deferred to a turn of its
|
|
26
|
+
// own, whose output the relay would misattribute. The relay itself runs only
|
|
27
|
+
// /compact and /preview as commands (any other "/x" is confirmed as text
|
|
28
|
+
// first), so a steered "/cmd …" must reach the model as plain text folded
|
|
29
|
+
// into the running turn, which the leading note guarantees. Appended, the
|
|
30
|
+
// prompt would still start with "/" and run as a command whose arguments
|
|
31
|
+
// include the note; as a separate leading block, the last block would still
|
|
32
|
+
// start with "/". A "/x" that is not steered (or is pushed into a continuation,
|
|
33
|
+
// which gets no note) is kept text by the Claude worker's slash-command guard
|
|
34
|
+
// instead (shared/slash-command-guard.mjs).
|
|
35
|
+
|
|
36
|
+
/** Prefix a plain-text prompt with the steer note. */
|
|
37
|
+
export function withSteerNote(text) {
|
|
38
|
+
const body = String(text ?? '');
|
|
39
|
+
return body.trim() ? `${STEERED_MESSAGE_NOTE}\n\n${body}` : STEERED_MESSAGE_NOTE;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Prefix a content-block array (Claude's user message content) with the note:
|
|
44
|
+
* into the first text block when there is one, so the block structure — and
|
|
45
|
+
* with it the replay the CLI echoes back — keeps its shape; otherwise as a new
|
|
46
|
+
* leading text block. Returns a new array; the input is not mutated.
|
|
47
|
+
*/
|
|
48
|
+
export function withSteerNoteContent(content) {
|
|
49
|
+
if (typeof content === 'string') return withSteerNote(content);
|
|
50
|
+
if (!Array.isArray(content)) return content;
|
|
51
|
+
const index = content.findIndex((block) => block?.type === 'text');
|
|
52
|
+
if (index < 0) return [{ type: 'text', text: STEERED_MESSAGE_NOTE }, ...content];
|
|
53
|
+
return content.map((block, i) => (i === index ? { ...block, text: withSteerNote(block.text) } : block));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The user's own text from a prompt that may carry the steer note — what a
|
|
58
|
+
* worker compares a replayed message against. Note-agnostic matching: the
|
|
59
|
+
* replay the CLI echoes back carries the note, the relay row does not.
|
|
60
|
+
*/
|
|
61
|
+
export function stripSteerNote(text) {
|
|
62
|
+
const value = String(text ?? '');
|
|
63
|
+
if (value === STEERED_MESSAGE_NOTE) return '';
|
|
64
|
+
const prefix = `${STEERED_MESSAGE_NOTE}\n\n`;
|
|
65
|
+
return value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
|
66
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The terminal failure for a message whose prompt the agent already consumed
|
|
2
|
+
// but whose outcome could not be recorded. At-most-once: such a row is never
|
|
3
|
+
// requeued (a re-delivery would run the prompt a second time), so it fails for
|
|
4
|
+
// good with wording that tells the user where the answer went. Shared by the
|
|
5
|
+
// session workers (their own settle paths) and the relay (recovery paths that
|
|
6
|
+
// meet a row marked consumed).
|
|
7
|
+
//
|
|
8
|
+
// Provider-neutral: the wording names `agentLabel` ("the agent" unless the
|
|
9
|
+
// caller knows better — the Claude worker passes 'Claude').
|
|
10
|
+
|
|
11
|
+
export const STEER_SETTLE_FAILED_CODE = 'steer-settle-failed';
|
|
12
|
+
export const STEER_SETTLE_FAILED_STABLE_CODE = `relay.${STEER_SETTLE_FAILED_CODE}`;
|
|
13
|
+
export const DEFAULT_STEER_AGENT_LABEL = 'the agent';
|
|
14
|
+
|
|
15
|
+
function normalizeAgentLabel(value) {
|
|
16
|
+
const text = String(value || '').trim();
|
|
17
|
+
return text || DEFAULT_STEER_AGENT_LABEL;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// "the agent" → "The agent" at the start of a sentence; a proper name is
|
|
21
|
+
// left as it is.
|
|
22
|
+
function sentenceCase(label) {
|
|
23
|
+
return label.charAt(0).toUpperCase() + label.slice(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const VARIANTS = Object.freeze({
|
|
27
|
+
// Folded into a turn whose reply answered it (or the path is unknown — the
|
|
28
|
+
// relay's recovery paths cannot tell which).
|
|
29
|
+
folded: (label) => ({
|
|
30
|
+
message: `This message was already sent to ${label} — check the reply above.`,
|
|
31
|
+
guidance: 'Resend it only if it went unanswered.',
|
|
32
|
+
}),
|
|
33
|
+
// Handed off mid-reply: its own partial reply stands, and the rest of the
|
|
34
|
+
// answer continued on the next message.
|
|
35
|
+
handoff: (label) => ({
|
|
36
|
+
message: `${sentenceCase(label)} started answering this message, then carried on with your next one — check both replies.`,
|
|
37
|
+
guidance: 'Resend it only if something went unanswered.',
|
|
38
|
+
}),
|
|
39
|
+
// Steered into a turn the user stopped.
|
|
40
|
+
stopped: (label) => ({
|
|
41
|
+
message: `This message was sent to ${label} just before you stopped the turn, so it was not answered.`,
|
|
42
|
+
guidance: 'Resend it if you still want an answer.',
|
|
43
|
+
}),
|
|
44
|
+
// Pulled back out of the runtime's queue by the user (un-steer) — the
|
|
45
|
+
// prompt never ran — but the relay could not be told in time.
|
|
46
|
+
cancelled: (label) => ({
|
|
47
|
+
message: `This message was cancelled before ${label} started on it, so it was not answered.`,
|
|
48
|
+
guidance: 'Resend it if you still want an answer.',
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The name a provider's runtime goes by in the settle wording, for callers
|
|
54
|
+
* that only know the conversation's provider type (the relay's recovery
|
|
55
|
+
* paths). Workers that know exactly which runtime took the prompt pass the
|
|
56
|
+
* label themselves.
|
|
57
|
+
*/
|
|
58
|
+
export function steerAgentLabelForProvider(providerType) {
|
|
59
|
+
switch (String(providerType || '').trim().toLowerCase()) {
|
|
60
|
+
case 'claude': return 'Claude';
|
|
61
|
+
case 'github':
|
|
62
|
+
case 'openai': return 'Copilot';
|
|
63
|
+
case 'cursor': return 'Cursor';
|
|
64
|
+
case 'grok': return 'Grok';
|
|
65
|
+
default: return DEFAULT_STEER_AGENT_LABEL;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function wordingFor(variant, agentLabel) {
|
|
70
|
+
const build = VARIANTS[variant] || VARIANTS.folded;
|
|
71
|
+
return build(normalizeAgentLabel(agentLabel));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function steerSettleFailureText(variant = 'folded', agentLabel = DEFAULT_STEER_AGENT_LABEL) {
|
|
75
|
+
return wordingFor(variant, agentLabel).message;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function buildSteerSettleFailure(message, { variant = 'folded', agentLabel = DEFAULT_STEER_AGENT_LABEL } = {}) {
|
|
79
|
+
const wording = wordingFor(variant, agentLabel);
|
|
80
|
+
return {
|
|
81
|
+
kind: STEER_SETTLE_FAILED_CODE,
|
|
82
|
+
code: STEER_SETTLE_FAILED_CODE,
|
|
83
|
+
stableCode: STEER_SETTLE_FAILED_STABLE_CODE,
|
|
84
|
+
message: wording.message,
|
|
85
|
+
guidance: wording.guidance,
|
|
86
|
+
failedAt: new Date().toISOString(),
|
|
87
|
+
queueMessageId: String(message?.id || '') || null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The settle stubs of steered messages the Claude CLI consumed without opening
|
|
2
|
+
// a turn of their own. Each is stamped with its own message kind, which is
|
|
3
|
+
// what the client renders from; the texts are shared so the relay's migration
|
|
4
|
+
// can recognise stubs saved before the kind existed.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A steer folded into the running turn and answered by that turn's reply.
|
|
8
|
+
* Stamped kind='folded' (before 0.9.3: kind='absorbed', which the client read
|
|
9
|
+
* as "the reply continues through the next message" and mis-merged the next
|
|
10
|
+
* ordinary message on reload).
|
|
11
|
+
*/
|
|
12
|
+
export const STEER_FOLDED_TEXT = '_(Handled together with the previous reply — this message was steered into that turn.)_';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A steer pushed into a turn the user then stopped: never answered. Stamped
|
|
16
|
+
* kind='stopped' — the stable handle the client keys its Resend on.
|
|
17
|
+
*/
|
|
18
|
+
export const STEER_STOPPED_TEXT = '_(Stopped with the turn — not answered.)_';
|
|
@@ -11,6 +11,45 @@
|
|
|
11
11
|
* The requeue is strictly bounded: a crash handler that hangs is worse than
|
|
12
12
|
* no handler at all.
|
|
13
13
|
*/
|
|
14
|
+
/**
|
|
15
|
+
* Post a bounded, fenced requeue for each owed row. Entries are plain ids or
|
|
16
|
+
* { id, attemptId, terminalError } objects: the attempt id fences the requeue
|
|
17
|
+
* (a row that has moved on to another attempt must not be yanked back by a
|
|
18
|
+
* dying predecessor), and a `terminalError` makes the requeue route fail the
|
|
19
|
+
* row outright — for a row whose prompt was already consumed, which must not
|
|
20
|
+
* be requeued and run a second time. Never waits longer than `timeoutMs`.
|
|
21
|
+
*/
|
|
22
|
+
export async function requeueOwedRows({ api, entries = [], timeoutMs = 2_000 } = {}) {
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
const owed = (Array.isArray(entries) ? entries : [])
|
|
25
|
+
.map((entry) => (entry && typeof entry === 'object'
|
|
26
|
+
? {
|
|
27
|
+
id: String(entry.id || '').trim(),
|
|
28
|
+
attemptId: String(entry.attemptId || '').trim() || null,
|
|
29
|
+
terminalError: entry.terminalError && typeof entry.terminalError === 'object' ? entry.terminalError : null,
|
|
30
|
+
}
|
|
31
|
+
: { id: String(entry || '').trim(), attemptId: null, terminalError: null }))
|
|
32
|
+
.filter((entry) => {
|
|
33
|
+
if (!entry.id || seen.has(entry.id)) return false;
|
|
34
|
+
seen.add(entry.id);
|
|
35
|
+
return true;
|
|
36
|
+
});
|
|
37
|
+
if (!owed.length || typeof api !== 'function') return;
|
|
38
|
+
let timer = null;
|
|
39
|
+
await Promise.race([
|
|
40
|
+
Promise.allSettled(owed.map((entry) => api('POST', '/api/requeue', {
|
|
41
|
+
messageId: entry.id,
|
|
42
|
+
...(entry.attemptId ? { attemptId: entry.attemptId } : {}),
|
|
43
|
+
...(entry.terminalError ? { terminalError: entry.terminalError } : {}),
|
|
44
|
+
}))),
|
|
45
|
+
new Promise((resolve) => {
|
|
46
|
+
timer = setTimeout(resolve, Math.max(0, Number(timeoutMs) || 0));
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
}),
|
|
49
|
+
]);
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
|
|
14
53
|
export function installWorkerCrashGuard({
|
|
15
54
|
api,
|
|
16
55
|
workerName = 'session-worker',
|
|
@@ -28,31 +67,7 @@ export function installWorkerCrashGuard({
|
|
|
28
67
|
logError(`${workerName} ${kind}:`, error?.stack || error?.message || error);
|
|
29
68
|
(async () => {
|
|
30
69
|
try {
|
|
31
|
-
|
|
32
|
-
// when known, fences the requeue: a row that has already moved on to
|
|
33
|
-
// another attempt must not be yanked back by a dying predecessor.
|
|
34
|
-
const seen = new Set();
|
|
35
|
-
const entries = (getActiveQueueMessageIds() || [])
|
|
36
|
-
.map((entry) => (entry && typeof entry === 'object'
|
|
37
|
-
? { id: String(entry.id || '').trim(), attemptId: String(entry.attemptId || '').trim() || null }
|
|
38
|
-
: { id: String(entry || '').trim(), attemptId: null }))
|
|
39
|
-
.filter((entry) => {
|
|
40
|
-
if (!entry.id || seen.has(entry.id)) return false;
|
|
41
|
-
seen.add(entry.id);
|
|
42
|
-
return true;
|
|
43
|
-
});
|
|
44
|
-
if (entries.length && typeof api === 'function') {
|
|
45
|
-
await Promise.race([
|
|
46
|
-
Promise.allSettled(entries.map((entry) => api('POST', '/api/requeue', {
|
|
47
|
-
messageId: entry.id,
|
|
48
|
-
...(entry.attemptId ? { attemptId: entry.attemptId } : {}),
|
|
49
|
-
}))),
|
|
50
|
-
new Promise((resolve) => {
|
|
51
|
-
const timer = setTimeout(resolve, Math.max(0, Number(requeueTimeoutMs) || 0));
|
|
52
|
-
timer.unref?.();
|
|
53
|
-
}),
|
|
54
|
-
]);
|
|
55
|
-
}
|
|
70
|
+
await requeueOwedRows({ api, entries: getActiveQueueMessageIds() || [], timeoutMs: requeueTimeoutMs });
|
|
56
71
|
} catch {}
|
|
57
72
|
try { onBeforeExit(); } catch {}
|
|
58
73
|
exit(1);
|