@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,20 @@
1
+ /**
2
+ * What a worker publishes when a turn ends terminally, without error, and
3
+ * without any assistant prose.
4
+ *
5
+ * That is a *completed* turn, not a failed delivery: the model can legitimately
6
+ * end a turn on tool activity alone ("do nothing else than spawn a subagent
7
+ * that returns OK" — conv `1e497a75`, where the Cursor SDK recorded
8
+ * `status: FINISHED, result: null`). Requeuing such a turn re-runs work that
9
+ * deterministically produces the same empty text, so every attempt re-bills the
10
+ * provider and re-spawns its subagents until the queue's retry cap fails the
11
+ * message with a "Relay timeout" that misreports a turn which actually
12
+ * succeeded.
13
+ *
14
+ * A note rather than an empty string because `/api/response` rejects an empty
15
+ * body (`shouldAcceptAssistantResponsePayload`), so publishing '' would leave
16
+ * the queue row stuck in `processing` — swapping one stall for another. Kept
17
+ * deliberately free of inference about *why* the model stayed silent; the
18
+ * activity and subagent bubbles already show what ran.
19
+ */
20
+ export const EMPTY_TURN_COMPLETION_NOTE = 'System note: the turn completed without a text reply.';
@@ -0,0 +1,11 @@
1
+ export function latestModelCatalogRefresh(...timestamps) {
2
+ let latestMs = Number.NEGATIVE_INFINITY;
3
+ let latestValue = null;
4
+ for (const timestamp of timestamps) {
5
+ const parsed = Date.parse(timestamp || '');
6
+ if (!Number.isFinite(parsed) || parsed <= latestMs) continue;
7
+ latestMs = parsed;
8
+ latestValue = new Date(parsed).toISOString();
9
+ }
10
+ return latestValue;
11
+ }
@@ -0,0 +1,118 @@
1
+ import { isValidModelId, normalizeModelIdCandidate } from './model-id.mjs';
2
+
3
+ // Keys that hold lists of sibling models in provider responses. The per-model
4
+ // fallback search must not descend into them, or a model with no context limit
5
+ // of its own would inherit a sibling's.
6
+ const MODEL_CONTAINER_KEYS = ['data', 'models', 'list', 'available', 'items', 'entries', 'result', 'response'];
7
+
8
+ export function normalizeContextLimitTokens(value) {
9
+ const numeric = Number(value);
10
+ if (!Number.isFinite(numeric) || numeric <= 0) return null;
11
+ return Math.round(numeric);
12
+ }
13
+
14
+ function findContextLimitTokens(value) {
15
+ if (!value || typeof value !== 'object') return null;
16
+ const candidates = [
17
+ value.contextLimitTokens,
18
+ value.maxContextTokens,
19
+ value.contextWindow,
20
+ value.max_context_tokens,
21
+ value.tokenBudget,
22
+ ];
23
+ for (const candidate of candidates) {
24
+ const normalized = normalizeContextLimitTokens(candidate);
25
+ if (normalized !== null) return normalized;
26
+ }
27
+ for (const [key, nested] of Object.entries(value)) {
28
+ if (!nested || typeof nested !== 'object') continue;
29
+ if (MODEL_CONTAINER_KEYS.includes(key)) continue;
30
+ const normalized = findContextLimitTokens(nested);
31
+ if (normalized !== null) return normalized;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function findPromptBudgetTokens(value) {
37
+ if (!value || typeof value !== 'object') return null;
38
+ const candidates = [
39
+ value.maxPromptTokens,
40
+ value.max_prompt_tokens,
41
+ value.contextMax,
42
+ ];
43
+ for (const candidate of candidates) {
44
+ const normalized = normalizeContextLimitTokens(candidate);
45
+ if (normalized !== null) return normalized;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function normalizePrice(value) {
51
+ const numeric = Number(value);
52
+ return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
53
+ }
54
+
55
+ function normalizePricing(value, fallbackBatchSize = null) {
56
+ if (!value || typeof value !== 'object') return null;
57
+ const batchSize = normalizeContextLimitTokens(value.batchSize) ?? normalizeContextLimitTokens(fallbackBatchSize);
58
+ const rates = {
59
+ input: normalizePrice(value.inputPrice),
60
+ output: normalizePrice(value.outputPrice),
61
+ cacheRead: normalizePrice(value.cacheReadPrice ?? value.cachePrice),
62
+ cacheWrite: normalizePrice(value.cacheWritePrice),
63
+ };
64
+ // batchSize alone is a denominator, not evidence of real pricing.
65
+ if (!Object.values(rates).some((entry) => entry !== null)) return null;
66
+ return { ...rates, batchSize };
67
+ }
68
+
69
+ function contextLimitForTier(value, tier = 'default') {
70
+ const limits = value?.capabilities?.limits || value?.limits || {};
71
+ const outputTokens = normalizeContextLimitTokens(
72
+ limits.max_output_tokens ?? limits.maxOutputTokens ?? value?.maxOutputTokens,
73
+ ) || 0;
74
+ const tokenPrices = value?.billing?.tokenPrices || value?.tokenPrices || null;
75
+ if (tier === 'long_context') {
76
+ const longContext = tokenPrices?.longContext
77
+ || value?.longContext;
78
+ const promptTokens = findPromptBudgetTokens(longContext);
79
+ return promptTokens === null ? null : promptTokens + outputTokens;
80
+ }
81
+ const defaultPromptTokens = findPromptBudgetTokens(tokenPrices)
82
+ ?? normalizeContextLimitTokens(limits.max_prompt_tokens ?? limits.maxPromptTokens);
83
+ if (defaultPromptTokens !== null) return defaultPromptTokens + outputTokens;
84
+ return findContextLimitTokens(value);
85
+ }
86
+
87
+ export function extractModelDescriptors(value, out = []) {
88
+ if (!value) return out;
89
+ if (typeof value === 'string') {
90
+ const modelId = normalizeModelIdCandidate(value);
91
+ if (isValidModelId(modelId)) out.push({ modelId, contextLimitTokens: null });
92
+ return out;
93
+ }
94
+ if (Array.isArray(value)) {
95
+ for (const item of value) extractModelDescriptors(item, out);
96
+ return out;
97
+ }
98
+ if (typeof value !== 'object') return out;
99
+
100
+ const modelId = normalizeModelIdCandidate(value.modelId || value.id || value.model || null);
101
+ if (isValidModelId(modelId)) {
102
+ const tokenPrices = value?.billing?.tokenPrices || value?.tokenPrices || null;
103
+ out.push({
104
+ modelId,
105
+ contextLimitTokens: contextLimitForTier(value),
106
+ longContextLimitTokens: contextLimitForTier(value, 'long_context'),
107
+ pricing: {
108
+ default: normalizePricing(tokenPrices),
109
+ longContext: normalizePricing(tokenPrices?.longContext, tokenPrices?.batchSize),
110
+ },
111
+ });
112
+ }
113
+ for (const key of MODEL_CONTAINER_KEYS) {
114
+ const nested = value[key];
115
+ if (nested !== undefined && nested !== null) extractModelDescriptors(nested, out);
116
+ }
117
+ return out;
118
+ }
@@ -0,0 +1,147 @@
1
+ const MODEL_ID_PREFIXES = [
2
+ 'gpt-',
3
+ 'chatgpt-',
4
+ 'claude-',
5
+ 'gemini-',
6
+ 'mai-',
7
+ 'o1-',
8
+ 'o3-',
9
+ 'o4-',
10
+ 'codex-',
11
+ 'openai/',
12
+ 'anthropic/',
13
+ 'google/',
14
+ 'microsoft/',
15
+ ];
16
+ const PROVIDER_PREFIXES = ['openai/', 'anthropic/', 'google/', 'microsoft/'];
17
+ const BASE_MODEL_PREFIXES = MODEL_ID_PREFIXES.filter((prefix) => !prefix.includes('/'));
18
+
19
+ const MODEL_ID_TOKEN_PATTERN = /^[a-z0-9](?:[a-z0-9._/-]{0,118}[a-z0-9])$/i;
20
+
21
+ const MODEL_ID_DENY_SUBSTRINGS = [
22
+ 'requires enablement',
23
+ 'enable this model',
24
+ 'pick a different one',
25
+ 'accept',
26
+ 'settings',
27
+ 'policy',
28
+ 'missing required authentication',
29
+ 'missing-required-authentication',
30
+ 'not authorized',
31
+ 'not-authorized',
32
+ 'http://',
33
+ 'https://',
34
+ 'github.com/settings',
35
+ ];
36
+
37
+ function isLikelyModelIdPrefix(value) {
38
+ const lower = String(value || '').toLowerCase();
39
+ if (lower === 'o1' || lower === 'o3') return true;
40
+ return MODEL_ID_PREFIXES.some((prefix) => lower.startsWith(prefix));
41
+ }
42
+
43
+ export function normalizeModelIdCandidate(value) {
44
+ return String(value || '').trim();
45
+ }
46
+
47
+ export function isSafeProviderModelId(value) {
48
+ const candidate = normalizeModelIdCandidate(value);
49
+ return candidate.length <= 120 && MODEL_ID_TOKEN_PATTERN.test(candidate);
50
+ }
51
+
52
+ // Claude Agent SDK model ids may carry a bracketed capability suffix
53
+ // (e.g. "claude-opus-5[1m]" for the 1M-context variant).
54
+ export function isSafeClaudeModelId(value) {
55
+ const candidate = normalizeModelIdCandidate(value);
56
+ if (candidate.length > 120) return false;
57
+ const base = candidate.replace(/\[[a-z0-9-]{1,12}\]$/i, '');
58
+ return MODEL_ID_TOKEN_PATTERN.test(base);
59
+ }
60
+
61
+ // Cursor model ids ("composer-2.5", "gpt-5.5", "auto-smart") carry no fixed
62
+ // prefix; the safe-chars token check is the whole validation today. Kept as a
63
+ // named seam so cursor-specific rules can diverge without touching callers.
64
+ export function isSafeCursorModelId(value) {
65
+ return isSafeProviderModelId(value);
66
+ }
67
+
68
+ // Grok / xAI model ids ("grok-4.5", "grok-code-fast-1") use the same safe-token
69
+ // rules as other unprefixed agent providers.
70
+ export function isSafeGrokModelId(value) {
71
+ return isSafeProviderModelId(value);
72
+ }
73
+
74
+ // The "[1m]" suffix marks the 1M-context variant of a Claude model. The UI
75
+ // treats it as a context tier of the base model, not a separate model.
76
+ export const CLAUDE_LONG_CONTEXT_SUFFIX = '[1m]';
77
+ export const CLAUDE_LONG_CONTEXT_SUFFIX_PATTERN = /\[1m\]$/i;
78
+ export const CLAUDE_LONG_CONTEXT_LIMIT_TOKENS = 1_000_000;
79
+ export const CLAUDE_DEFAULT_CONTEXT_LIMIT_TOKENS = 200_000;
80
+
81
+ export function isClaudeLongContextModelId(value) {
82
+ return CLAUDE_LONG_CONTEXT_SUFFIX_PATTERN.test(normalizeModelIdCandidate(value));
83
+ }
84
+
85
+ export function claudeBaseModelId(value) {
86
+ return normalizeModelIdCandidate(value).replace(CLAUDE_LONG_CONTEXT_SUFFIX_PATTERN, '');
87
+ }
88
+
89
+ export function claudeLongContextModelId(value) {
90
+ const base = claudeBaseModelId(value);
91
+ return base ? `${base}${CLAUDE_LONG_CONTEXT_SUFFIX}` : '';
92
+ }
93
+
94
+ export function isOpenAIModelId(value) {
95
+ const candidate = normalizeModelIdCandidate(value).toLowerCase();
96
+ if (candidate === 'o1' || candidate === 'o3') return true;
97
+ return [
98
+ 'gpt-',
99
+ 'chatgpt-',
100
+ 'o1-',
101
+ 'o3-',
102
+ 'o4-',
103
+ 'codex-',
104
+ 'openai/',
105
+ ].some((prefix) => candidate.startsWith(prefix));
106
+ }
107
+
108
+ export function canonicalizeModelId(value) {
109
+ const candidate = normalizeModelIdCandidate(value).toLowerCase();
110
+ if (!candidate) return '';
111
+ for (const providerPrefix of PROVIDER_PREFIXES) {
112
+ if (!candidate.startsWith(providerPrefix)) continue;
113
+ const stripped = candidate.slice(providerPrefix.length);
114
+ // Provider prefixes are redundant for known base model families,
115
+ // including the bare "o1"/"o3" ids that carry no dash suffix.
116
+ if (stripped === 'o1' || stripped === 'o3') return stripped;
117
+ if (BASE_MODEL_PREFIXES.some((prefix) => stripped.startsWith(prefix))) {
118
+ return stripped;
119
+ }
120
+ }
121
+ return candidate;
122
+ }
123
+
124
+ export function isValidModelId(value) {
125
+ const candidate = normalizeModelIdCandidate(value);
126
+ if (!candidate) return false;
127
+ if (candidate.length > 120) return false;
128
+ if (!isSafeProviderModelId(candidate)) return false;
129
+ if (!isLikelyModelIdPrefix(candidate)) return false;
130
+ const lower = candidate.toLowerCase();
131
+ if (MODEL_ID_DENY_SUBSTRINGS.some((token) => lower.includes(token))) return false;
132
+ return true;
133
+ }
134
+
135
+ export function filterValidModelIds(values = []) {
136
+ const out = [];
137
+ const seen = new Set();
138
+ for (const value of values || []) {
139
+ const candidate = normalizeModelIdCandidate(value);
140
+ if (!isValidModelId(candidate)) continue;
141
+ const canonical = canonicalizeModelId(candidate);
142
+ if (!canonical || seen.has(canonical)) continue;
143
+ seen.add(canonical);
144
+ out.push(canonical);
145
+ }
146
+ return out;
147
+ }
@@ -0,0 +1,24 @@
1
+ import { filterValidModelIds, normalizeModelIdCandidate } from './model-id.mjs';
2
+
3
+ export function selectModelIdsForVariantRefresh({
4
+ snapshotModels = [],
5
+ currentModel = '',
6
+ defaultModel = '',
7
+ helpModelIds = [],
8
+ } = {}) {
9
+ const fromSnapshot = filterValidModelIds([
10
+ ...(Array.isArray(snapshotModels) ? snapshotModels : []),
11
+ normalizeModelIdCandidate(currentModel),
12
+ normalizeModelIdCandidate(defaultModel),
13
+ ]);
14
+ if (fromSnapshot.length) {
15
+ return {
16
+ source: 'rpc-snapshot',
17
+ modelIds: fromSnapshot,
18
+ };
19
+ }
20
+ return {
21
+ source: 'copilot-help-manual-refresh',
22
+ modelIds: filterValidModelIds(Array.isArray(helpModelIds) ? helpModelIds : []),
23
+ };
24
+ }
@@ -0,0 +1,13 @@
1
+ export function openAIReasoningEffortsForModel(model = '') {
2
+ const normalized = String(model || '').trim().toLowerCase().replace(/^openai\//, '');
3
+ if (/^(?:gpt-image|dall-e)(?:[.-]|$)/.test(normalized)) {
4
+ return ['auto', 'low', 'medium', 'high'];
5
+ }
6
+ if (/^gpt-5(?:[.-]|$)/.test(normalized) || /^codex(?:[.-]|$)/.test(normalized)) {
7
+ return ['none', 'low', 'medium', 'high', 'xhigh'];
8
+ }
9
+ if (/^(?:o1|o3|o4)(?:[.-]|$)/.test(normalized)) {
10
+ return ['low', 'medium', 'high'];
11
+ }
12
+ return ['none'];
13
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Count bullet or numbered lines in a reply. Provider workers use this as the
3
+ * plan-mode fallback heuristic: a plan-mode turn whose final text contains at
4
+ * least two plan-like lines gets a `plan_ready` board even when the runtime
5
+ * never signalled plan completion explicitly.
6
+ */
7
+ export function countPlanLikeLines(text) {
8
+ return String(text || '')
9
+ .split(/\r?\n/)
10
+ .map((line) => line.trim())
11
+ .filter((line) => /^([-*]\s+|\d+\.\s+)/.test(line))
12
+ .length;
13
+ }
@@ -0,0 +1,77 @@
1
+ // Preview guidance for providers without a custom-tool surface (Copilot CLI,
2
+ // Grok). The wording lives in preview-tool-core.mjs; this module only decides
3
+ // whether a relay should advertise the capability at all, and where the
4
+ // generated block lands inside an existing guidance document.
5
+
6
+ import { renderPreviewInstructionBlock } from './preview-tool-core.mjs';
7
+
8
+ // Derived rather than restated, so a heading change in the generated block
9
+ // still matches the section it is meant to replace.
10
+ export const PREVIEW_INSTRUCTION_HEADING = String(
11
+ renderPreviewInstructionBlock({}).split('\n')[0] || '',
12
+ ).trim();
13
+
14
+ const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000;
15
+
16
+ /**
17
+ * Resolves the instruction block for this relay, or '' when the preview lane
18
+ * is off. Preview settings only change on relay restart, so the answer is
19
+ * cached: this must not become a per-turn HTTP call.
20
+ */
21
+ export function createPreviewInstructionsProvider({
22
+ api,
23
+ ttlMs = DEFAULT_TTL_MS,
24
+ now = () => Date.now(),
25
+ } = {}) {
26
+ let cachedText = '';
27
+ let cachedUntil = 0;
28
+ let inFlight = null;
29
+
30
+ async function load() {
31
+ const response = await api('GET', '/api/previews');
32
+ const text = response?.enabled === true
33
+ ? renderPreviewInstructionBlock({ publicBaseUrl: response?.publicBaseUrl || '' })
34
+ : '';
35
+ cachedText = text;
36
+ cachedUntil = now() + ttlMs;
37
+ return text;
38
+ }
39
+
40
+ return async function getPreviewInstructions() {
41
+ if (typeof api !== 'function') return '';
42
+ if (cachedUntil > now()) return cachedText;
43
+ if (!inFlight) {
44
+ // A failed lookup is deliberately not cached, and yields no block: an
45
+ // unreachable relay must neither lose the capability for the rest of the
46
+ // process nor advertise one that would 503.
47
+ inFlight = load()
48
+ .catch(() => '')
49
+ .finally(() => { inFlight = null; });
50
+ }
51
+ return inFlight;
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Swap a guidance document's preview section for the generated block, or drop
57
+ * the section when there is no block. Other sections are untouched.
58
+ */
59
+ export function applyPreviewInstructions(baseInstructions = '', previewBlock = '') {
60
+ const base = String(baseInstructions || '');
61
+ const block = String(previewBlock || '').trim();
62
+ const lines = base.split(/\r?\n/);
63
+ const start = lines.findIndex((line) => line.trim() === PREVIEW_INSTRUCTION_HEADING);
64
+ if (start === -1) {
65
+ return [base.trimEnd(), block].filter(Boolean).join('\n\n');
66
+ }
67
+ let end = lines.length;
68
+ for (let index = start + 1; index < lines.length; index += 1) {
69
+ if (/^##\s/.test(lines[index])) {
70
+ end = index;
71
+ break;
72
+ }
73
+ }
74
+ const before = lines.slice(0, start).join('\n').trim();
75
+ const after = lines.slice(end).join('\n').trim();
76
+ return [before, block, after].filter(Boolean).join('\n\n');
77
+ }
@@ -0,0 +1,191 @@
1
+ // The provider-facing definition of the preview capability, in one place.
2
+ //
3
+ // Two consumption modes, one source of truth:
4
+ // - Providers with a custom-tool surface (Claude via SDK MCP server, Cursor via
5
+ // customTools) register `{ name, description, inputSchema, execute }` built
6
+ // from these exports.
7
+ // - Providers without one (Copilot CLI, Grok) get renderPreviewInstructionBlock(),
8
+ // which teaches the identical HTTP API and is generated from the same strings —
9
+ // the tool and the instructions cannot drift apart.
10
+ //
11
+ // SDK-free and dependency-injected, following ask-user-bridge.mjs: all relay
12
+ // traffic goes through the worker's injected `api(method, path, body)` helper.
13
+
14
+ export const PREVIEW_TOOL_NAME = 'preview';
15
+
16
+ export const PREVIEW_TOOL_DESCRIPTION =
17
+ 'Publish a local web server or a static directory on a public preview URL so '
18
+ + 'the user can open it from any device, or list/close existing previews. Use '
19
+ + 'this whenever the user wants to see, try, or share something with a web UI '
20
+ + '— never ask them to open localhost or forward a port. '
21
+ + 'For a dev server: call this FIRST with {action:"create", port} to get the '
22
+ + 'basePath, then start the server configured to serve under that basePath '
23
+ + '(Vite --base, Next basePath, Express mount prefix); the proxy forwards '
24
+ + 'X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset '
25
+ + 'paths will not work. For plain files or a build output: pass {action:'
26
+ + '"create", dir} instead and the relay serves the directory itself — no dev '
27
+ + 'server needed. Always tell the user the returned URL and that the link is '
28
+ + 'public: anyone who has it can reach the app without logging in. Previews '
29
+ + 'never expire on their own; close them with {action:"close"} when the user '
30
+ + 'is done.';
31
+
32
+ export const PREVIEW_TOOL_INPUT_SCHEMA = {
33
+ type: 'object',
34
+ properties: {
35
+ action: {
36
+ type: 'string',
37
+ enum: ['create', 'list', 'close'],
38
+ description: 'create: publish a port or directory. list: live previews. close: unpublish one.',
39
+ },
40
+ port: {
41
+ type: 'integer',
42
+ minimum: 1024,
43
+ maximum: 65535,
44
+ description: 'create only: local port an already-listening dev server is on. Mutually exclusive with dir.',
45
+ },
46
+ dir: {
47
+ type: 'string',
48
+ description: 'create only: directory to serve statically (absolute, or relative to the workspace root). Mutually exclusive with port.',
49
+ },
50
+ label: {
51
+ type: 'string',
52
+ maxLength: 120,
53
+ description: 'create only: short human label shown on the preview card, e.g. "web app (vite)".',
54
+ },
55
+ token: {
56
+ type: 'string',
57
+ pattern: '^[0-9a-f]{32}$',
58
+ description: 'close only: which preview. May be omitted when exactly one preview is live in this conversation.',
59
+ },
60
+ },
61
+ required: ['action'],
62
+ };
63
+
64
+ function toText(value) {
65
+ return String(value ?? '').trim();
66
+ }
67
+
68
+ /**
69
+ * Validates and normalizes tool input. This is a courtesy layer so the model
70
+ * gets a crisp message instead of an HTTP 400 — the API performs the same
71
+ * checks authoritatively.
72
+ */
73
+ export function validatePreviewToolInput(input = {}) {
74
+ const action = toText(input?.action).toLowerCase();
75
+ if (!['create', 'list', 'close'].includes(action)) {
76
+ return { ok: false, error: 'action must be one of: create, list, close' };
77
+ }
78
+ if (action === 'create') {
79
+ const hasPort = input?.port !== undefined && input?.port !== null && toText(input?.port) !== '';
80
+ const dir = toText(input?.dir);
81
+ if (hasPort && dir) {
82
+ return { ok: false, error: 'Pass either port (proxy a running server) or dir (serve files statically), not both' };
83
+ }
84
+ if (!hasPort && !dir) {
85
+ return { ok: false, error: 'create needs a port (running dev server) or a dir (static files)' };
86
+ }
87
+ if (hasPort) {
88
+ const port = Number(input.port);
89
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) {
90
+ return { ok: false, error: 'port must be an integer between 1024 and 65535' };
91
+ }
92
+ return { ok: true, action, port, label: toText(input?.label).slice(0, 120) };
93
+ }
94
+ return { ok: true, action, dir, label: toText(input?.label).slice(0, 120) };
95
+ }
96
+ if (action === 'close') {
97
+ const token = toText(input?.token).toLowerCase();
98
+ if (token && !/^[0-9a-f]{32}$/.test(token)) {
99
+ return { ok: false, error: 'token must be the 32-hex preview token from create/list' };
100
+ }
101
+ return { ok: true, action, token: token || null };
102
+ }
103
+ return { ok: true, action };
104
+ }
105
+
106
+ /**
107
+ * Executes a validated call against the relay API. Returns a compact object the
108
+ * model can quote directly; errors come back as `{ ok:false, error }` rather
109
+ * than throws, so a refused create reads as an answer, not a tool crash.
110
+ */
111
+ export async function executePreviewTool(input, { api, conversationId = '' } = {}) {
112
+ if (typeof api !== 'function') return { ok: false, error: 'Preview API unavailable in this worker' };
113
+ const parsed = validatePreviewToolInput(input);
114
+ if (!parsed.ok) return parsed;
115
+
116
+ try {
117
+ if (parsed.action === 'list') {
118
+ const response = await api('GET', '/api/previews');
119
+ return {
120
+ ok: true,
121
+ enabled: response?.enabled === true,
122
+ previews: (response?.previews || []).map((entry) => ({
123
+ token: entry.token,
124
+ label: entry.label,
125
+ url: entry.url,
126
+ target: entry.mode === 'static' ? entry.rootDir : `localhost:${entry.targetPort}`,
127
+ online: entry.online,
128
+ conversationId: entry.conversationId,
129
+ })),
130
+ };
131
+ }
132
+
133
+ if (parsed.action === 'close') {
134
+ let token = parsed.token;
135
+ if (!token) {
136
+ const response = await api('GET', `/api/previews?conversationId=${encodeURIComponent(conversationId)}`);
137
+ const live = response?.previews || [];
138
+ if (live.length === 0) return { ok: false, error: 'No live previews in this conversation' };
139
+ if (live.length > 1) {
140
+ return {
141
+ ok: false,
142
+ error: 'Multiple previews are live; pass the token of the one to close',
143
+ previews: live.map((entry) => ({ token: entry.token, label: entry.label })),
144
+ };
145
+ }
146
+ token = live[0].token;
147
+ }
148
+ const response = await api('DELETE', `/api/previews/${token}`);
149
+ return { ok: true, closed: { token, label: response?.preview?.label || null } };
150
+ }
151
+
152
+ const body = {
153
+ conversationId,
154
+ label: parsed.label || undefined,
155
+ ...(parsed.port ? { port: parsed.port } : { dir: parsed.dir }),
156
+ };
157
+ const response = await api('POST', '/api/previews', body);
158
+ return {
159
+ ok: true,
160
+ url: response?.url,
161
+ basePath: response?.basePath,
162
+ token: response?.preview?.token,
163
+ mode: response?.preview?.mode || (parsed.port ? 'port' : 'static'),
164
+ hint: parsed.port
165
+ ? `Serve the app under ${response?.basePath} (the proxy strips the prefix and sends X-Forwarded-Prefix). Tell the user the URL and that the link is public.`
166
+ : 'The directory is served as-is; relative asset paths work, root-absolute ones do not. Tell the user the URL and that the link is public.',
167
+ };
168
+ } catch (error) {
169
+ return { ok: false, error: String(error?.message || error) };
170
+ }
171
+ }
172
+
173
+ /**
174
+ * The instruction block for providers without a custom-tool surface. Generated
175
+ * from the same description/schema strings the tool uses; keep any wording
176
+ * change up in the constants, never here.
177
+ */
178
+ export function renderPreviewInstructionBlock({ publicBaseUrl = '' } = {}) {
179
+ const base = toText(publicBaseUrl) || 'https://<preview-host>';
180
+ return `## Preview servers
181
+
182
+ ${PREVIEW_TOOL_DESCRIPTION}
183
+
184
+ There is no \`${PREVIEW_TOOL_NAME}\` tool on this provider — use the authenticated localhost API (same auth as /api/relay/shutdown):
185
+
186
+ - Publish a running dev server: \`POST /api/previews\` with \`{ "conversationId": "<conv>", "port": 5173, "label": "web app" }\` → \`{ "url": "${base}/test_<token>/", "basePath": "/test_<token>/" }\`. Register FIRST, then start the server under the returned basePath.
187
+ - Publish files without a server: \`POST /api/previews\` with \`{ "conversationId": "<conv>", "dir": "./dist", "label": "built site" }\` — the relay serves the directory itself. \`dir\` must live inside the conversation's workspace root.
188
+ - List: \`GET /api/previews\` (\`?conversationId=\` filters). Close: \`DELETE /api/previews/:token\` — closes the link only, never the dev server behind it.
189
+
190
+ A 503 with \`details\` means the preview lane is disabled or misconfigured — surface those details to the user. A 400 names exactly what was refused (relay-owned port, non-loopback host, dir outside the workspace root).`;
191
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The single source of truth for which providers execute where.
3
+ *
4
+ * The legacy Copilot relay CLI (server/relay.mjs) may only execute turns for
5
+ * these providers; everything else runs on a dedicated session worker. This
6
+ * used to be encoded twice in inverse shapes — an allowlist in relay.mjs and a
7
+ * NOT IN (...) denylist inside message-repository's SQL — so adding a provider
8
+ * meant editing both and missing one silently reopened cross-provider
9
+ * execution. relay.mjs is a standalone CLI that must not import the express
10
+ * routes, which is exactly why this lives in shared/.
11
+ */
12
+ export const LEGACY_RELAY_PROVIDER_TYPES = Object.freeze(['github', 'openai']);
13
+
14
+ export const SESSION_WORKER_PROVIDER_TYPES = Object.freeze(['claude', 'cursor', 'grok']);
15
+
16
+ export function isLegacyRelayProviderType(value) {
17
+ const normalized = String(value || '').trim().toLowerCase();
18
+ if (!normalized) return true; // no runtime session = github
19
+ return LEGACY_RELAY_PROVIDER_TYPES.includes(normalized);
20
+ }
21
+
22
+ export function isSessionWorkerProviderType(value) {
23
+ return SESSION_WORKER_PROVIDER_TYPES.includes(String(value || '').trim().toLowerCase());
24
+ }
25
+
26
+ /** `'claude', 'cursor', 'grok'` — for interpolation into SQL NOT IN (...) lists. */
27
+ export function sessionWorkerProviderSqlList() {
28
+ return SESSION_WORKER_PROVIDER_TYPES.map((provider) => `'${provider}'`).join(', ');
29
+ }