@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,320 @@
1
+ 'use strict';
2
+
3
+ import { createShareVisibilityStatements } from './share-visibility-statements.mjs';
4
+
5
+ export function createSessionRepository(db) {
6
+ const shareVisibility = createShareVisibilityStatements(db);
7
+ const runtimeSessionColumns = new Set(
8
+ db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((column) => String(column?.name || '').trim()),
9
+ );
10
+ const runtimeSessionsSupportProviders = runtimeSessionColumns.has('provider_type')
11
+ && runtimeSessionColumns.has('provider_model');
12
+ const runtimeProviderSelect = runtimeSessionsSupportProviders
13
+ ? 'rs.model AS runtime_model, rs.provider_type AS runtime_provider_type, rs.provider_model AS runtime_provider_model'
14
+ : 'rs.model AS runtime_model, NULL AS runtime_provider_type, NULL AS runtime_provider_model';
15
+ return {
16
+ // conversations
17
+ getConv: db.prepare(`SELECT * FROM conversations WHERE id = ? AND status != 'deleted'`),
18
+ getConvAnyStatus: db.prepare(`SELECT * FROM conversations WHERE id = ?`),
19
+ getConvBySdkSessionId: db.prepare(`SELECT * FROM conversations WHERE sdk_session_id = ? AND status != 'deleted' ORDER BY updated_at DESC LIMIT 1`),
20
+ listConvIdsMissingRuntimeSession: db.prepare(`SELECT c.id AS id FROM conversations c LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE rs.id IS NULL AND c.status != 'deleted'`),
21
+ runtimeSessionsSupportProviders,
22
+ listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = 0) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
23
+ insertConv: db.prepare(`INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`),
24
+ updateConvTime: db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`),
25
+ updateConvTitle: db.prepare(`UPDATE conversations SET title = ?, title_source = 'manual' WHERE id = ?`),
26
+ updateConvPreferences: db.prepare(`UPDATE conversations SET preferred_relay_mode = ?, preferred_model = ?, preferred_reasoning_effort = ?, updated_at = ? WHERE id = ?`),
27
+ updateConvConfiguredWorkspaceRoot: db.prepare(`UPDATE conversations SET configured_workspace_root_path = ?, updated_at = ? WHERE id = ?`),
28
+ updateConvRuntimeWorkspaceRoot: db.prepare(`UPDATE conversations SET runtime_workspace_root_path = ?, updated_at = ? WHERE id = ?`),
29
+ seedConvConfiguredWorkspaceRootIfMissing: db.prepare(`UPDATE conversations SET configured_workspace_root_path = ?, updated_at = ? WHERE id = ? AND (configured_workspace_root_path IS NULL OR configured_workspace_root_path = '')`),
30
+ updateConvSeed: db.prepare(`UPDATE conversations SET summary_seed = ?, seed_pending = ?, compacted_from = ?, updated_at = ? WHERE id = ?`),
31
+ markConvCompacted: db.prepare(`UPDATE conversations SET archived = 1, compacted_into = ?, updated_at = ? WHERE id = ?`),
32
+ getConvSeed: db.prepare(`SELECT summary_seed, seed_pending FROM conversations WHERE id = ?`),
33
+ clearConvSeed: db.prepare(`UPDATE conversations SET seed_pending = 0, updated_at = ? WHERE id = ?`),
34
+ updateConvDraft: db.prepare(`UPDATE conversations SET draft_text = ?, draft_updated_at = ?, draft_updated_by_client_id = ? WHERE id = ?`),
35
+ updateConvDraftAttachments: db.prepare(`UPDATE conversations SET draft_attachments = ?, draft_updated_at = ?, draft_updated_by_client_id = ? WHERE id = ?`),
36
+ archiveConv: db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ?`),
37
+ deleteConv: db.prepare(`DELETE FROM conversations WHERE id = ?`),
38
+
39
+ // messages
40
+ getMessages: db.prepare(`SELECT * FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC`),
41
+ getSharedMessages: shareVisibility.getSharedMessages,
42
+ getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
43
+ setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
44
+ getConversationMessageCount: db.prepare(`SELECT COUNT(*) AS count FROM messages WHERE conversation_id = ?`),
45
+ getConversationActiveQueueCount: db.prepare(`SELECT COUNT(*) AS count FROM queue WHERE conversation_id = ? AND status IN ('pending', 'processing', 'parked')`),
46
+ getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
47
+ getRecentMessagesDesc: db.prepare(`SELECT role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
48
+ insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
49
+
50
+ // queue
51
+ insertQ: db.prepare(`INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL)`),
52
+ findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY retry_count ASC, CASE WHEN next_attempt_at IS NULL THEN 0 ELSE 1 END ASC, COALESCE(next_attempt_at, timestamp) ASC, timestamp ASC LIMIT 1`),
53
+ countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') GROUP BY status`),
54
+ countRuntimeSessions: db.prepare(`SELECT COUNT(*) AS cnt FROM runtime_sessions WHERE status = 'active'`),
55
+ setProcessing: db.prepare(`UPDATE queue SET status = 'processing', processing_at = ? WHERE id = ?`),
56
+ setQueueRuntimeSession: db.prepare(`UPDATE queue SET runtime_session_id = ? WHERE id = ?`),
57
+ setQueueResponseMessageId: db.prepare(`UPDATE queue SET response_message_id = ? WHERE id = ?`),
58
+ setDone: db.prepare(`UPDATE queue SET status = 'done', response = ?, processing_at = NULL, next_attempt_at = NULL WHERE id = ? AND status IN ('processing', 'pending')`),
59
+ setFailed: db.prepare(`UPDATE queue SET status = 'failed', response = ?, processing_at = NULL, next_attempt_at = NULL WHERE id = ?`),
60
+ deleteConvQ: db.prepare(`DELETE FROM queue WHERE conversation_id = ?`),
61
+ findQById: db.prepare(`SELECT * FROM queue WHERE id = ?`),
62
+ pruneQueue: db.prepare(`DELETE FROM queue WHERE status = 'done' AND id NOT IN (SELECT id FROM queue WHERE status = 'done' ORDER BY timestamp DESC LIMIT 200)`),
63
+ recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, next_attempt_at = ? WHERE status = 'processing' AND processing_at < ?`),
64
+ listRecoverableProcessing: db.prepare(`SELECT id, conversation_id FROM queue WHERE status = 'processing' AND processing_at < ?`),
65
+ recoverProcessingBefore: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, next_attempt_at = ? WHERE status = 'processing' AND processing_at < ?`),
66
+ listQueueForPauseDrop: db.prepare(`SELECT id, conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
67
+ deleteQueueById: db.prepare(`DELETE FROM queue WHERE id = ?`),
68
+ getLatestProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) DESC LIMIT 1`),
69
+
70
+ // runtime sessions
71
+ getRuntimeSessionByConversation: db.prepare(`SELECT * FROM runtime_sessions WHERE conversation_id = ?`),
72
+ getRuntimeSessionBySdkSessionId: db.prepare(`SELECT * FROM runtime_sessions WHERE sdk_session_id = ?`),
73
+ getRuntimeSessionById: db.prepare(`SELECT * FROM runtime_sessions WHERE id = ?`),
74
+ listRuntimeSessions: db.prepare(`SELECT rs.*, c.title AS conversation_title, c.updated_at AS conversation_updated_at FROM runtime_sessions rs LEFT JOIN conversations c ON c.id = rs.conversation_id ORDER BY rs.last_used_at DESC`),
75
+ insertRuntimeSession: db.prepare(runtimeSessionsSupportProviders
76
+ ? `INSERT INTO runtime_sessions (id, conversation_id, strategy, runtime_key, model, status, created_at, last_used_at, sdk_session_id, provider_type, provider_model) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)`
77
+ : `INSERT INTO runtime_sessions (id, conversation_id, strategy, runtime_key, model, status, created_at, last_used_at, sdk_session_id) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?)`),
78
+ setConvSdkSessionIdIfMissing: db.prepare(`UPDATE conversations SET sdk_session_id = ?, updated_at = ? WHERE id = ? AND (sdk_session_id IS NULL OR sdk_session_id = '')`),
79
+ setRuntimeSessionSdkSessionIdIfMissing: db.prepare(`UPDATE runtime_sessions SET sdk_session_id = ?, last_used_at = ?, status = 'active' WHERE id = ? AND (sdk_session_id IS NULL OR sdk_session_id = '')`),
80
+ touchRuntimeSession: db.prepare(`UPDATE runtime_sessions SET model = ?, last_used_at = ?, status = 'active' WHERE id = ?`),
81
+ listRuntimeSessionProviderCandidates: db.prepare(`
82
+ SELECT
83
+ rs.*,
84
+ c.sdk_session_id AS conversation_sdk_session_id,
85
+ (SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) AS message_count,
86
+ (SELECT COUNT(*) FROM queue q WHERE q.conversation_id = c.id AND q.status IN ('pending', 'processing', 'parked')) AS active_queue_count
87
+ FROM runtime_sessions rs
88
+ JOIN conversations c ON c.id = rs.conversation_id
89
+ WHERE c.status != 'deleted'
90
+ `),
91
+ updateRuntimeSessionProvider: runtimeSessionsSupportProviders
92
+ ? db.prepare(`
93
+ UPDATE runtime_sessions
94
+ SET provider_type = ?, provider_model = ?, model = ?, last_used_at = ?, status = 'active'
95
+ WHERE id = ?
96
+ `)
97
+ : null,
98
+ updateRuntimeSessionClaudeNativeSessionId: runtimeSessionColumns.has('claude_native_session_id')
99
+ ? db.prepare(`
100
+ UPDATE runtime_sessions
101
+ SET claude_native_session_id = ?, last_used_at = ?
102
+ WHERE conversation_id = ?
103
+ `)
104
+ : null,
105
+ updateRuntimeSessionCursorAgentId: runtimeSessionColumns.has('cursor_agent_id')
106
+ ? db.prepare(`
107
+ UPDATE runtime_sessions
108
+ SET cursor_agent_id = ?, last_used_at = ?
109
+ WHERE conversation_id = ?
110
+ `)
111
+ : null,
112
+ updateRuntimeSessionGrokNativeSessionId: runtimeSessionColumns.has('grok_native_session_id')
113
+ ? db.prepare(`
114
+ UPDATE runtime_sessions
115
+ SET grok_native_session_id = ?, last_used_at = ?
116
+ WHERE conversation_id = ?
117
+ `)
118
+ : null,
119
+ updateRuntimeSessionContextUsage: runtimeSessionColumns.has('context_usage_json')
120
+ ? db.prepare(`
121
+ UPDATE runtime_sessions
122
+ SET context_usage_json = ?, context_usage_captured_at = ?
123
+ WHERE conversation_id = ?
124
+ `)
125
+ : null,
126
+ deleteRuntimeSessionByConversation: db.prepare(`DELETE FROM runtime_sessions WHERE conversation_id = ?`),
127
+
128
+ // deleted sdk sessions tombstones (hide rediscovered SDK sessions after UI delete)
129
+ listDeletedSdkSessions: db.prepare(`SELECT sdk_session_id FROM deleted_sdk_sessions`),
130
+ getDeletedSdkSession: db.prepare(`SELECT sdk_session_id FROM deleted_sdk_sessions WHERE sdk_session_id = ? LIMIT 1`),
131
+ upsertRelaySessionLink: db.prepare(`
132
+ INSERT INTO relay_session_links (sdk_session_id, conversation_id, created_at)
133
+ VALUES (?, ?, ?)
134
+ ON CONFLICT(sdk_session_id) DO UPDATE SET conversation_id = excluded.conversation_id
135
+ `),
136
+ getRelaySessionLink: db.prepare(`SELECT * FROM relay_session_links WHERE sdk_session_id = ?`),
137
+ markDeletedSdkSession: db.prepare(`INSERT OR REPLACE INTO deleted_sdk_sessions (sdk_session_id, deleted_at) VALUES (?, ?)`),
138
+ clearDeletedSdkSession: db.prepare(`DELETE FROM deleted_sdk_sessions WHERE sdk_session_id = ?`),
139
+ deleteDeletedSdkSessions: db.prepare(`DELETE FROM deleted_sdk_sessions`),
140
+
141
+ // recent workspace roots (relay-owned CWD history)
142
+ // Keyed by path_key (case-folded on Windows), so the LIMIT counts
143
+ // distinct directories rather than casing variants of the same one.
144
+ upsertRecentWorkspaceRoot: db.prepare(`
145
+ INSERT INTO recent_workspace_roots (path_key, path, last_seen_at)
146
+ VALUES (?, ?, ?)
147
+ ON CONFLICT(path_key) DO UPDATE SET
148
+ path = excluded.path,
149
+ last_seen_at = excluded.last_seen_at
150
+ `),
151
+ listRecentWorkspaceRoots: db.prepare(`
152
+ SELECT path, last_seen_at
153
+ FROM recent_workspace_roots
154
+ ORDER BY last_seen_at DESC
155
+ LIMIT ?
156
+ `),
157
+ pruneRecentWorkspaceRoots: db.prepare(`
158
+ DELETE FROM recent_workspace_roots
159
+ WHERE path_key NOT IN (
160
+ SELECT path_key
161
+ FROM recent_workspace_roots
162
+ ORDER BY last_seen_at DESC
163
+ LIMIT ?
164
+ )
165
+ `),
166
+ deleteRecentWorkspaceRoots: db.prepare(`DELETE FROM recent_workspace_roots`),
167
+
168
+ // app settings
169
+ getAppSetting: db.prepare(`
170
+ SELECT key, value, updated_at
171
+ FROM app_settings
172
+ WHERE key = ?
173
+ LIMIT 1
174
+ `),
175
+ upsertAppSetting: db.prepare(`
176
+ INSERT INTO app_settings (key, value, updated_at)
177
+ VALUES (?, ?, ?)
178
+ ON CONFLICT(key) DO UPDATE SET
179
+ value = excluded.value,
180
+ updated_at = excluded.updated_at
181
+ `),
182
+ deleteAppSetting: db.prepare(`DELETE FROM app_settings WHERE key = ?`),
183
+
184
+ // SDK session delete bridge queue (server <-> extension)
185
+ upsertSdkDeleteRequest: db.prepare(`
186
+ INSERT INTO sdk_delete_requests (
187
+ sdk_session_id, conversation_id, status, requested_at, updated_at, processing_at, retry_count, next_attempt_at, last_error
188
+ ) VALUES (?, ?, 'pending', ?, ?, NULL, 0, NULL, NULL)
189
+ ON CONFLICT(sdk_session_id) DO UPDATE SET
190
+ conversation_id = COALESCE(excluded.conversation_id, sdk_delete_requests.conversation_id),
191
+ status = 'pending',
192
+ requested_at = excluded.requested_at,
193
+ updated_at = excluded.updated_at,
194
+ processing_at = NULL,
195
+ retry_count = 0,
196
+ next_attempt_at = NULL,
197
+ last_error = NULL
198
+ `),
199
+ dequeueSdkDeleteRequest: db.prepare(`
200
+ SELECT sdk_session_id, conversation_id, retry_count, requested_at
201
+ FROM sdk_delete_requests
202
+ WHERE status = 'pending' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
203
+ ORDER BY requested_at ASC
204
+ LIMIT 1
205
+ `),
206
+ setSdkDeleteRequestProcessing: db.prepare(`
207
+ UPDATE sdk_delete_requests
208
+ SET status = 'processing', processing_at = ?, updated_at = ?, last_error = NULL
209
+ WHERE sdk_session_id = ? AND status = 'pending'
210
+ `),
211
+ resetStaleSdkDeleteProcessing: db.prepare(`
212
+ UPDATE sdk_delete_requests
213
+ SET status = 'pending', processing_at = NULL, updated_at = ?
214
+ WHERE status = 'processing' AND processing_at < ?
215
+ `),
216
+ getSdkDeleteRequestBySessionId: db.prepare(`
217
+ SELECT sdk_session_id, conversation_id, status, retry_count, requested_at, updated_at, next_attempt_at, last_error
218
+ FROM sdk_delete_requests
219
+ WHERE sdk_session_id = ?
220
+ LIMIT 1
221
+ `),
222
+ setSdkDeleteRequestPendingWithError: db.prepare(`
223
+ UPDATE sdk_delete_requests
224
+ SET status = 'pending',
225
+ processing_at = NULL,
226
+ retry_count = retry_count + 1,
227
+ next_attempt_at = ?,
228
+ updated_at = ?,
229
+ last_error = ?
230
+ WHERE sdk_session_id = ?
231
+ `),
232
+ deleteSdkDeleteRequest: db.prepare(`DELETE FROM sdk_delete_requests WHERE sdk_session_id = ?`),
233
+ deleteSdkDeleteRequests: db.prepare(`DELETE FROM sdk_delete_requests`),
234
+ // SDK session import ledger
235
+ getSdkSessionImport: db.prepare(`
236
+ SELECT sdk_session_id, conversation_id, status, attempt_count, started_at, completed_at,
237
+ source_started_at, source_modified_at, updated_at, last_error
238
+ FROM sdk_session_imports WHERE sdk_session_id = ? LIMIT 1
239
+ `),
240
+ upsertSdkSessionImport: db.prepare(`
241
+ INSERT INTO sdk_session_imports (
242
+ sdk_session_id, conversation_id, status, attempt_count, started_at, completed_at,
243
+ source_started_at, source_modified_at, updated_at, last_error
244
+ )
245
+ VALUES (?, ?, 'pending', 0, NULL, NULL, NULL, NULL, ?, NULL)
246
+ ON CONFLICT(sdk_session_id) DO NOTHING
247
+ `),
248
+ claimSdkSessionImport: db.prepare(`
249
+ UPDATE sdk_session_imports
250
+ SET status = 'processing', attempt_count = attempt_count + 1, started_at = ?, updated_at = ?, last_error = NULL
251
+ WHERE sdk_session_id = ?
252
+ AND (? = 1 OR status != 'completed')
253
+ AND status != 'processing'
254
+ `),
255
+ completeSdkSessionImport: db.prepare(`
256
+ UPDATE sdk_session_imports
257
+ SET conversation_id = ?, status = 'completed', completed_at = ?, source_started_at = ?,
258
+ source_modified_at = ?, updated_at = ?, last_error = NULL
259
+ WHERE sdk_session_id = ?
260
+ `),
261
+ failSdkSessionImport: db.prepare(`
262
+ UPDATE sdk_session_imports
263
+ SET status = 'failed', updated_at = ?, last_error = ?
264
+ WHERE sdk_session_id = ?
265
+ `),
266
+ resetInterruptedSdkSessionImports: db.prepare(`
267
+ UPDATE sdk_session_imports
268
+ SET status = 'failed', updated_at = ?, last_error = 'Interrupted before import completion'
269
+ WHERE status = 'processing'
270
+ `),
271
+ resetSdkSessionImports: db.prepare(`DELETE FROM sdk_session_imports`),
272
+ listDeletedConversationsBySdkSessionId: db.prepare(`
273
+ SELECT id, sdk_session_id
274
+ FROM conversations
275
+ WHERE status = 'deleted' AND sdk_session_id = ?
276
+ `),
277
+ // public conversation shares
278
+ getConversationShareByConversationId: db.prepare(`
279
+ SELECT token, conversation_id, created_at, last_accessed_at, revoked_at
280
+ FROM conversation_shares
281
+ WHERE conversation_id = ?
282
+ AND (revoked_at IS NULL OR revoked_at = '')
283
+ ORDER BY created_at DESC
284
+ LIMIT 1
285
+ `),
286
+ getConversationShareByToken: db.prepare(`
287
+ SELECT token, conversation_id, created_at, last_accessed_at, revoked_at
288
+ FROM conversation_shares
289
+ WHERE token = ?
290
+ LIMIT 1
291
+ `),
292
+ insertConversationShare: db.prepare(`
293
+ INSERT INTO conversation_shares (
294
+ token,
295
+ conversation_id,
296
+ created_at,
297
+ last_accessed_at,
298
+ revoked_at
299
+ ) VALUES (?, ?, ?, ?, NULL)
300
+ `),
301
+ touchConversationShare: db.prepare(`
302
+ UPDATE conversation_shares
303
+ SET last_accessed_at = ?
304
+ WHERE token = ?
305
+ AND (revoked_at IS NULL OR revoked_at = '')
306
+ `),
307
+ revokeConversationShareByToken: db.prepare(`
308
+ UPDATE conversation_shares
309
+ SET revoked_at = ?
310
+ WHERE token = ?
311
+ AND (revoked_at IS NULL OR revoked_at = '')
312
+ `),
313
+ revokeConversationSharesByConversationId: db.prepare(`
314
+ UPDATE conversation_shares
315
+ SET revoked_at = ?
316
+ WHERE conversation_id = ?
317
+ AND (revoked_at IS NULL OR revoked_at = '')
318
+ `),
319
+ };
320
+ }
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ // Shared between message-repository and session-repository so the
4
+ // hidden-from-shares column detection and SQL stay in one place.
5
+ export function createShareVisibilityStatements(db) {
6
+ const messageColumns = new Set(
7
+ db.prepare(`PRAGMA table_info(messages)`).all().map((column) => String(column?.name || '').trim()),
8
+ );
9
+ const messagesSupportShareVisibility = messageColumns.has('hidden_from_shares')
10
+ && messageColumns.has('share_hidden_at');
11
+ return {
12
+ messagesSupportShareVisibility,
13
+ getSharedMessages: db.prepare(messagesSupportShareVisibility
14
+ ? `SELECT * FROM messages WHERE conversation_id = ? AND hidden_from_shares = 0 ORDER BY timestamp ASC`
15
+ : `SELECT * FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC`),
16
+ setMessageShareVisibility: messagesSupportShareVisibility
17
+ ? db.prepare(`
18
+ UPDATE messages
19
+ SET hidden_from_shares = ?, share_hidden_at = ?
20
+ WHERE id = ? AND conversation_id = ?
21
+ `)
22
+ : null,
23
+ };
24
+ }
@@ -0,0 +1,292 @@
1
+ 'use strict';
2
+
3
+ import { createAskUserRoutingService } from '../services/ask-user-routing-service.mjs';
4
+ import {
5
+ extractRequestedSchema,
6
+ normalizeSchema,
7
+ schemaFields,
8
+ validateStructuredAnswer,
9
+ summarizeStructuredAnswer,
10
+ flatAnswerToStructured,
11
+ } from '../../shared/question-schema.mjs';
12
+
13
+ function normalizeId(value) {
14
+ const text = String(value || '').trim();
15
+ return text || null;
16
+ }
17
+
18
+ function resolveNestedValue(source, keyPath) {
19
+ let current = source;
20
+ for (const key of keyPath) {
21
+ if (!current || typeof current !== 'object') return null;
22
+ current = current[key];
23
+ }
24
+ return current;
25
+ }
26
+
27
+ function firstKnownValue(source, keyPaths = []) {
28
+ for (const keyPath of keyPaths) {
29
+ const value = resolveNestedValue(source, keyPath);
30
+ const normalized = normalizeId(value);
31
+ if (normalized) return normalized;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function extractContinuationOwnership(rawRequest) {
37
+ const parsed = rawRequest && typeof rawRequest === 'object' ? rawRequest : null;
38
+ if (!parsed) {
39
+ return {
40
+ continuationId: null,
41
+ continuationQuestionId: null,
42
+ };
43
+ }
44
+
45
+ return {
46
+ continuationId: firstKnownValue(parsed, [
47
+ ['continuationId'],
48
+ ['continuation_id'],
49
+ ['continuation', 'id'],
50
+ ['toolArgs', 'continuationId'],
51
+ ['toolArgs', 'continuation_id'],
52
+ ['toolArgs', 'continuation', 'id'],
53
+ ['input', 'continuationId'],
54
+ ['input', 'continuation_id'],
55
+ ['input', 'continuation', 'id'],
56
+ ['request', 'continuationId'],
57
+ ['request', 'continuation_id'],
58
+ ['request', 'continuation', 'id'],
59
+ ['arguments', 'continuationId'],
60
+ ['arguments', 'continuation_id'],
61
+ ['arguments', 'continuation', 'id'],
62
+ ]),
63
+ continuationQuestionId: firstKnownValue(parsed, [
64
+ ['questionId'],
65
+ ['question_id'],
66
+ ['askUserQuestionId'],
67
+ ['ask_user_question_id'],
68
+ ['continuation', 'questionId'],
69
+ ['continuation', 'question_id'],
70
+ ['toolArgs', 'questionId'],
71
+ ['toolArgs', 'question_id'],
72
+ ['input', 'questionId'],
73
+ ['input', 'question_id'],
74
+ ['request', 'questionId'],
75
+ ['request', 'question_id'],
76
+ ['arguments', 'questionId'],
77
+ ['arguments', 'question_id'],
78
+ ]),
79
+ };
80
+ }
81
+
82
+ export function registerAskUserRoutes(app, deps) {
83
+ const {
84
+ auth,
85
+ io,
86
+ db,
87
+ stmts,
88
+ runtimeState,
89
+ uuidv4,
90
+ ts,
91
+ questionExpiresAt,
92
+ sanitizeRelayQuestionPrompt,
93
+ sanitizeRelayQuestionRequest,
94
+ sanitizeRelayQuestionContext,
95
+ parseQuestionRequest,
96
+ normalizeQuestionChoices,
97
+ formatQuestionRow,
98
+ normalizeRelayMode,
99
+ DEFAULT_RELAY_MODE,
100
+ sessionWorkerRegistry,
101
+ pushDispatchService,
102
+ } = deps;
103
+
104
+ const continuationRoutingEnabled = runtimeState?.featureFlags?.SESSION_WORKER_CONTINUATION_ROUTING_ENABLED === true;
105
+ const workerRoutingEnabled = runtimeState?.featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true;
106
+ const askUserRoutingService = createAskUserRoutingService(db, {
107
+ continuationRoutingEnabled,
108
+ sessionWorkerRegistry,
109
+ });
110
+
111
+ app.get('/api/relay-questions', auth, (req, res) => {
112
+ const conversationId = req.query.conversationId ? String(req.query.conversationId) : null;
113
+ const status = String(req.query.status || 'pending').trim() || 'pending';
114
+ const rows = stmts.listQuestions.all(status, conversationId, conversationId);
115
+ res.json({ questions: rows.map(formatQuestionRow) });
116
+ });
117
+
118
+ app.get('/api/relay-question/:id', auth, (req, res) => {
119
+ const row = stmts.getQuestion.get(req.params.id);
120
+ if (!row) return res.status(404).json({ error: 'Not found' });
121
+ res.json({ question: formatQuestionRow(row) });
122
+ });
123
+
124
+ app.post('/api/relay-question', auth, (req, res) => {
125
+ const { queueId, messageId, conversationId, mode, prompt, choices, request, context, allowFreeform, requestedSchema } = req.body;
126
+ console.log(`[${ts()}] relay-question POST: ${choices?.length ?? 0} possible answer(s)`);
127
+ const q = stmts.findQById.get(queueId || messageId);
128
+ if (!q || q.status !== 'processing') {
129
+ return res.status(409).json({ error: 'No active relay turn' });
130
+ }
131
+
132
+ const effectiveMessageId = messageId || q.id;
133
+
134
+ // Check for an identical pending question (same prompt + choices) to handle retries.
135
+ // Do NOT reuse questions with different prompts/choices - this allows multiple
136
+ // concurrent ask_user calls in the same turn to each create their own question.
137
+ const promptText = sanitizeRelayQuestionPrompt({ prompt });
138
+ const normalizedChoices = normalizeQuestionChoices(choices);
139
+ const choicesJson = normalizedChoices.length ? JSON.stringify(normalizedChoices) : null;
140
+
141
+ const existingPending = stmts.findPendingQuestionByMessage.get(effectiveMessageId);
142
+ if (existingPending) {
143
+ const existingPrompt = String(existingPending.prompt || '');
144
+ const existingChoices = existingPending.choices || null;
145
+ const promptMatches = existingPrompt === promptText;
146
+ const choicesMatch = existingChoices === choicesJson;
147
+
148
+ if (promptMatches && choicesMatch) {
149
+ // This is likely a retry of the same question - reuse it
150
+ const question = formatQuestionRow(existingPending);
151
+ return res.json({ question, reused: true });
152
+ }
153
+ // Different question - continue to create a new one
154
+ }
155
+
156
+ const relayMode = normalizeRelayMode(mode || q.relay_mode) || DEFAULT_RELAY_MODE;
157
+ const now = new Date().toISOString();
158
+ const questionId = uuidv4();
159
+ const requestedSessionId = normalizeId(req.body.sdk_session_id);
160
+ const queueOwnerSessionId = workerRoutingEnabled || continuationRoutingEnabled
161
+ ? normalizeId(q.owner_sdk_session_id)
162
+ : null;
163
+ const sdkSessionId = requestedSessionId || queueOwnerSessionId || null;
164
+ const ownerWorkerId = sdkSessionId
165
+ ? normalizeId(sessionWorkerRegistry?.getWorker?.(sdkSessionId)?.workerId)
166
+ : null;
167
+ const parsedQuestionRequest = parseQuestionRequest(request);
168
+ const requestJson = sanitizeRelayQuestionRequest({
169
+ request: parsedQuestionRequest,
170
+ context: sanitizeRelayQuestionContext(context),
171
+ allowFreeform: typeof allowFreeform === 'boolean' ? allowFreeform : (!normalizedChoices.length),
172
+ });
173
+ // Capture the elicitation requestedSchema (explicit field or nested in the request)
174
+ // so multi-field structured answers can be validated and rendered.
175
+ const resolvedSchema = normalizeSchema(requestedSchema)
176
+ || extractRequestedSchema(requestedSchema)
177
+ || extractRequestedSchema(parsedQuestionRequest);
178
+ const requestSchemaJson = resolvedSchema ? JSON.stringify(resolvedSchema) : null;
179
+ const continuation = continuationRoutingEnabled
180
+ ? extractContinuationOwnership(parsedQuestionRequest)
181
+ : { continuationId: null, continuationQuestionId: null };
182
+ // questionExpiresAt normalizes: absent/junk falls back to the 8h default,
183
+ // explicit finite values (including 0) are honored.
184
+ const expiresAt = questionExpiresAt(now, req.body.timeout_ms);
185
+
186
+ stmts.insertQuestion.run(
187
+ questionId,
188
+ q.id,
189
+ conversationId || q.conversation_id,
190
+ effectiveMessageId,
191
+ relayMode,
192
+ promptText,
193
+ choicesJson,
194
+ requestJson,
195
+ requestSchemaJson,
196
+ sdkSessionId,
197
+ ownerWorkerId,
198
+ continuation.continuationId,
199
+ continuation.continuationQuestionId,
200
+ now,
201
+ expiresAt,
202
+ );
203
+
204
+ const question = formatQuestionRow(stmts.getQuestion.get(questionId));
205
+ const fieldCount = resolvedSchema ? schemaFields(resolvedSchema).length : 0;
206
+ console.log(`[${ts()}] QUESTION ${questionId.slice(0,8)} conv=${question.conversationId.slice(0,8)} mode=${relayMode} fields=${fieldCount} prompt="${promptText.slice(0,60)}"`);
207
+ io.emit('relay_question', { question });
208
+ void pushDispatchService?.notifyQuestion?.(question);
209
+ res.json({ question });
210
+ });
211
+
212
+ app.post('/api/relay-question/:id/answer', auth, (req, res) => {
213
+ const id = String(req.params.id || '').trim();
214
+ const { answer, structuredAnswer, sdk_session_id, continuation_id, continuation_question_id } = req.body;
215
+ const sdkSessionId = normalizeId(sdk_session_id);
216
+ const continuationId = normalizeId(continuation_id);
217
+ const continuationQuestionId = normalizeId(continuation_question_id);
218
+
219
+ const row = stmts.getQuestion.get(id);
220
+ if (!row) return res.status(404).json({ error: 'Not found' });
221
+ if (row.status !== 'pending') return res.status(409).json({ error: `Question already ${row.status}` });
222
+
223
+ const schema = parseQuestionRequest(row.request_schema);
224
+ const hasSchema = schema && typeof schema === 'object' && schemaFields(schema).length > 0;
225
+
226
+ // Resolve a structured answer object when a schema is present. Accept either
227
+ // an explicit `structuredAnswer` object or, for single-field schemas, the
228
+ // flat `answer` string mapped onto the lone field (backward compatibility).
229
+ let structured = null;
230
+ let flatText = String(answer || '').trim();
231
+
232
+ if (hasSchema) {
233
+ let candidate = null;
234
+ if (structuredAnswer && typeof structuredAnswer === 'object' && !Array.isArray(structuredAnswer)) {
235
+ candidate = structuredAnswer;
236
+ } else if (flatText) {
237
+ candidate = flatAnswerToStructured(schema, flatText);
238
+ }
239
+ if (!candidate) {
240
+ return res.status(400).json({ error: 'Structured answer required', schema });
241
+ }
242
+ const validation = validateStructuredAnswer(schema, candidate);
243
+ if (!validation.ok) {
244
+ return res.status(400).json({ error: 'Invalid structured answer', fields: validation.errors });
245
+ }
246
+ structured = validation.value;
247
+ flatText = summarizeStructuredAnswer(schema, structured) || flatText;
248
+ } else if (!flatText) {
249
+ return res.status(400).json({ error: 'Empty answer' });
250
+ }
251
+
252
+ const result = askUserRoutingService.routeAnswer({
253
+ question_id: id,
254
+ sdk_session_id: sdkSessionId,
255
+ continuation_id: continuationId,
256
+ continuation_question_id: continuationQuestionId,
257
+ answer: flatText,
258
+ structured_answer: structured ? JSON.stringify(structured) : null,
259
+ });
260
+ if (!result.ok) {
261
+ console.warn(
262
+ `[${ts()}] QUESTION reject id=${id.slice(0, 8)} reason=${String(result.error || 'unknown').slice(0, 64)} sid=${String(sdkSessionId || 'none').slice(0, 8)}`,
263
+ );
264
+ if (result.error === 'session mismatch' || result.error === 'owner mismatch') {
265
+ const targetSessionId = String(row.sdk_session_id || '').trim() || null;
266
+ return res.status(403).json({ error: result.error, expectedSessionId: targetSessionId });
267
+ }
268
+ return res.status(400).json({ error: result.error });
269
+ }
270
+
271
+ const question = formatQuestionRow(stmts.getQuestion.get(id));
272
+ console.log(`[${ts()}] QUESTION ${id.slice(0,8)} answered ${structured ? `fields=${Object.keys(structured).length}` : `len=${flatText.length}`}`);
273
+ io.emit('relay_question_updated', { question });
274
+ res.json({ ok: true, question });
275
+ });
276
+
277
+ app.post('/api/relay-question/:id/timeout', auth, (req, res) => {
278
+ const row = stmts.getQuestion.get(req.params.id);
279
+ if (!row) return res.status(404).json({ error: 'Not found' });
280
+ if (row.status !== 'pending') return res.json({ ok: true, question: formatQuestionRow(row) });
281
+
282
+ const result = stmts.timeoutQuestion.run(row.id);
283
+ if (result.changes > 0) {
284
+ const question = formatQuestionRow(stmts.getQuestion.get(row.id));
285
+ console.log(`[${ts()}] QUESTION ${row.id.slice(0,8)} timed out`);
286
+ io.emit('relay_question_updated', { question });
287
+ return res.json({ ok: true, question });
288
+ }
289
+
290
+ return res.json({ ok: true, question: formatQuestionRow(stmts.getQuestion.get(row.id)) });
291
+ });
292
+ }