@oar-sh/oar 0.9.11 → 0.9.13

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 (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -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
+ }
@@ -9,13 +9,28 @@ import { randomUUID } from 'crypto';
9
9
  import { createSdkSessionSyncService } from '../services/sdk-session-sync-service.mjs';
10
10
  import { serveFileWithRangeSupport } from '../services/file-serving.mjs';
11
11
  import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
12
+ import {
13
+ buildConversationHistoryPageInfo,
14
+ normalizeConversationHistoryLimit,
15
+ normalizeConversationTimestampMs,
16
+ } from '../services/conversation-history-page.mjs';
12
17
  import { MESSAGE_PIN_LIMIT, buildPinList } from '../services/message-pin-list.mjs';
13
18
  import { buildHiddenMessageList } from '../services/hidden-message-list.mjs';
14
19
  import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
15
20
  import { parseAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
21
+ import {
22
+ claudeBackgroundCommandLimitSettingsFields,
23
+ parseClaudeBackgroundCommandLimitUpdate,
24
+ } from '../../shared/claude-background-command-limit.mjs';
16
25
  import { CLAUDE_ATTRIBUTION_MODES, DEFAULT_CLAUDE_ATTRIBUTION_MODE, claudeAttributionModelLabel, claudeAttributionTrailer, normalizeClaudeAttributionMode, resolveClaudeAttributionMode } from '../../shared/claude-attribution.mjs';
17
26
  import { parseThinkingDisplay, parseThinkingEnabled } from '../../shared/claude-thinking.mjs';
18
- 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';
19
34
  import {
20
35
  DEFAULT_CLAUDE_REASONING_EFFORTS,
21
36
  resolveProviderReasoningEffort,
@@ -97,11 +112,18 @@ import {
97
112
  BACKGROUND_TASK_TIMEOUT_STEP_MINUTES,
98
113
  parseBackgroundTaskTimeoutUpdate,
99
114
  } from '../../shared/background-task-timeout.mjs';
115
+ import {
116
+ DEFAULT_TASK_STALL_MINUTES,
117
+ TASK_STALL_MINUTES_MAX,
118
+ TASK_STALL_MINUTES_MIN,
119
+ parseTaskStallMinutesUpdate,
120
+ } from '../../shared/task-output-text.mjs';
100
121
  import {
101
122
  PWA_APP_NAME_DEFAULT,
102
123
  PWA_APP_NAME_MAX_LENGTH,
103
124
  derivePwaShortName,
104
125
  } from '../../shared/pwa-app-name.mjs';
126
+ import { parseRestartResumeUpdate } from '../services/restart-resume-service.mjs';
105
127
 
106
128
  export { mapUsageSnapshotRow };
107
129
 
@@ -286,9 +308,16 @@ export function parseClaudeSettingsUpdateRequest(body = {}) {
286
308
  const hasEnabledModels = Array.isArray(payload.enabledModels);
287
309
  const hasAttributionMode = Object.prototype.hasOwnProperty.call(payload, 'attributionMode');
288
310
  const attributionMode = hasAttributionMode ? normalizeClaudeAttributionMode(payload.attributionMode) : undefined;
289
- if (!hasEnabled && !hasModel && !hasModels && !hasEnabledModels && !hasAttributionMode) {
311
+ const hasBackgroundCommandLimit = Object.prototype.hasOwnProperty.call(payload, 'backgroundCommandLimitMinutes');
312
+ const backgroundCommandLimit = hasBackgroundCommandLimit
313
+ ? parseClaudeBackgroundCommandLimitUpdate(payload.backgroundCommandLimitMinutes)
314
+ : null;
315
+ if (!hasEnabled && !hasModel && !hasModels && !hasEnabledModels && !hasAttributionMode && !hasBackgroundCommandLimit) {
290
316
  return { ok: false, error: 'No Claude settings update provided' };
291
317
  }
318
+ if (backgroundCommandLimit && !backgroundCommandLimit.ok) {
319
+ return { ok: false, error: backgroundCommandLimit.error };
320
+ }
292
321
  if (hasModel && !isSafeClaudeModelId(model)) {
293
322
  return { ok: false, error: 'Invalid Claude model ID' };
294
323
  }
@@ -302,6 +331,8 @@ export function parseClaudeSettingsUpdateRequest(body = {}) {
302
331
  ...(hasModels ? { models: payload.models } : {}),
303
332
  ...(hasEnabledModels ? { enabledModels: payload.enabledModels } : {}),
304
333
  ...(hasAttributionMode ? { attributionMode } : {}),
334
+ // Clamped to 30 min … 8 h and snapped to 30-minute steps.
335
+ ...(backgroundCommandLimit ? { backgroundCommandLimitMinutes: backgroundCommandLimit.minutes } : {}),
305
336
  };
306
337
  }
307
338
 
@@ -1504,19 +1535,6 @@ function isLikelyCanonicalDuplicateMessage(a, b, timestampWindowMs = 30_000) {
1504
1535
  return Math.abs(aTs - bTs) <= Math.max(1_000, Number(timestampWindowMs) || 30_000);
1505
1536
  }
1506
1537
 
1507
- function normalizeConversationTimestampMs(value) {
1508
- const text = String(value || '').trim();
1509
- if (!text) return 0;
1510
- const sqliteUtcLike = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?)$/;
1511
- const hasExplicitTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(text);
1512
- let normalized = text;
1513
- if (!hasExplicitTimezone && sqliteUtcLike.test(text)) {
1514
- const [, day, time] = text.match(sqliteUtcLike) || [];
1515
- normalized = `${day}T${time}Z`;
1516
- }
1517
- const parsed = Date.parse(normalized);
1518
- return Number.isFinite(parsed) ? parsed : 0;
1519
- }
1520
1538
 
1521
1539
  function normalizeConversationTimestampIso(value, fallback = null) {
1522
1540
  const ms = normalizeConversationTimestampMs(value);
@@ -1563,11 +1581,7 @@ function compareConversationMessageOrder(a, b) {
1563
1581
  return aId.localeCompare(bId);
1564
1582
  }
1565
1583
 
1566
- export function normalizeConversationHistoryLimit(value, fallback = 20) {
1567
- const numeric = Number(value);
1568
- if (!Number.isFinite(numeric)) return fallback;
1569
- return Math.min(100, Math.max(1, Math.trunc(numeric)));
1570
- }
1584
+ export { normalizeConversationHistoryLimit };
1571
1585
 
1572
1586
  function resolveConversationHistoryCursor(messages = [], {
1573
1587
  messageId = '',
@@ -1672,26 +1686,9 @@ export function selectConversationHistoryPage(messages = [], {
1672
1686
  }
1673
1687
  const hasMoreOlder = firstIdx > 0;
1674
1688
  const hasMoreNewer = lastIdx >= 0 && lastIdx < (ordered.length - 1);
1675
- const oldestMessage = pageMessages[0] || null;
1676
1689
  return {
1677
1690
  messages: pageMessages,
1678
- pageInfo: {
1679
- hasMore: hasMoreOlder,
1680
- hasMoreOlder,
1681
- hasMoreNewer,
1682
- nextCursor: oldestMessage ? {
1683
- beforeMessageId: String(oldestMessage.id || '').trim(),
1684
- beforeTimestamp: String(oldestMessage.timestamp || '').trim() || null,
1685
- } : null,
1686
- olderCursor: oldestMessage ? {
1687
- beforeMessageId: String(oldestMessage.id || '').trim(),
1688
- beforeTimestamp: String(oldestMessage.timestamp || '').trim() || null,
1689
- } : null,
1690
- newerCursor: lastMessage ? {
1691
- afterMessageId: String(lastMessage.id || '').trim(),
1692
- afterTimestamp: String(lastMessage.timestamp || '').trim() || null,
1693
- } : null,
1694
- },
1691
+ pageInfo: buildConversationHistoryPageInfo(pageMessages, { hasMoreOlder, hasMoreNewer }),
1695
1692
  };
1696
1693
  }
1697
1694
 
@@ -1762,6 +1759,10 @@ export function buildConversationMessages({
1762
1759
  responseMessageToSourceId = new Map(),
1763
1760
  queueRows = [],
1764
1761
  usageByResponseMessageId = new Map(),
1762
+ // When dbMessages is one page: the conversation's rows liveResendsByOriginal
1763
+ // reads (id, role, source_message_id, resend_of_message_id), since a stopped
1764
+ // marker on the page may have its Resend outside it.
1765
+ resendIndexMessages = null,
1765
1766
  } = {}) {
1766
1767
  const queueRowsById = new Map(
1767
1768
  (Array.isArray(queueRows) ? queueRows : [])
@@ -1770,7 +1771,7 @@ export function buildConversationMessages({
1770
1771
  );
1771
1772
  // A stopped steer's marker says "Resent" from the data, so it holds across
1772
1773
  // reloads and devices (the relay refuses a second Resend on the same basis).
1773
- const liveResends = liveResendsByOriginal(dbMessages, queueRows);
1774
+ const liveResends = liveResendsByOriginal(Array.isArray(resendIndexMessages) ? resendIndexMessages : dbMessages, queueRows);
1774
1775
  const normalizedDbMessages = Array.isArray(dbMessages)
1775
1776
  ? dbMessages.map((message) => {
1776
1777
  const id = String(message?.id || '').trim();
@@ -2143,6 +2144,10 @@ export function registerSessionsRoutes(app, deps) {
2143
2144
  setPwaAppName = () => ({ ok: false, status: 500, error: 'App name settings are unavailable' }),
2144
2145
  getBackgroundTaskTimeoutMinutes = () => DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES,
2145
2146
  setBackgroundTaskTimeoutMinutes = () => ({ ok: false, error: 'Background task timeout settings are unavailable' }),
2147
+ getTaskStallMinutes = () => DEFAULT_TASK_STALL_MINUTES,
2148
+ setTaskStallMinutes = () => ({ ok: false, error: 'Task stall settings are unavailable' }),
2149
+ getRestartResumeEnabled = () => false,
2150
+ setRestartResumeEnabled = () => ({ ok: false, error: 'Restart settings are unavailable' }),
2146
2151
  markSharedViewerPresence,
2147
2152
  getSharedWatcherCount,
2148
2153
  statusEventService,
@@ -2790,6 +2795,36 @@ export function registerSessionsRoutes(app, deps) {
2790
2795
  };
2791
2796
  }
2792
2797
 
2798
+ // One history page selected in SQL (conversation-history-page.mjs), so
2799
+ // only the page is hydrated; null when the statements are absent or the
2800
+ // conversation cannot be paged in SQL, and the caller loads every row.
2801
+ function selectConversationHistoryPageRows(conversationId, cursor = {}, { sharedOnly = false } = {}) {
2802
+ const pager = stmts.conversationHistoryPage;
2803
+ if (!pager || typeof pager.selectConversationHistoryPage !== 'function') return null;
2804
+ return pager.selectConversationHistoryPage(conversationId, { ...cursor, sharedOnly }) || null;
2805
+ }
2806
+
2807
+ // The usage snapshots of a page's replies, keyed by reply id.
2808
+ function usageSnapshotsForPage(conversationId, pageRows = []) {
2809
+ const usage = new Map();
2810
+ if (!stmts.getMessageUsageSnapshotByResponse) return usage;
2811
+ for (const row of pageRows) {
2812
+ if (row?.role !== 'assistant') continue;
2813
+ const id = String(row?.id || '').trim();
2814
+ const snapshot = id ? mapUsageSnapshotRow(stmts.getMessageUsageSnapshotByResponse.get(id, conversationId)) : null;
2815
+ if (snapshot) usage.set(id, snapshot);
2816
+ }
2817
+ return usage;
2818
+ }
2819
+
2820
+ // What liveResendsByOriginal needs beyond a page: only read when the page
2821
+ // holds a stopped marker, the one place its result is shown.
2822
+ function resendIndexForPage(conversationId, pageRows = [], { sharedOnly = false } = {}) {
2823
+ const hasStoppedMarker = pageRows.some((row) => row?.role === 'assistant' && row?.kind === 'stopped');
2824
+ if (!hasStoppedMarker) return [];
2825
+ return stmts.conversationHistoryPage?.listConversationResendIndex?.(conversationId, { sharedOnly }) || [];
2826
+ }
2827
+
2793
2828
  // A shared attachment is served by its share URL: the host path it is
2794
2829
  // stored at (and the `@path` reference built from it) stays on the relay.
2795
2830
  function shareSafeAttachment(attachment, shareToken) {
@@ -2820,9 +2855,11 @@ export function registerSessionsRoutes(app, deps) {
2820
2855
  });
2821
2856
  const inFlight = inFlightStateForConversation(resolvedConversationId);
2822
2857
  const sdkSessionId = String(conv.sdk_session_id || resolvedConversationId || '').trim();
2823
- const dbMessages = filterMessagesVisibleToSharedView(
2824
- (stmts.getSharedMessages || stmts.getMessages).all(resolvedConversationId),
2825
- );
2858
+ const historyCursor = { limit, beforeMessageId, beforeTimestamp, afterMessageId, afterTimestamp, aroundMessageId };
2859
+ const sqlPage = selectConversationHistoryPageRows(resolvedConversationId, historyCursor, { sharedOnly: true });
2860
+ const dbMessages = filterMessagesVisibleToSharedView(sqlPage
2861
+ ? sqlPage.rows
2862
+ : (stmts.getSharedMessages || stmts.getMessages).all(resolvedConversationId));
2826
2863
  const queueRows = db.prepare(`
2827
2864
  SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
2828
2865
  FROM queue
@@ -2853,6 +2890,20 @@ export function registerSessionsRoutes(app, deps) {
2853
2890
  .filter((m) => m.role === 'assistant')
2854
2891
  .map((m) => [m.id, workflowRunsForResponse ? workflowRunsForResponse(m.id) : []]),
2855
2892
  );
2893
+ // The page hydrates only its own cards, but the tokens to redact are the
2894
+ // whole conversation's: a reply on this page can quote a card's link from
2895
+ // outside it. The page's cards below add theirs too (a relay without the
2896
+ // statement still redacts what it shows).
2897
+ if (previewTokens) {
2898
+ for (const row of stmts.listPreviewCardJsonByConversation?.all(resolvedConversationId) || []) {
2899
+ try {
2900
+ const token = JSON.parse(String(row?.preview_json || ''))?.token;
2901
+ if (token) previewTokens.add?.(String(token));
2902
+ } catch {
2903
+ // An unreadable snapshot renders no card, so it has no link to quote.
2904
+ }
2905
+ }
2906
+ }
2856
2907
  // A preview link is a capability that now outlives the turn and the
2857
2908
  // restart: a public transcript keeps only the label, never token or link.
2858
2909
  const previewCardsByMessageId = new Map(
@@ -2870,7 +2921,7 @@ export function registerSessionsRoutes(app, deps) {
2870
2921
  redacted: true,
2871
2922
  }))]),
2872
2923
  );
2873
- const usageByResponseMessageId = new Map(
2924
+ const usageByResponseMessageId = sqlPage ? usageSnapshotsForPage(conv.id, dbMessages) : new Map(
2874
2925
  (stmts.listMessageUsageSnapshotsByConversation?.all(conv.id) || [])
2875
2926
  .map((row) => [String(row?.response_message_id || '').trim(), mapUsageSnapshotRow(row)])
2876
2927
  .filter(([messageId, usage]) => !!messageId && !!usage),
@@ -2892,6 +2943,7 @@ export function registerSessionsRoutes(app, deps) {
2892
2943
  responseMessageToSourceId,
2893
2944
  queueRows,
2894
2945
  usageByResponseMessageId,
2946
+ resendIndexMessages: sqlPage ? resendIndexForPage(resolvedConversationId, dbMessages, { sharedOnly: true }) : null,
2895
2947
  });
2896
2948
  messages = messages.map((message) => {
2897
2949
  const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
@@ -2909,14 +2961,9 @@ export function registerSessionsRoutes(app, deps) {
2909
2961
  ? nextMessage
2910
2962
  : { ...nextMessage, attachments };
2911
2963
  });
2912
- const history = selectConversationHistoryPage(messages, {
2913
- limit,
2914
- beforeMessageId,
2915
- beforeTimestamp,
2916
- afterMessageId,
2917
- afterTimestamp,
2918
- aroundMessageId,
2919
- });
2964
+ const history = sqlPage
2965
+ ? { messages, pageInfo: sqlPage.pageInfo }
2966
+ : selectConversationHistoryPage(messages, historyCursor);
2920
2967
  return {
2921
2968
  id: resolvedConversationId,
2922
2969
  sdkSessionId: conv.sdk_session_id || null,
@@ -4377,7 +4424,10 @@ export function registerSessionsRoutes(app, deps) {
4377
4424
  resolveClaudeSessionRoot,
4378
4425
  resolveCursorSessionRoot,
4379
4426
  });
4380
- const dbMessages = stmts.getMessages.all(resolvedConversationId);
4427
+ const historyCursor = { limit, beforeMessageId, beforeTimestamp, afterMessageId, afterTimestamp, aroundMessageId };
4428
+ // The page's rows only: every later lookup is per page, not per conversation.
4429
+ const sqlPage = selectConversationHistoryPageRows(resolvedConversationId, historyCursor);
4430
+ const dbMessages = sqlPage ? sqlPage.rows : stmts.getMessages.all(resolvedConversationId);
4381
4431
  const queueRows = db.prepare(`
4382
4432
  SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
4383
4433
  FROM queue
@@ -4413,7 +4463,7 @@ export function registerSessionsRoutes(app, deps) {
4413
4463
  .filter((m) => m.role === 'assistant')
4414
4464
  .map((m) => [m.id, previewCardsForResponse ? previewCardsForResponse(m.id) : []]),
4415
4465
  );
4416
- const usageByResponseMessageId = new Map(
4466
+ const usageByResponseMessageId = sqlPage ? usageSnapshotsForPage(conv.id, dbMessages) : new Map(
4417
4467
  (stmts.listMessageUsageSnapshotsByConversation?.all(conv.id) || [])
4418
4468
  .map((row) => [String(row?.response_message_id || '').trim(), mapUsageSnapshotRow(row)])
4419
4469
  .filter(([messageId, usage]) => !!messageId && !!usage),
@@ -4432,20 +4482,16 @@ export function registerSessionsRoutes(app, deps) {
4432
4482
  responseMessageToSourceId,
4433
4483
  queueRows,
4434
4484
  usageByResponseMessageId,
4485
+ resendIndexMessages: sqlPage ? resendIndexForPage(resolvedConversationId, dbMessages, { sharedOnly: false }) : null,
4435
4486
  });
4436
4487
  messages = messages.map((message) => {
4437
4488
  if (message.role !== 'assistant') return message;
4438
4489
  const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
4439
4490
  return sourceMessageId ? { ...message, sourceMessageId } : message;
4440
4491
  });
4441
- const history = selectConversationHistoryPage(messages, {
4442
- limit,
4443
- beforeMessageId,
4444
- beforeTimestamp,
4445
- afterMessageId,
4446
- afterTimestamp,
4447
- aroundMessageId,
4448
- });
4492
+ const history = sqlPage
4493
+ ? { messages, pageInfo: sqlPage.pageInfo }
4494
+ : selectConversationHistoryPage(messages, historyCursor);
4449
4495
  res.json({
4450
4496
  id: conv.id,
4451
4497
  sdkSessionId: conv.sdk_session_id || null,
@@ -4483,9 +4529,20 @@ export function registerSessionsRoutes(app, deps) {
4483
4529
  ...(describeSessionActivity(resolvedConversationId) || {}),
4484
4530
  // The published set, else the placeholder for background work a live
4485
4531
  // worker reports without this runtime knowing its details.
4486
- backgroundTasks: backgroundWorkView
4487
- ? backgroundWorkView.panelTasks(resolvedConversationId, runtimeSession?.sdk_session_id)
4488
- : (backgroundTaskStore?.get?.(resolvedConversationId) || []),
4532
+ // Each with the health poller's last reading (`health`); the tasks
4533
+ // that ended within the hour with a console beside them.
4534
+ backgroundTasks: (() => {
4535
+ const taskKeys = [resolvedConversationId, runtimeSession?.sdk_session_id];
4536
+ const tasks = backgroundWorkView
4537
+ ? backgroundWorkView.panelTasks(...taskKeys)
4538
+ : (backgroundTaskStore?.get?.(resolvedConversationId) || []);
4539
+ return backgroundTaskStore?.withHealth ? backgroundTaskStore.withHealth(tasks, ...taskKeys) : tasks;
4540
+ })(),
4541
+ endedBackgroundTasks: backgroundTaskStore?.ended?.(resolvedConversationId, runtimeSession?.sdk_session_id) || [],
4542
+ // Minutes without output after which the panel calls a task quiet.
4543
+ taskStallMinutes: (() => {
4544
+ try { return getTaskStallMinutes(); } catch { return DEFAULT_TASK_STALL_MINUTES; }
4545
+ })(),
4489
4546
  usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
4490
4547
  previews: listConversationPreviews?.(resolvedConversationId) || [],
4491
4548
  // Every pin of the conversation, also those outside this history page.
@@ -5744,6 +5801,55 @@ export function registerSessionsRoutes(app, deps) {
5744
5801
  supportedModels: providerModelSelection.availableModels || [],
5745
5802
  });
5746
5803
  }
5804
+ // Copilot falls back to the catalogue's current model for an id it does
5805
+ // not list, which New Chat (it only offers listed ids) never meets. An
5806
+ // agent naming a mistyped id ("gpt-5-4") would get a session on another
5807
+ // model than it asked for, or one its next send cannot reach: refused.
5808
+ // A listed id is bound in the list's casing, and one with its effort
5809
+ // ("gpt-5.4-high") as the listed id plus that effort: the catalogue
5810
+ // lookup below knows exact base ids only and would fall back too.
5811
+ let copilotBoundModel = '';
5812
+ let copilotVariantEffort = '';
5813
+ if (
5814
+ remoteRequest.remote
5815
+ && !useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider
5816
+ ) {
5817
+ const mergedModels = Array.isArray(modelState?.models) ? modelState.models : [];
5818
+ const providersByModel = modelState?.providersByModel && typeof modelState.providersByModel === 'object'
5819
+ ? modelState.providersByModel
5820
+ : {};
5821
+ // Copilot's own: listed for it, or for no provider in particular.
5822
+ const copilotModels = mergedModels.filter((modelId) => {
5823
+ const providers = providersByModel[String(modelId || '').trim().toLowerCase()];
5824
+ return !Array.isArray(providers) || providers.length === 0
5825
+ || providers.some((provider) => String(provider || '').trim().toLowerCase() === 'github-copilot');
5826
+ });
5827
+ const reasoningByModel = modelState?.reasoningByModel && typeof modelState.reasoningByModel === 'object'
5828
+ ? modelState.reasoningByModel
5829
+ : {};
5830
+ const copilotCheck = checkRequestedProviderModel({
5831
+ requestedModel: requestedBootstrapModel,
5832
+ provider: 'github',
5833
+ models: copilotModels,
5834
+ effortsFor: (base) => reasoningByModel[base] || reasoningByModel[String(base).toLowerCase()] || null,
5835
+ });
5836
+ // Another provider's model is answered by the checks below ("requires
5837
+ // the Claude provider"), not as an unknown one.
5838
+ const otherProviders = mergedModels.some((modelId) => String(modelId || '').trim().toLowerCase() === requestedBootstrapModel.toLowerCase());
5839
+ if (copilotCheck.status === 'known' && copilotCheck.model && !isAutoModelId(copilotCheck.model)) {
5840
+ copilotBoundModel = copilotCheck.base || copilotCheck.model;
5841
+ copilotVariantEffort = copilotCheck.effort || '';
5842
+ }
5843
+ if (copilotCheck.status === 'unknown' && !otherProviders) {
5844
+ return res.status(400).json({
5845
+ ok: false,
5846
+ error: unknownProviderModelText({ model: copilotCheck.model, provider: 'github', suggestions: copilotCheck.suggestions, models: copilotCheck.models }),
5847
+ code: 'COPILOT_MODEL_UNAVAILABLE',
5848
+ supportedModels: copilotCheck.models,
5849
+ ...(copilotCheck.suggestions.length ? { suggestedModels: copilotCheck.suggestions } : {}),
5850
+ });
5851
+ }
5852
+ }
5747
5853
  if (useOpenAIProvider && openAISettings?.configured !== true) {
5748
5854
  return res.status(400).json({
5749
5855
  ok: false,
@@ -5794,7 +5900,7 @@ export function registerSessionsRoutes(app, deps) {
5794
5900
  bootstrapWorkspaceRootPath = validatedRoot.realPath;
5795
5901
  }
5796
5902
 
5797
- const selectedModel = providerModelSelection ? providerModelSelection.model : catalogSelectedModel;
5903
+ const selectedModel = providerModelSelection ? providerModelSelection.model : (copilotBoundModel || catalogSelectedModel);
5798
5904
  if (requestedProviderType === 'openai-image' && !isOpenAIImageModelId(selectedModel)) {
5799
5905
  return res.status(400).json({
5800
5906
  ok: false,
@@ -5866,7 +5972,7 @@ export function registerSessionsRoutes(app, deps) {
5866
5972
  // first send would apply, so the composer can restore it from the server
5867
5973
  // instead of guessing from localStorage.
5868
5974
  const bootstrapReasoning = resolveProviderReasoningEffort({
5869
- requestedEffort: req.body?.reasoningEffort,
5975
+ requestedEffort: req.body?.reasoningEffort || copilotVariantEffort,
5870
5976
  strict: bootstrapProviderType === 'openai',
5871
5977
  supportedEfforts: supportedReasoningEffortsForProviderModel({
5872
5978
  providerType: bootstrapProviderType,
@@ -6291,6 +6397,7 @@ export function registerSessionsRoutes(app, deps) {
6291
6397
  models: Array.isArray(settings?.models) ? settings.models : [],
6292
6398
  availableModels: Array.isArray(settings?.availableModels) ? settings.availableModels : [],
6293
6399
  ...claudeAttributionSettingsFields(settings),
6400
+ ...claudeBackgroundCommandLimitSettingsFields(settings),
6294
6401
  });
6295
6402
  });
6296
6403
 
@@ -6340,6 +6447,7 @@ export function registerSessionsRoutes(app, deps) {
6340
6447
  models: Array.isArray(currentSettings?.models) ? currentSettings.models : [],
6341
6448
  availableModels: Array.isArray(currentSettings?.availableModels) ? currentSettings.availableModels : [],
6342
6449
  ...claudeAttributionSettingsFields(currentSettings),
6450
+ ...claudeBackgroundCommandLimitSettingsFields(currentSettings),
6343
6451
  reconciliation,
6344
6452
  };
6345
6453
  io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
@@ -7075,6 +7183,40 @@ export function registerSessionsRoutes(app, deps) {
7075
7183
  });
7076
7184
  });
7077
7185
 
7186
+ // "Mark a background task as quiet after N minutes" (task_stall_minutes).
7187
+ const taskStallMinutesPayload = (minutes) => ({
7188
+ minutes,
7189
+ min: TASK_STALL_MINUTES_MIN,
7190
+ max: TASK_STALL_MINUTES_MAX,
7191
+ default: DEFAULT_TASK_STALL_MINUTES,
7192
+ });
7193
+
7194
+ app.get('/api/settings/task-stall-minutes', auth, (_req, res) => {
7195
+ res.json(taskStallMinutesPayload(getTaskStallMinutes()));
7196
+ });
7197
+
7198
+ app.post('/api/settings/task-stall-minutes', auth, (req, res) => {
7199
+ const parsed = parseTaskStallMinutesUpdate(req.body?.minutes);
7200
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
7201
+ const result = setTaskStallMinutes(parsed.minutes);
7202
+ if (!result.ok) return res.status(500).json({ error: result.error });
7203
+ return res.json(taskStallMinutesPayload(result.minutes));
7204
+ });
7205
+
7206
+ // "After a relay restart, tell interrupted sessions to carry on"
7207
+ // (restart_resume_notes, off by default; services/restart-resume-service.mjs).
7208
+ app.get('/api/settings/restart-resume', auth, (_req, res) => {
7209
+ res.json({ enabled: getRestartResumeEnabled() === true, default: false });
7210
+ });
7211
+
7212
+ app.post('/api/settings/restart-resume', auth, (req, res) => {
7213
+ const parsed = parseRestartResumeUpdate(req.body?.enabled);
7214
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
7215
+ const result = setRestartResumeEnabled(parsed.enabled);
7216
+ if (!result.ok) return res.status(500).json({ error: result.error });
7217
+ return res.json({ enabled: result.enabled, default: false });
7218
+ });
7219
+
7078
7220
  const pwaAppNamePayload = (appName) => ({
7079
7221
  appName,
7080
7222
  defaultName: PWA_APP_NAME_DEFAULT,