kimaki 0.11.0 → 0.13.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.
Files changed (79) hide show
  1. package/dist/agent-model.e2e.test.js +91 -1
  2. package/dist/btw-prefix-detection.js +13 -15
  3. package/dist/btw-prefix-detection.test.js +60 -30
  4. package/dist/cli-runner.js +36 -12
  5. package/dist/cli.js +10 -0
  6. package/dist/commands/abort.js +1 -1
  7. package/dist/commands/agent.js +14 -16
  8. package/dist/commands/mention-mode.js +0 -1
  9. package/dist/commands/model-variant.js +2 -2
  10. package/dist/commands/model.js +47 -27
  11. package/dist/commands/restart-opencode-server.js +1 -1
  12. package/dist/commands/undo-redo.js +2 -2
  13. package/dist/commands/unset-model.js +2 -2
  14. package/dist/commands/upgrade.js +1 -2
  15. package/dist/commands/verbosity.js +0 -1
  16. package/dist/commands/worktree-settings.js +0 -1
  17. package/dist/discord-bot.js +65 -15
  18. package/dist/discord-command-registration.js +1 -1
  19. package/dist/discord-utils.js +14 -0
  20. package/dist/discord-utils.test.js +51 -1
  21. package/dist/external-opencode-sync.js +119 -54
  22. package/dist/interaction-handler.js +4 -0
  23. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +1 -1
  24. package/dist/message-formatting.js +91 -0
  25. package/dist/message-formatting.test.js +206 -1
  26. package/dist/message-preprocessing.js +1 -1
  27. package/dist/opencode-interrupt-plugin.js +14 -2
  28. package/dist/opencode-interrupt-plugin.test.js +22 -3
  29. package/dist/opencode.js +34 -158
  30. package/dist/queue-advanced-model-switch.e2e.test.js +1 -1
  31. package/dist/session-handler/agent-utils.js +9 -9
  32. package/dist/session-handler/thread-runtime-state.js +29 -0
  33. package/dist/session-handler/thread-session-runtime.js +51 -9
  34. package/dist/store.js +2 -0
  35. package/dist/system-message.test.js +16 -0
  36. package/dist/thread-message-queue.e2e.test.js +198 -1
  37. package/dist/voice-handler.js +91 -68
  38. package/package.json +6 -6
  39. package/skills/holocron/SKILL.md +432 -0
  40. package/skills/npm-package/SKILL.md +12 -2
  41. package/skills/termcast/SKILL.md +32 -846
  42. package/skills/tuistory/SKILL.md +71 -0
  43. package/src/agent-model.e2e.test.ts +117 -0
  44. package/src/btw-prefix-detection.test.ts +61 -30
  45. package/src/btw-prefix-detection.ts +15 -19
  46. package/src/cli-runner.ts +36 -12
  47. package/src/cli.ts +22 -0
  48. package/src/commands/abort.ts +1 -1
  49. package/src/commands/agent.ts +14 -17
  50. package/src/commands/mention-mode.ts +0 -1
  51. package/src/commands/model-variant.ts +2 -2
  52. package/src/commands/model.ts +63 -37
  53. package/src/commands/restart-opencode-server.ts +1 -1
  54. package/src/commands/undo-redo.ts +2 -2
  55. package/src/commands/unset-model.ts +1 -2
  56. package/src/commands/upgrade.ts +1 -2
  57. package/src/commands/verbosity.ts +0 -1
  58. package/src/commands/worktree-settings.ts +0 -1
  59. package/src/discord-bot.ts +76 -13
  60. package/src/discord-command-registration.ts +1 -1
  61. package/src/discord-utils.test.ts +63 -2
  62. package/src/discord-utils.ts +19 -0
  63. package/src/external-opencode-sync.ts +147 -64
  64. package/src/interaction-handler.ts +5 -0
  65. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +1 -1
  66. package/src/message-formatting.test.ts +247 -1
  67. package/src/message-formatting.ts +93 -1
  68. package/src/message-preprocessing.ts +1 -1
  69. package/src/opencode-interrupt-plugin.test.ts +27 -3
  70. package/src/opencode-interrupt-plugin.ts +15 -3
  71. package/src/opencode.ts +36 -152
  72. package/src/queue-advanced-model-switch.e2e.test.ts +1 -1
  73. package/src/session-handler/agent-utils.ts +11 -11
  74. package/src/session-handler/thread-runtime-state.ts +35 -0
  75. package/src/session-handler/thread-session-runtime.ts +67 -8
  76. package/src/store.ts +17 -0
  77. package/src/system-message.test.ts +16 -0
  78. package/src/thread-message-queue.e2e.test.ts +227 -1
  79. package/src/voice-handler.ts +106 -78
@@ -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, } from './database.js';
22
+ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, getThreadSession, getSessionAgent, getChannelAgent, } 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';
@@ -258,6 +258,10 @@ describe('agent model resolution', () => {
258
258
  description: `Switch to ${agentName} agent`,
259
259
  }))
260
260
  .setDMPermission(false)
261
+ .addStringOption((opt) => opt
262
+ .setName('prompt')
263
+ .setDescription('Send a prompt with this agent')
264
+ .setRequired(false))
261
265
  .toJSON();
262
266
  });
263
267
  const rest = new REST({ version: '10', api: discord.restUrl }).setToken(discord.botToken);
@@ -681,6 +685,92 @@ describe('agent model resolution', () => {
681
685
  expect(secondFooter.content).toContain(DEFAULT_MODEL);
682
686
  expect(secondFooter.content).not.toContain(AGENT_MODEL);
683
687
  }, 20_000);
688
+ test('/plan-agent with prompt starts a session with the plan agent', async () => {
689
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
690
+ const prompt = 'Reply with exactly: inline-plan-agent-msg';
691
+ const { id: interactionId } = await discord
692
+ .channel(TEXT_CHANNEL_ID)
693
+ .user(TEST_USER_ID)
694
+ .runSlashCommand({
695
+ name: 'plan-agent',
696
+ options: [{ name: 'prompt', type: 3, value: prompt }],
697
+ });
698
+ await discord
699
+ .channel(TEXT_CHANNEL_ID)
700
+ .waitForInteractionAck({ interactionId, timeout: 4_000 });
701
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
702
+ timeout: 4_000,
703
+ predicate: (t) => {
704
+ return t.name === prompt;
705
+ },
706
+ });
707
+ await waitForFooterMessage({
708
+ discord,
709
+ threadId: thread.id,
710
+ timeout: 4_000,
711
+ afterMessageIncludes: 'ok',
712
+ afterAuthorId: discord.botUserId,
713
+ });
714
+ const sessionId = await getThreadSession(thread.id);
715
+ expect(sessionId).toBeDefined();
716
+ expect(sessionId ? await getSessionAgent(sessionId) : undefined).toBe('plan');
717
+ expect(await getChannelAgent(TEXT_CHANNEL_ID)).toBe('test-agent');
718
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
719
+ "--- from: assistant (TestBot)
720
+ » **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-msg
721
+ *using deterministic-provider/plan-model-v2 ⋅ plan*
722
+ ⬥ ok
723
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
724
+ `);
725
+ }, 20_000);
726
+ test('/plan-agent with prompt in an existing thread changes the session agent', async () => {
727
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
728
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
729
+ content: 'Reply with exactly: inline-existing-first-msg',
730
+ });
731
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
732
+ timeout: 4_000,
733
+ predicate: (t) => {
734
+ return t.name === 'Reply with exactly: inline-existing-first-msg';
735
+ },
736
+ });
737
+ await waitForFooterMessage({
738
+ discord,
739
+ threadId: thread.id,
740
+ timeout: 4_000,
741
+ afterMessageIncludes: 'ok',
742
+ afterAuthorId: discord.botUserId,
743
+ });
744
+ const prompt = 'Reply with exactly: inline-existing-plan-msg';
745
+ const th = discord.thread(thread.id);
746
+ const { id: interactionId } = await th.user(TEST_USER_ID).runSlashCommand({
747
+ name: 'plan-agent',
748
+ options: [{ name: 'prompt', type: 3, value: prompt }],
749
+ });
750
+ await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
751
+ await waitForFooterMessage({
752
+ discord,
753
+ threadId: thread.id,
754
+ timeout: 4_000,
755
+ afterMessageIncludes: 'inline-existing-plan-msg',
756
+ afterAuthorId: discord.botUserId,
757
+ });
758
+ const sessionId = await getThreadSession(thread.id);
759
+ expect(sessionId).toBeDefined();
760
+ expect(sessionId ? await getSessionAgent(sessionId) : undefined).toBe('plan');
761
+ expect(await getChannelAgent(TEXT_CHANNEL_ID)).toBe('test-agent');
762
+ expect(await th.text()).toMatchInlineSnapshot(`
763
+ "--- from: user (agent-model-tester)
764
+ Reply with exactly: inline-existing-first-msg
765
+ --- from: assistant (TestBot)
766
+ *using deterministic-provider/agent-model-v2 ⋅ test-agent*
767
+ ⬥ ok
768
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
769
+ » **agent-model-tester** (plan): Reply with exactly: inline-existing-plan-msg
770
+ ⬥ ok
771
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
772
+ `);
773
+ }, 20_000);
684
774
  test('/plan-agent inside a thread switches the model for that thread', async () => {
685
775
  // 1. Start with test-agent on the channel
686
776
  await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
@@ -1,17 +1,15 @@
1
- // Detects the raw `btw ` Discord message shortcut used to fork a side-question
2
- // thread without invoking the /btw slash command UI.
3
- export function extractBtwPrefix(content) {
4
- if (!content) {
5
- return null;
1
+ // Detects `. btw` suffix at the end of a Discord message, identical pattern
2
+ // to the queue suffix. When present the suffix is stripped and the remaining
3
+ // message is forked to a new btw thread via /btw.
4
+ //
5
+ // Supported forms:
6
+ // - punctuation + btw: ". btw", "! btw", ". btw.", "!btw."
7
+ // - btw as its own final line: "text\nbtw"
8
+ // Non-matches: "btw fix this" (start only), "hello btw" (no punctuation)
9
+ const BTW_SUFFIX_RE = /(?:[.!?,;:])\s*btw\.?\s*$|\n\s*btw\.?\s*$/i;
10
+ export function extractBtwSuffix(content) {
11
+ if (!BTW_SUFFIX_RE.test(content)) {
12
+ return { prompt: content, forceBtw: false };
6
13
  }
7
- // Match "btw" followed by whitespace or punctuation (. , : ; ! ?) then the prompt
8
- const match = content.match(/^\s*btw[.,;:!?\s]\s*([\s\S]+)$/i);
9
- if (!match) {
10
- return null;
11
- }
12
- const prompt = match[1]?.trim();
13
- if (!prompt) {
14
- return null;
15
- }
16
- return { prompt };
14
+ return { prompt: content.replace(BTW_SUFFIX_RE, '').trimEnd(), forceBtw: true };
17
15
  }
@@ -1,63 +1,93 @@
1
1
  import { describe, expect, test } from 'vitest';
2
- import { extractBtwPrefix } from './btw-prefix-detection.js';
3
- describe('extractBtwPrefix', () => {
4
- test('matches lowercase prefix', () => {
5
- expect(extractBtwPrefix('btw fix this')).toMatchInlineSnapshot(`
2
+ import { extractBtwSuffix } from './btw-prefix-detection.js';
3
+ describe('extractBtwSuffix', () => {
4
+ test('matches after period', () => {
5
+ expect(extractBtwSuffix('fix the bug. btw')).toMatchInlineSnapshot(`
6
6
  {
7
- "prompt": "fix this",
7
+ "forceBtw": true,
8
+ "prompt": "fix the bug",
8
9
  }
9
10
  `);
10
11
  });
11
- test('matches uppercase prefix', () => {
12
- expect(extractBtwPrefix('BTW check this')).toMatchInlineSnapshot(`
12
+ test('matches after exclamation', () => {
13
+ expect(extractBtwSuffix('done! btw')).toMatchInlineSnapshot(`
13
14
  {
14
- "prompt": "check this",
15
+ "forceBtw": true,
16
+ "prompt": "done",
15
17
  }
16
18
  `);
17
19
  });
18
- test('keeps multiline content', () => {
19
- expect(extractBtwPrefix(' btw first line\nsecond line ')).toMatchInlineSnapshot(`
20
+ test('matches after comma', () => {
21
+ expect(extractBtwSuffix('sure, btw')).toMatchInlineSnapshot(`
20
22
  {
21
- "prompt": "first line
22
- second line",
23
+ "forceBtw": true,
24
+ "prompt": "sure",
23
25
  }
24
26
  `);
25
27
  });
26
- test('matches dot separator', () => {
27
- expect(extractBtwPrefix('btw. fix this')).toMatchInlineSnapshot(`
28
+ test('matches after newline', () => {
29
+ expect(extractBtwSuffix('fix the bug\nbtw')).toMatchInlineSnapshot(`
28
30
  {
29
- "prompt": "fix this",
31
+ "forceBtw": true,
32
+ "prompt": "fix the bug",
30
33
  }
31
34
  `);
32
35
  });
33
- test('matches comma separator', () => {
34
- expect(extractBtwPrefix('btw, fix this')).toMatchInlineSnapshot(`
36
+ test('matches with trailing dot', () => {
37
+ expect(extractBtwSuffix('fix the bug. btw.')).toMatchInlineSnapshot(`
35
38
  {
36
- "prompt": "fix this",
39
+ "forceBtw": true,
40
+ "prompt": "fix the bug",
37
41
  }
38
42
  `);
39
43
  });
40
- test('matches colon separator', () => {
41
- expect(extractBtwPrefix('btw: fix this')).toMatchInlineSnapshot(`
44
+ test('case insensitive', () => {
45
+ expect(extractBtwSuffix('done. BTW')).toMatchInlineSnapshot(`
42
46
  {
43
- "prompt": "fix this",
47
+ "forceBtw": true,
48
+ "prompt": "done",
44
49
  }
45
50
  `);
46
51
  });
47
- test('matches punctuation without trailing space', () => {
48
- expect(extractBtwPrefix('btw.fix this')).toMatchInlineSnapshot(`
52
+ test('no space between punctuation and btw', () => {
53
+ expect(extractBtwSuffix('done.btw')).toMatchInlineSnapshot(`
49
54
  {
50
- "prompt": "fix this",
55
+ "forceBtw": true,
56
+ "prompt": "done",
51
57
  }
52
58
  `);
53
59
  });
54
- test('does not match without separating whitespace', () => {
55
- expect(extractBtwPrefix('btwfix this')).toMatchInlineSnapshot(`null`);
60
+ test('does not match at start of message', () => {
61
+ expect(extractBtwSuffix('btw fix this')).toMatchInlineSnapshot(`
62
+ {
63
+ "forceBtw": false,
64
+ "prompt": "btw fix this",
65
+ }
66
+ `);
56
67
  });
57
- test('does not match mid-message', () => {
58
- expect(extractBtwPrefix('hello btw fix this')).toMatchInlineSnapshot(`null`);
68
+ test('does not match mid-message without punctuation', () => {
69
+ expect(extractBtwSuffix('hello btw')).toMatchInlineSnapshot(`
70
+ {
71
+ "forceBtw": false,
72
+ "prompt": "hello btw",
73
+ }
74
+ `);
59
75
  });
60
- test('does not match empty payload', () => {
61
- expect(extractBtwPrefix('btw ')).toMatchInlineSnapshot(`null`);
76
+ test('does not match empty content', () => {
77
+ expect(extractBtwSuffix('')).toMatchInlineSnapshot(`
78
+ {
79
+ "forceBtw": false,
80
+ "prompt": "",
81
+ }
82
+ `);
83
+ });
84
+ test('multiline message with btw at end', () => {
85
+ expect(extractBtwSuffix('first line\nsecond line. btw')).toMatchInlineSnapshot(`
86
+ {
87
+ "forceBtw": true,
88
+ "prompt": "first line
89
+ second line",
90
+ }
91
+ `);
62
92
  });
63
93
  });
@@ -111,7 +111,11 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
111
111
  const discordMaxLength = 2000;
112
112
  if (prompt.length <= discordMaxLength) {
113
113
  return (await rest.post(Routes.channelMessages(channelId), {
114
- body: { content: prompt, embeds },
114
+ body: {
115
+ content: prompt,
116
+ embeds,
117
+ allowed_mentions: { parse: store.getState().allowedMentions },
118
+ },
115
119
  }));
116
120
  }
117
121
  const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
@@ -158,6 +162,7 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
158
162
  content: summaryContent,
159
163
  attachments: [{ id: 0, filename: 'prompt.md' }],
160
164
  embeds,
165
+ allowed_mentions: { parse: store.getState().allowedMentions },
161
166
  }));
162
167
  const buffer = fs.readFileSync(tmpFile);
163
168
  formData.append('files[0]', new Blob([buffer], { type: 'text/markdown' }), 'prompt.md');
@@ -509,6 +514,7 @@ export async function ensureCommandAvailable({ name, envPathKey, installUnix, in
509
514
  }
510
515
  // Run opencode upgrade in the background so the user always has the latest version.
511
516
  // Spawn caffeinate on macOS to prevent system sleep while bot is running.
517
+ // Uses -s to also prevent sleep on lid close (AC power only, not battery).
512
518
  // Uses -w to watch the parent PID so caffeinate self-terminates if kimaki
513
519
  // exits for any reason (SIGTERM, crash, process.exit, supervisor stop).
514
520
  export function startCaffeinate() {
@@ -516,7 +522,7 @@ export function startCaffeinate() {
516
522
  return;
517
523
  }
518
524
  try {
519
- const proc = spawn('caffeinate', ['-i', '-w', String(process.pid)], {
525
+ const proc = spawn('caffeinate', ['-s', '-w', String(process.pid)], {
520
526
  stdio: 'ignore',
521
527
  detached: false,
522
528
  });
@@ -938,16 +944,34 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
938
944
  startCaffeinate();
939
945
  const forceRestartOnboarding = Boolean(restartOnboarding);
940
946
  const forceGateway = Boolean(gateway);
941
- // Step 0: Ensure bun is installed. OpenCode is downloaded on first run
942
- // to ~/.kimaki/bin/ (pinned version), so no install check is needed.
943
- await ensureCommandAvailable({
944
- name: 'bun',
945
- envPathKey: 'BUN_PATH',
946
- installUnix: 'curl -fsSL https://bun.sh/install | bash',
947
- installWindows: 'irm bun.sh/install.ps1 | iex',
948
- possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
949
- possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
950
- });
947
+ // Step 0: Ensure opencode and bun are installed
948
+ await Promise.all([
949
+ ensureCommandAvailable({
950
+ name: 'opencode',
951
+ envPathKey: 'OPENCODE_PATH',
952
+ installUnix: 'curl -fsSL https://opencode.ai/install | bash',
953
+ installWindows: 'irm https://opencode.ai/install.ps1 | iex',
954
+ possiblePathsUnix: [
955
+ '~/.local/bin/opencode',
956
+ '~/.opencode/bin/opencode',
957
+ '/usr/local/bin/opencode',
958
+ '/opt/opencode/bin/opencode',
959
+ ],
960
+ possiblePathsWindows: [
961
+ '~\\.local\\bin\\opencode.exe',
962
+ '~\\AppData\\Local\\opencode\\opencode.exe',
963
+ '~\\.opencode\\bin\\opencode.exe',
964
+ ],
965
+ }),
966
+ ensureCommandAvailable({
967
+ name: 'bun',
968
+ envPathKey: 'BUN_PATH',
969
+ installUnix: 'curl -fsSL https://bun.sh/install | bash',
970
+ installWindows: 'irm bun.sh/install.ps1 | iex',
971
+ possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
972
+ possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
973
+ }),
974
+ ]);
951
975
  void backgroundUpgradeKimaki();
952
976
  // Start in-process Hrana server before database init. Required for the bot
953
977
  // process because it serves as both the DB server and the single-instance
package/dist/cli.js CHANGED
@@ -40,9 +40,14 @@ cli
40
40
  .option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
41
41
  .option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
42
42
  .option('--allow-all-users', 'Allow all Discord users to start sessions without needing Kimaki role or admin permissions (no-kimaki role still blocks)')
43
+ .option('--disable-sync', 'Disable background sync of external OpenCode sessions into Discord')
43
44
  .option('--no-sentry', 'Disable Sentry error reporting')
44
45
  .option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
45
46
  .option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
47
+ .option('--allow-mention <type>', z
48
+ .array(z.enum(['users', 'roles', 'everyone']))
49
+ .optional()
50
+ .describe('Which mention types the bot can trigger (users, roles, everyone). Repeatable. Default: users only.'))
46
51
  .option('--enable-skill <name>', z
47
52
  .array(z.string())
48
53
  .optional()
@@ -136,8 +141,10 @@ cli
136
141
  ...(options.mentionMode && { defaultMentionMode: true }),
137
142
  ...(options.noCritique && { critiqueEnabled: false }),
138
143
  ...(options.allowAllUsers && { allowAllUsers: true }),
144
+ ...(options.disableSync && { syncEnabled: false }),
139
145
  ...(enabledSkills.length > 0 && { enabledSkills }),
140
146
  ...(disabledSkills.length > 0 && { disabledSkills }),
147
+ ...(options.allowMention && { allowedMentions: options.allowMention }),
141
148
  });
142
149
  if (enabledSkills.length > 0) {
143
150
  cliLogger.log(`Skill whitelist enabled: only [${enabledSkills.join(', ')}] will be injected`);
@@ -157,6 +164,9 @@ cli
157
164
  if (options.noCritique) {
158
165
  cliLogger.log('Critique disabled: diffs will not be auto-uploaded to critique.work');
159
166
  }
167
+ if (options.disableSync) {
168
+ cliLogger.log('Background sync disabled: external OpenCode sessions will not appear in Discord');
169
+ }
160
170
  if (options.noSentry) {
161
171
  process.env.KIMAKI_SENTRY_DISABLED = '1';
162
172
  cliLogger.log('Sentry error reporting disabled (--no-sentry)');
@@ -27,7 +27,7 @@ export async function handleAbortCommand({ command, }) {
27
27
  });
28
28
  return;
29
29
  }
30
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
30
+ await command.deferReply();
31
31
  const resolved = await resolveWorkingDirectory({
32
32
  channel: channel,
33
33
  });
@@ -1,7 +1,7 @@
1
1
  // /agent command - Set the preferred agent for this channel or session.
2
2
  // Also provides quick agent commands like /plan-agent, /build-agent that switch instantly.
3
3
  // When a prompt is provided to a quick agent command (e.g. /plan-agent "fix the bug"),
4
- // the prompt is sent as a one-shot with that agent without switching the persistent preference.
4
+ // the prompt is sent with that agent and the session keeps that agent afterwards.
5
5
  import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
6
6
  import crypto from 'node:crypto';
7
7
  import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, } from '../database.js';
@@ -162,7 +162,7 @@ export async function setAgentForContext({ context, agentName, }) {
162
162
  }
163
163
  }
164
164
  export async function handleAgentCommand({ interaction, appId, }) {
165
- await interaction.deferReply({ flags: MessageFlags.Ephemeral });
165
+ await interaction.deferReply();
166
166
  const context = await resolveAgentCommandContext({ interaction, appId });
167
167
  if (!context) {
168
168
  return;
@@ -290,13 +290,12 @@ export async function handleAgentSelectMenu(interaction) {
290
290
  export async function handleQuickAgentCommand({ command, appId, }) {
291
291
  const fallbackAgentName = command.commandName.replace(/-agent$/, '');
292
292
  const prompt = command.options.getString('prompt') || undefined;
293
- // One-shot prompt mode: send the prompt with a temporary agent override
294
- // without changing the persistent agent preference.
293
+ // Prompt mode: send the prompt with this agent immediately.
295
294
  if (prompt) {
296
295
  return handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt });
297
296
  }
298
297
  // No prompt: switch the persistent agent preference (original behavior).
299
- await command.deferReply({ flags: MessageFlags.Ephemeral });
298
+ await command.deferReply();
300
299
  const context = await resolveAgentCommandContext({
301
300
  interaction: command,
302
301
  appId,
@@ -363,10 +362,10 @@ export async function handleQuickAgentCommand({ command, appId, }) {
363
362
  }
364
363
  }
365
364
  /**
366
- * Handle one-shot prompt mode: send a prompt with a temporary agent override.
367
- * In a thread: enqueue the prompt with the agent override on the existing session.
368
- * In a channel: create a new thread and enqueue the prompt with the agent override.
369
- * Neither case changes the persistent agent preference.
365
+ * Handle prompt mode: send a prompt with the requested agent.
366
+ * In a thread: enqueue the prompt on the existing session and switch that session.
367
+ * In a channel: create a new thread whose session starts with the requested agent.
368
+ * Channel-level preferences are not changed.
370
369
  */
371
370
  async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt, }) {
372
371
  const channel = command.channel;
@@ -386,7 +385,7 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
386
385
  ].includes(channel.type);
387
386
  const displayText = `${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`;
388
387
  if (isThread) {
389
- // In a thread: enqueue the prompt on the existing session with agent override.
388
+ // In a thread: enqueue the prompt and switch the existing session to this agent.
390
389
  const thread = channel;
391
390
  const resolved = await resolveWorkingDirectory({ channel: thread });
392
391
  if (!resolved) {
@@ -419,9 +418,8 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
419
418
  });
420
419
  }
421
420
  else if (channel.type === ChannelType.GuildText) {
422
- // In a channel: create a new thread and enqueue with the agent override.
423
- const textChannel = channel;
424
- const metadata = await getKimakiMetadata(textChannel);
421
+ // In a channel: create a new thread and enqueue with the requested agent.
422
+ const metadata = await getKimakiMetadata(channel);
425
423
  const projectDirectory = metadata.projectDirectory;
426
424
  if (!projectDirectory) {
427
425
  await command.reply({
@@ -431,14 +429,14 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
431
429
  return;
432
430
  }
433
431
  await command.deferReply();
434
- const starterMessage = await textChannel.send({
432
+ const starterMessage = await channel.send({
435
433
  content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
436
434
  flags: SILENT_MESSAGE_FLAGS,
437
435
  });
438
436
  const thread = await starterMessage.startThread({
439
437
  name: prompt.slice(0, 80),
440
438
  autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
441
- reason: `One-shot ${resolvedAgentName} agent prompt`,
439
+ reason: `${resolvedAgentName} agent prompt`,
442
440
  });
443
441
  await thread.members.add(command.user.id);
444
442
  await command.editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`);
@@ -447,7 +445,7 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
447
445
  thread,
448
446
  projectDirectory,
449
447
  sdkDirectory: projectDirectory,
450
- channelId: textChannel.id,
448
+ channelId: channel.id,
451
449
  appId,
452
450
  });
453
451
  await runtime.enqueueIncoming({
@@ -38,6 +38,5 @@ export async function handleToggleMentionModeCommand({ command, }) {
38
38
  content: nextEnabled
39
39
  ? `Mention mode **enabled** for this channel.\nThe bot will only start new sessions when @mentioned.\nMessages in existing threads are not affected.`
40
40
  : `Mention mode **disabled** for this channel.\nThe bot will respond to all messages in **#${channel.name}**.`,
41
- flags: MessageFlags.Ephemeral,
42
41
  });
43
42
  }
@@ -40,7 +40,7 @@ function formatSourceLabel(info) {
40
40
  }
41
41
  }
42
42
  export async function handleModelVariantCommand({ interaction, appId, }) {
43
- await interaction.deferReply({ flags: MessageFlags.Ephemeral });
43
+ await interaction.deferReply();
44
44
  const channel = interaction.channel;
45
45
  if (!channel) {
46
46
  await interaction.editReply({
@@ -292,7 +292,7 @@ export async function handleVariantScopeSelectMenu(interaction) {
292
292
  async function applyVariant({ interaction, context, variant, scope, contextHash, }) {
293
293
  const modelId = context.modelId;
294
294
  const variantSuffix = variant ? ` (${variant})` : '';
295
- const agentTip = '\n_Tip: create [agent .md files](https://github.com/remorses/kimaki/blob/main/docs/model-switching.md) in .opencode/agent/ for one-command model switching_';
295
+ const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/docs/model-switching) in .opencode/agent/ for one-command model switching_';
296
296
  try {
297
297
  if (scope === 'session') {
298
298
  if (!context.sessionId) {