kimaki 0.17.0 → 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 (76) hide show
  1. package/dist/bin.js +5 -2
  2. package/dist/cache-drift-plugin.js +176 -0
  3. package/dist/channel-management.js +6 -8
  4. package/dist/cli-commands/bot.js +26 -0
  5. package/dist/cli-commands/session.js +4 -4
  6. package/dist/cli-runner.js +87 -22
  7. package/dist/cli-runner.test.js +23 -0
  8. package/dist/cli.js +5 -0
  9. package/dist/commands/ask-question.js +13 -3
  10. package/dist/commands/ask-question.test.js +18 -2
  11. package/dist/commands/last-sessions.js +3 -2
  12. package/dist/commands/merge-worktree.js +11 -10
  13. package/dist/commands/new-worktree.js +55 -25
  14. package/dist/commands/worktrees.js +92 -70
  15. package/dist/database.js +54 -0
  16. package/dist/db.js +13 -0
  17. package/dist/db.test.js +12 -11
  18. package/dist/discord-bot.js +59 -14
  19. package/dist/discord-utils.js +9 -8
  20. package/dist/format-tables.js +138 -1
  21. package/dist/format-tables.test.js +224 -1
  22. package/dist/git-worktree-core.js +296 -0
  23. package/dist/image-utils.js +1 -1
  24. package/dist/interaction-handler.js +92 -7
  25. package/dist/kimaki-opencode-plugin.js +2 -0
  26. package/dist/kimaki-workspace-adaptor.js +95 -0
  27. package/dist/opencode.js +1 -0
  28. package/dist/schema.js +16 -0
  29. package/dist/session-handler/thread-session-runtime.js +15 -15
  30. package/dist/store.js +1 -0
  31. package/dist/system-message.js +12 -0
  32. package/dist/system-message.test.js +12 -0
  33. package/dist/voice-handler.js +14 -0
  34. package/dist/worktree-lifecycle.e2e.test.js +187 -21
  35. package/dist/worktrees.js +13 -61
  36. package/dist/worktrees.test.js +17 -0
  37. package/package.json +3 -3
  38. package/skills/playwriter/SKILL.md +1 -1
  39. package/skills/sigillo/SKILL.md +2 -2
  40. package/src/bin.ts +8 -2
  41. package/src/cache-drift-plugin.ts +239 -0
  42. package/src/channel-management.ts +6 -8
  43. package/src/cli-commands/bot.ts +34 -0
  44. package/src/cli-commands/session.ts +4 -4
  45. package/src/cli-runner.test.ts +27 -0
  46. package/src/cli-runner.ts +102 -26
  47. package/src/cli.ts +8 -0
  48. package/src/commands/ask-question.test.ts +20 -1
  49. package/src/commands/ask-question.ts +25 -7
  50. package/src/commands/last-sessions.ts +4 -2
  51. package/src/commands/merge-worktree.ts +12 -12
  52. package/src/commands/new-worktree.ts +66 -27
  53. package/src/commands/worktrees.ts +104 -89
  54. package/src/database.ts +90 -0
  55. package/src/db.test.ts +12 -11
  56. package/src/db.ts +14 -0
  57. package/src/discord-bot.ts +76 -18
  58. package/src/discord-utils.ts +9 -8
  59. package/src/format-tables.test.ts +246 -0
  60. package/src/format-tables.ts +176 -1
  61. package/src/git-worktree-core.ts +389 -0
  62. package/src/image-utils.ts +5 -4
  63. package/src/interaction-handler.ts +102 -9
  64. package/src/kimaki-opencode-plugin.ts +2 -0
  65. package/src/kimaki-workspace-adaptor.ts +115 -0
  66. package/src/opencode.ts +2 -1
  67. package/src/schema.sql +13 -0
  68. package/src/schema.ts +18 -0
  69. package/src/session-handler/thread-session-runtime.ts +15 -15
  70. package/src/store.ts +8 -0
  71. package/src/system-message.test.ts +12 -0
  72. package/src/system-message.ts +12 -0
  73. package/src/voice-handler.ts +21 -0
  74. package/src/worktree-lifecycle.e2e.test.ts +229 -24
  75. package/src/worktrees.test.ts +19 -0
  76. package/src/worktrees.ts +13 -75
@@ -3,7 +3,7 @@
3
3
  // Preserves all commits (no squash). On rebase conflicts, asks the AI model
4
4
  // in the thread to resolve them.
5
5
  import {} from 'discord.js';
6
- import { getThreadWorktree, getThreadSession, getChannelDirectory, } from '../database.js';
6
+ import { getThreadWorktreeOrWorkspace, getThreadSession, getChannelDirectory, } from '../database.js';
7
7
  import { createLogger, LogPrefix } from '../logger.js';
8
8
  import { notifyError } from '../sentry.js';
9
9
  import { mergeWorktree, listBranchesByLastCommit, validateBranchRef } from '../worktrees.js';
@@ -63,20 +63,21 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
63
63
  return;
64
64
  }
65
65
  const thread = channel;
66
- const worktreeInfo = await getThreadWorktree(thread.id);
67
- if (!worktreeInfo) {
66
+ // Check both thread_workspaces (new) and thread_worktrees (legacy)
67
+ const info = await getThreadWorktreeOrWorkspace(thread.id);
68
+ if (!info) {
68
69
  await command.editReply('This thread is not associated with a worktree');
69
70
  return;
70
71
  }
71
- if (worktreeInfo.status !== 'ready' || !worktreeInfo.worktree_directory) {
72
- await command.editReply(`Worktree is not ready (status: ${worktreeInfo.status})${worktreeInfo.error_message ? `: ${worktreeInfo.error_message}` : ''}`);
72
+ if (info.status !== 'ready' || !info.workspace_directory) {
73
+ await command.editReply(`Worktree is not ready (status: ${info.status})${info.error_message ? `: ${info.error_message}` : ''}`);
73
74
  return;
74
75
  }
75
76
  const rawTargetBranch = command.options.getString('target-branch') || undefined;
76
77
  let targetBranch = rawTargetBranch;
77
78
  if (targetBranch) {
78
79
  const validated = await validateBranchRef({
79
- directory: worktreeInfo.project_directory,
80
+ directory: info.project_directory,
80
81
  ref: targetBranch,
81
82
  });
82
83
  if (validated instanceof Error) {
@@ -86,9 +87,9 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
86
87
  targetBranch = validated;
87
88
  }
88
89
  const result = await mergeWorktree({
89
- worktreeDir: worktreeInfo.worktree_directory,
90
- mainRepoDir: worktreeInfo.project_directory,
91
- worktreeName: worktreeInfo.worktree_name,
90
+ worktreeDir: info.workspace_directory,
91
+ mainRepoDir: info.project_directory,
92
+ worktreeName: info.workspace_name,
92
93
  targetBranch,
93
94
  onProgress: (msg) => {
94
95
  logger.log(`[merge] ${msg}`);
@@ -126,7 +127,7 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
126
127
  '9. Once the rebase is fully complete, tell me so I can run `/merge-worktree` again',
127
128
  ].join('\n'),
128
129
  thread,
129
- projectDirectory: worktreeInfo.project_directory,
130
+ projectDirectory: info.project_directory,
130
131
  command,
131
132
  appId,
132
133
  });
@@ -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
- import { splitTablesFromMarkdown } from '../format-tables.js';
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.
@@ -132,7 +133,9 @@ function buildWorktreeTable({ rows, gitStatuses, guildId, }) {
132
133
  return parts.join(', ');
133
134
  })();
134
135
  const created = row.createdAt ? formatTimeAgo(row.createdAt) : '-';
135
- const folder = row.directory;
136
+ // Show only the last 2 path segments to keep text size under Discord's
137
+ // 4000-char displayable text limit. Full paths are too long.
138
+ const folder = `…/${path.basename(path.dirname(row.directory))}/${path.basename(row.directory)}`;
136
139
  const action = buildActionCell({ row, gitStatus: gs });
137
140
  return `| ${sourceCell} | ${name} | ${status} | ${created} | ${folder} | ${action} |`;
138
141
  });
@@ -142,11 +145,7 @@ function buildActionCell({ row, gitStatus, }) {
142
145
  if (!canDeleteWorktree({ row, gitStatus })) {
143
146
  return '-';
144
147
  }
145
- return buildDeleteButtonHtml({
146
- buttonId: `del-wt-${worktreeButtonKey(row.directory)}`,
147
- });
148
- }
149
- function buildDeleteButtonHtml({ buttonId, }) {
148
+ const buttonId = `del-wt-${worktreeButtonKey(row.directory)}`;
150
149
  return `<button id="${buttonId}" variant="secondary">Delete</button>`;
151
150
  }
152
151
  function canDeleteWorktree({ row, gitStatus, }) {
@@ -196,21 +195,24 @@ async function resolveGitStatuses({ rows, projectDirectory, timeout, }) {
196
195
  // in the git list (pending/error) are appended at the end.
197
196
  async function buildWorktreeRows({ projectDirectory, gitWorktrees, }) {
198
197
  const db = await getDb();
199
- const dbWorktrees = await db.query.thread_worktrees.findMany({
198
+ const dbWorkspaces = await db.query.thread_workspaces.findMany({
200
199
  where: { project_directory: projectDirectory },
201
200
  });
202
- // 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
203
207
  const dbByDirectory = new Map();
204
- for (const dbWt of dbWorktrees) {
205
- if (dbWt.worktree_directory) {
206
- dbByDirectory.set(dbWt.worktree_directory, dbWt);
208
+ for (const ws of dbWorkspaces) {
209
+ if (ws.workspace_directory) {
210
+ dbByDirectory.set(ws.workspace_directory, ws);
207
211
  }
208
212
  }
209
213
  // Track which DB rows got matched so we can append unmatched ones
210
214
  const matchedDbThreadIds = new Set();
211
215
  // Build rows from git worktrees (the source of truth for on-disk state).
212
- // Use real DB status when available — a git-visible worktree whose DB row
213
- // is still 'pending' means setup hasn't finished (race window).
214
216
  const gitRows = gitWorktrees.map((gw) => {
215
217
  const dbMatch = dbByDirectory.get(gw.directory);
216
218
  if (dbMatch) {
@@ -222,15 +224,12 @@ async function buildWorktreeRows({ projectDirectory, gitWorktrees, }) {
222
224
  });
223
225
  const name = gw.branch ?? path.basename(gw.directory);
224
226
  const dbStatus = (() => {
225
- if (!dbMatch) {
227
+ if (!dbMatch)
226
228
  return 'ready';
227
- }
228
- if (dbMatch.status === 'error') {
229
+ if (dbMatch.status === 'error')
229
230
  return 'error';
230
- }
231
- if (dbMatch.status === 'pending') {
231
+ if (dbMatch.status === 'pending')
232
232
  return 'pending';
233
- }
234
233
  return 'ready';
235
234
  })();
236
235
  return {
@@ -238,48 +237,33 @@ async function buildWorktreeRows({ projectDirectory, gitWorktrees, }) {
238
237
  branch: gw.branch,
239
238
  name,
240
239
  threadId: dbMatch?.thread_id ?? null,
241
- guildId: null, // filled in by caller
242
- createdAt: dbMatch?.created_at ?? null,
240
+ guildId: null,
241
+ createdAt: toDate(dbMatch?.created_at),
243
242
  source,
243
+ workspaceId: dbMatch?.workspace_id ?? null,
244
244
  dbStatus,
245
245
  locked: gw.locked,
246
246
  prunable: gw.prunable,
247
247
  };
248
248
  });
249
- // Append DB-only worktrees (pending/error/stale — not visible to git).
250
- // Preserve actual DB status so stale 'ready' rows show as 'ready' (missing).
251
- const dbOnlyRows = dbWorktrees
252
- .filter((dbWt) => {
253
- return !matchedDbThreadIds.has(dbWt.thread_id);
254
- })
255
- .map((dbWt) => {
256
- const dbStatus = (() => {
257
- if (dbWt.status === 'error') {
258
- return 'error';
259
- }
260
- if (dbWt.status === 'pending') {
261
- return 'pending';
262
- }
263
- return 'ready';
264
- })();
265
- return {
266
- directory: dbWt.worktree_directory ?? dbWt.project_directory,
267
- branch: null,
268
- name: dbWt.worktree_name,
269
- threadId: dbWt.thread_id,
270
- guildId: null,
271
- createdAt: dbWt.created_at,
272
- source: 'kimaki',
273
- dbStatus,
274
- locked: false,
275
- prunable: false,
276
- };
277
- });
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
+ }));
278
265
  return [...gitRows, ...dbOnlyRows];
279
266
  }
280
- function getWorktreesActionOwnerKey({ userId, channelId, }) {
281
- return `worktrees:${userId}:${channelId}`;
282
- }
283
267
  function isProjectChannel(channel) {
284
268
  if (!channel) {
285
269
  return false;
@@ -291,8 +275,19 @@ function isProjectChannel(channel) {
291
275
  ChannelType.AnnouncementThread,
292
276
  ].includes(channel.type);
293
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
+ }
294
289
  async function renderWorktreesReply({ guildId, userId, channelId, projectDirectory, notice, editReply, }) {
295
- const ownerKey = getWorktreesActionOwnerKey({ userId, channelId });
290
+ const ownerKey = `worktrees:${userId}:${channelId}`;
296
291
  cancelHtmlActionsForOwner(ownerKey);
297
292
  const gitWorktrees = await listGitWorktrees({
298
293
  projectDirectory,
@@ -360,7 +355,7 @@ async function renderWorktreesReply({ guildId, userId, channelId, projectDirecto
360
355
  return buildHtmlActionCustomId(actionId);
361
356
  },
362
357
  });
363
- const components = segments.flatMap((segment) => {
358
+ const allComponents = segments.flatMap((segment) => {
364
359
  if (segment.type === 'components') {
365
360
  return segment.components;
366
361
  }
@@ -370,6 +365,20 @@ async function renderWorktreesReply({ guildId, userId, channelId, projectDirecto
370
365
  };
371
366
  return [textDisplay];
372
367
  });
368
+ // Reserve budget for a truncation notice (1 component + its text length)
369
+ // so appending the notice doesn't push us over either Discord limit.
370
+ const truncatedNoticeContent = `*Some worktrees were not shown due to Discord's component limit. Use \`git worktree list\` for the full list.*`;
371
+ const { components, truncated } = truncateComponents(allComponents, {
372
+ reserveCost: 1,
373
+ reserveTextSize: truncatedNoticeContent.length,
374
+ });
375
+ if (truncated) {
376
+ const truncatedNotice = {
377
+ type: ComponentType.TextDisplay,
378
+ content: truncatedNoticeContent,
379
+ };
380
+ components.push(truncatedNotice);
381
+ }
373
382
  await editReply({
374
383
  components,
375
384
  flags: MessageFlags.IsComponentsV2,
@@ -389,14 +398,17 @@ async function handleDeleteWorktreeAction({ interaction, row, projectDirectory,
389
398
  });
390
399
  return;
391
400
  }
392
- // Pass branch name for branch cleanup. Empty string for detached HEAD
393
- // 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.
394
404
  const displayName = row.branch ?? row.name;
395
- const deleteResult = await deleteWorktree({
396
- projectDirectory,
397
- worktreeDirectory: row.directory,
398
- worktreeName: row.branch ?? '',
399
- });
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
+ });
400
412
  if (deleteResult instanceof Error) {
401
413
  const gitStderr = extractGitStderr(deleteResult);
402
414
  const detail = gitStderr
@@ -412,9 +424,8 @@ async function handleDeleteWorktreeAction({ interaction, row, projectDirectory,
412
424
  });
413
425
  return;
414
426
  }
415
- // Clean up DB row if this was a kimaki-tracked worktree
416
427
  if (row.threadId) {
417
- await deleteThreadWorktree(row.threadId);
428
+ await deleteThreadWorkspace(row.threadId);
418
429
  }
419
430
  await renderWorktreesReply({
420
431
  guildId,
@@ -427,6 +438,19 @@ async function handleDeleteWorktreeAction({ interaction, row, projectDirectory,
427
438
  },
428
439
  });
429
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
+ }
430
454
  export async function handleWorktreesCommand({ command, }) {
431
455
  const channel = command.channel;
432
456
  const guildId = command.guildId;
@@ -444,9 +468,7 @@ export async function handleWorktreesCommand({ command, }) {
444
468
  });
445
469
  return;
446
470
  }
447
- const resolved = await resolveWorkingDirectory({
448
- channel: channel,
449
- });
471
+ const resolved = await resolveWorktreesWorkingDirectory(channel);
450
472
  if (!resolved) {
451
473
  await command.reply({
452
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 () => {