@oar-sh/oar 0.9.0

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 (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,717 @@
1
+ import { buildCursorUserMessage } from './cursor-attachments.mjs';
2
+ import { buildCursorContextUsage } from './cursor-context-usage.mjs';
3
+ import { createSdkMessageNormalizer } from './sdk-message-normalizer.mjs';
4
+ import {
5
+ createCursorAgentHandle,
6
+ startCursorRun,
7
+ classifyCursorError,
8
+ isCursorAuthErrorMessage,
9
+ readModelContextWindow,
10
+ readAgentUsage,
11
+ resolveCursorReasoningParams,
12
+ } from './cursor-sdk-adapter.mjs';
13
+ import { createAskUserTool } from './cursor-ask-user-tool.mjs';
14
+ import { createPreviewTool } from './cursor-preview-tool.mjs';
15
+ import {
16
+ buildCursorSubagentAgents,
17
+ cursorSubagentRosterFingerprint,
18
+ } from './cursor-subagent-roster.mjs';
19
+ import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
20
+ import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
21
+ import { resolveFallbackContextLimitTokens } from '../../shared/context-window-fallbacks.mjs';
22
+ import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
23
+
24
+ // Stale-auth recovery budget per turn: expiry is routine on long-lived
25
+ // handles, and one retry proved too small in practice (an orchestrator turn
26
+ // re-authenticated once, then died terminally on the second expiry hours
27
+ // later).
28
+ const MAX_STALE_AUTH_RETRIES = 2;
29
+ // Recreate a handle proactively once it has sat idle past this window; the
30
+ // exchanged auth goes stale around the 4h mark while the API key stays valid.
31
+ const STALE_HANDLE_IDLE_MS = 30 * 60_000;
32
+
33
+ const PLAN_BOARD_ACTIONS = [
34
+ { id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
35
+ { id: 'interactive', label: 'Stop here and prompt myself', mode: 'agent' },
36
+ { id: 'exit_only', label: 'Stop here', mode: 'agent' },
37
+ ];
38
+
39
+ // The Cursor SDK's SendOptions carries only model/mode/callbacks — there is
40
+ // no per-turn instruction channel — so ask/autopilot steering rides on the
41
+ // user message text, mirroring the Copilot extension's prompt-context
42
+ // injection. Injected only when the mode changes so the session transcript
43
+ // is not spammed with repeated instructions.
44
+ const MODE_NUDGES = {
45
+ ask: '[Relay mode: ask] Prioritize clarification questions before implementation work; '
46
+ + 'do not make broad assumptions when a question would materially change the result.',
47
+ autopilot: '[Relay mode: autopilot] Keep moving unless user input is truly blocking; avoid unnecessary questions.',
48
+ };
49
+
50
+ export function cursorModeNudge(relayMode, previousMode = '') {
51
+ const mode = String(relayMode || 'agent').trim().toLowerCase();
52
+ const previous = String(previousMode || '').trim().toLowerCase();
53
+ if (mode === previous) return '';
54
+ if (MODE_NUDGES[mode]) return MODE_NUDGES[mode];
55
+ // Leaving a nudged mode must cancel the standing instruction; before any
56
+ // nudge was sent there is nothing to cancel.
57
+ if (MODE_NUDGES[previous]) return `[Relay mode: ${mode}] Previous relay-mode instructions no longer apply.`;
58
+ return '';
59
+ }
60
+
61
+ // Cursor has no exit-plan tool, so the only board source is the plan-mode
62
+ // text-shape fallback; the payload otherwise matches the Claude worker's.
63
+ export function buildCursorPlanReadyBoardPayload({
64
+ message,
65
+ planText = '',
66
+ source = 'plan-mode-fallback',
67
+ } = {}) {
68
+ const summary = String(planText || '').trim();
69
+ if (!summary) return null;
70
+ return {
71
+ queueId: message?.id,
72
+ messageId: message?.id,
73
+ conversationId: message?.conversationId,
74
+ mode: message?.relayMode || 'agent',
75
+ boardType: 'plan_ready',
76
+ title: 'Plan ready for review',
77
+ body: summary,
78
+ actions: PLAN_BOARD_ACTIONS,
79
+ recommendedAction: null,
80
+ context: {
81
+ source,
82
+ queueMessageId: message?.id || null,
83
+ conversationId: message?.conversationId || null,
84
+ relayMode: message?.relayMode || 'agent',
85
+ },
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Execute one delivered relay turn against the Cursor SDK, streaming
91
+ * normalized events into the relay's activity channels and publishing the
92
+ * final response. Mirrors the Claude worker's `handlePendingPayload`
93
+ * contract (response / requeue / abort semantics).
94
+ */
95
+ export function createCursorTurnRunner({
96
+ api,
97
+ sdkSessionId,
98
+ cwd,
99
+ defaultModel = '',
100
+ apiKey = '',
101
+ storeDir = '',
102
+ controlPoller,
103
+ createAgentHandleImpl = createCursorAgentHandle,
104
+ startCursorRunImpl = startCursorRun,
105
+ classifyErrorImpl = classifyCursorError,
106
+ readContextWindowImpl = readModelContextWindow,
107
+ readAgentUsageImpl = readAgentUsage,
108
+ resolveModelParamsImpl = resolveCursorReasoningParams,
109
+ createNormalizerImpl = createSdkMessageNormalizer,
110
+ buildUserMessageImpl = buildCursorUserMessage,
111
+ dbg = () => {},
112
+ } = {}) {
113
+ // Unlike the Claude worker, the agent handle (and the custom tools bound
114
+ // into it) outlives a single turn, so the ask_user tool is built once here
115
+ // and reaches the active turn's state through these closures.
116
+ let agentHandle = null;
117
+ let cursorAgentId = '';
118
+ // The subagent roster is fixed when the handle is created, so the handle has
119
+ // to be rebuilt when the user's model selection changes mid-conversation.
120
+ let agentRosterFingerprint = null;
121
+ let activeMessage = null;
122
+ let waitingForTurn = false;
123
+ let currentAbortController = null;
124
+ // Handle staleness is the norm, not the exception (~4h idle expires the
125
+ // exchanged auth while the API key stays valid); recreate proactively after
126
+ // a long idle instead of paying a failed attempt to find out.
127
+ let agentHandleLastUsedAt = 0;
128
+ // Open ask_user round-trips: the merged-stream stall watchdog defers while
129
+ // a human is thinking about a question card.
130
+ let pendingAskUserCalls = 0;
131
+ // Mirrors the Copilot extension's lastPromptedRelayMode: full mode
132
+ // instructions ride on the first message of a mode and on mode changes only.
133
+ let lastNudgedRelayMode = '';
134
+
135
+ const bridge = createAskUserBridge({
136
+ api,
137
+ getActiveMessage: () => activeMessage,
138
+ sdkSessionId,
139
+ questionSource: 'ask_user',
140
+ questionRationale: 'Cursor requested clarification to continue this turn.',
141
+ dbg,
142
+ });
143
+ const askUserTool = createAskUserTool({
144
+ bridge,
145
+ getAbortSignal: () => currentAbortController?.signal || null,
146
+ dbg,
147
+ });
148
+ // Registered even when the relay's preview lane is disabled: the worker
149
+ // cannot see that configuration, and the API answers a disabled lane with a
150
+ // plain refusal the model can relay instead of a failed turn.
151
+ const previewTool = createPreviewTool({
152
+ api,
153
+ getConversationId: () => activeMessage?.conversationId || '',
154
+ dbg,
155
+ });
156
+ const customTools = {
157
+ ask_user: {
158
+ description: askUserTool.description,
159
+ inputSchema: askUserTool.inputSchema,
160
+ execute: async (...args) => {
161
+ pendingAskUserCalls += 1;
162
+ try {
163
+ return await askUserTool.execute(...args);
164
+ } finally {
165
+ pendingAskUserCalls -= 1;
166
+ }
167
+ },
168
+ },
169
+ [previewTool.name]: {
170
+ description: previewTool.description,
171
+ inputSchema: previewTool.inputSchema,
172
+ execute: previewTool.execute,
173
+ },
174
+ };
175
+
176
+ function getActiveQueueMessageId() {
177
+ return waitingForTurn ? String(activeMessage?.id || '') : '';
178
+ }
179
+
180
+ function isTurnActive() {
181
+ return waitingForTurn;
182
+ }
183
+
184
+ async function dispose() {
185
+ try {
186
+ await agentHandle?.close?.();
187
+ } catch (error) {
188
+ dbg('cursor agent close failed on dispose', error?.message || String(error));
189
+ }
190
+ agentHandle = null;
191
+ agentRosterFingerprint = null;
192
+ }
193
+
194
+ async function persistAgentId(message, agentId) {
195
+ const normalized = String(agentId || '').trim();
196
+ if (!normalized || normalized === cursorAgentId) return;
197
+ try {
198
+ await api('POST', '/api/cursor-agent-id', {
199
+ conversationId: message.conversationId,
200
+ cursorAgentId: normalized,
201
+ });
202
+ // Only cache after the server accepted it, so a failed persist is
203
+ // retried on the next turn — resume across worker restarts depends on
204
+ // the server-side copy.
205
+ cursorAgentId = normalized;
206
+ } catch (error) {
207
+ dbg('cursor agent id persist failed', error?.message || String(error));
208
+ }
209
+ }
210
+
211
+ async function ensureAgentHandle(message, model) {
212
+ const rosterModels = Array.isArray(message.cursorSubagentModels) ? message.cursorSubagentModels : [];
213
+ const rosterFingerprint = cursorSubagentRosterFingerprint(rosterModels);
214
+ if (
215
+ agentHandle
216
+ && agentHandleLastUsedAt
217
+ && Date.now() - agentHandleLastUsedAt > STALE_HANDLE_IDLE_MS
218
+ ) {
219
+ dbg('cursor agent handle idle past the staleness window, recreating proactively');
220
+ try {
221
+ await agentHandle.close?.();
222
+ } catch (error) {
223
+ dbg('cursor agent close failed on staleness refresh', error?.message || String(error));
224
+ }
225
+ agentHandle = null;
226
+ }
227
+ if (agentHandle && agentRosterFingerprint !== rosterFingerprint) {
228
+ // Closing drops only the local handle; the durable Cursor agent id is
229
+ // persisted, so the rebuild below resumes the same conversation with the
230
+ // new roster rather than starting a fresh one.
231
+ dbg('cursor subagent roster changed, rebuilding agent handle');
232
+ try {
233
+ await agentHandle.close?.();
234
+ } catch (error) {
235
+ dbg('cursor agent close failed on roster change', error?.message || String(error));
236
+ }
237
+ agentHandle = null;
238
+ }
239
+ if (!agentHandle) {
240
+ const durableId = String(message.cursorAgentId || '').trim() || cursorAgentId;
241
+ const agents = buildCursorSubagentAgents(rosterModels);
242
+ const options = { apiKey, model, cwd, storeDir, sdkSessionId, customTools, agents, dbg };
243
+ try {
244
+ agentHandle = await createAgentHandleImpl({ ...options, agentId: durableId });
245
+ } catch (error) {
246
+ if (!durableId) throw error;
247
+ // The durable agent may have been pruned server-side; fall back to a
248
+ // fresh agent rather than wedging the conversation.
249
+ dbg('cursor agent resume failed, creating fresh agent', error?.message || String(error));
250
+ agentHandle = await createAgentHandleImpl({ ...options, agentId: '' });
251
+ }
252
+ }
253
+ // Only recorded once a handle exists: a create that threw leaves the
254
+ // fingerprint stale, and the null handle makes the next turn rebuild.
255
+ agentRosterFingerprint = rosterFingerprint;
256
+ // Runs even when the handle is reused, so a persist that failed on an
257
+ // earlier turn is retried here.
258
+ if (agentHandle.agentId && agentHandle.agentId !== cursorAgentId) {
259
+ await persistAgentId(message, agentHandle.agentId);
260
+ }
261
+ return agentHandle;
262
+ }
263
+
264
+ async function postActivity(message, text, subagentRunId = null) {
265
+ if (!text) return;
266
+ await api('POST', '/api/activity', {
267
+ messageId: message.id,
268
+ conversationId: message.conversationId,
269
+ mode: message.relayMode || 'agent',
270
+ text,
271
+ ...(subagentRunId ? { subagentRunId } : {}),
272
+ }).catch(() => {});
273
+ }
274
+
275
+ async function dispatchAction(message, action, state, normalizer) {
276
+ const { channel, payload } = action;
277
+ if (channel === 'init') {
278
+ // The durable agent id is persisted at handle creation, not from init.
279
+ state.responseModel = payload.model || state.responseModel;
280
+ return;
281
+ }
282
+ if (channel === 'stream') {
283
+ // Only the main thread's text can stand in for the answer: subagent
284
+ // text landing here would publish a subagent's prose as the reply on
285
+ // the abort / error / no-result fallback paths below.
286
+ if (!payload.subagentRunId) state.lastStreamedText = payload.text;
287
+ await api('POST', '/api/stream', {
288
+ messageId: message.id,
289
+ conversationId: message.conversationId,
290
+ mode: message.relayMode || 'agent',
291
+ text: payload.text,
292
+ done: payload.done === true,
293
+ ...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
294
+ }).catch(() => {});
295
+ return;
296
+ }
297
+ if (channel === 'thought') {
298
+ await api('POST', '/api/thought', {
299
+ messageId: message.id,
300
+ conversationId: message.conversationId,
301
+ mode: message.relayMode || 'agent',
302
+ reasoningId: payload.reasoningId,
303
+ text: payload.text,
304
+ done: payload.done === true,
305
+ ...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
306
+ }).catch(() => {});
307
+ return;
308
+ }
309
+ if (channel === 'activity') {
310
+ await postActivity(message, payload.text, payload.subagentRunId);
311
+ return;
312
+ }
313
+ if (channel === 'subagent') {
314
+ await api('POST', '/api/subagent-run', {
315
+ messageId: message.id,
316
+ conversationId: message.conversationId,
317
+ subagentRunId: payload.subagentRunId,
318
+ parentSubagentId: payload.parentSubagentId || undefined,
319
+ displayName: payload.displayName || undefined,
320
+ status: payload.status,
321
+ }).catch(() => {});
322
+ return;
323
+ }
324
+ if (channel === 'result') {
325
+ state.result = payload;
326
+ state.lastUsage = payload.usage || state.lastUsage;
327
+ state.responseModel = state.responseModel || normalizer.model || '';
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Ship the turn's token usage to the relay as a context-window breakdown.
333
+ * Advisory data: a failure here must not disturb the turn's response.
334
+ */
335
+ async function publishContextUsage(message, state, model, normalizer) {
336
+ // The result payload is the primary carrier, but a run can end without a
337
+ // terminal status message (or get aborted); the normalizer still saw the
338
+ // turn's usage events, so fall back to its copy rather than dropping them.
339
+ const usage = state.lastUsage || normalizer?.lastUsage || null;
340
+ if (!usage) return;
341
+ const usageModel = state.responseModel || model || '';
342
+ // Provider-advertised window first; the shared static table covers models
343
+ // whose `models.list()` entry has no `context` parameter, so the gauge is
344
+ // not totals-only for them.
345
+ const contextWindow = (await readContextWindowImpl({ apiKey, model: usageModel, dbg })
346
+ .catch(() => null))
347
+ ?? resolveFallbackContextLimitTokens(usageModel);
348
+ const built = buildCursorContextUsage({
349
+ usage,
350
+ model: usageModel,
351
+ contextWindow,
352
+ modelCallCount: normalizer?.modelCallCount,
353
+ });
354
+ if (!built) return;
355
+ await api('POST', '/api/cursor-context-usage', {
356
+ conversationId: message.conversationId,
357
+ sdkSessionId,
358
+ model: usageModel,
359
+ contextUsage: built.contextUsage,
360
+ modelUsage: built.modelUsage,
361
+ }).catch((error) => {
362
+ dbg('context usage publish failed', error?.message || String(error));
363
+ });
364
+ }
365
+
366
+ /**
367
+ * Report the agent's cumulative billed usage so the relay can book this
368
+ * turn's increase against the configured monthly allowance. The model is
369
+ * carried along because it is the only signal available for deciding which
370
+ * Cursor billing pool the spend belongs to — the SDK's per-run entries do
371
+ * not identify a model.
372
+ */
373
+ async function publishPlanUsage(message, state, model) {
374
+ if (!agentHandle?.agent || !agentHandle.agentId) return;
375
+ const usage = await readAgentUsageImpl({ agent: agentHandle.agent, dbg });
376
+ if (!usage) return;
377
+ await api('POST', '/api/cursor-plan-usage', {
378
+ conversationId: message.conversationId,
379
+ agentId: agentHandle.agentId,
380
+ model: state.responseModel || model || '',
381
+ // Lets the ledger tell a brand-new agent (book its lifetime — it IS
382
+ // this conversation) from one resumed without a checkpoint (seed the
383
+ // baseline; booking would dump historic spend into today's cycle).
384
+ agentCreated: agentHandle.created === true,
385
+ ...usage,
386
+ capturedAt: new Date().toISOString(),
387
+ }).catch((error) => {
388
+ dbg('plan usage publish failed', error?.message || String(error));
389
+ });
390
+ }
391
+
392
+ async function publishFinalStream(message, text) {
393
+ await api('POST', '/api/stream', {
394
+ messageId: message.id,
395
+ conversationId: message.conversationId,
396
+ mode: message.relayMode || 'agent',
397
+ text: String(text || ''),
398
+ done: true,
399
+ }).catch(() => {});
400
+ }
401
+
402
+ async function publishResponse(message, { text, model, terminalError = null, modelOrigin }) {
403
+ await api('POST', '/api/response', {
404
+ messageId: message.id,
405
+ conversationId: message.conversationId,
406
+ text: String(text || ''),
407
+ model: model || null,
408
+ modelOrigin: modelOrigin
409
+ || (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
410
+ ...(terminalError ? { terminalError } : {}),
411
+ }).catch(async () => {
412
+ await api('POST', '/api/requeue', { messageId: message.id }).catch(() => {});
413
+ });
414
+ }
415
+
416
+ async function runTurn(message) {
417
+ currentAbortController = new AbortController();
418
+ // `let`: the auth-retry path burns the old signal to unblock a parked
419
+ // ask_user wait, then arms a fresh one for the retry attempt.
420
+ let abortController = currentAbortController;
421
+ let normalizer = createNormalizerImpl();
422
+ const state = {
423
+ result: null,
424
+ lastStreamedText: '',
425
+ responseModel: '',
426
+ lastUsage: null,
427
+ };
428
+ let aborted = false;
429
+ let planBoardPosted = false;
430
+
431
+ // Per-message model wins so the composer can switch models between turns.
432
+ // Cursor model ids are unprefixed, so any non-auto value is accepted.
433
+ const requestedModel = String(message.model || '').trim();
434
+ const perTurnModel = requestedModel && requestedModel.toLowerCase() !== 'auto'
435
+ ? requestedModel
436
+ : '';
437
+ const model = perTurnModel
438
+ || String(message.providerModel || '').trim()
439
+ || defaultModel;
440
+
441
+ let turn = null;
442
+ // Started before agent/params setup: Agent.resume and the model-params
443
+ // lookup can take a while (or hang on a slow API), and the user's Stop
444
+ // must not be inert during that window. The whole setup lives inside the
445
+ // try so the finally can never leak a running poller.
446
+ const controlState = controlPoller?.start?.({
447
+ queueMessageId: message.id,
448
+ onAbortTurn: async () => {
449
+ aborted = true;
450
+ abortController.abort();
451
+ await turn?.cancel?.();
452
+ },
453
+ });
454
+
455
+ try {
456
+ await ensureAgentHandle(message, model);
457
+ const userMessage = buildUserMessageImpl(message);
458
+ const modeNudge = cursorModeNudge(message.relayMode, lastNudgedRelayMode);
459
+ if (modeNudge) userMessage.text = [modeNudge, userMessage.text].filter(Boolean).join('\n\n');
460
+ // Committed only once the send reached the transport (before the loop's
461
+ // break below) — a turn that dies before delivery must re-inject the
462
+ // nudge next time, or a mode change would be silently swallowed.
463
+ const pendingNudgedRelayMode = String(message.relayMode || 'agent').trim().toLowerCase();
464
+ // Per-turn like the model: the composer's effort maps onto Cursor model
465
+ // params, and null (no mapping / lookup failure) sends the model default.
466
+ const modelParams = await resolveModelParamsImpl({
467
+ apiKey,
468
+ model,
469
+ reasoningEffort: message.reasoningEffort || '',
470
+ dbg,
471
+ });
472
+
473
+ let busyRetries = 0;
474
+ let staleAuthRetries = 0;
475
+ let forceNextSend = false;
476
+
477
+ // A stale handle's auth expiry is the norm on long conversations
478
+ // (~4h idle), so the budget is 2 recreate-and-retry attempts with a
479
+ // short backoff before the failure goes terminal. Shared by the
480
+ // result-shaped and the thrown flavor of the same failure.
481
+ const recreateHandleForAuthRetry = async (flavor) => {
482
+ staleAuthRetries += 1;
483
+ dbg(`cursor auth error (${flavor}), recreating handle for retry ${staleAuthRetries}/${MAX_STALE_AUTH_RETRIES}`);
484
+ // A question card parked on the dead attempt would poll forever:
485
+ // burn the old signal to settle its wait, then arm a fresh one for
486
+ // the retry.
487
+ try { abortController.abort(); } catch {}
488
+ abortController = new AbortController();
489
+ currentAbortController = abortController;
490
+ // Subagent runs the failed attempt already announced can never be
491
+ // terminated by the fresh normalizer (it has no memory of them), so
492
+ // close their relay rows out now instead of leaving them 'running'.
493
+ for (const run of normalizer?.activeSubagentRuns?.() || []) {
494
+ await dispatchAction(message, {
495
+ channel: 'subagent',
496
+ payload: { ...run, parentSubagentId: null, status: 'failed' },
497
+ }, state, normalizer);
498
+ }
499
+ await agentHandle?.close?.();
500
+ agentHandle = null;
501
+ await ensureAgentHandle(message, model);
502
+ normalizer = createNormalizerImpl();
503
+ state.result = null;
504
+ state.lastStreamedText = '';
505
+ state.responseModel = '';
506
+ state.lastUsage = null;
507
+ // The failed attempt already streamed partial text to the relay;
508
+ // clear it and leave a trace so the retry doesn't read as the
509
+ // same turn silently rewriting itself.
510
+ await publishFinalStream(message, '');
511
+ await postActivity(message, 'Cursor session re-authenticated; retrying this message on a fresh agent handle.');
512
+ await new Promise((resolve) => setTimeout(resolve, 400 * staleAuthRetries));
513
+ };
514
+
515
+ while (true) {
516
+ try {
517
+ turn = startCursorRunImpl({
518
+ agent: agentHandle.agent,
519
+ message: userMessage,
520
+ model,
521
+ modelParams,
522
+ relayMode: message.relayMode,
523
+ abortSignal: abortController.signal,
524
+ // The SDK's documented recovery for a wedged persisted run —
525
+ // armed by the second busy retry below.
526
+ sendLocal: forceNextSend ? { force: true } : null,
527
+ hasPendingClientWork: () => pendingAskUserCalls > 0,
528
+ dbg,
529
+ });
530
+ forceNextSend = false;
531
+ for await (const event of turn) {
532
+ const actions = normalizer.normalize(event);
533
+ for (const action of actions) {
534
+ await dispatchAction(message, action, state, normalizer);
535
+ }
536
+ }
537
+ // A long-lived handle whose exchanged auth expired fails as a
538
+ // terminal ERROR result, not a thrown AuthenticationError, and the
539
+ // API key itself is usually still valid — so recreate the handle
540
+ // and retry. Exhausting the budget falls through to the isError
541
+ // publish below.
542
+ if (
543
+ staleAuthRetries < MAX_STALE_AUTH_RETRIES
544
+ && !aborted
545
+ && !abortController.signal.aborted
546
+ && state.result?.isError
547
+ // Classify on the SDK status message only: result.text is the
548
+ // model's own prose and mentioning "invalid api key" in an answer
549
+ // must never trigger a silent re-run.
550
+ && isCursorAuthErrorMessage(state.result.errorMessage)
551
+ ) {
552
+ await recreateHandleForAuthRetry('result');
553
+ continue;
554
+ }
555
+ // An abort can end the iterator cleanly before the send ever
556
+ // reached the transport; committing the nudge then would swallow a
557
+ // mode change. Re-injecting on a delivered-then-aborted turn is a
558
+ // harmless duplicate, so under-committing is the safe direction.
559
+ if (!aborted && !abortController.signal.aborted) {
560
+ lastNudgedRelayMode = pendingNudgedRelayMode;
561
+ }
562
+ break;
563
+ } catch (error) {
564
+ const classified = classifyErrorImpl(error);
565
+ // A stale handle whose previous run never settled server-side
566
+ // reports busy; one close-and-resume usually clears it, and the
567
+ // second attempt additionally expires the wedged persisted run via
568
+ // LocalSendOptions.force. A third busy is terminal.
569
+ if (busyRetries < 2 && classified.isBusy) {
570
+ busyRetries += 1;
571
+ forceNextSend = busyRetries >= 2;
572
+ dbg(`cursor agent busy, recreating handle for retry ${busyRetries}/2${forceNextSend ? ' (forcing run expiry)' : ''}`);
573
+ await agentHandle?.close?.();
574
+ agentHandle = null;
575
+ await ensureAgentHandle(message, model);
576
+ continue;
577
+ }
578
+ // The thrown flavor of the same stale-auth failure (an
579
+ // AuthenticationError from agent.send or from the handle rebuild)
580
+ // deserves the same recreate-and-retry as the result-shaped one.
581
+ if (
582
+ staleAuthRetries < MAX_STALE_AUTH_RETRIES
583
+ && !aborted
584
+ && !abortController.signal.aborted
585
+ && classified.isAuth
586
+ ) {
587
+ await recreateHandleForAuthRetry('thrown');
588
+ continue;
589
+ }
590
+ throw error;
591
+ }
592
+ }
593
+ } catch (error) {
594
+ if (aborted || abortController.signal.aborted) {
595
+ dbg('turn aborted', message.id);
596
+ await publishFinalStream(message, state.lastStreamedText);
597
+ return true;
598
+ }
599
+ throw error;
600
+ } finally {
601
+ controlPoller?.stop?.(controlState);
602
+ agentHandleLastUsedAt = Date.now();
603
+ // Runs on every exit path so usage captured before the turn went
604
+ // sideways still reaches the relay. `normalizer` is the live instance
605
+ // (the auth retry swaps it), so a discarded attempt's usage is not
606
+ // republished over the retry's.
607
+ await publishContextUsage(message, state, model, normalizer);
608
+ await publishPlanUsage(message, state, model);
609
+ }
610
+
611
+ if (aborted) {
612
+ await publishFinalStream(message, state.lastStreamedText);
613
+ return true;
614
+ }
615
+
616
+ const result = state.result;
617
+ const responseModel = state.responseModel || model || null;
618
+ if (!result) {
619
+ // The run ended without a terminal status message; surface what streamed.
620
+ const fallbackText = String(state.lastStreamedText || normalizer.finalStreamText() || '').trim();
621
+ if (fallbackText) {
622
+ await publishFinalStream(message, fallbackText);
623
+ await publishResponse(message, { text: fallbackText, model: responseModel });
624
+ } else {
625
+ await api('POST', '/api/requeue', { messageId: message.id }).catch(() => {});
626
+ }
627
+ return true;
628
+ }
629
+
630
+ if (result.isError) {
631
+ // Reaching here with an auth error means the fresh-handle retry above
632
+ // already failed too, so the key itself needs renewing. Only the SDK
633
+ // status message is auth-classified — result.text is model prose.
634
+ const authMessage = String(result.errorMessage || '').trim();
635
+ const isAuthFailure = isCursorAuthErrorMessage(authMessage);
636
+ const errorText = isAuthFailure
637
+ ? `System note: the Cursor runtime could not authenticate (${authMessage}). Set or renew the Cursor API key in provider settings, then retry.`
638
+ : result.text || `Cursor turn failed (${result.subtype || 'unknown error'}).`;
639
+ const code = isAuthFailure ? 'authentication_failed' : result.subtype || 'unknown';
640
+ await publishFinalStream(message, state.lastStreamedText);
641
+ await publishResponse(message, {
642
+ text: errorText,
643
+ model: responseModel,
644
+ terminalError: {
645
+ kind: 'cursor-turn-failed',
646
+ code,
647
+ stableCode: `cursor.${code}`,
648
+ message: errorText,
649
+ failedAt: new Date().toISOString(),
650
+ queueMessageId: String(message.id || '') || null,
651
+ },
652
+ });
653
+ return true;
654
+ }
655
+
656
+ const finalText = String(result.text || state.lastStreamedText || '').trim();
657
+ if (
658
+ !planBoardPosted
659
+ && String(message.relayMode || '').trim().toLowerCase() === 'plan'
660
+ && countPlanLikeLines(finalText) >= 2
661
+ ) {
662
+ const boardPayload = buildCursorPlanReadyBoardPayload({ message, planText: finalText });
663
+ if (boardPayload) {
664
+ await api('POST', '/api/relay-board', boardPayload).catch(() => {});
665
+ planBoardPosted = true;
666
+ }
667
+ }
668
+ // A terminal, non-error result carrying no prose is a COMPLETED turn — not
669
+ // a failed delivery — so it publishes rather than requeues. The requeue
670
+ // that used to live here re-ran a turn whose emptiness was deterministic,
671
+ // re-billing the provider and re-spawning its subagents on every attempt
672
+ // before the retry cap failed the message with a "Relay timeout" that
673
+ // misreported a turn which had succeeded.
674
+ const publishedText = finalText || EMPTY_TURN_COMPLETION_NOTE;
675
+ await publishFinalStream(message, publishedText);
676
+ await publishResponse(message, { text: publishedText, model: responseModel });
677
+ return true;
678
+ }
679
+
680
+ async function handlePendingPayload(pending) {
681
+ const message = pending?.message || null;
682
+ if (!message) return false;
683
+ activeMessage = message;
684
+ waitingForTurn = true;
685
+ try {
686
+ return await runTurn(message);
687
+ } catch (error) {
688
+ const classified = classifyErrorImpl(error);
689
+ dbg('cursor turn failed', message.id, classified.message);
690
+ await publishResponse(message, {
691
+ text: classified.isAuth
692
+ ? `System note: the Cursor runtime could not authenticate (${classified.message}). Set or renew the Cursor API key in provider settings, then retry.`
693
+ : `System note: the Cursor turn failed (${classified.message}).`,
694
+ model: null,
695
+ terminalError: {
696
+ kind: 'cursor-turn-failed',
697
+ code: classified.code,
698
+ stableCode: classified.stableCode,
699
+ message: classified.message,
700
+ failedAt: new Date().toISOString(),
701
+ queueMessageId: String(message.id || '') || null,
702
+ },
703
+ });
704
+ return true;
705
+ } finally {
706
+ waitingForTurn = false;
707
+ activeMessage = null;
708
+ }
709
+ }
710
+
711
+ return {
712
+ handlePendingPayload,
713
+ getActiveQueueMessageId,
714
+ isTurnActive,
715
+ dispose,
716
+ };
717
+ }