kimaki 0.4.24 → 0.4.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/bin.js +6 -1
  2. package/dist/acp-client.test.js +149 -0
  3. package/dist/ai-tool-to-genai.js +3 -0
  4. package/dist/channel-management.js +14 -9
  5. package/dist/cli.js +148 -17
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +98 -0
  8. package/dist/commands/agent.js +152 -0
  9. package/dist/commands/ask-question.js +183 -0
  10. package/dist/commands/create-new-project.js +78 -0
  11. package/dist/commands/fork.js +186 -0
  12. package/dist/commands/model.js +313 -0
  13. package/dist/commands/permissions.js +126 -0
  14. package/dist/commands/queue.js +129 -0
  15. package/dist/commands/resume.js +145 -0
  16. package/dist/commands/session.js +142 -0
  17. package/dist/commands/share.js +80 -0
  18. package/dist/commands/types.js +2 -0
  19. package/dist/commands/undo-redo.js +161 -0
  20. package/dist/commands/user-command.js +145 -0
  21. package/dist/database.js +54 -0
  22. package/dist/discord-bot.js +35 -32
  23. package/dist/discord-utils.js +81 -15
  24. package/dist/format-tables.js +3 -0
  25. package/dist/genai-worker-wrapper.js +3 -0
  26. package/dist/genai-worker.js +3 -0
  27. package/dist/genai.js +3 -0
  28. package/dist/interaction-handler.js +89 -695
  29. package/dist/logger.js +46 -5
  30. package/dist/markdown.js +107 -0
  31. package/dist/markdown.test.js +31 -1
  32. package/dist/message-formatting.js +113 -28
  33. package/dist/message-formatting.test.js +73 -0
  34. package/dist/opencode.js +73 -16
  35. package/dist/session-handler.js +176 -63
  36. package/dist/system-message.js +7 -38
  37. package/dist/tools.js +3 -0
  38. package/dist/utils.js +3 -0
  39. package/dist/voice-handler.js +21 -8
  40. package/dist/voice.js +31 -12
  41. package/dist/worker-types.js +3 -0
  42. package/dist/xml.js +3 -0
  43. package/package.json +3 -3
  44. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  45. package/src/__snapshots__/compact-session-context.md +47 -0
  46. package/src/ai-tool-to-genai.ts +4 -0
  47. package/src/channel-management.ts +24 -8
  48. package/src/cli.ts +163 -18
  49. package/src/commands/abort.ts +94 -0
  50. package/src/commands/add-project.ts +139 -0
  51. package/src/commands/agent.ts +201 -0
  52. package/src/commands/ask-question.ts +276 -0
  53. package/src/commands/create-new-project.ts +111 -0
  54. package/src/{fork.ts → commands/fork.ts} +40 -7
  55. package/src/{model-command.ts → commands/model.ts} +31 -9
  56. package/src/commands/permissions.ts +146 -0
  57. package/src/commands/queue.ts +181 -0
  58. package/src/commands/resume.ts +230 -0
  59. package/src/commands/session.ts +184 -0
  60. package/src/commands/share.ts +96 -0
  61. package/src/commands/types.ts +25 -0
  62. package/src/commands/undo-redo.ts +213 -0
  63. package/src/commands/user-command.ts +178 -0
  64. package/src/database.ts +65 -0
  65. package/src/discord-bot.ts +40 -33
  66. package/src/discord-utils.ts +88 -14
  67. package/src/format-tables.ts +4 -0
  68. package/src/genai-worker-wrapper.ts +4 -0
  69. package/src/genai-worker.ts +4 -0
  70. package/src/genai.ts +4 -0
  71. package/src/interaction-handler.ts +111 -924
  72. package/src/logger.ts +51 -10
  73. package/src/markdown.test.ts +45 -1
  74. package/src/markdown.ts +136 -0
  75. package/src/message-formatting.test.ts +81 -0
  76. package/src/message-formatting.ts +143 -30
  77. package/src/opencode.ts +84 -21
  78. package/src/session-handler.ts +248 -91
  79. package/src/system-message.ts +8 -38
  80. package/src/tools.ts +4 -0
  81. package/src/utils.ts +4 -0
  82. package/src/voice-handler.ts +24 -9
  83. package/src/voice.ts +36 -13
  84. package/src/worker-types.ts +4 -0
  85. package/src/xml.ts +4 -0
  86. package/README.md +0 -48
package/src/opencode.ts CHANGED
@@ -1,10 +1,19 @@
1
+ // OpenCode server process manager.
2
+ // Spawns and maintains OpenCode API servers per project directory,
3
+ // handles automatic restarts on failure, and provides typed SDK clients.
4
+
1
5
  import { spawn, type ChildProcess } from 'node:child_process'
6
+ import fs from 'node:fs'
2
7
  import net from 'node:net'
3
8
  import {
4
9
  createOpencodeClient,
5
10
  type OpencodeClient,
6
11
  type Config,
7
12
  } from '@opencode-ai/sdk'
13
+ import {
14
+ createOpencodeClient as createOpencodeClientV2,
15
+ type OpencodeClient as OpencodeClientV2,
16
+ } from '@opencode-ai/sdk/v2'
8
17
  import { createLogger } from './logger.js'
9
18
 
10
19
  const opencodeLogger = createLogger('OPENCODE')
@@ -14,6 +23,7 @@ const opencodeServers = new Map<
14
23
  {
15
24
  process: ChildProcess
16
25
  client: OpencodeClient
26
+ clientV2: OpencodeClientV2
17
27
  port: number
18
28
  }
19
29
  >()
@@ -42,21 +52,36 @@ async function waitForServer(port: number, maxAttempts = 30): Promise<boolean> {
42
52
  for (let i = 0; i < maxAttempts; i++) {
43
53
  try {
44
54
  const endpoints = [
45
- `http://localhost:${port}/api/health`,
46
- `http://localhost:${port}/`,
47
- `http://localhost:${port}/api`,
55
+ `http://127.0.0.1:${port}/api/health`,
56
+ `http://127.0.0.1:${port}/`,
57
+ `http://127.0.0.1:${port}/api`,
48
58
  ]
49
59
 
50
60
  for (const endpoint of endpoints) {
51
61
  try {
52
62
  const response = await fetch(endpoint)
53
63
  if (response.status < 500) {
54
- opencodeLogger.log(`Server ready on port `)
55
64
  return true
56
65
  }
57
- } catch (e) {}
66
+ const body = await response.text()
67
+ // Fatal errors that won't resolve with retrying
68
+ if (body.includes('BunInstallFailedError')) {
69
+ throw new Error(`Server failed to start: ${body.slice(0, 200)}`)
70
+ }
71
+ } catch (e) {
72
+ // Re-throw fatal errors
73
+ if ((e as Error).message?.includes('Server failed to start')) {
74
+ throw e
75
+ }
76
+ }
77
+ }
78
+ } catch (e) {
79
+ // Re-throw fatal errors that won't resolve with retrying
80
+ if ((e as Error).message?.includes('Server failed to start')) {
81
+ throw e
58
82
  }
59
- } catch (e) {}
83
+ opencodeLogger.debug(`Server polling attempt failed: ${(e as Error).message}`)
84
+ }
60
85
  await new Promise((resolve) => setTimeout(resolve, 1000))
61
86
  }
62
87
  throw new Error(
@@ -81,9 +106,17 @@ export async function initializeOpencodeForDirectory(directory: string) {
81
106
  }
82
107
  }
83
108
 
109
+ // Verify directory exists and is accessible before spawning
110
+ try {
111
+ fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK)
112
+ } catch {
113
+ throw new Error(`Directory does not exist or is not accessible: ${directory}`)
114
+ }
115
+
84
116
  const port = await getOpenPort()
85
117
 
86
- const opencodeCommand = process.env.OPENCODE_PATH || 'opencode'
118
+ const opencodeBinDir = `${process.env.HOME}/.opencode/bin`
119
+ const opencodeCommand = process.env.OPENCODE_PATH || `${opencodeBinDir}/opencode`
87
120
 
88
121
  const serverProcess = spawn(
89
122
  opencodeCommand,
@@ -109,23 +142,24 @@ export async function initializeOpencodeForDirectory(directory: string) {
109
142
  },
110
143
  )
111
144
 
145
+ // Buffer logs until we know if server started successfully
146
+ const logBuffer: string[] = []
147
+ logBuffer.push(`Spawned opencode serve --port ${port} in ${directory} (pid: ${serverProcess.pid})`)
148
+
112
149
  serverProcess.stdout?.on('data', (data) => {
113
- opencodeLogger.log(`opencode ${directory}: ${data.toString().trim()}`)
150
+ logBuffer.push(`[stdout] ${data.toString().trim()}`)
114
151
  })
115
152
 
116
153
  serverProcess.stderr?.on('data', (data) => {
117
- opencodeLogger.error(`opencode ${directory}: ${data.toString().trim()}`)
154
+ logBuffer.push(`[stderr] ${data.toString().trim()}`)
118
155
  })
119
156
 
120
157
  serverProcess.on('error', (error) => {
121
- opencodeLogger.error(`Failed to start server on port :`, port, error)
158
+ logBuffer.push(`Failed to start server on port ${port}: ${error}`)
122
159
  })
123
160
 
124
161
  serverProcess.on('exit', (code) => {
125
- opencodeLogger.log(
126
- `Opencode server on ${directory} exited with code:`,
127
- code,
128
- )
162
+ opencodeLogger.log(`Opencode server on ${directory} exited with code:`, code)
129
163
  opencodeServers.delete(directory)
130
164
  if (code !== 0) {
131
165
  const retryCount = serverRetryCount.get(directory) || 0
@@ -147,20 +181,39 @@ export async function initializeOpencodeForDirectory(directory: string) {
147
181
  }
148
182
  })
149
183
 
150
- await waitForServer(port)
184
+ try {
185
+ await waitForServer(port)
186
+ opencodeLogger.log(`Server ready on port ${port}`)
187
+ } catch (e) {
188
+ // Dump buffered logs on failure
189
+ opencodeLogger.error(`Server failed to start for ${directory}:`)
190
+ for (const line of logBuffer) {
191
+ opencodeLogger.error(` ${line}`)
192
+ }
193
+ throw e
194
+ }
195
+
196
+ const baseUrl = `http://127.0.0.1:${port}`
197
+ const fetchWithTimeout = (request: Request) =>
198
+ fetch(request, {
199
+ // @ts-ignore
200
+ timeout: false,
201
+ })
151
202
 
152
203
  const client = createOpencodeClient({
153
- baseUrl: `http://localhost:${port}`,
154
- fetch: (request: Request) =>
155
- fetch(request, {
156
- // @ts-ignore
157
- timeout: false,
158
- }),
204
+ baseUrl,
205
+ fetch: fetchWithTimeout,
206
+ })
207
+
208
+ const clientV2 = createOpencodeClientV2({
209
+ baseUrl,
210
+ fetch: fetchWithTimeout as typeof fetch,
159
211
  })
160
212
 
161
213
  opencodeServers.set(directory, {
162
214
  process: serverProcess,
163
215
  client,
216
+ clientV2,
164
217
  port,
165
218
  })
166
219
 
@@ -178,3 +231,13 @@ export async function initializeOpencodeForDirectory(directory: string) {
178
231
  export function getOpencodeServers() {
179
232
  return opencodeServers
180
233
  }
234
+
235
+ export function getOpencodeServerPort(directory: string): number | null {
236
+ const entry = opencodeServers.get(directory)
237
+ return entry?.port ?? null
238
+ }
239
+
240
+ export function getOpencodeClientV2(directory: string): OpencodeClientV2 | null {
241
+ const entry = opencodeServers.get(directory)
242
+ return entry?.clientV2 ?? null
243
+ }
@@ -1,46 +1,136 @@
1
- import type { Part, FilePartInput, Permission } from '@opencode-ai/sdk'
1
+ // OpenCode session lifecycle manager.
2
+ // Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
3
+ // Handles streaming events, permissions, abort signals, and message queuing.
4
+
5
+ import type { Part, PermissionRequest } from '@opencode-ai/sdk/v2'
6
+ import type { FilePartInput } from '@opencode-ai/sdk'
2
7
  import type { Message, ThreadChannel } from 'discord.js'
3
8
  import prettyMilliseconds from 'pretty-ms'
4
- import { getDatabase, getSessionModel, getChannelModel } from './database.js'
5
- import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js'
6
- import { sendThreadMessage } from './discord-utils.js'
9
+ import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js'
10
+ import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js'
11
+ import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from './discord-utils.js'
7
12
  import { formatPart } from './message-formatting.js'
8
13
  import { getOpencodeSystemMessage } from './system-message.js'
9
14
  import { createLogger } from './logger.js'
10
15
  import { isAbortError } from './utils.js'
16
+ import { showAskUserQuestionDropdowns } from './commands/ask-question.js'
11
17
 
12
18
  const sessionLogger = createLogger('SESSION')
13
19
  const voiceLogger = createLogger('VOICE')
14
20
  const discordLogger = createLogger('DISCORD')
15
21
 
16
- export type ParsedCommand = {
17
- isCommand: true
18
- command: string
19
- arguments: string
20
- } | {
21
- isCommand: false
22
+ export const abortControllers = new Map<string, AbortController>()
23
+
24
+ export const pendingPermissions = new Map<
25
+ string,
26
+ { permission: PermissionRequest; messageId: string; directory: string }
27
+ >()
28
+
29
+ export type QueuedMessage = {
30
+ prompt: string
31
+ userId: string
32
+ username: string
33
+ queuedAt: number
34
+ images?: FilePartInput[]
35
+ }
36
+
37
+ // Queue of messages waiting to be sent after current response finishes
38
+ // Key is threadId, value is array of queued messages
39
+ export const messageQueue = new Map<string, QueuedMessage[]>()
40
+
41
+ export function addToQueue({
42
+ threadId,
43
+ message,
44
+ }: {
45
+ threadId: string
46
+ message: QueuedMessage
47
+ }): number {
48
+ const queue = messageQueue.get(threadId) || []
49
+ queue.push(message)
50
+ messageQueue.set(threadId, queue)
51
+ return queue.length
52
+ }
53
+
54
+ export function getQueueLength(threadId: string): number {
55
+ return messageQueue.get(threadId)?.length || 0
22
56
  }
23
57
 
24
- export function parseSlashCommand(text: string): ParsedCommand {
25
- const trimmed = text.trim()
26
- if (!trimmed.startsWith('/')) {
27
- return { isCommand: false }
58
+ export function clearQueue(threadId: string): void {
59
+ messageQueue.delete(threadId)
60
+ }
61
+
62
+ /**
63
+ * Abort a running session and retry with the last user message.
64
+ * Used when model preference changes mid-request.
65
+ * Fetches last user message from OpenCode API instead of tracking in memory.
66
+ * @returns true if aborted and retry scheduled, false if no active request
67
+ */
68
+ export async function abortAndRetrySession({
69
+ sessionId,
70
+ thread,
71
+ projectDirectory,
72
+ }: {
73
+ sessionId: string
74
+ thread: ThreadChannel
75
+ projectDirectory: string
76
+ }): Promise<boolean> {
77
+ const controller = abortControllers.get(sessionId)
78
+
79
+ if (!controller) {
80
+ sessionLogger.log(`[ABORT+RETRY] No active request for session ${sessionId}`)
81
+ return false
28
82
  }
29
- const match = trimmed.match(/^\/(\S+)(?:\s+(.*))?$/)
30
- if (!match) {
31
- return { isCommand: false }
83
+
84
+ sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`)
85
+
86
+ // Abort with special reason so we don't show "completed" message
87
+ controller.abort('model-change')
88
+
89
+ // Also call the API abort endpoint
90
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
91
+ try {
92
+ await getClient().session.abort({ path: { id: sessionId } })
93
+ } catch (e) {
94
+ sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, e)
32
95
  }
33
- const command = match[1]!
34
- const args = match[2]?.trim() || ''
35
- return { isCommand: true, command, arguments: args }
36
- }
37
96
 
38
- export const abortControllers = new Map<string, AbortController>()
97
+ // Small delay to let the abort propagate
98
+ await new Promise((resolve) => { setTimeout(resolve, 300) })
39
99
 
40
- export const pendingPermissions = new Map<
41
- string,
42
- { permission: Permission; messageId: string; directory: string }
43
- >()
100
+ // Fetch last user message from API
101
+ sessionLogger.log(`[ABORT+RETRY] Fetching last user message for session ${sessionId}`)
102
+ const messagesResponse = await getClient().session.messages({ path: { id: sessionId } })
103
+ const messages = messagesResponse.data || []
104
+ const lastUserMessage = [...messages].reverse().find((m) => m.info.role === 'user')
105
+
106
+ if (!lastUserMessage) {
107
+ sessionLogger.log(`[ABORT+RETRY] No user message found in session ${sessionId}`)
108
+ return false
109
+ }
110
+
111
+ // Extract text and images from parts
112
+ const textPart = lastUserMessage.parts.find((p) => p.type === 'text') as { type: 'text'; text: string } | undefined
113
+ const prompt = textPart?.text || ''
114
+ const images = lastUserMessage.parts.filter((p) => p.type === 'file') as FilePartInput[]
115
+
116
+ sessionLogger.log(`[ABORT+RETRY] Re-triggering session ${sessionId} with new model`)
117
+
118
+ // Use setImmediate to avoid blocking
119
+ setImmediate(() => {
120
+ handleOpencodeSession({
121
+ prompt,
122
+ thread,
123
+ projectDirectory,
124
+ images,
125
+ }).catch(async (e) => {
126
+ sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, e)
127
+ const errorMsg = e instanceof Error ? e.message : String(e)
128
+ await sendThreadMessage(thread, `✗ Failed to retry with new model: ${errorMsg.slice(0, 200)}`)
129
+ })
130
+ })
131
+
132
+ return true
133
+ }
44
134
 
45
135
  export async function handleOpencodeSession({
46
136
  prompt,
@@ -48,16 +138,17 @@ export async function handleOpencodeSession({
48
138
  projectDirectory,
49
139
  originalMessage,
50
140
  images = [],
51
- parsedCommand,
52
141
  channelId,
142
+ command,
53
143
  }: {
54
144
  prompt: string
55
145
  thread: ThreadChannel
56
146
  projectDirectory?: string
57
147
  originalMessage?: Message
58
148
  images?: FilePartInput[]
59
- parsedCommand?: ParsedCommand
60
149
  channelId?: string
150
+ /** If set, uses session.command API instead of session.prompt */
151
+ command?: { name: string; arguments: string }
61
152
  }): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
62
153
  voiceLogger.log(
63
154
  `[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
@@ -160,9 +251,15 @@ export async function handleOpencodeSession({
160
251
  return
161
252
  }
162
253
 
163
- const eventsResult = await getClient().event.subscribe({
164
- signal: abortController.signal,
165
- })
254
+ // Use v2 client for event subscription (has proper types for question.asked events)
255
+ const clientV2 = getOpencodeClientV2(directory)
256
+ if (!clientV2) {
257
+ throw new Error(`OpenCode v2 client not found for directory: ${directory}`)
258
+ }
259
+ const eventsResult = await clientV2.event.subscribe(
260
+ { directory },
261
+ { signal: abortController.signal }
262
+ )
166
263
 
167
264
  if (abortController.signal.aborted) {
168
265
  sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`)
@@ -183,6 +280,7 @@ export async function handleOpencodeSession({
183
280
  let stopTyping: (() => void) | null = null
184
281
  let usedModel: string | undefined
185
282
  let usedProviderID: string | undefined
283
+ let usedAgent: string | undefined
186
284
  let tokensUsedInSession = 0
187
285
  let lastDisplayedContextPercentage = 0
188
286
  let modelContextLimit: number | undefined
@@ -233,7 +331,7 @@ export async function handleOpencodeSession({
233
331
  const sendPartMessage = async (part: Part) => {
234
332
  const content = formatPart(part) + '\n\n'
235
333
  if (!content.trim() || content.length === 0) {
236
- discordLogger.log(`SKIP: Part ${part.id} has no content`)
334
+ // discordLogger.log(`SKIP: Part ${part.id} has no content`)
237
335
  return
238
336
  }
239
337
 
@@ -276,6 +374,7 @@ export async function handleOpencodeSession({
276
374
  assistantMessageId = msg.id
277
375
  usedModel = msg.modelID
278
376
  usedProviderID = msg.providerID
377
+ usedAgent = msg.mode
279
378
 
280
379
  if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
281
380
  if (!modelContextLimit) {
@@ -296,7 +395,7 @@ export async function handleOpencodeSession({
296
395
  const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
297
396
  if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
298
397
  lastDisplayedContextPercentage = thresholdCrossed
299
- await sendThreadMessage(thread, `◼︎ context usage ${currentPercentage}%`)
398
+ await sendThreadMessage(thread, `⬥ context usage ${currentPercentage}%`)
300
399
  }
301
400
  }
302
401
  }
@@ -372,7 +471,7 @@ export async function handleOpencodeSession({
372
471
  )
373
472
  }
374
473
  break
375
- } else if (event.type === 'permission.updated') {
474
+ } else if (event.type === 'permission.asked') {
376
475
  const permission = event.properties
377
476
  if (permission.sessionID !== session.id) {
378
477
  voiceLogger.log(
@@ -382,18 +481,15 @@ export async function handleOpencodeSession({
382
481
  }
383
482
 
384
483
  sessionLogger.log(
385
- `Permission requested: type=${permission.type}, title=${permission.title}`,
484
+ `Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
386
485
  )
387
486
 
388
- const patternStr = Array.isArray(permission.pattern)
389
- ? permission.pattern.join(', ')
390
- : permission.pattern || ''
487
+ const patternStr = permission.patterns.join(', ')
391
488
 
392
489
  const permissionMessage = await sendThreadMessage(
393
490
  thread,
394
491
  `⚠️ **Permission Required**\n\n` +
395
- `**Type:** \`${permission.type}\`\n` +
396
- `**Action:** ${permission.title}\n` +
492
+ `**Type:** \`${permission.permission}\`\n` +
397
493
  (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
398
494
  `\nUse \`/accept\` or \`/reject\` to respond.`,
399
495
  )
@@ -404,19 +500,40 @@ export async function handleOpencodeSession({
404
500
  directory,
405
501
  })
406
502
  } else if (event.type === 'permission.replied') {
407
- const { permissionID, response, sessionID } = event.properties
503
+ const { requestID, reply, sessionID } = event.properties
408
504
  if (sessionID !== session.id) {
409
505
  continue
410
506
  }
411
507
 
412
508
  sessionLogger.log(
413
- `Permission ${permissionID} replied with: ${response}`,
509
+ `Permission ${requestID} replied with: ${reply}`,
414
510
  )
415
511
 
416
512
  const pending = pendingPermissions.get(thread.id)
417
- if (pending && pending.permission.id === permissionID) {
513
+ if (pending && pending.permission.id === requestID) {
418
514
  pendingPermissions.delete(thread.id)
419
515
  }
516
+ } else if (event.type === 'question.asked') {
517
+ const questionRequest = event.properties
518
+
519
+ if (questionRequest.sessionID !== session.id) {
520
+ sessionLogger.log(
521
+ `[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`,
522
+ )
523
+ continue
524
+ }
525
+
526
+ sessionLogger.log(
527
+ `Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
528
+ )
529
+
530
+ await showAskUserQuestionDropdowns({
531
+ thread,
532
+ sessionId: session.id,
533
+ directory,
534
+ requestId: questionRequest.id,
535
+ input: { questions: questionRequest.questions },
536
+ })
420
537
  }
421
538
  }
422
539
  } catch (e) {
@@ -453,6 +570,7 @@ export async function handleOpencodeSession({
453
570
  )
454
571
  const attachCommand = port ? ` ⋅ ${session.id}` : ''
455
572
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
573
+ const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : ''
456
574
  let contextInfo = ''
457
575
 
458
576
  try {
@@ -467,8 +585,38 @@ export async function handleOpencodeSession({
467
585
  sessionLogger.error('Failed to fetch provider info for context percentage:', e)
468
586
  }
469
587
 
470
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`)
588
+ await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`, { flags: NOTIFY_MESSAGE_FLAGS })
471
589
  sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`)
590
+
591
+ // Process queued messages after completion
592
+ const queue = messageQueue.get(thread.id)
593
+ if (queue && queue.length > 0) {
594
+ const nextMessage = queue.shift()!
595
+ if (queue.length === 0) {
596
+ messageQueue.delete(thread.id)
597
+ }
598
+
599
+ sessionLogger.log(`[QUEUE] Processing queued message from ${nextMessage.username}`)
600
+
601
+ // Show that queued message is being sent
602
+ await sendThreadMessage(thread, `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`)
603
+
604
+ // Send the queued message as a new prompt (recursive call)
605
+ // Use setImmediate to avoid blocking and allow this finally to complete
606
+ setImmediate(() => {
607
+ handleOpencodeSession({
608
+ prompt: nextMessage.prompt,
609
+ thread,
610
+ projectDirectory,
611
+ images: nextMessage.images,
612
+ channelId,
613
+ }).catch(async (e) => {
614
+ sessionLogger.error(`[QUEUE] Failed to process queued message:`, e)
615
+ const errorMsg = e instanceof Error ? e.message : String(e)
616
+ await sendThreadMessage(thread, `✗ Queued message failed: ${errorMsg.slice(0, 200)}`)
617
+ })
618
+ })
619
+ }
472
620
  } else {
473
621
  sessionLogger.log(
474
622
  `Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
@@ -487,56 +635,65 @@ export async function handleOpencodeSession({
487
635
 
488
636
  stopTyping = startTyping()
489
637
 
490
- let response: { data?: unknown; error?: unknown; response: Response }
491
- if (parsedCommand?.isCommand) {
492
- sessionLogger.log(
493
- `[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`,
494
- )
495
- response = await getClient().session.command({
496
- path: { id: session.id },
497
- body: {
498
- command: parsedCommand.command,
499
- arguments: parsedCommand.arguments,
500
- },
501
- signal: abortController.signal,
502
- })
503
- } else {
504
- voiceLogger.log(
505
- `[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
506
- )
507
- if (images.length > 0) {
508
- sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
638
+ voiceLogger.log(
639
+ `[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
640
+ )
641
+ // append image paths to prompt so ai knows where they are on disk
642
+ const promptWithImagePaths = (() => {
643
+ if (images.length === 0) {
644
+ return prompt
509
645
  }
510
-
511
- const parts = [{ type: 'text' as const, text: prompt }, ...images]
512
- sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
513
-
514
- // Get model preference: session-level overrides channel-level
515
- const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
516
- const modelParam = (() => {
517
- if (!modelPreference) {
518
- return undefined
519
- }
520
- const [providerID, ...modelParts] = modelPreference.split('/')
521
- const modelID = modelParts.join('/')
522
- if (!providerID || !modelID) {
523
- return undefined
524
- }
525
- sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`)
526
- return { providerID, modelID }
527
- })()
528
-
529
- response = await getClient().session.prompt({
530
- path: { id: session.id },
531
- body: {
532
- parts,
533
- system: getOpencodeSystemMessage({ sessionId: session.id }),
534
- model: modelParam,
535
- },
536
- signal: abortController.signal,
537
- })
646
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
647
+ const imagePathsList = images.map((img) => `- ${img.filename}: ${img.url}`).join('\n')
648
+ return `${prompt}\n\n**attached images:**\n${imagePathsList}`
649
+ })()
650
+
651
+ const parts = [{ type: 'text' as const, text: promptWithImagePaths }, ...images]
652
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
653
+
654
+ // Get model preference: session-level overrides channel-level
655
+ const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
656
+ const modelParam = (() => {
657
+ if (!modelPreference) {
658
+ return undefined
659
+ }
660
+ const [providerID, ...modelParts] = modelPreference.split('/')
661
+ const modelID = modelParts.join('/')
662
+ if (!providerID || !modelID) {
663
+ return undefined
664
+ }
665
+ sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`)
666
+ return { providerID, modelID }
667
+ })()
668
+
669
+ // Get agent preference: session-level overrides channel-level
670
+ const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
671
+ if (agentPreference) {
672
+ sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
538
673
  }
539
674
 
675
+ // Use session.command API for slash commands, session.prompt for regular messages
676
+ const response = command
677
+ ? await getClient().session.command({
678
+ path: { id: session.id },
679
+ body: {
680
+ command: command.name,
681
+ arguments: command.arguments,
682
+ agent: agentPreference,
683
+ },
684
+ signal: abortController.signal,
685
+ })
686
+ : await getClient().session.prompt({
687
+ path: { id: session.id },
688
+ body: {
689
+ parts,
690
+ system: getOpencodeSystemMessage({ sessionId: session.id }),
691
+ model: modelParam,
692
+ agent: agentPreference,
693
+ },
694
+ signal: abortController.signal,
695
+ })
696
+
540
697
  if (response.error) {
541
698
  const errorMessage = (() => {
542
699
  const err = response.error