shuvmaki 0.4.26

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 (94) hide show
  1. package/bin.js +70 -0
  2. package/dist/ai-tool-to-genai.js +210 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/channel-management.js +97 -0
  5. package/dist/cli.js +709 -0
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +98 -0
  8. package/dist/commands/agent.js +152 -0
  9. package/dist/commands/ask-question.js +183 -0
  10. package/dist/commands/create-new-project.js +78 -0
  11. package/dist/commands/fork.js +186 -0
  12. package/dist/commands/model.js +313 -0
  13. package/dist/commands/permissions.js +126 -0
  14. package/dist/commands/queue.js +129 -0
  15. package/dist/commands/resume.js +145 -0
  16. package/dist/commands/session.js +142 -0
  17. package/dist/commands/share.js +80 -0
  18. package/dist/commands/types.js +2 -0
  19. package/dist/commands/undo-redo.js +161 -0
  20. package/dist/commands/user-command.js +145 -0
  21. package/dist/database.js +184 -0
  22. package/dist/discord-bot.js +384 -0
  23. package/dist/discord-utils.js +217 -0
  24. package/dist/escape-backticks.test.js +410 -0
  25. package/dist/format-tables.js +96 -0
  26. package/dist/format-tables.test.js +418 -0
  27. package/dist/genai-worker-wrapper.js +109 -0
  28. package/dist/genai-worker.js +297 -0
  29. package/dist/genai.js +232 -0
  30. package/dist/interaction-handler.js +144 -0
  31. package/dist/logger.js +51 -0
  32. package/dist/markdown.js +310 -0
  33. package/dist/markdown.test.js +262 -0
  34. package/dist/message-formatting.js +273 -0
  35. package/dist/message-formatting.test.js +73 -0
  36. package/dist/openai-realtime.js +228 -0
  37. package/dist/opencode.js +216 -0
  38. package/dist/session-handler.js +580 -0
  39. package/dist/system-message.js +61 -0
  40. package/dist/tools.js +356 -0
  41. package/dist/utils.js +85 -0
  42. package/dist/voice-handler.js +541 -0
  43. package/dist/voice.js +314 -0
  44. package/dist/worker-types.js +4 -0
  45. package/dist/xml.js +92 -0
  46. package/dist/xml.test.js +32 -0
  47. package/package.json +60 -0
  48. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  49. package/src/__snapshots__/compact-session-context.md +47 -0
  50. package/src/ai-tool-to-genai.test.ts +296 -0
  51. package/src/ai-tool-to-genai.ts +255 -0
  52. package/src/channel-management.ts +161 -0
  53. package/src/cli.ts +1010 -0
  54. package/src/commands/abort.ts +94 -0
  55. package/src/commands/add-project.ts +139 -0
  56. package/src/commands/agent.ts +201 -0
  57. package/src/commands/ask-question.ts +276 -0
  58. package/src/commands/create-new-project.ts +111 -0
  59. package/src/commands/fork.ts +257 -0
  60. package/src/commands/model.ts +402 -0
  61. package/src/commands/permissions.ts +146 -0
  62. package/src/commands/queue.ts +181 -0
  63. package/src/commands/resume.ts +230 -0
  64. package/src/commands/session.ts +184 -0
  65. package/src/commands/share.ts +96 -0
  66. package/src/commands/types.ts +25 -0
  67. package/src/commands/undo-redo.ts +213 -0
  68. package/src/commands/user-command.ts +178 -0
  69. package/src/database.ts +220 -0
  70. package/src/discord-bot.ts +513 -0
  71. package/src/discord-utils.ts +282 -0
  72. package/src/escape-backticks.test.ts +447 -0
  73. package/src/format-tables.test.ts +440 -0
  74. package/src/format-tables.ts +110 -0
  75. package/src/genai-worker-wrapper.ts +160 -0
  76. package/src/genai-worker.ts +366 -0
  77. package/src/genai.ts +321 -0
  78. package/src/interaction-handler.ts +187 -0
  79. package/src/logger.ts +57 -0
  80. package/src/markdown.test.ts +358 -0
  81. package/src/markdown.ts +365 -0
  82. package/src/message-formatting.test.ts +81 -0
  83. package/src/message-formatting.ts +340 -0
  84. package/src/openai-realtime.ts +363 -0
  85. package/src/opencode.ts +277 -0
  86. package/src/session-handler.ts +758 -0
  87. package/src/system-message.ts +62 -0
  88. package/src/tools.ts +428 -0
  89. package/src/utils.ts +118 -0
  90. package/src/voice-handler.ts +760 -0
  91. package/src/voice.ts +432 -0
  92. package/src/worker-types.ts +66 -0
  93. package/src/xml.test.ts +37 -0
  94. package/src/xml.ts +121 -0
@@ -0,0 +1,758 @@
1
+ // OpenCode session lifecycle manager.
2
+ // Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
3
+ // Handles streaming events, permissions, abort signals, and message queuing.
4
+
5
+ import type { Part, PermissionRequest } from '@opencode-ai/sdk/v2'
6
+ import type { FilePartInput } from '@opencode-ai/sdk'
7
+ import type { Message, ThreadChannel } from 'discord.js'
8
+ import prettyMilliseconds from 'pretty-ms'
9
+ import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js'
10
+ import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js'
11
+ import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from './discord-utils.js'
12
+ import { formatPart } from './message-formatting.js'
13
+ import { getOpencodeSystemMessage } from './system-message.js'
14
+ import { createLogger } from './logger.js'
15
+ import { isAbortError } from './utils.js'
16
+ import { showAskUserQuestionDropdowns } from './commands/ask-question.js'
17
+
18
+ const sessionLogger = createLogger('SESSION')
19
+ const voiceLogger = createLogger('VOICE')
20
+ const discordLogger = createLogger('DISCORD')
21
+
22
+ export const abortControllers = new Map<string, AbortController>()
23
+
24
+ export const pendingPermissions = new Map<
25
+ string,
26
+ { permission: PermissionRequest; messageId: string; directory: string }
27
+ >()
28
+
29
+ export type QueuedMessage = {
30
+ prompt: string
31
+ userId: string
32
+ username: string
33
+ queuedAt: number
34
+ images?: FilePartInput[]
35
+ }
36
+
37
+ // Queue of messages waiting to be sent after current response finishes
38
+ // Key is threadId, value is array of queued messages
39
+ export const messageQueue = new Map<string, QueuedMessage[]>()
40
+
41
+ export function addToQueue({
42
+ threadId,
43
+ message,
44
+ }: {
45
+ threadId: string
46
+ message: QueuedMessage
47
+ }): number {
48
+ const queue = messageQueue.get(threadId) || []
49
+ queue.push(message)
50
+ messageQueue.set(threadId, queue)
51
+ return queue.length
52
+ }
53
+
54
+ export function getQueueLength(threadId: string): number {
55
+ return messageQueue.get(threadId)?.length || 0
56
+ }
57
+
58
+ export function clearQueue(threadId: string): void {
59
+ messageQueue.delete(threadId)
60
+ }
61
+
62
+ /**
63
+ * Abort a running session and retry with the last user message.
64
+ * Used when model preference changes mid-request.
65
+ * Fetches last user message from OpenCode API instead of tracking in memory.
66
+ * @returns true if aborted and retry scheduled, false if no active request
67
+ */
68
+ export async function abortAndRetrySession({
69
+ sessionId,
70
+ thread,
71
+ projectDirectory,
72
+ }: {
73
+ sessionId: string
74
+ thread: ThreadChannel
75
+ projectDirectory: string
76
+ }): Promise<boolean> {
77
+ const controller = abortControllers.get(sessionId)
78
+
79
+ if (!controller) {
80
+ sessionLogger.log(`[ABORT+RETRY] No active request for session ${sessionId}`)
81
+ return false
82
+ }
83
+
84
+ sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`)
85
+
86
+ // Abort with special reason so we don't show "completed" message
87
+ controller.abort('model-change')
88
+
89
+ // Also call the API abort endpoint
90
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
91
+ try {
92
+ await getClient().session.abort({ path: { id: sessionId } })
93
+ } catch (e) {
94
+ sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, e)
95
+ }
96
+
97
+ // Small delay to let the abort propagate
98
+ await new Promise((resolve) => { setTimeout(resolve, 300) })
99
+
100
+ // Fetch last user message from API
101
+ sessionLogger.log(`[ABORT+RETRY] Fetching last user message for session ${sessionId}`)
102
+ const messagesResponse = await getClient().session.messages({ path: { id: sessionId } })
103
+ const messages = messagesResponse.data || []
104
+ const lastUserMessage = [...messages].reverse().find((m) => m.info.role === 'user')
105
+
106
+ if (!lastUserMessage) {
107
+ sessionLogger.log(`[ABORT+RETRY] No user message found in session ${sessionId}`)
108
+ return false
109
+ }
110
+
111
+ // Extract text and images from parts
112
+ const textPart = lastUserMessage.parts.find((p) => p.type === 'text') as { type: 'text'; text: string } | undefined
113
+ const prompt = textPart?.text || ''
114
+ const images = lastUserMessage.parts.filter((p) => p.type === 'file') as FilePartInput[]
115
+
116
+ sessionLogger.log(`[ABORT+RETRY] Re-triggering session ${sessionId} with new model`)
117
+
118
+ // Use setImmediate to avoid blocking
119
+ setImmediate(() => {
120
+ handleOpencodeSession({
121
+ prompt,
122
+ thread,
123
+ projectDirectory,
124
+ images,
125
+ }).catch(async (e) => {
126
+ sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, e)
127
+ const errorMsg = e instanceof Error ? e.message : String(e)
128
+ await sendThreadMessage(thread, `✗ Failed to retry with new model: ${errorMsg.slice(0, 200)}`)
129
+ })
130
+ })
131
+
132
+ return true
133
+ }
134
+
135
+ export async function handleOpencodeSession({
136
+ prompt,
137
+ thread,
138
+ projectDirectory,
139
+ originalMessage,
140
+ images = [],
141
+ channelId,
142
+ command,
143
+ }: {
144
+ prompt: string
145
+ thread: ThreadChannel
146
+ projectDirectory?: string
147
+ originalMessage?: Message
148
+ images?: FilePartInput[]
149
+ channelId?: string
150
+ /** If set, uses session.command API instead of session.prompt */
151
+ command?: { name: string; arguments: string }
152
+ }): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
153
+ voiceLogger.log(
154
+ `[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
155
+ )
156
+
157
+ const sessionStartTime = Date.now()
158
+
159
+ const directory = projectDirectory || process.cwd()
160
+ sessionLogger.log(`Using directory: ${directory}`)
161
+
162
+ const getClient = await initializeOpencodeForDirectory(directory)
163
+
164
+ const serverEntry = getOpencodeServers().get(directory)
165
+ const port = serverEntry?.port
166
+
167
+ const row = getDatabase()
168
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
169
+ .get(thread.id) as { session_id: string } | undefined
170
+ let sessionId = row?.session_id
171
+ let session
172
+
173
+ if (sessionId) {
174
+ sessionLogger.log(`Attempting to reuse existing session ${sessionId}`)
175
+ try {
176
+ const sessionResponse = await getClient().session.get({
177
+ path: { id: sessionId },
178
+ })
179
+ session = sessionResponse.data
180
+ sessionLogger.log(`Successfully reused session ${sessionId}`)
181
+ } catch (error) {
182
+ voiceLogger.log(
183
+ `[SESSION] Session ${sessionId} not found, will create new one`,
184
+ )
185
+ }
186
+ }
187
+
188
+ if (!session) {
189
+ const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80)
190
+ voiceLogger.log(
191
+ `[SESSION] Creating new session with title: "${sessionTitle}"`,
192
+ )
193
+ const sessionResponse = await getClient().session.create({
194
+ body: { title: sessionTitle },
195
+ })
196
+ session = sessionResponse.data
197
+ sessionLogger.log(`Created new session ${session?.id}`)
198
+ }
199
+
200
+ if (!session) {
201
+ throw new Error('Failed to create or get session')
202
+ }
203
+
204
+ getDatabase()
205
+ .prepare(
206
+ 'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
207
+ )
208
+ .run(thread.id, session.id)
209
+ sessionLogger.log(`Stored session ${session.id} for thread ${thread.id}`)
210
+
211
+ const existingController = abortControllers.get(session.id)
212
+ if (existingController) {
213
+ voiceLogger.log(
214
+ `[ABORT] Cancelling existing request for session: ${session.id}`,
215
+ )
216
+ existingController.abort(new Error('New request started'))
217
+ }
218
+
219
+ const pendingPerm = pendingPermissions.get(thread.id)
220
+ if (pendingPerm) {
221
+ try {
222
+ sessionLogger.log(`[PERMISSION] Auto-rejecting pending permission ${pendingPerm.permission.id} due to new message`)
223
+ await getClient().postSessionIdPermissionsPermissionId({
224
+ path: {
225
+ id: pendingPerm.permission.sessionID,
226
+ permissionID: pendingPerm.permission.id,
227
+ },
228
+ body: { response: 'reject' },
229
+ })
230
+ pendingPermissions.delete(thread.id)
231
+ await sendThreadMessage(thread, `⚠️ Previous permission request auto-rejected due to new message`)
232
+ } catch (e) {
233
+ sessionLogger.log(`[PERMISSION] Failed to auto-reject permission:`, e)
234
+ pendingPermissions.delete(thread.id)
235
+ }
236
+ }
237
+
238
+ const abortController = new AbortController()
239
+ abortControllers.set(session.id, abortController)
240
+
241
+ if (existingController) {
242
+ await new Promise((resolve) => { setTimeout(resolve, 200) })
243
+ if (abortController.signal.aborted) {
244
+ sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`)
245
+ return
246
+ }
247
+ }
248
+
249
+ if (abortController.signal.aborted) {
250
+ sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`)
251
+ return
252
+ }
253
+
254
+ // Use v2 client for event subscription (has proper types for question.asked events)
255
+ const clientV2 = getOpencodeClientV2(directory)
256
+ if (!clientV2) {
257
+ throw new Error(`OpenCode v2 client not found for directory: ${directory}`)
258
+ }
259
+ const eventsResult = await clientV2.event.subscribe(
260
+ { directory },
261
+ { signal: abortController.signal }
262
+ )
263
+
264
+ if (abortController.signal.aborted) {
265
+ sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`)
266
+ return
267
+ }
268
+
269
+ const events = eventsResult.stream
270
+ sessionLogger.log(`Subscribed to OpenCode events`)
271
+
272
+ const sentPartIds = new Set<string>(
273
+ (getDatabase()
274
+ .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
275
+ .all(thread.id) as { part_id: string }[])
276
+ .map((row) => row.part_id)
277
+ )
278
+
279
+ let currentParts: Part[] = []
280
+ let stopTyping: (() => void) | null = null
281
+ let usedModel: string | undefined
282
+ let usedProviderID: string | undefined
283
+ let usedAgent: string | undefined
284
+ let tokensUsedInSession = 0
285
+ let lastDisplayedContextPercentage = 0
286
+ let modelContextLimit: number | undefined
287
+
288
+ let typingInterval: NodeJS.Timeout | null = null
289
+
290
+ function startTyping(): () => void {
291
+ if (abortController.signal.aborted) {
292
+ discordLogger.log(`Not starting typing, already aborted`)
293
+ return () => {}
294
+ }
295
+ if (typingInterval) {
296
+ clearInterval(typingInterval)
297
+ typingInterval = null
298
+ }
299
+
300
+ thread.sendTyping().catch((e) => {
301
+ discordLogger.log(`Failed to send initial typing: ${e}`)
302
+ })
303
+
304
+ typingInterval = setInterval(() => {
305
+ thread.sendTyping().catch((e) => {
306
+ discordLogger.log(`Failed to send periodic typing: ${e}`)
307
+ })
308
+ }, 8000)
309
+
310
+ if (!abortController.signal.aborted) {
311
+ abortController.signal.addEventListener(
312
+ 'abort',
313
+ () => {
314
+ if (typingInterval) {
315
+ clearInterval(typingInterval)
316
+ typingInterval = null
317
+ }
318
+ },
319
+ { once: true },
320
+ )
321
+ }
322
+
323
+ return () => {
324
+ if (typingInterval) {
325
+ clearInterval(typingInterval)
326
+ typingInterval = null
327
+ }
328
+ }
329
+ }
330
+
331
+ const sendPartMessage = async (part: Part) => {
332
+ const content = formatPart(part) + '\n\n'
333
+ if (!content.trim() || content.length === 0) {
334
+ // discordLogger.log(`SKIP: Part ${part.id} has no content`)
335
+ return
336
+ }
337
+
338
+ if (sentPartIds.has(part.id)) {
339
+ return
340
+ }
341
+
342
+ try {
343
+ const firstMessage = await sendThreadMessage(thread, content)
344
+ sentPartIds.add(part.id)
345
+
346
+ getDatabase()
347
+ .prepare(
348
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
349
+ )
350
+ .run(part.id, firstMessage.id, thread.id)
351
+ } catch (error) {
352
+ discordLogger.error(`ERROR: Failed to send part ${part.id}:`, error)
353
+ }
354
+ }
355
+
356
+ const eventHandler = async () => {
357
+ try {
358
+ let assistantMessageId: string | undefined
359
+
360
+ for await (const event of events) {
361
+ if (event.type === 'message.updated') {
362
+ const msg = event.properties.info
363
+
364
+ if (msg.sessionID !== session.id) {
365
+ continue
366
+ }
367
+
368
+ if (msg.role === 'assistant') {
369
+ const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
370
+ if (newTokensTotal > 0) {
371
+ tokensUsedInSession = newTokensTotal
372
+ }
373
+
374
+ assistantMessageId = msg.id
375
+ usedModel = msg.modelID
376
+ usedProviderID = msg.providerID
377
+ usedAgent = msg.mode
378
+
379
+ if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
380
+ if (!modelContextLimit) {
381
+ try {
382
+ const providersResponse = await getClient().provider.list({ query: { directory } })
383
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
384
+ const model = provider?.models?.[usedModel]
385
+ if (model?.limit?.context) {
386
+ modelContextLimit = model.limit.context
387
+ }
388
+ } catch (e) {
389
+ sessionLogger.error('Failed to fetch provider info for context limit:', e)
390
+ }
391
+ }
392
+
393
+ if (modelContextLimit) {
394
+ const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100)
395
+ const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
396
+ if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
397
+ lastDisplayedContextPercentage = thresholdCrossed
398
+ await sendThreadMessage(thread, `⬥ context usage ${currentPercentage}%`)
399
+ }
400
+ }
401
+ }
402
+ }
403
+ } else if (event.type === 'message.part.updated') {
404
+ const part = event.properties.part
405
+
406
+ if (part.sessionID !== session.id) {
407
+ continue
408
+ }
409
+
410
+ if (part.messageID !== assistantMessageId) {
411
+ continue
412
+ }
413
+
414
+ const existingIndex = currentParts.findIndex(
415
+ (p: Part) => p.id === part.id,
416
+ )
417
+ if (existingIndex >= 0) {
418
+ currentParts[existingIndex] = part
419
+ } else {
420
+ currentParts.push(part)
421
+ }
422
+
423
+ if (part.type === 'step-start') {
424
+ stopTyping = startTyping()
425
+ }
426
+
427
+ if (part.type === 'tool' && part.state.status === 'running') {
428
+ await sendPartMessage(part)
429
+ }
430
+
431
+ if (part.type === 'reasoning') {
432
+ await sendPartMessage(part)
433
+ }
434
+
435
+ if (part.type === 'step-finish') {
436
+ for (const p of currentParts) {
437
+ if (p.type !== 'step-start' && p.type !== 'step-finish') {
438
+ await sendPartMessage(p)
439
+ }
440
+ }
441
+ setTimeout(() => {
442
+ if (abortController.signal.aborted) return
443
+ stopTyping = startTyping()
444
+ }, 300)
445
+ }
446
+ } else if (event.type === 'session.error') {
447
+ sessionLogger.error(`ERROR:`, event.properties)
448
+ if (event.properties.sessionID === session.id) {
449
+ const errorData = event.properties.error
450
+ const errorMessage = errorData?.data?.message || 'Unknown error'
451
+ sessionLogger.error(`Sending error to thread: ${errorMessage}`)
452
+ await sendThreadMessage(
453
+ thread,
454
+ `✗ opencode session error: ${errorMessage}`,
455
+ )
456
+
457
+ if (originalMessage) {
458
+ try {
459
+ await originalMessage.reactions.removeAll()
460
+ await originalMessage.react('❌')
461
+ voiceLogger.log(
462
+ `[REACTION] Added error reaction due to session error`,
463
+ )
464
+ } catch (e) {
465
+ discordLogger.log(`Could not update reaction:`, e)
466
+ }
467
+ }
468
+ } else {
469
+ voiceLogger.log(
470
+ `[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${event.properties.sessionID})`,
471
+ )
472
+ }
473
+ break
474
+ } else if (event.type === 'permission.asked') {
475
+ const permission = event.properties
476
+ if (permission.sessionID !== session.id) {
477
+ voiceLogger.log(
478
+ `[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`,
479
+ )
480
+ continue
481
+ }
482
+
483
+ sessionLogger.log(
484
+ `Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
485
+ )
486
+
487
+ const patternStr = permission.patterns.join(', ')
488
+
489
+ const permissionMessage = await sendThreadMessage(
490
+ thread,
491
+ `⚠️ **Permission Required**\n\n` +
492
+ `**Type:** \`${permission.permission}\`\n` +
493
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
494
+ `\nUse \`/accept\` or \`/reject\` to respond.`,
495
+ )
496
+
497
+ pendingPermissions.set(thread.id, {
498
+ permission,
499
+ messageId: permissionMessage.id,
500
+ directory,
501
+ })
502
+ } else if (event.type === 'permission.replied') {
503
+ const { requestID, reply, sessionID } = event.properties
504
+ if (sessionID !== session.id) {
505
+ continue
506
+ }
507
+
508
+ sessionLogger.log(
509
+ `Permission ${requestID} replied with: ${reply}`,
510
+ )
511
+
512
+ const pending = pendingPermissions.get(thread.id)
513
+ if (pending && pending.permission.id === requestID) {
514
+ pendingPermissions.delete(thread.id)
515
+ }
516
+ } else if (event.type === 'question.asked') {
517
+ const questionRequest = event.properties
518
+
519
+ if (questionRequest.sessionID !== session.id) {
520
+ sessionLogger.log(
521
+ `[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`,
522
+ )
523
+ continue
524
+ }
525
+
526
+ sessionLogger.log(
527
+ `Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
528
+ )
529
+
530
+ await showAskUserQuestionDropdowns({
531
+ thread,
532
+ sessionId: session.id,
533
+ directory,
534
+ requestId: questionRequest.id,
535
+ input: { questions: questionRequest.questions },
536
+ })
537
+ }
538
+ }
539
+ } catch (e) {
540
+ if (isAbortError(e, abortController.signal)) {
541
+ sessionLogger.log(
542
+ 'AbortController aborted event handling (normal exit)',
543
+ )
544
+ return
545
+ }
546
+ sessionLogger.error(`Unexpected error in event handling code`, e)
547
+ throw e
548
+ } finally {
549
+ for (const part of currentParts) {
550
+ if (!sentPartIds.has(part.id)) {
551
+ try {
552
+ await sendPartMessage(part)
553
+ } catch (error) {
554
+ sessionLogger.error(`Failed to send part ${part.id}:`, error)
555
+ }
556
+ }
557
+ }
558
+
559
+ if (stopTyping) {
560
+ stopTyping()
561
+ stopTyping = null
562
+ }
563
+
564
+ if (
565
+ !abortController.signal.aborted ||
566
+ abortController.signal.reason === 'finished'
567
+ ) {
568
+ const sessionDuration = prettyMilliseconds(
569
+ Date.now() - sessionStartTime,
570
+ )
571
+ const attachCommand = port ? ` ⋅ ${session.id}` : ''
572
+ const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
573
+ const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : ''
574
+ let contextInfo = ''
575
+
576
+ try {
577
+ const providersResponse = await getClient().provider.list({ query: { directory } })
578
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
579
+ const model = provider?.models?.[usedModel || '']
580
+ if (model?.limit?.context) {
581
+ const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100)
582
+ contextInfo = ` ⋅ ${percentage}%`
583
+ }
584
+ } catch (e) {
585
+ sessionLogger.error('Failed to fetch provider info for context percentage:', e)
586
+ }
587
+
588
+ await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`, { flags: NOTIFY_MESSAGE_FLAGS })
589
+ sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`)
590
+
591
+ // Process queued messages after completion
592
+ const queue = messageQueue.get(thread.id)
593
+ if (queue && queue.length > 0) {
594
+ const nextMessage = queue.shift()!
595
+ if (queue.length === 0) {
596
+ messageQueue.delete(thread.id)
597
+ }
598
+
599
+ sessionLogger.log(`[QUEUE] Processing queued message from ${nextMessage.username}`)
600
+
601
+ // Show that queued message is being sent
602
+ await sendThreadMessage(thread, `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`)
603
+
604
+ // Send the queued message as a new prompt (recursive call)
605
+ // Use setImmediate to avoid blocking and allow this finally to complete
606
+ setImmediate(() => {
607
+ handleOpencodeSession({
608
+ prompt: nextMessage.prompt,
609
+ thread,
610
+ projectDirectory,
611
+ images: nextMessage.images,
612
+ channelId,
613
+ }).catch(async (e) => {
614
+ sessionLogger.error(`[QUEUE] Failed to process queued message:`, e)
615
+ const errorMsg = e instanceof Error ? e.message : String(e)
616
+ await sendThreadMessage(thread, `✗ Queued message failed: ${errorMsg.slice(0, 200)}`)
617
+ })
618
+ })
619
+ }
620
+ } else {
621
+ sessionLogger.log(
622
+ `Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
623
+ )
624
+ }
625
+ }
626
+ }
627
+
628
+ try {
629
+ const eventHandlerPromise = eventHandler()
630
+
631
+ if (abortController.signal.aborted) {
632
+ sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`)
633
+ return
634
+ }
635
+
636
+ stopTyping = startTyping()
637
+
638
+ voiceLogger.log(
639
+ `[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
640
+ )
641
+ // append image paths to prompt so ai knows where they are on disk
642
+ const promptWithImagePaths = (() => {
643
+ if (images.length === 0) {
644
+ return prompt
645
+ }
646
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
647
+ const imagePathsList = images.map((img) => `- ${img.filename}: ${img.url}`).join('\n')
648
+ return `${prompt}\n\n**attached images:**\n${imagePathsList}`
649
+ })()
650
+
651
+ const parts = [{ type: 'text' as const, text: promptWithImagePaths }, ...images]
652
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
653
+
654
+ // Get model preference: session-level overrides channel-level
655
+ const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
656
+ const modelParam = (() => {
657
+ if (!modelPreference) {
658
+ return undefined
659
+ }
660
+ const [providerID, ...modelParts] = modelPreference.split('/')
661
+ const modelID = modelParts.join('/')
662
+ if (!providerID || !modelID) {
663
+ return undefined
664
+ }
665
+ sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`)
666
+ return { providerID, modelID }
667
+ })()
668
+
669
+ // Get agent preference: session-level overrides channel-level
670
+ const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
671
+ if (agentPreference) {
672
+ sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
673
+ }
674
+
675
+ // Use session.command API for slash commands, session.prompt for regular messages
676
+ const response = command
677
+ ? await getClient().session.command({
678
+ path: { id: session.id },
679
+ body: {
680
+ command: command.name,
681
+ arguments: command.arguments,
682
+ agent: agentPreference,
683
+ },
684
+ signal: abortController.signal,
685
+ })
686
+ : await getClient().session.prompt({
687
+ path: { id: session.id },
688
+ body: {
689
+ parts,
690
+ system: getOpencodeSystemMessage({ sessionId: session.id }),
691
+ model: modelParam,
692
+ agent: agentPreference,
693
+ },
694
+ signal: abortController.signal,
695
+ })
696
+
697
+ if (response.error) {
698
+ const errorMessage = (() => {
699
+ const err = response.error
700
+ if (err && typeof err === 'object') {
701
+ if ('data' in err && err.data && typeof err.data === 'object' && 'message' in err.data) {
702
+ return String(err.data.message)
703
+ }
704
+ if ('errors' in err && Array.isArray(err.errors) && err.errors.length > 0) {
705
+ return JSON.stringify(err.errors)
706
+ }
707
+ }
708
+ return JSON.stringify(err)
709
+ })()
710
+ throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`)
711
+ }
712
+
713
+ abortController.abort('finished')
714
+
715
+ sessionLogger.log(`Successfully sent prompt, got response`)
716
+
717
+ if (originalMessage) {
718
+ try {
719
+ await originalMessage.reactions.removeAll()
720
+ await originalMessage.react('✅')
721
+ } catch (e) {
722
+ discordLogger.log(`Could not update reactions:`, e)
723
+ }
724
+ }
725
+
726
+ return { sessionID: session.id, result: response.data, port }
727
+ } catch (error) {
728
+ sessionLogger.error(`ERROR: Failed to send prompt:`, error)
729
+
730
+ if (!isAbortError(error, abortController.signal)) {
731
+ abortController.abort('error')
732
+
733
+ if (originalMessage) {
734
+ try {
735
+ await originalMessage.reactions.removeAll()
736
+ await originalMessage.react('❌')
737
+ discordLogger.log(`Added error reaction to message`)
738
+ } catch (e) {
739
+ discordLogger.log(`Could not update reaction:`, e)
740
+ }
741
+ }
742
+ const errorName =
743
+ error &&
744
+ typeof error === 'object' &&
745
+ 'constructor' in error &&
746
+ error.constructor &&
747
+ typeof error.constructor.name === 'string'
748
+ ? error.constructor.name
749
+ : typeof error
750
+ const errorMsg =
751
+ error instanceof Error ? error.stack || error.message : String(error)
752
+ await sendThreadMessage(
753
+ thread,
754
+ `✗ Unexpected bot Error: [${errorName}]\n${errorMsg}`,
755
+ )
756
+ }
757
+ }
758
+ }