kimaki 0.12.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 (42) hide show
  1. package/dist/btw-prefix-detection.js +13 -15
  2. package/dist/btw-prefix-detection.test.js +60 -30
  3. package/dist/cli-runner.js +8 -2
  4. package/dist/cli.js +5 -0
  5. package/dist/commands/abort.js +1 -1
  6. package/dist/commands/model-variant.js +1 -1
  7. package/dist/commands/model.js +1 -1
  8. package/dist/commands/restart-opencode-server.js +1 -1
  9. package/dist/commands/undo-redo.js +2 -2
  10. package/dist/commands/upgrade.js +1 -2
  11. package/dist/discord-bot.js +55 -10
  12. package/dist/message-preprocessing.js +1 -1
  13. package/dist/opencode-interrupt-plugin.js +14 -2
  14. package/dist/opencode-interrupt-plugin.test.js +22 -3
  15. package/dist/queue-advanced-model-switch.e2e.test.js +1 -1
  16. package/dist/session-handler/agent-utils.js +9 -9
  17. package/dist/session-handler/thread-runtime-state.js +29 -0
  18. package/dist/session-handler/thread-session-runtime.js +40 -6
  19. package/dist/store.js +1 -0
  20. package/dist/thread-message-queue.e2e.test.js +198 -1
  21. package/package.json +6 -6
  22. package/skills/holocron/SKILL.md +432 -0
  23. package/src/btw-prefix-detection.test.ts +61 -30
  24. package/src/btw-prefix-detection.ts +15 -19
  25. package/src/cli-runner.ts +8 -2
  26. package/src/cli.ts +11 -0
  27. package/src/commands/abort.ts +1 -1
  28. package/src/commands/model-variant.ts +1 -1
  29. package/src/commands/model.ts +1 -1
  30. package/src/commands/restart-opencode-server.ts +1 -1
  31. package/src/commands/undo-redo.ts +2 -2
  32. package/src/commands/upgrade.ts +1 -2
  33. package/src/discord-bot.ts +65 -9
  34. package/src/message-preprocessing.ts +1 -1
  35. package/src/opencode-interrupt-plugin.test.ts +27 -3
  36. package/src/opencode-interrupt-plugin.ts +15 -3
  37. package/src/queue-advanced-model-switch.e2e.test.ts +1 -1
  38. package/src/session-handler/agent-utils.ts +11 -11
  39. package/src/session-handler/thread-runtime-state.ts +35 -0
  40. package/src/session-handler/thread-session-runtime.ts +56 -6
  41. package/src/store.ts +8 -0
  42. package/src/thread-message-queue.e2e.test.ts +227 -1
@@ -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
  });
package/dist/cli.js CHANGED
@@ -44,6 +44,10 @@ cli
44
44
  .option('--no-sentry', 'Disable Sentry error reporting')
45
45
  .option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
46
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.'))
47
51
  .option('--enable-skill <name>', z
48
52
  .array(z.string())
49
53
  .optional()
@@ -140,6 +144,7 @@ cli
140
144
  ...(options.disableSync && { syncEnabled: false }),
141
145
  ...(enabledSkills.length > 0 && { enabledSkills }),
142
146
  ...(disabledSkills.length > 0 && { disabledSkills }),
147
+ ...(options.allowMention && { allowedMentions: options.allowMention }),
143
148
  });
144
149
  if (enabledSkills.length > 0) {
145
150
  cliLogger.log(`Skill whitelist enabled: only [${enabledSkills.join(', ')}] will be injected`);
@@ -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
  });
@@ -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://kimaki.dev/model-switching) 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) {
@@ -728,7 +728,7 @@ export async function handleModelScopeSelectMenu(interaction) {
728
728
  const modelDisplay = modelId.split('/')[1] || modelId;
729
729
  const variant = context.selectedVariant ?? null;
730
730
  const variantSuffix = variant ? ` (${variant})` : '';
731
- const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/model-switching) in .opencode/agent/ for one-command model switching_';
731
+ const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/docs/model-switching) in .opencode/agent/ for one-command model switching_';
732
732
  try {
733
733
  if (selectedScope === 'session') {
734
734
  if (!context.sessionId) {
@@ -46,7 +46,7 @@ export async function handleRestartOpencodeServerCommand({ command, appId, }) {
46
46
  }
47
47
  const { projectDirectory } = resolved;
48
48
  // Defer reply since restart may take a moment
49
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
49
+ await command.deferReply();
50
50
  // Dispose all runtimes for this directory/channel scope.
51
51
  // disposeRuntimesForDirectory aborts active runs, kills listeners, and
52
52
  // removes runtimes from the registry. Scoped by channelId so runtimes
@@ -58,7 +58,7 @@ export async function handleUndoCommand({ command, }) {
58
58
  });
59
59
  return;
60
60
  }
61
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
61
+ await command.deferReply();
62
62
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
63
63
  if (getClient instanceof Error) {
64
64
  await command.editReply(`Failed to undo: ${getClient.message}`);
@@ -209,7 +209,7 @@ export async function handleRedoCommand({ command, }) {
209
209
  });
210
210
  return;
211
211
  }
212
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
212
+ await command.deferReply();
213
213
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
214
214
  if (getClient instanceof Error) {
215
215
  await command.editReply(`Failed to redo: ${getClient.message}`);
@@ -1,13 +1,12 @@
1
1
  // /upgrade-and-restart command - Upgrade kimaki to the latest version and restart the bot.
2
2
  // Checks npm for a newer version, installs it globally, then spawns a new kimaki process.
3
3
  // The new process kills the old one on startup (kimaki's single-instance lock).
4
- import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
5
4
  import { createLogger, LogPrefix } from '../logger.js';
6
5
  import { getCurrentVersion, upgrade } from '../upgrade.js';
7
6
  import { spawn } from 'node:child_process';
8
7
  const logger = createLogger(LogPrefix.CLI);
9
8
  export async function handleUpgradeAndRestartCommand({ command, }) {
10
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
9
+ await command.deferReply();
11
10
  logger.log('[UPGRADE] /upgrade-and-restart triggered');
12
11
  try {
13
12
  const currentVersion = getCurrentVersion();
@@ -10,10 +10,10 @@ import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage
10
10
  import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
11
11
  import YAML from 'yaml';
12
12
  import { getTextAttachments, resolveMentions, } from './message-formatting.js';
13
- import { extractBtwPrefix } from './btw-prefix-detection.js';
13
+ import { extractBtwSuffix } from './btw-prefix-detection.js';
14
14
  import { isVoiceAttachment } from './voice-attachment.js';
15
15
  import { forkSessionToBtwThread } from './commands/btw.js';
16
- import { getChannelReferencePermissionRules, preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
16
+ import { extractQueueSuffix, getChannelReferencePermissionRules, preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
17
17
  import { cancelPendingActionButtons } from './commands/action-buttons.js';
18
18
  import { cancelPendingQuestion, hasPendingQuestionForThread } from './commands/ask-question.js';
19
19
  import { cancelPendingFileUpload } from './commands/file-upload.js';
@@ -30,6 +30,7 @@ import { markDiscordGatewayReady, stopHranaServer } from './hrana-server.js';
30
30
  import { notifyError } from './sentry.js';
31
31
  import { flushDebouncedProcessCallbacks } from './debounced-process-flush.js';
32
32
  import { startRuntimeIdleSweeper } from './runtime-idle-sweeper.js';
33
+ import { store } from './store.js';
33
34
  import { startExternalOpencodeSessionSync, stopExternalOpencodeSessionSync, } from './external-opencode-sync.js';
34
35
  export { initDatabase, closeDatabase, getChannelDirectory, } from './database.js';
35
36
  export { initializeOpencodeForDirectory } from './opencode.js';
@@ -144,6 +145,7 @@ export async function createDiscordClient() {
144
145
  // Read REST API URL lazily so gateway mode can set store.discordBaseUrl
145
146
  // after module import but before client creation.
146
147
  const restApiUrl = getDiscordRestApiUrl();
148
+ const { allowedMentions } = store.getState();
147
149
  return new Client({
148
150
  intents: [
149
151
  GatewayIntentBits.Guilds,
@@ -158,6 +160,7 @@ export async function createDiscordClient() {
158
160
  Partials.ThreadMember,
159
161
  ],
160
162
  rest: { api: restApiUrl },
163
+ allowedMentions: { parse: allowedMentions },
161
164
  });
162
165
  }
163
166
  export async function startDiscordBot({ token, appId, discordClient, useWorktrees, }) {
@@ -455,18 +458,17 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
455
458
  return;
456
459
  }
457
460
  }
458
- // Raw `btw ` mirrors /btw for fast side-question forks from Discord.
459
- // Keep this at ingress instead of preprocess because it must create a
460
- // new thread/runtime, not just transform the current prompt.
461
- // Voice-transcribed `btw` still goes through normal preprocessing.
462
- const btwShortcut = projectDirectory && worktreeInfo?.status !== 'pending'
463
- ? extractBtwPrefix(message.content || '')
461
+ // `. btw` suffix mirrors /btw for fast side-question forks.
462
+ // Works like queue: just the word "btw" at the end after punctuation
463
+ // or newline. The whole message (minus the suffix) becomes the fork prompt.
464
+ const btwResult = projectDirectory && worktreeInfo?.status !== 'pending'
465
+ ? extractBtwSuffix(message.content || '')
464
466
  : null;
465
- if (btwShortcut && projectDirectory) {
467
+ if (btwResult?.forceBtw && projectDirectory) {
466
468
  const result = await forkSessionToBtwThread({
467
469
  sourceThread: thread,
468
470
  projectDirectory,
469
- prompt: btwShortcut.prompt,
471
+ prompt: btwResult.prompt,
470
472
  userId: message.author.id,
471
473
  username: message.member?.displayName || message.author.displayName,
472
474
  appId: currentAppId,
@@ -743,6 +745,49 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
743
745
  }
744
746
  }
745
747
  });
748
+ // Handle user message edits to update queued messages.
749
+ // When a user edits a message that is still waiting in kimaki's local queue,
750
+ // the queue item is updated with the new content. If the edit removes the
751
+ // queue suffix, the item is removed from the queue.
752
+ discordClient.on(Events.MessageUpdate, async (_oldMessage, newMessage) => {
753
+ try {
754
+ // Fetch full message if partial (cache miss). Needed for mentions
755
+ // and content to be fully resolved.
756
+ const message = newMessage.partial
757
+ ? await newMessage.fetch().catch(() => null)
758
+ : newMessage;
759
+ if (!message)
760
+ return;
761
+ if (message.author.bot)
762
+ return;
763
+ if (!message.content)
764
+ return;
765
+ const channel = message.channel;
766
+ const isThread = [
767
+ ChannelType.PublicThread,
768
+ ChannelType.PrivateThread,
769
+ ChannelType.AnnouncementThread,
770
+ ].includes(channel.type);
771
+ if (!isThread)
772
+ return;
773
+ const runtime = getRuntime(channel.id);
774
+ if (!runtime)
775
+ return;
776
+ // Use resolveMentions to match initial preprocessing and preserve
777
+ // newlines (stripMentions collapses them, breaking final-line queue
778
+ // suffix detection).
779
+ const { prompt, forceQueue } = extractQueueSuffix(resolveMentions(message));
780
+ // If the edit removed the queue suffix, remove the item from the queue.
781
+ // If the suffix is still present, update the prompt.
782
+ const result = runtime.updateQueuedMessage(message.id, forceQueue ? prompt : '');
783
+ if (result.found) {
784
+ discordLogger.log(`[MESSAGE_EDIT] ${result.removed ? 'Removed' : 'Updated'} queued message ${message.id} in thread ${channel.id}`);
785
+ }
786
+ }
787
+ catch (error) {
788
+ discordLogger.error('Error handling message update:', error instanceof Error ? error.stack : String(error));
789
+ }
790
+ });
746
791
  // Handle bot-initiated threads created by `kimaki send` (without --notify-only)
747
792
  // Uses JSON embed marker to pass options (start, worktree name)
748
793
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
@@ -45,7 +45,7 @@ async function fetchAvailableAgents(getClient, directory) {
45
45
  // kimaki's local queue (same as /queue command).
46
46
  const QUEUE_SUFFIX_RE = /(?:[.!?,;:]|^)\s*queue\.?\s*$|\n\s*queue\.?\s*$/i;
47
47
  const REPLIED_MESSAGE_TEXT_LIMIT = 1_000;
48
- function extractQueueSuffix(prompt) {
48
+ export function extractQueueSuffix(prompt) {
49
49
  if (!QUEUE_SUFFIX_RE.test(prompt)) {
50
50
  return { prompt, forceQueue: false };
51
51
  }
@@ -56,6 +56,7 @@ function toPromptParts(parts) {
56
56
  }, []);
57
57
  }
58
58
  const DEFAULT_INTERRUPT_STEP_TIMEOUT_MS = 3_000;
59
+ const POST_ABORT_IDLE_GRACE_MS = 250;
59
60
  function getInterruptStepTimeoutMsFromEnv() {
60
61
  const raw = process.env['KIMAKI_INTERRUPT_STEP_TIMEOUT_MS'];
61
62
  if (!raw) {
@@ -240,7 +241,7 @@ const interruptOpencodeSessionOnUserMessage = async (ctx) => {
240
241
  },
241
242
  timeoutMs: 5_000,
242
243
  });
243
- const idleWait = state.waitForEvent({
244
+ const initialIdleWait = state.waitForEvent({
244
245
  match: (event) => {
245
246
  return event.type === 'session.idle' && event.properties.sessionID === sessionID;
246
247
  },
@@ -250,7 +251,18 @@ const interruptOpencodeSessionOnUserMessage = async (ctx) => {
250
251
  path: { id: sessionID },
251
252
  });
252
253
  await abortedAssistantWait;
253
- await idleWait;
254
+ await initialIdleWait;
255
+ // OpenCode can emit `session.idle` before the aborted assistant update,
256
+ // then emit another idle after the cancelled run finishes cleanup. Replaying
257
+ // on the first idle can enqueue the replay behind the still-settling run.
258
+ // Some paths only emit the first idle, so wait briefly for a post-abort
259
+ // idle and then continue rather than dropping the user's interrupt.
260
+ await state.waitForEvent({
261
+ match: (event) => {
262
+ return event.type === 'session.idle' && event.properties.sessionID === sessionID;
263
+ },
264
+ timeoutMs: POST_ABORT_IDLE_GRACE_MS,
265
+ });
254
266
  const currentPending = state.getPending(messageID);
255
267
  if (!currentPending || currentPending.started) {
256
268
  state.clearPending(messageID);
@@ -230,6 +230,10 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
230
230
  parentID: REAL_RATE_LIMIT_CASE.previousMessageID,
231
231
  }),
232
232
  });
233
+ await delay({ ms: 1 });
234
+ await eventHook({
235
+ event: createSessionIdleEvent({ sessionID: REAL_RATE_LIMIT_CASE.sessionID }),
236
+ });
233
237
  await delay({ ms: 20 });
234
238
  expect(abortCalls).toEqual([{ path: { id: REAL_RATE_LIMIT_CASE.sessionID } }]);
235
239
  expect(promptAsyncCalls).toEqual([
@@ -326,7 +330,10 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
326
330
  await chatHook({ sessionID, messageID: userMsgID }, createChatOutput({ sessionID, messageID: userMsgID }));
327
331
  // 3. Timeout fires (20ms), plugin runs handleUnsentTimeout
328
332
  await delay({ ms: 30 });
329
- // 4. Simulate abort completing (error + idle from opencode)
333
+ // 4. Simulate abort completing. OpenCode can emit an idle event before the
334
+ // aborted assistant update, then emit another idle after cleanup settles.
335
+ // Replaying before that post-abort idle can leave the replayed message
336
+ // queued behind the cancelled run.
330
337
  await eventHook({ event: createSessionErrorEvent({ sessionID }) });
331
338
  await eventHook({ event: createSessionIdleEvent({ sessionID }) });
332
339
  await eventHook({
@@ -339,6 +346,10 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
339
346
  await delay({ ms: 20 });
340
347
  // 5. Verify plugin aborted the session
341
348
  expect(abortCalls).toEqual([{ path: { id: sessionID } }]);
349
+ expect(promptAsyncCalls).toEqual([]);
350
+ await delay({ ms: 1 });
351
+ await eventHook({ event: createSessionIdleEvent({ sessionID }) });
352
+ await delay({ ms: 20 });
342
353
  // 6. Recovery should replay the queued message itself, not an empty
343
354
  // resume prompt. This preserves the original messageID + parts after
344
355
  // session.abort() clears OpenCode's internal prompt queue.
@@ -383,8 +394,8 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
383
394
  messageID: REAL_SLEEP_INTERRUPT_CASE.interruptingMessageID,
384
395
  }));
385
396
  await delay({ ms: 30 });
386
- await eventHook({ event: REAL_SLEEP_INTERRUPT_CASE.idleEvent });
387
397
  await eventHook({ event: REAL_SLEEP_INTERRUPT_CASE.abortErrorEvent });
398
+ await eventHook({ event: REAL_SLEEP_INTERRUPT_CASE.idleEvent });
388
399
  await eventHook({
389
400
  event: createAssistantAbortedEvent({
390
401
  sessionID: REAL_SLEEP_INTERRUPT_CASE.sessionID,
@@ -394,6 +405,10 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
394
405
  });
395
406
  await delay({ ms: 20 });
396
407
  expect(abortCalls).toEqual([{ path: { id: REAL_SLEEP_INTERRUPT_CASE.sessionID } }]);
408
+ expect(promptAsyncCalls).toEqual([]);
409
+ await delay({ ms: 1 });
410
+ await eventHook({ event: REAL_SLEEP_INTERRUPT_CASE.idleEvent });
411
+ await delay({ ms: 20 });
397
412
  expect(promptAsyncCalls).toEqual([
398
413
  {
399
414
  path: { id: REAL_SLEEP_INTERRUPT_CASE.sessionID },
@@ -440,8 +455,8 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
440
455
  });
441
456
  await delay({ ms: 10 });
442
457
  expect(abortCalls).toEqual([{ path: { id: sessionID } }]);
443
- await eventHook({ event: createSessionIdleEvent({ sessionID }) });
444
458
  await eventHook({ event: createSessionErrorEvent({ sessionID }) });
459
+ await eventHook({ event: createSessionIdleEvent({ sessionID }) });
445
460
  await eventHook({
446
461
  event: createAssistantAbortedEvent({
447
462
  sessionID,
@@ -450,6 +465,10 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
450
465
  }),
451
466
  });
452
467
  await delay({ ms: 20 });
468
+ expect(promptAsyncCalls).toEqual([]);
469
+ await delay({ ms: 1 });
470
+ await eventHook({ event: createSessionIdleEvent({ sessionID }) });
471
+ await delay({ ms: 20 });
453
472
  expect(promptAsyncCalls).toEqual([
454
473
  {
455
474
  path: { id: sessionID },
@@ -257,7 +257,7 @@ describe('queue advanced: /model with interrupt recovery', () => {
257
257
  **Deterministic Provider** / **deterministic-v3**
258
258
  \`deterministic-provider/deterministic-v3\`
259
259
  _Restarting current request with new model..._
260
- _Tip: create [agent .md files](https://kimaki.dev/model-switching) in .opencode/agent/ for one-command model switching_
260
+ _Tip: create [agent .md files](https://kimaki.dev/docs/model-switching) in .opencode/agent/ for one-command model switching_
261
261
  --- from: user (queue-model-switch-tester)
262
262
  PLUGIN_TIMEOUT_SLEEP_MARKER
263
263
  --- from: assistant (TestBot)
@@ -1,9 +1,14 @@
1
1
  // Agent preference resolution utility.
2
2
  // Validates agent preferences against the OpenCode API.
3
+ // When a requested agent is not found, we fall back to the default agent
4
+ // instead of throwing. This handles stale agent preferences from CLI send
5
+ // commands or database references to agents that were removed from config.
3
6
  import * as errore from 'errore';
4
7
  import { getSessionAgent, getSessionModel, getChannelAgent, } from '../database.js';
8
+ import { createLogger } from '../logger.js';
5
9
  import {} from '../opencode.js';
6
10
  import {} from '../system-message.js';
11
+ const agentLogger = createLogger('agent');
7
12
  export async function resolveValidatedAgentPreference({ agent, sessionId, channelId, getClient, directory, }) {
8
13
  const agentPreference = await (async () => {
9
14
  if (agent) {
@@ -55,13 +60,8 @@ export async function resolveValidatedAgentPreference({ agent, sessionId, channe
55
60
  if (hasAgent) {
56
61
  return { agentPreference, agents };
57
62
  }
58
- const availableAgentNames = availableAgents
59
- .map((availableAgent) => {
60
- return availableAgent.name;
61
- })
62
- .slice(0, 20);
63
- const availableAgentsMessage = availableAgentNames.length > 0
64
- ? `Available agents: ${availableAgentNames.join(', ')}`
65
- : 'No agents are available in this project.';
66
- throw new Error(`Agent "${agentPreference}" not found. ${availableAgentsMessage} Use /agent to choose a valid one.`);
63
+ // Fall back to default agent instead of erroring. This handles stale
64
+ // preferences from CLI send commands or removed agents in config.
65
+ agentLogger.warn(`Agent "${agentPreference}" not found, falling back to default agent`);
66
+ return { agentPreference: undefined, agents };
67
67
  }
@@ -131,6 +131,35 @@ export function removeQueueItemAtPosition(threadId, position) {
131
131
  });
132
132
  return removedItem;
133
133
  }
134
+ /**
135
+ * Find a queued item by its Discord source message ID and apply an updater.
136
+ * If the updater returns null, the item is removed from the queue.
137
+ * Returns the original (pre-update) item, or undefined if not found.
138
+ */
139
+ export function updateQueueItemBySourceMessageId(threadId, sourceMessageId, updater) {
140
+ let originalItem;
141
+ store.setState((s) => {
142
+ const t = s.threads.get(threadId);
143
+ if (!t)
144
+ return s;
145
+ const index = t.queueItems.findIndex((item) => {
146
+ return item.sourceMessageId === sourceMessageId;
147
+ });
148
+ if (index === -1)
149
+ return s;
150
+ originalItem = t.queueItems[index];
151
+ const updated = updater(originalItem);
152
+ const newThreads = new Map(s.threads);
153
+ newThreads.set(threadId, {
154
+ ...t,
155
+ queueItems: updated === null
156
+ ? t.queueItems.filter((_, i) => i !== index)
157
+ : t.queueItems.map((item, i) => (i === index ? updated : item)),
158
+ });
159
+ return { threads: newThreads };
160
+ });
161
+ return originalItem;
162
+ }
134
163
  // ── Queries ──────────────────────────────────────────────────────
135
164
  export function getThreadState(threadId) {
136
165
  return store.getState().threads.get(threadId);