kimaki 0.4.24 → 0.4.26

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 (86) hide show
  1. package/bin.js +6 -1
  2. package/dist/acp-client.test.js +149 -0
  3. package/dist/ai-tool-to-genai.js +3 -0
  4. package/dist/channel-management.js +14 -9
  5. package/dist/cli.js +148 -17
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +98 -0
  8. package/dist/commands/agent.js +152 -0
  9. package/dist/commands/ask-question.js +183 -0
  10. package/dist/commands/create-new-project.js +78 -0
  11. package/dist/commands/fork.js +186 -0
  12. package/dist/commands/model.js +313 -0
  13. package/dist/commands/permissions.js +126 -0
  14. package/dist/commands/queue.js +129 -0
  15. package/dist/commands/resume.js +145 -0
  16. package/dist/commands/session.js +142 -0
  17. package/dist/commands/share.js +80 -0
  18. package/dist/commands/types.js +2 -0
  19. package/dist/commands/undo-redo.js +161 -0
  20. package/dist/commands/user-command.js +145 -0
  21. package/dist/database.js +54 -0
  22. package/dist/discord-bot.js +35 -32
  23. package/dist/discord-utils.js +81 -15
  24. package/dist/format-tables.js +3 -0
  25. package/dist/genai-worker-wrapper.js +3 -0
  26. package/dist/genai-worker.js +3 -0
  27. package/dist/genai.js +3 -0
  28. package/dist/interaction-handler.js +89 -695
  29. package/dist/logger.js +46 -5
  30. package/dist/markdown.js +107 -0
  31. package/dist/markdown.test.js +31 -1
  32. package/dist/message-formatting.js +113 -28
  33. package/dist/message-formatting.test.js +73 -0
  34. package/dist/opencode.js +73 -16
  35. package/dist/session-handler.js +176 -63
  36. package/dist/system-message.js +7 -38
  37. package/dist/tools.js +3 -0
  38. package/dist/utils.js +3 -0
  39. package/dist/voice-handler.js +21 -8
  40. package/dist/voice.js +31 -12
  41. package/dist/worker-types.js +3 -0
  42. package/dist/xml.js +3 -0
  43. package/package.json +3 -3
  44. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  45. package/src/__snapshots__/compact-session-context.md +47 -0
  46. package/src/ai-tool-to-genai.ts +4 -0
  47. package/src/channel-management.ts +24 -8
  48. package/src/cli.ts +163 -18
  49. package/src/commands/abort.ts +94 -0
  50. package/src/commands/add-project.ts +139 -0
  51. package/src/commands/agent.ts +201 -0
  52. package/src/commands/ask-question.ts +276 -0
  53. package/src/commands/create-new-project.ts +111 -0
  54. package/src/{fork.ts → commands/fork.ts} +40 -7
  55. package/src/{model-command.ts → commands/model.ts} +31 -9
  56. package/src/commands/permissions.ts +146 -0
  57. package/src/commands/queue.ts +181 -0
  58. package/src/commands/resume.ts +230 -0
  59. package/src/commands/session.ts +184 -0
  60. package/src/commands/share.ts +96 -0
  61. package/src/commands/types.ts +25 -0
  62. package/src/commands/undo-redo.ts +213 -0
  63. package/src/commands/user-command.ts +178 -0
  64. package/src/database.ts +65 -0
  65. package/src/discord-bot.ts +40 -33
  66. package/src/discord-utils.ts +88 -14
  67. package/src/format-tables.ts +4 -0
  68. package/src/genai-worker-wrapper.ts +4 -0
  69. package/src/genai-worker.ts +4 -0
  70. package/src/genai.ts +4 -0
  71. package/src/interaction-handler.ts +111 -924
  72. package/src/logger.ts +51 -10
  73. package/src/markdown.test.ts +45 -1
  74. package/src/markdown.ts +136 -0
  75. package/src/message-formatting.test.ts +81 -0
  76. package/src/message-formatting.ts +143 -30
  77. package/src/opencode.ts +84 -21
  78. package/src/session-handler.ts +248 -91
  79. package/src/system-message.ts +8 -38
  80. package/src/tools.ts +4 -0
  81. package/src/utils.ts +4 -0
  82. package/src/voice-handler.ts +24 -9
  83. package/src/voice.ts +36 -13
  84. package/src/worker-types.ts +4 -0
  85. package/src/xml.ts +4 -0
  86. package/README.md +0 -48
package/dist/voice.js CHANGED
@@ -1,3 +1,6 @@
1
+ // Audio transcription service using Google Gemini.
2
+ // Transcribes voice messages with code-aware context, using grep/glob tools
3
+ // to verify technical terms, filenames, and function names in the codebase.
1
4
  import { GoogleGenAI, Type, } from '@google/genai';
2
5
  import { createLogger } from './logger.js';
3
6
  import { glob } from 'glob';
@@ -20,7 +23,8 @@ async function runGrep({ pattern, directory, }) {
20
23
  .join('\n');
21
24
  return output.slice(0, 2000);
22
25
  }
23
- catch {
26
+ catch (e) {
27
+ voiceLogger.error('grep search failed:', e);
24
28
  return 'grep search failed';
25
29
  }
26
30
  }
@@ -201,7 +205,7 @@ export async function runTranscriptionLoop({ genAI, model, initialContents, tool
201
205
  });
202
206
  }
203
207
  }
204
- export async function transcribeAudio({ audio, prompt, language, temperature, geminiApiKey, directory, sessionMessages, }) {
208
+ export async function transcribeAudio({ audio, prompt, language, temperature, geminiApiKey, directory, currentSessionContext, lastSessionContext, }) {
205
209
  try {
206
210
  const apiKey = geminiApiKey || process.env.GEMINI_API_KEY;
207
211
  if (!apiKey) {
@@ -225,6 +229,21 @@ export async function transcribeAudio({ audio, prompt, language, temperature, ge
225
229
  throw new Error('Invalid audio format');
226
230
  }
227
231
  const languageHint = language ? `The audio is in ${language}.\n\n` : '';
232
+ // build session context section
233
+ const sessionContextParts = [];
234
+ if (lastSessionContext) {
235
+ sessionContextParts.push(`<last_session>
236
+ ${lastSessionContext}
237
+ </last_session>`);
238
+ }
239
+ if (currentSessionContext) {
240
+ sessionContextParts.push(`<current_session>
241
+ ${currentSessionContext}
242
+ </current_session>`);
243
+ }
244
+ const sessionContextSection = sessionContextParts.length > 0
245
+ ? `\nSession context (use to understand references to files, functions, tools used):\n${sessionContextParts.join('\n\n')}`
246
+ : '';
228
247
  const transcriptionPrompt = `${languageHint}Transcribe this audio for a coding agent (like Claude Code or OpenCode).
229
248
 
230
249
  CRITICAL REQUIREMENT: You MUST call the "transcriptionResult" tool to complete this task.
@@ -238,29 +257,29 @@ This is a software development environment. The speaker is giving instructions t
238
257
  - File paths, function names, CLI commands, package names, API endpoints
239
258
 
240
259
  RULES:
241
- 1. You have LIMITED tool calls - use grep/glob sparingly, call them in parallel
242
- 2. If audio is unclear, transcribe your best interpretation
243
- 3. If audio seems silent/empty, call transcriptionResult with "[inaudible audio]"
244
- 4. When warned about remaining steps, STOP searching and call transcriptionResult immediately
260
+ 1. If audio is unclear, transcribe your best interpretation, interpreting words event with strong accents are present, identifying the accent being used first so you can guess what the words meawn
261
+ 2. If audio seems silent/empty, call transcriptionResult with "[inaudible audio]"
262
+ 3. Use the session context below to understand technical terms, file names, function names mentioned
245
263
 
246
264
  Common corrections (apply without tool calls):
247
265
  - "reacked" → "React", "jason" → "JSON", "get hub" → "GitHub", "no JS" → "Node.js", "dacker" → "Docker"
248
266
 
249
- Project context for reference:
250
- <context>
267
+ Project file structure:
268
+ <file_tree>
251
269
  ${prompt}
252
- </context>
253
- ${sessionMessages ? `\nRecent session messages:\n<session_messages>\n${sessionMessages}\n</session_messages>` : ''}
270
+ </file_tree>
271
+ ${sessionContextSection}
254
272
 
255
273
  REMEMBER: Call "transcriptionResult" tool with your transcription. This is mandatory.
256
274
 
257
275
  Note: "critique" is a CLI tool for showing diffs in the browser.`;
258
- const hasDirectory = directory && directory.trim().length > 0;
276
+ // const hasDirectory = directory && directory.trim().length > 0
259
277
  const tools = [
260
278
  {
261
279
  functionDeclarations: [
262
280
  transcriptionResultToolDeclaration,
263
- ...(hasDirectory ? [grepToolDeclaration, globToolDeclaration] : []),
281
+ // grep/glob disabled - was causing transcription to hang
282
+ // ...(hasDirectory ? [grepToolDeclaration, globToolDeclaration] : []),
264
283
  ],
265
284
  },
266
285
  ];
@@ -1 +1,4 @@
1
+ // Type definitions for worker thread message passing.
2
+ // Defines the protocol between main thread and GenAI worker for
3
+ // audio streaming, tool calls, and session lifecycle management.
1
4
  export {};
package/dist/xml.js CHANGED
@@ -1,3 +1,6 @@
1
+ // XML/HTML tag content extractor.
2
+ // Parses XML-like tags from strings (e.g., channel topics) to extract
3
+ // Kimaki configuration like directory paths and app IDs.
1
4
  import { DomHandler, Parser, ElementType } from 'htmlparser2';
2
5
  import { createLogger } from './logger.js';
3
6
  const xmlLogger = createLogger('XML');
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.24",
5
+ "version": "0.4.26",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
@@ -21,7 +21,7 @@
21
21
  "bin.js"
22
22
  ],
23
23
  "devDependencies": {
24
- "@opencode-ai/plugin": "^1.0.193",
24
+ "@opencode-ai/plugin": "^1.1.12",
25
25
  "@types/better-sqlite3": "^7.6.13",
26
26
  "@types/bun": "latest",
27
27
  "@types/js-yaml": "^4.0.9",
@@ -35,7 +35,7 @@
35
35
  "@discordjs/opus": "^0.10.0",
36
36
  "@discordjs/voice": "^0.19.0",
37
37
  "@google/genai": "^1.34.0",
38
- "@opencode-ai/sdk": "^1.0.193",
38
+ "@opencode-ai/sdk": "^1.1.12",
39
39
  "@purinton/resampler": "^1.0.4",
40
40
  "@snazzah/davey": "^0.1.6",
41
41
  "ai": "^5.0.114",
@@ -0,0 +1,35 @@
1
+ [Assistant]: Still not showing. The session might not have a `system` field and the first assistant message might not have `synthetic: true`. Let me add debug logging to see what's available:
2
+
3
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= // Get system prompt if requested
4
+ if (includeSystemPrompt) {
5
+ // try session.system fie, newString= // Get system prompt if requested
6
+ if (includeSystemPrompt) {
7
+ // try session.system fie
8
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= } else if (messages.length > 0) {
9
+ // fallback: look for synthetic text part in first a, newString= } else if (messages.length > 0) {
10
+ // fallback: look for synthetic text part in first a
11
+ [Tool bash]: command=pnpm vitest run --testNamePattern "getCompactSessionContext generates" 2>&1 | tail -30, description=run test and check output, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord, timeout=120000
12
+ [Tool bash]: command=grep -i "session keys\|text parts" tmp/kimaki.log | tail -10, description=check debug output, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord
13
+ [Assistant]: Session doesn't have a `system` field. Let me fix the debug logging:
14
+
15
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= const textParts = (firstAssistant.parts || []).filter((p) => p.type === 'text')
16
+ , newString= const textParts = (firstAssistant.parts || []).filter((p) => p.type === 'text')
17
+
18
+ [Tool bash]: command=pnpm vitest run --testNamePattern "getCompactSessionContext generates" 2>&1 && grep -i "text parts" , description=run test and check debug, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord, timeout=120000
19
+ [Assistant]: I see - the first assistant text part has **no `synthetic` field** (it's `undefined`), and it's only 44 chars. The session doesn't expose the system prompt.
20
+
21
+ Let me remove debug logging and fix the test:
22
+
23
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= // Get system prompt if requested
24
+ if (includeSystemPrompt) {
25
+ // try session.system fie, newString= // Get system prompt if requested
26
+ // Note: OpenCode SDK doesn't expose system prompt directl
27
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= try {
28
+ // fetch session info to get system prompt
29
+ const sessionResponse = await client.sess, newString= try {
30
+ const messagesResponse = await client.session.messages({
31
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.test.ts, oldString= expect(context).toBeTruthy()
32
+ expect(context.length).toBeGreaterThan(0)
33
+ // should have tool cal, newString= expect(context).toBeTruthy()
34
+ expect(context.length).toBeGreaterThan(0)
35
+ // should have tool cal
@@ -0,0 +1,47 @@
1
+ [Tool bash]: command=grep -A5 -B5 "Session" node_modules/@opencode-ai/.ignored_sdk/dist/gen/core/types.gen.d.ts 2>/dev/nu, description=check session types, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord
2
+ [Tool read]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, offset=248, limit=35
3
+ [Assistant]: Let me update the code to also look for system role messages and fetch session info:
4
+
5
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= try {
6
+ const messagesResponse = await client.session.messages({
7
+ path: { id: sessionId },
8
+ , newString= try {
9
+ // fetch session info to get system prompt
10
+ const sessionResponse = await client.sess
11
+ [Tool bash]: command=pnpm vitest run -u --testNamePattern "getCompactSessionContext", description=run tests to check system prompt, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord, timeout=120000
12
+ [Tool bash]: command=head -30 discord/src/__snapshots__/compact-session-context.md, description=check if system prompt is in snapshot
13
+ [Assistant]: Still not showing. The session might not have a `system` field and the first assistant message might not have `synthetic: true`. Let me add debug logging to see what's available:
14
+
15
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= // Get system prompt if requested
16
+ if (includeSystemPrompt) {
17
+ // try session.system fie, newString= // Get system prompt if requested
18
+ if (includeSystemPrompt) {
19
+ // try session.system fie
20
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= } else if (messages.length > 0) {
21
+ // fallback: look for synthetic text part in first a, newString= } else if (messages.length > 0) {
22
+ // fallback: look for synthetic text part in first a
23
+ [Tool bash]: command=pnpm vitest run --testNamePattern "getCompactSessionContext generates" 2>&1 | tail -30, description=run test and check output, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord, timeout=120000
24
+ [Tool bash]: command=grep -i "session keys\|text parts" tmp/kimaki.log | tail -10, description=check debug output, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord
25
+ [Assistant]: Session doesn't have a `system` field. Let me fix the debug logging:
26
+
27
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= const textParts = (firstAssistant.parts || []).filter((p) => p.type === 'text')
28
+ , newString= const textParts = (firstAssistant.parts || []).filter((p) => p.type === 'text')
29
+
30
+ [Tool bash]: command=pnpm vitest run --testNamePattern "getCompactSessionContext generates" 2>&1 && grep -i "text parts" , description=run test and check debug, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord, timeout=120000
31
+ [Assistant]: I see - the first assistant text part has **no `synthetic` field** (it's `undefined`), and it's only 44 chars. The session doesn't expose the system prompt.
32
+
33
+ Let me remove debug logging and fix the test:
34
+
35
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= // Get system prompt if requested
36
+ if (includeSystemPrompt) {
37
+ // try session.system fie, newString= // Get system prompt if requested
38
+ // Note: OpenCode SDK doesn't expose system prompt directl
39
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= try {
40
+ // fetch session info to get system prompt
41
+ const sessionResponse = await client.sess, newString= try {
42
+ const messagesResponse = await client.session.messages({
43
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.test.ts, oldString= expect(context).toBeTruthy()
44
+ expect(context.length).toBeGreaterThan(0)
45
+ // should have tool cal, newString= expect(context).toBeTruthy()
46
+ expect(context.length).toBeGreaterThan(0)
47
+ // should have tool cal
@@ -1,3 +1,7 @@
1
+ // AI SDK to Google GenAI tool converter.
2
+ // Transforms Vercel AI SDK tool definitions into Google GenAI CallableTool format
3
+ // for use with Gemini's function calling in the voice assistant.
4
+
1
5
  import type { Tool, jsonSchema as JsonSchemaType } from 'ai'
2
6
  import type {
3
7
  FunctionDeclaration,
@@ -1,3 +1,7 @@
1
+ // Discord channel and category management.
2
+ // Creates and manages Kimaki project channels (text + voice pairs),
3
+ // extracts channel metadata from topic tags, and ensures category structure.
4
+
1
5
  import {
2
6
  ChannelType,
3
7
  type CategoryChannel,
@@ -8,14 +12,19 @@ import path from 'node:path'
8
12
  import { getDatabase } from './database.js'
9
13
  import { extractTagsArrays } from './xml.js'
10
14
 
11
- export async function ensureKimakiCategory(guild: Guild): Promise<CategoryChannel> {
15
+ export async function ensureKimakiCategory(
16
+ guild: Guild,
17
+ botName?: string,
18
+ ): Promise<CategoryChannel> {
19
+ const categoryName = botName ? `Kimaki ${botName}` : 'Kimaki'
20
+
12
21
  const existingCategory = guild.channels.cache.find(
13
22
  (channel): channel is CategoryChannel => {
14
23
  if (channel.type !== ChannelType.GuildCategory) {
15
24
  return false
16
25
  }
17
26
 
18
- return channel.name.toLowerCase() === 'kimaki'
27
+ return channel.name.toLowerCase() === categoryName.toLowerCase()
19
28
  },
20
29
  )
21
30
 
@@ -24,19 +33,24 @@ export async function ensureKimakiCategory(guild: Guild): Promise<CategoryChanne
24
33
  }
25
34
 
26
35
  return guild.channels.create({
27
- name: 'Kimaki',
36
+ name: categoryName,
28
37
  type: ChannelType.GuildCategory,
29
38
  })
30
39
  }
31
40
 
32
- export async function ensureKimakiAudioCategory(guild: Guild): Promise<CategoryChannel> {
41
+ export async function ensureKimakiAudioCategory(
42
+ guild: Guild,
43
+ botName?: string,
44
+ ): Promise<CategoryChannel> {
45
+ const categoryName = botName ? `Kimaki Audio ${botName}` : 'Kimaki Audio'
46
+
33
47
  const existingCategory = guild.channels.cache.find(
34
48
  (channel): channel is CategoryChannel => {
35
49
  if (channel.type !== ChannelType.GuildCategory) {
36
50
  return false
37
51
  }
38
52
 
39
- return channel.name.toLowerCase() === 'kimaki audio'
53
+ return channel.name.toLowerCase() === categoryName.toLowerCase()
40
54
  },
41
55
  )
42
56
 
@@ -45,7 +59,7 @@ export async function ensureKimakiAudioCategory(guild: Guild): Promise<CategoryC
45
59
  }
46
60
 
47
61
  return guild.channels.create({
48
- name: 'Kimaki Audio',
62
+ name: categoryName,
49
63
  type: ChannelType.GuildCategory,
50
64
  })
51
65
  }
@@ -54,10 +68,12 @@ export async function createProjectChannels({
54
68
  guild,
55
69
  projectDirectory,
56
70
  appId,
71
+ botName,
57
72
  }: {
58
73
  guild: Guild
59
74
  projectDirectory: string
60
75
  appId: string
76
+ botName?: string
61
77
  }): Promise<{ textChannelId: string; voiceChannelId: string; channelName: string }> {
62
78
  const baseName = path.basename(projectDirectory)
63
79
  const channelName = `${baseName}`
@@ -65,8 +81,8 @@ export async function createProjectChannels({
65
81
  .replace(/[^a-z0-9-]/g, '-')
66
82
  .slice(0, 100)
67
83
 
68
- const kimakiCategory = await ensureKimakiCategory(guild)
69
- const kimakiAudioCategory = await ensureKimakiAudioCategory(guild)
84
+ const kimakiCategory = await ensureKimakiCategory(guild, botName)
85
+ const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName)
70
86
 
71
87
  const textChannel = await guild.channels.create({
72
88
  name: channelName,
package/src/cli.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ // Main CLI entrypoint for the Kimaki Discord bot.
3
+ // Handles interactive setup, Discord OAuth, slash command registration,
4
+ // project channel creation, and launching the bot with opencode integration.
2
5
  import { cac } from 'cac'
3
6
  import {
4
7
  intro,
@@ -24,7 +27,7 @@ import {
24
27
  createProjectChannels,
25
28
  type ChannelWithTags,
26
29
  } from './discord-bot.js'
27
- import type { OpencodeClient } from '@opencode-ai/sdk'
30
+ import type { OpencodeClient, Command as OpencodeCommand } from '@opencode-ai/sdk'
28
31
  import {
29
32
  Events,
30
33
  ChannelType,
@@ -50,31 +53,84 @@ process.title = 'kimaki'
50
53
 
51
54
  const LOCK_PORT = 29988
52
55
 
56
+ async function killProcessOnPort(port: number): Promise<boolean> {
57
+ const isWindows = process.platform === 'win32'
58
+ const myPid = process.pid
59
+
60
+ try {
61
+ if (isWindows) {
62
+ // Windows: find PID using netstat, then kill
63
+ const result = spawnSync('cmd', ['/c', `for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port} ^| findstr LISTENING') do @echo %a`], {
64
+ shell: false,
65
+ encoding: 'utf-8',
66
+ })
67
+ const pids = result.stdout?.trim().split('\n').map((p) => p.trim()).filter((p) => /^\d+$/.test(p))
68
+ // Filter out our own PID and take the first (oldest)
69
+ const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid)
70
+ if (targetPid) {
71
+ cliLogger.log(`Killing existing kimaki process (PID: ${targetPid})`)
72
+ spawnSync('taskkill', ['/F', '/PID', targetPid], { shell: false })
73
+ return true
74
+ }
75
+ } else {
76
+ // Unix: use lsof with -sTCP:LISTEN to only find the listening process
77
+ const result = spawnSync('lsof', ['-i', `:${port}`, '-sTCP:LISTEN', '-t'], {
78
+ shell: false,
79
+ encoding: 'utf-8',
80
+ })
81
+ const pids = result.stdout?.trim().split('\n').map((p) => p.trim()).filter((p) => /^\d+$/.test(p))
82
+ // Filter out our own PID and take the first (oldest)
83
+ const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid)
84
+ if (targetPid) {
85
+ const pid = parseInt(targetPid, 10)
86
+ cliLogger.log(`Stopping existing kimaki process (PID: ${pid})`)
87
+ process.kill(pid, 'SIGKILL')
88
+ return true
89
+ }
90
+ }
91
+ } catch (e) {
92
+ cliLogger.debug(`Failed to kill process on port ${port}:`, e)
93
+ }
94
+ return false
95
+ }
96
+
53
97
  async function checkSingleInstance(): Promise<void> {
54
98
  try {
55
99
  const response = await fetch(`http://127.0.0.1:${LOCK_PORT}`, {
56
100
  signal: AbortSignal.timeout(1000),
57
101
  })
58
102
  if (response.ok) {
59
- cliLogger.error('Another kimaki instance is already running')
60
- process.exit(1)
103
+ cliLogger.log('Another kimaki instance detected')
104
+ await killProcessOnPort(LOCK_PORT)
105
+ // Wait a moment for port to be released
106
+ await new Promise((resolve) => { setTimeout(resolve, 500) })
61
107
  }
62
108
  } catch {
63
- // Connection refused means no instance running, continue
109
+ cliLogger.debug('No other kimaki instance detected on lock port')
64
110
  }
65
111
  }
66
112
 
67
- function startLockServer(): void {
68
- const server = http.createServer((req, res) => {
69
- res.writeHead(200)
70
- res.end('kimaki')
71
- })
72
- server.listen(LOCK_PORT, '127.0.0.1')
73
- server.on('error', (err: NodeJS.ErrnoException) => {
74
- if (err.code === 'EADDRINUSE') {
75
- cliLogger.error('Another kimaki instance is already running')
76
- process.exit(1)
77
- }
113
+ async function startLockServer(): Promise<void> {
114
+ return new Promise((resolve, reject) => {
115
+ const server = http.createServer((req, res) => {
116
+ res.writeHead(200)
117
+ res.end('kimaki')
118
+ })
119
+ server.listen(LOCK_PORT, '127.0.0.1')
120
+ server.once('listening', () => {
121
+ resolve()
122
+ })
123
+ server.on('error', async (err: NodeJS.ErrnoException) => {
124
+ if (err.code === 'EADDRINUSE') {
125
+ cliLogger.log('Port still in use, retrying...')
126
+ await killProcessOnPort(LOCK_PORT)
127
+ await new Promise((r) => { setTimeout(r, 500) })
128
+ // Retry once
129
+ server.listen(LOCK_PORT, '127.0.0.1')
130
+ } else {
131
+ reject(err)
132
+ }
133
+ })
78
134
  })
79
135
  }
80
136
 
@@ -97,7 +153,10 @@ type CliOptions = {
97
153
  addChannels?: boolean
98
154
  }
99
155
 
100
- async function registerCommands(token: string, appId: string) {
156
+ // Commands to skip when registering user commands (reserved names)
157
+ const SKIP_USER_COMMANDS = ['init']
158
+
159
+ async function registerCommands(token: string, appId: string, userCommands: OpencodeCommand[] = []) {
101
160
  const commands = [
102
161
  new SlashCommandBuilder()
103
162
  .setName('resume')
@@ -176,6 +235,10 @@ async function registerCommands(token: string, appId: string) {
176
235
  .setName('abort')
177
236
  .setDescription('Abort the current OpenCode request in this thread')
178
237
  .toJSON(),
238
+ new SlashCommandBuilder()
239
+ .setName('stop')
240
+ .setDescription('Abort the current OpenCode request in this thread')
241
+ .toJSON(),
179
242
  new SlashCommandBuilder()
180
243
  .setName('share')
181
244
  .setDescription('Share the current session as a public URL')
@@ -188,8 +251,60 @@ async function registerCommands(token: string, appId: string) {
188
251
  .setName('model')
189
252
  .setDescription('Set the preferred model for this channel or session')
190
253
  .toJSON(),
254
+ new SlashCommandBuilder()
255
+ .setName('agent')
256
+ .setDescription('Set the preferred agent for this channel or session')
257
+ .toJSON(),
258
+ new SlashCommandBuilder()
259
+ .setName('queue')
260
+ .setDescription('Queue a message to be sent after the current response finishes')
261
+ .addStringOption((option) => {
262
+ option
263
+ .setName('message')
264
+ .setDescription('The message to queue')
265
+ .setRequired(true)
266
+
267
+ return option
268
+ })
269
+ .toJSON(),
270
+ new SlashCommandBuilder()
271
+ .setName('clear-queue')
272
+ .setDescription('Clear all queued messages in this thread')
273
+ .toJSON(),
274
+ new SlashCommandBuilder()
275
+ .setName('undo')
276
+ .setDescription('Undo the last assistant message (revert file changes)')
277
+ .toJSON(),
278
+ new SlashCommandBuilder()
279
+ .setName('redo')
280
+ .setDescription('Redo previously undone changes')
281
+ .toJSON(),
191
282
  ]
192
283
 
284
+ // Add user-defined commands with -cmd suffix
285
+ for (const cmd of userCommands) {
286
+ if (SKIP_USER_COMMANDS.includes(cmd.name)) {
287
+ continue
288
+ }
289
+
290
+ const commandName = `${cmd.name}-cmd`
291
+ const description = cmd.description || `Run /${cmd.name} command`
292
+
293
+ commands.push(
294
+ new SlashCommandBuilder()
295
+ .setName(commandName)
296
+ .setDescription(description.slice(0, 100)) // Discord limits to 100 chars
297
+ .addStringOption((option) => {
298
+ option
299
+ .setName('arguments')
300
+ .setDescription('Arguments to pass to the command')
301
+ .setRequired(false)
302
+ return option
303
+ })
304
+ .toJSON(),
305
+ )
306
+ }
307
+
193
308
  const rest = new REST().setToken(token)
194
309
 
195
310
  try {
@@ -607,6 +722,7 @@ async function run({ restart, addChannels }: CliOptions) {
607
722
  guild: targetGuild,
608
723
  projectDirectory: project.worktree,
609
724
  appId,
725
+ botName: discordClient.user?.username,
610
726
  })
611
727
 
612
728
  createdChannels.push({
@@ -630,8 +746,37 @@ async function run({ restart, addChannels }: CliOptions) {
630
746
  }
631
747
  }
632
748
 
749
+ // Fetch user-defined commands using the already-running server
750
+ const allUserCommands: OpencodeCommand[] = []
751
+ try {
752
+ const commandsResponse = await getClient().command.list({
753
+ query: { directory: currentDir },
754
+ })
755
+ if (commandsResponse.data) {
756
+ allUserCommands.push(...commandsResponse.data)
757
+ }
758
+ } catch {
759
+ // Ignore errors fetching commands
760
+ }
761
+
762
+ // Log available user commands
763
+ const registrableCommands = allUserCommands.filter(
764
+ (cmd) => !SKIP_USER_COMMANDS.includes(cmd.name),
765
+ )
766
+
767
+ if (registrableCommands.length > 0) {
768
+ const commandList = registrableCommands
769
+ .map((cmd) => ` /${cmd.name}-cmd - ${cmd.description || 'No description'}`)
770
+ .join('\n')
771
+
772
+ note(
773
+ `Found ${registrableCommands.length} user-defined command(s):\n${commandList}`,
774
+ 'OpenCode Commands',
775
+ )
776
+ }
777
+
633
778
  cliLogger.log('Registering slash commands asynchronously...')
634
- void registerCommands(token, appId)
779
+ void registerCommands(token, appId, allUserCommands)
635
780
  .then(() => {
636
781
  cliLogger.log('Slash commands registered!')
637
782
  })
@@ -693,7 +838,7 @@ cli
693
838
  .action(async (options: { restart?: boolean; addChannels?: boolean }) => {
694
839
  try {
695
840
  await checkSingleInstance()
696
- startLockServer()
841
+ await startLockServer()
697
842
  await run({
698
843
  restart: options.restart,
699
844
  addChannels: options.addChannels,
@@ -0,0 +1,94 @@
1
+ // /abort command - Abort the current OpenCode request in this thread.
2
+
3
+ import { ChannelType, type ThreadChannel } from 'discord.js'
4
+ import type { CommandContext } from './types.js'
5
+ import { getDatabase } from '../database.js'
6
+ import { initializeOpencodeForDirectory } from '../opencode.js'
7
+ import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
8
+ import { abortControllers } from '../session-handler.js'
9
+ import { createLogger } from '../logger.js'
10
+
11
+ const logger = createLogger('ABORT')
12
+
13
+ export async function handleAbortCommand({
14
+ command,
15
+ }: CommandContext): Promise<void> {
16
+ const channel = command.channel
17
+
18
+ if (!channel) {
19
+ await command.reply({
20
+ content: 'This command can only be used in a channel',
21
+ ephemeral: true,
22
+ flags: SILENT_MESSAGE_FLAGS,
23
+ })
24
+ return
25
+ }
26
+
27
+ const isThread = [
28
+ ChannelType.PublicThread,
29
+ ChannelType.PrivateThread,
30
+ ChannelType.AnnouncementThread,
31
+ ].includes(channel.type)
32
+
33
+ if (!isThread) {
34
+ await command.reply({
35
+ content: 'This command can only be used in a thread with an active session',
36
+ ephemeral: true,
37
+ flags: SILENT_MESSAGE_FLAGS,
38
+ })
39
+ return
40
+ }
41
+
42
+ const textChannel = await resolveTextChannel(channel as ThreadChannel)
43
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel)
44
+
45
+ if (!directory) {
46
+ await command.reply({
47
+ content: 'Could not determine project directory for this channel',
48
+ ephemeral: true,
49
+ flags: SILENT_MESSAGE_FLAGS,
50
+ })
51
+ return
52
+ }
53
+
54
+ const row = getDatabase()
55
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
56
+ .get(channel.id) as { session_id: string } | undefined
57
+
58
+ if (!row?.session_id) {
59
+ await command.reply({
60
+ content: 'No active session in this thread',
61
+ ephemeral: true,
62
+ flags: SILENT_MESSAGE_FLAGS,
63
+ })
64
+ return
65
+ }
66
+
67
+ const sessionId = row.session_id
68
+
69
+ try {
70
+ const existingController = abortControllers.get(sessionId)
71
+ if (existingController) {
72
+ existingController.abort(new Error('User requested abort'))
73
+ abortControllers.delete(sessionId)
74
+ }
75
+
76
+ const getClient = await initializeOpencodeForDirectory(directory)
77
+ await getClient().session.abort({
78
+ path: { id: sessionId },
79
+ })
80
+
81
+ await command.reply({
82
+ content: `🛑 Request **aborted**`,
83
+ flags: SILENT_MESSAGE_FLAGS,
84
+ })
85
+ logger.log(`Session ${sessionId} aborted by user`)
86
+ } catch (error) {
87
+ logger.error('[ABORT] Error:', error)
88
+ await command.reply({
89
+ content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
90
+ ephemeral: true,
91
+ flags: SILENT_MESSAGE_FLAGS,
92
+ })
93
+ }
94
+ }