@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,1105 @@
1
+ import fs from "fs";
2
+ import {
3
+ buildTerminalFailureText,
4
+ isTerminalSendAndWaitError,
5
+ normalizeTerminalSendAndWaitError,
6
+ } from "../runtime/send-and-wait-errors.mjs";
7
+ import { getActiveSession } from "../runtime/session-registry.mjs";
8
+ import { DEFAULT_QUESTION_TIMEOUT_MS } from "../../../../shared/question-timeout.mjs";
9
+ import { QUESTION_TIMEOUT_CONTINUATION_TEXT } from "../../../../shared/question-timeout.mjs";
10
+ import { EMPTY_TURN_COMPLETION_NOTE } from "../../../../shared/empty-turn-completion.mjs";
11
+ import { stripPromptContextPrefix } from "../skills/prompt-context.mjs";
12
+
13
+ function isImageAttachment(att) {
14
+ const type = String(att?.type || "").toLowerCase();
15
+ return type.startsWith("image/");
16
+ }
17
+
18
+ export function classifySwitchingFailure(input = {}) {
19
+ const explicitRetryable = input?.retryable;
20
+ const reason = String(input?.reason || "switch-call-failed").trim() || "switch-call-failed";
21
+ if (typeof explicitRetryable === "boolean") {
22
+ return { reason, retryable: explicitRetryable };
23
+ }
24
+ const nonRetryable = new Set([
25
+ "switch-api-missing",
26
+ "target-session-invalid",
27
+ ]);
28
+ return { reason, retryable: !nonRetryable.has(reason) };
29
+ }
30
+
31
+ export function evaluateSwitchRetry({
32
+ retryable = false,
33
+ attempts = 0,
34
+ maxRetries = 2,
35
+ } = {}) {
36
+ const safeAttempts = Math.max(0, Math.trunc(Number(attempts) || 0));
37
+ const safeMaxRetries = Math.max(0, Math.trunc(Number(maxRetries) || 0));
38
+ if (!retryable || safeAttempts >= safeMaxRetries) {
39
+ return {
40
+ shouldRetry: false,
41
+ attempts: Math.min(safeAttempts, safeMaxRetries),
42
+ };
43
+ }
44
+ return {
45
+ shouldRetry: true,
46
+ attempts: safeAttempts + 1,
47
+ };
48
+ }
49
+
50
+ function readImageBlobFromDataUrl(att) {
51
+ const dataUrl = String(att?.dataUrl || "").trim();
52
+ if (!dataUrl.startsWith("data:")) return null;
53
+ const match = dataUrl.match(/^data:([^;,]+);base64,(.+)$/i);
54
+ if (!match) return null;
55
+ const mimeType = String(match[1] || "").trim().toLowerCase();
56
+ const data = String(match[2] || "").trim();
57
+ if (!mimeType.startsWith("image/") || !data) return null;
58
+ return {
59
+ type: "blob",
60
+ data,
61
+ mimeType,
62
+ displayName: String(att?.name || "image"),
63
+ };
64
+ }
65
+
66
+ function readImageBlobFromPath(att) {
67
+ const filePath = String(att?.path || "").trim();
68
+ if (!filePath) return null;
69
+ if (!fs.existsSync(filePath)) return null;
70
+ const bytes = fs.readFileSync(filePath);
71
+ if (!Buffer.isBuffer(bytes) || !bytes.length) return null;
72
+ const mimeType = String(att?.type || "application/octet-stream").trim().toLowerCase();
73
+ return {
74
+ type: "blob",
75
+ data: bytes.toString("base64"),
76
+ mimeType,
77
+ displayName: String(att?.name || "image"),
78
+ };
79
+ }
80
+
81
+ function normalizeWorkerLivenessIssueReason(reason) {
82
+ const normalized = String(reason || "").trim().toLowerCase();
83
+ if (!normalized) return "worker-unavailable";
84
+ return normalized.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "worker-unavailable";
85
+ }
86
+
87
+ export async function processSdkSessionDeleteRequest({
88
+ request,
89
+ api,
90
+ session,
91
+ dbg = () => {},
92
+ getActiveSessionFn = getActiveSession,
93
+ } = {}) {
94
+ const sdkSessionId = String(request?.sdkSessionId || "").trim();
95
+ if (!sdkSessionId) return false;
96
+
97
+ if (!session || typeof session.deleteSession !== "function") {
98
+ const errorText = "SDK deleteSession() is unavailable in this CLI runtime";
99
+ dbg("sdk delete unsupported", `session=${sdkSessionId}`);
100
+ await api("POST", "/api/sdk-session-delete/result", {
101
+ sdk_session_id: sdkSessionId,
102
+ conversation_id: request?.conversationId || undefined,
103
+ ok: false,
104
+ unsupported: true,
105
+ error: errorText,
106
+ }).catch(() => {});
107
+ return true;
108
+ }
109
+
110
+ let ok = false;
111
+ let errorText = "";
112
+ try {
113
+ const activeSession = getActiveSessionFn();
114
+ const activeSdkSessionId = String(activeSession?.sdkSessionId || "").trim();
115
+ if (activeSdkSessionId && activeSdkSessionId === sdkSessionId) {
116
+ throw new Error("Refusing to delete the currently active SDK session");
117
+ }
118
+ await session.deleteSession(sdkSessionId);
119
+ ok = true;
120
+ await session.log(`🧹 Deleted SDK session ${sdkSessionId.slice(0, 8)} from relay request`, { ephemeral: true });
121
+ } catch (error) {
122
+ errorText = String(error?.message || error || "unknown delete failure").trim() || "unknown delete failure";
123
+ dbg("sdk delete failed", `session=${sdkSessionId}`, errorText);
124
+ await session.log(`⚠️ SDK session delete failed (${sdkSessionId.slice(0, 8)}): ${errorText}`);
125
+ }
126
+
127
+ await api("POST", "/api/sdk-session-delete/result", {
128
+ sdk_session_id: sdkSessionId,
129
+ conversation_id: request?.conversationId || undefined,
130
+ ok,
131
+ error: ok ? undefined : errorText,
132
+ }).catch(() => {});
133
+ return true;
134
+ }
135
+
136
+ function buildWorkerLivenessTerminalFailure({ message, ownerSessionId, issueReason, detail = "" } = {}) {
137
+ const reason = normalizeWorkerLivenessIssueReason(issueReason);
138
+ const detailParts = [
139
+ ownerSessionId ? `session=${ownerSessionId}` : null,
140
+ detail ? String(detail).trim() : null,
141
+ ].filter(Boolean);
142
+ return {
143
+ kind: "worker-session-unavailable",
144
+ code: reason,
145
+ stableCode: `relay.${reason}`,
146
+ message: "System note: This session worker stopped responding and the relay marked the turn as unavailable.",
147
+ guidance: "Use ☠️ Kill session from the conversation menu if you want to reset it, then retry or send a new message.",
148
+ detail: detailParts.join(" | ") || null,
149
+ failedAt: new Date().toISOString(),
150
+ requesterSessionId: String(ownerSessionId || "").trim() || null,
151
+ queueMessageId: String(message?.id || "").trim() || null,
152
+ };
153
+ }
154
+
155
+ function buildSdkAttachments(rawAttachments) {
156
+ const input = Array.isArray(rawAttachments) ? rawAttachments : [];
157
+ const sdkAttachments = [];
158
+ for (const att of input) {
159
+ if (!att || typeof att !== "object") continue;
160
+
161
+ if (isImageAttachment(att)) {
162
+ const blobFromPath = readImageBlobFromPath(att);
163
+ if (blobFromPath) {
164
+ sdkAttachments.push(blobFromPath);
165
+ continue;
166
+ }
167
+ const blobFromDataUrl = readImageBlobFromDataUrl(att);
168
+ if (blobFromDataUrl) {
169
+ sdkAttachments.push(blobFromDataUrl);
170
+ }
171
+ continue;
172
+ }
173
+
174
+ const filePath = String(att?.path || "").trim();
175
+ if (filePath && fs.existsSync(filePath)) {
176
+ sdkAttachments.push({
177
+ type: "file",
178
+ path: filePath,
179
+ displayName: String(att?.name || ""),
180
+ });
181
+ }
182
+ }
183
+ return sdkAttachments;
184
+ }
185
+
186
+ function collectStreamTextCandidates(value, out, depth = 0) {
187
+ if (depth > 8 || value === null || value === undefined) return;
188
+ if (typeof value === "string") {
189
+ const text = value;
190
+ if (text) out.push(text);
191
+ return;
192
+ }
193
+ if (Array.isArray(value)) {
194
+ for (const item of value) collectStreamTextCandidates(item, out, depth + 1);
195
+ return;
196
+ }
197
+ if (typeof value !== "object") return;
198
+ collectStreamTextCandidates(value.text, out, depth + 1);
199
+ collectStreamTextCandidates(value.delta, out, depth + 1);
200
+ collectStreamTextCandidates(value.content, out, depth + 1);
201
+ collectStreamTextCandidates(value.output_text, out, depth + 1);
202
+ collectStreamTextCandidates(value.outputText, out, depth + 1);
203
+ collectStreamTextCandidates(value.message, out, depth + 1);
204
+ collectStreamTextCandidates(value.response, out, depth + 1);
205
+ collectStreamTextCandidates(value.result, out, depth + 1);
206
+ collectStreamTextCandidates(value.output, out, depth + 1);
207
+ }
208
+
209
+ export function extractStreamTextFromEvent(event) {
210
+ const candidates = [];
211
+ collectStreamTextCandidates(event?.data, candidates);
212
+ collectStreamTextCandidates(event, candidates);
213
+ if (!candidates.length) return "";
214
+ const normalized = candidates
215
+ .map((candidate) => String(candidate || ""))
216
+ .filter((candidate) => candidate.length > 0);
217
+ if (!normalized.length) return "";
218
+ normalized.sort((a, b) => b.length - a.length);
219
+ return normalized[0];
220
+ }
221
+
222
+ export function shouldEmitRelayStreamUpdate(nextText, previousText) {
223
+ const next = String(nextText || "");
224
+ const prev = String(previousText || "");
225
+ if (!next) return false;
226
+ if (!prev) return true;
227
+ if (next === prev) return false;
228
+ const delta = next.length - prev.length;
229
+ if (delta >= 24) return true;
230
+ if (delta > 0 && /[\n.!?:)]$/.test(next)) return true;
231
+ if (delta <= 0) return true;
232
+ return false;
233
+ }
234
+
235
+ export function resolveEmptyFinalTextHandling({ lastStreamedSent = "", lastActivityText = "", hasGeneratedImages = false } = {}) {
236
+ const streamed = String(lastStreamedSent || "").trim();
237
+ if (streamed) {
238
+ return { action: "use_stream_text", text: streamed };
239
+ }
240
+ if (hasGeneratedImages) {
241
+ return { action: "publish_generated_images_only", reason: "empty-final-text:generated-images" };
242
+ }
243
+ const activity = String(lastActivityText || "").trim();
244
+ if (activity) {
245
+ // The turn ran tools but produced no prose: a COMPLETED turn, not a
246
+ // failed delivery. Requeueing re-ran work whose emptiness was
247
+ // deterministic until the retry cap failed it (Claude/Cursor parity via
248
+ // the shared completion note).
249
+ return {
250
+ action: "publish_completion_note",
251
+ reason: `empty-final-text:last-activity:${activity.slice(0, 120)}`,
252
+ };
253
+ }
254
+ return {
255
+ action: "requeue",
256
+ reason: "empty-final-text:no-stream-or-text",
257
+ };
258
+ }
259
+
260
+ export function shouldUseDirectOpenAIImageApi(message = {}) {
261
+ const providerType = String(message?.providerType || "").trim().toLowerCase();
262
+ if (providerType !== "openai") return false;
263
+ const model = String(message?.providerModel || message?.model || "").trim().toLowerCase();
264
+ return model.startsWith("gpt-image-") || model.startsWith("dall-e-");
265
+ }
266
+
267
+ function isDirectOpenAIImageTerminalStatus(status) {
268
+ const numeric = Number(status);
269
+ if (!Number.isFinite(numeric)) return false;
270
+ return (
271
+ numeric === 400
272
+ || numeric === 401
273
+ || numeric === 403
274
+ || numeric === 404
275
+ || numeric === 409
276
+ || numeric === 422
277
+ || numeric === 429
278
+ || numeric === 503
279
+ );
280
+ }
281
+
282
+ export async function publishRelayStreamEvent({
283
+ api,
284
+ message,
285
+ text,
286
+ done = false,
287
+ dbg = () => {},
288
+ } = {}) {
289
+ const value = String(text || "");
290
+ try {
291
+ await api("POST", "/api/stream", {
292
+ messageId: message?.id,
293
+ conversationId: message?.conversationId,
294
+ mode: message?.relayMode || "agent",
295
+ text: value,
296
+ done: !!done,
297
+ });
298
+ return { ok: true, text: value };
299
+ } catch (streamError) {
300
+ dbg("relay stream publish failed", `msgId=${message?.id || "none"}`, streamError?.message || String(streamError));
301
+ return { ok: false, text: value };
302
+ }
303
+ }
304
+
305
+ function extractToolCallInputObject(value, toolName, depth = 0) {
306
+ if (!value || typeof value !== "object" || depth > 12) return null;
307
+ if (Array.isArray(value)) {
308
+ for (const item of value) {
309
+ const found = extractToolCallInputObject(item, toolName, depth + 1);
310
+ if (found) return found;
311
+ }
312
+ return null;
313
+ }
314
+ const name = value.name || value.tool || value.function_name || value.toolName || value.tool_name;
315
+ if (String(name || "").trim() === toolName) {
316
+ const input = value.input || value.arguments || value.params || value.args || null;
317
+ if (input && typeof input === "object") return input;
318
+ if (typeof value.arguments === "string") {
319
+ try {
320
+ const parsed = JSON.parse(value.arguments);
321
+ if (parsed && typeof parsed === "object") return parsed;
322
+ } catch {}
323
+ }
324
+ }
325
+ const CONTAINER_KEYS = ["data", "output", "content", "tool_calls", "toolRequests", "calls", "items", "results", "steps", "turns", "messages", "events"];
326
+ for (const key of CONTAINER_KEYS) {
327
+ const child = value[key];
328
+ if (child === undefined || child === null) continue;
329
+ const found = extractToolCallInputObject(child, toolName, depth + 1);
330
+ if (found) return found;
331
+ }
332
+ return null;
333
+ }
334
+
335
+ function normalizePlanBoardActions(rawActions, recommendedAction = "") {
336
+ const ids = Array.isArray(rawActions)
337
+ ? rawActions
338
+ .map((entry) => {
339
+ if (typeof entry === "string") return entry.trim().toLowerCase();
340
+ if (entry && typeof entry === "object") {
341
+ return String(entry.id || entry.actionId || entry.value || "").trim().toLowerCase();
342
+ }
343
+ return "";
344
+ })
345
+ .filter(Boolean)
346
+ : [];
347
+ const sourceIds = ids.length ? ids : ["autopilot", "interactive", "exit_only"];
348
+ const seen = new Set();
349
+ const deduped = [];
350
+ for (const id of sourceIds) {
351
+ if (seen.has(id)) continue;
352
+ seen.add(id);
353
+ deduped.push(id);
354
+ }
355
+ const recommended = String(recommendedAction || "").trim().toLowerCase();
356
+ return {
357
+ actions: deduped.map((id) => {
358
+ if (id === "autopilot_fleet") return { id, label: "Implement with autopilot fleet", mode: "autopilot" };
359
+ if (id === "autopilot") return { id, label: "Implement in autopilot", mode: "autopilot" };
360
+ if (id === "interactive") return { id, label: "Stop here and prompt myself", mode: "agent" };
361
+ if (id === "exit_only") return { id, label: "Stop here", mode: "agent" };
362
+ return { id, label: id.replace(/[_-]+/g, " "), mode: null };
363
+ }),
364
+ recommendedAction: recommended || null,
365
+ };
366
+ }
367
+
368
+ function countPlanLikeLines(text) {
369
+ return String(text || "")
370
+ .split(/\r?\n/)
371
+ .map((line) => line.trim())
372
+ .filter((line) => /^([-*]\s+|\d+\.\s+)/.test(line))
373
+ .length;
374
+ }
375
+
376
+ export function buildPlanReadyBoardPayload({ finalEvent, message, finalText = "" } = {}) {
377
+ const exitPlanInput = extractToolCallInputObject(finalEvent, "exit_plan_mode");
378
+ const taskCompleteInput = extractToolCallInputObject(finalEvent, "task_complete");
379
+ const toolInput = (exitPlanInput && typeof exitPlanInput === "object")
380
+ ? exitPlanInput
381
+ : ((taskCompleteInput && typeof taskCompleteInput === "object") ? taskCompleteInput : null);
382
+ const fallbackSummary = String(finalText || "").trim();
383
+ const allowPlanModeFallback =
384
+ !toolInput
385
+ && String(message?.relayMode || "").trim().toLowerCase() === "plan"
386
+ && countPlanLikeLines(fallbackSummary) >= 2;
387
+ if (!toolInput && !allowPlanModeFallback) return null;
388
+ const summary = String(
389
+ toolInput?.summary
390
+ || toolInput?.result
391
+ || toolInput?.output
392
+ || toolInput?.message
393
+ || fallbackSummary
394
+ || "",
395
+ ).trim();
396
+ if (!summary) return null;
397
+ const normalized = normalizePlanBoardActions(toolInput?.actions, toolInput?.recommendedAction);
398
+ const source = exitPlanInput
399
+ ? "exit_plan_mode"
400
+ : (taskCompleteInput ? "task_complete" : "plan-mode-fallback");
401
+ return {
402
+ queueId: message?.id,
403
+ messageId: message?.id,
404
+ conversationId: message?.conversationId,
405
+ mode: message?.relayMode || "agent",
406
+ boardType: "plan_ready",
407
+ title: "Plan ready for review",
408
+ body: summary,
409
+ actions: normalized.actions,
410
+ recommendedAction: normalized.recommendedAction,
411
+ context: {
412
+ source,
413
+ queueMessageId: message?.id || null,
414
+ conversationId: message?.conversationId || null,
415
+ relayMode: message?.relayMode || "agent",
416
+ },
417
+ };
418
+ }
419
+
420
+ export function createPollingLoop({
421
+ sleep,
422
+ pollMs,
423
+ api,
424
+ dbg,
425
+ session,
426
+ sendTimeout,
427
+ publishModelSnapshot,
428
+ setModelForMessage,
429
+ buildPromptWithRelayContext,
430
+ sendAndWaitWithHardTimeout,
431
+ sendWithBestEffortStreaming,
432
+ extractFinalText,
433
+ extractGeneratedImages = () => [],
434
+ getLastActivityText,
435
+ getCurrentModelId,
436
+ getPreferredConversationSessionMode,
437
+ getSupportsIsolatedSessions,
438
+ getWarnedConversationModeFallback,
439
+ setWarnedConversationModeFallback,
440
+ getPollingLoopStarted,
441
+ setPollingLoopStarted,
442
+ getSessionReady,
443
+ getWaitingForAI,
444
+ syncActiveSession,
445
+ ensureSessionForConversation,
446
+ setActiveMsg,
447
+ setWaitingForAI,
448
+ setRelayTurnActive,
449
+ setLastActivityText,
450
+ setLastAskUserBridge,
451
+ setPendingAskUserRequest,
452
+ clearRelayScopeState,
453
+ shouldFetchPending = () => true,
454
+ handleControl,
455
+ }) {
456
+ let stopRequested = false;
457
+ let lastAbortControlCheckAt = 0;
458
+ let activeTurnMessageId = "";
459
+ let iterationPromise = null;
460
+ const isAutoRequestedModel = (value) => String(value || "").trim().toLowerCase() === "auto";
461
+ const requestedManualModelOrNull = (message) => {
462
+ const requested = String(message?.model || "").trim();
463
+ if (!requested || isAutoRequestedModel(requested)) return null;
464
+ return requested;
465
+ };
466
+ const resolveResponseModel = async (message, { finalEvent = null, unknownForAuto = false } = {}) => {
467
+ const eventModel = String(finalEvent?.data?.model || finalEvent?.data?.modelId || "").trim();
468
+ if (eventModel) return eventModel;
469
+ const currentModel = String(await getCurrentModelId() || "").trim();
470
+ if (currentModel) return currentModel;
471
+ const requestedManualModel = requestedManualModelOrNull(message);
472
+ if (requestedManualModel) return requestedManualModel;
473
+ if (unknownForAuto && isAutoRequestedModel(message?.model)) return "unknown";
474
+ return null;
475
+ };
476
+
477
+ async function waitForRelayQuestionAnswer(questionId, timeoutMs = DEFAULT_QUESTION_TIMEOUT_MS, pollIntervalMs = 1500) {
478
+ const started = Date.now();
479
+ while (true) {
480
+ const { question } = await api("GET", `/api/relay-question/${questionId}`);
481
+ if (!question) throw new Error("Relay question missing");
482
+ if (question.status === "answered") {
483
+ return {
484
+ answer: String(question.answer || "").trim(),
485
+ structuredAnswer: question.structuredAnswer && typeof question.structuredAnswer === "object"
486
+ ? question.structuredAnswer
487
+ : null,
488
+ timedOut: false,
489
+ };
490
+ }
491
+ if (question.status === "timed_out" || question.status === "cancelled") {
492
+ return {
493
+ answer: QUESTION_TIMEOUT_CONTINUATION_TEXT,
494
+ structuredAnswer: null,
495
+ timedOut: true,
496
+ };
497
+ }
498
+ if (Date.now() - started >= timeoutMs) {
499
+ await api("POST", `/api/relay-question/${questionId}/timeout`, {}).catch(() => {});
500
+ return {
501
+ answer: QUESTION_TIMEOUT_CONTINUATION_TEXT,
502
+ structuredAnswer: null,
503
+ timedOut: true,
504
+ };
505
+ }
506
+ await sleep(pollIntervalMs);
507
+ }
508
+ }
509
+
510
+ async function processPendingSdkSessionDeletes() {
511
+ const status = await api("GET", "/api/status").catch(() => null);
512
+ if (status?.relayPaused) return false;
513
+ const pending = await api("GET", "/api/sdk-session-delete/pending").catch(() => null);
514
+ const request = pending?.request || null;
515
+ return processSdkSessionDeleteRequest({ request, api, session, dbg });
516
+ }
517
+
518
+ async function checkActiveAbortControl(message, { force = false } = {}) {
519
+ const ownerSessionId = String(message?.ownerSessionId || "").trim();
520
+ if (!ownerSessionId || !getWaitingForAI()) return false;
521
+ const now = Date.now();
522
+ if (!force && (now - lastAbortControlCheckAt) < 1200) return false;
523
+ lastAbortControlCheckAt = now;
524
+ const queueMessageId = String(message?.id || "").trim();
525
+ const pending = await api("GET", `/api/control/active?sdkSessionId=${encodeURIComponent(ownerSessionId)}&queueMessageId=${encodeURIComponent(queueMessageId)}`).catch(() => null);
526
+ const control = pending?.control || null;
527
+ const controlType = String(control?.type || "").trim();
528
+ if (!control || !controlType) return false;
529
+
530
+ if (controlType === "abort_subagent") {
531
+ const targetRunId = String(control?.request?.subagentRunId || "").trim();
532
+ if (!targetRunId) {
533
+ await api("POST", `/api/control/${encodeURIComponent(control.id)}/result`, {
534
+ ok: false,
535
+ error: "invalid-subagent-control-request",
536
+ }).catch(() => {});
537
+ return false;
538
+ }
539
+
540
+ const tryAbortSubagent = async () => {
541
+ if (!session) return { ok: false, error: "SDK session is unavailable in this CLI runtime" };
542
+ const candidates = [
543
+ () => (typeof session.abortSubagentRun === "function" ? session.abortSubagentRun(targetRunId) : null),
544
+ () => (typeof session.abortSubagent === "function" ? session.abortSubagent(targetRunId) : null),
545
+ () => (typeof session.abortAgent === "function" ? session.abortAgent(targetRunId) : null),
546
+ () => (typeof session.abort === "function" && Number(session.abort.length || 0) > 0 ? session.abort({ subagentRunId: targetRunId }) : null),
547
+ () => (typeof session.abort === "function" && Number(session.abort.length || 0) > 0 ? session.abort({ agentId: targetRunId }) : null),
548
+ () => (typeof session.abort === "function" && Number(session.abort.length || 0) > 0 ? session.abort(targetRunId) : null),
549
+ ];
550
+ for (const invoke of candidates) {
551
+ try {
552
+ const result = invoke();
553
+ if (result && typeof result.then === "function") await result;
554
+ else if (result === null) continue;
555
+ return { ok: true };
556
+ } catch (error) {
557
+ const messageText = String(error?.message || error || "subagent abort failed").trim() || "subagent abort failed";
558
+ if (/is not a function/i.test(messageText)) continue;
559
+ return { ok: false, error: messageText };
560
+ }
561
+ }
562
+ return { ok: false, error: "Targeted subagent cancellation is not supported by this runtime." };
563
+ };
564
+
565
+ const abortResult = await tryAbortSubagent();
566
+ if (!abortResult.ok) {
567
+ await api("POST", `/api/control/${encodeURIComponent(control.id)}/result`, {
568
+ ok: false,
569
+ error: abortResult.error,
570
+ }).catch(() => {});
571
+ await session?.log?.(`⚠️ Subagent stop request could not be executed (${targetRunId.slice(0, 8)}): ${abortResult.error}`);
572
+ return false;
573
+ }
574
+
575
+ await session?.log?.(`⛔ Stop requested for subagent ${targetRunId.slice(0, 8)}`, { ephemeral: true });
576
+ await api("POST", `/api/control/${encodeURIComponent(control.id)}/result`, {
577
+ ok: true,
578
+ note: `targeted subagent abort completed (${targetRunId})`,
579
+ }).catch(() => {});
580
+ return false;
581
+ }
582
+
583
+ if (controlType !== "abort_turn") return false;
584
+
585
+ if (!session || typeof session.abort !== "function") {
586
+ const error = "SDK abort() is unavailable in this CLI runtime";
587
+ await api("POST", `/api/control/${encodeURIComponent(control.id)}/result`, {
588
+ ok: false,
589
+ error,
590
+ }).catch(() => {});
591
+ await session?.log?.(`⚠️ Stop request could not be executed: ${error}`);
592
+ return false;
593
+ }
594
+
595
+ await session.log(`⛔ Stop requested for relay turn ${String(message?.id || "").slice(0, 8)}`, { ephemeral: true });
596
+ await session.abort();
597
+ await api("POST", `/api/control/${encodeURIComponent(control.id)}/result`, {
598
+ ok: true,
599
+ note: "session.abort() completed",
600
+ }).catch(() => {});
601
+ const abortError = new Error("Relay turn aborted by user request");
602
+ abortError.code = "RELAY_TURN_ABORTED";
603
+ abortError.controlId = control.id;
604
+ throw abortError;
605
+ }
606
+
607
+ async function handlePendingPayload(pending, source = "poll") {
608
+ const pendingBlockedReason = String(pending?.routing?.blockedReason || "").trim();
609
+ const control = pending?.control || null;
610
+ if (control && typeof handleControl === "function") {
611
+ const handled = await handleControl(control, pending);
612
+ if (handled) return true;
613
+ }
614
+ const { message } = pending || {};
615
+
616
+ if (!message) return false;
617
+
618
+ activeTurnMessageId = String(message.id || "");
619
+ setActiveMsg(message);
620
+ if (typeof ensureSessionForConversation !== "function") {
621
+ dbg("session routing unavailable for msgId", message.id, "ensureSessionForConversation is not configured");
622
+ await session.log("⚠️ Session routing is unavailable for this turn");
623
+ await api("POST", "/api/response", {
624
+ messageId: message.id,
625
+ conversationId: message.conversationId,
626
+ text: "I couldn't process this turn because session routing is unavailable in the relay runtime. Please retry after the relay extension is fully initialized.",
627
+ model: await resolveResponseModel(message),
628
+ }).catch(async () => {
629
+ await api("POST", "/api/requeue", { messageId: message.id }).catch(() => {});
630
+ });
631
+ setActiveMsg(null);
632
+ return true;
633
+ }
634
+
635
+ const sessionResolution = await ensureSessionForConversation(message.conversationId, source);
636
+ if (sessionResolution && !sessionResolution.ok) {
637
+ const activeSdkSessionId = String(sessionResolution?.activeSessionId || "").trim();
638
+ const targetSdkSessionId = String(sessionResolution?.targetSessionId || "").trim();
639
+ const detail = String(sessionResolution?.message || "").trim();
640
+ const retryable = sessionResolution?.retryable === true;
641
+ dbg(
642
+ "session availability check failed for msgId",
643
+ message.id,
644
+ `reason=${sessionResolution.reason || "unknown"}`,
645
+ `active=${activeSdkSessionId || "none"}`,
646
+ `target=${targetSdkSessionId || "none"}`,
647
+ );
648
+ await session.log(
649
+ detail
650
+ ? `⚠️ Session unavailable for this turn: ${detail}`
651
+ : "⚠️ Session unavailable for this turn",
652
+ );
653
+ if (retryable) {
654
+ await api("POST", "/api/requeue", { messageId: message.id }).catch(() => {});
655
+ } else {
656
+ await api("POST", "/api/response", {
657
+ messageId: message.id,
658
+ conversationId: message.conversationId,
659
+ text: detail
660
+ ? `System note: I could not process this turn because the bound SDK session is unavailable (${detail}).`
661
+ : "System note: I could not process this turn because the bound SDK session is unavailable.",
662
+ model: await resolveResponseModel(message),
663
+ }).catch(async () => {
664
+ await api("POST", "/api/requeue", { messageId: message.id }).catch(() => {});
665
+ });
666
+ }
667
+ setActiveMsg(null);
668
+ return true;
669
+ }
670
+
671
+ const synced = await syncActiveSession?.(source, true);
672
+ if (!synced) {
673
+ dbg("session sync failed before processing msgId", message.id, "- requeueing");
674
+ await session.log("⚠️ Session sync failed before processing; re-queuing turn");
675
+ await api("POST", "/api/requeue", { messageId: message.id }).catch(() => {});
676
+ setActiveMsg(null);
677
+ return true;
678
+ }
679
+ setWaitingForAI(true);
680
+ setRelayTurnActive(true, message);
681
+ setLastActivityText("");
682
+ setLastAskUserBridge(null);
683
+ setPendingAskUserRequest?.(null);
684
+
685
+ const label = message.isNewConversation ? "new conv" : "existing conv";
686
+ await session.log(`📨 [${label}] Web message (${message.model || "default"}${message.reasoningEffort ? `:${message.reasoningEffort}` : ""} / ${message.relayMode || "agent"}): "${String(message.text || "").slice(0, 80)}"`);
687
+ dbg("session.send: queuing for msgId", message.id, `source=${source}`, pendingBlockedReason ? `blocked=${pendingBlockedReason}` : "");
688
+ let lastStreamedSent = "";
689
+ const pushRelayStream = async (text, done = false) => {
690
+ const value = String(text || "");
691
+ if (!value && !done) return;
692
+ if (!done && value === lastStreamedSent) return;
693
+ const publish = await publishRelayStreamEvent({
694
+ api,
695
+ message,
696
+ text: value,
697
+ done,
698
+ dbg,
699
+ });
700
+ if (!done && publish.ok) lastStreamedSent = value;
701
+ };
702
+ let sendAndWaitStartedAtMs = 0;
703
+ const processDirectOpenAIImageRequest = async () => {
704
+ if (!shouldUseDirectOpenAIImageApi(message)) return false;
705
+ if (message?.imageOperationId) {
706
+ const result = await api(
707
+ "POST",
708
+ `/api/image-operations/${encodeURIComponent(message.imageOperationId)}/execute`,
709
+ { messageId: message.id },
710
+ ).catch((error) => {
711
+ if (error?.status === 409 || error?.status === 503) {
712
+ return error?.payload || { outcome: error?.status === 503 ? "retryable" : "terminal" };
713
+ }
714
+ throw error;
715
+ });
716
+ const outcome = String(result?.outcome || "").trim().toLowerCase();
717
+ if (!["completed", "executing", "terminal", "uncertain", "retryable"].includes(outcome)) {
718
+ throw new Error("Image operation returned an invalid outcome");
719
+ }
720
+ await session.log(`Image operation ${outcome}`, {
721
+ ephemeral: true,
722
+ });
723
+ return true;
724
+ }
725
+ const directModel = String(message?.providerModel || requestedManualModelOrNull(message) || message?.model || "").trim();
726
+ const prompt = String(message?.text || "").trim();
727
+ if (!directModel) throw new Error("OpenAI image request is missing model");
728
+ if (!prompt) throw new Error("OpenAI image request is missing prompt");
729
+ await api("POST", "/api/activity", {
730
+ messageId: message.id,
731
+ conversationId: message.conversationId,
732
+ mode: message.relayMode || "agent",
733
+ text: `Calling OpenAI image API directly (${directModel})`,
734
+ }).catch(() => {});
735
+ let directResult;
736
+ try {
737
+ directResult = await api("POST", "/api/openai/images/generate", {
738
+ messageId: message.id,
739
+ conversationId: message.conversationId,
740
+ model: directModel,
741
+ prompt,
742
+ n: message?.n,
743
+ size: message?.size || message?.contextTier,
744
+ quality: message?.quality || message?.reasoningEffort,
745
+ attachments: Array.isArray(message?.attachments) ? message.attachments : [],
746
+ });
747
+ } catch (error) {
748
+ if (isDirectOpenAIImageTerminalStatus(error?.status)) {
749
+ const terminalError = new Error(
750
+ `OpenAI image request failed: ${error?.detail || `HTTP ${error?.status || "error"}`}`,
751
+ );
752
+ terminalError.code = "RELAY_OPENAI_IMAGE_TERMINAL";
753
+ throw terminalError;
754
+ }
755
+ throw error;
756
+ }
757
+ const generatedImages = Array.isArray(directResult?.generatedImages) ? directResult.generatedImages : [];
758
+ if (!generatedImages.length) {
759
+ throw new Error("OpenAI image API returned no images");
760
+ }
761
+ await api("POST", "/api/response", {
762
+ messageId: message.id,
763
+ conversationId: message.conversationId,
764
+ text: "",
765
+ generatedImages,
766
+ model: String(directResult?.model || directModel).trim() || directModel,
767
+ modelOrigin: isAutoRequestedModel(message?.model) ? "auto" : "manual",
768
+ });
769
+ await session.log(`✅ Sent generated image response (${generatedImages.length} image${generatedImages.length === 1 ? "" : "s"})`, { ephemeral: true });
770
+ return true;
771
+ };
772
+
773
+ try {
774
+ const handledWithDirectImageApi = await processDirectOpenAIImageRequest();
775
+ if (handledWithDirectImageApi) return true;
776
+
777
+ if (message.model && !isAutoRequestedModel(message.model)) {
778
+ const modelSwitch = await setModelForMessage(message.model, message.contextTier);
779
+ const activeModel = modelSwitch.after || modelSwitch.current || "unknown";
780
+ const switchText = modelSwitch.confirmationPending
781
+ ? `Model switch requested: requested=${message.model} active=pending target=${activeModel} via=${modelSwitch.via || "switchTo"}`
782
+ : modelSwitch.switched
783
+ ? `Model selected: requested=${message.model} active=${activeModel} via=${modelSwitch.via || "switchTo"}`
784
+ : `Model switch failed: requested=${message.model} active=${activeModel}${modelSwitch.error ? ` error=${modelSwitch.error}` : ""}`;
785
+ await publishModelSnapshot("model-switch", true);
786
+ await api("POST", "/api/activity", {
787
+ messageId: message.id,
788
+ conversationId: message.conversationId,
789
+ mode: message.relayMode || "agent",
790
+ text: switchText,
791
+ }).catch(() => {});
792
+ dbg("model switch", switchText);
793
+ } else if (isAutoRequestedModel(message.model)) {
794
+ await api("POST", "/api/activity", {
795
+ messageId: message.id,
796
+ conversationId: message.conversationId,
797
+ mode: message.relayMode || "agent",
798
+ text: "Model selection: Auto routing is active for this new SDK session",
799
+ }).catch(() => {});
800
+ }
801
+
802
+ const prompt = await buildPromptWithRelayContext(message);
803
+
804
+ const sdkAttachments = buildSdkAttachments(message.attachments);
805
+ const payload = sdkAttachments.length ? { prompt, attachments: sdkAttachments } : { prompt };
806
+ if (message.reasoningEffort && String(message.reasoningEffort || "").trim().toLowerCase() !== "none") {
807
+ payload.reasoningEffort = String(message.reasoningEffort || "").trim();
808
+ }
809
+ if (sdkAttachments.length) {
810
+ const imageCount = sdkAttachments.filter((att) => att.type === "blob").length;
811
+ const fileCount = sdkAttachments.filter((att) => att.type === "file").length;
812
+ dbg("sdk attachments prepared", `msgId=${message.id}`, `total=${sdkAttachments.length}`, `images=${imageCount}`, `files=${fileCount}`);
813
+ await api("POST", "/api/activity", {
814
+ messageId: message.id,
815
+ conversationId: message.conversationId,
816
+ mode: message.relayMode || "agent",
817
+ text: `Attached ${sdkAttachments.length} file(s) to SDK request${imageCount ? ` (images=${imageCount})` : ""}${fileCount ? ` (files=${fileCount})` : ""}.`,
818
+ }).catch(() => {});
819
+ }
820
+
821
+ let finalEvent;
822
+ let lastWorkerStatusCheckAt = 0;
823
+ const inspectActiveWorkerLiveness = async () => {
824
+ await checkActiveAbortControl(message, { force: true });
825
+ const ownerSessionId = String(message?.ownerSessionId || "").trim();
826
+ if (!ownerSessionId) return;
827
+ const now = Date.now();
828
+ if ((now - lastWorkerStatusCheckAt) < 10_000) return;
829
+ lastWorkerStatusCheckAt = now;
830
+
831
+ const status = await api("GET", "/api/status").catch(() => null);
832
+ const workers = Array.isArray(status?.sessionWorker?.workers) ? status.sessionWorker.workers : [];
833
+ const worker = workers.find((entry) => String(entry?.sdkSessionId || "").trim() === ownerSessionId) || null;
834
+ if (!worker) {
835
+ throw Object.assign(new Error("Active session worker is missing from relay status"), {
836
+ code: "RELAY_WORKER_UNAVAILABLE",
837
+ terminalFailure: buildWorkerLivenessTerminalFailure({
838
+ message,
839
+ ownerSessionId,
840
+ issueReason: "worker-missing",
841
+ detail: "No worker snapshot was reported for the owning session.",
842
+ }),
843
+ });
844
+ }
845
+
846
+ const degraded = String(worker?.uiState || "").trim().toLowerCase() === "yellow";
847
+ const degradedReason = String(worker?.degradedReason || "").trim().toLowerCase();
848
+ const routingMismatch = (
849
+ (worker?.conversationId && String(worker.conversationId).trim() !== String(message?.conversationId || "").trim())
850
+ || (message?.runtimeSessionId && worker?.runtimeSessionId && String(worker.runtimeSessionId).trim() !== String(message.runtimeSessionId).trim())
851
+ );
852
+ if (!degraded && !routingMismatch) return;
853
+
854
+ const issueReason = routingMismatch
855
+ ? "worker-routing-mismatch"
856
+ : (degradedReason || "worker-degraded");
857
+ const detail = routingMismatch
858
+ ? `workerConversation=${String(worker?.conversationId || "").trim() || "none"} workerRuntime=${String(worker?.runtimeSessionId || "").trim() || "none"}`
859
+ : String(worker?.lastError || worker?.degradedReason || "").trim();
860
+ throw Object.assign(new Error(`Active session worker became unavailable (${issueReason})`), {
861
+ code: "RELAY_WORKER_UNAVAILABLE",
862
+ terminalFailure: buildWorkerLivenessTerminalFailure({
863
+ message,
864
+ ownerSessionId,
865
+ issueReason,
866
+ detail,
867
+ }),
868
+ });
869
+ };
870
+ const sendWithoutStreaming = async (sendPayload) => {
871
+ const turnPromise = Promise.resolve().then(() => sendAndWaitWithHardTimeout(sendPayload, sendTimeout));
872
+ while (true) {
873
+ const outcome = await Promise.race([
874
+ turnPromise.then((value) => ({ done: true, value })),
875
+ sleep(1000).then(() => ({ done: false })),
876
+ ]);
877
+ if (outcome.done) return outcome.value;
878
+ await inspectActiveWorkerLiveness();
879
+ }
880
+ };
881
+ try {
882
+ sendAndWaitStartedAtMs = Date.now();
883
+ finalEvent = await sendWithoutStreaming(payload);
884
+ } catch (attachmentError) {
885
+ if (!sdkAttachments.length) throw attachmentError;
886
+ dbg("sdk attachment delivery failed", `msgId=${message.id}`, attachmentError?.message || String(attachmentError));
887
+ await api("POST", "/api/activity", {
888
+ messageId: message.id,
889
+ conversationId: message.conversationId,
890
+ mode: message.relayMode || "agent",
891
+ text: `Attachment delivery failed (${attachmentError?.message || "unknown error"}). Retrying without SDK attachments.`,
892
+ }).catch(() => {});
893
+ finalEvent = await sendWithoutStreaming({ prompt });
894
+ }
895
+ const sendAndWaitDurationMs = sendAndWaitStartedAtMs > 0 ? Math.max(0, Date.now() - sendAndWaitStartedAtMs) : null;
896
+ dbg("session.sendAndWait: completed for msgId", message.id, sendAndWaitDurationMs ? `durationMs=${sendAndWaitDurationMs}` : "");
897
+
898
+ const text = stripPromptContextPrefix(extractFinalText(finalEvent), message, "", prompt);
899
+ const generatedImagePayload = extractGeneratedImages(finalEvent);
900
+ const generatedImages = Array.isArray(generatedImagePayload) ? generatedImagePayload : [];
901
+ const model = await resolveResponseModel(message, { finalEvent, unknownForAuto: true });
902
+ const boardPayload = buildPlanReadyBoardPayload({
903
+ finalEvent,
904
+ message,
905
+ finalText: text,
906
+ });
907
+ if (boardPayload) {
908
+ try {
909
+ await api("POST", "/api/relay-board", boardPayload);
910
+ } catch (boardError) {
911
+ dbg("plan board publish failed", `msgId=${message.id}`, boardError?.message || String(boardError));
912
+ }
913
+ }
914
+
915
+ if (!text) {
916
+ const emptyHandling = resolveEmptyFinalTextHandling({
917
+ lastStreamedSent,
918
+ lastActivityText: String(getLastActivityText?.() || ""),
919
+ hasGeneratedImages: generatedImages.length > 0,
920
+ });
921
+ if (emptyHandling.action === "use_stream_text") {
922
+ const streamedText = String(emptyHandling.text || "");
923
+ dbg("sendAndWait returned empty content; finalizing from streamed text msgId", message.id, `len=${streamedText.length}`);
924
+ await session.log("⚠️ Empty final envelope text — using streamed text as final reply");
925
+ await pushRelayStream(streamedText, true);
926
+ await api("POST", "/api/response", {
927
+ messageId: message.id,
928
+ conversationId: message.conversationId,
929
+ text: streamedText,
930
+ generatedImages,
931
+ model,
932
+ modelOrigin: isAutoRequestedModel(message?.model) ? "auto" : "manual",
933
+ });
934
+ } else if (emptyHandling.action === "publish_generated_images_only") {
935
+ dbg("sendAndWait returned empty text but generated images were captured; finalizing msgId", message.id, `images=${generatedImages.length}`);
936
+ await api("POST", "/api/response", {
937
+ messageId: message.id,
938
+ conversationId: message.conversationId,
939
+ text: "",
940
+ generatedImages,
941
+ model,
942
+ modelOrigin: isAutoRequestedModel(message?.model) ? "auto" : "manual",
943
+ });
944
+ } else if (emptyHandling.action === "publish_completion_note") {
945
+ dbg("sendAndWait returned empty content after tool activity; publishing completion note msgId", message.id, emptyHandling.reason || "");
946
+ await api("POST", "/api/response", {
947
+ messageId: message.id,
948
+ conversationId: message.conversationId,
949
+ text: EMPTY_TURN_COMPLETION_NOTE,
950
+ generatedImages,
951
+ model,
952
+ modelOrigin: isAutoRequestedModel(message?.model) ? "auto" : "manual",
953
+ });
954
+ } else {
955
+ dbg("sendAndWait returned empty content; re-queueing msgId", message.id, emptyHandling.reason || "empty-final-text");
956
+ await session.log("⚠️ Empty assistant response envelope — re-queuing instead of sending fallback");
957
+ await api("POST", "/api/requeue", { messageId: message.id }).catch(() => {});
958
+ }
959
+ } else {
960
+ await pushRelayStream(text || lastStreamedSent, true);
961
+ await api("POST", "/api/response", {
962
+ messageId: message.id,
963
+ conversationId: message.conversationId,
964
+ text,
965
+ generatedImages,
966
+ model,
967
+ modelOrigin: isAutoRequestedModel(message?.model) ? "auto" : "manual",
968
+ });
969
+ await session.log(`✅ Sent response to web (${text.length} chars)`, { ephemeral: true });
970
+ }
971
+ } catch (e) {
972
+ const terminalFailure = normalizeTerminalSendAndWaitError(e);
973
+ dbg(
974
+ "sendAndWait ERROR for msgId",
975
+ message.id,
976
+ ":",
977
+ e.message,
978
+ `terminal=${terminalFailure ? "yes" : "no"}`,
979
+ `stableCode=${terminalFailure?.stableCode || "none"}`,
980
+ );
981
+ if (String(e?.code || "").trim() === "RELAY_TURN_ABORTED") {
982
+ await pushRelayStream(lastStreamedSent, true);
983
+ } else if (isTerminalSendAndWaitError(e)) {
984
+ const failureText = buildTerminalFailureText(e);
985
+ await session.log("❌ Terminal SDK/tool-output error — marking turn failed", { level: "error" }).catch((logError) => {
986
+ dbg("session.log failed while reporting terminal error", message.id, logError?.message || String(logError));
987
+ });
988
+ await pushRelayStream(lastStreamedSent, true);
989
+ await api("POST", "/api/response", {
990
+ messageId: message.id,
991
+ conversationId: message.conversationId,
992
+ text: failureText,
993
+ terminalError: terminalFailure || undefined,
994
+ model: await resolveResponseModel(message, { unknownForAuto: true }),
995
+ }).catch(async (responseError) => {
996
+ dbg("terminal response publish failed for msgId", message.id, responseError?.message || String(responseError));
997
+ await api("POST", "/api/requeue", { messageId: message.id }).catch(() => {});
998
+ });
999
+ } else if (e?.code === "RELAY_WORKER_UNAVAILABLE" && e?.terminalFailure) {
1000
+ await session.log("⚠️ Session worker became unavailable during the turn — marking it failed").catch((logError) => {
1001
+ dbg("session.log failed while reporting worker-unavailable", message.id, logError?.message || String(logError));
1002
+ });
1003
+ await pushRelayStream(lastStreamedSent, true);
1004
+ await api("POST", "/api/response", {
1005
+ messageId: message.id,
1006
+ conversationId: message.conversationId,
1007
+ terminalError: e.terminalFailure,
1008
+ model: await resolveResponseModel(message, { unknownForAuto: true }),
1009
+ }).catch(async () => {
1010
+ await api("POST", "/api/requeue", { messageId: message.id }).catch(() => {});
1011
+ });
1012
+ } else if (String(e?.code || "").trim() === "RELAY_OPENAI_IMAGE_TERMINAL") {
1013
+ await pushRelayStream(lastStreamedSent, true);
1014
+ await api("POST", "/api/response", {
1015
+ messageId: message.id,
1016
+ conversationId: message.conversationId,
1017
+ text: String(e?.message || "OpenAI image request failed").trim(),
1018
+ model: await resolveResponseModel(message, { unknownForAuto: true }),
1019
+ }).catch(async () => {
1020
+ await api("POST", "/api/requeue", { messageId: message.id }).catch(() => {});
1021
+ });
1022
+ } else {
1023
+ await session.log(`❌ Response failed: ${e.message}; re-queuing`, { level: "error" }).catch((logError) => {
1024
+ dbg("session.log failed while reporting generic send error", message.id, logError?.message || String(logError));
1025
+ });
1026
+ api("POST", "/api/requeue", { messageId: message.id }).catch(() => {});
1027
+ }
1028
+ } finally {
1029
+ setLastActivityText("");
1030
+ setPendingAskUserRequest?.(null);
1031
+ clearRelayScopeState?.();
1032
+ setRelayTurnActive(false, message);
1033
+ setActiveMsg(null);
1034
+ setWaitingForAI(false);
1035
+ activeTurnMessageId = "";
1036
+ }
1037
+ return true;
1038
+ }
1039
+
1040
+ async function runPollingIteration() {
1041
+ if (!getSessionReady()) return;
1042
+ try {
1043
+ await api("POST", "/api/heartbeat", {
1044
+ activeQueueMessageId: getWaitingForAI() ? activeTurnMessageId || undefined : undefined,
1045
+ });
1046
+ await publishModelSnapshot("poll");
1047
+
1048
+ if (getWaitingForAI()) return;
1049
+ // Keep SDK-session-delete maintenance best-effort only; never starve
1050
+ // user turn dequeue when delete requests are backlogged/retrying.
1051
+ await processPendingSdkSessionDeletes();
1052
+ if (!shouldFetchPending()) return;
1053
+
1054
+ const pending = await api("GET", "/api/pending");
1055
+ await handlePendingPayload(pending, "poll");
1056
+ } catch (error) {
1057
+ dbg("runPollingIteration failed", error?.message || String(error));
1058
+ // Server may be down — keep retrying silently
1059
+ }
1060
+ }
1061
+
1062
+ async function runPollingIterationSerialized() {
1063
+ if (iterationPromise) return iterationPromise;
1064
+ iterationPromise = Promise.resolve()
1065
+ .then(() => runPollingIteration())
1066
+ .finally(() => {
1067
+ iterationPromise = null;
1068
+ });
1069
+ return iterationPromise;
1070
+ }
1071
+
1072
+ async function startPolling() {
1073
+ if (getPollingLoopStarted()) return;
1074
+ setPollingLoopStarted(true);
1075
+ stopRequested = false;
1076
+ dbg("startPolling: entered");
1077
+ await session.log("🔄 Polling started", { ephemeral: true });
1078
+
1079
+ while (!stopRequested) {
1080
+ await sleep(pollMs);
1081
+ if (stopRequested) break;
1082
+ await runPollingIterationSerialized();
1083
+ }
1084
+
1085
+ setPollingLoopStarted(false);
1086
+ dbg("startPolling: exited");
1087
+ }
1088
+
1089
+ async function kick() {
1090
+ if (stopRequested || !getPollingLoopStarted()) return false;
1091
+ await runPollingIterationSerialized();
1092
+ return true;
1093
+ }
1094
+
1095
+ function stopPolling() {
1096
+ stopRequested = true;
1097
+ }
1098
+
1099
+ return {
1100
+ handlePendingPayload,
1101
+ startPolling,
1102
+ kick,
1103
+ stopPolling,
1104
+ };
1105
+ }