@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
@@ -0,0 +1,809 @@
1
+ // The Claude Cloud session API, as far as OAR needs it.
2
+ //
3
+ // Cloud sessions run Claude Code in a VM at Anthropic, on a clone of a GitHub
4
+ // repository. This client creates them, sends them messages and answers,
5
+ // follows their event stream, and reads usage. It is SDK-free (Node's fetch),
6
+ // keeps no state besides the organisation id, and is used by the cloud worker
7
+ // and by the server alike.
8
+ //
9
+ // The API is the one the Claude CLI itself speaks; paths, headers and bodies
10
+ // were measured, not documented, so every expectation about them lives in this
11
+ // one file.
12
+ //
13
+ // Every call goes through `request`: it adds the headers, tries once more with
14
+ // a freshly read token after a 401, and turns every failure into a
15
+ // ClaudeCloudError whose text has been through `credentials.redact`. Nothing
16
+ // here logs, and the token is in no error and no return value.
17
+
18
+ import { randomUUID } from 'node:crypto';
19
+
20
+ import { ClaudeCloudError, claudeCloudErrorMessage, createClaudeCloudCredentials } from './credentials.mjs';
21
+ import { stripModelTierSuffix } from './repo-url.mjs';
22
+
23
+ export { ClaudeCloudError };
24
+
25
+ export const CLAUDE_CLOUD_BASE_URL = 'https://api.anthropic.com';
26
+ export const CLAUDE_CLOUD_API_VERSION = '2023-06-01';
27
+ export const CLAUDE_CLOUD_API_BETA = 'ccr-triggers-2026-01-30,oauth-2025-04-20';
28
+
29
+ const DEFAULT_USER_AGENT = 'oar-claude-cloud';
30
+ /** How long a call may take until its answer is complete (a create took 8 s when measured). */
31
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
32
+ const MAX_DETAIL_CHARS = 500;
33
+ const DEFAULT_SESSION_TITLE = 'Untitled session';
34
+ /**
35
+ * How many pages of the repository list are followed. One page held 61
36
+ * repositories with no next cursor when measured; the cap keeps a cursor that
37
+ * never ends from turning into an endless walk.
38
+ */
39
+ const MAX_REPOSITORY_PAGES = 20;
40
+
41
+ function abortError() {
42
+ const error = new Error('The operation was aborted');
43
+ error.name = 'AbortError';
44
+ return error;
45
+ }
46
+
47
+ function firstText(...values) {
48
+ for (const value of values) {
49
+ if (typeof value === 'string' && value.trim()) return value.trim();
50
+ }
51
+ return '';
52
+ }
53
+
54
+ function clip(text) {
55
+ const flat = String(text || '').replace(/\s+/g, ' ').trim();
56
+ return flat.length > MAX_DETAIL_CHARS ? `${flat.slice(0, MAX_DETAIL_CHARS)}…` : flat;
57
+ }
58
+
59
+ /**
60
+ * A push parser for a `text/event-stream` body. Feed it decoded text in any
61
+ * chunking; it calls `onFrame({ event, id, data })` for every complete frame
62
+ * that carries an `event:` or a `data:` field (`data` is the joined text of
63
+ * the data lines, or null when the frame has none) and `onComment(text)` for
64
+ * every comment line (the keepalives).
65
+ *
66
+ * Lines may end in LF, CRLF or CR, and a chunk may end anywhere, also between
67
+ * the CR and the LF. A frame is complete at its blank line: `end()` drops
68
+ * what an interrupted stream left unfinished, as the SSE rules say, so that a
69
+ * frame cut off in the middle is never taken for a whole one. The reader gets
70
+ * it again when it reconnects from the last id it saw.
71
+ */
72
+ export function createSseParser({ onFrame = () => {}, onComment = () => {} } = {}) {
73
+ let buffer = '';
74
+ let started = false;
75
+ let event = '';
76
+ let id = null;
77
+ let dataLines = [];
78
+ let hasContent = false;
79
+
80
+ function reset() {
81
+ event = '';
82
+ id = null;
83
+ dataLines = [];
84
+ hasContent = false;
85
+ }
86
+
87
+ function takeLine(line) {
88
+ if (line === '') {
89
+ if (hasContent) onFrame({ event: event || 'message', id, data: dataLines.length ? dataLines.join('\n') : null });
90
+ reset();
91
+ return;
92
+ }
93
+ if (line.startsWith(':')) {
94
+ onComment(line.slice(1).trim());
95
+ return;
96
+ }
97
+ const colon = line.indexOf(':');
98
+ const field = colon === -1 ? line : line.slice(0, colon);
99
+ let value = colon === -1 ? '' : line.slice(colon + 1);
100
+ if (value.startsWith(' ')) value = value.slice(1);
101
+ if (field === 'event') {
102
+ event = value;
103
+ hasContent = true;
104
+ } else if (field === 'data') {
105
+ dataLines.push(value);
106
+ hasContent = true;
107
+ } else if (field === 'id') {
108
+ // An id alone does not make a frame; it belongs to the one it is in.
109
+ if (!value.includes('\u0000')) id = value;
110
+ }
111
+ // `retry:` and unknown fields are ignored.
112
+ }
113
+
114
+ function push(text) {
115
+ buffer += String(text ?? '');
116
+ if (!started && buffer.length) {
117
+ started = true;
118
+ if (buffer.charCodeAt(0) === 0xfeff) buffer = buffer.slice(1);
119
+ }
120
+ let start = 0;
121
+ for (;;) {
122
+ let end = -1;
123
+ for (let i = start; i < buffer.length; i += 1) {
124
+ const code = buffer.charCodeAt(i);
125
+ if (code === 10 || code === 13) {
126
+ end = i;
127
+ break;
128
+ }
129
+ }
130
+ if (end === -1) break;
131
+ const isCr = buffer.charCodeAt(end) === 13;
132
+ // A CR at the very end may be the first half of a CRLF still on its way.
133
+ if (isCr && end === buffer.length - 1) break;
134
+ takeLine(buffer.slice(start, end));
135
+ start = end + (isCr && buffer.charCodeAt(end + 1) === 10 ? 2 : 1);
136
+ }
137
+ buffer = buffer.slice(start);
138
+ }
139
+
140
+ function end() {
141
+ // A lone CR held back above did end its line; a blank one closes a frame.
142
+ if (buffer.endsWith('\r')) {
143
+ const line = buffer.slice(0, -1);
144
+ buffer = '';
145
+ takeLine(line);
146
+ }
147
+ buffer = '';
148
+ reset();
149
+ }
150
+
151
+ return { push, end };
152
+ }
153
+
154
+ /** The SDK user message a cloud session takes as an event payload. */
155
+ export function buildClaudeCloudUserMessage({ content, sessionId = '', uuid = null } = {}) {
156
+ return {
157
+ uuid: uuid || randomUUID(),
158
+ session_id: String(sessionId || ''),
159
+ type: 'user',
160
+ parent_tool_use_id: null,
161
+ message: { role: 'user', content },
162
+ };
163
+ }
164
+
165
+ /**
166
+ * The control request that hands settings to the sandbox's Claude Code, as
167
+ * the SDK's `applyFlagSettings` does for a local one (session-scoped: the
168
+ * flag layer, nothing is written in the sandbox). A key set to null is taken
169
+ * out of that layer again.
170
+ */
171
+ export function buildClaudeCloudFlagSettingsRequest({ settings, sessionId = '', requestId = null } = {}) {
172
+ return {
173
+ uuid: randomUUID(),
174
+ session_id: String(sessionId || ''),
175
+ type: 'control_request',
176
+ request_id: requestId || randomUUID(),
177
+ request: { subtype: 'apply_flag_settings', settings },
178
+ };
179
+ }
180
+
181
+ /** What an error body says about itself, wherever the API put it. */
182
+ function readErrorBody(text) {
183
+ let payload = null;
184
+ try {
185
+ payload = JSON.parse(text);
186
+ } catch {
187
+ payload = null;
188
+ }
189
+ const outer = payload && typeof payload === 'object' ? payload : {};
190
+ const inner = outer.error && typeof outer.error === 'object' ? outer.error : {};
191
+ return {
192
+ type: firstText(inner.type, outer.type),
193
+ reason: firstText(inner.reason, outer.reason),
194
+ subReason: firstText(inner.sub_reason, outer.sub_reason),
195
+ message: firstText(inner.message, outer.message, outer.error, outer.detail),
196
+ };
197
+ }
198
+
199
+ /**
200
+ * The error code for a refused call: what the body names wins over the bare
201
+ * status, because a missing GitHub connection and a repository the Claude
202
+ * GitHub app may not read both arrive as plain 4xx answers.
203
+ */
204
+ export function classifyClaudeCloudFailure(status, { type = '', reason = '', subReason = '', message = '' } = {}) {
205
+ const said = `${type} ${reason} ${subReason} ${message}`.toLowerCase();
206
+ if (status >= 400 && status < 500) {
207
+ if (/github_token_missing|github_not_connected|github[^.]{0,40}\bnot connected/.test(said)) return 'github_not_connected';
208
+ if (/repo(?:sitory)?_access_denied/.test(said)) return 'repo_access_denied';
209
+ if (/environment_(?:not_found|missing|invalid|unavailable)/.test(said)
210
+ || /\benvironment(?:_id)?\b[^.]{0,60}\b(?:not found|does not exist|missing|invalid|unknown)/.test(said)
211
+ || /\b(?:invalid|unknown|missing)\b[^.]{0,30}\benvironment/.test(said)) return 'environment_missing';
212
+ }
213
+ if (status === 401) return 'login_expired';
214
+ if (status === 404) return 'not_found';
215
+ if (status === 429) return 'rate_limited';
216
+ if (status === 408 || status === 425 || status >= 500) return 'transient';
217
+ return 'bad_request';
218
+ }
219
+
220
+ function retryAfterMs(headers) {
221
+ const seconds = Number(headers?.get?.('retry-after'));
222
+ return Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds * 1000) : null;
223
+ }
224
+
225
+ /**
226
+ * `credentials`: from createClaudeCloudCredentials (the default reads the
227
+ * CLI's login). `fetchImpl`, `baseUrl`, `userAgent` and `requestTimeoutMs`
228
+ * are for tests and operators. `now` and `sleep` are accepted so that every
229
+ * caller can pass its clock, and unused: this client neither waits nor
230
+ * retries on a timer; backing off is the caller's business.
231
+ */
232
+ export function createClaudeCloudClient({
233
+ credentials = createClaudeCloudCredentials(),
234
+ fetchImpl = globalThis.fetch,
235
+ baseUrl = CLAUDE_CLOUD_BASE_URL,
236
+ userAgent = DEFAULT_USER_AGENT,
237
+ requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
238
+ } = {}) {
239
+ const base = String(baseUrl || CLAUDE_CLOUD_BASE_URL).replace(/\/+$/, '');
240
+ const redact = (text) => credentials.redact(String(text ?? ''));
241
+ // The organisation belongs to the login, so it is kept with the token it
242
+ // was read for: another account after a re-login gets its own.
243
+ let organization = null;
244
+
245
+ function failure(code, { status = null, detail = '', message = '' } = {}) {
246
+ const safeDetail = clip(redact(detail));
247
+ const text = message || claudeCloudErrorMessage(code);
248
+ const withStatus = status && (code === 'bad_request' || code === 'transient') ? `${text.replace(/\.$/, '')} (HTTP ${status}).` : text;
249
+ const full = safeDetail && (code === 'bad_request' || code === 'not_found') ? `${withStatus} ${safeDetail}` : withStatus;
250
+ return new ClaudeCloudError(code, redact(full), { status, detail: safeDetail || null });
251
+ }
252
+
253
+ function failureFromResponse(res, text) {
254
+ const safeText = redact(text);
255
+ const said = readErrorBody(safeText);
256
+ const error = failure(classifyClaudeCloudFailure(res.status, said), {
257
+ status: res.status,
258
+ detail: said.message || safeText,
259
+ });
260
+ const wait = res.status === 429 ? retryAfterMs(res.headers) : null;
261
+ if (wait !== null) error.retryAfterMs = wait;
262
+ return error;
263
+ }
264
+
265
+ /**
266
+ * One HTTP exchange. A `stream` call that is answered with a body returns
267
+ * as soon as the headers are there, with the controller that ends it; every
268
+ * other call returns the complete text. Rejects with an AbortError when
269
+ * `signal` ended it, with `transient` for anything else that kept the
270
+ * answer from arriving.
271
+ */
272
+ async function exchange(method, routePath, { token, body, headers, stream, signal }) {
273
+ const controller = new AbortController();
274
+ let timedOut = false;
275
+ const timer = setTimeout(() => {
276
+ timedOut = true;
277
+ controller.abort();
278
+ }, requestTimeoutMs);
279
+ timer.unref?.();
280
+ const onAbort = () => controller.abort();
281
+ if (signal?.aborted) controller.abort();
282
+ else signal?.addEventListener?.('abort', onAbort, { once: true });
283
+ const release = () => {
284
+ clearTimeout(timer);
285
+ signal?.removeEventListener?.('abort', onAbort);
286
+ };
287
+ try {
288
+ const res = await fetchImpl(`${base}${routePath}`, {
289
+ method,
290
+ headers: {
291
+ Authorization: `Bearer ${token}`,
292
+ 'Content-Type': 'application/json',
293
+ 'anthropic-version': CLAUDE_CLOUD_API_VERSION,
294
+ 'anthropic-beta': CLAUDE_CLOUD_API_BETA,
295
+ 'user-agent': userAgent,
296
+ ...headers,
297
+ },
298
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
299
+ signal: controller.signal,
300
+ });
301
+ if (stream && res.ok && res.body) {
302
+ // The clock was for the headers; the stream itself has no deadline.
303
+ clearTimeout(timer);
304
+ return { res, text: null, controller, release };
305
+ }
306
+ const text = await res.text();
307
+ release();
308
+ return { res, text, controller: null, release: null };
309
+ } catch (error) {
310
+ release();
311
+ if (signal?.aborted) throw abortError();
312
+ if (timedOut) {
313
+ throw failure('transient', { message: `Claude Cloud did not answer within ${Math.round(requestTimeoutMs / 1000)} s.` });
314
+ }
315
+ throw failure('transient', {
316
+ message: 'Claude Cloud could not be reached.',
317
+ detail: firstText(error?.cause?.code, error?.cause?.message, error?.message),
318
+ });
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Every call. Resolves `{ status, body }` (the parsed JSON, null for an
324
+ * empty answer), or the open exchange for a `stream` call. A 401 is tried
325
+ * once more with the token read afresh; `okStatuses` are answers besides
326
+ * 2xx that the caller takes as success.
327
+ */
328
+ async function request(method, routePath, { body, headers = {}, stream = false, signal = null, okStatuses = [] } = {}) {
329
+ let token = await credentials.getAccessToken();
330
+ for (let attempt = 0; ; attempt += 1) {
331
+ const result = await exchange(method, routePath, { token, body, headers, stream, signal });
332
+ const { res, text } = result;
333
+ if (result.controller) return result;
334
+ if (res.status === 401 && attempt === 0) {
335
+ token = await credentials.getAccessToken({ forceReload: true });
336
+ continue;
337
+ }
338
+ const accepted = okStatuses.includes(res.status);
339
+ if (!res.ok && !accepted) throw failureFromResponse(res, text);
340
+ let parsed = null;
341
+ if (String(text || '').trim()) {
342
+ try {
343
+ parsed = JSON.parse(text);
344
+ } catch {
345
+ if (!accepted) {
346
+ throw failure('transient', { status: res.status, message: 'Claude Cloud sent an answer that is not JSON.', detail: text });
347
+ }
348
+ }
349
+ }
350
+ return { status: res.status, body: parsed };
351
+ }
352
+ }
353
+
354
+ function sessionPath(id, suffix = '') {
355
+ const sessionId = String(id ?? '').trim();
356
+ if (!sessionId) throw failure('bad_request', { message: 'A cloud session id is required.' });
357
+ return `/v1/code/sessions/${encodeURIComponent(sessionId)}${suffix}`;
358
+ }
359
+
360
+ /** The uuid of the organisation the login belongs to. */
361
+ async function getOrganizationId() {
362
+ const token = await credentials.getAccessToken();
363
+ if (organization && organization.token === token) return organization.id;
364
+ const { body } = await request('GET', '/api/oauth/profile');
365
+ const id = firstText(body?.organization?.uuid);
366
+ if (!id) throw failure('bad_request', { message: 'The Claude account has no organisation to run cloud sessions in.' });
367
+ organization = { token, id };
368
+ return id;
369
+ }
370
+
371
+ /** The cloud environments of the organisation as `{ id, name, kind, state }`, the active ones first. */
372
+ async function listEnvironments() {
373
+ const organizationId = await getOrganizationId();
374
+ const { body } = await request('GET', '/v1/environment_providers', { headers: { 'x-organization-uuid': organizationId } });
375
+ const environments = (Array.isArray(body?.environments) ? body.environments : [])
376
+ .map((entry) => ({
377
+ id: firstText(entry?.environment_id, entry?.id),
378
+ name: firstText(entry?.name),
379
+ kind: firstText(entry?.kind),
380
+ state: firstText(entry?.state),
381
+ }))
382
+ .filter((entry) => entry.id);
383
+ return [
384
+ ...environments.filter((entry) => entry.state === 'active'),
385
+ ...environments.filter((entry) => entry.state !== 'active'),
386
+ ];
387
+ }
388
+
389
+ function requireContent(content) {
390
+ const usable = typeof content === 'string' ? content.trim().length > 0 : Array.isArray(content) && content.length > 0;
391
+ if (!usable) throw failure('bad_request', { message: 'A message for the cloud session is required.' });
392
+ }
393
+
394
+ /**
395
+ * Create a session with its first message. `uuid` is the message's id: the
396
+ * API answers a repeated create with the same uuid with the session it
397
+ * already made (`deduplicated`), so a retried delivery should pass it again.
398
+ * `flagSettings` (optional) reach the sandbox's Claude Code before the
399
+ * message does; `flagSettingsRequestId` names the request its
400
+ * `control_response` on the event stream answers.
401
+ */
402
+ async function createSession({ title, environmentId, model, repoUrl, branch, content, uuid, flagSettings = null } = {}) {
403
+ const environment = firstText(environmentId);
404
+ if (!environment) throw failure('environment_missing');
405
+ const url = firstText(repoUrl);
406
+ if (!url) throw failure('bad_request', { message: 'A repository URL for the cloud session is required.' });
407
+ const plainModel = stripModelTierSuffix(model);
408
+ if (!plainModel) throw failure('bad_request', { message: 'A model for the cloud session is required.' });
409
+ requireContent(content);
410
+ const revision = firstText(branch);
411
+ const settingsRequest = flagSettings && typeof flagSettings === 'object'
412
+ ? buildClaudeCloudFlagSettingsRequest({ settings: flagSettings })
413
+ : null;
414
+ const { body } = await request('POST', '/v1/code/sessions', {
415
+ body: {
416
+ title: firstText(title) || DEFAULT_SESSION_TITLE,
417
+ environment_id: environment,
418
+ config: {
419
+ model: plainModel,
420
+ sources: [{ type: 'git_repository', url, ...(revision ? { revision } : {}) }],
421
+ },
422
+ // In this order: the settings have to be in place when the agent
423
+ // reads the message.
424
+ events: settingsRequest
425
+ ? [
426
+ { event_type: 'control_request', payload: settingsRequest },
427
+ { event_type: 'user', payload: buildClaudeCloudUserMessage({ content, sessionId: '', uuid }) },
428
+ ]
429
+ : [{ payload: buildClaudeCloudUserMessage({ content, sessionId: '', uuid }) }],
430
+ },
431
+ });
432
+ const id = firstText(body?.session?.id);
433
+ if (!id) throw failure('transient', { message: 'Claude Cloud answered the create without a session id.' });
434
+ return {
435
+ id,
436
+ sessionUrl: firstText(body.session.session_url) || null,
437
+ deduplicated: body.deduplicated === true,
438
+ flagSettingsRequestId: settingsRequest?.request_id || null,
439
+ raw: body,
440
+ };
441
+ }
442
+
443
+ /**
444
+ * The session as the API describes it (`status`, `worker_status`,
445
+ * `external_metadata`, …). The API wraps it in `response_shape`; the object
446
+ * returned is the unwrapped one and answers to `.response_shape` as well.
447
+ */
448
+ async function getSession(id) {
449
+ const { body } = await request('GET', sessionPath(id));
450
+ const session = body?.response_shape && typeof body.response_shape === 'object' ? body.response_shape : body;
451
+ if (!session || typeof session !== 'object') {
452
+ throw failure('transient', { message: 'Claude Cloud answered without a session.' });
453
+ }
454
+ if (!('response_shape' in session)) {
455
+ Object.defineProperty(session, 'response_shape', { value: session, enumerable: false });
456
+ }
457
+ return session;
458
+ }
459
+
460
+ /** One page of the event log: `{ events, nextCursor }` (also as `data` / `next_cursor`). */
461
+ async function listEvents(id, { cursor = null, sortOrder = 'asc', limit = null } = {}) {
462
+ const query = new URLSearchParams({ sort_order: sortOrder === 'desc' ? 'desc' : 'asc' });
463
+ if (cursor !== null && cursor !== undefined && String(cursor) !== '') query.set('cursor', String(cursor));
464
+ if (Number.isInteger(limit) && limit > 0) query.set('limit', String(limit));
465
+ const { body } = await request('GET', sessionPath(id, `/events?${query}`));
466
+ const events = Array.isArray(body?.data) ? body.data : [];
467
+ const nextCursor = body?.next_cursor === null || body?.next_cursor === undefined || body.next_cursor === ''
468
+ ? null
469
+ : String(body.next_cursor);
470
+ return { events, nextCursor, data: events, next_cursor: nextCursor };
471
+ }
472
+
473
+ async function postEvent(id, eventType, payload) {
474
+ const { body } = await request('POST', sessionPath(id, '/events'), {
475
+ body: { events: [{ event_type: eventType, payload }] },
476
+ });
477
+ const first = Array.isArray(body?.results) ? body.results[0] : null;
478
+ return {
479
+ eventId: firstText(first?.event_id) || null,
480
+ sequence: first?.sequence_num === null || first?.sequence_num === undefined ? null : String(first.sequence_num),
481
+ duplicate: first?.duplicate === true,
482
+ };
483
+ }
484
+
485
+ /** Send a user message; `sequence` is where its turn starts in the event log. */
486
+ async function sendUserMessage(id, content, { uuid = null } = {}) {
487
+ const routeId = String(id ?? '').trim();
488
+ requireContent(content);
489
+ return postEvent(routeId, 'user', buildClaudeCloudUserMessage({ content, sessionId: routeId, uuid }));
490
+ }
491
+
492
+ /**
493
+ * Answer a `can_use_tool` control request. `response` is the decision:
494
+ * `{ behavior: 'allow', updatedInput }` or `{ behavior: 'deny', message }`.
495
+ */
496
+ async function sendControlResponse(id, { requestId, response } = {}) {
497
+ const routeId = String(id ?? '').trim();
498
+ const request_id = firstText(requestId);
499
+ if (!request_id || !response || typeof response !== 'object') {
500
+ throw failure('bad_request', { message: 'A control response needs the request id and the decision.' });
501
+ }
502
+ return postEvent(routeId, 'control_response', {
503
+ uuid: randomUUID(),
504
+ session_id: routeId,
505
+ type: 'control_response',
506
+ response: { subtype: 'success', request_id, response },
507
+ });
508
+ }
509
+
510
+ /** Stop the running turn. Its `result` event follows on the stream. */
511
+ async function sendInterrupt(id) {
512
+ const routeId = String(id ?? '').trim();
513
+ const requestId = randomUUID();
514
+ const sent = await postEvent(routeId, 'control_request', {
515
+ uuid: randomUUID(),
516
+ session_id: routeId,
517
+ type: 'control_request',
518
+ request_id: requestId,
519
+ request: { subtype: 'interrupt' },
520
+ });
521
+ return { ...sent, requestId };
522
+ }
523
+
524
+ /**
525
+ * Hand settings to the sandbox's Claude Code (see
526
+ * buildClaudeCloudFlagSettingsRequest). Whether it took them is its
527
+ * `control_response` on the event stream, under the returned `requestId`.
528
+ */
529
+ async function applyFlagSettings(id, settings) {
530
+ const routeId = String(id ?? '').trim();
531
+ if (!settings || typeof settings !== 'object') {
532
+ throw failure('bad_request', { message: 'Flag settings for the cloud session are required.' });
533
+ }
534
+ const payload = buildClaudeCloudFlagSettingsRequest({ settings, sessionId: routeId });
535
+ const sent = await postEvent(routeId, 'control_request', payload);
536
+ return { ...sent, requestId: payload.request_id };
537
+ }
538
+
539
+ /**
540
+ * Switch the model of the sandbox's Claude Code for the turns to come: a
541
+ * `set_model` control request, answered on the event stream like
542
+ * applyFlagSettings (the sandbox re-initialises with the new model).
543
+ */
544
+ async function setModel(id, model) {
545
+ const routeId = String(id ?? '').trim();
546
+ const plainModel = stripModelTierSuffix(model);
547
+ if (!plainModel) throw failure('bad_request', { message: 'A model for the cloud session is required.' });
548
+ const payload = {
549
+ uuid: randomUUID(),
550
+ session_id: routeId,
551
+ type: 'control_request',
552
+ request_id: randomUUID(),
553
+ request: { subtype: 'set_model', model: plainModel },
554
+ };
555
+ const sent = await postEvent(routeId, 'control_request', payload);
556
+ return { ...sent, requestId: payload.request_id, model: plainModel };
557
+ }
558
+
559
+ /** Archive the session. One that is archived already counts as done. */
560
+ async function archiveSession(id) {
561
+ const { status } = await request('POST', sessionPath(id, '/archive'), { body: {}, okStatuses: [409] });
562
+ return { archived: true, alreadyArchived: status === 409 };
563
+ }
564
+
565
+ /** Bring an archived session back; one that is active already counts as done. */
566
+ async function unarchiveSession(id) {
567
+ const { status } = await request('POST', sessionPath(id, '/unarchive'), { body: {}, okStatuses: [409] });
568
+ return { unarchived: true, alreadyActive: status === 409 };
569
+ }
570
+
571
+ /** The account's usage windows and dollar buckets, as the API sends them. */
572
+ async function getAccountUsage() {
573
+ const { body } = await request('GET', '/api/oauth/usage');
574
+ return body;
575
+ }
576
+
577
+ function organizationPath(organizationId, suffix) {
578
+ return `/api/oauth/organizations/${encodeURIComponent(organizationId)}${suffix}`;
579
+ }
580
+
581
+ /**
582
+ * The organisation's prepaid usage credits, as the API sends them
583
+ * (`{ amount, currency, … }`, the amount in minor units).
584
+ */
585
+ async function getPrepaidCredits() {
586
+ const organizationId = await getOrganizationId();
587
+ const { body } = await request('GET', organizationPath(organizationId, '/prepaid/credits'), {
588
+ headers: { 'x-organization-uuid': organizationId },
589
+ });
590
+ return body;
591
+ }
592
+
593
+ /**
594
+ * Whether a usage credit is on offer to the organisation, as the API sends
595
+ * it (`{ available, eligible, granted, amount_minor_units, currency, … }`).
596
+ * Only ever read: claiming is done on claude.ai.
597
+ */
598
+ async function getCreditGrantOffer() {
599
+ const organizationId = await getOrganizationId();
600
+ const { body } = await request('GET', organizationPath(organizationId, '/overage_credit_grant'), {
601
+ headers: { 'x-organization-uuid': organizationId },
602
+ });
603
+ return body;
604
+ }
605
+
606
+ /**
607
+ * One entry of the repository list as OAR describes it, or null for one
608
+ * the cloud cannot clone (`disabled`) or that has no owner and name.
609
+ */
610
+ function describeRepository(entry) {
611
+ const repo = entry?.repo && typeof entry.repo === 'object' ? entry.repo : null;
612
+ if (!repo || repo.disabled === true) return null;
613
+ const owner = firstText(repo.owner?.login);
614
+ const name = firstText(repo.name);
615
+ if (!owner || !name) return null;
616
+ const slug = `${owner}/${name}`;
617
+ return {
618
+ owner,
619
+ name,
620
+ slug,
621
+ repoUrl: `https://github.com/${slug}`,
622
+ defaultBranch: firstText(repo.default_branch) || null,
623
+ private: repo.private === true,
624
+ archived: repo.archived === true,
625
+ pushedAt: firstText(repo.pushed_at) || null,
626
+ description: firstText(repo.description) || null,
627
+ };
628
+ }
629
+
630
+ /**
631
+ * The GitHub repositories the Claude GitHub app can reach for the
632
+ * organisation: `{ repos, complete, raw }`. `repos` are `{ owner, name,
633
+ * slug, repoUrl, defaultBranch, private, archived, pushedAt, description }`
634
+ * in the API's order, without the disabled ones and without duplicates;
635
+ * `complete` is false when the API said its list is not complete or when
636
+ * the page cap was reached first; `raw` is the list of page bodies as sent.
637
+ */
638
+ async function listRepositories() {
639
+ const organizationId = await getOrganizationId();
640
+ const repos = [];
641
+ const seen = new Set();
642
+ const raw = [];
643
+ let complete = true;
644
+ let cursor = null;
645
+ for (let page = 0; page < MAX_REPOSITORY_PAGES; page += 1) {
646
+ const query = cursor === null ? '' : `?${new URLSearchParams({ cursor })}`;
647
+ const { body } = await request('GET', organizationPath(organizationId, `/code/repos${query}`), {
648
+ headers: { 'x-organization-uuid': organizationId },
649
+ });
650
+ raw.push(body);
651
+ for (const entry of Array.isArray(body?.repos) ? body.repos : []) {
652
+ const repo = describeRepository(entry);
653
+ if (!repo || seen.has(repo.slug.toLowerCase())) continue;
654
+ seen.add(repo.slug.toLowerCase());
655
+ repos.push(repo);
656
+ }
657
+ if (body?.is_complete === false) complete = false;
658
+ const next = firstText(body?.next_cursor);
659
+ if (!next) break;
660
+ cursor = next;
661
+ // A cursor after the last page allowed: what follows it is missing.
662
+ if (page === MAX_REPOSITORY_PAGES - 1) complete = false;
663
+ }
664
+ return { repos, complete, raw };
665
+ }
666
+
667
+ /**
668
+ * Follow the session's event stream. Calls `onOpen()` once the stream is
669
+ * open and `onEvent({ kind, id, data })` for every frame, one after the
670
+ * other (an async `onEvent` is awaited): `kind` is the `event:` name, `id`
671
+ * the sequence number as sent (a string) or null, `data` the parsed JSON.
672
+ * Keepalive comments, frames without data and frames whose data is not a
673
+ * JSON value are skipped; `onKeepalive()` hears the comments.
674
+ *
675
+ * Throws a ClaudeCloudError when the stream cannot be opened. Once it is
676
+ * open it only ever resolves, with `{ reason, lastEventId, error }`:
677
+ * `ended` (the API closed it), `aborted` (`signal`), `idle` (no byte for
678
+ * `idleTimeoutMs`, when one is given), `error` (the connection broke;
679
+ * `error` says how). It never reconnects: the caller does, passing the last
680
+ * id it handled as `lastEventId`.
681
+ */
682
+ async function openEventStream(id, {
683
+ lastEventId = null, signal = null, onEvent = null, onOpen = null, onKeepalive = null, idleTimeoutMs = 0,
684
+ } = {}) {
685
+ const routePath = sessionPath(id, '/events/stream');
686
+ let lastId = lastEventId === null || lastEventId === undefined || String(lastEventId) === '' ? null : String(lastEventId);
687
+ if (signal?.aborted) return { reason: 'aborted', lastEventId: lastId, error: null };
688
+
689
+ let open;
690
+ try {
691
+ open = await request('GET', routePath, {
692
+ headers: { Accept: 'text/event-stream', ...(lastId !== null ? { 'Last-Event-ID': lastId } : {}) },
693
+ stream: true,
694
+ signal,
695
+ });
696
+ } catch (error) {
697
+ if (signal?.aborted) return { reason: 'aborted', lastEventId: lastId, error: null };
698
+ throw error;
699
+ }
700
+ // A 2xx without a body: nothing to follow.
701
+ if (!open.controller) return { reason: 'ended', lastEventId: lastId, error: null };
702
+
703
+ const { res, controller, release } = open;
704
+ const reader = res.body.getReader();
705
+ const decoder = new TextDecoder();
706
+ const pending = [];
707
+ const parser = createSseParser({
708
+ onFrame: (frame) => pending.push(frame),
709
+ onComment: () => pending.push(null),
710
+ });
711
+ let idle = false;
712
+ let broken = null;
713
+ let idleTimer = null;
714
+ const stopReading = () => { reader.cancel().catch(() => {}); };
715
+ const armIdleTimer = () => {
716
+ if (!(idleTimeoutMs > 0)) return;
717
+ clearTimeout(idleTimer);
718
+ idleTimer = setTimeout(() => {
719
+ idle = true;
720
+ controller.abort();
721
+ stopReading();
722
+ }, idleTimeoutMs);
723
+ idleTimer.unref?.();
724
+ };
725
+ signal?.addEventListener?.('abort', stopReading, { once: true });
726
+
727
+ // Hand over what the parser has completed, in order, until the caller
728
+ // aborts.
729
+ async function deliverPending() {
730
+ for (const frame of pending.splice(0)) {
731
+ if (signal?.aborted) return;
732
+ if (frame === null) {
733
+ if (typeof onKeepalive === 'function') await onKeepalive();
734
+ continue;
735
+ }
736
+ if (frame.data === null) continue;
737
+ let data;
738
+ try {
739
+ data = JSON.parse(frame.data);
740
+ } catch {
741
+ continue;
742
+ }
743
+ if (data === null || data === undefined) continue;
744
+ if (typeof onEvent === 'function') await onEvent({ kind: frame.event, id: frame.id, data });
745
+ if (frame.id !== null) lastId = frame.id;
746
+ }
747
+ }
748
+
749
+ try {
750
+ if (typeof onOpen === 'function') await onOpen({ lastEventId: lastId });
751
+ armIdleTimer();
752
+ while (!signal?.aborted && !idle) {
753
+ let chunk;
754
+ try {
755
+ chunk = await reader.read();
756
+ } catch (error) {
757
+ if (!signal?.aborted && !idle) broken = error;
758
+ break;
759
+ }
760
+ if (chunk.done) break;
761
+ armIdleTimer();
762
+ parser.push(typeof chunk.value === 'string' ? chunk.value : decoder.decode(chunk.value, { stream: true }));
763
+ await deliverPending();
764
+ }
765
+ parser.end();
766
+ await deliverPending();
767
+ } finally {
768
+ clearTimeout(idleTimer);
769
+ signal?.removeEventListener?.('abort', stopReading);
770
+ release();
771
+ controller.abort();
772
+ stopReading();
773
+ }
774
+
775
+ if (signal?.aborted) return { reason: 'aborted', lastEventId: lastId, error: null };
776
+ if (idle) return { reason: 'idle', lastEventId: lastId, error: null };
777
+ if (broken) {
778
+ return {
779
+ reason: 'error',
780
+ lastEventId: lastId,
781
+ error: failure('transient', {
782
+ message: 'The Claude Cloud event stream broke off.',
783
+ detail: firstText(broken?.cause?.code, broken?.cause?.message, broken?.message),
784
+ }),
785
+ };
786
+ }
787
+ return { reason: 'ended', lastEventId: lastId, error: null };
788
+ }
789
+
790
+ return {
791
+ getOrganizationId,
792
+ listEnvironments,
793
+ createSession,
794
+ getSession,
795
+ listEvents,
796
+ sendUserMessage,
797
+ sendControlResponse,
798
+ sendInterrupt,
799
+ applyFlagSettings,
800
+ setModel,
801
+ archiveSession,
802
+ unarchiveSession,
803
+ getAccountUsage,
804
+ getPrepaidCredits,
805
+ getCreditGrantOffer,
806
+ listRepositories,
807
+ openEventStream,
808
+ };
809
+ }