@oar-sh/oar 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. package/shared/worker-runtime/turn-liveness.mjs +249 -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
- 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 }),
@@ -2093,6 +2170,7 @@ export function registerSessionsRoutes(app, deps) {
2093
2170
  uploadPathForSha,
2094
2171
  hostSuspendService = null,
2095
2172
  collectHostActivity = null,
2173
+ usageLimitPauseService = null,
2096
2174
  hostSuspendPlatform = process.platform,
2097
2175
  // Remote relays (server/services/remote-relay-inbound.mjs): origin on
2098
2176
  // conversations another relay's agent created, and the inbound switch.
@@ -2460,6 +2538,14 @@ export function registerSessionsRoutes(app, deps) {
2460
2538
  return finalized;
2461
2539
  }
2462
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
+
2463
2549
  function resolveConversationByIdOrSdkSessionId(requestedId) {
2464
2550
  const lookupId = String(requestedId || '').trim();
2465
2551
  if (!lookupId) return null;
@@ -2720,11 +2806,13 @@ export function registerSessionsRoutes(app, deps) {
2720
2806
 
2721
2807
  // GET /api/conversations — list all conversations
2722
2808
  app.get('/api/conversations', auth, (req, res) => {
2723
- 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();
2724
2812
  const limit = normalizeConversationListLimit(req.query.limit, DEFAULT_CONVERSATION_LIST_LIMIT);
2725
2813
  const beforeConversationId = String(req.query.beforeConversationId || '').trim();
2726
2814
  const beforeUpdatedAt = String(req.query.beforeUpdatedAt || '').trim();
2727
- const rows = stmts.listConvs.all(includeArchived ? 1 : 0);
2815
+ const rows = stmts.listConvs.all(archivedMode === 'true' ? 1 : 0, archivedMode === 'only' ? 1 : 0);
2728
2816
  const page = selectConversationListPage(rows, {
2729
2817
  limit,
2730
2818
  beforeConversationId,
@@ -2781,6 +2869,11 @@ export function registerSessionsRoutes(app, deps) {
2781
2869
  draftUpdatedByClientId: r.draft_updated_by_client_id || null,
2782
2870
  // Set on conversations another relay's agent created ("via <relay>").
2783
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
+ }),
2784
2877
  };
2785
2878
  });
2786
2879
 
@@ -3107,10 +3200,11 @@ export function registerSessionsRoutes(app, deps) {
3107
3200
  || null;
3108
3201
  const copilotSessionId = String(runtimeSessionBySdkSessionId?.sdk_session_id || runtimeSession?.sdk_session_id || '').trim() || null;
3109
3202
  const providerType = String(runtimeSession?.provider_type || 'github').trim().toLowerCase();
3110
- // The cursor and grok workers post the same Claude-shaped usage blob, so
3111
- // 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.
3112
3206
  const usesStoredContextUsage = providerType === 'claude' || providerType === 'cursor'
3113
- || providerType === 'grok';
3207
+ || providerType === 'grok' || providerType === 'claude-cloud';
3114
3208
 
3115
3209
  const parsed = usesStoredContextUsage
3116
3210
  ? (() => {
@@ -3147,6 +3241,19 @@ export function registerSessionsRoutes(app, deps) {
3147
3241
  autoCompactWindow: parseAutoCompactWindow(conversationRow?.auto_compact_window),
3148
3242
  thinkingEnabled: parseThinkingEnabled(conversationRow?.thinking_enabled),
3149
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
+ : {}),
3150
3257
  runtimeSessionId: runtimeSession?.id || null,
3151
3258
  copilotSessionId,
3152
3259
  providerType: usesStoredContextUsage ? providerType : 'github',
@@ -3167,6 +3274,60 @@ export function registerSessionsRoutes(app, deps) {
3167
3274
  };
3168
3275
  }
3169
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
+
3170
3331
  app.get('/api/context/:conversationId', auth, (req, res) => {
3171
3332
  const conversationId = String(req.params.conversationId || '').trim();
3172
3333
  if (!conversationId) return res.status(400).json({ error: 'Missing conversationId' });
@@ -3423,6 +3584,7 @@ export function registerSessionsRoutes(app, deps) {
3423
3584
  draftUpdatedAt: conv.draft_updated_at || null,
3424
3585
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
3425
3586
  origin: parseRemoteRelayOriginJson(conv.origin_json),
3587
+ cloud: conversationCloudPayload(conv, runtimeSession),
3426
3588
  messages: history.messages,
3427
3589
  pageInfo: history.pageInfo,
3428
3590
  refreshed: true,
@@ -3823,6 +3985,7 @@ export function registerSessionsRoutes(app, deps) {
3823
3985
  // "cancelled" with it.
3824
3986
  activeTurn: Number(stmts.getConversationActiveQueueCount?.get?.(resolvedConversationId)?.count || 0) > 0,
3825
3987
  backgroundTasks: backgroundTaskStore?.get?.(resolvedConversationId) || [],
3988
+ usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
3826
3989
  previews: listConversationPreviews?.(resolvedConversationId) || [],
3827
3990
  preferredRelayMode: preferences.preferredRelayMode,
3828
3991
  preferredModel: preferences.preferredModel,
@@ -3833,6 +3996,8 @@ export function registerSessionsRoutes(app, deps) {
3833
3996
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
3834
3997
  // Set on conversations another relay's agent created ("via <relay>").
3835
3998
  origin: parseRemoteRelayOriginJson(conv.origin_json),
3999
+ // Set on Claude Cloud conversations: repository, branch, session link.
4000
+ cloud: conversationCloudPayload(conv, runtimeSession),
3836
4001
  messages: history.messages,
3837
4002
  pageInfo: history.pageInfo,
3838
4003
  });
@@ -4176,7 +4341,14 @@ export function registerSessionsRoutes(app, deps) {
4176
4341
  // A session that is not 'ready' can still own a live process (status 'error',
4177
4342
  // or no registry entry at all). Detect that, or the stop is skipped and the
4178
4343
  // launch silently reuses the old process in the old directory.
4179
- const liveProcessDetected = !!sessionWorkerProcessInspector?.findProcessForSession?.(sdkSessionId);
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
+ }
4180
4352
  const eligibility = evaluateWorkspaceRootRelaunch({
4181
4353
  workerStatus: worker?.status,
4182
4354
  activeQueueCount,
@@ -4529,6 +4701,16 @@ export function registerSessionsRoutes(app, deps) {
4529
4701
  for (const key of new Set([id, workerSessionId])) backgroundTaskStore?.replace?.(key, []);
4530
4702
  }
4531
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
+
4532
4714
  const deletedAt = new Date().toISOString();
4533
4715
  markConversationDeleted.run(deletedAt, id);
4534
4716
  // Tombstones keep the SDK session importer from bringing it back.
@@ -4561,7 +4743,11 @@ export function registerSessionsRoutes(app, deps) {
4561
4743
  return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
4562
4744
  }
4563
4745
 
4564
- // 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.
4565
4751
  app.post('/api/conversation/:id/archive', auth, async (req, res) => {
4566
4752
  if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
4567
4753
  const id = String(req.params.id || '').trim();
@@ -4571,15 +4757,118 @@ export function registerSessionsRoutes(app, deps) {
4571
4757
  if (!existing || String(existing.status || '').trim() === 'deleted') {
4572
4758
  return res.json({ ok: true, alreadyDeleted: true });
4573
4759
  }
4574
- db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
4575
- io.emit('conversation_archived', { conversationId: id });
4576
- return res.json({ ok: true });
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);
4577
4836
  } catch (error) {
4578
4837
  console.warn(`[archive] Archive failed for ${id}: ${error?.message || error}`);
4579
4838
  return res.status(500).json({ error: 'Failed to archive conversation' });
4580
4839
  }
4581
4840
  });
4582
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
+
4583
4872
  app.post('/api/conversation/bootstrap', auth, async (req, res) => {
4584
4873
  // Another relay's agent starting a session: refused while the inbound
4585
4874
  // switch is off, otherwise the conversation keeps its provenance.
@@ -4603,6 +4892,47 @@ export function registerSessionsRoutes(app, deps) {
4603
4892
  const title = (requestedTitle || 'New Conversation').slice(0, 80);
4604
4893
  const openAISettings = getOpenAIProviderSettings();
4605
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
+ }
4606
4936
  const modelState = buildModelCatalogWithProviders(
4607
4937
  getModelCatalogState(),
4608
4938
  openAISettings,
@@ -4689,7 +5019,15 @@ export function registerSessionsRoutes(app, deps) {
4689
5019
  requestedBootstrapModel
4690
5020
  || (isProviderModelAvailable(catalogSelectedModel, availableModels) ? catalogSelectedModel : '')
4691
5021
  );
4692
- const providerModelSelection = useOpenAIProvider
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
4693
5031
  ? resolveProviderModelSelection({
4694
5032
  requestedModel: providerRequestedModel(availableOpenAIModels),
4695
5033
  // The removed resolveOpenAISessionModel carried this last-resort id.
@@ -4719,14 +5057,18 @@ export function registerSessionsRoutes(app, deps) {
4719
5057
  // Per-provider pre-checks used to duplicate it and had drifted apart in
4720
5058
  // wording, casing rules and whether they listed the alternatives.
4721
5059
  if (providerModelSelection && !providerModelSelection.ok) {
4722
- const providerLabel = useOpenAIProvider
4723
- ? 'OpenAI'
4724
- : (useClaudeProvider ? 'Claude' : (useCursorProvider ? 'Cursor' : 'Grok'));
4725
- const providerCode = useOpenAIProvider
4726
- ? 'OPENAI_MODEL_UNAVAILABLE'
4727
- : (useClaudeProvider
4728
- ? 'CLAUDE_MODEL_UNAVAILABLE'
4729
- : (useCursorProvider ? 'CURSOR_MODEL_UNAVAILABLE' : 'GROK_MODEL_UNAVAILABLE'));
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')));
4730
5072
  return res.status(400).json({
4731
5073
  ok: false,
4732
5074
  error: `${providerLabel} model "${providerModelSelection.requestedModel}" is not available`,
@@ -4792,7 +5134,7 @@ export function registerSessionsRoutes(app, deps) {
4792
5134
  code: 'OPENAI_IMAGE_MODEL_REQUIRED',
4793
5135
  });
4794
5136
  }
4795
- if (!useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider) {
5137
+ if (!useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider) {
4796
5138
  const selectedProviders = Array.isArray(modelState?.providersByModel?.[String(selectedModel || '').trim().toLowerCase()])
4797
5139
  ? modelState.providersByModel[String(selectedModel || '').trim().toLowerCase()]
4798
5140
  : [];
@@ -4834,13 +5176,15 @@ export function registerSessionsRoutes(app, deps) {
4834
5176
  }
4835
5177
  }
4836
5178
 
4837
- const bootstrapProviderType = useOpenAIProvider
4838
- ? 'openai'
4839
- : (useClaudeProvider
4840
- ? 'claude'
4841
- : (useCursorProvider
4842
- ? 'cursor'
4843
- : (useGrokProvider ? 'grok' : 'github')));
5179
+ const bootstrapProviderType = useClaudeCloudProvider
5180
+ ? 'claude-cloud'
5181
+ : (useOpenAIProvider
5182
+ ? 'openai'
5183
+ : (useClaudeProvider
5184
+ ? 'claude'
5185
+ : (useCursorProvider
5186
+ ? 'cursor'
5187
+ : (useGrokProvider ? 'grok' : 'github'))));
4844
5188
  // Preferences are written in the same transaction as the conversation, so
4845
5189
  // the caller's current mode has to arrive with the request: without it every
4846
5190
  // new chat would be stored as the default one and reset the composer. An
@@ -4889,9 +5233,14 @@ export function registerSessionsRoutes(app, deps) {
4889
5233
  {
4890
5234
  assignConfiguredProvider: true,
4891
5235
  providerType: bootstrapProviderType,
4892
- providerModel: (useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider) ? selectedModel : null,
5236
+ providerModel: (useClaudeCloudProvider || useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider) ? selectedModel : null,
4893
5237
  },
4894
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
+ }
4895
5244
  const preferences = persistConversationPreferences({
4896
5245
  db,
4897
5246
  stmts,
@@ -5001,6 +5350,9 @@ export function registerSessionsRoutes(app, deps) {
5001
5350
  conversationId,
5002
5351
  conversation,
5003
5352
  origin: parseRemoteRelayOriginJson(conversation?.origin_json),
5353
+ cloud: bootstrapCloudSource
5354
+ ? buildConversationCloudPayload({ cloudSourceJson: bootstrapCloudSource })
5355
+ : null,
5004
5356
  runtimeSessionId: runtimeSession?.id || null,
5005
5357
  ownerSessionId,
5006
5358
  worker: ownerWorker,
@@ -5043,6 +5395,7 @@ export function registerSessionsRoutes(app, deps) {
5043
5395
  featureFlags,
5044
5396
  supervisorSnapshot: sessionWorkerSupervisor?.snapshot?.({ pendingQuestionSessionIds }) || null,
5045
5397
  queueRows: listSessionWorkerQueueRows.all(...SESSION_WORKER_STATUS_QUEUE_STATES),
5398
+ processList: sessionWorkerProcessInspector?.getLastRead?.() || null,
5046
5399
  });
5047
5400
  const activeRuntimeSessionCount = featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true
5048
5401
  ? Number(sessionWorkerStatus?.onlineBoundProcessCount || 0)
@@ -5103,6 +5456,7 @@ export function registerSessionsRoutes(app, deps) {
5103
5456
  restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
5104
5457
  relayShutdown: runtimeState.relayShutdown || null,
5105
5458
  hostSuspend: runtimeState.hostSuspend || null,
5459
+ usageLimit: runtimeState.usageLimit || null,
5106
5460
  platform: process.platform,
5107
5461
  features: featureFlags || {},
5108
5462
  sessionWorker: sessionWorkerStatus,
@@ -5259,6 +5613,7 @@ export function registerSessionsRoutes(app, deps) {
5259
5613
  model: String(settings?.model || 'claude-sonnet-5').trim() || 'claude-sonnet-5',
5260
5614
  models: Array.isArray(settings?.models) ? settings.models : [],
5261
5615
  availableModels: Array.isArray(settings?.availableModels) ? settings.availableModels : [],
5616
+ ...claudeAttributionSettingsFields(settings),
5262
5617
  });
5263
5618
  });
5264
5619
 
@@ -5307,6 +5662,7 @@ export function registerSessionsRoutes(app, deps) {
5307
5662
  model: String(currentSettings?.model || result.model || 'claude-sonnet-5').trim() || 'claude-sonnet-5',
5308
5663
  models: Array.isArray(currentSettings?.models) ? currentSettings.models : [],
5309
5664
  availableModels: Array.isArray(currentSettings?.availableModels) ? currentSettings.availableModels : [],
5665
+ ...claudeAttributionSettingsFields(currentSettings),
5310
5666
  reconciliation,
5311
5667
  };
5312
5668
  io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
@@ -6250,6 +6606,39 @@ export function registerSessionsRoutes(app, deps) {
6250
6606
  return res.json({ ok: true, cancelled: !!result.cancelled, state: result.state });
6251
6607
  });
6252
6608
 
6609
+ // Turns paused at the Claude usage limit (usage-limit-pause-service): the
6610
+ // held follow-up can be sent before the reset, or dropped.
6611
+ app.get('/api/usage-limit', auth, (req, res) => {
6612
+ if (!usageLimitPauseService) {
6613
+ return res.status(501).json({ ok: false, error: 'Usage limit handling is unavailable' });
6614
+ }
6615
+ return res.json({
6616
+ ok: true,
6617
+ account: usageLimitPauseService.getAccountState(),
6618
+ pauses: usageLimitPauseService.listPauses(),
6619
+ });
6620
+ });
6621
+
6622
+ app.post('/api/usage-limit/resume', auth, (req, res) => {
6623
+ if (!usageLimitPauseService) {
6624
+ return res.status(501).json({ ok: false, error: 'Usage limit handling is unavailable' });
6625
+ }
6626
+ const conversationId = String(req.body?.conversationId || '').trim();
6627
+ if (!conversationId) return res.status(400).json({ ok: false, error: 'Missing conversationId' });
6628
+ const result = usageLimitPauseService.resumeNow({ conversationId });
6629
+ return res.json({ ok: true, resumed: !!result.resumed, messageId: result.messageId || null, pause: result.state });
6630
+ });
6631
+
6632
+ app.post('/api/usage-limit/cancel', auth, (req, res) => {
6633
+ if (!usageLimitPauseService) {
6634
+ return res.status(501).json({ ok: false, error: 'Usage limit handling is unavailable' });
6635
+ }
6636
+ const conversationId = String(req.body?.conversationId || '').trim();
6637
+ if (!conversationId) return res.status(400).json({ ok: false, error: 'Missing conversationId' });
6638
+ const result = usageLimitPauseService.cancel({ conversationId });
6639
+ return res.json({ ok: true, cancelled: !!result.cancelled, messageId: result.messageId || null, pause: result.state });
6640
+ });
6641
+
6253
6642
  app.get('/api/restart-orchestrator', auth, (req, res) => {
6254
6643
  ensureSessionId(req, res);
6255
6644
  return res.json({ orchestrator: relayRestartOrchestrator?.getState?.() || null });
@@ -6567,78 +6956,155 @@ export function registerSessionsRoutes(app, deps) {
6567
6956
  // token/cost diagnostics carried as collapsible detail sections. Each
6568
6957
  // provider is independent — a failing source yields an "unavailable" card
6569
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
+
6570
6981
  app.get('/api/usage', auth, async (req, res) => {
6571
6982
  const wantsLegacyOnly = String(req.query?.legacy || '').trim() === '1';
6572
-
6573
- let summary = null;
6574
- let summaryError = null;
6575
- try {
6576
- summary = await fetchUsageSummaryPromise(fetchUsageSummary);
6577
- } catch (error) {
6578
- summaryError = String(error?.message || error || 'Failed to fetch usage data');
6579
- }
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
+ };
6580
7002
 
6581
7003
  if (wantsLegacyOnly || !planUsageService) {
6582
- if (!summary) return res.status(500).json({ error: summaryError || 'Usage is unavailable' });
7004
+ const { summary, error } = await readCopilotSummary();
7005
+ if (!summary) return res.status(500).json({ error: error || 'Usage is unavailable' });
6583
7006
  return res.json(summary);
6584
7007
  }
6585
7008
 
6586
- // Billing detail is best-effort and must never delay or fail the card.
6587
- let billing = null;
6588
- if (typeof fetchCopilotBillingUsage === 'function') {
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;
6589
7013
  try {
6590
- billing = await fetchCopilotBillingUsage();
6591
- } catch (error) {
6592
- billing = { items: [], timePeriod: null, scope: null, error: String(error?.message || error) };
7014
+ value = await read();
7015
+ } catch {
7016
+ value = null;
6593
7017
  }
6594
- }
7018
+ const entry = { value, at: nowIso() };
7019
+ if (value !== null || !usageLiveCache[key]) usageLiveCache[key] = entry;
7020
+ return entry;
7021
+ };
6595
7022
 
6596
7023
  const claudeSettings = getClaudeProviderSettings();
6597
7024
  const cursorSettings = getCursorProviderSettings();
6598
7025
  const grokSettings = getGrokProviderSettings();
6599
- // Live Grok subscription quota is best-effort like Copilot billing.
6600
- let grokBilling = null;
6601
- if (grokSettings?.enabled === true && typeof fetchGrokBillingUsage === 'function') {
6602
- try {
6603
- grokBilling = await fetchGrokBillingUsage();
6604
- } catch {
6605
- grokBilling = null;
6606
- }
6607
- }
6608
- // Live Cursor plan quota (dashboard session token) — same best-effort rule.
6609
- let cursorBilling = null;
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.
6610
7065
  let cursorDashboardAuth = null;
6611
- if (cursorSettings?.enabled === true && typeof fetchCursorDashboardUsage === 'function') {
6612
- try {
6613
- cursorBilling = await fetchCursorDashboardUsage();
6614
- } catch {
6615
- cursorBilling = null;
6616
- }
6617
- // Whether a token exists at all decides which of the two very different
6618
- // "no live bars" explanations the card shows.
7066
+ if (cursorSettings?.enabled === true) {
6619
7067
  try {
6620
7068
  cursorDashboardAuth = getCursorDashboardTokenSettings() || null;
6621
7069
  } catch {
6622
7070
  cursorDashboardAuth = null;
6623
7071
  }
6624
7072
  }
7073
+
6625
7074
  const report = planUsageService.buildReport({
6626
- copilotSummary: summary,
6627
- copilotError: summaryError,
6628
- copilotBilling: billing,
7075
+ copilotSummary: copilot.summary,
7076
+ copilotError: copilot.error,
7077
+ copilotCapturedAt: copilot.at,
7078
+ copilotBilling: billingEntry.value,
6629
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(),
6630
7085
  cursorConfigured: cursorSettings?.enabled === true,
6631
7086
  cursorAllowances: getCursorPlanAllowanceSettings(),
6632
- cursorBilling,
7087
+ cursorBilling: cursorEntry.value,
6633
7088
  cursorDashboardAuth,
6634
7089
  grokConfigured: grokSettings?.enabled === true,
6635
7090
  grokAllowances: getGrokPlanAllowanceSettings(),
6636
- grokBilling,
7091
+ grokBilling: grokEntry.value,
6637
7092
  });
6638
7093
 
6639
7094
  // Legacy top-level fields stay alongside `providers` so an older cached
6640
7095
  // client shell keeps working through a PWA update.
6641
- return res.json({ ...(summary || {}), ...report });
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
+ });
6642
7108
  });
6643
7109
 
6644
7110
  app.get('/api/settings/cursor-allowance', auth, (_req, res) => {