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.
- package/dist/cli-commands/project.js +82 -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/new-worktree.js +14 -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/discord-bot.js +38 -8
- package/dist/message-preprocessing.js +9 -2
- package/dist/system-message.js +20 -8
- package/dist/system-message.test.js +17 -8
- package/dist/worktree-lifecycle.e2e.test.js +70 -1
- package/dist/worktrees.js +63 -0
- package/package.json +5 -5
- package/skills/holocron/SKILL.md +23 -0
- package/src/cli-commands/project.ts +100 -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/new-worktree.ts +14 -1
- package/src/commands/session.ts +79 -17
- package/src/commands/tasks.ts +11 -3
- package/src/commands/user-command.ts +3 -2
- package/src/discord-bot.ts +42 -7
- package/src/message-preprocessing.ts +11 -2
- package/src/system-message.test.ts +17 -8
- package/src/system-message.ts +20 -8
- package/src/worktree-lifecycle.e2e.test.ts +88 -1
- package/src/worktrees.ts +74 -0
|
@@ -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
|
|
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
|
-
|
|
209
|
-
|
|
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.
|
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,
|
|
@@ -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
|
-
|
|
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) {
|
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
|
});
|