@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,554 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+
4
+ /**
5
+ * The only module in the repo that imports `@cursor/sdk` (and its
6
+ * `@cursor/sdk/sqlite` entry). All SDK access is injectable and the real
7
+ * bindings are resolved lazily via dynamic import, so tests that inject
8
+ * fakes never load the SDK at all.
9
+ */
10
+
11
+ async function defaultAgentFactory() {
12
+ const { Agent } = await import('@cursor/sdk');
13
+ return {
14
+ create: (options) => Agent.create(options),
15
+ resume: (agentId, options) => Agent.resume(agentId, options),
16
+ };
17
+ }
18
+
19
+ // The published SDK exposes a static `open({ workspaceRef, stateRoot })`
20
+ // instead of a path constructor; the injectable surface stays
21
+ // `storeFactoryImpl(storePath, { cwd })` with the real store rooted in the
22
+ // storePath's directory.
23
+ async function defaultStoreFactory() {
24
+ const { SqliteLocalAgentStore } = await import('@cursor/sdk/sqlite');
25
+ return (storePath, { cwd } = {}) =>
26
+ SqliteLocalAgentStore.open({
27
+ workspaceRef: cwd || path.dirname(storePath),
28
+ stateRoot: path.dirname(storePath),
29
+ });
30
+ }
31
+
32
+ export async function createCursorAgentHandle({
33
+ apiKey,
34
+ model,
35
+ cwd,
36
+ storeDir,
37
+ sdkSessionId,
38
+ agentId = '',
39
+ customTools = {},
40
+ agents = null,
41
+ agentFactoryImpl = null,
42
+ storeFactoryImpl = null,
43
+ dbg = () => {},
44
+ } = {}) {
45
+ const factory = agentFactoryImpl || (await defaultAgentFactory());
46
+ const makeStore = storeFactoryImpl || (await defaultStoreFactory());
47
+ const storePath = path.join(storeDir, sdkSessionId, 'agent.db');
48
+ fs.mkdirSync(path.dirname(storePath), { recursive: true });
49
+ const store = await makeStore(storePath, { cwd });
50
+ // `agents` rides on BOTH create and resume on purpose. The SDK persists the
51
+ // roster into the agent's store metadata at create time and falls back to
52
+ // that copy when a resume passes none, so a session resumed after the user
53
+ // changed their model selection would otherwise keep the old subagent menu.
54
+ // An empty roster is omitted rather than sent: the SDK maps `{}` to
55
+ // "unspecified" anyway, so there is no way to express "no subagents" that
56
+ // clears the persisted set.
57
+ const hasAgents = !!agents && Object.keys(agents).length > 0;
58
+ const options = {
59
+ apiKey,
60
+ model: { id: model },
61
+ ...(hasAgents ? { agents } : {}),
62
+ local: { cwd, store, customTools, autoReview: false },
63
+ };
64
+ const resumeId = String(agentId || '').trim();
65
+ let agent = null;
66
+ try {
67
+ agent = resumeId
68
+ ? await factory.resume(resumeId, options)
69
+ : await factory.create(options);
70
+ } catch (error) {
71
+ // The handle owns the store it opened; a failed create/resume must not
72
+ // leak the SQLite handle (rebuild paths retry with a fresh open).
73
+ try { store?.close?.(); } catch {}
74
+ throw error;
75
+ }
76
+ const resolvedAgentId = agent?.agentId || resumeId;
77
+ dbg('cursor agent ready', resumeId ? 'resumed' : 'created', resolvedAgentId);
78
+ return {
79
+ agent,
80
+ agentId: resolvedAgentId,
81
+ // Fresh agents' lifetime usage belongs to this conversation; resumed ones
82
+ // carry history the plan-usage ledger must baseline, not book.
83
+ created: !resumeId,
84
+ async close() {
85
+ try {
86
+ await agent?.close?.();
87
+ } catch (error) {
88
+ dbg('cursor agent close failed', error?.message || String(error));
89
+ }
90
+ // Handles are rebuilt on roster changes and busy/auth retries; without
91
+ // this every rebuild leaked an open SQLite store on the same file.
92
+ try { store?.close?.(); } catch (error) {
93
+ dbg('cursor store close failed', error?.message || String(error));
94
+ }
95
+ },
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Read the agent's cumulative billed usage and cost.
101
+ *
102
+ * `agent.getUsage()` returns lifetime totals for the agent (plus a per-run
103
+ * breakdown that carries no model, which is why the relay diffs whole-agent
104
+ * totals per turn instead of classifying runs). Cost is documented as
105
+ * server-derived and eventually consistent, so it can legitimately be absent
106
+ * right after a run ends — the caller re-reports on the next turn and the
107
+ * checkpoint diff picks it up whole.
108
+ *
109
+ * Best-effort: usage reporting must never disturb the turn that produced it.
110
+ * Despite the local agent, this is a *remote* read — the SDK routes
111
+ * `getUsage()` to `GET /v1/agents/:id/usage` through a plain `fetch` with no
112
+ * abort signal — and it runs on the path to publishing the turn's reply, so it
113
+ * is raced against a timeout the way the Claude worker races its control
114
+ * requests. A slow Cursor API costs the usage reading, never the answer.
115
+ */
116
+ export async function readAgentUsage({ agent, dbg = () => {}, timeoutMs = 8000 } = {}) {
117
+ if (typeof agent?.getUsage !== 'function') return null;
118
+ let timer = null;
119
+ try {
120
+ const request = agent.getUsage();
121
+ // The race leaves the losing promise unobserved on timeout; swallow its
122
+ // rejection so it cannot surface as an unhandledRejection.
123
+ request?.catch?.(() => {});
124
+ const usage = await Promise.race([
125
+ request,
126
+ new Promise((_, reject) => {
127
+ timer = setTimeout(() => reject(new Error(`cursor usage timed out after ${timeoutMs}ms`)), timeoutMs);
128
+ }),
129
+ ]);
130
+ if (!usage || typeof usage !== 'object') return null;
131
+ const tokens = usage.usage && typeof usage.usage === 'object' ? usage.usage : {};
132
+ const cost = usage.cost && typeof usage.cost === 'object' ? usage.cost : null;
133
+ return {
134
+ rawCostCents: Number.isFinite(Number(cost?.rawCostCents)) ? Number(cost.rawCostCents) : null,
135
+ chargedCents: Number.isFinite(Number(cost?.chargedCents)) ? Number(cost.chargedCents) : null,
136
+ inputTokens: Number.isFinite(Number(tokens.inputTokens)) ? Number(tokens.inputTokens) : null,
137
+ outputTokens: Number.isFinite(Number(tokens.outputTokens)) ? Number(tokens.outputTokens) : null,
138
+ cacheReadTokens: Number.isFinite(Number(tokens.cacheReadTokens)) ? Number(tokens.cacheReadTokens) : null,
139
+ cacheWriteTokens: Number.isFinite(Number(tokens.cacheWriteTokens)) ? Number(tokens.cacheWriteTokens) : null,
140
+ totalTokens: Number.isFinite(Number(tokens.totalTokens)) ? Number(tokens.totalTokens) : null,
141
+ runCount: Array.isArray(usage.runs) ? usage.runs.length : null,
142
+ };
143
+ } catch (error) {
144
+ dbg('cursor agent usage read failed', error?.message || String(error));
145
+ return null;
146
+ } finally {
147
+ if (timer) clearTimeout(timer);
148
+ }
149
+ }
150
+
151
+ export function modeForRelayMode(relayMode) {
152
+ return String(relayMode || 'agent').trim().toLowerCase() === 'plan' ? 'plan' : 'agent';
153
+ }
154
+
155
+ /**
156
+ * Start one run and return a turn that is both async-iterable (merged
157
+ * onDelta + run.stream() events in arrival order) and cancellable. The
158
+ * returned iterator ends when the stream ends or the abort signal fires —
159
+ * the Cursor SDK does not reliably emit a terminal message on cancel, so
160
+ * abort must end the consumer's for-await on its own.
161
+ */
162
+ export function startCursorRun({
163
+ agent,
164
+ message,
165
+ model,
166
+ modelParams = null,
167
+ relayMode = 'agent',
168
+ abortSignal = null,
169
+ // LocalSendOptions passthrough — `{ force: true }` is the SDK's documented
170
+ // recovery for a local agent left wedged by a crashed process.
171
+ sendLocal = null,
172
+ // Turn backstop: with no SDK traffic for this long (and no client-hosted
173
+ // work pending, e.g. an open ask_user card) the run is failed as stalled
174
+ // instead of parking the merged iterator forever. 0 disables.
175
+ stallTimeoutMs = 120_000,
176
+ hasPendingClientWork = () => false,
177
+ dbg = () => {},
178
+ } = {}) {
179
+ const queue = [];
180
+ let ended = false;
181
+ let failure = null;
182
+ let notify = null;
183
+ let run = null;
184
+ let lastEventAt = Date.now();
185
+ let stallTimer = null;
186
+
187
+ const wake = () => {
188
+ if (!notify) return;
189
+ const resolve = notify;
190
+ notify = null;
191
+ resolve();
192
+ };
193
+ const push = (event) => {
194
+ if (ended) return;
195
+ lastEventAt = Date.now();
196
+ queue.push(event);
197
+ wake();
198
+ };
199
+ const end = () => {
200
+ ended = true;
201
+ if (stallTimer) {
202
+ clearInterval(stallTimer);
203
+ stallTimer = null;
204
+ }
205
+ wake();
206
+ };
207
+ const onAbort = () => {
208
+ // Unread events are dropped so the consumer ends promptly.
209
+ queue.length = 0;
210
+ end();
211
+ };
212
+
213
+ if (abortSignal?.aborted) {
214
+ ended = true;
215
+ } else if (abortSignal) {
216
+ abortSignal.addEventListener('abort', onAbort, { once: true });
217
+ }
218
+
219
+ if (!ended && Number(stallTimeoutMs) > 0) {
220
+ stallTimer = setInterval(() => {
221
+ if (ended) return;
222
+ if (hasPendingClientWork()) {
223
+ // A human is thinking about a question card; the clock restarts when
224
+ // the tool settles and traffic resumes.
225
+ lastEventAt = Date.now();
226
+ return;
227
+ }
228
+ if (Date.now() - lastEventAt >= Number(stallTimeoutMs)) {
229
+ failure = failure || new Error(`cursor turn stalled: no SDK traffic for ${Math.round(Number(stallTimeoutMs) / 1000)}s`);
230
+ dbg('cursor run stalled, ending merged stream');
231
+ end();
232
+ }
233
+ }, Math.min(15_000, Math.max(1_000, Math.floor(Number(stallTimeoutMs) / 4))));
234
+ stallTimer.unref?.();
235
+ }
236
+
237
+ (async () => {
238
+ if (ended) return;
239
+ try {
240
+ run = await agent.send(message, {
241
+ // Model on EVERY send: per-run overrides are sticky across later runs.
242
+ model: {
243
+ id: model,
244
+ ...(Array.isArray(modelParams) && modelParams.length ? { params: modelParams } : {}),
245
+ },
246
+ mode: modeForRelayMode(relayMode),
247
+ ...(sendLocal && typeof sendLocal === 'object' ? { local: sendLocal } : {}),
248
+ onDelta: ({ update }) => push({ source: 'delta', update }),
249
+ });
250
+ for await (const sdkMessage of run.stream()) {
251
+ if (ended) break;
252
+ push({ source: 'stream', message: sdkMessage });
253
+ }
254
+ } catch (error) {
255
+ failure = failure || error;
256
+ dbg('cursor run failed', error?.message || String(error));
257
+ } finally {
258
+ abortSignal?.removeEventListener('abort', onAbort);
259
+ end();
260
+ }
261
+ })();
262
+
263
+ async function* iterate() {
264
+ while (true) {
265
+ // Drain buffered events before surfacing a producer failure: the
266
+ // consumer awaits an HTTP post per action, so a terminal status frame
267
+ // (with the turn's text and usage) is often still queued when the
268
+ // stream throws — dropping it would turn a completed turn into a hard
269
+ // failure. An abort clears the queue, so the failure still surfaces
270
+ // immediately on the abort race.
271
+ if (queue.length) {
272
+ yield queue.shift();
273
+ continue;
274
+ }
275
+ if (failure) throw failure;
276
+ if (ended) return;
277
+ await new Promise((resolve) => { notify = resolve; });
278
+ }
279
+ }
280
+
281
+ const iterator = iterate();
282
+ return {
283
+ [Symbol.asyncIterator]: () => iterator,
284
+ async cancel() {
285
+ try {
286
+ await run?.cancel?.();
287
+ } catch (error) {
288
+ dbg('cursor run cancel failed', error?.message || String(error));
289
+ }
290
+ },
291
+ get runId() {
292
+ return run?.id || '';
293
+ },
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Backend auth rejections often arrive without an AuthenticationError type:
299
+ * as a terminal ERROR run status ("Authentication error If you are logged
300
+ * in, try logging out and back in.") or as a transport error whose code is
301
+ * not a string. Text matching is the only signal that works across all of
302
+ * these shapes.
303
+ */
304
+ export function isCursorAuthErrorMessage(text) {
305
+ return /authentication error|unauthenticated|not authenticated|invalid api key|api key.*(?:expired|revoked)/i
306
+ .test(String(text || ''));
307
+ }
308
+
309
+ /**
310
+ * Name/code checks rather than instanceof so classification works on
311
+ * SDK-typed errors, transported plain objects, and test fixtures alike.
312
+ */
313
+ export function classifyCursorError(error) {
314
+ const message = String(error?.message || error);
315
+ const isRetryable = error?.isRetryable === true;
316
+ const name = String(error?.name || '');
317
+ if (name === 'AuthenticationError' || isCursorAuthErrorMessage(message)) {
318
+ return {
319
+ isAuth: true,
320
+ isBusy: false,
321
+ isRetryable,
322
+ code: 'authentication_failed',
323
+ stableCode: 'cursor.authentication_failed',
324
+ message,
325
+ };
326
+ }
327
+ // v1.0.26 in practice throws UnknownAgentError("Agent <id> already has
328
+ // active run") for the documented AgentBusyError case — match both.
329
+ if (name === 'AgentBusyError' || /already has (an )?active run/i.test(message)) {
330
+ return {
331
+ isAuth: false,
332
+ isBusy: true,
333
+ isRetryable,
334
+ code: 'agent_busy',
335
+ stableCode: 'cursor.agent_busy',
336
+ message,
337
+ };
338
+ }
339
+ const rawCode = typeof error?.code === 'string' ? error.code : '';
340
+ const code = rawCode.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
341
+ if (code) {
342
+ return {
343
+ isAuth: false,
344
+ isBusy: false,
345
+ isRetryable,
346
+ code,
347
+ stableCode: `cursor.${code}`,
348
+ message,
349
+ };
350
+ }
351
+ return {
352
+ isAuth: false,
353
+ isBusy: false,
354
+ isRetryable,
355
+ code: 'turn-error',
356
+ stableCode: 'cursor.turn-error',
357
+ message,
358
+ };
359
+ }
360
+
361
+ async function defaultModelsList({ apiKey }) {
362
+ // The namespace form is the only working call shape: `Cursor` has a private
363
+ // constructor and `models` is a static member, so the old client-instance
364
+ // fallback could only ever throw a TypeError that masked the real error.
365
+ const sdk = await import('@cursor/sdk');
366
+ return sdk.Cursor.models.list({ apiKey });
367
+ }
368
+
369
+ const contextWindowCache = new Map();
370
+ // Models without a `context` parameter (composer-2.5, grok-4.5) resolve to
371
+ // null; remember that for a while so they stop paying a models.list() round
372
+ // trip on every turn. Transient list failures are never cached.
373
+ const NEGATIVE_CONTEXT_WINDOW_TTL_MS = 30 * 60_000;
374
+ const MODELS_LIST_TIMEOUT_MS = 8_000;
375
+
376
+ function modelEntriesFromListResponse(response) {
377
+ if (Array.isArray(response)) return response;
378
+ if (Array.isArray(response?.models)) return response.models;
379
+ if (Array.isArray(response?.data)) return response.data;
380
+ return [];
381
+ }
382
+
383
+ function modelEntryId(entry) {
384
+ return typeof entry === 'string'
385
+ ? entry
386
+ : String(entry?.id || entry?.name || entry?.model?.id || '');
387
+ }
388
+
389
+ /** Parse a Cursor `context` parameter value: '300k' → 300000, '1m' → 1000000. */
390
+ export function parseContextWindowValue(value) {
391
+ const raw = String(value ?? '').trim().toLowerCase();
392
+ if (!raw) return null;
393
+ const match = raw.match(/^(\d+(?:\.\d+)?)([km])?$/);
394
+ if (!match) return null;
395
+ const base = Number(match[1]);
396
+ if (!Number.isFinite(base) || base <= 0) return null;
397
+ const scale = match[2] === 'm' ? 1_000_000 : (match[2] === 'k' ? 1_000 : 1);
398
+ return Math.round(base * scale);
399
+ }
400
+
401
+ function pickContextWindow(entry) {
402
+ const value = Number(
403
+ entry?.contextWindow
404
+ ?? entry?.context_window
405
+ ?? entry?.contextLength
406
+ ?? entry?.maxContextTokens,
407
+ );
408
+ if (Number.isFinite(value) && value > 0) return value;
409
+
410
+ // Current SDK builds expose no window field; the window is encoded in the
411
+ // model's 'context' parameter values ('300k', '1m'). The default variant's
412
+ // choice is what an unmodified send runs with; models without a flagged
413
+ // default fall back to the largest advertised context.
414
+ const parameters = Array.isArray(entry?.parameters) ? entry.parameters : [];
415
+ const contextParam = parameters.find(
416
+ (param) => String(param?.id || '').trim().toLowerCase() === 'context',
417
+ );
418
+ if (!contextParam) return null;
419
+
420
+ const variants = Array.isArray(entry?.variants) ? entry.variants : [];
421
+ const defaultVariant = variants.find((variant) => variant?.isDefault === true);
422
+ const defaultContext = (Array.isArray(defaultVariant?.params) ? defaultVariant.params : [])
423
+ .find((param) => String(param?.id || '').trim().toLowerCase() === 'context');
424
+ const fromDefault = parseContextWindowValue(defaultContext?.value);
425
+ if (fromDefault !== null) return fromDefault;
426
+
427
+ const candidates = (Array.isArray(contextParam.values) ? contextParam.values : [])
428
+ .map((entryValue) => parseContextWindowValue(
429
+ entryValue && typeof entryValue === 'object' ? entryValue.value : entryValue,
430
+ ))
431
+ .filter((parsed) => parsed !== null);
432
+ return candidates.length ? Math.max(...candidates) : null;
433
+ }
434
+
435
+ /**
436
+ * Best-effort context-window lookup: never throws, null on any failure.
437
+ * Successful lookups are cached per model for the process lifetime; a list
438
+ * that succeeds but resolves no window is cached as null with a TTL (the
439
+ * absence is structural, not transient); list failures are never cached. The
440
+ * call is raced against a timeout because it runs in the turn's finally path,
441
+ * where a hung Cursor API would otherwise gate reply publication.
442
+ */
443
+ export async function readModelContextWindow({
444
+ apiKey,
445
+ model,
446
+ modelsListImpl = null,
447
+ timeoutMs = MODELS_LIST_TIMEOUT_MS,
448
+ dbg = () => {},
449
+ } = {}) {
450
+ const wanted = String(model || '').trim();
451
+ if (!wanted) return null;
452
+ const cached = contextWindowCache.get(wanted);
453
+ if (cached && (!cached.expiresAt || cached.expiresAt > Date.now())) return cached.value;
454
+ try {
455
+ const listImpl = modelsListImpl || defaultModelsList;
456
+ const response = await Promise.race([
457
+ listImpl({ apiKey }),
458
+ new Promise((_resolve, reject) => {
459
+ const timer = setTimeout(
460
+ () => reject(new Error(`models.list timed out after ${timeoutMs}ms`)),
461
+ Math.max(1, Number(timeoutMs) || MODELS_LIST_TIMEOUT_MS),
462
+ );
463
+ timer.unref?.();
464
+ }),
465
+ ]);
466
+ const entries = modelEntriesFromListResponse(response);
467
+ const wantedLower = wanted.toLowerCase();
468
+ for (const entry of entries) {
469
+ const id = modelEntryId(entry);
470
+ if (id.toLowerCase() !== wantedLower) continue;
471
+ if (typeof entry === 'string') break;
472
+ const contextWindow = pickContextWindow(entry);
473
+ if (contextWindow !== null) {
474
+ contextWindowCache.set(wanted, { value: contextWindow, expiresAt: 0 });
475
+ return contextWindow;
476
+ }
477
+ break;
478
+ }
479
+ contextWindowCache.set(wanted, {
480
+ value: null,
481
+ expiresAt: Date.now() + NEGATIVE_CONTEXT_WINDOW_TTL_MS,
482
+ });
483
+ } catch (error) {
484
+ dbg('cursor models list failed', error?.message || String(error));
485
+ }
486
+ return null;
487
+ }
488
+
489
+ const modelParameterDefsCache = new Map();
490
+
491
+ /**
492
+ * Map the composer's reasoning effort onto the model params Cursor expects on
493
+ * `agent.send`. Effort lives in a per-model parameter named 'effort' or
494
+ * 'reasoning' ('extra-high' is Cursor's spelling of 'xhigh'); models that also
495
+ * expose a boolean 'thinking' parameter only honor the higher tiers with
496
+ * thinking enabled, so an explicit effort turns thinking on and 'none' turns it
497
+ * off. Best-effort like readModelContextWindow: null on any failure or when the
498
+ * model has no matching parameter, which sends the model's default variant.
499
+ */
500
+ export async function resolveCursorReasoningParams({
501
+ apiKey,
502
+ model,
503
+ reasoningEffort,
504
+ modelsListImpl = null,
505
+ dbg = () => {},
506
+ } = {}) {
507
+ const wanted = String(model || '').trim();
508
+ const effort = String(reasoningEffort || '').trim().toLowerCase();
509
+ if (!wanted || !effort) return null;
510
+ let parameters = modelParameterDefsCache.get(wanted);
511
+ if (parameters === undefined) {
512
+ try {
513
+ const listImpl = modelsListImpl || defaultModelsList;
514
+ const response = await listImpl({ apiKey });
515
+ const wantedLower = wanted.toLowerCase();
516
+ for (const entry of modelEntriesFromListResponse(response)) {
517
+ if (modelEntryId(entry).toLowerCase() !== wantedLower) continue;
518
+ parameters = typeof entry === 'object' && Array.isArray(entry?.parameters) ? entry.parameters : [];
519
+ break;
520
+ }
521
+ // Successful lookups are cached (including "model has no parameters");
522
+ // failures are not, so a transient list error is retried next turn.
523
+ if (parameters !== undefined) modelParameterDefsCache.set(wanted, parameters);
524
+ } catch (error) {
525
+ dbg('cursor models list failed', error?.message || String(error));
526
+ return null;
527
+ }
528
+ }
529
+ if (!Array.isArray(parameters) || !parameters.length) return null;
530
+ const paramsById = new Map(
531
+ parameters.map((param) => [String(param?.id || '').trim().toLowerCase(), param]),
532
+ );
533
+ const effortParam = paramsById.get('effort') || paramsById.get('reasoning');
534
+ const hasThinkingParam = paramsById.has('thinking');
535
+ const effortValues = new Set(
536
+ (Array.isArray(effortParam?.values) ? effortParam.values : [])
537
+ .map((value) => String((value && typeof value === 'object' ? value.value : value) || '').trim().toLowerCase())
538
+ .filter(Boolean),
539
+ );
540
+ if (effort === 'none') {
541
+ if (hasThinkingParam) return [{ id: 'thinking', value: 'false' }];
542
+ if (effortParam && effortValues.has('none')) return [{ id: String(effortParam.id), value: 'none' }];
543
+ return null;
544
+ }
545
+ if (!effortParam) return null;
546
+ const nativeValue = effortValues.has(effort)
547
+ ? effort
548
+ : (effort === 'xhigh' && effortValues.has('extra-high') ? 'extra-high' : '');
549
+ if (!nativeValue) return null;
550
+ return [
551
+ ...(hasThinkingParam ? [{ id: 'thinking', value: 'true' }] : []),
552
+ { id: String(effortParam.id), value: nativeValue },
553
+ ];
554
+ }
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ // Cursor session worker: a per-conversation Node process that speaks the same
3
+ // relay contracts as the Copilot CLI workers (worker WebSocket, heartbeat,
4
+ // control polling, activity channels) but executes turns through the Cursor
5
+ // SDK using an API key from the environment.
6
+ import path from 'path';
7
+ import { fileURLToPath } from 'url';
8
+
9
+ import {
10
+ loadTokenFromConfig,
11
+ resolveRelayServerUrl,
12
+ } from '../../.github/extensions/web-relay/runtime/config-loader.mjs';
13
+ import { createApiClient } from '../../.github/extensions/web-relay/runtime/api-client.mjs';
14
+ import { createWorkerWebSocketLink } from '../../.github/extensions/web-relay/runtime/worker-websocket-link.mjs';
15
+ import { createHeartbeatController } from '../../.github/extensions/web-relay/polling/heartbeat.mjs';
16
+ import { createControlPoller } from '../../shared/control-poller.mjs';
17
+ import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
18
+ import { createCursorTurnRunner } from './cursor-turn-runner.mjs';
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const HEARTBEAT_MS = 10_000;
22
+
23
+ function parseSessionIdArg(argv = process.argv) {
24
+ const index = argv.indexOf('--session-id');
25
+ if (index !== -1 && argv[index + 1]) return String(argv[index + 1]).trim();
26
+ const inline = argv.find((arg) => String(arg || '').startsWith('--session-id='));
27
+ if (inline) return String(inline.split('=')[1] || '').trim();
28
+ return String(process.env.SESSION_ID || '').trim();
29
+ }
30
+
31
+ function dbg(...parts) {
32
+ const timestamp = new Date().toISOString();
33
+ console.log(`[cursor-worker ${timestamp}]`, ...parts);
34
+ }
35
+
36
+ async function main() {
37
+ const sdkSessionId = parseSessionIdArg();
38
+ if (!sdkSessionId) {
39
+ console.error('cursor-session-worker: missing --session-id');
40
+ process.exit(2);
41
+ }
42
+
43
+ const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
44
+ || path.resolve(__dirname, '..', 'config.json');
45
+ const serverUrl = resolveRelayServerUrl({ configPath });
46
+ const token = loadTokenFromConfig(configPath);
47
+ const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
48
+ const defaultModel = String(process.env.CURSOR_RELAY_MODEL || '').trim();
49
+ const apiKey = String(process.env.CURSOR_API_KEY || '').trim();
50
+ if (!apiKey) {
51
+ // Not fatal: the first turn surfaces cursor.authentication_failed with a
52
+ // renewal hint, which is more visible to the user than a dead worker.
53
+ console.warn('cursor-session-worker: CURSOR_API_KEY is not set; turns will fail until it is provided');
54
+ }
55
+ const storeDir = String(process.env.CURSOR_AGENT_STORE_DIR || '').trim()
56
+ || path.resolve(__dirname, '..', 'data', 'cursor-agents');
57
+
58
+ const api = createApiClient({
59
+ serverUrl,
60
+ token,
61
+ getHeaders: () => ({
62
+ 'X-Relay-Process-Pid': String(process.pid),
63
+ 'X-Relay-Parent-Pid': String(process.ppid),
64
+ 'X-Relay-Session-Id': sdkSessionId,
65
+ 'X-Relay-Conversation-Id': sdkSessionId,
66
+ }),
67
+ });
68
+
69
+ const controlPoller = createControlPoller({ api, sdkSessionId, abortAckNote: 'cursor run cancelled', dbg });
70
+ const turnRunner = createCursorTurnRunner({
71
+ api,
72
+ sdkSessionId,
73
+ cwd,
74
+ defaultModel,
75
+ apiKey,
76
+ storeDir,
77
+ controlPoller,
78
+ dbg,
79
+ });
80
+
81
+ let heartbeatTimer = null;
82
+ const heartbeat = createHeartbeatController({
83
+ api,
84
+ pollMs: HEARTBEAT_MS,
85
+ getSessionReady: () => true,
86
+ getHeartbeatTimer: () => heartbeatTimer,
87
+ setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
88
+ getActiveQueueMessageId: () => turnRunner.getActiveQueueMessageId(),
89
+ });
90
+
91
+ const wsLink = createWorkerWebSocketLink({
92
+ serverUrl,
93
+ token,
94
+ dbg,
95
+ getSessionReady: () => true,
96
+ getSessionId: () => sdkSessionId,
97
+ getPid: () => process.pid,
98
+ onDeliver: async (pending, reason) => {
99
+ dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
100
+ try {
101
+ return await turnRunner.handlePendingPayload(pending);
102
+ } catch (error) {
103
+ dbg('turn handling failed', error?.message || String(error));
104
+ return false;
105
+ }
106
+ },
107
+ });
108
+
109
+ const shutdown = async (signal) => {
110
+ dbg(`shutting down (${signal})`);
111
+ try { wsLink.stop(); } catch {}
112
+ try { heartbeat.stopHeartbeat(); } catch {}
113
+ try { controlPoller.stop(); } catch {}
114
+ // The agent handle owns a SQLite store; close it before exiting — but
115
+ // bounded: a dispose that hangs must never leave the process ignoring the
116
+ // supervisor's SIGTERM (the exit path has to be as reliable as the Claude
117
+ // worker's synchronous one).
118
+ try {
119
+ await Promise.race([
120
+ turnRunner.dispose(),
121
+ new Promise((resolve) => {
122
+ const timer = setTimeout(resolve, 3_000);
123
+ timer.unref?.();
124
+ }),
125
+ ]);
126
+ } catch {}
127
+ process.exit(0);
128
+ };
129
+ process.on('SIGTERM', () => { shutdown('SIGTERM'); });
130
+ process.on('SIGINT', () => { shutdown('SIGINT'); });
131
+ installWorkerCrashGuard({
132
+ api,
133
+ workerName: 'cursor-session-worker',
134
+ getActiveQueueMessageIds: () => [turnRunner.getActiveQueueMessageId()],
135
+ });
136
+
137
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
138
+ heartbeat.startHeartbeat();
139
+ wsLink.start();
140
+ }
141
+
142
+ main().catch((error) => {
143
+ console.error('cursor-session-worker fatal:', error?.stack || error?.message || error);
144
+ process.exit(1);
145
+ });