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
package/dist/voice.js CHANGED
@@ -1,49 +1,51 @@
1
1
  // Audio transcription service using Google Gemini.
2
2
  // Transcribes voice messages with code-aware context, using grep/glob tools
3
3
  // to verify technical terms, filenames, and function names in the codebase.
4
+ // Uses errore for type-safe error handling.
4
5
  import { GoogleGenAI, Type } from '@google/genai';
6
+ import * as errore from 'errore';
5
7
  import { createLogger } from './logger.js';
6
8
  import { glob } from 'glob';
7
9
  import { ripGrep } from 'ripgrep-js';
10
+ import { ApiKeyMissingError, InvalidAudioFormatError, TranscriptionError, EmptyTranscriptionError, NoResponseContentError, NoToolResponseError, GrepSearchError, GlobSearchError, } from './errors.js';
8
11
  const voiceLogger = createLogger('VOICE');
9
- async function runGrep({ pattern, directory, }) {
10
- try {
11
- const results = await ripGrep(directory, {
12
- string: pattern,
13
- globs: ['!node_modules/**', '!.git/**', '!dist/**', '!build/**'],
14
- });
15
- if (results.length === 0) {
16
- return 'No matches found';
17
- }
18
- const output = results
19
- .slice(0, 10)
20
- .map((match) => {
21
- return `${match.path.text}:${match.line_number}: ${match.lines.text.trim()}`;
22
- })
23
- .join('\n');
24
- return output.slice(0, 2000);
25
- }
26
- catch (e) {
27
- voiceLogger.error('grep search failed:', e);
28
- return 'grep search failed';
29
- }
12
+ function runGrep({ pattern, directory }) {
13
+ return errore.tryAsync({
14
+ try: async () => {
15
+ const results = await ripGrep(directory, {
16
+ string: pattern,
17
+ globs: ['!node_modules/**', '!.git/**', '!dist/**', '!build/**'],
18
+ });
19
+ if (results.length === 0) {
20
+ return 'No matches found';
21
+ }
22
+ const output = results
23
+ .slice(0, 10)
24
+ .map((match) => {
25
+ return `${match.path.text}:${match.line_number}: ${match.lines.text.trim()}`;
26
+ })
27
+ .join('\n');
28
+ return output.slice(0, 2000);
29
+ },
30
+ catch: (e) => new GrepSearchError({ pattern, cause: e }),
31
+ });
30
32
  }
31
- async function runGlob({ pattern, directory, }) {
32
- try {
33
- const files = await glob(pattern, {
34
- cwd: directory,
35
- nodir: false,
36
- ignore: ['node_modules/**', '.git/**', 'dist/**', 'build/**'],
37
- maxDepth: 10,
38
- });
39
- if (files.length === 0) {
40
- return 'No files found';
41
- }
42
- return files.slice(0, 30).join('\n');
43
- }
44
- catch (error) {
45
- return `Glob search failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
46
- }
33
+ function runGlob({ pattern, directory }) {
34
+ return errore.tryAsync({
35
+ try: async () => {
36
+ const files = await glob(pattern, {
37
+ cwd: directory,
38
+ nodir: false,
39
+ ignore: ['node_modules/**', '.git/**', 'dist/**', 'build/**'],
40
+ maxDepth: 10,
41
+ });
42
+ if (files.length === 0) {
43
+ return 'No files found';
44
+ }
45
+ return files.slice(0, 30).join('\n');
46
+ },
47
+ catch: (e) => new GlobSearchError({ pattern, cause: e }),
48
+ });
47
49
  }
48
50
  const grepToolDeclaration = {
49
51
  name: 'grep',
@@ -99,14 +101,28 @@ function createToolRunner({ directory }) {
99
101
  if (name === 'grep' && hasDirectory) {
100
102
  const pattern = args?.pattern || '';
101
103
  voiceLogger.log(`Grep search: "${pattern}"`);
102
- const output = await runGrep({ pattern, directory });
104
+ const result = await runGrep({ pattern, directory });
105
+ const output = (() => {
106
+ if (errore.isError(result)) {
107
+ voiceLogger.error('grep search failed:', result);
108
+ return 'grep search failed';
109
+ }
110
+ return result;
111
+ })();
103
112
  voiceLogger.log(`Grep result: ${output.slice(0, 100)}...`);
104
113
  return { type: 'toolResponse', name: 'grep', output };
105
114
  }
106
115
  if (name === 'glob' && hasDirectory) {
107
116
  const pattern = args?.pattern || '';
108
117
  voiceLogger.log(`Glob search: "${pattern}"`);
109
- const output = await runGlob({ pattern, directory });
118
+ const result = await runGlob({ pattern, directory });
119
+ const output = (() => {
120
+ if (errore.isError(result)) {
121
+ voiceLogger.error('glob search failed:', result);
122
+ return 'glob search failed';
123
+ }
124
+ return result;
125
+ })();
110
126
  voiceLogger.log(`Glob result: ${output.slice(0, 100)}...`);
111
127
  return { type: 'toolResponse', name: 'glob', output };
112
128
  }
@@ -114,17 +130,25 @@ function createToolRunner({ directory }) {
114
130
  };
115
131
  }
116
132
  export async function runTranscriptionLoop({ genAI, model, initialContents, tools, temperature, toolRunner, maxSteps = 10, }) {
117
- let response = await genAI.models.generateContent({
118
- model,
119
- contents: initialContents,
120
- config: {
121
- temperature,
122
- thinkingConfig: {
123
- thinkingBudget: 1024,
133
+ // Wrap external API call that can throw
134
+ const initialResponse = await errore.tryAsync({
135
+ try: () => genAI.models.generateContent({
136
+ model,
137
+ contents: initialContents,
138
+ config: {
139
+ temperature,
140
+ thinkingConfig: {
141
+ thinkingBudget: 1024,
142
+ },
143
+ tools,
124
144
  },
125
- tools,
126
- },
145
+ }),
146
+ catch: (e) => new TranscriptionError({ reason: `API call failed: ${String(e)}`, cause: e }),
127
147
  });
148
+ if (errore.isError(initialResponse)) {
149
+ return initialResponse;
150
+ }
151
+ let response = initialResponse;
128
152
  const conversationHistory = [...initialContents];
129
153
  let stepsRemaining = maxSteps;
130
154
  while (true) {
@@ -135,7 +159,7 @@ export async function runTranscriptionLoop({ genAI, model, initialContents, tool
135
159
  voiceLogger.log(`No parts but got text response: "${text.slice(0, 100)}..."`);
136
160
  return text;
137
161
  }
138
- throw new Error('Transcription failed: No response content from model');
162
+ return new NoResponseContentError();
139
163
  }
140
164
  const functionCalls = candidate.content.parts.filter((part) => 'functionCall' in part && !!part.functionCall);
141
165
  if (functionCalls.length === 0) {
@@ -144,7 +168,7 @@ export async function runTranscriptionLoop({ genAI, model, initialContents, tool
144
168
  voiceLogger.log(`No function calls but got text: "${text.slice(0, 100)}..."`);
145
169
  return text;
146
170
  }
147
- throw new Error('Transcription failed: Model did not produce a transcription');
171
+ return new TranscriptionError({ reason: 'Model did not produce a transcription' });
148
172
  }
149
173
  conversationHistory.push({
150
174
  role: 'model',
@@ -159,7 +183,7 @@ export async function runTranscriptionLoop({ genAI, model, initialContents, tool
159
183
  const transcription = result.transcription?.trim() || '';
160
184
  voiceLogger.log(`Transcription result received: "${transcription.slice(0, 100)}..."`);
161
185
  if (!transcription) {
162
- throw new Error('Transcription failed: Model returned empty transcription');
186
+ return new EmptyTranscriptionError();
163
187
  }
164
188
  return transcription;
165
189
  }
@@ -186,67 +210,76 @@ export async function runTranscriptionLoop({ genAI, model, initialContents, tool
186
210
  }
187
211
  }
188
212
  if (functionResponseParts.length === 0) {
189
- throw new Error('Transcription failed: No valid tool responses');
213
+ return new NoToolResponseError();
190
214
  }
191
215
  conversationHistory.push({
192
216
  role: 'user',
193
217
  parts: functionResponseParts,
194
218
  });
195
- response = await genAI.models.generateContent({
196
- model,
197
- contents: conversationHistory,
198
- config: {
199
- temperature,
200
- thinkingConfig: {
201
- thinkingBudget: 512,
219
+ // Wrap external API call that can throw
220
+ const nextResponse = await errore.tryAsync({
221
+ try: () => genAI.models.generateContent({
222
+ model,
223
+ contents: conversationHistory,
224
+ config: {
225
+ temperature,
226
+ thinkingConfig: {
227
+ thinkingBudget: 512,
228
+ },
229
+ tools: stepsRemaining <= 0
230
+ ? [{ functionDeclarations: [transcriptionResultToolDeclaration] }]
231
+ : tools,
202
232
  },
203
- tools: stepsRemaining <= 0
204
- ? [{ functionDeclarations: [transcriptionResultToolDeclaration] }]
205
- : tools,
206
- },
233
+ }),
234
+ catch: (e) => new TranscriptionError({ reason: `API call failed: ${String(e)}`, cause: e }),
207
235
  });
236
+ if (errore.isError(nextResponse)) {
237
+ return nextResponse;
238
+ }
239
+ response = nextResponse;
208
240
  }
209
241
  }
210
- export async function transcribeAudio({ audio, prompt, language, temperature, geminiApiKey, directory, currentSessionContext, lastSessionContext, }) {
211
- try {
212
- const apiKey = geminiApiKey || process.env.GEMINI_API_KEY;
213
- if (!apiKey) {
214
- throw new Error('Gemini API key is required for audio transcription');
215
- }
216
- const genAI = new GoogleGenAI({ apiKey });
217
- let audioBase64;
242
+ export function transcribeAudio({ audio, prompt, language, temperature, geminiApiKey, directory, currentSessionContext, lastSessionContext, }) {
243
+ const apiKey = geminiApiKey || process.env.GEMINI_API_KEY;
244
+ if (!apiKey) {
245
+ return Promise.resolve(new ApiKeyMissingError({ service: 'Gemini' }));
246
+ }
247
+ const genAI = new GoogleGenAI({ apiKey });
248
+ const audioBase64 = (() => {
218
249
  if (typeof audio === 'string') {
219
- audioBase64 = audio;
220
- }
221
- else if (audio instanceof Buffer) {
222
- audioBase64 = audio.toString('base64');
250
+ return audio;
223
251
  }
224
- else if (audio instanceof Uint8Array) {
225
- audioBase64 = Buffer.from(audio).toString('base64');
252
+ if (audio instanceof Buffer) {
253
+ return audio.toString('base64');
226
254
  }
227
- else if (audio instanceof ArrayBuffer) {
228
- audioBase64 = Buffer.from(audio).toString('base64');
255
+ if (audio instanceof Uint8Array) {
256
+ return Buffer.from(audio).toString('base64');
229
257
  }
230
- else {
231
- throw new Error('Invalid audio format');
258
+ if (audio instanceof ArrayBuffer) {
259
+ return Buffer.from(audio).toString('base64');
232
260
  }
233
- const languageHint = language ? `The audio is in ${language}.\n\n` : '';
234
- // build session context section
235
- const sessionContextParts = [];
236
- if (lastSessionContext) {
237
- sessionContextParts.push(`<last_session>
261
+ return '';
262
+ })();
263
+ if (!audioBase64) {
264
+ return Promise.resolve(new InvalidAudioFormatError());
265
+ }
266
+ const languageHint = language ? `The audio is in ${language}.\n\n` : '';
267
+ // build session context section
268
+ const sessionContextParts = [];
269
+ if (lastSessionContext) {
270
+ sessionContextParts.push(`<last_session>
238
271
  ${lastSessionContext}
239
272
  </last_session>`);
240
- }
241
- if (currentSessionContext) {
242
- sessionContextParts.push(`<current_session>
273
+ }
274
+ if (currentSessionContext) {
275
+ sessionContextParts.push(`<current_session>
243
276
  ${currentSessionContext}
244
277
  </current_session>`);
245
- }
246
- const sessionContextSection = sessionContextParts.length > 0
247
- ? `\nSession context (use to understand references to files, functions, tools used):\n${sessionContextParts.join('\n\n')}`
248
- : '';
249
- const transcriptionPrompt = `${languageHint}Transcribe this audio for a coding agent (like Claude Code or OpenCode).
278
+ }
279
+ const sessionContextSection = sessionContextParts.length > 0
280
+ ? `\nSession context (use to understand references to files, functions, tools used):\n${sessionContextParts.join('\n\n')}`
281
+ : '';
282
+ const transcriptionPrompt = `${languageHint}Transcribe this audio for a coding agent (like Claude Code or OpenCode).
250
283
 
251
284
  CRITICAL REQUIREMENT: You MUST call the "transcriptionResult" tool to complete this task.
252
285
  - The transcriptionResult tool is the ONLY way to return results
@@ -275,42 +308,37 @@ ${sessionContextSection}
275
308
  REMEMBER: Call "transcriptionResult" tool with your transcription. This is mandatory.
276
309
 
277
310
  Note: "critique" is a CLI tool for showing diffs in the browser.`;
278
- // const hasDirectory = directory && directory.trim().length > 0
279
- const tools = [
280
- {
281
- functionDeclarations: [
282
- transcriptionResultToolDeclaration,
283
- // grep/glob disabled - was causing transcription to hang
284
- // ...(hasDirectory ? [grepToolDeclaration, globToolDeclaration] : []),
285
- ],
286
- },
287
- ];
288
- const initialContents = [
289
- {
290
- role: 'user',
291
- parts: [
292
- { text: transcriptionPrompt },
293
- {
294
- inlineData: {
295
- data: audioBase64,
296
- mimeType: 'audio/mpeg',
297
- },
311
+ // const hasDirectory = directory && directory.trim().length > 0
312
+ const tools = [
313
+ {
314
+ functionDeclarations: [
315
+ transcriptionResultToolDeclaration,
316
+ // grep/glob disabled - was causing transcription to hang
317
+ // ...(hasDirectory ? [grepToolDeclaration, globToolDeclaration] : []),
318
+ ],
319
+ },
320
+ ];
321
+ const initialContents = [
322
+ {
323
+ role: 'user',
324
+ parts: [
325
+ { text: transcriptionPrompt },
326
+ {
327
+ inlineData: {
328
+ data: audioBase64,
329
+ mimeType: 'audio/mpeg',
298
330
  },
299
- ],
300
- },
301
- ];
302
- const toolRunner = createToolRunner({ directory });
303
- return await runTranscriptionLoop({
304
- genAI,
305
- model: 'gemini-2.5-flash',
306
- initialContents,
307
- tools,
308
- temperature: temperature ?? 0.3,
309
- toolRunner,
310
- });
311
- }
312
- catch (error) {
313
- voiceLogger.error('Failed to transcribe audio:', error);
314
- throw new Error(`Audio transcription failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
315
- }
331
+ },
332
+ ],
333
+ },
334
+ ];
335
+ const toolRunner = createToolRunner({ directory });
336
+ return runTranscriptionLoop({
337
+ genAI,
338
+ model: 'gemini-2.5-flash',
339
+ initialContents,
340
+ tools,
341
+ temperature: temperature ?? 0.3,
342
+ toolRunner,
343
+ });
316
344
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.37",
5
+ "version": "0.4.39",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
@@ -41,6 +41,7 @@
41
41
  "cac": "^6.7.14",
42
42
  "discord.js": "^14.16.3",
43
43
  "domhandler": "^5.0.3",
44
+ "errore": "^0.5.2",
44
45
  "glob": "^13.0.0",
45
46
  "htmlparser2": "^10.0.0",
46
47
  "js-yaml": "^4.1.0",
@@ -11,7 +11,9 @@ export async function ensureKimakiCategory(
11
11
  guild: Guild,
12
12
  botName?: string,
13
13
  ): Promise<CategoryChannel> {
14
- const categoryName = botName ? `Kimaki ${botName}` : 'Kimaki'
14
+ // Skip appending bot name if it's already "kimaki" to avoid "Kimaki kimaki"
15
+ const isKimakiBot = botName?.toLowerCase() === 'kimaki'
16
+ const categoryName = botName && !isKimakiBot ? `Kimaki ${botName}` : 'Kimaki'
15
17
 
16
18
  const existingCategory = guild.channels.cache.find((channel): channel is CategoryChannel => {
17
19
  if (channel.type !== ChannelType.GuildCategory) {
@@ -35,7 +37,9 @@ export async function ensureKimakiAudioCategory(
35
37
  guild: Guild,
36
38
  botName?: string,
37
39
  ): Promise<CategoryChannel> {
38
- const categoryName = botName ? `Kimaki Audio ${botName}` : 'Kimaki Audio'
40
+ // Skip appending bot name if it's already "kimaki" to avoid "Kimaki Audio kimaki"
41
+ const isKimakiBot = botName?.toLowerCase() === 'kimaki'
42
+ const categoryName = botName && !isKimakiBot ? `Kimaki Audio ${botName}` : 'Kimaki Audio'
39
43
 
40
44
  const existingCategory = guild.channels.cache.find((channel): channel is CategoryChannel => {
41
45
  if (channel.type !== ChannelType.GuildCategory) {
package/src/cli.ts CHANGED
@@ -40,12 +40,14 @@ import {
40
40
  } from 'discord.js'
41
41
  import path from 'node:path'
42
42
  import fs from 'node:fs'
43
+ import * as errore from 'errore'
43
44
 
44
45
  import { createLogger } from './logger.js'
45
46
  import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
46
47
  import http from 'node:http'
47
48
  import { setDataDir, getDataDir, getLockPort } from './config.js'
48
49
  import { extractTagsArrays } from './xml.js'
50
+ import { sanitizeAgentName } from './commands/agent.js'
49
51
 
50
52
  const cliLogger = createLogger('CLI')
51
53
  const cli = cac('kimaki')
@@ -176,11 +178,24 @@ type CliOptions = {
176
178
  // Commands to skip when registering user commands (reserved names)
177
179
  const SKIP_USER_COMMANDS = ['init']
178
180
 
179
- async function registerCommands(
180
- token: string,
181
- appId: string,
182
- userCommands: OpencodeCommand[] = [],
183
- ) {
181
+ type AgentInfo = {
182
+ name: string
183
+ description?: string
184
+ mode: string
185
+ hidden?: boolean
186
+ }
187
+
188
+ async function registerCommands({
189
+ token,
190
+ appId,
191
+ userCommands = [],
192
+ agents = [],
193
+ }: {
194
+ token: string
195
+ appId: string
196
+ userCommands?: OpencodeCommand[]
197
+ agents?: AgentInfo[]
198
+ }) {
184
199
  const commands = [
185
200
  new SlashCommandBuilder()
186
201
  .setName('resume')
@@ -329,6 +344,24 @@ async function registerCommands(
329
344
  )
330
345
  }
331
346
 
347
+ // Add agent-specific quick commands like /plan-agent, /build-agent
348
+ // Filter to primary/all mode agents (same as /agent command shows), excluding hidden agents
349
+ const primaryAgents = agents.filter(
350
+ (a) => (a.mode === 'primary' || a.mode === 'all') && !a.hidden,
351
+ )
352
+ for (const agent of primaryAgents) {
353
+ const sanitizedName = sanitizeAgentName(agent.name)
354
+ const commandName = `${sanitizedName}-agent`
355
+ const description = agent.description || `Switch to ${agent.name} agent`
356
+
357
+ commands.push(
358
+ new SlashCommandBuilder()
359
+ .setName(commandName.slice(0, 32)) // Discord limits to 32 chars
360
+ .setDescription(description.slice(0, 100))
361
+ .toJSON(),
362
+ )
363
+ }
364
+
332
365
  const rest = new REST().setToken(token)
333
366
 
334
367
  try {
@@ -548,7 +581,12 @@ async function run({ restart, addChannels }: CliOptions) {
548
581
  // This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
549
582
  const currentDir = process.cwd()
550
583
  s.start('Starting OpenCode server...')
551
- const opencodePromise = initializeOpencodeForDirectory(currentDir)
584
+ const opencodePromise = initializeOpencodeForDirectory(currentDir).then((result) => {
585
+ if (errore.isError(result)) {
586
+ throw new Error(result.message)
587
+ }
588
+ return result
589
+ })
552
590
 
553
591
  s.message('Connecting to Discord...')
554
592
  const discordClient = await createDiscordClient()
@@ -669,8 +707,8 @@ async function run({ restart, addChannels }: CliOptions) {
669
707
 
670
708
  s.start('Fetching OpenCode data...')
671
709
 
672
- // Fetch projects and commands in parallel
673
- const [projects, allUserCommands] = await Promise.all([
710
+ // Fetch projects, commands, and agents in parallel
711
+ const [projects, allUserCommands, allAgents] = await Promise.all([
674
712
  getClient()
675
713
  .project.list({})
676
714
  .then((r) => r.data || [])
@@ -684,6 +722,10 @@ async function run({ restart, addChannels }: CliOptions) {
684
722
  .command.list({ query: { directory: currentDir } })
685
723
  .then((r) => r.data || [])
686
724
  .catch(() => []),
725
+ getClient()
726
+ .app.agents({ query: { directory: currentDir } })
727
+ .then((r) => r.data || [])
728
+ .catch(() => []),
687
729
  ])
688
730
 
689
731
  s.stop(`Found ${projects.length} OpenCode project(s)`)
@@ -805,7 +847,7 @@ async function run({ restart, addChannels }: CliOptions) {
805
847
  }
806
848
 
807
849
  cliLogger.log('Registering slash commands asynchronously...')
808
- void registerCommands(token, appId, allUserCommands)
850
+ void registerCommands({ token, appId, userCommands: allUserCommands, agents: allAgents })
809
851
  .then(() => {
810
852
  cliLogger.log('Slash commands registered!')
811
853
  })
@@ -999,12 +1041,7 @@ cli
999
1041
  }
1000
1042
  })
1001
1043
 
1002
- // Magic prefix used to identify bot-initiated sessions.
1003
- // The running bot will recognize this prefix and start a session.
1004
- const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
1005
- // Notify-only prefix - bot won't start a session, just creates thread for notifications.
1006
- // Reply to the thread to start a session with the notification as context.
1007
- const BOT_NOTIFY_PREFIX = '📢 **Notification**'
1044
+
1008
1045
 
1009
1046
  cli
1010
1047
  .command(
@@ -1263,9 +1300,7 @@ cli
1263
1300
 
1264
1301
  s.message('Creating starter message...')
1265
1302
 
1266
- // Create starter message with magic prefix
1267
- // BOT_SESSION_PREFIX triggers AI session, BOT_NOTIFY_PREFIX is notification-only
1268
- const messagePrefix = notifyOnly ? BOT_NOTIFY_PREFIX : BOT_SESSION_PREFIX
1303
+ // Create starter message with just the prompt (no prefix)
1269
1304
  const starterMessageResponse = await fetch(
1270
1305
  `https://discord.com/api/v10/channels/${channelId}/messages`,
1271
1306
  {
@@ -1275,7 +1310,7 @@ cli
1275
1310
  'Content-Type': 'application/json',
1276
1311
  },
1277
1312
  body: JSON.stringify({
1278
- content: `${messagePrefix}\n${prompt}`,
1313
+ content: prompt,
1279
1314
  }),
1280
1315
  },
1281
1316
  )
@@ -1315,6 +1350,19 @@ cli
1315
1350
 
1316
1351
  const threadData = (await threadResponse.json()) as { id: string; name: string }
1317
1352
 
1353
+ // Mark thread for auto-start if not notify-only
1354
+ // This is optional - only works if local database exists (for local bot auto-start)
1355
+ if (!notifyOnly) {
1356
+ try {
1357
+ const db = getDatabase()
1358
+ db.prepare('INSERT OR REPLACE INTO pending_auto_start (thread_id) VALUES (?)').run(
1359
+ threadData.id,
1360
+ )
1361
+ } catch {
1362
+ // Database not available (e.g., CI environment) - skip auto-start marking
1363
+ }
1364
+ }
1365
+
1318
1366
  s.stop('Thread created!')
1319
1367
 
1320
1368
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
@@ -7,6 +7,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js'
7
7
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
8
8
  import { abortControllers } from '../session-handler.js'
9
9
  import { createLogger } from '../logger.js'
10
+ import * as errore from 'errore'
10
11
 
11
12
  const logger = createLogger('ABORT')
12
13
 
@@ -64,14 +65,23 @@ export async function handleAbortCommand({ command }: CommandContext): Promise<v
64
65
 
65
66
  const sessionId = row.session_id
66
67
 
67
- try {
68
- const existingController = abortControllers.get(sessionId)
69
- if (existingController) {
70
- existingController.abort(new Error('User requested abort'))
71
- abortControllers.delete(sessionId)
72
- }
68
+ const existingController = abortControllers.get(sessionId)
69
+ if (existingController) {
70
+ existingController.abort(new Error('User requested abort'))
71
+ abortControllers.delete(sessionId)
72
+ }
73
+
74
+ const getClient = await initializeOpencodeForDirectory(directory)
75
+ if (errore.isError(getClient)) {
76
+ await command.reply({
77
+ content: `Failed to abort: ${getClient.message}`,
78
+ ephemeral: true,
79
+ flags: SILENT_MESSAGE_FLAGS,
80
+ })
81
+ return
82
+ }
73
83
 
74
- const getClient = await initializeOpencodeForDirectory(directory)
84
+ try {
75
85
  await getClient().session.abort({
76
86
  path: { id: sessionId },
77
87
  })
@@ -8,6 +8,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js'
8
8
  import { createProjectChannels } from '../channel-management.js'
9
9
  import { createLogger } from '../logger.js'
10
10
  import { abbreviatePath } from '../utils.js'
11
+ import * as errore from 'errore'
11
12
 
12
13
  const logger = createLogger('ADD-PROJECT')
13
14
 
@@ -25,6 +26,10 @@ export async function handleAddProjectCommand({ command, appId }: CommandContext
25
26
  try {
26
27
  const currentDir = process.cwd()
27
28
  const getClient = await initializeOpencodeForDirectory(currentDir)
29
+ if (errore.isError(getClient)) {
30
+ await command.editReply(getClient.message)
31
+ return
32
+ }
28
33
 
29
34
  const projectsResponse = await getClient().project.list({})
30
35
  if (!projectsResponse.data) {
@@ -89,6 +94,10 @@ export async function handleAddProjectAutocomplete({
89
94
  try {
90
95
  const currentDir = process.cwd()
91
96
  const getClient = await initializeOpencodeForDirectory(currentDir)
97
+ if (errore.isError(getClient)) {
98
+ await interaction.respond([])
99
+ return
100
+ }
92
101
 
93
102
  const projectsResponse = await getClient().project.list({})
94
103
  if (!projectsResponse.data) {