@oar-sh/oar 0.9.8 → 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 (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -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 +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. package/shared/subagent-timeline.mjs +79 -0
@@ -116,6 +116,23 @@ export function isSubagentToolName(name) {
116
116
  return SUBAGENT_TOOL_NAMES.has(String(name || '').trim().toLowerCase());
117
117
  }
118
118
 
119
+ const BACKGROUND_LAUNCH_STATUSES = new Set(['async_launched', 'remote_launched']);
120
+
121
+ /**
122
+ * The tool result of an Agent/Task spawn that went to the background: the
123
+ * structured result says so (`tool_use_result.status`), and the receipt text
124
+ * the model reads is the fallback for producers without it.
125
+ */
126
+ export function isBackgroundLaunch(block, toolUseResult = null) {
127
+ const status = String(toolUseResult?.status || '').trim();
128
+ if (BACKGROUND_LAUNCH_STATUSES.has(status)) return true;
129
+ const content = block?.content;
130
+ const text = typeof content === 'string'
131
+ ? content
132
+ : (Array.isArray(content) ? content.map((part) => String(part?.text || '')).join(' ') : '');
133
+ return /^\s*Async agent launched/i.test(text);
134
+ }
135
+
119
136
  export function summarizeToolInput(toolName, input) {
120
137
  if (!input || typeof input !== 'object') return '';
121
138
  // `mcp__relay__remote_relay` reads as what it does on which relay
@@ -287,7 +304,8 @@ export function createSdkMessageNormalizer() {
287
304
  || 'Subagent',
288
305
  ).trim() || 'Subagent';
289
306
  const parentSubagentId = subagentRunIdFor(parentToolUseId);
290
- knownSubagentRuns.set(toolUseId, { displayName, parentSubagentId });
307
+ const subagentType = String(input.subagent_type || '').trim();
308
+ knownSubagentRuns.set(toolUseId, { displayName, parentSubagentId, subagentType });
291
309
  actions.push({
292
310
  channel: 'subagent',
293
311
  payload: {
@@ -295,6 +313,10 @@ export function createSdkMessageNormalizer() {
295
313
  parentSubagentId,
296
314
  displayName,
297
315
  status: 'running',
316
+ ...(subagentType ? { subagentType } : {}),
317
+ // The worker's timeline recorder writes this run's record itself;
318
+ // the relay must not copy the lane frames into it as well.
319
+ timelineOwner: 'worker',
298
320
  },
299
321
  });
300
322
  }
@@ -308,19 +330,24 @@ export function createSdkMessageNormalizer() {
308
330
  return actions;
309
331
  }
310
332
 
311
- function actionsForToolResultBlock(block, parentToolUseId) {
333
+ function actionsForToolResultBlock(block, parentToolUseId, toolUseResult = null) {
312
334
  const actions = [];
313
335
  const toolUseId = String(block?.tool_use_id || '').trim();
314
336
  const isError = block?.is_error === true;
315
337
  if (toolUseId && knownSubagentRuns.has(toolUseId)) {
316
338
  const run = knownSubagentRuns.get(toolUseId);
339
+ // A background launch answers its spawn at once with a receipt; the
340
+ // agent is only starting. Its end arrives later as a task
341
+ // notification, which the worker's timeline recorder reports.
342
+ const launchedInBackground = !isError && isBackgroundLaunch(block, toolUseResult);
317
343
  actions.push({
318
344
  channel: 'subagent',
319
345
  payload: {
320
346
  subagentRunId: toolUseId,
321
347
  parentSubagentId: run.parentSubagentId,
322
348
  displayName: run.displayName,
323
- status: isError ? 'failed' : 'completed',
349
+ status: launchedInBackground ? 'running' : (isError ? 'failed' : 'completed'),
350
+ ...(launchedInBackground ? { background: true } : {}),
324
351
  },
325
352
  });
326
353
  }
@@ -574,7 +601,7 @@ export function createSdkMessageNormalizer() {
574
601
  const content = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
575
602
  for (const block of content) {
576
603
  if (block?.type === 'tool_result') {
577
- actions.push(...actionsForToolResultBlock(block, parentToolUseId));
604
+ actions.push(...actionsForToolResultBlock(block, parentToolUseId, sdkMessage?.tool_use_result || null));
578
605
  }
579
606
  }
580
607
  return actions;
@@ -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.