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
@@ -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, 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,7 +263,14 @@ 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 });
268
+ await restorePersistedLocalQueues({
269
+ discordClient: c,
270
+ appId: currentAppId,
271
+ }).catch((error) => {
272
+ discordLogger.warn(`Failed to restore persisted local queues: ${error instanceof Error ? error.stack : String(error)}`);
273
+ });
217
274
  // Channel logging is informational only; do it in background so startup stays responsive.
218
275
  void (async () => {
219
276
  for (const guild of c.guilds.cache.values()) {
@@ -230,18 +287,15 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
230
287
  discordLogger.warn(`Background guild channel scan failed: ${error instanceof Error ? error.stack : String(error)}`);
231
288
  });
232
289
  };
233
- // If client is already ready (was logged in before being passed to us),
234
- // run setup immediately. Otherwise wait for the ClientReady event.
235
- if (discordClient.isReady()) {
236
- await setupHandlers(discordClient);
237
- }
238
- else {
239
- discordClient.once(Events.ClientReady, (readyClient) => {
240
- void setupHandlers(readyClient).catch((error) => {
241
- 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);
242
297
  });
243
298
  });
244
- }
245
299
  discordClient.on(Events.Error, (error) => {
246
300
  discordLogger.error('[GATEWAY] Client error:', formatErrorWithStack(error));
247
301
  });
@@ -688,7 +742,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
688
742
  });
689
743
  // Notify when a voice message was queued instead of sent immediately
690
744
  if (enqueueResult.queued && enqueueResult.position) {
691
- 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`));
692
746
  }
693
747
  }
694
748
  if (channel.type === ChannelType.GuildText) {
@@ -896,7 +950,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
896
950
  const { prompt, forceQueue } = extractQueueSuffix(resolveMentions(message));
897
951
  // If the edit removed the queue suffix, remove the item from the queue.
898
952
  // If the suffix is still present, update the prompt.
899
- const result = runtime.updateQueuedMessage(message.id, forceQueue ? prompt : '');
953
+ const result = await runtime.updateQueuedMessage(message.id, forceQueue ? prompt : '');
900
954
  if (result.found && channel.isThread()) {
901
955
  const displayName = message.member?.displayName ?? message.author.displayName;
902
956
  if (result.removed) {
@@ -924,7 +978,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
924
978
  const runtime = getRuntime(channel.id);
925
979
  if (!runtime)
926
980
  return;
927
- const removed = runtime.removeQueuedMessage(message.id);
981
+ const removed = await runtime.removeQueuedMessage(message.id);
928
982
  if (!removed)
929
983
  return;
930
984
  discordLogger.log(`[MESSAGE_DELETE] Removed queued message ${message.id} in thread ${channel.id}`);
@@ -1144,25 +1198,15 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1144
1198
  // Dispose runtime when a thread is deleted so memory is freed immediately
1145
1199
  // instead of waiting for the idle sweeper (1 hour default).
1146
1200
  discordClient.on(Events.ThreadDelete, (thread) => {
1147
- 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
+ });
1148
1204
  });
1149
- // Clean up SQLite when a Discord channel is deleted so project list
1150
- // doesn't show stale ghost entries. Thread runtimes inside the deleted
1151
- // 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.
1152
1207
  discordClient.on(Events.ChannelDelete, async (channel) => {
1153
1208
  try {
1154
- // Check if this is the default kimaki channel. If so, preserve the
1155
- // channel_directories row as a tombstone so we don't recreate it.
1156
- const mapping = await getChannelDirectory(channel.id);
1157
- const defaultDir = getDefaultKimakiDirectory();
1158
- if (mapping && mapping.directory === defaultDir) {
1159
- discordLogger.log(`Preserving channel_directories row for deleted default channel ${channel.id} as tombstone`);
1160
- return;
1161
- }
1162
- const deleted = await deleteChannelDirectoryById(channel.id);
1163
- if (deleted) {
1164
- discordLogger.log(`Cleaned up channel_directories for deleted channel ${channel.id}`);
1165
- }
1209
+ await cleanupDeletedDiscordChannel(channel.id);
1166
1210
  }
1167
1211
  catch (error) {
1168
1212
  notifyError(error instanceof Error ? error : new Error(String(error)), `Failed to clean up channel_directories for deleted channel ${channel.id}`);
@@ -1174,10 +1218,19 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1174
1218
  if (!discordClient.isReady()) {
1175
1219
  await discordClient.login(token);
1176
1220
  }
1221
+ await setupPromise;
1222
+ // A stop signal during setup: do not start workers against closing resources.
1223
+ if (global.shuttingDown) {
1224
+ return;
1225
+ }
1177
1226
  startHeapMonitor();
1178
1227
  startStdinCpuProfListener();
1179
1228
  const stopTaskRunner = startTaskRunner({ token });
1180
1229
  const stopRuntimeIdleSweeper = startRuntimeIdleSweeper();
1230
+ stopBackgroundWorkers = async () => {
1231
+ await stopRuntimeIdleSweeper();
1232
+ await stopTaskRunner();
1233
+ };
1181
1234
  // Prevent discord.js from permanently killing the REST token on 401.
1182
1235
  // @discordjs/rest calls setToken(null) whenever it receives a 401 response.
1183
1236
  // The gateway proxy now returns 503 for stale-DB rejections (not 401), but
@@ -1192,117 +1245,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1192
1245
  }
1193
1246
  return originalSetToken(newToken);
1194
1247
  };
1195
- const handleShutdown = async (signal, { skipExit = false } = {}) => {
1196
- discordLogger.log(`Received ${signal}, cleaning up...`);
1197
- if (global.shuttingDown) {
1198
- discordLogger.log('Already shutting down, ignoring duplicate signal');
1199
- return;
1200
- }
1201
- global.shuttingDown = true;
1202
- try {
1203
- stopStdinCpuProfListener();
1204
- const flushed = await flushCpuProfiling();
1205
- if (flushed instanceof Error) {
1206
- discordLogger.warn('Failed to flush CPU profile on shutdown:', flushed.message);
1207
- }
1208
- await stopRuntimeIdleSweeper();
1209
- await stopTaskRunner();
1210
- await flushDebouncedProcessCallbacks().catch((error) => {
1211
- discordLogger.warn('Failed to flush debounced process callbacks:', error instanceof Error ? error.stack : String(error));
1212
- });
1213
- await flushAnalytics();
1214
- // Cancel pending IPC requests so plugin tools don't hang
1215
- await cancelAllPendingIpcRequests().catch((e) => {
1216
- discordLogger.warn('Failed to cancel pending IPC requests:', e.message);
1217
- });
1218
- const cleanupPromises = [];
1219
- for (const [guildId] of voiceConnections) {
1220
- voiceLogger.log(`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`);
1221
- cleanupPromises.push(cleanupVoiceConnection(guildId));
1222
- }
1223
- if (cleanupPromises.length > 0) {
1224
- voiceLogger.log(`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`);
1225
- await Promise.allSettled(cleanupPromises);
1226
- discordLogger.log(`All voice connections cleaned up`);
1227
- }
1228
- voiceLogger.log('[SHUTDOWN] Stopping OpenCode server');
1229
- stopExternalOpencodeSessionSync();
1230
- await stopOpencodeServer();
1231
- discordLogger.log('Closing database...');
1232
- await closeDatabase();
1233
- discordLogger.log('Stopping hrana server...');
1234
- await stopHranaServer();
1235
- discordLogger.log('Destroying Discord client...');
1236
- // Remove all listeners before destroy to prevent late-arriving shard
1237
- // errors (from pending DNS lookups, WebSocket frames) from becoming
1238
- // uncaught exceptions after the client's internal handlers are torn down.
1239
- discordClient.removeAllListeners();
1240
- void discordClient.destroy();
1241
- discordLogger.log('Cleanup complete.');
1242
- if (!skipExit) {
1243
- process.exit(0);
1244
- }
1245
- }
1246
- catch (error) {
1247
- voiceLogger.error('[SHUTDOWN] Error during cleanup:', error);
1248
- if (!skipExit) {
1249
- process.exit(1);
1250
- }
1251
- }
1252
- };
1253
- process.on('SIGTERM', async () => {
1254
- try {
1255
- await handleShutdown('SIGTERM');
1256
- }
1257
- catch (error) {
1258
- voiceLogger.error('[SIGTERM] Error during shutdown:', error);
1259
- process.exit(1);
1260
- }
1261
- });
1262
- process.on('SIGINT', async () => {
1263
- try {
1264
- await handleShutdown('SIGINT');
1265
- }
1266
- catch (error) {
1267
- voiceLogger.error('[SIGINT] Error during shutdown:', error);
1268
- process.exit(1);
1269
- }
1270
- });
1271
- process.on('SIGUSR1', () => {
1272
- discordLogger.log('Received SIGUSR1, writing heap snapshot...');
1273
- writeHeapSnapshot().catch((e) => {
1274
- discordLogger.error('Failed to write heap snapshot:', e instanceof Error ? e.message : String(e));
1275
- });
1276
- });
1277
- // Self-restart: die so the bin.ts wrapper restarts us with exponential
1278
- // backoff and crash-loop detection. process.exit() can hang joining native
1279
- // worker threads after Discord gateway failures, so SIGKILL instead.
1280
- // When running without the wrapper (e.g. `tsx src/cli.ts`), the process
1281
- // just dies — use `tsx src/bin.ts` for auto-restart support.
1282
- let selfRestarting = false;
1283
- async function selfRestart(reason) {
1284
- if (selfRestarting) {
1285
- discordLogger.log(`Self-restart already in progress, ignoring duplicate reason: ${reason}`);
1286
- return;
1287
- }
1288
- selfRestarting = true;
1289
- discordLogger.log(`Self-restarting (reason: ${reason})...`);
1290
- setTimeout(() => process.kill(process.pid, 'SIGKILL'), 15_000);
1291
- try {
1292
- await handleShutdown(reason, { skipExit: true });
1293
- }
1294
- catch (error) {
1295
- voiceLogger.error(`[${reason}] Error during shutdown:`, error);
1296
- }
1297
- if (!process.env.__KIMAKI_CHILD) {
1298
- discordLogger.warn('No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.');
1299
- }
1300
- process.kill(process.pid, 'SIGKILL');
1301
- }
1302
- process.on('SIGUSR2', () => {
1303
- discordLogger.log('Received SIGUSR2, restarting after cleanup...');
1304
- void selfRestart('SIGUSR2');
1305
- });
1306
1248
  process.on('uncaughtException', (error) => {
1307
1249
  // During self-restart or shutdown, discord.js can still fire errors from
1308
1250
  // pending async operations (DNS lookups, WebSocket frames) after the client
@@ -1314,9 +1256,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1314
1256
  }
1315
1257
  discordLogger.error('Uncaught exception:', formatErrorWithStack(error));
1316
1258
  notifyError(error, 'Uncaught exception in bot process');
1317
- void handleShutdown('uncaughtException', { skipExit: true }).catch((shutdownError) => {
1318
- discordLogger.error('[uncaughtException] shutdown failed:', formatErrorWithStack(shutdownError));
1319
- });
1259
+ void shutdownBot('uncaughtException', { skipExit: true });
1320
1260
  setTimeout(() => {
1321
1261
  process.exit(1);
1322
1262
  }, 250).unref();
@@ -1333,3 +1273,137 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1333
1273
  void notifyError(error, 'Unhandled rejection in bot process');
1334
1274
  });
1335
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
+ }
@@ -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()
@@ -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)
@@ -323,9 +323,9 @@ describeIf('gateway-proxy e2e', () => {
323
323
  "--- from: user (proxy-tester)
324
324
  hello from gateway proxy test
325
325
  --- from: assistant (TestBot)
326
- > *using deterministic-provider/deterministic-v2*
326
+ -# *using deterministic-provider/deterministic-v2*
327
327
  > gateway-proxy-reply
328
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@900000000000000001>"
328
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
329
329
  `);
330
330
  expect(reply).toBeDefined();
331
331
  expect(getMessageVisibleText(reply).trim().length).toBeGreaterThan(0);
@@ -350,14 +350,14 @@ describeIf('gateway-proxy e2e', () => {
350
350
  "--- from: user (proxy-tester)
351
351
  hello from gateway proxy test
352
352
  --- from: assistant (TestBot)
353
- > *using deterministic-provider/deterministic-v2*
353
+ -# *using deterministic-provider/deterministic-v2*
354
354
  > gateway-proxy-reply
355
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@900000000000000001>
355
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
356
356
  --- from: user (proxy-tester)
357
357
  follow up through proxy
358
358
  --- from: assistant (TestBot)
359
359
  > gateway-proxy-reply
360
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@900000000000000001>"
360
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
361
361
  `);
362
362
  expect(reply).toBeDefined();
363
363
  expect(getMessageVisibleText(reply).trim().length).toBeGreaterThan(0);
@@ -382,14 +382,14 @@ describeIf('gateway-proxy e2e', () => {
382
382
  "--- from: user (proxy-tester)
383
383
  hello from gateway proxy test
384
384
  --- from: assistant (TestBot)
385
- > *using deterministic-provider/deterministic-v2*
385
+ -# *using deterministic-provider/deterministic-v2*
386
386
  > gateway-proxy-reply
387
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@900000000000000001>
387
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
388
388
  --- from: user (proxy-tester)
389
389
  follow up through proxy
390
390
  --- from: assistant (TestBot)
391
391
  > gateway-proxy-reply
392
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@900000000000000001>
392
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
393
393
  --- from: user (proxy-tester)
394
394
  !echo proxy-shell-test
395
395
  --- from: assistant (TestBot)
@@ -417,7 +417,7 @@ describeIf('gateway-proxy e2e', () => {
417
417
  "--- from: user (proxy-tester)
418
418
  second message through proxy
419
419
  --- from: assistant (TestBot)
420
- > *using deterministic-provider/deterministic-v2*"
420
+ -# *using deterministic-provider/deterministic-v2*"
421
421
  `);
422
422
  expect(reply).toBeDefined();
423
423
  expect(getMessageVisibleText(reply).trim().length).toBeGreaterThan(0);