@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
@@ -0,0 +1,256 @@
1
+ // Where a reader is in a conversation, kept across a switch to another one
2
+ // and across a page reload, and the window of loaded messages kept with it.
3
+ // No DOM here: the caller measures the rows and applies what is decided.
4
+ //
5
+ // A pixel scrollTop does not survive either trip. The rows on the page after
6
+ // a switch back are another set (the oldest dropped by the page-size cap,
7
+ // new ones written meanwhile at the end, a window centred on the place read
8
+ // after a reload), so the same pixel count lands on another message. The
9
+ // position is therefore a message and how far its top sits from the pane's
10
+ // top, or "at the end".
11
+ //
12
+ // Finer than the reading block of transcript-viewport-anchor.mjs, coarser
13
+ // than it too: that one is for rows rebuilt under the reader with other
14
+ // heights, and holds the text read. This one goes to localStorage, so it
15
+ // holds an id and an offset only.
16
+
17
+ import { sortConversationMessages } from './thread-order.mjs';
18
+
19
+ function messageIdOf(message) {
20
+ return String(message?.id || '').trim();
21
+ }
22
+
23
+ /**
24
+ * The index of the first row whose bottom edge is below the pane's top (the
25
+ * row being read), or -1. `bottomAt(i)` is the row's bottom in the pane's
26
+ * coordinates; rows are in page order, so their bottoms only grow and a
27
+ * binary search reads a handful of them on a long transcript.
28
+ */
29
+ export function findReadingRowIndex(rowCount, bottomAt, paneTop) {
30
+ const count = Math.max(0, Math.trunc(Number(rowCount) || 0));
31
+ let low = 0;
32
+ let high = count;
33
+ const edge = Number(paneTop) + 1;
34
+ while (low < high) {
35
+ const mid = (low + high) >> 1;
36
+ if (Number(bottomAt(mid)) > edge) high = mid;
37
+ else low = mid + 1;
38
+ }
39
+ return low < count ? low : -1;
40
+ }
41
+
42
+ /**
43
+ * The reader's position from the pane and its rows. `rowAt(i)` gives
44
+ * `{ messageId, top, bottom }`; `atTail` is true when the reader is at the
45
+ * end of a window that is the end of the conversation (they follow live
46
+ * rows). Null when no row is on the page.
47
+ */
48
+ export function captureReadingAnchor({ rowCount, rowAt, paneTop, atTail = false }) {
49
+ const index = findReadingRowIndex(rowCount, (i) => rowAt(i)?.bottom, paneTop);
50
+ if (index < 0) return null;
51
+ const row = rowAt(index);
52
+ const messageId = String(row?.messageId || '').trim();
53
+ if (!messageId) return null;
54
+ return {
55
+ messageId,
56
+ offsetPx: Math.round(Number(row.top) - Number(paneTop)),
57
+ atTail: atTail === true,
58
+ };
59
+ }
60
+
61
+ /** How far to scroll so the anchored row's top is at its saved offset again. */
62
+ export function readingAnchorScrollDelta({ rowTop, paneTop, offsetPx }) {
63
+ return Number(rowTop) - Number(paneTop) - Number(offsetPx || 0);
64
+ }
65
+
66
+ /**
67
+ * Where a row's top sits in the content, whatever the scroll: its distance
68
+ * from the pane's top plus how far the pane is scrolled. It changes only when
69
+ * something above the row changes height.
70
+ */
71
+ export function readingAnchorContentTop({ rowTop, paneTop, scrollTop }) {
72
+ return Number(rowTop) - Number(paneTop) + Number(scrollTop);
73
+ }
74
+
75
+ /**
76
+ * One look at the anchored row while the images above it are still loading
77
+ * (they grow from nothing to their height after the restore, and a browser
78
+ * without scroll anchoring lets that push the row down). `seen` is the row's
79
+ * content position and the pane's scrollTop as of the last look.
80
+ *
81
+ * The total height is no guide: growth below the row (a reply streaming in
82
+ * at the end) changes it too, and a scroll in the same frame (a jump to the
83
+ * end, a key, a drag of the scrollbar) would be taken back. So only the
84
+ * row's own content position counts, and a scroll is told apart from it:
85
+ * - 'stay': nothing above the row changed, or the browser's own scroll
86
+ * anchoring already scrolled by exactly what grew above it.
87
+ * - 'correct': something above the row grew by `delta` and nothing else
88
+ * scrolled: scroll by `delta`.
89
+ * - 'release': scrollTop changed for any other reason. That is the reader
90
+ * (or a jump, a send) and the place is theirs from now on, even when
91
+ * something above grew in the same frame.
92
+ * `rowContentTop` is the position to remember for the next look.
93
+ */
94
+ export function readingAnchorHoldStep({ rowTop, paneTop, scrollTop, seen, tolerancePx = 1 }) {
95
+ const rowContentTop = readingAnchorContentTop({ rowTop, paneTop, scrollTop });
96
+ const grewAbove = rowContentTop - Number(seen?.rowContentTop);
97
+ const scrolled = Number(scrollTop) - Number(seen?.scrollTop);
98
+ if (!Number.isFinite(grewAbove) || !Number.isFinite(scrolled)) return { action: 'release', delta: 0, rowContentTop };
99
+ const grew = Math.abs(grewAbove) > tolerancePx;
100
+ if (Math.abs(scrolled) > tolerancePx) {
101
+ if (grew && Math.abs(scrolled - grewAbove) <= tolerancePx) return { action: 'stay', delta: 0, rowContentTop };
102
+ return { action: 'release', delta: 0, rowContentTop };
103
+ }
104
+ if (grew) return { action: 'correct', delta: grewAbove, rowContentTop };
105
+ return { action: 'stay', delta: 0, rowContentTop };
106
+ }
107
+
108
+ /** An anchor read back from storage, or null when it is not one. */
109
+ export function normalizeReadingAnchor(value) {
110
+ if (!value || typeof value !== 'object') return null;
111
+ const messageId = String(value.messageId || '').trim();
112
+ const atTail = value.atTail === true;
113
+ if (!messageId && !atTail) return null;
114
+ const offset = Number(value.offsetPx);
115
+ return {
116
+ messageId: messageId || null,
117
+ offsetPx: Number.isFinite(offset) ? Math.round(offset) : 0,
118
+ atTail,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * The part of a loaded window kept for a switch back: at most `cap` messages
124
+ * (no cap when it is not a positive finite number). A reader at the end keeps
125
+ * the newest; anyone else keeps the messages around the one they read, half
126
+ * above and half below, so either direction can be read on without a fetch.
127
+ * Rows cut off on a side mean there is more on that side to load.
128
+ */
129
+ export function trimMessageWindow(messages, {
130
+ cap = Infinity,
131
+ anchorMessageId = '',
132
+ atTail = false,
133
+ hasMoreOlder = false,
134
+ hasMoreNewer = false,
135
+ } = {}) {
136
+ const list = Array.isArray(messages) ? messages : [];
137
+ const limit = Number(cap);
138
+ if (!(limit > 0) || !Number.isFinite(limit) || list.length <= limit) {
139
+ return { messages: list, hasMoreOlder: !!hasMoreOlder, hasMoreNewer: !!hasMoreNewer, trimmed: false };
140
+ }
141
+ const keep = Math.trunc(limit);
142
+ const ordered = sortConversationMessages(list);
143
+ const anchorId = String(anchorMessageId || '').trim();
144
+ const anchorIndex = !atTail && anchorId ? ordered.findIndex((message) => messageIdOf(message) === anchorId) : -1;
145
+ let start;
146
+ if (anchorIndex < 0) {
147
+ start = ordered.length - keep;
148
+ } else {
149
+ start = Math.max(0, anchorIndex - Math.floor(keep / 2));
150
+ start = Math.min(start, ordered.length - keep);
151
+ }
152
+ const end = start + keep;
153
+ return {
154
+ messages: ordered.slice(start, end),
155
+ hasMoreOlder: !!hasMoreOlder || start > 0,
156
+ hasMoreNewer: !!hasMoreNewer || end < ordered.length,
157
+ trimmed: true,
158
+ };
159
+ }
160
+
161
+ function timestampMsOf(message) {
162
+ const ms = Date.parse(message?.timestamp || 0);
163
+ return Number.isFinite(ms) ? ms : 0;
164
+ }
165
+
166
+ /**
167
+ * The relay's history order: timestamp in ms, then id by localeCompare (as
168
+ * conversation-history-page.mjs pages it). The page's range is measured in
169
+ * this order, not in the thread order the transcript is drawn in: the relay
170
+ * cuts its pages by it, so a row inside the page's range in this order is a
171
+ * row the relay would have put on the page.
172
+ */
173
+ export function compareHistoryOrder(a, b) {
174
+ const aMs = timestampMsOf(a);
175
+ const bMs = timestampMsOf(b);
176
+ if (aMs !== bMs) return aMs - bMs;
177
+ return messageIdOf(a).localeCompare(messageIdOf(b));
178
+ }
179
+
180
+ /**
181
+ * The newest page of a conversation merged into a kept window that reaches
182
+ * the end. A message of the page replaces the one with its id (its text may
183
+ * have changed since: a reply that was still streaming), the others join in
184
+ * conversation order. Null when the page does not meet the window: more rows
185
+ * were written than the page holds, and the rows between are unknown.
186
+ * `changed` and `added` are the ids the page brought that differ from, or
187
+ * are missing in, the window (`sameMessage` decides what differs).
188
+ * The page is the truth for its own range, from its oldest row to its
189
+ * newest in the relay's order: a window row inside it that the page lacks
190
+ * was deleted while the reader was away and is dropped (`removed`). Rows
191
+ * older than the page are kept, the page says nothing about them; so are
192
+ * rows newer than it, which the socket may have brought after the page was
193
+ * read. With `windowPredatesPage` every window row was there before the page
194
+ * was asked for (the caller merged a window read before the request, or no
195
+ * live row arrived while it was on its way): the page, which ends at the
196
+ * conversation's end, then is the truth up to that end, and a newer window
197
+ * row it lacks was deleted too (the relay's own follow-up dropped by a
198
+ * Cancel, say: the newest row of the conversation).
199
+ */
200
+ export function mergeTailPage(windowMessages, pageMessages, {
201
+ pageHasMoreOlder = false,
202
+ windowPredatesPage = false,
203
+ sameMessage = (a, b) => JSON.stringify(a) === JSON.stringify(b),
204
+ } = {}) {
205
+ const kept = Array.isArray(windowMessages) ? windowMessages : [];
206
+ const page = Array.isArray(pageMessages) ? pageMessages : [];
207
+ const keptById = new Map();
208
+ for (const message of kept) {
209
+ const id = messageIdOf(message);
210
+ if (id) keptById.set(id, message);
211
+ }
212
+ const meets = !pageHasMoreOlder || page.some((message) => keptById.has(messageIdOf(message)));
213
+ if (!meets) return null;
214
+ const changed = [];
215
+ const added = [];
216
+ const pageById = new Map();
217
+ for (const message of page) {
218
+ const id = messageIdOf(message);
219
+ if (!id) continue;
220
+ pageById.set(id, message);
221
+ if (!keptById.has(id)) added.push(id);
222
+ else if (!sameMessage(keptById.get(id), message)) changed.push(id);
223
+ }
224
+ let oldest = null;
225
+ let newest = null;
226
+ for (const message of pageById.values()) {
227
+ if (!oldest || compareHistoryOrder(message, oldest) < 0) oldest = message;
228
+ if (!newest || compareHistoryOrder(message, newest) > 0) newest = message;
229
+ }
230
+ // An empty page that is the whole conversation (meets without a shared
231
+ // row) holds nothing: every window row is gone, when the window predates it.
232
+ const insidePage = (message) => {
233
+ if (!oldest) return windowPredatesPage && !pageHasMoreOlder;
234
+ if (compareHistoryOrder(message, oldest) < 0) return false;
235
+ return windowPredatesPage || compareHistoryOrder(message, newest) <= 0;
236
+ };
237
+ const removed = [];
238
+ const merged = [];
239
+ for (const message of kept) {
240
+ const id = messageIdOf(message);
241
+ if (pageById.has(id)) {
242
+ merged.push(pageById.get(id));
243
+ } else if (id && insidePage(message)) {
244
+ removed.push(id);
245
+ } else {
246
+ merged.push(message);
247
+ }
248
+ }
249
+ for (const id of added) merged.push(pageById.get(id));
250
+ return {
251
+ messages: added.length ? sortConversationMessages(merged) : merged,
252
+ changed,
253
+ added,
254
+ removed,
255
+ };
256
+ }
@@ -12,7 +12,8 @@ export const LIVE_ROW_RELOAD_ATTEMPTS = 3;
12
12
  /**
13
13
  * `stale` is true when rows still arrived during the last attempt: the page
14
14
  * holds rows the answer lacks, so the caller leaves the transcript alone and
15
- * the next reload asks again.
15
+ * the next reload asks again (unless the page holds an older copy, see
16
+ * shouldKeepTranscriptOnReload).
16
17
  */
17
18
  export async function loadKeepingLiveRows(load, getLiveRowEpoch, { attempts = LIVE_ROW_RELOAD_ATTEMPTS } = {}) {
18
19
  let response = null;
@@ -23,3 +24,18 @@ export async function loadKeepingLiveRows(load, getLiveRowEpoch, { attempts = LI
23
24
  }
24
25
  return { response, stale: true };
25
26
  }
27
+
28
+ /**
29
+ * Whether a reload leaves the transcript as it is. A jump into the history
30
+ * (`jumped`) always wins. A `stale` answer is kept off the page only when
31
+ * the page shows the live transcript (the live poll): it then holds every
32
+ * row the answer has and the socket's newer ones. After a kept copy was
33
+ * drawn on a switch (`showsLiveTranscript` false) the page holds an old
34
+ * copy instead, and the rows stored while the reader was away exist only
35
+ * in the answer: the answer is the closer one, and the next reload brings
36
+ * whatever socket row it still lacks.
37
+ */
38
+ export function shouldKeepTranscriptOnReload({ stale = false, jumped = false, showsLiveTranscript = true } = {}) {
39
+ if (jumped) return true;
40
+ return !!stale && !!showsLiveTranscript;
41
+ }
@@ -127,6 +127,24 @@ function isReadable(block, rect) {
127
127
  return typeof block.closest !== 'function' || !block.closest('details:not([open]) > :not(summary)');
128
128
  }
129
129
 
130
+ // Where restoreReadingBlock last put a block, per scroller. scrollTop takes
131
+ // whole pixels, so the block lands up to half a pixel off the offset it was
132
+ // asked for. Rows are rebuilt twice in a row when a turn ends (the reply
133
+ // takes the live bubble's place, then the reload after the turn), and each
134
+ // capture would take the last landing as the truth: the halves added up to
135
+ // more than a pixel.
136
+ const lastRestored = new WeakMap();
137
+
138
+ // The offset the block was put at, while the reader is still there: the
139
+ // same block, scrollTop as it was left, and the block no further off than
140
+ // the rounding.
141
+ function intendedOffset(el, tag, text, measured) {
142
+ const restored = lastRestored.get(el);
143
+ if (!restored || restored.tag !== tag || restored.text !== text) return measured;
144
+ if (Number(el.scrollTop || 0) !== restored.scrollTop) return measured;
145
+ return Math.abs(measured - restored.offset) <= 0.5 ? restored.offset : measured;
146
+ }
147
+
130
148
  /**
131
149
  * The block of text the reader is at: the first one that starts inside the
132
150
  * viewport, else the one that reaches into it from above (a code block
@@ -167,7 +185,7 @@ export function captureReadingBlock(el, { scope = null } = {}) {
167
185
  const tag = String(block.tagName || '').toLowerCase();
168
186
  const text = blockText(block);
169
187
  const height = Math.max(0, rect.bottom - rect.top);
170
- const offset = rect.top - view.top;
188
+ const offset = intendedOffset(el, tag, text, rect.top - view.top);
171
189
  return {
172
190
  messageId: String(foundScope?.dataset?.messageId || '').trim() || null,
173
191
  // Read in the live bubble: the reply that replaces it is another row.
@@ -220,6 +238,7 @@ export function restoreReadingBlock(el, anchor, { scope = null } = {}) {
220
238
  : Number(anchor.offsetFraction) * Math.max(0, rect.bottom - rect.top);
221
239
  const delta = rect.top - view.top - target;
222
240
  if (Math.abs(delta) > 0.5) el.scrollTop = Number(el.scrollTop || 0) + delta;
241
+ lastRestored.set(el, { tag: anchor.tag, text: anchor.text, offset: target, scrollTop: Number(el.scrollTop || 0) });
223
242
  return true;
224
243
  }
225
244