@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.
Files changed (146) hide show
  1. package/.github/extensions/web-relay/extension.mjs +0 -3
  2. package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
  3. package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
  4. package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
  5. package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
  6. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
  7. package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
  8. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
  9. package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
  10. package/CHANGELOG.md +269 -0
  11. package/README.md +407 -424
  12. package/bin/oar.js +142 -11
  13. package/package.json +8 -4
  14. package/server/README.md +59 -22
  15. package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
  16. package/server/claude-worker/claude-session-process.mjs +1013 -45
  17. package/server/claude-worker/claude-session-worker.mjs +54 -9
  18. package/server/claude-worker/claude-turn-publisher.mjs +110 -19
  19. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  20. package/server/copilot-sdk-runtime.mjs +12 -3
  21. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  22. package/server/copilot-worker/copilot-attachments.mjs +35 -2
  23. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  24. package/server/copilot-worker/copilot-model-switch.mjs +425 -0
  25. package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
  26. package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
  27. package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
  28. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
  29. package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
  30. package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
  31. package/server/cursor-worker/cursor-session-worker.mjs +5 -5
  32. package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
  33. package/server/db-schema.mjs +61 -1
  34. package/server/features.mjs +155 -41
  35. package/server/grok-worker/grok-session-worker.mjs +5 -5
  36. package/server/grok-worker/grok-turn-runner.mjs +45 -4
  37. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  38. package/server/public/app/annotate-core.mjs +181 -0
  39. package/server/public/app/annotate-editor.js +617 -0
  40. package/server/public/app/api-client.js +86 -5
  41. package/server/public/app/ask-user-view.js +102 -13
  42. package/server/public/app/attachments-view.js +172 -3
  43. package/server/public/app/background-task-timeout-options.mjs +9 -0
  44. package/server/public/app/background-tasks-view.mjs +34 -19
  45. package/server/public/app/bootstrap.js +153 -69
  46. package/server/public/app/composer-control-state.mjs +52 -34
  47. package/server/public/app/context-tier-options.mjs +24 -4
  48. package/server/public/app/conversation-draft-sync.mjs +187 -0
  49. package/server/public/app/conversation-list-filter.mjs +86 -0
  50. package/server/public/app/conversation-preferences.mjs +47 -10
  51. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  52. package/server/public/app/conversation-view.js +1032 -218
  53. package/server/public/app/emoji-view.js +2 -0
  54. package/server/public/app/font-scaling.js +4 -37
  55. package/server/public/app/journal-view.js +190 -24
  56. package/server/public/app/model-selector-options.mjs +185 -1
  57. package/server/public/app/new-conversation-model-choice.mjs +29 -9
  58. package/server/public/app/pwa-install.js +64 -136
  59. package/server/public/app/question-multi-select.mjs +52 -0
  60. package/server/public/app/reasoning-effort-labels.mjs +13 -3
  61. package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
  62. package/server/public/app/router.js +120 -11
  63. package/server/public/app/settings-modal.js +225 -20
  64. package/server/public/app/settings-tabs.js +1 -1
  65. package/server/public/app/socket-handlers.js +47 -4
  66. package/server/public/app/steered-turn-merge.mjs +100 -0
  67. package/server/public/app/store.js +159 -16
  68. package/server/public/app/sync-outbox.mjs +16 -8
  69. package/server/public/app/tool-activity-emoji.mjs +36 -0
  70. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  71. package/server/public/app/update-ui.js +194 -0
  72. package/server/public/index.html +483 -24
  73. package/server/public/sw.js +7 -0
  74. package/server/relay-tools.md +2 -0
  75. package/server/relay.mjs +23 -21
  76. package/server/repositories/message-repository.mjs +78 -21
  77. package/server/repositories/question-repository.mjs +19 -1
  78. package/server/repositories/session-repository.mjs +7 -31
  79. package/server/routes/ask-user-routes.mjs +10 -0
  80. package/server/routes/messages-routes.mjs +689 -159
  81. package/server/routes/sessions-routes.mjs +574 -80
  82. package/server/server-runtime.mjs +393 -925
  83. package/server/services/claude-auth-service.mjs +7 -1
  84. package/server/services/cli-install-service.mjs +8 -27
  85. package/server/services/cli-process-runner.mjs +30 -0
  86. package/server/services/copilot-model-discovery-service.mjs +202 -0
  87. package/server/services/data-dir-resolution.mjs +47 -0
  88. package/server/services/grok-auth-service.mjs +9 -1
  89. package/server/services/live-turn-picker.mjs +47 -0
  90. package/server/services/model-variant-catalog-service.mjs +1183 -0
  91. package/server/services/oar-cli-helpers.mjs +6 -2
  92. package/server/services/relay-prompt-sanitizer.mjs +30 -3
  93. package/server/services/relay-question-context.mjs +46 -0
  94. package/server/services/sdk-session-import-service.mjs +144 -16
  95. package/server/services/sdk-session-sync-service.mjs +84 -27
  96. package/server/services/session-worker-registry-service.mjs +116 -3
  97. package/server/services/session-worker-supervisor-service.mjs +7 -1
  98. package/server/services/session-worker-websocket-service.mjs +119 -24
  99. package/server/services/status-event-service.mjs +8 -2
  100. package/server/services/steer-resend.mjs +45 -0
  101. package/server/services/update-check-service.mjs +220 -0
  102. package/server/services/update-install-service.mjs +279 -0
  103. package/server/services/windows-autostart-service.mjs +42 -6
  104. package/server/services/windows-boot-autostart-service.mjs +487 -0
  105. package/shared/ask-user-bridge.mjs +37 -5
  106. package/shared/background-task-timeout.mjs +12 -4
  107. package/shared/claude-model-catalog.mjs +49 -0
  108. package/shared/context-window-fallbacks.mjs +1 -0
  109. package/shared/control-poller.mjs +4 -1
  110. package/shared/copilot-model-order.mjs +182 -0
  111. package/shared/media-embed-instructions.mjs +35 -0
  112. package/shared/model-descriptors.mjs +181 -30
  113. package/shared/model-id.mjs +6 -0
  114. package/shared/pwa-app-name.mjs +44 -0
  115. package/shared/question-multi-select.mjs +16 -0
  116. package/shared/relay-stop-failure.mjs +15 -0
  117. package/shared/slash-command-guard.mjs +76 -0
  118. package/shared/steer-note.mjs +66 -0
  119. package/shared/steer-settle-failure.mjs +89 -0
  120. package/shared/steer-settle-markers.mjs +18 -0
  121. package/shared/update-semver.mjs +50 -0
  122. package/shared/worker-crash-guard.mjs +40 -14
  123. package/shared/worker-runtime/api-client.mjs +34 -0
  124. package/shared/worker-runtime/config-loader.mjs +104 -0
  125. package/shared/worker-runtime/heartbeat.mjs +109 -0
  126. package/shared/worker-runtime/prompt-context.mjs +160 -0
  127. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  128. package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
  129. package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
  130. package/server/claude-worker/claude-session-test-harness.mjs +0 -236
  131. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  132. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
  133. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  134. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  135. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  136. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  137. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  138. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  139. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  140. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  141. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  142. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  143. package/server/routes/messages-routes-test-harness.mjs +0 -111
  144. package/server/services/fixtures/claude-auth-stub.sh +0 -62
  145. package/server/services/fixtures/cli-install-stub.sh +0 -75
  146. 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
- const MAX_CONVERSATION_DRAFT_LENGTH = 20_000;
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
- return text.slice(0, maxLength);
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: false,
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
- async function waitForSdkDeleteCompletion(sdkSessionId, timeoutMs = SDK_DELETE_WAIT_TIMEOUT_MS) {
2267
- const sid = String(sdkSessionId || '').trim();
2268
- if (!sid) return { completed: false };
2269
- const startedAt = Date.now();
2270
- while ((Date.now() - startedAt) < timeoutMs) {
2271
- const row = stmts.getSdkDeleteRequestBySessionId.get(sid);
2272
- if (!row) return { completed: true };
2273
- await sleep(SDK_DELETE_POLL_MS);
2274
- }
2275
- return { completed: false };
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
- const workspaceRootSync = learnWorkspaceRootFromSessionSync({
2743
- learnConversationWorkspaceRoot,
2744
- sdkSessionId,
2745
- conversationId,
2746
- workspaceRootPath,
2747
- });
2748
- const sync = sdkSessionSyncService.syncSession({
2749
- sdk_session_id: sdkSessionId,
2750
- conversation_id: conversationId,
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
- const error = new Error(
3074
- result.reason === 'tombstoned'
3075
- ? 'Conversation not found'
3076
- : 'Conversation history refresh is already in progress',
3077
- );
3078
- error.statusCode = result.reason === 'tombstoned' ? 404 : 409;
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
- const baseDraftUpdatedAtValue = req.body?.baseDraftUpdatedAt ?? req.body?.base_draft_updated_at;
3799
- const comparesDraftVersion = baseDraftUpdatedAtValue !== undefined;
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
- if (hasConversationDraftVersionConflict({
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
- const sdkSessionId = String(existing.sdk_session_id || '').trim() || null;
4220
- if (!sdkSessionId) {
4221
- stmts.markDeletedSdkSession.run(id, new Date().toISOString());
4222
- cleanupGeneratedImagesForConversation({
4223
- conversationId: id,
4224
- sdkSessionId: null,
4225
- messageRows: stmts.getMessages?.all?.(id) || [],
4226
- parseAttachments,
4227
- hydrateAttachment,
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
- app.get('/api/settings/windows-autostart', auth, (_req, res) => {
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(windowsAutostartService.getState());
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
- if (typeof req.body?.enabled !== 'boolean') {
5633
- return res.status(400).json({ error: 'enabled must be a boolean' });
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
- const currentState = windowsAutostartService.getState();
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
- return res.json(windowsAutostartService.setEnabled(req.body.enabled));
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,