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
@@ -26,6 +26,7 @@ const logger = createLogger(LogPrefix.FORK)
26
26
  export async function forkSessionToBtwThread({
27
27
  sourceThread,
28
28
  projectDirectory,
29
+ sdkDirectory,
29
30
  prompt,
30
31
  userId,
31
32
  username,
@@ -33,6 +34,8 @@ export async function forkSessionToBtwThread({
33
34
  }: {
34
35
  sourceThread: ThreadChannel
35
36
  projectDirectory: string
37
+ /** Worktree directory when forking from a worktree thread, otherwise same as projectDirectory */
38
+ sdkDirectory: string
36
39
  prompt: string
37
40
  userId: string
38
41
  username: string
@@ -97,6 +100,9 @@ export async function forkSessionToBtwThread({
97
100
  `Created btw fork session ${forkedSession.id} in thread ${thread.id} from source thread ${sourceThread.id} (session ${sessionId})`,
98
101
  )
99
102
 
103
+ // Parent context stays in the user prompt only. Do NOT pass parentSessionId
104
+ // into enqueueIncoming: that would inject a parent block into the system
105
+ // message and bust prompt cache shared with the parent session.
100
106
  const wrappedPrompt = [
101
107
  `The user asked a side question while you were working on another task.`,
102
108
  `This is a forked session whose ONLY goal is to answer this question.`,
@@ -112,7 +118,7 @@ export async function forkSessionToBtwThread({
112
118
  threadId: thread.id,
113
119
  thread,
114
120
  projectDirectory,
115
- sdkDirectory: projectDirectory,
121
+ sdkDirectory,
116
122
  channelId,
117
123
  appId,
118
124
  })
@@ -173,7 +179,7 @@ export async function handleBtwCommand({
173
179
  return
174
180
  }
175
181
 
176
- const { projectDirectory } = resolved
182
+ const { projectDirectory, workingDirectory } = resolved
177
183
 
178
184
  await command.deferReply({ flags: MessageFlags.Ephemeral })
179
185
 
@@ -181,6 +187,7 @@ export async function handleBtwCommand({
181
187
  const result = await forkSessionToBtwThread({
182
188
  sourceThread: threadChannel,
183
189
  projectDirectory,
190
+ sdkDirectory: workingDirectory,
184
191
  prompt,
185
192
  userId: command.user.id,
186
193
  username: command.user.displayName,
@@ -32,6 +32,7 @@ import { notifyError } from '../sentry.js'
32
32
  import {
33
33
  execAsync,
34
34
  listBranchesByLastCommit,
35
+ resolveBestBaseRef,
35
36
  validateBranchRef,
36
37
  } from '../worktrees.js'
37
38
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js'
@@ -60,10 +61,22 @@ async function resolveRequestedWorktreeBaseRef({
60
61
  return DEFAULT_WORKTREE_BASE_REF
61
62
  }
62
63
 
63
- return validateBranchRef({
64
+ const validated = await validateBranchRef({
64
65
  directory: projectDirectory,
65
66
  ref: rawBaseBranch,
66
67
  })
68
+ if (validated instanceof Error) return validated
69
+
70
+ // Fetch and prefer the remote tracking ref if it's strictly ahead of local.
71
+ // Handles the common case where user says "main" but upstream/main has new commits.
72
+ const bestRef = await resolveBestBaseRef({
73
+ directory: projectDirectory,
74
+ branch: validated,
75
+ })
76
+ if (bestRef !== validated) {
77
+ logger.log(`Base branch resolved: ${validated} → ${bestRef} (remote is ahead)`)
78
+ }
79
+ return bestRef
67
80
  }
68
81
 
69
82
  /** Status message shown while a worktree is being created. */
@@ -1,12 +1,25 @@
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
 
3
- import { ChannelType, type TextChannel } from 'discord.js'
6
+ import { ChannelType, type TextChannel, type ThreadChannel } from 'discord.js'
4
7
  import fs from 'node:fs'
5
8
  import path from 'node:path'
6
9
  import type { CommandContext, AutocompleteContext } from './types.js'
7
- import { getChannelDirectory } from '../database.js'
10
+ import {
11
+ getChannelDirectory,
12
+ getThreadWorktreeOrWorkspace,
13
+ createPendingWorkspace,
14
+ setWorkspaceReady,
15
+ } from '../database.js'
8
16
  import { initializeOpencodeForDirectory } from '../opencode.js'
9
- import { SILENT_MESSAGE_FLAGS, resolveProjectDirectoryFromAutocomplete } from '../discord-utils.js'
17
+ import {
18
+ SILENT_MESSAGE_FLAGS,
19
+ resolveProjectDirectoryFromAutocomplete,
20
+ resolveWorkingDirectory,
21
+ resolveTextChannel,
22
+ } from '../discord-utils.js'
10
23
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js'
11
24
  import { createLogger, LogPrefix } from '../logger.js'
12
25
  import * as errore from 'errore'
@@ -24,19 +37,52 @@ export async function handleSessionCommand({
24
37
  const agent = command.options.getString('agent') || undefined
25
38
  const channel = command.channel
26
39
 
27
- if (!channel || channel.type !== ChannelType.GuildText) {
28
- await command.editReply('This command can only be used in text channels')
40
+ const isThread = channel && [
41
+ ChannelType.PublicThread,
42
+ ChannelType.PrivateThread,
43
+ ].includes(channel.type)
44
+
45
+ if (!channel || (channel.type !== ChannelType.GuildText && !isThread)) {
46
+ await command.editReply('This command can only be used in text channels or threads')
29
47
  return
30
48
  }
31
49
 
32
- const channelConfig = await getChannelDirectory(channel.id)
33
- const projectDirectory = channelConfig?.directory
34
-
35
- if (!projectDirectory) {
36
- await command.editReply(
37
- 'This channel is not configured with a project directory',
38
- )
39
- return
50
+ // Resolve project and working directories.
51
+ // In a thread: inherit from the thread's session (worktree/workspace aware).
52
+ // In a text channel: look up the channel's configured directory.
53
+ let projectDirectory: string
54
+ let sdkDirectory: string
55
+ let textChannel: TextChannel
56
+ let sourceWorkspace: Awaited<ReturnType<typeof getThreadWorktreeOrWorkspace>> | undefined
57
+
58
+ if (isThread) {
59
+ const threadChannel = channel as ThreadChannel
60
+ const [resolved, parentChannel, workspace] = await Promise.all([
61
+ resolveWorkingDirectory({ channel: threadChannel }),
62
+ resolveTextChannel(threadChannel),
63
+ getThreadWorktreeOrWorkspace(threadChannel.id),
64
+ ])
65
+ if (!resolved) {
66
+ await command.editReply('Could not determine project directory for this thread')
67
+ return
68
+ }
69
+ if (!parentChannel) {
70
+ await command.editReply('Could not resolve parent text channel')
71
+ return
72
+ }
73
+ projectDirectory = resolved.projectDirectory
74
+ sdkDirectory = resolved.workingDirectory
75
+ textChannel = parentChannel
76
+ sourceWorkspace = workspace
77
+ } else {
78
+ const channelConfig = await getChannelDirectory(channel.id)
79
+ if (!channelConfig?.directory) {
80
+ await command.editReply('This channel is not configured with a project directory')
81
+ return
82
+ }
83
+ projectDirectory = channelConfig.directory
84
+ sdkDirectory = channelConfig.directory
85
+ textChannel = channel as TextChannel
40
86
  }
41
87
 
42
88
  if (!fs.existsSync(projectDirectory)) {
@@ -61,8 +107,8 @@ export async function handleSessionCommand({
61
107
  fullPrompt = `${prompt}\n\n@${files.join(' @')}`
62
108
  }
63
109
 
64
- const starterMessage = await channel.send({
65
- content: `🚀 **Starting OpenCode session**\n📝 ${prompt}${files.length > 0 ? `\n📎 Files: ${files.join(', ')}` : ''}`,
110
+ const starterMessage = await textChannel.send({
111
+ content: `**Starting OpenCode session**\n${prompt}${files.length > 0 ? `\nFiles: ${files.join(', ')}` : ''}`,
66
112
  flags: SILENT_MESSAGE_FLAGS,
67
113
  })
68
114
 
@@ -72,6 +118,22 @@ export async function handleSessionCommand({
72
118
  reason: 'OpenCode session',
73
119
  })
74
120
 
121
+ // Persist workspace association so commands in the new thread resolve the
122
+ // correct working directory, and the runtime survives bot restarts.
123
+ if (sourceWorkspace?.status === 'ready' && sourceWorkspace.workspace_directory) {
124
+ await createPendingWorkspace({
125
+ threadId: thread.id,
126
+ workspaceType: sourceWorkspace.workspace_type,
127
+ workspaceName: sourceWorkspace.workspace_name ?? '',
128
+ projectDirectory,
129
+ })
130
+ await setWorkspaceReady({
131
+ threadId: thread.id,
132
+ workspaceId: sourceWorkspace.workspace_id ?? undefined,
133
+ workspaceDirectory: sourceWorkspace.workspace_directory,
134
+ })
135
+ }
136
+
75
137
  // Add user to thread so it appears in their sidebar
76
138
  await thread.members.add(command.user.id)
77
139
 
@@ -81,8 +143,8 @@ export async function handleSessionCommand({
81
143
  threadId: thread.id,
82
144
  thread,
83
145
  projectDirectory,
84
- sdkDirectory: projectDirectory,
85
- channelId: channel.id,
146
+ sdkDirectory,
147
+ channelId: textChannel.id,
86
148
  appId,
87
149
  })
88
150
  await runtime.enqueueIncoming({
@@ -1,6 +1,6 @@
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
 
5
5
  import {
6
6
  ButtonInteraction,
@@ -23,6 +23,7 @@ import {
23
23
  cancelHtmlActionsForOwner,
24
24
  registerHtmlAction,
25
25
  } from '../html-actions.js'
26
+ import { runScheduledTaskNow } from '../task-runner.js'
26
27
  import { formatTimeAgo } from './worktrees.js'
27
28
 
28
29
  function formatTimeUntil(date: Date): string {
@@ -55,6 +56,10 @@ function scheduleLabel(task: ScheduledTask): string {
55
56
  return 'one-time'
56
57
  }
57
58
 
59
+ function canRunTask(task: ScheduledTask): boolean {
60
+ return task.status === 'planned'
61
+ }
62
+
58
63
  function canCancelTask(task: ScheduledTask): boolean {
59
64
  return task.status === 'planned' || task.status === 'running'
60
65
  }
@@ -65,32 +70,43 @@ function sanitizeTableCell(value: string): string {
65
70
  return value.replaceAll('|', '\\|').replace(/\s+/g, ' ').trim()
66
71
  }
67
72
 
68
- function buildCancelButtonHtml({ buttonId }: { buttonId: string }): string {
69
- return `<button id="${buttonId}" variant="secondary">Delete</button>`
73
+ function buildRunCell(task: ScheduledTask): string {
74
+ if (!canRunTask(task)) {
75
+ return '-'
76
+ }
77
+ return `<button id="run-task-${task.id}" variant="primary">Run now</button>`
70
78
  }
71
79
 
72
- function buildActionCell(task: ScheduledTask): string {
80
+ function buildCancelCell(task: ScheduledTask): string {
73
81
  if (!canCancelTask(task)) {
74
82
  return '-'
75
83
  }
76
- return buildCancelButtonHtml({ buttonId: `cancel-task-${task.id}` })
84
+ return `<button id="cancel-task-${task.id}" variant="secondary">Delete</button>`
77
85
  }
78
86
 
79
87
  // Cap rows to avoid exceeding Discord's 40-component CV2 limit.
80
- // Each cancellable row renders as text + action row + button (~4 components),
81
- // so 10 rows is a safe ceiling.
82
- const MAX_TASK_ROWS = 10
88
+ // Each actionable row is text + action row + up to 2 buttons (~4 components),
89
+ // so 7 rows stays under the budget with separators.
90
+ const MAX_TASK_ROWS = 7
91
+
92
+ function formatChannelCell(task: ScheduledTask): string {
93
+ if (!task.channel_id) {
94
+ return '-'
95
+ }
96
+ return `<#${task.channel_id}>`
97
+ }
83
98
 
84
99
  function buildTaskTable({
85
100
  tasks,
86
101
  }: {
87
102
  tasks: ScheduledTask[]
88
103
  }): string {
89
- const header = '| ID | Status | Prompt | Schedule | Next Run | Action |'
90
- const separator = '|---|---|---|---|---|---|'
104
+ const header = '| ID | Status | Channel | Prompt | Schedule | Next Run | Run | Delete |'
105
+ const separator = '|---|---|---|---|---|---|---|---|'
91
106
  const rows = tasks.map((task) => {
92
107
  const id = String(task.id)
93
108
  const status = task.status
109
+ const channel = formatChannelCell(task)
94
110
  const prompt = sanitizeTableCell(
95
111
  task.prompt_preview.length > 240
96
112
  ? task.prompt_preview.slice(0, 237) + '...'
@@ -107,8 +123,9 @@ function buildTaskTable({
107
123
  }
108
124
  return formatTimeUntil(task.next_run_at)
109
125
  })()
110
- const action = buildActionCell(task)
111
- return `| ${id} | ${status} | ${prompt} | ${schedule} | ${nextRun} | ${action} |`
126
+ const run = buildRunCell(task)
127
+ const cancel = buildCancelCell(task)
128
+ return `| ${id} | ${status} | ${channel} | ${prompt} | ${schedule} | ${nextRun} | ${run} | ${cancel} |`
112
129
  })
113
130
  return [header, separator, ...rows].join('\n')
114
131
  }
@@ -171,12 +188,15 @@ async function renderTasksReply({
171
188
  : undefined
172
189
  const combinedNotice = [notice, truncatedNotice].filter(Boolean).join('\n')
173
190
 
191
+ const runnableTasksByButtonId = new Map<string, ScheduledTask>()
174
192
  const cancellableTasksByButtonId = new Map<string, ScheduledTask>()
175
193
  tasks.forEach((task) => {
176
- if (!canCancelTask(task)) {
177
- return
194
+ if (canRunTask(task)) {
195
+ runnableTasksByButtonId.set(`run-task-${task.id}`, task)
196
+ }
197
+ if (canCancelTask(task)) {
198
+ cancellableTasksByButtonId.set(`cancel-task-${task.id}`, task)
178
199
  }
179
- cancellableTasksByButtonId.set(`cancel-task-${task.id}`, task)
180
200
  })
181
201
 
182
202
  const tableMarkdown = buildTaskTable({ tasks })
@@ -185,18 +205,34 @@ async function renderTasksReply({
185
205
  : tableMarkdown
186
206
  const segments = splitTablesFromMarkdown(markdown, {
187
207
  resolveButtonCustomId: ({ button }) => {
188
- const task = cancellableTasksByButtonId.get(button.id)
189
- if (!task) {
208
+ const runTask = runnableTasksByButtonId.get(button.id)
209
+ if (runTask) {
210
+ const actionId = registerHtmlAction({
211
+ ownerKey,
212
+ threadId: String(runTask.id),
213
+ run: async ({ interaction }) => {
214
+ await handleRunTaskAction({
215
+ interaction,
216
+ taskId: runTask.id,
217
+ showAll,
218
+ })
219
+ },
220
+ })
221
+ return buildHtmlActionCustomId(actionId)
222
+ }
223
+
224
+ const cancelTask = cancellableTasksByButtonId.get(button.id)
225
+ if (!cancelTask) {
190
226
  return new Error(`No task registered for button ${button.id}`)
191
227
  }
192
228
 
193
229
  const actionId = registerHtmlAction({
194
230
  ownerKey,
195
- threadId: String(task.id),
231
+ threadId: String(cancelTask.id),
196
232
  run: async ({ interaction }) => {
197
233
  await handleCancelTaskAction({
198
234
  interaction,
199
- taskId: task.id,
235
+ taskId: cancelTask.id,
200
236
  showAll,
201
237
  })
202
238
  },
@@ -224,6 +260,70 @@ async function renderTasksReply({
224
260
  })
225
261
  }
226
262
 
263
+ async function handleRunTaskAction({
264
+ interaction,
265
+ taskId,
266
+ showAll,
267
+ }: {
268
+ interaction: ButtonInteraction
269
+ taskId: number
270
+ showAll: boolean
271
+ }): Promise<void> {
272
+ const guildId = interaction.guildId
273
+ if (!guildId) {
274
+ await interaction.editReply({
275
+ components: [
276
+ {
277
+ type: ComponentType.TextDisplay,
278
+ content: 'This action can only be used in a server.',
279
+ },
280
+ ],
281
+ flags: MessageFlags.IsComponentsV2,
282
+ })
283
+ return
284
+ }
285
+
286
+ const token = interaction.client.token
287
+ if (!token) {
288
+ await renderTasksReply({
289
+ guildId,
290
+ userId: interaction.user.id,
291
+ channelId: interaction.channelId,
292
+ showAll,
293
+ notice: `Could not run task #${taskId}: bot token unavailable.`,
294
+ editReply: (options) => {
295
+ return interaction.editReply(options)
296
+ },
297
+ })
298
+ return
299
+ }
300
+
301
+ const result = await runScheduledTaskNow({ token, taskId })
302
+ const notice = (() => {
303
+ if (result instanceof Error) {
304
+ return `Could not run task #${taskId}: ${result.message}`
305
+ }
306
+ if (result.kind === 'skipped') {
307
+ return `Task #${taskId} is already running or was claimed elsewhere.`
308
+ }
309
+ if (result.kind === 'failed') {
310
+ return `Task #${taskId} failed: ${result.error.message}`
311
+ }
312
+ return `Started task #${taskId}.`
313
+ })()
314
+
315
+ await renderTasksReply({
316
+ guildId,
317
+ userId: interaction.user.id,
318
+ channelId: interaction.channelId,
319
+ showAll,
320
+ notice,
321
+ editReply: (options) => {
322
+ return interaction.editReply(options)
323
+ },
324
+ })
325
+ }
326
+
227
327
  async function handleCancelTaskAction({
228
328
  interaction,
229
329
  taskId,
@@ -10,7 +10,7 @@ import {
10
10
  type ThreadChannel,
11
11
  } from 'discord.js'
12
12
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js'
13
- import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
13
+ import { SILENT_MESSAGE_FLAGS, resolveWorkingDirectory } from '../discord-utils.js'
14
14
  import { createLogger, LogPrefix } from '../logger.js'
15
15
  import {
16
16
  getChannelDirectory,
@@ -138,11 +138,12 @@ export const handleUserCommand: CommandHandler = async ({
138
138
  // Running in existing thread - just send the command
139
139
  await command.editReply(`Running ${commandInvocation}...`)
140
140
 
141
+ const resolved = await resolveWorkingDirectory({ channel: thread })
141
142
  const runtime = getOrCreateRuntime({
142
143
  threadId: thread.id,
143
144
  thread,
144
145
  projectDirectory,
145
- sdkDirectory: projectDirectory,
146
+ sdkDirectory: resolved?.workingDirectory || projectDirectory,
146
147
  channelId: textChannel?.id,
147
148
  appId,
148
149
  })
package/src/database.ts CHANGED
@@ -534,6 +534,30 @@ export async function upsertThreadSession({ threadId, sessionId, source }: { thr
534
534
  .onConflictDoUpdate({ target: schema.thread_sessions.thread_id, set: { session_id: sessionId, source } })
535
535
  }
536
536
 
537
+ export async function getThreadParentSessionId(threadId: string) {
538
+ const db = await getDb()
539
+ return (
540
+ await db.query.thread_sessions.findFirst({
541
+ where: { thread_id: threadId },
542
+ columns: { parent_session_id: true },
543
+ })
544
+ )?.parent_session_id ?? undefined
545
+ }
546
+
547
+ export async function setThreadParentSessionId({
548
+ threadId,
549
+ parentSessionId,
550
+ }: {
551
+ threadId: string
552
+ parentSessionId: string
553
+ }) {
554
+ const db = await getDb()
555
+ await db
556
+ .update(schema.thread_sessions)
557
+ .set({ parent_session_id: parentSessionId })
558
+ .where(orm.eq(schema.thread_sessions.thread_id, threadId))
559
+ }
560
+
537
561
  export async function getThreadSessionSource(threadId: string) {
538
562
  const db = await getDb()
539
563
  return (await db.query.thread_sessions.findFirst({ where: { thread_id: threadId }, columns: { source: true } }))?.source
package/src/db.ts CHANGED
@@ -158,6 +158,7 @@ async function migrateSchema({
158
158
  'ALTER TABLE bot_tokens ADD COLUMN last_used_at DATETIME',
159
159
  "ALTER TABLE thread_sessions ADD COLUMN source TEXT DEFAULT 'kimaki'",
160
160
  'ALTER TABLE thread_sessions ADD COLUMN last_synced_name TEXT',
161
+ 'ALTER TABLE thread_sessions ADD COLUMN parent_session_id TEXT',
161
162
  ]
162
163
  for (const stmt of alterStatements) {
163
164
  await client.execute(stmt).catch(() => undefined)
@@ -52,6 +52,7 @@ import {
52
52
  } from './system-message.js'
53
53
  import YAML from 'yaml'
54
54
  import {
55
+ getFileAttachments,
55
56
  getTextAttachments,
56
57
  resolveMentions,
57
58
  } from './message-formatting.js'
@@ -484,6 +485,9 @@ export async function startDiscordBot({
484
485
  const cliInjectedInjectionGuardPatterns = isCliInjectedPrompt
485
486
  ? promptMarker?.injectionGuardPatterns
486
487
  : undefined
488
+ const cliInjectedParentSessionId = isCliInjectedPrompt
489
+ ? promptMarker?.parentSessionId
490
+ : undefined
487
491
 
488
492
  // Always ignore our own messages (unless CLI-injected prompt above).
489
493
  // Without this, assigning the Kimaki role to the bot itself would loop.
@@ -729,9 +733,15 @@ export async function startDiscordBot({
729
733
  ? extractBtwSuffix(message.content || '')
730
734
  : null
731
735
  if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
736
+ const btwSdkDir =
737
+ worktreeInfo?.status === 'ready' &&
738
+ worktreeInfo.workspace_directory
739
+ ? worktreeInfo.workspace_directory
740
+ : projectDirectory
732
741
  const result = await forkSessionToBtwThread({
733
742
  sourceThread: thread,
734
743
  projectDirectory,
744
+ sdkDirectory: btwSdkDir,
735
745
  prompt: btwResult.prompt,
736
746
  userId: message.author.id,
737
747
  username:
@@ -829,6 +839,7 @@ export async function startDiscordBot({
829
839
  model: cliInjectedModel,
830
840
  permissions: cliInjectedPermissions,
831
841
  injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
842
+ parentSessionId: cliInjectedParentSessionId,
832
843
  noReply: isLeadingMentionToOtherUser || undefined,
833
844
  sessionStartSource: sessionStartSource
834
845
  ? {
@@ -851,7 +862,10 @@ export async function startDiscordBot({
851
862
 
852
863
  // Notify when a voice message was queued instead of sent immediately
853
864
  if (enqueueResult.queued && enqueueResult.position) {
854
- await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit your message to update it in queue`)
865
+ await sendThreadMessage(
866
+ thread,
867
+ `Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`,
868
+ )
855
869
  }
856
870
  }
857
871
 
@@ -1076,6 +1090,10 @@ export async function startDiscordBot({
1076
1090
  if (!message) return
1077
1091
  if (message.author.bot) return
1078
1092
  if (!message.content) return
1093
+ // Discord fires MESSAGE_UPDATE for embed-only updates (link preview
1094
+ // unfurling) without the user actually editing the message content.
1095
+ // editedTimestamp is null for these; skip them to avoid false queue removals.
1096
+ if (!message.editedTimestamp) return
1079
1097
 
1080
1098
  const channel = message.channel
1081
1099
  const isThread = [
@@ -1131,6 +1149,35 @@ export async function startDiscordBot({
1131
1149
  }
1132
1150
  })
1133
1151
 
1152
+ // Handle user message deletes to remove queued messages.
1153
+ // Discord delete events do not include author/content, so attribution comes
1154
+ // from the queued item captured at enqueue time.
1155
+ discordClient.on(Events.MessageDelete, async (message) => {
1156
+ try {
1157
+ const channel = message.channel
1158
+ if (!channel.isThread()) return
1159
+
1160
+ const runtime = getRuntime(channel.id)
1161
+ if (!runtime) return
1162
+
1163
+ const removed = runtime.removeQueuedMessage(message.id)
1164
+ if (!removed) return
1165
+
1166
+ discordLogger.log(
1167
+ `[MESSAGE_DELETE] Removed queued message ${message.id} in thread ${channel.id}`,
1168
+ )
1169
+ await sendThreadMessage(
1170
+ channel,
1171
+ `⬦ **${removed.username}** removed message from queue`,
1172
+ )
1173
+ } catch (error) {
1174
+ discordLogger.error(
1175
+ 'Error handling message delete:',
1176
+ error instanceof Error ? error.stack : String(error),
1177
+ )
1178
+ }
1179
+ })
1180
+
1134
1181
  // Handle bot-initiated threads created by `kimaki send` (without --notify-only)
1135
1182
  // Uses JSON embed marker to pass options (start, worktree name)
1136
1183
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
@@ -1188,7 +1235,10 @@ export async function startDiscordBot({
1188
1235
  `[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`,
1189
1236
  )
1190
1237
 
1191
- const textAttachmentsContent = await getTextAttachments(starterMessage)
1238
+ const [textAttachmentsContent, fileAttachments] = await Promise.all([
1239
+ getTextAttachments(starterMessage),
1240
+ getFileAttachments(starterMessage),
1241
+ ])
1192
1242
  const messageText = resolveMentions(starterMessage).trim()
1193
1243
  const prompt = textAttachmentsContent
1194
1244
  ? `${messageText}\n\n${textAttachmentsContent}`
@@ -1221,16 +1271,28 @@ export async function startDiscordBot({
1221
1271
  return
1222
1272
  }
1223
1273
 
1224
- // Start worktree creation concurrently if requested.
1274
+ // Start worktree creation concurrently if requested via marker OR
1275
+ // if the channel/global toggle enables auto-worktrees.
1225
1276
  // The runtime is created immediately so follow-up messages queue
1226
1277
  // naturally; the worktree promise is awaited inside enqueueIncoming.
1278
+ const autoWorktreeEnabled =
1279
+ !marker.worktree &&
1280
+ !marker.cwd &&
1281
+ (store.getState().useWorktrees ||
1282
+ (await getChannelWorktreesEnabled(parent.id)))
1283
+ const effectiveWorktreeName =
1284
+ marker.worktree ||
1285
+ (autoWorktreeEnabled
1286
+ ? formatAutoWorktreeName(thread.name.slice(0, 50))
1287
+ : undefined)
1288
+
1227
1289
  let worktreePromise: Promise<string | Error> | undefined
1228
- if (marker.worktree && (await isGitRepositoryRoot(projectDirectory))) {
1229
- discordLogger.log(`[BOT_SESSION] Creating worktree: ${marker.worktree}`)
1290
+ if (effectiveWorktreeName && (await isGitRepositoryRoot(projectDirectory))) {
1291
+ discordLogger.log(`[BOT_SESSION] Creating worktree: ${effectiveWorktreeName}`)
1230
1292
 
1231
1293
  const worktreeStatusMessage = await thread
1232
1294
  .send({
1233
- content: worktreeCreatingMessage(marker.worktree),
1295
+ content: worktreeCreatingMessage(effectiveWorktreeName),
1234
1296
  flags: SILENT_MESSAGE_FLAGS,
1235
1297
  })
1236
1298
  .catch(() => undefined)
@@ -1238,7 +1300,7 @@ export async function startDiscordBot({
1238
1300
  worktreePromise = createWorktreeInBackground({
1239
1301
  thread,
1240
1302
  starterMessage: worktreeStatusMessage,
1241
- worktreeName: marker.worktree,
1303
+ worktreeName: effectiveWorktreeName,
1242
1304
  projectDirectory,
1243
1305
  rest: discordClient.rest,
1244
1306
  })
@@ -1246,6 +1308,10 @@ export async function startDiscordBot({
1246
1308
  discordLogger.warn(
1247
1309
  `[BOT_SESSION] Skipping requested worktree for non-git project directory: ${projectDirectory}`,
1248
1310
  )
1311
+ } else if (autoWorktreeEnabled) {
1312
+ discordLogger.warn(
1313
+ `[BOT_SESSION] Skipping auto-worktree for non-git project directory: ${projectDirectory}`,
1314
+ )
1249
1315
  }
1250
1316
 
1251
1317
  // --cwd: reuse an existing project subfolder or worktree directory. Revalidate at bot-time
@@ -1335,6 +1401,7 @@ export async function startDiscordBot({
1335
1401
  model: marker.model,
1336
1402
  permissions: marker.permissions,
1337
1403
  injectionGuardPatterns: marker.injectionGuardPatterns,
1404
+ parentSessionId: marker.parentSessionId,
1338
1405
  mode: 'opencode',
1339
1406
  sessionStartSource: botThreadStartSource
1340
1407
  ? {
@@ -1346,7 +1413,12 @@ export async function startDiscordBot({
1346
1413
  const permissionRules = await getChannelReferencePermissionRules({
1347
1414
  message: starterMessage,
1348
1415
  })
1349
- return { prompt, permissionRules, mode: 'opencode' }
1416
+ return {
1417
+ prompt,
1418
+ permissionRules,
1419
+ mode: 'opencode',
1420
+ ...(fileAttachments.length > 0 && { images: fileAttachments }),
1421
+ }
1350
1422
  },
1351
1423
  })
1352
1424
  } catch (error) {