kimaki 0.4.24 → 0.4.25
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.
- package/LICENSE +21 -0
- package/bin.js +6 -1
- package/dist/ai-tool-to-genai.js +3 -0
- package/dist/channel-management.js +3 -0
- package/dist/cli.js +93 -14
- package/dist/commands/abort.js +78 -0
- package/dist/commands/add-project.js +97 -0
- package/dist/commands/create-new-project.js +78 -0
- package/dist/commands/fork.js +186 -0
- package/dist/commands/model.js +294 -0
- package/dist/commands/permissions.js +126 -0
- package/dist/commands/queue.js +129 -0
- package/dist/commands/resume.js +145 -0
- package/dist/commands/session.js +144 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +161 -0
- package/dist/database.js +3 -0
- package/dist/discord-bot.js +3 -0
- package/dist/discord-utils.js +10 -1
- package/dist/format-tables.js +3 -0
- package/dist/genai-worker-wrapper.js +3 -0
- package/dist/genai-worker.js +3 -0
- package/dist/genai.js +3 -0
- package/dist/interaction-handler.js +71 -697
- package/dist/logger.js +3 -0
- package/dist/markdown.js +3 -0
- package/dist/message-formatting.js +41 -6
- package/dist/opencode.js +3 -0
- package/dist/session-handler.js +47 -3
- package/dist/system-message.js +16 -0
- package/dist/tools.js +3 -0
- package/dist/utils.js +3 -0
- package/dist/voice-handler.js +3 -0
- package/dist/voice.js +3 -0
- package/dist/worker-types.js +3 -0
- package/dist/xml.js +3 -0
- package/package.json +11 -12
- package/src/ai-tool-to-genai.ts +4 -0
- package/src/channel-management.ts +4 -0
- package/src/cli.ts +93 -14
- package/src/commands/abort.ts +94 -0
- package/src/commands/add-project.ts +138 -0
- package/src/commands/create-new-project.ts +111 -0
- package/src/{fork.ts → commands/fork.ts} +39 -5
- package/src/{model-command.ts → commands/model.ts} +7 -5
- package/src/commands/permissions.ts +146 -0
- package/src/commands/queue.ts +181 -0
- package/src/commands/resume.ts +230 -0
- package/src/commands/session.ts +186 -0
- package/src/commands/share.ts +96 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +213 -0
- package/src/database.ts +4 -0
- package/src/discord-bot.ts +4 -0
- package/src/discord-utils.ts +12 -0
- package/src/format-tables.ts +4 -0
- package/src/genai-worker-wrapper.ts +4 -0
- package/src/genai-worker.ts +4 -0
- package/src/genai.ts +4 -0
- package/src/interaction-handler.ts +81 -919
- package/src/logger.ts +4 -0
- package/src/markdown.ts +4 -0
- package/src/message-formatting.ts +52 -7
- package/src/opencode.ts +4 -0
- package/src/session-handler.ts +70 -3
- package/src/system-message.ts +17 -0
- package/src/tools.ts +4 -0
- package/src/utils.ts +4 -0
- package/src/voice-handler.ts +4 -0
- package/src/voice.ts +4 -0
- package/src/worker-types.ts +4 -0
- package/src/xml.ts +4 -0
- package/README.md +0 -48
package/dist/logger.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Prefixed logging utility using @clack/prompts.
|
|
2
|
+
// Creates loggers with consistent prefixes for different subsystems
|
|
3
|
+
// (DISCORD, VOICE, SESSION, etc.) for easier debugging.
|
|
1
4
|
import { log } from '@clack/prompts';
|
|
2
5
|
export function createLogger(prefix) {
|
|
3
6
|
return {
|
package/dist/markdown.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Session-to-markdown renderer for sharing.
|
|
2
|
+
// Generates shareable markdown from OpenCode sessions, formatting
|
|
3
|
+
// user messages, assistant responses, tool calls, and reasoning blocks.
|
|
1
4
|
import * as yaml from 'js-yaml';
|
|
2
5
|
import { formatDateTime } from './utils.js';
|
|
3
6
|
import { extractNonXmlContent } from './xml.js';
|
|
@@ -1,5 +1,30 @@
|
|
|
1
|
+
// OpenCode message part formatting for Discord.
|
|
2
|
+
// Converts SDK message parts (text, tools, reasoning) to Discord-friendly format,
|
|
3
|
+
// handles file attachments, and provides tool summary generation.
|
|
1
4
|
import { createLogger } from './logger.js';
|
|
2
5
|
const logger = createLogger('FORMATTING');
|
|
6
|
+
/**
|
|
7
|
+
* Collects and formats the last N assistant parts from session messages.
|
|
8
|
+
* Used by both /resume and /fork to show recent assistant context.
|
|
9
|
+
*/
|
|
10
|
+
export function collectLastAssistantParts({ messages, limit = 30, }) {
|
|
11
|
+
const allAssistantParts = [];
|
|
12
|
+
for (const message of messages) {
|
|
13
|
+
if (message.info.role === 'assistant') {
|
|
14
|
+
for (const part of message.parts) {
|
|
15
|
+
const content = formatPart(part);
|
|
16
|
+
if (content.trim()) {
|
|
17
|
+
allAssistantParts.push({ id: part.id, content: content.trimEnd() });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const partsToRender = allAssistantParts.slice(-limit);
|
|
23
|
+
const partIds = partsToRender.map((p) => p.id);
|
|
24
|
+
const content = partsToRender.map((p) => p.content).join('\n');
|
|
25
|
+
const skippedCount = allAssistantParts.length - partsToRender.length;
|
|
26
|
+
return { partIds, content, skippedCount };
|
|
27
|
+
}
|
|
3
28
|
export const TEXT_MIME_TYPES = [
|
|
4
29
|
'text/',
|
|
5
30
|
'application/json',
|
|
@@ -108,7 +133,7 @@ export function getToolSummaryText(part) {
|
|
|
108
133
|
if (value === null || value === undefined)
|
|
109
134
|
return null;
|
|
110
135
|
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
|
111
|
-
const truncatedValue = stringValue.length >
|
|
136
|
+
const truncatedValue = stringValue.length > 50 ? stringValue.slice(0, 50) + '…' : stringValue;
|
|
112
137
|
return `${key}: ${truncatedValue}`;
|
|
113
138
|
})
|
|
114
139
|
.filter(Boolean);
|
|
@@ -130,12 +155,14 @@ export function formatTodoList(part) {
|
|
|
130
155
|
}
|
|
131
156
|
export function formatPart(part) {
|
|
132
157
|
if (part.type === 'text') {
|
|
133
|
-
|
|
158
|
+
if (!part.text?.trim())
|
|
159
|
+
return '';
|
|
160
|
+
return `⬥ ${part.text}`;
|
|
134
161
|
}
|
|
135
162
|
if (part.type === 'reasoning') {
|
|
136
163
|
if (!part.text?.trim())
|
|
137
164
|
return '';
|
|
138
|
-
return
|
|
165
|
+
return `┣ thinking`;
|
|
139
166
|
}
|
|
140
167
|
if (part.type === 'file') {
|
|
141
168
|
return `📄 ${part.filename || 'File'}`;
|
|
@@ -144,10 +171,10 @@ export function formatPart(part) {
|
|
|
144
171
|
return '';
|
|
145
172
|
}
|
|
146
173
|
if (part.type === 'agent') {
|
|
147
|
-
return
|
|
174
|
+
return `┣ agent ${part.id}`;
|
|
148
175
|
}
|
|
149
176
|
if (part.type === 'snapshot') {
|
|
150
|
-
return
|
|
177
|
+
return `┣ snapshot ${part.snapshot}`;
|
|
151
178
|
}
|
|
152
179
|
if (part.type === 'tool') {
|
|
153
180
|
if (part.tool === 'todowrite') {
|
|
@@ -180,7 +207,15 @@ export function formatPart(part) {
|
|
|
180
207
|
else if (stateTitle) {
|
|
181
208
|
toolTitle = `_${stateTitle}_`;
|
|
182
209
|
}
|
|
183
|
-
const icon =
|
|
210
|
+
const icon = (() => {
|
|
211
|
+
if (part.state.status === 'error') {
|
|
212
|
+
return '⨯';
|
|
213
|
+
}
|
|
214
|
+
if (part.tool === 'edit' || part.tool === 'write') {
|
|
215
|
+
return '◼︎';
|
|
216
|
+
}
|
|
217
|
+
return '┣';
|
|
218
|
+
})();
|
|
184
219
|
return `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
|
|
185
220
|
}
|
|
186
221
|
logger.warn('Unknown part type:', part);
|
package/dist/opencode.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// OpenCode server process manager.
|
|
2
|
+
// Spawns and maintains OpenCode API servers per project directory,
|
|
3
|
+
// handles automatic restarts on failure, and provides typed SDK clients.
|
|
1
4
|
import { spawn } from 'node:child_process';
|
|
2
5
|
import net from 'node:net';
|
|
3
6
|
import { createOpencodeClient, } from '@opencode-ai/sdk';
|
package/dist/session-handler.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
// OpenCode session lifecycle manager.
|
|
2
|
+
// Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
|
|
3
|
+
// Handles streaming events, permissions, abort signals, and message queuing.
|
|
1
4
|
import prettyMilliseconds from 'pretty-ms';
|
|
2
5
|
import { getDatabase, getSessionModel, getChannelModel } from './database.js';
|
|
3
6
|
import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js';
|
|
4
|
-
import { sendThreadMessage } from './discord-utils.js';
|
|
7
|
+
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from './discord-utils.js';
|
|
5
8
|
import { formatPart } from './message-formatting.js';
|
|
6
9
|
import { getOpencodeSystemMessage } from './system-message.js';
|
|
7
10
|
import { createLogger } from './logger.js';
|
|
@@ -24,6 +27,21 @@ export function parseSlashCommand(text) {
|
|
|
24
27
|
}
|
|
25
28
|
export const abortControllers = new Map();
|
|
26
29
|
export const pendingPermissions = new Map();
|
|
30
|
+
// Queue of messages waiting to be sent after current response finishes
|
|
31
|
+
// Key is threadId, value is array of queued messages
|
|
32
|
+
export const messageQueue = new Map();
|
|
33
|
+
export function addToQueue({ threadId, message, }) {
|
|
34
|
+
const queue = messageQueue.get(threadId) || [];
|
|
35
|
+
queue.push(message);
|
|
36
|
+
messageQueue.set(threadId, queue);
|
|
37
|
+
return queue.length;
|
|
38
|
+
}
|
|
39
|
+
export function getQueueLength(threadId) {
|
|
40
|
+
return messageQueue.get(threadId)?.length || 0;
|
|
41
|
+
}
|
|
42
|
+
export function clearQueue(threadId) {
|
|
43
|
+
messageQueue.delete(threadId);
|
|
44
|
+
}
|
|
27
45
|
export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], parsedCommand, channelId, }) {
|
|
28
46
|
voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
|
|
29
47
|
const sessionStartTime = Date.now();
|
|
@@ -212,7 +230,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
212
230
|
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
|
|
213
231
|
if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
|
|
214
232
|
lastDisplayedContextPercentage = thresholdCrossed;
|
|
215
|
-
await sendThreadMessage(thread,
|
|
233
|
+
await sendThreadMessage(thread, `⬥ context usage ${currentPercentage}%`);
|
|
216
234
|
}
|
|
217
235
|
}
|
|
218
236
|
}
|
|
@@ -353,8 +371,34 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
353
371
|
catch (e) {
|
|
354
372
|
sessionLogger.error('Failed to fetch provider info for context percentage:', e);
|
|
355
373
|
}
|
|
356
|
-
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}
|
|
374
|
+
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`, { flags: NOTIFY_MESSAGE_FLAGS });
|
|
357
375
|
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
|
|
376
|
+
// Process queued messages after completion
|
|
377
|
+
const queue = messageQueue.get(thread.id);
|
|
378
|
+
if (queue && queue.length > 0) {
|
|
379
|
+
const nextMessage = queue.shift();
|
|
380
|
+
if (queue.length === 0) {
|
|
381
|
+
messageQueue.delete(thread.id);
|
|
382
|
+
}
|
|
383
|
+
sessionLogger.log(`[QUEUE] Processing queued message from ${nextMessage.username}`);
|
|
384
|
+
// Show that queued message is being sent
|
|
385
|
+
await sendThreadMessage(thread, `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`);
|
|
386
|
+
// Send the queued message as a new prompt (recursive call)
|
|
387
|
+
// Use setImmediate to avoid blocking and allow this finally to complete
|
|
388
|
+
setImmediate(() => {
|
|
389
|
+
handleOpencodeSession({
|
|
390
|
+
prompt: nextMessage.prompt,
|
|
391
|
+
thread,
|
|
392
|
+
projectDirectory,
|
|
393
|
+
images: nextMessage.images,
|
|
394
|
+
channelId,
|
|
395
|
+
}).catch(async (e) => {
|
|
396
|
+
sessionLogger.error(`[QUEUE] Failed to process queued message:`, e);
|
|
397
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
398
|
+
await sendThreadMessage(thread, `✗ Queued message failed: ${errorMsg.slice(0, 200)}`);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
}
|
|
358
402
|
}
|
|
359
403
|
else {
|
|
360
404
|
sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
|
package/dist/system-message.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// OpenCode system prompt generator.
|
|
2
|
+
// Creates the system message injected into every OpenCode session,
|
|
3
|
+
// including Discord-specific formatting rules, diff commands, and permissions info.
|
|
1
4
|
export function getOpencodeSystemMessage({ sessionId }) {
|
|
2
5
|
return `
|
|
3
6
|
The user is reading your messages from inside Discord, via kimaki.xyz
|
|
@@ -66,6 +69,19 @@ the max heading level is 3, so do not use ####
|
|
|
66
69
|
|
|
67
70
|
headings are discouraged anyway. instead try to use bold text for titles which renders more nicely in Discord
|
|
68
71
|
|
|
72
|
+
## capitalization
|
|
73
|
+
|
|
74
|
+
write casually like a discord user. never capitalize the initials of phrases or acronyms in your messages. use all lowercase instead.
|
|
75
|
+
|
|
76
|
+
examples:
|
|
77
|
+
- write "api" not "API"
|
|
78
|
+
- write "url" not "URL"
|
|
79
|
+
- write "json" not "JSON"
|
|
80
|
+
- write "cli" not "CLI"
|
|
81
|
+
- write "sdk" not "SDK"
|
|
82
|
+
|
|
83
|
+
this makes your messages blend in naturally with how people actually type on discord.
|
|
84
|
+
|
|
69
85
|
## tables
|
|
70
86
|
|
|
71
87
|
discord does NOT support markdown gfm tables.
|
package/dist/tools.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Voice assistant tool definitions for the GenAI worker.
|
|
2
|
+
// Provides tools for managing OpenCode sessions (create, submit, abort),
|
|
3
|
+
// listing chats, searching files, and reading session messages.
|
|
1
4
|
import { tool } from 'ai';
|
|
2
5
|
import { z } from 'zod';
|
|
3
6
|
import { spawn } from 'node:child_process';
|
package/dist/utils.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// General utility functions for the bot.
|
|
2
|
+
// Includes Discord OAuth URL generation, array deduplication,
|
|
3
|
+
// abort error detection, and date/time formatting helpers.
|
|
1
4
|
import { PermissionsBitField } from 'discord.js';
|
|
2
5
|
export function generateBotInstallUrl({ clientId, permissions = [
|
|
3
6
|
PermissionsBitField.Flags.ViewChannel,
|
package/dist/voice-handler.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Discord voice channel connection and audio stream handler.
|
|
2
|
+
// Manages joining/leaving voice channels, captures user audio, resamples to 16kHz,
|
|
3
|
+
// and routes audio to the GenAI worker for real-time voice assistant interactions.
|
|
1
4
|
import { VoiceConnectionStatus, EndBehaviorType, joinVoiceChannel, entersState, } from '@discordjs/voice';
|
|
2
5
|
import { exec } from 'node:child_process';
|
|
3
6
|
import fs, { createWriteStream } from 'node:fs';
|
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';
|
package/dist/worker-types.js
CHANGED
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,17 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
6
|
-
"scripts": {
|
|
7
|
-
"dev": "tsx --env-file .env src/cli.ts",
|
|
8
|
-
"prepublishOnly": "pnpm tsc",
|
|
9
|
-
"dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
|
|
10
|
-
"watch": "tsx scripts/watch-session.ts",
|
|
11
|
-
"test:events": "tsx test-events.ts",
|
|
12
|
-
"pcm-to-mp3": "bun scripts/pcm-to-mp3",
|
|
13
|
-
"test:send": "tsx send-test-message.ts",
|
|
14
|
-
"register-commands": "tsx scripts/register-commands.ts"
|
|
15
|
-
},
|
|
5
|
+
"version": "0.4.25",
|
|
16
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
17
7
|
"bin": "bin.js",
|
|
18
8
|
"files": [
|
|
@@ -55,5 +45,14 @@
|
|
|
55
45
|
"string-dedent": "^3.0.2",
|
|
56
46
|
"undici": "^7.16.0",
|
|
57
47
|
"zod": "^4.2.1"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"dev": "tsx --env-file .env src/cli.ts",
|
|
51
|
+
"dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
|
|
52
|
+
"watch": "tsx scripts/watch-session.ts",
|
|
53
|
+
"test:events": "tsx test-events.ts",
|
|
54
|
+
"pcm-to-mp3": "bun scripts/pcm-to-mp3",
|
|
55
|
+
"test:send": "tsx send-test-message.ts",
|
|
56
|
+
"register-commands": "tsx scripts/register-commands.ts"
|
|
58
57
|
}
|
|
59
|
-
}
|
|
58
|
+
}
|
package/src/ai-tool-to-genai.ts
CHANGED
|
@@ -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,
|
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,
|
|
@@ -50,31 +53,83 @@ 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
|
+
cliLogger.log(`Killing existing kimaki process (PID: ${targetPid})`)
|
|
86
|
+
process.kill(parseInt(targetPid, 10), 'SIGKILL')
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// Failed to kill, continue anyway
|
|
92
|
+
}
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
|
|
53
96
|
async function checkSingleInstance(): Promise<void> {
|
|
54
97
|
try {
|
|
55
98
|
const response = await fetch(`http://127.0.0.1:${LOCK_PORT}`, {
|
|
56
99
|
signal: AbortSignal.timeout(1000),
|
|
57
100
|
})
|
|
58
101
|
if (response.ok) {
|
|
59
|
-
cliLogger.
|
|
60
|
-
|
|
102
|
+
cliLogger.log('Another kimaki instance detected')
|
|
103
|
+
await killProcessOnPort(LOCK_PORT)
|
|
104
|
+
// Wait a moment for port to be released
|
|
105
|
+
await new Promise((resolve) => { setTimeout(resolve, 500) })
|
|
61
106
|
}
|
|
62
107
|
} catch {
|
|
63
108
|
// Connection refused means no instance running, continue
|
|
64
109
|
}
|
|
65
110
|
}
|
|
66
111
|
|
|
67
|
-
function startLockServer(): void {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
112
|
+
async function startLockServer(): Promise<void> {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const server = http.createServer((req, res) => {
|
|
115
|
+
res.writeHead(200)
|
|
116
|
+
res.end('kimaki')
|
|
117
|
+
})
|
|
118
|
+
server.listen(LOCK_PORT, '127.0.0.1')
|
|
119
|
+
server.once('listening', () => {
|
|
120
|
+
resolve()
|
|
121
|
+
})
|
|
122
|
+
server.on('error', async (err: NodeJS.ErrnoException) => {
|
|
123
|
+
if (err.code === 'EADDRINUSE') {
|
|
124
|
+
cliLogger.log('Port still in use, retrying...')
|
|
125
|
+
await killProcessOnPort(LOCK_PORT)
|
|
126
|
+
await new Promise((r) => { setTimeout(r, 500) })
|
|
127
|
+
// Retry once
|
|
128
|
+
server.listen(LOCK_PORT, '127.0.0.1')
|
|
129
|
+
} else {
|
|
130
|
+
reject(err)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
78
133
|
})
|
|
79
134
|
}
|
|
80
135
|
|
|
@@ -188,6 +243,30 @@ async function registerCommands(token: string, appId: string) {
|
|
|
188
243
|
.setName('model')
|
|
189
244
|
.setDescription('Set the preferred model for this channel or session')
|
|
190
245
|
.toJSON(),
|
|
246
|
+
new SlashCommandBuilder()
|
|
247
|
+
.setName('queue')
|
|
248
|
+
.setDescription('Queue a message to be sent after the current response finishes')
|
|
249
|
+
.addStringOption((option) => {
|
|
250
|
+
option
|
|
251
|
+
.setName('message')
|
|
252
|
+
.setDescription('The message to queue')
|
|
253
|
+
.setRequired(true)
|
|
254
|
+
|
|
255
|
+
return option
|
|
256
|
+
})
|
|
257
|
+
.toJSON(),
|
|
258
|
+
new SlashCommandBuilder()
|
|
259
|
+
.setName('clear-queue')
|
|
260
|
+
.setDescription('Clear all queued messages in this thread')
|
|
261
|
+
.toJSON(),
|
|
262
|
+
new SlashCommandBuilder()
|
|
263
|
+
.setName('undo')
|
|
264
|
+
.setDescription('Undo the last assistant message (revert file changes)')
|
|
265
|
+
.toJSON(),
|
|
266
|
+
new SlashCommandBuilder()
|
|
267
|
+
.setName('redo')
|
|
268
|
+
.setDescription('Redo previously undone changes')
|
|
269
|
+
.toJSON(),
|
|
191
270
|
]
|
|
192
271
|
|
|
193
272
|
const rest = new REST().setToken(token)
|
|
@@ -693,7 +772,7 @@ cli
|
|
|
693
772
|
.action(async (options: { restart?: boolean; addChannels?: boolean }) => {
|
|
694
773
|
try {
|
|
695
774
|
await checkSingleInstance()
|
|
696
|
-
startLockServer()
|
|
775
|
+
await startLockServer()
|
|
697
776
|
await run({
|
|
698
777
|
restart: options.restart,
|
|
699
778
|
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
|
+
}
|