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
@@ -1,44 +1,94 @@
1
1
  // Queue commands - /queue, /queue-command
2
2
  // Clear/remove actions live as buttons on each queue confirmation message.
3
3
  import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, MessageFlags, } from 'discord.js';
4
- import { getThreadSession } from '../database.js';
4
+ import { deleteThreadQueueItem, getThreadSession } from '../database.js';
5
5
  import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
6
6
  import { getOrCreateRuntime, getRuntime, } from '../session-handler/thread-session-runtime.js';
7
- import { buildHtmlActionCustomId, registerHtmlAction, } from '../html-actions.js';
8
7
  import { createLogger, LogPrefix } from '../logger.js';
9
- import { QUEUE_PREFIX } from '../message-formatting.js';
8
+ import { asSystemLine, QUEUE_PREFIX } from '../message-formatting.js';
10
9
  import { store } from '../store.js';
11
10
  const logger = createLogger(LogPrefix.QUEUE);
12
- function buildQueueRemoveRow({ threadId, queueId, position, }) {
13
- const actionId = registerHtmlAction({
14
- ownerKey: `queue-remove:${threadId}:${queueId}`,
15
- threadId,
16
- run: async ({ interaction }) => {
17
- const removed = getRuntime(threadId)?.removeQueueItemById(queueId);
18
- if (!removed) {
19
- await interaction.editReply({
20
- content: position
21
- ? `Queued message at position ${position} is no longer in the queue`
22
- : 'Queued message is no longer in the queue',
23
- components: [],
24
- });
25
- return;
26
- }
27
- const label = removed.command
28
- ? `/${removed.command.name}`
29
- : removed.prompt.slice(0, 120);
30
- await interaction.editReply({
31
- content: `Removed queued message${position ? ` (was position ${position})` : ''}: ${label}`,
32
- components: [],
33
- });
34
- logger.log(`[QUEUE] User ${interaction.user.displayName} removed queued item ${queueId} in thread ${threadId}`);
35
- },
11
+ export const QUEUE_REMOVE_CUSTOM_ID_PREFIX = 'queue_remove:';
12
+ async function deleteThreadQueueItemWithoutRuntime(queueId) {
13
+ const row = await deleteThreadQueueItem(queueId).catch((error) => {
14
+ logger.error(`[QUEUE] Failed to delete persisted queue item ${queueId}: ${error instanceof Error ? error.message : String(error)}`);
15
+ return undefined;
36
16
  });
17
+ if (!row) {
18
+ return undefined;
19
+ }
20
+ try {
21
+ const parsed = JSON.parse(row.payload_json);
22
+ return {
23
+ prompt: typeof parsed.prompt === 'string' ? parsed.prompt : '',
24
+ username: typeof parsed.username === 'string' ? parsed.username : '',
25
+ command: parsed.command,
26
+ };
27
+ }
28
+ catch {
29
+ return { prompt: '', username: '' };
30
+ }
31
+ }
32
+ export function buildQueueRemoveCustomId({ threadId, queueId, }) {
33
+ return `${QUEUE_REMOVE_CUSTOM_ID_PREFIX}${threadId}:${queueId}`;
34
+ }
35
+ export function parseQueueRemoveCustomId(customId) {
36
+ if (!customId.startsWith(QUEUE_REMOVE_CUSTOM_ID_PREFIX)) {
37
+ return undefined;
38
+ }
39
+ const rest = customId.slice(QUEUE_REMOVE_CUSTOM_ID_PREFIX.length);
40
+ const separator = rest.indexOf(':');
41
+ if (separator <= 0 || separator >= rest.length - 1) {
42
+ return undefined;
43
+ }
44
+ return {
45
+ threadId: rest.slice(0, separator),
46
+ queueId: rest.slice(separator + 1),
47
+ };
48
+ }
49
+ function buildQueueRemoveRow({ threadId, queueId, }) {
37
50
  return new ActionRowBuilder().addComponents(new ButtonBuilder()
38
- .setCustomId(buildHtmlActionCustomId(actionId))
51
+ .setCustomId(buildQueueRemoveCustomId({ threadId, queueId }))
39
52
  .setLabel('Remove from queue')
40
53
  .setStyle(ButtonStyle.Secondary));
41
54
  }
55
+ export async function handleQueueRemoveButton(interaction) {
56
+ const parsed = parseQueueRemoveCustomId(interaction.customId);
57
+ if (!parsed) {
58
+ await interaction.reply({
59
+ content: 'Invalid queue remove button',
60
+ flags: MessageFlags.Ephemeral,
61
+ });
62
+ return;
63
+ }
64
+ if (interaction.channelId && interaction.channelId !== parsed.threadId) {
65
+ await interaction.reply({
66
+ content: 'This queue button does not belong to this thread',
67
+ flags: MessageFlags.Ephemeral,
68
+ });
69
+ return;
70
+ }
71
+ await interaction.deferUpdate();
72
+ const runtime = getRuntime(parsed.threadId);
73
+ const removed = runtime
74
+ ? await runtime.removeQueueItemById(parsed.queueId)
75
+ : await deleteThreadQueueItemWithoutRuntime(parsed.queueId);
76
+ if (!removed) {
77
+ await interaction.editReply({
78
+ content: asSystemLine('Queued message is no longer in the queue'),
79
+ components: [],
80
+ });
81
+ return;
82
+ }
83
+ const label = removed.command
84
+ ? `/${removed.command.name}`
85
+ : removed.prompt.slice(0, 120);
86
+ await interaction.editReply({
87
+ content: asSystemLine(`Removed queued message: ${label}`),
88
+ components: [],
89
+ });
90
+ logger.log(`[QUEUE] User ${interaction.user.displayName} removed queued item ${parsed.queueId} in thread ${parsed.threadId}`);
91
+ }
42
92
  export async function handleQueueCommand({ command, appId, }) {
43
93
  const message = command.options.getString('message', true);
44
94
  const channel = command.channel;
@@ -95,14 +145,13 @@ export async function handleQueueCommand({ command, appId, }) {
95
145
  mode: 'local-queue',
96
146
  });
97
147
  if (enqueueResult.queued && enqueueResult.queueId) {
98
- const responseText = `Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`;
148
+ const responseText = asSystemLine(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`);
99
149
  await command.reply({
100
150
  content: responseText,
101
151
  components: [
102
152
  buildQueueRemoveRow({
103
153
  threadId: thread.id,
104
154
  queueId: enqueueResult.queueId,
105
- position: enqueueResult.position,
106
155
  }),
107
156
  ],
108
157
  flags: SILENT_MESSAGE_FLAGS,
@@ -147,7 +196,7 @@ export async function handleClearQueueCommand({ command, }) {
147
196
  return;
148
197
  }
149
198
  if (position !== undefined) {
150
- const removed = runtime?.removeQueuePosition(position);
199
+ const removed = await runtime?.removeQueuePosition(position);
151
200
  if (!removed) {
152
201
  await command.reply({
153
202
  content: `No queued message at position ${position}`,
@@ -156,13 +205,13 @@ export async function handleClearQueueCommand({ command, }) {
156
205
  return;
157
206
  }
158
207
  await command.reply({
159
- content: `Cleared queued message at position ${position}`,
208
+ content: asSystemLine(`Cleared queued message at position ${position}`),
160
209
  flags: SILENT_MESSAGE_FLAGS,
161
210
  });
162
211
  logger.log(`[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`);
163
212
  return;
164
213
  }
165
- const cleared = runtime?.clearQueue() ?? [];
214
+ const cleared = await runtime?.clearQueue() ?? [];
166
215
  const lines = cleared.map((item, i) => {
167
216
  const label = item.command
168
217
  ? `/${item.command.name}`
@@ -174,7 +223,7 @@ export async function handleClearQueueCommand({ command, }) {
174
223
  list = list.slice(0, 597) + '...';
175
224
  }
176
225
  await command.reply({
177
- content: `Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`,
226
+ content: asSystemLine(`Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`),
178
227
  flags: SILENT_MESSAGE_FLAGS,
179
228
  });
180
229
  logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`);
@@ -250,14 +299,13 @@ export async function handleQueueCommandCommand({ command, appId, }) {
250
299
  mode: 'local-queue',
251
300
  });
252
301
  if (enqueueResult.queued && enqueueResult.queueId) {
253
- const responseText = `Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`;
302
+ const responseText = asSystemLine(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`);
254
303
  await command.reply({
255
304
  content: responseText,
256
305
  components: [
257
306
  buildQueueRemoveRow({
258
307
  threadId: thread.id,
259
308
  queueId: enqueueResult.queueId,
260
- position: enqueueResult.position,
261
309
  }),
262
310
  ],
263
311
  flags: SILENT_MESSAGE_FLAGS,
@@ -0,0 +1,15 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { buildQueueRemoveCustomId, parseQueueRemoveCustomId, } from './queue.js';
3
+ describe('queue remove custom id', () => {
4
+ test('round-trips thread and queue ids under Discord custom_id limit', () => {
5
+ const customId = buildQueueRemoveCustomId({
6
+ threadId: '1550106184055398531',
7
+ queueId: 'a1b2c3d4e5f60789',
8
+ });
9
+ expect(customId.length).toBeLessThanOrEqual(100);
10
+ expect(parseQueueRemoveCustomId(customId)).toEqual({
11
+ threadId: '1550106184055398531',
12
+ queueId: 'a1b2c3d4e5f60789',
13
+ });
14
+ });
15
+ });
@@ -9,7 +9,7 @@ import { ChannelType, MessageFlags, } from 'discord.js';
9
9
  import { initializeOpencodeForDirectory, restartOpencodeServer } from '../opencode.js';
10
10
  import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
11
11
  import { createLogger, LogPrefix } from '../logger.js';
12
- import { disposeRuntimesForDirectory } from '../session-handler/thread-session-runtime.js';
12
+ import { disposeRuntimesForDirectory, restorePersistedLocalQueues, } from '../session-handler/thread-session-runtime.js';
13
13
  import { registerCommands } from '../discord-command-registration.js';
14
14
  const logger = createLogger(LogPrefix.OPENCODE);
15
15
  export async function handleRestartOpencodeServerCommand({ command, appId, }) {
@@ -74,6 +74,12 @@ export async function handleRestartOpencodeServerCommand({ command, appId, }) {
74
74
  content: `Opencode server **restarted** successfully${abortMsg}. Re-registering slash commands...`,
75
75
  });
76
76
  logger.log('[RESTART] Shared opencode server restarted');
77
+ await restorePersistedLocalQueues({
78
+ discordClient: command.client,
79
+ appId,
80
+ }).catch((error) => {
81
+ logger.warn(`[RESTART] Failed to restore persisted queues: ${error instanceof Error ? error.message : String(error)}`);
82
+ });
77
83
  // Re-register Discord slash commands after restart so new/changed
78
84
  // commands, agents, and plugins are picked up immediately.
79
85
  const token = command.client.token;
@@ -1,4 +1,4 @@
1
- // /session-id command - Show current session ID and an opencode attach command.
1
+ // /session-id command - Show current session ID, Discord thread ID, and an opencode attach command.
2
2
  import { ChannelType, MessageFlags, } from 'discord.js';
3
3
  import { getThreadSession } from '../database.js';
4
4
  import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
@@ -46,7 +46,7 @@ export async function handleSessionIdCommand({ command, }) {
46
46
  const sessionId = await getThreadSession(channel.id);
47
47
  if (!sessionId) {
48
48
  await command.reply({
49
- content: 'No active session in this thread',
49
+ content: `No active session in this thread\n**Thread ID:** \`${channel.id}\``,
50
50
  flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
51
51
  });
52
52
  return;
@@ -57,7 +57,7 @@ export async function handleSessionIdCommand({ command, }) {
57
57
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
58
58
  if (getClient instanceof Error) {
59
59
  await command.editReply({
60
- content: `Session ID: \`${sessionId}\`\nFailed to resolve OpenCode server port: ${getClient.message}`,
60
+ content: `**Session ID:** \`${sessionId}\`\n**Thread ID:** \`${channel.id}\`\nFailed to resolve OpenCode server port: ${getClient.message}`,
61
61
  });
62
62
  return;
63
63
  }
@@ -65,14 +65,14 @@ export async function handleSessionIdCommand({ command, }) {
65
65
  }
66
66
  if (!port) {
67
67
  await command.editReply({
68
- content: `Session ID: \`${sessionId}\`\nCould not determine OpenCode server port`,
68
+ content: `**Session ID:** \`${sessionId}\`\n**Thread ID:** \`${channel.id}\`\nCould not determine OpenCode server port`,
69
69
  });
70
70
  return;
71
71
  }
72
72
  const attachUrl = `http://127.0.0.1:${port}`;
73
73
  const attachCommand = `opencode attach ${attachUrl} --session ${sessionId} --dir ${shellQuote(workingDirectory)}`;
74
74
  await command.editReply({
75
- content: `**Session ID:** \`${sessionId}\`\n**Attach command:**\n\`\`\`bash\n${attachCommand}\n\`\`\``,
75
+ content: `**Session ID:** \`${sessionId}\`\n**Thread ID:** \`${channel.id}\`\n**Attach command:**\n\`\`\`bash\n${attachCommand}\n\`\`\``,
76
76
  });
77
77
  logger.log(`Session ID shown for thread ${channel.id}: ${sessionId}`);
78
78
  }
package/dist/database.js CHANGED
@@ -174,13 +174,13 @@ export async function recoverStaleRunningScheduledTasks({ staleBefore }) {
174
174
  export async function deleteScheduledTask(taskId) {
175
175
  const db = await getDb();
176
176
  await db.delete(schema.scheduled_tasks)
177
- .where(orm.eq(schema.scheduled_tasks.id, taskId));
177
+ .where(orm.and(orm.eq(schema.scheduled_tasks.id, taskId), orm.eq(schema.scheduled_tasks.status, 'running')));
178
178
  }
179
179
  export async function markScheduledTaskCronRescheduled({ taskId, completedAt, nextRunAt }) {
180
180
  const db = await getDb();
181
181
  await db.update(schema.scheduled_tasks)
182
182
  .set({ status: 'planned', last_run_at: completedAt, running_started_at: null, last_error: null, next_run_at: nextRunAt })
183
- .where(orm.eq(schema.scheduled_tasks.id, taskId));
183
+ .where(orm.and(orm.eq(schema.scheduled_tasks.id, taskId), orm.eq(schema.scheduled_tasks.status, 'running')));
184
184
  }
185
185
  export async function markScheduledTaskFailed({ taskId, failedAt, errorMessage }) {
186
186
  const db = await getDb();
@@ -192,7 +192,7 @@ export async function markScheduledTaskFailed({ taskId, failedAt, errorMessage }
192
192
  last_error: errorMessage,
193
193
  attempts: orm.sql `${schema.scheduled_tasks.attempts} + 1`,
194
194
  })
195
- .where(orm.eq(schema.scheduled_tasks.id, taskId));
195
+ .where(orm.and(orm.eq(schema.scheduled_tasks.id, taskId), orm.eq(schema.scheduled_tasks.status, 'running')));
196
196
  }
197
197
  export async function markScheduledTaskCronRetry({ taskId, failedAt, errorMessage, nextRunAt }) {
198
198
  const db = await getDb();
@@ -205,7 +205,7 @@ export async function markScheduledTaskCronRetry({ taskId, failedAt, errorMessag
205
205
  last_error: errorMessage,
206
206
  attempts: orm.sql `${schema.scheduled_tasks.attempts} + 1`,
207
207
  })
208
- .where(orm.eq(schema.scheduled_tasks.id, taskId));
208
+ .where(orm.and(orm.eq(schema.scheduled_tasks.id, taskId), orm.eq(schema.scheduled_tasks.status, 'running')));
209
209
  }
210
210
  export async function setSessionStartSource({ sessionId, scheduleKind, scheduledTaskId }) {
211
211
  const db = await getDb();
@@ -542,6 +542,42 @@ export async function getThreadSession(threadId) {
542
542
  const db = await getDb();
543
543
  return (await db.query.thread_sessions.findFirst({ where: { thread_id: threadId } }))?.session_id;
544
544
  }
545
+ export async function insertThreadQueueItem({ queueId, threadId, payloadJson, }) {
546
+ const db = await getDb();
547
+ await db.insert(schema.thread_queue_items).values({
548
+ queue_id: queueId,
549
+ thread_id: threadId,
550
+ payload_json: payloadJson,
551
+ });
552
+ }
553
+ export async function listThreadQueueItems(threadId) {
554
+ const db = await getDb();
555
+ return db.query.thread_queue_items.findMany({
556
+ where: { thread_id: threadId },
557
+ orderBy: { id: 'asc' },
558
+ });
559
+ }
560
+ export async function listAllThreadQueueItems() {
561
+ const db = await getDb();
562
+ return db.query.thread_queue_items.findMany({
563
+ orderBy: { id: 'asc' },
564
+ });
565
+ }
566
+ export async function deleteThreadQueueItem(queueId) {
567
+ const db = await getDb();
568
+ const rows = await db.delete(schema.thread_queue_items).where(orm.eq(schema.thread_queue_items.queue_id, queueId)).returning();
569
+ return rows[0];
570
+ }
571
+ export async function deleteThreadQueueItems(threadId) {
572
+ const db = await getDb();
573
+ await db.delete(schema.thread_queue_items).where(orm.eq(schema.thread_queue_items.thread_id, threadId));
574
+ }
575
+ export async function updateThreadQueueItemPayload({ queueId, payloadJson, }) {
576
+ const db = await getDb();
577
+ await db.update(schema.thread_queue_items)
578
+ .set({ payload_json: payloadJson })
579
+ .where(orm.eq(schema.thread_queue_items.queue_id, queueId));
580
+ }
545
581
  export async function setThreadSession(threadId, sessionId) {
546
582
  await upsertThreadSession({ threadId, sessionId, source: 'kimaki' });
547
583
  }
@@ -833,24 +869,131 @@ export async function listTrackedTextChannels() {
833
869
  columns: { channel_id: true, directory: true, created_at: true },
834
870
  });
835
871
  }
872
+ export async function getGuildCategories(guildId) {
873
+ const db = await getDb();
874
+ const row = await db.query.guild_categories.findFirst({ where: { guild_id: guildId } });
875
+ if (!row)
876
+ return null;
877
+ return {
878
+ guild_id: row.guild_id,
879
+ category_id: row.category_id,
880
+ audio_category_id: row.audio_category_id,
881
+ };
882
+ }
883
+ export async function listGuildCategoryIds() {
884
+ const db = await getDb();
885
+ const rows = await db.query.guild_categories.findMany({
886
+ columns: { category_id: true, audio_category_id: true },
887
+ });
888
+ const ids = new Set();
889
+ for (const row of rows) {
890
+ if (row.category_id)
891
+ ids.add(row.category_id);
892
+ if (row.audio_category_id)
893
+ ids.add(row.audio_category_id);
894
+ }
895
+ return [...ids];
896
+ }
897
+ export async function setGuildCategoryId({ guildId, kind, categoryId, }) {
898
+ const db = await getDb();
899
+ const values = kind === 'audio'
900
+ ? { guild_id: guildId, audio_category_id: categoryId }
901
+ : { guild_id: guildId, category_id: categoryId };
902
+ const set = kind === 'audio'
903
+ ? { audio_category_id: categoryId }
904
+ : { category_id: categoryId };
905
+ await db.insert(schema.guild_categories)
906
+ .values(values)
907
+ .onConflictDoUpdate({
908
+ target: schema.guild_categories.guild_id,
909
+ set,
910
+ });
911
+ }
836
912
  export async function deleteChannelDirectoriesByDirectory(directory) {
837
913
  const db = await getDb();
838
914
  await db.delete(schema.channel_directories).where(orm.eq(schema.channel_directories.directory, directory));
839
915
  }
840
- export async function deleteChannelDirectoryById(channelId) {
916
+ export async function deleteChannelDirectoryById(channelId, { preserveMapping = false } = {}) {
841
917
  const db = await getDb();
842
918
  await db.batch([
919
+ db.update(schema.scheduled_tasks)
920
+ .set({
921
+ status: 'cancelled',
922
+ running_started_at: null,
923
+ last_error: 'Discord channel was deleted',
924
+ })
925
+ .where(orm.and(orm.eq(schema.scheduled_tasks.channel_id, channelId), orm.inArray(schema.scheduled_tasks.status, ['planned', 'running']))),
843
926
  db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, channelId)),
844
927
  db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, channelId)),
845
928
  db.delete(schema.channel_worktrees).where(orm.eq(schema.channel_worktrees.channel_id, channelId)),
846
929
  db.delete(schema.channel_verbosity).where(orm.eq(schema.channel_verbosity.channel_id, channelId)),
847
930
  db.delete(schema.channel_mention_mode).where(orm.eq(schema.channel_mention_mode.channel_id, channelId)),
848
931
  ]);
932
+ if (preserveMapping) {
933
+ return Boolean(await db.query.channel_directories.findFirst({
934
+ where: { channel_id: channelId },
935
+ columns: { channel_id: true },
936
+ }));
937
+ }
849
938
  const rows = await db.delete(schema.channel_directories)
850
939
  .where(orm.eq(schema.channel_directories.channel_id, channelId))
851
940
  .returning({ channel_id: schema.channel_directories.channel_id });
852
941
  return rows.length > 0;
853
942
  }
943
+ export async function cleanupDeletedThread(threadId) {
944
+ const db = await getDb();
945
+ const binding = await db.query.thread_sessions.findFirst({
946
+ where: { thread_id: threadId },
947
+ columns: { session_id: true },
948
+ });
949
+ const currentThreadId = binding
950
+ ? await getThreadIdBySessionId(binding.session_id)
951
+ : undefined;
952
+ const currentSessionId = currentThreadId === threadId
953
+ ? binding?.session_id
954
+ : undefined;
955
+ await db.batch([
956
+ db.delete(schema.thread_queue_items)
957
+ .where(orm.eq(schema.thread_queue_items.thread_id, threadId)),
958
+ db.update(schema.session_sleeps)
959
+ .set({ status: 'cancelled' })
960
+ .where(orm.and(orm.eq(schema.session_sleeps.status, 'planned'), orm.eq(schema.session_sleeps.session_id, currentSessionId ?? ''))),
961
+ db.update(schema.scheduled_tasks)
962
+ .set({
963
+ status: 'cancelled',
964
+ running_started_at: null,
965
+ last_error: 'Discord thread was deleted',
966
+ })
967
+ .where(orm.and(orm.eq(schema.scheduled_tasks.thread_id, threadId), orm.inArray(schema.scheduled_tasks.status, ['planned', 'running']))),
968
+ db.update(schema.ipc_requests)
969
+ .set({
970
+ status: 'cancelled',
971
+ response: JSON.stringify({ error: 'Discord thread was deleted' }),
972
+ })
973
+ .where(orm.and(orm.eq(schema.ipc_requests.thread_id, threadId), orm.inArray(schema.ipc_requests.status, ['pending', 'processing']))),
974
+ ]);
975
+ }
976
+ export async function isCurrentThreadSessionBinding(threadId) {
977
+ const db = await getDb();
978
+ const binding = await db.query.thread_sessions.findFirst({
979
+ where: { thread_id: threadId },
980
+ columns: { session_id: true },
981
+ });
982
+ if (!binding)
983
+ return false;
984
+ return await getThreadIdBySessionId(binding.session_id) === threadId;
985
+ }
986
+ export async function clearGuildCategoryByChannelId(channelId) {
987
+ const db = await getDb();
988
+ await db.batch([
989
+ db.update(schema.guild_categories)
990
+ .set({ category_id: null })
991
+ .where(orm.eq(schema.guild_categories.category_id, channelId)),
992
+ db.update(schema.guild_categories)
993
+ .set({ audio_category_id: null })
994
+ .where(orm.eq(schema.guild_categories.audio_category_id, channelId)),
995
+ ]);
996
+ }
854
997
  export async function getVoiceChannelDirectory(channelId) {
855
998
  const db = await getDb();
856
999
  return (await db.query.channel_directories.findFirst({ where: { channel_id: channelId, channel_type: 'voice' } }))?.directory;
@@ -906,7 +1049,7 @@ export async function completeIpcRequest({ id, response }) {
906
1049
  const db = await getDb();
907
1050
  const [row] = await db.update(schema.ipc_requests)
908
1051
  .set({ response, status: 'completed' })
909
- .where(orm.eq(schema.ipc_requests.id, id))
1052
+ .where(orm.and(orm.eq(schema.ipc_requests.id, id), orm.inArray(schema.ipc_requests.status, ['pending', 'processing'])))
910
1053
  .returning();
911
1054
  return row;
912
1055
  }
package/dist/db.js CHANGED
@@ -89,6 +89,37 @@ async function initializeDb() {
89
89
  dbInstance = db;
90
90
  return db;
91
91
  }
92
+ async function rebuildLegacyThreadQueueItems(client) {
93
+ const info = await client.execute('PRAGMA table_info(thread_queue_items)').catch(() => undefined);
94
+ if (!info) {
95
+ return;
96
+ }
97
+ const columns = new Set(info.rows.map((row) => String(row.name)));
98
+ if (columns.has('id')) {
99
+ return;
100
+ }
101
+ if (!columns.has('queue_id')) {
102
+ return;
103
+ }
104
+ await client.execute('ALTER TABLE thread_queue_items RENAME TO thread_queue_items_legacy');
105
+ await client.execute(`
106
+ CREATE TABLE IF NOT EXISTS thread_queue_items (
107
+ id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
108
+ queue_id text NOT NULL UNIQUE,
109
+ thread_id text NOT NULL,
110
+ payload_json text NOT NULL,
111
+ created_at datetime DEFAULT CURRENT_TIMESTAMP
112
+ )
113
+ `);
114
+ await client.execute(`
115
+ INSERT INTO thread_queue_items (queue_id, thread_id, payload_json, created_at)
116
+ SELECT queue_id, thread_id, payload_json, created_at
117
+ FROM thread_queue_items_legacy
118
+ ORDER BY created_at, queue_id
119
+ `);
120
+ await client.execute('DROP TABLE thread_queue_items_legacy');
121
+ await client.execute('CREATE INDEX IF NOT EXISTS thread_queue_items_thread_id_id_idx ON thread_queue_items (thread_id, id)');
122
+ }
92
123
  // CREATE TABLE IF NOT EXISTS cannot drop columns. The first two session_sleeps
93
124
  // shapes kept thread_id NOT NULL and posted_at. Inserts now omit thread_id, so
94
125
  // existing DBs fail with SQLITE_CONSTRAINT_NOTNULL until those columns go away.
@@ -104,6 +135,9 @@ async function dropLegacySessionSleepsColumns(client) {
104
135
  await client.execute("UPDATE session_sleeps SET status = 'planned' WHERE status = 'posted'");
105
136
  }
106
137
  async function migrateSchema({ db, client, }) {
138
+ // schema.sql CREATE TABLE IF NOT EXISTS cannot change the primary key.
139
+ // Rebuild first so CREATE INDEX (... id) does not hit the old queue_id table.
140
+ await rebuildLegacyThreadQueueItems(client);
107
141
  const schemaPath = path.join(__dirname, '../src/schema.sql');
108
142
  const sql = fs.readFileSync(schemaPath, 'utf-8');
109
143
  const statements = sql