kimaki 0.21.0 → 0.23.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 (63) hide show
  1. package/dist/anthropic-auth-plugin.js +78 -33
  2. package/dist/anthropic-auth-state.js +62 -27
  3. package/dist/anthropic-auth-state.test.js +55 -2
  4. package/dist/channel-management.js +29 -6
  5. package/dist/cli-commands/project.js +87 -15
  6. package/dist/cli-commands/send.js +43 -3
  7. package/dist/cli-runner.js +57 -1
  8. package/dist/commands/btw.js +4 -3
  9. package/dist/commands/login.js +1 -1
  10. package/dist/commands/new-worktree.js +23 -5
  11. package/dist/commands/queue.js +12 -2
  12. package/dist/commands/session.js +66 -13
  13. package/dist/commands/tasks.js +10 -3
  14. package/dist/commands/user-command.js +3 -2
  15. package/dist/database.js +7 -8
  16. package/dist/db.js +1 -0
  17. package/dist/discord-bot.js +47 -8
  18. package/dist/hrana-server.js +19 -0
  19. package/dist/message-preprocessing.js +9 -2
  20. package/dist/oauth-rotation-shared.js +21 -0
  21. package/dist/opencode.js +73 -4
  22. package/dist/schema.js +3 -0
  23. package/dist/session-handler/global-event-listener.js +38 -6
  24. package/dist/session-handler/thread-runtime-state.js +2 -0
  25. package/dist/session-handler/thread-session-runtime.js +19 -3
  26. package/dist/system-message.js +52 -26
  27. package/dist/system-message.test.js +49 -26
  28. package/dist/task-runner.js +8 -4
  29. package/dist/worktree-lifecycle.e2e.test.js +84 -1
  30. package/dist/worktrees.js +63 -0
  31. package/package.json +3 -3
  32. package/skills/holocron/SKILL.md +34 -8
  33. package/src/anthropic-auth-plugin.ts +82 -35
  34. package/src/anthropic-auth-state.test.ts +73 -1
  35. package/src/anthropic-auth-state.ts +85 -25
  36. package/src/channel-management.ts +35 -6
  37. package/src/cli-commands/project.ts +105 -17
  38. package/src/cli-commands/send.ts +56 -5
  39. package/src/cli-runner.ts +81 -0
  40. package/src/commands/btw.ts +6 -2
  41. package/src/commands/login.ts +1 -1
  42. package/src/commands/new-worktree.ts +28 -4
  43. package/src/commands/queue.ts +13 -2
  44. package/src/commands/session.ts +79 -17
  45. package/src/commands/tasks.ts +11 -3
  46. package/src/commands/user-command.ts +3 -2
  47. package/src/database.ts +8 -9
  48. package/src/db.ts +1 -0
  49. package/src/discord-bot.ts +54 -7
  50. package/src/hrana-server.ts +19 -0
  51. package/src/message-preprocessing.ts +11 -2
  52. package/src/oauth-rotation-shared.ts +22 -0
  53. package/src/opencode.ts +90 -6
  54. package/src/schema.sql +1 -0
  55. package/src/schema.ts +3 -0
  56. package/src/session-handler/global-event-listener.ts +38 -6
  57. package/src/session-handler/thread-runtime-state.ts +4 -1
  58. package/src/session-handler/thread-session-runtime.ts +25 -3
  59. package/src/system-message.test.ts +49 -26
  60. package/src/system-message.ts +52 -26
  61. package/src/task-runner.ts +8 -4
  62. package/src/worktree-lifecycle.e2e.test.ts +104 -1
  63. package/src/worktrees.ts +74 -0
@@ -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,
@@ -843,7 +843,7 @@ async function startOAuthFlow(interaction, ctx, hash) {
843
843
  // completing login in a browser (possibly on a different machine).
844
844
  const button = new ButtonBuilder()
845
845
  .setCustomId(`login_oauth_code_btn:${hash}`)
846
- .setLabel('Paste authorization code')
846
+ .setLabel('Paste authorization code or callback url')
847
847
  .setStyle(ButtonStyle.Primary);
848
848
  await interaction.editReply({
849
849
  content: message,
@@ -4,11 +4,11 @@
4
4
  import { ChannelType, REST, } from 'discord.js';
5
5
  import fs from 'node:fs';
6
6
  import { OpenCodeSdkError } from '../errors.js';
7
- import { createPendingWorkspace, setWorkspaceReady, setWorkspaceError, getChannelDirectory, getThreadSession, setThreadSession, } from '../database.js';
7
+ import { createPendingWorkspace, setWorkspaceReady, setWorkspaceError, getChannelDirectory, getThreadSession, getThreadWorktreeOrWorkspace, setThreadSession, } from '../database.js';
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) {
@@ -161,8 +173,8 @@ async function tryWorkspaceCreate({ threadId, worktreeName, projectDirectory, ba
161
173
  return new Error(`Workspace creation failed: ${JSON.stringify(response.error)}`);
162
174
  }
163
175
  const workspace = response.data;
164
- if (!workspace?.directory) {
165
- return new Error('Workspace SDK returned no directory');
176
+ if (!workspace?.directory || !workspace.id) {
177
+ return new Error('Workspace SDK returned no directory or ID');
166
178
  }
167
179
  return { directory: workspace.directory, workspaceId: workspace.id };
168
180
  }
@@ -439,6 +451,11 @@ async function handleWorktreeInThread({ command, thread, appId, }) {
439
451
  await sendThreadMessage(worktreeThread, 'Worktree is ready. Send a message here to start a fresh session in this checkout.');
440
452
  return;
441
453
  }
454
+ const workspace = await getThreadWorktreeOrWorkspace(worktreeThread.id);
455
+ if (!workspace?.workspace_id) {
456
+ await sendThreadMessage(worktreeThread, '✗ Worktree is ready, but OpenCode returned no workspace ID for context reuse.');
457
+ return;
458
+ }
442
459
  const getClient = await initializeOpencodeForDirectory(result, {
443
460
  originalRepoDirectory: projectDirectory,
444
461
  channelId: parent.id,
@@ -450,6 +467,7 @@ async function handleWorktreeInThread({ command, thread, appId, }) {
450
467
  const forkResponse = await getClient().session.fork({
451
468
  sessionID: sourceSessionId,
452
469
  directory: result,
470
+ workspace: workspace.workspace_id,
453
471
  }).catch((e) => new OpenCodeSdkError({ operation: 'session.fork', cause: e }));
454
472
  if (forkResponse instanceof Error) {
455
473
  logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', forkResponse);
@@ -117,9 +117,19 @@ export async function handleClearQueueCommand({ command, }) {
117
117
  logger.log(`[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`);
118
118
  return;
119
119
  }
120
- runtime?.clearQueue();
120
+ const cleared = runtime?.clearQueue() ?? [];
121
+ const lines = cleared.map((item, i) => {
122
+ const label = item.command
123
+ ? `/${item.command.name}`
124
+ : item.prompt;
125
+ return `${i + 1}. ${label}`;
126
+ });
127
+ let list = lines.join('\n');
128
+ if (list.length > 600) {
129
+ list = list.slice(0, 597) + '...';
130
+ }
121
131
  await command.reply({
122
- content: `Cleared ${queueLength} queued message${queueLength > 1 ? 's' : ''}`,
132
+ content: `Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`,
123
133
  flags: SILENT_MESSAGE_FLAGS,
124
134
  });
125
135
  logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`);
@@ -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
  });
package/dist/database.js CHANGED
@@ -103,10 +103,9 @@ export async function recoverStaleRunningScheduledTasks({ staleBefore }) {
103
103
  .returning({ id: schema.scheduled_tasks.id });
104
104
  return countRows(rows);
105
105
  }
106
- export async function markScheduledTaskOneShotCompleted({ taskId, completedAt }) {
106
+ export async function deleteScheduledTask(taskId) {
107
107
  const db = await getDb();
108
- await db.update(schema.scheduled_tasks)
109
- .set({ status: 'completed', last_run_at: completedAt, running_started_at: null, last_error: null })
108
+ await db.delete(schema.scheduled_tasks)
110
109
  .where(orm.eq(schema.scheduled_tasks.id, taskId));
111
110
  }
112
111
  export async function markScheduledTaskCronRescheduled({ taskId, completedAt, nextRunAt }) {
@@ -584,17 +583,17 @@ export async function getAnyAudioApiKey() {
584
583
  return { provider: 'gemini', apiKey: row.gemini_api_key, appId: row.app_id };
585
584
  return null;
586
585
  }
587
- export async function setChannelDirectory({ channelId, directory, channelType, skipIfExists = false }) {
586
+ export async function setChannelDirectory({ channelId, directory, channelType, guildId, skipIfExists = false }) {
588
587
  const db = await getDb();
589
588
  if (skipIfExists) {
590
589
  await db.insert(schema.channel_directories)
591
- .values({ channel_id: channelId, directory, channel_type: channelType })
590
+ .values({ channel_id: channelId, directory, channel_type: channelType, guild_id: guildId })
592
591
  .onConflictDoNothing({ target: schema.channel_directories.channel_id });
593
592
  return;
594
593
  }
595
594
  await db.insert(schema.channel_directories)
596
- .values({ channel_id: channelId, directory, channel_type: channelType })
597
- .onConflictDoUpdate({ target: schema.channel_directories.channel_id, set: { directory, channel_type: channelType } });
595
+ .values({ channel_id: channelId, directory, channel_type: channelType, guild_id: guildId })
596
+ .onConflictDoUpdate({ target: schema.channel_directories.channel_id, set: { directory, channel_type: channelType, guild_id: guildId } });
598
597
  }
599
598
  export async function findChannelsByDirectory({ directory, channelType }) {
600
599
  const db = await getDb();
@@ -605,7 +604,7 @@ export async function findChannelsByDirectory({ directory, channelType }) {
605
604
  : channelType
606
605
  ? { channel_type: channelType }
607
606
  : undefined;
608
- return db.query.channel_directories.findMany({ where, columns: { channel_id: true, directory: true, channel_type: true } });
607
+ return db.query.channel_directories.findMany({ where, columns: { channel_id: true, directory: true, channel_type: true, guild_id: true } });
609
608
  }
610
609
  export async function getAllTextChannelDirectories() {
611
610
  const db = await getDb();
package/dist/db.js CHANGED
@@ -120,6 +120,7 @@ async function migrateSchema({ db, client, }) {
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
122
  'ALTER TABLE thread_sessions ADD COLUMN parent_session_id TEXT',
123
+ 'ALTER TABLE channel_directories ADD COLUMN guild_id TEXT',
123
124
  ];
124
125
  for (const stmt of alterStatements) {
125
126
  await client.execute(stmt).catch(() => undefined);