kimaki 0.15.0 → 0.17.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.
@@ -19,7 +19,7 @@ import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provid
19
19
  import { setDataDir } from './config.js';
20
20
  import { store } from './store.js';
21
21
  import { startDiscordBot } from './discord-bot.js';
22
- import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, getThreadSession, getSessionAgent, getChannelAgent, } from './database.js';
22
+ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, getThreadSession, getSessionModel, getSessionAgent, getChannelAgent, setSessionModel, } from './database.js';
23
23
  import { getDb } from './db.js';
24
24
  import * as orm from 'drizzle-orm';
25
25
  import * as schema from './schema.js';
@@ -213,6 +213,9 @@ describe('agent model resolution', () => {
213
213
  });
214
214
  // Add extra models to the provider so opencode accepts them
215
215
  const providerConfig = opencodeConfig.provider[PROVIDER_NAME];
216
+ if (!providerConfig) {
217
+ throw new Error(`Missing deterministic provider config for ${PROVIDER_NAME}`);
218
+ }
216
219
  providerConfig.models[AGENT_MODEL] = { name: AGENT_MODEL };
217
220
  providerConfig.models[PLAN_AGENT_MODEL] = { name: PLAN_AGENT_MODEL };
218
221
  providerConfig.models[CHANNEL_MODEL] = { name: CHANNEL_MODEL };
@@ -532,6 +535,76 @@ describe('agent model resolution', () => {
532
535
  expect(footerMessage.content).toContain(CHANNEL_MODEL);
533
536
  expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
534
537
  }, 15_000);
538
+ test('/btw fork keeps source session model when channel model differs', async () => {
539
+ const db = await getDb();
540
+ await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
541
+ await db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, TEXT_CHANNEL_ID));
542
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
543
+ content: 'Reply with exactly: btw-source-msg',
544
+ });
545
+ const sourceThread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
546
+ timeout: 4_000,
547
+ predicate: (t) => {
548
+ return t.name === 'Reply with exactly: btw-source-msg';
549
+ },
550
+ });
551
+ await waitForFooterMessage({
552
+ discord,
553
+ threadId: sourceThread.id,
554
+ timeout: 6_000,
555
+ afterMessageIncludes: 'ok',
556
+ afterAuthorId: discord.botUserId,
557
+ });
558
+ const sourceSessionId = await getThreadSession(sourceThread.id);
559
+ expect(sourceSessionId).toBeDefined();
560
+ if (!sourceSessionId)
561
+ throw new Error('Expected source session');
562
+ await setSessionModel({
563
+ sessionId: sourceSessionId,
564
+ modelId: `${PROVIDER_NAME}/${PLAN_AGENT_MODEL}`,
565
+ });
566
+ await setChannelModel({
567
+ channelId: TEXT_CHANNEL_ID,
568
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
569
+ });
570
+ await discord.thread(sourceThread.id).user(TEST_USER_ID).sendMessage({
571
+ content: 'Reply with exactly: btw-model-check. btw',
572
+ });
573
+ const forkedThread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
574
+ timeout: 4_000,
575
+ predicate: (t) => {
576
+ return t.name === 'btw: Reply with exactly: btw-model-check';
577
+ },
578
+ });
579
+ await waitForFooterMessage({
580
+ discord,
581
+ threadId: forkedThread.id,
582
+ timeout: 6_000,
583
+ afterMessageIncludes: 'ok',
584
+ afterAuthorId: discord.botUserId,
585
+ });
586
+ const forkedSessionId = await getThreadSession(forkedThread.id);
587
+ expect(forkedSessionId).toBeDefined();
588
+ const forkedSessionModel = forkedSessionId
589
+ ? await getSessionModel(forkedSessionId)
590
+ : undefined;
591
+ const forkedThreadText = (await discord.thread(forkedThread.id).text())
592
+ .replace(`<#${sourceThread.id}>`, '<#SOURCE_THREAD>');
593
+ expect(forkedThreadText).toMatchInlineSnapshot(`
594
+ "--- from: assistant (TestBot)
595
+ Reusing context from <#SOURCE_THREAD> to answer prompt...
596
+ Reply with exactly: btw-model-check
597
+ ⬥ ok
598
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2*"
599
+ `);
600
+ expect(forkedSessionModel).toMatchInlineSnapshot(`
601
+ {
602
+ "modelId": "deterministic-provider/plan-model-v2",
603
+ "variant": null,
604
+ }
605
+ `);
606
+ expect(forkedSessionModel?.modelId).not.toBe(`${PROVIDER_NAME}/${CHANNEL_MODEL}`);
607
+ }, 20_000);
535
608
  test('changing channel agent via /plan-agent does not affect existing thread model', async () => {
536
609
  // 1. Set channel agent to test-agent (uses AGENT_MODEL)
537
610
  await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
@@ -457,6 +457,7 @@ cli
457
457
  botToken,
458
458
  embeds: autoStartEmbed,
459
459
  rest,
460
+ splitInsteadOfAttach: notifyOnly,
460
461
  });
461
462
  // For notify-only on non-project channels, just post the message without
462
463
  // creating a thread. There's no session to start, so a thread is unnecessary.
@@ -107,7 +107,7 @@ export function isThreadChannelType(type) {
107
107
  ChannelType.AnnouncementThread,
108
108
  ].includes(type);
109
109
  }
110
- export async function sendDiscordMessageWithOptionalAttachment({ channelId, prompt, botToken, embeds, rest, }) {
110
+ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prompt, botToken, embeds, rest, splitInsteadOfAttach, }) {
111
111
  const discordMaxLength = 2000;
112
112
  if (prompt.length <= discordMaxLength) {
113
113
  return (await rest.post(Routes.channelMessages(channelId), {
@@ -118,6 +118,33 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
118
118
  },
119
119
  }));
120
120
  }
121
+ if (splitInsteadOfAttach) {
122
+ const { splitMarkdownForDiscord } = await import('./discord-utils.js');
123
+ const chunks = splitMarkdownForDiscord({
124
+ content: prompt,
125
+ maxLength: discordMaxLength,
126
+ });
127
+ let firstMessage;
128
+ for (let chunk of chunks) {
129
+ if (!chunk?.trim())
130
+ continue;
131
+ // Safety net: hard-truncate if splitting still produced an oversized chunk
132
+ if (chunk.length > discordMaxLength) {
133
+ chunk = chunk.slice(0, discordMaxLength - 4) + '...';
134
+ }
135
+ const message = (await rest.post(Routes.channelMessages(channelId), {
136
+ body: {
137
+ content: chunk,
138
+ // Only attach embeds to the first message
139
+ ...(firstMessage ? {} : { embeds }),
140
+ allowed_mentions: { parse: store.getState().allowedMentions },
141
+ },
142
+ }));
143
+ if (!firstMessage)
144
+ firstMessage = message;
145
+ }
146
+ return firstMessage;
147
+ }
121
148
  const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
122
149
  const summaryContent = `Prompt attached as file (${prompt.length} chars)\n\n> ${preview}...`;
123
150
  const tmpDir = path.join(process.cwd(), 'tmp');
@@ -759,8 +786,7 @@ export async function resolveCredentials({ forceRestartOnboarding, forceGateway,
759
786
  options: [
760
787
  {
761
788
  value: 'gateway',
762
- disabled: true,
763
- label: 'Gateway (pre-built Kimaki bot, currently disabled because of Discord verification process. will be re-enabled soon)',
789
+ label: 'Gateway (pre-built Kimaki bot, no setup needed)',
764
790
  },
765
791
  {
766
792
  value: 'self_hosted',
@@ -8,6 +8,7 @@ import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage, } from
8
8
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
9
9
  import { createLogger, LogPrefix } from '../logger.js';
10
10
  import { initializeOpencodeForDirectory } from '../opencode.js';
11
+ import { copyCurrentSessionModel } from './model.js';
11
12
  const logger = createLogger(LogPrefix.FORK);
12
13
  export async function forkSessionToBtwThread({ sourceThread, projectDirectory, prompt, userId, username, appId, }) {
13
14
  // Parallelize: session lookup + opencode init + parent channel resolve are independent
@@ -33,6 +34,15 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, p
33
34
  return new Error('Failed to fork session');
34
35
  }
35
36
  const forkedSession = forkResponse.data;
37
+ const channelId = sourceThread.parentId || sourceThread.id;
38
+ await copyCurrentSessionModel({
39
+ sourceSessionId: sessionId,
40
+ targetSessionId: forkedSession.id,
41
+ channelId,
42
+ appId,
43
+ getClient: getClientResult,
44
+ directory: projectDirectory,
45
+ });
36
46
  const thread = await textChannel.threads.create({
37
47
  name: `btw: ${prompt}`.slice(0, 100),
38
48
  autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
@@ -53,8 +63,7 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, p
53
63
  `Do NOT continue, resume, or reference the previous task. Only answer the question below.`,
54
64
  ``,
55
65
  `Parent session: ${sessionId} (thread <#${sourceThread.id}>)`,
56
- `If the user asks you to send a message or follow-up to the parent session, use:`,
57
- ` kimaki send --session ${sessionId} --prompt 'your message here'`,
66
+ `Do NOT send messages to the parent session unless the user explicitly asks you to.`,
58
67
  ``,
59
68
  prompt,
60
69
  ].join('\n');
@@ -63,7 +72,7 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, p
63
72
  thread,
64
73
  projectDirectory,
65
74
  sdkDirectory: projectDirectory,
66
- channelId: sourceThread.parentId || sourceThread.id,
75
+ channelId,
67
76
  appId,
68
77
  });
69
78
  await runtime.enqueueIncoming({
@@ -9,7 +9,7 @@ import { notifyError } from '../sentry.js';
9
9
  import { mergeWorktree, listBranchesByLastCommit, validateBranchRef } from '../worktrees.js';
10
10
  import { sendThreadMessage, resolveWorkingDirectory, resolveProjectDirectoryFromAutocomplete, } from '../discord-utils.js';
11
11
  import { getOrCreateRuntime, } from '../session-handler/thread-session-runtime.js';
12
- import { RebaseConflictError, DirtyWorktreeError, TargetDirtyWorktreeError, } from '../errors.js';
12
+ import { RebaseConflictError, DirtyWorktreeError, TargetDirtyWorktreeError, NothingToMergeError, } from '../errors.js';
13
13
  const logger = createLogger(LogPrefix.WORKTREE);
14
14
  /** Worktree thread title prefix - indicates unmerged worktree */
15
15
  export const WORKTREE_PREFIX = '⬦ ';
@@ -103,6 +103,11 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
103
103
  await command.editReply('Merge failed: uncommitted changes in main. Commit changes in the main worktree first, then run `/merge-worktree` again.');
104
104
  return;
105
105
  }
106
+ if (result instanceof NothingToMergeError) {
107
+ void removeWorktreePrefixFromTitle(thread);
108
+ await command.editReply(`Merge failed: ${result.message}`);
109
+ return;
110
+ }
106
111
  if (result instanceof RebaseConflictError) {
107
112
  await command.editReply('Rebase conflict detected. Asking the model to resolve...');
108
113
  await sendPromptToModel({
@@ -104,6 +104,28 @@ export async function ensureSessionPreferencesSnapshot({ sessionId, channelId, a
104
104
  });
105
105
  modelLogger.log(`[MODEL] Snapshotted session model ${bootstrappedModel.model} for session ${sessionId}`);
106
106
  }
107
+ export async function copyCurrentSessionModel({ sourceSessionId, targetSessionId, channelId, appId, getClient, directory, }) {
108
+ const modelInfo = await getCurrentModelInfo({
109
+ sessionId: sourceSessionId,
110
+ channelId,
111
+ appId,
112
+ getClient,
113
+ directory,
114
+ });
115
+ if (modelInfo.type === 'none')
116
+ return;
117
+ const variant = await getVariantCascade({
118
+ sessionId: sourceSessionId,
119
+ channelId,
120
+ appId,
121
+ });
122
+ await setSessionModel({
123
+ sessionId: targetSessionId,
124
+ modelId: modelInfo.model,
125
+ variant: variant ?? null,
126
+ });
127
+ modelLogger.log(`[MODEL] Copied session model ${modelInfo.model} from ${sourceSessionId} to ${targetSessionId}`);
128
+ }
107
129
  /**
108
130
  * Get the current model info for a channel/session, including where it comes from.
109
131
  * Priority: session > agent > channel > global > opencode default
@@ -196,16 +218,11 @@ export async function handleModelCommand({ interaction, appId, }) {
196
218
  return;
197
219
  }
198
220
  // Determine if we're in a thread or text channel
199
- const isThread = [
200
- ChannelType.PublicThread,
201
- ChannelType.PrivateThread,
202
- ChannelType.AnnouncementThread,
203
- ].includes(channel.type);
221
+ const thread = channel.isThread() ? channel : undefined;
204
222
  let projectDirectory;
205
223
  let targetChannelId;
206
224
  let sessionId;
207
- if (isThread) {
208
- const thread = channel;
225
+ if (thread) {
209
226
  // Parallelize: resolve metadata and session ID at the same time
210
227
  const [textChannel, threadSessionId] = await Promise.all([
211
228
  resolveTextChannel(thread),
@@ -240,7 +257,7 @@ export async function handleModelCommand({ interaction, appId, }) {
240
257
  return;
241
258
  }
242
259
  const effectiveAppId = appId;
243
- if (isThread && sessionId) {
260
+ if (thread && sessionId) {
244
261
  await ensureSessionPreferencesSnapshot({
245
262
  sessionId,
246
263
  channelId: targetChannelId,
@@ -313,8 +330,8 @@ export async function handleModelCommand({ interaction, appId, }) {
313
330
  dir: projectDirectory,
314
331
  channelId: targetChannelId,
315
332
  sessionId: sessionId,
316
- isThread: isThread,
317
- thread: isThread ? channel : undefined,
333
+ isThread: Boolean(thread),
334
+ thread,
318
335
  appId,
319
336
  providerSelectHeader,
320
337
  };
@@ -13,6 +13,7 @@ import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js
13
13
  import { buildSessionPermissions, initializeOpencodeForDirectory, } from '../opencode.js';
14
14
  import { WORKTREE_PREFIX } from './merge-worktree.js';
15
15
  import * as errore from 'errore';
16
+ import { copyCurrentSessionModel } from './model.js';
16
17
  const logger = createLogger(LogPrefix.WORKTREE);
17
18
  const DEFAULT_WORKTREE_BASE_REF = 'HEAD';
18
19
  async function resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch, }) {
@@ -434,6 +435,14 @@ async function handleWorktreeInThread({ command, thread, appId, }) {
434
435
  await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${error.message}`);
435
436
  return;
436
437
  }
438
+ await copyCurrentSessionModel({
439
+ sourceSessionId,
440
+ targetSessionId: forkedSession.id,
441
+ channelId: parent.id,
442
+ appId,
443
+ getClient,
444
+ directory: projectDirectory,
445
+ });
437
446
  const permissionResponse = await getClient().session.update({
438
447
  sessionID: forkedSession.id,
439
448
  directory: result,
package/dist/db.test.js CHANGED
@@ -7,9 +7,10 @@ import { afterAll, describe, expect, test } from 'vitest';
7
7
  import { closeDb, getDb } from './db.js';
8
8
  import * as orm from 'drizzle-orm';
9
9
  import * as schema from './schema.js';
10
- import { appendSessionEventsSinceLastTimestamp, createPendingWorktree, getSessionEventSnapshot, } from './database.js';
10
+ import { appendSessionEventsSinceLastTimestamp, createPendingWorktree, getSessionEventSnapshot, getSessionModel, setSessionModel, } from './database.js';
11
11
  import { startHranaServer, stopHranaServer } from './hrana-server.js';
12
12
  import { chooseLockPort } from './test-utils.js';
13
+ import { copyCurrentSessionModel } from './commands/model.js';
13
14
  afterAll(async () => {
14
15
  await closeDb();
15
16
  });
@@ -100,6 +101,31 @@ describe('getDb', () => {
100
101
  await db.delete(schema.thread_worktrees).where(orm.eq(schema.thread_worktrees.thread_id, threadId));
101
102
  await db.delete(schema.thread_sessions).where(orm.eq(schema.thread_sessions.thread_id, threadId));
102
103
  });
104
+ test('copyCurrentSessionModel snapshots source session model to forked session', async () => {
105
+ const db = await getDb();
106
+ const sourceSessionId = `test-source-session-${crypto.randomUUID()}`;
107
+ const targetSessionId = `test-target-session-${crypto.randomUUID()}`;
108
+ const getClient = (() => {
109
+ throw new Error('provider lookup should not run for explicit session models');
110
+ });
111
+ await setSessionModel({
112
+ sessionId: sourceSessionId,
113
+ modelId: 'anthropic/claude-opus-4-6',
114
+ variant: 'thinking',
115
+ });
116
+ await copyCurrentSessionModel({
117
+ sourceSessionId,
118
+ targetSessionId,
119
+ getClient,
120
+ });
121
+ await expect(getSessionModel(targetSessionId)).resolves.toMatchInlineSnapshot(`
122
+ {
123
+ "modelId": "anthropic/claude-opus-4-6",
124
+ "variant": "thinking",
125
+ }
126
+ `);
127
+ await db.delete(schema.session_models).where(orm.inArray(schema.session_models.session_id, [sourceSessionId, targetSessionId]));
128
+ });
103
129
  test('session event persistence uses (timestamp, event_index) ordering for deterministic same-ms replay', async () => {
104
130
  const db = await getDb();
105
131
  const threadId = 'test-session-events-thread';
package/dist/opencode.js CHANGED
@@ -529,6 +529,26 @@ async function startSingleServer({ directory, } = {}) {
529
529
  experimental: {
530
530
  continue_loop_on_deny: true,
531
531
  },
532
+ provider: {
533
+ xai: {
534
+ models: {
535
+ 'grok-composer-2.5-fast': {
536
+ name: 'Grok Composer 2.5 Fast',
537
+ attachment: true,
538
+ tool_call: true,
539
+ limit: {
540
+ context: 256000,
541
+ output: 256000,
542
+ },
543
+ cost: {
544
+ input: 0.50,
545
+ output: 2.50,
546
+ cache_read: 0.20,
547
+ },
548
+ },
549
+ },
550
+ },
551
+ },
532
552
  skills: {
533
553
  paths: [path.resolve(__dirname, '..', 'skills')],
534
554
  },
@@ -535,7 +535,7 @@ When you are approaching the **context window limit** or the user explicitly ask
535
535
  kimaki send --channel ${channelId} --prompt 'Continuing from previous session: <summary of current task and state>' --agent <current_agent>${userArg}
536
536
  \`\`\`
537
537
 
538
- The command automatically handles long prompts (over 2000 chars) by sending them as file attachments.
538
+ The command automatically handles long prompts (over 2000 chars) by sending them as file attachments. With \`--notify-only\`, long prompts are split into multiple messages instead so the content is directly visible.
539
539
 
540
540
  Use this for handoff when:
541
541
  - User asks to "handoff", "continue in new thread", or "start fresh session"
@@ -296,7 +296,7 @@ describe('system-message', () => {
296
296
  kimaki send --channel chan_123 --prompt 'Continuing from previous session: <summary of current task and state>' --agent <current_agent> --user '<discord-user-id>'
297
297
  \`\`\`
298
298
 
299
- The command automatically handles long prompts (over 2000 chars) by sending them as file attachments.
299
+ The command automatically handles long prompts (over 2000 chars) by sending them as file attachments. With \`--notify-only\`, long prompts are split into multiple messages instead so the content is directly visible.
300
300
 
301
301
  Use this for handoff when:
302
302
  - User asks to "handoff", "continue in new thread", or "start fresh session"
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.15.0",
5
+ "version": "0.17.0",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -25,8 +25,8 @@
25
25
  "tsx": "^4.20.5",
26
26
  "undici": "^8.0.2",
27
27
  "discord-digital-twin": "^0.1.0",
28
- "opencode-deterministic-provider": "^0.0.1",
29
28
  "opencode-cached-provider": "^0.0.1",
29
+ "opencode-deterministic-provider": "^0.0.1",
30
30
  "db": "^0.0.0"
31
31
  },
32
32
  "dependencies": {
@@ -62,10 +62,10 @@
62
62
  "yaml": "^2.8.3",
63
63
  "zod": "^4.3.6",
64
64
  "zustand": "^5.0.11",
65
- "traforo": "^0.7.0",
66
65
  "errore": "^0.14.1",
67
66
  "libsqlproxy": "^0.1.0",
68
- "opencode-injection-guard": "^0.2.1"
67
+ "opencode-injection-guard": "^0.2.1",
68
+ "traforo": "^0.7.1"
69
69
  },
70
70
  "optionalDependencies": {
71
71
  "@snazzah/davey": "^0.1.10",
@@ -39,8 +39,10 @@ import {
39
39
  setChannelAgent,
40
40
  setChannelModel,
41
41
  getThreadSession,
42
+ getSessionModel,
42
43
  getSessionAgent,
43
44
  getChannelAgent,
45
+ setSessionModel,
44
46
  type VerbosityLevel,
45
47
  } from './database.js'
46
48
  import { getDb } from './db.js'
@@ -281,8 +283,9 @@ describe('agent model resolution', () => {
281
283
  })
282
284
 
283
285
  // Add extra models to the provider so opencode accepts them
284
- const providerConfig = opencodeConfig.provider[PROVIDER_NAME] as {
285
- models: Record<string, { name: string }>
286
+ const providerConfig = opencodeConfig.provider[PROVIDER_NAME]
287
+ if (!providerConfig) {
288
+ throw new Error(`Missing deterministic provider config for ${PROVIDER_NAME}`)
286
289
  }
287
290
  providerConfig.models[AGENT_MODEL] = { name: AGENT_MODEL }
288
291
  providerConfig.models[PLAN_AGENT_MODEL] = { name: PLAN_AGENT_MODEL }
@@ -700,6 +703,91 @@ describe('agent model resolution', () => {
700
703
  15_000,
701
704
  )
702
705
 
706
+ test(
707
+ '/btw fork keeps source session model when channel model differs',
708
+ async () => {
709
+ const db = await getDb()
710
+ await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID))
711
+ await db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, TEXT_CHANNEL_ID))
712
+
713
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
714
+ content: 'Reply with exactly: btw-source-msg',
715
+ })
716
+
717
+ const sourceThread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
718
+ timeout: 4_000,
719
+ predicate: (t) => {
720
+ return t.name === 'Reply with exactly: btw-source-msg'
721
+ },
722
+ })
723
+
724
+ await waitForFooterMessage({
725
+ discord,
726
+ threadId: sourceThread.id,
727
+ timeout: 6_000,
728
+ afterMessageIncludes: 'ok',
729
+ afterAuthorId: discord.botUserId,
730
+ })
731
+
732
+ const sourceSessionId = await getThreadSession(sourceThread.id)
733
+ expect(sourceSessionId).toBeDefined()
734
+ if (!sourceSessionId) throw new Error('Expected source session')
735
+
736
+ await setSessionModel({
737
+ sessionId: sourceSessionId,
738
+ modelId: `${PROVIDER_NAME}/${PLAN_AGENT_MODEL}`,
739
+ })
740
+ await setChannelModel({
741
+ channelId: TEXT_CHANNEL_ID,
742
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
743
+ })
744
+
745
+ await discord.thread(sourceThread.id).user(TEST_USER_ID).sendMessage({
746
+ content: 'Reply with exactly: btw-model-check. btw',
747
+ })
748
+
749
+ const forkedThread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
750
+ timeout: 4_000,
751
+ predicate: (t) => {
752
+ return t.name === 'btw: Reply with exactly: btw-model-check'
753
+ },
754
+ })
755
+
756
+ await waitForFooterMessage({
757
+ discord,
758
+ threadId: forkedThread.id,
759
+ timeout: 6_000,
760
+ afterMessageIncludes: 'ok',
761
+ afterAuthorId: discord.botUserId,
762
+ })
763
+
764
+ const forkedSessionId = await getThreadSession(forkedThread.id)
765
+ expect(forkedSessionId).toBeDefined()
766
+ const forkedSessionModel = forkedSessionId
767
+ ? await getSessionModel(forkedSessionId)
768
+ : undefined
769
+
770
+ const forkedThreadText = (await discord.thread(forkedThread.id).text())
771
+ .replace(`<#${sourceThread.id}>`, '<#SOURCE_THREAD>')
772
+
773
+ expect(forkedThreadText).toMatchInlineSnapshot(`
774
+ "--- from: assistant (TestBot)
775
+ Reusing context from <#SOURCE_THREAD> to answer prompt...
776
+ Reply with exactly: btw-model-check
777
+ ⬥ ok
778
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2*"
779
+ `)
780
+ expect(forkedSessionModel).toMatchInlineSnapshot(`
781
+ {
782
+ "modelId": "deterministic-provider/plan-model-v2",
783
+ "variant": null,
784
+ }
785
+ `)
786
+ expect(forkedSessionModel?.modelId).not.toBe(`${PROVIDER_NAME}/${CHANNEL_MODEL}`)
787
+ },
788
+ 20_000,
789
+ )
790
+
703
791
  test(
704
792
  'changing channel agent via /plan-agent does not affect existing thread model',
705
793
  async () => {
@@ -621,6 +621,7 @@ cli
621
621
  botToken,
622
622
  embeds: autoStartEmbed,
623
623
  rest,
624
+ splitInsteadOfAttach: notifyOnly,
624
625
  })
625
626
 
626
627
  // For notify-only on non-project channels, just post the message without
package/src/cli-runner.ts CHANGED
@@ -172,12 +172,17 @@ export async function sendDiscordMessageWithOptionalAttachment({
172
172
  botToken,
173
173
  embeds,
174
174
  rest,
175
+ splitInsteadOfAttach,
175
176
  }: {
176
177
  channelId: string
177
178
  prompt: string
178
179
  botToken: string
179
180
  embeds?: Array<{ color: number; footer: { text: string } }>
180
181
  rest: REST
182
+ /** When true, long messages are split into multiple Discord messages instead of
183
+ * being attached as a file. Useful for notify-only messages where the content
184
+ * should be directly visible in the channel. */
185
+ splitInsteadOfAttach?: boolean
181
186
  }): Promise<{ id: string }> {
182
187
  const discordMaxLength = 2000
183
188
  if (prompt.length <= discordMaxLength) {
@@ -190,6 +195,32 @@ export async function sendDiscordMessageWithOptionalAttachment({
190
195
  })) as { id: string }
191
196
  }
192
197
 
198
+ if (splitInsteadOfAttach) {
199
+ const { splitMarkdownForDiscord } = await import('./discord-utils.js')
200
+ const chunks = splitMarkdownForDiscord({
201
+ content: prompt,
202
+ maxLength: discordMaxLength,
203
+ })
204
+ let firstMessage: { id: string } | undefined
205
+ for (let chunk of chunks) {
206
+ if (!chunk?.trim()) continue
207
+ // Safety net: hard-truncate if splitting still produced an oversized chunk
208
+ if (chunk.length > discordMaxLength) {
209
+ chunk = chunk.slice(0, discordMaxLength - 4) + '...'
210
+ }
211
+ const message = (await rest.post(Routes.channelMessages(channelId), {
212
+ body: {
213
+ content: chunk,
214
+ // Only attach embeds to the first message
215
+ ...(firstMessage ? {} : { embeds }),
216
+ allowed_mentions: { parse: store.getState().allowedMentions },
217
+ },
218
+ })) as { id: string }
219
+ if (!firstMessage) firstMessage = message
220
+ }
221
+ return firstMessage!
222
+ }
223
+
193
224
  const preview = prompt.slice(0, 100).replace(/\n/g, ' ')
194
225
  const summaryContent = `Prompt attached as file (${prompt.length} chars)\n\n> ${preview}...`
195
226
 
@@ -1109,8 +1140,7 @@ export async function resolveCredentials({
1109
1140
  options: [
1110
1141
  {
1111
1142
  value: 'gateway' as const,
1112
- disabled: true,
1113
- label: 'Gateway (pre-built Kimaki bot, currently disabled because of Discord verification process. will be re-enabled soon)',
1143
+ label: 'Gateway (pre-built Kimaki bot, no setup needed)',
1114
1144
  },
1115
1145
  {
1116
1146
  value: 'self_hosted' as const,
@@ -19,6 +19,7 @@ import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js
19
19
  import { createLogger, LogPrefix } from '../logger.js'
20
20
  import type { CommandContext } from './types.js'
21
21
  import { initializeOpencodeForDirectory } from '../opencode.js'
22
+ import { copyCurrentSessionModel } from './model.js'
22
23
 
23
24
  const logger = createLogger(LogPrefix.FORK)
24
25
 
@@ -62,6 +63,16 @@ export async function forkSessionToBtwThread({
62
63
  return new Error('Failed to fork session')
63
64
  }
64
65
  const forkedSession = forkResponse.data
66
+ const channelId = sourceThread.parentId || sourceThread.id
67
+
68
+ await copyCurrentSessionModel({
69
+ sourceSessionId: sessionId,
70
+ targetSessionId: forkedSession.id,
71
+ channelId,
72
+ appId,
73
+ getClient: getClientResult,
74
+ directory: projectDirectory,
75
+ })
65
76
 
66
77
  const thread = await textChannel.threads.create({
67
78
  name: `btw: ${prompt}`.slice(0, 100),
@@ -92,8 +103,7 @@ export async function forkSessionToBtwThread({
92
103
  `Do NOT continue, resume, or reference the previous task. Only answer the question below.`,
93
104
  ``,
94
105
  `Parent session: ${sessionId} (thread <#${sourceThread.id}>)`,
95
- `If the user asks you to send a message or follow-up to the parent session, use:`,
96
- ` kimaki send --session ${sessionId} --prompt 'your message here'`,
106
+ `Do NOT send messages to the parent session unless the user explicitly asks you to.`,
97
107
  ``,
98
108
  prompt,
99
109
  ].join('\n')
@@ -103,7 +113,7 @@ export async function forkSessionToBtwThread({
103
113
  thread,
104
114
  projectDirectory,
105
115
  sdkDirectory: projectDirectory,
106
- channelId: sourceThread.parentId || sourceThread.id,
116
+ channelId,
107
117
  appId,
108
118
  })
109
119
  await runtime.enqueueIncoming({
@@ -25,6 +25,7 @@ import {
25
25
  RebaseConflictError,
26
26
  DirtyWorktreeError,
27
27
  TargetDirtyWorktreeError,
28
+ NothingToMergeError,
28
29
  } from '../errors.js'
29
30
 
30
31
  const logger = createLogger(LogPrefix.WORKTREE)
@@ -160,6 +161,12 @@ export async function handleMergeWorktreeCommand({
160
161
  return
161
162
  }
162
163
 
164
+ if (result instanceof NothingToMergeError) {
165
+ void removeWorktreePrefixFromTitle(thread)
166
+ await command.editReply(`Merge failed: ${result.message}`)
167
+ return
168
+ }
169
+
163
170
  if (result instanceof RebaseConflictError) {
164
171
  await command.editReply(
165
172
  'Rebase conflict detected. Asking the model to resolve...',
@@ -248,6 +248,45 @@ export async function ensureSessionPreferencesSnapshot({
248
248
  )
249
249
  }
250
250
 
251
+ export async function copyCurrentSessionModel({
252
+ sourceSessionId,
253
+ targetSessionId,
254
+ channelId,
255
+ appId,
256
+ getClient,
257
+ directory,
258
+ }: {
259
+ sourceSessionId: string
260
+ targetSessionId: string
261
+ channelId?: string
262
+ appId?: string
263
+ getClient: Awaited<ReturnType<typeof initializeOpencodeForDirectory>>
264
+ directory?: string
265
+ }) {
266
+ const modelInfo = await getCurrentModelInfo({
267
+ sessionId: sourceSessionId,
268
+ channelId,
269
+ appId,
270
+ getClient,
271
+ directory,
272
+ })
273
+ if (modelInfo.type === 'none') return
274
+
275
+ const variant = await getVariantCascade({
276
+ sessionId: sourceSessionId,
277
+ channelId,
278
+ appId,
279
+ })
280
+ await setSessionModel({
281
+ sessionId: targetSessionId,
282
+ modelId: modelInfo.model,
283
+ variant: variant ?? null,
284
+ })
285
+ modelLogger.log(
286
+ `[MODEL] Copied session model ${modelInfo.model} from ${sourceSessionId} to ${targetSessionId}`,
287
+ )
288
+ }
289
+
251
290
  /**
252
291
  * Get the current model info for a channel/session, including where it comes from.
253
292
  * Priority: session > agent > channel > global > opencode default
@@ -372,18 +411,13 @@ export async function handleModelCommand({
372
411
  }
373
412
 
374
413
  // Determine if we're in a thread or text channel
375
- const isThread = [
376
- ChannelType.PublicThread,
377
- ChannelType.PrivateThread,
378
- ChannelType.AnnouncementThread,
379
- ].includes(channel.type)
414
+ const thread = channel.isThread() ? channel : undefined
380
415
 
381
416
  let projectDirectory: string | undefined
382
417
  let targetChannelId: string
383
418
  let sessionId: string | undefined
384
419
 
385
- if (isThread) {
386
- const thread = channel as ThreadChannel
420
+ if (thread) {
387
421
  // Parallelize: resolve metadata and session ID at the same time
388
422
  const [textChannel, threadSessionId] = await Promise.all([
389
423
  resolveTextChannel(thread),
@@ -420,7 +454,7 @@ export async function handleModelCommand({
420
454
 
421
455
  const effectiveAppId = appId
422
456
 
423
- if (isThread && sessionId) {
457
+ if (thread && sessionId) {
424
458
  await ensureSessionPreferencesSnapshot({
425
459
  sessionId,
426
460
  channelId: targetChannelId,
@@ -503,8 +537,8 @@ export async function handleModelCommand({
503
537
  dir: projectDirectory,
504
538
  channelId: targetChannelId,
505
539
  sessionId: sessionId,
506
- isThread: isThread,
507
- thread: isThread ? (channel as ThreadChannel) : undefined,
540
+ isThread: Boolean(thread),
541
+ thread,
508
542
  appId,
509
543
  providerSelectHeader,
510
544
  }
@@ -43,6 +43,7 @@ import {
43
43
  import { WORKTREE_PREFIX } from './merge-worktree.js'
44
44
  import type { AutocompleteContext } from './types.js'
45
45
  import * as errore from 'errore'
46
+ import { copyCurrentSessionModel } from './model.js'
46
47
 
47
48
  const logger = createLogger(LogPrefix.WORKTREE)
48
49
  const DEFAULT_WORKTREE_BASE_REF = 'HEAD'
@@ -598,6 +599,15 @@ async function handleWorktreeInThread({
598
599
  return
599
600
  }
600
601
 
602
+ await copyCurrentSessionModel({
603
+ sourceSessionId,
604
+ targetSessionId: forkedSession.id,
605
+ channelId: parent.id,
606
+ appId,
607
+ getClient,
608
+ directory: projectDirectory,
609
+ })
610
+
601
611
  const permissionResponse = await getClient().session.update({
602
612
  sessionID: forkedSession.id,
603
613
  directory: result,
package/src/db.test.ts CHANGED
@@ -12,9 +12,13 @@ import {
12
12
  appendSessionEventsSinceLastTimestamp,
13
13
  createPendingWorktree,
14
14
  getSessionEventSnapshot,
15
+ getSessionModel,
16
+ setSessionModel,
15
17
  } from './database.js'
16
18
  import { startHranaServer, stopHranaServer } from './hrana-server.js'
17
19
  import { chooseLockPort } from './test-utils.js'
20
+ import { copyCurrentSessionModel } from './commands/model.js'
21
+ import type { initializeOpencodeForDirectory } from './opencode.js'
18
22
 
19
23
  afterAll(async () => {
20
24
  await closeDb()
@@ -118,6 +122,36 @@ describe('getDb', () => {
118
122
  await db.delete(schema.thread_sessions).where(orm.eq(schema.thread_sessions.thread_id, threadId))
119
123
  })
120
124
 
125
+ test('copyCurrentSessionModel snapshots source session model to forked session', async () => {
126
+ const db = await getDb()
127
+ const sourceSessionId = `test-source-session-${crypto.randomUUID()}`
128
+ const targetSessionId = `test-target-session-${crypto.randomUUID()}`
129
+ const getClient = (() => {
130
+ throw new Error('provider lookup should not run for explicit session models')
131
+ }) satisfies Exclude<Awaited<ReturnType<typeof initializeOpencodeForDirectory>>, Error>
132
+
133
+ await setSessionModel({
134
+ sessionId: sourceSessionId,
135
+ modelId: 'anthropic/claude-opus-4-6',
136
+ variant: 'thinking',
137
+ })
138
+
139
+ await copyCurrentSessionModel({
140
+ sourceSessionId,
141
+ targetSessionId,
142
+ getClient,
143
+ })
144
+
145
+ await expect(getSessionModel(targetSessionId)).resolves.toMatchInlineSnapshot(`
146
+ {
147
+ "modelId": "anthropic/claude-opus-4-6",
148
+ "variant": "thinking",
149
+ }
150
+ `)
151
+
152
+ await db.delete(schema.session_models).where(orm.inArray(schema.session_models.session_id, [sourceSessionId, targetSessionId]))
153
+ })
154
+
121
155
  test('session event persistence uses (timestamp, event_index) ordering for deterministic same-ms replay', async () => {
122
156
  const db = await getDb()
123
157
  const threadId = 'test-session-events-thread'
package/src/opencode.ts CHANGED
@@ -735,6 +735,26 @@ async function startSingleServer({
735
735
  experimental: {
736
736
  continue_loop_on_deny: true,
737
737
  },
738
+ provider: {
739
+ xai: {
740
+ models: {
741
+ 'grok-composer-2.5-fast': {
742
+ name: 'Grok Composer 2.5 Fast',
743
+ attachment: true,
744
+ tool_call: true,
745
+ limit: {
746
+ context: 256000,
747
+ output: 256000,
748
+ },
749
+ cost: {
750
+ input: 0.50,
751
+ output: 2.50,
752
+ cache_read: 0.20,
753
+ },
754
+ },
755
+ },
756
+ },
757
+ },
738
758
  skills: {
739
759
  paths: [path.resolve(__dirname, '..', 'skills')],
740
760
  },
@@ -306,7 +306,7 @@ describe('system-message', () => {
306
306
  kimaki send --channel chan_123 --prompt 'Continuing from previous session: <summary of current task and state>' --agent <current_agent> --user '<discord-user-id>'
307
307
  \`\`\`
308
308
 
309
- The command automatically handles long prompts (over 2000 chars) by sending them as file attachments.
309
+ The command automatically handles long prompts (over 2000 chars) by sending them as file attachments. With \`--notify-only\`, long prompts are split into multiple messages instead so the content is directly visible.
310
310
 
311
311
  Use this for handoff when:
312
312
  - User asks to "handoff", "continue in new thread", or "start fresh session"
@@ -649,7 +649,7 @@ When you are approaching the **context window limit** or the user explicitly ask
649
649
  kimaki send --channel ${channelId} --prompt 'Continuing from previous session: <summary of current task and state>' --agent <current_agent>${userArg}
650
650
  \`\`\`
651
651
 
652
- The command automatically handles long prompts (over 2000 chars) by sending them as file attachments.
652
+ The command automatically handles long prompts (over 2000 chars) by sending them as file attachments. With \`--notify-only\`, long prompts are split into multiple messages instead so the content is directly visible.
653
653
 
654
654
  Use this for handoff when:
655
655
  - User asks to "handoff", "continue in new thread", or "start fresh session"