kimaki 0.21.0 → 0.22.0

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.
@@ -16,7 +16,7 @@ import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
16
16
  import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, resolveGuildMessageMember, } from './discord-utils.js';
17
17
  import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
18
18
  import YAML from 'yaml';
19
- import { getTextAttachments, resolveMentions, } from './message-formatting.js';
19
+ import { getFileAttachments, getTextAttachments, resolveMentions, } from './message-formatting.js';
20
20
  import { extractBtwSuffix } from './btw-prefix-detection.js';
21
21
  import { isVoiceAttachment } from './voice-attachment.js';
22
22
  import { forkSessionToBtwThread } from './commands/btw.js';
@@ -523,9 +523,14 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
523
523
  ? extractBtwSuffix(message.content || '')
524
524
  : null;
525
525
  if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
526
+ const btwSdkDir = worktreeInfo?.status === 'ready' &&
527
+ worktreeInfo.workspace_directory
528
+ ? worktreeInfo.workspace_directory
529
+ : projectDirectory;
526
530
  const result = await forkSessionToBtwThread({
527
531
  sourceThread: thread,
528
532
  projectDirectory,
533
+ sdkDirectory: btwSdkDir,
529
534
  prompt: btwResult.prompt,
530
535
  userId: message.author.id,
531
536
  username: message.member?.displayName || message.author.displayName,
@@ -820,6 +825,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
820
825
  return;
821
826
  if (!message.content)
822
827
  return;
828
+ // Discord fires MESSAGE_UPDATE for embed-only updates (link preview
829
+ // unfurling) without the user actually editing the message content.
830
+ // editedTimestamp is null for these; skip them to avoid false queue removals.
831
+ if (!message.editedTimestamp)
832
+ return;
823
833
  const channel = message.channel;
824
834
  const isThread = [
825
835
  ChannelType.PublicThread,
@@ -917,7 +927,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
917
927
  return; // Not an auto-start thread
918
928
  }
919
929
  discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
920
- const textAttachmentsContent = await getTextAttachments(starterMessage);
930
+ const [textAttachmentsContent, fileAttachments] = await Promise.all([
931
+ getTextAttachments(starterMessage),
932
+ getFileAttachments(starterMessage),
933
+ ]);
921
934
  const messageText = resolveMentions(starterMessage).trim();
922
935
  const prompt = textAttachmentsContent
923
936
  ? `${messageText}\n\n${textAttachmentsContent}`
@@ -941,22 +954,31 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
941
954
  });
942
955
  return;
943
956
  }
944
- // Start worktree creation concurrently if requested.
957
+ // Start worktree creation concurrently if requested via marker OR
958
+ // if the channel/global toggle enables auto-worktrees.
945
959
  // The runtime is created immediately so follow-up messages queue
946
960
  // naturally; the worktree promise is awaited inside enqueueIncoming.
961
+ const autoWorktreeEnabled = !marker.worktree &&
962
+ !marker.cwd &&
963
+ (store.getState().useWorktrees ||
964
+ (await getChannelWorktreesEnabled(parent.id)));
965
+ const effectiveWorktreeName = marker.worktree ||
966
+ (autoWorktreeEnabled
967
+ ? formatAutoWorktreeName(thread.name.slice(0, 50))
968
+ : undefined);
947
969
  let worktreePromise;
948
- if (marker.worktree && (await isGitRepositoryRoot(projectDirectory))) {
949
- discordLogger.log(`[BOT_SESSION] Creating worktree: ${marker.worktree}`);
970
+ if (effectiveWorktreeName && (await isGitRepositoryRoot(projectDirectory))) {
971
+ discordLogger.log(`[BOT_SESSION] Creating worktree: ${effectiveWorktreeName}`);
950
972
  const worktreeStatusMessage = await thread
951
973
  .send({
952
- content: worktreeCreatingMessage(marker.worktree),
974
+ content: worktreeCreatingMessage(effectiveWorktreeName),
953
975
  flags: SILENT_MESSAGE_FLAGS,
954
976
  })
955
977
  .catch(() => undefined);
956
978
  worktreePromise = createWorktreeInBackground({
957
979
  thread,
958
980
  starterMessage: worktreeStatusMessage,
959
- worktreeName: marker.worktree,
981
+ worktreeName: effectiveWorktreeName,
960
982
  projectDirectory,
961
983
  rest: discordClient.rest,
962
984
  });
@@ -964,6 +986,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
964
986
  else if (marker.worktree) {
965
987
  discordLogger.warn(`[BOT_SESSION] Skipping requested worktree for non-git project directory: ${projectDirectory}`);
966
988
  }
989
+ else if (autoWorktreeEnabled) {
990
+ discordLogger.warn(`[BOT_SESSION] Skipping auto-worktree for non-git project directory: ${projectDirectory}`);
991
+ }
967
992
  // --cwd: reuse an existing project subfolder or worktree directory. Revalidate at bot-time
968
993
  // (CLI validated at send-time but the path could become stale).
969
994
  // Only worktree directories are stored in thread_workspaces. Project
@@ -1054,7 +1079,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1054
1079
  const permissionRules = await getChannelReferencePermissionRules({
1055
1080
  message: starterMessage,
1056
1081
  });
1057
- return { prompt, permissionRules, mode: 'opencode' };
1082
+ return {
1083
+ prompt,
1084
+ permissionRules,
1085
+ mode: 'opencode',
1086
+ ...(fileAttachments.length > 0 && { images: fileAttachments }),
1087
+ };
1058
1088
  },
1059
1089
  });
1060
1090
  }
@@ -276,15 +276,22 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
276
276
  }
277
277
  }
278
278
  const qs = extractQueueSuffix(prompt);
279
+ const fileAttachments = await getFileAttachments(message);
280
+ const textAttachmentsContent = await getTextAttachments(message);
281
+ const finalPrompt = textAttachmentsContent
282
+ ? `${qs.prompt}\n\n${textAttachmentsContent}`
283
+ : qs.prompt;
279
284
  if (shouldSkipEmptyPrompt({
280
285
  message,
281
- prompt: qs.prompt,
286
+ prompt: finalPrompt,
287
+ images: fileAttachments,
282
288
  hasVoiceAttachment,
283
289
  })) {
284
290
  return { prompt: '', mode: 'opencode', skip: true };
285
291
  }
286
292
  return {
287
- prompt: qs.prompt,
293
+ prompt: finalPrompt,
294
+ images: fileAttachments.length > 0 ? fileAttachments : undefined,
288
295
  repliedMessage,
289
296
  permissionRules,
290
297
  mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
@@ -226,6 +226,9 @@ ${escapePromptText(repliedMessage.text)}
226
226
  export function getOpencodeSystemMessage({ sessionId, channelId, guildId, threadId, channelTopic, agents, userId, parentSessionId, }) {
227
227
  const userArg = ` --user '${userId || '<discord-user-id>'}'`;
228
228
  const parentSessionArg = ` --parent-session ${sessionId}`;
229
+ // Prefer thread ID for cross-machine compatibility; fall back to session ID.
230
+ const archiveTarget = threadId ? `${threadId} (or --session ${sessionId})` : `--session ${sessionId}`;
231
+ const sendToSelfTarget = threadId ? `--thread ${threadId} (or --session ${sessionId})` : `--session ${sessionId}`;
229
232
  const topicContext = channelTopic?.trim()
230
233
  ? `\n\n<channel-topic>\n${channelTopic.trim()}\n</channel-topic>`
231
234
  : '';
@@ -320,7 +323,7 @@ To ask the user to upload files from their device, use the \`kimaki_file_upload\
320
323
 
321
324
  To archive the current Discord thread (hide it from sidebar) and stop the session, run:
322
325
 
323
- kimaki session archive --session ${sessionId}
326
+ kimaki session archive ${archiveTarget}
324
327
 
325
328
  Only do this when the user explicitly asks to close or archive the thread, and only after your final message.
326
329
 
@@ -366,13 +369,13 @@ To send a prompt to an existing thread instead of creating a new one:
366
369
 
367
370
  kimaki send --thread <thread_id> --prompt 'follow-up prompt' --agent <current_agent>
368
371
 
369
- Use this when you already have the Discord thread ID.
372
+ Use this when you already have the Discord thread ID. Prefer \`--thread\` over \`--session\` because thread IDs work across machines while session IDs only resolve on the machine that created the session.
370
373
 
371
- To send to the thread associated with a known session:
374
+ To send to the thread associated with a known session (same machine only):
372
375
 
373
376
  kimaki send --session <session_id> --prompt 'follow-up prompt' --agent <current_agent>
374
377
 
375
- Use this when you have the OpenCode session ID.
378
+ Use this when you only have the OpenCode session ID and the session was created on this machine.
376
379
 
377
380
  Use --notify-only to create a notification thread without starting an AI session:
378
381
 
@@ -398,6 +401,11 @@ Important:
398
401
  - Do NOT tell that prompt to "create a new worktree" again, or it can create recursive worktree threads.
399
402
  - Ask the new session to operate on its current checkout only (e.g. "validate current worktree", "run checks in this repo").
400
403
 
404
+ Use --file to attach local files (images, text files, PDFs) to the message:
405
+
406
+ kimaki send --channel ${channelId} --prompt 'Review this screenshot' --file /path/to/screenshot.png --agent <current_agent>${parentSessionArg}${userArg}
407
+ kimaki send --thread <thread_id> --prompt 'Here is the error log' --file ./error.log --file ./stack-trace.txt --agent <current_agent>
408
+
401
409
  Use --agent to specify which agent to use for the session:
402
410
 
403
411
  kimaki send --channel ${channelId} --prompt 'Plan the refactor of the auth module' --agent plan${parentSessionArg}${userArg}
@@ -450,7 +458,7 @@ Notification strategy for scheduled tasks:
450
458
  - Without \`--user\`, there is no guaranteed direct user mention path; task output should mention users only when relevant.
451
459
  - With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
452
460
  - If a scheduled task completes with no actionable result and no user-visible change, prefer archiving the session after the final message so Discord does not keep a no-op thread highlighted.
453
- - Example no-op cleanup command: \`kimaki session archive --session ${sessionId}\`
461
+ - Example no-op cleanup command: \`kimaki session archive ${archiveTarget}\`
454
462
 
455
463
  Manage scheduled tasks with:
456
464
 
@@ -466,10 +474,10 @@ Use case patterns:
466
474
  - Weekly QA: schedule "run full test suite, inspect failures, post summary, and mention the user via Discord ID only when failures require review".
467
475
  - Weekly benchmark automation: schedule a benchmark prompt that runs model evals, writes JSON outputs in the repo, commits results, and mentions only for regressions.
468
476
  - Recurring maintenance: use cron \`--send-at\` for repetitive tasks like rotating secrets, checking dependency updates, running security audits, or cleaning up stale branches. Example: \`--send-at "0 9 1 * *"\` to run on the 1st of every month.
469
- - Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive --session ${sessionId}\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
477
+ - Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive ${archiveTarget}\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
470
478
  - Thread reminders: when the user says "remind me about this in 2 hours" (or any duration), use \`--send-at\` with \`--thread\` to resurface the current thread. Compute the future UTC time and send a mention so Discord shows a notification:
471
479
 
472
- kimaki send --session ${sessionId} --prompt 'Reminder: <@USER_ID> you asked to be reminded about this thread.' --send-at '<future_UTC_time>' --notify-only --agent <current_agent>
480
+ kimaki send ${sendToSelfTarget} --prompt 'Reminder: <@USER_ID> you asked to be reminded about this thread.' --send-at '<future_UTC_time>' --notify-only --agent <current_agent>
473
481
 
474
482
  Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp. The \`--notify-only\` flag creates just a notification message without starting a new AI session. The \`<@userId>\` mention ensures the user gets a Discord notification.
475
483
 
@@ -573,7 +581,11 @@ If you don't know which project the thread belongs to, try each project from \`k
573
581
  \`\`\`bash
574
582
  # List all registered projects with their channel IDs and guild names
575
583
  kimaki project list
576
- kimaki project list --json # machine-readable output with guild_id, guild_name
584
+ kimaki project list --json # machine-readable output with guild_id, guild_name, is_local
585
+
586
+ # Include projects from other machines (scans Kimaki category in Discord)
587
+ kimaki project list --all
588
+ kimaki project list --all --json # remote projects have is_local: false and directory: null
577
589
 
578
590
  # Resolve by channel name (prefer adding guild_name filter if duplicates exist)
579
591
  kimaki project list --json | jq -r '.[] | select(.channel_name == "project-name") | .channel_id + " " + .guild_name + " " + .directory'
@@ -137,7 +137,7 @@ describe('system-message', () => {
137
137
 
138
138
  To archive the current Discord thread (hide it from sidebar) and stop the session, run:
139
139
 
140
- kimaki session archive --session ses_123
140
+ kimaki session archive thread_123 (or --session ses_123)
141
141
 
142
142
  Only do this when the user explicitly asks to close or archive the thread, and only after your final message.
143
143
 
@@ -182,13 +182,13 @@ describe('system-message', () => {
182
182
 
183
183
  kimaki send --thread <thread_id> --prompt 'follow-up prompt' --agent <current_agent>
184
184
 
185
- Use this when you already have the Discord thread ID.
185
+ Use this when you already have the Discord thread ID. Prefer \`--thread\` over \`--session\` because thread IDs work across machines while session IDs only resolve on the machine that created the session.
186
186
 
187
- To send to the thread associated with a known session:
187
+ To send to the thread associated with a known session (same machine only):
188
188
 
189
189
  kimaki send --session <session_id> --prompt 'follow-up prompt' --agent <current_agent>
190
190
 
191
- Use this when you have the OpenCode session ID.
191
+ Use this when you only have the OpenCode session ID and the session was created on this machine.
192
192
 
193
193
  Use --notify-only to create a notification thread without starting an AI session:
194
194
 
@@ -214,6 +214,11 @@ describe('system-message', () => {
214
214
  - Do NOT tell that prompt to "create a new worktree" again, or it can create recursive worktree threads.
215
215
  - Ask the new session to operate on its current checkout only (e.g. "validate current worktree", "run checks in this repo").
216
216
 
217
+ Use --file to attach local files (images, text files, PDFs) to the message:
218
+
219
+ kimaki send --channel chan_123 --prompt 'Review this screenshot' --file /path/to/screenshot.png --agent <current_agent> --parent-session ses_123 --user '<discord-user-id>'
220
+ kimaki send --thread <thread_id> --prompt 'Here is the error log' --file ./error.log --file ./stack-trace.txt --agent <current_agent>
221
+
217
222
  Use --agent to specify which agent to use for the session:
218
223
 
219
224
  kimaki send --channel chan_123 --prompt 'Plan the refactor of the auth module' --agent plan --parent-session ses_123 --user '<discord-user-id>'
@@ -270,7 +275,7 @@ describe('system-message', () => {
270
275
  - Without \`--user\`, there is no guaranteed direct user mention path; task output should mention users only when relevant.
271
276
  - With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
272
277
  - If a scheduled task completes with no actionable result and no user-visible change, prefer archiving the session after the final message so Discord does not keep a no-op thread highlighted.
273
- - Example no-op cleanup command: \`kimaki session archive --session ses_123\`
278
+ - Example no-op cleanup command: \`kimaki session archive thread_123 (or --session ses_123)\`
274
279
 
275
280
  Manage scheduled tasks with:
276
281
 
@@ -286,10 +291,10 @@ describe('system-message', () => {
286
291
  - Weekly QA: schedule "run full test suite, inspect failures, post summary, and mention the user via Discord ID only when failures require review".
287
292
  - Weekly benchmark automation: schedule a benchmark prompt that runs model evals, writes JSON outputs in the repo, commits results, and mentions only for regressions.
288
293
  - Recurring maintenance: use cron \`--send-at\` for repetitive tasks like rotating secrets, checking dependency updates, running security audits, or cleaning up stale branches. Example: \`--send-at "0 9 1 * *"\` to run on the 1st of every month.
289
- - Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive --session ses_123\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
294
+ - Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive thread_123 (or --session ses_123)\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
290
295
  - Thread reminders: when the user says "remind me about this in 2 hours" (or any duration), use \`--send-at\` with \`--thread\` to resurface the current thread. Compute the future UTC time and send a mention so Discord shows a notification:
291
296
 
292
- kimaki send --session ses_123 --prompt 'Reminder: <@USER_ID> you asked to be reminded about this thread.' --send-at '<future_UTC_time>' --notify-only --agent <current_agent>
297
+ kimaki send --thread thread_123 (or --session ses_123) --prompt 'Reminder: <@USER_ID> you asked to be reminded about this thread.' --send-at '<future_UTC_time>' --notify-only --agent <current_agent>
293
298
 
294
299
  Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp. The \`--notify-only\` flag creates just a notification message without starting a new AI session. The \`<@userId>\` mention ensures the user gets a Discord notification.
295
300
 
@@ -393,7 +398,11 @@ describe('system-message', () => {
393
398
  \`\`\`bash
394
399
  # List all registered projects with their channel IDs and guild names
395
400
  kimaki project list
396
- kimaki project list --json # machine-readable output with guild_id, guild_name
401
+ kimaki project list --json # machine-readable output with guild_id, guild_name, is_local
402
+
403
+ # Include projects from other machines (scans Kimaki category in Discord)
404
+ kimaki project list --all
405
+ kimaki project list --all --json # remote projects have is_local: false and directory: null
397
406
 
398
407
  # Resolve by channel name (prefer adding guild_name filter if duplicates exist)
399
408
  kimaki project list --json | jq -r '.[] | select(.channel_name == "project-name") | .channel_id + " " + .guild_name + " " + .directory'
@@ -9,7 +9,8 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import url from 'node:url';
11
11
  import { describe, beforeAll, afterAll, test, expect } from 'vitest';
12
- import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js';
12
+ import { ChannelType, Client, GatewayIntentBits, Partials, Routes } from 'discord.js';
13
+ import YAML from 'yaml';
13
14
  import { DigitalDiscord } from 'discord-digital-twin/src';
14
15
  import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
15
16
  import { setDataDir } from './config.js';
@@ -35,6 +36,7 @@ function normalizeWorktreeLifecycleText(text) {
35
36
  .replaceAll(CHANNEL_WORKTREE_NAME, 'CHANNEL_WORKTREE_NAME')
36
37
  .replaceAll(AUTO_WORKTREE_SUFFIX, 'AUTO_WORKTREE_NAME')
37
38
  .replaceAll(WORKTREE_NAME, 'WORKTREE_NAME')
39
+ .replaceAll(WORKTREE_SUFFIX, 'SUFFIX')
38
40
  .replace(/ses_[a-zA-Z0-9]+/g, 'ses_TEST')
39
41
  .replace(/<#\d+>/g, '<#THREAD_ID>')
40
42
  .replace(/`[^`\n]*\/worktrees\/[^`\n]*`/g, '`/tmp/worktrees/WORKTREE_NAME`');
@@ -630,4 +632,71 @@ describe('worktree lifecycle', () => {
630
632
  const okCount = (text.match(/⬥ ok/g) || []).length;
631
633
  expect(okCount).toBe(2);
632
634
  }, 20_000);
635
+ test('kimaki send --channel auto-creates worktree when channel toggle is enabled', async () => {
636
+ // Simulate `kimaki send --channel AUTO_WORKTREE_CHANNEL_ID --prompt '...'`
637
+ // WITHOUT --worktree flag. The bot-side ThreadCreate handler should detect
638
+ // the channel toggle and auto-create a worktree.
639
+ const prompt = `Reply with exactly: send-auto-wt-${WORKTREE_SUFFIX}`;
640
+ const embedMarker = {
641
+ start: true,
642
+ username: 'worktree-tester',
643
+ userId: TEST_USER_ID,
644
+ };
645
+ // Post starter message (what `kimaki send` does)
646
+ const starterMessage = await discord
647
+ .channel(AUTO_WORKTREE_CHANNEL_ID)
648
+ .bot()
649
+ .sendMessage({
650
+ content: `» **kimaki-cli:**\n${prompt}`,
651
+ embeds: [
652
+ { color: 0x2b2d31, footer: { text: YAML.stringify(embedMarker) } },
653
+ ],
654
+ });
655
+ // Create thread on that message (what `kimaki send` does via REST)
656
+ const threadData = (await botClient.rest.post(Routes.threads(AUTO_WORKTREE_CHANNEL_ID, starterMessage.id), {
657
+ body: {
658
+ name: prompt.slice(0, 80),
659
+ auto_archive_duration: 1440,
660
+ },
661
+ }));
662
+ // Wait for worktree creation status message (Branch:)
663
+ await waitForBotMessageContaining({
664
+ discord,
665
+ threadId: threadData.id,
666
+ userId: TEST_USER_ID,
667
+ text: 'Branch:',
668
+ timeout: 25_000,
669
+ });
670
+ // Wait for the bot reply to the prompt. The starter message is from
671
+ // the bot (kimaki-cli), not a user, so just wait for the text to appear.
672
+ await waitForBotMessageContaining({
673
+ discord,
674
+ threadId: threadData.id,
675
+ userId: discord.botUserId,
676
+ text: '⬥ ok',
677
+ timeout: 10_000,
678
+ });
679
+ // Snapshot the thread content
680
+ const th = discord.thread(threadData.id);
681
+ expect(normalizeWorktreeLifecycleText(await th.text())).toMatchInlineSnapshot(`
682
+ "--- from: assistant (TestBot)
683
+ » **kimaki-cli:**
684
+ Reply with exactly: send-auto-wt-SUFFIX
685
+ [embed]
686
+ 🌳 **Worktree: opencode/kimaki-rply-wth-exctly-snd-at-wt-l8kct**
687
+ 📁 \`/tmp/worktrees/WORKTREE_NAME\`
688
+ 🌿 Branch: \`opencode/kimaki-rply-wth-exctly-snd-at-wt-l8kct\`
689
+ *using deterministic-provider/deterministic-v2*
690
+ ⬥ ok"
691
+ `);
692
+ // Verify DB has worktree info
693
+ const worktreeInfo = await getThreadWorktreeOrWorkspace(threadData.id);
694
+ expect(worktreeInfo?.status).toBe('ready');
695
+ expect(worktreeInfo?.workspace_directory).toBeTruthy();
696
+ // Verify runtime is in the worktree directory
697
+ const runtime = getRuntime(threadData.id);
698
+ expect(runtime).toBeDefined();
699
+ expect(runtime.sdkDirectory).toContain(`${path.sep}worktrees${path.sep}`);
700
+ expect(runtime.sdkDirectory).not.toBe(directories.projectDirectory);
701
+ }, 35_000);
633
702
  });
package/dist/worktrees.js CHANGED
@@ -713,6 +713,69 @@ export async function mergeWorktree({ worktreeDir, mainRepoDir, worktreeName, ta
713
713
  shortSha: shortSha instanceof Error ? 'unknown' : shortSha,
714
714
  };
715
715
  }
716
+ /**
717
+ * Resolve the best git ref for a base branch by checking remote tracking refs.
718
+ * Prefers upstream/<branch> over origin/<branch> over local <branch>.
719
+ * Fetches the remote first so tracking refs are up to date.
720
+ *
721
+ * If the remote is strictly ahead of local, returns the remote ref.
722
+ * If local and remote have diverged (local is both ahead and behind),
723
+ * returns the local branch to avoid needing a merge/rebase.
724
+ * If the branch is already an explicit remote ref (e.g. `origin/main`), skips resolution.
725
+ * Uses `git remote` to distinguish real remote prefixes from local branches
726
+ * containing `/` like `feature/foo`.
727
+ */
728
+ export async function resolveBestBaseRef({ directory, branch, }) {
729
+ // Check if branch is already an explicit remote ref like "origin/main".
730
+ // Local branches can contain `/` (e.g. "feature/foo"), so we check
731
+ // against actual remote names instead of a naive slash check.
732
+ if (branch.includes('/')) {
733
+ const remotes = await git(directory, 'remote');
734
+ if (!(remotes instanceof Error)) {
735
+ const prefix = branch.slice(0, branch.indexOf('/'));
736
+ if (remotes.split('\n').some((r) => r.trim() === prefix)) {
737
+ return branch;
738
+ }
739
+ }
740
+ }
741
+ for (const remote of ['upstream', 'origin']) {
742
+ // Best-effort fetch with short timeout
743
+ const fetchResult = await git(directory, `fetch ${remote} ${branch}`, {
744
+ timeout: 15_000,
745
+ });
746
+ if (fetchResult instanceof Error)
747
+ continue;
748
+ const remoteRef = `${remote}/${branch}`;
749
+ const refExists = await git(directory, `rev-parse --verify refs/remotes/${remoteRef}`);
750
+ if (refExists instanceof Error)
751
+ continue;
752
+ // Check if local branch exists
753
+ const localExists = await git(directory, `rev-parse --verify refs/heads/${branch}`);
754
+ if (localExists instanceof Error) {
755
+ // No local branch but remote exists — use remote
756
+ return remoteRef;
757
+ }
758
+ // Count commits: remote ahead of local, and local ahead of remote
759
+ const [remoteAhead, localAhead] = await Promise.all([
760
+ git(directory, `rev-list --count refs/heads/${branch}..refs/remotes/${remoteRef}`),
761
+ git(directory, `rev-list --count refs/remotes/${remoteRef}..refs/heads/${branch}`),
762
+ ]);
763
+ if (remoteAhead instanceof Error || localAhead instanceof Error)
764
+ continue;
765
+ const remoteAheadCount = parseInt(remoteAhead, 10);
766
+ const localAheadCount = parseInt(localAhead, 10);
767
+ // Diverged (both ahead): use local to avoid needing a merge
768
+ if (remoteAheadCount > 0 && localAheadCount > 0)
769
+ return branch;
770
+ // Remote is strictly ahead: use remote ref
771
+ if (remoteAheadCount > 0)
772
+ return remoteRef;
773
+ // Equal or local is ahead — check next remote instead of returning early,
774
+ // because origin might be ahead even when upstream is equal.
775
+ }
776
+ // No usable remote found — use local branch as-is
777
+ return branch;
778
+ }
716
779
  /**
717
780
  * List branches sorted by most recent commit date.
718
781
  * Returns branch short names (e.g. "main", "origin/feature-x").
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.21.0",
5
+ "version": "0.22.0",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "exports": {
@@ -31,10 +31,10 @@
31
31
  "lintcn": "^0.7.1",
32
32
  "tsx": "^4.20.5",
33
33
  "undici": "^8.0.2",
34
- "db": "^0.0.0",
35
34
  "discord-digital-twin": "^0.1.1",
35
+ "opencode-deterministic-provider": "^0.0.1",
36
36
  "opencode-cached-provider": "^0.0.1",
37
- "opencode-deterministic-provider": "^0.0.1"
37
+ "db": "^0.0.0"
38
38
  },
39
39
  "dependencies": {
40
40
  "@ai-sdk/google": "^3.0.53",
@@ -70,9 +70,9 @@
70
70
  "zod": "^4.3.6",
71
71
  "zustand": "^5.0.11",
72
72
  "errore": "^0.14.1",
73
- "libsqlproxy": "^0.1.0",
73
+ "traforo": "^0.7.1",
74
74
  "opencode-injection-guard": "^0.2.1",
75
- "traforo": "^0.7.1"
75
+ "libsqlproxy": "^0.1.0"
76
76
  },
77
77
  "optionalDependencies": {
78
78
  "@snazzah/davey": "^0.1.10",
@@ -705,6 +705,29 @@ Accordion apply `no-bleed` automatically to keep descendants inside their frame.
705
705
 
706
706
  See https://holocron.so/docs/customize/bleed.md for the full reference.
707
707
 
708
+ ## Custom CSS — prefer CSS variables over class selectors
709
+
710
+ When customizing the look of a Holocron site, **always use CSS variable overrides** in `:root` instead of targeting internal class names, DOM structure, or aria labels. Holocron exposes a set of documented CSS variables for colors, typography, spacing, code blocks, blockquotes, and sidebar navigation. These are stable across Holocron updates; internal markup is not.
711
+
712
+ ```css
713
+ /* style.css */
714
+ :root {
715
+ --weight-heading: 700;
716
+ --code-block-background: var(--muted);
717
+ --code-block-border: 1px solid var(--border-subtle);
718
+ --sidebar-link-radius: var(--radius-sm);
719
+ --blockquote-border-color: var(--border-subtle);
720
+ }
721
+ ```
722
+
723
+ Fetch the full list of available tokens:
724
+
725
+ ```bash
726
+ curl -fsSL https://holocron.so/docs/customize/theme.md
727
+ ```
728
+
729
+ If a user wants to customize something that has no CSS variable, **do not hack it with fragile selectors**. Instead, open an issue on the Holocron repo (https://github.com/remorses/holocron/issues) requesting a new CSS variable for that use case. Holocron actively adds new variables based on user needs. Internal selectors like `.slot-sidebar-left nav > div:first-child > a` or `figure[class~='group/code'] button[aria-label='Copy code']` will break on any refactor and should never be used.
730
+
708
731
  ## Agent rules
709
732
 
710
733
  - **Always place MDX pages inside the Holocron `pagesDir`.** Before creating or