@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.
- package/CHANGELOG.md +247 -0
- package/README.md +78 -9
- package/bin/bat/oar.bat +46 -46
- package/package.json +2 -2
- package/server/README.md +286 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +36 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +61 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +186 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +361 -85
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +146 -26
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +59 -28
- package/server/public/app/store.js +19 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +396 -5
- package/server/repositories/message-repository.mjs +29 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +515 -50
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +72 -0
- package/server/server-runtime.mjs +245 -62
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- 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(),
|