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.
@@ -98,6 +98,8 @@ cli
98
98
  cli
99
99
  .command('project list', 'List all registered projects with their Discord channels')
100
100
  .option('--json', 'Output as JSON')
101
+ .option('--all', 'Include remote projects from other machines (scans Kimaki category in Discord)')
102
+ .option('-g, --guild <guildId>', 'Discord guild/server ID to scan (used with --all when no local projects exist)')
101
103
  .option('--prune', 'Remove stale entries whose Discord channel no longer exists')
102
104
  .action(async (options) => {
103
105
  await initDatabase();
@@ -106,13 +108,10 @@ cli
106
108
  where: { channel_type: 'text' },
107
109
  orderBy: { created_at: 'desc' },
108
110
  });
109
- if (channels.length === 0) {
110
- cliLogger.log('No projects registered');
111
- process.exit(0);
112
- }
113
111
  // Fetch Discord channel names and guild IDs via REST API
114
112
  const botRow = await getBotTokenWithMode();
115
113
  const rest = botRow ? createDiscordRest(botRow.token) : null;
114
+ const localChannelIds = new Set(channels.map((ch) => ch.channel_id));
116
115
  const enriched = await Promise.all(channels.map(async (ch) => {
117
116
  let channelName = '';
118
117
  let guildId = '';
@@ -132,12 +131,17 @@ cli
132
131
  deleted = isUnknownChannel;
133
132
  }
134
133
  }
135
- return { ...ch, channelName, guildId, deleted };
134
+ return { ...ch, channelName, guildId, deleted, isLocal: true };
136
135
  }));
137
136
  // Fetch guild names for unique guild IDs (deduplicated to save API calls)
138
137
  const guildNameMap = new Map();
138
+ // Collect guild IDs from local channels + explicit --guild flag
139
+ const guildIdsFromChannels = enriched.map((ch) => ch.guildId).filter(Boolean);
140
+ if (options.guild) {
141
+ guildIdsFromChannels.push(options.guild);
142
+ }
143
+ const uniqueGuildIds = [...new Set(guildIdsFromChannels)];
139
144
  if (rest) {
140
- const uniqueGuildIds = [...new Set(enriched.map((ch) => ch.guildId).filter(Boolean))];
141
145
  await Promise.all(uniqueGuildIds.map(async (guildId) => {
142
146
  try {
143
147
  const data = (await rest.get(Routes.guild(guildId)));
@@ -148,15 +152,67 @@ cli
148
152
  }
149
153
  }));
150
154
  }
155
+ // When --all is passed, scan each guild's channels to find Kimaki category
156
+ // text channels not in our local DB (projects from other machines).
157
+ // Fail explicitly when prerequisites are missing so the user doesn't
158
+ // confuse "scan never ran" with "no remote projects found".
159
+ let remoteEntries = [];
160
+ if (options.all) {
161
+ if (!rest) {
162
+ cliLogger.error('Discord credentials are required to scan remote projects. Run `kimaki` first.');
163
+ process.exit(EXIT_NO_RESTART);
164
+ }
165
+ if (uniqueGuildIds.length === 0) {
166
+ cliLogger.error('Cannot determine which Discord server to scan. Pass `--guild <guildId>` or register a local project first.');
167
+ process.exit(EXIT_NO_RESTART);
168
+ }
169
+ let guildScanFailures = 0;
170
+ for (const guildId of uniqueGuildIds) {
171
+ try {
172
+ const guildChannels = (await rest.get(Routes.guildChannels(guildId)));
173
+ // Find Kimaki category channels (type 4 = GuildCategory)
174
+ const kimakiCategoryIds = new Set(guildChannels
175
+ .filter((ch) => ch.type === 4 && /^kimaki(\s|$)/i.test(ch.name))
176
+ .map((ch) => ch.id));
177
+ // Find text channels (type 0) in Kimaki categories that are not in our local DB
178
+ for (const ch of guildChannels) {
179
+ if (ch.type === 0 &&
180
+ ch.parent_id &&
181
+ kimakiCategoryIds.has(ch.parent_id) &&
182
+ !localChannelIds.has(ch.id)) {
183
+ remoteEntries.push({
184
+ channel_id: ch.id,
185
+ directory: '',
186
+ channel_type: 'text',
187
+ created_at: null,
188
+ channelName: ch.name,
189
+ guildId,
190
+ deleted: false,
191
+ isLocal: false,
192
+ });
193
+ }
194
+ }
195
+ }
196
+ catch (error) {
197
+ guildScanFailures++;
198
+ cliLogger.warn(`Failed to scan guild ${guildNameMap.get(guildId) || guildId}: ${error instanceof Error ? error.message : String(error)}`);
199
+ }
200
+ }
201
+ if (guildScanFailures === uniqueGuildIds.length) {
202
+ cliLogger.error('Failed to scan all guilds. Check bot permissions or try again.');
203
+ process.exit(EXIT_NO_RESTART);
204
+ }
205
+ }
151
206
  // Build final enriched entries with guild names resolved
152
- const enrichedWithGuild = enriched.map((ch) => ({
207
+ const allEntries = [...enriched, ...remoteEntries];
208
+ const enrichedWithGuild = allEntries.map((ch) => ({
153
209
  ...ch,
154
210
  guildName: ch.guildId ? (guildNameMap.get(ch.guildId) || '') : '',
155
211
  }));
156
212
  // Warn on stderr if the same directory appears in multiple channels (multi-guild duplicates)
157
213
  const directoryCounts = new Map();
158
214
  for (const ch of enrichedWithGuild) {
159
- if (!ch.deleted) {
215
+ if (!ch.deleted && ch.directory) {
160
216
  directoryCounts.set(ch.directory, (directoryCounts.get(ch.directory) || 0) + 1);
161
217
  }
162
218
  }
@@ -168,7 +224,7 @@ cli
168
224
  // Prune stale entries if requested
169
225
  let finalEntries = enrichedWithGuild;
170
226
  if (options.prune) {
171
- const stale = finalEntries.filter((ch) => ch.deleted);
227
+ const stale = finalEntries.filter((ch) => ch.deleted && ch.isLocal);
172
228
  if (stale.length === 0) {
173
229
  cliLogger.log('No stale channels to prune');
174
230
  }
@@ -185,28 +241,39 @@ cli
185
241
  process.exit(0);
186
242
  }
187
243
  }
244
+ if (finalEntries.length === 0) {
245
+ cliLogger.log('No projects registered');
246
+ process.exit(0);
247
+ }
188
248
  if (options.json) {
189
249
  const output = finalEntries.map((ch) => ({
190
250
  channel_id: ch.channel_id,
191
251
  channel_name: ch.channelName,
192
252
  guild_id: ch.guildId,
193
253
  guild_name: ch.guildName,
194
- directory: ch.directory,
195
- folder_name: path.basename(ch.directory),
254
+ directory: ch.directory || null,
255
+ folder_name: ch.directory ? path.basename(ch.directory) : null,
196
256
  deleted: ch.deleted,
257
+ is_local: ch.isLocal,
197
258
  }));
198
259
  console.log(JSON.stringify(output, null, 2));
199
260
  process.exit(0);
200
261
  }
201
262
  for (const ch of finalEntries) {
202
- const folderName = path.basename(ch.directory);
203
263
  const deletedTag = ch.deleted ? ' (deleted from Discord)' : '';
264
+ const remoteTag = !ch.isLocal ? ' [remote]' : '';
204
265
  const channelLabel = ch.channelName ? `#${ch.channelName}` : ch.channel_id;
205
266
  const guildLabel = ch.guildName || ch.guildId || '';
206
267
  const guildSuffix = guildLabel ? ` (${guildLabel})` : '';
207
- console.log(`\n${channelLabel}${guildSuffix}${deletedTag}`);
208
- console.log(` Folder: ${folderName}`);
209
- console.log(` Directory: ${ch.directory}`);
268
+ console.log(`\n${channelLabel}${guildSuffix}${deletedTag}${remoteTag}`);
269
+ if (ch.isLocal && ch.directory) {
270
+ const folderName = path.basename(ch.directory);
271
+ console.log(` Folder: ${folderName}`);
272
+ console.log(` Directory: ${ch.directory}`);
273
+ }
274
+ else if (!ch.isLocal) {
275
+ console.log(` (Not registered on this machine)`);
276
+ }
210
277
  console.log(` Channel ID: ${ch.channel_id}`);
211
278
  if (ch.guildId) {
212
279
  console.log(` Guild ID: ${ch.guildId}`);
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url';
15
15
  import { spawn, execSync } from 'node:child_process';
16
16
  import { createLogger, LogPrefix, initLogFile } from '../logger.js';
17
17
  import { createDiscordClient, initDatabase, getChannelDirectory, initializeOpencodeForDirectory, createProjectChannels } from '../discord-bot.js';
18
- import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory } from '../database.js';
18
+ import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, getChannelWorktreesEnabled } from '../database.js';
19
19
  import { ShareMarkdown } from '../markdown.js';
20
20
  import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js';
21
21
  import { formatWorktreeName, formatAutoWorktreeName } from '../commands/new-worktree.js';
@@ -24,7 +24,7 @@ import { buildOpencodeEventLogLine } from '../session-handler/opencode-session-e
24
24
  import { createDiscordRest } from '../discord-urls.js';
25
25
  import { archiveThread, uploadFilesToDiscord, stripMentions } from '../discord-utils.js';
26
26
  import { setDataDir, setProjectsDir, getDataDir, getProjectsDir } from '../config.js';
27
- import { execAsync, resolveSessionWorkingDirectory } from '../worktrees.js';
27
+ import { execAsync, resolveSessionWorkingDirectory, isGitRepositoryRoot } from '../worktrees.js';
28
28
  import { upgrade, getCurrentVersion } from '../upgrade.js';
29
29
  import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload } from '../task-schedule.js';
30
30
  import { EXIT_NO_RESTART, formatMemberLookupUnavailableMessage, formatRelativeTime, formatTaskScheduleLine, isDiscordMemberLookupUnavailable, isGuildMemberSearchResult, isThreadChannelType, printDiscordInstallUrlAndExit, resolveBotCredentials, resolveDiscordUserOption, sendDiscordMessageWithOptionalAttachment, } from '../cli-runner.js';
@@ -48,6 +48,8 @@ cli
48
48
  'Actions: allow, deny, ask. Examples: --permission "bash:deny" --permission "edit:deny"'))
49
49
  .option('--injection-guard <pattern>', z.array(z.string()).describe('Injection guard scan pattern (repeatable). Enables prompt injection detection for this session. ' +
50
50
  'Format: "tool:argsGlob". Examples: --injection-guard "bash:*" --injection-guard "webfetch:*"'))
51
+ .option('-f, --file <path>', z.array(z.string()).describe('Local file to attach (repeatable). Images, text files, PDFs, etc. ' +
52
+ 'Examples: --file screenshot.png --file report.pdf'))
51
53
  .option('--send-at <schedule>', 'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)')
52
54
  .option('--thread <threadId>', 'Post prompt to an existing thread')
53
55
  .option('--session <sessionId>', 'Post prompt to thread mapped to an existing session')
@@ -80,16 +82,45 @@ cli
80
82
  cliLogger.error('Prompt is required. Use --prompt <prompt>');
81
83
  process.exit(EXIT_NO_RESTART);
82
84
  }
85
+ const filePaths = options.file?.length
86
+ ? options.file.map((f) => path.resolve(f))
87
+ : undefined;
83
88
  if (sendAt) {
84
89
  if (options.wait) {
85
90
  cliLogger.error('Cannot use --wait with --send-at');
86
91
  process.exit(EXIT_NO_RESTART);
87
92
  }
93
+ if (filePaths?.length) {
94
+ cliLogger.error('Cannot use --file with --send-at');
95
+ process.exit(EXIT_NO_RESTART);
96
+ }
88
97
  if (prompt.length > 1900) {
89
98
  cliLogger.error('--send-at currently supports prompts up to 1900 characters');
90
99
  process.exit(EXIT_NO_RESTART);
91
100
  }
92
101
  }
102
+ // Validate all --file paths exist and are regular files
103
+ if (filePaths?.length) {
104
+ // Discord allows max 10 attachments per message. Long prompts also
105
+ // consume one slot (prompt.md), so reserve space for that.
106
+ const maxUserFiles = prompt.length > 2000 ? 9 : 10;
107
+ if (filePaths.length > maxUserFiles) {
108
+ cliLogger.error(`Too many files: ${filePaths.length} provided, Discord allows at most ${maxUserFiles} attachments per message` +
109
+ (maxUserFiles === 9 ? ' (1 slot reserved for long prompt)' : ''));
110
+ process.exit(EXIT_NO_RESTART);
111
+ }
112
+ for (const file of filePaths) {
113
+ if (!fs.existsSync(file)) {
114
+ cliLogger.error(`File not found: ${file}`);
115
+ process.exit(EXIT_NO_RESTART);
116
+ }
117
+ const stat = fs.statSync(file);
118
+ if (!stat.isFile()) {
119
+ cliLogger.error(`Not a regular file: ${file}`);
120
+ process.exit(EXIT_NO_RESTART);
121
+ }
122
+ }
123
+ }
93
124
  const parsedSchedule = (() => {
94
125
  if (!sendAt) {
95
126
  return null;
@@ -345,6 +376,7 @@ cli
345
376
  botToken,
346
377
  embeds: promptEmbed,
347
378
  rest,
379
+ files: filePaths,
348
380
  });
349
381
  const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`;
350
382
  const existingSessionId = sessionId || await getThreadSession(targetThreadId);
@@ -418,12 +450,19 @@ cli
418
450
  : cleanPrompt);
419
451
  // Explicit string => use as-is via formatWorktreeName (no vowel strip).
420
452
  // Boolean true => derived from thread/prompt, compress via formatAutoWorktreeName.
453
+ // When no --worktree flag but channel has worktrees enabled via toggle,
454
+ // the bot-side ThreadCreate handler auto-creates the worktree. We add
455
+ // the prefix here for cosmetic consistency (thread name shows 🌳).
456
+ const channelWorktreesEnabled = !options.worktree && !options.cwd && !notifyOnly && projectDirectory
457
+ ? (await getChannelWorktreesEnabled(channelId)) &&
458
+ (await isGitRepositoryRoot(projectDirectory))
459
+ : false;
421
460
  const worktreeName = options.worktree
422
461
  ? typeof options.worktree === 'string'
423
462
  ? formatWorktreeName(options.worktree)
424
463
  : formatAutoWorktreeName(baseThreadName)
425
464
  : undefined;
426
- const threadName = worktreeName
465
+ const threadName = worktreeName || channelWorktreesEnabled
427
466
  ? `${WORKTREE_PREFIX}${baseThreadName}`
428
467
  : baseThreadName;
429
468
  if (parsedSchedule) {
@@ -487,6 +526,7 @@ cli
487
526
  embeds: autoStartEmbed,
488
527
  rest,
489
528
  splitInsteadOfAttach: notifyOnly,
529
+ files: filePaths,
490
530
  });
491
531
  // For notify-only on non-project channels, just post the message without
492
532
  // creating a thread. There's no session to start, so a thread is unnecessary.
@@ -135,8 +135,64 @@ export function isThreadChannelType(type) {
135
135
  ChannelType.AnnouncementThread,
136
136
  ].includes(type);
137
137
  }
138
- export async function sendDiscordMessageWithOptionalAttachment({ channelId, prompt, botToken, embeds, rest, splitInsteadOfAttach, }) {
138
+ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prompt, botToken, embeds, rest, splitInsteadOfAttach, files, }) {
139
139
  const discordMaxLength = 2000;
140
+ // When files are provided, always use multipart FormData upload
141
+ if (files?.length) {
142
+ const { DISCORD_DEFAULT_MAX_FILE_SIZE } = await import('./discord-utils.js');
143
+ const { default: mime } = await import('mime');
144
+ for (const file of files) {
145
+ const stat = fs.statSync(file);
146
+ if (stat.size > DISCORD_DEFAULT_MAX_FILE_SIZE) {
147
+ const fileMB = (stat.size / 1024 / 1024).toFixed(1);
148
+ const limitMB = (DISCORD_DEFAULT_MAX_FILE_SIZE / 1024 / 1024).toFixed(0);
149
+ throw new Error(`File "${path.basename(file)}" is ${fileMB} MB, which exceeds Discord's ${limitMB} MB upload limit`);
150
+ }
151
+ }
152
+ // When prompt exceeds Discord's limit, attach it as prompt.md alongside
153
+ // user files so nothing is silently lost.
154
+ const isLongPrompt = prompt.length > discordMaxLength;
155
+ const content = isLongPrompt
156
+ ? `Prompt attached as file (${prompt.length} chars)\n\n> ${prompt.slice(0, 100).replace(/\n/g, ' ')}...`
157
+ : prompt;
158
+ // Build attachment metadata: user files + optional prompt.md
159
+ const allFiles = files.map((file) => ({
160
+ filePath: file,
161
+ filename: path.basename(file),
162
+ mimeType: mime.getType(file) || 'application/octet-stream',
163
+ }));
164
+ if (isLongPrompt) {
165
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-prompt-'));
166
+ const tmpFile = path.join(tmpDir, 'prompt.md');
167
+ fs.writeFileSync(tmpFile, prompt);
168
+ allFiles.push({ filePath: tmpFile, filename: 'prompt.md', mimeType: 'text/markdown' });
169
+ }
170
+ const attachments = allFiles.map((f, index) => ({
171
+ id: index,
172
+ filename: f.filename,
173
+ }));
174
+ const formData = new FormData();
175
+ formData.append('payload_json', JSON.stringify({
176
+ content,
177
+ attachments,
178
+ embeds,
179
+ allowed_mentions: { parse: store.getState().allowedMentions },
180
+ }));
181
+ for (const [index, f] of allFiles.entries()) {
182
+ const buffer = fs.readFileSync(f.filePath);
183
+ formData.append(`files[${index}]`, new Blob([buffer], { type: f.mimeType }), f.filename);
184
+ }
185
+ const response = await fetch(discordApiUrl(`/channels/${channelId}/messages`), {
186
+ method: 'POST',
187
+ headers: { Authorization: `Bot ${botToken}` },
188
+ body: formData,
189
+ });
190
+ if (!response.ok) {
191
+ const error = await response.text();
192
+ throw new Error(`Discord API error: ${response.status} - ${error}`);
193
+ }
194
+ return (await response.json());
195
+ }
140
196
  if (prompt.length <= discordMaxLength) {
141
197
  return (await rest.post(Routes.channelMessages(channelId), {
142
198
  body: {
@@ -10,7 +10,7 @@ import { createLogger, LogPrefix } from '../logger.js';
10
10
  import { initializeOpencodeForDirectory } from '../opencode.js';
11
11
  import { copyCurrentSessionModel } from './model.js';
12
12
  const logger = createLogger(LogPrefix.FORK);
13
- export async function forkSessionToBtwThread({ sourceThread, projectDirectory, prompt, userId, username, appId, }) {
13
+ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, sdkDirectory, prompt, userId, username, appId, }) {
14
14
  // Parallelize: session lookup + opencode init + parent channel resolve are independent
15
15
  const [sessionId, getClientResult, textChannel] = await Promise.all([
16
16
  getThreadSession(sourceThread.id),
@@ -74,7 +74,7 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, p
74
74
  threadId: thread.id,
75
75
  thread,
76
76
  projectDirectory,
77
- sdkDirectory: projectDirectory,
77
+ sdkDirectory,
78
78
  channelId,
79
79
  appId,
80
80
  });
@@ -120,12 +120,13 @@ export async function handleBtwCommand({ command, appId, }) {
120
120
  });
121
121
  return;
122
122
  }
123
- const { projectDirectory } = resolved;
123
+ const { projectDirectory, workingDirectory } = resolved;
124
124
  await command.deferReply({ flags: MessageFlags.Ephemeral });
125
125
  try {
126
126
  const result = await forkSessionToBtwThread({
127
127
  sourceThread: threadChannel,
128
128
  projectDirectory,
129
+ sdkDirectory: workingDirectory,
129
130
  prompt,
130
131
  userId: command.user.id,
131
132
  username: command.user.displayName,
@@ -8,7 +8,7 @@ import { createPendingWorkspace, setWorkspaceReady, setWorkspaceError, getChanne
8
8
  import { SILENT_MESSAGE_FLAGS, reactToThread, resolveProjectDirectoryFromAutocomplete, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
9
9
  import { createLogger, LogPrefix } from '../logger.js';
10
10
  import { notifyError } from '../sentry.js';
11
- import { execAsync, listBranchesByLastCommit, validateBranchRef, } from '../worktrees.js';
11
+ import { execAsync, listBranchesByLastCommit, resolveBestBaseRef, validateBranchRef, } from '../worktrees.js';
12
12
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
13
13
  import { buildSessionPermissions, initializeOpencodeForDirectory, } from '../opencode.js';
14
14
  import { WORKTREE_PREFIX } from './merge-worktree.js';
@@ -22,10 +22,22 @@ async function resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch
22
22
  // unpublished commits in the main checkout.
23
23
  return DEFAULT_WORKTREE_BASE_REF;
24
24
  }
25
- return validateBranchRef({
25
+ const validated = await validateBranchRef({
26
26
  directory: projectDirectory,
27
27
  ref: rawBaseBranch,
28
28
  });
29
+ if (validated instanceof Error)
30
+ return validated;
31
+ // Fetch and prefer the remote tracking ref if it's strictly ahead of local.
32
+ // Handles the common case where user says "main" but upstream/main has new commits.
33
+ const bestRef = await resolveBestBaseRef({
34
+ directory: projectDirectory,
35
+ branch: validated,
36
+ });
37
+ if (bestRef !== validated) {
38
+ logger.log(`Base branch resolved: ${validated} → ${bestRef} (remote is ahead)`);
39
+ }
40
+ return bestRef;
29
41
  }
30
42
  /** Status message shown while a worktree is being created. */
31
43
  export function worktreeCreatingMessage(worktreeName) {
@@ -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({
@@ -62,12 +62,19 @@ function buildCancelCell(task) {
62
62
  // Each actionable row is text + action row + up to 2 buttons (~4 components),
63
63
  // so 7 rows stays under the budget with separators.
64
64
  const MAX_TASK_ROWS = 7;
65
+ function formatChannelCell(task) {
66
+ if (!task.channel_id) {
67
+ return '-';
68
+ }
69
+ return `<#${task.channel_id}>`;
70
+ }
65
71
  function buildTaskTable({ tasks, }) {
66
- const header = '| ID | Status | Prompt | Schedule | Next Run | Run | Delete |';
67
- const separator = '|---|---|---|---|---|---|---|';
72
+ const header = '| ID | Status | Channel | Prompt | Schedule | Next Run | Run | Delete |';
73
+ const separator = '|---|---|---|---|---|---|---|---|';
68
74
  const rows = tasks.map((task) => {
69
75
  const id = String(task.id);
70
76
  const status = task.status;
77
+ const channel = formatChannelCell(task);
71
78
  const prompt = sanitizeTableCell(task.prompt_preview.length > 240
72
79
  ? task.prompt_preview.slice(0, 237) + '...'
73
80
  : task.prompt_preview);
@@ -82,7 +89,7 @@ function buildTaskTable({ tasks, }) {
82
89
  })();
83
90
  const run = buildRunCell(task);
84
91
  const cancel = buildCancelCell(task);
85
- return `| ${id} | ${status} | ${prompt} | ${schedule} | ${nextRun} | ${run} | ${cancel} |`;
92
+ return `| ${id} | ${status} | ${channel} | ${prompt} | ${schedule} | ${nextRun} | ${run} | ${cancel} |`;
86
93
  });
87
94
  return [header, separator, ...rows].join('\n');
88
95
  }
@@ -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
  });