@oar-sh/oar 0.9.11 → 0.9.12

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 (63) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +17 -3
  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 +128 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -3
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +9 -0
  15. package/server/grok-worker/acp-host-services.mjs +42 -3
  16. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  17. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  18. package/server/grok-worker/grok-session-worker.mjs +33 -1
  19. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  20. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  21. package/server/public/app/api-client.js +13 -0
  22. package/server/public/app/background-tasks-view.mjs +285 -16
  23. package/server/public/app/bootstrap.js +95 -5
  24. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  25. package/server/public/app/conversation-payload-cache.mjs +357 -0
  26. package/server/public/app/conversation-turn-state.mjs +1 -1
  27. package/server/public/app/conversation-view.js +433 -3
  28. package/server/public/app/conversation-window-retention.mjs +96 -0
  29. package/server/public/app/journal-view.js +252 -66
  30. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  31. package/server/public/app/settings-modal.js +110 -2
  32. package/server/public/app/socket-handlers.js +59 -7
  33. package/server/public/app/store.js +25 -0
  34. package/server/public/app/subagent-viewer.mjs +7 -1
  35. package/server/public/app/task-console-render.mjs +237 -0
  36. package/server/public/app/task-console-watch.mjs +42 -0
  37. package/server/public/app/task-console.mjs +630 -0
  38. package/server/public/app/task-output-text.mjs +234 -0
  39. package/server/public/app/transcript-island.mjs +73 -0
  40. package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
  41. package/server/public/app/transcript-jump-bottom.mjs +48 -11
  42. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  43. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  44. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  45. package/server/public/index.html +239 -6
  46. package/server/relay-tools.md +1 -0
  47. package/server/repositories/message-repository.mjs +8 -0
  48. package/server/repositories/question-repository.mjs +4 -0
  49. package/server/routes/messages-routes.mjs +249 -3
  50. package/server/routes/sessions-routes.mjs +133 -63
  51. package/server/server-runtime.mjs +77 -0
  52. package/server/services/background-task-store.mjs +240 -3
  53. package/server/services/cli-process-runner.mjs +4 -21
  54. package/server/services/conversation-history-page.mjs +199 -0
  55. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  56. package/server/services/remote-relay-dispatcher.mjs +130 -2
  57. package/server/services/session-activity-tracker.mjs +6 -2
  58. package/server/services/task-output-rooms.mjs +259 -0
  59. package/server/services/task-output-service.mjs +562 -0
  60. package/shared/claude-background-command-limit.mjs +83 -0
  61. package/shared/remote-relay-contract.mjs +32 -2
  62. package/shared/task-output-text.mjs +737 -0
  63. package/shared/worker-runtime/task-output-spool.mjs +181 -0
@@ -29,6 +29,8 @@ import {
29
29
  updateTurnCeilingSetting as requestTurnCeilingSetting,
30
30
  loadBackgroundTaskTimeoutSetting,
31
31
  updateBackgroundTaskTimeoutSetting as requestBackgroundTaskTimeoutSetting,
32
+ loadTaskStallMinutesSetting,
33
+ updateTaskStallMinutesSetting as requestTaskStallMinutesSetting,
32
34
  loadSubagentRecordRetentionSetting,
33
35
  updateSubagentRecordRetentionSetting as requestSubagentRecordRetentionSetting,
34
36
  loadFeatureFlagSettings,
@@ -36,6 +38,12 @@ import {
36
38
  requestRelayRestart,
37
39
  } from './api-client.js';
38
40
  import { DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES } from './background-task-timeout-options.mjs';
41
+ import {
42
+ DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES,
43
+ formatClaudeBackgroundCommandLimitLabel,
44
+ } from './claude-background-command-limit-options.mjs';
45
+ import { DEFAULT_TASK_STALL_MINUTES } from './task-console-render.mjs';
46
+ import { setTaskStallThreshold } from './background-tasks-view.mjs';
39
47
  import { describeRelayRestartError } from './relay-restart-error.mjs';
40
48
  import { syncFontScaleSelect } from './font-scaling.js';
41
49
  import { syncInstalledAppUiState, syncPwaAppNameInput } from './pwa-install.js';
@@ -51,6 +59,7 @@ import { refreshRemoteRelaysSection } from './remote-relays-settings.js';
51
59
  import { refreshAgentSessionsSection } from './agent-sessions-settings-ui.js';
52
60
  import { refreshSharedConversationsSection } from './shared-conversations-settings.js';
53
61
  import { syncScrollOnSendToggle } from './transcript-jump-bottom.mjs';
62
+ import { syncWindowRetentionSlider } from './conversation-window-retention.mjs';
54
63
  import {
55
64
  getPreviews,
56
65
  renderPreviewRowsInto,
@@ -202,6 +211,74 @@ export async function refreshBackgroundTaskTimeoutSetting() {
202
211
  }
203
212
  }
204
213
 
214
+ // Minutes without output after which a background task's row turns amber.
215
+ // Starts at the relay's default; bounds come from the API response.
216
+ let taskStallMinutes = DEFAULT_TASK_STALL_MINUTES;
217
+ let taskStallMinutesUpdateInFlight = false;
218
+
219
+ export function formatTaskStallMinutesLabel(value) {
220
+ const minutes = Math.max(1, Math.round(Number(value) || DEFAULT_TASK_STALL_MINUTES));
221
+ return `${minutes} min`;
222
+ }
223
+
224
+ function syncTaskStallMinutesSlider() {
225
+ const slider = document.getElementById('task-stall-minutes-slider');
226
+ const label = document.getElementById('task-stall-minutes-value');
227
+ if (slider instanceof HTMLInputElement) {
228
+ slider.value = String(taskStallMinutes);
229
+ slider.disabled = taskStallMinutesUpdateInFlight;
230
+ }
231
+ if (label) label.textContent = formatTaskStallMinutesLabel(taskStallMinutes);
232
+ }
233
+
234
+ /** Live label feedback while dragging; nothing is persisted until change fires. */
235
+ export function previewTaskStallMinutesSetting(value) {
236
+ const label = document.getElementById('task-stall-minutes-value');
237
+ if (label) label.textContent = formatTaskStallMinutesLabel(value);
238
+ }
239
+
240
+ export async function refreshTaskStallMinutesSetting() {
241
+ syncTaskStallMinutesSlider();
242
+ try {
243
+ const result = await loadTaskStallMinutesSetting();
244
+ if (!result || !Number.isFinite(Number(result.minutes))) return;
245
+ taskStallMinutes = Number(result.minutes);
246
+ setTaskStallThreshold(taskStallMinutes);
247
+ const slider = document.getElementById('task-stall-minutes-slider');
248
+ if (slider instanceof HTMLInputElement) {
249
+ if (Number.isFinite(Number(result.min))) slider.min = String(result.min);
250
+ if (Number.isFinite(Number(result.max))) slider.max = String(result.max);
251
+ }
252
+ } catch {
253
+ // Keep the last known value; the setting is not critical.
254
+ } finally {
255
+ syncTaskStallMinutesSlider();
256
+ }
257
+ }
258
+
259
+ export async function updateTaskStallMinutesSetting(value) {
260
+ if (taskStallMinutesUpdateInFlight) {
261
+ syncTaskStallMinutesSlider();
262
+ return;
263
+ }
264
+ const requested = Math.max(1, Math.round(Number(value) || DEFAULT_TASK_STALL_MINUTES));
265
+ taskStallMinutesUpdateInFlight = true;
266
+ syncTaskStallMinutesSlider();
267
+ try {
268
+ const result = await requestTaskStallMinutesSetting(requested);
269
+ if (!result) throw new Error('Failed to update when a background task counts as quiet.');
270
+ taskStallMinutes = Number.isFinite(Number(result.minutes)) ? Number(result.minutes) : requested;
271
+ // The open conversation's rows follow at once, not on the next load.
272
+ setTaskStallThreshold(taskStallMinutes);
273
+ showTransientRelayNotice(`Background tasks are marked as quiet after ${formatTaskStallMinutesLabel(taskStallMinutes)} without output.`);
274
+ } catch (error) {
275
+ alert(error?.message || 'Failed to update when a background task counts as quiet.');
276
+ } finally {
277
+ taskStallMinutesUpdateInFlight = false;
278
+ syncTaskStallMinutesSlider();
279
+ }
280
+ }
281
+
205
282
  // Mirrors shared/subagent-record-retention.mjs (the browser cannot import it):
206
283
  // the select starts at the relay's default until the relay's value loads.
207
284
  const DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS = 90;
@@ -479,6 +556,7 @@ let claudeSettingsState = {
479
556
  enabled: false,
480
557
  model: 'claude-sonnet-5',
481
558
  models: [],
559
+ backgroundCommandLimitMinutes: DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES,
482
560
  };
483
561
  let claudeSettingsInputsDirty = false;
484
562
 
@@ -492,7 +570,7 @@ function ensureClaudeSettingsInputTracking() {
492
570
  }
493
571
 
494
572
  function setClaudeSettingsControlsDisabled(disabled) {
495
- for (const id of ['claude-model-input', 'claude-enabled-toggle', 'claude-attribution-select', 'claude-save-btn']) {
573
+ for (const id of ['claude-model-input', 'claude-enabled-toggle', 'claude-attribution-select', 'claude-background-command-limit-slider', 'claude-save-btn']) {
496
574
  const element = document.getElementById(id);
497
575
  if (element) element.disabled = disabled;
498
576
  }
@@ -507,6 +585,9 @@ export function applyClaudeSettingsState(settings = {}, { resetInputs = false }
507
585
  models: Array.isArray(settings?.models) ? settings.models : claudeSettingsState.models,
508
586
  attributionMode: ['oar', 'vanilla', 'off'].includes(settings?.attributionMode) ? settings.attributionMode : (claudeSettingsState.attributionMode || 'oar'),
509
587
  attributionExample: String(settings?.attributionExample || claudeSettingsState.attributionExample || '').trim(),
588
+ backgroundCommandLimitMinutes: Number(settings?.backgroundCommandLimitMinutes) > 0
589
+ ? Number(settings.backgroundCommandLimitMinutes)
590
+ : (claudeSettingsState.backgroundCommandLimitMinutes || DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES),
510
591
  };
511
592
  const modelInput = document.getElementById('claude-model-input');
512
593
  const toggle = document.getElementById('claude-enabled-toggle');
@@ -514,6 +595,15 @@ export function applyClaudeSettingsState(settings = {}, { resetInputs = false }
514
595
  const attributionSelect = document.getElementById('claude-attribution-select');
515
596
  if (attributionSelect && (!claudeSettingsInputsDirty || resetInputs)) attributionSelect.value = claudeSettingsState.attributionMode;
516
597
  renderClaudeAttributionExample(attributionSelect?.value || claudeSettingsState.attributionMode);
598
+ const limitSlider = document.getElementById('claude-background-command-limit-slider');
599
+ if (limitSlider instanceof HTMLInputElement) {
600
+ // Bounds come from the relay; the markup's are only the pre-load copy.
601
+ for (const [attribute, field] of [['min', 'backgroundCommandLimitMinMinutes'], ['max', 'backgroundCommandLimitMaxMinutes'], ['step', 'backgroundCommandLimitStepMinutes']]) {
602
+ if (Number(settings?.[field]) > 0) limitSlider[attribute] = String(settings[field]);
603
+ }
604
+ if (!claudeSettingsInputsDirty || resetInputs) limitSlider.value = String(claudeSettingsState.backgroundCommandLimitMinutes);
605
+ }
606
+ renderClaudeBackgroundCommandLimitLabel(limitSlider?.value || claudeSettingsState.backgroundCommandLimitMinutes);
517
607
  if (modelInput && (!claudeSettingsInputsDirty || resetInputs)) modelInput.value = claudeSettingsState.model;
518
608
  if (resetInputs) claudeSettingsInputsDirty = false;
519
609
  if (toggle) {
@@ -561,6 +651,19 @@ function renderClaudeAttributionExample(mode) {
561
651
  example.textContent = claudeAttributionExampleText(mode, claudeSettingsState.attributionExample);
562
652
  }
563
653
 
654
+ function renderClaudeBackgroundCommandLimitLabel(minutes) {
655
+ const label = document.getElementById('claude-background-command-limit-value');
656
+ if (label) label.textContent = formatClaudeBackgroundCommandLimitLabel(minutes);
657
+ }
658
+
659
+ /** Live label feedback while dragging; saved with the other Claude settings. */
660
+ export function previewClaudeBackgroundCommandLimit(value) {
661
+ claudeSettingsInputsDirty = true;
662
+ renderClaudeBackgroundCommandLimitLabel(value);
663
+ }
664
+
665
+ window.previewClaudeBackgroundCommandLimit = previewClaudeBackgroundCommandLimit;
666
+
564
667
  window.previewClaudeAttribution = (mode) => {
565
668
  claudeSettingsInputsDirty = true;
566
669
  renderClaudeAttributionExample(mode);
@@ -572,10 +675,12 @@ export async function saveClaudeSettings() {
572
675
  const model = String(modelInput?.value || '').trim() || 'claude-sonnet-5';
573
676
  const attributionSelect = document.getElementById('claude-attribution-select');
574
677
  const attributionMode = ['oar', 'vanilla', 'off'].includes(attributionSelect?.value) ? attributionSelect.value : undefined;
678
+ const limitSlider = document.getElementById('claude-background-command-limit-slider');
679
+ const backgroundCommandLimitMinutes = Number(limitSlider?.value) > 0 ? Number(limitSlider.value) : undefined;
575
680
  claudeSettingsUpdateInFlight = true;
576
681
  setClaudeSettingsControlsDisabled(true);
577
682
  try {
578
- const result = await updateClaudeSettings({ model, attributionMode });
683
+ const result = await updateClaudeSettings({ model, attributionMode, backgroundCommandLimitMinutes });
579
684
  if (!result) throw new Error('Failed to save Claude settings.');
580
685
  applyClaudeSettingsState(result, { resetInputs: true });
581
686
  showTransientRelayNotice(
@@ -1757,6 +1862,7 @@ export function openSettingsModal(tab, providerTab) {
1757
1862
  }
1758
1863
  syncSuspendHostVisibility();
1759
1864
  syncScrollOnSendToggle();
1865
+ syncWindowRetentionSlider();
1760
1866
  syncFontScaleSelect();
1761
1867
  syncPwaAppNameInput();
1762
1868
  syncDefaultSessionWorkspaceRootInput();
@@ -1789,6 +1895,8 @@ export function openSettingsModal(tab, providerTab) {
1789
1895
  void refreshTurnCeilingSetting();
1790
1896
  syncBackgroundTaskTimeoutSlider();
1791
1897
  void refreshBackgroundTaskTimeoutSetting();
1898
+ syncTaskStallMinutesSlider();
1899
+ void refreshTaskStallMinutesSetting();
1792
1900
  syncSubagentRecordRetentionSelect();
1793
1901
  void refreshSubagentRecordRetentionSetting();
1794
1902
  void refreshPushSettingsSection();
@@ -30,6 +30,7 @@ import {
30
30
  applySessionWorkerSteering,
31
31
  refreshSummaryModal,
32
32
  summaryModalState,
33
+ IS_SHARED_VIEW,
33
34
  } from './store.js';
34
35
  import { scheduleContextUsageRefresh } from './api-client.js';
35
36
  import { capLiveActivityEntries } from './activity-replay-state.mjs';
@@ -43,7 +44,8 @@ import {
43
44
  updatePendingQuestionBanner,
44
45
  } from './ask-user-view.js';
45
46
  import { upsertRelayBoard, loadRelayBoards, renderRelayBoards } from './relay-board-view.js';
46
- import { setConversationBackgroundTasks } from './background-tasks-view.mjs';
47
+ import { setConversationBackgroundTasks, setConversationTaskHealth } from './background-tasks-view.mjs';
48
+ import { handleTaskConsoleReconnect, handleTaskOutputChunk } from './task-console.mjs';
47
49
  import { setConversationPins } from './pinned-messages-view.mjs';
48
50
  import { setConversationHiddenMessages } from './hidden-messages-view.mjs';
49
51
  import { setPreviews } from './preview-cards.mjs';
@@ -83,6 +85,8 @@ import {
83
85
  noteTranscriptLiveRow,
84
86
  applyIncomingConversationDraftUpdate,
85
87
  getRenderedConversationMessageFingerprints,
88
+ holdBackLiveRow,
89
+ isConversationWindowAtTail,
86
90
  clearBubbleCancelState,
87
91
  removeUserBubbleCancelButton,
88
92
  updateSubagentBubbleFromStatus,
@@ -96,6 +100,17 @@ import { clearMessageSearchRuntimeState } from './message-search-view.js';
96
100
  import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
97
101
  import { isLikelyLiveDuplicateMessage } from './live-message-dedupe.mjs';
98
102
  import { mergeRelayThoughts } from './relay-thoughts.mjs';
103
+ import { conversationPayloadCache } from './conversation-payload-cache.mjs';
104
+
105
+ // A live row of the conversation on screen joins its kept window, so a
106
+ // switch away and back still shows it. Rows of other conversations do not:
107
+ // their kept copy is what the reader saw, and the switch back fetches the rest.
108
+ function keepLiveRow(conversationId, message, messageId) {
109
+ if (IS_SHARED_VIEW || !message || typeof message !== 'object') return;
110
+ const id = String(message.id || messageId || '').trim();
111
+ if (!id) return;
112
+ conversationPayloadCache.upsertMessage(conversationId, message.id ? message : { ...message, id });
113
+ }
99
114
  import {
100
115
  handleSubagentStatusEventForViewer,
101
116
  handleSubagentTimelineEvent,
@@ -335,6 +350,9 @@ export async function connectSocket(overrideDeps) {
335
350
  // session, whose restored socket.data the server deliberately resets.
336
351
  emitDeviceVisibility(document.visibilityState === 'visible');
337
352
  clearMessageSearchRuntimeState();
353
+ // Events missed while the socket was down may have changed any of the
354
+ // conversations kept for a quick switch back.
355
+ conversationPayloadCache.clear();
338
356
  setRelayOnline(true);
339
357
  setCliOnline(true);
340
358
  renderConvList();
@@ -346,6 +364,8 @@ export async function connectSocket(overrideDeps) {
346
364
  refreshModelCatalog().catch(() => {});
347
365
  // An open sub-agent viewer fetches whatever it missed while away.
348
366
  handleSubagentViewerReconnect();
367
+ // An open task console watches again and reads what it missed.
368
+ void handleTaskConsoleReconnect();
349
369
  // Subscriptions that changed while away.
350
370
  void loadRelayEventSubscriptions();
351
371
  // Shares made or stopped while away.
@@ -519,11 +539,15 @@ export async function connectSocket(overrideDeps) {
519
539
  })) {
520
540
  return;
521
541
  }
542
+ // A window that ends above the conversation's end takes no live row:
543
+ // the newer pages bring it, and the jump-to-bottom badge counts it.
544
+ if (holdBackLiveRow(conversationId, messageId || normalizedMessage.id)) return;
522
545
  // Another client's message (an agent's prompt included) follows the
523
546
  // end like any live row: a reader scrolled up stays where they are,
524
547
  // and the jump-to-bottom badge counts it.
525
548
  appendMessage(normalizedMessage, followsTranscriptEnd(), messageId);
526
549
  noteTranscriptLiveRow();
550
+ keepLiveRow(conversationId, normalizedMessage, messageId);
527
551
  }
528
552
  });
529
553
  socket.on('assistant_message', ({ conversationId, message, messageId, sourceMessageId }) => {
@@ -554,12 +578,15 @@ export async function connectSocket(overrideDeps) {
554
578
  restoreLiveReplyReadingPosition(reading);
555
579
  return;
556
580
  }
557
- if (isCurrentConversation) {
581
+ // Held back under a window that ends above the conversation's end, as
582
+ // in user_message.
583
+ if (isCurrentConversation && !holdBackLiveRow(conversationId, messageId || message?.id)) {
558
584
  appendMessage(message, autoScroll, messageId || null, false, sourceMessageId || null);
559
585
  restoreLiveReplyReadingPosition(reading);
560
586
  noteTranscriptLiveRow();
561
- scheduleContextUsageRefresh(conversationId, 120);
587
+ keepLiveRow(conversationId, message, messageId);
562
588
  }
589
+ if (isCurrentConversation) scheduleContextUsageRefresh(conversationId, 120);
563
590
  if (sourceMessageId) relayActivities.delete(sourceMessageId);
564
591
  if (sourceMessageId) relayThoughts.delete(sourceMessageId);
565
592
  if (sourceMessageId) clearRelayStreamStateForMessage(sourceMessageId);
@@ -574,11 +601,22 @@ export async function connectSocket(overrideDeps) {
574
601
  // cannot be started): same id, new text or kind. Not on the page yet (the
575
602
  // conversation was opened after it was written): appended like any other.
576
603
  socket.on('message_updated', ({ conversationId, messageId, message }) => {
577
- if (!messageId || conversationId !== currentConvId || !message) return;
604
+ // A row rewritten in place. Another conversation's kept window would show
605
+ // the old one: it is dropped. The window on screen takes the new row as
606
+ // the page does, so a switch away and back keeps both the row and the
607
+ // window around it.
608
+ if (!messageId || conversationId !== currentConvId || !message) {
609
+ conversationPayloadCache.forget(conversationId);
610
+ return;
611
+ }
578
612
  if (!replaceRenderedMessage(message, messageId)) {
613
+ // A row outside a window that ends above the conversation's end: the
614
+ // newer pages bring it as it is now.
615
+ if (!isConversationWindowAtTail()) return;
579
616
  appendMessage(message, followsTranscriptEnd(), messageId, false);
580
617
  }
581
618
  noteTranscriptLiveRow();
619
+ keepLiveRow(conversationId, message, messageId);
582
620
  });
583
621
  socket.on('relay_question', ({ question }) => upsertRelayQuestion(question));
584
622
  socket.on('relay_question_updated', ({ question }) => upsertRelayQuestion(question));
@@ -594,8 +632,8 @@ export async function connectSocket(overrideDeps) {
594
632
  socket.on('relay_event_subscriptions', ({ subscriptions }) => {
595
633
  applyRelayEventSubscriptions(subscriptions);
596
634
  });
597
- socket.on('background_tasks', ({ conversationId, tasks }) => {
598
- setConversationBackgroundTasks(conversationId, tasks);
635
+ socket.on('background_tasks', ({ conversationId, tasks, endedTasks }) => {
636
+ setConversationBackgroundTasks(conversationId, tasks, { endedTasks });
599
637
  // The sidebar's row (and its group's head) count background work as
600
638
  // running; the set arrives under the conversation or its session id.
601
639
  const id = String(conversationId || '').trim();
@@ -604,6 +642,16 @@ export async function connectSocket(overrideDeps) {
604
642
  || null;
605
643
  if (record && applyBackgroundTasksToConversation(record, tasks)) renderConvList();
606
644
  });
645
+ // The relay's health poller: last output time and test-runner counts of
646
+ // shell and Monitor tasks, only when they changed.
647
+ socket.on('background_tasks_health', ({ conversationId, health }) => {
648
+ setConversationTaskHealth(conversationId, health);
649
+ });
650
+ // New bytes of the task the console shows; the relay sends them only while
651
+ // this page's console watches that task.
652
+ socket.on('task_output_chunk', (payload) => {
653
+ handleTaskOutputChunk(payload);
654
+ });
607
655
  // A message was pinned or unpinned (any client): the conversation's whole
608
656
  // pin list, replacing what this page had.
609
657
  socket.on('conversation_pins_updated', ({ conversationId, pins, revision }) => {
@@ -738,6 +786,8 @@ export async function connectSocket(overrideDeps) {
738
786
  });
739
787
  socket.on('conversation_compacted', async ({ sourceConversationId, targetConversationId }) => {
740
788
  if (!sourceConversationId || !targetConversationId) return;
789
+ conversationPayloadCache.forget(sourceConversationId);
790
+ conversationPayloadCache.forget(targetConversationId);
741
791
  await refreshConversations();
742
792
  if (currentConvId === sourceConversationId) {
743
793
  await openConversation(targetConversationId);
@@ -827,7 +877,7 @@ export async function connectSocket(overrideDeps) {
827
877
  applyConversationTurnStatus({ conversationId, messageId, status });
828
878
  if (conversationId && conversations[conversationId]) {
829
879
  const conversation = conversations[conversationId];
830
- // The row's turn fields follow the event, open or not: the dots, a
880
+ // The row's turn fields follow the event, open or not: the title colour, a
831
881
  // group head's pill and its fold read them between list fetches.
832
882
  applyTurnStatusToConversation(conversation, normalizedStatus);
833
883
  if (normalizedStatus === 'processing') {
@@ -892,6 +942,7 @@ export async function connectSocket(overrideDeps) {
892
942
  if (!id) return;
893
943
  const conversation = conversations[id];
894
944
  if (conversation) conversation.archived = archived !== false;
945
+ conversationPayloadCache.forget(id);
895
946
  renderConvList();
896
947
  if (currentConvId === id) {
897
948
  syncChatTitleControls();
@@ -900,6 +951,7 @@ export async function connectSocket(overrideDeps) {
900
951
  });
901
952
  socket.on('conversation_deleted', ({ conversationId }) => {
902
953
  delete conversations[conversationId];
954
+ conversationPayloadCache.forget(conversationId);
903
955
  for (const [id, question] of relayQuestions.entries()) {
904
956
  if (question?.conversationId === conversationId) relayQuestions.delete(id);
905
957
  }
@@ -1,4 +1,5 @@
1
1
  import { resolveNearBottomThresholdPx } from './transcript-viewport-anchor.mjs';
2
+ import { normalizeReadingAnchor } from './transcript-reading-anchor.mjs';
2
3
  import { recordCliLifecycleEvent, recordRelayLifecycleEvent } from './status-store.mjs';
3
4
  import { resolveRelayDotState } from './relay-dot-state.mjs';
4
5
  import { readRepoBrowserPreferences } from './repo-browser-preferences.mjs';
@@ -313,6 +314,7 @@ function readConversationViewState(conversationId) {
313
314
  loadedMessageCount: Number.isFinite(Number(parsed.loadedMessageCount)) && Number(parsed.loadedMessageCount) >= 0
314
315
  ? Math.trunc(Number(parsed.loadedMessageCount))
315
316
  : null,
317
+ anchor: normalizeReadingAnchor(parsed.anchor),
316
318
  };
317
319
  }
318
320
  } catch {
@@ -323,6 +325,7 @@ function readConversationViewState(conversationId) {
323
325
  return {
324
326
  scrollTop: Math.trunc(legacyScrollTop),
325
327
  loadedMessageCount: null,
328
+ anchor: null,
326
329
  };
327
330
  }
328
331
 
@@ -337,6 +340,7 @@ function writeConversationViewState(conversationId, nextState = {}) {
337
340
  loadedMessageCount: Number.isFinite(Number(nextState.loadedMessageCount)) && Number(nextState.loadedMessageCount) >= 0
338
341
  ? Math.trunc(Number(nextState.loadedMessageCount))
339
342
  : current.loadedMessageCount ?? null,
343
+ anchor: 'anchor' in nextState ? normalizeReadingAnchor(nextState.anchor) : (current.anchor ?? null),
340
344
  };
341
345
  localStorage.setItem(key, JSON.stringify(next));
342
346
  return true;
@@ -373,6 +377,27 @@ export function loadConversationLoadedMessageCount(conversationId) {
373
377
  return readConversationViewState(conversationId)?.loadedMessageCount ?? null;
374
378
  }
375
379
 
380
+ // The message read and its offset, or "at the end" (transcript-reading-anchor.mjs):
381
+ // what a switch back and a reload restore. scrollTop is still written for
382
+ // older pages of this app reading the same storage.
383
+ export function saveConversationReadingAnchor(conversationId, anchor) {
384
+ const value = normalizeReadingAnchor(anchor);
385
+ if (!value) return false;
386
+ try {
387
+ return writeConversationViewState(conversationId, { anchor: value });
388
+ } catch {
389
+ return false;
390
+ }
391
+ }
392
+
393
+ export function loadConversationReadingAnchor(conversationId) {
394
+ try {
395
+ return readConversationViewState(conversationId)?.anchor ?? null;
396
+ } catch {
397
+ return null;
398
+ }
399
+ }
400
+
376
401
  function normalizePendingMessageText(text) {
377
402
  return String(text || '').replace(/\s+/g, ' ').trim().toLowerCase();
378
403
  }
@@ -21,7 +21,7 @@ import { createViewerHistory } from './viewer-history.mjs';
21
21
  import { getSocket } from './socket-handlers.js';
22
22
  import { createSubagentViewerWatch } from './subagent-viewer-watch.mjs';
23
23
  import { formatTaskModelLabel, formatTaskTokens } from './task-row-format.mjs';
24
- import { getConversationBackgroundTasks } from './background-tasks-view.mjs';
24
+ import { getConversationBackgroundTasks, setSubagentStepCounter } from './background-tasks-view.mjs';
25
25
  import { formatRunDuration, isRunningSubagentStatus, subagentRowHtml, subagentStatusLabel } from './subagent-row.mjs';
26
26
  import {
27
27
  createTimelineState,
@@ -488,6 +488,12 @@ export function syncSubagentRowClock() {
488
488
  export function initSubagentViewer() {
489
489
  if (initialized) return;
490
490
  initialized = true;
491
+ // A sub-agent row without the worker's `steps` counts the run's activity
492
+ // lines, the number its reply bubble shows.
493
+ setSubagentStepCounter((runId) => {
494
+ const activities = getSubagentRun(runId)?.activities;
495
+ return Array.isArray(activities) ? activities.length : null;
496
+ });
491
497
  viewerHistory = createViewerHistory({
492
498
  history: window.history,
493
499
  mark: 'oarSubagentViewer',