@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,566 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Cursor plan usage.
5
+ *
6
+ * The Cursor SDK reports authoritative *spend* (`agent.getUsage()` →
7
+ * `rawCostCents` / `chargedCents` plus token counts) but exposes no documented
8
+ * account API for the monthly included pools, their remaining balance, or the
9
+ * billing reset date — those live only in the Spending dashboard. So the plan
10
+ * side is reconstructed locally:
11
+ *
12
+ * - the user configures a monthly allowance per pool and a reset day;
13
+ * - every worker report is diffed against a per-agent cumulative checkpoint;
14
+ * - the diff is attributed to a pool using the model that ran that turn and
15
+ * accumulated into the current billing cycle.
16
+ *
17
+ * Diffing per turn (rather than classifying the SDK's `runs[]`, which carry no
18
+ * model) is what makes pool attribution possible at all: the relay knows which
19
+ * model it asked for, and the cumulative delta since the previous checkpoint is
20
+ * exactly that turn's cost.
21
+ *
22
+ * Everything derived this way is flagged `estimated` — the Spending dashboard
23
+ * stays the authoritative source and is linked from the card.
24
+ */
25
+
26
+ import {
27
+ SOURCE_LIVE,
28
+ SOURCE_MANUAL,
29
+ STATUS_NOT_CONFIGURED,
30
+ STATUS_OK,
31
+ STATUS_PARTIAL,
32
+ buildDetailSection,
33
+ buildMeter,
34
+ buildProviderCard,
35
+ buildUnavailableCard,
36
+ roundCurrency,
37
+ toFiniteNumber,
38
+ toIsoTimestamp,
39
+ toTrimmedString,
40
+ } from './plan-usage-contract.mjs';
41
+
42
+ export const CURSOR_PROVIDER_ID = 'cursor';
43
+ export const CURSOR_LABEL = 'Cursor';
44
+
45
+ export const CURSOR_SPENDING_URL = 'https://cursor.com/dashboard/spending';
46
+
47
+ export const POOL_CURSOR_MODELS = 'cursor';
48
+ export const POOL_OTHER_MODELS = 'other';
49
+ export const POOL_UNCLASSIFIED = 'unclassified';
50
+ export const CURSOR_POOLS = Object.freeze([POOL_CURSOR_MODELS, POOL_OTHER_MODELS, POOL_UNCLASSIFIED]);
51
+
52
+ export const POOL_LABELS = Object.freeze({
53
+ [POOL_CURSOR_MODELS]: 'Cursor Models',
54
+ [POOL_OTHER_MODELS]: 'Other Models',
55
+ [POOL_UNCLASSIFIED]: 'Unclassified',
56
+ });
57
+
58
+ export const DEFAULT_CURSOR_RESET_DAY = 1;
59
+
60
+ // Router/auto selections bill to whichever pool served the request, which the
61
+ // SDK does not disclose — they must not be silently charged to one pool.
62
+ const ROUTER_MODEL_PATTERNS = [/^auto$/i, /router/i, /^cost$/i, /^balance$/i, /^intelligence$/i];
63
+ // "Cursor Models" per Cursor's usage-limits docs: Composer plus Cursor's own
64
+ // Grok tier.
65
+ const FIRST_PARTY_PATTERNS = [/^composer/i, /^cursor-/i, /grok/i, /^cheetah/i, /^sonic/i];
66
+ const THIRD_PARTY_PATTERNS = [
67
+ /claude/i, /sonnet/i, /opus/i, /haiku/i, /fable/i,
68
+ /^gpt/i, /^o[1-9]/i, /codex/i,
69
+ /gemini/i, /deepseek/i, /kimi/i, /qwen/i, /llama/i, /mistral/i, /^glm/i, /minimax/i,
70
+ ];
71
+
72
+ export function classifyCursorModelPool(model) {
73
+ const id = toTrimmedString(model);
74
+ if (!id) return POOL_UNCLASSIFIED;
75
+ if (ROUTER_MODEL_PATTERNS.some((pattern) => pattern.test(id))) return POOL_UNCLASSIFIED;
76
+ if (FIRST_PARTY_PATTERNS.some((pattern) => pattern.test(id))) return POOL_CURSOR_MODELS;
77
+ if (THIRD_PARTY_PATTERNS.some((pattern) => pattern.test(id))) return POOL_OTHER_MODELS;
78
+ return POOL_UNCLASSIFIED;
79
+ }
80
+
81
+ export function centsToUsd(cents) {
82
+ const numeric = toFiniteNumber(cents);
83
+ return numeric === null ? null : Math.round(numeric) / 100;
84
+ }
85
+
86
+ export function normalizeCursorResetDay(value) {
87
+ const numeric = toFiniteNumber(value);
88
+ if (numeric === null) return DEFAULT_CURSOR_RESET_DAY;
89
+ return Math.max(1, Math.min(31, Math.round(numeric)));
90
+ }
91
+
92
+ export function normalizeAllowanceUsd(value) {
93
+ const numeric = toFiniteNumber(value);
94
+ if (numeric === null || numeric < 0) return null;
95
+ return Math.round(numeric * 100) / 100;
96
+ }
97
+
98
+ export function normalizeCursorAllowanceSettings(raw = {}) {
99
+ return {
100
+ cursorModelsUsd: normalizeAllowanceUsd(raw?.cursorModelsUsd),
101
+ otherModelsUsd: normalizeAllowanceUsd(raw?.otherModelsUsd),
102
+ resetDay: normalizeCursorResetDay(raw?.resetDay),
103
+ };
104
+ }
105
+
106
+ function daysInMonth(year, monthIndex) {
107
+ return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
108
+ }
109
+
110
+ /**
111
+ * Resolve the billing cycle that `now` falls into for a recurring monthly reset
112
+ * day. A reset day past the end of a short month clamps to that month's last
113
+ * day, so a "31st" cycle still works in February.
114
+ */
115
+ export function resolveCursorBillingCycle({ resetDay = DEFAULT_CURSOR_RESET_DAY, now = new Date() } = {}) {
116
+ const day = normalizeCursorResetDay(resetDay);
117
+ const reference = now instanceof Date && !Number.isNaN(now.getTime()) ? now : new Date();
118
+ const year = reference.getUTCFullYear();
119
+ const month = reference.getUTCMonth();
120
+
121
+ const anchorFor = (targetYear, targetMonth) => {
122
+ const normalizedYear = targetYear + Math.floor(targetMonth / 12);
123
+ const normalizedMonth = ((targetMonth % 12) + 12) % 12;
124
+ const clampedDay = Math.min(day, daysInMonth(normalizedYear, normalizedMonth));
125
+ return new Date(Date.UTC(normalizedYear, normalizedMonth, clampedDay, 0, 0, 0, 0));
126
+ };
127
+
128
+ const thisMonthAnchor = anchorFor(year, month);
129
+ const startsAt = reference.getTime() >= thisMonthAnchor.getTime()
130
+ ? thisMonthAnchor
131
+ : anchorFor(year, month - 1);
132
+ const endsAt = anchorFor(startsAt.getUTCFullYear(), startsAt.getUTCMonth() + 1);
133
+
134
+ return {
135
+ key: startsAt.toISOString().slice(0, 10),
136
+ startsAt: startsAt.toISOString(),
137
+ endsAt: endsAt.toISOString(),
138
+ resetDay: day,
139
+ };
140
+ }
141
+
142
+ const NUMERIC_REPORT_FIELDS = [
143
+ 'rawCostCents',
144
+ 'chargedCents',
145
+ 'inputTokens',
146
+ 'outputTokens',
147
+ 'cacheReadTokens',
148
+ 'cacheWriteTokens',
149
+ 'totalTokens',
150
+ 'runCount',
151
+ ];
152
+
153
+ export function normalizeCursorUsageReport(raw = {}) {
154
+ const agentId = toTrimmedString(raw?.agentId);
155
+ if (!agentId) return null;
156
+ const report = {
157
+ agentId,
158
+ model: toTrimmedString(raw?.model),
159
+ capturedAt: toIsoTimestamp(raw?.capturedAt) || new Date().toISOString(),
160
+ // True only when the worker created this agent fresh (its lifetime IS the
161
+ // current conversation) — the first-sighting rule below keys on it.
162
+ agentCreated: raw?.agentCreated === true,
163
+ };
164
+ let hasMetric = false;
165
+ for (const field of NUMERIC_REPORT_FIELDS) {
166
+ const value = toFiniteNumber(raw?.[field]);
167
+ // Negative cumulative counters are nonsense; treat them as "not reported"
168
+ // rather than letting them drive a negative delta.
169
+ report[field] = value === null || value < 0 ? null : value;
170
+ if (report[field] !== null) hasMetric = true;
171
+ }
172
+ return hasMetric ? report : null;
173
+ }
174
+
175
+ /**
176
+ * Diff a cumulative per-agent report against the stored checkpoint.
177
+ *
178
+ * `agent.getUsage()` returns totals for the agent's whole lifetime, so only the
179
+ * increase since the previous report belongs to the current cycle. A field that
180
+ * moves *backwards* means the backend restated it (cost is documented as
181
+ * eventually consistent) or that reports arrived out of order; the checkpoint
182
+ * then holds its previous high-water mark and nothing is booked. Lowering the
183
+ * baseline instead would re-book the difference when the value recovered.
184
+ *
185
+ * Known limit of the "diff per turn" approach: cost that lands after the turn
186
+ * that incurred it is attributed to whichever turn's model/cycle observes it.
187
+ * That is inherent to a locally reconstructed figure and is why the whole
188
+ * Cursor card is flagged as estimated.
189
+ */
190
+ export function applyCursorUsageDelta({ checkpoint = null, report = null } = {}) {
191
+ // Always normalize: the worker sends raw values, and skipping this for
192
+ // anything that merely carries an agentId would let unvalidated (or entirely
193
+ // metric-free) payloads through.
194
+ const normalized = normalizeCursorUsageReport(report);
195
+ if (!normalized) return null;
196
+
197
+ const previous = checkpoint && typeof checkpoint === 'object' ? checkpoint : {};
198
+ // First sighting of this agent with no stored checkpoint: getUsage() is a
199
+ // LIFETIME total, so booking it as a delta would dump the agent's entire
200
+ // history into the current cycle (fresh install, restored DB, pruned
201
+ // checkpoint). Seed the baseline without booking — unless the worker says
202
+ // it created the agent this conversation, in which case the lifetime IS
203
+ // current work and books whole.
204
+ const firstSighting = toTrimmedString(previous?.agentId) !== normalized.agentId;
205
+ const seedOnly = firstSighting && normalized.agentCreated !== true;
206
+ const delta = {};
207
+ const nextCheckpoint = { agentId: normalized.agentId, capturedAt: normalized.capturedAt };
208
+ let restated = false;
209
+ let hasIncrease = false;
210
+
211
+ for (const field of NUMERIC_REPORT_FIELDS) {
212
+ const current = normalized[field];
213
+ const priorRaw = toFiniteNumber(previous?.[field]);
214
+ const prior = priorRaw === null || priorRaw < 0 ? null : priorRaw;
215
+ if (current === null) {
216
+ // Not reported this time (cost lags behind a finished run) — keep the
217
+ // baseline so the value is picked up whole on a later report.
218
+ delta[field] = 0;
219
+ nextCheckpoint[field] = prior;
220
+ continue;
221
+ }
222
+ if (seedOnly) {
223
+ delta[field] = 0;
224
+ nextCheckpoint[field] = current;
225
+ continue;
226
+ }
227
+ const increase = prior === null ? current : current - prior;
228
+ if (increase < 0) restated = true;
229
+ delta[field] = increase > 0 ? increase : 0;
230
+ if (delta[field] > 0) hasIncrease = true;
231
+ // High-water mark: never lower the baseline, or a value that dips and then
232
+ // recovers would be counted a second time on the way back up.
233
+ nextCheckpoint[field] = increase < 0 ? prior : current;
234
+ }
235
+
236
+ return {
237
+ agentId: normalized.agentId,
238
+ model: normalized.model,
239
+ pool: classifyCursorModelPool(normalized.model),
240
+ capturedAt: normalized.capturedAt,
241
+ delta,
242
+ checkpoint: nextCheckpoint,
243
+ restated,
244
+ changed: hasIncrease,
245
+ };
246
+ }
247
+
248
+ export function emptyCursorPoolTotals() {
249
+ const totals = {};
250
+ for (const pool of CURSOR_POOLS) {
251
+ totals[pool] = {
252
+ rawCostCents: 0,
253
+ chargedCents: 0,
254
+ inputTokens: 0,
255
+ outputTokens: 0,
256
+ cacheReadTokens: 0,
257
+ cacheWriteTokens: 0,
258
+ totalTokens: 0,
259
+ runCount: 0,
260
+ };
261
+ }
262
+ return totals;
263
+ }
264
+
265
+ function formatUsd(value) {
266
+ const numeric = roundCurrency(value);
267
+ return numeric === null ? null : `$${numeric.toFixed(2)}`;
268
+ }
269
+
270
+ function formatTokens(value) {
271
+ const numeric = toFiniteNumber(value);
272
+ if (numeric === null || numeric <= 0) return null;
273
+ if (numeric < 1000) return String(Math.round(numeric));
274
+ if (numeric < 1_000_000) return `${(numeric / 1000).toFixed(1)}k`;
275
+ return `${(numeric / 1_000_000).toFixed(1)}M`;
276
+ }
277
+
278
+ function poolTotals(totals, pool) {
279
+ const entry = totals?.[pool];
280
+ if (!entry || typeof entry !== 'object') return null;
281
+ return {
282
+ rawCostUsd: centsToUsd(entry.rawCostCents) ?? 0,
283
+ chargedUsd: centsToUsd(entry.chargedCents) ?? 0,
284
+ inputTokens: toFiniteNumber(entry.inputTokens) || 0,
285
+ outputTokens: toFiniteNumber(entry.outputTokens) || 0,
286
+ cacheReadTokens: toFiniteNumber(entry.cacheReadTokens) || 0,
287
+ cacheWriteTokens: toFiniteNumber(entry.cacheWriteTokens) || 0,
288
+ totalTokens: toFiniteNumber(entry.totalTokens) || 0,
289
+ runCount: toFiniteNumber(entry.runCount) || 0,
290
+ };
291
+ }
292
+
293
+ export function buildCursorPlanCard({
294
+ totals = null,
295
+ allowances = null,
296
+ cycle = null,
297
+ capturedAt = null,
298
+ configured = true,
299
+ message = null,
300
+ // normalizeCursorDashboardUsage() — live plan quota from cursor.com's
301
+ // dashboard API (requires the user-provided session token).
302
+ dashboard = null,
303
+ // { configured, source } from the dashboard-token settings. Lets the card
304
+ // explain an empty panel instead of silently showing $0.00 estimates; null
305
+ // when the caller cannot tell.
306
+ dashboardAuth = null,
307
+ } = {}) {
308
+ if (!configured) {
309
+ return buildUnavailableCard({
310
+ provider: CURSOR_PROVIDER_ID,
311
+ label: CURSOR_LABEL,
312
+ status: STATUS_NOT_CONFIGURED,
313
+ message: 'Cursor is not enabled in provider settings.',
314
+ links: [{ label: 'Cursor Spending dashboard', url: CURSOR_SPENDING_URL }],
315
+ });
316
+ }
317
+
318
+ const settings = normalizeCursorAllowanceSettings(allowances || {});
319
+ const resolvedCycle = cycle || resolveCursorBillingCycle({ resetDay: settings.resetDay });
320
+ const resolvedTotals = totals || emptyCursorPoolTotals();
321
+
322
+ const allowanceByPool = {
323
+ [POOL_CURSOR_MODELS]: settings.cursorModelsUsd,
324
+ [POOL_OTHER_MODELS]: settings.otherModelsUsd,
325
+ [POOL_UNCLASSIFIED]: null,
326
+ };
327
+
328
+ const meters = [];
329
+ const hasLiveQuota = toFiniteNumber(dashboard?.totalPercentUsed) !== null
330
+ || toFiniteNumber(dashboard?.apiPercentUsed) !== null
331
+ || toFiniteNumber(dashboard?.autoPercentUsed) !== null;
332
+ if (hasLiveQuota) {
333
+ const resetAt = dashboard.billingCycleEnd || null;
334
+ if (toFiniteNumber(dashboard.totalPercentUsed) !== null) {
335
+ meters.push(buildMeter({
336
+ id: 'cursor-plan-total',
337
+ label: 'Plan usage (total)',
338
+ unit: 'percent',
339
+ utilization: dashboard.totalPercentUsed,
340
+ resetAt,
341
+ estimated: false,
342
+ emphasis: 'primary',
343
+ note: 'Live from the Cursor dashboard.',
344
+ }));
345
+ }
346
+ if (toFiniteNumber(dashboard.autoPercentUsed) !== null) {
347
+ meters.push(buildMeter({
348
+ id: 'cursor-plan-auto',
349
+ label: 'Auto + Composer',
350
+ unit: 'percent',
351
+ utilization: dashboard.autoPercentUsed,
352
+ resetAt,
353
+ estimated: false,
354
+ emphasis: 'secondary',
355
+ }));
356
+ }
357
+ if (toFiniteNumber(dashboard.apiPercentUsed) !== null) {
358
+ meters.push(buildMeter({
359
+ id: 'cursor-plan-api',
360
+ label: 'API',
361
+ unit: 'percent',
362
+ utilization: dashboard.apiPercentUsed,
363
+ resetAt,
364
+ estimated: false,
365
+ emphasis: 'secondary',
366
+ }));
367
+ }
368
+ }
369
+ let unclassifiedSpend = 0;
370
+ for (const pool of CURSOR_POOLS) {
371
+ const entry = poolTotals(resolvedTotals, pool);
372
+ if (!entry) continue;
373
+ const allowance = allowanceByPool[pool];
374
+ if (pool === POOL_UNCLASSIFIED) {
375
+ unclassifiedSpend = entry.rawCostUsd;
376
+ if (entry.rawCostUsd <= 0) continue;
377
+ meters.push(buildMeter({
378
+ id: 'cursor-unclassified',
379
+ label: POOL_LABELS[pool],
380
+ unit: 'usd',
381
+ used: entry.rawCostUsd,
382
+ estimated: true,
383
+ emphasis: 'secondary',
384
+ note: 'Router/auto or unrecognized models — Cursor does not disclose which pool served these.',
385
+ }));
386
+ continue;
387
+ }
388
+ // Local pool estimates step back to secondary once the dashboard supplies
389
+ // the authoritative plan percentages.
390
+ if (hasLiveQuota && entry.rawCostUsd <= 0 && allowance === null) continue;
391
+ meters.push(buildMeter({
392
+ id: `cursor-${pool}`,
393
+ label: POOL_LABELS[pool],
394
+ unit: 'usd',
395
+ used: entry.rawCostUsd,
396
+ allowance,
397
+ resetAt: resolvedCycle.endsAt,
398
+ estimated: true,
399
+ emphasis: hasLiveQuota ? 'secondary' : 'primary',
400
+ note: allowance === null && !hasLiveQuota
401
+ ? 'Set this pool’s monthly allowance in Settings to track what is left.'
402
+ : null,
403
+ }));
404
+ }
405
+
406
+ const details = [];
407
+ const perPoolRows = [];
408
+ for (const pool of CURSOR_POOLS) {
409
+ const entry = poolTotals(resolvedTotals, pool);
410
+ if (!entry || (entry.rawCostUsd <= 0 && entry.totalTokens <= 0)) continue;
411
+ perPoolRows.push({
412
+ label: POOL_LABELS[pool],
413
+ value: formatUsd(entry.rawCostUsd) ?? '$0.00',
414
+ hint: [
415
+ formatTokens(entry.totalTokens) ? `${formatTokens(entry.totalTokens)} tokens` : null,
416
+ entry.chargedUsd > 0 ? `charged ${formatUsd(entry.chargedUsd)}` : 'charged $0.00',
417
+ ].filter(Boolean).join(' · '),
418
+ });
419
+ }
420
+ const poolSection = buildDetailSection({
421
+ id: 'cursor-pools',
422
+ label: 'This cycle by pool',
423
+ // rawCost is the undiscounted model price and is what the included pools
424
+ // are measured against; chargedCents is what Cursor actually bills after
425
+ // discounts, plan inclusion and BYOK, and is often $0.
426
+ note: 'Allowance consumption uses raw model cost; “charged” is what Cursor actually bills.',
427
+ rows: perPoolRows,
428
+ });
429
+ if (poolSection) details.push(poolSection);
430
+
431
+ const combined = CURSOR_POOLS.reduce((acc, pool) => {
432
+ const entry = poolTotals(resolvedTotals, pool);
433
+ if (!entry) return acc;
434
+ acc.rawCostUsd += entry.rawCostUsd;
435
+ acc.chargedUsd += entry.chargedUsd;
436
+ acc.inputTokens += entry.inputTokens;
437
+ acc.outputTokens += entry.outputTokens;
438
+ acc.cacheReadTokens += entry.cacheReadTokens;
439
+ acc.cacheWriteTokens += entry.cacheWriteTokens;
440
+ acc.totalTokens += entry.totalTokens;
441
+ acc.runCount += entry.runCount;
442
+ return acc;
443
+ }, {
444
+ rawCostUsd: 0,
445
+ chargedUsd: 0,
446
+ inputTokens: 0,
447
+ outputTokens: 0,
448
+ cacheReadTokens: 0,
449
+ cacheWriteTokens: 0,
450
+ totalTokens: 0,
451
+ runCount: 0,
452
+ });
453
+
454
+ const totalRows = [
455
+ { label: 'Raw model cost', value: formatUsd(combined.rawCostUsd) ?? '$0.00' },
456
+ { label: 'Actually charged', value: formatUsd(combined.chargedUsd) ?? '$0.00', hint: 'Zero for plan-included, BYOK and credit-grant usage' },
457
+ ];
458
+ if (combined.runCount > 0) totalRows.push({ label: 'Runs', value: String(Math.round(combined.runCount)) });
459
+ for (const [label, value] of [
460
+ ['Input tokens', combined.inputTokens],
461
+ ['Output tokens', combined.outputTokens],
462
+ ['Cache read', combined.cacheReadTokens],
463
+ ['Cache write', combined.cacheWriteTokens],
464
+ ]) {
465
+ const formatted = formatTokens(value);
466
+ if (formatted) totalRows.push({ label, value: formatted });
467
+ }
468
+ const totalsSection = buildDetailSection({
469
+ id: 'cursor-totals',
470
+ label: 'This cycle in total',
471
+ rows: totalRows,
472
+ });
473
+ if (totalsSection) details.push(totalsSection);
474
+
475
+ if (hasLiveQuota) {
476
+ const planRows = [];
477
+ const includedSpend = toFiniteNumber(dashboard.includedSpendCents);
478
+ const limit = toFiniteNumber(dashboard.limitCents);
479
+ if (includedSpend !== null) {
480
+ planRows.push({
481
+ label: 'Included spend',
482
+ value: formatUsd(includedSpend / 100) ?? '$0.00',
483
+ hint: limit !== null ? `of ${formatUsd(limit / 100)} included` : null,
484
+ });
485
+ }
486
+ const onDemandUsed = toFiniteNumber(dashboard.onDemand?.usedCents);
487
+ if (onDemandUsed !== null) {
488
+ const onDemandLimit = toFiniteNumber(dashboard.onDemand?.limitCents);
489
+ planRows.push({
490
+ label: 'On-demand spend',
491
+ value: formatUsd(onDemandUsed / 100) ?? '$0.00',
492
+ hint: onDemandLimit !== null ? `cap ${formatUsd(onDemandLimit / 100)}` : null,
493
+ });
494
+ }
495
+ if (dashboard.displayMessage) {
496
+ planRows.push({ label: 'Cursor says', value: dashboard.displayMessage });
497
+ }
498
+ const planSection = buildDetailSection({
499
+ id: 'cursor-plan',
500
+ label: 'Plan (live)',
501
+ rows: planRows,
502
+ });
503
+ if (planSection) details.push(planSection);
504
+ }
505
+
506
+ const cycleStartsAt = (hasLiveQuota && dashboard.billingCycleStart) || resolvedCycle.startsAt;
507
+ const cycleEndsAt = (hasLiveQuota && dashboard.billingCycleEnd) || resolvedCycle.endsAt;
508
+ const cycleSection = buildDetailSection({
509
+ id: 'cursor-cycle',
510
+ label: 'Billing cycle',
511
+ rows: [
512
+ { label: 'Cycle started', value: cycleStartsAt.slice(0, 10) },
513
+ {
514
+ label: 'Resets',
515
+ value: cycleEndsAt.slice(0, 10),
516
+ hint: hasLiveQuota && dashboard.billingCycleEnd
517
+ ? 'From the Cursor dashboard'
518
+ : `Day ${resolvedCycle.resetDay} of each month`,
519
+ },
520
+ ],
521
+ });
522
+ if (cycleSection) details.push(cycleSection);
523
+
524
+ const notes = [];
525
+ // Cursor's API key exposes no plan surface, so without a dashboard token the
526
+ // card can only show locally reconstructed spend — which is $0.00 on a relay
527
+ // that has not run a Cursor turn yet, and reads as "broken" unless we say so.
528
+ if (!hasLiveQuota && dashboardAuth) {
529
+ const REJECTED_BY_SOURCE = {
530
+ ide: "Live plan bars unavailable — Cursor did not accept the session token from this machine's Cursor IDE login. Sign in to Cursor again in the IDE, or paste a token in Settings.",
531
+ env: 'Live plan bars unavailable — Cursor did not accept the CURSOR_SESSION_TOKEN from the relay environment. It has most likely expired; set a fresh value.',
532
+ manual: 'Live plan bars unavailable — Cursor did not accept the stored dashboard token. It has most likely expired; save a fresh one in Settings.',
533
+ };
534
+ notes.push(dashboardAuth.configured === true
535
+ ? (REJECTED_BY_SOURCE[dashboardAuth.source] || REJECTED_BY_SOURCE.manual)
536
+ : 'Live plan bars need a Cursor dashboard token. This host has no Cursor IDE login to read one from — paste the WorkosCursorSessionToken cookie in Settings, or set CURSOR_SESSION_TOKEN on the relay.');
537
+ }
538
+ if (!hasLiveQuota && settings.cursorModelsUsd === null && settings.otherModelsUsd === null) {
539
+ notes.push('No monthly allowance configured — showing spend only.');
540
+ }
541
+ if (unclassifiedSpend > 0) {
542
+ notes.push('Some spend could not be attributed to a pool.');
543
+ }
544
+ const explicitMessage = toTrimmedString(message);
545
+
546
+ const planName = hasLiveQuota
547
+ ? (dashboard.membershipType
548
+ ? `Cursor ${dashboard.membershipType}`
549
+ : 'Cursor plan')
550
+ // Everything else on this card is locally reconstructed, so the plan name
551
+ // says so rather than implying Cursor reported a tier.
552
+ : 'Manual allowance';
553
+
554
+ return buildProviderCard({
555
+ provider: CURSOR_PROVIDER_ID,
556
+ label: CURSOR_LABEL,
557
+ status: meters.filter(Boolean).length ? STATUS_OK : STATUS_PARTIAL,
558
+ planName,
559
+ source: hasLiveQuota ? SOURCE_LIVE : SOURCE_MANUAL,
560
+ capturedAt,
561
+ message: [explicitMessage, ...notes].filter(Boolean).join(' ') || null,
562
+ meters: meters.filter(Boolean),
563
+ details,
564
+ links: [{ label: 'Cursor Spending dashboard', url: CURSOR_SPENDING_URL }],
565
+ });
566
+ }