kimaki 0.21.0 → 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.
@@ -163,8 +163,10 @@ cli
163
163
  'List all registered projects with their Discord channels',
164
164
  )
165
165
  .option('--json', 'Output as JSON')
166
+ .option('--all', 'Include remote projects from other machines (scans Kimaki category in Discord)')
167
+ .option('-g, --guild <guildId>', 'Discord guild/server ID to scan (used with --all when no local projects exist)')
166
168
  .option('--prune', 'Remove stale entries whose Discord channel no longer exists')
167
- .action(async (options: { json?: boolean; prune?: boolean }) => {
169
+ .action(async (options) => {
168
170
  await initDatabase()
169
171
 
170
172
  const db = await getDb()
@@ -173,15 +175,12 @@ cli
173
175
  orderBy: { created_at: 'desc' },
174
176
  })
175
177
 
176
- if (channels.length === 0) {
177
- cliLogger.log('No projects registered')
178
- process.exit(0)
179
- }
180
-
181
178
  // Fetch Discord channel names and guild IDs via REST API
182
179
  const botRow = await getBotTokenWithMode()
183
180
  const rest = botRow ? createDiscordRest(botRow.token) : null
184
181
 
182
+ const localChannelIds = new Set(channels.map((ch) => ch.channel_id))
183
+
185
184
  const enriched = await Promise.all(
186
185
  channels.map(async (ch) => {
187
186
  let channelName = ''
@@ -204,14 +203,19 @@ cli
204
203
  deleted = isUnknownChannel
205
204
  }
206
205
  }
207
- return { ...ch, channelName, guildId, deleted }
206
+ return { ...ch, channelName, guildId, deleted, isLocal: true as boolean }
208
207
  }),
209
208
  )
210
209
 
211
210
  // Fetch guild names for unique guild IDs (deduplicated to save API calls)
212
211
  const guildNameMap = new Map<string, string>()
212
+ // Collect guild IDs from local channels + explicit --guild flag
213
+ const guildIdsFromChannels = enriched.map((ch) => ch.guildId).filter(Boolean)
214
+ if (options.guild) {
215
+ guildIdsFromChannels.push(options.guild)
216
+ }
217
+ const uniqueGuildIds = [...new Set(guildIdsFromChannels)]
213
218
  if (rest) {
214
- const uniqueGuildIds = [...new Set(enriched.map((ch) => ch.guildId).filter(Boolean))]
215
219
  await Promise.all(
216
220
  uniqueGuildIds.map(async (guildId) => {
217
221
  try {
@@ -227,8 +231,76 @@ cli
227
231
  )
228
232
  }
229
233
 
234
+ // When --all is passed, scan each guild's channels to find Kimaki category
235
+ // text channels not in our local DB (projects from other machines).
236
+ // Fail explicitly when prerequisites are missing so the user doesn't
237
+ // confuse "scan never ran" with "no remote projects found".
238
+ let remoteEntries: typeof enriched = []
239
+ if (options.all) {
240
+ if (!rest) {
241
+ cliLogger.error('Discord credentials are required to scan remote projects. Run `kimaki` first.')
242
+ process.exit(EXIT_NO_RESTART)
243
+ }
244
+ if (uniqueGuildIds.length === 0) {
245
+ cliLogger.error(
246
+ 'Cannot determine which Discord server to scan. Pass `--guild <guildId>` or register a local project first.',
247
+ )
248
+ process.exit(EXIT_NO_RESTART)
249
+ }
250
+
251
+ let guildScanFailures = 0
252
+ for (const guildId of uniqueGuildIds) {
253
+ try {
254
+ const guildChannels = (await rest.get(Routes.guildChannels(guildId))) as Array<{
255
+ id: string
256
+ name: string
257
+ type: number
258
+ parent_id: string | null
259
+ }>
260
+
261
+ // Find Kimaki category channels (type 4 = GuildCategory)
262
+ const kimakiCategoryIds = new Set(
263
+ guildChannels
264
+ .filter((ch) => ch.type === 4 && /^kimaki(\s|$)/i.test(ch.name))
265
+ .map((ch) => ch.id),
266
+ )
267
+
268
+ // Find text channels (type 0) in Kimaki categories that are not in our local DB
269
+ for (const ch of guildChannels) {
270
+ if (
271
+ ch.type === 0 &&
272
+ ch.parent_id &&
273
+ kimakiCategoryIds.has(ch.parent_id) &&
274
+ !localChannelIds.has(ch.id)
275
+ ) {
276
+ remoteEntries.push({
277
+ channel_id: ch.id,
278
+ directory: '',
279
+ channel_type: 'text' as const,
280
+ created_at: null,
281
+ channelName: ch.name,
282
+ guildId,
283
+ deleted: false,
284
+ isLocal: false,
285
+ })
286
+ }
287
+ }
288
+ } catch (error) {
289
+ guildScanFailures++
290
+ cliLogger.warn(
291
+ `Failed to scan guild ${guildNameMap.get(guildId) || guildId}: ${error instanceof Error ? error.message : String(error)}`,
292
+ )
293
+ }
294
+ }
295
+ if (guildScanFailures === uniqueGuildIds.length) {
296
+ cliLogger.error('Failed to scan all guilds. Check bot permissions or try again.')
297
+ process.exit(EXIT_NO_RESTART)
298
+ }
299
+ }
300
+
230
301
  // Build final enriched entries with guild names resolved
231
- const enrichedWithGuild = enriched.map((ch) => ({
302
+ const allEntries = [...enriched, ...remoteEntries]
303
+ const enrichedWithGuild = allEntries.map((ch) => ({
232
304
  ...ch,
233
305
  guildName: ch.guildId ? (guildNameMap.get(ch.guildId) || '') : '',
234
306
  }))
@@ -236,7 +308,7 @@ cli
236
308
  // Warn on stderr if the same directory appears in multiple channels (multi-guild duplicates)
237
309
  const directoryCounts = new Map<string, number>()
238
310
  for (const ch of enrichedWithGuild) {
239
- if (!ch.deleted) {
311
+ if (!ch.deleted && ch.directory) {
240
312
  directoryCounts.set(ch.directory, (directoryCounts.get(ch.directory) || 0) + 1)
241
313
  }
242
314
  }
@@ -251,7 +323,7 @@ cli
251
323
  // Prune stale entries if requested
252
324
  let finalEntries = enrichedWithGuild
253
325
  if (options.prune) {
254
- const stale = finalEntries.filter((ch) => ch.deleted)
326
+ const stale = finalEntries.filter((ch) => ch.deleted && ch.isLocal)
255
327
  if (stale.length === 0) {
256
328
  cliLogger.log('No stale channels to prune')
257
329
  } else {
@@ -268,29 +340,40 @@ cli
268
340
  }
269
341
  }
270
342
 
343
+ if (finalEntries.length === 0) {
344
+ cliLogger.log('No projects registered')
345
+ process.exit(0)
346
+ }
347
+
271
348
  if (options.json) {
272
349
  const output = finalEntries.map((ch) => ({
273
350
  channel_id: ch.channel_id,
274
351
  channel_name: ch.channelName,
275
352
  guild_id: ch.guildId,
276
353
  guild_name: ch.guildName,
277
- directory: ch.directory,
278
- folder_name: path.basename(ch.directory),
354
+ directory: ch.directory || null,
355
+ folder_name: ch.directory ? path.basename(ch.directory) : null,
279
356
  deleted: ch.deleted,
357
+ is_local: ch.isLocal,
280
358
  }))
281
359
  console.log(JSON.stringify(output, null, 2))
282
360
  process.exit(0)
283
361
  }
284
362
 
285
363
  for (const ch of finalEntries) {
286
- const folderName = path.basename(ch.directory)
287
364
  const deletedTag = ch.deleted ? ' (deleted from Discord)' : ''
365
+ const remoteTag = !ch.isLocal ? ' [remote]' : ''
288
366
  const channelLabel = ch.channelName ? `#${ch.channelName}` : ch.channel_id
289
367
  const guildLabel = ch.guildName || ch.guildId || ''
290
368
  const guildSuffix = guildLabel ? ` (${guildLabel})` : ''
291
- console.log(`\n${channelLabel}${guildSuffix}${deletedTag}`)
292
- console.log(` Folder: ${folderName}`)
293
- console.log(` Directory: ${ch.directory}`)
369
+ console.log(`\n${channelLabel}${guildSuffix}${deletedTag}${remoteTag}`)
370
+ if (ch.isLocal && ch.directory) {
371
+ const folderName = path.basename(ch.directory)
372
+ console.log(` Folder: ${folderName}`)
373
+ console.log(` Directory: ${ch.directory}`)
374
+ } else if (!ch.isLocal) {
375
+ console.log(` (Not registered on this machine)`)
376
+ }
294
377
  console.log(` Channel ID: ${ch.channel_id}`)
295
378
  if (ch.guildId) {
296
379
  console.log(` Guild ID: ${ch.guildId}`)
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url'
16
16
  import { spawn, execSync } from 'node:child_process'
17
17
  import { createLogger, LogPrefix, initLogFile } from '../logger.js'
18
18
  import { createDiscordClient, initDatabase, getChannelDirectory, initializeOpencodeForDirectory, createProjectChannels } from '../discord-bot.js'
19
- import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory } from '../database.js'
19
+ import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, getChannelWorktreesEnabled } from '../database.js'
20
20
  import { ShareMarkdown } from '../markdown.js'
21
21
  import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js'
22
22
  import { formatWorktreeName, formatAutoWorktreeName } from '../commands/new-worktree.js'
@@ -26,7 +26,7 @@ import { buildOpencodeEventLogLine } from '../session-handler/opencode-session-e
26
26
  import { createDiscordRest } from '../discord-urls.js'
27
27
  import { archiveThread, uploadFilesToDiscord, stripMentions } from '../discord-utils.js'
28
28
  import { setDataDir, setProjectsDir, getDataDir, getProjectsDir } from '../config.js'
29
- import { execAsync, resolveSessionWorkingDirectory } from '../worktrees.js'
29
+ import { execAsync, resolveSessionWorkingDirectory, isGitRepositoryRoot } from '../worktrees.js'
30
30
  import { upgrade, getCurrentVersion } from '../upgrade.js'
31
31
  import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload, type ScheduledTaskPayload } from '../task-schedule.js'
32
32
  import {
@@ -95,6 +95,13 @@ cli
95
95
  'Format: "tool:argsGlob". Examples: --injection-guard "bash:*" --injection-guard "webfetch:*"',
96
96
  ),
97
97
  )
98
+ .option(
99
+ '-f, --file <path>',
100
+ z.array(z.string()).describe(
101
+ 'Local file to attach (repeatable). Images, text files, PDFs, etc. ' +
102
+ 'Examples: --file screenshot.png --file report.pdf',
103
+ ),
104
+ )
98
105
  .option(
99
106
  '--send-at <schedule>',
100
107
  'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)',
@@ -153,11 +160,19 @@ cli
153
160
  process.exit(EXIT_NO_RESTART)
154
161
  }
155
162
 
163
+ const filePaths = options.file?.length
164
+ ? options.file.map((f: string) => path.resolve(f))
165
+ : undefined
166
+
156
167
  if (sendAt) {
157
168
  if (options.wait) {
158
169
  cliLogger.error('Cannot use --wait with --send-at')
159
170
  process.exit(EXIT_NO_RESTART)
160
171
  }
172
+ if (filePaths?.length) {
173
+ cliLogger.error('Cannot use --file with --send-at')
174
+ process.exit(EXIT_NO_RESTART)
175
+ }
161
176
  if (prompt.length > 1900) {
162
177
  cliLogger.error(
163
178
  '--send-at currently supports prompts up to 1900 characters',
@@ -166,6 +181,31 @@ cli
166
181
  }
167
182
  }
168
183
 
184
+ // Validate all --file paths exist and are regular files
185
+ if (filePaths?.length) {
186
+ // Discord allows max 10 attachments per message. Long prompts also
187
+ // consume one slot (prompt.md), so reserve space for that.
188
+ const maxUserFiles = prompt.length > 2000 ? 9 : 10
189
+ if (filePaths.length > maxUserFiles) {
190
+ cliLogger.error(
191
+ `Too many files: ${filePaths.length} provided, Discord allows at most ${maxUserFiles} attachments per message` +
192
+ (maxUserFiles === 9 ? ' (1 slot reserved for long prompt)' : ''),
193
+ )
194
+ process.exit(EXIT_NO_RESTART)
195
+ }
196
+ for (const file of filePaths) {
197
+ if (!fs.existsSync(file)) {
198
+ cliLogger.error(`File not found: ${file}`)
199
+ process.exit(EXIT_NO_RESTART)
200
+ }
201
+ const stat = fs.statSync(file)
202
+ if (!stat.isFile()) {
203
+ cliLogger.error(`Not a regular file: ${file}`)
204
+ process.exit(EXIT_NO_RESTART)
205
+ }
206
+ }
207
+ }
208
+
169
209
  const parsedSchedule = (() => {
170
210
  if (!sendAt) {
171
211
  return null
@@ -483,6 +523,7 @@ cli
483
523
  botToken,
484
524
  embeds: promptEmbed,
485
525
  rest,
526
+ files: filePaths,
486
527
  })
487
528
 
488
529
  const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`
@@ -578,14 +619,23 @@ cli
578
619
  : cleanPrompt)
579
620
  // Explicit string => use as-is via formatWorktreeName (no vowel strip).
580
621
  // Boolean true => derived from thread/prompt, compress via formatAutoWorktreeName.
622
+ // When no --worktree flag but channel has worktrees enabled via toggle,
623
+ // the bot-side ThreadCreate handler auto-creates the worktree. We add
624
+ // the prefix here for cosmetic consistency (thread name shows 🌳).
625
+ const channelWorktreesEnabled =
626
+ !options.worktree && !options.cwd && !notifyOnly && projectDirectory
627
+ ? (await getChannelWorktreesEnabled(channelId)) &&
628
+ (await isGitRepositoryRoot(projectDirectory))
629
+ : false
581
630
  const worktreeName = options.worktree
582
631
  ? typeof options.worktree === 'string'
583
632
  ? formatWorktreeName(options.worktree)
584
633
  : formatAutoWorktreeName(baseThreadName)
585
634
  : undefined
586
- const threadName = worktreeName
587
- ? `${WORKTREE_PREFIX}${baseThreadName}`
588
- : baseThreadName
635
+ const threadName =
636
+ worktreeName || channelWorktreesEnabled
637
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
638
+ : baseThreadName
589
639
 
590
640
  if (parsedSchedule) {
591
641
  const payload: ScheduledTaskPayload = {
@@ -654,6 +704,7 @@ cli
654
704
  embeds: autoStartEmbed,
655
705
  rest,
656
706
  splitInsteadOfAttach: notifyOnly,
707
+ files: filePaths,
657
708
  })
658
709
 
659
710
  // For notify-only on non-project channels, just post the message without
package/src/cli-runner.ts CHANGED
@@ -214,6 +214,7 @@ export async function sendDiscordMessageWithOptionalAttachment({
214
214
  embeds,
215
215
  rest,
216
216
  splitInsteadOfAttach,
217
+ files,
217
218
  }: {
218
219
  channelId: string
219
220
  prompt: string
@@ -224,8 +225,88 @@ export async function sendDiscordMessageWithOptionalAttachment({
224
225
  * being attached as a file. Useful for notify-only messages where the content
225
226
  * should be directly visible in the channel. */
226
227
  splitInsteadOfAttach?: boolean
228
+ /** Local file paths to attach to the message (images, text files, etc.). */
229
+ files?: string[]
227
230
  }): Promise<{ id: string }> {
228
231
  const discordMaxLength = 2000
232
+
233
+ // When files are provided, always use multipart FormData upload
234
+ if (files?.length) {
235
+ const { DISCORD_DEFAULT_MAX_FILE_SIZE } = await import('./discord-utils.js')
236
+ const { default: mime } = await import('mime')
237
+
238
+ for (const file of files) {
239
+ const stat = fs.statSync(file)
240
+ if (stat.size > DISCORD_DEFAULT_MAX_FILE_SIZE) {
241
+ const fileMB = (stat.size / 1024 / 1024).toFixed(1)
242
+ const limitMB = (DISCORD_DEFAULT_MAX_FILE_SIZE / 1024 / 1024).toFixed(0)
243
+ throw new Error(
244
+ `File "${path.basename(file)}" is ${fileMB} MB, which exceeds Discord's ${limitMB} MB upload limit`,
245
+ )
246
+ }
247
+ }
248
+
249
+ // When prompt exceeds Discord's limit, attach it as prompt.md alongside
250
+ // user files so nothing is silently lost.
251
+ const isLongPrompt = prompt.length > discordMaxLength
252
+ const content = isLongPrompt
253
+ ? `Prompt attached as file (${prompt.length} chars)\n\n> ${prompt.slice(0, 100).replace(/\n/g, ' ')}...`
254
+ : prompt
255
+
256
+ // Build attachment metadata: user files + optional prompt.md
257
+ const allFiles: Array<{ filePath: string; filename: string; mimeType: string }> = files.map((file) => ({
258
+ filePath: file,
259
+ filename: path.basename(file),
260
+ mimeType: mime.getType(file) || 'application/octet-stream',
261
+ }))
262
+
263
+ if (isLongPrompt) {
264
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-prompt-'))
265
+ const tmpFile = path.join(tmpDir, 'prompt.md')
266
+ fs.writeFileSync(tmpFile, prompt)
267
+ allFiles.push({ filePath: tmpFile, filename: 'prompt.md', mimeType: 'text/markdown' })
268
+ }
269
+
270
+ const attachments = allFiles.map((f, index) => ({
271
+ id: index,
272
+ filename: f.filename,
273
+ }))
274
+
275
+ const formData = new FormData()
276
+ formData.append(
277
+ 'payload_json',
278
+ JSON.stringify({
279
+ content,
280
+ attachments,
281
+ embeds,
282
+ allowed_mentions: { parse: store.getState().allowedMentions },
283
+ }),
284
+ )
285
+
286
+ for (const [index, f] of allFiles.entries()) {
287
+ const buffer = fs.readFileSync(f.filePath)
288
+ formData.append(
289
+ `files[${index}]`,
290
+ new Blob([buffer], { type: f.mimeType }),
291
+ f.filename,
292
+ )
293
+ }
294
+
295
+ const response = await fetch(
296
+ discordApiUrl(`/channels/${channelId}/messages`),
297
+ {
298
+ method: 'POST',
299
+ headers: { Authorization: `Bot ${botToken}` },
300
+ body: formData,
301
+ },
302
+ )
303
+ if (!response.ok) {
304
+ const error = await response.text()
305
+ throw new Error(`Discord API error: ${response.status} - ${error}`)
306
+ }
307
+ return (await response.json()) as { id: string }
308
+ }
309
+
229
310
  if (prompt.length <= discordMaxLength) {
230
311
  return (await rest.post(Routes.channelMessages(channelId), {
231
312
  body: {
@@ -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
@@ -115,7 +118,7 @@ export async function forkSessionToBtwThread({
115
118
  threadId: thread.id,
116
119
  thread,
117
120
  projectDirectory,
118
- sdkDirectory: projectDirectory,
121
+ sdkDirectory,
119
122
  channelId,
120
123
  appId,
121
124
  })
@@ -176,7 +179,7 @@ export async function handleBtwCommand({
176
179
  return
177
180
  }
178
181
 
179
- const { projectDirectory } = resolved
182
+ const { projectDirectory, workingDirectory } = resolved
180
183
 
181
184
  await command.deferReply({ flags: MessageFlags.Ephemeral })
182
185
 
@@ -184,6 +187,7 @@ export async function handleBtwCommand({
184
187
  const result = await forkSessionToBtwThread({
185
188
  sourceThread: threadChannel,
186
189
  projectDirectory,
190
+ sdkDirectory: workingDirectory,
187
191
  prompt,
188
192
  userId: command.user.id,
189
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({
@@ -89,16 +89,24 @@ function buildCancelCell(task: ScheduledTask): string {
89
89
  // so 7 rows stays under the budget with separators.
90
90
  const MAX_TASK_ROWS = 7
91
91
 
92
+ function formatChannelCell(task: ScheduledTask): string {
93
+ if (!task.channel_id) {
94
+ return '-'
95
+ }
96
+ return `<#${task.channel_id}>`
97
+ }
98
+
92
99
  function buildTaskTable({
93
100
  tasks,
94
101
  }: {
95
102
  tasks: ScheduledTask[]
96
103
  }): string {
97
- const header = '| ID | Status | Prompt | Schedule | Next Run | Run | Delete |'
98
- const separator = '|---|---|---|---|---|---|---|'
104
+ const header = '| ID | Status | Channel | Prompt | Schedule | Next Run | Run | Delete |'
105
+ const separator = '|---|---|---|---|---|---|---|---|'
99
106
  const rows = tasks.map((task) => {
100
107
  const id = String(task.id)
101
108
  const status = task.status
109
+ const channel = formatChannelCell(task)
102
110
  const prompt = sanitizeTableCell(
103
111
  task.prompt_preview.length > 240
104
112
  ? task.prompt_preview.slice(0, 237) + '...'
@@ -117,7 +125,7 @@ function buildTaskTable({
117
125
  })()
118
126
  const run = buildRunCell(task)
119
127
  const cancel = buildCancelCell(task)
120
- return `| ${id} | ${status} | ${prompt} | ${schedule} | ${nextRun} | ${run} | ${cancel} |`
128
+ return `| ${id} | ${status} | ${channel} | ${prompt} | ${schedule} | ${nextRun} | ${run} | ${cancel} |`
121
129
  })
122
130
  return [header, separator, ...rows].join('\n')
123
131
  }
@@ -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
  })