@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
@@ -14,6 +14,7 @@ import {
14
14
  REMOTE_RELAY_LOCAL_ALIAS,
15
15
  REMOTE_RELAY_LOCAL_ID,
16
16
  REMOTE_RELAY_PROVIDERS,
17
+ REMOTE_RELAY_SEND_PROVIDERS,
17
18
  REMOTE_RELAY_SESSION_SETTINGS,
18
19
  formatSessionGroupList,
19
20
  isRemoteRelayApprovalQuestion,
@@ -45,7 +46,7 @@ import {
45
46
  import { RELAY_ASK_KIND, relayQuestionKind } from '../../shared/relay-asks.mjs';
46
47
  import { RELAY_ASK_REMOTE_PATHS, isMissingRelayAskRoute, relayWithoutAsksText } from './relay-ask-link-service.mjs';
47
48
 
48
- // The one place a `remote_relay` tool call is decided and carried out (plan
49
+ // The one place a `relay_tool` tool call is decided and carried out (plan
49
50
  // §5.6). Every provider adapter ends up at POST /api/remote-relays/tool, whose
50
51
  // handler calls dispatch(). In order:
51
52
  //
@@ -114,7 +115,7 @@ const TITLE_MAX = 80;
114
115
 
115
116
  // The provider settings routes a relay serves; github (Copilot) has none with
116
117
  // models — its catalogue is /api/models plus /api/status defaultModel.
117
- const SETTINGS_PROVIDERS = Object.freeze(['claude', 'cursor', 'grok', 'openai', 'claude-cloud', 'deepseek']);
118
+ const SETTINGS_PROVIDERS = Object.freeze(['claude', 'cursor', 'grok', 'openai', 'claude-cloud', 'deepseek', 'claude-api']);
118
119
  // A Claude Cloud session clones a GitHub repository instead of opening a
119
120
  // folder: create_session takes `repo` and `branch` for it.
120
121
  const CLOUD_PROVIDER = REMOTE_RELAY_CLOUD_PROVIDER;
@@ -141,6 +142,7 @@ const CATALOG_PROVIDER_LABELS = Object.freeze({
141
142
  grok: 'grok',
142
143
  'claude-cloud': 'claude-cloud',
143
144
  deepseek: 'deepseek',
145
+ 'claude-api': 'claude-api',
144
146
  });
145
147
  // Worker statuses that mean "working on something right now" (scope active).
146
148
  const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
@@ -1926,7 +1928,7 @@ export function createRemoteRelayDispatcher({
1926
1928
  * When the replay fails too, nobody knows; the error carries the session
1927
1929
  * and message id so the agent checks with wait instead of sending again.
1928
1930
  */
1929
- async function postPrompt(ctx, { session, text, origin, model, mode, effort }) {
1931
+ async function postPrompt(ctx, { session, text, origin, model, mode, effort, billing = '' }) {
1930
1932
  const messageId = String(randomUUID());
1931
1933
  const body = {
1932
1934
  conversationId: session,
@@ -1937,13 +1939,18 @@ export function createRemoteRelayDispatcher({
1937
1939
  ...(mode ? { relayMode: mode } : {}),
1938
1940
  ...(effort ? { reasoningEffort: effort } : {}),
1939
1941
  };
1942
+ // A Claude session moved to another billing with this message: one call,
1943
+ // which switches and enqueues together on the target, or does neither.
1944
+ const post = () => (billing
1945
+ ? postBillingSwitch(ctx, session, { to: billing, by: 'agent', message: body })
1946
+ : call(ctx, 'POST', '/api/message', { body }));
1940
1947
  let sent;
1941
1948
  try {
1942
- sent = await call(ctx, 'POST', '/api/message', { body });
1949
+ sent = await post();
1943
1950
  } catch (error) {
1944
1951
  if (!isTransientRemoteError(error)) throw error;
1945
1952
  try {
1946
- sent = await call(ctx, 'POST', '/api/message', { body });
1953
+ sent = await post();
1947
1954
  } catch (retryError) {
1948
1955
  if (isOwnDuplicateMessage(retryError, messageId)) return { duplicate: false, messageId };
1949
1956
  if (!isTransientRemoteError(retryError)) throw retryError;
@@ -1969,6 +1976,36 @@ export function createRemoteRelayDispatcher({
1969
1976
  };
1970
1977
  }
1971
1978
 
1979
+ /**
1980
+ * POST /api/conversation/:id/billing with a message. A relay older than the
1981
+ * switch answers 404 without a code: nothing was switched or sent. Its
1982
+ * refusals (the billing not set up there, the model, the cap, the
1983
+ * history) keep the remote's words.
1984
+ */
1985
+ async function postBillingSwitch(ctx, session, body) {
1986
+ try {
1987
+ return await call(ctx, 'POST', sessionPath(session, '/billing'), { body });
1988
+ } catch (error) {
1989
+ if (isTransientRemoteError(error)) throw error;
1990
+ const remoteCode = toText(error?.remoteBody?.code);
1991
+ const label = body.to === 'claude-api' ? 'the API key' : 'the subscription';
1992
+ if (Number(error?.status) === 404 && !remoteCode) {
1993
+ throw new DispatchFailure(409, REMOTE_RELAY_ERROR_CODES.unsupported,
1994
+ `Relay "${ctx.relay.name}" cannot switch a session's billing (it runs an OAR from before the switch); nothing was sent. Send without provider to keep the session's billing.`,
1995
+ { session });
1996
+ }
1997
+ const remoteText = toText(error?.remoteBody?.error) || toText(error?.message);
1998
+ const code = remoteCode === 'CLAUDE_BILLING_MODEL_UNAVAILABLE'
1999
+ ? REMOTE_RELAY_ERROR_CODES.unknownModel
2000
+ : (['CLAUDE_API_NOT_CONFIGURED', 'CLAUDE_NOT_CONFIGURED', 'CLAUDE_API_CAP_REACHED'].includes(remoteCode)
2001
+ ? REMOTE_RELAY_ERROR_CODES.providerUnavailable
2002
+ : (Number(error?.status) === 404 ? REMOTE_RELAY_ERROR_CODES.notFound : REMOTE_RELAY_ERROR_CODES.invalidInput));
2003
+ throw new DispatchFailure(Number(error?.status) || 409, code,
2004
+ `Relay "${ctx.relay.name}" did not switch session ${session} to ${label}: ${remoteText || 'refused'}. Nothing was sent.`,
2005
+ { session, ...(remoteCode ? { remoteCode } : {}) });
2006
+ }
2007
+ }
2008
+
1972
2009
  /**
1973
2010
  * What the remote made of the effort: `reported` is the one it bound (the
1974
2011
  * bootstrap's preferredReasoningEffort, the message's
@@ -2090,10 +2127,19 @@ export function createRemoteRelayDispatcher({
2090
2127
  }
2091
2128
  const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
2092
2129
  const provider = toText(runtime.providerType).toLowerCase() || 'github';
2130
+ // `provider` on a send is a Claude session's billing: the subscription
2131
+ // or the API key. The other one switches the session with this message.
2132
+ const billing = toText(args.provider).toLowerCase();
2133
+ if (billing && !REMOTE_RELAY_SEND_PROVIDERS.includes(provider)) {
2134
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
2135
+ `Session ${session} on relay "${ctx.relay.name}" runs on ${provider}: provider on send switches a Claude session's billing only. Nothing was sent.`,
2136
+ { session, provider });
2137
+ }
2138
+ const switchTo = billing && billing !== provider ? billing : '';
2093
2139
  // Checked before any approval card (runRelayAction) when the session
2094
- // could be read there; here otherwise.
2140
+ // could be read there; here otherwise. Against the billing it moves to.
2095
2141
  const settled = args.model
2096
- ? (ctx.settledModel || await settleSendModel(ctx, conversation, args.model))
2142
+ ? (ctx.settledModel || await settleSendModel(ctx, conversation, args.model, { provider: billing }))
2097
2143
  : null;
2098
2144
  const requestedModel = settled?.model || '';
2099
2145
  const modelNote = settled?.note || null;
@@ -2110,16 +2156,25 @@ export function createRemoteRelayDispatcher({
2110
2156
  const requested = normalizeEffort(args.effort) || normalizeEffort(settled?.effort);
2111
2157
  const own = requested ? '' : normalizeEffort(conversation?.preferredReasoningEffort);
2112
2158
  const origin = await buildOrigin(ctx);
2113
- const { sent, choice } = await postPromptWithEffort(ctx, { session, text: args.text, origin, model, mode }, {
2159
+ const { sent, choice } = await postPromptWithEffort(ctx, { session, text: args.text, origin, model, mode, billing: switchTo }, {
2114
2160
  effort: requested || own,
2115
2161
  source: requested ? EFFORT_SOURCES.requested : (own ? EFFORT_SOURCES.remote : ''),
2116
2162
  note: null,
2117
2163
  });
2164
+ const billingNote = switchTo
2165
+ ? `Session ${session} now runs on ${switchTo === 'claude-api' ? 'the API key' : 'the subscription'}, from this message on (a running turn finishes on the old billing).`
2166
+ : '';
2118
2167
  return deliverAndWait('send', ctx, {
2119
2168
  session,
2120
2169
  sent,
2121
2170
  waitSeconds: args.wait_seconds,
2122
- extra: { ...(modelNote ? { modelNote } : {}), ...effortResult(choice) },
2171
+ extra: {
2172
+ ...(modelNote ? { modelNote } : {}),
2173
+ ...(billing && REMOTE_RELAY_SEND_PROVIDERS.includes(provider) ? { provider: switchTo || provider } : {}),
2174
+ ...(switchTo ? { billingSwitched: true } : {}),
2175
+ ...effortResult(choice),
2176
+ ...(billingNote ? { note: [effortResult(choice).note, billingNote].filter(Boolean).join(' ') } : {}),
2177
+ },
2123
2178
  });
2124
2179
  }
2125
2180
 
@@ -2178,9 +2233,11 @@ export function createRemoteRelayDispatcher({
2178
2233
  }
2179
2234
 
2180
2235
  /** settleRequestedModel for a send to `conversation` (its detail payload); an unread catalogue leaves it unchecked. */
2181
- async function settleSendModel(ctx, conversation, requested) {
2236
+ async function settleSendModel(ctx, conversation, requested, { provider: billing = '' } = {}) {
2182
2237
  const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
2183
- const provider = toText(runtime.providerType).toLowerCase() || 'github';
2238
+ const own = toText(runtime.providerType).toLowerCase() || 'github';
2239
+ // A Claude session moving to its other billing takes that billing's list.
2240
+ const provider = billing && REMOTE_RELAY_SEND_PROVIDERS.includes(own) ? billing : own;
2184
2241
  let catalog = null;
2185
2242
  try {
2186
2243
  catalog = await loadCatalog(ctx);
@@ -3711,7 +3768,7 @@ export function createRemoteRelayDispatcher({
3711
3768
  }
3712
3769
  } else {
3713
3770
  const conversation = await call(early, 'GET', sessionPath(args.session), { query: { limit: 1 } });
3714
- settledModel = await settleSendModel(early, conversation, args.model);
3771
+ settledModel = await settleSendModel(early, conversation, args.model, { provider: args.provider });
3715
3772
  }
3716
3773
  } catch (error) {
3717
3774
  if (error instanceof DispatchFailure) {
@@ -3722,6 +3779,18 @@ export function createRemoteRelayDispatcher({
3722
3779
  settledModel = null;
3723
3780
  }
3724
3781
  }
3782
+ // The card names a billing switch only when the send makes one: a
3783
+ // provider that is already the session's billing changes nothing. Read
3784
+ // off the target before anyone is asked; a target that cannot be read
3785
+ // keeps the line (the send itself then answers).
3786
+ let cardArgs = args;
3787
+ if (action === 'send' && args.provider) {
3788
+ try {
3789
+ const conversation = await call({ relay, caller, hops, conversationId, signal }, 'GET', sessionPath(args.session), { query: { limit: 1 } });
3790
+ const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
3791
+ if (toText(runtime.providerType).toLowerCase() === args.provider) cardArgs = { ...args, provider: undefined };
3792
+ } catch {}
3793
+ }
3725
3794
  // What an update_session would change, settled on the target before
3726
3795
  // anyone is asked: an unknown model or group, a busy session or a change
3727
3796
  // to nothing never raises a card.
@@ -3788,7 +3857,7 @@ export function createRemoteRelayDispatcher({
3788
3857
  callerContext: caller,
3789
3858
  relay: relayTarget(relay),
3790
3859
  action,
3791
- args,
3860
+ args: cardArgs,
3792
3861
  summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
3793
3862
  signal,
3794
3863
  agentPrompts: agentPrompts(conversationId).count,
@@ -3810,7 +3879,7 @@ export function createRemoteRelayDispatcher({
3810
3879
  callerContext: caller,
3811
3880
  relay: relayTarget(relay),
3812
3881
  action,
3813
- args,
3882
+ args: cardArgs,
3814
3883
  summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
3815
3884
  signal,
3816
3885
  ...(group ? { group } : {}),
@@ -27,7 +27,7 @@ import { remoteRelayAliases } from '../../shared/remote-relay-mentions.mjs';
27
27
  // remote_relay_inbound_enabled accept prompts from other relays' agents
28
28
  // agent_sessions_enabled this relay's own agents may start and use
29
29
  // sessions on it (the local target; off by default)
30
- // remote_relay_max_wait_seconds the longest wait of one remote_relay call
30
+ // remote_relay_max_wait_seconds the longest wait of one relay_tool call
31
31
  //
32
32
  // Tokens never leave through listPublic(), the socket event or any route; only
33
33
  // the outbound client reads them (list()/get()/resolve() are server-internal).
@@ -311,6 +311,34 @@ export function applyDeepSeekProviderEnvironment(env = {}, {
311
311
  return next;
312
312
  }
313
313
 
314
+ /**
315
+ * Claude API: the Claude worker's runner on an API key from the Claude
316
+ * Console instead of the host's subscription login
317
+ * (server/claude-api-worker/). The key travels as OAR_CLAUDE_API_KEY, through
318
+ * the secret file under tmux, and becomes the CLI's ANTHROPIC_API_KEY only
319
+ * inside its spawn environment (claude-api-backend.mjs). The worker shares the
320
+ * host's Claude Code state directory with the Claude provider: transcripts,
321
+ * resume and task output are found where a Claude chat's are.
322
+ */
323
+ export function applyClaudeApiProviderEnvironment(env = {}, {
324
+ enabled = false,
325
+ model = '',
326
+ apiKey = '',
327
+ } = {}) {
328
+ const next = { ...env };
329
+ if (resolveWorkerKind(next) === 'claude-api') delete next.COPILOT_WEB_RELAY_WORKER_KIND;
330
+ delete next.CLAUDE_API_RELAY_MODEL;
331
+ delete next.OAR_CLAUDE_API_KEY;
332
+ if (!enabled) return next;
333
+ const normalizedApiKey = normalizeText(apiKey);
334
+ if (!normalizedApiKey) throw new Error('claude-api-key-not-configured');
335
+ next.COPILOT_WEB_RELAY_WORKER_KIND = 'claude-api';
336
+ next.OAR_CLAUDE_API_KEY = normalizedApiKey;
337
+ const normalizedModel = normalizeText(model);
338
+ if (normalizedModel) next.CLAUDE_API_RELAY_MODEL = normalizedModel;
339
+ return next;
340
+ }
341
+
314
342
  /**
315
343
  * The Copilot SDK engine: the same `github`/`openai` providers the extension
316
344
  * serves, run as a plain Node worker against the CLI's headless runtime.
@@ -404,6 +432,10 @@ export function isDeepSeekWorkerEnvironment(env = {}) {
404
432
  return resolveWorkerKind(env) === 'deepseek';
405
433
  }
406
434
 
435
+ export function isClaudeApiWorkerEnvironment(env = {}) {
436
+ return resolveWorkerKind(env) === 'claude-api';
437
+ }
438
+
407
439
  export function isCopilotSdkWorkerEnvironment(env = {}) {
408
440
  return resolveWorkerKind(env) === 'copilot-sdk';
409
441
  }
@@ -458,6 +490,16 @@ export function resolveDeepSeekWorkerScriptPath(env = {}) {
458
490
  return path.join(process.cwd(), 'server', 'deepseek-worker', 'deepseek-session-worker.mjs');
459
491
  }
460
492
 
493
+ export function resolveClaudeApiWorkerScriptPath(env = {}) {
494
+ const explicit = normalizeText(env?.COPILOT_WEB_RELAY_CLAUDE_API_WORKER_PATH);
495
+ if (explicit) return explicit;
496
+ const repoRoot = normalizeText(env?.COPILOT_WEB_RELAY_ROOT);
497
+ if (repoRoot) return path.join(repoRoot, 'server', 'claude-api-worker', 'claude-api-session-worker.mjs');
498
+ const serverDir = normalizeText(env?.COPILOT_WEB_RELAY_SERVER_DIR);
499
+ if (serverDir) return path.join(serverDir, 'claude-api-worker', 'claude-api-session-worker.mjs');
500
+ return path.join(process.cwd(), 'server', 'claude-api-worker', 'claude-api-session-worker.mjs');
501
+ }
502
+
461
503
  export function resolveCopilotSdkWorkerScriptPath(env = {}) {
462
504
  const explicit = normalizeText(env?.COPILOT_WEB_RELAY_COPILOT_SDK_WORKER_PATH);
463
505
  if (explicit) return explicit;
@@ -487,6 +529,10 @@ const NODE_WORKER_DESCRIPTORS = Object.freeze({
487
529
  windowsTitle: 'Copilot SDK Worker',
488
530
  }),
489
531
  deepseek: Object.freeze({ resolveScriptPath: resolveDeepSeekWorkerScriptPath, windowsTitle: 'DeepSeek Worker' }),
532
+ 'claude-api': Object.freeze({
533
+ resolveScriptPath: resolveClaudeApiWorkerScriptPath,
534
+ windowsTitle: 'Claude API Worker',
535
+ }),
490
536
  });
491
537
 
492
538
  function resolveNodeWorkerDescriptor(env = {}) {
@@ -525,7 +571,7 @@ function buildPosixWorkerLaunchCommand(targetSessionId, env = {}, extraCliArgs =
525
571
  // Server entry shape (the CLI's own `copilot mcp add` writes the same):
526
572
  // `{ type: 'local', command, args, env, tools: ['*'], timeout }`. `timeout` is
527
573
  // the per-call limit in ms; without it the CLI cuts a tool call off at 180 s,
528
- // shorter than one remote_relay wait.
574
+ // shorter than one relay_tool wait.
529
575
 
530
576
  export function resolveOarMcpServerScriptPath(env = {}, { pathImpl = path } = {}) {
531
577
  const repoRoot = normalizeText(env?.COPILOT_WEB_RELAY_ROOT);
@@ -662,6 +708,7 @@ export const WORKER_SECRET_ENV_VARS = Object.freeze([
662
708
  'CURSOR_API_KEY',
663
709
  'CLAUDE_CODE_OAUTH_TOKEN',
664
710
  'DEEPSEEK_API_KEY',
711
+ 'OAR_CLAUDE_API_KEY',
665
712
  'ANTHROPIC_API_KEY',
666
713
  'ANTHROPIC_AUTH_TOKEN',
667
714
  ]);
@@ -682,6 +729,11 @@ export const WORKER_SECRET_ENV_VARS = Object.freeze([
682
729
  // - The DeepSeek key is put there by its own applier, but the same name could
683
730
  // also sit in the relay's environment (a shell that exports it); it is the
684
731
  // DeepSeek worker's alone either way.
732
+ // - The Claude API key is the Claude API worker's alone, the same way. It
733
+ // travels under a name of the relay's (OAR_CLAUDE_API_KEY), never as
734
+ // ANTHROPIC_API_KEY: the worker's CLI is the only process that gets it under
735
+ // that name (claude-api-backend.mjs), and a host's ANTHROPIC_API_KEY stays
736
+ // what it is below, no worker's.
685
737
  // - A host's ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN are no worker's (an
686
738
  // empty list): Claude Code prefers either to the Claude login, so a Claude
687
739
  // chat would run, and be billed, on a key nobody chose for it, and every
@@ -691,6 +743,7 @@ export const WORKER_SECRET_ENV_VARS = Object.freeze([
691
743
  const WORKER_SECRET_ENV_VAR_KINDS = Object.freeze({
692
744
  CLAUDE_CODE_OAUTH_TOKEN: Object.freeze(['claude', 'claude-cloud']),
693
745
  DEEPSEEK_API_KEY: Object.freeze(['deepseek']),
746
+ OAR_CLAUDE_API_KEY: Object.freeze(['claude-api']),
694
747
  ANTHROPIC_API_KEY: Object.freeze([]),
695
748
  ANTHROPIC_AUTH_TOKEN: Object.freeze([]),
696
749
  });
@@ -867,6 +920,12 @@ export const TMUX_WORKER_EXPORTED_ENV_VARS = Object.freeze([
867
920
  'DEEPSEEK_RELAY_MODEL',
868
921
  'DEEPSEEK_BASE_URL',
869
922
  'DEEPSEEK_CLAUDE_CONFIG_DIR',
923
+ // The Claude API worker's model; its key comes through the file like
924
+ // DeepSeek's. Tests point the worker's CLI at a fake Anthropic API on this
925
+ // machine the way they point the cloud client (shared/anthropic-api.mjs
926
+ // takes a loopback URL only, and so does the worker).
927
+ 'CLAUDE_API_RELAY_MODEL',
928
+ 'OAR_ANTHROPIC_API_BASE_URL',
870
929
  'GROK_RELAY_MODEL',
871
930
  'GROK_CLI_COMMAND',
872
931
  'GROK_ALWAYS_APPROVE',
@@ -1128,7 +1187,7 @@ export async function launchSessionCli({
1128
1187
  // Set per launch below, never inherited: a relay started from inside a
1129
1188
  // worker session would otherwise hand its workers that session's log.
1130
1189
  delete launchSessionEnv[WORKER_LOG_FILE_ENV];
1131
- // The extension engine gets the OAR MCP server (remote_relay + preview) the
1190
+ // The extension engine gets the OAR MCP server (relay_tool + preview) the
1132
1191
  // node workers carry in-process; every launch path appends the same args.
1133
1192
  const extensionCliArgs = resolveNodeWorkerDescriptor(launchSessionEnv)
1134
1193
  ? []
@@ -61,6 +61,16 @@ function normalizeStatus(value) {
61
61
  }
62
62
 
63
63
  const STEERING_HOLD_REASONS = new Set(['question', 'compaction', 'adoption', 'delivery', 'tool', 'other']);
64
+ // What keeps a Claude worker's CLI from being released for a billing swap.
65
+ const STEERING_RELEASE_HOLDS = new Set(['question', 'compaction', 'turn', 'background']);
66
+ const WORKER_KIND_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
67
+
68
+ // The kind a worker says it is ("claude", "claude-api", "deepseek", ...):
69
+ // lower case, short, or null when it did not say.
70
+ export function normalizeWorkerKind(value) {
71
+ const kind = String(value || '').trim().toLowerCase();
72
+ return WORKER_KIND_PATTERN.test(kind) ? kind : null;
73
+ }
64
74
  const STEERING_MESSAGE_ID_MAX_LENGTH = 64;
65
75
  const STEERING_CANCELLABLE_IDS_MAX = 50;
66
76
 
@@ -122,6 +132,15 @@ export function normalizeWorkerSteeringSnapshot(raw) {
122
132
  // Only a snapshot that reports a running CLI carries the key.
123
133
  const autoCompactWindow = normalizeSteeringAutoCompactWindow(raw.autoCompactWindow);
124
134
  if (autoCompactWindow) snapshot.autoCompactWindow = autoCompactWindow;
135
+ // Whether the relay may replace the worker now (a Claude billing swap), and
136
+ // what holds it when not. Only a worker that reports it carries the keys.
137
+ if (typeof raw.releasable === 'boolean') {
138
+ snapshot.releasable = raw.releasable;
139
+ const hold = String(raw.releaseHold || '').trim().toLowerCase();
140
+ snapshot.releaseHold = !raw.releasable && STEERING_RELEASE_HOLDS.has(hold) ? hold : null;
141
+ const tasks = Number(raw.backgroundTaskCount);
142
+ snapshot.backgroundTaskCount = Number.isInteger(tasks) && tasks > 0 ? Math.min(tasks, 999) : 0;
143
+ }
125
144
  return snapshot;
126
145
  }
127
146
 
@@ -140,6 +159,7 @@ function sanitizeState(raw = null) {
140
159
  retryCount: toNonNegativeInt(base.retryCount, 0),
141
160
  lastError: String(base.lastError || '').trim() || null,
142
161
  steering: normalizeWorkerSteeringSnapshot(base.steering),
162
+ kind: normalizeWorkerKind(base.kind),
143
163
  updatedAt: normalizeTimestamp(base.updatedAt, nowIso),
144
164
  createdAt,
145
165
  };
@@ -244,7 +264,14 @@ export function createSessionWorkerRegistry() {
244
264
  // the old entry into every upsert, so without this a crashed worker's
245
265
  // hold outlived it and the composer showed it until a replacement
246
266
  // heartbeated — indefinitely, if none ever did.
247
- if (existing && isWorkerReplacedOrGone(existing, next)) next.steering = null;
267
+ // The kind is the same: a process's own word. An upsert that does not
268
+ // state it keeps the known one, unless the process it described is gone.
269
+ const statesKind = Object.prototype.hasOwnProperty.call(rawState, 'kind');
270
+ if (!statesKind) next.kind = existing?.kind || null;
271
+ if (existing && isWorkerReplacedOrGone(existing, next)) {
272
+ next.steering = null;
273
+ if (!statesKind) next.kind = null;
274
+ }
248
275
  clearIndexesForSession(existing);
249
276
  bySession.set(state.sdkSessionId, next);
250
277
  updateIndexesForSession(next);
@@ -108,6 +108,9 @@ export function createSessionWorkerWebSocketService({
108
108
  onDeliverySendFailed = async () => {},
109
109
  onWorkerSocketClosed = async () => {},
110
110
  requestWork = async () => null,
111
+ // A worker stated its kind ("claude", "claude-api", ...) in a hello or a
112
+ // ready: (sessionId, kind, pid). The registry keeps it.
113
+ onWorkerKind = () => {},
111
114
  // Liveness probe for a socket's worker process. A killed worker's socket
112
115
  // survives its process by an event-loop turn (the kill path blocks the loop,
113
116
  // so this service's timer runs before the socket close lands), and asking for
@@ -233,6 +236,15 @@ export function createSessionWorkerWebSocketService({
233
236
  }
234
237
  }
235
238
 
239
+ // The kind a hello or ready states, kept on the socket (a process does not
240
+ // change kind) and reported once per change.
241
+ function noteWorkerKind(meta, payload) {
242
+ const kind = String(payload?.kind || '').trim().toLowerCase();
243
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(kind) || meta.workerKind === kind) return;
244
+ meta.workerKind = kind;
245
+ try { onWorkerKind(meta.sessionId, kind, meta.pid); } catch {}
246
+ }
247
+
236
248
  function noteHeartbeat(meta, reason = 'worker-message') {
237
249
  if (!meta?.sessionId) return;
238
250
  try {
@@ -258,6 +270,9 @@ export function createSessionWorkerWebSocketService({
258
270
  sessionId: meta.sessionId,
259
271
  pid: meta.pid,
260
272
  reason,
273
+ // What this socket's worker said it is: a Claude chat's row goes only
274
+ // to a worker of its billing.
275
+ workerKind: meta.workerKind || null,
261
276
  });
262
277
  touchCli();
263
278
  if (!pending || typeof pending !== 'object') return false;
@@ -404,6 +419,7 @@ export function createSessionWorkerWebSocketService({
404
419
  if (payload.type === 'worker.hello') {
405
420
  if (!bindSocketIdentity(ws, meta, payload)) return;
406
421
  meta.lastSeenAtMs = nowMsSafe();
422
+ noteWorkerKind(meta, payload);
407
423
  // A worker holding deliveries (open question card, compaction) binds
408
424
  // its identity with hello but must not be handed work.
409
425
  meta.ready = payload.ready !== false;
@@ -423,6 +439,7 @@ export function createSessionWorkerWebSocketService({
423
439
  if (payload.type === 'worker.ready') {
424
440
  if (!bindSocketIdentity(ws, meta, payload)) return;
425
441
  meta.lastSeenAtMs = nowMsSafe();
442
+ noteWorkerKind(meta, payload);
426
443
  meta.ready = true;
427
444
  meta.lastReadyAt = nowIso();
428
445
  noteHeartbeat(meta, String(payload.reason || 'worker-ready'));
@@ -175,10 +175,11 @@ export function createUsageLimitPauseService({
175
175
  * Decide whether a refused turn is paused. Returns the plan, or null when
176
176
  * the response is to be handled as the terminal failure it says it is.
177
177
  */
178
- function planPause({ terminalError, conversationId, providerType, queueRow = null, partialText = '' } = {}) {
178
+ function planPause({ terminalError, conversationId, providerType, chatProviderType = null, queueRow = null, partialText = '' } = {}) {
179
179
  if (!isUsageLimitTerminalError(terminalError)) return null;
180
180
  // Claude sessions, local and in the cloud: both run Claude Code on the
181
- // same account and report the same limit.
181
+ // same account and report the same limit. `providerType` is what the
182
+ // refused turn ran on.
182
183
  if (!['claude', 'claude-cloud'].includes(String(providerType || '').trim().toLowerCase())) return null;
183
184
  if (!String(conversationId || '').trim()) return null;
184
185
  const at = now();
@@ -187,6 +188,21 @@ export function createUsageLimitPauseService({
187
188
  // What the turn had written before the refusal stays above the note.
188
189
  const written = String(partialText || '').trim();
189
190
  const withWritten = (note) => (written ? `${written}\n\n${note}` : note);
191
+ // The chat moved to the API key while this subscription turn ran: its
192
+ // follow-up is not held for the subscription's reset, it runs on the key
193
+ // at once (a row on the key is never held for the usage limit).
194
+ if (String(chatProviderType || '').trim().toLowerCase() === 'claude-api') {
195
+ return {
196
+ conversationId: String(conversationId),
197
+ rateLimitType,
198
+ resetsAt: null,
199
+ resumeAt: null,
200
+ auto: false,
201
+ retries: 0,
202
+ onApiKey: true,
203
+ noteText: withWritten(`⏸ Stopped at the Claude ${label}. This chat now runs on the Claude API key, so the turn carries on there.`),
204
+ };
205
+ }
190
206
  const resetsAtMs = Date.parse(String(terminalError.resetsAt || ''));
191
207
  if (!Number.isFinite(resetsAtMs) || resetsAtMs <= at) {
192
208
  // Counted along the follow-ups: the refused row is one itself when the
@@ -232,7 +248,8 @@ export function createUsageLimitPauseService({
232
248
  function schedulePause(plan, { queueRow = null } = {}) {
233
249
  if (!plan?.conversationId) return null;
234
250
  const at = new Date(now()).toISOString();
235
- const heldUntil = plan.auto ? plan.resumeAt : HELD_UNTIL_RESUMED;
251
+ // On the key: due now, never held.
252
+ const heldUntil = plan.onApiKey ? null : (plan.auto ? plan.resumeAt : HELD_UNTIL_RESUMED);
236
253
  // What this refusal cut off. A refused follow-up stands for what it
237
254
  // quoted; a turn of the CLI's own has no prompt.
238
255
  const refusedFollowUp = parseJson(queueRow?.usage_limit_pause);
@@ -251,6 +268,7 @@ export function createUsageLimitPauseService({
251
268
  resetsAt: plan.resetsAt,
252
269
  auto: plan.auto,
253
270
  retries: Number(plan.retries || 0),
271
+ ...(plan.onApiKey ? { onApiKey: true, resumedAt: at, resumedBy: 'billing-switch' } : {}),
254
272
  pausedAt: at,
255
273
  refusedMessageId: queueRow?.id ? String(queueRow.id) : null,
256
274
  prompts,
@@ -345,8 +363,15 @@ export function createUsageLimitPauseService({
345
363
  } catch {}
346
364
  }
347
365
  const state = getPause(plan.conversationId);
348
- try { logger.log?.(`[usage-limit] conv=${plan.conversationId.slice(0, 8)} paused at the ${usageLimitWindowLabel(plan.rateLimitType)}; ${plan.auto ? `resumes ${plan.resumeAt}` : `resets ${plan.resetsAt}, waits for the user`}`); } catch {}
366
+ try {
367
+ logger.log?.(`[usage-limit] conv=${plan.conversationId.slice(0, 8)} ${plan.onApiKey
368
+ ? `stopped at the ${usageLimitWindowLabel(plan.rateLimitType)}; carries on on the API key`
369
+ : `paused at the ${usageLimitWindowLabel(plan.rateLimitType)}; ${plan.auto ? `resumes ${plan.resumeAt}` : `resets ${plan.resetsAt}, waits for the user`}`}`);
370
+ } catch {}
349
371
  emitPause(plan.conversationId, state);
372
+ if (plan.onApiKey) {
373
+ try { onQueueChanged('usage-limit-on-api-key'); } catch {}
374
+ }
350
375
  return { messageId, created: Boolean(created), state };
351
376
  }
352
377
 
@@ -375,6 +400,31 @@ export function createUsageLimitPauseService({
375
400
  return { resumed: true, messageId: String(row.id), state: null };
376
401
  }
377
402
 
403
+ /**
404
+ * Release the held follow-up because the chat moved to the API key: it
405
+ * runs now, on the key (the billing switch re-stamped it). Called inside
406
+ * the switch's transaction; `announce()` tells the clients once that has
407
+ * committed. Null when nothing is held.
408
+ */
409
+ function releaseForBillingSwitch({ conversationId, by = 'billing-switch' } = {}) {
410
+ const id = String(conversationId || '').trim();
411
+ const row = id ? findPausedRowForConversation.get(id) : null;
412
+ if (!row || !stateFromRow(row)) return null;
413
+ const pause = parseJson(row.usage_limit_pause) || {};
414
+ const at = new Date(now()).toISOString();
415
+ setPause.run(null, JSON.stringify({ ...pause, resumedAt: at, resumedBy: String(by || 'billing-switch') }), row.id);
416
+ const messageId = String(row.id);
417
+ return {
418
+ messageId,
419
+ announce() {
420
+ try { logger.log?.(`[usage-limit] conv=${id.slice(0, 8)} released by the switch to the API key`); } catch {}
421
+ emitPause(id, null);
422
+ try { emit('message_status', { messageId, conversationId: id, status: 'pending' }); } catch {}
423
+ try { onQueueChanged('usage-limit-billing-switch'); } catch {}
424
+ },
425
+ };
426
+ }
427
+
378
428
  /** Drop the held follow-up: the paused turn is not carried on. */
379
429
  function cancel({ conversationId } = {}) {
380
430
  const id = String(conversationId || '').trim();
@@ -436,6 +486,7 @@ export function createUsageLimitPauseService({
436
486
  getPause,
437
487
  listPauses,
438
488
  resumeNow,
489
+ releaseForBillingSwitch,
439
490
  cancel,
440
491
  recordReport,
441
492
  getAccountState: accountState,
@@ -45,6 +45,7 @@
45
45
  // A provider registers its key by one entry in SAVED_PROVIDER_KEY_SETTINGS.
46
46
 
47
47
  import { DEEPSEEK_PROVIDER_TYPE } from '../../shared/deepseek-models.mjs';
48
+ import { CLAUDE_API_KEY_SETTING_KEY, CLAUDE_API_PROVIDER_TYPE } from './claude-api-settings-service.mjs';
48
49
  import { DEEPSEEK_API_KEY_SETTING_KEY } from './deepseek-settings-service.mjs';
49
50
 
50
51
  export const REDACTED_SECRET_TEXT = '[redacted]';
@@ -64,6 +65,7 @@ export const WORKER_SESSION_HEADER = 'x-relay-session-id';
64
65
  /** The providers whose workers get a key saved in the relay's settings. */
65
66
  export const SAVED_PROVIDER_KEY_SETTINGS = Object.freeze([
66
67
  Object.freeze({ provider: DEEPSEEK_PROVIDER_TYPE, settingKey: DEEPSEEK_API_KEY_SETTING_KEY }),
68
+ Object.freeze({ provider: CLAUDE_API_PROVIDER_TYPE, settingKey: CLAUDE_API_KEY_SETTING_KEY }),
67
69
  ]);
68
70
 
69
71
  // JSON bodies are shallow in practice; a deeper one is left as it is below