@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +176 -22
  3. package/package.json +1 -1
  4. package/server/README.md +174 -7
  5. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  6. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  7. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  10. package/server/claude-worker/claude-session-process.mjs +33 -0
  11. package/server/claude-worker/claude-session-worker.mjs +6 -0
  12. package/server/db-schema.mjs +17 -0
  13. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  14. package/server/migrations/0007-claude-cloud.mjs +82 -0
  15. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  16. package/server/public/app/agent-sessions-settings.mjs +134 -0
  17. package/server/public/app/api-client.js +143 -4
  18. package/server/public/app/attachments-view.js +20 -1
  19. package/server/public/app/attribution-control.mjs +46 -0
  20. package/server/public/app/bootstrap.js +344 -31
  21. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  22. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  23. package/server/public/app/claude-cloud-ui.mjs +611 -0
  24. package/server/public/app/composer-control-state.mjs +11 -0
  25. package/server/public/app/context-menu.mjs +140 -0
  26. package/server/public/app/context-tier-options.mjs +4 -0
  27. package/server/public/app/conversation-origin-ui.js +27 -0
  28. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  29. package/server/public/app/conversation-view.js +83 -4
  30. package/server/public/app/cwd-picker.js +12 -0
  31. package/server/public/app/journal-view.js +698 -28
  32. package/server/public/app/math-dollars.mjs +89 -0
  33. package/server/public/app/model-selector-options.mjs +1 -1
  34. package/server/public/app/plan-usage-view.mjs +36 -8
  35. package/server/public/app/relay-error-ctas.mjs +16 -2
  36. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  37. package/server/public/app/router.js +49 -12
  38. package/server/public/app/settings-modal.js +33 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +51 -0
  41. package/server/public/index.html +381 -7
  42. package/server/relay-tools.md +13 -1
  43. package/server/repositories/remote-relay-repository.mjs +42 -1
  44. package/server/repositories/session-repository.mjs +47 -1
  45. package/server/routes/claude-cloud-routes.mjs +140 -0
  46. package/server/routes/git-routes.mjs +37 -0
  47. package/server/routes/messages-routes.mjs +120 -15
  48. package/server/routes/remote-relay-routes.mjs +15 -1
  49. package/server/routes/sessions-routes.mjs +502 -72
  50. package/server/server-runtime.mjs +258 -25
  51. package/server/services/claude-account-usage-service.mjs +176 -0
  52. package/server/services/claude-cloud-repo-service.mjs +408 -0
  53. package/server/services/claude-cloud-session-service.mjs +326 -0
  54. package/server/services/claude-cloud-settings-service.mjs +269 -0
  55. package/server/services/git-remote-service.mjs +115 -0
  56. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  57. package/server/services/plan-usage-claude.mjs +202 -40
  58. package/server/services/plan-usage-contract.mjs +39 -3
  59. package/server/services/plan-usage-service.mjs +82 -2
  60. package/server/services/provider-reasoning-effort.mjs +3 -0
  61. package/server/services/remote-relay-caller-context.mjs +49 -10
  62. package/server/services/remote-relay-client.mjs +9 -0
  63. package/server/services/remote-relay-dispatcher.mjs +383 -35
  64. package/server/services/remote-relay-inbound.mjs +49 -2
  65. package/server/services/remote-relay-loopback.mjs +245 -0
  66. package/server/services/remote-relay-registry-service.mjs +52 -0
  67. package/server/services/session-worker-launch-service.mjs +100 -15
  68. package/server/services/session-worker-process-service.mjs +348 -129
  69. package/server/services/session-worker-stop-service.mjs +24 -13
  70. package/server/services/session-worker-supervisor-service.mjs +56 -1
  71. package/server/services/usage-limit-pause-service.mjs +3 -1
  72. package/server/services/worker-launch-note-service.mjs +151 -0
  73. package/shared/claude-attribution.mjs +117 -0
  74. package/shared/claude-cloud/api-client.mjs +809 -0
  75. package/shared/claude-cloud/base-url.mjs +47 -0
  76. package/shared/claude-cloud/credentials.mjs +221 -0
  77. package/shared/claude-cloud/repo-url.mjs +111 -0
  78. package/shared/provider-routing.mjs +4 -2
  79. package/shared/remote-relay-contract.mjs +153 -22
  80. package/shared/remote-relay-tool-core.mjs +11 -8
  81. package/shared/steer-settle-failure.mjs +1 -0
@@ -0,0 +1,47 @@
1
+ // Where the cloud client sends its calls.
2
+ //
3
+ // Always the Anthropic API, with one exception for tests: the environment
4
+ // variable below may point the client at a fake API on the same machine. The
5
+ // client sends the Claude login as a bearer token to whatever its base URL
6
+ // is, so an environment variable must never be able to name another host: a
7
+ // value that is not a plain http(s) URL of the loopback interface is ignored
8
+ // and the default stays. The relay's client and the cloud worker's both ask
9
+ // here, so the rule lives in this one function.
10
+
11
+ import { CLAUDE_CLOUD_BASE_URL } from './api-client.mjs';
12
+
13
+ export const CLAUDE_CLOUD_BASE_URL_ENV = 'OAR_CLAUDE_CLOUD_API_BASE_URL';
14
+
15
+ // Exactly these, as the URL parser writes them (it lower-cases the name and
16
+ // puts the IPv6 address in brackets). No "anything in 127/8", no trailing dot,
17
+ // no name that merely starts or ends like one of them.
18
+ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', '[::1]', 'localhost']);
19
+
20
+ /**
21
+ * The origin of a loopback http(s) URL (`http://127.0.0.1:4010`), or null for
22
+ * anything else: another host, another scheme, a URL with a user or a
23
+ * password in it, or text that is no URL at all. A path, query or fragment is
24
+ * dropped: the client's routes start at the root.
25
+ */
26
+ export function loopbackBaseUrlOrNull(value) {
27
+ const text = typeof value === 'string' ? value.trim() : '';
28
+ if (!text) return null;
29
+ let url;
30
+ try {
31
+ url = new URL(text);
32
+ } catch {
33
+ return null;
34
+ }
35
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
36
+ if (url.username || url.password) return null;
37
+ if (!LOOPBACK_HOSTNAMES.has(url.hostname)) return null;
38
+ return url.origin;
39
+ }
40
+
41
+ /**
42
+ * The base URL for `createClaudeCloudClient`: the loopback URL named by
43
+ * `OAR_CLAUDE_CLOUD_API_BASE_URL`, else the Anthropic API.
44
+ */
45
+ export function resolveClaudeCloudBaseUrl(env = process.env) {
46
+ return loopbackBaseUrlOrNull(env?.[CLAUDE_CLOUD_BASE_URL_ENV]) || CLAUDE_CLOUD_BASE_URL;
47
+ }
@@ -0,0 +1,221 @@
1
+ // The Claude login a cloud session runs on.
2
+ //
3
+ // Claude Cloud sessions are created with the claude.ai login the Claude CLI
4
+ // keeps in `<CLAUDE_CONFIG_DIR | ~/.claude>/.credentials.json`. This module is
5
+ // the only place that reads it. It hands out the access token, says what it
6
+ // found (never the token itself), and takes the token back out of any text
7
+ // that is about to leave the process.
8
+ //
9
+ // What it never does: use the refresh token. A refresh from here would rotate
10
+ // the login under the CLI and log it out. When the token has run out the file
11
+ // is read again (the CLI rewrites it while it works), then the caller's
12
+ // `nudge` may run the CLI once so that it refreshes its own login, and after
13
+ // that the answer is `login_expired`.
14
+ //
15
+ // Nothing here logs, and no error it throws carries the token.
16
+
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+
21
+ export const CLAUDE_CLOUD_ERROR_CODES = Object.freeze([
22
+ 'login_missing',
23
+ 'login_expired',
24
+ 'github_not_connected',
25
+ 'repo_access_denied',
26
+ 'environment_missing',
27
+ 'not_found',
28
+ 'rate_limited',
29
+ 'bad_request',
30
+ 'transient',
31
+ ]);
32
+
33
+ const DEFAULT_ERROR_MESSAGES = Object.freeze({
34
+ login_missing: 'No Claude login was found on this machine. Log in with the Claude CLI first.',
35
+ login_expired: 'The Claude login has expired or was rejected. Log in to Claude again.',
36
+ github_not_connected: 'GitHub is not connected to this Claude account.',
37
+ repo_access_denied: 'The Claude GitHub app has no access to this repository.',
38
+ environment_missing: 'The Claude Cloud environment was not found.',
39
+ not_found: 'Claude Cloud did not find the session or resource.',
40
+ rate_limited: 'Claude Cloud is rate limiting this account. Try again later.',
41
+ bad_request: 'Claude Cloud refused the request.',
42
+ transient: 'Claude Cloud is not answering right now.',
43
+ });
44
+
45
+ /** The plain sentence for an error code; the fallback of every ClaudeCloudError. */
46
+ export function claudeCloudErrorMessage(code) {
47
+ return DEFAULT_ERROR_MESSAGES[code] || DEFAULT_ERROR_MESSAGES.transient;
48
+ }
49
+
50
+ /**
51
+ * Every failure of the cloud client and of the credentials: `code` (one of
52
+ * CLAUDE_CLOUD_ERROR_CODES), `status` (the HTTP status, or null when no answer
53
+ * was involved) and `detail` (what the API said, already redacted, or null).
54
+ *
55
+ * `new ClaudeCloudError(code, message?, { status, detail })`; also accepted:
56
+ * `(message, { code, … })` and `({ code, message, … })`.
57
+ */
58
+ export class ClaudeCloudError extends Error {
59
+ constructor(code, message, options) {
60
+ let fields;
61
+ if (code && typeof code === 'object') fields = code;
62
+ else if (message && typeof message === 'object') {
63
+ fields = message.code ? { ...message, message: code } : { ...message, code };
64
+ } else fields = { ...(options || {}), code, message };
65
+ const errorCode = String(fields.code || 'transient');
66
+ super(String(fields.message || '') || claudeCloudErrorMessage(errorCode));
67
+ this.name = 'ClaudeCloudError';
68
+ this.code = errorCode;
69
+ this.status = Number.isFinite(fields.status) ? fields.status : null;
70
+ this.detail = fields.detail ? String(fields.detail) : null;
71
+ }
72
+ }
73
+
74
+ export const CLAUDE_CLOUD_TOKEN_ENV = 'CLAUDE_CODE_OAUTH_TOKEN';
75
+ /** The cached token is trusted until this long before it runs out. */
76
+ export const CLAUDE_CLOUD_TOKEN_MARGIN_MS = 60_000;
77
+ /** The CLI is asked to refresh its login at most this often. */
78
+ export const CLAUDE_CLOUD_NUDGE_INTERVAL_MS = 5 * 60_000;
79
+
80
+ const REDACTED = '[redacted]';
81
+ /** Tokens handed out earlier stay redactable after the file moved on. */
82
+ const MAX_REMEMBERED_TOKENS = 16;
83
+
84
+ /** Where the CLI keeps its login: `CLAUDE_CONFIG_DIR`, else `.claude` in the home directory. */
85
+ export function resolveClaudeCredentialsPath({ env = process.env, homedir = os.homedir, pathImpl = path } = {}) {
86
+ const configDir = String(env?.CLAUDE_CONFIG_DIR || '').trim();
87
+ if (configDir) return pathImpl.join(configDir, '.credentials.json');
88
+ const home = String((typeof homedir === 'function' ? homedir() : homedir) || '').trim();
89
+ return pathImpl.join(home, '.claude', '.credentials.json');
90
+ }
91
+
92
+ function textOrNull(value) {
93
+ const text = typeof value === 'string' ? value.trim() : '';
94
+ return text || null;
95
+ }
96
+
97
+ function isoOrNull(ms) {
98
+ if (ms === null) return null;
99
+ const date = new Date(ms);
100
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
101
+ }
102
+
103
+ /**
104
+ * `nudge`: an async function that makes the Claude CLI refresh its own login
105
+ * (the caller decides which command). Called at most once per five minutes,
106
+ * and only when the login in the file is about to run out or has.
107
+ */
108
+ export function createClaudeCloudCredentials({
109
+ env = process.env,
110
+ homedir = os.homedir,
111
+ fsImpl = fs,
112
+ now = Date.now,
113
+ nudge = null,
114
+ pathImpl = path,
115
+ } = {}) {
116
+ let cached = null;
117
+ let loading = null;
118
+ let lastNudgeAt = null;
119
+ const remembered = [];
120
+
121
+ function remember(token) {
122
+ if (!token || remembered.includes(token)) return;
123
+ remembered.push(token);
124
+ if (remembered.length > MAX_REMEMBERED_TOKENS) remembered.shift();
125
+ }
126
+
127
+ function readEnvToken() {
128
+ const token = textOrNull(env?.[CLAUDE_CLOUD_TOKEN_ENV]);
129
+ if (token) remember(token);
130
+ return token;
131
+ }
132
+
133
+ /** The login in the file, or null when there is no file, no JSON or no token in it. */
134
+ function readFileLogin() {
135
+ let oauth = null;
136
+ try {
137
+ const filePath = resolveClaudeCredentialsPath({ env, homedir, pathImpl });
138
+ oauth = JSON.parse(fsImpl.readFileSync(filePath, 'utf8'))?.claudeAiOauth;
139
+ } catch {
140
+ return null;
141
+ }
142
+ const accessToken = textOrNull(oauth?.accessToken);
143
+ if (!accessToken) return null;
144
+ remember(accessToken);
145
+ const expiresAt = Number(oauth.expiresAt);
146
+ return {
147
+ accessToken,
148
+ expiresAt: Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt : null,
149
+ subscriptionType: textOrNull(oauth.subscriptionType),
150
+ rateLimitTier: textOrNull(oauth.rateLimitTier),
151
+ };
152
+ }
153
+
154
+ // A login without an expiry time is taken as it is and read again each time.
155
+ const isExpired = (login) => login.expiresAt !== null && now() >= login.expiresAt;
156
+ const isFresh = (login) => login.expiresAt !== null && now() < login.expiresAt - CLAUDE_CLOUD_TOKEN_MARGIN_MS;
157
+ const needsRefresh = (login) => login.expiresAt !== null && !isFresh(login);
158
+
159
+ async function loadFromFile() {
160
+ let login = readFileLogin();
161
+ if (login && needsRefresh(login) && typeof nudge === 'function'
162
+ && (lastNudgeAt === null || now() - lastNudgeAt >= CLAUDE_CLOUD_NUDGE_INTERVAL_MS)) {
163
+ lastNudgeAt = now();
164
+ // A nudge that fails changes nothing: the file decides.
165
+ try { await nudge(); } catch {}
166
+ login = readFileLogin();
167
+ }
168
+ cached = null;
169
+ if (!login) throw new ClaudeCloudError('login_missing');
170
+ if (isExpired(login)) throw new ClaudeCloudError('login_expired');
171
+ // Inside the last minute the token still works, so it is handed out, but
172
+ // `isFresh` keeps it from being served from the cache.
173
+ cached = login;
174
+ return login.accessToken;
175
+ }
176
+
177
+ /**
178
+ * The bearer token. `forceReload` skips the cache (the API refused the
179
+ * cached token: the CLI may have replaced it early). Throws ClaudeCloudError
180
+ * `login_missing` or `login_expired`.
181
+ */
182
+ async function getAccessToken({ forceReload = false } = {}) {
183
+ const envToken = readEnvToken();
184
+ if (envToken) return envToken;
185
+ if (!forceReload && cached && isFresh(cached)) return cached.accessToken;
186
+ // One read (and one nudge) for everybody who asks while it runs.
187
+ if (!loading) loading = loadFromFile().finally(() => { loading = null; });
188
+ return loading;
189
+ }
190
+
191
+ /** What there is to say about the login without saying the token. Never throws. */
192
+ function describe() {
193
+ const none = { source: 'none', hasToken: false, expiresAt: null, subscriptionType: null, rateLimitTier: null, expired: false };
194
+ if (readEnvToken()) return { ...none, source: 'env', hasToken: true };
195
+ const login = readFileLogin();
196
+ if (!login) return none;
197
+ return {
198
+ source: 'file',
199
+ hasToken: true,
200
+ expiresAt: isoOrNull(login.expiresAt),
201
+ subscriptionType: login.subscriptionType,
202
+ rateLimitTier: login.rateLimitTier,
203
+ expired: isExpired(login),
204
+ };
205
+ }
206
+
207
+ /**
208
+ * The text without any token this module has handed out or seen, and
209
+ * without anything that has the shape of a bearer header or a Claude token.
210
+ */
211
+ function redact(text) {
212
+ let out = String(text ?? '');
213
+ readEnvToken();
214
+ for (const token of remembered) out = out.split(token).join(REDACTED);
215
+ return out
216
+ .replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi, `$1 ${REDACTED}`)
217
+ .replace(/\bsk-ant-[A-Za-z0-9_-]{8,}/g, REDACTED);
218
+ }
219
+
220
+ return { getAccessToken, describe, redact };
221
+ }
@@ -0,0 +1,111 @@
1
+ // Repository, branch and model strings for Claude Cloud sessions.
2
+ //
3
+ // A cloud session clones a GitHub repository by its https URL and, when one is
4
+ // named, checks out a branch. Both arrive as free text (the New Chat fields,
5
+ // an `origin` remote read from a folder), so everything is brought to one form
6
+ // here before it is stored or sent: the server validates with these, the
7
+ // browser shows what they return, the client sends it.
8
+ //
9
+ // Pure functions, no I/O.
10
+
11
+ const GITHUB_HOSTS = new Set(['github.com', 'www.github.com']);
12
+ // GitHub's own rules: an account name is letters, digits and single hyphens
13
+ // (never leading), at most 39 characters; a repository name is letters,
14
+ // digits, `.`, `_` and `-`, at most 100.
15
+ const OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
16
+ const REPO_PATTERN = /^[A-Za-z0-9._-]{1,100}$/;
17
+ const MAX_BRANCH_LENGTH = 255;
18
+
19
+ function ownerAndRepoFromPath(pathText) {
20
+ const parts = String(pathText || '').split('/').filter(Boolean);
21
+ // Exactly owner/repo: a pasted page URL (…/tree/main, /orgs/<name>/…) would
22
+ // otherwise be read as some other repository.
23
+ if (parts.length !== 2) return null;
24
+ const owner = parts[0];
25
+ const repo = parts[1].replace(/\.git$/i, '');
26
+ if (!OWNER_PATTERN.test(owner) || owner.endsWith('-')) return null;
27
+ if (!REPO_PATTERN.test(repo) || repo === '.' || repo === '..') return null;
28
+ return { owner, repo };
29
+ }
30
+
31
+ /**
32
+ * The canonical https form of a GitHub repository reference, or null when the
33
+ * text is not one. Accepts `https://github.com/owner/repo` (with `.git`, a
34
+ * trailing slash, or credentials in front of the host, which are dropped),
35
+ * `git@github.com:owner/repo.git`, `ssh://git@github.com/owner/repo`,
36
+ * `git://…` and the scheme-less `github.com/owner/repo`.
37
+ */
38
+ export function normalizeGitHubRepoUrl(input) {
39
+ const text = String(input ?? '').trim();
40
+ if (!text || /\s/.test(text)) return null;
41
+
42
+ let host = '';
43
+ let pathText = '';
44
+ // scp-like: [user@]host:owner/repo(.git). A scheme is followed by `//`, so
45
+ // `https://…` and `ssh://…` never match here.
46
+ const scp = text.match(/^(?:[A-Za-z0-9._-]+@)?([A-Za-z0-9.-]+):(?!\/\/)(.+)$/);
47
+ if (scp) {
48
+ host = scp[1];
49
+ pathText = scp[2];
50
+ } else {
51
+ const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(text) ? text : `https://${text}`;
52
+ let url;
53
+ try {
54
+ url = new URL(withScheme);
55
+ } catch {
56
+ return null;
57
+ }
58
+ if (!['https:', 'http:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol)) return null;
59
+ host = url.hostname;
60
+ pathText = url.pathname;
61
+ }
62
+
63
+ if (!GITHUB_HOSTS.has(host.toLowerCase())) return null;
64
+ const parsed = ownerAndRepoFromPath(pathText);
65
+ if (!parsed) return null;
66
+ const slug = `${parsed.owner}/${parsed.repo}`;
67
+ return { repoUrl: `https://github.com/${slug}`, owner: parsed.owner, repo: parsed.repo, slug };
68
+ }
69
+
70
+ /**
71
+ * Whether git would accept the text as a branch name (the rules of
72
+ * `git check-ref-format --branch`). Nothing is trimmed: a name with a space
73
+ * around it is not a branch name.
74
+ */
75
+ export function isValidBranchName(value) {
76
+ if (typeof value !== 'string') return false;
77
+ if (!value || value.length > MAX_BRANCH_LENGTH) return false;
78
+ if (value === '@' || value.startsWith('-')) return false;
79
+ // Control characters, space, and the characters git reserves for revisions.
80
+ if (/[\u0000- \u007f~^:?*[\\]/.test(value)) return false;
81
+ if (value.includes('..') || value.includes('@{') || value.includes('//')) return false;
82
+ if (value.startsWith('/') || value.endsWith('/') || value.endsWith('.')) return false;
83
+ return value.split('/').every((part) => !part.startsWith('.') && !part.endsWith('.lock'));
84
+ }
85
+
86
+ /**
87
+ * A model id without its tier suffix: "claude-opus-5[1m]" → "claude-opus-5".
88
+ * The cloud takes plain ids only.
89
+ */
90
+ export function stripModelTierSuffix(model) {
91
+ return String(model ?? '').trim().replace(/\s*\[[^\]]*\]$/, '').trim();
92
+ }
93
+
94
+ function encodeRefForUrl(ref) {
95
+ return ref.split('/').map((part) => encodeURIComponent(part)).join('/');
96
+ }
97
+
98
+ /**
99
+ * Where GitHub shows what a pushed branch changed: the compare page against
100
+ * `base`, the compare page against the default branch when no base is known,
101
+ * and the branch itself when it is the base (a compare of a branch with itself
102
+ * is empty). Null when the slug or the branch is not usable.
103
+ */
104
+ export function compareUrl(slug, base, branch) {
105
+ const repo = normalizeGitHubRepoUrl(`https://github.com/${String(slug ?? '').trim()}`);
106
+ if (!repo || !isValidBranchName(branch)) return null;
107
+ const head = encodeRefForUrl(branch);
108
+ if (base === branch) return `${repo.repoUrl}/tree/${head}`;
109
+ if (!isValidBranchName(base)) return `${repo.repoUrl}/compare/${head}`;
110
+ return `${repo.repoUrl}/compare/${encodeRefForUrl(base)}...${head}`;
111
+ }
@@ -11,7 +11,9 @@
11
11
  */
12
12
  export const LEGACY_RELAY_PROVIDER_TYPES = Object.freeze(['github', 'openai']);
13
13
 
14
- export const SESSION_WORKER_PROVIDER_TYPES = Object.freeze(['claude', 'cursor', 'grok']);
14
+ // `claude-cloud` is its own provider, never an alias of `claude`: its worker
15
+ // drives a session in Anthropic's cloud and spawns no CLI.
16
+ export const SESSION_WORKER_PROVIDER_TYPES = Object.freeze(['claude', 'cursor', 'grok', 'claude-cloud']);
15
17
 
16
18
  export function isLegacyRelayProviderType(value) {
17
19
  const normalized = String(value || '').trim().toLowerCase();
@@ -23,7 +25,7 @@ export function isSessionWorkerProviderType(value) {
23
25
  return SESSION_WORKER_PROVIDER_TYPES.includes(String(value || '').trim().toLowerCase());
24
26
  }
25
27
 
26
- /** `'claude', 'cursor', 'grok'` — for interpolation into SQL NOT IN (...) lists. */
28
+ /** `'claude', 'cursor', 'grok', 'claude-cloud'` — for interpolation into SQL NOT IN (...) lists. */
27
29
  export function sessionWorkerProviderSqlList() {
28
30
  return SESSION_WORKER_PROVIDER_TYPES.map((provider) => `'${provider}'`).join(', ');
29
31
  }