kimaki 0.4.37 → 0.4.39

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 (53) hide show
  1. package/dist/channel-management.js +6 -2
  2. package/dist/cli.js +41 -15
  3. package/dist/commands/abort.js +15 -6
  4. package/dist/commands/add-project.js +9 -0
  5. package/dist/commands/agent.js +114 -20
  6. package/dist/commands/fork.js +13 -2
  7. package/dist/commands/model.js +12 -0
  8. package/dist/commands/remove-project.js +26 -16
  9. package/dist/commands/resume.js +9 -0
  10. package/dist/commands/session.js +13 -0
  11. package/dist/commands/share.js +10 -1
  12. package/dist/commands/undo-redo.js +13 -4
  13. package/dist/database.js +24 -5
  14. package/dist/discord-bot.js +38 -31
  15. package/dist/errors.js +110 -0
  16. package/dist/genai-worker.js +18 -16
  17. package/dist/interaction-handler.js +6 -1
  18. package/dist/markdown.js +96 -85
  19. package/dist/markdown.test.js +10 -3
  20. package/dist/message-formatting.js +50 -37
  21. package/dist/opencode.js +43 -46
  22. package/dist/session-handler.js +136 -8
  23. package/dist/system-message.js +2 -0
  24. package/dist/tools.js +18 -8
  25. package/dist/voice-handler.js +48 -25
  26. package/dist/voice.js +159 -131
  27. package/package.json +2 -1
  28. package/src/channel-management.ts +6 -2
  29. package/src/cli.ts +67 -19
  30. package/src/commands/abort.ts +17 -7
  31. package/src/commands/add-project.ts +9 -0
  32. package/src/commands/agent.ts +160 -25
  33. package/src/commands/fork.ts +18 -7
  34. package/src/commands/model.ts +12 -0
  35. package/src/commands/remove-project.ts +28 -16
  36. package/src/commands/resume.ts +9 -0
  37. package/src/commands/session.ts +13 -0
  38. package/src/commands/share.ts +11 -1
  39. package/src/commands/undo-redo.ts +15 -6
  40. package/src/database.ts +26 -4
  41. package/src/discord-bot.ts +42 -34
  42. package/src/errors.ts +208 -0
  43. package/src/genai-worker.ts +20 -17
  44. package/src/interaction-handler.ts +7 -1
  45. package/src/markdown.test.ts +13 -3
  46. package/src/markdown.ts +111 -95
  47. package/src/message-formatting.ts +55 -38
  48. package/src/opencode.ts +52 -49
  49. package/src/session-handler.ts +164 -11
  50. package/src/system-message.ts +2 -0
  51. package/src/tools.ts +18 -8
  52. package/src/voice-handler.ts +48 -23
  53. package/src/voice.ts +195 -148
@@ -6,7 +6,9 @@ import path from 'node:path';
6
6
  import { getDatabase } from './database.js';
7
7
  import { extractTagsArrays } from './xml.js';
8
8
  export async function ensureKimakiCategory(guild, botName) {
9
- const categoryName = botName ? `Kimaki ${botName}` : 'Kimaki';
9
+ // Skip appending bot name if it's already "kimaki" to avoid "Kimaki kimaki"
10
+ const isKimakiBot = botName?.toLowerCase() === 'kimaki';
11
+ const categoryName = botName && !isKimakiBot ? `Kimaki ${botName}` : 'Kimaki';
10
12
  const existingCategory = guild.channels.cache.find((channel) => {
11
13
  if (channel.type !== ChannelType.GuildCategory) {
12
14
  return false;
@@ -22,7 +24,9 @@ export async function ensureKimakiCategory(guild, botName) {
22
24
  });
23
25
  }
24
26
  export async function ensureKimakiAudioCategory(guild, botName) {
25
- const categoryName = botName ? `Kimaki Audio ${botName}` : 'Kimaki Audio';
27
+ // Skip appending bot name if it's already "kimaki" to avoid "Kimaki Audio kimaki"
28
+ const isKimakiBot = botName?.toLowerCase() === 'kimaki';
29
+ const categoryName = botName && !isKimakiBot ? `Kimaki Audio ${botName}` : 'Kimaki Audio';
26
30
  const existingCategory = guild.channels.cache.find((channel) => {
27
31
  if (channel.type !== ChannelType.GuildCategory) {
28
32
  return false;
package/dist/cli.js CHANGED
@@ -9,11 +9,13 @@ import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDis
9
9
  import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
10
10
  import path from 'node:path';
11
11
  import fs from 'node:fs';
12
+ import * as errore from 'errore';
12
13
  import { createLogger } from './logger.js';
13
14
  import { spawn, spawnSync, execSync } from 'node:child_process';
14
15
  import http from 'node:http';
15
16
  import { setDataDir, getDataDir, getLockPort } from './config.js';
16
17
  import { extractTagsArrays } from './xml.js';
18
+ import { sanitizeAgentName } from './commands/agent.js';
17
19
  const cliLogger = createLogger('CLI');
18
20
  const cli = cac('kimaki');
19
21
  process.title = 'kimaki';
@@ -119,7 +121,7 @@ async function startLockServer() {
119
121
  const EXIT_NO_RESTART = 64;
120
122
  // Commands to skip when registering user commands (reserved names)
121
123
  const SKIP_USER_COMMANDS = ['init'];
122
- async function registerCommands(token, appId, userCommands = []) {
124
+ async function registerCommands({ token, appId, userCommands = [], agents = [], }) {
123
125
  const commands = [
124
126
  new SlashCommandBuilder()
125
127
  .setName('resume')
@@ -254,6 +256,18 @@ async function registerCommands(token, appId, userCommands = []) {
254
256
  })
255
257
  .toJSON());
256
258
  }
259
+ // Add agent-specific quick commands like /plan-agent, /build-agent
260
+ // Filter to primary/all mode agents (same as /agent command shows), excluding hidden agents
261
+ const primaryAgents = agents.filter((a) => (a.mode === 'primary' || a.mode === 'all') && !a.hidden);
262
+ for (const agent of primaryAgents) {
263
+ const sanitizedName = sanitizeAgentName(agent.name);
264
+ const commandName = `${sanitizedName}-agent`;
265
+ const description = agent.description || `Switch to ${agent.name} agent`;
266
+ commands.push(new SlashCommandBuilder()
267
+ .setName(commandName.slice(0, 32)) // Discord limits to 32 chars
268
+ .setDescription(description.slice(0, 100))
269
+ .toJSON());
270
+ }
257
271
  const rest = new REST().setToken(token);
258
272
  try {
259
273
  const data = (await rest.put(Routes.applicationCommands(appId), {
@@ -419,7 +433,12 @@ async function run({ restart, addChannels }) {
419
433
  // This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
420
434
  const currentDir = process.cwd();
421
435
  s.start('Starting OpenCode server...');
422
- const opencodePromise = initializeOpencodeForDirectory(currentDir);
436
+ const opencodePromise = initializeOpencodeForDirectory(currentDir).then((result) => {
437
+ if (errore.isError(result)) {
438
+ throw new Error(result.message);
439
+ }
440
+ return result;
441
+ });
423
442
  s.message('Connecting to Discord...');
424
443
  const discordClient = await createDiscordClient();
425
444
  const guilds = [];
@@ -506,8 +525,8 @@ async function run({ restart, addChannels }) {
506
525
  const getClient = await opencodePromise;
507
526
  s.stop('OpenCode server ready!');
508
527
  s.start('Fetching OpenCode data...');
509
- // Fetch projects and commands in parallel
510
- const [projects, allUserCommands] = await Promise.all([
528
+ // Fetch projects, commands, and agents in parallel
529
+ const [projects, allUserCommands, allAgents] = await Promise.all([
511
530
  getClient()
512
531
  .project.list({})
513
532
  .then((r) => r.data || [])
@@ -521,6 +540,10 @@ async function run({ restart, addChannels }) {
521
540
  .command.list({ query: { directory: currentDir } })
522
541
  .then((r) => r.data || [])
523
542
  .catch(() => []),
543
+ getClient()
544
+ .app.agents({ query: { directory: currentDir } })
545
+ .then((r) => r.data || [])
546
+ .catch(() => []),
524
547
  ]);
525
548
  s.stop(`Found ${projects.length} OpenCode project(s)`);
526
549
  const existingDirs = kimakiChannels.flatMap(({ channels }) => channels
@@ -611,7 +634,7 @@ async function run({ restart, addChannels }) {
611
634
  note(`Found ${registrableCommands.length} user-defined command(s):\n${commandList}`, 'OpenCode Commands');
612
635
  }
613
636
  cliLogger.log('Registering slash commands asynchronously...');
614
- void registerCommands(token, appId, allUserCommands)
637
+ void registerCommands({ token, appId, userCommands: allUserCommands, agents: allAgents })
615
638
  .then(() => {
616
639
  cliLogger.log('Slash commands registered!');
617
640
  })
@@ -746,12 +769,6 @@ cli
746
769
  process.exit(EXIT_NO_RESTART);
747
770
  }
748
771
  });
749
- // Magic prefix used to identify bot-initiated sessions.
750
- // The running bot will recognize this prefix and start a session.
751
- const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
752
- // Notify-only prefix - bot won't start a session, just creates thread for notifications.
753
- // Reply to the thread to start a session with the notification as context.
754
- const BOT_NOTIFY_PREFIX = '📢 **Notification**';
755
772
  cli
756
773
  .command('send', 'Send a message to Discord channel, creating a thread. Use --notify-only to skip AI session.')
757
774
  .alias('start-session') // backwards compatibility
@@ -953,9 +970,7 @@ cli
953
970
  throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`);
954
971
  }
955
972
  s.message('Creating starter message...');
956
- // Create starter message with magic prefix
957
- // BOT_SESSION_PREFIX triggers AI session, BOT_NOTIFY_PREFIX is notification-only
958
- const messagePrefix = notifyOnly ? BOT_NOTIFY_PREFIX : BOT_SESSION_PREFIX;
973
+ // Create starter message with just the prompt (no prefix)
959
974
  const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
960
975
  method: 'POST',
961
976
  headers: {
@@ -963,7 +978,7 @@ cli
963
978
  'Content-Type': 'application/json',
964
979
  },
965
980
  body: JSON.stringify({
966
- content: `${messagePrefix}\n${prompt}`,
981
+ content: prompt,
967
982
  }),
968
983
  });
969
984
  if (!starterMessageResponse.ok) {
@@ -992,6 +1007,17 @@ cli
992
1007
  throw new Error(`Discord API error: ${threadResponse.status} - ${error}`);
993
1008
  }
994
1009
  const threadData = (await threadResponse.json());
1010
+ // Mark thread for auto-start if not notify-only
1011
+ // This is optional - only works if local database exists (for local bot auto-start)
1012
+ if (!notifyOnly) {
1013
+ try {
1014
+ const db = getDatabase();
1015
+ db.prepare('INSERT OR REPLACE INTO pending_auto_start (thread_id) VALUES (?)').run(threadData.id);
1016
+ }
1017
+ catch {
1018
+ // Database not available (e.g., CI environment) - skip auto-start marking
1019
+ }
1020
+ }
995
1021
  s.stop('Thread created!');
996
1022
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
997
1023
  const successMessage = notifyOnly
@@ -5,6 +5,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
6
  import { abortControllers } from '../session-handler.js';
7
7
  import { createLogger } from '../logger.js';
8
+ import * as errore from 'errore';
8
9
  const logger = createLogger('ABORT');
9
10
  export async function handleAbortCommand({ command }) {
10
11
  const channel = command.channel;
@@ -51,13 +52,21 @@ export async function handleAbortCommand({ command }) {
51
52
  return;
52
53
  }
53
54
  const sessionId = row.session_id;
55
+ const existingController = abortControllers.get(sessionId);
56
+ if (existingController) {
57
+ existingController.abort(new Error('User requested abort'));
58
+ abortControllers.delete(sessionId);
59
+ }
60
+ const getClient = await initializeOpencodeForDirectory(directory);
61
+ if (errore.isError(getClient)) {
62
+ await command.reply({
63
+ content: `Failed to abort: ${getClient.message}`,
64
+ ephemeral: true,
65
+ flags: SILENT_MESSAGE_FLAGS,
66
+ });
67
+ return;
68
+ }
54
69
  try {
55
- const existingController = abortControllers.get(sessionId);
56
- if (existingController) {
57
- existingController.abort(new Error('User requested abort'));
58
- abortControllers.delete(sessionId);
59
- }
60
- const getClient = await initializeOpencodeForDirectory(directory);
61
70
  await getClient().session.abort({
62
71
  path: { id: sessionId },
63
72
  });
@@ -6,6 +6,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
6
6
  import { createProjectChannels } from '../channel-management.js';
7
7
  import { createLogger } from '../logger.js';
8
8
  import { abbreviatePath } from '../utils.js';
9
+ import * as errore from 'errore';
9
10
  const logger = createLogger('ADD-PROJECT');
10
11
  export async function handleAddProjectCommand({ command, appId }) {
11
12
  await command.deferReply({ ephemeral: false });
@@ -18,6 +19,10 @@ export async function handleAddProjectCommand({ command, appId }) {
18
19
  try {
19
20
  const currentDir = process.cwd();
20
21
  const getClient = await initializeOpencodeForDirectory(currentDir);
22
+ if (errore.isError(getClient)) {
23
+ await command.editReply(getClient.message);
24
+ return;
25
+ }
21
26
  const projectsResponse = await getClient().project.list({});
22
27
  if (!projectsResponse.data) {
23
28
  await command.editReply('Failed to fetch projects');
@@ -60,6 +65,10 @@ export async function handleAddProjectAutocomplete({ interaction, appId, }) {
60
65
  try {
61
66
  const currentDir = process.cwd();
62
67
  const getClient = await initializeOpencodeForDirectory(currentDir);
68
+ if (errore.isError(getClient)) {
69
+ await interaction.respond([]);
70
+ return;
71
+ }
63
72
  const projectsResponse = await getClient().project.list({});
64
73
  if (!projectsResponse.data) {
65
74
  await interaction.respond([]);
@@ -1,19 +1,30 @@
1
1
  // /agent command - Set the preferred agent for this channel or session.
2
+ // Also provides quick agent commands like /plan-agent, /build-agent that switch instantly.
2
3
  import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, } from 'discord.js';
3
4
  import crypto from 'node:crypto';
4
- import { getDatabase, setChannelAgent, setSessionAgent, runModelMigrations } from '../database.js';
5
+ import { getDatabase, setChannelAgent, setSessionAgent, clearSessionModel, runModelMigrations } from '../database.js';
5
6
  import { initializeOpencodeForDirectory } from '../opencode.js';
6
7
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
7
8
  import { createLogger } from '../logger.js';
9
+ import * as errore from 'errore';
8
10
  const agentLogger = createLogger('AGENT');
9
11
  const pendingAgentContexts = new Map();
10
- export async function handleAgentCommand({ interaction, appId, }) {
11
- await interaction.deferReply({ ephemeral: true });
12
- runModelMigrations();
12
+ /**
13
+ * Sanitize an agent name to be a valid Discord command name component.
14
+ * Lowercase, alphanumeric and hyphens only.
15
+ */
16
+ export function sanitizeAgentName(name) {
17
+ return name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
18
+ }
19
+ /**
20
+ * Resolve the context for an agent command (directory, channel, session).
21
+ * Returns null if the command cannot be executed in this context.
22
+ */
23
+ export async function resolveAgentCommandContext({ interaction, appId, }) {
13
24
  const channel = interaction.channel;
14
25
  if (!channel) {
15
26
  await interaction.editReply({ content: 'This command can only be used in a channel' });
16
- return;
27
+ return null;
17
28
  }
18
29
  const isThread = [
19
30
  ChannelType.PublicThread,
@@ -47,41 +58,74 @@ export async function handleAgentCommand({ interaction, appId, }) {
47
58
  await interaction.editReply({
48
59
  content: 'This command can only be used in text channels or threads',
49
60
  });
50
- return;
61
+ return null;
51
62
  }
52
63
  if (channelAppId && channelAppId !== appId) {
53
64
  await interaction.editReply({ content: 'This channel is not configured for this bot' });
54
- return;
65
+ return null;
55
66
  }
56
67
  if (!projectDirectory) {
57
68
  await interaction.editReply({
58
69
  content: 'This channel is not configured with a project directory',
59
70
  });
71
+ return null;
72
+ }
73
+ return {
74
+ dir: projectDirectory,
75
+ channelId: targetChannelId,
76
+ sessionId,
77
+ isThread,
78
+ };
79
+ }
80
+ /**
81
+ * Set the agent preference for a context (session or channel).
82
+ * When switching agents for a session, also clears the session model preference
83
+ * so the new agent's model takes effect.
84
+ */
85
+ export function setAgentForContext({ context, agentName, }) {
86
+ if (context.isThread && context.sessionId) {
87
+ setSessionAgent(context.sessionId, agentName);
88
+ // Clear session model so the new agent's model takes effect
89
+ clearSessionModel(context.sessionId);
90
+ agentLogger.log(`Set agent ${agentName} for session ${context.sessionId} (cleared model preference)`);
91
+ }
92
+ else {
93
+ setChannelAgent(context.channelId, agentName);
94
+ agentLogger.log(`Set agent ${agentName} for channel ${context.channelId}`);
95
+ }
96
+ }
97
+ export async function handleAgentCommand({ interaction, appId, }) {
98
+ await interaction.deferReply({ ephemeral: true });
99
+ runModelMigrations();
100
+ const context = await resolveAgentCommandContext({ interaction, appId });
101
+ if (!context) {
60
102
  return;
61
103
  }
62
104
  try {
63
- const getClient = await initializeOpencodeForDirectory(projectDirectory);
105
+ const getClient = await initializeOpencodeForDirectory(context.dir);
106
+ if (errore.isError(getClient)) {
107
+ await interaction.editReply({ content: getClient.message });
108
+ return;
109
+ }
64
110
  const agentsResponse = await getClient().app.agents({
65
- query: { directory: projectDirectory },
111
+ query: { directory: context.dir },
66
112
  });
67
113
  if (!agentsResponse.data || agentsResponse.data.length === 0) {
68
114
  await interaction.editReply({ content: 'No agents available' });
69
115
  return;
70
116
  }
71
117
  const agents = agentsResponse.data
72
- .filter((a) => a.mode === 'primary' || a.mode === 'all')
118
+ .filter((agent) => {
119
+ const hidden = agent.hidden;
120
+ return (agent.mode === 'primary' || agent.mode === 'all') && !hidden;
121
+ })
73
122
  .slice(0, 25);
74
123
  if (agents.length === 0) {
75
124
  await interaction.editReply({ content: 'No primary agents available' });
76
125
  return;
77
126
  }
78
127
  const contextHash = crypto.randomBytes(8).toString('hex');
79
- pendingAgentContexts.set(contextHash, {
80
- dir: projectDirectory,
81
- channelId: targetChannelId,
82
- sessionId,
83
- isThread,
84
- });
128
+ pendingAgentContexts.set(contextHash, context);
85
129
  const options = agents.map((agent) => ({
86
130
  label: agent.name.slice(0, 100),
87
131
  value: agent.name,
@@ -128,17 +172,14 @@ export async function handleAgentSelectMenu(interaction) {
128
172
  return;
129
173
  }
130
174
  try {
175
+ setAgentForContext({ context, agentName: selectedAgent });
131
176
  if (context.isThread && context.sessionId) {
132
- setSessionAgent(context.sessionId, selectedAgent);
133
- agentLogger.log(`Set agent ${selectedAgent} for session ${context.sessionId}`);
134
177
  await interaction.editReply({
135
178
  content: `Agent preference set for this session: **${selectedAgent}**`,
136
179
  components: [],
137
180
  });
138
181
  }
139
182
  else {
140
- setChannelAgent(context.channelId, selectedAgent);
141
- agentLogger.log(`Set agent ${selectedAgent} for channel ${context.channelId}`);
142
183
  await interaction.editReply({
143
184
  content: `Agent preference set for this channel: **${selectedAgent}**\n\nAll new sessions in this channel will use this agent.`,
144
185
  components: [],
@@ -154,3 +195,56 @@ export async function handleAgentSelectMenu(interaction) {
154
195
  });
155
196
  }
156
197
  }
198
+ /**
199
+ * Handle quick agent commands like /plan-agent, /build-agent.
200
+ * These instantly switch to the specified agent without showing a dropdown.
201
+ */
202
+ export async function handleQuickAgentCommand({ command, appId, }) {
203
+ await command.deferReply({ ephemeral: true });
204
+ runModelMigrations();
205
+ // Extract agent name from command: "plan-agent" → "plan"
206
+ const sanitizedAgentName = command.commandName.replace(/-agent$/, '');
207
+ const context = await resolveAgentCommandContext({ interaction: command, appId });
208
+ if (!context) {
209
+ return;
210
+ }
211
+ try {
212
+ const getClient = await initializeOpencodeForDirectory(context.dir);
213
+ if (errore.isError(getClient)) {
214
+ await command.editReply({ content: getClient.message });
215
+ return;
216
+ }
217
+ const agentsResponse = await getClient().app.agents({
218
+ query: { directory: context.dir },
219
+ });
220
+ if (!agentsResponse.data || agentsResponse.data.length === 0) {
221
+ await command.editReply({ content: 'No agents available in this project' });
222
+ return;
223
+ }
224
+ // Find the agent matching the sanitized command name
225
+ const matchingAgent = agentsResponse.data.find((a) => sanitizeAgentName(a.name) === sanitizedAgentName);
226
+ if (!matchingAgent) {
227
+ await command.editReply({
228
+ content: `Agent not found. Available agents: ${agentsResponse.data.map((a) => a.name).join(', ')}`,
229
+ });
230
+ return;
231
+ }
232
+ setAgentForContext({ context, agentName: matchingAgent.name });
233
+ if (context.isThread && context.sessionId) {
234
+ await command.editReply({
235
+ content: `Switched to **${matchingAgent.name}** agent for this session`,
236
+ });
237
+ }
238
+ else {
239
+ await command.editReply({
240
+ content: `Switched to **${matchingAgent.name}** agent for this channel\n\nAll new sessions will use this agent.`,
241
+ });
242
+ }
243
+ }
244
+ catch (error) {
245
+ agentLogger.error('Error in quick agent command:', error);
246
+ await command.editReply({
247
+ content: `Failed to switch agent: ${error instanceof Error ? error.message : 'Unknown error'}`,
248
+ });
249
+ }
250
+ }
@@ -5,6 +5,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from '../discord-utils.js';
6
6
  import { collectLastAssistantParts } from '../message-formatting.js';
7
7
  import { createLogger } from '../logger.js';
8
+ import * as errore from 'errore';
8
9
  const sessionLogger = createLogger('SESSION');
9
10
  const forkLogger = createLogger('FORK');
10
11
  export async function handleForkCommand(interaction) {
@@ -50,8 +51,14 @@ export async function handleForkCommand(interaction) {
50
51
  // Defer reply before API calls to avoid 3-second timeout
51
52
  await interaction.deferReply({ ephemeral: true });
52
53
  const sessionId = row.session_id;
54
+ const getClient = await initializeOpencodeForDirectory(directory);
55
+ if (errore.isError(getClient)) {
56
+ await interaction.editReply({
57
+ content: `Failed to load messages: ${getClient.message}`,
58
+ });
59
+ return;
60
+ }
53
61
  try {
54
- const getClient = await initializeOpencodeForDirectory(directory);
55
62
  const messagesResponse = await getClient().session.messages({
56
63
  path: { id: sessionId },
57
64
  });
@@ -120,8 +127,12 @@ export async function handleForkSelectMenu(interaction) {
120
127
  return;
121
128
  }
122
129
  await interaction.deferReply({ ephemeral: false });
130
+ const getClient = await initializeOpencodeForDirectory(directory);
131
+ if (errore.isError(getClient)) {
132
+ await interaction.editReply(`Failed to fork session: ${getClient.message}`);
133
+ return;
134
+ }
123
135
  try {
124
- const getClient = await initializeOpencodeForDirectory(directory);
125
136
  const forkResponse = await getClient().session.fork({
126
137
  path: { id: sessionId },
127
138
  body: { messageID: selectedMessageId },
@@ -6,6 +6,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
6
6
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
7
7
  import { abortAndRetrySession } from '../session-handler.js';
8
8
  import { createLogger } from '../logger.js';
9
+ import * as errore from 'errore';
9
10
  const modelLogger = createLogger('MODEL');
10
11
  // Store context by hash to avoid customId length limits (Discord max: 100 chars)
11
12
  const pendingModelContexts = new Map();
@@ -77,6 +78,10 @@ export async function handleModelCommand({ interaction, appId, }) {
77
78
  }
78
79
  try {
79
80
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
81
+ if (errore.isError(getClient)) {
82
+ await interaction.editReply({ content: getClient.message });
83
+ return;
84
+ }
80
85
  const providersResponse = await getClient().provider.list({
81
86
  query: { directory: projectDirectory },
82
87
  });
@@ -162,6 +167,13 @@ export async function handleProviderSelectMenu(interaction) {
162
167
  }
163
168
  try {
164
169
  const getClient = await initializeOpencodeForDirectory(context.dir);
170
+ if (errore.isError(getClient)) {
171
+ await interaction.editReply({
172
+ content: getClient.message,
173
+ components: [],
174
+ });
175
+ return;
176
+ }
165
177
  const providersResponse = await getClient().provider.list({
166
178
  query: { directory: context.dir },
167
179
  });
@@ -1,5 +1,6 @@
1
1
  // /remove-project command - Remove Discord channels for a project.
2
2
  import path from 'node:path';
3
+ import * as errore from 'errore';
3
4
  import { getDatabase } from '../database.js';
4
5
  import { createLogger } from '../logger.js';
5
6
  import { abbreviatePath } from '../utils.js';
@@ -25,20 +26,27 @@ export async function handleRemoveProjectCommand({ command, appId }) {
25
26
  const deletedChannels = [];
26
27
  const failedChannels = [];
27
28
  for (const { channel_id, channel_type } of channels) {
28
- try {
29
- const channel = await guild.channels.fetch(channel_id).catch(() => null);
30
- if (channel) {
29
+ const channel = await errore.tryAsync({
30
+ try: () => guild.channels.fetch(channel_id),
31
+ catch: (e) => e,
32
+ });
33
+ if (errore.isError(channel)) {
34
+ logger.error(`Failed to fetch channel ${channel_id}:`, channel);
35
+ failedChannels.push(`${channel_type}: ${channel_id}`);
36
+ continue;
37
+ }
38
+ if (channel) {
39
+ try {
31
40
  await channel.delete(`Removed by /remove-project command`);
32
41
  deletedChannels.push(`${channel_type}: ${channel_id}`);
33
42
  }
34
- else {
35
- // Channel doesn't exist in this guild or was already deleted
36
- deletedChannels.push(`${channel_type}: ${channel_id} (already deleted)`);
43
+ catch (error) {
44
+ logger.error(`Failed to delete channel ${channel_id}:`, error);
45
+ failedChannels.push(`${channel_type}: ${channel_id}`);
37
46
  }
38
47
  }
39
- catch (error) {
40
- logger.error(`Failed to delete channel ${channel_id}:`, error);
41
- failedChannels.push(`${channel_type}: ${channel_id}`);
48
+ else {
49
+ deletedChannels.push(`${channel_type}: ${channel_id} (already deleted)`);
42
50
  }
43
51
  }
44
52
  // Remove from database
@@ -76,14 +84,16 @@ export async function handleRemoveProjectAutocomplete({ interaction, appId, }) {
76
84
  // Filter to only channels that exist in this guild
77
85
  const projectsInGuild = [];
78
86
  for (const { directory, channel_id } of allChannels) {
79
- try {
80
- const channel = await guild.channels.fetch(channel_id).catch(() => null);
81
- if (channel) {
82
- projectsInGuild.push({ directory, channelId: channel_id });
83
- }
84
- }
85
- catch {
87
+ const channel = await errore.tryAsync({
88
+ try: () => guild.channels.fetch(channel_id),
89
+ catch: (e) => e,
90
+ });
91
+ if (errore.isError(channel)) {
86
92
  // Channel not in this guild, skip
93
+ continue;
94
+ }
95
+ if (channel) {
96
+ projectsInGuild.push({ directory, channelId: channel_id });
87
97
  }
88
98
  }
89
99
  const projects = projectsInGuild
@@ -7,6 +7,7 @@ import { sendThreadMessage, resolveTextChannel, getKimakiMetadata } from '../dis
7
7
  import { extractTagsArrays } from '../xml.js';
8
8
  import { collectLastAssistantParts } from '../message-formatting.js';
9
9
  import { createLogger } from '../logger.js';
10
+ import * as errore from 'errore';
10
11
  const logger = createLogger('RESUME');
11
12
  export async function handleResumeCommand({ command, appId }) {
12
13
  await command.deferReply({ ephemeral: false });
@@ -41,6 +42,10 @@ export async function handleResumeCommand({ command, appId }) {
41
42
  }
42
43
  try {
43
44
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
45
+ if (errore.isError(getClient)) {
46
+ await command.editReply(getClient.message);
47
+ return;
48
+ }
44
49
  const sessionResponse = await getClient().session.get({
45
50
  path: { id: sessionId },
46
51
  });
@@ -111,6 +116,10 @@ export async function handleResumeAutocomplete({ interaction, appId, }) {
111
116
  }
112
117
  try {
113
118
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
119
+ if (errore.isError(getClient)) {
120
+ await interaction.respond([]);
121
+ return;
122
+ }
114
123
  const sessionsResponse = await getClient().session.list();
115
124
  if (!sessionsResponse.data) {
116
125
  await interaction.respond([]);
@@ -8,6 +8,7 @@ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
8
8
  import { extractTagsArrays } from '../xml.js';
9
9
  import { handleOpencodeSession } from '../session-handler.js';
10
10
  import { createLogger } from '../logger.js';
11
+ import * as errore from 'errore';
11
12
  const logger = createLogger('SESSION');
12
13
  export async function handleSessionCommand({ command, appId }) {
13
14
  await command.deferReply({ ephemeral: false });
@@ -44,6 +45,10 @@ export async function handleSessionCommand({ command, appId }) {
44
45
  }
45
46
  try {
46
47
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
48
+ if (errore.isError(getClient)) {
49
+ await command.editReply(getClient.message);
50
+ return;
51
+ }
47
52
  const files = filesString
48
53
  .split(',')
49
54
  .map((f) => f.trim())
@@ -102,6 +107,10 @@ async function handleAgentAutocomplete({ interaction, appId }) {
102
107
  }
103
108
  try {
104
109
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
110
+ if (errore.isError(getClient)) {
111
+ await interaction.respond([]);
112
+ return;
113
+ }
105
114
  const agentsResponse = await getClient().app.agents({
106
115
  query: { directory: projectDirectory },
107
116
  });
@@ -165,6 +174,10 @@ export async function handleSessionAutocomplete({ interaction, appId, }) {
165
174
  }
166
175
  try {
167
176
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
177
+ if (errore.isError(getClient)) {
178
+ await interaction.respond([]);
179
+ return;
180
+ }
168
181
  const response = await getClient().find.files({
169
182
  query: {
170
183
  query: currentQuery || '',
@@ -4,6 +4,7 @@ import { getDatabase } from '../database.js';
4
4
  import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
6
  import { createLogger } from '../logger.js';
7
+ import * as errore from 'errore';
7
8
  const logger = createLogger('SHARE');
8
9
  export async function handleShareCommand({ command }) {
9
10
  const channel = command.channel;
@@ -50,8 +51,16 @@ export async function handleShareCommand({ command }) {
50
51
  return;
51
52
  }
52
53
  const sessionId = row.session_id;
54
+ const getClient = await initializeOpencodeForDirectory(directory);
55
+ if (errore.isError(getClient)) {
56
+ await command.reply({
57
+ content: `Failed to share session: ${getClient.message}`,
58
+ ephemeral: true,
59
+ flags: SILENT_MESSAGE_FLAGS,
60
+ });
61
+ return;
62
+ }
53
63
  try {
54
- const getClient = await initializeOpencodeForDirectory(directory);
55
64
  const response = await getClient().session.share({
56
65
  path: { id: sessionId },
57
66
  });