@oar-sh/oar 0.9.13 → 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 (72) 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 +302 -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 +296 -149
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +39 -13
  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/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  14. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  15. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  16. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  17. package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
  18. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  19. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  20. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  21. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  22. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  24. package/server/public/app/api-client.js +3 -1
  25. package/server/public/app/ask-user-view.js +29 -4
  26. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  27. package/server/public/app/question-answer-rule.mjs +15 -0
  28. package/server/public/app/question-multi-select.mjs +9 -2
  29. package/server/public/app/relay-event-view.mjs +5 -1
  30. package/server/public/index.html +9 -5
  31. package/server/relay-exit-codes.mjs +6 -0
  32. package/server/relay-self-restart.mjs +6 -1
  33. package/server/relay-tools.md +14 -11
  34. package/server/repositories/message-repository.mjs +29 -28
  35. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  36. package/server/routes/ask-user-routes.mjs +41 -7
  37. package/server/routes/messages-routes.mjs +65 -10
  38. package/server/routes/preview-routes.mjs +73 -7
  39. package/server/routes/sessions-routes.mjs +4 -2
  40. package/server/server-runtime.mjs +156 -94
  41. package/server/services/cursor-session-root-service.mjs +70 -6
  42. package/server/services/data-dir-resolution.mjs +208 -19
  43. package/server/services/earlier-database-service.mjs +631 -0
  44. package/server/services/oar-cli-helpers.mjs +6 -1
  45. package/server/services/oar-state-layout.mjs +107 -0
  46. package/server/services/oar-state-migration-service.mjs +387 -96
  47. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  48. package/server/services/package-data-rescue.mjs +137 -0
  49. package/server/services/preview-registry-service.mjs +3 -2
  50. package/server/services/push-dispatch-service.mjs +33 -6
  51. package/server/services/relay-event-service.mjs +183 -5
  52. package/server/services/relay-restart-queue.mjs +10 -3
  53. package/server/services/relay-singleton-guard.mjs +300 -36
  54. package/server/services/remote-relay-caller-context.mjs +7 -2
  55. package/server/services/remote-relay-dispatcher.mjs +30 -9
  56. package/server/services/session-activity-tracker.mjs +52 -15
  57. package/server/services/session-worker-launch-service.mjs +31 -1
  58. package/server/services/stale-turn-recovery.mjs +99 -0
  59. package/server/services/subagent-timeline-service.mjs +4 -1
  60. package/server/services/windows-autostart-service.mjs +329 -31
  61. package/server/services/windows-boot-autostart-service.mjs +188 -60
  62. package/shared/preview-instructions.mjs +17 -12
  63. package/shared/preview-tool-core.mjs +32 -14
  64. package/shared/question-answer-rule.mjs +63 -0
  65. package/shared/relay-events.mjs +54 -4
  66. package/shared/remote-relay-contract.mjs +20 -10
  67. package/shared/subagent-timeline.mjs +4 -0
  68. package/shared/windows-system-tools.mjs +25 -0
  69. package/shared/worker-runtime/config-loader.mjs +54 -8
  70. package/shared/worker-runtime/prompt-context.mjs +3 -1
  71. package/shared/worker-runtime/working-directory.mjs +65 -0
  72. 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,
@@ -82,8 +84,6 @@ function modeAppendClass(relayMode) {
82
84
 
83
85
  export { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT };
84
86
 
85
- /** Task types that run a model of their own (vs. bash/monitor/workflow). */
86
- const AGENT_TASK_TYPES = new Set(['local_agent', 'agent', 'subagent']);
87
87
  const MAX_TASK_TOOL_CALL_CHARS = 300;
88
88
  /** Output paths remembered per process (one per backgrounded shell). */
89
89
  const MAX_TASK_OUTPUT_PATHS = 200;
@@ -459,6 +459,9 @@ export function createClaudeSessionRunner({
459
459
  readContextUsageImpl = readContextUsage,
460
460
  readPlanUsageImpl = readPlanUsage,
461
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,
462
465
  idleShutdownMs = 10 * 60_000,
463
466
  getBackgroundTaskTimeoutMs = () => 0,
464
467
  // The per-conversation auto-compact window (token count, null = Auto). Read
@@ -1763,7 +1766,7 @@ export function createClaudeSessionRunner({
1763
1766
  // agent-like task runs on the session model. Resolved at publish
1764
1767
  // time so the spawn block landing after task_started still heals on
1765
1768
  // the next publish. Bash & co. get no model at all.
1766
- const agentLike = AGENT_TASK_TYPES.has(String(task.taskType || ''));
1769
+ const agentLike = SUBAGENT_TASK_TYPES.has(String(task.taskType || ''));
1767
1770
  const pinnedModel = (task.toolUseId && processRef.subagentSpawns.get(task.toolUseId)) || '';
1768
1771
  const activity = (task.toolUseId && processRef.subagentActivity.get(task.toolUseId)) || {};
1769
1772
  const model = agentLike ? (activity.model || pinnedModel || processRef.initModel || null) : null;
@@ -1777,6 +1780,7 @@ export function createClaudeSessionRunner({
1777
1780
  lastToolCall: (agentLike && activity.lastToolCall) || null,
1778
1781
  totalTokens: task.totalTokens ?? null,
1779
1782
  toolUseId: task.toolUseId || null,
1783
+ ...(task.taskRunId ? { taskRunId: task.taskRunId } : {}),
1780
1784
  subagentType: task.subagentType || null,
1781
1785
  model,
1782
1786
  modelInherited: Boolean(model && !pinnedModel && !activity.model),
@@ -2215,7 +2219,7 @@ export function createClaudeSessionRunner({
2215
2219
  // alias like "opus") and the full input of the tool it is calling now.
2216
2220
  // Single-lined and capped: the panel crops it to its width.
2217
2221
  function recordSubagentActivity(sdkMessage) {
2218
- const parentId = String(sdkMessage?.parent_tool_use_id || '').trim();
2222
+ const parentId = timelineRecorder.runIdFor(sdkMessage?.parent_tool_use_id);
2219
2223
  if (!parentId) return;
2220
2224
  const entry = proc.subagentActivity.get(parentId) || {};
2221
2225
  const before = `${entry.model || ''}\n${entry.lastToolCall || ''}`;
@@ -2591,11 +2595,15 @@ export function createClaudeSessionRunner({
2591
2595
  .filter((task) => String(task?.task_id || '').trim())
2592
2596
  .map((task) => {
2593
2597
  const taskId = String(task.task_id).trim();
2594
- 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;
2595
2602
  return [taskId, {
2596
2603
  ...known,
2597
2604
  taskType: String(task?.task_type || '').trim() || known.taskType || '',
2598
2605
  description: String(task?.description || '').trim() || known.description || '',
2606
+ taskRunId: runId || known.taskRunId || '',
2599
2607
  startedAt: known.startedAt || Date.now(),
2600
2608
  }];
2601
2609
  }));
@@ -2651,8 +2659,14 @@ export function createClaudeSessionRunner({
2651
2659
  known.subagentType = String(sdkMessage.subagent_type || '').trim() || known.subagentType || '';
2652
2660
  // The task's spawning tool_use id is the same identity the subagent
2653
2661
  // bubbles key on — recorded so the panel and the bubbles can be
2654
- // correlated (and a targeted stop can find its task).
2655
- 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;
2656
2670
  // task_started can be the first event carrying the task_type; a
2657
2671
  // workflow revealed here must start its poller too.
2658
2672
  syncWorkflowPoller(proc);
@@ -2667,7 +2681,7 @@ export function createClaudeSessionRunner({
2667
2681
  known.summary = String(sdkMessage.summary || '').trim() || known.summary;
2668
2682
  known.lastToolName = String(sdkMessage.last_tool_name || '').trim() || known.lastToolName;
2669
2683
  known.subagentType = String(sdkMessage.subagent_type || '').trim() || known.subagentType || '';
2670
- known.toolUseId = String(sdkMessage.tool_use_id || '').trim() || known.toolUseId || '';
2684
+ known.toolUseId = timelineRecorder.runIdFor(sdkMessage.tool_use_id) || known.toolUseId || '';
2671
2685
  const totalTokens = Number(sdkMessage.usage?.total_tokens);
2672
2686
  if (Number.isFinite(totalTokens)) known.totalTokens = totalTokens;
2673
2687
  // The sub-agent's tool calls so far: the panel row's step count.
@@ -2701,13 +2715,15 @@ export function createClaudeSessionRunner({
2701
2715
  proc.taskCommands.delete(taskId);
2702
2716
  const exitCode = shellLike ? exitCodeFromSummary(sdkMessage.summary) : null;
2703
2717
  const toolUses = Number(sdkMessage.usage?.tool_uses);
2704
- 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
2705
2719
  ? Math.floor(toolUses)
2706
2720
  : null;
2721
+ const settledRunId = String(sdkMessage.run_id || published?.taskRunId || '').trim();
2707
2722
  void keptTaskSettles.post('/api/background-task-settled', {
2708
2723
  conversationId: sdkSessionId,
2709
2724
  taskId,
2710
- 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 } : {}),
2711
2727
  status: String(sdkMessage.status || '').trim() || 'completed',
2712
2728
  summary: String(sdkMessage.summary || '').trim().slice(0, 500) || null,
2713
2729
  ...(sdkMessage.reason ? { reason: String(sdkMessage.reason).slice(0, 40) } : {}),
@@ -3501,6 +3517,7 @@ export function createClaudeSessionRunner({
3501
3517
  message: ctx.message,
3502
3518
  errorText: String(streamError?.message || streamError || 'unknown error'),
3503
3519
  usageLimit: usageLimit.classifyException(streamError),
3520
+ error: streamError,
3504
3521
  }).catch(() => {});
3505
3522
  }
3506
3523
  continue;
@@ -3522,6 +3539,11 @@ export function createClaudeSessionRunner({
3522
3539
  // Spawn / adapt
3523
3540
 
3524
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;
3525
3547
  const relayMode = message.relayMode || 'agent';
3526
3548
  const model = resolvePerTurnModel(message);
3527
3549
  const effort = normalizeClaudeEffort(message.reasoningEffort);
@@ -3742,7 +3764,11 @@ export function createClaudeSessionRunner({
3742
3764
  syncDeliveryReadiness();
3743
3765
  }
3744
3766
  } catch (error) {
3745
- 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
+ }
3746
3772
  } finally {
3747
3773
  try {
3748
3774
  processRef.turn.endInput?.();
@@ -4221,7 +4247,7 @@ export function createClaudeSessionRunner({
4221
4247
  settingsWindowOpen = false;
4222
4248
  const errorText = String(error?.message || error || 'unknown error');
4223
4249
  dbg('claude turn failed', message.id, errorText);
4224
- await publisher.publishTurnException({ message, errorText, usageLimit: usageLimit.classifyException(error) });
4250
+ await publisher.publishTurnException({ message, errorText, usageLimit: usageLimit.classifyException(error), error });
4225
4251
  return true;
4226
4252
  } finally {
4227
4253
  endAdmission();
@@ -4262,7 +4288,7 @@ export function createClaudeSessionRunner({
4262
4288
  * subagents have no task id and remain full-turn-Stop only.
4263
4289
  */
4264
4290
  async function stopBackgroundTaskByToolUseId(toolUseId) {
4265
- const normalized = String(toolUseId || '').trim();
4291
+ const normalized = timelineRecorder.runIdFor(toolUseId);
4266
4292
  if (!normalized || !proc) return false;
4267
4293
  for (const [taskId, task] of proc.liveTasks.entries()) {
4268
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
  };
@@ -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',
@@ -80,7 +80,9 @@ export function createCopilotPromptContextBuilder({
80
80
  if (includeInstructions) {
81
81
  // Advisory: a preview lookup that fails costs the block, never the turn.
82
82
  const previewBlock = typeof getPreviewInstructions === 'function'
83
- ? await Promise.resolve().then(getPreviewInstructions).catch(() => '')
83
+ ? await Promise.resolve()
84
+ .then(() => getPreviewInstructions({ conversationId: String(message?.conversationId || '') }))
85
+ .catch(() => '')
84
86
  : '';
85
87
  instructions = applyMediaEmbedInstructions(applyPreviewInstructions(toolInstructions, previewBlock));
86
88
  }
@@ -25,6 +25,7 @@
25
25
  import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
26
26
  import { createRelayQuestion } from '../../shared/question-wait.mjs';
27
27
  import { looksLikeMultiSelectQuestion } from '../../shared/question-multi-select.mjs';
28
+ import { readApprovalAnswer } from '../../shared/question-answer-rule.mjs';
28
29
  import {
29
30
  DEFAULT_QUESTION_TIMEOUT_MS,
30
31
  QUESTION_TIMEOUT_CONTINUATION_TEXT,
@@ -262,9 +263,10 @@ export function createCopilotQuestionBridge({
262
263
  /**
263
264
  * `onPermissionRequest` in ask mode → an approve/deny card.
264
265
  *
265
- * Freeform is allowed and anything that is not exactly "Approve" denies —
266
- * so a human can type *why* they are refusing and the model receives it as
267
- * feedback instead of a bare refusal.
266
+ * Freeform is allowed and anything but the approve choice (by the shared
267
+ * rule: trimmed, any case) denies — so a human can type *why* they are
268
+ * refusing and the model receives it as feedback instead of a bare refusal.
269
+ * The card's rationale says so, since a typed "yes" refuses too.
268
270
  */
269
271
  async function askToolApproval(request, { signal, message } = {}) {
270
272
  const description = describePermissionRequest(request);
@@ -273,21 +275,24 @@ export function createCopilotQuestionBridge({
273
275
  choices: [PERMISSION_APPROVE_CHOICE, PERMISSION_DENY_CHOICE],
274
276
  allowFreeform: true,
275
277
  source: 'onPermissionRequest',
276
- rationale: 'Copilot requested permission to run a tool in ask mode.',
278
+ rationale: 'Copilot requested permission to run a tool in ask mode. Approve runs it; Deny or a typed reply refuses it, and a typed reply tells Copilot why.',
277
279
  extra: {
278
280
  requestId: String(request?.requestId || '') || undefined,
279
281
  permissionKind: String(request?.kind || '') || undefined,
280
282
  },
281
283
  }, { signal, message });
282
284
  const answer = String(result?.answer ?? '').trim();
283
- const approved = !result?.timedOut && answer.toLowerCase() === PERMISSION_APPROVE_CHOICE.toLowerCase();
285
+ const decision = result?.timedOut
286
+ ? null
287
+ : readApprovalAnswer(answer, { approve: PERMISSION_APPROVE_CHOICE, deny: PERMISSION_DENY_CHOICE });
288
+ const approved = decision === 'approve';
284
289
  // The denial note is composed HERE rather than in the permission handler so
285
290
  // the handler never needs to know the card's choice labels — that would be
286
291
  // an import cycle (this module already imports the request describer from
287
292
  // the adapter). A freeform denial carries the human's own reason; a plain
288
293
  // "Deny" click does not, so echoing the button label back at the model as
289
294
  // though it were an explanation is avoided.
290
- const feedback = answer && answer.toLowerCase() !== PERMISSION_DENY_CHOICE.toLowerCase()
295
+ const feedback = answer && decision !== 'deny'
291
296
  ? answer
292
297
  : 'The user declined this action.';
293
298
  return { approved, answer, feedback, timedOut: result?.timedOut === true, description };
@@ -32,6 +32,7 @@ import {
32
32
  } from './copilot-model-switch.mjs';
33
33
  import { copilotRuntimeEntry } from '../copilot-sdk-runtime.mjs';
34
34
  import { describeTurnStall, isTurnStalledError } from '../../shared/worker-runtime/turn-liveness.mjs';
35
+ import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
35
36
 
36
37
  const VERSION_DIR_RE = /^\d+\.\d+\.\d+$/;
37
38
 
@@ -510,6 +511,16 @@ export const COPILOT_TURN_CONTINUE_GUIDANCE =
510
511
  */
511
512
  export function classifyCopilotTurnException(error) {
512
513
  const detail = String(error?.message || error || '').trim() || 'unknown error';
514
+ // The conversation's folder is gone; the message names it.
515
+ if (isWorkingDirectoryMissingError(error)) {
516
+ return {
517
+ code: WORKING_DIRECTORY_MISSING_CODE,
518
+ stableCode: `copilot.${WORKING_DIRECTORY_MISSING_CODE}`,
519
+ text: detail,
520
+ guidance: error.guidance,
521
+ detail,
522
+ };
523
+ }
513
524
  // An unconfirmed explicit model selection (audit #13). The error's own
514
525
  // message already names requested vs actual model, so it IS the user-facing
515
526
  // text; the stable code is what lets the UI (and tests) key on it.
@@ -124,6 +124,7 @@ import { createTaskOutputSpool } from '../../shared/worker-runtime/task-output-s
124
124
  import { createShellOutputMirror } from './copilot-shell-output-mirror.mjs';
125
125
  import { isTransientProgressText, settleForTask, shellExitFromEvent } from './copilot-task-settle.mjs';
126
126
  import { createKeptPosts } from '../../shared/worker-runtime/kept-posts.mjs';
127
+ import { explainLaunchFailure, missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
127
128
 
128
129
  // How long the runtime may sit with no session activity before the worker
129
130
  // closes it. The worker process itself stays up and reconnects lazily on the
@@ -369,6 +370,9 @@ export function createCopilotSdkSessionRunner({
369
370
  // module imports the real SDK (that lives in copilot-sdk-adapter.mjs).
370
371
  resolvePathsImpl = resolveCopilotSdkPaths,
371
372
  startClientImpl = startCopilotClient,
373
+ // The error a runtime started in `cwd` fails with up front, or null
374
+ // (shared/worker-runtime/working-directory.mjs).
375
+ checkWorkingDirectoryImpl = missingWorkingDirectoryError,
372
376
  createNormalizerImpl = createCopilotEventNormalizer,
373
377
  buildMessageOptionsImpl = buildCopilotMessageOptions,
374
378
  // BYOK: `COPILOT_PROVIDER_*` in this worker's env become
@@ -2622,8 +2626,17 @@ export function createCopilotSdkSessionRunner({
2622
2626
  if (client) return client;
2623
2627
  if (starting) return starting;
2624
2628
  starting = (async () => {
2629
+ // The runtime is spawned in the conversation's folder: a folder that is
2630
+ // gone fails the turn with its name, not with the spawn's ENOENT.
2631
+ const missingFolder = checkWorkingDirectoryImpl(cwd);
2632
+ if (missingFolder) throw missingFolder;
2625
2633
  sdkPaths = resolvePathsImpl({ env });
2626
- const started = await startClientImpl({ paths: sdkPaths, cwd, clientName, logLevel, dbg });
2634
+ let started;
2635
+ try {
2636
+ started = await startClientImpl({ paths: sdkPaths, cwd, clientName, logLevel, dbg });
2637
+ } catch (error) {
2638
+ throw explainLaunchFailure(error, cwd, { check: checkWorkingDirectoryImpl });
2639
+ }
2627
2640
  client = started.client;
2628
2641
  detachRuntimeExit = observeRuntimeExit(client, handleRuntimeExit);
2629
2642
  runtimeTree = watchRuntimeTree(client);
@@ -10,7 +10,7 @@ export const ASK_USER_TOOL_DESCRIPTION =
10
10
  + 'This is the ONLY way to ask the user anything: a question written as plain '
11
11
  + 'reply text is NOT shown interactively and ends the turn unanswered. Call '
12
12
  + 'this tool whenever user input would materially change the result, before '
13
- + 'making assumptions. Provide 2-4 concise options per question; the user can '
13
+ + 'making assumptions. Provide 2-6 concise options per question; the user can '
14
14
  + 'always answer in free text instead. This tool blocks until the user '
15
15
  + 'responds; on timeout it returns an instruction to continue under the '
16
16
  + 'current relay mode.';
@@ -8,6 +8,7 @@ import {
8
8
  isTurnStalledError,
9
9
  resolveTurnStallWindows,
10
10
  } from '../../shared/worker-runtime/turn-liveness.mjs';
11
+ import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
11
12
 
12
13
  /**
13
14
  * The only module in the repo that imports `@cursor/sdk` (and its
@@ -422,6 +423,19 @@ export function classifyCursorError(error) {
422
423
  const message = String(error?.message || error);
423
424
  const isRetryable = error?.isRetryable === true;
424
425
  const name = String(error?.name || '');
426
+ // The conversation's folder is gone; the message names it.
427
+ if (isWorkingDirectoryMissingError(error)) {
428
+ return {
429
+ isAuth: false,
430
+ isBusy: false,
431
+ isRetryable: false,
432
+ code: WORKING_DIRECTORY_MISSING_CODE,
433
+ stableCode: `cursor.${WORKING_DIRECTORY_MISSING_CODE}`,
434
+ message,
435
+ text: message,
436
+ guidance: error.guidance,
437
+ };
438
+ }
425
439
  if (isTurnStalledError(error)) {
426
440
  return {
427
441
  isAuth: false,
@@ -611,12 +625,13 @@ const modelParameterDefsCache = new Map();
611
625
 
612
626
  /**
613
627
  * Map the composer's reasoning effort onto the model params Cursor expects on
614
- * `agent.send`. Effort lives in a per-model parameter named 'effort' or
615
- * 'reasoning' ('extra-high' is Cursor's spelling of 'xhigh'); models that also
616
- * expose a boolean 'thinking' parameter only honor the higher tiers with
617
- * thinking enabled, so an explicit effort turns thinking on and 'none' turns it
618
- * off. Best-effort like readModelContextWindow: null on any failure or when the
619
- * model has no matching parameter, which sends the model's default variant.
628
+ * `agent.send`. Effort lives in a per-model parameter named 'effort',
629
+ * 'reasoning' or 'reasoning_effort', consulted in that order ('extra-high' is
630
+ * Cursor's spelling of 'xhigh'); models that also expose a boolean 'thinking'
631
+ * parameter only honor the higher tiers with thinking enabled, so an explicit
632
+ * effort turns thinking on and 'none' turns it off. Best-effort like
633
+ * readModelContextWindow: null on any failure or when the model has no
634
+ * matching parameter, which sends the model's default variant.
620
635
  */
621
636
  export async function resolveCursorReasoningParams({
622
637
  apiKey,
@@ -651,7 +666,7 @@ export async function resolveCursorReasoningParams({
651
666
  const paramsById = new Map(
652
667
  parameters.map((param) => [String(param?.id || '').trim().toLowerCase(), param]),
653
668
  );
654
- const effortParam = paramsById.get('effort') || paramsById.get('reasoning');
669
+ const effortParam = paramsById.get('effort') || paramsById.get('reasoning') || paramsById.get('reasoning_effort');
655
670
  const hasThinkingParam = paramsById.has('thinking');
656
671
  const effortValues = new Set(
657
672
  (Array.isArray(effortParam?.values) ? effortParam.values : [])