@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
@@ -18,14 +18,14 @@ import {
18
18
  } from '../services/conversation-preferences-service.mjs';
19
19
  import { fallbackReasoningEffort, inheritedConversationModel } from '../services/conversation-model-fallback.mjs';
20
20
  import { killTmuxSession } from '../services/session-worker-launch-service.mjs';
21
- import { normalizeWorkerSteeringSnapshot } from '../services/session-worker-registry-service.mjs';
21
+ import { normalizeWorkerKind, normalizeWorkerSteeringSnapshot } from '../services/session-worker-registry-service.mjs';
22
22
  import {
23
23
  buildSteerSettleFailure,
24
24
  steerAgentLabelForProvider,
25
25
  STEER_SETTLE_FAILED_CODE,
26
26
  STEER_SETTLE_FAILED_STABLE_CODE,
27
27
  } from '../../shared/steer-settle-failure.mjs';
28
- import { matchFailureNote } from '../../shared/failure-note-text.mjs';
28
+ import { FAILURE_NOTE_LEAD_MAX, matchFailureNote } from '../../shared/failure-note-text.mjs';
29
29
  import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
30
30
  import { stopSessionWorkerProcesses } from '../services/session-worker-stop-service.mjs';
31
31
  import {
@@ -81,6 +81,9 @@ import {
81
81
  } from '../../shared/deepseek-models.mjs';
82
82
  import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
83
83
  import { recordClaudeCliStart } from '../services/claude-session-leftovers.mjs';
84
+ import { normalizeClaudeBilling } from '../services/claude-billing-switch-service.mjs';
85
+ import { createClaudeApiStopNoteLedger } from '../services/plan-usage-claude-api.mjs';
86
+ import { normalizeClaudeApiUsageReport } from '../services/claude-api-usage-service.mjs';
84
87
  import {
85
88
  admitRemoteRelayRequest,
86
89
  readRemoteRelayRequest,
@@ -161,6 +164,8 @@ function normalizeRequestedProviderType(value = '') {
161
164
  // Its own provider, never read as `claude`.
162
165
  if (normalized === 'claude-cloud') return 'claude-cloud';
163
166
  if (normalized === 'deepseek') return 'deepseek';
167
+ // Its own provider too (Claude on an API key), never read as `claude`.
168
+ if (normalized === 'claude-api') return 'claude-api';
164
169
  return '';
165
170
  }
166
171
 
@@ -170,6 +175,7 @@ const BOOTSTRAP_ONLY_PROVIDER_LABELS = Object.freeze({
170
175
  claude: 'Claude',
171
176
  'claude-cloud': 'Claude Cloud',
172
177
  deepseek: 'DeepSeek',
178
+ 'claude-api': 'Claude API',
173
179
  });
174
180
 
175
181
  function deriveModelOrigin(model) {
@@ -867,9 +873,16 @@ export function resolveExecutedProviderForResponse({
867
873
  responseBridgeIdentity,
868
874
  conversationProvider = null,
869
875
  serverExecutedOperation = false,
876
+ // The kind the responding worker said it is. A Claude chat moves between
877
+ // the subscription and the API key, and its runtime row names the billing
878
+ // of now, not the one a turn that ends after a switch ran on: the worker's
879
+ // own kind is what ran.
880
+ responderKind = null,
870
881
  } = {}) {
871
882
  const boundProvider = String(conversationProvider || '').trim().toLowerCase() || 'github';
872
883
  const responderSessionId = normalizeSessionWorkerId(responseBridgeIdentity?.sessionId);
884
+ const responderBilling = normalizeClaudeBilling(responderKind);
885
+ if (responderSessionId && responderBilling) return responderBilling;
873
886
  if (responderSessionId) {
874
887
  const runtimeSession = stmts?.getRuntimeSessionBySdkSessionId?.get?.(responderSessionId) || null;
875
888
  const providerType = String(runtimeSession?.provider_type || '').trim().toLowerCase();
@@ -912,10 +925,21 @@ export function dequeuePendingMessage({
912
925
  requesterSessionId = null,
913
926
  ownerLeaseMs = SESSION_WORKER_OWNER_LEASE_MS,
914
927
  affinityOnly = false,
928
+ // The kind the requesting worker says it is ("claude", "claude-api", ...),
929
+ // null when unknown. A Claude chat's row is claimed only by a worker of
930
+ // the row's billing (claudeBillingFence); a refused claim calls
931
+ // `onBillingFence` and claims nothing.
932
+ workerKind = null,
933
+ onBillingFence = null,
934
+ // () → null, or `{ code, error }` when a Claude API row must not run now
935
+ // (the provider off, no key, the monthly cap): such a row is fenced too,
936
+ // whatever the worker, and the caller fails it closed.
937
+ claudeApiClaimCheck = null,
915
938
  } = {}) {
916
939
  const currentIso = String(nowIso || '').trim() || new Date().toISOString();
917
940
  const requesterSid = normalizeSessionWorkerId(requesterSessionId);
918
941
  const leaseExpiresAt = requesterSid ? addMsToIso(currentIso, ownerLeaseMs) : null;
942
+ let fenced = null;
919
943
  const dequeue = db.transaction(() => {
920
944
  let next = null;
921
945
  if (routingEnabled && requesterSid && stmts.findPendingForWorker) {
@@ -927,6 +951,10 @@ export function dequeuePendingMessage({
927
951
  } else if (!routingEnabled && requesterSid && stmts.findPendingForSessionAffinity) {
928
952
  next = stmts.findPendingForSessionAffinity.get(currentIso, requesterSid);
929
953
  }
954
+ if (next && requesterSid) {
955
+ fenced = claudeBillingFence({ stmts, row: next, workerKind, claudeApiClaimCheck });
956
+ if (fenced) return null;
957
+ }
930
958
  if (!next && affinityOnly) return null;
931
959
  if (!next) {
932
960
  // Requesters that reach the global fallback carry no worker identity
@@ -963,7 +991,119 @@ export function dequeuePendingMessage({
963
991
  attempt_id: attemptId,
964
992
  };
965
993
  });
966
- return dequeue();
994
+ const claimed = dequeue();
995
+ if (fenced && typeof onBillingFence === 'function') {
996
+ try { onBillingFence({ ...fenced, requesterSessionId: requesterSid }); } catch {}
997
+ }
998
+ return claimed;
999
+ }
1000
+
1001
+ // The `source` of an abort_turn control queued for a continuation on the
1002
+ // key that the Claude API claim check refuses (POST /api/continuation-turn):
1003
+ // the cap, no key, or the provider switched off.
1004
+ const CLAUDE_API_CAP_STOP_SOURCE = 'claude-api-cap';
1005
+
1006
+ /**
1007
+ * What a note on a Claude API turn that did not run tells the user to do,
1008
+ * without the full stop: the cap is raised, a switched-off Claude API is
1009
+ * switched on, a missing key is set up; or the chat moves to the
1010
+ * subscription.
1011
+ */
1012
+ export function claudeApiRemedyText({ code = '', switchedOff = false } = {}) {
1013
+ const place = 'Settings → Providers → Claude API';
1014
+ if (String(code) === 'CLAUDE_API_CAP_REACHED') return `Switch this chat to the subscription, or raise the monthly cap in ${place}`;
1015
+ if (switchedOff) return `Switch this chat to the subscription, or switch Claude API on in ${place}`;
1016
+ return `Switch this chat to the subscription, or set Claude API up in ${place}`;
1017
+ }
1018
+
1019
+ const CLAUDE_API_STOP_NOTE_LABELS = 2;
1020
+ const CLAUDE_API_STOP_NOTE_LABEL_CHARS = 40;
1021
+
1022
+ /**
1023
+ * The note for a self-started turn on the key the Claude API claim check
1024
+ * refused (`refusal`: `{ code, error, switchedOff? }`), with the background tasks the
1025
+ * relay stopped and those no stop reaches (`tasks`: `{ stopped, unreached }`
1026
+ * of `{ label }`). One paragraph whose lead stays within
1027
+ * FAILURE_NOTE_LEAD_MAX, so it still reads as a failure note and the
1028
+ * transcript's buttons find its code: at most two labels per list, cut to
1029
+ * 40 characters, then only the counts if the lead is still too long. The
1030
+ * fixed sentences and the code are never cut.
1031
+ */
1032
+ export function buildClaudeApiStopNoteText({ refusal = {}, tasks = {} } = {}) {
1033
+ const code = String(refusal.code || 'CLAUDE_API_NOT_CONFIGURED');
1034
+ const stableCode = `relay.${code.toLowerCase().replace(/_/g, '-')}`;
1035
+ const reason = String(refusal.error || '').trim()
1036
+ .replace(/,? so this turn did not run\.$/, '.')
1037
+ .replace(/\s*This turn did not run\.$/, '');
1038
+ const stopped = Array.isArray(tasks.stopped) ? tasks.stopped : [];
1039
+ const unreached = Array.isArray(tasks.unreached) ? tasks.unreached : [];
1040
+ const cut = (label) => {
1041
+ const text = String(label || '').trim().replace(/\s+/g, ' ') || 'a task';
1042
+ return text.length > CLAUDE_API_STOP_NOTE_LABEL_CHARS ? `${text.slice(0, CLAUDE_API_STOP_NOTE_LABEL_CHARS - 1).trimEnd()}…` : text;
1043
+ };
1044
+ const named = (list, shown) => {
1045
+ const labels = list.slice(0, shown).map((task) => cut(task.label));
1046
+ const more = list.length - labels.length;
1047
+ return `${labels.join(', ')}${more ? ` and ${more} more` : ''}`;
1048
+ };
1049
+ const tasksText = (shown) => [
1050
+ stopped.length
1051
+ ? (shown ? `Background tasks stopped: ${named(stopped, shown)}. ` : `${stopped.length} background task${stopped.length === 1 ? '' : 's'} stopped. `)
1052
+ : '',
1053
+ unreached.length
1054
+ ? (shown
1055
+ ? `Still running (stop them in the task panel): ${named(unreached, shown)}. `
1056
+ : `${unreached.length}${stopped.length ? ' more' : ` background task${unreached.length === 1 ? '' : 's'}`} still running (stop them in the task panel). `)
1057
+ : '',
1058
+ ].join('');
1059
+ const lead = (shown) => `${reason} Claude Code started this turn by itself when a background task reported back. `
1060
+ + 'It is being stopped, but its first request may already be billed, and a short turn can end first. '
1061
+ + tasksText(shown)
1062
+ + `${claudeApiRemedyText({ code, switchedOff: refusal.switchedOff === true })}. `;
1063
+ let shown = CLAUDE_API_STOP_NOTE_LABELS;
1064
+ while (shown > 0 && lead(shown).length > FAILURE_NOTE_LEAD_MAX) shown -= 1;
1065
+ return `${lead(shown)}Error code: ${stableCode}.`;
1066
+ }
1067
+
1068
+ /**
1069
+ * Whether a worker of `workerKind` must not claim `row`, a Claude chat's
1070
+ * turn: null when it may, else `{ conversationId, messageId, rowBilling,
1071
+ * workerKind, reason }`. The row's billing is its stamp (queue.provider),
1072
+ * else its chat's. A worker that states no kind is taken at its chat's
1073
+ * word, except on a chat that has switched since (a worker from before the
1074
+ * switch existed: it is replaced once, when idle).
1075
+ */
1076
+ export function claudeBillingFence({ stmts, row, workerKind = null, claudeApiClaimCheck = null } = {}) {
1077
+ if (!row) return null;
1078
+ const runtime = stmts?.getRuntimeSessionByConversation?.get?.(row.conversation_id) || null;
1079
+ const chatBilling = normalizeClaudeBilling(runtime?.provider_type);
1080
+ const rowBilling = normalizeClaudeBilling(row.provider) || chatBilling;
1081
+ if (!rowBilling) return null;
1082
+ const kind = normalizeWorkerKind(workerKind);
1083
+ const base = {
1084
+ conversationId: String(row.conversation_id || ''),
1085
+ messageId: String(row.id || ''),
1086
+ rowBilling,
1087
+ workerKind: kind,
1088
+ };
1089
+ if (rowBilling === 'claude-api' && typeof claudeApiClaimCheck === 'function') {
1090
+ let refusal = null;
1091
+ try {
1092
+ refusal = claudeApiClaimCheck() || null;
1093
+ } catch (error) {
1094
+ // A check that cannot answer does not let a turn run on the key: the
1095
+ // row fails closed as unconfigured. Subscription rows never get here.
1096
+ console.warn(`[claude-billing] Claude API claim check failed for ${String(row.id || '').slice(0, 8)}: ${error?.message || error} — the row fails closed`);
1097
+ refusal = {
1098
+ code: 'CLAUDE_API_NOT_CONFIGURED',
1099
+ error: 'The relay could not check Claude API (key, switch, cap), so this turn did not run.',
1100
+ };
1101
+ }
1102
+ if (refusal) return { ...base, reason: 'claude-api-unavailable', refusal };
1103
+ }
1104
+ if (kind) return kind === rowBilling ? null : { ...base, reason: 'kind-mismatch' };
1105
+ if (String(runtime?.billing_switched_at || '').trim()) return { ...base, reason: 'kind-unknown' };
1106
+ return null;
967
1107
  }
968
1108
 
969
1109
  // A requeue for a turn that produced nothing and was only briefly in flight is
@@ -1029,9 +1169,13 @@ export async function dequeuePendingMessageForWorkerLoop({
1029
1169
  relayRestartOrchestrator = null,
1030
1170
  inFlightProcessingCount = 0,
1031
1171
  telemetry = null,
1172
+ workerKind = null,
1173
+ onBillingFence = null,
1174
+ claudeApiClaimCheck = null,
1032
1175
  } = {}) {
1033
1176
  const requesterSid = normalizeSessionWorkerId(requesterSessionId);
1034
1177
  const routingWithWorker = routingEnabled && requesterSid;
1178
+ let billingFenced = null;
1035
1179
  const retryLimit = Math.max(0, Number(transientRetryLimit) || 0);
1036
1180
  const backoffBaseMs = Math.max(1, Number(transientRetryBackoffMs) || 1);
1037
1181
  const supervisor = sessionWorkerSupervisor || null;
@@ -1112,6 +1256,12 @@ export async function dequeuePendingMessageForWorkerLoop({
1112
1256
  requesterSessionId: requesterSid,
1113
1257
  ownerLeaseMs,
1114
1258
  affinityOnly,
1259
+ workerKind,
1260
+ claudeApiClaimCheck,
1261
+ onBillingFence: (fence) => {
1262
+ billingFenced = fence;
1263
+ if (typeof onBillingFence === 'function') onBillingFence(fence);
1264
+ },
1115
1265
  });
1116
1266
  emitWorkerLoopTelemetry(telemetry, {
1117
1267
  event: message ? 'queue.dequeue.success' : 'queue.dequeue.empty',
@@ -1134,7 +1284,9 @@ export async function dequeuePendingMessageForWorkerLoop({
1134
1284
  }
1135
1285
  return {
1136
1286
  message,
1137
- blockedReason: null,
1287
+ // The next row is of another billing than this worker: it waits for
1288
+ // the swap (claude-billing-swap-service.mjs).
1289
+ blockedReason: !message && billingFenced ? 'billing-swap' : null,
1138
1290
  worker: routingWithWorker ? (supervisor?.getWorkerState?.(requesterSid) || null) : null,
1139
1291
  lifecycle: routingWithWorker ? (supervisor?.getLifecycleState?.(requesterSid) || null) : null,
1140
1292
  attempts: attempt + 1,
@@ -1204,6 +1356,9 @@ export function buildDequeuedRelayMessage({
1204
1356
  }
1205
1357
  }
1206
1358
  const providerType = String(runtimeSession?.provider_type || '').trim().toLowerCase() || null;
1359
+ // A Claude chat's billing for this row: its stamp, else the chat's. The
1360
+ // worker runs it only when that is its own kind.
1361
+ const billing = normalizeClaudeBilling(msg.provider) || normalizeClaudeBilling(providerType) || null;
1207
1362
  // The Cursor worker declares one subagent per enabled model, because the
1208
1363
  // SDK's built-in subagent model menu is stuck at `inherit` +
1209
1364
  // `composer-2.5-fast` (see cursor-subagent-roster.mjs). Delivered per turn
@@ -1241,6 +1396,7 @@ export function buildDequeuedRelayMessage({
1241
1396
  model: String(msg.model || '').trim() || defaultModel,
1242
1397
  modelVariantId: String(msg.model_variant_id || '').trim() || String(msg.model || '').trim() || null,
1243
1398
  providerType,
1399
+ ...(billing ? { billing } : {}),
1244
1400
  providerModel: String(runtimeSession?.provider_model || '').trim() || null,
1245
1401
  claudeNativeSessionId: String(runtimeSession?.claude_native_session_id || '').trim() || null,
1246
1402
  cursorAgentId: String(runtimeSession?.cursor_agent_id || '').trim() || null,
@@ -1844,6 +2000,7 @@ export function registerMessagesRoutes(app, deps) {
1844
2000
  getGrokProviderSettings = () => ({ enabled: false, model: '', models: [] }),
1845
2001
  getClaudeCloudProviderSettings = () => ({ enabled: false, defaultModel: '', environmentId: '', models: [] }),
1846
2002
  getDeepSeekProviderSettings = () => ({ enabled: false, model: '', models: [] }),
2003
+ getClaudeApiProviderSettings = () => ({ enabled: false, model: '', models: [], availableModels: [], effortsByModel: {} }),
1847
2004
  rebindUnstartedOpenAIConversationModel = null,
1848
2005
  normalizeRelayMode,
1849
2006
  DEFAULT_RELAY_MODE,
@@ -1883,6 +2040,27 @@ export function registerMessagesRoutes(app, deps) {
1883
2040
  fetchUsageSummary = null,
1884
2041
  planUsageService = null,
1885
2042
  usageLimitPauseService = null,
2043
+ // The Claude billing switch (claude-billing-switch-service.mjs): the
2044
+ // billing route, and the send-path guards that refuse a switch by send.
2045
+ claudeBillingSwitchService = null,
2046
+ // The Claude API booking (claude-api-usage-service.mjs).
2047
+ claudeApiUsageService = null,
2048
+ // The delivery fence's runtime half for GET /api/pending, as on the
2049
+ // socket: the Claude API claim check (null: no refusal) and what a fenced
2050
+ // row does (the swap, or fail closed).
2051
+ claudeApiClaimCheck = null,
2052
+ onClaudeBillingFence = null,
2053
+ // The Claude API monthly cap's one seam (`{ reached, error? }`; a throw
2054
+ // counts as reached): an agent's send to a chat on the key is refused
2055
+ // while it is reached. Absent in older wiring and most tests: no cap.
2056
+ getClaudeApiCapState = () => ({ reached: false }),
2057
+ // Asks whether the cap flipped after a booked report (the runtime then
2058
+ // re-announces the Claude chats' billing states). A no-op without it.
2059
+ checkClaudeApiCap = () => {},
2060
+ // The notes for self-started turns stopped on the key, once per chat per
2061
+ // episode (plan-usage-claude-api.mjs); the runtime shares its own, which
2062
+ // the cap watch and the settings route clear.
2063
+ claudeApiStopLedger = createClaudeApiStopNoteLedger(),
1886
2064
  getCursorPlanAllowanceSettings = () => ({ cursorModelsUsd: null, otherModelsUsd: null, resetDay: 1 }),
1887
2065
  getGrokPlanAllowanceSettings = () => ({ monthlyUsd: null, resetDay: 1 }),
1888
2066
  opaqueResponseRecoveryWaitMs = OPAQUE_RESPONSE_RECOVERY_WAIT_MS,
@@ -2722,6 +2900,92 @@ export function registerMessagesRoutes(app, deps) {
2722
2900
  return buildRelayStopFailure();
2723
2901
  }
2724
2902
 
2903
+ // The key stop's request (`{ source, code, error, episode }`), or null for any other control.
2904
+ function claudeApiCapStopOf(control) {
2905
+ let request = null;
2906
+ try { request = JSON.parse(String(control?.request || 'null')); } catch { request = null; }
2907
+ return request?.source === CLAUDE_API_CAP_STOP_SOURCE ? request : null;
2908
+ }
2909
+
2910
+ // The Claude API claim check for a continuation on the key, failing closed
2911
+ // as the delivery fence does: `{ code, error }` or null. Without the
2912
+ // runtime's check (older wiring, tests) the cap seam alone is asked.
2913
+ function claudeApiContinuationRefusal() {
2914
+ try {
2915
+ if (typeof claudeApiClaimCheck === 'function') return claudeApiClaimCheck() || null;
2916
+ let cap = null;
2917
+ try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
2918
+ return cap?.reached === true
2919
+ ? { code: 'CLAUDE_API_CAP_REACHED', error: `${String(cap.error || '').trim() || 'This month\'s Claude API spend has reached its cap.'} This turn did not run.` }
2920
+ : null;
2921
+ } catch (error) {
2922
+ console.warn(`[claude-billing] Claude API claim check failed for a continuation: ${error?.message || error} — it is stopped`);
2923
+ return { code: 'CLAUDE_API_NOT_CONFIGURED', error: 'The relay could not check Claude API (key, switch, cap), so this turn did not run.' };
2924
+ }
2925
+ }
2926
+
2927
+ // The episode of a refusal, for its note (claudeApiStopLedger): the cap's
2928
+ // month and value, or the refusal's code.
2929
+ function claudeApiStopEpisode(code) {
2930
+ if (code !== 'CLAUDE_API_CAP_REACHED') return String(code || 'refused');
2931
+ try {
2932
+ return `cap:${String(getClaudeApiCapState()?.episode || '')}`;
2933
+ } catch {
2934
+ return 'cap:unreadable';
2935
+ }
2936
+ }
2937
+
2938
+ // Stops the live background tasks of a key worker whose self-started turn
2939
+ // is refused: each report would start another such turn, and a billing
2940
+ // switch waits for them. `{ stopped, unreached }`, each `[{ taskId,
2941
+ // label, reason? }]`: the stops sent, and the tasks no stop reaches (not
2942
+ // stoppable, or the worker has no socket to take it).
2943
+ function stopClaudeApiBackgroundTasks(conversationId, sessionId) {
2944
+ const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
2945
+ const workerSessionId = String(sessionId || conversation?.sdk_session_id || conversationId);
2946
+ const keys = [...new Set([conversationId, workerSessionId, String(conversation?.sdk_session_id || '')].filter(Boolean))];
2947
+ const stopped = [];
2948
+ const unreached = [];
2949
+ const seen = new Set();
2950
+ for (const task of keys.flatMap((key) => backgroundTaskStore?.get?.(key) || [])) {
2951
+ const taskId = String(task?.taskId || '').trim();
2952
+ if (!taskId || seen.has(taskId)) continue;
2953
+ seen.add(taskId);
2954
+ const label = String(task?.description || task?.command || task?.taskType || '').trim().replace(/\s+/g, ' ').slice(0, 80) || taskId;
2955
+ if (task?.stoppable === false) {
2956
+ unreached.push({ taskId, label, reason: 'no-stop' });
2957
+ } else if (sendWorkerControl?.(workerSessionId, { type: 'stop_background_task', taskId })) {
2958
+ stopped.push({ taskId, label });
2959
+ } else {
2960
+ unreached.push({ taskId, label, reason: 'worker-not-connected' });
2961
+ }
2962
+ }
2963
+ if (stopped.length || unreached.length) {
2964
+ console.log(`[${ts()}] CLAUDE API STOP conv=${conversationId.slice(0, 8)} background tasks stopped=${stopped.length} unreached=${unreached.length}`);
2965
+ }
2966
+ return { stopped, unreached };
2967
+ }
2968
+
2969
+ /**
2970
+ * The note for a self-started turn on the key the claim check refused,
2971
+ * written when the turn is registered (a short turn can end before the
2972
+ * worker's control poller takes the stop): the refusal, what the stop can
2973
+ * and can't do, the background tasks stopped and those no stop reached,
2974
+ * and the way out, its code last where the transcript's buttons look.
2975
+ */
2976
+ function writeClaudeApiStopNote({ conversationId, relayMode, refusal, tasks, now }) {
2977
+ const text = buildClaudeApiStopNoteText({ refusal, tasks });
2978
+ const noteId = uuidv4();
2979
+ stmts.insertMsg.run(noteId, conversationId, 'assistant', text, null, relayMode, null, now, null, null, null);
2980
+ stmts.updateConvTime?.run?.(now, conversationId);
2981
+ io.emit('assistant_message', {
2982
+ conversationId,
2983
+ messageId: noteId,
2984
+ message: { id: noteId, role: 'assistant', text, model: null, mode: relayMode, attachments: [], timestamp: now },
2985
+ });
2986
+ return noteId;
2987
+ }
2988
+
2725
2989
  function settleRelayAbortControlsForQueueMessage(queueMessageId, { ok = true, note = null, error = null } = {}) {
2726
2990
  const id = String(queueMessageId || '').trim();
2727
2991
  if (!id) return 0;
@@ -3509,6 +3773,17 @@ export function registerMessagesRoutes(app, deps) {
3509
3773
  return res.json({ ok: true, control: formatRelayControlResponse(control) });
3510
3774
  });
3511
3775
 
3776
+ /**
3777
+ * A continuation on the key, stopped because the Claude API claim check
3778
+ * refused it: its note was written, and its tasks stopped, when it was
3779
+ * registered, so the row fails quietly.
3780
+ */
3781
+ function settleClaudeApiContinuationStop({ queueRow, queueMessageId }) {
3782
+ if (Number(stmts.dropStaleContinuation?.run?.(queueMessageId)?.changes || 0) > 0) {
3783
+ io.emit('message_status', { messageId: queueMessageId, conversationId: queueRow.conversation_id, status: 'failed' });
3784
+ }
3785
+ }
3786
+
3512
3787
  app.post('/api/control/:controlId/result', auth, (req, res) => {
3513
3788
  const controlId = String(req.params.controlId || '').trim();
3514
3789
  if (!controlId) {
@@ -3571,7 +3846,10 @@ export function registerMessagesRoutes(app, deps) {
3571
3846
  if (String(control.type || '').trim() === 'abort_turn') {
3572
3847
  const queueMessageId = String(control.queue_message_id || '').trim();
3573
3848
  const queueRow = queueMessageId ? (stmts.findQById.get(queueMessageId) || null) : null;
3574
- if (queueRow) {
3849
+ const capStop = queueRow ? claudeApiCapStopOf(control) : null;
3850
+ if (capStop) {
3851
+ settleClaudeApiContinuationStop({ queueRow, queueMessageId });
3852
+ } else if (queueRow) {
3575
3853
  const failureRecord = buildRelayStopFailurePayload();
3576
3854
  failQueueMessage({
3577
3855
  queueRow,
@@ -4479,7 +4757,11 @@ export function registerMessagesRoutes(app, deps) {
4479
4757
  }
4480
4758
 
4481
4759
  // POST /api/message — browser sends a message
4482
- app.post('/api/message', auth, async (req, res) => {
4760
+ // The send route's handler. The billing route runs it too, for an agent's
4761
+ // send that switches the chat's billing with its message: that request
4762
+ // carries `oarBillingSwitch` (set by the route, never read from a body),
4763
+ // and the switch happens in the same transaction as the enqueue.
4764
+ const handleSendMessage = async (req, res) => {
4483
4765
  const {
4484
4766
  messageId: clientMessageId,
4485
4767
  clientId,
@@ -4566,7 +4848,90 @@ export function registerMessagesRoutes(app, deps) {
4566
4848
  : (stmts.getRuntimeSessionByConversation.get(conversationId) || null);
4567
4849
  const configuredOpenAI = getOpenAIProviderSettings();
4568
4850
  const requestedProviderType = normalizeRequestedProviderType(providerType || provider);
4569
- const runtimeProviderType = String(existingRuntimeSession?.provider_type || '').trim().toLowerCase();
4851
+ const boundProviderType = String(existingRuntimeSession?.provider_type || '').trim().toLowerCase();
4852
+ // A Claude chat runs on the subscription (`claude`) or the saved API key
4853
+ // (`claude-api`). A send never moves it: the billing route does
4854
+ // (claude-billing-switch-service.mjs). A send that names the other
4855
+ // billing is refused, and so is one from a page that still showed the
4856
+ // other one (`billing`), so nobody's turn runs on a bill they did not
4857
+ // see. A send without `billing` (an older page, an agent) is not checked.
4858
+ const boundClaudeBilling = shouldCreateConversation ? '' : normalizeClaudeBilling(boundProviderType);
4859
+ const billingSwitch = boundClaudeBilling && req.oarBillingSwitch
4860
+ ? { to: normalizeClaudeBilling(req.oarBillingSwitch.to), by: String(req.oarBillingSwitch.by || 'agent') }
4861
+ : null;
4862
+ if (boundClaudeBilling && !billingSwitch) {
4863
+ const namedBilling = normalizeClaudeBilling(requestedProviderType);
4864
+ if (namedBilling && namedBilling !== boundClaudeBilling) {
4865
+ return res.status(409).json({
4866
+ error: `This chat runs on the ${boundClaudeBilling === 'claude-api' ? 'Claude API key' : 'Claude subscription'}. Switch its billing first; a send does not.`,
4867
+ code: 'CLAUDE_BILLING_SWITCH_REQUIRED',
4868
+ conversationId,
4869
+ billing: boundClaudeBilling,
4870
+ });
4871
+ }
4872
+ const shownBilling = normalizeClaudeBilling(req.body?.billing);
4873
+ if (shownBilling && shownBilling !== boundClaudeBilling) {
4874
+ // The text is not lost: it goes into the chat's draft (after what is
4875
+ // there), where every page shows it, so a page's own retry and a
4876
+ // replay from the outbox both leave it for the user to send again.
4877
+ const keptText = String(trimmedText || '').trim();
4878
+ const conversationRow = keptText ? (stmts.getConvAnyStatus?.get?.(conversationId) || null) : null;
4879
+ if (conversationRow) {
4880
+ const draft = String(conversationRow.draft_text || '');
4881
+ if (!draft.includes(keptText)) {
4882
+ const keptDraft = draft.trim() ? `${draft.replace(/\s+$/, '')}\n\n${keptText}` : keptText;
4883
+ const keptAt = new Date().toISOString();
4884
+ db.prepare(`UPDATE conversations SET draft_text = ?, draft_updated_at = ?, draft_updated_by_client_id = NULL WHERE id = ?`)
4885
+ .run(keptDraft, keptAt, conversationId);
4886
+ // The draft's attachments stay what they are: the update must not
4887
+ // clear them on the other pages.
4888
+ let keptAttachments = [];
4889
+ try {
4890
+ const parsed = JSON.parse(String(conversationRow.draft_attachments || '[]'));
4891
+ keptAttachments = Array.isArray(parsed) ? parsed : [];
4892
+ } catch {
4893
+ keptAttachments = [];
4894
+ }
4895
+ io.emit('conversation_draft_updated', {
4896
+ conversationId,
4897
+ draftText: keptDraft,
4898
+ draftAttachments: keptAttachments,
4899
+ draftUpdatedAt: keptAt,
4900
+ draftUpdatedByClientId: null,
4901
+ senderClientId: null,
4902
+ });
4903
+ }
4904
+ }
4905
+ return res.status(409).json({
4906
+ error: `This chat now runs on the ${boundClaudeBilling === 'claude-api' ? 'Claude API key' : 'Claude subscription'}. Send again to confirm.`,
4907
+ code: 'CLAUDE_BILLING_CHANGED',
4908
+ conversationId,
4909
+ billing: boundClaudeBilling,
4910
+ keptInDraft: true,
4911
+ });
4912
+ }
4913
+ }
4914
+ // A switch with the message: the rest of the send resolves the model,
4915
+ // effort and limits against the billing it switches to.
4916
+ const runtimeProviderType = billingSwitch?.to || boundProviderType;
4917
+ // An agent's send to a chat on the key while this month's cap is reached
4918
+ // is refused before anything is stored, so the agent hears it at once (a
4919
+ // page's send is queued and refused at the claim, with its note there).
4920
+ // A send that switches to the key was refused by the switch's own check;
4921
+ // one that names the key for a chat already on it (a switch that changes
4922
+ // nothing) is refused here like any other.
4923
+ if (remoteRequest.remote && (!billingSwitch || billingSwitch.to === 'claude-api') && runtimeProviderType === 'claude-api') {
4924
+ let cap = null;
4925
+ try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
4926
+ if (cap?.reached === true) {
4927
+ return res.status(409).json({
4928
+ error: `${String(cap.error || '').trim() || 'This month\'s Claude API spend has reached its cap.'} Nothing was queued. Send with provider "claude" to move the session to the Claude subscription, or ask the user to raise the cap.`,
4929
+ code: 'CLAUDE_API_CAP_REACHED',
4930
+ conversationId,
4931
+ billing: 'claude-api',
4932
+ });
4933
+ }
4934
+ }
4570
4935
  const runtimeUsesOpenAI = runtimeProviderType === 'openai';
4571
4936
  // Providers that resell each other's model ids ("claude-opus-5" is served by
4572
4937
  // Claude, Copilot and Cursor alike) make a model id useless for deciding
@@ -4582,7 +4947,8 @@ export function registerMessagesRoutes(app, deps) {
4582
4947
  || runtimeProviderType === 'cursor'
4583
4948
  || runtimeProviderType === 'grok'
4584
4949
  || runtimeProviderType === 'claude-cloud'
4585
- || runtimeProviderType === 'deepseek';
4950
+ || runtimeProviderType === 'deepseek'
4951
+ || runtimeProviderType === 'claude-api';
4586
4952
  const configuredOpenAIModel = String(configuredOpenAI?.model || '').trim();
4587
4953
  const availableOpenAIModels = new Set(
4588
4954
  (Array.isArray(configuredOpenAI?.models) ? configuredOpenAI.models : [])
@@ -4743,6 +5109,16 @@ export function registerMessagesRoutes(app, deps) {
4743
5109
  code: 'CLAUDE_CLOUD_REQUIRES_NEW_CONVERSATION',
4744
5110
  });
4745
5111
  }
5112
+ // Claude API conversations are bootstrap-created only as well, and asked
5113
+ // for by name only: their model ids are the Claude provider's. Moving a
5114
+ // chat between the subscription and the key is not a send's to make here.
5115
+ const runtimeUsesClaudeApi = runtimeProviderType === 'claude-api';
5116
+ if (!shouldCreateConversation && !runtimeUsesClaudeApi && requestedProviderType === 'claude-api') {
5117
+ return res.status(409).json({
5118
+ error: 'Claude API requires creating a new Claude API conversation',
5119
+ code: 'CLAUDE_API_REQUIRES_NEW_CONVERSATION',
5120
+ });
5121
+ }
4746
5122
  if (
4747
5123
  useOpenAIProvider
4748
5124
  && requestedOpenAIModel
@@ -4790,8 +5166,12 @@ export function registerMessagesRoutes(app, deps) {
4790
5166
  // Claude conversations resolve models against the Claude provider
4791
5167
  // catalog (the Copilot catalog does not know these ids). Per-turn model
4792
5168
  // switching is allowed: each Claude turn is a fresh query() with resume.
4793
- const runtimeUsesClaude = runtimeProviderType === 'claude';
4794
- const configuredClaude = runtimeUsesClaude ? getClaudeProviderSettings() : null;
5169
+ // A Claude API conversation runs the same CLI on an API key: the same
5170
+ // rules, against its own model list and efforts.
5171
+ const runtimeUsesClaude = runtimeProviderType === 'claude' || runtimeUsesClaudeApi;
5172
+ const configuredClaude = runtimeUsesClaude
5173
+ ? (runtimeUsesClaudeApi ? getClaudeApiProviderSettings() : getClaudeProviderSettings())
5174
+ : null;
4795
5175
  const requestedClaudeModel = runtimeUsesClaude ? String(model || '').trim() : '';
4796
5176
  const availableClaudeModels = new Set([
4797
5177
  String(configuredClaude?.model || '').trim(),
@@ -5331,7 +5711,22 @@ export function registerMessagesRoutes(app, deps) {
5331
5711
  const requestedModelOrigin = deriveModelOrigin(requestedModelVariantId || requestedModel);
5332
5712
  let conversationPreferences;
5333
5713
  let imageOperation = null;
5714
+ // The switch's announcement, made once the send has committed.
5715
+ let announceBillingSwitch = null;
5334
5716
  const persistMessageAndQueue = db.transaction(() => {
5717
+ // First, so the row below is stamped with the new billing; a refused
5718
+ // switch undoes the whole send.
5719
+ if (billingSwitch) {
5720
+ const switched = claudeBillingSwitchService?.switchBilling?.({
5721
+ conversationId: convId,
5722
+ to: billingSwitch.to,
5723
+ by: billingSwitch.by,
5724
+ model: claudeModel,
5725
+ deferAnnounce: true,
5726
+ }) || { ok: false, status: 501, code: 'CLAUDE_BILLING_UNAVAILABLE', error: 'The billing switch is unavailable' };
5727
+ if (!switched.ok) throw Object.assign(new Error(switched.error), { billingRefusal: switched });
5728
+ announceBillingSwitch = switched.announce || null;
5729
+ }
5335
5730
  stmts.insertMsg.run(
5336
5731
  msgId,
5337
5732
  convId,
@@ -5394,7 +5789,7 @@ export function registerMessagesRoutes(app, deps) {
5394
5789
  && claudeModel !== String(existingRuntimeSession?.provider_model || '').trim()
5395
5790
  && typeof stmts.updateRuntimeSessionProvider?.run === 'function'
5396
5791
  ) {
5397
- stmts.updateRuntimeSessionProvider.run('claude', claudeModel, claudeModel, now, runtimeSession.id);
5792
+ stmts.updateRuntimeSessionProvider.run(runtimeUsesClaudeApi ? 'claude-api' : 'claude', claudeModel, claudeModel, now, runtimeSession.id);
5398
5793
  }
5399
5794
  // DeepSeek follows the composer the same way: a relaunched worker reads
5400
5795
  // provider_model, so it has to be the model last used.
@@ -5482,7 +5877,17 @@ export function registerMessagesRoutes(app, deps) {
5482
5877
  });
5483
5878
  try {
5484
5879
  persistMessageAndQueue();
5880
+ try { announceBillingSwitch?.(); } catch {}
5485
5881
  } catch (error) {
5882
+ if (error?.billingRefusal) {
5883
+ const refused = error.billingRefusal;
5884
+ return res.status(refused.status || 409).json({
5885
+ error: refused.error,
5886
+ code: refused.code,
5887
+ conversationId: convId,
5888
+ ...(refused.model ? { model: refused.model } : {}),
5889
+ });
5890
+ }
5486
5891
  // A replayed send (Background Sync outbox) with a caller-supplied id hits
5487
5892
  // the messages.id primary key. Surface it as a clean 409 so the replay
5488
5893
  // can treat "already accepted" as success instead of retrying forever.
@@ -5613,7 +6018,9 @@ export function registerMessagesRoutes(app, deps) {
5613
6018
  messageId: msgId,
5614
6019
  conversationId: convId,
5615
6020
  runtimeSessionId: runtimeSession?.id || null,
5616
- runtimeProviderType: String(runtimeSession?.provider_type || (useOpenAIProvider ? 'openai' : 'github')).trim().toLowerCase(),
6021
+ runtimeProviderType: billingSwitch?.to
6022
+ || String(runtimeSession?.provider_type || (useOpenAIProvider ? 'openai' : 'github')).trim().toLowerCase(),
6023
+ ...(billingSwitch ? { billingSwitched: true } : {}),
5617
6024
  runtimeProviderModel: runtimeSession?.provider_model || (useOpenAIProvider ? openAIModel : null),
5618
6025
  ownerSessionId: ownerSessionId || null,
5619
6026
  preferredRelayMode: conversationPreferences.preferredRelayMode,
@@ -5639,6 +6046,73 @@ export function registerMessagesRoutes(app, deps) {
5639
6046
  }
5640
6047
  : null,
5641
6048
  });
6049
+ };
6050
+ app.post('/api/message', auth, handleSendMessage);
6051
+
6052
+ // POST /api/conversation/:id/billing — move a Claude chat between the
6053
+ // subscription and the saved API key (`to`: "claude" | "claude-api"): the
6054
+ // composer's toggle, the usage-limit note's button (`by: "usage-limit"`)
6055
+ // and an agent's `send` with a provider (`by: "agent"`). With `message`
6056
+ // (the body /api/message takes), the switch and the enqueue are one
6057
+ // transaction: a send that is refused leaves the billing as it was, and a
6058
+ // refused switch sends nothing. Applies from the next turn; a running one
6059
+ // finishes on its billing.
6060
+ app.post('/api/conversation/:id/billing', auth, async (req, res) => {
6061
+ if (!claudeBillingSwitchService) {
6062
+ return res.status(501).json({ ok: false, error: 'The billing switch is unavailable', code: 'CLAUDE_BILLING_UNAVAILABLE' });
6063
+ }
6064
+ const conversationId = String(req.params?.id || '').trim();
6065
+ const to = normalizeClaudeBilling(req.body?.to ?? req.body?.provider);
6066
+ const byValue = String(req.body?.by || '').trim().toLowerCase();
6067
+ const by = ['user', 'agent', 'usage-limit'].includes(byValue) ? byValue : 'user';
6068
+ const message = req.body?.message && typeof req.body.message === 'object' && !Array.isArray(req.body.message)
6069
+ ? req.body.message
6070
+ : null;
6071
+ // Checked first, so a refusal names the switch's reason before the send's.
6072
+ const check = claudeBillingSwitchService.checkSwitch({ conversationId, to, model: message?.model || '' });
6073
+ if (!check.ok) {
6074
+ return res.status(check.status || 409).json({
6075
+ ok: false,
6076
+ error: check.error,
6077
+ code: check.code,
6078
+ conversationId,
6079
+ ...(check.model ? { model: check.model } : {}),
6080
+ });
6081
+ }
6082
+ if (message) {
6083
+ const sendRequest = Object.create(req);
6084
+ sendRequest.body = {
6085
+ ...message,
6086
+ conversationId,
6087
+ newConversation: false,
6088
+ provider: undefined,
6089
+ providerType: undefined,
6090
+ billing: undefined,
6091
+ };
6092
+ sendRequest.oarBillingSwitch = { to: check.to, by };
6093
+ return handleSendMessage(sendRequest, res);
6094
+ }
6095
+ if (by === 'usage-limit' && check.to !== 'claude-api') {
6096
+ return res.status(400).json({ ok: false, error: 'The usage-limit note switches to the API key only', code: 'CLAUDE_BILLING_INVALID' });
6097
+ }
6098
+ const result = claudeBillingSwitchService.switchBilling({ conversationId, to: check.to, by });
6099
+ if (!result?.ok) {
6100
+ return res.status(result?.status || 409).json({
6101
+ ok: false,
6102
+ error: result?.error || 'The billing switch failed',
6103
+ code: result?.code || null,
6104
+ conversationId,
6105
+ ...(result?.model ? { model: result.model } : {}),
6106
+ });
6107
+ }
6108
+ return res.json({
6109
+ ok: true,
6110
+ conversationId,
6111
+ changed: result.changed,
6112
+ provider: result.provider,
6113
+ released: !!result.released,
6114
+ billing: claudeBillingSwitchService.billingState(conversationId),
6115
+ });
5642
6116
  });
5643
6117
 
5644
6118
  app.post('/api/heartbeat', auth, (req, res) => {
@@ -5704,7 +6178,11 @@ export function registerMessagesRoutes(app, deps) {
5704
6178
  || (stored.supported === true) !== (steering.supported === true)
5705
6179
  || storedCancellableIds.join('\n') !== steering.cancellableIds.join('\n')
5706
6180
  // The running CLI's compaction window (context-usage modal).
5707
- || JSON.stringify(stored.autoCompactWindow ?? null) !== JSON.stringify(steering.autoCompactWindow ?? null);
6181
+ || JSON.stringify(stored.autoCompactWindow ?? null) !== JSON.stringify(steering.autoCompactWindow ?? null)
6182
+ // Whether a billing swap may replace the worker now, and why not.
6183
+ || (stored.releasable ?? null) !== (steering.releasable ?? null)
6184
+ || (stored.releaseHold || null) !== (steering.releaseHold || null)
6185
+ || (stored.backgroundTaskCount || 0) !== (steering.backgroundTaskCount || 0);
5708
6186
  if (workerForSteering && changed) {
5709
6187
  sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
5710
6188
  // Pushed rather than left for the clients' status poll: the
@@ -5713,6 +6191,15 @@ export function registerMessagesRoutes(app, deps) {
5713
6191
  io.emit('session_worker_steering', { sdkSessionId: requesterSessionId, steering });
5714
6192
  }
5715
6193
  }
6194
+ // The worker's kind (a Claude chat's billing runs only on its own kind):
6195
+ // recorded when it says one and the registry has another.
6196
+ const heartbeatKind = normalizeWorkerKind(req.body?.workerKind);
6197
+ if (heartbeatKind) {
6198
+ const workerForKind = sessionWorkerRegistry?.getWorker?.(requesterSessionId) || null;
6199
+ if (workerForKind && workerForKind.kind !== heartbeatKind) {
6200
+ sessionWorkerRegistry?.upsertWorker?.({ ...workerForKind, kind: heartbeatKind });
6201
+ }
6202
+ }
5716
6203
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
5717
6204
  // The worker's digest of its live background-task ids against the set
5718
6205
  // held for it (keyed as the worker posts, by its session id). They
@@ -5930,9 +6417,10 @@ export function registerMessagesRoutes(app, deps) {
5930
6417
  return res.status(404).json({ error: 'Runtime session not found for conversation' });
5931
6418
  }
5932
6419
  // The DeepSeek worker runs the same CLI (against DeepSeek) and resumes
5933
- // its sessions the same way.
6420
+ // its sessions the same way; the Claude API worker is the Claude one on
6421
+ // an API key.
5934
6422
  const nativeSessionProvider = String(runtimeSession.provider_type || 'github').trim().toLowerCase();
5935
- if (nativeSessionProvider !== 'claude' && nativeSessionProvider !== 'deepseek') {
6423
+ if (nativeSessionProvider !== 'claude' && nativeSessionProvider !== 'deepseek' && nativeSessionProvider !== 'claude-api') {
5936
6424
  return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
5937
6425
  }
5938
6426
  if (typeof stmts.updateRuntimeSessionClaudeNativeSessionId?.run !== 'function') {
@@ -5974,9 +6462,10 @@ export function registerMessagesRoutes(app, deps) {
5974
6462
  return res.status(404).json({ error: 'Runtime session not found for conversation' });
5975
6463
  }
5976
6464
  // The Claude Cloud worker reports the same shape (the cloud session's
5977
- // context and token usage), and so does the DeepSeek one (the same CLI).
6465
+ // context and token usage), and so do the DeepSeek and Claude API ones
6466
+ // (the same CLI).
5978
6467
  const contextUsageProvider = String(runtimeSession.provider_type || 'github').trim().toLowerCase();
5979
- if (contextUsageProvider !== 'claude' && contextUsageProvider !== 'claude-cloud' && contextUsageProvider !== 'deepseek') {
6468
+ if (!['claude', 'claude-cloud', 'deepseek', 'claude-api'].includes(contextUsageProvider)) {
5980
6469
  return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
5981
6470
  }
5982
6471
  if (typeof stmts.updateRuntimeSessionContextUsage?.run !== 'function') {
@@ -6057,6 +6546,42 @@ export function registerMessagesRoutes(app, deps) {
6057
6546
  res.json({ ok: true, at: booked.at, models: booked.models.map((entry) => entry.model) });
6058
6547
  });
6059
6548
 
6549
+ // POST /api/claude-api-usage — both Claude workers (the subscription's and
6550
+ // the Claude API one) report, after every turn, the CLI process's own
6551
+ // totals per model with Claude Code's cost estimate (`runCounts`) and the
6552
+ // CLI's session totals (`sessionTotals`). The relay books what turns on
6553
+ // the key cost (claude-api-usage-service.mjs); a subscription report only
6554
+ // keeps the session's mark, against which a resumed run's first result is
6555
+ // measured. What a report ran on is the posting worker's registered kind;
6556
+ // a body that says otherwise is logged as a fence failure.
6557
+ app.post('/api/claude-api-usage', auth, (req, res) => {
6558
+ touchCli();
6559
+ if (!claudeApiUsageService) return res.status(501).json({ error: 'Claude API usage storage is unavailable' });
6560
+ const conversationId = String(req.body?.conversationId || '').trim();
6561
+ if (!conversationId) return res.status(400).json({ error: 'Missing conversationId' });
6562
+ const runtimeSession = stmts.getRuntimeSessionByConversation.get(conversationId);
6563
+ if (!runtimeSession) return res.status(404).json({ error: 'Runtime session not found for conversation' });
6564
+ if (!normalizeClaudeBilling(runtimeSession.provider_type)) {
6565
+ return res.status(409).json({ error: 'Conversation is not a Claude conversation' });
6566
+ }
6567
+ const parsed = normalizeClaudeApiUsageReport(req.body);
6568
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
6569
+ const requesterSid = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
6570
+ const registeredKind = normalizeClaudeBilling(requesterSid ? sessionWorkerRegistry?.getWorker?.(requesterSid)?.kind : '');
6571
+ const statedKind = normalizeClaudeBilling(parsed.report.billing);
6572
+ if (registeredKind && statedKind && registeredKind !== statedKind) {
6573
+ console.warn(`[${ts()}] BILLING FENCE FAILURE usage conv=${conversationId.slice(0, 8)} stated=${statedKind} registered=${registeredKind} — booked as the registered kind`);
6574
+ }
6575
+ const billing = registeredKind || statedKind;
6576
+ if (!billing) return res.status(400).json({ error: 'Missing billing' });
6577
+ const booked = claudeApiUsageService.recordReport({ conversationId, billing, report: parsed.report });
6578
+ if (!booked) return res.status(400).json({ error: 'Missing usable usage metrics' });
6579
+ if (booked.booked.length) {
6580
+ try { checkClaudeApiCap('usage'); } catch {}
6581
+ }
6582
+ res.json({ ok: true, billing: booked.billing, basis: booked.basis, models: booked.booked.map((entry) => entry.model) });
6583
+ });
6584
+
6060
6585
  // POST /api/claude-plan-usage — Claude worker reports the session's
6061
6586
  // structured /usage data (plan rate-limit windows + session cost totals).
6062
6587
  //
@@ -6320,6 +6845,15 @@ export function registerMessagesRoutes(app, deps) {
6320
6845
  });
6321
6846
 
6322
6847
  // GET /api/pending — CLI fetches next pending message
6848
+ // The kind a poller of GET /api/pending is fenced as: the one its worker
6849
+ // stated, else none, as on the socket. A worker of unknown kind gets the
6850
+ // rows of a chat that never switched, and none of a chat that has
6851
+ // (kind-unknown): such a chat waits for a worker that says its kind.
6852
+ function httpPendingWorkerKind(requesterSessionId) {
6853
+ const stated = requesterSessionId ? sessionWorkerRegistry?.getWorker?.(requesterSessionId)?.kind : null;
6854
+ return normalizeWorkerKind(stated) || null;
6855
+ }
6856
+
6323
6857
  app.get('/api/pending', auth, async (req, res) => {
6324
6858
  touchCli();
6325
6859
  const requester = readBridgeIdentity(req);
@@ -6447,6 +6981,12 @@ export function registerMessagesRoutes(app, deps) {
6447
6981
  sessionWorkerSupervisor,
6448
6982
  relayRestartOrchestrator,
6449
6983
  inFlightProcessingCount: Number(counts.processingCount || 0),
6984
+ // No Claude worker polls here; one that does is fenced as on the
6985
+ // socket: by the kind it stated, the key's claim check, and a fenced
6986
+ // row asks for the swap or fails closed as there.
6987
+ workerKind: httpPendingWorkerKind(requesterSessionId),
6988
+ claudeApiClaimCheck,
6989
+ onBillingFence: onClaudeBillingFence,
6450
6990
  telemetry: (payload = {}) => {
6451
6991
  if (!sessionWorkerRoutingEnabled) return;
6452
6992
  emitSessionWorkerTelemetry(payload.event, {
@@ -6664,6 +7204,9 @@ export function registerMessagesRoutes(app, deps) {
6664
7204
  sessionWorkerSupervisor,
6665
7205
  relayRestartOrchestrator,
6666
7206
  inFlightProcessingCount: Number(queueCounts()?.processingCount || 0),
7207
+ workerKind: httpPendingWorkerKind(requesterSessionId),
7208
+ claudeApiClaimCheck,
7209
+ onBillingFence: onClaudeBillingFence,
6667
7210
  });
6668
7211
  if (recoveryDequeue?.message) {
6669
7212
  msg = recoveryDequeue.message;
@@ -7384,7 +7927,12 @@ export function registerMessagesRoutes(app, deps) {
7384
7927
  ? (usageLimitPauseService?.planPause?.({
7385
7928
  terminalError: req.body.terminalError,
7386
7929
  conversationId: targetConversationId,
7387
- providerType: stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type,
7930
+ // What the refused turn ran on (its row's billing), and the chat's
7931
+ // billing of now: a chat switched to the API key meanwhile carries on
7932
+ // there instead of waiting for the subscription's reset.
7933
+ providerType: normalizeClaudeBilling(q?.provider)
7934
+ || stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type,
7935
+ chatProviderType: stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type,
7388
7936
  queueRow: q,
7389
7937
  partialText: req.body?.partialText,
7390
7938
  }) || null)
@@ -7504,6 +8052,7 @@ export function registerMessagesRoutes(app, deps) {
7504
8052
  const conversationProvider = String(
7505
8053
  stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type || '',
7506
8054
  ).trim().toLowerCase() || 'github';
8055
+ const responderKind = sessionWorkerRegistry?.getWorker?.(normalizeSessionWorkerId(responseBridgeIdentity?.sessionId))?.kind || null;
7507
8056
  const executedProvider = resolveExecutedProviderForResponse({
7508
8057
  stmts,
7509
8058
  responseBridgeIdentity,
@@ -7511,13 +8060,28 @@ export function registerMessagesRoutes(app, deps) {
7511
8060
  // Image operations are executed by this server against the conversation's
7512
8061
  // own provider API, then finalized through an identity-less self-post.
7513
8062
  serverExecutedOperation: !!q?.image_operation_id,
8063
+ responderKind,
7514
8064
  });
8065
+ // A Claude chat's row ran on a worker of another billing than it was
8066
+ // stamped with: the delivery fence failed. What ran is recorded.
8067
+ const stampedBilling = normalizeClaudeBilling(q?.provider);
8068
+ const ranBilling = normalizeClaudeBilling(executedProvider);
8069
+ // A claimed turn on the key answered (it passed the claim check): the
8070
+ // next refusal of a self-started turn there is a new episode, with its
8071
+ // own note. A continuation that slipped through before its stop is not.
8072
+ if (ranBilling === 'claude-api' && String(q?.kind || '') !== 'continuation') claudeApiStopLedger.clear(targetConversationId);
8073
+ if (stampedBilling && ranBilling && stampedBilling !== ranBilling) {
8074
+ console.warn(`[${ts()}] BILLING FENCE FAILURE ${messageId?.slice(0, 8)} conv=${targetConversationId?.slice(0, 8)} stamped=${stampedBilling} ran=${ranBilling} — a Claude turn ran on the other billing`);
8075
+ }
8076
+ // A Claude chat that switched billing mid-turn finished that turn on the
8077
+ // billing it started with: not a mismatch.
8078
+ const switchedClaudeTurn = ranBilling && normalizeClaudeBilling(conversationProvider) && (!stampedBilling || stampedBilling === ranBilling);
7515
8079
  if (executedProvider === 'unknown') {
7516
8080
  // An identity that names no runtime session is itself an anomaly (a stale
7517
8081
  // or forged header), just not evidence of cross-provider execution. Say so
7518
8082
  // rather than staying silent.
7519
8083
  console.warn(`[${ts()}] PROVIDER UNRESOLVED ${messageId?.slice(0, 8)} conv=${targetConversationId?.slice(0, 8)} bound=${conversationProvider} responder=${normalizeSessionWorkerId(responseBridgeIdentity?.sessionId)?.slice(0, 8) || 'none'} — responder identity matches no runtime session`);
7520
- } else if (executedProvider !== conversationProvider) {
8084
+ } else if (executedProvider !== conversationProvider && !switchedClaudeTurn) {
7521
8085
  console.warn(`[${ts()}] PROVIDER MISMATCH ${messageId?.slice(0, 8)} conv=${targetConversationId?.slice(0, 8)} bound=${conversationProvider} executed=${executedProvider} — turn answered by the wrong provider`);
7522
8086
  }
7523
8087
  const responderConversationCheck = resolveResponderConversationMismatch({
@@ -7874,8 +8438,9 @@ export function registerMessagesRoutes(app, deps) {
7874
8438
  // worker (detached background shells) serving both Copilot-CLI providers.
7875
8439
  // A Claude Cloud session can take a turn of its own too: one the agent
7876
8440
  // starts when its background work reports back, or one started on
7877
- // claude.ai; the cloud worker follows it and registers it here.
7878
- const CONTINUATION_PROVIDER_TYPES = new Set(['claude', 'github', 'openai', 'claude-cloud']);
8441
+ // claude.ai; the cloud worker follows it and registers it here. The Claude
8442
+ // API worker is the Claude one on an API key.
8443
+ const CONTINUATION_PROVIDER_TYPES = new Set(['claude', 'github', 'openai', 'claude-cloud', 'claude-api']);
7879
8444
 
7880
8445
  // POST /api/continuation-turn — a session worker's CLI started a turn on its
7881
8446
  // own (a background task's notification). The synthetic queue row gives that
@@ -7928,7 +8493,35 @@ export function registerMessagesRoutes(app, deps) {
7928
8493
  const attemptId = uuidv4();
7929
8494
  const now = new Date().toISOString();
7930
8495
  const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
7931
- try {
8496
+ // A Claude chat's continuation runs on the worker that started it,
8497
+ // whose billing may no longer be the chat's (a switch waiting for this
8498
+ // very background work): stamped with that worker's kind.
8499
+ const continuationBilling = normalizeClaudeBilling(sessionWorkerRegistry?.getWorker?.(ownerSessionId)?.kind);
8500
+ // A turn the CLI starts by itself on the key passes no claim, so the
8501
+ // claim check (the cap, the key, the switch) is asked here: when it
8502
+ // refuses, the row is still minted (the worker needs its id), with a
8503
+ // stop for it that the worker's control poller picks up; the stop's
8504
+ // result stops the worker's background tasks and fails the row.
8505
+ let capStop = null;
8506
+ if ((continuationBilling || normalizeClaudeBilling(providerType)) === 'claude-api') {
8507
+ const refusal = claudeApiContinuationRefusal();
8508
+ if (refusal) {
8509
+ const code = String(refusal.code || 'CLAUDE_API_NOT_CONFIGURED');
8510
+ capStop = {
8511
+ source: CLAUDE_API_CAP_STOP_SOURCE,
8512
+ code,
8513
+ error: String(refusal.error || '').trim() || 'Claude API is unavailable, so this turn did not run.',
8514
+ // Its note says to switch Claude API on rather than to set it up.
8515
+ ...(refusal.switchedOff === true ? { switchedOff: true } : {}),
8516
+ episode: claudeApiStopEpisode(code),
8517
+ };
8518
+ } else {
8519
+ claudeApiStopLedger.clear(conversationId);
8520
+ }
8521
+ }
8522
+ // The row, its stamp and a cap stop commit together (a database without
8523
+ // transactions, a test's stand-in, runs them in order).
8524
+ const insertContinuationRow = () => {
7932
8525
  stmts.insertContinuationQ.run(
7933
8526
  messageId,
7934
8527
  conversationId,
@@ -7945,6 +8538,14 @@ export function registerMessagesRoutes(app, deps) {
7945
8538
  attemptId,
7946
8539
  operationId,
7947
8540
  );
8541
+ if (continuationBilling) stmts.setQueueProvider?.run?.(continuationBilling, messageId);
8542
+ if (capStop) {
8543
+ insertRelayControlRequest.run(uuidv4(), 'abort_turn', conversationId, messageId, ownerSessionId, JSON.stringify(capStop), now, now);
8544
+ }
8545
+ };
8546
+ const insertContinuation = typeof db.transaction === 'function' ? db.transaction(insertContinuationRow) : insertContinuationRow;
8547
+ try {
8548
+ insertContinuation();
7948
8549
  } catch (error) {
7949
8550
  // Lost the race against a concurrent duplicate registration: the unique
7950
8551
  // index on continuation_op_id guarantees the winner's row exists now.
@@ -7954,7 +8555,20 @@ export function registerMessagesRoutes(app, deps) {
7954
8555
  }
7955
8556
  throw error;
7956
8557
  }
7957
- console.log(`[${ts()}] CONTINUATION ${messageId.slice(0, 8)} conv=${conversationId.slice(0, 8)} owner=${ownerSessionId.slice(0, 8)} trigger=${String(req.body?.trigger || 'background_task')}${operationId ? ` op=${operationId.slice(0, 8)}` : ''}`);
8558
+ console.log(`[${ts()}] CONTINUATION ${messageId.slice(0, 8)} conv=${conversationId.slice(0, 8)} owner=${ownerSessionId.slice(0, 8)} trigger=${String(req.body?.trigger || 'background_task')}${operationId ? ` op=${operationId.slice(0, 8)}` : ''}${capStop ? ' stopped=claude-api-cap' : ''}`);
8559
+ if (capStop) {
8560
+ // Not left to the stop: a turn that ends before the worker's control
8561
+ // poller takes it still gets its tasks stopped and the note.
8562
+ try {
8563
+ const tasks = stopClaudeApiBackgroundTasks(conversationId, ownerSessionId);
8564
+ if (!claudeApiStopLedger.has(conversationId, capStop.episode)) {
8565
+ writeClaudeApiStopNote({ conversationId, relayMode, refusal: capStop, tasks, now });
8566
+ claudeApiStopLedger.note(conversationId, capStop.episode);
8567
+ }
8568
+ } catch (error) {
8569
+ console.warn(`[${ts()}] CLAUDE API STOP conv=${conversationId.slice(0, 8)}: ${error?.message || error}`);
8570
+ }
8571
+ }
7958
8572
  io.emit('message_status', { messageId, conversationId, status: 'processing', kind: 'continuation' });
7959
8573
  io.emit('queue_updated', { continuation: 1 });
7960
8574
  res.json({ ok: true, messageId, conversationId, attemptId });
@@ -8208,7 +8822,7 @@ export function registerMessagesRoutes(app, deps) {
8208
8822
  // The providers whose workers name a shell task's output (a file, a spool
8209
8823
  // or a snapshot). A task of theirs without one comes from a worker that
8210
8824
  // started before the console existed.
8211
- const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok', 'deepseek']);
8825
+ const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok', 'deepseek', 'claude-api']);
8212
8826
  const SUBAGENT_OUTPUT_MESSAGE = TASK_OUTPUT_NONE_MESSAGES.subagent;
8213
8827
 
8214
8828
  // Why a task has no output to show, for the console's `none` answer. The
@@ -8256,7 +8870,7 @@ export function registerMessagesRoutes(app, deps) {
8256
8870
  // 256 KB; with `before` (a startOffset of an earlier answer): the lines
8257
8871
  // ending there. Everything is cleaned (controls out, colours kept,
8258
8872
  // credentials scrubbed) for every reader. Another relay's agent reads it
8259
- // too (remote_relay read_task_output, read permission): no inbound switch,
8873
+ // too (relay_tool read_task_output, read permission): no inbound switch,
8260
8874
  // nothing it reads changes anything.
8261
8875
  app.get('/api/conversation/:conversationId/background-task/:taskId/output', auth, async (req, res) => {
8262
8876
  const conversationId = String(req.params.conversationId || '').trim();
@@ -8947,12 +9561,16 @@ export function registerMessagesRoutes(app, deps) {
8947
9561
  console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=provider-mismatch retry=${Number(q.retry_count || 0)}`);
8948
9562
  return res.json({ ok: true, mismatch: true });
8949
9563
  }
8950
- if (q && q.status === 'processing' && String(req.body?.class || '').trim() === 'steering-held') {
9564
+ const handBackClass = String(req.body?.class || '').trim();
9565
+ if (q && q.status === 'processing' && (handBackClass === 'steering-held' || handBackClass === 'kind-mismatch')) {
8951
9566
  // The worker refused to push the message because its live turn is
8952
9567
  // holding steering (an open question card, a compaction, an adoption).
8953
9568
  // Not a failure: no retry increment, no backoff, and the owner is kept —
8954
9569
  // the same worker takes it again the moment it signals ready, which is
8955
9570
  // when the hold ends, so the message steers into the resumed turn.
9571
+ // `kind-mismatch`: a Claude chat's row reached a worker of the other
9572
+ // billing; back to pending (re-stamped to the chat's billing by the
9573
+ // queue trigger) for the worker the billing swap brings up.
8956
9574
  const result = db.prepare(`
8957
9575
  UPDATE queue
8958
9576
  SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL
@@ -8962,7 +9580,7 @@ export function registerMessagesRoutes(app, deps) {
8962
9580
  settleRelayAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
8963
9581
  settleRelaySubagentAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
8964
9582
  io.emit('message_status', { messageId, conversationId: q.conversation_id, status: 'pending' });
8965
- console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=steering-held retry=${Number(q.retry_count || 0)}`);
9583
+ console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=${handBackClass} retry=${Number(q.retry_count || 0)}`);
8966
9584
  }
8967
9585
  return res.json({ ok: true, held: true });
8968
9586
  }