@oar-sh/oar 0.9.6 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +176 -22
  3. package/package.json +1 -1
  4. package/server/README.md +174 -7
  5. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  6. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  7. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  10. package/server/claude-worker/claude-session-process.mjs +33 -0
  11. package/server/claude-worker/claude-session-worker.mjs +6 -0
  12. package/server/db-schema.mjs +17 -0
  13. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  14. package/server/migrations/0007-claude-cloud.mjs +82 -0
  15. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  16. package/server/public/app/agent-sessions-settings.mjs +134 -0
  17. package/server/public/app/api-client.js +143 -4
  18. package/server/public/app/attachments-view.js +20 -1
  19. package/server/public/app/attribution-control.mjs +46 -0
  20. package/server/public/app/bootstrap.js +344 -31
  21. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  22. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  23. package/server/public/app/claude-cloud-ui.mjs +611 -0
  24. package/server/public/app/composer-control-state.mjs +11 -0
  25. package/server/public/app/context-menu.mjs +140 -0
  26. package/server/public/app/context-tier-options.mjs +4 -0
  27. package/server/public/app/conversation-origin-ui.js +27 -0
  28. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  29. package/server/public/app/conversation-view.js +83 -4
  30. package/server/public/app/cwd-picker.js +12 -0
  31. package/server/public/app/journal-view.js +698 -28
  32. package/server/public/app/math-dollars.mjs +89 -0
  33. package/server/public/app/model-selector-options.mjs +1 -1
  34. package/server/public/app/plan-usage-view.mjs +36 -8
  35. package/server/public/app/relay-error-ctas.mjs +16 -2
  36. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  37. package/server/public/app/router.js +49 -12
  38. package/server/public/app/settings-modal.js +33 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +51 -0
  41. package/server/public/index.html +381 -7
  42. package/server/relay-tools.md +13 -1
  43. package/server/repositories/remote-relay-repository.mjs +42 -1
  44. package/server/repositories/session-repository.mjs +47 -1
  45. package/server/routes/claude-cloud-routes.mjs +140 -0
  46. package/server/routes/git-routes.mjs +37 -0
  47. package/server/routes/messages-routes.mjs +120 -15
  48. package/server/routes/remote-relay-routes.mjs +15 -1
  49. package/server/routes/sessions-routes.mjs +502 -72
  50. package/server/server-runtime.mjs +258 -25
  51. package/server/services/claude-account-usage-service.mjs +176 -0
  52. package/server/services/claude-cloud-repo-service.mjs +408 -0
  53. package/server/services/claude-cloud-session-service.mjs +326 -0
  54. package/server/services/claude-cloud-settings-service.mjs +269 -0
  55. package/server/services/git-remote-service.mjs +115 -0
  56. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  57. package/server/services/plan-usage-claude.mjs +202 -40
  58. package/server/services/plan-usage-contract.mjs +39 -3
  59. package/server/services/plan-usage-service.mjs +82 -2
  60. package/server/services/provider-reasoning-effort.mjs +3 -0
  61. package/server/services/remote-relay-caller-context.mjs +49 -10
  62. package/server/services/remote-relay-client.mjs +9 -0
  63. package/server/services/remote-relay-dispatcher.mjs +383 -35
  64. package/server/services/remote-relay-inbound.mjs +49 -2
  65. package/server/services/remote-relay-loopback.mjs +245 -0
  66. package/server/services/remote-relay-registry-service.mjs +52 -0
  67. package/server/services/session-worker-launch-service.mjs +100 -15
  68. package/server/services/session-worker-process-service.mjs +348 -129
  69. package/server/services/session-worker-stop-service.mjs +24 -13
  70. package/server/services/session-worker-supervisor-service.mjs +56 -1
  71. package/server/services/usage-limit-pause-service.mjs +3 -1
  72. package/server/services/worker-launch-note-service.mjs +151 -0
  73. package/shared/claude-attribution.mjs +117 -0
  74. package/shared/claude-cloud/api-client.mjs +809 -0
  75. package/shared/claude-cloud/base-url.mjs +47 -0
  76. package/shared/claude-cloud/credentials.mjs +221 -0
  77. package/shared/claude-cloud/repo-url.mjs +111 -0
  78. package/shared/provider-routing.mjs +4 -2
  79. package/shared/remote-relay-contract.mjs +153 -22
  80. package/shared/remote-relay-tool-core.mjs +11 -8
  81. package/shared/steer-settle-failure.mjs +1 -0
@@ -70,6 +70,7 @@ import {
70
70
  refreshConversationHistory,
71
71
  updateConversationTitle,
72
72
  updateConversationPreferences,
73
+ updateWorkspaceRootAttribution,
73
74
  createConversationShareLink,
74
75
  updateDefaultSessionWorkspaceRoot,
75
76
  scheduleContextUsageRefresh,
@@ -83,6 +84,12 @@ import {
83
84
  confirmNewConversationModel,
84
85
  closeNewConversationModelModal,
85
86
  deleteConv,
87
+ archiveConv,
88
+ unarchiveConv,
89
+ toggleArchivedConversations,
90
+ isArchivedConversationsView,
91
+ refreshNewConversationCloudRepoList,
92
+ openOriginConversation,
86
93
  } from './journal-view.js';
87
94
  import {
88
95
  loadRelayQuestions,
@@ -197,6 +204,7 @@ import {
197
204
  thinkingEnabledFromKey,
198
205
  thinkingEnabledToKey,
199
206
  } from './context-usage-view.mjs';
207
+ import { renderAttributionControlHtml } from './attribution-control.mjs';
200
208
  import {
201
209
  autoCompactWindowFromIndex,
202
210
  autoCompactWindowToIndex,
@@ -329,6 +337,22 @@ import {
329
337
  openGrokLogoutConfirmation,
330
338
  confirmGrokLogout,
331
339
  } from './grok-auth-ui.js';
340
+ import {
341
+ getClaudeCloudSettings,
342
+ openClaudeCloudLoginTab,
343
+ refreshClaudeCloudSettingsState,
344
+ saveClaudeCloudSettings,
345
+ subscribeClaudeCloudSettings,
346
+ toggleClaudeCloudProvider,
347
+ } from './claude-cloud-settings-ui.js';
348
+ import { openCurrentCloudSession, syncClaudeCloudConversationUi } from './claude-cloud-conversation-ui.js';
349
+ import { syncConversationOriginHeader } from './conversation-origin-ui.js';
350
+ import {
351
+ previewAgentSessionsMaxWait,
352
+ saveAgentSessionsMaxWait,
353
+ toggleAgentSessions,
354
+ } from './agent-sessions-settings-ui.js';
355
+ import { CLAUDE_CLOUD_PROVIDER, claudeCloudComposerModelIds } from './claude-cloud-ui.mjs';
332
356
  import {
333
357
  confirmCliInstall,
334
358
  runCliInstall,
@@ -360,6 +384,7 @@ const PROVIDER_LABELS = {
360
384
  openai: 'OpenAI',
361
385
  'openai-byok': 'OpenAI (BYOK)',
362
386
  claude: 'Claude SDK',
387
+ 'claude-cloud': 'Claude Cloud',
363
388
  cursor: 'Cursor SDK',
364
389
  grok: 'Grok',
365
390
  'github-copilot': 'GitHub Copilot',
@@ -1197,6 +1222,9 @@ function currentGrokModelLock() {
1197
1222
  };
1198
1223
  }
1199
1224
 
1225
+ // A cloud session's model is switched between turns like a local Claude
1226
+ // chat's (the worker sends set_model before the message), so there is no
1227
+ // cloud lock on the picker.
1200
1228
  function currentRuntimeModelLock() {
1201
1229
  return currentOpenAIModelLock() || currentGrokModelLock();
1202
1230
  }
@@ -1351,6 +1379,7 @@ function normalizeModelSelectorProviderType(providerType = '') {
1351
1379
  const normalized = String(providerType || '').trim().toLowerCase();
1352
1380
  if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
1353
1381
  if (normalized === 'claude') return 'claude';
1382
+ if (normalized === CLAUDE_CLOUD_PROVIDER) return CLAUDE_CLOUD_PROVIDER;
1354
1383
  if (normalized === 'cursor') return 'cursor';
1355
1384
  if (normalized === 'grok') return 'grok';
1356
1385
  return 'github';
@@ -1380,18 +1409,43 @@ function modelVisibleForActiveProvider(modelId, activeProviderType, providersByM
1380
1409
  const hasClaude = providers.includes('claude');
1381
1410
  const hasCursor = providers.includes('cursor');
1382
1411
  const hasGrok = providers.includes('grok');
1412
+ // Only present if a relay tags catalog rows for Claude Cloud; such a row is
1413
+ // then as exclusive as the other runtimes' rows.
1414
+ const hasClaudeCloud = providers.includes(CLAUDE_CLOUD_PROVIDER);
1383
1415
  const hasNonExclusiveProvider = providers.some((provider) => (
1384
1416
  provider !== 'openai-byok'
1385
1417
  && provider !== 'claude'
1386
1418
  && provider !== 'cursor'
1387
1419
  && provider !== 'grok'
1420
+ && provider !== CLAUDE_CLOUD_PROVIDER
1388
1421
  ));
1389
1422
  const activeProvider = normalizeModelSelectorProviderType(activeProviderType);
1390
1423
  if (activeProvider === 'openai') return hasOpenAIByok;
1391
1424
  if (activeProvider === 'claude') return hasClaude;
1392
1425
  if (activeProvider === 'cursor') return hasCursor;
1393
1426
  if (activeProvider === 'grok') return hasGrok;
1394
- return !((hasOpenAIByok || hasClaude || hasCursor || hasGrok) && !hasNonExclusiveProvider);
1427
+ return !((hasOpenAIByok || hasClaude || hasCursor || hasGrok || hasClaudeCloud) && !hasNonExclusiveProvider);
1428
+ }
1429
+
1430
+ // A Claude Cloud conversation does not pick from the shared catalog: its
1431
+ // models are the list of its settings tab (plain ids, no Auto), plus the model
1432
+ // the conversation is bound to.
1433
+ function buildClaudeCloudModelSelectorOptions(models, providersByModel, metadataByModel) {
1434
+ const conversation = currentConvId ? conversations[currentConvId] : null;
1435
+ const cloudModels = claudeCloudComposerModelIds({
1436
+ settings: getClaudeCloudSettings(),
1437
+ catalogModels: models,
1438
+ providersByModel,
1439
+ conversationModel: conversation?.runtimeProviderModel
1440
+ || conversation?.runtime_provider_model
1441
+ || conversation?.preferredModel
1442
+ || '',
1443
+ });
1444
+ return buildCatalogModelOptions(cloudModels, {
1445
+ autoValue: AUTO_MODEL_OPTION,
1446
+ metadataByModel,
1447
+ annotateContextWindow: false,
1448
+ }).filter((option) => option.value.toLowerCase() !== AUTO_MODEL_OPTION);
1395
1449
  }
1396
1450
 
1397
1451
  // metadataByModel is a parameter because the catalog refresh builds the next
@@ -1403,6 +1457,9 @@ function buildModelSelectorOptions(
1403
1457
  activeProviderType = '',
1404
1458
  metadataByModel = modelCatalogState.modelMetadataByModel,
1405
1459
  ) {
1460
+ if (normalizeModelSelectorProviderType(activeProviderType) === CLAUDE_CLOUD_PROVIDER) {
1461
+ return buildClaudeCloudModelSelectorOptions(models, providersByModel, metadataByModel);
1462
+ }
1406
1463
  const normalizedOptions = buildCatalogModelOptions(models.length ? models : [FALLBACK_MODEL], {
1407
1464
  autoValue: AUTO_MODEL_OPTION,
1408
1465
  metadataByModel,
@@ -1419,6 +1476,11 @@ function reasoningOptionsForModel(modelId = '') {
1419
1476
  if (!isModelMetadataHealthy()) return [];
1420
1477
  const key = String(modelId || '').trim().toLowerCase();
1421
1478
  const provider = activeComposerProviderType();
1479
+ // A cloud session has no effort choice; the catalog's efforts for the same
1480
+ // model id belong to the local Claude and Copilot runtimes. The one value is
1481
+ // still listed: the (hidden) select must hold it, because a send without an
1482
+ // effort is refused as "model metadata missing".
1483
+ if (provider === CLAUDE_CLOUD_PROVIDER) return ['none'];
1422
1484
  const providerOptions = modelCatalogState.reasoningByProvider?.[provider]?.[key];
1423
1485
  if (Array.isArray(providerOptions)) return normalizeReasoningEffortList(providerOptions);
1424
1486
  return normalizeReasoningEffortList(modelCatalogState.reasoningByModel?.[key] || []);
@@ -1757,7 +1819,11 @@ function updateModelPricingDetails(modelId, tier) {
1757
1819
  const grid = document.getElementById('model-pricing-grid');
1758
1820
  if (!details || !summary || !grid) return;
1759
1821
  const metadata = modelCatalogState.modelMetadataByModel?.[modelId] || {};
1760
- const pricing = metadata?.pricing?.[tier === 'long_context' ? 'longContext' : 'default'];
1822
+ // The catalog's prices are Copilot credits; a cloud session is billed to the
1823
+ // Claude account, so they would be the wrong numbers for it.
1824
+ const pricing = activeComposerProviderType() === CLAUDE_CLOUD_PROVIDER
1825
+ ? null
1826
+ : metadata?.pricing?.[tier === 'long_context' ? 'longContext' : 'default'];
1761
1827
  if (!pricing || typeof pricing !== 'object') {
1762
1828
  details.open = false;
1763
1829
  details.style.display = 'none';
@@ -1784,12 +1850,14 @@ function updateModelPricingDetails(modelId, tier) {
1784
1850
  details.style.display = grid.childElementCount ? '' : 'none';
1785
1851
  }
1786
1852
 
1787
- // Relay modes each provider backend actually honors. All four providers
1788
- // currently support the full set — Copilot/OpenAI via prompt-context
1853
+ // Relay modes each provider backend actually honors. The local providers
1854
+ // support the full set — Copilot/OpenAI via prompt-context
1789
1855
  // injection, Claude via permissionMode + system-prompt appends, Cursor via
1790
1856
  // native plan mode plus message-text nudges for ask/autopilot — but the
1791
1857
  // composer builds its options from this table so a provider that loses (or
1792
- // gains) a mode only needs a change here.
1858
+ // gains) a mode only needs a change here. A Claude Cloud session runs in the
1859
+ // cloud's own permission mode and ignores relay modes, so it has the one
1860
+ // entry and its selector is hidden (body.claude-cloud-conversation).
1793
1861
  const RELAY_MODE_LABELS = { agent: 'Agent', ask: 'Ask', plan: 'Plan', autopilot: 'Autopilot' };
1794
1862
  const RELAY_MODES_BY_PROVIDER = {
1795
1863
  github: ['agent', 'ask', 'plan', 'autopilot'],
@@ -1797,6 +1865,7 @@ const RELAY_MODES_BY_PROVIDER = {
1797
1865
  claude: ['agent', 'ask', 'plan', 'autopilot'],
1798
1866
  cursor: ['agent', 'ask', 'plan', 'autopilot'],
1799
1867
  grok: ['agent', 'ask', 'plan', 'autopilot'],
1868
+ 'claude-cloud': ['agent'],
1800
1869
  };
1801
1870
 
1802
1871
  function relayModesForProvider(providerType = '') {
@@ -1848,8 +1917,9 @@ async function persistCurrentConversationPreferences() {
1848
1917
  // record an effort the user never picked when they only changed the mode.
1849
1918
  const reasoningEffort = String(document.getElementById('reasoning-effort-select')?.value || '').trim().toLowerCase();
1850
1919
  // These keys hold the last explicit choice, so they are not rolled back when
1851
- // the write below fails: the user did make the choice either way.
1852
- localStorage.setItem(MODE_STORAGE_KEY, mode);
1920
+ // the write below fails: the user did make the choice either way. A cloud
1921
+ // chat has no mode to choose, so its fixed "agent" is not such a choice.
1922
+ if (activeComposerProviderType() !== CLAUDE_CLOUD_PROVIDER) localStorage.setItem(MODE_STORAGE_KEY, mode);
1853
1923
  if (model) localStorage.setItem(MODEL_STORAGE_KEY, model);
1854
1924
  if (reasoningEffort) localStorage.setItem(REASONING_STORAGE_KEY, reasoningEffort);
1855
1925
 
@@ -2337,6 +2407,9 @@ function renderModelVariantCatalogBody() {
2337
2407
  // shows Claude-SDK rows exclusively, the Cursor SDK tab Cursor rows, the
2338
2408
  // Grok tab Grok rows, and Copilot never shows rows that a different runtime
2339
2409
  // contributed (there is no cross-runtime switching).
2410
+ // Claude Cloud has no rows of its own to select: its picker is fed by the
2411
+ // Claude Cloud settings tab, so a row tagged for it belongs to no tab.
2412
+ if (entryProvider === CLAUDE_CLOUD_PROVIDER) return false;
2340
2413
  if (activeTab === 'anthropic') {
2341
2414
  return entryProvider === 'claude';
2342
2415
  }
@@ -2360,6 +2433,7 @@ function renderModelVariantCatalogBody() {
2360
2433
  && provider !== 'claude'
2361
2434
  && provider !== 'cursor'
2362
2435
  && provider !== 'grok'
2436
+ && provider !== CLAUDE_CLOUD_PROVIDER
2363
2437
  ));
2364
2438
  }
2365
2439
  return true;
@@ -2786,6 +2860,66 @@ async function saveSelectedModelsFromModal() {
2786
2860
  // click pins a provider so the modal's Refresh re-renders onto the same tab
2787
2861
  // instead of snapping back. Reset on every fresh open of the modal.
2788
2862
  let usageTabProviderOverride = null;
2863
+ // The last usage report, shown at once on the next open while the open tab's
2864
+ // provider is read live; kept across reloads in sessionStorage. `fetchedAt`
2865
+ // (per card id) decides whether a tab needs a live read when it is shown.
2866
+ const USAGE_REPORT_STORAGE_KEY = 'oar.usage.lastReport.v1';
2867
+ const USAGE_LIVE_FRESH_MS = 60_000;
2868
+ let lastUsageReport = null;
2869
+ let usageRefreshInFlight = null;
2870
+
2871
+ function readStoredUsageReport() {
2872
+ try {
2873
+ const parsed = JSON.parse(sessionStorage.getItem(USAGE_REPORT_STORAGE_KEY) || 'null');
2874
+ return parsed && Array.isArray(parsed.providers) ? parsed : null;
2875
+ } catch {
2876
+ return null;
2877
+ }
2878
+ }
2879
+
2880
+ function storeUsageReport(report) {
2881
+ lastUsageReport = report;
2882
+ try {
2883
+ sessionStorage.setItem(USAGE_REPORT_STORAGE_KEY, JSON.stringify(report));
2884
+ } catch {
2885
+ // Storage full or unavailable: the module copy still serves this page.
2886
+ }
2887
+ }
2888
+
2889
+ /**
2890
+ * The next report: cards the answer read live replace the kept ones; a card
2891
+ * the answer only served from its cache replaces nothing newer we hold (a
2892
+ * restarted relay has an empty cache and would otherwise blank good cards).
2893
+ */
2894
+ function mergeUsageReports(kept, answer) {
2895
+ if (!kept || !Array.isArray(kept.providers)) return answer;
2896
+ const live = new Set(Array.isArray(answer?.live) ? answer.live : []);
2897
+ const fetchedAt = { ...(kept.fetchedAt || {}) };
2898
+ const keptById = new Map(kept.providers.map((card) => [normalizeUsageProvider(card?.provider), card]));
2899
+ const providers = (answer.providers || []).map((card) => {
2900
+ const id = normalizeUsageProvider(card?.provider);
2901
+ const previous = keptById.get(id);
2902
+ if (live.has(id) || !previous) {
2903
+ fetchedAt[id] = answer.fetchedAt?.[id] || new Date().toISOString();
2904
+ return card;
2905
+ }
2906
+ // Unavailable from the cache but known from before: keep what we had.
2907
+ return card?.status === 'unavailable' && previous.status !== 'unavailable' ? previous : card;
2908
+ });
2909
+ return { ...answer, providers, fetchedAt };
2910
+ }
2911
+
2912
+ function usageCardIsFresh(provider) {
2913
+ const at = Date.parse(String(lastUsageReport?.fetchedAt?.[provider] || ''));
2914
+ return Number.isFinite(at) && Date.now() - at < USAGE_LIVE_FRESH_MS;
2915
+ }
2916
+
2917
+ /** The "Updating…" marker on one card, while its live read runs. */
2918
+ function markUsageCardUpdating(provider, updating) {
2919
+ const body = document.getElementById('summary-modal-body');
2920
+ const card = body?.querySelector(`[data-usage-panel="${CSS.escape(provider)}"]`);
2921
+ if (card) card.toggleAttribute('data-updating', !!updating);
2922
+ }
2789
2923
 
2790
2924
  /**
2791
2925
  * Delegated because #summary-modal-body's innerHTML is replaced on every render
@@ -2803,7 +2937,7 @@ function initUsageTabControl() {
2803
2937
  });
2804
2938
  }
2805
2939
 
2806
- /** Pure DOM toggle: switching tabs never refetches `/api/usage`. */
2940
+ /** DOM toggle first; a tab whose card is older than a minute is then read live. */
2807
2941
  function selectUsageTab(root, provider) {
2808
2942
  const target = String(provider || '').trim();
2809
2943
  if (!target) return;
@@ -2819,6 +2953,78 @@ function selectUsageTab(root, provider) {
2819
2953
  panel.hidden = panel.dataset.usagePanel !== target;
2820
2954
  }
2821
2955
  usageTabProviderOverride = target;
2956
+ if (!usageCardIsFresh(target)) {
2957
+ refreshUsageProvider(target).catch(() => {});
2958
+ }
2959
+ }
2960
+
2961
+ /**
2962
+ * Read one provider live and swap the result into the modal. Serialised: a
2963
+ * second request while one runs waits for it and is then made if still
2964
+ * needed (the tab may have changed).
2965
+ */
2966
+ async function refreshUsageProvider(provider) {
2967
+ const target = String(provider || '').trim();
2968
+ if (!target) return;
2969
+ if (usageRefreshInFlight) {
2970
+ await usageRefreshInFlight.catch(() => {});
2971
+ if (usageCardIsFresh(target)) return;
2972
+ }
2973
+ markUsageCardUpdating(target, true);
2974
+ const run = (async () => {
2975
+ const [usageResult, authResult] = await Promise.allSettled([
2976
+ loadUsageSummary({ providers: [target] }),
2977
+ getClaudeAuthStatus(),
2978
+ ]);
2979
+ if (usageResult.status === 'rejected') throw usageResult.reason;
2980
+ const answer = usageResult.value;
2981
+ if (!answer || !Array.isArray(answer.providers)) return;
2982
+ if (summaryModalState.kind !== 'usage') {
2983
+ storeUsageReport(mergeUsageReports(lastUsageReport, answer));
2984
+ return;
2985
+ }
2986
+ const account = authResult.status === 'fulfilled' ? claudeAccountFromAuthStatus(authResult.value) : null;
2987
+ renderUsageReport(mergeUsageReports(lastUsageReport, answer), account);
2988
+ })();
2989
+ usageRefreshInFlight = run;
2990
+ try {
2991
+ await run;
2992
+ } finally {
2993
+ if (usageRefreshInFlight === run) usageRefreshInFlight = null;
2994
+ markUsageCardUpdating(target, false);
2995
+ }
2996
+ }
2997
+
2998
+ /** Render a report into the open modal and keep it for the next open. */
2999
+ function renderUsageReport(report, account, { updating = '' } = {}) {
3000
+ storeUsageReport(report);
3001
+ const activeProvider = usageTabProviderOverride
3002
+ || normalizeUsageProvider(activeComposerProviderType());
3003
+ const planHtml = renderPlanUsageHtml(withClaudeAccount(report, account), { activeProvider });
3004
+ if (!planHtml) return false;
3005
+ initUsageTabControl();
3006
+ renderSummaryModalContent({
3007
+ title: 'Plan usage',
3008
+ subtitle: `${planUsageSubtitle(report)}${updating ? ' · updating…' : ''}`,
3009
+ bodyHtml: planHtml,
3010
+ refresh: refreshOpenUsageTab,
3011
+ kind: 'usage',
3012
+ });
3013
+ if (updating) markUsageCardUpdating(updating, true);
3014
+ return true;
3015
+ }
3016
+
3017
+ /** The modal's Refresh button: the open tab live, nothing else. */
3018
+ async function refreshOpenUsageTab() {
3019
+ const body = document.getElementById('summary-modal-body');
3020
+ const active = body?.querySelector('[data-usage-tab].active')?.dataset.usageTab
3021
+ || usageTabProviderOverride
3022
+ || normalizeUsageProvider(activeComposerProviderType());
3023
+ if (!lastUsageReport || !active) {
3024
+ await loadUsageSummaryAndRender();
3025
+ return;
3026
+ }
3027
+ await refreshUsageProvider(active);
2822
3028
  }
2823
3029
 
2824
3030
  /**
@@ -2848,11 +3054,18 @@ function withClaudeAccount(report, account) {
2848
3054
  };
2849
3055
  }
2850
3056
 
3057
+ /**
3058
+ * The first open with nothing kept: the open tab's provider is read live and
3059
+ * the other cards come from the relay's cache (or are read once when it has
3060
+ * none). With a kept report, showUsage renders it first and only refreshes.
3061
+ */
2851
3062
  async function loadUsageSummaryAndRender() {
3063
+ const activeProvider = usageTabProviderOverride
3064
+ || normalizeUsageProvider(activeComposerProviderType());
2852
3065
  // Usage is the point of the modal; the account line is a garnish, so the two
2853
3066
  // are settled independently and a failing auth probe cannot blank the cards.
2854
3067
  const [usageResult, authResult] = await Promise.allSettled([
2855
- loadUsageSummary(),
3068
+ loadUsageSummary({ providers: activeProvider ? [activeProvider] : null }),
2856
3069
  getClaudeAuthStatus(),
2857
3070
  ]);
2858
3071
  if (usageResult.status === 'rejected') throw usageResult.reason;
@@ -2861,20 +3074,7 @@ async function loadUsageSummaryAndRender() {
2861
3074
  const account = authResult.status === 'fulfilled'
2862
3075
  ? claudeAccountFromAuthStatus(authResult.value)
2863
3076
  : null;
2864
- const activeProvider = usageTabProviderOverride
2865
- || normalizeUsageProvider(activeComposerProviderType());
2866
- const planHtml = renderPlanUsageHtml(withClaudeAccount(d, account), { activeProvider });
2867
- if (planHtml) {
2868
- initUsageTabControl();
2869
- renderSummaryModalContent({
2870
- title: 'Plan usage',
2871
- subtitle: planUsageSubtitle(d),
2872
- bodyHtml: planHtml,
2873
- refresh: loadUsageSummaryAndRender,
2874
- kind: 'usage',
2875
- });
2876
- return;
2877
- }
3077
+ if (Array.isArray(d.providers) && renderUsageReport(mergeUsageReports(lastUsageReport, d), account)) return;
2878
3078
  // Legacy relay (or a relay without the plan-usage service): fall back to the
2879
3079
  // Copilot-only text summary rather than showing an empty modal.
2880
3080
  const pct = d.premiumInteractions?.percentRemaining != null
@@ -2954,6 +3154,36 @@ function setThinkingControlValue({ enabled, display }) {
2954
3154
  }
2955
3155
  }
2956
3156
 
3157
+ // The folder's commit attribution select in the 🧠 modal: one request per
3158
+ // change, then the modal is re-read so the effect text matches the relay.
3159
+ let attributionControlUpdateInFlight = false;
3160
+ function initAttributionControl() {
3161
+ const body = document.getElementById('summary-modal-body');
3162
+ if (!body || body.dataset.attributionControlBound === 'true') return;
3163
+ body.dataset.attributionControlBound = 'true';
3164
+ body.addEventListener('change', async (event) => {
3165
+ const select = event.target?.closest?.('#ctx-attribution-select');
3166
+ if (!select) return;
3167
+ const path = String(select.dataset.workspaceRoot || '').trim();
3168
+ if (!path || attributionControlUpdateInFlight) return;
3169
+ attributionControlUpdateInFlight = true;
3170
+ select.disabled = true;
3171
+ try {
3172
+ const response = await updateWorkspaceRootAttribution(path, select.value || null);
3173
+ if (!response || response.ok === false) throw new Error(String(response?.error || 'The relay refused the change'));
3174
+ showTransientRelayNotice(select.value
3175
+ ? `Commit attribution for this folder: ${select.value}.`
3176
+ : 'Commit attribution for this folder follows the provider setting again.');
3177
+ await refreshSummaryModal();
3178
+ } catch (error) {
3179
+ showTransientRelayNotice(`Could not change the commit attribution: ${error?.message || error}`);
3180
+ select.disabled = false;
3181
+ } finally {
3182
+ attributionControlUpdateInFlight = false;
3183
+ }
3184
+ });
3185
+ }
3186
+
2957
3187
  function initThinkingControl() {
2958
3188
  const body = document.getElementById('summary-modal-body');
2959
3189
  if (!body || body.dataset.thinkingBound === '1') return;
@@ -3055,9 +3285,11 @@ async function loadContextSummaryAndRender(convId) {
3055
3285
  const refreshLookupId = sessionId || trimmedConvId || null;
3056
3286
  const providerLabel = payload.providerType === 'claude'
3057
3287
  ? 'Claude'
3058
- : (payload.providerType === 'cursor'
3059
- ? 'Cursor'
3060
- : (payload.providerType === 'grok' ? 'Grok' : 'Copilot'));
3288
+ : (payload.providerType === CLAUDE_CLOUD_PROVIDER
3289
+ ? 'Claude Cloud'
3290
+ : (payload.providerType === 'cursor'
3291
+ ? 'Cursor'
3292
+ : (payload.providerType === 'grok' ? 'Grok' : 'Copilot')));
3061
3293
  const subtitle = sessionId
3062
3294
  ? `${providerLabel} session ${sessionId.slice(0, 8)}`
3063
3295
  : (trimmedConvId ? `Conversation ${trimmedConvId.slice(0, 8)}` : 'No conversation selected');
@@ -3086,6 +3318,11 @@ async function loadContextSummaryAndRender(convId) {
3086
3318
  thinkingDisplay: payload.thinkingDisplay ?? null,
3087
3319
  })
3088
3320
  : '';
3321
+ // What this folder's commits say, with the folder override: Claude, and a
3322
+ // Claude Cloud chat that was started from a folder (no path, no row).
3323
+ const attributionHtml = payload.providerType === 'claude' || payload.providerType === 'claude-cloud'
3324
+ ? renderAttributionControlHtml(payload.attribution || null)
3325
+ : '';
3089
3326
  // The slider's writes need the conversation id even when the modal was
3090
3327
  // opened through an sdk_session_id.
3091
3328
  autoCompactControlConversationId = String(
@@ -3102,10 +3339,10 @@ async function loadContextSummaryAndRender(convId) {
3102
3339
  // The structured breakdown is the answer; the runtime's own text dump stays
3103
3340
  // available underneath for the details it carries that categories don't.
3104
3341
  const bodyHtml = usageHtml
3105
- ? `${usageHtml}${autoCompactHtml}${thinkingHtml}${detailText
3342
+ ? `${usageHtml}${autoCompactHtml}${thinkingHtml}${attributionHtml}${detailText
3106
3343
  ? `<details class="ctx-usage-raw"><summary>Raw details</summary><pre>${escHtml(detailText)}</pre></details>`
3107
3344
  : ''}`
3108
- : `${autoCompactHtml}${thinkingHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
3345
+ : `${autoCompactHtml}${thinkingHtml}${attributionHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
3109
3346
 
3110
3347
  renderSummaryModalContent({
3111
3348
  title: 'Context usage',
@@ -3125,15 +3362,27 @@ async function showUsage() {
3125
3362
  btn.textContent = '⏳';
3126
3363
  btn.disabled = true;
3127
3364
  }
3365
+ // What was shown last time is shown again at once; only the open tab's
3366
+ // provider is read live, and its card is swapped in when the answer comes.
3367
+ const kept = lastUsageReport || readStoredUsageReport();
3368
+ const activeProvider = normalizeUsageProvider(activeComposerProviderType());
3128
3369
  openSummaryModal({
3129
3370
  title: 'Plan usage',
3130
3371
  subtitle: 'Loading…',
3131
- bodyHtml: '<div class="summary-loading">Fetching plan usage across providers…</div>',
3132
- refresh: loadUsageSummaryAndRender,
3372
+ bodyHtml: '<div class="summary-loading">Fetching plan usage…</div>',
3373
+ refresh: refreshOpenUsageTab,
3133
3374
  kind: 'usage',
3134
3375
  });
3135
- setSummaryModalLoading(true);
3136
3376
  try {
3377
+ if (kept && renderUsageReport(kept, null, { updating: activeProvider })) {
3378
+ if (btn) {
3379
+ btn.textContent = btn.id === 'chat-menu-usage' ? '📊 Check Usage' : '📊';
3380
+ btn.disabled = false;
3381
+ }
3382
+ await refreshUsageProvider(activeProvider);
3383
+ return;
3384
+ }
3385
+ setSummaryModalLoading(true);
3137
3386
  await loadUsageSummaryAndRender();
3138
3387
  } catch (e) {
3139
3388
  renderSummaryModalContent({
@@ -3747,6 +3996,11 @@ function syncChatTitleControls() {
3747
3996
  killBtn.disabled = sharedMode || !convId || !sdkSessionId;
3748
3997
  killBtn.hidden = sharedMode || !convId;
3749
3998
  }
3999
+ const archiveBtn = document.getElementById('chat-menu-archive');
4000
+ if (archiveBtn) {
4001
+ archiveBtn.hidden = sharedMode || !convId;
4002
+ archiveBtn.textContent = conversation?.archived === true ? '📂 Unarchive conversation' : '🗄 Archive conversation';
4003
+ }
3750
4004
  if (editor) {
3751
4005
  editor.hidden = !editing;
3752
4006
  }
@@ -3762,6 +4016,11 @@ function syncChatTitleControls() {
3762
4016
  if (!isStatusViewActive()) {
3763
4017
  syncChatHeaderWorkspaceLabel();
3764
4018
  }
4019
+ // Cloud line, hidden controls and the image-only attach button all follow
4020
+ // the conversation on screen, like the header does.
4021
+ syncClaudeCloudConversationUi();
4022
+ // "via agent": the conversation an agent on this relay started this one from.
4023
+ syncConversationOriginHeader();
3765
4024
  syncChatTitleWatcherIndicator();
3766
4025
  }
3767
4026
 
@@ -3919,6 +4178,21 @@ initTmuxInspectorView({
3919
4178
  closeChatActionsMenu,
3920
4179
  });
3921
4180
 
4181
+ // The composer's model list of a cloud conversation comes from the Claude
4182
+ // Cloud settings, which load (and change) independently of the catalog.
4183
+ function refreshClaudeCloudComposerModels() {
4184
+ if (isSharedReaderMode()) return;
4185
+ if (normalizeModelSelectorProviderType(activeComposerProviderType()) !== CLAUDE_CLOUD_PROVIDER) return;
4186
+ const select = document.getElementById('model-select');
4187
+ // Without a healthy catalog there is nothing to rebuild from (and a rebuild
4188
+ // would raise the metadata blocker); the next catalog load builds the list
4189
+ // with whatever settings have arrived by then.
4190
+ if (!select || !isModelMetadataHealthy()) return;
4191
+ // Dropping the scope makes syncAutoModelAvailability rebuild the options.
4192
+ delete select.dataset.providerScope;
4193
+ syncAutoModelAvailability();
4194
+ }
4195
+
3922
4196
  function showAuthGate(error = '') {
3923
4197
  document.getElementById('startup-loading')?.remove();
3924
4198
  document.getElementById('auth-gate').style.display = 'flex';
@@ -4023,6 +4297,17 @@ async function initApp() {
4023
4297
  });
4024
4298
  });
4025
4299
  }
4300
+ const chatMenuArchiveBtn = document.getElementById('chat-menu-archive');
4301
+ bindMenuAction(chatMenuArchiveBtn, (event) => {
4302
+ event.preventDefault();
4303
+ event.stopPropagation();
4304
+ lockChatActionsMenuShield(350);
4305
+ closeChatActionsMenu();
4306
+ const convId = String(currentConvId || '').trim();
4307
+ if (!convId) return;
4308
+ const archived = conversations[convId]?.archived === true;
4309
+ void (archived ? unarchiveConv(null, convId) : archiveConv(null, convId));
4310
+ });
4026
4311
  const chatMenuShareConversationBtn = document.getElementById('chat-menu-share-conversation');
4027
4312
  bindMenuAction(chatMenuShareConversationBtn, (event) => {
4028
4313
  event.preventDefault();
@@ -4082,6 +4367,14 @@ async function initApp() {
4082
4367
  closeChatActionsMenu();
4083
4368
  openGitChangesModal();
4084
4369
  });
4370
+ const chatMenuOpenCloudSessionBtn = document.getElementById('chat-menu-open-cloud-session');
4371
+ bindMenuAction(chatMenuOpenCloudSessionBtn, (event) => {
4372
+ event.preventDefault();
4373
+ event.stopPropagation();
4374
+ lockChatActionsMenuShield(350);
4375
+ closeChatActionsMenu();
4376
+ openCurrentCloudSession(showExternalLinkFallback);
4377
+ });
4085
4378
  const chatMenuSettingsBtn = document.getElementById('chat-menu-settings');
4086
4379
  bindMenuAction(chatMenuSettingsBtn, (event) => {
4087
4380
  event.preventDefault();
@@ -4172,6 +4465,7 @@ async function initApp() {
4172
4465
  }
4173
4466
  initAutoCompactWindowControl();
4174
4467
  initThinkingControl();
4468
+ initAttributionControl();
4175
4469
  initModeSelector();
4176
4470
  initModelSelector();
4177
4471
  initReasoningSelector();
@@ -4190,6 +4484,11 @@ async function initApp() {
4190
4484
  await refreshOpenAISettingsState();
4191
4485
  await refreshCursorSettingsState();
4192
4486
  await refreshGrokSettingsState();
4487
+ // Not awaited: only a Claude Cloud conversation needs it (its model list),
4488
+ // and the subscription below rebuilds that list when the answer arrives. A
4489
+ // relay without the provider answers nothing and nothing changes.
4490
+ subscribeClaudeCloudSettings(refreshClaudeCloudComposerModels);
4491
+ void refreshClaudeCloudSettingsState();
4193
4492
  await refreshModelCatalog(true);
4194
4493
  initFullscreenButton();
4195
4494
  initInstallButton();
@@ -4311,6 +4610,9 @@ window.dismissAvailableUpdate = dismissAvailableUpdate;
4311
4610
  window.dismissUpdateOutcome = dismissUpdateOutcome;
4312
4611
  window.saveGrokSettings = saveGrokSettings;
4313
4612
  window.toggleGrokProvider = toggleGrokProvider;
4613
+ window.saveClaudeCloudSettings = saveClaudeCloudSettings;
4614
+ window.toggleClaudeCloudProvider = toggleClaudeCloudProvider;
4615
+ window.openClaudeCloudLoginTab = openClaudeCloudLoginTab;
4314
4616
  window.saveCursorSettings = saveCursorSettings;
4315
4617
  window.removeCursorSettings = removeCursorSettings;
4316
4618
  window.saveCursorAllowanceSettings = saveCursorAllowanceSettings;
@@ -4334,6 +4636,9 @@ window.addRemoteRelayFromForm = addRemoteRelayFromForm;
4334
4636
  window.showRemoteRelayTokenField = showRemoteRelayTokenField;
4335
4637
  window.saveRemoteRelayPublicUrl = saveRemoteRelayPublicUrl;
4336
4638
  window.toggleRemoteRelayInbound = toggleRemoteRelayInbound;
4639
+ window.toggleAgentSessions = toggleAgentSessions;
4640
+ window.previewAgentSessionsMaxWait = previewAgentSessionsMaxWait;
4641
+ window.saveAgentSessionsMaxWait = saveAgentSessionsMaxWait;
4337
4642
  window.doAuth = doAuth;
4338
4643
  window.initApp = initApp;
4339
4644
  window.connectSocket = connectSocket;
@@ -4370,7 +4675,12 @@ window.newConversation = newConversation;
4370
4675
  window.confirmNewConversationModel = confirmNewConversationModel;
4371
4676
  window.closeNewConversationModelModal = closeNewConversationModelModal;
4372
4677
  window.deleteConv = deleteConv;
4678
+ window.archiveConv = archiveConv;
4679
+ window.unarchiveConv = unarchiveConv;
4680
+ window.toggleArchivedConversations = toggleArchivedConversations;
4681
+ window.refreshNewConversationCloudRepoList = refreshNewConversationCloudRepoList;
4373
4682
  window.openConversation = openConversation;
4683
+ window.openOriginConversation = openOriginConversation;
4374
4684
  window.toggleStatusView = toggleStatusView;
4375
4685
  window.refreshConversations = refreshConversations;
4376
4686
  window.renderConvList = renderConvList;
@@ -4428,6 +4738,9 @@ window.syncChatTitleControls = syncChatTitleControls;
4428
4738
  window.closeChatActionsMenu = closeChatActionsMenu;
4429
4739
  window.openSuspendHostConfirmation = openSuspendHostConfirmation;
4430
4740
  window.confirmKillCurrentSession = confirmKillCurrentSession;
4741
+ // The conversation list's context menu reaches these for the open conversation.
4742
+ window.openKillSessionConfirmation = openKillSessionConfirmation;
4743
+ window.openChatTitleEditor = openChatTitleEditor;
4431
4744
  window.confirmRestartWebRelay = confirmRestartWebRelay;
4432
4745
  window.confirmSuspendHost = confirmSuspendHost;
4433
4746
  window.cancelQueuedHostSuspend = cancelQueuedHostSuspend;