@oar-sh/oar 0.9.8 → 0.9.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +478 -0
- package/README.md +129 -39
- package/bin/bat/oar.bat +46 -46
- package/bin/oar.js +814 -228
- package/package.json +5 -6
- package/server/README.md +306 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +44 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +91 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +1024 -131
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +230 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +471 -110
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +161 -27
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/router.js +8 -2
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +65 -28
- package/server/public/app/store.js +25 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +45 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +700 -59
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +34 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +548 -111
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +164 -24
- package/server/server-runtime.mjs +274 -62
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +31 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -5,7 +5,9 @@ import path from 'path';
|
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
6
|
import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
|
|
7
7
|
import { resolveUploadMimeType } from '../services/mime-sniffer.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import { fileVersionToken, serveFileWithRangeSupport, withFileVersion } from '../services/file-serving.mjs';
|
|
9
|
+
import { validateEntryName } from '../services/explorer-entry-name.mjs';
|
|
10
|
+
import { countEntries, createDirectory, removeEntry, renameEntry } from '../services/explorer-mutations.mjs';
|
|
9
11
|
import {
|
|
10
12
|
shouldParkForRestart,
|
|
11
13
|
parkPendingQueueForRestart,
|
|
@@ -44,6 +46,17 @@ import { isLiveResend } from '../services/steer-resend.mjs';
|
|
|
44
46
|
import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
|
|
45
47
|
import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
|
|
46
48
|
import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
|
|
49
|
+
import {
|
|
50
|
+
createSubagentTimelineService,
|
|
51
|
+
mapSubagentRunHeader,
|
|
52
|
+
sanitizeRunHeaderPatch,
|
|
53
|
+
TIMELINE_READ_DEFAULT_LIMIT,
|
|
54
|
+
} from '../services/subagent-timeline-service.mjs';
|
|
55
|
+
import { normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
|
|
56
|
+
import { RELAY_EVENT_MESSAGE_KIND } from '../../shared/relay-events.mjs';
|
|
57
|
+
import { isRelayShutdownQueued, relayShutdownGateReply } from '../services/relay-shutdown-gate.mjs';
|
|
58
|
+
import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limiter.mjs';
|
|
59
|
+
import { createSubagentTimelinePublisher } from '../services/subagent-viewer-rooms.mjs';
|
|
47
60
|
import { buildClaudeCloudDelivery, isClaudeCloudProviderType } from '../services/claude-cloud-session-service.mjs';
|
|
48
61
|
import {
|
|
49
62
|
admitRemoteRelayRequest,
|
|
@@ -71,6 +84,11 @@ const STRANDED_SESSION_HEARTBEAT_FRESH_MS = 15_000;
|
|
|
71
84
|
const RELAY_QUESTION_FINALIZATION_HOLD_MS = 5_000;
|
|
72
85
|
const AUTO_MODEL_SENTINEL = 'auto';
|
|
73
86
|
const USAGE_FETCH_RETRY_DELAY_MS = 250;
|
|
87
|
+
// Preview kinds whose bytes travel in the preview JSON; media and PDF are
|
|
88
|
+
// fetched by the browser from the raw URL instead.
|
|
89
|
+
const TEXT_PREVIEW_KINDS = new Set(['markdown', 'code', 'text']);
|
|
90
|
+
// An upload's URL names its bytes (the sha256), so a kept answer stays right.
|
|
91
|
+
const UPLOAD_CONTENT_CACHE_CONTROL = 'private, max-age=31536000, immutable';
|
|
74
92
|
|
|
75
93
|
export function resolveOpenAIReasoningEffort(explicitReasoningEffort = '', model = '') {
|
|
76
94
|
const explicit = String(explicitReasoningEffort || '').trim().toLowerCase();
|
|
@@ -1175,6 +1193,12 @@ export function buildDequeuedRelayMessage({
|
|
|
1175
1193
|
fallbackEnvironmentId: getClaudeCloudProviderSettings()?.environmentId || '',
|
|
1176
1194
|
})
|
|
1177
1195
|
: null;
|
|
1196
|
+
// An event message the relay wrote (relay event subscriptions): the
|
|
1197
|
+
// workers deliver it without the steer note, which speaks as the user.
|
|
1198
|
+
let messageKind = '';
|
|
1199
|
+
try {
|
|
1200
|
+
messageKind = String(stmts.getMessageByConversation?.get?.(msg.id, msg.conversation_id)?.kind || '').trim();
|
|
1201
|
+
} catch {}
|
|
1178
1202
|
return {
|
|
1179
1203
|
id: msg.id,
|
|
1180
1204
|
conversationId: msg.conversation_id,
|
|
@@ -1206,6 +1230,7 @@ export function buildDequeuedRelayMessage({
|
|
|
1206
1230
|
ownerAssignedAt: msg.owner_assigned_at || null,
|
|
1207
1231
|
ownerLeaseExpiresAt: msg.owner_lease_expires_at || null,
|
|
1208
1232
|
imageOperationId: String(msg.image_operation_id || '').trim() || null,
|
|
1233
|
+
...(messageKind === RELAY_EVENT_MESSAGE_KIND ? { messageKind } : {}),
|
|
1209
1234
|
};
|
|
1210
1235
|
}
|
|
1211
1236
|
|
|
@@ -1610,57 +1635,6 @@ export function cleanupPersistedGeneratedImagesForAssistantResponse({
|
|
|
1610
1635
|
}
|
|
1611
1636
|
}
|
|
1612
1637
|
|
|
1613
|
-
function serveFileWithRangeSupport(req, res, filePath, meta, { safeName, cacheDelete = null } = {}) {
|
|
1614
|
-
const fileSize = meta.size;
|
|
1615
|
-
const name = safeName || path.basename(filePath).replace(/"/g, '');
|
|
1616
|
-
const rangeHeader = req.headers['range'];
|
|
1617
|
-
|
|
1618
|
-
res.setHeader('Accept-Ranges', 'bytes');
|
|
1619
|
-
res.setHeader('Cache-Control', 'no-store');
|
|
1620
|
-
// Neutralize browser-executable types (HTML/SVG/XML), force nosniff, and
|
|
1621
|
-
// sandbox the response so a worker-written workspace file can't run as script
|
|
1622
|
-
// on this origin. Media stays inline for preview; everything else downloads.
|
|
1623
|
-
applySafeServedContentHeaders(res, meta.contentType, { fileName: name });
|
|
1624
|
-
|
|
1625
|
-
const onStreamError = (error) => {
|
|
1626
|
-
if (cacheDelete) cacheDelete(filePath);
|
|
1627
|
-
if (res.headersSent) { res.destroy(error); return; }
|
|
1628
|
-
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
|
|
1629
|
-
res.status(404).json({ error: 'File not found' });
|
|
1630
|
-
return;
|
|
1631
|
-
}
|
|
1632
|
-
res.status(500).json({ error: 'Failed to read file' });
|
|
1633
|
-
};
|
|
1634
|
-
|
|
1635
|
-
if (rangeHeader) {
|
|
1636
|
-
const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
|
|
1637
|
-
if (!match) {
|
|
1638
|
-
res.setHeader('Content-Range', `bytes */${fileSize}`);
|
|
1639
|
-
res.status(416).end();
|
|
1640
|
-
return;
|
|
1641
|
-
}
|
|
1642
|
-
const start = parseInt(match[1], 10);
|
|
1643
|
-
const end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
|
|
1644
|
-
if (start > end || start >= fileSize || end >= fileSize) {
|
|
1645
|
-
res.setHeader('Content-Range', `bytes */${fileSize}`);
|
|
1646
|
-
res.status(416).end();
|
|
1647
|
-
return;
|
|
1648
|
-
}
|
|
1649
|
-
const chunkSize = end - start + 1;
|
|
1650
|
-
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
|
1651
|
-
res.setHeader('Content-Length', String(chunkSize));
|
|
1652
|
-
res.status(206);
|
|
1653
|
-
const stream = fs.createReadStream(filePath, { start, end });
|
|
1654
|
-
stream.on('error', onStreamError);
|
|
1655
|
-
stream.pipe(res);
|
|
1656
|
-
} else {
|
|
1657
|
-
res.setHeader('Content-Length', String(fileSize));
|
|
1658
|
-
const stream = fs.createReadStream(filePath);
|
|
1659
|
-
stream.on('error', onStreamError);
|
|
1660
|
-
stream.pipe(res);
|
|
1661
|
-
}
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
1638
|
// Structural sanitizer for the workflow-progress digest a Claude session
|
|
1665
1639
|
// worker attaches to its background-task rows (docs/plans/workflow-progress-tree.md,
|
|
1666
1640
|
// Phase 2). The relay never trusts worker JSON: strings are trimmed and
|
|
@@ -1769,6 +1743,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1769
1743
|
fetchLinuxDirectoryEntries,
|
|
1770
1744
|
mapLinuxDirectoryEntry,
|
|
1771
1745
|
readWorkspaceFileMeta,
|
|
1746
|
+
// The cache readWorkspaceFileMeta fills. The routes drop an entry when a
|
|
1747
|
+
// read shows it is out of date; the name was used here without ever
|
|
1748
|
+
// being handed in, so each of those paths threw a ReferenceError.
|
|
1749
|
+
workspaceFileMetaCache,
|
|
1772
1750
|
resolveWorkspaceFilePath,
|
|
1773
1751
|
normalizeWorkspaceRelativePath,
|
|
1774
1752
|
previewLanguageForWorkspaceFile,
|
|
@@ -1855,9 +1833,90 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1855
1833
|
// agent-sent messages, the inbound switch, and mention unlocks. Absent in
|
|
1856
1834
|
// older wiring and most tests, where every remote-relay step is a no-op.
|
|
1857
1835
|
remoteRelayInbound = null,
|
|
1836
|
+
// Relay event subscriptions (services/relay-event-service.mjs) and the
|
|
1837
|
+
// per-session activity they read (services/session-activity-tracker.mjs).
|
|
1838
|
+
// Absent in older wiring and most tests: every hook is then a no-op.
|
|
1839
|
+
relayEventService = null,
|
|
1840
|
+
sessionActivityTracker = null,
|
|
1841
|
+
// A worker's SDK session id or a conversation id → the conversation row.
|
|
1842
|
+
resolveConversationRecord = null,
|
|
1858
1843
|
} = deps;
|
|
1859
1844
|
|
|
1860
1845
|
const ttyConsoleActive = runtimeState?.ttyConsoleActive === true;
|
|
1846
|
+
|
|
1847
|
+
// ─── Sub-agent runs: header, timeline, live events ─────────────────────────
|
|
1848
|
+
const subagentTimeline = createSubagentTimelineService({ db, stmts });
|
|
1849
|
+
|
|
1850
|
+
// The live background task a run belongs to (the panel's row), if any: the
|
|
1851
|
+
// task's spawning tool_use id is the run id.
|
|
1852
|
+
function liveTaskForSubagentRun(run) {
|
|
1853
|
+
const tasks = backgroundTaskStore?.get?.(run?.conversation_id) || [];
|
|
1854
|
+
const runId = String(run?.id || '');
|
|
1855
|
+
const taskId = String(run?.task_id || '');
|
|
1856
|
+
return tasks.find((task) => (task?.toolUseId && task.toolUseId === runId) || (taskId && task?.taskId === taskId)) || null;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// Stop is offered while a run is running: a background run through its
|
|
1860
|
+
// task, a foreground run through the sub-agent cancel route.
|
|
1861
|
+
function isSubagentRunStoppable(run) {
|
|
1862
|
+
if (String(run?.status || '') !== 'running') return false;
|
|
1863
|
+
const task = liveTaskForSubagentRun(run);
|
|
1864
|
+
if (task) return task.stoppable !== false;
|
|
1865
|
+
return Number(run?.background || 0) !== 1;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// Every subagent_status event carries the whole header and the run's own
|
|
1869
|
+
// turn, so a client can place it without having seen the run before.
|
|
1870
|
+
function subagentStatusEvent(run, extra = {}) {
|
|
1871
|
+
const header = mapSubagentRunHeader(run, { stoppable: isSubagentRunStoppable(run) });
|
|
1872
|
+
if (!header) return null;
|
|
1873
|
+
return { ...header, timestamp: new Date().toISOString(), ...extra };
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function emitSubagentStatus(run, extra = {}) {
|
|
1877
|
+
const event = subagentStatusEvent(run, extra);
|
|
1878
|
+
if (event) io.emit('subagent_status', event);
|
|
1879
|
+
try { sessionActivityTracker?.noteSubagentRun?.(run); } catch {}
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// Only to the pages whose viewer shows this run, and volatile: a batch a
|
|
1883
|
+
// connection cannot take is dropped (never kept for a reconnect replay);
|
|
1884
|
+
// the viewer sees the hole in `seq` and fetches it.
|
|
1885
|
+
const publishSubagentTimeline = createSubagentTimelinePublisher(io, deps.subagentTimelineDefer ? { defer: deps.subagentTimelineDefer } : {});
|
|
1886
|
+
function emitSubagentTimeline(run, entries) {
|
|
1887
|
+
if (!run || !entries?.length) return;
|
|
1888
|
+
publishSubagentTimeline({
|
|
1889
|
+
conversationId: String(run.conversation_id || ''),
|
|
1890
|
+
messageId: run.queue_message_id ? String(run.queue_message_id) : null,
|
|
1891
|
+
subagentRunId: String(run.id),
|
|
1892
|
+
entries,
|
|
1893
|
+
truncated: Number(run.truncated || 0) === 1,
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// Copilot and Cursor publish their sub-agents on the live lanes only; the
|
|
1898
|
+
// relay copies each frame into the run's timeline (the Claude worker writes
|
|
1899
|
+
// its own and owns its runs' timelines).
|
|
1900
|
+
function mirrorSubagentFrame(frame) {
|
|
1901
|
+
if (!frame?.runId) return;
|
|
1902
|
+
try {
|
|
1903
|
+
const mirrored = subagentTimeline.mirrorFrame(frame);
|
|
1904
|
+
if (!mirrored) return;
|
|
1905
|
+
const run = mirrored.truncated ? (stmts.getSubagentRun?.get?.(frame.runId) || mirrored.run) : mirrored.run;
|
|
1906
|
+
if (mirrored.truncated) emitSubagentStatus(run);
|
|
1907
|
+
emitSubagentTimeline(run, mirrored.entries);
|
|
1908
|
+
} catch (error) {
|
|
1909
|
+
console.warn(`[${ts()}] SUBAGENT MIRROR failed run=${String(frame.runId).slice(0, 12)}: ${error?.message || error}`);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
function activityTimelineFrame(text) {
|
|
1914
|
+
const value = String(text || '');
|
|
1915
|
+
const toolMatch = /^Tool \(([^)]+)\)/.exec(value);
|
|
1916
|
+
if (toolMatch) return { kind: 'tool_call', toolName: toolMatch[1], text: value };
|
|
1917
|
+
if (/^Tool failed:/i.test(value)) return { kind: 'tool_result', text: value, metadata: { isError: true } };
|
|
1918
|
+
return { kind: 'note', text: value };
|
|
1919
|
+
}
|
|
1861
1920
|
const getMessageAttachmentsByConversation = db.prepare(`
|
|
1862
1921
|
SELECT attachments
|
|
1863
1922
|
FROM messages
|
|
@@ -2041,25 +2100,28 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2041
2100
|
};
|
|
2042
2101
|
}
|
|
2043
2102
|
|
|
2103
|
+
// One warning per superseded attempt: a worker streaming for a requeued
|
|
2104
|
+
// row writes several times a second until it learns of the refusal.
|
|
2105
|
+
const staleAttemptLog = createStaleAttemptLogLimiter({ warn: (line) => console.warn(`[${ts()}] ${line}`) });
|
|
2106
|
+
|
|
2044
2107
|
function rejectSessionBinding(res, status, error, extra = {}) {
|
|
2045
2108
|
return res.status(status).json({ error, ...extra });
|
|
2046
2109
|
}
|
|
2047
2110
|
|
|
2048
2111
|
function reconcileSubagentRunsForTerminalTurn(messageId, { status = 'failed' } = {}) {
|
|
2049
|
-
|
|
2112
|
+
// A background run outlives the turn that spawned it: its worker reports
|
|
2113
|
+
// its end (task_notification), and a dead worker's runs are closed by the
|
|
2114
|
+
// worker-death sweep. Closing it here marked live agents failed.
|
|
2115
|
+
const running = (stmts.listRunningSubagentRunsByQueueMessage?.all?.(messageId) || [])
|
|
2116
|
+
.filter((run) => Number(run.background || 0) !== 1 && !liveTaskForSubagentRun(run));
|
|
2050
2117
|
if (!running.length) return 0;
|
|
2051
2118
|
const now = new Date().toISOString();
|
|
2052
|
-
stmts.closeRunningSubagentRunsByQueueMessage?.run?.(status, now, now, messageId);
|
|
2053
2119
|
for (const run of running) {
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
status,
|
|
2060
|
-
queueMessageId: messageId,
|
|
2061
|
-
reconciled: true,
|
|
2062
|
-
});
|
|
2120
|
+
stmts.updateSubagentRunStatus?.run?.(status, now, now, run.id);
|
|
2121
|
+
const closed = stmts.getSubagentRun?.get?.(run.id) || { ...run, status, completed_at: now };
|
|
2122
|
+
// messageId is the run's own turn: the browser drops a status event it
|
|
2123
|
+
// cannot place, which left these runs showing "running" until a reload.
|
|
2124
|
+
emitSubagentStatus(closed, { queueMessageId: messageId, reconciled: true });
|
|
2063
2125
|
}
|
|
2064
2126
|
return running.length;
|
|
2065
2127
|
}
|
|
@@ -2146,6 +2208,19 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2146
2208
|
});
|
|
2147
2209
|
io.emit('message_status', { messageId, conversationId, status: 'failed' });
|
|
2148
2210
|
void pushDispatchService?.notifyTurnFailed?.({ conversationId, messageId: responseId, sourceMessageId: messageId, text: responseText });
|
|
2211
|
+
// A Stop from the relay ends the turn; anything else failed it.
|
|
2212
|
+
try {
|
|
2213
|
+
if (String(failureRecord?.code || '') === 'turn-aborted') {
|
|
2214
|
+
relayEventService?.noteTurnEnd?.({ conversationId, queueMessageId: messageId, status: 'stopped' });
|
|
2215
|
+
} else {
|
|
2216
|
+
relayEventService?.noteError?.({
|
|
2217
|
+
conversationId,
|
|
2218
|
+
kind: 'turn_failed',
|
|
2219
|
+
message: failureRecord?.message || failureRecord?.error || responseText,
|
|
2220
|
+
queueMessageId: messageId,
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
} catch {}
|
|
2149
2224
|
cancelPendingRelayQuestionsForMessage(messageId);
|
|
2150
2225
|
const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)
|
|
2151
2226
|
? normalizeSessionWorkerId(queueRow?.owner_sdk_session_id)
|
|
@@ -3408,9 +3483,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3408
3483
|
if (!file) return res.status(404).json({ error: 'Not found' });
|
|
3409
3484
|
const filePath = uploadPathForSha(sha256);
|
|
3410
3485
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Missing file on disk' });
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3486
|
+
serveFileWithRangeSupport(req, res, filePath, { contentType: file.mime_type }, {
|
|
3487
|
+
safeName: String(file.name || file.file_name || '').replace(/"/g, ''),
|
|
3488
|
+
cacheControl: UPLOAD_CONTENT_CACHE_CONTROL,
|
|
3489
|
+
});
|
|
3414
3490
|
});
|
|
3415
3491
|
|
|
3416
3492
|
app.get('/api/generated-image/:conversationId/:messageId/:imageId/content', auth, (req, res) => {
|
|
@@ -3483,6 +3559,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3483
3559
|
const filePath = resolveWorkspaceFilePath(requestedPath, rootOverride);
|
|
3484
3560
|
if (!filePath || !normalizedPath) return res.status(400).json({ error: 'Invalid file path' });
|
|
3485
3561
|
|
|
3562
|
+
// The preview carries the version the download link is built from, so
|
|
3563
|
+
// it reads the file's metadata fresh, not from the short-lived cache.
|
|
3564
|
+
workspaceFileMetaCache.delete(filePath);
|
|
3486
3565
|
let meta = null;
|
|
3487
3566
|
try {
|
|
3488
3567
|
meta = readWorkspaceFileMeta(filePath);
|
|
@@ -3538,10 +3617,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3538
3617
|
size,
|
|
3539
3618
|
truncated,
|
|
3540
3619
|
previewBytes: contentBuffer.length,
|
|
3541
|
-
|
|
3620
|
+
mtimeMs: meta.mtimeMs ?? null,
|
|
3621
|
+
version: fileVersionToken(meta),
|
|
3622
|
+
rawUrl: withFileVersion(`${remotePath}/api/files/${normalizedWebPath.split('/').map((part) => encodeURIComponent(part)).join('/')}${scopeSuffix}`, fileVersionToken(meta)),
|
|
3542
3623
|
};
|
|
3543
3624
|
|
|
3544
|
-
if (kind
|
|
3625
|
+
if (TEXT_PREVIEW_KINDS.has(kind)) {
|
|
3545
3626
|
payload.content = contentBuffer.toString('utf8');
|
|
3546
3627
|
}
|
|
3547
3628
|
|
|
@@ -3861,6 +3942,137 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3861
3942
|
});
|
|
3862
3943
|
});
|
|
3863
3944
|
|
|
3945
|
+
// ---------------------------------------------------------------------------
|
|
3946
|
+
// Explorer changes: new folder, rename, delete. Two routes per operation,
|
|
3947
|
+
// one for the conversation's workspace (relative paths, resolved exactly like
|
|
3948
|
+
// `/api/files/*`) and one for the drives and session roots (absolute paths,
|
|
3949
|
+
// checked exactly like `/api/drives/list`). Each write route therefore
|
|
3950
|
+
// reaches nothing its listing route would not show.
|
|
3951
|
+
// ---------------------------------------------------------------------------
|
|
3952
|
+
|
|
3953
|
+
function workspaceWebPath(relativePath) {
|
|
3954
|
+
return String(relativePath || '').split(path.sep).join('/');
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3957
|
+
// Resolve a workspace entry from a request body: `{ absolutePath, relative }`
|
|
3958
|
+
// or `null` after answering the request with the error.
|
|
3959
|
+
function resolveWorkspaceMutationTarget(req, res, rawRelativePath) {
|
|
3960
|
+
const relative = normalizeWorkspaceRelativePath(rawRelativePath);
|
|
3961
|
+
if (!relative) {
|
|
3962
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
3963
|
+
return null;
|
|
3964
|
+
}
|
|
3965
|
+
const absolutePath = resolveWorkspaceFilePath(relative, resolveScopedWorkspaceRootPath(req));
|
|
3966
|
+
if (!absolutePath) {
|
|
3967
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
3968
|
+
return null;
|
|
3969
|
+
}
|
|
3970
|
+
return { absolutePath, relative };
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
// Resolve an absolute drives/session path from a request body and hand it
|
|
3974
|
+
// on, with the function that turns an absolute path into the explorer's
|
|
3975
|
+
// path form for the answer. Answers the request itself on a bad path.
|
|
3976
|
+
function withDriveMutationTarget(req, res, rawPath, { allowRoot = false } = {}, next) {
|
|
3977
|
+
const requestedPath = String(rawPath || '').trim();
|
|
3978
|
+
if (process.platform !== 'win32') {
|
|
3979
|
+
const absolutePath = normalizeLinuxAbsolutePath(requestedPath);
|
|
3980
|
+
if (!absolutePath) return res.status(400).json({ error: 'Invalid Linux path' });
|
|
3981
|
+
if (absolutePath === '/' && !allowRoot) return res.status(400).json({ error: 'The root folder cannot be changed' });
|
|
3982
|
+
return next(absolutePath, (value) => value);
|
|
3983
|
+
}
|
|
3984
|
+
return fetchBrowsableDrives((drivesErr, drives) => {
|
|
3985
|
+
if (drivesErr) return res.status(500).json({ error: drivesErr.message || 'Failed to enumerate drives' });
|
|
3986
|
+
const allowedRoots = new Set(drives.map((drive) => drive.rootAbsolute.toUpperCase()));
|
|
3987
|
+
const absolutePath = normalizeDriveAbsolutePath(requestedPath);
|
|
3988
|
+
const rootAbsolute = driveRootFromAbsolutePath(absolutePath).toUpperCase();
|
|
3989
|
+
if (!absolutePath || !rootAbsolute || !allowedRoots.has(rootAbsolute)) {
|
|
3990
|
+
return res.status(400).json({ error: 'Invalid drive path' });
|
|
3991
|
+
}
|
|
3992
|
+
if (absolutePath.toUpperCase() === rootAbsolute && !allowRoot) {
|
|
3993
|
+
return res.status(400).json({ error: 'A drive root cannot be changed' });
|
|
3994
|
+
}
|
|
3995
|
+
return next(absolutePath, toDriveWebPath);
|
|
3996
|
+
});
|
|
3997
|
+
}
|
|
3998
|
+
|
|
3999
|
+
function validatedName(res, rawName) {
|
|
4000
|
+
const check = validateEntryName(rawName);
|
|
4001
|
+
if (!check.ok) {
|
|
4002
|
+
res.status(400).json({ error: check.error });
|
|
4003
|
+
return null;
|
|
4004
|
+
}
|
|
4005
|
+
return check.name;
|
|
4006
|
+
}
|
|
4007
|
+
|
|
4008
|
+
function sendMutationResult(res, result, extra = {}) {
|
|
4009
|
+
if (!result?.ok) return res.status(Number(result?.statusCode) || 500).json({ error: result?.error || 'Request failed' });
|
|
4010
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
4011
|
+
return res.json({ ok: true, ...extra });
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
app.post('/api/repo/mkdir', auth, (req, res) => {
|
|
4015
|
+
const name = validatedName(res, req.body?.name);
|
|
4016
|
+
if (!name) return;
|
|
4017
|
+
const parentRelative = normalizeWorkspaceRelativePath(req.body?.path);
|
|
4018
|
+
if (String(req.body?.path || '').trim() && !parentRelative) return res.status(400).json({ error: 'Invalid path' });
|
|
4019
|
+
const target = resolveWorkspaceMutationTarget(req, res, parentRelative ? `${workspaceWebPath(parentRelative)}/${name}` : name);
|
|
4020
|
+
if (!target) return;
|
|
4021
|
+
return sendMutationResult(res, createDirectory(target.absolutePath), { path: workspaceWebPath(target.relative) });
|
|
4022
|
+
});
|
|
4023
|
+
|
|
4024
|
+
app.post('/api/repo/rename', auth, (req, res) => {
|
|
4025
|
+
const newName = validatedName(res, req.body?.newName);
|
|
4026
|
+
if (!newName) return;
|
|
4027
|
+
const source = resolveWorkspaceMutationTarget(req, res, req.body?.path);
|
|
4028
|
+
if (!source) return;
|
|
4029
|
+
const parentWeb = workspaceWebPath(path.dirname(source.relative));
|
|
4030
|
+
const destinationWeb = parentWeb && parentWeb !== '.' ? `${parentWeb}/${newName}` : newName;
|
|
4031
|
+
// The destination goes through the same resolver: a new name can never
|
|
4032
|
+
// leave the workspace or land on the root.
|
|
4033
|
+
const destination = resolveWorkspaceMutationTarget(req, res, destinationWeb);
|
|
4034
|
+
if (!destination) return;
|
|
4035
|
+
return sendMutationResult(res, renameEntry(source.absolutePath, newName), { path: workspaceWebPath(destination.relative) });
|
|
4036
|
+
});
|
|
4037
|
+
|
|
4038
|
+
app.post('/api/repo/delete', auth, (req, res) => {
|
|
4039
|
+
const target = resolveWorkspaceMutationTarget(req, res, req.body?.path);
|
|
4040
|
+
if (!target) return;
|
|
4041
|
+
if (req.body?.dryRun === true) {
|
|
4042
|
+
const counted = countEntries(target.absolutePath);
|
|
4043
|
+
return sendMutationResult(res, counted, counted.ok ? { kind: counted.kind, files: counted.files, dirs: counted.dirs, capped: counted.capped } : {});
|
|
4044
|
+
}
|
|
4045
|
+
return sendMutationResult(res, removeEntry(target.absolutePath));
|
|
4046
|
+
});
|
|
4047
|
+
|
|
4048
|
+
app.post('/api/drives/mkdir', auth, (req, res) => {
|
|
4049
|
+
const name = validatedName(res, req.body?.name);
|
|
4050
|
+
if (!name) return;
|
|
4051
|
+
withDriveMutationTarget(req, res, req.body?.path, { allowRoot: true }, (parentAbsolute, webPathOf) => {
|
|
4052
|
+
const targetAbsolute = path.join(parentAbsolute, name);
|
|
4053
|
+
return sendMutationResult(res, createDirectory(targetAbsolute), { path: webPathOf(targetAbsolute) });
|
|
4054
|
+
});
|
|
4055
|
+
});
|
|
4056
|
+
|
|
4057
|
+
app.post('/api/drives/rename', auth, (req, res) => {
|
|
4058
|
+
const newName = validatedName(res, req.body?.newName);
|
|
4059
|
+
if (!newName) return;
|
|
4060
|
+
withDriveMutationTarget(req, res, req.body?.path, {}, (sourceAbsolute, webPathOf) => {
|
|
4061
|
+
const result = renameEntry(sourceAbsolute, newName);
|
|
4062
|
+
return sendMutationResult(res, result, result.ok ? { path: webPathOf(result.path) } : {});
|
|
4063
|
+
});
|
|
4064
|
+
});
|
|
4065
|
+
|
|
4066
|
+
app.post('/api/drives/delete', auth, (req, res) => {
|
|
4067
|
+
withDriveMutationTarget(req, res, req.body?.path, {}, (targetAbsolute) => {
|
|
4068
|
+
if (req.body?.dryRun === true) {
|
|
4069
|
+
const counted = countEntries(targetAbsolute);
|
|
4070
|
+
return sendMutationResult(res, counted, counted.ok ? { kind: counted.kind, files: counted.files, dirs: counted.dirs, capped: counted.capped } : {});
|
|
4071
|
+
}
|
|
4072
|
+
return sendMutationResult(res, removeEntry(targetAbsolute));
|
|
4073
|
+
});
|
|
4074
|
+
});
|
|
4075
|
+
|
|
3864
4076
|
app.get('/api/drives/file', auth, (req, res) => {
|
|
3865
4077
|
const requestedPath = String(req.query.path || '').trim();
|
|
3866
4078
|
|
|
@@ -3917,6 +4129,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3917
4129
|
const filePath = normalizeLinuxAbsolutePath(requestedPath);
|
|
3918
4130
|
if (!filePath) return res.status(400).json({ error: 'Invalid Linux file path' });
|
|
3919
4131
|
|
|
4132
|
+
// Fresh metadata: the download link's version comes from this answer.
|
|
4133
|
+
workspaceFileMetaCache.delete(filePath);
|
|
3920
4134
|
let meta = null;
|
|
3921
4135
|
try {
|
|
3922
4136
|
meta = readWorkspaceFileMeta(filePath);
|
|
@@ -3969,10 +4183,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3969
4183
|
size,
|
|
3970
4184
|
truncated,
|
|
3971
4185
|
previewBytes: contentBuffer.length,
|
|
3972
|
-
|
|
4186
|
+
mtimeMs: meta.mtimeMs ?? null,
|
|
4187
|
+
version: fileVersionToken(meta),
|
|
4188
|
+
rawUrl: withFileVersion(`${remotePath}/api/drives/file?path=${encodeURIComponent(filePath)}`, fileVersionToken(meta)),
|
|
3973
4189
|
};
|
|
3974
4190
|
|
|
3975
|
-
if (kind
|
|
4191
|
+
if (TEXT_PREVIEW_KINDS.has(kind)) {
|
|
3976
4192
|
payload.content = contentBuffer.toString('utf8');
|
|
3977
4193
|
}
|
|
3978
4194
|
|
|
@@ -3989,6 +4205,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3989
4205
|
return res.status(400).json({ error: 'Invalid drive file path' });
|
|
3990
4206
|
}
|
|
3991
4207
|
|
|
4208
|
+
// Fresh metadata: the download link's version comes from this answer.
|
|
4209
|
+
workspaceFileMetaCache.delete(filePath);
|
|
3992
4210
|
let meta = null;
|
|
3993
4211
|
try {
|
|
3994
4212
|
meta = readWorkspaceFileMeta(filePath);
|
|
@@ -4043,10 +4261,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4043
4261
|
size,
|
|
4044
4262
|
truncated,
|
|
4045
4263
|
previewBytes: contentBuffer.length,
|
|
4046
|
-
|
|
4264
|
+
mtimeMs: meta.mtimeMs ?? null,
|
|
4265
|
+
version: fileVersionToken(meta),
|
|
4266
|
+
rawUrl: withFileVersion(`${remotePath}/api/drives/file?path=${encodeURIComponent(normalizedWebPath)}`, fileVersionToken(meta)),
|
|
4047
4267
|
};
|
|
4048
4268
|
|
|
4049
|
-
if (kind
|
|
4269
|
+
if (TEXT_PREVIEW_KINDS.has(kind)) {
|
|
4050
4270
|
payload.content = contentBuffer.toString('utf8');
|
|
4051
4271
|
}
|
|
4052
4272
|
|
|
@@ -5277,7 +5497,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5277
5497
|
}
|
|
5278
5498
|
const rowAttemptId = String(row.attempt_id || '').trim() || null;
|
|
5279
5499
|
if (attemptId && rowAttemptId && attemptId !== rowAttemptId) {
|
|
5280
|
-
|
|
5500
|
+
staleAttemptLog.note({ messageId, attemptId, currentAttemptId: rowAttemptId, route: 'queue-cancelled', status: row.status });
|
|
5281
5501
|
return res.status(409).json({ error: 'stale_attempt' });
|
|
5282
5502
|
}
|
|
5283
5503
|
|
|
@@ -5732,6 +5952,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5732
5952
|
sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
|
|
5733
5953
|
}
|
|
5734
5954
|
if (runtimeState.relayPaused) return res.json({ message: null, paused: true });
|
|
5955
|
+
// A queued shutdown or restart waits for the processing turns only: a
|
|
5956
|
+
// pending row handed out now would start a turn that postpones it again.
|
|
5957
|
+
if (isRelayShutdownQueued(runtimeState)) return res.json(relayShutdownGateReply(runtimeState));
|
|
5735
5958
|
if (runtimeState.tunnelState?.blocking) {
|
|
5736
5959
|
return res.json({
|
|
5737
5960
|
message: null,
|
|
@@ -6717,7 +6940,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6717
6940
|
// the legacy conditional semantics.
|
|
6718
6941
|
const claimedAttemptId = String(req.body.attemptId || '').trim() || null;
|
|
6719
6942
|
if (claimedAttemptId && q && String(q.attempt_id || '') !== claimedAttemptId) {
|
|
6720
|
-
|
|
6943
|
+
staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: q.attempt_id, route: 'response', status: q.status });
|
|
6721
6944
|
return res.status(409).json({ error: 'stale_attempt', currentStatus: q.status });
|
|
6722
6945
|
}
|
|
6723
6946
|
if (!claimedAttemptId && q && q.status === 'processing') {
|
|
@@ -6810,7 +7033,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6810
7033
|
if (!failed) {
|
|
6811
7034
|
const currentRow = stmts.findQById?.get(messageId) || null;
|
|
6812
7035
|
if (claimedAttemptId && currentRow && String(currentRow.attempt_id || '') !== claimedAttemptId) {
|
|
6813
|
-
|
|
7036
|
+
staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: currentRow.attempt_id, route: 'response-terminal', status: currentRow.status });
|
|
6814
7037
|
return res.status(409).json({ error: 'stale_attempt', currentStatus: String(currentRow.status || 'unknown') });
|
|
6815
7038
|
}
|
|
6816
7039
|
console.log(`[${ts()}] RESPONSE ${messageId?.slice(0,8)} ignored=not_pending_or_processing`);
|
|
@@ -7026,7 +7249,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7026
7249
|
const currentRow = stmts.findQById?.get(messageId) || null;
|
|
7027
7250
|
const currentStatus = String(currentRow?.status || 'unknown');
|
|
7028
7251
|
if (claimedAttemptId && currentRow && String(currentRow.attempt_id || '') !== claimedAttemptId) {
|
|
7029
|
-
|
|
7252
|
+
staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: currentRow.attempt_id, route: 'response-finalize', status: currentStatus });
|
|
7030
7253
|
return res.status(409).json({ error: 'stale_attempt', currentStatus });
|
|
7031
7254
|
}
|
|
7032
7255
|
console.log(`[${ts()}] RESPONSE ${messageId?.slice(0,8)} ignored=not_pending_or_processing actual_status=${currentStatus}`);
|
|
@@ -7202,6 +7425,25 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7202
7425
|
sourceMessageId: messageId,
|
|
7203
7426
|
text: resolvedText,
|
|
7204
7427
|
});
|
|
7428
|
+
// A turn paused at the usage limit did not end: it is an error event
|
|
7429
|
+
// for whoever watches the session.
|
|
7430
|
+
try {
|
|
7431
|
+
if (usageLimitPause) {
|
|
7432
|
+
relayEventService?.noteError?.({
|
|
7433
|
+
conversationId: targetConversationId,
|
|
7434
|
+
kind: 'usage_limit',
|
|
7435
|
+
message: usageLimitPause.noteText,
|
|
7436
|
+
queueMessageId: messageId,
|
|
7437
|
+
});
|
|
7438
|
+
} else {
|
|
7439
|
+
relayEventService?.noteTurnEnd?.({
|
|
7440
|
+
conversationId: targetConversationId,
|
|
7441
|
+
queueMessageId: messageId,
|
|
7442
|
+
replyText: resolvedText,
|
|
7443
|
+
status: 'done',
|
|
7444
|
+
});
|
|
7445
|
+
}
|
|
7446
|
+
} catch {}
|
|
7205
7447
|
}
|
|
7206
7448
|
cancelPendingRelayQuestionsForMessage(messageId);
|
|
7207
7449
|
res.json({ ok: true });
|
|
@@ -7298,6 +7540,37 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7298
7540
|
res.json({ ok: true, messageId, conversationId, attemptId });
|
|
7299
7541
|
});
|
|
7300
7542
|
|
|
7543
|
+
function resolveTaskConversationId(id) {
|
|
7544
|
+
const key = String(id || '').trim();
|
|
7545
|
+
if (!key) return '';
|
|
7546
|
+
try {
|
|
7547
|
+
return String(resolveConversationRecord?.({ conversationId: key, sdkSessionId: key })?.id || key);
|
|
7548
|
+
} catch {
|
|
7549
|
+
return key;
|
|
7550
|
+
}
|
|
7551
|
+
}
|
|
7552
|
+
|
|
7553
|
+
// POST /api/background-task-settled — how a background task ended, where
|
|
7554
|
+
// the worker learns it (the Claude worker's task_notification): status,
|
|
7555
|
+
// summary, and the reason for an unusual end. The task leaves the live set
|
|
7556
|
+
// on its own; this only says how it ended.
|
|
7557
|
+
app.post('/api/background-task-settled', auth, (req, res) => {
|
|
7558
|
+
touchCli();
|
|
7559
|
+
const conversationId = resolveTaskConversationId(req.body?.conversationId);
|
|
7560
|
+
const taskId = String(req.body?.taskId || '').trim();
|
|
7561
|
+
if (!conversationId || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
|
|
7562
|
+
const exitCode = Number(req.body?.exitCode);
|
|
7563
|
+
sessionActivityTracker?.noteTaskSettled?.(conversationId, {
|
|
7564
|
+
taskId,
|
|
7565
|
+
toolUseId: sanitizeSubagentRunId(req.body?.toolUseId) || null,
|
|
7566
|
+
status: String(req.body?.status || '').trim().slice(0, 40) || 'completed',
|
|
7567
|
+
summary: String(req.body?.summary || '').trim().slice(0, 500) || null,
|
|
7568
|
+
exitCode: Number.isInteger(exitCode) ? exitCode : null,
|
|
7569
|
+
});
|
|
7570
|
+
relayEventService?.afterStateChange?.(conversationId);
|
|
7571
|
+
res.json({ ok: true });
|
|
7572
|
+
});
|
|
7573
|
+
|
|
7301
7574
|
// POST /api/background-tasks — a session worker publishes its live
|
|
7302
7575
|
// background-task set (REPLACE semantics, mirroring the SDK's
|
|
7303
7576
|
// background_tasks_changed signal) so the composer panel tracks it live.
|
|
@@ -7324,10 +7597,40 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7324
7597
|
// Sanitized digest or undefined: JSON serialization (store broadcast,
|
|
7325
7598
|
// conversation payload) drops the key entirely for flat rows.
|
|
7326
7599
|
workflowProgress: sanitizeWorkflowProgress(task?.workflowProgress) || undefined,
|
|
7600
|
+
// The spawning tool_use id: the sub-agent run this task is. The panel
|
|
7601
|
+
// opens the run's viewer from it.
|
|
7602
|
+
toolUseId: sanitizeSubagentRunId(task?.toolUseId) || undefined,
|
|
7327
7603
|
}))
|
|
7328
7604
|
.filter((task) => task.taskId);
|
|
7329
7605
|
backgroundTaskStore?.replace?.(conversationId, tasks);
|
|
7330
7606
|
io.emit('background_tasks', { conversationId, tasks });
|
|
7607
|
+
// The store is keyed as the worker posts (its SDK session id); the
|
|
7608
|
+
// tracker by the conversation.
|
|
7609
|
+
try {
|
|
7610
|
+
const trackedConversationId = resolveTaskConversationId(conversationId);
|
|
7611
|
+
sessionActivityTracker?.noteTaskSet?.(trackedConversationId, tasks);
|
|
7612
|
+
relayEventService?.afterStateChange?.(trackedConversationId);
|
|
7613
|
+
} catch {}
|
|
7614
|
+
// The panel's copy of a task lives in memory and goes when the task
|
|
7615
|
+
// settles; what the run's row needs of it is kept on the run.
|
|
7616
|
+
for (const task of tasks) {
|
|
7617
|
+
if (!task.toolUseId) continue;
|
|
7618
|
+
const before = stmts.getSubagentRun?.get?.(task.toolUseId) || null;
|
|
7619
|
+
if (!before || String(before.conversation_id || '') !== conversationId) continue;
|
|
7620
|
+
const patch = sanitizeRunHeaderPatch({
|
|
7621
|
+
model: task.model,
|
|
7622
|
+
modelInherited: task.model ? task.modelInherited : undefined,
|
|
7623
|
+
subagentType: task.subagentType,
|
|
7624
|
+
taskId: task.taskId,
|
|
7625
|
+
totalTokens: task.totalTokens,
|
|
7626
|
+
background: true,
|
|
7627
|
+
});
|
|
7628
|
+
if (!patch) continue;
|
|
7629
|
+
const after = subagentTimeline.applyHeader(before.id, patch);
|
|
7630
|
+
const changed = after && ['model', 'model_inherited', 'subagent_type', 'task_id', 'total_tokens', 'background']
|
|
7631
|
+
.some((column) => before[column] !== after[column]);
|
|
7632
|
+
if (changed) emitSubagentStatus(after);
|
|
7633
|
+
}
|
|
7331
7634
|
res.json({ ok: true, count: tasks.length });
|
|
7332
7635
|
});
|
|
7333
7636
|
|
|
@@ -7360,7 +7663,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7360
7663
|
if (!claimed || !queueRow) return false;
|
|
7361
7664
|
const current = String(queueRow.attempt_id || '');
|
|
7362
7665
|
if (current === claimed) return false;
|
|
7363
|
-
|
|
7666
|
+
staleAttemptLog.note({ messageId: queueRow.id, attemptId: claimed, currentAttemptId: current, route, status: queueRow.status });
|
|
7364
7667
|
res.status(409).json({ error: 'stale_attempt', currentStatus: String(queueRow.status || 'unknown') });
|
|
7365
7668
|
return true;
|
|
7366
7669
|
}
|
|
@@ -7375,10 +7678,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7375
7678
|
|
|
7376
7679
|
const q = stmts.findQById.get(messageId);
|
|
7377
7680
|
if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'activity' })) return;
|
|
7681
|
+
try { sessionActivityTracker?.touch?.(q?.conversation_id || conversationId); } catch {}
|
|
7378
7682
|
const responseMessageId = q?.response_message_id || null;
|
|
7379
7683
|
const normalizedSubagentRunId = subagentRunId ? String(subagentRunId).trim() : null;
|
|
7380
7684
|
const normalizedMetadata = sanitizeActivityMetadata(metadata);
|
|
7381
|
-
stmts.insertActivity.run(
|
|
7685
|
+
const activityRow = stmts.insertActivity.run(
|
|
7382
7686
|
messageId,
|
|
7383
7687
|
responseMessageId,
|
|
7384
7688
|
conversationId,
|
|
@@ -7388,6 +7692,14 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7388
7692
|
normalizedSubagentRunId,
|
|
7389
7693
|
normalizedMetadata ? JSON.stringify(normalizedMetadata) : null,
|
|
7390
7694
|
);
|
|
7695
|
+
if (normalizedSubagentRunId) {
|
|
7696
|
+
// The lane keeps the capped line; the timeline copy keeps what came in.
|
|
7697
|
+
mirrorSubagentFrame({
|
|
7698
|
+
runId: normalizedSubagentRunId,
|
|
7699
|
+
key: `activity:${activityRow?.lastInsertRowid ?? Date.now()}`,
|
|
7700
|
+
...activityTimelineFrame(String(text || '').trim() || activityText),
|
|
7701
|
+
});
|
|
7702
|
+
}
|
|
7391
7703
|
|
|
7392
7704
|
io.emit('relay_activity', {
|
|
7393
7705
|
messageId,
|
|
@@ -7422,6 +7734,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7422
7734
|
return res.status(409).json({ error: 'Stream conversationId does not match queue conversation' });
|
|
7423
7735
|
}
|
|
7424
7736
|
if (rejectStaleAttemptWrite({ req, res, queueRow, route: 'stream' })) return;
|
|
7737
|
+
try { sessionActivityTracker?.touch?.(queueConversationId); } catch {}
|
|
7425
7738
|
|
|
7426
7739
|
const isStreamSeqConstraintError = (error) => {
|
|
7427
7740
|
const message = String(error?.message || '').toLowerCase();
|
|
@@ -7502,6 +7815,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7502
7815
|
return res.status(500).json({ error: error?.message || 'Failed to persist stream event' });
|
|
7503
7816
|
}
|
|
7504
7817
|
|
|
7818
|
+
if (normalizedSubagentRunId) {
|
|
7819
|
+
// One reply text per run and turn, updated in place as it grows.
|
|
7820
|
+
mirrorSubagentFrame({ runId: normalizedSubagentRunId, key: `stream:${messageId}`, kind: 'text', text: streamText, done: !!done });
|
|
7821
|
+
}
|
|
7822
|
+
|
|
7505
7823
|
// Volatile keeps these out of the connection-state-recovery buffer. Each chunk
|
|
7506
7824
|
// carries the full text so far, so replaying a turn's worth of them costs
|
|
7507
7825
|
// megabytes to deliver what one reconnect resync already fetches from
|
|
@@ -7543,6 +7861,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7543
7861
|
return res.status(409).json({ error: 'Thought conversationId does not match queue conversation' });
|
|
7544
7862
|
}
|
|
7545
7863
|
if (rejectStaleAttemptWrite({ req, res, queueRow, route: 'thought' })) return;
|
|
7864
|
+
try { sessionActivityTracker?.touch?.(queueConversationId); } catch {}
|
|
7546
7865
|
|
|
7547
7866
|
const relayMode = normalizeRelayMode(mode) || DEFAULT_RELAY_MODE;
|
|
7548
7867
|
const normalizedReasoningId = String(reasoningId || '').trim() || null;
|
|
@@ -7622,6 +7941,16 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7622
7941
|
}
|
|
7623
7942
|
}
|
|
7624
7943
|
|
|
7944
|
+
if (normalizedSubagentRunId && (normalizedReasoningId || done) && String(thoughtText).trim()) {
|
|
7945
|
+
mirrorSubagentFrame({
|
|
7946
|
+
runId: normalizedSubagentRunId,
|
|
7947
|
+
key: normalizedReasoningId ? `thought:${normalizedReasoningId}` : `thought:${messageId}:${seq}`,
|
|
7948
|
+
kind: 'thought',
|
|
7949
|
+
text: thoughtText,
|
|
7950
|
+
done: !!done,
|
|
7951
|
+
});
|
|
7952
|
+
}
|
|
7953
|
+
|
|
7625
7954
|
io.emit('relay_thought', {
|
|
7626
7955
|
messageId,
|
|
7627
7956
|
conversationId,
|
|
@@ -7636,7 +7965,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7636
7965
|
res.json({ ok: true, seq });
|
|
7637
7966
|
});
|
|
7638
7967
|
|
|
7639
|
-
// POST /api/subagent-run — relay notifies about subagent lifecycle events
|
|
7968
|
+
// POST /api/subagent-run — relay notifies about subagent lifecycle events.
|
|
7969
|
+
// A new run needs the turn that spawned it (messageId). An update to a known
|
|
7970
|
+
// run may come without one: a background agent outlives its turn, and its
|
|
7971
|
+
// end (or its header) arrives between turns or during a later turn; the run
|
|
7972
|
+
// row names its own turn. Header fields (model, type, task id, background,
|
|
7973
|
+
// who writes the timeline) are patched onto the run.
|
|
7640
7974
|
app.post('/api/subagent-run', auth, (req, res) => {
|
|
7641
7975
|
touchCli();
|
|
7642
7976
|
const { messageId, conversationId, subagentRunId, parentSubagentId, displayName, status } = req.body || {};
|
|
@@ -7645,40 +7979,56 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7645
7979
|
const normalizedSubagentRunId = sanitizeSubagentRunId(subagentRunId);
|
|
7646
7980
|
const normalizedParentSubagentId = sanitizeSubagentRunId(parentSubagentId);
|
|
7647
7981
|
const normalizedDisplayName = displayName ? String(displayName).trim() : null;
|
|
7648
|
-
const normalizedStatus = status ?
|
|
7982
|
+
const normalizedStatus = status ? normalizeSubagentRunStatus(status) : 'running';
|
|
7983
|
+
const normalizedMessageId = String(messageId || '').trim();
|
|
7649
7984
|
|
|
7650
|
-
if (!
|
|
7985
|
+
if (!conversationId || !normalizedSubagentRunId) {
|
|
7651
7986
|
return res.status(400).json({ error: 'Missing required subagent-run fields: messageId, conversationId, subagentRunId' });
|
|
7652
7987
|
}
|
|
7653
|
-
|
|
7654
|
-
const q = stmts.findQById.get(messageId);
|
|
7655
|
-
if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'subagent-run' })) return;
|
|
7656
7988
|
const existingRun = stmts.getSubagentRun?.get(normalizedSubagentRunId);
|
|
7657
|
-
|
|
7658
|
-
|
|
7659
|
-
|
|
7660
|
-
|
|
7661
|
-
|
|
7662
|
-
|
|
7663
|
-
|
|
7664
|
-
|
|
7989
|
+
if (!normalizedMessageId && !existingRun) {
|
|
7990
|
+
return res.status(400).json({ error: 'Missing required subagent-run fields: messageId, conversationId, subagentRunId' });
|
|
7991
|
+
}
|
|
7992
|
+
|
|
7993
|
+
let authoritativeConversationId;
|
|
7994
|
+
if (normalizedMessageId) {
|
|
7995
|
+
const q = stmts.findQById.get(normalizedMessageId);
|
|
7996
|
+
if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'subagent-run' })) return;
|
|
7997
|
+
const binding = validateSubagentRunBinding({
|
|
7998
|
+
queueRow: q,
|
|
7999
|
+
messageId: normalizedMessageId,
|
|
8000
|
+
conversationId,
|
|
8001
|
+
existingRun,
|
|
8002
|
+
});
|
|
8003
|
+
if (!binding.ok) {
|
|
8004
|
+
return res.status(binding.statusCode || 409).json({ error: binding.error || 'Subagent run binding mismatch' });
|
|
8005
|
+
}
|
|
8006
|
+
authoritativeConversationId = String(binding.conversationId || conversationId || '').trim();
|
|
8007
|
+
} else {
|
|
8008
|
+
authoritativeConversationId = String(existingRun.conversation_id || '').trim();
|
|
8009
|
+
if (authoritativeConversationId !== String(conversationId).trim()) {
|
|
8010
|
+
return res.status(409).json({ error: 'Subagent run conversation mismatch' });
|
|
8011
|
+
}
|
|
7665
8012
|
}
|
|
7666
|
-
const authoritativeConversationId = String(binding.conversationId || conversationId || '').trim();
|
|
7667
8013
|
|
|
7668
8014
|
const now = new Date().toISOString();
|
|
7669
8015
|
|
|
7670
8016
|
if (existingRun) {
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
|
|
7676
|
-
|
|
7677
|
-
|
|
8017
|
+
// Without a status the post is a header update and leaves the run's
|
|
8018
|
+
// state alone.
|
|
8019
|
+
if (status) {
|
|
8020
|
+
const isTerminal = normalizedStatus === 'completed' || normalizedStatus === 'failed' || normalizedStatus === 'cancelled';
|
|
8021
|
+
stmts.updateSubagentRunStatus?.run(
|
|
8022
|
+
normalizedStatus,
|
|
8023
|
+
now,
|
|
8024
|
+
isTerminal ? now : null,
|
|
8025
|
+
normalizedSubagentRunId,
|
|
8026
|
+
);
|
|
8027
|
+
}
|
|
7678
8028
|
} else {
|
|
7679
8029
|
stmts.insertSubagentRun?.run(
|
|
7680
8030
|
normalizedSubagentRunId,
|
|
7681
|
-
|
|
8031
|
+
normalizedMessageId,
|
|
7682
8032
|
authoritativeConversationId,
|
|
7683
8033
|
normalizedParentSubagentId,
|
|
7684
8034
|
normalizedDisplayName,
|
|
@@ -7687,20 +8037,107 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7687
8037
|
now,
|
|
7688
8038
|
);
|
|
7689
8039
|
}
|
|
7690
|
-
|
|
7691
|
-
|
|
7692
|
-
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
status: normalizedStatus,
|
|
7698
|
-
timestamp: now,
|
|
8040
|
+
const headerPatch = sanitizeRunHeaderPatch({
|
|
8041
|
+
model: req.body?.model,
|
|
8042
|
+
modelInherited: req.body?.modelInherited,
|
|
8043
|
+
subagentType: req.body?.subagentType,
|
|
8044
|
+
taskId: req.body?.taskId,
|
|
8045
|
+
background: req.body?.background === true,
|
|
8046
|
+
timelineOwner: req.body?.timelineOwner,
|
|
7699
8047
|
});
|
|
8048
|
+
if (headerPatch) subagentTimeline.applyHeader(normalizedSubagentRunId, headerPatch);
|
|
8049
|
+
|
|
8050
|
+
const run = stmts.getSubagentRun?.get?.(normalizedSubagentRunId);
|
|
8051
|
+
if (run) {
|
|
8052
|
+
emitSubagentStatus(run);
|
|
8053
|
+
} else {
|
|
8054
|
+
io.emit('subagent_status', {
|
|
8055
|
+
messageId: normalizedMessageId || null,
|
|
8056
|
+
conversationId: authoritativeConversationId,
|
|
8057
|
+
subagentRunId: normalizedSubagentRunId,
|
|
8058
|
+
parentSubagentId: normalizedParentSubagentId || undefined,
|
|
8059
|
+
displayName: normalizedDisplayName || undefined,
|
|
8060
|
+
status: normalizedStatus,
|
|
8061
|
+
timestamp: now,
|
|
8062
|
+
});
|
|
8063
|
+
}
|
|
7700
8064
|
|
|
7701
8065
|
res.json({ ok: true });
|
|
7702
8066
|
});
|
|
7703
8067
|
|
|
8068
|
+
// POST /api/subagent-timeline — a worker publishes entries of a sub-agent's
|
|
8069
|
+
// record (and, optionally, a header patch) for a run it already announced.
|
|
8070
|
+
// Fenced by the run row, not by an open turn: a background agent keeps
|
|
8071
|
+
// writing after its turn ended, and its entries belong to that turn. A run
|
|
8072
|
+
// the relay does not know yet but whose parent it does (an agent spawned
|
|
8073
|
+
// inside another) is created under the parent's turn; anything else is a
|
|
8074
|
+
// 404 the worker retries once the spawn has landed.
|
|
8075
|
+
app.post('/api/subagent-timeline', auth, (req, res) => {
|
|
8076
|
+
touchCli();
|
|
8077
|
+
const conversationId = String(req.body?.conversationId || '').trim();
|
|
8078
|
+
const runId = sanitizeSubagentRunId(req.body?.subagentRunId);
|
|
8079
|
+
if (!conversationId || !runId) {
|
|
8080
|
+
return res.status(400).json({ error: 'Missing conversationId or subagentRunId' });
|
|
8081
|
+
}
|
|
8082
|
+
if (!subagentTimeline.supported()) {
|
|
8083
|
+
return res.status(503).json({ error: 'timeline_unavailable' });
|
|
8084
|
+
}
|
|
8085
|
+
const rawHeader = req.body?.header && typeof req.body.header === 'object' ? req.body.header : {};
|
|
8086
|
+
let run = stmts.getSubagentRun?.get?.(runId) || null;
|
|
8087
|
+
let created = false;
|
|
8088
|
+
if (!run) {
|
|
8089
|
+
const parentId = sanitizeSubagentRunId(rawHeader.parentSubagentId);
|
|
8090
|
+
const parent = parentId ? (stmts.getSubagentRun?.get?.(parentId) || null) : null;
|
|
8091
|
+
if (!parent) return res.status(404).json({ error: 'unknown_run' });
|
|
8092
|
+
if (String(parent.conversation_id || '') !== conversationId) {
|
|
8093
|
+
return res.status(409).json({ error: 'Subagent run conversation mismatch' });
|
|
8094
|
+
}
|
|
8095
|
+
const now = new Date().toISOString();
|
|
8096
|
+
const displayName = String(rawHeader.displayName || '').trim().slice(0, 500) || 'Subagent';
|
|
8097
|
+
stmts.insertSubagentRun?.run(runId, parent.queue_message_id, parent.conversation_id, parentId, displayName, 'running', now, now);
|
|
8098
|
+
run = stmts.getSubagentRun?.get?.(runId) || null;
|
|
8099
|
+
if (!run) return res.status(500).json({ error: 'Failed to create the run' });
|
|
8100
|
+
created = true;
|
|
8101
|
+
}
|
|
8102
|
+
if (String(run.conversation_id || '') !== conversationId) {
|
|
8103
|
+
return res.status(409).json({ error: 'Subagent run conversation mismatch' });
|
|
8104
|
+
}
|
|
8105
|
+
const patch = sanitizeRunHeaderPatch(rawHeader);
|
|
8106
|
+
if (patch) run = subagentTimeline.applyHeader(runId, patch) || run;
|
|
8107
|
+
let stored;
|
|
8108
|
+
try {
|
|
8109
|
+
stored = subagentTimeline.applyEntries(run, req.body?.entries);
|
|
8110
|
+
} catch (error) {
|
|
8111
|
+
return res.status(500).json({ error: error?.message || 'Failed to store the timeline' });
|
|
8112
|
+
}
|
|
8113
|
+
if (stored.truncated) run = stmts.getSubagentRun?.get?.(runId) || run;
|
|
8114
|
+
if (created || patch || stored.truncated) emitSubagentStatus(run);
|
|
8115
|
+
emitSubagentTimeline(run, stored.entries);
|
|
8116
|
+
try { sessionActivityTracker?.touch?.(conversationId); } catch {}
|
|
8117
|
+
res.json({ ok: true, stored: stored.entries.length, truncated: stored.truncated });
|
|
8118
|
+
});
|
|
8119
|
+
|
|
8120
|
+
// GET /api/conversation/:conversationId/subagent/:subagentRunId — one run's
|
|
8121
|
+
// record for the viewer: header, entries after `after` (plus open ones),
|
|
8122
|
+
// child runs. `complete` is false when more entries follow this page.
|
|
8123
|
+
app.get('/api/conversation/:conversationId/subagent/:subagentRunId', auth, (req, res) => {
|
|
8124
|
+
const conversationId = String(req.params.conversationId || '').trim();
|
|
8125
|
+
const runId = sanitizeSubagentRunId(req.params.subagentRunId);
|
|
8126
|
+
if (!conversationId || !runId) return res.status(400).json({ error: 'Missing conversationId or subagentRunId' });
|
|
8127
|
+
const run = stmts.getSubagentRun?.get?.(runId) || null;
|
|
8128
|
+
if (!run || String(run.conversation_id || '') !== conversationId) {
|
|
8129
|
+
return res.status(404).json({ error: 'Sub-agent run not found' });
|
|
8130
|
+
}
|
|
8131
|
+
const page = subagentTimeline.readTimeline(run, {
|
|
8132
|
+
afterSeq: Number(req.query?.after) || 0,
|
|
8133
|
+
limit: Number(req.query?.limit) || TIMELINE_READ_DEFAULT_LIMIT,
|
|
8134
|
+
isStoppable: isSubagentRunStoppable,
|
|
8135
|
+
});
|
|
8136
|
+
const task = liveTaskForSubagentRun(run);
|
|
8137
|
+
if (page.run && task?.taskId) page.run.taskId = page.run.taskId || task.taskId;
|
|
8138
|
+
res.json(page);
|
|
8139
|
+
});
|
|
8140
|
+
|
|
7704
8141
|
// POST /api/requeue — relay re-queues a message it failed to process
|
|
7705
8142
|
app.post('/api/requeue', auth, (req, res) => {
|
|
7706
8143
|
const { messageId } = req.body;
|
|
@@ -7711,7 +8148,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7711
8148
|
// terminal-fail a row that has already been requeued to someone else).
|
|
7712
8149
|
const claimedAttemptId = String(req.body?.attemptId || '').trim() || null;
|
|
7713
8150
|
if (claimedAttemptId && q && String(q.attempt_id || '') !== claimedAttemptId) {
|
|
7714
|
-
|
|
8151
|
+
staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: q.attempt_id, route: 'requeue', status: q.status });
|
|
7715
8152
|
return res.status(409).json({ error: 'stale_attempt', currentStatus: String(q.status || 'unknown') });
|
|
7716
8153
|
}
|
|
7717
8154
|
const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)
|