@oar-sh/oar 0.9.11 → 0.9.13

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 (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -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 {
@@ -42,6 +43,16 @@ import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning
42
43
  import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
43
44
  import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
44
45
  import { normalizeBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
46
+ import {
47
+ DEFAULT_TASK_STALL_MINUTES,
48
+ TASK_OUTPUT_LIMITS,
49
+ cleanTaskText,
50
+ normalizeTaskCommand,
51
+ normalizeTaskOutputKind,
52
+ normalizeTaskOutputPath,
53
+ } from '../../shared/task-output-text.mjs';
54
+ import { readBefore, readTail, shapeSnapshot, taskOutputSource } from '../services/task-output-service.mjs';
55
+ import { taskKindOf } from '../services/session-activity-tracker.mjs';
45
56
  import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
46
57
  import { isLiveResend } from '../services/steer-resend.mjs';
47
58
  import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
@@ -60,6 +71,7 @@ import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limi
60
71
  import { createSubagentTimelinePublisher } from '../services/subagent-viewer-rooms.mjs';
61
72
  import { createLocalRequestCheck } from '../services/request-boundary.mjs';
62
73
  import { buildClaudeCloudDelivery, isClaudeCloudProviderType } from '../services/claude-cloud-session-service.mjs';
74
+ import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
63
75
  import {
64
76
  admitRemoteRelayRequest,
65
77
  readRemoteRelayRequest,
@@ -236,9 +248,17 @@ function findRecentDuplicateUserMessage({
236
248
  const exempt = new Set((Array.isArray(exemptMessageIds) ? exemptMessageIds : [])
237
249
  .map((id) => String(id || '').trim()).filter(Boolean));
238
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));
239
258
 
240
259
  for (const row of Array.isArray(recentUserMessages) ? recentUserMessages : []) {
241
260
  if (isExempt(row)) continue;
261
+ if (failedTurnIds.has(String(row?.id || '').trim())) continue;
242
262
  const rowText = normalizeDuplicateMessageText(row?.text || '');
243
263
  if (!rowText || rowText !== fingerprint) continue;
244
264
  const rowTime = Date.parse(row?.timestamp || '');
@@ -1779,6 +1799,15 @@ export function registerMessagesRoutes(app, deps) {
1779
1799
  buildRelayReadyBannerData,
1780
1800
  backgroundTaskStore,
1781
1801
  backgroundWorkView = null,
1802
+ // The 8 KiB a worker posts for a task without an output file
1803
+ // (services/task-output-service.mjs createTaskOutputSnapshots).
1804
+ taskOutputSnapshots = null,
1805
+ // Has the health poller read soon (a task with a console ended).
1806
+ pokeTaskHealth = null,
1807
+ // What a recovery did with a turn that was running when the relay
1808
+ // started (services/restart-resume-service.mjs noteOutcome).
1809
+ noteRestartTurnOutcome = () => {},
1810
+ getTaskStallMinutes = () => DEFAULT_TASK_STALL_MINUTES,
1782
1811
  sendWorkerControl,
1783
1812
  ensureSessionId,
1784
1813
  touchCli,
@@ -2713,10 +2742,12 @@ export function registerMessagesRoutes(app, deps) {
2713
2742
  // quietly.
2714
2743
  stmts.dropStaleContinuation?.run?.(row.id);
2715
2744
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
2745
+ noteRestartTurnOutcome(row, 'cut');
2716
2746
  continue;
2717
2747
  }
2718
2748
  if (row.consumed_at) {
2719
2749
  failConsumedQueueRow(row, { reason });
2750
+ noteRestartTurnOutcome(row, 'cut');
2720
2751
  continue;
2721
2752
  }
2722
2753
  const relayActivityRows = stmts.listActivityByQueueMessage?.all?.(row.id);
@@ -2753,6 +2784,7 @@ export function registerMessagesRoutes(app, deps) {
2753
2784
  error: reason,
2754
2785
  });
2755
2786
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
2787
+ noteRestartTurnOutcome(row, 'requeued');
2756
2788
  }
2757
2789
  for (const row of rowsToFail) {
2758
2790
  const failureRecord = buildHeartbeatIdleReplayFailure({
@@ -2778,6 +2810,8 @@ export function registerMessagesRoutes(app, deps) {
2778
2810
  ok: false,
2779
2811
  error: failureRecord.code,
2780
2812
  });
2813
+ // Started, so not replayed: a turn the agent never heard the end of.
2814
+ noteRestartTurnOutcome(row, 'cut');
2781
2815
  }
2782
2816
  }
2783
2817
  if (rowsToRecover.length || rowsToFail.length) {
@@ -4415,7 +4449,17 @@ export function registerMessagesRoutes(app, deps) {
4415
4449
  const requesterSessionId = normalizeSessionWorkerId(requesterIdentity?.sessionId);
4416
4450
  const sessionWorkerRoutingEnabled = isSessionWorkerRoutingEnabled(featureFlags);
4417
4451
  let conversationSdkSessionId = null;
4418
- const requestedRelayMode = normalizeRelayMode(relayMode || mode);
4452
+ // The conversation's saved choices, for a send that leaves one out: a
4453
+ // send to an existing conversation that names no mode runs in the mode
4454
+ // the conversation is in, not the relay default.
4455
+ const existingConversation = (!newConversation && conversationId)
4456
+ ? (stmts.getConvAnyStatus?.get?.(conversationId) || null)
4457
+ : null;
4458
+ const explicitRelayModeGiven = !!String(relayMode || mode || '').trim();
4459
+ const savedRelayMode = !explicitRelayModeGiven && existingConversation?.preferred_relay_mode
4460
+ ? normalizeRelayMode(existingConversation.preferred_relay_mode)
4461
+ : null;
4462
+ const requestedRelayMode = savedRelayMode || normalizeRelayMode(relayMode || mode);
4419
4463
  const trimmedText = stripRelayPromptContext(text, requestedRelayMode || relayMode || mode);
4420
4464
  const normalizedAttachments = normalizeAttachments(rawAttachments);
4421
4465
  const requestedAttachmentCount = Math.min(
@@ -4772,6 +4816,34 @@ export function registerMessagesRoutes(app, deps) {
4772
4816
  // sends set_model before the message), so a changed model is taken as
4773
4817
  // for a local Claude chat.
4774
4818
  const claudeCloudModel = claudeCloudModelSelection ? claudeCloudModelSelection.model : '';
4819
+ // What the request itself chose. Only these are saved as the
4820
+ // conversation's preference; a value filled in below never is.
4821
+ const explicitModelGiven = !!requestedOpenAIModel;
4822
+ const conversationReasoningEffort = String(existingConversation?.preferred_reasoning_effort || '').trim().toLowerCase();
4823
+ // A Copilot conversation sent no model keeps its own: the saved
4824
+ // preference, the runtime session's model, the latest user message's,
4825
+ // and the catalog default only when it has none of these. A model the
4826
+ // catalog no longer lists is refused rather than swapped for another.
4827
+ const usesGitHubCatalog = !useOpenAIProvider && !runtimeBoundToExplicitProvider;
4828
+ const inheritedModel = usesGitHubCatalog && !shouldCreateConversation && !explicitModelGiven
4829
+ ? inheritedConversationModel({
4830
+ conversation: existingConversation,
4831
+ runtimeSession: existingRuntimeSession,
4832
+ latestUserMessageModel: stmts.getLatestUserMessageModel?.get?.(conversationId)?.model || '',
4833
+ })
4834
+ : null;
4835
+ const githubModelResolution = usesGitHubCatalog
4836
+ ? resolveRequestedModel(inheritedModel ? inheritedModel.model : model)
4837
+ : null;
4838
+ if (inheritedModel && !githubModelResolution?.ok) {
4839
+ return res.status(409).json({
4840
+ error: `This conversation's model "${inheritedModel.model}" is no longer available. Choose a model and send again.`,
4841
+ code: 'CONVERSATION_MODEL_UNAVAILABLE',
4842
+ conversationId,
4843
+ model: inheritedModel.model,
4844
+ supportedModels: githubModelResolution?.available || [],
4845
+ });
4846
+ }
4775
4847
  const modelResolution = useOpenAIProvider
4776
4848
  ? {
4777
4849
  ok: !!openAIModel,
@@ -4812,7 +4884,7 @@ export function registerMessagesRoutes(app, deps) {
4812
4884
  reasoningEffort: null,
4813
4885
  error: claudeCloudModel ? null : 'Claude Cloud model is not configured',
4814
4886
  }
4815
- : resolveRequestedModel(model)))));
4887
+ : githubModelResolution))));
4816
4888
  if (!modelResolution.ok) return res.status(400).json({ error: modelResolution.error, supportedModels: modelResolution.available || [] });
4817
4889
  const requestedModel = String(modelResolution.model || '').trim();
4818
4890
  const requestedAutoModel = requestedModel.toLowerCase() === AUTO_MODEL_SENTINEL;
@@ -4837,6 +4909,15 @@ export function registerMessagesRoutes(app, deps) {
4837
4909
  code: 'IMAGE_TARGET_REQUIRES_IMAGE_MODEL',
4838
4910
  });
4839
4911
  }
4912
+ // A send that names no effort runs with the conversation's own, clamped
4913
+ // to the nearest level the model has. Null when there is nothing to keep
4914
+ // or it neither matches nor clamps (an ultracode on a model without it):
4915
+ // the provider's own default applies then.
4916
+ const conversationEffortFor = (supported) => (
4917
+ !explicitReasoningEffort && conversationReasoningEffort
4918
+ ? (fallbackReasoningEffort([conversationReasoningEffort], supported, { fallback: '' }) || null)
4919
+ : null
4920
+ );
4840
4921
  // Claude conversations validate effort against the Claude provider's
4841
4922
  // per-model levels ('none' = SDK default). Effort is per-turn: each Claude
4842
4923
  // turn is a fresh query(), so it can change between messages.
@@ -4845,6 +4926,8 @@ export function registerMessagesRoutes(app, deps) {
4845
4926
  const supported = Array.isArray(configuredClaude?.effortsByModel?.[String(requestedModel || '').trim().toLowerCase()])
4846
4927
  ? configuredClaude.effortsByModel[String(requestedModel || '').trim().toLowerCase()]
4847
4928
  : [...DEFAULT_CLAUDE_REASONING_EFFORTS];
4929
+ const kept = conversationEffortFor(supported);
4930
+ if (kept) return { ok: true, effort: kept, supported };
4848
4931
  const effort = supported.includes(requestedEffort) ? requestedEffort : 'none';
4849
4932
  return { ok: true, effort: effort === 'none' ? 'none' : effort, supported };
4850
4933
  };
@@ -4860,8 +4943,11 @@ export function registerMessagesRoutes(app, deps) {
4860
4943
  // the worker validates it against the resolved model's live params
4861
4944
  // and falls back to the model default on any mismatch. Clamping to
4862
4945
  // 'none' here would actively disable thinking on the resolved model.
4863
- return { ok: true, effort: requestedEffort, supported: [] };
4946
+ // The conversation's own effort goes through the same way.
4947
+ return { ok: true, effort: requestedEffort || conversationReasoningEffort, supported: [] };
4864
4948
  }
4949
+ const kept = conversationEffortFor(discovered);
4950
+ if (kept) return { ok: true, effort: kept, supported: discovered };
4865
4951
  const effort = discovered.includes(requestedEffort) ? requestedEffort : 'none';
4866
4952
  return { ok: true, effort, supported: discovered };
4867
4953
  };
@@ -4870,11 +4956,16 @@ export function registerMessagesRoutes(app, deps) {
4870
4956
  const supported = Array.isArray(configuredGrok?.effortsByModel?.[String(requestedModel || '').trim().toLowerCase()])
4871
4957
  ? configuredGrok.effortsByModel[String(requestedModel || '').trim().toLowerCase()]
4872
4958
  : ['none', 'low', 'medium', 'high'];
4959
+ const kept = conversationEffortFor(supported);
4960
+ if (kept) return { ok: true, effort: kept, supported };
4873
4961
  const effort = supported.includes(requestedEffort) ? requestedEffort : 'none';
4874
4962
  return { ok: true, effort, supported };
4875
4963
  };
4876
4964
  let reasoningResolution = useOpenAIProvider
4877
- ? resolveOpenAIReasoningEffort(explicitReasoningEffort, openAIModel)
4965
+ ? resolveOpenAIReasoningEffort(
4966
+ explicitReasoningEffort || conversationEffortFor(openAIReasoningEffortsForModel(openAIModel)) || '',
4967
+ openAIModel,
4968
+ )
4878
4969
  : (runtimeUsesClaude
4879
4970
  ? resolveClaudeReasoningEffort()
4880
4971
  : (runtimeUsesCursor
@@ -4888,15 +4979,21 @@ export function registerMessagesRoutes(app, deps) {
4888
4979
  requestedModel,
4889
4980
  explicitReasoningEffort || modelResolution.reasoningEffort || null,
4890
4981
  )))));
4982
+ // No effort sent (or the model's variant carried one it lacks): the
4983
+ // conversation's own effort, clamped to the model's levels, else its
4984
+ // lowest level that is not 'none'. Some models list 'none' and the API
4985
+ // still refuses it, so it is taken only when the conversation chose it or
4986
+ // the model has nothing else.
4891
4987
  if (!reasoningResolution?.ok && !explicitReasoningEffort) {
4892
4988
  const supportedEfforts = Array.isArray(reasoningResolution?.supported)
4893
4989
  ? reasoningResolution.supported.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean)
4894
4990
  : [];
4895
- const fallbackReasoningEffort = supportedEfforts.includes('none')
4896
- ? 'none'
4897
- : (supportedEfforts[0] || null);
4898
- if (fallbackReasoningEffort) {
4899
- reasoningResolution = resolveRequestedReasoningEffort(requestedModel, fallbackReasoningEffort);
4991
+ const fallbackEffort = fallbackReasoningEffort(
4992
+ [modelResolution.reasoningEffort, conversationReasoningEffort],
4993
+ supportedEfforts,
4994
+ );
4995
+ if (fallbackEffort) {
4996
+ reasoningResolution = resolveRequestedReasoningEffort(requestedModel, fallbackEffort);
4900
4997
  }
4901
4998
  }
4902
4999
  if (!reasoningResolution?.ok) {
@@ -4906,6 +5003,9 @@ export function registerMessagesRoutes(app, deps) {
4906
5003
  });
4907
5004
  }
4908
5005
  const requestedReasoningEffort = reasoningResolution.effort || null;
5006
+ // The request's own effort: sent as such, or carried by the variant id of
5007
+ // a model it named.
5008
+ const explicitEffortGiven = !!explicitReasoningEffort || (explicitModelGiven && !!modelResolution.reasoningEffort);
4909
5009
  let normalizedContextTier = requestedContextTier;
4910
5010
  if (openAIImageModel) {
4911
5011
  normalizedContextTier = normalizeOpenAIImageSize(requestedContextTier) || 'auto';
@@ -4950,6 +5050,7 @@ export function registerMessagesRoutes(app, deps) {
4950
5050
  return res.json({
4951
5051
  ok: true,
4952
5052
  duplicate: true,
5053
+ queued: false,
4953
5054
  alreadyResent: true,
4954
5055
  duplicateOfMessageId: resend.alreadyResentAs,
4955
5056
  conversationId,
@@ -4980,6 +5081,8 @@ export function registerMessagesRoutes(app, deps) {
4980
5081
  return res.json({
4981
5082
  ok: true,
4982
5083
  duplicate: true,
5084
+ // Said outright for a caller that reads only `ok`: nothing new runs.
5085
+ queued: false,
4983
5086
  duplicateWindowMs: DUPLICATE_USER_MESSAGE_WINDOW_MS,
4984
5087
  duplicateSource: duplicateMessage.source,
4985
5088
  duplicateOfMessageId: duplicateMessage.messageId,
@@ -5161,15 +5264,25 @@ export function registerMessagesRoutes(app, deps) {
5161
5264
  }
5162
5265
  // Another relay's agent picks the model and mode of its own turn; the
5163
5266
  // composer keeps restoring the user's choice for this conversation.
5164
- conversationPreferences = remoteRequest.remote && !shouldCreateConversation
5165
- ? readConversationModelPreference(convId, requestedRelayMode)
5166
- : persistConversationModelPreference(
5267
+ // Any other send to an existing conversation saves only what it named:
5268
+ // a model or an effort filled in from a fallback is never written back
5269
+ // as the conversation's choice (an empty effort keeps the stored one).
5270
+ const savedModel = String(existingConversation?.preferred_model || '').trim();
5271
+ if (shouldCreateConversation) {
5272
+ conversationPreferences = persistConversationModelPreference(
5273
+ convId, requestedRelayMode, requestedModel, requestedReasoningEffort, now,
5274
+ );
5275
+ } else if (remoteRequest.remote || (!explicitModelGiven && !(explicitEffortGiven && savedModel))) {
5276
+ conversationPreferences = readConversationModelPreference(convId, requestedRelayMode);
5277
+ } else {
5278
+ conversationPreferences = persistConversationModelPreference(
5167
5279
  convId,
5168
5280
  requestedRelayMode,
5169
- requestedModel,
5170
- requestedReasoningEffort,
5281
+ explicitModelGiven ? requestedModel : savedModel,
5282
+ explicitEffortGiven ? requestedReasoningEffort : '',
5171
5283
  now,
5172
5284
  );
5285
+ }
5173
5286
  stmts.insertQ.run(
5174
5287
  msgId,
5175
5288
  convId,
@@ -5468,6 +5581,7 @@ export function registerMessagesRoutes(app, deps) {
5468
5581
  io.emit('background_tasks', {
5469
5582
  conversationId: requesterSessionId,
5470
5583
  tasks: backgroundWorkView.panelTasks(requesterSessionId, requesterConversationId),
5584
+ endedTasks: backgroundTaskStore?.ended?.(requesterSessionId) || [],
5471
5585
  });
5472
5586
  }
5473
5587
  }
@@ -5512,6 +5626,9 @@ export function registerMessagesRoutes(app, deps) {
5512
5626
  const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
5513
5627
  for (const id of activeQueueMessageIds) {
5514
5628
  refreshProcessingLeaseForOwnedMessage.run(leaseExpiresAt, now, id, requesterSessionId);
5629
+ // The worker has the turn: if it was running when the relay
5630
+ // started, the restart did not cut it off.
5631
+ noteRestartTurnOutcome({ id }, 'claimed');
5515
5632
  }
5516
5633
  recoverOwnedProcessingRowsForSession(requesterSessionId, {
5517
5634
  excludeMessageIds: activeQueueMessageIds,
@@ -6555,7 +6672,17 @@ export function registerMessagesRoutes(app, deps) {
6555
6672
  || rawRestart === 1
6556
6673
  || String(rawRestart || '').trim().toLowerCase() === 'true'
6557
6674
  || String(rawRestart || '').trim() === '1';
6558
- const result = requestRelayShutdown({ reason, requestedBy, restart });
6675
+ // `immediate` starts it now, without waiting for the running turns (the
6676
+ // banner's "Restart now"); the answer names what it did not wait for.
6677
+ const rawImmediate = req.body?.immediate;
6678
+ const immediate = rawImmediate === true
6679
+ || rawImmediate === 1
6680
+ || String(rawImmediate || '').trim().toLowerCase() === 'true'
6681
+ || String(rawImmediate || '').trim() === '1';
6682
+ // The banner's "Restart now" names the queued request it saw (see
6683
+ // services/relay-restart-queue.mjs); a stale one is refused.
6684
+ const expectRequestedAt = String(req.body?.expectRequestedAt || '').trim().slice(0, 40) || null;
6685
+ const result = requestRelayShutdown({ reason, requestedBy, restart, immediate, expectRequestedAt });
6559
6686
  return res.json({ ok: true, ...result });
6560
6687
  });
6561
6688
 
@@ -7629,6 +7756,34 @@ export function registerMessagesRoutes(app, deps) {
7629
7756
  res.json({ ok: true, messageId, conversationId, attemptId });
7630
7757
  });
7631
7758
 
7759
+ // An integer from a worker's post, or undefined (dropped from the row).
7760
+ function wholeNumber(value, { min = Number.MIN_SAFE_INTEGER } = {}) {
7761
+ if (value === null || value === undefined || value === '' || typeof value === 'boolean') return undefined;
7762
+ const number = Number(value);
7763
+ return Number.isSafeInteger(number) && number >= min ? number : undefined;
7764
+ }
7765
+
7766
+ // A task row's console fields as the publish route keeps them.
7767
+ function taskOutputFields(task) {
7768
+ const outputPath = normalizeTaskOutputPath(task?.outputPath);
7769
+ const requestedKind = String(task?.outputKind || '').trim();
7770
+ if (!outputPath && !requestedKind) return {};
7771
+ const outputKind = normalizeTaskOutputKind(requestedKind, outputPath);
7772
+ // A file kind without a readable path has nothing to show.
7773
+ if ((outputKind === 'file' || outputKind === 'spool') && !outputPath) return { outputKind: 'none' };
7774
+ return { ...(outputPath ? { outputPath } : {}), outputKind };
7775
+ }
7776
+
7777
+ // `background_tasks` with the health readings on the live rows and the
7778
+ // ended rows beside them, for the key the worker posts under.
7779
+ function emitBackgroundTasks(key, tasks = backgroundTaskStore?.get?.(key) || []) {
7780
+ io.emit('background_tasks', {
7781
+ conversationId: key,
7782
+ tasks: backgroundTaskStore?.withHealth ? backgroundTaskStore.withHealth(tasks, key) : tasks,
7783
+ endedTasks: backgroundTaskStore?.ended?.(key) || [],
7784
+ });
7785
+ }
7786
+
7632
7787
  function resolveTaskConversationId(id) {
7633
7788
  const key = String(id || '').trim();
7634
7789
  if (!key) return '';
@@ -7648,14 +7803,36 @@ export function registerMessagesRoutes(app, deps) {
7648
7803
  const conversationId = resolveTaskConversationId(req.body?.conversationId);
7649
7804
  const taskId = String(req.body?.taskId || '').trim();
7650
7805
  if (!conversationId || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
7651
- const exitCode = Number(req.body?.exitCode);
7652
- const settledDescription = String(req.body?.description || '').trim().slice(0, 500);
7806
+ // Only a number the worker sent: a missing or null exit code stays null
7807
+ // (Number(null) would read as a clean exit).
7808
+ const exitCode = wholeNumber(req.body?.exitCode);
7809
+ // Scrubbed like the output: a description can be the command line.
7810
+ const settledDescription = cleanTaskText(String(req.body?.description || ''), 500);
7811
+ // The store is keyed as the worker posts. A task with a console stays
7812
+ // viewable as an ended row; one that ended while this runtime was away
7813
+ // becomes one when the post names its output.
7814
+ const settledOutputPath = normalizeTaskOutputPath(req.body?.outputPath);
7815
+ const endedRow = backgroundTaskStore?.settle?.(String(req.body?.conversationId || '').trim(), taskId, {
7816
+ status: req.body?.status,
7817
+ exitCode: exitCode ?? null,
7818
+ steps: wholeNumber(req.body?.steps, { min: 0 }),
7819
+ outputPath: settledOutputPath || undefined,
7820
+ outputKind: settledOutputPath ? normalizeTaskOutputKind(req.body?.outputKind, settledOutputPath) : undefined,
7821
+ description: settledDescription,
7822
+ command: normalizeTaskCommand(req.body?.command) || undefined,
7823
+ taskType: String(req.body?.taskType || '').trim().slice(0, 80),
7824
+ startedAt: Number(req.body?.startedAt) || null,
7825
+ }) || null;
7826
+ if (endedRow) {
7827
+ emitBackgroundTasks(String(req.body?.conversationId || '').trim());
7828
+ pokeTaskHealth?.();
7829
+ }
7653
7830
  sessionActivityTracker?.noteTaskSettled?.(conversationId, {
7654
7831
  taskId,
7655
7832
  toolUseId: sanitizeSubagentRunId(req.body?.toolUseId) || null,
7656
7833
  status: String(req.body?.status || '').trim().slice(0, 40) || 'completed',
7657
- summary: String(req.body?.summary || '').trim().slice(0, 500) || null,
7658
- exitCode: Number.isInteger(exitCode) ? exitCode : null,
7834
+ summary: cleanTaskText(String(req.body?.summary || ''), 500) || null,
7835
+ exitCode: exitCode ?? null,
7659
7836
  // What the task was, from a worker that sends it: the end of a task
7660
7837
  // this runtime never knew (it ended while the relay was away) is
7661
7838
  // still an event then.
@@ -7684,11 +7861,18 @@ export function registerMessagesRoutes(app, deps) {
7684
7861
  .map((task) => ({
7685
7862
  taskId: String(task?.taskId || '').trim(),
7686
7863
  taskType: String(task?.taskType || '').trim(),
7687
- description: String(task?.description || '').trim().slice(0, 500),
7864
+ // Scrubbed like the output before anything keeps or sends them: a
7865
+ // description can be the command line (Grok's terminals, a Copilot
7866
+ // shell without one), a summary or tool call can quote one.
7867
+ description: cleanTaskText(String(task?.description || ''), 500),
7868
+ // A shell task's command line, where the worker knows it: scrubbed
7869
+ // like the output and cut at 2,000 characters before it is kept, so
7870
+ // every reader (the pages, read_session, the stored row) gets it so.
7871
+ command: normalizeTaskCommand(task?.command) || undefined,
7688
7872
  startedAt: Number(task?.startedAt) || null,
7689
- summary: String(task?.summary || '').trim().slice(0, 500) || null,
7873
+ summary: cleanTaskText(String(task?.summary || ''), 500) || null,
7690
7874
  lastToolName: String(task?.lastToolName || '').trim() || null,
7691
- lastToolCall: String(task?.lastToolCall || '').replace(/\s+/g, ' ').trim().slice(0, 300) || null,
7875
+ lastToolCall: cleanTaskText(String(task?.lastToolCall || '').replace(/\s+/g, ' '), 300) || null,
7692
7876
  totalTokens: Number.isFinite(Number(task?.totalTokens)) ? Number(task.totalTokens) : null,
7693
7877
  subagentType: String(task?.subagentType || '').trim().slice(0, 120) || null,
7694
7878
  model: String(task?.model || '').trim().slice(0, 120) || null,
@@ -7702,8 +7886,23 @@ export function registerMessagesRoutes(app, deps) {
7702
7886
  // The spawning tool_use id: the sub-agent run this task is. The panel
7703
7887
  // opens the run's viewer from it.
7704
7888
  toolUseId: sanitizeSubagentRunId(task?.toolUseId) || undefined,
7889
+ // The console (shell and Monitor tasks): the file holding the
7890
+ // output, how it is obtained ('none': the provider has none to give).
7891
+ ...taskOutputFields(task),
7892
+ // Tool calls a sub-agent made so far.
7893
+ steps: wholeNumber(task?.steps, { min: 0 }),
7894
+ exitCode: wholeNumber(task?.exitCode),
7705
7895
  }))
7706
7896
  .filter((task) => task.taskId);
7897
+ // The first path a task names wins: a later post cannot point a live
7898
+ // task's console at another file.
7899
+ for (const task of tasks) {
7900
+ const held = (backgroundTaskStore?.get?.(conversationId) || []).find((entry) => entry?.taskId === task.taskId);
7901
+ if (held?.outputPath && task.outputPath !== held.outputPath) {
7902
+ task.outputPath = held.outputPath;
7903
+ task.outputKind = held.outputKind;
7904
+ }
7905
+ }
7707
7906
  const resync = req.body?.resync === true;
7708
7907
  // Posts can overtake each other on the way (an ordinary publish and the
7709
7908
  // answer to a resync, two publishes): a worker numbers its sets
@@ -7720,9 +7919,15 @@ export function registerMessagesRoutes(app, deps) {
7720
7919
  console.log(`[${typeof ts === 'function' ? ts() : new Date().toISOString()}] BG-TASKS set dropped (${setOrder.reason}) session=${conversationId.slice(0, 8)} epoch=${Number(req.body?.order?.epoch) || 0} seq=${Number(req.body?.order?.seq) || 0} tasks=${tasks.length}`);
7721
7920
  return res.json({ ok: true, count: tasks.length, stale: true, reason: setOrder.reason });
7722
7921
  }
7922
+ // A task with a console that leaves the set ended: its file is read once
7923
+ // more soon (its last output often lands after the poller's last look).
7924
+ const nextIds = new Set(tasks.map((task) => task.taskId));
7925
+ const endedWithConsole = (backgroundTaskStore?.get?.(conversationId) || [])
7926
+ .some((task) => task?.outputPath && !nextIds.has(task.taskId));
7723
7927
  backgroundTaskStore?.replace?.(conversationId, tasks);
7724
7928
  backgroundTaskStore?.announce?.(conversationId, false);
7725
- io.emit('background_tasks', { conversationId, tasks });
7929
+ if (endedWithConsole) pokeTaskHealth?.();
7930
+ emitBackgroundTasks(conversationId, tasks);
7726
7931
  // The store is keyed as the worker posts (its SDK session id); the
7727
7932
  // tracker by the conversation.
7728
7933
  try {
@@ -7769,6 +7974,175 @@ export function registerMessagesRoutes(app, deps) {
7769
7974
  res.json({ ok: true });
7770
7975
  });
7771
7976
 
7977
+ // ─── Task console ──────────────────────────────────────────────────────────
7978
+
7979
+ // The task a conversation's console asks for: in the live or the ended set
7980
+ // of the conversation, under its own id or its worker's session id (the
7981
+ // key the worker posts under), as the stop route resolves it.
7982
+ function findConversationTask(conversationId, taskId) {
7983
+ const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
7984
+ if (!conversation) return { conversation: null, found: null, sessionId: '' };
7985
+ const sessionId = String(conversation.sdk_session_id || conversationId);
7986
+ const found = backgroundTaskStore?.findTask?.([conversationId, sessionId], taskId) || null;
7987
+ return { conversation, found, sessionId };
7988
+ }
7989
+
7990
+ // The providers whose workers name a shell task's output (a file, a spool
7991
+ // or a snapshot). A task of theirs without one comes from a worker that
7992
+ // started before the console existed.
7993
+ const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok']);
7994
+ const SUBAGENT_OUTPUT_MESSAGE = TASK_OUTPUT_NONE_MESSAGES.subagent;
7995
+
7996
+ // Why a task has no output to show, for the console's `none` answer. The
7997
+ // texts are the page's own (task-console-render.mjs), so the row's hover
7998
+ // title and the console say the same.
7999
+ function noOutputMessage(conversationId, task) {
8000
+ if (taskKindOf(task?.taskType) === 'subagent') return SUBAGENT_OUTPUT_MESSAGE;
8001
+ let provider = '';
8002
+ try {
8003
+ provider = String(stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.provider_type || '').trim().toLowerCase();
8004
+ } catch {}
8005
+ if (!provider || TASK_OUTPUT_PROVIDERS.has(provider)) {
8006
+ return TASK_OUTPUT_NONE_MESSAGES.noOutputFile;
8007
+ }
8008
+ return TASK_OUTPUT_NONE_MESSAGES.provider;
8009
+ }
8010
+
8011
+ // A task the session's task list shows that the task store does not hold:
8012
+ // a sub-agent (live or ended, its run is its record) or a task that ended
8013
+ // without a console. The activity tracker knows it by its task or run id.
8014
+ function trackedTask(conversationId, taskId) {
8015
+ let view = null;
8016
+ try { view = sessionActivityTracker?.describe?.(conversationId) || null; } catch {}
8017
+ const tasks = Array.isArray(view?.tasks) ? view.tasks : [];
8018
+ const found = tasks.find((task) => !task?.placeholder && (String(task?.id || '') === taskId || String(task?.runId || '') === taskId));
8019
+ if (found) return found;
8020
+ const run = stmts.getSubagentRun?.get?.(taskId) || null;
8021
+ if (run && String(run.conversation_id || '') === conversationId) {
8022
+ return { id: taskId, kind: 'subagent', type: run.subagent_type || null, description: run.display_name || '', status: run.status || null };
8023
+ }
8024
+ return null;
8025
+ }
8026
+
8027
+ function taskStallMinutes() {
8028
+ try {
8029
+ return getTaskStallMinutes();
8030
+ } catch {
8031
+ return DEFAULT_TASK_STALL_MINUTES;
8032
+ }
8033
+ }
8034
+
8035
+ // GET /api/conversation/:conversationId/background-task/:taskId/output —
8036
+ // the console's read of a shell or Monitor task's output. Without
8037
+ // `before`: the last `lines` lines (default 2,000, at most 5,000) within
8038
+ // 256 KB; with `before` (a startOffset of an earlier answer): the lines
8039
+ // ending there. Everything is cleaned (controls out, colours kept,
8040
+ // credentials scrubbed) for every reader. Another relay's agent reads it
8041
+ // too (remote_relay read_task_output, read permission): no inbound switch,
8042
+ // nothing it reads changes anything.
8043
+ app.get('/api/conversation/:conversationId/background-task/:taskId/output', auth, async (req, res) => {
8044
+ const conversationId = String(req.params.conversationId || '').trim();
8045
+ const taskId = String(req.params.taskId || '').trim();
8046
+ if (!conversationId || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
8047
+ const { conversation, found, sessionId } = findConversationTask(conversationId, taskId);
8048
+ if (!conversation) return res.status(404).json({ error: 'Conversation not found' });
8049
+ if (!found) {
8050
+ const tracked = trackedTask(conversationId, taskId);
8051
+ if (!tracked) return res.status(404).json({ error: 'Task not found', code: 'task-not-found' });
8052
+ const running = String(tracked.status || 'running') === 'running';
8053
+ const subagent = tracked.kind !== 'shell';
8054
+ return res.json({
8055
+ ok: true,
8056
+ taskId,
8057
+ kind: 'none',
8058
+ ended: !running,
8059
+ status: running ? 'running' : String(tracked.status || 'unknown'),
8060
+ exitCode: running ? null : (Number.isInteger(tracked.exitCode) ? tracked.exitCode : null),
8061
+ health: null,
8062
+ taskStallMinutes: taskStallMinutes(),
8063
+ task: {
8064
+ taskId,
8065
+ taskType: tracked.type || (subagent ? 'local_agent' : 'local_bash'),
8066
+ description: String(tracked.description || ''),
8067
+ startedAt: Date.parse(tracked.startedAt) || null,
8068
+ endedAt: Date.parse(tracked.endedAt) || null,
8069
+ stoppable: false,
8070
+ },
8071
+ message: subagent ? SUBAGENT_OUTPUT_MESSAGE : noOutputMessage(conversationId, { taskType: 'local_bash' }),
8072
+ });
8073
+ }
8074
+ const { task, ended, key } = found;
8075
+ // Held since a relay restart: its worker has not said yet whether it
8076
+ // still runs, so it is neither running nor stoppable.
8077
+ const held = !ended && found.restored === true;
8078
+ const { kind, path } = taskOutputSource(task);
8079
+ const head = {
8080
+ ok: true,
8081
+ taskId,
8082
+ kind,
8083
+ ended,
8084
+ status: ended ? task.status : (held ? 'held' : 'running'),
8085
+ ...(held ? { held: true } : {}),
8086
+ exitCode: ended ? (task.exitCode ?? null) : null,
8087
+ health: task.health || null,
8088
+ taskStallMinutes: taskStallMinutes(),
8089
+ task: {
8090
+ taskId,
8091
+ taskType: task.taskType || null,
8092
+ description: task.description || '',
8093
+ ...(task.command ? { command: task.command } : {}),
8094
+ startedAt: task.startedAt || null,
8095
+ endedAt: task.endedAt || null,
8096
+ stoppable: !ended && !held && task.stoppable !== false,
8097
+ },
8098
+ };
8099
+ if (kind === 'none') {
8100
+ return res.json({ ...head, message: noOutputMessage(conversationId, task) });
8101
+ }
8102
+ if (kind === 'snapshot') {
8103
+ // The worker answers a request with a post of the task's last 8 KiB;
8104
+ // the console asks again every few seconds and gets the newest copy.
8105
+ const cached = taskOutputSnapshots?.get?.([key, sessionId, conversationId], taskId) || null;
8106
+ if (!ended) {
8107
+ const sent = sendWorkerControl?.(sessionId, { type: 'task_output_snapshot', taskId });
8108
+ if (!sent && !cached) return res.status(409).json({ error: 'Session worker is not connected', code: 'worker-not-connected' });
8109
+ }
8110
+ if (!cached) return res.json({ ...head, pending: true, text: '', lines: [], startOffset: 0, endOffset: 0, size: 0, mtime: null, hasMoreBefore: false });
8111
+ return res.json({ ...head, ...shapeSnapshot(cached) });
8112
+ }
8113
+ if (!path) return res.json({ ...head, kind: 'none', message: TASK_OUTPUT_NONE_MESSAGES.noPath });
8114
+ const lines = req.query?.lines;
8115
+ const before = req.query?.before;
8116
+ const read = before !== undefined && before !== ''
8117
+ ? await readBefore(path, { before, lines })
8118
+ : await readTail(path, { lines });
8119
+ if (!read.ok) {
8120
+ // No file yet (a spool is named before its first byte) reads as no
8121
+ // output yet; one the relay does not read (a symlink, a folder) is
8122
+ // said so instead of failing.
8123
+ const empty = { text: '', lines: [], startOffset: 0, endOffset: 0, size: 0, mtime: null, hasMoreBefore: false };
8124
+ return res.json({ ...head, ...empty, ...(read.code === 'missing' ? {} : { unavailable: read.code }) });
8125
+ }
8126
+ return res.json({ ...head, ...read });
8127
+ });
8128
+
8129
+ // POST /api/background-tasks/:taskId/output-snapshot — a worker's answer
8130
+ // to `task_output_snapshot`: the task's latest output where it has no
8131
+ // file (Claude's getTaskOutput, its last 8 KiB).
8132
+ app.post('/api/background-tasks/:taskId/output-snapshot', auth, (req, res) => {
8133
+ touchCli();
8134
+ const key = String(req.body?.conversationId || '').trim();
8135
+ const taskId = String(req.params.taskId || '').trim();
8136
+ if (!key || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
8137
+ if (typeof req.body?.output !== 'string') return res.status(400).json({ error: 'output must be a string' });
8138
+ taskOutputSnapshots?.set?.(key, taskId, {
8139
+ output: req.body.output.slice(0, TASK_OUTPUT_LIMITS.chunkBytes),
8140
+ totalBytes: req.body?.totalBytes,
8141
+ truncated: req.body?.truncated === true,
8142
+ });
8143
+ res.json({ ok: true });
8144
+ });
8145
+
7772
8146
  // POST /api/activity — relay sends in-flight activity updates (tool/search sections)
7773
8147
  // Optional attempt fence for the live lanes (stream/thought/activity/
7774
8148
  // subagent): a writer that names its processing attempt is rejected once