@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.
Files changed (115) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +508 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +155 -94
  7. package/package.json +5 -5
  8. package/server/README.md +479 -157
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +92 -15
  11. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  12. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  13. package/server/claude-worker/task-output-discovery.mjs +28 -2
  14. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  15. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  16. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  17. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  18. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
  20. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  21. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  22. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  23. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  24. package/server/db-schema.mjs +10 -0
  25. package/server/grok-worker/acp-host-services.mjs +6 -3
  26. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  27. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  28. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  29. package/server/migrations/0018-background-task-rows.mjs +101 -0
  30. package/server/public/app/action-confirmations.js +12 -15
  31. package/server/public/app/api-client.js +60 -4
  32. package/server/public/app/ask-user-view.js +29 -4
  33. package/server/public/app/background-tasks-view.mjs +56 -7
  34. package/server/public/app/bootstrap.js +7 -0
  35. package/server/public/app/conversation-preferences.mjs +10 -4
  36. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  37. package/server/public/app/conversation-view.js +33 -9
  38. package/server/public/app/host-suspend-ui.js +45 -44
  39. package/server/public/app/journal-view.js +9 -3
  40. package/server/public/app/push-settings.js +1 -0
  41. package/server/public/app/question-answer-rule.mjs +15 -0
  42. package/server/public/app/question-multi-select.mjs +9 -2
  43. package/server/public/app/relay-event-view.mjs +5 -1
  44. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  45. package/server/public/app/relay-restart-note-view.mjs +36 -0
  46. package/server/public/app/relay-restart-ui.js +568 -0
  47. package/server/public/app/settings-modal.js +52 -0
  48. package/server/public/app/socket-handlers.js +1 -1
  49. package/server/public/app/task-console-render.mjs +171 -7
  50. package/server/public/app/task-console.mjs +111 -11
  51. package/server/public/app/task-output-text.mjs +15 -6
  52. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  53. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  54. package/server/public/index.html +76 -5
  55. package/server/relay-exit-codes.mjs +6 -0
  56. package/server/relay-self-restart.mjs +6 -1
  57. package/server/relay-tools.md +17 -9
  58. package/server/repositories/message-repository.mjs +32 -28
  59. package/server/repositories/remote-relay-repository.mjs +4 -2
  60. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  61. package/server/routes/ask-user-routes.mjs +41 -7
  62. package/server/routes/messages-routes.mjs +220 -37
  63. package/server/routes/preview-routes.mjs +73 -7
  64. package/server/routes/relay-board-routes.mjs +19 -2
  65. package/server/routes/relay-restart-routes.mjs +73 -0
  66. package/server/routes/sessions-routes.mjs +79 -5
  67. package/server/server-runtime.mjs +335 -218
  68. package/server/services/about-service.mjs +13 -12
  69. package/server/services/background-task-persistence.mjs +133 -0
  70. package/server/services/background-task-store.mjs +240 -13
  71. package/server/services/conversation-model-fallback.mjs +57 -0
  72. package/server/services/cursor-session-root-service.mjs +70 -6
  73. package/server/services/data-dir-resolution.mjs +208 -19
  74. package/server/services/earlier-database-service.mjs +631 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/oar-cli-helpers.mjs +6 -1
  77. package/server/services/oar-state-layout.mjs +107 -0
  78. package/server/services/oar-state-migration-service.mjs +387 -96
  79. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  80. package/server/services/package-data-rescue.mjs +137 -0
  81. package/server/services/preview-proxy-server.mjs +14 -1
  82. package/server/services/preview-registry-service.mjs +3 -2
  83. package/server/services/process-list-service.mjs +2 -1
  84. package/server/services/provider-model-selection.mjs +195 -0
  85. package/server/services/push-dispatch-service.mjs +42 -8
  86. package/server/services/relay-event-service.mjs +210 -14
  87. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  88. package/server/services/relay-restart-picture.mjs +209 -0
  89. package/server/services/relay-restart-queue.mjs +387 -0
  90. package/server/services/relay-shutdown-gate.mjs +4 -1
  91. package/server/services/relay-singleton-guard.mjs +300 -36
  92. package/server/services/remote-relay-caller-context.mjs +7 -2
  93. package/server/services/remote-relay-dispatcher.mjs +217 -28
  94. package/server/services/restart-resume-service.mjs +303 -0
  95. package/server/services/session-activity-tracker.mjs +52 -15
  96. package/server/services/session-worker-launch-service.mjs +31 -1
  97. package/server/services/session-worker-process-service.mjs +6 -4
  98. package/server/services/stale-turn-recovery.mjs +99 -0
  99. package/server/services/subagent-timeline-service.mjs +4 -1
  100. package/server/services/task-output-service.mjs +217 -18
  101. package/server/services/windows-autostart-service.mjs +329 -31
  102. package/server/services/windows-boot-autostart-service.mjs +188 -60
  103. package/shared/preview-instructions.mjs +17 -12
  104. package/shared/preview-tool-core.mjs +32 -14
  105. package/shared/question-answer-rule.mjs +63 -0
  106. package/shared/relay-events.mjs +106 -3
  107. package/shared/remote-relay-contract.mjs +32 -15
  108. package/shared/subagent-timeline.mjs +4 -0
  109. package/shared/task-output-text.mjs +196 -83
  110. package/shared/windows-system-tools.mjs +25 -0
  111. package/shared/worker-runtime/config-loader.mjs +54 -8
  112. package/shared/worker-runtime/prompt-context.mjs +3 -1
  113. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
  114. package/shared/worker-runtime/working-directory.mjs +65 -0
  115. 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
- if (!isWithinRoot(allowedRoot) && !isGeneratedImagePath) {
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
- const requestedRelayMode = normalizeRelayMode(relayMode || mode);
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 model is part of the cloud
4765
- // session's configuration: it can still change before the first message
4766
- // creates that session, and is locked afterwards.
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
- : resolveRequestedModel(model)))));
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
- return { ok: true, effort: requestedEffort, supported: [] };
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(explicitReasoningEffort, openAIModel)
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 fallbackReasoningEffort = supportedEfforts.includes('none')
4910
- ? 'none'
4911
- : (supportedEfforts[0] || null);
4912
- if (fallbackReasoningEffort) {
4913
- reasoningResolution = resolveRequestedReasoningEffort(requestedModel, fallbackReasoningEffort);
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
- conversationPreferences = remoteRequest.remote && !shouldCreateConversation
5179
- ? readConversationModelPreference(convId, requestedRelayMode)
5180
- : persistConversationModelPreference(
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
- const result = requestRelayShutdown({ reason, requestedBy, restart });
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
- const settledDescription = String(req.body?.description || '').trim().slice(0, 500);
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 || '').trim().slice(0, 500) || null,
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
- description: String(task?.description || '').trim().slice(0, 500),
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 || '').trim().slice(0, 500) || null,
7902
+ summary: cleanTaskText(String(task?.summary || ''), 500) || null,
7753
7903
  lastToolName: String(task?.lastToolName || '').trim() || null,
7754
- lastToolCall: String(task?.lastToolCall || '').replace(/\s+/g, ' ').trim().slice(0, 300) || null,
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 = 'Sub-agents have no shell output; open the sub-agent view.';
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 'This session did not report an output file for this task. Sessions started before the task console need a restart to show output.';
8047
+ return TASK_OUTPUT_NONE_MESSAGES.noOutputFile;
7884
8048
  }
7885
- return 'Output is not available for this provider.';
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: 'Output is not available for this task.' });
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; anything else is a
8439
- // 404 the worker retries once the spawn has landed.
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
- const runId = sanitizeSubagentRunId(req.body?.subagentRunId);
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
- stored = subagentTimeline.applyEntries(run, req.body?.entries);
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
  }