@oar-sh/oar 0.9.6 → 0.9.8

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 (105) hide show
  1. package/CHANGELOG.md +285 -0
  2. package/README.md +222 -40
  3. package/bin/oar.js +320 -18
  4. package/package.json +1 -1
  5. package/server/README.md +174 -7
  6. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  7. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  10. package/server/claude-worker/claude-sdk-adapter.mjs +18 -13
  11. package/server/claude-worker/claude-session-process.mjs +225 -24
  12. package/server/claude-worker/claude-session-worker.mjs +6 -0
  13. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  14. package/server/db-schema.mjs +17 -0
  15. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  16. package/server/migrations/0007-claude-cloud.mjs +82 -0
  17. package/server/public/app/activity-replay-state.mjs +68 -5
  18. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  19. package/server/public/app/agent-sessions-settings.mjs +134 -0
  20. package/server/public/app/api-client.js +149 -6
  21. package/server/public/app/attachments-view.js +20 -1
  22. package/server/public/app/attribution-control.mjs +46 -0
  23. package/server/public/app/bootstrap.js +468 -88
  24. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  25. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  26. package/server/public/app/claude-cloud-ui.mjs +611 -0
  27. package/server/public/app/composer-control-state.mjs +11 -0
  28. package/server/public/app/context-menu.mjs +140 -0
  29. package/server/public/app/context-tier-options.mjs +4 -0
  30. package/server/public/app/context-usage-view.mjs +127 -8
  31. package/server/public/app/conversation-origin-ui.js +27 -0
  32. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  33. package/server/public/app/conversation-view.js +145 -11
  34. package/server/public/app/cwd-picker.js +12 -0
  35. package/server/public/app/journal-view.js +698 -28
  36. package/server/public/app/math-dollars.mjs +89 -0
  37. package/server/public/app/model-selector-options.mjs +1 -1
  38. package/server/public/app/plan-usage-view.mjs +36 -8
  39. package/server/public/app/pwa-install.js +5 -0
  40. package/server/public/app/relay-error-ctas.mjs +16 -2
  41. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  42. package/server/public/app/router.js +49 -12
  43. package/server/public/app/scrollbar-hover.mjs +78 -0
  44. package/server/public/app/settings-modal.js +37 -3
  45. package/server/public/app/settings-tabs.js +1 -1
  46. package/server/public/app/socket-handlers.js +53 -1
  47. package/server/public/app/store.js +26 -0
  48. package/server/public/app/transcript-separators.mjs +48 -11
  49. package/server/public/app-icon-192.png +0 -0
  50. package/server/public/app-icon-512.png +0 -0
  51. package/server/public/app-icon-light-192.png +0 -0
  52. package/server/public/app-icon-light-512.png +0 -0
  53. package/server/public/app-icon-light-maskable-512.png +0 -0
  54. package/server/public/app-icon-light.svg +4 -0
  55. package/server/public/app-icon-maskable-512.png +0 -0
  56. package/server/public/app-icon.svg +3 -3
  57. package/server/public/favicon.ico +0 -0
  58. package/server/public/index.html +564 -20
  59. package/server/public/manifest.webmanifest +9 -3
  60. package/server/public/sw.js +6 -1
  61. package/server/relay-tools.md +13 -1
  62. package/server/repositories/remote-relay-repository.mjs +42 -1
  63. package/server/repositories/session-repository.mjs +47 -1
  64. package/server/routes/claude-cloud-routes.mjs +140 -0
  65. package/server/routes/git-routes.mjs +37 -0
  66. package/server/routes/messages-routes.mjs +123 -16
  67. package/server/routes/remote-relay-routes.mjs +15 -1
  68. package/server/routes/sessions-routes.mjs +517 -74
  69. package/server/server-runtime.mjs +268 -33
  70. package/server/services/claude-account-usage-service.mjs +176 -0
  71. package/server/services/claude-cloud-repo-service.mjs +408 -0
  72. package/server/services/claude-cloud-session-service.mjs +326 -0
  73. package/server/services/claude-cloud-settings-service.mjs +269 -0
  74. package/server/services/claude-context-usage.mjs +51 -3
  75. package/server/services/git-remote-service.mjs +115 -0
  76. package/server/services/oar-cli-helpers.mjs +67 -1
  77. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  78. package/server/services/plan-usage-claude.mjs +202 -40
  79. package/server/services/plan-usage-contract.mjs +39 -3
  80. package/server/services/plan-usage-service.mjs +82 -2
  81. package/server/services/provider-reasoning-effort.mjs +3 -0
  82. package/server/services/pwa-manifest-theme.mjs +30 -0
  83. package/server/services/remote-relay-caller-context.mjs +49 -10
  84. package/server/services/remote-relay-client.mjs +9 -0
  85. package/server/services/remote-relay-dispatcher.mjs +383 -35
  86. package/server/services/remote-relay-inbound.mjs +49 -2
  87. package/server/services/remote-relay-loopback.mjs +245 -0
  88. package/server/services/remote-relay-registry-service.mjs +52 -0
  89. package/server/services/session-worker-launch-service.mjs +100 -15
  90. package/server/services/session-worker-process-service.mjs +348 -129
  91. package/server/services/session-worker-registry-service.mjs +18 -1
  92. package/server/services/session-worker-stop-service.mjs +24 -13
  93. package/server/services/session-worker-supervisor-service.mjs +56 -1
  94. package/server/services/update-install-service.mjs +9 -3
  95. package/server/services/usage-limit-pause-service.mjs +3 -1
  96. package/server/services/worker-launch-note-service.mjs +151 -0
  97. package/shared/claude-attribution.mjs +117 -0
  98. package/shared/claude-cloud/api-client.mjs +809 -0
  99. package/shared/claude-cloud/base-url.mjs +47 -0
  100. package/shared/claude-cloud/credentials.mjs +221 -0
  101. package/shared/claude-cloud/repo-url.mjs +111 -0
  102. package/shared/provider-routing.mjs +4 -2
  103. package/shared/remote-relay-contract.mjs +153 -22
  104. package/shared/remote-relay-tool-core.mjs +11 -8
  105. 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 {
@@ -20,7 +21,7 @@ import {
20
21
  withClaudeUltracodeTier,
21
22
  } from '../services/provider-reasoning-effort.mjs';
22
23
  import { mapUsageSnapshotRow, fetchUsageSummaryPromise } from '../services/usage-snapshot-helpers.mjs';
23
- import { readStoredClaudeContextUsage } from '../services/claude-context-usage.mjs';
24
+ import { readStoredClaudeContextUsage, resolveAutoCompactWindowStatus } from '../services/claude-context-usage.mjs';
24
25
  import { buildContextUsageView } from '../services/context-usage-view.mjs';
25
26
  import { cleanupGeneratedImagesForConversation as cleanupGeneratedImagesForConversationDefault } from '../services/generated-image-cleanup-service.mjs';
26
27
  import { stopSessionWorkerProcesses } from '../services/session-worker-stop-service.mjs';
@@ -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
- if (!hasEnabled && !hasModel && !hasModels && !hasEnabledModels) {
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
- const includeArchived = String(req.query.archived || '').trim().toLowerCase() === 'true';
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(includeArchived ? 1 : 0);
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, so
3112
- // all three providers share the stored reader.
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
  ? (() => {
@@ -3119,6 +3212,7 @@ export function registerSessionsRoutes(app, deps) {
3119
3212
  return {
3120
3213
  snapshot: stored.snapshot,
3121
3214
  contextUsage: stored.contextUsage,
3215
+ modelContextLimit: stored.modelContextLimit,
3122
3216
  eventsPath: null,
3123
3217
  error: stored.snapshot
3124
3218
  ? null
@@ -3138,6 +3232,15 @@ export function registerSessionsRoutes(app, deps) {
3138
3232
  const conversationRow = resolveConversationByIdOrSdkSessionId(
3139
3233
  String(runtimeSession?.conversation_id || '').trim() || lookupId,
3140
3234
  );
3235
+ const autoCompactWindow = parseAutoCompactWindow(conversationRow?.auto_compact_window);
3236
+ // Only the local Claude worker runs a CLI whose window the slider sets;
3237
+ // it reports that CLI on its heartbeat's steering snapshot.
3238
+ const windowStatus = resolveAutoCompactWindowStatus({
3239
+ storedWindow: autoCompactWindow,
3240
+ steering: providerType === 'claude' && copilotSessionId
3241
+ ? sessionWorkerRegistry?.getWorker?.(copilotSessionId)?.steering || null
3242
+ : null,
3243
+ });
3141
3244
 
3142
3245
  return {
3143
3246
  conversationId: lookupId,
@@ -3145,9 +3248,25 @@ export function registerSessionsRoutes(app, deps) {
3145
3248
  // for the modal's refresh handle), so the id a preferences PATCH needs is
3146
3249
  // reported separately.
3147
3250
  resolvedConversationId: conversationRow?.id || null,
3148
- autoCompactWindow: parseAutoCompactWindow(conversationRow?.auto_compact_window),
3251
+ autoCompactWindow,
3252
+ activeAutoCompactWindow: windowStatus.activeAutoCompactWindow,
3253
+ autoCompactWindowDeferred: windowStatus.autoCompactWindowDeferred,
3254
+ modelContextLimit: parsed.modelContextLimit ?? null,
3149
3255
  thinkingEnabled: parseThinkingEnabled(conversationRow?.thinking_enabled),
3150
3256
  thinkingDisplay: parseThinkingDisplay(conversationRow?.thinking_display),
3257
+ // What this folder's Claude commits say (the 🧠 modal's override row).
3258
+ // A Claude Cloud chat has the row when it was started from a folder,
3259
+ // with its own model in the example.
3260
+ ...(providerType === 'claude'
3261
+ ? { attribution: workspaceRootAttributionFor(conversationRow) }
3262
+ : {}),
3263
+ ...(providerType === 'claude-cloud' && cloudConversationFolder(conversationRow)
3264
+ ? {
3265
+ attribution: workspaceRootAttributionFor(conversationRow, '', {
3266
+ model: runtimeSession?.provider_model || runtimeSession?.model || '',
3267
+ }),
3268
+ }
3269
+ : {}),
3151
3270
  runtimeSessionId: runtimeSession?.id || null,
3152
3271
  copilotSessionId,
3153
3272
  providerType: usesStoredContextUsage ? providerType : 'github',
@@ -3168,6 +3287,60 @@ export function registerSessionsRoutes(app, deps) {
3168
3287
  };
3169
3288
  }
3170
3289
 
3290
+ // --- Per-folder commit attribution (shared/claude-attribution.mjs) -----
3291
+ function workspaceRootSettingsRow(rootPath) {
3292
+ const root = String(rootPath || '').trim();
3293
+ if (!root || typeof stmts?.getWorkspaceRootSettings?.get !== 'function') return null;
3294
+ try {
3295
+ return stmts.getWorkspaceRootSettings.get(normalizeWorkspaceRootKey(root)) || null;
3296
+ } catch {
3297
+ return null;
3298
+ }
3299
+ }
3300
+
3301
+ /** The folder a Claude Cloud chat was started from, if it was started from one. */
3302
+ function cloudConversationFolder(conversationRow) {
3303
+ return String(conversationRow?.runtime_workspace_root_path || conversationRow?.configured_workspace_root_path || '').trim();
3304
+ }
3305
+
3306
+ function workspaceRootAttributionFor(conversationRow, explicitPath = '', { model = '' } = {}) {
3307
+ const path = String(explicitPath
3308
+ || conversationRow?.runtime_workspace_root_path
3309
+ || conversationRow?.configured_workspace_root_path
3310
+ || '').trim();
3311
+ const provider = typeof getClaudeProviderSettings === 'function' ? getClaudeProviderSettings() : {};
3312
+ return buildWorkspaceRootAttributionPayload({
3313
+ path,
3314
+ folderMode: workspaceRootSettingsRow(path)?.attribution_mode || null,
3315
+ providerMode: provider?.attributionMode,
3316
+ model: String(model || '').trim() || provider?.model,
3317
+ });
3318
+ }
3319
+
3320
+ app.get('/api/workspace-root/attribution', auth, (req, res) => {
3321
+ const path = String(req.query.path || '').trim();
3322
+ if (!path) return res.status(400).json({ error: 'Missing path' });
3323
+ res.json({ ok: true, ...workspaceRootAttributionFor(null, path) });
3324
+ });
3325
+
3326
+ app.post('/api/workspace-root/attribution', auth, (req, res) => {
3327
+ const parsed = parseWorkspaceRootAttributionUpdate(req.body);
3328
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
3329
+ if (typeof stmts?.upsertWorkspaceRootAttribution?.run !== 'function') {
3330
+ return res.status(503).json({ error: 'Folder settings are unavailable' });
3331
+ }
3332
+ const key = normalizeWorkspaceRootKey(parsed.path);
3333
+ const now = new Date().toISOString();
3334
+ if (parsed.attributionMode === null) {
3335
+ stmts.deleteWorkspaceRootSettings.run(key);
3336
+ } else {
3337
+ stmts.upsertWorkspaceRootAttribution.run(key, parsed.path, parsed.attributionMode, now);
3338
+ }
3339
+ const payload = workspaceRootAttributionFor(null, parsed.path);
3340
+ try { io.emit('workspace_root_attribution_updated', payload); } catch {}
3341
+ res.json({ ok: true, ...payload });
3342
+ });
3343
+
3171
3344
  app.get('/api/context/:conversationId', auth, (req, res) => {
3172
3345
  const conversationId = String(req.params.conversationId || '').trim();
3173
3346
  if (!conversationId) return res.status(400).json({ error: 'Missing conversationId' });
@@ -3424,6 +3597,7 @@ export function registerSessionsRoutes(app, deps) {
3424
3597
  draftUpdatedAt: conv.draft_updated_at || null,
3425
3598
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
3426
3599
  origin: parseRemoteRelayOriginJson(conv.origin_json),
3600
+ cloud: conversationCloudPayload(conv, runtimeSession),
3427
3601
  messages: history.messages,
3428
3602
  pageInfo: history.pageInfo,
3429
3603
  refreshed: true,
@@ -3835,6 +4009,8 @@ export function registerSessionsRoutes(app, deps) {
3835
4009
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
3836
4010
  // Set on conversations another relay's agent created ("via <relay>").
3837
4011
  origin: parseRemoteRelayOriginJson(conv.origin_json),
4012
+ // Set on Claude Cloud conversations: repository, branch, session link.
4013
+ cloud: conversationCloudPayload(conv, runtimeSession),
3838
4014
  messages: history.messages,
3839
4015
  pageInfo: history.pageInfo,
3840
4016
  });
@@ -4178,7 +4354,14 @@ export function registerSessionsRoutes(app, deps) {
4178
4354
  // A session that is not 'ready' can still own a live process (status 'error',
4179
4355
  // or no registry entry at all). Detect that, or the stop is skipped and the
4180
4356
  // launch silently reuses the old process in the old directory.
4181
- const liveProcessDetected = !!sessionWorkerProcessInspector?.findProcessForSession?.(sdkSessionId);
4357
+ let liveProcessDetected = false;
4358
+ try {
4359
+ liveProcessDetected = !!(await sessionWorkerProcessInspector?.findProcessForSessionAsync?.(sdkSessionId, { fresh: true }));
4360
+ } catch (error) {
4361
+ // An unreadable list must not pass for "no process": the relaunch would
4362
+ // start a second CLI next to the one it could not see.
4363
+ return { ok: false, statusCode: 409, error: `process-list-unreadable: ${error?.message || error}` };
4364
+ }
4182
4365
  const eligibility = evaluateWorkspaceRootRelaunch({
4183
4366
  workerStatus: worker?.status,
4184
4367
  activeQueueCount,
@@ -4531,6 +4714,16 @@ export function registerSessionsRoutes(app, deps) {
4531
4714
  for (const key of new Set([id, workerSessionId])) backgroundTaskStore?.replace?.(key, []);
4532
4715
  }
4533
4716
 
4717
+ // A Claude Cloud conversation takes its cloud session along: archived
4718
+ // there, nothing destroyed. Started here, while the binding can still be
4719
+ // read, and not waited for: the delete never depends on Anthropic.
4720
+ if (!sharedWithConversationId) {
4721
+ claudeCloudSessionService?.archiveSessionInBackground?.({
4722
+ conversationId: id,
4723
+ runtimeSession: stmts.getRuntimeSessionByConversation?.get?.(id) || null,
4724
+ });
4725
+ }
4726
+
4534
4727
  const deletedAt = new Date().toISOString();
4535
4728
  markConversationDeleted.run(deletedAt, id);
4536
4729
  // Tombstones keep the SDK session importer from bringing it back.
@@ -4563,7 +4756,11 @@ export function registerSessionsRoutes(app, deps) {
4563
4756
  return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
4564
4757
  }
4565
4758
 
4566
- // POST /api/conversation/:id/archive — archive conversation
4759
+ // POST /api/conversation/:id/archive — archive conversation. The chat
4760
+ // leaves the sidebar's live list (it is kept, and comes back with
4761
+ // /unarchive); its idle worker is stopped like a deleted chat's; a Claude
4762
+ // Cloud chat's cloud session is archived with it. A chat that is working
4763
+ // is not archived: stop it first.
4567
4764
  app.post('/api/conversation/:id/archive', auth, async (req, res) => {
4568
4765
  if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
4569
4766
  const id = String(req.params.id || '').trim();
@@ -4573,15 +4770,118 @@ export function registerSessionsRoutes(app, deps) {
4573
4770
  if (!existing || String(existing.status || '').trim() === 'deleted') {
4574
4771
  return res.json({ ok: true, alreadyDeleted: true });
4575
4772
  }
4576
- db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
4577
- io.emit('conversation_archived', { conversationId: id });
4578
- return res.json({ ok: true });
4773
+ if (Number(existing.archived || 0) === 1) return res.json({ ok: true, alreadyArchived: true });
4774
+ const sdkSessionId = String(existing.sdk_session_id || '').trim() || null;
4775
+ const workerSessionId = sdkSessionId || id;
4776
+ const outcome = await sessionLaunchMutex.runExclusive(workerSessionId, async () => {
4777
+ const activity = describeConversationActivity(id, workerSessionId);
4778
+ if (activity.turnRunning) {
4779
+ return {
4780
+ statusCode: 409,
4781
+ body: {
4782
+ ok: false,
4783
+ error: 'conversation-active',
4784
+ reason: 'turn-running',
4785
+ message: 'This conversation is still working on a reply. Stop it, then archive it.',
4786
+ },
4787
+ };
4788
+ }
4789
+ if (activity.backgroundTaskCount > 0) {
4790
+ return {
4791
+ statusCode: 409,
4792
+ body: {
4793
+ ok: false,
4794
+ error: 'conversation-active',
4795
+ reason: 'background-tasks',
4796
+ message: activity.unstoppableTaskCount > 0
4797
+ ? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ⋯ menu, then archive it.'
4798
+ : 'Background tasks are still running in this conversation. Stop them in the task panel, then archive it.',
4799
+ },
4800
+ };
4801
+ }
4802
+ // A worker another live conversation still uses stays.
4803
+ const sharedWithConversationId = sdkSessionId
4804
+ ? String(findOtherLiveConversationForSdkSession.get(sdkSessionId, id)?.id || '').trim() || null
4805
+ : null;
4806
+ const importedOnly = !!sdkSessionId && sdkSessionImportService?.isImportedOnly?.(sdkSessionId) === true;
4807
+ let workerStopped = false;
4808
+ if (!sharedWithConversationId && !importedOnly) {
4809
+ const stopped = await stopIdleWorkspaceRootSession({
4810
+ sdkSessionId: workerSessionId,
4811
+ worker: sessionWorkerRegistry?.getWorker?.(workerSessionId) || null,
4812
+ sessionWorkerSupervisor,
4813
+ sessionWorkerRegistry,
4814
+ sessionWorkerProcessInspector,
4815
+ stopOverrides: sessionWorkerStopOverrides,
4816
+ });
4817
+ if (!stopped.ok) {
4818
+ return {
4819
+ statusCode: 409,
4820
+ body: {
4821
+ ok: false,
4822
+ error: 'worker-stop-failed',
4823
+ message: "Couldn't stop this conversation's session, so it was not archived. Try again in a moment.",
4824
+ remainingPids: stopped.remainingPids || [],
4825
+ },
4826
+ };
4827
+ }
4828
+ workerStopped = (stopped.stoppedPids || []).length > 0;
4829
+ // Nothing may respawn a worker for an archived chat.
4830
+ sessionWorkerSupervisor?.markKilled?.(workerSessionId);
4831
+ }
4832
+ db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
4833
+ io.emit('conversation_archived', { conversationId: id, archived: true });
4834
+ // The cloud session of a Claude Cloud conversation is archived with it;
4835
+ // best effort and not waited for.
4836
+ if (!sharedWithConversationId) {
4837
+ try {
4838
+ claudeCloudSessionService?.archiveSessionInBackground?.({
4839
+ conversationId: id,
4840
+ runtimeSession: stmts.getRuntimeSessionByConversation?.get?.(id) || null,
4841
+ });
4842
+ } catch (error) {
4843
+ console.warn(`[archive] Cloud session archive not started for ${id}: ${error?.message || error}`);
4844
+ }
4845
+ }
4846
+ return { statusCode: 200, body: { ok: true, workerStopped } };
4847
+ });
4848
+ return res.status(outcome.statusCode).json(outcome.body);
4579
4849
  } catch (error) {
4580
4850
  console.warn(`[archive] Archive failed for ${id}: ${error?.message || error}`);
4581
4851
  return res.status(500).json({ error: 'Failed to archive conversation' });
4582
4852
  }
4583
4853
  });
4584
4854
 
4855
+ // POST /api/conversation/:id/unarchive — back to the live list. A Claude
4856
+ // Cloud chat's cloud session is brought back too, so the next message
4857
+ // reaches it; a stopped worker is launched again by the next delivery.
4858
+ app.post('/api/conversation/:id/unarchive', auth, (req, res) => {
4859
+ if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
4860
+ const id = String(req.params.id || '').trim();
4861
+ if (!id) return res.status(400).json({ error: 'Missing conversation id' });
4862
+ try {
4863
+ const existing = stmts.getConvAnyStatus.get(id);
4864
+ if (!existing || String(existing.status || '').trim() === 'deleted') {
4865
+ return res.status(404).json({ ok: false, error: 'Conversation not found' });
4866
+ }
4867
+ if (Number(existing.archived || 0) !== 1) return res.json({ ok: true, alreadyLive: true });
4868
+ db.prepare(`UPDATE conversations SET archived = 0, updated_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
4869
+ io.emit('conversation_archived', { conversationId: id, archived: false });
4870
+ try {
4871
+ claudeCloudSessionService?.unarchiveSessionInBackground?.({
4872
+ conversationId: id,
4873
+ runtimeSession: stmts.getRuntimeSessionByConversation?.get?.(id) || null,
4874
+ });
4875
+ } catch (error) {
4876
+ console.warn(`[archive] Cloud session unarchive not started for ${id}: ${error?.message || error}`);
4877
+ }
4878
+ return res.json({ ok: true });
4879
+ } catch (error) {
4880
+ console.warn(`[archive] Unarchive failed for ${id}: ${error?.message || error}`);
4881
+ return res.status(500).json({ error: 'Failed to unarchive conversation' });
4882
+ }
4883
+ });
4884
+
4585
4885
  app.post('/api/conversation/bootstrap', auth, async (req, res) => {
4586
4886
  // Another relay's agent starting a session: refused while the inbound
4587
4887
  // switch is off, otherwise the conversation keeps its provenance.
@@ -4605,6 +4905,47 @@ export function registerSessionsRoutes(app, deps) {
4605
4905
  const title = (requestedTitle || 'New Conversation').slice(0, 80);
4606
4906
  const openAISettings = getOpenAIProviderSettings();
4607
4907
  const requestedProviderType = normalizeRequestedProviderType(req.body?.providerType || req.body?.provider);
4908
+ // Claude Cloud is asked for by name only: its model ids are the Claude
4909
+ // provider's own, so a model never selects it. Refused before anything
4910
+ // else is looked at while the provider is off, so a relay that has not
4911
+ // switched it on answers every such request the same way.
4912
+ const useClaudeCloudProvider = requestedProviderType === 'claude-cloud';
4913
+ const claudeCloudSettings = useClaudeCloudProvider
4914
+ ? (claudeCloudSettingsService?.getSettings?.() || null)
4915
+ : null;
4916
+ let bootstrapCloudSource = null;
4917
+ if (useClaudeCloudProvider) {
4918
+ const refuseCloud = (code, error) => res.status(400).json({ ok: false, error, code });
4919
+ if (claudeCloudSettings?.enabled !== true) {
4920
+ return refuseCloud('claude_cloud_disabled', 'Claude Cloud is not enabled on this relay');
4921
+ }
4922
+ const validatedSource = validateCloudSourceRequest(req.body?.cloudSource);
4923
+ if (!validatedSource.ok) return refuseCloud(validatedSource.code, validatedSource.error);
4924
+ // The stored environment, else the account's first active one (which
4925
+ // is then stored). Nothing is created at Anthropic here.
4926
+ let cloudEnvironmentId = String(claudeCloudSettings.environmentId || '').trim();
4927
+ if (!cloudEnvironmentId) {
4928
+ try {
4929
+ cloudEnvironmentId = String(await claudeCloudSettingsService.resolveEnvironmentId?.() || '').trim();
4930
+ } catch {
4931
+ cloudEnvironmentId = '';
4932
+ }
4933
+ }
4934
+ if (!cloudEnvironmentId) {
4935
+ return refuseCloud(
4936
+ 'claude_cloud_environment_missing',
4937
+ 'No cloud environment is set. Pick one on Settings → Providers → Claude Cloud (opening claude.ai/code once creates the default one).',
4938
+ );
4939
+ }
4940
+ if (typeof stmts.updateConvCloudSource?.run !== 'function') {
4941
+ return res.status(500).json({ ok: false, error: 'Claude Cloud storage is unavailable' });
4942
+ }
4943
+ bootstrapCloudSource = buildCloudSourceRecord({
4944
+ repoUrl: validatedSource.repoUrl,
4945
+ branch: validatedSource.branch,
4946
+ environmentId: cloudEnvironmentId,
4947
+ });
4948
+ }
4608
4949
  const modelState = buildModelCatalogWithProviders(
4609
4950
  getModelCatalogState(),
4610
4951
  openAISettings,
@@ -4691,7 +5032,15 @@ export function registerSessionsRoutes(app, deps) {
4691
5032
  requestedBootstrapModel
4692
5033
  || (isProviderModelAvailable(catalogSelectedModel, availableModels) ? catalogSelectedModel : '')
4693
5034
  );
4694
- const providerModelSelection = useOpenAIProvider
5035
+ const providerModelSelection = useClaudeCloudProvider
5036
+ // The cloud takes plain ids; with no model in the request the tab's
5037
+ // default applies (never the shared catalog's current model).
5038
+ ? resolveProviderModelSelection({
5039
+ requestedModel: stripModelTierSuffix(requestedBootstrapModel),
5040
+ configuredModel: claudeCloudSettings.defaultModel,
5041
+ availableModels: Array.isArray(claudeCloudSettings.models) ? claudeCloudSettings.models : [],
5042
+ })
5043
+ : useOpenAIProvider
4695
5044
  ? resolveProviderModelSelection({
4696
5045
  requestedModel: providerRequestedModel(availableOpenAIModels),
4697
5046
  // The removed resolveOpenAISessionModel carried this last-resort id.
@@ -4721,14 +5070,18 @@ export function registerSessionsRoutes(app, deps) {
4721
5070
  // Per-provider pre-checks used to duplicate it and had drifted apart in
4722
5071
  // wording, casing rules and whether they listed the alternatives.
4723
5072
  if (providerModelSelection && !providerModelSelection.ok) {
4724
- const providerLabel = useOpenAIProvider
4725
- ? 'OpenAI'
4726
- : (useClaudeProvider ? 'Claude' : (useCursorProvider ? 'Cursor' : 'Grok'));
4727
- const providerCode = useOpenAIProvider
4728
- ? 'OPENAI_MODEL_UNAVAILABLE'
4729
- : (useClaudeProvider
4730
- ? 'CLAUDE_MODEL_UNAVAILABLE'
4731
- : (useCursorProvider ? 'CURSOR_MODEL_UNAVAILABLE' : 'GROK_MODEL_UNAVAILABLE'));
5073
+ const providerLabel = useClaudeCloudProvider
5074
+ ? 'Claude Cloud'
5075
+ : (useOpenAIProvider
5076
+ ? 'OpenAI'
5077
+ : (useClaudeProvider ? 'Claude' : (useCursorProvider ? 'Cursor' : 'Grok')));
5078
+ const providerCode = useClaudeCloudProvider
5079
+ ? 'CLAUDE_CLOUD_MODEL_UNAVAILABLE'
5080
+ : (useOpenAIProvider
5081
+ ? 'OPENAI_MODEL_UNAVAILABLE'
5082
+ : (useClaudeProvider
5083
+ ? 'CLAUDE_MODEL_UNAVAILABLE'
5084
+ : (useCursorProvider ? 'CURSOR_MODEL_UNAVAILABLE' : 'GROK_MODEL_UNAVAILABLE')));
4732
5085
  return res.status(400).json({
4733
5086
  ok: false,
4734
5087
  error: `${providerLabel} model "${providerModelSelection.requestedModel}" is not available`,
@@ -4794,7 +5147,7 @@ export function registerSessionsRoutes(app, deps) {
4794
5147
  code: 'OPENAI_IMAGE_MODEL_REQUIRED',
4795
5148
  });
4796
5149
  }
4797
- if (!useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider) {
5150
+ if (!useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider) {
4798
5151
  const selectedProviders = Array.isArray(modelState?.providersByModel?.[String(selectedModel || '').trim().toLowerCase()])
4799
5152
  ? modelState.providersByModel[String(selectedModel || '').trim().toLowerCase()]
4800
5153
  : [];
@@ -4836,13 +5189,15 @@ export function registerSessionsRoutes(app, deps) {
4836
5189
  }
4837
5190
  }
4838
5191
 
4839
- const bootstrapProviderType = useOpenAIProvider
4840
- ? 'openai'
4841
- : (useClaudeProvider
4842
- ? 'claude'
4843
- : (useCursorProvider
4844
- ? 'cursor'
4845
- : (useGrokProvider ? 'grok' : 'github')));
5192
+ const bootstrapProviderType = useClaudeCloudProvider
5193
+ ? 'claude-cloud'
5194
+ : (useOpenAIProvider
5195
+ ? 'openai'
5196
+ : (useClaudeProvider
5197
+ ? 'claude'
5198
+ : (useCursorProvider
5199
+ ? 'cursor'
5200
+ : (useGrokProvider ? 'grok' : 'github'))));
4846
5201
  // Preferences are written in the same transaction as the conversation, so
4847
5202
  // the caller's current mode has to arrive with the request: without it every
4848
5203
  // new chat would be stored as the default one and reset the composer. An
@@ -4891,9 +5246,14 @@ export function registerSessionsRoutes(app, deps) {
4891
5246
  {
4892
5247
  assignConfiguredProvider: true,
4893
5248
  providerType: bootstrapProviderType,
4894
- providerModel: (useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider) ? selectedModel : null,
5249
+ providerModel: (useClaudeCloudProvider || useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider) ? selectedModel : null,
4895
5250
  },
4896
5251
  );
5252
+ // What the cloud session will clone, stored with the conversation so
5253
+ // a cloud conversation never exists without its repository.
5254
+ if (bootstrapCloudSource) {
5255
+ stmts.updateConvCloudSource.run(JSON.stringify(bootstrapCloudSource), conversationId);
5256
+ }
4897
5257
  const preferences = persistConversationPreferences({
4898
5258
  db,
4899
5259
  stmts,
@@ -5003,6 +5363,9 @@ export function registerSessionsRoutes(app, deps) {
5003
5363
  conversationId,
5004
5364
  conversation,
5005
5365
  origin: parseRemoteRelayOriginJson(conversation?.origin_json),
5366
+ cloud: bootstrapCloudSource
5367
+ ? buildConversationCloudPayload({ cloudSourceJson: bootstrapCloudSource })
5368
+ : null,
5006
5369
  runtimeSessionId: runtimeSession?.id || null,
5007
5370
  ownerSessionId,
5008
5371
  worker: ownerWorker,
@@ -5045,6 +5408,7 @@ export function registerSessionsRoutes(app, deps) {
5045
5408
  featureFlags,
5046
5409
  supervisorSnapshot: sessionWorkerSupervisor?.snapshot?.({ pendingQuestionSessionIds }) || null,
5047
5410
  queueRows: listSessionWorkerQueueRows.all(...SESSION_WORKER_STATUS_QUEUE_STATES),
5411
+ processList: sessionWorkerProcessInspector?.getLastRead?.() || null,
5048
5412
  });
5049
5413
  const activeRuntimeSessionCount = featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true
5050
5414
  ? Number(sessionWorkerStatus?.onlineBoundProcessCount || 0)
@@ -5262,6 +5626,7 @@ export function registerSessionsRoutes(app, deps) {
5262
5626
  model: String(settings?.model || 'claude-sonnet-5').trim() || 'claude-sonnet-5',
5263
5627
  models: Array.isArray(settings?.models) ? settings.models : [],
5264
5628
  availableModels: Array.isArray(settings?.availableModels) ? settings.availableModels : [],
5629
+ ...claudeAttributionSettingsFields(settings),
5265
5630
  });
5266
5631
  });
5267
5632
 
@@ -5310,6 +5675,7 @@ export function registerSessionsRoutes(app, deps) {
5310
5675
  model: String(currentSettings?.model || result.model || 'claude-sonnet-5').trim() || 'claude-sonnet-5',
5311
5676
  models: Array.isArray(currentSettings?.models) ? currentSettings.models : [],
5312
5677
  availableModels: Array.isArray(currentSettings?.availableModels) ? currentSettings.availableModels : [],
5678
+ ...claudeAttributionSettingsFields(currentSettings),
5313
5679
  reconciliation,
5314
5680
  };
5315
5681
  io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
@@ -6603,78 +6969,155 @@ export function registerSessionsRoutes(app, deps) {
6603
6969
  // token/cost diagnostics carried as collapsible detail sections. Each
6604
6970
  // provider is independent — a failing source yields an "unavailable" card
6605
6971
  // rather than a failed response — so the modal always renders something.
6972
+ // The live reads behind the usage modal (GitHub quota and billing, Grok,
6973
+ // Cursor, the Claude account) each take a second or more. The modal asks
6974
+ // for the provider on screen only (`?providers=github,claude`); the other
6975
+ // cards are built from the last live answer kept here, and the answer says
6976
+ // which cards are live (`live`) and when each was read (`fetchedAt`). No
6977
+ // parameter = everything live, as before.
6978
+ const usageLiveCache = {
6979
+ copilot: null, // { summary, error, at }
6980
+ copilotBilling: null, // { value, at }
6981
+ grokBilling: null,
6982
+ cursorBilling: null,
6983
+ };
6984
+ const USAGE_PROVIDER_IDS = ['github', 'claude', 'claude-cloud', 'cursor', 'grok'];
6985
+
6986
+ function parseUsageProviders(raw) {
6987
+ const text = Array.isArray(raw) ? raw.join(',') : String(raw ?? '');
6988
+ if (!text.trim()) return null;
6989
+ const ids = text.split(',').map((part) => part.trim().toLowerCase()).filter(Boolean);
6990
+ if (ids.includes('all')) return null;
6991
+ return new Set(ids.filter((id) => USAGE_PROVIDER_IDS.includes(id)));
6992
+ }
6993
+
6606
6994
  app.get('/api/usage', auth, async (req, res) => {
6607
6995
  const wantsLegacyOnly = String(req.query?.legacy || '').trim() === '1';
6608
-
6609
- let summary = null;
6610
- let summaryError = null;
6611
- try {
6612
- summary = await fetchUsageSummaryPromise(fetchUsageSummary);
6613
- } catch (error) {
6614
- summaryError = String(error?.message || error || 'Failed to fetch usage data');
6615
- }
6996
+ const requested = parseUsageProviders(req.query?.providers);
6997
+ const live = (id) => requested === null || requested.has(id);
6998
+ const nowIso = () => new Date().toISOString();
6999
+
7000
+ // The Copilot quota: live, or the last live answer.
7001
+ const readCopilotSummary = async () => {
7002
+ if (!live('github') && usageLiveCache.copilot) return usageLiveCache.copilot;
7003
+ let summary = null;
7004
+ let error = null;
7005
+ try {
7006
+ summary = await fetchUsageSummaryPromise(fetchUsageSummary);
7007
+ } catch (fetchError) {
7008
+ error = String(fetchError?.message || fetchError || 'Failed to fetch usage data');
7009
+ }
7010
+ const entry = { summary, error, at: nowIso() };
7011
+ // A failure is not kept over a good answer.
7012
+ if (summary || !usageLiveCache.copilot) usageLiveCache.copilot = entry;
7013
+ return entry;
7014
+ };
6616
7015
 
6617
7016
  if (wantsLegacyOnly || !planUsageService) {
6618
- if (!summary) return res.status(500).json({ error: summaryError || 'Usage is unavailable' });
7017
+ const { summary, error } = await readCopilotSummary();
7018
+ if (!summary) return res.status(500).json({ error: error || 'Usage is unavailable' });
6619
7019
  return res.json(summary);
6620
7020
  }
6621
7021
 
6622
- // Billing detail is best-effort and must never delay or fail the card.
6623
- let billing = null;
6624
- if (typeof fetchCopilotBillingUsage === 'function') {
7022
+ // Each best-effort read: live when asked for, else the cached answer.
7023
+ const cachedOrLive = async (key, wanted, read) => {
7024
+ if (!wanted && usageLiveCache[key]) return usageLiveCache[key];
7025
+ let value = null;
6625
7026
  try {
6626
- billing = await fetchCopilotBillingUsage();
6627
- } catch (error) {
6628
- billing = { items: [], timePeriod: null, scope: null, error: String(error?.message || error) };
7027
+ value = await read();
7028
+ } catch {
7029
+ value = null;
6629
7030
  }
6630
- }
7031
+ const entry = { value, at: nowIso() };
7032
+ if (value !== null || !usageLiveCache[key]) usageLiveCache[key] = entry;
7033
+ return entry;
7034
+ };
6631
7035
 
6632
7036
  const claudeSettings = getClaudeProviderSettings();
6633
7037
  const cursorSettings = getCursorProviderSettings();
6634
7038
  const grokSettings = getGrokProviderSettings();
6635
- // Live Grok subscription quota is best-effort like Copilot billing.
6636
- let grokBilling = null;
6637
- if (grokSettings?.enabled === true && typeof fetchGrokBillingUsage === 'function') {
6638
- try {
6639
- grokBilling = await fetchGrokBillingUsage();
6640
- } catch {
6641
- grokBilling = null;
6642
- }
6643
- }
6644
- // Live Cursor plan quota (dashboard session token) — same best-effort rule.
6645
- let cursorBilling = null;
7039
+ let claudeCloudEnabled = false;
7040
+ try {
7041
+ claudeCloudEnabled = claudeCloudSettingsService?.getSettings?.()?.enabled === true;
7042
+ } catch {
7043
+ claudeCloudEnabled = false;
7044
+ }
7045
+
7046
+ // Live Claude account usage, three seconds at most; without it the
7047
+ // Claude card shows the last turn's snapshot. The service keeps its own
7048
+ // minute of cache; when the modal did not ask for a Claude card live,
7049
+ // the last answer is used however old it is.
7050
+ const wantsClaudeLive = live('claude') || live('claude-cloud');
7051
+ const claudeAccountPromise = typeof claudeAccountUsageService?.getAccountUsage === 'function'
7052
+ ? (wantsClaudeLive
7053
+ ? Promise.resolve()
7054
+ .then(() => claudeAccountUsageService.getAccountUsage({ timeoutMs: 3000 }))
7055
+ .catch(() => null)
7056
+ : Promise.resolve(claudeAccountUsageService.peekAccountUsage?.() ?? null))
7057
+ : Promise.resolve(null);
7058
+
7059
+ // All reads side by side: the modal waits for the slowest, not the sum.
7060
+ const [copilot, billingEntry, grokEntry, cursorEntry, claudeAccount] = await Promise.all([
7061
+ readCopilotSummary(),
7062
+ cachedOrLive('copilotBilling', live('github'), () => (
7063
+ typeof fetchCopilotBillingUsage === 'function'
7064
+ ? fetchCopilotBillingUsage().catch((error) => ({ items: [], timePeriod: null, scope: null, error: String(error?.message || error) }))
7065
+ : null
7066
+ )),
7067
+ cachedOrLive('grokBilling', live('grok'), () => (
7068
+ grokSettings?.enabled === true && typeof fetchGrokBillingUsage === 'function' ? fetchGrokBillingUsage() : null
7069
+ )),
7070
+ cachedOrLive('cursorBilling', live('cursor'), () => (
7071
+ cursorSettings?.enabled === true && typeof fetchCursorDashboardUsage === 'function' ? fetchCursorDashboardUsage() : null
7072
+ )),
7073
+ claudeAccountPromise,
7074
+ ]);
7075
+
7076
+ // Whether a token exists at all decides which of the two very different
7077
+ // "no live bars" explanations the Cursor card shows.
6646
7078
  let cursorDashboardAuth = null;
6647
- if (cursorSettings?.enabled === true && typeof fetchCursorDashboardUsage === 'function') {
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.
7079
+ if (cursorSettings?.enabled === true) {
6655
7080
  try {
6656
7081
  cursorDashboardAuth = getCursorDashboardTokenSettings() || null;
6657
7082
  } catch {
6658
7083
  cursorDashboardAuth = null;
6659
7084
  }
6660
7085
  }
7086
+
6661
7087
  const report = planUsageService.buildReport({
6662
- copilotSummary: summary,
6663
- copilotError: summaryError,
6664
- copilotBilling: billing,
7088
+ copilotSummary: copilot.summary,
7089
+ copilotError: copilot.error,
7090
+ copilotCapturedAt: copilot.at,
7091
+ copilotBilling: billingEntry.value,
6665
7092
  claudeConfigured: claudeSettings?.enabled === true,
7093
+ claudeAccount,
7094
+ claudeCloudConfigured: claudeCloudEnabled,
7095
+ // The conversation the modal was opened from: a cloud conversation gets
7096
+ // its own cost on the Claude Cloud card.
7097
+ conversationId: String(req.query?.conversationId || '').trim(),
6666
7098
  cursorConfigured: cursorSettings?.enabled === true,
6667
7099
  cursorAllowances: getCursorPlanAllowanceSettings(),
6668
- cursorBilling,
7100
+ cursorBilling: cursorEntry.value,
6669
7101
  cursorDashboardAuth,
6670
7102
  grokConfigured: grokSettings?.enabled === true,
6671
7103
  grokAllowances: getGrokPlanAllowanceSettings(),
6672
- grokBilling,
7104
+ grokBilling: grokEntry.value,
6673
7105
  });
6674
7106
 
6675
7107
  // Legacy top-level fields stay alongside `providers` so an older cached
6676
7108
  // client shell keeps working through a PWA update.
6677
- return res.json({ ...(summary || {}), ...report });
7109
+ return res.json({
7110
+ ...(copilot.summary || {}),
7111
+ ...report,
7112
+ live: USAGE_PROVIDER_IDS.filter(live),
7113
+ fetchedAt: {
7114
+ github: copilot.at,
7115
+ claude: claudeAccount?.fetchedAt || null,
7116
+ 'claude-cloud': claudeAccount?.fetchedAt || null,
7117
+ cursor: cursorEntry.at,
7118
+ grok: grokEntry.at,
7119
+ },
7120
+ });
6678
7121
  });
6679
7122
 
6680
7123
  app.get('/api/settings/cursor-allowance', auth, (_req, res) => {