@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.
Files changed (76) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +98 -0
  4. package/README.md +76 -14
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +100 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +385 -43
  10. package/server/claude-worker/claude-session-worker.mjs +5 -0
  11. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  12. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
  16. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  17. package/server/cursor-worker/cursor-session-worker.mjs +5 -0
  18. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  19. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  20. package/server/db-schema.mjs +8 -0
  21. package/server/grok-worker/acp-client.mjs +92 -6
  22. package/server/grok-worker/grok-session-worker.mjs +5 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  24. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  25. package/server/mcp/oar-mcp-server.mjs +473 -0
  26. package/server/migrations/0005-remote-relays.mjs +90 -0
  27. package/server/public/app/action-confirmations.js +141 -13
  28. package/server/public/app/api-client.js +103 -0
  29. package/server/public/app/bootstrap.js +21 -6
  30. package/server/public/app/conversation-deep-link.mjs +42 -0
  31. package/server/public/app/conversation-view.js +100 -7
  32. package/server/public/app/host-suspend-ui.js +272 -0
  33. package/server/public/app/journal-view.js +4 -1
  34. package/server/public/app/mention-autocomplete.mjs +298 -0
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/pwa-install.js +8 -3
  37. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  38. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  39. package/server/public/app/remote-relay-shared.mjs +85 -0
  40. package/server/public/app/remote-relays-settings.js +618 -0
  41. package/server/public/app/remote-relays-store.mjs +126 -0
  42. package/server/public/app/settings-modal.js +4 -2
  43. package/server/public/app/settings-tabs.js +1 -1
  44. package/server/public/app/socket-handlers.js +18 -1
  45. package/server/public/app/steered-turn-merge.mjs +10 -1
  46. package/server/public/app/thinking-follow.js +260 -0
  47. package/server/public/app/tool-activity-emoji.mjs +4 -0
  48. package/server/public/index.html +264 -11
  49. package/server/relay-tools.md +5 -1
  50. package/server/repositories/message-repository.mjs +7 -0
  51. package/server/repositories/question-repository.mjs +3 -0
  52. package/server/repositories/remote-relay-repository.mjs +75 -0
  53. package/server/repositories/session-repository.mjs +6 -1
  54. package/server/routes/ask-user-routes.mjs +42 -2
  55. package/server/routes/messages-routes.mjs +98 -19
  56. package/server/routes/remote-relay-routes.mjs +133 -0
  57. package/server/routes/sessions-routes.mjs +84 -31
  58. package/server/server-runtime.mjs +238 -0
  59. package/server/services/github-ci-activity-service.mjs +199 -0
  60. package/server/services/host-suspend-command.mjs +38 -0
  61. package/server/services/host-suspend-service.mjs +254 -0
  62. package/server/services/push-dispatch-service.mjs +37 -4
  63. package/server/services/remote-relay-caller-context.mjs +346 -0
  64. package/server/services/remote-relay-client.mjs +332 -0
  65. package/server/services/remote-relay-dispatcher.mjs +1743 -0
  66. package/server/services/remote-relay-inbound.mjs +187 -0
  67. package/server/services/remote-relay-pairing.mjs +189 -0
  68. package/server/services/remote-relay-registry-service.mjs +581 -0
  69. package/server/services/session-worker-launch-service.mjs +148 -14
  70. package/shared/remote-relay-contract.mjs +556 -0
  71. package/shared/remote-relay-mentions.mjs +129 -0
  72. package/shared/remote-relay-tool-core.mjs +314 -0
  73. package/shared/steer-settle-failure.mjs +6 -0
  74. package/shared/steer-settle-markers.mjs +16 -0
  75. package/shared/worker-runtime/api-client.mjs +112 -22
  76. package/shared/worker-runtime/worker-log-file.mjs +97 -0
@@ -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
+ }
@@ -41,6 +41,12 @@ const VARIANTS = Object.freeze({
41
41
  message: `This message was sent to ${label} just before you stopped the turn, so it was not answered.`,
42
42
  guidance: 'Resend it if you still want an answer.',
43
43
  }),
44
+ // Not a steer: delivered between turns and answered on a background
45
+ // continuation row (ANSWERED_ELSEWHERE_KIND in steer-settle-markers.mjs).
46
+ answeredElsewhere: (label) => ({
47
+ message: `${sentenceCase(label)} answered this message in a reply marked “background continuation” — check the replies next to it.`,
48
+ guidance: 'Resend it only if it went unanswered.',
49
+ }),
44
50
  // Pulled back out of the runtime's queue by the user (un-steer) — the
45
51
  // prompt never ran — but the relay could not be told in time.
46
52
  cancelled: (label) => ({
@@ -16,3 +16,19 @@ export const STEER_FOLDED_TEXT = '_(Handled together with the previous reply —
16
16
  * kind='stopped' — the stable handle the client keys its Resend on.
17
17
  */
18
18
  export const STEER_STOPPED_TEXT = '_(Stopped with the turn — not answered.)_';
19
+
20
+ /**
21
+ * Not a steer: a message delivered between turns whose answer the worker
22
+ * published on a background-continuation row, because it took the turn for one
23
+ * the CLI had opened by itself. Stamped with a kind of its own: it is a
24
+ * marker, not a reply (no completion push, muted in the transcript, never
25
+ * handed to a calling relay as the answer), and not a steer marker either,
26
+ * which would label the message "steered mid-turn".
27
+ *
28
+ * The wording names the reply by its badge, not by its position: a live
29
+ * append puts the marker below that reply, a reload anchors it under its own
30
+ * message and so above it. The worker infers all this from silence, hence the
31
+ * way out at the end.
32
+ */
33
+ export const ANSWERED_ELSEWHERE_KIND = 'answered-elsewhere';
34
+ export const DELIVERY_ANSWERED_ELSEWHERE_TEXT = '_(Answered in the reply marked “background continuation” next to this message. Resend the message if that reply does not answer it.)_';
@@ -1,33 +1,123 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+
4
+ // The error a non-2xx answer throws, the same for both transports:
5
+ // `HTTP <status> <path>: <detail>`, with `status`, `detail` and `body`.
6
+ function httpError(status, routePath, text) {
7
+ const rawText = String(text || "").trim();
8
+ let detail = rawText;
9
+ let payload = null;
10
+ if (rawText) {
11
+ try {
12
+ payload = JSON.parse(rawText);
13
+ detail = String(payload?.error || payload?.message || rawText).trim();
14
+ } catch {
15
+ detail = rawText;
16
+ }
17
+ }
18
+ const error = new Error(`HTTP ${status} ${routePath}${detail ? `: ${detail}` : ""}`);
19
+ error.status = status;
20
+ error.detail = detail;
21
+ // The parsed error body, for callers that pass the relay's answer on as
22
+ // is (a refused remote_relay call carries its code there).
23
+ error.body = payload && typeof payload === "object" ? payload : null;
24
+ return error;
25
+ }
26
+
27
+ function abortError(signal) {
28
+ const error = new Error("The operation was aborted");
29
+ error.name = "AbortError";
30
+ error.code = "ABORT_ERR";
31
+ if (signal?.reason !== undefined) error.cause = signal.reason;
32
+ return error;
33
+ }
34
+
35
+ /**
36
+ * One request over node:http(s) with no clock on it at all. Global fetch
37
+ * (undici) gives up when no response headers arrive within 300 s, and a
38
+ * long call's route sends nothing until it has its answer: a remote turn of
39
+ * up to 600 s, or an approval card that may sit for hours. So: its own
40
+ * connection (`agent: false` — the global agent carries a socket timeout),
41
+ * no request timeout, no body timeout. Only `signal` ends it early; the
42
+ * relay notices the closed request and stops waiting on its side too.
43
+ */
44
+ function requestWithoutTimeout({ url, method, headers, payload, signal }) {
45
+ return new Promise((resolve, reject) => {
46
+ if (signal?.aborted) {
47
+ reject(abortError(signal));
48
+ return;
49
+ }
50
+ const target = new URL(url);
51
+ const transport = target.protocol === "https:" ? https : http;
52
+ let settled = false;
53
+ let req = null;
54
+ const onAbort = () => {
55
+ const error = abortError(signal);
56
+ finish(error);
57
+ req?.destroy(error);
58
+ };
59
+ function finish(error, value) {
60
+ if (settled) return;
61
+ settled = true;
62
+ signal?.removeEventListener?.("abort", onAbort);
63
+ if (error) reject(error);
64
+ else resolve(value);
65
+ }
66
+ req = transport.request(target, {
67
+ method,
68
+ agent: false,
69
+ headers: {
70
+ ...headers,
71
+ ...(payload !== undefined ? { "Content-Length": Buffer.byteLength(payload) } : {}),
72
+ },
73
+ }, (res) => {
74
+ const chunks = [];
75
+ res.on("data", (chunk) => { chunks.push(chunk); });
76
+ res.on("end", () => finish(null, { status: res.statusCode, text: Buffer.concat(chunks).toString("utf8") }));
77
+ res.on("error", (error) => finish(error));
78
+ res.on("close", () => {
79
+ if (!res.complete) finish(new Error(`The relay closed the connection mid-answer (${url})`));
80
+ });
81
+ });
82
+ req.on("error", (error) => finish(error));
83
+ signal?.addEventListener?.("abort", onAbort, { once: true });
84
+ if (payload !== undefined) req.write(payload);
85
+ req.end();
86
+ });
87
+ }
88
+
1
89
  export function createApiClient({ serverUrl, token, getHeaders }) {
2
- return async function api(method, routePath, body) {
90
+ /**
91
+ * `options.longCall`: a call the relay may legitimately hold open for many
92
+ * minutes (the remote_relay tool) — it goes over node:http without any
93
+ * timeout instead of fetch. `options.signal` aborts either kind; an aborted
94
+ * call rejects with an AbortError.
95
+ */
96
+ return async function api(method, routePath, body, { longCall = false, signal = null } = {}) {
3
97
  const url = `${serverUrl}${routePath}`;
4
98
  const extraHeaders = typeof getHeaders === "function" ? (getHeaders() || {}) : {};
99
+ const headers = {
100
+ "Content-Type": "application/json",
101
+ Authorization: `Bearer ${token}`,
102
+ ...extraHeaders,
103
+ };
104
+ const payload = method !== "GET" ? JSON.stringify(body || {}) : undefined;
105
+
106
+ if (longCall) {
107
+ const res = await requestWithoutTimeout({ url, method, headers, payload, signal });
108
+ if (res.status < 200 || res.status > 299) throw httpError(res.status, routePath, res.text);
109
+ return JSON.parse(res.text);
110
+ }
111
+
5
112
  const opts = {
6
113
  method,
7
- headers: {
8
- "Content-Type": "application/json",
9
- Authorization: `Bearer ${token}`,
10
- ...extraHeaders,
11
- },
12
- ...(method !== "GET" ? { body: JSON.stringify(body || {}) } : {}),
114
+ headers,
115
+ ...(payload !== undefined ? { body: payload } : {}),
116
+ ...(signal ? { signal } : {}),
13
117
  };
14
-
15
118
  const res = await fetch(url, opts);
16
119
  if (!res.ok) {
17
- const rawText = String(await res.text().catch(() => "")).trim();
18
- let detail = rawText;
19
- if (rawText) {
20
- try {
21
- const payload = JSON.parse(rawText);
22
- detail = String(payload?.error || payload?.message || rawText).trim();
23
- } catch {
24
- detail = rawText;
25
- }
26
- }
27
- const error = new Error(`HTTP ${res.status} ${routePath}${detail ? `: ${detail}` : ""}`);
28
- error.status = res.status;
29
- error.detail = detail;
30
- throw error;
120
+ throw httpError(res.status, routePath, await res.text().catch(() => ""));
31
121
  }
32
122
  return res.json();
33
123
  };
@@ -0,0 +1,97 @@
1
+ // The worker log for launches that cannot redirect the worker's output.
2
+ //
3
+ // On Linux the launcher redirects a Node worker's stdout/stderr into
4
+ // `<log dir>/worker-<session id>.log` (`>> file 2>&1` under tmux, an inherited
5
+ // descriptor on the detached path). On Windows the worker runs in its own
6
+ // console window behind `cmd /c start`: no descriptor of the launcher reaches
7
+ // it, and a shell redirect would empty the window. So the launcher names the
8
+ // file in `COPILOT_WEB_RELAY_WORKER_LOG_FILE` and the worker copies what it
9
+ // writes to stdout/stderr into it, while the console keeps showing the same
10
+ // lines.
11
+ //
12
+ // Out of reach from inside the process: what Node prints natively (a V8 fatal
13
+ // error such as heap exhaustion, a module that fails to load before this is
14
+ // installed) and what a child process writes to an inherited console.
15
+ import fs from 'fs';
16
+
17
+ export const WORKER_LOG_FILE_ENV = 'COPILOT_WEB_RELAY_WORKER_LOG_FILE';
18
+
19
+ // Colours are for the console; the Linux file never has them, because there
20
+ // stdout is not a terminal.
21
+ const ANSI_STYLE_PATTERN = /\u001b\[[0-9;]*m/g;
22
+
23
+ /**
24
+ * Copies stdout/stderr into the log file the launcher named. Returns null when
25
+ * no file was named or it cannot be opened: best-effort like the launcher's
26
+ * side, a log problem must never stop a worker.
27
+ */
28
+ export function installWorkerLogFile({
29
+ env = process.env,
30
+ processImpl = process,
31
+ fsImpl = fs,
32
+ } = {}) {
33
+ const logPath = String(env?.[WORKER_LOG_FILE_ENV] || '').trim();
34
+ // Not inherited: the provider CLI, the agent's shells and anything started
35
+ // from them must not write into this worker's log.
36
+ if (env && WORKER_LOG_FILE_ENV in env) delete env[WORKER_LOG_FILE_ENV];
37
+ if (!logPath) return null;
38
+
39
+ let fd = null;
40
+ try {
41
+ fd = fsImpl.openSync(logPath, 'a');
42
+ } catch {
43
+ return null;
44
+ }
45
+
46
+ // Synchronous on purpose: the last lines before a process.exit() are the
47
+ // ones an incident needs.
48
+ const append = (chunk, encoding) => {
49
+ if (fd === null) return;
50
+ try {
51
+ if (typeof chunk === 'string') {
52
+ const text = chunk.replace(ANSI_STYLE_PATTERN, '');
53
+ if (text) fsImpl.writeSync(fd, text, null, typeof encoding === 'string' ? encoding : 'utf8');
54
+ } else if (chunk instanceof Uint8Array) {
55
+ fsImpl.writeSync(fd, chunk);
56
+ }
57
+ } catch {
58
+ // A file that stopped taking writes (disk full, handle gone) is given up.
59
+ try { fsImpl.closeSync(fd); } catch {}
60
+ fd = null;
61
+ }
62
+ };
63
+
64
+ const restores = [];
65
+ for (const stream of [processImpl.stdout, processImpl.stderr]) {
66
+ if (!stream || typeof stream.write !== 'function') continue;
67
+ const originalWrite = stream.write;
68
+ const teeWrite = function teeWrite(chunk, encoding, callback) {
69
+ append(chunk, encoding);
70
+ return originalWrite.call(this, chunk, encoding, callback);
71
+ };
72
+ stream.write = teeWrite;
73
+ restores.push(() => {
74
+ if (stream.write === teeWrite) stream.write = originalWrite;
75
+ });
76
+ }
77
+
78
+ // Without an uncaughtException handler Node prints the error natively, past
79
+ // the streams above. With one (the worker crash guard) the handler logs it.
80
+ const onUncaught = (error, origin) => {
81
+ if (processImpl.listenerCount('uncaughtException') > 0) return;
82
+ append(`${origin || 'uncaughtException'}: ${error?.stack || error?.message || error}\n`);
83
+ };
84
+ processImpl.on?.('uncaughtExceptionMonitor', onUncaught);
85
+
86
+ return {
87
+ logPath,
88
+ uninstall() {
89
+ for (const restore of restores) restore();
90
+ processImpl.off?.('uncaughtExceptionMonitor', onUncaught);
91
+ if (fd !== null) {
92
+ try { fsImpl.closeSync(fd); } catch {}
93
+ fd = null;
94
+ }
95
+ },
96
+ };
97
+ }