kimaki 0.4.38 → 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.
Files changed (55) hide show
  1. package/dist/cli.js +27 -23
  2. package/dist/commands/abort.js +15 -6
  3. package/dist/commands/add-project.js +9 -0
  4. package/dist/commands/agent.js +13 -1
  5. package/dist/commands/fork.js +13 -2
  6. package/dist/commands/model.js +12 -0
  7. package/dist/commands/remove-project.js +26 -16
  8. package/dist/commands/resume.js +9 -0
  9. package/dist/commands/session.js +14 -1
  10. package/dist/commands/share.js +10 -1
  11. package/dist/commands/undo-redo.js +13 -4
  12. package/dist/commands/worktree.js +180 -0
  13. package/dist/database.js +57 -5
  14. package/dist/discord-bot.js +48 -10
  15. package/dist/discord-utils.js +36 -0
  16. package/dist/errors.js +109 -0
  17. package/dist/genai-worker.js +18 -16
  18. package/dist/interaction-handler.js +6 -2
  19. package/dist/markdown.js +100 -85
  20. package/dist/markdown.test.js +10 -3
  21. package/dist/message-formatting.js +50 -37
  22. package/dist/opencode.js +43 -46
  23. package/dist/session-handler.js +100 -2
  24. package/dist/system-message.js +2 -0
  25. package/dist/tools.js +18 -8
  26. package/dist/voice-handler.js +48 -25
  27. package/dist/voice.js +159 -131
  28. package/package.json +4 -2
  29. package/src/cli.ts +31 -32
  30. package/src/commands/abort.ts +17 -7
  31. package/src/commands/add-project.ts +9 -0
  32. package/src/commands/agent.ts +13 -1
  33. package/src/commands/fork.ts +18 -7
  34. package/src/commands/model.ts +12 -0
  35. package/src/commands/remove-project.ts +28 -16
  36. package/src/commands/resume.ts +9 -0
  37. package/src/commands/session.ts +14 -1
  38. package/src/commands/share.ts +11 -1
  39. package/src/commands/undo-redo.ts +15 -6
  40. package/src/commands/worktree.ts +243 -0
  41. package/src/database.ts +104 -4
  42. package/src/discord-bot.ts +49 -9
  43. package/src/discord-utils.ts +50 -0
  44. package/src/errors.ts +138 -0
  45. package/src/genai-worker.ts +20 -17
  46. package/src/interaction-handler.ts +7 -2
  47. package/src/markdown.test.ts +13 -3
  48. package/src/markdown.ts +112 -95
  49. package/src/message-formatting.ts +55 -38
  50. package/src/opencode.ts +52 -49
  51. package/src/session-handler.ts +118 -3
  52. package/src/system-message.ts +2 -0
  53. package/src/tools.ts +18 -8
  54. package/src/voice-handler.ts +48 -23
  55. package/src/voice.ts +195 -148
package/dist/cli.js CHANGED
@@ -9,7 +9,9 @@ import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDis
9
9
  import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
10
10
  import path from 'node:path';
11
11
  import fs from 'node:fs';
12
+ import * as errore from 'errore';
12
13
  import { createLogger } from './logger.js';
14
+ import { uploadFilesToDiscord } from './discord-utils.js';
13
15
  import { spawn, spawnSync, execSync } from 'node:child_process';
14
16
  import http from 'node:http';
15
17
  import { setDataDir, getDataDir, getLockPort } from './config.js';
@@ -135,7 +137,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
135
137
  })
136
138
  .toJSON(),
137
139
  new SlashCommandBuilder()
138
- .setName('session')
140
+ .setName('new-session')
139
141
  .setDescription('Start a new OpenCode session')
140
142
  .addStringOption((option) => {
141
143
  option.setName('prompt').setDescription('Prompt content for the session').setRequired(true);
@@ -157,6 +159,17 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
157
159
  return option;
158
160
  })
159
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(),
160
173
  new SlashCommandBuilder()
161
174
  .setName('add-project')
162
175
  .setDescription('Create Discord channels for a new OpenCode project')
@@ -256,8 +269,8 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
256
269
  .toJSON());
257
270
  }
258
271
  // Add agent-specific quick commands like /plan-agent, /build-agent
259
- // Filter to primary/all mode agents (same as /agent command shows)
260
- const primaryAgents = agents.filter((a) => a.mode === 'primary' || a.mode === 'all');
272
+ // Filter to primary/all mode agents (same as /agent command shows), excluding hidden agents
273
+ const primaryAgents = agents.filter((a) => (a.mode === 'primary' || a.mode === 'all') && !a.hidden);
261
274
  for (const agent of primaryAgents) {
262
275
  const sanitizedName = sanitizeAgentName(agent.name);
263
276
  const commandName = `${sanitizedName}-agent`;
@@ -432,7 +445,12 @@ async function run({ restart, addChannels }) {
432
445
  // This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
433
446
  const currentDir = process.cwd();
434
447
  s.start('Starting OpenCode server...');
435
- const opencodePromise = initializeOpencodeForDirectory(currentDir);
448
+ const opencodePromise = initializeOpencodeForDirectory(currentDir).then((result) => {
449
+ if (result instanceof Error) {
450
+ throw new Error(result.message);
451
+ }
452
+ return result;
453
+ });
436
454
  s.message('Connecting to Discord...');
437
455
  const discordClient = await createDiscordClient();
438
456
  const guilds = [];
@@ -735,25 +753,11 @@ cli
735
753
  }
736
754
  const s = spinner();
737
755
  s.start(`Uploading ${resolvedFiles.length} file(s)...`);
738
- for (const file of resolvedFiles) {
739
- const buffer = fs.readFileSync(file);
740
- const formData = new FormData();
741
- formData.append('payload_json', JSON.stringify({
742
- attachments: [{ id: 0, filename: path.basename(file) }],
743
- }));
744
- formData.append('files[0]', new Blob([buffer]), path.basename(file));
745
- const response = await fetch(`https://discord.com/api/v10/channels/${threadRow.thread_id}/messages`, {
746
- method: 'POST',
747
- headers: {
748
- Authorization: `Bot ${botRow.token}`,
749
- },
750
- body: formData,
751
- });
752
- if (!response.ok) {
753
- const error = await response.text();
754
- throw new Error(`Discord API error: ${response.status} - ${error}`);
755
- }
756
- }
756
+ await uploadFilesToDiscord({
757
+ threadId: threadRow.thread_id,
758
+ botToken: botRow.token,
759
+ files: resolvedFiles,
760
+ });
757
761
  s.stop(`Uploaded ${resolvedFiles.length} file(s)!`);
758
762
  note(`Files uploaded to Discord thread!\n\nFiles: ${resolvedFiles.map((f) => path.basename(f)).join(', ')}`, 'āœ… Success');
759
763
  process.exit(0);
@@ -5,6 +5,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
6
  import { abortControllers } from '../session-handler.js';
7
7
  import { createLogger } from '../logger.js';
8
+ import * as errore from 'errore';
8
9
  const logger = createLogger('ABORT');
9
10
  export async function handleAbortCommand({ command }) {
10
11
  const channel = command.channel;
@@ -51,13 +52,21 @@ export async function handleAbortCommand({ command }) {
51
52
  return;
52
53
  }
53
54
  const sessionId = row.session_id;
55
+ const existingController = abortControllers.get(sessionId);
56
+ if (existingController) {
57
+ existingController.abort(new Error('User requested abort'));
58
+ abortControllers.delete(sessionId);
59
+ }
60
+ const getClient = await initializeOpencodeForDirectory(directory);
61
+ if (getClient instanceof Error) {
62
+ await command.reply({
63
+ content: `Failed to abort: ${getClient.message}`,
64
+ ephemeral: true,
65
+ flags: SILENT_MESSAGE_FLAGS,
66
+ });
67
+ return;
68
+ }
54
69
  try {
55
- const existingController = abortControllers.get(sessionId);
56
- if (existingController) {
57
- existingController.abort(new Error('User requested abort'));
58
- abortControllers.delete(sessionId);
59
- }
60
- const getClient = await initializeOpencodeForDirectory(directory);
61
70
  await getClient().session.abort({
62
71
  path: { id: sessionId },
63
72
  });
@@ -6,6 +6,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
6
6
  import { createProjectChannels } from '../channel-management.js';
7
7
  import { createLogger } from '../logger.js';
8
8
  import { abbreviatePath } from '../utils.js';
9
+ import * as errore from 'errore';
9
10
  const logger = createLogger('ADD-PROJECT');
10
11
  export async function handleAddProjectCommand({ command, appId }) {
11
12
  await command.deferReply({ ephemeral: false });
@@ -18,6 +19,10 @@ export async function handleAddProjectCommand({ command, appId }) {
18
19
  try {
19
20
  const currentDir = process.cwd();
20
21
  const getClient = await initializeOpencodeForDirectory(currentDir);
22
+ if (getClient instanceof Error) {
23
+ await command.editReply(getClient.message);
24
+ return;
25
+ }
21
26
  const projectsResponse = await getClient().project.list({});
22
27
  if (!projectsResponse.data) {
23
28
  await command.editReply('Failed to fetch projects');
@@ -60,6 +65,10 @@ export async function handleAddProjectAutocomplete({ interaction, appId, }) {
60
65
  try {
61
66
  const currentDir = process.cwd();
62
67
  const getClient = await initializeOpencodeForDirectory(currentDir);
68
+ if (getClient instanceof Error) {
69
+ await interaction.respond([]);
70
+ return;
71
+ }
63
72
  const projectsResponse = await getClient().project.list({});
64
73
  if (!projectsResponse.data) {
65
74
  await interaction.respond([]);
@@ -6,6 +6,7 @@ import { getDatabase, setChannelAgent, setSessionAgent, clearSessionModel, runMo
6
6
  import { initializeOpencodeForDirectory } from '../opencode.js';
7
7
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
8
8
  import { createLogger } from '../logger.js';
9
+ import * as errore from 'errore';
9
10
  const agentLogger = createLogger('AGENT');
10
11
  const pendingAgentContexts = new Map();
11
12
  /**
@@ -102,6 +103,10 @@ export async function handleAgentCommand({ interaction, appId, }) {
102
103
  }
103
104
  try {
104
105
  const getClient = await initializeOpencodeForDirectory(context.dir);
106
+ if (getClient instanceof Error) {
107
+ await interaction.editReply({ content: getClient.message });
108
+ return;
109
+ }
105
110
  const agentsResponse = await getClient().app.agents({
106
111
  query: { directory: context.dir },
107
112
  });
@@ -110,7 +115,10 @@ export async function handleAgentCommand({ interaction, appId, }) {
110
115
  return;
111
116
  }
112
117
  const agents = agentsResponse.data
113
- .filter((a) => a.mode === 'primary' || a.mode === 'all')
118
+ .filter((agent) => {
119
+ const hidden = agent.hidden;
120
+ return (agent.mode === 'primary' || agent.mode === 'all') && !hidden;
121
+ })
114
122
  .slice(0, 25);
115
123
  if (agents.length === 0) {
116
124
  await interaction.editReply({ content: 'No primary agents available' });
@@ -202,6 +210,10 @@ export async function handleQuickAgentCommand({ command, appId, }) {
202
210
  }
203
211
  try {
204
212
  const getClient = await initializeOpencodeForDirectory(context.dir);
213
+ if (getClient instanceof Error) {
214
+ await command.editReply({ content: getClient.message });
215
+ return;
216
+ }
205
217
  const agentsResponse = await getClient().app.agents({
206
218
  query: { directory: context.dir },
207
219
  });
@@ -5,6 +5,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from '../discord-utils.js';
6
6
  import { collectLastAssistantParts } from '../message-formatting.js';
7
7
  import { createLogger } from '../logger.js';
8
+ import * as errore from 'errore';
8
9
  const sessionLogger = createLogger('SESSION');
9
10
  const forkLogger = createLogger('FORK');
10
11
  export async function handleForkCommand(interaction) {
@@ -50,8 +51,14 @@ export async function handleForkCommand(interaction) {
50
51
  // Defer reply before API calls to avoid 3-second timeout
51
52
  await interaction.deferReply({ ephemeral: true });
52
53
  const sessionId = row.session_id;
54
+ const getClient = await initializeOpencodeForDirectory(directory);
55
+ if (getClient instanceof Error) {
56
+ await interaction.editReply({
57
+ content: `Failed to load messages: ${getClient.message}`,
58
+ });
59
+ return;
60
+ }
53
61
  try {
54
- const getClient = await initializeOpencodeForDirectory(directory);
55
62
  const messagesResponse = await getClient().session.messages({
56
63
  path: { id: sessionId },
57
64
  });
@@ -120,8 +127,12 @@ export async function handleForkSelectMenu(interaction) {
120
127
  return;
121
128
  }
122
129
  await interaction.deferReply({ ephemeral: false });
130
+ const getClient = await initializeOpencodeForDirectory(directory);
131
+ if (getClient instanceof Error) {
132
+ await interaction.editReply(`Failed to fork session: ${getClient.message}`);
133
+ return;
134
+ }
123
135
  try {
124
- const getClient = await initializeOpencodeForDirectory(directory);
125
136
  const forkResponse = await getClient().session.fork({
126
137
  path: { id: sessionId },
127
138
  body: { messageID: selectedMessageId },
@@ -6,6 +6,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
6
6
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
7
7
  import { abortAndRetrySession } from '../session-handler.js';
8
8
  import { createLogger } from '../logger.js';
9
+ import * as errore from 'errore';
9
10
  const modelLogger = createLogger('MODEL');
10
11
  // Store context by hash to avoid customId length limits (Discord max: 100 chars)
11
12
  const pendingModelContexts = new Map();
@@ -77,6 +78,10 @@ export async function handleModelCommand({ interaction, appId, }) {
77
78
  }
78
79
  try {
79
80
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
81
+ if (getClient instanceof Error) {
82
+ await interaction.editReply({ content: getClient.message });
83
+ return;
84
+ }
80
85
  const providersResponse = await getClient().provider.list({
81
86
  query: { directory: projectDirectory },
82
87
  });
@@ -162,6 +167,13 @@ export async function handleProviderSelectMenu(interaction) {
162
167
  }
163
168
  try {
164
169
  const getClient = await initializeOpencodeForDirectory(context.dir);
170
+ if (getClient instanceof Error) {
171
+ await interaction.editReply({
172
+ content: getClient.message,
173
+ components: [],
174
+ });
175
+ return;
176
+ }
165
177
  const providersResponse = await getClient().provider.list({
166
178
  query: { directory: context.dir },
167
179
  });
@@ -1,5 +1,6 @@
1
1
  // /remove-project command - Remove Discord channels for a project.
2
2
  import path from 'node:path';
3
+ import * as errore from 'errore';
3
4
  import { getDatabase } from '../database.js';
4
5
  import { createLogger } from '../logger.js';
5
6
  import { abbreviatePath } from '../utils.js';
@@ -25,20 +26,27 @@ export async function handleRemoveProjectCommand({ command, appId }) {
25
26
  const deletedChannels = [];
26
27
  const failedChannels = [];
27
28
  for (const { channel_id, channel_type } of channels) {
28
- try {
29
- const channel = await guild.channels.fetch(channel_id).catch(() => null);
30
- if (channel) {
29
+ const channel = await errore.tryAsync({
30
+ try: () => guild.channels.fetch(channel_id),
31
+ catch: (e) => e,
32
+ });
33
+ if (channel instanceof Error) {
34
+ logger.error(`Failed to fetch channel ${channel_id}:`, channel);
35
+ failedChannels.push(`${channel_type}: ${channel_id}`);
36
+ continue;
37
+ }
38
+ if (channel) {
39
+ try {
31
40
  await channel.delete(`Removed by /remove-project command`);
32
41
  deletedChannels.push(`${channel_type}: ${channel_id}`);
33
42
  }
34
- else {
35
- // Channel doesn't exist in this guild or was already deleted
36
- deletedChannels.push(`${channel_type}: ${channel_id} (already deleted)`);
43
+ catch (error) {
44
+ logger.error(`Failed to delete channel ${channel_id}:`, error);
45
+ failedChannels.push(`${channel_type}: ${channel_id}`);
37
46
  }
38
47
  }
39
- catch (error) {
40
- logger.error(`Failed to delete channel ${channel_id}:`, error);
41
- failedChannels.push(`${channel_type}: ${channel_id}`);
48
+ else {
49
+ deletedChannels.push(`${channel_type}: ${channel_id} (already deleted)`);
42
50
  }
43
51
  }
44
52
  // Remove from database
@@ -76,14 +84,16 @@ export async function handleRemoveProjectAutocomplete({ interaction, appId, }) {
76
84
  // Filter to only channels that exist in this guild
77
85
  const projectsInGuild = [];
78
86
  for (const { directory, channel_id } of allChannels) {
79
- try {
80
- const channel = await guild.channels.fetch(channel_id).catch(() => null);
81
- if (channel) {
82
- projectsInGuild.push({ directory, channelId: channel_id });
83
- }
84
- }
85
- catch {
87
+ const channel = await errore.tryAsync({
88
+ try: () => guild.channels.fetch(channel_id),
89
+ catch: (e) => e,
90
+ });
91
+ if (channel instanceof Error) {
86
92
  // Channel not in this guild, skip
93
+ continue;
94
+ }
95
+ if (channel) {
96
+ projectsInGuild.push({ directory, channelId: channel_id });
87
97
  }
88
98
  }
89
99
  const projects = projectsInGuild
@@ -7,6 +7,7 @@ import { sendThreadMessage, resolveTextChannel, getKimakiMetadata } from '../dis
7
7
  import { extractTagsArrays } from '../xml.js';
8
8
  import { collectLastAssistantParts } from '../message-formatting.js';
9
9
  import { createLogger } from '../logger.js';
10
+ import * as errore from 'errore';
10
11
  const logger = createLogger('RESUME');
11
12
  export async function handleResumeCommand({ command, appId }) {
12
13
  await command.deferReply({ ephemeral: false });
@@ -41,6 +42,10 @@ export async function handleResumeCommand({ command, appId }) {
41
42
  }
42
43
  try {
43
44
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
45
+ if (getClient instanceof Error) {
46
+ await command.editReply(getClient.message);
47
+ return;
48
+ }
44
49
  const sessionResponse = await getClient().session.get({
45
50
  path: { id: sessionId },
46
51
  });
@@ -111,6 +116,10 @@ export async function handleResumeAutocomplete({ interaction, appId, }) {
111
116
  }
112
117
  try {
113
118
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
119
+ if (getClient instanceof Error) {
120
+ await interaction.respond([]);
121
+ return;
122
+ }
114
123
  const sessionsResponse = await getClient().session.list();
115
124
  if (!sessionsResponse.data) {
116
125
  await interaction.respond([]);
@@ -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';
@@ -8,6 +8,7 @@ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
8
8
  import { extractTagsArrays } from '../xml.js';
9
9
  import { handleOpencodeSession } from '../session-handler.js';
10
10
  import { createLogger } from '../logger.js';
11
+ import * as errore from 'errore';
11
12
  const logger = createLogger('SESSION');
12
13
  export async function handleSessionCommand({ command, appId }) {
13
14
  await command.deferReply({ ephemeral: false });
@@ -44,6 +45,10 @@ export async function handleSessionCommand({ command, appId }) {
44
45
  }
45
46
  try {
46
47
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
48
+ if (getClient instanceof Error) {
49
+ await command.editReply(getClient.message);
50
+ return;
51
+ }
47
52
  const files = filesString
48
53
  .split(',')
49
54
  .map((f) => f.trim())
@@ -102,6 +107,10 @@ async function handleAgentAutocomplete({ interaction, appId }) {
102
107
  }
103
108
  try {
104
109
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
110
+ if (getClient instanceof Error) {
111
+ await interaction.respond([]);
112
+ return;
113
+ }
105
114
  const agentsResponse = await getClient().app.agents({
106
115
  query: { directory: projectDirectory },
107
116
  });
@@ -165,6 +174,10 @@ export async function handleSessionAutocomplete({ interaction, appId, }) {
165
174
  }
166
175
  try {
167
176
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
177
+ if (getClient instanceof Error) {
178
+ await interaction.respond([]);
179
+ return;
180
+ }
168
181
  const response = await getClient().find.files({
169
182
  query: {
170
183
  query: currentQuery || '',
@@ -4,6 +4,7 @@ import { getDatabase } from '../database.js';
4
4
  import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
6
  import { createLogger } from '../logger.js';
7
+ import * as errore from 'errore';
7
8
  const logger = createLogger('SHARE');
8
9
  export async function handleShareCommand({ command }) {
9
10
  const channel = command.channel;
@@ -50,8 +51,16 @@ export async function handleShareCommand({ command }) {
50
51
  return;
51
52
  }
52
53
  const sessionId = row.session_id;
54
+ const getClient = await initializeOpencodeForDirectory(directory);
55
+ if (getClient instanceof Error) {
56
+ await command.reply({
57
+ content: `Failed to share session: ${getClient.message}`,
58
+ ephemeral: true,
59
+ flags: SILENT_MESSAGE_FLAGS,
60
+ });
61
+ return;
62
+ }
53
63
  try {
54
- const getClient = await initializeOpencodeForDirectory(directory);
55
64
  const response = await getClient().session.share({
56
65
  path: { id: sessionId },
57
66
  });
@@ -4,6 +4,7 @@ import { getDatabase } from '../database.js';
4
4
  import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
6
  import { createLogger } from '../logger.js';
7
+ import * as errore from 'errore';
7
8
  const logger = createLogger('UNDO-REDO');
8
9
  export async function handleUndoCommand({ command }) {
9
10
  const channel = command.channel;
@@ -50,9 +51,13 @@ export async function handleUndoCommand({ command }) {
50
51
  return;
51
52
  }
52
53
  const sessionId = row.session_id;
54
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
55
+ const getClient = await initializeOpencodeForDirectory(directory);
56
+ if (getClient instanceof Error) {
57
+ await command.editReply(`Failed to undo: ${getClient.message}`);
58
+ return;
59
+ }
53
60
  try {
54
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
55
- const getClient = await initializeOpencodeForDirectory(directory);
56
61
  // Fetch messages to find the last assistant message
57
62
  const messagesResponse = await getClient().session.messages({
58
63
  path: { id: sessionId },
@@ -133,9 +138,13 @@ export async function handleRedoCommand({ command }) {
133
138
  return;
134
139
  }
135
140
  const sessionId = row.session_id;
141
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
142
+ const getClient = await initializeOpencodeForDirectory(directory);
143
+ if (getClient instanceof Error) {
144
+ await command.editReply(`Failed to redo: ${getClient.message}`);
145
+ return;
146
+ }
136
147
  try {
137
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
138
- const getClient = await initializeOpencodeForDirectory(directory);
139
148
  // Check if session has reverted state
140
149
  const sessionResponse = await getClient().session.get({
141
150
  path: { id: sessionId },
@@ -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
+ }