@oar-sh/oar 0.9.8 → 0.9.10

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 (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. package/shared/subagent-timeline.mjs +79 -0
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ // Relay event subscriptions, for the browser: the active subscriptions (the
4
+ // "watching" chip and the "watched" badges) and Unsubscribe. Agents use the
5
+ // remote_relay tool instead; a request from another relay's agent is
6
+ // refused here, so only this relay's user ends a subscription from outside
7
+ // its conversation.
8
+ //
9
+ // And for paired relays: their agents subscribe to this relay's sessions
10
+ // (POST /api/relay-events/remote/subscribe) and their relay fetches the
11
+ // events (POST /api/relay-events/remote/pull, relay-event-link-service.mjs on
12
+ // the other side). Only another relay's request is taken there, under the
13
+ // inbound switch like a prompt; its subscriptions are filed under the relay
14
+ // id it states, which never names a conversation here.
15
+
16
+ import { REMOTE_RELAY_ERROR_CODES } from '../../shared/remote-relay-contract.mjs';
17
+ import { admitRemoteRelayRequest, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
18
+ import { RelayEventError, cleanSubscriberRelayId } from '../services/relay-event-service.mjs';
19
+
20
+ const RELAY_EVENT_ERROR_STATUS = Object.freeze({
21
+ invalid: [400, REMOTE_RELAY_ERROR_CODES.invalidInput],
22
+ notFound: [404, REMOTE_RELAY_ERROR_CODES.notFound],
23
+ ownSession: [400, REMOTE_RELAY_ERROR_CODES.ownSession],
24
+ limit: [409, REMOTE_RELAY_ERROR_CODES.subscriptionLimit],
25
+ unavailable: [503, REMOTE_RELAY_ERROR_CODES.unsupported],
26
+ });
27
+
28
+ function sendRelayEventError(res, error, what) {
29
+ if (error instanceof RelayEventError) {
30
+ const [status, code] = RELAY_EVENT_ERROR_STATUS[error.code] || [400, REMOTE_RELAY_ERROR_CODES.invalidInput];
31
+ return res.status(status).json({ error: error.message, code, ...(error.extra && typeof error.extra === 'object' ? error.extra : {}) });
32
+ }
33
+ return res.status(500).json({ error: `${what} failed: ${String(error?.message || error)}`, code: REMOTE_RELAY_ERROR_CODES.internal });
34
+ }
35
+
36
+ function asObject(value) {
37
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
38
+ }
39
+
40
+ export function registerRelayEventRoutes(app, deps) {
41
+ const {
42
+ auth,
43
+ relayEventService = null,
44
+ remoteRelayInbound = null,
45
+ } = deps;
46
+
47
+ function refuseRemote(req, res) {
48
+ const request = readRemoteRelayRequest(req, remoteRelayInbound || {});
49
+ if (!request.remote) return false;
50
+ res.status(403).json({ error: 'Relay event subscriptions are managed by this relay\'s user and its own agents only.' });
51
+ return true;
52
+ }
53
+
54
+ // GET /api/relay-events/subscriptions — every active subscription.
55
+ app.get('/api/relay-events/subscriptions', auth, (req, res) => {
56
+ if (refuseRemote(req, res)) return;
57
+ if (!relayEventService?.isAvailable?.()) return res.json({ available: false, subscriptions: [] });
58
+ res.json({ available: true, subscriptions: relayEventService.listActive() });
59
+ });
60
+
61
+ /**
62
+ * The paired relay a request comes from: its stated relay id, or null
63
+ * after answering. Refused: this relay's own agents and the browser (they
64
+ * have the tool and the routes above), any request while the inbound
65
+ * switch is off, and one that names no relay.
66
+ */
67
+ function admitPairedRelay(req, res) {
68
+ const request = admitRemoteRelayRequest(req, res, remoteRelayInbound);
69
+ if (!request) return null;
70
+ if (!request.remote || request.local) {
71
+ res.status(403).json({
72
+ error: 'Only another relay subscribes to sessions here this way; this relay\'s own agents use the remote_relay tool.',
73
+ code: REMOTE_RELAY_ERROR_CODES.forbidden,
74
+ });
75
+ return null;
76
+ }
77
+ const subscriber = cleanSubscriberRelayId(request.origin?.relayId);
78
+ if (!subscriber) {
79
+ res.status(400).json({ error: 'The calling relay sent no relay id.', code: REMOTE_RELAY_ERROR_CODES.invalidInput });
80
+ return null;
81
+ }
82
+ if (!relayEventService?.isCrossRelayAvailable?.()) {
83
+ res.status(503).json({
84
+ error: 'Event subscriptions across relays are not available on this relay right now (see its server log).',
85
+ code: REMOTE_RELAY_ERROR_CODES.unsupported,
86
+ });
87
+ return null;
88
+ }
89
+ return subscriber;
90
+ }
91
+
92
+ // POST /api/relay-events/remote/subscribe — a paired relay's agent
93
+ // subscribes: `{ watcher: { conversationId, title }, subscriber: { name },
94
+ // scope, sessions, events, once, match, stalled_minutes, heartbeat_minutes,
95
+ // dry_run }`. A dry run checks everything and stores nothing.
96
+ app.post('/api/relay-events/remote/subscribe', auth, (req, res) => {
97
+ const subscriber = admitPairedRelay(req, res);
98
+ if (!subscriber) return;
99
+ const body = asObject(req.body);
100
+ const watcher = asObject(body.watcher);
101
+ try {
102
+ const subscription = relayEventService.subscribeServed({
103
+ subscriberRelayId: subscriber,
104
+ subscriberRelayName: String(asObject(body.subscriber).name || ''),
105
+ watcherId: String(watcher.conversationId || ''),
106
+ watcherTitle: String(watcher.title || ''),
107
+ scope: body.scope === 'all' ? 'all' : 'sessions',
108
+ sessionIds: Array.isArray(body.sessions) ? body.sessions.map((id) => String(id ?? '')) : [],
109
+ events: Array.isArray(body.events) ? body.events.map((type) => String(type ?? '')) : null,
110
+ once: body.once === true,
111
+ match: typeof body.match === 'string' ? body.match : '',
112
+ stalledMinutes: body.stalled_minutes ?? null,
113
+ heartbeatMinutes: body.heartbeat_minutes ?? null,
114
+ dryRun: body.dry_run === true,
115
+ });
116
+ res.json({ ok: true, ...(body.dry_run === true ? { dryRun: true } : {}), subscription });
117
+ } catch (error) {
118
+ sendRelayEventError(res, error, 'Subscribe');
119
+ }
120
+ });
121
+
122
+ // POST /api/relay-events/remote/pull — a paired relay fetches its events:
123
+ // `{ after, subscriptions: [{ id, watcher }], ended: [ids] }`.
124
+ app.post('/api/relay-events/remote/pull', auth, (req, res) => {
125
+ const subscriber = admitPairedRelay(req, res);
126
+ if (!subscriber) return;
127
+ const body = asObject(req.body);
128
+ try {
129
+ res.json({
130
+ ok: true,
131
+ ...relayEventService.pullServed({
132
+ subscriberRelayId: subscriber,
133
+ after: body.after,
134
+ subscriptions: Array.isArray(body.subscriptions) ? body.subscriptions : [],
135
+ ended: Array.isArray(body.ended) ? body.ended : [],
136
+ }),
137
+ });
138
+ } catch (error) {
139
+ sendRelayEventError(res, error, 'The event fetch');
140
+ }
141
+ });
142
+
143
+ // DELETE /api/relay-events/subscriptions/:id — the user's Unsubscribe.
144
+ app.delete('/api/relay-events/subscriptions/:id', auth, (req, res) => {
145
+ if (refuseRemote(req, res)) return;
146
+ if (!relayEventService?.isAvailable?.()) return res.status(503).json({ error: 'Relay event subscriptions are not available' });
147
+ try {
148
+ const ended = relayEventService.unsubscribe({ subscriptionId: String(req.params.id || '').trim(), reason: 'unsubscribed-by-user' });
149
+ res.json({ ok: true, ended });
150
+ } catch (error) {
151
+ if (error instanceof RelayEventError) {
152
+ return res.status(error.code === 'notFound' ? 404 : 400).json({ error: error.message, code: error.code });
153
+ }
154
+ res.status(500).json({ error: `Unsubscribe failed: ${String(error?.message || error)}` });
155
+ }
156
+ });
157
+ }
@@ -8,6 +8,9 @@
8
8
  // No response carries a stored token: the registry's public views drop it, and
9
9
  // the pairing service's answers never contain one.
10
10
 
11
+ import { REMOTE_RELAY_ERROR_CODES } from '../../shared/remote-relay-contract.mjs';
12
+ import { readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
13
+
11
14
  function sendResult(res, result, fallbackStatus = 400) {
12
15
  const { status, ...payload } = result || {};
13
16
  const code = Number.isInteger(status) ? status : (payload.ok === false ? fallbackStatus : 200);
@@ -26,6 +29,9 @@ export function registerRemoteRelayRoutes(app, deps) {
26
29
  // WP2's dispatcher: `{ dispatch({ conversationId, action, args, req }) →
27
30
  // Promise<{ status, body }>, inflight(conversationId) → number }`.
28
31
  dispatcher = null,
32
+ // The inbound facts (remote-relay-inbound.mjs): which requests come from
33
+ // another relay.
34
+ remoteRelayInbound = null,
29
35
  } = deps;
30
36
 
31
37
  // Static paths first: Express would otherwise hand them to /:id.
@@ -85,6 +91,15 @@ export function registerRemoteRelayRoutes(app, deps) {
85
91
  // POST /api/remote-relays/tool — every provider adapter's remote_relay call.
86
92
  app.post('/api/remote-relays/tool', auth, async (req, res) => {
87
93
  if (!dispatcher) return res.status(503).json({ error: 'Remote relay dispatcher unavailable' });
94
+ // The tool acts for one of this relay's conversations: another relay
95
+ // never calls it (it would act, or subscribe, for a conversation it does
96
+ // not host). Its agents reach this relay through the routes it calls.
97
+ if (readRemoteRelayRequest(req, remoteRelayInbound).remote) {
98
+ return res.status(403).json({
99
+ error: 'The remote_relay tool of this relay is for its own sessions; another relay cannot call it.',
100
+ code: REMOTE_RELAY_ERROR_CODES.forbidden,
101
+ });
102
+ }
88
103
  // A worker that gives up on the call (turn stopped, worker gone) closes
89
104
  // the request: withdraw its approval card and stop waiting for the reply.
90
105
  const controller = new AbortController();
@@ -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';
@@ -68,6 +69,11 @@ import {
68
69
  TURN_CEILING_STEP_MINUTES,
69
70
  parseTurnCeilingUpdate,
70
71
  } from '../../shared/turn-ceiling.mjs';
72
+ import {
73
+ DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS,
74
+ SUBAGENT_RECORD_RETENTION_CHOICES,
75
+ parseSubagentRecordRetentionUpdate,
76
+ } from '../../shared/subagent-record-retention.mjs';
71
77
  import {
72
78
  DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES,
73
79
  BACKGROUND_TASK_TIMEOUT_MAX_MINUTES,
@@ -88,6 +94,9 @@ const SESSION_WORKER_STATUS_QUEUE_STATES = Object.freeze(['pending', 'processing
88
94
  // Registry statuses that mean "this worker is currently running" (same triple
89
95
  // as countActiveCliWorkers in server-runtime.mjs).
90
96
  const SESSION_WORKER_ACTIVE_STATUSES = Object.freeze(['starting', 'ready', 'processing']);
97
+ // A shared attachment's URL names its bytes (sha256 or a fixed generated-image
98
+ // path), so a kept answer stays right.
99
+ const SHARED_CONTENT_CACHE_CONTROL = 'private, max-age=31536000, immutable';
91
100
 
92
101
  function normalizeWorkerStatusText(value, fallback = null) {
93
102
  const text = String(value || '').trim();
@@ -1834,6 +1843,7 @@ export function buildConversationMessages({
1834
1843
  attachments: message?.attachments || [],
1835
1844
  mode: message?.mode || undefined,
1836
1845
  hiddenFromShares: Number(message?.hidden_from_shares || 0) === 1,
1846
+ pinned: !!message?.pinned_at,
1837
1847
  timestamp: message?.timestamp,
1838
1848
  sourceMessageId,
1839
1849
  executedProvider: message?.executed_provider || message?.executedProvider || undefined,
@@ -2155,6 +2165,8 @@ export function registerSessionsRoutes(app, deps) {
2155
2165
  resolveCursorSessionRoot = null,
2156
2166
  getTurnCeilingMinutes = () => DEFAULT_TURN_CEILING_MINUTES,
2157
2167
  setTurnCeilingMinutes = () => ({ ok: false, error: 'Turn ceiling settings are unavailable' }),
2168
+ getSubagentRecordRetentionDays = () => DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS,
2169
+ setSubagentRecordRetentionDays = () => ({ ok: false, error: 'Sub-agent record settings are unavailable' }),
2158
2170
  getFeatureFlagsSettingsState = () => [],
2159
2171
  setFeatureFlagSetting = () => ({ ok: false, error: 'Feature settings are unavailable' }),
2160
2172
  getPwaAppName = () => '',
@@ -2176,8 +2188,47 @@ export function registerSessionsRoutes(app, deps) {
2176
2188
  // conversations another relay's agent created, and the inbound switch.
2177
2189
  // Absent in older wiring and most tests, where both are no-ops.
2178
2190
  remoteRelayInbound = null,
2191
+ // Relay events: what each session is doing (state, tasks) and the
2192
+ // subscriptions that end with an archived or deleted conversation.
2193
+ relayEventService = null,
2194
+ sessionActivityTracker = null,
2179
2195
  } = deps;
2180
2196
  const sdkSessionSyncService = createSdkSessionSyncService(db);
2197
+ let listRunningRunConversations = null;
2198
+ try {
2199
+ listRunningRunConversations = db.prepare(`SELECT DISTINCT conversation_id FROM subagent_runs WHERE status = 'running'`);
2200
+ } catch {}
2201
+
2202
+ /**
2203
+ * `{ sessionState, sessionTasks, lastActivityAt }` of one conversation:
2204
+ * running_turn, background_only or idle, with its tasks. Only conversations
2205
+ * that may be doing something are asked (`candidates`); the rest are idle.
2206
+ */
2207
+ function describeSessionActivity(conversationId, candidates = null) {
2208
+ const id = String(conversationId || '').trim();
2209
+ if (!sessionActivityTracker || !id) return null;
2210
+ if (candidates && !candidates.has(id)) return { sessionState: 'idle' };
2211
+ try {
2212
+ const view = sessionActivityTracker.describe(id);
2213
+ return {
2214
+ sessionState: view.state,
2215
+ ...(view.tasks.length ? { sessionTasks: view.tasks } : {}),
2216
+ ...(view.state !== 'idle' && view.lastActivityAt ? { lastActivityAt: view.lastActivityAt } : {}),
2217
+ };
2218
+ } catch {
2219
+ return null;
2220
+ }
2221
+ }
2222
+
2223
+ /** The conversations that may be busy: a turn queued, a run or task known. */
2224
+ function activityCandidates(activeTurnConversationIds) {
2225
+ const ids = new Set(activeTurnConversationIds);
2226
+ for (const id of sessionActivityTracker?.knownConversationIds?.() || []) ids.add(String(id));
2227
+ try {
2228
+ for (const row of listRunningRunConversations?.all?.() || []) ids.add(String(row.conversation_id || ''));
2229
+ } catch {}
2230
+ return ids;
2231
+ }
2181
2232
  // Generic synchronous transaction runner for the session-sync route: binding
2182
2233
  // validation, workspace learning and the binding writes must commit or roll
2183
2234
  // back as one unit (audit #21).
@@ -2218,6 +2269,23 @@ export function registerSessionsRoutes(app, deps) {
2218
2269
  AND (id = ? OR response_message_id = ?)
2219
2270
  LIMIT 1
2220
2271
  `);
2272
+ function listConversationPins(conversationId) {
2273
+ return buildPinList(stmts.listPinnedMessages?.all?.(conversationId) || []);
2274
+ }
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;
2283
+ }
2284
+ function bumpConversationPinRevision(conversationId) {
2285
+ const next = Math.max(Date.now(), conversationPinRevision(conversationId) + 1);
2286
+ pinRevisionByConversation.set(conversationId, next);
2287
+ return next;
2288
+ }
2221
2289
  const listSessionWorkerQueueRows = db.prepare(`
2222
2290
  SELECT id, conversation_id, runtime_session_id, owner_sdk_session_id, status
2223
2291
  FROM queue
@@ -2533,6 +2601,7 @@ export function registerSessionsRoutes(app, deps) {
2533
2601
  hardDeleteConversationRows(conversationId);
2534
2602
  deleteOrphanedUploads(orphanedUploads);
2535
2603
  io.emit('conversation_deleted', { conversationId });
2604
+ try { relayEventService?.noteConversationGone?.(conversationId, { deleted: true }); } catch {}
2536
2605
  finalized.push(conversationId);
2537
2606
  }
2538
2607
  return finalized;
@@ -2712,7 +2781,9 @@ export function registerSessionsRoutes(app, deps) {
2712
2781
  });
2713
2782
  messages = messages.map((message) => {
2714
2783
  const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
2715
- const withSource = sourceMessageId ? { ...message, sourceMessageId } : message;
2784
+ // Pins are the owner's bookmarks: a public share never learns of them.
2785
+ const { pinned: _pinned, ...unpinnedMessage } = message;
2786
+ const withSource = sourceMessageId ? { ...unpinnedMessage, sourceMessageId } : unpinnedMessage;
2716
2787
  // A public share never shows another relay's address or ids.
2717
2788
  const nextMessage = withSource.origin
2718
2789
  ? { ...withSource, origin: publicRemoteRelayOrigin(withSource.origin) }
@@ -2823,6 +2894,7 @@ export function registerSessionsRoutes(app, deps) {
2823
2894
  .map((row) => String(row?.conversation_id || '').trim())
2824
2895
  .filter(Boolean),
2825
2896
  );
2897
+ const busyCandidates = sessionActivityTracker ? activityCandidates(activeTurnConversationIds) : null;
2826
2898
  const conversations = page.rows.map((r) => {
2827
2899
  const sid = String(r.sdk_session_id || '').trim();
2828
2900
  const workspaceState = typeof resolveConversationWorkspaceState === 'function'
@@ -2841,6 +2913,7 @@ export function registerSessionsRoutes(app, deps) {
2841
2913
  sdkSessionId: sid || null,
2842
2914
  title: resolveConversationTitle({ title: r.title, titleSource: r.title_source }),
2843
2915
  activeTurn: activeTurnConversationIds.has(String(r.id || '').trim()),
2916
+ ...(describeSessionActivity(r.id, busyCandidates) || {}),
2844
2917
  archived: Number(r.archived || 0) === 1,
2845
2918
  compactedInto: r.compacted_into || null,
2846
2919
  compactedFrom: r.compacted_from || null,
@@ -3600,6 +3673,9 @@ export function registerSessionsRoutes(app, deps) {
3600
3673
  cloud: conversationCloudPayload(conv, runtimeSession),
3601
3674
  messages: history.messages,
3602
3675
  pageInfo: history.pageInfo,
3676
+ // A re-import can move a pin onto a new message id.
3677
+ pins: listConversationPins(conversationId),
3678
+ pinsRevision: conversationPinRevision(conversationId),
3603
3679
  refreshed: true,
3604
3680
  });
3605
3681
  });
@@ -3777,20 +3853,14 @@ export function registerSessionsRoutes(app, deps) {
3777
3853
  if (!file) return res.status(404).json({ error: 'Shared attachment not found' });
3778
3854
  const filePath = uploadPathForSha(sha256);
3779
3855
  if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Missing file on disk' });
3780
- // Unauthenticated share viewers: neutralize executable types (HTML/SVG),
3781
- // force nosniff + sandbox so an attachment can't run as script on this origin.
3782
- res.setHeader('Cache-Control', 'private, max-age=31536000, immutable');
3856
+ // Unauthenticated share viewers: the serving helper neutralizes executable
3857
+ // types (HTML/SVG) and forces nosniff + sandbox so an attachment can't run
3858
+ // as script on this origin; ranges let a shared video seek.
3783
3859
  res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
3784
- applySafeServedContentHeaders(res, file.mime_type, { fileName: file.name || file.file_name || '' });
3785
- const stream = fs.createReadStream(filePath);
3786
- stream.on('error', () => {
3787
- if (!res.headersSent) {
3788
- res.status(500).json({ error: 'Failed to stream shared attachment' });
3789
- return;
3790
- }
3791
- res.destroy();
3860
+ serveFileWithRangeSupport(req, res, filePath, { contentType: file.mime_type }, {
3861
+ safeName: String(file.name || file.file_name || '').replace(/"/g, ''),
3862
+ cacheControl: SHARED_CONTENT_CACHE_CONTROL,
3792
3863
  });
3793
- stream.pipe(res);
3794
3864
  });
3795
3865
 
3796
3866
  app.get('/api/shared/:token/generated-image/:messageId/:imageId/content', (req, res) => {
@@ -3832,18 +3902,10 @@ export function registerSessionsRoutes(app, deps) {
3832
3902
  return res.status(404).json({ error: 'Shared attachment not found' });
3833
3903
  }
3834
3904
  if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Shared attachment not found' });
3835
- res.setHeader('Cache-Control', 'private, max-age=31536000, immutable');
3836
3905
  res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
3837
- applySafeServedContentHeaders(res, attachment.type);
3838
- const stream = fs.createReadStream(filePath);
3839
- stream.on('error', () => {
3840
- if (!res.headersSent) {
3841
- res.status(500).json({ error: 'Failed to stream shared attachment' });
3842
- return;
3843
- }
3844
- res.destroy();
3906
+ serveFileWithRangeSupport(req, res, filePath, { contentType: attachment.type }, {
3907
+ cacheControl: SHARED_CONTENT_CACHE_CONTROL,
3845
3908
  });
3846
- stream.pipe(res);
3847
3909
  });
3848
3910
 
3849
3911
  // GET /api/conversation/:id — get paginated conversation history
@@ -3997,9 +4059,13 @@ export function registerSessionsRoutes(app, deps) {
3997
4059
  // processing one. Another relay's agent tells "still queued" from
3998
4060
  // "cancelled" with it.
3999
4061
  activeTurn: Number(stmts.getConversationActiveQueueCount?.get?.(resolvedConversationId)?.count || 0) > 0,
4062
+ ...(describeSessionActivity(resolvedConversationId) || {}),
4000
4063
  backgroundTasks: backgroundTaskStore?.get?.(resolvedConversationId) || [],
4001
4064
  usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
4002
4065
  previews: listConversationPreviews?.(resolvedConversationId) || [],
4066
+ // Every pin of the conversation, also those outside this history page.
4067
+ pins: listConversationPins(resolvedConversationId),
4068
+ pinsRevision: conversationPinRevision(resolvedConversationId),
4003
4069
  preferredRelayMode: preferences.preferredRelayMode,
4004
4070
  preferredModel: preferences.preferredModel,
4005
4071
  preferredReasoningEffort: preferences.preferredReasoningEffort,
@@ -4059,6 +4125,57 @@ export function registerSessionsRoutes(app, deps) {
4059
4125
  });
4060
4126
  });
4061
4127
 
4128
+ // Pin or unpin one message. Unlike share visibility this may change while
4129
+ // the message's turn runs (nothing public depends on it), and every open
4130
+ // client learns of it through the socket instead of reloading.
4131
+ app.patch('/api/conversation/:id/message/:messageId/pin', auth, (req, res) => {
4132
+ const conversationId = String(req.params.id || '').trim();
4133
+ const messageId = String(req.params.messageId || '').trim();
4134
+ if (!conversationId || !messageId) {
4135
+ return res.status(400).json({ error: 'Missing conversation or message id' });
4136
+ }
4137
+ if (typeof req.body?.pinned !== 'boolean') {
4138
+ return res.status(400).json({ error: 'pinned must be a boolean' });
4139
+ }
4140
+
4141
+ const conv = stmts.getConvAnyStatus.get(conversationId);
4142
+ if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') {
4143
+ return res.status(404).json({ error: 'Conversation not found' });
4144
+ }
4145
+ const message = stmts.getMessageByConversation?.get(messageId, conversationId) || null;
4146
+ if (!message) return res.status(404).json({ error: 'Message not found' });
4147
+ if (!stmts.setMessagePinnedAt || !stmts.countPinnedMessages) {
4148
+ return res.status(503).json({ error: 'Pinning messages is unavailable until the relay restarts' });
4149
+ }
4150
+
4151
+ const pinned = req.body.pinned === true;
4152
+ const currentlyPinned = !!message.pinned_at;
4153
+ if (currentlyPinned !== pinned) {
4154
+ if (pinned && Number(stmts.countPinnedMessages.get(conversationId)?.cnt || 0) >= MESSAGE_PIN_LIMIT) {
4155
+ return res.status(409).json({
4156
+ error: `This conversation already has ${MESSAGE_PIN_LIMIT} pinned messages. Unpin one first.`,
4157
+ code: 'pin-limit',
4158
+ limit: MESSAGE_PIN_LIMIT,
4159
+ });
4160
+ }
4161
+ stmts.setMessagePinnedAt.run(pinned ? new Date().toISOString() : null, messageId, conversationId);
4162
+ }
4163
+
4164
+ const pins = listConversationPins(conversationId);
4165
+ if (currentlyPinned !== pinned) {
4166
+ const revision = bumpConversationPinRevision(conversationId);
4167
+ io?.emit?.('conversation_pins_updated', { conversationId, pins, revision });
4168
+ }
4169
+ return res.json({
4170
+ ok: true,
4171
+ conversationId,
4172
+ messageId,
4173
+ pinned,
4174
+ pins,
4175
+ pinsRevision: conversationPinRevision(conversationId),
4176
+ });
4177
+ });
4178
+
4062
4179
  app.patch('/api/conversation/:id', auth, (req, res) => {
4063
4180
  const conversationId = String(req.params.id || '').trim();
4064
4181
  const result = persistConversationTitle({
@@ -4753,6 +4870,7 @@ export function registerSessionsRoutes(app, deps) {
4753
4870
  deleteOrphanedUploads(orphanedUploads);
4754
4871
  if (sdkSessionId && !cliSession.queued) stmts.deleteSdkDeleteRequest?.run?.(sdkSessionId);
4755
4872
  io.emit('conversation_deleted', { conversationId: id });
4873
+ try { relayEventService?.noteConversationGone?.(id, { deleted: true }); } catch {}
4756
4874
  return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
4757
4875
  }
4758
4876
 
@@ -4831,6 +4949,8 @@ export function registerSessionsRoutes(app, deps) {
4831
4949
  }
4832
4950
  db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
4833
4951
  io.emit('conversation_archived', { conversationId: id, archived: true });
4952
+ // Its subscriptions end, and so does its place in others'.
4953
+ try { relayEventService?.noteConversationGone?.(id); } catch {}
4834
4954
  // The cloud session of a Claude Cloud conversation is archived with it;
4835
4955
  // best effort and not waited for.
4836
4956
  if (!sharedWithConversationId) {
@@ -5461,6 +5581,7 @@ export function registerSessionsRoutes(app, deps) {
5461
5581
  reconnectAttempts: runtimeState.cloudflaredTunnelState?.reconnectAttempts ?? 0,
5462
5582
  connectedSince: runtimeState.cloudflaredTunnelState?.connectedSince ?? null,
5463
5583
  lastError: runtimeState.cloudflaredTunnelState?.lastError ?? null,
5584
+ binaryMissing: runtimeState.cloudflaredTunnelState?.binaryMissing ?? false,
5464
5585
  valid: runtimeState.cloudflaredTunnelState?.valid ?? true,
5465
5586
  },
5466
5587
  workerWebSocket: runtimeState.workerWebSocketStatus || null,
@@ -6349,6 +6470,25 @@ export function registerSessionsRoutes(app, deps) {
6349
6470
  });
6350
6471
  });
6351
6472
 
6473
+ // How long sub-agent records keep their tool inputs and output (0 = always).
6474
+ const subagentRecordRetentionPayload = (retentionDays) => ({
6475
+ retentionDays,
6476
+ choices: [...SUBAGENT_RECORD_RETENTION_CHOICES],
6477
+ defaultDays: DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS,
6478
+ });
6479
+
6480
+ app.get('/api/settings/subagent-record-retention', auth, (_req, res) => {
6481
+ res.json(subagentRecordRetentionPayload(getSubagentRecordRetentionDays()));
6482
+ });
6483
+
6484
+ app.post('/api/settings/subagent-record-retention', auth, (req, res) => {
6485
+ const parsed = parseSubagentRecordRetentionUpdate(req.body?.retentionDays);
6486
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
6487
+ const result = setSubagentRecordRetentionDays(parsed.days);
6488
+ if (!result.ok) return res.status(500).json({ error: result.error });
6489
+ return res.json(subagentRecordRetentionPayload(result.retentionDays));
6490
+ });
6491
+
6352
6492
  app.get('/api/settings/background-task-timeout', auth, (_req, res) => {
6353
6493
  res.json({
6354
6494
  timeoutMinutes: getBackgroundTaskTimeoutMinutes(),