kimaki 0.2.0 → 0.3.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.
@@ -18,6 +18,10 @@ import { transcribeAudio } from './voice.js';
18
18
  import { extractTagsArrays, extractNonXmlContent } from './xml.js';
19
19
  import prettyMilliseconds from 'pretty-ms';
20
20
  import { createLogger } from './logger.js';
21
+ import { isAbortError } from './utils.js';
22
+ import { setGlobalDispatcher, Agent } from 'undici';
23
+ // disables the automatic 5 minutes abort after no body
24
+ setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }));
21
25
  const discordLogger = createLogger('DISCORD');
22
26
  const voiceLogger = createLogger('VOICE');
23
27
  const opencodeLogger = createLogger('OPENCODE');
@@ -27,11 +31,8 @@ const dbLogger = createLogger('DB');
27
31
  const opencodeServers = new Map();
28
32
  // Map of session ID to current AbortController
29
33
  const abortControllers = new Map();
30
- // Map of guild ID to voice connection
34
+ // Map of guild ID to voice connection and GenAI worker
31
35
  const voiceConnections = new Map();
32
- // Map of channel ID to GenAI worker and session state
33
- // This allows sessions to persist across voice channel changes
34
- const genAiSessions = new Map();
35
36
  // Map of directory to retry count for server restarts
36
37
  const serverRetryCount = new Map();
37
38
  let db = null;
@@ -82,7 +83,7 @@ async function createUserAudioLogStream(guildId, channelId) {
82
83
  }
83
84
  }
84
85
  // Set up voice handling for a connection (called once per connection)
85
- async function setupVoiceHandling({ connection, guildId, channelId, appId, cleanupGenAiSession, }) {
86
+ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
86
87
  voiceLogger.log(`Setting up voice handling for guild ${guildId}, channel ${channelId}`);
87
88
  // Check if this voice channel has an associated directory
88
89
  const channelDirRow = getDatabase()
@@ -102,28 +103,11 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, clean
102
103
  }
103
104
  // Create user audio stream for debugging
104
105
  voiceData.userAudioStream = await createUserAudioLogStream(guildId, channelId);
105
- // Check if we already have a GenAI session for this channel
106
- const existingSession = genAiSessions.get(channelId);
107
- if (existingSession) {
108
- voiceLogger.log(`Reusing existing GenAI session for channel ${channelId}`);
109
- // Cancel any pending cleanup since user has returned
110
- if (existingSession.pendingCleanup) {
111
- voiceLogger.log(`Cancelling pending cleanup for channel ${channelId} - user returned`);
112
- existingSession.pendingCleanup = false;
113
- if (existingSession.cleanupTimer) {
114
- clearTimeout(existingSession.cleanupTimer);
115
- existingSession.cleanupTimer = undefined;
116
- }
117
- }
118
- // Session already exists, just update the voice handling
119
- return;
120
- }
121
106
  // Get API keys from database
122
107
  const apiKeys = getDatabase()
123
108
  .prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
124
109
  .get(appId);
125
- // Track if sessions are active
126
- let hasActiveSessions = false;
110
+ // Create GenAI worker
127
111
  const genAiWorker = await createGenAIWorker({
128
112
  directory,
129
113
  guildId,
@@ -191,75 +175,28 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, clean
191
175
  genAiWorker.interrupt();
192
176
  connection.setSpeaking(false);
193
177
  },
194
- onAllSessionsCompleted() {
195
- // All OpenCode sessions have completed
196
- hasActiveSessions = false;
197
- voiceLogger.log('All OpenCode sessions completed for this GenAI session');
198
- // Update the stored session state
199
- const session = genAiSessions.get(channelId);
200
- if (session) {
201
- session.hasActiveSessions = false;
202
- // If cleanup is pending (user left channel), schedule cleanup with grace period
203
- if (session.pendingCleanup) {
204
- voiceLogger.log(`Scheduling cleanup for channel ${channelId} in 1 minute`);
205
- // Clear any existing timer
206
- if (session.cleanupTimer) {
207
- clearTimeout(session.cleanupTimer);
208
- }
209
- // Schedule cleanup after 1 minute grace period
210
- session.cleanupTimer = setTimeout(() => {
211
- // Double-check that cleanup is still needed
212
- const currentSession = genAiSessions.get(channelId);
213
- if (currentSession?.pendingCleanup &&
214
- !currentSession.hasActiveSessions) {
215
- voiceLogger.log(`Grace period expired, cleaning up GenAI session for channel ${channelId}`);
216
- // Use the main cleanup function - defined later in startDiscordBot
217
- cleanupGenAiSession(channelId);
218
- }
219
- }, 60000); // 1 minute
220
- }
221
- }
222
- },
223
178
  onToolCallCompleted(params) {
224
- // Note: We now track at the tools.ts level, but still handle completion messages
225
- voiceLogger.log(`OpenCode session ${params.sessionId} completed`);
226
179
  const text = params.error
227
180
  ? `<systemMessage>\nThe coding agent encountered an error while processing session ${params.sessionId}: ${params.error?.message || String(params.error)}\n</systemMessage>`
228
181
  : `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`;
229
182
  genAiWorker.sendTextInput(text);
230
- // Mark that we have active sessions (will be updated by onAllSessionsCompleted when done)
231
- hasActiveSessions = true;
232
- const session = genAiSessions.get(channelId);
233
- if (session) {
234
- session.hasActiveSessions = true;
235
- }
236
183
  },
237
184
  onError(error) {
238
185
  voiceLogger.error('GenAI worker error:', error);
239
186
  },
240
187
  });
188
+ // Stop any existing GenAI worker before storing new one
189
+ if (voiceData.genAiWorker) {
190
+ voiceLogger.log('Stopping existing GenAI worker before creating new one');
191
+ await voiceData.genAiWorker.stop();
192
+ }
241
193
  // Send initial greeting
242
194
  genAiWorker.sendTextInput(`<systemMessage>\nsay "Hello boss, how we doing today?"\n</systemMessage>`);
243
- // Store the GenAI session
244
- genAiSessions.set(channelId, {
245
- genAiWorker,
246
- hasActiveSessions,
247
- pendingCleanup: false,
248
- cleanupTimer: undefined,
249
- guildId,
250
- channelId,
251
- directory,
252
- });
195
+ voiceData.genAiWorker = genAiWorker;
253
196
  // Set up voice receiver for user input
254
197
  const receiver = connection.receiver;
255
198
  // Remove all existing listeners to prevent accumulation
256
199
  receiver.speaking.removeAllListeners('start');
257
- // Get the GenAI session for this channel
258
- const genAiSession = genAiSessions.get(channelId);
259
- if (!genAiSession) {
260
- voiceLogger.error(`GenAI session was just created but not found for channel ${channelId}`);
261
- return;
262
- }
263
200
  // Counter to track overlapping speaking sessions
264
201
  let speakingSessionCount = 0;
265
202
  receiver.speaking.on('start', (userId) => {
@@ -306,16 +243,15 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, clean
306
243
  // )
307
244
  return;
308
245
  }
309
- const genAiSession = genAiSessions.get(channelId);
310
- if (!genAiSession) {
311
- voiceLogger.warn(`[VOICE] Received audio frame but no GenAI session active for channel ${channelId}`);
246
+ if (!voiceData.genAiWorker) {
247
+ voiceLogger.warn(`[VOICE] Received audio frame but no GenAI worker active for guild ${guildId}`);
312
248
  return;
313
249
  }
314
250
  // voiceLogger.debug('User audio chunk length', frame.length)
315
251
  // Write to PCM file if stream exists
316
252
  voiceData.userAudioStream?.write(frame);
317
253
  // stream incrementally — low latency
318
- genAiSession.genAiWorker.sendRealtimeInput({
254
+ voiceData.genAiWorker.sendRealtimeInput({
319
255
  audio: {
320
256
  mimeType: 'audio/pcm;rate=16000',
321
257
  data: frame.toString('base64'),
@@ -326,8 +262,7 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, clean
326
262
  // Only send audioStreamEnd if this is still the current session
327
263
  if (currentSessionCount === speakingSessionCount) {
328
264
  voiceLogger.log(`User ${userId} stopped speaking (session ${currentSessionCount})`);
329
- const genAiSession = genAiSessions.get(channelId);
330
- genAiSession?.genAiWorker.sendRealtimeInput({
265
+ voiceData.genAiWorker?.sendRealtimeInput({
331
266
  audioStreamEnd: true,
332
267
  });
333
268
  }
@@ -684,6 +619,41 @@ export async function initializeOpencodeForDirectory(directory) {
684
619
  cwd: directory,
685
620
  env: {
686
621
  ...process.env,
622
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
623
+ $schema: 'https://opencode.ai/config.json',
624
+ lsp: {
625
+ typescript: { disabled: true },
626
+ eslint: { disabled: true },
627
+ gopls: { disabled: true },
628
+ 'ruby-lsp': { disabled: true },
629
+ pyright: { disabled: true },
630
+ 'elixir-ls': { disabled: true },
631
+ zls: { disabled: true },
632
+ csharp: { disabled: true },
633
+ vue: { disabled: true },
634
+ rust: { disabled: true },
635
+ clangd: { disabled: true },
636
+ svelte: { disabled: true },
637
+ },
638
+ formatter: {
639
+ prettier: { disabled: true },
640
+ biome: { disabled: true },
641
+ gofmt: { disabled: true },
642
+ mix: { disabled: true },
643
+ zig: { disabled: true },
644
+ 'clang-format': { disabled: true },
645
+ ktlint: { disabled: true },
646
+ ruff: { disabled: true },
647
+ rubocop: { disabled: true },
648
+ standardrb: { disabled: true },
649
+ htmlbeautifier: { disabled: true },
650
+ },
651
+ permission: {
652
+ edit: 'allow',
653
+ bash: 'allow',
654
+ webfetch: 'allow',
655
+ },
656
+ }),
687
657
  OPENCODE_PORT: port.toString(),
688
658
  },
689
659
  });
@@ -718,17 +688,12 @@ export async function initializeOpencodeForDirectory(directory) {
718
688
  }
719
689
  });
720
690
  await waitForServer(port);
721
- // Create a custom fetch that disables Bun's default timeout
722
- const customFetch = (input, init) => {
723
- return fetch(input, {
724
- ...init,
725
- // @ts-ignore - Bun-specific option to disable timeout
726
- timeout: false,
727
- });
728
- };
729
691
  const client = createOpencodeClient({
730
692
  baseUrl: `http://localhost:${port}`,
731
- fetch: customFetch,
693
+ fetch: (request) => fetch(request, {
694
+ // @ts-ignore
695
+ timeout: false,
696
+ }),
732
697
  });
733
698
  opencodeServers.set(directory, {
734
699
  process: serverProcess,
@@ -1115,8 +1080,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1115
1080
  }
1116
1081
  }
1117
1082
  catch (e) {
1118
- if (e instanceof Error && e.name === 'AbortError') {
1119
- // Ignore abort controller errors as requested
1083
+ if (isAbortError(e, abortController.signal)) {
1120
1084
  sessionLogger.log('AbortController aborted event handling (normal exit)');
1121
1085
  return;
1122
1086
  }
@@ -1192,7 +1156,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1192
1156
  }
1193
1157
  catch (error) {
1194
1158
  sessionLogger.error(`ERROR: Failed to send prompt:`, error);
1195
- if (!(error instanceof Error && error.name === 'AbortError')) {
1159
+ if (!isAbortError(error, abortController.signal)) {
1196
1160
  abortController.abort('error');
1197
1161
  if (originalMessage) {
1198
1162
  try {
@@ -1737,37 +1701,19 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1737
1701
  voiceLogger.error('[INTERACTION] Error handling interaction:', error);
1738
1702
  }
1739
1703
  });
1740
- // Helper function to clean up GenAI session
1741
- async function cleanupGenAiSession(channelId) {
1742
- const genAiSession = genAiSessions.get(channelId);
1743
- if (!genAiSession)
1744
- return;
1745
- try {
1746
- // Clear any cleanup timer
1747
- if (genAiSession.cleanupTimer) {
1748
- clearTimeout(genAiSession.cleanupTimer);
1749
- genAiSession.cleanupTimer = undefined;
1750
- }
1751
- voiceLogger.log(`Stopping GenAI worker for channel ${channelId}...`);
1752
- await genAiSession.genAiWorker.stop();
1753
- voiceLogger.log(`GenAI worker stopped for channel ${channelId}`);
1754
- // Remove from map
1755
- genAiSessions.delete(channelId);
1756
- voiceLogger.log(`GenAI session cleanup complete for channel ${channelId}`);
1757
- }
1758
- catch (error) {
1759
- voiceLogger.error(`Error during GenAI session cleanup for channel ${channelId}:`, error);
1760
- // Still remove from map even if there was an error
1761
- genAiSessions.delete(channelId);
1762
- }
1763
- }
1764
1704
  // Helper function to clean up voice connection and associated resources
1765
- async function cleanupVoiceConnection(guildId, channelId) {
1705
+ async function cleanupVoiceConnection(guildId) {
1766
1706
  const voiceData = voiceConnections.get(guildId);
1767
1707
  if (!voiceData)
1768
1708
  return;
1769
1709
  voiceLogger.log(`Starting cleanup for guild ${guildId}`);
1770
1710
  try {
1711
+ // Stop GenAI worker if exists (this is async!)
1712
+ if (voiceData.genAiWorker) {
1713
+ voiceLogger.log(`Stopping GenAI worker...`);
1714
+ await voiceData.genAiWorker.stop();
1715
+ voiceLogger.log(`GenAI worker stopped`);
1716
+ }
1771
1717
  // Close user audio stream if exists
1772
1718
  if (voiceData.userAudioStream) {
1773
1719
  voiceLogger.log(`Closing user audio stream...`);
@@ -1787,33 +1733,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1787
1733
  }
1788
1734
  // Remove from map
1789
1735
  voiceConnections.delete(guildId);
1790
- voiceLogger.log(`Voice connection cleanup complete for guild ${guildId}`);
1791
- // Mark the GenAI session for cleanup when all sessions complete
1792
- if (channelId) {
1793
- const genAiSession = genAiSessions.get(channelId);
1794
- if (genAiSession) {
1795
- voiceLogger.log(`Marking channel ${channelId} for cleanup when sessions complete`);
1796
- genAiSession.pendingCleanup = true;
1797
- // If no active sessions, trigger cleanup immediately (with grace period)
1798
- if (!genAiSession.hasActiveSessions) {
1799
- voiceLogger.log(`No active sessions, scheduling cleanup for channel ${channelId} in 1 minute`);
1800
- // Clear any existing timer
1801
- if (genAiSession.cleanupTimer) {
1802
- clearTimeout(genAiSession.cleanupTimer);
1803
- }
1804
- // Schedule cleanup after 1 minute grace period
1805
- genAiSession.cleanupTimer = setTimeout(() => {
1806
- // Double-check that cleanup is still needed
1807
- const currentSession = genAiSessions.get(channelId);
1808
- if (currentSession?.pendingCleanup &&
1809
- !currentSession.hasActiveSessions) {
1810
- voiceLogger.log(`Grace period expired, cleaning up GenAI session for channel ${channelId}`);
1811
- cleanupGenAiSession(channelId);
1812
- }
1813
- }, 60000); // 1 minute
1814
- }
1815
- }
1816
- }
1736
+ voiceLogger.log(`Cleanup complete for guild ${guildId}`);
1817
1737
  }
1818
1738
  catch (error) {
1819
1739
  voiceLogger.error(`Error during cleanup for guild ${guildId}:`, error);
@@ -1856,7 +1776,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1856
1776
  if (!hasOtherAdmins) {
1857
1777
  voiceLogger.log(`No other admins in channel, bot leaving voice channel in guild: ${guild.name}`);
1858
1778
  // Properly clean up all resources
1859
- await cleanupVoiceConnection(guildId, oldState.channelId);
1779
+ await cleanupVoiceConnection(guildId);
1860
1780
  }
1861
1781
  else {
1862
1782
  voiceLogger.log(`Other admins still in channel, bot staying in voice channel`);
@@ -1892,15 +1812,6 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1892
1812
  selfDeaf: false,
1893
1813
  selfMute: false,
1894
1814
  });
1895
- // Set up voice handling for the new channel
1896
- // This will reuse existing GenAI session if one exists
1897
- await setupVoiceHandling({
1898
- connection: voiceData.connection,
1899
- guildId,
1900
- channelId: voiceChannel.id,
1901
- appId: currentAppId,
1902
- cleanupGenAiSession,
1903
- });
1904
1815
  }
1905
1816
  }
1906
1817
  else {
@@ -1959,7 +1870,6 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1959
1870
  guildId: newState.guild.id,
1960
1871
  channelId: voiceChannel.id,
1961
1872
  appId: currentAppId,
1962
- cleanupGenAiSession,
1963
1873
  });
1964
1874
  // Handle connection state changes
1965
1875
  connection.on(VoiceConnectionStatus.Disconnected, async () => {
@@ -1982,7 +1892,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1982
1892
  connection.on(VoiceConnectionStatus.Destroyed, async () => {
1983
1893
  voiceLogger.log(`Connection destroyed for guild: ${newState.guild.name}`);
1984
1894
  // Use the cleanup function to ensure everything is properly closed
1985
- await cleanupVoiceConnection(newState.guild.id, voiceChannel.id);
1895
+ await cleanupVoiceConnection(newState.guild.id);
1986
1896
  });
1987
1897
  // Handle errors
1988
1898
  connection.on('error', (error) => {
@@ -1991,7 +1901,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1991
1901
  }
1992
1902
  catch (error) {
1993
1903
  voiceLogger.error(`Failed to join voice channel:`, error);
1994
- await cleanupVoiceConnection(newState.guild.id, voiceChannel.id);
1904
+ await cleanupVoiceConnection(newState.guild.id);
1995
1905
  }
1996
1906
  }
1997
1907
  catch (error) {
@@ -2009,31 +1919,17 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2009
1919
  ;
2010
1920
  global.shuttingDown = true;
2011
1921
  try {
2012
- // Clean up all voice connections
2013
- const voiceCleanupPromises = [];
2014
- for (const [guildId, voiceData] of voiceConnections) {
1922
+ // Clean up all voice connections (this includes GenAI workers and audio streams)
1923
+ const cleanupPromises = [];
1924
+ for (const [guildId] of voiceConnections) {
2015
1925
  voiceLogger.log(`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`);
2016
- // Find the channel ID for this connection
2017
- const channelId = voiceData.connection.joinConfig.channelId || undefined;
2018
- voiceCleanupPromises.push(cleanupVoiceConnection(guildId, channelId));
2019
- }
2020
- // Wait for all voice cleanups to complete
2021
- if (voiceCleanupPromises.length > 0) {
2022
- voiceLogger.log(`[SHUTDOWN] Waiting for ${voiceCleanupPromises.length} voice connection(s) to clean up...`);
2023
- await Promise.allSettled(voiceCleanupPromises);
2024
- discordLogger.log(`All voice connections cleaned up`);
1926
+ cleanupPromises.push(cleanupVoiceConnection(guildId));
2025
1927
  }
2026
- // Clean up all GenAI sessions (force cleanup regardless of active sessions)
2027
- const genAiCleanupPromises = [];
2028
- for (const [channelId, session] of genAiSessions) {
2029
- voiceLogger.log(`[SHUTDOWN] Cleaning up GenAI session for channel ${channelId} (active sessions: ${session.hasActiveSessions})`);
2030
- genAiCleanupPromises.push(cleanupGenAiSession(channelId));
2031
- }
2032
- // Wait for all GenAI cleanups to complete
2033
- if (genAiCleanupPromises.length > 0) {
2034
- voiceLogger.log(`[SHUTDOWN] Waiting for ${genAiCleanupPromises.length} GenAI session(s) to clean up...`);
2035
- await Promise.allSettled(genAiCleanupPromises);
2036
- discordLogger.log(`All GenAI sessions cleaned up`);
1928
+ // Wait for all cleanups to complete
1929
+ if (cleanupPromises.length > 0) {
1930
+ voiceLogger.log(`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`);
1931
+ await Promise.allSettled(cleanupPromises);
1932
+ discordLogger.log(`All voice connections cleaned up`);
2037
1933
  }
2038
1934
  // Kill all OpenCode servers
2039
1935
  for (const [dir, server] of opencodeServers) {
@@ -20,9 +20,6 @@ export function createGenAIWorker(options) {
20
20
  case 'assistantInterruptSpeaking':
21
21
  options.onAssistantInterruptSpeaking?.();
22
22
  break;
23
- case 'allSessionsCompleted':
24
- options.onAllSessionsCompleted?.();
25
- break;
26
23
  case 'toolCallCompleted':
27
24
  options.onToolCallCompleted?.(message);
28
25
  break;
@@ -205,11 +205,6 @@ parentPort.on('message', async (message) => {
205
205
  ...params,
206
206
  });
207
207
  },
208
- onAllSessionsCompleted: () => {
209
- parentPort.postMessage({
210
- type: 'allSessionsCompleted',
211
- });
212
- },
213
208
  });
214
209
  // Start GenAI session
215
210
  session = await startGenAiSession({
package/dist/tools.js CHANGED
@@ -9,14 +9,12 @@ import { formatDistanceToNow } from 'date-fns';
9
9
  import { ShareMarkdown } from './markdown.js';
10
10
  import pc from 'picocolors';
11
11
  import { initializeOpencodeForDirectory } from './discordBot.js';
12
- export async function getTools({ onMessageCompleted, onAllSessionsCompleted, directory, }) {
12
+ export async function getTools({ onMessageCompleted, directory, }) {
13
13
  const getClient = await initializeOpencodeForDirectory(directory);
14
14
  const client = getClient();
15
15
  const markdownRenderer = new ShareMarkdown(client);
16
16
  const providersResponse = await client.config.providers({});
17
17
  const providers = providersResponse.data?.providers || [];
18
- // Track all active OpenCode sessions
19
- const activeSessions = new Set();
20
18
  // Helper: get last assistant model for a session (non-summary)
21
19
  const getSessionModel = async (sessionId) => {
22
20
  const res = await getClient().session.messages({ path: { id: sessionId } });
@@ -43,9 +41,6 @@ export async function getTools({ onMessageCompleted, onAllSessionsCompleted, dir
43
41
  }),
44
42
  execute: async ({ sessionId, message }) => {
45
43
  const sessionModel = await getSessionModel(sessionId);
46
- // Track this session as active
47
- activeSessions.add(sessionId);
48
- toolsLogger.log(`Session ${sessionId} started, ${activeSessions.size} active sessions`);
49
44
  // do not await
50
45
  getClient()
51
46
  .session.prompt({
@@ -66,14 +61,6 @@ export async function getTools({ onMessageCompleted, onAllSessionsCompleted, dir
66
61
  data: response.data,
67
62
  markdown,
68
63
  });
69
- // Remove from active sessions
70
- activeSessions.delete(sessionId);
71
- toolsLogger.log(`Session ${sessionId} completed, ${activeSessions.size} active sessions remaining`);
72
- // Check if all sessions are complete
73
- if (activeSessions.size === 0) {
74
- toolsLogger.log('All sessions completed');
75
- onAllSessionsCompleted?.();
76
- }
77
64
  })
78
65
  .catch((error) => {
79
66
  onMessageCompleted?.({
@@ -81,14 +68,6 @@ export async function getTools({ onMessageCompleted, onAllSessionsCompleted, dir
81
68
  messageId: '',
82
69
  error,
83
70
  });
84
- // Remove from active sessions even on error
85
- activeSessions.delete(sessionId);
86
- toolsLogger.log(`Session ${sessionId} failed, ${activeSessions.size} active sessions remaining`);
87
- // Check if all sessions are complete
88
- if (activeSessions.size === 0) {
89
- toolsLogger.log('All sessions completed');
90
- onAllSessionsCompleted?.();
91
- }
92
71
  });
93
72
  return {
94
73
  success: true,
@@ -129,52 +108,32 @@ export async function getTools({ onMessageCompleted, onAllSessionsCompleted, dir
129
108
  if (!session.data) {
130
109
  throw new Error('Failed to create session');
131
110
  }
132
- const newSessionId = session.data.id;
133
- // Track this session as active
134
- activeSessions.add(newSessionId);
135
- toolsLogger.log(`New session ${newSessionId} created, ${activeSessions.size} active sessions`);
136
111
  // do not await
137
112
  getClient()
138
113
  .session.prompt({
139
- path: { id: newSessionId },
114
+ path: { id: session.data.id },
140
115
  body: {
141
116
  parts: [{ type: 'text', text: message }],
142
117
  },
143
118
  })
144
119
  .then(async (response) => {
145
120
  const markdown = await markdownRenderer.generate({
146
- sessionID: newSessionId,
121
+ sessionID: session.data.id,
147
122
  lastAssistantOnly: true,
148
123
  });
149
124
  onMessageCompleted?.({
150
- sessionId: newSessionId,
125
+ sessionId: session.data.id,
151
126
  messageId: '',
152
127
  data: response.data,
153
128
  markdown,
154
129
  });
155
- // Remove from active sessions
156
- activeSessions.delete(newSessionId);
157
- toolsLogger.log(`Session ${newSessionId} completed, ${activeSessions.size} active sessions remaining`);
158
- // Check if all sessions are complete
159
- if (activeSessions.size === 0) {
160
- toolsLogger.log('All sessions completed');
161
- onAllSessionsCompleted?.();
162
- }
163
130
  })
164
131
  .catch((error) => {
165
132
  onMessageCompleted?.({
166
- sessionId: newSessionId,
133
+ sessionId: session.data.id,
167
134
  messageId: '',
168
135
  error,
169
136
  });
170
- // Remove from active sessions even on error
171
- activeSessions.delete(newSessionId);
172
- toolsLogger.log(`Session ${newSessionId} failed, ${activeSessions.size} active sessions remaining`);
173
- // Check if all sessions are complete
174
- if (activeSessions.size === 0) {
175
- toolsLogger.log('All sessions completed');
176
- onAllSessionsCompleted?.();
177
- }
178
137
  });
179
138
  return {
180
139
  success: true,
package/dist/utils.js CHANGED
@@ -30,7 +30,7 @@ export function generateBotInstallUrl({ clientId, permissions = [
30
30
  }
31
31
  export function deduplicateByKey(arr, keyFn) {
32
32
  const seen = new Set();
33
- return arr.filter(item => {
33
+ return arr.filter((item) => {
34
34
  const key = keyFn(item);
35
35
  if (seen.has(key)) {
36
36
  return false;
@@ -39,3 +39,12 @@ export function deduplicateByKey(arr, keyFn) {
39
39
  return true;
40
40
  });
41
41
  }
42
+ export function isAbortError(error, signal) {
43
+ return (error instanceof Error &&
44
+ (error.name === 'AbortError' ||
45
+ error.name === 'Aborterror' ||
46
+ error.name === 'aborterror' ||
47
+ error.name.toLowerCase() === 'aborterror' ||
48
+ error.message?.includes('aborted') ||
49
+ (signal?.aborted ?? false)));
50
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.2.0",
5
+ "version": "0.3.0",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -41,6 +41,7 @@
41
41
  "pretty-ms": "^9.3.0",
42
42
  "prism-media": "^1.3.5",
43
43
  "string-dedent": "^3.0.2",
44
+ "undici": "^7.16.0",
44
45
  "zod": "^4.0.17"
45
46
  },
46
47
  "scripts": {
package/src/discordBot.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  createOpencodeClient,
3
3
  type OpencodeClient,
4
4
  type Part,
5
+ type Config,
5
6
  } from '@opencode-ai/sdk'
6
7
 
7
8
  import { createGenAIWorker, type GenAIWorker } from './genai-worker-wrapper.js'
@@ -45,6 +46,10 @@ import { extractTagsArrays, extractNonXmlContent } from './xml.js'
45
46
  import prettyMilliseconds from 'pretty-ms'
46
47
  import type { Session } from '@google/genai'
47
48
  import { createLogger } from './logger.js'
49
+ import { isAbortError } from './utils.js'
50
+ import { setGlobalDispatcher, Agent } from 'undici'
51
+ // disables the automatic 5 minutes abort after no body
52
+ setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }))
48
53
 
49
54
  const discordLogger = createLogger('DISCORD')
50
55
  const voiceLogger = createLogger('VOICE')
@@ -70,30 +75,16 @@ const opencodeServers = new Map<
70
75
  // Map of session ID to current AbortController
71
76
  const abortControllers = new Map<string, AbortController>()
72
77
 
73
- // Map of guild ID to voice connection
78
+ // Map of guild ID to voice connection and GenAI worker
74
79
  const voiceConnections = new Map<
75
80
  string,
76
81
  {
77
82
  connection: VoiceConnection
83
+ genAiWorker?: GenAIWorker
78
84
  userAudioStream?: fs.WriteStream
79
85
  }
80
86
  >()
81
87
 
82
- // Map of channel ID to GenAI worker and session state
83
- // This allows sessions to persist across voice channel changes
84
- const genAiSessions = new Map<
85
- string,
86
- {
87
- genAiWorker: GenAIWorker
88
- hasActiveSessions: boolean // Track if there are active OpenCode sessions
89
- pendingCleanup: boolean // Track if cleanup was requested (user left channel)
90
- cleanupTimer?: NodeJS.Timeout // Timer for delayed cleanup
91
- guildId: string
92
- channelId: string
93
- directory: string
94
- }
95
- >()
96
-
97
88
  // Map of directory to retry count for server restarts
98
89
  const serverRetryCount = new Map<string, number>()
99
90
 
@@ -168,13 +159,11 @@ async function setupVoiceHandling({
168
159
  guildId,
169
160
  channelId,
170
161
  appId,
171
- cleanupGenAiSession,
172
162
  }: {
173
163
  connection: VoiceConnection
174
164
  guildId: string
175
165
  channelId: string
176
166
  appId: string
177
- cleanupGenAiSession: (channelId: string) => Promise<void>
178
167
  }) {
179
168
  voiceLogger.log(
180
169
  `Setting up voice handling for guild ${guildId}, channel ${channelId}`,
@@ -207,36 +196,12 @@ async function setupVoiceHandling({
207
196
  // Create user audio stream for debugging
208
197
  voiceData.userAudioStream = await createUserAudioLogStream(guildId, channelId)
209
198
 
210
- // Check if we already have a GenAI session for this channel
211
- const existingSession = genAiSessions.get(channelId)
212
- if (existingSession) {
213
- voiceLogger.log(`Reusing existing GenAI session for channel ${channelId}`)
214
-
215
- // Cancel any pending cleanup since user has returned
216
- if (existingSession.pendingCleanup) {
217
- voiceLogger.log(
218
- `Cancelling pending cleanup for channel ${channelId} - user returned`,
219
- )
220
- existingSession.pendingCleanup = false
221
-
222
- if (existingSession.cleanupTimer) {
223
- clearTimeout(existingSession.cleanupTimer)
224
- existingSession.cleanupTimer = undefined
225
- }
226
- }
227
-
228
- // Session already exists, just update the voice handling
229
- return
230
- }
231
-
232
199
  // Get API keys from database
233
200
  const apiKeys = getDatabase()
234
201
  .prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
235
202
  .get(appId) as { gemini_api_key: string | null } | undefined
236
203
 
237
- // Track if sessions are active
238
- let hasActiveSessions = false
239
-
204
+ // Create GenAI worker
240
205
  const genAiWorker = await createGenAIWorker({
241
206
  directory,
242
207
  guildId,
@@ -304,82 +269,30 @@ async function setupVoiceHandling({
304
269
  genAiWorker.interrupt()
305
270
  connection.setSpeaking(false)
306
271
  },
307
- onAllSessionsCompleted() {
308
- // All OpenCode sessions have completed
309
- hasActiveSessions = false
310
- voiceLogger.log('All OpenCode sessions completed for this GenAI session')
311
-
312
- // Update the stored session state
313
- const session = genAiSessions.get(channelId)
314
- if (session) {
315
- session.hasActiveSessions = false
316
-
317
- // If cleanup is pending (user left channel), schedule cleanup with grace period
318
- if (session.pendingCleanup) {
319
- voiceLogger.log(
320
- `Scheduling cleanup for channel ${channelId} in 1 minute`,
321
- )
322
-
323
- // Clear any existing timer
324
- if (session.cleanupTimer) {
325
- clearTimeout(session.cleanupTimer)
326
- }
327
-
328
- // Schedule cleanup after 1 minute grace period
329
- session.cleanupTimer = setTimeout(() => {
330
- // Double-check that cleanup is still needed
331
- const currentSession = genAiSessions.get(channelId)
332
- if (
333
- currentSession?.pendingCleanup &&
334
- !currentSession.hasActiveSessions
335
- ) {
336
- voiceLogger.log(
337
- `Grace period expired, cleaning up GenAI session for channel ${channelId}`,
338
- )
339
- // Use the main cleanup function - defined later in startDiscordBot
340
- cleanupGenAiSession(channelId)
341
- }
342
- }, 60000) // 1 minute
343
- }
344
- }
345
- },
346
272
  onToolCallCompleted(params) {
347
- // Note: We now track at the tools.ts level, but still handle completion messages
348
- voiceLogger.log(`OpenCode session ${params.sessionId} completed`)
349
-
350
273
  const text = params.error
351
274
  ? `<systemMessage>\nThe coding agent encountered an error while processing session ${params.sessionId}: ${params.error?.message || String(params.error)}\n</systemMessage>`
352
275
  : `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`
353
276
 
354
277
  genAiWorker.sendTextInput(text)
355
-
356
- // Mark that we have active sessions (will be updated by onAllSessionsCompleted when done)
357
- hasActiveSessions = true
358
- const session = genAiSessions.get(channelId)
359
- if (session) {
360
- session.hasActiveSessions = true
361
- }
362
278
  },
363
279
  onError(error) {
364
280
  voiceLogger.error('GenAI worker error:', error)
365
281
  },
366
282
  })
367
283
 
284
+ // Stop any existing GenAI worker before storing new one
285
+ if (voiceData.genAiWorker) {
286
+ voiceLogger.log('Stopping existing GenAI worker before creating new one')
287
+ await voiceData.genAiWorker.stop()
288
+ }
289
+
368
290
  // Send initial greeting
369
291
  genAiWorker.sendTextInput(
370
292
  `<systemMessage>\nsay "Hello boss, how we doing today?"\n</systemMessage>`,
371
293
  )
372
294
 
373
- // Store the GenAI session
374
- genAiSessions.set(channelId, {
375
- genAiWorker,
376
- hasActiveSessions,
377
- pendingCleanup: false,
378
- cleanupTimer: undefined,
379
- guildId,
380
- channelId,
381
- directory,
382
- })
295
+ voiceData.genAiWorker = genAiWorker
383
296
 
384
297
  // Set up voice receiver for user input
385
298
  const receiver = connection.receiver
@@ -387,15 +300,6 @@ async function setupVoiceHandling({
387
300
  // Remove all existing listeners to prevent accumulation
388
301
  receiver.speaking.removeAllListeners('start')
389
302
 
390
- // Get the GenAI session for this channel
391
- const genAiSession = genAiSessions.get(channelId)
392
- if (!genAiSession) {
393
- voiceLogger.error(
394
- `GenAI session was just created but not found for channel ${channelId}`,
395
- )
396
- return
397
- }
398
-
399
303
  // Counter to track overlapping speaking sessions
400
304
  let speakingSessionCount = 0
401
305
 
@@ -451,10 +355,9 @@ async function setupVoiceHandling({
451
355
  return
452
356
  }
453
357
 
454
- const genAiSession = genAiSessions.get(channelId)
455
- if (!genAiSession) {
358
+ if (!voiceData.genAiWorker) {
456
359
  voiceLogger.warn(
457
- `[VOICE] Received audio frame but no GenAI session active for channel ${channelId}`,
360
+ `[VOICE] Received audio frame but no GenAI worker active for guild ${guildId}`,
458
361
  )
459
362
  return
460
363
  }
@@ -464,7 +367,7 @@ async function setupVoiceHandling({
464
367
  voiceData.userAudioStream?.write(frame)
465
368
 
466
369
  // stream incrementally — low latency
467
- genAiSession.genAiWorker.sendRealtimeInput({
370
+ voiceData.genAiWorker.sendRealtimeInput({
468
371
  audio: {
469
372
  mimeType: 'audio/pcm;rate=16000',
470
373
  data: frame.toString('base64'),
@@ -477,8 +380,7 @@ async function setupVoiceHandling({
477
380
  voiceLogger.log(
478
381
  `User ${userId} stopped speaking (session ${currentSessionCount})`,
479
382
  )
480
- const genAiSession = genAiSessions.get(channelId)
481
- genAiSession?.genAiWorker.sendRealtimeInput({
383
+ voiceData.genAiWorker?.sendRealtimeInput({
482
384
  audioStreamEnd: true,
483
385
  })
484
386
  } else {
@@ -936,6 +838,41 @@ export async function initializeOpencodeForDirectory(directory: string) {
936
838
  cwd: directory,
937
839
  env: {
938
840
  ...process.env,
841
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
842
+ $schema: 'https://opencode.ai/config.json',
843
+ lsp: {
844
+ typescript: { disabled: true },
845
+ eslint: { disabled: true },
846
+ gopls: { disabled: true },
847
+ 'ruby-lsp': { disabled: true },
848
+ pyright: { disabled: true },
849
+ 'elixir-ls': { disabled: true },
850
+ zls: { disabled: true },
851
+ csharp: { disabled: true },
852
+ vue: { disabled: true },
853
+ rust: { disabled: true },
854
+ clangd: { disabled: true },
855
+ svelte: { disabled: true },
856
+ },
857
+ formatter: {
858
+ prettier: { disabled: true },
859
+ biome: { disabled: true },
860
+ gofmt: { disabled: true },
861
+ mix: { disabled: true },
862
+ zig: { disabled: true },
863
+ 'clang-format': { disabled: true },
864
+ ktlint: { disabled: true },
865
+ ruff: { disabled: true },
866
+ rubocop: { disabled: true },
867
+ standardrb: { disabled: true },
868
+ htmlbeautifier: { disabled: true },
869
+ },
870
+ permission: {
871
+ edit: 'allow',
872
+ bash: 'allow',
873
+ webfetch: 'allow',
874
+ },
875
+ } satisfies Config),
939
876
  OPENCODE_PORT: port.toString(),
940
877
  },
941
878
  },
@@ -982,21 +919,13 @@ export async function initializeOpencodeForDirectory(directory: string) {
982
919
 
983
920
  await waitForServer(port)
984
921
 
985
- // Create a custom fetch that disables Bun's default timeout
986
- const customFetch = (
987
- input: string | URL | Request,
988
- init?: RequestInit,
989
- ): Promise<Response> => {
990
- return fetch(input, {
991
- ...init,
992
- // @ts-ignore - Bun-specific option to disable timeout
993
- timeout: false,
994
- })
995
- }
996
-
997
922
  const client = createOpencodeClient({
998
923
  baseUrl: `http://localhost:${port}`,
999
- fetch: customFetch,
924
+ fetch: (request: Request) =>
925
+ fetch(request, {
926
+ // @ts-ignore
927
+ timeout: false,
928
+ }),
1000
929
  })
1001
930
 
1002
931
  opencodeServers.set(directory, {
@@ -1475,8 +1404,7 @@ async function handleOpencodeSession(
1475
1404
  }
1476
1405
  }
1477
1406
  } catch (e) {
1478
- if (e instanceof Error && e.name === 'AbortError') {
1479
- // Ignore abort controller errors as requested
1407
+ if (isAbortError(e, abortController.signal)) {
1480
1408
  sessionLogger.log(
1481
1409
  'AbortController aborted event handling (normal exit)',
1482
1410
  )
@@ -1573,7 +1501,7 @@ async function handleOpencodeSession(
1573
1501
  } catch (error) {
1574
1502
  sessionLogger.error(`ERROR: Failed to send prompt:`, error)
1575
1503
 
1576
- if (!(error instanceof Error && error.name === 'AbortError')) {
1504
+ if (!isAbortError(error, abortController.signal)) {
1577
1505
  abortController.abort('error')
1578
1506
 
1579
1507
  if (originalMessage) {
@@ -2345,43 +2273,21 @@ export async function startDiscordBot({
2345
2273
  },
2346
2274
  )
2347
2275
 
2348
- // Helper function to clean up GenAI session
2349
- async function cleanupGenAiSession(channelId: string) {
2350
- const genAiSession = genAiSessions.get(channelId)
2351
- if (!genAiSession) return
2352
-
2353
- try {
2354
- // Clear any cleanup timer
2355
- if (genAiSession.cleanupTimer) {
2356
- clearTimeout(genAiSession.cleanupTimer)
2357
- genAiSession.cleanupTimer = undefined
2358
- }
2359
-
2360
- voiceLogger.log(`Stopping GenAI worker for channel ${channelId}...`)
2361
- await genAiSession.genAiWorker.stop()
2362
- voiceLogger.log(`GenAI worker stopped for channel ${channelId}`)
2363
-
2364
- // Remove from map
2365
- genAiSessions.delete(channelId)
2366
- voiceLogger.log(`GenAI session cleanup complete for channel ${channelId}`)
2367
- } catch (error) {
2368
- voiceLogger.error(
2369
- `Error during GenAI session cleanup for channel ${channelId}:`,
2370
- error,
2371
- )
2372
- // Still remove from map even if there was an error
2373
- genAiSessions.delete(channelId)
2374
- }
2375
- }
2376
-
2377
2276
  // Helper function to clean up voice connection and associated resources
2378
- async function cleanupVoiceConnection(guildId: string, channelId?: string) {
2277
+ async function cleanupVoiceConnection(guildId: string) {
2379
2278
  const voiceData = voiceConnections.get(guildId)
2380
2279
  if (!voiceData) return
2381
2280
 
2382
2281
  voiceLogger.log(`Starting cleanup for guild ${guildId}`)
2383
2282
 
2384
2283
  try {
2284
+ // Stop GenAI worker if exists (this is async!)
2285
+ if (voiceData.genAiWorker) {
2286
+ voiceLogger.log(`Stopping GenAI worker...`)
2287
+ await voiceData.genAiWorker.stop()
2288
+ voiceLogger.log(`GenAI worker stopped`)
2289
+ }
2290
+
2385
2291
  // Close user audio stream if exists
2386
2292
  if (voiceData.userAudioStream) {
2387
2293
  voiceLogger.log(`Closing user audio stream...`)
@@ -2405,45 +2311,7 @@ export async function startDiscordBot({
2405
2311
 
2406
2312
  // Remove from map
2407
2313
  voiceConnections.delete(guildId)
2408
- voiceLogger.log(`Voice connection cleanup complete for guild ${guildId}`)
2409
-
2410
- // Mark the GenAI session for cleanup when all sessions complete
2411
- if (channelId) {
2412
- const genAiSession = genAiSessions.get(channelId)
2413
- if (genAiSession) {
2414
- voiceLogger.log(
2415
- `Marking channel ${channelId} for cleanup when sessions complete`,
2416
- )
2417
- genAiSession.pendingCleanup = true
2418
-
2419
- // If no active sessions, trigger cleanup immediately (with grace period)
2420
- if (!genAiSession.hasActiveSessions) {
2421
- voiceLogger.log(
2422
- `No active sessions, scheduling cleanup for channel ${channelId} in 1 minute`,
2423
- )
2424
-
2425
- // Clear any existing timer
2426
- if (genAiSession.cleanupTimer) {
2427
- clearTimeout(genAiSession.cleanupTimer)
2428
- }
2429
-
2430
- // Schedule cleanup after 1 minute grace period
2431
- genAiSession.cleanupTimer = setTimeout(() => {
2432
- // Double-check that cleanup is still needed
2433
- const currentSession = genAiSessions.get(channelId)
2434
- if (
2435
- currentSession?.pendingCleanup &&
2436
- !currentSession.hasActiveSessions
2437
- ) {
2438
- voiceLogger.log(
2439
- `Grace period expired, cleaning up GenAI session for channel ${channelId}`,
2440
- )
2441
- cleanupGenAiSession(channelId)
2442
- }
2443
- }, 60000) // 1 minute
2444
- }
2445
- }
2446
- }
2314
+ voiceLogger.log(`Cleanup complete for guild ${guildId}`)
2447
2315
  } catch (error) {
2448
2316
  voiceLogger.error(`Error during cleanup for guild ${guildId}:`, error)
2449
2317
  // Still remove from map even if there was an error
@@ -2501,7 +2369,7 @@ export async function startDiscordBot({
2501
2369
  )
2502
2370
 
2503
2371
  // Properly clean up all resources
2504
- await cleanupVoiceConnection(guildId, oldState.channelId)
2372
+ await cleanupVoiceConnection(guildId)
2505
2373
  } else {
2506
2374
  voiceLogger.log(
2507
2375
  `Other admins still in channel, bot staying in voice channel`,
@@ -2551,16 +2419,6 @@ export async function startDiscordBot({
2551
2419
  selfDeaf: false,
2552
2420
  selfMute: false,
2553
2421
  })
2554
-
2555
- // Set up voice handling for the new channel
2556
- // This will reuse existing GenAI session if one exists
2557
- await setupVoiceHandling({
2558
- connection: voiceData.connection,
2559
- guildId,
2560
- channelId: voiceChannel.id,
2561
- appId: currentAppId!,
2562
- cleanupGenAiSession,
2563
- })
2564
2422
  }
2565
2423
  } else {
2566
2424
  voiceLogger.log(
@@ -2643,7 +2501,6 @@ export async function startDiscordBot({
2643
2501
  guildId: newState.guild.id,
2644
2502
  channelId: voiceChannel.id,
2645
2503
  appId: currentAppId!,
2646
- cleanupGenAiSession,
2647
2504
  })
2648
2505
 
2649
2506
  // Handle connection state changes
@@ -2671,7 +2528,7 @@ export async function startDiscordBot({
2671
2528
  `Connection destroyed for guild: ${newState.guild.name}`,
2672
2529
  )
2673
2530
  // Use the cleanup function to ensure everything is properly closed
2674
- await cleanupVoiceConnection(newState.guild.id, voiceChannel.id)
2531
+ await cleanupVoiceConnection(newState.guild.id)
2675
2532
  })
2676
2533
 
2677
2534
  // Handle errors
@@ -2683,7 +2540,7 @@ export async function startDiscordBot({
2683
2540
  })
2684
2541
  } catch (error) {
2685
2542
  voiceLogger.error(`Failed to join voice channel:`, error)
2686
- await cleanupVoiceConnection(newState.guild.id, voiceChannel.id)
2543
+ await cleanupVoiceConnection(newState.guild.id)
2687
2544
  }
2688
2545
  } catch (error) {
2689
2546
  voiceLogger.error('Error in voice state update handler:', error)
@@ -2703,44 +2560,24 @@ export async function startDiscordBot({
2703
2560
  ;(global as any).shuttingDown = true
2704
2561
 
2705
2562
  try {
2706
- // Clean up all voice connections
2707
- const voiceCleanupPromises: Promise<void>[] = []
2708
- for (const [guildId, voiceData] of voiceConnections) {
2563
+ // Clean up all voice connections (this includes GenAI workers and audio streams)
2564
+ const cleanupPromises: Promise<void>[] = []
2565
+ for (const [guildId] of voiceConnections) {
2709
2566
  voiceLogger.log(
2710
2567
  `[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`,
2711
2568
  )
2712
- // Find the channel ID for this connection
2713
- const channelId = voiceData.connection.joinConfig.channelId || undefined
2714
- voiceCleanupPromises.push(cleanupVoiceConnection(guildId, channelId))
2569
+ cleanupPromises.push(cleanupVoiceConnection(guildId))
2715
2570
  }
2716
2571
 
2717
- // Wait for all voice cleanups to complete
2718
- if (voiceCleanupPromises.length > 0) {
2572
+ // Wait for all cleanups to complete
2573
+ if (cleanupPromises.length > 0) {
2719
2574
  voiceLogger.log(
2720
- `[SHUTDOWN] Waiting for ${voiceCleanupPromises.length} voice connection(s) to clean up...`,
2575
+ `[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`,
2721
2576
  )
2722
- await Promise.allSettled(voiceCleanupPromises)
2577
+ await Promise.allSettled(cleanupPromises)
2723
2578
  discordLogger.log(`All voice connections cleaned up`)
2724
2579
  }
2725
2580
 
2726
- // Clean up all GenAI sessions (force cleanup regardless of active sessions)
2727
- const genAiCleanupPromises: Promise<void>[] = []
2728
- for (const [channelId, session] of genAiSessions) {
2729
- voiceLogger.log(
2730
- `[SHUTDOWN] Cleaning up GenAI session for channel ${channelId} (active sessions: ${session.hasActiveSessions})`,
2731
- )
2732
- genAiCleanupPromises.push(cleanupGenAiSession(channelId))
2733
- }
2734
-
2735
- // Wait for all GenAI cleanups to complete
2736
- if (genAiCleanupPromises.length > 0) {
2737
- voiceLogger.log(
2738
- `[SHUTDOWN] Waiting for ${genAiCleanupPromises.length} GenAI session(s) to clean up...`,
2739
- )
2740
- await Promise.allSettled(genAiCleanupPromises)
2741
- discordLogger.log(`All GenAI sessions cleaned up`)
2742
- }
2743
-
2744
2581
  // Kill all OpenCode servers
2745
2582
  for (const [dir, server] of opencodeServers) {
2746
2583
  if (!server.process.killed) {
@@ -17,7 +17,6 @@ export interface GenAIWorkerOptions {
17
17
  onAssistantStartSpeaking?: () => void
18
18
  onAssistantStopSpeaking?: () => void
19
19
  onAssistantInterruptSpeaking?: () => void
20
- onAllSessionsCompleted?: () => void
21
20
  onToolCallCompleted?: (params: {
22
21
  sessionId: string
23
22
  messageId: string
@@ -61,9 +60,6 @@ export function createGenAIWorker(
61
60
  case 'assistantInterruptSpeaking':
62
61
  options.onAssistantInterruptSpeaking?.()
63
62
  break
64
- case 'allSessionsCompleted':
65
- options.onAllSessionsCompleted?.()
66
- break
67
63
  case 'toolCallCompleted':
68
64
  options.onToolCallCompleted?.(message)
69
65
  break
@@ -265,11 +265,6 @@ parentPort.on('message', async (message: WorkerInMessage) => {
265
265
  ...params,
266
266
  } satisfies WorkerOutMessage)
267
267
  },
268
- onAllSessionsCompleted: () => {
269
- parentPort!.postMessage({
270
- type: 'allSessionsCompleted',
271
- } satisfies WorkerOutMessage)
272
- },
273
268
  })
274
269
 
275
270
  // Start GenAI session
package/src/tools.ts CHANGED
@@ -19,7 +19,6 @@ import { initializeOpencodeForDirectory } from './discordBot.js'
19
19
 
20
20
  export async function getTools({
21
21
  onMessageCompleted,
22
- onAllSessionsCompleted,
23
22
  directory,
24
23
  }: {
25
24
  directory: string
@@ -30,7 +29,6 @@ export async function getTools({
30
29
  error?: unknown
31
30
  markdown?: string
32
31
  }) => void
33
- onAllSessionsCompleted?: () => void
34
32
  }) {
35
33
  const getClient = await initializeOpencodeForDirectory(directory)
36
34
  const client = getClient()
@@ -39,9 +37,6 @@ export async function getTools({
39
37
 
40
38
  const providersResponse = await client.config.providers({})
41
39
  const providers: Provider[] = providersResponse.data?.providers || []
42
-
43
- // Track all active OpenCode sessions
44
- const activeSessions = new Set<string>()
45
40
 
46
41
  // Helper: get last assistant model for a session (non-summary)
47
42
  const getSessionModel = async (
@@ -73,10 +68,6 @@ export async function getTools({
73
68
  execute: async ({ sessionId, message }) => {
74
69
  const sessionModel = await getSessionModel(sessionId)
75
70
 
76
- // Track this session as active
77
- activeSessions.add(sessionId)
78
- toolsLogger.log(`Session ${sessionId} started, ${activeSessions.size} active sessions`)
79
-
80
71
  // do not await
81
72
  getClient()
82
73
  .session.prompt({
@@ -98,16 +89,6 @@ export async function getTools({
98
89
  data: response.data,
99
90
  markdown,
100
91
  })
101
-
102
- // Remove from active sessions
103
- activeSessions.delete(sessionId)
104
- toolsLogger.log(`Session ${sessionId} completed, ${activeSessions.size} active sessions remaining`)
105
-
106
- // Check if all sessions are complete
107
- if (activeSessions.size === 0) {
108
- toolsLogger.log('All sessions completed')
109
- onAllSessionsCompleted?.()
110
- }
111
92
  })
112
93
  .catch((error) => {
113
94
  onMessageCompleted?.({
@@ -115,16 +96,6 @@ export async function getTools({
115
96
  messageId: '',
116
97
  error,
117
98
  })
118
-
119
- // Remove from active sessions even on error
120
- activeSessions.delete(sessionId)
121
- toolsLogger.log(`Session ${sessionId} failed, ${activeSessions.size} active sessions remaining`)
122
-
123
- // Check if all sessions are complete
124
- if (activeSessions.size === 0) {
125
- toolsLogger.log('All sessions completed')
126
- onAllSessionsCompleted?.()
127
- }
128
99
  })
129
100
  return {
130
101
  success: true,
@@ -172,58 +143,32 @@ export async function getTools({
172
143
  throw new Error('Failed to create session')
173
144
  }
174
145
 
175
- const newSessionId = session.data.id
176
-
177
- // Track this session as active
178
- activeSessions.add(newSessionId)
179
- toolsLogger.log(`New session ${newSessionId} created, ${activeSessions.size} active sessions`)
180
-
181
146
  // do not await
182
147
  getClient()
183
148
  .session.prompt({
184
- path: { id: newSessionId },
149
+ path: { id: session.data.id },
185
150
  body: {
186
151
  parts: [{ type: 'text', text: message }],
187
152
  },
188
153
  })
189
154
  .then(async (response) => {
190
155
  const markdown = await markdownRenderer.generate({
191
- sessionID: newSessionId,
156
+ sessionID: session.data.id,
192
157
  lastAssistantOnly: true,
193
158
  })
194
159
  onMessageCompleted?.({
195
- sessionId: newSessionId,
160
+ sessionId: session.data.id,
196
161
  messageId: '',
197
162
  data: response.data,
198
163
  markdown,
199
164
  })
200
-
201
- // Remove from active sessions
202
- activeSessions.delete(newSessionId)
203
- toolsLogger.log(`Session ${newSessionId} completed, ${activeSessions.size} active sessions remaining`)
204
-
205
- // Check if all sessions are complete
206
- if (activeSessions.size === 0) {
207
- toolsLogger.log('All sessions completed')
208
- onAllSessionsCompleted?.()
209
- }
210
165
  })
211
166
  .catch((error) => {
212
167
  onMessageCompleted?.({
213
- sessionId: newSessionId,
168
+ sessionId: session.data.id,
214
169
  messageId: '',
215
170
  error,
216
171
  })
217
-
218
- // Remove from active sessions even on error
219
- activeSessions.delete(newSessionId)
220
- toolsLogger.log(`Session ${newSessionId} failed, ${activeSessions.size} active sessions remaining`)
221
-
222
- // Check if all sessions are complete
223
- if (activeSessions.size === 0) {
224
- toolsLogger.log('All sessions completed')
225
- onAllSessionsCompleted?.()
226
- }
227
172
  })
228
173
 
229
174
  return {
package/src/utils.ts CHANGED
@@ -48,10 +48,9 @@ export function generateBotInstallUrl({
48
48
  return url.toString()
49
49
  }
50
50
 
51
-
52
51
  export function deduplicateByKey<T, K>(arr: T[], keyFn: (item: T) => K): T[] {
53
52
  const seen = new Set<K>()
54
- return arr.filter(item => {
53
+ return arr.filter((item) => {
55
54
  const key = keyFn(item)
56
55
  if (seen.has(key)) {
57
56
  return false
@@ -60,3 +59,18 @@ export function deduplicateByKey<T, K>(arr: T[], keyFn: (item: T) => K): T[] {
60
59
  return true
61
60
  })
62
61
  }
62
+
63
+ export function isAbortError(
64
+ error: unknown,
65
+ signal?: AbortSignal,
66
+ ): error is Error {
67
+ return (
68
+ error instanceof Error &&
69
+ (error.name === 'AbortError' ||
70
+ error.name === 'Aborterror' ||
71
+ error.name === 'aborterror' ||
72
+ error.name.toLowerCase() === 'aborterror' ||
73
+ error.message?.includes('aborted') ||
74
+ (signal?.aborted ?? false))
75
+ )
76
+ }
@@ -53,9 +53,6 @@ export type WorkerOutMessage =
53
53
  error?: any
54
54
  markdown?: string
55
55
  }
56
- | {
57
- type: 'allSessionsCompleted'
58
- }
59
56
  | {
60
57
  type: 'error'
61
58
  error: string