@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/CHANGELOG.md +305 -0
- package/README.md +12 -11
- package/package.json +2 -1
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
- package/server/claude-worker/claude-session-process.mjs +131 -33
- package/server/claude-worker/claude-session-worker-main.mjs +266 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +282 -13
- package/server/claude-worker/claude-usage-totals.mjs +108 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
- package/server/db-schema.mjs +115 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/public/app/api-client.js +53 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +162 -16
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/conversation-provider-indicator.mjs +7 -0
- package/server/public/app/conversation-view.js +2 -0
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +367 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +54 -5
- package/server/public/app/model-selector-options.mjs +11 -0
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +4 -1
- package/server/public/app/relay-error-ctas.mjs +8 -0
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/settings-modal.js +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +567 -35
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +110 -17
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +19 -3
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +195 -18
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +932 -97
- package/server/server-runtime.mjs +203 -11
- package/server/services/about-service.mjs +1 -0
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +431 -0
- package/server/services/provider-model-selection.mjs +1 -0
- package/server/services/provider-reasoning-effort.mjs +15 -2
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +958 -31
- package/server/services/session-worker-launch-service.mjs +233 -82
- package/server/services/worker-secret-redaction.mjs +318 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +2 -2
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +554 -26
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/shared/steer-settle-failure.mjs +1 -0
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- 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
|
-
|
|
1510
|
+
const claudeCodeProvider = String(providerType || '').trim().toLowerCase();
|
|
1511
|
+
if (claudeCodeProvider === 'claude' || claudeCodeProvider === 'deepseek') {
|
|
1439
1512
|
const nativeSessionId = String(claudeNativeSessionId || '').trim();
|
|
1440
|
-
|
|
1441
|
-
|
|
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
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2509
|
+
return buildModelCatalogWithDeepSeekProvider(
|
|
2510
|
+
buildModelCatalogWithGrokProvider(
|
|
2511
|
+
buildModelCatalogWithCursorProvider(
|
|
2512
|
+
buildModelCatalogWithClaudeProvider(
|
|
2513
|
+
buildModelCatalogWithOpenAIProvider(modelState, openAISettings),
|
|
2514
|
+
getClaudeProviderSettings(),
|
|
2515
|
+
),
|
|
2516
|
+
getCursorProviderSettings(),
|
|
2406
2517
|
),
|
|
2407
|
-
|
|
2518
|
+
getGrokProviderSettings(),
|
|
2408
2519
|
),
|
|
2409
|
-
|
|
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
|
|
2528
|
-
const anchors =
|
|
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
|
|
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
|
|
3502
|
-
// it reports that CLI on its
|
|
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
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
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(
|
|
4936
|
+
const mentions = (key) => Object.prototype.hasOwnProperty.call(body || {}, key);
|
|
4758
4937
|
const preferredRelayMode = normalizeRelayModePreference(
|
|
4759
|
-
mentions('preferredRelayMode') ?
|
|
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') ?
|
|
4945
|
+
mentions('preferredModel') ? body.preferredModel : existing?.preferred_model,
|
|
4767
4946
|
);
|
|
4768
4947
|
const preferredReasoningEffort = normalizePreferredReasoningEffort(
|
|
4769
|
-
mentions('preferredReasoningEffort') ?
|
|
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
|
-
...(
|
|
4785
|
-
? { autoCompactWindow: parseAutoCompactWindow(
|
|
4960
|
+
...(mentions('autoCompactWindow')
|
|
4961
|
+
? { autoCompactWindow: parseAutoCompactWindow(body.autoCompactWindow) }
|
|
4786
4962
|
: {}),
|
|
4787
|
-
...(
|
|
4788
|
-
? { thinkingEnabled: parseThinkingEnabled(
|
|
4963
|
+
...(mentions('thinkingEnabled')
|
|
4964
|
+
? { thinkingEnabled: parseThinkingEnabled(body.thinkingEnabled) }
|
|
4789
4965
|
: {}),
|
|
4790
|
-
...(
|
|
4791
|
-
? { thinkingDisplay: parseThinkingDisplay(
|
|
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
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
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
|
-
:
|
|
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
|
|
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
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
});
|