@oar-sh/oar 0.9.9 → 0.9.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +247 -0
- package/README.md +78 -9
- package/bin/bat/oar.bat +46 -46
- package/package.json +2 -2
- package/server/README.md +286 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +36 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +61 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +186 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +361 -85
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +146 -26
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +59 -28
- package/server/public/app/store.js +19 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +396 -5
- package/server/repositories/message-repository.mjs +29 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +515 -50
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +72 -0
- package/server/server-runtime.mjs +245 -62
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -6,6 +6,8 @@ import { randomUUID } from 'crypto';
|
|
|
6
6
|
import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
|
|
7
7
|
import { resolveUploadMimeType } from '../services/mime-sniffer.mjs';
|
|
8
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,
|
|
@@ -1180,6 +1193,12 @@ export function buildDequeuedRelayMessage({
|
|
|
1180
1193
|
fallbackEnvironmentId: getClaudeCloudProviderSettings()?.environmentId || '',
|
|
1181
1194
|
})
|
|
1182
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 {}
|
|
1183
1202
|
return {
|
|
1184
1203
|
id: msg.id,
|
|
1185
1204
|
conversationId: msg.conversation_id,
|
|
@@ -1211,6 +1230,7 @@ export function buildDequeuedRelayMessage({
|
|
|
1211
1230
|
ownerAssignedAt: msg.owner_assigned_at || null,
|
|
1212
1231
|
ownerLeaseExpiresAt: msg.owner_lease_expires_at || null,
|
|
1213
1232
|
imageOperationId: String(msg.image_operation_id || '').trim() || null,
|
|
1233
|
+
...(messageKind === RELAY_EVENT_MESSAGE_KIND ? { messageKind } : {}),
|
|
1214
1234
|
};
|
|
1215
1235
|
}
|
|
1216
1236
|
|
|
@@ -1813,9 +1833,90 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1813
1833
|
// agent-sent messages, the inbound switch, and mention unlocks. Absent in
|
|
1814
1834
|
// older wiring and most tests, where every remote-relay step is a no-op.
|
|
1815
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,
|
|
1816
1843
|
} = deps;
|
|
1817
1844
|
|
|
1818
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
|
+
}
|
|
1819
1920
|
const getMessageAttachmentsByConversation = db.prepare(`
|
|
1820
1921
|
SELECT attachments
|
|
1821
1922
|
FROM messages
|
|
@@ -1999,25 +2100,28 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1999
2100
|
};
|
|
2000
2101
|
}
|
|
2001
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
|
+
|
|
2002
2107
|
function rejectSessionBinding(res, status, error, extra = {}) {
|
|
2003
2108
|
return res.status(status).json({ error, ...extra });
|
|
2004
2109
|
}
|
|
2005
2110
|
|
|
2006
2111
|
function reconcileSubagentRunsForTerminalTurn(messageId, { status = 'failed' } = {}) {
|
|
2007
|
-
|
|
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));
|
|
2008
2117
|
if (!running.length) return 0;
|
|
2009
2118
|
const now = new Date().toISOString();
|
|
2010
|
-
stmts.closeRunningSubagentRunsByQueueMessage?.run?.(status, now, now, messageId);
|
|
2011
2119
|
for (const run of running) {
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
status,
|
|
2018
|
-
queueMessageId: messageId,
|
|
2019
|
-
reconciled: true,
|
|
2020
|
-
});
|
|
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 });
|
|
2021
2125
|
}
|
|
2022
2126
|
return running.length;
|
|
2023
2127
|
}
|
|
@@ -2104,6 +2208,19 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2104
2208
|
});
|
|
2105
2209
|
io.emit('message_status', { messageId, conversationId, status: 'failed' });
|
|
2106
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 {}
|
|
2107
2224
|
cancelPendingRelayQuestionsForMessage(messageId);
|
|
2108
2225
|
const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)
|
|
2109
2226
|
? normalizeSessionWorkerId(queueRow?.owner_sdk_session_id)
|
|
@@ -3825,6 +3942,137 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3825
3942
|
});
|
|
3826
3943
|
});
|
|
3827
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
|
+
|
|
3828
4076
|
app.get('/api/drives/file', auth, (req, res) => {
|
|
3829
4077
|
const requestedPath = String(req.query.path || '').trim();
|
|
3830
4078
|
|
|
@@ -5249,7 +5497,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5249
5497
|
}
|
|
5250
5498
|
const rowAttemptId = String(row.attempt_id || '').trim() || null;
|
|
5251
5499
|
if (attemptId && rowAttemptId && attemptId !== rowAttemptId) {
|
|
5252
|
-
|
|
5500
|
+
staleAttemptLog.note({ messageId, attemptId, currentAttemptId: rowAttemptId, route: 'queue-cancelled', status: row.status });
|
|
5253
5501
|
return res.status(409).json({ error: 'stale_attempt' });
|
|
5254
5502
|
}
|
|
5255
5503
|
|
|
@@ -5704,6 +5952,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5704
5952
|
sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
|
|
5705
5953
|
}
|
|
5706
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));
|
|
5707
5958
|
if (runtimeState.tunnelState?.blocking) {
|
|
5708
5959
|
return res.json({
|
|
5709
5960
|
message: null,
|
|
@@ -6689,7 +6940,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6689
6940
|
// the legacy conditional semantics.
|
|
6690
6941
|
const claimedAttemptId = String(req.body.attemptId || '').trim() || null;
|
|
6691
6942
|
if (claimedAttemptId && q && String(q.attempt_id || '') !== claimedAttemptId) {
|
|
6692
|
-
|
|
6943
|
+
staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: q.attempt_id, route: 'response', status: q.status });
|
|
6693
6944
|
return res.status(409).json({ error: 'stale_attempt', currentStatus: q.status });
|
|
6694
6945
|
}
|
|
6695
6946
|
if (!claimedAttemptId && q && q.status === 'processing') {
|
|
@@ -6782,7 +7033,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6782
7033
|
if (!failed) {
|
|
6783
7034
|
const currentRow = stmts.findQById?.get(messageId) || null;
|
|
6784
7035
|
if (claimedAttemptId && currentRow && String(currentRow.attempt_id || '') !== claimedAttemptId) {
|
|
6785
|
-
|
|
7036
|
+
staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: currentRow.attempt_id, route: 'response-terminal', status: currentRow.status });
|
|
6786
7037
|
return res.status(409).json({ error: 'stale_attempt', currentStatus: String(currentRow.status || 'unknown') });
|
|
6787
7038
|
}
|
|
6788
7039
|
console.log(`[${ts()}] RESPONSE ${messageId?.slice(0,8)} ignored=not_pending_or_processing`);
|
|
@@ -6998,7 +7249,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6998
7249
|
const currentRow = stmts.findQById?.get(messageId) || null;
|
|
6999
7250
|
const currentStatus = String(currentRow?.status || 'unknown');
|
|
7000
7251
|
if (claimedAttemptId && currentRow && String(currentRow.attempt_id || '') !== claimedAttemptId) {
|
|
7001
|
-
|
|
7252
|
+
staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: currentRow.attempt_id, route: 'response-finalize', status: currentStatus });
|
|
7002
7253
|
return res.status(409).json({ error: 'stale_attempt', currentStatus });
|
|
7003
7254
|
}
|
|
7004
7255
|
console.log(`[${ts()}] RESPONSE ${messageId?.slice(0,8)} ignored=not_pending_or_processing actual_status=${currentStatus}`);
|
|
@@ -7174,6 +7425,25 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7174
7425
|
sourceMessageId: messageId,
|
|
7175
7426
|
text: resolvedText,
|
|
7176
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 {}
|
|
7177
7447
|
}
|
|
7178
7448
|
cancelPendingRelayQuestionsForMessage(messageId);
|
|
7179
7449
|
res.json({ ok: true });
|
|
@@ -7270,6 +7540,37 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7270
7540
|
res.json({ ok: true, messageId, conversationId, attemptId });
|
|
7271
7541
|
});
|
|
7272
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
|
+
|
|
7273
7574
|
// POST /api/background-tasks — a session worker publishes its live
|
|
7274
7575
|
// background-task set (REPLACE semantics, mirroring the SDK's
|
|
7275
7576
|
// background_tasks_changed signal) so the composer panel tracks it live.
|
|
@@ -7296,10 +7597,40 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7296
7597
|
// Sanitized digest or undefined: JSON serialization (store broadcast,
|
|
7297
7598
|
// conversation payload) drops the key entirely for flat rows.
|
|
7298
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,
|
|
7299
7603
|
}))
|
|
7300
7604
|
.filter((task) => task.taskId);
|
|
7301
7605
|
backgroundTaskStore?.replace?.(conversationId, tasks);
|
|
7302
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
|
+
}
|
|
7303
7634
|
res.json({ ok: true, count: tasks.length });
|
|
7304
7635
|
});
|
|
7305
7636
|
|
|
@@ -7332,7 +7663,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7332
7663
|
if (!claimed || !queueRow) return false;
|
|
7333
7664
|
const current = String(queueRow.attempt_id || '');
|
|
7334
7665
|
if (current === claimed) return false;
|
|
7335
|
-
|
|
7666
|
+
staleAttemptLog.note({ messageId: queueRow.id, attemptId: claimed, currentAttemptId: current, route, status: queueRow.status });
|
|
7336
7667
|
res.status(409).json({ error: 'stale_attempt', currentStatus: String(queueRow.status || 'unknown') });
|
|
7337
7668
|
return true;
|
|
7338
7669
|
}
|
|
@@ -7347,10 +7678,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7347
7678
|
|
|
7348
7679
|
const q = stmts.findQById.get(messageId);
|
|
7349
7680
|
if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'activity' })) return;
|
|
7681
|
+
try { sessionActivityTracker?.touch?.(q?.conversation_id || conversationId); } catch {}
|
|
7350
7682
|
const responseMessageId = q?.response_message_id || null;
|
|
7351
7683
|
const normalizedSubagentRunId = subagentRunId ? String(subagentRunId).trim() : null;
|
|
7352
7684
|
const normalizedMetadata = sanitizeActivityMetadata(metadata);
|
|
7353
|
-
stmts.insertActivity.run(
|
|
7685
|
+
const activityRow = stmts.insertActivity.run(
|
|
7354
7686
|
messageId,
|
|
7355
7687
|
responseMessageId,
|
|
7356
7688
|
conversationId,
|
|
@@ -7360,6 +7692,14 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7360
7692
|
normalizedSubagentRunId,
|
|
7361
7693
|
normalizedMetadata ? JSON.stringify(normalizedMetadata) : null,
|
|
7362
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
|
+
}
|
|
7363
7703
|
|
|
7364
7704
|
io.emit('relay_activity', {
|
|
7365
7705
|
messageId,
|
|
@@ -7394,6 +7734,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7394
7734
|
return res.status(409).json({ error: 'Stream conversationId does not match queue conversation' });
|
|
7395
7735
|
}
|
|
7396
7736
|
if (rejectStaleAttemptWrite({ req, res, queueRow, route: 'stream' })) return;
|
|
7737
|
+
try { sessionActivityTracker?.touch?.(queueConversationId); } catch {}
|
|
7397
7738
|
|
|
7398
7739
|
const isStreamSeqConstraintError = (error) => {
|
|
7399
7740
|
const message = String(error?.message || '').toLowerCase();
|
|
@@ -7474,6 +7815,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7474
7815
|
return res.status(500).json({ error: error?.message || 'Failed to persist stream event' });
|
|
7475
7816
|
}
|
|
7476
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
|
+
|
|
7477
7823
|
// Volatile keeps these out of the connection-state-recovery buffer. Each chunk
|
|
7478
7824
|
// carries the full text so far, so replaying a turn's worth of them costs
|
|
7479
7825
|
// megabytes to deliver what one reconnect resync already fetches from
|
|
@@ -7515,6 +7861,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7515
7861
|
return res.status(409).json({ error: 'Thought conversationId does not match queue conversation' });
|
|
7516
7862
|
}
|
|
7517
7863
|
if (rejectStaleAttemptWrite({ req, res, queueRow, route: 'thought' })) return;
|
|
7864
|
+
try { sessionActivityTracker?.touch?.(queueConversationId); } catch {}
|
|
7518
7865
|
|
|
7519
7866
|
const relayMode = normalizeRelayMode(mode) || DEFAULT_RELAY_MODE;
|
|
7520
7867
|
const normalizedReasoningId = String(reasoningId || '').trim() || null;
|
|
@@ -7594,6 +7941,16 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7594
7941
|
}
|
|
7595
7942
|
}
|
|
7596
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
|
+
|
|
7597
7954
|
io.emit('relay_thought', {
|
|
7598
7955
|
messageId,
|
|
7599
7956
|
conversationId,
|
|
@@ -7608,7 +7965,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7608
7965
|
res.json({ ok: true, seq });
|
|
7609
7966
|
});
|
|
7610
7967
|
|
|
7611
|
-
// 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.
|
|
7612
7974
|
app.post('/api/subagent-run', auth, (req, res) => {
|
|
7613
7975
|
touchCli();
|
|
7614
7976
|
const { messageId, conversationId, subagentRunId, parentSubagentId, displayName, status } = req.body || {};
|
|
@@ -7617,40 +7979,56 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7617
7979
|
const normalizedSubagentRunId = sanitizeSubagentRunId(subagentRunId);
|
|
7618
7980
|
const normalizedParentSubagentId = sanitizeSubagentRunId(parentSubagentId);
|
|
7619
7981
|
const normalizedDisplayName = displayName ? String(displayName).trim() : null;
|
|
7620
|
-
const normalizedStatus = status ?
|
|
7982
|
+
const normalizedStatus = status ? normalizeSubagentRunStatus(status) : 'running';
|
|
7983
|
+
const normalizedMessageId = String(messageId || '').trim();
|
|
7621
7984
|
|
|
7622
|
-
if (!
|
|
7985
|
+
if (!conversationId || !normalizedSubagentRunId) {
|
|
7623
7986
|
return res.status(400).json({ error: 'Missing required subagent-run fields: messageId, conversationId, subagentRunId' });
|
|
7624
7987
|
}
|
|
7625
|
-
|
|
7626
|
-
const q = stmts.findQById.get(messageId);
|
|
7627
|
-
if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'subagent-run' })) return;
|
|
7628
7988
|
const existingRun = stmts.getSubagentRun?.get(normalizedSubagentRunId);
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
7636
|
-
|
|
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
|
+
}
|
|
7637
8012
|
}
|
|
7638
|
-
const authoritativeConversationId = String(binding.conversationId || conversationId || '').trim();
|
|
7639
8013
|
|
|
7640
8014
|
const now = new Date().toISOString();
|
|
7641
8015
|
|
|
7642
8016
|
if (existingRun) {
|
|
7643
|
-
|
|
7644
|
-
|
|
7645
|
-
|
|
7646
|
-
|
|
7647
|
-
|
|
7648
|
-
|
|
7649
|
-
|
|
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
|
+
}
|
|
7650
8028
|
} else {
|
|
7651
8029
|
stmts.insertSubagentRun?.run(
|
|
7652
8030
|
normalizedSubagentRunId,
|
|
7653
|
-
|
|
8031
|
+
normalizedMessageId,
|
|
7654
8032
|
authoritativeConversationId,
|
|
7655
8033
|
normalizedParentSubagentId,
|
|
7656
8034
|
normalizedDisplayName,
|
|
@@ -7659,20 +8037,107 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7659
8037
|
now,
|
|
7660
8038
|
);
|
|
7661
8039
|
}
|
|
7662
|
-
|
|
7663
|
-
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
|
|
7667
|
-
|
|
7668
|
-
|
|
7669
|
-
status: normalizedStatus,
|
|
7670
|
-
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,
|
|
7671
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
|
+
}
|
|
7672
8064
|
|
|
7673
8065
|
res.json({ ok: true });
|
|
7674
8066
|
});
|
|
7675
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
|
+
|
|
7676
8141
|
// POST /api/requeue — relay re-queues a message it failed to process
|
|
7677
8142
|
app.post('/api/requeue', auth, (req, res) => {
|
|
7678
8143
|
const { messageId } = req.body;
|
|
@@ -7683,7 +8148,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7683
8148
|
// terminal-fail a row that has already been requeued to someone else).
|
|
7684
8149
|
const claimedAttemptId = String(req.body?.attemptId || '').trim() || null;
|
|
7685
8150
|
if (claimedAttemptId && q && String(q.attempt_id || '') !== claimedAttemptId) {
|
|
7686
|
-
|
|
8151
|
+
staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: q.attempt_id, route: 'requeue', status: q.status });
|
|
7687
8152
|
return res.status(409).json({ error: 'stale_attempt', currentStatus: String(q.status || 'unknown') });
|
|
7688
8153
|
}
|
|
7689
8154
|
const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)
|