disunday 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-tool-to-genai.js +208 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/channel-management.js +96 -0
- package/dist/cli.js +1674 -0
- package/dist/commands/abort.js +89 -0
- package/dist/commands/add-project.js +117 -0
- package/dist/commands/agent.js +250 -0
- package/dist/commands/ask-question.js +219 -0
- package/dist/commands/compact.js +126 -0
- package/dist/commands/context-menu.js +171 -0
- package/dist/commands/context.js +89 -0
- package/dist/commands/cost.js +93 -0
- package/dist/commands/create-new-project.js +111 -0
- package/dist/commands/diff.js +77 -0
- package/dist/commands/export.js +100 -0
- package/dist/commands/files.js +73 -0
- package/dist/commands/fork.js +199 -0
- package/dist/commands/help.js +54 -0
- package/dist/commands/login.js +488 -0
- package/dist/commands/merge-worktree.js +165 -0
- package/dist/commands/model.js +325 -0
- package/dist/commands/permissions.js +140 -0
- package/dist/commands/ping.js +13 -0
- package/dist/commands/queue.js +133 -0
- package/dist/commands/remove-project.js +119 -0
- package/dist/commands/rename.js +70 -0
- package/dist/commands/restart-opencode-server.js +77 -0
- package/dist/commands/resume.js +276 -0
- package/dist/commands/run-config.js +79 -0
- package/dist/commands/run.js +240 -0
- package/dist/commands/schedule.js +170 -0
- package/dist/commands/session-info.js +58 -0
- package/dist/commands/session.js +191 -0
- package/dist/commands/settings.js +84 -0
- package/dist/commands/share.js +89 -0
- package/dist/commands/status.js +79 -0
- package/dist/commands/sync.js +119 -0
- package/dist/commands/theme.js +53 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +170 -0
- package/dist/commands/user-command.js +135 -0
- package/dist/commands/verbosity.js +59 -0
- package/dist/commands/worktree-settings.js +50 -0
- package/dist/commands/worktree.js +288 -0
- package/dist/config.js +139 -0
- package/dist/database.js +585 -0
- package/dist/discord-bot.js +700 -0
- package/dist/discord-utils.js +336 -0
- package/dist/discord-utils.test.js +20 -0
- package/dist/errors.js +193 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/format-tables.js +96 -0
- package/dist/format-tables.test.js +418 -0
- package/dist/genai-worker-wrapper.js +109 -0
- package/dist/genai-worker.js +299 -0
- package/dist/genai.js +230 -0
- package/dist/image-utils.js +107 -0
- package/dist/interaction-handler.js +289 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +111 -0
- package/dist/markdown.js +323 -0
- package/dist/markdown.test.js +269 -0
- package/dist/message-formatting.js +447 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +226 -0
- package/dist/opencode.js +224 -0
- package/dist/reaction-handler.js +128 -0
- package/dist/scheduler.js +93 -0
- package/dist/security.js +200 -0
- package/dist/session-handler.js +1436 -0
- package/dist/system-message.js +138 -0
- package/dist/tools.js +354 -0
- package/dist/unnest-code-blocks.js +117 -0
- package/dist/unnest-code-blocks.test.js +432 -0
- package/dist/utils.js +95 -0
- package/dist/voice-handler.js +569 -0
- package/dist/voice.js +344 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-utils.js +134 -0
- package/dist/xml.js +90 -0
- package/dist/xml.test.js +32 -0
- package/package.json +84 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// User-defined OpenCode command handler.
|
|
2
|
+
// Handles slash commands that map to user-configured commands in opencode.json.
|
|
3
|
+
import { ChannelType } from 'discord.js';
|
|
4
|
+
import { handleOpencodeSession } from '../session-handler.js';
|
|
5
|
+
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
6
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
7
|
+
import { getDatabase, getChannelDirectory } from '../database.js';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
const userCommandLogger = createLogger(LogPrefix.USER_CMD);
|
|
10
|
+
export const handleUserCommand = async ({ command, appId }) => {
|
|
11
|
+
const discordCommandName = command.commandName;
|
|
12
|
+
// Strip the -cmd suffix to get the actual OpenCode command name
|
|
13
|
+
const commandName = discordCommandName.replace(/-cmd$/, '');
|
|
14
|
+
const args = command.options.getString('arguments') || '';
|
|
15
|
+
userCommandLogger.log(`Executing /${commandName} (from /${discordCommandName}) with args: ${args}`);
|
|
16
|
+
const channel = command.channel;
|
|
17
|
+
userCommandLogger.log(`Channel info: type=${channel?.type}, id=${channel?.id}, isNull=${channel === null}`);
|
|
18
|
+
const isThread = channel &&
|
|
19
|
+
[ChannelType.PublicThread, ChannelType.PrivateThread, ChannelType.AnnouncementThread].includes(channel.type);
|
|
20
|
+
const isTextChannel = channel?.type === ChannelType.GuildText;
|
|
21
|
+
if (!channel || (!isTextChannel && !isThread)) {
|
|
22
|
+
await command.reply({
|
|
23
|
+
content: 'This command can only be used in text channels or threads',
|
|
24
|
+
ephemeral: true,
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
let projectDirectory;
|
|
29
|
+
let channelAppId;
|
|
30
|
+
let textChannel = null;
|
|
31
|
+
let thread = null;
|
|
32
|
+
if (isThread) {
|
|
33
|
+
// Running in an existing thread - get project directory from parent channel
|
|
34
|
+
thread = channel;
|
|
35
|
+
textChannel = thread.parent;
|
|
36
|
+
// Verify this thread has an existing session
|
|
37
|
+
const row = getDatabase()
|
|
38
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
39
|
+
.get(thread.id);
|
|
40
|
+
if (!row) {
|
|
41
|
+
await command.reply({
|
|
42
|
+
content: 'This thread does not have an active session. Use this command in a project channel to create a new thread.',
|
|
43
|
+
ephemeral: true,
|
|
44
|
+
});
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (textChannel) {
|
|
48
|
+
const channelConfig = getChannelDirectory(textChannel.id);
|
|
49
|
+
projectDirectory = channelConfig?.directory;
|
|
50
|
+
channelAppId = channelConfig?.appId || undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
// Running in a text channel - will create a new thread
|
|
55
|
+
textChannel = channel;
|
|
56
|
+
const channelConfig = getChannelDirectory(textChannel.id);
|
|
57
|
+
projectDirectory = channelConfig?.directory;
|
|
58
|
+
channelAppId = channelConfig?.appId || undefined;
|
|
59
|
+
}
|
|
60
|
+
if (channelAppId && channelAppId !== appId) {
|
|
61
|
+
await command.reply({
|
|
62
|
+
content: 'This channel is not configured for this bot',
|
|
63
|
+
ephemeral: true,
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!projectDirectory) {
|
|
68
|
+
await command.reply({
|
|
69
|
+
content: 'This channel is not configured with a project directory',
|
|
70
|
+
ephemeral: true,
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
75
|
+
await command.reply({
|
|
76
|
+
content: `Directory does not exist: ${projectDirectory}`,
|
|
77
|
+
ephemeral: true,
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
await command.deferReply({ ephemeral: false });
|
|
82
|
+
try {
|
|
83
|
+
// Use the dedicated session.command API instead of formatting as text prompt
|
|
84
|
+
const commandPayload = { name: commandName, arguments: args };
|
|
85
|
+
if (isThread && thread) {
|
|
86
|
+
// Running in existing thread - just send the command
|
|
87
|
+
await command.editReply(`Running /${commandName}...`);
|
|
88
|
+
await handleOpencodeSession({
|
|
89
|
+
prompt: '', // Not used when command is set
|
|
90
|
+
thread,
|
|
91
|
+
projectDirectory,
|
|
92
|
+
channelId: textChannel?.id,
|
|
93
|
+
command: commandPayload,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
else if (textChannel) {
|
|
97
|
+
// Running in text channel - create a new thread
|
|
98
|
+
const starterMessage = await textChannel.send({
|
|
99
|
+
content: `**/${commandName}**${args ? ` ${args.slice(0, 200)}${args.length > 200 ? '…' : ''}` : ''}`,
|
|
100
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
101
|
+
});
|
|
102
|
+
const threadName = `/${commandName} ${args.slice(0, 80)}${args.length > 80 ? '…' : ''}`;
|
|
103
|
+
const newThread = await starterMessage.startThread({
|
|
104
|
+
name: threadName.slice(0, 100),
|
|
105
|
+
autoArchiveDuration: 1440,
|
|
106
|
+
reason: `OpenCode command: ${commandName}`,
|
|
107
|
+
});
|
|
108
|
+
// Add user to thread so it appears in their sidebar
|
|
109
|
+
await newThread.members.add(command.user.id);
|
|
110
|
+
await command.editReply(`Started /${commandName} in ${newThread.toString()}`);
|
|
111
|
+
await handleOpencodeSession({
|
|
112
|
+
prompt: '', // Not used when command is set
|
|
113
|
+
thread: newThread,
|
|
114
|
+
projectDirectory,
|
|
115
|
+
channelId: textChannel.id,
|
|
116
|
+
command: commandPayload,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
userCommandLogger.error(`Error executing /${commandName}:`, error);
|
|
122
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
123
|
+
if (command.deferred) {
|
|
124
|
+
await command.editReply({
|
|
125
|
+
content: `Failed to execute /${commandName}: ${errorMessage}`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
await command.reply({
|
|
130
|
+
content: `Failed to execute /${commandName}: ${errorMessage}`,
|
|
131
|
+
ephemeral: true,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
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, LogPrefix } from '../logger.js';
|
|
8
|
+
const verbosityLogger = createLogger(LogPrefix.VERBOSITY);
|
|
9
|
+
/**
|
|
10
|
+
* Handle the /verbosity slash command.
|
|
11
|
+
* Sets output verbosity for the channel (applies immediately, even mid-session).
|
|
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}** for this channel.`,
|
|
40
|
+
ephemeral: true,
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
setChannelVerbosity(channelId, level);
|
|
45
|
+
verbosityLogger.log(`[VERBOSITY] Set channel ${channelId} to ${level}`);
|
|
46
|
+
const description = (() => {
|
|
47
|
+
if (level === 'text-only') {
|
|
48
|
+
return 'Only text responses will be shown. Tool executions, status messages, and thinking will be hidden.';
|
|
49
|
+
}
|
|
50
|
+
if (level === 'text-and-essential-tools') {
|
|
51
|
+
return 'Text responses and essential tools (edits, custom MCP tools) will be shown. Read, search, and navigation tools will be hidden.';
|
|
52
|
+
}
|
|
53
|
+
return 'All output will be shown, including tool executions and status messages.';
|
|
54
|
+
})();
|
|
55
|
+
await command.reply({
|
|
56
|
+
content: `Verbosity set to **${level}** for this channel.\n${description}\nThis is a per-channel setting and applies immediately, including any active sessions.`,
|
|
57
|
+
ephemeral: true,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// /toggle-worktrees command.
|
|
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 { getDisundayMetadata } from '../discord-utils.js';
|
|
7
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
8
|
+
const worktreeSettingsLogger = createLogger(LogPrefix.WORKTREE);
|
|
9
|
+
/**
|
|
10
|
+
* Handle the /toggle-worktrees slash command.
|
|
11
|
+
* Toggles automatic worktree creation for new sessions in this channel.
|
|
12
|
+
*/
|
|
13
|
+
export async function handleToggleWorktreesCommand({ command, appId, }) {
|
|
14
|
+
worktreeSettingsLogger.log('[TOGGLE_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 = getDisundayMetadata(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
|
+
const nextEnabled = !wasEnabled;
|
|
41
|
+
setChannelWorktreesEnabled(textChannel.id, nextEnabled);
|
|
42
|
+
const nextLabel = nextEnabled ? 'enabled' : 'disabled';
|
|
43
|
+
worktreeSettingsLogger.log(`[TOGGLE_WORKTREES] ${nextLabel.toUpperCase()} for channel ${textChannel.id}`);
|
|
44
|
+
await command.reply({
|
|
45
|
+
content: nextEnabled
|
|
46
|
+
? `Worktrees **enabled** for this channel.\n\nNew sessions started from messages in **#${textChannel.name}** will now automatically create git worktrees.\n\nNew setting for **#${textChannel.name}**: **enabled**.`
|
|
47
|
+
: `Worktrees **disabled** for this channel.\n\nNew sessions started from messages in **#${textChannel.name}** will use the main project directory.\n\nNew setting for **#${textChannel.name}**: **disabled**.`,
|
|
48
|
+
ephemeral: true,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Worktree management command: /new-worktree
|
|
2
|
+
// Uses OpenCode SDK v2 to create worktrees with disunday- 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, getChannelDirectory, getThreadWorktree, } from '../database.js';
|
|
7
|
+
import { initializeOpencodeForDirectory, getOpencodeClientV2 } from '../opencode.js';
|
|
8
|
+
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
9
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
10
|
+
import { createWorktreeWithSubmodules, captureGitDiff } from '../worktree-utils.js';
|
|
11
|
+
import { WORKTREE_PREFIX } from './merge-worktree.js';
|
|
12
|
+
import * as errore from 'errore';
|
|
13
|
+
const logger = createLogger(LogPrefix.WORKTREE);
|
|
14
|
+
class WorktreeError extends Error {
|
|
15
|
+
constructor(message, options) {
|
|
16
|
+
super(message, options);
|
|
17
|
+
this.name = 'WorktreeError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Format worktree name: lowercase, spaces to dashes, remove special chars, add opencode/disunday- prefix.
|
|
22
|
+
* "My Feature" → "opencode/disunday-my-feature"
|
|
23
|
+
* Returns empty string if no valid name can be extracted.
|
|
24
|
+
*/
|
|
25
|
+
export function formatWorktreeName(name) {
|
|
26
|
+
const formatted = name
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.trim()
|
|
29
|
+
.replace(/\s+/g, '-')
|
|
30
|
+
.replace(/[^a-z0-9-]/g, '');
|
|
31
|
+
if (!formatted) {
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
return `opencode/disunday-${formatted}`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Derive worktree name from thread name.
|
|
38
|
+
* Handles existing "⬦ worktree: opencode/disunday-name" format or uses thread name directly.
|
|
39
|
+
*/
|
|
40
|
+
function deriveWorktreeNameFromThread(threadName) {
|
|
41
|
+
// Handle existing "⬦ worktree: opencode/disunday-name" format
|
|
42
|
+
const worktreeMatch = threadName.match(/worktree:\s*(.+)$/i);
|
|
43
|
+
const extractedName = worktreeMatch?.[1]?.trim();
|
|
44
|
+
if (extractedName) {
|
|
45
|
+
// If already has opencode/disunday- prefix, return as is
|
|
46
|
+
if (extractedName.startsWith('opencode/disunday-')) {
|
|
47
|
+
return extractedName;
|
|
48
|
+
}
|
|
49
|
+
return formatWorktreeName(extractedName);
|
|
50
|
+
}
|
|
51
|
+
// Use thread name directly
|
|
52
|
+
return formatWorktreeName(threadName);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Get project directory from database.
|
|
56
|
+
*/
|
|
57
|
+
function getProjectDirectoryFromChannel(channel, appId) {
|
|
58
|
+
const channelConfig = getChannelDirectory(channel.id);
|
|
59
|
+
if (!channelConfig) {
|
|
60
|
+
return new WorktreeError('This channel is not configured with a project directory');
|
|
61
|
+
}
|
|
62
|
+
if (channelConfig.appId && channelConfig.appId !== appId) {
|
|
63
|
+
return new WorktreeError('This channel is not configured for this bot');
|
|
64
|
+
}
|
|
65
|
+
if (!fs.existsSync(channelConfig.directory)) {
|
|
66
|
+
return new WorktreeError(`Directory does not exist: ${channelConfig.directory}`);
|
|
67
|
+
}
|
|
68
|
+
return channelConfig.directory;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Create worktree in background and update starter message when done.
|
|
72
|
+
* If diff is provided, it's applied during worktree creation (before submodule init).
|
|
73
|
+
*/
|
|
74
|
+
async function createWorktreeInBackground({ thread, starterMessage, worktreeName, projectDirectory, clientV2, diff, }) {
|
|
75
|
+
// Create worktree using SDK v2, apply diff, then init submodules
|
|
76
|
+
logger.log(`Creating worktree "${worktreeName}" for project ${projectDirectory}`);
|
|
77
|
+
const worktreeResult = await createWorktreeWithSubmodules({
|
|
78
|
+
clientV2,
|
|
79
|
+
directory: projectDirectory,
|
|
80
|
+
name: worktreeName,
|
|
81
|
+
diff,
|
|
82
|
+
});
|
|
83
|
+
if (worktreeResult instanceof Error) {
|
|
84
|
+
const errorMsg = worktreeResult.message;
|
|
85
|
+
logger.error('[NEW-WORKTREE] Error:', worktreeResult);
|
|
86
|
+
setWorktreeError({ threadId: thread.id, errorMessage: errorMsg });
|
|
87
|
+
await starterMessage.edit(`🌳 **Worktree: ${worktreeName}**\n❌ ${errorMsg}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// Success - update database and edit starter message
|
|
91
|
+
setWorktreeReady({ threadId: thread.id, worktreeDirectory: worktreeResult.directory });
|
|
92
|
+
const diffStatus = diff ? (worktreeResult.diffApplied ? '\n✅ Changes applied' : '\n⚠️ Failed to apply changes') : '';
|
|
93
|
+
await starterMessage.edit(`🌳 **Worktree: ${worktreeName}**\n` +
|
|
94
|
+
`📁 \`${worktreeResult.directory}\`\n` +
|
|
95
|
+
`🌿 Branch: \`${worktreeResult.branch}\`` +
|
|
96
|
+
diffStatus);
|
|
97
|
+
}
|
|
98
|
+
export async function handleNewWorktreeCommand({ command, appId, }) {
|
|
99
|
+
await command.deferReply({ ephemeral: false });
|
|
100
|
+
const channel = command.channel;
|
|
101
|
+
if (!channel) {
|
|
102
|
+
await command.editReply('Cannot determine channel');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const isThread = channel.type === ChannelType.PublicThread || channel.type === ChannelType.PrivateThread;
|
|
106
|
+
// Handle command in existing thread - attach worktree to this thread
|
|
107
|
+
if (isThread) {
|
|
108
|
+
await handleWorktreeInThread({ command, appId, thread: channel });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Handle command in text channel - create new thread with worktree (existing behavior)
|
|
112
|
+
if (channel.type !== ChannelType.GuildText) {
|
|
113
|
+
await command.editReply('This command can only be used in text channels or threads');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const rawName = command.options.getString('name');
|
|
117
|
+
if (!rawName) {
|
|
118
|
+
await command.editReply('Name is required when creating a worktree from a text channel. Use `/new-worktree name:my-feature`');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const worktreeName = formatWorktreeName(rawName);
|
|
122
|
+
if (!worktreeName) {
|
|
123
|
+
await command.editReply('Invalid worktree name. Please use letters, numbers, and spaces.');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const textChannel = channel;
|
|
127
|
+
const projectDirectory = getProjectDirectoryFromChannel(textChannel, appId);
|
|
128
|
+
if (errore.isError(projectDirectory)) {
|
|
129
|
+
await command.editReply(projectDirectory.message);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Initialize opencode and check if worktree already exists
|
|
133
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
134
|
+
if (errore.isError(getClient)) {
|
|
135
|
+
await command.editReply(`Failed to initialize OpenCode: ${getClient.message}`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const clientV2 = getOpencodeClientV2(projectDirectory);
|
|
139
|
+
if (!clientV2) {
|
|
140
|
+
await command.editReply('Failed to get OpenCode client');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
// Check if worktree with this name already exists
|
|
144
|
+
// SDK returns array of directory paths like "~/.opencode/worktree/abc/disunday-my-feature"
|
|
145
|
+
const listResult = await errore.tryAsync({
|
|
146
|
+
try: async () => {
|
|
147
|
+
const response = await clientV2.worktree.list({ directory: projectDirectory });
|
|
148
|
+
return response.data || [];
|
|
149
|
+
},
|
|
150
|
+
catch: (e) => new WorktreeError('Failed to list worktrees', { cause: e }),
|
|
151
|
+
});
|
|
152
|
+
if (errore.isError(listResult)) {
|
|
153
|
+
await command.editReply(listResult.message);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// Check if any worktree path ends with our name
|
|
157
|
+
const existingWorktree = listResult.find((dir) => dir.endsWith(`/${worktreeName}`));
|
|
158
|
+
if (existingWorktree) {
|
|
159
|
+
await command.editReply(`Worktree \`${worktreeName}\` already exists at \`${existingWorktree}\``);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// Create thread immediately so user can start typing
|
|
163
|
+
const result = await errore.tryAsync({
|
|
164
|
+
try: async () => {
|
|
165
|
+
const starterMessage = await textChannel.send({
|
|
166
|
+
content: `🌳 **Creating worktree: ${worktreeName}**\n⏳ Setting up...`,
|
|
167
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
168
|
+
});
|
|
169
|
+
const thread = await starterMessage.startThread({
|
|
170
|
+
name: `${WORKTREE_PREFIX}worktree: ${worktreeName}`,
|
|
171
|
+
autoArchiveDuration: 1440,
|
|
172
|
+
reason: 'Worktree session',
|
|
173
|
+
});
|
|
174
|
+
// Add user to thread so it appears in their sidebar
|
|
175
|
+
await thread.members.add(command.user.id);
|
|
176
|
+
return { thread, starterMessage };
|
|
177
|
+
},
|
|
178
|
+
catch: (e) => new WorktreeError('Failed to create thread', { cause: e }),
|
|
179
|
+
});
|
|
180
|
+
if (errore.isError(result)) {
|
|
181
|
+
logger.error('[NEW-WORKTREE] Error:', result.cause);
|
|
182
|
+
await command.editReply(result.message);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const { thread, starterMessage } = result;
|
|
186
|
+
// Store pending worktree in database
|
|
187
|
+
createPendingWorktree({
|
|
188
|
+
threadId: thread.id,
|
|
189
|
+
worktreeName,
|
|
190
|
+
projectDirectory,
|
|
191
|
+
});
|
|
192
|
+
await command.editReply(`Creating worktree in ${thread.toString()}`);
|
|
193
|
+
// Create worktree in background (don't await)
|
|
194
|
+
createWorktreeInBackground({
|
|
195
|
+
thread,
|
|
196
|
+
starterMessage,
|
|
197
|
+
worktreeName,
|
|
198
|
+
projectDirectory,
|
|
199
|
+
clientV2,
|
|
200
|
+
}).catch((e) => {
|
|
201
|
+
logger.error('[NEW-WORKTREE] Background error:', e);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Handle /new-worktree when called inside an existing thread.
|
|
206
|
+
* Attaches a worktree to the current thread, using thread name if no name provided.
|
|
207
|
+
*/
|
|
208
|
+
async function handleWorktreeInThread({ command, appId, thread, }) {
|
|
209
|
+
// Error if thread already has a worktree
|
|
210
|
+
if (getThreadWorktree(thread.id)) {
|
|
211
|
+
await command.editReply('This thread already has a worktree attached.');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// Get worktree name from parameter or derive from thread name
|
|
215
|
+
const rawName = command.options.getString('name');
|
|
216
|
+
const worktreeName = rawName ? formatWorktreeName(rawName) : deriveWorktreeNameFromThread(thread.name);
|
|
217
|
+
if (!worktreeName) {
|
|
218
|
+
await command.editReply('Invalid worktree name. Please provide a name or rename the thread.');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// Get parent channel for project directory
|
|
222
|
+
const parent = thread.parent;
|
|
223
|
+
if (!parent || parent.type !== ChannelType.GuildText) {
|
|
224
|
+
await command.editReply('Cannot determine parent channel');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const projectDirectory = getProjectDirectoryFromChannel(parent, appId);
|
|
228
|
+
if (errore.isError(projectDirectory)) {
|
|
229
|
+
await command.editReply(projectDirectory.message);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// Initialize opencode
|
|
233
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
234
|
+
if (errore.isError(getClient)) {
|
|
235
|
+
await command.editReply(`Failed to initialize OpenCode: ${getClient.message}`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const clientV2 = getOpencodeClientV2(projectDirectory);
|
|
239
|
+
if (!clientV2) {
|
|
240
|
+
await command.editReply('Failed to get OpenCode client');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// Check if worktree with this name already exists
|
|
244
|
+
const listResult = await errore.tryAsync({
|
|
245
|
+
try: async () => {
|
|
246
|
+
const response = await clientV2.worktree.list({ directory: projectDirectory });
|
|
247
|
+
return response.data || [];
|
|
248
|
+
},
|
|
249
|
+
catch: (e) => new WorktreeError('Failed to list worktrees', { cause: e }),
|
|
250
|
+
});
|
|
251
|
+
if (errore.isError(listResult)) {
|
|
252
|
+
await command.editReply(listResult.message);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const existingWorktreePath = listResult.find((dir) => dir.endsWith(`/${worktreeName}`));
|
|
256
|
+
if (existingWorktreePath) {
|
|
257
|
+
await command.editReply(`Worktree \`${worktreeName}\` already exists at \`${existingWorktreePath}\``);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
// Capture git diff from project directory before creating worktree.
|
|
261
|
+
// This allows transferring uncommitted changes to the new worktree.
|
|
262
|
+
const diff = await captureGitDiff(projectDirectory);
|
|
263
|
+
const hasDiff = diff && (diff.staged || diff.unstaged);
|
|
264
|
+
// Store pending worktree in database for this existing thread
|
|
265
|
+
createPendingWorktree({
|
|
266
|
+
threadId: thread.id,
|
|
267
|
+
worktreeName,
|
|
268
|
+
projectDirectory,
|
|
269
|
+
});
|
|
270
|
+
// Send status message in thread
|
|
271
|
+
const diffNote = hasDiff ? '\n📋 Will transfer uncommitted changes' : '';
|
|
272
|
+
const statusMessage = await thread.send({
|
|
273
|
+
content: `🌳 **Creating worktree: ${worktreeName}**\n⏳ Setting up...${diffNote}`,
|
|
274
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
275
|
+
});
|
|
276
|
+
await command.editReply(`Creating worktree \`${worktreeName}\` for this thread...`);
|
|
277
|
+
// Create worktree in background, passing diff to apply after creation
|
|
278
|
+
createWorktreeInBackground({
|
|
279
|
+
thread,
|
|
280
|
+
starterMessage: statusMessage,
|
|
281
|
+
worktreeName,
|
|
282
|
+
projectDirectory,
|
|
283
|
+
clientV2,
|
|
284
|
+
diff,
|
|
285
|
+
}).catch((e) => {
|
|
286
|
+
logger.error('[NEW-WORKTREE] Background error:', e);
|
|
287
|
+
});
|
|
288
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Runtime configuration for Disunday bot.
|
|
2
|
+
// Stores data directory path and provides accessors for other modules.
|
|
3
|
+
// Must be initialized before database or other path-dependent modules are used.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
const DEFAULT_DATA_DIR = path.join(os.homedir(), '.disunday');
|
|
8
|
+
let dataDir = null;
|
|
9
|
+
/**
|
|
10
|
+
* Get the data directory path.
|
|
11
|
+
* Falls back to ~/.disunday if not explicitly set.
|
|
12
|
+
*/
|
|
13
|
+
export function getDataDir() {
|
|
14
|
+
if (!dataDir) {
|
|
15
|
+
dataDir = DEFAULT_DATA_DIR;
|
|
16
|
+
}
|
|
17
|
+
return dataDir;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Set the data directory path.
|
|
21
|
+
* Creates the directory if it doesn't exist.
|
|
22
|
+
* Must be called before any database or path-dependent operations.
|
|
23
|
+
*/
|
|
24
|
+
export function setDataDir(dir) {
|
|
25
|
+
const resolvedDir = path.resolve(dir);
|
|
26
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
27
|
+
fs.mkdirSync(resolvedDir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
dataDir = resolvedDir;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Get the projects directory path (for /create-new-project command).
|
|
33
|
+
* Returns <dataDir>/projects
|
|
34
|
+
*/
|
|
35
|
+
export function getProjectsDir() {
|
|
36
|
+
return path.join(getDataDir(), 'projects');
|
|
37
|
+
}
|
|
38
|
+
let defaultVerbosity = 'tools-and-text';
|
|
39
|
+
export function getDefaultVerbosity() {
|
|
40
|
+
return defaultVerbosity;
|
|
41
|
+
}
|
|
42
|
+
export function setDefaultVerbosity(level) {
|
|
43
|
+
defaultVerbosity = level;
|
|
44
|
+
}
|
|
45
|
+
const DEFAULT_LOCK_PORT = 29988;
|
|
46
|
+
/**
|
|
47
|
+
* Derive a lock port from the data directory path.
|
|
48
|
+
* Returns 29988 for the default ~/.disunday directory (backwards compatible).
|
|
49
|
+
* For custom data dirs, uses a hash to generate a port in the range 30000-39999.
|
|
50
|
+
*/
|
|
51
|
+
export function getLockPort() {
|
|
52
|
+
const dir = getDataDir();
|
|
53
|
+
// Use original port for default data dir (backwards compatible)
|
|
54
|
+
if (dir === DEFAULT_DATA_DIR) {
|
|
55
|
+
return DEFAULT_LOCK_PORT;
|
|
56
|
+
}
|
|
57
|
+
// Hash-based port for custom data dirs
|
|
58
|
+
let hash = 0;
|
|
59
|
+
for (let i = 0; i < dir.length; i++) {
|
|
60
|
+
const char = dir.charCodeAt(i);
|
|
61
|
+
hash = (hash << 5) - hash + char;
|
|
62
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
63
|
+
}
|
|
64
|
+
// Map to port range 30000-39999
|
|
65
|
+
return 30000 + (Math.abs(hash) % 10000);
|
|
66
|
+
}
|
|
67
|
+
// Bash command whitelist for OpenCode sessions
|
|
68
|
+
// Default allowed bash commands for security
|
|
69
|
+
const DEFAULT_BASH_WHITELIST = [
|
|
70
|
+
'git',
|
|
71
|
+
'npm',
|
|
72
|
+
'pnpm',
|
|
73
|
+
'bun',
|
|
74
|
+
'node',
|
|
75
|
+
'npx',
|
|
76
|
+
'bunx',
|
|
77
|
+
'cat',
|
|
78
|
+
'ls',
|
|
79
|
+
'pwd',
|
|
80
|
+
'echo',
|
|
81
|
+
'grep',
|
|
82
|
+
'find',
|
|
83
|
+
'head',
|
|
84
|
+
'tail',
|
|
85
|
+
'wc',
|
|
86
|
+
'tree',
|
|
87
|
+
'which',
|
|
88
|
+
'env',
|
|
89
|
+
'printenv',
|
|
90
|
+
'date',
|
|
91
|
+
'uname',
|
|
92
|
+
'mkdir',
|
|
93
|
+
'touch',
|
|
94
|
+
'cp',
|
|
95
|
+
'mv',
|
|
96
|
+
'rm', // file operations
|
|
97
|
+
'curl',
|
|
98
|
+
'wget', // network
|
|
99
|
+
'tsc',
|
|
100
|
+
'eslint',
|
|
101
|
+
'prettier',
|
|
102
|
+
'vitest',
|
|
103
|
+
'jest', // dev tools
|
|
104
|
+
];
|
|
105
|
+
let bashWhitelist = DEFAULT_BASH_WHITELIST;
|
|
106
|
+
export function getBashWhitelist() {
|
|
107
|
+
return bashWhitelist;
|
|
108
|
+
}
|
|
109
|
+
export function setBashWhitelist(whitelist) {
|
|
110
|
+
bashWhitelist = whitelist;
|
|
111
|
+
}
|
|
112
|
+
const DEFAULT_RATE_LIMIT = {
|
|
113
|
+
messagesPerMinute: 10,
|
|
114
|
+
commandsPerMinute: 20,
|
|
115
|
+
};
|
|
116
|
+
let rateLimitConfig = DEFAULT_RATE_LIMIT;
|
|
117
|
+
export function getRateLimitConfig() {
|
|
118
|
+
return rateLimitConfig;
|
|
119
|
+
}
|
|
120
|
+
export function setRateLimitConfig(config) {
|
|
121
|
+
rateLimitConfig = config;
|
|
122
|
+
}
|
|
123
|
+
const DEFAULT_FILE_VALIDATION = {
|
|
124
|
+
maxFileSizeBytes: 10 * 1024 * 1024, // 10MB
|
|
125
|
+
allowedMimeTypes: [
|
|
126
|
+
'image/png',
|
|
127
|
+
'image/jpeg',
|
|
128
|
+
'image/gif',
|
|
129
|
+
'image/webp',
|
|
130
|
+
'application/pdf',
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
let fileValidationConfig = DEFAULT_FILE_VALIDATION;
|
|
134
|
+
export function getFileValidationConfig() {
|
|
135
|
+
return fileValidationConfig;
|
|
136
|
+
}
|
|
137
|
+
export function setFileValidationConfig(config) {
|
|
138
|
+
fileValidationConfig = config;
|
|
139
|
+
}
|