@oar-sh/oar 0.9.10 → 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 (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  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 +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -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 +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -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/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -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();
@@ -0,0 +1,81 @@
1
+ // A conversation record's turn fields (`activeTurn`, `sessionState`,
2
+ // `backgroundTaskCount`) kept in step with the live events, for every row
3
+ // and not only the open one. The list fetch sets them; between fetches the
4
+ // `message_status` and `background_tasks` events are the only word on what
5
+ // a session is doing, and the sidebar's title colour, a group head's pill and its
6
+ // fold read these fields. DOM-free.
7
+
8
+ const TERMINAL_STATUSES = new Set(['done', 'failed', 'dropped', 'cancelled']);
9
+ const QUEUED_STATUSES = new Set(['pending', 'parked']);
10
+
11
+ function runningTaskCount(tasks) {
12
+ return (Array.isArray(tasks) ? tasks : []).filter((task) => {
13
+ const status = String(task?.status || 'running').trim().toLowerCase();
14
+ return !status || status === 'running';
15
+ }).length;
16
+ }
17
+
18
+ function settleState(conversation) {
19
+ conversation.sessionState = Number(conversation.backgroundTaskCount || 0) > 0 ? 'background_only' : 'idle';
20
+ }
21
+
22
+ /**
23
+ * A `message_status` for the conversation. Processing: a turn runs. Pending
24
+ * or parked: a turn is queued (the relay counts it as active). A terminal
25
+ * status: the turn is over; background work, if any was reported, carries
26
+ * the state. Another message still queued is learnt from its own status or
27
+ * the next list fetch. Returns true when a field changed.
28
+ */
29
+ export function applyTurnStatusToConversation(conversation, status) {
30
+ if (!conversation || typeof conversation !== 'object') return false;
31
+ const normalized = String(status || '').trim().toLowerCase();
32
+ const before = `${conversation.activeTurn}|${conversation.sessionState}`;
33
+ if (normalized === 'processing') {
34
+ conversation.activeTurn = true;
35
+ conversation.sessionState = 'running_turn';
36
+ } else if (QUEUED_STATUSES.has(normalized)) {
37
+ conversation.activeTurn = true;
38
+ if (conversation.sessionState !== 'running_turn') settleState(conversation);
39
+ } else if (TERMINAL_STATUSES.has(normalized)) {
40
+ conversation.activeTurn = false;
41
+ settleState(conversation);
42
+ } else {
43
+ return false;
44
+ }
45
+ return before !== `${conversation.activeTurn}|${conversation.sessionState}`;
46
+ }
47
+
48
+ /**
49
+ * What a fetched payload says (a list row, or the single conversation): the
50
+ * relay's own view of the session is the truth whenever one arrives; the
51
+ * events only move it forward until the next. `sessionState` names the
52
+ * state, `sessionTasks` (list) or `backgroundTasks` (single) the live set;
53
+ * a `background_only` state without a set still counts as one task.
54
+ * Returns true when a field changed.
55
+ */
56
+ export function applyFetchedTurnStateToConversation(conversation, payload) {
57
+ if (!conversation || typeof conversation !== 'object' || !payload || typeof payload !== 'object') return false;
58
+ const before = `${conversation.activeTurn}|${conversation.sessionState}|${conversation.backgroundTaskCount}`;
59
+ if (typeof payload.activeTurn === 'boolean') conversation.activeTurn = payload.activeTurn;
60
+ const state = String(payload.sessionState || '').trim().toLowerCase();
61
+ if (state) {
62
+ conversation.sessionState = state;
63
+ const tasks = Array.isArray(payload.sessionTasks) ? payload.sessionTasks
64
+ : (Array.isArray(payload.backgroundTasks) ? payload.backgroundTasks : null);
65
+ conversation.backgroundTaskCount = tasks ? runningTaskCount(tasks) : (state === 'background_only' ? 1 : 0);
66
+ } else if (Array.isArray(payload.backgroundTasks)) {
67
+ conversation.backgroundTaskCount = runningTaskCount(payload.backgroundTasks);
68
+ if (conversation.sessionState !== 'running_turn' && conversation.activeTurn !== true) settleState(conversation);
69
+ }
70
+ return before !== `${conversation.activeTurn}|${conversation.sessionState}|${conversation.backgroundTaskCount}`;
71
+ }
72
+
73
+ /** A `background_tasks` set for the conversation (replace semantics). Returns true when a field changed. */
74
+ export function applyBackgroundTasksToConversation(conversation, tasks) {
75
+ if (!conversation || typeof conversation !== 'object') return false;
76
+ const before = `${conversation.backgroundTaskCount}|${conversation.sessionState}`;
77
+ conversation.backgroundTaskCount = runningTaskCount(tasks);
78
+ if (conversation.sessionState !== 'running_turn' && conversation.activeTurn !== true) settleState(conversation);
79
+ else if (conversation.sessionState !== 'running_turn') settleState(conversation);
80
+ return before !== `${conversation.backgroundTaskCount}|${conversation.sessionState}`;
81
+ }