@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
@@ -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,
@@ -312,6 +320,7 @@ import {
312
320
  cancelQueuedHostSuspend,
313
321
  } from './action-confirmations.js';
314
322
  import { applyPendingActionsFromStatus, initHostSuspendUi } from './host-suspend-ui.js';
323
+ import { applyUsageLimitFromStatus, initUsageLimitUi } from './usage-limit-ui.js';
315
324
  import {
316
325
  startClaudeRelogin,
317
326
  submitClaudeLoginCodeFromInput,
@@ -328,6 +337,22 @@ import {
328
337
  openGrokLogoutConfirmation,
329
338
  confirmGrokLogout,
330
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';
331
356
  import {
332
357
  confirmCliInstall,
333
358
  runCliInstall,
@@ -359,6 +384,7 @@ const PROVIDER_LABELS = {
359
384
  openai: 'OpenAI',
360
385
  'openai-byok': 'OpenAI (BYOK)',
361
386
  claude: 'Claude SDK',
387
+ 'claude-cloud': 'Claude Cloud',
362
388
  cursor: 'Cursor SDK',
363
389
  grok: 'Grok',
364
390
  'github-copilot': 'GitHub Copilot',
@@ -1196,6 +1222,9 @@ function currentGrokModelLock() {
1196
1222
  };
1197
1223
  }
1198
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.
1199
1228
  function currentRuntimeModelLock() {
1200
1229
  return currentOpenAIModelLock() || currentGrokModelLock();
1201
1230
  }
@@ -1350,6 +1379,7 @@ function normalizeModelSelectorProviderType(providerType = '') {
1350
1379
  const normalized = String(providerType || '').trim().toLowerCase();
1351
1380
  if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
1352
1381
  if (normalized === 'claude') return 'claude';
1382
+ if (normalized === CLAUDE_CLOUD_PROVIDER) return CLAUDE_CLOUD_PROVIDER;
1353
1383
  if (normalized === 'cursor') return 'cursor';
1354
1384
  if (normalized === 'grok') return 'grok';
1355
1385
  return 'github';
@@ -1379,18 +1409,43 @@ function modelVisibleForActiveProvider(modelId, activeProviderType, providersByM
1379
1409
  const hasClaude = providers.includes('claude');
1380
1410
  const hasCursor = providers.includes('cursor');
1381
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);
1382
1415
  const hasNonExclusiveProvider = providers.some((provider) => (
1383
1416
  provider !== 'openai-byok'
1384
1417
  && provider !== 'claude'
1385
1418
  && provider !== 'cursor'
1386
1419
  && provider !== 'grok'
1420
+ && provider !== CLAUDE_CLOUD_PROVIDER
1387
1421
  ));
1388
1422
  const activeProvider = normalizeModelSelectorProviderType(activeProviderType);
1389
1423
  if (activeProvider === 'openai') return hasOpenAIByok;
1390
1424
  if (activeProvider === 'claude') return hasClaude;
1391
1425
  if (activeProvider === 'cursor') return hasCursor;
1392
1426
  if (activeProvider === 'grok') return hasGrok;
1393
- 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);
1394
1449
  }
1395
1450
 
1396
1451
  // metadataByModel is a parameter because the catalog refresh builds the next
@@ -1402,6 +1457,9 @@ function buildModelSelectorOptions(
1402
1457
  activeProviderType = '',
1403
1458
  metadataByModel = modelCatalogState.modelMetadataByModel,
1404
1459
  ) {
1460
+ if (normalizeModelSelectorProviderType(activeProviderType) === CLAUDE_CLOUD_PROVIDER) {
1461
+ return buildClaudeCloudModelSelectorOptions(models, providersByModel, metadataByModel);
1462
+ }
1405
1463
  const normalizedOptions = buildCatalogModelOptions(models.length ? models : [FALLBACK_MODEL], {
1406
1464
  autoValue: AUTO_MODEL_OPTION,
1407
1465
  metadataByModel,
@@ -1418,6 +1476,11 @@ function reasoningOptionsForModel(modelId = '') {
1418
1476
  if (!isModelMetadataHealthy()) return [];
1419
1477
  const key = String(modelId || '').trim().toLowerCase();
1420
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'];
1421
1484
  const providerOptions = modelCatalogState.reasoningByProvider?.[provider]?.[key];
1422
1485
  if (Array.isArray(providerOptions)) return normalizeReasoningEffortList(providerOptions);
1423
1486
  return normalizeReasoningEffortList(modelCatalogState.reasoningByModel?.[key] || []);
@@ -1756,7 +1819,11 @@ function updateModelPricingDetails(modelId, tier) {
1756
1819
  const grid = document.getElementById('model-pricing-grid');
1757
1820
  if (!details || !summary || !grid) return;
1758
1821
  const metadata = modelCatalogState.modelMetadataByModel?.[modelId] || {};
1759
- 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'];
1760
1827
  if (!pricing || typeof pricing !== 'object') {
1761
1828
  details.open = false;
1762
1829
  details.style.display = 'none';
@@ -1783,12 +1850,14 @@ function updateModelPricingDetails(modelId, tier) {
1783
1850
  details.style.display = grid.childElementCount ? '' : 'none';
1784
1851
  }
1785
1852
 
1786
- // Relay modes each provider backend actually honors. All four providers
1787
- // 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
1788
1855
  // injection, Claude via permissionMode + system-prompt appends, Cursor via
1789
1856
  // native plan mode plus message-text nudges for ask/autopilot — but the
1790
1857
  // composer builds its options from this table so a provider that loses (or
1791
- // 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).
1792
1861
  const RELAY_MODE_LABELS = { agent: 'Agent', ask: 'Ask', plan: 'Plan', autopilot: 'Autopilot' };
1793
1862
  const RELAY_MODES_BY_PROVIDER = {
1794
1863
  github: ['agent', 'ask', 'plan', 'autopilot'],
@@ -1796,6 +1865,7 @@ const RELAY_MODES_BY_PROVIDER = {
1796
1865
  claude: ['agent', 'ask', 'plan', 'autopilot'],
1797
1866
  cursor: ['agent', 'ask', 'plan', 'autopilot'],
1798
1867
  grok: ['agent', 'ask', 'plan', 'autopilot'],
1868
+ 'claude-cloud': ['agent'],
1799
1869
  };
1800
1870
 
1801
1871
  function relayModesForProvider(providerType = '') {
@@ -1847,8 +1917,9 @@ async function persistCurrentConversationPreferences() {
1847
1917
  // record an effort the user never picked when they only changed the mode.
1848
1918
  const reasoningEffort = String(document.getElementById('reasoning-effort-select')?.value || '').trim().toLowerCase();
1849
1919
  // These keys hold the last explicit choice, so they are not rolled back when
1850
- // the write below fails: the user did make the choice either way.
1851
- 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);
1852
1923
  if (model) localStorage.setItem(MODEL_STORAGE_KEY, model);
1853
1924
  if (reasoningEffort) localStorage.setItem(REASONING_STORAGE_KEY, reasoningEffort);
1854
1925
 
@@ -2336,6 +2407,9 @@ function renderModelVariantCatalogBody() {
2336
2407
  // shows Claude-SDK rows exclusively, the Cursor SDK tab Cursor rows, the
2337
2408
  // Grok tab Grok rows, and Copilot never shows rows that a different runtime
2338
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;
2339
2413
  if (activeTab === 'anthropic') {
2340
2414
  return entryProvider === 'claude';
2341
2415
  }
@@ -2359,6 +2433,7 @@ function renderModelVariantCatalogBody() {
2359
2433
  && provider !== 'claude'
2360
2434
  && provider !== 'cursor'
2361
2435
  && provider !== 'grok'
2436
+ && provider !== CLAUDE_CLOUD_PROVIDER
2362
2437
  ));
2363
2438
  }
2364
2439
  return true;
@@ -2785,6 +2860,66 @@ async function saveSelectedModelsFromModal() {
2785
2860
  // click pins a provider so the modal's Refresh re-renders onto the same tab
2786
2861
  // instead of snapping back. Reset on every fresh open of the modal.
2787
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
+ }
2788
2923
 
2789
2924
  /**
2790
2925
  * Delegated because #summary-modal-body's innerHTML is replaced on every render
@@ -2802,7 +2937,7 @@ function initUsageTabControl() {
2802
2937
  });
2803
2938
  }
2804
2939
 
2805
- /** 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. */
2806
2941
  function selectUsageTab(root, provider) {
2807
2942
  const target = String(provider || '').trim();
2808
2943
  if (!target) return;
@@ -2818,6 +2953,78 @@ function selectUsageTab(root, provider) {
2818
2953
  panel.hidden = panel.dataset.usagePanel !== target;
2819
2954
  }
2820
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);
2821
3028
  }
2822
3029
 
2823
3030
  /**
@@ -2847,11 +3054,18 @@ function withClaudeAccount(report, account) {
2847
3054
  };
2848
3055
  }
2849
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
+ */
2850
3062
  async function loadUsageSummaryAndRender() {
3063
+ const activeProvider = usageTabProviderOverride
3064
+ || normalizeUsageProvider(activeComposerProviderType());
2851
3065
  // Usage is the point of the modal; the account line is a garnish, so the two
2852
3066
  // are settled independently and a failing auth probe cannot blank the cards.
2853
3067
  const [usageResult, authResult] = await Promise.allSettled([
2854
- loadUsageSummary(),
3068
+ loadUsageSummary({ providers: activeProvider ? [activeProvider] : null }),
2855
3069
  getClaudeAuthStatus(),
2856
3070
  ]);
2857
3071
  if (usageResult.status === 'rejected') throw usageResult.reason;
@@ -2860,20 +3074,7 @@ async function loadUsageSummaryAndRender() {
2860
3074
  const account = authResult.status === 'fulfilled'
2861
3075
  ? claudeAccountFromAuthStatus(authResult.value)
2862
3076
  : null;
2863
- const activeProvider = usageTabProviderOverride
2864
- || normalizeUsageProvider(activeComposerProviderType());
2865
- const planHtml = renderPlanUsageHtml(withClaudeAccount(d, account), { activeProvider });
2866
- if (planHtml) {
2867
- initUsageTabControl();
2868
- renderSummaryModalContent({
2869
- title: 'Plan usage',
2870
- subtitle: planUsageSubtitle(d),
2871
- bodyHtml: planHtml,
2872
- refresh: loadUsageSummaryAndRender,
2873
- kind: 'usage',
2874
- });
2875
- return;
2876
- }
3077
+ if (Array.isArray(d.providers) && renderUsageReport(mergeUsageReports(lastUsageReport, d), account)) return;
2877
3078
  // Legacy relay (or a relay without the plan-usage service): fall back to the
2878
3079
  // Copilot-only text summary rather than showing an empty modal.
2879
3080
  const pct = d.premiumInteractions?.percentRemaining != null
@@ -2953,6 +3154,36 @@ function setThinkingControlValue({ enabled, display }) {
2953
3154
  }
2954
3155
  }
2955
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
+
2956
3187
  function initThinkingControl() {
2957
3188
  const body = document.getElementById('summary-modal-body');
2958
3189
  if (!body || body.dataset.thinkingBound === '1') return;
@@ -3054,9 +3285,11 @@ async function loadContextSummaryAndRender(convId) {
3054
3285
  const refreshLookupId = sessionId || trimmedConvId || null;
3055
3286
  const providerLabel = payload.providerType === 'claude'
3056
3287
  ? 'Claude'
3057
- : (payload.providerType === 'cursor'
3058
- ? 'Cursor'
3059
- : (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')));
3060
3293
  const subtitle = sessionId
3061
3294
  ? `${providerLabel} session ${sessionId.slice(0, 8)}`
3062
3295
  : (trimmedConvId ? `Conversation ${trimmedConvId.slice(0, 8)}` : 'No conversation selected');
@@ -3085,6 +3318,11 @@ async function loadContextSummaryAndRender(convId) {
3085
3318
  thinkingDisplay: payload.thinkingDisplay ?? null,
3086
3319
  })
3087
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
+ : '';
3088
3326
  // The slider's writes need the conversation id even when the modal was
3089
3327
  // opened through an sdk_session_id.
3090
3328
  autoCompactControlConversationId = String(
@@ -3101,10 +3339,10 @@ async function loadContextSummaryAndRender(convId) {
3101
3339
  // The structured breakdown is the answer; the runtime's own text dump stays
3102
3340
  // available underneath for the details it carries that categories don't.
3103
3341
  const bodyHtml = usageHtml
3104
- ? `${usageHtml}${autoCompactHtml}${thinkingHtml}${detailText
3342
+ ? `${usageHtml}${autoCompactHtml}${thinkingHtml}${attributionHtml}${detailText
3105
3343
  ? `<details class="ctx-usage-raw"><summary>Raw details</summary><pre>${escHtml(detailText)}</pre></details>`
3106
3344
  : ''}`
3107
- : `${autoCompactHtml}${thinkingHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
3345
+ : `${autoCompactHtml}${thinkingHtml}${attributionHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
3108
3346
 
3109
3347
  renderSummaryModalContent({
3110
3348
  title: 'Context usage',
@@ -3124,15 +3362,27 @@ async function showUsage() {
3124
3362
  btn.textContent = '⏳';
3125
3363
  btn.disabled = true;
3126
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());
3127
3369
  openSummaryModal({
3128
3370
  title: 'Plan usage',
3129
3371
  subtitle: 'Loading…',
3130
- bodyHtml: '<div class="summary-loading">Fetching plan usage across providers…</div>',
3131
- refresh: loadUsageSummaryAndRender,
3372
+ bodyHtml: '<div class="summary-loading">Fetching plan usage…</div>',
3373
+ refresh: refreshOpenUsageTab,
3132
3374
  kind: 'usage',
3133
3375
  });
3134
- setSummaryModalLoading(true);
3135
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);
3136
3386
  await loadUsageSummaryAndRender();
3137
3387
  } catch (e) {
3138
3388
  renderSummaryModalContent({
@@ -3250,6 +3500,7 @@ async function refreshSessionWorkerStatus() {
3250
3500
  if (!status) return;
3251
3501
  syncQueueStatusMenuEntry(status);
3252
3502
  applyPendingActionsFromStatus(status);
3503
+ applyUsageLimitFromStatus(status);
3253
3504
  if (setSessionWorkerStatesFromStatusPayload(status.sessionWorker)) {
3254
3505
  renderConvList();
3255
3506
  // A worker steering snapshot may have changed; the composer's Steer
@@ -3745,6 +3996,11 @@ function syncChatTitleControls() {
3745
3996
  killBtn.disabled = sharedMode || !convId || !sdkSessionId;
3746
3997
  killBtn.hidden = sharedMode || !convId;
3747
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
+ }
3748
4004
  if (editor) {
3749
4005
  editor.hidden = !editing;
3750
4006
  }
@@ -3760,6 +4016,11 @@ function syncChatTitleControls() {
3760
4016
  if (!isStatusViewActive()) {
3761
4017
  syncChatHeaderWorkspaceLabel();
3762
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();
3763
4024
  syncChatTitleWatcherIndicator();
3764
4025
  }
3765
4026
 
@@ -3917,6 +4178,21 @@ initTmuxInspectorView({
3917
4178
  closeChatActionsMenu,
3918
4179
  });
3919
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
+
3920
4196
  function showAuthGate(error = '') {
3921
4197
  document.getElementById('startup-loading')?.remove();
3922
4198
  document.getElementById('auth-gate').style.display = 'flex';
@@ -3959,6 +4235,7 @@ async function initApp() {
3959
4235
  if (!sharedMode) {
3960
4236
  syncSuspendHostVisibility();
3961
4237
  initHostSuspendUi();
4238
+ initUsageLimitUi();
3962
4239
  }
3963
4240
  setupViewportTracking();
3964
4241
  bindChatSelectionGuard();
@@ -4020,6 +4297,17 @@ async function initApp() {
4020
4297
  });
4021
4298
  });
4022
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
+ });
4023
4311
  const chatMenuShareConversationBtn = document.getElementById('chat-menu-share-conversation');
4024
4312
  bindMenuAction(chatMenuShareConversationBtn, (event) => {
4025
4313
  event.preventDefault();
@@ -4079,6 +4367,14 @@ async function initApp() {
4079
4367
  closeChatActionsMenu();
4080
4368
  openGitChangesModal();
4081
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
+ });
4082
4378
  const chatMenuSettingsBtn = document.getElementById('chat-menu-settings');
4083
4379
  bindMenuAction(chatMenuSettingsBtn, (event) => {
4084
4380
  event.preventDefault();
@@ -4169,6 +4465,7 @@ async function initApp() {
4169
4465
  }
4170
4466
  initAutoCompactWindowControl();
4171
4467
  initThinkingControl();
4468
+ initAttributionControl();
4172
4469
  initModeSelector();
4173
4470
  initModelSelector();
4174
4471
  initReasoningSelector();
@@ -4187,6 +4484,11 @@ async function initApp() {
4187
4484
  await refreshOpenAISettingsState();
4188
4485
  await refreshCursorSettingsState();
4189
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();
4190
4492
  await refreshModelCatalog(true);
4191
4493
  initFullscreenButton();
4192
4494
  initInstallButton();
@@ -4308,6 +4610,9 @@ window.dismissAvailableUpdate = dismissAvailableUpdate;
4308
4610
  window.dismissUpdateOutcome = dismissUpdateOutcome;
4309
4611
  window.saveGrokSettings = saveGrokSettings;
4310
4612
  window.toggleGrokProvider = toggleGrokProvider;
4613
+ window.saveClaudeCloudSettings = saveClaudeCloudSettings;
4614
+ window.toggleClaudeCloudProvider = toggleClaudeCloudProvider;
4615
+ window.openClaudeCloudLoginTab = openClaudeCloudLoginTab;
4311
4616
  window.saveCursorSettings = saveCursorSettings;
4312
4617
  window.removeCursorSettings = removeCursorSettings;
4313
4618
  window.saveCursorAllowanceSettings = saveCursorAllowanceSettings;
@@ -4331,6 +4636,9 @@ window.addRemoteRelayFromForm = addRemoteRelayFromForm;
4331
4636
  window.showRemoteRelayTokenField = showRemoteRelayTokenField;
4332
4637
  window.saveRemoteRelayPublicUrl = saveRemoteRelayPublicUrl;
4333
4638
  window.toggleRemoteRelayInbound = toggleRemoteRelayInbound;
4639
+ window.toggleAgentSessions = toggleAgentSessions;
4640
+ window.previewAgentSessionsMaxWait = previewAgentSessionsMaxWait;
4641
+ window.saveAgentSessionsMaxWait = saveAgentSessionsMaxWait;
4334
4642
  window.doAuth = doAuth;
4335
4643
  window.initApp = initApp;
4336
4644
  window.connectSocket = connectSocket;
@@ -4367,7 +4675,12 @@ window.newConversation = newConversation;
4367
4675
  window.confirmNewConversationModel = confirmNewConversationModel;
4368
4676
  window.closeNewConversationModelModal = closeNewConversationModelModal;
4369
4677
  window.deleteConv = deleteConv;
4678
+ window.archiveConv = archiveConv;
4679
+ window.unarchiveConv = unarchiveConv;
4680
+ window.toggleArchivedConversations = toggleArchivedConversations;
4681
+ window.refreshNewConversationCloudRepoList = refreshNewConversationCloudRepoList;
4370
4682
  window.openConversation = openConversation;
4683
+ window.openOriginConversation = openOriginConversation;
4371
4684
  window.toggleStatusView = toggleStatusView;
4372
4685
  window.refreshConversations = refreshConversations;
4373
4686
  window.renderConvList = renderConvList;
@@ -4425,6 +4738,9 @@ window.syncChatTitleControls = syncChatTitleControls;
4425
4738
  window.closeChatActionsMenu = closeChatActionsMenu;
4426
4739
  window.openSuspendHostConfirmation = openSuspendHostConfirmation;
4427
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;
4428
4744
  window.confirmRestartWebRelay = confirmRestartWebRelay;
4429
4745
  window.confirmSuspendHost = confirmSuspendHost;
4430
4746
  window.cancelQueuedHostSuspend = cancelQueuedHostSuspend;