@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
@@ -68,6 +68,11 @@ import {
68
68
  outputDirOf,
69
69
  usableOutputPath,
70
70
  } from './task-output-discovery.mjs';
71
+ import {
72
+ checkClaudeSubscriptionInit,
73
+ describeClaudeSubscriptionFailure,
74
+ isKnownApiKeySource,
75
+ } from './claude-subscription-key-check.mjs';
71
76
 
72
77
  /**
73
78
  * Which system-prompt append a relay mode gets (claude-sdk-adapter's
@@ -448,6 +453,13 @@ function isPhantomResult(sdkMessage) {
448
453
  * every live task is stopped via the SDK and the wind-down proceeds through
449
454
  * the normal notification/continuation path.
450
455
  */
456
+
457
+ // A Claude chat's billing named by a delivered row or a worker kind: the
458
+ // subscription ('claude') or the saved API key ('claude-api'); '' otherwise.
459
+ function claudeBillingOf(value) {
460
+ const billing = String(value || '').trim().toLowerCase();
461
+ return billing === 'claude' || billing === 'claude-api' ? billing : '';
462
+ }
451
463
  export function createClaudeSessionRunner({
452
464
  api,
453
465
  sdkSessionId,
@@ -571,7 +583,7 @@ export function createClaudeSessionRunner({
571
583
  // worker asks for a heartbeat, on which the relay learns which process
572
584
  // holds the session and asks for the set again.
573
585
  onBackgroundTaskSetStale = () => {},
574
- // Whether CLI processes carry the relay's `remote_relay` tool
586
+ // Whether CLI processes carry the relay's `relay_tool` tool
575
587
  // (shared/remote-relay-tool-core.mjs). Default: ask the relay.
576
588
  remoteRelayToolGate = null,
577
589
  // Records every sub-agent's frames as its timeline, run-scoped (see
@@ -583,6 +595,16 @@ export function createClaudeSessionRunner({
583
595
  backend = null,
584
596
  dbg = () => {},
585
597
  } = {}) {
598
+ // The backend that maps models, effort, thinking and images into its own
599
+ // terms. A backend on Claude's own models (`claudeModels`, the Claude API
600
+ // backend) brings only its environment, its failures and what it reports:
601
+ // everything model-shaped stays exactly as the Claude worker has it.
602
+ const modelBackend = backend && backend.claudeModels !== true ? backend : null;
603
+ // The kind of worker this runner is part of: 'claude' on the host's login,
604
+ // else the backend's provider ('claude-api', 'deepseek').
605
+ const workerKind = String(backend?.providerType || backend?.id || 'claude').trim().toLowerCase();
606
+ // A relay-announced billing swap is noted once per worker.
607
+ let billingSwapNoted = false;
586
608
  // Neither Claude's plan windows nor its usage limit exist on another backend.
587
609
  const readPlanUsageForTurn = backend && !backend.reportsPlanUsage ? async () => null : readPlanUsageImpl;
588
610
  const timelineRecorder = subagentTimelineRecorder || createSubagentTimelineRecorder({ api, conversationId: sdkSessionId, dbg });
@@ -590,6 +612,9 @@ export function createClaudeSessionRunner({
590
612
  api,
591
613
  dbg,
592
614
  takeWorkflowRuns: () => drainSettledWorkflowRuns(),
615
+ // Both Claude billings report to the Claude API booking; DeepSeek has
616
+ // its own.
617
+ ...(claudeBillingOf(workerKind) ? { billingUsageRoute: '/api/claude-api-usage', billingKind: workerKind } : {}),
593
618
  ...(backend
594
619
  ? {
595
620
  describeFailure: backend.describeFailure,
@@ -597,12 +622,17 @@ export function createClaudeSessionRunner({
597
622
  failureCodePrefix: backend.id || 'deepseek',
598
623
  reportsPlanUsage: backend.reportsPlanUsage !== false,
599
624
  usageRoute: backend.usageRoute || null,
600
- usageModelKey: backend.relayModel,
601
- relayModel: (value) => (value ? backend.relayModel(value) : value),
625
+ ...(modelBackend
626
+ ? {
627
+ usageModelKey: modelBackend.relayModel,
628
+ relayModel: (value) => (value ? modelBackend.relayModel(value) : value),
629
+ }
630
+ : {}),
602
631
  }
603
- : {}),
632
+ // The subscription worker's own refusal: a turn that came up on an API key.
633
+ : { describeFailure: describeClaudeSubscriptionFailure }),
604
634
  });
605
- // `remote_relay` is registered only while this relay has paired remotes.
635
+ // `relay_tool` is registered only while this relay has paired remotes.
606
636
  // Asked once when the worker starts, and again whenever a CLI process winds
607
637
  // down, so a remote paired later reaches the next process. A cold spawn
608
638
  // waits for the pending answer (handlePendingPayload): the tool list is
@@ -706,7 +736,7 @@ export function createClaudeSessionRunner({
706
736
  // Per-message model wins so the composer can switch Claude models between
707
737
  // turns; the conversation's provider model and worker default are fallbacks.
708
738
  const requestedModel = String(message.model || '').trim();
709
- const ownModel = backend ? backend.isModel(requestedModel) : requestedModel.toLowerCase().startsWith('claude-');
739
+ const ownModel = modelBackend ? modelBackend.isModel(requestedModel) : requestedModel.toLowerCase().startsWith('claude-');
710
740
  const perTurnModel = requestedModel.toLowerCase() !== 'auto' && ownModel
711
741
  ? requestedModel
712
742
  : '';
@@ -1821,8 +1851,8 @@ export function createClaudeSessionRunner({
1821
1851
  // A backend's CLI names a sub-agent's model in Claude's terms (the
1822
1852
  // spawn's "opus", a Claude id of a CLI without the alias pins): the
1823
1853
  // badge shows the backend's model that runs it.
1824
- const model = knownModel && backend?.subagentModel
1825
- ? (backend.subagentModel(knownModel, processRef.initModel || processRef.model) || knownModel)
1854
+ const model = knownModel && modelBackend?.subagentModel
1855
+ ? (modelBackend.subagentModel(knownModel, processRef.initModel || processRef.model) || knownModel)
1826
1856
  : knownModel;
1827
1857
  return {
1828
1858
  taskId,
@@ -2460,7 +2490,7 @@ export function createClaudeSessionRunner({
2460
2490
  // set to `auto` published responses with model: null.
2461
2491
  // A backend's CLI names the model in its own terms ("deepseek-flash[1m]").
2462
2492
  const initModel = String(sdkMessage.model || '').trim();
2463
- proc.initModel = (backend && initModel ? backend.relayModel(initModel) : initModel) || proc.initModel;
2493
+ proc.initModel = (modelBackend && initModel ? modelBackend.relayModel(initModel) : initModel) || proc.initModel;
2464
2494
  // A fresh init with no active turn AND nothing queued to deliver is the CLI
2465
2495
  // opening a turn on its own — a background-task continuation ("you will be
2466
2496
  // notified"). The real SDK does NOT emit a user-message replay for these
@@ -2600,7 +2630,7 @@ export function createClaudeSessionRunner({
2600
2630
  // `auto` composer keeps the CLI's own behavior — only the recorded
2601
2631
  // model follows the switch.
2602
2632
  const reportedFallback = String(sdkMessage.fallbackModel || '').trim();
2603
- const fallbackModel = backend && reportedFallback ? backend.relayModel(reportedFallback) : reportedFallback;
2633
+ const fallbackModel = modelBackend && reportedFallback ? modelBackend.relayModel(reportedFallback) : reportedFallback;
2604
2634
  if (fallbackModel) {
2605
2635
  proc.initModel = fallbackModel;
2606
2636
  if (String(sdkMessage.scope || '').trim().toLowerCase() === 'session') {
@@ -3604,10 +3634,10 @@ export function createClaudeSessionRunner({
3604
3634
  const model = resolvePerTurnModel(message);
3605
3635
  // A backend's effort is its own (DeepSeek: low/high/max), and so is its
3606
3636
  // thinking switch (DeepSeek: always on).
3607
- const effort = backend ? backend.effortFor(message.reasoningEffort) : normalizeClaudeEffort(message.reasoningEffort);
3637
+ const effort = modelBackend ? modelBackend.effortFor(message.reasoningEffort) : normalizeClaudeEffort(message.reasoningEffort);
3608
3638
  const autoCompactWindow = normalizeAutoCompactWindow(getAutoCompactWindow());
3609
3639
  const thinkingState = getThinking() || {};
3610
- const thinkingEnabled = backend ? backend.thinkingEnabled(effort) : parseThinkingEnabled(thinkingState.enabled);
3640
+ const thinkingEnabled = modelBackend ? modelBackend.thinkingEnabled(effort) : parseThinkingEnabled(thinkingState.enabled);
3611
3641
  const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
3612
3642
  const attribution = getAttribution() || null;
3613
3643
  const backgroundCommandLimitMs = getBackgroundCommandLimitMs() ?? null;
@@ -3640,7 +3670,11 @@ export function createClaudeSessionRunner({
3640
3670
  appendClass: modeAppendClass(relayMode),
3641
3671
  permissionMode: permissionModeForRelayMode(relayMode),
3642
3672
  // What only a fresh CLI of the backend takes (adaptProcess compares it).
3643
- spawnKey: backend ? backend.spawnKey(message, { model }) : '',
3673
+ spawnKey: modelBackend ? modelBackend.spawnKey(message, { model }) : '',
3674
+ // Whether the API answered since the last result (noteApiTraffic), and
3675
+ // the error a backend's init check ended the process with.
3676
+ trafficSinceResult: false,
3677
+ failClosed: null,
3644
3678
  liveTasks: new Map(),
3645
3679
  knownTasks: new Set(),
3646
3680
  // taskId → the row of a task that left the live set, until its
@@ -3834,6 +3868,17 @@ export function createClaudeSessionRunner({
3834
3868
  let streamError = null;
3835
3869
  try {
3836
3870
  for await (const sdkMessage of processRef.turn) {
3871
+ // A backend that checks the CLI's credential (the Claude API
3872
+ // backend: the key's source on every init) stops the process on a
3873
+ // wrong one before the init is handled: the open turns fail with
3874
+ // its note (cleanupProcess), and nothing more is read.
3875
+ const refused = refuseOnInit(processRef, sdkMessage);
3876
+ if (refused) {
3877
+ streamError = refused;
3878
+ abortController.abort();
3879
+ break;
3880
+ }
3881
+ noteApiTraffic(processRef, sdkMessage);
3837
3882
  await onSdkMessage(processRef, sdkMessage);
3838
3883
  syncDeliveryReadiness();
3839
3884
  }
@@ -3859,6 +3904,42 @@ export function createClaudeSessionRunner({
3859
3904
  return processRef;
3860
3905
  }
3861
3906
 
3907
+ /**
3908
+ * The backend's verdict on a `system/init` (`checkInit`): null to go on, or
3909
+ * the Error that ends the process. `afterTraffic` says whether the turn had
3910
+ * already talked to the API since the last result.
3911
+ */
3912
+ function refuseOnInit(processRef, sdkMessage) {
3913
+ // A backend checks its own credential; the subscription worker checks
3914
+ // that the CLI took no API key (claude-subscription-key-check.mjs).
3915
+ const checkInit = typeof backend?.checkInit === 'function'
3916
+ ? backend.checkInit
3917
+ : (backend ? null : checkClaudeSubscriptionInit);
3918
+ if (typeof checkInit !== 'function') return null;
3919
+ if (sdkMessage?.type !== 'system' || sdkMessage?.subtype !== 'init') return null;
3920
+ if (!backend && sdkMessage.apiKeySource !== undefined && !isKnownApiKeySource(sdkMessage.apiKeySource) && !processRef.unknownKeySourceLogged) {
3921
+ processRef.unknownKeySourceLogged = true;
3922
+ dbg('init names an API key source this relay does not know; the subscription turn goes on', String(sdkMessage.apiKeySource).slice(0, 40));
3923
+ }
3924
+ let refused = null;
3925
+ try {
3926
+ refused = checkInit(sdkMessage, { afterTraffic: processRef.trafficSinceResult === true });
3927
+ } catch (error) {
3928
+ refused = error instanceof Error ? error : new Error(String(error));
3929
+ }
3930
+ if (!refused) return null;
3931
+ processRef.failClosed = refused;
3932
+ dbg('init refused by the backend', refused.code || '', refused.message);
3933
+ return refused;
3934
+ }
3935
+
3936
+ /** Whether the API has answered since the last result: a stream event or an assistant frame. */
3937
+ function noteApiTraffic(processRef, sdkMessage) {
3938
+ const type = sdkMessage?.type;
3939
+ if (type === 'stream_event' || type === 'assistant') processRef.trafficSinceResult = true;
3940
+ else if (type === 'result') processRef.trafficSinceResult = false;
3941
+ }
3942
+
3862
3943
  function expectSettingsReinit() {
3863
3944
  settingsReinitsOwed += 1;
3864
3945
  settingsWindowOpen = true;
@@ -3877,6 +3958,24 @@ export function createClaudeSessionRunner({
3877
3958
  return !proc.suppressingTurn;
3878
3959
  }
3879
3960
 
3961
+ /**
3962
+ * What keeps the live process from being released now, for the relay's
3963
+ * billing swap ('' when nothing does, or there is no process): 'question',
3964
+ * 'compaction', 'turn' or 'background'. The same conditions as
3965
+ * canReleaseForRespawn, named.
3966
+ */
3967
+ function releaseHold() {
3968
+ // A delivered message on its way in (waiting for a draining process or
3969
+ // a cold spawn): a turn, though no process may exist yet.
3970
+ if (admittingDeliveries > 0) return 'turn';
3971
+ if (!proc || proc.closing || proc.aborted) return '';
3972
+ if (proc.pendingControlRequests > 0) return 'question';
3973
+ if (isCompacting() || Date.now() < proc.compactReplayUntil) return 'compaction';
3974
+ if (proc.activeCtx || proc.pendingDelivered.length || proc.suppressingTurn) return 'turn';
3975
+ if (proc.liveTasks.size || isContinuationImminent()) return 'background';
3976
+ return '';
3977
+ }
3978
+
3880
3979
  /**
3881
3980
  * The running CLI's compaction window (the one it was spawned with) and
3882
3981
  * whether background work would block the respawn that applies another
@@ -3900,14 +3999,14 @@ export function createClaudeSessionRunner({
3900
3999
  async function adaptProcess(message, delivery = {}) {
3901
4000
  const relayMode = message.relayMode || 'agent';
3902
4001
  const model = resolvePerTurnModel(message);
3903
- const effort = backend ? backend.effortFor(message.reasoningEffort) : normalizeClaudeEffort(message.reasoningEffort);
4002
+ const effort = modelBackend ? modelBackend.effortFor(message.reasoningEffort) : normalizeClaudeEffort(message.reasoningEffort);
3904
4003
 
3905
4004
  // A backend's effort, and the model its CLI pins Claude Code's aliases
3906
4005
  // to, are read by the CLI at spawn only: a change takes a fresh process
3907
4006
  // on the same session, when nothing lives in the old one. Busy, the
3908
4007
  // delivery runs on the old process (setModel below) and the next
3909
4008
  // turn-opening one tries again.
3910
- if (backend && backend.spawnKey(message, { model }) !== proc.spawnKey && canReleaseForRespawn()) {
4009
+ if (modelBackend && modelBackend.spawnKey(message, { model }) !== proc.spawnKey && canReleaseForRespawn()) {
3911
4010
  gracefulShutdown('backend-spawn');
3912
4011
  await drainClosingProcess(10_000);
3913
4012
  return null;
@@ -3964,14 +4063,14 @@ export function createClaudeSessionRunner({
3964
4063
  const repinAfterFallback = Boolean(proc.modelFallback) && Boolean(model);
3965
4064
  if (model !== proc.model || repinAfterFallback) {
3966
4065
  expectSettingsReinit();
3967
- const cliModel = backend && model ? backend.cliModel(model) : model;
4066
+ const cliModel = modelBackend && model ? modelBackend.cliModel(model) : model;
3968
4067
  await Promise.resolve(proc.turn.setModel?.(cliModel || undefined)).catch((error) => {
3969
4068
  dbg('setModel failed', error?.message || String(error));
3970
4069
  });
3971
4070
  proc.model = model;
3972
4071
  if (model) proc.modelFallback = '';
3973
4072
  }
3974
- if (!backend && effort !== proc.effort) {
4073
+ if (!modelBackend && effort !== proc.effort) {
3975
4074
  // 'ultracode' is not an effortLevel the CLI accepts — it maps to the
3976
4075
  // session-scoped ultracode/enableWorkflows flags (with the effort pinned
3977
4076
  // to the xhigh the flag implies); every other change clears those flags
@@ -4014,7 +4113,7 @@ export function createClaudeSessionRunner({
4014
4113
  const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
4015
4114
  let reassertDisplay = false;
4016
4115
  // A backend's thinking is fixed at spawn (above).
4017
- if (!backend && thinkingEnabled !== proc.thinkingEnabled) {
4116
+ if (!modelBackend && thinkingEnabled !== proc.thinkingEnabled) {
4018
4117
  if (thinkingEnabled === true) {
4019
4118
  // Enabling applies live. The display MUST be re-sent afterwards, in
4020
4119
  // this order: a session spawned with thinking off carries no display
@@ -4145,9 +4244,14 @@ export function createClaudeSessionRunner({
4145
4244
  function steeringState() {
4146
4245
  // A closing/aborted process is about to die — nothing about its last turn
4147
4246
  // should hold the composer of the worker that replaces it.
4247
+ // `releasable`: the relay may replace this worker now (a billing swap):
4248
+ // no CLI process and no delivery on its way in, or a process
4249
+ // canReleaseForRespawn would let go of.
4148
4250
  if (!proc || proc.closing || proc.aborted) {
4251
+ const admitting = admittingDeliveries > 0;
4149
4252
  return {
4150
4253
  turnActive: false, canSteer: false, holdReason: null, messageId: null, supported: true, autoCompactWindow: null,
4254
+ releasable: !admitting, releaseHold: admitting ? 'turn' : null, backgroundTaskCount: 0,
4151
4255
  };
4152
4256
  }
4153
4257
  const ctx = proc.activeCtx || null;
@@ -4175,7 +4279,18 @@ export function createClaudeSessionRunner({
4175
4279
  // `supported` is the worker-advertised opt-in the client's composer reads
4176
4280
  // (the same flag the Copilot SDK worker publishes) — its only steering
4177
4281
  // gate, so every snapshot carries it, idle ones included.
4178
- return { turnActive, canSteer, holdReason, messageId, supported: true, autoCompactWindow };
4282
+ const hold = releaseHold();
4283
+ return {
4284
+ turnActive,
4285
+ canSteer,
4286
+ holdReason,
4287
+ messageId,
4288
+ supported: true,
4289
+ autoCompactWindow,
4290
+ releasable: !hold,
4291
+ releaseHold: hold || null,
4292
+ backgroundTaskCount: proc.liveTasks.size,
4293
+ };
4179
4294
  }
4180
4295
 
4181
4296
  /**
@@ -4200,6 +4315,20 @@ export function createClaudeSessionRunner({
4200
4315
  async function handlePendingPayload(pending) {
4201
4316
  const message = pending?.message || null;
4202
4317
  if (!message) return false;
4318
+ // A Claude chat's row runs only on a worker of its billing: one of the
4319
+ // other kind (a relay that missed a switch) goes back to the queue, with
4320
+ // no retry penalty, for the worker the switch brings up.
4321
+ const deliveredBilling = claudeBillingOf(message.billing || message.providerType);
4322
+ if (deliveredBilling && claudeBillingOf(workerKind) && deliveredBilling !== workerKind) {
4323
+ dbg('delivery of another billing handed back', message.id || '(no id)', `${deliveredBilling} != ${workerKind}`);
4324
+ if (message.id) {
4325
+ await api('POST', '/api/requeue', { messageId: message.id, class: 'kind-mismatch', ...attemptFields(message) }).catch((error) => {
4326
+ if (isStaleAttemptError(error)) dbg('kind-mismatch hand-back refused as stale_attempt', message.id);
4327
+ else dbg('kind-mismatch hand-back failed', message.id, error?.message || String(error));
4328
+ });
4329
+ }
4330
+ return false;
4331
+ }
4203
4332
  // An older relay (the worker updated on disk before the relay restarted)
4204
4333
  // treats the hand-back as a failed delivery — retry, backoff, worker
4205
4334
  // marked errored — so against one the message is pushed the legacy way.
@@ -4216,6 +4345,21 @@ export function createClaudeSessionRunner({
4216
4345
  syncDeliveryReadiness();
4217
4346
  };
4218
4347
  syncDeliveryReadiness();
4348
+ // The relay replaced the previous worker for a billing switch: said once,
4349
+ // on the first message this worker runs.
4350
+ const billingSwap = pending?.settings?.billingSwap;
4351
+ if (billingSwap && typeof billingSwap === 'object' && !billingSwapNoted) {
4352
+ billingSwapNoted = true;
4353
+ const to = claudeBillingOf(billingSwap.to) || workerKind;
4354
+ publisher.postActivity(
4355
+ message,
4356
+ to === 'claude-api'
4357
+ ? 'Restarted the session on the Claude API key (billing switch)'
4358
+ : 'Restarted the session on the Claude subscription (billing switch)',
4359
+ null,
4360
+ { kind: 'billing_switch_respawn', from: claudeBillingOf(billingSwap.from) || null, to },
4361
+ );
4362
+ }
4219
4363
  // This delivery's own record: a window respawn taken for it (one at most).
4220
4364
  const delivery = { windowRespawn: null };
4221
4365
  // Its note goes on this message's row, once, whichever delivery's spawn
@@ -4247,7 +4391,7 @@ export function createClaudeSessionRunner({
4247
4391
  // Awaited only when there is one: a cold spawn with the decision
4248
4392
  // already in pushes synchronously.
4249
4393
  if (proc) await drainClosingProcess();
4250
- // The tool set is fixed per CLI process: let the remote_relay
4394
+ // The tool set is fixed per CLI process: let the relay_tool
4251
4395
  // decision settle first. The worker's first delivery can arrive
4252
4396
  // before its startup GET has answered (seen live: 180 ms after the
4253
4397
  // spawn). ready() never rejects and is bounded by the gate's timeout.
@@ -4279,7 +4423,7 @@ export function createClaudeSessionRunner({
4279
4423
  noteWindowRespawn();
4280
4424
  // A model that takes no image input gets every image by its path.
4281
4425
  const content = buildClaudeUserContent(message, {
4282
- embedImages: backend ? backend.acceptsImages(resolvePerTurnModel(message)) : true,
4426
+ embedImages: modelBackend ? modelBackend.acceptsImages(resolvePerTurnModel(message)) : true,
4283
4427
  });
4284
4428
  for (let attempt = 0; attempt < 2; attempt += 1) {
4285
4429
  const procRef = proc;
@@ -4412,6 +4556,7 @@ export function createClaudeSessionRunner({
4412
4556
  }
4413
4557
 
4414
4558
  return {
4559
+ workerKind,
4415
4560
  handlePendingPayload,
4416
4561
  canAcceptSteering,
4417
4562
  isDeliveryHeld,
@@ -143,6 +143,8 @@ export async function runClaudeSessionWorker({
143
143
  let heartbeatTimer = null;
144
144
  heartbeat = createHeartbeatController({
145
145
  api,
146
+ // What this worker runs on: the relay hands it only rows of that billing.
147
+ workerKind: turnRunner.workerKind,
146
148
  pollMs: HEARTBEAT_MS,
147
149
  getSessionReady: () => true,
148
150
  getHeartbeatTimer: () => heartbeatTimer,
@@ -188,6 +190,7 @@ export async function runClaudeSessionWorker({
188
190
  onServerHello: () => heartbeat.requestPulse(),
189
191
  getSessionId: () => sdkSessionId,
190
192
  getPid: () => process.pid,
193
+ workerKind: turnRunner.workerKind,
191
194
  onDeliver: async (pending, reason) => {
192
195
  dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
193
196
  const deliveredTimeout = Number(pending?.settings?.backgroundTaskTimeoutMs);
@@ -0,0 +1,64 @@
1
+ // The subscription side of the Claude billing check (plan decision 4).
2
+ //
3
+ // A Claude chat on the host's subscription must never run on an API key
4
+ // without being asked: the key's owner would pay. The worker strips every
5
+ // Anthropic credential from the CLI's environment (WP0), but a settings
6
+ // file's `env.ANTHROPIC_API_KEY`, an `apiKeyHelper` or a Console `/login`
7
+ // can still hand Claude Code a key. Its `system/init` names where the
8
+ // credential came from (`apiKeySource`); a turn that comes up on an API key
9
+ // is stopped there, as the Claude API worker stops one that comes up on
10
+ // anything but its key (claude-api-backend.mjs, checkClaudeApiInit). At most
11
+ // the one request that may beat the init has gone out (decision 24).
12
+
13
+ export const CLAUDE_SUBSCRIPTION_KEY_SOURCE_CODE = 'subscription-key-source';
14
+
15
+ // The sources that bill an API key, not the subscription: a key in the
16
+ // environment (a settings file's env block), a helper that prints one, and
17
+ // the key a Console login keeps. Every other source (`none`, `oauth`, ...)
18
+ // lets the turn go on; one the relay does not know is logged.
19
+ export const API_BILLED_KEY_SOURCES = Object.freeze(['ANTHROPIC_API_KEY', 'apiKeyHelper', '/login managed key']);
20
+
21
+ const KNOWN_KEY_SOURCES = Object.freeze([
22
+ ...API_BILLED_KEY_SOURCES,
23
+ 'none',
24
+ 'user',
25
+ 'project',
26
+ 'org',
27
+ 'temporary',
28
+ 'oauth',
29
+ ]);
30
+
31
+ const GUIDANCE = 'Remove ANTHROPIC_API_KEY from the env of the host\'s Claude Code settings (~/.claude/settings.json, '
32
+ + 'or the folder\'s .claude/settings*.json), remove an apiKeyHelper, or sign the Console login out, then send the '
33
+ + 'message again. To run this chat on the API key saved in Settings, switch its billing to API key.';
34
+
35
+ /** Whether a source the CLI named is one the relay knows. */
36
+ export function isKnownApiKeySource(value) {
37
+ return KNOWN_KEY_SOURCES.includes(String(value ?? ''));
38
+ }
39
+
40
+ /**
41
+ * Null when the CLI's `system/init` came up without an API key, else the
42
+ * Error that stops the turn. `afterTraffic`: the turn had already talked to
43
+ * the API, so one request may have gone out on that key.
44
+ */
45
+ export function checkClaudeSubscriptionInit(sdkMessage, { afterTraffic = false } = {}) {
46
+ if (!sdkMessage || sdkMessage.type !== 'system' || sdkMessage.subtype !== 'init') return null;
47
+ const source = typeof sdkMessage.apiKeySource === 'string' ? sdkMessage.apiKeySource : '';
48
+ if (!API_BILLED_KEY_SOURCES.includes(source)) return null;
49
+ const error = new Error(
50
+ `Claude Code took an API key for this subscription turn (source "${source}"), so the turn was stopped: `
51
+ + 'it would have been billed to that key, not the subscription.'
52
+ + (afterTraffic ? ' At most one request may have gone out on that key.' : ''),
53
+ );
54
+ error.code = CLAUDE_SUBSCRIPTION_KEY_SOURCE_CODE;
55
+ error.apiKeySource = source;
56
+ error.guidance = GUIDANCE;
57
+ return error;
58
+ }
59
+
60
+ /** `{ code, guidance }` for a turn the check stopped, else null (the publisher's describeFailure). */
61
+ export function describeClaudeSubscriptionFailure(_errorText, { error = null } = {}) {
62
+ if (error?.code !== CLAUDE_SUBSCRIPTION_KEY_SOURCE_CODE) return null;
63
+ return { code: CLAUDE_SUBSCRIPTION_KEY_SOURCE_CODE, guidance: error.guidance || GUIDANCE };
64
+ }
@@ -10,7 +10,7 @@ import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.m
10
10
  import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
11
11
  import { buildUsageLimitFailure } from '../../shared/claude-usage-limit.mjs';
12
12
  import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
13
- import { claudeSearchTotals, claudeToolRequests, claudeUsageTotals } from './claude-usage-totals.mjs';
13
+ import { claudeBillingTotals, claudeSearchTotals, claudeToolRequests, claudeUsageTotals } from './claude-usage-totals.mjs';
14
14
 
15
15
  const PLAN_BOARD_ACTIONS = [
16
16
  { id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
@@ -72,7 +72,8 @@ export function createClaudeTurnPublisher({
72
72
  dbg = () => {},
73
73
  takeWorkflowRuns = null,
74
74
  // A backend's own reading of a failed request (`{ code, guidance }` or
75
- // null) and its name in the notes; null for Claude.
75
+ // null) and its name in the notes; null for Claude. It gets the error a
76
+ // turn ended with, when there is one, beside its text and status.
76
77
  describeFailure = null,
77
78
  runtimeLabel = 'Claude',
78
79
  // The namespace of a backend's failure codes (DeepSeek: `deepseek`): its
@@ -89,6 +90,12 @@ export function createClaudeTurnPublisher({
89
90
  // The relay's id of a model the backend's CLI names ("deepseek-flash[1m]"
90
91
  // → "deepseek-flash"); identity for Claude, whose `[1m]` is a real tier.
91
92
  relayModel = (value) => value,
93
+ // The Claude API booking (POST /api/claude-api-usage), which both Claude
94
+ // workers report to after every turn, whatever else they report: a chat
95
+ // moves between the subscription and the key and keeps its session.
96
+ // `billingKind` is the worker's kind.
97
+ billingUsageRoute = null,
98
+ billingKind = null,
92
99
  } = {}) {
93
100
  /** `{ code, stableCode }` of a failure code, in the backend's namespace. */
94
101
  function failureCodes(code) {
@@ -108,9 +115,9 @@ export function createClaudeTurnPublisher({
108
115
  return { ...failureCodes(classified.code), guidance: classified.code === 'model_not_found' ? classified.guidance : null };
109
116
  }
110
117
 
111
- function backendFailure(errorText, status = null) {
118
+ function backendFailure(errorText, status = null, error = null) {
112
119
  if (typeof describeFailure !== 'function') return null;
113
- const described = describeFailure(errorText, { status });
120
+ const described = describeFailure(errorText, { status, ...(error ? { error } : {}) });
114
121
  return described?.code ? described : null;
115
122
  }
116
123
 
@@ -223,6 +230,7 @@ export function createClaudeTurnPublisher({
223
230
  * Advisory: a failure here must not disturb the turn's response.
224
231
  */
225
232
  async function publishPlanUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
233
+ if (billingUsageRoute) await publishBillingUsage({ message, state, sdkSessionId, runId, resumedSessionId });
226
234
  if (usageRoute) {
227
235
  await publishBackendUsage({ message, state, sdkSessionId, runId, resumedSessionId });
228
236
  return;
@@ -417,6 +425,110 @@ export function createClaudeTurnPublisher({
417
425
  });
418
426
  }
419
427
 
428
+ // The process's own totals per model with their cost, for the Claude API
429
+ // booking: counted like `run` above, per process and CLI session, with the
430
+ // CLI's cost estimate beside the tokens. A resumed process's first result
431
+ // adds the turn's `usage` (its main agent loop, no cost); the relay books
432
+ // that turn from the session totals the report carries as well.
433
+ const billingRun = { key: '', results: 0, totals: new Map(), lastSession: new Map() };
434
+ const billedStates = new WeakSet();
435
+
436
+ function billingCounts(state, result, { runId, sessionId, resumedSessionId }) {
437
+ const model = result.model || state.responseModel;
438
+ const key = `${runId}\n${sessionId}`;
439
+ if (billingRun.key !== key) {
440
+ billingRun.key = key;
441
+ billingRun.results = 0;
442
+ billingRun.totals = new Map();
443
+ billingRun.lastSession = new Map();
444
+ }
445
+ const firstResultOfRun = billingRun.results === 0;
446
+ const session = claudeBillingTotals(result.modelUsage, { usage: result.usage, model });
447
+ const add = (entries) => {
448
+ for (const entry of entries) {
449
+ const current = billingRun.totals.get(entry.model) || { model: entry.model, hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0, costUnknown: false };
450
+ current.hitTokens += entry.hitTokens;
451
+ current.missTokens += entry.missTokens;
452
+ current.outputTokens += entry.outputTokens;
453
+ current.costUsd += entry.costUsd;
454
+ current.costUnknown = current.costUnknown || entry.costUnknown;
455
+ billingRun.totals.set(entry.model, current);
456
+ }
457
+ };
458
+ if (!billedStates.has(state)) {
459
+ billedStates.add(state);
460
+ const hasModelUsage = Boolean(result.modelUsage) && typeof result.modelUsage === 'object' && !Array.isArray(result.modelUsage);
461
+ const resumed = firstResultOfRun && Boolean(resumedSessionId) && sessionId === resumedSessionId;
462
+ if (!hasModelUsage || resumed) {
463
+ add(claudeBillingTotals(null, { usage: result.usage, model }));
464
+ } else {
465
+ add(session.map((entry) => {
466
+ const last = billingRun.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0 };
467
+ return {
468
+ model: entry.model,
469
+ hitTokens: Math.max(0, entry.hitTokens - last.hitTokens),
470
+ missTokens: Math.max(0, entry.missTokens - last.missTokens),
471
+ outputTokens: Math.max(0, entry.outputTokens - last.outputTokens),
472
+ costUsd: Math.max(0, entry.costUsd - last.costUsd),
473
+ costUnknown: entry.costUnknown,
474
+ };
475
+ }));
476
+ }
477
+ // The baseline only rises: a late result of an older turn below it
478
+ // must not make the next one count twice.
479
+ if (hasModelUsage) {
480
+ for (const entry of session) {
481
+ const last = billingRun.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0 };
482
+ billingRun.lastSession.set(entry.model, {
483
+ hitTokens: Math.max(last.hitTokens, entry.hitTokens),
484
+ missTokens: Math.max(last.missTokens, entry.missTokens),
485
+ outputTokens: Math.max(last.outputTokens, entry.outputTokens),
486
+ costUsd: Math.max(last.costUsd, entry.costUsd),
487
+ });
488
+ }
489
+ }
490
+ billingRun.results += 1;
491
+ }
492
+ return {
493
+ firstResultOfRun,
494
+ runCounts: [...billingRun.totals.values()].map((entry) => ({ ...entry })),
495
+ sessionTotals: session,
496
+ };
497
+ }
498
+
499
+ /**
500
+ * Ship the turn's counts to the Claude API booking: the process's own
501
+ * running totals (`runCounts`) and the CLI's session totals
502
+ * (`sessionTotals`), with the worker's kind, the process (`runId`), the
503
+ * session it resumed, and the turn (`turnId`). Advisory, like the plan usage.
504
+ */
505
+ async function publishBillingUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
506
+ const result = state.result;
507
+ const claudeNativeSessionId = String(result?.sessionId || '').trim();
508
+ if (!billingUsageRoute || !claudeNativeSessionId || !runId) return;
509
+ const { firstResultOfRun, runCounts, sessionTotals } = billingCounts(state, result, {
510
+ runId: String(runId),
511
+ sessionId: claudeNativeSessionId,
512
+ resumedSessionId: String(resumedSessionId || '').trim(),
513
+ });
514
+ if (!runCounts.length && !sessionTotals.length) return;
515
+ await api('POST', billingUsageRoute, {
516
+ conversationId: message.conversationId,
517
+ sdkSessionId,
518
+ claudeNativeSessionId,
519
+ runId: String(runId),
520
+ billing: billingKind || null,
521
+ turnId: String(message.id || '') || null,
522
+ at: new Date().toISOString(),
523
+ firstResultOfRun,
524
+ ...(resumedSessionId ? { resumedSessionId: String(resumedSessionId) } : {}),
525
+ runCounts,
526
+ sessionTotals,
527
+ }).catch((error) => {
528
+ dbg('billing usage publish failed', error?.message || String(error));
529
+ });
530
+ }
531
+
420
532
  /**
421
533
  * Ship what the CLI reported about the subscription's usage limit: the
422
534
  * relay warns before the limit and shows the reset once it is reached.
@@ -670,7 +782,7 @@ export function createClaudeTurnPublisher({
670
782
  });
671
783
  return;
672
784
  }
673
- const described = backendFailure(errorText);
785
+ const described = backendFailure(errorText, null, error);
674
786
  if (described) {
675
787
  await publishResponse(message, {
676
788
  text: `System note: the ${runtimeLabel} turn failed (${errorText}). ${described.guidance}`,