@oar-sh/oar 0.9.9 → 0.9.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +561 -1
- package/README.md +162 -909
- package/bin/bat/oar.bat +46 -46
- package/package.json +14 -14
- package/server/README.md +369 -13
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +121 -9
- package/server/claude-worker/claude-session-worker.mjs +9 -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 +79 -12
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +95 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- 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/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +187 -0
- package/server/public/app/api-client.js +312 -16
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +17 -12
- 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 +336 -59
- package/server/public/app/chat-actions-menu-state.mjs +126 -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/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +436 -117
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +13 -4
- package/server/public/app/journal-view.js +930 -45
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +19 -3
- 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/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +93 -9
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +137 -40
- package/server/public/app/store.js +115 -32
- 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 +558 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
- package/server/public/app/transcript-jump-bottom.mjs +210 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +774 -12
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +30 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +698 -114
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +843 -193
- package/server/server-runtime.mjs +559 -160
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +49 -27
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1952 -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 +551 -7
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +695 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -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/status-event-service.mjs +7 -3
- 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/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +160 -6
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -10,6 +10,7 @@ import { createSdkSessionSyncService } from '../services/sdk-session-sync-servic
|
|
|
10
10
|
import { serveFileWithRangeSupport } from '../services/file-serving.mjs';
|
|
11
11
|
import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
|
|
12
12
|
import { MESSAGE_PIN_LIMIT, buildPinList } from '../services/message-pin-list.mjs';
|
|
13
|
+
import { buildHiddenMessageList } from '../services/hidden-message-list.mjs';
|
|
13
14
|
import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
|
|
14
15
|
import { parseAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
|
|
15
16
|
import { CLAUDE_ATTRIBUTION_MODES, DEFAULT_CLAUDE_ATTRIBUTION_MODE, claudeAttributionModelLabel, claudeAttributionTrailer, normalizeClaudeAttributionMode, resolveClaudeAttributionMode } from '../../shared/claude-attribution.mjs';
|
|
@@ -34,10 +35,19 @@ import {
|
|
|
34
35
|
readWorkspaceRootPathFromBody,
|
|
35
36
|
validateRequestedWorkspaceRoot, normalizeWorkspaceRootKey } from '../services/workspace-root-path-policy.mjs';
|
|
36
37
|
import { workspaceRootDisplayName } from '../workspace-root.mjs';
|
|
38
|
+
import { redactPreviewTokens } from '../services/preview-registry-service.mjs';
|
|
39
|
+
import { createSharePathRedactor } from '../services/share-path-redaction.mjs';
|
|
40
|
+
import { createSubagentTimelineService, TIMELINE_READ_DEFAULT_LIMIT } from '../services/subagent-timeline-service.mjs';
|
|
41
|
+
import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
|
|
37
42
|
import { createKeyedMutex } from '../services/keyed-mutex-service.mjs';
|
|
38
43
|
import { createFixedWindowRateLimiter } from '../services/rate-limit-service.mjs';
|
|
44
|
+
import { requestHostOf } from '../services/request-boundary.mjs';
|
|
39
45
|
import { parseRemoteRelayOriginJson } from '../repositories/remote-relay-repository.mjs';
|
|
40
|
-
import {
|
|
46
|
+
import { createShareRepository } from '../repositories/share-repository.mjs';
|
|
47
|
+
import { createShareAccessLog, readShareRequestViewer } from '../services/share-access-log-service.mjs';
|
|
48
|
+
import { resolveGroupDisplayName } from '../repositories/conversation-group-repository.mjs';
|
|
49
|
+
import { conversationGroupPosition, conversationGroupRole, groupActivityTimes, pageConversationRows } from '../public/app/conversation-group-order.mjs';
|
|
50
|
+
import { admitRemoteRelayRequest, publicRemoteRelayOrigin, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
|
|
41
51
|
import {
|
|
42
52
|
RELAUNCH_COALESCE_WINDOW_MS,
|
|
43
53
|
buildRelaunchRequestKey,
|
|
@@ -69,6 +79,17 @@ import {
|
|
|
69
79
|
TURN_CEILING_STEP_MINUTES,
|
|
70
80
|
parseTurnCeilingUpdate,
|
|
71
81
|
} from '../../shared/turn-ceiling.mjs';
|
|
82
|
+
import {
|
|
83
|
+
DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS,
|
|
84
|
+
SUBAGENT_RECORD_RETENTION_CHOICES,
|
|
85
|
+
parseSubagentRecordRetentionUpdate,
|
|
86
|
+
} from '../../shared/subagent-record-retention.mjs';
|
|
87
|
+
import {
|
|
88
|
+
DEFAULT_SHARE_PATH_PRIVACY_MODE,
|
|
89
|
+
SHARE_PATH_PRIVACY_LABELS,
|
|
90
|
+
SHARE_PATH_PRIVACY_MODES,
|
|
91
|
+
parseSharePathPrivacyUpdate,
|
|
92
|
+
} from '../../shared/share-path-privacy.mjs';
|
|
72
93
|
import {
|
|
73
94
|
DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES,
|
|
74
95
|
BACKGROUND_TASK_TIMEOUT_MAX_MINUTES,
|
|
@@ -1709,80 +1730,25 @@ export function normalizeConversationListLimit(value, fallback = DEFAULT_CONVERS
|
|
|
1709
1730
|
return Math.min(MAX_CONVERSATION_LIST_LIMIT, Math.max(1, Math.trunc(parsed)));
|
|
1710
1731
|
}
|
|
1711
1732
|
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
function resolveConversationListCursor(rows = [], {
|
|
1720
|
-
beforeConversationId = '',
|
|
1721
|
-
beforeUpdatedAt = '',
|
|
1722
|
-
} = {}) {
|
|
1723
|
-
const cursorConversationId = String(beforeConversationId || '').trim();
|
|
1724
|
-
const cursorUpdatedAt = String(beforeUpdatedAt || '').trim();
|
|
1725
|
-
const list = Array.isArray(rows) ? rows : [];
|
|
1726
|
-
|
|
1727
|
-
if (cursorConversationId) {
|
|
1728
|
-
const cursorRow = list.find((row) => String(row?.id || '').trim() === cursorConversationId) || null;
|
|
1729
|
-
if (cursorRow) {
|
|
1730
|
-
return {
|
|
1731
|
-
beforeConversationId: cursorConversationId,
|
|
1732
|
-
beforeUpdatedAt: normalizeConversationTimestampIso(
|
|
1733
|
-
cursorRow.updated_at || cursorRow.updatedAt,
|
|
1734
|
-
String(cursorRow.updated_at || cursorRow.updatedAt || '').trim() || null,
|
|
1735
|
-
),
|
|
1736
|
-
timestampMs: normalizeConversationTimestampMs(cursorRow.updated_at || cursorRow.updatedAt),
|
|
1737
|
-
};
|
|
1738
|
-
}
|
|
1739
|
-
}
|
|
1740
|
-
|
|
1741
|
-
if (cursorUpdatedAt) {
|
|
1742
|
-
return {
|
|
1743
|
-
beforeConversationId: cursorConversationId || null,
|
|
1744
|
-
beforeUpdatedAt: cursorUpdatedAt,
|
|
1745
|
-
timestampMs: normalizeConversationTimestampMs(cursorUpdatedAt),
|
|
1746
|
-
};
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
return null;
|
|
1750
|
-
}
|
|
1751
|
-
|
|
1733
|
+
/**
|
|
1734
|
+
* One page of the conversation list, newest first, with session groups kept
|
|
1735
|
+
* whole: the order and the cursor are those of conversation-group-order.mjs,
|
|
1736
|
+
* which the sidebar uses too. `groups` is the `Map<id, group>` of the rows'
|
|
1737
|
+
* groups (the head of each group is needed for the order inside it).
|
|
1738
|
+
*/
|
|
1752
1739
|
export function selectConversationListPage(rows = [], {
|
|
1753
1740
|
limit = DEFAULT_CONVERSATION_LIST_LIMIT,
|
|
1754
1741
|
beforeConversationId = '',
|
|
1755
1742
|
beforeUpdatedAt = '',
|
|
1743
|
+
beforeGroupId = '',
|
|
1744
|
+
groups = null,
|
|
1756
1745
|
} = {}) {
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
? ordered.filter((row) => {
|
|
1764
|
-
const rowUpdatedAtMs = normalizeConversationTimestampMs(row?.updated_at || row?.updatedAt);
|
|
1765
|
-
if (rowUpdatedAtMs < cursor.timestampMs) return true;
|
|
1766
|
-
if (rowUpdatedAtMs > cursor.timestampMs) return false;
|
|
1767
|
-
if (!cursor.beforeConversationId) return false;
|
|
1768
|
-
return String(row?.id || '').trim().localeCompare(cursor.beforeConversationId) < 0;
|
|
1769
|
-
})
|
|
1770
|
-
: ordered;
|
|
1771
|
-
const pageRows = olderRows.slice(0, pageLimit);
|
|
1772
|
-
const oldestRow = pageRows[pageRows.length - 1] || null;
|
|
1773
|
-
return {
|
|
1774
|
-
rows: pageRows,
|
|
1775
|
-
pageInfo: {
|
|
1776
|
-
hasMore: olderRows.length > pageRows.length,
|
|
1777
|
-
nextCursor: oldestRow ? {
|
|
1778
|
-
beforeConversationId: String(oldestRow.id || '').trim(),
|
|
1779
|
-
beforeUpdatedAt: normalizeConversationTimestampIso(
|
|
1780
|
-
oldestRow.updated_at || oldestRow.updatedAt,
|
|
1781
|
-
String(oldestRow.updated_at || oldestRow.updatedAt || '').trim() || null,
|
|
1782
|
-
),
|
|
1783
|
-
} : null,
|
|
1784
|
-
},
|
|
1785
|
-
};
|
|
1746
|
+
return pageConversationRows(rows, groups, {
|
|
1747
|
+
limit: normalizeConversationListLimit(limit),
|
|
1748
|
+
beforeConversationId,
|
|
1749
|
+
beforeUpdatedAt,
|
|
1750
|
+
beforeGroupId,
|
|
1751
|
+
});
|
|
1786
1752
|
}
|
|
1787
1753
|
|
|
1788
1754
|
export function buildConversationMessages({
|
|
@@ -2034,7 +2000,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2034
2000
|
readContextFromSessionEvents,
|
|
2035
2001
|
inFlightStateForConversation,
|
|
2036
2002
|
backgroundTaskStore,
|
|
2003
|
+
backgroundWorkView = null,
|
|
2037
2004
|
listConversationPreviews,
|
|
2005
|
+
listKnownPreviewTokens,
|
|
2006
|
+
closeConversationPreviews,
|
|
2038
2007
|
createCompactedConversation,
|
|
2039
2008
|
collectOrphanedUploadsFromConversation,
|
|
2040
2009
|
deleteOrphanedUploads,
|
|
@@ -2135,6 +2104,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2135
2104
|
setGrokPlanAllowanceSettings = () => ({ ok: false, error: 'Grok allowance settings are unavailable' }),
|
|
2136
2105
|
sessionHistoryRefreshService,
|
|
2137
2106
|
sdkSessionImportService,
|
|
2107
|
+
// Session groups (migration 0014): null in older wiring and most tests,
|
|
2108
|
+
// where every conversation is ungrouped.
|
|
2109
|
+
conversationGroups = null,
|
|
2138
2110
|
ensureRuntimeSessionBinding,
|
|
2139
2111
|
bootstrapRuntimeSessionBindings,
|
|
2140
2112
|
configuredConversationSessionMode,
|
|
@@ -2160,6 +2132,11 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2160
2132
|
resolveCursorSessionRoot = null,
|
|
2161
2133
|
getTurnCeilingMinutes = () => DEFAULT_TURN_CEILING_MINUTES,
|
|
2162
2134
|
setTurnCeilingMinutes = () => ({ ok: false, error: 'Turn ceiling settings are unavailable' }),
|
|
2135
|
+
getSubagentRecordRetentionDays = () => DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS,
|
|
2136
|
+
setSubagentRecordRetentionDays = () => ({ ok: false, error: 'Sub-agent record settings are unavailable' }),
|
|
2137
|
+
getSharePathPrivacyMode = () => DEFAULT_SHARE_PATH_PRIVACY_MODE,
|
|
2138
|
+
setSharePathPrivacyMode = () => ({ ok: false, error: 'Path privacy settings are unavailable' }),
|
|
2139
|
+
sharePathKnownRoots = () => ({ roots: [], userNames: [] }),
|
|
2163
2140
|
getFeatureFlagsSettingsState = () => [],
|
|
2164
2141
|
setFeatureFlagSetting = () => ({ ok: false, error: 'Feature settings are unavailable' }),
|
|
2165
2142
|
getPwaAppName = () => '',
|
|
@@ -2181,8 +2158,55 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2181
2158
|
// conversations another relay's agent created, and the inbound switch.
|
|
2182
2159
|
// Absent in older wiring and most tests, where both are no-ops.
|
|
2183
2160
|
remoteRelayInbound = null,
|
|
2161
|
+
// Relay events: what each session is doing (state, tasks) and the
|
|
2162
|
+
// subscriptions that end with an archived or deleted conversation.
|
|
2163
|
+
relayEventService = null,
|
|
2164
|
+
sessionActivityTracker = null,
|
|
2165
|
+
// Public shares and their access log (migration 0016); built on `db` when
|
|
2166
|
+
// the wiring does not pass one.
|
|
2167
|
+
shareRepository = null,
|
|
2184
2168
|
} = deps;
|
|
2185
2169
|
const sdkSessionSyncService = createSdkSessionSyncService(db);
|
|
2170
|
+
// Built on first use: many route tests hand in a database without tables.
|
|
2171
|
+
let shareRepositoryInstance = shareRepository;
|
|
2172
|
+
let shareAccessLogInstance = null;
|
|
2173
|
+
const shares = () => (shareRepositoryInstance ||= createShareRepository(db));
|
|
2174
|
+
const shareAccessLog = () => (shareAccessLogInstance ||= createShareAccessLog({ repository: shares() }));
|
|
2175
|
+
let listRunningRunConversations = null;
|
|
2176
|
+
try {
|
|
2177
|
+
listRunningRunConversations = db.prepare(`SELECT DISTINCT conversation_id FROM subagent_runs WHERE status = 'running'`);
|
|
2178
|
+
} catch {}
|
|
2179
|
+
|
|
2180
|
+
/**
|
|
2181
|
+
* `{ sessionState, sessionTasks, lastActivityAt }` of one conversation:
|
|
2182
|
+
* running_turn, background_only or idle, with its tasks. Only conversations
|
|
2183
|
+
* that may be doing something are asked (`candidates`); the rest are idle.
|
|
2184
|
+
*/
|
|
2185
|
+
function describeSessionActivity(conversationId, candidates = null) {
|
|
2186
|
+
const id = String(conversationId || '').trim();
|
|
2187
|
+
if (!sessionActivityTracker || !id) return null;
|
|
2188
|
+
if (candidates && !candidates.has(id)) return { sessionState: 'idle' };
|
|
2189
|
+
try {
|
|
2190
|
+
const view = sessionActivityTracker.describe(id);
|
|
2191
|
+
return {
|
|
2192
|
+
sessionState: view.state,
|
|
2193
|
+
...(view.tasks.length ? { sessionTasks: view.tasks } : {}),
|
|
2194
|
+
...(view.state !== 'idle' && view.lastActivityAt ? { lastActivityAt: view.lastActivityAt } : {}),
|
|
2195
|
+
};
|
|
2196
|
+
} catch {
|
|
2197
|
+
return null;
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
/** The conversations that may be busy: a turn queued, a run or task known. */
|
|
2202
|
+
function activityCandidates(activeTurnConversationIds) {
|
|
2203
|
+
const ids = new Set(activeTurnConversationIds);
|
|
2204
|
+
for (const id of sessionActivityTracker?.knownConversationIds?.() || []) ids.add(String(id));
|
|
2205
|
+
try {
|
|
2206
|
+
for (const row of listRunningRunConversations?.all?.() || []) ids.add(String(row.conversation_id || ''));
|
|
2207
|
+
} catch {}
|
|
2208
|
+
return ids;
|
|
2209
|
+
}
|
|
2186
2210
|
// Generic synchronous transaction runner for the session-sync route: binding
|
|
2187
2211
|
// validation, workspace learning and the binding writes must commit or roll
|
|
2188
2212
|
// back as one unit (audit #21).
|
|
@@ -2226,19 +2250,47 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2226
2250
|
function listConversationPins(conversationId) {
|
|
2227
2251
|
return buildPinList(stmts.listPinnedMessages?.all?.(conversationId) || []);
|
|
2228
2252
|
}
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
// the revision the browser can tell the older list from the newer one. In
|
|
2232
|
-
// memory only: a restart starts above every revision handed out before.
|
|
2233
|
-
const pinRevisionFloor = Date.now();
|
|
2234
|
-
const pinRevisionByConversation = new Map();
|
|
2235
|
-
function conversationPinRevision(conversationId) {
|
|
2236
|
-
return pinRevisionByConversation.get(conversationId) || pinRevisionFloor;
|
|
2253
|
+
function listConversationHiddenMessages(conversationId) {
|
|
2254
|
+
return buildHiddenMessageList(stmts.listHiddenMessages?.all?.(conversationId) || []);
|
|
2237
2255
|
}
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2256
|
+
// Orders the per-conversation lists a browser receives (pins, hidden
|
|
2257
|
+
// messages). A conversation load that left before a change can arrive after
|
|
2258
|
+
// the change's own socket event; with the revision the browser can tell the
|
|
2259
|
+
// older list from the newer one. In memory only: a restart starts above
|
|
2260
|
+
// every revision handed out before.
|
|
2261
|
+
function createConversationListRevisions() {
|
|
2262
|
+
const floor = Date.now();
|
|
2263
|
+
const byConversation = new Map();
|
|
2264
|
+
const current = (conversationId) => byConversation.get(conversationId) || floor;
|
|
2265
|
+
return {
|
|
2266
|
+
current,
|
|
2267
|
+
bump(conversationId) {
|
|
2268
|
+
const next = Math.max(Date.now(), current(conversationId) + 1);
|
|
2269
|
+
byConversation.set(conversationId, next);
|
|
2270
|
+
return next;
|
|
2271
|
+
},
|
|
2272
|
+
};
|
|
2273
|
+
}
|
|
2274
|
+
const pinRevisions = createConversationListRevisions();
|
|
2275
|
+
const conversationPinRevision = pinRevisions.current;
|
|
2276
|
+
const bumpConversationPinRevision = pinRevisions.bump;
|
|
2277
|
+
const hiddenMessageRevisions = createConversationListRevisions();
|
|
2278
|
+
const conversationHiddenMessagesRevision = hiddenMessageRevisions.current;
|
|
2279
|
+
// Every open owner page follows a share-visibility change without a reload:
|
|
2280
|
+
// the 🙈 list and count, the bubbles' Hide / Unhide, and the pin list's
|
|
2281
|
+
// "hidden from shared" chip when a pinned message changed.
|
|
2282
|
+
function broadcastShareVisibilityChange(conversationId, { pinnedChanged = false } = {}) {
|
|
2283
|
+
const hiddenMessages = listConversationHiddenMessages(conversationId);
|
|
2284
|
+
const revision = hiddenMessageRevisions.bump(conversationId);
|
|
2285
|
+
io?.emit?.('conversation_hidden_messages_updated', { conversationId, hiddenMessages, revision });
|
|
2286
|
+
if (pinnedChanged) {
|
|
2287
|
+
io?.emit?.('conversation_pins_updated', {
|
|
2288
|
+
conversationId,
|
|
2289
|
+
pins: listConversationPins(conversationId),
|
|
2290
|
+
revision: bumpConversationPinRevision(conversationId),
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
return { hiddenMessages, revision };
|
|
2242
2294
|
}
|
|
2243
2295
|
const listSessionWorkerQueueRows = db.prepare(`
|
|
2244
2296
|
SELECT id, conversation_id, runtime_session_id, owner_sdk_session_id, status
|
|
@@ -2313,6 +2365,8 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2313
2365
|
db.prepare(`DELETE FROM queue WHERE conversation_id = ?`).run(conversationId);
|
|
2314
2366
|
db.prepare(`DELETE FROM messages WHERE conversation_id = ?`).run(conversationId);
|
|
2315
2367
|
db.prepare(`DELETE FROM runtime_sessions WHERE conversation_id = ?`).run(conversationId);
|
|
2368
|
+
// The share access log goes with it; the share rows follow the foreign key.
|
|
2369
|
+
shares().forgetConversation(conversationId);
|
|
2316
2370
|
db.prepare(`DELETE FROM conversations WHERE id = ?`).run(conversationId);
|
|
2317
2371
|
});
|
|
2318
2372
|
const sharedPresenceRateLimiter = createFixedWindowRateLimiter({
|
|
@@ -2356,17 +2410,12 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2356
2410
|
io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
|
|
2357
2411
|
});
|
|
2358
2412
|
|
|
2413
|
+
// The client address as Express derives it through `trust proxy`: a
|
|
2414
|
+
// forwarded header counts only when a trusted proxy (by default one on
|
|
2415
|
+
// loopback) sent it, so a direct client cannot pick its own bucket.
|
|
2359
2416
|
function extractClientIp(req) {
|
|
2360
|
-
const
|
|
2361
|
-
|
|
2362
|
-
const firstHop = forwardedForHeader.split(',')[0].trim();
|
|
2363
|
-
const forwardedFor = firstHop.replace(/^for=/i, '').replace(/^"|"$/g, '').trim();
|
|
2364
|
-
if (forwardedFor) return forwardedFor.slice(0, 128);
|
|
2365
|
-
}
|
|
2366
|
-
const realIp = String(req.headers?.['x-real-ip'] || '').trim();
|
|
2367
|
-
if (realIp) return realIp.slice(0, 128);
|
|
2368
|
-
const reqIp = String(req.ip || req.socket?.remoteAddress || '').trim();
|
|
2369
|
-
return reqIp.slice(0, 128) || 'unknown';
|
|
2417
|
+
const ip = String(req.ip || req.socket?.remoteAddress || '').trim();
|
|
2418
|
+
return ip.slice(0, 128) || 'unknown';
|
|
2370
2419
|
}
|
|
2371
2420
|
|
|
2372
2421
|
function consumeSharedPresenceRateLimit(token, req, nowMs = Date.now()) {
|
|
@@ -2438,7 +2487,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2438
2487
|
: LIVE_WORKER_STATUSES.has(String(worker.status || '').trim().toLowerCase()));
|
|
2439
2488
|
const processingCount = Number(countProcessingConversationQueueRows.get(conversationId)?.count || 0);
|
|
2440
2489
|
const taskKeys = [...new Set([conversationId, workerSessionId])];
|
|
2441
|
-
|
|
2490
|
+
let liveTasks = workerAlive ? taskKeys.flatMap((key) => backgroundTaskStore?.get?.(key) || []) : [];
|
|
2491
|
+
// No published set, but the worker says it has background work the
|
|
2492
|
+
// relay was never told the details of: that counts, with no Stop button.
|
|
2493
|
+
if (workerAlive && !liveTasks.length && backgroundWorkView) liveTasks = backgroundWorkView.panelTasks(...taskKeys);
|
|
2442
2494
|
return {
|
|
2443
2495
|
turnRunning: processingCount > 0 && (workerAlive || !worker),
|
|
2444
2496
|
backgroundTaskCount: liveTasks.length,
|
|
@@ -2552,14 +2604,98 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2552
2604
|
resolveSessionStateRoot,
|
|
2553
2605
|
});
|
|
2554
2606
|
const orphanedUploads = collectOrphanedUploadsFromConversation(conversationId);
|
|
2607
|
+
const leftGroupId = conversationGroups?.forgetConversation?.(conversationId) || null;
|
|
2555
2608
|
hardDeleteConversationRows(conversationId);
|
|
2556
2609
|
deleteOrphanedUploads(orphanedUploads);
|
|
2557
2610
|
io.emit('conversation_deleted', { conversationId });
|
|
2611
|
+
if (leftGroupId) emitGroupChanged(leftGroupId, [conversationId]);
|
|
2612
|
+
try { relayEventService?.noteConversationGone?.(conversationId, { deleted: true }); } catch {}
|
|
2558
2613
|
finalized.push(conversationId);
|
|
2559
2614
|
}
|
|
2560
2615
|
return finalized;
|
|
2561
2616
|
}
|
|
2562
2617
|
|
|
2618
|
+
/**
|
|
2619
|
+
* A session group as the list payload carries it: name, display name (the
|
|
2620
|
+
* head's current title when none was given), head and member counts.
|
|
2621
|
+
*/
|
|
2622
|
+
function groupSummaryPayload(group) {
|
|
2623
|
+
if (!group) return null;
|
|
2624
|
+
const members = conversationGroups?.membersOf?.(group.id) || [];
|
|
2625
|
+
const head = group.parentConversationId
|
|
2626
|
+
? members.find((member) => member.id === group.parentConversationId) || null
|
|
2627
|
+
: null;
|
|
2628
|
+
return {
|
|
2629
|
+
id: group.id,
|
|
2630
|
+
name: group.name || null,
|
|
2631
|
+
displayName: resolveGroupDisplayName({
|
|
2632
|
+
name: group.name,
|
|
2633
|
+
headTitle: head ? resolveConversationTitle({ title: head.title, titleSource: 'auto' }) : null,
|
|
2634
|
+
}),
|
|
2635
|
+
parentConversationId: group.parentConversationId || null,
|
|
2636
|
+
memberCount: members.length,
|
|
2637
|
+
liveMemberCount: members.filter((member) => !member.archived).length,
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
/** The `group` field of a single conversation payload: the summary plus the members. */
|
|
2642
|
+
/**
|
|
2643
|
+
* The `group` field of a single conversation payload: the summary and the
|
|
2644
|
+
* conversation's role. The live members, with what each is doing, only
|
|
2645
|
+
* when asked for (`groupMembers=1`, the remote_relay tool's read_session)
|
|
2646
|
+
* and never for a paired relay's agent: the browser does not read them,
|
|
2647
|
+
* and archived members' titles stay on this relay.
|
|
2648
|
+
*/
|
|
2649
|
+
function conversationGroupPayload(conversationId, req = null) {
|
|
2650
|
+
const group = conversationGroups?.groupOfConversation?.(conversationId) || null;
|
|
2651
|
+
if (!group) return null;
|
|
2652
|
+
const summary = groupSummaryPayload(group);
|
|
2653
|
+
const payload = {
|
|
2654
|
+
...summary,
|
|
2655
|
+
role: conversationId === group.parentConversationId ? 'head' : 'member',
|
|
2656
|
+
};
|
|
2657
|
+
const wantsMembers = String(req?.query?.groupMembers || '').trim() === '1';
|
|
2658
|
+
const request = req ? readRemoteRelayRequest(req, remoteRelayInbound) : { remote: false };
|
|
2659
|
+
if (wantsMembers && (!request.remote || request.local === true)) {
|
|
2660
|
+
payload.members = (conversationGroups.membersOf(group.id) || [])
|
|
2661
|
+
.filter((member) => member.archived !== true)
|
|
2662
|
+
.slice(0, 50)
|
|
2663
|
+
.map((member) => ({
|
|
2664
|
+
id: member.id,
|
|
2665
|
+
title: resolveConversationTitle({ title: member.title, titleSource: 'auto' }),
|
|
2666
|
+
role: member.id === group.parentConversationId ? 'head' : 'member',
|
|
2667
|
+
state: describeSessionActivity(member.id)?.sessionState || 'idle',
|
|
2668
|
+
}));
|
|
2669
|
+
}
|
|
2670
|
+
return payload;
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
/** A conversation row's place in its group's hand order; null outside a group. */
|
|
2674
|
+
function conversationGroupPositionOf(conv) {
|
|
2675
|
+
if (!String(conv?.group_id || '').trim() || !conversationGroups?.getGroup?.(conv.group_id)) return null;
|
|
2676
|
+
return conversationGroupPosition(conv);
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
/**
|
|
2680
|
+
* Tells every page a group changed, with what the page can apply in place:
|
|
2681
|
+
* the group's summary (null once dissolved), the named conversations'
|
|
2682
|
+
* membership, the group's order (head first) and the positions of its
|
|
2683
|
+
* members and of the named conversations.
|
|
2684
|
+
*/
|
|
2685
|
+
function emitGroupChanged(groupId, conversationIds = []) {
|
|
2686
|
+
const ids = [...new Set(conversationIds.map((id) => String(id || '').trim()).filter(Boolean))];
|
|
2687
|
+
const group = groupId ? groupSummaryPayload(conversationGroups?.getGroup?.(groupId) || null) : null;
|
|
2688
|
+
const order = group ? conversationGroups?.orderOf?.(groupId) || [] : [];
|
|
2689
|
+
io.emit('conversation_group_changed', {
|
|
2690
|
+
groupId: groupId || null,
|
|
2691
|
+
conversationIds: ids,
|
|
2692
|
+
group,
|
|
2693
|
+
memberships: conversationGroups?.membershipsOf?.(ids) || {},
|
|
2694
|
+
order,
|
|
2695
|
+
positions: conversationGroups?.positionsOf?.([...order, ...ids]) || {},
|
|
2696
|
+
});
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2563
2699
|
/** The `cloud` field of a conversation payload; null unless it is a cloud one. */
|
|
2564
2700
|
function conversationCloudPayload(conversation, runtimeSession) {
|
|
2565
2701
|
return buildConversationCloudPayload({
|
|
@@ -2583,9 +2719,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2583
2719
|
function buildShareUrl(req, token) {
|
|
2584
2720
|
const shareToken = String(token || '').trim();
|
|
2585
2721
|
if (!shareToken) return '';
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
const
|
|
2722
|
+
// Host and scheme follow `trust proxy`: X-Forwarded-Host/-Proto only
|
|
2723
|
+
// from a trusted proxy, so a direct client cannot mint links elsewhere.
|
|
2724
|
+
const host = requestHostOf(req);
|
|
2725
|
+
const protocol = String(req.protocol || 'http').toLowerCase();
|
|
2589
2726
|
const basePath = String(remotePath || '').trim().replace(/\/+$/, '');
|
|
2590
2727
|
const relative = `${basePath}/shared/${shareToken}`.replace(/\/{2,}/g, '/');
|
|
2591
2728
|
if (!host) return relative;
|
|
@@ -2653,6 +2790,15 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2653
2790
|
};
|
|
2654
2791
|
}
|
|
2655
2792
|
|
|
2793
|
+
// A shared attachment is served by its share URL: the host path it is
|
|
2794
|
+
// stored at (and the `@path` reference built from it) stays on the relay.
|
|
2795
|
+
function shareSafeAttachment(attachment, shareToken) {
|
|
2796
|
+
const rewritten = rewriteSharedGeneratedImageAttachmentContentUrl(rewriteSharedAttachmentContentUrl(attachment, shareToken), shareToken);
|
|
2797
|
+
if (!rewritten || typeof rewritten !== 'object') return rewritten;
|
|
2798
|
+
const { path: _path, reference: _reference, ...rest } = rewritten;
|
|
2799
|
+
return rest;
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2656
2802
|
function buildConversationPayloadForShare({
|
|
2657
2803
|
conv,
|
|
2658
2804
|
shareToken = '',
|
|
@@ -2662,6 +2808,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2662
2808
|
afterMessageId = '',
|
|
2663
2809
|
afterTimestamp = '',
|
|
2664
2810
|
aroundMessageId = '',
|
|
2811
|
+
// Filled with the tokens of the transcript's preview cards, for the route
|
|
2812
|
+
// to redact wherever a reply or tool line quotes them.
|
|
2813
|
+
previewTokens = null,
|
|
2665
2814
|
} = {}) {
|
|
2666
2815
|
const resolvedConversationId = String(conv?.id || '').trim();
|
|
2667
2816
|
if (!resolvedConversationId) return null;
|
|
@@ -2704,10 +2853,22 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2704
2853
|
.filter((m) => m.role === 'assistant')
|
|
2705
2854
|
.map((m) => [m.id, workflowRunsForResponse ? workflowRunsForResponse(m.id) : []]),
|
|
2706
2855
|
);
|
|
2856
|
+
// A preview link is a capability that now outlives the turn and the
|
|
2857
|
+
// restart: a public transcript keeps only the label, never token or link.
|
|
2707
2858
|
const previewCardsByMessageId = new Map(
|
|
2708
2859
|
dbMessages
|
|
2709
2860
|
.filter((m) => m.role === 'assistant')
|
|
2710
|
-
.map((m) => [m.id, previewCardsForResponse ? previewCardsForResponse(m.id) : []
|
|
2861
|
+
.map((m) => [m.id, (previewCardsForResponse ? previewCardsForResponse(m.id) : [])
|
|
2862
|
+
.filter((card) => card && typeof card === 'object')
|
|
2863
|
+
.map((card) => {
|
|
2864
|
+
if (card.token) previewTokens?.add?.(String(card.token));
|
|
2865
|
+
return card;
|
|
2866
|
+
})
|
|
2867
|
+
.map((card) => ({
|
|
2868
|
+
label: String(card.label || '').trim().slice(0, 120) || 'Preview',
|
|
2869
|
+
mode: card.mode === 'static' ? 'static' : 'port',
|
|
2870
|
+
redacted: true,
|
|
2871
|
+
}))]),
|
|
2711
2872
|
);
|
|
2712
2873
|
const usageByResponseMessageId = new Map(
|
|
2713
2874
|
(stmts.listMessageUsageSnapshotsByConversation?.all(conv.id) || [])
|
|
@@ -2720,7 +2881,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2720
2881
|
attachments: parseAttachments(message.attachments)
|
|
2721
2882
|
.map(hydrateAttachment)
|
|
2722
2883
|
.filter(Boolean)
|
|
2723
|
-
.map((attachment) =>
|
|
2884
|
+
.map((attachment) => shareSafeAttachment(attachment, shareToken)),
|
|
2724
2885
|
})),
|
|
2725
2886
|
transcriptMessages: [],
|
|
2726
2887
|
relayActivitiesByMessageId,
|
|
@@ -2742,7 +2903,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2742
2903
|
? { ...withSource, origin: publicRemoteRelayOrigin(withSource.origin) }
|
|
2743
2904
|
: withSource;
|
|
2744
2905
|
const attachments = Array.isArray(nextMessage?.attachments)
|
|
2745
|
-
? nextMessage.attachments.map((attachment) =>
|
|
2906
|
+
? nextMessage.attachments.map((attachment) => shareSafeAttachment(attachment, shareToken))
|
|
2746
2907
|
: nextMessage?.attachments;
|
|
2747
2908
|
return attachments === nextMessage?.attachments
|
|
2748
2909
|
? nextMessage
|
|
@@ -2764,6 +2925,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2764
2925
|
compactedInto: conv.compacted_into || null,
|
|
2765
2926
|
compactedFrom: conv.compacted_from || null,
|
|
2766
2927
|
runtimeSession: null,
|
|
2928
|
+
// The provider alone, null when the conversation has none bound: the
|
|
2929
|
+
// view tells a reply that ran elsewhere from one that ran here by it.
|
|
2930
|
+
// Not private, the model chip names it as well.
|
|
2931
|
+
runtimeProviderType: String(stmts.getRuntimeSessionByConversation?.get?.(resolvedConversationId)?.provider_type || '').trim().toLowerCase() || null,
|
|
2767
2932
|
sessionRootPath: null,
|
|
2768
2933
|
sessionRootName: resolvedTitle || 'Session',
|
|
2769
2934
|
configuredWorkspaceRootPath: null,
|
|
@@ -2836,17 +3001,26 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2836
3001
|
const limit = normalizeConversationListLimit(req.query.limit, DEFAULT_CONVERSATION_LIST_LIMIT);
|
|
2837
3002
|
const beforeConversationId = String(req.query.beforeConversationId || '').trim();
|
|
2838
3003
|
const beforeUpdatedAt = String(req.query.beforeUpdatedAt || '').trim();
|
|
3004
|
+
const beforeGroupId = String(req.query.beforeGroupId || '').trim();
|
|
2839
3005
|
const rows = stmts.listConvs.all(archivedMode === 'true' ? 1 : 0, archivedMode === 'only' ? 1 : 0);
|
|
3006
|
+
// The groups of the listed rows: the order inside a group needs its head.
|
|
3007
|
+
const groups = conversationGroups?.listGroupsByIds?.(rows.map((row) => row?.group_id)) || new Map();
|
|
3008
|
+
// The time a row sorts by (its group's newest row): a caller that pages
|
|
3009
|
+
// by a row that is gone by then continues from this, not the row's own.
|
|
3010
|
+
const activityTimes = groupActivityTimes(rows);
|
|
2840
3011
|
const page = selectConversationListPage(rows, {
|
|
2841
3012
|
limit,
|
|
2842
3013
|
beforeConversationId,
|
|
2843
3014
|
beforeUpdatedAt,
|
|
3015
|
+
beforeGroupId,
|
|
3016
|
+
groups,
|
|
2844
3017
|
});
|
|
2845
3018
|
const activeTurnConversationIds = new Set(
|
|
2846
3019
|
(stmts.listConversationIdsWithActiveQueue?.all() || [])
|
|
2847
3020
|
.map((row) => String(row?.conversation_id || '').trim())
|
|
2848
3021
|
.filter(Boolean),
|
|
2849
3022
|
);
|
|
3023
|
+
const busyCandidates = sessionActivityTracker ? activityCandidates(activeTurnConversationIds) : null;
|
|
2850
3024
|
const conversations = page.rows.map((r) => {
|
|
2851
3025
|
const sid = String(r.sdk_session_id || '').trim();
|
|
2852
3026
|
const workspaceState = typeof resolveConversationWorkspaceState === 'function'
|
|
@@ -2865,6 +3039,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2865
3039
|
sdkSessionId: sid || null,
|
|
2866
3040
|
title: resolveConversationTitle({ title: r.title, titleSource: r.title_source }),
|
|
2867
3041
|
activeTurn: activeTurnConversationIds.has(String(r.id || '').trim()),
|
|
3042
|
+
...(describeSessionActivity(r.id, busyCandidates) || {}),
|
|
2868
3043
|
archived: Number(r.archived || 0) === 1,
|
|
2869
3044
|
compactedInto: r.compacted_into || null,
|
|
2870
3045
|
compactedFrom: r.compacted_from || null,
|
|
@@ -2898,12 +3073,30 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2898
3073
|
cloudSourceJson: r.cloud_source_json,
|
|
2899
3074
|
costUsd: r.runtime_claude_cloud_cost_usd,
|
|
2900
3075
|
}),
|
|
3076
|
+
// Session group: its id and whether this row is the group's head.
|
|
3077
|
+
groupId: groups.has(String(r.group_id || '')) ? String(r.group_id) : null,
|
|
3078
|
+
groupRole: groups.has(String(r.group_id || '')) ? conversationGroupRole({ id: r.id, groupId: r.group_id }, groups) : null,
|
|
3079
|
+
// Its place in the group's hand order; null for the head, for a
|
|
3080
|
+
// group nobody reordered, and outside a group.
|
|
3081
|
+
groupPosition: groups.has(String(r.group_id || '')) ? conversationGroupPosition(r) : null,
|
|
3082
|
+
activityAt: (() => {
|
|
3083
|
+
const own = normalizeConversationTimestampMs(r.updated_at);
|
|
3084
|
+
const ms = groups.has(String(r.group_id || '')) ? Math.max(activityTimes.get(String(r.group_id)) || 0, own) : own;
|
|
3085
|
+
return ms ? new Date(ms).toISOString() : normalizeConversationTimestampIso(r.updated_at, r.updated_at);
|
|
3086
|
+
})(),
|
|
2901
3087
|
};
|
|
2902
3088
|
});
|
|
3089
|
+
// The groups that have a row on this page, with what the sidebar's head
|
|
3090
|
+
// row shows without the members' rows.
|
|
3091
|
+
const groupsPayload = {};
|
|
3092
|
+
for (const groupId of new Set(page.rows.map((row) => String(row?.group_id || '')).filter((id) => groups.has(id)))) {
|
|
3093
|
+
groupsPayload[groupId] = groupSummaryPayload(groups.get(groupId));
|
|
3094
|
+
}
|
|
2903
3095
|
|
|
2904
3096
|
return res.json({
|
|
2905
3097
|
conversations,
|
|
2906
3098
|
pageInfo: page.pageInfo,
|
|
3099
|
+
groups: groupsPayload,
|
|
2907
3100
|
});
|
|
2908
3101
|
});
|
|
2909
3102
|
|
|
@@ -3622,123 +3815,398 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3622
3815
|
draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
|
|
3623
3816
|
origin: parseRemoteRelayOriginJson(conv.origin_json),
|
|
3624
3817
|
cloud: conversationCloudPayload(conv, runtimeSession),
|
|
3818
|
+
group: conversationGroupPayload(conversationId),
|
|
3819
|
+
groupPosition: conversationGroupPositionOf(conv),
|
|
3625
3820
|
messages: history.messages,
|
|
3626
3821
|
pageInfo: history.pageInfo,
|
|
3627
3822
|
// A re-import can move a pin onto a new message id.
|
|
3628
3823
|
pins: listConversationPins(conversationId),
|
|
3629
3824
|
pinsRevision: conversationPinRevision(conversationId),
|
|
3825
|
+
// And a hidden mark.
|
|
3826
|
+
hiddenMessages: listConversationHiddenMessages(conversationId),
|
|
3827
|
+
hiddenMessagesRevision: conversationHiddenMessagesRevision(conversationId),
|
|
3630
3828
|
refreshed: true,
|
|
3631
3829
|
});
|
|
3632
3830
|
});
|
|
3633
3831
|
|
|
3832
|
+
function shareLinkPayload(req, share) {
|
|
3833
|
+
const token = String(share?.token || '').trim();
|
|
3834
|
+
return {
|
|
3835
|
+
token,
|
|
3836
|
+
shareUrl: buildShareUrl(req, token),
|
|
3837
|
+
path: `${String(remotePath || '').replace(/\/+$/, '')}/shared/${token}`.replace(/\/{2,}/g, '/'),
|
|
3838
|
+
};
|
|
3839
|
+
}
|
|
3840
|
+
|
|
3841
|
+
function findShareableConversation(conversationId) {
|
|
3842
|
+
const conv = conversationId ? stmts.getConvAnyStatus.get(conversationId) || null : null;
|
|
3843
|
+
if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') return null;
|
|
3844
|
+
return conv;
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3847
|
+
// Get-or-create: a conversation has one active share, and asking again
|
|
3848
|
+
// returns its link.
|
|
3634
3849
|
app.post('/api/conversation/:id/share', auth, (req, res) => {
|
|
3635
3850
|
const conversationId = String(req.params.id || '').trim();
|
|
3636
3851
|
if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
|
|
3637
|
-
|
|
3638
|
-
if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') {
|
|
3852
|
+
if (!findShareableConversation(conversationId)) {
|
|
3639
3853
|
return res.status(404).json({ error: 'Conversation not found' });
|
|
3640
3854
|
}
|
|
3641
|
-
|
|
3642
|
-
if (!share) {
|
|
3643
|
-
const now = new Date().toISOString();
|
|
3644
|
-
let attempts = 0;
|
|
3645
|
-
while (!share && attempts < 6) {
|
|
3646
|
-
const token = buildConversationShareToken();
|
|
3647
|
-
try {
|
|
3648
|
-
stmts.insertConversationShare.run(token, conversationId, now, now);
|
|
3649
|
-
share = stmts.getConversationShareByToken.get(token) || null;
|
|
3650
|
-
} catch (error) {
|
|
3651
|
-
if (String(error?.code || '') !== 'SQLITE_CONSTRAINT_PRIMARYKEY') throw error;
|
|
3652
|
-
}
|
|
3653
|
-
attempts += 1;
|
|
3654
|
-
}
|
|
3655
|
-
}
|
|
3855
|
+
const { share, created } = shares().getOrCreateShare(conversationId, buildConversationShareToken, new Date().toISOString());
|
|
3656
3856
|
if (!share?.token) return res.status(500).json({ error: 'Failed to create share token' });
|
|
3657
|
-
|
|
3658
|
-
stmts.touchConversationShare?.run(now, share.token);
|
|
3659
|
-
const token = String(share.token || '').trim();
|
|
3857
|
+
if (created) io.emit('share_created', { conversationId });
|
|
3660
3858
|
res.setHeader('Cache-Control', 'no-store');
|
|
3661
|
-
res.json({
|
|
3662
|
-
ok: true,
|
|
3663
|
-
conversationId,
|
|
3664
|
-
token,
|
|
3665
|
-
shareUrl: buildShareUrl(req, token),
|
|
3666
|
-
path: `${String(remotePath || '').replace(/\/+$/, '')}/shared/${token}`.replace(/\/{2,}/g, '/'),
|
|
3667
|
-
});
|
|
3859
|
+
res.json({ ok: true, conversationId, created, ...shareLinkPayload(req, share) });
|
|
3668
3860
|
});
|
|
3669
3861
|
|
|
3670
|
-
//
|
|
3671
|
-
//
|
|
3672
|
-
|
|
3862
|
+
// A new link for the active share: the old token answers 404 from now on,
|
|
3863
|
+
// the access log carries over.
|
|
3864
|
+
app.post('/api/conversation/:id/share/renew', auth, (req, res) => {
|
|
3865
|
+
const conversationId = String(req.params.id || '').trim();
|
|
3866
|
+
if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
|
|
3867
|
+
if (!findShareableConversation(conversationId)) {
|
|
3868
|
+
return res.status(404).json({ error: 'Conversation not found' });
|
|
3869
|
+
}
|
|
3870
|
+
const renewed = shares().renewShare(conversationId, buildConversationShareToken, new Date().toISOString());
|
|
3871
|
+
if (!renewed?.share?.token) return res.status(404).json({ error: 'No active share to renew' });
|
|
3872
|
+
io.emit('share_renewed', { conversationId });
|
|
3873
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3874
|
+
res.json({ ok: true, conversationId, renewed: true, ...shareLinkPayload(req, renewed.share) });
|
|
3875
|
+
});
|
|
3876
|
+
|
|
3877
|
+
// Stop sharing: every token of the conversation is revoked and its access
|
|
3878
|
+
// log deleted. An open shared page gets a 404 at its next poll.
|
|
3673
3879
|
app.delete('/api/conversation/:id/share', auth, (req, res) => {
|
|
3674
3880
|
const conversationId = String(req.params.id || '').trim();
|
|
3675
3881
|
if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
|
|
3676
|
-
|
|
3677
|
-
if (!share?.token) {
|
|
3882
|
+
if (!shares().findActiveShare(conversationId)) {
|
|
3678
3883
|
return res.status(404).json({ error: 'No active share to revoke' });
|
|
3679
3884
|
}
|
|
3680
3885
|
const now = new Date().toISOString();
|
|
3681
|
-
const result =
|
|
3682
|
-
// Drop any live shared viewers so they lose access immediately.
|
|
3886
|
+
const result = shares().stopSharing(conversationId, now);
|
|
3683
3887
|
io.emit('share_revoked', { conversationId });
|
|
3684
3888
|
res.setHeader('Cache-Control', 'no-store');
|
|
3685
|
-
res.json({ ok: true, conversationId, revokedAt: now, revokedCount:
|
|
3889
|
+
res.json({ ok: true, conversationId, revokedAt: now, revokedCount: result.revokedCount, opensDeleted: result.opensDeleted });
|
|
3686
3890
|
});
|
|
3687
3891
|
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3892
|
+
// Every active share, for Settings → Sharing.
|
|
3893
|
+
app.get('/api/shares', auth, (req, res) => {
|
|
3894
|
+
const list = shares().listActiveShares().map((share) => ({
|
|
3895
|
+
conversationId: share.conversationId,
|
|
3896
|
+
title: share.title,
|
|
3897
|
+
archived: share.archived,
|
|
3898
|
+
sharedSince: share.sharedSince,
|
|
3899
|
+
// The current link's date, when it is a renewed one.
|
|
3900
|
+
renewedAt: share.createdAt && share.createdAt !== share.sharedSince ? share.createdAt : null,
|
|
3901
|
+
lastOpenedAt: share.lastOpenedAt,
|
|
3902
|
+
openCount: share.openCount,
|
|
3903
|
+
viewerCount: share.viewerCount,
|
|
3904
|
+
...shareLinkPayload(req, share),
|
|
3905
|
+
}));
|
|
3906
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3907
|
+
res.json({ shares: list, storeViewerIp: shares().readStoreViewerIp(), accessLog: shares().logAvailable });
|
|
3908
|
+
});
|
|
3909
|
+
|
|
3910
|
+
// The access log of a conversation's active share, newest first.
|
|
3911
|
+
app.get('/api/conversation/:id/share/opens', auth, (req, res) => {
|
|
3912
|
+
const conversationId = String(req.params.id || '').trim();
|
|
3913
|
+
const share = conversationId ? shares().findActiveShare(conversationId) : null;
|
|
3914
|
+
if (!share) return res.status(404).json({ error: 'No active share' });
|
|
3915
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3916
|
+
res.json({ conversationId, ...shares().listOpens(share.lineageId, { limit: req.query.limit }) });
|
|
3917
|
+
});
|
|
3918
|
+
|
|
3919
|
+
app.get('/api/settings/shares', auth, (req, res) => {
|
|
3920
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3921
|
+
res.json({ storeViewerIp: shares().readStoreViewerIp() });
|
|
3922
|
+
});
|
|
3923
|
+
|
|
3924
|
+
// "Store viewer IP addresses": off keeps the address out of new access log
|
|
3925
|
+
// rows and out of the status events of shared reads. Rows logged before
|
|
3926
|
+
// keep theirs.
|
|
3927
|
+
app.post('/api/settings/shares', auth, (req, res) => {
|
|
3928
|
+
if (typeof req.body?.storeViewerIp !== 'boolean') {
|
|
3929
|
+
return res.status(400).json({ error: 'storeViewerIp must be true or false' });
|
|
3694
3930
|
}
|
|
3695
|
-
const
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3931
|
+
const storeViewerIp = shares().writeStoreViewerIp(req.body.storeViewerIp);
|
|
3932
|
+
io.emit('share_settings_changed', { storeViewerIp });
|
|
3933
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3934
|
+
res.json({ storeViewerIp });
|
|
3935
|
+
});
|
|
3936
|
+
|
|
3937
|
+
// The viewer addresses stored before IP storage was switched off: how many,
|
|
3938
|
+
// and removing them (Settings → Sharing offers it when the switch goes off).
|
|
3939
|
+
app.get('/api/settings/shares/stored-viewer-ips', auth, (_req, res) => {
|
|
3940
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3941
|
+
res.json({ count: shares().countStoredViewerIps() });
|
|
3942
|
+
});
|
|
3943
|
+
|
|
3944
|
+
app.delete('/api/settings/shares/stored-viewer-ips', auth, (_req, res) => {
|
|
3945
|
+
const cleared = shares().forgetStoredViewerIps();
|
|
3946
|
+
io.emit('share_viewer_ips_forgotten', cleared);
|
|
3947
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3948
|
+
res.json({ ok: true, cleared, count: shares().countStoredViewerIps() });
|
|
3949
|
+
});
|
|
3950
|
+
|
|
3951
|
+
// ─── Path privacy for shared views ─────────────────────────────────────────
|
|
3952
|
+
// A share is served through one redactor per response
|
|
3953
|
+
// (services/share-path-redaction.mjs): the relay's own roots, the
|
|
3954
|
+
// conversation's workspace roots and the OS user name are what it knows to
|
|
3955
|
+
// look for. The owner's own routes never pass through it.
|
|
3956
|
+
function sharePathRedactorFor(conv, { annotate = false } = {}) {
|
|
3957
|
+
let known = null;
|
|
3958
|
+
try { known = sharePathKnownRoots?.() || null; } catch { known = null; }
|
|
3959
|
+
const roots = [...(Array.isArray(known?.roots) ? known.roots : [])];
|
|
3960
|
+
const conversationId = String(conv?.id || '').trim();
|
|
3961
|
+
try {
|
|
3962
|
+
const state = typeof resolveConversationWorkspaceState === 'function'
|
|
3963
|
+
? resolveConversationWorkspaceState({
|
|
3964
|
+
conversationId,
|
|
3965
|
+
sdkSessionId: conv?.sdk_session_id || conversationId,
|
|
3966
|
+
discoveredWorkspaceRootPath: '',
|
|
3967
|
+
})
|
|
3968
|
+
: null;
|
|
3969
|
+
roots.push(state?.configuredWorkspaceRootPath, state?.runtimeWorkspaceRootPath, state?.currentWorkspaceRootPath);
|
|
3970
|
+
} catch {}
|
|
3971
|
+
roots.push(...(Array.isArray(workspaceRootAllowList) ? workspaceRootAllowList : []));
|
|
3972
|
+
let mode = DEFAULT_SHARE_PATH_PRIVACY_MODE;
|
|
3973
|
+
try { mode = getSharePathPrivacyMode(); } catch {}
|
|
3974
|
+
return createSharePathRedactor({
|
|
3975
|
+
mode,
|
|
3976
|
+
roots: roots.filter(Boolean),
|
|
3977
|
+
userNames: Array.isArray(known?.userNames) ? known.userNames : [],
|
|
3978
|
+
relayPathPrefix: remotePath,
|
|
3979
|
+
annotate,
|
|
3980
|
+
});
|
|
3981
|
+
}
|
|
3982
|
+
|
|
3983
|
+
// Every preview token a shared response must not carry: the relay's known
|
|
3984
|
+
// previews and the conversation's own. Null when the list cannot be read;
|
|
3985
|
+
// then nothing is shared.
|
|
3986
|
+
function collectSharePreviewTokens(conversationId) {
|
|
3987
|
+
const tokens = new Set();
|
|
3988
|
+
// Every preview of the relay, not only this conversation's: the preview
|
|
3989
|
+
// tool's list covers them all, and a reply may quote any of its tokens.
|
|
3990
|
+
try {
|
|
3991
|
+
for (const knownToken of listKnownPreviewTokens?.() || []) tokens.add(String(knownToken));
|
|
3992
|
+
} catch {
|
|
3993
|
+
return null;
|
|
3994
|
+
}
|
|
3995
|
+
for (const preview of listConversationPreviews?.(conversationId) || []) {
|
|
3996
|
+
if (preview?.token) tokens.add(String(preview.token));
|
|
3997
|
+
}
|
|
3998
|
+
return tokens;
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
function sharedHistoryPageQuery(query = {}) {
|
|
4002
|
+
return {
|
|
4003
|
+
limit: normalizeConversationHistoryLimit(query.limit, 120),
|
|
4004
|
+
beforeMessageId: String(query.beforeMessageId || '').trim(),
|
|
4005
|
+
beforeTimestamp: String(query.beforeTimestamp || '').trim(),
|
|
4006
|
+
afterMessageId: String(query.afterMessageId || '').trim(),
|
|
4007
|
+
afterTimestamp: String(query.afterTimestamp || '').trim(),
|
|
4008
|
+
aroundMessageId: String(query.aroundMessageId || '').trim(),
|
|
4009
|
+
};
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
// A revoked or renewed token is a plain 404, like one that never existed.
|
|
4013
|
+
function resolveActiveShare(rawToken) {
|
|
4014
|
+
const token = normalizeShareToken(rawToken);
|
|
4015
|
+
if (!token) return null;
|
|
4016
|
+
const share = shares().findLiveShare(token);
|
|
4017
|
+
const conversationId = String(share?.conversationId || '').trim();
|
|
4018
|
+
if (!conversationId) return null;
|
|
4019
|
+
return { token, share, conversationId };
|
|
4020
|
+
}
|
|
4021
|
+
|
|
4022
|
+
function resolveShareableConversation(conversationId) {
|
|
4023
|
+
const conv = stmts.getConvAnyStatus.get(conversationId) || null;
|
|
4024
|
+
if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') return null;
|
|
4025
|
+
return conv;
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
app.get('/api/shared/:token', (req, res) => {
|
|
4029
|
+
const active = resolveActiveShare(req.params.token);
|
|
4030
|
+
if (!active) return res.status(404).json({ error: 'Shared conversation not found' });
|
|
4031
|
+
const { token, share, conversationId: convId } = active;
|
|
4032
|
+
const conv = resolveShareableConversation(convId);
|
|
4033
|
+
if (!conv) return res.status(404).json({ error: 'Shared conversation not found' });
|
|
4034
|
+
const previewTokens = collectSharePreviewTokens(convId);
|
|
4035
|
+
if (!previewTokens) {
|
|
4036
|
+
res.setHeader('Retry-After', '15');
|
|
4037
|
+
return res.status(503).json({ error: 'Shared conversation is not available right now' });
|
|
3700
4038
|
}
|
|
3701
|
-
const limit = normalizeConversationHistoryLimit(req.query.limit, 120);
|
|
3702
|
-
const beforeMessageId = String(req.query.beforeMessageId || '').trim();
|
|
3703
|
-
const beforeTimestamp = String(req.query.beforeTimestamp || '').trim();
|
|
3704
|
-
const afterMessageId = String(req.query.afterMessageId || '').trim();
|
|
3705
|
-
const afterTimestamp = String(req.query.afterTimestamp || '').trim();
|
|
3706
|
-
const aroundMessageId = String(req.query.aroundMessageId || '').trim();
|
|
3707
4039
|
const payload = buildConversationPayloadForShare({
|
|
3708
4040
|
conv,
|
|
3709
4041
|
shareToken: token,
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
beforeTimestamp,
|
|
3713
|
-
afterMessageId,
|
|
3714
|
-
afterTimestamp,
|
|
3715
|
-
aroundMessageId,
|
|
4042
|
+
...sharedHistoryPageQuery(req.query),
|
|
4043
|
+
previewTokens,
|
|
3716
4044
|
});
|
|
3717
4045
|
if (!payload) return res.status(404).json({ error: 'Shared conversation not found' });
|
|
4046
|
+
// The access log: one row per open, not per poll (the page's first fetch
|
|
4047
|
+
// says `open=1`; see share-access-log-service.mjs). A failing log never
|
|
4048
|
+
// keeps a reader out.
|
|
4049
|
+
const viewer = readShareRequestViewer(req);
|
|
4050
|
+
const storeIp = shares().readStoreViewerIp();
|
|
4051
|
+
let viewerKey = '';
|
|
4052
|
+
try {
|
|
4053
|
+
const noted = shareAccessLog().noteFetch({ share, viewer, opened: String(req.query.open || '') === '1', storeIp });
|
|
4054
|
+
viewerKey = noted.viewerKey;
|
|
4055
|
+
if (noted.recorded) io.emit('share_opened', { conversationId: convId });
|
|
4056
|
+
} catch (error) {
|
|
4057
|
+
console.warn(`[shares] could not log a shared read: ${error?.message || error}`);
|
|
4058
|
+
}
|
|
3718
4059
|
const sharedAccess = statusEventService.recordSharedAccess({
|
|
3719
4060
|
shareToken: token,
|
|
3720
|
-
viewerIp:
|
|
4061
|
+
viewerIp: storeIp ? viewer.ip : null,
|
|
4062
|
+
viewerKey,
|
|
3721
4063
|
conversationId: convId,
|
|
3722
4064
|
});
|
|
3723
4065
|
if (sharedAccess.event) {
|
|
3724
4066
|
const details = sharedAccess.event.details;
|
|
3725
|
-
console.log(`SHARED ACCESS shareId=${details.shareId} ip=${details.viewerIp || 'unknown'}`);
|
|
4067
|
+
console.log(`SHARED ACCESS shareId=${details.shareId} ip=${storeIp ? details.viewerIp || 'unknown' : 'not stored'}`);
|
|
3726
4068
|
io.emit('shared_access', sharedAccess.event);
|
|
3727
4069
|
}
|
|
3728
4070
|
const now = new Date().toISOString();
|
|
3729
4071
|
stmts.touchConversationShare?.run(now, token);
|
|
3730
4072
|
res.setHeader('Cache-Control', 'no-store');
|
|
3731
4073
|
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
3732
|
-
|
|
4074
|
+
// A preview link is a public capability that outlives the turn: no string
|
|
4075
|
+
// of a shared transcript (reply text, thoughts, tool lines, sub-agent runs)
|
|
4076
|
+
// carries one. One pass over the whole response, not a list of fields;
|
|
4077
|
+
// then the host's paths, by the relay's path privacy setting.
|
|
4078
|
+
const redactor = sharePathRedactorFor(conv);
|
|
4079
|
+
res.json(redactor.payload(redactPreviewTokens({
|
|
3733
4080
|
...payload,
|
|
3734
4081
|
shared: {
|
|
3735
4082
|
token,
|
|
3736
4083
|
readOnly: true,
|
|
3737
4084
|
watcherCount: Number(getSharedWatcherCount?.(convId) || 0),
|
|
3738
4085
|
},
|
|
4086
|
+
}, { tokens: [...previewTokens] })));
|
|
4087
|
+
});
|
|
4088
|
+
|
|
4089
|
+
// The owner's "Preview as shared": the payload a share link would serve, with
|
|
4090
|
+
// every redaction and every leftover that still looks like a path marked
|
|
4091
|
+
// (SHARE_PREVIEW_MARKS). Works without a share link and never creates one;
|
|
4092
|
+
// records no access. Content URLs stay the owner's.
|
|
4093
|
+
app.get('/api/conversation/:id/share-preview', auth, (req, res) => {
|
|
4094
|
+
const conversationId = String(req.params.id || '').trim();
|
|
4095
|
+
const conv = conversationId ? resolveShareableConversation(conversationId) : null;
|
|
4096
|
+
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
4097
|
+
const previewTokens = collectSharePreviewTokens(conversationId);
|
|
4098
|
+
if (!previewTokens) {
|
|
4099
|
+
res.setHeader('Retry-After', '15');
|
|
4100
|
+
return res.status(503).json({ error: 'The preview is not available right now' });
|
|
4101
|
+
}
|
|
4102
|
+
const payload = buildConversationPayloadForShare({
|
|
4103
|
+
conv,
|
|
4104
|
+
shareToken: '',
|
|
4105
|
+
...sharedHistoryPageQuery(req.query),
|
|
4106
|
+
previewTokens,
|
|
4107
|
+
});
|
|
4108
|
+
if (!payload) return res.status(404).json({ error: 'Conversation not found' });
|
|
4109
|
+
const redactor = sharePathRedactorFor(conv, { annotate: true });
|
|
4110
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
4111
|
+
res.json({
|
|
4112
|
+
...redactor.payload(redactPreviewTokens(payload, { tokens: [...previewTokens] })),
|
|
4113
|
+
shared: {
|
|
4114
|
+
token: null,
|
|
4115
|
+
readOnly: true,
|
|
4116
|
+
preview: true,
|
|
4117
|
+
pathPrivacy: redactor.mode,
|
|
4118
|
+
watcherCount: 0,
|
|
4119
|
+
},
|
|
3739
4120
|
});
|
|
3740
4121
|
});
|
|
3741
4122
|
|
|
4123
|
+
// ─── Sub-agent records in a shared view ────────────────────────────────────
|
|
4124
|
+
const sharedSubagentTimeline = createSubagentTimelineService({ db, stmts });
|
|
4125
|
+
|
|
4126
|
+
// The runs a shared view shows a row for: those of the replies it shows,
|
|
4127
|
+
// and those of the turn in flight.
|
|
4128
|
+
function sharedConversationRunIds(conversationId) {
|
|
4129
|
+
const ids = new Set();
|
|
4130
|
+
const add = (run) => {
|
|
4131
|
+
const id = String(run?.subagentRunId || run?.id || '').trim();
|
|
4132
|
+
if (id) ids.add(id);
|
|
4133
|
+
};
|
|
4134
|
+
const rows = filterMessagesVisibleToSharedView(
|
|
4135
|
+
(stmts.getSharedMessages || stmts.getMessages).all(conversationId),
|
|
4136
|
+
);
|
|
4137
|
+
for (const row of rows) {
|
|
4138
|
+
if (row?.role !== 'assistant') continue;
|
|
4139
|
+
for (const run of (subagentRunsForResponse ? subagentRunsForResponse(row.id) : []) || []) add(run);
|
|
4140
|
+
}
|
|
4141
|
+
for (const run of inFlightStateForConversation(conversationId)?.subagentRuns || []) add(run);
|
|
4142
|
+
return ids;
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
// A run of the conversation whose row the shared view shows, or a child of
|
|
4146
|
+
// one: a share never opens a run of another conversation or of a reply the
|
|
4147
|
+
// owner hid from shares.
|
|
4148
|
+
function shareMayReadSubagentRun(conversationId, run) {
|
|
4149
|
+
if (!run || String(run.conversation_id || '') !== conversationId) return false;
|
|
4150
|
+
const visible = sharedConversationRunIds(conversationId);
|
|
4151
|
+
let current = run;
|
|
4152
|
+
for (let depth = 0; current && depth < 16; depth += 1) {
|
|
4153
|
+
if (visible.has(String(current.id || ''))) return true;
|
|
4154
|
+
const parentId = String(current.parent_subagent_id || '').trim();
|
|
4155
|
+
if (!parentId) return false;
|
|
4156
|
+
current = stmts.getSubagentRun?.get?.(parentId) || null;
|
|
4157
|
+
if (current && String(current.conversation_id || '') !== conversationId) return false;
|
|
4158
|
+
}
|
|
4159
|
+
return false;
|
|
4160
|
+
}
|
|
4161
|
+
|
|
4162
|
+
// A reader cannot stop a run: no stop flag, no task handle.
|
|
4163
|
+
function readerRunHeader(header) {
|
|
4164
|
+
if (!header || typeof header !== 'object') return header;
|
|
4165
|
+
const { taskId: _taskId, ...rest } = header;
|
|
4166
|
+
return { ...rest, stoppable: false };
|
|
4167
|
+
}
|
|
4168
|
+
|
|
4169
|
+
function sharedSubagentRecord(req, res, { conv, conversationId, annotate }) {
|
|
4170
|
+
const runId = sanitizeSubagentRunId(req.params.subagentRunId);
|
|
4171
|
+
const run = runId ? stmts.getSubagentRun?.get?.(runId) || null : null;
|
|
4172
|
+
if (!run || !shareMayReadSubagentRun(conversationId, run)) {
|
|
4173
|
+
return res.status(404).json({ error: 'Sub-agent run not found' });
|
|
4174
|
+
}
|
|
4175
|
+
const previewTokens = collectSharePreviewTokens(conversationId);
|
|
4176
|
+
if (!previewTokens) {
|
|
4177
|
+
res.setHeader('Retry-After', '15');
|
|
4178
|
+
return res.status(503).json({ error: 'Shared conversation is not available right now' });
|
|
4179
|
+
}
|
|
4180
|
+
const page = sharedSubagentTimeline.readTimeline(run, {
|
|
4181
|
+
afterSeq: Number(req.query?.after) || 0,
|
|
4182
|
+
limit: Number(req.query?.limit) || TIMELINE_READ_DEFAULT_LIMIT,
|
|
4183
|
+
isStoppable: () => false,
|
|
4184
|
+
});
|
|
4185
|
+
const readerPage = {
|
|
4186
|
+
...page,
|
|
4187
|
+
run: readerRunHeader(page.run),
|
|
4188
|
+
children: (Array.isArray(page.children) ? page.children : []).map(readerRunHeader),
|
|
4189
|
+
};
|
|
4190
|
+
const redactor = sharePathRedactorFor(conv, { annotate });
|
|
4191
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
4192
|
+
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
4193
|
+
return res.json(redactor.payload(redactPreviewTokens(readerPage, { tokens: [...previewTokens] })));
|
|
4194
|
+
}
|
|
4195
|
+
|
|
4196
|
+
app.get('/api/shared/:token/subagent/:subagentRunId', (req, res) => {
|
|
4197
|
+
const active = resolveActiveShare(req.params.token);
|
|
4198
|
+
const conv = active ? resolveShareableConversation(active.conversationId) : null;
|
|
4199
|
+
if (!conv) return res.status(404).json({ error: 'Sub-agent run not found' });
|
|
4200
|
+
return sharedSubagentRecord(req, res, { conv, conversationId: active.conversationId, annotate: false });
|
|
4201
|
+
});
|
|
4202
|
+
|
|
4203
|
+
app.get('/api/conversation/:id/share-preview/subagent/:subagentRunId', auth, (req, res) => {
|
|
4204
|
+
const conversationId = String(req.params.id || '').trim();
|
|
4205
|
+
const conv = conversationId ? resolveShareableConversation(conversationId) : null;
|
|
4206
|
+
if (!conv) return res.status(404).json({ error: 'Sub-agent run not found' });
|
|
4207
|
+
return sharedSubagentRecord(req, res, { conv, conversationId, annotate: true });
|
|
4208
|
+
});
|
|
4209
|
+
|
|
3742
4210
|
app.get('/api/status/events', auth, (req, res) => {
|
|
3743
4211
|
const beforeTimestamp = Number(req.query.beforeTimestamp);
|
|
3744
4212
|
const page = statusEventService.getEventsPage({
|
|
@@ -3753,12 +4221,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3753
4221
|
app.post('/api/shared/:token/presence', (req, res) => {
|
|
3754
4222
|
const token = normalizeShareToken(req.params.token);
|
|
3755
4223
|
if (!token) return res.status(404).json({ error: 'Shared conversation not found' });
|
|
3756
|
-
const share =
|
|
3757
|
-
if (!share
|
|
3758
|
-
|
|
3759
|
-
}
|
|
3760
|
-
const conversationId = String(share.conversation_id || '').trim();
|
|
3761
|
-
if (!conversationId) return res.status(404).json({ error: 'Shared conversation not found' });
|
|
4224
|
+
const share = shares().findLiveShare(token);
|
|
4225
|
+
if (!share) return res.status(404).json({ error: 'Shared conversation not found' });
|
|
4226
|
+
const conversationId = share.conversationId;
|
|
3762
4227
|
const rateLimit = consumeSharedPresenceRateLimit(token, req);
|
|
3763
4228
|
if (!rateLimit.ok) {
|
|
3764
4229
|
res.setHeader('Cache-Control', 'no-store');
|
|
@@ -3786,17 +4251,24 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3786
4251
|
});
|
|
3787
4252
|
});
|
|
3788
4253
|
|
|
4254
|
+
// The download name of a shared file: with names hidden, only its type.
|
|
4255
|
+
function sharedDownloadName(fileName) {
|
|
4256
|
+
let mode = DEFAULT_SHARE_PATH_PRIVACY_MODE;
|
|
4257
|
+
try { mode = getSharePathPrivacyMode(); } catch {}
|
|
4258
|
+
if (mode !== 'hide' || !fileName) return fileName;
|
|
4259
|
+
const extension = path.extname(fileName).replace(/[^.A-Za-z0-9]/g, '').slice(0, 12);
|
|
4260
|
+
return `attachment${extension}`;
|
|
4261
|
+
}
|
|
4262
|
+
|
|
3789
4263
|
app.get('/api/shared/:token/upload/:sha256/content', (req, res) => {
|
|
3790
4264
|
const token = normalizeShareToken(req.params.token);
|
|
3791
4265
|
const sha256 = String(req.params.sha256 || '').trim().toLowerCase();
|
|
3792
4266
|
if (!token || !isSha256(sha256)) {
|
|
3793
4267
|
return res.status(404).json({ error: 'Shared attachment not found' });
|
|
3794
4268
|
}
|
|
3795
|
-
const share =
|
|
3796
|
-
if (!share
|
|
3797
|
-
|
|
3798
|
-
}
|
|
3799
|
-
const conversationId = String(share.conversation_id || '').trim();
|
|
4269
|
+
const share = shares().findLiveShare(token);
|
|
4270
|
+
if (!share) return res.status(404).json({ error: 'Shared attachment not found' });
|
|
4271
|
+
const conversationId = share.conversationId;
|
|
3800
4272
|
if (!conversationId || !conversationReferencesUploadSha(conversationId, sha256)) {
|
|
3801
4273
|
return res.status(404).json({ error: 'Shared attachment not found' });
|
|
3802
4274
|
}
|
|
@@ -3809,7 +4281,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3809
4281
|
// as script on this origin; ranges let a shared video seek.
|
|
3810
4282
|
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
3811
4283
|
serveFileWithRangeSupport(req, res, filePath, { contentType: file.mime_type }, {
|
|
3812
|
-
safeName: String(file.name || file.file_name || '').replace(/"/g, ''),
|
|
4284
|
+
safeName: sharedDownloadName(String(file.name || file.file_name || '').replace(/"/g, '')),
|
|
3813
4285
|
cacheControl: SHARED_CONTENT_CACHE_CONTROL,
|
|
3814
4286
|
});
|
|
3815
4287
|
});
|
|
@@ -3821,11 +4293,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3821
4293
|
if (!token || !messageId || !imageId) {
|
|
3822
4294
|
return res.status(404).json({ error: 'Shared attachment not found' });
|
|
3823
4295
|
}
|
|
3824
|
-
const share =
|
|
3825
|
-
if (!share
|
|
3826
|
-
|
|
3827
|
-
}
|
|
3828
|
-
const conversationId = String(share.conversation_id || '').trim();
|
|
4296
|
+
const share = shares().findLiveShare(token);
|
|
4297
|
+
if (!share) return res.status(404).json({ error: 'Shared attachment not found' });
|
|
4298
|
+
const conversationId = share.conversationId;
|
|
3829
4299
|
if (!conversationId) return res.status(404).json({ error: 'Shared attachment not found' });
|
|
3830
4300
|
const rows = filterMessagesVisibleToSharedView(
|
|
3831
4301
|
(stmts.getSharedMessages || stmts.getMessages).all(conversationId),
|
|
@@ -4010,12 +4480,20 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4010
4480
|
// processing one. Another relay's agent tells "still queued" from
|
|
4011
4481
|
// "cancelled" with it.
|
|
4012
4482
|
activeTurn: Number(stmts.getConversationActiveQueueCount?.get?.(resolvedConversationId)?.count || 0) > 0,
|
|
4013
|
-
|
|
4483
|
+
...(describeSessionActivity(resolvedConversationId) || {}),
|
|
4484
|
+
// The published set, else the placeholder for background work a live
|
|
4485
|
+
// worker reports without this runtime knowing its details.
|
|
4486
|
+
backgroundTasks: backgroundWorkView
|
|
4487
|
+
? backgroundWorkView.panelTasks(resolvedConversationId, runtimeSession?.sdk_session_id)
|
|
4488
|
+
: (backgroundTaskStore?.get?.(resolvedConversationId) || []),
|
|
4014
4489
|
usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
|
|
4015
4490
|
previews: listConversationPreviews?.(resolvedConversationId) || [],
|
|
4016
4491
|
// Every pin of the conversation, also those outside this history page.
|
|
4017
4492
|
pins: listConversationPins(resolvedConversationId),
|
|
4018
4493
|
pinsRevision: conversationPinRevision(resolvedConversationId),
|
|
4494
|
+
// Every message hidden from shared viewers, also those outside the page.
|
|
4495
|
+
hiddenMessages: listConversationHiddenMessages(resolvedConversationId),
|
|
4496
|
+
hiddenMessagesRevision: conversationHiddenMessagesRevision(resolvedConversationId),
|
|
4019
4497
|
preferredRelayMode: preferences.preferredRelayMode,
|
|
4020
4498
|
preferredModel: preferences.preferredModel,
|
|
4021
4499
|
preferredReasoningEffort: preferences.preferredReasoningEffort,
|
|
@@ -4027,6 +4505,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4027
4505
|
origin: parseRemoteRelayOriginJson(conv.origin_json),
|
|
4028
4506
|
// Set on Claude Cloud conversations: repository, branch, session link.
|
|
4029
4507
|
cloud: conversationCloudPayload(conv, runtimeSession),
|
|
4508
|
+
// The session group it is in, with the other members; null when none.
|
|
4509
|
+
group: conversationGroupPayload(resolvedConversationId, req),
|
|
4510
|
+
// Its place in the group's hand order, as on the list rows.
|
|
4511
|
+
groupPosition: conversationGroupPositionOf(conv),
|
|
4030
4512
|
messages: history.messages,
|
|
4031
4513
|
pageInfo: history.pageInfo,
|
|
4032
4514
|
});
|
|
@@ -4054,6 +4536,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4054
4536
|
|
|
4055
4537
|
const hiddenFromShares = req.body.hiddenFromShares === true;
|
|
4056
4538
|
const currentlyHidden = Number(message.hidden_from_shares || 0) === 1;
|
|
4539
|
+
let change = null;
|
|
4057
4540
|
if (currentlyHidden !== hiddenFromShares) {
|
|
4058
4541
|
if (!stmts.setMessageShareVisibility) {
|
|
4059
4542
|
return res.status(503).json({ error: 'Message share visibility is unavailable until the relay restarts' });
|
|
@@ -4065,6 +4548,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4065
4548
|
messageId,
|
|
4066
4549
|
conversationId,
|
|
4067
4550
|
);
|
|
4551
|
+
change = broadcastShareVisibilityChange(conversationId, { pinnedChanged: !!message.pinned_at });
|
|
4068
4552
|
}
|
|
4069
4553
|
|
|
4070
4554
|
return res.json({
|
|
@@ -4072,6 +4556,52 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4072
4556
|
conversationId,
|
|
4073
4557
|
messageId,
|
|
4074
4558
|
hiddenFromShares,
|
|
4559
|
+
hiddenMessages: change?.hiddenMessages || listConversationHiddenMessages(conversationId),
|
|
4560
|
+
hiddenMessagesRevision: change?.revision || conversationHiddenMessagesRevision(conversationId),
|
|
4561
|
+
});
|
|
4562
|
+
});
|
|
4563
|
+
|
|
4564
|
+
// Shows every hidden message to shared viewers again. A message whose turn
|
|
4565
|
+
// is still running stays hidden (the single route refuses it the same way)
|
|
4566
|
+
// and is named in `skipped`; the rest are unhidden.
|
|
4567
|
+
app.post('/api/conversation/:id/share-visibility/unhide-all', auth, (req, res) => {
|
|
4568
|
+
const conversationId = String(req.params.id || '').trim();
|
|
4569
|
+
if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
|
|
4570
|
+
const conv = stmts.getConvAnyStatus.get(conversationId);
|
|
4571
|
+
if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') {
|
|
4572
|
+
return res.status(404).json({ error: 'Conversation not found' });
|
|
4573
|
+
}
|
|
4574
|
+
const hiddenRows = stmts.listHiddenMessages?.all?.(conversationId) || [];
|
|
4575
|
+
if (hiddenRows.length && !stmts.setMessageShareVisibility) {
|
|
4576
|
+
return res.status(503).json({ error: 'Message share visibility is unavailable until the relay restarts' });
|
|
4577
|
+
}
|
|
4578
|
+
const unhidden = [];
|
|
4579
|
+
const skipped = [];
|
|
4580
|
+
const unhideRows = db.transaction((rows) => {
|
|
4581
|
+
for (const row of rows) {
|
|
4582
|
+
const messageId = String(row?.id || '').trim();
|
|
4583
|
+
if (!messageId) continue;
|
|
4584
|
+
if (getActiveQueueForMessage.get(conversationId, messageId, messageId)) {
|
|
4585
|
+
skipped.push({ messageId, reason: 'active-turn' });
|
|
4586
|
+
continue;
|
|
4587
|
+
}
|
|
4588
|
+
stmts.setMessageShareVisibility.run(0, null, messageId, conversationId);
|
|
4589
|
+
unhidden.push(messageId);
|
|
4590
|
+
}
|
|
4591
|
+
});
|
|
4592
|
+
unhideRows(hiddenRows);
|
|
4593
|
+
let change = null;
|
|
4594
|
+
if (unhidden.length) {
|
|
4595
|
+
const pinnedChanged = unhidden.some((messageId) => !!stmts.getMessageByConversation?.get(messageId, conversationId)?.pinned_at);
|
|
4596
|
+
change = broadcastShareVisibilityChange(conversationId, { pinnedChanged });
|
|
4597
|
+
}
|
|
4598
|
+
return res.json({
|
|
4599
|
+
ok: true,
|
|
4600
|
+
conversationId,
|
|
4601
|
+
unhidden,
|
|
4602
|
+
skipped,
|
|
4603
|
+
hiddenMessages: change?.hiddenMessages || listConversationHiddenMessages(conversationId),
|
|
4604
|
+
hiddenMessagesRevision: change?.revision || conversationHiddenMessagesRevision(conversationId),
|
|
4075
4605
|
});
|
|
4076
4606
|
});
|
|
4077
4607
|
|
|
@@ -4778,7 +5308,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4778
5308
|
// Re-armed after the stop, which can outlast the kill block's grace
|
|
4779
5309
|
// window: nothing may respawn a worker for the rows deleted below.
|
|
4780
5310
|
sessionWorkerSupervisor?.markKilled?.(workerSessionId);
|
|
4781
|
-
|
|
5311
|
+
try { sessionActivityTracker?.forget?.(id); } catch {}
|
|
5312
|
+
if (typeof backgroundTaskStore?.forget === 'function') backgroundTaskStore.forget(id, workerSessionId);
|
|
5313
|
+
else for (const key of new Set([id, workerSessionId])) backgroundTaskStore?.replace?.(key, []);
|
|
4782
5314
|
}
|
|
4783
5315
|
|
|
4784
5316
|
// A Claude Cloud conversation takes its cloud session along: archived
|
|
@@ -4793,6 +5325,13 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4793
5325
|
|
|
4794
5326
|
const deletedAt = new Date().toISOString();
|
|
4795
5327
|
markConversationDeleted.run(deletedAt, id);
|
|
5328
|
+
// A deleted conversation takes its public preview links with it (archive
|
|
5329
|
+
// keeps them: an archived conversation can come back).
|
|
5330
|
+
try {
|
|
5331
|
+
closeConversationPreviews?.(id);
|
|
5332
|
+
} catch (error) {
|
|
5333
|
+
console.warn(`[preview] closing the previews of a deleted conversation failed: ${error?.message || error}`);
|
|
5334
|
+
}
|
|
4796
5335
|
// Tombstones keep the SDK session importer from bringing it back.
|
|
4797
5336
|
stmts.markDeletedSdkSession.run(sdkSessionId || id, deletedAt);
|
|
4798
5337
|
if (sdkSessionId && sdkSessionId !== id) stmts.markDeletedSdkSession.run(id, deletedAt);
|
|
@@ -4815,11 +5354,18 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4815
5354
|
resolveSessionStateRoot,
|
|
4816
5355
|
});
|
|
4817
5356
|
const orphanedUploads = collectOrphanedUploadsFromConversation(id);
|
|
5357
|
+
// Before the row goes: a head leaves its group with its title frozen as
|
|
5358
|
+
// the name, and a group left empty is removed.
|
|
5359
|
+
const leftGroupId = conversationGroups?.forgetConversation?.(id, deletedAt) || null;
|
|
5360
|
+
const wasShared = !!shares().findActiveShare(id);
|
|
4818
5361
|
hardDeleteConversationRows(id);
|
|
4819
5362
|
remoteRelayInbound?.repository?.forgetConversation?.(id);
|
|
4820
5363
|
deleteOrphanedUploads(orphanedUploads);
|
|
4821
5364
|
if (sdkSessionId && !cliSession.queued) stmts.deleteSdkDeleteRequest?.run?.(sdkSessionId);
|
|
4822
5365
|
io.emit('conversation_deleted', { conversationId: id });
|
|
5366
|
+
if (wasShared) io.emit('share_revoked', { conversationId: id });
|
|
5367
|
+
if (leftGroupId) emitGroupChanged(leftGroupId, [id]);
|
|
5368
|
+
try { relayEventService?.noteConversationGone?.(id, { deleted: true }); } catch {}
|
|
4823
5369
|
return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
|
|
4824
5370
|
}
|
|
4825
5371
|
|
|
@@ -4833,14 +5379,24 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4833
5379
|
const id = String(req.params.id || '').trim();
|
|
4834
5380
|
if (!id) return res.status(400).json({ error: 'Missing conversation id' });
|
|
4835
5381
|
try {
|
|
5382
|
+
const outcome = await archiveConversationNow(id);
|
|
5383
|
+
return res.status(outcome.statusCode).json(outcome.body);
|
|
5384
|
+
} catch (error) {
|
|
5385
|
+
console.warn(`[archive] Archive failed for ${id}: ${error?.message || error}`);
|
|
5386
|
+
return res.status(500).json({ error: 'Failed to archive conversation' });
|
|
5387
|
+
}
|
|
5388
|
+
});
|
|
5389
|
+
|
|
5390
|
+
/** POST /api/conversation/:id/archive under its session's lock; see the route. */
|
|
5391
|
+
async function archiveConversationNow(id) {
|
|
4836
5392
|
const existing = stmts.getConvAnyStatus.get(id);
|
|
4837
5393
|
if (!existing || String(existing.status || '').trim() === 'deleted') {
|
|
4838
|
-
return
|
|
5394
|
+
return { statusCode: 200, body: { ok: true, alreadyDeleted: true } };
|
|
4839
5395
|
}
|
|
4840
|
-
if (Number(existing.archived || 0) === 1) return
|
|
5396
|
+
if (Number(existing.archived || 0) === 1) return { statusCode: 200, body: { ok: true, alreadyArchived: true } };
|
|
4841
5397
|
const sdkSessionId = String(existing.sdk_session_id || '').trim() || null;
|
|
4842
5398
|
const workerSessionId = sdkSessionId || id;
|
|
4843
|
-
|
|
5399
|
+
return sessionLaunchMutex.runExclusive(workerSessionId, async () => {
|
|
4844
5400
|
const activity = describeConversationActivity(id, workerSessionId);
|
|
4845
5401
|
if (activity.turnRunning) {
|
|
4846
5402
|
return {
|
|
@@ -4898,6 +5454,8 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4898
5454
|
}
|
|
4899
5455
|
db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
|
|
4900
5456
|
io.emit('conversation_archived', { conversationId: id, archived: true });
|
|
5457
|
+
// Its subscriptions end, and so does its place in others'.
|
|
5458
|
+
try { relayEventService?.noteConversationGone?.(id); } catch {}
|
|
4901
5459
|
// The cloud session of a Claude Cloud conversation is archived with it;
|
|
4902
5460
|
// best effort and not waited for.
|
|
4903
5461
|
if (!sharedWithConversationId) {
|
|
@@ -4912,11 +5470,41 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4912
5470
|
}
|
|
4913
5471
|
return { statusCode: 200, body: { ok: true, workerStopped } };
|
|
4914
5472
|
});
|
|
4915
|
-
|
|
5473
|
+
}
|
|
5474
|
+
|
|
5475
|
+
// POST /api/conversation-groups/:id/archive — archive every live member of
|
|
5476
|
+
// a session group that can be archived now. A member with a running turn
|
|
5477
|
+
// or background work is skipped and named in the answer, so the owner can
|
|
5478
|
+
// stop it ("Stop all running") and archive again.
|
|
5479
|
+
app.post('/api/conversation-groups/:id/archive', auth, async (req, res) => {
|
|
5480
|
+
if (readRemoteRelayRequest(req, remoteRelayInbound).remote) {
|
|
5481
|
+
return res.status(403).json({ error: 'Session groups are managed by this relay\'s owner only.' });
|
|
5482
|
+
}
|
|
5483
|
+
const groupId = String(req.params.id || '').trim();
|
|
5484
|
+
const group = groupId ? conversationGroups?.getGroup?.(groupId) : null;
|
|
5485
|
+
if (!group) return res.status(404).json({ ok: false, error: 'Group not found' });
|
|
5486
|
+
const archived = [];
|
|
5487
|
+
const skipped = [];
|
|
5488
|
+
try {
|
|
5489
|
+
for (const member of conversationGroups.membersOf(groupId)) {
|
|
5490
|
+
if (member.archived) continue;
|
|
5491
|
+
const outcome = await archiveConversationNow(member.id);
|
|
5492
|
+
if (outcome.statusCode === 200) {
|
|
5493
|
+
archived.push(member.id);
|
|
5494
|
+
} else {
|
|
5495
|
+
skipped.push({
|
|
5496
|
+
id: member.id,
|
|
5497
|
+
title: resolveConversationTitle({ title: member.title, titleSource: 'auto' }),
|
|
5498
|
+
reason: String(outcome.body?.reason || outcome.body?.error || 'refused'),
|
|
5499
|
+
});
|
|
5500
|
+
}
|
|
5501
|
+
}
|
|
4916
5502
|
} catch (error) {
|
|
4917
|
-
console.warn(`[archive]
|
|
4918
|
-
return res.status(500).json({ error: 'Failed to archive
|
|
5503
|
+
console.warn(`[archive] Group archive failed for ${groupId}: ${error?.message || error}`);
|
|
5504
|
+
return res.status(500).json({ ok: false, error: 'Failed to archive the group', archived, skipped });
|
|
4919
5505
|
}
|
|
5506
|
+
if (archived.length) emitGroupChanged(groupId, archived);
|
|
5507
|
+
return res.json({ ok: true, archived, skipped });
|
|
4920
5508
|
});
|
|
4921
5509
|
|
|
4922
5510
|
// POST /api/conversation/:id/unarchive — back to the live list. A Claude
|
|
@@ -5302,9 +5890,16 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5302
5890
|
// One transaction so a conversation can never exist without its runtime
|
|
5303
5891
|
// binding and preferences: every later reader treats these three as a
|
|
5304
5892
|
// single canonical tuple.
|
|
5893
|
+
let attachedGroupId = null;
|
|
5305
5894
|
const commitBootstrapState = db.transaction(() => {
|
|
5306
5895
|
stmts.insertConv.run(conversationId, title, now, now);
|
|
5307
5896
|
if (remoteRequest.origin) remoteRelayInbound?.repository?.setConversationOrigin?.(conversationId, remoteRequest.origin);
|
|
5897
|
+
// A session an agent of this relay created joins its creator's group
|
|
5898
|
+
// (or starts one with the creator as head). A creator on a paired
|
|
5899
|
+
// relay has no local flag and groups nothing.
|
|
5900
|
+
if (remoteRequest.origin?.local === true && remoteRequest.origin.conversationId) {
|
|
5901
|
+
attachedGroupId = conversationGroups?.attachCreatedConversation?.(conversationId, remoteRequest.origin.conversationId, now) || null;
|
|
5902
|
+
}
|
|
5308
5903
|
const boundRuntimeSession = ensureRuntimeSessionBinding(
|
|
5309
5904
|
conversationId,
|
|
5310
5905
|
selectedModel,
|
|
@@ -5333,6 +5928,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5333
5928
|
return { boundRuntimeSession, preferences };
|
|
5334
5929
|
});
|
|
5335
5930
|
const { boundRuntimeSession, preferences } = commitBootstrapState();
|
|
5931
|
+
if (attachedGroupId) emitGroupChanged(attachedGroupId, [conversationId, remoteRequest.origin.conversationId]);
|
|
5336
5932
|
const runtimeSession = boundRuntimeSession;
|
|
5337
5933
|
const preferredRelayMode = preferences?.preferredRelayMode || bootstrapRelayMode;
|
|
5338
5934
|
const preferredModel = preferences?.preferredModel || selectedModel;
|
|
@@ -6417,6 +7013,44 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
6417
7013
|
});
|
|
6418
7014
|
});
|
|
6419
7015
|
|
|
7016
|
+
// How long sub-agent records keep their tool inputs and output (0 = always).
|
|
7017
|
+
const subagentRecordRetentionPayload = (retentionDays) => ({
|
|
7018
|
+
retentionDays,
|
|
7019
|
+
choices: [...SUBAGENT_RECORD_RETENTION_CHOICES],
|
|
7020
|
+
defaultDays: DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS,
|
|
7021
|
+
});
|
|
7022
|
+
|
|
7023
|
+
app.get('/api/settings/subagent-record-retention', auth, (_req, res) => {
|
|
7024
|
+
res.json(subagentRecordRetentionPayload(getSubagentRecordRetentionDays()));
|
|
7025
|
+
});
|
|
7026
|
+
|
|
7027
|
+
app.post('/api/settings/subagent-record-retention', auth, (req, res) => {
|
|
7028
|
+
const parsed = parseSubagentRecordRetentionUpdate(req.body?.retentionDays);
|
|
7029
|
+
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
|
|
7030
|
+
const result = setSubagentRecordRetentionDays(parsed.days);
|
|
7031
|
+
if (!result.ok) return res.status(500).json({ error: result.error });
|
|
7032
|
+
return res.json(subagentRecordRetentionPayload(result.retentionDays));
|
|
7033
|
+
});
|
|
7034
|
+
|
|
7035
|
+
// What a shared conversation shows of the host's paths and file names.
|
|
7036
|
+
const sharePathPrivacyPayload = (mode) => ({
|
|
7037
|
+
mode,
|
|
7038
|
+
choices: SHARE_PATH_PRIVACY_MODES.map((value) => ({ value, label: SHARE_PATH_PRIVACY_LABELS[value] })),
|
|
7039
|
+
defaultMode: DEFAULT_SHARE_PATH_PRIVACY_MODE,
|
|
7040
|
+
});
|
|
7041
|
+
|
|
7042
|
+
app.get('/api/settings/share-path-privacy', auth, (_req, res) => {
|
|
7043
|
+
res.json(sharePathPrivacyPayload(getSharePathPrivacyMode()));
|
|
7044
|
+
});
|
|
7045
|
+
|
|
7046
|
+
app.post('/api/settings/share-path-privacy', auth, (req, res) => {
|
|
7047
|
+
const parsed = parseSharePathPrivacyUpdate(req.body?.mode);
|
|
7048
|
+
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
|
|
7049
|
+
const result = setSharePathPrivacyMode(parsed.mode);
|
|
7050
|
+
if (!result.ok) return res.status(500).json({ error: result.error });
|
|
7051
|
+
return res.json(sharePathPrivacyPayload(result.mode));
|
|
7052
|
+
});
|
|
7053
|
+
|
|
6420
7054
|
app.get('/api/settings/background-task-timeout', auth, (_req, res) => {
|
|
6421
7055
|
res.json({
|
|
6422
7056
|
timeoutMinutes: getBackgroundTaskTimeoutMinutes(),
|
|
@@ -6494,9 +7128,18 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
6494
7128
|
*/
|
|
6495
7129
|
async function windowsAutostartMergedState() {
|
|
6496
7130
|
const signin = windowsAutostartService.getState();
|
|
6497
|
-
|
|
6498
|
-
|
|
6499
|
-
|
|
7131
|
+
// A boot task that cannot be read is reported (and logged), not allowed
|
|
7132
|
+
// to fail the whole setting; the sign-in half above still does, since
|
|
7133
|
+
// "off" would be a guess there.
|
|
7134
|
+
let boot = { supported: false };
|
|
7135
|
+
if (windowsBootAutostartService) {
|
|
7136
|
+
try {
|
|
7137
|
+
boot = await windowsBootAutostartService.getState();
|
|
7138
|
+
} catch (error) {
|
|
7139
|
+
console.warn(`[autostart] could not read the boot task: ${error?.message || error}`);
|
|
7140
|
+
boot = { supported: true, taskStatus: 'unknown', taskName: null, legacyTaskPresent: false, readError: 'The boot task could not be read; the relay log has the details.' };
|
|
7141
|
+
}
|
|
7142
|
+
}
|
|
6500
7143
|
const bootReady = boot.supported && boot.taskStatus === 'ready';
|
|
6501
7144
|
const mode = bootReady ? 'boot' : (signin.enabled ? 'signin' : 'off');
|
|
6502
7145
|
return {
|
|
@@ -6510,9 +7153,13 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
6510
7153
|
taskStatus: boot.taskStatus,
|
|
6511
7154
|
taskName: boot.taskName,
|
|
6512
7155
|
legacyTaskPresent: boot.legacyTaskPresent,
|
|
6513
|
-
|
|
6514
|
-
|
|
6515
|
-
|
|
7156
|
+
customised: boot.customised === true,
|
|
7157
|
+
customisations: Array.isArray(boot.customisations) ? boot.customisations : [],
|
|
7158
|
+
launcherBackupPath: boot.launcherBackupPath || null,
|
|
7159
|
+
readError: boot.readError || null,
|
|
7160
|
+
pendingElevation: boot.pendingElevation || null,
|
|
7161
|
+
lastError: boot.lastError || null,
|
|
7162
|
+
manualCommand: boot.manualCommand || null,
|
|
6516
7163
|
} : null,
|
|
6517
7164
|
signinEnabled: signin.enabled,
|
|
6518
7165
|
};
|
|
@@ -6524,7 +7171,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
6524
7171
|
}
|
|
6525
7172
|
try {
|
|
6526
7173
|
return res.json(await windowsAutostartMergedState());
|
|
6527
|
-
} catch {
|
|
7174
|
+
} catch (error) {
|
|
7175
|
+
// The details stay in the log: they name folders on the host.
|
|
7176
|
+
console.warn(`[autostart] could not read the Windows autostart setting: ${error?.message || error}`);
|
|
6528
7177
|
return res.status(500).json({
|
|
6529
7178
|
error: 'Unable to read Windows autostart. Check access to your user Startup folder.',
|
|
6530
7179
|
});
|
|
@@ -6574,7 +7223,8 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
6574
7223
|
}
|
|
6575
7224
|
windowsAutostartService.setEnabled(mode === 'signin');
|
|
6576
7225
|
return res.json({ ...(await windowsAutostartMergedState()), accepted });
|
|
6577
|
-
} catch {
|
|
7226
|
+
} catch (error) {
|
|
7227
|
+
console.warn(`[autostart] could not change the Windows autostart setting to ${mode}: ${error?.message || error}`);
|
|
6578
7228
|
return res.status(500).json({
|
|
6579
7229
|
error: 'Unable to update Windows autostart. Check access to your user Startup folder.',
|
|
6580
7230
|
});
|