@oar-sh/oar 0.9.16 → 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 (136) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  3. package/CHANGELOG.md +412 -0
  4. package/README.md +15 -13
  5. package/package.json +2 -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-attachments.mjs +7 -4
  9. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
  11. package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
  12. package/server/claude-worker/claude-session-process.mjs +276 -33
  13. package/server/claude-worker/claude-session-worker-main.mjs +269 -0
  14. package/server/claude-worker/claude-session-worker.mjs +2 -253
  15. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  16. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  17. package/server/claude-worker/claude-turn-publisher.mjs +394 -13
  18. package/server/claude-worker/claude-usage-totals.mjs +161 -0
  19. package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
  20. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  21. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  22. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  23. package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
  24. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  25. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  26. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  27. package/server/db-schema.mjs +96 -1
  28. package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
  29. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  30. package/server/grok-worker/acp-client.mjs +1 -1
  31. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  32. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  33. package/server/mcp/oar-mcp-server.mjs +11 -5
  34. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  35. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  36. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  37. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  38. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  39. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  40. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  41. package/server/migrations/0031-queue-provider.mjs +143 -0
  42. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  43. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  44. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  45. package/server/public/app/activity-replay-state.mjs +4 -3
  46. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  47. package/server/public/app/agent-sessions-settings.mjs +1 -1
  48. package/server/public/app/api-client.js +105 -0
  49. package/server/public/app/bootstrap.js +243 -29
  50. package/server/public/app/claude-api-settings-ui.js +362 -0
  51. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  52. package/server/public/app/claude-billing-toggle.mjs +112 -0
  53. package/server/public/app/context-tier-options.mjs +10 -2
  54. package/server/public/app/context-usage-view.mjs +1 -1
  55. package/server/public/app/conversation-provider-indicator.mjs +18 -0
  56. package/server/public/app/conversation-view.js +31 -3
  57. package/server/public/app/deepseek-settings-ui.js +342 -0
  58. package/server/public/app/inbox-ui.js +14 -3
  59. package/server/public/app/inbox-view.mjs +1 -1
  60. package/server/public/app/journal-view.js +156 -11
  61. package/server/public/app/model-selector-options.mjs +28 -1
  62. package/server/public/app/model-vision-support.mjs +2 -0
  63. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  64. package/server/public/app/no-conversation-state.mjs +1 -1
  65. package/server/public/app/plan-usage-view.mjs +165 -13
  66. package/server/public/app/provider-readiness.mjs +8 -2
  67. package/server/public/app/relay-error-ctas.mjs +58 -0
  68. package/server/public/app/relay-schedules-ui.js +1 -1
  69. package/server/public/app/settings-modal.js +22 -0
  70. package/server/public/app/settings-tabs.js +1 -1
  71. package/server/public/app/socket-handlers.js +28 -1
  72. package/server/public/app/tool-activity-emoji.mjs +4 -3
  73. package/server/public/app/usage-limit-ui.js +136 -5
  74. package/server/public/index.html +596 -35
  75. package/server/relay-tools.md +4 -4
  76. package/server/repositories/conversation-group-repository.mjs +14 -7
  77. package/server/repositories/message-repository.mjs +7 -0
  78. package/server/repositories/session-repository.mjs +8 -1
  79. package/server/routes/ask-user-routes.mjs +1 -1
  80. package/server/routes/conversation-group-routes.mjs +3 -2
  81. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  82. package/server/routes/messages-routes.mjs +831 -36
  83. package/server/routes/relay-ask-routes.mjs +3 -3
  84. package/server/routes/relay-event-routes.mjs +2 -2
  85. package/server/routes/relay-schedule-routes.mjs +1 -1
  86. package/server/routes/remote-relay-routes.mjs +3 -3
  87. package/server/routes/sessions-routes.mjs +670 -63
  88. package/server/server-runtime.mjs +476 -26
  89. package/server/services/about-service.mjs +2 -0
  90. package/server/services/background-work-view.mjs +2 -1
  91. package/server/services/claude-api-settings-service.mjs +575 -0
  92. package/server/services/claude-api-usage-service.mjs +361 -0
  93. package/server/services/claude-billing-swap-service.mjs +274 -0
  94. package/server/services/claude-billing-switch-service.mjs +343 -0
  95. package/server/services/claude-session-leftovers.mjs +226 -0
  96. package/server/services/deepseek-balance-service.mjs +102 -0
  97. package/server/services/deepseek-settings-service.mjs +380 -0
  98. package/server/services/deepseek-usage-import.mjs +471 -0
  99. package/server/services/model-variant-catalog-service.mjs +1 -0
  100. package/server/services/plan-usage-claude-api.mjs +445 -0
  101. package/server/services/plan-usage-contract.mjs +100 -5
  102. package/server/services/plan-usage-deepseek.mjs +931 -0
  103. package/server/services/plan-usage-service.mjs +436 -0
  104. package/server/services/provider-model-selection.mjs +4 -2
  105. package/server/services/provider-reasoning-effort.mjs +24 -2
  106. package/server/services/relay-ask-service.mjs +1 -1
  107. package/server/services/relay-event-service.mjs +3 -2
  108. package/server/services/relay-schedule-service.mjs +1 -1
  109. package/server/services/remote-relay-caller-context.mjs +7 -0
  110. package/server/services/remote-relay-dispatcher.mjs +84 -14
  111. package/server/services/remote-relay-registry-service.mjs +1 -1
  112. package/server/services/session-worker-launch-service.mjs +294 -84
  113. package/server/services/session-worker-registry-service.mjs +28 -1
  114. package/server/services/session-worker-websocket-service.mjs +17 -0
  115. package/server/services/usage-limit-pause-service.mjs +55 -4
  116. package/server/services/worker-secret-redaction.mjs +320 -0
  117. package/shared/anthropic-api.mjs +202 -0
  118. package/shared/context-window-fallbacks.mjs +3 -0
  119. package/shared/deepseek-api.mjs +137 -0
  120. package/shared/deepseek-models.mjs +114 -0
  121. package/shared/deepseek-pricing.mjs +146 -0
  122. package/shared/host-claude-session-env.mjs +101 -0
  123. package/shared/model-id.mjs +7 -0
  124. package/shared/preview-tool-core.mjs +1 -1
  125. package/shared/provider-routing.mjs +5 -3
  126. package/shared/relay-asks.mjs +11 -5
  127. package/shared/relay-events.mjs +2 -2
  128. package/shared/relay-schedules.mjs +2 -2
  129. package/shared/remote-relay-contract.mjs +29 -9
  130. package/shared/remote-relay-tool-core.mjs +23 -6
  131. package/shared/steer-settle-failure.mjs +2 -0
  132. package/shared/worker-runtime/api-client.mjs +2 -2
  133. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  134. package/shared/worker-runtime/heartbeat.mjs +5 -0
  135. package/shared/worker-runtime/worker-log-file.mjs +60 -0
  136. 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,
@@ -532,6 +544,9 @@ export function createClaudeSessionRunner({
532
544
  // by the first turn to open regardless.
533
545
  compactReplayAdoptionMs = 60_000,
534
546
  continuationRetryDelayMs = 500,
547
+ // How long a delivered turn that opens over a finished continuation waits
548
+ // for that continuation's row to register, so its answer publishes first.
549
+ continuationRegistrationWaitMs = 10_000,
535
550
  // Backoff between attempts to save a consumed steer's settle marker (~31 s
536
551
  // in all); after the last one the row fails terminally instead.
537
552
  settleRetryDelaysMs = [1_000, 2_000, 4_000, 8_000, 8_000, 8_000],
@@ -568,21 +583,56 @@ export function createClaudeSessionRunner({
568
583
  // worker asks for a heartbeat, on which the relay learns which process
569
584
  // holds the session and asks for the set again.
570
585
  onBackgroundTaskSetStale = () => {},
571
- // Whether CLI processes carry the relay's `remote_relay` tool
586
+ // Whether CLI processes carry the relay's `relay_tool` tool
572
587
  // (shared/remote-relay-tool-core.mjs). Default: ask the relay.
573
588
  remoteRelayToolGate = null,
574
589
  // Records every sub-agent's frames as its timeline, run-scoped (see
575
590
  // subagent-timeline-recorder.mjs). Injectable for tests.
576
591
  subagentTimelineRecorder = null,
592
+ // Another endpoint for the CLI (../deepseek-worker/deepseek-claude-backend.mjs):
593
+ // its spawn environment, model ids and effort, and what it reports. null is
594
+ // Claude through the host's login.
595
+ backend = null,
577
596
  dbg = () => {},
578
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;
608
+ // Neither Claude's plan windows nor its usage limit exist on another backend.
609
+ const readPlanUsageForTurn = backend && !backend.reportsPlanUsage ? async () => null : readPlanUsageImpl;
579
610
  const timelineRecorder = subagentTimelineRecorder || createSubagentTimelineRecorder({ api, conversationId: sdkSessionId, dbg });
580
611
  const publisher = createClaudeTurnPublisher({
581
612
  api,
582
613
  dbg,
583
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 } : {}),
618
+ ...(backend
619
+ ? {
620
+ describeFailure: backend.describeFailure,
621
+ runtimeLabel: backend.label || 'DeepSeek',
622
+ failureCodePrefix: backend.id || 'deepseek',
623
+ reportsPlanUsage: backend.reportsPlanUsage !== false,
624
+ usageRoute: backend.usageRoute || null,
625
+ ...(modelBackend
626
+ ? {
627
+ usageModelKey: modelBackend.relayModel,
628
+ relayModel: (value) => (value ? modelBackend.relayModel(value) : value),
629
+ }
630
+ : {}),
631
+ }
632
+ // The subscription worker's own refusal: a turn that came up on an API key.
633
+ : { describeFailure: describeClaudeSubscriptionFailure }),
584
634
  });
585
- // `remote_relay` is registered only while this relay has paired remotes.
635
+ // `relay_tool` is registered only while this relay has paired remotes.
586
636
  // Asked once when the worker starts, and again whenever a CLI process winds
587
637
  // down, so a remote paired later reaches the next process. A cold spawn
588
638
  // waits for the pending answer (handlePendingPayload): the tool list is
@@ -591,7 +641,9 @@ export function createClaudeSessionRunner({
591
641
  void remoteRelayGate.refresh();
592
642
  // The subscription's usage limit as the CLI reports it. Runner-level: the
593
643
  // limit is the account's, not the CLI process's.
594
- const usageLimit = createUsageLimitTracker();
644
+ const usageLimit = backend && !backend.reportsUsageLimit
645
+ ? { observe: () => null, classifyResult: () => null, classifyException: () => null }
646
+ : createUsageLimitTracker();
595
647
  const resolveWorkflowSessionDir = resolveWorkflowSessionDirImpl || createDefaultWorkflowSessionDirResolver();
596
648
  let claudeNativeSessionId = '';
597
649
  let proc = null;
@@ -657,13 +709,19 @@ export function createClaudeSessionRunner({
657
709
  // Row id → the latest in-flight /api/queue-consumed POST touching it.
658
710
  const consumedMarkChains = new Map();
659
711
 
660
- async function persistNativeSessionId(conversationId, sessionId) {
712
+ /**
713
+ * `cliStartedAt` (ms) is a backend CLI's spawn time, sent at its first
714
+ * init even when the session id is the known one: the relay records each
715
+ * CLI start of a DeepSeek session to find its shell snapshot on delete.
716
+ */
717
+ async function persistNativeSessionId(conversationId, sessionId, { cliStartedAt = null } = {}) {
661
718
  const normalized = String(sessionId || '').trim();
662
- if (!normalized || normalized === claudeNativeSessionId) return;
719
+ if (!normalized || (normalized === claudeNativeSessionId && !cliStartedAt)) return;
663
720
  try {
664
721
  await api('POST', '/api/claude-native-session', {
665
722
  conversationId,
666
723
  claudeNativeSessionId: normalized,
724
+ ...(cliStartedAt ? { cliStartedAt } : {}),
667
725
  });
668
726
  // Only cache after the server accepted it, so a failed persist is
669
727
  // retried on the next init — resume across worker restarts depends on
@@ -678,7 +736,8 @@ export function createClaudeSessionRunner({
678
736
  // Per-message model wins so the composer can switch Claude models between
679
737
  // turns; the conversation's provider model and worker default are fallbacks.
680
738
  const requestedModel = String(message.model || '').trim();
681
- const perTurnModel = requestedModel.toLowerCase() !== 'auto' && requestedModel.toLowerCase().startsWith('claude-')
739
+ const ownModel = modelBackend ? modelBackend.isModel(requestedModel) : requestedModel.toLowerCase().startsWith('claude-');
740
+ const perTurnModel = requestedModel.toLowerCase() !== 'auto' && ownModel
682
741
  ? requestedModel
683
742
  : '';
684
743
  return perTurnModel
@@ -813,6 +872,12 @@ export function createClaudeSessionRunner({
813
872
  modelUsage: null,
814
873
  },
815
874
  planBoardPosted: false,
875
+ // The CLI process the turn ran in, as its usage report names it: taken
876
+ // from the process here and again when the context is made active, and
877
+ // read from the context at finalize — a late continuation flush
878
+ // finalizes after the process died, when `proc` is null or a newer one.
879
+ runId: proc?.runId || '',
880
+ resumedSessionId: proc?.resumedSessionId || '',
816
881
  // Set only on a context attached by post-compaction adoption, and only
817
882
  // until that turn produces output — see handBackAdoptedContext.
818
883
  adoptedFromCompaction: false,
@@ -846,6 +911,8 @@ export function createClaudeSessionRunner({
846
911
 
847
912
  function activateContext(ctx) {
848
913
  proc.activeCtx = ctx;
914
+ ctx.runId = proc.runId || '';
915
+ ctx.resumedSessionId = proc.resumedSessionId || '';
849
916
  proc.lastBoundary = null;
850
917
  proc.continuationInitPending = false;
851
918
  proc.notificationPendingAt = 0;
@@ -1173,7 +1240,7 @@ export function createClaudeSessionRunner({
1173
1240
  }
1174
1241
 
1175
1242
  /** Wait until a continuation context has its relay queue row (or gave up). */
1176
- async function waitForContextRegistration(ctx, timeoutMs = 10_000) {
1243
+ async function waitForContextRegistration(ctx, timeoutMs = continuationRegistrationWaitMs) {
1177
1244
  const deadline = Date.now() + timeoutMs;
1178
1245
  while (!ctx.registered && !ctx.discarded && Date.now() < deadline) {
1179
1246
  await new Promise((resolve) => setTimeout(resolve, 50));
@@ -1432,9 +1499,13 @@ export function createClaudeSessionRunner({
1432
1499
  ctx.finalized = true;
1433
1500
  controlPoller?.stop?.(ctx.controlState);
1434
1501
  // A late continuation flush can land after the process died; usage reads
1435
- // and model fallbacks must survive that.
1502
+ // and model fallbacks must survive that. The process the report names is
1503
+ // the context's own (set when it was made active), never the one that
1504
+ // happens to be current now.
1436
1505
  const turnRef = proc?.turn || null;
1437
1506
  const procModel = proc?.model || null;
1507
+ const runId = ctx.runId || '';
1508
+ const resumedSessionId = ctx.resumedSessionId || '';
1438
1509
  // Steers pushed into this turn and still unsettled: the CLI has their
1439
1510
  // prompts, so the relay marks them consumed with this turn's commit and
1440
1511
  // never re-runs them even if this worker dies before settling them.
@@ -1461,12 +1532,12 @@ export function createClaudeSessionRunner({
1461
1532
  await publisher.publishFinalStream(ctx.message, ctx.state.lastStreamedText);
1462
1533
  const [contextUsage, planUsage] = await Promise.all([
1463
1534
  readContextUsageImpl(turnRef, dbg),
1464
- readPlanUsageImpl(turnRef, dbg),
1535
+ readPlanUsageForTurn(turnRef, dbg),
1465
1536
  ]);
1466
1537
  ctx.state.contextUsage = contextUsage;
1467
1538
  ctx.state.planUsage = planUsage;
1468
1539
  await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model: procModel, sdkSessionId });
1469
- await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId });
1540
+ await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId, runId, resumedSessionId });
1470
1541
  })().catch((error) => dbg('stopped-turn publish failed', error?.message || String(error)));
1471
1542
  lingerStoppedRow(ctx, publishChain);
1472
1543
  closeContext(ctx, true);
@@ -1478,13 +1549,13 @@ export function createClaudeSessionRunner({
1478
1549
  // indicator updates with each reply.
1479
1550
  const [contextUsage, planUsage] = await Promise.all([
1480
1551
  readContextUsageImpl(turnRef, dbg),
1481
- readPlanUsageImpl(turnRef, dbg),
1552
+ readPlanUsageForTurn(turnRef, dbg),
1482
1553
  ]);
1483
1554
  ctx.state.contextUsage = contextUsage;
1484
1555
  ctx.state.planUsage = planUsage;
1485
1556
  const responseModel = ctx.state.responseModel || procModel || null;
1486
1557
  await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model: procModel, sdkSessionId });
1487
- await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId });
1558
+ await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId, runId, resumedSessionId });
1488
1559
 
1489
1560
  if (ctx.state.result?.isError) {
1490
1561
  await publisher.publishErrorResult({ message: ctx.message, state: ctx.state, responseModel, consumedSteerIds });
@@ -1511,10 +1582,17 @@ export function createClaudeSessionRunner({
1511
1582
  }
1512
1583
  // The transport is gone, so nothing new can be read — but usage already
1513
1584
  // captured on this context (a result seen before the stream died) still
1514
- // reaches the relay. Best-effort by definition of this path.
1585
+ // reaches the relay, under the process the context ran in. Best-effort by
1586
+ // definition of this path.
1515
1587
  if (ctx.state.contextUsage || ctx.state.planUsage) {
1516
1588
  await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model, sdkSessionId }).catch(() => {});
1517
- await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId }).catch(() => {});
1589
+ await publisher.publishPlanUsage({
1590
+ message: ctx.message,
1591
+ state: ctx.state,
1592
+ sdkSessionId,
1593
+ runId: ctx.runId || '',
1594
+ resumedSessionId: ctx.resumedSessionId || '',
1595
+ }).catch(() => {});
1518
1596
  }
1519
1597
  if (ctx.interrupted || aborted) {
1520
1598
  await publisher.publishFinalStream(ctx.message, ctx.state.lastStreamedText);
@@ -1769,7 +1847,13 @@ export function createClaudeSessionRunner({
1769
1847
  const agentLike = SUBAGENT_TASK_TYPES.has(String(task.taskType || ''));
1770
1848
  const pinnedModel = (task.toolUseId && processRef.subagentSpawns.get(task.toolUseId)) || '';
1771
1849
  const activity = (task.toolUseId && processRef.subagentActivity.get(task.toolUseId)) || {};
1772
- const model = agentLike ? (activity.model || pinnedModel || processRef.initModel || null) : null;
1850
+ const knownModel = agentLike ? (activity.model || pinnedModel || processRef.initModel || null) : null;
1851
+ // A backend's CLI names a sub-agent's model in Claude's terms (the
1852
+ // spawn's "opus", a Claude id of a CLI without the alias pins): the
1853
+ // badge shows the backend's model that runs it.
1854
+ const model = knownModel && modelBackend?.subagentModel
1855
+ ? (modelBackend.subagentModel(knownModel, processRef.initModel || processRef.model) || knownModel)
1856
+ : knownModel;
1773
1857
  return {
1774
1858
  taskId,
1775
1859
  taskType: task.taskType,
@@ -2393,7 +2477,9 @@ export function createClaudeSessionRunner({
2393
2477
  // init lands, and skipping that init settled the message with a pointer
2394
2478
  // and dropped its real answer.
2395
2479
  if (!firstInit && proc.pendingDelivered.length) turnStartedForWaitingDeliveries();
2396
- persistNativeSessionId(sdkSessionId, sdkMessage.session_id).catch(() => {});
2480
+ persistNativeSessionId(sdkSessionId, sdkMessage.session_id, {
2481
+ cliStartedAt: backend && firstInit ? proc.spawnedAt : null,
2482
+ }).catch(() => {});
2397
2483
  // Tracked on the process too (not only through the persist round-trip):
2398
2484
  // the workflow poller needs the native id to find the session dir even
2399
2485
  // when the persist call is still in flight or failed.
@@ -2402,7 +2488,9 @@ export function createClaudeSessionRunner({
2402
2488
  // normalizer never sees it — capture the CLI's actual model here and
2403
2489
  // seed it into every context at activation. Without this, a composer
2404
2490
  // set to `auto` published responses with model: null.
2405
- proc.initModel = String(sdkMessage.model || '').trim() || proc.initModel;
2491
+ // A backend's CLI names the model in its own terms ("deepseek-flash[1m]").
2492
+ const initModel = String(sdkMessage.model || '').trim();
2493
+ proc.initModel = (modelBackend && initModel ? modelBackend.relayModel(initModel) : initModel) || proc.initModel;
2406
2494
  // A fresh init with no active turn AND nothing queued to deliver is the CLI
2407
2495
  // opening a turn on its own — a background-task continuation ("you will be
2408
2496
  // notified"). The real SDK does NOT emit a user-message replay for these
@@ -2541,7 +2629,8 @@ export function createClaudeSessionRunner({
2541
2629
  // the composer pinned a model (adaptProcess repins via setModel). An
2542
2630
  // `auto` composer keeps the CLI's own behavior — only the recorded
2543
2631
  // model follows the switch.
2544
- const fallbackModel = String(sdkMessage.fallbackModel || '').trim();
2632
+ const reportedFallback = String(sdkMessage.fallbackModel || '').trim();
2633
+ const fallbackModel = modelBackend && reportedFallback ? modelBackend.relayModel(reportedFallback) : reportedFallback;
2545
2634
  if (fallbackModel) {
2546
2635
  proc.initModel = fallbackModel;
2547
2636
  if (String(sdkMessage.scope || '').trim().toLowerCase() === 'session') {
@@ -3522,10 +3611,7 @@ export function createClaudeSessionRunner({
3522
3611
  }
3523
3612
  continue;
3524
3613
  }
3525
- await finalizeContextOnStreamEnd(ctx, {
3526
- aborted: processRef.aborted,
3527
- model: processRef.model,
3528
- }).catch(() => {});
3614
+ await finalizeContextOnStreamEnd(ctx, { aborted: processRef.aborted, model: processRef.model }).catch(() => {});
3529
3615
  }
3530
3616
  for (const entry of stoppedSteers) {
3531
3617
  await settleFoldedSteeredEntry(entry).catch((error) => {
@@ -3546,10 +3632,12 @@ export function createClaudeSessionRunner({
3546
3632
  if (missingFolder) throw missingFolder;
3547
3633
  const relayMode = message.relayMode || 'agent';
3548
3634
  const model = resolvePerTurnModel(message);
3549
- const effort = normalizeClaudeEffort(message.reasoningEffort);
3635
+ // A backend's effort is its own (DeepSeek: low/high/max), and so is its
3636
+ // thinking switch (DeepSeek: always on).
3637
+ const effort = modelBackend ? modelBackend.effortFor(message.reasoningEffort) : normalizeClaudeEffort(message.reasoningEffort);
3550
3638
  const autoCompactWindow = normalizeAutoCompactWindow(getAutoCompactWindow());
3551
3639
  const thinkingState = getThinking() || {};
3552
- const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
3640
+ const thinkingEnabled = modelBackend ? modelBackend.thinkingEnabled(effort) : parseThinkingEnabled(thinkingState.enabled);
3553
3641
  const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
3554
3642
  const attribution = getAttribution() || null;
3555
3643
  const backgroundCommandLimitMs = getBackgroundCommandLimitMs() ?? null;
@@ -3557,6 +3645,13 @@ export function createClaudeSessionRunner({
3557
3645
  const processRef = {
3558
3646
  turn: null,
3559
3647
  abortController,
3648
+ // Names this CLI process in its usage reports: the relay tells a
3649
+ // report that fell behind one of the same process from a process
3650
+ // that started counting over.
3651
+ runId: randomUUID(),
3652
+ // When the CLI was spawned, reported with its first init (see
3653
+ // persistNativeSessionId).
3654
+ spawnedAt: Date.now(),
3560
3655
  model,
3561
3656
  relayMode,
3562
3657
  effort,
@@ -3574,6 +3669,12 @@ export function createClaudeSessionRunner({
3574
3669
  thinkingBudget: null,
3575
3670
  appendClass: modeAppendClass(relayMode),
3576
3671
  permissionMode: permissionModeForRelayMode(relayMode),
3672
+ // What only a fresh CLI of the backend takes (adaptProcess compares it).
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,
3577
3678
  liveTasks: new Map(),
3578
3679
  knownTasks: new Set(),
3579
3680
  // taskId → the row of a task that left the live set, until its
@@ -3602,6 +3703,8 @@ export function createClaudeSessionRunner({
3602
3703
  // from `resume`, updated on init) — the workflow poller's key into
3603
3704
  // `~/.claude/projects`.
3604
3705
  nativeSessionId: '',
3706
+ // The session the process was spawned to resume ('' for a fresh one).
3707
+ resumedSessionId: '',
3605
3708
  // tool_use id → explicitly pinned model of a subagent spawn block; the
3606
3709
  // task panel's model column resolves through this at publish time.
3607
3710
  subagentSpawns: new Map(),
@@ -3721,6 +3824,10 @@ export function createClaudeSessionRunner({
3721
3824
 
3722
3825
  const resume = String(resumeOverride || message.claudeNativeSessionId || claudeNativeSessionId || '').trim();
3723
3826
  processRef.nativeSessionId = resume;
3827
+ // The session this process continues, for its usage reports: the CLI's
3828
+ // first result then carries that session's earlier totals (see
3829
+ // claude-turn-publisher.mjs, runningCounts).
3830
+ processRef.resumedSessionId = resume;
3724
3831
  // The CLI resolves `resume` inside the project directory for *this* CWD, so
3725
3832
  // a session whose workspace root changed has to bring its transcript along
3726
3833
  // or every turn from here on fails with "No conversation found".
@@ -3752,6 +3859,7 @@ export function createClaudeSessionRunner({
3752
3859
  || '',
3753
3860
  ),
3754
3861
  remoteRelayTool: remoteRelayGate.isEnabled(),
3862
+ backend,
3755
3863
  dbg,
3756
3864
  });
3757
3865
  processRef.lifecycleTimer = setInterval(() => evaluateLifecycle(), lifecyclePollMs);
@@ -3760,6 +3868,17 @@ export function createClaudeSessionRunner({
3760
3868
  let streamError = null;
3761
3869
  try {
3762
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);
3763
3882
  await onSdkMessage(processRef, sdkMessage);
3764
3883
  syncDeliveryReadiness();
3765
3884
  }
@@ -3773,6 +3892,11 @@ export function createClaudeSessionRunner({
3773
3892
  try {
3774
3893
  processRef.turn.endInput?.();
3775
3894
  } catch {}
3895
+ // The CLI refused a backend's live flag settings (its key and
3896
+ // endpoint pins, claude-sdk-adapter.mjs): nothing was sent, and the
3897
+ // SDK aborted the query, which reads like a stop. The open turns fail
3898
+ // with that note instead.
3899
+ if (!streamError && processRef.turn?.liveFlagSettingsError) streamError = processRef.turn.liveFlagSettingsError;
3776
3900
  await cleanupProcess(processRef, streamError);
3777
3901
  }
3778
3902
  })();
@@ -3780,6 +3904,42 @@ export function createClaudeSessionRunner({
3780
3904
  return processRef;
3781
3905
  }
3782
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
+
3783
3943
  function expectSettingsReinit() {
3784
3944
  settingsReinitsOwed += 1;
3785
3945
  settingsWindowOpen = true;
@@ -3798,6 +3958,24 @@ export function createClaudeSessionRunner({
3798
3958
  return !proc.suppressingTurn;
3799
3959
  }
3800
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
+
3801
3979
  /**
3802
3980
  * The running CLI's compaction window (the one it was spawned with) and
3803
3981
  * whether background work would block the respawn that applies another
@@ -3821,7 +3999,18 @@ export function createClaudeSessionRunner({
3821
3999
  async function adaptProcess(message, delivery = {}) {
3822
4000
  const relayMode = message.relayMode || 'agent';
3823
4001
  const model = resolvePerTurnModel(message);
3824
- const effort = normalizeClaudeEffort(message.reasoningEffort);
4002
+ const effort = modelBackend ? modelBackend.effortFor(message.reasoningEffort) : normalizeClaudeEffort(message.reasoningEffort);
4003
+
4004
+ // A backend's effort, and the model its CLI pins Claude Code's aliases
4005
+ // to, are read by the CLI at spawn only: a change takes a fresh process
4006
+ // on the same session, when nothing lives in the old one. Busy, the
4007
+ // delivery runs on the old process (setModel below) and the next
4008
+ // turn-opening one tries again.
4009
+ if (modelBackend && modelBackend.spawnKey(message, { model }) !== proc.spawnKey && canReleaseForRespawn()) {
4010
+ gracefulShutdown('backend-spawn');
4011
+ await drainClosingProcess(10_000);
4012
+ return null;
4013
+ }
3825
4014
 
3826
4015
  // A mode change that would alter the spawn-time system prompt append gets
3827
4016
  // a fresh process — but only when nothing lives in the old one.
@@ -3874,13 +4063,14 @@ export function createClaudeSessionRunner({
3874
4063
  const repinAfterFallback = Boolean(proc.modelFallback) && Boolean(model);
3875
4064
  if (model !== proc.model || repinAfterFallback) {
3876
4065
  expectSettingsReinit();
3877
- await Promise.resolve(proc.turn.setModel?.(model || undefined)).catch((error) => {
4066
+ const cliModel = modelBackend && model ? modelBackend.cliModel(model) : model;
4067
+ await Promise.resolve(proc.turn.setModel?.(cliModel || undefined)).catch((error) => {
3878
4068
  dbg('setModel failed', error?.message || String(error));
3879
4069
  });
3880
4070
  proc.model = model;
3881
4071
  if (model) proc.modelFallback = '';
3882
4072
  }
3883
- if (effort !== proc.effort) {
4073
+ if (!modelBackend && effort !== proc.effort) {
3884
4074
  // 'ultracode' is not an effortLevel the CLI accepts — it maps to the
3885
4075
  // session-scoped ultracode/enableWorkflows flags (with the effort pinned
3886
4076
  // to the xhigh the flag implies); every other change clears those flags
@@ -3922,7 +4112,8 @@ export function createClaudeSessionRunner({
3922
4112
  const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
3923
4113
  const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
3924
4114
  let reassertDisplay = false;
3925
- if (thinkingEnabled !== proc.thinkingEnabled) {
4115
+ // A backend's thinking is fixed at spawn (above).
4116
+ if (!modelBackend && thinkingEnabled !== proc.thinkingEnabled) {
3926
4117
  if (thinkingEnabled === true) {
3927
4118
  // Enabling applies live. The display MUST be re-sent afterwards, in
3928
4119
  // this order: a session spawned with thinking off carries no display
@@ -4053,9 +4244,14 @@ export function createClaudeSessionRunner({
4053
4244
  function steeringState() {
4054
4245
  // A closing/aborted process is about to die — nothing about its last turn
4055
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.
4056
4250
  if (!proc || proc.closing || proc.aborted) {
4251
+ const admitting = admittingDeliveries > 0;
4057
4252
  return {
4058
4253
  turnActive: false, canSteer: false, holdReason: null, messageId: null, supported: true, autoCompactWindow: null,
4254
+ releasable: !admitting, releaseHold: admitting ? 'turn' : null, backgroundTaskCount: 0,
4059
4255
  };
4060
4256
  }
4061
4257
  const ctx = proc.activeCtx || null;
@@ -4083,7 +4279,18 @@ export function createClaudeSessionRunner({
4083
4279
  // `supported` is the worker-advertised opt-in the client's composer reads
4084
4280
  // (the same flag the Copilot SDK worker publishes) — its only steering
4085
4281
  // gate, so every snapshot carries it, idle ones included.
4086
- 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
+ };
4087
4294
  }
4088
4295
 
4089
4296
  /**
@@ -4108,6 +4315,20 @@ export function createClaudeSessionRunner({
4108
4315
  async function handlePendingPayload(pending) {
4109
4316
  const message = pending?.message || null;
4110
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
+ }
4111
4332
  // An older relay (the worker updated on disk before the relay restarted)
4112
4333
  // treats the hand-back as a failed delivery — retry, backoff, worker
4113
4334
  // marked errored — so against one the message is pushed the legacy way.
@@ -4124,6 +4345,21 @@ export function createClaudeSessionRunner({
4124
4345
  syncDeliveryReadiness();
4125
4346
  };
4126
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
+ }
4127
4363
  // This delivery's own record: a window respawn taken for it (one at most).
4128
4364
  const delivery = { windowRespawn: null };
4129
4365
  // Its note goes on this message's row, once, whichever delivery's spawn
@@ -4155,7 +4391,7 @@ export function createClaudeSessionRunner({
4155
4391
  // Awaited only when there is one: a cold spawn with the decision
4156
4392
  // already in pushes synchronously.
4157
4393
  if (proc) await drainClosingProcess();
4158
- // 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
4159
4395
  // decision settle first. The worker's first delivery can arrive
4160
4396
  // before its startup GET has answered (seen live: 180 ms after the
4161
4397
  // spawn). ready() never rejects and is bounded by the gate's timeout.
@@ -4175,14 +4411,20 @@ export function createClaudeSessionRunner({
4175
4411
  // seconds (transcript load + first request); say so instead of
4176
4412
  // showing bare dots. Posted once here — not in spawnProcess — so a
4177
4413
  // push-race respawn cannot duplicate the line.
4414
+ // In a DeepSeek chat the CLI is Claude Code talking to DeepSeek; the
4415
+ // line names the backend so the wait is not read as a Claude turn.
4416
+ const startingWhat = backend ? `Claude Code for ${backend.label || 'DeepSeek'}` : 'the Claude CLI';
4178
4417
  publisher.postActivity(message, (respawnResume || message.claudeNativeSessionId || claudeNativeSessionId)
4179
- ? 'Starting the Claude CLI (cold start — resuming the session transcript)…'
4180
- : 'Starting the Claude CLI (cold start)…');
4418
+ ? `Starting ${startingWhat} (cold start — resuming the session transcript)…`
4419
+ : `Starting ${startingWhat} (cold start)…`);
4181
4420
  }
4182
4421
  // Released for the window, then joined a process another delivery
4183
4422
  // spawned in the meantime.
4184
4423
  noteWindowRespawn();
4185
- const content = buildClaudeUserContent(message);
4424
+ // A model that takes no image input gets every image by its path.
4425
+ const content = buildClaudeUserContent(message, {
4426
+ embedImages: modelBackend ? modelBackend.acceptsImages(resolvePerTurnModel(message)) : true,
4427
+ });
4186
4428
  for (let attempt = 0; attempt < 2; attempt += 1) {
4187
4429
  const procRef = proc;
4188
4430
  const ctx = createDeliveredContext(message);
@@ -4314,6 +4556,7 @@ export function createClaudeSessionRunner({
4314
4556
  }
4315
4557
 
4316
4558
  return {
4559
+ workerKind,
4317
4560
  handlePendingPayload,
4318
4561
  canAcceptSteering,
4319
4562
  isDeliveryHeld,