@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
|
@@ -11,6 +11,7 @@ import { applySafeServedContentHeaders } from '../services/safe-served-content.m
|
|
|
11
11
|
import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
|
|
12
12
|
import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
|
|
13
13
|
import { parseAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
|
|
14
|
+
import { CLAUDE_ATTRIBUTION_MODES, DEFAULT_CLAUDE_ATTRIBUTION_MODE, claudeAttributionModelLabel, claudeAttributionTrailer, normalizeClaudeAttributionMode, resolveClaudeAttributionMode } from '../../shared/claude-attribution.mjs';
|
|
14
15
|
import { parseThinkingDisplay, parseThinkingEnabled } from '../../shared/claude-thinking.mjs';
|
|
15
16
|
import { isProviderModelAvailable, resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
|
|
16
17
|
import {
|
|
@@ -30,8 +31,7 @@ import { createGrokAuthService } from '../services/grok-auth-service.mjs';
|
|
|
30
31
|
import {
|
|
31
32
|
isWithinAllowedPrefix,
|
|
32
33
|
readWorkspaceRootPathFromBody,
|
|
33
|
-
validateRequestedWorkspaceRoot,
|
|
34
|
-
} from '../services/workspace-root-path-policy.mjs';
|
|
34
|
+
validateRequestedWorkspaceRoot, normalizeWorkspaceRootKey } from '../services/workspace-root-path-policy.mjs';
|
|
35
35
|
import { workspaceRootDisplayName } from '../workspace-root.mjs';
|
|
36
36
|
import { createKeyedMutex } from '../services/keyed-mutex-service.mjs';
|
|
37
37
|
import { createFixedWindowRateLimiter } from '../services/rate-limit-service.mjs';
|
|
@@ -54,6 +54,13 @@ import {
|
|
|
54
54
|
isSafeProviderModelId,
|
|
55
55
|
} from '../../shared/model-id.mjs';
|
|
56
56
|
import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mjs';
|
|
57
|
+
import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
|
|
58
|
+
import {
|
|
59
|
+
buildCloudSourceRecord,
|
|
60
|
+
buildConversationCloudPayload,
|
|
61
|
+
isClaudeCloudProviderType,
|
|
62
|
+
validateCloudSourceRequest,
|
|
63
|
+
} from '../services/claude-cloud-session-service.mjs';
|
|
57
64
|
import {
|
|
58
65
|
DEFAULT_TURN_CEILING_MINUTES,
|
|
59
66
|
TURN_CEILING_MAX_MINUTES,
|
|
@@ -252,18 +259,69 @@ export function parseClaudeSettingsUpdateRequest(body = {}) {
|
|
|
252
259
|
const model = hasModel ? (String(payload.model || '').trim() || 'claude-sonnet-5') : undefined;
|
|
253
260
|
const hasModels = Array.isArray(payload.models);
|
|
254
261
|
const hasEnabledModels = Array.isArray(payload.enabledModels);
|
|
255
|
-
|
|
262
|
+
const hasAttributionMode = Object.prototype.hasOwnProperty.call(payload, 'attributionMode');
|
|
263
|
+
const attributionMode = hasAttributionMode ? normalizeClaudeAttributionMode(payload.attributionMode) : undefined;
|
|
264
|
+
if (!hasEnabled && !hasModel && !hasModels && !hasEnabledModels && !hasAttributionMode) {
|
|
256
265
|
return { ok: false, error: 'No Claude settings update provided' };
|
|
257
266
|
}
|
|
258
267
|
if (hasModel && !isSafeClaudeModelId(model)) {
|
|
259
268
|
return { ok: false, error: 'Invalid Claude model ID' };
|
|
260
269
|
}
|
|
270
|
+
if (hasAttributionMode && !attributionMode) {
|
|
271
|
+
return { ok: false, error: `Invalid Claude attribution mode (one of ${CLAUDE_ATTRIBUTION_MODES.join(', ')})` };
|
|
272
|
+
}
|
|
261
273
|
return {
|
|
262
274
|
ok: true,
|
|
263
275
|
...(hasEnabled ? { enabled: payload.enabled } : {}),
|
|
264
276
|
...(hasModel ? { model } : {}),
|
|
265
277
|
...(hasModels ? { models: payload.models } : {}),
|
|
266
278
|
...(hasEnabledModels ? { enabledModels: payload.enabledModels } : {}),
|
|
279
|
+
...(hasAttributionMode ? { attributionMode } : {}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A folder's attribution override update: `{ path, attributionMode }` with
|
|
285
|
+
* the mode one of the three, or null to inherit the provider setting again.
|
|
286
|
+
*/
|
|
287
|
+
export function parseWorkspaceRootAttributionUpdate(body = {}) {
|
|
288
|
+
const payload = body && typeof body === 'object' ? body : {};
|
|
289
|
+
const path = String(payload.path || '').trim();
|
|
290
|
+
if (!path) return { ok: false, error: 'Missing path' };
|
|
291
|
+
if (!Object.prototype.hasOwnProperty.call(payload, 'attributionMode')) {
|
|
292
|
+
return { ok: false, error: 'Missing attributionMode (oar, vanilla, off, or null to inherit)' };
|
|
293
|
+
}
|
|
294
|
+
if (payload.attributionMode === null || payload.attributionMode === '') return { ok: true, path, attributionMode: null };
|
|
295
|
+
const attributionMode = normalizeClaudeAttributionMode(payload.attributionMode);
|
|
296
|
+
if (!attributionMode) {
|
|
297
|
+
return { ok: false, error: `Invalid attribution mode (one of ${CLAUDE_ATTRIBUTION_MODES.join(', ')}, or null to inherit)` };
|
|
298
|
+
}
|
|
299
|
+
return { ok: true, path, attributionMode };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** What a folder's Claude sessions will say, given the provider setting and the folder's own choice. */
|
|
303
|
+
export function buildWorkspaceRootAttributionPayload({ path = '', folderMode = null, providerMode = null, model = '' } = {}) {
|
|
304
|
+
const effectiveMode = resolveClaudeAttributionMode({ providerMode, folderMode });
|
|
305
|
+
return {
|
|
306
|
+
path: String(path || '').trim(),
|
|
307
|
+
attributionMode: normalizeClaudeAttributionMode(folderMode),
|
|
308
|
+
providerMode: normalizeClaudeAttributionMode(providerMode) || DEFAULT_CLAUDE_ATTRIBUTION_MODE,
|
|
309
|
+
effectiveMode,
|
|
310
|
+
attributionModes: [...CLAUDE_ATTRIBUTION_MODES],
|
|
311
|
+
attributionExample: effectiveMode === 'oar'
|
|
312
|
+
? claudeAttributionTrailer(claudeAttributionModelLabel(String(model || 'claude-sonnet-5')))
|
|
313
|
+
: (effectiveMode === 'off' ? '' : null),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The attribution fields every Claude settings payload carries, with the trailer the default model would get. */
|
|
318
|
+
export function claudeAttributionSettingsFields(settings = {}) {
|
|
319
|
+
const attributionMode = normalizeClaudeAttributionMode(settings?.attributionMode) || DEFAULT_CLAUDE_ATTRIBUTION_MODE;
|
|
320
|
+
const model = String(settings?.model || 'claude-sonnet-5').trim() || 'claude-sonnet-5';
|
|
321
|
+
return {
|
|
322
|
+
attributionMode,
|
|
323
|
+
attributionModes: [...CLAUDE_ATTRIBUTION_MODES],
|
|
324
|
+
attributionExample: claudeAttributionTrailer(claudeAttributionModelLabel(model)),
|
|
267
325
|
};
|
|
268
326
|
}
|
|
269
327
|
|
|
@@ -676,6 +734,8 @@ function normalizeRequestedProviderType(value = '') {
|
|
|
676
734
|
if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
|
|
677
735
|
if (normalized === 'openai-image' || normalized === 'openai-image-byok') return 'openai-image';
|
|
678
736
|
if (normalized === 'claude' || normalized === 'claude-agent-sdk' || normalized === 'anthropic') return 'claude';
|
|
737
|
+
// Its own provider, never read as `claude`: a cloud chat is asked for by name.
|
|
738
|
+
if (normalized === 'claude-cloud') return 'claude-cloud';
|
|
679
739
|
if (normalized === 'cursor') return 'cursor';
|
|
680
740
|
if (normalized === 'grok' || normalized === 'xai' || normalized === 'xai-grok') return 'grok';
|
|
681
741
|
if (normalized === 'github' || normalized === 'github-copilot') return 'github';
|
|
@@ -1169,6 +1229,9 @@ export function buildSessionWorkerStatusPayload({
|
|
|
1169
1229
|
featureFlags = null,
|
|
1170
1230
|
supervisorSnapshot = null,
|
|
1171
1231
|
queueRows = [],
|
|
1232
|
+
// What the last read of the process list did ({ at, durationMs, processes,
|
|
1233
|
+
// error }); a slow box shows here before it shows as 502s.
|
|
1234
|
+
processList = null,
|
|
1172
1235
|
} = {}) {
|
|
1173
1236
|
const snapshot = supervisorSnapshot && typeof supervisorSnapshot === 'object'
|
|
1174
1237
|
? supervisorSnapshot
|
|
@@ -1273,6 +1336,7 @@ export function buildSessionWorkerStatusPayload({
|
|
|
1273
1336
|
workerCount: toSafeNonNegativeInt(snapshot.workerCount, workers.length),
|
|
1274
1337
|
onlineCount,
|
|
1275
1338
|
onlineProcessCount,
|
|
1339
|
+
processList: processList && typeof processList === 'object' ? { ...processList } : null,
|
|
1276
1340
|
onlineBoundProcessCount,
|
|
1277
1341
|
onlineUnassignedProcessCount,
|
|
1278
1342
|
counts: snapshot.counts && typeof snapshot.counts === 'object' ? snapshot.counts : {},
|
|
@@ -1295,6 +1359,9 @@ export function buildConversationSessionRootPayload({
|
|
|
1295
1359
|
resolveCursorSessionRoot = null,
|
|
1296
1360
|
} = {}) {
|
|
1297
1361
|
const sid = String(sdkSessionId || '').trim() || String(conversationId || '').trim();
|
|
1362
|
+
// A Claude Cloud session lives in Anthropic's cloud: there is no session
|
|
1363
|
+
// folder on this host, and it must not be looked up as a Copilot one.
|
|
1364
|
+
if (isClaudeCloudProviderType(providerType)) return null;
|
|
1298
1365
|
// Cursor sessions have no ~/.copilot/session-state entry either — their
|
|
1299
1366
|
// on-disk footprint is the worker's per-session agent store, created on the
|
|
1300
1367
|
// first turn.
|
|
@@ -2011,6 +2078,12 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2011
2078
|
// Owns the relay-wide single-flight Grok device-code login, on the same
|
|
2012
2079
|
// terms as claudeAuthService above.
|
|
2013
2080
|
grokAuthService = null,
|
|
2081
|
+
// Claude Cloud (provider `claude-cloud`): its settings (the switch, the
|
|
2082
|
+
// default model, the environment) and what the relay keeps per cloud
|
|
2083
|
+
// conversation. Absent in older wiring and most tests, where the provider
|
|
2084
|
+
// is simply off.
|
|
2085
|
+
claudeCloudSettingsService = null,
|
|
2086
|
+
claudeCloudSessionService = null,
|
|
2014
2087
|
reconcileUnstartedConversationProviders = async () => ({
|
|
2015
2088
|
updatedUnstartedConversations: 0,
|
|
2016
2089
|
skippedStartedConversations: 0,
|
|
@@ -2047,6 +2120,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2047
2120
|
getCursorDashboardTokenSettings = () => ({ configured: false }),
|
|
2048
2121
|
setCursorDashboardTokenSettings = () => ({ ok: false, error: 'Cursor dashboard token settings are unavailable' }),
|
|
2049
2122
|
planUsageService = null,
|
|
2123
|
+
// Optional live Claude account usage for the Check Usage modal; it reads
|
|
2124
|
+
// nothing while Claude Cloud is off, and absent (tests, older wiring) the
|
|
2125
|
+
// Claude card stays the last turn's snapshot.
|
|
2126
|
+
claudeAccountUsageService = null,
|
|
2050
2127
|
getCursorPlanAllowanceSettings = () => ({ cursorModelsUsd: null, otherModelsUsd: null, resetDay: 1 }),
|
|
2051
2128
|
setCursorPlanAllowanceSettings = () => ({ ok: false, error: 'Cursor allowance settings are unavailable' }),
|
|
2052
2129
|
getGrokPlanAllowanceSettings = () => ({ monthlyUsd: null, resetDay: 1 }),
|
|
@@ -2461,6 +2538,14 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2461
2538
|
return finalized;
|
|
2462
2539
|
}
|
|
2463
2540
|
|
|
2541
|
+
/** The `cloud` field of a conversation payload; null unless it is a cloud one. */
|
|
2542
|
+
function conversationCloudPayload(conversation, runtimeSession) {
|
|
2543
|
+
return buildConversationCloudPayload({
|
|
2544
|
+
cloudSourceJson: conversation?.cloud_source_json,
|
|
2545
|
+
costUsd: runtimeSession?.claude_cloud_cost_usd,
|
|
2546
|
+
});
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2464
2549
|
function resolveConversationByIdOrSdkSessionId(requestedId) {
|
|
2465
2550
|
const lookupId = String(requestedId || '').trim();
|
|
2466
2551
|
if (!lookupId) return null;
|
|
@@ -2721,11 +2806,13 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2721
2806
|
|
|
2722
2807
|
// GET /api/conversations — list all conversations
|
|
2723
2808
|
app.get('/api/conversations', auth, (req, res) => {
|
|
2724
|
-
|
|
2809
|
+
// `archived=true` lists both, `archived=only` the archived ones (the
|
|
2810
|
+
// sidebar's Archived view), nothing: the live ones.
|
|
2811
|
+
const archivedMode = String(req.query.archived || '').trim().toLowerCase();
|
|
2725
2812
|
const limit = normalizeConversationListLimit(req.query.limit, DEFAULT_CONVERSATION_LIST_LIMIT);
|
|
2726
2813
|
const beforeConversationId = String(req.query.beforeConversationId || '').trim();
|
|
2727
2814
|
const beforeUpdatedAt = String(req.query.beforeUpdatedAt || '').trim();
|
|
2728
|
-
const rows = stmts.listConvs.all(
|
|
2815
|
+
const rows = stmts.listConvs.all(archivedMode === 'true' ? 1 : 0, archivedMode === 'only' ? 1 : 0);
|
|
2729
2816
|
const page = selectConversationListPage(rows, {
|
|
2730
2817
|
limit,
|
|
2731
2818
|
beforeConversationId,
|
|
@@ -2782,6 +2869,11 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2782
2869
|
draftUpdatedByClientId: r.draft_updated_by_client_id || null,
|
|
2783
2870
|
// Set on conversations another relay's agent created ("via <relay>").
|
|
2784
2871
|
origin: parseRemoteRelayOriginJson(r.origin_json),
|
|
2872
|
+
// Set on Claude Cloud conversations: repository, branch, session link.
|
|
2873
|
+
cloud: buildConversationCloudPayload({
|
|
2874
|
+
cloudSourceJson: r.cloud_source_json,
|
|
2875
|
+
costUsd: r.runtime_claude_cloud_cost_usd,
|
|
2876
|
+
}),
|
|
2785
2877
|
};
|
|
2786
2878
|
});
|
|
2787
2879
|
|
|
@@ -3108,10 +3200,11 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3108
3200
|
|| null;
|
|
3109
3201
|
const copilotSessionId = String(runtimeSessionBySdkSessionId?.sdk_session_id || runtimeSession?.sdk_session_id || '').trim() || null;
|
|
3110
3202
|
const providerType = String(runtimeSession?.provider_type || 'github').trim().toLowerCase();
|
|
3111
|
-
// The cursor and grok workers post the same Claude-shaped usage blob,
|
|
3112
|
-
//
|
|
3203
|
+
// The cursor and grok workers post the same Claude-shaped usage blob, and
|
|
3204
|
+
// so does the Claude Cloud worker (the cloud session's context and token
|
|
3205
|
+
// usage), so all four providers share the stored reader.
|
|
3113
3206
|
const usesStoredContextUsage = providerType === 'claude' || providerType === 'cursor'
|
|
3114
|
-
|| providerType === 'grok';
|
|
3207
|
+
|| providerType === 'grok' || providerType === 'claude-cloud';
|
|
3115
3208
|
|
|
3116
3209
|
const parsed = usesStoredContextUsage
|
|
3117
3210
|
? (() => {
|
|
@@ -3148,6 +3241,19 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3148
3241
|
autoCompactWindow: parseAutoCompactWindow(conversationRow?.auto_compact_window),
|
|
3149
3242
|
thinkingEnabled: parseThinkingEnabled(conversationRow?.thinking_enabled),
|
|
3150
3243
|
thinkingDisplay: parseThinkingDisplay(conversationRow?.thinking_display),
|
|
3244
|
+
// What this folder's Claude commits say (the 🧠 modal's override row).
|
|
3245
|
+
// A Claude Cloud chat has the row when it was started from a folder,
|
|
3246
|
+
// with its own model in the example.
|
|
3247
|
+
...(providerType === 'claude'
|
|
3248
|
+
? { attribution: workspaceRootAttributionFor(conversationRow) }
|
|
3249
|
+
: {}),
|
|
3250
|
+
...(providerType === 'claude-cloud' && cloudConversationFolder(conversationRow)
|
|
3251
|
+
? {
|
|
3252
|
+
attribution: workspaceRootAttributionFor(conversationRow, '', {
|
|
3253
|
+
model: runtimeSession?.provider_model || runtimeSession?.model || '',
|
|
3254
|
+
}),
|
|
3255
|
+
}
|
|
3256
|
+
: {}),
|
|
3151
3257
|
runtimeSessionId: runtimeSession?.id || null,
|
|
3152
3258
|
copilotSessionId,
|
|
3153
3259
|
providerType: usesStoredContextUsage ? providerType : 'github',
|
|
@@ -3168,6 +3274,60 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3168
3274
|
};
|
|
3169
3275
|
}
|
|
3170
3276
|
|
|
3277
|
+
// --- Per-folder commit attribution (shared/claude-attribution.mjs) -----
|
|
3278
|
+
function workspaceRootSettingsRow(rootPath) {
|
|
3279
|
+
const root = String(rootPath || '').trim();
|
|
3280
|
+
if (!root || typeof stmts?.getWorkspaceRootSettings?.get !== 'function') return null;
|
|
3281
|
+
try {
|
|
3282
|
+
return stmts.getWorkspaceRootSettings.get(normalizeWorkspaceRootKey(root)) || null;
|
|
3283
|
+
} catch {
|
|
3284
|
+
return null;
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
/** The folder a Claude Cloud chat was started from, if it was started from one. */
|
|
3289
|
+
function cloudConversationFolder(conversationRow) {
|
|
3290
|
+
return String(conversationRow?.runtime_workspace_root_path || conversationRow?.configured_workspace_root_path || '').trim();
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
function workspaceRootAttributionFor(conversationRow, explicitPath = '', { model = '' } = {}) {
|
|
3294
|
+
const path = String(explicitPath
|
|
3295
|
+
|| conversationRow?.runtime_workspace_root_path
|
|
3296
|
+
|| conversationRow?.configured_workspace_root_path
|
|
3297
|
+
|| '').trim();
|
|
3298
|
+
const provider = typeof getClaudeProviderSettings === 'function' ? getClaudeProviderSettings() : {};
|
|
3299
|
+
return buildWorkspaceRootAttributionPayload({
|
|
3300
|
+
path,
|
|
3301
|
+
folderMode: workspaceRootSettingsRow(path)?.attribution_mode || null,
|
|
3302
|
+
providerMode: provider?.attributionMode,
|
|
3303
|
+
model: String(model || '').trim() || provider?.model,
|
|
3304
|
+
});
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
app.get('/api/workspace-root/attribution', auth, (req, res) => {
|
|
3308
|
+
const path = String(req.query.path || '').trim();
|
|
3309
|
+
if (!path) return res.status(400).json({ error: 'Missing path' });
|
|
3310
|
+
res.json({ ok: true, ...workspaceRootAttributionFor(null, path) });
|
|
3311
|
+
});
|
|
3312
|
+
|
|
3313
|
+
app.post('/api/workspace-root/attribution', auth, (req, res) => {
|
|
3314
|
+
const parsed = parseWorkspaceRootAttributionUpdate(req.body);
|
|
3315
|
+
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
|
|
3316
|
+
if (typeof stmts?.upsertWorkspaceRootAttribution?.run !== 'function') {
|
|
3317
|
+
return res.status(503).json({ error: 'Folder settings are unavailable' });
|
|
3318
|
+
}
|
|
3319
|
+
const key = normalizeWorkspaceRootKey(parsed.path);
|
|
3320
|
+
const now = new Date().toISOString();
|
|
3321
|
+
if (parsed.attributionMode === null) {
|
|
3322
|
+
stmts.deleteWorkspaceRootSettings.run(key);
|
|
3323
|
+
} else {
|
|
3324
|
+
stmts.upsertWorkspaceRootAttribution.run(key, parsed.path, parsed.attributionMode, now);
|
|
3325
|
+
}
|
|
3326
|
+
const payload = workspaceRootAttributionFor(null, parsed.path);
|
|
3327
|
+
try { io.emit('workspace_root_attribution_updated', payload); } catch {}
|
|
3328
|
+
res.json({ ok: true, ...payload });
|
|
3329
|
+
});
|
|
3330
|
+
|
|
3171
3331
|
app.get('/api/context/:conversationId', auth, (req, res) => {
|
|
3172
3332
|
const conversationId = String(req.params.conversationId || '').trim();
|
|
3173
3333
|
if (!conversationId) return res.status(400).json({ error: 'Missing conversationId' });
|
|
@@ -3424,6 +3584,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3424
3584
|
draftUpdatedAt: conv.draft_updated_at || null,
|
|
3425
3585
|
draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
|
|
3426
3586
|
origin: parseRemoteRelayOriginJson(conv.origin_json),
|
|
3587
|
+
cloud: conversationCloudPayload(conv, runtimeSession),
|
|
3427
3588
|
messages: history.messages,
|
|
3428
3589
|
pageInfo: history.pageInfo,
|
|
3429
3590
|
refreshed: true,
|
|
@@ -3835,6 +3996,8 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3835
3996
|
draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
|
|
3836
3997
|
// Set on conversations another relay's agent created ("via <relay>").
|
|
3837
3998
|
origin: parseRemoteRelayOriginJson(conv.origin_json),
|
|
3999
|
+
// Set on Claude Cloud conversations: repository, branch, session link.
|
|
4000
|
+
cloud: conversationCloudPayload(conv, runtimeSession),
|
|
3838
4001
|
messages: history.messages,
|
|
3839
4002
|
pageInfo: history.pageInfo,
|
|
3840
4003
|
});
|
|
@@ -4178,7 +4341,14 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4178
4341
|
// A session that is not 'ready' can still own a live process (status 'error',
|
|
4179
4342
|
// or no registry entry at all). Detect that, or the stop is skipped and the
|
|
4180
4343
|
// launch silently reuses the old process in the old directory.
|
|
4181
|
-
|
|
4344
|
+
let liveProcessDetected = false;
|
|
4345
|
+
try {
|
|
4346
|
+
liveProcessDetected = !!(await sessionWorkerProcessInspector?.findProcessForSessionAsync?.(sdkSessionId, { fresh: true }));
|
|
4347
|
+
} catch (error) {
|
|
4348
|
+
// An unreadable list must not pass for "no process": the relaunch would
|
|
4349
|
+
// start a second CLI next to the one it could not see.
|
|
4350
|
+
return { ok: false, statusCode: 409, error: `process-list-unreadable: ${error?.message || error}` };
|
|
4351
|
+
}
|
|
4182
4352
|
const eligibility = evaluateWorkspaceRootRelaunch({
|
|
4183
4353
|
workerStatus: worker?.status,
|
|
4184
4354
|
activeQueueCount,
|
|
@@ -4531,6 +4701,16 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4531
4701
|
for (const key of new Set([id, workerSessionId])) backgroundTaskStore?.replace?.(key, []);
|
|
4532
4702
|
}
|
|
4533
4703
|
|
|
4704
|
+
// A Claude Cloud conversation takes its cloud session along: archived
|
|
4705
|
+
// there, nothing destroyed. Started here, while the binding can still be
|
|
4706
|
+
// read, and not waited for: the delete never depends on Anthropic.
|
|
4707
|
+
if (!sharedWithConversationId) {
|
|
4708
|
+
claudeCloudSessionService?.archiveSessionInBackground?.({
|
|
4709
|
+
conversationId: id,
|
|
4710
|
+
runtimeSession: stmts.getRuntimeSessionByConversation?.get?.(id) || null,
|
|
4711
|
+
});
|
|
4712
|
+
}
|
|
4713
|
+
|
|
4534
4714
|
const deletedAt = new Date().toISOString();
|
|
4535
4715
|
markConversationDeleted.run(deletedAt, id);
|
|
4536
4716
|
// Tombstones keep the SDK session importer from bringing it back.
|
|
@@ -4563,7 +4743,11 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4563
4743
|
return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
|
|
4564
4744
|
}
|
|
4565
4745
|
|
|
4566
|
-
// POST /api/conversation/:id/archive — archive conversation
|
|
4746
|
+
// POST /api/conversation/:id/archive — archive conversation. The chat
|
|
4747
|
+
// leaves the sidebar's live list (it is kept, and comes back with
|
|
4748
|
+
// /unarchive); its idle worker is stopped like a deleted chat's; a Claude
|
|
4749
|
+
// Cloud chat's cloud session is archived with it. A chat that is working
|
|
4750
|
+
// is not archived: stop it first.
|
|
4567
4751
|
app.post('/api/conversation/:id/archive', auth, async (req, res) => {
|
|
4568
4752
|
if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
|
|
4569
4753
|
const id = String(req.params.id || '').trim();
|
|
@@ -4573,15 +4757,118 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4573
4757
|
if (!existing || String(existing.status || '').trim() === 'deleted') {
|
|
4574
4758
|
return res.json({ ok: true, alreadyDeleted: true });
|
|
4575
4759
|
}
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4760
|
+
if (Number(existing.archived || 0) === 1) return res.json({ ok: true, alreadyArchived: true });
|
|
4761
|
+
const sdkSessionId = String(existing.sdk_session_id || '').trim() || null;
|
|
4762
|
+
const workerSessionId = sdkSessionId || id;
|
|
4763
|
+
const outcome = await sessionLaunchMutex.runExclusive(workerSessionId, async () => {
|
|
4764
|
+
const activity = describeConversationActivity(id, workerSessionId);
|
|
4765
|
+
if (activity.turnRunning) {
|
|
4766
|
+
return {
|
|
4767
|
+
statusCode: 409,
|
|
4768
|
+
body: {
|
|
4769
|
+
ok: false,
|
|
4770
|
+
error: 'conversation-active',
|
|
4771
|
+
reason: 'turn-running',
|
|
4772
|
+
message: 'This conversation is still working on a reply. Stop it, then archive it.',
|
|
4773
|
+
},
|
|
4774
|
+
};
|
|
4775
|
+
}
|
|
4776
|
+
if (activity.backgroundTaskCount > 0) {
|
|
4777
|
+
return {
|
|
4778
|
+
statusCode: 409,
|
|
4779
|
+
body: {
|
|
4780
|
+
ok: false,
|
|
4781
|
+
error: 'conversation-active',
|
|
4782
|
+
reason: 'background-tasks',
|
|
4783
|
+
message: activity.unstoppableTaskCount > 0
|
|
4784
|
+
? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ⋯ menu, then archive it.'
|
|
4785
|
+
: 'Background tasks are still running in this conversation. Stop them in the task panel, then archive it.',
|
|
4786
|
+
},
|
|
4787
|
+
};
|
|
4788
|
+
}
|
|
4789
|
+
// A worker another live conversation still uses stays.
|
|
4790
|
+
const sharedWithConversationId = sdkSessionId
|
|
4791
|
+
? String(findOtherLiveConversationForSdkSession.get(sdkSessionId, id)?.id || '').trim() || null
|
|
4792
|
+
: null;
|
|
4793
|
+
const importedOnly = !!sdkSessionId && sdkSessionImportService?.isImportedOnly?.(sdkSessionId) === true;
|
|
4794
|
+
let workerStopped = false;
|
|
4795
|
+
if (!sharedWithConversationId && !importedOnly) {
|
|
4796
|
+
const stopped = await stopIdleWorkspaceRootSession({
|
|
4797
|
+
sdkSessionId: workerSessionId,
|
|
4798
|
+
worker: sessionWorkerRegistry?.getWorker?.(workerSessionId) || null,
|
|
4799
|
+
sessionWorkerSupervisor,
|
|
4800
|
+
sessionWorkerRegistry,
|
|
4801
|
+
sessionWorkerProcessInspector,
|
|
4802
|
+
stopOverrides: sessionWorkerStopOverrides,
|
|
4803
|
+
});
|
|
4804
|
+
if (!stopped.ok) {
|
|
4805
|
+
return {
|
|
4806
|
+
statusCode: 409,
|
|
4807
|
+
body: {
|
|
4808
|
+
ok: false,
|
|
4809
|
+
error: 'worker-stop-failed',
|
|
4810
|
+
message: "Couldn't stop this conversation's session, so it was not archived. Try again in a moment.",
|
|
4811
|
+
remainingPids: stopped.remainingPids || [],
|
|
4812
|
+
},
|
|
4813
|
+
};
|
|
4814
|
+
}
|
|
4815
|
+
workerStopped = (stopped.stoppedPids || []).length > 0;
|
|
4816
|
+
// Nothing may respawn a worker for an archived chat.
|
|
4817
|
+
sessionWorkerSupervisor?.markKilled?.(workerSessionId);
|
|
4818
|
+
}
|
|
4819
|
+
db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
|
|
4820
|
+
io.emit('conversation_archived', { conversationId: id, archived: true });
|
|
4821
|
+
// The cloud session of a Claude Cloud conversation is archived with it;
|
|
4822
|
+
// best effort and not waited for.
|
|
4823
|
+
if (!sharedWithConversationId) {
|
|
4824
|
+
try {
|
|
4825
|
+
claudeCloudSessionService?.archiveSessionInBackground?.({
|
|
4826
|
+
conversationId: id,
|
|
4827
|
+
runtimeSession: stmts.getRuntimeSessionByConversation?.get?.(id) || null,
|
|
4828
|
+
});
|
|
4829
|
+
} catch (error) {
|
|
4830
|
+
console.warn(`[archive] Cloud session archive not started for ${id}: ${error?.message || error}`);
|
|
4831
|
+
}
|
|
4832
|
+
}
|
|
4833
|
+
return { statusCode: 200, body: { ok: true, workerStopped } };
|
|
4834
|
+
});
|
|
4835
|
+
return res.status(outcome.statusCode).json(outcome.body);
|
|
4579
4836
|
} catch (error) {
|
|
4580
4837
|
console.warn(`[archive] Archive failed for ${id}: ${error?.message || error}`);
|
|
4581
4838
|
return res.status(500).json({ error: 'Failed to archive conversation' });
|
|
4582
4839
|
}
|
|
4583
4840
|
});
|
|
4584
4841
|
|
|
4842
|
+
// POST /api/conversation/:id/unarchive — back to the live list. A Claude
|
|
4843
|
+
// Cloud chat's cloud session is brought back too, so the next message
|
|
4844
|
+
// reaches it; a stopped worker is launched again by the next delivery.
|
|
4845
|
+
app.post('/api/conversation/:id/unarchive', auth, (req, res) => {
|
|
4846
|
+
if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
|
|
4847
|
+
const id = String(req.params.id || '').trim();
|
|
4848
|
+
if (!id) return res.status(400).json({ error: 'Missing conversation id' });
|
|
4849
|
+
try {
|
|
4850
|
+
const existing = stmts.getConvAnyStatus.get(id);
|
|
4851
|
+
if (!existing || String(existing.status || '').trim() === 'deleted') {
|
|
4852
|
+
return res.status(404).json({ ok: false, error: 'Conversation not found' });
|
|
4853
|
+
}
|
|
4854
|
+
if (Number(existing.archived || 0) !== 1) return res.json({ ok: true, alreadyLive: true });
|
|
4855
|
+
db.prepare(`UPDATE conversations SET archived = 0, updated_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
|
|
4856
|
+
io.emit('conversation_archived', { conversationId: id, archived: false });
|
|
4857
|
+
try {
|
|
4858
|
+
claudeCloudSessionService?.unarchiveSessionInBackground?.({
|
|
4859
|
+
conversationId: id,
|
|
4860
|
+
runtimeSession: stmts.getRuntimeSessionByConversation?.get?.(id) || null,
|
|
4861
|
+
});
|
|
4862
|
+
} catch (error) {
|
|
4863
|
+
console.warn(`[archive] Cloud session unarchive not started for ${id}: ${error?.message || error}`);
|
|
4864
|
+
}
|
|
4865
|
+
return res.json({ ok: true });
|
|
4866
|
+
} catch (error) {
|
|
4867
|
+
console.warn(`[archive] Unarchive failed for ${id}: ${error?.message || error}`);
|
|
4868
|
+
return res.status(500).json({ error: 'Failed to unarchive conversation' });
|
|
4869
|
+
}
|
|
4870
|
+
});
|
|
4871
|
+
|
|
4585
4872
|
app.post('/api/conversation/bootstrap', auth, async (req, res) => {
|
|
4586
4873
|
// Another relay's agent starting a session: refused while the inbound
|
|
4587
4874
|
// switch is off, otherwise the conversation keeps its provenance.
|
|
@@ -4605,6 +4892,47 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4605
4892
|
const title = (requestedTitle || 'New Conversation').slice(0, 80);
|
|
4606
4893
|
const openAISettings = getOpenAIProviderSettings();
|
|
4607
4894
|
const requestedProviderType = normalizeRequestedProviderType(req.body?.providerType || req.body?.provider);
|
|
4895
|
+
// Claude Cloud is asked for by name only: its model ids are the Claude
|
|
4896
|
+
// provider's own, so a model never selects it. Refused before anything
|
|
4897
|
+
// else is looked at while the provider is off, so a relay that has not
|
|
4898
|
+
// switched it on answers every such request the same way.
|
|
4899
|
+
const useClaudeCloudProvider = requestedProviderType === 'claude-cloud';
|
|
4900
|
+
const claudeCloudSettings = useClaudeCloudProvider
|
|
4901
|
+
? (claudeCloudSettingsService?.getSettings?.() || null)
|
|
4902
|
+
: null;
|
|
4903
|
+
let bootstrapCloudSource = null;
|
|
4904
|
+
if (useClaudeCloudProvider) {
|
|
4905
|
+
const refuseCloud = (code, error) => res.status(400).json({ ok: false, error, code });
|
|
4906
|
+
if (claudeCloudSettings?.enabled !== true) {
|
|
4907
|
+
return refuseCloud('claude_cloud_disabled', 'Claude Cloud is not enabled on this relay');
|
|
4908
|
+
}
|
|
4909
|
+
const validatedSource = validateCloudSourceRequest(req.body?.cloudSource);
|
|
4910
|
+
if (!validatedSource.ok) return refuseCloud(validatedSource.code, validatedSource.error);
|
|
4911
|
+
// The stored environment, else the account's first active one (which
|
|
4912
|
+
// is then stored). Nothing is created at Anthropic here.
|
|
4913
|
+
let cloudEnvironmentId = String(claudeCloudSettings.environmentId || '').trim();
|
|
4914
|
+
if (!cloudEnvironmentId) {
|
|
4915
|
+
try {
|
|
4916
|
+
cloudEnvironmentId = String(await claudeCloudSettingsService.resolveEnvironmentId?.() || '').trim();
|
|
4917
|
+
} catch {
|
|
4918
|
+
cloudEnvironmentId = '';
|
|
4919
|
+
}
|
|
4920
|
+
}
|
|
4921
|
+
if (!cloudEnvironmentId) {
|
|
4922
|
+
return refuseCloud(
|
|
4923
|
+
'claude_cloud_environment_missing',
|
|
4924
|
+
'No cloud environment is set. Pick one on Settings → Providers → Claude Cloud (opening claude.ai/code once creates the default one).',
|
|
4925
|
+
);
|
|
4926
|
+
}
|
|
4927
|
+
if (typeof stmts.updateConvCloudSource?.run !== 'function') {
|
|
4928
|
+
return res.status(500).json({ ok: false, error: 'Claude Cloud storage is unavailable' });
|
|
4929
|
+
}
|
|
4930
|
+
bootstrapCloudSource = buildCloudSourceRecord({
|
|
4931
|
+
repoUrl: validatedSource.repoUrl,
|
|
4932
|
+
branch: validatedSource.branch,
|
|
4933
|
+
environmentId: cloudEnvironmentId,
|
|
4934
|
+
});
|
|
4935
|
+
}
|
|
4608
4936
|
const modelState = buildModelCatalogWithProviders(
|
|
4609
4937
|
getModelCatalogState(),
|
|
4610
4938
|
openAISettings,
|
|
@@ -4691,7 +5019,15 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4691
5019
|
requestedBootstrapModel
|
|
4692
5020
|
|| (isProviderModelAvailable(catalogSelectedModel, availableModels) ? catalogSelectedModel : '')
|
|
4693
5021
|
);
|
|
4694
|
-
const providerModelSelection =
|
|
5022
|
+
const providerModelSelection = useClaudeCloudProvider
|
|
5023
|
+
// The cloud takes plain ids; with no model in the request the tab's
|
|
5024
|
+
// default applies (never the shared catalog's current model).
|
|
5025
|
+
? resolveProviderModelSelection({
|
|
5026
|
+
requestedModel: stripModelTierSuffix(requestedBootstrapModel),
|
|
5027
|
+
configuredModel: claudeCloudSettings.defaultModel,
|
|
5028
|
+
availableModels: Array.isArray(claudeCloudSettings.models) ? claudeCloudSettings.models : [],
|
|
5029
|
+
})
|
|
5030
|
+
: useOpenAIProvider
|
|
4695
5031
|
? resolveProviderModelSelection({
|
|
4696
5032
|
requestedModel: providerRequestedModel(availableOpenAIModels),
|
|
4697
5033
|
// The removed resolveOpenAISessionModel carried this last-resort id.
|
|
@@ -4721,14 +5057,18 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4721
5057
|
// Per-provider pre-checks used to duplicate it and had drifted apart in
|
|
4722
5058
|
// wording, casing rules and whether they listed the alternatives.
|
|
4723
5059
|
if (providerModelSelection && !providerModelSelection.ok) {
|
|
4724
|
-
const providerLabel =
|
|
4725
|
-
? '
|
|
4726
|
-
: (
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
5060
|
+
const providerLabel = useClaudeCloudProvider
|
|
5061
|
+
? 'Claude Cloud'
|
|
5062
|
+
: (useOpenAIProvider
|
|
5063
|
+
? 'OpenAI'
|
|
5064
|
+
: (useClaudeProvider ? 'Claude' : (useCursorProvider ? 'Cursor' : 'Grok')));
|
|
5065
|
+
const providerCode = useClaudeCloudProvider
|
|
5066
|
+
? 'CLAUDE_CLOUD_MODEL_UNAVAILABLE'
|
|
5067
|
+
: (useOpenAIProvider
|
|
5068
|
+
? 'OPENAI_MODEL_UNAVAILABLE'
|
|
5069
|
+
: (useClaudeProvider
|
|
5070
|
+
? 'CLAUDE_MODEL_UNAVAILABLE'
|
|
5071
|
+
: (useCursorProvider ? 'CURSOR_MODEL_UNAVAILABLE' : 'GROK_MODEL_UNAVAILABLE')));
|
|
4732
5072
|
return res.status(400).json({
|
|
4733
5073
|
ok: false,
|
|
4734
5074
|
error: `${providerLabel} model "${providerModelSelection.requestedModel}" is not available`,
|
|
@@ -4794,7 +5134,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4794
5134
|
code: 'OPENAI_IMAGE_MODEL_REQUIRED',
|
|
4795
5135
|
});
|
|
4796
5136
|
}
|
|
4797
|
-
if (!useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider) {
|
|
5137
|
+
if (!useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider) {
|
|
4798
5138
|
const selectedProviders = Array.isArray(modelState?.providersByModel?.[String(selectedModel || '').trim().toLowerCase()])
|
|
4799
5139
|
? modelState.providersByModel[String(selectedModel || '').trim().toLowerCase()]
|
|
4800
5140
|
: [];
|
|
@@ -4836,13 +5176,15 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4836
5176
|
}
|
|
4837
5177
|
}
|
|
4838
5178
|
|
|
4839
|
-
const bootstrapProviderType =
|
|
4840
|
-
? '
|
|
4841
|
-
: (
|
|
4842
|
-
? '
|
|
4843
|
-
: (
|
|
4844
|
-
? '
|
|
4845
|
-
: (
|
|
5179
|
+
const bootstrapProviderType = useClaudeCloudProvider
|
|
5180
|
+
? 'claude-cloud'
|
|
5181
|
+
: (useOpenAIProvider
|
|
5182
|
+
? 'openai'
|
|
5183
|
+
: (useClaudeProvider
|
|
5184
|
+
? 'claude'
|
|
5185
|
+
: (useCursorProvider
|
|
5186
|
+
? 'cursor'
|
|
5187
|
+
: (useGrokProvider ? 'grok' : 'github'))));
|
|
4846
5188
|
// Preferences are written in the same transaction as the conversation, so
|
|
4847
5189
|
// the caller's current mode has to arrive with the request: without it every
|
|
4848
5190
|
// new chat would be stored as the default one and reset the composer. An
|
|
@@ -4891,9 +5233,14 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4891
5233
|
{
|
|
4892
5234
|
assignConfiguredProvider: true,
|
|
4893
5235
|
providerType: bootstrapProviderType,
|
|
4894
|
-
providerModel: (useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider) ? selectedModel : null,
|
|
5236
|
+
providerModel: (useClaudeCloudProvider || useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider) ? selectedModel : null,
|
|
4895
5237
|
},
|
|
4896
5238
|
);
|
|
5239
|
+
// What the cloud session will clone, stored with the conversation so
|
|
5240
|
+
// a cloud conversation never exists without its repository.
|
|
5241
|
+
if (bootstrapCloudSource) {
|
|
5242
|
+
stmts.updateConvCloudSource.run(JSON.stringify(bootstrapCloudSource), conversationId);
|
|
5243
|
+
}
|
|
4897
5244
|
const preferences = persistConversationPreferences({
|
|
4898
5245
|
db,
|
|
4899
5246
|
stmts,
|
|
@@ -5003,6 +5350,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5003
5350
|
conversationId,
|
|
5004
5351
|
conversation,
|
|
5005
5352
|
origin: parseRemoteRelayOriginJson(conversation?.origin_json),
|
|
5353
|
+
cloud: bootstrapCloudSource
|
|
5354
|
+
? buildConversationCloudPayload({ cloudSourceJson: bootstrapCloudSource })
|
|
5355
|
+
: null,
|
|
5006
5356
|
runtimeSessionId: runtimeSession?.id || null,
|
|
5007
5357
|
ownerSessionId,
|
|
5008
5358
|
worker: ownerWorker,
|
|
@@ -5045,6 +5395,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5045
5395
|
featureFlags,
|
|
5046
5396
|
supervisorSnapshot: sessionWorkerSupervisor?.snapshot?.({ pendingQuestionSessionIds }) || null,
|
|
5047
5397
|
queueRows: listSessionWorkerQueueRows.all(...SESSION_WORKER_STATUS_QUEUE_STATES),
|
|
5398
|
+
processList: sessionWorkerProcessInspector?.getLastRead?.() || null,
|
|
5048
5399
|
});
|
|
5049
5400
|
const activeRuntimeSessionCount = featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true
|
|
5050
5401
|
? Number(sessionWorkerStatus?.onlineBoundProcessCount || 0)
|
|
@@ -5262,6 +5613,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5262
5613
|
model: String(settings?.model || 'claude-sonnet-5').trim() || 'claude-sonnet-5',
|
|
5263
5614
|
models: Array.isArray(settings?.models) ? settings.models : [],
|
|
5264
5615
|
availableModels: Array.isArray(settings?.availableModels) ? settings.availableModels : [],
|
|
5616
|
+
...claudeAttributionSettingsFields(settings),
|
|
5265
5617
|
});
|
|
5266
5618
|
});
|
|
5267
5619
|
|
|
@@ -5310,6 +5662,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5310
5662
|
model: String(currentSettings?.model || result.model || 'claude-sonnet-5').trim() || 'claude-sonnet-5',
|
|
5311
5663
|
models: Array.isArray(currentSettings?.models) ? currentSettings.models : [],
|
|
5312
5664
|
availableModels: Array.isArray(currentSettings?.availableModels) ? currentSettings.availableModels : [],
|
|
5665
|
+
...claudeAttributionSettingsFields(currentSettings),
|
|
5313
5666
|
reconciliation,
|
|
5314
5667
|
};
|
|
5315
5668
|
io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
|
|
@@ -6603,78 +6956,155 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
6603
6956
|
// token/cost diagnostics carried as collapsible detail sections. Each
|
|
6604
6957
|
// provider is independent — a failing source yields an "unavailable" card
|
|
6605
6958
|
// rather than a failed response — so the modal always renders something.
|
|
6959
|
+
// The live reads behind the usage modal (GitHub quota and billing, Grok,
|
|
6960
|
+
// Cursor, the Claude account) each take a second or more. The modal asks
|
|
6961
|
+
// for the provider on screen only (`?providers=github,claude`); the other
|
|
6962
|
+
// cards are built from the last live answer kept here, and the answer says
|
|
6963
|
+
// which cards are live (`live`) and when each was read (`fetchedAt`). No
|
|
6964
|
+
// parameter = everything live, as before.
|
|
6965
|
+
const usageLiveCache = {
|
|
6966
|
+
copilot: null, // { summary, error, at }
|
|
6967
|
+
copilotBilling: null, // { value, at }
|
|
6968
|
+
grokBilling: null,
|
|
6969
|
+
cursorBilling: null,
|
|
6970
|
+
};
|
|
6971
|
+
const USAGE_PROVIDER_IDS = ['github', 'claude', 'claude-cloud', 'cursor', 'grok'];
|
|
6972
|
+
|
|
6973
|
+
function parseUsageProviders(raw) {
|
|
6974
|
+
const text = Array.isArray(raw) ? raw.join(',') : String(raw ?? '');
|
|
6975
|
+
if (!text.trim()) return null;
|
|
6976
|
+
const ids = text.split(',').map((part) => part.trim().toLowerCase()).filter(Boolean);
|
|
6977
|
+
if (ids.includes('all')) return null;
|
|
6978
|
+
return new Set(ids.filter((id) => USAGE_PROVIDER_IDS.includes(id)));
|
|
6979
|
+
}
|
|
6980
|
+
|
|
6606
6981
|
app.get('/api/usage', auth, async (req, res) => {
|
|
6607
6982
|
const wantsLegacyOnly = String(req.query?.legacy || '').trim() === '1';
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
6983
|
+
const requested = parseUsageProviders(req.query?.providers);
|
|
6984
|
+
const live = (id) => requested === null || requested.has(id);
|
|
6985
|
+
const nowIso = () => new Date().toISOString();
|
|
6986
|
+
|
|
6987
|
+
// The Copilot quota: live, or the last live answer.
|
|
6988
|
+
const readCopilotSummary = async () => {
|
|
6989
|
+
if (!live('github') && usageLiveCache.copilot) return usageLiveCache.copilot;
|
|
6990
|
+
let summary = null;
|
|
6991
|
+
let error = null;
|
|
6992
|
+
try {
|
|
6993
|
+
summary = await fetchUsageSummaryPromise(fetchUsageSummary);
|
|
6994
|
+
} catch (fetchError) {
|
|
6995
|
+
error = String(fetchError?.message || fetchError || 'Failed to fetch usage data');
|
|
6996
|
+
}
|
|
6997
|
+
const entry = { summary, error, at: nowIso() };
|
|
6998
|
+
// A failure is not kept over a good answer.
|
|
6999
|
+
if (summary || !usageLiveCache.copilot) usageLiveCache.copilot = entry;
|
|
7000
|
+
return entry;
|
|
7001
|
+
};
|
|
6616
7002
|
|
|
6617
7003
|
if (wantsLegacyOnly || !planUsageService) {
|
|
6618
|
-
|
|
7004
|
+
const { summary, error } = await readCopilotSummary();
|
|
7005
|
+
if (!summary) return res.status(500).json({ error: error || 'Usage is unavailable' });
|
|
6619
7006
|
return res.json(summary);
|
|
6620
7007
|
}
|
|
6621
7008
|
|
|
6622
|
-
//
|
|
6623
|
-
|
|
6624
|
-
|
|
7009
|
+
// Each best-effort read: live when asked for, else the cached answer.
|
|
7010
|
+
const cachedOrLive = async (key, wanted, read) => {
|
|
7011
|
+
if (!wanted && usageLiveCache[key]) return usageLiveCache[key];
|
|
7012
|
+
let value = null;
|
|
6625
7013
|
try {
|
|
6626
|
-
|
|
6627
|
-
} catch
|
|
6628
|
-
|
|
7014
|
+
value = await read();
|
|
7015
|
+
} catch {
|
|
7016
|
+
value = null;
|
|
6629
7017
|
}
|
|
6630
|
-
|
|
7018
|
+
const entry = { value, at: nowIso() };
|
|
7019
|
+
if (value !== null || !usageLiveCache[key]) usageLiveCache[key] = entry;
|
|
7020
|
+
return entry;
|
|
7021
|
+
};
|
|
6631
7022
|
|
|
6632
7023
|
const claudeSettings = getClaudeProviderSettings();
|
|
6633
7024
|
const cursorSettings = getCursorProviderSettings();
|
|
6634
7025
|
const grokSettings = getGrokProviderSettings();
|
|
6635
|
-
|
|
6636
|
-
|
|
6637
|
-
|
|
6638
|
-
|
|
6639
|
-
|
|
6640
|
-
|
|
6641
|
-
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
//
|
|
6645
|
-
|
|
7026
|
+
let claudeCloudEnabled = false;
|
|
7027
|
+
try {
|
|
7028
|
+
claudeCloudEnabled = claudeCloudSettingsService?.getSettings?.()?.enabled === true;
|
|
7029
|
+
} catch {
|
|
7030
|
+
claudeCloudEnabled = false;
|
|
7031
|
+
}
|
|
7032
|
+
|
|
7033
|
+
// Live Claude account usage, three seconds at most; without it the
|
|
7034
|
+
// Claude card shows the last turn's snapshot. The service keeps its own
|
|
7035
|
+
// minute of cache; when the modal did not ask for a Claude card live,
|
|
7036
|
+
// the last answer is used however old it is.
|
|
7037
|
+
const wantsClaudeLive = live('claude') || live('claude-cloud');
|
|
7038
|
+
const claudeAccountPromise = typeof claudeAccountUsageService?.getAccountUsage === 'function'
|
|
7039
|
+
? (wantsClaudeLive
|
|
7040
|
+
? Promise.resolve()
|
|
7041
|
+
.then(() => claudeAccountUsageService.getAccountUsage({ timeoutMs: 3000 }))
|
|
7042
|
+
.catch(() => null)
|
|
7043
|
+
: Promise.resolve(claudeAccountUsageService.peekAccountUsage?.() ?? null))
|
|
7044
|
+
: Promise.resolve(null);
|
|
7045
|
+
|
|
7046
|
+
// All reads side by side: the modal waits for the slowest, not the sum.
|
|
7047
|
+
const [copilot, billingEntry, grokEntry, cursorEntry, claudeAccount] = await Promise.all([
|
|
7048
|
+
readCopilotSummary(),
|
|
7049
|
+
cachedOrLive('copilotBilling', live('github'), () => (
|
|
7050
|
+
typeof fetchCopilotBillingUsage === 'function'
|
|
7051
|
+
? fetchCopilotBillingUsage().catch((error) => ({ items: [], timePeriod: null, scope: null, error: String(error?.message || error) }))
|
|
7052
|
+
: null
|
|
7053
|
+
)),
|
|
7054
|
+
cachedOrLive('grokBilling', live('grok'), () => (
|
|
7055
|
+
grokSettings?.enabled === true && typeof fetchGrokBillingUsage === 'function' ? fetchGrokBillingUsage() : null
|
|
7056
|
+
)),
|
|
7057
|
+
cachedOrLive('cursorBilling', live('cursor'), () => (
|
|
7058
|
+
cursorSettings?.enabled === true && typeof fetchCursorDashboardUsage === 'function' ? fetchCursorDashboardUsage() : null
|
|
7059
|
+
)),
|
|
7060
|
+
claudeAccountPromise,
|
|
7061
|
+
]);
|
|
7062
|
+
|
|
7063
|
+
// Whether a token exists at all decides which of the two very different
|
|
7064
|
+
// "no live bars" explanations the Cursor card shows.
|
|
6646
7065
|
let cursorDashboardAuth = null;
|
|
6647
|
-
if (cursorSettings?.enabled === true
|
|
6648
|
-
try {
|
|
6649
|
-
cursorBilling = await fetchCursorDashboardUsage();
|
|
6650
|
-
} catch {
|
|
6651
|
-
cursorBilling = null;
|
|
6652
|
-
}
|
|
6653
|
-
// Whether a token exists at all decides which of the two very different
|
|
6654
|
-
// "no live bars" explanations the card shows.
|
|
7066
|
+
if (cursorSettings?.enabled === true) {
|
|
6655
7067
|
try {
|
|
6656
7068
|
cursorDashboardAuth = getCursorDashboardTokenSettings() || null;
|
|
6657
7069
|
} catch {
|
|
6658
7070
|
cursorDashboardAuth = null;
|
|
6659
7071
|
}
|
|
6660
7072
|
}
|
|
7073
|
+
|
|
6661
7074
|
const report = planUsageService.buildReport({
|
|
6662
|
-
copilotSummary: summary,
|
|
6663
|
-
copilotError:
|
|
6664
|
-
|
|
7075
|
+
copilotSummary: copilot.summary,
|
|
7076
|
+
copilotError: copilot.error,
|
|
7077
|
+
copilotCapturedAt: copilot.at,
|
|
7078
|
+
copilotBilling: billingEntry.value,
|
|
6665
7079
|
claudeConfigured: claudeSettings?.enabled === true,
|
|
7080
|
+
claudeAccount,
|
|
7081
|
+
claudeCloudConfigured: claudeCloudEnabled,
|
|
7082
|
+
// The conversation the modal was opened from: a cloud conversation gets
|
|
7083
|
+
// its own cost on the Claude Cloud card.
|
|
7084
|
+
conversationId: String(req.query?.conversationId || '').trim(),
|
|
6666
7085
|
cursorConfigured: cursorSettings?.enabled === true,
|
|
6667
7086
|
cursorAllowances: getCursorPlanAllowanceSettings(),
|
|
6668
|
-
cursorBilling,
|
|
7087
|
+
cursorBilling: cursorEntry.value,
|
|
6669
7088
|
cursorDashboardAuth,
|
|
6670
7089
|
grokConfigured: grokSettings?.enabled === true,
|
|
6671
7090
|
grokAllowances: getGrokPlanAllowanceSettings(),
|
|
6672
|
-
grokBilling,
|
|
7091
|
+
grokBilling: grokEntry.value,
|
|
6673
7092
|
});
|
|
6674
7093
|
|
|
6675
7094
|
// Legacy top-level fields stay alongside `providers` so an older cached
|
|
6676
7095
|
// client shell keeps working through a PWA update.
|
|
6677
|
-
return res.json({
|
|
7096
|
+
return res.json({
|
|
7097
|
+
...(copilot.summary || {}),
|
|
7098
|
+
...report,
|
|
7099
|
+
live: USAGE_PROVIDER_IDS.filter(live),
|
|
7100
|
+
fetchedAt: {
|
|
7101
|
+
github: copilot.at,
|
|
7102
|
+
claude: claudeAccount?.fetchedAt || null,
|
|
7103
|
+
'claude-cloud': claudeAccount?.fetchedAt || null,
|
|
7104
|
+
cursor: cursorEntry.at,
|
|
7105
|
+
grok: grokEntry.at,
|
|
7106
|
+
},
|
|
7107
|
+
});
|
|
6678
7108
|
});
|
|
6679
7109
|
|
|
6680
7110
|
app.get('/api/settings/cursor-allowance', auth, (_req, res) => {
|