@oar-sh/oar 0.9.12 → 0.9.14

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 (115) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +508 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +155 -94
  7. package/package.json +5 -5
  8. package/server/README.md +479 -157
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +92 -15
  11. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  12. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  13. package/server/claude-worker/task-output-discovery.mjs +28 -2
  14. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  15. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  16. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  17. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  18. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
  20. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  21. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  22. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  23. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  24. package/server/db-schema.mjs +10 -0
  25. package/server/grok-worker/acp-host-services.mjs +6 -3
  26. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  27. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  28. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  29. package/server/migrations/0018-background-task-rows.mjs +101 -0
  30. package/server/public/app/action-confirmations.js +12 -15
  31. package/server/public/app/api-client.js +60 -4
  32. package/server/public/app/ask-user-view.js +29 -4
  33. package/server/public/app/background-tasks-view.mjs +56 -7
  34. package/server/public/app/bootstrap.js +7 -0
  35. package/server/public/app/conversation-preferences.mjs +10 -4
  36. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  37. package/server/public/app/conversation-view.js +33 -9
  38. package/server/public/app/host-suspend-ui.js +45 -44
  39. package/server/public/app/journal-view.js +9 -3
  40. package/server/public/app/push-settings.js +1 -0
  41. package/server/public/app/question-answer-rule.mjs +15 -0
  42. package/server/public/app/question-multi-select.mjs +9 -2
  43. package/server/public/app/relay-event-view.mjs +5 -1
  44. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  45. package/server/public/app/relay-restart-note-view.mjs +36 -0
  46. package/server/public/app/relay-restart-ui.js +568 -0
  47. package/server/public/app/settings-modal.js +52 -0
  48. package/server/public/app/socket-handlers.js +1 -1
  49. package/server/public/app/task-console-render.mjs +171 -7
  50. package/server/public/app/task-console.mjs +111 -11
  51. package/server/public/app/task-output-text.mjs +15 -6
  52. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  53. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  54. package/server/public/index.html +76 -5
  55. package/server/relay-exit-codes.mjs +6 -0
  56. package/server/relay-self-restart.mjs +6 -1
  57. package/server/relay-tools.md +17 -9
  58. package/server/repositories/message-repository.mjs +32 -28
  59. package/server/repositories/remote-relay-repository.mjs +4 -2
  60. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  61. package/server/routes/ask-user-routes.mjs +41 -7
  62. package/server/routes/messages-routes.mjs +220 -37
  63. package/server/routes/preview-routes.mjs +73 -7
  64. package/server/routes/relay-board-routes.mjs +19 -2
  65. package/server/routes/relay-restart-routes.mjs +73 -0
  66. package/server/routes/sessions-routes.mjs +79 -5
  67. package/server/server-runtime.mjs +335 -218
  68. package/server/services/about-service.mjs +13 -12
  69. package/server/services/background-task-persistence.mjs +133 -0
  70. package/server/services/background-task-store.mjs +240 -13
  71. package/server/services/conversation-model-fallback.mjs +57 -0
  72. package/server/services/cursor-session-root-service.mjs +70 -6
  73. package/server/services/data-dir-resolution.mjs +208 -19
  74. package/server/services/earlier-database-service.mjs +631 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/oar-cli-helpers.mjs +6 -1
  77. package/server/services/oar-state-layout.mjs +107 -0
  78. package/server/services/oar-state-migration-service.mjs +387 -96
  79. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  80. package/server/services/package-data-rescue.mjs +137 -0
  81. package/server/services/preview-proxy-server.mjs +14 -1
  82. package/server/services/preview-registry-service.mjs +3 -2
  83. package/server/services/process-list-service.mjs +2 -1
  84. package/server/services/provider-model-selection.mjs +195 -0
  85. package/server/services/push-dispatch-service.mjs +42 -8
  86. package/server/services/relay-event-service.mjs +210 -14
  87. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  88. package/server/services/relay-restart-picture.mjs +209 -0
  89. package/server/services/relay-restart-queue.mjs +387 -0
  90. package/server/services/relay-shutdown-gate.mjs +4 -1
  91. package/server/services/relay-singleton-guard.mjs +300 -36
  92. package/server/services/remote-relay-caller-context.mjs +7 -2
  93. package/server/services/remote-relay-dispatcher.mjs +217 -28
  94. package/server/services/restart-resume-service.mjs +303 -0
  95. package/server/services/session-activity-tracker.mjs +52 -15
  96. package/server/services/session-worker-launch-service.mjs +31 -1
  97. package/server/services/session-worker-process-service.mjs +6 -4
  98. package/server/services/stale-turn-recovery.mjs +99 -0
  99. package/server/services/subagent-timeline-service.mjs +4 -1
  100. package/server/services/task-output-service.mjs +217 -18
  101. package/server/services/windows-autostart-service.mjs +329 -31
  102. package/server/services/windows-boot-autostart-service.mjs +188 -60
  103. package/shared/preview-instructions.mjs +17 -12
  104. package/shared/preview-tool-core.mjs +32 -14
  105. package/shared/question-answer-rule.mjs +63 -0
  106. package/shared/relay-events.mjs +106 -3
  107. package/shared/remote-relay-contract.mjs +32 -15
  108. package/shared/subagent-timeline.mjs +4 -0
  109. package/shared/task-output-text.mjs +196 -83
  110. package/shared/windows-system-tools.mjs +25 -0
  111. package/shared/worker-runtime/config-loader.mjs +54 -8
  112. package/shared/worker-runtime/prompt-context.mjs +3 -1
  113. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
  114. package/shared/worker-runtime/working-directory.mjs +65 -0
  115. package/server/uploads/image_input.md +0 -540
@@ -4,9 +4,46 @@
4
4
  // listener itself has no routes and no way to register anything. Creating or
5
5
  // closing a preview therefore always requires the relay token, even though the
6
6
  // resulting URL is public.
7
+ //
8
+ // Conversation scope. A preview's token is its only credential, so an agent's
9
+ // preview tool must not see or close another conversation's. A request that
10
+ // names a conversation — the `conversationId` query, else the
11
+ // `X-Relay-Conversation-Id` header every worker and the OAR MCP server send —
12
+ // is scoped to it: GET lists only its previews, DELETE refuses another
13
+ // conversation's with a 403. Only a request with neither (the web UI's
14
+ // Settings → Previews) gets the relay-wide view. Agent-side callers present the
15
+ // same relay token as the owner, so this is a boundary for the tools, not
16
+ // against a caller that deliberately leaves both out.
7
17
 
8
18
  import { validateStaticRoot } from '../services/preview-static-handler.mjs';
9
19
 
20
+ /**
21
+ * The conversation ids a named scope covers: the id itself, and — when it
22
+ * names a conversation by its id or by its session id — that conversation's id
23
+ * and session id. They differ after a placeholder rekey, and a worker's or
24
+ * tool server's own identity is the session id. A lookup that fails narrows
25
+ * the scope to the named id rather than failing the request.
26
+ */
27
+ export function createPreviewScopeResolver({
28
+ getConversation = () => null,
29
+ getConversationBySdkSessionId = () => null,
30
+ } = {}) {
31
+ return function resolveConversationScope(id) {
32
+ const named = String(id || '').trim();
33
+ if (!named) return [];
34
+ const ids = new Set([named]);
35
+ let conversation = null;
36
+ try {
37
+ conversation = getConversation(named) || getConversationBySdkSessionId(named) || null;
38
+ } catch {}
39
+ for (const value of [conversation?.id, conversation?.sdk_session_id]) {
40
+ const text = String(value || '').trim();
41
+ if (text) ids.add(text);
42
+ }
43
+ return [...ids];
44
+ };
45
+ }
46
+
10
47
  export function registerPreviewRoutes(app, deps) {
11
48
  const {
12
49
  auth,
@@ -20,6 +57,8 @@ export function registerPreviewRoutes(app, deps) {
20
57
  // publishes stay panel-only — the transcript records the conversation, not
21
58
  // panel actions).
22
59
  recordPreviewCard = () => {},
60
+ // (scope id) => the conversation ids it covers; createPreviewScopeResolver.
61
+ resolveConversationScope = (id) => (id ? [id] : []),
23
62
  } = deps;
24
63
 
25
64
  function laneUnavailable(res) {
@@ -36,13 +75,27 @@ export function registerPreviewRoutes(app, deps) {
36
75
  });
37
76
  }
38
77
 
39
- // GET /api/previews — every live preview, or one conversation's when filtered.
40
- // Unfiltered is what the settings "Live previews" list uses: nothing expires
41
- // on its own, so there has to be one place that shows everything.
78
+ // The conversation ids a request is scoped to, or null for the owner's
79
+ // view. The query wins over the header: the header is the worker's own
80
+ // session, while a worker can be handed a row of another conversation (a
81
+ // placeholder rekey, or the shared fallback of the worker's pending lookup)
82
+ // and its tool names the conversation of the turn it is running.
83
+ function requestScope(req) {
84
+ const named = String(req.query?.conversationId || '').trim()
85
+ || String(req.headers?.['x-relay-conversation-id'] || '').trim();
86
+ if (!named) return null;
87
+ const ids = resolveConversationScope(named);
88
+ return Array.isArray(ids) && ids.length ? ids : [named];
89
+ }
90
+
91
+ // GET /api/previews — one conversation's live previews when scoped (see the
92
+ // top of the file), else every one. Unscoped is what the settings "Live
93
+ // previews" list uses: nothing expires on its own, so there has to be one
94
+ // place that shows everything.
42
95
  app.get('/api/previews', auth, (req, res) => {
43
- const conversationId = String(req.query?.conversationId || '').trim();
44
- const previews = conversationId
45
- ? previewRegistry.listForConversation(conversationId)
96
+ const scope = requestScope(req);
97
+ const previews = scope
98
+ ? previewRegistry.list().filter((entry) => scope.includes(entry.conversationId))
46
99
  : previewRegistry.list();
47
100
  res.json({
48
101
  previews,
@@ -107,8 +160,21 @@ export function registerPreviewRoutes(app, deps) {
107
160
  });
108
161
 
109
162
  // DELETE /api/previews/:token — the link stops resolving immediately. The dev
110
- // server behind it is untouched; it is a separate background task.
163
+ // server behind it is untouched; it is a separate background task. A scoped
164
+ // request may close only its own conversation's previews.
111
165
  app.delete('/api/previews/:token', auth, (req, res) => {
166
+ const scope = requestScope(req);
167
+ if (scope) {
168
+ const token = String(req.params?.token || '').trim();
169
+ // list() also holds the stored previews a restart could not check yet.
170
+ const target = previewRegistry.list().find((entry) => entry.token === token);
171
+ if (target && !scope.includes(target.conversationId)) {
172
+ return res.status(403).json({
173
+ error: 'This preview belongs to another conversation; a conversation can close only its own previews. '
174
+ + 'The owner can close it in Settings → Previews.',
175
+ });
176
+ }
177
+ }
112
178
  const closed = previewRegistry.close(req.params?.token);
113
179
  if (!closed.ok) return res.status(closed.status || 404).json({ error: closed.error });
114
180
  res.json({ ok: true, preview: closed.preview });
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
4
+ import { fallbackReasoningEffort } from '../services/conversation-model-fallback.mjs';
4
5
 
5
6
  function normalizeId(value) {
6
7
  const text = String(value || '').trim();
@@ -93,12 +94,28 @@ export function registerRelayBoardRoutes(app, deps) {
93
94
  uuidv4,
94
95
  normalizeRelayMode,
95
96
  resolveRequestedModel,
97
+ resolveRequestedReasoningEffort,
96
98
  DEFAULT_RELAY_MODE,
97
99
  DEFAULT_MODEL,
98
100
  formatRelayBoardRow,
99
101
  pushDispatchService,
100
102
  } = deps;
101
103
 
104
+ /**
105
+ * The effort the follow-up runs with when the model id carries none: the
106
+ * conversation's own, clamped to the levels the model has. A model the
107
+ * catalog cannot check (another provider's) takes it as it is.
108
+ */
109
+ function conversationEffortFor(conversationId, model) {
110
+ const preferred = String(stmts.getConvAnyStatus?.get?.(conversationId)?.preferred_reasoning_effort || '').trim().toLowerCase();
111
+ if (!preferred) return null;
112
+ if (typeof resolveRequestedReasoningEffort !== 'function') return preferred;
113
+ const check = resolveRequestedReasoningEffort(model, preferred);
114
+ if (check?.ok) return String(check.effort || '').trim() || null;
115
+ const supported = Array.isArray(check?.supported) ? check.supported : [];
116
+ return supported.length ? (fallbackReasoningEffort([preferred], supported) || null) : preferred;
117
+ }
118
+
102
119
  /**
103
120
  * The session goes on in the mode that was chosen on the board: after
104
121
  * "Implement in autopilot" the composer says Autopilot, and the next message
@@ -250,9 +267,9 @@ export function registerRelayBoardRoutes(app, deps) {
250
267
  const resolvedVariantModel = modelResolution?.ok
251
268
  ? String(modelResolution.modelVariantId || latestModel || resolvedBaseModel).trim()
252
269
  : String(latestModel || resolvedBaseModel).trim();
253
- const resolvedReasoningEffort = modelResolution?.ok
270
+ const resolvedReasoningEffort = (modelResolution?.ok
254
271
  ? String(modelResolution.reasoningEffort || '').trim() || null
255
- : null;
272
+ : null) || conversationEffortFor(convId, resolvedBaseModel);
256
273
  queuedMessageId = uuidv4();
257
274
  stmts.insertMsg.run(
258
275
  queuedMessageId,
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ // The queued relay restart beyond queueing and cancelling it (those two are
4
+ // POST /api/relay/shutdown and /api/relay/shutdown/cancel in
5
+ // messages-routes.mjs): what a restart would wait for and leave running, the
6
+ // "keep waiting" answer to the max-wait question, and the max-wait setting
7
+ // (services/relay-restart-queue.mjs). Token only, like the restart itself.
8
+
9
+ import {
10
+ DEFAULT_RELAY_RESTART_MAX_WAIT_MINUTES,
11
+ RELAY_RESTART_MAX_WAIT_CHOICES,
12
+ parseRelayRestartMaxWaitUpdate,
13
+ } from '../services/relay-restart-queue.mjs';
14
+
15
+ function requestedByOf(req) {
16
+ return String(req.body?.requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
17
+ }
18
+
19
+ export function registerRelayRestartRoutes(app, {
20
+ auth,
21
+ getState = null,
22
+ collectRestartPicture = null,
23
+ keepWaiting = null,
24
+ getMaxWaitMinutes = () => DEFAULT_RELAY_RESTART_MAX_WAIT_MINUTES,
25
+ setMaxWaitMinutes = null,
26
+ maxWaitOverrideMs = null,
27
+ } = {}) {
28
+ // The queued request's state, and with `?action=` the picture for a
29
+ // restart or shutdown asked now (the confirmation reads it first).
30
+ app.get('/api/relay/shutdown', auth, (req, res) => {
31
+ if (typeof getState !== 'function') {
32
+ return res.status(501).json({ error: 'Relay shutdown orchestration is unavailable' });
33
+ }
34
+ const state = getState();
35
+ const action = String(req.query?.action || 'restart').trim().toLowerCase() === 'shutdown' ? 'shutdown' : 'restart';
36
+ let preview = null;
37
+ if (typeof collectRestartPicture === 'function') {
38
+ try { preview = { action, ...collectRestartPicture({ action }) }; } catch {}
39
+ }
40
+ return res.json({ ok: true, ...state, preview });
41
+ });
42
+
43
+ app.post('/api/relay/shutdown/keep-waiting', auth, (req, res) => {
44
+ if (typeof keepWaiting !== 'function') {
45
+ return res.status(501).json({ error: 'Relay shutdown orchestration is unavailable' });
46
+ }
47
+ return res.json({ ok: true, ...keepWaiting({ requestedBy: requestedByOf(req) }) });
48
+ });
49
+
50
+ const settingPayload = (maxWaitMinutes) => ({
51
+ ok: true,
52
+ maxWaitMinutes,
53
+ choices: [...RELAY_RESTART_MAX_WAIT_CHOICES],
54
+ defaultMinutes: DEFAULT_RELAY_RESTART_MAX_WAIT_MINUTES,
55
+ // A test relay's environment override wins over the stored value.
56
+ overrideMs: maxWaitOverrideMs,
57
+ });
58
+
59
+ app.get('/api/settings/relay-restart-max-wait', auth, (_req, res) => {
60
+ res.json(settingPayload(getMaxWaitMinutes()));
61
+ });
62
+
63
+ app.post('/api/settings/relay-restart-max-wait', auth, (req, res) => {
64
+ const parsed = parseRelayRestartMaxWaitUpdate(req.body?.maxWaitMinutes);
65
+ if (!parsed.ok) return res.status(400).json({ ok: false, error: parsed.error });
66
+ if (typeof setMaxWaitMinutes !== 'function') {
67
+ return res.status(503).json({ ok: false, error: 'Relay restart settings are unavailable' });
68
+ }
69
+ const result = setMaxWaitMinutes(parsed.minutes);
70
+ if (!result?.ok) return res.status(500).json({ ok: false, error: result?.error || 'Saving failed' });
71
+ return res.json(settingPayload(result.maxWaitMinutes));
72
+ });
73
+ }
@@ -24,7 +24,13 @@ import {
24
24
  } from '../../shared/claude-background-command-limit.mjs';
25
25
  import { CLAUDE_ATTRIBUTION_MODES, DEFAULT_CLAUDE_ATTRIBUTION_MODE, claudeAttributionModelLabel, claudeAttributionTrailer, normalizeClaudeAttributionMode, resolveClaudeAttributionMode } from '../../shared/claude-attribution.mjs';
26
26
  import { parseThinkingDisplay, parseThinkingEnabled } from '../../shared/claude-thinking.mjs';
27
- import { isProviderModelAvailable, resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
27
+ import {
28
+ checkRequestedProviderModel,
29
+ isAutoModelId,
30
+ isProviderModelAvailable,
31
+ resolveProviderModelSelection,
32
+ unknownProviderModelText,
33
+ } from '../services/provider-model-selection.mjs';
28
34
  import {
29
35
  DEFAULT_CLAUDE_REASONING_EFFORTS,
30
36
  resolveProviderReasoningEffort,
@@ -117,6 +123,7 @@ import {
117
123
  PWA_APP_NAME_MAX_LENGTH,
118
124
  derivePwaShortName,
119
125
  } from '../../shared/pwa-app-name.mjs';
126
+ import { parseRestartResumeUpdate } from '../services/restart-resume-service.mjs';
120
127
 
121
128
  export { mapUsageSnapshotRow };
122
129
 
@@ -2139,6 +2146,8 @@ export function registerSessionsRoutes(app, deps) {
2139
2146
  setBackgroundTaskTimeoutMinutes = () => ({ ok: false, error: 'Background task timeout settings are unavailable' }),
2140
2147
  getTaskStallMinutes = () => DEFAULT_TASK_STALL_MINUTES,
2141
2148
  setTaskStallMinutes = () => ({ ok: false, error: 'Task stall settings are unavailable' }),
2149
+ getRestartResumeEnabled = () => false,
2150
+ setRestartResumeEnabled = () => ({ ok: false, error: 'Restart settings are unavailable' }),
2142
2151
  markSharedViewerPresence,
2143
2152
  getSharedWatcherCount,
2144
2153
  statusEventService,
@@ -4023,8 +4032,10 @@ export function registerSessionsRoutes(app, deps) {
4023
4032
  // then nothing is shared.
4024
4033
  function collectSharePreviewTokens(conversationId) {
4025
4034
  const tokens = new Set();
4026
- // Every preview of the relay, not only this conversation's: the preview
4027
- // tool's list covers them all, and a reply may quote any of its tokens.
4035
+ // Every preview of the relay, not only this conversation's: a reply may
4036
+ // quote any token its agent got hold of — one the user pasted from
4037
+ // Settings → Previews, or a relay-wide list fetched by hand with the relay
4038
+ // token (the preview tool's own list is scoped to its conversation).
4028
4039
  try {
4029
4040
  for (const knownToken of listKnownPreviewTokens?.() || []) tokens.add(String(knownToken));
4030
4041
  } catch {
@@ -5792,6 +5803,55 @@ export function registerSessionsRoutes(app, deps) {
5792
5803
  supportedModels: providerModelSelection.availableModels || [],
5793
5804
  });
5794
5805
  }
5806
+ // Copilot falls back to the catalogue's current model for an id it does
5807
+ // not list, which New Chat (it only offers listed ids) never meets. An
5808
+ // agent naming a mistyped id ("gpt-5-4") would get a session on another
5809
+ // model than it asked for, or one its next send cannot reach: refused.
5810
+ // A listed id is bound in the list's casing, and one with its effort
5811
+ // ("gpt-5.4-high") as the listed id plus that effort: the catalogue
5812
+ // lookup below knows exact base ids only and would fall back too.
5813
+ let copilotBoundModel = '';
5814
+ let copilotVariantEffort = '';
5815
+ if (
5816
+ remoteRequest.remote
5817
+ && !useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider
5818
+ ) {
5819
+ const mergedModels = Array.isArray(modelState?.models) ? modelState.models : [];
5820
+ const providersByModel = modelState?.providersByModel && typeof modelState.providersByModel === 'object'
5821
+ ? modelState.providersByModel
5822
+ : {};
5823
+ // Copilot's own: listed for it, or for no provider in particular.
5824
+ const copilotModels = mergedModels.filter((modelId) => {
5825
+ const providers = providersByModel[String(modelId || '').trim().toLowerCase()];
5826
+ return !Array.isArray(providers) || providers.length === 0
5827
+ || providers.some((provider) => String(provider || '').trim().toLowerCase() === 'github-copilot');
5828
+ });
5829
+ const reasoningByModel = modelState?.reasoningByModel && typeof modelState.reasoningByModel === 'object'
5830
+ ? modelState.reasoningByModel
5831
+ : {};
5832
+ const copilotCheck = checkRequestedProviderModel({
5833
+ requestedModel: requestedBootstrapModel,
5834
+ provider: 'github',
5835
+ models: copilotModels,
5836
+ effortsFor: (base) => reasoningByModel[base] || reasoningByModel[String(base).toLowerCase()] || null,
5837
+ });
5838
+ // Another provider's model is answered by the checks below ("requires
5839
+ // the Claude provider"), not as an unknown one.
5840
+ const otherProviders = mergedModels.some((modelId) => String(modelId || '').trim().toLowerCase() === requestedBootstrapModel.toLowerCase());
5841
+ if (copilotCheck.status === 'known' && copilotCheck.model && !isAutoModelId(copilotCheck.model)) {
5842
+ copilotBoundModel = copilotCheck.base || copilotCheck.model;
5843
+ copilotVariantEffort = copilotCheck.effort || '';
5844
+ }
5845
+ if (copilotCheck.status === 'unknown' && !otherProviders) {
5846
+ return res.status(400).json({
5847
+ ok: false,
5848
+ error: unknownProviderModelText({ model: copilotCheck.model, provider: 'github', suggestions: copilotCheck.suggestions, models: copilotCheck.models }),
5849
+ code: 'COPILOT_MODEL_UNAVAILABLE',
5850
+ supportedModels: copilotCheck.models,
5851
+ ...(copilotCheck.suggestions.length ? { suggestedModels: copilotCheck.suggestions } : {}),
5852
+ });
5853
+ }
5854
+ }
5795
5855
  if (useOpenAIProvider && openAISettings?.configured !== true) {
5796
5856
  return res.status(400).json({
5797
5857
  ok: false,
@@ -5842,7 +5902,7 @@ export function registerSessionsRoutes(app, deps) {
5842
5902
  bootstrapWorkspaceRootPath = validatedRoot.realPath;
5843
5903
  }
5844
5904
 
5845
- const selectedModel = providerModelSelection ? providerModelSelection.model : catalogSelectedModel;
5905
+ const selectedModel = providerModelSelection ? providerModelSelection.model : (copilotBoundModel || catalogSelectedModel);
5846
5906
  if (requestedProviderType === 'openai-image' && !isOpenAIImageModelId(selectedModel)) {
5847
5907
  return res.status(400).json({
5848
5908
  ok: false,
@@ -5914,7 +5974,7 @@ export function registerSessionsRoutes(app, deps) {
5914
5974
  // first send would apply, so the composer can restore it from the server
5915
5975
  // instead of guessing from localStorage.
5916
5976
  const bootstrapReasoning = resolveProviderReasoningEffort({
5917
- requestedEffort: req.body?.reasoningEffort,
5977
+ requestedEffort: req.body?.reasoningEffort || copilotVariantEffort,
5918
5978
  strict: bootstrapProviderType === 'openai',
5919
5979
  supportedEfforts: supportedReasoningEffortsForProviderModel({
5920
5980
  providerType: bootstrapProviderType,
@@ -7145,6 +7205,20 @@ export function registerSessionsRoutes(app, deps) {
7145
7205
  return res.json(taskStallMinutesPayload(result.minutes));
7146
7206
  });
7147
7207
 
7208
+ // "After a relay restart, tell interrupted sessions to carry on"
7209
+ // (restart_resume_notes, off by default; services/restart-resume-service.mjs).
7210
+ app.get('/api/settings/restart-resume', auth, (_req, res) => {
7211
+ res.json({ enabled: getRestartResumeEnabled() === true, default: false });
7212
+ });
7213
+
7214
+ app.post('/api/settings/restart-resume', auth, (req, res) => {
7215
+ const parsed = parseRestartResumeUpdate(req.body?.enabled);
7216
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
7217
+ const result = setRestartResumeEnabled(parsed.enabled);
7218
+ if (!result.ok) return res.status(500).json({ error: result.error });
7219
+ return res.json({ enabled: result.enabled, default: false });
7220
+ });
7221
+
7148
7222
  const pwaAppNamePayload = (appName) => ({
7149
7223
  appName,
7150
7224
  defaultName: PWA_APP_NAME_DEFAULT,