@oar-sh/oar 0.9.17 → 0.9.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +13 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  3. package/CHANGELOG.md +234 -0
  4. package/README.md +13 -12
  5. package/package.json +1 -1
  6. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  7. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  8. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +5 -5
  10. package/server/claude-worker/claude-sdk-adapter.mjs +35 -9
  11. package/server/claude-worker/claude-session-process.mjs +166 -21
  12. package/server/claude-worker/claude-session-worker-main.mjs +3 -0
  13. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  14. package/server/claude-worker/claude-turn-publisher.mjs +117 -5
  15. package/server/claude-worker/claude-usage-totals.mjs +53 -0
  16. package/server/claude-worker/sdk-message-normalizer.mjs +1 -1
  17. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  18. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +9 -9
  20. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  21. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  22. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  23. package/server/db-schema.mjs +33 -1
  24. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -130
  25. package/server/grok-worker/acp-client.mjs +1 -1
  26. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  27. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  28. package/server/mcp/oar-mcp-server.mjs +11 -5
  29. package/server/migrations/0031-queue-provider.mjs +143 -0
  30. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  31. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  32. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  33. package/server/public/app/activity-replay-state.mjs +4 -3
  34. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  35. package/server/public/app/agent-sessions-settings.mjs +1 -1
  36. package/server/public/app/api-client.js +52 -0
  37. package/server/public/app/bootstrap.js +153 -27
  38. package/server/public/app/claude-api-settings-ui.js +362 -0
  39. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  40. package/server/public/app/claude-billing-toggle.mjs +112 -0
  41. package/server/public/app/context-tier-options.mjs +6 -2
  42. package/server/public/app/context-usage-view.mjs +1 -1
  43. package/server/public/app/conversation-provider-indicator.mjs +11 -0
  44. package/server/public/app/conversation-view.js +29 -3
  45. package/server/public/app/inbox-view.mjs +1 -1
  46. package/server/public/app/journal-view.js +127 -10
  47. package/server/public/app/model-selector-options.mjs +17 -1
  48. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  49. package/server/public/app/provider-readiness.mjs +5 -2
  50. package/server/public/app/relay-error-ctas.mjs +50 -0
  51. package/server/public/app/relay-schedules-ui.js +1 -1
  52. package/server/public/app/settings-modal.js +11 -0
  53. package/server/public/app/settings-tabs.js +1 -1
  54. package/server/public/app/socket-handlers.js +18 -1
  55. package/server/public/app/tool-activity-emoji.mjs +4 -3
  56. package/server/public/app/usage-limit-ui.js +136 -5
  57. package/server/public/index.html +189 -3
  58. package/server/relay-heap-guard.mjs +338 -0
  59. package/server/relay-self-restart.mjs +33 -7
  60. package/server/relay-tools.md +6 -6
  61. package/server/repositories/conversation-group-repository.mjs +1 -1
  62. package/server/repositories/message-repository.mjs +7 -0
  63. package/server/repositories/session-repository.mjs +8 -1
  64. package/server/routes/ask-user-routes.mjs +1 -1
  65. package/server/routes/conversation-group-routes.mjs +1 -1
  66. package/server/routes/messages-routes.mjs +678 -31
  67. package/server/routes/relay-ask-routes.mjs +3 -3
  68. package/server/routes/relay-event-routes.mjs +2 -2
  69. package/server/routes/relay-schedule-routes.mjs +1 -1
  70. package/server/routes/remote-relay-routes.mjs +3 -3
  71. package/server/routes/sessions-routes.mjs +365 -53
  72. package/server/server-runtime.mjs +373 -26
  73. package/server/services/about-service.mjs +1 -0
  74. package/server/services/background-work-view.mjs +2 -1
  75. package/server/services/claude-api-settings-service.mjs +575 -0
  76. package/server/services/claude-api-usage-service.mjs +361 -0
  77. package/server/services/claude-billing-swap-service.mjs +274 -0
  78. package/server/services/claude-billing-switch-service.mjs +343 -0
  79. package/server/services/plan-usage-claude-api.mjs +445 -0
  80. package/server/services/plan-usage-service.mjs +5 -0
  81. package/server/services/provider-model-selection.mjs +3 -2
  82. package/server/services/provider-reasoning-effort.mjs +9 -0
  83. package/server/services/relay-ask-service.mjs +1 -1
  84. package/server/services/relay-event-service.mjs +3 -2
  85. package/server/services/relay-schedule-service.mjs +1 -1
  86. package/server/services/remote-relay-caller-context.mjs +7 -0
  87. package/server/services/remote-relay-dispatcher.mjs +111 -17
  88. package/server/services/remote-relay-registry-service.mjs +1 -1
  89. package/server/services/session-worker-launch-service.mjs +61 -2
  90. package/server/services/session-worker-registry-service.mjs +28 -1
  91. package/server/services/session-worker-websocket-service.mjs +17 -0
  92. package/server/services/usage-limit-pause-service.mjs +55 -4
  93. package/server/services/worker-secret-redaction.mjs +2 -0
  94. package/shared/anthropic-api.mjs +202 -0
  95. package/shared/provider-routing.mjs +5 -3
  96. package/shared/relay-asks.mjs +11 -5
  97. package/shared/relay-events.mjs +2 -2
  98. package/shared/relay-schedules.mjs +2 -2
  99. package/shared/remote-relay-contract.mjs +54 -12
  100. package/shared/remote-relay-tool-core.mjs +23 -6
  101. package/shared/steer-settle-failure.mjs +1 -0
  102. package/shared/worker-runtime/api-client.mjs +2 -2
  103. package/shared/worker-runtime/heartbeat.mjs +5 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
@@ -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 {
@@ -56,7 +56,11 @@ import { readBefore, readTail, shapeSnapshot, taskOutputSource } from '../servic
56
56
  import { taskKindOf } from '../services/session-activity-tracker.mjs';
57
57
  import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
58
58
  import { isLiveResend } from '../services/steer-resend.mjs';
59
- import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
59
+ import {
60
+ checkRequestedProviderModel,
61
+ resolveProviderModelSelection,
62
+ unknownProviderModelText,
63
+ } from '../services/provider-model-selection.mjs';
60
64
  import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
61
65
  import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
62
66
  import {
@@ -81,6 +85,9 @@ import {
81
85
  } from '../../shared/deepseek-models.mjs';
82
86
  import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
83
87
  import { recordClaudeCliStart } from '../services/claude-session-leftovers.mjs';
88
+ import { normalizeClaudeBilling } from '../services/claude-billing-switch-service.mjs';
89
+ import { createClaudeApiStopNoteLedger } from '../services/plan-usage-claude-api.mjs';
90
+ import { normalizeClaudeApiUsageReport } from '../services/claude-api-usage-service.mjs';
84
91
  import {
85
92
  admitRemoteRelayRequest,
86
93
  readRemoteRelayRequest,
@@ -161,6 +168,8 @@ function normalizeRequestedProviderType(value = '') {
161
168
  // Its own provider, never read as `claude`.
162
169
  if (normalized === 'claude-cloud') return 'claude-cloud';
163
170
  if (normalized === 'deepseek') return 'deepseek';
171
+ // Its own provider too (Claude on an API key), never read as `claude`.
172
+ if (normalized === 'claude-api') return 'claude-api';
164
173
  return '';
165
174
  }
166
175
 
@@ -170,6 +179,7 @@ const BOOTSTRAP_ONLY_PROVIDER_LABELS = Object.freeze({
170
179
  claude: 'Claude',
171
180
  'claude-cloud': 'Claude Cloud',
172
181
  deepseek: 'DeepSeek',
182
+ 'claude-api': 'Claude API',
173
183
  });
174
184
 
175
185
  function deriveModelOrigin(model) {
@@ -867,9 +877,16 @@ export function resolveExecutedProviderForResponse({
867
877
  responseBridgeIdentity,
868
878
  conversationProvider = null,
869
879
  serverExecutedOperation = false,
880
+ // The kind the responding worker said it is. A Claude chat moves between
881
+ // the subscription and the API key, and its runtime row names the billing
882
+ // of now, not the one a turn that ends after a switch ran on: the worker's
883
+ // own kind is what ran.
884
+ responderKind = null,
870
885
  } = {}) {
871
886
  const boundProvider = String(conversationProvider || '').trim().toLowerCase() || 'github';
872
887
  const responderSessionId = normalizeSessionWorkerId(responseBridgeIdentity?.sessionId);
888
+ const responderBilling = normalizeClaudeBilling(responderKind);
889
+ if (responderSessionId && responderBilling) return responderBilling;
873
890
  if (responderSessionId) {
874
891
  const runtimeSession = stmts?.getRuntimeSessionBySdkSessionId?.get?.(responderSessionId) || null;
875
892
  const providerType = String(runtimeSession?.provider_type || '').trim().toLowerCase();
@@ -912,10 +929,21 @@ export function dequeuePendingMessage({
912
929
  requesterSessionId = null,
913
930
  ownerLeaseMs = SESSION_WORKER_OWNER_LEASE_MS,
914
931
  affinityOnly = false,
932
+ // The kind the requesting worker says it is ("claude", "claude-api", ...),
933
+ // null when unknown. A Claude chat's row is claimed only by a worker of
934
+ // the row's billing (claudeBillingFence); a refused claim calls
935
+ // `onBillingFence` and claims nothing.
936
+ workerKind = null,
937
+ onBillingFence = null,
938
+ // () → null, or `{ code, error }` when a Claude API row must not run now
939
+ // (the provider off, no key, the monthly cap): such a row is fenced too,
940
+ // whatever the worker, and the caller fails it closed.
941
+ claudeApiClaimCheck = null,
915
942
  } = {}) {
916
943
  const currentIso = String(nowIso || '').trim() || new Date().toISOString();
917
944
  const requesterSid = normalizeSessionWorkerId(requesterSessionId);
918
945
  const leaseExpiresAt = requesterSid ? addMsToIso(currentIso, ownerLeaseMs) : null;
946
+ let fenced = null;
919
947
  const dequeue = db.transaction(() => {
920
948
  let next = null;
921
949
  if (routingEnabled && requesterSid && stmts.findPendingForWorker) {
@@ -927,6 +955,10 @@ export function dequeuePendingMessage({
927
955
  } else if (!routingEnabled && requesterSid && stmts.findPendingForSessionAffinity) {
928
956
  next = stmts.findPendingForSessionAffinity.get(currentIso, requesterSid);
929
957
  }
958
+ if (next && requesterSid) {
959
+ fenced = claudeBillingFence({ stmts, row: next, workerKind, claudeApiClaimCheck });
960
+ if (fenced) return null;
961
+ }
930
962
  if (!next && affinityOnly) return null;
931
963
  if (!next) {
932
964
  // Requesters that reach the global fallback carry no worker identity
@@ -963,7 +995,119 @@ export function dequeuePendingMessage({
963
995
  attempt_id: attemptId,
964
996
  };
965
997
  });
966
- return dequeue();
998
+ const claimed = dequeue();
999
+ if (fenced && typeof onBillingFence === 'function') {
1000
+ try { onBillingFence({ ...fenced, requesterSessionId: requesterSid }); } catch {}
1001
+ }
1002
+ return claimed;
1003
+ }
1004
+
1005
+ // The `source` of an abort_turn control queued for a continuation on the
1006
+ // key that the Claude API claim check refuses (POST /api/continuation-turn):
1007
+ // the cap, no key, or the provider switched off.
1008
+ const CLAUDE_API_CAP_STOP_SOURCE = 'claude-api-cap';
1009
+
1010
+ /**
1011
+ * What a note on a Claude API turn that did not run tells the user to do,
1012
+ * without the full stop: the cap is raised, a switched-off Claude API is
1013
+ * switched on, a missing key is set up; or the chat moves to the
1014
+ * subscription.
1015
+ */
1016
+ export function claudeApiRemedyText({ code = '', switchedOff = false } = {}) {
1017
+ const place = 'Settings → Providers → Claude API';
1018
+ if (String(code) === 'CLAUDE_API_CAP_REACHED') return `Switch this chat to the subscription, or raise the monthly cap in ${place}`;
1019
+ if (switchedOff) return `Switch this chat to the subscription, or switch Claude API on in ${place}`;
1020
+ return `Switch this chat to the subscription, or set Claude API up in ${place}`;
1021
+ }
1022
+
1023
+ const CLAUDE_API_STOP_NOTE_LABELS = 2;
1024
+ const CLAUDE_API_STOP_NOTE_LABEL_CHARS = 40;
1025
+
1026
+ /**
1027
+ * The note for a self-started turn on the key the Claude API claim check
1028
+ * refused (`refusal`: `{ code, error, switchedOff? }`), with the background tasks the
1029
+ * relay stopped and those no stop reaches (`tasks`: `{ stopped, unreached }`
1030
+ * of `{ label }`). One paragraph whose lead stays within
1031
+ * FAILURE_NOTE_LEAD_MAX, so it still reads as a failure note and the
1032
+ * transcript's buttons find its code: at most two labels per list, cut to
1033
+ * 40 characters, then only the counts if the lead is still too long. The
1034
+ * fixed sentences and the code are never cut.
1035
+ */
1036
+ export function buildClaudeApiStopNoteText({ refusal = {}, tasks = {} } = {}) {
1037
+ const code = String(refusal.code || 'CLAUDE_API_NOT_CONFIGURED');
1038
+ const stableCode = `relay.${code.toLowerCase().replace(/_/g, '-')}`;
1039
+ const reason = String(refusal.error || '').trim()
1040
+ .replace(/,? so this turn did not run\.$/, '.')
1041
+ .replace(/\s*This turn did not run\.$/, '');
1042
+ const stopped = Array.isArray(tasks.stopped) ? tasks.stopped : [];
1043
+ const unreached = Array.isArray(tasks.unreached) ? tasks.unreached : [];
1044
+ const cut = (label) => {
1045
+ const text = String(label || '').trim().replace(/\s+/g, ' ') || 'a task';
1046
+ return text.length > CLAUDE_API_STOP_NOTE_LABEL_CHARS ? `${text.slice(0, CLAUDE_API_STOP_NOTE_LABEL_CHARS - 1).trimEnd()}…` : text;
1047
+ };
1048
+ const named = (list, shown) => {
1049
+ const labels = list.slice(0, shown).map((task) => cut(task.label));
1050
+ const more = list.length - labels.length;
1051
+ return `${labels.join(', ')}${more ? ` and ${more} more` : ''}`;
1052
+ };
1053
+ const tasksText = (shown) => [
1054
+ stopped.length
1055
+ ? (shown ? `Background tasks stopped: ${named(stopped, shown)}. ` : `${stopped.length} background task${stopped.length === 1 ? '' : 's'} stopped. `)
1056
+ : '',
1057
+ unreached.length
1058
+ ? (shown
1059
+ ? `Still running (stop them in the task panel): ${named(unreached, shown)}. `
1060
+ : `${unreached.length}${stopped.length ? ' more' : ` background task${unreached.length === 1 ? '' : 's'}`} still running (stop them in the task panel). `)
1061
+ : '',
1062
+ ].join('');
1063
+ const lead = (shown) => `${reason} Claude Code started this turn by itself when a background task reported back. `
1064
+ + 'It is being stopped, but its first request may already be billed, and a short turn can end first. '
1065
+ + tasksText(shown)
1066
+ + `${claudeApiRemedyText({ code, switchedOff: refusal.switchedOff === true })}. `;
1067
+ let shown = CLAUDE_API_STOP_NOTE_LABELS;
1068
+ while (shown > 0 && lead(shown).length > FAILURE_NOTE_LEAD_MAX) shown -= 1;
1069
+ return `${lead(shown)}Error code: ${stableCode}.`;
1070
+ }
1071
+
1072
+ /**
1073
+ * Whether a worker of `workerKind` must not claim `row`, a Claude chat's
1074
+ * turn: null when it may, else `{ conversationId, messageId, rowBilling,
1075
+ * workerKind, reason }`. The row's billing is its stamp (queue.provider),
1076
+ * else its chat's. A worker that states no kind is taken at its chat's
1077
+ * word, except on a chat that has switched since (a worker from before the
1078
+ * switch existed: it is replaced once, when idle).
1079
+ */
1080
+ export function claudeBillingFence({ stmts, row, workerKind = null, claudeApiClaimCheck = null } = {}) {
1081
+ if (!row) return null;
1082
+ const runtime = stmts?.getRuntimeSessionByConversation?.get?.(row.conversation_id) || null;
1083
+ const chatBilling = normalizeClaudeBilling(runtime?.provider_type);
1084
+ const rowBilling = normalizeClaudeBilling(row.provider) || chatBilling;
1085
+ if (!rowBilling) return null;
1086
+ const kind = normalizeWorkerKind(workerKind);
1087
+ const base = {
1088
+ conversationId: String(row.conversation_id || ''),
1089
+ messageId: String(row.id || ''),
1090
+ rowBilling,
1091
+ workerKind: kind,
1092
+ };
1093
+ if (rowBilling === 'claude-api' && typeof claudeApiClaimCheck === 'function') {
1094
+ let refusal = null;
1095
+ try {
1096
+ refusal = claudeApiClaimCheck() || null;
1097
+ } catch (error) {
1098
+ // A check that cannot answer does not let a turn run on the key: the
1099
+ // row fails closed as unconfigured. Subscription rows never get here.
1100
+ console.warn(`[claude-billing] Claude API claim check failed for ${String(row.id || '').slice(0, 8)}: ${error?.message || error} — the row fails closed`);
1101
+ refusal = {
1102
+ code: 'CLAUDE_API_NOT_CONFIGURED',
1103
+ error: 'The relay could not check Claude API (key, switch, cap), so this turn did not run.',
1104
+ };
1105
+ }
1106
+ if (refusal) return { ...base, reason: 'claude-api-unavailable', refusal };
1107
+ }
1108
+ if (kind) return kind === rowBilling ? null : { ...base, reason: 'kind-mismatch' };
1109
+ if (String(runtime?.billing_switched_at || '').trim()) return { ...base, reason: 'kind-unknown' };
1110
+ return null;
967
1111
  }
968
1112
 
969
1113
  // A requeue for a turn that produced nothing and was only briefly in flight is
@@ -1029,9 +1173,13 @@ export async function dequeuePendingMessageForWorkerLoop({
1029
1173
  relayRestartOrchestrator = null,
1030
1174
  inFlightProcessingCount = 0,
1031
1175
  telemetry = null,
1176
+ workerKind = null,
1177
+ onBillingFence = null,
1178
+ claudeApiClaimCheck = null,
1032
1179
  } = {}) {
1033
1180
  const requesterSid = normalizeSessionWorkerId(requesterSessionId);
1034
1181
  const routingWithWorker = routingEnabled && requesterSid;
1182
+ let billingFenced = null;
1035
1183
  const retryLimit = Math.max(0, Number(transientRetryLimit) || 0);
1036
1184
  const backoffBaseMs = Math.max(1, Number(transientRetryBackoffMs) || 1);
1037
1185
  const supervisor = sessionWorkerSupervisor || null;
@@ -1112,6 +1260,12 @@ export async function dequeuePendingMessageForWorkerLoop({
1112
1260
  requesterSessionId: requesterSid,
1113
1261
  ownerLeaseMs,
1114
1262
  affinityOnly,
1263
+ workerKind,
1264
+ claudeApiClaimCheck,
1265
+ onBillingFence: (fence) => {
1266
+ billingFenced = fence;
1267
+ if (typeof onBillingFence === 'function') onBillingFence(fence);
1268
+ },
1115
1269
  });
1116
1270
  emitWorkerLoopTelemetry(telemetry, {
1117
1271
  event: message ? 'queue.dequeue.success' : 'queue.dequeue.empty',
@@ -1134,7 +1288,9 @@ export async function dequeuePendingMessageForWorkerLoop({
1134
1288
  }
1135
1289
  return {
1136
1290
  message,
1137
- blockedReason: null,
1291
+ // The next row is of another billing than this worker: it waits for
1292
+ // the swap (claude-billing-swap-service.mjs).
1293
+ blockedReason: !message && billingFenced ? 'billing-swap' : null,
1138
1294
  worker: routingWithWorker ? (supervisor?.getWorkerState?.(requesterSid) || null) : null,
1139
1295
  lifecycle: routingWithWorker ? (supervisor?.getLifecycleState?.(requesterSid) || null) : null,
1140
1296
  attempts: attempt + 1,
@@ -1204,6 +1360,9 @@ export function buildDequeuedRelayMessage({
1204
1360
  }
1205
1361
  }
1206
1362
  const providerType = String(runtimeSession?.provider_type || '').trim().toLowerCase() || null;
1363
+ // A Claude chat's billing for this row: its stamp, else the chat's. The
1364
+ // worker runs it only when that is its own kind.
1365
+ const billing = normalizeClaudeBilling(msg.provider) || normalizeClaudeBilling(providerType) || null;
1207
1366
  // The Cursor worker declares one subagent per enabled model, because the
1208
1367
  // SDK's built-in subagent model menu is stuck at `inherit` +
1209
1368
  // `composer-2.5-fast` (see cursor-subagent-roster.mjs). Delivered per turn
@@ -1241,6 +1400,7 @@ export function buildDequeuedRelayMessage({
1241
1400
  model: String(msg.model || '').trim() || defaultModel,
1242
1401
  modelVariantId: String(msg.model_variant_id || '').trim() || String(msg.model || '').trim() || null,
1243
1402
  providerType,
1403
+ ...(billing ? { billing } : {}),
1244
1404
  providerModel: String(runtimeSession?.provider_model || '').trim() || null,
1245
1405
  claudeNativeSessionId: String(runtimeSession?.claude_native_session_id || '').trim() || null,
1246
1406
  cursorAgentId: String(runtimeSession?.cursor_agent_id || '').trim() || null,
@@ -1844,6 +2004,7 @@ export function registerMessagesRoutes(app, deps) {
1844
2004
  getGrokProviderSettings = () => ({ enabled: false, model: '', models: [] }),
1845
2005
  getClaudeCloudProviderSettings = () => ({ enabled: false, defaultModel: '', environmentId: '', models: [] }),
1846
2006
  getDeepSeekProviderSettings = () => ({ enabled: false, model: '', models: [] }),
2007
+ getClaudeApiProviderSettings = () => ({ enabled: false, model: '', models: [], availableModels: [], effortsByModel: {} }),
1847
2008
  rebindUnstartedOpenAIConversationModel = null,
1848
2009
  normalizeRelayMode,
1849
2010
  DEFAULT_RELAY_MODE,
@@ -1883,6 +2044,27 @@ export function registerMessagesRoutes(app, deps) {
1883
2044
  fetchUsageSummary = null,
1884
2045
  planUsageService = null,
1885
2046
  usageLimitPauseService = null,
2047
+ // The Claude billing switch (claude-billing-switch-service.mjs): the
2048
+ // billing route, and the send-path guards that refuse a switch by send.
2049
+ claudeBillingSwitchService = null,
2050
+ // The Claude API booking (claude-api-usage-service.mjs).
2051
+ claudeApiUsageService = null,
2052
+ // The delivery fence's runtime half for GET /api/pending, as on the
2053
+ // socket: the Claude API claim check (null: no refusal) and what a fenced
2054
+ // row does (the swap, or fail closed).
2055
+ claudeApiClaimCheck = null,
2056
+ onClaudeBillingFence = null,
2057
+ // The Claude API monthly cap's one seam (`{ reached, error? }`; a throw
2058
+ // counts as reached): an agent's send to a chat on the key is refused
2059
+ // while it is reached. Absent in older wiring and most tests: no cap.
2060
+ getClaudeApiCapState = () => ({ reached: false }),
2061
+ // Asks whether the cap flipped after a booked report (the runtime then
2062
+ // re-announces the Claude chats' billing states). A no-op without it.
2063
+ checkClaudeApiCap = () => {},
2064
+ // The notes for self-started turns stopped on the key, once per chat per
2065
+ // episode (plan-usage-claude-api.mjs); the runtime shares its own, which
2066
+ // the cap watch and the settings route clear.
2067
+ claudeApiStopLedger = createClaudeApiStopNoteLedger(),
1886
2068
  getCursorPlanAllowanceSettings = () => ({ cursorModelsUsd: null, otherModelsUsd: null, resetDay: 1 }),
1887
2069
  getGrokPlanAllowanceSettings = () => ({ monthlyUsd: null, resetDay: 1 }),
1888
2070
  opaqueResponseRecoveryWaitMs = OPAQUE_RESPONSE_RECOVERY_WAIT_MS,
@@ -2722,6 +2904,92 @@ export function registerMessagesRoutes(app, deps) {
2722
2904
  return buildRelayStopFailure();
2723
2905
  }
2724
2906
 
2907
+ // The key stop's request (`{ source, code, error, episode }`), or null for any other control.
2908
+ function claudeApiCapStopOf(control) {
2909
+ let request = null;
2910
+ try { request = JSON.parse(String(control?.request || 'null')); } catch { request = null; }
2911
+ return request?.source === CLAUDE_API_CAP_STOP_SOURCE ? request : null;
2912
+ }
2913
+
2914
+ // The Claude API claim check for a continuation on the key, failing closed
2915
+ // as the delivery fence does: `{ code, error }` or null. Without the
2916
+ // runtime's check (older wiring, tests) the cap seam alone is asked.
2917
+ function claudeApiContinuationRefusal() {
2918
+ try {
2919
+ if (typeof claudeApiClaimCheck === 'function') return claudeApiClaimCheck() || null;
2920
+ let cap = null;
2921
+ try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
2922
+ return cap?.reached === true
2923
+ ? { 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.` }
2924
+ : null;
2925
+ } catch (error) {
2926
+ console.warn(`[claude-billing] Claude API claim check failed for a continuation: ${error?.message || error} — it is stopped`);
2927
+ return { code: 'CLAUDE_API_NOT_CONFIGURED', error: 'The relay could not check Claude API (key, switch, cap), so this turn did not run.' };
2928
+ }
2929
+ }
2930
+
2931
+ // The episode of a refusal, for its note (claudeApiStopLedger): the cap's
2932
+ // month and value, or the refusal's code.
2933
+ function claudeApiStopEpisode(code) {
2934
+ if (code !== 'CLAUDE_API_CAP_REACHED') return String(code || 'refused');
2935
+ try {
2936
+ return `cap:${String(getClaudeApiCapState()?.episode || '')}`;
2937
+ } catch {
2938
+ return 'cap:unreadable';
2939
+ }
2940
+ }
2941
+
2942
+ // Stops the live background tasks of a key worker whose self-started turn
2943
+ // is refused: each report would start another such turn, and a billing
2944
+ // switch waits for them. `{ stopped, unreached }`, each `[{ taskId,
2945
+ // label, reason? }]`: the stops sent, and the tasks no stop reaches (not
2946
+ // stoppable, or the worker has no socket to take it).
2947
+ function stopClaudeApiBackgroundTasks(conversationId, sessionId) {
2948
+ const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
2949
+ const workerSessionId = String(sessionId || conversation?.sdk_session_id || conversationId);
2950
+ const keys = [...new Set([conversationId, workerSessionId, String(conversation?.sdk_session_id || '')].filter(Boolean))];
2951
+ const stopped = [];
2952
+ const unreached = [];
2953
+ const seen = new Set();
2954
+ for (const task of keys.flatMap((key) => backgroundTaskStore?.get?.(key) || [])) {
2955
+ const taskId = String(task?.taskId || '').trim();
2956
+ if (!taskId || seen.has(taskId)) continue;
2957
+ seen.add(taskId);
2958
+ const label = String(task?.description || task?.command || task?.taskType || '').trim().replace(/\s+/g, ' ').slice(0, 80) || taskId;
2959
+ if (task?.stoppable === false) {
2960
+ unreached.push({ taskId, label, reason: 'no-stop' });
2961
+ } else if (sendWorkerControl?.(workerSessionId, { type: 'stop_background_task', taskId })) {
2962
+ stopped.push({ taskId, label });
2963
+ } else {
2964
+ unreached.push({ taskId, label, reason: 'worker-not-connected' });
2965
+ }
2966
+ }
2967
+ if (stopped.length || unreached.length) {
2968
+ console.log(`[${ts()}] CLAUDE API STOP conv=${conversationId.slice(0, 8)} background tasks stopped=${stopped.length} unreached=${unreached.length}`);
2969
+ }
2970
+ return { stopped, unreached };
2971
+ }
2972
+
2973
+ /**
2974
+ * The note for a self-started turn on the key the claim check refused,
2975
+ * written when the turn is registered (a short turn can end before the
2976
+ * worker's control poller takes the stop): the refusal, what the stop can
2977
+ * and can't do, the background tasks stopped and those no stop reached,
2978
+ * and the way out, its code last where the transcript's buttons look.
2979
+ */
2980
+ function writeClaudeApiStopNote({ conversationId, relayMode, refusal, tasks, now }) {
2981
+ const text = buildClaudeApiStopNoteText({ refusal, tasks });
2982
+ const noteId = uuidv4();
2983
+ stmts.insertMsg.run(noteId, conversationId, 'assistant', text, null, relayMode, null, now, null, null, null);
2984
+ stmts.updateConvTime?.run?.(now, conversationId);
2985
+ io.emit('assistant_message', {
2986
+ conversationId,
2987
+ messageId: noteId,
2988
+ message: { id: noteId, role: 'assistant', text, model: null, mode: relayMode, attachments: [], timestamp: now },
2989
+ });
2990
+ return noteId;
2991
+ }
2992
+
2725
2993
  function settleRelayAbortControlsForQueueMessage(queueMessageId, { ok = true, note = null, error = null } = {}) {
2726
2994
  const id = String(queueMessageId || '').trim();
2727
2995
  if (!id) return 0;
@@ -3509,6 +3777,17 @@ export function registerMessagesRoutes(app, deps) {
3509
3777
  return res.json({ ok: true, control: formatRelayControlResponse(control) });
3510
3778
  });
3511
3779
 
3780
+ /**
3781
+ * A continuation on the key, stopped because the Claude API claim check
3782
+ * refused it: its note was written, and its tasks stopped, when it was
3783
+ * registered, so the row fails quietly.
3784
+ */
3785
+ function settleClaudeApiContinuationStop({ queueRow, queueMessageId }) {
3786
+ if (Number(stmts.dropStaleContinuation?.run?.(queueMessageId)?.changes || 0) > 0) {
3787
+ io.emit('message_status', { messageId: queueMessageId, conversationId: queueRow.conversation_id, status: 'failed' });
3788
+ }
3789
+ }
3790
+
3512
3791
  app.post('/api/control/:controlId/result', auth, (req, res) => {
3513
3792
  const controlId = String(req.params.controlId || '').trim();
3514
3793
  if (!controlId) {
@@ -3571,7 +3850,10 @@ export function registerMessagesRoutes(app, deps) {
3571
3850
  if (String(control.type || '').trim() === 'abort_turn') {
3572
3851
  const queueMessageId = String(control.queue_message_id || '').trim();
3573
3852
  const queueRow = queueMessageId ? (stmts.findQById.get(queueMessageId) || null) : null;
3574
- if (queueRow) {
3853
+ const capStop = queueRow ? claudeApiCapStopOf(control) : null;
3854
+ if (capStop) {
3855
+ settleClaudeApiContinuationStop({ queueRow, queueMessageId });
3856
+ } else if (queueRow) {
3575
3857
  const failureRecord = buildRelayStopFailurePayload();
3576
3858
  failQueueMessage({
3577
3859
  queueRow,
@@ -4479,7 +4761,11 @@ export function registerMessagesRoutes(app, deps) {
4479
4761
  }
4480
4762
 
4481
4763
  // POST /api/message — browser sends a message
4482
- app.post('/api/message', auth, async (req, res) => {
4764
+ // The send route's handler. The billing route runs it too, for an agent's
4765
+ // send that switches the chat's billing with its message: that request
4766
+ // carries `oarBillingSwitch` (set by the route, never read from a body),
4767
+ // and the switch happens in the same transaction as the enqueue.
4768
+ const handleSendMessage = async (req, res) => {
4483
4769
  const {
4484
4770
  messageId: clientMessageId,
4485
4771
  clientId,
@@ -4566,7 +4852,90 @@ export function registerMessagesRoutes(app, deps) {
4566
4852
  : (stmts.getRuntimeSessionByConversation.get(conversationId) || null);
4567
4853
  const configuredOpenAI = getOpenAIProviderSettings();
4568
4854
  const requestedProviderType = normalizeRequestedProviderType(providerType || provider);
4569
- const runtimeProviderType = String(existingRuntimeSession?.provider_type || '').trim().toLowerCase();
4855
+ const boundProviderType = String(existingRuntimeSession?.provider_type || '').trim().toLowerCase();
4856
+ // A Claude chat runs on the subscription (`claude`) or the saved API key
4857
+ // (`claude-api`). A send never moves it: the billing route does
4858
+ // (claude-billing-switch-service.mjs). A send that names the other
4859
+ // billing is refused, and so is one from a page that still showed the
4860
+ // other one (`billing`), so nobody's turn runs on a bill they did not
4861
+ // see. A send without `billing` (an older page, an agent) is not checked.
4862
+ const boundClaudeBilling = shouldCreateConversation ? '' : normalizeClaudeBilling(boundProviderType);
4863
+ const billingSwitch = boundClaudeBilling && req.oarBillingSwitch
4864
+ ? { to: normalizeClaudeBilling(req.oarBillingSwitch.to), by: String(req.oarBillingSwitch.by || 'agent') }
4865
+ : null;
4866
+ if (boundClaudeBilling && !billingSwitch) {
4867
+ const namedBilling = normalizeClaudeBilling(requestedProviderType);
4868
+ if (namedBilling && namedBilling !== boundClaudeBilling) {
4869
+ return res.status(409).json({
4870
+ error: `This chat runs on the ${boundClaudeBilling === 'claude-api' ? 'Claude API key' : 'Claude subscription'}. Switch its billing first; a send does not.`,
4871
+ code: 'CLAUDE_BILLING_SWITCH_REQUIRED',
4872
+ conversationId,
4873
+ billing: boundClaudeBilling,
4874
+ });
4875
+ }
4876
+ const shownBilling = normalizeClaudeBilling(req.body?.billing);
4877
+ if (shownBilling && shownBilling !== boundClaudeBilling) {
4878
+ // The text is not lost: it goes into the chat's draft (after what is
4879
+ // there), where every page shows it, so a page's own retry and a
4880
+ // replay from the outbox both leave it for the user to send again.
4881
+ const keptText = String(trimmedText || '').trim();
4882
+ const conversationRow = keptText ? (stmts.getConvAnyStatus?.get?.(conversationId) || null) : null;
4883
+ if (conversationRow) {
4884
+ const draft = String(conversationRow.draft_text || '');
4885
+ if (!draft.includes(keptText)) {
4886
+ const keptDraft = draft.trim() ? `${draft.replace(/\s+$/, '')}\n\n${keptText}` : keptText;
4887
+ const keptAt = new Date().toISOString();
4888
+ db.prepare(`UPDATE conversations SET draft_text = ?, draft_updated_at = ?, draft_updated_by_client_id = NULL WHERE id = ?`)
4889
+ .run(keptDraft, keptAt, conversationId);
4890
+ // The draft's attachments stay what they are: the update must not
4891
+ // clear them on the other pages.
4892
+ let keptAttachments = [];
4893
+ try {
4894
+ const parsed = JSON.parse(String(conversationRow.draft_attachments || '[]'));
4895
+ keptAttachments = Array.isArray(parsed) ? parsed : [];
4896
+ } catch {
4897
+ keptAttachments = [];
4898
+ }
4899
+ io.emit('conversation_draft_updated', {
4900
+ conversationId,
4901
+ draftText: keptDraft,
4902
+ draftAttachments: keptAttachments,
4903
+ draftUpdatedAt: keptAt,
4904
+ draftUpdatedByClientId: null,
4905
+ senderClientId: null,
4906
+ });
4907
+ }
4908
+ }
4909
+ return res.status(409).json({
4910
+ error: `This chat now runs on the ${boundClaudeBilling === 'claude-api' ? 'Claude API key' : 'Claude subscription'}. Send again to confirm.`,
4911
+ code: 'CLAUDE_BILLING_CHANGED',
4912
+ conversationId,
4913
+ billing: boundClaudeBilling,
4914
+ keptInDraft: true,
4915
+ });
4916
+ }
4917
+ }
4918
+ // A switch with the message: the rest of the send resolves the model,
4919
+ // effort and limits against the billing it switches to.
4920
+ const runtimeProviderType = billingSwitch?.to || boundProviderType;
4921
+ // An agent's send to a chat on the key while this month's cap is reached
4922
+ // is refused before anything is stored, so the agent hears it at once (a
4923
+ // page's send is queued and refused at the claim, with its note there).
4924
+ // A send that switches to the key was refused by the switch's own check;
4925
+ // one that names the key for a chat already on it (a switch that changes
4926
+ // nothing) is refused here like any other.
4927
+ if (remoteRequest.remote && (!billingSwitch || billingSwitch.to === 'claude-api') && runtimeProviderType === 'claude-api') {
4928
+ let cap = null;
4929
+ try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
4930
+ if (cap?.reached === true) {
4931
+ return res.status(409).json({
4932
+ 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.`,
4933
+ code: 'CLAUDE_API_CAP_REACHED',
4934
+ conversationId,
4935
+ billing: 'claude-api',
4936
+ });
4937
+ }
4938
+ }
4570
4939
  const runtimeUsesOpenAI = runtimeProviderType === 'openai';
4571
4940
  // Providers that resell each other's model ids ("claude-opus-5" is served by
4572
4941
  // Claude, Copilot and Cursor alike) make a model id useless for deciding
@@ -4582,7 +4951,8 @@ export function registerMessagesRoutes(app, deps) {
4582
4951
  || runtimeProviderType === 'cursor'
4583
4952
  || runtimeProviderType === 'grok'
4584
4953
  || runtimeProviderType === 'claude-cloud'
4585
- || runtimeProviderType === 'deepseek';
4954
+ || runtimeProviderType === 'deepseek'
4955
+ || runtimeProviderType === 'claude-api';
4586
4956
  const configuredOpenAIModel = String(configuredOpenAI?.model || '').trim();
4587
4957
  const availableOpenAIModels = new Set(
4588
4958
  (Array.isArray(configuredOpenAI?.models) ? configuredOpenAI.models : [])
@@ -4653,6 +5023,13 @@ export function registerMessagesRoutes(app, deps) {
4653
5023
  code: 'OPENAI_MODEL_REQUIRES_NEW_CONVERSATION',
4654
5024
  });
4655
5025
  }
5026
+ // A Copilot conversation is bound too, for the model-id guards below only:
5027
+ // Copilot resells Claude and GPT models under ids of its own
5028
+ // ("claude-opus-5.5"), so a Claude-form id another provider lists
5029
+ // ("claude-opus-5-5") is a model Copilot lacks, answered by the Copilot
5030
+ // check further down, never a switch to Cursor, Grok or DeepSeek. (It is
5031
+ // not in runtimeBoundToExplicitProvider: that one also picks the catalog.)
5032
+ const runtimeBoundForModelGuards = runtimeBoundToExplicitProvider || runtimeProviderType === 'github';
4656
5033
  // Cursor conversations are bootstrap-created only, so an existing
4657
5034
  // github/openai/claude conversation cannot cross to the Cursor provider
4658
5035
  // mid-conversation. Cursor settings are read lazily so the hot path stays
@@ -4662,7 +5039,7 @@ export function registerMessagesRoutes(app, deps) {
4662
5039
  let requestTargetsCursor = requestedProviderType === 'cursor';
4663
5040
  if (
4664
5041
  !requestTargetsCursor
4665
- && !runtimeBoundToExplicitProvider
5042
+ && !runtimeBoundForModelGuards
4666
5043
  && requestedOpenAIModel
4667
5044
  && !requestedModelAvailableInGitHub
4668
5045
  ) {
@@ -4687,7 +5064,7 @@ export function registerMessagesRoutes(app, deps) {
4687
5064
  let requestTargetsGrok = requestedProviderType === 'grok';
4688
5065
  if (
4689
5066
  !requestTargetsGrok
4690
- && !runtimeBoundToExplicitProvider
5067
+ && !runtimeBoundForModelGuards
4691
5068
  && requestedOpenAIModel
4692
5069
  && !requestedModelAvailableInGitHub
4693
5070
  ) {
@@ -4713,7 +5090,7 @@ export function registerMessagesRoutes(app, deps) {
4713
5090
  let requestTargetsDeepSeek = requestedProviderType === 'deepseek';
4714
5091
  if (
4715
5092
  !requestTargetsDeepSeek
4716
- && !runtimeBoundToExplicitProvider
5093
+ && !runtimeBoundForModelGuards
4717
5094
  && requestedOpenAIModel
4718
5095
  && !requestedModelAvailableInGitHub
4719
5096
  ) {
@@ -4743,6 +5120,16 @@ export function registerMessagesRoutes(app, deps) {
4743
5120
  code: 'CLAUDE_CLOUD_REQUIRES_NEW_CONVERSATION',
4744
5121
  });
4745
5122
  }
5123
+ // Claude API conversations are bootstrap-created only as well, and asked
5124
+ // for by name only: their model ids are the Claude provider's. Moving a
5125
+ // chat between the subscription and the key is not a send's to make here.
5126
+ const runtimeUsesClaudeApi = runtimeProviderType === 'claude-api';
5127
+ if (!shouldCreateConversation && !runtimeUsesClaudeApi && requestedProviderType === 'claude-api') {
5128
+ return res.status(409).json({
5129
+ error: 'Claude API requires creating a new Claude API conversation',
5130
+ code: 'CLAUDE_API_REQUIRES_NEW_CONVERSATION',
5131
+ });
5132
+ }
4746
5133
  if (
4747
5134
  useOpenAIProvider
4748
5135
  && requestedOpenAIModel
@@ -4790,8 +5177,12 @@ export function registerMessagesRoutes(app, deps) {
4790
5177
  // Claude conversations resolve models against the Claude provider
4791
5178
  // catalog (the Copilot catalog does not know these ids). Per-turn model
4792
5179
  // switching is allowed: each Claude turn is a fresh query() with resume.
4793
- const runtimeUsesClaude = runtimeProviderType === 'claude';
4794
- const configuredClaude = runtimeUsesClaude ? getClaudeProviderSettings() : null;
5180
+ // A Claude API conversation runs the same CLI on an API key: the same
5181
+ // rules, against its own model list and efforts.
5182
+ const runtimeUsesClaude = runtimeProviderType === 'claude' || runtimeUsesClaudeApi;
5183
+ const configuredClaude = runtimeUsesClaude
5184
+ ? (runtimeUsesClaudeApi ? getClaudeApiProviderSettings() : getClaudeProviderSettings())
5185
+ : null;
4795
5186
  const requestedClaudeModel = runtimeUsesClaude ? String(model || '').trim() : '';
4796
5187
  const availableClaudeModels = new Set([
4797
5188
  String(configuredClaude?.model || '').trim(),
@@ -5017,6 +5408,24 @@ export function registerMessagesRoutes(app, deps) {
5017
5408
  error: deepseekModel ? null : 'DeepSeek model is not configured',
5018
5409
  }
5019
5410
  : githubModelResolution)))));
5411
+ // A model Copilot does not list is named with its close matches, as the
5412
+ // bootstrap names it: "claude-opus-5-5" (the Claude form) gets "did you
5413
+ // mean 'claude-opus-5.5'?". Refused before anything is stored.
5414
+ if (!modelResolution.ok && modelResolution === githubModelResolution && explicitModelGiven) {
5415
+ const copilotCheck = checkRequestedProviderModel({
5416
+ requestedModel: requestedOpenAIModel,
5417
+ provider: 'github',
5418
+ models: Array.isArray(githubModelResolution.available) ? githubModelResolution.available : [],
5419
+ });
5420
+ if (copilotCheck.status === 'unknown') {
5421
+ return res.status(400).json({
5422
+ error: unknownProviderModelText({ model: copilotCheck.model, provider: 'github', suggestions: copilotCheck.suggestions, models: copilotCheck.models }),
5423
+ code: 'COPILOT_MODEL_UNAVAILABLE',
5424
+ supportedModels: copilotCheck.models,
5425
+ ...(copilotCheck.suggestions.length ? { suggestedModels: copilotCheck.suggestions } : {}),
5426
+ });
5427
+ }
5428
+ }
5020
5429
  if (!modelResolution.ok) return res.status(400).json({ error: modelResolution.error, supportedModels: modelResolution.available || [] });
5021
5430
  const requestedModel = String(modelResolution.model || '').trim();
5022
5431
  const requestedAutoModel = requestedModel.toLowerCase() === AUTO_MODEL_SENTINEL;
@@ -5331,7 +5740,22 @@ export function registerMessagesRoutes(app, deps) {
5331
5740
  const requestedModelOrigin = deriveModelOrigin(requestedModelVariantId || requestedModel);
5332
5741
  let conversationPreferences;
5333
5742
  let imageOperation = null;
5743
+ // The switch's announcement, made once the send has committed.
5744
+ let announceBillingSwitch = null;
5334
5745
  const persistMessageAndQueue = db.transaction(() => {
5746
+ // First, so the row below is stamped with the new billing; a refused
5747
+ // switch undoes the whole send.
5748
+ if (billingSwitch) {
5749
+ const switched = claudeBillingSwitchService?.switchBilling?.({
5750
+ conversationId: convId,
5751
+ to: billingSwitch.to,
5752
+ by: billingSwitch.by,
5753
+ model: claudeModel,
5754
+ deferAnnounce: true,
5755
+ }) || { ok: false, status: 501, code: 'CLAUDE_BILLING_UNAVAILABLE', error: 'The billing switch is unavailable' };
5756
+ if (!switched.ok) throw Object.assign(new Error(switched.error), { billingRefusal: switched });
5757
+ announceBillingSwitch = switched.announce || null;
5758
+ }
5335
5759
  stmts.insertMsg.run(
5336
5760
  msgId,
5337
5761
  convId,
@@ -5394,7 +5818,7 @@ export function registerMessagesRoutes(app, deps) {
5394
5818
  && claudeModel !== String(existingRuntimeSession?.provider_model || '').trim()
5395
5819
  && typeof stmts.updateRuntimeSessionProvider?.run === 'function'
5396
5820
  ) {
5397
- stmts.updateRuntimeSessionProvider.run('claude', claudeModel, claudeModel, now, runtimeSession.id);
5821
+ stmts.updateRuntimeSessionProvider.run(runtimeUsesClaudeApi ? 'claude-api' : 'claude', claudeModel, claudeModel, now, runtimeSession.id);
5398
5822
  }
5399
5823
  // DeepSeek follows the composer the same way: a relaunched worker reads
5400
5824
  // provider_model, so it has to be the model last used.
@@ -5482,7 +5906,17 @@ export function registerMessagesRoutes(app, deps) {
5482
5906
  });
5483
5907
  try {
5484
5908
  persistMessageAndQueue();
5909
+ try { announceBillingSwitch?.(); } catch {}
5485
5910
  } catch (error) {
5911
+ if (error?.billingRefusal) {
5912
+ const refused = error.billingRefusal;
5913
+ return res.status(refused.status || 409).json({
5914
+ error: refused.error,
5915
+ code: refused.code,
5916
+ conversationId: convId,
5917
+ ...(refused.model ? { model: refused.model } : {}),
5918
+ });
5919
+ }
5486
5920
  // A replayed send (Background Sync outbox) with a caller-supplied id hits
5487
5921
  // the messages.id primary key. Surface it as a clean 409 so the replay
5488
5922
  // can treat "already accepted" as success instead of retrying forever.
@@ -5613,7 +6047,9 @@ export function registerMessagesRoutes(app, deps) {
5613
6047
  messageId: msgId,
5614
6048
  conversationId: convId,
5615
6049
  runtimeSessionId: runtimeSession?.id || null,
5616
- runtimeProviderType: String(runtimeSession?.provider_type || (useOpenAIProvider ? 'openai' : 'github')).trim().toLowerCase(),
6050
+ runtimeProviderType: billingSwitch?.to
6051
+ || String(runtimeSession?.provider_type || (useOpenAIProvider ? 'openai' : 'github')).trim().toLowerCase(),
6052
+ ...(billingSwitch ? { billingSwitched: true } : {}),
5617
6053
  runtimeProviderModel: runtimeSession?.provider_model || (useOpenAIProvider ? openAIModel : null),
5618
6054
  ownerSessionId: ownerSessionId || null,
5619
6055
  preferredRelayMode: conversationPreferences.preferredRelayMode,
@@ -5639,6 +6075,73 @@ export function registerMessagesRoutes(app, deps) {
5639
6075
  }
5640
6076
  : null,
5641
6077
  });
6078
+ };
6079
+ app.post('/api/message', auth, handleSendMessage);
6080
+
6081
+ // POST /api/conversation/:id/billing — move a Claude chat between the
6082
+ // subscription and the saved API key (`to`: "claude" | "claude-api"): the
6083
+ // composer's toggle, the usage-limit note's button (`by: "usage-limit"`)
6084
+ // and an agent's `send` with a provider (`by: "agent"`). With `message`
6085
+ // (the body /api/message takes), the switch and the enqueue are one
6086
+ // transaction: a send that is refused leaves the billing as it was, and a
6087
+ // refused switch sends nothing. Applies from the next turn; a running one
6088
+ // finishes on its billing.
6089
+ app.post('/api/conversation/:id/billing', auth, async (req, res) => {
6090
+ if (!claudeBillingSwitchService) {
6091
+ return res.status(501).json({ ok: false, error: 'The billing switch is unavailable', code: 'CLAUDE_BILLING_UNAVAILABLE' });
6092
+ }
6093
+ const conversationId = String(req.params?.id || '').trim();
6094
+ const to = normalizeClaudeBilling(req.body?.to ?? req.body?.provider);
6095
+ const byValue = String(req.body?.by || '').trim().toLowerCase();
6096
+ const by = ['user', 'agent', 'usage-limit'].includes(byValue) ? byValue : 'user';
6097
+ const message = req.body?.message && typeof req.body.message === 'object' && !Array.isArray(req.body.message)
6098
+ ? req.body.message
6099
+ : null;
6100
+ // Checked first, so a refusal names the switch's reason before the send's.
6101
+ const check = claudeBillingSwitchService.checkSwitch({ conversationId, to, model: message?.model || '' });
6102
+ if (!check.ok) {
6103
+ return res.status(check.status || 409).json({
6104
+ ok: false,
6105
+ error: check.error,
6106
+ code: check.code,
6107
+ conversationId,
6108
+ ...(check.model ? { model: check.model } : {}),
6109
+ });
6110
+ }
6111
+ if (message) {
6112
+ const sendRequest = Object.create(req);
6113
+ sendRequest.body = {
6114
+ ...message,
6115
+ conversationId,
6116
+ newConversation: false,
6117
+ provider: undefined,
6118
+ providerType: undefined,
6119
+ billing: undefined,
6120
+ };
6121
+ sendRequest.oarBillingSwitch = { to: check.to, by };
6122
+ return handleSendMessage(sendRequest, res);
6123
+ }
6124
+ if (by === 'usage-limit' && check.to !== 'claude-api') {
6125
+ return res.status(400).json({ ok: false, error: 'The usage-limit note switches to the API key only', code: 'CLAUDE_BILLING_INVALID' });
6126
+ }
6127
+ const result = claudeBillingSwitchService.switchBilling({ conversationId, to: check.to, by });
6128
+ if (!result?.ok) {
6129
+ return res.status(result?.status || 409).json({
6130
+ ok: false,
6131
+ error: result?.error || 'The billing switch failed',
6132
+ code: result?.code || null,
6133
+ conversationId,
6134
+ ...(result?.model ? { model: result.model } : {}),
6135
+ });
6136
+ }
6137
+ return res.json({
6138
+ ok: true,
6139
+ conversationId,
6140
+ changed: result.changed,
6141
+ provider: result.provider,
6142
+ released: !!result.released,
6143
+ billing: claudeBillingSwitchService.billingState(conversationId),
6144
+ });
5642
6145
  });
5643
6146
 
5644
6147
  app.post('/api/heartbeat', auth, (req, res) => {
@@ -5704,7 +6207,11 @@ export function registerMessagesRoutes(app, deps) {
5704
6207
  || (stored.supported === true) !== (steering.supported === true)
5705
6208
  || storedCancellableIds.join('\n') !== steering.cancellableIds.join('\n')
5706
6209
  // The running CLI's compaction window (context-usage modal).
5707
- || JSON.stringify(stored.autoCompactWindow ?? null) !== JSON.stringify(steering.autoCompactWindow ?? null);
6210
+ || JSON.stringify(stored.autoCompactWindow ?? null) !== JSON.stringify(steering.autoCompactWindow ?? null)
6211
+ // Whether a billing swap may replace the worker now, and why not.
6212
+ || (stored.releasable ?? null) !== (steering.releasable ?? null)
6213
+ || (stored.releaseHold || null) !== (steering.releaseHold || null)
6214
+ || (stored.backgroundTaskCount || 0) !== (steering.backgroundTaskCount || 0);
5708
6215
  if (workerForSteering && changed) {
5709
6216
  sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
5710
6217
  // Pushed rather than left for the clients' status poll: the
@@ -5713,6 +6220,15 @@ export function registerMessagesRoutes(app, deps) {
5713
6220
  io.emit('session_worker_steering', { sdkSessionId: requesterSessionId, steering });
5714
6221
  }
5715
6222
  }
6223
+ // The worker's kind (a Claude chat's billing runs only on its own kind):
6224
+ // recorded when it says one and the registry has another.
6225
+ const heartbeatKind = normalizeWorkerKind(req.body?.workerKind);
6226
+ if (heartbeatKind) {
6227
+ const workerForKind = sessionWorkerRegistry?.getWorker?.(requesterSessionId) || null;
6228
+ if (workerForKind && workerForKind.kind !== heartbeatKind) {
6229
+ sessionWorkerRegistry?.upsertWorker?.({ ...workerForKind, kind: heartbeatKind });
6230
+ }
6231
+ }
5716
6232
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
5717
6233
  // The worker's digest of its live background-task ids against the set
5718
6234
  // held for it (keyed as the worker posts, by its session id). They
@@ -5930,9 +6446,10 @@ export function registerMessagesRoutes(app, deps) {
5930
6446
  return res.status(404).json({ error: 'Runtime session not found for conversation' });
5931
6447
  }
5932
6448
  // The DeepSeek worker runs the same CLI (against DeepSeek) and resumes
5933
- // its sessions the same way.
6449
+ // its sessions the same way; the Claude API worker is the Claude one on
6450
+ // an API key.
5934
6451
  const nativeSessionProvider = String(runtimeSession.provider_type || 'github').trim().toLowerCase();
5935
- if (nativeSessionProvider !== 'claude' && nativeSessionProvider !== 'deepseek') {
6452
+ if (nativeSessionProvider !== 'claude' && nativeSessionProvider !== 'deepseek' && nativeSessionProvider !== 'claude-api') {
5936
6453
  return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
5937
6454
  }
5938
6455
  if (typeof stmts.updateRuntimeSessionClaudeNativeSessionId?.run !== 'function') {
@@ -5974,9 +6491,10 @@ export function registerMessagesRoutes(app, deps) {
5974
6491
  return res.status(404).json({ error: 'Runtime session not found for conversation' });
5975
6492
  }
5976
6493
  // 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).
6494
+ // context and token usage), and so do the DeepSeek and Claude API ones
6495
+ // (the same CLI).
5978
6496
  const contextUsageProvider = String(runtimeSession.provider_type || 'github').trim().toLowerCase();
5979
- if (contextUsageProvider !== 'claude' && contextUsageProvider !== 'claude-cloud' && contextUsageProvider !== 'deepseek') {
6497
+ if (!['claude', 'claude-cloud', 'deepseek', 'claude-api'].includes(contextUsageProvider)) {
5980
6498
  return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
5981
6499
  }
5982
6500
  if (typeof stmts.updateRuntimeSessionContextUsage?.run !== 'function') {
@@ -6057,6 +6575,42 @@ export function registerMessagesRoutes(app, deps) {
6057
6575
  res.json({ ok: true, at: booked.at, models: booked.models.map((entry) => entry.model) });
6058
6576
  });
6059
6577
 
6578
+ // POST /api/claude-api-usage — both Claude workers (the subscription's and
6579
+ // the Claude API one) report, after every turn, the CLI process's own
6580
+ // totals per model with Claude Code's cost estimate (`runCounts`) and the
6581
+ // CLI's session totals (`sessionTotals`). The relay books what turns on
6582
+ // the key cost (claude-api-usage-service.mjs); a subscription report only
6583
+ // keeps the session's mark, against which a resumed run's first result is
6584
+ // measured. What a report ran on is the posting worker's registered kind;
6585
+ // a body that says otherwise is logged as a fence failure.
6586
+ app.post('/api/claude-api-usage', auth, (req, res) => {
6587
+ touchCli();
6588
+ if (!claudeApiUsageService) return res.status(501).json({ error: 'Claude API usage storage is unavailable' });
6589
+ const conversationId = String(req.body?.conversationId || '').trim();
6590
+ if (!conversationId) return res.status(400).json({ error: 'Missing conversationId' });
6591
+ const runtimeSession = stmts.getRuntimeSessionByConversation.get(conversationId);
6592
+ if (!runtimeSession) return res.status(404).json({ error: 'Runtime session not found for conversation' });
6593
+ if (!normalizeClaudeBilling(runtimeSession.provider_type)) {
6594
+ return res.status(409).json({ error: 'Conversation is not a Claude conversation' });
6595
+ }
6596
+ const parsed = normalizeClaudeApiUsageReport(req.body);
6597
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
6598
+ const requesterSid = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
6599
+ const registeredKind = normalizeClaudeBilling(requesterSid ? sessionWorkerRegistry?.getWorker?.(requesterSid)?.kind : '');
6600
+ const statedKind = normalizeClaudeBilling(parsed.report.billing);
6601
+ if (registeredKind && statedKind && registeredKind !== statedKind) {
6602
+ console.warn(`[${ts()}] BILLING FENCE FAILURE usage conv=${conversationId.slice(0, 8)} stated=${statedKind} registered=${registeredKind} — booked as the registered kind`);
6603
+ }
6604
+ const billing = registeredKind || statedKind;
6605
+ if (!billing) return res.status(400).json({ error: 'Missing billing' });
6606
+ const booked = claudeApiUsageService.recordReport({ conversationId, billing, report: parsed.report });
6607
+ if (!booked) return res.status(400).json({ error: 'Missing usable usage metrics' });
6608
+ if (booked.booked.length) {
6609
+ try { checkClaudeApiCap('usage'); } catch {}
6610
+ }
6611
+ res.json({ ok: true, billing: booked.billing, basis: booked.basis, models: booked.booked.map((entry) => entry.model) });
6612
+ });
6613
+
6060
6614
  // POST /api/claude-plan-usage — Claude worker reports the session's
6061
6615
  // structured /usage data (plan rate-limit windows + session cost totals).
6062
6616
  //
@@ -6320,6 +6874,15 @@ export function registerMessagesRoutes(app, deps) {
6320
6874
  });
6321
6875
 
6322
6876
  // GET /api/pending — CLI fetches next pending message
6877
+ // The kind a poller of GET /api/pending is fenced as: the one its worker
6878
+ // stated, else none, as on the socket. A worker of unknown kind gets the
6879
+ // rows of a chat that never switched, and none of a chat that has
6880
+ // (kind-unknown): such a chat waits for a worker that says its kind.
6881
+ function httpPendingWorkerKind(requesterSessionId) {
6882
+ const stated = requesterSessionId ? sessionWorkerRegistry?.getWorker?.(requesterSessionId)?.kind : null;
6883
+ return normalizeWorkerKind(stated) || null;
6884
+ }
6885
+
6323
6886
  app.get('/api/pending', auth, async (req, res) => {
6324
6887
  touchCli();
6325
6888
  const requester = readBridgeIdentity(req);
@@ -6447,6 +7010,12 @@ export function registerMessagesRoutes(app, deps) {
6447
7010
  sessionWorkerSupervisor,
6448
7011
  relayRestartOrchestrator,
6449
7012
  inFlightProcessingCount: Number(counts.processingCount || 0),
7013
+ // No Claude worker polls here; one that does is fenced as on the
7014
+ // socket: by the kind it stated, the key's claim check, and a fenced
7015
+ // row asks for the swap or fails closed as there.
7016
+ workerKind: httpPendingWorkerKind(requesterSessionId),
7017
+ claudeApiClaimCheck,
7018
+ onBillingFence: onClaudeBillingFence,
6450
7019
  telemetry: (payload = {}) => {
6451
7020
  if (!sessionWorkerRoutingEnabled) return;
6452
7021
  emitSessionWorkerTelemetry(payload.event, {
@@ -6664,6 +7233,9 @@ export function registerMessagesRoutes(app, deps) {
6664
7233
  sessionWorkerSupervisor,
6665
7234
  relayRestartOrchestrator,
6666
7235
  inFlightProcessingCount: Number(queueCounts()?.processingCount || 0),
7236
+ workerKind: httpPendingWorkerKind(requesterSessionId),
7237
+ claudeApiClaimCheck,
7238
+ onBillingFence: onClaudeBillingFence,
6667
7239
  });
6668
7240
  if (recoveryDequeue?.message) {
6669
7241
  msg = recoveryDequeue.message;
@@ -7384,7 +7956,12 @@ export function registerMessagesRoutes(app, deps) {
7384
7956
  ? (usageLimitPauseService?.planPause?.({
7385
7957
  terminalError: req.body.terminalError,
7386
7958
  conversationId: targetConversationId,
7387
- providerType: stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type,
7959
+ // What the refused turn ran on (its row's billing), and the chat's
7960
+ // billing of now: a chat switched to the API key meanwhile carries on
7961
+ // there instead of waiting for the subscription's reset.
7962
+ providerType: normalizeClaudeBilling(q?.provider)
7963
+ || stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type,
7964
+ chatProviderType: stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type,
7388
7965
  queueRow: q,
7389
7966
  partialText: req.body?.partialText,
7390
7967
  }) || null)
@@ -7504,6 +8081,7 @@ export function registerMessagesRoutes(app, deps) {
7504
8081
  const conversationProvider = String(
7505
8082
  stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type || '',
7506
8083
  ).trim().toLowerCase() || 'github';
8084
+ const responderKind = sessionWorkerRegistry?.getWorker?.(normalizeSessionWorkerId(responseBridgeIdentity?.sessionId))?.kind || null;
7507
8085
  const executedProvider = resolveExecutedProviderForResponse({
7508
8086
  stmts,
7509
8087
  responseBridgeIdentity,
@@ -7511,13 +8089,28 @@ export function registerMessagesRoutes(app, deps) {
7511
8089
  // Image operations are executed by this server against the conversation's
7512
8090
  // own provider API, then finalized through an identity-less self-post.
7513
8091
  serverExecutedOperation: !!q?.image_operation_id,
8092
+ responderKind,
7514
8093
  });
8094
+ // A Claude chat's row ran on a worker of another billing than it was
8095
+ // stamped with: the delivery fence failed. What ran is recorded.
8096
+ const stampedBilling = normalizeClaudeBilling(q?.provider);
8097
+ const ranBilling = normalizeClaudeBilling(executedProvider);
8098
+ // A claimed turn on the key answered (it passed the claim check): the
8099
+ // next refusal of a self-started turn there is a new episode, with its
8100
+ // own note. A continuation that slipped through before its stop is not.
8101
+ if (ranBilling === 'claude-api' && String(q?.kind || '') !== 'continuation') claudeApiStopLedger.clear(targetConversationId);
8102
+ if (stampedBilling && ranBilling && stampedBilling !== ranBilling) {
8103
+ 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`);
8104
+ }
8105
+ // A Claude chat that switched billing mid-turn finished that turn on the
8106
+ // billing it started with: not a mismatch.
8107
+ const switchedClaudeTurn = ranBilling && normalizeClaudeBilling(conversationProvider) && (!stampedBilling || stampedBilling === ranBilling);
7515
8108
  if (executedProvider === 'unknown') {
7516
8109
  // An identity that names no runtime session is itself an anomaly (a stale
7517
8110
  // or forged header), just not evidence of cross-provider execution. Say so
7518
8111
  // rather than staying silent.
7519
8112
  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) {
8113
+ } else if (executedProvider !== conversationProvider && !switchedClaudeTurn) {
7521
8114
  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
8115
  }
7523
8116
  const responderConversationCheck = resolveResponderConversationMismatch({
@@ -7874,8 +8467,9 @@ export function registerMessagesRoutes(app, deps) {
7874
8467
  // worker (detached background shells) serving both Copilot-CLI providers.
7875
8468
  // A Claude Cloud session can take a turn of its own too: one the agent
7876
8469
  // 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']);
8470
+ // claude.ai; the cloud worker follows it and registers it here. The Claude
8471
+ // API worker is the Claude one on an API key.
8472
+ const CONTINUATION_PROVIDER_TYPES = new Set(['claude', 'github', 'openai', 'claude-cloud', 'claude-api']);
7879
8473
 
7880
8474
  // POST /api/continuation-turn — a session worker's CLI started a turn on its
7881
8475
  // own (a background task's notification). The synthetic queue row gives that
@@ -7928,7 +8522,35 @@ export function registerMessagesRoutes(app, deps) {
7928
8522
  const attemptId = uuidv4();
7929
8523
  const now = new Date().toISOString();
7930
8524
  const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
7931
- try {
8525
+ // A Claude chat's continuation runs on the worker that started it,
8526
+ // whose billing may no longer be the chat's (a switch waiting for this
8527
+ // very background work): stamped with that worker's kind.
8528
+ const continuationBilling = normalizeClaudeBilling(sessionWorkerRegistry?.getWorker?.(ownerSessionId)?.kind);
8529
+ // A turn the CLI starts by itself on the key passes no claim, so the
8530
+ // claim check (the cap, the key, the switch) is asked here: when it
8531
+ // refuses, the row is still minted (the worker needs its id), with a
8532
+ // stop for it that the worker's control poller picks up; the stop's
8533
+ // result stops the worker's background tasks and fails the row.
8534
+ let capStop = null;
8535
+ if ((continuationBilling || normalizeClaudeBilling(providerType)) === 'claude-api') {
8536
+ const refusal = claudeApiContinuationRefusal();
8537
+ if (refusal) {
8538
+ const code = String(refusal.code || 'CLAUDE_API_NOT_CONFIGURED');
8539
+ capStop = {
8540
+ source: CLAUDE_API_CAP_STOP_SOURCE,
8541
+ code,
8542
+ error: String(refusal.error || '').trim() || 'Claude API is unavailable, so this turn did not run.',
8543
+ // Its note says to switch Claude API on rather than to set it up.
8544
+ ...(refusal.switchedOff === true ? { switchedOff: true } : {}),
8545
+ episode: claudeApiStopEpisode(code),
8546
+ };
8547
+ } else {
8548
+ claudeApiStopLedger.clear(conversationId);
8549
+ }
8550
+ }
8551
+ // The row, its stamp and a cap stop commit together (a database without
8552
+ // transactions, a test's stand-in, runs them in order).
8553
+ const insertContinuationRow = () => {
7932
8554
  stmts.insertContinuationQ.run(
7933
8555
  messageId,
7934
8556
  conversationId,
@@ -7945,6 +8567,14 @@ export function registerMessagesRoutes(app, deps) {
7945
8567
  attemptId,
7946
8568
  operationId,
7947
8569
  );
8570
+ if (continuationBilling) stmts.setQueueProvider?.run?.(continuationBilling, messageId);
8571
+ if (capStop) {
8572
+ insertRelayControlRequest.run(uuidv4(), 'abort_turn', conversationId, messageId, ownerSessionId, JSON.stringify(capStop), now, now);
8573
+ }
8574
+ };
8575
+ const insertContinuation = typeof db.transaction === 'function' ? db.transaction(insertContinuationRow) : insertContinuationRow;
8576
+ try {
8577
+ insertContinuation();
7948
8578
  } catch (error) {
7949
8579
  // Lost the race against a concurrent duplicate registration: the unique
7950
8580
  // index on continuation_op_id guarantees the winner's row exists now.
@@ -7954,7 +8584,20 @@ export function registerMessagesRoutes(app, deps) {
7954
8584
  }
7955
8585
  throw error;
7956
8586
  }
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)}` : ''}`);
8587
+ 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' : ''}`);
8588
+ if (capStop) {
8589
+ // Not left to the stop: a turn that ends before the worker's control
8590
+ // poller takes it still gets its tasks stopped and the note.
8591
+ try {
8592
+ const tasks = stopClaudeApiBackgroundTasks(conversationId, ownerSessionId);
8593
+ if (!claudeApiStopLedger.has(conversationId, capStop.episode)) {
8594
+ writeClaudeApiStopNote({ conversationId, relayMode, refusal: capStop, tasks, now });
8595
+ claudeApiStopLedger.note(conversationId, capStop.episode);
8596
+ }
8597
+ } catch (error) {
8598
+ console.warn(`[${ts()}] CLAUDE API STOP conv=${conversationId.slice(0, 8)}: ${error?.message || error}`);
8599
+ }
8600
+ }
7958
8601
  io.emit('message_status', { messageId, conversationId, status: 'processing', kind: 'continuation' });
7959
8602
  io.emit('queue_updated', { continuation: 1 });
7960
8603
  res.json({ ok: true, messageId, conversationId, attemptId });
@@ -8208,7 +8851,7 @@ export function registerMessagesRoutes(app, deps) {
8208
8851
  // The providers whose workers name a shell task's output (a file, a spool
8209
8852
  // or a snapshot). A task of theirs without one comes from a worker that
8210
8853
  // started before the console existed.
8211
- const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok', 'deepseek']);
8854
+ const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok', 'deepseek', 'claude-api']);
8212
8855
  const SUBAGENT_OUTPUT_MESSAGE = TASK_OUTPUT_NONE_MESSAGES.subagent;
8213
8856
 
8214
8857
  // Why a task has no output to show, for the console's `none` answer. The
@@ -8256,7 +8899,7 @@ export function registerMessagesRoutes(app, deps) {
8256
8899
  // 256 KB; with `before` (a startOffset of an earlier answer): the lines
8257
8900
  // ending there. Everything is cleaned (controls out, colours kept,
8258
8901
  // credentials scrubbed) for every reader. Another relay's agent reads it
8259
- // too (remote_relay read_task_output, read permission): no inbound switch,
8902
+ // too (relay_tool read_task_output, read permission): no inbound switch,
8260
8903
  // nothing it reads changes anything.
8261
8904
  app.get('/api/conversation/:conversationId/background-task/:taskId/output', auth, async (req, res) => {
8262
8905
  const conversationId = String(req.params.conversationId || '').trim();
@@ -8947,12 +9590,16 @@ export function registerMessagesRoutes(app, deps) {
8947
9590
  console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=provider-mismatch retry=${Number(q.retry_count || 0)}`);
8948
9591
  return res.json({ ok: true, mismatch: true });
8949
9592
  }
8950
- if (q && q.status === 'processing' && String(req.body?.class || '').trim() === 'steering-held') {
9593
+ const handBackClass = String(req.body?.class || '').trim();
9594
+ if (q && q.status === 'processing' && (handBackClass === 'steering-held' || handBackClass === 'kind-mismatch')) {
8951
9595
  // The worker refused to push the message because its live turn is
8952
9596
  // holding steering (an open question card, a compaction, an adoption).
8953
9597
  // Not a failure: no retry increment, no backoff, and the owner is kept —
8954
9598
  // the same worker takes it again the moment it signals ready, which is
8955
9599
  // when the hold ends, so the message steers into the resumed turn.
9600
+ // `kind-mismatch`: a Claude chat's row reached a worker of the other
9601
+ // billing; back to pending (re-stamped to the chat's billing by the
9602
+ // queue trigger) for the worker the billing swap brings up.
8956
9603
  const result = db.prepare(`
8957
9604
  UPDATE queue
8958
9605
  SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL
@@ -8962,7 +9609,7 @@ export function registerMessagesRoutes(app, deps) {
8962
9609
  settleRelayAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
8963
9610
  settleRelaySubagentAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
8964
9611
  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)}`);
9612
+ console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=${handBackClass} retry=${Number(q.retry_count || 0)}`);
8966
9613
  }
8967
9614
  return res.json({ ok: true, held: true });
8968
9615
  }