kimaki 0.4.39 → 0.4.40
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.js +19 -21
- package/dist/commands/abort.js +1 -1
- package/dist/commands/add-project.js +2 -2
- package/dist/commands/agent.js +2 -2
- package/dist/commands/fork.js +2 -2
- package/dist/commands/model.js +2 -2
- package/dist/commands/remove-project.js +2 -2
- package/dist/commands/resume.js +2 -2
- package/dist/commands/session.js +4 -4
- package/dist/commands/share.js +1 -1
- package/dist/commands/undo-redo.js +2 -2
- package/dist/commands/worktree.js +180 -0
- package/dist/database.js +49 -1
- package/dist/discord-bot.js +29 -4
- package/dist/discord-utils.js +36 -0
- package/dist/errors.js +86 -87
- package/dist/genai-worker.js +1 -1
- package/dist/interaction-handler.js +6 -2
- package/dist/markdown.js +5 -1
- package/dist/message-formatting.js +2 -2
- package/dist/opencode.js +4 -4
- package/dist/session-handler.js +2 -2
- package/dist/tools.js +3 -3
- package/dist/voice-handler.js +3 -3
- package/dist/voice.js +4 -4
- package/package.json +4 -3
- package/src/cli.ts +20 -30
- package/src/commands/abort.ts +1 -1
- package/src/commands/add-project.ts +2 -2
- package/src/commands/agent.ts +2 -2
- package/src/commands/fork.ts +2 -2
- package/src/commands/model.ts +2 -2
- package/src/commands/remove-project.ts +2 -2
- package/src/commands/resume.ts +2 -2
- package/src/commands/session.ts +4 -4
- package/src/commands/share.ts +1 -1
- package/src/commands/undo-redo.ts +2 -2
- package/src/commands/worktree.ts +243 -0
- package/src/database.ts +96 -1
- package/src/discord-bot.ts +30 -4
- package/src/discord-utils.ts +50 -0
- package/src/errors.ts +90 -160
- package/src/genai-worker.ts +1 -1
- package/src/interaction-handler.ts +7 -2
- package/src/markdown.ts +5 -4
- package/src/message-formatting.ts +2 -2
- package/src/opencode.ts +4 -4
- package/src/session-handler.ts +2 -2
- package/src/tools.ts +3 -3
- package/src/voice-handler.ts +3 -3
- package/src/voice.ts +4 -4
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import path from 'node:path';
|
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import * as errore from 'errore';
|
|
13
13
|
import { createLogger } from './logger.js';
|
|
14
|
+
import { uploadFilesToDiscord } from './discord-utils.js';
|
|
14
15
|
import { spawn, spawnSync, execSync } from 'node:child_process';
|
|
15
16
|
import http from 'node:http';
|
|
16
17
|
import { setDataDir, getDataDir, getLockPort } from './config.js';
|
|
@@ -136,7 +137,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
|
|
|
136
137
|
})
|
|
137
138
|
.toJSON(),
|
|
138
139
|
new SlashCommandBuilder()
|
|
139
|
-
.setName('session')
|
|
140
|
+
.setName('new-session')
|
|
140
141
|
.setDescription('Start a new OpenCode session')
|
|
141
142
|
.addStringOption((option) => {
|
|
142
143
|
option.setName('prompt').setDescription('Prompt content for the session').setRequired(true);
|
|
@@ -158,6 +159,17 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
|
|
|
158
159
|
return option;
|
|
159
160
|
})
|
|
160
161
|
.toJSON(),
|
|
162
|
+
new SlashCommandBuilder()
|
|
163
|
+
.setName('new-worktree')
|
|
164
|
+
.setDescription('Create a new git worktree and start a session thread')
|
|
165
|
+
.addStringOption((option) => {
|
|
166
|
+
option
|
|
167
|
+
.setName('name')
|
|
168
|
+
.setDescription('Name for the worktree (will be formatted: lowercase, spaces to dashes)')
|
|
169
|
+
.setRequired(true);
|
|
170
|
+
return option;
|
|
171
|
+
})
|
|
172
|
+
.toJSON(),
|
|
161
173
|
new SlashCommandBuilder()
|
|
162
174
|
.setName('add-project')
|
|
163
175
|
.setDescription('Create Discord channels for a new OpenCode project')
|
|
@@ -434,7 +446,7 @@ async function run({ restart, addChannels }) {
|
|
|
434
446
|
const currentDir = process.cwd();
|
|
435
447
|
s.start('Starting OpenCode server...');
|
|
436
448
|
const opencodePromise = initializeOpencodeForDirectory(currentDir).then((result) => {
|
|
437
|
-
if (
|
|
449
|
+
if (result instanceof Error) {
|
|
438
450
|
throw new Error(result.message);
|
|
439
451
|
}
|
|
440
452
|
return result;
|
|
@@ -741,25 +753,11 @@ cli
|
|
|
741
753
|
}
|
|
742
754
|
const s = spinner();
|
|
743
755
|
s.start(`Uploading ${resolvedFiles.length} file(s)...`);
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
}));
|
|
750
|
-
formData.append('files[0]', new Blob([buffer]), path.basename(file));
|
|
751
|
-
const response = await fetch(`https://discord.com/api/v10/channels/${threadRow.thread_id}/messages`, {
|
|
752
|
-
method: 'POST',
|
|
753
|
-
headers: {
|
|
754
|
-
Authorization: `Bot ${botRow.token}`,
|
|
755
|
-
},
|
|
756
|
-
body: formData,
|
|
757
|
-
});
|
|
758
|
-
if (!response.ok) {
|
|
759
|
-
const error = await response.text();
|
|
760
|
-
throw new Error(`Discord API error: ${response.status} - ${error}`);
|
|
761
|
-
}
|
|
762
|
-
}
|
|
756
|
+
await uploadFilesToDiscord({
|
|
757
|
+
threadId: threadRow.thread_id,
|
|
758
|
+
botToken: botRow.token,
|
|
759
|
+
files: resolvedFiles,
|
|
760
|
+
});
|
|
763
761
|
s.stop(`Uploaded ${resolvedFiles.length} file(s)!`);
|
|
764
762
|
note(`Files uploaded to Discord thread!\n\nFiles: ${resolvedFiles.map((f) => path.basename(f)).join(', ')}`, '✅ Success');
|
|
765
763
|
process.exit(0);
|
package/dist/commands/abort.js
CHANGED
|
@@ -58,7 +58,7 @@ export async function handleAbortCommand({ command }) {
|
|
|
58
58
|
abortControllers.delete(sessionId);
|
|
59
59
|
}
|
|
60
60
|
const getClient = await initializeOpencodeForDirectory(directory);
|
|
61
|
-
if (
|
|
61
|
+
if (getClient instanceof Error) {
|
|
62
62
|
await command.reply({
|
|
63
63
|
content: `Failed to abort: ${getClient.message}`,
|
|
64
64
|
ephemeral: true,
|
|
@@ -19,7 +19,7 @@ export async function handleAddProjectCommand({ command, appId }) {
|
|
|
19
19
|
try {
|
|
20
20
|
const currentDir = process.cwd();
|
|
21
21
|
const getClient = await initializeOpencodeForDirectory(currentDir);
|
|
22
|
-
if (
|
|
22
|
+
if (getClient instanceof Error) {
|
|
23
23
|
await command.editReply(getClient.message);
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
@@ -65,7 +65,7 @@ export async function handleAddProjectAutocomplete({ interaction, appId, }) {
|
|
|
65
65
|
try {
|
|
66
66
|
const currentDir = process.cwd();
|
|
67
67
|
const getClient = await initializeOpencodeForDirectory(currentDir);
|
|
68
|
-
if (
|
|
68
|
+
if (getClient instanceof Error) {
|
|
69
69
|
await interaction.respond([]);
|
|
70
70
|
return;
|
|
71
71
|
}
|
package/dist/commands/agent.js
CHANGED
|
@@ -103,7 +103,7 @@ export async function handleAgentCommand({ interaction, appId, }) {
|
|
|
103
103
|
}
|
|
104
104
|
try {
|
|
105
105
|
const getClient = await initializeOpencodeForDirectory(context.dir);
|
|
106
|
-
if (
|
|
106
|
+
if (getClient instanceof Error) {
|
|
107
107
|
await interaction.editReply({ content: getClient.message });
|
|
108
108
|
return;
|
|
109
109
|
}
|
|
@@ -210,7 +210,7 @@ export async function handleQuickAgentCommand({ command, appId, }) {
|
|
|
210
210
|
}
|
|
211
211
|
try {
|
|
212
212
|
const getClient = await initializeOpencodeForDirectory(context.dir);
|
|
213
|
-
if (
|
|
213
|
+
if (getClient instanceof Error) {
|
|
214
214
|
await command.editReply({ content: getClient.message });
|
|
215
215
|
return;
|
|
216
216
|
}
|
package/dist/commands/fork.js
CHANGED
|
@@ -52,7 +52,7 @@ export async function handleForkCommand(interaction) {
|
|
|
52
52
|
await interaction.deferReply({ ephemeral: true });
|
|
53
53
|
const sessionId = row.session_id;
|
|
54
54
|
const getClient = await initializeOpencodeForDirectory(directory);
|
|
55
|
-
if (
|
|
55
|
+
if (getClient instanceof Error) {
|
|
56
56
|
await interaction.editReply({
|
|
57
57
|
content: `Failed to load messages: ${getClient.message}`,
|
|
58
58
|
});
|
|
@@ -128,7 +128,7 @@ export async function handleForkSelectMenu(interaction) {
|
|
|
128
128
|
}
|
|
129
129
|
await interaction.deferReply({ ephemeral: false });
|
|
130
130
|
const getClient = await initializeOpencodeForDirectory(directory);
|
|
131
|
-
if (
|
|
131
|
+
if (getClient instanceof Error) {
|
|
132
132
|
await interaction.editReply(`Failed to fork session: ${getClient.message}`);
|
|
133
133
|
return;
|
|
134
134
|
}
|
package/dist/commands/model.js
CHANGED
|
@@ -78,7 +78,7 @@ export async function handleModelCommand({ interaction, appId, }) {
|
|
|
78
78
|
}
|
|
79
79
|
try {
|
|
80
80
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
81
|
-
if (
|
|
81
|
+
if (getClient instanceof Error) {
|
|
82
82
|
await interaction.editReply({ content: getClient.message });
|
|
83
83
|
return;
|
|
84
84
|
}
|
|
@@ -167,7 +167,7 @@ export async function handleProviderSelectMenu(interaction) {
|
|
|
167
167
|
}
|
|
168
168
|
try {
|
|
169
169
|
const getClient = await initializeOpencodeForDirectory(context.dir);
|
|
170
|
-
if (
|
|
170
|
+
if (getClient instanceof Error) {
|
|
171
171
|
await interaction.editReply({
|
|
172
172
|
content: getClient.message,
|
|
173
173
|
components: [],
|
|
@@ -30,7 +30,7 @@ export async function handleRemoveProjectCommand({ command, appId }) {
|
|
|
30
30
|
try: () => guild.channels.fetch(channel_id),
|
|
31
31
|
catch: (e) => e,
|
|
32
32
|
});
|
|
33
|
-
if (
|
|
33
|
+
if (channel instanceof Error) {
|
|
34
34
|
logger.error(`Failed to fetch channel ${channel_id}:`, channel);
|
|
35
35
|
failedChannels.push(`${channel_type}: ${channel_id}`);
|
|
36
36
|
continue;
|
|
@@ -88,7 +88,7 @@ export async function handleRemoveProjectAutocomplete({ interaction, appId, }) {
|
|
|
88
88
|
try: () => guild.channels.fetch(channel_id),
|
|
89
89
|
catch: (e) => e,
|
|
90
90
|
});
|
|
91
|
-
if (
|
|
91
|
+
if (channel instanceof Error) {
|
|
92
92
|
// Channel not in this guild, skip
|
|
93
93
|
continue;
|
|
94
94
|
}
|
package/dist/commands/resume.js
CHANGED
|
@@ -42,7 +42,7 @@ export async function handleResumeCommand({ command, appId }) {
|
|
|
42
42
|
}
|
|
43
43
|
try {
|
|
44
44
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
45
|
-
if (
|
|
45
|
+
if (getClient instanceof Error) {
|
|
46
46
|
await command.editReply(getClient.message);
|
|
47
47
|
return;
|
|
48
48
|
}
|
|
@@ -116,7 +116,7 @@ export async function handleResumeAutocomplete({ interaction, appId, }) {
|
|
|
116
116
|
}
|
|
117
117
|
try {
|
|
118
118
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
119
|
-
if (
|
|
119
|
+
if (getClient instanceof Error) {
|
|
120
120
|
await interaction.respond([]);
|
|
121
121
|
return;
|
|
122
122
|
}
|
package/dist/commands/session.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// /session command - Start a new OpenCode session.
|
|
1
|
+
// /new-session command - Start a new OpenCode session.
|
|
2
2
|
import { ChannelType } from 'discord.js';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
@@ -45,7 +45,7 @@ export async function handleSessionCommand({ command, appId }) {
|
|
|
45
45
|
}
|
|
46
46
|
try {
|
|
47
47
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
48
|
-
if (
|
|
48
|
+
if (getClient instanceof Error) {
|
|
49
49
|
await command.editReply(getClient.message);
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
@@ -107,7 +107,7 @@ async function handleAgentAutocomplete({ interaction, appId }) {
|
|
|
107
107
|
}
|
|
108
108
|
try {
|
|
109
109
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
110
|
-
if (
|
|
110
|
+
if (getClient instanceof Error) {
|
|
111
111
|
await interaction.respond([]);
|
|
112
112
|
return;
|
|
113
113
|
}
|
|
@@ -174,7 +174,7 @@ export async function handleSessionAutocomplete({ interaction, appId, }) {
|
|
|
174
174
|
}
|
|
175
175
|
try {
|
|
176
176
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
177
|
-
if (
|
|
177
|
+
if (getClient instanceof Error) {
|
|
178
178
|
await interaction.respond([]);
|
|
179
179
|
return;
|
|
180
180
|
}
|
package/dist/commands/share.js
CHANGED
|
@@ -52,7 +52,7 @@ export async function handleShareCommand({ command }) {
|
|
|
52
52
|
}
|
|
53
53
|
const sessionId = row.session_id;
|
|
54
54
|
const getClient = await initializeOpencodeForDirectory(directory);
|
|
55
|
-
if (
|
|
55
|
+
if (getClient instanceof Error) {
|
|
56
56
|
await command.reply({
|
|
57
57
|
content: `Failed to share session: ${getClient.message}`,
|
|
58
58
|
ephemeral: true,
|
|
@@ -53,7 +53,7 @@ export async function handleUndoCommand({ command }) {
|
|
|
53
53
|
const sessionId = row.session_id;
|
|
54
54
|
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
|
|
55
55
|
const getClient = await initializeOpencodeForDirectory(directory);
|
|
56
|
-
if (
|
|
56
|
+
if (getClient instanceof Error) {
|
|
57
57
|
await command.editReply(`Failed to undo: ${getClient.message}`);
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
@@ -140,7 +140,7 @@ export async function handleRedoCommand({ command }) {
|
|
|
140
140
|
const sessionId = row.session_id;
|
|
141
141
|
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
|
|
142
142
|
const getClient = await initializeOpencodeForDirectory(directory);
|
|
143
|
-
if (
|
|
143
|
+
if (getClient instanceof Error) {
|
|
144
144
|
await command.editReply(`Failed to redo: ${getClient.message}`);
|
|
145
145
|
return;
|
|
146
146
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Worktree management command: /new-worktree
|
|
2
|
+
// Uses OpenCode SDK v2 to create worktrees with kimaki- prefix
|
|
3
|
+
// Creates thread immediately, then worktree in background so user can type
|
|
4
|
+
import { ChannelType } from 'discord.js';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { createPendingWorktree, setWorktreeReady, setWorktreeError, } from '../database.js';
|
|
7
|
+
import { initializeOpencodeForDirectory, getOpencodeClientV2 } from '../opencode.js';
|
|
8
|
+
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
9
|
+
import { extractTagsArrays } from '../xml.js';
|
|
10
|
+
import { createLogger } from '../logger.js';
|
|
11
|
+
import * as errore from 'errore';
|
|
12
|
+
const logger = createLogger('WORKTREE');
|
|
13
|
+
class WorktreeError extends Error {
|
|
14
|
+
constructor(message, options) {
|
|
15
|
+
super(message, options);
|
|
16
|
+
this.name = 'WorktreeError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Format worktree name: lowercase, spaces to dashes, remove special chars, add kimaki- prefix.
|
|
21
|
+
* "My Feature" → "kimaki-my-feature"
|
|
22
|
+
*/
|
|
23
|
+
function formatWorktreeName(name) {
|
|
24
|
+
const formatted = name
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.trim()
|
|
27
|
+
.replace(/\s+/g, '-')
|
|
28
|
+
.replace(/[^a-z0-9-]/g, '');
|
|
29
|
+
return `kimaki-${formatted}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Get project directory from channel topic.
|
|
33
|
+
*/
|
|
34
|
+
function getProjectDirectoryFromChannel(channel, appId) {
|
|
35
|
+
if (!channel.topic) {
|
|
36
|
+
return new WorktreeError('This channel has no topic configured');
|
|
37
|
+
}
|
|
38
|
+
const extracted = extractTagsArrays({
|
|
39
|
+
xml: channel.topic,
|
|
40
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
41
|
+
});
|
|
42
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
43
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
44
|
+
if (channelAppId && channelAppId !== appId) {
|
|
45
|
+
return new WorktreeError('This channel is not configured for this bot');
|
|
46
|
+
}
|
|
47
|
+
if (!projectDirectory) {
|
|
48
|
+
return new WorktreeError('This channel is not configured with a project directory');
|
|
49
|
+
}
|
|
50
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
51
|
+
return new WorktreeError(`Directory does not exist: ${projectDirectory}`);
|
|
52
|
+
}
|
|
53
|
+
return projectDirectory;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Create worktree in background and update starter message when done.
|
|
57
|
+
*/
|
|
58
|
+
async function createWorktreeInBackground({ thread, starterMessage, worktreeName, projectDirectory, clientV2, }) {
|
|
59
|
+
// Create worktree using SDK v2
|
|
60
|
+
logger.log(`Creating worktree "${worktreeName}" for project ${projectDirectory}`);
|
|
61
|
+
const worktreeResult = await errore.tryAsync({
|
|
62
|
+
try: async () => {
|
|
63
|
+
const response = await clientV2.worktree.create({
|
|
64
|
+
directory: projectDirectory,
|
|
65
|
+
worktreeCreateInput: {
|
|
66
|
+
name: worktreeName,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
if (response.error) {
|
|
70
|
+
throw new Error(`SDK error: ${JSON.stringify(response.error)}`);
|
|
71
|
+
}
|
|
72
|
+
if (!response.data) {
|
|
73
|
+
throw new Error('No worktree data returned from SDK');
|
|
74
|
+
}
|
|
75
|
+
return response.data;
|
|
76
|
+
},
|
|
77
|
+
catch: (e) => new WorktreeError('Failed to create worktree', { cause: e }),
|
|
78
|
+
});
|
|
79
|
+
if (errore.isError(worktreeResult)) {
|
|
80
|
+
const errorMsg = worktreeResult.message;
|
|
81
|
+
logger.error('[NEW-WORKTREE] Error:', worktreeResult.cause);
|
|
82
|
+
setWorktreeError({ threadId: thread.id, errorMessage: errorMsg });
|
|
83
|
+
await starterMessage.edit(`🌳 **Worktree: ${worktreeName}**\n❌ ${errorMsg}`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Success - update database and edit starter message
|
|
87
|
+
setWorktreeReady({ threadId: thread.id, worktreeDirectory: worktreeResult.directory });
|
|
88
|
+
await starterMessage.edit(`🌳 **Worktree: ${worktreeName}**\n` +
|
|
89
|
+
`📁 \`${worktreeResult.directory}\`\n` +
|
|
90
|
+
`🌿 Branch: \`${worktreeResult.branch}\``);
|
|
91
|
+
}
|
|
92
|
+
export async function handleNewWorktreeCommand({ command, appId, }) {
|
|
93
|
+
await command.deferReply({ ephemeral: false });
|
|
94
|
+
const rawName = command.options.getString('name', true);
|
|
95
|
+
const worktreeName = formatWorktreeName(rawName);
|
|
96
|
+
if (worktreeName === 'kimaki-') {
|
|
97
|
+
await command.editReply('Invalid worktree name. Please use letters, numbers, and spaces.');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const channel = command.channel;
|
|
101
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
102
|
+
await command.editReply('This command can only be used in text channels');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const textChannel = channel;
|
|
106
|
+
const projectDirectory = getProjectDirectoryFromChannel(textChannel, appId);
|
|
107
|
+
if (errore.isError(projectDirectory)) {
|
|
108
|
+
await command.editReply(projectDirectory.message);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Initialize opencode and check if worktree already exists
|
|
112
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
113
|
+
if (errore.isError(getClient)) {
|
|
114
|
+
await command.editReply(`Failed to initialize OpenCode: ${getClient.message}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const clientV2 = getOpencodeClientV2(projectDirectory);
|
|
118
|
+
if (!clientV2) {
|
|
119
|
+
await command.editReply('Failed to get OpenCode client');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// Check if worktree with this name already exists
|
|
123
|
+
// SDK returns array of directory paths like "~/.opencode/worktree/abc/kimaki-my-feature"
|
|
124
|
+
const listResult = await errore.tryAsync({
|
|
125
|
+
try: async () => {
|
|
126
|
+
const response = await clientV2.worktree.list({ directory: projectDirectory });
|
|
127
|
+
return response.data || [];
|
|
128
|
+
},
|
|
129
|
+
catch: (e) => new WorktreeError('Failed to list worktrees', { cause: e }),
|
|
130
|
+
});
|
|
131
|
+
if (errore.isError(listResult)) {
|
|
132
|
+
await command.editReply(listResult.message);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Check if any worktree path ends with our name
|
|
136
|
+
const existingWorktree = listResult.find((dir) => dir.endsWith(`/${worktreeName}`));
|
|
137
|
+
if (existingWorktree) {
|
|
138
|
+
await command.editReply(`Worktree \`${worktreeName}\` already exists at \`${existingWorktree}\``);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// Create thread immediately so user can start typing
|
|
142
|
+
const result = await errore.tryAsync({
|
|
143
|
+
try: async () => {
|
|
144
|
+
const starterMessage = await textChannel.send({
|
|
145
|
+
content: `🌳 **Creating worktree: ${worktreeName}**\n⏳ Setting up...`,
|
|
146
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
147
|
+
});
|
|
148
|
+
const thread = await starterMessage.startThread({
|
|
149
|
+
name: `worktree: ${worktreeName}`,
|
|
150
|
+
autoArchiveDuration: 1440,
|
|
151
|
+
reason: 'Worktree session',
|
|
152
|
+
});
|
|
153
|
+
return { thread, starterMessage };
|
|
154
|
+
},
|
|
155
|
+
catch: (e) => new WorktreeError('Failed to create thread', { cause: e }),
|
|
156
|
+
});
|
|
157
|
+
if (errore.isError(result)) {
|
|
158
|
+
logger.error('[NEW-WORKTREE] Error:', result.cause);
|
|
159
|
+
await command.editReply(result.message);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const { thread, starterMessage } = result;
|
|
163
|
+
// Store pending worktree in database
|
|
164
|
+
createPendingWorktree({
|
|
165
|
+
threadId: thread.id,
|
|
166
|
+
worktreeName,
|
|
167
|
+
projectDirectory,
|
|
168
|
+
});
|
|
169
|
+
await command.editReply(`Creating worktree in ${thread.toString()}`);
|
|
170
|
+
// Create worktree in background (don't await)
|
|
171
|
+
createWorktreeInBackground({
|
|
172
|
+
thread,
|
|
173
|
+
starterMessage,
|
|
174
|
+
worktreeName,
|
|
175
|
+
projectDirectory,
|
|
176
|
+
clientV2,
|
|
177
|
+
}).catch((e) => {
|
|
178
|
+
logger.error('[NEW-WORKTREE] Background error:', e);
|
|
179
|
+
});
|
|
180
|
+
}
|
package/dist/database.js
CHANGED
|
@@ -18,7 +18,7 @@ export function getDatabase() {
|
|
|
18
18
|
},
|
|
19
19
|
catch: (e) => e,
|
|
20
20
|
});
|
|
21
|
-
if (
|
|
21
|
+
if (mkdirError instanceof Error) {
|
|
22
22
|
dbLogger.error(`Failed to create data directory ${dataDir}:`, mkdirError.message);
|
|
23
23
|
}
|
|
24
24
|
const dbPath = path.join(dataDir, 'discord-sessions.db');
|
|
@@ -75,6 +75,19 @@ export function getDatabase() {
|
|
|
75
75
|
xai_api_key TEXT,
|
|
76
76
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
77
77
|
)
|
|
78
|
+
`);
|
|
79
|
+
// Track worktrees created for threads (for /new-worktree command)
|
|
80
|
+
// status: 'pending' while creating, 'ready' when done, 'error' if failed
|
|
81
|
+
db.exec(`
|
|
82
|
+
CREATE TABLE IF NOT EXISTS thread_worktrees (
|
|
83
|
+
thread_id TEXT PRIMARY KEY,
|
|
84
|
+
worktree_name TEXT NOT NULL,
|
|
85
|
+
worktree_directory TEXT,
|
|
86
|
+
project_directory TEXT NOT NULL,
|
|
87
|
+
status TEXT DEFAULT 'pending',
|
|
88
|
+
error_message TEXT,
|
|
89
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
90
|
+
)
|
|
78
91
|
`);
|
|
79
92
|
runModelMigrations(db);
|
|
80
93
|
}
|
|
@@ -202,6 +215,41 @@ export function setSessionAgent(sessionId, agentName) {
|
|
|
202
215
|
const db = getDatabase();
|
|
203
216
|
db.prepare(`INSERT OR REPLACE INTO session_agents (session_id, agent_name) VALUES (?, ?)`).run(sessionId, agentName);
|
|
204
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Get the worktree info for a thread.
|
|
220
|
+
*/
|
|
221
|
+
export function getThreadWorktree(threadId) {
|
|
222
|
+
const db = getDatabase();
|
|
223
|
+
return db.prepare('SELECT * FROM thread_worktrees WHERE thread_id = ?').get(threadId);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Create a pending worktree entry for a thread.
|
|
227
|
+
*/
|
|
228
|
+
export function createPendingWorktree({ threadId, worktreeName, projectDirectory, }) {
|
|
229
|
+
const db = getDatabase();
|
|
230
|
+
db.prepare(`INSERT OR REPLACE INTO thread_worktrees (thread_id, worktree_name, project_directory, status) VALUES (?, ?, ?, 'pending')`).run(threadId, worktreeName, projectDirectory);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Mark a worktree as ready with its directory.
|
|
234
|
+
*/
|
|
235
|
+
export function setWorktreeReady({ threadId, worktreeDirectory, }) {
|
|
236
|
+
const db = getDatabase();
|
|
237
|
+
db.prepare(`UPDATE thread_worktrees SET worktree_directory = ?, status = 'ready' WHERE thread_id = ?`).run(worktreeDirectory, threadId);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Mark a worktree as failed with error message.
|
|
241
|
+
*/
|
|
242
|
+
export function setWorktreeError({ threadId, errorMessage, }) {
|
|
243
|
+
const db = getDatabase();
|
|
244
|
+
db.prepare(`UPDATE thread_worktrees SET status = 'error', error_message = ? WHERE thread_id = ?`).run(errorMessage, threadId);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Delete the worktree info for a thread.
|
|
248
|
+
*/
|
|
249
|
+
export function deleteThreadWorktree(threadId) {
|
|
250
|
+
const db = getDatabase();
|
|
251
|
+
db.prepare('DELETE FROM thread_worktrees WHERE thread_id = ?').run(threadId);
|
|
252
|
+
}
|
|
205
253
|
export function closeDatabase() {
|
|
206
254
|
if (db) {
|
|
207
255
|
db.close();
|
package/dist/discord-bot.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Core Discord bot module that handles message events and bot lifecycle.
|
|
2
2
|
// Bridges Discord messages to OpenCode sessions, manages voice connections,
|
|
3
3
|
// and orchestrates the main event loop for the Kimaki bot.
|
|
4
|
-
import { getDatabase, closeDatabase } from './database.js';
|
|
4
|
+
import { getDatabase, closeDatabase, getThreadWorktree } from './database.js';
|
|
5
5
|
import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js';
|
|
6
6
|
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, SILENT_MESSAGE_FLAGS, } from './discord-utils.js';
|
|
7
7
|
import { getOpencodeSystemMessage } from './system-message.js';
|
|
@@ -22,7 +22,10 @@ import * as errore from 'errore';
|
|
|
22
22
|
import { extractTagsArrays } from './xml.js';
|
|
23
23
|
import { createLogger } from './logger.js';
|
|
24
24
|
import { setGlobalDispatcher, Agent } from 'undici';
|
|
25
|
-
|
|
25
|
+
// Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
|
|
26
|
+
// Each session's event.subscribe() holds a connection; without enough connections,
|
|
27
|
+
// regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
|
|
28
|
+
setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connections: 500 }));
|
|
26
29
|
const discordLogger = createLogger('DISCORD');
|
|
27
30
|
const voiceLogger = createLogger('VOICE');
|
|
28
31
|
export async function createDiscordClient() {
|
|
@@ -94,7 +97,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
94
97
|
try: () => message.fetch(),
|
|
95
98
|
catch: (e) => e,
|
|
96
99
|
});
|
|
97
|
-
if (
|
|
100
|
+
if (fetched instanceof Error) {
|
|
98
101
|
discordLogger.log(`Failed to fetch partial message ${message.id}:`, fetched.message);
|
|
99
102
|
return;
|
|
100
103
|
}
|
|
@@ -132,6 +135,28 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
132
135
|
projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
133
136
|
channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
134
137
|
}
|
|
138
|
+
// Check if this thread is a worktree thread
|
|
139
|
+
const worktreeInfo = getThreadWorktree(thread.id);
|
|
140
|
+
if (worktreeInfo) {
|
|
141
|
+
if (worktreeInfo.status === 'pending') {
|
|
142
|
+
await message.reply({
|
|
143
|
+
content: '⏳ Worktree is still being created. Please wait...',
|
|
144
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (worktreeInfo.status === 'error') {
|
|
149
|
+
await message.reply({
|
|
150
|
+
content: `❌ Worktree creation failed: ${worktreeInfo.error_message}`,
|
|
151
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (worktreeInfo.worktree_directory) {
|
|
156
|
+
projectDirectory = worktreeInfo.worktree_directory;
|
|
157
|
+
discordLogger.log(`Using worktree directory: ${projectDirectory}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
135
160
|
if (channelAppId && channelAppId !== currentAppId) {
|
|
136
161
|
voiceLogger.log(`[IGNORED] Thread belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`);
|
|
137
162
|
return;
|
|
@@ -175,7 +200,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
175
200
|
if (projectDirectory) {
|
|
176
201
|
try {
|
|
177
202
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
178
|
-
if (
|
|
203
|
+
if (getClient instanceof Error) {
|
|
179
204
|
voiceLogger.error(`[SESSION] Failed to initialize OpenCode client:`, getClient.message);
|
|
180
205
|
throw new Error(getClient.message);
|
|
181
206
|
}
|
package/dist/discord-utils.js
CHANGED
|
@@ -8,6 +8,9 @@ import { formatMarkdownTables } from './format-tables.js';
|
|
|
8
8
|
import { limitHeadingDepth } from './limit-heading-depth.js';
|
|
9
9
|
import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
|
|
10
10
|
import { createLogger } from './logger.js';
|
|
11
|
+
import mime from 'mime';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
11
14
|
const discordLogger = createLogger('DISCORD');
|
|
12
15
|
export const SILENT_MESSAGE_FLAGS = 4 | 4096;
|
|
13
16
|
// Same as SILENT but without SuppressNotifications - triggers badge/notification
|
|
@@ -244,3 +247,36 @@ export function getKimakiMetadata(textChannel) {
|
|
|
244
247
|
const channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
245
248
|
return { projectDirectory, channelAppId };
|
|
246
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Upload files to a Discord thread/channel in a single message.
|
|
252
|
+
* Sending all files in one message causes Discord to display images in a grid layout.
|
|
253
|
+
*/
|
|
254
|
+
export async function uploadFilesToDiscord({ threadId, botToken, files, }) {
|
|
255
|
+
if (files.length === 0) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// Build attachments array for all files
|
|
259
|
+
const attachments = files.map((file, index) => ({
|
|
260
|
+
id: index,
|
|
261
|
+
filename: path.basename(file),
|
|
262
|
+
}));
|
|
263
|
+
const formData = new FormData();
|
|
264
|
+
formData.append('payload_json', JSON.stringify({ attachments }));
|
|
265
|
+
// Append each file with its array index, with correct MIME type for grid display
|
|
266
|
+
files.forEach((file, index) => {
|
|
267
|
+
const buffer = fs.readFileSync(file);
|
|
268
|
+
const mimeType = mime.getType(file) || 'application/octet-stream';
|
|
269
|
+
formData.append(`files[${index}]`, new Blob([buffer], { type: mimeType }), path.basename(file));
|
|
270
|
+
});
|
|
271
|
+
const response = await fetch(`https://discord.com/api/v10/channels/${threadId}/messages`, {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: {
|
|
274
|
+
Authorization: `Bot ${botToken}`,
|
|
275
|
+
},
|
|
276
|
+
body: formData,
|
|
277
|
+
});
|
|
278
|
+
if (!response.ok) {
|
|
279
|
+
const error = await response.text();
|
|
280
|
+
throw new Error(`Discord API error: ${response.status} - ${error}`);
|
|
281
|
+
}
|
|
282
|
+
}
|