kimaki 0.28.0 → 0.30.0

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 (179) hide show
  1. package/dist/agent-model.e2e.test.js +33 -33
  2. package/dist/anthropic-account-identity.js +1 -1
  3. package/dist/anthropic-auth-plugin.js +5 -5
  4. package/dist/anthropic-auth-plugin.test.js +30 -2
  5. package/dist/anthropic-auth-state.js +1 -1
  6. package/dist/bash-tool-schema-plugin.js +18 -0
  7. package/dist/bash-tool-schema-plugin.test.js +20 -1
  8. package/dist/bin.js +7 -3
  9. package/dist/bot-lifecycle.test.js +206 -0
  10. package/dist/cache-drift-plugin.js +25 -18
  11. package/dist/cache-rewrite.js +39 -0
  12. package/dist/cache-rewrite.test.js +41 -0
  13. package/dist/channel-management.js +113 -51
  14. package/dist/cli-commands/misc.js +4 -1
  15. package/dist/cli-commands/project.js +7 -4
  16. package/dist/cli-commands/session.js +118 -36
  17. package/dist/cli-commands/thread.js +63 -0
  18. package/dist/cli-parsing.test.js +3 -3
  19. package/dist/cli-runner.js +11 -1
  20. package/dist/cli-send-thread.e2e.test.js +2 -2
  21. package/dist/cli.js +6 -4
  22. package/dist/commands/ask-question.js +72 -23
  23. package/dist/commands/queue.js +84 -36
  24. package/dist/commands/queue.test.js +15 -0
  25. package/dist/commands/restart-opencode-server.js +7 -1
  26. package/dist/commands/session-id.js +5 -5
  27. package/dist/database.js +149 -6
  28. package/dist/db.js +34 -0
  29. package/dist/db.test.js +199 -1
  30. package/dist/default-channel-provisioning.test.js +2 -1
  31. package/dist/discord-bot.js +220 -146
  32. package/dist/discord-command-registration.js +1 -1
  33. package/dist/external-directory-permissions.e2e.test.js +3 -3
  34. package/dist/external-directory-project-deny.e2e.test.js +1 -1
  35. package/dist/forum-sync/watchers.js +15 -0
  36. package/dist/gateway-proxy.e2e.test.js +9 -9
  37. package/dist/guild-categories.test.js +225 -0
  38. package/dist/hrana-server.js +68 -14
  39. package/dist/interaction-handler.js +5 -1
  40. package/dist/kimaki-opencode-plugin.js +2 -0
  41. package/dist/kimaki-opencode-plugin.test.js +28 -0
  42. package/dist/list-channel-threads.js +163 -0
  43. package/dist/list-channel-threads.test.js +80 -0
  44. package/dist/markdown.js +164 -102
  45. package/dist/markdown.test.js +88 -20
  46. package/dist/message-formatting.js +29 -18
  47. package/dist/message-formatting.test.js +111 -3
  48. package/dist/opencode.js +27 -12
  49. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  50. package/dist/question-abort-resume.e2e.test.js +100 -0
  51. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  52. package/dist/question-text-before-dropdown.e2e.test.js +1 -1
  53. package/dist/queue-advanced-abort.e2e.test.js +3 -3
  54. package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
  55. package/dist/queue-advanced-e2e-setup.js +63 -0
  56. package/dist/queue-advanced-footer.e2e.test.js +18 -18
  57. package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
  58. package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
  59. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  60. package/dist/queue-delete-message.e2e.test.js +4 -4
  61. package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
  62. package/dist/queue-interrupt-drain.e2e.test.js +10 -10
  63. package/dist/queue-question-select-drain.e2e.test.js +5 -5
  64. package/dist/runtime-lifecycle.e2e.test.js +95 -11
  65. package/dist/schema.js +21 -0
  66. package/dist/session-handler/event-stream-state.js +240 -6
  67. package/dist/session-handler/event-stream-state.test.js +1116 -130
  68. package/dist/session-handler/thread-runtime-state.js +4 -0
  69. package/dist/session-handler/thread-session-runtime.js +499 -89
  70. package/dist/sleep.e2e.test.js +7 -7
  71. package/dist/store.js +1 -1
  72. package/dist/system-message.js +64 -23
  73. package/dist/system-message.test.js +69 -23
  74. package/dist/task-id-plugin.js +16 -0
  75. package/dist/task-runner.js +7 -0
  76. package/dist/task-schedule.test.js +1 -1
  77. package/dist/test-utils.js +5 -3
  78. package/dist/thread-message-queue.e2e.test.js +30 -31
  79. package/dist/undo-redo.e2e.test.js +3 -3
  80. package/dist/voice-message.e2e.test.js +64 -26
  81. package/dist/voice-routing-input.e2e.test.js +4 -4
  82. package/dist/voice-routing-question.e2e.test.js +4 -4
  83. package/dist/voice.js +107 -21
  84. package/dist/voice.test.js +52 -1
  85. package/dist/wait-session.js +24 -2
  86. package/dist/worktree-lifecycle.e2e.test.js +8 -8
  87. package/package.json +7 -7
  88. package/skills/egaki/SKILL.md +14 -0
  89. package/skills/holocron/SKILL.md +125 -42
  90. package/skills/opensrc/SKILL.md +20 -3
  91. package/skills/spiceflow/SKILL.md +53 -172
  92. package/skills/strada/SKILL.md +61 -232
  93. package/src/agent-model.e2e.test.ts +33 -33
  94. package/src/anthropic-account-identity.ts +1 -1
  95. package/src/anthropic-auth-plugin.test.ts +37 -2
  96. package/src/anthropic-auth-plugin.ts +9 -7
  97. package/src/anthropic-auth-state.ts +1 -0
  98. package/src/bash-tool-schema-plugin.test.ts +29 -0
  99. package/src/bash-tool-schema-plugin.ts +31 -0
  100. package/src/bin.ts +7 -3
  101. package/src/bot-lifecycle.test.ts +252 -0
  102. package/src/cache-drift-plugin.ts +34 -24
  103. package/src/cache-rewrite.test.ts +49 -0
  104. package/src/cache-rewrite.ts +96 -0
  105. package/src/channel-management.ts +144 -65
  106. package/src/cli-commands/misc.ts +4 -1
  107. package/src/cli-commands/project.ts +9 -4
  108. package/src/cli-commands/session.ts +167 -38
  109. package/src/cli-commands/thread.ts +86 -0
  110. package/src/cli-parsing.test.ts +3 -3
  111. package/src/cli-runner.ts +11 -0
  112. package/src/cli-send-thread.e2e.test.ts +2 -2
  113. package/src/cli.ts +8 -6
  114. package/src/commands/ask-question.ts +88 -25
  115. package/src/commands/queue.test.ts +19 -0
  116. package/src/commands/queue.ts +111 -45
  117. package/src/commands/restart-opencode-server.ts +12 -1
  118. package/src/commands/session-id.ts +5 -5
  119. package/src/database.ts +219 -6
  120. package/src/db.test.ts +236 -0
  121. package/src/db.ts +38 -0
  122. package/src/default-channel-provisioning.test.ts +2 -1
  123. package/src/discord-bot.ts +272 -185
  124. package/src/discord-command-registration.ts +3 -1
  125. package/src/external-directory-permissions.e2e.test.ts +3 -3
  126. package/src/external-directory-project-deny.e2e.test.ts +1 -1
  127. package/src/forum-sync/watchers.ts +20 -0
  128. package/src/gateway-proxy.e2e.test.ts +9 -9
  129. package/src/guild-categories.test.ts +260 -0
  130. package/src/hrana-server.ts +96 -16
  131. package/src/interaction-handler.ts +7 -0
  132. package/src/kimaki-opencode-plugin.test.ts +34 -0
  133. package/src/kimaki-opencode-plugin.ts +2 -0
  134. package/src/list-channel-threads.test.ts +90 -0
  135. package/src/list-channel-threads.ts +252 -0
  136. package/src/markdown.test.ts +108 -22
  137. package/src/markdown.ts +216 -103
  138. package/src/message-formatting.test.ts +129 -2
  139. package/src/message-formatting.ts +28 -14
  140. package/src/opencode.ts +28 -13
  141. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  142. package/src/question-abort-resume.e2e.test.ts +121 -0
  143. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  144. package/src/question-text-before-dropdown.e2e.test.ts +1 -1
  145. package/src/queue-advanced-abort.e2e.test.ts +3 -3
  146. package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
  147. package/src/queue-advanced-e2e-setup.ts +65 -0
  148. package/src/queue-advanced-footer.e2e.test.ts +18 -18
  149. package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
  150. package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
  151. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  152. package/src/queue-delete-message.e2e.test.ts +4 -4
  153. package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
  154. package/src/queue-interrupt-drain.e2e.test.ts +10 -10
  155. package/src/queue-question-select-drain.e2e.test.ts +5 -5
  156. package/src/runtime-lifecycle.e2e.test.ts +104 -10
  157. package/src/schema.sql +16 -0
  158. package/src/schema.ts +23 -0
  159. package/src/session-handler/event-stream-state.test.ts +1184 -133
  160. package/src/session-handler/event-stream-state.ts +322 -8
  161. package/src/session-handler/thread-runtime-state.ts +5 -0
  162. package/src/session-handler/thread-session-runtime.ts +597 -95
  163. package/src/sleep.e2e.test.ts +7 -7
  164. package/src/store.ts +2 -2
  165. package/src/system-message.test.ts +70 -23
  166. package/src/system-message.ts +67 -22
  167. package/src/task-id-plugin.ts +17 -0
  168. package/src/task-runner.ts +8 -0
  169. package/src/task-schedule.test.ts +1 -1
  170. package/src/test-utils.ts +5 -3
  171. package/src/thread-message-queue.e2e.test.ts +30 -31
  172. package/src/undo-redo.e2e.test.ts +3 -3
  173. package/src/voice-message.e2e.test.ts +70 -26
  174. package/src/voice-routing-input.e2e.test.ts +4 -4
  175. package/src/voice-routing-question.e2e.test.ts +4 -4
  176. package/src/voice.test.ts +56 -0
  177. package/src/voice.ts +135 -25
  178. package/src/wait-session.ts +25 -2
  179. package/src/worktree-lifecycle.e2e.test.ts +8 -8
@@ -0,0 +1,86 @@
1
+ import { goke } from 'goke'
2
+ import { z } from 'zod'
3
+ import { createLogger, LogPrefix } from '../logger.js'
4
+ import { initDatabase } from '../discord-bot.js'
5
+ import { createDiscordRest } from '../discord-urls.js'
6
+ import { EXIT_NO_RESTART, resolveBotCredentials } from '../cli-runner.js'
7
+ import { listDiscordChannelThreads } from '../list-channel-threads.js'
8
+
9
+ const cliLogger = createLogger(LogPrefix.CLI)
10
+ const cli = goke()
11
+
12
+ cli
13
+ .command(
14
+ 'thread list',
15
+ 'List Discord threads in a channel. Use this to find a thread on another computer, then send with --thread.',
16
+ )
17
+ .option('-c, --channel <channelId>', 'Discord channel ID')
18
+ .option('--json', 'Output as JSON')
19
+ .option(
20
+ '--limit <n>',
21
+ z.number().default(50).describe('Maximum threads to show'),
22
+ )
23
+ .example('kimaki thread list --channel 123456789012345678')
24
+ .example('kimaki thread list --channel 123456789012345678 --json')
25
+ .action(async (options, { console, process }) => {
26
+ try {
27
+ if (!options.channel) {
28
+ cliLogger.error(
29
+ 'Channel ID is required. Use --channel <channelId>. Find remote channels with `kimaki project list --all --json`.',
30
+ )
31
+ process.exit(EXIT_NO_RESTART)
32
+ }
33
+
34
+ await initDatabase()
35
+ const { token: botToken } = await resolveBotCredentials({})
36
+ const rest = createDiscordRest(botToken)
37
+ const listed = await listDiscordChannelThreads({
38
+ rest,
39
+ channelId: options.channel,
40
+ })
41
+ if (!listed.ok) {
42
+ cliLogger.error(listed.error.message)
43
+ process.exit(EXIT_NO_RESTART)
44
+ return
45
+ }
46
+
47
+ const limited = listed.threads.slice(0, options.limit)
48
+ if (options.json) {
49
+ console.log(
50
+ JSON.stringify(
51
+ limited.map((thread) => ({
52
+ thread_id: thread.id,
53
+ name: thread.name,
54
+ channel_id: thread.parentId,
55
+ guild_id: thread.guildId,
56
+ archived: thread.archived,
57
+ archive_state: thread.archiveState,
58
+ last_message_id: thread.lastMessageId,
59
+ })),
60
+ null,
61
+ 2,
62
+ ),
63
+ )
64
+ process.exit(0)
65
+ }
66
+
67
+ if (limited.length === 0) {
68
+ console.log(`No threads found in channel ${options.channel}`)
69
+ process.exit(0)
70
+ }
71
+
72
+ for (const thread of limited) {
73
+ const archivedTag = thread.archived ? ' [archived]' : ''
74
+ console.log(`${thread.id} | ${thread.name}${archivedTag}`)
75
+ }
76
+ process.exit(0)
77
+ } catch (error) {
78
+ cliLogger.error(
79
+ 'Error:',
80
+ error instanceof Error ? error.stack : String(error),
81
+ )
82
+ process.exit(EXIT_NO_RESTART)
83
+ }
84
+ })
85
+
86
+ export default cli
@@ -58,7 +58,7 @@ async function parseRootBotOptions(argv: string[]) {
58
58
  const script = [
59
59
  "import { goke } from 'goke'",
60
60
  'const cli = goke(\'kimaki\')',
61
- "cli.command('', 'bot').option('--no-analytics', 'Disable analytics').option('--skip-footer-mentions', 'Do not mention the thread creator in final footers')",
61
+ "cli.command('', 'bot').option('--no-analytics', 'Disable analytics').option('--enable-footer-mentions', 'Mention the thread creator in final footers')",
62
62
  `const result = await cli.parse(${JSON.stringify(argv)}, { run: false })`,
63
63
  'process.stdout.write(JSON.stringify({ args: result.args, options: result.options }))',
64
64
  ].join(';')
@@ -291,14 +291,14 @@ describe('goke CLI ID parsing', () => {
291
291
  'node',
292
292
  'kimaki',
293
293
  '--no-analytics',
294
- '--skip-footer-mentions',
294
+ '--enable-footer-mentions',
295
295
  ])
296
296
 
297
297
  expect(result.options).toMatchInlineSnapshot(`
298
298
  {
299
299
  "--": [],
300
+ "enableFooterMentions": true,
300
301
  "noAnalytics": true,
301
- "skipFooterMentions": true,
302
302
  }
303
303
  `)
304
304
  })
package/src/cli-runner.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  initDatabase,
29
29
  getChannelDirectory,
30
30
  startDiscordBot,
31
+ registerBotLifecycleHandlers,
31
32
  initializeOpencodeForDirectory,
32
33
  assertCompatibleOpencodeVersion,
33
34
  createProjectChannels,
@@ -1638,6 +1639,9 @@ export async function run({
1638
1639
  cliLogger.error('Failed to start hrana server:', hranaResult.message)
1639
1640
  process.exit(EXIT_NO_RESTART)
1640
1641
  }
1642
+ // We own the lock port from here on. Stop signals must release it in every
1643
+ // startup phase (Discord login, channel reconcile), not only after ready.
1644
+ registerBotLifecycleHandlers()
1641
1645
 
1642
1646
  // Initialize database (connects to hrana server via HTTP)
1643
1647
  await initDatabase()
@@ -1803,6 +1807,10 @@ export async function run({
1803
1807
  }
1804
1808
  process.exit(EXIT_NO_RESTART)
1805
1809
  }
1810
+ // Stop signal during Discord login: shutdown owns the process now.
1811
+ if (global.shuttingDown) {
1812
+ return
1813
+ }
1806
1814
  await setBotToken(appId, token)
1807
1815
 
1808
1816
  // In gateway mode the bot only sees guilds the user has installed
@@ -1842,6 +1850,9 @@ export async function run({
1842
1850
  // Start bot immediately — channel sync happens in the background.
1843
1851
  cliLogger.log('Starting Discord bot...')
1844
1852
  await startDiscordBot({ token, appId, discordClient, useWorktrees })
1853
+ if (global.shuttingDown) {
1854
+ return
1855
+ }
1845
1856
  cliLogger.log('Discord bot is running!')
1846
1857
 
1847
1858
  // Background channel sync + role reconciliation + default channel creation.
@@ -410,9 +410,9 @@ describe('kimaki send --channel thread creation', () => {
410
410
  "--- from: user (cli-send-tester)
411
411
  thread empty content seed
412
412
  --- from: assistant (TestBot)
413
- > *using deterministic-provider/deterministic-v2*
413
+ -# *using deterministic-provider/deterministic-v2*
414
414
  > caught-by-model
415
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000830>
415
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
416
416
  --- from: user (cli-send-tester)
417
417
  --- from: assistant (TestBot)
418
418
  I can see you sent a message, but Discord did not include its text.
package/src/cli.ts CHANGED
@@ -25,6 +25,7 @@ import projectCommands from './cli-commands/project.js'
25
25
  import sendCommands from './cli-commands/send.js'
26
26
  import sessionCommands from './cli-commands/session.js'
27
27
  import taskCommands from './cli-commands/task.js'
28
+ import threadCommands from './cli-commands/thread.js'
28
29
  import userCommands from './cli-commands/user.js'
29
30
  import {
30
31
  EXIT_NO_RESTART,
@@ -75,8 +76,8 @@ cli
75
76
  'Disable automatic diff upload to critique.work in system prompts',
76
77
  )
77
78
  .option(
78
- '--skip-footer-mentions',
79
- 'Do not mention the thread creator in final session footers',
79
+ '--enable-footer-mentions',
80
+ 'Mention the thread creator in final session footers so they get a notification',
80
81
  )
81
82
  .option(
82
83
  '--auto-restart',
@@ -158,7 +159,7 @@ cli
158
159
  verbosity?: string
159
160
  mentionMode?: boolean
160
161
  noCritique?: boolean
161
- skipFooterMentions?: boolean
162
+ enableFooterMentions?: boolean
162
163
  allowAllUsers?: boolean
163
164
  restrictDirectories?: boolean
164
165
  permissionTimeoutMinutes?: string
@@ -314,7 +315,7 @@ cli
314
315
  }),
315
316
  ...(options.mentionMode && { defaultMentionMode: true }),
316
317
  ...(options.noCritique && { critiqueEnabled: false }),
317
- ...(options.skipFooterMentions && { footerMentionsEnabled: false }),
318
+ ...(options.enableFooterMentions && { footerMentionsEnabled: true }),
318
319
  ...(options.allowAllUsers && { allowAllUsers: true }),
319
320
  ...(options.restrictDirectories && { restrictExternalDirectories: true }),
320
321
  ...(permissionTimeoutMs !== undefined && { permissionTimeoutMs }),
@@ -365,9 +366,9 @@ cli
365
366
  'Critique disabled: diffs will not be auto-uploaded to critique.work',
366
367
  )
367
368
  }
368
- if (options.skipFooterMentions) {
369
+ if (options.enableFooterMentions) {
369
370
  cliLogger.log(
370
- 'Footer mentions disabled: final session footers will not mention thread creators',
371
+ 'Footer mentions enabled: final session footers will mention thread creators',
371
372
  )
372
373
  }
373
374
  if (options.noAutoUpgrade) {
@@ -423,6 +424,7 @@ cli.use(miscCommands)
423
424
  cli.use(sendCommands)
424
425
  cli.use(taskCommands)
425
426
  cli.use(projectCommands)
427
+ cli.use(threadCommands)
426
428
  cli.use(userCommands)
427
429
  cli.use(sessionCommands)
428
430
  cli.use(maintenanceCommands)
@@ -15,6 +15,7 @@ import { getOpencodeClient } from '../opencode.js'
15
15
  import { DiscordOperationError } from '../errors.js'
16
16
  import { createLogger, LogPrefix } from '../logger.js'
17
17
  import { QUEUE_PREFIX } from '../message-formatting.js'
18
+ import { getRuntime } from '../session-handler/thread-session-runtime.js'
18
19
 
19
20
  const logger = createLogger(LogPrefix.ASK_QUESTION)
20
21
 
@@ -298,51 +299,113 @@ export async function handleAskQuestionSelectMenu(
298
299
  `${QUEUE_PREFIX}**${username}:** ${answeredText}`,
299
300
  )
300
301
 
301
- // Check if all questions are answered
302
- if (areAllQuestionsAnswered(context)) {
303
- // All questions answered - send result back to session
304
- await submitQuestionAnswers(context)
302
+ // Check if all questions are answered. Claim the context synchronously
303
+ // (delete before awaiting submit) so two concurrent final selections for a
304
+ // multi-question tool cannot both submit and double-resume the session.
305
+ if (
306
+ areAllQuestionsAnswered(context)
307
+ && pendingQuestionContexts.get(contextHash) === context
308
+ ) {
305
309
  deletePendingQuestionContextsForRequest({
306
310
  threadId: context.thread.id,
307
311
  requestId: context.requestId,
308
312
  })
313
+ await submitQuestionAnswers(context)
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Format collected answers as a plain-text summary the model can read when
319
+ * the run is resumed after an abort (e.g. `"Which option?"="Alpha"`).
320
+ *
321
+ * Known limitation: if the user had queued items via /queue during the pending
322
+ * question AND aborted the run from another opencode client, the first queued
323
+ * item may have already been handed off to opencode and can lose its ordering
324
+ * on resume. That rare combination needs a queue-handoff redesign; the common
325
+ * (no-queue) abort case is handled correctly.
326
+ */
327
+ function formatQuestionAnswersText(context: PendingQuestionContext): string {
328
+ const parts = context.questions.map((q, i) => {
329
+ const answer = (context.answers[i] || []).join(', ') || 'Unanswered'
330
+ return `"${q.question}"="${answer}"`
331
+ })
332
+ return `Answers to your previous questions: ${parts.join(', ')}`
333
+ }
334
+
335
+ /** Resume the session by feeding the answers back as a new user prompt. */
336
+ async function resumeSessionWithAnswers(
337
+ context: PendingQuestionContext,
338
+ ): Promise<void> {
339
+ const runtime = getRuntime(context.thread.id)
340
+ const resumed = await runtime?.resumeWithText({
341
+ text: formatQuestionAnswersText(context),
342
+ })
343
+ if (!resumed) {
344
+ await sendThreadMessage(
345
+ context.thread,
346
+ '✗ Failed to submit answers: session is no longer active',
347
+ )
309
348
  }
310
349
  }
311
350
 
312
351
  /**
313
352
  * Submit all collected answers back to the OpenCode session.
314
- * Uses the question.reply API to provide answers to the waiting tool.
353
+ *
354
+ * The decision is based on whether the session still has a live run:
355
+ *
356
+ * - Busy: a live run is parked on the question. Reply so it continues.
357
+ * - Idle: the run was aborted (from this or another opencode client). Replying
358
+ * would resolve a dead run and the session would never continue, so resume it
359
+ * with the answers as a fresh prompt instead.
360
+ *
361
+ * Note: `question.list` is not a reliable signal here. After an abort the
362
+ * question request can stay in the pending list but orphaned (no run awaiting
363
+ * it), so a reply would succeed yet the session would still not continue. The
364
+ * session busy state, derived from the event stream, is the accurate signal.
365
+ * The reply `.error` check is a safety net for the case where the request was
366
+ * actually removed while the session still looked busy.
315
367
  */
316
368
  async function submitQuestionAnswers(
317
369
  context: PendingQuestionContext,
318
370
  ): Promise<void> {
319
- try {
320
- const client = getOpencodeClient(context.directory)
321
- if (!client) {
322
- throw new Error('OpenCode server not found for directory')
323
- }
371
+ const client = getOpencodeClient(context.directory)
372
+ if (!client) {
373
+ logger.error('OpenCode server not found for directory')
374
+ return
375
+ }
324
376
 
325
- // Build answers array: each element is an array of selected labels for that question
326
- const answers = context.questions.map((_, i) => {
327
- return context.answers[i] || []
328
- })
377
+ const runtime = getRuntime(context.thread.id)
329
378
 
330
- await client.question.reply({
331
- requestID: context.requestId,
332
- directory: context.directory,
333
- answers,
334
- })
379
+ if (runtime && !runtime.isBusy()) {
380
+ logger.log(
381
+ `Session ${context.sessionId} idle; resuming with answers for question ${context.requestId}`,
382
+ )
383
+ await resumeSessionWithAnswers(context)
384
+ return
385
+ }
386
+
387
+ const answers = context.questions.map((_, i) => {
388
+ return context.answers[i] || []
389
+ })
335
390
 
391
+ // throwOnError is off on this client, so failures come back in `.error`.
392
+ const replyResult = await client.question
393
+ .reply({ requestID: context.requestId, directory: context.directory, answers })
394
+ .catch((error) => ({ error }))
395
+
396
+ if (!replyResult.error) {
336
397
  logger.log(
337
398
  `Submitted answers for question ${context.requestId} in session ${context.sessionId}`,
338
399
  )
339
- } catch (error) {
340
- logger.error('Failed to submit answers:', error)
341
- await sendThreadMessage(
342
- context.thread,
343
- `✗ Failed to submit answers: ${error instanceof Error ? error.message : 'Unknown error'}`,
344
- )
400
+ return
345
401
  }
402
+
403
+ // Reply failed (the request was removed while the session still looked busy).
404
+ // Resume so the answer still reaches the model.
405
+ logger.log(
406
+ `Reply failed for question ${context.requestId}; resuming session ${context.sessionId} with answers`,
407
+ )
408
+ await resumeSessionWithAnswers(context)
346
409
  }
347
410
 
348
411
  /**
@@ -0,0 +1,19 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import {
3
+ buildQueueRemoveCustomId,
4
+ parseQueueRemoveCustomId,
5
+ } from './queue.js'
6
+
7
+ describe('queue remove custom id', () => {
8
+ test('round-trips thread and queue ids under Discord custom_id limit', () => {
9
+ const customId = buildQueueRemoveCustomId({
10
+ threadId: '1550106184055398531',
11
+ queueId: 'a1b2c3d4e5f60789',
12
+ })
13
+ expect(customId.length).toBeLessThanOrEqual(100)
14
+ expect(parseQueueRemoveCustomId(customId)).toEqual({
15
+ threadId: '1550106184055398531',
16
+ queueId: 'a1b2c3d4e5f60789',
17
+ })
18
+ })
19
+ })
@@ -7,10 +7,11 @@ import {
7
7
  ButtonStyle,
8
8
  ChannelType,
9
9
  MessageFlags,
10
+ type ButtonInteraction,
10
11
  type ThreadChannel,
11
12
  } from 'discord.js'
12
13
  import type { AutocompleteContext, CommandContext } from './types.js'
13
- import { getThreadSession } from '../database.js'
14
+ import { deleteThreadQueueItem, getThreadSession } from '../database.js'
14
15
  import {
15
16
  resolveWorkingDirectory,
16
17
  SILENT_MESSAGE_FLAGS,
@@ -19,61 +20,128 @@ import {
19
20
  getOrCreateRuntime,
20
21
  getRuntime,
21
22
  } from '../session-handler/thread-session-runtime.js'
22
- import {
23
- buildHtmlActionCustomId,
24
- registerHtmlAction,
25
- } from '../html-actions.js'
26
23
  import { createLogger, LogPrefix } from '../logger.js'
27
- import { QUEUE_PREFIX } from '../message-formatting.js'
24
+ import { asSystemLine, QUEUE_PREFIX } from '../message-formatting.js'
28
25
  import { store } from '../store.js'
29
26
 
30
27
  const logger = createLogger(LogPrefix.QUEUE)
31
28
 
29
+ export const QUEUE_REMOVE_CUSTOM_ID_PREFIX = 'queue_remove:'
30
+
31
+ async function deleteThreadQueueItemWithoutRuntime(queueId: string) {
32
+ const row = await deleteThreadQueueItem(queueId).catch((error) => {
33
+ logger.error(
34
+ `[QUEUE] Failed to delete persisted queue item ${queueId}: ${error instanceof Error ? error.message : String(error)}`,
35
+ )
36
+ return undefined
37
+ })
38
+ if (!row) {
39
+ return undefined
40
+ }
41
+ try {
42
+ const parsed = JSON.parse(row.payload_json) as {
43
+ prompt?: string
44
+ username?: string
45
+ command?: { name: string }
46
+ }
47
+ return {
48
+ prompt: typeof parsed.prompt === 'string' ? parsed.prompt : '',
49
+ username: typeof parsed.username === 'string' ? parsed.username : '',
50
+ command: parsed.command,
51
+ }
52
+ } catch {
53
+ return { prompt: '', username: '' }
54
+ }
55
+ }
56
+
57
+ export function buildQueueRemoveCustomId({
58
+ threadId,
59
+ queueId,
60
+ }: {
61
+ threadId: string
62
+ queueId: string
63
+ }): string {
64
+ return `${QUEUE_REMOVE_CUSTOM_ID_PREFIX}${threadId}:${queueId}`
65
+ }
66
+
67
+ export function parseQueueRemoveCustomId(customId: string): {
68
+ threadId: string
69
+ queueId: string
70
+ } | undefined {
71
+ if (!customId.startsWith(QUEUE_REMOVE_CUSTOM_ID_PREFIX)) {
72
+ return undefined
73
+ }
74
+ const rest = customId.slice(QUEUE_REMOVE_CUSTOM_ID_PREFIX.length)
75
+ const separator = rest.indexOf(':')
76
+ if (separator <= 0 || separator >= rest.length - 1) {
77
+ return undefined
78
+ }
79
+ return {
80
+ threadId: rest.slice(0, separator),
81
+ queueId: rest.slice(separator + 1),
82
+ }
83
+ }
84
+
32
85
  function buildQueueRemoveRow({
33
86
  threadId,
34
87
  queueId,
35
- position,
36
88
  }: {
37
89
  threadId: string
38
90
  queueId: string
39
- position?: number
40
91
  }) {
41
- const actionId = registerHtmlAction({
42
- ownerKey: `queue-remove:${threadId}:${queueId}`,
43
- threadId,
44
- run: async ({ interaction }) => {
45
- const removed = getRuntime(threadId)?.removeQueueItemById(queueId)
46
- if (!removed) {
47
- await interaction.editReply({
48
- content: position
49
- ? `Queued message at position ${position} is no longer in the queue`
50
- : 'Queued message is no longer in the queue',
51
- components: [],
52
- })
53
- return
54
- }
55
-
56
- const label = removed.command
57
- ? `/${removed.command.name}`
58
- : removed.prompt.slice(0, 120)
59
- await interaction.editReply({
60
- content: `Removed queued message${position ? ` (was position ${position})` : ''}: ${label}`,
61
- components: [],
62
- })
63
- logger.log(
64
- `[QUEUE] User ${interaction.user.displayName} removed queued item ${queueId} in thread ${threadId}`,
65
- )
66
- },
67
- })
68
-
69
92
  return new ActionRowBuilder<ButtonBuilder>().addComponents(
70
93
  new ButtonBuilder()
71
- .setCustomId(buildHtmlActionCustomId(actionId))
94
+ .setCustomId(buildQueueRemoveCustomId({ threadId, queueId }))
72
95
  .setLabel('Remove from queue')
73
96
  .setStyle(ButtonStyle.Secondary),
74
97
  )
75
98
  }
76
99
 
100
+ export async function handleQueueRemoveButton(
101
+ interaction: ButtonInteraction,
102
+ ): Promise<void> {
103
+ const parsed = parseQueueRemoveCustomId(interaction.customId)
104
+ if (!parsed) {
105
+ await interaction.reply({
106
+ content: 'Invalid queue remove button',
107
+ flags: MessageFlags.Ephemeral,
108
+ })
109
+ return
110
+ }
111
+
112
+ if (interaction.channelId && interaction.channelId !== parsed.threadId) {
113
+ await interaction.reply({
114
+ content: 'This queue button does not belong to this thread',
115
+ flags: MessageFlags.Ephemeral,
116
+ })
117
+ return
118
+ }
119
+
120
+ await interaction.deferUpdate()
121
+ const runtime = getRuntime(parsed.threadId)
122
+ const removed = runtime
123
+ ? await runtime.removeQueueItemById(parsed.queueId)
124
+ : await deleteThreadQueueItemWithoutRuntime(parsed.queueId)
125
+ if (!removed) {
126
+ await interaction.editReply({
127
+ content: asSystemLine('Queued message is no longer in the queue'),
128
+ components: [],
129
+ })
130
+ return
131
+ }
132
+
133
+ const label = removed.command
134
+ ? `/${removed.command.name}`
135
+ : removed.prompt.slice(0, 120)
136
+ await interaction.editReply({
137
+ content: asSystemLine(`Removed queued message: ${label}`),
138
+ components: [],
139
+ })
140
+ logger.log(
141
+ `[QUEUE] User ${interaction.user.displayName} removed queued item ${parsed.queueId} in thread ${parsed.threadId}`,
142
+ )
143
+ }
144
+
77
145
  export async function handleQueueCommand({
78
146
  command,
79
147
  appId,
@@ -143,14 +211,13 @@ export async function handleQueueCommand({
143
211
  })
144
212
 
145
213
  if (enqueueResult.queued && enqueueResult.queueId) {
146
- const responseText = `Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`
214
+ const responseText = asSystemLine(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`)
147
215
  await command.reply({
148
216
  content: responseText,
149
217
  components: [
150
218
  buildQueueRemoveRow({
151
219
  threadId: thread.id,
152
220
  queueId: enqueueResult.queueId,
153
- position: enqueueResult.position,
154
221
  }),
155
222
  ],
156
223
  flags: SILENT_MESSAGE_FLAGS,
@@ -205,7 +272,7 @@ export async function handleClearQueueCommand({
205
272
  }
206
273
 
207
274
  if (position !== undefined) {
208
- const removed = runtime?.removeQueuePosition(position)
275
+ const removed = await runtime?.removeQueuePosition(position)
209
276
  if (!removed) {
210
277
  await command.reply({
211
278
  content: `No queued message at position ${position}`,
@@ -215,7 +282,7 @@ export async function handleClearQueueCommand({
215
282
  }
216
283
 
217
284
  await command.reply({
218
- content: `Cleared queued message at position ${position}`,
285
+ content: asSystemLine(`Cleared queued message at position ${position}`),
219
286
  flags: SILENT_MESSAGE_FLAGS,
220
287
  })
221
288
 
@@ -225,7 +292,7 @@ export async function handleClearQueueCommand({
225
292
  return
226
293
  }
227
294
 
228
- const cleared = runtime?.clearQueue() ?? []
295
+ const cleared = await runtime?.clearQueue() ?? []
229
296
 
230
297
  const lines = cleared.map((item, i) => {
231
298
  const label = item.command
@@ -239,7 +306,7 @@ export async function handleClearQueueCommand({
239
306
  }
240
307
 
241
308
  await command.reply({
242
- content: `Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`,
309
+ content: asSystemLine(`Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`),
243
310
  flags: SILENT_MESSAGE_FLAGS,
244
311
  })
245
312
 
@@ -335,14 +402,13 @@ export async function handleQueueCommandCommand({
335
402
  })
336
403
 
337
404
  if (enqueueResult.queued && enqueueResult.queueId) {
338
- const responseText = `Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`
405
+ const responseText = asSystemLine(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`)
339
406
  await command.reply({
340
407
  content: responseText,
341
408
  components: [
342
409
  buildQueueRemoveRow({
343
410
  threadId: thread.id,
344
411
  queueId: enqueueResult.queueId,
345
- position: enqueueResult.position,
346
412
  }),
347
413
  ],
348
414
  flags: SILENT_MESSAGE_FLAGS,
@@ -20,7 +20,10 @@ import {
20
20
  SILENT_MESSAGE_FLAGS,
21
21
  } from '../discord-utils.js'
22
22
  import { createLogger, LogPrefix } from '../logger.js'
23
- import { disposeRuntimesForDirectory } from '../session-handler/thread-session-runtime.js'
23
+ import {
24
+ disposeRuntimesForDirectory,
25
+ restorePersistedLocalQueues,
26
+ } from '../session-handler/thread-session-runtime.js'
24
27
  import { registerCommands, type AgentInfo } from '../discord-command-registration.js'
25
28
 
26
29
  const logger = createLogger(LogPrefix.OPENCODE)
@@ -104,6 +107,14 @@ export async function handleRestartOpencodeServerCommand({
104
107
  content: `Opencode server **restarted** successfully${abortMsg}. Re-registering slash commands...`,
105
108
  })
106
109
  logger.log('[RESTART] Shared opencode server restarted')
110
+ await restorePersistedLocalQueues({
111
+ discordClient: command.client,
112
+ appId,
113
+ }).catch((error) => {
114
+ logger.warn(
115
+ `[RESTART] Failed to restore persisted queues: ${error instanceof Error ? error.message : String(error)}`,
116
+ )
117
+ })
107
118
 
108
119
  // Re-register Discord slash commands after restart so new/changed
109
120
  // commands, agents, and plugins are picked up immediately.