kimaki 0.4.78 → 0.4.80

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 (90) hide show
  1. package/dist/anthropic-auth-plugin.js +628 -0
  2. package/dist/channel-management.js +2 -2
  3. package/dist/cli.js +316 -129
  4. package/dist/commands/action-buttons.js +1 -1
  5. package/dist/commands/login.js +634 -277
  6. package/dist/commands/model.js +91 -6
  7. package/dist/commands/paginated-select.js +57 -0
  8. package/dist/commands/resume.js +2 -2
  9. package/dist/commands/tasks.js +205 -0
  10. package/dist/commands/undo-redo.js +80 -18
  11. package/dist/context-awareness-plugin.js +347 -0
  12. package/dist/database.js +103 -7
  13. package/dist/db.js +39 -1
  14. package/dist/discord-bot.js +42 -19
  15. package/dist/discord-urls.js +11 -0
  16. package/dist/discord-ws-proxy.js +350 -0
  17. package/dist/discord-ws-proxy.test.js +500 -0
  18. package/dist/errors.js +1 -1
  19. package/dist/gateway-session.js +163 -0
  20. package/dist/hrana-server.js +114 -4
  21. package/dist/interaction-handler.js +30 -7
  22. package/dist/ipc-tools-plugin.js +186 -0
  23. package/dist/message-preprocessing.js +56 -11
  24. package/dist/onboarding-welcome.js +1 -1
  25. package/dist/opencode-interrupt-plugin.js +133 -75
  26. package/dist/opencode-plugin.js +12 -389
  27. package/dist/opencode.js +59 -5
  28. package/dist/parse-permission-rules.test.js +117 -0
  29. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  30. package/dist/session-handler/thread-session-runtime.js +68 -29
  31. package/dist/startup-time.e2e.test.js +295 -0
  32. package/dist/store.js +1 -0
  33. package/dist/system-message.js +3 -1
  34. package/dist/task-runner.js +7 -3
  35. package/dist/task-schedule.js +12 -0
  36. package/dist/thread-message-queue.e2e.test.js +13 -1
  37. package/dist/undo-redo.e2e.test.js +166 -0
  38. package/dist/utils.js +4 -1
  39. package/dist/voice-attachment.js +34 -0
  40. package/dist/voice-handler.js +11 -9
  41. package/dist/voice-message.e2e.test.js +78 -0
  42. package/dist/voice.test.js +31 -0
  43. package/package.json +12 -7
  44. package/skills/egaki/SKILL.md +80 -15
  45. package/skills/errore/SKILL.md +13 -0
  46. package/skills/lintcn/SKILL.md +749 -0
  47. package/skills/npm-package/SKILL.md +17 -3
  48. package/skills/spiceflow/SKILL.md +14 -0
  49. package/skills/zele/SKILL.md +9 -0
  50. package/src/anthropic-auth-plugin.ts +732 -0
  51. package/src/channel-management.ts +2 -2
  52. package/src/cli.ts +354 -132
  53. package/src/commands/action-buttons.ts +1 -0
  54. package/src/commands/login.ts +836 -337
  55. package/src/commands/model.ts +102 -7
  56. package/src/commands/paginated-select.ts +81 -0
  57. package/src/commands/resume.ts +6 -1
  58. package/src/commands/tasks.ts +293 -0
  59. package/src/commands/undo-redo.ts +87 -20
  60. package/src/context-awareness-plugin.ts +469 -0
  61. package/src/database.ts +138 -7
  62. package/src/db.ts +40 -1
  63. package/src/discord-bot.ts +46 -19
  64. package/src/discord-urls.ts +12 -0
  65. package/src/errors.ts +1 -1
  66. package/src/hrana-server.ts +124 -3
  67. package/src/interaction-handler.ts +41 -9
  68. package/src/ipc-tools-plugin.ts +228 -0
  69. package/src/message-preprocessing.ts +82 -11
  70. package/src/onboarding-welcome.ts +1 -1
  71. package/src/opencode-interrupt-plugin.ts +164 -91
  72. package/src/opencode-plugin.ts +13 -483
  73. package/src/opencode.ts +60 -5
  74. package/src/parse-permission-rules.test.ts +127 -0
  75. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  76. package/src/session-handler/thread-runtime-state.ts +4 -1
  77. package/src/session-handler/thread-session-runtime.ts +82 -20
  78. package/src/startup-time.e2e.test.ts +372 -0
  79. package/src/store.ts +8 -0
  80. package/src/system-message.ts +10 -1
  81. package/src/task-runner.ts +9 -22
  82. package/src/task-schedule.ts +15 -0
  83. package/src/thread-message-queue.e2e.test.ts +14 -1
  84. package/src/undo-redo.e2e.test.ts +207 -0
  85. package/src/utils.ts +7 -0
  86. package/src/voice-attachment.ts +51 -0
  87. package/src/voice-handler.ts +15 -7
  88. package/src/voice-message.e2e.test.ts +95 -0
  89. package/src/voice.test.ts +36 -0
  90. package/src/onboarding-tutorial-plugin.ts +0 -93
@@ -32,17 +32,87 @@
32
32
  import fs from 'node:fs'
33
33
  import http from 'node:http'
34
34
  import path from 'node:path'
35
+ import crypto from 'node:crypto'
35
36
  import Database from 'libsql'
36
37
  import * as errore from 'errore'
37
38
  import { createLogger, LogPrefix } from './logger.js'
38
39
  import { ServerStartError, FetchError } from './errors.js'
39
40
  import { getLockPort } from './config.js'
41
+ import { store } from './store.js'
40
42
 
41
43
  const hranaLogger = createLogger(LogPrefix.DB)
42
44
 
43
45
  let db: Database.Database | null = null
44
46
  let server: http.Server | null = null
45
47
  let hranaUrl: string | null = null
48
+ let discordGatewayReady = false
49
+ let readyWaiters: Array<() => void> = []
50
+
51
+ export function markDiscordGatewayReady(): void {
52
+ if (discordGatewayReady) {
53
+ return
54
+ }
55
+ discordGatewayReady = true
56
+ for (const resolve of readyWaiters) {
57
+ resolve()
58
+ }
59
+ readyWaiters = []
60
+ }
61
+
62
+ async function waitForDiscordGatewayReady({ timeoutMs }: { timeoutMs: number }): Promise<boolean> {
63
+ if (discordGatewayReady) {
64
+ return true
65
+ }
66
+ const readyPromise = new Promise<boolean>((resolve) => {
67
+ readyWaiters.push(() => {
68
+ resolve(true)
69
+ })
70
+ })
71
+ const timeoutPromise = new Promise<boolean>((resolve) => {
72
+ setTimeout(() => {
73
+ resolve(false)
74
+ }, timeoutMs)
75
+ })
76
+ return Promise.race([readyPromise, timeoutPromise])
77
+ }
78
+
79
+ function getRequestAuthToken(req: http.IncomingMessage): string | null {
80
+ const authorizationHeader = req.headers.authorization
81
+ if (typeof authorizationHeader === 'string' && authorizationHeader.startsWith('Bearer ')) {
82
+ return authorizationHeader.slice('Bearer '.length)
83
+ }
84
+
85
+ return null
86
+ }
87
+
88
+ // Timing-safe comparison to prevent timing attacks when the hrana server
89
+ // is internet-facing (bindAll=true / KIMAKI_INTERNET_REACHABLE_URL set).
90
+ function isAuthorizedRequest(req: http.IncomingMessage): boolean {
91
+ const expectedToken = store.getState().gatewayToken
92
+ if (!expectedToken) {
93
+ return false
94
+ }
95
+ const providedToken = getRequestAuthToken(req)
96
+ if (!providedToken) {
97
+ return false
98
+ }
99
+ const expectedBuf = Buffer.from(expectedToken, 'utf8')
100
+ const providedBuf = Buffer.from(providedToken, 'utf8')
101
+ if (expectedBuf.length !== providedBuf.length) {
102
+ return false
103
+ }
104
+ return crypto.timingSafeEqual(expectedBuf, providedBuf)
105
+ }
106
+
107
+ function ensureServiceAuthTokenInStore(): string {
108
+ const existingToken = store.getState().gatewayToken
109
+ if (existingToken) {
110
+ return existingToken
111
+ }
112
+ const generatedToken = `${crypto.randomUUID()}:${crypto.randomBytes(32).toString('hex')}`
113
+ store.setState({ gatewayToken: generatedToken })
114
+ return generatedToken
115
+ }
46
116
 
47
117
  /**
48
118
  * Get the Hrana HTTP URL for injecting into plugin child processes.
@@ -61,18 +131,24 @@ export function getHranaUrl(): string | null {
61
131
  */
62
132
  export async function startHranaServer({
63
133
  dbPath,
134
+ bindAll = false,
64
135
  }: {
65
136
  dbPath: string
137
+ /** Bind to 0.0.0.0 instead of 127.0.0.1. Set when KIMAKI_INTERNET_REACHABLE_URL is defined. */
138
+ bindAll?: boolean
66
139
  }) {
67
140
  if (server && db && hranaUrl) return hranaUrl
68
141
 
69
142
  const port = getLockPort()
143
+ const bindHost = bindAll ? '0.0.0.0' : '127.0.0.1'
144
+ const serviceAuthToken = ensureServiceAuthTokenInStore()
145
+ process.env.KIMAKI_DB_AUTH_TOKEN = serviceAuthToken
70
146
 
71
147
  fs.mkdirSync(path.dirname(dbPath), { recursive: true })
72
148
  await evictExistingInstance({ port })
73
149
 
74
150
  hranaLogger.log(
75
- `Starting hrana server on 127.0.0.1:${port} with db: ${dbPath}`,
151
+ `Starting hrana server on ${bindHost}:${port} with db: ${dbPath}`,
76
152
  )
77
153
 
78
154
  const database = new Database(dbPath)
@@ -80,10 +156,53 @@ export async function startHranaServer({
80
156
  database.exec('PRAGMA busy_timeout = 5000')
81
157
  db = database
82
158
 
83
- const handler = createHranaHandler(database)
159
+ const hranaHandler = createHranaHandler(database)
160
+
161
+ // Combined handler: all control/data routes require the same service auth token.
162
+ const handler: http.RequestListener = async (req, res) => {
163
+ const pathname = new URL(req.url || '/', 'http://localhost').pathname
164
+ if (pathname === '/kimaki/wake') {
165
+ if (req.method !== 'POST') {
166
+ res.writeHead(405, { 'content-type': 'application/json' })
167
+ res.end(JSON.stringify({ error: 'method_not_allowed' }))
168
+ return
169
+ }
170
+ if (!isAuthorizedRequest(req)) {
171
+ res.writeHead(401, { 'content-type': 'application/json' })
172
+ res.end(JSON.stringify({ error: 'unauthorized' }))
173
+ return
174
+ }
175
+ const isReady = await waitForDiscordGatewayReady({ timeoutMs: 30_000 })
176
+ if (!isReady) {
177
+ res.writeHead(504, { 'content-type': 'application/json' })
178
+ res.end(JSON.stringify({ ready: false, error: 'timeout_waiting_for_discord_ready' }))
179
+ return
180
+ }
181
+ res.writeHead(200, { 'content-type': 'application/json' })
182
+ res.end(JSON.stringify({ ready: true }))
183
+ return
184
+ }
185
+ // Hrana routes: /health, /v2, /v2/pipeline
186
+ if (pathname === '/health') {
187
+ hranaHandler(req, res)
188
+ return
189
+ }
190
+ if (pathname === '/v2' || pathname === '/v2/pipeline') {
191
+ if (!isAuthorizedRequest(req)) {
192
+ res.writeHead(401, { 'content-type': 'application/json' })
193
+ res.end(JSON.stringify({ error: 'unauthorized' }))
194
+ return
195
+ }
196
+ hranaHandler(req, res)
197
+ return
198
+ }
199
+ res.writeHead(404)
200
+ res.end()
201
+ }
84
202
 
85
203
  const started = await new Promise<ServerStartError | true>((resolve) => {
86
204
  const srv = http.createServer(handler)
205
+
87
206
  srv.on('error', (err: NodeJS.ErrnoException) => {
88
207
  resolve(
89
208
  new ServerStartError({
@@ -95,7 +214,7 @@ export async function startHranaServer({
95
214
  }),
96
215
  )
97
216
  })
98
- srv.listen(port, '127.0.0.1', () => {
217
+ srv.listen(port, bindHost, () => {
99
218
  server = srv
100
219
  resolve(true)
101
220
  })
@@ -129,6 +248,8 @@ export async function stopHranaServer() {
129
248
  db = null
130
249
  }
131
250
  hranaUrl = null
251
+ discordGatewayReady = false
252
+ readyWaiters = []
132
253
  hranaLogger.log('Hrana server stopped')
133
254
  }
134
255
 
@@ -22,6 +22,7 @@ import {
22
22
  } from './commands/merge-worktree.js'
23
23
  import { handleToggleWorktreesCommand } from './commands/worktree-settings.js'
24
24
  import { handleWorktreesCommand } from './commands/worktrees.js'
25
+ import { handleTasksCommand } from './commands/tasks.js'
25
26
  import { handleToggleMentionModeCommand } from './commands/mention-mode.js'
26
27
  import {
27
28
  handleResumeCommand,
@@ -51,8 +52,12 @@ import {
51
52
  import { handleUnsetModelCommand } from './commands/unset-model.js'
52
53
  import {
53
54
  handleLoginCommand,
54
- handleLoginProviderSelectMenu,
55
- handleLoginMethodSelectMenu,
55
+ handleLoginSelect,
56
+ handleLoginTextButton,
57
+ handleLoginTextModalSubmit,
58
+ handleLoginApiKeyButton,
59
+ handleOAuthCodeButton,
60
+ handleOAuthCodeModalSubmit,
56
61
  handleApiKeyModalSubmit,
57
62
  } from './commands/login.js'
58
63
  import {
@@ -205,6 +210,13 @@ export function registerInteractionHandler({
205
210
  })
206
211
  return
207
212
 
213
+ case 'tasks':
214
+ await handleTasksCommand({
215
+ command: interaction,
216
+ appId,
217
+ })
218
+ return
219
+
208
220
  case 'toggle-mention-mode':
209
221
  await handleToggleMentionModeCommand({
210
222
  command: interaction,
@@ -396,6 +408,21 @@ export function registerInteractionHandler({
396
408
  return
397
409
  }
398
410
 
411
+ if (customId.startsWith('login_text_btn:')) {
412
+ await handleLoginTextButton(interaction)
413
+ return
414
+ }
415
+
416
+ if (customId.startsWith('login_apikey_btn:')) {
417
+ await handleLoginApiKeyButton(interaction)
418
+ return
419
+ }
420
+
421
+ if (customId.startsWith('login_oauth_code_btn:')) {
422
+ await handleOAuthCodeButton(interaction)
423
+ return
424
+ }
425
+
399
426
  if (customId.startsWith('action_button:')) {
400
427
  await handleActionButton(interaction)
401
428
  return
@@ -475,13 +502,8 @@ export function registerInteractionHandler({
475
502
  return
476
503
  }
477
504
 
478
- if (customId.startsWith('login_provider:')) {
479
- await handleLoginProviderSelectMenu(interaction)
480
- return
481
- }
482
-
483
- if (customId.startsWith('login_method:')) {
484
- await handleLoginMethodSelectMenu(interaction)
505
+ if (customId.startsWith('login_select:')) {
506
+ await handleLoginSelect(interaction)
485
507
  return
486
508
  }
487
509
  return
@@ -503,6 +525,16 @@ export function registerInteractionHandler({
503
525
  return
504
526
  }
505
527
 
528
+ if (customId.startsWith('login_text:')) {
529
+ await handleLoginTextModalSubmit(interaction)
530
+ return
531
+ }
532
+
533
+ if (customId.startsWith('login_oauth_code:')) {
534
+ await handleOAuthCodeModalSubmit(interaction)
535
+ return
536
+ }
537
+
506
538
  if (customId.startsWith('transcription_apikey_modal:')) {
507
539
  await handleTranscriptionApiKeyModalSubmit(interaction)
508
540
  return
@@ -0,0 +1,228 @@
1
+ // OpenCode plugin that provides IPC-based tools for Discord interaction:
2
+ // - kimaki_file_upload: prompts the Discord user to upload files via native picker
3
+ // - kimaki_action_buttons: shows clickable action buttons in the Discord thread
4
+ //
5
+ // Tools communicate with the bot process via IPC rows in SQLite (the plugin
6
+ // runs inside the OpenCode server process, not the bot process).
7
+ //
8
+ // Exported from opencode-plugin.ts — each export is treated as a separate
9
+ // plugin by OpenCode's plugin loader.
10
+
11
+ import type { Plugin } from '@opencode-ai/plugin'
12
+ import type { ToolContext } from '@opencode-ai/plugin/tool'
13
+ import dedent from 'string-dedent'
14
+ import { z } from 'zod'
15
+ import { getPrisma, createIpcRequest, getIpcRequestById } from './database.js'
16
+ import { setDataDir } from './config.js'
17
+ import { createLogger, LogPrefix, setLogFilePath } from './logger.js'
18
+ import { initSentry } from './sentry.js'
19
+
20
+ // Inlined from '@opencode-ai/plugin/tool' because the subpath value import
21
+ // fails at runtime in global npm installs (#35). Opencode loads this plugin
22
+ // file in its own process and resolves modules from kimaki's install dir,
23
+ // but the '/tool' subpath export isn't found by opencode's module resolver.
24
+ // The type-only imports above are fine (erased at compile time).
25
+ //
26
+ // NOTE: @opencode-ai/plugin bundles its own zod 4.1.x as a hard dependency
27
+ // while goke (used by cli.ts) requires zod 4.3.x. This version skew makes
28
+ // the Plugin return type structurally incompatible with our local tool()
29
+ // even though runtime behavior is identical. ipcToolsPlugin is cast to
30
+ // Plugin via unknown to bypass this purely type-level incompatibility.
31
+ function tool<Args extends z.ZodRawShape>(input: {
32
+ description: string
33
+ args: Args
34
+ execute(
35
+ args: z.infer<z.ZodObject<Args>>,
36
+ context: ToolContext,
37
+ ): Promise<string>
38
+ }) {
39
+ return input
40
+ }
41
+
42
+ const logger = createLogger(LogPrefix.OPENCODE)
43
+
44
+ const FILE_UPLOAD_TIMEOUT_MS = 6 * 60 * 1000
45
+ const DEFAULT_FILE_UPLOAD_MAX_FILES = 5
46
+ const ACTION_BUTTON_TIMEOUT_MS = 30 * 1000
47
+
48
+ // @opencode-ai/plugin bundles zod 4.1.x as a hard dep; our code uses 4.3.x
49
+ // (required by goke for ~standard.jsonSchema). The Plugin return type is
50
+ // structurally incompatible due to _zod.version.minor skew even though
51
+ // runtime behavior is identical. `any` bypasses the type-level mismatch —
52
+ // opencode's plugin loader doesn't care about the zod version at runtime.
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ const ipcToolsPlugin: any = async () => {
55
+ initSentry()
56
+
57
+ const dataDir = process.env.KIMAKI_DATA_DIR
58
+ if (dataDir) {
59
+ setDataDir(dataDir)
60
+ setLogFilePath(dataDir)
61
+ }
62
+
63
+ return {
64
+ tool: {
65
+ kimaki_file_upload: tool({
66
+ description:
67
+ 'Prompt the Discord user to upload files using a native file picker modal. ' +
68
+ 'The user sees a button, clicks it, and gets a file upload dialog. ' +
69
+ 'Returns the local file paths of downloaded files in the project directory. ' +
70
+ 'Use this when you need the user to provide files (images, documents, configs, etc.). ' +
71
+ 'IMPORTANT: Always call this tool last in your message, after all text parts.',
72
+ args: {
73
+ prompt: z
74
+ .string()
75
+ .describe(
76
+ 'Message shown to the user explaining what files to upload',
77
+ ),
78
+ maxFiles: z
79
+ .number()
80
+ .min(1)
81
+ .max(10)
82
+ .optional()
83
+ .describe(
84
+ 'Maximum number of files the user can upload (1-10, default 5)',
85
+ ),
86
+ },
87
+ async execute({ prompt, maxFiles }, context) {
88
+ const prisma = await getPrisma()
89
+ const row = await prisma.thread_sessions.findFirst({
90
+ where: { session_id: context.sessionID },
91
+ select: { thread_id: true },
92
+ })
93
+
94
+ if (!row?.thread_id) {
95
+ return 'Could not find thread for current session'
96
+ }
97
+
98
+ const ipcRow = await createIpcRequest({
99
+ type: 'file_upload',
100
+ sessionId: context.sessionID,
101
+ threadId: row.thread_id,
102
+ payload: JSON.stringify({
103
+ prompt,
104
+ maxFiles: maxFiles || DEFAULT_FILE_UPLOAD_MAX_FILES,
105
+ directory: context.directory,
106
+ }),
107
+ })
108
+
109
+ const deadline = Date.now() + FILE_UPLOAD_TIMEOUT_MS
110
+ const POLL_INTERVAL_MS = 300
111
+ while (Date.now() < deadline) {
112
+ await new Promise((resolve) => {
113
+ setTimeout(resolve, POLL_INTERVAL_MS)
114
+ })
115
+ const updated = await getIpcRequestById({ id: ipcRow.id })
116
+ if (!updated || updated.status === 'cancelled') {
117
+ return 'File upload was cancelled'
118
+ }
119
+ if (updated.response) {
120
+ const parsed = JSON.parse(updated.response) as {
121
+ filePaths?: string[]
122
+ error?: string
123
+ }
124
+ if (parsed.error) {
125
+ return `File upload failed: ${parsed.error}`
126
+ }
127
+ const filePaths = parsed.filePaths || []
128
+ if (filePaths.length === 0) {
129
+ return 'No files were uploaded (user may have cancelled or sent a new message)'
130
+ }
131
+ return `Files uploaded successfully:\n${filePaths.join('\n')}`
132
+ }
133
+ }
134
+
135
+ return 'File upload timed out - user did not upload files within the time limit'
136
+ },
137
+ }),
138
+ kimaki_action_buttons: tool({
139
+ description: dedent`
140
+ Show action buttons in the current Discord thread for quick confirmations.
141
+ Use this when the user can respond by clicking one of up to 3 buttons.
142
+ Prefer a single button whenever possible.
143
+ Default color is white (same visual style as permission deny button).
144
+ If you need more than 3 options, use the question tool instead.
145
+ IMPORTANT: Always call this tool last in your message, after all text parts.
146
+
147
+ Examples:
148
+ - buttons: [{"label":"Yes, proceed"}]
149
+ - buttons: [{"label":"Approve","color":"green"}]
150
+ - buttons: [
151
+ {"label":"Confirm","color":"blue"},
152
+ {"label":"Cancel","color":"white"}
153
+ ]
154
+ `,
155
+ args: {
156
+ buttons: z
157
+ .array(
158
+ z.object({
159
+ label: z
160
+ .string()
161
+ .min(1)
162
+ .max(80)
163
+ .describe('Button label shown to the user (1-80 chars)'),
164
+ color: z
165
+ .enum(['white', 'blue', 'green', 'red'])
166
+ .optional()
167
+ .describe(
168
+ 'Optional button color. white is default and preferred for most confirmations.',
169
+ ),
170
+ }),
171
+ )
172
+ .min(1)
173
+ .max(3)
174
+ .describe(
175
+ 'Array of 1-3 action buttons. Prefer one button whenever possible.',
176
+ ),
177
+ },
178
+ async execute({ buttons }, context) {
179
+ const prisma = await getPrisma()
180
+ const row = await prisma.thread_sessions.findFirst({
181
+ where: { session_id: context.sessionID },
182
+ select: { thread_id: true },
183
+ })
184
+
185
+ if (!row?.thread_id) {
186
+ return 'Could not find thread for current session'
187
+ }
188
+
189
+ const ipcRow = await createIpcRequest({
190
+ type: 'action_buttons',
191
+ sessionId: context.sessionID,
192
+ threadId: row.thread_id,
193
+ payload: JSON.stringify({
194
+ buttons,
195
+ directory: context.directory,
196
+ }),
197
+ })
198
+
199
+ const deadline = Date.now() + ACTION_BUTTON_TIMEOUT_MS
200
+ const POLL_INTERVAL_MS = 200
201
+ while (Date.now() < deadline) {
202
+ await new Promise((resolve) => {
203
+ setTimeout(resolve, POLL_INTERVAL_MS)
204
+ })
205
+ const updated = await getIpcRequestById({ id: ipcRow.id })
206
+ if (!updated || updated.status === 'cancelled') {
207
+ return 'Action button request was cancelled'
208
+ }
209
+ if (updated.response) {
210
+ const parsed = JSON.parse(updated.response) as {
211
+ ok?: boolean
212
+ error?: string
213
+ }
214
+ if (parsed.error) {
215
+ return `Action button request failed: ${parsed.error}`
216
+ }
217
+ return `Action button(s) shown: ${buttons.map((button) => button.label).join(', ')}`
218
+ }
219
+ }
220
+
221
+ return 'Action button request timed out'
222
+ },
223
+ }),
224
+ },
225
+ }
226
+ }
227
+
228
+ export { ipcToolsPlugin }
@@ -16,6 +16,7 @@ import {
16
16
  getTextAttachments,
17
17
  } from './message-formatting.js'
18
18
  import { processVoiceAttachment } from './voice-handler.js'
19
+ import { isVoiceAttachment } from './voice-attachment.js'
19
20
  import { initializeOpencodeForDirectory } from './opencode.js'
20
21
  import { getCompactSessionContext, getLastSessionId } from './markdown.js'
21
22
  import { getThreadSession } from './database.js'
@@ -42,6 +43,37 @@ function extractQueueSuffix(prompt: string): { prompt: string; forceQueue: boole
42
43
  return { prompt: prompt.replace(QUEUE_SUFFIX_RE, '').trimEnd(), forceQueue: true }
43
44
  }
44
45
 
46
+ function shouldSkipEmptyPrompt({
47
+ message,
48
+ prompt,
49
+ images,
50
+ hasVoiceAttachment,
51
+ }: {
52
+ message: Message
53
+ prompt: string
54
+ images?: DiscordFileAttachment[]
55
+ hasVoiceAttachment: boolean
56
+ }): boolean {
57
+ if (prompt.trim()) {
58
+ return false
59
+ }
60
+ if ((images?.length || 0) > 0) {
61
+ return false
62
+ }
63
+
64
+ const inferredVoiceAttachment = message.attachments.some((attachment) => {
65
+ return isVoiceAttachment(attachment)
66
+ })
67
+ if (!hasVoiceAttachment && !inferredVoiceAttachment && message.attachments.size === 0) {
68
+ return false
69
+ }
70
+
71
+ voiceLogger.warn(
72
+ `[INGRESS] Skipping empty prompt after preprocessing attachments=${message.attachments.size} hasVoiceAttachment=${hasVoiceAttachment} inferredVoiceAttachment=${inferredVoiceAttachment}`,
73
+ )
74
+ return true
75
+ }
76
+
45
77
  /**
46
78
  * Pre-process a message in an existing thread (thread already has a session or
47
79
  * needs a new one). Handles voice transcription, text/file attachments, and
@@ -156,15 +188,30 @@ export async function preprocessExistingThreadMessage({
156
188
  return { prompt: '', mode: 'opencode', skip: true }
157
189
  }
158
190
 
191
+ // Extract queue suffix from raw message content BEFORE appending text
192
+ // attachments. Otherwise a text file attachment pushes "? queue" away from
193
+ // the end of the string and the regex fails to match.
194
+ const qs = extractQueueSuffix(messageContent)
195
+
159
196
  const fileAttachments = await getFileAttachments(message)
160
197
  const textAttachmentsContent = await getTextAttachments(message)
161
- const promptWithAttachments = textAttachmentsContent
162
- ? `${messageContent}\n\n${textAttachmentsContent}`
163
- : messageContent
198
+ const prompt = textAttachmentsContent
199
+ ? `${qs.prompt}\n\n${textAttachmentsContent}`
200
+ : qs.prompt
201
+
202
+ if (
203
+ shouldSkipEmptyPrompt({
204
+ message,
205
+ prompt,
206
+ images: fileAttachments,
207
+ hasVoiceAttachment,
208
+ })
209
+ ) {
210
+ return { prompt: '', mode: 'opencode', skip: true }
211
+ }
164
212
 
165
- const qs = extractQueueSuffix(promptWithAttachments)
166
213
  return {
167
- prompt: qs.prompt,
214
+ prompt,
168
215
  images: fileAttachments.length > 0 ? fileAttachments : undefined,
169
216
  mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
170
217
  }
@@ -212,7 +259,7 @@ export async function preprocessNewSessionMessage({
212
259
  .catch((error) => {
213
260
  logger.warn(
214
261
  `[SESSION] Failed to fetch starter message for thread ${thread.id}:`,
215
- error instanceof Error ? error.message : String(error),
262
+ error instanceof Error ? error.stack : String(error),
216
263
  )
217
264
  return null
218
265
  })
@@ -228,6 +275,16 @@ export async function preprocessNewSessionMessage({
228
275
  }
229
276
 
230
277
  const qs = extractQueueSuffix(prompt)
278
+ if (
279
+ shouldSkipEmptyPrompt({
280
+ message,
281
+ prompt: qs.prompt,
282
+ hasVoiceAttachment,
283
+ })
284
+ ) {
285
+ return { prompt: '', mode: 'opencode', skip: true }
286
+ }
287
+
231
288
  return {
232
289
  prompt: qs.prompt,
233
290
  mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
@@ -268,15 +325,29 @@ export async function preprocessNewThreadMessage({
268
325
  return { prompt: '', mode: 'opencode', skip: true }
269
326
  }
270
327
 
328
+ // Extract queue suffix from raw message content BEFORE appending text
329
+ // attachments (same fix as preprocessExistingThreadMessage).
330
+ const qs = extractQueueSuffix(messageContent)
331
+
271
332
  const fileAttachments = await getFileAttachments(message)
272
333
  const textAttachmentsContent = await getTextAttachments(message)
273
- const promptWithAttachments = textAttachmentsContent
274
- ? `${messageContent}\n\n${textAttachmentsContent}`
275
- : messageContent
334
+ const prompt = textAttachmentsContent
335
+ ? `${qs.prompt}\n\n${textAttachmentsContent}`
336
+ : qs.prompt
337
+
338
+ if (
339
+ shouldSkipEmptyPrompt({
340
+ message,
341
+ prompt,
342
+ images: fileAttachments,
343
+ hasVoiceAttachment,
344
+ })
345
+ ) {
346
+ return { prompt: '', mode: 'opencode', skip: true }
347
+ }
276
348
 
277
- const qs = extractQueueSuffix(promptWithAttachments)
278
349
  return {
279
- prompt: qs.prompt,
350
+ prompt,
280
351
  images: fileAttachments.length > 0 ? fileAttachments : undefined,
281
352
  mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
282
353
  }
@@ -43,7 +43,7 @@ export async function sendWelcomeMessage({
43
43
  logger.log(`Sent welcome message with thread to #${channel.name}`)
44
44
  } catch (error) {
45
45
  logger.warn(
46
- `Failed to send welcome message to #${channel.name}: ${error instanceof Error ? error.message : String(error)}`,
46
+ `Failed to send welcome message to #${channel.name}: ${error instanceof Error ? error.stack : String(error)}`,
47
47
  )
48
48
  }
49
49
  }