@oar-sh/oar 0.9.17 → 0.9.19

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 (104) hide show
  1. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +13 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  3. package/CHANGELOG.md +234 -0
  4. package/README.md +13 -12
  5. package/package.json +1 -1
  6. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  7. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  8. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +5 -5
  10. package/server/claude-worker/claude-sdk-adapter.mjs +35 -9
  11. package/server/claude-worker/claude-session-process.mjs +166 -21
  12. package/server/claude-worker/claude-session-worker-main.mjs +3 -0
  13. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  14. package/server/claude-worker/claude-turn-publisher.mjs +117 -5
  15. package/server/claude-worker/claude-usage-totals.mjs +53 -0
  16. package/server/claude-worker/sdk-message-normalizer.mjs +1 -1
  17. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  18. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +9 -9
  20. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  21. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  22. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  23. package/server/db-schema.mjs +33 -1
  24. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -130
  25. package/server/grok-worker/acp-client.mjs +1 -1
  26. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  27. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  28. package/server/mcp/oar-mcp-server.mjs +11 -5
  29. package/server/migrations/0031-queue-provider.mjs +143 -0
  30. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  31. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  32. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  33. package/server/public/app/activity-replay-state.mjs +4 -3
  34. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  35. package/server/public/app/agent-sessions-settings.mjs +1 -1
  36. package/server/public/app/api-client.js +52 -0
  37. package/server/public/app/bootstrap.js +153 -27
  38. package/server/public/app/claude-api-settings-ui.js +362 -0
  39. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  40. package/server/public/app/claude-billing-toggle.mjs +112 -0
  41. package/server/public/app/context-tier-options.mjs +6 -2
  42. package/server/public/app/context-usage-view.mjs +1 -1
  43. package/server/public/app/conversation-provider-indicator.mjs +11 -0
  44. package/server/public/app/conversation-view.js +29 -3
  45. package/server/public/app/inbox-view.mjs +1 -1
  46. package/server/public/app/journal-view.js +127 -10
  47. package/server/public/app/model-selector-options.mjs +17 -1
  48. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  49. package/server/public/app/provider-readiness.mjs +5 -2
  50. package/server/public/app/relay-error-ctas.mjs +50 -0
  51. package/server/public/app/relay-schedules-ui.js +1 -1
  52. package/server/public/app/settings-modal.js +11 -0
  53. package/server/public/app/settings-tabs.js +1 -1
  54. package/server/public/app/socket-handlers.js +18 -1
  55. package/server/public/app/tool-activity-emoji.mjs +4 -3
  56. package/server/public/app/usage-limit-ui.js +136 -5
  57. package/server/public/index.html +189 -3
  58. package/server/relay-heap-guard.mjs +338 -0
  59. package/server/relay-self-restart.mjs +33 -7
  60. package/server/relay-tools.md +6 -6
  61. package/server/repositories/conversation-group-repository.mjs +1 -1
  62. package/server/repositories/message-repository.mjs +7 -0
  63. package/server/repositories/session-repository.mjs +8 -1
  64. package/server/routes/ask-user-routes.mjs +1 -1
  65. package/server/routes/conversation-group-routes.mjs +1 -1
  66. package/server/routes/messages-routes.mjs +678 -31
  67. package/server/routes/relay-ask-routes.mjs +3 -3
  68. package/server/routes/relay-event-routes.mjs +2 -2
  69. package/server/routes/relay-schedule-routes.mjs +1 -1
  70. package/server/routes/remote-relay-routes.mjs +3 -3
  71. package/server/routes/sessions-routes.mjs +365 -53
  72. package/server/server-runtime.mjs +373 -26
  73. package/server/services/about-service.mjs +1 -0
  74. package/server/services/background-work-view.mjs +2 -1
  75. package/server/services/claude-api-settings-service.mjs +575 -0
  76. package/server/services/claude-api-usage-service.mjs +361 -0
  77. package/server/services/claude-billing-swap-service.mjs +274 -0
  78. package/server/services/claude-billing-switch-service.mjs +343 -0
  79. package/server/services/plan-usage-claude-api.mjs +445 -0
  80. package/server/services/plan-usage-service.mjs +5 -0
  81. package/server/services/provider-model-selection.mjs +3 -2
  82. package/server/services/provider-reasoning-effort.mjs +9 -0
  83. package/server/services/relay-ask-service.mjs +1 -1
  84. package/server/services/relay-event-service.mjs +3 -2
  85. package/server/services/relay-schedule-service.mjs +1 -1
  86. package/server/services/remote-relay-caller-context.mjs +7 -0
  87. package/server/services/remote-relay-dispatcher.mjs +111 -17
  88. package/server/services/remote-relay-registry-service.mjs +1 -1
  89. package/server/services/session-worker-launch-service.mjs +61 -2
  90. package/server/services/session-worker-registry-service.mjs +28 -1
  91. package/server/services/session-worker-websocket-service.mjs +17 -0
  92. package/server/services/usage-limit-pause-service.mjs +55 -4
  93. package/server/services/worker-secret-redaction.mjs +2 -0
  94. package/shared/anthropic-api.mjs +202 -0
  95. package/shared/provider-routing.mjs +5 -3
  96. package/shared/relay-asks.mjs +11 -5
  97. package/shared/relay-events.mjs +2 -2
  98. package/shared/relay-schedules.mjs +2 -2
  99. package/shared/remote-relay-contract.mjs +54 -12
  100. package/shared/remote-relay-tool-core.mjs +23 -6
  101. package/shared/steer-settle-failure.mjs +1 -0
  102. package/shared/worker-runtime/api-client.mjs +2 -2
  103. package/shared/worker-runtime/heartbeat.mjs +5 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
@@ -36,7 +36,7 @@ import { createMessageRepository } from './repositories/message-repository.mjs';
36
36
  import { createQuestionRepository } from './repositories/question-repository.mjs';
37
37
  import { createImageConversationRepository } from './repositories/image-conversation-repository.mjs';
38
38
  import { registerSessionsRoutes } from './routes/sessions-routes.mjs';
39
- import { buildDequeuedRelayMessage, dequeuePendingMessageForWorkerLoop, registerMessagesRoutes } from './routes/messages-routes.mjs';
39
+ import { buildDequeuedRelayMessage, claudeApiRemedyText, dequeuePendingMessageForWorkerLoop, registerMessagesRoutes } from './routes/messages-routes.mjs';
40
40
  import { registerAskUserRoutes } from './routes/ask-user-routes.mjs';
41
41
  import { registerRelayBoardRoutes } from './routes/relay-board-routes.mjs';
42
42
  import { registerCacheRoutes } from './routes/cache-routes.mjs';
@@ -70,6 +70,11 @@ import { createWorkerPostRedaction } from './services/worker-secret-redaction.mj
70
70
  import { createDeepSeekBalanceService } from './services/deepseek-balance-service.mjs';
71
71
  import { deepseekCardCurrency } from './services/plan-usage-deepseek.mjs';
72
72
  import { createDeepSeekUsageImportService } from './services/deepseek-usage-import.mjs';
73
+ import { createClaudeApiSettingsService } from './services/claude-api-settings-service.mjs';
74
+ import { createClaudeBillingSwitchService, normalizeClaudeBilling } from './services/claude-billing-switch-service.mjs';
75
+ import { createClaudeBillingSwapService } from './services/claude-billing-swap-service.mjs';
76
+ import { createClaudeApiUsageService } from './services/claude-api-usage-service.mjs';
77
+ import { createClaudeApiCapWatch, createClaudeApiPlanUsage, createClaudeApiStopNoteLedger } from './services/plan-usage-claude-api.mjs';
73
78
  import { DEEPSEEK_DEFAULT_AUTO_COMPACT_WINDOW, DEFAULT_DEEPSEEK_MODEL, deepseekModelLabel } from '../shared/deepseek-models.mjs';
74
79
  import { createClaudeCloudSessionService } from './services/claude-cloud-session-service.mjs';
75
80
  import { createClaudeCloudRepoService, listRecentCloudSourcesFromStatements } from './services/claude-cloud-repo-service.mjs';
@@ -136,16 +141,17 @@ import { createClaudeSessionRootResolver } from './services/claude-session-root-
136
141
  import { createCursorSessionRootResolver } from './services/cursor-session-root-service.mjs';
137
142
  import { createGitChangesService } from './services/git-changes-service.mjs';
138
143
  import { createRelaySingletonGuard } from './services/relay-singleton-guard.mjs';
144
+ import { startRelayMemoryLog } from './relay-heap-guard.mjs';
139
145
  import { adoptEarlierDatabase, lostPackageDatabaseWarning, packageDatabaseEvidence } from './services/earlier-database-service.mjs';
140
146
  import { ensureDataDirs, resolveDataDir, resolveUploadDir, resolveUploadReadDirs, uploadPathInDirs } from './services/data-dir-resolution.mjs';
141
147
  import { mergeStrayMigratedUploads } from './services/oar-state-migration-service.mjs';
142
148
  import { createRelayRestartOrchestrator } from './services/relay-restart-orchestrator-service.mjs';
143
149
  import { createRelayBridgeOwnerService } from './services/relay-bridge-owner-service.mjs';
144
150
  import { createRelayCliLauncherService } from './services/relay-cli-launcher-service.mjs';
145
- import { createSessionWorkerRegistry } from './services/session-worker-registry-service.mjs';
151
+ import { createSessionWorkerRegistry, normalizeWorkerKind } from './services/session-worker-registry-service.mjs';
146
152
  import { createSessionWorkerSupervisor } from './services/session-worker-supervisor-service.mjs';
147
153
  import { createSessionWorkerProcessInspector } from './services/session-worker-process-service.mjs';
148
- import { applyClaudeCloudProviderEnvironment, applyClaudeProviderEnvironment, applyCopilotSdkProviderEnvironment, applyCursorProviderEnvironment, applyDeepSeekProviderEnvironment, applyGrokProviderEnvironment, applyOpenAIProviderEnvironment, copilotSdkEngineUnavailableReason, killTmuxSession, launchSessionCli } from './services/session-worker-launch-service.mjs';
154
+ import { applyClaudeApiProviderEnvironment, applyClaudeCloudProviderEnvironment, applyClaudeProviderEnvironment, applyCopilotSdkProviderEnvironment, applyCursorProviderEnvironment, applyDeepSeekProviderEnvironment, applyGrokProviderEnvironment, applyOpenAIProviderEnvironment, copilotSdkEngineUnavailableReason, killTmuxSession, launchSessionCli } from './services/session-worker-launch-service.mjs';
149
155
  import { createSshTunnelManager } from './services/ssh-tunnel-manager-service.mjs';
150
156
  import { createCloudflaredTunnelManager } from './services/cloudflared-tunnel-service.mjs';
151
157
  import {
@@ -1943,6 +1949,9 @@ const runtimeTimers = {
1943
1949
  pendingWorkerPrime: null,
1944
1950
  sharedViewerPrune: null,
1945
1951
  restoredTaskSettle: null,
1952
+ claudeApiCapClock: null,
1953
+ memoryLog: null,
1954
+ memoryWatch: null,
1946
1955
  };
1947
1956
 
1948
1957
  function normalizeRemotePath(value) {
@@ -2839,7 +2848,8 @@ const claudeCloudRepoService = createClaudeCloudRepoService({
2839
2848
  });
2840
2849
  // What a session worker posts loses every key a provider saved in Settings
2841
2850
  // (worker-secret-redaction.mjs). Built here, at the start, so it reads the
2842
- // saved keys now, and the DeepSeek save below notes each key it writes: one
2851
+ // saved keys now, and the DeepSeek and Claude API saves below note each key
2852
+ // they write: one
2843
2853
  // changed before any worker has posted is still redacted.
2844
2854
  const workerPostRedaction = createWorkerPostRedaction({ readSetting: readAppSettingValue });
2845
2855
  // DeepSeek: one API key per relay, its models and its endpoint. The key stays
@@ -2856,6 +2866,23 @@ const deepseekSettingsService = createDeepSeekSettingsService({
2856
2866
  function getDeepSeekProviderSettings() {
2857
2867
  return deepseekSettingsService.getSettings();
2858
2868
  }
2869
+ // Claude API: Claude chats on a Console API key, one key per relay. The key
2870
+ // stays in the settings table; GET routes answer `configured`, never the key.
2871
+ // Before its own discovery the Claude provider's models stand in.
2872
+ const claudeApiSettingsService = createClaudeApiSettingsService({
2873
+ readSetting: readAppSettingValue,
2874
+ writeSetting: (key, value) => {
2875
+ stmts.upsertAppSetting.run(key, String(value), new Date().toISOString());
2876
+ workerPostRedaction.noteSavedSetting(key, value);
2877
+ },
2878
+ deleteSetting: (key) => stmts.deleteAppSetting.run(key),
2879
+ listProviderCandidates: () => stmts.listRuntimeSessionProviderCandidates?.all?.() || [],
2880
+ countLiveKeyWorkers: () => (sessionWorkerRegistry?.listWorkers?.() || []).filter((worker) => worker?.kind === 'claude-api').length,
2881
+ getClaudeProviderSettings: () => getClaudeProviderSettings(),
2882
+ });
2883
+ function getClaudeApiProviderSettings() {
2884
+ return claudeApiSettingsService.getSettings();
2885
+ }
2859
2886
  // The DeepSeek balance for Check Usage: read with the saved key only while
2860
2887
  // DeepSeek is on, kept for a minute.
2861
2888
  const deepseekBalanceService = createDeepSeekBalanceService({ getSettings: getDeepSeekProviderSettings });
@@ -3117,7 +3144,7 @@ function buildSessionWorkerLaunchEnvForSession(targetSessionId) {
3117
3144
  const providerType = String(runtimeSession?.provider_type || 'github').trim().toLowerCase();
3118
3145
  // Clear every provider's variables first (the appliers' kind-guarded deletes
3119
3146
  // keep the chained clears order-independent), then apply only the bound one.
3120
- const cleared = applyDeepSeekProviderEnvironment(applyClaudeCloudProviderEnvironment(applyCopilotSdkProviderEnvironment(applyGrokProviderEnvironment(applyCursorProviderEnvironment(applyClaudeProviderEnvironment(applyOpenAIProviderEnvironment(sessionWorkerLaunchEnv)))))));
3147
+ const cleared = applyClaudeApiProviderEnvironment(applyDeepSeekProviderEnvironment(applyClaudeCloudProviderEnvironment(applyCopilotSdkProviderEnvironment(applyGrokProviderEnvironment(applyCursorProviderEnvironment(applyClaudeProviderEnvironment(applyOpenAIProviderEnvironment(sessionWorkerLaunchEnv))))))));
3121
3148
  // A Claude Cloud conversation gets its own worker and nothing else: it must
3122
3149
  // never reach the Copilot CLI fall-through at the end of this function. The
3123
3150
  // environment names the worker and the model, no credential.
@@ -3160,6 +3187,19 @@ function buildSessionWorkerLaunchEnvForSession(targetSessionId) {
3160
3187
  configDir: DEEPSEEK_CLAUDE_CONFIG_DIR,
3161
3188
  });
3162
3189
  }
3190
+ // A Claude API conversation gets its own worker, never the Copilot
3191
+ // fall-through below nor a Claude one on the host's subscription login.
3192
+ // Without a key the applier throws, and the conversation gets the launch
3193
+ // note. No config dir of its own: the chat is a Claude chat in the host's
3194
+ // Claude Code state, billed to the key.
3195
+ if (providerType === 'claude-api') {
3196
+ const claudeApiSettings = getClaudeApiProviderSettings();
3197
+ return applyClaudeApiProviderEnvironment(cleared, {
3198
+ enabled: true,
3199
+ model: String(runtimeSession?.provider_model || runtimeSession?.model || claudeApiSettings.model).trim(),
3200
+ apiKey: claudeApiSettings.apiKey,
3201
+ });
3202
+ }
3163
3203
  if (providerType === 'grok') {
3164
3204
  const grokSettings = getGrokProviderSettings();
3165
3205
  const grokModel = String(runtimeSession?.provider_model || runtimeSession?.model || grokSettings.model).trim();
@@ -3388,14 +3428,16 @@ async function stopSessionWorkerForProviderRebind(sdkSessionId) {
3388
3428
 
3389
3429
  async function reconcileUnstartedConversationProviders({ enabled, model, provider = 'openai' } = {}) {
3390
3430
  const normalizedProvider = String(provider || 'openai').trim().toLowerCase();
3391
- const managedProvider = ['claude', 'cursor', 'grok', 'deepseek'].includes(normalizedProvider) ? normalizedProvider : 'openai';
3392
- const managedDefaultModel = {
3393
- openai: DEFAULT_OPENAI_MODEL,
3394
- claude: DEFAULT_CLAUDE_MODEL,
3395
- cursor: DEFAULT_CURSOR_MODEL,
3396
- grok: DEFAULT_GROK_MODEL,
3397
- deepseek: DEFAULT_DEEPSEEK_MODEL,
3398
- }[managedProvider];
3431
+ const managedProvider = ['claude', 'cursor', 'grok', 'deepseek', 'claude-api'].includes(normalizedProvider) ? normalizedProvider : 'openai';
3432
+ const managedDefaultModel = managedProvider === 'claude-api'
3433
+ ? getClaudeApiProviderSettings().model
3434
+ : {
3435
+ openai: DEFAULT_OPENAI_MODEL,
3436
+ claude: DEFAULT_CLAUDE_MODEL,
3437
+ cursor: DEFAULT_CURSOR_MODEL,
3438
+ grok: DEFAULT_GROK_MODEL,
3439
+ deepseek: DEFAULT_DEEPSEEK_MODEL,
3440
+ }[managedProvider];
3399
3441
  const providerType = enabled === true ? managedProvider : 'github';
3400
3442
  const providerModel = enabled === true
3401
3443
  ? (String(model || '').trim() || managedDefaultModel)
@@ -3470,7 +3512,9 @@ async function reconcileUnstartedConversationProviders({ enabled, model, provide
3470
3512
  ? getGrokProviderSettings().enabled === true
3471
3513
  : (currentProvider === 'deepseek'
3472
3514
  ? getDeepSeekProviderSettings().configured === true
3473
- : (currentProvider !== 'openai' || getOpenAIProviderSettings().configured === true))));
3515
+ : (currentProvider === 'claude-api'
3516
+ ? getClaudeApiProviderSettings().enabled === true
3517
+ : (currentProvider !== 'openai' || getOpenAIProviderSettings().configured === true)))));
3474
3518
  if (providerWasUpdated && canRestorePreviousProvider) {
3475
3519
  stmts.updateRuntimeSessionProvider.run(
3476
3520
  currentProvider,
@@ -5205,7 +5249,18 @@ function createCompactedConversation(sourceConversationId) {
5205
5249
  // The new conversation stays in the old one's session group, and heads
5206
5250
  // it if the old one did; the archived source stays a member.
5207
5251
  conversationGroups.carryToCompacted(sourceConversationId, targetConversationId, now);
5208
- targetRuntimeSession = ensureRuntimeSessionBinding(targetConversationId, null, now);
5252
+ // A Claude chat keeps its billing (the subscription or the API key, a
5253
+ // pending swap's included): the new chat's first worker launches on it,
5254
+ // and it is never guessed again from a Claude model id. Other providers
5255
+ // bind as before.
5256
+ const sourceRuntime = stmts.getRuntimeSessionByConversation.get(sourceConversationId) || null;
5257
+ const sourceBilling = normalizeClaudeBilling(sourceRuntime?.provider_type);
5258
+ targetRuntimeSession = ensureRuntimeSessionBinding(targetConversationId, null, now, null, sourceBilling
5259
+ ? {
5260
+ providerType: sourceBilling,
5261
+ providerModel: String(sourceRuntime?.provider_model || sourceRuntime?.model || '').trim() || null,
5262
+ }
5263
+ : {});
5209
5264
  });
5210
5265
  tx();
5211
5266
  // Its schedules go on in the new conversation, both the ones that fire
@@ -5491,6 +5546,67 @@ const usageLimitPauseService = createUsageLimitPauseService({
5491
5546
  logger: console,
5492
5547
  });
5493
5548
 
5549
+ // What turns on the saved Claude API key cost, booked from both Claude
5550
+ // workers' reports (services/claude-api-usage-service.mjs); the Claude API
5551
+ // card and its cap read it.
5552
+ const claudeApiUsageService = createClaudeApiUsageService({ db, logger: console });
5553
+ // This month's spend on the key against the cap, and the Check Usage card
5554
+ // (services/plan-usage-claude-api.mjs), over the same local days.
5555
+ const claudeApiPlanUsage = createClaudeApiPlanUsage({
5556
+ usageService: claudeApiUsageService,
5557
+ getSettings: () => getClaudeApiProviderSettings(),
5558
+ });
5559
+
5560
+ // A Claude chat's billing: the subscription or the saved API key, switched
5561
+ // between turns (services/claude-billing-switch-service.mjs). A switch to the
5562
+ // key releases a turn held at the subscription's usage limit.
5563
+ const claudeBillingSwitchService = createClaudeBillingSwitchService({
5564
+ db,
5565
+ stmts,
5566
+ emit: (event, payload) => io.emit(event, payload),
5567
+ getClaudeProviderSettings: () => getClaudeProviderSettings(),
5568
+ getClaudeApiProviderSettings: () => getClaudeApiProviderSettings(),
5569
+ getClaudeApiCapState: () => getClaudeApiCapState(),
5570
+ releaseUsageLimitPause: (args) => usageLimitPauseService.releaseForBillingSwitch(args),
5571
+ // What the swap waits for: a turn, a question, a compaction, background tasks.
5572
+ getPending: (runtime) => claudeBillingSwapService.pendingFor(runtime?.sdk_session_id || runtime?.conversation_id),
5573
+ onQueueChanged: (reason) => {
5574
+ void primePendingSessionWorkers(reason);
5575
+ sessionWorkerWebSocketService?.emitQueueChanged?.(reason);
5576
+ },
5577
+ logger: console,
5578
+ });
5579
+
5580
+ // When the Claude API cap flips (a booked report, the cap setting, a new
5581
+ // month), every open Claude chat's billing state is announced again: the
5582
+ // composer's toggle and the usage-limit banner's switch button follow it.
5583
+ const listOpenClaudeChats = db.prepare(`
5584
+ SELECT rs.conversation_id AS id FROM runtime_sessions rs
5585
+ JOIN conversations c ON c.id = rs.conversation_id
5586
+ WHERE rs.provider_type IN ('claude', 'claude-api') AND COALESCE(c.archived, 0) = 0
5587
+ AND COALESCE(c.status, '') != 'deleted'
5588
+ `);
5589
+ // The notes for self-started turns stopped on the key, once per chat per
5590
+ // episode: a cap that clears ends every chat's episode (the settings route
5591
+ // clears it on a save, a claimed turn on the key for its chat).
5592
+ const claudeApiStopLedger = createClaudeApiStopNoteLedger();
5593
+ const claudeApiCapWatch = createClaudeApiCapWatch({
5594
+ getCapState: () => getClaudeApiCapState(),
5595
+ onChange: ({ reached, reason }) => {
5596
+ if (!reached) claudeApiStopLedger.clearAll();
5597
+ const chats = listOpenClaudeChats.all();
5598
+ console.log(`[claude-api-cap] ${reached ? 'reached' : 'cleared'} (${reason}): announcing ${chats.length} Claude chat(s)`);
5599
+ for (const { id } of chats) {
5600
+ const billing = claudeBillingSwitchService.billingState(id);
5601
+ if (billing) io.emit('conversation_billing', { conversationId: id, billing });
5602
+ }
5603
+ },
5604
+ logger: console,
5605
+ });
5606
+ claudeApiCapWatch.check('boot');
5607
+ runtimeTimers.claudeApiCapClock = setInterval(() => claudeApiCapWatch.check('clock'), 5 * 60 * 1000);
5608
+ runtimeTimers.claudeApiCapClock.unref?.();
5609
+
5494
5610
  // Turns a restart cut off for good (their worker went with the relay, and a
5495
5611
  // second delivery would run the prompt twice): with the setting on, their
5496
5612
  // session gets one note to carry on. The rows processing right now are the
@@ -5544,7 +5660,7 @@ relayEventService = createRelayEventService({
5544
5660
  });
5545
5661
 
5546
5662
  // ─── Relay schedules ──────────────────────────────────────────────────────────
5547
- // Prompts agents scheduled with remote_relay's `schedule`: kept in the
5663
+ // Prompts agents scheduled with relay_tool's `schedule`: kept in the
5548
5664
  // database, their timers rebuilt at boot (services/relay-schedule-service.mjs).
5549
5665
  const relayScheduleService = createRelayScheduleService({
5550
5666
  db,
@@ -5562,7 +5678,7 @@ const relayScheduleService = createRelayScheduleService({
5562
5678
  });
5563
5679
 
5564
5680
  // ─── Questions that don't block ───────────────────────────────────────────────
5565
- // remote_relay's `ask`: a card in the session and the Inbox; the answer
5681
+ // relay_tool's `ask`: a card in the session and the Inbox; the answer
5566
5682
  // reaches the askers as a relay event (services/relay-ask-service.mjs).
5567
5683
  const relayAskService = createRelayAskService({
5568
5684
  db,
@@ -6309,7 +6425,7 @@ function markSharedViewerPresence({ conversationId, token, viewerId } = {}) {
6309
6425
  }
6310
6426
  return { ok: true, watcherCount, capped: false };
6311
6427
  }
6312
- async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'worker-ready' } = {}) {
6428
+ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'worker-ready', workerKind = null } = {}) {
6313
6429
  const requesterSessionId = String(sessionId || '').trim();
6314
6430
  if (!requesterSessionId) return { message: null, reason: 'missing-session-id' };
6315
6431
  touchCli();
@@ -6367,6 +6483,10 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6367
6483
 
6368
6484
  const counts = queueCounts();
6369
6485
  const existingWorker = sessionWorkerRegistry?.getWorker?.(requesterSessionId) || null;
6486
+ // The kind the socket's worker stated is registered with it, so what a
6487
+ // response ran on is known from the first turn on (not from its first
6488
+ // heartbeat).
6489
+ const statedKind = normalizeWorkerKind(workerKind);
6370
6490
  if (!existingWorker) {
6371
6491
  sessionWorkerRegistry?.upsertWorker?.({
6372
6492
  sdkSessionId: requesterSessionId,
@@ -6374,6 +6494,7 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6374
6494
  status: 'ready',
6375
6495
  pid: pid || null,
6376
6496
  queueDepth: Math.max(0, Number(counts.pendingCount || 0)),
6497
+ ...(statedKind ? { kind: statedKind } : {}),
6377
6498
  });
6378
6499
  } else if (pid && existingWorker.pid !== pid) {
6379
6500
  sessionWorkerRegistry?.upsertWorker?.({
@@ -6382,7 +6503,10 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6382
6503
  pid,
6383
6504
  status: existingWorker.status || 'ready',
6384
6505
  queueDepth: Math.max(0, Number(counts.pendingCount || 0)),
6506
+ kind: statedKind,
6385
6507
  });
6508
+ } else if (statedKind && existingWorker.kind !== statedKind) {
6509
+ sessionWorkerRegistry?.upsertWorker?.({ ...existingWorker, kind: statedKind });
6386
6510
  }
6387
6511
 
6388
6512
  const dequeueResult = await dequeuePendingMessageForWorkerLoop({
@@ -6396,6 +6520,11 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6396
6520
  sessionWorkerSupervisor,
6397
6521
  relayRestartOrchestrator,
6398
6522
  inFlightProcessingCount: Number(counts.processingCount || 0),
6523
+ // A Claude chat's row goes only to a worker of its billing: the socket's
6524
+ // own word, else what the worker last said in a heartbeat.
6525
+ workerKind: workerKind || sessionWorkerRegistry?.getWorker?.(requesterSessionId)?.kind || null,
6526
+ claudeApiClaimCheck,
6527
+ onBillingFence: handleClaudeBillingFence,
6399
6528
  });
6400
6529
  if (!dequeueResult?.message) {
6401
6530
  return {
@@ -6439,11 +6568,14 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6439
6568
  }
6440
6569
  console.log(`${runtimeLogPrefix()}WS DEQUEUE ${out.id.slice(0, 8)} session=${requesterSessionId.slice(0, 8)} reason=${String(reason || 'worker-ready')}`);
6441
6570
  io.emit('message_status', { messageId: out.id, conversationId: out.conversationId, status: 'processing' });
6571
+ // The first message after a billing swap tells the new worker so.
6572
+ const billingSwap = out?.ownerSessionId ? claudeBillingSwapService.takeSwapNote(out.ownerSessionId) : null;
6442
6573
  return {
6443
6574
  message: out,
6444
6575
  // Piggybacked settings so workers pick up slider changes on the next
6445
6576
  // delivery without a restart.
6446
6577
  settings: {
6578
+ ...(billingSwap ? { billingSwap } : {}),
6447
6579
  backgroundTaskTimeoutMs: backgroundTaskTimeoutMinutesToMs(getBackgroundTaskTimeoutMinutes()),
6448
6580
  // The user's max-turn-duration ceiling (0 = no limit): worker-local
6449
6581
  // watchdogs (Grok's prompt ceiling) must honor it rather than imposing
@@ -6479,15 +6611,18 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6479
6611
  claudeBackgroundCommandLimitMs: claudeBackgroundCommandLimitMinutesToMs(getClaudeBackgroundCommandLimitMinutes()),
6480
6612
  };
6481
6613
  }
6482
- if (deliveredProvider !== 'claude' && deliveredProvider !== 'claude-cloud') return {};
6614
+ // A Claude API session is a Claude session on another bill:
6615
+ // Claude's attribution and background command limit.
6616
+ const localClaude = deliveredProvider === 'claude' || deliveredProvider === 'claude-api';
6617
+ if (!localClaude && deliveredProvider !== 'claude-cloud') return {};
6483
6618
  return {
6484
6619
  attribution: claudeAttributionForConversation(conv, out.providerModel || out.model, {
6485
- relayFolderFallback: deliveredProvider === 'claude',
6620
+ relayFolderFallback: localClaude,
6486
6621
  }),
6487
6622
  // The Claude tab's background command limit; the worker
6488
6623
  // hands it to the CLI it spawns next. Claude Cloud runs no
6489
6624
  // local CLI, so it does not get one.
6490
- ...(deliveredProvider === 'claude'
6625
+ ...(localClaude
6491
6626
  ? { claudeBackgroundCommandLimitMs: claudeBackgroundCommandLimitMinutesToMs(getClaudeBackgroundCommandLimitMinutes()) }
6492
6627
  : {}),
6493
6628
  };
@@ -6699,6 +6834,177 @@ async function sweepDeadWorkerProcessingRowsOnce() {
6699
6834
  return recovered;
6700
6835
  }
6701
6836
 
6837
+ // The Claude billing switch's worker swap (services/
6838
+ // claude-billing-swap-service.mjs): a Claude chat's next row of the other
6839
+ // billing is refused to the worker it has, which is replaced, once idle, by
6840
+ // one of the row's kind. The old one is stopped like the kill route does, and
6841
+ // the new one launched only once every process of the session is gone.
6842
+ const claudeBillingSwapService = createClaudeBillingSwapService({
6843
+ getWorker: (sid) => sessionWorkerRegistry?.getWorker?.(sid) || null,
6844
+ // A row the session claimed and has not finished: never stopped under it.
6845
+ hasRunningTurn: (sid) => (stmts.listProcessingRowsForOwner?.all?.(sid) || []).length > 0,
6846
+ stopWorker: async (sid) => {
6847
+ const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
6848
+ // Nothing may start the session while it is being stopped: a launch then
6849
+ // would adopt the old process, kind and all.
6850
+ sessionWorkerSupervisor?.markKilled?.(sid);
6851
+ // A stop that fails backs off (the swap service retries it): the killed
6852
+ // marker goes with it, so the session's worker, alive or relaunched,
6853
+ // keeps asking for work meanwhile. The fence still keeps the other
6854
+ // billing's rows from it.
6855
+ const backOff = (error, remainingPids = []) => {
6856
+ sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
6857
+ console.warn(`${runtimeLogPrefix()}BILLING SWAP STOP FAILED session=${sid.slice(0, 8)} ${error}${remainingPids.length ? ` remaining=${remainingPids.join(',')}` : ''} — kill marker cleared, retrying with a backoff`);
6858
+ return { ok: false, error, remainingPids };
6859
+ };
6860
+ let stopped = null;
6861
+ try {
6862
+ await sessionWorkerSupervisor?.cancelPendingStart?.(sid, { wait: true });
6863
+ stopped = await stopSessionWorkerProcesses({
6864
+ sdkSessionId: sid,
6865
+ worker,
6866
+ processInspector: sessionWorkerProcessInspector,
6867
+ isPidAliveImpl: isProcessAlive,
6868
+ killTmuxSessionImpl: killTmuxSession,
6869
+ });
6870
+ } catch (error) {
6871
+ return backOff(String(error?.message || error || 'worker-stop-failed'));
6872
+ }
6873
+ if (!stopped.ok) return backOff(stopped.error || 'worker-survived-stop', stopped.remainingPids || []);
6874
+ sessionWorkerRegistry?.removeWorker?.(sid);
6875
+ sessionWorkerSupervisor?.resetHealth?.(sid, { clearFailureCount: false });
6876
+ return { ok: true };
6877
+ },
6878
+ launchWorker: async (sid) => {
6879
+ sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
6880
+ const launched = await sessionWorkerSupervisor?.ensureWorker?.(sid, { allowProcessReuse: false });
6881
+ return { ok: launched?.ok === true, error: launched?.error || null };
6882
+ },
6883
+ onStuck: ({ conversationId, rowBilling, attempts, reason }) => {
6884
+ if (!conversationId) return;
6885
+ const at = new Date().toISOString();
6886
+ const messageId = uuidv4();
6887
+ const cause = reason === 'probe'
6888
+ ? `the relay cannot tell whether the old Claude process still runs a turn (${attempts} tries)`
6889
+ : `the old Claude process did not stop (${attempts} tries)`;
6890
+ const text = `⚠️ Could not restart the session on the ${rowBilling === 'claude-api' ? 'Claude API key' : 'Claude subscription'}: `
6891
+ + `${cause}. The relay keeps trying; switching the billing back runs the queued messages on the old process.`;
6892
+ try {
6893
+ db.transaction(() => {
6894
+ stmts.insertMsg.run(messageId, conversationId, 'assistant', text, null, null, null, at, null, null, null);
6895
+ stmts.setMessageKind?.run?.('billing-switch-stuck', messageId);
6896
+ stmts.updateConvTime?.run?.(at, conversationId);
6897
+ })();
6898
+ io.emit('assistant_message', {
6899
+ conversationId,
6900
+ messageId,
6901
+ message: { id: messageId, role: 'assistant', text, kind: 'billing-switch-stuck', model: null, mode: null, attachments: [], timestamp: at },
6902
+ });
6903
+ } catch (error) {
6904
+ console.warn(`[claude-billing] stuck note failed: ${error?.message || error}`);
6905
+ }
6906
+ },
6907
+ onPendingChange: ({ conversationId }) => {
6908
+ if (!conversationId) return;
6909
+ io.emit('conversation_billing', { conversationId, billing: claudeBillingSwitchService.billingState(conversationId) });
6910
+ },
6911
+ logger: console,
6912
+ });
6913
+
6914
+ // The Claude API monthly cap (plan decision 12): `{ reached, error? }`. The
6915
+ // one seam the claim check below, the billing switch, an agent's send and a
6916
+ // new Claude API chat ask: this month's priced estimate in
6917
+ // claude_api_usage_totals (relay local days, claudeApiUsageService.localDay)
6918
+ // against claude_api_monthly_cap_usd (services/plan-usage-claude-api.mjs).
6919
+ // It throws when the totals cannot be read; every caller counts that as reached.
6920
+ function getClaudeApiCapState() {
6921
+ return claudeApiPlanUsage.getCapState();
6922
+ }
6923
+
6924
+ // A Claude API row must not run while the provider is off, has no key, or
6925
+ // this month's cap is reached (getClaudeApiCapState): it is failed closed at
6926
+ // the claim, with the way out in its note.
6927
+ function claudeApiClaimCheck() {
6928
+ const settings = getClaudeApiProviderSettings();
6929
+ if (settings?.configured !== true) {
6930
+ return { code: 'CLAUDE_API_NOT_CONFIGURED', error: 'Claude API has no key, so this turn did not run.' };
6931
+ }
6932
+ if (settings?.enabled !== true) {
6933
+ return { code: 'CLAUDE_API_NOT_CONFIGURED', error: 'Claude API is switched off, so this turn did not run.', switchedOff: true };
6934
+ }
6935
+ let cap = null;
6936
+ try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
6937
+ if (cap?.reached === true) {
6938
+ return {
6939
+ code: 'CLAUDE_API_CAP_REACHED',
6940
+ error: `${String(cap.error || '').trim() || 'This month\'s Claude API spend has reached its cap.'} This turn did not run.`,
6941
+ };
6942
+ }
6943
+ return null;
6944
+ }
6945
+
6946
+ // A Claude API chat's worker is not launched while Claude API cannot run a
6947
+ // turn (no key, switched off, the cap): its waiting rows on the key are
6948
+ // failed closed here, as the claim would, instead of a spawn that could only
6949
+ // refuse them. True when it was so.
6950
+ const listPendingClaudeApiRowsForOwner = db.prepare(`
6951
+ SELECT id FROM queue
6952
+ WHERE status = 'pending' AND NULLIF(owner_sdk_session_id, '') = ?
6953
+ AND COALESCE(kind, '') != 'continuation'
6954
+ AND LOWER(COALESCE(${stmts.setQueueProvider ? 'provider' : 'NULL'}, ?)) = 'claude-api'
6955
+ `);
6956
+ function failClaudeApiRowsWithoutLaunch(sdkSessionId) {
6957
+ const runtime = stmts.getRuntimeSessionBySdkSessionId.get(sdkSessionId) || null;
6958
+ if (normalizeClaudeBilling(runtime?.provider_type) !== 'claude-api') return false;
6959
+ // A check that cannot answer fails this session's rows on the key closed,
6960
+ // as the claim does (messages-routes.mjs, claudeBillingFence).
6961
+ let refusal = null;
6962
+ try {
6963
+ refusal = claudeApiClaimCheck();
6964
+ } catch (error) {
6965
+ console.warn(`${runtimeLogPrefix()}Claude API claim check failed for session=${sdkSessionId.slice(0, 8)}: ${error?.message || error} — its rows on the key fail closed`);
6966
+ refusal = {
6967
+ code: 'CLAUDE_API_NOT_CONFIGURED',
6968
+ error: 'The relay could not check Claude API (key, switch, cap), so this turn did not run.',
6969
+ };
6970
+ }
6971
+ if (!refusal) return false;
6972
+ for (const row of listPendingClaudeApiRowsForOwner.all(sdkSessionId, 'claude-api')) {
6973
+ handleClaudeBillingFence({ reason: 'claude-api-unavailable', messageId: row.id, refusal });
6974
+ }
6975
+ console.warn(`${runtimeLogPrefix()}WORKER PRIME skipped session=${sdkSessionId.slice(0, 8)} reason=${refusal.code} — no Claude API worker while Claude API cannot run a turn`);
6976
+ return true;
6977
+ }
6978
+
6979
+ // The delivery refused a Claude chat's row to the worker that asked
6980
+ // (messages-routes.mjs, claudeBillingFence).
6981
+ function handleClaudeBillingFence(fence) {
6982
+ if (fence?.reason === 'claude-api-unavailable') {
6983
+ const row = stmts.findQById.get(fence.messageId);
6984
+ if (!row || String(row.status || '') !== 'pending') return;
6985
+ const code = String(fence.refusal?.code || 'CLAUDE_API_NOT_CONFIGURED');
6986
+ failRecoveredRowTerminally(row, 'claude-api-unavailable', {
6987
+ failureRecord: {
6988
+ kind: 'claude-api-unavailable',
6989
+ error: 'claude-api-unavailable',
6990
+ code,
6991
+ stableCode: `relay.${code.toLowerCase().replace(/_/g, '-')}`,
6992
+ failedAt: new Date().toISOString(),
6993
+ },
6994
+ // The code ends the note's paragraph, where the transcript's buttons
6995
+ // look for it (relay-error-ctas.mjs).
6996
+ failureText: `${fence.refusal?.error || 'Claude API is unavailable, so this turn did not run.'} `
6997
+ + `${claudeApiRemedyText({ code, switchedOff: fence.refusal?.switchedOff === true })}, then send the message again. `
6998
+ + `Error code: relay.${code.toLowerCase().replace(/_/g, '-')}.`,
6999
+ });
7000
+ return;
7001
+ }
7002
+ const result = claudeBillingSwapService.request(fence);
7003
+ if (result.state === 'swapping') {
7004
+ console.log(`${runtimeLogPrefix()}BILLING SWAP session=${String(fence.requesterSessionId || '').slice(0, 8)} conv=${String(fence.conversationId || '').slice(0, 8)} ${fence.workerKind || 'unknown'} → ${fence.rowBilling}`);
7005
+ }
7006
+ }
7007
+
6702
7008
  // A worker alive without its socket while a message waits: replaced after a
6703
7009
  // grace time (see the service). Stops the way the kill route does, but only
6704
7010
  // names the registry's pid when the process list confirms it is the
@@ -6769,6 +7075,14 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
6769
7075
  onDeliverySendFailed: recoverUndeliveredSessionWorkerMessage,
6770
7076
  onWorkerSocketClosed: handleWorkerSocketClosed,
6771
7077
  requestWork: requestSessionWorkerSocketDelivery,
7078
+ // What a worker says it is ("claude", "claude-api", ...): the registry
7079
+ // keeps it for the billing fence and for what a response ran on.
7080
+ onWorkerKind: (sessionId, kind) => {
7081
+ const sid = String(sessionId || '').trim();
7082
+ if (!sid) return;
7083
+ const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
7084
+ if (worker && worker.kind !== kind) sessionWorkerRegistry?.upsertWorker?.({ ...worker, kind });
7085
+ },
6772
7086
  isWorkerProcessAlive: (pid) => isProcessAlive(Number(pid)),
6773
7087
  pathPrefix: remotePath,
6774
7088
  pollIntervalMs: 500,
@@ -6811,6 +7125,14 @@ async function primePendingSessionWorkers(reason = 'pending-worker-prime') {
6811
7125
  const sdkSessionId = String(row?.sdk_session_id || '').trim();
6812
7126
  if (!sdkSessionId) continue;
6813
7127
  if (sessionWorkerSupervisor?.isKillBlocked?.(sdkSessionId) === true) continue;
7128
+ try {
7129
+ // Per session: a throw here costs this session its launch, never the
7130
+ // rest of the pass.
7131
+ if (failClaudeApiRowsWithoutLaunch(sdkSessionId)) continue;
7132
+ } catch (error) {
7133
+ console.warn(`${runtimeLogPrefix()}WORKER PRIME skipped session=${sdkSessionId.slice(0, 8)} err=${error?.message || error}`);
7134
+ continue;
7135
+ }
6814
7136
  requested += 1;
6815
7137
  try {
6816
7138
  const result = await sessionWorkerSupervisor.ensureWorker(sdkSessionId);
@@ -7057,7 +7379,7 @@ const remoteRelayInbound = {
7057
7379
  },
7058
7380
  repository: remoteRelayRepository,
7059
7381
  };
7060
- // The remote_relay tool: who is calling (live turn, provider, model, mode,
7382
+ // The relay_tool: who is calling (live turn, provider, model, mode,
7061
7383
  // hops) and the Allow / Deny card write actions need in ask/plan mode.
7062
7384
  const remoteRelayCallerContext = createRemoteRelayCallerContext({
7063
7385
  db,
@@ -7175,6 +7497,8 @@ const sharedRouteDeps = {
7175
7497
  deepseekSettingsService,
7176
7498
  deepseekBalanceService,
7177
7499
  deepseekUsageImportService,
7500
+ getClaudeApiProviderSettings,
7501
+ claudeApiSettingsService,
7178
7502
  claudeCloudRepoService,
7179
7503
  // The login's state for the workers' nudge route (never the token).
7180
7504
  claudeCloudLoginStatus: { describe: () => claudeCloudCredentials.describe() },
@@ -7321,6 +7645,14 @@ const sharedRouteDeps = {
7321
7645
  hostSuspendService,
7322
7646
  collectHostActivity,
7323
7647
  usageLimitPauseService,
7648
+ claudeBillingSwitchService,
7649
+ claudeApiUsageService,
7650
+ claudeApiClaimCheck,
7651
+ onClaudeBillingFence: handleClaudeBillingFence,
7652
+ claudeApiPlanUsage,
7653
+ getClaudeApiCapState,
7654
+ checkClaudeApiCap: (reason) => claudeApiCapWatch.check(reason),
7655
+ claudeApiStopLedger,
7324
7656
  relayEventService,
7325
7657
  relayScheduleService,
7326
7658
  relayAskService,
@@ -7420,6 +7752,7 @@ registerAboutRoutes(app, {
7420
7752
  'claude-cloud': { enabled: getClaudeCloudProviderSettings()?.enabled === true },
7421
7753
  cursor: { enabled: getCursorProviderSettings()?.enabled === true },
7422
7754
  grok: { enabled: getGrokProviderSettings()?.enabled === true },
7755
+ 'claude-api': { enabled: getClaudeApiProviderSettings()?.enabled === true },
7423
7756
  }),
7424
7757
  getCachedCliProviders: () => cliInstallService.getCachedStatusSnapshot()?.providers || {},
7425
7758
  isRelayCliOnline: () => runtimeState.cliOnline === true,
@@ -7526,6 +7859,13 @@ runtimeTimers.questionExpiry = setInterval(expirePendingQuestions, 10_000);
7526
7859
  expirePendingQuestions();
7527
7860
  runtimeTimers.sharedViewerPrune = setInterval(pruneSharedViewerPresence, 5_000);
7528
7861
  if (typeof runtimeTimers.sharedViewerPrune.unref === 'function') runtimeTimers.sharedViewerPrune.unref();
7862
+ // Memory in the status log: now, every 10 minutes, and once per crossing of
7863
+ // 80 % of the heap limit (relay-heap-guard.mjs).
7864
+ {
7865
+ const memoryTimers = startRelayMemoryLog({ logger: console });
7866
+ runtimeTimers.memoryLog = memoryTimers.log;
7867
+ runtimeTimers.memoryWatch = memoryTimers.check;
7868
+ }
7529
7869
  const sweptUploads = sweepUnreferencedUploadBlobs();
7530
7870
  if (sweptUploads > 0) {
7531
7871
  console.log(`${runtimeLogPrefix()}Reclaimed ${sweptUploads} unreferenced upload(s)`);
@@ -7665,10 +8005,17 @@ function ensureRuntimeSessionBinding(
7665
8005
  ? 'claude-cloud'
7666
8006
  : (normalizedRequestedProviderType === 'deepseek'
7667
8007
  ? 'deepseek'
7668
- : (normalizedRequestedProviderType === 'github'
7669
- ? 'github'
7670
- : (openAISettings?.enabled ? 'openai' : 'github')))))));
7671
- const resolvedProviderModel = (resolvedProviderType === 'openai' || resolvedProviderType === 'claude' || resolvedProviderType === 'cursor' || resolvedProviderType === 'grok' || resolvedProviderType === 'claude-cloud' || resolvedProviderType === 'deepseek')
8008
+ // Never folded into `claude`: the two bill different accounts.
8009
+ : (normalizedRequestedProviderType === 'claude-api'
8010
+ ? 'claude-api'
8011
+ : (normalizedRequestedProviderType === 'github'
8012
+ ? 'github'
8013
+ : (openAISettings?.enabled ? 'openai' : 'github'))))))));
8014
+ const resolvedProviderModel = (
8015
+ resolvedProviderType === 'openai' || resolvedProviderType === 'claude' || resolvedProviderType === 'cursor'
8016
+ || resolvedProviderType === 'grok' || resolvedProviderType === 'claude-cloud' || resolvedProviderType === 'deepseek'
8017
+ || resolvedProviderType === 'claude-api'
8018
+ )
7672
8019
  ? (String(providerModel || normalizedModel || '').trim() || null)
7673
8020
  : null;
7674
8021
  try {
@@ -31,6 +31,7 @@ export const ABOUT_PROVIDER_LABELS = Object.freeze({
31
31
  cursor: 'Cursor',
32
32
  grok: 'Grok',
33
33
  deepseek: 'DeepSeek',
34
+ 'claude-api': 'Claude API',
34
35
  });
35
36
 
36
37
  // The CLI each provider runs, keyed as the CLI install service keys them.
@@ -25,7 +25,8 @@ import {
25
25
  // Providers whose worker lists every running background sub-agent in its
26
26
  // task set, by the run's id. Others may run background agents no set lists
27
27
  // (their run rows are their only trace), so a set says nothing about a row.
28
- const RUN_LISTING_PROVIDERS = new Set(['claude']);
28
+ // The Claude API worker is the Claude one on an API key.
29
+ const RUN_LISTING_PROVIDERS = new Set(['claude', 'claude-api']);
29
30
 
30
31
  function toText(value) {
31
32
  return String(value ?? '').trim();