@oar-sh/oar 0.9.10 → 0.9.11

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 (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -10,6 +10,7 @@ import { createSdkSessionSyncService } from '../services/sdk-session-sync-servic
10
10
  import { serveFileWithRangeSupport } from '../services/file-serving.mjs';
11
11
  import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
12
12
  import { MESSAGE_PIN_LIMIT, buildPinList } from '../services/message-pin-list.mjs';
13
+ import { buildHiddenMessageList } from '../services/hidden-message-list.mjs';
13
14
  import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
14
15
  import { parseAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
15
16
  import { CLAUDE_ATTRIBUTION_MODES, DEFAULT_CLAUDE_ATTRIBUTION_MODE, claudeAttributionModelLabel, claudeAttributionTrailer, normalizeClaudeAttributionMode, resolveClaudeAttributionMode } from '../../shared/claude-attribution.mjs';
@@ -34,10 +35,19 @@ import {
34
35
  readWorkspaceRootPathFromBody,
35
36
  validateRequestedWorkspaceRoot, normalizeWorkspaceRootKey } from '../services/workspace-root-path-policy.mjs';
36
37
  import { workspaceRootDisplayName } from '../workspace-root.mjs';
38
+ import { redactPreviewTokens } from '../services/preview-registry-service.mjs';
39
+ import { createSharePathRedactor } from '../services/share-path-redaction.mjs';
40
+ import { createSubagentTimelineService, TIMELINE_READ_DEFAULT_LIMIT } from '../services/subagent-timeline-service.mjs';
41
+ import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
37
42
  import { createKeyedMutex } from '../services/keyed-mutex-service.mjs';
38
43
  import { createFixedWindowRateLimiter } from '../services/rate-limit-service.mjs';
44
+ import { requestHostOf } from '../services/request-boundary.mjs';
39
45
  import { parseRemoteRelayOriginJson } from '../repositories/remote-relay-repository.mjs';
40
- import { admitRemoteRelayRequest, publicRemoteRelayOrigin } from '../services/remote-relay-inbound.mjs';
46
+ import { createShareRepository } from '../repositories/share-repository.mjs';
47
+ import { createShareAccessLog, readShareRequestViewer } from '../services/share-access-log-service.mjs';
48
+ import { resolveGroupDisplayName } from '../repositories/conversation-group-repository.mjs';
49
+ import { conversationGroupPosition, conversationGroupRole, groupActivityTimes, pageConversationRows } from '../public/app/conversation-group-order.mjs';
50
+ import { admitRemoteRelayRequest, publicRemoteRelayOrigin, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
41
51
  import {
42
52
  RELAUNCH_COALESCE_WINDOW_MS,
43
53
  buildRelaunchRequestKey,
@@ -74,6 +84,12 @@ import {
74
84
  SUBAGENT_RECORD_RETENTION_CHOICES,
75
85
  parseSubagentRecordRetentionUpdate,
76
86
  } from '../../shared/subagent-record-retention.mjs';
87
+ import {
88
+ DEFAULT_SHARE_PATH_PRIVACY_MODE,
89
+ SHARE_PATH_PRIVACY_LABELS,
90
+ SHARE_PATH_PRIVACY_MODES,
91
+ parseSharePathPrivacyUpdate,
92
+ } from '../../shared/share-path-privacy.mjs';
77
93
  import {
78
94
  DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES,
79
95
  BACKGROUND_TASK_TIMEOUT_MAX_MINUTES,
@@ -1714,80 +1730,25 @@ export function normalizeConversationListLimit(value, fallback = DEFAULT_CONVERS
1714
1730
  return Math.min(MAX_CONVERSATION_LIST_LIMIT, Math.max(1, Math.trunc(parsed)));
1715
1731
  }
1716
1732
 
1717
- function compareConversationListOrder(a, b) {
1718
- const aUpdatedAtMs = normalizeConversationTimestampMs(a?.updated_at || a?.updatedAt);
1719
- const bUpdatedAtMs = normalizeConversationTimestampMs(b?.updated_at || b?.updatedAt);
1720
- if (aUpdatedAtMs !== bUpdatedAtMs) return bUpdatedAtMs - aUpdatedAtMs;
1721
- return String(b?.id || '').trim().localeCompare(String(a?.id || '').trim());
1722
- }
1723
-
1724
- function resolveConversationListCursor(rows = [], {
1725
- beforeConversationId = '',
1726
- beforeUpdatedAt = '',
1727
- } = {}) {
1728
- const cursorConversationId = String(beforeConversationId || '').trim();
1729
- const cursorUpdatedAt = String(beforeUpdatedAt || '').trim();
1730
- const list = Array.isArray(rows) ? rows : [];
1731
-
1732
- if (cursorConversationId) {
1733
- const cursorRow = list.find((row) => String(row?.id || '').trim() === cursorConversationId) || null;
1734
- if (cursorRow) {
1735
- return {
1736
- beforeConversationId: cursorConversationId,
1737
- beforeUpdatedAt: normalizeConversationTimestampIso(
1738
- cursorRow.updated_at || cursorRow.updatedAt,
1739
- String(cursorRow.updated_at || cursorRow.updatedAt || '').trim() || null,
1740
- ),
1741
- timestampMs: normalizeConversationTimestampMs(cursorRow.updated_at || cursorRow.updatedAt),
1742
- };
1743
- }
1744
- }
1745
-
1746
- if (cursorUpdatedAt) {
1747
- return {
1748
- beforeConversationId: cursorConversationId || null,
1749
- beforeUpdatedAt: cursorUpdatedAt,
1750
- timestampMs: normalizeConversationTimestampMs(cursorUpdatedAt),
1751
- };
1752
- }
1753
-
1754
- return null;
1755
- }
1756
-
1733
+ /**
1734
+ * One page of the conversation list, newest first, with session groups kept
1735
+ * whole: the order and the cursor are those of conversation-group-order.mjs,
1736
+ * which the sidebar uses too. `groups` is the `Map<id, group>` of the rows'
1737
+ * groups (the head of each group is needed for the order inside it).
1738
+ */
1757
1739
  export function selectConversationListPage(rows = [], {
1758
1740
  limit = DEFAULT_CONVERSATION_LIST_LIMIT,
1759
1741
  beforeConversationId = '',
1760
1742
  beforeUpdatedAt = '',
1743
+ beforeGroupId = '',
1744
+ groups = null,
1761
1745
  } = {}) {
1762
- const pageLimit = normalizeConversationListLimit(limit);
1763
- const ordered = Array.isArray(rows)
1764
- ? rows.filter((row) => !!row).slice().sort(compareConversationListOrder)
1765
- : [];
1766
- const cursor = resolveConversationListCursor(ordered, { beforeConversationId, beforeUpdatedAt });
1767
- const olderRows = cursor
1768
- ? ordered.filter((row) => {
1769
- const rowUpdatedAtMs = normalizeConversationTimestampMs(row?.updated_at || row?.updatedAt);
1770
- if (rowUpdatedAtMs < cursor.timestampMs) return true;
1771
- if (rowUpdatedAtMs > cursor.timestampMs) return false;
1772
- if (!cursor.beforeConversationId) return false;
1773
- return String(row?.id || '').trim().localeCompare(cursor.beforeConversationId) < 0;
1774
- })
1775
- : ordered;
1776
- const pageRows = olderRows.slice(0, pageLimit);
1777
- const oldestRow = pageRows[pageRows.length - 1] || null;
1778
- return {
1779
- rows: pageRows,
1780
- pageInfo: {
1781
- hasMore: olderRows.length > pageRows.length,
1782
- nextCursor: oldestRow ? {
1783
- beforeConversationId: String(oldestRow.id || '').trim(),
1784
- beforeUpdatedAt: normalizeConversationTimestampIso(
1785
- oldestRow.updated_at || oldestRow.updatedAt,
1786
- String(oldestRow.updated_at || oldestRow.updatedAt || '').trim() || null,
1787
- ),
1788
- } : null,
1789
- },
1790
- };
1746
+ return pageConversationRows(rows, groups, {
1747
+ limit: normalizeConversationListLimit(limit),
1748
+ beforeConversationId,
1749
+ beforeUpdatedAt,
1750
+ beforeGroupId,
1751
+ });
1791
1752
  }
1792
1753
 
1793
1754
  export function buildConversationMessages({
@@ -2039,7 +2000,10 @@ export function registerSessionsRoutes(app, deps) {
2039
2000
  readContextFromSessionEvents,
2040
2001
  inFlightStateForConversation,
2041
2002
  backgroundTaskStore,
2003
+ backgroundWorkView = null,
2042
2004
  listConversationPreviews,
2005
+ listKnownPreviewTokens,
2006
+ closeConversationPreviews,
2043
2007
  createCompactedConversation,
2044
2008
  collectOrphanedUploadsFromConversation,
2045
2009
  deleteOrphanedUploads,
@@ -2140,6 +2104,9 @@ export function registerSessionsRoutes(app, deps) {
2140
2104
  setGrokPlanAllowanceSettings = () => ({ ok: false, error: 'Grok allowance settings are unavailable' }),
2141
2105
  sessionHistoryRefreshService,
2142
2106
  sdkSessionImportService,
2107
+ // Session groups (migration 0014): null in older wiring and most tests,
2108
+ // where every conversation is ungrouped.
2109
+ conversationGroups = null,
2143
2110
  ensureRuntimeSessionBinding,
2144
2111
  bootstrapRuntimeSessionBindings,
2145
2112
  configuredConversationSessionMode,
@@ -2167,6 +2134,9 @@ export function registerSessionsRoutes(app, deps) {
2167
2134
  setTurnCeilingMinutes = () => ({ ok: false, error: 'Turn ceiling settings are unavailable' }),
2168
2135
  getSubagentRecordRetentionDays = () => DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS,
2169
2136
  setSubagentRecordRetentionDays = () => ({ ok: false, error: 'Sub-agent record settings are unavailable' }),
2137
+ getSharePathPrivacyMode = () => DEFAULT_SHARE_PATH_PRIVACY_MODE,
2138
+ setSharePathPrivacyMode = () => ({ ok: false, error: 'Path privacy settings are unavailable' }),
2139
+ sharePathKnownRoots = () => ({ roots: [], userNames: [] }),
2170
2140
  getFeatureFlagsSettingsState = () => [],
2171
2141
  setFeatureFlagSetting = () => ({ ok: false, error: 'Feature settings are unavailable' }),
2172
2142
  getPwaAppName = () => '',
@@ -2192,8 +2162,16 @@ export function registerSessionsRoutes(app, deps) {
2192
2162
  // subscriptions that end with an archived or deleted conversation.
2193
2163
  relayEventService = null,
2194
2164
  sessionActivityTracker = null,
2165
+ // Public shares and their access log (migration 0016); built on `db` when
2166
+ // the wiring does not pass one.
2167
+ shareRepository = null,
2195
2168
  } = deps;
2196
2169
  const sdkSessionSyncService = createSdkSessionSyncService(db);
2170
+ // Built on first use: many route tests hand in a database without tables.
2171
+ let shareRepositoryInstance = shareRepository;
2172
+ let shareAccessLogInstance = null;
2173
+ const shares = () => (shareRepositoryInstance ||= createShareRepository(db));
2174
+ const shareAccessLog = () => (shareAccessLogInstance ||= createShareAccessLog({ repository: shares() }));
2197
2175
  let listRunningRunConversations = null;
2198
2176
  try {
2199
2177
  listRunningRunConversations = db.prepare(`SELECT DISTINCT conversation_id FROM subagent_runs WHERE status = 'running'`);
@@ -2272,19 +2250,47 @@ export function registerSessionsRoutes(app, deps) {
2272
2250
  function listConversationPins(conversationId) {
2273
2251
  return buildPinList(stmts.listPinnedMessages?.all?.(conversationId) || []);
2274
2252
  }
2275
- // Orders the pin lists a browser receives. A conversation load that left
2276
- // before a pin change can arrive after the change's own socket event; with
2277
- // the revision the browser can tell the older list from the newer one. In
2278
- // memory only: a restart starts above every revision handed out before.
2279
- const pinRevisionFloor = Date.now();
2280
- const pinRevisionByConversation = new Map();
2281
- function conversationPinRevision(conversationId) {
2282
- return pinRevisionByConversation.get(conversationId) || pinRevisionFloor;
2253
+ function listConversationHiddenMessages(conversationId) {
2254
+ return buildHiddenMessageList(stmts.listHiddenMessages?.all?.(conversationId) || []);
2255
+ }
2256
+ // Orders the per-conversation lists a browser receives (pins, hidden
2257
+ // messages). A conversation load that left before a change can arrive after
2258
+ // the change's own socket event; with the revision the browser can tell the
2259
+ // older list from the newer one. In memory only: a restart starts above
2260
+ // every revision handed out before.
2261
+ function createConversationListRevisions() {
2262
+ const floor = Date.now();
2263
+ const byConversation = new Map();
2264
+ const current = (conversationId) => byConversation.get(conversationId) || floor;
2265
+ return {
2266
+ current,
2267
+ bump(conversationId) {
2268
+ const next = Math.max(Date.now(), current(conversationId) + 1);
2269
+ byConversation.set(conversationId, next);
2270
+ return next;
2271
+ },
2272
+ };
2283
2273
  }
2284
- function bumpConversationPinRevision(conversationId) {
2285
- const next = Math.max(Date.now(), conversationPinRevision(conversationId) + 1);
2286
- pinRevisionByConversation.set(conversationId, next);
2287
- return next;
2274
+ const pinRevisions = createConversationListRevisions();
2275
+ const conversationPinRevision = pinRevisions.current;
2276
+ const bumpConversationPinRevision = pinRevisions.bump;
2277
+ const hiddenMessageRevisions = createConversationListRevisions();
2278
+ const conversationHiddenMessagesRevision = hiddenMessageRevisions.current;
2279
+ // Every open owner page follows a share-visibility change without a reload:
2280
+ // the 🙈 list and count, the bubbles' Hide / Unhide, and the pin list's
2281
+ // "hidden from shared" chip when a pinned message changed.
2282
+ function broadcastShareVisibilityChange(conversationId, { pinnedChanged = false } = {}) {
2283
+ const hiddenMessages = listConversationHiddenMessages(conversationId);
2284
+ const revision = hiddenMessageRevisions.bump(conversationId);
2285
+ io?.emit?.('conversation_hidden_messages_updated', { conversationId, hiddenMessages, revision });
2286
+ if (pinnedChanged) {
2287
+ io?.emit?.('conversation_pins_updated', {
2288
+ conversationId,
2289
+ pins: listConversationPins(conversationId),
2290
+ revision: bumpConversationPinRevision(conversationId),
2291
+ });
2292
+ }
2293
+ return { hiddenMessages, revision };
2288
2294
  }
2289
2295
  const listSessionWorkerQueueRows = db.prepare(`
2290
2296
  SELECT id, conversation_id, runtime_session_id, owner_sdk_session_id, status
@@ -2359,6 +2365,8 @@ export function registerSessionsRoutes(app, deps) {
2359
2365
  db.prepare(`DELETE FROM queue WHERE conversation_id = ?`).run(conversationId);
2360
2366
  db.prepare(`DELETE FROM messages WHERE conversation_id = ?`).run(conversationId);
2361
2367
  db.prepare(`DELETE FROM runtime_sessions WHERE conversation_id = ?`).run(conversationId);
2368
+ // The share access log goes with it; the share rows follow the foreign key.
2369
+ shares().forgetConversation(conversationId);
2362
2370
  db.prepare(`DELETE FROM conversations WHERE id = ?`).run(conversationId);
2363
2371
  });
2364
2372
  const sharedPresenceRateLimiter = createFixedWindowRateLimiter({
@@ -2402,17 +2410,12 @@ export function registerSessionsRoutes(app, deps) {
2402
2410
  io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
2403
2411
  });
2404
2412
 
2413
+ // The client address as Express derives it through `trust proxy`: a
2414
+ // forwarded header counts only when a trusted proxy (by default one on
2415
+ // loopback) sent it, so a direct client cannot pick its own bucket.
2405
2416
  function extractClientIp(req) {
2406
- const forwardedForHeader = String(req.headers?.['x-forwarded-for'] || '').trim();
2407
- if (forwardedForHeader) {
2408
- const firstHop = forwardedForHeader.split(',')[0].trim();
2409
- const forwardedFor = firstHop.replace(/^for=/i, '').replace(/^"|"$/g, '').trim();
2410
- if (forwardedFor) return forwardedFor.slice(0, 128);
2411
- }
2412
- const realIp = String(req.headers?.['x-real-ip'] || '').trim();
2413
- if (realIp) return realIp.slice(0, 128);
2414
- const reqIp = String(req.ip || req.socket?.remoteAddress || '').trim();
2415
- return reqIp.slice(0, 128) || 'unknown';
2417
+ const ip = String(req.ip || req.socket?.remoteAddress || '').trim();
2418
+ return ip.slice(0, 128) || 'unknown';
2416
2419
  }
2417
2420
 
2418
2421
  function consumeSharedPresenceRateLimit(token, req, nowMs = Date.now()) {
@@ -2484,7 +2487,10 @@ export function registerSessionsRoutes(app, deps) {
2484
2487
  : LIVE_WORKER_STATUSES.has(String(worker.status || '').trim().toLowerCase()));
2485
2488
  const processingCount = Number(countProcessingConversationQueueRows.get(conversationId)?.count || 0);
2486
2489
  const taskKeys = [...new Set([conversationId, workerSessionId])];
2487
- const liveTasks = workerAlive ? taskKeys.flatMap((key) => backgroundTaskStore?.get?.(key) || []) : [];
2490
+ let liveTasks = workerAlive ? taskKeys.flatMap((key) => backgroundTaskStore?.get?.(key) || []) : [];
2491
+ // No published set, but the worker says it has background work the
2492
+ // relay was never told the details of: that counts, with no Stop button.
2493
+ if (workerAlive && !liveTasks.length && backgroundWorkView) liveTasks = backgroundWorkView.panelTasks(...taskKeys);
2488
2494
  return {
2489
2495
  turnRunning: processingCount > 0 && (workerAlive || !worker),
2490
2496
  backgroundTaskCount: liveTasks.length,
@@ -2598,15 +2604,98 @@ export function registerSessionsRoutes(app, deps) {
2598
2604
  resolveSessionStateRoot,
2599
2605
  });
2600
2606
  const orphanedUploads = collectOrphanedUploadsFromConversation(conversationId);
2607
+ const leftGroupId = conversationGroups?.forgetConversation?.(conversationId) || null;
2601
2608
  hardDeleteConversationRows(conversationId);
2602
2609
  deleteOrphanedUploads(orphanedUploads);
2603
2610
  io.emit('conversation_deleted', { conversationId });
2611
+ if (leftGroupId) emitGroupChanged(leftGroupId, [conversationId]);
2604
2612
  try { relayEventService?.noteConversationGone?.(conversationId, { deleted: true }); } catch {}
2605
2613
  finalized.push(conversationId);
2606
2614
  }
2607
2615
  return finalized;
2608
2616
  }
2609
2617
 
2618
+ /**
2619
+ * A session group as the list payload carries it: name, display name (the
2620
+ * head's current title when none was given), head and member counts.
2621
+ */
2622
+ function groupSummaryPayload(group) {
2623
+ if (!group) return null;
2624
+ const members = conversationGroups?.membersOf?.(group.id) || [];
2625
+ const head = group.parentConversationId
2626
+ ? members.find((member) => member.id === group.parentConversationId) || null
2627
+ : null;
2628
+ return {
2629
+ id: group.id,
2630
+ name: group.name || null,
2631
+ displayName: resolveGroupDisplayName({
2632
+ name: group.name,
2633
+ headTitle: head ? resolveConversationTitle({ title: head.title, titleSource: 'auto' }) : null,
2634
+ }),
2635
+ parentConversationId: group.parentConversationId || null,
2636
+ memberCount: members.length,
2637
+ liveMemberCount: members.filter((member) => !member.archived).length,
2638
+ };
2639
+ }
2640
+
2641
+ /** The `group` field of a single conversation payload: the summary plus the members. */
2642
+ /**
2643
+ * The `group` field of a single conversation payload: the summary and the
2644
+ * conversation's role. The live members, with what each is doing, only
2645
+ * when asked for (`groupMembers=1`, the remote_relay tool's read_session)
2646
+ * and never for a paired relay's agent: the browser does not read them,
2647
+ * and archived members' titles stay on this relay.
2648
+ */
2649
+ function conversationGroupPayload(conversationId, req = null) {
2650
+ const group = conversationGroups?.groupOfConversation?.(conversationId) || null;
2651
+ if (!group) return null;
2652
+ const summary = groupSummaryPayload(group);
2653
+ const payload = {
2654
+ ...summary,
2655
+ role: conversationId === group.parentConversationId ? 'head' : 'member',
2656
+ };
2657
+ const wantsMembers = String(req?.query?.groupMembers || '').trim() === '1';
2658
+ const request = req ? readRemoteRelayRequest(req, remoteRelayInbound) : { remote: false };
2659
+ if (wantsMembers && (!request.remote || request.local === true)) {
2660
+ payload.members = (conversationGroups.membersOf(group.id) || [])
2661
+ .filter((member) => member.archived !== true)
2662
+ .slice(0, 50)
2663
+ .map((member) => ({
2664
+ id: member.id,
2665
+ title: resolveConversationTitle({ title: member.title, titleSource: 'auto' }),
2666
+ role: member.id === group.parentConversationId ? 'head' : 'member',
2667
+ state: describeSessionActivity(member.id)?.sessionState || 'idle',
2668
+ }));
2669
+ }
2670
+ return payload;
2671
+ }
2672
+
2673
+ /** A conversation row's place in its group's hand order; null outside a group. */
2674
+ function conversationGroupPositionOf(conv) {
2675
+ if (!String(conv?.group_id || '').trim() || !conversationGroups?.getGroup?.(conv.group_id)) return null;
2676
+ return conversationGroupPosition(conv);
2677
+ }
2678
+
2679
+ /**
2680
+ * Tells every page a group changed, with what the page can apply in place:
2681
+ * the group's summary (null once dissolved), the named conversations'
2682
+ * membership, the group's order (head first) and the positions of its
2683
+ * members and of the named conversations.
2684
+ */
2685
+ function emitGroupChanged(groupId, conversationIds = []) {
2686
+ const ids = [...new Set(conversationIds.map((id) => String(id || '').trim()).filter(Boolean))];
2687
+ const group = groupId ? groupSummaryPayload(conversationGroups?.getGroup?.(groupId) || null) : null;
2688
+ const order = group ? conversationGroups?.orderOf?.(groupId) || [] : [];
2689
+ io.emit('conversation_group_changed', {
2690
+ groupId: groupId || null,
2691
+ conversationIds: ids,
2692
+ group,
2693
+ memberships: conversationGroups?.membershipsOf?.(ids) || {},
2694
+ order,
2695
+ positions: conversationGroups?.positionsOf?.([...order, ...ids]) || {},
2696
+ });
2697
+ }
2698
+
2610
2699
  /** The `cloud` field of a conversation payload; null unless it is a cloud one. */
2611
2700
  function conversationCloudPayload(conversation, runtimeSession) {
2612
2701
  return buildConversationCloudPayload({
@@ -2630,9 +2719,10 @@ export function registerSessionsRoutes(app, deps) {
2630
2719
  function buildShareUrl(req, token) {
2631
2720
  const shareToken = String(token || '').trim();
2632
2721
  if (!shareToken) return '';
2633
- const host = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
2634
- const forwardedProto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
2635
- const protocol = forwardedProto || req.protocol || 'http';
2722
+ // Host and scheme follow `trust proxy`: X-Forwarded-Host/-Proto only
2723
+ // from a trusted proxy, so a direct client cannot mint links elsewhere.
2724
+ const host = requestHostOf(req);
2725
+ const protocol = String(req.protocol || 'http').toLowerCase();
2636
2726
  const basePath = String(remotePath || '').trim().replace(/\/+$/, '');
2637
2727
  const relative = `${basePath}/shared/${shareToken}`.replace(/\/{2,}/g, '/');
2638
2728
  if (!host) return relative;
@@ -2700,6 +2790,15 @@ export function registerSessionsRoutes(app, deps) {
2700
2790
  };
2701
2791
  }
2702
2792
 
2793
+ // A shared attachment is served by its share URL: the host path it is
2794
+ // stored at (and the `@path` reference built from it) stays on the relay.
2795
+ function shareSafeAttachment(attachment, shareToken) {
2796
+ const rewritten = rewriteSharedGeneratedImageAttachmentContentUrl(rewriteSharedAttachmentContentUrl(attachment, shareToken), shareToken);
2797
+ if (!rewritten || typeof rewritten !== 'object') return rewritten;
2798
+ const { path: _path, reference: _reference, ...rest } = rewritten;
2799
+ return rest;
2800
+ }
2801
+
2703
2802
  function buildConversationPayloadForShare({
2704
2803
  conv,
2705
2804
  shareToken = '',
@@ -2709,6 +2808,9 @@ export function registerSessionsRoutes(app, deps) {
2709
2808
  afterMessageId = '',
2710
2809
  afterTimestamp = '',
2711
2810
  aroundMessageId = '',
2811
+ // Filled with the tokens of the transcript's preview cards, for the route
2812
+ // to redact wherever a reply or tool line quotes them.
2813
+ previewTokens = null,
2712
2814
  } = {}) {
2713
2815
  const resolvedConversationId = String(conv?.id || '').trim();
2714
2816
  if (!resolvedConversationId) return null;
@@ -2751,10 +2853,22 @@ export function registerSessionsRoutes(app, deps) {
2751
2853
  .filter((m) => m.role === 'assistant')
2752
2854
  .map((m) => [m.id, workflowRunsForResponse ? workflowRunsForResponse(m.id) : []]),
2753
2855
  );
2856
+ // A preview link is a capability that now outlives the turn and the
2857
+ // restart: a public transcript keeps only the label, never token or link.
2754
2858
  const previewCardsByMessageId = new Map(
2755
2859
  dbMessages
2756
2860
  .filter((m) => m.role === 'assistant')
2757
- .map((m) => [m.id, previewCardsForResponse ? previewCardsForResponse(m.id) : []]),
2861
+ .map((m) => [m.id, (previewCardsForResponse ? previewCardsForResponse(m.id) : [])
2862
+ .filter((card) => card && typeof card === 'object')
2863
+ .map((card) => {
2864
+ if (card.token) previewTokens?.add?.(String(card.token));
2865
+ return card;
2866
+ })
2867
+ .map((card) => ({
2868
+ label: String(card.label || '').trim().slice(0, 120) || 'Preview',
2869
+ mode: card.mode === 'static' ? 'static' : 'port',
2870
+ redacted: true,
2871
+ }))]),
2758
2872
  );
2759
2873
  const usageByResponseMessageId = new Map(
2760
2874
  (stmts.listMessageUsageSnapshotsByConversation?.all(conv.id) || [])
@@ -2767,7 +2881,7 @@ export function registerSessionsRoutes(app, deps) {
2767
2881
  attachments: parseAttachments(message.attachments)
2768
2882
  .map(hydrateAttachment)
2769
2883
  .filter(Boolean)
2770
- .map((attachment) => rewriteSharedGeneratedImageAttachmentContentUrl(rewriteSharedAttachmentContentUrl(attachment, shareToken), shareToken)),
2884
+ .map((attachment) => shareSafeAttachment(attachment, shareToken)),
2771
2885
  })),
2772
2886
  transcriptMessages: [],
2773
2887
  relayActivitiesByMessageId,
@@ -2789,7 +2903,7 @@ export function registerSessionsRoutes(app, deps) {
2789
2903
  ? { ...withSource, origin: publicRemoteRelayOrigin(withSource.origin) }
2790
2904
  : withSource;
2791
2905
  const attachments = Array.isArray(nextMessage?.attachments)
2792
- ? nextMessage.attachments.map((attachment) => rewriteSharedGeneratedImageAttachmentContentUrl(rewriteSharedAttachmentContentUrl(attachment, shareToken), shareToken))
2906
+ ? nextMessage.attachments.map((attachment) => shareSafeAttachment(attachment, shareToken))
2793
2907
  : nextMessage?.attachments;
2794
2908
  return attachments === nextMessage?.attachments
2795
2909
  ? nextMessage
@@ -2811,6 +2925,10 @@ export function registerSessionsRoutes(app, deps) {
2811
2925
  compactedInto: conv.compacted_into || null,
2812
2926
  compactedFrom: conv.compacted_from || null,
2813
2927
  runtimeSession: null,
2928
+ // The provider alone, null when the conversation has none bound: the
2929
+ // view tells a reply that ran elsewhere from one that ran here by it.
2930
+ // Not private, the model chip names it as well.
2931
+ runtimeProviderType: String(stmts.getRuntimeSessionByConversation?.get?.(resolvedConversationId)?.provider_type || '').trim().toLowerCase() || null,
2814
2932
  sessionRootPath: null,
2815
2933
  sessionRootName: resolvedTitle || 'Session',
2816
2934
  configuredWorkspaceRootPath: null,
@@ -2883,11 +3001,19 @@ export function registerSessionsRoutes(app, deps) {
2883
3001
  const limit = normalizeConversationListLimit(req.query.limit, DEFAULT_CONVERSATION_LIST_LIMIT);
2884
3002
  const beforeConversationId = String(req.query.beforeConversationId || '').trim();
2885
3003
  const beforeUpdatedAt = String(req.query.beforeUpdatedAt || '').trim();
3004
+ const beforeGroupId = String(req.query.beforeGroupId || '').trim();
2886
3005
  const rows = stmts.listConvs.all(archivedMode === 'true' ? 1 : 0, archivedMode === 'only' ? 1 : 0);
3006
+ // The groups of the listed rows: the order inside a group needs its head.
3007
+ const groups = conversationGroups?.listGroupsByIds?.(rows.map((row) => row?.group_id)) || new Map();
3008
+ // The time a row sorts by (its group's newest row): a caller that pages
3009
+ // by a row that is gone by then continues from this, not the row's own.
3010
+ const activityTimes = groupActivityTimes(rows);
2887
3011
  const page = selectConversationListPage(rows, {
2888
3012
  limit,
2889
3013
  beforeConversationId,
2890
3014
  beforeUpdatedAt,
3015
+ beforeGroupId,
3016
+ groups,
2891
3017
  });
2892
3018
  const activeTurnConversationIds = new Set(
2893
3019
  (stmts.listConversationIdsWithActiveQueue?.all() || [])
@@ -2947,12 +3073,30 @@ export function registerSessionsRoutes(app, deps) {
2947
3073
  cloudSourceJson: r.cloud_source_json,
2948
3074
  costUsd: r.runtime_claude_cloud_cost_usd,
2949
3075
  }),
3076
+ // Session group: its id and whether this row is the group's head.
3077
+ groupId: groups.has(String(r.group_id || '')) ? String(r.group_id) : null,
3078
+ groupRole: groups.has(String(r.group_id || '')) ? conversationGroupRole({ id: r.id, groupId: r.group_id }, groups) : null,
3079
+ // Its place in the group's hand order; null for the head, for a
3080
+ // group nobody reordered, and outside a group.
3081
+ groupPosition: groups.has(String(r.group_id || '')) ? conversationGroupPosition(r) : null,
3082
+ activityAt: (() => {
3083
+ const own = normalizeConversationTimestampMs(r.updated_at);
3084
+ const ms = groups.has(String(r.group_id || '')) ? Math.max(activityTimes.get(String(r.group_id)) || 0, own) : own;
3085
+ return ms ? new Date(ms).toISOString() : normalizeConversationTimestampIso(r.updated_at, r.updated_at);
3086
+ })(),
2950
3087
  };
2951
3088
  });
3089
+ // The groups that have a row on this page, with what the sidebar's head
3090
+ // row shows without the members' rows.
3091
+ const groupsPayload = {};
3092
+ for (const groupId of new Set(page.rows.map((row) => String(row?.group_id || '')).filter((id) => groups.has(id)))) {
3093
+ groupsPayload[groupId] = groupSummaryPayload(groups.get(groupId));
3094
+ }
2952
3095
 
2953
3096
  return res.json({
2954
3097
  conversations,
2955
3098
  pageInfo: page.pageInfo,
3099
+ groups: groupsPayload,
2956
3100
  });
2957
3101
  });
2958
3102
 
@@ -3671,123 +3815,398 @@ export function registerSessionsRoutes(app, deps) {
3671
3815
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
3672
3816
  origin: parseRemoteRelayOriginJson(conv.origin_json),
3673
3817
  cloud: conversationCloudPayload(conv, runtimeSession),
3818
+ group: conversationGroupPayload(conversationId),
3819
+ groupPosition: conversationGroupPositionOf(conv),
3674
3820
  messages: history.messages,
3675
3821
  pageInfo: history.pageInfo,
3676
3822
  // A re-import can move a pin onto a new message id.
3677
3823
  pins: listConversationPins(conversationId),
3678
3824
  pinsRevision: conversationPinRevision(conversationId),
3825
+ // And a hidden mark.
3826
+ hiddenMessages: listConversationHiddenMessages(conversationId),
3827
+ hiddenMessagesRevision: conversationHiddenMessagesRevision(conversationId),
3679
3828
  refreshed: true,
3680
3829
  });
3681
3830
  });
3682
3831
 
3832
+ function shareLinkPayload(req, share) {
3833
+ const token = String(share?.token || '').trim();
3834
+ return {
3835
+ token,
3836
+ shareUrl: buildShareUrl(req, token),
3837
+ path: `${String(remotePath || '').replace(/\/+$/, '')}/shared/${token}`.replace(/\/{2,}/g, '/'),
3838
+ };
3839
+ }
3840
+
3841
+ function findShareableConversation(conversationId) {
3842
+ const conv = conversationId ? stmts.getConvAnyStatus.get(conversationId) || null : null;
3843
+ if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') return null;
3844
+ return conv;
3845
+ }
3846
+
3847
+ // Get-or-create: a conversation has one active share, and asking again
3848
+ // returns its link.
3683
3849
  app.post('/api/conversation/:id/share', auth, (req, res) => {
3684
3850
  const conversationId = String(req.params.id || '').trim();
3685
3851
  if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
3686
- const conv = stmts.getConvAnyStatus.get(conversationId) || null;
3687
- if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') {
3852
+ if (!findShareableConversation(conversationId)) {
3688
3853
  return res.status(404).json({ error: 'Conversation not found' });
3689
3854
  }
3690
- let share = stmts.getConversationShareByConversationId?.get(conversationId) || null;
3691
- if (!share) {
3692
- const now = new Date().toISOString();
3693
- let attempts = 0;
3694
- while (!share && attempts < 6) {
3695
- const token = buildConversationShareToken();
3696
- try {
3697
- stmts.insertConversationShare.run(token, conversationId, now, now);
3698
- share = stmts.getConversationShareByToken.get(token) || null;
3699
- } catch (error) {
3700
- if (String(error?.code || '') !== 'SQLITE_CONSTRAINT_PRIMARYKEY') throw error;
3701
- }
3702
- attempts += 1;
3703
- }
3704
- }
3855
+ const { share, created } = shares().getOrCreateShare(conversationId, buildConversationShareToken, new Date().toISOString());
3705
3856
  if (!share?.token) return res.status(500).json({ error: 'Failed to create share token' });
3706
- const now = new Date().toISOString();
3707
- stmts.touchConversationShare?.run(now, share.token);
3708
- const token = String(share.token || '').trim();
3857
+ if (created) io.emit('share_created', { conversationId });
3709
3858
  res.setHeader('Cache-Control', 'no-store');
3710
- res.json({
3711
- ok: true,
3712
- conversationId,
3713
- token,
3714
- shareUrl: buildShareUrl(req, token),
3715
- path: `${String(remotePath || '').replace(/\/+$/, '')}/shared/${token}`.replace(/\/{2,}/g, '/'),
3716
- });
3859
+ res.json({ ok: true, conversationId, created, ...shareLinkPayload(req, share) });
3860
+ });
3861
+
3862
+ // A new link for the active share: the old token answers 404 from now on,
3863
+ // the access log carries over.
3864
+ app.post('/api/conversation/:id/share/renew', auth, (req, res) => {
3865
+ const conversationId = String(req.params.id || '').trim();
3866
+ if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
3867
+ if (!findShareableConversation(conversationId)) {
3868
+ return res.status(404).json({ error: 'Conversation not found' });
3869
+ }
3870
+ const renewed = shares().renewShare(conversationId, buildConversationShareToken, new Date().toISOString());
3871
+ if (!renewed?.share?.token) return res.status(404).json({ error: 'No active share to renew' });
3872
+ io.emit('share_renewed', { conversationId });
3873
+ res.setHeader('Cache-Control', 'no-store');
3874
+ res.json({ ok: true, conversationId, renewed: true, ...shareLinkPayload(req, renewed.share) });
3717
3875
  });
3718
3876
 
3719
- // Revoke every active share for a conversation. Without this a share link was
3720
- // permanent (revoked_at existed in the schema and was checked on read, but no
3721
- // route ever set it), so a leaked URL could never be invalidated.
3877
+ // Stop sharing: every token of the conversation is revoked and its access
3878
+ // log deleted. An open shared page gets a 404 at its next poll.
3722
3879
  app.delete('/api/conversation/:id/share', auth, (req, res) => {
3723
3880
  const conversationId = String(req.params.id || '').trim();
3724
3881
  if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
3725
- const share = stmts.getConversationShareByConversationId?.get(conversationId) || null;
3726
- if (!share?.token) {
3882
+ if (!shares().findActiveShare(conversationId)) {
3727
3883
  return res.status(404).json({ error: 'No active share to revoke' });
3728
3884
  }
3729
3885
  const now = new Date().toISOString();
3730
- const result = stmts.revokeConversationSharesByConversationId?.run(now, conversationId);
3731
- // Drop any live shared viewers so they lose access immediately.
3886
+ const result = shares().stopSharing(conversationId, now);
3732
3887
  io.emit('share_revoked', { conversationId });
3733
3888
  res.setHeader('Cache-Control', 'no-store');
3734
- res.json({ ok: true, conversationId, revokedAt: now, revokedCount: Number(result?.changes || 0) });
3889
+ res.json({ ok: true, conversationId, revokedAt: now, revokedCount: result.revokedCount, opensDeleted: result.opensDeleted });
3735
3890
  });
3736
3891
 
3737
- app.get('/api/shared/:token', (req, res) => {
3738
- const token = normalizeShareToken(req.params.token);
3739
- if (!token) return res.status(404).json({ error: 'Shared conversation not found' });
3740
- const share = stmts.getConversationShareByToken?.get(token) || null;
3741
- if (!share || String(share.revoked_at || '').trim()) {
3742
- return res.status(404).json({ error: 'Shared conversation not found' });
3892
+ // Every active share, for Settings → Sharing.
3893
+ app.get('/api/shares', auth, (req, res) => {
3894
+ const list = shares().listActiveShares().map((share) => ({
3895
+ conversationId: share.conversationId,
3896
+ title: share.title,
3897
+ archived: share.archived,
3898
+ sharedSince: share.sharedSince,
3899
+ // The current link's date, when it is a renewed one.
3900
+ renewedAt: share.createdAt && share.createdAt !== share.sharedSince ? share.createdAt : null,
3901
+ lastOpenedAt: share.lastOpenedAt,
3902
+ openCount: share.openCount,
3903
+ viewerCount: share.viewerCount,
3904
+ ...shareLinkPayload(req, share),
3905
+ }));
3906
+ res.setHeader('Cache-Control', 'no-store');
3907
+ res.json({ shares: list, storeViewerIp: shares().readStoreViewerIp(), accessLog: shares().logAvailable });
3908
+ });
3909
+
3910
+ // The access log of a conversation's active share, newest first.
3911
+ app.get('/api/conversation/:id/share/opens', auth, (req, res) => {
3912
+ const conversationId = String(req.params.id || '').trim();
3913
+ const share = conversationId ? shares().findActiveShare(conversationId) : null;
3914
+ if (!share) return res.status(404).json({ error: 'No active share' });
3915
+ res.setHeader('Cache-Control', 'no-store');
3916
+ res.json({ conversationId, ...shares().listOpens(share.lineageId, { limit: req.query.limit }) });
3917
+ });
3918
+
3919
+ app.get('/api/settings/shares', auth, (req, res) => {
3920
+ res.setHeader('Cache-Control', 'no-store');
3921
+ res.json({ storeViewerIp: shares().readStoreViewerIp() });
3922
+ });
3923
+
3924
+ // "Store viewer IP addresses": off keeps the address out of new access log
3925
+ // rows and out of the status events of shared reads. Rows logged before
3926
+ // keep theirs.
3927
+ app.post('/api/settings/shares', auth, (req, res) => {
3928
+ if (typeof req.body?.storeViewerIp !== 'boolean') {
3929
+ return res.status(400).json({ error: 'storeViewerIp must be true or false' });
3743
3930
  }
3744
- const convId = String(share.conversation_id || '').trim();
3745
- if (!convId) return res.status(404).json({ error: 'Shared conversation not found' });
3746
- const conv = stmts.getConvAnyStatus.get(convId) || null;
3747
- if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') {
3748
- return res.status(404).json({ error: 'Shared conversation not found' });
3931
+ const storeViewerIp = shares().writeStoreViewerIp(req.body.storeViewerIp);
3932
+ io.emit('share_settings_changed', { storeViewerIp });
3933
+ res.setHeader('Cache-Control', 'no-store');
3934
+ res.json({ storeViewerIp });
3935
+ });
3936
+
3937
+ // The viewer addresses stored before IP storage was switched off: how many,
3938
+ // and removing them (Settings → Sharing offers it when the switch goes off).
3939
+ app.get('/api/settings/shares/stored-viewer-ips', auth, (_req, res) => {
3940
+ res.setHeader('Cache-Control', 'no-store');
3941
+ res.json({ count: shares().countStoredViewerIps() });
3942
+ });
3943
+
3944
+ app.delete('/api/settings/shares/stored-viewer-ips', auth, (_req, res) => {
3945
+ const cleared = shares().forgetStoredViewerIps();
3946
+ io.emit('share_viewer_ips_forgotten', cleared);
3947
+ res.setHeader('Cache-Control', 'no-store');
3948
+ res.json({ ok: true, cleared, count: shares().countStoredViewerIps() });
3949
+ });
3950
+
3951
+ // ─── Path privacy for shared views ─────────────────────────────────────────
3952
+ // A share is served through one redactor per response
3953
+ // (services/share-path-redaction.mjs): the relay's own roots, the
3954
+ // conversation's workspace roots and the OS user name are what it knows to
3955
+ // look for. The owner's own routes never pass through it.
3956
+ function sharePathRedactorFor(conv, { annotate = false } = {}) {
3957
+ let known = null;
3958
+ try { known = sharePathKnownRoots?.() || null; } catch { known = null; }
3959
+ const roots = [...(Array.isArray(known?.roots) ? known.roots : [])];
3960
+ const conversationId = String(conv?.id || '').trim();
3961
+ try {
3962
+ const state = typeof resolveConversationWorkspaceState === 'function'
3963
+ ? resolveConversationWorkspaceState({
3964
+ conversationId,
3965
+ sdkSessionId: conv?.sdk_session_id || conversationId,
3966
+ discoveredWorkspaceRootPath: '',
3967
+ })
3968
+ : null;
3969
+ roots.push(state?.configuredWorkspaceRootPath, state?.runtimeWorkspaceRootPath, state?.currentWorkspaceRootPath);
3970
+ } catch {}
3971
+ roots.push(...(Array.isArray(workspaceRootAllowList) ? workspaceRootAllowList : []));
3972
+ let mode = DEFAULT_SHARE_PATH_PRIVACY_MODE;
3973
+ try { mode = getSharePathPrivacyMode(); } catch {}
3974
+ return createSharePathRedactor({
3975
+ mode,
3976
+ roots: roots.filter(Boolean),
3977
+ userNames: Array.isArray(known?.userNames) ? known.userNames : [],
3978
+ relayPathPrefix: remotePath,
3979
+ annotate,
3980
+ });
3981
+ }
3982
+
3983
+ // Every preview token a shared response must not carry: the relay's known
3984
+ // previews and the conversation's own. Null when the list cannot be read;
3985
+ // then nothing is shared.
3986
+ function collectSharePreviewTokens(conversationId) {
3987
+ const tokens = new Set();
3988
+ // Every preview of the relay, not only this conversation's: the preview
3989
+ // tool's list covers them all, and a reply may quote any of its tokens.
3990
+ try {
3991
+ for (const knownToken of listKnownPreviewTokens?.() || []) tokens.add(String(knownToken));
3992
+ } catch {
3993
+ return null;
3994
+ }
3995
+ for (const preview of listConversationPreviews?.(conversationId) || []) {
3996
+ if (preview?.token) tokens.add(String(preview.token));
3997
+ }
3998
+ return tokens;
3999
+ }
4000
+
4001
+ function sharedHistoryPageQuery(query = {}) {
4002
+ return {
4003
+ limit: normalizeConversationHistoryLimit(query.limit, 120),
4004
+ beforeMessageId: String(query.beforeMessageId || '').trim(),
4005
+ beforeTimestamp: String(query.beforeTimestamp || '').trim(),
4006
+ afterMessageId: String(query.afterMessageId || '').trim(),
4007
+ afterTimestamp: String(query.afterTimestamp || '').trim(),
4008
+ aroundMessageId: String(query.aroundMessageId || '').trim(),
4009
+ };
4010
+ }
4011
+
4012
+ // A revoked or renewed token is a plain 404, like one that never existed.
4013
+ function resolveActiveShare(rawToken) {
4014
+ const token = normalizeShareToken(rawToken);
4015
+ if (!token) return null;
4016
+ const share = shares().findLiveShare(token);
4017
+ const conversationId = String(share?.conversationId || '').trim();
4018
+ if (!conversationId) return null;
4019
+ return { token, share, conversationId };
4020
+ }
4021
+
4022
+ function resolveShareableConversation(conversationId) {
4023
+ const conv = stmts.getConvAnyStatus.get(conversationId) || null;
4024
+ if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') return null;
4025
+ return conv;
4026
+ }
4027
+
4028
+ app.get('/api/shared/:token', (req, res) => {
4029
+ const active = resolveActiveShare(req.params.token);
4030
+ if (!active) return res.status(404).json({ error: 'Shared conversation not found' });
4031
+ const { token, share, conversationId: convId } = active;
4032
+ const conv = resolveShareableConversation(convId);
4033
+ if (!conv) return res.status(404).json({ error: 'Shared conversation not found' });
4034
+ const previewTokens = collectSharePreviewTokens(convId);
4035
+ if (!previewTokens) {
4036
+ res.setHeader('Retry-After', '15');
4037
+ return res.status(503).json({ error: 'Shared conversation is not available right now' });
3749
4038
  }
3750
- const limit = normalizeConversationHistoryLimit(req.query.limit, 120);
3751
- const beforeMessageId = String(req.query.beforeMessageId || '').trim();
3752
- const beforeTimestamp = String(req.query.beforeTimestamp || '').trim();
3753
- const afterMessageId = String(req.query.afterMessageId || '').trim();
3754
- const afterTimestamp = String(req.query.afterTimestamp || '').trim();
3755
- const aroundMessageId = String(req.query.aroundMessageId || '').trim();
3756
4039
  const payload = buildConversationPayloadForShare({
3757
4040
  conv,
3758
4041
  shareToken: token,
3759
- limit,
3760
- beforeMessageId,
3761
- beforeTimestamp,
3762
- afterMessageId,
3763
- afterTimestamp,
3764
- aroundMessageId,
4042
+ ...sharedHistoryPageQuery(req.query),
4043
+ previewTokens,
3765
4044
  });
3766
4045
  if (!payload) return res.status(404).json({ error: 'Shared conversation not found' });
4046
+ // The access log: one row per open, not per poll (the page's first fetch
4047
+ // says `open=1`; see share-access-log-service.mjs). A failing log never
4048
+ // keeps a reader out.
4049
+ const viewer = readShareRequestViewer(req);
4050
+ const storeIp = shares().readStoreViewerIp();
4051
+ let viewerKey = '';
4052
+ try {
4053
+ const noted = shareAccessLog().noteFetch({ share, viewer, opened: String(req.query.open || '') === '1', storeIp });
4054
+ viewerKey = noted.viewerKey;
4055
+ if (noted.recorded) io.emit('share_opened', { conversationId: convId });
4056
+ } catch (error) {
4057
+ console.warn(`[shares] could not log a shared read: ${error?.message || error}`);
4058
+ }
3767
4059
  const sharedAccess = statusEventService.recordSharedAccess({
3768
4060
  shareToken: token,
3769
- viewerIp: extractClientIp(req),
4061
+ viewerIp: storeIp ? viewer.ip : null,
4062
+ viewerKey,
3770
4063
  conversationId: convId,
3771
4064
  });
3772
4065
  if (sharedAccess.event) {
3773
4066
  const details = sharedAccess.event.details;
3774
- console.log(`SHARED ACCESS shareId=${details.shareId} ip=${details.viewerIp || 'unknown'}`);
4067
+ console.log(`SHARED ACCESS shareId=${details.shareId} ip=${storeIp ? details.viewerIp || 'unknown' : 'not stored'}`);
3775
4068
  io.emit('shared_access', sharedAccess.event);
3776
4069
  }
3777
4070
  const now = new Date().toISOString();
3778
4071
  stmts.touchConversationShare?.run(now, token);
3779
4072
  res.setHeader('Cache-Control', 'no-store');
3780
4073
  res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
3781
- res.json({
4074
+ // A preview link is a public capability that outlives the turn: no string
4075
+ // of a shared transcript (reply text, thoughts, tool lines, sub-agent runs)
4076
+ // carries one. One pass over the whole response, not a list of fields;
4077
+ // then the host's paths, by the relay's path privacy setting.
4078
+ const redactor = sharePathRedactorFor(conv);
4079
+ res.json(redactor.payload(redactPreviewTokens({
3782
4080
  ...payload,
3783
4081
  shared: {
3784
4082
  token,
3785
4083
  readOnly: true,
3786
4084
  watcherCount: Number(getSharedWatcherCount?.(convId) || 0),
3787
4085
  },
4086
+ }, { tokens: [...previewTokens] })));
4087
+ });
4088
+
4089
+ // The owner's "Preview as shared": the payload a share link would serve, with
4090
+ // every redaction and every leftover that still looks like a path marked
4091
+ // (SHARE_PREVIEW_MARKS). Works without a share link and never creates one;
4092
+ // records no access. Content URLs stay the owner's.
4093
+ app.get('/api/conversation/:id/share-preview', auth, (req, res) => {
4094
+ const conversationId = String(req.params.id || '').trim();
4095
+ const conv = conversationId ? resolveShareableConversation(conversationId) : null;
4096
+ if (!conv) return res.status(404).json({ error: 'Conversation not found' });
4097
+ const previewTokens = collectSharePreviewTokens(conversationId);
4098
+ if (!previewTokens) {
4099
+ res.setHeader('Retry-After', '15');
4100
+ return res.status(503).json({ error: 'The preview is not available right now' });
4101
+ }
4102
+ const payload = buildConversationPayloadForShare({
4103
+ conv,
4104
+ shareToken: '',
4105
+ ...sharedHistoryPageQuery(req.query),
4106
+ previewTokens,
4107
+ });
4108
+ if (!payload) return res.status(404).json({ error: 'Conversation not found' });
4109
+ const redactor = sharePathRedactorFor(conv, { annotate: true });
4110
+ res.setHeader('Cache-Control', 'no-store');
4111
+ res.json({
4112
+ ...redactor.payload(redactPreviewTokens(payload, { tokens: [...previewTokens] })),
4113
+ shared: {
4114
+ token: null,
4115
+ readOnly: true,
4116
+ preview: true,
4117
+ pathPrivacy: redactor.mode,
4118
+ watcherCount: 0,
4119
+ },
3788
4120
  });
3789
4121
  });
3790
4122
 
4123
+ // ─── Sub-agent records in a shared view ────────────────────────────────────
4124
+ const sharedSubagentTimeline = createSubagentTimelineService({ db, stmts });
4125
+
4126
+ // The runs a shared view shows a row for: those of the replies it shows,
4127
+ // and those of the turn in flight.
4128
+ function sharedConversationRunIds(conversationId) {
4129
+ const ids = new Set();
4130
+ const add = (run) => {
4131
+ const id = String(run?.subagentRunId || run?.id || '').trim();
4132
+ if (id) ids.add(id);
4133
+ };
4134
+ const rows = filterMessagesVisibleToSharedView(
4135
+ (stmts.getSharedMessages || stmts.getMessages).all(conversationId),
4136
+ );
4137
+ for (const row of rows) {
4138
+ if (row?.role !== 'assistant') continue;
4139
+ for (const run of (subagentRunsForResponse ? subagentRunsForResponse(row.id) : []) || []) add(run);
4140
+ }
4141
+ for (const run of inFlightStateForConversation(conversationId)?.subagentRuns || []) add(run);
4142
+ return ids;
4143
+ }
4144
+
4145
+ // A run of the conversation whose row the shared view shows, or a child of
4146
+ // one: a share never opens a run of another conversation or of a reply the
4147
+ // owner hid from shares.
4148
+ function shareMayReadSubagentRun(conversationId, run) {
4149
+ if (!run || String(run.conversation_id || '') !== conversationId) return false;
4150
+ const visible = sharedConversationRunIds(conversationId);
4151
+ let current = run;
4152
+ for (let depth = 0; current && depth < 16; depth += 1) {
4153
+ if (visible.has(String(current.id || ''))) return true;
4154
+ const parentId = String(current.parent_subagent_id || '').trim();
4155
+ if (!parentId) return false;
4156
+ current = stmts.getSubagentRun?.get?.(parentId) || null;
4157
+ if (current && String(current.conversation_id || '') !== conversationId) return false;
4158
+ }
4159
+ return false;
4160
+ }
4161
+
4162
+ // A reader cannot stop a run: no stop flag, no task handle.
4163
+ function readerRunHeader(header) {
4164
+ if (!header || typeof header !== 'object') return header;
4165
+ const { taskId: _taskId, ...rest } = header;
4166
+ return { ...rest, stoppable: false };
4167
+ }
4168
+
4169
+ function sharedSubagentRecord(req, res, { conv, conversationId, annotate }) {
4170
+ const runId = sanitizeSubagentRunId(req.params.subagentRunId);
4171
+ const run = runId ? stmts.getSubagentRun?.get?.(runId) || null : null;
4172
+ if (!run || !shareMayReadSubagentRun(conversationId, run)) {
4173
+ return res.status(404).json({ error: 'Sub-agent run not found' });
4174
+ }
4175
+ const previewTokens = collectSharePreviewTokens(conversationId);
4176
+ if (!previewTokens) {
4177
+ res.setHeader('Retry-After', '15');
4178
+ return res.status(503).json({ error: 'Shared conversation is not available right now' });
4179
+ }
4180
+ const page = sharedSubagentTimeline.readTimeline(run, {
4181
+ afterSeq: Number(req.query?.after) || 0,
4182
+ limit: Number(req.query?.limit) || TIMELINE_READ_DEFAULT_LIMIT,
4183
+ isStoppable: () => false,
4184
+ });
4185
+ const readerPage = {
4186
+ ...page,
4187
+ run: readerRunHeader(page.run),
4188
+ children: (Array.isArray(page.children) ? page.children : []).map(readerRunHeader),
4189
+ };
4190
+ const redactor = sharePathRedactorFor(conv, { annotate });
4191
+ res.setHeader('Cache-Control', 'no-store');
4192
+ res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
4193
+ return res.json(redactor.payload(redactPreviewTokens(readerPage, { tokens: [...previewTokens] })));
4194
+ }
4195
+
4196
+ app.get('/api/shared/:token/subagent/:subagentRunId', (req, res) => {
4197
+ const active = resolveActiveShare(req.params.token);
4198
+ const conv = active ? resolveShareableConversation(active.conversationId) : null;
4199
+ if (!conv) return res.status(404).json({ error: 'Sub-agent run not found' });
4200
+ return sharedSubagentRecord(req, res, { conv, conversationId: active.conversationId, annotate: false });
4201
+ });
4202
+
4203
+ app.get('/api/conversation/:id/share-preview/subagent/:subagentRunId', auth, (req, res) => {
4204
+ const conversationId = String(req.params.id || '').trim();
4205
+ const conv = conversationId ? resolveShareableConversation(conversationId) : null;
4206
+ if (!conv) return res.status(404).json({ error: 'Sub-agent run not found' });
4207
+ return sharedSubagentRecord(req, res, { conv, conversationId, annotate: true });
4208
+ });
4209
+
3791
4210
  app.get('/api/status/events', auth, (req, res) => {
3792
4211
  const beforeTimestamp = Number(req.query.beforeTimestamp);
3793
4212
  const page = statusEventService.getEventsPage({
@@ -3802,12 +4221,9 @@ export function registerSessionsRoutes(app, deps) {
3802
4221
  app.post('/api/shared/:token/presence', (req, res) => {
3803
4222
  const token = normalizeShareToken(req.params.token);
3804
4223
  if (!token) return res.status(404).json({ error: 'Shared conversation not found' });
3805
- const share = stmts.getConversationShareByToken?.get(token) || null;
3806
- if (!share || String(share.revoked_at || '').trim()) {
3807
- return res.status(404).json({ error: 'Shared conversation not found' });
3808
- }
3809
- const conversationId = String(share.conversation_id || '').trim();
3810
- if (!conversationId) return res.status(404).json({ error: 'Shared conversation not found' });
4224
+ const share = shares().findLiveShare(token);
4225
+ if (!share) return res.status(404).json({ error: 'Shared conversation not found' });
4226
+ const conversationId = share.conversationId;
3811
4227
  const rateLimit = consumeSharedPresenceRateLimit(token, req);
3812
4228
  if (!rateLimit.ok) {
3813
4229
  res.setHeader('Cache-Control', 'no-store');
@@ -3835,17 +4251,24 @@ export function registerSessionsRoutes(app, deps) {
3835
4251
  });
3836
4252
  });
3837
4253
 
4254
+ // The download name of a shared file: with names hidden, only its type.
4255
+ function sharedDownloadName(fileName) {
4256
+ let mode = DEFAULT_SHARE_PATH_PRIVACY_MODE;
4257
+ try { mode = getSharePathPrivacyMode(); } catch {}
4258
+ if (mode !== 'hide' || !fileName) return fileName;
4259
+ const extension = path.extname(fileName).replace(/[^.A-Za-z0-9]/g, '').slice(0, 12);
4260
+ return `attachment${extension}`;
4261
+ }
4262
+
3838
4263
  app.get('/api/shared/:token/upload/:sha256/content', (req, res) => {
3839
4264
  const token = normalizeShareToken(req.params.token);
3840
4265
  const sha256 = String(req.params.sha256 || '').trim().toLowerCase();
3841
4266
  if (!token || !isSha256(sha256)) {
3842
4267
  return res.status(404).json({ error: 'Shared attachment not found' });
3843
4268
  }
3844
- const share = stmts.getConversationShareByToken?.get(token) || null;
3845
- if (!share || String(share.revoked_at || '').trim()) {
3846
- return res.status(404).json({ error: 'Shared attachment not found' });
3847
- }
3848
- const conversationId = String(share.conversation_id || '').trim();
4269
+ const share = shares().findLiveShare(token);
4270
+ if (!share) return res.status(404).json({ error: 'Shared attachment not found' });
4271
+ const conversationId = share.conversationId;
3849
4272
  if (!conversationId || !conversationReferencesUploadSha(conversationId, sha256)) {
3850
4273
  return res.status(404).json({ error: 'Shared attachment not found' });
3851
4274
  }
@@ -3858,7 +4281,7 @@ export function registerSessionsRoutes(app, deps) {
3858
4281
  // as script on this origin; ranges let a shared video seek.
3859
4282
  res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
3860
4283
  serveFileWithRangeSupport(req, res, filePath, { contentType: file.mime_type }, {
3861
- safeName: String(file.name || file.file_name || '').replace(/"/g, ''),
4284
+ safeName: sharedDownloadName(String(file.name || file.file_name || '').replace(/"/g, '')),
3862
4285
  cacheControl: SHARED_CONTENT_CACHE_CONTROL,
3863
4286
  });
3864
4287
  });
@@ -3870,11 +4293,9 @@ export function registerSessionsRoutes(app, deps) {
3870
4293
  if (!token || !messageId || !imageId) {
3871
4294
  return res.status(404).json({ error: 'Shared attachment not found' });
3872
4295
  }
3873
- const share = stmts.getConversationShareByToken?.get(token) || null;
3874
- if (!share || String(share.revoked_at || '').trim()) {
3875
- return res.status(404).json({ error: 'Shared attachment not found' });
3876
- }
3877
- const conversationId = String(share.conversation_id || '').trim();
4296
+ const share = shares().findLiveShare(token);
4297
+ if (!share) return res.status(404).json({ error: 'Shared attachment not found' });
4298
+ const conversationId = share.conversationId;
3878
4299
  if (!conversationId) return res.status(404).json({ error: 'Shared attachment not found' });
3879
4300
  const rows = filterMessagesVisibleToSharedView(
3880
4301
  (stmts.getSharedMessages || stmts.getMessages).all(conversationId),
@@ -4060,12 +4481,19 @@ export function registerSessionsRoutes(app, deps) {
4060
4481
  // "cancelled" with it.
4061
4482
  activeTurn: Number(stmts.getConversationActiveQueueCount?.get?.(resolvedConversationId)?.count || 0) > 0,
4062
4483
  ...(describeSessionActivity(resolvedConversationId) || {}),
4063
- backgroundTasks: backgroundTaskStore?.get?.(resolvedConversationId) || [],
4484
+ // The published set, else the placeholder for background work a live
4485
+ // worker reports without this runtime knowing its details.
4486
+ backgroundTasks: backgroundWorkView
4487
+ ? backgroundWorkView.panelTasks(resolvedConversationId, runtimeSession?.sdk_session_id)
4488
+ : (backgroundTaskStore?.get?.(resolvedConversationId) || []),
4064
4489
  usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
4065
4490
  previews: listConversationPreviews?.(resolvedConversationId) || [],
4066
4491
  // Every pin of the conversation, also those outside this history page.
4067
4492
  pins: listConversationPins(resolvedConversationId),
4068
4493
  pinsRevision: conversationPinRevision(resolvedConversationId),
4494
+ // Every message hidden from shared viewers, also those outside the page.
4495
+ hiddenMessages: listConversationHiddenMessages(resolvedConversationId),
4496
+ hiddenMessagesRevision: conversationHiddenMessagesRevision(resolvedConversationId),
4069
4497
  preferredRelayMode: preferences.preferredRelayMode,
4070
4498
  preferredModel: preferences.preferredModel,
4071
4499
  preferredReasoningEffort: preferences.preferredReasoningEffort,
@@ -4077,6 +4505,10 @@ export function registerSessionsRoutes(app, deps) {
4077
4505
  origin: parseRemoteRelayOriginJson(conv.origin_json),
4078
4506
  // Set on Claude Cloud conversations: repository, branch, session link.
4079
4507
  cloud: conversationCloudPayload(conv, runtimeSession),
4508
+ // The session group it is in, with the other members; null when none.
4509
+ group: conversationGroupPayload(resolvedConversationId, req),
4510
+ // Its place in the group's hand order, as on the list rows.
4511
+ groupPosition: conversationGroupPositionOf(conv),
4080
4512
  messages: history.messages,
4081
4513
  pageInfo: history.pageInfo,
4082
4514
  });
@@ -4104,6 +4536,7 @@ export function registerSessionsRoutes(app, deps) {
4104
4536
 
4105
4537
  const hiddenFromShares = req.body.hiddenFromShares === true;
4106
4538
  const currentlyHidden = Number(message.hidden_from_shares || 0) === 1;
4539
+ let change = null;
4107
4540
  if (currentlyHidden !== hiddenFromShares) {
4108
4541
  if (!stmts.setMessageShareVisibility) {
4109
4542
  return res.status(503).json({ error: 'Message share visibility is unavailable until the relay restarts' });
@@ -4115,6 +4548,7 @@ export function registerSessionsRoutes(app, deps) {
4115
4548
  messageId,
4116
4549
  conversationId,
4117
4550
  );
4551
+ change = broadcastShareVisibilityChange(conversationId, { pinnedChanged: !!message.pinned_at });
4118
4552
  }
4119
4553
 
4120
4554
  return res.json({
@@ -4122,6 +4556,52 @@ export function registerSessionsRoutes(app, deps) {
4122
4556
  conversationId,
4123
4557
  messageId,
4124
4558
  hiddenFromShares,
4559
+ hiddenMessages: change?.hiddenMessages || listConversationHiddenMessages(conversationId),
4560
+ hiddenMessagesRevision: change?.revision || conversationHiddenMessagesRevision(conversationId),
4561
+ });
4562
+ });
4563
+
4564
+ // Shows every hidden message to shared viewers again. A message whose turn
4565
+ // is still running stays hidden (the single route refuses it the same way)
4566
+ // and is named in `skipped`; the rest are unhidden.
4567
+ app.post('/api/conversation/:id/share-visibility/unhide-all', auth, (req, res) => {
4568
+ const conversationId = String(req.params.id || '').trim();
4569
+ if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
4570
+ const conv = stmts.getConvAnyStatus.get(conversationId);
4571
+ if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') {
4572
+ return res.status(404).json({ error: 'Conversation not found' });
4573
+ }
4574
+ const hiddenRows = stmts.listHiddenMessages?.all?.(conversationId) || [];
4575
+ if (hiddenRows.length && !stmts.setMessageShareVisibility) {
4576
+ return res.status(503).json({ error: 'Message share visibility is unavailable until the relay restarts' });
4577
+ }
4578
+ const unhidden = [];
4579
+ const skipped = [];
4580
+ const unhideRows = db.transaction((rows) => {
4581
+ for (const row of rows) {
4582
+ const messageId = String(row?.id || '').trim();
4583
+ if (!messageId) continue;
4584
+ if (getActiveQueueForMessage.get(conversationId, messageId, messageId)) {
4585
+ skipped.push({ messageId, reason: 'active-turn' });
4586
+ continue;
4587
+ }
4588
+ stmts.setMessageShareVisibility.run(0, null, messageId, conversationId);
4589
+ unhidden.push(messageId);
4590
+ }
4591
+ });
4592
+ unhideRows(hiddenRows);
4593
+ let change = null;
4594
+ if (unhidden.length) {
4595
+ const pinnedChanged = unhidden.some((messageId) => !!stmts.getMessageByConversation?.get(messageId, conversationId)?.pinned_at);
4596
+ change = broadcastShareVisibilityChange(conversationId, { pinnedChanged });
4597
+ }
4598
+ return res.json({
4599
+ ok: true,
4600
+ conversationId,
4601
+ unhidden,
4602
+ skipped,
4603
+ hiddenMessages: change?.hiddenMessages || listConversationHiddenMessages(conversationId),
4604
+ hiddenMessagesRevision: change?.revision || conversationHiddenMessagesRevision(conversationId),
4125
4605
  });
4126
4606
  });
4127
4607
 
@@ -4828,7 +5308,9 @@ export function registerSessionsRoutes(app, deps) {
4828
5308
  // Re-armed after the stop, which can outlast the kill block's grace
4829
5309
  // window: nothing may respawn a worker for the rows deleted below.
4830
5310
  sessionWorkerSupervisor?.markKilled?.(workerSessionId);
4831
- for (const key of new Set([id, workerSessionId])) backgroundTaskStore?.replace?.(key, []);
5311
+ try { sessionActivityTracker?.forget?.(id); } catch {}
5312
+ if (typeof backgroundTaskStore?.forget === 'function') backgroundTaskStore.forget(id, workerSessionId);
5313
+ else for (const key of new Set([id, workerSessionId])) backgroundTaskStore?.replace?.(key, []);
4832
5314
  }
4833
5315
 
4834
5316
  // A Claude Cloud conversation takes its cloud session along: archived
@@ -4843,6 +5325,13 @@ export function registerSessionsRoutes(app, deps) {
4843
5325
 
4844
5326
  const deletedAt = new Date().toISOString();
4845
5327
  markConversationDeleted.run(deletedAt, id);
5328
+ // A deleted conversation takes its public preview links with it (archive
5329
+ // keeps them: an archived conversation can come back).
5330
+ try {
5331
+ closeConversationPreviews?.(id);
5332
+ } catch (error) {
5333
+ console.warn(`[preview] closing the previews of a deleted conversation failed: ${error?.message || error}`);
5334
+ }
4846
5335
  // Tombstones keep the SDK session importer from bringing it back.
4847
5336
  stmts.markDeletedSdkSession.run(sdkSessionId || id, deletedAt);
4848
5337
  if (sdkSessionId && sdkSessionId !== id) stmts.markDeletedSdkSession.run(id, deletedAt);
@@ -4865,11 +5354,17 @@ export function registerSessionsRoutes(app, deps) {
4865
5354
  resolveSessionStateRoot,
4866
5355
  });
4867
5356
  const orphanedUploads = collectOrphanedUploadsFromConversation(id);
5357
+ // Before the row goes: a head leaves its group with its title frozen as
5358
+ // the name, and a group left empty is removed.
5359
+ const leftGroupId = conversationGroups?.forgetConversation?.(id, deletedAt) || null;
5360
+ const wasShared = !!shares().findActiveShare(id);
4868
5361
  hardDeleteConversationRows(id);
4869
5362
  remoteRelayInbound?.repository?.forgetConversation?.(id);
4870
5363
  deleteOrphanedUploads(orphanedUploads);
4871
5364
  if (sdkSessionId && !cliSession.queued) stmts.deleteSdkDeleteRequest?.run?.(sdkSessionId);
4872
5365
  io.emit('conversation_deleted', { conversationId: id });
5366
+ if (wasShared) io.emit('share_revoked', { conversationId: id });
5367
+ if (leftGroupId) emitGroupChanged(leftGroupId, [id]);
4873
5368
  try { relayEventService?.noteConversationGone?.(id, { deleted: true }); } catch {}
4874
5369
  return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
4875
5370
  }
@@ -4884,14 +5379,24 @@ export function registerSessionsRoutes(app, deps) {
4884
5379
  const id = String(req.params.id || '').trim();
4885
5380
  if (!id) return res.status(400).json({ error: 'Missing conversation id' });
4886
5381
  try {
5382
+ const outcome = await archiveConversationNow(id);
5383
+ return res.status(outcome.statusCode).json(outcome.body);
5384
+ } catch (error) {
5385
+ console.warn(`[archive] Archive failed for ${id}: ${error?.message || error}`);
5386
+ return res.status(500).json({ error: 'Failed to archive conversation' });
5387
+ }
5388
+ });
5389
+
5390
+ /** POST /api/conversation/:id/archive under its session's lock; see the route. */
5391
+ async function archiveConversationNow(id) {
4887
5392
  const existing = stmts.getConvAnyStatus.get(id);
4888
5393
  if (!existing || String(existing.status || '').trim() === 'deleted') {
4889
- return res.json({ ok: true, alreadyDeleted: true });
5394
+ return { statusCode: 200, body: { ok: true, alreadyDeleted: true } };
4890
5395
  }
4891
- if (Number(existing.archived || 0) === 1) return res.json({ ok: true, alreadyArchived: true });
5396
+ if (Number(existing.archived || 0) === 1) return { statusCode: 200, body: { ok: true, alreadyArchived: true } };
4892
5397
  const sdkSessionId = String(existing.sdk_session_id || '').trim() || null;
4893
5398
  const workerSessionId = sdkSessionId || id;
4894
- const outcome = await sessionLaunchMutex.runExclusive(workerSessionId, async () => {
5399
+ return sessionLaunchMutex.runExclusive(workerSessionId, async () => {
4895
5400
  const activity = describeConversationActivity(id, workerSessionId);
4896
5401
  if (activity.turnRunning) {
4897
5402
  return {
@@ -4965,11 +5470,41 @@ export function registerSessionsRoutes(app, deps) {
4965
5470
  }
4966
5471
  return { statusCode: 200, body: { ok: true, workerStopped } };
4967
5472
  });
4968
- return res.status(outcome.statusCode).json(outcome.body);
5473
+ }
5474
+
5475
+ // POST /api/conversation-groups/:id/archive — archive every live member of
5476
+ // a session group that can be archived now. A member with a running turn
5477
+ // or background work is skipped and named in the answer, so the owner can
5478
+ // stop it ("Stop all running") and archive again.
5479
+ app.post('/api/conversation-groups/:id/archive', auth, async (req, res) => {
5480
+ if (readRemoteRelayRequest(req, remoteRelayInbound).remote) {
5481
+ return res.status(403).json({ error: 'Session groups are managed by this relay\'s owner only.' });
5482
+ }
5483
+ const groupId = String(req.params.id || '').trim();
5484
+ const group = groupId ? conversationGroups?.getGroup?.(groupId) : null;
5485
+ if (!group) return res.status(404).json({ ok: false, error: 'Group not found' });
5486
+ const archived = [];
5487
+ const skipped = [];
5488
+ try {
5489
+ for (const member of conversationGroups.membersOf(groupId)) {
5490
+ if (member.archived) continue;
5491
+ const outcome = await archiveConversationNow(member.id);
5492
+ if (outcome.statusCode === 200) {
5493
+ archived.push(member.id);
5494
+ } else {
5495
+ skipped.push({
5496
+ id: member.id,
5497
+ title: resolveConversationTitle({ title: member.title, titleSource: 'auto' }),
5498
+ reason: String(outcome.body?.reason || outcome.body?.error || 'refused'),
5499
+ });
5500
+ }
5501
+ }
4969
5502
  } catch (error) {
4970
- console.warn(`[archive] Archive failed for ${id}: ${error?.message || error}`);
4971
- return res.status(500).json({ error: 'Failed to archive conversation' });
5503
+ console.warn(`[archive] Group archive failed for ${groupId}: ${error?.message || error}`);
5504
+ return res.status(500).json({ ok: false, error: 'Failed to archive the group', archived, skipped });
4972
5505
  }
5506
+ if (archived.length) emitGroupChanged(groupId, archived);
5507
+ return res.json({ ok: true, archived, skipped });
4973
5508
  });
4974
5509
 
4975
5510
  // POST /api/conversation/:id/unarchive — back to the live list. A Claude
@@ -5355,9 +5890,16 @@ export function registerSessionsRoutes(app, deps) {
5355
5890
  // One transaction so a conversation can never exist without its runtime
5356
5891
  // binding and preferences: every later reader treats these three as a
5357
5892
  // single canonical tuple.
5893
+ let attachedGroupId = null;
5358
5894
  const commitBootstrapState = db.transaction(() => {
5359
5895
  stmts.insertConv.run(conversationId, title, now, now);
5360
5896
  if (remoteRequest.origin) remoteRelayInbound?.repository?.setConversationOrigin?.(conversationId, remoteRequest.origin);
5897
+ // A session an agent of this relay created joins its creator's group
5898
+ // (or starts one with the creator as head). A creator on a paired
5899
+ // relay has no local flag and groups nothing.
5900
+ if (remoteRequest.origin?.local === true && remoteRequest.origin.conversationId) {
5901
+ attachedGroupId = conversationGroups?.attachCreatedConversation?.(conversationId, remoteRequest.origin.conversationId, now) || null;
5902
+ }
5361
5903
  const boundRuntimeSession = ensureRuntimeSessionBinding(
5362
5904
  conversationId,
5363
5905
  selectedModel,
@@ -5386,6 +5928,7 @@ export function registerSessionsRoutes(app, deps) {
5386
5928
  return { boundRuntimeSession, preferences };
5387
5929
  });
5388
5930
  const { boundRuntimeSession, preferences } = commitBootstrapState();
5931
+ if (attachedGroupId) emitGroupChanged(attachedGroupId, [conversationId, remoteRequest.origin.conversationId]);
5389
5932
  const runtimeSession = boundRuntimeSession;
5390
5933
  const preferredRelayMode = preferences?.preferredRelayMode || bootstrapRelayMode;
5391
5934
  const preferredModel = preferences?.preferredModel || selectedModel;
@@ -6489,6 +7032,25 @@ export function registerSessionsRoutes(app, deps) {
6489
7032
  return res.json(subagentRecordRetentionPayload(result.retentionDays));
6490
7033
  });
6491
7034
 
7035
+ // What a shared conversation shows of the host's paths and file names.
7036
+ const sharePathPrivacyPayload = (mode) => ({
7037
+ mode,
7038
+ choices: SHARE_PATH_PRIVACY_MODES.map((value) => ({ value, label: SHARE_PATH_PRIVACY_LABELS[value] })),
7039
+ defaultMode: DEFAULT_SHARE_PATH_PRIVACY_MODE,
7040
+ });
7041
+
7042
+ app.get('/api/settings/share-path-privacy', auth, (_req, res) => {
7043
+ res.json(sharePathPrivacyPayload(getSharePathPrivacyMode()));
7044
+ });
7045
+
7046
+ app.post('/api/settings/share-path-privacy', auth, (req, res) => {
7047
+ const parsed = parseSharePathPrivacyUpdate(req.body?.mode);
7048
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
7049
+ const result = setSharePathPrivacyMode(parsed.mode);
7050
+ if (!result.ok) return res.status(500).json({ error: result.error });
7051
+ return res.json(sharePathPrivacyPayload(result.mode));
7052
+ });
7053
+
6492
7054
  app.get('/api/settings/background-task-timeout', auth, (_req, res) => {
6493
7055
  res.json({
6494
7056
  timeoutMinutes: getBackgroundTaskTimeoutMinutes(),
@@ -6566,9 +7128,18 @@ export function registerSessionsRoutes(app, deps) {
6566
7128
  */
6567
7129
  async function windowsAutostartMergedState() {
6568
7130
  const signin = windowsAutostartService.getState();
6569
- const boot = windowsBootAutostartService
6570
- ? await windowsBootAutostartService.getState()
6571
- : { supported: false };
7131
+ // A boot task that cannot be read is reported (and logged), not allowed
7132
+ // to fail the whole setting; the sign-in half above still does, since
7133
+ // "off" would be a guess there.
7134
+ let boot = { supported: false };
7135
+ if (windowsBootAutostartService) {
7136
+ try {
7137
+ boot = await windowsBootAutostartService.getState();
7138
+ } catch (error) {
7139
+ console.warn(`[autostart] could not read the boot task: ${error?.message || error}`);
7140
+ boot = { supported: true, taskStatus: 'unknown', taskName: null, legacyTaskPresent: false, readError: 'The boot task could not be read; the relay log has the details.' };
7141
+ }
7142
+ }
6572
7143
  const bootReady = boot.supported && boot.taskStatus === 'ready';
6573
7144
  const mode = bootReady ? 'boot' : (signin.enabled ? 'signin' : 'off');
6574
7145
  return {
@@ -6582,9 +7153,13 @@ export function registerSessionsRoutes(app, deps) {
6582
7153
  taskStatus: boot.taskStatus,
6583
7154
  taskName: boot.taskName,
6584
7155
  legacyTaskPresent: boot.legacyTaskPresent,
6585
- pendingElevation: boot.pendingElevation,
6586
- lastError: boot.lastError,
6587
- manualCommand: boot.manualCommand,
7156
+ customised: boot.customised === true,
7157
+ customisations: Array.isArray(boot.customisations) ? boot.customisations : [],
7158
+ launcherBackupPath: boot.launcherBackupPath || null,
7159
+ readError: boot.readError || null,
7160
+ pendingElevation: boot.pendingElevation || null,
7161
+ lastError: boot.lastError || null,
7162
+ manualCommand: boot.manualCommand || null,
6588
7163
  } : null,
6589
7164
  signinEnabled: signin.enabled,
6590
7165
  };
@@ -6596,7 +7171,9 @@ export function registerSessionsRoutes(app, deps) {
6596
7171
  }
6597
7172
  try {
6598
7173
  return res.json(await windowsAutostartMergedState());
6599
- } catch {
7174
+ } catch (error) {
7175
+ // The details stay in the log: they name folders on the host.
7176
+ console.warn(`[autostart] could not read the Windows autostart setting: ${error?.message || error}`);
6600
7177
  return res.status(500).json({
6601
7178
  error: 'Unable to read Windows autostart. Check access to your user Startup folder.',
6602
7179
  });
@@ -6646,7 +7223,8 @@ export function registerSessionsRoutes(app, deps) {
6646
7223
  }
6647
7224
  windowsAutostartService.setEnabled(mode === 'signin');
6648
7225
  return res.json({ ...(await windowsAutostartMergedState()), accepted });
6649
- } catch {
7226
+ } catch (error) {
7227
+ console.warn(`[autostart] could not change the Windows autostart setting to ${mode}: ${error?.message || error}`);
6650
7228
  return res.status(500).json({
6651
7229
  error: 'Unable to update Windows autostart. Check access to your user Startup folder.',
6652
7230
  });