@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
@@ -135,7 +135,7 @@ export function isBackgroundLaunch(block, toolUseResult = null) {
135
135
 
136
136
  export function summarizeToolInput(toolName, input) {
137
137
  if (!input || typeof input !== 'object') return '';
138
- // `mcp__relay__remote_relay` reads as what it does on which relay
138
+ // `mcp__relay__relay_tool` reads as what it does on which relay
139
139
  // (`send → linux-test session 01234567: “…”`), not as its first argument.
140
140
  if (isRemoteRelayToolName(toolName)) {
141
141
  const summary = remoteRelayActivitySummary(input);
@@ -198,6 +198,57 @@ export function createSdkMessageNormalizer() {
198
198
  // The `error` of the turn's last main-thread assistant message: the SDK's
199
199
  // name for the failure a refused turn ended on.
200
200
  let lastAssistantError = '';
201
+ // The turn's web searches and fetches, per API message id: the API counts
202
+ // them in each message's `usage.server_tool_use`, and the SDK repeats a
203
+ // message's usage with every content block it delivers, so a message is
204
+ // counted once. Summed per model on the result; DeepSeek bills them per
205
+ // request (shared/deepseek-pricing.mjs).
206
+ const serverToolUse = new Map(); // message id -> { model, webSearchRequests, webFetchRequests }
207
+ let unnamedServerToolUseMessages = 0;
208
+
209
+ function requestCount(value) {
210
+ const number = Number(value ?? 0);
211
+ return Number.isFinite(number) && number > 0 ? Math.round(number) : 0;
212
+ }
213
+
214
+ function noteServerToolUse(messageId, message) {
215
+ const use = message?.usage?.server_tool_use;
216
+ if (!use || typeof use !== 'object') return;
217
+ const webSearchRequests = requestCount(use.web_search_requests);
218
+ const webFetchRequests = requestCount(use.web_fetch_requests);
219
+ if (!webSearchRequests && !webFetchRequests) return;
220
+ const key = messageId || `unnamed-${++unnamedServerToolUseMessages}`;
221
+ const current = serverToolUse.get(key)
222
+ || { model: String(message?.model || '').trim() || initModel, webSearchRequests: 0, webFetchRequests: 0 };
223
+ // A message's usage is the message's total, so repeats do not add up.
224
+ current.webSearchRequests = Math.max(current.webSearchRequests, webSearchRequests);
225
+ current.webFetchRequests = Math.max(current.webFetchRequests, webFetchRequests);
226
+ serverToolUse.set(key, current);
227
+ }
228
+
229
+ /** The turn's `[{ model, webSearchRequests, webFetchRequests }]`, and the count starts over. */
230
+ function takeServerToolUse(resultUsage) {
231
+ const byModel = new Map();
232
+ const add = (model, webSearchRequests, webFetchRequests) => {
233
+ const key = String(model || '').trim() || initModel;
234
+ const current = byModel.get(key) || { model: key, webSearchRequests: 0, webFetchRequests: 0 };
235
+ current.webSearchRequests += webSearchRequests;
236
+ current.webFetchRequests += webFetchRequests;
237
+ byModel.set(key, current);
238
+ };
239
+ for (const entry of serverToolUse.values()) add(entry.model, entry.webSearchRequests, entry.webFetchRequests);
240
+ serverToolUse.clear();
241
+ unnamedServerToolUseMessages = 0;
242
+ // A result without assistant messages seen still carries the turn's
243
+ // counts in its own usage, under the turn's model.
244
+ const use = resultUsage?.server_tool_use;
245
+ if (!byModel.size && use && typeof use === 'object') {
246
+ const webSearchRequests = requestCount(use.web_search_requests);
247
+ const webFetchRequests = requestCount(use.web_fetch_requests);
248
+ if (webSearchRequests || webFetchRequests) add(initModel, webSearchRequests, webFetchRequests);
249
+ }
250
+ return [...byModel.values()];
251
+ }
201
252
 
202
253
  function threadKeyFor(parentToolUseId) {
203
254
  return String(parentToolUseId || '').trim() || 'main';
@@ -592,6 +643,7 @@ export function createSdkMessageNormalizer() {
592
643
  const content = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
593
644
  const messageId = String(sdkMessage?.message?.id || '').trim();
594
645
  if (!parentToolUseId) lastAssistantError = String(sdkMessage?.error || '').trim();
646
+ noteServerToolUse(messageId, sdkMessage?.message);
595
647
  return actionsForAssistantMessage(content, parentToolUseId, messageId);
596
648
  }
597
649
 
@@ -683,6 +735,9 @@ export function createSdkMessageNormalizer() {
683
735
  totalCostUsd: Number.isFinite(Number(sdkMessage.total_cost_usd))
684
736
  ? Number(sdkMessage.total_cost_usd)
685
737
  : null,
738
+ // This turn's web searches and fetches per model (not running
739
+ // totals): `[{ model, webSearchRequests, webFetchRequests }]`.
740
+ serverToolUse: takeServerToolUse(sdkMessage.usage),
686
741
  },
687
742
  }];
688
743
  }
@@ -1,4 +1,4 @@
1
- // The relay's `remote_relay` tool for Copilot SDK sessions: a plain SDK
1
+ // The relay's `relay_tool` tool for Copilot SDK sessions: a plain SDK
2
2
  // `Tool` (`{ name, description, parameters, skipPermission, handler }`) built
3
3
  // from the shared contract and forwarded through the shared tool core. The
4
4
  // module never imports the SDK; the session process hands the definition to
@@ -50,7 +50,7 @@ export function buildCopilotRemoteRelayTool({
50
50
  }));
51
51
  } catch (error) {
52
52
  const message = error?.message || String(error);
53
- dbg('remote_relay tool failed', message);
53
+ dbg('relay_tool failed', message);
54
54
  result = { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: message };
55
55
  }
56
56
  return formatRemoteRelayToolResult(result);
@@ -392,8 +392,11 @@ export function isCopilotQuotaError(data) {
392
392
  * "Open Check Usage" pointer shipped in 37d2899. The structured quota verdict
393
393
  * is passed to that classifier as a hint rather than smuggled in as forged
394
394
  * prose, so a quota error is non-retryable however GitHub worded it.
395
+ *
396
+ * `provider` is the worker's provider (`openai` for a BYOK worker): the note
397
+ * names it, so an OpenAI turn does not read as a Copilot one.
395
398
  */
396
- export function classifyCopilotSessionError(data) {
399
+ export function classifyCopilotSessionError(data, { provider = '' } = {}) {
397
400
  const detail = String(data?.message || '').trim() || 'the Copilot runtime reported a session error';
398
401
  const quota = isCopilotQuotaError(data);
399
402
  const probe = new Error(detail);
@@ -420,7 +423,7 @@ export function classifyCopilotSessionError(data) {
420
423
  return {
421
424
  code,
422
425
  stableCode: `copilot.${code}`,
423
- text: `System note: the Copilot turn failed (${detail}). Retry or send a new message.`,
426
+ text: turnFailedText(detail, provider),
424
427
  // Without one the relay adds its default, which advises a retry.
425
428
  guidance: COPILOT_TURN_FAILURE_GUIDANCE,
426
429
  detail,
@@ -490,6 +493,17 @@ export function isCopilotAuthError(error) {
490
493
  return AUTH_ERROR_RE.test(String(error?.message || error || ''));
491
494
  }
492
495
 
496
+ /**
497
+ * The generic failure note. On the SDK engine the provider is GitHub Copilot or,
498
+ * for a BYOK worker, OpenAI; the note names the one the turn ran on.
499
+ */
500
+ function turnFailedText(detail, provider) {
501
+ const subject = String(provider || '').trim().toLowerCase() === 'openai'
502
+ ? 'the OpenAI turn on the Copilot SDK engine'
503
+ : 'the Copilot turn';
504
+ return `System note: ${subject} failed (${detail}). Retry or send a new message.`;
505
+ }
506
+
493
507
  /**
494
508
  * What the relay appends to a failed Copilot turn in place of its own default
495
509
  * (which advises a retry). The worker deals with its runtime itself: a delivered
@@ -509,7 +523,7 @@ export const COPILOT_TURN_CONTINUE_GUIDANCE =
509
523
  * than a `session.error` event. Auth gets its own branch because the fix is a
510
524
  * user action on the relay host, not a retry.
511
525
  */
512
- export function classifyCopilotTurnException(error) {
526
+ export function classifyCopilotTurnException(error, { provider = '' } = {}) {
513
527
  const detail = String(error?.message || error || '').trim() || 'unknown error';
514
528
  // The conversation's folder is gone; the message names it.
515
529
  if (isWorkingDirectoryMissingError(error)) {
@@ -563,7 +577,7 @@ export function classifyCopilotTurnException(error) {
563
577
  return {
564
578
  code: 'turn-error',
565
579
  stableCode: 'copilot.turn-error',
566
- text: `System note: the Copilot turn failed (${detail}). Retry or send a new message.`,
580
+ text: turnFailedText(detail, provider),
567
581
  guidance: COPILOT_TURN_FAILURE_GUIDANCE,
568
582
  detail,
569
583
  };
@@ -84,7 +84,7 @@ export function summarizeToolInput(input) {
84
84
 
85
85
  export function formatToolActivityText(toolName, input) {
86
86
  const rawName = String(toolName || '').trim() || 'tool';
87
- // `remote_relay` (bare, or namespaced by an MCP server) reads as what it
87
+ // `relay_tool` (bare, or namespaced by an MCP server) reads as what it
88
88
  // does on which relay (`send → linux-test session 01234567: “…”`); its
89
89
  // arguments may arrive as a JSON string.
90
90
  const isRemoteRelay = isRemoteRelayToolName(rawName);
@@ -114,6 +114,11 @@ import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
114
114
  import { withSteerNote } from '../../shared/steer-note.mjs';
115
115
  import { isRelayAutomatedDelivery } from '../../shared/relay-events.mjs';
116
116
  import { isTransientRelayError } from '../../shared/worker-runtime/relay-errors.mjs';
117
+ import {
118
+ isOpenAIImageTerminalError,
119
+ runDirectOpenAIImageTurn,
120
+ shouldUseDirectOpenAIImageApi,
121
+ } from '../../shared/worker-runtime/direct-openai-image.mjs';
117
122
  import {
118
123
  createTurnLiveness,
119
124
  createTurnStalledError,
@@ -451,7 +456,7 @@ export function createCopilotSdkSessionRunner({
451
456
  // runtime's built-in one. A runtime that refuses the override falls back
452
457
  // to the built-in for the rest of this worker's life.
453
458
  relayAskUserTool = true,
454
- // Whether sessions carry the relay's `remote_relay` tool
459
+ // Whether sessions carry the relay's `relay_tool` tool
455
460
  // (shared/remote-relay-tool-core.mjs). Default: ask the relay.
456
461
  remoteRelayToolGate = null,
457
462
  taskRefreshMs = DEFAULT_TASK_REFRESH_MS,
@@ -556,6 +561,12 @@ export function createCopilotSdkSessionRunner({
556
561
  // terminal error until the publish lands, so recovery can never re-run them
557
562
  // (see publishSettleMarker).
558
563
  const settlingMessages = new Map();
564
+ // Image-model rows running on the relay's image routes (id → message). They
565
+ // never reach the runtime, so no turn owns them; the heartbeat and the crash
566
+ // guard still have to.
567
+ const directImageMessages = new Map();
568
+ // `openai` on a BYOK worker; the failure notes name the provider.
569
+ const workerProvider = String(env.COPILOT_PROVIDER_TYPE || '').trim().toLowerCase();
559
570
  // Settle markers that could not be posted even terminally; the heartbeat
560
571
  // hands them to the relay to fail (id → terminalError).
561
572
  const settleFailedTerminals = new Map();
@@ -685,12 +696,12 @@ export function createCopilotSdkSessionRunner({
685
696
  // the human's eventual answer to a runtime whose row is already settled.
686
697
  // Same guard the Cursor worker's `hasPendingClientWork` provides.
687
698
  let pendingHumanRequests = 0;
688
- // Running `remote_relay` calls. The runtime is just as silent while one
699
+ // Running `relay_tool` calls. The runtime is just as silent while one
689
700
  // runs (the relay may wait up to 600 s for a remote turn, plus an approval
690
701
  // card), so they hold the stall watchdog and the idle shutdown too — but
691
702
  // not steering: nobody is answering a card here.
692
703
  let pendingRelayToolCalls = 0;
693
- // `remote_relay` is registered only while this relay has paired remotes.
704
+ // `relay_tool` is registered only while this relay has paired remotes.
694
705
  // Asked once when the worker starts; a session build waits for that first
695
706
  // answer (it is async anyway), and every runtime stop asks again so the
696
707
  // next session picks up a remote paired in the meantime.
@@ -2295,7 +2306,7 @@ export function createCopilotSdkSessionRunner({
2295
2306
  }
2296
2307
 
2297
2308
  /**
2298
- * The relay's `remote_relay` tool (copilot-remote-relay-tool.mjs). Every call
2309
+ * The relay's `relay_tool` tool (copilot-remote-relay-tool.mjs). Every call
2299
2310
  * is counted while it runs: the stall watchdog re-arms instead of failing the
2300
2311
  * row, and the lifecycle keeps the runtime up. The clock restarts from the
2301
2312
  * answer, like after a card. A call is cancelled by the runtime (its
@@ -2338,7 +2349,7 @@ export function createCopilotSdkSessionRunner({
2338
2349
  * runtime refuses the override (an older runtime). The refusal is
2339
2350
  * remembered until the runtime is stopped — the next runtime (possibly
2340
2351
  * auto-updated under the relay) gets another try. Only the override goes:
2341
- * the relay's other tools (`remote_relay`) override nothing and stay.
2352
+ * the relay's other tools (`relay_tool`) override nothing and stay.
2342
2353
  */
2343
2354
  async function openSession(open, config) {
2344
2355
  try {
@@ -3187,7 +3198,7 @@ export function createCopilotSdkSessionRunner({
3187
3198
  // Cancels any relay question card this turn is blocked on, so an aborted
3188
3199
  // turn does not leave a human answering into the void.
3189
3200
  abortController: new AbortController(),
3190
- // Cancels the `remote_relay` calls that started under this turn — on the
3201
+ // Cancels the `relay_tool` calls that started under this turn — on the
3191
3202
  // user's Stop only. Not `abortController`: that one also fires when the
3192
3203
  // turn settles normally, and a background agent's call must outlive that.
3193
3204
  remoteRelayStop: new AbortController(),
@@ -3358,7 +3369,7 @@ export function createCopilotSdkSessionRunner({
3358
3369
  if (turn.settled) return;
3359
3370
  // A human staring at a question card is not a stalled runtime: the card
3360
3371
  // has its own (much longer) timeout, and failing the row here would
3361
- // settle it while the answer is still coming. A remote_relay call waiting
3372
+ // settle it while the answer is still coming. A relay_tool call waiting
3362
3373
  // on another relay is the same kind of silence; the relay bounds that
3363
3374
  // wait itself. So is a compaction, bounded by its stale cap, and the wait
3364
3375
  // for the previous runtime to stop, bounded by the teardown's own limits.
@@ -3728,7 +3739,7 @@ export function createCopilotSdkSessionRunner({
3728
3739
  // afterwards: a runtime that died or went silent under a Stop used to put
3729
3740
  // the watchdog's failure on every row instead.
3730
3741
  if (failure && !turn.aborted) {
3731
- const classified = classifyCopilotTurnException(failure);
3742
+ const classified = classifyCopilotTurnException(failure, { provider: workerProvider });
3732
3743
  dbg('copilot turn failed', turn.message?.id || '(continuation)', classified.detail);
3733
3744
  const running = turn.currentOwner || turn.primaryEntry;
3734
3745
  for (const entry of pending) {
@@ -3747,7 +3758,7 @@ export function createCopilotSdkSessionRunner({
3747
3758
  return;
3748
3759
  }
3749
3760
  if (result?.isError) {
3750
- const classified = classifyCopilotSessionError(result.errorData || { message: result.errorMessage });
3761
+ const classified = classifyCopilotSessionError(result.errorData || { message: result.errorMessage }, { provider: workerProvider });
3751
3762
  dbg('turn failed', turn.message?.id || '(continuation)', classified.stableCode);
3752
3763
  const running = turn.currentOwner || turn.primaryEntry;
3753
3764
  for (const entry of pending) {
@@ -3901,7 +3912,7 @@ export function createCopilotSdkSessionRunner({
3901
3912
  }
3902
3913
  turn.aborted = true;
3903
3914
  turn.abortedRowId = rowId;
3904
- // A pending `remote_relay` call ends now rather than when the runtime gets
3915
+ // A pending `relay_tool` call ends now rather than when the runtime gets
3905
3916
  // round to it: the relay drops the request, withdraws its approval card
3906
3917
  // and stops waiting on the remote, and the watchdog hold is released.
3907
3918
  turn.remoteRelayStop?.abort();
@@ -4117,6 +4128,7 @@ export function createCopilotSdkSessionRunner({
4117
4128
  dbg('continuation output dropped (no relay row)');
4118
4129
  const classified = classifyCopilotTurnException(
4119
4130
  turn.failure || new Error('the relay refused a continuation row for this turn'),
4131
+ { provider: workerProvider },
4120
4132
  );
4121
4133
  for (const entry of turn.entries) {
4122
4134
  if (entry.settled || entry.turn !== turn) continue;
@@ -4552,7 +4564,7 @@ export function createCopilotSdkSessionRunner({
4552
4564
  * is not waiting for its tools, so a steer interrupts nothing. A
4553
4565
  * subagent the main agent runs in the foreground is covered by the main
4554
4566
  * agent's own tool call that started it.
4555
- * - `remote_relay`, which holds the stall watchdog but never held steering
4567
+ * - `relay_tool`, which holds the stall watchdog but never held steering
4556
4568
  * (see `pendingRelayToolCalls`).
4557
4569
  */
4558
4570
  function mainToolsInFlight(turn = activeTurn) {
@@ -4821,6 +4833,9 @@ export function createCopilotSdkSessionRunner({
4821
4833
  async function handlePendingPayload(pending) {
4822
4834
  const message = pending?.message || null;
4823
4835
  if (!message) return false;
4836
+ // An image model is no chat model: its row goes to the relay's image
4837
+ // routes, whatever the runtime is doing, and never into the runtime.
4838
+ if (shouldUseDirectOpenAIImageApi(message)) return runDirectImageTurn(message);
4824
4839
  // Held (a card is open, a compaction is running, a tool of the main agent
4825
4840
  // is running, the turn-opening prompt is not sent yet): hand the row back
4826
4841
  // with no retry penalty; the relay re-delivers it when the hold ends. An
@@ -4850,7 +4865,7 @@ export function createCopilotSdkSessionRunner({
4850
4865
  if (outcome !== NOT_STEERED) return outcome;
4851
4866
  } catch (error) {
4852
4867
  dbg('steering failed', message.id, error?.message || String(error));
4853
- const classified = classifyCopilotTurnException(error);
4868
+ const classified = classifyCopilotTurnException(error, { provider: workerProvider });
4854
4869
  await publishResponse(message, {
4855
4870
  text: classified.text,
4856
4871
  model: null,
@@ -4863,6 +4878,54 @@ export function createCopilotSdkSessionRunner({
4863
4878
  return runTurn(turn);
4864
4879
  }
4865
4880
 
4881
+ /**
4882
+ * One image-model row (shared/worker-runtime/direct-openai-image.mjs). With
4883
+ * an image operation the relay's execute route publishes the reply, the
4884
+ * failure or the requeue itself; without one the shared helper publishes
4885
+ * the reply, and a refusal by the provider is published here as the row's
4886
+ * failure. Anything else requeues the row, as on the extension engine.
4887
+ */
4888
+ async function runDirectImageTurn(message) {
4889
+ const id = String(message.id || '');
4890
+ directImageMessages.set(id, message);
4891
+ touch();
4892
+ try {
4893
+ const result = await runDirectOpenAIImageTurn({
4894
+ api,
4895
+ message,
4896
+ // An edit can take minutes, longer than fetch waits for headers.
4897
+ requestOptions: { longCall: true },
4898
+ responseFields: attemptFields(message),
4899
+ });
4900
+ dbg('image turn', message.id, result.via, result.outcome);
4901
+ } catch (error) {
4902
+ if (isOpenAIImageTerminalError(error)) {
4903
+ dbg('image turn refused by OpenAI', message.id, error.message);
4904
+ await publishResponse(message, {
4905
+ text: error.message,
4906
+ model: null,
4907
+ terminalError: {
4908
+ kind: 'openai-image-failed',
4909
+ // The code the relay's image operation uses for the same refusal.
4910
+ code: 'image-provider-rejected',
4911
+ stableCode: 'image-provider-rejected',
4912
+ message: error.message,
4913
+ ...(error.detail ? { detail: String(error.detail) } : {}),
4914
+ failedAt: new Date().toISOString(),
4915
+ queueMessageId: id || null,
4916
+ },
4917
+ });
4918
+ } else {
4919
+ dbg('image turn failed; requeueing', message.id, error?.message || String(error));
4920
+ await api('POST', '/api/requeue', { messageId: message.id, ...attemptFields(message) }).catch(() => {});
4921
+ }
4922
+ } finally {
4923
+ directImageMessages.delete(id);
4924
+ touch();
4925
+ }
4926
+ return true;
4927
+ }
4928
+
4866
4929
  // --------------------------------------------------------------- teardown --
4867
4930
 
4868
4931
  function getActiveQueueMessageId() {
@@ -4872,6 +4935,7 @@ export function createCopilotSdkSessionRunner({
4872
4935
  const id = String(entry.message?.id || '');
4873
4936
  if (id && !entry.released) return id;
4874
4937
  }
4938
+ for (const id of directImageMessages.keys()) return id;
4875
4939
  return '';
4876
4940
  }
4877
4941
 
@@ -4911,6 +4975,7 @@ export function createCopilotSdkSessionRunner({
4911
4975
  for (const { message, variant } of settlingMessages.values()) {
4912
4976
  push(message, buildSteerSettleFailure(message, { variant, agentLabel: SETTLE_AGENT_LABEL }));
4913
4977
  }
4978
+ for (const message of directImageMessages.values()) push(message);
4914
4979
  return entries;
4915
4980
  }
4916
4981
 
@@ -4949,8 +5014,9 @@ export function createCopilotSdkSessionRunner({
4949
5014
  // registry and refuses ids it does not know.
4950
5015
  stopBackgroundTask,
4951
5016
  // "Active" spans both ownerships: a settled turn still publishing must
4952
- // keep the worker's idle/shutdown gates closed just like a running one.
4953
- isTurnActive: () => !!activeTurn || publishingTurns.size > 0,
5017
+ // keep the worker's idle/shutdown gates closed just like a running one,
5018
+ // and so must an image row on the relay's image routes.
5019
+ isTurnActive: () => !!activeTurn || publishingTurns.size > 0 || directImageMessages.size > 0,
4954
5020
  dispose,
4955
5021
  // The turn's tokens/cost/TTFT, as posted to `/api/copilot-plan-usage`.
4956
5022
  getLastTurnUsage: () => lastTurnUsage,
@@ -1,7 +1,7 @@
1
1
  /**
2
- * The relay's `remote_relay` custom tool for the Cursor worker. SDK-free: the
2
+ * The relay's `relay_tool` custom tool for the Cursor worker. SDK-free: the
3
3
  * turn runner passes the returned `{ description, inputSchema, execute }` into
4
- * `customTools.remote_relay`, and every call goes through the shared tool core
4
+ * `customTools.relay_tool`, and every call goes through the shared tool core
5
5
  * to the local relay endpoint.
6
6
  *
7
7
  * A call can legitimately run for many minutes (a remote turn plus an approval
@@ -46,7 +46,7 @@ export function createRemoteRelayTool({
46
46
  } catch (error) {
47
47
  // A tool exception must never kill the run.
48
48
  const message = error?.message || String(error);
49
- dbg('remote_relay tool failed', message);
49
+ dbg('relay_tool failed', message);
50
50
  const result = { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: message };
51
51
  return {
52
52
  structuredContent: result,
@@ -126,7 +126,7 @@ export function createCursorTurnRunner({
126
126
  // The error an agent started in `cwd` fails with up front, or null
127
127
  // (shared/worker-runtime/working-directory.mjs).
128
128
  checkWorkingDirectoryImpl = missingWorkingDirectoryError,
129
- // Whether agent handles carry the relay's `remote_relay` tool
129
+ // Whether agent handles carry the relay's `relay_tool` tool
130
130
  // (shared/remote-relay-tool-core.mjs). Default: ask the relay.
131
131
  remoteRelayToolGate = null,
132
132
  dbg = () => {},
@@ -149,7 +149,7 @@ export function createCursorTurnRunner({
149
149
  // Open ask_user round-trips: the merged-stream stall watchdog defers while
150
150
  // a human is thinking about a question card.
151
151
  let pendingAskUserCalls = 0;
152
- // Running remote_relay calls, same family: the relay may be waiting up to
152
+ // Running relay_tool calls, same family: the relay may be waiting up to
153
153
  // 600 s for a remote turn (plus an approval card) with no SDK traffic.
154
154
  let pendingRemoteRelayCalls = 0;
155
155
  // Mirrors the Copilot extension's lastPromptedRelayMode: full mode
@@ -178,7 +178,7 @@ export function createCursorTurnRunner({
178
178
  // plain refusal the model can relay instead of a failed turn.
179
179
  const previewTool = createPreviewTool({
180
180
  api,
181
- // Between turns, the worker's own session (as remote_relay below): list
181
+ // Between turns, the worker's own session (as relay_tool below): list
182
182
  // and close are scoped to a conversation and refused without one.
183
183
  getConversationId: () => activeMessage?.conversationId || sdkSessionId || '',
184
184
  dbg,
@@ -202,7 +202,7 @@ export function createCursorTurnRunner({
202
202
  execute: previewTool.execute,
203
203
  },
204
204
  };
205
- // `remote_relay` joins the set only while this relay has paired remotes.
205
+ // `relay_tool` joins the set only while this relay has paired remotes.
206
206
  // Asked once when the worker starts; the first handle waits for the answer
207
207
  // (it is created asynchronously anyway), and every handle creation refreshes
208
208
  // it so a later rebuild picks up a remote paired in the meantime.
@@ -47,8 +47,8 @@ export function summarizeToolInput(toolName, input) {
47
47
  /**
48
48
  * The relay's custom tools reach the stream as the SDK's generic MCP call
49
49
  * (`name: 'mcp'`, args `{ providerIdentifier: 'custom-user-tools', toolName,
50
- * args }`). `remote_relay` is shown as itself with the contract's one-line
51
- * summary, so the transcript reads `Tool (remote_relay): send → …` like every
50
+ * args }`). `relay_tool` is shown as itself with the contract's one-line
51
+ * summary, so the transcript reads `Tool (relay_tool): send → …` like every
52
52
  * other provider's; null for any other tool.
53
53
  */
54
54
  function remoteRelayCallArgs(toolName, input) {
@@ -29,6 +29,17 @@ import { migrateConversationMayCreateSessions } from './migrations/0020-conversa
29
29
  import { migrateConversationGroupAgentMarks } from './migrations/0021-conversation-group-agent-marks.mjs';
30
30
  import { migrateRelaySchedules } from './migrations/0022-relay-schedules.mjs';
31
31
  import { migrateRelayAsks } from './migrations/0023-relay-asks.mjs';
32
+ import { migrateDeepSeekUsageTotals } from './migrations/0024-deepseek-usage-totals.mjs';
33
+ import { migrateDeepSeekUsageRequestsDaily } from './migrations/0025-deepseek-usage-requests-daily.mjs';
34
+ import { migrateDeepSeekUsageServedModels } from './migrations/0026-deepseek-usage-served-models.mjs';
35
+ import { migrateDeepSeekUsageCheckpointRuns } from './migrations/0027-deepseek-usage-checkpoint-runs.mjs';
36
+ import { migrateDeepSeekUsageCheckpointRequestRuns } from './migrations/0028-deepseek-usage-checkpoint-request-runs.mjs';
37
+ import { migrateDeepSeekBalanceRises } from './migrations/0029-deepseek-balance-rises.mjs';
38
+ import { migrateDeepSeekUsageActuals } from './migrations/0030-deepseek-usage-actuals.mjs';
39
+ import { migrateQueueProvider } from './migrations/0031-queue-provider.mjs';
40
+ import { migrateRuntimeSessionBillingSwitch } from './migrations/0032-runtime-session-billing-switch.mjs';
41
+ import { migrateClaudeApiUsageCheckpoints } from './migrations/0033-claude-api-usage-checkpoints.mjs';
42
+ import { migrateClaudeApiUsageTotals } from './migrations/0034-claude-api-usage-totals.mjs';
32
43
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
33
44
 
34
45
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -898,7 +909,7 @@ try {
898
909
  console.warn(`[relay-schedules] migration 0022 failed; retrying on next boot: ${error?.message || error}`);
899
910
  }
900
911
 
901
- // Questions that don't block (remote_relay ask): one new table. The ask
912
+ // Questions that don't block (relay_tool ask): one new table. The ask
902
913
  // service checks for it and refuses the ask actions without it, so a failure
903
914
  // is logged and retried next boot like 0022.
904
915
  try {
@@ -907,6 +918,62 @@ try {
907
918
  console.warn(`[relay-asks] migration 0023 failed; retrying on next boot: ${error?.message || error}`);
908
919
  }
909
920
 
921
+ // DeepSeek usage totals: one new table. The plan usage service creates it
922
+ // itself as well, so a failure here is logged and retried next boot like 0023.
923
+ try {
924
+ migrateDeepSeekUsageTotals(db);
925
+ } catch (error) {
926
+ console.warn(`[deepseek-usage] migration 0024 failed; retrying on next boot: ${error?.message || error}`);
927
+ }
928
+
929
+ // DeepSeek search/fetch counts, daily spend and balance reference: four
930
+ // columns on 0024's table and two tables, handled like 0024.
931
+ try {
932
+ migrateDeepSeekUsageRequestsDaily(db);
933
+ } catch (error) {
934
+ console.warn(`[deepseek-usage] migration 0025 failed; retrying on next boot: ${error?.message || error}`);
935
+ }
936
+
937
+ // DeepSeek usage booked under a Claude model name is folded into the DeepSeek
938
+ // model that served it, handled like 0024.
939
+ try {
940
+ migrateDeepSeekUsageServedModels(db);
941
+ } catch (error) {
942
+ console.warn(`[deepseek-usage] migration 0026 failed; retrying on next boot: ${error?.message || error}`);
943
+ }
944
+
945
+ // The DeepSeek usage checkpoint learns the worker process's run id and its
946
+ // search and fetch counts: three columns on 0024's table, handled like 0024.
947
+ try {
948
+ migrateDeepSeekUsageCheckpointRuns(db);
949
+ } catch (error) {
950
+ console.warn(`[deepseek-usage] migration 0027 failed; retrying on next boot: ${error?.message || error}`);
951
+ }
952
+
953
+ // The checkpoint's search and fetch counts get a run id of their own: one
954
+ // column on 0024's table, handled like 0024.
955
+ try {
956
+ migrateDeepSeekUsageCheckpointRequestRuns(db);
957
+ } catch (error) {
958
+ console.warn(`[deepseek-usage] migration 0028 failed; retrying on next boot: ${error?.message || error}`);
959
+ }
960
+
961
+ // The balance reference notes when the balance last rose: one column on
962
+ // 0025's table, handled like 0025.
963
+ try {
964
+ migrateDeepSeekBalanceRises(db);
965
+ } catch (error) {
966
+ console.warn(`[deepseek-usage] migration 0029 failed; retrying on next boot: ${error?.message || error}`);
967
+ }
968
+
969
+ // The imported figures of DeepSeek's usage export: a table of its own, handled
970
+ // like 0024.
971
+ try {
972
+ migrateDeepSeekUsageActuals(db);
973
+ } catch (error) {
974
+ console.warn(`[deepseek-usage] migration 0030 failed; retrying on next boot: ${error?.message || error}`);
975
+ }
976
+
910
977
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
911
978
  if (runtimeSessionColumns.length) {
912
979
  if (!runtimeSessionColumns.includes('strategy')) {
@@ -964,6 +1031,34 @@ if (runtimeSessionColumns.length) {
964
1031
  }
965
1032
  }
966
1033
 
1034
+ // The Claude billing switch: the billing a queue row runs on, and the chat's
1035
+ // last switch. After runtime_sessions has provider_type: 0031's back-fill
1036
+ // reads it. Every reader and writer of these columns checks for them, so a
1037
+ // failure is logged and retried next boot like 0004.
1038
+ try {
1039
+ migrateQueueProvider(db);
1040
+ } catch (error) {
1041
+ console.warn(`[claude-billing] migration 0031 failed; retrying on next boot: ${error?.message || error}`);
1042
+ }
1043
+ try {
1044
+ migrateRuntimeSessionBillingSwitch(db);
1045
+ } catch (error) {
1046
+ console.warn(`[claude-billing] migration 0032 failed; retrying on next boot: ${error?.message || error}`);
1047
+ }
1048
+
1049
+ // Claude API usage: the checkpoints across both Claude billings and the
1050
+ // booked totals per day and model, handled like 0024.
1051
+ try {
1052
+ migrateClaudeApiUsageCheckpoints(db);
1053
+ } catch (error) {
1054
+ console.warn(`[claude-api-usage] migration 0033 failed; retrying on next boot: ${error?.message || error}`);
1055
+ }
1056
+ try {
1057
+ migrateClaudeApiUsageTotals(db);
1058
+ } catch (error) {
1059
+ console.warn(`[claude-api-usage] migration 0034 failed; retrying on next boot: ${error?.message || error}`);
1060
+ }
1061
+
967
1062
  const conversationColumns = db.prepare(`PRAGMA table_info(conversations)`).all().map((c) => c.name);
968
1063
  if (!conversationColumns.includes('archived')) {
969
1064
  db.exec(`ALTER TABLE conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`);