@oar-sh/oar 0.9.9 → 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 (85) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/README.md +78 -9
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +2 -2
  5. package/server/README.md +286 -3
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +35 -1
  8. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  9. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  10. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  12. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  13. package/server/db-schema.mjs +36 -0
  14. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  15. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  16. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  17. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  18. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  19. package/server/public/app/api-client.js +61 -0
  20. package/server/public/app/archive-confirm.mjs +28 -0
  21. package/server/public/app/ask-user-view.js +3 -0
  22. package/server/public/app/attachments-view.js +516 -6
  23. package/server/public/app/background-tasks-view.mjs +18 -26
  24. package/server/public/app/bootstrap.js +186 -35
  25. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  26. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  27. package/server/public/app/composer-control-state.mjs +16 -0
  28. package/server/public/app/composer-keyboard.mjs +54 -0
  29. package/server/public/app/conversation-view.js +361 -85
  30. package/server/public/app/explorer-edit-state.mjs +68 -0
  31. package/server/public/app/host-suspend-ui.js +6 -3
  32. package/server/public/app/journal-view.js +146 -26
  33. package/server/public/app/no-conversation-state.mjs +58 -0
  34. package/server/public/app/provider-readiness.mjs +120 -0
  35. package/server/public/app/relay-board-view.js +3 -0
  36. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  37. package/server/public/app/relay-event-view.mjs +234 -0
  38. package/server/public/app/settings-modal.js +53 -1
  39. package/server/public/app/socket-handlers.js +59 -28
  40. package/server/public/app/store.js +19 -2
  41. package/server/public/app/subagent-row.mjs +138 -0
  42. package/server/public/app/subagent-timeline-view.mjs +186 -0
  43. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  44. package/server/public/app/subagent-viewer.mjs +534 -0
  45. package/server/public/app/task-row-format.mjs +29 -0
  46. package/server/public/app/thinking-follow.js +4 -0
  47. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  48. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  49. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  50. package/server/public/app/update-ui.js +1 -1
  51. package/server/public/app/viewer-history.mjs +5 -3
  52. package/server/public/index.html +396 -5
  53. package/server/repositories/message-repository.mjs +29 -3
  54. package/server/repositories/question-repository.mjs +9 -3
  55. package/server/repositories/remote-relay-repository.mjs +6 -2
  56. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  57. package/server/routes/ask-user-routes.mjs +3 -0
  58. package/server/routes/messages-routes.mjs +515 -50
  59. package/server/routes/relay-event-routes.mjs +157 -0
  60. package/server/routes/remote-relay-routes.mjs +15 -0
  61. package/server/routes/sessions-routes.mjs +72 -0
  62. package/server/server-runtime.mjs +245 -62
  63. package/server/services/explorer-entry-name.mjs +26 -0
  64. package/server/services/explorer-mutations.mjs +122 -0
  65. package/server/services/preview-proxy-server.mjs +41 -23
  66. package/server/services/push-dispatch-service.mjs +18 -0
  67. package/server/services/relay-event-link-service.mjs +289 -0
  68. package/server/services/relay-event-service.mjs +1935 -0
  69. package/server/services/relay-shutdown-gate.mjs +31 -0
  70. package/server/services/remote-relay-caller-context.mjs +52 -7
  71. package/server/services/remote-relay-dispatcher.mjs +494 -2
  72. package/server/services/remote-relay-registry-service.mjs +11 -1
  73. package/server/services/session-activity-tracker.mjs +487 -0
  74. package/server/services/session-history-refresh-service.mjs +7 -0
  75. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  76. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  77. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  78. package/server/services/subagent-record-retention.mjs +164 -0
  79. package/server/services/subagent-timeline-service.mjs +328 -0
  80. package/server/services/subagent-viewer-rooms.mjs +119 -0
  81. package/server/services/upgrade-socket-guard.mjs +16 -0
  82. package/shared/relay-events.mjs +371 -0
  83. package/shared/remote-relay-contract.mjs +157 -5
  84. package/shared/subagent-record-retention.mjs +31 -0
  85. 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();
@@ -69,6 +69,11 @@ import {
69
69
  TURN_CEILING_STEP_MINUTES,
70
70
  parseTurnCeilingUpdate,
71
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';
72
77
  import {
73
78
  DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES,
74
79
  BACKGROUND_TASK_TIMEOUT_MAX_MINUTES,
@@ -2160,6 +2165,8 @@ export function registerSessionsRoutes(app, deps) {
2160
2165
  resolveCursorSessionRoot = null,
2161
2166
  getTurnCeilingMinutes = () => DEFAULT_TURN_CEILING_MINUTES,
2162
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' }),
2163
2170
  getFeatureFlagsSettingsState = () => [],
2164
2171
  setFeatureFlagSetting = () => ({ ok: false, error: 'Feature settings are unavailable' }),
2165
2172
  getPwaAppName = () => '',
@@ -2181,8 +2188,47 @@ export function registerSessionsRoutes(app, deps) {
2181
2188
  // conversations another relay's agent created, and the inbound switch.
2182
2189
  // Absent in older wiring and most tests, where both are no-ops.
2183
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,
2184
2195
  } = deps;
2185
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
+ }
2186
2232
  // Generic synchronous transaction runner for the session-sync route: binding
2187
2233
  // validation, workspace learning and the binding writes must commit or roll
2188
2234
  // back as one unit (audit #21).
@@ -2555,6 +2601,7 @@ export function registerSessionsRoutes(app, deps) {
2555
2601
  hardDeleteConversationRows(conversationId);
2556
2602
  deleteOrphanedUploads(orphanedUploads);
2557
2603
  io.emit('conversation_deleted', { conversationId });
2604
+ try { relayEventService?.noteConversationGone?.(conversationId, { deleted: true }); } catch {}
2558
2605
  finalized.push(conversationId);
2559
2606
  }
2560
2607
  return finalized;
@@ -2847,6 +2894,7 @@ export function registerSessionsRoutes(app, deps) {
2847
2894
  .map((row) => String(row?.conversation_id || '').trim())
2848
2895
  .filter(Boolean),
2849
2896
  );
2897
+ const busyCandidates = sessionActivityTracker ? activityCandidates(activeTurnConversationIds) : null;
2850
2898
  const conversations = page.rows.map((r) => {
2851
2899
  const sid = String(r.sdk_session_id || '').trim();
2852
2900
  const workspaceState = typeof resolveConversationWorkspaceState === 'function'
@@ -2865,6 +2913,7 @@ export function registerSessionsRoutes(app, deps) {
2865
2913
  sdkSessionId: sid || null,
2866
2914
  title: resolveConversationTitle({ title: r.title, titleSource: r.title_source }),
2867
2915
  activeTurn: activeTurnConversationIds.has(String(r.id || '').trim()),
2916
+ ...(describeSessionActivity(r.id, busyCandidates) || {}),
2868
2917
  archived: Number(r.archived || 0) === 1,
2869
2918
  compactedInto: r.compacted_into || null,
2870
2919
  compactedFrom: r.compacted_from || null,
@@ -4010,6 +4059,7 @@ export function registerSessionsRoutes(app, deps) {
4010
4059
  // processing one. Another relay's agent tells "still queued" from
4011
4060
  // "cancelled" with it.
4012
4061
  activeTurn: Number(stmts.getConversationActiveQueueCount?.get?.(resolvedConversationId)?.count || 0) > 0,
4062
+ ...(describeSessionActivity(resolvedConversationId) || {}),
4013
4063
  backgroundTasks: backgroundTaskStore?.get?.(resolvedConversationId) || [],
4014
4064
  usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
4015
4065
  previews: listConversationPreviews?.(resolvedConversationId) || [],
@@ -4820,6 +4870,7 @@ export function registerSessionsRoutes(app, deps) {
4820
4870
  deleteOrphanedUploads(orphanedUploads);
4821
4871
  if (sdkSessionId && !cliSession.queued) stmts.deleteSdkDeleteRequest?.run?.(sdkSessionId);
4822
4872
  io.emit('conversation_deleted', { conversationId: id });
4873
+ try { relayEventService?.noteConversationGone?.(id, { deleted: true }); } catch {}
4823
4874
  return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
4824
4875
  }
4825
4876
 
@@ -4898,6 +4949,8 @@ export function registerSessionsRoutes(app, deps) {
4898
4949
  }
4899
4950
  db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
4900
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 {}
4901
4954
  // The cloud session of a Claude Cloud conversation is archived with it;
4902
4955
  // best effort and not waited for.
4903
4956
  if (!sharedWithConversationId) {
@@ -6417,6 +6470,25 @@ export function registerSessionsRoutes(app, deps) {
6417
6470
  });
6418
6471
  });
6419
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
+
6420
6492
  app.get('/api/settings/background-task-timeout', auth, (_req, res) => {
6421
6493
  res.json({
6422
6494
  timeoutMinutes: getBackgroundTaskTimeoutMinutes(),