kimaki 0.4.43 → 0.4.45
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/channel-management.js +6 -15
- package/dist/cli.js +210 -32
- package/dist/commands/merge-worktree.js +152 -0
- package/dist/commands/permissions.js +21 -5
- package/dist/commands/queue.js +5 -1
- package/dist/commands/resume.js +8 -16
- package/dist/commands/session.js +18 -42
- package/dist/commands/user-command.js +8 -17
- package/dist/commands/verbosity.js +53 -0
- package/dist/commands/worktree-settings.js +88 -0
- package/dist/commands/worktree.js +146 -50
- package/dist/database.js +85 -0
- package/dist/discord-bot.js +97 -55
- package/dist/discord-utils.js +51 -13
- package/dist/discord-utils.test.js +20 -0
- package/dist/escape-backticks.test.js +14 -3
- package/dist/interaction-handler.js +15 -0
- package/dist/session-handler.js +549 -412
- package/dist/system-message.js +25 -1
- package/dist/worktree-utils.js +50 -0
- package/package.json +1 -1
- package/src/__snapshots__/first-session-no-info.md +1344 -0
- package/src/__snapshots__/first-session-with-info.md +1350 -0
- package/src/__snapshots__/session-1.md +1344 -0
- package/src/__snapshots__/session-2.md +291 -0
- package/src/__snapshots__/session-3.md +20324 -0
- package/src/__snapshots__/session-with-tools.md +1344 -0
- package/src/channel-management.ts +6 -17
- package/src/cli.ts +250 -35
- package/src/commands/merge-worktree.ts +186 -0
- package/src/commands/permissions.ts +31 -5
- package/src/commands/queue.ts +5 -1
- package/src/commands/resume.ts +8 -18
- package/src/commands/session.ts +18 -44
- package/src/commands/user-command.ts +8 -19
- package/src/commands/verbosity.ts +71 -0
- package/src/commands/worktree-settings.ts +122 -0
- package/src/commands/worktree.ts +174 -55
- package/src/database.ts +108 -0
- package/src/discord-bot.ts +119 -63
- package/src/discord-utils.test.ts +23 -0
- package/src/discord-utils.ts +52 -13
- package/src/escape-backticks.test.ts +14 -3
- package/src/interaction-handler.ts +22 -0
- package/src/session-handler.ts +681 -436
- package/src/system-message.ts +37 -0
- package/src/worktree-utils.ts +78 -0
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
// extracts channel metadata from topic tags, and ensures category structure.
|
|
4
4
|
import { ChannelType } from 'discord.js';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { getDatabase } from './database.js';
|
|
7
|
-
import { extractTagsArrays } from './xml.js';
|
|
6
|
+
import { getDatabase, getChannelDirectory } from './database.js';
|
|
8
7
|
export async function ensureKimakiCategory(guild, botName) {
|
|
9
8
|
// Skip appending bot name if it's already "kimaki" to avoid "Kimaki kimaki"
|
|
10
9
|
const isKimakiBot = botName?.toLowerCase() === 'kimaki';
|
|
@@ -53,7 +52,7 @@ export async function createProjectChannels({ guild, projectDirectory, appId, bo
|
|
|
53
52
|
name: channelName,
|
|
54
53
|
type: ChannelType.GuildText,
|
|
55
54
|
parent: kimakiCategory,
|
|
56
|
-
topic
|
|
55
|
+
// Channel configuration is stored in SQLite, not in the topic
|
|
57
56
|
});
|
|
58
57
|
const voiceChannel = await guild.channels.create({
|
|
59
58
|
name: channelName,
|
|
@@ -79,22 +78,14 @@ export async function getChannelsWithDescriptions(guild) {
|
|
|
79
78
|
.forEach((channel) => {
|
|
80
79
|
const textChannel = channel;
|
|
81
80
|
const description = textChannel.topic || null;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (description) {
|
|
85
|
-
const extracted = extractTagsArrays({
|
|
86
|
-
xml: description,
|
|
87
|
-
tags: ['kimaki.directory', 'kimaki.app'],
|
|
88
|
-
});
|
|
89
|
-
kimakiDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
90
|
-
kimakiApp = extracted['kimaki.app']?.[0]?.trim();
|
|
91
|
-
}
|
|
81
|
+
// Get channel config from database instead of parsing XML from topic
|
|
82
|
+
const channelConfig = getChannelDirectory(textChannel.id);
|
|
92
83
|
channels.push({
|
|
93
84
|
id: textChannel.id,
|
|
94
85
|
name: textChannel.name,
|
|
95
86
|
description,
|
|
96
|
-
kimakiDirectory,
|
|
97
|
-
kimakiApp,
|
|
87
|
+
kimakiDirectory: channelConfig?.directory,
|
|
88
|
+
kimakiApp: channelConfig?.appId || undefined,
|
|
98
89
|
});
|
|
99
90
|
});
|
|
100
91
|
return channels;
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { cac } from 'cac';
|
|
6
6
|
import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, spinner, } from '@clack/prompts';
|
|
7
7
|
import { deduplicateByKey, generateBotInstallUrl, abbreviatePath } from './utils.js';
|
|
8
|
-
import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discord-bot.js';
|
|
8
|
+
import { getChannelsWithDescriptions, createDiscordClient, getDatabase, getChannelDirectory, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discord-bot.js';
|
|
9
9
|
import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import fs from 'node:fs';
|
|
@@ -15,7 +15,6 @@ import { uploadFilesToDiscord } from './discord-utils.js';
|
|
|
15
15
|
import { spawn, spawnSync, execSync } from 'node:child_process';
|
|
16
16
|
import http from 'node:http';
|
|
17
17
|
import { setDataDir, getDataDir, getLockPort } from './config.js';
|
|
18
|
-
import { extractTagsArrays } from './xml.js';
|
|
19
18
|
import { sanitizeAgentName } from './commands/agent.js';
|
|
20
19
|
const cliLogger = createLogger('CLI');
|
|
21
20
|
const cli = cac('kimaki');
|
|
@@ -161,15 +160,27 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
|
|
|
161
160
|
.toJSON(),
|
|
162
161
|
new SlashCommandBuilder()
|
|
163
162
|
.setName('new-worktree')
|
|
164
|
-
.setDescription('Create a new git worktree
|
|
163
|
+
.setDescription('Create a new git worktree (in thread: uses thread name if no name given)')
|
|
165
164
|
.addStringOption((option) => {
|
|
166
165
|
option
|
|
167
166
|
.setName('name')
|
|
168
|
-
.setDescription('Name for
|
|
169
|
-
.setRequired(
|
|
167
|
+
.setDescription('Name for worktree (optional in threads - uses thread name)')
|
|
168
|
+
.setRequired(false);
|
|
170
169
|
return option;
|
|
171
170
|
})
|
|
172
171
|
.toJSON(),
|
|
172
|
+
new SlashCommandBuilder()
|
|
173
|
+
.setName('merge-worktree')
|
|
174
|
+
.setDescription('Merge the worktree branch into the default branch')
|
|
175
|
+
.toJSON(),
|
|
176
|
+
new SlashCommandBuilder()
|
|
177
|
+
.setName('enable-worktrees')
|
|
178
|
+
.setDescription('Enable automatic git worktree creation for new sessions in this channel')
|
|
179
|
+
.toJSON(),
|
|
180
|
+
new SlashCommandBuilder()
|
|
181
|
+
.setName('disable-worktrees')
|
|
182
|
+
.setDescription('Disable automatic git worktree creation for new sessions in this channel')
|
|
183
|
+
.toJSON(),
|
|
173
184
|
new SlashCommandBuilder()
|
|
174
185
|
.setName('add-project')
|
|
175
186
|
.setDescription('Create Discord channels for a new OpenCode project')
|
|
@@ -246,6 +257,18 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
|
|
|
246
257
|
.setName('redo')
|
|
247
258
|
.setDescription('Redo previously undone changes')
|
|
248
259
|
.toJSON(),
|
|
260
|
+
new SlashCommandBuilder()
|
|
261
|
+
.setName('verbosity')
|
|
262
|
+
.setDescription('Set output verbosity for new sessions in this channel')
|
|
263
|
+
.addStringOption((option) => {
|
|
264
|
+
option
|
|
265
|
+
.setName('level')
|
|
266
|
+
.setDescription('Verbosity level')
|
|
267
|
+
.setRequired(true)
|
|
268
|
+
.addChoices({ name: 'tools-and-text (default)', value: 'tools-and-text' }, { name: 'text-only', value: 'text-only' });
|
|
269
|
+
return option;
|
|
270
|
+
})
|
|
271
|
+
.toJSON(),
|
|
249
272
|
];
|
|
250
273
|
// Add user-defined commands with -cmd suffix
|
|
251
274
|
for (const cmd of userCommands) {
|
|
@@ -365,7 +388,7 @@ async function backgroundInit({ currentDir, token, appId, }) {
|
|
|
365
388
|
cliLogger.error('Background init failed:', error instanceof Error ? error.message : String(error));
|
|
366
389
|
}
|
|
367
390
|
}
|
|
368
|
-
async function run({ restart, addChannels }) {
|
|
391
|
+
async function run({ restart, addChannels, useWorktrees }) {
|
|
369
392
|
const forceSetup = Boolean(restart);
|
|
370
393
|
intro('🤖 Discord Bot Setup');
|
|
371
394
|
// Step 0: Check if OpenCode CLI is available
|
|
@@ -600,7 +623,7 @@ async function run({ restart, addChannels }) {
|
|
|
600
623
|
const isQuickStart = existingBot && !forceSetup && !addChannels;
|
|
601
624
|
if (isQuickStart) {
|
|
602
625
|
s.start('Starting Discord bot...');
|
|
603
|
-
await startDiscordBot({ token, appId, discordClient });
|
|
626
|
+
await startDiscordBot({ token, appId, discordClient, useWorktrees });
|
|
604
627
|
s.stop('Discord bot is running!');
|
|
605
628
|
// Background: OpenCode init + slash command registration (non-blocking)
|
|
606
629
|
void backgroundInit({ currentDir, token, appId });
|
|
@@ -731,7 +754,7 @@ async function run({ restart, addChannels }) {
|
|
|
731
754
|
cliLogger.error('Failed to register slash commands:', error instanceof Error ? error.message : String(error));
|
|
732
755
|
});
|
|
733
756
|
s.start('Starting Discord bot...');
|
|
734
|
-
await startDiscordBot({ token, appId, discordClient });
|
|
757
|
+
await startDiscordBot({ token, appId, discordClient, useWorktrees });
|
|
735
758
|
s.stop('Discord bot is running!');
|
|
736
759
|
showReadyMessage({ kimakiChannels, createdChannels, appId });
|
|
737
760
|
outro('✨ Setup complete! Listening for new messages... do not close this process.');
|
|
@@ -742,6 +765,7 @@ cli
|
|
|
742
765
|
.option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
|
|
743
766
|
.option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
|
|
744
767
|
.option('--install-url', 'Print the bot install URL and exit')
|
|
768
|
+
.option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
|
|
745
769
|
.action(async (options) => {
|
|
746
770
|
try {
|
|
747
771
|
// Set data directory early, before any database access
|
|
@@ -767,6 +791,7 @@ cli
|
|
|
767
791
|
restart: options.restart,
|
|
768
792
|
addChannels: options.addChannels,
|
|
769
793
|
dataDir: options.dataDir,
|
|
794
|
+
useWorktrees: options.useWorktrees,
|
|
770
795
|
});
|
|
771
796
|
}
|
|
772
797
|
catch (error) {
|
|
@@ -1007,20 +1032,13 @@ cli
|
|
|
1007
1032
|
throw new Error(`Discord API error: ${channelResponse.status} - ${error}`);
|
|
1008
1033
|
}
|
|
1009
1034
|
const channelData = (await channelResponse.json());
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
const extracted = extractTagsArrays({
|
|
1015
|
-
xml: channelData.topic,
|
|
1016
|
-
tags: ['kimaki.directory', 'kimaki.app'],
|
|
1017
|
-
});
|
|
1018
|
-
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
1019
|
-
const channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
1020
|
-
if (!projectDirectory) {
|
|
1021
|
-
s.stop('No kimaki.directory tag found');
|
|
1022
|
-
throw new Error(`Channel #${channelData.name} has no <kimaki.directory> tag in topic.`);
|
|
1035
|
+
const channelConfig = getChannelDirectory(channelData.id);
|
|
1036
|
+
if (!channelConfig) {
|
|
1037
|
+
s.stop('Channel not configured');
|
|
1038
|
+
throw new Error(`Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`);
|
|
1023
1039
|
}
|
|
1040
|
+
const projectDirectory = channelConfig.directory;
|
|
1041
|
+
const channelAppId = channelConfig.appId || undefined;
|
|
1024
1042
|
// Verify app ID matches if both are present
|
|
1025
1043
|
if (channelAppId && appId && channelAppId !== appId) {
|
|
1026
1044
|
s.stop('Channel belongs to different bot');
|
|
@@ -1031,6 +1049,12 @@ cli
|
|
|
1031
1049
|
// If prompt exceeds this, send it as a file attachment instead.
|
|
1032
1050
|
const DISCORD_MAX_LENGTH = 2000;
|
|
1033
1051
|
let starterMessage;
|
|
1052
|
+
// Embed marker for auto-start sessions (unless --notify-only)
|
|
1053
|
+
// Bot checks for this embed footer to know it should start a session
|
|
1054
|
+
const AUTO_START_MARKER = 'kimaki:start';
|
|
1055
|
+
const autoStartEmbed = notifyOnly
|
|
1056
|
+
? undefined
|
|
1057
|
+
: [{ color: 0x2b2d31, footer: { text: AUTO_START_MARKER } }];
|
|
1034
1058
|
if (prompt.length > DISCORD_MAX_LENGTH) {
|
|
1035
1059
|
// Send as file attachment with a short summary
|
|
1036
1060
|
const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
|
|
@@ -1048,6 +1072,7 @@ cli
|
|
|
1048
1072
|
formData.append('payload_json', JSON.stringify({
|
|
1049
1073
|
content: summaryContent,
|
|
1050
1074
|
attachments: [{ id: 0, filename: 'prompt.md' }],
|
|
1075
|
+
embeds: autoStartEmbed,
|
|
1051
1076
|
}));
|
|
1052
1077
|
const buffer = fs.readFileSync(tmpFile);
|
|
1053
1078
|
formData.append('files[0]', new Blob([buffer], { type: 'text/markdown' }), 'prompt.md');
|
|
@@ -1080,6 +1105,7 @@ cli
|
|
|
1080
1105
|
},
|
|
1081
1106
|
body: JSON.stringify({
|
|
1082
1107
|
content: prompt,
|
|
1108
|
+
embeds: autoStartEmbed,
|
|
1083
1109
|
}),
|
|
1084
1110
|
});
|
|
1085
1111
|
if (!starterMessageResponse.ok) {
|
|
@@ -1109,17 +1135,6 @@ cli
|
|
|
1109
1135
|
throw new Error(`Discord API error: ${threadResponse.status} - ${error}`);
|
|
1110
1136
|
}
|
|
1111
1137
|
const threadData = (await threadResponse.json());
|
|
1112
|
-
// Mark thread for auto-start if not notify-only
|
|
1113
|
-
// This is optional - only works if local database exists (for local bot auto-start)
|
|
1114
|
-
if (!notifyOnly) {
|
|
1115
|
-
try {
|
|
1116
|
-
const db = getDatabase();
|
|
1117
|
-
db.prepare('INSERT OR REPLACE INTO pending_auto_start (thread_id) VALUES (?)').run(threadData.id);
|
|
1118
|
-
}
|
|
1119
|
-
catch {
|
|
1120
|
-
// Database not available (e.g., CI environment) - skip auto-start marking
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
1138
|
s.stop('Thread created!');
|
|
1124
1139
|
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
|
|
1125
1140
|
const successMessage = notifyOnly
|
|
@@ -1134,5 +1149,168 @@ cli
|
|
|
1134
1149
|
process.exit(EXIT_NO_RESTART);
|
|
1135
1150
|
}
|
|
1136
1151
|
});
|
|
1152
|
+
cli
|
|
1153
|
+
.command('add-project [directory]', 'Create Discord channels for a project directory')
|
|
1154
|
+
.option('-g, --guild <guildId>', 'Discord guild/server ID (auto-detects if bot is in only one server)')
|
|
1155
|
+
.option('-a, --app-id <appId>', 'Bot application ID (reads from database if available)')
|
|
1156
|
+
.action(async (directory, options) => {
|
|
1157
|
+
try {
|
|
1158
|
+
const absolutePath = path.resolve(directory || '.');
|
|
1159
|
+
if (!fs.existsSync(absolutePath)) {
|
|
1160
|
+
cliLogger.error(`Directory does not exist: ${absolutePath}`);
|
|
1161
|
+
process.exit(EXIT_NO_RESTART);
|
|
1162
|
+
}
|
|
1163
|
+
// Get bot token from env var or database
|
|
1164
|
+
const envToken = process.env.KIMAKI_BOT_TOKEN;
|
|
1165
|
+
let botToken;
|
|
1166
|
+
let appId = options.appId;
|
|
1167
|
+
if (envToken) {
|
|
1168
|
+
botToken = envToken;
|
|
1169
|
+
if (!appId) {
|
|
1170
|
+
try {
|
|
1171
|
+
const db = getDatabase();
|
|
1172
|
+
const botRow = db
|
|
1173
|
+
.prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
1174
|
+
.get();
|
|
1175
|
+
appId = botRow?.app_id;
|
|
1176
|
+
}
|
|
1177
|
+
catch {
|
|
1178
|
+
// Database might not exist in CI
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
else {
|
|
1183
|
+
try {
|
|
1184
|
+
const db = getDatabase();
|
|
1185
|
+
const botRow = db
|
|
1186
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
1187
|
+
.get();
|
|
1188
|
+
if (botRow) {
|
|
1189
|
+
botToken = botRow.token;
|
|
1190
|
+
appId = appId || botRow.app_id;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
catch (e) {
|
|
1194
|
+
cliLogger.error('Database error:', e instanceof Error ? e.message : String(e));
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
if (!botToken) {
|
|
1198
|
+
cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.');
|
|
1199
|
+
process.exit(EXIT_NO_RESTART);
|
|
1200
|
+
}
|
|
1201
|
+
if (!appId) {
|
|
1202
|
+
cliLogger.error('App ID is required to create channels. Use --app-id or run `kimaki` first.');
|
|
1203
|
+
process.exit(EXIT_NO_RESTART);
|
|
1204
|
+
}
|
|
1205
|
+
const s = spinner();
|
|
1206
|
+
s.start('Connecting to Discord...');
|
|
1207
|
+
const client = await createDiscordClient();
|
|
1208
|
+
await new Promise((resolve, reject) => {
|
|
1209
|
+
client.once(Events.ClientReady, () => {
|
|
1210
|
+
resolve();
|
|
1211
|
+
});
|
|
1212
|
+
client.once(Events.Error, reject);
|
|
1213
|
+
client.login(botToken);
|
|
1214
|
+
});
|
|
1215
|
+
s.message('Finding guild...');
|
|
1216
|
+
// Find guild
|
|
1217
|
+
let guild;
|
|
1218
|
+
if (options.guild) {
|
|
1219
|
+
// Get raw guild ID from argv to avoid cac's number coercion losing precision on large IDs
|
|
1220
|
+
const guildArgIndex = process.argv.findIndex((arg) => arg === '-g' || arg === '--guild');
|
|
1221
|
+
const rawGuildArg = guildArgIndex >= 0 ? process.argv[guildArgIndex + 1] : undefined;
|
|
1222
|
+
const guildId = rawGuildArg || String(options.guild);
|
|
1223
|
+
const foundGuild = client.guilds.cache.get(guildId);
|
|
1224
|
+
if (!foundGuild) {
|
|
1225
|
+
s.stop('Guild not found');
|
|
1226
|
+
cliLogger.error(`Guild not found: ${guildId}`);
|
|
1227
|
+
client.destroy();
|
|
1228
|
+
process.exit(EXIT_NO_RESTART);
|
|
1229
|
+
}
|
|
1230
|
+
guild = foundGuild;
|
|
1231
|
+
}
|
|
1232
|
+
else {
|
|
1233
|
+
// Auto-detect: prefer guild with existing channels for this bot, else first guild
|
|
1234
|
+
const db = getDatabase();
|
|
1235
|
+
const existingChannelRow = db
|
|
1236
|
+
.prepare('SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1')
|
|
1237
|
+
.get(appId);
|
|
1238
|
+
if (existingChannelRow) {
|
|
1239
|
+
try {
|
|
1240
|
+
const ch = await client.channels.fetch(existingChannelRow.channel_id);
|
|
1241
|
+
if (ch && 'guild' in ch && ch.guild) {
|
|
1242
|
+
guild = ch.guild;
|
|
1243
|
+
}
|
|
1244
|
+
else {
|
|
1245
|
+
throw new Error('Channel has no guild');
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
catch {
|
|
1249
|
+
// Channel might be deleted, fall back to first guild
|
|
1250
|
+
const firstGuild = client.guilds.cache.first();
|
|
1251
|
+
if (!firstGuild) {
|
|
1252
|
+
s.stop('No guild found');
|
|
1253
|
+
cliLogger.error('No guild found. Add the bot to a server first.');
|
|
1254
|
+
client.destroy();
|
|
1255
|
+
process.exit(EXIT_NO_RESTART);
|
|
1256
|
+
}
|
|
1257
|
+
guild = firstGuild;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
else {
|
|
1261
|
+
const firstGuild = client.guilds.cache.first();
|
|
1262
|
+
if (!firstGuild) {
|
|
1263
|
+
s.stop('No guild found');
|
|
1264
|
+
cliLogger.error('No guild found. Add the bot to a server first.');
|
|
1265
|
+
client.destroy();
|
|
1266
|
+
process.exit(EXIT_NO_RESTART);
|
|
1267
|
+
}
|
|
1268
|
+
guild = firstGuild;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
// Check if channel already exists in this guild
|
|
1272
|
+
s.message('Checking for existing channel...');
|
|
1273
|
+
try {
|
|
1274
|
+
const db = getDatabase();
|
|
1275
|
+
const existingChannels = db
|
|
1276
|
+
.prepare('SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?')
|
|
1277
|
+
.all(absolutePath, 'text', appId);
|
|
1278
|
+
for (const existingChannel of existingChannels) {
|
|
1279
|
+
try {
|
|
1280
|
+
const ch = await client.channels.fetch(existingChannel.channel_id);
|
|
1281
|
+
if (ch && 'guild' in ch && ch.guild?.id === guild.id) {
|
|
1282
|
+
s.stop('Channel already exists');
|
|
1283
|
+
note(`Channel already exists for this directory in ${guild.name}.\n\nChannel: <#${existingChannel.channel_id}>\nDirectory: ${absolutePath}`, '⚠️ Already Exists');
|
|
1284
|
+
client.destroy();
|
|
1285
|
+
process.exit(0);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
catch {
|
|
1289
|
+
// Channel might be deleted, continue checking
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
catch {
|
|
1294
|
+
// Database might not exist, continue to create
|
|
1295
|
+
}
|
|
1296
|
+
s.message(`Creating channels in ${guild.name}...`);
|
|
1297
|
+
const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
|
|
1298
|
+
guild,
|
|
1299
|
+
projectDirectory: absolutePath,
|
|
1300
|
+
appId,
|
|
1301
|
+
botName: client.user?.username,
|
|
1302
|
+
});
|
|
1303
|
+
client.destroy();
|
|
1304
|
+
s.stop('Channels created!');
|
|
1305
|
+
const channelUrl = `https://discord.com/channels/${guild.id}/${textChannelId}`;
|
|
1306
|
+
note(`Created channels for project:\n\n📝 Text: #${channelName}\n🔊 Voice: #${channelName}\n📁 Directory: ${absolutePath}\n\nURL: ${channelUrl}`, '✅ Success');
|
|
1307
|
+
console.log(channelUrl);
|
|
1308
|
+
process.exit(0);
|
|
1309
|
+
}
|
|
1310
|
+
catch (error) {
|
|
1311
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
1312
|
+
process.exit(EXIT_NO_RESTART);
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1137
1315
|
cli.help();
|
|
1138
1316
|
cli.parse();
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// /merge-worktree command - Merge worktree commits into main/default branch.
|
|
2
|
+
// Handles both branch-based worktrees and detached HEAD state.
|
|
3
|
+
// After merge, switches to detached HEAD at main so user can keep working.
|
|
4
|
+
import {} from 'discord.js';
|
|
5
|
+
import { getThreadWorktree } from '../database.js';
|
|
6
|
+
import { createLogger } from '../logger.js';
|
|
7
|
+
import { execAsync } from '../worktree-utils.js';
|
|
8
|
+
const logger = createLogger('MERGE-WORKTREE');
|
|
9
|
+
/** Worktree thread title prefix - indicates unmerged worktree */
|
|
10
|
+
export const WORKTREE_PREFIX = '⬦ ';
|
|
11
|
+
/**
|
|
12
|
+
* Remove the worktree prefix from a thread title.
|
|
13
|
+
* Uses Promise.race with timeout since Discord thread title updates can hang.
|
|
14
|
+
*/
|
|
15
|
+
async function removeWorktreePrefixFromTitle(thread) {
|
|
16
|
+
if (!thread.name.startsWith(WORKTREE_PREFIX)) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const newName = thread.name.slice(WORKTREE_PREFIX.length);
|
|
20
|
+
// Race between the edit and a timeout - thread title updates are heavily rate-limited
|
|
21
|
+
const timeoutMs = 5000;
|
|
22
|
+
const editPromise = thread.setName(newName).catch((e) => {
|
|
23
|
+
logger.warn(`Failed to update thread title: ${e instanceof Error ? e.message : String(e)}`);
|
|
24
|
+
});
|
|
25
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
26
|
+
setTimeout(() => {
|
|
27
|
+
logger.warn(`Thread title update timed out after ${timeoutMs}ms`);
|
|
28
|
+
resolve();
|
|
29
|
+
}, timeoutMs);
|
|
30
|
+
});
|
|
31
|
+
await Promise.race([editPromise, timeoutPromise]);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Check if worktree is in detached HEAD state.
|
|
35
|
+
*/
|
|
36
|
+
async function isDetachedHead(worktreeDir) {
|
|
37
|
+
try {
|
|
38
|
+
await execAsync(`git -C "${worktreeDir}" symbolic-ref HEAD`);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Get current branch name (returns null if detached).
|
|
47
|
+
*/
|
|
48
|
+
async function getCurrentBranch(worktreeDir) {
|
|
49
|
+
try {
|
|
50
|
+
const { stdout } = await execAsync(`git -C "${worktreeDir}" symbolic-ref --short HEAD`);
|
|
51
|
+
return stdout.trim() || null;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function handleMergeWorktreeCommand({ command, appId }) {
|
|
58
|
+
await command.deferReply({ ephemeral: false });
|
|
59
|
+
const channel = command.channel;
|
|
60
|
+
// Must be in a thread
|
|
61
|
+
if (!channel || !channel.isThread()) {
|
|
62
|
+
await command.editReply('This command can only be used in a thread');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const thread = channel;
|
|
66
|
+
// Get worktree info from database
|
|
67
|
+
const worktreeInfo = getThreadWorktree(thread.id);
|
|
68
|
+
if (!worktreeInfo) {
|
|
69
|
+
await command.editReply('This thread is not associated with a worktree');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (worktreeInfo.status !== 'ready' || !worktreeInfo.worktree_directory) {
|
|
73
|
+
await command.editReply(`Worktree is not ready (status: ${worktreeInfo.status})${worktreeInfo.error_message ? `: ${worktreeInfo.error_message}` : ''}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const mainRepoDir = worktreeInfo.project_directory;
|
|
77
|
+
const worktreeDir = worktreeInfo.worktree_directory;
|
|
78
|
+
try {
|
|
79
|
+
// 1. Check for uncommitted changes
|
|
80
|
+
const { stdout: status } = await execAsync(`git -C "${worktreeDir}" status --porcelain`);
|
|
81
|
+
if (status.trim()) {
|
|
82
|
+
await command.editReply(`❌ Uncommitted changes detected in worktree.\n\nPlease commit your changes first, then retry \`/merge-worktree\`.`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// 2. Get the default branch name
|
|
86
|
+
logger.log(`Getting default branch for ${mainRepoDir}`);
|
|
87
|
+
let defaultBranch;
|
|
88
|
+
try {
|
|
89
|
+
const { stdout } = await execAsync(`git -C "${mainRepoDir}" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@'`);
|
|
90
|
+
defaultBranch = stdout.trim() || 'main';
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
defaultBranch = 'main';
|
|
94
|
+
}
|
|
95
|
+
// 3. Determine if we're on a branch or detached HEAD
|
|
96
|
+
const isDetached = await isDetachedHead(worktreeDir);
|
|
97
|
+
const currentBranch = await getCurrentBranch(worktreeDir);
|
|
98
|
+
let branchToMerge;
|
|
99
|
+
let tempBranch = null;
|
|
100
|
+
if (isDetached) {
|
|
101
|
+
// Create a temporary branch from detached HEAD
|
|
102
|
+
tempBranch = `temp-merge-${Date.now()}`;
|
|
103
|
+
logger.log(`Detached HEAD detected, creating temp branch: ${tempBranch}`);
|
|
104
|
+
await execAsync(`git -C "${worktreeDir}" checkout -b ${tempBranch}`);
|
|
105
|
+
branchToMerge = tempBranch;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
branchToMerge = currentBranch || worktreeInfo.worktree_name;
|
|
109
|
+
}
|
|
110
|
+
logger.log(`Default branch: ${defaultBranch}, branch to merge: ${branchToMerge}`);
|
|
111
|
+
// 4. Merge default branch INTO worktree (handles diverged branches)
|
|
112
|
+
logger.log(`Merging ${defaultBranch} into worktree at ${worktreeDir}`);
|
|
113
|
+
try {
|
|
114
|
+
await execAsync(`git -C "${worktreeDir}" merge ${defaultBranch} --no-edit`);
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
// If merge fails (conflicts), abort and report
|
|
118
|
+
await execAsync(`git -C "${worktreeDir}" merge --abort`).catch(() => { });
|
|
119
|
+
// Clean up temp branch if we created one
|
|
120
|
+
if (tempBranch) {
|
|
121
|
+
await execAsync(`git -C "${worktreeDir}" checkout --detach`).catch(() => { });
|
|
122
|
+
await execAsync(`git -C "${worktreeDir}" branch -D ${tempBranch}`).catch(() => { });
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`Merge conflict - resolve manually in worktree then retry`);
|
|
125
|
+
}
|
|
126
|
+
// 5. Update default branch ref to point to current HEAD
|
|
127
|
+
// Use update-ref instead of fetch because fetch refuses if branch is checked out
|
|
128
|
+
logger.log(`Updating ${defaultBranch} to point to current HEAD`);
|
|
129
|
+
const { stdout: commitHash } = await execAsync(`git -C "${worktreeDir}" rev-parse HEAD`);
|
|
130
|
+
await execAsync(`git -C "${mainRepoDir}" update-ref refs/heads/${defaultBranch} ${commitHash.trim()}`);
|
|
131
|
+
// 6. Switch to detached HEAD at default branch (allows main to be checked out elsewhere)
|
|
132
|
+
logger.log(`Switching to detached HEAD at ${defaultBranch}`);
|
|
133
|
+
await execAsync(`git -C "${worktreeDir}" checkout --detach ${defaultBranch}`);
|
|
134
|
+
// 7. Delete the merged branch (temp or original)
|
|
135
|
+
logger.log(`Deleting merged branch ${branchToMerge}`);
|
|
136
|
+
await execAsync(`git -C "${worktreeDir}" branch -D ${branchToMerge}`).catch(() => { });
|
|
137
|
+
// Also delete the original worktree branch if different from what we merged
|
|
138
|
+
if (!isDetached && branchToMerge !== worktreeInfo.worktree_name) {
|
|
139
|
+
await execAsync(`git -C "${worktreeDir}" branch -D ${worktreeInfo.worktree_name}`).catch(() => { });
|
|
140
|
+
}
|
|
141
|
+
// 8. Remove worktree prefix from thread title (fire and forget with timeout)
|
|
142
|
+
void removeWorktreePrefixFromTitle(thread);
|
|
143
|
+
const sourceDesc = isDetached ? 'detached commits' : `\`${branchToMerge}\``;
|
|
144
|
+
await command.editReply(`✅ Merged ${sourceDesc} into \`${defaultBranch}\`\n\nWorktree now at detached HEAD - you can keep working here.`);
|
|
145
|
+
logger.log(`Successfully merged ${branchToMerge} into ${defaultBranch}`);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
149
|
+
logger.error(`Merge failed: ${errorMsg}`);
|
|
150
|
+
await command.editReply(`❌ Merge failed:\n\`\`\`\n${errorMsg}\n\`\`\``);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -17,6 +17,7 @@ export async function showPermissionDropdown({ thread, permission, directory, })
|
|
|
17
17
|
const contextHash = crypto.randomBytes(8).toString('hex');
|
|
18
18
|
const context = {
|
|
19
19
|
permission,
|
|
20
|
+
requestIds: [permission.id],
|
|
20
21
|
directory,
|
|
21
22
|
thread,
|
|
22
23
|
contextHash,
|
|
@@ -80,10 +81,13 @@ export async function handlePermissionSelectMenu(interaction) {
|
|
|
80
81
|
if (!clientV2) {
|
|
81
82
|
throw new Error('OpenCode server not found for directory');
|
|
82
83
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
reply
|
|
86
|
-
|
|
84
|
+
const requestIds = context.requestIds.length > 0 ? context.requestIds : [context.permission.id];
|
|
85
|
+
await Promise.all(requestIds.map((requestId) => {
|
|
86
|
+
return clientV2.permission.reply({
|
|
87
|
+
requestID: requestId,
|
|
88
|
+
reply: response,
|
|
89
|
+
});
|
|
90
|
+
}));
|
|
87
91
|
pendingPermissionContexts.delete(contextHash);
|
|
88
92
|
// Update message: show result and remove dropdown
|
|
89
93
|
const resultText = (() => {
|
|
@@ -104,7 +108,7 @@ export async function handlePermissionSelectMenu(interaction) {
|
|
|
104
108
|
resultText,
|
|
105
109
|
components: [], // Remove the dropdown
|
|
106
110
|
});
|
|
107
|
-
logger.log(`Permission ${context.permission.id} ${response}`);
|
|
111
|
+
logger.log(`Permission ${context.permission.id} ${response} (${requestIds.length} request(s))`);
|
|
108
112
|
}
|
|
109
113
|
catch (error) {
|
|
110
114
|
logger.error('Error handling permission:', error);
|
|
@@ -114,6 +118,18 @@ export async function handlePermissionSelectMenu(interaction) {
|
|
|
114
118
|
});
|
|
115
119
|
}
|
|
116
120
|
}
|
|
121
|
+
export function addPermissionRequestToContext({ contextHash, requestId, }) {
|
|
122
|
+
const context = pendingPermissionContexts.get(contextHash);
|
|
123
|
+
if (!context) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
if (context.requestIds.includes(requestId)) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
context.requestIds = [...context.requestIds, requestId];
|
|
130
|
+
pendingPermissionContexts.set(contextHash, context);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
117
133
|
/**
|
|
118
134
|
* Clean up a pending permission context (e.g., on auto-reject).
|
|
119
135
|
*/
|
package/dist/commands/queue.js
CHANGED
|
@@ -41,7 +41,11 @@ export async function handleQueueCommand({ command }) {
|
|
|
41
41
|
return;
|
|
42
42
|
}
|
|
43
43
|
// Check if there's an active request running
|
|
44
|
-
const
|
|
44
|
+
const existingController = abortControllers.get(row.session_id);
|
|
45
|
+
const hasActiveRequest = Boolean(existingController && !existingController.signal.aborted);
|
|
46
|
+
if (existingController && existingController.signal.aborted) {
|
|
47
|
+
abortControllers.delete(row.session_id);
|
|
48
|
+
}
|
|
45
49
|
if (!hasActiveRequest) {
|
|
46
50
|
// No active request, send immediately
|
|
47
51
|
const textChannel = await resolveTextChannel(channel);
|
package/dist/commands/resume.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
// /resume command - Resume an existing OpenCode session.
|
|
2
2
|
import { ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
-
import { getDatabase } from '../database.js';
|
|
4
|
+
import { getDatabase, getChannelDirectory } from '../database.js';
|
|
5
5
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
6
|
-
import { sendThreadMessage, resolveTextChannel
|
|
7
|
-
import { extractTagsArrays } from '../xml.js';
|
|
6
|
+
import { sendThreadMessage, resolveTextChannel } from '../discord-utils.js';
|
|
8
7
|
import { collectLastAssistantParts } from '../message-formatting.js';
|
|
9
8
|
import { createLogger } from '../logger.js';
|
|
10
9
|
import * as errore from 'errore';
|
|
@@ -18,16 +17,9 @@ export async function handleResumeCommand({ command, appId }) {
|
|
|
18
17
|
return;
|
|
19
18
|
}
|
|
20
19
|
const textChannel = channel;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const extracted = extractTagsArrays({
|
|
25
|
-
xml: textChannel.topic,
|
|
26
|
-
tags: ['kimaki.directory', 'kimaki.app'],
|
|
27
|
-
});
|
|
28
|
-
projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
29
|
-
channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
30
|
-
}
|
|
20
|
+
const channelConfig = getChannelDirectory(textChannel.id);
|
|
21
|
+
const projectDirectory = channelConfig?.directory;
|
|
22
|
+
const channelAppId = channelConfig?.appId || undefined;
|
|
31
23
|
if (channelAppId && channelAppId !== appId) {
|
|
32
24
|
await command.editReply('This channel is not configured for this bot');
|
|
33
25
|
return;
|
|
@@ -102,12 +94,12 @@ export async function handleResumeAutocomplete({ interaction, appId, }) {
|
|
|
102
94
|
if (interaction.channel) {
|
|
103
95
|
const textChannel = await resolveTextChannel(interaction.channel);
|
|
104
96
|
if (textChannel) {
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
97
|
+
const channelConfig = getChannelDirectory(textChannel.id);
|
|
98
|
+
if (channelConfig?.appId && channelConfig.appId !== appId) {
|
|
107
99
|
await interaction.respond([]);
|
|
108
100
|
return;
|
|
109
101
|
}
|
|
110
|
-
projectDirectory = directory;
|
|
102
|
+
projectDirectory = channelConfig?.directory;
|
|
111
103
|
}
|
|
112
104
|
}
|
|
113
105
|
if (!projectDirectory) {
|