@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
@@ -17,7 +17,7 @@ import {
17
17
  } from '../services/conversation-history-page.mjs';
18
18
  import { MESSAGE_PIN_LIMIT, buildPinList } from '../services/message-pin-list.mjs';
19
19
  import { buildHiddenMessageList } from '../services/hidden-message-list.mjs';
20
- import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
20
+ import { persistConversationPreferences, readMayCreateSessions } from '../services/conversation-preferences-service.mjs';
21
21
  import { parseAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
22
22
  import {
23
23
  claudeBackgroundCommandLimitSettingsFields,
@@ -46,6 +46,14 @@ import { stopSessionWorkerProcesses } from '../services/session-worker-stop-serv
46
46
  import { createClaudeAuthService } from '../services/claude-auth-service.mjs';
47
47
  import { createCliInstallService } from '../services/cli-install-service.mjs';
48
48
  import { createGrokAuthService } from '../services/grok-auth-service.mjs';
49
+ import { parseDeepSeekSettingsUpdateRequest } from '../services/deepseek-settings-service.mjs';
50
+ import {
51
+ DEEPSEEK_PROVIDER_TYPE,
52
+ DEEPSEEK_REASONING_EFFORTS,
53
+ DEFAULT_DEEPSEEK_MODEL,
54
+ DEFAULT_DEEPSEEK_REASONING_EFFORT,
55
+ deepseekReasoningEffortFor,
56
+ } from '../../shared/deepseek-models.mjs';
49
57
  import {
50
58
  isWithinAllowedPrefix,
51
59
  readWorkspaceRootPathFromBody,
@@ -61,7 +69,7 @@ import { requestHostOf } from '../services/request-boundary.mjs';
61
69
  import { parseRemoteRelayOriginJson } from '../repositories/remote-relay-repository.mjs';
62
70
  import { createShareRepository } from '../repositories/share-repository.mjs';
63
71
  import { createShareAccessLog, readShareRequestViewer } from '../services/share-access-log-service.mjs';
64
- import { resolveGroupDisplayName } from '../repositories/conversation-group-repository.mjs';
72
+ import { GROUP_NAME_MAX, resolveGroupDisplayName } from '../repositories/conversation-group-repository.mjs';
65
73
  import { conversationGroupPosition, conversationGroupRole, groupActivityTimes, pageConversationRows } from '../public/app/conversation-group-order.mjs';
66
74
  import { admitRemoteRelayRequest, publicRemoteRelayOrigin, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
67
75
  import {
@@ -82,6 +90,14 @@ import {
82
90
  } from '../../shared/model-id.mjs';
83
91
  import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mjs';
84
92
  import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
93
+ import {
94
+ REMOTE_RELAY_ERROR_CODES,
95
+ REMOTE_RELAY_GROUP_LIMITS,
96
+ REMOTE_RELAY_SESSION_SETTINGS,
97
+ formatSessionGroupList,
98
+ resolveSessionGroupReference,
99
+ validateRemoteRelaySessionSettings,
100
+ } from '../../shared/remote-relay-contract.mjs';
85
101
  import {
86
102
  buildCloudSourceRecord,
87
103
  buildConversationCloudPayload,
@@ -125,6 +141,10 @@ import {
125
141
  derivePwaShortName,
126
142
  } from '../../shared/pwa-app-name.mjs';
127
143
  import { parseRestartResumeUpdate } from '../services/restart-resume-service.mjs';
144
+ import { removeClaudeSessionLeftovers } from '../services/claude-session-leftovers.mjs';
145
+ import { deepseekStartingBalanceNote } from '../services/plan-usage-deepseek.mjs';
146
+ import { DEEPSEEK_USAGE_CHECKPOINTS_TABLE } from '../migrations/0024-deepseek-usage-totals.mjs';
147
+ import { claudeProjectDirSlug } from '../services/claude-session-root-service.mjs';
128
148
 
129
149
  export { mapUsageSnapshotRow };
130
150
 
@@ -786,6 +806,55 @@ export function buildModelCatalogWithGrokProvider(modelState = {}, grokSettings
786
806
  };
787
807
  }
788
808
 
809
+ export function buildModelCatalogWithDeepSeekProvider(modelState = {}, deepseekSettings = {}) {
810
+ const configured = deepseekSettings?.enabled === true;
811
+ const model = String(deepseekSettings?.model || '').trim();
812
+ if (!configured || !model) return { ...modelState };
813
+ const deepseekModels = [];
814
+ const seenDeepSeekModels = new Set();
815
+ const listed = Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : [];
816
+ for (const candidate of [model, ...listed]) {
817
+ const modelId = String(candidate || '').trim();
818
+ const lowerId = modelId.toLowerCase();
819
+ if (!modelId || seenDeepSeekModels.has(lowerId)) continue;
820
+ seenDeepSeekModels.add(lowerId);
821
+ deepseekModels.push(modelId);
822
+ }
823
+ const models = Array.from(new Set([...(Array.isArray(modelState?.models) ? modelState.models : []), ...deepseekModels]));
824
+ const reasoningByModel = { ...(modelState?.reasoningByModel || {}) };
825
+ const deepseekReasoningByModel = {};
826
+ const modelMetadataByModel = { ...(modelState?.modelMetadataByModel || {}) };
827
+ const providersByModel = { ...(modelState?.providersByModel || {}) };
828
+ // DeepSeek's ids are its own ("deepseek-*"): no other provider lists them,
829
+ // so the provider tag is never shared with Copilot.
830
+ for (const deepseekModel of deepseekModels) {
831
+ const lowerKey = deepseekModel.toLowerCase();
832
+ deepseekReasoningByModel[lowerKey] = [...DEEPSEEK_REASONING_EFFORTS];
833
+ if (!Array.isArray(reasoningByModel[lowerKey]) || reasoningByModel[lowerKey].length === 0) {
834
+ reasoningByModel[lowerKey] = [...DEEPSEEK_REASONING_EFFORTS];
835
+ }
836
+ const existingProviders = Array.isArray(providersByModel[deepseekModel])
837
+ ? providersByModel[deepseekModel]
838
+ : (Array.isArray(providersByModel[lowerKey]) ? providersByModel[lowerKey] : []);
839
+ providersByModel[deepseekModel] = Array.from(new Set([...existingProviders, DEEPSEEK_PROVIDER_TYPE]));
840
+ modelMetadataByModel[deepseekModel] = {
841
+ ...(modelMetadataByModel[deepseekModel] || {}),
842
+ provider: modelMetadataByModel[deepseekModel]?.provider || DEEPSEEK_PROVIDER_TYPE,
843
+ };
844
+ }
845
+ return {
846
+ ...modelState,
847
+ models,
848
+ reasoningByModel,
849
+ reasoningByProvider: {
850
+ ...(modelState?.reasoningByProvider || {}),
851
+ [DEEPSEEK_PROVIDER_TYPE]: deepseekReasoningByModel,
852
+ },
853
+ modelMetadataByModel,
854
+ providersByModel,
855
+ };
856
+ }
857
+
789
858
  function normalizeRequestedProviderType(value = '') {
790
859
  const normalized = String(value || '').trim().toLowerCase();
791
860
  if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
@@ -795,6 +864,7 @@ function normalizeRequestedProviderType(value = '') {
795
864
  if (normalized === 'claude-cloud') return 'claude-cloud';
796
865
  if (normalized === 'cursor') return 'cursor';
797
866
  if (normalized === 'grok' || normalized === 'xai' || normalized === 'xai-grok') return 'grok';
867
+ if (normalized === 'deepseek') return 'deepseek';
798
868
  if (normalized === 'github' || normalized === 'github-copilot') return 'github';
799
869
  return '';
800
870
  }
@@ -1413,6 +1483,8 @@ export function buildConversationSessionRootPayload({
1413
1483
  claudeNativeSessionId = '',
1414
1484
  workspaceRootPath = '',
1415
1485
  resolveClaudeSessionRoot = null,
1486
+ // The same Claude Code layout, in the DeepSeek workers' own config dir.
1487
+ resolveDeepSeekSessionRoot = null,
1416
1488
  resolveCursorSessionRoot = null,
1417
1489
  } = {}) {
1418
1490
  const sid = String(sdkSessionId || '').trim() || String(conversationId || '').trim();
@@ -1435,10 +1507,12 @@ export function buildConversationSessionRootPayload({
1435
1507
  // Claude sessions have no ~/.copilot/session-state entry — only the Copilot
1436
1508
  // CLI creates those — so they resolve against the Agent SDK's own layout and
1437
1509
  // never fall through to the branch below.
1438
- if (String(providerType || '').trim().toLowerCase() === 'claude') {
1510
+ const claudeCodeProvider = String(providerType || '').trim().toLowerCase();
1511
+ if (claudeCodeProvider === 'claude' || claudeCodeProvider === 'deepseek') {
1439
1512
  const nativeSessionId = String(claudeNativeSessionId || '').trim();
1440
- if (!nativeSessionId || typeof resolveClaudeSessionRoot !== 'function') return null;
1441
- const claudeRoot = resolveClaudeSessionRoot({
1513
+ const resolveRoot = claudeCodeProvider === 'deepseek' ? resolveDeepSeekSessionRoot : resolveClaudeSessionRoot;
1514
+ if (!nativeSessionId || typeof resolveRoot !== 'function') return null;
1515
+ const claudeRoot = resolveRoot({
1442
1516
  claudeNativeSessionId: nativeSessionId,
1443
1517
  workspaceRootPath: String(workspaceRootPath || '').trim(),
1444
1518
  });
@@ -2060,6 +2134,10 @@ export function registerSessionsRoutes(app, deps) {
2060
2134
  // is simply off.
2061
2135
  claudeCloudSettingsService = null,
2062
2136
  claudeCloudSessionService = null,
2137
+ // DeepSeek (provider `deepseek`): its key, models, base URL and
2138
+ // attribution (services/deepseek-settings-service.mjs). Absent in older
2139
+ // wiring and most tests, where the provider is simply off.
2140
+ deepseekSettingsService = null,
2063
2141
  reconcileUnstartedConversationProviders = async () => ({
2064
2142
  updatedUnstartedConversations: 0,
2065
2143
  skippedStartedConversations: 0,
@@ -2100,6 +2178,13 @@ export function registerSessionsRoutes(app, deps) {
2100
2178
  // nothing while Claude Cloud is off, and absent (tests, older wiring) the
2101
2179
  // Claude card stays the last turn's snapshot.
2102
2180
  claudeAccountUsageService = null,
2181
+ // Optional DeepSeek balance (deepseek-balance-service.mjs); it reads
2182
+ // nothing while DeepSeek is off, and absent (tests, older wiring) the
2183
+ // DeepSeek card shows the relay's estimated spend alone.
2184
+ deepseekBalanceService = null,
2185
+ // Optional DeepSeek usage export (deepseek-usage-import.mjs): its figures
2186
+ // replace the estimate on the days it covers; absent, the card is the estimate.
2187
+ deepseekUsageImportService = null,
2103
2188
  getCursorPlanAllowanceSettings = () => ({ cursorModelsUsd: null, otherModelsUsd: null, resetDay: 1 }),
2104
2189
  setCursorPlanAllowanceSettings = () => ({ ok: false, error: 'Cursor allowance settings are unavailable' }),
2105
2190
  getGrokPlanAllowanceSettings = () => ({ monthlyUsd: null, resetDay: 1 }),
@@ -2131,6 +2216,10 @@ export function registerSessionsRoutes(app, deps) {
2131
2216
  sessionWorkerStopOverrides = null,
2132
2217
  resolveSessionStateRoot,
2133
2218
  resolveClaudeSessionRoot = null,
2219
+ resolveDeepSeekSessionRoot = null,
2220
+ // DeepSeek's Claude Code config directory, which the relay owns: a deleted
2221
+ // conversation's leftovers there go with it (claude-session-leftovers.mjs).
2222
+ deepseekClaudeConfigDir = '',
2134
2223
  resolveCursorSessionRoot = null,
2135
2224
  getTurnCeilingMinutes = () => DEFAULT_TURN_CEILING_MINUTES,
2136
2225
  setTurnCeilingMinutes = () => ({ ok: false, error: 'Turn ceiling settings are unavailable' }),
@@ -2167,6 +2256,12 @@ export function registerSessionsRoutes(app, deps) {
2167
2256
  // Relay events: what each session is doing (state, tasks) and the
2168
2257
  // subscriptions that end with an archived or deleted conversation.
2169
2258
  relayEventService = null,
2259
+ // Relay schedules: the ones that fire into or were set by an archived or
2260
+ // deleted conversation end with it.
2261
+ relayScheduleService = null,
2262
+ // Questions that don't block: the pending ones in an archived or deleted
2263
+ // conversation end with it.
2264
+ relayAskService = null,
2170
2265
  sessionActivityTracker = null,
2171
2266
  // Public shares and their access log (migration 0016); built on `db` when
2172
2267
  // the wiring does not pass one.
@@ -2342,6 +2437,9 @@ export function registerSessionsRoutes(app, deps) {
2342
2437
  FROM message_usage_snapshots
2343
2438
  WHERE conversation_id = ?
2344
2439
  `);
2440
+ // Asked on each delete: the usage service creates the table at its own
2441
+ // start, which may come after these routes'.
2442
+ const findDeepSeekUsageCheckpointsTable = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`);
2345
2443
  const hardDeleteConversationRows = db.transaction((conversationId) => {
2346
2444
  if (typeof stmts.deleteConvQuestions?.run === 'function') {
2347
2445
  stmts.deleteConvQuestions.run(conversationId);
@@ -2371,6 +2469,11 @@ export function registerSessionsRoutes(app, deps) {
2371
2469
  db.prepare(`DELETE FROM queue WHERE conversation_id = ?`).run(conversationId);
2372
2470
  db.prepare(`DELETE FROM messages WHERE conversation_id = ?`).run(conversationId);
2373
2471
  db.prepare(`DELETE FROM runtime_sessions WHERE conversation_id = ?`).run(conversationId);
2472
+ // A DeepSeek conversation's usage checkpoints are only good for diffing its
2473
+ // later reports; the totals and days they fed stay, as history.
2474
+ if (findDeepSeekUsageCheckpointsTable.get(DEEPSEEK_USAGE_CHECKPOINTS_TABLE)) {
2475
+ db.prepare(`DELETE FROM ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} WHERE conversation_id = ?`).run(conversationId);
2476
+ }
2374
2477
  // The share access log goes with it; the share rows follow the foreign key.
2375
2478
  shares().forgetConversation(conversationId);
2376
2479
  db.prepare(`DELETE FROM conversations WHERE id = ?`).run(conversationId);
@@ -2396,17 +2499,25 @@ export function registerSessionsRoutes(app, deps) {
2396
2499
  const sessionLaunchMutex = createKeyedMutex();
2397
2500
  const relaunchCoalescer = createRelaunchCoalescer({ windowMs: RELAUNCH_COALESCE_WINDOW_MS });
2398
2501
 
2502
+ function getDeepSeekProviderSettings() {
2503
+ return deepseekSettingsService?.getSettings?.()
2504
+ || { configured: false, enabled: false, model: DEFAULT_DEEPSEEK_MODEL, models: [], availableModels: [] };
2505
+ }
2506
+
2399
2507
  // Layer every enabled provider's models onto the base Copilot catalog.
2400
2508
  function buildModelCatalogWithProviders(modelState, openAISettings = getOpenAIProviderSettings()) {
2401
- return buildModelCatalogWithGrokProvider(
2402
- buildModelCatalogWithCursorProvider(
2403
- buildModelCatalogWithClaudeProvider(
2404
- buildModelCatalogWithOpenAIProvider(modelState, openAISettings),
2405
- getClaudeProviderSettings(),
2509
+ return buildModelCatalogWithDeepSeekProvider(
2510
+ buildModelCatalogWithGrokProvider(
2511
+ buildModelCatalogWithCursorProvider(
2512
+ buildModelCatalogWithClaudeProvider(
2513
+ buildModelCatalogWithOpenAIProvider(modelState, openAISettings),
2514
+ getClaudeProviderSettings(),
2515
+ ),
2516
+ getCursorProviderSettings(),
2406
2517
  ),
2407
- getCursorProviderSettings(),
2518
+ getGrokProviderSettings(),
2408
2519
  ),
2409
- getGrokProviderSettings(),
2520
+ getDeepSeekProviderSettings(),
2410
2521
  );
2411
2522
  }
2412
2523
 
@@ -2522,10 +2633,10 @@ export function registerSessionsRoutes(app, deps) {
2522
2633
  * it. Nothing else in the project folder is touched — it holds every other
2523
2634
  * session of that workspace and the project memory.
2524
2635
  */
2525
- function deleteClaudeCliSession({ conversation, runtimeSession }) {
2636
+ function deleteClaudeCliSession({ conversation, runtimeSession, resolveRoot = resolveClaudeSessionRoot }) {
2526
2637
  const nativeSessionId = String(runtimeSession?.claude_native_session_id || '').trim();
2527
- if (!nativeSessionId || typeof resolveClaudeSessionRoot !== 'function') return { deleted: null };
2528
- const anchors = resolveClaudeSessionRoot({
2638
+ if (!nativeSessionId || typeof resolveRoot !== 'function') return { deleted: null };
2639
+ const anchors = resolveRoot({
2529
2640
  claudeNativeSessionId: nativeSessionId,
2530
2641
  workspaceRootPath: String(
2531
2642
  conversation?.runtime_workspace_root_path || conversation?.configured_workspace_root_path || '',
@@ -2539,7 +2650,25 @@ export function registerSessionsRoutes(app, deps) {
2539
2650
  .filter((target) => path.dirname(target) === projectDir
2540
2651
  && [nativeSessionId, `${nativeSessionId}.jsonl`].includes(path.basename(target)));
2541
2652
  for (const target of targets) fs.rmSync(target, { recursive: true, force: true });
2542
- return { deleted: targets.length > 0 };
2653
+ return { deleted: targets.length > 0, projectDirPath: projectDir };
2654
+ }
2655
+
2656
+ /**
2657
+ * A DeepSeek conversation's Claude Code session: the transcript as for
2658
+ * Claude, then what the killed CLI left in the relay's own config directory
2659
+ * (its hook environment, registration, shell snapshot, and the project
2660
+ * folder once no session is left in it).
2661
+ */
2662
+ function deleteDeepSeekCliSession({ conversation, runtimeSession }) {
2663
+ const outcome = deleteClaudeCliSession({ conversation, runtimeSession, resolveRoot: resolveDeepSeekSessionRoot });
2664
+ const configRoot = String(deepseekClaudeConfigDir || '').trim();
2665
+ const nativeSessionId = String(runtimeSession?.claude_native_session_id || '').trim();
2666
+ if (!configRoot || !nativeSessionId) return { deleted: outcome.deleted };
2667
+ const workspaceRoot = String(conversation?.runtime_workspace_root_path || conversation?.configured_workspace_root_path || '').trim();
2668
+ const projectDirPath = outcome.projectDirPath
2669
+ || (workspaceRoot ? path.join(configRoot, 'projects', claudeProjectDirSlug(workspaceRoot)) : '');
2670
+ const removed = removeClaudeSessionLeftovers({ configRoot, nativeSessionId, projectDirPath });
2671
+ return { deleted: outcome.deleted === null && removed.length ? true : outcome.deleted };
2543
2672
  }
2544
2673
 
2545
2674
  /**
@@ -2580,6 +2709,7 @@ export function registerSessionsRoutes(app, deps) {
2580
2709
  const provider = String(runtimeSession?.provider_type || '').trim().toLowerCase();
2581
2710
  try {
2582
2711
  if (provider === 'claude') return deleteClaudeCliSession({ conversation, runtimeSession });
2712
+ if (provider === 'deepseek') return deleteDeepSeekCliSession({ conversation, runtimeSession });
2583
2713
  if (!sdkSessionId) return { deleted: null };
2584
2714
  if (!provider || provider === 'github' || provider === 'openai') {
2585
2715
  return await deleteCopilotCliSession({ sdkSessionId, conversationId });
@@ -2616,6 +2746,8 @@ export function registerSessionsRoutes(app, deps) {
2616
2746
  io.emit('conversation_deleted', { conversationId });
2617
2747
  if (leftGroupId) emitGroupChanged(leftGroupId, [conversationId]);
2618
2748
  try { relayEventService?.noteConversationGone?.(conversationId, { deleted: true }); } catch {}
2749
+ try { relayScheduleService?.noteConversationGone?.(conversationId, { deleted: true }); } catch {}
2750
+ try { relayAskService?.noteConversationGone?.(conversationId); } catch {}
2619
2751
  finalized.push(conversationId);
2620
2752
  }
2621
2753
  return finalized;
@@ -2641,6 +2773,8 @@ export function registerSessionsRoutes(app, deps) {
2641
2773
  parentConversationId: group.parentConversationId || null,
2642
2774
  memberCount: members.length,
2643
2775
  liveMemberCount: members.filter((member) => !member.archived).length,
2776
+ // An owner's group an agent emptied, which stays until the owner acts.
2777
+ keptEmptyAt: group.keptEmptyAt || null,
2644
2778
  };
2645
2779
  }
2646
2780
 
@@ -2702,6 +2836,38 @@ export function registerSessionsRoutes(app, deps) {
2702
2836
  });
2703
2837
  }
2704
2838
 
2839
+ // A session group named at bootstrap (an agent's create_session `group`):
2840
+ // what the lookup sees is what an agent may see (GET /api/conversation-groups).
2841
+ const autoTitle = (title) => resolveConversationTitle({ title, titleSource: 'auto' });
2842
+ const listGroupsForAgents = () => conversationGroups?.listGroups?.({ headTitle: autoTitle }) || [];
2843
+
2844
+ /** The 400 body for a group this relay does not have, or has several of by that name. */
2845
+ function bootstrapGroupRefusal(found, requested, groups) {
2846
+ const ambiguous = found.status === 'ambiguous';
2847
+ const listed = ambiguous ? found.matches : groups;
2848
+ const asked = String(requested || '').replace(/["\r\n]+/g, ' ').slice(0, 80);
2849
+ let error;
2850
+ if (ambiguous) error = `"${asked}" names several session groups here: ${formatSessionGroupList(listed)}. Pass the id.`;
2851
+ else if (groups.length) error = `No session group "${asked}" here. Groups: ${formatSessionGroupList(groups)}.`;
2852
+ else error = `No session group "${asked}" here: this relay has no session groups.`;
2853
+ return {
2854
+ ok: false,
2855
+ code: ambiguous ? 'SESSION_GROUP_AMBIGUOUS' : 'SESSION_GROUP_NOT_FOUND',
2856
+ error: `${error} No conversation was created.`,
2857
+ groups: listed.slice(0, REMOTE_RELAY_GROUP_LIMITS.refusalMax).map((group) => ({ id: group.id, name: group.name })),
2858
+ // How many there are when `groups` is cut to the cap.
2859
+ ...(listed.length > REMOTE_RELAY_GROUP_LIMITS.refusalMax ? { groupsTotal: listed.length } : {}),
2860
+ };
2861
+ }
2862
+
2863
+ /** `{ id, name, role }` of the group a conversation sits in (list_sessions' shape), or null. */
2864
+ function conversationGroupField(conversationId) {
2865
+ const membership = conversationGroups?.membershipsOf?.([conversationId])?.[conversationId];
2866
+ if (!membership?.groupId) return null;
2867
+ const described = conversationGroups.describeGroup?.(membership.groupId, { headTitle: autoTitle }) || null;
2868
+ return { id: membership.groupId, name: described?.displayName || null, role: membership.role === 'head' ? 'head' : 'member' };
2869
+ }
2870
+
2705
2871
  /** The `cloud` field of a conversation payload; null unless it is a cloud one. */
2706
2872
  function conversationCloudPayload(conversation, runtimeSession) {
2707
2873
  return buildConversationCloudPayload({
@@ -3116,6 +3282,8 @@ export function registerSessionsRoutes(app, deps) {
3116
3282
  draftUpdatedByClientId: r.draft_updated_by_client_id || null,
3117
3283
  // Set on conversations another relay's agent created ("via <relay>").
3118
3284
  origin: parseRemoteRelayOriginJson(r.origin_json),
3285
+ // The owner let this agent-started session's agents start sessions.
3286
+ mayCreateSessions: readMayCreateSessions(r),
3119
3287
  // Set on Claude Cloud conversations: repository, branch, session link.
3120
3288
  cloud: buildConversationCloudPayload({
3121
3289
  cloudSourceJson: r.cloud_source_json,
@@ -3140,6 +3308,14 @@ export function registerSessionsRoutes(app, deps) {
3140
3308
  for (const groupId of new Set(page.rows.map((row) => String(row?.group_id || '')).filter((id) => groups.has(id)))) {
3141
3309
  groupsPayload[groupId] = groupSummaryPayload(groups.get(groupId));
3142
3310
  }
3311
+ // The owner's groups an agent emptied have no row to come with: the live
3312
+ // list's first page brings them, so the sidebar shows them as empty
3313
+ // groups the owner can rename or dissolve.
3314
+ if (archivedMode !== 'only' && !beforeConversationId) {
3315
+ for (const group of conversationGroups?.listKeptEmptyGroups?.() || []) {
3316
+ groupsPayload[group.id] = groupSummaryPayload(group);
3317
+ }
3318
+ }
3143
3319
 
3144
3320
  return res.json({
3145
3321
  conversations,
@@ -3467,9 +3643,10 @@ export function registerSessionsRoutes(app, deps) {
3467
3643
  const providerType = String(runtimeSession?.provider_type || 'github').trim().toLowerCase();
3468
3644
  // The cursor and grok workers post the same Claude-shaped usage blob, and
3469
3645
  // so does the Claude Cloud worker (the cloud session's context and token
3470
- // usage), so all four providers share the stored reader.
3646
+ // usage), and so does the DeepSeek worker (the Claude one, pointed at
3647
+ // DeepSeek), so all five providers share the stored reader.
3471
3648
  const usesStoredContextUsage = providerType === 'claude' || providerType === 'cursor'
3472
- || providerType === 'grok' || providerType === 'claude-cloud';
3649
+ || providerType === 'grok' || providerType === 'claude-cloud' || providerType === 'deepseek';
3473
3650
 
3474
3651
  const parsed = usesStoredContextUsage
3475
3652
  ? (() => {
@@ -3498,11 +3675,12 @@ export function registerSessionsRoutes(app, deps) {
3498
3675
  String(runtimeSession?.conversation_id || '').trim() || lookupId,
3499
3676
  );
3500
3677
  const autoCompactWindow = parseAutoCompactWindow(conversationRow?.auto_compact_window);
3501
- // Only the local Claude worker runs a CLI whose window the slider sets;
3502
- // it reports that CLI on its heartbeat's steering snapshot.
3678
+ // Only the local Claude worker (and the DeepSeek one, the same runner)
3679
+ // runs a CLI whose window the slider sets; it reports that CLI on its
3680
+ // heartbeat's steering snapshot.
3503
3681
  const windowStatus = resolveAutoCompactWindowStatus({
3504
3682
  storedWindow: autoCompactWindow,
3505
- steering: providerType === 'claude' && copilotSessionId
3683
+ steering: (providerType === 'claude' || providerType === 'deepseek') && copilotSessionId
3506
3684
  ? sessionWorkerRegistry?.getWorker?.(copilotSessionId)?.steering || null
3507
3685
  : null,
3508
3686
  });
@@ -3753,6 +3931,7 @@ export function registerSessionsRoutes(app, deps) {
3753
3931
  claudeNativeSessionId: runtimeSession?.claude_native_session_id || '',
3754
3932
  workspaceRootPath: workspaceState?.currentWorkspaceRootPath || '',
3755
3933
  resolveClaudeSessionRoot,
3934
+ resolveDeepSeekSessionRoot,
3756
3935
  resolveCursorSessionRoot,
3757
3936
  });
3758
3937
  const dbMessages = stmts.getMessages.all(conversationId);
@@ -3862,6 +4041,7 @@ export function registerSessionsRoutes(app, deps) {
3862
4041
  draftUpdatedAt: conv.draft_updated_at || null,
3863
4042
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
3864
4043
  origin: parseRemoteRelayOriginJson(conv.origin_json),
4044
+ mayCreateSessions: readMayCreateSessions(conv),
3865
4045
  cloud: conversationCloudPayload(conv, runtimeSession),
3866
4046
  group: conversationGroupPayload(conversationId),
3867
4047
  groupPosition: conversationGroupPositionOf(conv),
@@ -4425,6 +4605,7 @@ export function registerSessionsRoutes(app, deps) {
4425
4605
  claudeNativeSessionId: runtimeSession?.claude_native_session_id || '',
4426
4606
  workspaceRootPath: workspaceState?.currentWorkspaceRootPath || '',
4427
4607
  resolveClaudeSessionRoot,
4608
+ resolveDeepSeekSessionRoot,
4428
4609
  resolveCursorSessionRoot,
4429
4610
  });
4430
4611
  const historyCursor = { limit, beforeMessageId, beforeTimestamp, afterMessageId, afterTimestamp, aroundMessageId };
@@ -4563,6 +4744,7 @@ export function registerSessionsRoutes(app, deps) {
4563
4744
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
4564
4745
  // Set on conversations another relay's agent created ("via <relay>").
4565
4746
  origin: parseRemoteRelayOriginJson(conv.origin_json),
4747
+ mayCreateSessions: readMayCreateSessions(conv),
4566
4748
  // Set on Claude Cloud conversations: repository, branch, session link.
4567
4749
  cloud: conversationCloudPayload(conv, runtimeSession),
4568
4750
  // The session group it is in, with the other members; null when none.
@@ -4738,42 +4920,36 @@ export function registerSessionsRoutes(app, deps) {
4738
4920
  });
4739
4921
  });
4740
4922
 
4741
- app.patch('/api/conversation/:id/preferences', auth, (req, res) => {
4742
- const conversationId = String(req.params.id || '').trim();
4743
- if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
4744
- const senderClientId = String(req.body?.clientId || '').trim() || null;
4745
-
4746
- const existing = stmts.getConvAnyStatus.get(conversationId);
4747
- if (existing && String(existing.status || '').trim() === 'deleted') {
4748
- return res.status(404).json({ error: 'Conversation not found' });
4749
- }
4750
-
4923
+ /**
4924
+ * Writes a conversation's preferences and tells every open page: the one
4925
+ * code path of PATCH …/preferences (the composer, the context modal) and of
4926
+ * an agent's update_session (PATCH …/settings). `body` uses the preferences
4927
+ * route's keys; a key it does not mention keeps the stored value.
4928
+ */
4929
+ function applyConversationPreferences({ conversationId, existing, body = {}, senderClientId = null }) {
4751
4930
  const now = new Date().toISOString();
4752
4931
  // Every write replaces all four columns, so a caller that only wants to
4753
4932
  // change one field (the context modal's auto-compact slider) must not have
4754
4933
  // the others reset to their defaults: an unmentioned key keeps the stored
4755
4934
  // value. The composer always sends all three, so its behaviour — including
4756
4935
  // clearing a preference with an explicit '' — is unchanged.
4757
- const mentions = (key) => Object.prototype.hasOwnProperty.call(req.body || {}, key);
4936
+ const mentions = (key) => Object.prototype.hasOwnProperty.call(body || {}, key);
4758
4937
  const preferredRelayMode = normalizeRelayModePreference(
4759
- mentions('preferredRelayMode') ? req.body?.preferredRelayMode : existing?.preferred_relay_mode,
4938
+ mentions('preferredRelayMode') ? body.preferredRelayMode : existing?.preferred_relay_mode,
4760
4939
  {
4761
4940
  supportedRelayModes: SUPPORTED_RELAY_MODES,
4762
4941
  fallbackMode: DEFAULT_RELAY_MODE,
4763
4942
  },
4764
4943
  );
4765
4944
  const preferredModel = normalizePreferredModel(
4766
- mentions('preferredModel') ? req.body?.preferredModel : existing?.preferred_model,
4945
+ mentions('preferredModel') ? body.preferredModel : existing?.preferred_model,
4767
4946
  );
4768
4947
  const preferredReasoningEffort = normalizePreferredReasoningEffort(
4769
- mentions('preferredReasoningEffort') ? req.body?.preferredReasoningEffort : existing?.preferred_reasoning_effort,
4948
+ mentions('preferredReasoningEffort') ? body.preferredReasoningEffort : existing?.preferred_reasoning_effort,
4770
4949
  );
4771
4950
  // Only an explicit mention rewrites the window (null = Auto is a real
4772
4951
  // value, so a plain falsy check would not do). Same for the thinking
4773
4952
  // control, where null = Host default.
4774
- const mentionsAutoCompactWindow = mentions('autoCompactWindow');
4775
- const mentionsThinkingEnabled = mentions('thinkingEnabled');
4776
- const mentionsThinkingDisplay = mentions('thinkingDisplay');
4777
4953
  const persisted = persistConversationPreferences({
4778
4954
  db,
4779
4955
  stmts,
@@ -4781,21 +4957,21 @@ export function registerSessionsRoutes(app, deps) {
4781
4957
  preferredRelayMode,
4782
4958
  preferredModel,
4783
4959
  preferredReasoningEffort,
4784
- ...(mentionsAutoCompactWindow
4785
- ? { autoCompactWindow: parseAutoCompactWindow(req.body?.autoCompactWindow) }
4960
+ ...(mentions('autoCompactWindow')
4961
+ ? { autoCompactWindow: parseAutoCompactWindow(body.autoCompactWindow) }
4786
4962
  : {}),
4787
- ...(mentionsThinkingEnabled
4788
- ? { thinkingEnabled: parseThinkingEnabled(req.body?.thinkingEnabled) }
4963
+ ...(mentions('thinkingEnabled')
4964
+ ? { thinkingEnabled: parseThinkingEnabled(body.thinkingEnabled) }
4789
4965
  : {}),
4790
- ...(mentionsThinkingDisplay
4791
- ? { thinkingDisplay: parseThinkingDisplay(req.body?.thinkingDisplay) }
4966
+ ...(mentions('thinkingDisplay')
4967
+ ? { thinkingDisplay: parseThinkingDisplay(body.thinkingDisplay) }
4792
4968
  : {}),
4969
+ ...(mentions('mayCreateSessions') ? { mayCreateSessions: body.mayCreateSessions === true } : {}),
4793
4970
  updatedAt: now,
4794
4971
  createIfMissing: !existing,
4795
4972
  createTitle: 'Session',
4796
4973
  });
4797
-
4798
- io.emit('conversation_preferences_updated', {
4974
+ const payload = {
4799
4975
  conversationId,
4800
4976
  preferredRelayMode: persisted.preferredRelayMode,
4801
4977
  preferredModel: persisted.preferredModel,
@@ -4803,22 +4979,447 @@ export function registerSessionsRoutes(app, deps) {
4803
4979
  autoCompactWindow: persisted.autoCompactWindow ?? null,
4804
4980
  thinkingEnabled: persisted.thinkingEnabled ?? null,
4805
4981
  thinkingDisplay: persisted.thinkingDisplay ?? null,
4982
+ mayCreateSessions: persisted.mayCreateSessions === true,
4806
4983
  updatedAt: persisted.updatedAt,
4807
4984
  senderClientId,
4985
+ };
4986
+ io.emit('conversation_preferences_updated', payload);
4987
+ return { ...payload, created: persisted.created };
4988
+ }
4989
+
4990
+ app.patch('/api/conversation/:id/preferences', auth, (req, res) => {
4991
+ const conversationId = String(req.params.id || '').trim();
4992
+ if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
4993
+ const senderClientId = String(req.body?.clientId || '').trim() || null;
4994
+
4995
+ const existing = stmts.getConvAnyStatus.get(conversationId);
4996
+ if (existing && String(existing.status || '').trim() === 'deleted') {
4997
+ return res.status(404).json({ error: 'Conversation not found' });
4998
+ }
4999
+ // "Agents in this conversation may start sessions" is the owner's alone:
5000
+ // an agent (of this relay or a paired one) could grant it to itself.
5001
+ if (Object.prototype.hasOwnProperty.call(req.body || {}, 'mayCreateSessions')) {
5002
+ if (readRemoteRelayRequest(req, remoteRelayInbound).remote) {
5003
+ return res.status(403).json({ error: 'Whether agents in a conversation may start sessions is the owner\'s setting alone.' });
5004
+ }
5005
+ if (typeof req.body.mayCreateSessions !== 'boolean') {
5006
+ return res.status(400).json({ error: 'mayCreateSessions must be true or false' });
5007
+ }
5008
+ if (!stmts.conversationsHaveMayCreateSessions) {
5009
+ return res.status(503).json({ error: 'This setting is unavailable until the relay restarts (its storage is missing).' });
5010
+ }
5011
+ }
5012
+
5013
+ const persisted = applyConversationPreferences({ conversationId, existing, body: req.body || {}, senderClientId });
5014
+ return res.json({ ok: true, ...persisted });
5015
+ });
5016
+
5017
+ // ─── PATCH /api/conversation/:id/settings — the remote_relay tool's update_session ───
5018
+ //
5019
+ // `{ settings: { model, effort, mode, auto_compact_window, title, group,
5020
+ // thinking_enabled, thinking_display, cwd }, dry_run, owner_approved }`.
5021
+ // Reached by an agent of this relay (the loopback) and by a paired relay's
5022
+ // agent (under the inbound switch); the owner may call it too. It applies
5023
+ // this relay's own rule: an agent changes freely only a session its own
5024
+ // conversation started (the request's origin against the session's); any
5025
+ // other changes only with `owner_approved` (the calling relay asked its
5026
+ // user). A dry run checks everything, writes nothing, and says whether
5027
+ // approval is needed. A group the owner made never goes through this path
5028
+ // (it may stay empty); one an agent made here goes once empty. A step that
5029
+ // fails after an earlier one wrote answers `changed` with what was written.
5030
+ // Every write goes through the code the owner's
5031
+ // own controls use (preferences, title, group, workspace root), with their
5032
+ // socket events, so an open page shows the change at once. The owner's
5033
+ // "may start sessions" switch is not among the settings.
5034
+
5035
+ const SESSION_SETTINGS_INVALID = 'SESSION_SETTINGS_INVALID';
5036
+
5037
+ function currentSessionSettings(conversationId) {
5038
+ const conv = stmts.getConvAnyStatus.get(conversationId) || {};
5039
+ const runtime = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
5040
+ const workspace = resolveConversationWorkspaceState?.({ conversationId }) || null;
5041
+ const window = parseAutoCompactWindow(conv.auto_compact_window);
5042
+ return {
5043
+ model: normalizePreferredModel(conv.preferred_model) || String(runtime?.provider_model || runtime?.model || '').trim() || null,
5044
+ effort: normalizePreferredReasoningEffort(conv.preferred_reasoning_effort) || null,
5045
+ mode: normalizeRelayModePreference(conv.preferred_relay_mode, { supportedRelayModes: SUPPORTED_RELAY_MODES, fallbackMode: DEFAULT_RELAY_MODE }),
5046
+ auto_compact_window: window === null ? 'auto' : window,
5047
+ title: resolveConversationTitle({ title: conv.title, titleSource: conv.title_source }) || null,
5048
+ group: conversationGroupField(conversationId),
5049
+ thinking_enabled: parseThinkingEnabled(conv.thinking_enabled),
5050
+ thinking_display: parseThinkingDisplay(conv.thinking_display),
5051
+ cwd: String(workspace?.currentWorkspaceRootPath || workspace?.configuredWorkspaceRootPath || conv.configured_workspace_root_path || '').trim() || null,
5052
+ };
5053
+ }
5054
+
5055
+ /**
5056
+ * Whether the agent calling the settings route started this session from
5057
+ * its own conversation: the session's origin names that conversation, and
5058
+ * this relay's own agent (the loopback) for a local origin, the same
5059
+ * paired relay for one from there.
5060
+ */
5061
+ function sessionStartedByCaller(createdBy, remoteRequest) {
5062
+ const caller = remoteRequest?.remote ? remoteRequest.origin : null;
5063
+ if (!createdBy || !caller) return false;
5064
+ const callerConversation = String(caller.conversationId || '').trim();
5065
+ if (!callerConversation || String(createdBy.conversationId || '').trim() !== callerConversation) return false;
5066
+ if (remoteRequest.local === true) return createdBy.local === true && caller.local === true;
5067
+ const relayId = String(createdBy.relayId || '').trim();
5068
+ return createdBy.local !== true && !!relayId && relayId === String(caller.relayId || '').trim();
5069
+ }
5070
+
5071
+ /** Why a session cannot move to another folder now ('' when it can). */
5072
+ function sessionBusyReason(conversationId) {
5073
+ if (Number(countActiveConversationQueueRows.get(conversationId)?.count || 0) > 0) return 'a turn is queued or running there';
5074
+ const state = describeSessionActivity(conversationId)?.sessionState;
5075
+ if (state === 'running_turn') return 'a turn is running there';
5076
+ if (state === 'background_only') return 'a background task is running there';
5077
+ return '';
5078
+ }
5079
+
5080
+ /**
5081
+ * What update_session would do: `{ ok, plan }` or `{ ok:false, status,
5082
+ * body }`. The plan holds what changes (`changed`, in the order of the
5083
+ * contract's settings) and how.
5084
+ */
5085
+ function planSessionSettings(conversation, raw) {
5086
+ const conversationId = String(conversation.id);
5087
+ const fail = (status, error, extra = {}) => ({ ok: false, status, body: { ok: false, error, ...extra } });
5088
+ const settingsInput = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
5089
+ if (!settingsInput) return fail(400, 'settings must be an object', { code: SESSION_SETTINGS_INVALID });
5090
+ const unknown = Object.keys(settingsInput).filter((key) => !REMOTE_RELAY_SESSION_SETTINGS.includes(key));
5091
+ if (unknown.length) {
5092
+ return fail(400, `Not a setting an agent may change: ${unknown.slice(0, 10).join(', ')}. Settings: ${REMOTE_RELAY_SESSION_SETTINGS.join(', ')}.`, { code: SESSION_SETTINGS_INVALID });
5093
+ }
5094
+ const checked = validateRemoteRelaySessionSettings(settingsInput);
5095
+ if (!checked.ok) return fail(400, checked.error, { code: SESSION_SETTINGS_INVALID });
5096
+ const wanted = checked.values;
5097
+ const current = currentSessionSettings(conversationId);
5098
+ const runtime = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
5099
+ const provider = String(runtime?.provider_type || 'github').trim().toLowerCase() || 'github';
5100
+ const plan = { conversationId, provider, current, wanted, changed: [], preferences: {}, notes: [] };
5101
+ const change = (key) => { if (!plan.changed.includes(key)) plan.changed.push(key); };
5102
+
5103
+ if (wanted.model !== undefined && normalizePreferredModel(wanted.model) !== normalizePreferredModel(conversation.preferred_model)) {
5104
+ plan.preferences.preferredModel = wanted.model;
5105
+ change('model');
5106
+ }
5107
+ if (wanted.effort !== undefined && wanted.effort !== normalizePreferredReasoningEffort(conversation.preferred_reasoning_effort)) {
5108
+ plan.preferences.preferredReasoningEffort = wanted.effort;
5109
+ change('effort');
5110
+ }
5111
+ if (wanted.mode !== undefined) {
5112
+ if (!SUPPORTED_RELAY_MODES.includes(wanted.mode)) {
5113
+ return fail(400, `This relay has no mode "${wanted.mode}". Modes: ${SUPPORTED_RELAY_MODES.join(', ')}.`, { code: SESSION_SETTINGS_INVALID });
5114
+ }
5115
+ if (wanted.mode !== current.mode) {
5116
+ plan.preferences.preferredRelayMode = wanted.mode;
5117
+ change('mode');
5118
+ }
5119
+ }
5120
+ if (wanted.auto_compact_window !== undefined) {
5121
+ const window = wanted.auto_compact_window === 'auto' ? null : parseAutoCompactWindow(wanted.auto_compact_window);
5122
+ if (window !== parseAutoCompactWindow(conversation.auto_compact_window)) {
5123
+ plan.preferences.autoCompactWindow = window;
5124
+ change('auto_compact_window');
5125
+ }
5126
+ if (window !== null && window !== wanted.auto_compact_window) {
5127
+ plan.notes.push(`auto_compact_window ${wanted.auto_compact_window} was taken as ${window}, the nearest window this relay offers.`);
5128
+ }
5129
+ }
5130
+ if (wanted.thinking_enabled !== undefined && wanted.thinking_enabled !== current.thinking_enabled) {
5131
+ plan.preferences.thinkingEnabled = wanted.thinking_enabled;
5132
+ change('thinking_enabled');
5133
+ }
5134
+ if (wanted.thinking_display !== undefined && wanted.thinking_display !== current.thinking_display) {
5135
+ plan.preferences.thinkingDisplay = wanted.thinking_display;
5136
+ change('thinking_display');
5137
+ }
5138
+ if (provider !== 'claude' && ['auto_compact_window', 'thinking_enabled', 'thinking_display'].some((key) => wanted[key] !== undefined)) {
5139
+ plan.notes.push(`auto_compact_window and thinking apply to Claude sessions only; this one runs on ${provider}, so they are stored but change nothing there.`);
5140
+ }
5141
+ if (wanted.title !== undefined) {
5142
+ const title = normalizeConversationTitle(wanted.title);
5143
+ if (title !== current.title) {
5144
+ plan.title = title;
5145
+ change('title');
5146
+ }
5147
+ }
5148
+ if (Object.prototype.hasOwnProperty.call(wanted, 'group')) {
5149
+ if (!conversationGroups?.enabled) {
5150
+ return fail(503, 'Session groups are not available on this relay (its storage for them is missing). Nothing was changed.', { code: 'SESSION_GROUPS_UNAVAILABLE' });
5151
+ }
5152
+ if (wanted.group === null) {
5153
+ if (current.group) {
5154
+ plan.group = { action: 'leave', previous: current.group };
5155
+ change('group');
5156
+ }
5157
+ } else {
5158
+ const groups = listGroupsForAgents();
5159
+ const found = resolveSessionGroupReference(groups, wanted.group);
5160
+ if (found.status === 'ambiguous') {
5161
+ const refusal = bootstrapGroupRefusal(found, wanted.group, groups);
5162
+ return fail(400, refusal.error.replace('No conversation was created.', 'Nothing was changed.'), { code: refusal.code, groups: refusal.groups, ...(refusal.groupsTotal ? { groupsTotal: refusal.groupsTotal } : {}) });
5163
+ }
5164
+ if (found.status === 'found') {
5165
+ if (current.group?.id !== found.group.id) {
5166
+ plan.group = { action: 'join', id: found.group.id, name: found.group.name };
5167
+ change('group');
5168
+ }
5169
+ } else if (/^grp_/i.test(wanted.group)) {
5170
+ // An id that is not there names no group to make.
5171
+ const refusal = bootstrapGroupRefusal(found, wanted.group, groups);
5172
+ return fail(400, refusal.error.replace('No conversation was created.', 'Nothing was changed.'), { code: refusal.code, groups: refusal.groups, ...(refusal.groupsTotal ? { groupsTotal: refusal.groupsTotal } : {}) });
5173
+ } else if (wanted.group.replace(/\s+/g, ' ').length > GROUP_NAME_MAX) {
5174
+ return fail(400, `A group name has at most ${GROUP_NAME_MAX} characters. Nothing was changed.`, { code: SESSION_SETTINGS_INVALID });
5175
+ } else {
5176
+ plan.group = { action: 'create', name: wanted.group.replace(/\s+/g, ' ') };
5177
+ change('group');
5178
+ }
5179
+ }
5180
+ }
5181
+ if (wanted.cwd !== undefined) {
5182
+ if (provider === 'claude-cloud') {
5183
+ return fail(400, 'cwd does not apply to a Claude Cloud session: it works on a clone of its repository. Nothing was changed.', { code: SESSION_SETTINGS_INVALID });
5184
+ }
5185
+ if (typeof updateConversationConfiguredWorkspaceRoot !== 'function') {
5186
+ return fail(503, 'Conversation workspace updates are unavailable on this relay. Nothing was changed.', { code: SESSION_SETTINGS_INVALID });
5187
+ }
5188
+ const validated = validateRequestedWorkspaceRoot(wanted.cwd, { allowList: workspaceRootAllowList });
5189
+ if (!validated.ok) {
5190
+ return fail(validated.code === 'root-path-not-allowed' ? 403 : 400, `${validated.error || 'cwd is not a folder this relay may use'}. Nothing was changed.`, { code: validated.code || SESSION_SETTINGS_INVALID });
5191
+ }
5192
+ if (normalizeWorkspaceRootKey(validated.realPath) !== normalizeWorkspaceRootKey(current.cwd || '')) {
5193
+ const busy = sessionBusyReason(conversationId);
5194
+ if (busy) {
5195
+ return fail(409, `The session cannot move to another folder now: ${busy}. Wait for it to finish (wait or read_session), then try again. Nothing was changed.`, { code: 'SESSION_BUSY', reason: busy });
5196
+ }
5197
+ plan.cwd = validated.realPath;
5198
+ change('cwd');
5199
+ }
5200
+ }
5201
+ plan.changed.sort((a, b) => REMOTE_RELAY_SESSION_SETTINGS.indexOf(a) - REMOTE_RELAY_SESSION_SETTINGS.indexOf(b));
5202
+ return { ok: true, plan };
5203
+ }
5204
+
5205
+ /** The session's folder through the owner's CWD path: saved, and its CLI relaunched there when it has one. */
5206
+ async function applySessionCwd(plan, req) {
5207
+ const { conversationId } = plan;
5208
+ const rateLimited = consumeWorkspaceRootRateLimit(conversationId, req);
5209
+ if (!rateLimited.ok) {
5210
+ return { ok: false, status: 429, body: { ok: false, code: 'rate-limited', error: 'Too many CWD updates. Try again shortly. Nothing was changed.' } };
5211
+ }
5212
+ const sdkSessionId = String(resolveConversationWorkspaceState?.({ conversationId })?.sdkSessionId || '').trim();
5213
+ if (!sdkSessionId) {
5214
+ // Never launched: the folder is saved for its first launch.
5215
+ const saved = await sessionLaunchMutex.runExclusive(`conv:${conversationId}`, () => {
5216
+ const busy = sessionBusyReason(conversationId);
5217
+ return busy ? { ok: false, busyReason: busy } : updateConversationConfiguredWorkspaceRoot({ conversationId, rootPath: plan.cwd });
5218
+ });
5219
+ if (saved?.busyReason) {
5220
+ return { ok: false, status: 409, body: { ok: false, code: 'SESSION_BUSY', reason: saved.busyReason, error: `The session cannot move to another folder now: ${saved.busyReason}. Wait for it to finish, then try again. Nothing was changed.` } };
5221
+ }
5222
+ if (!saved?.ok) return { ok: false, status: 400, body: { ok: false, error: `${saved?.error || 'Failed to update the session\'s folder'}. Nothing was changed.` } };
5223
+ const state = saved.state || null;
5224
+ io.emit('conversation_workspace_root_updated', {
5225
+ conversationId,
5226
+ sdkSessionId: state?.sdkSessionId || null,
5227
+ configuredWorkspaceRootPath: state?.configuredWorkspaceRootPath || null,
5228
+ configuredWorkspaceRootName: state?.configuredWorkspaceRootName || null,
5229
+ runtimeWorkspaceRootPath: state?.runtimeWorkspaceRootPath || null,
5230
+ runtimeWorkspaceRootName: state?.runtimeWorkspaceRootName || null,
5231
+ currentWorkspaceRootPath: state?.currentWorkspaceRootPath || null,
5232
+ currentWorkspaceRootName: state?.currentWorkspaceRootName || null,
5233
+ recentWorkspaceRoots: Array.isArray(workspaceRootPayload()?.recentWorkspaceRoots) ? workspaceRootPayload().recentWorkspaceRoots : [],
5234
+ });
5235
+ return { ok: true, relaunched: false };
5236
+ }
5237
+ let attempt;
5238
+ try {
5239
+ attempt = await sessionLaunchMutex.tryRunExclusive(sdkSessionId, () => {
5240
+ // Asked again under the lock, right before the relaunch: a turn or a
5241
+ // task may have started since the plan.
5242
+ const busy = sessionBusyReason(conversationId);
5243
+ if (busy) return { busyReason: busy };
5244
+ return runWorkspaceRootRelaunch({ conversationId, sdkSessionId, rootPath: plan.cwd });
5245
+ });
5246
+ } catch (error) {
5247
+ return { ok: false, status: 500, body: { ok: false, code: 'relaunch-error', error: error?.message || 'Failed to relaunch the CLI' } };
5248
+ }
5249
+ if (attempt.busy) {
5250
+ return { ok: false, status: 409, body: { ok: false, code: 'SESSION_BUSY', reason: 'its CLI is being relaunched', error: 'The session cannot move to another folder now: its CLI is being relaunched. Try again shortly. Nothing was changed.' } };
5251
+ }
5252
+ const outcome = attempt.result || {};
5253
+ if (outcome.busyReason) {
5254
+ return { ok: false, status: 409, body: { ok: false, code: 'SESSION_BUSY', reason: outcome.busyReason, error: `The session cannot move to another folder now: ${outcome.busyReason}. Wait for it to finish, then try again. Nothing was changed.` } };
5255
+ }
5256
+ // The relay could not read its process list, so it cannot tell whether
5257
+ // the session's CLI still runs: nothing is stopped or started.
5258
+ if (/^process-list-unreadable/.test(String(outcome.error || ''))) {
5259
+ return {
5260
+ ok: false,
5261
+ status: 503,
5262
+ body: {
5263
+ ok: false,
5264
+ code: 'PROCESS_LIST_UNREADABLE',
5265
+ error: 'This relay could not read its process list, so it cannot tell whether the session\'s CLI still runs; the folder was not changed. Try again shortly. Nothing was changed.',
5266
+ },
5267
+ };
5268
+ }
5269
+ if (outcome.statusCode !== 200 || outcome.body?.ok !== true) {
5270
+ const body = outcome.body || {};
5271
+ const busy = body.code === 'relaunch-not-eligible';
5272
+ // A CLI that would not stop or start: the folder is saved for the next
5273
+ // launch all the same (the owner's route does the same), so cwd changed.
5274
+ const saved = !busy && !!body.configuredWorkspaceRootPath;
5275
+ return {
5276
+ ok: false,
5277
+ status: outcome.statusCode || 500,
5278
+ body: {
5279
+ ...body,
5280
+ ok: false,
5281
+ ...(busy ? { code: 'SESSION_BUSY', reason: 'a turn is queued or running there' } : {}),
5282
+ error: busy
5283
+ ? `The session cannot move to another folder now: ${body.error || 'it is busy'}. Nothing was changed.`
5284
+ : `${body.error || 'Failed to relaunch the CLI'}${saved ? ' The folder is saved for its next launch; nothing else was changed.' : ' Nothing was changed.'}`,
5285
+ changed: saved ? ['cwd'] : [],
5286
+ },
5287
+ };
5288
+ }
5289
+ return {
5290
+ ok: true,
5291
+ relaunched: outcome.body.relaunched === true,
5292
+ ...(outcome.body.relaunched === true ? {} : { warning: outcome.body.message || 'The folder is saved for the next launch, but the running CLI kept its current folder.' }),
5293
+ };
5294
+ }
5295
+
5296
+ app.patch('/api/conversation/:id/settings', auth, async (req, res) => {
5297
+ const remoteRequest = admitRemoteRelayRequest(req, res, remoteRelayInbound);
5298
+ if (!remoteRequest) return;
5299
+ const requestedId = String(req.params.id || '').trim();
5300
+ const conversation = requestedId ? resolveConversationByIdOrSdkSessionId(requestedId) : null;
5301
+ if (!conversation || String(conversation.status || '').trim() === 'deleted') {
5302
+ return res.status(404).json({ ok: false, code: 'SESSION_NOT_FOUND', error: 'Conversation not found' });
5303
+ }
5304
+ const conversationId = String(conversation.id);
5305
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
5306
+ const planned = planSessionSettings(conversation, body.settings);
5307
+ if (!planned.ok) return res.status(planned.status).json(planned.body);
5308
+ const { plan } = planned;
5309
+ const createdBy = parseRemoteRelayOriginJson(conversation.origin_json);
5310
+ const byCaller = sessionStartedByCaller(createdBy, remoteRequest);
5311
+ // An agent changes freely only a session its own conversation started;
5312
+ // any other (the owner's, another agent's, its own creator's) needs the
5313
+ // owner's Allow, which the calling relay asks for and states.
5314
+ const approvalNeeded = remoteRequest.remote === true && !byCaller;
5315
+ const facts = { session: conversationId, provider: plan.provider, ownerStarted: !createdBy, startedByCaller: byCaller, approvalNeeded };
5316
+ if (body.dry_run === true) {
5317
+ return res.json({
5318
+ ok: true,
5319
+ dryRun: true,
5320
+ ...facts,
5321
+ settings: plan.current,
5322
+ changed: plan.changed,
5323
+ ...(plan.group ? { group: plan.group } : {}),
5324
+ ...(plan.notes.length ? { notes: plan.notes } : {}),
5325
+ });
5326
+ }
5327
+ // This relay's own rule, whatever the calling relay checked.
5328
+ if (approvalNeeded && body.owner_approved !== true) {
5329
+ return res.status(403).json({
5330
+ ok: false,
5331
+ code: REMOTE_RELAY_ERROR_CODES.permission,
5332
+ ...facts,
5333
+ error: 'This session was not started by the calling conversation: an agent changes its settings only after the relay\'s owner allowed that for the calling conversation.',
5334
+ });
5335
+ }
5336
+ if (!plan.changed.length) {
5337
+ return res.json({ ok: true, ...facts, settings: plan.current, changed: [], ...(plan.notes.length ? { notes: plan.notes } : {}) });
5338
+ }
5339
+
5340
+ // What is written so far: a step that fails later answers it, so a
5341
+ // partial change is never passed off as none.
5342
+ const written = [];
5343
+ const failAfter = (status, payload) => res.status(status).json({
5344
+ ...payload,
5345
+ ok: false,
5346
+ error: `${payload.error}${written.length ? ` These settings were changed before: ${written.join(', ')}.` : ' Nothing was changed.'}`,
5347
+ changed: [...written],
4808
5348
  });
5349
+ // The folder first: it is the one change that can fail after the checks
5350
+ // (a CLI that does not stop or start), and then nothing else is written.
5351
+ let cwdResult = null;
5352
+ if (plan.cwd) {
5353
+ cwdResult = await applySessionCwd(plan, req);
5354
+ if (!cwdResult.ok) return res.status(cwdResult.status).json(cwdResult.body);
5355
+ written.push('cwd');
5356
+ }
5357
+ if (plan.title !== undefined) {
5358
+ const titled = persistConversationTitle({ db, stmts, io, conversationId, title: plan.title, resolveSessionStateRoot });
5359
+ if (!titled.ok) return failAfter(titled.statusCode || 500, { error: titled.error || 'Failed to update the title.' });
5360
+ written.push('title');
5361
+ }
5362
+ if (Object.keys(plan.preferences).length) {
5363
+ applyConversationPreferences({
5364
+ conversationId,
5365
+ existing: stmts.getConvAnyStatus.get(conversationId),
5366
+ body: plan.preferences,
5367
+ });
5368
+ written.push(...plan.changed.filter((key) => !['cwd', 'title', 'group'].includes(key)));
5369
+ }
5370
+ // Groups through the agent path: an owner's group the session leaves is
5371
+ // never removed (it stays, empty if it was the last member); a group an
5372
+ // agent made goes once empty.
5373
+ const groupOptions = { keepOwnerGroups: remoteRequest.remote === true };
5374
+ let groupCreated = null;
5375
+ let groupPlan = plan.group;
5376
+ if (groupPlan?.action === 'create') {
5377
+ // Looked up once more right before it is made: a call that ran beside
5378
+ // this one may have made the group of that name meanwhile.
5379
+ const groups = listGroupsForAgents();
5380
+ const found = resolveSessionGroupReference(groups, groupPlan.name);
5381
+ if (found.status === 'found') groupPlan = { action: 'join', id: found.group.id, name: found.group.name };
5382
+ // Several of that name by now: refused as the first lookup refuses.
5383
+ if (found.status === 'ambiguous') {
5384
+ const refusal = bootstrapGroupRefusal(found, groupPlan.name, groups);
5385
+ return failAfter(400, {
5386
+ code: refusal.code,
5387
+ error: refusal.error.replace(' No conversation was created.', ''),
5388
+ groups: refusal.groups,
5389
+ ...(refusal.groupsTotal ? { groupsTotal: refusal.groupsTotal } : {}),
5390
+ });
5391
+ }
5392
+ }
5393
+ if (groupPlan?.action === 'leave') {
5394
+ const left = conversationGroups.setMembership(conversationId, null, undefined, groupOptions);
5395
+ if (left.ok && left.changed) emitGroupChanged(left.previousGroupId, [conversationId]);
5396
+ } else if (groupPlan?.action === 'join') {
5397
+ const joined = conversationGroups.setMembership(conversationId, groupPlan.id, undefined, groupOptions);
5398
+ if (!joined.ok) {
5399
+ return failAfter(400, { code: 'SESSION_GROUP_NOT_FOUND', error: `The session group ${groupPlan.id} is gone.` });
5400
+ }
5401
+ if (joined.changed) {
5402
+ emitGroupChanged(groupPlan.id, [conversationId]);
5403
+ if (joined.previousGroupId && joined.previousGroupId !== groupPlan.id) emitGroupChanged(joined.previousGroupId, [conversationId]);
5404
+ }
5405
+ } else if (groupPlan?.action === 'create') {
5406
+ const previousGroupId = conversationGroups.groupOfConversation(conversationId)?.id || null;
5407
+ const group = conversationGroups.createGroup({ name: groupPlan.name, conversationIds: [conversationId], agentMade: remoteRequest.remote === true, ...groupOptions });
5408
+ groupCreated = group ? { id: group.id, name: group.name } : null;
5409
+ if (group) emitGroupChanged(group.id, [conversationId]);
5410
+ if (previousGroupId) emitGroupChanged(previousGroupId, [conversationId]);
5411
+ }
4809
5412
 
5413
+ const notes = [...plan.notes];
5414
+ if (cwdResult?.warning) notes.push(cwdResult.warning);
4810
5415
  return res.json({
4811
5416
  ok: true,
4812
- conversationId,
4813
- preferredRelayMode: persisted.preferredRelayMode,
4814
- preferredModel: persisted.preferredModel,
4815
- preferredReasoningEffort: persisted.preferredReasoningEffort,
4816
- autoCompactWindow: persisted.autoCompactWindow ?? null,
4817
- thinkingEnabled: persisted.thinkingEnabled ?? null,
4818
- thinkingDisplay: persisted.thinkingDisplay ?? null,
4819
- updatedAt: persisted.updatedAt,
4820
- created: persisted.created,
4821
- senderClientId,
5417
+ ...facts,
5418
+ settings: currentSessionSettings(conversationId),
5419
+ changed: plan.changed,
5420
+ ...(cwdResult ? { relaunched: cwdResult.relaunched } : {}),
5421
+ ...(groupCreated ? { groupCreated } : {}),
5422
+ ...(notes.length ? { notes } : {}),
4822
5423
  });
4823
5424
  });
4824
5425
 
@@ -5426,6 +6027,8 @@ export function registerSessionsRoutes(app, deps) {
5426
6027
  if (wasShared) io.emit('share_revoked', { conversationId: id });
5427
6028
  if (leftGroupId) emitGroupChanged(leftGroupId, [id]);
5428
6029
  try { relayEventService?.noteConversationGone?.(id, { deleted: true }); } catch {}
6030
+ try { relayScheduleService?.noteConversationGone?.(id, { deleted: true }); } catch {}
6031
+ try { relayAskService?.noteConversationGone?.(id); } catch {}
5429
6032
  return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
5430
6033
  }
5431
6034
 
@@ -5516,6 +6119,9 @@ export function registerSessionsRoutes(app, deps) {
5516
6119
  io.emit('conversation_archived', { conversationId: id, archived: true });
5517
6120
  // Its subscriptions end, and so does its place in others'.
5518
6121
  try { relayEventService?.noteConversationGone?.(id); } catch {}
6122
+ // So do its schedules.
6123
+ try { relayScheduleService?.noteConversationGone?.(id); } catch {}
6124
+ try { relayAskService?.noteConversationGone?.(id); } catch {}
5519
6125
  // The cloud session of a Claude Cloud conversation is archived with it;
5520
6126
  // best effort and not waited for.
5521
6127
  if (!sharedWithConversationId) {
@@ -5700,6 +6306,12 @@ export function registerSessionsRoutes(app, deps) {
5700
6306
  ...(Array.isArray(grokSettings?.models) ? grokSettings.models : []),
5701
6307
  ].map((value) => String(value || '').trim()).filter(Boolean);
5702
6308
  const availableGrokModels = new Set(grokModelCatalog.map((value) => value.toLowerCase()));
6309
+ const deepseekSettings = getDeepSeekProviderSettings();
6310
+ const deepseekModelCatalog = [
6311
+ String(deepseekSettings?.model || '').trim(),
6312
+ ...(Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : []),
6313
+ ].map((value) => String(value || '').trim()).filter(Boolean);
6314
+ const availableDeepSeekModels = new Set(deepseekModelCatalog.map((value) => value.toLowerCase()));
5703
6315
  const useOpenAIProvider = requestedProviderType === 'openai'
5704
6316
  || requestedProviderType === 'openai-image'
5705
6317
  || (
@@ -5735,6 +6347,15 @@ export function registerSessionsRoutes(app, deps) {
5735
6347
  && availableGrokModels.has(requestedBootstrapModel.toLowerCase())
5736
6348
  )
5737
6349
  );
6350
+ const useDeepSeekProvider = !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider && (
6351
+ requestedProviderType === 'deepseek'
6352
+ || (
6353
+ requestedProviderType === ''
6354
+ && requestedBootstrapModel
6355
+ && requestedBootstrapModel.toLowerCase() !== 'auto'
6356
+ && availableDeepSeekModels.has(requestedBootstrapModel.toLowerCase())
6357
+ )
6358
+ );
5738
6359
  // Managed providers resolve the request against their own catalog only.
5739
6360
  // Routing it through the merged Copilot catalog first (resolveBootstrapModelSelection)
5740
6361
  // silently substituted that catalog's current model whenever the requested
@@ -5780,7 +6401,13 @@ export function registerSessionsRoutes(app, deps) {
5780
6401
  configuredModel: grokSettings.model,
5781
6402
  availableModels: grokModelCatalog,
5782
6403
  })
5783
- : null)));
6404
+ : (useDeepSeekProvider
6405
+ ? resolveProviderModelSelection({
6406
+ requestedModel: providerRequestedModel(deepseekModelCatalog),
6407
+ configuredModel: deepseekSettings.model,
6408
+ availableModels: deepseekModelCatalog,
6409
+ })
6410
+ : null))));
5784
6411
  // The single rejection for "this provider does not offer that model".
5785
6412
  // Per-provider pre-checks used to duplicate it and had drifted apart in
5786
6413
  // wording, casing rules and whether they listed the alternatives.
@@ -5789,14 +6416,16 @@ export function registerSessionsRoutes(app, deps) {
5789
6416
  ? 'Claude Cloud'
5790
6417
  : (useOpenAIProvider
5791
6418
  ? 'OpenAI'
5792
- : (useClaudeProvider ? 'Claude' : (useCursorProvider ? 'Cursor' : 'Grok')));
6419
+ : (useClaudeProvider ? 'Claude' : (useCursorProvider ? 'Cursor' : (useGrokProvider ? 'Grok' : 'DeepSeek'))));
5793
6420
  const providerCode = useClaudeCloudProvider
5794
6421
  ? 'CLAUDE_CLOUD_MODEL_UNAVAILABLE'
5795
6422
  : (useOpenAIProvider
5796
6423
  ? 'OPENAI_MODEL_UNAVAILABLE'
5797
6424
  : (useClaudeProvider
5798
6425
  ? 'CLAUDE_MODEL_UNAVAILABLE'
5799
- : (useCursorProvider ? 'CURSOR_MODEL_UNAVAILABLE' : 'GROK_MODEL_UNAVAILABLE')));
6426
+ : (useCursorProvider
6427
+ ? 'CURSOR_MODEL_UNAVAILABLE'
6428
+ : (useGrokProvider ? 'GROK_MODEL_UNAVAILABLE' : 'DEEPSEEK_MODEL_UNAVAILABLE'))));
5800
6429
  return res.status(400).json({
5801
6430
  ok: false,
5802
6431
  error: `${providerLabel} model "${providerModelSelection.requestedModel}" is not available`,
@@ -5816,6 +6445,7 @@ export function registerSessionsRoutes(app, deps) {
5816
6445
  if (
5817
6446
  remoteRequest.remote
5818
6447
  && !useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider
6448
+ && !useDeepSeekProvider
5819
6449
  ) {
5820
6450
  const mergedModels = Array.isArray(modelState?.models) ? modelState.models : [];
5821
6451
  const providersByModel = modelState?.providersByModel && typeof modelState.providersByModel === 'object'
@@ -5881,6 +6511,39 @@ export function registerSessionsRoutes(app, deps) {
5881
6511
  code: 'GROK_NOT_CONFIGURED',
5882
6512
  });
5883
6513
  }
6514
+ if (useDeepSeekProvider && deepseekSettings?.enabled !== true) {
6515
+ return res.status(400).json({
6516
+ ok: false,
6517
+ error: deepseekSettings?.configured === true
6518
+ ? 'DeepSeek is switched off for new conversations'
6519
+ : 'DeepSeek API key is not configured',
6520
+ code: 'DEEPSEEK_NOT_CONFIGURED',
6521
+ });
6522
+ }
6523
+
6524
+ // The session group an agent's create_session names (an id or an exact
6525
+ // name): resolved before anything is written, so an unknown one creates
6526
+ // nothing. It wins over the creator's group. Only the row created here is
6527
+ // ever placed; the group routes stay the owner's.
6528
+ const rawGroup = req.body?.group;
6529
+ if (rawGroup !== undefined && rawGroup !== null && typeof rawGroup !== 'string') {
6530
+ return res.status(400).json({ ok: false, code: 'SESSION_GROUP_INVALID', error: 'group must be text: a group id or name' });
6531
+ }
6532
+ const requestedGroup = String(rawGroup || '').trim();
6533
+ let bootstrapGroupId = '';
6534
+ if (requestedGroup) {
6535
+ if (!conversationGroups?.enabled) {
6536
+ return res.status(503).json({
6537
+ ok: false,
6538
+ code: 'SESSION_GROUPS_UNAVAILABLE',
6539
+ error: 'Session groups are not available on this relay (its storage for them is missing). No conversation was created.',
6540
+ });
6541
+ }
6542
+ const groups = listGroupsForAgents();
6543
+ const found = resolveSessionGroupReference(groups, requestedGroup);
6544
+ if (found.status !== 'found') return res.status(400).json(bootstrapGroupRefusal(found, requestedGroup, groups));
6545
+ bootstrapGroupId = found.group.id;
6546
+ }
5884
6547
 
5885
6548
  // Optional launch CWD from the New Chat modal. Validated before insertConv
5886
6549
  // so a rejected path never leaves an orphaned conversation behind.
@@ -5911,7 +6574,7 @@ export function registerSessionsRoutes(app, deps) {
5911
6574
  code: 'OPENAI_IMAGE_MODEL_REQUIRED',
5912
6575
  });
5913
6576
  }
5914
- if (!useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider) {
6577
+ if (!useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider && !useDeepSeekProvider) {
5915
6578
  const selectedProviders = Array.isArray(modelState?.providersByModel?.[String(selectedModel || '').trim().toLowerCase()])
5916
6579
  ? modelState.providersByModel[String(selectedModel || '').trim().toLowerCase()]
5917
6580
  : [];
@@ -5951,6 +6614,15 @@ export function registerSessionsRoutes(app, deps) {
5951
6614
  code: 'GROK_PROVIDER_REQUIRED',
5952
6615
  });
5953
6616
  }
6617
+ const deepseekOnlySelection = selectedProviders.length > 0
6618
+ && selectedProviders.every((provider) => String(provider || '').trim().toLowerCase() === DEEPSEEK_PROVIDER_TYPE);
6619
+ if (deepseekOnlySelection) {
6620
+ return res.status(400).json({
6621
+ ok: false,
6622
+ error: `Model "${selectedModel}" requires the DeepSeek provider`,
6623
+ code: 'DEEPSEEK_PROVIDER_REQUIRED',
6624
+ });
6625
+ }
5954
6626
  }
5955
6627
 
5956
6628
  const bootstrapProviderType = useClaudeCloudProvider
@@ -5961,7 +6633,7 @@ export function registerSessionsRoutes(app, deps) {
5961
6633
  ? 'claude'
5962
6634
  : (useCursorProvider
5963
6635
  ? 'cursor'
5964
- : (useGrokProvider ? 'grok' : 'github'))));
6636
+ : (useGrokProvider ? 'grok' : (useDeepSeekProvider ? 'deepseek' : 'github')))));
5965
6637
  // Preferences are written in the same transaction as the conversation, so
5966
6638
  // the caller's current mode has to arrive with the request: without it every
5967
6639
  // new chat would be stored as the default one and reset the composer. An
@@ -5977,6 +6649,9 @@ export function registerSessionsRoutes(app, deps) {
5977
6649
  const bootstrapReasoning = resolveProviderReasoningEffort({
5978
6650
  requestedEffort: req.body?.reasoningEffort || copilotVariantEffort,
5979
6651
  strict: bootstrapProviderType === 'openai',
6652
+ ...(bootstrapProviderType === 'deepseek'
6653
+ ? { defaultEffort: DEFAULT_DEEPSEEK_REASONING_EFFORT, aliasFor: deepseekReasoningEffortFor }
6654
+ : {}),
5980
6655
  supportedEfforts: supportedReasoningEffortsForProviderModel({
5981
6656
  providerType: bootstrapProviderType,
5982
6657
  model: selectedModel,
@@ -6000,14 +6675,23 @@ export function registerSessionsRoutes(app, deps) {
6000
6675
  // binding and preferences: every later reader treats these three as a
6001
6676
  // single canonical tuple.
6002
6677
  let attachedGroupId = null;
6678
+ let attachedGroupIds = [];
6003
6679
  const commitBootstrapState = db.transaction(() => {
6004
6680
  stmts.insertConv.run(conversationId, title, now, now);
6005
6681
  if (remoteRequest.origin) remoteRelayInbound?.repository?.setConversationOrigin?.(conversationId, remoteRequest.origin);
6006
- // A session an agent of this relay created joins its creator's group
6007
- // (or starts one with the creator as head). A creator on a paired
6008
- // relay has no local flag and groups nothing.
6009
- if (remoteRequest.origin?.local === true && remoteRequest.origin.conversationId) {
6682
+ if (bootstrapGroupId) {
6683
+ // The group named in the request. Dissolved since it was looked up:
6684
+ // the whole bootstrap rolls back, and the caller hears it as unknown.
6685
+ const placed = conversationGroups.setMembership(conversationId, bootstrapGroupId, now);
6686
+ if (!placed?.ok) throw Object.assign(new Error('session group gone'), { bootstrapGroupGone: true });
6687
+ attachedGroupId = bootstrapGroupId;
6688
+ attachedGroupIds = [conversationId];
6689
+ } else if (remoteRequest.origin?.local === true && remoteRequest.origin.conversationId) {
6690
+ // A session an agent of this relay created joins its creator's group
6691
+ // (or starts one with the creator as head). A creator on a paired
6692
+ // relay has no local flag and groups nothing.
6010
6693
  attachedGroupId = conversationGroups?.attachCreatedConversation?.(conversationId, remoteRequest.origin.conversationId, now) || null;
6694
+ attachedGroupIds = [conversationId, remoteRequest.origin.conversationId];
6011
6695
  }
6012
6696
  const boundRuntimeSession = ensureRuntimeSessionBinding(
6013
6697
  conversationId,
@@ -6017,7 +6701,7 @@ export function registerSessionsRoutes(app, deps) {
6017
6701
  {
6018
6702
  assignConfiguredProvider: true,
6019
6703
  providerType: bootstrapProviderType,
6020
- providerModel: (useClaudeCloudProvider || useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider) ? selectedModel : null,
6704
+ providerModel: (useClaudeCloudProvider || useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider || useDeepSeekProvider) ? selectedModel : null,
6021
6705
  },
6022
6706
  );
6023
6707
  // What the cloud session will clone, stored with the conversation so
@@ -6037,7 +6721,7 @@ export function registerSessionsRoutes(app, deps) {
6037
6721
  return { boundRuntimeSession, preferences };
6038
6722
  });
6039
6723
  const { boundRuntimeSession, preferences } = commitBootstrapState();
6040
- if (attachedGroupId) emitGroupChanged(attachedGroupId, [conversationId, remoteRequest.origin.conversationId]);
6724
+ if (attachedGroupId) emitGroupChanged(attachedGroupId, attachedGroupIds);
6041
6725
  const runtimeSession = boundRuntimeSession;
6042
6726
  const preferredRelayMode = preferences?.preferredRelayMode || bootstrapRelayMode;
6043
6727
  const preferredModel = preferences?.preferredModel || selectedModel;
@@ -6138,6 +6822,9 @@ export function registerSessionsRoutes(app, deps) {
6138
6822
  cloud: bootstrapCloudSource
6139
6823
  ? buildConversationCloudPayload({ cloudSourceJson: bootstrapCloudSource })
6140
6824
  : null,
6825
+ // Always present (null outside a group): a caller that asked for a
6826
+ // group tells this relay from one that predates it by the key.
6827
+ group: conversationGroupField(conversationId),
6141
6828
  runtimeSessionId: runtimeSession?.id || null,
6142
6829
  ownerSessionId,
6143
6830
  worker: ownerWorker,
@@ -6156,6 +6843,10 @@ export function registerSessionsRoutes(app, deps) {
6156
6843
  ...workspaceRootPayload(),
6157
6844
  });
6158
6845
  } catch (error) {
6846
+ if (error?.bootstrapGroupGone) {
6847
+ const groups = listGroupsForAgents();
6848
+ return res.status(400).json(bootstrapGroupRefusal({ status: 'unknown' }, requestedGroup, groups));
6849
+ }
6159
6850
  console.warn(`[bootstrap] conversation bootstrap failed: ${error?.message || error}`);
6160
6851
  return res.status(500).json({
6161
6852
  ok: false,
@@ -6985,6 +7676,40 @@ export function registerSessionsRoutes(app, deps) {
6985
7676
  });
6986
7677
  });
6987
7678
 
7679
+ // A rotated API key only reaches a worker's environment at spawn: live
7680
+ // workers of the provider keep the old key and fail each turn after the
7681
+ // rotation. Stops them; the primer or the next delivery respawns them with
7682
+ // the fresh key from settings.
7683
+ async function stopLiveProviderWorkersForKeyRotation(providerType) {
7684
+ const rows = db.prepare(`
7685
+ SELECT DISTINCT NULLIF(sdk_session_id, '') AS sdk_session_id
7686
+ FROM runtime_sessions
7687
+ WHERE LOWER(COALESCE(provider_type, '')) = ?
7688
+ AND NULLIF(sdk_session_id, '') IS NOT NULL
7689
+ `).all(providerType);
7690
+ for (const row of rows) {
7691
+ const sid = String(row.sdk_session_id || '').trim();
7692
+ if (!sid) continue;
7693
+ const workerState = sessionWorkerSupervisor?.getWorkerState?.(sid) || null;
7694
+ if (!workerState) continue;
7695
+ try {
7696
+ const stopped = await stopIdleWorkspaceRootSession({
7697
+ sdkSessionId: sid,
7698
+ worker: sessionWorkerRegistry?.getWorker?.(sid) || null,
7699
+ sessionWorkerSupervisor,
7700
+ sessionWorkerRegistry,
7701
+ sessionWorkerProcessInspector,
7702
+ });
7703
+ if (stopped.ok) {
7704
+ sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
7705
+ console.log(`[sessions] ${providerType} worker ${sid.slice(0, 8)} stopped for API key rotation`);
7706
+ }
7707
+ } catch (error) {
7708
+ console.warn(`[sessions] ${providerType} worker ${sid.slice(0, 8)} stop failed on key rotation: ${error?.message || error}`);
7709
+ }
7710
+ }
7711
+ }
7712
+
6988
7713
  app.get('/api/settings/cursor', auth, (_req, res) => {
6989
7714
  const settings = getCursorProviderSettings();
6990
7715
  return res.json({
@@ -7016,33 +7741,7 @@ export function registerSessionsRoutes(app, deps) {
7016
7741
  // fail each turn after the rotation. Stop them now; the primer or the
7017
7742
  // next delivery respawns them with the fresh key from settings.
7018
7743
  if (parsed.apiKey && previous?.configured === true) {
7019
- const cursorSessionRows = db.prepare(`
7020
- SELECT DISTINCT NULLIF(sdk_session_id, '') AS sdk_session_id
7021
- FROM runtime_sessions
7022
- WHERE LOWER(COALESCE(provider_type, '')) = 'cursor'
7023
- AND NULLIF(sdk_session_id, '') IS NOT NULL
7024
- `).all();
7025
- for (const row of cursorSessionRows) {
7026
- const sid = String(row.sdk_session_id || '').trim();
7027
- if (!sid) continue;
7028
- const workerState = sessionWorkerSupervisor?.getWorkerState?.(sid) || null;
7029
- if (!workerState) continue;
7030
- try {
7031
- const stopped = await stopIdleWorkspaceRootSession({
7032
- sdkSessionId: sid,
7033
- worker: sessionWorkerRegistry?.getWorker?.(sid) || null,
7034
- sessionWorkerSupervisor,
7035
- sessionWorkerRegistry,
7036
- sessionWorkerProcessInspector,
7037
- });
7038
- if (stopped.ok) {
7039
- sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
7040
- console.log(`[sessions] cursor worker ${sid.slice(0, 8)} stopped for API key rotation`);
7041
- }
7042
- } catch (error) {
7043
- console.warn(`[sessions] cursor worker ${sid.slice(0, 8)} stop failed on key rotation: ${error?.message || error}`);
7044
- }
7045
- }
7744
+ await stopLiveProviderWorkersForKeyRotation('cursor');
7046
7745
  }
7047
7746
  const shouldDiscover = result.enabled === true && (
7048
7747
  !!parsed.apiKey
@@ -7100,6 +7799,96 @@ export function registerSessionsRoutes(app, deps) {
7100
7799
  });
7101
7800
  });
7102
7801
 
7802
+ // --- DeepSeek ----------------------------------------------------------
7803
+ // The key is written here and never read back: GET and every answer carry
7804
+ // `configured` only. A rotated key reaches a worker only at spawn, so the
7805
+ // live DeepSeek workers are stopped; the next message starts them again.
7806
+ // What the tab shows: the stored settings, and whether the starting
7807
+ // balance still applies (a top-up since it was set replaces it), judged
7808
+ // on the currency the card's bar uses: the last balance read's main one.
7809
+ function describeDeepSeekSettings() {
7810
+ const described = deepseekSettingsService.describe();
7811
+ let references = [];
7812
+ try {
7813
+ references = planUsageService?.readDeepSeekBalanceReferences?.() || [];
7814
+ } catch {}
7815
+ let balances = null;
7816
+ try {
7817
+ balances = deepseekBalanceService?.peekBalance?.()?.balances || null;
7818
+ } catch {}
7819
+ const startingBalanceNote = deepseekStartingBalanceNote(
7820
+ described.startingBalance ? { amount: described.startingBalance, setAt: described.startingBalanceSetAt } : null,
7821
+ references,
7822
+ { balances },
7823
+ );
7824
+ return { ...described, startingBalanceNote };
7825
+ }
7826
+
7827
+ app.get('/api/settings/deepseek', auth, (_req, res) => {
7828
+ if (!deepseekSettingsService) return res.status(501).json({ error: 'DeepSeek settings are unavailable' });
7829
+ return res.json(describeDeepSeekSettings());
7830
+ });
7831
+
7832
+ app.post('/api/settings/deepseek', auth, async (req, res) => {
7833
+ if (!deepseekSettingsService) return res.status(501).json({ error: 'DeepSeek settings are unavailable' });
7834
+ const parsed = parseDeepSeekSettingsUpdateRequest(req.body);
7835
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
7836
+ // A starting balance a top-up has replaced is set anew when it is saved
7837
+ // again, the same amount too.
7838
+ const before = describeDeepSeekSettings();
7839
+ const startingBalanceReplaced = !!before.startingBalanceNote;
7840
+ const result = deepseekSettingsService.update(parsed, { startingBalanceReplaced });
7841
+ if (!result?.ok) {
7842
+ return res.status(result?.code === 'deepseek-key-removal-blocked' ? 409 : 400).json({
7843
+ error: result?.error || 'Failed to update DeepSeek settings',
7844
+ code: result?.code || null,
7845
+ activeConversationCount: Number(result?.activeConversationCount || 0),
7846
+ startedConversationCount: Number(result?.startedConversationCount || 0),
7847
+ activeQueueConversationCount: Number(result?.activeQueueConversationCount || 0),
7848
+ });
7849
+ }
7850
+ if (result.keyChanged) await stopLiveProviderWorkersForKeyRotation(DEEPSEEK_PROVIDER_TYPE);
7851
+ const discovery = result.discover
7852
+ ? await deepseekSettingsService.refreshModels()
7853
+ : { ok: true, models: [], error: null };
7854
+ // A starting balance set anew: the balance is read right then and noted
7855
+ // as the latest read, so a later top-up is a rise from a balance read
7856
+ // after the setting, and one made before it is already in this read
7857
+ // (deepseekStartingBalanceState). A failed read changes nothing: the
7858
+ // setting then stays until a rise seen after a read that followed it.
7859
+ const setAt = result.settings?.startingBalanceSetAt || null;
7860
+ if (setAt && setAt !== before.startingBalanceSetAt) {
7861
+ try {
7862
+ const balance = await deepseekBalanceService?.getBalance?.({ force: true });
7863
+ planUsageService?.recordDeepSeekBalance?.(balance);
7864
+ } catch {}
7865
+ }
7866
+ // Removing the key moves unstarted DeepSeek conversations back to the
7867
+ // default provider, as for OpenAI and Cursor.
7868
+ const reconciliationResult = parsed.remove === true
7869
+ ? await reconcileUnstartedConversationProviders({ enabled: false, model: DEFAULT_DEEPSEEK_MODEL, provider: DEEPSEEK_PROVIDER_TYPE })
7870
+ : { updatedUnstartedConversations: 0, skippedStartedConversations: 0, skippedActiveQueueConversations: 0, failedConversations: [] };
7871
+ const failedConversations = Array.isArray(reconciliationResult?.failedConversations) ? reconciliationResult.failedConversations : [];
7872
+ const settingsPayload = {
7873
+ ...describeDeepSeekSettings(),
7874
+ reconciliation: {
7875
+ updatedUnstartedConversations: Number(reconciliationResult?.updatedUnstartedConversations || 0),
7876
+ skippedStartedConversations: Number(reconciliationResult?.skippedStartedConversations || 0),
7877
+ skippedActiveQueueConversations: Number(reconciliationResult?.skippedActiveQueueConversations || 0),
7878
+ failedConversations,
7879
+ },
7880
+ };
7881
+ io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
7882
+ io.emit('deepseek_settings_updated', settingsPayload);
7883
+ return res.json({
7884
+ ok: true,
7885
+ ...settingsPayload,
7886
+ warning: failedConversations.length
7887
+ ? `${failedConversations.length} unstarted conversation(s) could not switch provider.`
7888
+ : (discovery?.ok ? null : (discovery?.error || 'DeepSeek model discovery failed')),
7889
+ });
7890
+ });
7891
+
7103
7892
  app.get('/api/settings/turn-ceiling', auth, (_req, res) => {
7104
7893
  res.json({
7105
7894
  ceilingMinutes: getTurnCeilingMinutes(),
@@ -7667,6 +8456,7 @@ export function registerSessionsRoutes(app, deps) {
7667
8456
  const claudeSettings = getClaudeProviderSettings();
7668
8457
  const cursorSettings = getCursorProviderSettings();
7669
8458
  const grokSettings = getGrokProviderSettings();
8459
+ const deepseekSettings = getDeepSeekProviderSettings();
7670
8460
  return {
7671
8461
  ...buildModelVariantCatalogPayload({
7672
8462
  rows,
@@ -7696,6 +8486,13 @@ export function registerSessionsRoutes(app, deps) {
7696
8486
  enabledModels: Array.isArray(grokSettings.models) ? grokSettings.models : [],
7697
8487
  }
7698
8488
  : null,
8489
+ deepseekModels: deepseekSettings?.enabled === true
8490
+ ? {
8491
+ defaultModel: deepseekSettings.model,
8492
+ availableModels: Array.isArray(deepseekSettings.availableModels) ? deepseekSettings.availableModels : [],
8493
+ enabledModels: Array.isArray(deepseekSettings.models) ? deepseekSettings.models : [],
8494
+ }
8495
+ : null,
7699
8496
  };
7700
8497
  }
7701
8498
 
@@ -7714,6 +8511,7 @@ export function registerSessionsRoutes(app, deps) {
7714
8511
  const claudeSettings = getClaudeProviderSettings();
7715
8512
  const cursorSettings = getCursorProviderSettings();
7716
8513
  const grokSettings = getGrokProviderSettings();
8514
+ const deepseekSettings = getDeepSeekProviderSettings();
7717
8515
  const refreshTasks = [
7718
8516
  refreshModelVariantCatalogFromCli(),
7719
8517
  openAISettings?.enabled === true
@@ -7734,8 +8532,11 @@ export function registerSessionsRoutes(app, deps) {
7734
8532
  typeof copilotModelDiscoveryService?.refresh === 'function'
7735
8533
  ? copilotModelDiscoveryService.refresh('manual-refresh')
7736
8534
  : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8535
+ deepseekSettings?.enabled === true && deepseekSettingsService
8536
+ ? deepseekSettingsService.refreshModels()
8537
+ : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
7737
8538
  ];
7738
- const [cliRefresh, openAIRefresh, claudeRefresh, cursorRefresh, grokRefresh, copilotDiscoveryRefresh] = await Promise.allSettled(refreshTasks);
8539
+ const [cliRefresh, openAIRefresh, claudeRefresh, cursorRefresh, grokRefresh, copilotDiscoveryRefresh, deepseekRefresh] = await Promise.allSettled(refreshTasks);
7739
8540
  if (cliRefresh.status === 'rejected') throw cliRefresh.reason;
7740
8541
  const openAIModelDiscovery = openAIRefresh.status === 'fulfilled'
7741
8542
  ? openAIRefresh.value
@@ -7765,6 +8566,13 @@ export function registerSessionsRoutes(app, deps) {
7765
8566
  models: Array.isArray(grokSettings?.models) ? grokSettings.models : [],
7766
8567
  error: grokRefresh.reason?.message || 'Grok model discovery failed',
7767
8568
  };
8569
+ const deepseekModelDiscovery = deepseekRefresh.status === 'fulfilled'
8570
+ ? deepseekRefresh.value
8571
+ : {
8572
+ ok: false,
8573
+ models: Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : [],
8574
+ error: deepseekRefresh.reason?.message || 'DeepSeek model discovery failed',
8575
+ };
7768
8576
  const copilotModelDiscovery = copilotDiscoveryRefresh.status === 'fulfilled'
7769
8577
  ? copilotDiscoveryRefresh.value
7770
8578
  : { ok: false, models: [], error: copilotDiscoveryRefresh.reason?.message || 'Copilot model discovery failed' };
@@ -7778,6 +8586,7 @@ export function registerSessionsRoutes(app, deps) {
7778
8586
  claudeModelDiscovery,
7779
8587
  cursorModelDiscovery,
7780
8588
  grokModelDiscovery,
8589
+ deepseekModelDiscovery,
7781
8590
  copilotModelDiscovery,
7782
8591
  });
7783
8592
  } catch (error) {
@@ -7873,8 +8682,9 @@ export function registerSessionsRoutes(app, deps) {
7873
8682
  copilotBilling: null, // { value, at }
7874
8683
  grokBilling: null,
7875
8684
  cursorBilling: null,
8685
+ deepseekBalance: null,
7876
8686
  };
7877
- const USAGE_PROVIDER_IDS = ['github', 'claude', 'claude-cloud', 'cursor', 'grok'];
8687
+ const USAGE_PROVIDER_IDS = ['github', 'claude', 'claude-cloud', 'cursor', 'grok', 'deepseek'];
7878
8688
 
7879
8689
  function parseUsageProviders(raw) {
7880
8690
  const text = Array.isArray(raw) ? raw.join(',') : String(raw ?? '');
@@ -7929,6 +8739,16 @@ export function registerSessionsRoutes(app, deps) {
7929
8739
  const claudeSettings = getClaudeProviderSettings();
7930
8740
  const cursorSettings = getCursorProviderSettings();
7931
8741
  const grokSettings = getGrokProviderSettings();
8742
+ const deepseekSettings = getDeepSeekProviderSettings();
8743
+ const deepseekEnabled = deepseekSettings?.enabled === true;
8744
+ // An unreadable export is no reason to lose the card: the estimate stays.
8745
+ const readDeepSeekActuals = () => {
8746
+ try {
8747
+ return deepseekUsageImportService?.readActuals?.() ?? null;
8748
+ } catch {
8749
+ return null;
8750
+ }
8751
+ };
7932
8752
  let claudeCloudEnabled = false;
7933
8753
  try {
7934
8754
  claudeCloudEnabled = claudeCloudSettingsService?.getSettings?.()?.enabled === true;
@@ -7950,7 +8770,7 @@ export function registerSessionsRoutes(app, deps) {
7950
8770
  : Promise.resolve(null);
7951
8771
 
7952
8772
  // All reads side by side: the modal waits for the slowest, not the sum.
7953
- const [copilot, billingEntry, grokEntry, cursorEntry, claudeAccount] = await Promise.all([
8773
+ const [copilot, billingEntry, grokEntry, cursorEntry, deepseekEntry, claudeAccount] = await Promise.all([
7954
8774
  readCopilotSummary(),
7955
8775
  cachedOrLive('copilotBilling', live('github'), () => (
7956
8776
  typeof fetchCopilotBillingUsage === 'function'
@@ -7963,6 +8783,12 @@ export function registerSessionsRoutes(app, deps) {
7963
8783
  cachedOrLive('cursorBilling', live('cursor'), () => (
7964
8784
  cursorSettings?.enabled === true && typeof fetchCursorDashboardUsage === 'function' ? fetchCursorDashboardUsage() : null
7965
8785
  )),
8786
+ // The service keeps its own minute of cache and answers a failure as
8787
+ // `{ ok: false, … }`, so a failed read replaces the earlier answer here
8788
+ // (which it carries as `previous`).
8789
+ cachedOrLive('deepseekBalance', live('deepseek'), () => (
8790
+ deepseekEnabled && typeof deepseekBalanceService?.getBalance === 'function' ? deepseekBalanceService.getBalance() : null
8791
+ )),
7966
8792
  claudeAccountPromise,
7967
8793
  ]);
7968
8794
 
@@ -7995,6 +8821,14 @@ export function registerSessionsRoutes(app, deps) {
7995
8821
  grokConfigured: grokSettings?.enabled === true,
7996
8822
  grokAllowances: getGrokPlanAllowanceSettings(),
7997
8823
  grokBilling: grokEntry.value,
8824
+ deepseekConfigured: deepseekEnabled,
8825
+ deepseekBalance: deepseekEnabled ? deepseekEntry.value : null,
8826
+ deepseekMonthlyBudget: deepseekEnabled ? (deepseekSettings?.monthlyBudget ?? null) : null,
8827
+ deepseekMonthlyBudgetCurrency: deepseekEnabled ? (deepseekSettings?.monthlyBudgetCurrency ?? null) : null,
8828
+ deepseekStartingBalance: deepseekEnabled && deepseekSettings?.startingBalance
8829
+ ? { amount: deepseekSettings.startingBalance, setAt: deepseekSettings.startingBalanceSetAt }
8830
+ : null,
8831
+ deepseekActuals: deepseekEnabled ? readDeepSeekActuals() : null,
7998
8832
  });
7999
8833
 
8000
8834
  // Legacy top-level fields stay alongside `providers` so an older cached
@@ -8009,6 +8843,7 @@ export function registerSessionsRoutes(app, deps) {
8009
8843
  'claude-cloud': claudeAccount?.fetchedAt || null,
8010
8844
  cursor: cursorEntry.at,
8011
8845
  grok: grokEntry.at,
8846
+ deepseek: deepseekEntry.value?.fetchedAt || deepseekEntry.at,
8012
8847
  },
8013
8848
  });
8014
8849
  });