@open-mercato/ai-assistant 0.4.2-canary-c02407ff85

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 (193) hide show
  1. package/AGENTS.md +1090 -0
  2. package/README.md +607 -0
  3. package/build.mjs +92 -0
  4. package/dist/di.js +8 -0
  5. package/dist/di.js.map +7 -0
  6. package/dist/frontend/components/CommandPalette/CommandFooter.js +80 -0
  7. package/dist/frontend/components/CommandPalette/CommandFooter.js.map +7 -0
  8. package/dist/frontend/components/CommandPalette/CommandHeader.js +53 -0
  9. package/dist/frontend/components/CommandPalette/CommandHeader.js.map +7 -0
  10. package/dist/frontend/components/CommandPalette/CommandInput.js +29 -0
  11. package/dist/frontend/components/CommandPalette/CommandInput.js.map +7 -0
  12. package/dist/frontend/components/CommandPalette/CommandItem.js +92 -0
  13. package/dist/frontend/components/CommandPalette/CommandItem.js.map +7 -0
  14. package/dist/frontend/components/CommandPalette/CommandPalette.js +244 -0
  15. package/dist/frontend/components/CommandPalette/CommandPalette.js.map +7 -0
  16. package/dist/frontend/components/CommandPalette/CommandPaletteProvider.js +42 -0
  17. package/dist/frontend/components/CommandPalette/CommandPaletteProvider.js.map +7 -0
  18. package/dist/frontend/components/CommandPalette/CommandPaletteWrapper.js +18 -0
  19. package/dist/frontend/components/CommandPalette/CommandPaletteWrapper.js.map +7 -0
  20. package/dist/frontend/components/CommandPalette/DebugPanel.js +215 -0
  21. package/dist/frontend/components/CommandPalette/DebugPanel.js.map +7 -0
  22. package/dist/frontend/components/CommandPalette/MessageBubble.js +64 -0
  23. package/dist/frontend/components/CommandPalette/MessageBubble.js.map +7 -0
  24. package/dist/frontend/components/CommandPalette/ToolCallConfirmation.js +91 -0
  25. package/dist/frontend/components/CommandPalette/ToolCallConfirmation.js.map +7 -0
  26. package/dist/frontend/components/CommandPalette/ToolCallDisplay.js +47 -0
  27. package/dist/frontend/components/CommandPalette/ToolCallDisplay.js.map +7 -0
  28. package/dist/frontend/components/CommandPalette/ToolChatPage.js +74 -0
  29. package/dist/frontend/components/CommandPalette/ToolChatPage.js.map +7 -0
  30. package/dist/frontend/components/CommandPalette/index.js +28 -0
  31. package/dist/frontend/components/CommandPalette/index.js.map +7 -0
  32. package/dist/frontend/constants.js +41 -0
  33. package/dist/frontend/constants.js.map +7 -0
  34. package/dist/frontend/hooks/index.js +13 -0
  35. package/dist/frontend/hooks/index.js.map +7 -0
  36. package/dist/frontend/hooks/useCommandPalette.js +1094 -0
  37. package/dist/frontend/hooks/useCommandPalette.js.map +7 -0
  38. package/dist/frontend/hooks/useMcpTools.js +66 -0
  39. package/dist/frontend/hooks/useMcpTools.js.map +7 -0
  40. package/dist/frontend/hooks/usePageContext.js +48 -0
  41. package/dist/frontend/hooks/usePageContext.js.map +7 -0
  42. package/dist/frontend/hooks/useRecentActions.js +56 -0
  43. package/dist/frontend/hooks/useRecentActions.js.map +7 -0
  44. package/dist/frontend/hooks/useRecentTools.js +55 -0
  45. package/dist/frontend/hooks/useRecentTools.js.map +7 -0
  46. package/dist/frontend/index.js +35 -0
  47. package/dist/frontend/index.js.map +7 -0
  48. package/dist/frontend/types.js +1 -0
  49. package/dist/frontend/types.js.map +7 -0
  50. package/dist/frontend/utils/index.js +7 -0
  51. package/dist/frontend/utils/index.js.map +7 -0
  52. package/dist/frontend/utils/toolMatcher.js +95 -0
  53. package/dist/frontend/utils/toolMatcher.js.map +7 -0
  54. package/dist/index.js +57 -0
  55. package/dist/index.js.map +7 -0
  56. package/dist/modules/ai_assistant/acl.js +14 -0
  57. package/dist/modules/ai_assistant/acl.js.map +7 -0
  58. package/dist/modules/ai_assistant/api/chat/route.js +152 -0
  59. package/dist/modules/ai_assistant/api/chat/route.js.map +7 -0
  60. package/dist/modules/ai_assistant/api/health/route.js +27 -0
  61. package/dist/modules/ai_assistant/api/health/route.js.map +7 -0
  62. package/dist/modules/ai_assistant/api/route/route.js +123 -0
  63. package/dist/modules/ai_assistant/api/route/route.js.map +7 -0
  64. package/dist/modules/ai_assistant/api/settings/route.js +60 -0
  65. package/dist/modules/ai_assistant/api/settings/route.js.map +7 -0
  66. package/dist/modules/ai_assistant/api/tools/execute/route.js +58 -0
  67. package/dist/modules/ai_assistant/api/tools/execute/route.js.map +7 -0
  68. package/dist/modules/ai_assistant/api/tools/route.js +48 -0
  69. package/dist/modules/ai_assistant/api/tools/route.js.map +7 -0
  70. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.js +10 -0
  71. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.js.map +7 -0
  72. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.meta.js +28 -0
  73. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.meta.js.map +7 -0
  74. package/dist/modules/ai_assistant/cli.js +192 -0
  75. package/dist/modules/ai_assistant/cli.js.map +7 -0
  76. package/dist/modules/ai_assistant/di.js +11 -0
  77. package/dist/modules/ai_assistant/di.js.map +7 -0
  78. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js +257 -0
  79. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js.map +7 -0
  80. package/dist/modules/ai_assistant/index.js +13 -0
  81. package/dist/modules/ai_assistant/index.js.map +7 -0
  82. package/dist/modules/ai_assistant/lib/ai-sdk.js +13 -0
  83. package/dist/modules/ai_assistant/lib/ai-sdk.js.map +7 -0
  84. package/dist/modules/ai_assistant/lib/api-discovery-tools.js +249 -0
  85. package/dist/modules/ai_assistant/lib/api-discovery-tools.js.map +7 -0
  86. package/dist/modules/ai_assistant/lib/api-endpoint-index-config.js +177 -0
  87. package/dist/modules/ai_assistant/lib/api-endpoint-index-config.js.map +7 -0
  88. package/dist/modules/ai_assistant/lib/api-endpoint-index.js +210 -0
  89. package/dist/modules/ai_assistant/lib/api-endpoint-index.js.map +7 -0
  90. package/dist/modules/ai_assistant/lib/auth.js +87 -0
  91. package/dist/modules/ai_assistant/lib/auth.js.map +7 -0
  92. package/dist/modules/ai_assistant/lib/chat-config.js +117 -0
  93. package/dist/modules/ai_assistant/lib/chat-config.js.map +7 -0
  94. package/dist/modules/ai_assistant/lib/client-factory.js +60 -0
  95. package/dist/modules/ai_assistant/lib/client-factory.js.map +7 -0
  96. package/dist/modules/ai_assistant/lib/http-server.js +367 -0
  97. package/dist/modules/ai_assistant/lib/http-server.js.map +7 -0
  98. package/dist/modules/ai_assistant/lib/in-process-client.js +126 -0
  99. package/dist/modules/ai_assistant/lib/in-process-client.js.map +7 -0
  100. package/dist/modules/ai_assistant/lib/mcp-client.js +146 -0
  101. package/dist/modules/ai_assistant/lib/mcp-client.js.map +7 -0
  102. package/dist/modules/ai_assistant/lib/mcp-dev-server.js +283 -0
  103. package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +7 -0
  104. package/dist/modules/ai_assistant/lib/mcp-server-config.js +160 -0
  105. package/dist/modules/ai_assistant/lib/mcp-server-config.js.map +7 -0
  106. package/dist/modules/ai_assistant/lib/mcp-server.js +156 -0
  107. package/dist/modules/ai_assistant/lib/mcp-server.js.map +7 -0
  108. package/dist/modules/ai_assistant/lib/mcp-tool-adapter.js +44 -0
  109. package/dist/modules/ai_assistant/lib/mcp-tool-adapter.js.map +7 -0
  110. package/dist/modules/ai_assistant/lib/opencode-client.js +247 -0
  111. package/dist/modules/ai_assistant/lib/opencode-client.js.map +7 -0
  112. package/dist/modules/ai_assistant/lib/opencode-handlers.js +398 -0
  113. package/dist/modules/ai_assistant/lib/opencode-handlers.js.map +7 -0
  114. package/dist/modules/ai_assistant/lib/schema-utils.js +94 -0
  115. package/dist/modules/ai_assistant/lib/schema-utils.js.map +7 -0
  116. package/dist/modules/ai_assistant/lib/tool-executor.js +55 -0
  117. package/dist/modules/ai_assistant/lib/tool-executor.js.map +7 -0
  118. package/dist/modules/ai_assistant/lib/tool-index-config.js +125 -0
  119. package/dist/modules/ai_assistant/lib/tool-index-config.js.map +7 -0
  120. package/dist/modules/ai_assistant/lib/tool-loader.js +88 -0
  121. package/dist/modules/ai_assistant/lib/tool-loader.js.map +7 -0
  122. package/dist/modules/ai_assistant/lib/tool-registry.js +65 -0
  123. package/dist/modules/ai_assistant/lib/tool-registry.js.map +7 -0
  124. package/dist/modules/ai_assistant/lib/tool-search.js +192 -0
  125. package/dist/modules/ai_assistant/lib/tool-search.js.map +7 -0
  126. package/dist/modules/ai_assistant/lib/types.js +1 -0
  127. package/dist/modules/ai_assistant/lib/types.js.map +7 -0
  128. package/package.json +108 -0
  129. package/src/di.ts +11 -0
  130. package/src/frontend/components/CommandPalette/CommandFooter.tsx +113 -0
  131. package/src/frontend/components/CommandPalette/CommandHeader.tsx +76 -0
  132. package/src/frontend/components/CommandPalette/CommandInput.tsx +50 -0
  133. package/src/frontend/components/CommandPalette/CommandItem.tsx +111 -0
  134. package/src/frontend/components/CommandPalette/CommandPalette.tsx +276 -0
  135. package/src/frontend/components/CommandPalette/CommandPaletteProvider.tsx +60 -0
  136. package/src/frontend/components/CommandPalette/CommandPaletteWrapper.tsx +21 -0
  137. package/src/frontend/components/CommandPalette/DebugPanel.tsx +257 -0
  138. package/src/frontend/components/CommandPalette/MessageBubble.tsx +73 -0
  139. package/src/frontend/components/CommandPalette/ToolCallConfirmation.tsx +130 -0
  140. package/src/frontend/components/CommandPalette/ToolCallDisplay.tsx +57 -0
  141. package/src/frontend/components/CommandPalette/ToolChatPage.tsx +125 -0
  142. package/src/frontend/components/CommandPalette/index.ts +14 -0
  143. package/src/frontend/constants.ts +35 -0
  144. package/src/frontend/hooks/index.ts +5 -0
  145. package/src/frontend/hooks/useCommandPalette.ts +1389 -0
  146. package/src/frontend/hooks/useMcpTools.ts +73 -0
  147. package/src/frontend/hooks/usePageContext.ts +61 -0
  148. package/src/frontend/hooks/useRecentActions.ts +64 -0
  149. package/src/frontend/hooks/useRecentTools.ts +69 -0
  150. package/src/frontend/index.ts +39 -0
  151. package/src/frontend/types.ts +260 -0
  152. package/src/frontend/utils/index.ts +1 -0
  153. package/src/frontend/utils/toolMatcher.ts +127 -0
  154. package/src/index.ts +92 -0
  155. package/src/modules/ai_assistant/acl.ts +10 -0
  156. package/src/modules/ai_assistant/api/chat/route.ts +213 -0
  157. package/src/modules/ai_assistant/api/health/route.ts +30 -0
  158. package/src/modules/ai_assistant/api/route/route.ts +149 -0
  159. package/src/modules/ai_assistant/api/settings/route.ts +73 -0
  160. package/src/modules/ai_assistant/api/tools/execute/route.ts +71 -0
  161. package/src/modules/ai_assistant/api/tools/route.ts +57 -0
  162. package/src/modules/ai_assistant/backend/config/ai-assistant/page.meta.ts +26 -0
  163. package/src/modules/ai_assistant/backend/config/ai-assistant/page.tsx +12 -0
  164. package/src/modules/ai_assistant/cli.ts +233 -0
  165. package/src/modules/ai_assistant/di.ts +9 -0
  166. package/src/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.tsx +418 -0
  167. package/src/modules/ai_assistant/index.ts +11 -0
  168. package/src/modules/ai_assistant/lib/ai-sdk.ts +5 -0
  169. package/src/modules/ai_assistant/lib/api-discovery-tools.ts +334 -0
  170. package/src/modules/ai_assistant/lib/api-endpoint-index-config.ts +243 -0
  171. package/src/modules/ai_assistant/lib/api-endpoint-index.ts +381 -0
  172. package/src/modules/ai_assistant/lib/auth.ts +185 -0
  173. package/src/modules/ai_assistant/lib/chat-config.ts +152 -0
  174. package/src/modules/ai_assistant/lib/client-factory.ts +130 -0
  175. package/src/modules/ai_assistant/lib/http-server.ts +498 -0
  176. package/src/modules/ai_assistant/lib/in-process-client.ts +205 -0
  177. package/src/modules/ai_assistant/lib/mcp-client.ts +221 -0
  178. package/src/modules/ai_assistant/lib/mcp-dev-server.ts +373 -0
  179. package/src/modules/ai_assistant/lib/mcp-server-config.ts +287 -0
  180. package/src/modules/ai_assistant/lib/mcp-server.ts +214 -0
  181. package/src/modules/ai_assistant/lib/mcp-tool-adapter.ts +76 -0
  182. package/src/modules/ai_assistant/lib/opencode-client.ts +426 -0
  183. package/src/modules/ai_assistant/lib/opencode-handlers.ts +676 -0
  184. package/src/modules/ai_assistant/lib/schema-utils.ts +142 -0
  185. package/src/modules/ai_assistant/lib/tool-executor.ts +71 -0
  186. package/src/modules/ai_assistant/lib/tool-index-config.ts +178 -0
  187. package/src/modules/ai_assistant/lib/tool-loader.ts +149 -0
  188. package/src/modules/ai_assistant/lib/tool-registry.ts +114 -0
  189. package/src/modules/ai_assistant/lib/tool-search.ts +308 -0
  190. package/src/modules/ai_assistant/lib/types.ts +147 -0
  191. package/test-schema.ts +37 -0
  192. package/tsconfig.json +10 -0
  193. package/watch.mjs +6 -0
@@ -0,0 +1,676 @@
1
+ /**
2
+ * OpenCode API Route Handlers
3
+ *
4
+ * These handlers can be used by Next.js API routes to interact with OpenCode.
5
+ */
6
+
7
+ import {
8
+ createOpenCodeClient,
9
+ type OpenCodeClient,
10
+ type OpenCodeQuestion,
11
+ } from './opencode-client'
12
+
13
+ let clientInstance: OpenCodeClient | null = null
14
+
15
+ function getClient(): OpenCodeClient {
16
+ if (!clientInstance) {
17
+ clientInstance = createOpenCodeClient()
18
+ }
19
+ return clientInstance
20
+ }
21
+
22
+ export type OpenCodeTestRequest = {
23
+ message: string
24
+ sessionId?: string
25
+ model?: {
26
+ providerID: string
27
+ modelID: string
28
+ }
29
+ }
30
+
31
+ export type OpenCodeTestResponse = {
32
+ sessionId: string
33
+ result: unknown
34
+ }
35
+
36
+ export type OpenCodeHealthResponse = {
37
+ status: 'ok' | 'error'
38
+ opencode?: {
39
+ healthy: boolean
40
+ version: string
41
+ }
42
+ mcp?: Record<string, { status: string; error?: string }>
43
+ search?: {
44
+ available: boolean
45
+ driver: string | null // 'meilisearch' or null
46
+ }
47
+ url: string
48
+ message?: string
49
+ }
50
+
51
+ /**
52
+ * Handle POST request to send a message to OpenCode.
53
+ */
54
+ export async function handleOpenCodeMessage(
55
+ request: OpenCodeTestRequest
56
+ ): Promise<OpenCodeTestResponse> {
57
+ const client = getClient()
58
+
59
+ const { message, sessionId, model } = request
60
+
61
+ if (!message) {
62
+ throw new Error('Message is required')
63
+ }
64
+
65
+ // Create or get session
66
+ let session
67
+ if (sessionId) {
68
+ session = await client.getSession(sessionId)
69
+ } else {
70
+ session = await client.createSession()
71
+ }
72
+
73
+ // Send message
74
+ const result = await client.sendMessage(session.id, message, { model })
75
+
76
+ return {
77
+ sessionId: session.id,
78
+ result,
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Handle GET request to check OpenCode health.
84
+ */
85
+ export async function handleOpenCodeHealth(): Promise<OpenCodeHealthResponse> {
86
+ const client = getClient()
87
+ const url = process.env.OPENCODE_URL ?? 'http://localhost:4096'
88
+
89
+ // Check search service availability
90
+ let searchStatus: { available: boolean; driver: string | null } = {
91
+ available: false,
92
+ driver: null,
93
+ }
94
+ try {
95
+ const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
96
+ const container = await createRequestContainer()
97
+ const searchService = container.resolve<{
98
+ isStrategyAvailable: (strategy: string) => boolean
99
+ }>('searchService')
100
+ const available = searchService.isStrategyAvailable('fulltext')
101
+ searchStatus = {
102
+ available,
103
+ driver: available ? 'meilisearch' : null,
104
+ }
105
+ } catch {
106
+ // Search service not available
107
+ }
108
+
109
+ try {
110
+ const [health, mcp] = await Promise.all([client.health(), client.mcpStatus()])
111
+
112
+ return {
113
+ status: 'ok',
114
+ opencode: health,
115
+ mcp,
116
+ search: searchStatus,
117
+ url,
118
+ }
119
+ } catch (error) {
120
+ return {
121
+ status: 'error',
122
+ search: searchStatus,
123
+ message: error instanceof Error ? error.message : 'OpenCode not reachable',
124
+ url,
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Extract text content from OpenCode message response.
131
+ */
132
+ export function extractTextFromResponse(result: unknown): string | null {
133
+ if (!result || typeof result !== 'object') return null
134
+
135
+ const message = result as { parts?: Array<{ type: string; text?: string }> }
136
+ if (!message.parts) return null
137
+
138
+ const textParts = message.parts.filter((p) => p.type === 'text' && p.text)
139
+ return textParts.map((p) => p.text).join('\n') || null
140
+ }
141
+
142
+ /**
143
+ * Response part from OpenCode - can be text, tool-call, tool-result, etc.
144
+ */
145
+ export interface OpenCodeResponsePart {
146
+ id: string
147
+ type: string
148
+ text?: string
149
+ // Tool call fields (OpenCode uses 'tool_use' type)
150
+ name?: string
151
+ input?: unknown
152
+ // Tool result fields (OpenCode uses 'tool_result' type)
153
+ tool_use_id?: string
154
+ content?: unknown
155
+ // Step fields (step-start, step-finish)
156
+ sessionID?: string
157
+ messageID?: string
158
+ reason?: string
159
+ cost?: number
160
+ tokens?: {
161
+ input: number
162
+ output: number
163
+ reasoning?: number
164
+ cache?: { read: number; write: number }
165
+ }
166
+ // Generic catch-all
167
+ [key: string]: unknown
168
+ }
169
+
170
+ /**
171
+ * Metadata about the OpenCode response.
172
+ */
173
+ export interface OpenCodeResponseMetadata {
174
+ modelID?: string
175
+ providerID?: string
176
+ tokens?: { input: number; output: number }
177
+ timing?: { created: number; completed?: number }
178
+ }
179
+
180
+ /**
181
+ * Extract all parts from OpenCode response for verbose debugging.
182
+ */
183
+ export function extractAllPartsFromResponse(result: unknown): OpenCodeResponsePart[] {
184
+ if (!result || typeof result !== 'object') return []
185
+
186
+ const message = result as { parts?: OpenCodeResponsePart[] }
187
+ return message.parts || []
188
+ }
189
+
190
+ /**
191
+ * Extract metadata (model, tokens, timing) from OpenCode response.
192
+ */
193
+ export function extractMetadataFromResponse(result: unknown): OpenCodeResponseMetadata | null {
194
+ if (!result || typeof result !== 'object') return null
195
+
196
+ const message = result as {
197
+ info?: {
198
+ modelID?: string
199
+ providerID?: string
200
+ tokens?: { input: number; output: number }
201
+ time?: { created: number; completed?: number }
202
+ }
203
+ }
204
+
205
+ if (!message.info) return null
206
+
207
+ return {
208
+ modelID: message.info.modelID,
209
+ providerID: message.info.providerID,
210
+ tokens: message.info.tokens,
211
+ timing: message.info.time,
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Event types emitted during streaming message handling.
217
+ */
218
+ export type OpenCodeStreamEvent =
219
+ | { type: 'thinking' }
220
+ | { type: 'text'; content: string }
221
+ | { type: 'tool-call'; id: string; toolName: string; args: unknown }
222
+ | { type: 'tool-result'; id: string; result: unknown }
223
+ | { type: 'question'; question: OpenCodeQuestion }
224
+ | { type: 'metadata'; model?: string; provider?: string; tokens?: { input: number; output: number }; durationMs?: number }
225
+ | { type: 'debug'; partType: string; data: unknown }
226
+ | { type: 'done'; sessionId: string }
227
+ | { type: 'error'; error: string }
228
+
229
+ /**
230
+ * Handle OpenCode message with real-time SSE streaming.
231
+ * Uses OpenCode's /event SSE endpoint for live updates.
232
+ *
233
+ * OpenCode does agentic loops - it may generate multiple assistant messages
234
+ * with tool calls in between. We complete only when the session becomes "idle"
235
+ * after being "busy", indicating the full agentic loop is done.
236
+ */
237
+ export async function handleOpenCodeMessageStreaming(
238
+ request: OpenCodeTestRequest,
239
+ onEvent: (event: OpenCodeStreamEvent) => Promise<void>
240
+ ): Promise<void> {
241
+ const client = getClient()
242
+ const { message, sessionId, model } = request
243
+ const startTime = Date.now()
244
+
245
+ if (!message) {
246
+ await onEvent({ type: 'error', error: 'Message is required' })
247
+ return
248
+ }
249
+
250
+ try {
251
+ // Create or get session
252
+ let session
253
+ if (sessionId) {
254
+ session = await client.getSession(sessionId)
255
+ } else {
256
+ session = await client.createSession()
257
+ }
258
+
259
+ const targetSessionId = session.id
260
+ let unsubscribe: (() => void) | null = null
261
+ let emittedThinking = false
262
+ let wasBusy = false // Track if session was ever busy
263
+ let resolved = false // Track if we've already completed
264
+ let lastActivityTime = Date.now() // Track last event for heartbeat
265
+ let heartbeatInterval: NodeJS.Timeout | null = null
266
+ let lastMetadata: {
267
+ model?: string
268
+ provider?: string
269
+ tokens?: { input: number; output: number }
270
+ } | null = null
271
+
272
+ // Helper to clean up resources
273
+ const cleanup = () => {
274
+ if (heartbeatInterval) {
275
+ clearInterval(heartbeatInterval)
276
+ heartbeatInterval = null
277
+ }
278
+ }
279
+
280
+ // Set up SSE subscription for real-time events
281
+ const eventPromise = new Promise<void>((resolve, reject) => {
282
+ const timeout = setTimeout(() => {
283
+ cleanup()
284
+ unsubscribe?.()
285
+ reject(new Error('OpenCode request timed out'))
286
+ }, 300000) // 5 minute timeout for complex agentic tasks
287
+
288
+ // Heartbeat: Check every second for completion conditions
289
+ heartbeatInterval = setInterval(async () => {
290
+ if (resolved) return
291
+
292
+ const idleTime = Date.now() - lastActivityTime
293
+
294
+ // If no activity for 5 seconds and we were busy, check session status
295
+ if (idleTime >= 5000 && wasBusy && !resolved) {
296
+ try {
297
+ // Check actual session status before completing
298
+ const status = await client.getSessionStatus(targetSessionId)
299
+
300
+ if (status.status === 'busy') {
301
+ // Session is still busy - wait
302
+ return
303
+ }
304
+
305
+ if (status.status === 'waiting' && status.questionId) {
306
+ // Session is waiting for a question answer
307
+ const questions = await client.getPendingQuestions()
308
+ const sessionQuestion = questions.find((q) => q.id === status.questionId)
309
+ if (sessionQuestion) {
310
+ await onEvent({ type: 'question', question: sessionQuestion })
311
+ lastActivityTime = Date.now() // Reset timer after emitting question
312
+ return
313
+ }
314
+ }
315
+
316
+ // Check for any pending questions for this session
317
+ const questions = await client.getPendingQuestions()
318
+ const sessionQuestion = questions.find((q) => q.sessionID === targetSessionId)
319
+
320
+ if (sessionQuestion) {
321
+ await onEvent({ type: 'question', question: sessionQuestion })
322
+ lastActivityTime = Date.now() // Reset timer after emitting question
323
+ } else if (status.status === 'idle') {
324
+ // Session is explicitly idle and no questions - complete
325
+ resolved = true
326
+ try {
327
+ await onEvent({ type: 'done', sessionId: targetSessionId })
328
+ } catch (err) {
329
+ console.error('[OpenCode SSE] Heartbeat: Failed to emit done event:', err)
330
+ }
331
+ cleanup()
332
+ clearTimeout(timeout)
333
+ unsubscribe?.()
334
+ resolve()
335
+ }
336
+ // Status is 'unknown' or something else - wait for SSE events
337
+ } catch (err) {
338
+ console.error('[OpenCode SSE] Heartbeat error:', err)
339
+ }
340
+ }
341
+ }, 1000)
342
+
343
+ unsubscribe = client.subscribeToEvents(
344
+ async (sseEvent) => {
345
+ try {
346
+ const { type, properties } = sseEvent
347
+
348
+ // Update activity timestamp for heartbeat
349
+ lastActivityTime = Date.now()
350
+
351
+ // Filter events for our session
352
+ const eventSessionId =
353
+ (properties.sessionID as string) ||
354
+ (properties.info as { sessionID?: string })?.sessionID ||
355
+ (properties.part as { sessionID?: string })?.sessionID ||
356
+ (properties.question as { sessionID?: string })?.sessionID ||
357
+ (properties.session as { id?: string })?.id ||
358
+ (properties.status as { sessionID?: string })?.sessionID
359
+
360
+ if (eventSessionId && eventSessionId !== targetSessionId) {
361
+ return // Ignore events from other sessions
362
+ }
363
+
364
+ switch (type) {
365
+ case 'question.asked': {
366
+ // OpenCode is asking a question - use the data directly from the SSE event
367
+ await onEvent({ type: 'debug', partType: 'question-asked', data: properties })
368
+
369
+ // The question data is in properties.question (from SSE event)
370
+ // This is more reliable than fetching from API which may return incomplete data
371
+ const questionFromEvent = properties.question as OpenCodeQuestion | undefined
372
+
373
+ if (questionFromEvent && questionFromEvent.sessionID === targetSessionId) {
374
+ await onEvent({ type: 'question', question: questionFromEvent })
375
+ } else {
376
+ // Fallback to fetching from API if event doesn't have full question
377
+ const questions = await client.getPendingQuestions()
378
+ const sessionQuestion = questions.find((q) => q.sessionID === targetSessionId)
379
+
380
+ if (sessionQuestion) {
381
+ await onEvent({ type: 'question', question: sessionQuestion })
382
+ }
383
+ }
384
+ break
385
+ }
386
+
387
+ case 'session.status': {
388
+ const status = properties.status as { type: string; questionId?: string }
389
+
390
+ if (status?.type === 'busy') {
391
+ wasBusy = true
392
+ if (!emittedThinking) {
393
+ emittedThinking = true
394
+ await onEvent({ type: 'thinking' })
395
+ }
396
+ } else if (status?.type === 'waiting' && !resolved) {
397
+ // Session is waiting for user to answer a question
398
+ const questions = await client.getPendingQuestions()
399
+ const sessionQuestion = status.questionId
400
+ ? questions.find((q) => q.id === status.questionId)
401
+ : questions.find((q) => q.sessionID === targetSessionId)
402
+
403
+ if (sessionQuestion) {
404
+ await onEvent({ type: 'question', question: sessionQuestion })
405
+ lastActivityTime = Date.now()
406
+ }
407
+ } else if (status?.type === 'idle' && wasBusy && !resolved) {
408
+ // Session went from busy to idle - check if there are pending questions
409
+ const endTime = Date.now()
410
+
411
+ // Emit final metadata if we have it
412
+ if (lastMetadata) {
413
+ await onEvent({
414
+ type: 'metadata',
415
+ model: lastMetadata.model,
416
+ provider: lastMetadata.provider,
417
+ tokens: lastMetadata.tokens,
418
+ durationMs: endTime - startTime,
419
+ })
420
+ }
421
+
422
+ // Check for pending questions before declaring done
423
+ const questions = await client.getPendingQuestions()
424
+ const sessionQuestion = questions.find((q) => q.sessionID === targetSessionId)
425
+
426
+ if (sessionQuestion) {
427
+ // Question found - emit it but keep stream open for answer
428
+ await onEvent({ type: 'question', question: sessionQuestion })
429
+ // Reset activity time so heartbeat doesn't close prematurely
430
+ lastActivityTime = Date.now()
431
+ // Don't set resolved - let heartbeat handle completion after user answers
432
+ } else {
433
+ // No questions found - but give OpenCode a moment to register one
434
+ // (race condition prevention)
435
+ setTimeout(async () => {
436
+ try {
437
+ if (resolved) {
438
+ return
439
+ }
440
+
441
+ // Check one more time for questions
442
+ const finalQuestions = await client.getPendingQuestions()
443
+ const finalQuestion = finalQuestions.find((q) => q.sessionID === targetSessionId)
444
+
445
+ if (finalQuestion) {
446
+ await onEvent({ type: 'question', question: finalQuestion })
447
+ lastActivityTime = Date.now()
448
+ } else {
449
+ // Truly idle - complete the stream
450
+ resolved = true
451
+ await onEvent({ type: 'done', sessionId: targetSessionId })
452
+ cleanup()
453
+ clearTimeout(timeout)
454
+ unsubscribe?.()
455
+ resolve()
456
+ }
457
+ } catch (err) {
458
+ console.error('[OpenCode SSE] Error in timeout callback:', err)
459
+ // Still try to complete even if there was an error
460
+ if (!resolved) {
461
+ resolved = true
462
+ try {
463
+ await onEvent({ type: 'done', sessionId: targetSessionId })
464
+ } catch (e2) {
465
+ console.error('[OpenCode SSE] Failed to emit done event:', e2)
466
+ }
467
+ cleanup()
468
+ clearTimeout(timeout)
469
+ unsubscribe?.()
470
+ resolve()
471
+ }
472
+ }
473
+ }, 2000)
474
+ }
475
+ }
476
+ break
477
+ }
478
+
479
+ case 'message.updated': {
480
+ const info = properties.info as {
481
+ id: string
482
+ role: string
483
+ time?: { completed?: number }
484
+ modelID?: string
485
+ providerID?: string
486
+ tokens?: { input: number; output: number }
487
+ error?: { name: string; message?: string }
488
+ }
489
+
490
+ if (info.role === 'assistant') {
491
+ // Check for error
492
+ if (info.error) {
493
+ cleanup()
494
+ clearTimeout(timeout)
495
+ unsubscribe?.()
496
+ await onEvent({
497
+ type: 'error',
498
+ error: `${info.error.name}: ${info.error.message || 'Unknown error'}`,
499
+ })
500
+ resolve()
501
+ return
502
+ }
503
+
504
+ // Track metadata from completed messages
505
+ if (info.time?.completed) {
506
+ lastMetadata = {
507
+ model: info.modelID,
508
+ provider: info.providerID,
509
+ tokens: info.tokens,
510
+ }
511
+ // Emit intermediate metadata for visibility
512
+ await onEvent({
513
+ type: 'debug',
514
+ partType: 'message-completed',
515
+ data: { messageId: info.id, tokens: info.tokens },
516
+ })
517
+ // Note: Completion is now handled by heartbeat interval
518
+ }
519
+ }
520
+ break
521
+ }
522
+
523
+ case 'message.part.updated': {
524
+ const part = properties.part as {
525
+ type: string
526
+ text?: string
527
+ name?: string
528
+ input?: unknown
529
+ tool_use_id?: string
530
+ content?: unknown
531
+ id: string
532
+ }
533
+ const delta = properties.delta as string | undefined
534
+
535
+ switch (part.type) {
536
+ case 'text':
537
+ // Use delta for streaming text if available
538
+ if (delta) {
539
+ await onEvent({ type: 'text', content: delta })
540
+ }
541
+ break
542
+ case 'tool_use':
543
+ if (part.name) {
544
+ await onEvent({
545
+ type: 'tool-call',
546
+ id: part.id,
547
+ toolName: part.name,
548
+ args: part.input,
549
+ })
550
+ }
551
+ break
552
+ case 'tool_result':
553
+ await onEvent({
554
+ type: 'tool-result',
555
+ id: part.tool_use_id || part.id,
556
+ result: part.content,
557
+ })
558
+ break
559
+ case 'step-start':
560
+ case 'step-finish':
561
+ await onEvent({ type: 'debug', partType: part.type, data: part })
562
+ break
563
+ }
564
+ break
565
+ }
566
+ }
567
+ } catch (err) {
568
+ console.error('[OpenCode SSE] Error processing event:', err)
569
+ }
570
+ },
571
+ (error) => {
572
+ clearTimeout(timeout)
573
+ reject(error)
574
+ }
575
+ )
576
+ })
577
+
578
+ // Send message (don't await - let SSE handle the response via events)
579
+ // We only catch errors here - successful completion is signaled via SSE session.status: idle
580
+ client.sendMessage(session.id, message, { model }).catch((err) => {
581
+ // Log send errors - SSE should also receive an error event
582
+ console.error('[OpenCode] Send error (SSE should handle):', err)
583
+ })
584
+
585
+ // Wait for SSE to indicate completion (session.status: idle or error)
586
+ await eventPromise
587
+ } catch (error) {
588
+ await onEvent({
589
+ type: 'error',
590
+ error: error instanceof Error ? error.message : 'OpenCode request failed',
591
+ })
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Answer a pending question and continue processing.
597
+ * Uses polling to check for completion/next question.
598
+ */
599
+ export async function handleOpenCodeAnswer(
600
+ questionId: string,
601
+ answer: number,
602
+ sessionId: string,
603
+ onEvent: (event: OpenCodeStreamEvent) => Promise<void>
604
+ ): Promise<void> {
605
+ const client = getClient()
606
+
607
+ try {
608
+ // Answer the question
609
+ await client.answerQuestion(questionId, answer)
610
+ await onEvent({ type: 'thinking' })
611
+
612
+ // Poll for completion using session status (max 20 seconds for same-question wait, 60 seconds total)
613
+ const maxAttempts = 30
614
+ const pollInterval = 2000
615
+ let sameQuestionWaitCount = 0
616
+ const maxSameQuestionWait = 5 // Give up after 10 seconds of waiting on same question
617
+
618
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
619
+ await new Promise((resolve) => setTimeout(resolve, pollInterval))
620
+
621
+ // Check session status - most reliable way to know if processing is done
622
+ const status = await client.getSessionStatus(sessionId)
623
+
624
+ if (status.status === 'idle' || status.status === 'unknown') {
625
+ // Session is idle or unknown - processing complete
626
+ await onEvent({ type: 'done', sessionId })
627
+ return
628
+ }
629
+
630
+ if (status.status === 'waiting' && status.questionId && status.questionId !== questionId) {
631
+ // A new question appeared - fetch and emit it
632
+ const allQuestions = await client.getPendingQuestions()
633
+ const newQuestion = allQuestions.find((q) => q.id === status.questionId)
634
+ if (newQuestion) {
635
+ await onEvent({ type: 'question', question: newQuestion })
636
+ return
637
+ }
638
+ }
639
+
640
+ // If waiting on the same question we answered, track how long
641
+ if (status.status === 'waiting' && status.questionId === questionId) {
642
+ sameQuestionWaitCount++
643
+ if (sameQuestionWaitCount >= maxSameQuestionWait) {
644
+ // OpenCode didn't properly clear the question - assume answered and complete
645
+ await onEvent({ type: 'done', sessionId })
646
+ return
647
+ }
648
+ } else {
649
+ // Reset counter if status changed
650
+ sameQuestionWaitCount = 0
651
+ }
652
+
653
+ // Session is busy - keep polling
654
+ }
655
+
656
+ // Timeout - assume complete
657
+ await onEvent({ type: 'done', sessionId })
658
+ } catch (error) {
659
+ console.error('[OpenCode Answer] Error:', error)
660
+ await onEvent({
661
+ type: 'error',
662
+ error: error instanceof Error ? error.message : 'Failed to answer question',
663
+ })
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Get pending questions for a session.
669
+ */
670
+ export async function getPendingQuestions(): Promise<OpenCodeQuestion[]> {
671
+ const client = getClient()
672
+ return client.getPendingQuestions()
673
+ }
674
+
675
+ // Re-export the question type
676
+ export type { OpenCodeQuestion }