@oar-sh/oar 0.9.9 → 0.9.11

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 (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -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 +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -0,0 +1,695 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * What each session of this relay is doing right now: a turn, background
5
+ * tasks only, or nothing, with the tasks themselves and when anything last
6
+ * moved.
7
+ *
8
+ * `list_sessions` / `read_session` read it as `state` and `tasks[]`; the
9
+ * relay event service reads its edges (a task started or ended) and its quiet
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).
23
+ *
24
+ * Sources:
25
+ * - `noteTaskSet` — every POST /api/background-tasks (REPLACE semantics):
26
+ * a new task id is a start, a task gone from the set has ended, a changed
27
+ * task (tool call, summary, tokens) is activity. A task that started
28
+ * before this runtime did was announced by the runtime before it.
29
+ * - `noteTaskSettled` — how a task ended, where the worker says so (the
30
+ * Claude worker's task_notification). A task this runtime never knew
31
+ * still gets its end event when the worker describes it.
32
+ * - `noteSubagentRun` — every sub-agent run status the relay stores; a
33
+ * background run's terminal status is how its task ended, and a
34
+ * background run with no task in the set (Copilot, Cursor) ends here.
35
+ * - `touch` — any activity, thought, stream or timeline post.
36
+ */
37
+
38
+ import { isTerminalSubagentRunStatus, normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
39
+
40
+ export const SESSION_STATES = Object.freeze(['running_turn', 'background_only', 'idle']);
41
+
42
+ // How long a task gone from the set waits for the word on how it ended.
43
+ export const TASK_END_GRACE_MS = 5_000;
44
+ // A `running` run row not touched for this long is not running work: its
45
+ // worker's record of its end never arrived (live: rows months old still
46
+ // `running`, counted as a running task by every reader, so a subscription
47
+ // to all sessions never saw all_idle and reported the session stalled
48
+ // again and again). The sweep in stale-subagent-run-sweep.mjs closes such
49
+ // rows; this bound keeps them out of the state meanwhile.
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;
89
+ const RECENT_TASKS_MAX = 5;
90
+ const RECENT_TASK_TTL_MS = 60 * 60_000;
91
+ const SHELL_TASK_TYPES = new Set(['local_bash', 'bash', 'shell', 'monitor', 'local_monitor']);
92
+ const EXIT_CODE_PATTERN = /exit(?:ed)?(?: with)?(?: code| status)?[:\s]+(-?\d{1,3})\b/i;
93
+
94
+ function toText(value) {
95
+ return String(value ?? '').trim();
96
+ }
97
+
98
+ function isoOf(ms) {
99
+ return Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : null;
100
+ }
101
+
102
+ function msOf(value) {
103
+ if (value === null || value === undefined || value === '') return 0;
104
+ const number = Number(value);
105
+ if (Number.isFinite(number) && number > 0) return number;
106
+ const parsed = Date.parse(String(value));
107
+ return Number.isFinite(parsed) ? parsed : 0;
108
+ }
109
+
110
+ export function taskKindOf(taskType) {
111
+ return SHELL_TASK_TYPES.has(toText(taskType).toLowerCase()) ? 'shell' : 'subagent';
112
+ }
113
+
114
+ /** An exit code a task summary names ("Exit code 2", "exited with 1"), else null. */
115
+ export function exitCodeFromSummary(summary) {
116
+ const match = EXIT_CODE_PATTERN.exec(String(summary || ''));
117
+ if (!match) return null;
118
+ const code = Number(match[1]);
119
+ return Number.isInteger(code) ? code : null;
120
+ }
121
+
122
+ /** The status words of a task: running, completed, failed, stopped. */
123
+ export function normalizeTaskStatus(status) {
124
+ const value = normalizeSubagentRunStatus(status);
125
+ if (value === 'cancelled') return 'stopped';
126
+ if (value === 'running' || value === 'completed' || value === 'failed') return value;
127
+ return value ? 'completed' : 'running';
128
+ }
129
+
130
+ function taskSignature(task) {
131
+ return JSON.stringify([
132
+ task.summary || null,
133
+ task.lastToolName || null,
134
+ task.lastToolCall || null,
135
+ task.totalTokens ?? null,
136
+ task.workflowProgress ? JSON.stringify(task.workflowProgress).length : 0,
137
+ ]);
138
+ }
139
+
140
+ export function createSessionActivityTracker({
141
+ db = null,
142
+ now = () => Date.now(),
143
+ setTimer = (fn, ms) => {
144
+ const timer = setTimeout(fn, ms);
145
+ timer.unref?.();
146
+ return timer;
147
+ },
148
+ clearTimer = (timer) => clearTimeout(timer),
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,
164
+ logger = console,
165
+ } = {}) {
166
+ const startedAtMs = now();
167
+ // conversationId → { live: Map<taskId, entry>, recent: [], lastActivityAt }
168
+ const sessions = new Map();
169
+ // Edges already reported for an end, by `${conversationId}:${key}`.
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();
175
+
176
+ let listRuns = null;
177
+ let getQueueRows = null;
178
+ if (db) {
179
+ try {
180
+ listRuns = db.prepare(`
181
+ SELECT * FROM subagent_runs
182
+ WHERE conversation_id = ? AND (status = 'running' OR COALESCE(completed_at, updated_at) >= ?)
183
+ AND parent_subagent_id IS NULL
184
+ ORDER BY started_at ASC
185
+ LIMIT 50
186
+ `);
187
+ } catch {}
188
+ try {
189
+ getQueueRows = db.prepare(`
190
+ SELECT id, status, timestamp, processing_at, next_attempt_at, usage_limit_pause
191
+ FROM queue WHERE conversation_id = ? AND status IN ('pending', 'processing', 'parked')
192
+ `);
193
+ } catch {}
194
+ }
195
+
196
+ function session(conversationId) {
197
+ const id = toText(conversationId);
198
+ let entry = sessions.get(id);
199
+ if (!entry) {
200
+ entry = { live: new Map(), recent: [], lastActivityAt: 0 };
201
+ sessions.set(id, entry);
202
+ }
203
+ return entry;
204
+ }
205
+
206
+ function emitEdge(type, conversationId, task) {
207
+ try {
208
+ onEdge({ type, conversationId, task: publicTask(task) });
209
+ } catch (error) {
210
+ try { logger?.warn?.(`[activity] edge handler failed: ${error?.message || error}`); } catch {}
211
+ }
212
+ }
213
+
214
+ function markEnded(conversationId, task) {
215
+ const at = now();
216
+ for (const key of [task.taskId, task.toolUseId ? `run:${task.toolUseId}` : '']) {
217
+ if (key) endedKeys.set(`${conversationId}:${key}`, at);
218
+ }
219
+ if (endedKeys.size > 2000) {
220
+ for (const [key, when] of endedKeys) {
221
+ if (at - when > RECENT_TASK_TTL_MS) {
222
+ endedKeys.delete(key);
223
+ endedByAbsence.delete(key);
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ function wasEnded(conversationId, task) {
230
+ return [task.taskId, task.toolUseId ? `run:${task.toolUseId}` : '']
231
+ .some((key) => key && endedKeys.has(`${conversationId}:${key}`));
232
+ }
233
+
234
+ function publicTask(task) {
235
+ return {
236
+ id: task.taskId || task.toolUseId || null,
237
+ kind: task.kind,
238
+ type: task.taskType || null,
239
+ description: task.description || '',
240
+ startedAt: isoOf(task.startedAtMs),
241
+ lastActivityAt: isoOf(task.lastActivityAtMs || task.startedAtMs),
242
+ status: task.status,
243
+ ...(task.exitCode !== null && task.exitCode !== undefined ? { exitCode: task.exitCode } : {}),
244
+ ...(task.status !== 'running' && task.statusReported === false ? { statusReported: false } : {}),
245
+ ...(task.summary ? { summary: task.summary } : {}),
246
+ ...(task.endedAtMs ? { endedAt: isoOf(task.endedAtMs) } : {}),
247
+ ...(task.toolUseId ? { runId: task.toolUseId } : {}),
248
+ ...(task.model ? { model: task.model } : {}),
249
+ };
250
+ }
251
+
252
+ function finishTask(conversationId, task, { status, summary = null, exitCode = null, reported = true, absent = false } = {}) {
253
+ if (task.graceTimer) {
254
+ clearTimer(task.graceTimer);
255
+ task.graceTimer = null;
256
+ }
257
+ const entry = session(conversationId);
258
+ entry.live.delete(task.taskId);
259
+ if (wasEnded(conversationId, task)) return;
260
+ task.status = normalizeTaskStatus(status || 'completed');
261
+ task.statusReported = reported;
262
+ if (summary) task.summary = toText(summary).slice(0, 500);
263
+ const code = Number.isInteger(exitCode) ? exitCode : exitCodeFromSummary(task.summary);
264
+ task.exitCode = code;
265
+ task.endedAtMs = now();
266
+ markEnded(conversationId, task);
267
+ if (absent) endedByAbsence.add(`${conversationId}:${task.taskId}`);
268
+ entry.recent.unshift(task);
269
+ entry.recent.length = Math.min(entry.recent.length, RECENT_TASKS_MAX);
270
+ entry.lastActivityAt = Math.max(entry.lastActivityAt, task.endedAtMs);
271
+ const failed = task.status === 'failed' || task.status === 'stopped' || (code !== null && code !== 0);
272
+ emitEdge(failed ? 'task_failed' : 'task_finished', conversationId, task);
273
+ }
274
+
275
+ function runStatus(runId) {
276
+ if (!db || !runId) return null;
277
+ try {
278
+ const row = db.prepare(`SELECT status FROM subagent_runs WHERE id = ?`).get(runId);
279
+ return row ? normalizeSubagentRunStatus(row.status) : null;
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+
285
+ /** A task left the set: settle it now if its end is known, else after the grace. */
286
+ function taskLeft(conversationId, task) {
287
+ if (task.settled) {
288
+ finishTask(conversationId, task, task.settled);
289
+ return;
290
+ }
291
+ const status = runStatus(task.toolUseId);
292
+ if (status && isTerminalSubagentRunStatus(status)) {
293
+ finishTask(conversationId, task, { status });
294
+ return;
295
+ }
296
+ task.leftAtMs = now();
297
+ task.graceTimer = setTimer(() => {
298
+ task.graceTimer = null;
299
+ const late = runStatus(task.toolUseId);
300
+ if (late && isTerminalSubagentRunStatus(late)) finishTask(conversationId, task, { status: late });
301
+ else finishTask(conversationId, task, { status: 'completed', reported: false, absent: true });
302
+ }, TASK_END_GRACE_MS);
303
+ }
304
+
305
+ /** Takes back an end that was only an absence; the task's old row goes from the recent ones. */
306
+ function unEnd(conversationId, entry, taskId, toolUseId) {
307
+ endedByAbsence.delete(`${conversationId}:${taskId}`);
308
+ const index = entry.recent.findIndex((task) => task.taskId === taskId);
309
+ const [old] = index >= 0 ? entry.recent.splice(index, 1) : [null];
310
+ for (const key of [taskId, toolUseId ? `run:${toolUseId}` : '', old?.toolUseId ? `run:${old.toolUseId}` : '']) {
311
+ if (key) endedKeys.delete(`${conversationId}:${key}`);
312
+ }
313
+ return old;
314
+ }
315
+
316
+ /**
317
+ * `resync`: the set is the worker's answer to the relay asking for it
318
+ * again, not a change of the set.
319
+ */
320
+ function noteTaskSet(conversationId, tasks, { resync = false } = {}) {
321
+ const id = toText(conversationId);
322
+ if (!id) return;
323
+ const entry = session(id);
324
+ const at = now();
325
+ const seen = new Set();
326
+ for (const raw of Array.isArray(tasks) ? tasks : []) {
327
+ const taskId = toText(raw?.taskId);
328
+ if (!taskId) continue;
329
+ seen.add(taskId);
330
+ const signature = taskSignature(raw);
331
+ const known = entry.live.get(taskId);
332
+ if (known && !known.leftAtMs) {
333
+ if (known.signature !== signature) {
334
+ known.signature = signature;
335
+ known.lastActivityAtMs = at;
336
+ entry.lastActivityAt = at;
337
+ }
338
+ known.description = toText(raw.description) || known.description;
339
+ known.toolUseId = toText(raw.toolUseId) || known.toolUseId;
340
+ known.summary = toText(raw.summary) || known.summary;
341
+ known.model = toText(raw.model) || known.model;
342
+ continue;
343
+ }
344
+ if (known?.leftAtMs) {
345
+ // Back in the set after it left: it did not end after all.
346
+ if (known.graceTimer) clearTimer(known.graceTimer);
347
+ known.graceTimer = null;
348
+ known.leftAtMs = 0;
349
+ continue;
350
+ }
351
+ let revived = null;
352
+ if (wasEnded(id, { taskId, toolUseId: toText(raw.toolUseId) })) {
353
+ // Settled already (its notice came before the set dropped it).
354
+ if (!endedByAbsence.has(`${id}:${taskId}`)) continue;
355
+ // Ended only because a set did not list it (a set that arrived out
356
+ // of turn, a worker that left it out for a moment) and listed
357
+ // again: it never ended. It comes back as the task it was, with no
358
+ // second start. The end that was reported for it stays reported;
359
+ // its real end will be another.
360
+ revived = unEnd(id, entry, taskId, toText(raw.toolUseId)) || {};
361
+ }
362
+ const taskStartedAtMs = msOf(raw.startedAt);
363
+ const task = {
364
+ taskId,
365
+ toolUseId: toText(raw.toolUseId) || null,
366
+ taskType: toText(raw.taskType),
367
+ kind: taskKindOf(raw.taskType),
368
+ description: toText(raw.description).slice(0, 500),
369
+ startedAtMs: taskStartedAtMs || revived?.startedAtMs || at,
370
+ lastActivityAtMs: at,
371
+ summary: toText(raw.summary) || null,
372
+ model: toText(raw.model) || null,
373
+ status: 'running',
374
+ signature,
375
+ settled: null,
376
+ graceTimer: null,
377
+ leftAtMs: 0,
378
+ };
379
+ entry.live.set(taskId, task);
380
+ entry.lastActivityAt = at;
381
+ // A task older than this runtime was announced by the one before it,
382
+ // whichever post brings it back (the resync, or a progress update that
383
+ // overtakes it). One that started since is new even in a resync: its
384
+ // first post was lost. Without a start time a resync decides.
385
+ const announcedBefore = taskStartedAtMs ? taskStartedAtMs < startedAtMs : resync;
386
+ if (!announcedBefore && !revived) emitEdge('task_started', id, task);
387
+ }
388
+ for (const task of [...entry.live.values()]) {
389
+ if (seen.has(task.taskId) || task.leftAtMs) continue;
390
+ taskLeft(id, task);
391
+ }
392
+ }
393
+
394
+ function noteTaskSettled(conversationId, {
395
+ taskId, toolUseId, status, summary, exitCode, description, taskType, startedAt,
396
+ } = {}) {
397
+ const id = toText(conversationId);
398
+ if (!id) return;
399
+ const entry = session(id);
400
+ const key = toText(taskId);
401
+ const run = toText(toolUseId);
402
+ const task = (key && entry.live.get(key))
403
+ || [...entry.live.values()].find((candidate) => run && candidate.toolUseId === run)
404
+ || null;
405
+ const settled = {
406
+ status: normalizeTaskStatus(status || 'completed'),
407
+ summary: toText(summary) || null,
408
+ exitCode: Number.isInteger(exitCode) ? exitCode : null,
409
+ };
410
+ entry.lastActivityAt = now();
411
+ if (!task && key && endedByAbsence.delete(`${id}:${key}`)) {
412
+ // It was taken for ended when a set no longer listed it; this is the
413
+ // word on how it ended. A real end is final: no later set brings the
414
+ // task back.
415
+ const ended = entry.recent.find((candidate) => candidate.taskId === key);
416
+ if (ended) {
417
+ ended.status = settled.status;
418
+ ended.statusReported = true;
419
+ if (settled.summary) ended.summary = settled.summary.slice(0, 500);
420
+ ended.exitCode = settled.exitCode ?? exitCodeFromSummary(ended.summary);
421
+ }
422
+ return;
423
+ }
424
+ if (!task) {
425
+ // A task this runtime never knew (it ended while the relay was away, or
426
+ // its set never arrived): the end is still an event when the worker
427
+ // says what the task was. Once only, like every end: a sub-agent's
428
+ // end also comes as its run's status, so the notice is paired with
429
+ // the run that carries its task id when it names no run. (With no
430
+ // such link the two are two ends; rare, and better than a guess.)
431
+ if (!key || !toText(description)) return;
432
+ const runId = run || (taskKindOf(taskType) === 'subagent'
433
+ ? runOfUnknownTask(id, { taskId: key })
434
+ : '');
435
+ const unknown = {
436
+ taskId: key,
437
+ toolUseId: runId || null,
438
+ taskType: toText(taskType),
439
+ kind: taskKindOf(taskType),
440
+ description: toText(description).slice(0, 500),
441
+ startedAtMs: msOf(startedAt) || now(),
442
+ lastActivityAtMs: now(),
443
+ summary: null,
444
+ model: null,
445
+ status: 'running',
446
+ };
447
+ finishTask(id, unknown, settled);
448
+ return;
449
+ }
450
+ task.settled = settled;
451
+ // Gone from the set already, or about to: settle now either way. A task
452
+ // still listed would leave with this status anyway.
453
+ finishTask(id, task, settled);
454
+ }
455
+
456
+ /**
457
+ * The background run a described settle notice without a run id speaks
458
+ * of: the run that carries the task id. Nothing looser: a run picked by
459
+ * name or by start time can be another agent's, whose own end would then
460
+ * never be reported.
461
+ */
462
+ function runOfUnknownTask(conversationId, { taskId }) {
463
+ if (!listRuns) return '';
464
+ let rows = [];
465
+ try {
466
+ rows = listRuns.all(conversationId, new Date(now() - RECENT_TASK_TTL_MS).toISOString());
467
+ } catch {
468
+ return '';
469
+ }
470
+ const match = rows.find((row) => Number(row.background) === 1 && toText(row.task_id) === taskId);
471
+ return match ? toText(match.id) : '';
472
+ }
473
+
474
+ function noteSubagentRun(run) {
475
+ const id = toText(run?.conversation_id || run?.conversationId);
476
+ const runId = toText(run?.id || run?.subagentRunId);
477
+ if (!id || !runId) return;
478
+ const entry = session(id);
479
+ entry.lastActivityAt = now();
480
+ const status = normalizeSubagentRunStatus(run?.status);
481
+ if (!isTerminalSubagentRunStatus(status)) return;
482
+ const runTaskId = toText(run?.task_id || run?.taskId);
483
+ const task = [...entry.live.values()].find((candidate) => candidate.toolUseId === runId)
484
+ || (runTaskId && entry.live.get(runTaskId))
485
+ || null;
486
+ if (task) {
487
+ task.toolUseId = task.toolUseId || runId;
488
+ } else {
489
+ // The run of a task that was taken for ended by its absence: its end
490
+ // is real now, and final.
491
+ const ended = entry.recent.find((candidate) => candidate.toolUseId === runId || (runTaskId && candidate.taskId === runTaskId));
492
+ if (ended && endedByAbsence.delete(`${id}:${ended.taskId}`)) {
493
+ ended.status = normalizeTaskStatus(status);
494
+ ended.statusReported = true;
495
+ }
496
+ }
497
+ if (task) {
498
+ // The run's end is the task's: finish it once it has left the set,
499
+ // which the grace timer is waiting for; a task still listed keeps
500
+ // waiting for its own departure.
501
+ if (task.leftAtMs) finishTask(id, task, { status });
502
+ else task.settled = task.settled || { status };
503
+ return;
504
+ }
505
+ const background = Number(run?.background) === 1 || run?.background === true;
506
+ if (!background || toText(run?.parent_subagent_id || run?.parentSubagentId)) return;
507
+ const synthetic = {
508
+ taskId: `run:${runId}`,
509
+ toolUseId: runId,
510
+ taskType: toText(run?.subagent_type) || 'agent',
511
+ kind: 'subagent',
512
+ description: toText(run?.display_name || run?.displayName) || 'sub-agent',
513
+ startedAtMs: msOf(run?.started_at) || now(),
514
+ lastActivityAtMs: now(),
515
+ summary: null,
516
+ model: toText(run?.model) || null,
517
+ status: 'running',
518
+ };
519
+ if (wasEnded(id, synthetic)) return;
520
+ finishTask(id, synthetic, { status });
521
+ }
522
+
523
+ function touch(conversationId) {
524
+ const id = toText(conversationId);
525
+ if (!id) return;
526
+ session(id).lastActivityAt = now();
527
+ }
528
+
529
+ function forget(conversationId) {
530
+ const id = toText(conversationId);
531
+ const entry = sessions.get(id);
532
+ if (!entry) return;
533
+ for (const task of entry.live.values()) {
534
+ if (task.graceTimer) clearTimer(task.graceTimer);
535
+ }
536
+ sessions.delete(id);
537
+ }
538
+
539
+ /** The worker died: its tasks went with it, with no edge (the error says it). */
540
+ function noteWorkerDied(conversationId) {
541
+ const id = toText(conversationId);
542
+ const entry = sessions.get(id);
543
+ if (!entry) return;
544
+ for (const task of [...entry.live.values()]) {
545
+ if (task.graceTimer) clearTimer(task.graceTimer);
546
+ entry.live.delete(task.taskId);
547
+ task.status = 'stopped';
548
+ task.statusReported = false;
549
+ task.endedAtMs = now();
550
+ markEnded(id, task);
551
+ entry.recent.unshift(task);
552
+ }
553
+ entry.recent.length = Math.min(entry.recent.length, RECENT_TASKS_MAX);
554
+ }
555
+
556
+ function turnState(conversationId) {
557
+ if (!getQueueRows) return { running: false, sinceMs: 0 };
558
+ let rows = [];
559
+ try {
560
+ rows = getQueueRows.all(conversationId);
561
+ } catch {
562
+ return { running: false, sinceMs: 0 };
563
+ }
564
+ const at = now();
565
+ let running = false;
566
+ let sinceMs = 0;
567
+ for (const row of rows) {
568
+ const held = row.usage_limit_pause && msOf(row.next_attempt_at) > at;
569
+ if (held) continue;
570
+ running = true;
571
+ const since = msOf(row.processing_at) || msOf(row.timestamp);
572
+ sinceMs = Math.max(sinceMs, since);
573
+ }
574
+ return { running, sinceMs };
575
+ }
576
+
577
+ function runTasks(conversationId, coveredRunIds) {
578
+ if (!listRuns) return [];
579
+ let rows = [];
580
+ try {
581
+ rows = listRuns.all(conversationId, new Date(now() - RECENT_TASK_TTL_MS).toISOString());
582
+ } catch {
583
+ return [];
584
+ }
585
+ const staleBefore = now() - STALE_RUNNING_RUN_MAX_AGE_MS;
586
+ const quietBefore = now() - UNLISTED_RUNNING_RUN_MAX_QUIET_MS;
587
+ const unlisted = (row) => {
588
+ if (Number(row.background) !== 1 || msOf(row.updated_at || row.started_at) >= quietBefore) return false;
589
+ try { return runListedByWorker(conversationId, row) === false; } catch { return false; }
590
+ };
591
+ const staleRunning = (row) => normalizeTaskStatus(row.status) === 'running'
592
+ && (msOf(row.updated_at || row.started_at) < staleBefore || unlisted(row));
593
+ return rows
594
+ .filter((row) => !coveredRunIds.has(toText(row.id)) && !staleRunning(row))
595
+ .map((row) => {
596
+ const status = normalizeTaskStatus(row.status);
597
+ return {
598
+ id: toText(row.task_id) || toText(row.id),
599
+ kind: 'subagent',
600
+ type: toText(row.subagent_type) || null,
601
+ description: toText(row.display_name) || 'sub-agent',
602
+ startedAt: row.started_at || null,
603
+ lastActivityAt: row.updated_at || row.started_at || null,
604
+ status,
605
+ ...(status !== 'running' && row.completed_at ? { endedAt: row.completed_at } : {}),
606
+ runId: toText(row.id),
607
+ ...(row.model ? { model: row.model } : {}),
608
+ ...(Number(row.background) === 1 ? {} : { inTurn: true }),
609
+ };
610
+ });
611
+ }
612
+
613
+ /**
614
+ * `{ state, tasks, lastActivityAt, turnSince }` of one conversation.
615
+ * `tasks` lists the running ones, then up to five that ended within the
616
+ * hour, newest first.
617
+ */
618
+ function describe(conversationId) {
619
+ const id = toText(conversationId);
620
+ const entry = sessions.get(id) || null;
621
+ const at = now();
622
+ const live = entry ? [...entry.live.values()].filter((task) => !task.leftAtMs) : [];
623
+ const recent = entry
624
+ ? entry.recent.filter((task) => at - (task.endedAtMs || 0) <= RECENT_TASK_TTL_MS)
625
+ : [];
626
+ const covered = new Set([...live, ...recent, ...(entry ? [...entry.live.values()] : [])]
627
+ .map((task) => toText(task.toolUseId))
628
+ .filter(Boolean));
629
+ const fromRuns = runTasks(id, covered);
630
+ const runningRuns = fromRuns.filter((task) => task.status === 'running' && !task.inTurn);
631
+ let unreported = false;
632
+ if (!live.length && !runningRuns.length) {
633
+ try { unreported = hasUnreportedBackgroundWork(id) === true; } catch {}
634
+ }
635
+ const tasks = [
636
+ ...live.map(publicTask),
637
+ ...(unreported ? [{
638
+ id: UNREPORTED_BACKGROUND_TASK_ID,
639
+ kind: 'unknown',
640
+ type: null,
641
+ description: UNREPORTED_BACKGROUND_TASK_DESCRIPTION,
642
+ startedAt: null,
643
+ lastActivityAt: null,
644
+ status: 'running',
645
+ placeholder: true,
646
+ }] : []),
647
+ ...fromRuns.filter((task) => task.status === 'running'),
648
+ ...[...recent.map(publicTask), ...fromRuns.filter((task) => task.status !== 'running')]
649
+ .sort((a, b) => msOf(b.endedAt) - msOf(a.endedAt))
650
+ .slice(0, RECENT_TASKS_MAX),
651
+ ];
652
+ const turn = turnState(id);
653
+ const runningTasks = tasks.filter((task) => task.status === 'running');
654
+ const state = turn.running ? 'running_turn' : (runningTasks.length ? 'background_only' : 'idle');
655
+ const taskActivity = Math.max(0, ...runningTasks.map((task) => msOf(task.lastActivityAt)));
656
+ const lastActivityMs = Math.max(entry?.lastActivityAt || 0, taskActivity, turn.sinceMs, startedAtMs);
657
+ return {
658
+ state,
659
+ tasks,
660
+ runningTaskCount: runningTasks.length,
661
+ lastActivityAt: isoOf(lastActivityMs),
662
+ turnSince: isoOf(turn.sinceMs),
663
+ };
664
+ }
665
+
666
+ function knownConversationIds() {
667
+ const ids = new Set(sessions.keys());
668
+ try {
669
+ for (const id of listUnreportedBackgroundWork() || []) if (toText(id)) ids.add(toText(id));
670
+ } catch {}
671
+ return [...ids];
672
+ }
673
+
674
+ /**
675
+ * `{ active, ageMs, remainingMs }`: whether this runtime is young enough
676
+ * that workers which outlived the one before it may not have reported yet.
677
+ */
678
+ function bootReporting() {
679
+ const ageMs = Math.max(0, now() - startedAtMs);
680
+ const remainingMs = Math.max(0, bootReportingWindowMs - ageMs);
681
+ return { active: remainingMs > 0, ageMs, remainingMs };
682
+ }
683
+
684
+ return {
685
+ noteTaskSet,
686
+ noteTaskSettled,
687
+ noteSubagentRun,
688
+ noteWorkerDied,
689
+ touch,
690
+ forget,
691
+ describe,
692
+ knownConversationIds,
693
+ bootReporting,
694
+ };
695
+ }