@oar-sh/oar 0.9.5 → 0.9.7

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 (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -10,10 +10,19 @@
10
10
  // { listRelays, selfNames, inboundEnabled, recordUnlock, describeRelay, repository }
11
11
  // Every helper treats a missing one as "feature absent": nothing is remote,
12
12
  // nothing unlocks, and the routes behave exactly as before.
13
+ //
14
+ // An agent of THIS relay reaches the same routes through the in-process
15
+ // loopback (remote-relay-loopback.mjs), which marks its requests here. Such a
16
+ // request is an agent's like any other relay's (provenance is stored, approval
17
+ // cards stay hidden, nothing unlocks), with two differences: the inbound
18
+ // switch does not apply to it (the local target has its own setting, which the
19
+ // dispatcher checks), and only it may store an origin with `local: true`.
13
20
 
14
21
  import {
15
22
  REMOTE_RELAY_ERROR_CODES,
16
23
  REMOTE_RELAY_HEADERS,
24
+ REMOTE_RELAY_LOCAL_ALIAS,
25
+ REMOTE_RELAY_LOCAL_ID,
17
26
  REMOTE_RELAY_TOOL_NAME,
18
27
  normalizeRemoteRelayOrigin,
19
28
  stripRemotePromptHeader,
@@ -25,6 +34,19 @@ export const REMOTE_INBOUND_DISABLED_RESPONSE = Object.freeze({
25
34
  code: REMOTE_RELAY_ERROR_CODES.inboundDisabled,
26
35
  });
27
36
 
37
+ // Requests the loopback built. Membership cannot be claimed over HTTP.
38
+ const loopbackRequests = new WeakSet();
39
+
40
+ /** Marks a request object as built in-process for an agent of this relay. */
41
+ export function markRemoteRelayLoopbackRequest(req) {
42
+ if (req && typeof req === 'object') loopbackRequests.add(req);
43
+ return req;
44
+ }
45
+
46
+ export function isRemoteRelayLoopbackRequest(req) {
47
+ return !!req && typeof req === 'object' && loopbackRequests.has(req);
48
+ }
49
+
28
50
  function headerValue(req, name) {
29
51
  const value = req?.headers?.[name];
30
52
  return String((Array.isArray(value) ? value[0] : value) ?? '').trim();
@@ -44,8 +66,21 @@ function headerHops(req) {
44
66
  * built from the header (its value is the sender's relay id), so a turn an
45
67
  * agent started is always recognisable as one. The hop count is the larger of
46
68
  * the body's and the X-OAR-Remote-Hops header's.
69
+ * `local` is true for a request of this relay's own agent (the loopback);
70
+ * an origin's `local` flag is kept for those alone.
47
71
  */
48
72
  export function readRemoteRelayRequest(req, inbound, { body = req?.body } = {}) {
73
+ const facts = readRemoteRelayRequestFacts(req, inbound, body);
74
+ if (!facts.remote) return facts;
75
+ const local = isRemoteRelayLoopbackRequest(req);
76
+ if (facts.origin?.local && !local) {
77
+ const { local: _claimed, ...origin } = facts.origin;
78
+ return { remote: true, origin };
79
+ }
80
+ return local ? { ...facts, local: true } : facts;
81
+ }
82
+
83
+ function readRemoteRelayRequestFacts(req, inbound, body) {
49
84
  if (!inbound) return { remote: false, origin: null };
50
85
  const rawOrigin = body && typeof body === 'object' ? body.origin : undefined;
51
86
  const senderRelayId = headerValue(req, REMOTE_RELAY_HEADERS.origin);
@@ -78,7 +113,9 @@ export function remoteRelayInboundEnabled(inbound) {
78
113
  */
79
114
  export function admitRemoteRelayRequest(req, res, inbound) {
80
115
  const request = readRemoteRelayRequest(req, inbound);
81
- if (!request.remote || remoteRelayInboundEnabled(inbound)) return request;
116
+ // The switch is about OTHER relays' agents; this relay's own are let in by
117
+ // the agent-sessions setting, before the loopback is ever reached.
118
+ if (!request.remote || request.local || remoteRelayInboundEnabled(inbound)) return request;
82
119
  res.status(403).json({ ...REMOTE_INBOUND_DISABLED_RESPONSE });
83
120
  return null;
84
121
  }
@@ -161,6 +198,51 @@ export function formatRemoteRelayMentionHint(relays = []) {
161
198
  return `<system_reminder>The user mentioned ${subject}. The ${REMOTE_RELAY_TOOL_NAME} tool can list, read, prompt and create sessions there.</system_reminder>`;
162
199
  }
163
200
 
201
+ /**
202
+ * The paired relay a remote prompt came from: `{ relayId, name }` (`relayId`
203
+ * is the local registry id), or null when the sender is not paired here or
204
+ * names no relay id. For a prompt from an agent of this relay itself:
205
+ * `{ relayId, name, local: true }` with the local target's id.
206
+ */
207
+ export function findOriginRemoteRelay(inbound, origin) {
208
+ if (inbound && origin?.local === true) {
209
+ return { relayId: REMOTE_RELAY_LOCAL_ID, name: String(origin.relayName || '').trim() || REMOTE_RELAY_LOCAL_ALIAS, local: true };
210
+ }
211
+ const instance = String(origin?.relayId || '').trim();
212
+ if (!inbound || typeof inbound.listRelays !== 'function' || !instance) return null;
213
+ try {
214
+ const listed = inbound.listRelays();
215
+ const relay = (Array.isArray(listed) ? listed : [])
216
+ .find((entry) => String(entry?.relayId || '').trim() === instance);
217
+ return relay ? { relayId: String(relay.id || ''), name: String(relay.name || relay.id || '') } : null;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * The reminder the agent gets with a prompt another relay's agent sent: that
225
+ * relay is open to this conversation, and where the sender's session is. ''
226
+ * when the sender is not a paired relay. Like the mention hint, it rides in
227
+ * the queued prompt only.
228
+ */
229
+ export function formatRemoteRelayOriginHint(relay, origin) {
230
+ const name = hintText(relay?.name);
231
+ if (!name) return '';
232
+ if (relay?.local === true) {
233
+ const title = hintText(origin?.conversationTitle, 80);
234
+ return `<system_reminder>This prompt came from an agent in another session on this relay${title ? ` ("${title}")` : ''}, which started or prompted this session for a part of its own task. `
235
+ + 'Your reply to it is handed back to that agent by itself, so end with the result it needs. '
236
+ + 'You see nothing of its conversation: work from the prompt alone.</system_reminder>';
237
+ }
238
+ const session = hintText(origin?.conversationId, 80);
239
+ const where = session ? `relay "${name}", session "${session}"` : `relay "${name}"`;
240
+ return `<system_reminder>This prompt came from an agent on the paired OAR relay "${name}". `
241
+ + 'Your reply to it is handed back to that agent by itself. '
242
+ + `For more than the reply (reading its session, a question, a later report) the ${REMOTE_RELAY_TOOL_NAME} tool is open for that relay in this conversation: ${where}. `
243
+ + 'A prompt you send there while its turn is still running is steered into that turn.</system_reminder>';
244
+ }
245
+
164
246
  /** The queued prompt with the mention hint after the user's text. */
165
247
  export function withRemoteRelayMentionHint(promptText, hint) {
166
248
  const text = String(promptText ?? '');
@@ -0,0 +1,245 @@
1
+ 'use strict';
2
+
3
+ import { IncomingMessage, ServerResponse } from 'node:http';
4
+ import { Duplex } from 'node:stream';
5
+
6
+ import {
7
+ REMOTE_RELAY_ERROR_CODES,
8
+ REMOTE_RELAY_HEADERS,
9
+ REMOTE_RELAY_LIMITS,
10
+ } from '../../shared/remote-relay-contract.mjs';
11
+ import { RemoteRelayError, remoteRelayHttpError } from './remote-relay-client.mjs';
12
+ import { markRemoteRelayLoopbackRequest } from './remote-relay-inbound.mjs';
13
+
14
+ // The client for the local target: this relay as a relay its own agents work
15
+ // on. It has the outbound client's `request(relay, method, path, options)`,
16
+ // but instead of opening a connection it hands the request to the relay's own
17
+ // HTTP handler in-process. So a session an agent creates here goes through the
18
+ // very routes a paired relay's request reaches (bootstrap, message,
19
+ // conversation, questions, cancel-turn, archive) with their validation, their
20
+ // provenance and their events, and there is no second implementation of what a
21
+ // created session is or how reading, stopping and answering work.
22
+ //
23
+ // The request carries what the outbound client sends: the bearer token (the
24
+ // routes sit behind `auth`), Accept, and the two x-oar-remote-* headers, so the
25
+ // routes treat it as an agent's request (approval cards stay hidden, nothing
26
+ // unlocks, the composer draft is left alone). It is also marked as built here
27
+ // (markRemoteRelayLoopbackRequest), which is what lets it past the inbound
28
+ // switch and lets its origin say `local: true`; nothing sent over HTTP can
29
+ // claim that.
30
+
31
+ function toText(value) {
32
+ return String(value ?? '').trim();
33
+ }
34
+
35
+ function isPlainObject(value) {
36
+ return !!value && typeof value === 'object' && !Array.isArray(value);
37
+ }
38
+
39
+ function relayLabel(relay) {
40
+ const name = toText(relay?.name);
41
+ return name ? `Relay "${name}"` : 'This relay';
42
+ }
43
+
44
+ function buildUrl(path, query) {
45
+ const route = String(path || '');
46
+ if (!route.startsWith('/') || route.startsWith('//')) {
47
+ throw new RemoteRelayError(REMOTE_RELAY_ERROR_CODES.invalidInput, `Invalid path: ${route.slice(0, 80)}`);
48
+ }
49
+ const params = new URLSearchParams();
50
+ if (isPlainObject(query)) {
51
+ for (const [key, value] of Object.entries(query)) {
52
+ if (value === undefined || value === null) continue;
53
+ params.set(key, String(value));
54
+ }
55
+ }
56
+ const search = params.toString();
57
+ return search ? `${route}?${search}` : route;
58
+ }
59
+
60
+ /** A socket that goes nowhere: the response's bytes are read off the response itself. */
61
+ function createNullSocket() {
62
+ const socket = new Duplex({
63
+ read() {},
64
+ write(_chunk, _encoding, callback) { callback(); },
65
+ });
66
+ socket.remoteAddress = '127.0.0.1';
67
+ socket.setTimeout = () => socket;
68
+ socket.setNoDelay = () => socket;
69
+ socket.setKeepAlive = () => socket;
70
+ return socket;
71
+ }
72
+
73
+ function toBuffer(chunk, encoding) {
74
+ if (chunk === undefined || chunk === null || typeof chunk === 'function') return null;
75
+ if (Buffer.isBuffer(chunk)) return chunk;
76
+ if (chunk instanceof Uint8Array) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
77
+ return Buffer.from(String(chunk), typeof encoding === 'string' ? encoding : 'utf8');
78
+ }
79
+
80
+ export function createRemoteRelayLoopbackClient({
81
+ // The relay's own request handler: `(req, res) => void` (the Express app).
82
+ handle,
83
+ getOwnRelayId = () => '',
84
+ getOwnToken = () => '',
85
+ timeoutMs: defaultTimeoutMs = REMOTE_RELAY_LIMITS.requestTimeoutMs,
86
+ maxBytes = REMOTE_RELAY_LIMITS.responseMaxBytes,
87
+ setTimeoutImpl = setTimeout,
88
+ clearTimeoutImpl = clearTimeout,
89
+ } = {}) {
90
+ /** Runs one request through the handler. Resolves `{ status, text }`. */
91
+ function inject({ method, url, headers, body, hasBody, timeoutMs }) {
92
+ return new Promise((resolve, reject) => {
93
+ const socket = createNullSocket();
94
+ const req = new IncomingMessage(socket);
95
+ req.method = method;
96
+ req.url = url;
97
+ req.headers = headers;
98
+ req.rawHeaders = Object.entries(headers).flat();
99
+ req.httpVersionMajor = 1;
100
+ req.httpVersionMinor = 1;
101
+ req.httpVersion = '1.1';
102
+ if (hasBody) {
103
+ // What the wire would deliver: JSON, parsed. `_body` tells the JSON
104
+ // body parser the body is already there.
105
+ req.body = JSON.parse(JSON.stringify(body));
106
+ req._body = true;
107
+ }
108
+ req.complete = true;
109
+ req.push(null);
110
+ markRemoteRelayLoopbackRequest(req);
111
+
112
+ const res = new ServerResponse(req);
113
+ res.assignSocket(socket);
114
+ // An HTTP server forwards its socket's drain to the response; a handler
115
+ // that streams its answer waits for it.
116
+ socket.on('drain', () => res.emit('drain'));
117
+ const chunks = [];
118
+ let total = 0;
119
+ let settled = false;
120
+ let timer = null;
121
+ const finish = (error, value) => {
122
+ if (settled) return;
123
+ settled = true;
124
+ if (timer) clearTimeoutImpl(timer);
125
+ try { res.detachSocket(socket); } catch {}
126
+ try { socket.destroy(); } catch {}
127
+ if (error) reject(error);
128
+ else resolve(value);
129
+ };
130
+ const collect = (chunk, encoding) => {
131
+ const buffer = toBuffer(chunk, encoding);
132
+ if (!buffer || !buffer.length) return;
133
+ total += buffer.length;
134
+ if (total <= maxBytes) chunks.push(buffer);
135
+ };
136
+ const write = res.write;
137
+ const end = res.end;
138
+ res.write = function writeCollected(chunk, encoding, callback) {
139
+ collect(chunk, encoding);
140
+ return write.call(this, chunk, encoding, callback);
141
+ };
142
+ res.end = function endCollected(chunk, encoding, callback) {
143
+ collect(chunk, encoding);
144
+ return end.call(this, chunk, encoding, callback);
145
+ };
146
+ res.once('finish', () => {
147
+ if (total > maxBytes) {
148
+ finish(new RemoteRelayError(
149
+ REMOTE_RELAY_ERROR_CODES.unsupported,
150
+ `This relay's answer is larger than ${maxBytes} bytes`,
151
+ { detail: 'response too large' },
152
+ ));
153
+ return;
154
+ }
155
+ finish(null, { status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') });
156
+ });
157
+ res.once('error', (error) => finish(error));
158
+
159
+ if (timeoutMs > 0) {
160
+ timer = setTimeoutImpl(() => finish(new RemoteRelayError(
161
+ REMOTE_RELAY_ERROR_CODES.offline,
162
+ `This relay did not answer its own request within ${Math.round(timeoutMs / 100) / 10} s`,
163
+ { detail: 'loopback timeout' },
164
+ )), timeoutMs);
165
+ timer?.unref?.();
166
+ }
167
+
168
+ try {
169
+ handle(req, res);
170
+ } catch (error) {
171
+ finish(error);
172
+ }
173
+ });
174
+ }
175
+
176
+ /**
177
+ * One call to this relay's own API. `relay` is the dispatcher's local target
178
+ * (only its name is used, for messages). Resolves to the parsed JSON body
179
+ * (null when empty); rejects with a RemoteRelayError, like the outbound
180
+ * client's request.
181
+ */
182
+ async function request(relay, method, path, { body, query, hops = 0, timeoutMs } = {}) {
183
+ if (typeof handle !== 'function') {
184
+ throw new RemoteRelayError(REMOTE_RELAY_ERROR_CODES.unsupported, 'This relay cannot call its own API', { detail: 'no handler' });
185
+ }
186
+ const token = toText(getOwnToken?.());
187
+ if (!token) {
188
+ throw new RemoteRelayError(REMOTE_RELAY_ERROR_CODES.unauthorized, 'This relay has no token configured');
189
+ }
190
+ const hopCount = Number.isInteger(Number(hops)) && Number(hops) >= 0 ? Number(hops) : 0;
191
+ const hasBody = body !== undefined;
192
+ const headers = {
193
+ authorization: `Bearer ${token}`,
194
+ accept: 'application/json',
195
+ host: 'localhost',
196
+ [REMOTE_RELAY_HEADERS.origin]: toText(getOwnRelayId?.()) || 'self',
197
+ [REMOTE_RELAY_HEADERS.hops]: String(hopCount),
198
+ ...(hasBody ? { 'content-type': 'application/json' } : {}),
199
+ };
200
+ const effectiveTimeoutMs = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
201
+ ? Number(timeoutMs)
202
+ : defaultTimeoutMs;
203
+
204
+ let answer;
205
+ try {
206
+ answer = await inject({
207
+ method: String(method || 'GET').toUpperCase(),
208
+ url: buildUrl(path, query),
209
+ headers,
210
+ body,
211
+ hasBody,
212
+ timeoutMs: effectiveTimeoutMs,
213
+ });
214
+ } catch (error) {
215
+ if (error instanceof RemoteRelayError) throw error;
216
+ throw new RemoteRelayError(
217
+ REMOTE_RELAY_ERROR_CODES.internal,
218
+ `${relayLabel(relay)} failed on its own request: ${toText(error?.message || error).slice(0, 200)}`,
219
+ { detail: 'loopback failure' },
220
+ );
221
+ }
222
+
223
+ const status = Number(answer.status) || 0;
224
+ let parsed = null;
225
+ let json = true;
226
+ if (answer.text.trim()) {
227
+ try {
228
+ parsed = JSON.parse(answer.text);
229
+ } catch {
230
+ json = false;
231
+ }
232
+ }
233
+ if (status < 200 || status >= 300) throw remoteRelayHttpError(relay, status, parsed);
234
+ if (!json) {
235
+ throw new RemoteRelayError(
236
+ REMOTE_RELAY_ERROR_CODES.unsupported,
237
+ `${relayLabel(relay)} did not answer its own request with JSON`,
238
+ { status, detail: 'not json' },
239
+ );
240
+ }
241
+ return parsed;
242
+ }
243
+
244
+ return { request };
245
+ }
@@ -3,6 +3,7 @@
3
3
  import { randomUUID as nodeRandomUUID } from 'crypto';
4
4
 
5
5
  import {
6
+ AGENT_SESSIONS_SOCKET_EVENT,
6
7
  REMOTE_RELAY_LIMITS,
7
8
  REMOTE_RELAY_PERMISSIONS,
8
9
  REMOTE_RELAY_PROTOCOL,
@@ -11,6 +12,7 @@ import {
11
12
  REMOTE_RELAY_ERROR_CODES,
12
13
  checkRemoteRelayUrlPolicy,
13
14
  normalizeRemoteRelayLink,
15
+ normalizeRemoteRelayMaxWaitSeconds,
14
16
  normalizeRemoteRelayPermission,
15
17
  } from '../../shared/remote-relay-contract.mjs';
16
18
  import { remoteRelayAliases } from '../../shared/remote-relay-mentions.mjs';
@@ -23,6 +25,9 @@ import { remoteRelayAliases } from '../../shared/remote-relay-mentions.mjs';
23
25
  // remote_relays the entries, tokens included
24
26
  // relay_public_url how other relays reach this one
25
27
  // remote_relay_inbound_enabled accept prompts from other relays' agents
28
+ // agent_sessions_enabled this relay's own agents may start and use
29
+ // sessions on it (the local target; off by default)
30
+ // remote_relay_max_wait_seconds the longest wait of one remote_relay call
26
31
  //
27
32
  // Tokens never leave through listPublic(), the socket event or any route; only
28
33
  // the outbound client reads them (list()/get()/resolve() are server-internal).
@@ -260,6 +265,51 @@ export function createRemoteRelayRegistry({
260
265
  return { ok: true, ...getSelfSettings() };
261
266
  }
262
267
 
268
+ // ─── Agent sessions ────────────────────────────────────────────────────────
269
+
270
+ /** The payload of GET /api/settings/agent-sessions and of the socket event. */
271
+ function getAgentSessionsSettings() {
272
+ const enabled = readJsonSetting(REMOTE_RELAY_SETTING_KEYS.agentSessionsEnabled);
273
+ return {
274
+ enabled: enabled === true || enabled === 1 || ['true', '1', 'on'].includes(toText(enabled).toLowerCase()),
275
+ maxWaitSeconds: normalizeRemoteRelayMaxWaitSeconds(readJsonSetting(REMOTE_RELAY_SETTING_KEYS.maxWaitSeconds)),
276
+ limits: {
277
+ minWaitSeconds: REMOTE_RELAY_LIMITS.waitMaxFloorSeconds,
278
+ maxWaitSeconds: REMOTE_RELAY_LIMITS.waitMaxCeilingSeconds,
279
+ stepSeconds: REMOTE_RELAY_LIMITS.waitMaxStepSeconds,
280
+ },
281
+ maxActiveSessions: REMOTE_RELAY_LIMITS.localActiveSessions,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * `{ enabled?, maxWaitSeconds? }`. Nothing is rounded or clamped here: a
287
+ * value outside the range, or off the slider's step, is refused, so what the
288
+ * settings page shows is what was stored.
289
+ */
290
+ function setAgentSessionsSettings(patch = {}) {
291
+ const input = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
292
+ if (input.enabled !== undefined && typeof input.enabled !== 'boolean') {
293
+ return invalid('enabled must be true or false');
294
+ }
295
+ if (input.maxWaitSeconds !== undefined) {
296
+ const { waitMaxFloorSeconds: min, waitMaxCeilingSeconds: max, waitMaxStepSeconds: step } = REMOTE_RELAY_LIMITS;
297
+ const seconds = input.maxWaitSeconds;
298
+ if (typeof seconds !== 'number' || !Number.isInteger(seconds) || seconds < min || seconds > max || seconds % step !== 0) {
299
+ return invalid(`maxWaitSeconds must be a whole number of seconds from ${min} to ${max}, in steps of ${step}`);
300
+ }
301
+ }
302
+ if (input.enabled !== undefined) writeJsonSetting(REMOTE_RELAY_SETTING_KEYS.agentSessionsEnabled, input.enabled);
303
+ if (input.maxWaitSeconds !== undefined) writeJsonSetting(REMOTE_RELAY_SETTING_KEYS.maxWaitSeconds, input.maxWaitSeconds);
304
+ const settings = getAgentSessionsSettings();
305
+ try {
306
+ emit(AGENT_SESSIONS_SOCKET_EVENT, settings);
307
+ } catch (error) {
308
+ logger?.warn?.(`[remote-relays] agent sessions event failed: ${error?.message || error}`);
309
+ }
310
+ return { ok: true, ...settings };
311
+ }
312
+
263
313
  // ─── Remote relays ─────────────────────────────────────────────────────────
264
314
 
265
315
  function list() {
@@ -562,6 +612,8 @@ export function createRemoteRelayRegistry({
562
612
  selfIdentity,
563
613
  getSelfSettings,
564
614
  setSelfSettings,
615
+ getAgentSessionsSettings,
616
+ setAgentSessionsSettings,
565
617
  list,
566
618
  listPublic,
567
619
  get,
@@ -160,6 +160,23 @@ export function applyGrokProviderEnvironment(env = {}, {
160
160
  return next;
161
161
  }
162
162
 
163
+ export function applyClaudeCloudProviderEnvironment(env = {}, {
164
+ enabled = false,
165
+ model = '',
166
+ } = {}) {
167
+ const next = { ...env };
168
+ if (resolveWorkerKind(next) === 'claude-cloud') delete next.COPILOT_WEB_RELAY_WORKER_KIND;
169
+ delete next.CLAUDE_CLOUD_RELAY_MODEL;
170
+ if (!enabled) return next;
171
+ // No credential is added here: the worker reads the Claude CLI's login
172
+ // itself. A relay that runs on CLAUDE_CODE_OAUTH_TOKEN instead has it in its
173
+ // own environment; workerSecretEnvVarsFor hands that to the cloud worker.
174
+ next.COPILOT_WEB_RELAY_WORKER_KIND = 'claude-cloud';
175
+ const normalizedModel = normalizeText(model);
176
+ if (normalizedModel) next.CLAUDE_CLOUD_RELAY_MODEL = normalizedModel;
177
+ return next;
178
+ }
179
+
163
180
  /**
164
181
  * The Copilot SDK engine: the same `github`/`openai` providers the extension
165
182
  * serves, run as a plain Node worker against the CLI's headless runtime.
@@ -245,6 +262,10 @@ export function isGrokWorkerEnvironment(env = {}) {
245
262
  return resolveWorkerKind(env) === 'grok';
246
263
  }
247
264
 
265
+ export function isClaudeCloudWorkerEnvironment(env = {}) {
266
+ return resolveWorkerKind(env) === 'claude-cloud';
267
+ }
268
+
248
269
  export function isCopilotSdkWorkerEnvironment(env = {}) {
249
270
  return resolveWorkerKind(env) === 'copilot-sdk';
250
271
  }
@@ -279,6 +300,16 @@ export function resolveGrokWorkerScriptPath(env = {}) {
279
300
  return path.join(process.cwd(), 'server', 'grok-worker', 'grok-session-worker.mjs');
280
301
  }
281
302
 
303
+ export function resolveClaudeCloudWorkerScriptPath(env = {}) {
304
+ const explicit = normalizeText(env?.COPILOT_WEB_RELAY_CLAUDE_CLOUD_WORKER_PATH);
305
+ if (explicit) return explicit;
306
+ const repoRoot = normalizeText(env?.COPILOT_WEB_RELAY_ROOT);
307
+ if (repoRoot) return path.join(repoRoot, 'server', 'claude-cloud-worker', 'claude-cloud-session-worker.mjs');
308
+ const serverDir = normalizeText(env?.COPILOT_WEB_RELAY_SERVER_DIR);
309
+ if (serverDir) return path.join(serverDir, 'claude-cloud-worker', 'claude-cloud-session-worker.mjs');
310
+ return path.join(process.cwd(), 'server', 'claude-cloud-worker', 'claude-cloud-session-worker.mjs');
311
+ }
312
+
282
313
  export function resolveCopilotSdkWorkerScriptPath(env = {}) {
283
314
  const explicit = normalizeText(env?.COPILOT_WEB_RELAY_COPILOT_SDK_WORKER_PATH);
284
315
  if (explicit) return explicit;
@@ -293,11 +324,16 @@ export function resolveCopilotSdkWorkerScriptPath(env = {}) {
293
324
  // `copilot` kind is intentionally absent: that is the extension engine, whose
294
325
  // launch path (the real TUI under a `script` pseudo-TTY) must stay exactly
295
326
  // as-is. `copilot-sdk` is the SAME provider on the headless SDK engine, and it
296
- // belongs here with Claude/Cursor/Grok.
327
+ // belongs here with Claude/Cursor/Grok. `claude-cloud` is a provider of its
328
+ // own: its worker follows a session in Anthropic's cloud and runs no CLI.
297
329
  const NODE_WORKER_DESCRIPTORS = Object.freeze({
298
330
  claude: Object.freeze({ resolveScriptPath: resolveClaudeWorkerScriptPath, windowsTitle: 'Claude Worker' }),
299
331
  cursor: Object.freeze({ resolveScriptPath: resolveCursorWorkerScriptPath, windowsTitle: 'Cursor Worker' }),
300
332
  grok: Object.freeze({ resolveScriptPath: resolveGrokWorkerScriptPath, windowsTitle: 'Grok Worker' }),
333
+ 'claude-cloud': Object.freeze({
334
+ resolveScriptPath: resolveClaudeCloudWorkerScriptPath,
335
+ windowsTitle: 'Claude Cloud Worker',
336
+ }),
301
337
  'copilot-sdk': Object.freeze({
302
338
  resolveScriptPath: resolveCopilotSdkWorkerScriptPath,
303
339
  windowsTitle: 'Copilot SDK Worker',
@@ -472,14 +508,38 @@ export function getTmuxPanePid(sessionName, {
472
508
  }
473
509
  }
474
510
 
475
- export const WORKER_SECRET_ENV_VARS = Object.freeze(['COPILOT_PROVIDER_API_KEY', 'CURSOR_API_KEY']);
511
+ export const WORKER_SECRET_ENV_VARS = Object.freeze(['COPILOT_PROVIDER_API_KEY', 'CURSOR_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']);
512
+
513
+ // Secrets that are not every worker's. The two API keys are put into the
514
+ // launch environment by their own provider's applier and cleared for every
515
+ // other one, so they need no entry. The Claude login token is different: it
516
+ // sits in the relay's own environment (a relay run on CLAUDE_CODE_OAUTH_TOKEN
517
+ // instead of the CLI's credentials file), so it would otherwise travel with
518
+ // every launch. Only the Claude Cloud worker gets it: its cloud client reads
519
+ // the login from there exactly as the relay's does (credentials.mjs).
520
+ const WORKER_SECRET_ENV_VAR_KINDS = Object.freeze({
521
+ CLAUDE_CODE_OAUTH_TOKEN: Object.freeze(['claude-cloud']),
522
+ });
523
+
524
+ /**
525
+ * The secret variables the worker of this launch environment is handed, and
526
+ * only through the secret env file: never as a tmux `export`, never in the
527
+ * environment tmux itself is started with.
528
+ */
529
+ export function workerSecretEnvVarsFor(env = {}) {
530
+ const kind = resolveWorkerKind(env);
531
+ return WORKER_SECRET_ENV_VARS.filter((key) => {
532
+ const kinds = WORKER_SECRET_ENV_VAR_KINDS[key];
533
+ return !kinds || kinds.includes(kind);
534
+ });
535
+ }
476
536
 
477
537
  export function createWorkerSecretEnvFile(env = {}, {
478
538
  fsImpl = fs,
479
539
  tempRoot = os.tmpdir(),
480
540
  } = {}) {
481
541
  const secretLines = [];
482
- for (const key of WORKER_SECRET_ENV_VARS) {
542
+ for (const key of workerSecretEnvVarsFor(env)) {
483
543
  const value = String(env?.[key] || '').trim();
484
544
  if (!value) continue;
485
545
  secretLines.push(`export ${key}=${shellQuote(value)}\n`);
@@ -555,7 +615,7 @@ export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
555
615
  ...env,
556
616
  SESSION_ID: String(targetSessionId || '').trim() || String(env?.SESSION_ID || '').trim(),
557
617
  };
558
- const hasSecretEnvValue = WORKER_SECRET_ENV_VARS
618
+ const hasSecretEnvValue = workerSecretEnvVarsFor(launchEnv)
559
619
  .some((key) => String(launchEnv[key] || '').trim());
560
620
  const normalizedSecretEnvFilePath = String(secretEnvFilePath || '').trim();
561
621
  if (hasSecretEnvValue && !normalizedSecretEnvFilePath) {
@@ -602,6 +662,16 @@ export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
602
662
  // counterpart, has always been on this list.
603
663
  'CLAUDE_CODE_EXECUTABLE',
604
664
  'CLAUDE_RELAY_MODEL',
665
+ // The Claude Cloud worker's model. Its login is not listed here: the
666
+ // worker reads the CLI's credentials file (CLAUDE_CONFIG_DIR above tells
667
+ // it where), and a relay-level CLAUDE_CODE_OAUTH_TOKEN is a secret
668
+ // (workerSecretEnvVarsFor), so it comes through the secret file.
669
+ 'CLAUDE_CLOUD_RELAY_MODEL',
670
+ // Tests point the relay's cloud client at a fake API on this machine;
671
+ // the worker's client has to follow, or the relay would talk to the fake
672
+ // and its worker to the real API. Not a secret, and harmless as a value:
673
+ // shared/claude-cloud/base-url.mjs ignores everything but a loopback URL.
674
+ 'OAR_CLAUDE_CLOUD_API_BASE_URL',
605
675
  'CURSOR_RELAY_MODEL',
606
676
  'CURSOR_AGENT_STORE_DIR',
607
677
  'GROK_RELAY_MODEL',
@@ -688,10 +758,30 @@ export async function launchSessionCli({
688
758
  // Windows console launch: resolves (and rotates) the worker log the worker
689
759
  // then writes itself; returns its path or null.
690
760
  prepareWorkerLogFileImpl = prepareWorkerLogFile,
761
+ // Told when the process list could not be read (the error); the launch
762
+ // goes on without it.
763
+ onProcessListError = null,
691
764
  } = {}) {
692
765
  const target = String(targetSessionId || '').trim();
693
766
  if (!target) throw new Error('missing-target-session-id');
694
767
 
768
+ // On win32 the process list comes from PowerShell and can be unreadable for
769
+ // a reason that has nothing to do with this session (2026-10-01: a control
770
+ // byte in another process's command line, for an hour). A launch is not
771
+ // stopped by that: an unreadable list counts as "no process found".
772
+ // The asynchronous finder reads the list off the relay's thread (shared
773
+ // between concurrent callers, cached for a moment); `fresh` is for the poll
774
+ // that waits for the process this launch just started.
775
+ const findLiveProcess = async ({ fresh = false } = {}) => {
776
+ if (typeof processInspector?.findProcessForSessionAsync !== 'function') return null;
777
+ try {
778
+ return (await processInspector.findProcessForSessionAsync(target, { fresh })) || null;
779
+ } catch (error) {
780
+ try { onProcessListError?.(error); } catch {}
781
+ return null;
782
+ }
783
+ };
784
+
695
785
  // Kill switch for test/e2e servers: a relay started with this env var must
696
786
  // never spawn real session workers (Copilot CLI clients or Claude workers).
697
787
  if (String(env?.COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN || '').trim()) {
@@ -699,9 +789,7 @@ export async function launchSessionCli({
699
789
  }
700
790
 
701
791
  if (allowProcessReuse) {
702
- const liveProcess = typeof processInspector?.findProcessForSession === 'function'
703
- ? processInspector.findProcessForSession(target)
704
- : null;
792
+ const liveProcess = await findLiveProcess();
705
793
  const liveProcessPid = parsePositiveInt(liveProcess?.processId);
706
794
  if (liveProcessPid && isPidAlive(liveProcessPid)) {
707
795
  return {
@@ -741,8 +829,9 @@ export async function launchSessionCli({
741
829
  const tmuxEnv = { ...launchSessionEnv };
742
830
  delete tmuxEnv.TMUX;
743
831
  delete tmuxEnv.TMUX_PANE;
744
- delete tmuxEnv.COPILOT_PROVIDER_API_KEY;
745
- delete tmuxEnv.CURSOR_API_KEY;
832
+ // What the secret file carries is in no environment tmux is started with
833
+ // (a tmux server started by this call would keep it as its global one).
834
+ for (const key of workerSecretEnvVarsFor(launchSessionEnv)) delete tmuxEnv[key];
746
835
  if (allowProcessReuse) {
747
836
  const existingPanePid = getTmuxPanePid(sessionName, { execFileSyncImpl });
748
837
  if (existingPanePid && isPidAlive(existingPanePid)) {
@@ -785,9 +874,7 @@ export async function launchSessionCli({
785
874
  const attempts = Math.max(1, Number(tmuxPollAttempts) || 1);
786
875
  for (let index = 0; index < attempts; index += 1) {
787
876
  await sleep(Math.max(50, Number(tmuxPollDelayMs) || 200));
788
- const processMatch = typeof processInspector?.findProcessForSession === 'function'
789
- ? processInspector.findProcessForSession(target)
790
- : null;
877
+ const processMatch = await findLiveProcess({ fresh: true });
791
878
  if (processMatch?.processId) {
792
879
  return {
793
880
  pid: Number(processMatch.processId),
@@ -886,9 +973,7 @@ export async function launchSessionCli({
886
973
  const attempts = Math.max(1, Number(detachedPollAttempts) || 1);
887
974
  for (let index = 0; index < attempts; index += 1) {
888
975
  await sleep(Math.max(50, Number(detachedPollDelayMs) || 200));
889
- const processMatch = typeof processInspector?.findProcessForSession === 'function'
890
- ? processInspector.findProcessForSession(target)
891
- : null;
976
+ const processMatch = await findLiveProcess({ fresh: true });
892
977
  const processPid = parsePositiveInt(processMatch?.processId);
893
978
  if (processPid && isPidAlive(processPid)) {
894
979
  return {