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