@oar-sh/oar 0.9.1 → 0.9.3
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/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
import { liveResendsByOriginal } from '../services/steer-resend.mjs';
|
|
2
3
|
import { killTmuxSession } from '../services/session-worker-launch-service.mjs';
|
|
3
4
|
|
|
4
5
|
import fs from 'fs';
|
|
@@ -66,6 +67,11 @@ import {
|
|
|
66
67
|
BACKGROUND_TASK_TIMEOUT_STEP_MINUTES,
|
|
67
68
|
parseBackgroundTaskTimeoutUpdate,
|
|
68
69
|
} from '../../shared/background-task-timeout.mjs';
|
|
70
|
+
import {
|
|
71
|
+
PWA_APP_NAME_DEFAULT,
|
|
72
|
+
PWA_APP_NAME_MAX_LENGTH,
|
|
73
|
+
derivePwaShortName,
|
|
74
|
+
} from '../../shared/pwa-app-name.mjs';
|
|
69
75
|
|
|
70
76
|
export { mapUsageSnapshotRow };
|
|
71
77
|
|
|
@@ -942,13 +948,19 @@ export function normalizePreferredReasoningEffort(value) {
|
|
|
942
948
|
return String(value || '').trim().toLowerCase();
|
|
943
949
|
}
|
|
944
950
|
|
|
945
|
-
|
|
951
|
+
// The web client caps what it saves at the same length (MAX_DRAFT_TEXT_LENGTH
|
|
952
|
+
// in public/app/conversation-draft-sync.mjs); a test pins them together.
|
|
953
|
+
export const MAX_CONVERSATION_DRAFT_LENGTH = 20_000;
|
|
946
954
|
|
|
947
955
|
function normalizeConversationDraftText(value, { maxLength = MAX_CONVERSATION_DRAFT_LENGTH } = {}) {
|
|
948
956
|
const text = String(value ?? '');
|
|
949
957
|
if (!text.trim()) return '';
|
|
950
958
|
if (text.length <= maxLength) return text;
|
|
951
|
-
|
|
959
|
+
// Never split a surrogate pair: SQLite would store the lone half as U+FFFD,
|
|
960
|
+
// and the client (which cuts the same way) would no longer match its draft.
|
|
961
|
+
const cut = text.slice(0, maxLength);
|
|
962
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
963
|
+
return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
|
|
952
964
|
}
|
|
953
965
|
|
|
954
966
|
export function normalizeOptionalIsoTimestamp(value) {
|
|
@@ -1273,7 +1285,7 @@ export function buildSessionWorkerStatusPayload({
|
|
|
1273
1285
|
return {
|
|
1274
1286
|
enabled: featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true,
|
|
1275
1287
|
continuationRoutingEnabled: featureFlags?.SESSION_WORKER_CONTINUATION_ROUTING_ENABLED === true,
|
|
1276
|
-
fallbackRestartEnabled:
|
|
1288
|
+
fallbackRestartEnabled: featureFlags?.SESSION_WORKER_FALLBACK_RESTART_ENABLED === true,
|
|
1277
1289
|
uiState: normalizeWorkerStatusText(snapshot?.health?.uiState, 'white'),
|
|
1278
1290
|
degradedReason: normalizeWorkerStatusText(snapshot?.health?.degradedReason, null),
|
|
1279
1291
|
health: snapshot?.health && typeof snapshot.health === 'object' ? snapshot.health : null,
|
|
@@ -1738,11 +1750,16 @@ export function buildConversationMessages({
|
|
|
1738
1750
|
.map((row) => [String(row?.id || '').trim(), row])
|
|
1739
1751
|
.filter(([id]) => !!id),
|
|
1740
1752
|
);
|
|
1753
|
+
// A stopped steer's marker says "Resent" from the data, so it holds across
|
|
1754
|
+
// reloads and devices (the relay refuses a second Resend on the same basis).
|
|
1755
|
+
const liveResends = liveResendsByOriginal(dbMessages, queueRows);
|
|
1741
1756
|
const normalizedDbMessages = Array.isArray(dbMessages)
|
|
1742
1757
|
? dbMessages.map((message) => {
|
|
1743
1758
|
const id = String(message?.id || '').trim();
|
|
1759
|
+
// The queue's link is pruned with old rows; the message's own column
|
|
1760
|
+
// keeps it, so a reload still anchors the reply under its prompt.
|
|
1744
1761
|
const sourceMessageId = message?.role === 'assistant'
|
|
1745
|
-
? (responseMessageToSourceId.get(id) || undefined)
|
|
1762
|
+
? (responseMessageToSourceId.get(id) || String(message?.source_message_id || '').trim() || undefined)
|
|
1746
1763
|
: undefined;
|
|
1747
1764
|
// A user row's queue entry shares its id; assistant rows reach the same
|
|
1748
1765
|
// entry through the response mapping. Both need it for the effort tag.
|
|
@@ -1770,6 +1787,15 @@ export function buildConversationMessages({
|
|
|
1770
1787
|
timestamp: message?.timestamp,
|
|
1771
1788
|
sourceMessageId,
|
|
1772
1789
|
executedProvider: message?.executed_provider || message?.executedProvider || undefined,
|
|
1790
|
+
// 'continuation' badges a self-started turn; 'absorbed' marks a
|
|
1791
|
+
// reply that continues through the next (steered) user message;
|
|
1792
|
+
// 'folded'/'stopped' are a steer's settle markers. Without this the
|
|
1793
|
+
// live-appended badge/merge/marker vanished on reload.
|
|
1794
|
+
kind: message?.kind || undefined,
|
|
1795
|
+
resendOfMessageId: message?.role === 'user' ? (message?.resend_of_message_id || undefined) : undefined,
|
|
1796
|
+
resentAs: (message?.role === 'assistant' && message?.kind === 'stopped' && sourceMessageId)
|
|
1797
|
+
? (liveResends.get(sourceMessageId) || undefined)
|
|
1798
|
+
: undefined,
|
|
1773
1799
|
};
|
|
1774
1800
|
})
|
|
1775
1801
|
: [];
|
|
@@ -1971,6 +1997,11 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
1971
1997
|
refreshOpenAIProviderModels = async () => ({ ok: false, models: [], error: 'OpenAI model discovery is unavailable' }),
|
|
1972
1998
|
getCopilotProviderSettings = () => ({ engine: 'extension', engines: ['extension', 'sdk'] }),
|
|
1973
1999
|
setCopilotProviderSettings = () => ({ ok: false, error: 'Copilot settings are unavailable' }),
|
|
2000
|
+
// Server-side Copilot model discovery (Phase 5A) — optional so route tests
|
|
2001
|
+
// that don't exercise the catalog can omit it. When present it also gets
|
|
2002
|
+
// the models_updated broadcaster, since only this module knows how to
|
|
2003
|
+
// layer provider models onto the base catalog payload.
|
|
2004
|
+
copilotModelDiscoveryService = null,
|
|
1974
2005
|
getClaudeProviderSettings = () => ({ configured: false, enabled: false, model: 'claude-sonnet-5', models: [] }),
|
|
1975
2006
|
setClaudeProviderSettings = () => ({ ok: false, error: 'Claude settings are unavailable' }),
|
|
1976
2007
|
refreshClaudeProviderModels = async () => ({ ok: false, models: [], error: 'Claude model discovery is unavailable' }),
|
|
@@ -1981,6 +2012,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
1981
2012
|
// Owns the relay-wide single-flight CLI install/update, the descriptor
|
|
1982
2013
|
// table and the binary bindings, on the same terms as claudeAuthService.
|
|
1983
2014
|
cliInstallService = null,
|
|
2015
|
+
updateCheckService = null,
|
|
2016
|
+
updateInstallService = null,
|
|
2017
|
+
buildUpdateStatePayload = null,
|
|
2018
|
+
runningVersion = null,
|
|
1984
2019
|
getCursorProviderSettings = () => ({ configured: false, enabled: false, model: 'composer-2.5', models: [] }),
|
|
1985
2020
|
setCursorProviderSettings = () => ({ ok: false, error: 'Cursor settings are unavailable' }),
|
|
1986
2021
|
refreshCursorProviderModels = async () => ({ ok: false, models: [], error: 'Cursor model discovery is unavailable' }),
|
|
@@ -2057,16 +2092,25 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2057
2092
|
resolveCursorSessionRoot = null,
|
|
2058
2093
|
getTurnCeilingMinutes = () => DEFAULT_TURN_CEILING_MINUTES,
|
|
2059
2094
|
setTurnCeilingMinutes = () => ({ ok: false, error: 'Turn ceiling settings are unavailable' }),
|
|
2095
|
+
getFeatureFlagsSettingsState = () => [],
|
|
2096
|
+
setFeatureFlagSetting = () => ({ ok: false, error: 'Feature settings are unavailable' }),
|
|
2097
|
+
getPwaAppName = () => '',
|
|
2098
|
+
setPwaAppName = () => ({ ok: false, status: 500, error: 'App name settings are unavailable' }),
|
|
2060
2099
|
getBackgroundTaskTimeoutMinutes = () => DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES,
|
|
2061
2100
|
setBackgroundTaskTimeoutMinutes = () => ({ ok: false, error: 'Background task timeout settings are unavailable' }),
|
|
2062
2101
|
markSharedViewerPresence,
|
|
2063
2102
|
getSharedWatcherCount,
|
|
2064
2103
|
statusEventService,
|
|
2065
2104
|
windowsAutostartService,
|
|
2105
|
+
windowsBootAutostartService,
|
|
2066
2106
|
isSha256,
|
|
2067
2107
|
uploadPathForSha,
|
|
2068
2108
|
} = deps;
|
|
2069
2109
|
const sdkSessionSyncService = createSdkSessionSyncService(db);
|
|
2110
|
+
// Generic synchronous transaction runner for the session-sync route: binding
|
|
2111
|
+
// validation, workspace learning and the binding writes must commit or roll
|
|
2112
|
+
// back as one unit (audit #21).
|
|
2113
|
+
const sessionSyncTransaction = db.transaction((work) => work());
|
|
2070
2114
|
|
|
2071
2115
|
/**
|
|
2072
2116
|
* Drops draft references to the given blobs and reclaims any that no longer
|
|
@@ -2089,9 +2133,11 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2089
2133
|
}
|
|
2090
2134
|
}
|
|
2091
2135
|
|
|
2092
|
-
const SDK_DELETE_WAIT_TIMEOUT_MS = 12_000;
|
|
2093
|
-
const SDK_DELETE_POLL_MS = 200;
|
|
2094
2136
|
const SDK_DELETE_STALE_PROCESSING_MS = 60_000;
|
|
2137
|
+
const CLI_SESSION_DELETE_TIMEOUT_MS = 10_000;
|
|
2138
|
+
const LIVE_WORKER_STATUSES = new Set(['starting', 'ready', 'processing']);
|
|
2139
|
+
// A session id that is safe to join into a path under the session-state root.
|
|
2140
|
+
const SESSION_FOLDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
2095
2141
|
const markConversationDeleted = db.prepare(`UPDATE conversations SET status = 'deleted', updated_at = ? WHERE id = ?`);
|
|
2096
2142
|
const getActiveQueueForMessage = db.prepare(`
|
|
2097
2143
|
SELECT id
|
|
@@ -2120,6 +2166,20 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2120
2166
|
WHERE conversation_id = ?
|
|
2121
2167
|
AND status IN ('pending', 'processing', 'parked')
|
|
2122
2168
|
`);
|
|
2169
|
+
const countProcessingConversationQueueRows = db.prepare(`
|
|
2170
|
+
SELECT COUNT(*) AS count
|
|
2171
|
+
FROM queue
|
|
2172
|
+
WHERE conversation_id = ?
|
|
2173
|
+
AND status = 'processing'
|
|
2174
|
+
`);
|
|
2175
|
+
const findOtherLiveConversationForSdkSession = db.prepare(`
|
|
2176
|
+
SELECT id
|
|
2177
|
+
FROM conversations
|
|
2178
|
+
WHERE sdk_session_id = ?
|
|
2179
|
+
AND id != ?
|
|
2180
|
+
AND status != 'deleted'
|
|
2181
|
+
LIMIT 1
|
|
2182
|
+
`);
|
|
2123
2183
|
const getConversationUsageSnapshotAggregate = db.prepare(`
|
|
2124
2184
|
SELECT
|
|
2125
2185
|
COUNT(*) AS snapshot_count,
|
|
@@ -2183,10 +2243,6 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2183
2243
|
const sessionLaunchMutex = createKeyedMutex();
|
|
2184
2244
|
const relaunchCoalescer = createRelaunchCoalescer({ windowMs: RELAUNCH_COALESCE_WINDOW_MS });
|
|
2185
2245
|
|
|
2186
|
-
function sleep(ms) {
|
|
2187
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2188
|
-
}
|
|
2189
|
-
|
|
2190
2246
|
// Layer every enabled provider's models onto the base Copilot catalog.
|
|
2191
2247
|
function buildModelCatalogWithProviders(modelState, openAISettings = getOpenAIProviderSettings()) {
|
|
2192
2248
|
return buildModelCatalogWithGrokProvider(
|
|
@@ -2201,6 +2257,12 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2201
2257
|
);
|
|
2202
2258
|
}
|
|
2203
2259
|
|
|
2260
|
+
// A discovery that lands outside any route (the deferred boot refresh) still
|
|
2261
|
+
// has to reach connected pickers the way a snapshot POST does.
|
|
2262
|
+
copilotModelDiscoveryService?.setOnCatalogUpdated?.(() => {
|
|
2263
|
+
io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
|
|
2264
|
+
});
|
|
2265
|
+
|
|
2204
2266
|
function extractClientIp(req) {
|
|
2205
2267
|
const forwardedForHeader = String(req.headers?.['x-forwarded-for'] || '').trim();
|
|
2206
2268
|
if (forwardedForHeader) {
|
|
@@ -2263,16 +2325,121 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2263
2325
|
return true;
|
|
2264
2326
|
}
|
|
2265
2327
|
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2328
|
+
/**
|
|
2329
|
+
* Whether a conversation is still working, which blocks deleting it: a turn
|
|
2330
|
+
* is running (including one waiting on a question card or an approval) or
|
|
2331
|
+
* background tasks are live. Both only count while the session's worker is
|
|
2332
|
+
* alive: a dead worker runs nothing, and the processing rows or task set it
|
|
2333
|
+
* left behind would otherwise make the conversation undeletable. A session
|
|
2334
|
+
* the registry has never seen may still be running a turn, so its
|
|
2335
|
+
* processing rows count.
|
|
2336
|
+
*
|
|
2337
|
+
* A worker's pid decides whether it is alive; its status only when it has
|
|
2338
|
+
* none. A turn that fails marks a worker that is still running 'error',
|
|
2339
|
+
* and it stays so until its next turn, background tasks and all.
|
|
2340
|
+
*/
|
|
2341
|
+
function describeConversationActivity(conversationId, workerSessionId) {
|
|
2342
|
+
const worker = sessionWorkerRegistry?.getWorker?.(workerSessionId) || null;
|
|
2343
|
+
const workerAlive = !!worker && (worker.pid
|
|
2344
|
+
? isPidAlive(worker.pid)
|
|
2345
|
+
: LIVE_WORKER_STATUSES.has(String(worker.status || '').trim().toLowerCase()));
|
|
2346
|
+
const processingCount = Number(countProcessingConversationQueueRows.get(conversationId)?.count || 0);
|
|
2347
|
+
const taskKeys = [...new Set([conversationId, workerSessionId])];
|
|
2348
|
+
const liveTasks = workerAlive ? taskKeys.flatMap((key) => backgroundTaskStore?.get?.(key) || []) : [];
|
|
2349
|
+
return {
|
|
2350
|
+
turnRunning: processingCount > 0 && (workerAlive || !worker),
|
|
2351
|
+
backgroundTaskCount: liveTasks.length,
|
|
2352
|
+
// Tasks the panel shows no Stop button for (Copilot detached shells).
|
|
2353
|
+
unstoppableTaskCount: liveTasks.filter((task) => task?.stoppable === false).length,
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
function withDeadline(promise, timeoutMs, label) {
|
|
2358
|
+
let timer = null;
|
|
2359
|
+
return Promise.race([
|
|
2360
|
+
promise,
|
|
2361
|
+
new Promise((_, reject) => {
|
|
2362
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs} ms`)), timeoutMs);
|
|
2363
|
+
timer.unref?.();
|
|
2364
|
+
}),
|
|
2365
|
+
]).finally(() => clearTimeout(timer));
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
/**
|
|
2369
|
+
* The Claude CLI's footprint for one session: its transcript
|
|
2370
|
+
* (`<projectDir>/<nativeSessionId>.jsonl`) and the per-session folder beside
|
|
2371
|
+
* it. Nothing else in the project folder is touched — it holds every other
|
|
2372
|
+
* session of that workspace and the project memory.
|
|
2373
|
+
*/
|
|
2374
|
+
function deleteClaudeCliSession({ conversation, runtimeSession }) {
|
|
2375
|
+
const nativeSessionId = String(runtimeSession?.claude_native_session_id || '').trim();
|
|
2376
|
+
if (!nativeSessionId || typeof resolveClaudeSessionRoot !== 'function') return { deleted: null };
|
|
2377
|
+
const anchors = resolveClaudeSessionRoot({
|
|
2378
|
+
claudeNativeSessionId: nativeSessionId,
|
|
2379
|
+
workspaceRootPath: String(
|
|
2380
|
+
conversation?.runtime_workspace_root_path || conversation?.configured_workspace_root_path || '',
|
|
2381
|
+
).trim(),
|
|
2382
|
+
});
|
|
2383
|
+
if (!anchors?.projectDirPath) return { deleted: null };
|
|
2384
|
+
const projectDir = path.resolve(anchors.projectDirPath);
|
|
2385
|
+
const targets = [anchors.transcriptPath, anchors.sessionRootExists ? anchors.sessionRootPath : '']
|
|
2386
|
+
.filter(Boolean)
|
|
2387
|
+
.map((target) => path.resolve(target))
|
|
2388
|
+
.filter((target) => path.dirname(target) === projectDir
|
|
2389
|
+
&& [nativeSessionId, `${nativeSessionId}.jsonl`].includes(path.basename(target)));
|
|
2390
|
+
for (const target of targets) fs.rmSync(target, { recursive: true, force: true });
|
|
2391
|
+
return { deleted: targets.length > 0 };
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
/**
|
|
2395
|
+
* The Copilot session, through the runtime's own deleteSession(). When that
|
|
2396
|
+
* fails the request goes on the SDK delete queue, which a CLI-side consumer
|
|
2397
|
+
* (the extension engine) still works off.
|
|
2398
|
+
*/
|
|
2399
|
+
async function deleteCopilotCliSession({ sdkSessionId, conversationId }) {
|
|
2400
|
+
const stateRoot = String(resolveSessionStateRoot?.() || '').trim();
|
|
2401
|
+
const sessionDir = stateRoot && SESSION_FOLDER_ID_PATTERN.test(sdkSessionId) ? path.join(stateRoot, sdkSessionId) : '';
|
|
2402
|
+
// A conversation deleted before its first turn has no session on disk yet
|
|
2403
|
+
// (the runtime would answer "Session file not found").
|
|
2404
|
+
if (sessionDir && !fs.existsSync(sessionDir)) return { deleted: null };
|
|
2405
|
+
if (typeof sdkSessionImportService?.deleteSession !== 'function') return { deleted: null };
|
|
2406
|
+
try {
|
|
2407
|
+
await withDeadline(
|
|
2408
|
+
sdkSessionImportService.deleteSession(sdkSessionId),
|
|
2409
|
+
CLI_SESSION_DELETE_TIMEOUT_MS,
|
|
2410
|
+
'Copilot deleteSession()',
|
|
2411
|
+
);
|
|
2412
|
+
return { deleted: true };
|
|
2413
|
+
} catch (error) {
|
|
2414
|
+
const reason = String(error?.message || error);
|
|
2415
|
+
if (/Session file not found/i.test(reason)) {
|
|
2416
|
+
// The runtime keeps no session for it, only this session's own
|
|
2417
|
+
// folder (a run that never wrote its session file): nothing to retry.
|
|
2418
|
+
if (sessionDir) fs.rmSync(sessionDir, { recursive: true, force: true });
|
|
2419
|
+
return { deleted: sessionDir ? true : null };
|
|
2420
|
+
}
|
|
2421
|
+
console.warn(`[delete] Copilot session ${shortId(sdkSessionId)} was not deleted: ${reason}`);
|
|
2422
|
+
enqueueSdkDeleteRequest(sdkSessionId, conversationId);
|
|
2423
|
+
return { deleted: false, queued: true };
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
/** The conversation's CLI-side session, per provider; null where the relay keeps none. */
|
|
2428
|
+
async function deleteCliSession({ conversation, conversationId, sdkSessionId, runtimeSession }) {
|
|
2429
|
+
const provider = String(runtimeSession?.provider_type || '').trim().toLowerCase();
|
|
2430
|
+
try {
|
|
2431
|
+
if (provider === 'claude') return deleteClaudeCliSession({ conversation, runtimeSession });
|
|
2432
|
+
if (!sdkSessionId) return { deleted: null };
|
|
2433
|
+
if (!provider || provider === 'github' || provider === 'openai') {
|
|
2434
|
+
return await deleteCopilotCliSession({ sdkSessionId, conversationId });
|
|
2435
|
+
}
|
|
2436
|
+
return { deleted: null };
|
|
2437
|
+
} catch (error) {
|
|
2438
|
+
// The conversation is already tombstoned: a file the OS will not let go
|
|
2439
|
+
// of must not leave it half deleted.
|
|
2440
|
+
console.warn(`[delete] CLI session of ${shortId(conversationId)} was not deleted: ${error?.message || error}`);
|
|
2441
|
+
return { deleted: false };
|
|
2442
|
+
}
|
|
2276
2443
|
}
|
|
2277
2444
|
|
|
2278
2445
|
function finalizeDeletedConversationsForSdkSession(sdkSessionId) {
|
|
@@ -2407,7 +2574,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2407
2574
|
(stmts.getSharedMessages || stmts.getMessages).all(resolvedConversationId),
|
|
2408
2575
|
);
|
|
2409
2576
|
const queueRows = db.prepare(`
|
|
2410
|
-
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model
|
|
2577
|
+
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
|
|
2411
2578
|
FROM queue
|
|
2412
2579
|
WHERE conversation_id = ?
|
|
2413
2580
|
`).all(resolvedConversationId);
|
|
@@ -2739,16 +2906,36 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2739
2906
|
}
|
|
2740
2907
|
|
|
2741
2908
|
try {
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
})
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2909
|
+
// Conflicts veto the request BEFORE workspace learning may create or
|
|
2910
|
+
// mutate rows, and the whole set commits atomically — a 409 leaves every
|
|
2911
|
+
// table exactly as the request found it (audit #21). The placeholder
|
|
2912
|
+
// queue-owner rekey inside syncSession lands in this same transaction,
|
|
2913
|
+
// so a dequeue can never read a half-migrated owner id (audit #22).
|
|
2914
|
+
const { workspaceRootSync, sync } = sessionSyncTransaction(() => {
|
|
2915
|
+
sdkSessionSyncService.validateBinding({
|
|
2916
|
+
sdk_session_id: sdkSessionId,
|
|
2917
|
+
conversation_id: conversationId,
|
|
2918
|
+
});
|
|
2919
|
+
const learned = learnWorkspaceRootFromSessionSync({
|
|
2920
|
+
learnConversationWorkspaceRoot,
|
|
2921
|
+
sdkSessionId,
|
|
2922
|
+
conversationId,
|
|
2923
|
+
workspaceRootPath,
|
|
2924
|
+
});
|
|
2925
|
+
return {
|
|
2926
|
+
workspaceRootSync: learned,
|
|
2927
|
+
sync: sdkSessionSyncService.syncSession({
|
|
2928
|
+
sdk_session_id: sdkSessionId,
|
|
2929
|
+
conversation_id: conversationId,
|
|
2930
|
+
}),
|
|
2931
|
+
};
|
|
2751
2932
|
});
|
|
2933
|
+
// In-memory half of the placeholder rekey, immediately after the DB half
|
|
2934
|
+
// committed: markWorkerSessionSeen below would otherwise mint a second
|
|
2935
|
+
// registry entry under the real id and strand the placeholder entry.
|
|
2936
|
+
if (sync?.placeholderSdkSessionId) {
|
|
2937
|
+
sessionWorkerRegistry?.rekeyWorker?.(sync.placeholderSdkSessionId, sync.sdkSessionId);
|
|
2938
|
+
}
|
|
2752
2939
|
const rebind = relayRestartOrchestrator?.applySessionSync?.({
|
|
2753
2940
|
sdkSessionId,
|
|
2754
2941
|
conversationId,
|
|
@@ -3070,17 +3257,27 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3070
3257
|
const result = await sdkSessionImportService.refreshConversation(existingConversation);
|
|
3071
3258
|
if (result.status === 'failed') throw new Error(result.error || 'Failed to refresh conversation history');
|
|
3072
3259
|
if (result.status !== 'completed') {
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3260
|
+
// Each skip gets its precise cause; the old blanket "already in
|
|
3261
|
+
// progress" told users a relay-owned conversation was merely busy and
|
|
3262
|
+
// to try again (audit #19).
|
|
3263
|
+
const skip = result.category === 'relay-owned'
|
|
3264
|
+
? { statusCode: 409, code: 'relay-owned', message: 'Conversation history is owned by the relay and cannot be re-imported' }
|
|
3265
|
+
: result.category === 'bound-elsewhere'
|
|
3266
|
+
? { statusCode: 409, code: 'bound-elsewhere', message: 'SDK session is bound to another conversation' }
|
|
3267
|
+
: result.reason === 'tombstoned'
|
|
3268
|
+
? { statusCode: 404, code: 'not-found', message: 'Conversation not found' }
|
|
3269
|
+
: result.reason === 'importer-closing'
|
|
3270
|
+
? { statusCode: 503, code: 'shutting-down', message: 'Relay is shutting down' }
|
|
3271
|
+
: { statusCode: 409, code: 'refresh-in-progress', message: 'Conversation history refresh is already in progress' };
|
|
3272
|
+
const error = new Error(skip.message);
|
|
3273
|
+
error.statusCode = skip.statusCode;
|
|
3274
|
+
error.code = skip.code;
|
|
3079
3275
|
throw error;
|
|
3080
3276
|
}
|
|
3081
3277
|
} catch (error) {
|
|
3082
3278
|
return res.status(error?.statusCode || 500).json({
|
|
3083
3279
|
error: error?.message || 'Failed to refresh conversation history',
|
|
3280
|
+
...(error?.code ? { code: error.code } : {}),
|
|
3084
3281
|
});
|
|
3085
3282
|
}
|
|
3086
3283
|
|
|
@@ -3122,7 +3319,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3122
3319
|
});
|
|
3123
3320
|
const dbMessages = stmts.getMessages.all(conversationId);
|
|
3124
3321
|
const queueRows = db.prepare(`
|
|
3125
|
-
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model
|
|
3322
|
+
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
|
|
3126
3323
|
FROM queue
|
|
3127
3324
|
WHERE conversation_id = ?
|
|
3128
3325
|
`).all(conversationId);
|
|
@@ -3524,7 +3721,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3524
3721
|
});
|
|
3525
3722
|
const dbMessages = stmts.getMessages.all(resolvedConversationId);
|
|
3526
3723
|
const queueRows = db.prepare(`
|
|
3527
|
-
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model
|
|
3724
|
+
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
|
|
3528
3725
|
FROM queue
|
|
3529
3726
|
WHERE conversation_id = ?
|
|
3530
3727
|
`).all(resolvedConversationId);
|
|
@@ -3795,15 +3992,30 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3795
3992
|
if (!existing || String(existing.status || '').trim() === 'deleted') {
|
|
3796
3993
|
return res.status(404).json({ error: 'Conversation not found' });
|
|
3797
3994
|
}
|
|
3798
|
-
|
|
3799
|
-
|
|
3995
|
+
// A null base means "I have seen no draft" and conflicts with any versioned
|
|
3996
|
+
// draft — but only from clients that declare the versioned protocol. Older
|
|
3997
|
+
// tabs also send an explicit null (after every keystroke), and a 409 there
|
|
3998
|
+
// would revert their typing, so for them null stays unconditional.
|
|
3999
|
+
const baseDraftUpdatedAtValue = req.body?.baseDraftUpdatedAt !== undefined
|
|
4000
|
+
? req.body.baseDraftUpdatedAt
|
|
4001
|
+
: req.body?.base_draft_updated_at;
|
|
4002
|
+
const speaksVersionedDraftProtocol = Number(req.body?.draftSyncVersion) >= 2;
|
|
4003
|
+
const comparesDraftVersion = baseDraftUpdatedAtValue !== undefined
|
|
4004
|
+
&& (baseDraftUpdatedAtValue !== null || speaksVersionedDraftProtocol);
|
|
3800
4005
|
const normalizedBaseDraftUpdatedAt = normalizeOptionalIsoTimestamp(baseDraftUpdatedAtValue);
|
|
3801
4006
|
const suppliedBaseTimestamp = String(baseDraftUpdatedAtValue ?? '').trim();
|
|
3802
4007
|
if (comparesDraftVersion && suppliedBaseTimestamp && !normalizedBaseDraftUpdatedAt) {
|
|
3803
4008
|
return res.status(400).json({ error: 'Invalid baseDraftUpdatedAt timestamp' });
|
|
3804
4009
|
}
|
|
3805
4010
|
const existingDraftUpdatedAt = normalizeOptionalIsoTimestamp(existing.draft_updated_at);
|
|
3806
|
-
|
|
4011
|
+
// A pagehide copy taken while this client's own send was in flight carries
|
|
4012
|
+
// the pre-send base; the send transaction has since moved the version. That
|
|
4013
|
+
// move (recorded with the sender's client id) is the only mismatch it may
|
|
4014
|
+
// pass — a newer draft from anyone else still conflicts.
|
|
4015
|
+
const acceptsOwnSendVersion = req.body?.acceptOwnSend === true
|
|
4016
|
+
&& !!senderClientId
|
|
4017
|
+
&& String(existing.draft_updated_by_client_id || '') === senderClientId;
|
|
4018
|
+
if (!acceptsOwnSendVersion && hasConversationDraftVersionConflict({
|
|
3807
4019
|
existingDraftUpdatedAt,
|
|
3808
4020
|
baseDraftUpdatedAt: normalizedBaseDraftUpdatedAt,
|
|
3809
4021
|
compareEnabled: comparesDraftVersion,
|
|
@@ -3815,6 +4027,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3815
4027
|
conflict: true,
|
|
3816
4028
|
conversationId,
|
|
3817
4029
|
draftText: String(existing.draft_text || ''),
|
|
4030
|
+
draftAttachments: parseDraftAttachmentsColumn(existing.draft_attachments),
|
|
3818
4031
|
draftUpdatedAt: existingDraftUpdatedAt,
|
|
3819
4032
|
draftUpdatedByClientId: existing.draft_updated_by_client_id || null,
|
|
3820
4033
|
});
|
|
@@ -4216,42 +4429,131 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4216
4429
|
}
|
|
4217
4430
|
|
|
4218
4431
|
try {
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
resolveSessionStateRoot,
|
|
4229
|
-
});
|
|
4230
|
-
const orphanedUploads = collectOrphanedUploadsFromConversation(id);
|
|
4231
|
-
hardDeleteConversationRows(id);
|
|
4232
|
-
deleteOrphanedUploads(orphanedUploads);
|
|
4233
|
-
io.emit('conversation_deleted', { conversationId: id });
|
|
4234
|
-
return res.json({ ok: true });
|
|
4235
|
-
}
|
|
4236
|
-
|
|
4237
|
-
markConversationDeleted.run(new Date().toISOString(), id);
|
|
4238
|
-
stmts.markDeletedSdkSession.run(sdkSessionId, new Date().toISOString());
|
|
4239
|
-
if (sdkSessionId !== id) {
|
|
4240
|
-
// Also tombstone the conversation id itself for legacy/synthetic sdk id fallback paths.
|
|
4241
|
-
stmts.markDeletedSdkSession.run(id, new Date().toISOString());
|
|
4242
|
-
}
|
|
4243
|
-
enqueueSdkDeleteRequest(sdkSessionId, id);
|
|
4244
|
-
const awaited = await waitForSdkDeleteCompletion(sdkSessionId);
|
|
4245
|
-
if (awaited.completed) return res.json({ ok: true });
|
|
4246
|
-
|
|
4247
|
-
io.emit('conversation_delete_pending', { conversationId: id });
|
|
4248
|
-
return res.json({ ok: true, pending: true });
|
|
4432
|
+
// Session workers are keyed by the SDK session id, which is the
|
|
4433
|
+
// conversation id until a session is bound. Serialized with launches and
|
|
4434
|
+
// relaunches of that session, and with a second delete of it.
|
|
4435
|
+
const workerSessionId = String(existing.sdk_session_id || '').trim() || id;
|
|
4436
|
+
const outcome = await sessionLaunchMutex.runExclusive(
|
|
4437
|
+
workerSessionId,
|
|
4438
|
+
() => deleteConversationNow(id),
|
|
4439
|
+
);
|
|
4440
|
+
return res.status(outcome.statusCode).json(outcome.body);
|
|
4249
4441
|
} catch (error) {
|
|
4250
4442
|
console.warn(`[archive] Delete failed for ${id}: ${error?.message || error}`);
|
|
4251
4443
|
return res.status(500).json({ error: 'Failed to delete conversation' });
|
|
4252
4444
|
}
|
|
4253
4445
|
});
|
|
4254
4446
|
|
|
4447
|
+
/** DELETE /api/conversation/:id under its session's lock; see the route. */
|
|
4448
|
+
async function deleteConversationNow(id) {
|
|
4449
|
+
const existing = stmts.getConvAnyStatus.get(id);
|
|
4450
|
+
if (!existing) return { statusCode: 200, body: { ok: true, alreadyDeleted: true } };
|
|
4451
|
+
const sdkSessionId = String(existing.sdk_session_id || '').trim() || null;
|
|
4452
|
+
const workerSessionId = sdkSessionId || id;
|
|
4453
|
+
|
|
4454
|
+
const activity = describeConversationActivity(id, workerSessionId);
|
|
4455
|
+
if (activity.turnRunning) {
|
|
4456
|
+
return {
|
|
4457
|
+
statusCode: 409,
|
|
4458
|
+
body: {
|
|
4459
|
+
ok: false,
|
|
4460
|
+
error: 'conversation-active',
|
|
4461
|
+
reason: 'turn-running',
|
|
4462
|
+
message: 'This conversation is still working on a reply. Stop it, then delete it.',
|
|
4463
|
+
},
|
|
4464
|
+
};
|
|
4465
|
+
}
|
|
4466
|
+
if (activity.backgroundTaskCount > 0) {
|
|
4467
|
+
return {
|
|
4468
|
+
statusCode: 409,
|
|
4469
|
+
body: {
|
|
4470
|
+
ok: false,
|
|
4471
|
+
error: 'conversation-active',
|
|
4472
|
+
reason: 'background-tasks',
|
|
4473
|
+
// Kill session stops the session's worker, and the delete counts no
|
|
4474
|
+
// tasks for a conversation whose worker is gone.
|
|
4475
|
+
message: activity.unstoppableTaskCount > 0
|
|
4476
|
+
? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ⋯ menu, then delete it.'
|
|
4477
|
+
: 'Background tasks are still running in this conversation. Stop them in the task panel, then delete it.',
|
|
4478
|
+
},
|
|
4479
|
+
};
|
|
4480
|
+
}
|
|
4481
|
+
|
|
4482
|
+
// A worker and CLI session that another live conversation still uses
|
|
4483
|
+
// stay; only this conversation's own data goes.
|
|
4484
|
+
const sharedWithConversationId = sdkSessionId
|
|
4485
|
+
? String(findOtherLiveConversationForSdkSession.get(sdkSessionId, id)?.id || '').trim() || null
|
|
4486
|
+
: null;
|
|
4487
|
+
// A Copilot session the relay only imported, and never ran, belongs to
|
|
4488
|
+
// whoever started it in the CLI. Deleting the conversation hides and
|
|
4489
|
+
// tombstones it, and nothing more: the relay started no process for it
|
|
4490
|
+
// (the process scan would find that user's own `copilot --resume`), and
|
|
4491
|
+
// its CLI session stays. Asked before the stop, which removes the registry
|
|
4492
|
+
// entry that shows the relay has run it.
|
|
4493
|
+
const importedOnly = !!sdkSessionId && sdkSessionImportService?.isImportedOnly?.(sdkSessionId) === true;
|
|
4494
|
+
|
|
4495
|
+
// An idle worker is stopped before anything is deleted: the route used to
|
|
4496
|
+
// leave it running (and holding its workspace) until the relay restarted.
|
|
4497
|
+
// Nothing is touched when it will not die.
|
|
4498
|
+
let workerStopped = false;
|
|
4499
|
+
if (!sharedWithConversationId && !importedOnly) {
|
|
4500
|
+
const stopped = await stopIdleWorkspaceRootSession({
|
|
4501
|
+
sdkSessionId: workerSessionId,
|
|
4502
|
+
worker: sessionWorkerRegistry?.getWorker?.(workerSessionId) || null,
|
|
4503
|
+
sessionWorkerSupervisor,
|
|
4504
|
+
sessionWorkerRegistry,
|
|
4505
|
+
sessionWorkerProcessInspector,
|
|
4506
|
+
stopOverrides: sessionWorkerStopOverrides,
|
|
4507
|
+
});
|
|
4508
|
+
if (!stopped.ok) {
|
|
4509
|
+
return {
|
|
4510
|
+
statusCode: 409,
|
|
4511
|
+
body: {
|
|
4512
|
+
ok: false,
|
|
4513
|
+
error: 'worker-stop-failed',
|
|
4514
|
+
message: "Couldn't stop this conversation's session, so nothing was deleted. Try again in a moment.",
|
|
4515
|
+
remainingPids: stopped.remainingPids || [],
|
|
4516
|
+
},
|
|
4517
|
+
};
|
|
4518
|
+
}
|
|
4519
|
+
workerStopped = (stopped.stoppedPids || []).length > 0;
|
|
4520
|
+
// Re-armed after the stop, which can outlast the kill block's grace
|
|
4521
|
+
// window: nothing may respawn a worker for the rows deleted below.
|
|
4522
|
+
sessionWorkerSupervisor?.markKilled?.(workerSessionId);
|
|
4523
|
+
for (const key of new Set([id, workerSessionId])) backgroundTaskStore?.replace?.(key, []);
|
|
4524
|
+
}
|
|
4525
|
+
|
|
4526
|
+
const deletedAt = new Date().toISOString();
|
|
4527
|
+
markConversationDeleted.run(deletedAt, id);
|
|
4528
|
+
// Tombstones keep the SDK session importer from bringing it back.
|
|
4529
|
+
stmts.markDeletedSdkSession.run(sdkSessionId || id, deletedAt);
|
|
4530
|
+
if (sdkSessionId && sdkSessionId !== id) stmts.markDeletedSdkSession.run(id, deletedAt);
|
|
4531
|
+
|
|
4532
|
+
const cliSession = sharedWithConversationId || importedOnly
|
|
4533
|
+
? { deleted: null }
|
|
4534
|
+
: await deleteCliSession({
|
|
4535
|
+
conversation: existing,
|
|
4536
|
+
conversationId: id,
|
|
4537
|
+
sdkSessionId,
|
|
4538
|
+
runtimeSession: stmts.getRuntimeSessionByConversation?.get?.(id) || null,
|
|
4539
|
+
});
|
|
4540
|
+
|
|
4541
|
+
cleanupGeneratedImagesForConversation({
|
|
4542
|
+
conversationId: id,
|
|
4543
|
+
sdkSessionId,
|
|
4544
|
+
messageRows: stmts.getMessages?.all?.(id) || [],
|
|
4545
|
+
parseAttachments,
|
|
4546
|
+
hydrateAttachment,
|
|
4547
|
+
resolveSessionStateRoot,
|
|
4548
|
+
});
|
|
4549
|
+
const orphanedUploads = collectOrphanedUploadsFromConversation(id);
|
|
4550
|
+
hardDeleteConversationRows(id);
|
|
4551
|
+
deleteOrphanedUploads(orphanedUploads);
|
|
4552
|
+
if (sdkSessionId && !cliSession.queued) stmts.deleteSdkDeleteRequest?.run?.(sdkSessionId);
|
|
4553
|
+
io.emit('conversation_deleted', { conversationId: id });
|
|
4554
|
+
return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
|
|
4555
|
+
}
|
|
4556
|
+
|
|
4255
4557
|
// POST /api/conversation/:id/archive — archive conversation
|
|
4256
4558
|
app.post('/api/conversation/:id/archive', auth, async (req, res) => {
|
|
4257
4559
|
const id = String(req.params.id || '').trim();
|
|
@@ -4789,6 +5091,8 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4789
5091
|
platform: process.platform,
|
|
4790
5092
|
features: featureFlags || {},
|
|
4791
5093
|
sessionWorker: sessionWorkerStatus,
|
|
5094
|
+
version: runningVersion || null,
|
|
5095
|
+
update: typeof buildUpdateStatePayload === 'function' ? buildUpdateStatePayload() : null,
|
|
4792
5096
|
});
|
|
4793
5097
|
});
|
|
4794
5098
|
|
|
@@ -4925,6 +5229,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4925
5229
|
// which models exist, only which process runs them. Running workers are
|
|
4926
5230
|
// deliberately left alone — the new engine applies to the next spawn.
|
|
4927
5231
|
io.emit('copilot_settings_updated', settingsPayload);
|
|
5232
|
+
// Fire-and-forget: an engine flip to SDK is exactly when the extension
|
|
5233
|
+
// stops publishing snapshots, so re-run the server-side discovery in the
|
|
5234
|
+
// background (it dedupes/skips on its own; a failure keeps the catalog).
|
|
5235
|
+
void copilotModelDiscoveryService?.refresh?.('settings-change');
|
|
4928
5236
|
return res.json({ ok: true, ...settingsPayload });
|
|
4929
5237
|
});
|
|
4930
5238
|
|
|
@@ -5264,6 +5572,72 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5264
5572
|
});
|
|
5265
5573
|
});
|
|
5266
5574
|
|
|
5575
|
+
// --- OAR self-update ----------------------------------------------------
|
|
5576
|
+
// Checking is opt-in (zero telemetry by default). All routes are no-ops
|
|
5577
|
+
// with clear errors when OAR_NO_UPDATE_CHECK killed the check service.
|
|
5578
|
+
const updateStatePayload = () => (typeof buildUpdateStatePayload === 'function' ? buildUpdateStatePayload() : null);
|
|
5579
|
+
|
|
5580
|
+
app.get('/api/update/state', auth, (_req, res) => {
|
|
5581
|
+
res.json({ ok: true, update: updateStatePayload() });
|
|
5582
|
+
});
|
|
5583
|
+
|
|
5584
|
+
app.post('/api/update/auto-check', auth, (req, res) => {
|
|
5585
|
+
if (!updateCheckService) {
|
|
5586
|
+
return res.status(503).json({ error: 'Update checks are disabled on this relay (OAR_NO_UPDATE_CHECK)' });
|
|
5587
|
+
}
|
|
5588
|
+
const enabled = req.body?.enabled;
|
|
5589
|
+
if (typeof enabled !== 'boolean') {
|
|
5590
|
+
return res.status(400).json({ error: 'enabled must be a boolean' });
|
|
5591
|
+
}
|
|
5592
|
+
updateCheckService.setAutoCheck(enabled);
|
|
5593
|
+
return res.json({ ok: true, update: updateStatePayload() });
|
|
5594
|
+
});
|
|
5595
|
+
|
|
5596
|
+
app.post('/api/update/check', auth, async (_req, res) => {
|
|
5597
|
+
if (!updateCheckService) {
|
|
5598
|
+
return res.status(503).json({ error: 'Update checks are disabled on this relay (OAR_NO_UPDATE_CHECK)' });
|
|
5599
|
+
}
|
|
5600
|
+
await updateCheckService.checkNow();
|
|
5601
|
+
return res.json({ ok: true, update: updateStatePayload() });
|
|
5602
|
+
});
|
|
5603
|
+
|
|
5604
|
+
app.post('/api/update/dismiss', auth, (req, res) => {
|
|
5605
|
+
if (req.body?.outcome === true) {
|
|
5606
|
+
const cleared = updateInstallService?.clearOutcome?.();
|
|
5607
|
+
return cleared?.ok
|
|
5608
|
+
? res.json({ ok: true, update: updateStatePayload() })
|
|
5609
|
+
: res.status(500).json({ error: 'Update state is unavailable' });
|
|
5610
|
+
}
|
|
5611
|
+
if (!updateCheckService) {
|
|
5612
|
+
return res.status(503).json({ error: 'Update checks are disabled on this relay (OAR_NO_UPDATE_CHECK)' });
|
|
5613
|
+
}
|
|
5614
|
+
const result = updateCheckService.dismissVersion(req.body?.version);
|
|
5615
|
+
if (!result.ok) return res.status(400).json({ error: result.error });
|
|
5616
|
+
return res.json({ ok: true, update: updateStatePayload() });
|
|
5617
|
+
});
|
|
5618
|
+
|
|
5619
|
+
app.post('/api/update/cancel', auth, (_req, res) => {
|
|
5620
|
+
if (!updateInstallService?.cancel) {
|
|
5621
|
+
return res.status(503).json({ error: 'Updates are unavailable on this relay' });
|
|
5622
|
+
}
|
|
5623
|
+
updateInstallService.cancel();
|
|
5624
|
+
return res.json({ ok: true, update: updateStatePayload() });
|
|
5625
|
+
});
|
|
5626
|
+
|
|
5627
|
+
app.post('/api/update/apply', auth, async (req, res) => {
|
|
5628
|
+
if (!updateInstallService) {
|
|
5629
|
+
return res.status(503).json({ error: 'Updates are unavailable on this relay' });
|
|
5630
|
+
}
|
|
5631
|
+
const result = await updateInstallService.startUpdate({ version: req.body?.version });
|
|
5632
|
+
if (!result.ok) {
|
|
5633
|
+
return res.status(Number(result.statusCode) || 400).json({
|
|
5634
|
+
error: result.error || 'Failed to start the update',
|
|
5635
|
+
update: updateStatePayload(),
|
|
5636
|
+
});
|
|
5637
|
+
}
|
|
5638
|
+
return res.json({ ok: true, targetVersion: result.targetVersion, update: updateStatePayload() });
|
|
5639
|
+
});
|
|
5640
|
+
|
|
5267
5641
|
// --- Grok account (CLI device-code OAuth) -------------------------------
|
|
5268
5642
|
// Same shape as the Claude block above, one state shorter: `grok login
|
|
5269
5643
|
// --device-auth` prints a URL carrying the device code, then polls x.ai and
|
|
@@ -5615,12 +5989,89 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5615
5989
|
});
|
|
5616
5990
|
});
|
|
5617
5991
|
|
|
5618
|
-
|
|
5992
|
+
const pwaAppNamePayload = (appName) => ({
|
|
5993
|
+
appName,
|
|
5994
|
+
defaultName: PWA_APP_NAME_DEFAULT,
|
|
5995
|
+
shortName: derivePwaShortName(appName || PWA_APP_NAME_DEFAULT),
|
|
5996
|
+
maxLength: PWA_APP_NAME_MAX_LENGTH,
|
|
5997
|
+
});
|
|
5998
|
+
|
|
5999
|
+
app.get('/api/settings/pwa-app-name', auth, (_req, res) => {
|
|
6000
|
+
res.json(pwaAppNamePayload(getPwaAppName()));
|
|
6001
|
+
});
|
|
6002
|
+
|
|
6003
|
+
app.post('/api/settings/pwa-app-name', auth, (req, res) => {
|
|
6004
|
+
const result = setPwaAppName(req.body?.appName);
|
|
6005
|
+
if (!result.ok) {
|
|
6006
|
+
return res.status(result.status || 400).json({ error: result.error });
|
|
6007
|
+
}
|
|
6008
|
+
return res.json(pwaAppNamePayload(result.appName));
|
|
6009
|
+
});
|
|
6010
|
+
|
|
6011
|
+
const featureFlagsSettingsPayload = () => {
|
|
6012
|
+
const flags = getFeatureFlagsSettingsState();
|
|
6013
|
+
return {
|
|
6014
|
+
flags,
|
|
6015
|
+
restartRequired: flags.some((flag) => flag.restartRequired === true),
|
|
6016
|
+
};
|
|
6017
|
+
};
|
|
6018
|
+
|
|
6019
|
+
app.get('/api/settings/features', auth, (_req, res) => {
|
|
6020
|
+
res.json(featureFlagsSettingsPayload());
|
|
6021
|
+
});
|
|
6022
|
+
|
|
6023
|
+
app.post('/api/settings/features', auth, (req, res) => {
|
|
6024
|
+
const name = String(req.body?.name || '').trim();
|
|
6025
|
+
const enabled = req.body?.enabled;
|
|
6026
|
+
if (!name) return res.status(400).json({ error: 'name is required' });
|
|
6027
|
+
if (typeof enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
|
|
6028
|
+
const result = setFeatureFlagSetting(name, enabled);
|
|
6029
|
+
if (!result.ok) {
|
|
6030
|
+
const status = result.error === 'Unknown feature flag' ? 400 : 500;
|
|
6031
|
+
return res.status(status).json({ error: result.error });
|
|
6032
|
+
}
|
|
6033
|
+
return res.json(featureFlagsSettingsPayload());
|
|
6034
|
+
});
|
|
6035
|
+
|
|
6036
|
+
/**
|
|
6037
|
+
* Merge the two Windows autostart services into one mode-shaped state.
|
|
6038
|
+
* A ready boot task wins the mode call even when a Startup .cmd also
|
|
6039
|
+
* exists (that mixed state is real on upgraded hosts): the boot task is
|
|
6040
|
+
* what owns the running relay there, and the leftover .cmd is reported so
|
|
6041
|
+
* the UI can offer cleanup instead of hiding the race.
|
|
6042
|
+
*/
|
|
6043
|
+
async function windowsAutostartMergedState() {
|
|
6044
|
+
const signin = windowsAutostartService.getState();
|
|
6045
|
+
const boot = windowsBootAutostartService
|
|
6046
|
+
? await windowsBootAutostartService.getState()
|
|
6047
|
+
: { supported: false };
|
|
6048
|
+
const bootReady = boot.supported && boot.taskStatus === 'ready';
|
|
6049
|
+
const mode = bootReady ? 'boot' : (signin.enabled ? 'signin' : 'off');
|
|
6050
|
+
return {
|
|
6051
|
+
supported: signin.supported,
|
|
6052
|
+
platform: signin.platform,
|
|
6053
|
+
mode,
|
|
6054
|
+
// Back-compat for pre-mode clients: their toggle keeps meaning
|
|
6055
|
+
// "starts automatically somehow".
|
|
6056
|
+
enabled: mode !== 'off',
|
|
6057
|
+
boot: boot.supported ? {
|
|
6058
|
+
taskStatus: boot.taskStatus,
|
|
6059
|
+
taskName: boot.taskName,
|
|
6060
|
+
legacyTaskPresent: boot.legacyTaskPresent,
|
|
6061
|
+
pendingElevation: boot.pendingElevation,
|
|
6062
|
+
lastError: boot.lastError,
|
|
6063
|
+
manualCommand: boot.manualCommand,
|
|
6064
|
+
} : null,
|
|
6065
|
+
signinEnabled: signin.enabled,
|
|
6066
|
+
};
|
|
6067
|
+
}
|
|
6068
|
+
|
|
6069
|
+
app.get('/api/settings/windows-autostart', auth, async (_req, res) => {
|
|
5619
6070
|
if (!windowsAutostartService) {
|
|
5620
6071
|
return res.status(500).json({ error: 'Windows autostart settings are unavailable' });
|
|
5621
6072
|
}
|
|
5622
6073
|
try {
|
|
5623
|
-
return res.json(
|
|
6074
|
+
return res.json(await windowsAutostartMergedState());
|
|
5624
6075
|
} catch {
|
|
5625
6076
|
return res.status(500).json({
|
|
5626
6077
|
error: 'Unable to read Windows autostart. Check access to your user Startup folder.',
|
|
@@ -5628,19 +6079,49 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5628
6079
|
}
|
|
5629
6080
|
});
|
|
5630
6081
|
|
|
5631
|
-
app.post('/api/settings/windows-autostart', auth, (req, res) => {
|
|
5632
|
-
|
|
5633
|
-
|
|
6082
|
+
app.post('/api/settings/windows-autostart', auth, async (req, res) => {
|
|
6083
|
+
const rawMode = req.body?.mode;
|
|
6084
|
+
const hasBoolean = typeof req.body?.enabled === 'boolean';
|
|
6085
|
+
// Pre-mode clients send {enabled}; it maps onto the sign-in mode exactly
|
|
6086
|
+
// as before this endpoint learned about boot mode.
|
|
6087
|
+
const mode = rawMode !== undefined
|
|
6088
|
+
? String(rawMode)
|
|
6089
|
+
: (hasBoolean ? (req.body.enabled ? 'signin' : 'off') : null);
|
|
6090
|
+
if (!['off', 'signin', 'boot'].includes(mode)) {
|
|
6091
|
+
return res.status(400).json({ error: "mode must be 'off', 'signin' or 'boot' (or enabled a boolean)" });
|
|
5634
6092
|
}
|
|
5635
6093
|
if (!windowsAutostartService) {
|
|
5636
6094
|
return res.status(500).json({ error: 'Windows autostart settings are unavailable' });
|
|
5637
6095
|
}
|
|
5638
6096
|
try {
|
|
5639
|
-
|
|
5640
|
-
if (!currentState.supported) {
|
|
6097
|
+
if (!windowsAutostartService.getState().supported) {
|
|
5641
6098
|
return res.status(400).json({ error: 'Windows autostart is only available on Windows' });
|
|
5642
6099
|
}
|
|
5643
|
-
|
|
6100
|
+
if (mode === 'boot') {
|
|
6101
|
+
if (!windowsBootAutostartService) {
|
|
6102
|
+
return res.status(500).json({ error: 'Windows boot autostart is unavailable' });
|
|
6103
|
+
}
|
|
6104
|
+
// The sweep's completion also removes the Startup .cmd entries; the
|
|
6105
|
+
// sign-in service is not touched here so a declined UAC leaves the
|
|
6106
|
+
// previous mode fully intact.
|
|
6107
|
+
const result = await windowsBootAutostartService.requestEnable();
|
|
6108
|
+
return res.json({ ...(await windowsAutostartMergedState()), accepted: result.accepted });
|
|
6109
|
+
}
|
|
6110
|
+
// Leaving boot mode needs its own elevated sweep before the sign-in
|
|
6111
|
+
// half applies; a declined UAC must not leave both modes armed.
|
|
6112
|
+
const boot = windowsBootAutostartService ? await windowsBootAutostartService.getState() : null;
|
|
6113
|
+
let accepted = true;
|
|
6114
|
+
if (boot?.supported && (boot.taskStatus === 'ready' || boot.taskStatus === 'foreign' || boot.legacyTaskPresent)) {
|
|
6115
|
+
if (boot.taskStatus === 'foreign' && mode === 'signin') {
|
|
6116
|
+
// Someone else's task under our name: never delete it as a side
|
|
6117
|
+
// effect of picking sign-in mode.
|
|
6118
|
+
windowsAutostartService.setEnabled(true);
|
|
6119
|
+
return res.json({ ...(await windowsAutostartMergedState()), accepted: true });
|
|
6120
|
+
}
|
|
6121
|
+
accepted = (await windowsBootAutostartService.requestDisable()).accepted;
|
|
6122
|
+
}
|
|
6123
|
+
windowsAutostartService.setEnabled(mode === 'signin');
|
|
6124
|
+
return res.json({ ...(await windowsAutostartMergedState()), accepted });
|
|
5644
6125
|
} catch {
|
|
5645
6126
|
return res.status(500).json({
|
|
5646
6127
|
error: 'Unable to update Windows autostart. Check access to your user Startup folder.',
|
|
@@ -5899,8 +6380,14 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5899
6380
|
grokSettings?.enabled === true
|
|
5900
6381
|
? refreshGrokProviderModels()
|
|
5901
6382
|
: Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
|
|
6383
|
+
// Copilot SDK discovery self-gates on an installed CLI (skipped:true
|
|
6384
|
+
// when there is none), so unlike the providers above it has no
|
|
6385
|
+
// enablement flag to check here.
|
|
6386
|
+
typeof copilotModelDiscoveryService?.refresh === 'function'
|
|
6387
|
+
? copilotModelDiscoveryService.refresh('manual-refresh')
|
|
6388
|
+
: Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
|
|
5902
6389
|
];
|
|
5903
|
-
const [cliRefresh, openAIRefresh, claudeRefresh, cursorRefresh, grokRefresh] = await Promise.allSettled(refreshTasks);
|
|
6390
|
+
const [cliRefresh, openAIRefresh, claudeRefresh, cursorRefresh, grokRefresh, copilotDiscoveryRefresh] = await Promise.allSettled(refreshTasks);
|
|
5904
6391
|
if (cliRefresh.status === 'rejected') throw cliRefresh.reason;
|
|
5905
6392
|
const openAIModelDiscovery = openAIRefresh.status === 'fulfilled'
|
|
5906
6393
|
? openAIRefresh.value
|
|
@@ -5930,6 +6417,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5930
6417
|
models: Array.isArray(grokSettings?.models) ? grokSettings.models : [],
|
|
5931
6418
|
error: grokRefresh.reason?.message || 'Grok model discovery failed',
|
|
5932
6419
|
};
|
|
6420
|
+
const copilotModelDiscovery = copilotDiscoveryRefresh.status === 'fulfilled'
|
|
6421
|
+
? copilotDiscoveryRefresh.value
|
|
6422
|
+
: { ok: false, models: [], error: copilotDiscoveryRefresh.reason?.message || 'Copilot model discovery failed' };
|
|
5933
6423
|
io.emit('models_updated', buildModelCatalogWithProviders(
|
|
5934
6424
|
getModelCatalogState(),
|
|
5935
6425
|
));
|
|
@@ -5940,6 +6430,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5940
6430
|
claudeModelDiscovery,
|
|
5941
6431
|
cursorModelDiscovery,
|
|
5942
6432
|
grokModelDiscovery,
|
|
6433
|
+
copilotModelDiscovery,
|
|
5943
6434
|
});
|
|
5944
6435
|
} catch (error) {
|
|
5945
6436
|
return res.status(500).json({
|
|
@@ -5982,6 +6473,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5982
6473
|
reasoningEfforts: modelState.reasoningEfforts || [],
|
|
5983
6474
|
contextLimitsByModel: modelState.contextLimitsByModel || {},
|
|
5984
6475
|
modelMetadataByModel: modelState.modelMetadataByModel || {},
|
|
6476
|
+
// Enabled models the last authoritative snapshot no longer listed: they
|
|
6477
|
+
// stay in models[] (a selection must not vanish) but pickers grey them.
|
|
6478
|
+
unavailableModels: Array.isArray(modelState.unavailableModels) ? modelState.unavailableModels : [],
|
|
5985
6479
|
claudeContextTiersByModel: modelState.claudeContextTiersByModel || {},
|
|
5986
6480
|
providersByModel: modelState.providersByModel || {},
|
|
5987
6481
|
stale: modelState.stale,
|