@oar-sh/oar 0.9.7 → 0.9.9

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 (64) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/README.md +89 -40
  3. package/bin/oar.js +1023 -135
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
  7. package/server/claude-worker/claude-session-process.mjs +196 -28
  8. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  9. package/server/db-schema.mjs +8 -0
  10. package/server/migrations/0008-message-pins.mjs +85 -0
  11. package/server/public/app/activity-replay-state.mjs +68 -5
  12. package/server/public/app/api-client.js +36 -2
  13. package/server/public/app/attachments-view.js +508 -125
  14. package/server/public/app/back-guard.mjs +58 -0
  15. package/server/public/app/bootstrap.js +170 -59
  16. package/server/public/app/context-usage-view.mjs +127 -8
  17. package/server/public/app/conversation-view.js +172 -32
  18. package/server/public/app/external-link-policy.mjs +4 -1
  19. package/server/public/app/file-version.mjs +58 -0
  20. package/server/public/app/journal-view.js +16 -2
  21. package/server/public/app/pinned-messages-view.mjs +253 -0
  22. package/server/public/app/pwa-install.js +6 -1
  23. package/server/public/app/relay-dot-state.mjs +8 -1
  24. package/server/public/app/router.js +8 -2
  25. package/server/public/app/scrollbar-hover.mjs +78 -0
  26. package/server/public/app/settings-modal.js +4 -1
  27. package/server/public/app/socket-handlers.js +8 -1
  28. package/server/public/app/store.js +32 -0
  29. package/server/public/app/transcript-separators.mjs +48 -11
  30. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  31. package/server/public/app/viewer-controls-state.mjs +59 -0
  32. package/server/public/app/viewer-gallery.mjs +46 -0
  33. package/server/public/app/viewer-gestures.mjs +105 -0
  34. package/server/public/app/viewer-history.mjs +43 -0
  35. package/server/public/app/viewer-tap.mjs +112 -0
  36. package/server/public/app-icon-192.png +0 -0
  37. package/server/public/app-icon-512.png +0 -0
  38. package/server/public/app-icon-light-192.png +0 -0
  39. package/server/public/app-icon-light-512.png +0 -0
  40. package/server/public/app-icon-light-maskable-512.png +0 -0
  41. package/server/public/app-icon-light.svg +4 -0
  42. package/server/public/app-icon-maskable-512.png +0 -0
  43. package/server/public/app-icon.svg +3 -3
  44. package/server/public/favicon.ico +0 -0
  45. package/server/public/index.html +487 -67
  46. package/server/public/manifest.webmanifest +9 -3
  47. package/server/public/sw.js +6 -1
  48. package/server/repositories/message-pin-statements.mjs +39 -0
  49. package/server/repositories/message-repository.mjs +5 -0
  50. package/server/routes/messages-routes.mjs +36 -62
  51. package/server/routes/sessions-routes.mjs +107 -26
  52. package/server/server-runtime.mjs +39 -8
  53. package/server/services/claude-context-usage.mjs +51 -3
  54. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  55. package/server/services/file-serving.mjs +155 -0
  56. package/server/services/message-pin-list.mjs +52 -0
  57. package/server/services/oar-cli-helpers.mjs +205 -4
  58. package/server/services/oar-cli-menu.mjs +240 -0
  59. package/server/services/oar-cli-wsl.mjs +129 -0
  60. package/server/services/pwa-manifest-theme.mjs +30 -0
  61. package/server/services/safe-served-content.mjs +61 -11
  62. package/server/services/session-history-refresh-service.mjs +24 -0
  63. package/server/services/session-worker-registry-service.mjs +18 -1
  64. package/server/services/update-install-service.mjs +9 -3
@@ -7,11 +7,11 @@
7
7
  "scope": "./",
8
8
  "display_override": ["standalone"],
9
9
  "display": "standalone",
10
- "background_color": "#161b22",
11
- "theme_color": "#161b22",
10
+ "background_color": "#0d1117",
11
+ "theme_color": "#0d1117",
12
12
  "icons": [
13
13
  {
14
- "src": "app-icon.svg?v=25",
14
+ "src": "app-icon.svg?v=26",
15
15
  "sizes": "any",
16
16
  "type": "image/svg+xml",
17
17
  "purpose": "any"
@@ -27,6 +27,12 @@
27
27
  "sizes": "512x512",
28
28
  "type": "image/png",
29
29
  "purpose": "any"
30
+ },
31
+ {
32
+ "src": "app-icon-maskable-512.png",
33
+ "sizes": "512x512",
34
+ "type": "image/png",
35
+ "purpose": "maskable"
30
36
  }
31
37
  ]
32
38
  }
@@ -13,6 +13,11 @@ const STATIC_ASSETS = [
13
13
  'app-icon-512.png',
14
14
  'favicon.ico',
15
15
  'app-icon.svg',
16
+ 'app-icon-light-192.png',
17
+ 'app-icon-light-512.png',
18
+ 'app-icon-light.svg',
19
+ 'app-icon-maskable-512.png',
20
+ 'app-icon-light-maskable-512.png',
16
21
  ];
17
22
 
18
23
  function sameOrigin(url) {
@@ -26,7 +31,7 @@ function isApiRequest(url) {
26
31
  function isPwaMetadataRequest(url) {
27
32
  if (!url.pathname.startsWith(REGISTRATION_SCOPE_PATH)) return false;
28
33
  const relativePath = url.pathname.slice(REGISTRATION_SCOPE_PATH.length);
29
- return /^(?:manifest\.webmanifest|app-icon(?:-\d+)?\.png|app-icon\.svg|favicon\.ico)$/.test(relativePath);
34
+ return /^(?:manifest\.webmanifest|app-icon(?:-light)?(?:-maskable)?(?:-\d+)?\.(?:png|svg)|favicon\.ico)$/.test(relativePath);
30
35
  }
31
36
 
32
37
  function isApplicationModuleRequest(url) {
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ // Pinned messages: the pinned_at column detection and its SQL in one place.
4
+ // The statements are null on a database the 0008 migration has not reached
5
+ // yet; every caller treats that as "no pins".
6
+ export function createMessagePinStatements(db) {
7
+ const messageColumns = new Set(
8
+ db.prepare(`PRAGMA table_info(messages)`).all().map((column) => String(column?.name || '').trim()),
9
+ );
10
+ const messagesSupportPins = messageColumns.has('pinned_at');
11
+ if (!messagesSupportPins) {
12
+ return {
13
+ messagesSupportPins,
14
+ setMessagePinnedAt: null,
15
+ listPinnedMessages: null,
16
+ countPinnedMessages: null,
17
+ };
18
+ }
19
+ return {
20
+ messagesSupportPins,
21
+ setMessagePinnedAt: db.prepare(`
22
+ UPDATE messages
23
+ SET pinned_at = ?
24
+ WHERE id = ? AND conversation_id = ?
25
+ `),
26
+ // Conversation order, not pin order: the list reads like a table of contents.
27
+ listPinnedMessages: db.prepare(`
28
+ SELECT id, role, text, mode, attachments, timestamp, pinned_at, hidden_from_shares
29
+ FROM messages
30
+ WHERE conversation_id = ? AND pinned_at IS NOT NULL
31
+ ORDER BY timestamp ASC
32
+ `),
33
+ countPinnedMessages: db.prepare(`
34
+ SELECT COUNT(*) AS cnt
35
+ FROM messages
36
+ WHERE conversation_id = ? AND pinned_at IS NOT NULL
37
+ `),
38
+ };
39
+ }
@@ -1,10 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  import { createShareVisibilityStatements } from './share-visibility-statements.mjs';
4
+ import { createMessagePinStatements } from './message-pin-statements.mjs';
4
5
  import { sessionWorkerProviderSqlList } from '../../shared/provider-routing.mjs';
5
6
 
6
7
  export function createMessageRepository(db) {
7
8
  const shareVisibility = createShareVisibilityStatements(db);
9
+ const messagePins = createMessagePinStatements(db);
8
10
  const queueHasImageOperationId = db.prepare(`PRAGMA table_info(queue)`).all()
9
11
  .some((column) => column.name === 'image_operation_id');
10
12
  // A turn paused at the Claude usage limit (usage-limit-pause-service): a
@@ -27,6 +29,9 @@ export function createMessageRepository(db) {
27
29
  getSharedMessages: shareVisibility.getSharedMessages,
28
30
  getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
29
31
  setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
32
+ setMessagePinnedAt: messagePins.setMessagePinnedAt,
33
+ listPinnedMessages: messagePins.listPinnedMessages,
34
+ countPinnedMessages: messagePins.countPinnedMessages,
30
35
  getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
31
36
  getRecentMessagesDesc: db.prepare(`SELECT id, role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
32
37
  insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
@@ -5,7 +5,7 @@ import path from 'path';
5
5
  import { randomUUID } from 'crypto';
6
6
  import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
7
7
  import { resolveUploadMimeType } from '../services/mime-sniffer.mjs';
8
- import { applySafeServedContentHeaders } from '../services/safe-served-content.mjs';
8
+ import { fileVersionToken, serveFileWithRangeSupport, withFileVersion } from '../services/file-serving.mjs';
9
9
  import {
10
10
  shouldParkForRestart,
11
11
  parkPendingQueueForRestart,
@@ -71,6 +71,11 @@ const STRANDED_SESSION_HEARTBEAT_FRESH_MS = 15_000;
71
71
  const RELAY_QUESTION_FINALIZATION_HOLD_MS = 5_000;
72
72
  const AUTO_MODEL_SENTINEL = 'auto';
73
73
  const USAGE_FETCH_RETRY_DELAY_MS = 250;
74
+ // Preview kinds whose bytes travel in the preview JSON; media and PDF are
75
+ // fetched by the browser from the raw URL instead.
76
+ const TEXT_PREVIEW_KINDS = new Set(['markdown', 'code', 'text']);
77
+ // An upload's URL names its bytes (the sha256), so a kept answer stays right.
78
+ const UPLOAD_CONTENT_CACHE_CONTROL = 'private, max-age=31536000, immutable';
74
79
 
75
80
  export function resolveOpenAIReasoningEffort(explicitReasoningEffort = '', model = '') {
76
81
  const explicit = String(explicitReasoningEffort || '').trim().toLowerCase();
@@ -1610,57 +1615,6 @@ export function cleanupPersistedGeneratedImagesForAssistantResponse({
1610
1615
  }
1611
1616
  }
1612
1617
 
1613
- function serveFileWithRangeSupport(req, res, filePath, meta, { safeName, cacheDelete = null } = {}) {
1614
- const fileSize = meta.size;
1615
- const name = safeName || path.basename(filePath).replace(/"/g, '');
1616
- const rangeHeader = req.headers['range'];
1617
-
1618
- res.setHeader('Accept-Ranges', 'bytes');
1619
- res.setHeader('Cache-Control', 'no-store');
1620
- // Neutralize browser-executable types (HTML/SVG/XML), force nosniff, and
1621
- // sandbox the response so a worker-written workspace file can't run as script
1622
- // on this origin. Media stays inline for preview; everything else downloads.
1623
- applySafeServedContentHeaders(res, meta.contentType, { fileName: name });
1624
-
1625
- const onStreamError = (error) => {
1626
- if (cacheDelete) cacheDelete(filePath);
1627
- if (res.headersSent) { res.destroy(error); return; }
1628
- if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
1629
- res.status(404).json({ error: 'File not found' });
1630
- return;
1631
- }
1632
- res.status(500).json({ error: 'Failed to read file' });
1633
- };
1634
-
1635
- if (rangeHeader) {
1636
- const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
1637
- if (!match) {
1638
- res.setHeader('Content-Range', `bytes */${fileSize}`);
1639
- res.status(416).end();
1640
- return;
1641
- }
1642
- const start = parseInt(match[1], 10);
1643
- const end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
1644
- if (start > end || start >= fileSize || end >= fileSize) {
1645
- res.setHeader('Content-Range', `bytes */${fileSize}`);
1646
- res.status(416).end();
1647
- return;
1648
- }
1649
- const chunkSize = end - start + 1;
1650
- res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
1651
- res.setHeader('Content-Length', String(chunkSize));
1652
- res.status(206);
1653
- const stream = fs.createReadStream(filePath, { start, end });
1654
- stream.on('error', onStreamError);
1655
- stream.pipe(res);
1656
- } else {
1657
- res.setHeader('Content-Length', String(fileSize));
1658
- const stream = fs.createReadStream(filePath);
1659
- stream.on('error', onStreamError);
1660
- stream.pipe(res);
1661
- }
1662
- }
1663
-
1664
1618
  // Structural sanitizer for the workflow-progress digest a Claude session
1665
1619
  // worker attaches to its background-task rows (docs/plans/workflow-progress-tree.md,
1666
1620
  // Phase 2). The relay never trusts worker JSON: strings are trimmed and
@@ -1769,6 +1723,10 @@ export function registerMessagesRoutes(app, deps) {
1769
1723
  fetchLinuxDirectoryEntries,
1770
1724
  mapLinuxDirectoryEntry,
1771
1725
  readWorkspaceFileMeta,
1726
+ // The cache readWorkspaceFileMeta fills. The routes drop an entry when a
1727
+ // read shows it is out of date; the name was used here without ever
1728
+ // being handed in, so each of those paths threw a ReferenceError.
1729
+ workspaceFileMetaCache,
1772
1730
  resolveWorkspaceFilePath,
1773
1731
  normalizeWorkspaceRelativePath,
1774
1732
  previewLanguageForWorkspaceFile,
@@ -3408,9 +3366,10 @@ export function registerMessagesRoutes(app, deps) {
3408
3366
  if (!file) return res.status(404).json({ error: 'Not found' });
3409
3367
  const filePath = uploadPathForSha(sha256);
3410
3368
  if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Missing file on disk' });
3411
- res.setHeader('Cache-Control', 'private, max-age=31536000, immutable');
3412
- applySafeServedContentHeaders(res, file.mime_type, { fileName: file.name || file.file_name || '' });
3413
- fs.createReadStream(filePath).pipe(res);
3369
+ serveFileWithRangeSupport(req, res, filePath, { contentType: file.mime_type }, {
3370
+ safeName: String(file.name || file.file_name || '').replace(/"/g, ''),
3371
+ cacheControl: UPLOAD_CONTENT_CACHE_CONTROL,
3372
+ });
3414
3373
  });
3415
3374
 
3416
3375
  app.get('/api/generated-image/:conversationId/:messageId/:imageId/content', auth, (req, res) => {
@@ -3483,6 +3442,9 @@ export function registerMessagesRoutes(app, deps) {
3483
3442
  const filePath = resolveWorkspaceFilePath(requestedPath, rootOverride);
3484
3443
  if (!filePath || !normalizedPath) return res.status(400).json({ error: 'Invalid file path' });
3485
3444
 
3445
+ // The preview carries the version the download link is built from, so
3446
+ // it reads the file's metadata fresh, not from the short-lived cache.
3447
+ workspaceFileMetaCache.delete(filePath);
3486
3448
  let meta = null;
3487
3449
  try {
3488
3450
  meta = readWorkspaceFileMeta(filePath);
@@ -3538,10 +3500,12 @@ export function registerMessagesRoutes(app, deps) {
3538
3500
  size,
3539
3501
  truncated,
3540
3502
  previewBytes: contentBuffer.length,
3541
- rawUrl: `${remotePath}/api/files/${normalizedWebPath.split('/').map((part) => encodeURIComponent(part)).join('/')}${scopeSuffix}`,
3503
+ mtimeMs: meta.mtimeMs ?? null,
3504
+ version: fileVersionToken(meta),
3505
+ rawUrl: withFileVersion(`${remotePath}/api/files/${normalizedWebPath.split('/').map((part) => encodeURIComponent(part)).join('/')}${scopeSuffix}`, fileVersionToken(meta)),
3542
3506
  };
3543
3507
 
3544
- if (kind !== 'binary' && kind !== 'image' && kind !== 'video') {
3508
+ if (TEXT_PREVIEW_KINDS.has(kind)) {
3545
3509
  payload.content = contentBuffer.toString('utf8');
3546
3510
  }
3547
3511
 
@@ -3917,6 +3881,8 @@ export function registerMessagesRoutes(app, deps) {
3917
3881
  const filePath = normalizeLinuxAbsolutePath(requestedPath);
3918
3882
  if (!filePath) return res.status(400).json({ error: 'Invalid Linux file path' });
3919
3883
 
3884
+ // Fresh metadata: the download link's version comes from this answer.
3885
+ workspaceFileMetaCache.delete(filePath);
3920
3886
  let meta = null;
3921
3887
  try {
3922
3888
  meta = readWorkspaceFileMeta(filePath);
@@ -3969,10 +3935,12 @@ export function registerMessagesRoutes(app, deps) {
3969
3935
  size,
3970
3936
  truncated,
3971
3937
  previewBytes: contentBuffer.length,
3972
- rawUrl: `${remotePath}/api/drives/file?path=${encodeURIComponent(filePath)}`,
3938
+ mtimeMs: meta.mtimeMs ?? null,
3939
+ version: fileVersionToken(meta),
3940
+ rawUrl: withFileVersion(`${remotePath}/api/drives/file?path=${encodeURIComponent(filePath)}`, fileVersionToken(meta)),
3973
3941
  };
3974
3942
 
3975
- if (kind !== 'binary' && kind !== 'image' && kind !== 'video') {
3943
+ if (TEXT_PREVIEW_KINDS.has(kind)) {
3976
3944
  payload.content = contentBuffer.toString('utf8');
3977
3945
  }
3978
3946
 
@@ -3989,6 +3957,8 @@ export function registerMessagesRoutes(app, deps) {
3989
3957
  return res.status(400).json({ error: 'Invalid drive file path' });
3990
3958
  }
3991
3959
 
3960
+ // Fresh metadata: the download link's version comes from this answer.
3961
+ workspaceFileMetaCache.delete(filePath);
3992
3962
  let meta = null;
3993
3963
  try {
3994
3964
  meta = readWorkspaceFileMeta(filePath);
@@ -4043,10 +4013,12 @@ export function registerMessagesRoutes(app, deps) {
4043
4013
  size,
4044
4014
  truncated,
4045
4015
  previewBytes: contentBuffer.length,
4046
- rawUrl: `${remotePath}/api/drives/file?path=${encodeURIComponent(normalizedWebPath)}`,
4016
+ mtimeMs: meta.mtimeMs ?? null,
4017
+ version: fileVersionToken(meta),
4018
+ rawUrl: withFileVersion(`${remotePath}/api/drives/file?path=${encodeURIComponent(normalizedWebPath)}`, fileVersionToken(meta)),
4047
4019
  };
4048
4020
 
4049
- if (kind !== 'binary' && kind !== 'image' && kind !== 'video') {
4021
+ if (TEXT_PREVIEW_KINDS.has(kind)) {
4050
4022
  payload.content = contentBuffer.toString('utf8');
4051
4023
  }
4052
4024
 
@@ -5147,7 +5119,9 @@ export function registerMessagesRoutes(app, deps) {
5147
5119
  // in either must reach the status payload (composer gate, bubble
5148
5120
  // Cancel), so it counts as a change like the hold fields do.
5149
5121
  || (stored.supported === true) !== (steering.supported === true)
5150
- || storedCancellableIds.join('\n') !== steering.cancellableIds.join('\n');
5122
+ || storedCancellableIds.join('\n') !== steering.cancellableIds.join('\n')
5123
+ // The running CLI's compaction window (context-usage modal).
5124
+ || JSON.stringify(stored.autoCompactWindow ?? null) !== JSON.stringify(steering.autoCompactWindow ?? null);
5151
5125
  if (workerForSteering && changed) {
5152
5126
  sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
5153
5127
  // Pushed rather than left for the clients' status poll: the
@@ -7,8 +7,9 @@ import path from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { randomUUID } from 'crypto';
9
9
  import { createSdkSessionSyncService } from '../services/sdk-session-sync-service.mjs';
10
- import { applySafeServedContentHeaders } from '../services/safe-served-content.mjs';
10
+ import { serveFileWithRangeSupport } from '../services/file-serving.mjs';
11
11
  import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
12
+ import { MESSAGE_PIN_LIMIT, buildPinList } from '../services/message-pin-list.mjs';
12
13
  import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
13
14
  import { parseAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
14
15
  import { CLAUDE_ATTRIBUTION_MODES, DEFAULT_CLAUDE_ATTRIBUTION_MODE, claudeAttributionModelLabel, claudeAttributionTrailer, normalizeClaudeAttributionMode, resolveClaudeAttributionMode } from '../../shared/claude-attribution.mjs';
@@ -21,7 +22,7 @@ import {
21
22
  withClaudeUltracodeTier,
22
23
  } from '../services/provider-reasoning-effort.mjs';
23
24
  import { mapUsageSnapshotRow, fetchUsageSummaryPromise } from '../services/usage-snapshot-helpers.mjs';
24
- import { readStoredClaudeContextUsage } from '../services/claude-context-usage.mjs';
25
+ import { readStoredClaudeContextUsage, resolveAutoCompactWindowStatus } from '../services/claude-context-usage.mjs';
25
26
  import { buildContextUsageView } from '../services/context-usage-view.mjs';
26
27
  import { cleanupGeneratedImagesForConversation as cleanupGeneratedImagesForConversationDefault } from '../services/generated-image-cleanup-service.mjs';
27
28
  import { stopSessionWorkerProcesses } from '../services/session-worker-stop-service.mjs';
@@ -88,6 +89,9 @@ const SESSION_WORKER_STATUS_QUEUE_STATES = Object.freeze(['pending', 'processing
88
89
  // Registry statuses that mean "this worker is currently running" (same triple
89
90
  // as countActiveCliWorkers in server-runtime.mjs).
90
91
  const SESSION_WORKER_ACTIVE_STATUSES = Object.freeze(['starting', 'ready', 'processing']);
92
+ // A shared attachment's URL names its bytes (sha256 or a fixed generated-image
93
+ // path), so a kept answer stays right.
94
+ const SHARED_CONTENT_CACHE_CONTROL = 'private, max-age=31536000, immutable';
91
95
 
92
96
  function normalizeWorkerStatusText(value, fallback = null) {
93
97
  const text = String(value || '').trim();
@@ -1834,6 +1838,7 @@ export function buildConversationMessages({
1834
1838
  attachments: message?.attachments || [],
1835
1839
  mode: message?.mode || undefined,
1836
1840
  hiddenFromShares: Number(message?.hidden_from_shares || 0) === 1,
1841
+ pinned: !!message?.pinned_at,
1837
1842
  timestamp: message?.timestamp,
1838
1843
  sourceMessageId,
1839
1844
  executedProvider: message?.executed_provider || message?.executedProvider || undefined,
@@ -2218,6 +2223,23 @@ export function registerSessionsRoutes(app, deps) {
2218
2223
  AND (id = ? OR response_message_id = ?)
2219
2224
  LIMIT 1
2220
2225
  `);
2226
+ function listConversationPins(conversationId) {
2227
+ return buildPinList(stmts.listPinnedMessages?.all?.(conversationId) || []);
2228
+ }
2229
+ // Orders the pin lists a browser receives. A conversation load that left
2230
+ // before a pin change can arrive after the change's own socket event; with
2231
+ // the revision the browser can tell the older list from the newer one. In
2232
+ // memory only: a restart starts above every revision handed out before.
2233
+ const pinRevisionFloor = Date.now();
2234
+ const pinRevisionByConversation = new Map();
2235
+ function conversationPinRevision(conversationId) {
2236
+ return pinRevisionByConversation.get(conversationId) || pinRevisionFloor;
2237
+ }
2238
+ function bumpConversationPinRevision(conversationId) {
2239
+ const next = Math.max(Date.now(), conversationPinRevision(conversationId) + 1);
2240
+ pinRevisionByConversation.set(conversationId, next);
2241
+ return next;
2242
+ }
2221
2243
  const listSessionWorkerQueueRows = db.prepare(`
2222
2244
  SELECT id, conversation_id, runtime_session_id, owner_sdk_session_id, status
2223
2245
  FROM queue
@@ -2712,7 +2734,9 @@ export function registerSessionsRoutes(app, deps) {
2712
2734
  });
2713
2735
  messages = messages.map((message) => {
2714
2736
  const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
2715
- const withSource = sourceMessageId ? { ...message, sourceMessageId } : message;
2737
+ // Pins are the owner's bookmarks: a public share never learns of them.
2738
+ const { pinned: _pinned, ...unpinnedMessage } = message;
2739
+ const withSource = sourceMessageId ? { ...unpinnedMessage, sourceMessageId } : unpinnedMessage;
2716
2740
  // A public share never shows another relay's address or ids.
2717
2741
  const nextMessage = withSource.origin
2718
2742
  ? { ...withSource, origin: publicRemoteRelayOrigin(withSource.origin) }
@@ -3212,6 +3236,7 @@ export function registerSessionsRoutes(app, deps) {
3212
3236
  return {
3213
3237
  snapshot: stored.snapshot,
3214
3238
  contextUsage: stored.contextUsage,
3239
+ modelContextLimit: stored.modelContextLimit,
3215
3240
  eventsPath: null,
3216
3241
  error: stored.snapshot
3217
3242
  ? null
@@ -3231,6 +3256,15 @@ export function registerSessionsRoutes(app, deps) {
3231
3256
  const conversationRow = resolveConversationByIdOrSdkSessionId(
3232
3257
  String(runtimeSession?.conversation_id || '').trim() || lookupId,
3233
3258
  );
3259
+ const autoCompactWindow = parseAutoCompactWindow(conversationRow?.auto_compact_window);
3260
+ // Only the local Claude worker runs a CLI whose window the slider sets;
3261
+ // it reports that CLI on its heartbeat's steering snapshot.
3262
+ const windowStatus = resolveAutoCompactWindowStatus({
3263
+ storedWindow: autoCompactWindow,
3264
+ steering: providerType === 'claude' && copilotSessionId
3265
+ ? sessionWorkerRegistry?.getWorker?.(copilotSessionId)?.steering || null
3266
+ : null,
3267
+ });
3234
3268
 
3235
3269
  return {
3236
3270
  conversationId: lookupId,
@@ -3238,7 +3272,10 @@ export function registerSessionsRoutes(app, deps) {
3238
3272
  // for the modal's refresh handle), so the id a preferences PATCH needs is
3239
3273
  // reported separately.
3240
3274
  resolvedConversationId: conversationRow?.id || null,
3241
- autoCompactWindow: parseAutoCompactWindow(conversationRow?.auto_compact_window),
3275
+ autoCompactWindow,
3276
+ activeAutoCompactWindow: windowStatus.activeAutoCompactWindow,
3277
+ autoCompactWindowDeferred: windowStatus.autoCompactWindowDeferred,
3278
+ modelContextLimit: parsed.modelContextLimit ?? null,
3242
3279
  thinkingEnabled: parseThinkingEnabled(conversationRow?.thinking_enabled),
3243
3280
  thinkingDisplay: parseThinkingDisplay(conversationRow?.thinking_display),
3244
3281
  // What this folder's Claude commits say (the 🧠 modal's override row).
@@ -3587,6 +3624,9 @@ export function registerSessionsRoutes(app, deps) {
3587
3624
  cloud: conversationCloudPayload(conv, runtimeSession),
3588
3625
  messages: history.messages,
3589
3626
  pageInfo: history.pageInfo,
3627
+ // A re-import can move a pin onto a new message id.
3628
+ pins: listConversationPins(conversationId),
3629
+ pinsRevision: conversationPinRevision(conversationId),
3590
3630
  refreshed: true,
3591
3631
  });
3592
3632
  });
@@ -3764,20 +3804,14 @@ export function registerSessionsRoutes(app, deps) {
3764
3804
  if (!file) return res.status(404).json({ error: 'Shared attachment not found' });
3765
3805
  const filePath = uploadPathForSha(sha256);
3766
3806
  if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Missing file on disk' });
3767
- // Unauthenticated share viewers: neutralize executable types (HTML/SVG),
3768
- // force nosniff + sandbox so an attachment can't run as script on this origin.
3769
- res.setHeader('Cache-Control', 'private, max-age=31536000, immutable');
3807
+ // Unauthenticated share viewers: the serving helper neutralizes executable
3808
+ // types (HTML/SVG) and forces nosniff + sandbox so an attachment can't run
3809
+ // as script on this origin; ranges let a shared video seek.
3770
3810
  res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
3771
- applySafeServedContentHeaders(res, file.mime_type, { fileName: file.name || file.file_name || '' });
3772
- const stream = fs.createReadStream(filePath);
3773
- stream.on('error', () => {
3774
- if (!res.headersSent) {
3775
- res.status(500).json({ error: 'Failed to stream shared attachment' });
3776
- return;
3777
- }
3778
- res.destroy();
3811
+ serveFileWithRangeSupport(req, res, filePath, { contentType: file.mime_type }, {
3812
+ safeName: String(file.name || file.file_name || '').replace(/"/g, ''),
3813
+ cacheControl: SHARED_CONTENT_CACHE_CONTROL,
3779
3814
  });
3780
- stream.pipe(res);
3781
3815
  });
3782
3816
 
3783
3817
  app.get('/api/shared/:token/generated-image/:messageId/:imageId/content', (req, res) => {
@@ -3819,18 +3853,10 @@ export function registerSessionsRoutes(app, deps) {
3819
3853
  return res.status(404).json({ error: 'Shared attachment not found' });
3820
3854
  }
3821
3855
  if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Shared attachment not found' });
3822
- res.setHeader('Cache-Control', 'private, max-age=31536000, immutable');
3823
3856
  res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
3824
- applySafeServedContentHeaders(res, attachment.type);
3825
- const stream = fs.createReadStream(filePath);
3826
- stream.on('error', () => {
3827
- if (!res.headersSent) {
3828
- res.status(500).json({ error: 'Failed to stream shared attachment' });
3829
- return;
3830
- }
3831
- res.destroy();
3857
+ serveFileWithRangeSupport(req, res, filePath, { contentType: attachment.type }, {
3858
+ cacheControl: SHARED_CONTENT_CACHE_CONTROL,
3832
3859
  });
3833
- stream.pipe(res);
3834
3860
  });
3835
3861
 
3836
3862
  // GET /api/conversation/:id — get paginated conversation history
@@ -3987,6 +4013,9 @@ export function registerSessionsRoutes(app, deps) {
3987
4013
  backgroundTasks: backgroundTaskStore?.get?.(resolvedConversationId) || [],
3988
4014
  usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
3989
4015
  previews: listConversationPreviews?.(resolvedConversationId) || [],
4016
+ // Every pin of the conversation, also those outside this history page.
4017
+ pins: listConversationPins(resolvedConversationId),
4018
+ pinsRevision: conversationPinRevision(resolvedConversationId),
3990
4019
  preferredRelayMode: preferences.preferredRelayMode,
3991
4020
  preferredModel: preferences.preferredModel,
3992
4021
  preferredReasoningEffort: preferences.preferredReasoningEffort,
@@ -4046,6 +4075,57 @@ export function registerSessionsRoutes(app, deps) {
4046
4075
  });
4047
4076
  });
4048
4077
 
4078
+ // Pin or unpin one message. Unlike share visibility this may change while
4079
+ // the message's turn runs (nothing public depends on it), and every open
4080
+ // client learns of it through the socket instead of reloading.
4081
+ app.patch('/api/conversation/:id/message/:messageId/pin', auth, (req, res) => {
4082
+ const conversationId = String(req.params.id || '').trim();
4083
+ const messageId = String(req.params.messageId || '').trim();
4084
+ if (!conversationId || !messageId) {
4085
+ return res.status(400).json({ error: 'Missing conversation or message id' });
4086
+ }
4087
+ if (typeof req.body?.pinned !== 'boolean') {
4088
+ return res.status(400).json({ error: 'pinned must be a boolean' });
4089
+ }
4090
+
4091
+ const conv = stmts.getConvAnyStatus.get(conversationId);
4092
+ if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') {
4093
+ return res.status(404).json({ error: 'Conversation not found' });
4094
+ }
4095
+ const message = stmts.getMessageByConversation?.get(messageId, conversationId) || null;
4096
+ if (!message) return res.status(404).json({ error: 'Message not found' });
4097
+ if (!stmts.setMessagePinnedAt || !stmts.countPinnedMessages) {
4098
+ return res.status(503).json({ error: 'Pinning messages is unavailable until the relay restarts' });
4099
+ }
4100
+
4101
+ const pinned = req.body.pinned === true;
4102
+ const currentlyPinned = !!message.pinned_at;
4103
+ if (currentlyPinned !== pinned) {
4104
+ if (pinned && Number(stmts.countPinnedMessages.get(conversationId)?.cnt || 0) >= MESSAGE_PIN_LIMIT) {
4105
+ return res.status(409).json({
4106
+ error: `This conversation already has ${MESSAGE_PIN_LIMIT} pinned messages. Unpin one first.`,
4107
+ code: 'pin-limit',
4108
+ limit: MESSAGE_PIN_LIMIT,
4109
+ });
4110
+ }
4111
+ stmts.setMessagePinnedAt.run(pinned ? new Date().toISOString() : null, messageId, conversationId);
4112
+ }
4113
+
4114
+ const pins = listConversationPins(conversationId);
4115
+ if (currentlyPinned !== pinned) {
4116
+ const revision = bumpConversationPinRevision(conversationId);
4117
+ io?.emit?.('conversation_pins_updated', { conversationId, pins, revision });
4118
+ }
4119
+ return res.json({
4120
+ ok: true,
4121
+ conversationId,
4122
+ messageId,
4123
+ pinned,
4124
+ pins,
4125
+ pinsRevision: conversationPinRevision(conversationId),
4126
+ });
4127
+ });
4128
+
4049
4129
  app.patch('/api/conversation/:id', auth, (req, res) => {
4050
4130
  const conversationId = String(req.params.id || '').trim();
4051
4131
  const result = persistConversationTitle({
@@ -5448,6 +5528,7 @@ export function registerSessionsRoutes(app, deps) {
5448
5528
  reconnectAttempts: runtimeState.cloudflaredTunnelState?.reconnectAttempts ?? 0,
5449
5529
  connectedSince: runtimeState.cloudflaredTunnelState?.connectedSince ?? null,
5450
5530
  lastError: runtimeState.cloudflaredTunnelState?.lastError ?? null,
5531
+ binaryMissing: runtimeState.cloudflaredTunnelState?.binaryMissing ?? false,
5451
5532
  valid: runtimeState.cloudflaredTunnelState?.valid ?? true,
5452
5533
  },
5453
5534
  workerWebSocket: runtimeState.workerWebSocketStatus || null,