@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
@@ -43,6 +43,8 @@ import {
43
43
  loadConversationScrollTop,
44
44
  loadConversationLoadedMessageCount,
45
45
  saveConversationScrollTop,
46
+ loadConversationReadingAnchor,
47
+ saveConversationReadingAnchor,
46
48
  getSessionWorkerState,
47
49
  resolveConversationUiState,
48
50
  summaryModalState,
@@ -61,6 +63,8 @@ import {
61
63
  } from './transcript-viewport-anchor.mjs';
62
64
  import { initTranscriptJumpToBottom, onTranscriptEndLeft } from './transcript-jump-bottom-view.mjs';
63
65
  import { syncScrollOnSendToggle, updateScrollOnSendSetting } from './transcript-jump-bottom.mjs';
66
+ import { previewWindowRetention, syncWindowRetentionSlider, updateWindowRetention } from './conversation-window-retention.mjs';
67
+ import { conversationPayloadCache, mergeReloadIntoWindow } from './conversation-payload-cache.mjs';
64
68
  import {
65
69
  verifyExistingSession,
66
70
  verifyToken,
@@ -101,6 +105,7 @@ import {
101
105
  openOriginConversation,
102
106
  openConversationGroupPicker,
103
107
  toggleConversationGroupFold,
108
+ isConversationWorking,
104
109
  } from './journal-view.js';
105
110
  import {
106
111
  loadRelayQuestions,
@@ -139,8 +144,13 @@ import {
139
144
  isConversationWindowAtTail,
140
145
  getTranscriptJumpEpoch,
141
146
  getTranscriptLiveRowEpoch,
147
+ getHeldBackLiveRowIds,
148
+ captureConversationReadingAnchor,
149
+ canMergeIntoTranscript,
142
150
  } from './conversation-view.js';
143
151
  import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
152
+ import { backgroundTasksSnapshotMark } from './background-tasks-view.mjs';
153
+ import { newerPageReachesEnd } from './transcript-island.mjs';
144
154
  import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
145
155
  import { initHiddenMessagesView, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
146
156
  import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
@@ -189,6 +199,7 @@ import {
189
199
  closeMessageSearchModal,
190
200
  } from './message-search-view.js';
191
201
  import { initSubagentViewer } from './subagent-viewer.mjs';
202
+ import { initTaskConsole } from './task-console.mjs';
192
203
  import { watchPreviewMarks } from './share-preview-marks.mjs';
193
204
  import {
194
205
  initGitChangesView,
@@ -327,6 +338,9 @@ import {
327
338
  updateTurnCeilingSetting,
328
339
  previewBackgroundTaskTimeoutSetting,
329
340
  updateBackgroundTaskTimeoutSetting,
341
+ previewTaskStallMinutesSetting,
342
+ updateTaskStallMinutesSetting,
343
+ updateRestartResumeSettingFromToggle,
330
344
  updateSubagentRecordRetentionSetting,
331
345
  } from './settings-modal.js';
332
346
  import {
@@ -3703,6 +3717,23 @@ function transcriptIsInHistory(conversationId) {
3703
3717
  return !isConversationWindowAtTail() && !hasPendingUserMessageForConversation(conversationId);
3704
3718
  }
3705
3719
 
3720
+ // A reload of the end of the conversation on screen, when the page holds
3721
+ // more rows than the answer can (the relay caps a page at 100): the answer
3722
+ // is merged into the window (mergeReloadIntoWindow) rather than rendered,
3723
+ // which would cut the page and its kept window down to the answer. Null
3724
+ // means the plain render, as before: nothing to merge, or a transcript that
3725
+ // cannot take a merge (a bubble waiting for its send, the status view, a
3726
+ // held selection, a row missing from the page). Both callers merge only an
3727
+ // answer no live row overtook (loadKeepingLiveRows not stale), so the window
3728
+ // predates it: a newer row the answer lacks was deleted (a cancelled
3729
+ // follow-up) and leaves the page.
3730
+ function reloadMergeFor(conversationId, response) {
3731
+ if (IS_SHARED_VIEW) return null;
3732
+ const merge = mergeReloadIntoWindow(conversationPayloadCache.peekWindow(conversationId), response, { windowPredatesPage: true });
3733
+ if (!merge || !canMergeIntoTranscript(merge.mergeFrom, conversationId)) return null;
3734
+ return merge;
3735
+ }
3736
+
3706
3737
  async function pollAuthenticatedCurrentConversationLive() {
3707
3738
  if (liveConversationPollInFlight) return;
3708
3739
  if (isSharedReaderMode()) return;
@@ -3729,6 +3760,7 @@ async function pollAuthenticatedCurrentConversationLive() {
3729
3760
  const savedScrollTop = messagesEl?.scrollTop || 0;
3730
3761
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3731
3762
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
3763
+ const tasksMark = backgroundTasksSnapshotMark(currentId);
3732
3764
  const jumpEpoch = getTranscriptJumpEpoch();
3733
3765
  const { response, stale } = await loadKeepingLiveRows(
3734
3766
  () => loadConversation(currentId, { limit: requestLimit }),
@@ -3748,11 +3780,17 @@ async function pollAuthenticatedCurrentConversationLive() {
3748
3780
  // The rows are rebuilt: a reader who is not at the end keeps the text
3749
3781
  // they read where it is, whatever the rows above it measure now.
3750
3782
  const reading = preserveBottom ? null : captureReadingBlock(messagesEl);
3751
- applyLoadedConversationState(currentId, response, {
3783
+ const merge = reloadMergeFor(currentId, response);
3784
+ applyLoadedConversationState(currentId, merge?.payload || response, {
3752
3785
  restoreScroll: !preserveBottom,
3753
3786
  savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
3754
3787
  followLiveUpdates: preserveBottom,
3788
+ mergeFrom: merge?.mergeFrom || null,
3789
+ backgroundTasksAsOf: tasksMark,
3755
3790
  });
3791
+ // A merge does not scroll as the full render does: a reader at the end
3792
+ // is taken to the new end here.
3793
+ if (merge && preserveBottom && messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
3756
3794
  if (reading) restoreReadingBlock(messagesEl, reading);
3757
3795
  } finally {
3758
3796
  liveConversationPollInFlight = false;
@@ -3888,6 +3926,21 @@ function initComposerAttachmentInput() {
3888
3926
  window.addEventListener('dragend', clearHighlight);
3889
3927
  }
3890
3928
 
3929
+ // The place read is noted once per frame: it measures rows, a scroll event
3930
+ // can come many times a frame. It is noted for the conversation whose rows
3931
+ // are on the page, and only while that is the one open: during a switch the
3932
+ // page still shows the conversation left.
3933
+ let readingAnchorSaveFrame = 0;
3934
+ function scheduleReadingAnchorSave() {
3935
+ if (readingAnchorSaveFrame || IS_SHARED_VIEW) return;
3936
+ readingAnchorSaveFrame = requestAnimationFrame(() => {
3937
+ readingAnchorSaveFrame = 0;
3938
+ const captured = captureConversationReadingAnchor();
3939
+ if (!captured || captured.conversationId !== String(currentConvId || '').trim()) return;
3940
+ saveConversationReadingAnchor(captured.conversationId, captured.anchor);
3941
+ });
3942
+ }
3943
+
3891
3944
  function initMessageScrollPersistence() {
3892
3945
  const el = document.getElementById('messages');
3893
3946
  if (!el || el.dataset.scrollPersistenceBound === '1') return;
@@ -3897,19 +3950,43 @@ function initMessageScrollPersistence() {
3897
3950
  const convId = String(currentConvId || '').trim();
3898
3951
  if (!convId) return;
3899
3952
  saveConversationScrollTop(convId, el.scrollTop);
3953
+ scheduleReadingAnchorSave();
3900
3954
  }, { passive: true });
3901
3955
  }
3902
3956
 
3903
- // The jump-to-bottom button on a window in the middle of the history: the
3904
- // end of the conversation replaces it, scrolled to the bottom.
3957
+ // The end's answer can predate a row held back below the window while it
3958
+ // was on its way (stored after the request read the conversation), and its
3959
+ // render lets the held rows go. The newest row held is the one to look for:
3960
+ // older ones can lie before a page of the end and are not missing.
3961
+ function endAnswerLacksHeldBackRow(response) {
3962
+ const newestHeld = getHeldBackLiveRowIds().slice(-1);
3963
+ if (!newestHeld.length) return false;
3964
+ return !newerPageReachesEnd({
3965
+ pageHasMore: false,
3966
+ pageMessageIds: (Array.isArray(response?.messages) ? response.messages : []).map((message) => message?.id),
3967
+ heldBackIds: newestHeld,
3968
+ });
3969
+ }
3970
+
3971
+ // The jump-to-bottom button on a window in the middle of the history (and an
3972
+ // own send from one): the end of the conversation replaces it, scrolled to
3973
+ // the bottom.
3905
3974
  async function loadCurrentConversationEnd() {
3906
3975
  const currentId = String(currentConvId || '').trim();
3907
3976
  if (!currentId) return;
3908
3977
  const jumpEpoch = getTranscriptJumpEpoch();
3909
- const response = await loadConversation(currentId, { limit: 20 });
3978
+ const load = () => loadConversation(currentId, { limit: 20 });
3979
+ let response = await load();
3910
3980
  if (!response || String(currentConvId || '').trim() !== currentId) return;
3911
3981
  // A jump into the history while it was on its way wins.
3912
3982
  if (getTranscriptJumpEpoch() !== jumpEpoch) return;
3983
+ // Asked once more, not until it has them: a held row deleted since would
3984
+ // never come.
3985
+ if (endAnswerLacksHeldBackRow(response)) {
3986
+ const again = await load();
3987
+ if (String(currentConvId || '').trim() !== currentId || getTranscriptJumpEpoch() !== jumpEpoch) return;
3988
+ if (again) response = again;
3989
+ }
3913
3990
  applyLoadedConversationState(currentId, response, { restoreScroll: false });
3914
3991
  }
3915
3992
 
@@ -3988,6 +4065,7 @@ async function refreshCurrentView() {
3988
4065
 
3989
4066
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3990
4067
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
4068
+ const tasksMark = backgroundTasksSnapshotMark(currentId);
3991
4069
  const { response: r, stale } = await loadKeepingLiveRows(
3992
4070
  () => loadConversation(currentId, { limit: requestLimit }),
3993
4071
  getTranscriptLiveRowEpoch,
@@ -4005,12 +4083,17 @@ async function refreshCurrentView() {
4005
4083
  const reading = preserveBottom || keepTranscript ? null : captureReadingBlock(messagesEl);
4006
4084
  let restoredAt = null;
4007
4085
  if (r) {
4008
- applyLoadedConversationState(currentId, r, {
4086
+ const merge = keepTranscript ? null : reloadMergeFor(currentId, r);
4087
+ applyLoadedConversationState(currentId, merge?.payload || r, {
4009
4088
  restoreScroll: !preserveBottom,
4010
4089
  savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
4011
4090
  followLiveUpdates: preserveBottom && !keepTranscript,
4012
4091
  keepTranscript,
4092
+ mergeFrom: merge?.mergeFrom || null,
4093
+ backgroundTasksAsOf: tasksMark,
4013
4094
  });
4095
+ // As in the live poll: a merge leaves the scrolling to the caller.
4096
+ if (merge && preserveBottom && messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
4014
4097
  // At once, and again below once the cards are back on the page.
4015
4098
  restoreReadingBlock(messagesEl, reading);
4016
4099
  restoredAt = { scrollTop: messagesEl?.scrollTop };
@@ -4254,6 +4337,7 @@ function syncChatTitleControls() {
4254
4337
  if (title) {
4255
4338
  if (!convId || !conversation) {
4256
4339
  delete title.dataset.uiState;
4340
+ delete title.dataset.working;
4257
4341
  } else {
4258
4342
  const pendingByConversation = getPendingQuestionCountsByConversation();
4259
4343
  const pendingCount = Number(pendingByConversation[convId] || 0);
@@ -4264,6 +4348,8 @@ function syncChatTitleControls() {
4264
4348
  hasPendingQuestion: pendingCount > 0,
4265
4349
  });
4266
4350
  title.dataset.uiState = uiState;
4351
+ // Bright green while it works, the darker green while idle, as its row.
4352
+ title.toggleAttribute('data-working', isConversationWorking(conversation));
4267
4353
  }
4268
4354
  }
4269
4355
  if (chatTitleEditingConversationId && chatTitleEditingConversationId !== convId) {
@@ -4519,6 +4605,7 @@ async function initApp() {
4519
4605
  if (!sharedMode) {
4520
4606
  syncSuspendHostVisibility();
4521
4607
  syncScrollOnSendToggle();
4608
+ syncWindowRetentionSlider();
4522
4609
  initHostSuspendUi();
4523
4610
  initUsageLimitUi();
4524
4611
  }
@@ -4848,6 +4935,7 @@ async function initApp() {
4848
4935
  initComposerAttachmentInput();
4849
4936
  initMessageSearchView({ openConversation });
4850
4937
  initSubagentViewer();
4938
+ initTaskConsole();
4851
4939
  initPinnedMessagesView({
4852
4940
  openConversation,
4853
4941
  focusMessage: focusConversationMessageById,
@@ -5007,11 +5095,20 @@ window.resetGrokAllowanceAccounting = resetGrokAllowanceAccounting;
5007
5095
  window.toggleCursorProvider = toggleCursorProvider;
5008
5096
  window.updateShowSuspendHostSetting = updateShowSuspendHostSetting;
5009
5097
  window.updateScrollOnSendSetting = (enabled) => updateScrollOnSendSetting(enabled);
5098
+ window.previewConversationWindowRetention = (position) => previewWindowRetention(position);
5099
+ // A lower cap cuts the kept windows down now; the open conversation's is cut
5100
+ // when it is left.
5101
+ window.updateConversationWindowRetention = (position) => updateWindowRetention(position, {
5102
+ onChange: (cap) => conversationPayloadCache.trimAll(cap, loadConversationReadingAnchor, { except: currentConvId }),
5103
+ });
5010
5104
  window.updateWindowsAutostartMode = updateWindowsAutostartMode;
5011
5105
  window.previewTurnCeilingSetting = previewTurnCeilingSetting;
5012
5106
  window.updateTurnCeilingSetting = updateTurnCeilingSetting;
5013
5107
  window.previewBackgroundTaskTimeoutSetting = previewBackgroundTaskTimeoutSetting;
5014
5108
  window.updateBackgroundTaskTimeoutSetting = updateBackgroundTaskTimeoutSetting;
5109
+ window.previewTaskStallMinutesSetting = previewTaskStallMinutesSetting;
5110
+ window.updateTaskStallMinutesSetting = updateTaskStallMinutesSetting;
5111
+ window.updateRestartResumeSettingFromToggle = updateRestartResumeSettingFromToggle;
5015
5112
  window.updateSubagentRecordRetentionSetting = updateSubagentRecordRetentionSetting;
5016
5113
  window.togglePushOnThisDevice = togglePushOnThisDevice;
5017
5114
  window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
@@ -0,0 +1,21 @@
1
+ // Mirrors shared/claude-background-command-limit.mjs, which the browser cannot
2
+ // import (only server/public is served).
3
+ // shared/claude-background-command-limit.test.mjs asserts the two stay
4
+ // identical.
5
+ //
6
+ // The Claude settings slider shows the default until the relay's own value
7
+ // loads; the authoritative bounds come from GET /api/settings/claude.
8
+
9
+ export const DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES = 120;
10
+ export const CLAUDE_BACKGROUND_COMMAND_LIMIT_MIN_MINUTES = 30;
11
+ export const CLAUDE_BACKGROUND_COMMAND_LIMIT_MAX_MINUTES = 480;
12
+ export const CLAUDE_BACKGROUND_COMMAND_LIMIT_STEP_MINUTES = 30;
13
+
14
+ export function formatClaudeBackgroundCommandLimitLabel(minutes) {
15
+ const numeric = Math.round(Number(minutes));
16
+ const value = Number.isFinite(numeric) && numeric > 0 ? numeric : DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES;
17
+ if (value < 60) return `${value} min`;
18
+ const hours = Math.floor(value / 60);
19
+ const remainder = value % 60;
20
+ return remainder ? `${hours} h ${remainder} min` : `${hours} h`;
21
+ }
@@ -0,0 +1,357 @@
1
+ // The window of messages the page showed for each recently viewed
2
+ // conversation, with the payload it came in, in memory for this page load
3
+ // only. Switching back to one of them draws it at once from here, where the
4
+ // reader left it, older pages included; the fresh answer the switch asks for
5
+ // is merged into it when it arrives. The window follows the page: pages of
6
+ // older or newer history and the socket's live rows are added to it, and it
7
+ // is cut down to the retention cap when the reader leaves the conversation
8
+ // (conversation-window-retention.mjs).
9
+ // The live parts of a payload (a turn in flight, its state, tasks, draft,
10
+ // usage pause) are left out of the copy a read hands out: they are out of
11
+ // date the moment the payload is stored, and the fresh answer and the socket
12
+ // set them.
13
+
14
+ import { mergeTailPage, trimMessageWindow } from './transcript-reading-anchor.mjs';
15
+
16
+ /** Conversations kept; the least recently used goes first. */
17
+ export const CONVERSATION_PAYLOAD_CACHE_LIMIT = 8;
18
+
19
+ // Fields of a conversation payload that describe the moment it was read, not
20
+ // the transcript. A cached copy without them cannot end a turn, start the
21
+ // thinking bubble, or bring back a draft or a task list that has changed.
22
+ const LIVE_PAYLOAD_FIELDS = [
23
+ 'inFlight',
24
+ 'activeTurn',
25
+ 'sessionState',
26
+ 'backgroundTasks',
27
+ 'endedBackgroundTasks',
28
+ 'usageLimitPause',
29
+ 'draftText',
30
+ 'draftAttachments',
31
+ 'draftUpdatedAt',
32
+ 'draftUpdatedByClientId',
33
+ 'previews',
34
+ ];
35
+
36
+ // What the payload said about its own page; a read describes the kept
37
+ // window instead.
38
+ const PAGE_FIELDS = ['pageInfo', 'hasMoreHistory', 'historyCursor', 'historyNewestMessageId'];
39
+
40
+ function normalizeId(conversationId) {
41
+ return String(conversationId || '').trim();
42
+ }
43
+
44
+ function messageIdOf(message) {
45
+ return String(message?.id || '').trim();
46
+ }
47
+
48
+ function clonePayload(value) {
49
+ try {
50
+ return structuredClone(value);
51
+ } catch {
52
+ // A payload is plain JSON; this is only reached by an odd value in it.
53
+ return JSON.parse(JSON.stringify(value));
54
+ }
55
+ }
56
+
57
+ /** True when the payload is a window with newer messages left unloaded. */
58
+ export function payloadHasMoreNewer(response) {
59
+ if (!response || typeof response !== 'object') return false;
60
+ if (typeof response.hasMoreNewer === 'boolean') return response.hasMoreNewer;
61
+ const pageInfo = response.pageInfo && typeof response.pageInfo === 'object' ? response.pageInfo : null;
62
+ return !!pageInfo?.hasMoreNewer;
63
+ }
64
+
65
+ /** True when the payload has older messages left unloaded; read as renderMessages reads it. */
66
+ export function payloadHasMoreOlder(response) {
67
+ if (!response || typeof response !== 'object') return false;
68
+ if (typeof response.hasMoreOlder === 'boolean') return response.hasMoreOlder;
69
+ if (typeof response.hasMoreHistory === 'boolean') return response.hasMoreHistory;
70
+ const pageInfo = response.pageInfo && typeof response.pageInfo === 'object' ? response.pageInfo : null;
71
+ return !!pageInfo?.hasMoreOlder || !!pageInfo?.hasMore;
72
+ }
73
+
74
+ function isCacheable(response) {
75
+ return !!response && typeof response === 'object' && Array.isArray(response.messages);
76
+ }
77
+
78
+ function cursorOf(message, direction) {
79
+ if (!message) return null;
80
+ const id = messageIdOf(message);
81
+ const timestamp = String(message.timestamp || '').trim() || null;
82
+ return direction === 'older'
83
+ ? { beforeMessageId: id, beforeTimestamp: timestamp }
84
+ : { afterMessageId: id, afterTimestamp: timestamp };
85
+ }
86
+
87
+ /** The pageInfo of a kept window, in the relay's shape, so the loaders page on from its edges. */
88
+ export function windowPageInfo(messages, { hasMoreOlder = false, hasMoreNewer = false } = {}) {
89
+ const list = Array.isArray(messages) ? messages : [];
90
+ const first = list[0] || null;
91
+ const last = list[list.length - 1] || null;
92
+ return {
93
+ hasMore: !!hasMoreOlder,
94
+ hasMoreOlder: !!hasMoreOlder,
95
+ hasMoreNewer: !!hasMoreNewer,
96
+ nextCursor: cursorOf(first, 'older'),
97
+ olderCursor: cursorOf(first, 'older'),
98
+ newerCursor: cursorOf(last, 'newer'),
99
+ };
100
+ }
101
+
102
+ function windowPayload(entry) {
103
+ const transcriptOnly = { ...entry.payload };
104
+ for (const field of LIVE_PAYLOAD_FIELDS) delete transcriptOnly[field];
105
+ for (const field of PAGE_FIELDS) delete transcriptOnly[field];
106
+ const flags = { hasMoreOlder: entry.hasMoreOlder, hasMoreNewer: entry.hasMoreNewer };
107
+ return {
108
+ ...transcriptOnly,
109
+ messages: entry.messages,
110
+ ...flags,
111
+ pageInfo: windowPageInfo(entry.messages, flags),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * The copy of a payload the cache hands out, without its live fields, or
117
+ * null when it must not be kept. The live fields are left out before the
118
+ * clone: a turn in flight can carry thousands of thought rows nobody reads.
119
+ */
120
+ export function cacheablePayload(response) {
121
+ if (!isCacheable(response)) return null;
122
+ return clonePayload(windowPayload(entryFor(response)));
123
+ }
124
+
125
+ function entryFor(response) {
126
+ return {
127
+ payload: response,
128
+ messages: response.messages,
129
+ hasMoreOlder: payloadHasMoreOlder(response),
130
+ hasMoreNewer: payloadHasMoreNewer(response),
131
+ };
132
+ }
133
+
134
+ // The messages of `incoming` whose ids the window lacks, in their order.
135
+ function unseen(entry, incoming) {
136
+ const known = new Set(entry.messages.map(messageIdOf).filter(Boolean));
137
+ return (Array.isArray(incoming) ? incoming : []).filter((message) => {
138
+ const id = messageIdOf(message);
139
+ if (!message || !id || known.has(id)) return false;
140
+ known.add(id);
141
+ return true;
142
+ });
143
+ }
144
+
145
+ /**
146
+ * A reload of the end of the conversation on screen (the live poll, a view
147
+ * refresh) merged into the window the page holds, or null when the plain
148
+ * render of the answer is right. The relay caps a page at 100 messages: with
149
+ * more loaded, a render of the answer would cut the page and the kept window
150
+ * down to it. Merged only when both the window and the answer reach the end,
151
+ * the window holds more than the answer, and the two meet (mergeTailPage).
152
+ * `mergeFrom` is the window as it is on the page, `payload` the answer with
153
+ * the merged window as its messages and the window's edges.
154
+ * `windowPredatesPage`: no live row reached the window while the answer was
155
+ * on its way (see mergeTailPage), so a newer window row the answer lacks was
156
+ * deleted, not brought by the socket after the read.
157
+ */
158
+ export function mergeReloadIntoWindow(kept, response, { windowPredatesPage = false } = {}) {
159
+ if (!kept || !Array.isArray(kept.messages) || !isCacheable(response)) return null;
160
+ if (kept.hasMoreNewer || payloadHasMoreNewer(response)) return null;
161
+ if (kept.messages.length <= response.messages.length) return null;
162
+ const merged = mergeTailPage(kept.messages, response.messages, {
163
+ pageHasMoreOlder: payloadHasMoreOlder(response),
164
+ windowPredatesPage,
165
+ });
166
+ if (!merged) return null;
167
+ const flags = { hasMoreOlder: !!kept.hasMoreOlder, hasMoreNewer: false };
168
+ return {
169
+ mergeFrom: kept.messages,
170
+ payload: {
171
+ ...response,
172
+ messages: merged.messages,
173
+ ...flags,
174
+ pageInfo: windowPageInfo(merged.messages, flags),
175
+ },
176
+ };
177
+ }
178
+
179
+ export function createConversationPayloadCache({ limit = CONVERSATION_PAYLOAD_CACHE_LIMIT } = {}) {
180
+ const maxEntries = Math.max(1, Math.trunc(Number(limit) || CONVERSATION_PAYLOAD_CACHE_LIMIT));
181
+ // A Map keeps insertion order: the first key is the least recently used.
182
+ const entries = new Map();
183
+
184
+ function forget(conversationId) {
185
+ const id = normalizeId(conversationId);
186
+ if (!id) return false;
187
+ return entries.delete(id);
188
+ }
189
+
190
+ /**
191
+ * Keeps the payload just applied for the conversation: its messages are the
192
+ * window the page shows. A payload that may not be kept drops the older one
193
+ * too: the page no longer shows it.
194
+ * Only a reference is kept: this runs on every apply, the live poll during
195
+ * a turn included, and a fresh payload from the relay is not changed once
196
+ * it is on the page. The copy is made on read, when a switch needs it; the
197
+ * window's own changes below make new arrays and never touch the payload.
198
+ */
199
+ function remember(conversationId, response) {
200
+ const id = normalizeId(conversationId);
201
+ if (!id) return false;
202
+ entries.delete(id);
203
+ if (!isCacheable(response)) return false;
204
+ entries.set(id, entryFor(response));
205
+ while (entries.size > maxEntries) {
206
+ entries.delete(entries.keys().next().value);
207
+ }
208
+ return true;
209
+ }
210
+
211
+ // Changes to a window do not count as use: a conversation still open is
212
+ // the most recently used anyway, and one read last keeps its place.
213
+ function entryOf(conversationId) {
214
+ return entries.get(normalizeId(conversationId)) || null;
215
+ }
216
+
217
+ /** An older page was put above the window. */
218
+ function addOlder(conversationId, messages, { hasMoreOlder = false } = {}) {
219
+ const entry = entryOf(conversationId);
220
+ if (!entry) return false;
221
+ entry.messages = [...unseen(entry, messages), ...entry.messages];
222
+ entry.hasMoreOlder = !!hasMoreOlder;
223
+ return true;
224
+ }
225
+
226
+ /** A newer page was put below the window. */
227
+ function addNewer(conversationId, messages, { hasMoreNewer = false } = {}) {
228
+ const entry = entryOf(conversationId);
229
+ if (!entry) return false;
230
+ entry.messages = [...entry.messages, ...unseen(entry, messages)];
231
+ entry.hasMoreNewer = !!hasMoreNewer;
232
+ return true;
233
+ }
234
+
235
+ /**
236
+ * A live row from the socket: it replaces the message with its id, or joins
237
+ * the end of a window that is the end of the conversation. A window in the
238
+ * middle of the history leaves it to the newer pages.
239
+ */
240
+ function upsertMessage(conversationId, message) {
241
+ const entry = entryOf(conversationId);
242
+ const messageId = messageIdOf(message);
243
+ if (!entry || !messageId) return false;
244
+ const index = entry.messages.findIndex((item) => messageIdOf(item) === messageId);
245
+ if (index >= 0) {
246
+ entry.messages = entry.messages.map((item, at) => (at === index ? message : item));
247
+ return true;
248
+ }
249
+ if (entry.hasMoreNewer) return false;
250
+ entry.messages = [...entry.messages, message];
251
+ return true;
252
+ }
253
+
254
+ /** The window no longer meets the end of the conversation (more was written than one page holds). */
255
+ function detachFromTail(conversationId) {
256
+ const entry = entryOf(conversationId);
257
+ if (!entry) return false;
258
+ entry.hasMoreNewer = true;
259
+ return true;
260
+ }
261
+
262
+ /**
263
+ * Cuts the window down to `cap` messages around the reader's place
264
+ * (trimMessageWindow). True when rows were dropped.
265
+ */
266
+ function trim(conversationId, { cap = Infinity, anchorMessageId = '', atTail = false } = {}) {
267
+ const entry = entryOf(conversationId);
268
+ if (!entry) return false;
269
+ const next = trimMessageWindow(entry.messages, {
270
+ cap,
271
+ anchorMessageId,
272
+ atTail,
273
+ hasMoreOlder: entry.hasMoreOlder,
274
+ hasMoreNewer: entry.hasMoreNewer,
275
+ });
276
+ if (!next.trimmed) return false;
277
+ entry.messages = next.messages;
278
+ entry.hasMoreOlder = next.hasMoreOlder;
279
+ entry.hasMoreNewer = next.hasMoreNewer;
280
+ return true;
281
+ }
282
+
283
+ /** `anchorOf(id)` gives the reader's place in each kept conversation. */
284
+ function trimAll(cap, anchorOf = () => null, { except = '' } = {}) {
285
+ const skip = normalizeId(except);
286
+ let trimmed = 0;
287
+ for (const id of [...entries.keys()]) {
288
+ if (id === skip) continue;
289
+ const anchor = anchorOf(id) || null;
290
+ if (trim(id, { cap, anchorMessageId: anchor?.messageId || '', atTail: anchor?.atTail === true })) trimmed += 1;
291
+ }
292
+ return trimmed;
293
+ }
294
+
295
+ /**
296
+ * A copy of the kept window without its live fields (the caller may change
297
+ * it), or null. Its messages are the window, its pageInfo and flags
298
+ * describe the window's edges.
299
+ */
300
+ function read(conversationId) {
301
+ const id = normalizeId(conversationId);
302
+ if (!id || !entries.has(id)) return null;
303
+ const entry = entries.get(id);
304
+ entries.delete(id);
305
+ entries.set(id, entry);
306
+ return clonePayload(windowPayload(entry));
307
+ }
308
+
309
+ function has(conversationId) {
310
+ return entries.has(normalizeId(conversationId));
311
+ }
312
+
313
+ /**
314
+ * The window as it is, uncopied, without counting as use: for a reload of
315
+ * the conversation on screen, which reads it on every poll. The caller must
316
+ * not change it (the window's own changes make new arrays, so a held
317
+ * reference stays as it was read).
318
+ */
319
+ function peekWindow(conversationId) {
320
+ const entry = entryOf(conversationId);
321
+ if (!entry) return null;
322
+ return { messages: entry.messages, hasMoreOlder: entry.hasMoreOlder, hasMoreNewer: entry.hasMoreNewer };
323
+ }
324
+
325
+ /** How many messages the window holds (0 when none is kept). */
326
+ function windowSize(conversationId) {
327
+ return entryOf(conversationId)?.messages.length || 0;
328
+ }
329
+
330
+ function clear() {
331
+ entries.clear();
332
+ }
333
+
334
+ return {
335
+ remember,
336
+ addOlder,
337
+ addNewer,
338
+ upsertMessage,
339
+ detachFromTail,
340
+ trim,
341
+ trimAll,
342
+ read,
343
+ peekWindow,
344
+ forget,
345
+ has,
346
+ windowSize,
347
+ clear,
348
+ keys: () => [...entries.keys()],
349
+ get size() {
350
+ return entries.size;
351
+ },
352
+ };
353
+ }
354
+
355
+ // The page's one cache: the conversation views write and read it, the socket
356
+ // handlers drop entries the relay has changed.
357
+ export const conversationPayloadCache = createConversationPayloadCache();
@@ -89,11 +89,14 @@ function normalizeEffortList(efforts = []) {
89
89
  * 3. else the lowest supported rung ABOVE it, ignoring 'none'
90
90
  * (a remembered 'minimal' on a model whose floor is 'low' → low);
91
91
  * 4. else the next candidate.
92
- * With no candidate usable: the lowest non-'none' rung the model offers, or
93
- * 'none' when that is all there is. Effort names off the ladder (image
94
- * quality values, unknown providers) only ever match exactly.
92
+ * With no candidate usable: the lowest non-'none' rung the model offers (by
93
+ * rung, whatever the list order), or 'none' when that is all there is; a
94
+ * list with names off the ladder keeps its own order there. A caller with a
95
+ * default of its own passes `fallback`, returned instead in that case.
96
+ * Effort names off the ladder (image quality values, unknown providers) only
97
+ * ever match exactly.
95
98
  */
96
- export function nearestSupportedReasoningEffort(candidates = [], supportedEfforts = []) {
99
+ export function nearestSupportedReasoningEffort(candidates = [], supportedEfforts = [], { fallback } = {}) {
97
100
  const options = normalizeEffortList(supportedEfforts);
98
101
  if (!options.length) return '';
99
102
  const rungOf = (effort) => REASONING_EFFORT_LADDER.indexOf(effort);
@@ -109,6 +112,9 @@ export function nearestSupportedReasoningEffort(candidates = [], supportedEffort
109
112
  const above = ranked.find((option) => rungOf(option) > rung && option !== 'ultracode');
110
113
  if (above) return above;
111
114
  }
115
+ if (fallback !== undefined) return fallback;
116
+ const lowest = ranked.find((option) => option !== 'ultracode');
117
+ if (lowest && options.every((option) => rungOf(option) >= 0)) return lowest;
112
118
  return options.find((option) => option !== 'none') || options[0];
113
119
  }
114
120
 
@@ -2,7 +2,7 @@
2
2
  // `backgroundTaskCount`) kept in step with the live events, for every row
3
3
  // and not only the open one. The list fetch sets them; between fetches the
4
4
  // `message_status` and `background_tasks` events are the only word on what
5
- // a session is doing, and the sidebar's dots, a group head's pill and its
5
+ // a session is doing, and the sidebar's title colour, a group head's pill and its
6
6
  // fold read these fields. DOM-free.
7
7
 
8
8
  const TERMINAL_STATUSES = new Set(['done', 'failed', 'dropped', 'cancelled']);