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/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();
@@ -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 threadName = hasVoice
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 the prompt
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;
@@ -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
- const pendingPerm = pendingPermissions.get(thread.id);
152
- if (pendingPerm) {
153
- try {
154
- sessionLogger.log(`[PERMISSION] Auto-rejecting pending permission ${pendingPerm.permission.id} due to new message`);
155
- const clientV2 = getOpencodeClientV2(directory);
156
- if (clientV2) {
157
- await clientV2.permission.reply({
158
- requestID: pendingPerm.permission.id,
159
- reply: 'reject',
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
- catch (e) {
168
- sessionLogger.log(`[PERMISSION] Failed to auto-reject permission:`, e);
169
- cleanupPermissionContext(pendingPerm.contextHash);
170
- pendingPermissions.delete(thread.id);
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.has(thread.id);
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.has(thread.id);
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
- pendingPermissions.set(thread.id, {
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
- const pending = pendingPermissions.get(thread.id);
516
- if (pending && pending.permission.id === requestID) {
517
- cleanupPermissionContext(pending.contextHash);
518
- pendingPermissions.delete(thread.id);
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
  },
@@ -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 --title "Add user authentication flow"
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 --title "Fix database connection retry" -- path/to/file1.ts path/to/file2.ts
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 web --title "Refactor API endpoints" HEAD
89
+ bunx critique HEAD --web "Refactor API endpoints"
55
90
 
56
- bunx critique web --title "Update dependencies" HEAD~1 to get the one before last
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
@@ -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.42",
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.8.0"
44
+ "errore": "^0.9.0"
45
45
  },
46
46
  "optionalDependencies": {
47
47
  "@discordjs/opus": "^0.10.0",