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
package/dist/commands/session.js
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
import { ChannelType } from 'discord.js';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { getDatabase } from '../database.js';
|
|
5
|
+
import { getDatabase, getChannelDirectory } from '../database.js';
|
|
6
6
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
7
7
|
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
8
|
-
import { extractTagsArrays } from '../xml.js';
|
|
9
8
|
import { handleOpencodeSession } from '../session-handler.js';
|
|
10
9
|
import { createLogger } from '../logger.js';
|
|
11
10
|
import * as errore from 'errore';
|
|
@@ -21,16 +20,9 @@ export async function handleSessionCommand({ command, appId }) {
|
|
|
21
20
|
return;
|
|
22
21
|
}
|
|
23
22
|
const textChannel = channel;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const extracted = extractTagsArrays({
|
|
28
|
-
xml: textChannel.topic,
|
|
29
|
-
tags: ['kimaki.directory', 'kimaki.app'],
|
|
30
|
-
});
|
|
31
|
-
projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
32
|
-
channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
33
|
-
}
|
|
23
|
+
const channelConfig = getChannelDirectory(textChannel.id);
|
|
24
|
+
const projectDirectory = channelConfig?.directory;
|
|
25
|
+
const channelAppId = channelConfig?.appId || undefined;
|
|
34
26
|
if (channelAppId && channelAppId !== appId) {
|
|
35
27
|
await command.editReply('This channel is not configured for this bot');
|
|
36
28
|
return;
|
|
@@ -83,22 +75,14 @@ export async function handleSessionCommand({ command, appId }) {
|
|
|
83
75
|
async function handleAgentAutocomplete({ interaction, appId }) {
|
|
84
76
|
const focusedValue = interaction.options.getFocused();
|
|
85
77
|
let projectDirectory;
|
|
86
|
-
if (interaction.channel) {
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
xml: textChannel.topic,
|
|
93
|
-
tags: ['kimaki.directory', 'kimaki.app'],
|
|
94
|
-
});
|
|
95
|
-
const channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
96
|
-
if (channelAppId && channelAppId !== appId) {
|
|
97
|
-
await interaction.respond([]);
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
78
|
+
if (interaction.channel && interaction.channel.type === ChannelType.GuildText) {
|
|
79
|
+
const channelConfig = getChannelDirectory(interaction.channel.id);
|
|
80
|
+
if (channelConfig) {
|
|
81
|
+
if (channelConfig.appId && channelConfig.appId !== appId) {
|
|
82
|
+
await interaction.respond([]);
|
|
83
|
+
return;
|
|
101
84
|
}
|
|
85
|
+
projectDirectory = channelConfig.directory;
|
|
102
86
|
}
|
|
103
87
|
}
|
|
104
88
|
if (!projectDirectory) {
|
|
@@ -150,22 +134,14 @@ export async function handleSessionAutocomplete({ interaction, appId, }) {
|
|
|
150
134
|
.filter((f) => f);
|
|
151
135
|
const currentQuery = (parts[parts.length - 1] || '').trim();
|
|
152
136
|
let projectDirectory;
|
|
153
|
-
if (interaction.channel) {
|
|
154
|
-
const
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
xml: textChannel.topic,
|
|
160
|
-
tags: ['kimaki.directory', 'kimaki.app'],
|
|
161
|
-
});
|
|
162
|
-
const channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
163
|
-
if (channelAppId && channelAppId !== appId) {
|
|
164
|
-
await interaction.respond([]);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
137
|
+
if (interaction.channel && interaction.channel.type === ChannelType.GuildText) {
|
|
138
|
+
const channelConfig = getChannelDirectory(interaction.channel.id);
|
|
139
|
+
if (channelConfig) {
|
|
140
|
+
if (channelConfig.appId && channelConfig.appId !== appId) {
|
|
141
|
+
await interaction.respond([]);
|
|
142
|
+
return;
|
|
168
143
|
}
|
|
144
|
+
projectDirectory = channelConfig.directory;
|
|
169
145
|
}
|
|
170
146
|
}
|
|
171
147
|
if (!projectDirectory) {
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
// User-defined OpenCode command handler.
|
|
2
2
|
// Handles slash commands that map to user-configured commands in opencode.json.
|
|
3
3
|
import { ChannelType } from 'discord.js';
|
|
4
|
-
import { extractTagsArrays } from '../xml.js';
|
|
5
4
|
import { handleOpencodeSession } from '../session-handler.js';
|
|
6
5
|
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
7
6
|
import { createLogger } from '../logger.js';
|
|
8
|
-
import { getDatabase } from '../database.js';
|
|
7
|
+
import { getDatabase, getChannelDirectory } from '../database.js';
|
|
9
8
|
import fs from 'node:fs';
|
|
10
9
|
const userCommandLogger = createLogger('USER_CMD');
|
|
11
10
|
export const handleUserCommand = async ({ command, appId }) => {
|
|
@@ -45,26 +44,18 @@ export const handleUserCommand = async ({ command, appId }) => {
|
|
|
45
44
|
});
|
|
46
45
|
return;
|
|
47
46
|
}
|
|
48
|
-
if (textChannel
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
});
|
|
53
|
-
projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
54
|
-
channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
47
|
+
if (textChannel) {
|
|
48
|
+
const channelConfig = getChannelDirectory(textChannel.id);
|
|
49
|
+
projectDirectory = channelConfig?.directory;
|
|
50
|
+
channelAppId = channelConfig?.appId || undefined;
|
|
55
51
|
}
|
|
56
52
|
}
|
|
57
53
|
else {
|
|
58
54
|
// Running in a text channel - will create a new thread
|
|
59
55
|
textChannel = channel;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
tags: ['kimaki.directory', 'kimaki.app'],
|
|
64
|
-
});
|
|
65
|
-
projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
66
|
-
channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
67
|
-
}
|
|
56
|
+
const channelConfig = getChannelDirectory(textChannel.id);
|
|
57
|
+
projectDirectory = channelConfig?.directory;
|
|
58
|
+
channelAppId = channelConfig?.appId || undefined;
|
|
68
59
|
}
|
|
69
60
|
if (channelAppId && channelAppId !== appId) {
|
|
70
61
|
await command.reply({
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// /verbosity command.
|
|
2
|
+
// Sets the output verbosity level for sessions in a channel.
|
|
3
|
+
// 'tools-and-text' (default): shows all output including tool executions
|
|
4
|
+
// 'text-only': only shows text responses (⬄ diamond parts)
|
|
5
|
+
import { ChatInputCommandInteraction, ChannelType } from 'discord.js';
|
|
6
|
+
import { getChannelVerbosity, setChannelVerbosity } from '../database.js';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
const verbosityLogger = createLogger('VERBOSITY');
|
|
9
|
+
/**
|
|
10
|
+
* Handle the /verbosity slash command.
|
|
11
|
+
* Sets output verbosity for the channel (applies to new sessions).
|
|
12
|
+
*/
|
|
13
|
+
export async function handleVerbosityCommand({ command, appId, }) {
|
|
14
|
+
verbosityLogger.log('[VERBOSITY] Command called');
|
|
15
|
+
const channel = command.channel;
|
|
16
|
+
if (!channel) {
|
|
17
|
+
await command.reply({
|
|
18
|
+
content: 'Could not determine channel.',
|
|
19
|
+
ephemeral: true,
|
|
20
|
+
});
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
// Get the parent channel ID (for threads, use parent; for text channels, use self)
|
|
24
|
+
const channelId = (() => {
|
|
25
|
+
if (channel.type === ChannelType.GuildText) {
|
|
26
|
+
return channel.id;
|
|
27
|
+
}
|
|
28
|
+
if (channel.type === ChannelType.PublicThread ||
|
|
29
|
+
channel.type === ChannelType.PrivateThread ||
|
|
30
|
+
channel.type === ChannelType.AnnouncementThread) {
|
|
31
|
+
return channel.parentId || channel.id;
|
|
32
|
+
}
|
|
33
|
+
return channel.id;
|
|
34
|
+
})();
|
|
35
|
+
const level = command.options.getString('level', true);
|
|
36
|
+
const currentLevel = getChannelVerbosity(channelId);
|
|
37
|
+
if (currentLevel === level) {
|
|
38
|
+
await command.reply({
|
|
39
|
+
content: `Verbosity is already set to **${level}**.`,
|
|
40
|
+
ephemeral: true,
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
setChannelVerbosity(channelId, level);
|
|
45
|
+
verbosityLogger.log(`[VERBOSITY] Set channel ${channelId} to ${level}`);
|
|
46
|
+
const description = level === 'text-only'
|
|
47
|
+
? 'Only text responses will be shown. Tool executions, status messages, and thinking will be hidden.'
|
|
48
|
+
: 'All output will be shown, including tool executions and status messages.';
|
|
49
|
+
await command.reply({
|
|
50
|
+
content: `Verbosity set to **${level}**.\n\n${description}\n\nThis applies to all new sessions in this channel.`,
|
|
51
|
+
ephemeral: true,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// /enable-worktrees and /disable-worktrees commands.
|
|
2
|
+
// Allows per-channel opt-in for automatic worktree creation,
|
|
3
|
+
// as an alternative to the global --use-worktrees CLI flag.
|
|
4
|
+
import { ChatInputCommandInteraction, ChannelType } from 'discord.js';
|
|
5
|
+
import { getChannelWorktreesEnabled, setChannelWorktreesEnabled } from '../database.js';
|
|
6
|
+
import { getKimakiMetadata } from '../discord-utils.js';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
const worktreeSettingsLogger = createLogger('WORKTREE_SETTINGS');
|
|
9
|
+
/**
|
|
10
|
+
* Handle the /enable-worktrees slash command.
|
|
11
|
+
* Enables automatic worktree creation for new sessions in this channel.
|
|
12
|
+
*/
|
|
13
|
+
export async function handleEnableWorktreesCommand({ command, appId, }) {
|
|
14
|
+
worktreeSettingsLogger.log('[ENABLE_WORKTREES] Command called');
|
|
15
|
+
const channel = command.channel;
|
|
16
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
17
|
+
await command.reply({
|
|
18
|
+
content: 'This command can only be used in text channels (not threads).',
|
|
19
|
+
ephemeral: true,
|
|
20
|
+
});
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const textChannel = channel;
|
|
24
|
+
const metadata = getKimakiMetadata(textChannel);
|
|
25
|
+
if (metadata.channelAppId && metadata.channelAppId !== appId) {
|
|
26
|
+
await command.reply({
|
|
27
|
+
content: 'This channel is configured for a different bot.',
|
|
28
|
+
ephemeral: true,
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!metadata.projectDirectory) {
|
|
33
|
+
await command.reply({
|
|
34
|
+
content: 'This channel is not configured with a project directory.\nUse `/add-project` to set up this channel.',
|
|
35
|
+
ephemeral: true,
|
|
36
|
+
});
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const wasEnabled = getChannelWorktreesEnabled(textChannel.id);
|
|
40
|
+
setChannelWorktreesEnabled(textChannel.id, true);
|
|
41
|
+
worktreeSettingsLogger.log(`[ENABLE_WORKTREES] Enabled for channel ${textChannel.id}`);
|
|
42
|
+
await command.reply({
|
|
43
|
+
content: wasEnabled
|
|
44
|
+
? `Worktrees are already enabled for this channel.\n\nNew sessions started from messages in **#${textChannel.name}** will automatically create git worktrees.`
|
|
45
|
+
: `Worktrees **enabled** for this channel.\n\nNew sessions started from messages in **#${textChannel.name}** will now automatically create git worktrees.\n\nUse \`/disable-worktrees\` to turn this off.`,
|
|
46
|
+
ephemeral: true,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Handle the /disable-worktrees slash command.
|
|
51
|
+
* Disables automatic worktree creation for new sessions in this channel.
|
|
52
|
+
*/
|
|
53
|
+
export async function handleDisableWorktreesCommand({ command, appId, }) {
|
|
54
|
+
worktreeSettingsLogger.log('[DISABLE_WORKTREES] Command called');
|
|
55
|
+
const channel = command.channel;
|
|
56
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
57
|
+
await command.reply({
|
|
58
|
+
content: 'This command can only be used in text channels (not threads).',
|
|
59
|
+
ephemeral: true,
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const textChannel = channel;
|
|
64
|
+
const metadata = getKimakiMetadata(textChannel);
|
|
65
|
+
if (metadata.channelAppId && metadata.channelAppId !== appId) {
|
|
66
|
+
await command.reply({
|
|
67
|
+
content: 'This channel is configured for a different bot.',
|
|
68
|
+
ephemeral: true,
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!metadata.projectDirectory) {
|
|
73
|
+
await command.reply({
|
|
74
|
+
content: 'This channel is not configured with a project directory.\nUse `/add-project` to set up this channel.',
|
|
75
|
+
ephemeral: true,
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const wasEnabled = getChannelWorktreesEnabled(textChannel.id);
|
|
80
|
+
setChannelWorktreesEnabled(textChannel.id, false);
|
|
81
|
+
worktreeSettingsLogger.log(`[DISABLE_WORKTREES] Disabled for channel ${textChannel.id}`);
|
|
82
|
+
await command.reply({
|
|
83
|
+
content: wasEnabled
|
|
84
|
+
? `Worktrees **disabled** for this channel.\n\nNew sessions started from messages in **#${textChannel.name}** will use the main project directory.\n\nUse \`/enable-worktrees\` to turn this back on.`
|
|
85
|
+
: `Worktrees are already disabled for this channel.\n\nNew sessions will use the main project directory.`,
|
|
86
|
+
ephemeral: true,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
// Creates thread immediately, then worktree in background so user can type
|
|
4
4
|
import { ChannelType } from 'discord.js';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
|
-
import { createPendingWorktree, setWorktreeReady, setWorktreeError, } from '../database.js';
|
|
6
|
+
import { createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelDirectory, getThreadWorktree, } from '../database.js';
|
|
7
7
|
import { initializeOpencodeForDirectory, getOpencodeClientV2 } from '../opencode.js';
|
|
8
8
|
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
9
|
-
import { extractTagsArrays } from '../xml.js';
|
|
10
9
|
import { createLogger } from '../logger.js';
|
|
10
|
+
import { createWorktreeWithSubmodules } from '../worktree-utils.js';
|
|
11
|
+
import { WORKTREE_PREFIX } from './merge-worktree.js';
|
|
11
12
|
import * as errore from 'errore';
|
|
12
13
|
const logger = createLogger('WORKTREE');
|
|
13
14
|
class WorktreeError extends Error {
|
|
@@ -17,68 +18,69 @@ class WorktreeError extends Error {
|
|
|
17
18
|
}
|
|
18
19
|
}
|
|
19
20
|
/**
|
|
20
|
-
* Format worktree name: lowercase, spaces to dashes, remove special chars, add kimaki- prefix.
|
|
21
|
-
* "My Feature" ā "kimaki-my-feature"
|
|
21
|
+
* Format worktree name: lowercase, spaces to dashes, remove special chars, add opencode/kimaki- prefix.
|
|
22
|
+
* "My Feature" ā "opencode/kimaki-my-feature"
|
|
23
|
+
* Returns empty string if no valid name can be extracted.
|
|
22
24
|
*/
|
|
23
|
-
function formatWorktreeName(name) {
|
|
25
|
+
export function formatWorktreeName(name) {
|
|
24
26
|
const formatted = name
|
|
25
27
|
.toLowerCase()
|
|
26
28
|
.trim()
|
|
27
29
|
.replace(/\s+/g, '-')
|
|
28
30
|
.replace(/[^a-z0-9-]/g, '');
|
|
29
|
-
|
|
31
|
+
if (!formatted) {
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
return `opencode/kimaki-${formatted}`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Derive worktree name from thread name.
|
|
38
|
+
* Handles existing "⬦ worktree: opencode/kimaki-name" format or uses thread name directly.
|
|
39
|
+
*/
|
|
40
|
+
function deriveWorktreeNameFromThread(threadName) {
|
|
41
|
+
// Handle existing "⬦ worktree: opencode/kimaki-name" format
|
|
42
|
+
const worktreeMatch = threadName.match(/worktree:\s*(.+)$/i);
|
|
43
|
+
const extractedName = worktreeMatch?.[1]?.trim();
|
|
44
|
+
if (extractedName) {
|
|
45
|
+
// If already has opencode/kimaki- prefix, return as is
|
|
46
|
+
if (extractedName.startsWith('opencode/kimaki-')) {
|
|
47
|
+
return extractedName;
|
|
48
|
+
}
|
|
49
|
+
return formatWorktreeName(extractedName);
|
|
50
|
+
}
|
|
51
|
+
// Use thread name directly
|
|
52
|
+
return formatWorktreeName(threadName);
|
|
30
53
|
}
|
|
31
54
|
/**
|
|
32
|
-
* Get project directory from
|
|
55
|
+
* Get project directory from database.
|
|
33
56
|
*/
|
|
34
57
|
function getProjectDirectoryFromChannel(channel, appId) {
|
|
35
|
-
|
|
36
|
-
|
|
58
|
+
const channelConfig = getChannelDirectory(channel.id);
|
|
59
|
+
if (!channelConfig) {
|
|
60
|
+
return new WorktreeError('This channel is not configured with a project directory');
|
|
37
61
|
}
|
|
38
|
-
|
|
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) {
|
|
62
|
+
if (channelConfig.appId && channelConfig.appId !== appId) {
|
|
45
63
|
return new WorktreeError('This channel is not configured for this bot');
|
|
46
64
|
}
|
|
47
|
-
if (!
|
|
48
|
-
return new WorktreeError(
|
|
65
|
+
if (!fs.existsSync(channelConfig.directory)) {
|
|
66
|
+
return new WorktreeError(`Directory does not exist: ${channelConfig.directory}`);
|
|
49
67
|
}
|
|
50
|
-
|
|
51
|
-
return new WorktreeError(`Directory does not exist: ${projectDirectory}`);
|
|
52
|
-
}
|
|
53
|
-
return projectDirectory;
|
|
68
|
+
return channelConfig.directory;
|
|
54
69
|
}
|
|
55
70
|
/**
|
|
56
71
|
* Create worktree in background and update starter message when done.
|
|
57
72
|
*/
|
|
58
73
|
async function createWorktreeInBackground({ thread, starterMessage, worktreeName, projectDirectory, clientV2, }) {
|
|
59
|
-
// Create worktree using SDK v2
|
|
74
|
+
// Create worktree using SDK v2 and init submodules
|
|
60
75
|
logger.log(`Creating worktree "${worktreeName}" for project ${projectDirectory}`);
|
|
61
|
-
const worktreeResult = await
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 }),
|
|
76
|
+
const worktreeResult = await createWorktreeWithSubmodules({
|
|
77
|
+
clientV2,
|
|
78
|
+
directory: projectDirectory,
|
|
79
|
+
name: worktreeName,
|
|
78
80
|
});
|
|
79
|
-
if (
|
|
81
|
+
if (worktreeResult instanceof Error) {
|
|
80
82
|
const errorMsg = worktreeResult.message;
|
|
81
|
-
logger.error('[NEW-WORKTREE] Error:', worktreeResult
|
|
83
|
+
logger.error('[NEW-WORKTREE] Error:', worktreeResult);
|
|
82
84
|
setWorktreeError({ threadId: thread.id, errorMessage: errorMsg });
|
|
83
85
|
await starterMessage.edit(`š³ **Worktree: ${worktreeName}**\nā ${errorMsg}`);
|
|
84
86
|
return;
|
|
@@ -91,15 +93,30 @@ async function createWorktreeInBackground({ thread, starterMessage, worktreeName
|
|
|
91
93
|
}
|
|
92
94
|
export async function handleNewWorktreeCommand({ command, appId, }) {
|
|
93
95
|
await command.deferReply({ ephemeral: false });
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
await command.editReply('Invalid worktree name. Please use letters, numbers, and spaces.');
|
|
96
|
+
const channel = command.channel;
|
|
97
|
+
if (!channel) {
|
|
98
|
+
await command.editReply('Cannot determine channel');
|
|
98
99
|
return;
|
|
99
100
|
}
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
const isThread = channel.type === ChannelType.PublicThread || channel.type === ChannelType.PrivateThread;
|
|
102
|
+
// Handle command in existing thread - attach worktree to this thread
|
|
103
|
+
if (isThread) {
|
|
104
|
+
await handleWorktreeInThread({ command, appId, thread: channel });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Handle command in text channel - create new thread with worktree (existing behavior)
|
|
108
|
+
if (channel.type !== ChannelType.GuildText) {
|
|
109
|
+
await command.editReply('This command can only be used in text channels or threads');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const rawName = command.options.getString('name');
|
|
113
|
+
if (!rawName) {
|
|
114
|
+
await command.editReply('Name is required when creating a worktree from a text channel. Use `/new-worktree name:my-feature`');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const worktreeName = formatWorktreeName(rawName);
|
|
118
|
+
if (!worktreeName) {
|
|
119
|
+
await command.editReply('Invalid worktree name. Please use letters, numbers, and spaces.');
|
|
103
120
|
return;
|
|
104
121
|
}
|
|
105
122
|
const textChannel = channel;
|
|
@@ -146,7 +163,7 @@ export async function handleNewWorktreeCommand({ command, appId, }) {
|
|
|
146
163
|
flags: SILENT_MESSAGE_FLAGS,
|
|
147
164
|
});
|
|
148
165
|
const thread = await starterMessage.startThread({
|
|
149
|
-
name:
|
|
166
|
+
name: `${WORKTREE_PREFIX}worktree: ${worktreeName}`,
|
|
150
167
|
autoArchiveDuration: 1440,
|
|
151
168
|
reason: 'Worktree session',
|
|
152
169
|
});
|
|
@@ -178,3 +195,82 @@ export async function handleNewWorktreeCommand({ command, appId, }) {
|
|
|
178
195
|
logger.error('[NEW-WORKTREE] Background error:', e);
|
|
179
196
|
});
|
|
180
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Handle /new-worktree when called inside an existing thread.
|
|
200
|
+
* Attaches a worktree to the current thread, using thread name if no name provided.
|
|
201
|
+
*/
|
|
202
|
+
async function handleWorktreeInThread({ command, appId, thread, }) {
|
|
203
|
+
// Error if thread already has a worktree
|
|
204
|
+
if (getThreadWorktree(thread.id)) {
|
|
205
|
+
await command.editReply('This thread already has a worktree attached.');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Get worktree name from parameter or derive from thread name
|
|
209
|
+
const rawName = command.options.getString('name');
|
|
210
|
+
const worktreeName = rawName ? formatWorktreeName(rawName) : deriveWorktreeNameFromThread(thread.name);
|
|
211
|
+
if (!worktreeName) {
|
|
212
|
+
await command.editReply('Invalid worktree name. Please provide a name or rename the thread.');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// Get parent channel for project directory
|
|
216
|
+
const parent = thread.parent;
|
|
217
|
+
if (!parent || parent.type !== ChannelType.GuildText) {
|
|
218
|
+
await command.editReply('Cannot determine parent channel');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const projectDirectory = getProjectDirectoryFromChannel(parent, appId);
|
|
222
|
+
if (errore.isError(projectDirectory)) {
|
|
223
|
+
await command.editReply(projectDirectory.message);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
// Initialize opencode
|
|
227
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
228
|
+
if (errore.isError(getClient)) {
|
|
229
|
+
await command.editReply(`Failed to initialize OpenCode: ${getClient.message}`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const clientV2 = getOpencodeClientV2(projectDirectory);
|
|
233
|
+
if (!clientV2) {
|
|
234
|
+
await command.editReply('Failed to get OpenCode client');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// Check if worktree with this name already exists
|
|
238
|
+
const listResult = await errore.tryAsync({
|
|
239
|
+
try: async () => {
|
|
240
|
+
const response = await clientV2.worktree.list({ directory: projectDirectory });
|
|
241
|
+
return response.data || [];
|
|
242
|
+
},
|
|
243
|
+
catch: (e) => new WorktreeError('Failed to list worktrees', { cause: e }),
|
|
244
|
+
});
|
|
245
|
+
if (errore.isError(listResult)) {
|
|
246
|
+
await command.editReply(listResult.message);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const existingWorktreePath = listResult.find((dir) => dir.endsWith(`/${worktreeName}`));
|
|
250
|
+
if (existingWorktreePath) {
|
|
251
|
+
await command.editReply(`Worktree \`${worktreeName}\` already exists at \`${existingWorktreePath}\``);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
// Store pending worktree in database for this existing thread
|
|
255
|
+
createPendingWorktree({
|
|
256
|
+
threadId: thread.id,
|
|
257
|
+
worktreeName,
|
|
258
|
+
projectDirectory,
|
|
259
|
+
});
|
|
260
|
+
// Send status message in thread
|
|
261
|
+
const statusMessage = await thread.send({
|
|
262
|
+
content: `š³ **Creating worktree: ${worktreeName}**\nā³ Setting up...`,
|
|
263
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
264
|
+
});
|
|
265
|
+
await command.editReply(`Creating worktree \`${worktreeName}\` for this thread...`);
|
|
266
|
+
// Create worktree in background
|
|
267
|
+
createWorktreeInBackground({
|
|
268
|
+
thread,
|
|
269
|
+
starterMessage: statusMessage,
|
|
270
|
+
worktreeName,
|
|
271
|
+
projectDirectory,
|
|
272
|
+
clientV2,
|
|
273
|
+
}).catch((e) => {
|
|
274
|
+
logger.error('[NEW-WORKTREE] Background error:', e);
|
|
275
|
+
});
|
|
276
|
+
}
|
package/dist/database.js
CHANGED
|
@@ -90,6 +90,8 @@ export function getDatabase() {
|
|
|
90
90
|
)
|
|
91
91
|
`);
|
|
92
92
|
runModelMigrations(db);
|
|
93
|
+
runWorktreeSettingsMigrations(db);
|
|
94
|
+
runVerbosityMigrations(db);
|
|
93
95
|
}
|
|
94
96
|
return db;
|
|
95
97
|
}
|
|
@@ -250,6 +252,89 @@ export function deleteThreadWorktree(threadId) {
|
|
|
250
252
|
const db = getDatabase();
|
|
251
253
|
db.prepare('DELETE FROM thread_worktrees WHERE thread_id = ?').run(threadId);
|
|
252
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Run migrations for channel worktree settings table.
|
|
257
|
+
* Called on startup. Allows per-channel opt-in for automatic worktree creation.
|
|
258
|
+
*/
|
|
259
|
+
export function runWorktreeSettingsMigrations(database) {
|
|
260
|
+
const targetDb = database || getDatabase();
|
|
261
|
+
targetDb.exec(`
|
|
262
|
+
CREATE TABLE IF NOT EXISTS channel_worktrees (
|
|
263
|
+
channel_id TEXT PRIMARY KEY,
|
|
264
|
+
enabled INTEGER NOT NULL DEFAULT 0,
|
|
265
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
266
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
267
|
+
)
|
|
268
|
+
`);
|
|
269
|
+
dbLogger.log('Channel worktree settings migrations complete');
|
|
270
|
+
}
|
|
271
|
+
export function runVerbosityMigrations(database) {
|
|
272
|
+
const targetDb = database || getDatabase();
|
|
273
|
+
targetDb.exec(`
|
|
274
|
+
CREATE TABLE IF NOT EXISTS channel_verbosity (
|
|
275
|
+
channel_id TEXT PRIMARY KEY,
|
|
276
|
+
verbosity TEXT NOT NULL DEFAULT 'tools-and-text',
|
|
277
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
278
|
+
)
|
|
279
|
+
`);
|
|
280
|
+
dbLogger.log('Channel verbosity settings migrations complete');
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Get the verbosity setting for a channel.
|
|
284
|
+
* @returns 'tools-and-text' (default) or 'text-only'
|
|
285
|
+
*/
|
|
286
|
+
export function getChannelVerbosity(channelId) {
|
|
287
|
+
const db = getDatabase();
|
|
288
|
+
const row = db
|
|
289
|
+
.prepare('SELECT verbosity FROM channel_verbosity WHERE channel_id = ?')
|
|
290
|
+
.get(channelId);
|
|
291
|
+
return row?.verbosity || 'tools-and-text';
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Set the verbosity setting for a channel.
|
|
295
|
+
*/
|
|
296
|
+
export function setChannelVerbosity(channelId, verbosity) {
|
|
297
|
+
const db = getDatabase();
|
|
298
|
+
db.prepare(`INSERT INTO channel_verbosity (channel_id, verbosity, updated_at)
|
|
299
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
300
|
+
ON CONFLICT(channel_id) DO UPDATE SET verbosity = ?, updated_at = CURRENT_TIMESTAMP`).run(channelId, verbosity, verbosity);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Check if automatic worktree creation is enabled for a channel.
|
|
304
|
+
*/
|
|
305
|
+
export function getChannelWorktreesEnabled(channelId) {
|
|
306
|
+
const db = getDatabase();
|
|
307
|
+
const row = db
|
|
308
|
+
.prepare('SELECT enabled FROM channel_worktrees WHERE channel_id = ?')
|
|
309
|
+
.get(channelId);
|
|
310
|
+
return row?.enabled === 1;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Enable or disable automatic worktree creation for a channel.
|
|
314
|
+
*/
|
|
315
|
+
export function setChannelWorktreesEnabled(channelId, enabled) {
|
|
316
|
+
const db = getDatabase();
|
|
317
|
+
db.prepare(`INSERT INTO channel_worktrees (channel_id, enabled, updated_at)
|
|
318
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
319
|
+
ON CONFLICT(channel_id) DO UPDATE SET enabled = ?, updated_at = CURRENT_TIMESTAMP`).run(channelId, enabled ? 1 : 0, enabled ? 1 : 0);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Get the directory and app_id for a channel from the database.
|
|
323
|
+
* This is the single source of truth for channel-project mappings.
|
|
324
|
+
*/
|
|
325
|
+
export function getChannelDirectory(channelId) {
|
|
326
|
+
const db = getDatabase();
|
|
327
|
+
const row = db
|
|
328
|
+
.prepare('SELECT directory, app_id FROM channel_directories WHERE channel_id = ?')
|
|
329
|
+
.get(channelId);
|
|
330
|
+
if (!row) {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
directory: row.directory,
|
|
335
|
+
appId: row.app_id,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
253
338
|
export function closeDatabase() {
|
|
254
339
|
if (db) {
|
|
255
340
|
db.close();
|