@oar-sh/oar 0.9.17 → 0.9.18

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