kimaki 0.28.0 → 0.29.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 (71) hide show
  1. package/dist/bash-tool-schema-plugin.js +18 -0
  2. package/dist/bash-tool-schema-plugin.test.js +20 -1
  3. package/dist/channel-management.js +113 -51
  4. package/dist/cli-commands/misc.js +4 -1
  5. package/dist/cli-commands/project.js +7 -4
  6. package/dist/cli-commands/session.js +84 -33
  7. package/dist/cli-commands/thread.js +63 -0
  8. package/dist/cli.js +2 -0
  9. package/dist/commands/queue.js +79 -31
  10. package/dist/commands/queue.test.js +15 -0
  11. package/dist/commands/restart-opencode-server.js +7 -1
  12. package/dist/commands/session-id.js +5 -5
  13. package/dist/database.js +76 -0
  14. package/dist/db.js +34 -0
  15. package/dist/db.test.js +96 -1
  16. package/dist/default-channel-provisioning.test.js +2 -1
  17. package/dist/discord-bot.js +9 -3
  18. package/dist/discord-command-registration.js +1 -1
  19. package/dist/guild-categories.test.js +225 -0
  20. package/dist/interaction-handler.js +5 -1
  21. package/dist/kimaki-opencode-plugin.js +2 -0
  22. package/dist/kimaki-opencode-plugin.test.js +28 -0
  23. package/dist/list-channel-threads.js +163 -0
  24. package/dist/list-channel-threads.test.js +80 -0
  25. package/dist/message-formatting.js +9 -16
  26. package/dist/message-formatting.test.js +91 -2
  27. package/dist/schema.js +21 -0
  28. package/dist/session-handler/event-stream-state.js +53 -5
  29. package/dist/session-handler/event-stream-state.test.js +223 -0
  30. package/dist/session-handler/thread-runtime-state.js +4 -0
  31. package/dist/session-handler/thread-session-runtime.js +314 -73
  32. package/dist/system-message.js +55 -13
  33. package/dist/system-message.test.js +50 -13
  34. package/dist/task-id-plugin.js +16 -0
  35. package/package.json +5 -5
  36. package/skills/strada/SKILL.md +61 -232
  37. package/src/bash-tool-schema-plugin.test.ts +29 -0
  38. package/src/bash-tool-schema-plugin.ts +31 -0
  39. package/src/channel-management.ts +144 -65
  40. package/src/cli-commands/misc.ts +4 -1
  41. package/src/cli-commands/project.ts +9 -4
  42. package/src/cli-commands/session.ts +123 -34
  43. package/src/cli-commands/thread.ts +86 -0
  44. package/src/cli.ts +2 -0
  45. package/src/commands/queue.test.ts +19 -0
  46. package/src/commands/queue.ts +106 -40
  47. package/src/commands/restart-opencode-server.ts +12 -1
  48. package/src/commands/session-id.ts +5 -5
  49. package/src/database.ts +114 -0
  50. package/src/db.test.ts +112 -0
  51. package/src/db.ts +38 -0
  52. package/src/default-channel-provisioning.test.ts +2 -1
  53. package/src/discord-bot.ts +11 -2
  54. package/src/discord-command-registration.ts +3 -1
  55. package/src/guild-categories.test.ts +260 -0
  56. package/src/interaction-handler.ts +7 -0
  57. package/src/kimaki-opencode-plugin.test.ts +34 -0
  58. package/src/kimaki-opencode-plugin.ts +2 -0
  59. package/src/list-channel-threads.test.ts +90 -0
  60. package/src/list-channel-threads.ts +252 -0
  61. package/src/message-formatting.test.ts +105 -1
  62. package/src/message-formatting.ts +7 -12
  63. package/src/schema.sql +16 -0
  64. package/src/schema.ts +23 -0
  65. package/src/session-handler/event-stream-state.test.ts +226 -0
  66. package/src/session-handler/event-stream-state.ts +57 -5
  67. package/src/session-handler/thread-runtime-state.ts +5 -0
  68. package/src/session-handler/thread-session-runtime.ts +380 -81
  69. package/src/system-message.test.ts +50 -13
  70. package/src/system-message.ts +58 -12
  71. package/src/task-id-plugin.ts +17 -0
@@ -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
8
  import { 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: '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: `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;
@@ -102,7 +152,6 @@ export async function handleQueueCommand({ command, appId, }) {
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}`,
@@ -162,7 +211,7 @@ export async function handleClearQueueCommand({ command, }) {
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}`
@@ -257,7 +306,6 @@ export async function handleQueueCommandCommand({ command, appId, }) {
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
@@ -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,6 +869,46 @@ 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));
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
package/dist/db.test.js CHANGED
@@ -7,7 +7,7 @@ import { afterAll, describe, expect, test } from 'vitest';
7
7
  import { closeDb, getDb } from './db.js';
8
8
  import * as orm from 'drizzle-orm';
9
9
  import * as schema from './schema.js';
10
- import { appendSessionEventsSinceLastTimestamp, createPendingWorkspace, getDueSessionSleeps, getSessionEventSnapshot, getSessionModel, getSessionSleep, setSessionModel, upsertSessionSleep, } from './database.js';
10
+ import { appendSessionEventsSinceLastTimestamp, createPendingWorkspace, deleteThreadQueueItem, getDueSessionSleeps, getSessionEventSnapshot, getSessionModel, getSessionSleep, insertThreadQueueItem, listAllThreadQueueItems, listThreadQueueItems, setSessionModel, updateThreadQueueItemPayload, upsertSessionSleep, } from './database.js';
11
11
  import { createClient } from '@libsql/client';
12
12
  import { startHranaServer, stopHranaServer } from './hrana-server.js';
13
13
  import { chooseLockPort } from './test-utils.js';
@@ -27,6 +27,101 @@ describe('getDb', () => {
27
27
  ].map((match) => match[1]);
28
28
  expect(new Set(tablesFromSql)).toEqual(new Set(tablesFromTs));
29
29
  expect(tablesFromSql).toContain('session_sleeps');
30
+ expect(tablesFromSql).toContain('thread_queue_items');
31
+ });
32
+ test('persists local queue items in FIFO order', async () => {
33
+ const db = await getDb();
34
+ const threadId = `test-queue-thread-${crypto.randomUUID()}`;
35
+ const firstId = `queue-${crypto.randomUUID()}`;
36
+ const secondId = `queue-${crypto.randomUUID()}`;
37
+ await insertThreadQueueItem({
38
+ queueId: firstId,
39
+ threadId,
40
+ payloadJson: JSON.stringify({ prompt: 'first', userId: '1', username: 'tommy' }),
41
+ });
42
+ await insertThreadQueueItem({
43
+ queueId: secondId,
44
+ threadId,
45
+ payloadJson: JSON.stringify({ prompt: 'second', userId: '1', username: 'tommy' }),
46
+ });
47
+ const rows = await listThreadQueueItems(threadId);
48
+ expect(rows.map((row) => JSON.parse(row.payload_json).prompt)).toEqual([
49
+ 'first',
50
+ 'second',
51
+ ]);
52
+ await updateThreadQueueItemPayload({
53
+ queueId: firstId,
54
+ payloadJson: JSON.stringify({ prompt: 'first-edited', userId: '1', username: 'tommy' }),
55
+ });
56
+ await deleteThreadQueueItem(secondId);
57
+ const after = await listAllThreadQueueItems();
58
+ const remaining = after.filter((row) => row.thread_id === threadId);
59
+ expect(remaining).toHaveLength(1);
60
+ expect(JSON.parse(remaining[0].payload_json).prompt).toBe('first-edited');
61
+ await db.delete(schema.thread_queue_items).where(orm.eq(schema.thread_queue_items.thread_id, threadId));
62
+ });
63
+ test('rebuilds thread_queue_items that still use queue_id as the primary key', async () => {
64
+ await closeDb();
65
+ const previousDbUrl = process.env['KIMAKI_DB_URL'];
66
+ const dbPath = path.join(process.cwd(), `tmp/test-db-legacy-queue-${crypto.randomUUID().slice(0, 8)}.db`);
67
+ try {
68
+ const client = createClient({ url: `file:${dbPath}` });
69
+ await client.execute(`
70
+ CREATE TABLE thread_queue_items (
71
+ queue_id text PRIMARY KEY,
72
+ thread_id text NOT NULL,
73
+ payload_json text NOT NULL,
74
+ created_at datetime DEFAULT CURRENT_TIMESTAMP
75
+ )
76
+ `);
77
+ await client.execute(`
78
+ CREATE INDEX thread_queue_items_thread_id_created_at_queue_id_idx
79
+ ON thread_queue_items (thread_id, created_at, queue_id)
80
+ `);
81
+ await client.execute(`
82
+ INSERT INTO thread_queue_items (queue_id, thread_id, payload_json, created_at)
83
+ VALUES
84
+ ('queue-second', 'thr-legacy-queue', '{"prompt":"second"}', '2026-09-17 10:00:02'),
85
+ ('queue-first', 'thr-legacy-queue', '{"prompt":"first"}', '2026-09-17 10:00:01')
86
+ `);
87
+ client.close();
88
+ process.env['KIMAKI_DB_URL'] = `file:${dbPath}`;
89
+ await getDb();
90
+ const rows = await listThreadQueueItems('thr-legacy-queue');
91
+ expect(rows.map((row) => JSON.parse(row.payload_json).prompt)).toEqual([
92
+ 'first',
93
+ 'second',
94
+ ]);
95
+ expect(rows.map((row) => row.id)).toEqual([1, 2]);
96
+ await insertThreadQueueItem({
97
+ queueId: 'queue-third',
98
+ threadId: 'thr-legacy-queue',
99
+ payloadJson: JSON.stringify({ prompt: 'third' }),
100
+ });
101
+ const after = await listThreadQueueItems('thr-legacy-queue');
102
+ expect(after.map((row) => JSON.parse(row.payload_json).prompt)).toEqual([
103
+ 'first',
104
+ 'second',
105
+ 'third',
106
+ ]);
107
+ }
108
+ finally {
109
+ await closeDb();
110
+ if (previousDbUrl === undefined) {
111
+ delete process.env['KIMAKI_DB_URL'];
112
+ }
113
+ else {
114
+ process.env['KIMAKI_DB_URL'] = previousDbUrl;
115
+ }
116
+ for (const file of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
117
+ try {
118
+ fs.unlinkSync(file);
119
+ }
120
+ catch {
121
+ // Test cleanup best effort.
122
+ }
123
+ }
124
+ }
30
125
  });
31
126
  test('removes part_messages rows whose thread_sessions parent is gone', async () => {
32
127
  await closeDb();
@@ -97,11 +97,12 @@ for (const setup of ['legacy mapping', 'explicit onboarding']) {
97
97
  const guild = client.guilds.cache.get(trustedGuildId);
98
98
  if (setup === 'legacy mapping') {
99
99
  await setChannelDirectory({ channelId: projectChannelId, directory, channelType: 'text' });
100
+ expect((await findChannelsByDirectory({}))[0]?.guild_id).toBeNull();
100
101
  }
101
102
  else {
102
103
  await createProjectChannels({ guild, projectDirectory: path.join(directory, 'project'), analyticsSource: 'onboarding' });
104
+ expect((await findChannelsByDirectory({}))[0]?.guild_id).toBe(trustedGuildId);
103
105
  }
104
- expect((await findChannelsByDirectory({}))[0]?.guild_id).toBeNull();
105
106
  // Fetching actual guild channels must work even when startup cache is empty.
106
107
  guild.channels.cache.clear();
107
108
  const created = await ensureDefaultChannelsWithWelcome({
@@ -30,7 +30,7 @@ import { cancelHtmlActionsForThread } from './html-actions.js';
30
30
  import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
31
31
  import { voiceConnections, cleanupVoiceConnection, registerVoiceStateHandler, } from './voice-handler.js';
32
32
  import {} from './session-handler/model-utils.js';
33
- import { getRuntime, getOrCreateRuntime, disposeRuntime, reserveThreadIngress, runInThreadIngressSlot, } from './session-handler/thread-session-runtime.js';
33
+ import { getRuntime, getOrCreateRuntime, disposeRuntime, restorePersistedLocalQueues, reserveThreadIngress, runInThreadIngressSlot, } from './session-handler/thread-session-runtime.js';
34
34
  import { runShellCommand } from './commands/run-command.js';
35
35
  import { registerInteractionHandler } from './interaction-handler.js';
36
36
  import { getDiscordRestApiUrl } from './discord-urls.js';
@@ -214,6 +214,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
214
214
  registerInteractionHandler({ discordClient: c, appId: currentAppId });
215
215
  registerVoiceStateHandler({ discordClient: c, appId: currentAppId });
216
216
  startExternalOpencodeSessionSync({ discordClient: c });
217
+ await restorePersistedLocalQueues({
218
+ discordClient: c,
219
+ appId: currentAppId,
220
+ }).catch((error) => {
221
+ discordLogger.warn(`Failed to restore persisted local queues: ${error instanceof Error ? error.stack : String(error)}`);
222
+ });
217
223
  // Channel logging is informational only; do it in background so startup stays responsive.
218
224
  void (async () => {
219
225
  for (const guild of c.guilds.cache.values()) {
@@ -896,7 +902,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
896
902
  const { prompt, forceQueue } = extractQueueSuffix(resolveMentions(message));
897
903
  // If the edit removed the queue suffix, remove the item from the queue.
898
904
  // If the suffix is still present, update the prompt.
899
- const result = runtime.updateQueuedMessage(message.id, forceQueue ? prompt : '');
905
+ const result = await runtime.updateQueuedMessage(message.id, forceQueue ? prompt : '');
900
906
  if (result.found && channel.isThread()) {
901
907
  const displayName = message.member?.displayName ?? message.author.displayName;
902
908
  if (result.removed) {
@@ -924,7 +930,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
924
930
  const runtime = getRuntime(channel.id);
925
931
  if (!runtime)
926
932
  return;
927
- const removed = runtime.removeQueuedMessage(message.id);
933
+ const removed = await runtime.removeQueuedMessage(message.id);
928
934
  if (!removed)
929
935
  return;
930
936
  discordLogger.log(`[MESSAGE_DELETE] Removed queued message ${message.id} in thread ${channel.id}`);
@@ -335,7 +335,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
335
335
  .toJSON(),
336
336
  new SlashCommandBuilder()
337
337
  .setName('session-id')
338
- .setDescription(truncateCommandDescription('Show current session ID and opencode attach command for this thread'))
338
+ .setDescription(truncateCommandDescription('Show current session ID, Discord thread ID, and opencode attach command for this thread'))
339
339
  .setDMPermission(false)
340
340
  .toJSON(),
341
341
  new SlashCommandBuilder()