kimaki 0.29.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 (135) 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/bin.js +7 -3
  7. package/dist/bot-lifecycle.test.js +206 -0
  8. package/dist/cache-drift-plugin.js +25 -18
  9. package/dist/cache-rewrite.js +39 -0
  10. package/dist/cache-rewrite.test.js +41 -0
  11. package/dist/cli-commands/session.js +37 -6
  12. package/dist/cli-parsing.test.js +3 -3
  13. package/dist/cli-runner.js +11 -1
  14. package/dist/cli-send-thread.e2e.test.js +2 -2
  15. package/dist/cli.js +4 -4
  16. package/dist/commands/ask-question.js +72 -23
  17. package/dist/commands/queue.js +7 -7
  18. package/dist/database.js +73 -6
  19. package/dist/db.test.js +104 -1
  20. package/dist/discord-bot.js +212 -144
  21. package/dist/external-directory-permissions.e2e.test.js +3 -3
  22. package/dist/external-directory-project-deny.e2e.test.js +1 -1
  23. package/dist/forum-sync/watchers.js +15 -0
  24. package/dist/gateway-proxy.e2e.test.js +9 -9
  25. package/dist/hrana-server.js +68 -14
  26. package/dist/markdown.js +164 -102
  27. package/dist/markdown.test.js +88 -20
  28. package/dist/message-formatting.js +20 -2
  29. package/dist/message-formatting.test.js +20 -1
  30. package/dist/opencode.js +27 -12
  31. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  32. package/dist/question-abort-resume.e2e.test.js +100 -0
  33. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  34. package/dist/question-text-before-dropdown.e2e.test.js +1 -1
  35. package/dist/queue-advanced-abort.e2e.test.js +3 -3
  36. package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
  37. package/dist/queue-advanced-e2e-setup.js +63 -0
  38. package/dist/queue-advanced-footer.e2e.test.js +18 -18
  39. package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
  40. package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
  41. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  42. package/dist/queue-delete-message.e2e.test.js +4 -4
  43. package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
  44. package/dist/queue-interrupt-drain.e2e.test.js +10 -10
  45. package/dist/queue-question-select-drain.e2e.test.js +5 -5
  46. package/dist/runtime-lifecycle.e2e.test.js +95 -11
  47. package/dist/session-handler/event-stream-state.js +187 -1
  48. package/dist/session-handler/event-stream-state.test.js +764 -1
  49. package/dist/session-handler/thread-session-runtime.js +185 -16
  50. package/dist/sleep.e2e.test.js +7 -7
  51. package/dist/store.js +1 -1
  52. package/dist/system-message.js +9 -10
  53. package/dist/system-message.test.js +19 -10
  54. package/dist/task-runner.js +7 -0
  55. package/dist/task-schedule.test.js +1 -1
  56. package/dist/test-utils.js +5 -3
  57. package/dist/thread-message-queue.e2e.test.js +30 -31
  58. package/dist/undo-redo.e2e.test.js +3 -3
  59. package/dist/voice-message.e2e.test.js +64 -26
  60. package/dist/voice-routing-input.e2e.test.js +4 -4
  61. package/dist/voice-routing-question.e2e.test.js +4 -4
  62. package/dist/voice.js +107 -21
  63. package/dist/voice.test.js +52 -1
  64. package/dist/wait-session.js +24 -2
  65. package/dist/worktree-lifecycle.e2e.test.js +8 -8
  66. package/package.json +5 -5
  67. package/skills/egaki/SKILL.md +14 -0
  68. package/skills/holocron/SKILL.md +125 -42
  69. package/skills/opensrc/SKILL.md +20 -3
  70. package/skills/spiceflow/SKILL.md +53 -172
  71. package/src/agent-model.e2e.test.ts +33 -33
  72. package/src/anthropic-account-identity.ts +1 -1
  73. package/src/anthropic-auth-plugin.test.ts +37 -2
  74. package/src/anthropic-auth-plugin.ts +9 -7
  75. package/src/anthropic-auth-state.ts +1 -0
  76. package/src/bin.ts +7 -3
  77. package/src/bot-lifecycle.test.ts +252 -0
  78. package/src/cache-drift-plugin.ts +34 -24
  79. package/src/cache-rewrite.test.ts +49 -0
  80. package/src/cache-rewrite.ts +96 -0
  81. package/src/cli-commands/session.ts +48 -8
  82. package/src/cli-parsing.test.ts +3 -3
  83. package/src/cli-runner.ts +11 -0
  84. package/src/cli-send-thread.e2e.test.ts +2 -2
  85. package/src/cli.ts +6 -6
  86. package/src/commands/ask-question.ts +88 -25
  87. package/src/commands/queue.ts +7 -7
  88. package/src/database.ts +105 -6
  89. package/src/db.test.ts +124 -0
  90. package/src/discord-bot.ts +261 -183
  91. package/src/external-directory-permissions.e2e.test.ts +3 -3
  92. package/src/external-directory-project-deny.e2e.test.ts +1 -1
  93. package/src/forum-sync/watchers.ts +20 -0
  94. package/src/gateway-proxy.e2e.test.ts +9 -9
  95. package/src/hrana-server.ts +96 -16
  96. package/src/markdown.test.ts +108 -22
  97. package/src/markdown.ts +216 -103
  98. package/src/message-formatting.test.ts +24 -1
  99. package/src/message-formatting.ts +21 -2
  100. package/src/opencode.ts +28 -13
  101. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  102. package/src/question-abort-resume.e2e.test.ts +121 -0
  103. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  104. package/src/question-text-before-dropdown.e2e.test.ts +1 -1
  105. package/src/queue-advanced-abort.e2e.test.ts +3 -3
  106. package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
  107. package/src/queue-advanced-e2e-setup.ts +65 -0
  108. package/src/queue-advanced-footer.e2e.test.ts +18 -18
  109. package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
  110. package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
  111. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  112. package/src/queue-delete-message.e2e.test.ts +4 -4
  113. package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
  114. package/src/queue-interrupt-drain.e2e.test.ts +10 -10
  115. package/src/queue-question-select-drain.e2e.test.ts +5 -5
  116. package/src/runtime-lifecycle.e2e.test.ts +104 -10
  117. package/src/session-handler/event-stream-state.test.ts +825 -0
  118. package/src/session-handler/event-stream-state.ts +265 -3
  119. package/src/session-handler/thread-session-runtime.ts +217 -14
  120. package/src/sleep.e2e.test.ts +7 -7
  121. package/src/store.ts +2 -2
  122. package/src/system-message.test.ts +20 -10
  123. package/src/system-message.ts +9 -10
  124. package/src/task-runner.ts +8 -0
  125. package/src/task-schedule.test.ts +1 -1
  126. package/src/test-utils.ts +5 -3
  127. package/src/thread-message-queue.e2e.test.ts +30 -31
  128. package/src/undo-redo.e2e.test.ts +3 -3
  129. package/src/voice-message.e2e.test.ts +70 -26
  130. package/src/voice-routing-input.e2e.test.ts +4 -4
  131. package/src/voice-routing-question.e2e.test.ts +4 -4
  132. package/src/voice.test.ts +56 -0
  133. package/src/voice.ts +135 -25
  134. package/src/wait-session.ts +25 -2
  135. package/src/worktree-lifecycle.e2e.test.ts +8 -8
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, deleteThreadQueueItem, getDueSessionSleeps, getSessionEventSnapshot, getSessionModel, getSessionSleep, insertThreadQueueItem, listAllThreadQueueItems, listThreadQueueItems, setSessionModel, updateThreadQueueItemPayload, upsertSessionSleep, } from './database.js';
10
+ import { appendSessionEventsSinceLastTimestamp, cleanupDeletedThread, completeIpcRequest, createIpcRequest, createScheduledTask, createPendingWorkspace, deleteChannelDirectoryById, deleteThreadQueueItem, getChannelDirectory, getDueSessionSleeps, getIpcRequestById, getScheduledTask, getSessionEventSnapshot, getSessionModel, getSessionSleep, getThreadSession, insertThreadQueueItem, listAllThreadQueueItems, listThreadQueueItems, setChannelDirectory, setChannelVerbosity, setSessionModel, setThreadSession, 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';
@@ -60,6 +60,109 @@ describe('getDb', () => {
60
60
  expect(JSON.parse(remaining[0].payload_json).prompt).toBe('first-edited');
61
61
  await db.delete(schema.thread_queue_items).where(orm.eq(schema.thread_queue_items.thread_id, threadId));
62
62
  });
63
+ test('cancels channel tasks before deleting a channel mapping', async () => {
64
+ const channelId = `channel-${crypto.randomUUID()}`;
65
+ await setChannelDirectory({
66
+ channelId,
67
+ directory: `/tmp/${channelId}`,
68
+ channelType: 'text',
69
+ });
70
+ await setChannelVerbosity(channelId, 'text_only');
71
+ const taskId = await createScheduledTask({
72
+ scheduleKind: 'at',
73
+ nextRunAt: new Date('2099-01-01T00:00:00.000Z'),
74
+ payloadJson: JSON.stringify({ kind: 'channel', channelId, prompt: 'later' }),
75
+ promptPreview: 'later',
76
+ channelId,
77
+ });
78
+ expect(await deleteChannelDirectoryById(channelId)).toBe(true);
79
+ const db = await getDb();
80
+ expect({
81
+ mapping: await getChannelDirectory(channelId),
82
+ task: await getScheduledTask(taskId),
83
+ verbosity: await db.query.channel_verbosity.findFirst({
84
+ where: { channel_id: channelId },
85
+ }),
86
+ }).toMatchObject({
87
+ mapping: undefined,
88
+ task: {
89
+ status: 'cancelled',
90
+ last_error: 'Discord channel was deleted',
91
+ },
92
+ verbosity: undefined,
93
+ });
94
+ });
95
+ test('cleans pending work when a Discord thread is deleted', async () => {
96
+ const threadId = `thread-${crypto.randomUUID()}`;
97
+ const sessionId = `session-${crypto.randomUUID()}`;
98
+ const queueId = `queue-${crypto.randomUUID()}`;
99
+ await setThreadSession(threadId, sessionId);
100
+ await insertThreadQueueItem({
101
+ queueId,
102
+ threadId,
103
+ payloadJson: JSON.stringify({ prompt: 'queued' }),
104
+ });
105
+ await upsertSessionSleep({
106
+ sessionId,
107
+ wakeAt: new Date('2099-01-01T00:00:00.000Z'),
108
+ reason: 'later',
109
+ });
110
+ const ipcRequest = await createIpcRequest({
111
+ type: 'file_upload',
112
+ sessionId,
113
+ threadId,
114
+ payload: '{}',
115
+ });
116
+ const taskId = await createScheduledTask({
117
+ scheduleKind: 'at',
118
+ nextRunAt: new Date('2099-01-01T00:00:00.000Z'),
119
+ payloadJson: JSON.stringify({ kind: 'thread', threadId, prompt: 'later' }),
120
+ promptPreview: 'later',
121
+ threadId,
122
+ sessionId,
123
+ });
124
+ await cleanupDeletedThread(threadId);
125
+ await completeIpcRequest({ id: ipcRequest.id, response: 'late response' });
126
+ expect({
127
+ ipc: await getIpcRequestById({ id: ipcRequest.id }),
128
+ queue: await listThreadQueueItems(threadId),
129
+ session: await getThreadSession(threadId),
130
+ sleep: await getSessionSleep({ sessionId }),
131
+ task: await getScheduledTask(taskId),
132
+ }).toMatchObject({
133
+ ipc: { status: 'cancelled' },
134
+ queue: [],
135
+ session: sessionId,
136
+ sleep: { status: 'cancelled' },
137
+ task: {
138
+ status: 'cancelled',
139
+ last_error: 'Discord thread was deleted',
140
+ },
141
+ });
142
+ });
143
+ test('deleting an old resumed thread preserves the current session sleep', async () => {
144
+ const db = await getDb();
145
+ const sessionId = `resumed-session-${crypto.randomUUID()}`;
146
+ const oldThreadId = `old-thread-${crypto.randomUUID()}`;
147
+ const currentThreadId = `current-thread-${crypto.randomUUID()}`;
148
+ await setThreadSession(oldThreadId, sessionId);
149
+ await setThreadSession(currentThreadId, sessionId);
150
+ await db.update(schema.thread_sessions)
151
+ .set({ updated_at: new Date('2020-01-01T00:00:00.000Z') })
152
+ .where(orm.eq(schema.thread_sessions.thread_id, oldThreadId));
153
+ await db.update(schema.thread_sessions)
154
+ .set({ updated_at: new Date('2021-01-01T00:00:00.000Z') })
155
+ .where(orm.eq(schema.thread_sessions.thread_id, currentThreadId));
156
+ await upsertSessionSleep({
157
+ sessionId,
158
+ wakeAt: new Date('2099-01-01T00:00:00.000Z'),
159
+ reason: 'still current',
160
+ });
161
+ await cleanupDeletedThread(oldThreadId);
162
+ expect(await getSessionSleep({ sessionId })).toMatchObject({
163
+ status: 'planned',
164
+ });
165
+ });
63
166
  test('rebuilds thread_queue_items that still use queue_id as the primary key', async () => {
64
167
  await closeDb();
65
168
  const previousDbUrl = process.env['KIMAKI_DB_URL'];
@@ -8,12 +8,12 @@
8
8
  // suppresses these when shuttingDown is already set, and we removeAllListeners()
9
9
  // before destroying the client to prevent late events from becoming uncaught.
10
10
  import { DiscordOperationError } from './errors.js';
11
- import { initDatabase, closeDatabase, getThreadWorktreeOrWorkspace, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, cancelAllPendingIpcRequests, consumeSessionSleepWake, deleteChannelDirectoryById, createPendingWorkspace, setWorkspaceReady, } from './database.js';
11
+ import { initDatabase, closeDatabase, getThreadWorktreeOrWorkspace, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, cancelAllPendingIpcRequests, cleanupDeletedThread, clearGuildCategoryByChannelId, consumeSessionSleepWake, deleteForumSyncConfig, deleteChannelDirectoryById, findChannelsByDirectory, isCurrentThreadSessionBinding, createPendingWorkspace, setWorkspaceReady, } from './database.js';
12
12
  import { stopOpencodeServer, } from './opencode.js';
13
13
  import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
14
14
  import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js';
15
15
  import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
16
- import { STATUS_PREFIX } from './message-formatting.js';
16
+ import { asSystemLine, STATUS_PREFIX } from './message-formatting.js';
17
17
  import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, resolveGuildMessageMember, } from './discord-utils.js';
18
18
  import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
19
19
  import YAML from 'yaml';
@@ -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, restorePersistedLocalQueues, reserveThreadIngress, runInThreadIngressSlot, } from './session-handler/thread-session-runtime.js';
33
+ import { getRuntime, getOrCreateRuntime, disposeRuntime, getRuntimeThreadIdsForChannel, 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';
@@ -42,6 +42,7 @@ import { startRuntimeIdleSweeper } from './runtime-idle-sweeper.js';
42
42
  import { getDefaultKimakiDirectory, getUserProjectCount, } from './channel-management.js';
43
43
  import { store } from './store.js';
44
44
  import { startExternalOpencodeSessionSync, stopExternalOpencodeSessionSync, } from './external-opencode-sync.js';
45
+ import { stopForumSyncForChannel } from './forum-sync/watchers.js';
45
46
  export { initDatabase, closeDatabase, getChannelDirectory, } from './database.js';
46
47
  export { initializeOpencodeForDirectory, assertCompatibleOpencodeVersion } from './opencode.js';
47
48
  export { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, } from './discord-utils.js';
@@ -182,7 +183,56 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
182
183
  discordClient = await createDiscordClient();
183
184
  }
184
185
  store.setState({ useWorktrees: Boolean(useWorktrees) });
186
+ activeDiscordClient = discordClient;
187
+ registerBotLifecycleHandlers();
185
188
  let currentAppId = appId;
189
+ const cleanupDeletedDiscordThread = async (threadId) => {
190
+ const abortActiveRun = await isCurrentThreadSessionBinding(threadId);
191
+ disposeRuntime(threadId, { abortActiveRun });
192
+ await cleanupDeletedThread(threadId);
193
+ };
194
+ const cleanupDeletedDiscordChannel = async (channelId) => {
195
+ const mapping = await getChannelDirectory(channelId);
196
+ const preserveMapping = mapping?.directory === getDefaultKimakiDirectory();
197
+ const threadIds = getRuntimeThreadIdsForChannel(channelId);
198
+ await Promise.all(threadIds.map(cleanupDeletedDiscordThread));
199
+ await stopForumSyncForChannel(channelId);
200
+ await clearGuildCategoryByChannelId(channelId);
201
+ if (currentAppId) {
202
+ await deleteForumSyncConfig({
203
+ appId: currentAppId,
204
+ forumChannelId: channelId,
205
+ });
206
+ }
207
+ const deleted = await deleteChannelDirectoryById(channelId, {
208
+ preserveMapping,
209
+ });
210
+ if (preserveMapping) {
211
+ discordLogger.log(`Preserved deleted default channel ${channelId} as a tombstone`);
212
+ return;
213
+ }
214
+ if (deleted) {
215
+ discordLogger.log(`Cleaned up deleted channel ${channelId}`);
216
+ }
217
+ };
218
+ const reconcileDeletedDiscordChannels = async (c) => {
219
+ const mappings = await findChannelsByDirectory({});
220
+ for (const mapping of mappings) {
221
+ const channel = await c.channels
222
+ .fetch(mapping.channel_id, { force: true })
223
+ .catch((error) => {
224
+ const code = error instanceof Error ? Reflect.get(error, 'code') : undefined;
225
+ const status = error instanceof Error ? Reflect.get(error, 'status') : undefined;
226
+ if (code === 10003 || status === 404)
227
+ return null;
228
+ discordLogger.warn(`Could not verify channel ${mapping.channel_id}; preserving its SQLite mapping: ${formatErrorWithStack(error)}`);
229
+ return undefined;
230
+ });
231
+ if (channel !== null)
232
+ continue;
233
+ await cleanupDeletedDiscordChannel(mapping.channel_id);
234
+ }
235
+ };
186
236
  const setupHandlers = async (c) => {
187
237
  discordLogger.log(`Discord bot logged in as ${c.user.tag}`);
188
238
  discordLogger.log(`Connected to ${c.guilds.cache.size} guild(s)`);
@@ -213,6 +263,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
213
263
  })();
214
264
  registerInteractionHandler({ discordClient: c, appId: currentAppId });
215
265
  registerVoiceStateHandler({ discordClient: c, appId: currentAppId });
266
+ await reconcileDeletedDiscordChannels(c);
216
267
  startExternalOpencodeSessionSync({ discordClient: c });
217
268
  await restorePersistedLocalQueues({
218
269
  discordClient: c,
@@ -236,18 +287,15 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
236
287
  discordLogger.warn(`Background guild channel scan failed: ${error instanceof Error ? error.stack : String(error)}`);
237
288
  });
238
289
  };
239
- // If client is already ready (was logged in before being passed to us),
240
- // run setup immediately. Otherwise wait for the ClientReady event.
241
- if (discordClient.isReady()) {
242
- await setupHandlers(discordClient);
243
- }
244
- else {
245
- discordClient.once(Events.ClientReady, (readyClient) => {
246
- void setupHandlers(readyClient).catch((error) => {
247
- discordLogger.error(`[GATEWAY] ClientReady handler failed: ${formatErrorWithStack(error)}`);
290
+ // Keep startup ordered so stale targets are removed before queues and
291
+ // scheduled tasks can deliver work to them.
292
+ const setupPromise = discordClient.isReady()
293
+ ? setupHandlers(discordClient)
294
+ : new Promise((resolve, reject) => {
295
+ discordClient.once(Events.ClientReady, (readyClient) => {
296
+ void setupHandlers(readyClient).then(resolve, reject);
248
297
  });
249
298
  });
250
- }
251
299
  discordClient.on(Events.Error, (error) => {
252
300
  discordLogger.error('[GATEWAY] Client error:', formatErrorWithStack(error));
253
301
  });
@@ -694,7 +742,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
694
742
  });
695
743
  // Notify when a voice message was queued instead of sent immediately
696
744
  if (enqueueResult.queued && enqueueResult.position) {
697
- await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`);
745
+ await sendThreadMessage(thread, asSystemLine(`Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`));
698
746
  }
699
747
  }
700
748
  if (channel.type === ChannelType.GuildText) {
@@ -1150,25 +1198,15 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1150
1198
  // Dispose runtime when a thread is deleted so memory is freed immediately
1151
1199
  // instead of waiting for the idle sweeper (1 hour default).
1152
1200
  discordClient.on(Events.ThreadDelete, (thread) => {
1153
- disposeRuntime(thread.id);
1201
+ void cleanupDeletedDiscordThread(thread.id).catch((error) => {
1202
+ notifyError(error instanceof Error ? error : new Error(String(error)), `Failed to clean up deleted thread ${thread.id}`);
1203
+ });
1154
1204
  });
1155
- // Clean up SQLite when a Discord channel is deleted so project list
1156
- // doesn't show stale ghost entries. Thread runtimes inside the deleted
1157
- // channel are disposed by their own ThreadDelete events from Discord.
1205
+ // Clean up SQLite and active child runtimes without relying on Discord to
1206
+ // send separate ThreadDelete events for every child.
1158
1207
  discordClient.on(Events.ChannelDelete, async (channel) => {
1159
1208
  try {
1160
- // Check if this is the default kimaki channel. If so, preserve the
1161
- // channel_directories row as a tombstone so we don't recreate it.
1162
- const mapping = await getChannelDirectory(channel.id);
1163
- const defaultDir = getDefaultKimakiDirectory();
1164
- if (mapping && mapping.directory === defaultDir) {
1165
- discordLogger.log(`Preserving channel_directories row for deleted default channel ${channel.id} as tombstone`);
1166
- return;
1167
- }
1168
- const deleted = await deleteChannelDirectoryById(channel.id);
1169
- if (deleted) {
1170
- discordLogger.log(`Cleaned up channel_directories for deleted channel ${channel.id}`);
1171
- }
1209
+ await cleanupDeletedDiscordChannel(channel.id);
1172
1210
  }
1173
1211
  catch (error) {
1174
1212
  notifyError(error instanceof Error ? error : new Error(String(error)), `Failed to clean up channel_directories for deleted channel ${channel.id}`);
@@ -1180,10 +1218,19 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1180
1218
  if (!discordClient.isReady()) {
1181
1219
  await discordClient.login(token);
1182
1220
  }
1221
+ await setupPromise;
1222
+ // A stop signal during setup: do not start workers against closing resources.
1223
+ if (global.shuttingDown) {
1224
+ return;
1225
+ }
1183
1226
  startHeapMonitor();
1184
1227
  startStdinCpuProfListener();
1185
1228
  const stopTaskRunner = startTaskRunner({ token });
1186
1229
  const stopRuntimeIdleSweeper = startRuntimeIdleSweeper();
1230
+ stopBackgroundWorkers = async () => {
1231
+ await stopRuntimeIdleSweeper();
1232
+ await stopTaskRunner();
1233
+ };
1187
1234
  // Prevent discord.js from permanently killing the REST token on 401.
1188
1235
  // @discordjs/rest calls setToken(null) whenever it receives a 401 response.
1189
1236
  // The gateway proxy now returns 503 for stale-DB rejections (not 401), but
@@ -1198,117 +1245,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1198
1245
  }
1199
1246
  return originalSetToken(newToken);
1200
1247
  };
1201
- const handleShutdown = async (signal, { skipExit = false } = {}) => {
1202
- discordLogger.log(`Received ${signal}, cleaning up...`);
1203
- if (global.shuttingDown) {
1204
- discordLogger.log('Already shutting down, ignoring duplicate signal');
1205
- return;
1206
- }
1207
- global.shuttingDown = true;
1208
- try {
1209
- stopStdinCpuProfListener();
1210
- const flushed = await flushCpuProfiling();
1211
- if (flushed instanceof Error) {
1212
- discordLogger.warn('Failed to flush CPU profile on shutdown:', flushed.message);
1213
- }
1214
- await stopRuntimeIdleSweeper();
1215
- await stopTaskRunner();
1216
- await flushDebouncedProcessCallbacks().catch((error) => {
1217
- discordLogger.warn('Failed to flush debounced process callbacks:', error instanceof Error ? error.stack : String(error));
1218
- });
1219
- await flushAnalytics();
1220
- // Cancel pending IPC requests so plugin tools don't hang
1221
- await cancelAllPendingIpcRequests().catch((e) => {
1222
- discordLogger.warn('Failed to cancel pending IPC requests:', e.message);
1223
- });
1224
- const cleanupPromises = [];
1225
- for (const [guildId] of voiceConnections) {
1226
- voiceLogger.log(`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`);
1227
- cleanupPromises.push(cleanupVoiceConnection(guildId));
1228
- }
1229
- if (cleanupPromises.length > 0) {
1230
- voiceLogger.log(`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`);
1231
- await Promise.allSettled(cleanupPromises);
1232
- discordLogger.log(`All voice connections cleaned up`);
1233
- }
1234
- voiceLogger.log('[SHUTDOWN] Stopping OpenCode server');
1235
- stopExternalOpencodeSessionSync();
1236
- await stopOpencodeServer();
1237
- discordLogger.log('Closing database...');
1238
- await closeDatabase();
1239
- discordLogger.log('Stopping hrana server...');
1240
- await stopHranaServer();
1241
- discordLogger.log('Destroying Discord client...');
1242
- // Remove all listeners before destroy to prevent late-arriving shard
1243
- // errors (from pending DNS lookups, WebSocket frames) from becoming
1244
- // uncaught exceptions after the client's internal handlers are torn down.
1245
- discordClient.removeAllListeners();
1246
- void discordClient.destroy();
1247
- discordLogger.log('Cleanup complete.');
1248
- if (!skipExit) {
1249
- process.exit(0);
1250
- }
1251
- }
1252
- catch (error) {
1253
- voiceLogger.error('[SHUTDOWN] Error during cleanup:', error);
1254
- if (!skipExit) {
1255
- process.exit(1);
1256
- }
1257
- }
1258
- };
1259
- process.on('SIGTERM', async () => {
1260
- try {
1261
- await handleShutdown('SIGTERM');
1262
- }
1263
- catch (error) {
1264
- voiceLogger.error('[SIGTERM] Error during shutdown:', error);
1265
- process.exit(1);
1266
- }
1267
- });
1268
- process.on('SIGINT', async () => {
1269
- try {
1270
- await handleShutdown('SIGINT');
1271
- }
1272
- catch (error) {
1273
- voiceLogger.error('[SIGINT] Error during shutdown:', error);
1274
- process.exit(1);
1275
- }
1276
- });
1277
- process.on('SIGUSR1', () => {
1278
- discordLogger.log('Received SIGUSR1, writing heap snapshot...');
1279
- writeHeapSnapshot().catch((e) => {
1280
- discordLogger.error('Failed to write heap snapshot:', e instanceof Error ? e.message : String(e));
1281
- });
1282
- });
1283
- // Self-restart: die so the bin.ts wrapper restarts us with exponential
1284
- // backoff and crash-loop detection. process.exit() can hang joining native
1285
- // worker threads after Discord gateway failures, so SIGKILL instead.
1286
- // When running without the wrapper (e.g. `tsx src/cli.ts`), the process
1287
- // just dies — use `tsx src/bin.ts` for auto-restart support.
1288
- let selfRestarting = false;
1289
- async function selfRestart(reason) {
1290
- if (selfRestarting) {
1291
- discordLogger.log(`Self-restart already in progress, ignoring duplicate reason: ${reason}`);
1292
- return;
1293
- }
1294
- selfRestarting = true;
1295
- discordLogger.log(`Self-restarting (reason: ${reason})...`);
1296
- setTimeout(() => process.kill(process.pid, 'SIGKILL'), 15_000);
1297
- try {
1298
- await handleShutdown(reason, { skipExit: true });
1299
- }
1300
- catch (error) {
1301
- voiceLogger.error(`[${reason}] Error during shutdown:`, error);
1302
- }
1303
- if (!process.env.__KIMAKI_CHILD) {
1304
- discordLogger.warn('No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.');
1305
- }
1306
- process.kill(process.pid, 'SIGKILL');
1307
- }
1308
- process.on('SIGUSR2', () => {
1309
- discordLogger.log('Received SIGUSR2, restarting after cleanup...');
1310
- void selfRestart('SIGUSR2');
1311
- });
1312
1248
  process.on('uncaughtException', (error) => {
1313
1249
  // During self-restart or shutdown, discord.js can still fire errors from
1314
1250
  // pending async operations (DNS lookups, WebSocket frames) after the client
@@ -1320,9 +1256,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1320
1256
  }
1321
1257
  discordLogger.error('Uncaught exception:', formatErrorWithStack(error));
1322
1258
  notifyError(error, 'Uncaught exception in bot process');
1323
- void handleShutdown('uncaughtException', { skipExit: true }).catch((shutdownError) => {
1324
- discordLogger.error('[uncaughtException] shutdown failed:', formatErrorWithStack(shutdownError));
1325
- });
1259
+ void shutdownBot('uncaughtException', { skipExit: true });
1326
1260
  setTimeout(() => {
1327
1261
  process.exit(1);
1328
1262
  }, 250).unref();
@@ -1339,3 +1273,137 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1339
1273
  void notifyError(error, 'Unhandled rejection in bot process');
1340
1274
  });
1341
1275
  }
1276
+ // ── Process lifecycle ────────────────────────────────────────────────
1277
+ // cli-runner installs these handlers right after binding the hrana lock port,
1278
+ // long before Discord is ready. Before this, SIGINT/SIGTERM during startup only
1279
+ // hit the opencode.ts fallback listener, which does not exit, so the process
1280
+ // kept the lock port forever and every next start failed with EADDRINUSE.
1281
+ // Every cleanup step below must therefore be safe at any startup phase.
1282
+ const SHUTDOWN_DEADLINE_MS = 15_000;
1283
+ let activeDiscordClient = null;
1284
+ let stopBackgroundWorkers = null;
1285
+ let selfRestarting = false;
1286
+ let lifecycleHandlersRegistered = false;
1287
+ async function shutdownBot(reason, { skipExit = false } = {}) {
1288
+ discordLogger.log(`Received ${reason}, cleaning up...`);
1289
+ if (global.shuttingDown) {
1290
+ discordLogger.log('Already shutting down, ignoring duplicate signal');
1291
+ return;
1292
+ }
1293
+ global.shuttingDown = true;
1294
+ // Cleanup or process.exit() can hang (native worker threads after gateway
1295
+ // failures). Never let a stuck shutdown hold the lock port. Kept ref'd so a
1296
+ // drained event loop cannot exit with code 0 and skip the wrapper restart.
1297
+ setTimeout(() => {
1298
+ process.kill(process.pid, 'SIGKILL');
1299
+ }, SHUTDOWN_DEADLINE_MS);
1300
+ try {
1301
+ stopStdinCpuProfListener();
1302
+ const flushed = await flushCpuProfiling();
1303
+ if (flushed instanceof Error) {
1304
+ discordLogger.warn('Failed to flush CPU profile on shutdown:', flushed.message);
1305
+ }
1306
+ await stopBackgroundWorkers?.();
1307
+ await flushDebouncedProcessCallbacks().catch((error) => {
1308
+ discordLogger.warn('Failed to flush debounced process callbacks:', error instanceof Error ? error.stack : String(error));
1309
+ });
1310
+ await flushAnalytics();
1311
+ // Cancel pending IPC requests so plugin tools don't hang
1312
+ await cancelAllPendingIpcRequests().catch((e) => {
1313
+ discordLogger.warn('Failed to cancel pending IPC requests:', e.message);
1314
+ });
1315
+ const cleanupPromises = [];
1316
+ for (const [guildId] of voiceConnections) {
1317
+ voiceLogger.log(`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`);
1318
+ cleanupPromises.push(cleanupVoiceConnection(guildId));
1319
+ }
1320
+ if (cleanupPromises.length > 0) {
1321
+ voiceLogger.log(`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`);
1322
+ await Promise.allSettled(cleanupPromises);
1323
+ discordLogger.log(`All voice connections cleaned up`);
1324
+ }
1325
+ voiceLogger.log('[SHUTDOWN] Stopping OpenCode server');
1326
+ stopExternalOpencodeSessionSync();
1327
+ await stopOpencodeServer();
1328
+ discordLogger.log('Closing database...');
1329
+ await closeDatabase();
1330
+ discordLogger.log('Stopping hrana server...');
1331
+ await stopHranaServer();
1332
+ if (activeDiscordClient) {
1333
+ discordLogger.log('Destroying Discord client...');
1334
+ // Remove all listeners before destroy to prevent late-arriving shard
1335
+ // errors (from pending DNS lookups, WebSocket frames) from becoming
1336
+ // uncaught exceptions after the client's internal handlers are torn down.
1337
+ activeDiscordClient.removeAllListeners();
1338
+ void activeDiscordClient.destroy();
1339
+ }
1340
+ discordLogger.log('Cleanup complete.');
1341
+ if (!skipExit) {
1342
+ process.exit(0);
1343
+ }
1344
+ }
1345
+ catch (error) {
1346
+ voiceLogger.error('[SHUTDOWN] Error during cleanup:', error);
1347
+ if (!skipExit) {
1348
+ process.exit(1);
1349
+ }
1350
+ }
1351
+ }
1352
+ // Self-restart: die so the bin.ts wrapper restarts us with exponential
1353
+ // backoff and crash-loop detection. process.exit() can hang joining native
1354
+ // worker threads after Discord gateway failures, so SIGKILL instead.
1355
+ // When running without the wrapper (e.g. `tsx src/cli.ts`), the process
1356
+ // just dies — use `tsx src/bin.ts` for auto-restart support.
1357
+ async function selfRestart(reason) {
1358
+ if (selfRestarting) {
1359
+ discordLogger.log(`Self-restart already in progress, ignoring duplicate reason: ${reason}`);
1360
+ return;
1361
+ }
1362
+ selfRestarting = true;
1363
+ discordLogger.log(`Self-restarting (reason: ${reason})...`);
1364
+ await shutdownBot(reason, { skipExit: true });
1365
+ if (!process.env.__KIMAKI_CHILD) {
1366
+ discordLogger.warn('No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.');
1367
+ }
1368
+ process.kill(process.pid, 'SIGKILL');
1369
+ }
1370
+ /** Idempotent. Call as soon as the process owns the lock port. */
1371
+ export function registerBotLifecycleHandlers() {
1372
+ if (lifecycleHandlersRegistered) {
1373
+ return;
1374
+ }
1375
+ lifecycleHandlersRegistered = true;
1376
+ // SIGHUP: closing the terminal must stop the bot, not leave it orphaned.
1377
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
1378
+ process.on(signal, () => {
1379
+ void shutdownBot(signal);
1380
+ });
1381
+ }
1382
+ process.on('SIGUSR1', () => {
1383
+ discordLogger.log('Received SIGUSR1, writing heap snapshot...');
1384
+ writeHeapSnapshot().catch((e) => {
1385
+ discordLogger.error('Failed to write heap snapshot:', e instanceof Error ? e.message : String(e));
1386
+ });
1387
+ });
1388
+ process.on('SIGUSR2', () => {
1389
+ discordLogger.log('Received SIGUSR2, restarting after cleanup...');
1390
+ void selfRestart('SIGUSR2');
1391
+ });
1392
+ // bin.ts spawns us with an IPC channel. The OS closes it when the wrapper
1393
+ // dies for any reason (including SIGKILL), so an orphaned child exits instead
1394
+ // of holding the lock port. Guarded by __KIMAKI_CHILD so vitest workers,
1395
+ // which also have an IPC channel, are not affected.
1396
+ if (!process.env.__KIMAKI_CHILD) {
1397
+ return;
1398
+ }
1399
+ // The wrapper can die before we get here (e.g. during the eviction wait);
1400
+ // 'disconnect' is not replayed for late listeners.
1401
+ if (!process.connected) {
1402
+ void shutdownBot('wrapper-disconnect');
1403
+ return;
1404
+ }
1405
+ process.on('disconnect', () => {
1406
+ void shutdownBot('wrapper-disconnect');
1407
+ });
1408
+ process.channel?.unref();
1409
+ }
@@ -64,13 +64,13 @@ describe('external directory permissions', () => {
64
64
  "--- from: user (external-directory-tester)
65
65
  EXTERNAL_DIRECTORY_PROBE_MARKER first
66
66
  --- from: assistant (TestBot)
67
- > *using deterministic-provider/deterministic-v2*
67
+ -# *using deterministic-provider/deterministic-v2*
68
68
  > reading external directory
69
69
 
70
70
  ┣ read *probe.txt*
71
71
 
72
72
  > external-directory-probe-done
73
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>
73
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
74
74
  --- from: user (external-directory-tester)
75
75
  EXTERNAL_DIRECTORY_PROBE_MARKER followup
76
76
  --- from: assistant (TestBot)
@@ -79,7 +79,7 @@ describe('external directory permissions', () => {
79
79
  ┣ read *probe.txt*
80
80
 
81
81
  > external-directory-probe-done
82
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
82
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
83
83
  `);
84
84
  expect(text).not.toContain('Permission Required');
85
85
  });
@@ -54,7 +54,7 @@ describe('external directory project deny', () => {
54
54
  "--- from: user (external-directory-deny-tester)
55
55
  EXTERNAL_DIRECTORY_PROBE_MARKER denied
56
56
  --- from: assistant (TestBot)
57
- > *using deterministic-provider/deterministic-v2*
57
+ -# *using deterministic-provider/deterministic-v2*
58
58
  > reading external directory
59
59
 
60
60
  ┣ read *probe.txt*
@@ -251,6 +251,21 @@ export async function stopConfiguredForumSync() {
251
251
  });
252
252
  }));
253
253
  }
254
+ export async function stopForumSyncForChannel(forumChannelId) {
255
+ const runtimeState = forumStateById.get(forumChannelId);
256
+ if (runtimeState?.discordDebounceTimer) {
257
+ clearTimeout(runtimeState.discordDebounceTimer);
258
+ }
259
+ if (runtimeState?.fileDebounceTimer) {
260
+ clearTimeout(runtimeState.fileDebounceTimer);
261
+ }
262
+ forumStateById.delete(forumChannelId);
263
+ const unsubscribe = watcherUnsubscribeByForumId.get(forumChannelId);
264
+ watcherUnsubscribeByForumId.delete(forumChannelId);
265
+ await unsubscribe?.().catch((cause) => {
266
+ forumLogger.warn(`Failed to unsubscribe deleted forum ${forumChannelId}:`, cause);
267
+ });
268
+ }
254
269
  export async function startConfiguredForumSync({ discordClient, appId, }) {
255
270
  const loadedConfig = await readForumSyncConfig({ appId });
256
271
  if (loadedConfig instanceof Error)