@oar-sh/oar 0.9.12 → 0.9.14
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/.github/extensions/web-relay/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +508 -0
- package/README.md +12 -12
- package/bin/oar.js +155 -94
- package/package.json +5 -5
- package/server/README.md +479 -157
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +92 -15
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +60 -4
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +76 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +17 -9
- package/server/repositories/message-repository.mjs +32 -28
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +220 -37
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +79 -5
- package/server/server-runtime.mjs +335 -218
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +240 -13
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/oar-cli-helpers.mjs +6 -1
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +42 -8
- package/server/services/relay-event-service.mjs +210 -14
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +387 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +217 -28
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/task-output-service.mjs +217 -18
- package/server/services/windows-autostart-service.mjs +329 -31
- package/server/services/windows-boot-autostart-service.mjs +188 -60
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +106 -3
- package/shared/remote-relay-contract.mjs +32 -15
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/task-output-text.mjs +196 -83
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/server/uploads/image_input.md +0 -540
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import {
|
|
17
17
|
persistConversationModelPreference as persistConversationModelPreferenceTx,
|
|
18
18
|
} from '../services/conversation-preferences-service.mjs';
|
|
19
|
+
import { fallbackReasoningEffort, inheritedConversationModel } from '../services/conversation-model-fallback.mjs';
|
|
19
20
|
import { killTmuxSession } from '../services/session-worker-launch-service.mjs';
|
|
20
21
|
import { normalizeWorkerSteeringSnapshot } from '../services/session-worker-registry-service.mjs';
|
|
21
22
|
import {
|
|
@@ -45,6 +46,8 @@ import { normalizeBackgroundTaskDigest } from '../../shared/worker-runtime/backg
|
|
|
45
46
|
import {
|
|
46
47
|
DEFAULT_TASK_STALL_MINUTES,
|
|
47
48
|
TASK_OUTPUT_LIMITS,
|
|
49
|
+
cleanTaskText,
|
|
50
|
+
normalizeTaskCommand,
|
|
48
51
|
normalizeTaskOutputKind,
|
|
49
52
|
normalizeTaskOutputPath,
|
|
50
53
|
} from '../../shared/task-output-text.mjs';
|
|
@@ -61,13 +64,14 @@ import {
|
|
|
61
64
|
sanitizeRunHeaderPatch,
|
|
62
65
|
TIMELINE_READ_DEFAULT_LIMIT,
|
|
63
66
|
} from '../services/subagent-timeline-service.mjs';
|
|
64
|
-
import { normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
|
|
67
|
+
import { SUBAGENT_TASK_TYPES, isTerminalSubagentRunStatus, normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
|
|
65
68
|
import { RELAY_EVENT_MESSAGE_KIND } from '../../shared/relay-events.mjs';
|
|
66
69
|
import { isRelayShutdownQueued, relayShutdownGateReply } from '../services/relay-shutdown-gate.mjs';
|
|
67
70
|
import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limiter.mjs';
|
|
68
71
|
import { createSubagentTimelinePublisher } from '../services/subagent-viewer-rooms.mjs';
|
|
69
72
|
import { createLocalRequestCheck } from '../services/request-boundary.mjs';
|
|
70
73
|
import { buildClaudeCloudDelivery, isClaudeCloudProviderType } from '../services/claude-cloud-session-service.mjs';
|
|
74
|
+
import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
|
|
71
75
|
import {
|
|
72
76
|
admitRemoteRelayRequest,
|
|
73
77
|
readRemoteRelayRequest,
|
|
@@ -244,9 +248,17 @@ function findRecentDuplicateUserMessage({
|
|
|
244
248
|
const exempt = new Set((Array.isArray(exemptMessageIds) ? exemptMessageIds : [])
|
|
245
249
|
.map((id) => String(id || '').trim()).filter(Boolean));
|
|
246
250
|
const isExempt = (row) => exempt.has(String(row?.id || '').trim());
|
|
251
|
+
// A message whose turn failed never ran, so sending it again is a retry,
|
|
252
|
+
// not a double submit. Only failed rows: a cancelled or finished turn stays
|
|
253
|
+
// a duplicate, as before.
|
|
254
|
+
const failedTurnIds = new Set((Array.isArray(recentQueueRows) ? recentQueueRows : [])
|
|
255
|
+
.filter((row) => String(row?.status || '').trim().toLowerCase() === 'failed')
|
|
256
|
+
.map((row) => String(row?.id || '').trim())
|
|
257
|
+
.filter(Boolean));
|
|
247
258
|
|
|
248
259
|
for (const row of Array.isArray(recentUserMessages) ? recentUserMessages : []) {
|
|
249
260
|
if (isExempt(row)) continue;
|
|
261
|
+
if (failedTurnIds.has(String(row?.id || '').trim())) continue;
|
|
250
262
|
const rowText = normalizeDuplicateMessageText(row?.text || '');
|
|
251
263
|
if (!rowText || rowText !== fingerprint) continue;
|
|
252
264
|
const rowTime = Date.parse(row?.timestamp || '');
|
|
@@ -1419,6 +1431,9 @@ function isOpenAIImageAttachment(raw) {
|
|
|
1419
1431
|
export function resolveOpenAIImageEditAttachment(rawAttachments, {
|
|
1420
1432
|
maxImageBytes = 20 * 1024 * 1024,
|
|
1421
1433
|
allowedRootPath = '',
|
|
1434
|
+
// Folders of earlier versions that uploads are still read from: only a
|
|
1435
|
+
// sha256-named blob directly inside one counts.
|
|
1436
|
+
allowedBlobRootPaths = [],
|
|
1422
1437
|
allowedGeneratedImagesRootPath = '',
|
|
1423
1438
|
} = {}) {
|
|
1424
1439
|
const attachments = Array.isArray(rawAttachments) ? rawAttachments : [];
|
|
@@ -1439,8 +1454,11 @@ export function resolveOpenAIImageEditAttachment(rawAttachments, {
|
|
|
1439
1454
|
const filePath = String(firstImage.path || '').trim();
|
|
1440
1455
|
if (!filePath) throw new Error('Image attachment is missing file path or data URL');
|
|
1441
1456
|
const allowedRoot = String(allowedRootPath || '').trim();
|
|
1457
|
+
const blobRoots = (Array.isArray(allowedBlobRootPaths) ? allowedBlobRootPaths : [])
|
|
1458
|
+
.map((root) => String(root || '').trim())
|
|
1459
|
+
.filter(Boolean);
|
|
1442
1460
|
const generatedImagesRoot = String(allowedGeneratedImagesRootPath || '').trim();
|
|
1443
|
-
if (allowedRoot || generatedImagesRoot) {
|
|
1461
|
+
if (allowedRoot || blobRoots.length || generatedImagesRoot) {
|
|
1444
1462
|
const resolvedPath = path.resolve(filePath);
|
|
1445
1463
|
const isWithinRoot = (rootPath) => {
|
|
1446
1464
|
if (!rootPath) return false;
|
|
@@ -1455,7 +1473,11 @@ export function resolveOpenAIImageEditAttachment(rawAttachments, {
|
|
|
1455
1473
|
&& relativeParts[0] !== '..'
|
|
1456
1474
|
&& relativeParts[1] === 'generated-images';
|
|
1457
1475
|
})();
|
|
1458
|
-
|
|
1476
|
+
const isOldUploadBlob = blobRoots.some((root) => (
|
|
1477
|
+
path.dirname(resolvedPath) === path.resolve(root)
|
|
1478
|
+
&& /^[a-f0-9]{64}$/.test(path.basename(resolvedPath))
|
|
1479
|
+
));
|
|
1480
|
+
if (!isWithinRoot(allowedRoot) && !isOldUploadBlob && !isGeneratedImagePath) {
|
|
1459
1481
|
throw new Error('Image attachment path is outside the upload directory');
|
|
1460
1482
|
}
|
|
1461
1483
|
}
|
|
@@ -1735,6 +1757,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1735
1757
|
MAX_UPLOAD_BYTES,
|
|
1736
1758
|
MAX_UPLOAD_ATTACHMENTS,
|
|
1737
1759
|
uploadsDir,
|
|
1760
|
+
uploadReadDirs = [uploadsDir],
|
|
1738
1761
|
MAX_REPO_TREE_NODES,
|
|
1739
1762
|
MAX_REQUEUE_RETRIES,
|
|
1740
1763
|
MAX_IMAGE_DATA_URL_LENGTH,
|
|
@@ -1792,6 +1815,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1792
1815
|
taskOutputSnapshots = null,
|
|
1793
1816
|
// Has the health poller read soon (a task with a console ended).
|
|
1794
1817
|
pokeTaskHealth = null,
|
|
1818
|
+
// What a recovery did with a turn that was running when the relay
|
|
1819
|
+
// started (services/restart-resume-service.mjs noteOutcome).
|
|
1820
|
+
noteRestartTurnOutcome = () => {},
|
|
1795
1821
|
getTaskStallMinutes = () => DEFAULT_TASK_STALL_MINUTES,
|
|
1796
1822
|
sendWorkerControl,
|
|
1797
1823
|
ensureSessionId,
|
|
@@ -1881,6 +1907,21 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1881
1907
|
return tasks.find((task) => (task?.toolUseId && task.toolUseId === runId) || (taskId && task?.taskId === taskId)) || null;
|
|
1882
1908
|
}
|
|
1883
1909
|
|
|
1910
|
+
// A conversation's sub-agent run by its task id. A Claude agent resumed by
|
|
1911
|
+
// a new message keeps its task id, and the CLI names the call that resumed
|
|
1912
|
+
// it (SendMessage) as the task's tool use, an id no run row carries: its
|
|
1913
|
+
// run is the one the task id leads to.
|
|
1914
|
+
function subagentRunOfTask(conversationId, taskId) {
|
|
1915
|
+
const conversation = String(conversationId || '').trim();
|
|
1916
|
+
const task = String(taskId || '').trim().slice(0, 200);
|
|
1917
|
+
if (!conversation || !task) return null;
|
|
1918
|
+
try {
|
|
1919
|
+
return stmts.getSubagentRunByTaskId?.get?.(conversation, task) || null;
|
|
1920
|
+
} catch {
|
|
1921
|
+
return null;
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1884
1925
|
// Stop is offered while a run is running: a background run through its
|
|
1885
1926
|
// task, a foreground run through the sub-agent cancel route.
|
|
1886
1927
|
function isSubagentRunStoppable(run) {
|
|
@@ -2727,10 +2768,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2727
2768
|
// quietly.
|
|
2728
2769
|
stmts.dropStaleContinuation?.run?.(row.id);
|
|
2729
2770
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
2771
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
2730
2772
|
continue;
|
|
2731
2773
|
}
|
|
2732
2774
|
if (row.consumed_at) {
|
|
2733
2775
|
failConsumedQueueRow(row, { reason });
|
|
2776
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
2734
2777
|
continue;
|
|
2735
2778
|
}
|
|
2736
2779
|
const relayActivityRows = stmts.listActivityByQueueMessage?.all?.(row.id);
|
|
@@ -2767,6 +2810,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2767
2810
|
error: reason,
|
|
2768
2811
|
});
|
|
2769
2812
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
|
|
2813
|
+
noteRestartTurnOutcome(row, 'requeued');
|
|
2770
2814
|
}
|
|
2771
2815
|
for (const row of rowsToFail) {
|
|
2772
2816
|
const failureRecord = buildHeartbeatIdleReplayFailure({
|
|
@@ -2792,6 +2836,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2792
2836
|
ok: false,
|
|
2793
2837
|
error: failureRecord.code,
|
|
2794
2838
|
});
|
|
2839
|
+
// Started, so not replayed: a turn the agent never heard the end of.
|
|
2840
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
2795
2841
|
}
|
|
2796
2842
|
}
|
|
2797
2843
|
if (rowsToRecover.length || rowsToFail.length) {
|
|
@@ -4429,7 +4475,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4429
4475
|
const requesterSessionId = normalizeSessionWorkerId(requesterIdentity?.sessionId);
|
|
4430
4476
|
const sessionWorkerRoutingEnabled = isSessionWorkerRoutingEnabled(featureFlags);
|
|
4431
4477
|
let conversationSdkSessionId = null;
|
|
4432
|
-
|
|
4478
|
+
// The conversation's saved choices, for a send that leaves one out: a
|
|
4479
|
+
// send to an existing conversation that names no mode runs in the mode
|
|
4480
|
+
// the conversation is in, not the relay default.
|
|
4481
|
+
const existingConversation = (!newConversation && conversationId)
|
|
4482
|
+
? (stmts.getConvAnyStatus?.get?.(conversationId) || null)
|
|
4483
|
+
: null;
|
|
4484
|
+
const explicitRelayModeGiven = !!String(relayMode || mode || '').trim();
|
|
4485
|
+
const savedRelayMode = !explicitRelayModeGiven && existingConversation?.preferred_relay_mode
|
|
4486
|
+
? normalizeRelayMode(existingConversation.preferred_relay_mode)
|
|
4487
|
+
: null;
|
|
4488
|
+
const requestedRelayMode = savedRelayMode || normalizeRelayMode(relayMode || mode);
|
|
4433
4489
|
const trimmedText = stripRelayPromptContext(text, requestedRelayMode || relayMode || mode);
|
|
4434
4490
|
const normalizedAttachments = normalizeAttachments(rawAttachments);
|
|
4435
4491
|
const requestedAttachmentCount = Math.min(
|
|
@@ -4761,9 +4817,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4761
4817
|
? (pinnedGrokModel || String(configuredGrok?.model || '').trim())
|
|
4762
4818
|
: '';
|
|
4763
4819
|
// Claude Cloud conversations resolve models against the cloud provider's
|
|
4764
|
-
// own list (plain ids, no "[1m]" tier). The
|
|
4765
|
-
// session's
|
|
4766
|
-
//
|
|
4820
|
+
// own list (plain ids, no "[1m]" tier). The first message creates the
|
|
4821
|
+
// cloud session with the conversation's model; a later change is applied
|
|
4822
|
+
// by the worker before the next message (see below).
|
|
4767
4823
|
const configuredClaudeCloud = runtimeUsesClaudeCloud ? getClaudeCloudProviderSettings() : null;
|
|
4768
4824
|
const pinnedClaudeCloudModel = runtimeUsesClaudeCloud
|
|
4769
4825
|
? stripModelTierSuffix(existingRuntimeSession?.provider_model)
|
|
@@ -4786,6 +4842,34 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4786
4842
|
// sends set_model before the message), so a changed model is taken as
|
|
4787
4843
|
// for a local Claude chat.
|
|
4788
4844
|
const claudeCloudModel = claudeCloudModelSelection ? claudeCloudModelSelection.model : '';
|
|
4845
|
+
// What the request itself chose. Only these are saved as the
|
|
4846
|
+
// conversation's preference; a value filled in below never is.
|
|
4847
|
+
const explicitModelGiven = !!requestedOpenAIModel;
|
|
4848
|
+
const conversationReasoningEffort = String(existingConversation?.preferred_reasoning_effort || '').trim().toLowerCase();
|
|
4849
|
+
// A Copilot conversation sent no model keeps its own: the saved
|
|
4850
|
+
// preference, the runtime session's model, the latest user message's,
|
|
4851
|
+
// and the catalog default only when it has none of these. A model the
|
|
4852
|
+
// catalog no longer lists is refused rather than swapped for another.
|
|
4853
|
+
const usesGitHubCatalog = !useOpenAIProvider && !runtimeBoundToExplicitProvider;
|
|
4854
|
+
const inheritedModel = usesGitHubCatalog && !shouldCreateConversation && !explicitModelGiven
|
|
4855
|
+
? inheritedConversationModel({
|
|
4856
|
+
conversation: existingConversation,
|
|
4857
|
+
runtimeSession: existingRuntimeSession,
|
|
4858
|
+
latestUserMessageModel: stmts.getLatestUserMessageModel?.get?.(conversationId)?.model || '',
|
|
4859
|
+
})
|
|
4860
|
+
: null;
|
|
4861
|
+
const githubModelResolution = usesGitHubCatalog
|
|
4862
|
+
? resolveRequestedModel(inheritedModel ? inheritedModel.model : model)
|
|
4863
|
+
: null;
|
|
4864
|
+
if (inheritedModel && !githubModelResolution?.ok) {
|
|
4865
|
+
return res.status(409).json({
|
|
4866
|
+
error: `This conversation's model "${inheritedModel.model}" is no longer available. Choose a model and send again.`,
|
|
4867
|
+
code: 'CONVERSATION_MODEL_UNAVAILABLE',
|
|
4868
|
+
conversationId,
|
|
4869
|
+
model: inheritedModel.model,
|
|
4870
|
+
supportedModels: githubModelResolution?.available || [],
|
|
4871
|
+
});
|
|
4872
|
+
}
|
|
4789
4873
|
const modelResolution = useOpenAIProvider
|
|
4790
4874
|
? {
|
|
4791
4875
|
ok: !!openAIModel,
|
|
@@ -4826,7 +4910,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4826
4910
|
reasoningEffort: null,
|
|
4827
4911
|
error: claudeCloudModel ? null : 'Claude Cloud model is not configured',
|
|
4828
4912
|
}
|
|
4829
|
-
:
|
|
4913
|
+
: githubModelResolution))));
|
|
4830
4914
|
if (!modelResolution.ok) return res.status(400).json({ error: modelResolution.error, supportedModels: modelResolution.available || [] });
|
|
4831
4915
|
const requestedModel = String(modelResolution.model || '').trim();
|
|
4832
4916
|
const requestedAutoModel = requestedModel.toLowerCase() === AUTO_MODEL_SENTINEL;
|
|
@@ -4851,6 +4935,15 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4851
4935
|
code: 'IMAGE_TARGET_REQUIRES_IMAGE_MODEL',
|
|
4852
4936
|
});
|
|
4853
4937
|
}
|
|
4938
|
+
// A send that names no effort runs with the conversation's own, clamped
|
|
4939
|
+
// to the nearest level the model has. Null when there is nothing to keep
|
|
4940
|
+
// or it neither matches nor clamps (an ultracode on a model without it):
|
|
4941
|
+
// the provider's own default applies then.
|
|
4942
|
+
const conversationEffortFor = (supported) => (
|
|
4943
|
+
!explicitReasoningEffort && conversationReasoningEffort
|
|
4944
|
+
? (fallbackReasoningEffort([conversationReasoningEffort], supported, { fallback: '' }) || null)
|
|
4945
|
+
: null
|
|
4946
|
+
);
|
|
4854
4947
|
// Claude conversations validate effort against the Claude provider's
|
|
4855
4948
|
// per-model levels ('none' = SDK default). Effort is per-turn: each Claude
|
|
4856
4949
|
// turn is a fresh query(), so it can change between messages.
|
|
@@ -4859,6 +4952,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4859
4952
|
const supported = Array.isArray(configuredClaude?.effortsByModel?.[String(requestedModel || '').trim().toLowerCase()])
|
|
4860
4953
|
? configuredClaude.effortsByModel[String(requestedModel || '').trim().toLowerCase()]
|
|
4861
4954
|
: [...DEFAULT_CLAUDE_REASONING_EFFORTS];
|
|
4955
|
+
const kept = conversationEffortFor(supported);
|
|
4956
|
+
if (kept) return { ok: true, effort: kept, supported };
|
|
4862
4957
|
const effort = supported.includes(requestedEffort) ? requestedEffort : 'none';
|
|
4863
4958
|
return { ok: true, effort: effort === 'none' ? 'none' : effort, supported };
|
|
4864
4959
|
};
|
|
@@ -4874,8 +4969,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4874
4969
|
// the worker validates it against the resolved model's live params
|
|
4875
4970
|
// and falls back to the model default on any mismatch. Clamping to
|
|
4876
4971
|
// 'none' here would actively disable thinking on the resolved model.
|
|
4877
|
-
|
|
4972
|
+
// The conversation's own effort goes through the same way.
|
|
4973
|
+
return { ok: true, effort: requestedEffort || conversationReasoningEffort, supported: [] };
|
|
4878
4974
|
}
|
|
4975
|
+
const kept = conversationEffortFor(discovered);
|
|
4976
|
+
if (kept) return { ok: true, effort: kept, supported: discovered };
|
|
4879
4977
|
const effort = discovered.includes(requestedEffort) ? requestedEffort : 'none';
|
|
4880
4978
|
return { ok: true, effort, supported: discovered };
|
|
4881
4979
|
};
|
|
@@ -4884,11 +4982,16 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4884
4982
|
const supported = Array.isArray(configuredGrok?.effortsByModel?.[String(requestedModel || '').trim().toLowerCase()])
|
|
4885
4983
|
? configuredGrok.effortsByModel[String(requestedModel || '').trim().toLowerCase()]
|
|
4886
4984
|
: ['none', 'low', 'medium', 'high'];
|
|
4985
|
+
const kept = conversationEffortFor(supported);
|
|
4986
|
+
if (kept) return { ok: true, effort: kept, supported };
|
|
4887
4987
|
const effort = supported.includes(requestedEffort) ? requestedEffort : 'none';
|
|
4888
4988
|
return { ok: true, effort, supported };
|
|
4889
4989
|
};
|
|
4890
4990
|
let reasoningResolution = useOpenAIProvider
|
|
4891
|
-
? resolveOpenAIReasoningEffort(
|
|
4991
|
+
? resolveOpenAIReasoningEffort(
|
|
4992
|
+
explicitReasoningEffort || conversationEffortFor(openAIReasoningEffortsForModel(openAIModel)) || '',
|
|
4993
|
+
openAIModel,
|
|
4994
|
+
)
|
|
4892
4995
|
: (runtimeUsesClaude
|
|
4893
4996
|
? resolveClaudeReasoningEffort()
|
|
4894
4997
|
: (runtimeUsesCursor
|
|
@@ -4902,15 +5005,21 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4902
5005
|
requestedModel,
|
|
4903
5006
|
explicitReasoningEffort || modelResolution.reasoningEffort || null,
|
|
4904
5007
|
)))));
|
|
5008
|
+
// No effort sent (or the model's variant carried one it lacks): the
|
|
5009
|
+
// conversation's own effort, clamped to the model's levels, else its
|
|
5010
|
+
// lowest level that is not 'none'. Some models list 'none' and the API
|
|
5011
|
+
// still refuses it, so it is taken only when the conversation chose it or
|
|
5012
|
+
// the model has nothing else.
|
|
4905
5013
|
if (!reasoningResolution?.ok && !explicitReasoningEffort) {
|
|
4906
5014
|
const supportedEfforts = Array.isArray(reasoningResolution?.supported)
|
|
4907
5015
|
? reasoningResolution.supported.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean)
|
|
4908
5016
|
: [];
|
|
4909
|
-
const
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
5017
|
+
const fallbackEffort = fallbackReasoningEffort(
|
|
5018
|
+
[modelResolution.reasoningEffort, conversationReasoningEffort],
|
|
5019
|
+
supportedEfforts,
|
|
5020
|
+
);
|
|
5021
|
+
if (fallbackEffort) {
|
|
5022
|
+
reasoningResolution = resolveRequestedReasoningEffort(requestedModel, fallbackEffort);
|
|
4914
5023
|
}
|
|
4915
5024
|
}
|
|
4916
5025
|
if (!reasoningResolution?.ok) {
|
|
@@ -4920,6 +5029,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4920
5029
|
});
|
|
4921
5030
|
}
|
|
4922
5031
|
const requestedReasoningEffort = reasoningResolution.effort || null;
|
|
5032
|
+
// The request's own effort: sent as such, or carried by the variant id of
|
|
5033
|
+
// a model it named.
|
|
5034
|
+
const explicitEffortGiven = !!explicitReasoningEffort || (explicitModelGiven && !!modelResolution.reasoningEffort);
|
|
4923
5035
|
let normalizedContextTier = requestedContextTier;
|
|
4924
5036
|
if (openAIImageModel) {
|
|
4925
5037
|
normalizedContextTier = normalizeOpenAIImageSize(requestedContextTier) || 'auto';
|
|
@@ -4964,6 +5076,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4964
5076
|
return res.json({
|
|
4965
5077
|
ok: true,
|
|
4966
5078
|
duplicate: true,
|
|
5079
|
+
queued: false,
|
|
4967
5080
|
alreadyResent: true,
|
|
4968
5081
|
duplicateOfMessageId: resend.alreadyResentAs,
|
|
4969
5082
|
conversationId,
|
|
@@ -4994,6 +5107,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4994
5107
|
return res.json({
|
|
4995
5108
|
ok: true,
|
|
4996
5109
|
duplicate: true,
|
|
5110
|
+
// Said outright for a caller that reads only `ok`: nothing new runs.
|
|
5111
|
+
queued: false,
|
|
4997
5112
|
duplicateWindowMs: DUPLICATE_USER_MESSAGE_WINDOW_MS,
|
|
4998
5113
|
duplicateSource: duplicateMessage.source,
|
|
4999
5114
|
duplicateOfMessageId: duplicateMessage.messageId,
|
|
@@ -5175,15 +5290,25 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5175
5290
|
}
|
|
5176
5291
|
// Another relay's agent picks the model and mode of its own turn; the
|
|
5177
5292
|
// composer keeps restoring the user's choice for this conversation.
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5293
|
+
// Any other send to an existing conversation saves only what it named:
|
|
5294
|
+
// a model or an effort filled in from a fallback is never written back
|
|
5295
|
+
// as the conversation's choice (an empty effort keeps the stored one).
|
|
5296
|
+
const savedModel = String(existingConversation?.preferred_model || '').trim();
|
|
5297
|
+
if (shouldCreateConversation) {
|
|
5298
|
+
conversationPreferences = persistConversationModelPreference(
|
|
5299
|
+
convId, requestedRelayMode, requestedModel, requestedReasoningEffort, now,
|
|
5300
|
+
);
|
|
5301
|
+
} else if (remoteRequest.remote || (!explicitModelGiven && !(explicitEffortGiven && savedModel))) {
|
|
5302
|
+
conversationPreferences = readConversationModelPreference(convId, requestedRelayMode);
|
|
5303
|
+
} else {
|
|
5304
|
+
conversationPreferences = persistConversationModelPreference(
|
|
5181
5305
|
convId,
|
|
5182
5306
|
requestedRelayMode,
|
|
5183
|
-
requestedModel,
|
|
5184
|
-
requestedReasoningEffort,
|
|
5307
|
+
explicitModelGiven ? requestedModel : savedModel,
|
|
5308
|
+
explicitEffortGiven ? requestedReasoningEffort : '',
|
|
5185
5309
|
now,
|
|
5186
5310
|
);
|
|
5311
|
+
}
|
|
5187
5312
|
stmts.insertQ.run(
|
|
5188
5313
|
msgId,
|
|
5189
5314
|
convId,
|
|
@@ -5527,6 +5652,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5527
5652
|
const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
|
|
5528
5653
|
for (const id of activeQueueMessageIds) {
|
|
5529
5654
|
refreshProcessingLeaseForOwnedMessage.run(leaseExpiresAt, now, id, requesterSessionId);
|
|
5655
|
+
// The worker has the turn: if it was running when the relay
|
|
5656
|
+
// started, the restart did not cut it off.
|
|
5657
|
+
noteRestartTurnOutcome({ id }, 'claimed');
|
|
5530
5658
|
}
|
|
5531
5659
|
recoverOwnedProcessingRowsForSession(requesterSessionId, {
|
|
5532
5660
|
excludeMessageIds: activeQueueMessageIds,
|
|
@@ -6570,7 +6698,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6570
6698
|
|| rawRestart === 1
|
|
6571
6699
|
|| String(rawRestart || '').trim().toLowerCase() === 'true'
|
|
6572
6700
|
|| String(rawRestart || '').trim() === '1';
|
|
6573
|
-
|
|
6701
|
+
// `immediate` starts it now, without waiting for the running turns (the
|
|
6702
|
+
// banner's "Restart now"); the answer names what it did not wait for.
|
|
6703
|
+
const rawImmediate = req.body?.immediate;
|
|
6704
|
+
const immediate = rawImmediate === true
|
|
6705
|
+
|| rawImmediate === 1
|
|
6706
|
+
|| String(rawImmediate || '').trim().toLowerCase() === 'true'
|
|
6707
|
+
|| String(rawImmediate || '').trim() === '1';
|
|
6708
|
+
// The banner's "Restart now" names the queued request it saw (see
|
|
6709
|
+
// services/relay-restart-queue.mjs); a stale one is refused.
|
|
6710
|
+
const expectRequestedAt = String(req.body?.expectRequestedAt || '').trim().slice(0, 40) || null;
|
|
6711
|
+
const result = requestRelayShutdown({ reason, requestedBy, restart, immediate, expectRequestedAt });
|
|
6574
6712
|
return res.json({ ok: true, ...result });
|
|
6575
6713
|
});
|
|
6576
6714
|
|
|
@@ -6859,6 +6997,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6859
6997
|
editAttachment = resolveOpenAIImageEditAttachment(requestBody.attachments, {
|
|
6860
6998
|
maxImageBytes: MAX_UPLOAD_BYTES,
|
|
6861
6999
|
allowedRootPath: uploadsDir,
|
|
7000
|
+
allowedBlobRootPaths: uploadReadDirs.slice(1),
|
|
6862
7001
|
allowedGeneratedImagesRootPath: resolveSessionStateRoot?.() || '',
|
|
6863
7002
|
});
|
|
6864
7003
|
} catch (error) {
|
|
@@ -7694,7 +7833,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7694
7833
|
// Only a number the worker sent: a missing or null exit code stays null
|
|
7695
7834
|
// (Number(null) would read as a clean exit).
|
|
7696
7835
|
const exitCode = wholeNumber(req.body?.exitCode);
|
|
7697
|
-
|
|
7836
|
+
// Scrubbed like the output: a description can be the command line.
|
|
7837
|
+
const settledDescription = cleanTaskText(String(req.body?.description || ''), 500);
|
|
7698
7838
|
// The store is keyed as the worker posts. A task with a console stays
|
|
7699
7839
|
// viewable as an ended row; one that ended while this runtime was away
|
|
7700
7840
|
// becomes one when the post names its output.
|
|
@@ -7706,6 +7846,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7706
7846
|
outputPath: settledOutputPath || undefined,
|
|
7707
7847
|
outputKind: settledOutputPath ? normalizeTaskOutputKind(req.body?.outputKind, settledOutputPath) : undefined,
|
|
7708
7848
|
description: settledDescription,
|
|
7849
|
+
command: normalizeTaskCommand(req.body?.command) || undefined,
|
|
7709
7850
|
taskType: String(req.body?.taskType || '').trim().slice(0, 80),
|
|
7710
7851
|
startedAt: Number(req.body?.startedAt) || null,
|
|
7711
7852
|
}) || null;
|
|
@@ -7716,8 +7857,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7716
7857
|
sessionActivityTracker?.noteTaskSettled?.(conversationId, {
|
|
7717
7858
|
taskId,
|
|
7718
7859
|
toolUseId: sanitizeSubagentRunId(req.body?.toolUseId) || null,
|
|
7860
|
+
// Which run ended (a resumed agent runs again under the same task id).
|
|
7861
|
+
...(req.body?.taskRunId ? { taskRunId: String(req.body.taskRunId).trim().slice(0, 200) } : {}),
|
|
7719
7862
|
status: String(req.body?.status || '').trim().slice(0, 40) || 'completed',
|
|
7720
|
-
summary: String(req.body?.summary || '')
|
|
7863
|
+
summary: cleanTaskText(String(req.body?.summary || ''), 500) || null,
|
|
7721
7864
|
exitCode: exitCode ?? null,
|
|
7722
7865
|
// What the task was, from a worker that sends it: the end of a task
|
|
7723
7866
|
// this runtime never knew (it ended while the relay was away) is
|
|
@@ -7747,11 +7890,18 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7747
7890
|
.map((task) => ({
|
|
7748
7891
|
taskId: String(task?.taskId || '').trim(),
|
|
7749
7892
|
taskType: String(task?.taskType || '').trim(),
|
|
7750
|
-
|
|
7893
|
+
// Scrubbed like the output before anything keeps or sends them: a
|
|
7894
|
+
// description can be the command line (Grok's terminals, a Copilot
|
|
7895
|
+
// shell without one), a summary or tool call can quote one.
|
|
7896
|
+
description: cleanTaskText(String(task?.description || ''), 500),
|
|
7897
|
+
// A shell task's command line, where the worker knows it: scrubbed
|
|
7898
|
+
// like the output and cut at 2,000 characters before it is kept, so
|
|
7899
|
+
// every reader (the pages, read_session, the stored row) gets it so.
|
|
7900
|
+
command: normalizeTaskCommand(task?.command) || undefined,
|
|
7751
7901
|
startedAt: Number(task?.startedAt) || null,
|
|
7752
|
-
summary: String(task?.summary || '')
|
|
7902
|
+
summary: cleanTaskText(String(task?.summary || ''), 500) || null,
|
|
7753
7903
|
lastToolName: String(task?.lastToolName || '').trim() || null,
|
|
7754
|
-
lastToolCall: String(task?.lastToolCall || '').replace(/\s+/g, ' ')
|
|
7904
|
+
lastToolCall: cleanTaskText(String(task?.lastToolCall || '').replace(/\s+/g, ' '), 300) || null,
|
|
7755
7905
|
totalTokens: Number.isFinite(Number(task?.totalTokens)) ? Number(task.totalTokens) : null,
|
|
7756
7906
|
subagentType: String(task?.subagentType || '').trim().slice(0, 120) || null,
|
|
7757
7907
|
model: String(task?.model || '').trim().slice(0, 120) || null,
|
|
@@ -7765,6 +7915,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7765
7915
|
// The spawning tool_use id: the sub-agent run this task is. The panel
|
|
7766
7916
|
// opens the run's viewer from it.
|
|
7767
7917
|
toolUseId: sanitizeSubagentRunId(task?.toolUseId) || undefined,
|
|
7918
|
+
// Which run of the task this is (the SDK's run id): a resumed agent
|
|
7919
|
+
// keeps its task id and runs again under a new one.
|
|
7920
|
+
taskRunId: String(task?.taskRunId || '').trim().slice(0, 200) || undefined,
|
|
7768
7921
|
// The console (shell and Monitor tasks): the file holding the
|
|
7769
7922
|
// output, how it is obtained ('none': the provider has none to give).
|
|
7770
7923
|
...taskOutputFields(task),
|
|
@@ -7773,6 +7926,15 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7773
7926
|
exitCode: wholeNumber(task?.exitCode),
|
|
7774
7927
|
}))
|
|
7775
7928
|
.filter((task) => task.taskId);
|
|
7929
|
+
// A resumed agent's row names the call that resumed it, which no run
|
|
7930
|
+
// carries: it opens the run of its task instead (a worker that remembers
|
|
7931
|
+
// the spawn sends that already).
|
|
7932
|
+
const runConversationId = resolveTaskConversationId(conversationId);
|
|
7933
|
+
for (const task of tasks) {
|
|
7934
|
+
if (!task.toolUseId || !SUBAGENT_TASK_TYPES.has(task.taskType) || stmts.getSubagentRun?.get?.(task.toolUseId)) continue;
|
|
7935
|
+
const run = subagentRunOfTask(runConversationId, task.taskId);
|
|
7936
|
+
if (run) task.toolUseId = String(run.id);
|
|
7937
|
+
}
|
|
7776
7938
|
// The first path a task names wins: a later post cannot point a live
|
|
7777
7939
|
// task's console at another file.
|
|
7778
7940
|
for (const task of tasks) {
|
|
@@ -7870,9 +8032,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7870
8032
|
// or a snapshot). A task of theirs without one comes from a worker that
|
|
7871
8033
|
// started before the console existed.
|
|
7872
8034
|
const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok']);
|
|
7873
|
-
const SUBAGENT_OUTPUT_MESSAGE =
|
|
8035
|
+
const SUBAGENT_OUTPUT_MESSAGE = TASK_OUTPUT_NONE_MESSAGES.subagent;
|
|
7874
8036
|
|
|
7875
|
-
// Why a task has no output to show, for the console's `none` answer.
|
|
8037
|
+
// Why a task has no output to show, for the console's `none` answer. The
|
|
8038
|
+
// texts are the page's own (task-console-render.mjs), so the row's hover
|
|
8039
|
+
// title and the console say the same.
|
|
7876
8040
|
function noOutputMessage(conversationId, task) {
|
|
7877
8041
|
if (taskKindOf(task?.taskType) === 'subagent') return SUBAGENT_OUTPUT_MESSAGE;
|
|
7878
8042
|
let provider = '';
|
|
@@ -7880,9 +8044,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7880
8044
|
provider = String(stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.provider_type || '').trim().toLowerCase();
|
|
7881
8045
|
} catch {}
|
|
7882
8046
|
if (!provider || TASK_OUTPUT_PROVIDERS.has(provider)) {
|
|
7883
|
-
return
|
|
8047
|
+
return TASK_OUTPUT_NONE_MESSAGES.noOutputFile;
|
|
7884
8048
|
}
|
|
7885
|
-
return
|
|
8049
|
+
return TASK_OUTPUT_NONE_MESSAGES.provider;
|
|
7886
8050
|
}
|
|
7887
8051
|
|
|
7888
8052
|
// A task the session's task list shows that the task store does not hold:
|
|
@@ -7949,13 +8113,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7949
8113
|
});
|
|
7950
8114
|
}
|
|
7951
8115
|
const { task, ended, key } = found;
|
|
8116
|
+
// Held since a relay restart: its worker has not said yet whether it
|
|
8117
|
+
// still runs, so it is neither running nor stoppable.
|
|
8118
|
+
const held = !ended && found.restored === true;
|
|
7952
8119
|
const { kind, path } = taskOutputSource(task);
|
|
7953
8120
|
const head = {
|
|
7954
8121
|
ok: true,
|
|
7955
8122
|
taskId,
|
|
7956
8123
|
kind,
|
|
7957
8124
|
ended,
|
|
7958
|
-
status: ended ? task.status : 'running',
|
|
8125
|
+
status: ended ? task.status : (held ? 'held' : 'running'),
|
|
8126
|
+
...(held ? { held: true } : {}),
|
|
7959
8127
|
exitCode: ended ? (task.exitCode ?? null) : null,
|
|
7960
8128
|
health: task.health || null,
|
|
7961
8129
|
taskStallMinutes: taskStallMinutes(),
|
|
@@ -7963,9 +8131,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7963
8131
|
taskId,
|
|
7964
8132
|
taskType: task.taskType || null,
|
|
7965
8133
|
description: task.description || '',
|
|
8134
|
+
...(task.command ? { command: task.command } : {}),
|
|
7966
8135
|
startedAt: task.startedAt || null,
|
|
7967
8136
|
endedAt: task.endedAt || null,
|
|
7968
|
-
stoppable: !ended && task.stoppable !== false,
|
|
8137
|
+
stoppable: !ended && !held && task.stoppable !== false,
|
|
7969
8138
|
},
|
|
7970
8139
|
};
|
|
7971
8140
|
if (kind === 'none') {
|
|
@@ -7982,7 +8151,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7982
8151
|
if (!cached) return res.json({ ...head, pending: true, text: '', lines: [], startOffset: 0, endOffset: 0, size: 0, mtime: null, hasMoreBefore: false });
|
|
7983
8152
|
return res.json({ ...head, ...shapeSnapshot(cached) });
|
|
7984
8153
|
}
|
|
7985
|
-
if (!path) return res.json({ ...head, kind: 'none', message:
|
|
8154
|
+
if (!path) return res.json({ ...head, kind: 'none', message: TASK_OUTPUT_NONE_MESSAGES.noPath });
|
|
7986
8155
|
const lines = req.query?.lines;
|
|
7987
8156
|
const before = req.query?.before;
|
|
7988
8157
|
const read = before !== undefined && before !== ''
|
|
@@ -8435,12 +8604,14 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
8435
8604
|
// Fenced by the run row, not by an open turn: a background agent keeps
|
|
8436
8605
|
// writing after its turn ended, and its entries belong to that turn. A run
|
|
8437
8606
|
// the relay does not know yet but whose parent it does (an agent spawned
|
|
8438
|
-
// inside another) is created under the parent's turn;
|
|
8439
|
-
//
|
|
8607
|
+
// inside another) is created under the parent's turn; a run of a task the
|
|
8608
|
+
// relay knows by its task id (a resumed agent: same task, new tool use) is
|
|
8609
|
+
// that task's run; anything else is a 404 the worker retries once the spawn
|
|
8610
|
+
// has landed.
|
|
8440
8611
|
app.post('/api/subagent-timeline', auth, (req, res) => {
|
|
8441
8612
|
touchCli();
|
|
8442
8613
|
const conversationId = String(req.body?.conversationId || '').trim();
|
|
8443
|
-
|
|
8614
|
+
let runId = sanitizeSubagentRunId(req.body?.subagentRunId);
|
|
8444
8615
|
if (!conversationId || !runId) {
|
|
8445
8616
|
return res.status(400).json({ error: 'Missing conversationId or subagentRunId' });
|
|
8446
8617
|
}
|
|
@@ -8449,6 +8620,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
8449
8620
|
}
|
|
8450
8621
|
const rawHeader = req.body?.header && typeof req.body.header === 'object' ? req.body.header : {};
|
|
8451
8622
|
let run = stmts.getSubagentRun?.get?.(runId) || null;
|
|
8623
|
+
// A finished run that a later run of its task (posted under another id)
|
|
8624
|
+
// sets running again: the relay marks where it resumed, once per id.
|
|
8625
|
+
let resumedNote = null;
|
|
8626
|
+
if (!run) {
|
|
8627
|
+
run = subagentRunOfTask(conversationId, req.body?.taskId || rawHeader.taskId);
|
|
8628
|
+
if (run && isTerminalSubagentRunStatus(normalizeSubagentRunStatus(run.status))
|
|
8629
|
+
&& rawHeader.status && normalizeSubagentRunStatus(rawHeader.status) === 'running') {
|
|
8630
|
+
resumedNote = { key: `relay:resumed:${runId}`, kind: 'note', text: 'Resumed with a new message', metadata: { resumed: true } };
|
|
8631
|
+
}
|
|
8632
|
+
if (run) runId = String(run.id);
|
|
8633
|
+
}
|
|
8452
8634
|
let created = false;
|
|
8453
8635
|
if (!run) {
|
|
8454
8636
|
const parentId = sanitizeSubagentRunId(rawHeader.parentSubagentId);
|
|
@@ -8471,7 +8653,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
8471
8653
|
if (patch) run = subagentTimeline.applyHeader(runId, patch) || run;
|
|
8472
8654
|
let stored;
|
|
8473
8655
|
try {
|
|
8474
|
-
|
|
8656
|
+
const entries = Array.isArray(req.body?.entries) ? req.body.entries : [];
|
|
8657
|
+
stored = subagentTimeline.applyEntries(run, resumedNote ? [resumedNote, ...entries] : entries);
|
|
8475
8658
|
} catch (error) {
|
|
8476
8659
|
return res.status(500).json({ error: error?.message || 'Failed to store the timeline' });
|
|
8477
8660
|
}
|