shuvmaki 0.4.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/bin.js +70 -0
  2. package/dist/ai-tool-to-genai.js +210 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/channel-management.js +97 -0
  5. package/dist/cli.js +709 -0
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +98 -0
  8. package/dist/commands/agent.js +152 -0
  9. package/dist/commands/ask-question.js +183 -0
  10. package/dist/commands/create-new-project.js +78 -0
  11. package/dist/commands/fork.js +186 -0
  12. package/dist/commands/model.js +313 -0
  13. package/dist/commands/permissions.js +126 -0
  14. package/dist/commands/queue.js +129 -0
  15. package/dist/commands/resume.js +145 -0
  16. package/dist/commands/session.js +142 -0
  17. package/dist/commands/share.js +80 -0
  18. package/dist/commands/types.js +2 -0
  19. package/dist/commands/undo-redo.js +161 -0
  20. package/dist/commands/user-command.js +145 -0
  21. package/dist/database.js +184 -0
  22. package/dist/discord-bot.js +384 -0
  23. package/dist/discord-utils.js +217 -0
  24. package/dist/escape-backticks.test.js +410 -0
  25. package/dist/format-tables.js +96 -0
  26. package/dist/format-tables.test.js +418 -0
  27. package/dist/genai-worker-wrapper.js +109 -0
  28. package/dist/genai-worker.js +297 -0
  29. package/dist/genai.js +232 -0
  30. package/dist/interaction-handler.js +144 -0
  31. package/dist/logger.js +51 -0
  32. package/dist/markdown.js +310 -0
  33. package/dist/markdown.test.js +262 -0
  34. package/dist/message-formatting.js +273 -0
  35. package/dist/message-formatting.test.js +73 -0
  36. package/dist/openai-realtime.js +228 -0
  37. package/dist/opencode.js +216 -0
  38. package/dist/session-handler.js +580 -0
  39. package/dist/system-message.js +61 -0
  40. package/dist/tools.js +356 -0
  41. package/dist/utils.js +85 -0
  42. package/dist/voice-handler.js +541 -0
  43. package/dist/voice.js +314 -0
  44. package/dist/worker-types.js +4 -0
  45. package/dist/xml.js +92 -0
  46. package/dist/xml.test.js +32 -0
  47. package/package.json +60 -0
  48. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  49. package/src/__snapshots__/compact-session-context.md +47 -0
  50. package/src/ai-tool-to-genai.test.ts +296 -0
  51. package/src/ai-tool-to-genai.ts +255 -0
  52. package/src/channel-management.ts +161 -0
  53. package/src/cli.ts +1010 -0
  54. package/src/commands/abort.ts +94 -0
  55. package/src/commands/add-project.ts +139 -0
  56. package/src/commands/agent.ts +201 -0
  57. package/src/commands/ask-question.ts +276 -0
  58. package/src/commands/create-new-project.ts +111 -0
  59. package/src/commands/fork.ts +257 -0
  60. package/src/commands/model.ts +402 -0
  61. package/src/commands/permissions.ts +146 -0
  62. package/src/commands/queue.ts +181 -0
  63. package/src/commands/resume.ts +230 -0
  64. package/src/commands/session.ts +184 -0
  65. package/src/commands/share.ts +96 -0
  66. package/src/commands/types.ts +25 -0
  67. package/src/commands/undo-redo.ts +213 -0
  68. package/src/commands/user-command.ts +178 -0
  69. package/src/database.ts +220 -0
  70. package/src/discord-bot.ts +513 -0
  71. package/src/discord-utils.ts +282 -0
  72. package/src/escape-backticks.test.ts +447 -0
  73. package/src/format-tables.test.ts +440 -0
  74. package/src/format-tables.ts +110 -0
  75. package/src/genai-worker-wrapper.ts +160 -0
  76. package/src/genai-worker.ts +366 -0
  77. package/src/genai.ts +321 -0
  78. package/src/interaction-handler.ts +187 -0
  79. package/src/logger.ts +57 -0
  80. package/src/markdown.test.ts +358 -0
  81. package/src/markdown.ts +365 -0
  82. package/src/message-formatting.test.ts +81 -0
  83. package/src/message-formatting.ts +340 -0
  84. package/src/openai-realtime.ts +363 -0
  85. package/src/opencode.ts +277 -0
  86. package/src/session-handler.ts +758 -0
  87. package/src/system-message.ts +62 -0
  88. package/src/tools.ts +428 -0
  89. package/src/utils.ts +118 -0
  90. package/src/voice-handler.ts +760 -0
  91. package/src/voice.ts +432 -0
  92. package/src/worker-types.ts +66 -0
  93. package/src/xml.test.ts +37 -0
  94. package/src/xml.ts +121 -0
@@ -0,0 +1,94 @@
1
+ // /abort command - Abort the current OpenCode request in this thread.
2
+
3
+ import { ChannelType, type ThreadChannel } from 'discord.js'
4
+ import type { CommandContext } from './types.js'
5
+ import { getDatabase } from '../database.js'
6
+ import { initializeOpencodeForDirectory } from '../opencode.js'
7
+ import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
8
+ import { abortControllers } from '../session-handler.js'
9
+ import { createLogger } from '../logger.js'
10
+
11
+ const logger = createLogger('ABORT')
12
+
13
+ export async function handleAbortCommand({
14
+ command,
15
+ }: CommandContext): Promise<void> {
16
+ const channel = command.channel
17
+
18
+ if (!channel) {
19
+ await command.reply({
20
+ content: 'This command can only be used in a channel',
21
+ ephemeral: true,
22
+ flags: SILENT_MESSAGE_FLAGS,
23
+ })
24
+ return
25
+ }
26
+
27
+ const isThread = [
28
+ ChannelType.PublicThread,
29
+ ChannelType.PrivateThread,
30
+ ChannelType.AnnouncementThread,
31
+ ].includes(channel.type)
32
+
33
+ if (!isThread) {
34
+ await command.reply({
35
+ content: 'This command can only be used in a thread with an active session',
36
+ ephemeral: true,
37
+ flags: SILENT_MESSAGE_FLAGS,
38
+ })
39
+ return
40
+ }
41
+
42
+ const textChannel = await resolveTextChannel(channel as ThreadChannel)
43
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel)
44
+
45
+ if (!directory) {
46
+ await command.reply({
47
+ content: 'Could not determine project directory for this channel',
48
+ ephemeral: true,
49
+ flags: SILENT_MESSAGE_FLAGS,
50
+ })
51
+ return
52
+ }
53
+
54
+ const row = getDatabase()
55
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
56
+ .get(channel.id) as { session_id: string } | undefined
57
+
58
+ if (!row?.session_id) {
59
+ await command.reply({
60
+ content: 'No active session in this thread',
61
+ ephemeral: true,
62
+ flags: SILENT_MESSAGE_FLAGS,
63
+ })
64
+ return
65
+ }
66
+
67
+ const sessionId = row.session_id
68
+
69
+ try {
70
+ const existingController = abortControllers.get(sessionId)
71
+ if (existingController) {
72
+ existingController.abort(new Error('User requested abort'))
73
+ abortControllers.delete(sessionId)
74
+ }
75
+
76
+ const getClient = await initializeOpencodeForDirectory(directory)
77
+ await getClient().session.abort({
78
+ path: { id: sessionId },
79
+ })
80
+
81
+ await command.reply({
82
+ content: `🛑 Request **aborted**`,
83
+ flags: SILENT_MESSAGE_FLAGS,
84
+ })
85
+ logger.log(`Session ${sessionId} aborted by user`)
86
+ } catch (error) {
87
+ logger.error('[ABORT] Error:', error)
88
+ await command.reply({
89
+ content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
90
+ ephemeral: true,
91
+ flags: SILENT_MESSAGE_FLAGS,
92
+ })
93
+ }
94
+ }
@@ -0,0 +1,139 @@
1
+ // /add-project command - Create Discord channels for an existing OpenCode project.
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import type { CommandContext, AutocompleteContext } from './types.js'
6
+ import { getDatabase } from '../database.js'
7
+ import { initializeOpencodeForDirectory } from '../opencode.js'
8
+ import { createProjectChannels } from '../channel-management.js'
9
+ import { createLogger } from '../logger.js'
10
+
11
+ const logger = createLogger('ADD-PROJECT')
12
+
13
+ export async function handleAddProjectCommand({
14
+ command,
15
+ appId,
16
+ }: CommandContext): Promise<void> {
17
+ await command.deferReply({ ephemeral: false })
18
+
19
+ const projectId = command.options.getString('project', true)
20
+ const guild = command.guild
21
+
22
+ if (!guild) {
23
+ await command.editReply('This command can only be used in a guild')
24
+ return
25
+ }
26
+
27
+ try {
28
+ const currentDir = process.cwd()
29
+ const getClient = await initializeOpencodeForDirectory(currentDir)
30
+
31
+ const projectsResponse = await getClient().project.list({})
32
+ if (!projectsResponse.data) {
33
+ await command.editReply('Failed to fetch projects')
34
+ return
35
+ }
36
+
37
+ const project = projectsResponse.data.find((p) => p.id === projectId)
38
+
39
+ if (!project) {
40
+ await command.editReply('Project not found')
41
+ return
42
+ }
43
+
44
+ const directory = project.worktree
45
+
46
+ if (!fs.existsSync(directory)) {
47
+ await command.editReply(`Directory does not exist: ${directory}`)
48
+ return
49
+ }
50
+
51
+ const db = getDatabase()
52
+ const existingChannel = db
53
+ .prepare(
54
+ 'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?',
55
+ )
56
+ .get(directory, 'text') as { channel_id: string } | undefined
57
+
58
+ if (existingChannel) {
59
+ await command.editReply(
60
+ `A channel already exists for this directory: <#${existingChannel.channel_id}>`,
61
+ )
62
+ return
63
+ }
64
+
65
+ const { textChannelId, voiceChannelId, channelName } =
66
+ await createProjectChannels({
67
+ guild,
68
+ projectDirectory: directory,
69
+ appId,
70
+ botName: command.client.user?.username,
71
+ })
72
+
73
+ await command.editReply(
74
+ `✅ Created channels for project:\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n📁 Directory: \`${directory}\``,
75
+ )
76
+
77
+ logger.log(`Created channels for project ${channelName} at ${directory}`)
78
+ } catch (error) {
79
+ logger.error('[ADD-PROJECT] Error:', error)
80
+ await command.editReply(
81
+ `Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
82
+ )
83
+ }
84
+ }
85
+
86
+ export async function handleAddProjectAutocomplete({
87
+ interaction,
88
+ appId,
89
+ }: AutocompleteContext): Promise<void> {
90
+ const focusedValue = interaction.options.getFocused()
91
+
92
+ try {
93
+ const currentDir = process.cwd()
94
+ const getClient = await initializeOpencodeForDirectory(currentDir)
95
+
96
+ const projectsResponse = await getClient().project.list({})
97
+ if (!projectsResponse.data) {
98
+ await interaction.respond([])
99
+ return
100
+ }
101
+
102
+ const db = getDatabase()
103
+ const existingDirs = db
104
+ .prepare(
105
+ 'SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?',
106
+ )
107
+ .all('text') as { directory: string }[]
108
+ const existingDirSet = new Set(existingDirs.map((row) => row.directory))
109
+
110
+ const availableProjects = projectsResponse.data.filter(
111
+ (project) => !existingDirSet.has(project.worktree),
112
+ )
113
+
114
+ const projects = availableProjects
115
+ .filter((project) => {
116
+ const baseName = path.basename(project.worktree)
117
+ const searchText = `${baseName} ${project.worktree}`.toLowerCase()
118
+ return searchText.includes(focusedValue.toLowerCase())
119
+ })
120
+ .sort((a, b) => {
121
+ const aTime = a.time.initialized || a.time.created
122
+ const bTime = b.time.initialized || b.time.created
123
+ return bTime - aTime
124
+ })
125
+ .slice(0, 25)
126
+ .map((project) => {
127
+ const name = `${path.basename(project.worktree)} (${project.worktree})`
128
+ return {
129
+ name: name.length > 100 ? name.slice(0, 99) + '…' : name,
130
+ value: project.id,
131
+ }
132
+ })
133
+
134
+ await interaction.respond(projects)
135
+ } catch (error) {
136
+ logger.error('[AUTOCOMPLETE] Error fetching projects:', error)
137
+ await interaction.respond([])
138
+ }
139
+ }
@@ -0,0 +1,201 @@
1
+ // /agent command - Set the preferred agent for this channel or session.
2
+
3
+ import {
4
+ ChatInputCommandInteraction,
5
+ StringSelectMenuInteraction,
6
+ StringSelectMenuBuilder,
7
+ ActionRowBuilder,
8
+ ChannelType,
9
+ type ThreadChannel,
10
+ type TextChannel,
11
+ } from 'discord.js'
12
+ import crypto from 'node:crypto'
13
+ import { getDatabase, setChannelAgent, setSessionAgent, runModelMigrations } from '../database.js'
14
+ import { initializeOpencodeForDirectory } from '../opencode.js'
15
+ import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
16
+ import { createLogger } from '../logger.js'
17
+
18
+ const agentLogger = createLogger('AGENT')
19
+
20
+ const pendingAgentContexts = new Map<string, {
21
+ dir: string
22
+ channelId: string
23
+ sessionId?: string
24
+ isThread: boolean
25
+ }>()
26
+
27
+ export async function handleAgentCommand({
28
+ interaction,
29
+ appId,
30
+ }: {
31
+ interaction: ChatInputCommandInteraction
32
+ appId: string
33
+ }): Promise<void> {
34
+ await interaction.deferReply({ ephemeral: true })
35
+
36
+ runModelMigrations()
37
+
38
+ const channel = interaction.channel
39
+
40
+ if (!channel) {
41
+ await interaction.editReply({ content: 'This command can only be used in a channel' })
42
+ return
43
+ }
44
+
45
+ const isThread = [
46
+ ChannelType.PublicThread,
47
+ ChannelType.PrivateThread,
48
+ ChannelType.AnnouncementThread,
49
+ ].includes(channel.type)
50
+
51
+ let projectDirectory: string | undefined
52
+ let channelAppId: string | undefined
53
+ let targetChannelId: string
54
+ let sessionId: string | undefined
55
+
56
+ if (isThread) {
57
+ const thread = channel as ThreadChannel
58
+ const textChannel = await resolveTextChannel(thread)
59
+ const metadata = getKimakiMetadata(textChannel)
60
+ projectDirectory = metadata.projectDirectory
61
+ channelAppId = metadata.channelAppId
62
+ targetChannelId = textChannel?.id || channel.id
63
+
64
+ const row = getDatabase()
65
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
66
+ .get(thread.id) as { session_id: string } | undefined
67
+ sessionId = row?.session_id
68
+ } else if (channel.type === ChannelType.GuildText) {
69
+ const textChannel = channel as TextChannel
70
+ const metadata = getKimakiMetadata(textChannel)
71
+ projectDirectory = metadata.projectDirectory
72
+ channelAppId = metadata.channelAppId
73
+ targetChannelId = channel.id
74
+ } else {
75
+ await interaction.editReply({ content: 'This command can only be used in text channels or threads' })
76
+ return
77
+ }
78
+
79
+ if (channelAppId && channelAppId !== appId) {
80
+ await interaction.editReply({ content: 'This channel is not configured for this bot' })
81
+ return
82
+ }
83
+
84
+ if (!projectDirectory) {
85
+ await interaction.editReply({ content: 'This channel is not configured with a project directory' })
86
+ return
87
+ }
88
+
89
+ try {
90
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
91
+
92
+ const agentsResponse = await getClient().app.agents({
93
+ query: { directory: projectDirectory },
94
+ })
95
+
96
+ if (!agentsResponse.data || agentsResponse.data.length === 0) {
97
+ await interaction.editReply({ content: 'No agents available' })
98
+ return
99
+ }
100
+
101
+ const agents = agentsResponse.data
102
+ .filter((a) => a.mode === 'primary' || a.mode === 'all')
103
+ .slice(0, 25)
104
+
105
+ if (agents.length === 0) {
106
+ await interaction.editReply({ content: 'No primary agents available' })
107
+ return
108
+ }
109
+
110
+ const contextHash = crypto.randomBytes(8).toString('hex')
111
+ pendingAgentContexts.set(contextHash, {
112
+ dir: projectDirectory,
113
+ channelId: targetChannelId,
114
+ sessionId,
115
+ isThread,
116
+ })
117
+
118
+ const options = agents.map((agent) => ({
119
+ label: agent.name.slice(0, 100),
120
+ value: agent.name,
121
+ description: (agent.description || `${agent.mode} agent`).slice(0, 100),
122
+ }))
123
+
124
+ const selectMenu = new StringSelectMenuBuilder()
125
+ .setCustomId(`agent_select:${contextHash}`)
126
+ .setPlaceholder('Select an agent')
127
+ .addOptions(options)
128
+
129
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
130
+
131
+ await interaction.editReply({
132
+ content: '**Set Agent Preference**\nSelect an agent:',
133
+ components: [actionRow],
134
+ })
135
+ } catch (error) {
136
+ agentLogger.error('Error loading agents:', error)
137
+ await interaction.editReply({
138
+ content: `Failed to load agents: ${error instanceof Error ? error.message : 'Unknown error'}`,
139
+ })
140
+ }
141
+ }
142
+
143
+ export async function handleAgentSelectMenu(
144
+ interaction: StringSelectMenuInteraction
145
+ ): Promise<void> {
146
+ const customId = interaction.customId
147
+
148
+ if (!customId.startsWith('agent_select:')) {
149
+ return
150
+ }
151
+
152
+ await interaction.deferUpdate()
153
+
154
+ const contextHash = customId.replace('agent_select:', '')
155
+ const context = pendingAgentContexts.get(contextHash)
156
+
157
+ if (!context) {
158
+ await interaction.editReply({
159
+ content: 'Selection expired. Please run /agent again.',
160
+ components: [],
161
+ })
162
+ return
163
+ }
164
+
165
+ const selectedAgent = interaction.values[0]
166
+ if (!selectedAgent) {
167
+ await interaction.editReply({
168
+ content: 'No agent selected',
169
+ components: [],
170
+ })
171
+ return
172
+ }
173
+
174
+ try {
175
+ if (context.isThread && context.sessionId) {
176
+ setSessionAgent(context.sessionId, selectedAgent)
177
+ agentLogger.log(`Set agent ${selectedAgent} for session ${context.sessionId}`)
178
+
179
+ await interaction.editReply({
180
+ content: `Agent preference set for this session: **${selectedAgent}**`,
181
+ components: [],
182
+ })
183
+ } else {
184
+ setChannelAgent(context.channelId, selectedAgent)
185
+ agentLogger.log(`Set agent ${selectedAgent} for channel ${context.channelId}`)
186
+
187
+ await interaction.editReply({
188
+ content: `Agent preference set for this channel: **${selectedAgent}**\n\nAll new sessions in this channel will use this agent.`,
189
+ components: [],
190
+ })
191
+ }
192
+
193
+ pendingAgentContexts.delete(contextHash)
194
+ } catch (error) {
195
+ agentLogger.error('Error saving agent preference:', error)
196
+ await interaction.editReply({
197
+ content: `Failed to save agent preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
198
+ components: [],
199
+ })
200
+ }
201
+ }
@@ -0,0 +1,276 @@
1
+ // AskUserQuestion tool handler - Shows Discord dropdowns for AI questions.
2
+ // When the AI uses the AskUserQuestion tool, this module renders dropdowns
3
+ // for each question and collects user responses.
4
+
5
+ import {
6
+ StringSelectMenuBuilder,
7
+ StringSelectMenuInteraction,
8
+ ActionRowBuilder,
9
+ type ThreadChannel,
10
+ } from 'discord.js'
11
+ import crypto from 'node:crypto'
12
+ import { sendThreadMessage } from '../discord-utils.js'
13
+ import { getOpencodeServerPort } from '../opencode.js'
14
+ import { createLogger } from '../logger.js'
15
+
16
+ const logger = createLogger('ASK_QUESTION')
17
+
18
+ // Schema matching the question tool input
19
+ export type AskUserQuestionInput = {
20
+ questions: Array<{
21
+ question: string
22
+ header: string // max 12 chars
23
+ options: Array<{
24
+ label: string
25
+ description: string
26
+ }>
27
+ multiple?: boolean // optional, defaults to false
28
+ }>
29
+ }
30
+
31
+ type PendingQuestionContext = {
32
+ sessionId: string
33
+ directory: string
34
+ thread: ThreadChannel
35
+ requestId: string // OpenCode question request ID for replying
36
+ questions: AskUserQuestionInput['questions']
37
+ answers: Record<number, string[]> // questionIndex -> selected labels
38
+ totalQuestions: number
39
+ answeredCount: number
40
+ contextHash: string
41
+ }
42
+
43
+ // Store pending question contexts by hash
44
+ export const pendingQuestionContexts = new Map<string, PendingQuestionContext>()
45
+
46
+ /**
47
+ * Show dropdown menus for question tool input.
48
+ * Sends one message per question with the dropdown directly under the question text.
49
+ */
50
+ export async function showAskUserQuestionDropdowns({
51
+ thread,
52
+ sessionId,
53
+ directory,
54
+ requestId,
55
+ input,
56
+ }: {
57
+ thread: ThreadChannel
58
+ sessionId: string
59
+ directory: string
60
+ requestId: string // OpenCode question request ID
61
+ input: AskUserQuestionInput
62
+ }): Promise<void> {
63
+ const contextHash = crypto.randomBytes(8).toString('hex')
64
+
65
+ const context: PendingQuestionContext = {
66
+ sessionId,
67
+ directory,
68
+ thread,
69
+ requestId,
70
+ questions: input.questions,
71
+ answers: {},
72
+ totalQuestions: input.questions.length,
73
+ answeredCount: 0,
74
+ contextHash,
75
+ }
76
+
77
+ pendingQuestionContexts.set(contextHash, context)
78
+
79
+ // Send one message per question with its dropdown directly underneath
80
+ for (let i = 0; i < input.questions.length; i++) {
81
+ const q = input.questions[i]!
82
+
83
+ // Map options to Discord select menu options
84
+ // Discord max: 25 options per select menu
85
+ const options = [
86
+ ...q.options.slice(0, 24).map((opt, optIdx) => ({
87
+ label: opt.label.slice(0, 100),
88
+ value: `${optIdx}`,
89
+ description: opt.description.slice(0, 100),
90
+ })),
91
+ {
92
+ label: 'Other',
93
+ value: 'other',
94
+ description: 'Provide a custom answer in chat',
95
+ },
96
+ ]
97
+
98
+ const selectMenu = new StringSelectMenuBuilder()
99
+ .setCustomId(`ask_question:${contextHash}:${i}`)
100
+ .setPlaceholder(`Select an option`)
101
+ .addOptions(options)
102
+
103
+ // Enable multi-select if the question supports it
104
+ if (q.multiple) {
105
+ selectMenu.setMinValues(1)
106
+ selectMenu.setMaxValues(options.length)
107
+ }
108
+
109
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
110
+
111
+ await thread.send({
112
+ content: `**${q.header}**\n${q.question}`,
113
+ components: [actionRow],
114
+ })
115
+ }
116
+
117
+ logger.log(`Showed ${input.questions.length} question dropdown(s) for session ${sessionId}`)
118
+ }
119
+
120
+ /**
121
+ * Handle dropdown selection for AskUserQuestion.
122
+ */
123
+ export async function handleAskQuestionSelectMenu(
124
+ interaction: StringSelectMenuInteraction
125
+ ): Promise<void> {
126
+ const customId = interaction.customId
127
+
128
+ if (!customId.startsWith('ask_question:')) {
129
+ return
130
+ }
131
+
132
+ const parts = customId.split(':')
133
+ const contextHash = parts[1]
134
+ const questionIndex = parseInt(parts[2]!, 10)
135
+
136
+ if (!contextHash) {
137
+ await interaction.reply({
138
+ content: 'Invalid selection.',
139
+ ephemeral: true,
140
+ })
141
+ return
142
+ }
143
+
144
+ const context = pendingQuestionContexts.get(contextHash)
145
+
146
+ if (!context) {
147
+ await interaction.reply({
148
+ content: 'This question has expired. Please ask the AI again.',
149
+ ephemeral: true,
150
+ })
151
+ return
152
+ }
153
+
154
+ await interaction.deferUpdate()
155
+
156
+ const selectedValues = interaction.values
157
+ const question = context.questions[questionIndex]
158
+
159
+ if (!question) {
160
+ logger.error(`Question index ${questionIndex} not found in context`)
161
+ return
162
+ }
163
+
164
+ // Check if "other" was selected
165
+ if (selectedValues.includes('other')) {
166
+ // User wants to provide custom answer
167
+ // For now, mark as "Other" - they can type in chat
168
+ context.answers[questionIndex] = ['Other (please type your answer in chat)']
169
+ } else {
170
+ // Map value indices back to option labels
171
+ context.answers[questionIndex] = selectedValues.map((v) => {
172
+ const optIdx = parseInt(v, 10)
173
+ return question.options[optIdx]?.label || `Option ${optIdx + 1}`
174
+ })
175
+ }
176
+
177
+ context.answeredCount++
178
+
179
+ // Update this question's message: show answer and remove dropdown
180
+ const answeredText = context.answers[questionIndex]!.join(', ')
181
+ await interaction.editReply({
182
+ content: `**${question.header}**\n${question.question}\n✓ _${answeredText}_`,
183
+ components: [], // Remove the dropdown
184
+ })
185
+
186
+ // Check if all questions are answered
187
+ if (context.answeredCount >= context.totalQuestions) {
188
+ // All questions answered - send result back to session
189
+ await submitQuestionAnswers(context)
190
+ pendingQuestionContexts.delete(contextHash)
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Submit all collected answers back to the OpenCode session.
196
+ * Uses the question.reply API to provide answers to the waiting tool.
197
+ */
198
+ async function submitQuestionAnswers(
199
+ context: PendingQuestionContext
200
+ ): Promise<void> {
201
+ try {
202
+ // Build answers array: each element is an array of selected labels for that question
203
+ const answersPayload = context.questions.map((_, i) => {
204
+ return context.answers[i] || []
205
+ })
206
+
207
+ // Reply to the question using direct HTTP call to OpenCode API
208
+ // (v1 SDK doesn't have question.reply, so we call it directly)
209
+ const port = getOpencodeServerPort(context.directory)
210
+ if (!port) {
211
+ throw new Error('OpenCode server not found for directory')
212
+ }
213
+
214
+ const response = await fetch(
215
+ `http://127.0.0.1:${port}/question/${context.requestId}/reply`,
216
+ {
217
+ method: 'POST',
218
+ headers: { 'Content-Type': 'application/json' },
219
+ body: JSON.stringify({ answers: answersPayload }),
220
+ }
221
+ )
222
+
223
+ if (!response.ok) {
224
+ const text = await response.text()
225
+ throw new Error(`Failed to reply to question: ${response.status} ${text}`)
226
+ }
227
+
228
+ logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`)
229
+ } catch (error) {
230
+ logger.error('Failed to submit answers:', error)
231
+ await sendThreadMessage(
232
+ context.thread,
233
+ `✗ Failed to submit answers: ${error instanceof Error ? error.message : 'Unknown error'}`
234
+ )
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Check if a tool part is an AskUserQuestion tool.
240
+ * Returns the parsed input if valid, null otherwise.
241
+ */
242
+ export function parseAskUserQuestionTool(part: {
243
+ type: string
244
+ tool?: string
245
+ state?: { input?: unknown }
246
+ }): AskUserQuestionInput | null {
247
+ if (part.type !== 'tool') {
248
+ return null
249
+ }
250
+
251
+ // Check for the tool name (case-insensitive)
252
+ const toolName = part.tool?.toLowerCase()
253
+ if (toolName !== 'question') {
254
+ return null
255
+ }
256
+
257
+ const input = part.state?.input as AskUserQuestionInput | undefined
258
+
259
+ if (!input?.questions || !Array.isArray(input.questions) || input.questions.length === 0) {
260
+ return null
261
+ }
262
+
263
+ // Validate structure
264
+ for (const q of input.questions) {
265
+ if (
266
+ typeof q.question !== 'string' ||
267
+ typeof q.header !== 'string' ||
268
+ !Array.isArray(q.options) ||
269
+ q.options.length < 2
270
+ ) {
271
+ return null
272
+ }
273
+ }
274
+
275
+ return input
276
+ }