@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,365 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Live background-task sets, published by the session workers (REPLACE
5
+ * semantics, mirroring the SDK's background_tasks_changed signal) and keyed
6
+ * by the id the worker posts, its SDK session id.
7
+ *
8
+ * Memory only, and a relay restart starts it empty while the workers and
9
+ * their tasks keep running. The worker is the source of truth: its heartbeat
10
+ * carries a digest of its task ids, the relay compares it with the set held
11
+ * here and asks for the full set when the two differ (a restart, a lost
12
+ * post). `reported` remembers which workers this runtime has heard from
13
+ * about their tasks at all: until then "no set" means "not told yet", not
14
+ * "no tasks".
15
+ *
16
+ * Ended tasks: a task that leaves the set while it names an `outputPath` (a
17
+ * shell or Monitor task with a console) is kept as an ended row for an hour,
18
+ * at most 20 per key, so the panel can still show how it ended and open its
19
+ * output. A settle post (`settle`) says how; one that arrives before the set
20
+ * drops the task is kept until it does. Memory only, like the sets.
21
+ *
22
+ * Health: the health poller's last reading per live task (`setHealth`), served
23
+ * with the task rows (`withHealth`) and carried onto the ended row, where the
24
+ * poller's reading after the end replaces it (`setEndedHealth`).
25
+ */
26
+
27
+ import { backgroundTaskDigest, sameBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
28
+ import { TASK_OUTPUT_LIMITS } from '../../shared/task-output-text.mjs';
29
+
30
+ function keyOf(value) {
31
+ return String(value || '').trim();
32
+ }
33
+
34
+ const ENDED_STATUSES = new Set(['completed', 'failed', 'stopped', 'unknown']);
35
+
36
+ /** A worker's word for how a task ended, as one of completed, failed, stopped, unknown. */
37
+ export function normalizeEndedTaskStatus(status, exitCode = null) {
38
+ const text = String(status || '').trim().toLowerCase();
39
+ if (ENDED_STATUSES.has(text)) return text;
40
+ if (['complete', 'done', 'success', 'succeeded', 'ok', 'exited'].includes(text)) {
41
+ return Number.isInteger(exitCode) && exitCode !== 0 ? 'failed' : 'completed';
42
+ }
43
+ if (['error', 'errored', 'failure'].includes(text)) return 'failed';
44
+ if (['killed', 'cancelled', 'canceled', 'aborted', 'interrupted', 'timeout', 'timed_out'].includes(text)) return 'stopped';
45
+ if (Number.isInteger(exitCode)) return exitCode === 0 ? 'completed' : 'failed';
46
+ return 'unknown';
47
+ }
48
+
49
+ function exitCodeOf(value) {
50
+ if (value === null || value === undefined || value === '') return null;
51
+ const number = Number(value);
52
+ return Number.isInteger(number) ? number : null;
53
+ }
54
+
55
+ export function createBackgroundTaskStore({
56
+ now = () => Date.now(),
57
+ endedKeepMs = TASK_OUTPUT_LIMITS.endedKeepMs,
58
+ endedMax = TASK_OUTPUT_LIMITS.endedMax,
59
+ } = {}) {
60
+ const sets = new Map();
61
+ // key → Map(taskId → ended row), newest last.
62
+ const endedRows = new Map();
63
+ // key → Map(taskId → { status, exitCode, outputPath, at }): how a task
64
+ // ended, told before the set dropped it.
65
+ const settledAhead = new Map();
66
+ // key → Map(taskId → the health poller's last reading).
67
+ const healthByKey = new Map();
68
+ const reported = new Set();
69
+ // key → the order of the newest set applied, `{ epoch, seq }` as the
70
+ // worker numbered it. `epoch` names the worker process; it is compared
71
+ // for equality only, never as a time.
72
+ const applied = new Map();
73
+ // key → the pid of the worker process whose heartbeat came last: the
74
+ // process that holds the session, by the worker's own word.
75
+ const holders = new Map();
76
+ // Keys whose pages were told of the placeholder for unreported work.
77
+ const announced = new Set();
78
+
79
+ return {
80
+ sets,
81
+ replace(conversationId, tasks) {
82
+ const id = keyOf(conversationId);
83
+ if (!id) return;
84
+ reported.add(id);
85
+ const next = Array.isArray(tasks) ? tasks : [];
86
+ const nextIds = new Set(next.map((task) => keyOf(task?.taskId)).filter(Boolean));
87
+ // Tasks that left the set with a console become ended rows.
88
+ for (const task of sets.get(id)?.tasks || []) {
89
+ const taskId = keyOf(task?.taskId);
90
+ if (!taskId || nextIds.has(taskId) || !task?.outputPath) continue;
91
+ const ahead = settledAhead.get(id)?.get(taskId) || null;
92
+ addEnded(id, {
93
+ ...task,
94
+ ...(ahead?.steps !== undefined ? { steps: ahead.steps } : {}),
95
+ status: ahead?.status || 'unknown',
96
+ exitCode: ahead ? ahead.exitCode : (task.exitCode ?? null),
97
+ health: healthByKey.get(id)?.get(taskId) || task.health || undefined,
98
+ });
99
+ }
100
+ // A task back in the set (a resync after a restart) is not ended.
101
+ for (const taskId of nextIds) {
102
+ endedRows.get(id)?.delete(taskId);
103
+ settledAhead.get(id)?.delete(taskId);
104
+ }
105
+ const health = healthByKey.get(id);
106
+ if (health) {
107
+ for (const taskId of [...health.keys()]) if (!nextIds.has(taskId)) health.delete(taskId);
108
+ if (!health.size) healthByKey.delete(id);
109
+ }
110
+ if (next.length === 0) {
111
+ sets.delete(id);
112
+ return;
113
+ }
114
+ sets.set(id, { tasks: next, updatedAt: now() });
115
+ },
116
+ /**
117
+ * How a task ended (`/api/background-task-settled`): updates its ended
118
+ * row, or is kept for the moment the set drops it, or, for a task this
119
+ * runtime never saw live that names its output, becomes an ended row of
120
+ * its own. Returns the ended row when there is one.
121
+ */
122
+ settle(conversationId, taskId, { status, exitCode, outputPath, description, taskType, startedAt, outputKind, steps } = {}) {
123
+ const id = keyOf(conversationId);
124
+ const task = keyOf(taskId);
125
+ if (!id || !task) return null;
126
+ prune(id);
127
+ const code = exitCodeOf(exitCode);
128
+ const normalized = normalizeEndedTaskStatus(status, code);
129
+ const stepCount = Number.isInteger(steps) && steps >= 0 ? steps : undefined;
130
+ const existing = endedRows.get(id)?.get(task);
131
+ if (existing) {
132
+ existing.status = normalized;
133
+ existing.exitCode = code;
134
+ if (!existing.outputPath && outputPath) {
135
+ existing.outputPath = outputPath;
136
+ if (existing.outputKind !== 'file' && existing.outputKind !== 'spool') existing.outputKind = outputKind === 'spool' ? 'spool' : 'file';
137
+ }
138
+ if (stepCount !== undefined) existing.steps = stepCount;
139
+ return existing;
140
+ }
141
+ const live = (sets.get(id)?.tasks || []).find((entry) => keyOf(entry?.taskId) === task);
142
+ if (live) {
143
+ let ahead = settledAhead.get(id);
144
+ if (!ahead) settledAhead.set(id, ahead = new Map());
145
+ ahead.set(task, { status: normalized, exitCode: code, steps: stepCount, at: now() });
146
+ // The first path a task names wins. A row that had none was read
147
+ // from a snapshot (or not at all): from now on it is the file.
148
+ if (outputPath && !live.outputPath) {
149
+ live.outputPath = outputPath;
150
+ if (live.outputKind !== 'file' && live.outputKind !== 'spool') live.outputKind = outputKind === 'spool' ? 'spool' : 'file';
151
+ }
152
+ return null;
153
+ }
154
+ if (!outputPath) return null;
155
+ return addEnded(id, {
156
+ taskId: task,
157
+ taskType: keyOf(taskType) || 'local_bash',
158
+ description: String(description || '').trim().slice(0, 500),
159
+ startedAt: Number(startedAt) || null,
160
+ outputPath,
161
+ outputKind: outputKind || 'file',
162
+ stoppable: false,
163
+ status: normalized,
164
+ exitCode: code,
165
+ ...(stepCount !== undefined ? { steps: stepCount } : {}),
166
+ });
167
+ },
168
+ /** The ended rows of these keys, newest first. */
169
+ ended(...keys) {
170
+ const rows = [];
171
+ for (const id of new Set(keys.map(keyOf).filter(Boolean))) {
172
+ prune(id);
173
+ rows.push(...(endedRows.get(id)?.values() || []));
174
+ }
175
+ return rows.sort((a, b) => b.endedAt - a.endedAt).map((row) => ({ ...row }));
176
+ },
177
+ /** The poller's last reading for a live task, or null. */
178
+ health(conversationId, taskId) {
179
+ return healthByKey.get(keyOf(conversationId))?.get(keyOf(taskId)) || null;
180
+ },
181
+ setHealth(conversationId, taskId, value) {
182
+ const id = keyOf(conversationId);
183
+ const task = keyOf(taskId);
184
+ if (!id || !task) return;
185
+ let health = healthByKey.get(id);
186
+ if (!value) {
187
+ health?.delete(task);
188
+ return;
189
+ }
190
+ if (!health) healthByKey.set(id, health = new Map());
191
+ health.set(task, value);
192
+ },
193
+ /** The keys that hold ended rows. */
194
+ endedKeys() {
195
+ return [...endedRows.keys()];
196
+ },
197
+ /**
198
+ * The poller's reading of an ended task's file, taken after the end: the
199
+ * last output (a test runner's summary) often lands between two readings
200
+ * of the live task. Returns false when there is no such row.
201
+ */
202
+ setEndedHealth(conversationId, taskId, value) {
203
+ const row = endedRows.get(keyOf(conversationId))?.get(keyOf(taskId));
204
+ if (!row || !value) return false;
205
+ row.health = value;
206
+ return true;
207
+ },
208
+ /** Task rows with the poller's last reading on each as `health`. */
209
+ withHealth(tasks, ...keys) {
210
+ const ids = keys.map(keyOf).filter(Boolean);
211
+ return (Array.isArray(tasks) ? tasks : []).map((task) => {
212
+ const taskId = keyOf(task?.taskId);
213
+ const health = ids.map((id) => healthByKey.get(id)?.get(taskId)).find(Boolean);
214
+ return health ? { ...task, health } : task;
215
+ });
216
+ },
217
+ /**
218
+ * A task of these keys, live or ended: `{ key, task, ended }` or null.
219
+ * The live set wins over an ended row.
220
+ */
221
+ findTask(keys, taskId) {
222
+ const task = keyOf(taskId);
223
+ const ids = [...new Set((Array.isArray(keys) ? keys : [keys]).map(keyOf).filter(Boolean))];
224
+ if (!task) return null;
225
+ for (const id of ids) {
226
+ const live = (sets.get(id)?.tasks || []).find((entry) => keyOf(entry?.taskId) === task);
227
+ if (live) {
228
+ const health = healthByKey.get(id)?.get(task);
229
+ return { key: id, task: health ? { ...live, health } : live, ended: false };
230
+ }
231
+ }
232
+ for (const id of ids) {
233
+ prune(id);
234
+ const row = endedRows.get(id)?.get(task);
235
+ if (row) return { key: id, task: { ...row }, ended: true };
236
+ }
237
+ return null;
238
+ },
239
+ get(conversationId) {
240
+ return sets.get(keyOf(conversationId))?.tasks || [];
241
+ },
242
+ /** The digest of the set held under the first of `keys` that has one. */
243
+ digestFor(...keys) {
244
+ const held = keys.map(keyOf).find((key) => key && sets.has(key));
245
+ return backgroundTaskDigest((held ? sets.get(held).tasks : []).map((task) => task?.taskId));
246
+ },
247
+ /**
248
+ * A worker's heartbeat digest against the set held for it: true when
249
+ * they agree. Agreement counts as a report, an empty set included.
250
+ */
251
+ matches(digest, ...keys) {
252
+ const same = sameBackgroundTaskDigest(digest, this.digestFor(...keys));
253
+ if (same) for (const key of keys.map(keyOf).filter(Boolean)) reported.add(key);
254
+ return same;
255
+ },
256
+ /** Whether this runtime has heard from the worker about its tasks. */
257
+ hasReport(...keys) {
258
+ return keys.map(keyOf).some((key) => key && reported.has(key));
259
+ },
260
+ /**
261
+ * Whether a set with this order may be applied, as `{ accepted, reason }`.
262
+ * Accepting records it. `seq` is the worker process's count of set
263
+ * posts, `epoch` names that process. A set without an order, from a
264
+ * worker that numbers none, always applies.
265
+ *
266
+ * Refused are:
267
+ * - `older`: same process, a count not above the newest applied. The
268
+ * post was overtaken on the way.
269
+ * - `not-holder`: the post's `pid` is not the pid of the process whose
270
+ * heartbeat came last for the session (`noteHolder`): a late post of
271
+ * the process before it, or a new process that has not beaten yet,
272
+ * which is told so and beats.
273
+ *
274
+ * A different epoch from the holder, or from a process that cannot be
275
+ * placed (no pid on the post, no heartbeat yet), is a new process and
276
+ * starts the count over, whichever of the two epochs is the larger: a clock that
277
+ * stepped back between two starts must not turn every set away. A late
278
+ * post that slips through that way is put right by the next heartbeat's
279
+ * digest.
280
+ */
281
+ acceptOrder(conversationId, order, { pid = null } = {}) {
282
+ const id = keyOf(conversationId);
283
+ const epoch = Number(order?.epoch);
284
+ const seq = Number(order?.seq);
285
+ if (!id || !Number.isFinite(epoch) || !Number.isInteger(seq) || seq < 0) return { accepted: true, reason: null };
286
+ const holder = holders.get(id);
287
+ const from = Number(pid);
288
+ if (holder && Number.isInteger(from) && from > 0 && from !== holder) return { accepted: false, reason: 'not-holder' };
289
+ const last = applied.get(id);
290
+ if (last && epoch === last.epoch && seq <= last.seq) return { accepted: false, reason: 'older' };
291
+ applied.set(id, { epoch, seq });
292
+ return { accepted: true, reason: null };
293
+ },
294
+ /** A heartbeat of the session's worker came from this process. */
295
+ noteHolder(conversationId, pid) {
296
+ const id = keyOf(conversationId);
297
+ const from = Number(pid);
298
+ if (id && Number.isInteger(from) && from > 0) holders.set(id, from);
299
+ },
300
+ /** Records whether the pages know of the key's placeholder; true when that changed. */
301
+ announce(conversationId, shown) {
302
+ const id = keyOf(conversationId);
303
+ if (!id || announced.has(id) === !!shown) return false;
304
+ if (shown) announced.add(id);
305
+ else announced.delete(id);
306
+ return true;
307
+ },
308
+ /** The conversation is gone: everything held for these keys goes. */
309
+ /**
310
+ * Drops expired ended rows and pending settles of every key, also of
311
+ * conversations nobody reads again (the per-key prune runs on reads).
312
+ * Called from the health poller's tick.
313
+ */
314
+ pruneAll() {
315
+ for (const id of new Set([...endedRows.keys(), ...settledAhead.keys()])) prune(id);
316
+ },
317
+ forget(...keys) {
318
+ for (const id of keys.map(keyOf).filter(Boolean)) {
319
+ sets.delete(id);
320
+ reported.delete(id);
321
+ applied.delete(id);
322
+ holders.delete(id);
323
+ announced.delete(id);
324
+ endedRows.delete(id);
325
+ settledAhead.delete(id);
326
+ healthByKey.delete(id);
327
+ }
328
+ },
329
+ };
330
+
331
+ function prune(id) {
332
+ const cutoff = now() - endedKeepMs;
333
+ const rows = endedRows.get(id);
334
+ if (rows) {
335
+ for (const [taskId, row] of rows) if (row.endedAt < cutoff) rows.delete(taskId);
336
+ if (!rows.size) endedRows.delete(id);
337
+ }
338
+ const ahead = settledAhead.get(id);
339
+ if (ahead) {
340
+ for (const [taskId, entry] of ahead) if (entry.at < cutoff) ahead.delete(taskId);
341
+ if (!ahead.size) settledAhead.delete(id);
342
+ }
343
+ }
344
+
345
+ function addEnded(id, task) {
346
+ let rows = endedRows.get(id);
347
+ if (!rows) endedRows.set(id, rows = new Map());
348
+ const taskId = keyOf(task.taskId);
349
+ rows.delete(taskId);
350
+ const row = {
351
+ ...task,
352
+ taskId,
353
+ status: normalizeEndedTaskStatus(task.status, exitCodeOf(task.exitCode)),
354
+ exitCode: exitCodeOf(task.exitCode),
355
+ endedAt: now(),
356
+ stoppable: false,
357
+ };
358
+ if (!row.health) delete row.health;
359
+ rows.set(taskId, row);
360
+ settledAhead.get(id)?.delete(taskId);
361
+ while (rows.size > endedMax) rows.delete(rows.keys().next().value);
362
+ prune(id);
363
+ return row;
364
+ }
365
+ }
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * What the relay can say about background work whose details it may not
5
+ * hold, because its task store and activity tracker live in memory and
6
+ * start empty at every runtime start while the workers keep running:
7
+ *
8
+ * - the placeholder: a live worker that says it has background work (the
9
+ * Claude worker's `autoCompactWindow.backgroundWork` on its heartbeat)
10
+ * while this runtime was never told its task set. Workers that answer a
11
+ * resync request fill the real set in within one heartbeat; this is for
12
+ * the ones still running older code. It is read, never stored: it goes
13
+ * when the set arrives, when the flag goes false and when the worker is
14
+ * gone, and it raises no start or end event.
15
+ * - the suspend blockers for background work, from the store and from the
16
+ * activity tracker (which also reads running sub-agent runs from the
17
+ * database), and the blocker for a runtime that only just started.
18
+ */
19
+
20
+ import {
21
+ UNREPORTED_BACKGROUND_TASK_DESCRIPTION,
22
+ UNREPORTED_BACKGROUND_TASK_ID,
23
+ } from './session-activity-tracker.mjs';
24
+
25
+ // Providers whose worker lists every running background sub-agent in its
26
+ // task set, by the run's id. Others may run background agents no set lists
27
+ // (their run rows are their only trace), so a set says nothing about a row.
28
+ const RUN_LISTING_PROVIDERS = new Set(['claude']);
29
+
30
+ function toText(value) {
31
+ return String(value ?? '').trim();
32
+ }
33
+
34
+ /** The placeholder as a row of the task panel: no details, no Stop button. */
35
+ export function unreportedBackgroundTaskCard() {
36
+ return {
37
+ taskId: UNREPORTED_BACKGROUND_TASK_ID,
38
+ taskType: 'unreported',
39
+ description: UNREPORTED_BACKGROUND_TASK_DESCRIPTION,
40
+ startedAt: null,
41
+ stoppable: false,
42
+ placeholder: true,
43
+ };
44
+ }
45
+
46
+ export function createBackgroundWorkView({
47
+ store,
48
+ db = null,
49
+ getRegistry = () => null,
50
+ getTracker = () => null,
51
+ isWorkerAlive = () => false,
52
+ // A conversation's id and its worker's session id are two ids, usually
53
+ // equal and not always. The tracker and the database speak of
54
+ // conversations; the registry, the store and the workers' own headers of
55
+ // sessions. These two map between them through the runtime session.
56
+ sessionIdOf = () => '',
57
+ conversationIdOf = () => '',
58
+ // conversationId → its provider ('claude', 'github', …).
59
+ providerOf = () => '',
60
+ titleOf = (conversationId) => toText(conversationId).slice(0, 8),
61
+ } = {}) {
62
+ let listRunningRuns = null;
63
+ if (db) {
64
+ try {
65
+ listRunningRuns = db.prepare(`
66
+ SELECT DISTINCT conversation_id FROM subagent_runs
67
+ WHERE status = 'running' AND background = 1 AND parent_subagent_id IS NULL
68
+ `);
69
+ } catch {}
70
+ }
71
+
72
+ function mapped(resolve, id) {
73
+ try { return toText(resolve(id)); } catch { return ''; }
74
+ }
75
+
76
+ /** The worker of a session id or of a conversation id. */
77
+ function workerOf(key) {
78
+ const registry = getRegistry();
79
+ const id = toText(key);
80
+ if (!registry || !id) return null;
81
+ // The registry's own indexes first; the mapping is a database read.
82
+ const direct = registry.getWorker?.(id) || registry.getWorkerByConversationId?.(id) || null;
83
+ if (direct) return direct;
84
+ const sessionId = mapped(sessionIdOf, id);
85
+ return (sessionId && sessionId !== id && registry.getWorker?.(sessionId)) || null;
86
+ }
87
+
88
+ /** The conversation a worker serves; the registry's own field may hold its session id. */
89
+ function conversationOf(worker, fallback = '') {
90
+ return mapped(conversationIdOf, worker?.sdkSessionId) || toText(worker?.conversationId) || toText(fallback);
91
+ }
92
+
93
+ function workerAlive(key) {
94
+ const worker = workerOf(key);
95
+ return !!worker && isWorkerAlive(worker.sdkSessionId) === true;
96
+ }
97
+
98
+ /** Whether the placeholder stands for this worker (by session or conversation id). */
99
+ function hasUnreportedWork(key) {
100
+ const worker = workerOf(key);
101
+ if (!worker || worker.steering?.autoCompactWindow?.backgroundWork !== true) return false;
102
+ const keys = [worker.sdkSessionId, worker.conversationId, conversationOf(worker), key];
103
+ if (store.hasReport(...keys)) return false;
104
+ if (keys.some((entry) => store.get(entry).length > 0)) return false;
105
+ return isWorkerAlive(worker.sdkSessionId) === true;
106
+ }
107
+
108
+ function listUnreportedConversationIds() {
109
+ const workers = getRegistry()?.listWorkers?.() || [];
110
+ return workers
111
+ .filter((worker) => worker?.steering?.autoCompactWindow?.backgroundWork === true && hasUnreportedWork(worker.sdkSessionId))
112
+ .map((worker) => conversationOf(worker, worker.sdkSessionId))
113
+ .filter(Boolean);
114
+ }
115
+
116
+ /**
117
+ * Whether the task set of a conversation's worker lists a sub-agent run
118
+ * (by the run's id or its task id): true or false, and null where the set
119
+ * cannot answer, because the worker has reported none in this runtime
120
+ * or its provider's sets do not list every background run. The tracker
121
+ * drops a `running` row that is not listed and has long been quiet.
122
+ */
123
+ function runListed(conversationId, row) {
124
+ const conversation = toText(conversationId);
125
+ if (!RUN_LISTING_PROVIDERS.has(mapped(providerOf, conversation).toLowerCase())) return null;
126
+ const keys = [...new Set([conversation, mapped(sessionIdOf, conversation), toText(workerOf(conversation)?.sdkSessionId)].filter(Boolean))];
127
+ if (!store.hasReport(...keys)) return null;
128
+ const runId = toText(row?.id);
129
+ const taskId = toText(row?.task_id);
130
+ return keys.some((key) => store.get(key).some((task) => (runId && toText(task?.toolUseId) === runId)
131
+ || (taskId && toText(task?.taskId) === taskId)));
132
+ }
133
+
134
+ /** The task panel's rows for a conversation: its set, else the placeholder. */
135
+ function panelTasks(...keys) {
136
+ for (const key of keys) {
137
+ const tasks = store.get(key);
138
+ if (tasks.length) return tasks;
139
+ }
140
+ return keys.some((key) => toText(key) && hasUnreportedWork(key)) ? [unreportedBackgroundTaskCard()] : [];
141
+ }
142
+
143
+ /**
144
+ * One `background` blocker per conversation with running background work
145
+ * of a live worker: the tasks of its published set, the running tasks the
146
+ * tracker knows (a background sub-agent run in the database, the
147
+ * placeholder), each task once.
148
+ */
149
+ function backgroundBlockers() {
150
+ const byConversation = new Map();
151
+ const add = (conversationId, id, description) => {
152
+ const conversation = toText(conversationId);
153
+ if (!conversation) return;
154
+ let tasks = byConversation.get(conversation);
155
+ if (!tasks) byConversation.set(conversation, tasks = new Map());
156
+ const taskId = toText(id) || `task-${tasks.size + 1}`;
157
+ if (!tasks.has(taskId)) tasks.set(taskId, toText(description) || 'task');
158
+ };
159
+ for (const [key, entry] of store.sets) {
160
+ const tasks = Array.isArray(entry?.tasks) ? entry.tasks : [];
161
+ if (!tasks.length || !workerAlive(key)) continue;
162
+ const conversationId = conversationOf(workerOf(key), mapped(conversationIdOf, key) || key);
163
+ for (const task of tasks) add(conversationId, task?.taskId, task?.description || task?.taskType);
164
+ }
165
+ const tracker = getTracker();
166
+ if (tracker) {
167
+ const candidates = new Set();
168
+ try { for (const id of tracker.knownConversationIds?.() || []) candidates.add(toText(id)); } catch {}
169
+ try { for (const row of listRunningRuns?.all() || []) candidates.add(toText(row.conversation_id)); } catch {}
170
+ for (const conversationId of candidates) {
171
+ if (!conversationId || !workerAlive(conversationId)) continue;
172
+ let view = null;
173
+ try { view = tracker.describe(conversationId); } catch {}
174
+ for (const task of view?.tasks || []) {
175
+ // A run inside a turn is the turn's work; the turn is the blocker.
176
+ if (task.status !== 'running' || task.inTurn) continue;
177
+ add(conversationId, task.id, task.description || task.type);
178
+ }
179
+ }
180
+ }
181
+ return [...byConversation].map(([conversationId, tasks]) => {
182
+ // The placeholder's text names the way out; it is not cut short.
183
+ const what = [...tasks].slice(0, 3)
184
+ .map(([id, text]) => (id === UNREPORTED_BACKGROUND_TASK_ID ? text : text.slice(0, 80)))
185
+ .join('; ');
186
+ return {
187
+ kind: 'background',
188
+ conversationId,
189
+ title: titleOf(conversationId),
190
+ count: tasks.size,
191
+ detail: `${tasks.size} background agent${tasks.size === 1 ? '' : 's'}: ${what}`,
192
+ };
193
+ });
194
+ }
195
+
196
+ /**
197
+ * The blocker of a runtime that only just started: workers that outlived
198
+ * the one before it may not have reported their background work yet, so
199
+ * "nothing runs" is not known. Null once the window is over. Its text
200
+ * does not change while it stands: every change of a blocker goes out to
201
+ * every page.
202
+ */
203
+ function bootBlocker() {
204
+ let boot = null;
205
+ try { boot = getTracker()?.bootReporting?.() || null; } catch {}
206
+ if (!boot?.active) return null;
207
+ return {
208
+ kind: 'boot',
209
+ detail: 'the relay just started, sessions are still reporting',
210
+ };
211
+ }
212
+
213
+ /** What holds a host suspend back for background work: the two above. */
214
+ function blockers() {
215
+ const boot = bootBlocker();
216
+ return [...backgroundBlockers(), ...(boot ? [boot] : [])];
217
+ }
218
+
219
+ return {
220
+ hasUnreportedWork,
221
+ listUnreportedConversationIds,
222
+ runListed,
223
+ panelTasks,
224
+ backgroundBlockers,
225
+ bootBlocker,
226
+ blockers,
227
+ };
228
+ }
@@ -62,6 +62,12 @@ export function createCacheRebuildService({
62
62
  bootstrapRuntimeSessionBindings,
63
63
  collectOrphanedUploadsFromConversation,
64
64
  deleteOrphanedUploads,
65
+ // (conversationId) => void: a hard-deleted conversation takes its preview
66
+ // links with it, as a delete from the UI does. Archiving keeps them.
67
+ closeConversationPreviews = null,
68
+ // Session groups: a purged head leaves its group, and a wipe is followed
69
+ // by a repair of what the wiped rows headed. Absent in older wiring.
70
+ conversationGroups = null,
65
71
  }) {
66
72
  const legacyConversationList = buildLegacyConversationList(db);
67
73
  const boundConversationList = buildBoundConversationList(db);
@@ -81,6 +87,7 @@ export function createCacheRebuildService({
81
87
  if (!sid) return [];
82
88
  const orphanedUploads = collectOrphanedUploadsFromConversation(sid);
83
89
  db.transaction(() => {
90
+ try { conversationGroups?.forgetConversation?.(sid); } catch {}
84
91
  db.prepare(`DELETE FROM relay_questions WHERE conversation_id = ?`).run(sid);
85
92
  db.prepare(`DELETE FROM relay_boards WHERE conversation_id = ?`).run(sid);
86
93
  db.prepare(`DELETE FROM relay_activity WHERE conversation_id = ?`).run(sid);
@@ -91,10 +98,19 @@ export function createCacheRebuildService({
91
98
  db.prepare(`DELETE FROM runtime_sessions WHERE conversation_id = ?`).run(sid);
92
99
  db.prepare(`DELETE FROM conversations WHERE id = ?`).run(sid);
93
100
  })();
101
+ closePreviews(sid);
94
102
  deleteOrphanedUploads(orphanedUploads);
95
103
  return orphanedUploads;
96
104
  }
97
105
 
106
+ function closePreviews(conversationId) {
107
+ try {
108
+ closeConversationPreviews?.(conversationId);
109
+ } catch (error) {
110
+ console.warn(`[preview] closing the previews of a purged conversation failed: ${error?.message || error}`);
111
+ }
112
+ }
113
+
98
114
  function archiveConversation(conversationId) {
99
115
  const sid = normalizeId(conversationId);
100
116
  if (!sid) return false;
@@ -112,6 +128,12 @@ export function createCacheRebuildService({
112
128
  AND title_source = 'manual'
113
129
  `).all().map((row) => normalizeId(row?.id)).filter(Boolean);
114
130
  const manualTitleConversationSet = new Set(manualTitleConversations);
131
+ // The groups headed by a row about to go keep the head's title as their
132
+ // name, so a group whose hand-titled members survive is not nameless; the
133
+ // head link stays for a re-import under the same id.
134
+ try {
135
+ conversationGroups?.freezeNamesForHeads?.(conversationIds.filter((id) => !manualTitleConversationSet.has(id)), now);
136
+ } catch {}
115
137
  const uploadHashes = [];
116
138
  for (const conversationId of conversationIds) {
117
139
  for (const hash of collectOrphanedUploadsFromConversation(conversationId)) {
@@ -147,8 +169,16 @@ export function createCacheRebuildService({
147
169
  db.prepare(`DELETE FROM sdk_session_imports`).run();
148
170
  db.prepare(`DELETE FROM upload_refs`).run();
149
171
  db.prepare(`DELETE FROM uploaded_files`).run();
172
+ // Groups whose rows are all gone go too; one whose head was wiped but
173
+ // whose hand-titled members stay keeps the frozen name and the head
174
+ // link (a re-imported head takes it up again).
175
+ try { conversationGroups?.sweepEmpty?.(); } catch {}
150
176
  })();
151
177
 
178
+ for (const conversationId of conversationIds) {
179
+ if (!manualTitleConversationSet.has(conversationId)) closePreviews(conversationId);
180
+ }
181
+
152
182
  deleteOrphanedUploads(uploadHashes);
153
183
 
154
184
  if (uploadsDir && fs?.existsSync?.(uploadsDir)) {