@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,572 @@
1
+ import crypto from 'node:crypto';
2
+ import { displayToolName, isBackgroundLaunch, isSubagentToolName } from './sdk-message-normalizer.mjs';
3
+ import {
4
+ SUBAGENT_TIMELINE_ENTRY_MAX_CHARS,
5
+ SUBAGENT_TIMELINE_MAX_ENTRIES,
6
+ cutSubagentTimelineText,
7
+ normalizeSubagentRunStatus,
8
+ } from '../../shared/subagent-timeline.mjs';
9
+
10
+ const MAX_TRACKED_RUNS = 200;
11
+
12
+ export function toolResultText(content) {
13
+ if (typeof content === 'string') return content;
14
+ if (!Array.isArray(content)) return content === null || content === undefined ? '' : String(content);
15
+ return content.map((part) => {
16
+ if (typeof part === 'string') return part;
17
+ if (part?.type === 'text') return String(part.text || '');
18
+ if (part?.type === 'image') return '[image]';
19
+ return part?.type ? `[${part.type}]` : '';
20
+ }).filter(Boolean).join('\n');
21
+ }
22
+
23
+ function stringifyInput(input) {
24
+ if (input === undefined || input === null) return '';
25
+ if (typeof input === 'string') return input;
26
+ try {
27
+ return JSON.stringify(input, null, 2);
28
+ } catch {
29
+ return String(input);
30
+ }
31
+ }
32
+
33
+ function capText(text) {
34
+ const value = String(text ?? '');
35
+ return value.length > SUBAGENT_TIMELINE_ENTRY_MAX_CHARS ? value.slice(0, SUBAGENT_TIMELINE_ENTRY_MAX_CHARS) : value;
36
+ }
37
+
38
+ /**
39
+ * Records every sub-agent's frames as its timeline — thoughts, reply text,
40
+ * tool calls with their full input, tool results (cut at 4 KB), child agents
41
+ * and its end — and posts them to the relay keyed by the run, not by a turn.
42
+ *
43
+ * It runs at process level, before any turn is resolved: a background agent
44
+ * keeps working after the turn that spawned it ends, and its frames arrive
45
+ * between turns (where no turn takes them) or during a later turn (whose
46
+ * lanes they do not belong to). The relay files every entry under the run's
47
+ * own turn.
48
+ *
49
+ * Writes are coalesced: an open thought or reply text is updated in place
50
+ * (same key) and a batch goes out at most every `flushIntervalMs` per run. A
51
+ * run the relay does not know yet (its spawn has not landed, 404) keeps its
52
+ * entries and is retried, up to `retryLimitMs`.
53
+ *
54
+ * It also answers which turn owns a run's live lanes (`ownsRun`): only the
55
+ * turn that spawned an agent shows its frames in its in-turn lanes.
56
+ */
57
+ export function createSubagentTimelineRecorder({
58
+ api,
59
+ conversationId,
60
+ dbg = () => {},
61
+ flushIntervalMs = 500,
62
+ retryLimitMs = 120_000,
63
+ now = () => Date.now(),
64
+ setTimer = setTimeout,
65
+ clearTimer = clearTimeout,
66
+ writerId = crypto.randomBytes(4).toString('hex'),
67
+ } = {}) {
68
+ const runs = new Map(); // runId -> run state, in registration order
69
+ let flushTimer = null;
70
+ let flushing = null;
71
+ let localCounter = 0;
72
+
73
+ function newKey() {
74
+ localCounter += 1;
75
+ return `${writerId}:${localCounter}`;
76
+ }
77
+
78
+ function ensureRun(runId, { parentId = null, displayName = '', subagentType = '' } = {}) {
79
+ const id = String(runId || '').trim();
80
+ if (!id) return null;
81
+ let run = runs.get(id);
82
+ if (!run) {
83
+ const parent = parentId ? runs.get(parentId) : null;
84
+ run = {
85
+ id,
86
+ parentId: parentId || null,
87
+ owner: parent?.owner || null,
88
+ entryCount: 0,
89
+ truncated: false,
90
+ settled: false,
91
+ background: false,
92
+ pending: new Map(), // key -> entry (latest snapshot to send)
93
+ header: { timelineOwner: 'worker' },
94
+ headerDirty: true,
95
+ introduced: false,
96
+ model: '',
97
+ streamBlocks: new Map(), // block index -> entry, for the current API message
98
+ currentMessageId: '',
99
+ streamed: new Map(), // API message id -> { thought: [entry], text: [entry], used: { thought, text } }
100
+ firstFailureAt: 0,
101
+ dropped: false,
102
+ };
103
+ if (parentId) run.header.parentSubagentId = parentId;
104
+ if (displayName) run.header.displayName = displayName;
105
+ if (subagentType) run.header.subagentType = subagentType;
106
+ runs.set(id, run);
107
+ evictSettledRuns();
108
+ } else {
109
+ if (displayName && !run.header.displayName && !run.introduced) run.header.displayName = displayName;
110
+ if (parentId && !run.parentId) {
111
+ run.parentId = parentId;
112
+ run.header.parentSubagentId = parentId;
113
+ run.owner = run.owner || runs.get(parentId)?.owner || null;
114
+ }
115
+ }
116
+ return run;
117
+ }
118
+
119
+ function evictSettledRuns() {
120
+ if (runs.size <= MAX_TRACKED_RUNS) return;
121
+ for (const [id, run] of runs) {
122
+ if (runs.size <= MAX_TRACKED_RUNS) break;
123
+ if (run.settled && !run.pending.size && !run.headerDirty) runs.delete(id);
124
+ }
125
+ }
126
+
127
+ function patchHeader(run, patch) {
128
+ let changed = false;
129
+ for (const [key, value] of Object.entries(patch)) {
130
+ if (value === undefined || value === null || value === '') continue;
131
+ if (run.header[key] === value && !run.headerDirty) continue;
132
+ if (run.lastSentHeader?.[key] === value) continue;
133
+ run.header[key] = value;
134
+ changed = true;
135
+ }
136
+ if (changed) {
137
+ run.headerDirty = true;
138
+ schedule();
139
+ }
140
+ }
141
+
142
+ // A new entry, unless the run is full: then one note says so, once.
143
+ function addEntry(run, entry) {
144
+ if (run.dropped) return null;
145
+ if (run.entryCount >= SUBAGENT_TIMELINE_MAX_ENTRIES) {
146
+ if (!run.truncated) {
147
+ run.truncated = true;
148
+ const note = { key: newKey(), kind: 'note', text: `The record stops here: a sub-agent keeps at most ${SUBAGENT_TIMELINE_MAX_ENTRIES} entries.`, done: true, metadata: { truncated: true } };
149
+ run.pending.set(note.key, note);
150
+ patchHeader(run, { truncated: true });
151
+ }
152
+ return null;
153
+ }
154
+ run.entryCount += 1;
155
+ const stored = { key: newKey(), done: true, ...entry };
156
+ run.pending.set(stored.key, stored);
157
+ schedule();
158
+ return stored;
159
+ }
160
+
161
+ // An entry already sent changes (more streamed text, its end): send its
162
+ // newest state again under the same key.
163
+ function touchEntry(run, entry) {
164
+ if (!entry || run.dropped) return;
165
+ run.pending.set(entry.key, entry);
166
+ schedule();
167
+ }
168
+
169
+ function streamedFor(run, messageId) {
170
+ const key = messageId || '';
171
+ let slot = run.streamed.get(key);
172
+ if (!slot) {
173
+ slot = { thought: [], text: [], used: { thought: 0, text: 0 } };
174
+ run.streamed.set(key, slot);
175
+ // Only the last few API messages can still be paired.
176
+ while (run.streamed.size > 8) run.streamed.delete(run.streamed.keys().next().value);
177
+ }
178
+ return slot;
179
+ }
180
+
181
+ // The complete copy of a thinking or text block: it replaces the streamed
182
+ // entry of the same block (paired in block order per API message), or is
183
+ // a new entry when nothing streamed.
184
+ function completeBlock(run, messageId, kind, text) {
185
+ // The streamed copy is filed under the id its message_start carried; a
186
+ // stream without one is filed under the current (or no) id.
187
+ const slot = [messageId, run.currentMessageId, '']
188
+ .filter((id, index, list) => list.indexOf(id) === index)
189
+ .map((id) => run.streamed.get(id || ''))
190
+ .find((candidate) => candidate && candidate.used[kind] < candidate[kind].length) || null;
191
+ if (slot) {
192
+ const entry = slot[kind][slot.used[kind]];
193
+ slot.used[kind] += 1;
194
+ if (text.trim()) entry.text = capText(text);
195
+ entry.done = true;
196
+ touchEntry(run, entry);
197
+ return;
198
+ }
199
+ if (!text.trim()) return;
200
+ addEntry(run, { kind, text: capText(text), done: true });
201
+ }
202
+
203
+ function registerSpawn(block, parentId) {
204
+ const childId = String(block?.id || '').trim();
205
+ if (!childId) return null;
206
+ const input = block?.input && typeof block.input === 'object' ? block.input : {};
207
+ const displayName = String(input.description || input.name || input.subagent_type || 'Subagent').trim() || 'Subagent';
208
+ const subagentType = String(input.subagent_type || '').trim();
209
+ const child = ensureRun(childId, { parentId, displayName, subagentType });
210
+ if (parentId) {
211
+ const parent = ensureRun(parentId);
212
+ addEntry(parent, {
213
+ kind: 'child',
214
+ toolUseId: childId,
215
+ toolName: displayToolName(block?.name) || 'Agent',
216
+ text: displayName,
217
+ metadata: { subagentType: subagentType || null, prompt: typeof input.prompt === 'string' ? input.prompt.slice(0, 2000) : null },
218
+ });
219
+ }
220
+ return child;
221
+ }
222
+
223
+ function observeAssistant(sdkMessage) {
224
+ const parentId = String(sdkMessage?.parent_tool_use_id || '').trim();
225
+ const content = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
226
+ if (!parentId) {
227
+ // The main thread: only the agents it spawns matter here.
228
+ for (const block of content) {
229
+ if (block?.type === 'tool_use' && isSubagentToolName(block.name)) registerSpawn(block, null);
230
+ }
231
+ return;
232
+ }
233
+ const run = ensureRun(parentId);
234
+ const model = String(sdkMessage?.message?.model || '').trim();
235
+ if (model && model !== '<synthetic>' && model !== run.model) {
236
+ run.model = model;
237
+ patchHeader(run, { model });
238
+ }
239
+ const messageId = String(sdkMessage?.message?.id || '').trim();
240
+ for (const block of content) {
241
+ const type = String(block?.type || '');
242
+ if (type === 'thinking') {
243
+ completeBlock(run, messageId, 'thought', String(block.thinking || ''));
244
+ } else if (type === 'redacted_thinking') {
245
+ completeBlock(run, messageId, 'thought', String(block.data || '').trim() ? '[Reasoning redacted by the model provider]' : '');
246
+ } else if (type === 'text') {
247
+ completeBlock(run, messageId, 'text', String(block.text || ''));
248
+ } else if (type === 'tool_use') {
249
+ if (isSubagentToolName(block.name)) {
250
+ registerSpawn(block, parentId);
251
+ } else {
252
+ addEntry(run, {
253
+ kind: 'tool_call',
254
+ toolName: displayToolName(block.name) || 'tool',
255
+ toolUseId: String(block.id || '').trim() || null,
256
+ text: capText(stringifyInput(block.input)),
257
+ });
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ function observeStreamEvent(sdkMessage) {
264
+ const parentId = String(sdkMessage?.parent_tool_use_id || '').trim();
265
+ if (!parentId) return;
266
+ const run = ensureRun(parentId);
267
+ const event = sdkMessage?.event || {};
268
+ const type = String(event.type || '');
269
+ if (type === 'message_start') {
270
+ run.currentMessageId = String(event?.message?.id || '').trim();
271
+ run.streamBlocks.clear();
272
+ return;
273
+ }
274
+ const index = Number(event?.index ?? 0);
275
+ if (type === 'content_block_start') {
276
+ const blockType = String(event?.content_block?.type || '');
277
+ const kind = blockType === 'thinking' || blockType === 'redacted_thinking' ? 'thought' : (blockType === 'text' ? 'text' : '');
278
+ if (!kind) return;
279
+ const entry = addEntry(run, { kind, text: '', done: false });
280
+ if (!entry) return;
281
+ run.streamBlocks.set(index, entry);
282
+ streamedFor(run, run.currentMessageId)[kind].push(entry);
283
+ return;
284
+ }
285
+ if (type === 'content_block_delta') {
286
+ const entry = run.streamBlocks.get(index);
287
+ if (!entry || entry.done) return;
288
+ const deltaType = String(event?.delta?.type || '');
289
+ const piece = deltaType === 'thinking_delta' ? event.delta.thinking : (deltaType === 'text_delta' ? event.delta.text : '');
290
+ if (!piece) return;
291
+ entry.text = capText(entry.text + String(piece));
292
+ touchEntry(run, entry);
293
+ return;
294
+ }
295
+ if (type === 'content_block_stop') {
296
+ const entry = run.streamBlocks.get(index);
297
+ run.streamBlocks.delete(index);
298
+ if (entry && !entry.done) {
299
+ entry.done = true;
300
+ touchEntry(run, entry);
301
+ }
302
+ }
303
+ }
304
+
305
+ function settleRun(run, status, extraHeader = {}) {
306
+ if (!run) return;
307
+ run.settled = status !== 'running';
308
+ patchHeader(run, { status, ...extraHeader });
309
+ }
310
+
311
+ function observeUser(sdkMessage) {
312
+ const parentId = String(sdkMessage?.parent_tool_use_id || '').trim();
313
+ const content = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
314
+ for (const block of content) {
315
+ if (block?.type !== 'tool_result') continue;
316
+ const toolUseId = String(block.tool_use_id || '').trim();
317
+ // The result of a spawn: a foreground agent's end, or the receipt of a
318
+ // background launch (whose end comes as a task notification).
319
+ const spawned = toolUseId ? runs.get(toolUseId) : null;
320
+ if (spawned && (spawned.parentId || '') === parentId) {
321
+ if (block.is_error !== true && isBackgroundLaunch(block, sdkMessage?.tool_use_result || null)) {
322
+ spawned.background = true;
323
+ patchHeader(spawned, { background: true });
324
+ } else if (!spawned.background) {
325
+ settleRun(spawned, block.is_error === true ? 'failed' : 'completed');
326
+ }
327
+ if (parentId) continue;
328
+ }
329
+ if (!parentId) continue;
330
+ const run = ensureRun(parentId);
331
+ const full = toolResultText(block.content);
332
+ const { text, cut } = cutSubagentTimelineText('tool_result', full);
333
+ addEntry(run, {
334
+ kind: 'tool_result',
335
+ toolUseId: toolUseId || null,
336
+ text,
337
+ metadata: {
338
+ ...(block.is_error === true ? { isError: true } : {}),
339
+ ...(cut !== null ? { cut } : {}),
340
+ },
341
+ });
342
+ }
343
+ }
344
+
345
+ function observeTaskEvent(sdkMessage) {
346
+ const subtype = String(sdkMessage?.subtype || '');
347
+ const toolUseId = String(sdkMessage?.tool_use_id || '').trim();
348
+ if (!toolUseId) return;
349
+ if (subtype === 'task_started') {
350
+ const run = ensureRun(toolUseId, { subagentType: String(sdkMessage.subagent_type || '').trim() });
351
+ if (sdkMessage.is_backgrounded === true) run.background = true;
352
+ patchHeader(run, {
353
+ taskId: String(sdkMessage.task_id || '').trim() || null,
354
+ subagentType: String(sdkMessage.subagent_type || '').trim() || null,
355
+ background: sdkMessage.is_backgrounded === true ? true : null,
356
+ });
357
+ return;
358
+ }
359
+ const run = runs.get(toolUseId);
360
+ if (!run) return;
361
+ if (subtype === 'task_updated') {
362
+ if (sdkMessage?.patch?.is_backgrounded === true) {
363
+ run.background = true;
364
+ patchHeader(run, { background: true });
365
+ }
366
+ return;
367
+ }
368
+ const totalTokens = Number(sdkMessage?.usage?.total_tokens);
369
+ if (subtype === 'task_progress') {
370
+ if (Number.isFinite(totalTokens)) patchHeader(run, { totalTokens });
371
+ return;
372
+ }
373
+ if (subtype === 'task_notification') {
374
+ const status = normalizeSubagentRunStatus(sdkMessage.status);
375
+ const summary = String(sdkMessage.summary || '').trim();
376
+ const label = status === 'completed' ? 'Finished' : (status === 'failed' ? 'Failed' : 'Stopped');
377
+ const reason = String(sdkMessage.reason || '').trim();
378
+ addEntry(run, {
379
+ kind: 'note',
380
+ text: summary ? `${label}: ${summary}` : label,
381
+ metadata: { status, ...(reason ? { reason } : {}) },
382
+ });
383
+ settleRun(run, status, Number.isFinite(totalTokens) ? { totalTokens } : {});
384
+ }
385
+ }
386
+
387
+ /** Every SDK message the process reads, before any turn takes it. */
388
+ function observe(sdkMessage) {
389
+ if (!sdkMessage || typeof sdkMessage !== 'object') return;
390
+ try {
391
+ const type = String(sdkMessage.type || '');
392
+ if (type === 'assistant') observeAssistant(sdkMessage);
393
+ else if (type === 'stream_event') observeStreamEvent(sdkMessage);
394
+ else if (type === 'user') observeUser(sdkMessage);
395
+ else if (type === 'system') observeTaskEvent(sdkMessage);
396
+ } catch (error) {
397
+ dbg('subagent timeline: frame not recorded', error?.message || String(error));
398
+ }
399
+ }
400
+
401
+ /**
402
+ * The turn a top-level spawn happened in owns that agent's live lanes (and
403
+ * its children's). Called once the frame's turn is known.
404
+ */
405
+ function claimSpawns(sdkMessage, owner) {
406
+ if (!owner || sdkMessage?.type !== 'assistant' || sdkMessage?.parent_tool_use_id) return;
407
+ const content = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
408
+ for (const block of content) {
409
+ if (block?.type !== 'tool_use' || !isSubagentToolName(block.name)) continue;
410
+ const run = runs.get(String(block.id || '').trim());
411
+ if (run && !run.owner) run.owner = owner;
412
+ }
413
+ }
414
+
415
+ function ownsRun(runId, owner) {
416
+ const run = runs.get(String(runId || '').trim());
417
+ if (!run || !owner) return false;
418
+ if (!run.owner && run.parentId) run.owner = runs.get(run.parentId)?.owner || null;
419
+ return run.owner === owner;
420
+ }
421
+
422
+ /**
423
+ * True when a frame of this run must stay out of `owner`'s live lanes: the
424
+ * run (or an ancestor) is a background agent spawned by another turn. A
425
+ * foreground agent belongs to the CLI turn in progress, whatever relay turn
426
+ * carries it now; an unknown run keeps the old behaviour.
427
+ */
428
+ function isForeignRun(runId, owner) {
429
+ let run = runs.get(String(runId || '').trim());
430
+ if (!run) return false;
431
+ const spawnOwner = run.owner || null;
432
+ let background = false;
433
+ for (let depth = 0; run && depth < 32; depth += 1) {
434
+ if (run.background) background = true;
435
+ if (!run.parentId) break;
436
+ run = runs.get(run.parentId);
437
+ }
438
+ const rootOwner = spawnOwner || run?.owner || null;
439
+ return background && !!rootOwner && rootOwner !== owner;
440
+ }
441
+
442
+ function schedule(delayMs = flushIntervalMs) {
443
+ if (flushTimer) return;
444
+ flushTimer = setTimer(() => {
445
+ flushTimer = null;
446
+ void flush();
447
+ }, delayMs);
448
+ flushTimer?.unref?.();
449
+ }
450
+
451
+ async function postRun(run) {
452
+ const entries = [...run.pending.values()].map((entry) => ({
453
+ key: entry.key,
454
+ kind: entry.kind,
455
+ text: entry.text,
456
+ done: entry.done !== false,
457
+ ...(entry.toolName ? { toolName: entry.toolName } : {}),
458
+ ...(entry.toolUseId ? { toolUseId: entry.toolUseId } : {}),
459
+ ...(entry.metadata && Object.keys(entry.metadata).length ? { metadata: entry.metadata } : {}),
460
+ }));
461
+ const header = run.headerDirty ? { ...run.header } : null;
462
+ if (!entries.length && !header) return 'idle';
463
+ // Taken now: entries that change while the post is in flight are sent
464
+ // again by the next flush.
465
+ const sentEntries = new Map([...run.pending.entries()].map(([key, entry]) => [key, { ...entry }]));
466
+ run.pending.clear();
467
+ run.headerDirty = false;
468
+ try {
469
+ await api('POST', '/api/subagent-timeline', {
470
+ conversationId,
471
+ subagentRunId: run.id,
472
+ entries,
473
+ ...(header ? { header } : {}),
474
+ });
475
+ run.introduced = true;
476
+ run.lastSentHeader = { ...(run.lastSentHeader || {}), ...(header || {}) };
477
+ run.firstFailureAt = 0;
478
+ return 'sent';
479
+ } catch (error) {
480
+ const status = Number(error?.status) || 0;
481
+ const retryable = status === 0 || status === 404 || status === 429 || status >= 500;
482
+ if (retryable) {
483
+ if (!run.firstFailureAt) run.firstFailureAt = now();
484
+ if (now() - run.firstFailureAt < retryLimitMs) {
485
+ // Put the batch back AHEAD of anything newer: the relay orders a
486
+ // run's entries by the first time it sees each key. An entry that
487
+ // changed meanwhile goes back in its newer state.
488
+ const merged = new Map();
489
+ for (const [key, entry] of sentEntries) merged.set(key, run.pending.get(key) || entry);
490
+ for (const [key, entry] of run.pending) if (!merged.has(key)) merged.set(key, entry);
491
+ run.pending = merged;
492
+ if (header) run.headerDirty = true;
493
+ return 'retry';
494
+ }
495
+ }
496
+ dbg('subagent timeline: batch dropped', run.id, error?.message || String(error));
497
+ run.firstFailureAt = 0;
498
+ if (status === 404) run.dropped = true;
499
+ return 'dropped';
500
+ }
501
+ }
502
+
503
+ /** Send every run's pending entries and header; one flush at a time. */
504
+ async function flush() {
505
+ if (flushing) {
506
+ await flushing;
507
+ if (!flushTimer && hasPending()) schedule();
508
+ return;
509
+ }
510
+ flushing = (async () => {
511
+ let retry = false;
512
+ // Registration order: a parent's first post (which may create the run
513
+ // on the relay) goes before its children's.
514
+ for (const run of [...runs.values()]) {
515
+ if (!run.pending.size && !run.headerDirty) continue;
516
+ const outcome = await postRun(run);
517
+ if (outcome === 'retry') retry = true;
518
+ }
519
+ evictSettledRuns();
520
+ return retry;
521
+ })();
522
+ try {
523
+ const retry = await flushing;
524
+ flushing = null;
525
+ if (hasPending()) schedule(retry ? Math.max(flushIntervalMs, 2_000) : flushIntervalMs);
526
+ } finally {
527
+ flushing = null;
528
+ }
529
+ }
530
+
531
+ function hasPending() {
532
+ for (const run of runs.values()) if (run.pending.size || run.headerDirty) return true;
533
+ return false;
534
+ }
535
+
536
+ /**
537
+ * The CLI process ended: what streamed is closed, and agents still running
538
+ * end with it (a background agent cannot outlive its process). Flushed now.
539
+ */
540
+ async function processEnded(reasonText = 'The Claude process ended before this agent finished.') {
541
+ for (const run of runs.values()) {
542
+ for (const entry of run.streamBlocks.values()) {
543
+ if (!entry.done) {
544
+ entry.done = true;
545
+ touchEntry(run, entry);
546
+ }
547
+ }
548
+ run.streamBlocks.clear();
549
+ if (!run.settled && run.introduced) {
550
+ addEntry(run, { kind: 'note', text: reasonText, metadata: { status: 'failed', reason: 'process_ended' } });
551
+ settleRun(run, 'failed');
552
+ }
553
+ }
554
+ if (flushTimer) {
555
+ clearTimer(flushTimer);
556
+ flushTimer = null;
557
+ }
558
+ await flush();
559
+ }
560
+
561
+ return {
562
+ observe,
563
+ claimSpawns,
564
+ ownsRun,
565
+ isForeignRun,
566
+ flush,
567
+ processEnded,
568
+ hasPending,
569
+ // For tests and diagnostics.
570
+ runState: (runId) => runs.get(String(runId || '').trim()) || null,
571
+ };
572
+ }