@oar-sh/oar 0.9.10 → 0.9.12

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 (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -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 +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -43,6 +43,7 @@ import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
43
43
  import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
44
44
  import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
45
45
  import { createTurnLiveness, resolveTurnStallWindows } from '../../shared/worker-runtime/turn-liveness.mjs';
46
+ import { createKeptPosts } from '../../shared/worker-runtime/kept-posts.mjs';
46
47
  import {
47
48
  ANSWERED_ELSEWHERE_KIND,
48
49
  DELIVERY_ANSWERED_ELSEWHERE_TEXT,
@@ -57,6 +58,13 @@ import {
57
58
  withSlashCommandGuardContent,
58
59
  } from '../../shared/slash-command-guard.mjs';
59
60
  import { createUsageLimitTracker } from '../../shared/claude-usage-limit.mjs';
61
+ import {
62
+ discoverTaskOutputPaths,
63
+ exitCodeFromSummary,
64
+ isShellTaskType,
65
+ outputDirOf,
66
+ usableOutputPath,
67
+ } from './task-output-discovery.mjs';
60
68
 
61
69
  /**
62
70
  * Which system-prompt append a relay mode gets (claude-sdk-adapter's
@@ -76,6 +84,8 @@ export { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT };
76
84
  /** Task types that run a model of their own (vs. bash/monitor/workflow). */
77
85
  const AGENT_TASK_TYPES = new Set(['local_agent', 'agent', 'subagent']);
78
86
  const MAX_TASK_TOOL_CALL_CHARS = 300;
87
+ /** Output paths remembered per process (one per backgrounded shell). */
88
+ const MAX_TASK_OUTPUT_PATHS = 200;
79
89
 
80
90
  // ---------------------------------------------------------------------------
81
91
  // Workflow progress sources (CLI 2.1.226 on-disk formats)
@@ -459,6 +469,10 @@ export function createClaudeSessionRunner({
459
469
  // leaves the CLI's own attribution. Piggybacked on deliveries like the
460
470
  // window above.
461
471
  getAttribution = () => null,
472
+ // How long a backgrounded Bash command may run without its own timeout
473
+ // (ms; the relay's Claude setting). Read at spawn only: the CLI takes it
474
+ // from its environment, so a change applies to the next process.
475
+ getBackgroundCommandLimitMs = () => null,
462
476
  // The per-conversation thinking state ({enabled, display}); read per turn
463
477
  // like the window, so a settings change reaches a running process on its
464
478
  // next delivery.
@@ -546,6 +560,10 @@ export function createClaudeSessionRunner({
546
560
  // the socket link: a hold withdraws the relay's readiness, and its end
547
561
  // re-arms it at once so a held message steers into the resumed turn.
548
562
  onDeliveryReadinessChange = () => {},
563
+ // The relay turned this worker's newest task set away (`stale`): the
564
+ // worker asks for a heartbeat, on which the relay learns which process
565
+ // holds the session and asks for the set again.
566
+ onBackgroundTaskSetStale = () => {},
549
567
  // Whether CLI processes carry the relay's `remote_relay` tool
550
568
  // (shared/remote-relay-tool-core.mjs). Default: ask the relay.
551
569
  remoteRelayToolGate = null,
@@ -604,6 +622,34 @@ export function createClaudeSessionRunner({
604
622
  // id → terminalError for settling rows the worker gave up posting; reported
605
623
  // in the heartbeat's `settleFailed` until the relay confirms it failed them.
606
624
  const settleFailedTerminals = new Map();
625
+ // Settle notices of background tasks whose post failed (the relay was
626
+ // away): sent again after the next heartbeat it answers.
627
+ // Every post of the task set carries its place in this worker's order:
628
+ // two posts can overtake each other on the way, and the relay applies the
629
+ // newer one only. `epoch` tells this process from one before it.
630
+ const taskSetEpoch = Date.now();
631
+ let taskSetSeq = 0;
632
+ const nextTaskSetOrder = () => ({ epoch: taskSetEpoch, seq: ++taskSetSeq });
633
+ let taskSetStaleNotedAt = 0;
634
+ /**
635
+ * The relay's answer to a set post. `stale` for the newest one means the
636
+ * relay holds no current set of this worker; an older one was simply
637
+ * overtaken. At most one extra heartbeat per ten seconds: the periodic
638
+ * beat repeats the request anyway.
639
+ */
640
+ function noteTaskSetAnswer(answer, order) {
641
+ if (answer?.stale !== true || order.seq !== taskSetSeq) return;
642
+ dbg('the relay dropped the newest background task set', answer.reason || '');
643
+ if (Date.now() - taskSetStaleNotedAt < 10_000) return;
644
+ taskSetStaleNotedAt = Date.now();
645
+ try { onBackgroundTaskSetStale(); } catch {}
646
+ }
647
+ const postTaskSet = (body) => {
648
+ const order = nextTaskSetOrder();
649
+ return api('POST', '/api/background-tasks', { conversationId: sdkSessionId, ...body, order })
650
+ .then((answer) => noteTaskSetAnswer(answer, order));
651
+ };
652
+ const keptTaskSettles = createKeptPosts({ api, dbg: (...args) => dbg('background task settle:', ...args) });
607
653
  // Row id → the latest in-flight /api/queue-consumed POST touching it.
608
654
  const consumedMarkChains = new Map();
609
655
 
@@ -1694,10 +1740,19 @@ export function createClaudeSessionRunner({
1694
1740
  * which renders it as the composer's background-tasks panel. Membership
1695
1741
  * changes post immediately; chatty progress updates are trailing-edge
1696
1742
  * throttled. Advisory: failures must never disturb the stream consumer.
1743
+ *
1744
+ * `resync`: the relay asked for the set again on a heartbeat, because its
1745
+ * own set for this worker differs (it restarted, or a post was lost). The
1746
+ * same set, flagged, and "no tasks" is an answer too. Returns the post.
1697
1747
  */
1698
- function publishBackgroundTasks({ throttled = false } = {}) {
1748
+ function publishBackgroundTasks({ throttled = false, resync = false } = {}) {
1699
1749
  const processRef = proc;
1700
- if (!processRef) return;
1750
+ if (!processRef) {
1751
+ if (!resync) return Promise.resolve();
1752
+ return postTaskSet({ tasks: [], resync: true }).catch((error) => {
1753
+ dbg('background task resync failed', error?.message || String(error));
1754
+ });
1755
+ }
1701
1756
  const send = () => {
1702
1757
  processRef.taskPublishTimer = null;
1703
1758
  const tasks = [...processRef.liveTasks.entries()].map(([taskId, task]) => {
@@ -1727,9 +1782,12 @@ export function createClaudeSessionRunner({
1727
1782
  // The workflow digest (poller below) rides the same row; the relay
1728
1783
  // sanitizer whitelists and re-clamps it before storing.
1729
1784
  ...(task.workflowProgress ? { workflowProgress: task.workflowProgress } : {}),
1785
+ ...(agentLike && Number.isInteger(task.steps) ? { steps: task.steps } : {}),
1786
+ // Where the relay reads the task's console from.
1787
+ ...(isShellTaskType(task.taskType) ? taskOutputFields(processRef, taskId) : {}),
1730
1788
  };
1731
1789
  });
1732
- api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks }).catch((error) => {
1790
+ return postTaskSet({ tasks, ...(resync ? { resync: true } : {}) }).catch((error) => {
1733
1791
  dbg('background task publish failed', error?.message || String(error));
1734
1792
  });
1735
1793
  };
@@ -1738,12 +1796,95 @@ export function createClaudeSessionRunner({
1738
1796
  clearTimeout(processRef.taskPublishTimer);
1739
1797
  processRef.taskPublishTimer = null;
1740
1798
  }
1741
- send();
1742
- return;
1799
+ return send();
1743
1800
  }
1744
- if (processRef.taskPublishTimer) return;
1801
+ if (processRef.taskPublishTimer) return Promise.resolve();
1745
1802
  processRef.taskPublishTimer = setTimeout(send, 2_000);
1746
1803
  processRef.taskPublishTimer.unref?.();
1804
+ return Promise.resolve();
1805
+ }
1806
+
1807
+ // ---------------------------------------------------------------------------
1808
+ // Task output files
1809
+ //
1810
+ // The relay reads a shell task's console straight from the file the CLI
1811
+ // writes (`<tasks dir>/<taskId>.output`); the worker only names it. Bash
1812
+ // results announce the path (task-output-discovery.mjs); a Monitor result
1813
+ // does not, but its file follows the same scheme next to any known one, and
1814
+ // is named only once it exists. Without a path the relay falls back to the
1815
+ // CLI's `getTaskOutput` snapshot (task_output_snapshot control).
1816
+
1817
+ function rememberTaskOutputPath(processRef, taskId, outputPath) {
1818
+ const id = String(taskId || '').trim();
1819
+ const usable = usableOutputPath(outputPath);
1820
+ if (!id || !usable) return false;
1821
+ const dir = outputDirOf(usable);
1822
+ if (dir) processRef.taskOutputDir = dir;
1823
+ // The first path a task names wins, here as on the relay.
1824
+ if (processRef.taskOutputPaths.has(id)) return false;
1825
+ processRef.taskOutputPaths.set(id, usable);
1826
+ while (processRef.taskOutputPaths.size > MAX_TASK_OUTPUT_PATHS) {
1827
+ processRef.taskOutputPaths.delete(processRef.taskOutputPaths.keys().next().value);
1828
+ }
1829
+ return true;
1830
+ }
1831
+
1832
+ function taskOutputFields(processRef, taskId) {
1833
+ const known = processRef.taskOutputPaths.get(taskId);
1834
+ if (known) return { outputPath: known, outputKind: 'file' };
1835
+ if (processRef.taskOutputDir) {
1836
+ const candidate = nodePath.join(processRef.taskOutputDir, `${taskId}.output`);
1837
+ let exists = false;
1838
+ try { exists = nodeFs.existsSync(candidate); } catch {}
1839
+ if (exists && rememberTaskOutputPath(processRef, taskId, candidate)) {
1840
+ return { outputPath: candidate, outputKind: 'file' };
1841
+ }
1842
+ }
1843
+ return { outputKind: 'snapshot' };
1844
+ }
1845
+
1846
+ /** A backgrounded Bash/Monitor result: note its file, and show it on the live row. */
1847
+ function recordTaskOutputPaths(sdkMessage) {
1848
+ let liveChanged = false;
1849
+ for (const { taskId, outputPath } of discoverTaskOutputPaths(sdkMessage)) {
1850
+ if (rememberTaskOutputPath(proc, taskId, outputPath) && proc.liveTasks.has(taskId)) liveChanged = true;
1851
+ }
1852
+ if (liveChanged) publishBackgroundTasks();
1853
+ }
1854
+
1855
+ /**
1856
+ * The relay's fallback for a shell task with no file: the CLI's last 8 KiB
1857
+ * (`Query.getTaskOutput`, implemented in sdk.mjs though sdk.d.ts does not
1858
+ * declare it), posted for the relay to serve. Asked once per viewer open.
1859
+ */
1860
+ async function snapshotTaskOutput(taskId) {
1861
+ const id = String(taskId || '').trim();
1862
+ const processRef = proc;
1863
+ if (!id || typeof processRef?.turn?.getTaskOutput !== 'function') {
1864
+ dbg('task output snapshot unavailable', id || '(no task)');
1865
+ return false;
1866
+ }
1867
+ let answer;
1868
+ try {
1869
+ answer = await processRef.turn.getTaskOutput(id);
1870
+ } catch (error) {
1871
+ dbg('getTaskOutput failed', id, error?.message || String(error));
1872
+ return false;
1873
+ }
1874
+ const output = typeof answer?.output === 'string' ? answer.output : '';
1875
+ const totalBytes = Number(answer?.total_bytes);
1876
+ try {
1877
+ await api('POST', `/api/background-tasks/${encodeURIComponent(id)}/output-snapshot`, {
1878
+ conversationId: sdkSessionId,
1879
+ output,
1880
+ totalBytes: Number.isFinite(totalBytes) && totalBytes >= 0 ? totalBytes : Buffer.byteLength(output),
1881
+ truncated: answer?.truncated === true,
1882
+ });
1883
+ return true;
1884
+ } catch (error) {
1885
+ dbg('task output snapshot post failed', id, error?.message || String(error));
1886
+ return false;
1887
+ }
1747
1888
  }
1748
1889
 
1749
1890
  // ---------------------------------------------------------------------------
@@ -2184,6 +2325,11 @@ export function createClaudeSessionRunner({
2184
2325
  recordSubagentActivity(sdkMessage);
2185
2326
  return;
2186
2327
  }
2328
+ if (type === 'user') {
2329
+ // Any agent's backgrounded shell, the main thread's or a subagent's.
2330
+ recordTaskOutputPaths(sdkMessage);
2331
+ return;
2332
+ }
2187
2333
  if (type !== 'system') return;
2188
2334
  const subtype = String(sdkMessage.subtype || '');
2189
2335
  if (subtype === 'init') {
@@ -2441,7 +2587,12 @@ export function createClaudeSessionRunner({
2441
2587
  // capture a settling workflow's final digest for the transcript card
2442
2588
  // (the notification then refines the status when it lands).
2443
2589
  for (const [taskId, task] of previous) {
2444
- if (!proc.liveTasks.has(taskId)) bufferSettledWorkflowRun(taskId, task);
2590
+ if (proc.liveTasks.has(taskId)) continue;
2591
+ bufferSettledWorkflowRun(taskId, task);
2592
+ // What the task was, for its settle notice: the row is gone by the
2593
+ // time the notification says how it ended.
2594
+ proc.departedTasks.set(taskId, task);
2595
+ if (proc.departedTasks.size > 50) proc.departedTasks.delete(proc.departedTasks.keys().next().value);
2445
2596
  }
2446
2597
  syncWorkflowPoller(proc);
2447
2598
  publishBackgroundTasks();
@@ -2477,6 +2628,9 @@ export function createClaudeSessionRunner({
2477
2628
  known.toolUseId = String(sdkMessage.tool_use_id || '').trim() || known.toolUseId || '';
2478
2629
  const totalTokens = Number(sdkMessage.usage?.total_tokens);
2479
2630
  if (Number.isFinite(totalTokens)) known.totalTokens = totalTokens;
2631
+ // The sub-agent's tool calls so far: the panel row's step count.
2632
+ const toolUses = Number(sdkMessage.usage?.tool_uses);
2633
+ if (Number.isFinite(toolUses) && toolUses >= 0) known.steps = Math.floor(toolUses);
2480
2634
  publishBackgroundTasks({ throttled: true });
2481
2635
  }
2482
2636
  return;
@@ -2491,14 +2645,37 @@ export function createClaudeSessionRunner({
2491
2645
  // this session (the live set only says it is gone). Ambient tasks are
2492
2646
  // not activity. Advisory: a failed post never disturbs the stream.
2493
2647
  if (taskId && sdkMessage.ambient !== true) {
2494
- api('POST', '/api/background-task-settled', {
2648
+ // A task that was in the published set is described too, so that a
2649
+ // relay which no longer knows it (it restarted meanwhile) can still
2650
+ // tell its watchers the end. A failed post is kept and sent again.
2651
+ const published = proc.liveTasks.get(taskId) || proc.departedTasks.get(taskId) || null;
2652
+ proc.departedTasks.delete(taskId);
2653
+ // A shell's file and exit code make the relay's ended row: its
2654
+ // console stays readable after the task is gone.
2655
+ rememberTaskOutputPath(proc, taskId, sdkMessage.output_file);
2656
+ const shellLike = isShellTaskType(published?.taskType);
2657
+ const outputPath = shellLike ? (proc.taskOutputPaths.get(taskId) || '') : '';
2658
+ const exitCode = shellLike ? exitCodeFromSummary(sdkMessage.summary) : null;
2659
+ const toolUses = Number(sdkMessage.usage?.tool_uses);
2660
+ const steps = AGENT_TASK_TYPES.has(String(published?.taskType || '')) && Number.isFinite(toolUses) && toolUses >= 0
2661
+ ? Math.floor(toolUses)
2662
+ : null;
2663
+ void keptTaskSettles.post('/api/background-task-settled', {
2495
2664
  conversationId: sdkSessionId,
2496
2665
  taskId,
2497
- toolUseId: String(sdkMessage.tool_use_id || '').trim() || null,
2666
+ toolUseId: String(sdkMessage.tool_use_id || published?.toolUseId || '').trim() || null,
2498
2667
  status: String(sdkMessage.status || '').trim() || 'completed',
2499
2668
  summary: String(sdkMessage.summary || '').trim().slice(0, 500) || null,
2500
2669
  ...(sdkMessage.reason ? { reason: String(sdkMessage.reason).slice(0, 40) } : {}),
2501
- }).catch((error) => dbg('background task settle publish failed', error?.message || String(error)));
2670
+ ...(published?.description ? {
2671
+ description: String(published.description).slice(0, 500),
2672
+ taskType: String(published.taskType || ''),
2673
+ startedAt: published.startedAt || null,
2674
+ } : {}),
2675
+ ...(outputPath ? { outputPath } : {}),
2676
+ ...(exitCode !== null ? { exitCode } : {}),
2677
+ ...(steps !== null ? { steps } : {}),
2678
+ });
2502
2679
  }
2503
2680
  // A settled session-level task means the CLI is about to dequeue a
2504
2681
  // continuation turn — the process must not idle out under it. The SDK
@@ -3249,7 +3426,7 @@ export function createClaudeSessionRunner({
3249
3426
  if (processRef.liveTasks.size) {
3250
3427
  processRef.liveTasks = new Map();
3251
3428
  if (wasCurrent) {
3252
- api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks: [] }).catch(() => {});
3429
+ postTaskSet({ tasks: [] }).catch(() => {});
3253
3430
  }
3254
3431
  }
3255
3432
  // Steers pushed into a turn the user stopped: if the Stop fell back to
@@ -3308,6 +3485,7 @@ export function createClaudeSessionRunner({
3308
3485
  const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
3309
3486
  const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
3310
3487
  const attribution = getAttribution() || null;
3488
+ const backgroundCommandLimitMs = getBackgroundCommandLimitMs() ?? null;
3311
3489
  const abortController = new AbortController();
3312
3490
  const processRef = {
3313
3491
  turn: null,
@@ -3331,6 +3509,14 @@ export function createClaudeSessionRunner({
3331
3509
  permissionMode: permissionModeForRelayMode(relayMode),
3332
3510
  liveTasks: new Map(),
3333
3511
  knownTasks: new Set(),
3512
+ // taskId → the row of a task that left the live set, until its
3513
+ // notification arrives.
3514
+ departedTasks: new Map(),
3515
+ // taskId → the file a shell task's output goes to (first named wins),
3516
+ // and the directory of the last one seen: a Monitor task's file sits
3517
+ // next to it.
3518
+ taskOutputPaths: new Map(),
3519
+ taskOutputDir: '',
3334
3520
  // taskId → { runId, labels, digestJson } for live local_workflow tasks;
3335
3521
  // pruned by syncWorkflowPoller when a task leaves the live set.
3336
3522
  workflowStates: new Map(),
@@ -3478,6 +3664,7 @@ export function createClaudeSessionRunner({
3478
3664
  thinkingEnabled,
3479
3665
  thinkingDisplay,
3480
3666
  attribution,
3667
+ backgroundCommandLimitMs,
3481
3668
  abortController,
3482
3669
  canUseTool,
3483
3670
  pathToClaudeCodeExecutable,
@@ -4034,6 +4221,21 @@ export function createClaudeSessionRunner({
4034
4221
  return false;
4035
4222
  }
4036
4223
 
4224
+ /** The ids of the live background tasks, for the heartbeat's digest. */
4225
+ function backgroundTaskIds() {
4226
+ return proc ? [...proc.liveTasks.keys()] : [];
4227
+ }
4228
+
4229
+ /** The relay asked for the task set again (heartbeat answer). */
4230
+ function resyncBackgroundTasks() {
4231
+ return publishBackgroundTasks({ resync: true });
4232
+ }
4233
+
4234
+ /** The relay answered a heartbeat: settle notices kept for it go out. */
4235
+ function flushKeptTaskSettles() {
4236
+ return keptTaskSettles.flush();
4237
+ }
4238
+
4037
4239
  return {
4038
4240
  handlePendingPayload,
4039
4241
  canAcceptSteering,
@@ -4046,6 +4248,10 @@ export function createClaudeSessionRunner({
4046
4248
  isTurnActive,
4047
4249
  stopBackgroundTask,
4048
4250
  stopBackgroundTaskByToolUseId,
4251
+ snapshotTaskOutput,
4252
+ backgroundTaskIds,
4253
+ resyncBackgroundTasks,
4254
+ flushKeptTaskSettles,
4049
4255
  shutdown,
4050
4256
  // Test seams / observability.
4051
4257
  _getProcess: () => proc,
@@ -18,6 +18,10 @@ import { createControlPoller } from '../../shared/control-poller.mjs';
18
18
  import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
19
19
  import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
20
20
  import { resolveDeliveredAttribution } from '../../shared/claude-attribution.mjs';
21
+ import {
22
+ DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS,
23
+ resolveDeliveredClaudeBackgroundCommandLimitMs,
24
+ } from '../../shared/claude-background-command-limit.mjs';
21
25
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
22
26
  import { createClaudeSessionRunner } from './claude-session-process.mjs';
23
27
  import { createRunnerLinkBridge, failSettlingRowsOnShutdown } from './claude-worker-link-wiring.mjs';
@@ -70,6 +74,9 @@ async function main() {
70
74
  // What this session's commits and PRs say; same piggyback. null = the
71
75
  // CLI's own attribution (vanilla).
72
76
  let attribution = null;
77
+ // How long a backgrounded Bash command may run without its own timeout;
78
+ // same piggyback, applied when the CLI process is next spawned.
79
+ let backgroundCommandLimitMs = DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS;
73
80
 
74
81
  const api = createApiClient({
75
82
  serverUrl,
@@ -116,9 +123,11 @@ async function main() {
116
123
  getAutoCompactWindow: () => autoCompactWindow,
117
124
  getThinking: () => thinking,
118
125
  getAttribution: () => attribution,
126
+ getBackgroundCommandLimitMs: () => backgroundCommandLimitMs,
119
127
  // A readiness flip is a hold starting or ending (question card,
120
128
  // compaction, a turn-opening delivery): the steering snapshot changed
121
129
  // with it, so it goes out now rather than on the next 10 s heartbeat.
130
+ onBackgroundTaskSetStale: () => heartbeat?.requestPulse(),
122
131
  onDeliveryReadinessChange: (ready) => {
123
132
  linkBridge.onDeliveryReadinessChange(ready);
124
133
  heartbeat?.requestPulse();
@@ -147,6 +156,12 @@ async function main() {
147
156
  // Consumed rows the runner could not fail itself; the relay fails them.
148
157
  getSettleFailed: () => turnRunner.getSettleFailed(),
149
158
  onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
159
+ // The relay holds the task set in memory only: the digest of the live
160
+ // task ids rides every beat, and a relay that is out of step (it
161
+ // restarted, a post was lost) asks for the set again.
162
+ getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
163
+ onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
164
+ onAnswered: () => turnRunner.flushKeptTaskSettles(),
150
165
  });
151
166
 
152
167
  const wsLink = createWorkerWebSocketLink({
@@ -164,7 +179,8 @@ async function main() {
164
179
  // even between deliveries — a delivery drawn into it is only handed back.
165
180
  getDeliveryHeld: () => turnRunner.isDeliveryHeld(),
166
181
  // A relay that just (re)started knows nothing of this worker until it
167
- // heartbeats; the composer's steering snapshot rides that beat.
182
+ // heartbeats; the composer's steering snapshot rides that beat, and so
183
+ // does the digest that makes the relay ask for the background tasks.
168
184
  onServerHello: () => heartbeat.requestPulse(),
169
185
  getSessionId: () => sdkSessionId,
170
186
  getPid: () => process.pid,
@@ -182,6 +198,7 @@ async function main() {
182
198
  // user choosing Host default.
183
199
  thinking = resolveDeliveredThinking(thinking, pending?.settings);
184
200
  attribution = resolveDeliveredAttribution(attribution, pending?.settings);
201
+ backgroundCommandLimitMs = resolveDeliveredClaudeBackgroundCommandLimitMs(backgroundCommandLimitMs, pending?.settings);
185
202
  try {
186
203
  return await turnRunner.handlePendingPayload(pending);
187
204
  } catch (error) {
@@ -197,6 +214,14 @@ async function main() {
197
214
  await turnRunner.stopBackgroundTask(taskId);
198
215
  return;
199
216
  }
217
+ if (type === 'task_output_snapshot') {
218
+ // A console opened on a shell task with no output file: the CLI's
219
+ // last 8 KiB goes to the relay, which serves it as a static tail.
220
+ const taskId = String(control?.taskId || '').trim();
221
+ dbg('task output snapshot control', taskId);
222
+ await turnRunner.snapshotTaskOutput(taskId);
223
+ return;
224
+ }
200
225
  dbg('unknown worker control ignored', type || '(none)');
201
226
  },
202
227
  });
@@ -0,0 +1,126 @@
1
+ // Where a Claude background shell writes its output, read off the SDK stream.
2
+ //
3
+ // The CLI writes every Bash task (and every Monitor tool task, which is a
4
+ // `local_bash` task of kind "monitor") to `<tasks dir>/<taskId>.output`, and
5
+ // tells the model the path in the Bash tool's result text: "Command running in
6
+ // background with ID: <id>. Output is being written to: <path>." (wording
7
+ // read from the bundled CLI, SDK 0.3.292). The structured result
8
+ // (`SDKUserMessage.tool_use_result`, a BashOutput) names the task id as
9
+ // `backgroundTaskId` but carries no path of its own. `task_notification`
10
+ // repeats the path as `output_file` when the task ends.
11
+ import nodePath from 'node:path';
12
+
13
+ /** Task types whose output is a shell's stdout/stderr (getTaskOutput serves these too). */
14
+ export const SHELL_TASK_TYPES = new Set(['local_bash', 'monitor', 'monitor_mcp', 'monitor_ws']);
15
+ export const MAX_OUTPUT_PATH_CHARS = 1024;
16
+
17
+ const OUTPUT_MARKER = 'Output is being written to';
18
+ // The CLI's own file name: a path with spaces (a Windows profile dir) still
19
+ // reads whole, up to its `.output` and the sentence's full stop.
20
+ const OUTPUT_FILE_PATTERN = /Output is being written to:?\s+([^\n;]+?\.output)(?=[.;,]?(?:\s|$))/;
21
+ // Any other shape: one token, without the trailing punctuation.
22
+ const OUTPUT_TOKEN_PATTERN = /Output is being written to:?\s+(\S+?)[.;,]?(?:\s|$)/;
23
+ const TASK_ID_PATTERN = /\bID:\s*([A-Za-z0-9_-]+)/;
24
+ // What the CLI's own background notes start with (Bash, SDK 0.3.292). A
25
+ // foreground command that merely prints "Output is being written to: ..."
26
+ // must never bind a path to a task, so only these, the MCP form below, or a
27
+ // result whose structured output names the task are read.
28
+ const CLI_BACKGROUND_PREFIXES = [
29
+ 'Command running in background',
30
+ 'Command was manually backgrounded',
31
+ 'Command was moved to the background',
32
+ 'Command did not complete within',
33
+ ];
34
+ // The backgrounded MCP task's note: no colon, then "; use <tool> on that path".
35
+ const MCP_BACKGROUND_PATTERN = /Output is being written to (\S[^\n;]*?\.output); use \S/;
36
+
37
+ export function isShellTaskType(taskType) {
38
+ return SHELL_TASK_TYPES.has(String(taskType || '').trim());
39
+ }
40
+
41
+ /** An absolute path of sane length, or ''. Windows shapes count on every host. */
42
+ export function usableOutputPath(value) {
43
+ const text = String(value || '').trim();
44
+ if (!text || text.length > MAX_OUTPUT_PATH_CHARS) return '';
45
+ return nodePath.posix.isAbsolute(text) || nodePath.win32.isAbsolute(text) ? text : '';
46
+ }
47
+
48
+ /** `.../tasks/b8k2m4x7q.output` → `b8k2m4x7q`. */
49
+ function taskIdFromOutputFile(filePath) {
50
+ const base = String(filePath).split(/[\\/]/).pop() || '';
51
+ return base.endsWith('.output') ? base.slice(0, -'.output'.length) : '';
52
+ }
53
+
54
+ /** The directory part of a path, for either separator. */
55
+ export function outputDirOf(filePath) {
56
+ const text = String(filePath || '');
57
+ const cut = Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\'));
58
+ return cut > 0 ? text.slice(0, cut) : '';
59
+ }
60
+
61
+ function toolResultText(block) {
62
+ const content = block?.content;
63
+ if (typeof content === 'string') return content;
64
+ if (!Array.isArray(content)) return '';
65
+ return content
66
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
67
+ .map((part) => part.text)
68
+ .join('\n');
69
+ }
70
+
71
+ /** `{ taskId, outputPath }` from one tool result's text, or null. */
72
+ export function parseOutputAnnouncement(text, knownTaskId = '') {
73
+ const source = String(text || '');
74
+ if (!source.includes(OUTPUT_MARKER)) return null;
75
+ const match = source.match(OUTPUT_FILE_PATTERN) || source.match(OUTPUT_TOKEN_PATTERN);
76
+ const outputPath = usableOutputPath(match?.[1]);
77
+ if (!outputPath) return null;
78
+ const taskId = String(knownTaskId || '').trim()
79
+ || taskIdFromOutputFile(outputPath)
80
+ || (source.match(TASK_ID_PATTERN)?.[1] || '');
81
+ return taskId ? { taskId, outputPath } : null;
82
+ }
83
+
84
+ /** The CLI's own note about a backgrounded call, not a command's printed output. */
85
+ function isBackgroundNote(text) {
86
+ const source = String(text || '').trimStart();
87
+ if (CLI_BACKGROUND_PREFIXES.some((prefix) => source.startsWith(prefix))) return true;
88
+ const mcp = source.match(MCP_BACKGROUND_PATTERN);
89
+ return Boolean(mcp && taskIdFromOutputFile(mcp[1]));
90
+ }
91
+
92
+ /**
93
+ * Every task output file a `user` SDK message announces: the tool_result
94
+ * blocks of backgrounded Bash (and Monitor) calls. The structured result's
95
+ * `backgroundTaskId` names the task when the message answers a single call.
96
+ */
97
+ export function discoverTaskOutputPaths(sdkMessage) {
98
+ if (String(sdkMessage?.type || '') !== 'user') return [];
99
+ const blocks = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
100
+ const results = blocks.filter((block) => block?.type === 'tool_result');
101
+ if (!results.length) return [];
102
+ const structured = sdkMessage.tool_use_result && typeof sdkMessage.tool_use_result === 'object'
103
+ ? sdkMessage.tool_use_result
104
+ : null;
105
+ const structuredTaskId = results.length === 1 ? String(structured?.backgroundTaskId || '').trim() : '';
106
+ const found = [];
107
+ for (const block of results) {
108
+ const text = toolResultText(block);
109
+ if (!structuredTaskId && !isBackgroundNote(text)) continue;
110
+ const announced = parseOutputAnnouncement(text, structuredTaskId);
111
+ if (announced) found.push(announced);
112
+ }
113
+ return found;
114
+ }
115
+
116
+ /**
117
+ * The exit code a shell task's settle summary names: `Background command
118
+ * "npm test" failed with exit code 2` / `... completed (exit code 0)`.
119
+ * The last mention wins (the command's own description may say "exit code").
120
+ */
121
+ export function exitCodeFromSummary(summary) {
122
+ const matches = [...String(summary || '').matchAll(/exit code (-?\d+)/gi)];
123
+ if (!matches.length) return null;
124
+ const code = Number(matches[matches.length - 1][1]);
125
+ return Number.isInteger(code) ? code : null;
126
+ }