@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
@@ -7,17 +7,28 @@
7
7
  *
8
8
  * `list_sessions` / `read_session` read it as `state` and `tasks[]`; the
9
9
  * relay event service reads its edges (a task started or ended) and its quiet
10
- * time (stalled). Memory only: the background-task set is republished by the
11
- * workers on every change, sub-agent runs are read from the database, and a
12
- * restart forgets only the recently finished tasks and the activity clock
13
- * (which then starts at the restart, so nothing reads as stalled at boot).
10
+ * time (stalled). Memory only, and a restart of the relay starts it empty
11
+ * while the workers and their tasks keep running. What brings a running
12
+ * task back:
13
+ * - the worker sends its set again when the relay asks for it on a
14
+ * heartbeat (`noteTaskSet` with `resync`), which restores the task with
15
+ * its original start time and without a second `task_started`;
16
+ * - sub-agent runs are read from the database;
17
+ * - a worker that cannot send its set again yet but says it has background
18
+ * work shows one placeholder task (`hasUnreportedBackgroundWork`).
19
+ * Until the workers had time to report, `bootReporting()` says so: the
20
+ * suspend check and `all_idle` wait that long. The recently finished tasks
21
+ * and the activity clock are gone for good (the clock starts at the restart,
22
+ * so nothing reads as stalled at boot).
14
23
  *
15
24
  * Sources:
16
25
  * - `noteTaskSet` — every POST /api/background-tasks (REPLACE semantics):
17
26
  * a new task id is a start, a task gone from the set has ended, a changed
18
- * task (tool call, summary, tokens) is activity.
27
+ * task (tool call, summary, tokens) is activity. A task that started
28
+ * before this runtime did was announced by the runtime before it.
19
29
  * - `noteTaskSettled` — how a task ended, where the worker says so (the
20
- * Claude worker's task_notification).
30
+ * Claude worker's task_notification). A task this runtime never knew
31
+ * still gets its end event when the worker describes it.
21
32
  * - `noteSubagentRun` — every sub-agent run status the relay stores; a
22
33
  * background run's terminal status is how its task ended, and a
23
34
  * background run with no task in the set (Copilot, Cursor) ends here.
@@ -37,6 +48,44 @@ export const TASK_END_GRACE_MS = 5_000;
37
48
  // again and again). The sweep in stale-subagent-run-sweep.mjs closes such
38
49
  // rows; this bound keeps them out of the state meanwhile.
39
50
  export const STALE_RUNNING_RUN_MAX_AGE_MS = 24 * 60 * 60_000;
51
+ // How long after a runtime start the surviving workers may still be
52
+ // reporting their tasks: four heartbeat periods and a little. Inside it an
53
+ // idle-looking relay is not known to be idle.
54
+ export const BOOT_REPORTING_WINDOW_MS = 45_000;
55
+ // The most an override may ask for: a window is a hold on suspends and on
56
+ // all_idle, and timers take no delay above 2^31 ms.
57
+ export const BOOT_REPORTING_WINDOW_MAX_MS = 10 * 60_000;
58
+ /**
59
+ * The window of this runtime: BOOT_REPORTING_WINDOW_MS, unless
60
+ * OAR_BOOT_REPORTING_WINDOW_MS pins another, from 0 (no hold at all: a test
61
+ * relay that must read as idle straight after its start) to
62
+ * BOOT_REPORTING_WINDOW_MAX_MS. An override in effect is logged once; a
63
+ * value that is no number, or below 0, is ignored and logged too.
64
+ */
65
+ export function resolveBootReportingWindowMs(env = process.env, log = () => {}) {
66
+ const raw = String(env?.OAR_BOOT_REPORTING_WINDOW_MS ?? '').trim();
67
+ if (!raw) return BOOT_REPORTING_WINDOW_MS;
68
+ const pinned = Number(raw);
69
+ if (!Number.isFinite(pinned) || pinned < 0) {
70
+ log(`OAR_BOOT_REPORTING_WINDOW_MS=${raw.slice(0, 40)} is not a number of milliseconds from 0; using ${BOOT_REPORTING_WINDOW_MS}`);
71
+ return BOOT_REPORTING_WINDOW_MS;
72
+ }
73
+ const windowMs = Math.min(BOOT_REPORTING_WINDOW_MAX_MS, Math.trunc(pinned));
74
+ log(windowMs === 0
75
+ ? 'OAR_BOOT_REPORTING_WINDOW_MS=0: no wait after a relay start; a suspend and all_idle do not wait for sessions that are still reporting'
76
+ : `OAR_BOOT_REPORTING_WINDOW_MS: a suspend and all_idle wait ${windowMs} ms after a relay start${windowMs === pinned ? '' : ` (asked for ${raw.slice(0, 40)}, kept within 0 to ${BOOT_REPORTING_WINDOW_MAX_MS})`}`);
77
+ return windowMs;
78
+ }
79
+ // The one task shown for a worker that reports background work the relay
80
+ // was never told the details of.
81
+ export const UNREPORTED_BACKGROUND_TASK_ID = 'unreported-background-work';
82
+ // It stays until the worker reports, and a worker on older code never does
83
+ // for a task that prints nothing: the way out is the session's Kill session.
84
+ export const UNREPORTED_BACKGROUND_TASK_DESCRIPTION = 'background work, details not reported since the relay restarted (Kill session in its ⋯ menu ends it)';
85
+ // A `running` background run row that its worker's task set does not list
86
+ // and nothing has updated for this long is not running work (see
87
+ // `runListedByWorker`).
88
+ export const UNLISTED_RUNNING_RUN_MAX_QUIET_MS = 15 * 60_000;
40
89
  const RECENT_TASKS_MAX = 5;
41
90
  const RECENT_TASK_TTL_MS = 60 * 60_000;
42
91
  const SHELL_TASK_TYPES = new Set(['local_bash', 'bash', 'shell', 'monitor', 'local_monitor']);
@@ -98,6 +147,20 @@ export function createSessionActivityTracker({
98
147
  },
99
148
  clearTimer = (timer) => clearTimeout(timer),
100
149
  onEdge = () => {},
150
+ // conversationId → whether its live worker reports background work that no
151
+ // task set describes (see the placeholder in `describe`).
152
+ hasUnreportedBackgroundWork = () => false,
153
+ // The conversations that may be true for, so that readers which only ask
154
+ // about the conversations this tracker knows ask about them too.
155
+ listUnreportedBackgroundWork = () => [],
156
+ // (conversationId, runRow) → whether the task set of the conversation's
157
+ // worker lists the run: true, false, or null when the worker has reported
158
+ // no set in this runtime or its sets do not speak for its runs. A
159
+ // `running` background row that is not listed and that nothing has
160
+ // updated for UNLISTED_RUNNING_RUN_MAX_QUIET_MS is a row whose end was
161
+ // never written: it is kept out of the state, as the day-old ones are.
162
+ runListedByWorker = () => null,
163
+ bootReportingWindowMs = BOOT_REPORTING_WINDOW_MS,
101
164
  logger = console,
102
165
  } = {}) {
103
166
  const startedAtMs = now();
@@ -105,6 +168,10 @@ export function createSessionActivityTracker({
105
168
  const sessions = new Map();
106
169
  // Edges already reported for an end, by `${conversationId}:${key}`.
107
170
  const endedKeys = new Map();
171
+ // `${conversationId}:${taskId}` of the ends nobody reported: the task was
172
+ // only missing from a set for the grace. A later set that lists it again
173
+ // says it never ended.
174
+ const endedByAbsence = new Set();
108
175
 
109
176
  let listRuns = null;
110
177
  let getQueueRows = null;
@@ -151,7 +218,10 @@ export function createSessionActivityTracker({
151
218
  }
152
219
  if (endedKeys.size > 2000) {
153
220
  for (const [key, when] of endedKeys) {
154
- if (at - when > RECENT_TASK_TTL_MS) endedKeys.delete(key);
221
+ if (at - when > RECENT_TASK_TTL_MS) {
222
+ endedKeys.delete(key);
223
+ endedByAbsence.delete(key);
224
+ }
155
225
  }
156
226
  }
157
227
  }
@@ -179,7 +249,9 @@ export function createSessionActivityTracker({
179
249
  };
180
250
  }
181
251
 
182
- function finishTask(conversationId, task, { status, summary = null, exitCode = null, reported = true } = {}) {
252
+ // `replaceSummary`: the end as the worker reported it, whose summary (or
253
+ // none) replaces the live row's, which was a progress line.
254
+ function finishTask(conversationId, task, { status, summary = null, exitCode = null, reported = true, absent = false, replaceSummary = false } = {}) {
183
255
  if (task.graceTimer) {
184
256
  clearTimer(task.graceTimer);
185
257
  task.graceTimer = null;
@@ -190,10 +262,12 @@ export function createSessionActivityTracker({
190
262
  task.status = normalizeTaskStatus(status || 'completed');
191
263
  task.statusReported = reported;
192
264
  if (summary) task.summary = toText(summary).slice(0, 500);
265
+ else if (replaceSummary) task.summary = null;
193
266
  const code = Number.isInteger(exitCode) ? exitCode : exitCodeFromSummary(task.summary);
194
267
  task.exitCode = code;
195
268
  task.endedAtMs = now();
196
269
  markEnded(conversationId, task);
270
+ if (absent) endedByAbsence.add(`${conversationId}:${task.taskId}`);
197
271
  entry.recent.unshift(task);
198
272
  entry.recent.length = Math.min(entry.recent.length, RECENT_TASKS_MAX);
199
273
  entry.lastActivityAt = Math.max(entry.lastActivityAt, task.endedAtMs);
@@ -227,11 +301,26 @@ export function createSessionActivityTracker({
227
301
  task.graceTimer = null;
228
302
  const late = runStatus(task.toolUseId);
229
303
  if (late && isTerminalSubagentRunStatus(late)) finishTask(conversationId, task, { status: late });
230
- else finishTask(conversationId, task, { status: 'completed', reported: false });
304
+ else finishTask(conversationId, task, { status: 'completed', reported: false, absent: true });
231
305
  }, TASK_END_GRACE_MS);
232
306
  }
233
307
 
234
- function noteTaskSet(conversationId, tasks) {
308
+ /** Takes back an end that was only an absence; the task's old row goes from the recent ones. */
309
+ function unEnd(conversationId, entry, taskId, toolUseId) {
310
+ endedByAbsence.delete(`${conversationId}:${taskId}`);
311
+ const index = entry.recent.findIndex((task) => task.taskId === taskId);
312
+ const [old] = index >= 0 ? entry.recent.splice(index, 1) : [null];
313
+ for (const key of [taskId, toolUseId ? `run:${toolUseId}` : '', old?.toolUseId ? `run:${old.toolUseId}` : '']) {
314
+ if (key) endedKeys.delete(`${conversationId}:${key}`);
315
+ }
316
+ return old;
317
+ }
318
+
319
+ /**
320
+ * `resync`: the set is the worker's answer to the relay asking for it
321
+ * again, not a change of the set.
322
+ */
323
+ function noteTaskSet(conversationId, tasks, { resync = false } = {}) {
235
324
  const id = toText(conversationId);
236
325
  if (!id) return;
237
326
  const entry = session(id);
@@ -262,15 +351,25 @@ export function createSessionActivityTracker({
262
351
  known.leftAtMs = 0;
263
352
  continue;
264
353
  }
265
- // Settled already (its notice came before the set dropped it).
266
- if (wasEnded(id, { taskId, toolUseId: toText(raw.toolUseId) })) continue;
354
+ let revived = null;
355
+ if (wasEnded(id, { taskId, toolUseId: toText(raw.toolUseId) })) {
356
+ // Settled already (its notice came before the set dropped it).
357
+ if (!endedByAbsence.has(`${id}:${taskId}`)) continue;
358
+ // Ended only because a set did not list it (a set that arrived out
359
+ // of turn, a worker that left it out for a moment) and listed
360
+ // again: it never ended. It comes back as the task it was, with no
361
+ // second start. The end that was reported for it stays reported;
362
+ // its real end will be another.
363
+ revived = unEnd(id, entry, taskId, toText(raw.toolUseId)) || {};
364
+ }
365
+ const taskStartedAtMs = msOf(raw.startedAt);
267
366
  const task = {
268
367
  taskId,
269
368
  toolUseId: toText(raw.toolUseId) || null,
270
369
  taskType: toText(raw.taskType),
271
370
  kind: taskKindOf(raw.taskType),
272
371
  description: toText(raw.description).slice(0, 500),
273
- startedAtMs: msOf(raw.startedAt) || at,
372
+ startedAtMs: taskStartedAtMs || revived?.startedAtMs || at,
274
373
  lastActivityAtMs: at,
275
374
  summary: toText(raw.summary) || null,
276
375
  model: toText(raw.model) || null,
@@ -282,7 +381,12 @@ export function createSessionActivityTracker({
282
381
  };
283
382
  entry.live.set(taskId, task);
284
383
  entry.lastActivityAt = at;
285
- emitEdge('task_started', id, task);
384
+ // A task older than this runtime was announced by the one before it,
385
+ // whichever post brings it back (the resync, or a progress update that
386
+ // overtakes it). One that started since is new even in a resync: its
387
+ // first post was lost. Without a start time a resync decides.
388
+ const announcedBefore = taskStartedAtMs ? taskStartedAtMs < startedAtMs : resync;
389
+ if (!announcedBefore && !revived) emitEdge('task_started', id, task);
286
390
  }
287
391
  for (const task of [...entry.live.values()]) {
288
392
  if (seen.has(task.taskId) || task.leftAtMs) continue;
@@ -290,7 +394,9 @@ export function createSessionActivityTracker({
290
394
  }
291
395
  }
292
396
 
293
- function noteTaskSettled(conversationId, { taskId, toolUseId, status, summary, exitCode } = {}) {
397
+ function noteTaskSettled(conversationId, {
398
+ taskId, toolUseId, status, summary, exitCode, description, taskType, startedAt,
399
+ } = {}) {
294
400
  const id = toText(conversationId);
295
401
  if (!id) return;
296
402
  const entry = session(id);
@@ -303,15 +409,72 @@ export function createSessionActivityTracker({
303
409
  status: normalizeTaskStatus(status || 'completed'),
304
410
  summary: toText(summary) || null,
305
411
  exitCode: Number.isInteger(exitCode) ? exitCode : null,
412
+ replaceSummary: true,
306
413
  };
307
414
  entry.lastActivityAt = now();
308
- if (!task) return;
415
+ if (!task && key && endedByAbsence.delete(`${id}:${key}`)) {
416
+ // It was taken for ended when a set no longer listed it; this is the
417
+ // word on how it ended. A real end is final: no later set brings the
418
+ // task back.
419
+ const ended = entry.recent.find((candidate) => candidate.taskId === key);
420
+ if (ended) {
421
+ ended.status = settled.status;
422
+ ended.statusReported = true;
423
+ ended.summary = settled.summary ? settled.summary.slice(0, 500) : null;
424
+ ended.exitCode = settled.exitCode ?? exitCodeFromSummary(ended.summary);
425
+ }
426
+ return;
427
+ }
428
+ if (!task) {
429
+ // A task this runtime never knew (it ended while the relay was away, or
430
+ // its set never arrived): the end is still an event when the worker
431
+ // says what the task was. Once only, like every end: a sub-agent's
432
+ // end also comes as its run's status, so the notice is paired with
433
+ // the run that carries its task id when it names no run. (With no
434
+ // such link the two are two ends; rare, and better than a guess.)
435
+ if (!key || !toText(description)) return;
436
+ const runId = run || (taskKindOf(taskType) === 'subagent'
437
+ ? runOfUnknownTask(id, { taskId: key })
438
+ : '');
439
+ const unknown = {
440
+ taskId: key,
441
+ toolUseId: runId || null,
442
+ taskType: toText(taskType),
443
+ kind: taskKindOf(taskType),
444
+ description: toText(description).slice(0, 500),
445
+ startedAtMs: msOf(startedAt) || now(),
446
+ lastActivityAtMs: now(),
447
+ summary: null,
448
+ model: null,
449
+ status: 'running',
450
+ };
451
+ finishTask(id, unknown, settled);
452
+ return;
453
+ }
309
454
  task.settled = settled;
310
455
  // Gone from the set already, or about to: settle now either way. A task
311
456
  // still listed would leave with this status anyway.
312
457
  finishTask(id, task, settled);
313
458
  }
314
459
 
460
+ /**
461
+ * The background run a described settle notice without a run id speaks
462
+ * of: the run that carries the task id. Nothing looser: a run picked by
463
+ * name or by start time can be another agent's, whose own end would then
464
+ * never be reported.
465
+ */
466
+ function runOfUnknownTask(conversationId, { taskId }) {
467
+ if (!listRuns) return '';
468
+ let rows = [];
469
+ try {
470
+ rows = listRuns.all(conversationId, new Date(now() - RECENT_TASK_TTL_MS).toISOString());
471
+ } catch {
472
+ return '';
473
+ }
474
+ const match = rows.find((row) => Number(row.background) === 1 && toText(row.task_id) === taskId);
475
+ return match ? toText(match.id) : '';
476
+ }
477
+
315
478
  function noteSubagentRun(run) {
316
479
  const id = toText(run?.conversation_id || run?.conversationId);
317
480
  const runId = toText(run?.id || run?.subagentRunId);
@@ -320,7 +483,21 @@ export function createSessionActivityTracker({
320
483
  entry.lastActivityAt = now();
321
484
  const status = normalizeSubagentRunStatus(run?.status);
322
485
  if (!isTerminalSubagentRunStatus(status)) return;
323
- const task = [...entry.live.values()].find((candidate) => candidate.toolUseId === runId) || null;
486
+ const runTaskId = toText(run?.task_id || run?.taskId);
487
+ const task = [...entry.live.values()].find((candidate) => candidate.toolUseId === runId)
488
+ || (runTaskId && entry.live.get(runTaskId))
489
+ || null;
490
+ if (task) {
491
+ task.toolUseId = task.toolUseId || runId;
492
+ } else {
493
+ // The run of a task that was taken for ended by its absence: its end
494
+ // is real now, and final.
495
+ const ended = entry.recent.find((candidate) => candidate.toolUseId === runId || (runTaskId && candidate.taskId === runTaskId));
496
+ if (ended && endedByAbsence.delete(`${id}:${ended.taskId}`)) {
497
+ ended.status = normalizeTaskStatus(status);
498
+ ended.statusReported = true;
499
+ }
500
+ }
324
501
  if (task) {
325
502
  // The run's end is the task's: finish it once it has left the set,
326
503
  // which the grace timer is waiting for; a task still listed keeps
@@ -410,8 +587,13 @@ export function createSessionActivityTracker({
410
587
  return [];
411
588
  }
412
589
  const staleBefore = now() - STALE_RUNNING_RUN_MAX_AGE_MS;
590
+ const quietBefore = now() - UNLISTED_RUNNING_RUN_MAX_QUIET_MS;
591
+ const unlisted = (row) => {
592
+ if (Number(row.background) !== 1 || msOf(row.updated_at || row.started_at) >= quietBefore) return false;
593
+ try { return runListedByWorker(conversationId, row) === false; } catch { return false; }
594
+ };
413
595
  const staleRunning = (row) => normalizeTaskStatus(row.status) === 'running'
414
- && msOf(row.updated_at || row.started_at) < staleBefore;
596
+ && (msOf(row.updated_at || row.started_at) < staleBefore || unlisted(row));
415
597
  return rows
416
598
  .filter((row) => !coveredRunIds.has(toText(row.id)) && !staleRunning(row))
417
599
  .map((row) => {
@@ -449,8 +631,23 @@ export function createSessionActivityTracker({
449
631
  .map((task) => toText(task.toolUseId))
450
632
  .filter(Boolean));
451
633
  const fromRuns = runTasks(id, covered);
634
+ const runningRuns = fromRuns.filter((task) => task.status === 'running' && !task.inTurn);
635
+ let unreported = false;
636
+ if (!live.length && !runningRuns.length) {
637
+ try { unreported = hasUnreportedBackgroundWork(id) === true; } catch {}
638
+ }
452
639
  const tasks = [
453
640
  ...live.map(publicTask),
641
+ ...(unreported ? [{
642
+ id: UNREPORTED_BACKGROUND_TASK_ID,
643
+ kind: 'unknown',
644
+ type: null,
645
+ description: UNREPORTED_BACKGROUND_TASK_DESCRIPTION,
646
+ startedAt: null,
647
+ lastActivityAt: null,
648
+ status: 'running',
649
+ placeholder: true,
650
+ }] : []),
454
651
  ...fromRuns.filter((task) => task.status === 'running'),
455
652
  ...[...recent.map(publicTask), ...fromRuns.filter((task) => task.status !== 'running')]
456
653
  .sort((a, b) => msOf(b.endedAt) - msOf(a.endedAt))
@@ -471,7 +668,21 @@ export function createSessionActivityTracker({
471
668
  }
472
669
 
473
670
  function knownConversationIds() {
474
- return [...sessions.keys()];
671
+ const ids = new Set(sessions.keys());
672
+ try {
673
+ for (const id of listUnreportedBackgroundWork() || []) if (toText(id)) ids.add(toText(id));
674
+ } catch {}
675
+ return [...ids];
676
+ }
677
+
678
+ /**
679
+ * `{ active, ageMs, remainingMs }`: whether this runtime is young enough
680
+ * that workers which outlived the one before it may not have reported yet.
681
+ */
682
+ function bootReporting() {
683
+ const ageMs = Math.max(0, now() - startedAtMs);
684
+ const remainingMs = Math.max(0, bootReportingWindowMs - ageMs);
685
+ return { active: remainingMs > 0, ageMs, remainingMs };
475
686
  }
476
687
 
477
688
  return {
@@ -483,5 +694,6 @@ export function createSessionActivityTracker({
483
694
  forget,
484
695
  describe,
485
696
  knownConversationIds,
697
+ bootReporting,
486
698
  };
487
699
  }