@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +340 -0
- package/README.md +196 -26
- package/package.json +2 -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 +151 -1
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +24 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- 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 +160 -6
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +347 -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 +15 -3
- 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 +178 -8
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +704 -28
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- 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-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +28 -5
- 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 +60 -0
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +471 -8
- package/server/relay-tools.md +13 -1
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +72 -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 +202 -26
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +538 -72
- package/server/server-runtime.mjs +291 -27
- 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/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +78 -19
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +451 -39
- package/server/services/remote-relay-inbound.mjs +83 -1
- 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 +410 -138
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +89 -2
- package/server/services/usage-limit-pause-service.mjs +443 -0
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/ask-user-bridge.mjs +14 -39
- 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/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +167 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +17 -0
- package/shared/steer-settle-markers.mjs +8 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- package/shared/worker-runtime/turn-liveness.mjs +249 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// What the relay keeps about a Claude Cloud conversation (provider
|
|
4
|
+
// `claude-cloud`), and the two things it does with it besides showing it:
|
|
5
|
+
// taking the worker's reports and archiving the cloud session.
|
|
6
|
+
//
|
|
7
|
+
// A cloud conversation has two records. `conversations.cloud_source_json` says
|
|
8
|
+
// what the session clones and where it is shown (`{ repoUrl, branch,
|
|
9
|
+
// environmentId, sessionUrl, pushedBranches }`); the runtime session row holds
|
|
10
|
+
// the binding the worker reports (`claude_cloud_session_id`, the last handled
|
|
11
|
+
// event sequence, the cost). Nothing here ever sees the Claude login: the
|
|
12
|
+
// cloud client that archives is injected and reads the token itself.
|
|
13
|
+
|
|
14
|
+
import { isValidBranchName, normalizeGitHubRepoUrl } from '../../shared/claude-cloud/repo-url.mjs';
|
|
15
|
+
|
|
16
|
+
export const CLAUDE_CLOUD_PROVIDER_TYPE = 'claude-cloud';
|
|
17
|
+
export const CLAUDE_CLOUD_PROVIDER_LABEL = 'Claude Cloud';
|
|
18
|
+
|
|
19
|
+
// A session that pushes more branches than this keeps the newest ones.
|
|
20
|
+
const MAX_PUSHED_BRANCHES = 20;
|
|
21
|
+
// Ids go into request paths (the worker's and the archive call's), so only
|
|
22
|
+
// the characters the cloud uses for them are taken.
|
|
23
|
+
const CLOUD_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
24
|
+
const SEQUENCE_PATTERN = /^\d{1,30}$/;
|
|
25
|
+
|
|
26
|
+
function toText(value) {
|
|
27
|
+
return typeof value === 'string' ? value.trim() : (typeof value === 'number' ? String(value) : '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isPlainObject(value) {
|
|
31
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isClaudeCloudProviderType(value) {
|
|
35
|
+
return String(value || '').trim().toLowerCase() === CLAUDE_CLOUD_PROVIDER_TYPE;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isSafeClaudeCloudId(value) {
|
|
39
|
+
return CLOUD_ID_PATTERN.test(String(value || ''));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A link the browser may show: https only, so a stored value is never a script URL. */
|
|
43
|
+
function normalizeSessionUrl(value) {
|
|
44
|
+
const text = toText(value);
|
|
45
|
+
if (!text || text.length > 500) return null;
|
|
46
|
+
try {
|
|
47
|
+
const url = new URL(text);
|
|
48
|
+
return url.protocol === 'https:' ? url.toString() : null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizePushedBranches(value) {
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const entry of Array.isArray(value) ? value : []) {
|
|
57
|
+
const branch = isPlainObject(entry) ? entry.branch : entry;
|
|
58
|
+
if (!isValidBranchName(branch)) continue;
|
|
59
|
+
if (out.some((existing) => existing.branch === branch)) continue;
|
|
60
|
+
out.push({ branch, at: toText(entry?.at) || null });
|
|
61
|
+
}
|
|
62
|
+
return out.slice(-MAX_PUSHED_BRANCHES);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The stored cloud source of a conversation, or null when the column is empty
|
|
67
|
+
* or does not name a repository (every other conversation).
|
|
68
|
+
*/
|
|
69
|
+
export function parseCloudSourceJson(raw) {
|
|
70
|
+
let parsed = raw;
|
|
71
|
+
if (typeof raw === 'string') {
|
|
72
|
+
if (!raw.trim()) return null;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(raw);
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!isPlainObject(parsed)) return null;
|
|
80
|
+
const repo = normalizeGitHubRepoUrl(parsed.repoUrl);
|
|
81
|
+
if (!repo) return null;
|
|
82
|
+
return {
|
|
83
|
+
repoUrl: repo.repoUrl,
|
|
84
|
+
branch: isValidBranchName(parsed.branch) ? parsed.branch : null,
|
|
85
|
+
environmentId: isSafeClaudeCloudId(parsed.environmentId) ? parsed.environmentId : null,
|
|
86
|
+
sessionUrl: normalizeSessionUrl(parsed.sessionUrl),
|
|
87
|
+
pushedBranches: normalizePushedBranches(parsed.pushedBranches),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Checks the `cloudSource` of a bootstrap request. The repository comes back
|
|
93
|
+
* in its canonical https form; an empty branch means the default branch.
|
|
94
|
+
*/
|
|
95
|
+
export function validateCloudSourceRequest(cloudSource) {
|
|
96
|
+
const source = isPlainObject(cloudSource) ? cloudSource : {};
|
|
97
|
+
const repo = normalizeGitHubRepoUrl(source.repoUrl);
|
|
98
|
+
if (!repo) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
code: 'claude_cloud_repo_invalid',
|
|
102
|
+
error: 'A Claude Cloud conversation needs a GitHub repository (for example https://github.com/owner/repo).',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const branch = typeof source.branch === 'string' ? source.branch.trim() : '';
|
|
106
|
+
if (source.branch != null && typeof source.branch !== 'string') {
|
|
107
|
+
return { ok: false, code: 'claude_cloud_branch_invalid', error: 'The branch must be a branch name.' };
|
|
108
|
+
}
|
|
109
|
+
if (branch && !isValidBranchName(branch)) {
|
|
110
|
+
return { ok: false, code: 'claude_cloud_branch_invalid', error: `"${branch.slice(0, 80)}" is not a valid branch name.` };
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, repoUrl: repo.repoUrl, slug: repo.slug, branch: branch || null };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The `cloud_source_json` of a new conversation. */
|
|
116
|
+
export function buildCloudSourceRecord({ repoUrl, branch = null, environmentId = null } = {}) {
|
|
117
|
+
return {
|
|
118
|
+
repoUrl,
|
|
119
|
+
branch: branch || null,
|
|
120
|
+
environmentId: environmentId || null,
|
|
121
|
+
sessionUrl: null,
|
|
122
|
+
pushedBranches: [],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeCostUsd(value) {
|
|
127
|
+
if (value === null || value === undefined || value === '') return null;
|
|
128
|
+
const number = Number(value);
|
|
129
|
+
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The `cloud` field of the conversation list and detail payloads:
|
|
134
|
+
* `{ repoUrl, slug, branch, sessionUrl, pushedBranches, costUsd }`, or null
|
|
135
|
+
* for a conversation that is not a cloud one.
|
|
136
|
+
*/
|
|
137
|
+
export function buildConversationCloudPayload({ cloudSourceJson = null, costUsd = null } = {}) {
|
|
138
|
+
const source = parseCloudSourceJson(cloudSourceJson);
|
|
139
|
+
if (!source) return null;
|
|
140
|
+
return {
|
|
141
|
+
repoUrl: source.repoUrl,
|
|
142
|
+
slug: normalizeGitHubRepoUrl(source.repoUrl)?.slug || null,
|
|
143
|
+
branch: source.branch,
|
|
144
|
+
sessionUrl: source.sessionUrl,
|
|
145
|
+
pushedBranches: source.pushedBranches,
|
|
146
|
+
costUsd: normalizeCostUsd(costUsd),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The `claudeCloud` field of a delivered message: what the worker needs to
|
|
152
|
+
* create the session (first message) or to send into it (every later one).
|
|
153
|
+
* Null for another provider, and for a cloud conversation whose source is
|
|
154
|
+
* missing: the worker answers that with its own "no repository" reply.
|
|
155
|
+
*/
|
|
156
|
+
export function buildClaudeCloudDelivery({ conversation = null, runtimeSession = null, fallbackEnvironmentId = '' } = {}) {
|
|
157
|
+
if (!isClaudeCloudProviderType(runtimeSession?.provider_type)) return null;
|
|
158
|
+
const source = parseCloudSourceJson(conversation?.cloud_source_json);
|
|
159
|
+
if (!source) return null;
|
|
160
|
+
const sessionId = toText(runtimeSession?.claude_cloud_session_id);
|
|
161
|
+
const lastSequence = toText(runtimeSession?.claude_cloud_last_sequence);
|
|
162
|
+
const fallback = toText(fallbackEnvironmentId);
|
|
163
|
+
return {
|
|
164
|
+
sessionId: isSafeClaudeCloudId(sessionId) ? sessionId : null,
|
|
165
|
+
lastSequence: SEQUENCE_PATTERN.test(lastSequence) ? lastSequence : null,
|
|
166
|
+
repoUrl: source.repoUrl,
|
|
167
|
+
branch: source.branch,
|
|
168
|
+
environmentId: source.environmentId || (isSafeClaudeCloudId(fallback) ? fallback : null),
|
|
169
|
+
title: toText(conversation?.title) || 'New Conversation',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The larger of two sequence numbers (strings of digits), so a late report never moves the cursor back. */
|
|
174
|
+
function laterSequence(stored, reported) {
|
|
175
|
+
const a = SEQUENCE_PATTERN.test(toText(stored)) ? toText(stored) : null;
|
|
176
|
+
const b = SEQUENCE_PATTERN.test(toText(reported)) ? toText(reported) : null;
|
|
177
|
+
if (!a) return b;
|
|
178
|
+
if (!b) return a;
|
|
179
|
+
return BigInt(b) > BigInt(a) ? b : a;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function createClaudeCloudSessionService({
|
|
183
|
+
stmts,
|
|
184
|
+
// The shared cloud client (archive). A getter, so the server can build the
|
|
185
|
+
// client lazily and tests can leave it out.
|
|
186
|
+
getCloudClient = () => null,
|
|
187
|
+
emit = () => {},
|
|
188
|
+
now = () => new Date(),
|
|
189
|
+
logger = console,
|
|
190
|
+
} = {}) {
|
|
191
|
+
function nowIso() {
|
|
192
|
+
const value = now();
|
|
193
|
+
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function cloudPayloadFor(conversation, runtimeSession) {
|
|
197
|
+
return buildConversationCloudPayload({
|
|
198
|
+
cloudSourceJson: conversation?.cloud_source_json,
|
|
199
|
+
costUsd: runtimeSession?.claude_cloud_cost_usd,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* POST /api/claude-cloud-session: the worker's binding report (after the
|
|
205
|
+
* session was created, after every `result`, and on a push).
|
|
206
|
+
* Returns `{ ok, statusCode, error }` or `{ ok: true, cloud }`.
|
|
207
|
+
*/
|
|
208
|
+
function recordWorkerReport(body = {}) {
|
|
209
|
+
const conversationId = toText(body?.conversationId);
|
|
210
|
+
const cloudSessionId = toText(body?.cloudSessionId);
|
|
211
|
+
if (!conversationId || !cloudSessionId) {
|
|
212
|
+
return { ok: false, statusCode: 400, error: 'Missing conversationId or cloudSessionId' };
|
|
213
|
+
}
|
|
214
|
+
if (!isSafeClaudeCloudId(cloudSessionId)) {
|
|
215
|
+
return { ok: false, statusCode: 400, error: 'Invalid cloudSessionId' };
|
|
216
|
+
}
|
|
217
|
+
const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
|
|
218
|
+
if (!runtimeSession) {
|
|
219
|
+
return { ok: false, statusCode: 404, error: 'Runtime session not found for conversation' };
|
|
220
|
+
}
|
|
221
|
+
if (!isClaudeCloudProviderType(runtimeSession.provider_type || 'github')) {
|
|
222
|
+
return { ok: false, statusCode: 409, error: 'Conversation is not bound to the Claude Cloud provider' };
|
|
223
|
+
}
|
|
224
|
+
if (typeof stmts.updateRuntimeSessionClaudeCloudSession?.run !== 'function'
|
|
225
|
+
|| typeof stmts.updateConvCloudSource?.run !== 'function') {
|
|
226
|
+
return { ok: false, statusCode: 500, error: 'Claude Cloud session storage is unavailable' };
|
|
227
|
+
}
|
|
228
|
+
const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
|
|
229
|
+
const source = parseCloudSourceJson(conversation?.cloud_source_json);
|
|
230
|
+
if (!conversation || !source) {
|
|
231
|
+
return { ok: false, statusCode: 409, error: 'Conversation has no cloud repository' };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The sequence and the cost belong to one cloud session: a report naming
|
|
235
|
+
// another session (the first one was replaced) starts both again.
|
|
236
|
+
const storedSessionId = toText(runtimeSession.claude_cloud_session_id);
|
|
237
|
+
const sameSession = !storedSessionId || storedSessionId === cloudSessionId;
|
|
238
|
+
const reportedCost = normalizeCostUsd(body?.costUsd);
|
|
239
|
+
const lastSequence = laterSequence(sameSession ? runtimeSession.claude_cloud_last_sequence : null, body?.lastSequence);
|
|
240
|
+
const costUsd = reportedCost !== null
|
|
241
|
+
? reportedCost
|
|
242
|
+
: (sameSession ? normalizeCostUsd(runtimeSession.claude_cloud_cost_usd) : null);
|
|
243
|
+
const timestamp = nowIso();
|
|
244
|
+
stmts.updateRuntimeSessionClaudeCloudSession.run(cloudSessionId, lastSequence, costUsd, timestamp, conversationId);
|
|
245
|
+
|
|
246
|
+
const next = {
|
|
247
|
+
...source,
|
|
248
|
+
sessionUrl: normalizeSessionUrl(body?.sessionUrl) || (sameSession ? source.sessionUrl : null),
|
|
249
|
+
pushedBranches: sameSession ? [...source.pushedBranches] : [],
|
|
250
|
+
};
|
|
251
|
+
const pushedBranch = typeof body?.pushedBranch === 'string' ? body.pushedBranch.trim() : '';
|
|
252
|
+
if (pushedBranch && isValidBranchName(pushedBranch)) {
|
|
253
|
+
// A branch pushed again moves to the end with its new time.
|
|
254
|
+
next.pushedBranches = [
|
|
255
|
+
...next.pushedBranches.filter((entry) => entry.branch !== pushedBranch),
|
|
256
|
+
{ branch: pushedBranch, at: timestamp },
|
|
257
|
+
].slice(-MAX_PUSHED_BRANCHES);
|
|
258
|
+
}
|
|
259
|
+
const nextJson = JSON.stringify(next);
|
|
260
|
+
stmts.updateConvCloudSource.run(nextJson, conversationId);
|
|
261
|
+
|
|
262
|
+
const cloud = buildConversationCloudPayload({ cloudSourceJson: nextJson, costUsd });
|
|
263
|
+
emit('claude_cloud_session', { conversationId, cloud });
|
|
264
|
+
return { ok: true, cloud };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Archives the cloud session of a conversation that is being deleted or
|
|
269
|
+
* archived here. Best effort: it returns at once (true when an archive was
|
|
270
|
+
* started), and a failure is logged and never reaches the caller.
|
|
271
|
+
*/
|
|
272
|
+
function archiveSessionInBackground({ conversationId = '', runtimeSession = null } = {}) {
|
|
273
|
+
if (!isClaudeCloudProviderType(runtimeSession?.provider_type)) return false;
|
|
274
|
+
const cloudSessionId = toText(runtimeSession?.claude_cloud_session_id);
|
|
275
|
+
if (!isSafeClaudeCloudId(cloudSessionId)) return false;
|
|
276
|
+
let client = null;
|
|
277
|
+
try {
|
|
278
|
+
client = getCloudClient();
|
|
279
|
+
} catch {
|
|
280
|
+
client = null;
|
|
281
|
+
}
|
|
282
|
+
if (typeof client?.archiveSession !== 'function') return false;
|
|
283
|
+
const label = toText(conversationId).slice(0, 8) || 'unknown';
|
|
284
|
+
Promise.resolve()
|
|
285
|
+
.then(() => client.archiveSession(cloudSessionId))
|
|
286
|
+
.then(() => {
|
|
287
|
+
logger?.log?.(`[claude-cloud] archived the cloud session of conversation ${label}`);
|
|
288
|
+
})
|
|
289
|
+
.catch((error) => {
|
|
290
|
+
// The client's errors carry a code and a redacted message, never the token.
|
|
291
|
+
const reason = toText(error?.code) || toText(error?.message).slice(0, 200) || 'unknown error';
|
|
292
|
+
logger?.warn?.(`[claude-cloud] the cloud session of conversation ${label} was not archived: ${reason}`);
|
|
293
|
+
});
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The reverse, for a conversation that is unarchived here: the cloud
|
|
299
|
+
* session comes back so the next message reaches it. Same best-effort rule.
|
|
300
|
+
*/
|
|
301
|
+
function unarchiveSessionInBackground({ conversationId = '', runtimeSession = null } = {}) {
|
|
302
|
+
if (!isClaudeCloudProviderType(runtimeSession?.provider_type)) return false;
|
|
303
|
+
const cloudSessionId = toText(runtimeSession?.claude_cloud_session_id);
|
|
304
|
+
if (!isSafeClaudeCloudId(cloudSessionId)) return false;
|
|
305
|
+
let client = null;
|
|
306
|
+
try {
|
|
307
|
+
client = getCloudClient();
|
|
308
|
+
} catch {
|
|
309
|
+
client = null;
|
|
310
|
+
}
|
|
311
|
+
if (typeof client?.unarchiveSession !== 'function') return false;
|
|
312
|
+
const label = toText(conversationId).slice(0, 8) || 'unknown';
|
|
313
|
+
Promise.resolve()
|
|
314
|
+
.then(() => client.unarchiveSession(cloudSessionId))
|
|
315
|
+
.then(() => {
|
|
316
|
+
logger?.log?.(`[claude-cloud] unarchived the cloud session of conversation ${label}`);
|
|
317
|
+
})
|
|
318
|
+
.catch((error) => {
|
|
319
|
+
const reason = toText(error?.code) || toText(error?.message).slice(0, 200) || 'unknown error';
|
|
320
|
+
logger?.warn?.(`[claude-cloud] the cloud session of conversation ${label} was not unarchived: ${reason}`);
|
|
321
|
+
});
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return { cloudPayloadFor, recordWorkerReport, archiveSessionInBackground, unarchiveSessionInBackground };
|
|
326
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Settings of the Claude Cloud provider (Settings → Providers → Claude Cloud).
|
|
4
|
+
//
|
|
5
|
+
// Three stored values: the switch (off by default), the default model of a
|
|
6
|
+
// new cloud chat, and the cloud environment its sessions run in. Everything
|
|
7
|
+
// else the tab shows is read, never stored: the account from the Claude CLI's
|
|
8
|
+
// own status, the state of its login from the credentials module (source and
|
|
9
|
+
// expiry, never the token), the environments from the cloud.
|
|
10
|
+
//
|
|
11
|
+
// While the provider is switched off nothing here talks to Anthropic: the
|
|
12
|
+
// environments are only listed once it is on.
|
|
13
|
+
|
|
14
|
+
import { isSafeClaudeModelId } from '../../shared/model-id.mjs';
|
|
15
|
+
import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
|
|
16
|
+
import { CLAUDE_CLOUD_PROVIDER_TYPE, isSafeClaudeCloudId } from './claude-cloud-session-service.mjs';
|
|
17
|
+
|
|
18
|
+
export const CLAUDE_CLOUD_ENABLED_SETTING_KEY = 'claude_cloud_enabled';
|
|
19
|
+
export const CLAUDE_CLOUD_DEFAULT_MODEL_SETTING_KEY = 'claude_cloud_default_model';
|
|
20
|
+
export const CLAUDE_CLOUD_ENVIRONMENT_SETTING_KEY = 'claude_cloud_environment_id';
|
|
21
|
+
export const DEFAULT_CLAUDE_CLOUD_MODEL = 'claude-sonnet-5-5';
|
|
22
|
+
|
|
23
|
+
const ENVIRONMENTS_CACHE_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
function toText(value) {
|
|
26
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hasOwn(object, key) {
|
|
30
|
+
return Object.prototype.hasOwnProperty.call(object, key);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The cloud's model ids for a Claude catalog: the tier suffix dropped
|
|
35
|
+
* ("claude-opus-5[1m]" and "claude-opus-5" are one cloud model), no
|
|
36
|
+
* duplicates, the default first.
|
|
37
|
+
*/
|
|
38
|
+
export function buildClaudeCloudModelList(defaultModel, catalogModels = []) {
|
|
39
|
+
const out = [];
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
for (const candidate of [defaultModel, ...(Array.isArray(catalogModels) ? catalogModels : [])]) {
|
|
42
|
+
const model = stripModelTierSuffix(candidate);
|
|
43
|
+
const key = model.toLowerCase();
|
|
44
|
+
if (!model || seen.has(key) || !isSafeClaudeModelId(model)) continue;
|
|
45
|
+
seen.add(key);
|
|
46
|
+
out.push(model);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Reads `{ enabled?, defaultModel?, environmentId? }` from a request body. */
|
|
52
|
+
export function parseClaudeCloudSettingsUpdateRequest(body = {}) {
|
|
53
|
+
const payload = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
54
|
+
const update = {};
|
|
55
|
+
if (hasOwn(payload, 'enabled')) {
|
|
56
|
+
if (typeof payload.enabled !== 'boolean') return { ok: false, error: 'enabled must be true or false' };
|
|
57
|
+
update.enabled = payload.enabled;
|
|
58
|
+
}
|
|
59
|
+
if (hasOwn(payload, 'defaultModel')) {
|
|
60
|
+
const model = stripModelTierSuffix(payload.defaultModel);
|
|
61
|
+
if (!model || !isSafeClaudeModelId(model)) return { ok: false, error: 'Invalid Claude Cloud model ID' };
|
|
62
|
+
update.defaultModel = model;
|
|
63
|
+
}
|
|
64
|
+
if (hasOwn(payload, 'environmentId')) {
|
|
65
|
+
const environmentId = payload.environmentId === null ? '' : payload.environmentId;
|
|
66
|
+
if (typeof environmentId !== 'string') return { ok: false, error: 'Invalid environment id' };
|
|
67
|
+
const trimmed = environmentId.trim();
|
|
68
|
+
if (trimmed && !isSafeClaudeCloudId(trimmed)) return { ok: false, error: 'Invalid environment id' };
|
|
69
|
+
update.environmentId = trimmed;
|
|
70
|
+
}
|
|
71
|
+
if (!Object.keys(update).length) return { ok: false, error: 'No Claude Cloud settings update provided' };
|
|
72
|
+
return { ok: true, ...update };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createClaudeCloudSettingsService({
|
|
76
|
+
readSetting = () => '',
|
|
77
|
+
writeSetting = null,
|
|
78
|
+
deleteSetting = null,
|
|
79
|
+
// The Claude provider's settings: its model catalog is the cloud's too.
|
|
80
|
+
getClaudeProviderSettings = () => null,
|
|
81
|
+
// `claude auth status` of the host CLI (cached there); the account line.
|
|
82
|
+
claudeAuthService = null,
|
|
83
|
+
// shared/claude-cloud/credentials.mjs → describe(); never the token.
|
|
84
|
+
credentials = null,
|
|
85
|
+
// shared/claude-cloud/api-client.mjs → listEnvironments().
|
|
86
|
+
cloud = null,
|
|
87
|
+
emit = () => {},
|
|
88
|
+
now = () => Date.now(),
|
|
89
|
+
logger = console,
|
|
90
|
+
} = {}) {
|
|
91
|
+
let environmentsCache = null;
|
|
92
|
+
let environmentsInFlight = null;
|
|
93
|
+
|
|
94
|
+
function readEnabled() {
|
|
95
|
+
return toText(readSetting(CLAUDE_CLOUD_ENABLED_SETTING_KEY)) === 'true';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readDefaultModel() {
|
|
99
|
+
const stored = stripModelTierSuffix(readSetting(CLAUDE_CLOUD_DEFAULT_MODEL_SETTING_KEY));
|
|
100
|
+
return stored && isSafeClaudeModelId(stored) ? stored : DEFAULT_CLAUDE_CLOUD_MODEL;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readEnvironmentId() {
|
|
104
|
+
const stored = toText(readSetting(CLAUDE_CLOUD_ENVIRONMENT_SETTING_KEY));
|
|
105
|
+
return isSafeClaudeCloudId(stored) ? stored : '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function listModels(defaultModel) {
|
|
109
|
+
const claude = getClaudeProviderSettings() || {};
|
|
110
|
+
const catalog = Array.isArray(claude.models) && claude.models.length
|
|
111
|
+
? claude.models
|
|
112
|
+
: (Array.isArray(claude.availableModels) ? claude.availableModels : []);
|
|
113
|
+
return buildClaudeCloudModelList(defaultModel, catalog);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** What is stored, without any I/O beyond the settings table. */
|
|
117
|
+
function getSettings() {
|
|
118
|
+
const defaultModel = readDefaultModel();
|
|
119
|
+
return {
|
|
120
|
+
enabled: readEnabled(),
|
|
121
|
+
defaultModel,
|
|
122
|
+
environmentId: readEnvironmentId(),
|
|
123
|
+
models: listModels(defaultModel),
|
|
124
|
+
providerType: CLAUDE_CLOUD_PROVIDER_TYPE,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function describeToken() {
|
|
129
|
+
let described = null;
|
|
130
|
+
try {
|
|
131
|
+
described = credentials?.describe?.() || null;
|
|
132
|
+
} catch {
|
|
133
|
+
described = null;
|
|
134
|
+
}
|
|
135
|
+
const source = ['env', 'file'].includes(described?.source) ? described.source : 'none';
|
|
136
|
+
return {
|
|
137
|
+
source,
|
|
138
|
+
hasToken: described?.hasToken === true,
|
|
139
|
+
expiresAt: toText(described?.expiresAt) || null,
|
|
140
|
+
subscriptionType: toText(described?.subscriptionType) || null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function describeAccount(token) {
|
|
145
|
+
if (typeof claudeAuthService?.getStatus !== 'function') return null;
|
|
146
|
+
let status = null;
|
|
147
|
+
try {
|
|
148
|
+
status = await claudeAuthService.getStatus();
|
|
149
|
+
} catch {
|
|
150
|
+
status = claudeAuthService.getCachedStatus?.() || null;
|
|
151
|
+
}
|
|
152
|
+
if (!status || status.ok === false) return null;
|
|
153
|
+
return {
|
|
154
|
+
loggedIn: status.loggedIn === true,
|
|
155
|
+
email: toText(status.email) || null,
|
|
156
|
+
orgName: toText(status.orgName) || null,
|
|
157
|
+
subscriptionType: toText(status.subscriptionType) || token.subscriptionType || null,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function redact(text) {
|
|
162
|
+
const value = String(text ?? '');
|
|
163
|
+
try {
|
|
164
|
+
return typeof credentials?.redact === 'function' ? credentials.redact(value) : value;
|
|
165
|
+
} catch {
|
|
166
|
+
return 'The cloud environments could not be read.';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function loadEnvironments({ force = false } = {}) {
|
|
171
|
+
if (!force && environmentsCache && (now() - environmentsCache.at) < ENVIRONMENTS_CACHE_MS) {
|
|
172
|
+
return environmentsCache.value;
|
|
173
|
+
}
|
|
174
|
+
if (environmentsInFlight) return environmentsInFlight;
|
|
175
|
+
environmentsInFlight = (async () => {
|
|
176
|
+
let value;
|
|
177
|
+
try {
|
|
178
|
+
if (typeof cloud?.listEnvironments !== 'function') throw new Error('The cloud client is unavailable.');
|
|
179
|
+
const listed = await cloud.listEnvironments();
|
|
180
|
+
const environments = (Array.isArray(listed) ? listed : [])
|
|
181
|
+
.filter((entry) => isSafeClaudeCloudId(entry?.id) && (!toText(entry?.state) || toText(entry.state) === 'active'))
|
|
182
|
+
.map((entry) => ({ id: entry.id, name: toText(entry?.name) || entry.id }));
|
|
183
|
+
value = { environments, environmentsError: null };
|
|
184
|
+
} catch (error) {
|
|
185
|
+
value = {
|
|
186
|
+
environments: null,
|
|
187
|
+
environmentsError: redact(toText(error?.message) || 'The cloud environments could not be read.').slice(0, 300),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
environmentsCache = { at: now(), value };
|
|
191
|
+
return value;
|
|
192
|
+
})().finally(() => { environmentsInFlight = null; });
|
|
193
|
+
return environmentsInFlight;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The environment new sessions run in: the stored one, else the first
|
|
198
|
+
* active environment of the account, which is then stored. Empty when
|
|
199
|
+
* there is none (or the cloud cannot be asked).
|
|
200
|
+
*/
|
|
201
|
+
async function resolveEnvironmentId({ force = false } = {}) {
|
|
202
|
+
const stored = readEnvironmentId();
|
|
203
|
+
if (stored) return stored;
|
|
204
|
+
if (!describeToken().hasToken) return '';
|
|
205
|
+
const { environments } = await loadEnvironments({ force });
|
|
206
|
+
const first = Array.isArray(environments) && environments.length ? environments[0].id : '';
|
|
207
|
+
if (first && typeof writeSetting === 'function') {
|
|
208
|
+
writeSetting(CLAUDE_CLOUD_ENVIRONMENT_SETTING_KEY, first);
|
|
209
|
+
logger?.log?.(`[claude-cloud] using cloud environment ${first}`);
|
|
210
|
+
}
|
|
211
|
+
return first;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The body of GET /api/settings/claude-cloud. */
|
|
215
|
+
async function describe({ refreshEnvironments = false } = {}) {
|
|
216
|
+
const token = describeToken();
|
|
217
|
+
const enabled = readEnabled();
|
|
218
|
+
// Nothing is asked of Anthropic while the provider is off.
|
|
219
|
+
const listing = enabled && token.hasToken
|
|
220
|
+
? await loadEnvironments({ force: refreshEnvironments })
|
|
221
|
+
: { environments: null, environmentsError: null };
|
|
222
|
+
if (enabled && token.hasToken) await resolveEnvironmentId();
|
|
223
|
+
const settings = getSettings();
|
|
224
|
+
return {
|
|
225
|
+
enabled: settings.enabled,
|
|
226
|
+
defaultModel: settings.defaultModel,
|
|
227
|
+
environmentId: settings.environmentId || null,
|
|
228
|
+
environments: listing.environments,
|
|
229
|
+
environmentsError: listing.environmentsError,
|
|
230
|
+
account: await describeAccount(token),
|
|
231
|
+
token: { source: token.source, hasToken: token.hasToken, expiresAt: token.expiresAt },
|
|
232
|
+
models: settings.models,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* POST /api/settings/claude-cloud. Returns `{ ok: true, settings }` (the GET
|
|
238
|
+
* body) or `{ ok: false, statusCode, error, code? }`.
|
|
239
|
+
*/
|
|
240
|
+
async function update(body = {}) {
|
|
241
|
+
const parsed = parseClaudeCloudSettingsUpdateRequest(body);
|
|
242
|
+
if (!parsed.ok) return { ok: false, statusCode: 400, error: parsed.error };
|
|
243
|
+
if (typeof writeSetting !== 'function') {
|
|
244
|
+
return { ok: false, statusCode: 500, error: 'Claude Cloud settings are unavailable' };
|
|
245
|
+
}
|
|
246
|
+
if (parsed.enabled === true && !readEnabled() && !describeToken().hasToken) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
statusCode: 400,
|
|
250
|
+
code: 'claude_cloud_login_missing',
|
|
251
|
+
error: 'Claude Cloud needs the Claude CLI login on this host. Log in on Settings → Providers → Claude, then switch it on.',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (hasOwn(parsed, 'enabled')) writeSetting(CLAUDE_CLOUD_ENABLED_SETTING_KEY, parsed.enabled ? 'true' : 'false');
|
|
255
|
+
if (hasOwn(parsed, 'defaultModel')) writeSetting(CLAUDE_CLOUD_DEFAULT_MODEL_SETTING_KEY, parsed.defaultModel);
|
|
256
|
+
if (hasOwn(parsed, 'environmentId')) {
|
|
257
|
+
if (parsed.environmentId) writeSetting(CLAUDE_CLOUD_ENVIRONMENT_SETTING_KEY, parsed.environmentId);
|
|
258
|
+
else if (typeof deleteSetting === 'function') deleteSetting(CLAUDE_CLOUD_ENVIRONMENT_SETTING_KEY);
|
|
259
|
+
else writeSetting(CLAUDE_CLOUD_ENVIRONMENT_SETTING_KEY, '');
|
|
260
|
+
}
|
|
261
|
+
// Switching it on reads the environments afresh: the account may have
|
|
262
|
+
// changed since they were last listed.
|
|
263
|
+
const settings = await describe({ refreshEnvironments: parsed.enabled === true });
|
|
264
|
+
emit('claude_cloud_settings_updated', settings);
|
|
265
|
+
return { ok: true, settings };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { getSettings, describe, update, resolveEnvironmentId };
|
|
269
|
+
}
|