kimaki 0.20.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/agent-model.e2e.test.js +6 -4
  2. package/dist/cli-commands/project.js +148 -24
  3. package/dist/cli-commands/send.js +48 -3
  4. package/dist/cli-runner.js +86 -7
  5. package/dist/cli-runner.test.js +28 -1
  6. package/dist/commands/btw.js +7 -3
  7. package/dist/commands/new-worktree.js +14 -2
  8. package/dist/commands/session.js +66 -13
  9. package/dist/commands/tasks.js +104 -19
  10. package/dist/commands/user-command.js +3 -2
  11. package/dist/database.js +14 -0
  12. package/dist/db.js +1 -0
  13. package/dist/discord-bot.js +65 -9
  14. package/dist/interaction-handler.js +6 -15
  15. package/dist/message-preprocessing.js +9 -2
  16. package/dist/queue-advanced-e2e-setup.js +22 -0
  17. package/dist/queue-delete-message.e2e.test.js +75 -0
  18. package/dist/schema.js +3 -0
  19. package/dist/session-handler/thread-runtime-state.js +9 -0
  20. package/dist/session-handler/thread-session-runtime.js +49 -1
  21. package/dist/system-message.js +50 -63
  22. package/dist/system-message.test.js +65 -61
  23. package/dist/task-runner.js +18 -3
  24. package/dist/task-schedule.js +4 -0
  25. package/dist/thread-message-queue.e2e.test.js +4 -4
  26. package/dist/voice-message.e2e.test.js +1 -1
  27. package/dist/worktree-lifecycle.e2e.test.js +70 -1
  28. package/dist/worktrees.js +63 -0
  29. package/package.json +11 -4
  30. package/skills/egaki/SKILL.md +26 -80
  31. package/skills/goke/SKILL.md +27 -20
  32. package/skills/holocron/SKILL.md +72 -8
  33. package/skills/new-skill/SKILL.md +87 -0
  34. package/skills/playwriter/SKILL.md +4 -4
  35. package/skills/spiceflow/SKILL.md +2 -0
  36. package/src/agent-model.e2e.test.ts +10 -4
  37. package/src/cli-commands/project.ts +183 -26
  38. package/src/cli-commands/send.ts +64 -5
  39. package/src/cli-runner.test.ts +39 -1
  40. package/src/cli-runner.ts +115 -6
  41. package/src/commands/btw.ts +9 -2
  42. package/src/commands/new-worktree.ts +14 -1
  43. package/src/commands/session.ts +79 -17
  44. package/src/commands/tasks.ts +119 -19
  45. package/src/commands/user-command.ts +3 -2
  46. package/src/database.ts +24 -0
  47. package/src/db.ts +1 -0
  48. package/src/discord-bot.ts +80 -8
  49. package/src/interaction-handler.ts +7 -19
  50. package/src/message-preprocessing.ts +11 -2
  51. package/src/queue-advanced-e2e-setup.ts +23 -0
  52. package/src/queue-delete-message.e2e.test.ts +102 -0
  53. package/src/schema.sql +1 -0
  54. package/src/schema.ts +3 -0
  55. package/src/session-handler/thread-runtime-state.ts +19 -0
  56. package/src/session-handler/thread-session-runtime.ts +75 -0
  57. package/src/system-message.test.ts +73 -61
  58. package/src/system-message.ts +63 -62
  59. package/src/task-runner.ts +34 -3
  60. package/src/task-schedule.ts +6 -0
  61. package/src/thread-message-queue.e2e.test.ts +4 -4
  62. package/src/voice-message.e2e.test.ts +1 -1
  63. package/src/worktree-lifecycle.e2e.test.ts +88 -1
  64. package/src/worktrees.ts +74 -0
@@ -1,10 +1,13 @@
1
1
  // /new-session command - Start a new OpenCode session.
2
+ // Works in both text channels and threads. When used in a thread, the new
3
+ // session inherits the same working directory (worktree/workspace) so the
4
+ // user stays in the same folder context.
2
5
  import { ChannelType } from 'discord.js';
3
6
  import fs from 'node:fs';
4
7
  import path from 'node:path';
5
- import { getChannelDirectory } from '../database.js';
8
+ import { getChannelDirectory, getThreadWorktreeOrWorkspace, createPendingWorkspace, setWorkspaceReady, } from '../database.js';
6
9
  import { initializeOpencodeForDirectory } from '../opencode.js';
7
- import { SILENT_MESSAGE_FLAGS, resolveProjectDirectoryFromAutocomplete } from '../discord-utils.js';
10
+ import { SILENT_MESSAGE_FLAGS, resolveProjectDirectoryFromAutocomplete, resolveWorkingDirectory, resolveTextChannel, } from '../discord-utils.js';
8
11
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
9
12
  import { createLogger, LogPrefix } from '../logger.js';
10
13
  import * as errore from 'errore';
@@ -15,15 +18,50 @@ export async function handleSessionCommand({ command, appId, }) {
15
18
  const filesString = command.options.getString('files') || '';
16
19
  const agent = command.options.getString('agent') || undefined;
17
20
  const channel = command.channel;
18
- if (!channel || channel.type !== ChannelType.GuildText) {
19
- await command.editReply('This command can only be used in text channels');
21
+ const isThread = channel && [
22
+ ChannelType.PublicThread,
23
+ ChannelType.PrivateThread,
24
+ ].includes(channel.type);
25
+ if (!channel || (channel.type !== ChannelType.GuildText && !isThread)) {
26
+ await command.editReply('This command can only be used in text channels or threads');
20
27
  return;
21
28
  }
22
- const channelConfig = await getChannelDirectory(channel.id);
23
- const projectDirectory = channelConfig?.directory;
24
- if (!projectDirectory) {
25
- await command.editReply('This channel is not configured with a project directory');
26
- return;
29
+ // Resolve project and working directories.
30
+ // In a thread: inherit from the thread's session (worktree/workspace aware).
31
+ // In a text channel: look up the channel's configured directory.
32
+ let projectDirectory;
33
+ let sdkDirectory;
34
+ let textChannel;
35
+ let sourceWorkspace;
36
+ if (isThread) {
37
+ const threadChannel = channel;
38
+ const [resolved, parentChannel, workspace] = await Promise.all([
39
+ resolveWorkingDirectory({ channel: threadChannel }),
40
+ resolveTextChannel(threadChannel),
41
+ getThreadWorktreeOrWorkspace(threadChannel.id),
42
+ ]);
43
+ if (!resolved) {
44
+ await command.editReply('Could not determine project directory for this thread');
45
+ return;
46
+ }
47
+ if (!parentChannel) {
48
+ await command.editReply('Could not resolve parent text channel');
49
+ return;
50
+ }
51
+ projectDirectory = resolved.projectDirectory;
52
+ sdkDirectory = resolved.workingDirectory;
53
+ textChannel = parentChannel;
54
+ sourceWorkspace = workspace;
55
+ }
56
+ else {
57
+ const channelConfig = await getChannelDirectory(channel.id);
58
+ if (!channelConfig?.directory) {
59
+ await command.editReply('This channel is not configured with a project directory');
60
+ return;
61
+ }
62
+ projectDirectory = channelConfig.directory;
63
+ sdkDirectory = channelConfig.directory;
64
+ textChannel = channel;
27
65
  }
28
66
  if (!fs.existsSync(projectDirectory)) {
29
67
  await command.editReply(`Directory does not exist: ${projectDirectory}`);
@@ -43,8 +81,8 @@ export async function handleSessionCommand({ command, appId, }) {
43
81
  if (files.length > 0) {
44
82
  fullPrompt = `${prompt}\n\n@${files.join(' @')}`;
45
83
  }
46
- const starterMessage = await channel.send({
47
- content: `🚀 **Starting OpenCode session**\n📝 ${prompt}${files.length > 0 ? `\n📎 Files: ${files.join(', ')}` : ''}`,
84
+ const starterMessage = await textChannel.send({
85
+ content: `**Starting OpenCode session**\n${prompt}${files.length > 0 ? `\nFiles: ${files.join(', ')}` : ''}`,
48
86
  flags: SILENT_MESSAGE_FLAGS,
49
87
  });
50
88
  const thread = await starterMessage.startThread({
@@ -52,6 +90,21 @@ export async function handleSessionCommand({ command, appId, }) {
52
90
  autoArchiveDuration: 1440,
53
91
  reason: 'OpenCode session',
54
92
  });
93
+ // Persist workspace association so commands in the new thread resolve the
94
+ // correct working directory, and the runtime survives bot restarts.
95
+ if (sourceWorkspace?.status === 'ready' && sourceWorkspace.workspace_directory) {
96
+ await createPendingWorkspace({
97
+ threadId: thread.id,
98
+ workspaceType: sourceWorkspace.workspace_type,
99
+ workspaceName: sourceWorkspace.workspace_name ?? '',
100
+ projectDirectory,
101
+ });
102
+ await setWorkspaceReady({
103
+ threadId: thread.id,
104
+ workspaceId: sourceWorkspace.workspace_id ?? undefined,
105
+ workspaceDirectory: sourceWorkspace.workspace_directory,
106
+ });
107
+ }
55
108
  // Add user to thread so it appears in their sidebar
56
109
  await thread.members.add(command.user.id);
57
110
  await command.editReply(`Created new session in ${thread.toString()}`);
@@ -59,8 +112,8 @@ export async function handleSessionCommand({ command, appId, }) {
59
112
  threadId: thread.id,
60
113
  thread,
61
114
  projectDirectory,
62
- sdkDirectory: projectDirectory,
63
- channelId: channel.id,
115
+ sdkDirectory,
116
+ channelId: textChannel.id,
64
117
  appId,
65
118
  });
66
119
  await runtime.enqueueIncoming({
@@ -1,10 +1,11 @@
1
1
  // /tasks command — list all scheduled tasks sorted by next run time.
2
2
  // Renders a markdown table that the CV2 pipeline auto-formats for Discord,
3
- // including HTML-backed action buttons for cancellable tasks.
3
+ // including HTML-backed Run now / Delete action buttons.
4
4
  import { ButtonInteraction, ChatInputCommandInteraction, ComponentType, MessageFlags, } from 'discord.js';
5
5
  import { cancelScheduledTask, listScheduledTasks, } from '../database.js';
6
6
  import { splitTablesFromMarkdown } from '../format-tables.js';
7
7
  import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
8
+ import { runScheduledTaskNow } from '../task-runner.js';
8
9
  import { formatTimeAgo } from './worktrees.js';
9
10
  function formatTimeUntil(date) {
10
11
  const diffMs = date.getTime() - Date.now();
@@ -34,6 +35,9 @@ function scheduleLabel(task) {
34
35
  }
35
36
  return 'one-time';
36
37
  }
38
+ function canRunTask(task) {
39
+ return task.status === 'planned';
40
+ }
37
41
  function canCancelTask(task) {
38
42
  return task.status === 'planned' || task.status === 'running';
39
43
  }
@@ -42,25 +46,35 @@ function canCancelTask(task) {
42
46
  function sanitizeTableCell(value) {
43
47
  return value.replaceAll('|', '\\|').replace(/\s+/g, ' ').trim();
44
48
  }
45
- function buildCancelButtonHtml({ buttonId }) {
46
- return `<button id="${buttonId}" variant="secondary">Delete</button>`;
49
+ function buildRunCell(task) {
50
+ if (!canRunTask(task)) {
51
+ return '-';
52
+ }
53
+ return `<button id="run-task-${task.id}" variant="primary">Run now</button>`;
47
54
  }
48
- function buildActionCell(task) {
55
+ function buildCancelCell(task) {
49
56
  if (!canCancelTask(task)) {
50
57
  return '-';
51
58
  }
52
- return buildCancelButtonHtml({ buttonId: `cancel-task-${task.id}` });
59
+ return `<button id="cancel-task-${task.id}" variant="secondary">Delete</button>`;
53
60
  }
54
61
  // Cap rows to avoid exceeding Discord's 40-component CV2 limit.
55
- // Each cancellable row renders as text + action row + button (~4 components),
56
- // so 10 rows is a safe ceiling.
57
- const MAX_TASK_ROWS = 10;
62
+ // Each actionable row is text + action row + up to 2 buttons (~4 components),
63
+ // so 7 rows stays under the budget with separators.
64
+ const MAX_TASK_ROWS = 7;
65
+ function formatChannelCell(task) {
66
+ if (!task.channel_id) {
67
+ return '-';
68
+ }
69
+ return `<#${task.channel_id}>`;
70
+ }
58
71
  function buildTaskTable({ tasks, }) {
59
- const header = '| ID | Status | Prompt | Schedule | Next Run | Action |';
60
- const separator = '|---|---|---|---|---|---|';
72
+ const header = '| ID | Status | Channel | Prompt | Schedule | Next Run | Run | Delete |';
73
+ const separator = '|---|---|---|---|---|---|---|---|';
61
74
  const rows = tasks.map((task) => {
62
75
  const id = String(task.id);
63
76
  const status = task.status;
77
+ const channel = formatChannelCell(task);
64
78
  const prompt = sanitizeTableCell(task.prompt_preview.length > 240
65
79
  ? task.prompt_preview.slice(0, 237) + '...'
66
80
  : task.prompt_preview);
@@ -73,8 +87,9 @@ function buildTaskTable({ tasks, }) {
73
87
  }
74
88
  return formatTimeUntil(task.next_run_at);
75
89
  })();
76
- const action = buildActionCell(task);
77
- return `| ${id} | ${status} | ${prompt} | ${schedule} | ${nextRun} | ${action} |`;
90
+ const run = buildRunCell(task);
91
+ const cancel = buildCancelCell(task);
92
+ return `| ${id} | ${status} | ${channel} | ${prompt} | ${schedule} | ${nextRun} | ${run} | ${cancel} |`;
78
93
  });
79
94
  return [header, separator, ...rows].join('\n');
80
95
  }
@@ -107,12 +122,15 @@ async function renderTasksReply({ guildId, userId, channelId, showAll, notice, e
107
122
  ? `Showing ${MAX_TASK_ROWS}/${allTasks.length} tasks. Use \`kimaki task list\` for full list.`
108
123
  : undefined;
109
124
  const combinedNotice = [notice, truncatedNotice].filter(Boolean).join('\n');
125
+ const runnableTasksByButtonId = new Map();
110
126
  const cancellableTasksByButtonId = new Map();
111
127
  tasks.forEach((task) => {
112
- if (!canCancelTask(task)) {
113
- return;
128
+ if (canRunTask(task)) {
129
+ runnableTasksByButtonId.set(`run-task-${task.id}`, task);
130
+ }
131
+ if (canCancelTask(task)) {
132
+ cancellableTasksByButtonId.set(`cancel-task-${task.id}`, task);
114
133
  }
115
- cancellableTasksByButtonId.set(`cancel-task-${task.id}`, task);
116
134
  });
117
135
  const tableMarkdown = buildTaskTable({ tasks });
118
136
  const markdown = combinedNotice
@@ -120,17 +138,32 @@ async function renderTasksReply({ guildId, userId, channelId, showAll, notice, e
120
138
  : tableMarkdown;
121
139
  const segments = splitTablesFromMarkdown(markdown, {
122
140
  resolveButtonCustomId: ({ button }) => {
123
- const task = cancellableTasksByButtonId.get(button.id);
124
- if (!task) {
141
+ const runTask = runnableTasksByButtonId.get(button.id);
142
+ if (runTask) {
143
+ const actionId = registerHtmlAction({
144
+ ownerKey,
145
+ threadId: String(runTask.id),
146
+ run: async ({ interaction }) => {
147
+ await handleRunTaskAction({
148
+ interaction,
149
+ taskId: runTask.id,
150
+ showAll,
151
+ });
152
+ },
153
+ });
154
+ return buildHtmlActionCustomId(actionId);
155
+ }
156
+ const cancelTask = cancellableTasksByButtonId.get(button.id);
157
+ if (!cancelTask) {
125
158
  return new Error(`No task registered for button ${button.id}`);
126
159
  }
127
160
  const actionId = registerHtmlAction({
128
161
  ownerKey,
129
- threadId: String(task.id),
162
+ threadId: String(cancelTask.id),
130
163
  run: async ({ interaction }) => {
131
164
  await handleCancelTaskAction({
132
165
  interaction,
133
- taskId: task.id,
166
+ taskId: cancelTask.id,
134
167
  showAll,
135
168
  });
136
169
  },
@@ -153,6 +186,58 @@ async function renderTasksReply({ guildId, userId, channelId, showAll, notice, e
153
186
  flags: MessageFlags.IsComponentsV2,
154
187
  });
155
188
  }
189
+ async function handleRunTaskAction({ interaction, taskId, showAll, }) {
190
+ const guildId = interaction.guildId;
191
+ if (!guildId) {
192
+ await interaction.editReply({
193
+ components: [
194
+ {
195
+ type: ComponentType.TextDisplay,
196
+ content: 'This action can only be used in a server.',
197
+ },
198
+ ],
199
+ flags: MessageFlags.IsComponentsV2,
200
+ });
201
+ return;
202
+ }
203
+ const token = interaction.client.token;
204
+ if (!token) {
205
+ await renderTasksReply({
206
+ guildId,
207
+ userId: interaction.user.id,
208
+ channelId: interaction.channelId,
209
+ showAll,
210
+ notice: `Could not run task #${taskId}: bot token unavailable.`,
211
+ editReply: (options) => {
212
+ return interaction.editReply(options);
213
+ },
214
+ });
215
+ return;
216
+ }
217
+ const result = await runScheduledTaskNow({ token, taskId });
218
+ const notice = (() => {
219
+ if (result instanceof Error) {
220
+ return `Could not run task #${taskId}: ${result.message}`;
221
+ }
222
+ if (result.kind === 'skipped') {
223
+ return `Task #${taskId} is already running or was claimed elsewhere.`;
224
+ }
225
+ if (result.kind === 'failed') {
226
+ return `Task #${taskId} failed: ${result.error.message}`;
227
+ }
228
+ return `Started task #${taskId}.`;
229
+ })();
230
+ await renderTasksReply({
231
+ guildId,
232
+ userId: interaction.user.id,
233
+ channelId: interaction.channelId,
234
+ showAll,
235
+ notice,
236
+ editReply: (options) => {
237
+ return interaction.editReply(options);
238
+ },
239
+ });
240
+ }
156
241
  async function handleCancelTaskAction({ interaction, taskId, showAll, }) {
157
242
  const guildId = interaction.guildId;
158
243
  if (!guildId) {
@@ -2,7 +2,7 @@
2
2
  // Handles slash commands that map to user-configured commands in opencode.json.
3
3
  import { ChannelType, MessageFlags, ThreadAutoArchiveDuration, } from 'discord.js';
4
4
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
5
- import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
5
+ import { SILENT_MESSAGE_FLAGS, resolveWorkingDirectory } from '../discord-utils.js';
6
6
  import { createLogger, LogPrefix } from '../logger.js';
7
7
  import { getChannelDirectory, getChannelWorktreesEnabled, getThreadSession, } from '../database.js';
8
8
  import { store } from '../store.js';
@@ -91,11 +91,12 @@ export const handleUserCommand = async ({ command, appId, }) => {
91
91
  if (isThread && thread) {
92
92
  // Running in existing thread - just send the command
93
93
  await command.editReply(`Running ${commandInvocation}...`);
94
+ const resolved = await resolveWorkingDirectory({ channel: thread });
94
95
  const runtime = getOrCreateRuntime({
95
96
  threadId: thread.id,
96
97
  thread,
97
98
  projectDirectory,
98
- sdkDirectory: projectDirectory,
99
+ sdkDirectory: resolved?.workingDirectory || projectDirectory,
99
100
  channelId: textChannel?.id,
100
101
  appId,
101
102
  });
package/dist/database.js CHANGED
@@ -380,6 +380,20 @@ export async function upsertThreadSession({ threadId, sessionId, source }) {
380
380
  .values({ thread_id: threadId, session_id: sessionId, source })
381
381
  .onConflictDoUpdate({ target: schema.thread_sessions.thread_id, set: { session_id: sessionId, source } });
382
382
  }
383
+ export async function getThreadParentSessionId(threadId) {
384
+ const db = await getDb();
385
+ return (await db.query.thread_sessions.findFirst({
386
+ where: { thread_id: threadId },
387
+ columns: { parent_session_id: true },
388
+ }))?.parent_session_id ?? undefined;
389
+ }
390
+ export async function setThreadParentSessionId({ threadId, parentSessionId, }) {
391
+ const db = await getDb();
392
+ await db
393
+ .update(schema.thread_sessions)
394
+ .set({ parent_session_id: parentSessionId })
395
+ .where(orm.eq(schema.thread_sessions.thread_id, threadId));
396
+ }
383
397
  export async function getThreadSessionSource(threadId) {
384
398
  const db = await getDb();
385
399
  return (await db.query.thread_sessions.findFirst({ where: { thread_id: threadId }, columns: { source: true } }))?.source;
package/dist/db.js CHANGED
@@ -119,6 +119,7 @@ async function migrateSchema({ db, client, }) {
119
119
  'ALTER TABLE bot_tokens ADD COLUMN last_used_at DATETIME',
120
120
  "ALTER TABLE thread_sessions ADD COLUMN source TEXT DEFAULT 'kimaki'",
121
121
  'ALTER TABLE thread_sessions ADD COLUMN last_synced_name TEXT',
122
+ 'ALTER TABLE thread_sessions ADD COLUMN parent_session_id TEXT',
122
123
  ];
123
124
  for (const stmt of alterStatements) {
124
125
  await client.execute(stmt).catch(() => undefined);
@@ -16,7 +16,7 @@ import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
16
16
  import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, resolveGuildMessageMember, } from './discord-utils.js';
17
17
  import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
18
18
  import YAML from 'yaml';
19
- import { getTextAttachments, resolveMentions, } from './message-formatting.js';
19
+ import { getFileAttachments, getTextAttachments, resolveMentions, } from './message-formatting.js';
20
20
  import { extractBtwSuffix } from './btw-prefix-detection.js';
21
21
  import { isVoiceAttachment } from './voice-attachment.js';
22
22
  import { forkSessionToBtwThread } from './commands/btw.js';
@@ -317,6 +317,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
317
317
  const cliInjectedInjectionGuardPatterns = isCliInjectedPrompt
318
318
  ? promptMarker?.injectionGuardPatterns
319
319
  : undefined;
320
+ const cliInjectedParentSessionId = isCliInjectedPrompt
321
+ ? promptMarker?.parentSessionId
322
+ : undefined;
320
323
  // Always ignore our own messages (unless CLI-injected prompt above).
321
324
  // Without this, assigning the Kimaki role to the bot itself would loop.
322
325
  if (isSelfBotMessage && !isCliInjectedPrompt) {
@@ -520,9 +523,14 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
520
523
  ? extractBtwSuffix(message.content || '')
521
524
  : null;
522
525
  if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
526
+ const btwSdkDir = worktreeInfo?.status === 'ready' &&
527
+ worktreeInfo.workspace_directory
528
+ ? worktreeInfo.workspace_directory
529
+ : projectDirectory;
523
530
  const result = await forkSessionToBtwThread({
524
531
  sourceThread: thread,
525
532
  projectDirectory,
533
+ sdkDirectory: btwSdkDir,
526
534
  prompt: btwResult.prompt,
527
535
  userId: message.author.id,
528
536
  username: message.member?.displayName || message.author.displayName,
@@ -606,6 +614,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
606
614
  model: cliInjectedModel,
607
615
  permissions: cliInjectedPermissions,
608
616
  injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
617
+ parentSessionId: cliInjectedParentSessionId,
609
618
  noReply: isLeadingMentionToOtherUser || undefined,
610
619
  sessionStartSource: sessionStartSource
611
620
  ? {
@@ -627,7 +636,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
627
636
  });
628
637
  // Notify when a voice message was queued instead of sent immediately
629
638
  if (enqueueResult.queued && enqueueResult.position) {
630
- await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit your message to update it in queue`);
639
+ await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`);
631
640
  }
632
641
  }
633
642
  if (channel.type === ChannelType.GuildText) {
@@ -816,6 +825,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
816
825
  return;
817
826
  if (!message.content)
818
827
  return;
828
+ // Discord fires MESSAGE_UPDATE for embed-only updates (link preview
829
+ // unfurling) without the user actually editing the message content.
830
+ // editedTimestamp is null for these; skip them to avoid false queue removals.
831
+ if (!message.editedTimestamp)
832
+ return;
819
833
  const channel = message.channel;
820
834
  const isThread = [
821
835
  ChannelType.PublicThread,
@@ -850,6 +864,27 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
850
864
  discordLogger.error('Error handling message update:', error instanceof Error ? error.stack : String(error));
851
865
  }
852
866
  });
867
+ // Handle user message deletes to remove queued messages.
868
+ // Discord delete events do not include author/content, so attribution comes
869
+ // from the queued item captured at enqueue time.
870
+ discordClient.on(Events.MessageDelete, async (message) => {
871
+ try {
872
+ const channel = message.channel;
873
+ if (!channel.isThread())
874
+ return;
875
+ const runtime = getRuntime(channel.id);
876
+ if (!runtime)
877
+ return;
878
+ const removed = runtime.removeQueuedMessage(message.id);
879
+ if (!removed)
880
+ return;
881
+ discordLogger.log(`[MESSAGE_DELETE] Removed queued message ${message.id} in thread ${channel.id}`);
882
+ await sendThreadMessage(channel, `⬦ **${removed.username}** removed message from queue`);
883
+ }
884
+ catch (error) {
885
+ discordLogger.error('Error handling message delete:', error instanceof Error ? error.stack : String(error));
886
+ }
887
+ });
853
888
  // Handle bot-initiated threads created by `kimaki send` (without --notify-only)
854
889
  // Uses JSON embed marker to pass options (start, worktree name)
855
890
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
@@ -892,7 +927,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
892
927
  return; // Not an auto-start thread
893
928
  }
894
929
  discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
895
- const textAttachmentsContent = await getTextAttachments(starterMessage);
930
+ const [textAttachmentsContent, fileAttachments] = await Promise.all([
931
+ getTextAttachments(starterMessage),
932
+ getFileAttachments(starterMessage),
933
+ ]);
896
934
  const messageText = resolveMentions(starterMessage).trim();
897
935
  const prompt = textAttachmentsContent
898
936
  ? `${messageText}\n\n${textAttachmentsContent}`
@@ -916,22 +954,31 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
916
954
  });
917
955
  return;
918
956
  }
919
- // Start worktree creation concurrently if requested.
957
+ // Start worktree creation concurrently if requested via marker OR
958
+ // if the channel/global toggle enables auto-worktrees.
920
959
  // The runtime is created immediately so follow-up messages queue
921
960
  // naturally; the worktree promise is awaited inside enqueueIncoming.
961
+ const autoWorktreeEnabled = !marker.worktree &&
962
+ !marker.cwd &&
963
+ (store.getState().useWorktrees ||
964
+ (await getChannelWorktreesEnabled(parent.id)));
965
+ const effectiveWorktreeName = marker.worktree ||
966
+ (autoWorktreeEnabled
967
+ ? formatAutoWorktreeName(thread.name.slice(0, 50))
968
+ : undefined);
922
969
  let worktreePromise;
923
- if (marker.worktree && (await isGitRepositoryRoot(projectDirectory))) {
924
- discordLogger.log(`[BOT_SESSION] Creating worktree: ${marker.worktree}`);
970
+ if (effectiveWorktreeName && (await isGitRepositoryRoot(projectDirectory))) {
971
+ discordLogger.log(`[BOT_SESSION] Creating worktree: ${effectiveWorktreeName}`);
925
972
  const worktreeStatusMessage = await thread
926
973
  .send({
927
- content: worktreeCreatingMessage(marker.worktree),
974
+ content: worktreeCreatingMessage(effectiveWorktreeName),
928
975
  flags: SILENT_MESSAGE_FLAGS,
929
976
  })
930
977
  .catch(() => undefined);
931
978
  worktreePromise = createWorktreeInBackground({
932
979
  thread,
933
980
  starterMessage: worktreeStatusMessage,
934
- worktreeName: marker.worktree,
981
+ worktreeName: effectiveWorktreeName,
935
982
  projectDirectory,
936
983
  rest: discordClient.rest,
937
984
  });
@@ -939,6 +986,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
939
986
  else if (marker.worktree) {
940
987
  discordLogger.warn(`[BOT_SESSION] Skipping requested worktree for non-git project directory: ${projectDirectory}`);
941
988
  }
989
+ else if (autoWorktreeEnabled) {
990
+ discordLogger.warn(`[BOT_SESSION] Skipping auto-worktree for non-git project directory: ${projectDirectory}`);
991
+ }
942
992
  // --cwd: reuse an existing project subfolder or worktree directory. Revalidate at bot-time
943
993
  // (CLI validated at send-time but the path could become stale).
944
994
  // Only worktree directories are stored in thread_workspaces. Project
@@ -1017,6 +1067,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1017
1067
  model: marker.model,
1018
1068
  permissions: marker.permissions,
1019
1069
  injectionGuardPatterns: marker.injectionGuardPatterns,
1070
+ parentSessionId: marker.parentSessionId,
1020
1071
  mode: 'opencode',
1021
1072
  sessionStartSource: botThreadStartSource
1022
1073
  ? {
@@ -1028,7 +1079,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1028
1079
  const permissionRules = await getChannelReferencePermissionRules({
1029
1080
  message: starterMessage,
1030
1081
  });
1031
- return { prompt, permissionRules, mode: 'opencode' };
1082
+ return {
1083
+ prompt,
1084
+ permissionRules,
1085
+ mode: 'opencode',
1086
+ ...(fileAttachments.length > 0 && { images: fileAttachments }),
1087
+ };
1032
1088
  },
1033
1089
  });
1034
1090
  }
@@ -103,22 +103,13 @@ export function registerInteractionHandler({ discordClient, appId, }) {
103
103
  // Multi-machine routing: only handle interactions for channels owned
104
104
  // by this machine (have a project directory configured in local db).
105
105
  // If not owned, silently return so the other machine handles it.
106
+ // Setup commands (create-new-project, add-project) bypass this check
107
+ // because they are designed to run from any channel — they create new
108
+ // project channels rather than requiring one to already exist.
109
+ const isSetupCommand = interaction.isChatInputCommand() &&
110
+ SETUP_COMMANDS.has(interaction.commandName);
106
111
  const owned = await isInteractionOwnedByThisMachine(interaction);
107
- if (!owned) {
108
- // Setup commands get a helpful reply instead of silent ignore.
109
- // Both machines may race to reply; one wins, the other silently
110
- // fails (interaction tokens are single-use). The message is the
111
- // same from either machine so the race is harmless.
112
- if (interaction.isChatInputCommand() &&
113
- SETUP_COMMANDS.has(interaction.commandName)) {
114
- await interaction.reply({
115
- content: 'Run this command in an existing Kimaki project channel.\nTo add a new project channel from the terminal, run:\n```\nkimaki project add /path/to/folder\n```',
116
- flags: MessageFlags.Ephemeral,
117
- }).catch(() => {
118
- // Another machine already responded, safe to ignore
119
- });
120
- return;
121
- }
112
+ if (!owned && !isSetupCommand) {
122
113
  interactionLogger.log(`[IGNORED] Channel ${interaction.channelId} has no project directory configured, skipping interaction`);
123
114
  // Do not respond at all — consuming the interaction token would
124
115
  // prevent the owning machine from responding (tokens are single-use).
@@ -276,15 +276,22 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
276
276
  }
277
277
  }
278
278
  const qs = extractQueueSuffix(prompt);
279
+ const fileAttachments = await getFileAttachments(message);
280
+ const textAttachmentsContent = await getTextAttachments(message);
281
+ const finalPrompt = textAttachmentsContent
282
+ ? `${qs.prompt}\n\n${textAttachmentsContent}`
283
+ : qs.prompt;
279
284
  if (shouldSkipEmptyPrompt({
280
285
  message,
281
- prompt: qs.prompt,
286
+ prompt: finalPrompt,
287
+ images: fileAttachments,
282
288
  hasVoiceAttachment,
283
289
  })) {
284
290
  return { prompt: '', mode: 'opencode', skip: true };
285
291
  }
286
292
  return {
287
- prompt: qs.prompt,
293
+ prompt: finalPrompt,
294
+ images: fileAttachments.length > 0 ? fileAttachments : undefined,
288
295
  repliedMessage,
289
296
  permissionRules,
290
297
  mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
@@ -99,6 +99,27 @@ export function createDeterministicMatchers() {
99
99
  partDelaysMs: [0, 0, 0, 3_000, 0],
100
100
  },
101
101
  };
102
+ const slowBusyMatcher = {
103
+ id: 'slow-busy-reply',
104
+ priority: 100,
105
+ when: {
106
+ latestUserTextIncludes: 'SLOW_BUSY_MARKER',
107
+ },
108
+ then: {
109
+ parts: [
110
+ { type: 'stream-start', warnings: [] },
111
+ { type: 'text-start', id: 'slow-busy' },
112
+ { type: 'text-delta', id: 'slow-busy', delta: 'slow-busy-reply' },
113
+ { type: 'text-end', id: 'slow-busy' },
114
+ {
115
+ type: 'finish',
116
+ finishReason: 'stop',
117
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
118
+ },
119
+ ],
120
+ partDelaysMs: [0, 0, 0, 2_000, 0],
121
+ },
122
+ };
102
123
  const toolFollowupMatcher = {
103
124
  id: 'tool-followup',
104
125
  priority: 50,
@@ -704,6 +725,7 @@ export function createDeterministicMatchers() {
704
725
  };
705
726
  return [
706
727
  slowAbortMatcher,
728
+ slowBusyMatcher,
707
729
  typingRepulseMatcher,
708
730
  pluginTimeoutSleepMatcher,
709
731
  actionButtonClickFollowupMatcher,