@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
@@ -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
@@ -1567,6 +1571,7 @@ export function createCopilotSdkSessionRunner({
1567
1571
  taskId: shell.shellId,
1568
1572
  taskType: 'local_bash',
1569
1573
  description: shell.description || 'Detached shell',
1574
+ ...(shell.command ? { command: shell.command } : {}),
1570
1575
  startedAt: shell.startedAt || null,
1571
1576
  stoppable: false,
1572
1577
  }));
@@ -1587,6 +1592,7 @@ export function createCopilotSdkSessionRunner({
1587
1592
  taskId: task.id,
1588
1593
  taskType: 'local_bash',
1589
1594
  description: task.description || task.command || 'Background shell',
1595
+ ...shellCommandField(task),
1590
1596
  startedAt,
1591
1597
  stoppable: true,
1592
1598
  ...(task.progressSummary ? { summary: task.progressSummary } : {}),
@@ -1616,6 +1622,12 @@ export function createCopilotSdkSessionRunner({
1616
1622
  return cards;
1617
1623
  }
1618
1624
 
1625
+ /** A registry shell's command line, bounded (the relay scrubs, then cuts it to 2,000). */
1626
+ function shellCommandField(task) {
1627
+ const command = typeof task?.command === 'string' ? task.command.trim().slice(0, 8000) : '';
1628
+ return command ? { command } : {};
1629
+ }
1630
+
1619
1631
  /** Where the task console reads a shell's output: its own log, or the spool mirror. */
1620
1632
  function shellOutputFields(task) {
1621
1633
  const logPath = String(task.logPath || '').trim();
@@ -1869,6 +1881,7 @@ export function createCopilotSdkSessionRunner({
1869
1881
  postTaskSettle(task.id, {
1870
1882
  ...settleForTask(finished, { exitCode: shell ? (shellExitCodes.get(task.id) ?? null) : null }),
1871
1883
  description: task.description || task.displayName || task.command || (shell ? 'Background shell' : 'Background agent'),
1884
+ ...(shell ? shellCommandField(task) : {}),
1872
1885
  taskType: shell ? 'local_bash' : 'local_agent',
1873
1886
  startedAt: Date.parse(String(task.startedAt || '')) || null,
1874
1887
  ...(task.toolCallId ? { toolUseId: String(task.toolCallId) } : {}),
@@ -2135,6 +2148,7 @@ export function createCopilotSdkSessionRunner({
2135
2148
  postTaskSettle(shell.shellId, {
2136
2149
  ...settleForTask({ type: 'shell', status: '' }, { exitCode: shellExitCodes.get(shell.shellId) ?? null }),
2137
2150
  description: shell.description || 'Detached shell',
2151
+ ...(shell.command ? { command: shell.command } : {}),
2138
2152
  taskType: 'local_bash',
2139
2153
  startedAt: shell.startedAt || null,
2140
2154
  });
@@ -2612,8 +2626,17 @@ export function createCopilotSdkSessionRunner({
2612
2626
  if (client) return client;
2613
2627
  if (starting) return starting;
2614
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;
2615
2633
  sdkPaths = resolvePathsImpl({ env });
2616
- 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
+ }
2617
2640
  client = started.client;
2618
2641
  detachRuntimeExit = observeRuntimeExit(client, handleRuntimeExit);
2619
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 : [])
@@ -17,6 +17,7 @@ import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-fil
17
17
  import { createControlPoller } from '../../shared/control-poller.mjs';
18
18
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
19
19
  import { createCursorTurnRunner } from './cursor-turn-runner.mjs';
20
+ import { resolveCursorSessionStoreDir } from '../services/cursor-session-root-service.mjs';
20
21
 
21
22
  // Windows console launch: the launcher names the worker log, this copies
22
23
  // stdout/stderr into it. A no-op wherever the launcher redirects instead.
@@ -57,8 +58,14 @@ async function main() {
57
58
  // renewal hint, which is more visible to the user than a dead worker.
58
59
  console.warn('cursor-session-worker: CURSOR_API_KEY is not set; turns will fail until it is provided');
59
60
  }
60
- const storeDir = String(process.env.CURSOR_AGENT_STORE_DIR || '').trim()
61
- || path.resolve(__dirname, '..', 'data', 'cursor-agents');
61
+ // `$CURSOR_AGENT_STORE_DIR`, else `cursor-agents` in the relay's data dir:
62
+ // the same folder the relay's Session root looks in.
63
+ const storeDir = resolveCursorSessionStoreDir({
64
+ env: process.env,
65
+ serverDir: path.resolve(__dirname, '..'),
66
+ configPath,
67
+ sdkSessionId,
68
+ });
62
69
 
63
70
  const api = createApiClient({
64
71
  serverUrl,
@@ -23,6 +23,7 @@ import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instr
23
23
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
24
24
  import { resolveFallbackContextLimitTokens } from '../../shared/context-window-fallbacks.mjs';
25
25
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
26
+ import { explainLaunchFailure, missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
26
27
 
27
28
  // Stale-auth recovery budget per turn: expiry is routine on long-lived
28
29
  // handles, and one retry proved too small in practice (an orchestrator turn
@@ -122,6 +123,9 @@ export function createCursorTurnRunner({
122
123
  resolveModelParamsImpl = resolveCursorReasoningParams,
123
124
  createNormalizerImpl = createSdkMessageNormalizer,
124
125
  buildUserMessageImpl = buildCursorUserMessage,
126
+ // The error an agent started in `cwd` fails with up front, or null
127
+ // (shared/worker-runtime/working-directory.mjs).
128
+ checkWorkingDirectoryImpl = missingWorkingDirectoryError,
125
129
  // Whether agent handles carry the relay's `remote_relay` tool
126
130
  // (shared/remote-relay-tool-core.mjs). Default: ask the relay.
127
131
  remoteRelayToolGate = null,
@@ -174,7 +178,9 @@ export function createCursorTurnRunner({
174
178
  // plain refusal the model can relay instead of a failed turn.
175
179
  const previewTool = createPreviewTool({
176
180
  api,
177
- getConversationId: () => activeMessage?.conversationId || '',
181
+ // Between turns, the worker's own session (as remote_relay below): list
182
+ // and close are scoped to a conversation and refused without one.
183
+ getConversationId: () => activeMessage?.conversationId || sdkSessionId || '',
178
184
  dbg,
179
185
  });
180
186
  const customTools = {
@@ -301,6 +307,10 @@ export function createCursorTurnRunner({
301
307
  agentHandle = null;
302
308
  }
303
309
  if (!agentHandle) {
310
+ // The agent works in the conversation's folder: a folder that is gone
311
+ // fails the turn with its name (and is no reason to drop the agent).
312
+ const missingFolder = checkWorkingDirectoryImpl(cwd);
313
+ if (missingFolder) throw missingFolder;
304
314
  const durableId = String(message.cursorAgentId || '').trim() || cursorAgentId;
305
315
  const agents = buildCursorSubagentAgents(rosterModels);
306
316
  await remoteRelayGate.ready();
@@ -309,11 +319,15 @@ export function createCursorTurnRunner({
309
319
  try {
310
320
  agentHandle = await createAgentHandleImpl({ ...options, agentId: durableId });
311
321
  } catch (error) {
312
- if (!durableId) throw error;
322
+ if (!durableId) throw explainLaunchFailure(error, cwd, { check: checkWorkingDirectoryImpl });
313
323
  // The durable agent may have been pruned server-side; fall back to a
314
324
  // fresh agent rather than wedging the conversation.
315
325
  dbg('cursor agent resume failed, creating fresh agent', error?.message || String(error));
316
- agentHandle = await createAgentHandleImpl({ ...options, agentId: '' });
326
+ try {
327
+ agentHandle = await createAgentHandleImpl({ ...options, agentId: '' });
328
+ } catch (freshError) {
329
+ throw explainLaunchFailure(freshError, cwd, { check: checkWorkingDirectoryImpl });
330
+ }
317
331
  }
318
332
  }
319
333
  // Only recorded once a handle exists: a create that threw leaves the
@@ -787,6 +801,7 @@ export function createCursorTurnRunner({
787
801
  // A stall is told in the user's terms, and with its own advice: the
788
802
  // run was cancelled, so sending the message again is all it takes.
789
803
  message: classified.text || classified.message,
804
+ ...(classified.guidance ? { guidance: classified.guidance } : {}),
790
805
  ...(classified.isStalled ? { guidance: 'Send the message again to retry.' } : {}),
791
806
  failedAt: new Date().toISOString(),
792
807
  queueMessageId: String(message.id || '') || null,
@@ -23,6 +23,7 @@ import { migrateConversationGroups } from './migrations/0014-conversation-groups
23
23
  import { migrateConversationGroupPosition } from './migrations/0015-conversation-group-position.mjs';
24
24
  import { migrateShareAccessLog } from './migrations/0016-share-access-log.mjs';
25
25
  import { migrateConversationHistoryIndexes } from './migrations/0017-conversation-history-indexes.mjs';
26
+ import { migrateBackgroundTaskRows } from './migrations/0018-background-task-rows.mjs';
26
27
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
27
28
 
28
29
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -845,6 +846,15 @@ try {
845
846
  console.warn(`[history] migration 0017 failed; retrying on next boot: ${error?.message || error}`);
846
847
  }
847
848
 
849
+ // The task console's rows across a restart: one new table. The task store
850
+ // checks for it and keeps its rows in memory only without it, so a failure is
851
+ // logged and retried next boot like 0017.
852
+ try {
853
+ migrateBackgroundTaskRows(db);
854
+ } catch (error) {
855
+ console.warn(`[background-tasks] migration 0018 failed; retrying on next boot: ${error?.message || error}`);
856
+ }
857
+
848
858
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
849
859
  if (runtimeSessionColumns.length) {
850
860
  if (!runtimeSessionColumns.includes('strategy')) {
@@ -37,6 +37,9 @@ export function createAcpHostServices({
37
37
  platform = process.platform,
38
38
  spawnImpl = spawn,
39
39
  spawnSyncImpl = spawnSync,
40
+ // Full path of a program Windows ships (taskkill, Windows PowerShell), or
41
+ // its bare name when it is not where Windows keeps it.
42
+ systemToolPath = windowsSystemToolPath,
40
43
  fsImpl = fs,
41
44
  // Told about every terminal's life (grok-background-tasks.mjs publishes
42
45
  // them as background tasks): `started`, `output`, `killed`, `released`,
@@ -65,9 +68,9 @@ export function createAcpHostServices({
65
68
  timeout: 10_000,
66
69
  stdio: 'ignore',
67
70
  });
68
- cachedWindowsShell = probe && probe.status === 0 ? 'pwsh' : windowsSystemToolPath('powershell');
71
+ cachedWindowsShell = probe && probe.status === 0 ? 'pwsh' : systemToolPath('powershell');
69
72
  } catch {
70
- cachedWindowsShell = windowsSystemToolPath('powershell');
73
+ cachedWindowsShell = systemToolPath('powershell');
71
74
  }
72
75
  return cachedWindowsShell;
73
76
  }
@@ -210,7 +213,7 @@ export function createAcpHostServices({
210
213
  if (platform === 'win32' && entry.proc.pid) {
211
214
  // proc.kill() only reaches the shell; shells leave grandchildren behind.
212
215
  try {
213
- spawnImpl(windowsSystemToolPath('taskkill'), ['/pid', String(entry.proc.pid), '/t', '/f'], {
216
+ spawnImpl(systemToolPath('taskkill'), ['/pid', String(entry.proc.pid), '/t', '/f'], {
214
217
  stdio: 'ignore',
215
218
  windowsHide: true,
216
219
  });
@@ -15,6 +15,8 @@ import { createKeptPosts } from '../../shared/worker-runtime/kept-posts.mjs';
15
15
  import { createTaskOutputSpool } from '../../shared/worker-runtime/task-output-spool.mjs';
16
16
 
17
17
  const MAX_DESCRIPTION_CHARS = 500;
18
+ // A bound on the post: the relay scrubs the command, then cuts it to 2,000.
19
+ const MAX_COMMAND_CHARS = 8000;
18
20
  const SPOOL_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
19
21
  // Ended terminals remembered for a late exit after their release.
20
22
  const MAX_TRACKED_TERMINALS = 200;
@@ -68,6 +70,7 @@ export function createGrokTaskPublisher({
68
70
  taskId: entry.taskId,
69
71
  taskType: 'local_bash',
70
72
  description: entry.description,
73
+ ...(entry.command ? { command: entry.command } : {}),
71
74
  startedAt: entry.startedAt,
72
75
  stoppable: true,
73
76
  ...(outputPath ? { outputPath, outputKind: 'spool' } : {}),
@@ -117,6 +120,9 @@ export function createGrokTaskPublisher({
117
120
  const entry = {
118
121
  taskId,
119
122
  description: String(command || '').trim().slice(0, MAX_DESCRIPTION_CHARS) || 'Terminal',
123
+ // The whole command line (the description is cut shorter), for the
124
+ // console's `| tail` marker.
125
+ command: String(command || '').trim().slice(0, MAX_COMMAND_CHARS),
120
126
  startedAt,
121
127
  kill,
122
128
  live: false,
@@ -196,6 +202,7 @@ export function createGrokTaskPublisher({
196
202
  status,
197
203
  summary: exitCode !== null ? `exit code ${exitCode}` : (signal ? `killed by ${signal}` : null),
198
204
  description: entry.description,
205
+ ...(entry.command ? { command: entry.command } : {}),
199
206
  taskType: 'local_bash',
200
207
  startedAt: entry.startedAt,
201
208
  // Absent rather than null: a killed process has no exit code.
@@ -6,12 +6,26 @@ import { AcpClient, extractGrokModelsFromInitialize, pickAllowOnceOptionId } fro
6
6
  import { createAcpHostServices } from './acp-host-services.mjs';
7
7
  import { createSdkMessageNormalizer } from './sdk-message-normalizer.mjs';
8
8
  import { describeTurnStall, isTurnStalledError } from '../../shared/worker-runtime/turn-liveness.mjs';
9
+ import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
9
10
 
10
11
  export { extractGrokModelsFromInitialize };
11
12
 
12
13
  export function classifyGrokError(error) {
13
14
  const message = String(error?.message || error || '').trim();
14
15
  const lower = message.toLowerCase();
16
+ // First, and above all before the ENOENT branch: a spawn in a folder that
17
+ // is gone fails with ENOENT too, and the runner puts it down to the folder,
18
+ // whose path may hold any of the words matched below.
19
+ if (isWorkingDirectoryMissingError(error)) {
20
+ return {
21
+ code: `grok.${WORKING_DIRECTORY_MISSING_CODE}`,
22
+ message,
23
+ text: message,
24
+ guidance: error.guidance,
25
+ isAuth: false,
26
+ isBusy: false,
27
+ };
28
+ }
15
29
  // No bare 'auth' token: it would match unrelated words ("author…") in
16
30
  // arbitrary agent error text and misclassify them as login failures.
17
31
  if (
@@ -12,6 +12,7 @@ import { fetchRemoteRelayInflight } from '../../shared/remote-relay-tool-core.mj
12
12
  import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
13
13
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
14
14
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
15
+ import { explainLaunchFailure, missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
15
16
 
16
17
  const PLAN_BOARD_ACTIONS = [
17
18
  { id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
@@ -64,6 +65,7 @@ function buildGrokTerminalError(classified, message) {
64
65
  code: classified.code,
65
66
  stableCode: classified.code,
66
67
  message: classified.text || classified.message,
68
+ ...(classified.guidance ? { guidance: classified.guidance } : {}),
67
69
  // A stalled turn was cancelled and its agent is restarted for the next
68
70
  // message, so sending the message again is all it takes.
69
71
  ...(classified.isStalled ? { guidance: 'Send the message again to retry.' } : {}),
@@ -115,6 +117,9 @@ export function createGrokTurnRunner({
115
117
  createAgentHandleImpl = createGrokAgentHandle,
116
118
  startGrokTurnImpl = startGrokTurn,
117
119
  classifyErrorImpl = classifyGrokError,
120
+ // The error a Grok CLI started in `cwd` fails with up front, or null
121
+ // (shared/worker-runtime/working-directory.mjs).
122
+ checkWorkingDirectoryImpl = missingWorkingDirectoryError,
118
123
  getPreviewInstructions = createPreviewInstructionsProvider({ api }),
119
124
  env = process.env,
120
125
  nodePath = process.execPath,
@@ -200,6 +205,11 @@ export function createGrokTurnRunner({
200
205
 
201
206
  async function ensureAgentHandle(message, model) {
202
207
  if (agentHandle) return agentHandle;
208
+ // The CLI is spawned in the conversation's folder; a folder that is gone
209
+ // fails with ENOENT, which would otherwise read as a CLI that is not
210
+ // installed (classifyGrokError).
211
+ const missingFolder = checkWorkingDirectoryImpl(cwd);
212
+ if (missingFolder) throw missingFolder;
203
213
  const resumeId = String(message.grokNativeSessionId || grokNativeSessionId || '').trim();
204
214
  agentHandle = await createAgentHandleImpl({
205
215
  command,
@@ -215,7 +225,7 @@ export function createGrokTurnRunner({
215
225
  ? { AcpClientImpl: withAcpMcpServers(AcpClient, acpMcpServers), mcpServers: acpMcpServers }
216
226
  : {}),
217
227
  dbg,
218
- });
228
+ }).catch((error) => { throw explainLaunchFailure(error, cwd, { check: checkWorkingDirectoryImpl }); });
219
229
  if (agentHandle.sessionId) {
220
230
  await persistNativeSessionId(message, agentHandle.sessionId);
221
231
  }
@@ -427,7 +437,7 @@ export function createGrokTurnRunner({
427
437
  const previewBlock = previewInstructionsSent || acpMcpServers.length
428
438
  ? ''
429
439
  : await Promise.resolve()
430
- .then(() => getPreviewInstructions?.())
440
+ .then(() => getPreviewInstructions?.({ conversationId: message.conversationId || sdkSessionId || '' }))
431
441
  .catch(() => '');
432
442
  const mediaBlock = mediaInstructionsSent ? '' : renderMediaEmbedInstructionBlock();
433
443
  const promptText = [previewBlock, mediaBlock, modeNudge, userText].filter(Boolean).join('\n\n');
@@ -594,7 +604,7 @@ export function createGrokTurnRunner({
594
604
  : classified.isStalled
595
605
  ? classified.text
596
606
  || `System note: the Grok turn stalled and was stopped (${classified.message}). Send a new message to retry.`
597
- : `System note: the Grok turn failed (${classified.message}).`;
607
+ : classified.text || `System note: the Grok turn failed (${classified.message}).`;
598
608
  await publishResponse(message, {
599
609
  text: noteText,
600
610
  model: message.providerModel || defaultModel || null,
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: the task console's rows outlive a relay restart.
5
+ *
6
+ * `background_task_rows` holds what the relay keeps of a background task
7
+ * that has a console (a shell or Monitor task naming its output file), per
8
+ * store key (the id the worker posts under, its SDK session id) and task:
9
+ *
10
+ * - `state` 'live': a task of the worker's last set. After a restart it is
11
+ * held back until the worker reports again: the worker's set decides which
12
+ * are still running, and those of a worker that does not report are marked
13
+ * ended, never shown as running again.
14
+ * - `state` 'ended': the dimmed row the panel keeps for an hour after the end,
15
+ * with its status and exit code. `ended_at` (epoch ms) is what the hour
16
+ * counts from.
17
+ * - `row_json` the task row as the store holds it, without the health
18
+ * reading (the poller reads the file again).
19
+ *
20
+ * Rows go when the hour is up, when the conversation is deleted, and live
21
+ * rows when the worker's set no longer names them.
22
+ *
23
+ * Additive and idempotent. Safe to run on every boot. An older build never
24
+ * reads the table; its consoles start empty after a restart as before.
25
+ *
26
+ * Usage: node server/migrations/0018-background-task-rows.mjs [path/to/copilot.db]
27
+ */
28
+
29
+ import { fileURLToPath } from 'url';
30
+ import path from 'path';
31
+ import process from 'process';
32
+ import Database from 'better-sqlite3';
33
+
34
+ export const BACKGROUND_TASK_ROWS_COLUMNS = Object.freeze(['store_key', 'task_id', 'state', 'row_json', 'ended_at', 'updated_at']);
35
+
36
+ function tableExists(db, name) {
37
+ return !!db
38
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
39
+ .get(name);
40
+ }
41
+
42
+ /** Why the table cannot be used (a table of that name with other columns), or ''. */
43
+ export function backgroundTaskRowsTableProblem(db) {
44
+ if (!tableExists(db, 'background_task_rows')) return 'no background_task_rows table';
45
+ const present = new Set(db.prepare(`PRAGMA table_info(background_task_rows)`).all().map((column) => column.name));
46
+ const missing = BACKGROUND_TASK_ROWS_COLUMNS.filter((name) => !present.has(name));
47
+ return missing.length ? `background_task_rows lacks ${missing.join(', ')}` : '';
48
+ }
49
+
50
+ export function migrateBackgroundTaskRows(db) {
51
+ let tablesAdded = 0;
52
+ if (!tableExists(db, 'background_task_rows')) {
53
+ db.exec(`
54
+ CREATE TABLE background_task_rows (
55
+ store_key TEXT NOT NULL,
56
+ task_id TEXT NOT NULL,
57
+ state TEXT NOT NULL CHECK (state IN ('live', 'ended')),
58
+ row_json TEXT NOT NULL,
59
+ ended_at INTEGER,
60
+ updated_at INTEGER NOT NULL,
61
+ PRIMARY KEY (store_key, task_id)
62
+ )
63
+ `);
64
+ tablesAdded += 1;
65
+ }
66
+ db.exec(`
67
+ CREATE INDEX IF NOT EXISTS idx_background_task_rows_ended
68
+ ON background_task_rows(state, ended_at)
69
+ `);
70
+ return { applied: tablesAdded > 0, tablesAdded };
71
+ }
72
+
73
+ function defaultDbPath() {
74
+ const here = path.dirname(fileURLToPath(import.meta.url));
75
+ return path.resolve(here, '..', 'data', 'copilot.db');
76
+ }
77
+
78
+ export function migrate(dbPath, options = {}) {
79
+ const db = new Database(dbPath, options);
80
+ try {
81
+ return migrateBackgroundTaskRows(db);
82
+ } finally {
83
+ db.close();
84
+ }
85
+ }
86
+
87
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
88
+ if (isMain) {
89
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
90
+ try {
91
+ const result = migrate(dbPath);
92
+ console.log(`Migration 0018 on ${dbPath}`);
93
+ console.log(result.applied
94
+ ? ` background task rows: tables added ${result.tablesAdded}`
95
+ : ' skipped: nothing to change');
96
+ process.exit(0);
97
+ } catch (err) {
98
+ console.error(`Migration 0018 failed: ${err?.message || err}`);
99
+ process.exit(1);
100
+ }
101
+ }