@oar-sh/oar 0.9.15 → 0.9.17

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 (109) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +305 -0
  3. package/README.md +12 -11
  4. package/package.json +2 -1
  5. package/server/claude-worker/claude-attachments.mjs +7 -4
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  7. package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
  8. package/server/claude-worker/claude-session-process.mjs +131 -33
  9. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  10. package/server/claude-worker/claude-session-worker.mjs +2 -253
  11. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  12. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  13. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
  17. package/server/db-schema.mjs +115 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  19. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  20. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  21. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  22. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  23. package/server/migrations/0022-relay-schedules.mjs +142 -0
  24. package/server/migrations/0023-relay-asks.mjs +141 -0
  25. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  26. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  27. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  28. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  29. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  30. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  31. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  32. package/server/public/app/api-client.js +53 -0
  33. package/server/public/app/ask-user-view.js +3 -0
  34. package/server/public/app/bootstrap.js +162 -16
  35. package/server/public/app/context-tier-options.mjs +4 -0
  36. package/server/public/app/context-usage-view.mjs +17 -0
  37. package/server/public/app/conversation-group-order.mjs +35 -0
  38. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  39. package/server/public/app/conversation-view.js +2 -0
  40. package/server/public/app/deepseek-settings-ui.js +342 -0
  41. package/server/public/app/inbox-ui.js +367 -0
  42. package/server/public/app/inbox-view.mjs +370 -0
  43. package/server/public/app/journal-view.js +54 -5
  44. package/server/public/app/model-selector-options.mjs +11 -0
  45. package/server/public/app/model-vision-support.mjs +2 -0
  46. package/server/public/app/no-conversation-state.mjs +1 -1
  47. package/server/public/app/plan-usage-view.mjs +165 -13
  48. package/server/public/app/provider-readiness.mjs +4 -1
  49. package/server/public/app/relay-error-ctas.mjs +8 -0
  50. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  51. package/server/public/app/relay-event-view.mjs +33 -7
  52. package/server/public/app/relay-schedule-view.mjs +119 -0
  53. package/server/public/app/relay-schedules-ui.js +172 -0
  54. package/server/public/app/settings-modal.js +11 -0
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/socket-handlers.js +37 -3
  57. package/server/public/app/store.js +18 -5
  58. package/server/public/index.html +567 -35
  59. package/server/relay-tools.md +18 -3
  60. package/server/repositories/conversation-group-repository.mjs +110 -17
  61. package/server/repositories/remote-relay-repository.mjs +20 -2
  62. package/server/repositories/session-repository.mjs +7 -1
  63. package/server/routes/ask-user-routes.mjs +57 -3
  64. package/server/routes/cache-routes.mjs +6 -0
  65. package/server/routes/conversation-group-routes.mjs +19 -3
  66. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  67. package/server/routes/messages-routes.mjs +195 -18
  68. package/server/routes/relay-ask-routes.mjs +387 -0
  69. package/server/routes/relay-event-routes.mjs +3 -1
  70. package/server/routes/relay-schedule-routes.mjs +57 -0
  71. package/server/routes/sessions-routes.mjs +932 -97
  72. package/server/server-runtime.mjs +203 -11
  73. package/server/services/about-service.mjs +1 -0
  74. package/server/services/cache-rebuild-service.mjs +19 -2
  75. package/server/services/claude-session-leftovers.mjs +226 -0
  76. package/server/services/conversation-preferences-service.mjs +17 -0
  77. package/server/services/deepseek-balance-service.mjs +102 -0
  78. package/server/services/deepseek-settings-service.mjs +380 -0
  79. package/server/services/deepseek-usage-import.mjs +471 -0
  80. package/server/services/model-variant-catalog-service.mjs +1 -0
  81. package/server/services/plan-usage-contract.mjs +100 -5
  82. package/server/services/plan-usage-deepseek.mjs +931 -0
  83. package/server/services/plan-usage-service.mjs +431 -0
  84. package/server/services/provider-model-selection.mjs +1 -0
  85. package/server/services/provider-reasoning-effort.mjs +15 -2
  86. package/server/services/relay-ask-link-service.mjs +174 -0
  87. package/server/services/relay-ask-service.mjs +604 -0
  88. package/server/services/relay-event-service.mjs +589 -73
  89. package/server/services/relay-schedule-service.mjs +596 -0
  90. package/server/services/remote-relay-caller-context.mjs +89 -20
  91. package/server/services/remote-relay-dispatcher.mjs +958 -31
  92. package/server/services/session-worker-launch-service.mjs +233 -82
  93. package/server/services/worker-secret-redaction.mjs +318 -0
  94. package/shared/context-window-fallbacks.mjs +3 -0
  95. package/shared/deepseek-api.mjs +137 -0
  96. package/shared/deepseek-models.mjs +114 -0
  97. package/shared/deepseek-pricing.mjs +146 -0
  98. package/shared/host-claude-session-env.mjs +101 -0
  99. package/shared/model-id.mjs +7 -0
  100. package/shared/preview-tool-core.mjs +1 -1
  101. package/shared/provider-routing.mjs +2 -2
  102. package/shared/relay-asks.mjs +145 -0
  103. package/shared/relay-events.mjs +268 -18
  104. package/shared/relay-schedules.mjs +128 -0
  105. package/shared/remote-relay-contract.mjs +554 -26
  106. package/shared/remote-relay-tool-core.mjs +16 -5
  107. package/shared/steer-settle-failure.mjs +1 -0
  108. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  109. package/shared/worker-runtime/worker-log-file.mjs +60 -0
@@ -38,6 +38,7 @@ import { claudePlanUsageFromResult, normalizeClaudePlanUsage } from '../services
38
38
  import { isUsageLimitTerminalError } from '../services/usage-limit-pause-service.mjs';
39
39
  import { normalizeCopilotWorkerUsage } from '../services/plan-usage-copilot.mjs';
40
40
  import { normalizeGrokTurnUsage } from '../services/plan-usage-grok.mjs';
41
+ import { normalizeDeepSeekUsageReport } from '../services/plan-usage-deepseek.mjs';
41
42
  import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mjs';
42
43
  import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning-effort.mjs';
43
44
  import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
@@ -65,13 +66,21 @@ import {
65
66
  TIMELINE_READ_DEFAULT_LIMIT,
66
67
  } from '../services/subagent-timeline-service.mjs';
67
68
  import { SUBAGENT_TASK_TYPES, isTerminalSubagentRunStatus, normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
68
- import { RELAY_EVENT_MESSAGE_KIND } from '../../shared/relay-events.mjs';
69
+ import { isRelayAutomatedMessageKind } from '../../shared/relay-events.mjs';
69
70
  import { isRelayShutdownQueued, relayShutdownGateReply } from '../services/relay-shutdown-gate.mjs';
70
71
  import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limiter.mjs';
71
72
  import { createSubagentTimelinePublisher } from '../services/subagent-viewer-rooms.mjs';
72
73
  import { createLocalRequestCheck } from '../services/request-boundary.mjs';
73
74
  import { buildClaudeCloudDelivery, isClaudeCloudProviderType } from '../services/claude-cloud-session-service.mjs';
75
+ import {
76
+ DEEPSEEK_REASONING_EFFORTS,
77
+ DEFAULT_DEEPSEEK_REASONING_EFFORT,
78
+ deepseekModelAcceptsImages,
79
+ deepseekModelLabel,
80
+ deepseekReasoningEffortFor,
81
+ } from '../../shared/deepseek-models.mjs';
74
82
  import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
83
+ import { recordClaudeCliStart } from '../services/claude-session-leftovers.mjs';
75
84
  import {
76
85
  admitRemoteRelayRequest,
77
86
  readRemoteRelayRequest,
@@ -151,6 +160,7 @@ function normalizeRequestedProviderType(value = '') {
151
160
  if (normalized === 'claude' || normalized === 'anthropic') return 'claude';
152
161
  // Its own provider, never read as `claude`.
153
162
  if (normalized === 'claude-cloud') return 'claude-cloud';
163
+ if (normalized === 'deepseek') return 'deepseek';
154
164
  return '';
155
165
  }
156
166
 
@@ -159,6 +169,7 @@ const BOOTSTRAP_ONLY_PROVIDER_LABELS = Object.freeze({
159
169
  grok: 'Grok',
160
170
  claude: 'Claude',
161
171
  'claude-cloud': 'Claude Cloud',
172
+ deepseek: 'DeepSeek',
162
173
  });
163
174
 
164
175
  function deriveModelOrigin(model) {
@@ -1216,7 +1227,7 @@ export function buildDequeuedRelayMessage({
1216
1227
  fallbackEnvironmentId: getClaudeCloudProviderSettings()?.environmentId || '',
1217
1228
  })
1218
1229
  : null;
1219
- // An event message the relay wrote (relay event subscriptions): the
1230
+ // A message the relay wrote itself (a relay event, a scheduled prompt): the
1220
1231
  // workers deliver it without the steer note, which speaks as the user.
1221
1232
  let messageKind = '';
1222
1233
  try {
@@ -1253,7 +1264,7 @@ export function buildDequeuedRelayMessage({
1253
1264
  ownerAssignedAt: msg.owner_assigned_at || null,
1254
1265
  ownerLeaseExpiresAt: msg.owner_lease_expires_at || null,
1255
1266
  imageOperationId: String(msg.image_operation_id || '').trim() || null,
1256
- ...(messageKind === RELAY_EVENT_MESSAGE_KIND ? { messageKind } : {}),
1267
+ ...(isRelayAutomatedMessageKind(messageKind) ? { messageKind } : {}),
1257
1268
  };
1258
1269
  }
1259
1270
 
@@ -1832,6 +1843,7 @@ export function registerMessagesRoutes(app, deps) {
1832
1843
  getCursorProviderSettings = () => ({ enabled: false, model: '', models: [] }),
1833
1844
  getGrokProviderSettings = () => ({ enabled: false, model: '', models: [] }),
1834
1845
  getClaudeCloudProviderSettings = () => ({ enabled: false, defaultModel: '', environmentId: '', models: [] }),
1846
+ getDeepSeekProviderSettings = () => ({ enabled: false, model: '', models: [] }),
1835
1847
  rebindUnstartedOpenAIConversationModel = null,
1836
1848
  normalizeRelayMode,
1837
1849
  DEFAULT_RELAY_MODE,
@@ -1891,6 +1903,10 @@ export function registerMessagesRoutes(app, deps) {
1891
1903
  sessionActivityTracker = null,
1892
1904
  // A worker's SDK session id or a conversation id → the conversation row.
1893
1905
  resolveConversationRecord = null,
1906
+ // DeepSeek's Claude Code config directory, which the relay owns: each CLI
1907
+ // start a DeepSeek worker reports is recorded there for the delete
1908
+ // (claude-session-leftovers.mjs).
1909
+ deepseekClaudeConfigDir = '',
1894
1910
  } = deps;
1895
1911
 
1896
1912
  const ttyConsoleActive = runtimeState?.ttyConsoleActive === true;
@@ -1983,6 +1999,22 @@ export function registerMessagesRoutes(app, deps) {
1983
1999
  if (/^Tool failed:/i.test(value)) return { kind: 'tool_result', text: value, metadata: { isError: true } };
1984
2000
  return { kind: 'note', text: value };
1985
2001
  }
2002
+ // Whether a conversation's history holds an image the user attached. Read
2003
+ // only for a DeepSeek model switch; prepared on first use, so a database
2004
+ // without the messages table (tests) is answered with "no".
2005
+ let conversationHasImageStatement = null;
2006
+ function conversationHasImageAttachment(conversationId) {
2007
+ try {
2008
+ conversationHasImageStatement ||= db.prepare(`
2009
+ SELECT 1 FROM messages
2010
+ WHERE conversation_id = ? AND role = 'user' AND attachments LIKE '%"image/%'
2011
+ LIMIT 1
2012
+ `);
2013
+ return !!conversationHasImageStatement.get(conversationId);
2014
+ } catch {
2015
+ return false;
2016
+ }
2017
+ }
1986
2018
  const getMessageAttachmentsByConversation = db.prepare(`
1987
2019
  SELECT attachments
1988
2020
  FROM messages
@@ -4549,7 +4581,8 @@ export function registerMessagesRoutes(app, deps) {
4549
4581
  || runtimeProviderType === 'claude'
4550
4582
  || runtimeProviderType === 'cursor'
4551
4583
  || runtimeProviderType === 'grok'
4552
- || runtimeProviderType === 'claude-cloud';
4584
+ || runtimeProviderType === 'claude-cloud'
4585
+ || runtimeProviderType === 'deepseek';
4553
4586
  const configuredOpenAIModel = String(configuredOpenAI?.model || '').trim();
4554
4587
  const availableOpenAIModels = new Set(
4555
4588
  (Array.isArray(configuredOpenAI?.models) ? configuredOpenAI.models : [])
@@ -4673,6 +4706,32 @@ export function registerMessagesRoutes(app, deps) {
4673
4706
  });
4674
4707
  }
4675
4708
  }
4709
+ // DeepSeek conversations are bootstrap-created only too. Its ids are its
4710
+ // own ("deepseek-*"), so an id alone may name it for an unbound session.
4711
+ const runtimeUsesDeepSeek = runtimeProviderType === 'deepseek';
4712
+ if (!shouldCreateConversation && !runtimeUsesDeepSeek) {
4713
+ let requestTargetsDeepSeek = requestedProviderType === 'deepseek';
4714
+ if (
4715
+ !requestTargetsDeepSeek
4716
+ && !runtimeBoundToExplicitProvider
4717
+ && requestedOpenAIModel
4718
+ && !requestedModelAvailableInGitHub
4719
+ ) {
4720
+ const deepseekSettings = getDeepSeekProviderSettings();
4721
+ const deepseekOnlyModels = new Set([
4722
+ String(deepseekSettings?.model || '').trim(),
4723
+ ...(Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : [])
4724
+ .map((value) => String(value || '').trim()),
4725
+ ].filter(Boolean));
4726
+ requestTargetsDeepSeek = deepseekSettings?.enabled === true && deepseekOnlyModels.has(requestedOpenAIModel);
4727
+ }
4728
+ if (requestTargetsDeepSeek) {
4729
+ return res.status(409).json({
4730
+ error: 'DeepSeek model selection requires creating a new DeepSeek conversation',
4731
+ code: 'DEEPSEEK_MODEL_REQUIRES_NEW_CONVERSATION',
4732
+ });
4733
+ }
4734
+ }
4676
4735
  // Claude Cloud conversations are bootstrap-created only as well: each
4677
4736
  // carries the repository its cloud session clones. Only an explicit
4678
4737
  // provider counts here — the cloud's model ids are the Claude provider's
@@ -4842,6 +4901,45 @@ export function registerMessagesRoutes(app, deps) {
4842
4901
  // sends set_model before the message), so a changed model is taken as
4843
4902
  // for a local Claude chat.
4844
4903
  const claudeCloudModel = claudeCloudModelSelection ? claudeCloudModelSelection.model : '';
4904
+ // DeepSeek conversations resolve models against the DeepSeek catalog and
4905
+ // may switch between turns, like Claude ones (the worker's CLI switches
4906
+ // before the next message). A model DeepSeek does not offer is refused.
4907
+ const configuredDeepSeek = runtimeUsesDeepSeek ? getDeepSeekProviderSettings() : null;
4908
+ const pinnedDeepSeekModel = runtimeUsesDeepSeek ? String(existingRuntimeSession?.provider_model || '').trim() : '';
4909
+ const deepseekModelSelection = runtimeUsesDeepSeek
4910
+ ? resolveProviderModelSelection({
4911
+ requestedModel: String(model || '').trim(),
4912
+ configuredModel: pinnedDeepSeekModel || String(configuredDeepSeek?.model || '').trim(),
4913
+ availableModels: [
4914
+ String(configuredDeepSeek?.model || '').trim(),
4915
+ pinnedDeepSeekModel,
4916
+ ...(Array.isArray(configuredDeepSeek?.models) ? configuredDeepSeek.models : []),
4917
+ ].map((value) => String(value || '').trim()).filter(Boolean),
4918
+ })
4919
+ : null;
4920
+ if (deepseekModelSelection && !deepseekModelSelection.ok) {
4921
+ return res.status(400).json({
4922
+ error: `DeepSeek model "${deepseekModelSelection.requestedModel}" is not available`,
4923
+ code: 'DEEPSEEK_MODEL_UNAVAILABLE',
4924
+ supportedModels: deepseekModelSelection.availableModels || [],
4925
+ });
4926
+ }
4927
+ const deepseekModel = deepseekModelSelection ? deepseekModelSelection.model : '';
4928
+ // A model without image input cannot take over a session whose transcript
4929
+ // carries images: the resumed history would fail the whole request.
4930
+ if (
4931
+ runtimeUsesDeepSeek
4932
+ && deepseekModel
4933
+ && pinnedDeepSeekModel
4934
+ && deepseekModel.toLowerCase() !== pinnedDeepSeekModel.toLowerCase()
4935
+ && !deepseekModelAcceptsImages(deepseekModel)
4936
+ && conversationHasImageAttachment(conversationId)
4937
+ ) {
4938
+ return res.status(409).json({
4939
+ error: `${deepseekModelLabel(deepseekModel)} takes no images, and this conversation already has some. Stay on ${deepseekModelLabel(pinnedDeepSeekModel)}, or start a new DeepSeek conversation for ${deepseekModelLabel(deepseekModel)}.`,
4940
+ code: 'DEEPSEEK_MODEL_TEXT_ONLY',
4941
+ });
4942
+ }
4845
4943
  // What the request itself chose. Only these are saved as the
4846
4944
  // conversation's preference; a value filled in below never is.
4847
4945
  const explicitModelGiven = !!requestedOpenAIModel;
@@ -4910,7 +5008,15 @@ export function registerMessagesRoutes(app, deps) {
4910
5008
  reasoningEffort: null,
4911
5009
  error: claudeCloudModel ? null : 'Claude Cloud model is not configured',
4912
5010
  }
4913
- : githubModelResolution))));
5011
+ : (runtimeUsesDeepSeek
5012
+ ? {
5013
+ ok: !!deepseekModel,
5014
+ model: deepseekModel,
5015
+ modelVariantId: deepseekModel,
5016
+ reasoningEffort: null,
5017
+ error: deepseekModel ? null : 'DeepSeek model is not configured',
5018
+ }
5019
+ : githubModelResolution)))));
4914
5020
  if (!modelResolution.ok) return res.status(400).json({ error: modelResolution.error, supportedModels: modelResolution.available || [] });
4915
5021
  const requestedModel = String(modelResolution.model || '').trim();
4916
5022
  const requestedAutoModel = requestedModel.toLowerCase() === AUTO_MODEL_SENTINEL;
@@ -4987,6 +5093,16 @@ export function registerMessagesRoutes(app, deps) {
4987
5093
  const effort = supported.includes(requestedEffort) ? requestedEffort : 'none';
4988
5094
  return { ok: true, effort, supported };
4989
5095
  };
5096
+ // DeepSeek takes none/low/high/max for every model; other names map to
5097
+ // what DeepSeek would run ("medium" is high), and no stated effort is the
5098
+ // conversation's own, else high, DeepSeek's default.
5099
+ const resolveDeepSeekReasoningEffort = () => {
5100
+ const supported = [...DEEPSEEK_REASONING_EFFORTS];
5101
+ const requestedEffort = deepseekReasoningEffortFor(explicitReasoningEffort);
5102
+ if (requestedEffort) return { ok: true, effort: requestedEffort, supported };
5103
+ const kept = deepseekReasoningEffortFor(conversationReasoningEffort);
5104
+ return { ok: true, effort: kept || DEFAULT_DEEPSEEK_REASONING_EFFORT, supported };
5105
+ };
4990
5106
  let reasoningResolution = useOpenAIProvider
4991
5107
  ? resolveOpenAIReasoningEffort(
4992
5108
  explicitReasoningEffort || conversationEffortFor(openAIReasoningEffortsForModel(openAIModel)) || '',
@@ -5001,10 +5117,12 @@ export function registerMessagesRoutes(app, deps) {
5001
5117
  // A cloud session takes no effort: whatever was asked, it is none.
5002
5118
  : (runtimeUsesClaudeCloud
5003
5119
  ? { ok: true, effort: 'none', supported: ['none'] }
5004
- : resolveRequestedReasoningEffort(
5005
- requestedModel,
5006
- explicitReasoningEffort || modelResolution.reasoningEffort || null,
5007
- )))));
5120
+ : (runtimeUsesDeepSeek
5121
+ ? resolveDeepSeekReasoningEffort()
5122
+ : resolveRequestedReasoningEffort(
5123
+ requestedModel,
5124
+ explicitReasoningEffort || modelResolution.reasoningEffort || null,
5125
+ ))))));
5008
5126
  // No effort sent (or the model's variant carried one it lacks): the
5009
5127
  // conversation's own effort, clamped to the model's levels, else its
5010
5128
  // lowest level that is not 'none'. Some models list 'none' and the API
@@ -5040,8 +5158,9 @@ export function registerMessagesRoutes(app, deps) {
5040
5158
  }
5041
5159
  } else if (!['default', 'long_context'].includes(requestedContextTier)) {
5042
5160
  return res.status(400).json({ error: 'Unsupported context tier' });
5043
- } else if (runtimeUsesClaudeCloud) {
5044
- // The cloud has no long-context tier to pick.
5161
+ } else if (runtimeUsesClaudeCloud || runtimeUsesDeepSeek) {
5162
+ // The cloud has no long-context tier to pick, and DeepSeek's one window
5163
+ // is already 1M.
5045
5164
  normalizedContextTier = 'default';
5046
5165
  }
5047
5166
  if (!requestedRelayMode) return res.status(400).json({ error: 'Unsupported relay mode' });
@@ -5277,6 +5396,17 @@ export function registerMessagesRoutes(app, deps) {
5277
5396
  ) {
5278
5397
  stmts.updateRuntimeSessionProvider.run('claude', claudeModel, claudeModel, now, runtimeSession.id);
5279
5398
  }
5399
+ // DeepSeek follows the composer the same way: a relaunched worker reads
5400
+ // provider_model, so it has to be the model last used.
5401
+ if (
5402
+ runtimeUsesDeepSeek
5403
+ && deepseekModel
5404
+ && runtimeSession?.id
5405
+ && deepseekModel !== String(existingRuntimeSession?.provider_model || '').trim()
5406
+ && typeof stmts.updateRuntimeSessionProvider?.run === 'function'
5407
+ ) {
5408
+ stmts.updateRuntimeSessionProvider.run('deepseek', deepseekModel, deepseekModel, now, runtimeSession.id);
5409
+ }
5280
5410
  // A cloud conversation follows the composer like a Claude one: the
5281
5411
  // worker switches the sandbox's model before the message.
5282
5412
  if (
@@ -5784,7 +5914,10 @@ export function registerMessagesRoutes(app, deps) {
5784
5914
  });
5785
5915
 
5786
5916
  // POST /api/claude-native-session — Claude worker persists the native Agent
5787
- // SDK session id so later turns can resume it across worker restarts.
5917
+ // SDK session id so later turns can resume it across worker restarts. A
5918
+ // DeepSeek worker also reports each CLI start (`cliStartedAt`, ms), which
5919
+ // the relay records so deleting the conversation finds every CLI's shell
5920
+ // snapshot.
5788
5921
  app.post('/api/claude-native-session', auth, (req, res) => {
5789
5922
  touchCli();
5790
5923
  const conversationId = String(req.body?.conversationId || '').trim();
@@ -5796,7 +5929,10 @@ export function registerMessagesRoutes(app, deps) {
5796
5929
  if (!runtimeSession) {
5797
5930
  return res.status(404).json({ error: 'Runtime session not found for conversation' });
5798
5931
  }
5799
- if (String(runtimeSession.provider_type || 'github').trim().toLowerCase() !== 'claude') {
5932
+ // The DeepSeek worker runs the same CLI (against DeepSeek) and resumes
5933
+ // its sessions the same way.
5934
+ const nativeSessionProvider = String(runtimeSession.provider_type || 'github').trim().toLowerCase();
5935
+ if (nativeSessionProvider !== 'claude' && nativeSessionProvider !== 'deepseek') {
5800
5936
  return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
5801
5937
  }
5802
5938
  if (typeof stmts.updateRuntimeSessionClaudeNativeSessionId?.run !== 'function') {
@@ -5807,6 +5943,10 @@ export function registerMessagesRoutes(app, deps) {
5807
5943
  new Date().toISOString(),
5808
5944
  conversationId,
5809
5945
  );
5946
+ const cliStartedAt = Number(req.body?.cliStartedAt);
5947
+ if (nativeSessionProvider === 'deepseek' && Number.isFinite(cliStartedAt) && cliStartedAt > 0 && cliStartedAt <= Date.now() + 60_000) {
5948
+ recordClaudeCliStart({ configRoot: deepseekClaudeConfigDir, nativeSessionId: claudeNativeSessionId, startedAt: cliStartedAt });
5949
+ }
5810
5950
  res.json({ ok: true });
5811
5951
  });
5812
5952
 
@@ -5834,9 +5974,9 @@ export function registerMessagesRoutes(app, deps) {
5834
5974
  return res.status(404).json({ error: 'Runtime session not found for conversation' });
5835
5975
  }
5836
5976
  // The Claude Cloud worker reports the same shape (the cloud session's
5837
- // context and token usage), so both providers are taken here.
5977
+ // context and token usage), and so does the DeepSeek one (the same CLI).
5838
5978
  const contextUsageProvider = String(runtimeSession.provider_type || 'github').trim().toLowerCase();
5839
- if (contextUsageProvider !== 'claude' && contextUsageProvider !== 'claude-cloud') {
5979
+ if (contextUsageProvider !== 'claude' && contextUsageProvider !== 'claude-cloud' && contextUsageProvider !== 'deepseek') {
5840
5980
  return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
5841
5981
  }
5842
5982
  if (typeof stmts.updateRuntimeSessionContextUsage?.run !== 'function') {
@@ -5883,6 +6023,40 @@ export function registerMessagesRoutes(app, deps) {
5883
6023
  res.json({ ok: true, cycle: applied.cycle.key });
5884
6024
  });
5885
6025
 
6026
+ // POST /api/deepseek-plan-usage — DeepSeek worker reports, after a turn,
6027
+ // the CLI's running token totals per model (cache hit, cache miss, output)
6028
+ // for its Claude Code session, with the time and the id of the CLI
6029
+ // process (`runId`). The relay books what they add to the totals last
6030
+ // reported for that session (a resumed CLI starts from its session's
6031
+ // earlier totals), with the estimated cost at the price of that time
6032
+ // (shared/deepseek-pricing.mjs). Totals below the checkpoint under the
6033
+ // checkpoint's run id are a stale report (retried or out of order) and
6034
+ // book nothing; under another run id, or without one, they are a CLI
6035
+ // counting from zero again, booked in full. The web searches and fetches
6036
+ // come as `requests`: with a run id the process's running counts, booked
6037
+ // like the tokens; without one the turn's counts, booked as they are.
6038
+ // Priced from 2026-10-31 16:00 UTC.
6039
+ app.post('/api/deepseek-plan-usage', auth, (req, res) => {
6040
+ touchCli();
6041
+ if (!planUsageService?.recordDeepSeekUsageReport) {
6042
+ return res.status(500).json({ error: 'Plan usage storage is unavailable' });
6043
+ }
6044
+ const conversationId = String(req.body?.conversationId || '').trim();
6045
+ if (!conversationId) return res.status(400).json({ error: 'Missing conversationId' });
6046
+ const runtimeSession = stmts.getRuntimeSessionByConversation.get(conversationId);
6047
+ if (!runtimeSession) {
6048
+ return res.status(404).json({ error: 'Runtime session not found for conversation' });
6049
+ }
6050
+ if (String(runtimeSession.provider_type || 'github').trim().toLowerCase() !== 'deepseek') {
6051
+ return res.status(409).json({ error: 'Conversation is not bound to the DeepSeek provider' });
6052
+ }
6053
+ const parsed = normalizeDeepSeekUsageReport(req.body);
6054
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
6055
+ const booked = planUsageService.recordDeepSeekUsageReport({ ...parsed.report, conversationId });
6056
+ if (!booked) return res.status(400).json({ error: 'Missing usable usage metrics' });
6057
+ res.json({ ok: true, at: booked.at, models: booked.models.map((entry) => entry.model) });
6058
+ });
6059
+
5886
6060
  // POST /api/claude-plan-usage — Claude worker reports the session's
5887
6061
  // structured /usage data (plan rate-limit windows + session cost totals).
5888
6062
  //
@@ -6795,7 +6969,10 @@ export function registerMessagesRoutes(app, deps) {
6795
6969
  body: JSON.stringify({
6796
6970
  messageId: operation.queue_message_id,
6797
6971
  conversationId: operation.conversation_id,
6798
- terminalFailure: {
6972
+ // `terminalError` is the field /api/response reads; under any other
6973
+ // name the failure was refused as an empty reply and the row stayed
6974
+ // processing until the relay recovered and re-ran it.
6975
+ terminalError: {
6799
6976
  code: 'IMAGE_ATTEMPT_INTERRUPTED',
6800
6977
  message: 'Image generation may have completed before the relay was interrupted. It was not retried to avoid duplicate images.',
6801
6978
  retryable: false,
@@ -6956,7 +7133,7 @@ export function registerMessagesRoutes(app, deps) {
6956
7133
  body: JSON.stringify({
6957
7134
  messageId: operation.queue_message_id,
6958
7135
  conversationId: operation.conversation_id,
6959
- terminalFailure: failure,
7136
+ terminalError: failure,
6960
7137
  }),
6961
7138
  }).catch(() => null);
6962
7139
  if (!failResponse?.ok) {
@@ -8031,7 +8208,7 @@ export function registerMessagesRoutes(app, deps) {
8031
8208
  // The providers whose workers name a shell task's output (a file, a spool
8032
8209
  // or a snapshot). A task of theirs without one comes from a worker that
8033
8210
  // started before the console existed.
8034
- const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok']);
8211
+ const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok', 'deepseek']);
8035
8212
  const SUBAGENT_OUTPUT_MESSAGE = TASK_OUTPUT_NONE_MESSAGES.subagent;
8036
8213
 
8037
8214
  // Why a task has no output to show, for the console's `none` answer. The