@oar-sh/oar 0.9.12 → 0.9.14

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 (115) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +508 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +155 -94
  7. package/package.json +5 -5
  8. package/server/README.md +479 -157
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +92 -15
  11. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  12. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  13. package/server/claude-worker/task-output-discovery.mjs +28 -2
  14. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  15. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  16. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  17. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  18. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
  20. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  21. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  22. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  23. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  24. package/server/db-schema.mjs +10 -0
  25. package/server/grok-worker/acp-host-services.mjs +6 -3
  26. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  27. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  28. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  29. package/server/migrations/0018-background-task-rows.mjs +101 -0
  30. package/server/public/app/action-confirmations.js +12 -15
  31. package/server/public/app/api-client.js +60 -4
  32. package/server/public/app/ask-user-view.js +29 -4
  33. package/server/public/app/background-tasks-view.mjs +56 -7
  34. package/server/public/app/bootstrap.js +7 -0
  35. package/server/public/app/conversation-preferences.mjs +10 -4
  36. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  37. package/server/public/app/conversation-view.js +33 -9
  38. package/server/public/app/host-suspend-ui.js +45 -44
  39. package/server/public/app/journal-view.js +9 -3
  40. package/server/public/app/push-settings.js +1 -0
  41. package/server/public/app/question-answer-rule.mjs +15 -0
  42. package/server/public/app/question-multi-select.mjs +9 -2
  43. package/server/public/app/relay-event-view.mjs +5 -1
  44. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  45. package/server/public/app/relay-restart-note-view.mjs +36 -0
  46. package/server/public/app/relay-restart-ui.js +568 -0
  47. package/server/public/app/settings-modal.js +52 -0
  48. package/server/public/app/socket-handlers.js +1 -1
  49. package/server/public/app/task-console-render.mjs +171 -7
  50. package/server/public/app/task-console.mjs +111 -11
  51. package/server/public/app/task-output-text.mjs +15 -6
  52. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  53. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  54. package/server/public/index.html +76 -5
  55. package/server/relay-exit-codes.mjs +6 -0
  56. package/server/relay-self-restart.mjs +6 -1
  57. package/server/relay-tools.md +17 -9
  58. package/server/repositories/message-repository.mjs +32 -28
  59. package/server/repositories/remote-relay-repository.mjs +4 -2
  60. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  61. package/server/routes/ask-user-routes.mjs +41 -7
  62. package/server/routes/messages-routes.mjs +220 -37
  63. package/server/routes/preview-routes.mjs +73 -7
  64. package/server/routes/relay-board-routes.mjs +19 -2
  65. package/server/routes/relay-restart-routes.mjs +73 -0
  66. package/server/routes/sessions-routes.mjs +79 -5
  67. package/server/server-runtime.mjs +335 -218
  68. package/server/services/about-service.mjs +13 -12
  69. package/server/services/background-task-persistence.mjs +133 -0
  70. package/server/services/background-task-store.mjs +240 -13
  71. package/server/services/conversation-model-fallback.mjs +57 -0
  72. package/server/services/cursor-session-root-service.mjs +70 -6
  73. package/server/services/data-dir-resolution.mjs +208 -19
  74. package/server/services/earlier-database-service.mjs +631 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/oar-cli-helpers.mjs +6 -1
  77. package/server/services/oar-state-layout.mjs +107 -0
  78. package/server/services/oar-state-migration-service.mjs +387 -96
  79. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  80. package/server/services/package-data-rescue.mjs +137 -0
  81. package/server/services/preview-proxy-server.mjs +14 -1
  82. package/server/services/preview-registry-service.mjs +3 -2
  83. package/server/services/process-list-service.mjs +2 -1
  84. package/server/services/provider-model-selection.mjs +195 -0
  85. package/server/services/push-dispatch-service.mjs +42 -8
  86. package/server/services/relay-event-service.mjs +210 -14
  87. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  88. package/server/services/relay-restart-picture.mjs +209 -0
  89. package/server/services/relay-restart-queue.mjs +387 -0
  90. package/server/services/relay-shutdown-gate.mjs +4 -1
  91. package/server/services/relay-singleton-guard.mjs +300 -36
  92. package/server/services/remote-relay-caller-context.mjs +7 -2
  93. package/server/services/remote-relay-dispatcher.mjs +217 -28
  94. package/server/services/restart-resume-service.mjs +303 -0
  95. package/server/services/session-activity-tracker.mjs +52 -15
  96. package/server/services/session-worker-launch-service.mjs +31 -1
  97. package/server/services/session-worker-process-service.mjs +6 -4
  98. package/server/services/stale-turn-recovery.mjs +99 -0
  99. package/server/services/subagent-timeline-service.mjs +4 -1
  100. package/server/services/task-output-service.mjs +217 -18
  101. package/server/services/windows-autostart-service.mjs +329 -31
  102. package/server/services/windows-boot-autostart-service.mjs +188 -60
  103. package/shared/preview-instructions.mjs +17 -12
  104. package/shared/preview-tool-core.mjs +32 -14
  105. package/shared/question-answer-rule.mjs +63 -0
  106. package/shared/relay-events.mjs +106 -3
  107. package/shared/remote-relay-contract.mjs +32 -15
  108. package/shared/subagent-timeline.mjs +4 -0
  109. package/shared/task-output-text.mjs +196 -83
  110. package/shared/windows-system-tools.mjs +25 -0
  111. package/shared/worker-runtime/config-loader.mjs +54 -8
  112. package/shared/worker-runtime/prompt-context.mjs +3 -1
  113. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
  114. package/shared/worker-runtime/working-directory.mjs +65 -0
  115. package/server/uploads/image_input.md +0 -540
@@ -4,6 +4,7 @@ import fs from 'node:fs';
4
4
  import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
5
5
  import { createRelayQuestion } from '../../shared/question-wait.mjs';
6
6
  import { DEFAULT_QUESTION_TIMEOUT_MS } from '../../shared/question-timeout.mjs';
7
+ import { readApprovalAnswer } from '../../shared/question-answer-rule.mjs';
7
8
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
8
9
  import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
9
10
  import { resolveDeliveredAttribution, sameClaudeAttribution } from '../../shared/claude-attribution.mjs';
@@ -27,6 +28,7 @@ const PERMISSION_ALLOW = 'Allow';
27
28
  const PERMISSION_DENY = 'Deny';
28
29
  const QUESTION_UNANSWERED_MESSAGE = 'The user did not answer. Continue without this, or end the turn and say what you need.';
29
30
  const PERMISSION_DENIED_MESSAGE = 'The user denied this tool use.';
31
+ const PERMISSION_UNREADABLE_MESSAGE = 'The permission card was answered with neither Allow nor Deny, so the tool use is not allowed.';
30
32
  // A catch-up after a worker restart reads the events of one unfinished turn;
31
33
  // the cap only stops a cursor that never advances.
32
34
  const MAX_CATCH_UP_PAGES = 200;
@@ -253,8 +255,8 @@ export function createClaudeCloudSessionRunner({
253
255
  const requested = String(message?.model || '').trim();
254
256
  const perTurn = requested && requested.toLowerCase() !== 'auto' ? requested : '';
255
257
  // The delivered message names the conversation's current model; the launch
256
- // model is what it was when the worker started, and the model may still be
257
- // changed until the first message creates the cloud session.
258
+ // model is what it was when the worker started. A change after the first
259
+ // message is applied before the next one (applyModel).
258
260
  return stripModelTierSuffix(String(message?.providerModel || '').trim() || perTurn || defaultModel);
259
261
  }
260
262
 
@@ -723,8 +725,12 @@ export function createClaudeCloudSessionRunner({
723
725
  });
724
726
  const result = await askUserBridge.waitForRelayQuestionAnswer(questionId, { signal });
725
727
  if (result.timedOut || signal.aborted) return { behavior: 'deny', message: QUESTION_UNANSWERED_MESSAGE };
726
- if (result.answer === PERMISSION_ALLOW) return { behavior: 'allow', updatedInput: request.input };
727
- return { behavior: 'deny', message: PERMISSION_DENIED_MESSAGE };
728
+ const decision = readApprovalAnswer(result.answer, { approve: PERMISSION_ALLOW, deny: PERMISSION_DENY });
729
+ if (decision === 'approve') return { behavior: 'allow', updatedInput: request.input };
730
+ if (decision === 'deny') return { behavior: 'deny', message: PERMISSION_DENIED_MESSAGE };
731
+ // The relay takes only the choices on this card; anything else is no
732
+ // decision of the user's, and the agent is told so.
733
+ return { behavior: 'deny', message: PERMISSION_UNREADABLE_MESSAGE };
728
734
  }
729
735
 
730
736
  async function askUser(t, request, signal) {
@@ -39,11 +39,13 @@ import {
39
39
  isStaleAttemptError,
40
40
  } from './claude-turn-publisher.mjs';
41
41
  import { createSubagentTimelineRecorder } from './subagent-timeline-recorder.mjs';
42
+ import { SUBAGENT_TASK_TYPES } from '../../shared/subagent-timeline.mjs';
42
43
  import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
43
44
  import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
44
45
  import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
45
46
  import { createTurnLiveness, resolveTurnStallWindows } from '../../shared/worker-runtime/turn-liveness.mjs';
46
47
  import { createKeptPosts } from '../../shared/worker-runtime/kept-posts.mjs';
48
+ import { explainLaunchFailure, missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
47
49
  import {
48
50
  ANSWERED_ELSEWHERE_KIND,
49
51
  DELIVERY_ANSWERED_ELSEWHERE_TEXT,
@@ -60,6 +62,7 @@ import {
60
62
  import { createUsageLimitTracker } from '../../shared/claude-usage-limit.mjs';
61
63
  import {
62
64
  discoverTaskOutputPaths,
65
+ shellCommandsOf,
63
66
  exitCodeFromSummary,
64
67
  isShellTaskType,
65
68
  outputDirOf,
@@ -81,8 +84,6 @@ function modeAppendClass(relayMode) {
81
84
 
82
85
  export { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT };
83
86
 
84
- /** Task types that run a model of their own (vs. bash/monitor/workflow). */
85
- const AGENT_TASK_TYPES = new Set(['local_agent', 'agent', 'subagent']);
86
87
  const MAX_TASK_TOOL_CALL_CHARS = 300;
87
88
  /** Output paths remembered per process (one per backgrounded shell). */
88
89
  const MAX_TASK_OUTPUT_PATHS = 200;
@@ -458,6 +459,9 @@ export function createClaudeSessionRunner({
458
459
  readContextUsageImpl = readContextUsage,
459
460
  readPlanUsageImpl = readPlanUsage,
460
461
  relocateTranscriptImpl = relocateClaudeTranscriptForCwd,
462
+ // The error a spawn in `cwd` fails with up front, or null
463
+ // (shared/worker-runtime/working-directory.mjs).
464
+ checkWorkingDirectoryImpl = missingWorkingDirectoryError,
461
465
  idleShutdownMs = 10 * 60_000,
462
466
  getBackgroundTaskTimeoutMs = () => 0,
463
467
  // The per-conversation auto-compact window (token count, null = Auto). Read
@@ -1762,7 +1766,7 @@ export function createClaudeSessionRunner({
1762
1766
  // agent-like task runs on the session model. Resolved at publish
1763
1767
  // time so the spawn block landing after task_started still heals on
1764
1768
  // the next publish. Bash & co. get no model at all.
1765
- const agentLike = AGENT_TASK_TYPES.has(String(task.taskType || ''));
1769
+ const agentLike = SUBAGENT_TASK_TYPES.has(String(task.taskType || ''));
1766
1770
  const pinnedModel = (task.toolUseId && processRef.subagentSpawns.get(task.toolUseId)) || '';
1767
1771
  const activity = (task.toolUseId && processRef.subagentActivity.get(task.toolUseId)) || {};
1768
1772
  const model = agentLike ? (activity.model || pinnedModel || processRef.initModel || null) : null;
@@ -1776,6 +1780,7 @@ export function createClaudeSessionRunner({
1776
1780
  lastToolCall: (agentLike && activity.lastToolCall) || null,
1777
1781
  totalTokens: task.totalTokens ?? null,
1778
1782
  toolUseId: task.toolUseId || null,
1783
+ ...(task.taskRunId ? { taskRunId: task.taskRunId } : {}),
1779
1784
  subagentType: task.subagentType || null,
1780
1785
  model,
1781
1786
  modelInherited: Boolean(model && !pinnedModel && !activity.model),
@@ -1783,8 +1788,10 @@ export function createClaudeSessionRunner({
1783
1788
  // sanitizer whitelists and re-clamps it before storing.
1784
1789
  ...(task.workflowProgress ? { workflowProgress: task.workflowProgress } : {}),
1785
1790
  ...(agentLike && Number.isInteger(task.steps) ? { steps: task.steps } : {}),
1786
- // Where the relay reads the task's console from.
1791
+ // Where the relay reads the task's console from, and the command
1792
+ // line the console reads a `| tail` off.
1787
1793
  ...(isShellTaskType(task.taskType) ? taskOutputFields(processRef, taskId) : {}),
1794
+ ...(isShellTaskType(task.taskType) && taskCommand(processRef, taskId, task) ? { command: taskCommand(processRef, taskId, task) } : {}),
1788
1795
  };
1789
1796
  });
1790
1797
  return postTaskSet({ tasks, ...(resync ? { resync: true } : {}) }).catch((error) => {
@@ -1846,12 +1853,49 @@ export function createClaudeSessionRunner({
1846
1853
  /** A backgrounded Bash/Monitor result: note its file, and show it on the live row. */
1847
1854
  function recordTaskOutputPaths(sdkMessage) {
1848
1855
  let liveChanged = false;
1849
- for (const { taskId, outputPath } of discoverTaskOutputPaths(sdkMessage)) {
1856
+ for (const { taskId, outputPath, toolUseId } of discoverTaskOutputPaths(sdkMessage)) {
1857
+ if (toolUseId && !proc.taskToolUses.has(taskId)) {
1858
+ proc.taskToolUses.set(taskId, toolUseId);
1859
+ capMap(proc.taskToolUses, MAX_TASK_OUTPUT_PATHS);
1860
+ if (proc.liveTasks.has(taskId) && proc.shellCommands.has(toolUseId)) liveChanged = true;
1861
+ }
1850
1862
  if (rememberTaskOutputPath(proc, taskId, outputPath) && proc.liveTasks.has(taskId)) liveChanged = true;
1851
1863
  }
1852
1864
  if (liveChanged) publishBackgroundTasks();
1853
1865
  }
1854
1866
 
1867
+ function capMap(map, max) {
1868
+ while (map.size > max) map.delete(map.keys().next().value);
1869
+ }
1870
+
1871
+ // The command line of each Bash or Monitor call, by its tool_use id: a
1872
+ // shell task's row names it (task_started's tool_use_id, or the call whose
1873
+ // result announced the task's file).
1874
+ function recordShellCommands(sdkMessage) {
1875
+ for (const { toolUseId, command } of shellCommandsOf(sdkMessage)) {
1876
+ proc.shellCommands.set(toolUseId, command);
1877
+ capMap(proc.shellCommands, MAX_TASK_OUTPUT_PATHS);
1878
+ }
1879
+ }
1880
+
1881
+ // A task's command, pinned to its id once found: the 200 newest calls
1882
+ // the map by tool_use id keeps may move past a long-running task's call.
1883
+ function taskCommand(processRef, taskId, task) {
1884
+ const pinned = processRef.taskCommands.get(taskId);
1885
+ if (pinned) return pinned;
1886
+ const toolUseId = task?.toolUseId || processRef.taskToolUses.get(taskId) || '';
1887
+ const command = (toolUseId && processRef.shellCommands.get(toolUseId)) || '';
1888
+ if (command && (processRef.liveTasks.has(taskId) || processRef.departedTasks.has(taskId))) processRef.taskCommands.set(taskId, command);
1889
+ return command;
1890
+ }
1891
+
1892
+ // Pinned commands go with their task: live or departed until its settle.
1893
+ function prunePinnedCommands(processRef) {
1894
+ for (const taskId of [...processRef.taskCommands.keys()]) {
1895
+ if (!processRef.liveTasks.has(taskId) && !processRef.departedTasks.has(taskId)) processRef.taskCommands.delete(taskId);
1896
+ }
1897
+ }
1898
+
1855
1899
  /**
1856
1900
  * The relay's fallback for a shell task with no file: the CLI's last 8 KiB
1857
1901
  * (`Query.getTaskOutput`, implemented in sdk.mjs though sdk.d.ts does not
@@ -2175,7 +2219,7 @@ export function createClaudeSessionRunner({
2175
2219
  // alias like "opus") and the full input of the tool it is calling now.
2176
2220
  // Single-lined and capped: the panel crops it to its width.
2177
2221
  function recordSubagentActivity(sdkMessage) {
2178
- const parentId = String(sdkMessage?.parent_tool_use_id || '').trim();
2222
+ const parentId = timelineRecorder.runIdFor(sdkMessage?.parent_tool_use_id);
2179
2223
  if (!parentId) return;
2180
2224
  const entry = proc.subagentActivity.get(parentId) || {};
2181
2225
  const before = `${entry.model || ''}\n${entry.lastToolCall || ''}`;
@@ -2321,6 +2365,7 @@ export function createClaudeSessionRunner({
2321
2365
  function observeProcessLevel(sdkMessage) {
2322
2366
  const type = String(sdkMessage?.type || '');
2323
2367
  if (type === 'assistant') {
2368
+ recordShellCommands(sdkMessage);
2324
2369
  recordSubagentSpawns(sdkMessage);
2325
2370
  recordSubagentActivity(sdkMessage);
2326
2371
  return;
@@ -2550,11 +2595,15 @@ export function createClaudeSessionRunner({
2550
2595
  .filter((task) => String(task?.task_id || '').trim())
2551
2596
  .map((task) => {
2552
2597
  const taskId = String(task.task_id).trim();
2553
- const known = previous.get(taskId) || {};
2598
+ const runId = String(task?.run_id || '').trim();
2599
+ // Another run of a task still listed (resumed at once): its own row.
2600
+ const listed = previous.get(taskId) || {};
2601
+ const known = runId && listed.taskRunId && listed.taskRunId !== runId ? {} : listed;
2554
2602
  return [taskId, {
2555
2603
  ...known,
2556
2604
  taskType: String(task?.task_type || '').trim() || known.taskType || '',
2557
2605
  description: String(task?.description || '').trim() || known.description || '',
2606
+ taskRunId: runId || known.taskRunId || '',
2558
2607
  startedAt: known.startedAt || Date.now(),
2559
2608
  }];
2560
2609
  }));
@@ -2596,6 +2645,7 @@ export function createClaudeSessionRunner({
2596
2645
  }
2597
2646
  syncWorkflowPoller(proc);
2598
2647
  publishBackgroundTasks();
2648
+ prunePinnedCommands(proc);
2599
2649
  evaluateLifecycle();
2600
2650
  return;
2601
2651
  }
@@ -2609,8 +2659,14 @@ export function createClaudeSessionRunner({
2609
2659
  known.subagentType = String(sdkMessage.subagent_type || '').trim() || known.subagentType || '';
2610
2660
  // The task's spawning tool_use id is the same identity the subagent
2611
2661
  // bubbles key on — recorded so the panel and the bubbles can be
2612
- // correlated (and a targeted stop can find its task).
2613
- known.toolUseId = String(sdkMessage.tool_use_id || '').trim() || known.toolUseId || '';
2662
+ // correlated (and a targeted stop can find its task). A resumed agent
2663
+ // names the call that resumed it (SendMessage); the recorder leads
2664
+ // that back to the agent's run.
2665
+ const toolUseId = String(sdkMessage.tool_use_id || '').trim();
2666
+ known.toolUseId = timelineRecorder.runIdFor(toolUseId) || known.toolUseId || '';
2667
+ // Which run of the task this is: the SDK's run id, else (an older
2668
+ // CLI) the tool use that started it, which differs per run too.
2669
+ known.taskRunId = String(sdkMessage.run_id || '').trim() || known.taskRunId || toolUseId;
2614
2670
  // task_started can be the first event carrying the task_type; a
2615
2671
  // workflow revealed here must start its poller too.
2616
2672
  syncWorkflowPoller(proc);
@@ -2625,7 +2681,7 @@ export function createClaudeSessionRunner({
2625
2681
  known.summary = String(sdkMessage.summary || '').trim() || known.summary;
2626
2682
  known.lastToolName = String(sdkMessage.last_tool_name || '').trim() || known.lastToolName;
2627
2683
  known.subagentType = String(sdkMessage.subagent_type || '').trim() || known.subagentType || '';
2628
- known.toolUseId = String(sdkMessage.tool_use_id || '').trim() || known.toolUseId || '';
2684
+ known.toolUseId = timelineRecorder.runIdFor(sdkMessage.tool_use_id) || known.toolUseId || '';
2629
2685
  const totalTokens = Number(sdkMessage.usage?.total_tokens);
2630
2686
  if (Number.isFinite(totalTokens)) known.totalTokens = totalTokens;
2631
2687
  // The sub-agent's tool calls so far: the panel row's step count.
@@ -2655,15 +2711,19 @@ export function createClaudeSessionRunner({
2655
2711
  rememberTaskOutputPath(proc, taskId, sdkMessage.output_file);
2656
2712
  const shellLike = isShellTaskType(published?.taskType);
2657
2713
  const outputPath = shellLike ? (proc.taskOutputPaths.get(taskId) || '') : '';
2714
+ const command = shellLike ? taskCommand(proc, taskId, { toolUseId: String(sdkMessage.tool_use_id || published?.toolUseId || '').trim() }) : '';
2715
+ proc.taskCommands.delete(taskId);
2658
2716
  const exitCode = shellLike ? exitCodeFromSummary(sdkMessage.summary) : null;
2659
2717
  const toolUses = Number(sdkMessage.usage?.tool_uses);
2660
- const steps = AGENT_TASK_TYPES.has(String(published?.taskType || '')) && Number.isFinite(toolUses) && toolUses >= 0
2718
+ const steps = SUBAGENT_TASK_TYPES.has(String(published?.taskType || '')) && Number.isFinite(toolUses) && toolUses >= 0
2661
2719
  ? Math.floor(toolUses)
2662
2720
  : null;
2721
+ const settledRunId = String(sdkMessage.run_id || published?.taskRunId || '').trim();
2663
2722
  void keptTaskSettles.post('/api/background-task-settled', {
2664
2723
  conversationId: sdkSessionId,
2665
2724
  taskId,
2666
- toolUseId: String(sdkMessage.tool_use_id || published?.toolUseId || '').trim() || null,
2725
+ toolUseId: timelineRecorder.runIdFor(sdkMessage.tool_use_id) || published?.toolUseId || null,
2726
+ ...(settledRunId ? { taskRunId: settledRunId } : {}),
2667
2727
  status: String(sdkMessage.status || '').trim() || 'completed',
2668
2728
  summary: String(sdkMessage.summary || '').trim().slice(0, 500) || null,
2669
2729
  ...(sdkMessage.reason ? { reason: String(sdkMessage.reason).slice(0, 40) } : {}),
@@ -2673,6 +2733,7 @@ export function createClaudeSessionRunner({
2673
2733
  startedAt: published.startedAt || null,
2674
2734
  } : {}),
2675
2735
  ...(outputPath ? { outputPath } : {}),
2736
+ ...(command ? { command } : {}),
2676
2737
  ...(exitCode !== null ? { exitCode } : {}),
2677
2738
  ...(steps !== null ? { steps } : {}),
2678
2739
  });
@@ -3456,6 +3517,7 @@ export function createClaudeSessionRunner({
3456
3517
  message: ctx.message,
3457
3518
  errorText: String(streamError?.message || streamError || 'unknown error'),
3458
3519
  usageLimit: usageLimit.classifyException(streamError),
3520
+ error: streamError,
3459
3521
  }).catch(() => {});
3460
3522
  }
3461
3523
  continue;
@@ -3477,6 +3539,11 @@ export function createClaudeSessionRunner({
3477
3539
  // Spawn / adapt
3478
3540
 
3479
3541
  function spawnProcess(message, { resume: resumeOverride = '' } = {}) {
3542
+ // A spawn in a folder that is gone fails with ENOENT, which the SDK
3543
+ // reports as a native binary that does not match the libc. Thrown here,
3544
+ // the delivery fails with the folder's name instead (handlePendingPayload).
3545
+ const missingFolder = checkWorkingDirectoryImpl(cwd);
3546
+ if (missingFolder) throw missingFolder;
3480
3547
  const relayMode = message.relayMode || 'agent';
3481
3548
  const model = resolvePerTurnModel(message);
3482
3549
  const effort = normalizeClaudeEffort(message.reasoningEffort);
@@ -3517,6 +3584,12 @@ export function createClaudeSessionRunner({
3517
3584
  // next to it.
3518
3585
  taskOutputPaths: new Map(),
3519
3586
  taskOutputDir: '',
3587
+ // tool_use id → the command line of a Bash or Monitor call, and taskId
3588
+ // → the call whose result announced the task: a shell row's command.
3589
+ shellCommands: new Map(),
3590
+ taskToolUses: new Map(),
3591
+ // taskId → its command, pinned while the task is live or departed.
3592
+ taskCommands: new Map(),
3520
3593
  // taskId → { runId, labels, digestJson } for live local_workflow tasks;
3521
3594
  // pruned by syncWorkflowPoller when a task leaves the live set.
3522
3595
  workflowStates: new Map(),
@@ -3691,7 +3764,11 @@ export function createClaudeSessionRunner({
3691
3764
  syncDeliveryReadiness();
3692
3765
  }
3693
3766
  } catch (error) {
3694
- if (!processRef.aborted && !abortController.signal.aborted) streamError = error;
3767
+ // A launch that failed because the folder went away after the check
3768
+ // above is told as that, too.
3769
+ if (!processRef.aborted && !abortController.signal.aborted) {
3770
+ streamError = explainLaunchFailure(error, cwd, { check: checkWorkingDirectoryImpl });
3771
+ }
3695
3772
  } finally {
3696
3773
  try {
3697
3774
  processRef.turn.endInput?.();
@@ -4170,7 +4247,7 @@ export function createClaudeSessionRunner({
4170
4247
  settingsWindowOpen = false;
4171
4248
  const errorText = String(error?.message || error || 'unknown error');
4172
4249
  dbg('claude turn failed', message.id, errorText);
4173
- await publisher.publishTurnException({ message, errorText, usageLimit: usageLimit.classifyException(error) });
4250
+ await publisher.publishTurnException({ message, errorText, usageLimit: usageLimit.classifyException(error), error });
4174
4251
  return true;
4175
4252
  } finally {
4176
4253
  endAdmission();
@@ -4211,7 +4288,7 @@ export function createClaudeSessionRunner({
4211
4288
  * subagents have no task id and remain full-turn-Stop only.
4212
4289
  */
4213
4290
  async function stopBackgroundTaskByToolUseId(toolUseId) {
4214
- const normalized = String(toolUseId || '').trim();
4291
+ const normalized = timelineRecorder.runIdFor(toolUseId);
4215
4292
  if (!normalized || !proc) return false;
4216
4293
  for (const [taskId, task] of proc.liveTasks.entries()) {
4217
4294
  if (String(task?.toolUseId || '').trim() === normalized) {
@@ -4,6 +4,7 @@ import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
4
4
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
5
5
  import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
6
6
  import { buildUsageLimitFailure } from '../../shared/claude-usage-limit.mjs';
7
+ import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
7
8
 
8
9
  const PLAN_BOARD_ACTIONS = [
9
10
  { id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
@@ -390,7 +391,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
390
391
  });
391
392
  }
392
393
 
393
- async function publishTurnException({ message, errorText, usageLimit = null }) {
394
+ async function publishTurnException({ message, errorText, usageLimit = null, error = null }) {
394
395
  if (usageLimit) {
395
396
  await publishResponse(message, {
396
397
  text: usageLimit.text || errorText,
@@ -399,6 +400,24 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
399
400
  });
400
401
  return;
401
402
  }
403
+ // The conversation's folder is gone: the error names it and says what to
404
+ // do, which neither a retry nor a sign-in changes.
405
+ if (isWorkingDirectoryMissingError(error)) {
406
+ await publishResponse(message, {
407
+ text: `System note: ${error.message} ${error.guidance}`,
408
+ model: null,
409
+ terminalError: {
410
+ kind: 'claude-turn-failed',
411
+ code: WORKING_DIRECTORY_MISSING_CODE,
412
+ stableCode: `claude.${WORKING_DIRECTORY_MISSING_CODE}`,
413
+ message: error.message,
414
+ guidance: error.guidance,
415
+ failedAt: new Date().toISOString(),
416
+ queueMessageId: String(message.id || '') || null,
417
+ },
418
+ });
419
+ return;
420
+ }
402
421
  const isAuthError = /authentication|logged in|login|credential|api key/i.test(errorText);
403
422
  await publishResponse(message, {
404
423
  text: isAuthError
@@ -1,6 +1,7 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { displayToolName, isBackgroundLaunch, isSubagentToolName } from './sdk-message-normalizer.mjs';
3
3
  import {
4
+ SUBAGENT_TASK_TYPES,
4
5
  SUBAGENT_TIMELINE_ENTRY_MAX_CHARS,
5
6
  SUBAGENT_TIMELINE_MAX_ENTRIES,
6
7
  cutSubagentTimelineText,
@@ -8,6 +9,8 @@ import {
8
9
  } from '../../shared/subagent-timeline.mjs';
9
10
 
10
11
  const MAX_TRACKED_RUNS = 200;
12
+ // Task ids and resume ids remembered to find a resumed agent's run.
13
+ const MAX_TRACKED_TASKS = 500;
11
14
 
12
15
  export function toolResultText(content) {
13
16
  if (typeof content === 'string') return content;
@@ -53,6 +56,16 @@ function capText(text) {
53
56
  *
54
57
  * It also answers which turn owns a run's live lanes (`ownsRun`): only the
55
58
  * turn that spawned an agent shows its frames in its in-turn lanes.
59
+ *
60
+ * A finished background agent that the session sends a new message
61
+ * (SendMessage) runs again as the same task: task_started names its task id
62
+ * again, with the SendMessage call as its tool_use_id. That id leads to the
63
+ * run of the task's first start (`runIdFor`), so the record goes on under the
64
+ * agent's run, running again, with a note where it resumed. A start the
65
+ * recorder already saw (same run id) adds nothing, and an event of an
66
+ * earlier run (a lower run id) is late and left out. Every post names
67
+ * the run's task id too: a relay that knows the run by the task alone (this
68
+ * recorder did not see the first start) places it there.
56
69
  */
57
70
  export function createSubagentTimelineRecorder({
58
71
  api,
@@ -66,6 +79,9 @@ export function createSubagentTimelineRecorder({
66
79
  writerId = crypto.randomBytes(4).toString('hex'),
67
80
  } = {}) {
68
81
  const runs = new Map(); // runId -> run state, in registration order
82
+ const taskRuns = new Map(); // taskId -> the run its first start named
83
+ const resumeRuns = new Map(); // tool_use_id of a later start -> that run
84
+ const latestRuns = new Map(); // taskId -> the SDK run id of its latest start
69
85
  let flushTimer = null;
70
86
  let flushing = null;
71
87
  let localCounter = 0;
@@ -75,6 +91,26 @@ export function createSubagentTimelineRecorder({
75
91
  return `${writerId}:${localCounter}`;
76
92
  }
77
93
 
94
+ /** The run an id belongs to: a resumed agent's later tool use leads to its first run. */
95
+ function runIdFor(id) {
96
+ const key = String(id || '').trim();
97
+ return resumeRuns.get(key) || key;
98
+ }
99
+
100
+ // An event of an earlier run of a task than its latest start: late (the
101
+ // run ids of one task sort in the order the runs opened). Without run ids,
102
+ // every event counts.
103
+ function isEarlierRun(taskId, sdkRunId) {
104
+ const latest = taskId ? latestRuns.get(taskId) : '';
105
+ return !!sdkRunId && !!latest && sdkRunId < latest;
106
+ }
107
+
108
+ function remember(map, key, value) {
109
+ map.delete(key);
110
+ map.set(key, value);
111
+ while (map.size > MAX_TRACKED_TASKS) map.delete(map.keys().next().value);
112
+ }
113
+
78
114
  function ensureRun(runId, { parentId = null, displayName = '', subagentType = '' } = {}) {
79
115
  const id = String(runId || '').trim();
80
116
  if (!id) return null;
@@ -221,7 +257,7 @@ export function createSubagentTimelineRecorder({
221
257
  }
222
258
 
223
259
  function observeAssistant(sdkMessage) {
224
- const parentId = String(sdkMessage?.parent_tool_use_id || '').trim();
260
+ const parentId = runIdFor(sdkMessage?.parent_tool_use_id);
225
261
  const content = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
226
262
  if (!parentId) {
227
263
  // The main thread: only the agents it spawns matter here.
@@ -261,7 +297,7 @@ export function createSubagentTimelineRecorder({
261
297
  }
262
298
 
263
299
  function observeStreamEvent(sdkMessage) {
264
- const parentId = String(sdkMessage?.parent_tool_use_id || '').trim();
300
+ const parentId = runIdFor(sdkMessage?.parent_tool_use_id);
265
301
  if (!parentId) return;
266
302
  const run = ensureRun(parentId);
267
303
  const event = sdkMessage?.event || {};
@@ -309,7 +345,7 @@ export function createSubagentTimelineRecorder({
309
345
  }
310
346
 
311
347
  function observeUser(sdkMessage) {
312
- const parentId = String(sdkMessage?.parent_tool_use_id || '').trim();
348
+ const parentId = runIdFor(sdkMessage?.parent_tool_use_id);
313
349
  const content = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
314
350
  for (const block of content) {
315
351
  if (block?.type !== 'tool_result') continue;
@@ -346,17 +382,46 @@ export function createSubagentTimelineRecorder({
346
382
  const subtype = String(sdkMessage?.subtype || '');
347
383
  const toolUseId = String(sdkMessage?.tool_use_id || '').trim();
348
384
  if (!toolUseId) return;
385
+ const taskId = String(sdkMessage?.task_id || '').trim();
386
+ const sdkRunId = String(sdkMessage?.run_id || '').trim();
349
387
  if (subtype === 'task_started') {
350
- const run = ensureRun(toolUseId, { subagentType: String(sdkMessage.subagent_type || '').trim() });
388
+ // Only sub-agents are resumed; shells and the rest are not remembered.
389
+ const taskType = String(sdkMessage.task_type || '').trim();
390
+ const agent = !!taskId && (!taskType || SUBAGENT_TASK_TYPES.has(taskType));
391
+ if (agent && sdkRunId) {
392
+ // The same start again, or an earlier run's start arriving late.
393
+ const latest = latestRuns.get(taskId);
394
+ if (latest && sdkRunId <= latest) return;
395
+ remember(latestRuns, taskId, sdkRunId);
396
+ }
397
+ const firstRun = agent ? taskRuns.get(taskId) : '';
398
+ if (firstRun && firstRun !== toolUseId) remember(resumeRuns, toolUseId, firstRun);
399
+ else if (agent && !firstRun) remember(taskRuns, taskId, toolUseId);
400
+ const run = ensureRun(runIdFor(toolUseId), { subagentType: String(sdkMessage.subagent_type || '').trim() });
401
+ // The same task started again (under another tool use, or after it
402
+ // ended): resumed.
403
+ const resumed = !!firstRun && (firstRun !== toolUseId || run.settled);
404
+ run.settled = false;
351
405
  if (sdkMessage.is_backgrounded === true) run.background = true;
406
+ if (resumed) {
407
+ const prompt = String(sdkMessage.prompt || '').trim();
408
+ addEntry(run, {
409
+ kind: 'note',
410
+ text: capText(prompt ? `Resumed with a new message: ${prompt}` : 'Resumed with a new message'),
411
+ metadata: { resumed: true },
412
+ });
413
+ }
414
+ // Running: a start, and for a resumed agent its return from the end.
352
415
  patchHeader(run, {
353
- taskId: String(sdkMessage.task_id || '').trim() || null,
416
+ status: 'running',
417
+ taskId: taskId || null,
354
418
  subagentType: String(sdkMessage.subagent_type || '').trim() || null,
355
419
  background: sdkMessage.is_backgrounded === true ? true : null,
356
420
  });
357
421
  return;
358
422
  }
359
- const run = runs.get(toolUseId);
423
+ if (isEarlierRun(taskId, sdkRunId)) return;
424
+ const run = runs.get(runIdFor(toolUseId));
360
425
  if (!run) return;
361
426
  if (subtype === 'task_updated') {
362
427
  if (sdkMessage?.patch?.is_backgrounded === true) {
@@ -413,7 +478,7 @@ export function createSubagentTimelineRecorder({
413
478
  }
414
479
 
415
480
  function ownsRun(runId, owner) {
416
- const run = runs.get(String(runId || '').trim());
481
+ const run = runs.get(runIdFor(runId));
417
482
  if (!run || !owner) return false;
418
483
  if (!run.owner && run.parentId) run.owner = runs.get(run.parentId)?.owner || null;
419
484
  return run.owner === owner;
@@ -426,7 +491,7 @@ export function createSubagentTimelineRecorder({
426
491
  * carries it now; an unknown run keeps the old behaviour.
427
492
  */
428
493
  function isForeignRun(runId, owner) {
429
- let run = runs.get(String(runId || '').trim());
494
+ let run = runs.get(runIdFor(runId));
430
495
  if (!run) return false;
431
496
  const spawnOwner = run.owner || null;
432
497
  let background = false;
@@ -469,6 +534,7 @@ export function createSubagentTimelineRecorder({
469
534
  await api('POST', '/api/subagent-timeline', {
470
535
  conversationId,
471
536
  subagentRunId: run.id,
537
+ ...(run.header.taskId ? { taskId: run.header.taskId } : {}),
472
538
  entries,
473
539
  ...(header ? { header } : {}),
474
540
  });
@@ -566,6 +632,7 @@ export function createSubagentTimelineRecorder({
566
632
  flush,
567
633
  processEnded,
568
634
  hasPending,
635
+ runIdFor,
569
636
  // For tests and diagnostics.
570
637
  runState: (runId) => runs.get(String(runId || '').trim()) || null,
571
638
  };
@@ -13,6 +13,13 @@ import nodePath from 'node:path';
13
13
  /** Task types whose output is a shell's stdout/stderr (getTaskOutput serves these too). */
14
14
  export const SHELL_TASK_TYPES = new Set(['local_bash', 'monitor', 'monitor_mcp', 'monitor_ws']);
15
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']);
16
23
 
17
24
  const OUTPUT_MARKER = 'Output is being written to';
18
25
  // The CLI's own file name: a path with spaces (a Windows profile dir) still
@@ -89,9 +96,28 @@ function isBackgroundNote(text) {
89
96
  return Boolean(mcp && taskIdFromOutputFile(mcp[1]));
90
97
  }
91
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
+
92
117
  /**
93
118
  * Every task output file a `user` SDK message announces: the tool_result
94
- * blocks of backgrounded Bash (and Monitor) calls. The structured result's
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
95
121
  * `backgroundTaskId` names the task when the message answers a single call.
96
122
  */
97
123
  export function discoverTaskOutputPaths(sdkMessage) {
@@ -108,7 +134,7 @@ export function discoverTaskOutputPaths(sdkMessage) {
108
134
  const text = toolResultText(block);
109
135
  if (!structuredTaskId && !isBackgroundNote(text)) continue;
110
136
  const announced = parseOutputAnnouncement(text, structuredTaskId);
111
- if (announced) found.push(announced);
137
+ if (announced) found.push({ ...announced, toolUseId: String(block.tool_use_id || '').trim() });
112
138
  }
113
139
  return found;
114
140
  }
@@ -40,7 +40,7 @@ export const RELAY_ASK_USER_TOOL_PARAMETERS = Object.freeze({
40
40
  choices: {
41
41
  type: 'array',
42
42
  items: { type: 'string' },
43
- description: 'Optional list of choices for a multiple choice question. Prefer providing choices when possible.',
43
+ description: 'Optional list of choices for a multiple choice question. Prefer providing choices when possible. The relay keeps the first 8 choices and drops the rest.',
44
44
  },
45
45
  multi_select: {
46
46
  type: 'boolean',
@@ -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
  }