kimaki 0.4.44 → 0.4.46

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 (45) hide show
  1. package/dist/channel-management.js +6 -15
  2. package/dist/cli.js +54 -37
  3. package/dist/commands/create-new-project.js +2 -0
  4. package/dist/commands/fork.js +2 -0
  5. package/dist/commands/permissions.js +21 -5
  6. package/dist/commands/queue.js +5 -1
  7. package/dist/commands/resume.js +10 -16
  8. package/dist/commands/session.js +20 -42
  9. package/dist/commands/user-command.js +10 -17
  10. package/dist/commands/verbosity.js +53 -0
  11. package/dist/commands/worktree-settings.js +2 -2
  12. package/dist/commands/worktree.js +134 -25
  13. package/dist/database.js +49 -0
  14. package/dist/discord-bot.js +26 -38
  15. package/dist/discord-utils.js +51 -13
  16. package/dist/discord-utils.test.js +20 -0
  17. package/dist/escape-backticks.test.js +14 -3
  18. package/dist/interaction-handler.js +4 -0
  19. package/dist/session-handler.js +581 -414
  20. package/package.json +1 -1
  21. package/src/__snapshots__/first-session-no-info.md +1344 -0
  22. package/src/__snapshots__/first-session-with-info.md +1350 -0
  23. package/src/__snapshots__/session-1.md +1344 -0
  24. package/src/__snapshots__/session-2.md +291 -0
  25. package/src/__snapshots__/session-3.md +20324 -0
  26. package/src/__snapshots__/session-with-tools.md +1344 -0
  27. package/src/channel-management.ts +6 -17
  28. package/src/cli.ts +63 -45
  29. package/src/commands/create-new-project.ts +3 -0
  30. package/src/commands/fork.ts +3 -0
  31. package/src/commands/permissions.ts +31 -5
  32. package/src/commands/queue.ts +5 -1
  33. package/src/commands/resume.ts +11 -18
  34. package/src/commands/session.ts +21 -44
  35. package/src/commands/user-command.ts +11 -19
  36. package/src/commands/verbosity.ts +71 -0
  37. package/src/commands/worktree-settings.ts +2 -2
  38. package/src/commands/worktree.ts +163 -27
  39. package/src/database.ts +65 -0
  40. package/src/discord-bot.ts +29 -42
  41. package/src/discord-utils.test.ts +23 -0
  42. package/src/discord-utils.ts +52 -13
  43. package/src/escape-backticks.test.ts +14 -3
  44. package/src/interaction-handler.ts +5 -0
  45. package/src/session-handler.ts +711 -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
@@ -59,6 +86,8 @@ export type QueuedMessage = {
59
86
  // Key is threadId, value is array of queued messages
60
87
  export const messageQueue = new Map<string, QueuedMessage[]>()
61
88
 
89
+ const activeEventHandlers = new Map<string, Promise<void>>()
90
+
62
91
  export function addToQueue({
63
92
  threadId,
64
93
  message,
@@ -113,10 +142,11 @@ export async function abortAndRetrySession({
113
142
  sessionLogger.error(`[ABORT+RETRY] Failed to initialize OpenCode client:`, getClient.message)
114
143
  return false
115
144
  }
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)
145
+ const abortResult = await errore.tryAsync(() => {
146
+ return getClient().session.abort({ path: { id: sessionId } })
147
+ })
148
+ if (abortResult instanceof Error) {
149
+ sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, abortResult)
120
150
  }
121
151
 
122
152
  // Small delay to let the abort propagate
@@ -146,16 +176,25 @@ export async function abortAndRetrySession({
146
176
 
147
177
  // Use setImmediate to avoid blocking
148
178
  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
- })
179
+ void errore
180
+ .tryAsync(async () => {
181
+ return handleOpencodeSession({
182
+ prompt,
183
+ thread,
184
+ projectDirectory,
185
+ images,
186
+ })
187
+ })
188
+ .then(async (result) => {
189
+ if (!(result instanceof Error)) {
190
+ return
191
+ }
192
+ sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, result)
193
+ await sendThreadMessage(
194
+ thread,
195
+ `✗ Failed to retry with new model: ${result.message.slice(0, 200)}`,
196
+ )
197
+ })
159
198
  })
160
199
 
161
200
  return true
@@ -191,6 +230,18 @@ export async function handleOpencodeSession({
191
230
  const directory = projectDirectory || process.cwd()
192
231
  sessionLogger.log(`Using directory: ${directory}`)
193
232
 
233
+ // Get worktree info early so we can use the correct directory for events and prompts
234
+ const worktreeInfo = getThreadWorktree(thread.id)
235
+ const worktreeDirectory =
236
+ worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
237
+ ? worktreeInfo.worktree_directory
238
+ : undefined
239
+ // Use worktree directory for SDK calls if available, otherwise project directory
240
+ const sdkDirectory = worktreeDirectory || directory
241
+ if (worktreeDirectory) {
242
+ sessionLogger.log(`Using worktree directory for SDK calls: ${worktreeDirectory}`)
243
+ }
244
+
194
245
  const getClient = await initializeOpencodeForDirectory(directory)
195
246
  if (getClient instanceof Error) {
196
247
  await sendThreadMessage(thread, `✗ ${getClient.message}`)
@@ -208,14 +259,17 @@ export async function handleOpencodeSession({
208
259
 
209
260
  if (sessionId) {
210
261
  sessionLogger.log(`Attempting to reuse existing session ${sessionId}`)
211
- try {
212
- const sessionResponse = await getClient().session.get({
262
+ const sessionResponse = await errore.tryAsync(() => {
263
+ return getClient().session.get({
213
264
  path: { id: sessionId },
265
+ query: { directory: sdkDirectory },
214
266
  })
267
+ })
268
+ if (sessionResponse instanceof Error) {
269
+ voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`)
270
+ } else {
215
271
  session = sessionResponse.data
216
272
  sessionLogger.log(`Successfully reused session ${sessionId}`)
217
- } catch (error) {
218
- voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`)
219
273
  }
220
274
  }
221
275
 
@@ -224,6 +278,7 @@ export async function handleOpencodeSession({
224
278
  voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`)
225
279
  const sessionResponse = await getClient().session.create({
226
280
  body: { title: sessionTitle },
281
+ query: { directory: sdkDirectory },
227
282
  })
228
283
  session = sessionResponse.data
229
284
  sessionLogger.log(`Created new session ${session?.id}`)
@@ -248,6 +303,15 @@ export async function handleOpencodeSession({
248
303
  if (existingController) {
249
304
  voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`)
250
305
  existingController.abort(new Error('New request started'))
306
+ const abortResult = await errore.tryAsync(() => {
307
+ return getClient().session.abort({
308
+ path: { id: session.id },
309
+ query: { directory: sdkDirectory },
310
+ })
311
+ })
312
+ if (abortResult instanceof Error) {
313
+ sessionLogger.log(`[ABORT] Server abort failed (may be already done):`, abortResult)
314
+ }
251
315
  }
252
316
 
253
317
  // Auto-reject ALL pending permissions for this thread
@@ -256,20 +320,25 @@ export async function handleOpencodeSession({
256
320
  const clientV2 = getOpencodeClientV2(directory)
257
321
  let rejectedCount = 0
258
322
  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
- }
323
+ sessionLogger.log(`[PERMISSION] Auto-rejecting permission ${permId} due to new message`)
324
+ if (!clientV2) {
325
+ sessionLogger.log(`[PERMISSION] OpenCode v2 client unavailable for permission ${permId}`)
267
326
  cleanupPermissionContext(pendingPerm.contextHash)
268
327
  rejectedCount++
269
- } catch (e) {
270
- sessionLogger.log(`[PERMISSION] Failed to auto-reject permission ${permId}:`, e)
271
- cleanupPermissionContext(pendingPerm.contextHash)
328
+ continue
329
+ }
330
+ const rejectResult = await errore.tryAsync(() => {
331
+ return clientV2.permission.reply({
332
+ requestID: permId,
333
+ reply: 'reject',
334
+ })
335
+ })
336
+ if (rejectResult instanceof Error) {
337
+ sessionLogger.log(`[PERMISSION] Failed to auto-reject permission ${permId}:`, rejectResult)
338
+ } else {
339
+ rejectedCount++
272
340
  }
341
+ cleanupPermissionContext(pendingPerm.contextHash)
273
342
  }
274
343
  pendingPermissions.delete(thread.id)
275
344
  if (rejectedCount > 0) {
@@ -305,13 +374,24 @@ export async function handleOpencodeSession({
305
374
  return
306
375
  }
307
376
 
377
+ const previousHandler = activeEventHandlers.get(thread.id)
378
+ if (previousHandler) {
379
+ sessionLogger.log(`[EVENT] Waiting for previous handler to finish`)
380
+ await Promise.race([
381
+ previousHandler,
382
+ new Promise((resolve) => {
383
+ setTimeout(resolve, 1000)
384
+ }),
385
+ ])
386
+ }
387
+
308
388
  // Use v2 client for event subscription (has proper types for question.asked events)
309
389
  const clientV2 = getOpencodeClientV2(directory)
310
390
  if (!clientV2) {
311
391
  throw new Error(`OpenCode v2 client not found for directory: ${directory}`)
312
392
  }
313
393
  const eventsResult = await clientV2.event.subscribe(
314
- { directory },
394
+ { directory: sdkDirectory },
315
395
  { signal: abortController.signal },
316
396
  )
317
397
 
@@ -331,7 +411,7 @@ export async function handleOpencodeSession({
331
411
  ).map((row) => row.part_id),
332
412
  )
333
413
 
334
- let currentParts: Part[] = []
414
+ const partBuffer = new Map<string, Map<string, Part>>()
335
415
  let stopTyping: (() => void) | null = null
336
416
  let usedModel: string | undefined
337
417
  let usedProviderID: string | undefined
@@ -339,6 +419,8 @@ export async function handleOpencodeSession({
339
419
  let tokensUsedInSession = 0
340
420
  let lastDisplayedContextPercentage = 0
341
421
  let modelContextLimit: number | undefined
422
+ let assistantMessageId: string | undefined
423
+ let handlerPromise: Promise<void> | null = null
342
424
 
343
425
  let typingInterval: NodeJS.Timeout | null = null
344
426
 
@@ -352,13 +434,17 @@ export async function handleOpencodeSession({
352
434
  typingInterval = null
353
435
  }
354
436
 
355
- thread.sendTyping().catch((e) => {
356
- discordLogger.log(`Failed to send initial typing: ${e}`)
437
+ void errore.tryAsync(() => thread.sendTyping()).then((result) => {
438
+ if (result instanceof Error) {
439
+ discordLogger.log(`Failed to send initial typing: ${result}`)
440
+ }
357
441
  })
358
442
 
359
443
  typingInterval = setInterval(() => {
360
- thread.sendTyping().catch((e) => {
361
- discordLogger.log(`Failed to send periodic typing: ${e}`)
444
+ void errore.tryAsync(() => thread.sendTyping()).then((result) => {
445
+ if (result instanceof Error) {
446
+ discordLogger.log(`Failed to send periodic typing: ${result}`)
447
+ }
362
448
  })
363
449
  }, 8000)
364
450
 
@@ -383,7 +469,16 @@ export async function handleOpencodeSession({
383
469
  }
384
470
  }
385
471
 
472
+ // Get verbosity setting for this channel (use parent channel for threads)
473
+ const verbosityChannelId = channelId || thread.parentId || thread.id
474
+ const verbosity = getChannelVerbosity(verbosityChannelId)
475
+
386
476
  const sendPartMessage = async (part: Part) => {
477
+ // In text-only mode, only send text parts (the ⬥ diamond messages)
478
+ if (verbosity === 'text-only' && part.type !== 'text') {
479
+ return
480
+ }
481
+
387
482
  const content = formatPart(part) + '\n\n'
388
483
  if (!content.trim() || content.length === 0) {
389
484
  // discordLogger.log(`SKIP: Part ${part.id} has no content`)
@@ -394,18 +489,20 @@ export async function handleOpencodeSession({
394
489
  return
395
490
  }
396
491
 
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)
492
+ const sendResult = await errore.tryAsync(() => {
493
+ return sendThreadMessage(thread, content)
494
+ })
495
+ if (sendResult instanceof Error) {
496
+ discordLogger.error(`ERROR: Failed to send part ${part.id}:`, sendResult)
497
+ return
408
498
  }
499
+ sentPartIds.add(part.id)
500
+
501
+ getDatabase()
502
+ .prepare(
503
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
504
+ )
505
+ .run(part.id, sendResult.id, thread.id)
409
506
  }
410
507
 
411
508
  const eventHandler = async () => {
@@ -414,390 +511,548 @@ export async function handleOpencodeSession({
414
511
  // Counts spawned tasks per agent type: "explore" → 2
415
512
  const agentSpawnCounts: Record<string, number> = {}
416
513
 
417
- try {
418
- let assistantMessageId: string | undefined
514
+ const storePart = (part: Part) => {
515
+ const messageParts = partBuffer.get(part.messageID) || new Map<string, Part>()
516
+ messageParts.set(part.id, part)
517
+ partBuffer.set(part.messageID, messageParts)
518
+ }
419
519
 
420
- for await (const event of events) {
421
- if (event.type === 'message.updated') {
422
- const msg = event.properties.info
520
+ const getBufferedParts = (messageID: string) => {
521
+ return Array.from(partBuffer.get(messageID)?.values() ?? [])
522
+ }
423
523
 
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
- }
524
+ const shouldSendPart = ({ part, force }: { part: Part; force: boolean }) => {
525
+ if (part.type === 'step-start' || part.type === 'step-finish') {
526
+ return false
527
+ }
429
528
 
430
- if (msg.sessionID !== session.id) {
431
- continue
432
- }
529
+ if (part.type === 'tool' && part.state.status === 'pending') {
530
+ return false
531
+ }
433
532
 
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
- }
533
+ if (!force && part.type === 'text' && !part.time?.end) {
534
+ return false
535
+ }
444
536
 
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
- }
537
+ if (!force && part.type === 'tool' && part.state.status === 'completed') {
538
+ return false
539
+ }
465
540
 
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
541
+ return true
542
+ }
481
543
 
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)
544
+ const flushBufferedParts = async ({
545
+ messageID,
546
+ force,
547
+ skipPartId,
548
+ }: {
549
+ messageID: string
550
+ force: boolean
551
+ skipPartId?: string
552
+ }) => {
553
+ if (!messageID) {
554
+ return
555
+ }
556
+ const parts = getBufferedParts(messageID)
557
+ for (const part of parts) {
558
+ if (skipPartId && part.id === skipPartId) {
559
+ continue
560
+ }
561
+ if (!shouldSendPart({ part, force })) {
562
+ continue
563
+ }
564
+ await sendPartMessage(part)
565
+ }
566
+ }
485
567
 
486
- // Accept events from main session OR tracked subtask sessions
487
- if (part.sessionID !== session.id && !isSubtaskEvent) {
488
- continue
489
- }
568
+ const handleMessageUpdated = async (msg: {
569
+ id: string
570
+ sessionID: string
571
+ role: string
572
+ modelID?: string
573
+ providerID?: string
574
+ mode?: string
575
+ tokens?: {
576
+ input: number
577
+ output: number
578
+ reasoning: number
579
+ cache: { read: number; write: number }
580
+ }
581
+ }) => {
582
+ const subtaskInfo = subtaskSessions.get(msg.sessionID)
583
+ if (subtaskInfo && msg.role === 'assistant') {
584
+ subtaskInfo.assistantMessageId = msg.id
585
+ }
490
586
 
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
- }
587
+ if (msg.sessionID !== session.id) {
588
+ return
589
+ }
512
590
 
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
- }
591
+ if (msg.role !== 'assistant') {
592
+ return
593
+ }
529
594
 
530
- // Main session events: require matching assistantMessageId
531
- if (part.messageID !== assistantMessageId) {
532
- continue
533
- }
595
+ if (msg.tokens) {
596
+ const newTokensTotal =
597
+ msg.tokens.input +
598
+ msg.tokens.output +
599
+ msg.tokens.reasoning +
600
+ msg.tokens.cache.read +
601
+ msg.tokens.cache.write
602
+ if (newTokensTotal > 0) {
603
+ tokensUsedInSession = newTokensTotal
604
+ }
605
+ }
534
606
 
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
- }
607
+ assistantMessageId = msg.id
608
+ usedModel = msg.modelID
609
+ usedProviderID = msg.providerID
610
+ usedAgent = msg.mode
541
611
 
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
- }
612
+ await flushBufferedParts({
613
+ messageID: assistantMessageId,
614
+ force: false,
615
+ })
552
616
 
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
- }
617
+ if (tokensUsedInSession === 0 || !usedProviderID || !usedModel) {
618
+ return
619
+ }
577
620
 
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
- }
621
+ if (!modelContextLimit) {
622
+ const providersResponse = await errore.tryAsync(() => {
623
+ return getClient().provider.list({
624
+ query: { directory: sdkDirectory },
625
+ })
626
+ })
627
+ if (providersResponse instanceof Error) {
628
+ sessionLogger.error('Failed to fetch provider info for context limit:', providersResponse)
629
+ } else {
630
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
631
+ const model = provider?.models?.[usedModel]
632
+ if (model?.limit?.context) {
633
+ modelContextLimit = model.limit.context
599
634
  }
635
+ }
636
+ }
600
637
 
601
- if (part.type === 'reasoning') {
602
- await sendPartMessage(part)
603
- }
638
+ if (!modelContextLimit) {
639
+ return
640
+ }
604
641
 
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
- }
642
+ const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100)
643
+ const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
644
+ if (thresholdCrossed <= lastDisplayedContextPercentage || thresholdCrossed < 10) {
645
+ return
646
+ }
647
+ lastDisplayedContextPercentage = thresholdCrossed
648
+ const chunk = `⬦ context usage ${currentPercentage}%`
649
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
650
+ }
610
651
 
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
- }
652
+ const handleMainPart = async (part: Part) => {
653
+ const isActiveMessage = assistantMessageId ? part.messageID === assistantMessageId : false
654
+ const allowEarlyProcessing =
655
+ !assistantMessageId && part.type === 'tool' && part.state.status === 'running'
656
+ if (!isActiveMessage && !allowEarlyProcessing) {
657
+ if (part.type !== 'step-start') {
658
+ return
659
+ }
660
+ }
661
+
662
+ if (part.type === 'step-start') {
663
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
664
+ (ctx) => ctx.thread.id === thread.id,
665
+ )
666
+ const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
667
+ if (!hasPendingQuestion && !hasPendingPermission) {
668
+ stopTyping = startTyping()
669
+ }
670
+ return
671
+ }
672
+
673
+ if (part.type === 'tool' && part.state.status === 'running') {
674
+ await flushBufferedParts({
675
+ messageID: assistantMessageId || part.messageID,
676
+ force: true,
677
+ skipPartId: part.id,
678
+ })
679
+ await sendPartMessage(part)
680
+ if (part.tool === 'task' && !sentPartIds.has(part.id)) {
681
+ const description = (part.state.input?.description as string) || ''
682
+ const agent = (part.state.input?.subagent_type as string) || 'task'
683
+ const childSessionId = (part.state.metadata?.sessionId as string) || ''
684
+ if (description && childSessionId) {
685
+ agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1
686
+ const label = `${agent}-${agentSpawnCounts[agent]}`
687
+ subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined })
688
+ // Skip task messages in text-only mode
689
+ if (verbosity !== 'text-only') {
690
+ const taskDisplay = `┣ task **${label}** _${description}_`
691
+ await sendThreadMessage(thread, taskDisplay + '\n\n')
616
692
  }
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)
693
+ sentPartIds.add(part.id)
627
694
  }
695
+ }
696
+ return
697
+ }
628
698
 
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
- }
699
+ if (part.type === 'tool' && part.state.status === 'completed') {
700
+ const output = part.state.output || ''
701
+ const outputTokens = Math.ceil(output.length / 4)
702
+ const largeOutputThreshold = 3000
703
+ if (outputTokens >= largeOutputThreshold) {
704
+ const formattedTokens =
705
+ outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens)
706
+ const percentageSuffix = (() => {
707
+ if (!modelContextLimit) {
708
+ return ''
645
709
  }
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
- }
710
+ const pct = (outputTokens / modelContextLimit) * 100
711
+ if (pct < 1) {
712
+ return ''
713
+ }
714
+ return ` (${pct.toFixed(1)}%)`
715
+ })()
716
+ const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
717
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
718
+ }
719
+ }
660
720
 
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
- }
721
+ if (part.type === 'reasoning') {
722
+ await sendPartMessage(part)
723
+ return
724
+ }
669
725
 
670
- sessionLogger.log(
671
- `Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
726
+ if (part.type === 'text' && part.time?.end) {
727
+ await sendPartMessage(part)
728
+ return
729
+ }
730
+
731
+ if (part.type === 'step-finish') {
732
+ await flushBufferedParts({
733
+ messageID: assistantMessageId || part.messageID,
734
+ force: true,
735
+ })
736
+ setTimeout(() => {
737
+ if (abortController.signal.aborted) return
738
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
739
+ (ctx) => ctx.thread.id === thread.id,
672
740
  )
741
+ const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
742
+ if (hasPendingQuestion || hasPendingPermission) return
743
+ stopTyping = startTyping()
744
+ }, 300)
745
+ }
746
+ }
673
747
 
674
- // Stop typing - user needs to respond now, not the bot
675
- if (stopTyping) {
676
- stopTyping()
677
- stopTyping = null
678
- }
748
+ const handleSubtaskPart = async (
749
+ part: Part,
750
+ subtaskInfo: { label: string; assistantMessageId?: string },
751
+ ) => {
752
+ // In text-only mode, skip all subtask output (they're tool-related)
753
+ if (verbosity === 'text-only') {
754
+ return
755
+ }
756
+ if (part.type === 'step-start' || part.type === 'step-finish') {
757
+ return
758
+ }
759
+ if (part.type === 'tool' && part.state.status === 'pending') {
760
+ return
761
+ }
762
+ if (part.type === 'text') {
763
+ return
764
+ }
765
+ if (!subtaskInfo.assistantMessageId || part.messageID !== subtaskInfo.assistantMessageId) {
766
+ return
767
+ }
679
768
 
680
- // Show dropdown instead of text message
681
- const { messageId, contextHash } = await showPermissionDropdown({
682
- thread,
683
- permission,
684
- directory,
685
- })
769
+ const content = formatPart(part, subtaskInfo.label)
770
+ if (!content.trim() || sentPartIds.has(part.id)) {
771
+ return
772
+ }
773
+ const sendResult = await errore.tryAsync(() => {
774
+ return sendThreadMessage(thread, content + '\n\n')
775
+ })
776
+ if (sendResult instanceof Error) {
777
+ discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, sendResult)
778
+ return
779
+ }
780
+ sentPartIds.add(part.id)
781
+ getDatabase()
782
+ .prepare(
783
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
784
+ )
785
+ .run(part.id, sendResult.id, thread.id)
786
+ }
686
787
 
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
- }
788
+ const handlePartUpdated = async (part: Part) => {
789
+ storePart(part)
702
790
 
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
791
+ const subtaskInfo = subtaskSessions.get(part.sessionID)
792
+ const isSubtaskEvent = Boolean(subtaskInfo)
720
793
 
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
- }
794
+ if (part.sessionID !== session.id && !isSubtaskEvent) {
795
+ return
796
+ }
797
+
798
+ if (isSubtaskEvent && subtaskInfo) {
799
+ await handleSubtaskPart(part, subtaskInfo)
800
+ return
801
+ }
802
+
803
+ await handleMainPart(part)
804
+ }
805
+
806
+ const handleSessionError = async ({
807
+ sessionID,
808
+ error,
809
+ }: {
810
+ sessionID?: string
811
+ error?: { data?: { message?: string } }
812
+ }) => {
813
+ if (!sessionID || sessionID !== session.id) {
814
+ voiceLogger.log(
815
+ `[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${sessionID})`,
816
+ )
817
+ return
818
+ }
819
+
820
+ const errorMessage = error?.data?.message || 'Unknown error'
821
+ sessionLogger.error(`Sending error to thread: ${errorMessage}`)
822
+ await sendThreadMessage(thread, `✗ opencode session error: ${errorMessage}`)
823
+
824
+ if (!originalMessage) {
825
+ return
826
+ }
827
+ const reactionResult = await errore.tryAsync(async () => {
828
+ await originalMessage.reactions.removeAll()
829
+ await originalMessage.react('❌')
830
+ })
831
+ if (reactionResult instanceof Error) {
832
+ discordLogger.log(`Could not update reaction:`, reactionResult)
833
+ } else {
834
+ voiceLogger.log(`[REACTION] Added error reaction due to session error`)
835
+ }
836
+ }
837
+
838
+ const handlePermissionAsked = async (permission: PermissionRequest) => {
839
+ if (permission.sessionID !== session.id) {
840
+ voiceLogger.log(
841
+ `[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`,
842
+ )
843
+ return
844
+ }
845
+
846
+ const dedupeKey = buildPermissionDedupeKey({ permission, directory })
847
+ const threadPermissions = pendingPermissions.get(thread.id)
848
+ const existingPending = threadPermissions
849
+ ? Array.from(threadPermissions.values()).find((pending) => {
850
+ return pending.dedupeKey === dedupeKey
851
+ })
852
+ : undefined
727
853
 
854
+ if (existingPending) {
855
+ sessionLogger.log(
856
+ `[PERMISSION] Deduped permission ${permission.id} (matches pending ${existingPending.permission.id})`,
857
+ )
858
+ if (stopTyping) {
859
+ stopTyping()
860
+ stopTyping = null
861
+ }
862
+ if (!pendingPermissions.has(thread.id)) {
863
+ pendingPermissions.set(thread.id, new Map())
864
+ }
865
+ pendingPermissions.get(thread.id)!.set(permission.id, {
866
+ permission,
867
+ messageId: existingPending.messageId,
868
+ directory,
869
+ contextHash: existingPending.contextHash,
870
+ dedupeKey,
871
+ })
872
+ const added = addPermissionRequestToContext({
873
+ contextHash: existingPending.contextHash,
874
+ requestId: permission.id,
875
+ })
876
+ if (!added) {
728
877
  sessionLogger.log(
729
- `Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
878
+ `[PERMISSION] Failed to attach duplicate request ${permission.id} to context`,
730
879
  )
880
+ }
881
+ return
882
+ }
731
883
 
732
- // Stop typing - user needs to respond now, not the bot
733
- if (stopTyping) {
734
- stopTyping()
735
- stopTyping = null
736
- }
884
+ sessionLogger.log(
885
+ `Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
886
+ )
737
887
 
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
- }
888
+ if (stopTyping) {
889
+ stopTyping()
890
+ stopTyping = null
891
+ }
745
892
 
746
- await showAskUserQuestionDropdowns({
747
- thread,
748
- sessionId: session.id,
749
- directory,
750
- requestId: questionRequest.id,
751
- input: { questions: questionRequest.questions },
752
- })
893
+ const { messageId, contextHash } = await showPermissionDropdown({
894
+ thread,
895
+ permission,
896
+ directory,
897
+ })
753
898
 
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
- }
899
+ if (!pendingPermissions.has(thread.id)) {
900
+ pendingPermissions.set(thread.id, new Map())
901
+ }
902
+ pendingPermissions.get(thread.id)!.set(permission.id, {
903
+ permission,
904
+ messageId,
905
+ directory,
906
+ contextHash,
907
+ dedupeKey,
908
+ })
909
+ }
761
910
 
762
- sessionLogger.log(
763
- `[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`,
764
- )
911
+ const handlePermissionReplied = ({
912
+ requestID,
913
+ reply,
914
+ sessionID,
915
+ }: {
916
+ requestID: string
917
+ reply: string
918
+ sessionID: string
919
+ }) => {
920
+ if (sessionID !== session.id) {
921
+ return
922
+ }
765
923
 
924
+ sessionLogger.log(`Permission ${requestID} replied with: ${reply}`)
925
+
926
+ const threadPermissions = pendingPermissions.get(thread.id)
927
+ if (!threadPermissions) {
928
+ return
929
+ }
930
+ const pending = threadPermissions.get(requestID)
931
+ if (!pending) {
932
+ return
933
+ }
934
+ cleanupPermissionContext(pending.contextHash)
935
+ threadPermissions.delete(requestID)
936
+ if (threadPermissions.size === 0) {
937
+ pendingPermissions.delete(thread.id)
938
+ }
939
+ }
940
+
941
+ const handleQuestionAsked = async (questionRequest: QuestionRequest) => {
942
+ if (questionRequest.sessionID !== session.id) {
943
+ sessionLogger.log(
944
+ `[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`,
945
+ )
946
+ return
947
+ }
948
+
949
+ sessionLogger.log(
950
+ `Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
951
+ )
952
+
953
+ if (stopTyping) {
954
+ stopTyping()
955
+ stopTyping = null
956
+ }
957
+
958
+ await flushBufferedParts({
959
+ messageID: assistantMessageId || '',
960
+ force: true,
961
+ })
962
+
963
+ await showAskUserQuestionDropdowns({
964
+ thread,
965
+ sessionId: session.id,
966
+ directory,
967
+ requestId: questionRequest.id,
968
+ input: { questions: questionRequest.questions },
969
+ })
970
+
971
+ const queue = messageQueue.get(thread.id)
972
+ if (!queue || queue.length === 0) {
973
+ return
974
+ }
975
+
976
+ const nextMessage = queue.shift()!
977
+ if (queue.length === 0) {
978
+ messageQueue.delete(thread.id)
979
+ }
980
+
981
+ sessionLogger.log(
982
+ `[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`,
983
+ )
984
+
985
+ await sendThreadMessage(
986
+ thread,
987
+ `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`,
988
+ )
989
+
990
+ setImmediate(() => {
991
+ void errore
992
+ .tryAsync(async () => {
993
+ return handleOpencodeSession({
994
+ prompt: nextMessage.prompt,
995
+ thread,
996
+ projectDirectory: directory,
997
+ images: nextMessage.images,
998
+ channelId,
999
+ })
1000
+ })
1001
+ .then(async (result) => {
1002
+ if (!(result instanceof Error)) {
1003
+ return
1004
+ }
1005
+ sessionLogger.error(`[QUEUE] Failed to process queued message:`, result)
766
1006
  await sendThreadMessage(
767
1007
  thread,
768
- **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`,
1008
+ `✗ Queued message failed: ${result.message.slice(0, 200)}`,
769
1009
  )
1010
+ })
1011
+ })
1012
+ }
770
1013
 
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
- }
1014
+ const handleSessionIdle = (idleSessionId: string) => {
1015
+ if (idleSessionId === session.id) {
1016
+ sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`)
1017
+ abortController.abort('finished')
1018
+ return
1019
+ }
1020
+
1021
+ if (!subtaskSessions.has(idleSessionId)) {
1022
+ return
1023
+ }
1024
+ const subtask = subtaskSessions.get(idleSessionId)
1025
+ sessionLogger.log(`[SUBTASK IDLE] Subtask "${subtask?.label}" completed`)
1026
+ subtaskSessions.delete(idleSessionId)
1027
+ }
1028
+
1029
+ try {
1030
+ for await (const event of events) {
1031
+ switch (event.type) {
1032
+ case 'message.updated':
1033
+ await handleMessageUpdated(event.properties.info)
1034
+ break
1035
+ case 'message.part.updated':
1036
+ await handlePartUpdated(event.properties.part)
1037
+ break
1038
+ case 'session.error':
1039
+ sessionLogger.error(`ERROR:`, event.properties)
1040
+ await handleSessionError(event.properties)
1041
+ break
1042
+ case 'permission.asked':
1043
+ await handlePermissionAsked(event.properties)
1044
+ break
1045
+ case 'permission.replied':
1046
+ handlePermissionReplied(event.properties)
1047
+ break
1048
+ case 'question.asked':
1049
+ await handleQuestionAsked(event.properties)
1050
+ break
1051
+ case 'session.idle':
1052
+ handleSessionIdle(event.properties.sessionID)
1053
+ break
1054
+ default:
1055
+ break
801
1056
  }
802
1057
  }
803
1058
  } catch (e) {
@@ -808,12 +1063,13 @@ export async function handleOpencodeSession({
808
1063
  sessionLogger.error(`Unexpected error in event handling code`, e)
809
1064
  throw e
810
1065
  } finally {
811
- for (const part of currentParts) {
812
- if (!sentPartIds.has(part.id)) {
813
- try {
1066
+ abortControllers.delete(session.id)
1067
+ const finalMessageId = assistantMessageId
1068
+ if (finalMessageId) {
1069
+ const parts = getBufferedParts(finalMessageId)
1070
+ for (const part of parts) {
1071
+ if (!sentPartIds.has(part.id)) {
814
1072
  await sendPartMessage(part)
815
- } catch (error) {
816
- sessionLogger.error(`Failed to send part ${part.id}:`, error)
817
1073
  }
818
1074
  }
819
1075
  }
@@ -831,12 +1087,13 @@ export async function handleOpencodeSession({
831
1087
  usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : ''
832
1088
  let contextInfo = ''
833
1089
 
834
- try {
1090
+ const contextResult = await errore.tryAsync(async () => {
835
1091
  // Fetch final token count from API since message.updated events can arrive
836
1092
  // after session.idle due to race conditions in event ordering
837
1093
  if (tokensUsedInSession === 0) {
838
1094
  const messagesResponse = await getClient().session.messages({
839
1095
  path: { id: session.id },
1096
+ query: { directory: sdkDirectory },
840
1097
  })
841
1098
  const messages = messagesResponse.data || []
842
1099
  const lastAssistant = [...messages]
@@ -858,15 +1115,16 @@ export async function handleOpencodeSession({
858
1115
  }
859
1116
  }
860
1117
 
861
- const providersResponse = await getClient().provider.list({ query: { directory } })
1118
+ const providersResponse = await getClient().provider.list({ query: { directory: sdkDirectory } })
862
1119
  const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
863
1120
  const model = provider?.models?.[usedModel || '']
864
1121
  if (model?.limit?.context) {
865
1122
  const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100)
866
1123
  contextInfo = ` ⋅ ${percentage}%`
867
1124
  }
868
- } catch (e) {
869
- sessionLogger.error('Failed to fetch provider info for context percentage:', e)
1125
+ })
1126
+ if (contextResult instanceof Error) {
1127
+ sessionLogger.error('Failed to fetch provider info for context percentage:', contextResult)
870
1128
  }
871
1129
 
872
1130
  await sendThreadMessage(
@@ -918,8 +1176,15 @@ export async function handleOpencodeSession({
918
1176
  }
919
1177
  }
920
1178
 
921
- try {
922
- const eventHandlerPromise = eventHandler()
1179
+ const promptResult: Error | { sessionID: string; result: any; port?: number } | undefined =
1180
+ await errore.tryAsync(async () => {
1181
+ const newHandlerPromise = eventHandler().finally(() => {
1182
+ if (activeEventHandlers.get(thread.id) === newHandlerPromise) {
1183
+ activeEventHandlers.delete(thread.id)
1184
+ }
1185
+ })
1186
+ activeEventHandlers.set(thread.id, newHandlerPromise)
1187
+ handlerPromise = newHandlerPromise
923
1188
 
924
1189
  if (abortController.signal.aborted) {
925
1190
  sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`)
@@ -931,7 +1196,6 @@ export async function handleOpencodeSession({
931
1196
  voiceLogger.log(
932
1197
  `[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
933
1198
  )
934
- // append image paths to prompt so ai knows where they are on disk
935
1199
  const promptWithImagePaths = (() => {
936
1200
  if (images.length === 0) {
937
1201
  return prompt
@@ -951,19 +1215,15 @@ export async function handleOpencodeSession({
951
1215
  const parts = [{ type: 'text' as const, text: promptWithImagePaths }, ...images]
952
1216
  sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
953
1217
 
954
- // Get agent preference: session-level overrides channel-level
955
1218
  const agentPreference =
956
1219
  getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
957
1220
  if (agentPreference) {
958
1221
  sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
959
1222
  }
960
1223
 
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
1224
  const modelPreference =
964
1225
  getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
965
1226
  const modelParam = (() => {
966
- // When an agent is set, let the agent's model config take effect
967
1227
  if (agentPreference) {
968
1228
  sessionLogger.log(`[MODEL] Skipping model param, agent "${agentPreference}" controls model`)
969
1229
  return undefined
@@ -980,8 +1240,7 @@ export async function handleOpencodeSession({
980
1240
  return { providerID, modelID }
981
1241
  })()
982
1242
 
983
- // Get worktree info if this thread is in a worktree
984
- const worktreeInfo = getThreadWorktree(thread.id)
1243
+ // Build worktree info for system message (worktreeInfo was fetched at the start)
985
1244
  const worktree: WorktreeInfo | undefined =
986
1245
  worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
987
1246
  ? {
@@ -991,10 +1250,10 @@ export async function handleOpencodeSession({
991
1250
  }
992
1251
  : undefined
993
1252
 
994
- // Use session.command API for slash commands, session.prompt for regular messages
995
1253
  const response = command
996
1254
  ? await getClient().session.command({
997
1255
  path: { id: session.id },
1256
+ query: { directory: sdkDirectory },
998
1257
  body: {
999
1258
  command: command.name,
1000
1259
  arguments: command.arguments,
@@ -1004,6 +1263,7 @@ export async function handleOpencodeSession({
1004
1263
  })
1005
1264
  : await getClient().session.prompt({
1006
1265
  path: { id: session.id },
1266
+ query: { directory: sdkDirectory },
1007
1267
  body: {
1008
1268
  parts,
1009
1269
  system: getOpencodeSystemMessage({ sessionId: session.id, channelId, worktree }),
@@ -1034,40 +1294,55 @@ export async function handleOpencodeSession({
1034
1294
  sessionLogger.log(`Successfully sent prompt, got response`)
1035
1295
 
1036
1296
  if (originalMessage) {
1037
- try {
1297
+ const reactionResult = await errore.tryAsync(async () => {
1038
1298
  await originalMessage.reactions.removeAll()
1039
1299
  await originalMessage.react('✅')
1040
- } catch (e) {
1041
- discordLogger.log(`Could not update reactions:`, e)
1300
+ })
1301
+ if (reactionResult instanceof Error) {
1302
+ discordLogger.log(`Could not update reactions:`, reactionResult)
1042
1303
  }
1043
1304
  }
1044
1305
 
1045
1306
  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}`)
1307
+ })
1308
+
1309
+ if (handlerPromise) {
1310
+ await Promise.race([
1311
+ handlerPromise,
1312
+ new Promise((resolve) => {
1313
+ setTimeout(resolve, 1000)
1314
+ }),
1315
+ ])
1316
+ }
1317
+
1318
+ if (!errore.isError(promptResult)) {
1319
+ return promptResult
1320
+ }
1321
+
1322
+ const promptError: Error = promptResult instanceof Error ? promptResult : new Error('Unknown error')
1323
+ if (isAbortError(promptError, abortController.signal)) {
1324
+ return
1325
+ }
1326
+
1327
+ sessionLogger.error(`ERROR: Failed to send prompt:`, promptError)
1328
+ abortController.abort('error')
1329
+
1330
+ if (originalMessage) {
1331
+ const reactionResult = await errore.tryAsync(async () => {
1332
+ await originalMessage.reactions.removeAll()
1333
+ await originalMessage.react('❌')
1334
+ })
1335
+ if (reactionResult instanceof Error) {
1336
+ discordLogger.log(`Could not update reaction:`, reactionResult)
1337
+ } else {
1338
+ discordLogger.log(`Added error reaction to message`)
1071
1339
  }
1072
1340
  }
1341
+ const errorDisplay = (() => {
1342
+ const promptErrorValue = promptError as unknown as Error
1343
+ const name = promptErrorValue.name || 'Error'
1344
+ const message = promptErrorValue.stack || promptErrorValue.message
1345
+ return `[${name}]\n${message}`
1346
+ })()
1347
+ await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`)
1073
1348
  }