@oar-sh/oar 0.9.11 → 0.9.13

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 (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  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 +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -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/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -58,6 +58,14 @@ import {
58
58
  withSlashCommandGuardContent,
59
59
  } from '../../shared/slash-command-guard.mjs';
60
60
  import { createUsageLimitTracker } from '../../shared/claude-usage-limit.mjs';
61
+ import {
62
+ discoverTaskOutputPaths,
63
+ shellCommandsOf,
64
+ exitCodeFromSummary,
65
+ isShellTaskType,
66
+ outputDirOf,
67
+ usableOutputPath,
68
+ } from './task-output-discovery.mjs';
61
69
 
62
70
  /**
63
71
  * Which system-prompt append a relay mode gets (claude-sdk-adapter's
@@ -77,6 +85,8 @@ export { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT };
77
85
  /** Task types that run a model of their own (vs. bash/monitor/workflow). */
78
86
  const AGENT_TASK_TYPES = new Set(['local_agent', 'agent', 'subagent']);
79
87
  const MAX_TASK_TOOL_CALL_CHARS = 300;
88
+ /** Output paths remembered per process (one per backgrounded shell). */
89
+ const MAX_TASK_OUTPUT_PATHS = 200;
80
90
 
81
91
  // ---------------------------------------------------------------------------
82
92
  // Workflow progress sources (CLI 2.1.226 on-disk formats)
@@ -460,6 +470,10 @@ export function createClaudeSessionRunner({
460
470
  // leaves the CLI's own attribution. Piggybacked on deliveries like the
461
471
  // window above.
462
472
  getAttribution = () => null,
473
+ // How long a backgrounded Bash command may run without its own timeout
474
+ // (ms; the relay's Claude setting). Read at spawn only: the CLI takes it
475
+ // from its environment, so a change applies to the next process.
476
+ getBackgroundCommandLimitMs = () => null,
463
477
  // The per-conversation thinking state ({enabled, display}); read per turn
464
478
  // like the window, so a settings change reaches a running process on its
465
479
  // next delivery.
@@ -1769,6 +1783,11 @@ export function createClaudeSessionRunner({
1769
1783
  // The workflow digest (poller below) rides the same row; the relay
1770
1784
  // sanitizer whitelists and re-clamps it before storing.
1771
1785
  ...(task.workflowProgress ? { workflowProgress: task.workflowProgress } : {}),
1786
+ ...(agentLike && Number.isInteger(task.steps) ? { steps: task.steps } : {}),
1787
+ // Where the relay reads the task's console from, and the command
1788
+ // line the console reads a `| tail` off.
1789
+ ...(isShellTaskType(task.taskType) ? taskOutputFields(processRef, taskId) : {}),
1790
+ ...(isShellTaskType(task.taskType) && taskCommand(processRef, taskId, task) ? { command: taskCommand(processRef, taskId, task) } : {}),
1772
1791
  };
1773
1792
  });
1774
1793
  return postTaskSet({ tasks, ...(resync ? { resync: true } : {}) }).catch((error) => {
@@ -1788,6 +1807,126 @@ export function createClaudeSessionRunner({
1788
1807
  return Promise.resolve();
1789
1808
  }
1790
1809
 
1810
+ // ---------------------------------------------------------------------------
1811
+ // Task output files
1812
+ //
1813
+ // The relay reads a shell task's console straight from the file the CLI
1814
+ // writes (`<tasks dir>/<taskId>.output`); the worker only names it. Bash
1815
+ // results announce the path (task-output-discovery.mjs); a Monitor result
1816
+ // does not, but its file follows the same scheme next to any known one, and
1817
+ // is named only once it exists. Without a path the relay falls back to the
1818
+ // CLI's `getTaskOutput` snapshot (task_output_snapshot control).
1819
+
1820
+ function rememberTaskOutputPath(processRef, taskId, outputPath) {
1821
+ const id = String(taskId || '').trim();
1822
+ const usable = usableOutputPath(outputPath);
1823
+ if (!id || !usable) return false;
1824
+ const dir = outputDirOf(usable);
1825
+ if (dir) processRef.taskOutputDir = dir;
1826
+ // The first path a task names wins, here as on the relay.
1827
+ if (processRef.taskOutputPaths.has(id)) return false;
1828
+ processRef.taskOutputPaths.set(id, usable);
1829
+ while (processRef.taskOutputPaths.size > MAX_TASK_OUTPUT_PATHS) {
1830
+ processRef.taskOutputPaths.delete(processRef.taskOutputPaths.keys().next().value);
1831
+ }
1832
+ return true;
1833
+ }
1834
+
1835
+ function taskOutputFields(processRef, taskId) {
1836
+ const known = processRef.taskOutputPaths.get(taskId);
1837
+ if (known) return { outputPath: known, outputKind: 'file' };
1838
+ if (processRef.taskOutputDir) {
1839
+ const candidate = nodePath.join(processRef.taskOutputDir, `${taskId}.output`);
1840
+ let exists = false;
1841
+ try { exists = nodeFs.existsSync(candidate); } catch {}
1842
+ if (exists && rememberTaskOutputPath(processRef, taskId, candidate)) {
1843
+ return { outputPath: candidate, outputKind: 'file' };
1844
+ }
1845
+ }
1846
+ return { outputKind: 'snapshot' };
1847
+ }
1848
+
1849
+ /** A backgrounded Bash/Monitor result: note its file, and show it on the live row. */
1850
+ function recordTaskOutputPaths(sdkMessage) {
1851
+ let liveChanged = false;
1852
+ for (const { taskId, outputPath, toolUseId } of discoverTaskOutputPaths(sdkMessage)) {
1853
+ if (toolUseId && !proc.taskToolUses.has(taskId)) {
1854
+ proc.taskToolUses.set(taskId, toolUseId);
1855
+ capMap(proc.taskToolUses, MAX_TASK_OUTPUT_PATHS);
1856
+ if (proc.liveTasks.has(taskId) && proc.shellCommands.has(toolUseId)) liveChanged = true;
1857
+ }
1858
+ if (rememberTaskOutputPath(proc, taskId, outputPath) && proc.liveTasks.has(taskId)) liveChanged = true;
1859
+ }
1860
+ if (liveChanged) publishBackgroundTasks();
1861
+ }
1862
+
1863
+ function capMap(map, max) {
1864
+ while (map.size > max) map.delete(map.keys().next().value);
1865
+ }
1866
+
1867
+ // The command line of each Bash or Monitor call, by its tool_use id: a
1868
+ // shell task's row names it (task_started's tool_use_id, or the call whose
1869
+ // result announced the task's file).
1870
+ function recordShellCommands(sdkMessage) {
1871
+ for (const { toolUseId, command } of shellCommandsOf(sdkMessage)) {
1872
+ proc.shellCommands.set(toolUseId, command);
1873
+ capMap(proc.shellCommands, MAX_TASK_OUTPUT_PATHS);
1874
+ }
1875
+ }
1876
+
1877
+ // A task's command, pinned to its id once found: the 200 newest calls
1878
+ // the map by tool_use id keeps may move past a long-running task's call.
1879
+ function taskCommand(processRef, taskId, task) {
1880
+ const pinned = processRef.taskCommands.get(taskId);
1881
+ if (pinned) return pinned;
1882
+ const toolUseId = task?.toolUseId || processRef.taskToolUses.get(taskId) || '';
1883
+ const command = (toolUseId && processRef.shellCommands.get(toolUseId)) || '';
1884
+ if (command && (processRef.liveTasks.has(taskId) || processRef.departedTasks.has(taskId))) processRef.taskCommands.set(taskId, command);
1885
+ return command;
1886
+ }
1887
+
1888
+ // Pinned commands go with their task: live or departed until its settle.
1889
+ function prunePinnedCommands(processRef) {
1890
+ for (const taskId of [...processRef.taskCommands.keys()]) {
1891
+ if (!processRef.liveTasks.has(taskId) && !processRef.departedTasks.has(taskId)) processRef.taskCommands.delete(taskId);
1892
+ }
1893
+ }
1894
+
1895
+ /**
1896
+ * The relay's fallback for a shell task with no file: the CLI's last 8 KiB
1897
+ * (`Query.getTaskOutput`, implemented in sdk.mjs though sdk.d.ts does not
1898
+ * declare it), posted for the relay to serve. Asked once per viewer open.
1899
+ */
1900
+ async function snapshotTaskOutput(taskId) {
1901
+ const id = String(taskId || '').trim();
1902
+ const processRef = proc;
1903
+ if (!id || typeof processRef?.turn?.getTaskOutput !== 'function') {
1904
+ dbg('task output snapshot unavailable', id || '(no task)');
1905
+ return false;
1906
+ }
1907
+ let answer;
1908
+ try {
1909
+ answer = await processRef.turn.getTaskOutput(id);
1910
+ } catch (error) {
1911
+ dbg('getTaskOutput failed', id, error?.message || String(error));
1912
+ return false;
1913
+ }
1914
+ const output = typeof answer?.output === 'string' ? answer.output : '';
1915
+ const totalBytes = Number(answer?.total_bytes);
1916
+ try {
1917
+ await api('POST', `/api/background-tasks/${encodeURIComponent(id)}/output-snapshot`, {
1918
+ conversationId: sdkSessionId,
1919
+ output,
1920
+ totalBytes: Number.isFinite(totalBytes) && totalBytes >= 0 ? totalBytes : Buffer.byteLength(output),
1921
+ truncated: answer?.truncated === true,
1922
+ });
1923
+ return true;
1924
+ } catch (error) {
1925
+ dbg('task output snapshot post failed', id, error?.message || String(error));
1926
+ return false;
1927
+ }
1928
+ }
1929
+
1791
1930
  // ---------------------------------------------------------------------------
1792
1931
  // Workflow progress poller
1793
1932
  //
@@ -2222,10 +2361,16 @@ export function createClaudeSessionRunner({
2222
2361
  function observeProcessLevel(sdkMessage) {
2223
2362
  const type = String(sdkMessage?.type || '');
2224
2363
  if (type === 'assistant') {
2364
+ recordShellCommands(sdkMessage);
2225
2365
  recordSubagentSpawns(sdkMessage);
2226
2366
  recordSubagentActivity(sdkMessage);
2227
2367
  return;
2228
2368
  }
2369
+ if (type === 'user') {
2370
+ // Any agent's backgrounded shell, the main thread's or a subagent's.
2371
+ recordTaskOutputPaths(sdkMessage);
2372
+ return;
2373
+ }
2229
2374
  if (type !== 'system') return;
2230
2375
  const subtype = String(sdkMessage.subtype || '');
2231
2376
  if (subtype === 'init') {
@@ -2492,6 +2637,7 @@ export function createClaudeSessionRunner({
2492
2637
  }
2493
2638
  syncWorkflowPoller(proc);
2494
2639
  publishBackgroundTasks();
2640
+ prunePinnedCommands(proc);
2495
2641
  evaluateLifecycle();
2496
2642
  return;
2497
2643
  }
@@ -2524,6 +2670,9 @@ export function createClaudeSessionRunner({
2524
2670
  known.toolUseId = String(sdkMessage.tool_use_id || '').trim() || known.toolUseId || '';
2525
2671
  const totalTokens = Number(sdkMessage.usage?.total_tokens);
2526
2672
  if (Number.isFinite(totalTokens)) known.totalTokens = totalTokens;
2673
+ // The sub-agent's tool calls so far: the panel row's step count.
2674
+ const toolUses = Number(sdkMessage.usage?.tool_uses);
2675
+ if (Number.isFinite(toolUses) && toolUses >= 0) known.steps = Math.floor(toolUses);
2527
2676
  publishBackgroundTasks({ throttled: true });
2528
2677
  }
2529
2678
  return;
@@ -2543,6 +2692,18 @@ export function createClaudeSessionRunner({
2543
2692
  // tell its watchers the end. A failed post is kept and sent again.
2544
2693
  const published = proc.liveTasks.get(taskId) || proc.departedTasks.get(taskId) || null;
2545
2694
  proc.departedTasks.delete(taskId);
2695
+ // A shell's file and exit code make the relay's ended row: its
2696
+ // console stays readable after the task is gone.
2697
+ rememberTaskOutputPath(proc, taskId, sdkMessage.output_file);
2698
+ const shellLike = isShellTaskType(published?.taskType);
2699
+ const outputPath = shellLike ? (proc.taskOutputPaths.get(taskId) || '') : '';
2700
+ const command = shellLike ? taskCommand(proc, taskId, { toolUseId: String(sdkMessage.tool_use_id || published?.toolUseId || '').trim() }) : '';
2701
+ proc.taskCommands.delete(taskId);
2702
+ const exitCode = shellLike ? exitCodeFromSummary(sdkMessage.summary) : null;
2703
+ const toolUses = Number(sdkMessage.usage?.tool_uses);
2704
+ const steps = AGENT_TASK_TYPES.has(String(published?.taskType || '')) && Number.isFinite(toolUses) && toolUses >= 0
2705
+ ? Math.floor(toolUses)
2706
+ : null;
2546
2707
  void keptTaskSettles.post('/api/background-task-settled', {
2547
2708
  conversationId: sdkSessionId,
2548
2709
  taskId,
@@ -2555,6 +2716,10 @@ export function createClaudeSessionRunner({
2555
2716
  taskType: String(published.taskType || ''),
2556
2717
  startedAt: published.startedAt || null,
2557
2718
  } : {}),
2719
+ ...(outputPath ? { outputPath } : {}),
2720
+ ...(command ? { command } : {}),
2721
+ ...(exitCode !== null ? { exitCode } : {}),
2722
+ ...(steps !== null ? { steps } : {}),
2558
2723
  });
2559
2724
  }
2560
2725
  // A settled session-level task means the CLI is about to dequeue a
@@ -3365,6 +3530,7 @@ export function createClaudeSessionRunner({
3365
3530
  const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
3366
3531
  const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
3367
3532
  const attribution = getAttribution() || null;
3533
+ const backgroundCommandLimitMs = getBackgroundCommandLimitMs() ?? null;
3368
3534
  const abortController = new AbortController();
3369
3535
  const processRef = {
3370
3536
  turn: null,
@@ -3391,6 +3557,17 @@ export function createClaudeSessionRunner({
3391
3557
  // taskId → the row of a task that left the live set, until its
3392
3558
  // notification arrives.
3393
3559
  departedTasks: new Map(),
3560
+ // taskId → the file a shell task's output goes to (first named wins),
3561
+ // and the directory of the last one seen: a Monitor task's file sits
3562
+ // next to it.
3563
+ taskOutputPaths: new Map(),
3564
+ taskOutputDir: '',
3565
+ // tool_use id → the command line of a Bash or Monitor call, and taskId
3566
+ // → the call whose result announced the task: a shell row's command.
3567
+ shellCommands: new Map(),
3568
+ taskToolUses: new Map(),
3569
+ // taskId → its command, pinned while the task is live or departed.
3570
+ taskCommands: new Map(),
3394
3571
  // taskId → { runId, labels, digestJson } for live local_workflow tasks;
3395
3572
  // pruned by syncWorkflowPoller when a task leaves the live set.
3396
3573
  workflowStates: new Map(),
@@ -3538,6 +3715,7 @@ export function createClaudeSessionRunner({
3538
3715
  thinkingEnabled,
3539
3716
  thinkingDisplay,
3540
3717
  attribution,
3718
+ backgroundCommandLimitMs,
3541
3719
  abortController,
3542
3720
  canUseTool,
3543
3721
  pathToClaudeCodeExecutable,
@@ -4121,6 +4299,7 @@ export function createClaudeSessionRunner({
4121
4299
  isTurnActive,
4122
4300
  stopBackgroundTask,
4123
4301
  stopBackgroundTaskByToolUseId,
4302
+ snapshotTaskOutput,
4124
4303
  backgroundTaskIds,
4125
4304
  resyncBackgroundTasks,
4126
4305
  flushKeptTaskSettles,
@@ -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,6 +123,7 @@ 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.
@@ -190,6 +198,7 @@ async function main() {
190
198
  // user choosing Host default.
191
199
  thinking = resolveDeliveredThinking(thinking, pending?.settings);
192
200
  attribution = resolveDeliveredAttribution(attribution, pending?.settings);
201
+ backgroundCommandLimitMs = resolveDeliveredClaudeBackgroundCommandLimitMs(backgroundCommandLimitMs, pending?.settings);
193
202
  try {
194
203
  return await turnRunner.handlePendingPayload(pending);
195
204
  } catch (error) {
@@ -205,6 +214,14 @@ async function main() {
205
214
  await turnRunner.stopBackgroundTask(taskId);
206
215
  return;
207
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
+ }
208
225
  dbg('unknown worker control ignored', type || '(none)');
209
226
  },
210
227
  });
@@ -0,0 +1,152 @@
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
+ // The command line a shell row carries. The relay scrubs it, then cuts it
17
+ // to 2,000 characters: a cut here first could split a credential it would
18
+ // no longer recognise, so this one is only a bound on the post.
19
+ export const MAX_TASK_COMMAND_CHARS = 8000;
20
+ // The tools whose call starts a shell task: their input's `command` is the
21
+ // task's command line (the console marks a `| tail` from it).
22
+ const SHELL_TOOL_NAMES = new Set(['Bash', 'Monitor']);
23
+
24
+ const OUTPUT_MARKER = 'Output is being written to';
25
+ // The CLI's own file name: a path with spaces (a Windows profile dir) still
26
+ // reads whole, up to its `.output` and the sentence's full stop.
27
+ const OUTPUT_FILE_PATTERN = /Output is being written to:?\s+([^\n;]+?\.output)(?=[.;,]?(?:\s|$))/;
28
+ // Any other shape: one token, without the trailing punctuation.
29
+ const OUTPUT_TOKEN_PATTERN = /Output is being written to:?\s+(\S+?)[.;,]?(?:\s|$)/;
30
+ const TASK_ID_PATTERN = /\bID:\s*([A-Za-z0-9_-]+)/;
31
+ // What the CLI's own background notes start with (Bash, SDK 0.3.292). A
32
+ // foreground command that merely prints "Output is being written to: ..."
33
+ // must never bind a path to a task, so only these, the MCP form below, or a
34
+ // result whose structured output names the task are read.
35
+ const CLI_BACKGROUND_PREFIXES = [
36
+ 'Command running in background',
37
+ 'Command was manually backgrounded',
38
+ 'Command was moved to the background',
39
+ 'Command did not complete within',
40
+ ];
41
+ // The backgrounded MCP task's note: no colon, then "; use <tool> on that path".
42
+ const MCP_BACKGROUND_PATTERN = /Output is being written to (\S[^\n;]*?\.output); use \S/;
43
+
44
+ export function isShellTaskType(taskType) {
45
+ return SHELL_TASK_TYPES.has(String(taskType || '').trim());
46
+ }
47
+
48
+ /** An absolute path of sane length, or ''. Windows shapes count on every host. */
49
+ export function usableOutputPath(value) {
50
+ const text = String(value || '').trim();
51
+ if (!text || text.length > MAX_OUTPUT_PATH_CHARS) return '';
52
+ return nodePath.posix.isAbsolute(text) || nodePath.win32.isAbsolute(text) ? text : '';
53
+ }
54
+
55
+ /** `.../tasks/b8k2m4x7q.output` → `b8k2m4x7q`. */
56
+ function taskIdFromOutputFile(filePath) {
57
+ const base = String(filePath).split(/[\\/]/).pop() || '';
58
+ return base.endsWith('.output') ? base.slice(0, -'.output'.length) : '';
59
+ }
60
+
61
+ /** The directory part of a path, for either separator. */
62
+ export function outputDirOf(filePath) {
63
+ const text = String(filePath || '');
64
+ const cut = Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\'));
65
+ return cut > 0 ? text.slice(0, cut) : '';
66
+ }
67
+
68
+ function toolResultText(block) {
69
+ const content = block?.content;
70
+ if (typeof content === 'string') return content;
71
+ if (!Array.isArray(content)) return '';
72
+ return content
73
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
74
+ .map((part) => part.text)
75
+ .join('\n');
76
+ }
77
+
78
+ /** `{ taskId, outputPath }` from one tool result's text, or null. */
79
+ export function parseOutputAnnouncement(text, knownTaskId = '') {
80
+ const source = String(text || '');
81
+ if (!source.includes(OUTPUT_MARKER)) return null;
82
+ const match = source.match(OUTPUT_FILE_PATTERN) || source.match(OUTPUT_TOKEN_PATTERN);
83
+ const outputPath = usableOutputPath(match?.[1]);
84
+ if (!outputPath) return null;
85
+ const taskId = String(knownTaskId || '').trim()
86
+ || taskIdFromOutputFile(outputPath)
87
+ || (source.match(TASK_ID_PATTERN)?.[1] || '');
88
+ return taskId ? { taskId, outputPath } : null;
89
+ }
90
+
91
+ /** The CLI's own note about a backgrounded call, not a command's printed output. */
92
+ function isBackgroundNote(text) {
93
+ const source = String(text || '').trimStart();
94
+ if (CLI_BACKGROUND_PREFIXES.some((prefix) => source.startsWith(prefix))) return true;
95
+ const mcp = source.match(MCP_BACKGROUND_PATTERN);
96
+ return Boolean(mcp && taskIdFromOutputFile(mcp[1]));
97
+ }
98
+
99
+ /**
100
+ * `{ toolUseId, command }` of each Bash or Monitor call in an `assistant` SDK
101
+ * message: the command line of the task such a call starts in the
102
+ * background, cut at 8,000 characters (the relay cuts after scrubbing).
103
+ */
104
+ export function shellCommandsOf(sdkMessage) {
105
+ if (String(sdkMessage?.type || '') !== 'assistant') return [];
106
+ const blocks = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
107
+ const found = [];
108
+ for (const block of blocks) {
109
+ if (block?.type !== 'tool_use' || !SHELL_TOOL_NAMES.has(String(block.name || ''))) continue;
110
+ const toolUseId = String(block.id || '').trim();
111
+ const command = typeof block.input?.command === 'string' ? block.input.command.trim().slice(0, MAX_TASK_COMMAND_CHARS) : '';
112
+ if (toolUseId && command) found.push({ toolUseId, command });
113
+ }
114
+ return found;
115
+ }
116
+
117
+ /**
118
+ * Every task output file a `user` SDK message announces: the tool_result
119
+ * blocks of backgrounded Bash (and Monitor) calls, with the id of the call
120
+ * (`toolUseId`, '' when the block names none). The structured result's
121
+ * `backgroundTaskId` names the task when the message answers a single call.
122
+ */
123
+ export function discoverTaskOutputPaths(sdkMessage) {
124
+ if (String(sdkMessage?.type || '') !== 'user') return [];
125
+ const blocks = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
126
+ const results = blocks.filter((block) => block?.type === 'tool_result');
127
+ if (!results.length) return [];
128
+ const structured = sdkMessage.tool_use_result && typeof sdkMessage.tool_use_result === 'object'
129
+ ? sdkMessage.tool_use_result
130
+ : null;
131
+ const structuredTaskId = results.length === 1 ? String(structured?.backgroundTaskId || '').trim() : '';
132
+ const found = [];
133
+ for (const block of results) {
134
+ const text = toolResultText(block);
135
+ if (!structuredTaskId && !isBackgroundNote(text)) continue;
136
+ const announced = parseOutputAnnouncement(text, structuredTaskId);
137
+ if (announced) found.push({ ...announced, toolUseId: String(block.tool_use_id || '').trim() });
138
+ }
139
+ return found;
140
+ }
141
+
142
+ /**
143
+ * The exit code a shell task's settle summary names: `Background command
144
+ * "npm test" failed with exit code 2` / `... completed (exit code 0)`.
145
+ * The last mention wins (the command's own description may say "exit code").
146
+ */
147
+ export function exitCodeFromSummary(summary) {
148
+ const matches = [...String(summary || '').matchAll(/exit code (-?\d+)/gi)];
149
+ if (!matches.length) return null;
150
+ const code = Number(matches[matches.length - 1][1]);
151
+ return Number.isInteger(code) ? code : null;
152
+ }
@@ -249,9 +249,13 @@ export function createReplayGate() {
249
249
  * `observe(event)` returns what the event changed, so the caller can pin the
250
250
  * runtime's lifecycle and note that a continuation is due without re-parsing.
251
251
  */
252
+ // A shell row's command line: a bound on the post. The relay scrubs it, then
253
+ // cuts it to 2,000 characters.
254
+ const SHELL_COMMAND_CHARS_MAX = 8000;
255
+
252
256
  export function createBackgroundShellTracker({ now = () => Date.now() } = {}) {
253
- const shells = new Map(); // shellId -> { shellId, description, startedAt }
254
- const toolCalls = new Map(); // toolCallId -> { toolName, shellId, description }
257
+ const shells = new Map(); // shellId -> { shellId, description, command, startedAt }
258
+ const toolCalls = new Map(); // toolCallId -> { toolName, shellId, description, command }
255
259
 
256
260
  function rememberToolCall(toolCallId, entry) {
257
261
  if (!toolCallId) return;
@@ -264,10 +268,10 @@ export function createBackgroundShellTracker({ now = () => Date.now() } = {}) {
264
268
  }
265
269
  }
266
270
 
267
- function open(shellId, description) {
271
+ function open(shellId, description, command = '') {
268
272
  const id = normalizeShellId(shellId);
269
273
  if (!id || shells.has(id)) return null;
270
- const shell = { shellId: id, description: String(description || '').trim(), startedAt: now() };
274
+ const shell = { shellId: id, description: String(description || '').trim(), command: String(command || '').trim(), startedAt: now() };
271
275
  shells.set(id, shell);
272
276
  return shell;
273
277
  }
@@ -308,6 +312,8 @@ export function createBackgroundShellTracker({ now = () => Date.now() } = {}) {
308
312
  toolName,
309
313
  shellId: normalizeShellId(args.shellId),
310
314
  description: String(args.description || '').trim(),
315
+ // The shell tool's command line, for the row.
316
+ command: typeof args.command === 'string' ? args.command.trim().slice(0, SHELL_COMMAND_CHARS_MAX) : '',
311
317
  });
312
318
  return { opened, settled, heralded };
313
319
  }
@@ -337,7 +343,7 @@ export function createBackgroundShellTracker({ now = () => Date.now() } = {}) {
337
343
 
338
344
  const started = SHELL_OPENED_RE.exec(text);
339
345
  if (started && data.success !== false) {
340
- const shell = open(started[1], call?.description);
346
+ const shell = open(started[1], call?.description, call?.command);
341
347
  if (shell) opened.push(shell);
342
348
  return { opened, settled, heralded };
343
349
  }