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.
- package/dist/anthropic-auth-plugin.js +78 -33
- package/dist/anthropic-auth-state.js +62 -27
- package/dist/anthropic-auth-state.test.js +55 -2
- package/dist/channel-management.js +29 -6
- package/dist/cli-commands/project.js +87 -15
- package/dist/cli-commands/send.js +43 -3
- package/dist/cli-runner.js +57 -1
- package/dist/commands/btw.js +4 -3
- package/dist/commands/login.js +1 -1
- package/dist/commands/new-worktree.js +23 -5
- package/dist/commands/queue.js +12 -2
- package/dist/commands/session.js +66 -13
- package/dist/commands/tasks.js +10 -3
- package/dist/commands/user-command.js +3 -2
- package/dist/database.js +7 -8
- package/dist/db.js +1 -0
- package/dist/discord-bot.js +47 -8
- package/dist/hrana-server.js +19 -0
- package/dist/message-preprocessing.js +9 -2
- package/dist/oauth-rotation-shared.js +21 -0
- package/dist/opencode.js +73 -4
- package/dist/schema.js +3 -0
- package/dist/session-handler/global-event-listener.js +38 -6
- package/dist/session-handler/thread-runtime-state.js +2 -0
- package/dist/session-handler/thread-session-runtime.js +19 -3
- package/dist/system-message.js +52 -26
- package/dist/system-message.test.js +49 -26
- package/dist/task-runner.js +8 -4
- package/dist/worktree-lifecycle.e2e.test.js +84 -1
- package/dist/worktrees.js +63 -0
- package/package.json +3 -3
- package/skills/holocron/SKILL.md +34 -8
- package/src/anthropic-auth-plugin.ts +82 -35
- package/src/anthropic-auth-state.test.ts +73 -1
- package/src/anthropic-auth-state.ts +85 -25
- package/src/channel-management.ts +35 -6
- package/src/cli-commands/project.ts +105 -17
- package/src/cli-commands/send.ts +56 -5
- package/src/cli-runner.ts +81 -0
- package/src/commands/btw.ts +6 -2
- package/src/commands/login.ts +1 -1
- package/src/commands/new-worktree.ts +28 -4
- package/src/commands/queue.ts +13 -2
- package/src/commands/session.ts +79 -17
- package/src/commands/tasks.ts +11 -3
- package/src/commands/user-command.ts +3 -2
- package/src/database.ts +8 -9
- package/src/db.ts +1 -0
- package/src/discord-bot.ts +54 -7
- package/src/hrana-server.ts +19 -0
- package/src/message-preprocessing.ts +11 -2
- package/src/oauth-rotation-shared.ts +22 -0
- package/src/opencode.ts +90 -6
- package/src/schema.sql +1 -0
- package/src/schema.ts +3 -0
- package/src/session-handler/global-event-listener.ts +38 -6
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +25 -3
- package/src/system-message.test.ts +49 -26
- package/src/system-message.ts +52 -26
- package/src/task-runner.ts +8 -4
- package/src/worktree-lifecycle.e2e.test.ts +104 -1
- 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.
|
package/dist/cli-runner.js
CHANGED
|
@@ -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: {
|
package/dist/commands/btw.js
CHANGED
|
@@ -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
|
|
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,
|
package/dist/commands/login.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/commands/queue.js
CHANGED
|
@@ -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 ${
|
|
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}`);
|
package/dist/commands/session.js
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
47
|
-
content:
|
|
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
|
|
63
|
-
channelId:
|
|
115
|
+
sdkDirectory,
|
|
116
|
+
channelId: textChannel.id,
|
|
64
117
|
appId,
|
|
65
118
|
});
|
|
66
119
|
await runtime.enqueueIncoming({
|
package/dist/commands/tasks.js
CHANGED
|
@@ -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
|
|
106
|
+
export async function deleteScheduledTask(taskId) {
|
|
107
107
|
const db = await getDb();
|
|
108
|
-
await db.
|
|
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);
|