@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.
- package/CHANGELOG.md +478 -0
- package/README.md +129 -39
- package/bin/bat/oar.bat +46 -46
- package/bin/oar.js +814 -228
- package/package.json +5 -6
- package/server/README.md +306 -4
- 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 +44 -0
- package/server/migrations/0008-message-pins.mjs +85 -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 +91 -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 +1024 -131
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +230 -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 +471 -110
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +161 -27
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-dot-state.mjs +8 -1
- 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/router.js +8 -2
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +65 -28
- package/server/public/app/store.js +25 -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-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +45 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +700 -59
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +34 -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 +548 -111
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +164 -24
- package/server/server-runtime.mjs +274 -62
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -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/safe-served-content.mjs +61 -11
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +31 -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();
|
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
3781
|
-
//
|
|
3782
|
-
|
|
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
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
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
|
-
|
|
3838
|
-
|
|
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(),
|