kimaki 0.17.1 → 0.18.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.
Files changed (68) hide show
  1. package/dist/bin.js +5 -2
  2. package/dist/cache-drift-plugin.js +176 -0
  3. package/dist/cli-commands/bot.js +26 -0
  4. package/dist/cli-commands/session.js +4 -4
  5. package/dist/cli-runner.js +70 -18
  6. package/dist/cli-runner.test.js +23 -0
  7. package/dist/cli.js +5 -0
  8. package/dist/commands/ask-question.js +13 -3
  9. package/dist/commands/ask-question.test.js +18 -2
  10. package/dist/commands/merge-worktree.js +11 -10
  11. package/dist/commands/new-worktree.js +55 -25
  12. package/dist/commands/worktrees.js +73 -67
  13. package/dist/database.js +54 -0
  14. package/dist/db.js +13 -0
  15. package/dist/db.test.js +12 -11
  16. package/dist/discord-bot.js +59 -14
  17. package/dist/discord-utils.js +9 -8
  18. package/dist/git-worktree-core.js +296 -0
  19. package/dist/image-utils.js +1 -1
  20. package/dist/interaction-handler.js +66 -0
  21. package/dist/kimaki-opencode-plugin.js +2 -0
  22. package/dist/kimaki-workspace-adaptor.js +95 -0
  23. package/dist/opencode.js +1 -0
  24. package/dist/schema.js +16 -0
  25. package/dist/session-handler/thread-session-runtime.js +15 -15
  26. package/dist/store.js +1 -0
  27. package/dist/system-message.js +12 -0
  28. package/dist/system-message.test.js +12 -0
  29. package/dist/voice-handler.js +8 -0
  30. package/dist/worktree-lifecycle.e2e.test.js +187 -21
  31. package/dist/worktrees.js +13 -61
  32. package/dist/worktrees.test.js +17 -0
  33. package/package.json +4 -4
  34. package/skills/playwriter/SKILL.md +1 -1
  35. package/skills/sigillo/SKILL.md +2 -2
  36. package/src/bin.ts +8 -2
  37. package/src/cache-drift-plugin.ts +239 -0
  38. package/src/cli-commands/bot.ts +34 -0
  39. package/src/cli-commands/session.ts +4 -4
  40. package/src/cli-runner.test.ts +27 -0
  41. package/src/cli-runner.ts +83 -20
  42. package/src/cli.ts +8 -0
  43. package/src/commands/ask-question.test.ts +20 -1
  44. package/src/commands/ask-question.ts +25 -7
  45. package/src/commands/merge-worktree.ts +12 -12
  46. package/src/commands/new-worktree.ts +66 -27
  47. package/src/commands/worktrees.ts +84 -86
  48. package/src/database.ts +90 -0
  49. package/src/db.test.ts +12 -11
  50. package/src/db.ts +14 -0
  51. package/src/discord-bot.ts +76 -18
  52. package/src/discord-utils.ts +9 -8
  53. package/src/git-worktree-core.ts +389 -0
  54. package/src/image-utils.ts +5 -4
  55. package/src/interaction-handler.ts +77 -0
  56. package/src/kimaki-opencode-plugin.ts +2 -0
  57. package/src/kimaki-workspace-adaptor.ts +115 -0
  58. package/src/opencode.ts +2 -1
  59. package/src/schema.sql +13 -0
  60. package/src/schema.ts +18 -0
  61. package/src/session-handler/thread-session-runtime.ts +15 -15
  62. package/src/store.ts +8 -0
  63. package/src/system-message.test.ts +12 -0
  64. package/src/system-message.ts +12 -0
  65. package/src/voice-handler.ts +11 -0
  66. package/src/worktree-lifecycle.e2e.test.ts +229 -24
  67. package/src/worktrees.test.ts +19 -0
  68. package/src/worktrees.ts +13 -75
@@ -4,11 +4,11 @@
4
4
  import { ChannelType, REST, } from 'discord.js';
5
5
  import fs from 'node:fs';
6
6
  import { OpenCodeSdkError } from '../errors.js';
7
- import { createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelDirectory, getThreadSession, setThreadSession, } from '../database.js';
7
+ import { createPendingWorkspace, setWorkspaceReady, setWorkspaceError, getChannelDirectory, getThreadSession, setThreadSession, } from '../database.js';
8
8
  import { SILENT_MESSAGE_FLAGS, reactToThread, resolveProjectDirectoryFromAutocomplete, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
9
9
  import { createLogger, LogPrefix } from '../logger.js';
10
10
  import { notifyError } from '../sentry.js';
11
- import { createWorktreeWithSubmodules, execAsync, listBranchesByLastCommit, validateBranchRef, } from '../worktrees.js';
11
+ import { execAsync, listBranchesByLastCommit, validateBranchRef, } from '../worktrees.js';
12
12
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
13
13
  import { buildSessionPermissions, initializeOpencodeForDirectory, } from '../opencode.js';
14
14
  import { WORKTREE_PREFIX } from './merge-worktree.js';
@@ -139,10 +139,40 @@ async function getProjectDirectoryFromChannel(channel) {
139
139
  return channelConfig.directory;
140
140
  }
141
141
  /**
142
- * Create worktree and update the status message when done.
143
- * Handles the full lifecycle: pending DB entry, git creation, DB ready/error,
142
+ * Try creating a worktree via the OpenCode workspace SDK.
143
+ * Returns the workspace directory on success, or an Error if the workspace
144
+ * feature is not available or the creation fails. Callers fall back to the
145
+ * direct git path on Error.
146
+ */
147
+ async function tryWorkspaceCreate({ threadId, worktreeName, projectDirectory, baseBranch, }) {
148
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
149
+ if (getClient instanceof Error)
150
+ return getClient;
151
+ const client = getClient();
152
+ const response = await client.experimental.workspace.create({
153
+ directory: projectDirectory,
154
+ type: 'kimaki-worktree',
155
+ branch: worktreeName,
156
+ extra: baseBranch ? { baseBranch } : null,
157
+ }).catch((e) => new OpenCodeSdkError({ operation: 'workspace.create', cause: e }));
158
+ if (response instanceof Error)
159
+ return response;
160
+ if (response.error) {
161
+ return new Error(`Workspace creation failed: ${JSON.stringify(response.error)}`);
162
+ }
163
+ const workspace = response.data;
164
+ if (!workspace?.directory) {
165
+ return new Error('Workspace SDK returned no directory');
166
+ }
167
+ return { directory: workspace.directory, workspaceId: workspace.id };
168
+ }
169
+ /**
170
+ * Create worktree via the OpenCode workspace SDK and update the status message.
171
+ * Handles the full lifecycle: pending DB entry, SDK creation, DB ready/error,
144
172
  * tree emoji reaction, and editing the status message.
145
173
  *
174
+ * Uses thread_workspaces table exclusively (no legacy thread_worktrees).
175
+ *
146
176
  * starterMessage is optional — if omitted, status edits are skipped (creation
147
177
  * still proceeds). This keeps worktree creation independent of Discord message
148
178
  * delivery, so a transient send failure never silently skips the worktree.
@@ -163,33 +193,32 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
163
193
  })
164
194
  .catch(() => { });
165
195
  };
166
- // DB pending entry must complete before git creation so error paths
167
- // (setWorktreeError) can find the row reliably
168
- await createPendingWorktree({
196
+ // Write pending row BEFORE workspace creation so restarts/follow-up
197
+ // messages can see the in-progress state.
198
+ await createPendingWorkspace({
169
199
  threadId: thread.id,
170
- worktreeName,
200
+ workspaceType: 'kimaki-worktree',
201
+ workspaceName: worktreeName,
171
202
  projectDirectory,
172
203
  });
173
- const worktreeResult = await createWorktreeWithSubmodules({
174
- directory: projectDirectory,
175
- name: worktreeName,
204
+ const workspaceResult = await tryWorkspaceCreate({
205
+ threadId: thread.id,
206
+ worktreeName,
207
+ projectDirectory,
176
208
  baseBranch,
177
- onProgress: (phase) => {
178
- editStatus(`🌳 **Worktree: ${worktreeName}**\n${phase}`);
179
- },
180
209
  });
181
- if (worktreeResult instanceof Error) {
182
- const errorMsg = worktreeResult.message;
183
- logger.error('[WORKTREE] Creation failed:', worktreeResult);
184
- await setWorktreeError({ threadId: thread.id, errorMessage: errorMsg });
210
+ if (workspaceResult instanceof Error) {
211
+ const errorMsg = workspaceResult.message;
212
+ logger.error('[WORKTREE] Workspace creation failed:', workspaceResult);
213
+ await setWorkspaceError({ threadId: thread.id, errorMessage: errorMsg });
185
214
  editStatus(`🌳 **Worktree: ${worktreeName}**\n❌ ${errorMsg}`);
186
215
  await editChain;
187
- return worktreeResult;
216
+ return workspaceResult;
188
217
  }
189
- // DB ready update is critical; reaction is best-effort
190
- await setWorktreeReady({
218
+ await setWorkspaceReady({
191
219
  threadId: thread.id,
192
- worktreeDirectory: worktreeResult.directory,
220
+ workspaceId: workspaceResult.workspaceId,
221
+ workspaceDirectory: workspaceResult.directory,
193
222
  });
194
223
  void reactToThread({
195
224
  rest,
@@ -198,10 +227,11 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
198
227
  emoji: '🌳',
199
228
  }).catch(() => { });
200
229
  editStatus(`🌳 **Worktree: ${worktreeName}**\n` +
201
- `📁 \`${worktreeResult.directory}\`\n` +
202
- `🌿 Branch: \`${worktreeResult.branch}\``);
230
+ `📁 \`${workspaceResult.directory}\`\n` +
231
+ `🌿 Branch: \`${worktreeName}\``);
203
232
  await editChain;
204
- return worktreeResult.directory;
233
+ logger.log(`[WORKTREE] Created via workspace SDK: ${workspaceResult.directory}`);
234
+ return workspaceResult.directory;
205
235
  })().catch((e) => {
206
236
  logger.error('[WORKTREE] Unexpected error in createWorktreeInBackground:', e);
207
237
  return new Error(`Worktree creation failed: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
@@ -5,14 +5,15 @@
5
5
  // Renders a markdown table that the CV2 pipeline auto-formats for Discord,
6
6
  // including HTML-backed action buttons for deletable worktrees.
7
7
  import { ButtonInteraction, ChatInputCommandInteraction, ChannelType, ComponentType, MessageFlags, } from 'discord.js';
8
- import { deleteThreadWorktree, } from '../database.js';
8
+ import { deleteThreadWorkspace, } from '../database.js';
9
9
  import { getDb } from '../db.js';
10
10
  import { splitTablesFromMarkdown, truncateComponents } from '../format-tables.js';
11
11
  import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
12
12
  import * as errore from 'errore';
13
13
  import crypto from 'node:crypto';
14
- import { GitCommandError } from '../errors.js';
14
+ import { GitCommandError, OpenCodeSdkError } from '../errors.js';
15
15
  import { resolveWorkingDirectory } from '../discord-utils.js';
16
+ import { initializeOpencodeForDirectory } from '../opencode.js';
16
17
  import { deleteWorktree, git, getDefaultBranch, listGitWorktrees, } from '../worktrees.js';
17
18
  import path from 'node:path';
18
19
  // Extracts the git stderr from a deleteWorktree error via errore.findCause.
@@ -144,11 +145,7 @@ function buildActionCell({ row, gitStatus, }) {
144
145
  if (!canDeleteWorktree({ row, gitStatus })) {
145
146
  return '-';
146
147
  }
147
- return buildDeleteButtonHtml({
148
- buttonId: `del-wt-${worktreeButtonKey(row.directory)}`,
149
- });
150
- }
151
- function buildDeleteButtonHtml({ buttonId, }) {
148
+ const buttonId = `del-wt-${worktreeButtonKey(row.directory)}`;
152
149
  return `<button id="${buttonId}" variant="secondary">Delete</button>`;
153
150
  }
154
151
  function canDeleteWorktree({ row, gitStatus, }) {
@@ -198,21 +195,24 @@ async function resolveGitStatuses({ rows, projectDirectory, timeout, }) {
198
195
  // in the git list (pending/error) are appended at the end.
199
196
  async function buildWorktreeRows({ projectDirectory, gitWorktrees, }) {
200
197
  const db = await getDb();
201
- const dbWorktrees = await db.query.thread_worktrees.findMany({
198
+ const dbWorkspaces = await db.query.thread_workspaces.findMany({
202
199
  where: { project_directory: projectDirectory },
203
200
  });
204
- // Index DB worktrees by directory for fast lookup
201
+ const toDate = (v) => {
202
+ if (!v)
203
+ return null;
204
+ return v instanceof Date ? v : new Date(v);
205
+ };
206
+ // Index by directory for fast lookup
205
207
  const dbByDirectory = new Map();
206
- for (const dbWt of dbWorktrees) {
207
- if (dbWt.worktree_directory) {
208
- dbByDirectory.set(dbWt.worktree_directory, dbWt);
208
+ for (const ws of dbWorkspaces) {
209
+ if (ws.workspace_directory) {
210
+ dbByDirectory.set(ws.workspace_directory, ws);
209
211
  }
210
212
  }
211
213
  // Track which DB rows got matched so we can append unmatched ones
212
214
  const matchedDbThreadIds = new Set();
213
215
  // Build rows from git worktrees (the source of truth for on-disk state).
214
- // Use real DB status when available — a git-visible worktree whose DB row
215
- // is still 'pending' means setup hasn't finished (race window).
216
216
  const gitRows = gitWorktrees.map((gw) => {
217
217
  const dbMatch = dbByDirectory.get(gw.directory);
218
218
  if (dbMatch) {
@@ -224,15 +224,12 @@ async function buildWorktreeRows({ projectDirectory, gitWorktrees, }) {
224
224
  });
225
225
  const name = gw.branch ?? path.basename(gw.directory);
226
226
  const dbStatus = (() => {
227
- if (!dbMatch) {
227
+ if (!dbMatch)
228
228
  return 'ready';
229
- }
230
- if (dbMatch.status === 'error') {
229
+ if (dbMatch.status === 'error')
231
230
  return 'error';
232
- }
233
- if (dbMatch.status === 'pending') {
231
+ if (dbMatch.status === 'pending')
234
232
  return 'pending';
235
- }
236
233
  return 'ready';
237
234
  })();
238
235
  return {
@@ -240,48 +237,33 @@ async function buildWorktreeRows({ projectDirectory, gitWorktrees, }) {
240
237
  branch: gw.branch,
241
238
  name,
242
239
  threadId: dbMatch?.thread_id ?? null,
243
- guildId: null, // filled in by caller
244
- createdAt: dbMatch?.created_at ?? null,
240
+ guildId: null,
241
+ createdAt: toDate(dbMatch?.created_at),
245
242
  source,
243
+ workspaceId: dbMatch?.workspace_id ?? null,
246
244
  dbStatus,
247
245
  locked: gw.locked,
248
246
  prunable: gw.prunable,
249
247
  };
250
248
  });
251
- // Append DB-only worktrees (pending/error/stale — not visible to git).
252
- // Preserve actual DB status so stale 'ready' rows show as 'ready' (missing).
253
- const dbOnlyRows = dbWorktrees
254
- .filter((dbWt) => {
255
- return !matchedDbThreadIds.has(dbWt.thread_id);
256
- })
257
- .map((dbWt) => {
258
- const dbStatus = (() => {
259
- if (dbWt.status === 'error') {
260
- return 'error';
261
- }
262
- if (dbWt.status === 'pending') {
263
- return 'pending';
264
- }
265
- return 'ready';
266
- })();
267
- return {
268
- directory: dbWt.worktree_directory ?? dbWt.project_directory,
269
- branch: null,
270
- name: dbWt.worktree_name,
271
- threadId: dbWt.thread_id,
272
- guildId: null,
273
- createdAt: dbWt.created_at,
274
- source: 'kimaki',
275
- dbStatus,
276
- locked: false,
277
- prunable: false,
278
- };
279
- });
249
+ // Append DB-only workspaces (pending/error/stale — not visible to git).
250
+ const dbOnlyRows = dbWorkspaces
251
+ .filter((ws) => !matchedDbThreadIds.has(ws.thread_id))
252
+ .map((ws) => ({
253
+ directory: ws.workspace_directory ?? ws.project_directory,
254
+ branch: null,
255
+ name: ws.workspace_name,
256
+ threadId: ws.thread_id,
257
+ guildId: null,
258
+ createdAt: toDate(ws.created_at),
259
+ source: 'kimaki',
260
+ workspaceId: ws.workspace_id,
261
+ dbStatus: ws.status === 'error' ? 'error' : ws.status === 'pending' ? 'pending' : 'ready',
262
+ locked: false,
263
+ prunable: false,
264
+ }));
280
265
  return [...gitRows, ...dbOnlyRows];
281
266
  }
282
- function getWorktreesActionOwnerKey({ userId, channelId, }) {
283
- return `worktrees:${userId}:${channelId}`;
284
- }
285
267
  function isProjectChannel(channel) {
286
268
  if (!channel) {
287
269
  return false;
@@ -293,8 +275,19 @@ function isProjectChannel(channel) {
293
275
  ChannelType.AnnouncementThread,
294
276
  ].includes(channel.type);
295
277
  }
278
+ function resolveWorktreesWorkingDirectory(channel) {
279
+ switch (channel.type) {
280
+ case ChannelType.GuildText:
281
+ case ChannelType.PublicThread:
282
+ case ChannelType.PrivateThread:
283
+ case ChannelType.AnnouncementThread:
284
+ return resolveWorkingDirectory({ channel });
285
+ default:
286
+ return undefined;
287
+ }
288
+ }
296
289
  async function renderWorktreesReply({ guildId, userId, channelId, projectDirectory, notice, editReply, }) {
297
- const ownerKey = getWorktreesActionOwnerKey({ userId, channelId });
290
+ const ownerKey = `worktrees:${userId}:${channelId}`;
298
291
  cancelHtmlActionsForOwner(ownerKey);
299
292
  const gitWorktrees = await listGitWorktrees({
300
293
  projectDirectory,
@@ -405,14 +398,17 @@ async function handleDeleteWorktreeAction({ interaction, row, projectDirectory,
405
398
  });
406
399
  return;
407
400
  }
408
- // Pass branch name for branch cleanup. Empty string for detached HEAD
409
- // worktrees so deleteWorktree skips the `git branch -d` step.
401
+ // SDK-created workspaces must be removed through OpenCode so its workspace
402
+ // table stays in sync. Legacy/manual worktrees have no workspace_id, so they
403
+ // still use the direct git cleanup path.
410
404
  const displayName = row.branch ?? row.name;
411
- const deleteResult = await deleteWorktree({
412
- projectDirectory,
413
- worktreeDirectory: row.directory,
414
- worktreeName: row.branch ?? '',
415
- });
405
+ const deleteResult = row.workspaceId
406
+ ? await deleteWorkspace({ projectDirectory, workspaceId: row.workspaceId })
407
+ : await deleteWorktree({
408
+ projectDirectory,
409
+ worktreeDirectory: row.directory,
410
+ worktreeName: row.branch ?? '',
411
+ });
416
412
  if (deleteResult instanceof Error) {
417
413
  const gitStderr = extractGitStderr(deleteResult);
418
414
  const detail = gitStderr
@@ -428,9 +424,8 @@ async function handleDeleteWorktreeAction({ interaction, row, projectDirectory,
428
424
  });
429
425
  return;
430
426
  }
431
- // Clean up DB row if this was a kimaki-tracked worktree
432
427
  if (row.threadId) {
433
- await deleteThreadWorktree(row.threadId);
428
+ await deleteThreadWorkspace(row.threadId);
434
429
  }
435
430
  await renderWorktreesReply({
436
431
  guildId,
@@ -443,6 +438,19 @@ async function handleDeleteWorktreeAction({ interaction, row, projectDirectory,
443
438
  },
444
439
  });
445
440
  }
441
+ async function deleteWorkspace({ projectDirectory, workspaceId, }) {
442
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
443
+ if (getClient instanceof Error)
444
+ return getClient;
445
+ const response = await getClient().experimental.workspace.remove({
446
+ id: workspaceId,
447
+ directory: projectDirectory,
448
+ }).catch((e) => new OpenCodeSdkError({ operation: 'workspace.remove', cause: e }));
449
+ if (response instanceof Error)
450
+ return response;
451
+ if (response.error)
452
+ return new Error(`Workspace removal failed: ${JSON.stringify(response.error)}`);
453
+ }
446
454
  export async function handleWorktreesCommand({ command, }) {
447
455
  const channel = command.channel;
448
456
  const guildId = command.guildId;
@@ -460,9 +468,7 @@ export async function handleWorktreesCommand({ command, }) {
460
468
  });
461
469
  return;
462
470
  }
463
- const resolved = await resolveWorkingDirectory({
464
- channel: channel,
465
- });
471
+ const resolved = await resolveWorktreesWorkingDirectory(channel);
466
472
  if (!resolved) {
467
473
  await command.reply({
468
474
  content: 'Could not determine the project folder for this channel.',
package/dist/database.js CHANGED
@@ -276,6 +276,60 @@ export async function deleteThreadWorktree(threadId) {
276
276
  const db = await getDb();
277
277
  await db.delete(schema.thread_worktrees).where(orm.eq(schema.thread_worktrees.thread_id, threadId));
278
278
  }
279
+ export async function createPendingWorkspace({ threadId, workspaceType, workspaceName, projectDirectory, }) {
280
+ const db = await getDb();
281
+ await db.batch([
282
+ db.insert(schema.thread_sessions)
283
+ .values({ thread_id: threadId, session_id: '' })
284
+ .onConflictDoNothing({ target: schema.thread_sessions.thread_id }),
285
+ db.insert(schema.thread_workspaces)
286
+ .values({
287
+ thread_id: threadId,
288
+ workspace_type: workspaceType,
289
+ workspace_name: workspaceName,
290
+ project_directory: projectDirectory,
291
+ status: 'pending',
292
+ })
293
+ .onConflictDoUpdate({
294
+ target: schema.thread_workspaces.thread_id,
295
+ set: {
296
+ workspace_type: workspaceType,
297
+ workspace_name: workspaceName,
298
+ project_directory: projectDirectory,
299
+ status: 'pending',
300
+ workspace_id: null,
301
+ workspace_directory: null,
302
+ error_message: null,
303
+ },
304
+ }),
305
+ ]);
306
+ }
307
+ export async function setWorkspaceReady({ threadId, workspaceId, workspaceDirectory, }) {
308
+ const db = await getDb();
309
+ await db.update(schema.thread_workspaces)
310
+ .set({
311
+ workspace_directory: workspaceDirectory,
312
+ workspace_id: workspaceId ?? null,
313
+ status: 'ready',
314
+ })
315
+ .where(orm.eq(schema.thread_workspaces.thread_id, threadId));
316
+ }
317
+ export async function setWorkspaceError({ threadId, errorMessage, }) {
318
+ const db = await getDb();
319
+ await db.update(schema.thread_workspaces)
320
+ .set({ status: 'error', error_message: errorMessage })
321
+ .where(orm.eq(schema.thread_workspaces.thread_id, threadId));
322
+ }
323
+ export async function getThreadWorkspace(threadId) {
324
+ const db = await getDb();
325
+ return await db.query.thread_workspaces.findFirst({ where: { thread_id: threadId } }) ?? undefined;
326
+ }
327
+ export async function deleteThreadWorkspace(threadId) {
328
+ const db = await getDb();
329
+ await db.delete(schema.thread_workspaces).where(orm.eq(schema.thread_workspaces.thread_id, threadId));
330
+ }
331
+ /** Alias for getThreadWorkspace — used throughout the codebase. */
332
+ export const getThreadWorktreeOrWorkspace = getThreadWorkspace;
279
333
  export async function getChannelVerbosity(channelId) {
280
334
  const db = await getDb();
281
335
  const row = await db.query.channel_verbosity.findFirst({ where: { channel_id: channelId } });
package/dist/db.js CHANGED
@@ -142,6 +142,19 @@ async function migrateSchema({ db, client, }) {
142
142
  for (const stmt of migrationStatements) {
143
143
  await client.execute(stmt).catch(() => undefined);
144
144
  }
145
+ // Migrate legacy thread_worktrees rows into thread_workspaces.
146
+ // Rows that already exist in thread_workspaces (by thread_id) are skipped.
147
+ await client.execute(`
148
+ INSERT OR IGNORE INTO thread_workspaces (
149
+ thread_id, workspace_type, status, error_message,
150
+ project_directory, workspace_directory, workspace_name, created_at
151
+ )
152
+ SELECT
153
+ thread_id, 'kimaki-worktree', status, error_message,
154
+ project_directory, worktree_directory, worktree_name, created_at
155
+ FROM thread_worktrees
156
+ WHERE thread_id NOT IN (SELECT thread_id FROM thread_workspaces)
157
+ `).catch(() => undefined);
145
158
  const botRows = await db.query.bot_tokens.findMany({
146
159
  columns: {
147
160
  app_id: true,
package/dist/db.test.js CHANGED
@@ -7,7 +7,7 @@ import { afterAll, describe, expect, test } from 'vitest';
7
7
  import { closeDb, getDb } from './db.js';
8
8
  import * as orm from 'drizzle-orm';
9
9
  import * as schema from './schema.js';
10
- import { appendSessionEventsSinceLastTimestamp, createPendingWorktree, getSessionEventSnapshot, getSessionModel, setSessionModel, } from './database.js';
10
+ import { appendSessionEventsSinceLastTimestamp, createPendingWorkspace, getSessionEventSnapshot, getSessionModel, setSessionModel, } from './database.js';
11
11
  import { startHranaServer, stopHranaServer } from './hrana-server.js';
12
12
  import { chooseLockPort } from './test-utils.js';
13
13
  import { copyCurrentSessionModel } from './commands/model.js';
@@ -78,12 +78,13 @@ describe('getDb', () => {
78
78
  }
79
79
  }
80
80
  });
81
- test('createPendingWorktree creates parent and child rows', async () => {
81
+ test('createPendingWorkspace creates parent and child rows', async () => {
82
82
  const db = await getDb();
83
- const threadId = `test-worktree-${Date.now()}`;
84
- await createPendingWorktree({
83
+ const threadId = `test-workspace-${Date.now()}`;
84
+ await createPendingWorkspace({
85
85
  threadId,
86
- worktreeName: 'regression-worktree',
86
+ workspaceType: 'kimaki-worktree',
87
+ workspaceName: 'regression-workspace',
87
88
  projectDirectory: '/tmp/regression-project',
88
89
  });
89
90
  const session = await db.query.thread_sessions.findFirst({
@@ -91,14 +92,14 @@ describe('getDb', () => {
91
92
  });
92
93
  expect(session).toBeTruthy();
93
94
  expect(session?.session_id).toBe('');
94
- const worktree = await db.query.thread_worktrees.findFirst({
95
+ const workspace = await db.query.thread_workspaces.findFirst({
95
96
  where: { thread_id: threadId },
96
97
  });
97
- expect(worktree).toBeTruthy();
98
- expect(worktree?.worktree_name).toBe('regression-worktree');
99
- expect(worktree?.project_directory).toBe('/tmp/regression-project');
100
- expect(worktree?.status).toBe('pending');
101
- await db.delete(schema.thread_worktrees).where(orm.eq(schema.thread_worktrees.thread_id, threadId));
98
+ expect(workspace).toBeTruthy();
99
+ expect(workspace?.workspace_name).toBe('regression-workspace');
100
+ expect(workspace?.project_directory).toBe('/tmp/regression-project');
101
+ expect(workspace?.status).toBe('pending');
102
+ await db.delete(schema.thread_workspaces).where(orm.eq(schema.thread_workspaces.thread_id, threadId));
102
103
  await db.delete(schema.thread_sessions).where(orm.eq(schema.thread_sessions.thread_id, threadId));
103
104
  });
104
105
  test('copyCurrentSessionModel snapshots source session model to forked session', async () => {
@@ -1,8 +1,14 @@
1
1
  // Core Discord bot module that handles message events and bot lifecycle.
2
2
  // Bridges Discord messages to OpenCode sessions, manages voice connections,
3
3
  // and orchestrates the main event loop for the Kimaki bot.
4
+ //
5
+ // Shutdown resilience: during self-restart (gateway reconnect limit, SIGUSR2),
6
+ // discord.js can still fire errors from pending async operations (DNS lookups,
7
+ // WebSocket frames) after the client is destroyed. The uncaughtException handler
8
+ // suppresses these when shuttingDown is already set, and we removeAllListeners()
9
+ // before destroying the client to prevent late events from becoming uncaught.
4
10
  import { DiscordOperationError } from './errors.js';
5
- import { initDatabase, closeDatabase, getThreadWorktree, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, cancelAllPendingIpcRequests, deleteChannelDirectoryById, createPendingWorktree, setWorktreeReady, } from './database.js';
11
+ import { initDatabase, closeDatabase, getThreadWorktreeOrWorkspace, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, cancelAllPendingIpcRequests, deleteChannelDirectoryById, createPendingWorkspace, setWorkspaceReady, } from './database.js';
6
12
  import { stopOpencodeServer, } from './opencode.js';
7
13
  import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
8
14
  import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js';
@@ -359,6 +365,32 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
359
365
  }
360
366
  }
361
367
  }
368
+ // Multi-machine routing: check channel ownership before permission
369
+ // checks so we don't send permission-denial replies in channels owned
370
+ // by another machine. Resolve the "owning" channel: for threads, check
371
+ // the parent channel; for text channels, check the channel itself.
372
+ if (!isCliInjectedPrompt && message.guild) {
373
+ const channel = message.channel;
374
+ let owningChannelId;
375
+ if ([
376
+ ChannelType.PublicThread,
377
+ ChannelType.PrivateThread,
378
+ ChannelType.AnnouncementThread,
379
+ ].includes(channel.type)) {
380
+ const thread = channel;
381
+ owningChannelId = thread.parent?.id || thread.parentId || undefined;
382
+ }
383
+ else {
384
+ owningChannelId = channel.id;
385
+ }
386
+ if (owningChannelId) {
387
+ const channelConfig = await getChannelDirectory(owningChannelId);
388
+ if (!channelConfig) {
389
+ voiceLogger.log(`[IGNORED] Channel ${owningChannelId} has no project directory configured`);
390
+ return;
391
+ }
392
+ }
393
+ }
362
394
  if (!isCliInjectedPrompt && message.guild) {
363
395
  const member = await resolveGuildMessageMember(message);
364
396
  if (!member) {
@@ -392,7 +424,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
392
424
  // thread itself (e.g. /new-worktree, /fork, kimaki send). This prevents
393
425
  // the bot from hijacking user-created threads in project channels while
394
426
  // still responding to bot-created threads that may not yet have a session
395
- // row with a non-empty session_id (createPendingWorktree sets ''). (GitHub #84)
427
+ // row with a non-empty session_id (createPendingWorkspace sets ''). (GitHub #84)
396
428
  const hasExistingSession = await getThreadSession(thread.id);
397
429
  const botMentioned = discordClient.user && message.mentions.has(discordClient.user.id);
398
430
  const botCreatedThread = discordClient.user && thread.ownerId === discordClient.user.id;
@@ -416,7 +448,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
416
448
  // the preprocess chain (messages queue behind the worktree promise).
417
449
  // After a bot restart the runtime is gone, so we must reject messages
418
450
  // for pending worktrees to avoid running in the base directory.
419
- const worktreeInfo = await getThreadWorktree(thread.id);
451
+ // Check both thread_workspaces (new) and thread_worktrees (legacy)
452
+ const worktreeInfo = await getThreadWorktreeOrWorkspace(thread.id);
420
453
  if (worktreeInfo) {
421
454
  if (worktreeInfo.status === 'pending' && !getRuntime(thread.id)) {
422
455
  await message.reply({
@@ -436,7 +469,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
436
469
  // The worktree directory is passed via query.directory in prompt/command calls
437
470
  if (worktreeInfo.project_directory) {
438
471
  projectDirectory = worktreeInfo.project_directory;
439
- discordLogger.log(`Using project directory: ${projectDirectory} (worktree: ${worktreeInfo.worktree_directory})`);
472
+ discordLogger.log(`Using project directory: ${projectDirectory} (worktree: ${worktreeInfo.workspace_directory})`);
440
473
  }
441
474
  }
442
475
  if (projectDirectory && !fs.existsSync(projectDirectory)) {
@@ -456,8 +489,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
456
489
  const shellCmd = message.content.slice(1).trim();
457
490
  if (shellCmd) {
458
491
  const shellDir = worktreeInfo?.status === 'ready' &&
459
- worktreeInfo.worktree_directory
460
- ? worktreeInfo.worktree_directory
492
+ worktreeInfo.workspace_directory
493
+ ? worktreeInfo.workspace_directory
461
494
  : projectDirectory;
462
495
  const loadingReply = await message.reply({
463
496
  content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
@@ -514,8 +547,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
514
547
  }
515
548
  const resolvedProjectDir = projectDirectory;
516
549
  const sdkDir = worktreeInfo?.status === 'ready' &&
517
- worktreeInfo.worktree_directory
518
- ? worktreeInfo.worktree_directory
550
+ worktreeInfo.workspace_directory
551
+ ? worktreeInfo.workspace_directory
519
552
  : resolvedProjectDir;
520
553
  const runtime = getOrCreateRuntime({
521
554
  threadId: thread.id,
@@ -895,7 +928,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
895
928
  }
896
929
  // --cwd: reuse an existing project subfolder or worktree directory. Revalidate at bot-time
897
930
  // (CLI validated at send-time but the path could become stale).
898
- // Only worktree directories are stored in thread_worktrees. Project
931
+ // Only worktree directories are stored in thread_workspaces. Project
899
932
  // subfolders simply become the OpenCode session directory.
900
933
  // --cwd: if it matches projectDirectory, ignore silently (already the default).
901
934
  let cwdDirectory;
@@ -921,14 +954,15 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
921
954
  const cwdWorktreeName = branchResult instanceof Error
922
955
  ? path.basename(cwdDirectory)
923
956
  : branchResult;
924
- await createPendingWorktree({
957
+ await createPendingWorkspace({
925
958
  threadId: thread.id,
926
- worktreeName: cwdWorktreeName,
959
+ workspaceType: 'kimaki-worktree',
960
+ workspaceName: cwdWorktreeName,
927
961
  projectDirectory,
928
962
  });
929
- await setWorktreeReady({
963
+ await setWorkspaceReady({
930
964
  threadId: thread.id,
931
- worktreeDirectory: cwdDirectory,
965
+ workspaceDirectory: cwdDirectory,
932
966
  });
933
967
  // React with tree emoji to mark as worktree thread
934
968
  await reactToThread({
@@ -1048,7 +1082,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1048
1082
  discordLogger.log('Already shutting down, ignoring duplicate signal');
1049
1083
  return;
1050
1084
  }
1051
- ;
1052
1085
  global.shuttingDown = true;
1053
1086
  try {
1054
1087
  await stopRuntimeIdleSweeper();
@@ -1078,6 +1111,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1078
1111
  discordLogger.log('Stopping hrana server...');
1079
1112
  await stopHranaServer();
1080
1113
  discordLogger.log('Destroying Discord client...');
1114
+ // Remove all listeners before destroy to prevent late-arriving shard
1115
+ // errors (from pending DNS lookups, WebSocket frames) from becoming
1116
+ // uncaught exceptions after the client's internal handlers are torn down.
1117
+ discordClient.removeAllListeners();
1081
1118
  void discordClient.destroy();
1082
1119
  discordLogger.log('Cleanup complete.');
1083
1120
  if (!skipExit) {
@@ -1150,6 +1187,14 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1150
1187
  void selfRestart('SIGUSR2');
1151
1188
  });
1152
1189
  process.on('uncaughtException', (error) => {
1190
+ // During self-restart or shutdown, discord.js can still fire errors from
1191
+ // pending async operations (DNS lookups, WebSocket frames) after the client
1192
+ // is destroyed. These are expected and must not interfere with the restart
1193
+ // flow — let the existing selfRestart/handleShutdown finish cleanly.
1194
+ if (selfRestarting || global.shuttingDown) {
1195
+ discordLogger.log('Ignoring uncaught exception during shutdown:', error?.message || String(error));
1196
+ return;
1197
+ }
1153
1198
  discordLogger.error('Uncaught exception:', formatErrorWithStack(error));
1154
1199
  notifyError(error, 'Uncaught exception in bot process');
1155
1200
  void handleShutdown('uncaughtException', { skipExit: true }).catch((shutdownError) => {