@oar-sh/oar 0.9.6 → 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.
- package/CHANGELOG.md +172 -0
- package/README.md +176 -22
- package/package.json +1 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
- package/server/claude-worker/claude-session-process.mjs +33 -0
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/db-schema.mjs +17 -0
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +143 -4
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +344 -31
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +11 -0
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +83 -4
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +698 -28
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- package/server/public/app/relay-error-ctas.mjs +16 -2
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/settings-modal.js +33 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +51 -0
- package/server/public/index.html +381 -7
- package/server/relay-tools.md +13 -1
- package/server/repositories/remote-relay-repository.mjs +42 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +120 -15
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +502 -72
- package/server/server-runtime.mjs +258 -25
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/remote-relay-caller-context.mjs +49 -10
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +383 -35
- package/server/services/remote-relay-inbound.mjs +49 -2
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +348 -129
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +56 -1
- package/server/services/usage-limit-pause-service.mjs +3 -1
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/remote-relay-contract.mjs +153 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +1 -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
|
+
}
|