kimaki 0.4.42 → 0.4.44
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/dist/cli.js +236 -30
- package/dist/commands/merge-worktree.js +152 -0
- package/dist/commands/worktree-settings.js +88 -0
- package/dist/commands/worktree.js +14 -25
- package/dist/database.js +36 -0
- package/dist/discord-bot.js +74 -18
- package/dist/interaction-handler.js +11 -0
- package/dist/session-handler.js +63 -27
- package/dist/system-message.js +44 -5
- package/dist/voice-handler.js +1 -0
- package/dist/worktree-utils.js +50 -0
- package/package.json +2 -2
- package/src/cli.ts +287 -35
- package/src/commands/merge-worktree.ts +186 -0
- package/src/commands/worktree-settings.ts +122 -0
- package/src/commands/worktree.ts +14 -28
- package/src/database.ts +43 -0
- package/src/discord-bot.ts +93 -21
- package/src/interaction-handler.ts +17 -0
- package/src/session-handler.ts +71 -31
- package/src/system-message.ts +56 -4
- package/src/voice-handler.ts +1 -0
- package/src/worktree-utils.ts +78 -0
package/dist/database.js
CHANGED
|
@@ -90,6 +90,7 @@ export function getDatabase() {
|
|
|
90
90
|
)
|
|
91
91
|
`);
|
|
92
92
|
runModelMigrations(db);
|
|
93
|
+
runWorktreeSettingsMigrations(db);
|
|
93
94
|
}
|
|
94
95
|
return db;
|
|
95
96
|
}
|
|
@@ -250,6 +251,41 @@ export function deleteThreadWorktree(threadId) {
|
|
|
250
251
|
const db = getDatabase();
|
|
251
252
|
db.prepare('DELETE FROM thread_worktrees WHERE thread_id = ?').run(threadId);
|
|
252
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* Run migrations for channel worktree settings table.
|
|
256
|
+
* Called on startup. Allows per-channel opt-in for automatic worktree creation.
|
|
257
|
+
*/
|
|
258
|
+
export function runWorktreeSettingsMigrations(database) {
|
|
259
|
+
const targetDb = database || getDatabase();
|
|
260
|
+
targetDb.exec(`
|
|
261
|
+
CREATE TABLE IF NOT EXISTS channel_worktrees (
|
|
262
|
+
channel_id TEXT PRIMARY KEY,
|
|
263
|
+
enabled INTEGER NOT NULL DEFAULT 0,
|
|
264
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
265
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
266
|
+
)
|
|
267
|
+
`);
|
|
268
|
+
dbLogger.log('Channel worktree settings migrations complete');
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Check if automatic worktree creation is enabled for a channel.
|
|
272
|
+
*/
|
|
273
|
+
export function getChannelWorktreesEnabled(channelId) {
|
|
274
|
+
const db = getDatabase();
|
|
275
|
+
const row = db
|
|
276
|
+
.prepare('SELECT enabled FROM channel_worktrees WHERE channel_id = ?')
|
|
277
|
+
.get(channelId);
|
|
278
|
+
return row?.enabled === 1;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Enable or disable automatic worktree creation for a channel.
|
|
282
|
+
*/
|
|
283
|
+
export function setChannelWorktreesEnabled(channelId, enabled) {
|
|
284
|
+
const db = getDatabase();
|
|
285
|
+
db.prepare(`INSERT INTO channel_worktrees (channel_id, enabled, updated_at)
|
|
286
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
287
|
+
ON CONFLICT(channel_id) DO UPDATE SET enabled = ?, updated_at = CURRENT_TIMESTAMP`).run(channelId, enabled ? 1 : 0, enabled ? 1 : 0);
|
|
288
|
+
}
|
|
253
289
|
export function closeDatabase() {
|
|
254
290
|
if (db) {
|
|
255
291
|
db.close();
|
package/dist/discord-bot.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// Core Discord bot module that handles message events and bot lifecycle.
|
|
2
2
|
// Bridges Discord messages to OpenCode sessions, manages voice connections,
|
|
3
3
|
// and orchestrates the main event loop for the Kimaki bot.
|
|
4
|
-
import { getDatabase, closeDatabase, getThreadWorktree } from './database.js';
|
|
5
|
-
import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js';
|
|
4
|
+
import { getDatabase, closeDatabase, getThreadWorktree, createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelWorktreesEnabled, } from './database.js';
|
|
5
|
+
import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js';
|
|
6
|
+
import { formatWorktreeName } from './commands/worktree.js';
|
|
7
|
+
import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
|
|
8
|
+
import { createWorktreeWithSubmodules } from './worktree-utils.js';
|
|
6
9
|
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, SILENT_MESSAGE_FLAGS, } from './discord-utils.js';
|
|
7
10
|
import { getOpencodeSystemMessage } from './system-message.js';
|
|
8
11
|
import { getFileAttachments, getTextAttachments } from './message-formatting.js';
|
|
@@ -39,7 +42,7 @@ export async function createDiscordClient() {
|
|
|
39
42
|
partials: [Partials.Channel, Partials.Message, Partials.User, Partials.ThreadMember],
|
|
40
43
|
});
|
|
41
44
|
}
|
|
42
|
-
export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
45
|
+
export async function startDiscordBot({ token, appId, discordClient, useWorktrees, }) {
|
|
43
46
|
if (!discordClient) {
|
|
44
47
|
discordClient = await createDiscordClient();
|
|
45
48
|
}
|
|
@@ -299,20 +302,76 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
299
302
|
return;
|
|
300
303
|
}
|
|
301
304
|
const hasVoice = message.attachments.some((a) => a.contentType?.startsWith('audio/'));
|
|
302
|
-
const
|
|
305
|
+
const baseThreadName = hasVoice
|
|
303
306
|
? 'Voice Message'
|
|
304
307
|
: message.content?.replace(/\s+/g, ' ').trim() || 'Claude Thread';
|
|
308
|
+
// Check if worktrees should be enabled (CLI flag OR channel setting)
|
|
309
|
+
const shouldUseWorktrees = useWorktrees || getChannelWorktreesEnabled(textChannel.id);
|
|
310
|
+
// Add worktree prefix if worktrees are enabled
|
|
311
|
+
const threadName = shouldUseWorktrees
|
|
312
|
+
? `${WORKTREE_PREFIX}${baseThreadName}`
|
|
313
|
+
: baseThreadName;
|
|
305
314
|
const thread = await message.startThread({
|
|
306
315
|
name: threadName.slice(0, 80),
|
|
307
316
|
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
308
317
|
reason: 'Start Claude session',
|
|
309
318
|
});
|
|
310
319
|
discordLogger.log(`Created thread "${thread.name}" (${thread.id})`);
|
|
320
|
+
// Create worktree if worktrees are enabled (CLI flag OR channel setting)
|
|
321
|
+
let sessionDirectory = projectDirectory;
|
|
322
|
+
if (shouldUseWorktrees) {
|
|
323
|
+
const worktreeName = formatWorktreeName(hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50));
|
|
324
|
+
discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
|
|
325
|
+
// Store pending worktree immediately so bot knows about it
|
|
326
|
+
createPendingWorktree({
|
|
327
|
+
threadId: thread.id,
|
|
328
|
+
worktreeName,
|
|
329
|
+
projectDirectory,
|
|
330
|
+
});
|
|
331
|
+
// Initialize OpenCode and create worktree
|
|
332
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
333
|
+
if (getClient instanceof Error) {
|
|
334
|
+
discordLogger.error(`[WORKTREE] Failed to init OpenCode: ${getClient.message}`);
|
|
335
|
+
setWorktreeError({ threadId: thread.id, errorMessage: getClient.message });
|
|
336
|
+
await thread.send({
|
|
337
|
+
content: `⚠️ Failed to create worktree: ${getClient.message}\nUsing main project directory instead.`,
|
|
338
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
const clientV2 = getOpencodeClientV2(projectDirectory);
|
|
343
|
+
if (!clientV2) {
|
|
344
|
+
discordLogger.error(`[WORKTREE] No v2 client for ${projectDirectory}`);
|
|
345
|
+
setWorktreeError({ threadId: thread.id, errorMessage: 'No OpenCode v2 client' });
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
const worktreeResult = await createWorktreeWithSubmodules({
|
|
349
|
+
clientV2,
|
|
350
|
+
directory: projectDirectory,
|
|
351
|
+
name: worktreeName,
|
|
352
|
+
});
|
|
353
|
+
if (worktreeResult instanceof Error) {
|
|
354
|
+
const errMsg = worktreeResult.message;
|
|
355
|
+
discordLogger.error(`[WORKTREE] Creation failed: ${errMsg}`);
|
|
356
|
+
setWorktreeError({ threadId: thread.id, errorMessage: errMsg });
|
|
357
|
+
await thread.send({
|
|
358
|
+
content: `⚠️ Failed to create worktree: ${errMsg}\nUsing main project directory instead.`,
|
|
359
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
setWorktreeReady({ threadId: thread.id, worktreeDirectory: worktreeResult.directory });
|
|
364
|
+
sessionDirectory = worktreeResult.directory;
|
|
365
|
+
discordLogger.log(`[WORKTREE] Created: ${worktreeResult.directory} (branch: ${worktreeResult.branch})`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
311
370
|
let messageContent = message.content || '';
|
|
312
371
|
const transcription = await processVoiceAttachment({
|
|
313
372
|
message,
|
|
314
373
|
thread,
|
|
315
|
-
projectDirectory,
|
|
374
|
+
projectDirectory: sessionDirectory,
|
|
316
375
|
isNewThread: true,
|
|
317
376
|
appId: currentAppId,
|
|
318
377
|
});
|
|
@@ -327,7 +386,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
327
386
|
await handleOpencodeSession({
|
|
328
387
|
prompt: promptWithAttachments,
|
|
329
388
|
thread,
|
|
330
|
-
projectDirectory,
|
|
389
|
+
projectDirectory: sessionDirectory,
|
|
331
390
|
originalMessage: message,
|
|
332
391
|
images: fileAttachments,
|
|
333
392
|
channelId: textChannel.id,
|
|
@@ -349,33 +408,30 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
349
408
|
}
|
|
350
409
|
});
|
|
351
410
|
// Handle bot-initiated threads created by `kimaki send` (without --notify-only)
|
|
411
|
+
// Uses embed marker instead of database to avoid race conditions
|
|
412
|
+
const AUTO_START_MARKER = 'kimaki:start';
|
|
352
413
|
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
353
414
|
try {
|
|
354
415
|
if (!newlyCreated) {
|
|
355
416
|
return;
|
|
356
417
|
}
|
|
357
|
-
// Check if this thread is marked for auto-start in the database
|
|
358
|
-
const db = getDatabase();
|
|
359
|
-
const pendingRow = db
|
|
360
|
-
.prepare('SELECT thread_id FROM pending_auto_start WHERE thread_id = ?')
|
|
361
|
-
.get(thread.id);
|
|
362
|
-
if (!pendingRow) {
|
|
363
|
-
return; // Not a CLI-initiated auto-start thread
|
|
364
|
-
}
|
|
365
|
-
// Remove from pending table
|
|
366
|
-
db.prepare('DELETE FROM pending_auto_start WHERE thread_id = ?').run(thread.id);
|
|
367
|
-
discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
|
|
368
418
|
// Only handle threads in text channels
|
|
369
419
|
const parent = thread.parent;
|
|
370
420
|
if (!parent || parent.type !== ChannelType.GuildText) {
|
|
371
421
|
return;
|
|
372
422
|
}
|
|
373
|
-
// Get the starter message for
|
|
423
|
+
// Get the starter message to check for auto-start marker
|
|
374
424
|
const starterMessage = await thread.fetchStarterMessage().catch(() => null);
|
|
375
425
|
if (!starterMessage) {
|
|
376
426
|
discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`);
|
|
377
427
|
return;
|
|
378
428
|
}
|
|
429
|
+
// Check if starter message has the auto-start embed marker
|
|
430
|
+
const hasAutoStartMarker = starterMessage.embeds.some((embed) => embed.footer?.text === AUTO_START_MARKER);
|
|
431
|
+
if (!hasAutoStartMarker) {
|
|
432
|
+
return; // Not a CLI-initiated auto-start thread
|
|
433
|
+
}
|
|
434
|
+
discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
|
|
379
435
|
const prompt = starterMessage.content.trim();
|
|
380
436
|
if (!prompt) {
|
|
381
437
|
discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import { Events } from 'discord.js';
|
|
5
5
|
import { handleSessionCommand, handleSessionAutocomplete } from './commands/session.js';
|
|
6
6
|
import { handleNewWorktreeCommand } from './commands/worktree.js';
|
|
7
|
+
import { handleMergeWorktreeCommand } from './commands/merge-worktree.js';
|
|
8
|
+
import { handleEnableWorktreesCommand, handleDisableWorktreesCommand, } from './commands/worktree-settings.js';
|
|
7
9
|
import { handleResumeCommand, handleResumeAutocomplete } from './commands/resume.js';
|
|
8
10
|
import { handleAddProjectCommand, handleAddProjectAutocomplete } from './commands/add-project.js';
|
|
9
11
|
import { handleRemoveProjectCommand, handleRemoveProjectAutocomplete, } from './commands/remove-project.js';
|
|
@@ -57,6 +59,15 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
57
59
|
case 'new-worktree':
|
|
58
60
|
await handleNewWorktreeCommand({ command: interaction, appId });
|
|
59
61
|
return;
|
|
62
|
+
case 'merge-worktree':
|
|
63
|
+
await handleMergeWorktreeCommand({ command: interaction, appId });
|
|
64
|
+
return;
|
|
65
|
+
case 'enable-worktrees':
|
|
66
|
+
await handleEnableWorktreesCommand({ command: interaction, appId });
|
|
67
|
+
return;
|
|
68
|
+
case 'disable-worktrees':
|
|
69
|
+
await handleDisableWorktreesCommand({ command: interaction, appId });
|
|
70
|
+
return;
|
|
60
71
|
case 'resume':
|
|
61
72
|
await handleResumeCommand({ command: interaction, appId });
|
|
62
73
|
return;
|
package/dist/session-handler.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
|
|
3
3
|
// Handles streaming events, permissions, abort signals, and message queuing.
|
|
4
4
|
import prettyMilliseconds from 'pretty-ms';
|
|
5
|
-
import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent, setSessionAgent, } from './database.js';
|
|
5
|
+
import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent, setSessionAgent, getThreadWorktree, } from './database.js';
|
|
6
6
|
import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2, } from './opencode.js';
|
|
7
7
|
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js';
|
|
8
8
|
import { formatPart } from './message-formatting.js';
|
|
@@ -16,6 +16,9 @@ const sessionLogger = createLogger('SESSION');
|
|
|
16
16
|
const voiceLogger = createLogger('VOICE');
|
|
17
17
|
const discordLogger = createLogger('DISCORD');
|
|
18
18
|
export const abortControllers = new Map();
|
|
19
|
+
// Track multiple pending permissions per thread (keyed by permission ID)
|
|
20
|
+
// OpenCode handles blocking/sequencing - we just need to track all pending permissions
|
|
21
|
+
// to avoid duplicates and properly clean up on auto-reject
|
|
19
22
|
export const pendingPermissions = new Map();
|
|
20
23
|
// Queue of messages waiting to be sent after current response finishes
|
|
21
24
|
// Key is threadId, value is array of queued messages
|
|
@@ -148,26 +151,32 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
148
151
|
voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`);
|
|
149
152
|
existingController.abort(new Error('New request started'));
|
|
150
153
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
154
|
+
// Auto-reject ALL pending permissions for this thread
|
|
155
|
+
const threadPermissions = pendingPermissions.get(thread.id);
|
|
156
|
+
if (threadPermissions && threadPermissions.size > 0) {
|
|
157
|
+
const clientV2 = getOpencodeClientV2(directory);
|
|
158
|
+
let rejectedCount = 0;
|
|
159
|
+
for (const [permId, pendingPerm] of threadPermissions) {
|
|
160
|
+
try {
|
|
161
|
+
sessionLogger.log(`[PERMISSION] Auto-rejecting permission ${permId} due to new message`);
|
|
162
|
+
if (clientV2) {
|
|
163
|
+
await clientV2.permission.reply({
|
|
164
|
+
requestID: permId,
|
|
165
|
+
reply: 'reject',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
cleanupPermissionContext(pendingPerm.contextHash);
|
|
169
|
+
rejectedCount++;
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
sessionLogger.log(`[PERMISSION] Failed to auto-reject permission ${permId}:`, e);
|
|
173
|
+
cleanupPermissionContext(pendingPerm.contextHash);
|
|
161
174
|
}
|
|
162
|
-
// Clean up both the pending permission and its dropdown context
|
|
163
|
-
cleanupPermissionContext(pendingPerm.contextHash);
|
|
164
|
-
pendingPermissions.delete(thread.id);
|
|
165
|
-
await sendThreadMessage(thread, `⚠️ Previous permission request auto-rejected due to new message`);
|
|
166
175
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
176
|
+
pendingPermissions.delete(thread.id);
|
|
177
|
+
if (rejectedCount > 0) {
|
|
178
|
+
const plural = rejectedCount > 1 ? 's' : '';
|
|
179
|
+
await sendThreadMessage(thread, `⚠️ ${rejectedCount} pending permission request${plural} auto-rejected due to new message`);
|
|
171
180
|
}
|
|
172
181
|
}
|
|
173
182
|
// Cancel any pending question tool if user sends a new message (silently, no thread message)
|
|
@@ -382,7 +391,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
382
391
|
if (part.type === 'step-start') {
|
|
383
392
|
// Don't start typing if user needs to respond to a question or permission
|
|
384
393
|
const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
|
|
385
|
-
const hasPendingPermission = pendingPermissions.
|
|
394
|
+
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0;
|
|
386
395
|
if (!hasPendingQuestion && !hasPendingPermission) {
|
|
387
396
|
stopTyping = startTyping();
|
|
388
397
|
}
|
|
@@ -451,7 +460,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
451
460
|
return;
|
|
452
461
|
// Don't restart typing if user needs to respond to a question or permission
|
|
453
462
|
const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
|
|
454
|
-
const hasPendingPermission = pendingPermissions.
|
|
463
|
+
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0;
|
|
455
464
|
if (hasPendingQuestion || hasPendingPermission)
|
|
456
465
|
return;
|
|
457
466
|
stopTyping = startTyping();
|
|
@@ -487,6 +496,12 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
487
496
|
voiceLogger.log(`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`);
|
|
488
497
|
continue;
|
|
489
498
|
}
|
|
499
|
+
// Skip if this exact permission ID is already pending (dedupe)
|
|
500
|
+
const threadPermissions = pendingPermissions.get(thread.id);
|
|
501
|
+
if (threadPermissions?.has(permission.id)) {
|
|
502
|
+
sessionLogger.log(`[PERMISSION] Skipping duplicate permission ${permission.id} (already pending)`);
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
490
505
|
sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`);
|
|
491
506
|
// Stop typing - user needs to respond now, not the bot
|
|
492
507
|
if (stopTyping) {
|
|
@@ -499,7 +514,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
499
514
|
permission,
|
|
500
515
|
directory,
|
|
501
516
|
});
|
|
502
|
-
|
|
517
|
+
// Track permission in nested map (threadId -> permissionId -> data)
|
|
518
|
+
if (!pendingPermissions.has(thread.id)) {
|
|
519
|
+
pendingPermissions.set(thread.id, new Map());
|
|
520
|
+
}
|
|
521
|
+
pendingPermissions.get(thread.id).set(permission.id, {
|
|
503
522
|
permission,
|
|
504
523
|
messageId,
|
|
505
524
|
directory,
|
|
@@ -512,10 +531,18 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
512
531
|
continue;
|
|
513
532
|
}
|
|
514
533
|
sessionLogger.log(`Permission ${requestID} replied with: ${reply}`);
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
534
|
+
// Clean up the specific permission from nested map
|
|
535
|
+
const threadPermissions = pendingPermissions.get(thread.id);
|
|
536
|
+
if (threadPermissions) {
|
|
537
|
+
const pending = threadPermissions.get(requestID);
|
|
538
|
+
if (pending) {
|
|
539
|
+
cleanupPermissionContext(pending.contextHash);
|
|
540
|
+
threadPermissions.delete(requestID);
|
|
541
|
+
// Remove thread entry if no more pending permissions
|
|
542
|
+
if (threadPermissions.size === 0) {
|
|
543
|
+
pendingPermissions.delete(thread.id);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
519
546
|
}
|
|
520
547
|
}
|
|
521
548
|
else if (event.type === 'question.asked') {
|
|
@@ -728,6 +755,15 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
728
755
|
sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
|
|
729
756
|
return { providerID, modelID };
|
|
730
757
|
})();
|
|
758
|
+
// Get worktree info if this thread is in a worktree
|
|
759
|
+
const worktreeInfo = getThreadWorktree(thread.id);
|
|
760
|
+
const worktree = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
761
|
+
? {
|
|
762
|
+
worktreeDirectory: worktreeInfo.worktree_directory,
|
|
763
|
+
branch: worktreeInfo.worktree_name,
|
|
764
|
+
mainRepoDirectory: worktreeInfo.project_directory,
|
|
765
|
+
}
|
|
766
|
+
: undefined;
|
|
731
767
|
// Use session.command API for slash commands, session.prompt for regular messages
|
|
732
768
|
const response = command
|
|
733
769
|
? await getClient().session.command({
|
|
@@ -743,7 +779,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
743
779
|
path: { id: session.id },
|
|
744
780
|
body: {
|
|
745
781
|
parts,
|
|
746
|
-
system: getOpencodeSystemMessage({ sessionId: session.id, channelId }),
|
|
782
|
+
system: getOpencodeSystemMessage({ sessionId: session.id, channelId, worktree }),
|
|
747
783
|
model: modelParam,
|
|
748
784
|
agent: agentPreference,
|
|
749
785
|
},
|
package/dist/system-message.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// OpenCode system prompt generator.
|
|
2
2
|
// Creates the system message injected into every OpenCode session,
|
|
3
3
|
// including Discord-specific formatting rules, diff commands, and permissions info.
|
|
4
|
-
export function getOpencodeSystemMessage({ sessionId, channelId, }) {
|
|
4
|
+
export function getOpencodeSystemMessage({ sessionId, channelId, worktree, }) {
|
|
5
5
|
return `
|
|
6
6
|
The user is reading your messages from inside Discord, via kimaki.xyz
|
|
7
7
|
|
|
@@ -35,6 +35,41 @@ Use --notify-only to create a notification thread without starting an AI session
|
|
|
35
35
|
npx -y kimaki send --channel ${channelId} --prompt "User cancelled subscription" --notify-only
|
|
36
36
|
|
|
37
37
|
This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
|
|
38
|
+
|
|
39
|
+
### Session handoff
|
|
40
|
+
|
|
41
|
+
When you are approaching the **context window limit** or the user explicitly asks to **handoff to a new thread**, use the \`kimaki send\` command to start a fresh session with context:
|
|
42
|
+
|
|
43
|
+
\`\`\`bash
|
|
44
|
+
npx -y kimaki send --channel ${channelId} --prompt "Continuing from previous session: <summary of current task and state>"
|
|
45
|
+
\`\`\`
|
|
46
|
+
|
|
47
|
+
The command automatically handles long prompts (over 2000 chars) by sending them as file attachments.
|
|
48
|
+
|
|
49
|
+
Use this for handoff when:
|
|
50
|
+
- User asks to "handoff", "continue in new thread", or "start fresh session"
|
|
51
|
+
- You detect you're running low on context window space
|
|
52
|
+
- A complex task would benefit from a clean slate with summarized context
|
|
53
|
+
`
|
|
54
|
+
: ''}${worktree
|
|
55
|
+
? `
|
|
56
|
+
## worktree
|
|
57
|
+
|
|
58
|
+
This session is running inside a git worktree.
|
|
59
|
+
- **Worktree path:** \`${worktree.worktreeDirectory}\`
|
|
60
|
+
- **Branch:** \`${worktree.branch}\`
|
|
61
|
+
- **Main repo:** \`${worktree.mainRepoDirectory}\`
|
|
62
|
+
|
|
63
|
+
Before finishing a task, ask the user if they want to merge changes back to the main branch.
|
|
64
|
+
|
|
65
|
+
To merge (without leaving the worktree):
|
|
66
|
+
\`\`\`bash
|
|
67
|
+
# Get the default branch name
|
|
68
|
+
DEFAULT_BRANCH=$(git -C ${worktree.mainRepoDirectory} symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo "main")
|
|
69
|
+
|
|
70
|
+
# Merge worktree branch into main
|
|
71
|
+
git -C ${worktree.mainRepoDirectory} checkout $DEFAULT_BRANCH && git -C ${worktree.mainRepoDirectory} merge ${worktree.branch}
|
|
72
|
+
\`\`\`
|
|
38
73
|
`
|
|
39
74
|
: ''}
|
|
40
75
|
## showing diffs
|
|
@@ -43,20 +78,24 @@ IMPORTANT: After editing any files, you MUST execute the critique command using
|
|
|
43
78
|
|
|
44
79
|
Execute this after making changes:
|
|
45
80
|
|
|
46
|
-
bunx critique web
|
|
81
|
+
bunx critique --web "Add user authentication flow"
|
|
47
82
|
|
|
48
83
|
If there are other unrelated changes in the working directory, filter to only show the files you edited:
|
|
49
84
|
|
|
50
|
-
bunx critique web
|
|
85
|
+
bunx critique --web "Fix database connection retry" --filter "path/to/file1.ts" --filter "path/to/file2.ts"
|
|
51
86
|
|
|
52
87
|
You can also show latest commit changes using:
|
|
53
88
|
|
|
54
|
-
bunx critique
|
|
89
|
+
bunx critique HEAD --web "Refactor API endpoints"
|
|
55
90
|
|
|
56
|
-
bunx critique
|
|
91
|
+
bunx critique HEAD~1 --web "Update dependencies"
|
|
57
92
|
|
|
58
93
|
Do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
|
|
59
94
|
|
|
95
|
+
To compare two branches:
|
|
96
|
+
|
|
97
|
+
bunx critique main feature-branch --web "Compare branches"
|
|
98
|
+
|
|
60
99
|
The command outputs a URL - share that URL with the user so they can see the diff.
|
|
61
100
|
|
|
62
101
|
## markdown
|
package/dist/voice-handler.js
CHANGED
|
@@ -375,6 +375,7 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
|
|
|
375
375
|
EmptyTranscriptionError: (e) => e.message,
|
|
376
376
|
NoResponseContentError: (e) => e.message,
|
|
377
377
|
NoToolResponseError: (e) => e.message,
|
|
378
|
+
Error: (e) => e.message,
|
|
378
379
|
});
|
|
379
380
|
voiceLogger.error(`Transcription failed:`, transcription);
|
|
380
381
|
await sendThreadMessage(thread, `⚠️ Transcription failed: ${errMsg}`);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Worktree utility functions.
|
|
2
|
+
// Wrapper for OpenCode worktree creation that also initializes git submodules.
|
|
3
|
+
import { exec } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { createLogger } from './logger.js';
|
|
6
|
+
export const execAsync = promisify(exec);
|
|
7
|
+
const logger = createLogger('WORKTREE-UTILS');
|
|
8
|
+
/**
|
|
9
|
+
* Create a worktree using OpenCode SDK and initialize git submodules.
|
|
10
|
+
* This wrapper ensures submodules are properly set up in new worktrees.
|
|
11
|
+
*/
|
|
12
|
+
export async function createWorktreeWithSubmodules({ clientV2, directory, name, }) {
|
|
13
|
+
// 1. Create worktree via OpenCode SDK
|
|
14
|
+
const response = await clientV2.worktree.create({
|
|
15
|
+
directory,
|
|
16
|
+
worktreeCreateInput: { name },
|
|
17
|
+
});
|
|
18
|
+
if (response.error) {
|
|
19
|
+
return new Error(`SDK error: ${JSON.stringify(response.error)}`);
|
|
20
|
+
}
|
|
21
|
+
if (!response.data) {
|
|
22
|
+
return new Error('No worktree data returned from SDK');
|
|
23
|
+
}
|
|
24
|
+
const worktreeDir = response.data.directory;
|
|
25
|
+
// 2. Init submodules in new worktree (don't block on failure)
|
|
26
|
+
try {
|
|
27
|
+
logger.log(`Initializing submodules in ${worktreeDir}`);
|
|
28
|
+
await execAsync('git submodule update --init --recursive', {
|
|
29
|
+
cwd: worktreeDir,
|
|
30
|
+
});
|
|
31
|
+
logger.log(`Submodules initialized in ${worktreeDir}`);
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
// Log but don't fail - submodules might not exist
|
|
35
|
+
logger.warn(`Failed to init submodules in ${worktreeDir}: ${e instanceof Error ? e.message : String(e)}`);
|
|
36
|
+
}
|
|
37
|
+
// 3. Install dependencies using ni (detects package manager from lockfile)
|
|
38
|
+
try {
|
|
39
|
+
logger.log(`Installing dependencies in ${worktreeDir}`);
|
|
40
|
+
await execAsync('npx -y ni', {
|
|
41
|
+
cwd: worktreeDir,
|
|
42
|
+
});
|
|
43
|
+
logger.log(`Dependencies installed in ${worktreeDir}`);
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
// Log but don't fail - might not be a JS project or might fail for various reasons
|
|
47
|
+
logger.warn(`Failed to install dependencies in ${worktreeDir}: ${e instanceof Error ? e.message : String(e)}`);
|
|
48
|
+
}
|
|
49
|
+
return response.data;
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.44",
|
|
6
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
7
|
"bin": "bin.js",
|
|
8
8
|
"files": [
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"string-dedent": "^3.0.2",
|
|
42
42
|
"undici": "^7.16.0",
|
|
43
43
|
"zod": "^4.2.1",
|
|
44
|
-
"errore": "^0.
|
|
44
|
+
"errore": "^0.9.0"
|
|
45
45
|
},
|
|
46
46
|
"optionalDependencies": {
|
|
47
47
|
"@discordjs/opus": "^0.10.0",
|