@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
@@ -38,10 +38,12 @@ import {
38
38
  createClaudeTurnPublisher,
39
39
  isStaleAttemptError,
40
40
  } from './claude-turn-publisher.mjs';
41
+ import { createSubagentTimelineRecorder } from './subagent-timeline-recorder.mjs';
41
42
  import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
42
43
  import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
43
44
  import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
44
45
  import { createTurnLiveness, resolveTurnStallWindows } from '../../shared/worker-runtime/turn-liveness.mjs';
46
+ import { createKeptPosts } from '../../shared/worker-runtime/kept-posts.mjs';
45
47
  import {
46
48
  ANSWERED_ELSEWHERE_KIND,
47
49
  DELIVERY_ANSWERED_ELSEWHERE_TEXT,
@@ -49,6 +51,7 @@ import {
49
51
  STEER_STOPPED_TEXT,
50
52
  } from '../../shared/steer-settle-markers.mjs';
51
53
  import { stripSteerNote, withSteerNoteContent } from '../../shared/steer-note.mjs';
54
+ import { isRelayEventDelivery } from '../../shared/relay-events.mjs';
52
55
  import {
53
56
  stripSlashCommandGuard,
54
57
  stripSlashCommandGuardContent,
@@ -544,11 +547,19 @@ export function createClaudeSessionRunner({
544
547
  // the socket link: a hold withdraws the relay's readiness, and its end
545
548
  // re-arms it at once so a held message steers into the resumed turn.
546
549
  onDeliveryReadinessChange = () => {},
550
+ // The relay turned this worker's newest task set away (`stale`): the
551
+ // worker asks for a heartbeat, on which the relay learns which process
552
+ // holds the session and asks for the set again.
553
+ onBackgroundTaskSetStale = () => {},
547
554
  // Whether CLI processes carry the relay's `remote_relay` tool
548
555
  // (shared/remote-relay-tool-core.mjs). Default: ask the relay.
549
556
  remoteRelayToolGate = null,
557
+ // Records every sub-agent's frames as its timeline, run-scoped (see
558
+ // subagent-timeline-recorder.mjs). Injectable for tests.
559
+ subagentTimelineRecorder = null,
550
560
  dbg = () => {},
551
561
  } = {}) {
562
+ const timelineRecorder = subagentTimelineRecorder || createSubagentTimelineRecorder({ api, conversationId: sdkSessionId, dbg });
552
563
  const publisher = createClaudeTurnPublisher({
553
564
  api,
554
565
  dbg,
@@ -598,6 +609,34 @@ export function createClaudeSessionRunner({
598
609
  // id → terminalError for settling rows the worker gave up posting; reported
599
610
  // in the heartbeat's `settleFailed` until the relay confirms it failed them.
600
611
  const settleFailedTerminals = new Map();
612
+ // Settle notices of background tasks whose post failed (the relay was
613
+ // away): sent again after the next heartbeat it answers.
614
+ // Every post of the task set carries its place in this worker's order:
615
+ // two posts can overtake each other on the way, and the relay applies the
616
+ // newer one only. `epoch` tells this process from one before it.
617
+ const taskSetEpoch = Date.now();
618
+ let taskSetSeq = 0;
619
+ const nextTaskSetOrder = () => ({ epoch: taskSetEpoch, seq: ++taskSetSeq });
620
+ let taskSetStaleNotedAt = 0;
621
+ /**
622
+ * The relay's answer to a set post. `stale` for the newest one means the
623
+ * relay holds no current set of this worker; an older one was simply
624
+ * overtaken. At most one extra heartbeat per ten seconds: the periodic
625
+ * beat repeats the request anyway.
626
+ */
627
+ function noteTaskSetAnswer(answer, order) {
628
+ if (answer?.stale !== true || order.seq !== taskSetSeq) return;
629
+ dbg('the relay dropped the newest background task set', answer.reason || '');
630
+ if (Date.now() - taskSetStaleNotedAt < 10_000) return;
631
+ taskSetStaleNotedAt = Date.now();
632
+ try { onBackgroundTaskSetStale(); } catch {}
633
+ }
634
+ const postTaskSet = (body) => {
635
+ const order = nextTaskSetOrder();
636
+ return api('POST', '/api/background-tasks', { conversationId: sdkSessionId, ...body, order })
637
+ .then((answer) => noteTaskSetAnswer(answer, order));
638
+ };
639
+ const keptTaskSettles = createKeptPosts({ api, dbg: (...args) => dbg('background task settle:', ...args) });
601
640
  // Row id → the latest in-flight /api/queue-consumed POST touching it.
602
641
  const consumedMarkChains = new Map();
603
642
 
@@ -1688,10 +1727,19 @@ export function createClaudeSessionRunner({
1688
1727
  * which renders it as the composer's background-tasks panel. Membership
1689
1728
  * changes post immediately; chatty progress updates are trailing-edge
1690
1729
  * throttled. Advisory: failures must never disturb the stream consumer.
1730
+ *
1731
+ * `resync`: the relay asked for the set again on a heartbeat, because its
1732
+ * own set for this worker differs (it restarted, or a post was lost). The
1733
+ * same set, flagged, and "no tasks" is an answer too. Returns the post.
1691
1734
  */
1692
- function publishBackgroundTasks({ throttled = false } = {}) {
1735
+ function publishBackgroundTasks({ throttled = false, resync = false } = {}) {
1693
1736
  const processRef = proc;
1694
- if (!processRef) return;
1737
+ if (!processRef) {
1738
+ if (!resync) return Promise.resolve();
1739
+ return postTaskSet({ tasks: [], resync: true }).catch((error) => {
1740
+ dbg('background task resync failed', error?.message || String(error));
1741
+ });
1742
+ }
1695
1743
  const send = () => {
1696
1744
  processRef.taskPublishTimer = null;
1697
1745
  const tasks = [...processRef.liveTasks.entries()].map(([taskId, task]) => {
@@ -1723,7 +1771,7 @@ export function createClaudeSessionRunner({
1723
1771
  ...(task.workflowProgress ? { workflowProgress: task.workflowProgress } : {}),
1724
1772
  };
1725
1773
  });
1726
- api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks }).catch((error) => {
1774
+ return postTaskSet({ tasks, ...(resync ? { resync: true } : {}) }).catch((error) => {
1727
1775
  dbg('background task publish failed', error?.message || String(error));
1728
1776
  });
1729
1777
  };
@@ -1732,12 +1780,12 @@ export function createClaudeSessionRunner({
1732
1780
  clearTimeout(processRef.taskPublishTimer);
1733
1781
  processRef.taskPublishTimer = null;
1734
1782
  }
1735
- send();
1736
- return;
1783
+ return send();
1737
1784
  }
1738
- if (processRef.taskPublishTimer) return;
1785
+ if (processRef.taskPublishTimer) return Promise.resolve();
1739
1786
  processRef.taskPublishTimer = setTimeout(send, 2_000);
1740
1787
  processRef.taskPublishTimer.unref?.();
1788
+ return Promise.resolve();
1741
1789
  }
1742
1790
 
1743
1791
  // ---------------------------------------------------------------------------
@@ -2435,7 +2483,12 @@ export function createClaudeSessionRunner({
2435
2483
  // capture a settling workflow's final digest for the transcript card
2436
2484
  // (the notification then refines the status when it lands).
2437
2485
  for (const [taskId, task] of previous) {
2438
- if (!proc.liveTasks.has(taskId)) bufferSettledWorkflowRun(taskId, task);
2486
+ if (proc.liveTasks.has(taskId)) continue;
2487
+ bufferSettledWorkflowRun(taskId, task);
2488
+ // What the task was, for its settle notice: the row is gone by the
2489
+ // time the notification says how it ended.
2490
+ proc.departedTasks.set(taskId, task);
2491
+ if (proc.departedTasks.size > 50) proc.departedTasks.delete(proc.departedTasks.keys().next().value);
2439
2492
  }
2440
2493
  syncWorkflowPoller(proc);
2441
2494
  publishBackgroundTasks();
@@ -2481,6 +2534,29 @@ export function createClaudeSessionRunner({
2481
2534
  // tree publishes before the row clears (best-effort: the CLI may have
2482
2535
  // already removed the task via background_tasks_changed).
2483
2536
  if (taskId) publishFinalWorkflowDigest(taskId, String(sdkMessage.status || '').trim());
2537
+ // How the task ended, for the relay's task status and the watchers of
2538
+ // this session (the live set only says it is gone). Ambient tasks are
2539
+ // not activity. Advisory: a failed post never disturbs the stream.
2540
+ if (taskId && sdkMessage.ambient !== true) {
2541
+ // A task that was in the published set is described too, so that a
2542
+ // relay which no longer knows it (it restarted meanwhile) can still
2543
+ // tell its watchers the end. A failed post is kept and sent again.
2544
+ const published = proc.liveTasks.get(taskId) || proc.departedTasks.get(taskId) || null;
2545
+ proc.departedTasks.delete(taskId);
2546
+ void keptTaskSettles.post('/api/background-task-settled', {
2547
+ conversationId: sdkSessionId,
2548
+ taskId,
2549
+ toolUseId: String(sdkMessage.tool_use_id || published?.toolUseId || '').trim() || null,
2550
+ status: String(sdkMessage.status || '').trim() || 'completed',
2551
+ summary: String(sdkMessage.summary || '').trim().slice(0, 500) || null,
2552
+ ...(sdkMessage.reason ? { reason: String(sdkMessage.reason).slice(0, 40) } : {}),
2553
+ ...(published?.description ? {
2554
+ description: String(published.description).slice(0, 500),
2555
+ taskType: String(published.taskType || ''),
2556
+ startedAt: published.startedAt || null,
2557
+ } : {}),
2558
+ });
2559
+ }
2484
2560
  // A settled session-level task means the CLI is about to dequeue a
2485
2561
  // continuation turn — the process must not idle out under it. The SDK
2486
2562
  // flags silent notifications explicitly: skip_transcript means no
@@ -2681,6 +2757,9 @@ export function createClaudeSessionRunner({
2681
2757
  }
2682
2758
 
2683
2759
  async function handleSdkMessage(sdkMessage) {
2760
+ // Before any turn takes the frame: a background agent's frames arrive
2761
+ // between turns too, and belong to the run, not to whichever turn is open.
2762
+ timelineRecorder.observe(sdkMessage);
2684
2763
  const usageLimitReport = usageLimit.observe(sdkMessage);
2685
2764
  if (usageLimitReport) publisher.publishUsageLimitReport({ conversationId: sdkSessionId, sdkSessionId, report: usageLimitReport });
2686
2765
  const suppressed = maybeSuppressStopFollowUp(sdkMessage);
@@ -2702,6 +2781,12 @@ export function createClaudeSessionRunner({
2702
2781
  await settling;
2703
2782
  }
2704
2783
  if (!ctx) return;
2784
+ timelineRecorder.claimSpawns(sdkMessage, ctx);
2785
+ // A background agent spawned by an earlier turn: its frames are not this
2786
+ // turn's, and published here they would be filed under it. Its record
2787
+ // goes to its own run through the recorder above.
2788
+ const subagentParent = String(sdkMessage?.parent_tool_use_id || '').trim();
2789
+ if (subagentParent && timelineRecorder.isForeignRun(subagentParent, ctx)) return;
2705
2790
  const actions = ctx.normalizer.normalize(sdkMessage);
2706
2791
  for (const action of actions) {
2707
2792
  // Decided here, while the reports the turn ran under are the latest.
@@ -3201,6 +3286,10 @@ export function createClaudeSessionRunner({
3201
3286
  const wasCurrent = proc === processRef;
3202
3287
  // The next spawn reads the registration decision; refresh it now.
3203
3288
  if (wasCurrent) void remoteRelayGate.refresh();
3289
+ // Agents die with their process: their runs end, what streamed closes.
3290
+ if (wasCurrent) {
3291
+ void timelineRecorder.processEnded().catch((error) => dbg('subagent timeline flush failed', error?.message || String(error)));
3292
+ }
3204
3293
  stopLifecycleTimer(processRef);
3205
3294
  if (processRef.taskPublishTimer) {
3206
3295
  clearTimeout(processRef.taskPublishTimer);
@@ -3217,7 +3306,7 @@ export function createClaudeSessionRunner({
3217
3306
  if (processRef.liveTasks.size) {
3218
3307
  processRef.liveTasks = new Map();
3219
3308
  if (wasCurrent) {
3220
- api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks: [] }).catch(() => {});
3309
+ postTaskSet({ tasks: [] }).catch(() => {});
3221
3310
  }
3222
3311
  }
3223
3312
  // Steers pushed into a turn the user stopped: if the Stop fell back to
@@ -3299,6 +3388,9 @@ export function createClaudeSessionRunner({
3299
3388
  permissionMode: permissionModeForRelayMode(relayMode),
3300
3389
  liveTasks: new Map(),
3301
3390
  knownTasks: new Set(),
3391
+ // taskId → the row of a task that left the live set, until its
3392
+ // notification arrives.
3393
+ departedTasks: new Map(),
3302
3394
  // taskId → { runId, labels, digestJson } for live local_workflow tasks;
3303
3395
  // pruned by syncWorkflowPoller when a task leaves the live set.
3304
3396
  workflowStates: new Map(),
@@ -3913,7 +4005,9 @@ export function createClaudeSessionRunner({
3913
4005
  // noted steer no longer starts with "/" and is pushed as noted. The
3914
4006
  // replay signatures strip the guard too, so expectedText stays the
3915
4007
  // user's own text here as well.
3916
- const pushedContent = withSlashCommandGuardContent(steered && procRef.activeCtx?.kind !== 'continuation'
4008
+ // Nor into an event message the relay wrote: the note speaks as the
4009
+ // user, the event must not (shared/relay-events.mjs).
4010
+ const pushedContent = withSlashCommandGuardContent(steered && procRef.activeCtx?.kind !== 'continuation' && !isRelayEventDelivery(message)
3917
4011
  ? withSteerNoteContent(content)
3918
4012
  : content);
3919
4013
  procRef.pendingDelivered.push({ ctx, expectedText: contentText(content), pushedAt: Date.now(), steered });
@@ -4000,6 +4094,21 @@ export function createClaudeSessionRunner({
4000
4094
  return false;
4001
4095
  }
4002
4096
 
4097
+ /** The ids of the live background tasks, for the heartbeat's digest. */
4098
+ function backgroundTaskIds() {
4099
+ return proc ? [...proc.liveTasks.keys()] : [];
4100
+ }
4101
+
4102
+ /** The relay asked for the task set again (heartbeat answer). */
4103
+ function resyncBackgroundTasks() {
4104
+ return publishBackgroundTasks({ resync: true });
4105
+ }
4106
+
4107
+ /** The relay answered a heartbeat: settle notices kept for it go out. */
4108
+ function flushKeptTaskSettles() {
4109
+ return keptTaskSettles.flush();
4110
+ }
4111
+
4003
4112
  return {
4004
4113
  handlePendingPayload,
4005
4114
  canAcceptSteering,
@@ -4012,6 +4121,9 @@ export function createClaudeSessionRunner({
4012
4121
  isTurnActive,
4013
4122
  stopBackgroundTask,
4014
4123
  stopBackgroundTaskByToolUseId,
4124
+ backgroundTaskIds,
4125
+ resyncBackgroundTasks,
4126
+ flushKeptTaskSettles,
4015
4127
  shutdown,
4016
4128
  // Test seams / observability.
4017
4129
  _getProcess: () => proc,
@@ -119,6 +119,7 @@ async function main() {
119
119
  // A readiness flip is a hold starting or ending (question card,
120
120
  // compaction, a turn-opening delivery): the steering snapshot changed
121
121
  // with it, so it goes out now rather than on the next 10 s heartbeat.
122
+ onBackgroundTaskSetStale: () => heartbeat?.requestPulse(),
122
123
  onDeliveryReadinessChange: (ready) => {
123
124
  linkBridge.onDeliveryReadinessChange(ready);
124
125
  heartbeat?.requestPulse();
@@ -147,6 +148,12 @@ async function main() {
147
148
  // Consumed rows the runner could not fail itself; the relay fails them.
148
149
  getSettleFailed: () => turnRunner.getSettleFailed(),
149
150
  onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
151
+ // The relay holds the task set in memory only: the digest of the live
152
+ // task ids rides every beat, and a relay that is out of step (it
153
+ // restarted, a post was lost) asks for the set again.
154
+ getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
155
+ onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
156
+ onAnswered: () => turnRunner.flushKeptTaskSettles(),
150
157
  });
151
158
 
152
159
  const wsLink = createWorkerWebSocketLink({
@@ -164,7 +171,8 @@ async function main() {
164
171
  // even between deliveries — a delivery drawn into it is only handed back.
165
172
  getDeliveryHeld: () => turnRunner.isDeliveryHeld(),
166
173
  // A relay that just (re)started knows nothing of this worker until it
167
- // heartbeats; the composer's steering snapshot rides that beat.
174
+ // heartbeats; the composer's steering snapshot rides that beat, and so
175
+ // does the digest that makes the relay ask for the background tasks.
168
176
  onServerHello: () => heartbeat.requestPulse(),
169
177
  getSessionId: () => sdkSessionId,
170
178
  getPid: () => process.pid,
@@ -127,6 +127,9 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
127
127
  parentSubagentId: payload.parentSubagentId || undefined,
128
128
  displayName: payload.displayName || undefined,
129
129
  status: payload.status,
130
+ ...(payload.subagentType ? { subagentType: payload.subagentType } : {}),
131
+ ...(payload.background ? { background: true } : {}),
132
+ ...(payload.timelineOwner ? { timelineOwner: payload.timelineOwner } : {}),
130
133
  ...attemptFields(message),
131
134
  }).catch(() => {});
132
135
  return;
@@ -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;