mstro-app 0.5.1 → 0.5.6

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 (283) hide show
  1. package/PRIVACY.md +9 -9
  2. package/README.md +71 -28
  3. package/bin/commands/config.js +1 -1
  4. package/bin/mstro.js +55 -4
  5. package/dist/server/cli/eta-estimator.d.ts +55 -0
  6. package/dist/server/cli/eta-estimator.d.ts.map +1 -0
  7. package/dist/server/cli/eta-estimator.js +222 -0
  8. package/dist/server/cli/eta-estimator.js.map +1 -0
  9. package/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -1
  10. package/dist/server/cli/headless/claude-invoker-process.js +9 -1
  11. package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
  12. package/dist/server/cli/headless/mcp-config.d.ts +22 -5
  13. package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
  14. package/dist/server/cli/headless/mcp-config.js +7 -5
  15. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  16. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  17. package/dist/server/cli/headless/runner.js +19 -0
  18. package/dist/server/cli/headless/runner.js.map +1 -1
  19. package/dist/server/cli/headless/stall-assessor.d.ts +50 -0
  20. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  21. package/dist/server/cli/headless/stall-assessor.js +64 -9
  22. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  23. package/dist/server/cli/headless/tool-watchdog.d.ts +21 -0
  24. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  25. package/dist/server/cli/headless/tool-watchdog.js +19 -12
  26. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  27. package/dist/server/cli/headless/types.d.ts +16 -1
  28. package/dist/server/cli/headless/types.d.ts.map +1 -1
  29. package/dist/server/cli/improvisation-history-store.d.ts.map +1 -1
  30. package/dist/server/cli/improvisation-history-store.js +5 -1
  31. package/dist/server/cli/improvisation-history-store.js.map +1 -1
  32. package/dist/server/cli/improvisation-output-queue.d.ts +5 -1
  33. package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -1
  34. package/dist/server/cli/improvisation-output-queue.js +30 -7
  35. package/dist/server/cli/improvisation-output-queue.js.map +1 -1
  36. package/dist/server/cli/improvisation-session-manager.d.ts +35 -0
  37. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  38. package/dist/server/cli/improvisation-session-manager.js +58 -1
  39. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  40. package/dist/server/cli/improvisation-types.d.ts +9 -0
  41. package/dist/server/cli/improvisation-types.d.ts.map +1 -1
  42. package/dist/server/cli/improvisation-types.js.map +1 -1
  43. package/dist/server/cli/retry/retry-runner-factory.d.ts.map +1 -1
  44. package/dist/server/cli/retry/retry-runner-factory.js +1 -0
  45. package/dist/server/cli/retry/retry-runner-factory.js.map +1 -1
  46. package/dist/server/engines/EngineEvent.d.ts +126 -0
  47. package/dist/server/engines/EngineEvent.d.ts.map +1 -0
  48. package/dist/server/engines/EngineEvent.js +11 -0
  49. package/dist/server/engines/EngineEvent.js.map +1 -0
  50. package/dist/server/engines/claude/ClaudeCodeEngine.d.ts +47 -0
  51. package/dist/server/engines/claude/ClaudeCodeEngine.d.ts.map +1 -0
  52. package/dist/server/engines/claude/ClaudeCodeEngine.js +338 -0
  53. package/dist/server/engines/claude/ClaudeCodeEngine.js.map +1 -0
  54. package/dist/server/engines/factory.d.ts +21 -0
  55. package/dist/server/engines/factory.d.ts.map +1 -0
  56. package/dist/server/engines/factory.js +152 -0
  57. package/dist/server/engines/factory.js.map +1 -0
  58. package/dist/server/engines/opencode/OpenCodeEngine.d.ts +148 -0
  59. package/dist/server/engines/opencode/OpenCodeEngine.d.ts.map +1 -0
  60. package/dist/server/engines/opencode/OpenCodeEngine.js +630 -0
  61. package/dist/server/engines/opencode/OpenCodeEngine.js.map +1 -0
  62. package/dist/server/engines/opencode/OpenCodeServerManager.d.ts +172 -0
  63. package/dist/server/engines/opencode/OpenCodeServerManager.d.ts.map +1 -0
  64. package/dist/server/engines/opencode/OpenCodeServerManager.js +390 -0
  65. package/dist/server/engines/opencode/OpenCodeServerManager.js.map +1 -0
  66. package/dist/server/engines/opencode/model-catalog.d.ts +94 -0
  67. package/dist/server/engines/opencode/model-catalog.d.ts.map +1 -0
  68. package/dist/server/engines/opencode/model-catalog.js +141 -0
  69. package/dist/server/engines/opencode/model-catalog.js.map +1 -0
  70. package/dist/server/engines/types.d.ts +146 -0
  71. package/dist/server/engines/types.d.ts.map +1 -0
  72. package/dist/server/engines/types.js +4 -0
  73. package/dist/server/engines/types.js.map +1 -0
  74. package/dist/server/index.js +9 -2
  75. package/dist/server/index.js.map +1 -1
  76. package/dist/server/mcp/bouncer-haiku.d.ts +17 -4
  77. package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
  78. package/dist/server/mcp/bouncer-haiku.js +8 -124
  79. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  80. package/dist/server/mcp/bouncer-integration.d.ts +45 -0
  81. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  82. package/dist/server/mcp/bouncer-integration.js +69 -5
  83. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  84. package/dist/server/mcp/classifier/BouncerClassifier.d.ts +34 -0
  85. package/dist/server/mcp/classifier/BouncerClassifier.d.ts.map +1 -0
  86. package/dist/server/mcp/classifier/BouncerClassifier.js +4 -0
  87. package/dist/server/mcp/classifier/BouncerClassifier.js.map +1 -0
  88. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts +17 -0
  89. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts.map +1 -0
  90. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +142 -0
  91. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js.map +1 -0
  92. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts +68 -0
  93. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts.map +1 -0
  94. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js +182 -0
  95. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js.map +1 -0
  96. package/dist/server/mcp/classifier/factory.d.ts +70 -0
  97. package/dist/server/mcp/classifier/factory.d.ts.map +1 -0
  98. package/dist/server/mcp/classifier/factory.js +155 -0
  99. package/dist/server/mcp/classifier/factory.js.map +1 -0
  100. package/dist/server/mcp/server.js +52 -0
  101. package/dist/server/mcp/server.js.map +1 -1
  102. package/dist/server/routes/index.d.ts +1 -0
  103. package/dist/server/routes/index.d.ts.map +1 -1
  104. package/dist/server/routes/index.js +1 -0
  105. package/dist/server/routes/index.js.map +1 -1
  106. package/dist/server/routes/internal.d.ts +16 -0
  107. package/dist/server/routes/internal.d.ts.map +1 -0
  108. package/dist/server/routes/internal.js +94 -0
  109. package/dist/server/routes/internal.js.map +1 -0
  110. package/dist/server/services/plan/agent-resolver.d.ts +26 -0
  111. package/dist/server/services/plan/agent-resolver.d.ts.map +1 -0
  112. package/dist/server/services/plan/agent-resolver.js +102 -0
  113. package/dist/server/services/plan/agent-resolver.js.map +1 -0
  114. package/dist/server/services/plan/composer.d.ts.map +1 -1
  115. package/dist/server/services/plan/composer.js +59 -11
  116. package/dist/server/services/plan/composer.js.map +1 -1
  117. package/dist/server/services/plan/executor.d.ts.map +1 -1
  118. package/dist/server/services/plan/executor.js +3 -1
  119. package/dist/server/services/plan/executor.js.map +1 -1
  120. package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
  121. package/dist/server/services/plan/issue-prompt-builder.js +33 -1
  122. package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
  123. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  124. package/dist/server/services/plan/parser-core.js +1 -0
  125. package/dist/server/services/plan/parser-core.js.map +1 -1
  126. package/dist/server/services/plan/types.d.ts +1 -0
  127. package/dist/server/services/plan/types.d.ts.map +1 -1
  128. package/dist/server/services/runtime-info.d.ts +3 -0
  129. package/dist/server/services/runtime-info.d.ts.map +1 -0
  130. package/dist/server/services/runtime-info.js +21 -0
  131. package/dist/server/services/runtime-info.js.map +1 -0
  132. package/dist/server/services/settings.d.ts +76 -2
  133. package/dist/server/services/settings.d.ts.map +1 -1
  134. package/dist/server/services/settings.js +127 -4
  135. package/dist/server/services/settings.js.map +1 -1
  136. package/dist/server/services/websocket/ask-user-question-bridge.d.ts +32 -0
  137. package/dist/server/services/websocket/ask-user-question-bridge.d.ts.map +1 -0
  138. package/dist/server/services/websocket/ask-user-question-bridge.js +115 -0
  139. package/dist/server/services/websocket/ask-user-question-bridge.js.map +1 -0
  140. package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
  141. package/dist/server/services/websocket/git-branch-handlers.js +19 -6
  142. package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
  143. package/dist/server/services/websocket/handler.d.ts +25 -1
  144. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  145. package/dist/server/services/websocket/handler.js +84 -2
  146. package/dist/server/services/websocket/handler.js.map +1 -1
  147. package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -1
  148. package/dist/server/services/websocket/quality-complexity.js +78 -26
  149. package/dist/server/services/websocket/quality-complexity.js.map +1 -1
  150. package/dist/server/services/websocket/quality-eta.d.ts +47 -0
  151. package/dist/server/services/websocket/quality-eta.d.ts.map +1 -0
  152. package/dist/server/services/websocket/quality-eta.js +110 -0
  153. package/dist/server/services/websocket/quality-eta.js.map +1 -0
  154. package/dist/server/services/websocket/quality-grading.d.ts +27 -4
  155. package/dist/server/services/websocket/quality-grading.d.ts.map +1 -1
  156. package/dist/server/services/websocket/quality-grading.js +369 -201
  157. package/dist/server/services/websocket/quality-grading.js.map +1 -1
  158. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  159. package/dist/server/services/websocket/quality-handlers.js +145 -7
  160. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  161. package/dist/server/services/websocket/quality-operations.d.ts +34 -0
  162. package/dist/server/services/websocket/quality-operations.d.ts.map +1 -0
  163. package/dist/server/services/websocket/quality-operations.js +47 -0
  164. package/dist/server/services/websocket/quality-operations.js.map +1 -0
  165. package/dist/server/services/websocket/quality-persistence.d.ts +9 -0
  166. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  167. package/dist/server/services/websocket/quality-persistence.js +10 -0
  168. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  169. package/dist/server/services/websocket/quality-review-agent.d.ts +1 -1
  170. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  171. package/dist/server/services/websocket/quality-review-agent.js +105 -56
  172. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  173. package/dist/server/services/websocket/quality-service.d.ts +9 -1
  174. package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
  175. package/dist/server/services/websocket/quality-service.js +334 -14
  176. package/dist/server/services/websocket/quality-service.js.map +1 -1
  177. package/dist/server/services/websocket/quality-tools.d.ts +21 -0
  178. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  179. package/dist/server/services/websocket/quality-tools.js +49 -0
  180. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  181. package/dist/server/services/websocket/quality-types.d.ts +35 -2
  182. package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
  183. package/dist/server/services/websocket/quality-types.js +1 -1
  184. package/dist/server/services/websocket/quality-types.js.map +1 -1
  185. package/dist/server/services/websocket/session-handlers.d.ts +3 -1
  186. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  187. package/dist/server/services/websocket/session-handlers.js +60 -9
  188. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  189. package/dist/server/services/websocket/session-history.js +3 -0
  190. package/dist/server/services/websocket/session-history.js.map +1 -1
  191. package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
  192. package/dist/server/services/websocket/session-initialization.js +158 -42
  193. package/dist/server/services/websocket/session-initialization.js.map +1 -1
  194. package/dist/server/services/websocket/session-registry.d.ts +25 -0
  195. package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
  196. package/dist/server/services/websocket/session-registry.js +19 -0
  197. package/dist/server/services/websocket/session-registry.js.map +1 -1
  198. package/dist/server/services/websocket/settings-handlers.d.ts +1 -1
  199. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
  200. package/dist/server/services/websocket/settings-handlers.js +35 -4
  201. package/dist/server/services/websocket/settings-handlers.js.map +1 -1
  202. package/dist/server/services/websocket/tab-broadcast.d.ts +7 -2
  203. package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -1
  204. package/dist/server/services/websocket/tab-broadcast.js +10 -2
  205. package/dist/server/services/websocket/tab-broadcast.js.map +1 -1
  206. package/dist/server/services/websocket/tab-event-buffer.d.ts +97 -8
  207. package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -1
  208. package/dist/server/services/websocket/tab-event-buffer.js +138 -12
  209. package/dist/server/services/websocket/tab-event-buffer.js.map +1 -1
  210. package/dist/server/services/websocket/tab-event-replay.d.ts +29 -13
  211. package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -1
  212. package/dist/server/services/websocket/tab-event-replay.js +55 -2
  213. package/dist/server/services/websocket/tab-event-replay.js.map +1 -1
  214. package/dist/server/services/websocket/tab-handlers.d.ts +9 -1
  215. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  216. package/dist/server/services/websocket/tab-handlers.js +47 -2
  217. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  218. package/dist/server/services/websocket/types.d.ts +67 -7
  219. package/dist/server/services/websocket/types.d.ts.map +1 -1
  220. package/dist/server/services/websocket/types.js +12 -6
  221. package/dist/server/services/websocket/types.js.map +1 -1
  222. package/package.json +5 -3
  223. package/server/cli/eta-estimator.ts +249 -0
  224. package/server/cli/headless/claude-invoker-process.ts +9 -1
  225. package/server/cli/headless/mcp-config.ts +30 -5
  226. package/server/cli/headless/runner.ts +21 -0
  227. package/server/cli/headless/stall-assessor.ts +93 -0
  228. package/server/cli/headless/tool-watchdog.ts +21 -0
  229. package/server/cli/headless/types.ts +16 -1
  230. package/server/cli/improvisation-history-store.ts +4 -1
  231. package/server/cli/improvisation-output-queue.ts +29 -7
  232. package/server/cli/improvisation-session-manager.ts +63 -1
  233. package/server/cli/improvisation-types.ts +9 -0
  234. package/server/cli/retry/retry-runner-factory.ts +1 -0
  235. package/server/engines/EngineEvent.ts +156 -0
  236. package/server/engines/claude/ClaudeCodeEngine.ts +404 -0
  237. package/server/engines/factory.ts +176 -0
  238. package/server/engines/opencode/OpenCodeEngine.ts +786 -0
  239. package/server/engines/opencode/OpenCodeServerManager.ts +577 -0
  240. package/server/engines/opencode/model-catalog.ts +217 -0
  241. package/server/engines/types.ts +173 -0
  242. package/server/index.ts +9 -1
  243. package/server/mcp/bouncer-haiku.ts +21 -145
  244. package/server/mcp/bouncer-integration.ts +107 -5
  245. package/server/mcp/classifier/BouncerClassifier.ts +40 -0
  246. package/server/mcp/classifier/ClaudeBouncerClassifier.ts +189 -0
  247. package/server/mcp/classifier/OpenCodeBouncerClassifier.ts +305 -0
  248. package/server/mcp/classifier/factory.ts +195 -0
  249. package/server/mcp/server.ts +57 -0
  250. package/server/routes/index.ts +1 -0
  251. package/server/routes/internal.ts +112 -0
  252. package/server/services/plan/agent-resolver.ts +115 -0
  253. package/server/services/plan/agents/code-review.md +38 -8
  254. package/server/services/plan/composer.ts +63 -11
  255. package/server/services/plan/executor.ts +3 -1
  256. package/server/services/plan/issue-prompt-builder.ts +39 -1
  257. package/server/services/plan/parser-core.ts +1 -0
  258. package/server/services/plan/types.ts +4 -0
  259. package/server/services/runtime-info.ts +24 -0
  260. package/server/services/settings.ts +161 -4
  261. package/server/services/websocket/ask-user-question-bridge.ts +148 -0
  262. package/server/services/websocket/git-branch-handlers.ts +20 -6
  263. package/server/services/websocket/handler.ts +89 -2
  264. package/server/services/websocket/quality-complexity.ts +80 -26
  265. package/server/services/websocket/quality-eta.ts +155 -0
  266. package/server/services/websocket/quality-grading.ts +445 -222
  267. package/server/services/websocket/quality-handlers.ts +153 -7
  268. package/server/services/websocket/quality-operations.ts +72 -0
  269. package/server/services/websocket/quality-persistence.ts +17 -0
  270. package/server/services/websocket/quality-review-agent.ts +154 -64
  271. package/server/services/websocket/quality-service.ts +361 -13
  272. package/server/services/websocket/quality-tools.ts +51 -0
  273. package/server/services/websocket/quality-types.ts +41 -2
  274. package/server/services/websocket/session-handlers.ts +67 -10
  275. package/server/services/websocket/session-history.ts +3 -0
  276. package/server/services/websocket/session-initialization.ts +189 -46
  277. package/server/services/websocket/session-registry.ts +37 -0
  278. package/server/services/websocket/settings-handlers.ts +41 -4
  279. package/server/services/websocket/tab-broadcast.ts +10 -2
  280. package/server/services/websocket/tab-event-buffer.ts +143 -11
  281. package/server/services/websocket/tab-event-replay.ts +70 -3
  282. package/server/services/websocket/tab-handlers.ts +53 -5
  283. package/server/services/websocket/types.ts +85 -7
@@ -0,0 +1,786 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * OpenCodeEngine
6
+ *
7
+ * Adapter that wraps the OpenCode SDK (@opencode-ai/sdk) behind the
8
+ * CodingAgentEngine interface. Owns a single OpenCode `session` per Mstro
9
+ * improvisation:
10
+ *
11
+ * - `startSession` creates (or resumes) an OpenCode session and opens an
12
+ * SSE subscription to `/event`. A background pump consumes the stream
13
+ * and translates each payload into the engine-agnostic `EngineEvent`
14
+ * shape so the rest of the system does not need to know which engine
15
+ * produced the events.
16
+ * - `sendPrompt` dispatches `session.promptAsync` and resolves as soon as
17
+ * the server has accepted the prompt. Streaming output arrives via SSE.
18
+ * - `cancel` calls `session.abort`, which the OpenCode server eventually
19
+ * reflects back as a `session.idle` event.
20
+ * - `dispose` stops the pump and deletes the underlying session.
21
+ *
22
+ * The concrete SSE → EngineEvent mapping is documented inline in
23
+ * `handleSseEvent`.
24
+ */
25
+
26
+ import type {
27
+ AssistantMessage,
28
+ Message,
29
+ OpencodeClient,
30
+ Part,
31
+ Permission,
32
+ ReasoningPart,
33
+ Event as SseEvent,
34
+ StepFinishPart,
35
+ TextPart,
36
+ ToolPart,
37
+ } from '@opencode-ai/sdk'
38
+ import {
39
+ type BouncerDecision,
40
+ type EnginePermissionReviewRequest,
41
+ formatDenialMessage,
42
+ reviewEnginePermission,
43
+ } from '../../mcp/bouncer-integration.js'
44
+ import type { EngineEvent, EngineId } from '../EngineEvent.js'
45
+ import type {
46
+ CodingAgentEngine,
47
+ EngineUsage,
48
+ PromptAttachment,
49
+ StartSessionOptions,
50
+ } from '../types.js'
51
+
52
+ /**
53
+ * Bouncer entry-point signature. Exposed as an option on OpenCodeEngine so
54
+ * tests can swap in a stub without patching module internals. Production
55
+ * code passes `reviewEnginePermission` from bouncer-integration.ts, which
56
+ * in turn calls {@link reviewOperation} — the single source of truth for
57
+ * security decisions across every engine.
58
+ */
59
+ export type ReviewEnginePermissionFn = (
60
+ request: EnginePermissionReviewRequest,
61
+ ) => Promise<BouncerDecision>
62
+
63
+ /**
64
+ * Inferred type of the value returned by `OpencodeClient.event.subscribe()`.
65
+ * We derive it from the SDK rather than importing the underlying
66
+ * `ServerSentEventsResult` symbol so we don't depend on an internal SDK
67
+ * module path that isn't part of its public exports map.
68
+ */
69
+ type EventSubscription = Awaited<
70
+ ReturnType<OpencodeClient['event']['subscribe']>
71
+ >
72
+
73
+ type Resolver = (r: IteratorResult<EngineEvent>) => void
74
+
75
+ /** Construction-time dependencies for {@link OpenCodeEngine}. */
76
+ export interface OpenCodeEngineOptions {
77
+ /**
78
+ * Typed SDK client, already bound to a running opencode server. Usually
79
+ * obtained from `OpenCodeServerManager.getClient()`. Tests inject a
80
+ * hand-rolled mock matching the subset of methods used here.
81
+ */
82
+ client: OpencodeClient
83
+ /**
84
+ * Directory query parameter forwarded to each request. OpenCode scopes
85
+ * sessions and events by directory — the value is typically the same
86
+ * working directory passed to `startSession`.
87
+ */
88
+ directory?: string
89
+ /**
90
+ * Override the bouncer review function. Defaults to
91
+ * `reviewEnginePermission` from `cli/server/mcp/bouncer-integration.ts`
92
+ * — which wraps the unified {@link reviewOperation} entry point used by
93
+ * the Claude MCP path. Tests inject a stub to drive specific decisions.
94
+ */
95
+ reviewPermission?: ReviewEnginePermissionFn
96
+ }
97
+
98
+ /** Narrowing helper: OpenCode wraps the message union under `info`. */
99
+ function isAssistantMessage(msg: Message): msg is AssistantMessage {
100
+ return msg.role === 'assistant'
101
+ }
102
+
103
+ /**
104
+ * Token counts from `StepFinishPart` or the `AssistantMessage.tokens`
105
+ * object. Both sources share the same shape.
106
+ */
107
+ interface TokenCounts {
108
+ input: number
109
+ output: number
110
+ reasoning: number
111
+ cache: {
112
+ read: number
113
+ write: number
114
+ }
115
+ }
116
+
117
+ export class OpenCodeEngine implements CodingAgentEngine {
118
+ readonly engineId: EngineId = 'opencode'
119
+
120
+ private readonly client: OpencodeClient
121
+ private readonly directory: string | undefined
122
+ private readonly reviewPermission: ReviewEnginePermissionFn
123
+
124
+ private sessionOptions: StartSessionOptions | null = null
125
+ private openCodeSessionId: string | undefined
126
+ /** True once the caller has called `startSession` successfully. */
127
+ private started = false
128
+
129
+ /** Active SSE subscription returned by `client.event.subscribe()`. */
130
+ private subscription: EventSubscription | null = null
131
+ /** Background task consuming `subscription.stream`. */
132
+ private pumpPromise: Promise<void> | null = null
133
+
134
+ /** In-flight prompt promise — enforces the one-prompt-at-a-time contract. */
135
+ private currentPromptPromise: Promise<void> | null = null
136
+
137
+ private disposed = false
138
+ private iteratorDone = false
139
+ private readonly queue: EngineEvent[] = []
140
+ private readonly pending: Resolver[] = []
141
+
142
+ /**
143
+ * Tool-call id → start timestamp. Populated on the first `running`
144
+ * state of a ToolPart so we can compute `durationMs` when the part
145
+ * transitions to `completed` or `error`.
146
+ */
147
+ private readonly toolStartTimes: Map<string, number> = new Map()
148
+ /**
149
+ * Tool-call ids we have already announced via `tool.start`. Prevents
150
+ * duplicate starts when OpenCode emits multiple `running` updates.
151
+ */
152
+ private readonly toolStarted: Set<string> = new Set()
153
+
154
+ private usage: EngineUsage = {
155
+ inputTokens: 0,
156
+ outputTokens: 0,
157
+ lastUpdatedAt: Date.now(),
158
+ }
159
+
160
+ constructor(options: OpenCodeEngineOptions) {
161
+ if (!options || !options.client) {
162
+ throw new Error('OpenCodeEngine: client is required')
163
+ }
164
+ this.client = options.client
165
+ this.directory = options.directory
166
+ this.reviewPermission = options.reviewPermission ?? reviewEnginePermission
167
+ }
168
+
169
+ async startSession(options: StartSessionOptions): Promise<void> {
170
+ if (this.started) {
171
+ throw new Error('OpenCodeEngine: startSession called more than once')
172
+ }
173
+ if (this.disposed) {
174
+ throw new Error('OpenCodeEngine: cannot start a disposed engine')
175
+ }
176
+ this.sessionOptions = options
177
+ const dir = options.workingDir || this.directory
178
+
179
+ if (options.resumeSessionId) {
180
+ this.openCodeSessionId = options.resumeSessionId
181
+ } else {
182
+ const created = await this.client.session.create({
183
+ query: dir ? { directory: dir } : undefined,
184
+ })
185
+ const session = extractData<{ id: string }>(created)
186
+ if (!session || typeof session.id !== 'string') {
187
+ throw new Error(
188
+ 'OpenCodeEngine: session.create did not return a session id',
189
+ )
190
+ }
191
+ this.openCodeSessionId = session.id
192
+ }
193
+
194
+ this.subscription = await this.client.event.subscribe({
195
+ query: dir ? { directory: dir } : undefined,
196
+ })
197
+ this.pumpPromise = this.runEventPump()
198
+ this.started = true
199
+ }
200
+
201
+ async sendPrompt(
202
+ prompt: string,
203
+ _attachments?: PromptAttachment[],
204
+ ): Promise<void> {
205
+ if (!this.started || !this.openCodeSessionId) {
206
+ throw new Error('OpenCodeEngine: sendPrompt called before startSession')
207
+ }
208
+ if (this.disposed) {
209
+ throw new Error('OpenCodeEngine: sendPrompt called after dispose')
210
+ }
211
+ if (this.currentPromptPromise) {
212
+ throw new Error('OpenCodeEngine: another prompt is already in flight')
213
+ }
214
+
215
+ const model = parseModel(this.sessionOptions?.model)
216
+ const sendPromise = (async () => {
217
+ const result = await this.client.session.promptAsync({
218
+ path: { id: this.openCodeSessionId as string },
219
+ query: this.directory ? { directory: this.directory } : undefined,
220
+ body: {
221
+ parts: [{ type: 'text', text: prompt }],
222
+ ...(model ? { model } : {}),
223
+ },
224
+ })
225
+ const err = extractError(result)
226
+ if (err) {
227
+ throw new Error(err)
228
+ }
229
+ })()
230
+
231
+ this.currentPromptPromise = sendPromise
232
+ try {
233
+ await sendPromise
234
+ } finally {
235
+ this.currentPromptPromise = null
236
+ }
237
+ }
238
+
239
+ async cancel(): Promise<void> {
240
+ if (!this.openCodeSessionId || this.disposed) return
241
+ try {
242
+ await this.client.session.abort({
243
+ path: { id: this.openCodeSessionId },
244
+ query: this.directory ? { directory: this.directory } : undefined,
245
+ })
246
+ } catch (err) {
247
+ // Swallow — the Bouncer/UI layer only cares that we asked. A real
248
+ // failure surfaces as an `engine.error` emitted by the event pump.
249
+ this.emit({
250
+ kind: 'engine.error',
251
+ sessionId: this.sessionIdForEvent(),
252
+ timestamp: Date.now(),
253
+ code: 'OPENCODE_ABORT_ERROR',
254
+ message: err instanceof Error ? err.message : String(err),
255
+ fatal: false,
256
+ })
257
+ }
258
+ }
259
+
260
+ getUsage(): EngineUsage {
261
+ return { ...this.usage }
262
+ }
263
+
264
+ async dispose(): Promise<void> {
265
+ if (this.disposed) return
266
+ this.disposed = true
267
+
268
+ // Stop the SSE pump. Calling `return()` on the generator is the
269
+ // documented way to break a `for await` loop inside the pump.
270
+ const sub = this.subscription
271
+ this.subscription = null
272
+ if (sub) {
273
+ try {
274
+ await sub.stream.return(undefined)
275
+ } catch {
276
+ // ignore — stream may already be exhausted
277
+ }
278
+ }
279
+
280
+ if (this.pumpPromise) {
281
+ try {
282
+ await this.pumpPromise
283
+ } catch {
284
+ // pump errors are already surfaced as engine.error events
285
+ }
286
+ this.pumpPromise = null
287
+ }
288
+
289
+ // Best-effort session deletion. Failures are non-fatal at dispose.
290
+ if (this.openCodeSessionId) {
291
+ try {
292
+ await this.client.session.delete({
293
+ path: { id: this.openCodeSessionId },
294
+ query: this.directory ? { directory: this.directory } : undefined,
295
+ })
296
+ } catch {
297
+ // ignore
298
+ }
299
+ }
300
+
301
+ this.closeIterator()
302
+ }
303
+
304
+ [Symbol.asyncIterator](): AsyncIterator<EngineEvent> {
305
+ return {
306
+ next: (): Promise<IteratorResult<EngineEvent>> => {
307
+ if (this.queue.length > 0) {
308
+ return Promise.resolve({
309
+ value: this.queue.shift() as EngineEvent,
310
+ done: false,
311
+ })
312
+ }
313
+ if (this.iteratorDone) {
314
+ return Promise.resolve({ value: undefined, done: true })
315
+ }
316
+ return new Promise<IteratorResult<EngineEvent>>((resolve) => {
317
+ this.pending.push(resolve)
318
+ })
319
+ },
320
+ return: (): Promise<IteratorResult<EngineEvent>> => {
321
+ this.closeIterator()
322
+ return Promise.resolve({ value: undefined, done: true })
323
+ },
324
+ }
325
+ }
326
+
327
+ // ---------- private ----------
328
+
329
+ private sessionIdForEvent(): string {
330
+ return this.openCodeSessionId ?? 'pending'
331
+ }
332
+
333
+ private emit(event: EngineEvent): void {
334
+ if (this.iteratorDone) return
335
+ const resolver = this.pending.shift()
336
+ if (resolver) {
337
+ resolver({ value: event, done: false })
338
+ } else {
339
+ this.queue.push(event)
340
+ }
341
+ if (event.kind === 'engine.error' && event.fatal) {
342
+ this.closeIterator()
343
+ }
344
+ }
345
+
346
+ private closeIterator(): void {
347
+ if (this.iteratorDone) return
348
+ this.iteratorDone = true
349
+ const waiting = this.pending.splice(0)
350
+ for (const resolve of waiting) {
351
+ resolve({ value: undefined, done: true })
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Long-running task that consumes the SSE stream. Exits when the stream
357
+ * ends naturally (dispose called `stream.return()`) or when an error
358
+ * propagates out of the generator.
359
+ */
360
+ private async runEventPump(): Promise<void> {
361
+ const sub = this.subscription
362
+ if (!sub) return
363
+ try {
364
+ for await (const event of sub.stream) {
365
+ if (this.disposed) break
366
+ this.handleSseEvent(event)
367
+ }
368
+ } catch (err) {
369
+ if (this.disposed) return
370
+ const message = err instanceof Error ? err.message : String(err)
371
+ this.emit({
372
+ kind: 'engine.error',
373
+ sessionId: this.sessionIdForEvent(),
374
+ timestamp: Date.now(),
375
+ code: 'OPENCODE_EVENT_STREAM_ERROR',
376
+ message,
377
+ fatal: true,
378
+ })
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Core mapping from OpenCode SSE events to EngineEvents.
384
+ *
385
+ * - message.part.updated (TextPart) → message.delta
386
+ * - message.part.updated (ReasoningPart) → message.thinking
387
+ * - message.part.updated (ToolPart running) → tool.start (once per callID)
388
+ * - message.part.updated (ToolPart done) → tool.end
389
+ * - message.part.updated (StepFinishPart) → usage.update
390
+ * - message.updated (AssistantMessage) → usage.update (if tokens set)
391
+ * - permission.updated → permission.request
392
+ * - session.idle → session.idle
393
+ * - session.error → engine.error
394
+ *
395
+ * Events for sessions other than the one we own are ignored so that a
396
+ * shared server emitting events for multiple clients does not cross
397
+ * streams.
398
+ */
399
+ private handleSseEvent(event: SseEvent): void {
400
+ if (!this.openCodeSessionId) return
401
+ const ours = belongsToSession(event, this.openCodeSessionId)
402
+ if (!ours) return
403
+
404
+ switch (event.type) {
405
+ case 'message.part.updated':
406
+ this.handlePartUpdated(event.properties.part, event.properties.delta)
407
+ return
408
+
409
+ case 'message.updated':
410
+ this.handleMessageUpdated(event.properties.info)
411
+ return
412
+
413
+ case 'permission.updated': {
414
+ const p = event.properties
415
+ // Emit the observability event first so consumers (UI, audit) see
416
+ // the permission request before the Bouncer decision arrives.
417
+ this.emit({
418
+ kind: 'permission.request',
419
+ sessionId: this.sessionIdForEvent(),
420
+ timestamp: Date.now(),
421
+ raw: p,
422
+ requestId: p.id,
423
+ toolName: p.type,
424
+ input: (p.metadata ?? {}) as Record<string, unknown>,
425
+ reason: p.title,
426
+ })
427
+ // Fire-and-forget: resolve via the Bouncer, respond through the
428
+ // SDK, and — on denial — emit a user-visible engine.error. We do
429
+ // not block the SSE pump on this async work so other events from
430
+ // the same stream continue to flow.
431
+ void this.resolvePermission(p)
432
+ return
433
+ }
434
+
435
+ case 'session.idle':
436
+ this.emit({
437
+ kind: 'session.idle',
438
+ sessionId: this.sessionIdForEvent(),
439
+ timestamp: Date.now(),
440
+ })
441
+ return
442
+
443
+ case 'session.error': {
444
+ const errObj = event.properties.error
445
+ const msg =
446
+ errObj && 'data' in errObj && errObj.data && 'message' in errObj.data
447
+ ? String((errObj.data as { message?: unknown }).message ?? '')
448
+ : errObj?.name ?? 'OpenCode session error'
449
+ this.emit({
450
+ kind: 'engine.error',
451
+ sessionId: this.sessionIdForEvent(),
452
+ timestamp: Date.now(),
453
+ raw: errObj,
454
+ code: errObj?.name ?? 'OPENCODE_SESSION_ERROR',
455
+ message: msg,
456
+ fatal: false,
457
+ })
458
+ return
459
+ }
460
+
461
+ default:
462
+ return
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Route an OpenCode `permission.updated` through the unified Bouncer
468
+ * and respond to the SDK so the server never hangs waiting.
469
+ *
470
+ * Contract:
471
+ * - Approval (allow / warn_allow) → SDK `{ response: 'once' }`.
472
+ * - Denial (deny) → SDK `{ response: 'reject' }` *and*
473
+ * a user-visible `engine.error` carrying the same message the Claude
474
+ * MCP path returns on a deny (see `cli/server/mcp/server.ts`), so
475
+ * both engines surface denials with identical wording.
476
+ *
477
+ * Any error — bouncer failure, SDK failure — is treated as a deny for
478
+ * safety: we tell OpenCode `reject`, emit an engine.error, and keep the
479
+ * session alive (non-fatal).
480
+ */
481
+ private async resolvePermission(permission: Permission): Promise<void> {
482
+ if (this.disposed) return
483
+ const sessionId = this.openCodeSessionId
484
+ if (!sessionId) return
485
+
486
+ const decision = await this.reviewPermissionSafely(permission, sessionId)
487
+ const approved = decision.decision !== 'deny'
488
+ const response: 'once' | 'reject' = approved ? 'once' : 'reject'
489
+
490
+ try {
491
+ await this.client.postSessionIdPermissionsPermissionId({
492
+ path: { id: sessionId, permissionID: permission.id },
493
+ body: { response },
494
+ query: this.directory ? { directory: this.directory } : undefined,
495
+ })
496
+ } catch (err) {
497
+ if (this.disposed) return
498
+ this.emit({
499
+ kind: 'engine.error',
500
+ sessionId: this.sessionIdForEvent(),
501
+ timestamp: Date.now(),
502
+ code: 'OPENCODE_PERMISSION_RESPOND_ERROR',
503
+ message: err instanceof Error ? err.message : String(err),
504
+ fatal: false,
505
+ raw: { permissionId: permission.id, response },
506
+ })
507
+ // Without a successful respond, OpenCode will time out on its side.
508
+ // Still emit the denial event below if this was a deny decision so
509
+ // the user sees why their operation was blocked.
510
+ }
511
+
512
+ if (!approved && !this.disposed) {
513
+ this.emit({
514
+ kind: 'engine.error',
515
+ sessionId: this.sessionIdForEvent(),
516
+ timestamp: Date.now(),
517
+ code: 'BOUNCER_DENIED',
518
+ message: formatDenialMessage(decision),
519
+ fatal: false,
520
+ raw: { permissionId: permission.id, toolName: permission.type, decision },
521
+ })
522
+ }
523
+ }
524
+
525
+ private async reviewPermissionSafely(
526
+ permission: Permission,
527
+ sessionId: string,
528
+ ): Promise<BouncerDecision> {
529
+ try {
530
+ return await this.reviewPermission({
531
+ toolName: permission.type,
532
+ input: (permission.metadata ?? {}) as Record<string, unknown>,
533
+ context: {
534
+ purpose: 'OpenCode permission request',
535
+ workingDirectory: this.directory ?? this.sessionOptions?.workingDir,
536
+ sessionId,
537
+ },
538
+ })
539
+ } catch (err) {
540
+ const message = err instanceof Error ? err.message : String(err)
541
+ return {
542
+ decision: 'deny',
543
+ confidence: 0,
544
+ reasoning: `Security analysis failed: ${message}. Denying for safety.`,
545
+ threatLevel: 'critical',
546
+ }
547
+ }
548
+ }
549
+
550
+ private handlePartUpdated(part: Part, delta: string | undefined): void {
551
+ switch (part.type) {
552
+ case 'text':
553
+ this.onTextPart(part, delta)
554
+ return
555
+ case 'reasoning':
556
+ this.onReasoningPart(part, delta)
557
+ return
558
+ case 'tool':
559
+ this.onToolPart(part)
560
+ return
561
+ case 'step-finish':
562
+ this.onStepFinish(part)
563
+ return
564
+ default:
565
+ return
566
+ }
567
+ }
568
+
569
+ private onTextPart(part: TextPart, delta: string | undefined): void {
570
+ // OpenCode sets `delta` to the incremental chunk. Fall back to the
571
+ // full text if `delta` is absent and this is the first time we see
572
+ // this part id (best effort — the contract only requires the text
573
+ // be eventually concatenable).
574
+ const text = delta ?? ''
575
+ if (!text) return
576
+ this.emit({
577
+ kind: 'message.delta',
578
+ sessionId: this.sessionIdForEvent(),
579
+ timestamp: Date.now(),
580
+ text,
581
+ raw: part,
582
+ })
583
+ }
584
+
585
+ private onReasoningPart(
586
+ part: ReasoningPart,
587
+ delta: string | undefined,
588
+ ): void {
589
+ const text = delta ?? ''
590
+ if (!text) return
591
+ this.emit({
592
+ kind: 'message.thinking',
593
+ sessionId: this.sessionIdForEvent(),
594
+ timestamp: Date.now(),
595
+ text,
596
+ raw: part,
597
+ })
598
+ }
599
+
600
+ private onToolPart(part: ToolPart): void {
601
+ const callId = part.callID
602
+ if (!callId) return
603
+ const status = part.state.status
604
+ if (status === 'running' || status === 'pending') {
605
+ this.emitToolStartOnce(callId, part)
606
+ return
607
+ }
608
+ if (status === 'completed' || status === 'error') {
609
+ this.emitToolStartOnce(callId, part)
610
+ this.emitToolEnd(callId, part)
611
+ }
612
+ }
613
+
614
+ private emitToolStartOnce(callId: string, part: ToolPart): void {
615
+ if (this.toolStarted.has(callId)) return
616
+ const now = Date.now()
617
+ this.toolStarted.add(callId)
618
+ this.toolStartTimes.set(callId, now)
619
+ this.emit({
620
+ kind: 'tool.start',
621
+ sessionId: this.sessionIdForEvent(),
622
+ timestamp: now,
623
+ toolCallId: callId,
624
+ toolName: part.tool,
625
+ input: (part.state.input ?? {}) as Record<string, unknown>,
626
+ raw: part,
627
+ })
628
+ }
629
+
630
+ private emitToolEnd(callId: string, part: ToolPart): void {
631
+ const state = part.state
632
+ const now = Date.now()
633
+ const start = this.toolStartTimes.get(callId) ?? now
634
+ const isError = state.status === 'error'
635
+ const result = isError
636
+ ? (state as { error: string }).error
637
+ : (state as { output: string }).output
638
+ this.emit({
639
+ kind: 'tool.end',
640
+ sessionId: this.sessionIdForEvent(),
641
+ timestamp: now,
642
+ toolCallId: callId,
643
+ toolName: part.tool,
644
+ input: (state.input ?? {}) as Record<string, unknown>,
645
+ result: result ?? '',
646
+ isError,
647
+ durationMs: Math.max(0, now - start),
648
+ raw: part,
649
+ })
650
+ this.toolStartTimes.delete(callId)
651
+ // Keep `toolStarted` set so late duplicate `running` updates are
652
+ // deduped rather than spawning a new tool.start for a finished call.
653
+ }
654
+
655
+ private onStepFinish(part: StepFinishPart): void {
656
+ this.applyTokens(part.tokens, part)
657
+ }
658
+
659
+ private handleMessageUpdated(msg: Message): void {
660
+ if (!isAssistantMessage(msg)) return
661
+ if (!msg.tokens) return
662
+ this.applyTokens(msg.tokens, msg)
663
+ }
664
+
665
+ private applyTokens(tokens: TokenCounts, raw: unknown): void {
666
+ const input = tokens.input ?? 0
667
+ const output = tokens.output ?? 0
668
+ const cacheRead = tokens.cache?.read ?? 0
669
+ const cacheWrite = tokens.cache?.write ?? 0
670
+ // Contract: usage values are monotonically non-decreasing. If this
671
+ // update regresses any counter, keep the running maximum.
672
+ const nextInput = Math.max(input, this.usage.inputTokens)
673
+ const nextOutput = Math.max(output, this.usage.outputTokens)
674
+ const nextCacheRead = Math.max(
675
+ cacheRead,
676
+ this.usage.cacheReadTokens ?? 0,
677
+ )
678
+ const nextCacheWrite = Math.max(
679
+ cacheWrite,
680
+ this.usage.cacheCreationTokens ?? 0,
681
+ )
682
+ const changed =
683
+ nextInput !== this.usage.inputTokens ||
684
+ nextOutput !== this.usage.outputTokens ||
685
+ nextCacheRead !== (this.usage.cacheReadTokens ?? 0) ||
686
+ nextCacheWrite !== (this.usage.cacheCreationTokens ?? 0)
687
+ if (!changed) return
688
+
689
+ this.usage = {
690
+ inputTokens: nextInput,
691
+ outputTokens: nextOutput,
692
+ cacheReadTokens: nextCacheRead,
693
+ cacheCreationTokens: nextCacheWrite,
694
+ lastUpdatedAt: Date.now(),
695
+ }
696
+ this.emit({
697
+ kind: 'usage.update',
698
+ sessionId: this.sessionIdForEvent(),
699
+ timestamp: Date.now(),
700
+ inputTokens: nextInput,
701
+ outputTokens: nextOutput,
702
+ cacheReadTokens: nextCacheRead,
703
+ cacheCreationTokens: nextCacheWrite,
704
+ raw,
705
+ })
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Returns the session id carried by an SSE event, or `undefined` if the
711
+ * event is global/unrelated. Used to filter events for the session we own.
712
+ */
713
+ function belongsToSession(event: SseEvent, ownSessionId: string): boolean {
714
+ switch (event.type) {
715
+ case 'message.part.updated':
716
+ return event.properties.part.sessionID === ownSessionId
717
+ case 'message.updated':
718
+ return event.properties.info.sessionID === ownSessionId
719
+ case 'permission.updated':
720
+ return event.properties.sessionID === ownSessionId
721
+ case 'session.idle':
722
+ return event.properties.sessionID === ownSessionId
723
+ case 'session.error':
724
+ // sessionID is optional on error events. If absent, treat as ours.
725
+ return (
726
+ !event.properties.sessionID ||
727
+ event.properties.sessionID === ownSessionId
728
+ )
729
+ default:
730
+ return false
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Parse the `model` option string into OpenCode's `{ providerID, modelID }`
736
+ * shape. Accepts `"provider/model"` slugs (documented format in
737
+ * StartSessionOptions). Returns `undefined` for unparseable input so the
738
+ * server falls back to its default model.
739
+ */
740
+ function parseModel(
741
+ modelString: string | undefined,
742
+ ): { providerID: string; modelID: string } | undefined {
743
+ if (!modelString) return undefined
744
+ const slashIndex = modelString.indexOf('/')
745
+ if (slashIndex <= 0 || slashIndex === modelString.length - 1) {
746
+ return undefined
747
+ }
748
+ return {
749
+ providerID: modelString.slice(0, slashIndex),
750
+ modelID: modelString.slice(slashIndex + 1),
751
+ }
752
+ }
753
+
754
+ /**
755
+ * The SDK client returns `{ data, error, response, request }` by default.
756
+ * Pull out `data` regardless of whether the caller configured ThrowOnError.
757
+ */
758
+ function extractData<T>(result: unknown): T | undefined {
759
+ if (result && typeof result === 'object' && 'data' in result) {
760
+ return (result as { data?: T }).data
761
+ }
762
+ return result as T
763
+ }
764
+
765
+ /**
766
+ * Return the error message from an SDK response, or `undefined` if the
767
+ * call succeeded. Mirrors `extractData` for the error channel.
768
+ */
769
+ function extractError(result: unknown): string | undefined {
770
+ if (
771
+ result &&
772
+ typeof result === 'object' &&
773
+ 'error' in result &&
774
+ (result as { error?: unknown }).error
775
+ ) {
776
+ const err = (result as { error: unknown }).error
777
+ if (err && typeof err === 'object' && 'data' in err) {
778
+ const data = (err as { data?: unknown }).data
779
+ if (data && typeof data === 'object' && 'message' in data) {
780
+ return String((data as { message?: unknown }).message ?? 'OpenCode error')
781
+ }
782
+ }
783
+ return err instanceof Error ? err.message : JSON.stringify(err)
784
+ }
785
+ return undefined
786
+ }