@oar-sh/oar 0.9.9 → 0.9.10

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 (85) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/README.md +78 -9
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +2 -2
  5. package/server/README.md +286 -3
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +35 -1
  8. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  9. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  10. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  12. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  13. package/server/db-schema.mjs +36 -0
  14. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  15. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  16. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  17. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  18. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  19. package/server/public/app/api-client.js +61 -0
  20. package/server/public/app/archive-confirm.mjs +28 -0
  21. package/server/public/app/ask-user-view.js +3 -0
  22. package/server/public/app/attachments-view.js +516 -6
  23. package/server/public/app/background-tasks-view.mjs +18 -26
  24. package/server/public/app/bootstrap.js +186 -35
  25. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  26. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  27. package/server/public/app/composer-control-state.mjs +16 -0
  28. package/server/public/app/composer-keyboard.mjs +54 -0
  29. package/server/public/app/conversation-view.js +361 -85
  30. package/server/public/app/explorer-edit-state.mjs +68 -0
  31. package/server/public/app/host-suspend-ui.js +6 -3
  32. package/server/public/app/journal-view.js +146 -26
  33. package/server/public/app/no-conversation-state.mjs +58 -0
  34. package/server/public/app/provider-readiness.mjs +120 -0
  35. package/server/public/app/relay-board-view.js +3 -0
  36. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  37. package/server/public/app/relay-event-view.mjs +234 -0
  38. package/server/public/app/settings-modal.js +53 -1
  39. package/server/public/app/socket-handlers.js +59 -28
  40. package/server/public/app/store.js +19 -2
  41. package/server/public/app/subagent-row.mjs +138 -0
  42. package/server/public/app/subagent-timeline-view.mjs +186 -0
  43. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  44. package/server/public/app/subagent-viewer.mjs +534 -0
  45. package/server/public/app/task-row-format.mjs +29 -0
  46. package/server/public/app/thinking-follow.js +4 -0
  47. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  48. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  49. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  50. package/server/public/app/update-ui.js +1 -1
  51. package/server/public/app/viewer-history.mjs +5 -3
  52. package/server/public/index.html +396 -5
  53. package/server/repositories/message-repository.mjs +29 -3
  54. package/server/repositories/question-repository.mjs +9 -3
  55. package/server/repositories/remote-relay-repository.mjs +6 -2
  56. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  57. package/server/routes/ask-user-routes.mjs +3 -0
  58. package/server/routes/messages-routes.mjs +515 -50
  59. package/server/routes/relay-event-routes.mjs +157 -0
  60. package/server/routes/remote-relay-routes.mjs +15 -0
  61. package/server/routes/sessions-routes.mjs +72 -0
  62. package/server/server-runtime.mjs +245 -62
  63. package/server/services/explorer-entry-name.mjs +26 -0
  64. package/server/services/explorer-mutations.mjs +122 -0
  65. package/server/services/preview-proxy-server.mjs +41 -23
  66. package/server/services/push-dispatch-service.mjs +18 -0
  67. package/server/services/relay-event-link-service.mjs +289 -0
  68. package/server/services/relay-event-service.mjs +1935 -0
  69. package/server/services/relay-shutdown-gate.mjs +31 -0
  70. package/server/services/remote-relay-caller-context.mjs +52 -7
  71. package/server/services/remote-relay-dispatcher.mjs +494 -2
  72. package/server/services/remote-relay-registry-service.mjs +11 -1
  73. package/server/services/session-activity-tracker.mjs +487 -0
  74. package/server/services/session-history-refresh-service.mjs +7 -0
  75. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  76. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  77. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  78. package/server/services/subagent-record-retention.mjs +164 -0
  79. package/server/services/subagent-timeline-service.mjs +328 -0
  80. package/server/services/subagent-viewer-rooms.mjs +119 -0
  81. package/server/services/upgrade-socket-guard.mjs +16 -0
  82. package/shared/relay-events.mjs +371 -0
  83. package/shared/remote-relay-contract.mjs +157 -5
  84. package/shared/subagent-record-retention.mjs +31 -0
  85. package/shared/subagent-timeline.mjs +79 -0
@@ -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
+ }
@@ -112,6 +112,7 @@ import { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT, STEER_TURN_FAILED_TEXT } from '.
112
112
  import { buildSteerSettleFailure } from '../../shared/steer-settle-failure.mjs';
113
113
  import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
114
114
  import { withSteerNote } from '../../shared/steer-note.mjs';
115
+ import { isRelayEventDelivery } from '../../shared/relay-events.mjs';
115
116
  import { isTransientRelayError } from '../../shared/worker-runtime/relay-errors.mjs';
116
117
  import {
117
118
  createTurnLiveness,
@@ -4216,7 +4217,8 @@ export function createCopilotSdkSessionRunner({
4216
4217
  // message and drop the request it was working on (burn-in 2026-09-25).
4217
4218
  // Not in a continuation: there the running work is the runtime's own
4218
4219
  // follow-up, not a request of the user's to keep going with.
4219
- const prompt = withRelayContext(context?.prefix, turn.kind === 'continuation' ? body : withSteerNote(body));
4220
+ // Nor in an event message the relay wrote: the note speaks as the user.
4221
+ const prompt = withRelayContext(context?.prefix, turn.kind === 'continuation' || isRelayEventDelivery(message) ? body : withSteerNote(body));
4220
4222
  // Re-checked AFTER the awaits and before anything is sent or registered.
4221
4223
  // Nothing has been sent yet, so handing the row back to the normal path is
4222
4224
  // free.
@@ -209,12 +209,66 @@ export function startCursorRun({
209
209
  };
210
210
  // A tool call is reported on both surfaces (the delta hook and the message
211
211
  // stream) under the same id; either one opens and closes it.
212
+ //
213
+ // Not every tool call is reported. The SDK translates only the tools it has
214
+ // a type for and drops the rest on both surfaces: among them the wait on a
215
+ // background shell (`awaitToolCall`), web fetches and input to a running
216
+ // shell. All a dropped call leaves is the step's `tool-requests-listed`
217
+ // count, sent once the model has finished requesting tools; from there the
218
+ // run waits for them without a single event, for as long as the command it
219
+ // waits on takes. So the calls listed beyond those reported count as one
220
+ // open tool, which closes when the model writes again: the SDK tells
221
+ // nothing about such a call ending, and the next step is the first sign.
222
+ // A step without such calls keeps the model window once its reported
223
+ // tools have ended. Steps are told from the delta hook alone: it carries
224
+ // the count, the reported calls and the model's output in step order.
225
+ const stepCalls = new Set();
226
+ let stepListed = false;
227
+ let unreportedCalls = 0;
228
+ const UNREPORTED_TOOLS = 'unreported-tools';
229
+ const noteCallStarted = (callId) => {
230
+ const key = String(callId || '').trim();
231
+ if (!key || stepCalls.has(key)) return;
232
+ stepCalls.add(key);
233
+ // Reported after the count: it was one of the calls counted as unreported.
234
+ if (stepListed && unreportedCalls > 0 && --unreportedCalls === 0) liveness.end('tool', UNREPORTED_TOOLS);
235
+ };
236
+ const noteToolsListed = (callCount) => {
237
+ stepListed = true;
238
+ unreportedCalls = Math.max(0, (Number(callCount) || 0) - stepCalls.size);
239
+ if (unreportedCalls > 0) liveness.begin('tool', UNREPORTED_TOOLS, 'unreported tool');
240
+ };
241
+ const noteModelWriting = () => {
242
+ if (!stepListed) return;
243
+ stepListed = false;
244
+ unreportedCalls = 0;
245
+ stepCalls.clear();
246
+ liveness.end('tool', UNREPORTED_TOOLS);
247
+ };
212
248
  const observe = (event) => {
213
249
  if (event.source === 'delta') {
214
250
  const update = event.update || {};
215
251
  liveness.traffic(update.type);
216
- if (update.type === 'tool-call-started') liveness.begin('tool', update.callId, update.toolCall?.type);
217
- else if (update.type === 'tool-call-completed') liveness.end('tool', update.callId);
252
+ switch (update.type) {
253
+ case 'tool-call-started':
254
+ noteCallStarted(update.callId);
255
+ liveness.begin('tool', update.callId, update.toolCall?.type);
256
+ break;
257
+ case 'tool-call-completed':
258
+ liveness.end('tool', update.callId);
259
+ break;
260
+ case 'tool-requests-listed':
261
+ noteToolsListed(update.callCount);
262
+ break;
263
+ case 'text-delta':
264
+ case 'thinking-delta':
265
+ case 'thinking-completed':
266
+ case 'partial-tool-call':
267
+ noteModelWriting();
268
+ break;
269
+ default:
270
+ break;
271
+ }
218
272
  return;
219
273
  }
220
274
  const message = event.message || {};