@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
@@ -12,16 +12,89 @@
12
12
  * post). `reported` remembers which workers this runtime has heard from
13
13
  * about their tasks at all: until then "no set" means "not told yet", not
14
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
+ * Across a restart (`persistence`, the `background_task_rows` table): the
27
+ * console rows survive, the live sets do not. Ended rows come back as they
28
+ * were, for the rest of their hour. A live task with a console comes back
29
+ * held (`restored`): its console stays readable, but no panel, state or
30
+ * suspend check counts it as running, because only the worker can say that.
31
+ * The worker's next set settles it: a task in the set is live again, one not
32
+ * in it ended while the relay was away. A worker that never reports (it died
33
+ * with the relay) has its held tasks marked ended (`endUnconfirmed`), never
34
+ * running again. Health readings are not stored: the poller reads the files
35
+ * again. Sub-agent rows have no console and are not stored either.
15
36
  */
16
37
 
17
38
  import { backgroundTaskDigest, sameBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
39
+ import { TASK_OUTPUT_LIMITS } from '../../shared/task-output-text.mjs';
18
40
 
19
41
  function keyOf(value) {
20
42
  return String(value || '').trim();
21
43
  }
22
44
 
23
- export function createBackgroundTaskStore({ now = () => Date.now() } = {}) {
45
+ const ENDED_STATUSES = new Set(['completed', 'failed', 'stopped', 'unknown']);
46
+
47
+ /** A worker's word for how a task ended, as one of completed, failed, stopped, unknown. */
48
+ export function normalizeEndedTaskStatus(status, exitCode = null) {
49
+ const text = String(status || '').trim().toLowerCase();
50
+ if (ENDED_STATUSES.has(text)) return text;
51
+ if (['complete', 'done', 'success', 'succeeded', 'ok', 'exited'].includes(text)) {
52
+ return Number.isInteger(exitCode) && exitCode !== 0 ? 'failed' : 'completed';
53
+ }
54
+ if (['error', 'errored', 'failure'].includes(text)) return 'failed';
55
+ if (['killed', 'cancelled', 'canceled', 'aborted', 'interrupted', 'timeout', 'timed_out'].includes(text)) return 'stopped';
56
+ if (Number.isInteger(exitCode)) return exitCode === 0 ? 'completed' : 'failed';
57
+ return 'unknown';
58
+ }
59
+
60
+ function exitCodeOf(value) {
61
+ if (value === null || value === undefined || value === '') return null;
62
+ const number = Number(value);
63
+ return Number.isInteger(number) ? number : null;
64
+ }
65
+
66
+ // A stored live row is confirmed at most this often while its worker's
67
+ // digest agrees (its `updated_at`), and one not confirmed for this long is a
68
+ // leftover of a worker that went away without a word: not restored.
69
+ const LIVE_TOUCH_EVERY_MS = 30 * 60_000;
70
+ const LIVE_STALE_MS = 6 * 60 * 60_000;
71
+
72
+ // What of a live console task is stored: what the console and an ended row
73
+ // need, not what changes with every progress post.
74
+ const STORED_LIVE_FIELDS = ['taskId', 'taskType', 'description', 'command', 'startedAt', 'outputPath', 'outputKind', 'stoppable', 'toolUseId'];
75
+
76
+ function storedLiveRow(task, ahead) {
77
+ const row = {};
78
+ for (const field of STORED_LIVE_FIELDS) if (task?.[field] !== undefined && task?.[field] !== null) row[field] = task[field];
79
+ // How it ended, told before the set dropped it.
80
+ if (ahead) row.settled = { status: ahead.status, exitCode: ahead.exitCode, ...(ahead.steps !== undefined ? { steps: ahead.steps } : {}) };
81
+ return row;
82
+ }
83
+
84
+ export function createBackgroundTaskStore({
85
+ now = () => Date.now(),
86
+ endedKeepMs = TASK_OUTPUT_LIMITS.endedKeepMs,
87
+ endedMax = TASK_OUTPUT_LIMITS.endedMax,
88
+ persistence = null,
89
+ } = {}) {
24
90
  const sets = new Map();
91
+ // key → Map(taskId → ended row), newest last.
92
+ const endedRows = new Map();
93
+ // key → Map(taskId → { status, exitCode, outputPath, at }): how a task
94
+ // ended, told before the set dropped it.
95
+ const settledAhead = new Map();
96
+ // key → Map(taskId → the health poller's last reading).
97
+ const healthByKey = new Map();
25
98
  const reported = new Set();
26
99
  // key → the order of the newest set applied, `{ epoch, seq }` as the
27
100
  // worker numbered it. `epoch` names the worker process; it is compared
@@ -32,6 +105,18 @@ export function createBackgroundTaskStore({ now = () => Date.now() } = {}) {
32
105
  const holders = new Map();
33
106
  // Keys whose pages were told of the placeholder for unreported work.
34
107
  const announced = new Set();
108
+ // key → Map(taskId → row): live console tasks of the runtime before, held
109
+ // until the worker's set says what became of them.
110
+ const restored = new Map();
111
+ // key → what was stored of its live console tasks last, to write only on change.
112
+ const storedLive = new Map();
113
+ // key → when its stored live rows were last written or confirmed.
114
+ const touchedAt = new Map();
115
+ // key → Map(taskId → when the runtime before last knew the held task running).
116
+ const heldSince = new Map();
117
+ const store = persistence && persistence.available !== false ? persistence : null;
118
+
119
+ restore();
35
120
 
36
121
  return {
37
122
  sets,
@@ -39,11 +124,206 @@ export function createBackgroundTaskStore({ now = () => Date.now() } = {}) {
39
124
  const id = keyOf(conversationId);
40
125
  if (!id) return;
41
126
  reported.add(id);
42
- if (!Array.isArray(tasks) || tasks.length === 0) {
43
- sets.delete(id);
127
+ const next = Array.isArray(tasks) ? tasks : [];
128
+ const nextIds = new Set(next.map((task) => keyOf(task?.taskId)).filter(Boolean));
129
+ // Tasks that left the set with a console become ended rows.
130
+ for (const task of sets.get(id)?.tasks || []) {
131
+ const taskId = keyOf(task?.taskId);
132
+ if (!taskId || nextIds.has(taskId) || !task?.outputPath) continue;
133
+ const ahead = settledAhead.get(id)?.get(taskId) || null;
134
+ addEnded(id, {
135
+ ...task,
136
+ ...(ahead?.steps !== undefined ? { steps: ahead.steps } : {}),
137
+ status: ahead?.status || 'unknown',
138
+ exitCode: ahead ? ahead.exitCode : (task.exitCode ?? null),
139
+ health: healthByKey.get(id)?.get(taskId) || task.health || undefined,
140
+ });
141
+ }
142
+ // Tasks held since the restart: the worker's set says which still run
143
+ // (their console is the one they had; the first path a task names
144
+ // wins), and the others ended while the relay was away.
145
+ const held = restored.get(id);
146
+ if (held) {
147
+ restored.delete(id);
148
+ for (const [taskId, row] of held) {
149
+ const live = next.find((task) => keyOf(task?.taskId) === taskId);
150
+ if (live) {
151
+ if (row.outputPath && live.outputPath !== row.outputPath) {
152
+ live.outputPath = row.outputPath;
153
+ live.outputKind = row.outputKind;
154
+ }
155
+ if (row.settled && !settledAhead.get(id)?.has(taskId)) {
156
+ let ahead = settledAhead.get(id);
157
+ if (!ahead) settledAhead.set(id, ahead = new Map());
158
+ ahead.set(taskId, { ...row.settled, at: now() });
159
+ }
160
+ continue;
161
+ }
162
+ endRestored(id, row);
163
+ }
164
+ heldSince.delete(id);
165
+ }
166
+ // A task back in the set (a resync after a restart) is not ended.
167
+ for (const taskId of nextIds) {
168
+ if (endedRows.get(id)?.delete(taskId)) store?.remove(id, taskId);
169
+ settledAhead.get(id)?.delete(taskId);
170
+ }
171
+ const health = healthByKey.get(id);
172
+ if (health) {
173
+ for (const taskId of [...health.keys()]) if (!nextIds.has(taskId)) health.delete(taskId);
174
+ if (!health.size) healthByKey.delete(id);
175
+ }
176
+ if (next.length === 0) sets.delete(id);
177
+ else sets.set(id, { tasks: next, updatedAt: now() });
178
+ storeLive(id);
179
+ },
180
+ /**
181
+ * How a task ended (`/api/background-task-settled`): updates its ended
182
+ * row, or is kept for the moment the set drops it, or, for a task this
183
+ * runtime never saw live that names its output, becomes an ended row of
184
+ * its own. Returns the ended row when there is one.
185
+ */
186
+ settle(conversationId, taskId, { status, exitCode, outputPath, description, command, taskType, startedAt, outputKind, steps } = {}) {
187
+ const id = keyOf(conversationId);
188
+ const task = keyOf(taskId);
189
+ if (!id || !task) return null;
190
+ prune(id);
191
+ const code = exitCodeOf(exitCode);
192
+ const normalized = normalizeEndedTaskStatus(status, code);
193
+ const stepCount = Number.isInteger(steps) && steps >= 0 ? steps : undefined;
194
+ const existing = endedRows.get(id)?.get(task);
195
+ if (existing) {
196
+ existing.status = normalized;
197
+ existing.exitCode = code;
198
+ if (!existing.outputPath && outputPath) {
199
+ existing.outputPath = outputPath;
200
+ if (existing.outputKind !== 'file' && existing.outputKind !== 'spool') existing.outputKind = outputKind === 'spool' ? 'spool' : 'file';
201
+ }
202
+ if (stepCount !== undefined) existing.steps = stepCount;
203
+ store?.saveEnded(id, existing);
204
+ return existing;
205
+ }
206
+ // A task held since the restart that ended while the relay was away
207
+ // (the worker sends the settle again): it is not running any more.
208
+ const heldRow = restored.get(id)?.get(task);
209
+ if (heldRow) {
210
+ restored.get(id).delete(task);
211
+ if (!restored.get(id).size) restored.delete(id);
212
+ return endRestored(id, {
213
+ ...heldRow,
214
+ settled: { status: normalized, exitCode: code, ...(stepCount !== undefined ? { steps: stepCount } : {}) },
215
+ ...(!heldRow.outputPath && outputPath ? { outputPath, outputKind: outputKind === 'spool' ? 'spool' : 'file' } : {}),
216
+ });
217
+ }
218
+ const live = (sets.get(id)?.tasks || []).find((entry) => keyOf(entry?.taskId) === task);
219
+ if (live) {
220
+ let ahead = settledAhead.get(id);
221
+ if (!ahead) settledAhead.set(id, ahead = new Map());
222
+ ahead.set(task, { status: normalized, exitCode: code, steps: stepCount, at: now() });
223
+ // The first path a task names wins. A row that had none was read
224
+ // from a snapshot (or not at all): from now on it is the file.
225
+ if (outputPath && !live.outputPath) {
226
+ live.outputPath = outputPath;
227
+ if (live.outputKind !== 'file' && live.outputKind !== 'spool') live.outputKind = outputKind === 'spool' ? 'spool' : 'file';
228
+ }
229
+ storeLive(id);
230
+ return null;
231
+ }
232
+ if (!outputPath) return null;
233
+ return addEnded(id, {
234
+ taskId: task,
235
+ taskType: keyOf(taskType) || 'local_bash',
236
+ description: String(description || '').trim().slice(0, 500),
237
+ ...(command ? { command } : {}),
238
+ startedAt: Number(startedAt) || null,
239
+ outputPath,
240
+ outputKind: outputKind || 'file',
241
+ stoppable: false,
242
+ status: normalized,
243
+ exitCode: code,
244
+ ...(stepCount !== undefined ? { steps: stepCount } : {}),
245
+ });
246
+ },
247
+ /** The ended rows of these keys, newest first. */
248
+ ended(...keys) {
249
+ const rows = [];
250
+ for (const id of new Set(keys.map(keyOf).filter(Boolean))) {
251
+ prune(id);
252
+ rows.push(...(endedRows.get(id)?.values() || []));
253
+ }
254
+ return rows.sort((a, b) => b.endedAt - a.endedAt).map((row) => ({ ...row }));
255
+ },
256
+ /** The poller's last reading for a live task, or null. */
257
+ health(conversationId, taskId) {
258
+ return healthByKey.get(keyOf(conversationId))?.get(keyOf(taskId)) || null;
259
+ },
260
+ setHealth(conversationId, taskId, value) {
261
+ const id = keyOf(conversationId);
262
+ const task = keyOf(taskId);
263
+ if (!id || !task) return;
264
+ let health = healthByKey.get(id);
265
+ if (!value) {
266
+ health?.delete(task);
44
267
  return;
45
268
  }
46
- sets.set(id, { tasks, updatedAt: now() });
269
+ if (!health) healthByKey.set(id, health = new Map());
270
+ health.set(task, value);
271
+ },
272
+ /** The keys that hold ended rows. */
273
+ endedKeys() {
274
+ return [...endedRows.keys()];
275
+ },
276
+ /**
277
+ * The poller's reading of an ended task's file, taken after the end: the
278
+ * last output (a test runner's summary) often lands between two readings
279
+ * of the live task. Returns false when there is no such row.
280
+ */
281
+ setEndedHealth(conversationId, taskId, value) {
282
+ const row = endedRows.get(keyOf(conversationId))?.get(keyOf(taskId));
283
+ if (!row || !value) return false;
284
+ // Not stored: the poller reads the file again after a restart.
285
+ row.health = value;
286
+ return true;
287
+ },
288
+ /** Task rows with the poller's last reading on each as `health`. */
289
+ withHealth(tasks, ...keys) {
290
+ const ids = keys.map(keyOf).filter(Boolean);
291
+ return (Array.isArray(tasks) ? tasks : []).map((task) => {
292
+ const taskId = keyOf(task?.taskId);
293
+ const health = ids.map((id) => healthByKey.get(id)?.get(taskId)).find(Boolean);
294
+ return health ? { ...task, health } : task;
295
+ });
296
+ },
297
+ /**
298
+ * A task of these keys, live or ended: `{ key, task, ended }` or null.
299
+ * The live set wins over an ended row.
300
+ */
301
+ findTask(keys, taskId) {
302
+ const task = keyOf(taskId);
303
+ const ids = [...new Set((Array.isArray(keys) ? keys : [keys]).map(keyOf).filter(Boolean))];
304
+ if (!task) return null;
305
+ for (const id of ids) {
306
+ const live = (sets.get(id)?.tasks || []).find((entry) => keyOf(entry?.taskId) === task);
307
+ if (live) {
308
+ const health = healthByKey.get(id)?.get(task);
309
+ return { key: id, task: health ? { ...live, health } : live, ended: false };
310
+ }
311
+ }
312
+ for (const id of ids) {
313
+ prune(id);
314
+ const row = endedRows.get(id)?.get(task);
315
+ if (row) return { key: id, task: { ...row }, ended: true };
316
+ }
317
+ // Held since the restart: its console is readable while the worker
318
+ // has not reported yet.
319
+ for (const id of ids) {
320
+ const row = restored.get(id)?.get(task);
321
+ if (row) {
322
+ const { settled: _settled, ...rest } = row;
323
+ return { key: id, task: { ...rest }, ended: false, restored: true };
324
+ }
325
+ }
326
+ return null;
47
327
  },
48
328
  get(conversationId) {
49
329
  return sets.get(keyOf(conversationId))?.tasks || [];
@@ -58,8 +338,20 @@ export function createBackgroundTaskStore({ now = () => Date.now() } = {}) {
58
338
  * they agree. Agreement counts as a report, an empty set included.
59
339
  */
60
340
  matches(digest, ...keys) {
341
+ // Tasks held since the restart are settled only by a set the worker
342
+ // sends: until then the worker is asked for it, whatever its digest.
343
+ if (keys.map(keyOf).some((key) => key && restored.has(key))) return false;
61
344
  const same = sameBackgroundTaskDigest(digest, this.digestFor(...keys));
62
- if (same) for (const key of keys.map(keyOf).filter(Boolean)) reported.add(key);
345
+ if (same) {
346
+ for (const key of keys.map(keyOf).filter(Boolean)) {
347
+ reported.add(key);
348
+ // Its stored rows are still running: say so now and then, so a
349
+ // restart can tell them from a dead worker's leftovers.
350
+ if (store && storedLive.get(key) && now() - (touchedAt.get(key) || 0) >= LIVE_TOUCH_EVERY_MS) {
351
+ if (store.touchLive?.(key)) touchedAt.set(key, now());
352
+ }
353
+ }
354
+ }
63
355
  return same;
64
356
  },
65
357
  /** Whether this runtime has heard from the worker about its tasks. */
@@ -114,15 +406,187 @@ export function createBackgroundTaskStore({ now = () => Date.now() } = {}) {
114
406
  else announced.delete(id);
115
407
  return true;
116
408
  },
409
+ /**
410
+ * Read only: every task held since the restart, as `{ key, task }`
411
+ * (the stored row). Not running for any panel or check until the
412
+ * worker's set confirms it.
413
+ */
414
+ listRestored() {
415
+ const rows = [];
416
+ for (const [key, held] of restored) {
417
+ for (const row of held.values()) {
418
+ const { settled, ...task } = row;
419
+ rows.push({ key, task });
420
+ }
421
+ }
422
+ return rows;
423
+ },
424
+ /** Whether tasks of these keys are held since the restart. */
425
+ hasRestored(...keys) {
426
+ return keys.map(keyOf).some((key) => key && restored.has(key));
427
+ },
428
+ /**
429
+ * The workers that did not report since the restart: their held tasks
430
+ * are marked ended (how is unknown), never running again. Returns the
431
+ * keys whose ended rows changed.
432
+ */
433
+ endUnconfirmed() {
434
+ const changed = [];
435
+ for (const [id, held] of [...restored]) {
436
+ restored.delete(id);
437
+ for (const row of held.values()) endRestored(id, row);
438
+ storeLive(id);
439
+ changed.push(id);
440
+ }
441
+ return changed;
442
+ },
443
+ /**
444
+ * Drops expired ended rows and pending settles of every key, also of
445
+ * conversations nobody reads again (the per-key prune runs on reads).
446
+ * Called from the health poller's tick.
447
+ */
448
+ pruneAll() {
449
+ for (const id of new Set([...endedRows.keys(), ...settledAhead.keys()])) prune(id);
450
+ store?.pruneEnded(now() - endedKeepMs);
451
+ },
117
452
  /** The conversation is gone: everything held for these keys goes. */
118
453
  forget(...keys) {
119
- for (const id of keys.map(keyOf).filter(Boolean)) {
454
+ const ids = keys.map(keyOf).filter(Boolean);
455
+ if (ids.length) store?.forget(ids);
456
+ for (const id of ids) {
457
+ restored.delete(id);
458
+ heldSince.delete(id);
459
+ touchedAt.delete(id);
460
+ storedLive.delete(id);
120
461
  sets.delete(id);
121
462
  reported.delete(id);
122
463
  applied.delete(id);
123
464
  holders.delete(id);
124
465
  announced.delete(id);
466
+ endedRows.delete(id);
467
+ settledAhead.delete(id);
468
+ healthByKey.delete(id);
125
469
  }
126
470
  },
127
471
  };
472
+
473
+ // The rows the runtime before kept: ended rows for the rest of their
474
+ // hour, live console tasks held for the worker's word.
475
+ function restore() {
476
+ if (!store) return;
477
+ const cutoff = now() - endedKeepMs;
478
+ const ended = [];
479
+ for (const entry of store.load()) {
480
+ const id = keyOf(entry.key);
481
+ const taskId = keyOf(entry.taskId);
482
+ if (!id || !taskId) continue;
483
+ if (entry.state === 'ended') {
484
+ const endedAt = Number(entry.endedAt ?? entry.row.endedAt);
485
+ if (!Number.isFinite(endedAt) || endedAt < cutoff) continue;
486
+ ended.push({ id, row: { ...entry.row, taskId, endedAt, stoppable: false } });
487
+ continue;
488
+ }
489
+ if (entry.state !== 'live') continue;
490
+ // Not confirmed for hours: its worker went away in a runtime before
491
+ // without a word. Nothing to show, now or as an ended row.
492
+ const since = Number(entry.updatedAt);
493
+ if (!Number.isFinite(since) || now() - since > LIVE_STALE_MS) {
494
+ store.remove(id, taskId);
495
+ continue;
496
+ }
497
+ let held = restored.get(id);
498
+ if (!held) restored.set(id, held = new Map());
499
+ held.set(taskId, { ...entry.row, taskId });
500
+ let sinceOf = heldSince.get(id);
501
+ if (!sinceOf) heldSince.set(id, sinceOf = new Map());
502
+ sinceOf.set(taskId, Math.min(since, now()));
503
+ storedLive.set(id, null);
504
+ }
505
+ // Oldest first, as `addEnded` keeps them.
506
+ ended.sort((a, b) => a.row.endedAt - b.row.endedAt);
507
+ for (const { id, row } of ended) {
508
+ let rows = endedRows.get(id);
509
+ if (!rows) endedRows.set(id, rows = new Map());
510
+ rows.set(row.taskId, row);
511
+ while (rows.size > endedMax) rows.delete(rows.keys().next().value);
512
+ }
513
+ store.pruneEnded(cutoff);
514
+ }
515
+
516
+ // A held task that is not running any more becomes an ended row, with how
517
+ // it ended when the runtime before was told.
518
+ // It ended at the latest when the runtime before last knew it running, so
519
+ // that is its end time: a leftover from long ago does not count as fresh.
520
+ function endRestored(id, row) {
521
+ const { settled, ...task } = row;
522
+ const since = heldSince.get(id)?.get(keyOf(task.taskId));
523
+ heldSince.get(id)?.delete(keyOf(task.taskId));
524
+ if (!heldSince.get(id)?.size) heldSince.delete(id);
525
+ return addEnded(id, {
526
+ ...task,
527
+ ...(settled?.steps !== undefined ? { steps: settled.steps } : {}),
528
+ status: settled?.status || 'unknown',
529
+ exitCode: settled ? settled.exitCode : null,
530
+ }, { endedAt: Number.isFinite(since) ? since : undefined });
531
+ }
532
+
533
+ // Stores the key's live console tasks when they changed (what is stored,
534
+ // not every progress post).
535
+ function storeLive(id) {
536
+ if (!store) return;
537
+ const ahead = settledAhead.get(id);
538
+ const rows = (sets.get(id)?.tasks || [])
539
+ .filter((task) => task?.outputPath && keyOf(task?.taskId))
540
+ .map((task) => storedLiveRow(task, ahead?.get(keyOf(task.taskId))));
541
+ // Held tasks stay stored until the worker's set settles them.
542
+ for (const row of restored.get(id)?.values() || []) rows.push(row);
543
+ const signature = JSON.stringify(rows);
544
+ if (storedLive.get(id) === signature) return;
545
+ if (!rows.length && !storedLive.has(id)) return;
546
+ if (store.saveLive(id, rows)) {
547
+ touchedAt.set(id, now());
548
+ if (rows.length) storedLive.set(id, signature);
549
+ else storedLive.delete(id);
550
+ }
551
+ }
552
+
553
+ function prune(id) {
554
+ const cutoff = now() - endedKeepMs;
555
+ const rows = endedRows.get(id);
556
+ if (rows) {
557
+ for (const [taskId, row] of rows) if (row.endedAt < cutoff) rows.delete(taskId);
558
+ if (!rows.size) endedRows.delete(id);
559
+ }
560
+ const ahead = settledAhead.get(id);
561
+ if (ahead) {
562
+ for (const [taskId, entry] of ahead) if (entry.at < cutoff) ahead.delete(taskId);
563
+ if (!ahead.size) settledAhead.delete(id);
564
+ }
565
+ }
566
+
567
+ function addEnded(id, task, { endedAt = now() } = {}) {
568
+ let rows = endedRows.get(id);
569
+ if (!rows) endedRows.set(id, rows = new Map());
570
+ const taskId = keyOf(task.taskId);
571
+ rows.delete(taskId);
572
+ const row = {
573
+ ...task,
574
+ taskId,
575
+ status: normalizeEndedTaskStatus(task.status, exitCodeOf(task.exitCode)),
576
+ exitCode: exitCodeOf(task.exitCode),
577
+ endedAt,
578
+ stoppable: false,
579
+ };
580
+ if (!row.health) delete row.health;
581
+ rows.set(taskId, row);
582
+ settledAhead.get(id)?.delete(taskId);
583
+ store?.saveEnded(id, row);
584
+ while (rows.size > endedMax) {
585
+ const oldest = rows.keys().next().value;
586
+ rows.delete(oldest);
587
+ store?.remove(id, oldest);
588
+ }
589
+ prune(id);
590
+ return row;
591
+ }
128
592
  }
@@ -37,10 +37,6 @@ const OTHER_ESCAPE_PATTERN = /\x1b[@-Z\\-_]/g;
37
37
  // differently-sized terminal this degrades to the pre-wrap behaviour.
38
38
  const PTY_WRAP_COLUMNS = 80;
39
39
 
40
- function normalizeText(value) {
41
- return String(value == null ? '' : value).trim();
42
- }
43
-
44
40
  /**
45
41
  * Removes OSC sequences (including OSC-8 hyperlink wrappers), ANSI CSI
46
42
  * sequences and the CR the PTY adds to every line, leaving the human-visible
@@ -74,23 +70,10 @@ export function joinWrappedLines(value) {
74
70
  return joined.join('\n');
75
71
  }
76
72
 
77
- /**
78
- * Redacts anything token-shaped plus an exact secret the caller knows it
79
- * handed over (the pasted Claude login code), so an error tail can be surfaced
80
- * to the UI without leaking a credential. Long PKCE segments in an authorize
81
- * URL get caught by the generic rule too, which is fine: the URL is already
82
- * carried in the login payload.
83
- */
84
- export function scrubSecrets(text, submittedCode = '') {
85
- let output = String(text == null ? '' : text);
86
- const code = normalizeText(submittedCode);
87
- if (code.length >= 4) {
88
- output = output.split(code).join('[redacted]');
89
- }
90
- return output
91
- .replace(/sk-ant-[A-Za-z0-9_-]+/g, '[redacted]')
92
- .replace(/[A-Za-z0-9_-]{32,}/g, '[redacted]');
93
- }
73
+ // Redacts anything token-shaped plus an exact secret the caller handed over.
74
+ // The rules live in the shared text pipeline, which also scrubs background
75
+ // task output (browser-safe, so it cannot import this module).
76
+ export { scrubSecrets } from '../../shared/task-output-text.mjs';
94
77
 
95
78
  /** Collapses captured output into one short single-line tail for an error. */
96
79
  export function tailOf(text, limit = MAX_ERROR_TAIL_CHARS) {