kimaki 0.4.44 → 0.4.45

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 (41) hide show
  1. package/dist/channel-management.js +6 -15
  2. package/dist/cli.js +54 -37
  3. package/dist/commands/permissions.js +21 -5
  4. package/dist/commands/queue.js +5 -1
  5. package/dist/commands/resume.js +8 -16
  6. package/dist/commands/session.js +18 -42
  7. package/dist/commands/user-command.js +8 -17
  8. package/dist/commands/verbosity.js +53 -0
  9. package/dist/commands/worktree-settings.js +2 -2
  10. package/dist/commands/worktree.js +132 -25
  11. package/dist/database.js +49 -0
  12. package/dist/discord-bot.js +24 -38
  13. package/dist/discord-utils.js +51 -13
  14. package/dist/discord-utils.test.js +20 -0
  15. package/dist/escape-backticks.test.js +14 -3
  16. package/dist/interaction-handler.js +4 -0
  17. package/dist/session-handler.js +541 -413
  18. package/package.json +1 -1
  19. package/src/__snapshots__/first-session-no-info.md +1344 -0
  20. package/src/__snapshots__/first-session-with-info.md +1350 -0
  21. package/src/__snapshots__/session-1.md +1344 -0
  22. package/src/__snapshots__/session-2.md +291 -0
  23. package/src/__snapshots__/session-3.md +20324 -0
  24. package/src/__snapshots__/session-with-tools.md +1344 -0
  25. package/src/channel-management.ts +6 -17
  26. package/src/cli.ts +63 -45
  27. package/src/commands/permissions.ts +31 -5
  28. package/src/commands/queue.ts +5 -1
  29. package/src/commands/resume.ts +8 -18
  30. package/src/commands/session.ts +18 -44
  31. package/src/commands/user-command.ts +8 -19
  32. package/src/commands/verbosity.ts +71 -0
  33. package/src/commands/worktree-settings.ts +2 -2
  34. package/src/commands/worktree.ts +160 -27
  35. package/src/database.ts +65 -0
  36. package/src/discord-bot.ts +26 -42
  37. package/src/discord-utils.test.ts +23 -0
  38. package/src/discord-utils.ts +52 -13
  39. package/src/escape-backticks.test.ts +14 -3
  40. package/src/interaction-handler.ts +5 -0
  41. package/src/session-handler.ts +669 -436
@@ -2,7 +2,7 @@
2
2
  // Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
3
3
  // Handles streaming events, permissions, abort signals, and message queuing.
4
4
 
5
- import type { Part, PermissionRequest } from '@opencode-ai/sdk/v2'
5
+ import type { Part, PermissionRequest, QuestionRequest } from '@opencode-ai/sdk/v2'
6
6
  import type { FilePartInput } from '@opencode-ai/sdk'
7
7
  import type { Message, ThreadChannel } from 'discord.js'
8
8
  import prettyMilliseconds from 'pretty-ms'
@@ -14,6 +14,7 @@ import {
14
14
  getChannelAgent,
15
15
  setSessionAgent,
16
16
  getThreadWorktree,
17
+ getChannelVerbosity,
17
18
  } from './database.js'
18
19
  import {
19
20
  initializeOpencodeForDirectory,
@@ -30,7 +31,11 @@ import {
30
31
  cancelPendingQuestion,
31
32
  pendingQuestionContexts,
32
33
  } from './commands/ask-question.js'
33
- import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js'
34
+ import {
35
+ showPermissionDropdown,
36
+ cleanupPermissionContext,
37
+ addPermissionRequestToContext,
38
+ } from './commands/permissions.js'
34
39
  import * as errore from 'errore'
35
40
 
36
41
  const sessionLogger = createLogger('SESSION')
@@ -44,9 +49,31 @@ export const abortControllers = new Map<string, AbortController>()
44
49
  // to avoid duplicates and properly clean up on auto-reject
45
50
  export const pendingPermissions = new Map<
46
51
  string, // threadId
47
- Map<string, { permission: PermissionRequest; messageId: string; directory: string; contextHash: string }> // permissionId -> data
52
+ Map<
53
+ string,
54
+ {
55
+ permission: PermissionRequest
56
+ messageId: string
57
+ directory: string
58
+ contextHash: string
59
+ dedupeKey: string
60
+ }
61
+ > // permissionId -> data
48
62
  >()
49
63
 
64
+ function buildPermissionDedupeKey({
65
+ permission,
66
+ directory,
67
+ }: {
68
+ permission: PermissionRequest
69
+ directory: string
70
+ }): string {
71
+ const normalizedPatterns = [...permission.patterns].sort((a, b) => {
72
+ return a.localeCompare(b)
73
+ })
74
+ return `${directory}::${permission.permission}::${normalizedPatterns.join('|')}`
75
+ }
76
+
50
77
  export type QueuedMessage = {
51
78
  prompt: string
52
79
  userId: string
@@ -113,10 +140,11 @@ export async function abortAndRetrySession({
113
140
  sessionLogger.error(`[ABORT+RETRY] Failed to initialize OpenCode client:`, getClient.message)
114
141
  return false
115
142
  }
116
- try {
117
- await getClient().session.abort({ path: { id: sessionId } })
118
- } catch (e) {
119
- sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, e)
143
+ const abortResult = await errore.tryAsync(() => {
144
+ return getClient().session.abort({ path: { id: sessionId } })
145
+ })
146
+ if (abortResult instanceof Error) {
147
+ sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, abortResult)
120
148
  }
121
149
 
122
150
  // Small delay to let the abort propagate
@@ -146,16 +174,25 @@ export async function abortAndRetrySession({
146
174
 
147
175
  // Use setImmediate to avoid blocking
148
176
  setImmediate(() => {
149
- handleOpencodeSession({
150
- prompt,
151
- thread,
152
- projectDirectory,
153
- images,
154
- }).catch(async (e) => {
155
- sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, e)
156
- const errorMsg = e instanceof Error ? e.message : String(e)
157
- await sendThreadMessage(thread, `✗ Failed to retry with new model: ${errorMsg.slice(0, 200)}`)
158
- })
177
+ void errore
178
+ .tryAsync(async () => {
179
+ return handleOpencodeSession({
180
+ prompt,
181
+ thread,
182
+ projectDirectory,
183
+ images,
184
+ })
185
+ })
186
+ .then(async (result) => {
187
+ if (!(result instanceof Error)) {
188
+ return
189
+ }
190
+ sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, result)
191
+ await sendThreadMessage(
192
+ thread,
193
+ `✗ Failed to retry with new model: ${result.message.slice(0, 200)}`,
194
+ )
195
+ })
159
196
  })
160
197
 
161
198
  return true
@@ -191,6 +228,18 @@ export async function handleOpencodeSession({
191
228
  const directory = projectDirectory || process.cwd()
192
229
  sessionLogger.log(`Using directory: ${directory}`)
193
230
 
231
+ // Get worktree info early so we can use the correct directory for events and prompts
232
+ const worktreeInfo = getThreadWorktree(thread.id)
233
+ const worktreeDirectory =
234
+ worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
235
+ ? worktreeInfo.worktree_directory
236
+ : undefined
237
+ // Use worktree directory for SDK calls if available, otherwise project directory
238
+ const sdkDirectory = worktreeDirectory || directory
239
+ if (worktreeDirectory) {
240
+ sessionLogger.log(`Using worktree directory for SDK calls: ${worktreeDirectory}`)
241
+ }
242
+
194
243
  const getClient = await initializeOpencodeForDirectory(directory)
195
244
  if (getClient instanceof Error) {
196
245
  await sendThreadMessage(thread, `✗ ${getClient.message}`)
@@ -208,14 +257,17 @@ export async function handleOpencodeSession({
208
257
 
209
258
  if (sessionId) {
210
259
  sessionLogger.log(`Attempting to reuse existing session ${sessionId}`)
211
- try {
212
- const sessionResponse = await getClient().session.get({
260
+ const sessionResponse = await errore.tryAsync(() => {
261
+ return getClient().session.get({
213
262
  path: { id: sessionId },
263
+ query: { directory: sdkDirectory },
214
264
  })
265
+ })
266
+ if (sessionResponse instanceof Error) {
267
+ voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`)
268
+ } else {
215
269
  session = sessionResponse.data
216
270
  sessionLogger.log(`Successfully reused session ${sessionId}`)
217
- } catch (error) {
218
- voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`)
219
271
  }
220
272
  }
221
273
 
@@ -224,6 +276,7 @@ export async function handleOpencodeSession({
224
276
  voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`)
225
277
  const sessionResponse = await getClient().session.create({
226
278
  body: { title: sessionTitle },
279
+ query: { directory: sdkDirectory },
227
280
  })
228
281
  session = sessionResponse.data
229
282
  sessionLogger.log(`Created new session ${session?.id}`)
@@ -256,20 +309,25 @@ export async function handleOpencodeSession({
256
309
  const clientV2 = getOpencodeClientV2(directory)
257
310
  let rejectedCount = 0
258
311
  for (const [permId, pendingPerm] of threadPermissions) {
259
- try {
260
- sessionLogger.log(`[PERMISSION] Auto-rejecting permission ${permId} due to new message`)
261
- if (clientV2) {
262
- await clientV2.permission.reply({
263
- requestID: permId,
264
- reply: 'reject',
265
- })
266
- }
312
+ sessionLogger.log(`[PERMISSION] Auto-rejecting permission ${permId} due to new message`)
313
+ if (!clientV2) {
314
+ sessionLogger.log(`[PERMISSION] OpenCode v2 client unavailable for permission ${permId}`)
267
315
  cleanupPermissionContext(pendingPerm.contextHash)
268
316
  rejectedCount++
269
- } catch (e) {
270
- sessionLogger.log(`[PERMISSION] Failed to auto-reject permission ${permId}:`, e)
271
- cleanupPermissionContext(pendingPerm.contextHash)
317
+ continue
318
+ }
319
+ const rejectResult = await errore.tryAsync(() => {
320
+ return clientV2.permission.reply({
321
+ requestID: permId,
322
+ reply: 'reject',
323
+ })
324
+ })
325
+ if (rejectResult instanceof Error) {
326
+ sessionLogger.log(`[PERMISSION] Failed to auto-reject permission ${permId}:`, rejectResult)
327
+ } else {
328
+ rejectedCount++
272
329
  }
330
+ cleanupPermissionContext(pendingPerm.contextHash)
273
331
  }
274
332
  pendingPermissions.delete(thread.id)
275
333
  if (rejectedCount > 0) {
@@ -311,7 +369,7 @@ export async function handleOpencodeSession({
311
369
  throw new Error(`OpenCode v2 client not found for directory: ${directory}`)
312
370
  }
313
371
  const eventsResult = await clientV2.event.subscribe(
314
- { directory },
372
+ { directory: sdkDirectory },
315
373
  { signal: abortController.signal },
316
374
  )
317
375
 
@@ -331,7 +389,7 @@ export async function handleOpencodeSession({
331
389
  ).map((row) => row.part_id),
332
390
  )
333
391
 
334
- let currentParts: Part[] = []
392
+ const partBuffer = new Map<string, Map<string, Part>>()
335
393
  let stopTyping: (() => void) | null = null
336
394
  let usedModel: string | undefined
337
395
  let usedProviderID: string | undefined
@@ -339,6 +397,7 @@ export async function handleOpencodeSession({
339
397
  let tokensUsedInSession = 0
340
398
  let lastDisplayedContextPercentage = 0
341
399
  let modelContextLimit: number | undefined
400
+ let assistantMessageId: string | undefined
342
401
 
343
402
  let typingInterval: NodeJS.Timeout | null = null
344
403
 
@@ -352,13 +411,17 @@ export async function handleOpencodeSession({
352
411
  typingInterval = null
353
412
  }
354
413
 
355
- thread.sendTyping().catch((e) => {
356
- discordLogger.log(`Failed to send initial typing: ${e}`)
414
+ void errore.tryAsync(() => thread.sendTyping()).then((result) => {
415
+ if (result instanceof Error) {
416
+ discordLogger.log(`Failed to send initial typing: ${result}`)
417
+ }
357
418
  })
358
419
 
359
420
  typingInterval = setInterval(() => {
360
- thread.sendTyping().catch((e) => {
361
- discordLogger.log(`Failed to send periodic typing: ${e}`)
421
+ void errore.tryAsync(() => thread.sendTyping()).then((result) => {
422
+ if (result instanceof Error) {
423
+ discordLogger.log(`Failed to send periodic typing: ${result}`)
424
+ }
362
425
  })
363
426
  }, 8000)
364
427
 
@@ -383,7 +446,16 @@ export async function handleOpencodeSession({
383
446
  }
384
447
  }
385
448
 
449
+ // Get verbosity setting for this channel (use parent channel for threads)
450
+ const verbosityChannelId = channelId || thread.parentId || thread.id
451
+ const verbosity = getChannelVerbosity(verbosityChannelId)
452
+
386
453
  const sendPartMessage = async (part: Part) => {
454
+ // In text-only mode, only send text parts (the ⬥ diamond messages)
455
+ if (verbosity === 'text-only' && part.type !== 'text') {
456
+ return
457
+ }
458
+
387
459
  const content = formatPart(part) + '\n\n'
388
460
  if (!content.trim() || content.length === 0) {
389
461
  // discordLogger.log(`SKIP: Part ${part.id} has no content`)
@@ -394,18 +466,20 @@ export async function handleOpencodeSession({
394
466
  return
395
467
  }
396
468
 
397
- try {
398
- const firstMessage = await sendThreadMessage(thread, content)
399
- sentPartIds.add(part.id)
400
-
401
- getDatabase()
402
- .prepare(
403
- 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
404
- )
405
- .run(part.id, firstMessage.id, thread.id)
406
- } catch (error) {
407
- discordLogger.error(`ERROR: Failed to send part ${part.id}:`, error)
469
+ const sendResult = await errore.tryAsync(() => {
470
+ return sendThreadMessage(thread, content)
471
+ })
472
+ if (sendResult instanceof Error) {
473
+ discordLogger.error(`ERROR: Failed to send part ${part.id}:`, sendResult)
474
+ return
408
475
  }
476
+ sentPartIds.add(part.id)
477
+
478
+ getDatabase()
479
+ .prepare(
480
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
481
+ )
482
+ .run(part.id, sendResult.id, thread.id)
409
483
  }
410
484
 
411
485
  const eventHandler = async () => {
@@ -414,390 +488,544 @@ export async function handleOpencodeSession({
414
488
  // Counts spawned tasks per agent type: "explore" → 2
415
489
  const agentSpawnCounts: Record<string, number> = {}
416
490
 
417
- try {
418
- let assistantMessageId: string | undefined
491
+ const storePart = (part: Part) => {
492
+ const messageParts = partBuffer.get(part.messageID) || new Map<string, Part>()
493
+ messageParts.set(part.id, part)
494
+ partBuffer.set(part.messageID, messageParts)
495
+ }
419
496
 
420
- for await (const event of events) {
421
- if (event.type === 'message.updated') {
422
- const msg = event.properties.info
497
+ const getBufferedParts = (messageID: string) => {
498
+ return Array.from(partBuffer.get(messageID)?.values() ?? [])
499
+ }
423
500
 
424
- // Track assistant message IDs for subtask sessions
425
- const subtaskInfo = subtaskSessions.get(msg.sessionID)
426
- if (subtaskInfo && msg.role === 'assistant') {
427
- subtaskInfo.assistantMessageId = msg.id
428
- }
501
+ const shouldSendPart = ({ part, force }: { part: Part; force: boolean }) => {
502
+ if (part.type === 'step-start' || part.type === 'step-finish') {
503
+ return false
504
+ }
429
505
 
430
- if (msg.sessionID !== session.id) {
431
- continue
432
- }
506
+ if (part.type === 'tool' && part.state.status === 'pending') {
507
+ return false
508
+ }
433
509
 
434
- if (msg.role === 'assistant') {
435
- const newTokensTotal =
436
- msg.tokens.input +
437
- msg.tokens.output +
438
- msg.tokens.reasoning +
439
- msg.tokens.cache.read +
440
- msg.tokens.cache.write
441
- if (newTokensTotal > 0) {
442
- tokensUsedInSession = newTokensTotal
443
- }
510
+ if (!force && part.type === 'text' && !part.time?.end) {
511
+ return false
512
+ }
444
513
 
445
- assistantMessageId = msg.id
446
- usedModel = msg.modelID
447
- usedProviderID = msg.providerID
448
- usedAgent = msg.mode
449
-
450
- if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
451
- if (!modelContextLimit) {
452
- try {
453
- const providersResponse = await getClient().provider.list({
454
- query: { directory },
455
- })
456
- const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
457
- const model = provider?.models?.[usedModel]
458
- if (model?.limit?.context) {
459
- modelContextLimit = model.limit.context
460
- }
461
- } catch (e) {
462
- sessionLogger.error('Failed to fetch provider info for context limit:', e)
463
- }
464
- }
514
+ if (!force && part.type === 'tool' && part.state.status === 'completed') {
515
+ return false
516
+ }
465
517
 
466
- if (modelContextLimit) {
467
- const currentPercentage = Math.floor(
468
- (tokensUsedInSession / modelContextLimit) * 100,
469
- )
470
- const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
471
- if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
472
- lastDisplayedContextPercentage = thresholdCrossed
473
- const chunk = `⬦ context usage ${currentPercentage}%`
474
- await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
475
- }
476
- }
477
- }
478
- }
479
- } else if (event.type === 'message.part.updated') {
480
- const part = event.properties.part
518
+ return true
519
+ }
481
520
 
482
- // Check if this is a subtask event (child session we're tracking)
483
- const subtaskInfo = subtaskSessions.get(part.sessionID)
484
- const isSubtaskEvent = Boolean(subtaskInfo)
521
+ const flushBufferedParts = async ({
522
+ messageID,
523
+ force,
524
+ skipPartId,
525
+ }: {
526
+ messageID: string
527
+ force: boolean
528
+ skipPartId?: string
529
+ }) => {
530
+ if (!messageID) {
531
+ return
532
+ }
533
+ const parts = getBufferedParts(messageID)
534
+ for (const part of parts) {
535
+ if (skipPartId && part.id === skipPartId) {
536
+ continue
537
+ }
538
+ if (!shouldSendPart({ part, force })) {
539
+ continue
540
+ }
541
+ await sendPartMessage(part)
542
+ }
543
+ }
485
544
 
486
- // Accept events from main session OR tracked subtask sessions
487
- if (part.sessionID !== session.id && !isSubtaskEvent) {
488
- continue
489
- }
545
+ const handleMessageUpdated = async (msg: {
546
+ id: string
547
+ sessionID: string
548
+ role: string
549
+ modelID?: string
550
+ providerID?: string
551
+ mode?: string
552
+ tokens?: {
553
+ input: number
554
+ output: number
555
+ reasoning: number
556
+ cache: { read: number; write: number }
557
+ }
558
+ }) => {
559
+ const subtaskInfo = subtaskSessions.get(msg.sessionID)
560
+ if (subtaskInfo && msg.role === 'assistant') {
561
+ subtaskInfo.assistantMessageId = msg.id
562
+ }
490
563
 
491
- // For subtask events, send them immediately with prefix (don't buffer in currentParts)
492
- if (isSubtaskEvent && subtaskInfo) {
493
- // Skip parts that aren't useful to show (step-start, step-finish, pending tools)
494
- if (part.type === 'step-start' || part.type === 'step-finish') {
495
- continue
496
- }
497
- if (part.type === 'tool' && part.state.status === 'pending') {
498
- continue
499
- }
500
- // Skip text parts - the outer agent will report the task result anyway
501
- if (part.type === 'text') {
502
- continue
503
- }
504
- // Only show parts from assistant messages (not user prompts sent to subtask)
505
- // Skip if we haven't seen an assistant message yet, or if this part is from a different message
506
- if (
507
- !subtaskInfo.assistantMessageId ||
508
- part.messageID !== subtaskInfo.assistantMessageId
509
- ) {
510
- continue
511
- }
564
+ if (msg.sessionID !== session.id) {
565
+ return
566
+ }
512
567
 
513
- const content = formatPart(part, subtaskInfo.label)
514
- if (content.trim() && !sentPartIds.has(part.id)) {
515
- try {
516
- const msg = await sendThreadMessage(thread, content + '\n\n')
517
- sentPartIds.add(part.id)
518
- getDatabase()
519
- .prepare(
520
- 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
521
- )
522
- .run(part.id, msg.id, thread.id)
523
- } catch (error) {
524
- discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, error)
525
- }
526
- }
527
- continue
528
- }
568
+ if (msg.role !== 'assistant') {
569
+ return
570
+ }
529
571
 
530
- // Main session events: require matching assistantMessageId
531
- if (part.messageID !== assistantMessageId) {
532
- continue
533
- }
572
+ if (msg.tokens) {
573
+ const newTokensTotal =
574
+ msg.tokens.input +
575
+ msg.tokens.output +
576
+ msg.tokens.reasoning +
577
+ msg.tokens.cache.read +
578
+ msg.tokens.cache.write
579
+ if (newTokensTotal > 0) {
580
+ tokensUsedInSession = newTokensTotal
581
+ }
582
+ }
534
583
 
535
- const existingIndex = currentParts.findIndex((p: Part) => p.id === part.id)
536
- if (existingIndex >= 0) {
537
- currentParts[existingIndex] = part
538
- } else {
539
- currentParts.push(part)
540
- }
584
+ assistantMessageId = msg.id
585
+ usedModel = msg.modelID
586
+ usedProviderID = msg.providerID
587
+ usedAgent = msg.mode
541
588
 
542
- if (part.type === 'step-start') {
543
- // Don't start typing if user needs to respond to a question or permission
544
- const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
545
- (ctx) => ctx.thread.id === thread.id,
546
- )
547
- const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
548
- if (!hasPendingQuestion && !hasPendingPermission) {
549
- stopTyping = startTyping()
550
- }
551
- }
589
+ await flushBufferedParts({
590
+ messageID: assistantMessageId,
591
+ force: false,
592
+ })
552
593
 
553
- if (part.type === 'tool' && part.state.status === 'running') {
554
- // Flush any pending text/reasoning parts before showing the tool
555
- // This ensures text the LLM generated before the tool call is shown first
556
- for (const p of currentParts) {
557
- if (p.type !== 'step-start' && p.type !== 'step-finish' && p.id !== part.id) {
558
- await sendPartMessage(p)
559
- }
560
- }
561
- await sendPartMessage(part)
562
- // Track task tool and register child session when sessionId is available
563
- if (part.tool === 'task' && !sentPartIds.has(part.id)) {
564
- const description = (part.state.input?.description as string) || ''
565
- const agent = (part.state.input?.subagent_type as string) || 'task'
566
- const childSessionId = (part.state.metadata?.sessionId as string) || ''
567
- if (description && childSessionId) {
568
- agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1
569
- const label = `${agent}-${agentSpawnCounts[agent]}`
570
- subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined })
571
- const taskDisplay = `┣ task **${label}** _${description}_`
572
- await sendThreadMessage(thread, taskDisplay + '\n\n')
573
- sentPartIds.add(part.id)
574
- }
575
- }
576
- }
594
+ if (tokensUsedInSession === 0 || !usedProviderID || !usedModel) {
595
+ return
596
+ }
577
597
 
578
- // Show token usage for completed tools with large output (>5k tokens)
579
- if (part.type === 'tool' && part.state.status === 'completed') {
580
- const output = part.state.output || ''
581
- const outputTokens = Math.ceil(output.length / 4)
582
- const LARGE_OUTPUT_THRESHOLD = 3000
583
- if (outputTokens >= LARGE_OUTPUT_THRESHOLD) {
584
- const formattedTokens =
585
- outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens)
586
- const percentageSuffix = (() => {
587
- if (!modelContextLimit) {
588
- return ''
589
- }
590
- const pct = (outputTokens / modelContextLimit) * 100
591
- if (pct < 1) {
592
- return ''
593
- }
594
- return ` (${pct.toFixed(1)}%)`
595
- })()
596
- const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
597
- await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
598
- }
598
+ if (!modelContextLimit) {
599
+ const providersResponse = await errore.tryAsync(() => {
600
+ return getClient().provider.list({
601
+ query: { directory: sdkDirectory },
602
+ })
603
+ })
604
+ if (providersResponse instanceof Error) {
605
+ sessionLogger.error('Failed to fetch provider info for context limit:', providersResponse)
606
+ } else {
607
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
608
+ const model = provider?.models?.[usedModel]
609
+ if (model?.limit?.context) {
610
+ modelContextLimit = model.limit.context
599
611
  }
612
+ }
613
+ }
600
614
 
601
- if (part.type === 'reasoning') {
602
- await sendPartMessage(part)
603
- }
615
+ if (!modelContextLimit) {
616
+ return
617
+ }
604
618
 
605
- // Send text parts when complete (time.end is set)
606
- // Text parts stream incrementally; only send when finished to avoid partial text
607
- if (part.type === 'text' && part.time?.end) {
608
- await sendPartMessage(part)
609
- }
619
+ const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100)
620
+ const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
621
+ if (thresholdCrossed <= lastDisplayedContextPercentage || thresholdCrossed < 10) {
622
+ return
623
+ }
624
+ lastDisplayedContextPercentage = thresholdCrossed
625
+ const chunk = `⬦ context usage ${currentPercentage}%`
626
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
627
+ }
610
628
 
611
- if (part.type === 'step-finish') {
612
- for (const p of currentParts) {
613
- if (p.type !== 'step-start' && p.type !== 'step-finish') {
614
- await sendPartMessage(p)
615
- }
629
+ const handleMainPart = async (part: Part) => {
630
+ const isActiveMessage = assistantMessageId ? part.messageID === assistantMessageId : false
631
+ const allowEarlyProcessing =
632
+ !assistantMessageId && part.type === 'tool' && part.state.status === 'running'
633
+ if (!isActiveMessage && !allowEarlyProcessing) {
634
+ if (part.type !== 'step-start') {
635
+ return
636
+ }
637
+ }
638
+
639
+ if (part.type === 'step-start') {
640
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
641
+ (ctx) => ctx.thread.id === thread.id,
642
+ )
643
+ const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
644
+ if (!hasPendingQuestion && !hasPendingPermission) {
645
+ stopTyping = startTyping()
646
+ }
647
+ return
648
+ }
649
+
650
+ if (part.type === 'tool' && part.state.status === 'running') {
651
+ await flushBufferedParts({
652
+ messageID: assistantMessageId || part.messageID,
653
+ force: true,
654
+ skipPartId: part.id,
655
+ })
656
+ await sendPartMessage(part)
657
+ if (part.tool === 'task' && !sentPartIds.has(part.id)) {
658
+ const description = (part.state.input?.description as string) || ''
659
+ const agent = (part.state.input?.subagent_type as string) || 'task'
660
+ const childSessionId = (part.state.metadata?.sessionId as string) || ''
661
+ if (description && childSessionId) {
662
+ agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1
663
+ const label = `${agent}-${agentSpawnCounts[agent]}`
664
+ subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined })
665
+ // Skip task messages in text-only mode
666
+ if (verbosity !== 'text-only') {
667
+ const taskDisplay = `┣ task **${label}** _${description}_`
668
+ await sendThreadMessage(thread, taskDisplay + '\n\n')
616
669
  }
617
- setTimeout(() => {
618
- if (abortController.signal.aborted) return
619
- // Don't restart typing if user needs to respond to a question or permission
620
- const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
621
- (ctx) => ctx.thread.id === thread.id,
622
- )
623
- const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
624
- if (hasPendingQuestion || hasPendingPermission) return
625
- stopTyping = startTyping()
626
- }, 300)
670
+ sentPartIds.add(part.id)
627
671
  }
672
+ }
673
+ return
674
+ }
628
675
 
629
- } else if (event.type === 'session.error') {
630
- sessionLogger.error(`ERROR:`, event.properties)
631
- if (event.properties.sessionID === session.id) {
632
- const errorData = event.properties.error
633
- const errorMessage = errorData?.data?.message || 'Unknown error'
634
- sessionLogger.error(`Sending error to thread: ${errorMessage}`)
635
- await sendThreadMessage(thread, `✗ opencode session error: ${errorMessage}`)
636
-
637
- if (originalMessage) {
638
- try {
639
- await originalMessage.reactions.removeAll()
640
- await originalMessage.react('❌')
641
- voiceLogger.log(`[REACTION] Added error reaction due to session error`)
642
- } catch (e) {
643
- discordLogger.log(`Could not update reaction:`, e)
644
- }
676
+ if (part.type === 'tool' && part.state.status === 'completed') {
677
+ const output = part.state.output || ''
678
+ const outputTokens = Math.ceil(output.length / 4)
679
+ const largeOutputThreshold = 3000
680
+ if (outputTokens >= largeOutputThreshold) {
681
+ const formattedTokens =
682
+ outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens)
683
+ const percentageSuffix = (() => {
684
+ if (!modelContextLimit) {
685
+ return ''
645
686
  }
646
- } else {
647
- voiceLogger.log(
648
- `[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${event.properties.sessionID})`,
649
- )
650
- }
651
- break
652
- } else if (event.type === 'permission.asked') {
653
- const permission = event.properties
654
- if (permission.sessionID !== session.id) {
655
- voiceLogger.log(
656
- `[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`,
657
- )
658
- continue
659
- }
687
+ const pct = (outputTokens / modelContextLimit) * 100
688
+ if (pct < 1) {
689
+ return ''
690
+ }
691
+ return ` (${pct.toFixed(1)}%)`
692
+ })()
693
+ const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
694
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
695
+ }
696
+ }
660
697
 
661
- // Skip if this exact permission ID is already pending (dedupe)
662
- const threadPermissions = pendingPermissions.get(thread.id)
663
- if (threadPermissions?.has(permission.id)) {
664
- sessionLogger.log(
665
- `[PERMISSION] Skipping duplicate permission ${permission.id} (already pending)`,
666
- )
667
- continue
668
- }
698
+ if (part.type === 'reasoning') {
699
+ await sendPartMessage(part)
700
+ return
701
+ }
669
702
 
670
- sessionLogger.log(
671
- `Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
703
+ if (part.type === 'text' && part.time?.end) {
704
+ await sendPartMessage(part)
705
+ return
706
+ }
707
+
708
+ if (part.type === 'step-finish') {
709
+ await flushBufferedParts({
710
+ messageID: assistantMessageId || part.messageID,
711
+ force: true,
712
+ })
713
+ setTimeout(() => {
714
+ if (abortController.signal.aborted) return
715
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
716
+ (ctx) => ctx.thread.id === thread.id,
672
717
  )
718
+ const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
719
+ if (hasPendingQuestion || hasPendingPermission) return
720
+ stopTyping = startTyping()
721
+ }, 300)
722
+ }
723
+ }
673
724
 
674
- // Stop typing - user needs to respond now, not the bot
675
- if (stopTyping) {
676
- stopTyping()
677
- stopTyping = null
678
- }
725
+ const handleSubtaskPart = async (
726
+ part: Part,
727
+ subtaskInfo: { label: string; assistantMessageId?: string },
728
+ ) => {
729
+ if (part.type === 'step-start' || part.type === 'step-finish') {
730
+ return
731
+ }
732
+ if (part.type === 'tool' && part.state.status === 'pending') {
733
+ return
734
+ }
735
+ if (part.type === 'text') {
736
+ return
737
+ }
738
+ if (!subtaskInfo.assistantMessageId || part.messageID !== subtaskInfo.assistantMessageId) {
739
+ return
740
+ }
679
741
 
680
- // Show dropdown instead of text message
681
- const { messageId, contextHash } = await showPermissionDropdown({
682
- thread,
683
- permission,
684
- directory,
685
- })
742
+ const content = formatPart(part, subtaskInfo.label)
743
+ if (!content.trim() || sentPartIds.has(part.id)) {
744
+ return
745
+ }
746
+ const sendResult = await errore.tryAsync(() => {
747
+ return sendThreadMessage(thread, content + '\n\n')
748
+ })
749
+ if (sendResult instanceof Error) {
750
+ discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, sendResult)
751
+ return
752
+ }
753
+ sentPartIds.add(part.id)
754
+ getDatabase()
755
+ .prepare(
756
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
757
+ )
758
+ .run(part.id, sendResult.id, thread.id)
759
+ }
686
760
 
687
- // Track permission in nested map (threadId -> permissionId -> data)
688
- if (!pendingPermissions.has(thread.id)) {
689
- pendingPermissions.set(thread.id, new Map())
690
- }
691
- pendingPermissions.get(thread.id)!.set(permission.id, {
692
- permission,
693
- messageId,
694
- directory,
695
- contextHash,
696
- })
697
- } else if (event.type === 'permission.replied') {
698
- const { requestID, reply, sessionID } = event.properties
699
- if (sessionID !== session.id) {
700
- continue
701
- }
761
+ const handlePartUpdated = async (part: Part) => {
762
+ storePart(part)
702
763
 
703
- sessionLogger.log(`Permission ${requestID} replied with: ${reply}`)
704
-
705
- // Clean up the specific permission from nested map
706
- const threadPermissions = pendingPermissions.get(thread.id)
707
- if (threadPermissions) {
708
- const pending = threadPermissions.get(requestID)
709
- if (pending) {
710
- cleanupPermissionContext(pending.contextHash)
711
- threadPermissions.delete(requestID)
712
- // Remove thread entry if no more pending permissions
713
- if (threadPermissions.size === 0) {
714
- pendingPermissions.delete(thread.id)
715
- }
716
- }
717
- }
718
- } else if (event.type === 'question.asked') {
719
- const questionRequest = event.properties
764
+ const subtaskInfo = subtaskSessions.get(part.sessionID)
765
+ const isSubtaskEvent = Boolean(subtaskInfo)
720
766
 
721
- if (questionRequest.sessionID !== session.id) {
722
- sessionLogger.log(
723
- `[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`,
724
- )
725
- continue
726
- }
767
+ if (part.sessionID !== session.id && !isSubtaskEvent) {
768
+ return
769
+ }
770
+
771
+ if (isSubtaskEvent && subtaskInfo) {
772
+ await handleSubtaskPart(part, subtaskInfo)
773
+ return
774
+ }
727
775
 
776
+ await handleMainPart(part)
777
+ }
778
+
779
+ const handleSessionError = async ({
780
+ sessionID,
781
+ error,
782
+ }: {
783
+ sessionID?: string
784
+ error?: { data?: { message?: string } }
785
+ }) => {
786
+ if (!sessionID || sessionID !== session.id) {
787
+ voiceLogger.log(
788
+ `[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${sessionID})`,
789
+ )
790
+ return
791
+ }
792
+
793
+ const errorMessage = error?.data?.message || 'Unknown error'
794
+ sessionLogger.error(`Sending error to thread: ${errorMessage}`)
795
+ await sendThreadMessage(thread, `✗ opencode session error: ${errorMessage}`)
796
+
797
+ if (!originalMessage) {
798
+ return
799
+ }
800
+ const reactionResult = await errore.tryAsync(async () => {
801
+ await originalMessage.reactions.removeAll()
802
+ await originalMessage.react('❌')
803
+ })
804
+ if (reactionResult instanceof Error) {
805
+ discordLogger.log(`Could not update reaction:`, reactionResult)
806
+ } else {
807
+ voiceLogger.log(`[REACTION] Added error reaction due to session error`)
808
+ }
809
+ }
810
+
811
+ const handlePermissionAsked = async (permission: PermissionRequest) => {
812
+ if (permission.sessionID !== session.id) {
813
+ voiceLogger.log(
814
+ `[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`,
815
+ )
816
+ return
817
+ }
818
+
819
+ const dedupeKey = buildPermissionDedupeKey({ permission, directory })
820
+ const threadPermissions = pendingPermissions.get(thread.id)
821
+ const existingPending = threadPermissions
822
+ ? Array.from(threadPermissions.values()).find((pending) => {
823
+ return pending.dedupeKey === dedupeKey
824
+ })
825
+ : undefined
826
+
827
+ if (existingPending) {
828
+ sessionLogger.log(
829
+ `[PERMISSION] Deduped permission ${permission.id} (matches pending ${existingPending.permission.id})`,
830
+ )
831
+ if (stopTyping) {
832
+ stopTyping()
833
+ stopTyping = null
834
+ }
835
+ if (!pendingPermissions.has(thread.id)) {
836
+ pendingPermissions.set(thread.id, new Map())
837
+ }
838
+ pendingPermissions.get(thread.id)!.set(permission.id, {
839
+ permission,
840
+ messageId: existingPending.messageId,
841
+ directory,
842
+ contextHash: existingPending.contextHash,
843
+ dedupeKey,
844
+ })
845
+ const added = addPermissionRequestToContext({
846
+ contextHash: existingPending.contextHash,
847
+ requestId: permission.id,
848
+ })
849
+ if (!added) {
728
850
  sessionLogger.log(
729
- `Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
851
+ `[PERMISSION] Failed to attach duplicate request ${permission.id} to context`,
730
852
  )
853
+ }
854
+ return
855
+ }
731
856
 
732
- // Stop typing - user needs to respond now, not the bot
733
- if (stopTyping) {
734
- stopTyping()
735
- stopTyping = null
736
- }
857
+ sessionLogger.log(
858
+ `Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
859
+ )
737
860
 
738
- // Flush any pending text/reasoning parts before showing the dropdown
739
- // This ensures text the LLM generated before the question tool is shown first
740
- for (const p of currentParts) {
741
- if (p.type !== 'step-start' && p.type !== 'step-finish') {
742
- await sendPartMessage(p)
743
- }
744
- }
861
+ if (stopTyping) {
862
+ stopTyping()
863
+ stopTyping = null
864
+ }
745
865
 
746
- await showAskUserQuestionDropdowns({
747
- thread,
748
- sessionId: session.id,
749
- directory,
750
- requestId: questionRequest.id,
751
- input: { questions: questionRequest.questions },
752
- })
866
+ const { messageId, contextHash } = await showPermissionDropdown({
867
+ thread,
868
+ permission,
869
+ directory,
870
+ })
753
871
 
754
- // Process queued messages if any - queued message will cancel the pending question
755
- const queue = messageQueue.get(thread.id)
756
- if (queue && queue.length > 0) {
757
- const nextMessage = queue.shift()!
758
- if (queue.length === 0) {
759
- messageQueue.delete(thread.id)
760
- }
872
+ if (!pendingPermissions.has(thread.id)) {
873
+ pendingPermissions.set(thread.id, new Map())
874
+ }
875
+ pendingPermissions.get(thread.id)!.set(permission.id, {
876
+ permission,
877
+ messageId,
878
+ directory,
879
+ contextHash,
880
+ dedupeKey,
881
+ })
882
+ }
761
883
 
762
- sessionLogger.log(
763
- `[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`,
764
- )
884
+ const handlePermissionReplied = ({
885
+ requestID,
886
+ reply,
887
+ sessionID,
888
+ }: {
889
+ requestID: string
890
+ reply: string
891
+ sessionID: string
892
+ }) => {
893
+ if (sessionID !== session.id) {
894
+ return
895
+ }
896
+
897
+ sessionLogger.log(`Permission ${requestID} replied with: ${reply}`)
898
+
899
+ const threadPermissions = pendingPermissions.get(thread.id)
900
+ if (!threadPermissions) {
901
+ return
902
+ }
903
+ const pending = threadPermissions.get(requestID)
904
+ if (!pending) {
905
+ return
906
+ }
907
+ cleanupPermissionContext(pending.contextHash)
908
+ threadPermissions.delete(requestID)
909
+ if (threadPermissions.size === 0) {
910
+ pendingPermissions.delete(thread.id)
911
+ }
912
+ }
913
+
914
+ const handleQuestionAsked = async (questionRequest: QuestionRequest) => {
915
+ if (questionRequest.sessionID !== session.id) {
916
+ sessionLogger.log(
917
+ `[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`,
918
+ )
919
+ return
920
+ }
921
+
922
+ sessionLogger.log(
923
+ `Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
924
+ )
925
+
926
+ if (stopTyping) {
927
+ stopTyping()
928
+ stopTyping = null
929
+ }
930
+
931
+ await flushBufferedParts({
932
+ messageID: assistantMessageId || '',
933
+ force: true,
934
+ })
935
+
936
+ await showAskUserQuestionDropdowns({
937
+ thread,
938
+ sessionId: session.id,
939
+ directory,
940
+ requestId: questionRequest.id,
941
+ input: { questions: questionRequest.questions },
942
+ })
943
+
944
+ const queue = messageQueue.get(thread.id)
945
+ if (!queue || queue.length === 0) {
946
+ return
947
+ }
948
+
949
+ const nextMessage = queue.shift()!
950
+ if (queue.length === 0) {
951
+ messageQueue.delete(thread.id)
952
+ }
953
+
954
+ sessionLogger.log(
955
+ `[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`,
956
+ )
957
+
958
+ await sendThreadMessage(
959
+ thread,
960
+ `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`,
961
+ )
765
962
 
963
+ setImmediate(() => {
964
+ void errore
965
+ .tryAsync(async () => {
966
+ return handleOpencodeSession({
967
+ prompt: nextMessage.prompt,
968
+ thread,
969
+ projectDirectory: directory,
970
+ images: nextMessage.images,
971
+ channelId,
972
+ })
973
+ })
974
+ .then(async (result) => {
975
+ if (!(result instanceof Error)) {
976
+ return
977
+ }
978
+ sessionLogger.error(`[QUEUE] Failed to process queued message:`, result)
766
979
  await sendThreadMessage(
767
980
  thread,
768
- **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`,
981
+ `✗ Queued message failed: ${result.message.slice(0, 200)}`,
769
982
  )
983
+ })
984
+ })
985
+ }
770
986
 
771
- // handleOpencodeSession will call cancelPendingQuestion, which cancels the dropdown
772
- setImmediate(() => {
773
- handleOpencodeSession({
774
- prompt: nextMessage.prompt,
775
- thread,
776
- projectDirectory: directory,
777
- images: nextMessage.images,
778
- channelId,
779
- }).catch(async (e) => {
780
- sessionLogger.error(`[QUEUE] Failed to process queued message:`, e)
781
- const errorMsg = e instanceof Error ? e.message : String(e)
782
- await sendThreadMessage(
783
- thread,
784
- `✗ Queued message failed: ${errorMsg.slice(0, 200)}`,
785
- )
786
- })
787
- })
788
- }
789
- } else if (event.type === 'session.idle') {
790
- const idleSessionId = event.properties.sessionID
791
- // Session is done processing - abort to signal completion
792
- if (idleSessionId === session.id) {
793
- sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`)
794
- abortController.abort('finished')
795
- } else if (subtaskSessions.has(idleSessionId)) {
796
- // Child session completed - clean up tracking
797
- const subtask = subtaskSessions.get(idleSessionId)
798
- sessionLogger.log(`[SUBTASK IDLE] Subtask "${subtask?.label}" completed`)
799
- subtaskSessions.delete(idleSessionId)
800
- }
987
+ const handleSessionIdle = (idleSessionId: string) => {
988
+ if (idleSessionId === session.id) {
989
+ sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`)
990
+ abortController.abort('finished')
991
+ return
992
+ }
993
+
994
+ if (!subtaskSessions.has(idleSessionId)) {
995
+ return
996
+ }
997
+ const subtask = subtaskSessions.get(idleSessionId)
998
+ sessionLogger.log(`[SUBTASK IDLE] Subtask "${subtask?.label}" completed`)
999
+ subtaskSessions.delete(idleSessionId)
1000
+ }
1001
+
1002
+ try {
1003
+ for await (const event of events) {
1004
+ switch (event.type) {
1005
+ case 'message.updated':
1006
+ await handleMessageUpdated(event.properties.info)
1007
+ break
1008
+ case 'message.part.updated':
1009
+ await handlePartUpdated(event.properties.part)
1010
+ break
1011
+ case 'session.error':
1012
+ sessionLogger.error(`ERROR:`, event.properties)
1013
+ await handleSessionError(event.properties)
1014
+ break
1015
+ case 'permission.asked':
1016
+ await handlePermissionAsked(event.properties)
1017
+ break
1018
+ case 'permission.replied':
1019
+ handlePermissionReplied(event.properties)
1020
+ break
1021
+ case 'question.asked':
1022
+ await handleQuestionAsked(event.properties)
1023
+ break
1024
+ case 'session.idle':
1025
+ handleSessionIdle(event.properties.sessionID)
1026
+ break
1027
+ default:
1028
+ break
801
1029
  }
802
1030
  }
803
1031
  } catch (e) {
@@ -808,12 +1036,13 @@ export async function handleOpencodeSession({
808
1036
  sessionLogger.error(`Unexpected error in event handling code`, e)
809
1037
  throw e
810
1038
  } finally {
811
- for (const part of currentParts) {
812
- if (!sentPartIds.has(part.id)) {
813
- try {
1039
+ abortControllers.delete(session.id)
1040
+ const finalMessageId = assistantMessageId
1041
+ if (finalMessageId) {
1042
+ const parts = getBufferedParts(finalMessageId)
1043
+ for (const part of parts) {
1044
+ if (!sentPartIds.has(part.id)) {
814
1045
  await sendPartMessage(part)
815
- } catch (error) {
816
- sessionLogger.error(`Failed to send part ${part.id}:`, error)
817
1046
  }
818
1047
  }
819
1048
  }
@@ -831,12 +1060,13 @@ export async function handleOpencodeSession({
831
1060
  usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : ''
832
1061
  let contextInfo = ''
833
1062
 
834
- try {
1063
+ const contextResult = await errore.tryAsync(async () => {
835
1064
  // Fetch final token count from API since message.updated events can arrive
836
1065
  // after session.idle due to race conditions in event ordering
837
1066
  if (tokensUsedInSession === 0) {
838
1067
  const messagesResponse = await getClient().session.messages({
839
1068
  path: { id: session.id },
1069
+ query: { directory: sdkDirectory },
840
1070
  })
841
1071
  const messages = messagesResponse.data || []
842
1072
  const lastAssistant = [...messages]
@@ -858,15 +1088,16 @@ export async function handleOpencodeSession({
858
1088
  }
859
1089
  }
860
1090
 
861
- const providersResponse = await getClient().provider.list({ query: { directory } })
1091
+ const providersResponse = await getClient().provider.list({ query: { directory: sdkDirectory } })
862
1092
  const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
863
1093
  const model = provider?.models?.[usedModel || '']
864
1094
  if (model?.limit?.context) {
865
1095
  const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100)
866
1096
  contextInfo = ` ⋅ ${percentage}%`
867
1097
  }
868
- } catch (e) {
869
- sessionLogger.error('Failed to fetch provider info for context percentage:', e)
1098
+ })
1099
+ if (contextResult instanceof Error) {
1100
+ sessionLogger.error('Failed to fetch provider info for context percentage:', contextResult)
870
1101
  }
871
1102
 
872
1103
  await sendThreadMessage(
@@ -918,8 +1149,9 @@ export async function handleOpencodeSession({
918
1149
  }
919
1150
  }
920
1151
 
921
- try {
922
- const eventHandlerPromise = eventHandler()
1152
+ const promptResult: Error | { sessionID: string; result: any; port?: number } | undefined =
1153
+ await errore.tryAsync(async () => {
1154
+ const eventHandlerPromise = eventHandler()
923
1155
 
924
1156
  if (abortController.signal.aborted) {
925
1157
  sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`)
@@ -931,7 +1163,6 @@ export async function handleOpencodeSession({
931
1163
  voiceLogger.log(
932
1164
  `[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
933
1165
  )
934
- // append image paths to prompt so ai knows where they are on disk
935
1166
  const promptWithImagePaths = (() => {
936
1167
  if (images.length === 0) {
937
1168
  return prompt
@@ -951,19 +1182,15 @@ export async function handleOpencodeSession({
951
1182
  const parts = [{ type: 'text' as const, text: promptWithImagePaths }, ...images]
952
1183
  sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
953
1184
 
954
- // Get agent preference: session-level overrides channel-level
955
1185
  const agentPreference =
956
1186
  getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
957
1187
  if (agentPreference) {
958
1188
  sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
959
1189
  }
960
1190
 
961
- // Get model preference: session-level overrides channel-level
962
- // BUT: if an agent is set, don't pass model param so the agent's model takes effect
963
1191
  const modelPreference =
964
1192
  getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
965
1193
  const modelParam = (() => {
966
- // When an agent is set, let the agent's model config take effect
967
1194
  if (agentPreference) {
968
1195
  sessionLogger.log(`[MODEL] Skipping model param, agent "${agentPreference}" controls model`)
969
1196
  return undefined
@@ -980,8 +1207,7 @@ export async function handleOpencodeSession({
980
1207
  return { providerID, modelID }
981
1208
  })()
982
1209
 
983
- // Get worktree info if this thread is in a worktree
984
- const worktreeInfo = getThreadWorktree(thread.id)
1210
+ // Build worktree info for system message (worktreeInfo was fetched at the start)
985
1211
  const worktree: WorktreeInfo | undefined =
986
1212
  worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
987
1213
  ? {
@@ -991,10 +1217,10 @@ export async function handleOpencodeSession({
991
1217
  }
992
1218
  : undefined
993
1219
 
994
- // Use session.command API for slash commands, session.prompt for regular messages
995
1220
  const response = command
996
1221
  ? await getClient().session.command({
997
1222
  path: { id: session.id },
1223
+ query: { directory: sdkDirectory },
998
1224
  body: {
999
1225
  command: command.name,
1000
1226
  arguments: command.arguments,
@@ -1004,6 +1230,7 @@ export async function handleOpencodeSession({
1004
1230
  })
1005
1231
  : await getClient().session.prompt({
1006
1232
  path: { id: session.id },
1233
+ query: { directory: sdkDirectory },
1007
1234
  body: {
1008
1235
  parts,
1009
1236
  system: getOpencodeSystemMessage({ sessionId: session.id, channelId, worktree }),
@@ -1034,40 +1261,46 @@ export async function handleOpencodeSession({
1034
1261
  sessionLogger.log(`Successfully sent prompt, got response`)
1035
1262
 
1036
1263
  if (originalMessage) {
1037
- try {
1264
+ const reactionResult = await errore.tryAsync(async () => {
1038
1265
  await originalMessage.reactions.removeAll()
1039
1266
  await originalMessage.react('✅')
1040
- } catch (e) {
1041
- discordLogger.log(`Could not update reactions:`, e)
1267
+ })
1268
+ if (reactionResult instanceof Error) {
1269
+ discordLogger.log(`Could not update reactions:`, reactionResult)
1042
1270
  }
1043
1271
  }
1044
1272
 
1045
1273
  return { sessionID: session.id, result: response.data, port }
1046
- } catch (error) {
1047
- if (!isAbortError(error, abortController.signal)) {
1048
- sessionLogger.error(`ERROR: Failed to send prompt:`, error)
1049
- abortController.abort('error')
1050
-
1051
- if (originalMessage) {
1052
- try {
1053
- await originalMessage.reactions.removeAll()
1054
- await originalMessage.react('❌')
1055
- discordLogger.log(`Added error reaction to message`)
1056
- } catch (e) {
1057
- discordLogger.log(`Could not update reaction:`, e)
1058
- }
1059
- }
1060
- const errorDisplay = (() => {
1061
- if (error instanceof Error) {
1062
- const name = error.constructor.name || 'Error'
1063
- return `[${name}]\n${error.stack || error.message}`
1064
- }
1065
- if (typeof error === 'string') {
1066
- return error
1067
- }
1068
- return String(error)
1069
- })()
1070
- await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`)
1274
+ })
1275
+
1276
+ if (!errore.isError(promptResult)) {
1277
+ return promptResult
1278
+ }
1279
+
1280
+ const promptError: Error = promptResult instanceof Error ? promptResult : new Error('Unknown error')
1281
+ if (isAbortError(promptError, abortController.signal)) {
1282
+ return
1283
+ }
1284
+
1285
+ sessionLogger.error(`ERROR: Failed to send prompt:`, promptError)
1286
+ abortController.abort('error')
1287
+
1288
+ if (originalMessage) {
1289
+ const reactionResult = await errore.tryAsync(async () => {
1290
+ await originalMessage.reactions.removeAll()
1291
+ await originalMessage.react('❌')
1292
+ })
1293
+ if (reactionResult instanceof Error) {
1294
+ discordLogger.log(`Could not update reaction:`, reactionResult)
1295
+ } else {
1296
+ discordLogger.log(`Added error reaction to message`)
1071
1297
  }
1072
1298
  }
1299
+ const errorDisplay = (() => {
1300
+ const promptErrorValue = promptError as unknown as Error
1301
+ const name = promptErrorValue.name || 'Error'
1302
+ const message = promptErrorValue.stack || promptErrorValue.message
1303
+ return `[${name}]\n${message}`
1304
+ })()
1305
+ await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`)
1073
1306
  }