@otto-assistant/bridge 0.4.102 → 0.4.103

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 (70) hide show
  1. package/dist/agent-model.e2e.test.js +1 -0
  2. package/dist/anthropic-auth-plugin.js +22 -1
  3. package/dist/anthropic-auth-state.js +31 -0
  4. package/dist/btw-prefix-detection.js +17 -0
  5. package/dist/btw-prefix-detection.test.js +63 -0
  6. package/dist/cli.js +101 -15
  7. package/dist/commands/agent.js +21 -2
  8. package/dist/commands/ask-question.js +50 -4
  9. package/dist/commands/ask-question.test.js +92 -0
  10. package/dist/commands/btw.js +71 -66
  11. package/dist/commands/new-worktree.js +92 -35
  12. package/dist/commands/queue.js +17 -0
  13. package/dist/commands/worktrees.js +196 -139
  14. package/dist/context-awareness-plugin.js +16 -8
  15. package/dist/context-awareness-plugin.test.js +4 -2
  16. package/dist/discord-bot.js +35 -2
  17. package/dist/discord-command-registration.js +9 -2
  18. package/dist/memory-overview-plugin.js +3 -1
  19. package/dist/opencode.js +9 -0
  20. package/dist/queue-question-select-drain.e2e.test.js +135 -10
  21. package/dist/session-handler/thread-runtime-state.js +27 -0
  22. package/dist/session-handler/thread-session-runtime.js +58 -28
  23. package/dist/session-title-rename.test.js +12 -0
  24. package/dist/skill-filter.js +31 -0
  25. package/dist/skill-filter.test.js +65 -0
  26. package/dist/store.js +2 -0
  27. package/dist/system-message.js +12 -3
  28. package/dist/system-message.test.js +10 -6
  29. package/dist/thread-message-queue.e2e.test.js +109 -0
  30. package/dist/worktree-lifecycle.e2e.test.js +4 -1
  31. package/dist/worktrees.js +106 -12
  32. package/dist/worktrees.test.js +232 -6
  33. package/package.json +2 -2
  34. package/skills/goke/SKILL.md +13 -619
  35. package/skills/new-skill/SKILL.md +34 -10
  36. package/skills/npm-package/SKILL.md +336 -2
  37. package/skills/profano/SKILL.md +24 -0
  38. package/skills/zele/SKILL.md +50 -21
  39. package/src/agent-model.e2e.test.ts +1 -0
  40. package/src/anthropic-auth-plugin.ts +24 -4
  41. package/src/anthropic-auth-state.ts +45 -0
  42. package/src/btw-prefix-detection.test.ts +73 -0
  43. package/src/btw-prefix-detection.ts +23 -0
  44. package/src/cli.ts +138 -46
  45. package/src/commands/agent.ts +24 -2
  46. package/src/commands/ask-question.test.ts +111 -0
  47. package/src/commands/ask-question.ts +69 -4
  48. package/src/commands/btw.ts +105 -85
  49. package/src/commands/new-worktree.ts +107 -40
  50. package/src/commands/queue.ts +22 -0
  51. package/src/commands/worktrees.ts +246 -154
  52. package/src/context-awareness-plugin.test.ts +4 -2
  53. package/src/context-awareness-plugin.ts +16 -8
  54. package/src/discord-bot.ts +40 -2
  55. package/src/discord-command-registration.ts +12 -2
  56. package/src/memory-overview-plugin.ts +3 -1
  57. package/src/opencode.ts +9 -0
  58. package/src/queue-question-select-drain.e2e.test.ts +174 -10
  59. package/src/session-handler/thread-runtime-state.ts +36 -1
  60. package/src/session-handler/thread-session-runtime.ts +72 -32
  61. package/src/session-title-rename.test.ts +18 -0
  62. package/src/skill-filter.test.ts +83 -0
  63. package/src/skill-filter.ts +42 -0
  64. package/src/store.ts +17 -0
  65. package/src/system-message.test.ts +10 -6
  66. package/src/system-message.ts +12 -3
  67. package/src/thread-message-queue.e2e.test.ts +126 -0
  68. package/src/worktree-lifecycle.e2e.test.ts +6 -1
  69. package/src/worktrees.test.ts +274 -9
  70. package/src/worktrees.ts +144 -23
@@ -1,15 +1,20 @@
1
- // /worktrees command — list worktree sessions for the current channel's project.
1
+ // /worktrees command — list all git worktrees for the current channel's project.
2
+ // Uses `git worktree list --porcelain` as source of truth, enriched with
3
+ // DB metadata (thread link, created_at) when available. Shows kimaki-created,
4
+ // opencode-created, and manually created worktrees in a single table.
2
5
  // Renders a markdown table that the CV2 pipeline auto-formats for Discord,
3
6
  // including HTML-backed action buttons for deletable worktrees.
4
7
  import { ButtonInteraction, ChatInputCommandInteraction, ChannelType, ComponentType, MessageFlags, } from 'discord.js';
5
- import { deleteThreadWorktree, getThreadWorktree, } from '../database.js';
8
+ import { deleteThreadWorktree, } from '../database.js';
6
9
  import { getPrisma } from '../db.js';
7
10
  import { splitTablesFromMarkdown } from '../format-tables.js';
8
11
  import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
9
12
  import * as errore from 'errore';
13
+ import crypto from 'node:crypto';
10
14
  import { GitCommandError } from '../errors.js';
11
15
  import { resolveWorkingDirectory } from '../discord-utils.js';
12
- import { deleteWorktree, git, getDefaultBranch } from '../worktrees.js';
16
+ import { deleteWorktree, git, getDefaultBranch, listGitWorktrees, } from '../worktrees.js';
17
+ import path from 'node:path';
13
18
  // Extracts the git stderr from a deleteWorktree error via errore.findCause.
14
19
  // Chain: Error { cause: GitCommandError { cause: CommandError { stderr } } }.
15
20
  export function extractGitStderr(error) {
@@ -42,33 +47,36 @@ export function formatTimeAgo(date) {
42
47
  const remainingHours = hours % 24;
43
48
  return remainingHours > 0 ? `${days}d ${remainingHours}h ago` : `${days}d ago`;
44
49
  }
45
- function statusLabel(wt) {
46
- if (wt.status === 'ready') {
47
- return 'ready';
48
- }
49
- if (wt.status === 'error') {
50
- return 'error';
51
- }
52
- return 'pending';
50
+ // Stable button ID derived from directory path via sha1 hash.
51
+ // Avoids collisions that truncated path suffixes can cause.
52
+ function worktreeButtonKey(directory) {
53
+ return crypto.createHash('sha1').update(directory).digest('hex').slice(0, 12);
53
54
  }
54
55
  // 5s timeout per git call — prevents hangs from deleted dirs, git locks, slow disks.
55
56
  // Returns null on timeout/error so the table shows "unknown" for that worktree.
56
57
  const GIT_CMD_TIMEOUT = 5_000;
57
58
  const GLOBAL_TIMEOUT = 10_000;
58
- // Checks dirty state and commits ahead of default branch in parallel.
59
- // Returns null for worktrees that aren't ready or when the directory is
60
- // missing / git commands fail / timeout (e.g. deleted worktree folder).
61
- async function getWorktreeGitStatus({ wt, defaultBranch, }) {
62
- if (wt.status !== 'ready' || !wt.worktree_directory) {
63
- return null;
59
+ // Detect worktree source from branch name and directory path.
60
+ // opencode/kimaki-* branches kimaki, opencode worktree paths opencode, else manual.
61
+ function detectWorktreeSource({ branch, directory, }) {
62
+ if (branch?.startsWith('opencode/kimaki-')) {
63
+ return 'kimaki';
64
+ }
65
+ // opencode stores worktrees under ~/.local/share/opencode/worktree/
66
+ if (directory.includes('/opencode/worktree/')) {
67
+ return 'opencode';
64
68
  }
69
+ return 'manual';
70
+ }
71
+ // Checks dirty state and commits ahead of default branch in parallel.
72
+ // Returns null when the directory is missing / git commands fail / timeout.
73
+ async function getWorktreeGitStatus({ directory, defaultBranch, }) {
65
74
  try {
66
- const dir = wt.worktree_directory;
67
75
  // Use raw git calls so errors/timeouts are visible — isDirty() swallows
68
76
  // errors and returns false, which would render "merged" instead of "unknown".
69
77
  const [statusResult, aheadResult] = await Promise.all([
70
- git(dir, 'status --porcelain', { timeout: GIT_CMD_TIMEOUT }),
71
- git(dir, `rev-list --count "${defaultBranch}..HEAD"`, {
78
+ git(directory, 'status --porcelain', { timeout: GIT_CMD_TIMEOUT }),
79
+ git(directory, `rev-list --count "${defaultBranch}..HEAD"`, {
72
80
  timeout: GIT_CMD_TIMEOUT,
73
81
  }),
74
82
  ]);
@@ -85,16 +93,28 @@ async function getWorktreeGitStatus({ wt, defaultBranch, }) {
85
93
  return null;
86
94
  }
87
95
  }
88
- function buildWorktreeTable({ worktrees, gitStatuses, guildId, }) {
89
- const header = '| Thread | Name | Status | Created | Folder | Action |';
96
+ function buildWorktreeTable({ rows, gitStatuses, guildId, }) {
97
+ const header = '| Source | Name | Status | Created | Folder | Action |';
90
98
  const separator = '|---|---|---|---|---|---|';
91
- const rows = worktrees.map((wt, i) => {
92
- const threadLink = `[thread](https://discord.com/channels/${guildId}/${wt.thread_id})`;
93
- const name = wt.worktree_name;
99
+ const tableRows = rows.map((row, i) => {
100
+ const sourceCell = (() => {
101
+ if (row.threadId && row.guildId) {
102
+ const threadLink = `[${row.source}](https://discord.com/channels/${row.guildId}/${row.threadId})`;
103
+ return threadLink;
104
+ }
105
+ return row.source;
106
+ })();
107
+ const name = row.name;
94
108
  const gs = gitStatuses[i] ?? null;
95
109
  const status = (() => {
96
- if (wt.status !== 'ready') {
97
- return statusLabel(wt);
110
+ if (row.dbStatus !== 'ready') {
111
+ return row.dbStatus;
112
+ }
113
+ if (row.locked) {
114
+ return 'locked';
115
+ }
116
+ if (row.prunable) {
117
+ return 'prunable';
98
118
  }
99
119
  if (!gs) {
100
120
  return 'unknown';
@@ -111,26 +131,29 @@ function buildWorktreeTable({ worktrees, gitStatuses, guildId, }) {
111
131
  }
112
132
  return parts.join(', ');
113
133
  })();
114
- const created = wt.created_at ? formatTimeAgo(wt.created_at) : 'unknown';
115
- const folder = wt.worktree_directory ?? wt.project_directory;
116
- const action = buildActionCell({ wt, gitStatus: gs });
117
- return `| ${threadLink} | ${name} | ${status} | ${created} | ${folder} | ${action} |`;
134
+ const created = row.createdAt ? formatTimeAgo(row.createdAt) : '-';
135
+ const folder = row.directory;
136
+ const action = buildActionCell({ row, gitStatus: gs });
137
+ return `| ${sourceCell} | ${name} | ${status} | ${created} | ${folder} | ${action} |`;
118
138
  });
119
- return [header, separator, ...rows].join('\n');
139
+ return [header, separator, ...tableRows].join('\n');
120
140
  }
121
- function buildActionCell({ wt, gitStatus, }) {
122
- if (!canDeleteWorktree({ wt, gitStatus })) {
141
+ function buildActionCell({ row, gitStatus, }) {
142
+ if (!canDeleteWorktree({ row, gitStatus })) {
123
143
  return '-';
124
144
  }
125
145
  return buildDeleteButtonHtml({
126
- buttonId: `delete-worktree-${wt.thread_id}`,
146
+ buttonId: `del-wt-${worktreeButtonKey(row.directory)}`,
127
147
  });
128
148
  }
129
149
  function buildDeleteButtonHtml({ buttonId, }) {
130
150
  return `<button id="${buttonId}" variant="secondary">Delete</button>`;
131
151
  }
132
- function canDeleteWorktree({ wt, gitStatus, }) {
133
- if (wt.status !== 'ready' || !wt.worktree_directory) {
152
+ function canDeleteWorktree({ row, gitStatus, }) {
153
+ if (row.dbStatus !== 'ready') {
154
+ return false;
155
+ }
156
+ if (row.locked) {
134
157
  return false;
135
158
  }
136
159
  if (!gitStatus) {
@@ -142,11 +165,8 @@ function canDeleteWorktree({ wt, gitStatus, }) {
142
165
  return gitStatus.aheadCount === 0;
143
166
  }
144
167
  // Resolves git statuses for all worktrees within a single global deadline.
145
- // Caches getDefaultBranch per project_directory to avoid redundant spawns.
146
- // Returns null for any worktree whose git calls fail, timeout, or exceed
147
- // the global deadline — the table renders those as "unknown".
148
- async function resolveGitStatuses({ worktrees, timeout, }) {
149
- const nullFallback = worktrees.map(() => null);
168
+ async function resolveGitStatuses({ rows, projectDirectory, timeout, }) {
169
+ const nullFallback = rows.map(() => null);
150
170
  let timer;
151
171
  const deadline = new Promise((resolve) => {
152
172
  timer = setTimeout(() => {
@@ -154,19 +174,14 @@ async function resolveGitStatuses({ worktrees, timeout, }) {
154
174
  }, timeout);
155
175
  });
156
176
  const work = (async () => {
157
- // Resolve default branch once per unique project directory (avoids
158
- // redundant git subprocess spawns when multiple worktrees share a project).
159
- const uniqueProjectDirs = [
160
- ...new Set(worktrees.map((wt) => wt.project_directory)),
161
- ];
162
- const defaultBranchEntries = await Promise.all(uniqueProjectDirs.map(async (dir) => {
163
- const branch = await getDefaultBranch(dir, { timeout: GIT_CMD_TIMEOUT });
164
- return [dir, branch];
165
- }));
166
- const defaultBranchByProject = new Map(defaultBranchEntries);
167
- return Promise.all(worktrees.map((wt) => {
168
- const defaultBranch = defaultBranchByProject.get(wt.project_directory) ?? 'main';
169
- return getWorktreeGitStatus({ wt, defaultBranch });
177
+ const defaultBranch = await getDefaultBranch(projectDirectory, {
178
+ timeout: GIT_CMD_TIMEOUT,
179
+ });
180
+ return Promise.all(rows.map((row) => {
181
+ if (row.dbStatus !== 'ready' || row.locked || row.prunable) {
182
+ return null;
183
+ }
184
+ return getWorktreeGitStatus({ directory: row.directory, defaultBranch });
170
185
  }));
171
186
  })();
172
187
  try {
@@ -176,15 +191,91 @@ async function resolveGitStatuses({ worktrees, timeout, }) {
176
191
  clearTimeout(timer);
177
192
  }
178
193
  }
179
- async function getRecentWorktrees({ projectDirectory, }) {
194
+ // Merge git worktrees with DB metadata into unified WorktreeRows.
195
+ // Git is the source of truth for what exists on disk. DB rows that aren't
196
+ // in the git list (pending/error) are appended at the end.
197
+ async function buildWorktreeRows({ projectDirectory, gitWorktrees, }) {
180
198
  const prisma = await getPrisma();
181
- return await prisma.thread_worktrees.findMany({
182
- where: {
183
- project_directory: projectDirectory,
184
- },
185
- orderBy: { created_at: 'desc' },
186
- take: 10,
199
+ const dbWorktrees = await prisma.thread_worktrees.findMany({
200
+ where: { project_directory: projectDirectory },
201
+ });
202
+ // Index DB worktrees by directory for fast lookup
203
+ const dbByDirectory = new Map();
204
+ for (const dbWt of dbWorktrees) {
205
+ if (dbWt.worktree_directory) {
206
+ dbByDirectory.set(dbWt.worktree_directory, dbWt);
207
+ }
208
+ }
209
+ // Track which DB rows got matched so we can append unmatched ones
210
+ const matchedDbThreadIds = new Set();
211
+ // 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
+ const gitRows = gitWorktrees.map((gw) => {
215
+ const dbMatch = dbByDirectory.get(gw.directory);
216
+ if (dbMatch) {
217
+ matchedDbThreadIds.add(dbMatch.thread_id);
218
+ }
219
+ const source = detectWorktreeSource({
220
+ branch: gw.branch,
221
+ directory: gw.directory,
222
+ });
223
+ const name = gw.branch ?? path.basename(gw.directory);
224
+ const dbStatus = (() => {
225
+ if (!dbMatch) {
226
+ return 'ready';
227
+ }
228
+ if (dbMatch.status === 'error') {
229
+ return 'error';
230
+ }
231
+ if (dbMatch.status === 'pending') {
232
+ return 'pending';
233
+ }
234
+ return 'ready';
235
+ })();
236
+ return {
237
+ directory: gw.directory,
238
+ branch: gw.branch,
239
+ name,
240
+ threadId: dbMatch?.thread_id ?? null,
241
+ guildId: null, // filled in by caller
242
+ createdAt: dbMatch?.created_at ?? null,
243
+ source,
244
+ dbStatus,
245
+ locked: gw.locked,
246
+ prunable: gw.prunable,
247
+ };
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
+ };
187
277
  });
278
+ return [...gitRows, ...dbOnlyRows];
188
279
  }
189
280
  function getWorktreesActionOwnerKey({ userId, channelId, }) {
190
281
  return `worktrees:${userId}:${channelId}`;
@@ -203,9 +294,21 @@ function isProjectChannel(channel) {
203
294
  async function renderWorktreesReply({ guildId, userId, channelId, projectDirectory, notice, editReply, }) {
204
295
  const ownerKey = getWorktreesActionOwnerKey({ userId, channelId });
205
296
  cancelHtmlActionsForOwner(ownerKey);
206
- const worktrees = await getRecentWorktrees({ projectDirectory });
207
- if (worktrees.length === 0) {
208
- const message = notice ? `${notice}\n\nNo worktrees found.` : 'No worktrees found.';
297
+ const gitWorktrees = await listGitWorktrees({
298
+ projectDirectory,
299
+ timeout: GIT_CMD_TIMEOUT,
300
+ });
301
+ // On git failure, fall back to empty list (DB-only rows still shown)
302
+ const gitList = gitWorktrees instanceof Error ? [] : gitWorktrees;
303
+ const rows = await buildWorktreeRows({ projectDirectory, gitWorktrees: gitList });
304
+ // Inject guildId into all rows for thread link rendering
305
+ for (const row of rows) {
306
+ row.guildId = guildId;
307
+ }
308
+ if (rows.length === 0) {
309
+ const message = notice
310
+ ? `${notice}\n\nNo worktrees found.`
311
+ : 'No worktrees found.';
209
312
  const textDisplay = {
210
313
  type: ComponentType.TextDisplay,
211
314
  content: message,
@@ -217,36 +320,40 @@ async function renderWorktreesReply({ guildId, userId, channelId, projectDirecto
217
320
  return;
218
321
  }
219
322
  const gitStatuses = await resolveGitStatuses({
220
- worktrees,
323
+ rows,
324
+ projectDirectory,
221
325
  timeout: GLOBAL_TIMEOUT,
222
326
  });
223
- const deletableWorktreesByButtonId = new Map();
224
- worktrees.forEach((wt, index) => {
327
+ // Map deletable worktrees by button ID for the HTML action resolver.
328
+ // Uses the same worktreeButtonKey() as buildActionCell.
329
+ const deletableRowsByButtonId = new Map();
330
+ rows.forEach((row, index) => {
225
331
  const gitStatus = gitStatuses[index] ?? null;
226
- if (!canDeleteWorktree({ wt, gitStatus })) {
332
+ if (!canDeleteWorktree({ row, gitStatus })) {
227
333
  return;
228
334
  }
229
- deletableWorktreesByButtonId.set(`delete-worktree-${wt.thread_id}`, wt);
335
+ deletableRowsByButtonId.set(`del-wt-${worktreeButtonKey(row.directory)}`, row);
230
336
  });
231
337
  const tableMarkdown = buildWorktreeTable({
232
- worktrees,
338
+ rows,
233
339
  gitStatuses,
234
340
  guildId,
235
341
  });
236
342
  const markdown = notice ? `${notice}\n\n${tableMarkdown}` : tableMarkdown;
237
343
  const segments = splitTablesFromMarkdown(markdown, {
238
344
  resolveButtonCustomId: ({ button }) => {
239
- const worktree = deletableWorktreesByButtonId.get(button.id);
240
- if (!worktree) {
345
+ const row = deletableRowsByButtonId.get(button.id);
346
+ if (!row) {
241
347
  return new Error(`No worktree registered for button ${button.id}`);
242
348
  }
243
349
  const actionId = registerHtmlAction({
244
350
  ownerKey,
245
- threadId: worktree.thread_id,
351
+ threadId: row.threadId ?? row.directory,
246
352
  run: async ({ interaction }) => {
247
353
  await handleDeleteWorktreeAction({
248
354
  interaction,
249
- threadId: worktree.thread_id,
355
+ row,
356
+ projectDirectory,
250
357
  });
251
358
  },
252
359
  });
@@ -268,7 +375,7 @@ async function renderWorktreesReply({ guildId, userId, channelId, projectDirecto
268
375
  flags: MessageFlags.IsComponentsV2,
269
376
  });
270
377
  }
271
- async function handleDeleteWorktreeAction({ interaction, threadId, }) {
378
+ async function handleDeleteWorktreeAction({ interaction, row, projectDirectory, }) {
272
379
  const guildId = interaction.guildId;
273
380
  if (!guildId) {
274
381
  await interaction.editReply({
@@ -282,75 +389,22 @@ async function handleDeleteWorktreeAction({ interaction, threadId, }) {
282
389
  });
283
390
  return;
284
391
  }
285
- const worktree = await getThreadWorktree(threadId);
286
- if (!worktree) {
287
- if (!isProjectChannel(interaction.channel)) {
288
- await interaction.editReply({
289
- components: [
290
- {
291
- type: ComponentType.TextDisplay,
292
- content: 'This action can only be used in a project channel or thread.',
293
- },
294
- ],
295
- flags: MessageFlags.IsComponentsV2,
296
- });
297
- return;
298
- }
299
- const resolved = await resolveWorkingDirectory({
300
- channel: interaction.channel,
301
- });
302
- if (!resolved) {
303
- await interaction.editReply({
304
- components: [
305
- {
306
- type: ComponentType.TextDisplay,
307
- content: 'Could not determine the project folder for this channel.',
308
- },
309
- ],
310
- flags: MessageFlags.IsComponentsV2,
311
- });
312
- return;
313
- }
314
- await renderWorktreesReply({
315
- guildId,
316
- userId: interaction.user.id,
317
- channelId: interaction.channelId,
318
- projectDirectory: resolved.projectDirectory,
319
- notice: 'Worktree was already removed.',
320
- editReply: (options) => {
321
- return interaction.editReply(options);
322
- },
323
- });
324
- return;
325
- }
326
- if (worktree.status !== 'ready' || !worktree.worktree_directory) {
327
- await renderWorktreesReply({
328
- guildId,
329
- userId: interaction.user.id,
330
- channelId: interaction.channelId,
331
- projectDirectory: worktree.project_directory,
332
- notice: `Cannot delete \`${worktree.worktree_name}\` because it is ${worktree.status}.`,
333
- editReply: (options) => {
334
- return interaction.editReply(options);
335
- },
336
- });
337
- return;
338
- }
392
+ // Pass branch name for branch cleanup. Empty string for detached HEAD
393
+ // worktrees so deleteWorktree skips the `git branch -d` step.
394
+ const displayName = row.branch ?? row.name;
339
395
  const deleteResult = await deleteWorktree({
340
- projectDirectory: worktree.project_directory,
341
- worktreeDirectory: worktree.worktree_directory,
342
- worktreeName: worktree.worktree_name,
396
+ projectDirectory,
397
+ worktreeDirectory: row.directory,
398
+ worktreeName: row.branch ?? '',
343
399
  });
344
400
  if (deleteResult instanceof Error) {
345
- // Send error as a separate ephemeral follow-up so the table stays intact.
346
- // Dig into cause chain to surface the actual git stderr when available.
347
401
  const gitStderr = extractGitStderr(deleteResult);
348
402
  const detail = gitStderr
349
403
  ? `\`\`\`\n${gitStderr}\n\`\`\``
350
404
  : deleteResult.message;
351
405
  await interaction
352
406
  .followUp({
353
- content: `Failed to delete \`${worktree.worktree_name}\`\n${detail}`,
407
+ content: `Failed to delete \`${displayName}\`\n${detail}`,
354
408
  flags: MessageFlags.Ephemeral,
355
409
  })
356
410
  .catch(() => {
@@ -358,13 +412,16 @@ async function handleDeleteWorktreeAction({ interaction, threadId, }) {
358
412
  });
359
413
  return;
360
414
  }
361
- await deleteThreadWorktree(threadId);
415
+ // Clean up DB row if this was a kimaki-tracked worktree
416
+ if (row.threadId) {
417
+ await deleteThreadWorktree(row.threadId);
418
+ }
362
419
  await renderWorktreesReply({
363
420
  guildId,
364
421
  userId: interaction.user.id,
365
422
  channelId: interaction.channelId,
366
- projectDirectory: worktree.project_directory,
367
- notice: `Deleted \`${worktree.worktree_name}\`.`,
423
+ projectDirectory,
424
+ notice: `Deleted \`${displayName}\`.`,
368
425
  editReply: (options) => {
369
426
  return interaction.editReply(options);
370
427
  },
@@ -32,8 +32,10 @@ export function shouldInjectBranch({ previousGitState, currentGitState, }) {
32
32
  if (previousGitState && previousGitState.key === currentGitState.key) {
33
33
  return { inject: false };
34
34
  }
35
- const text = currentGitState.warning || `\n[current git branch is ${currentGitState.label}]`;
36
- return { inject: true, text };
35
+ // Trailing newline so this synthetic part does not fuse with the next text
36
+ // part when the model concatenates message parts.
37
+ const base = currentGitState.warning || `\n[current git branch is ${currentGitState.label}]`;
38
+ return { inject: true, text: `${base}\n` };
37
39
  }
38
40
  export function shouldInjectPwd({ currentDir, previousDir, announcedDir, }) {
39
41
  if (announcedDir === currentDir) {
@@ -45,10 +47,16 @@ export function shouldInjectPwd({ currentDir, previousDir, announcedDir, }) {
45
47
  }
46
48
  return {
47
49
  inject: true,
48
- text: `\n[working directory changed. Previous working directory: ${priorDirectory}. ` +
49
- `Current working directory: ${currentDir}. ` +
50
- `You should read, write, and edit files under ${currentDir}. ` +
51
- `Do NOT read, write, or edit files under ${priorDirectory}.]`,
50
+ // Trailing newline so this synthetic part does not fuse with the next text
51
+ // part when the model concatenates message parts.
52
+ text: `\n[working directory changed (cwd / pwd has changed). ` +
53
+ `The user expects you to edit files in the new cwd. ` +
54
+ `Previous folder (DO NOT TOUCH): ${priorDirectory}. ` +
55
+ `New folder (new cwd / pwd, edit files here): ${currentDir}. ` +
56
+ `You MUST read, write, and edit files only under the new folder ${currentDir}. ` +
57
+ `You MUST NOT read, write, or edit any files under the previous folder ${priorDirectory} — ` +
58
+ `that folder is a separate checkout and the user or another agent may be actively working there, ` +
59
+ `so writing to it would override their unrelated changes.]\n`,
52
60
  };
53
61
  }
54
62
  const MEMORY_REMINDER_OUTPUT_TOKENS = 12_000;
@@ -198,7 +206,7 @@ const contextAwarenessPlugin = async ({ directory, client }) => {
198
206
  sessionID,
199
207
  messageID: firstTextPart.messageID,
200
208
  type: 'text',
201
- text: `<system-reminder>\n${ONBOARDING_TUTORIAL_INSTRUCTIONS}\n</system-reminder>`,
209
+ text: `<system-reminder>\n${ONBOARDING_TUTORIAL_INSTRUCTIONS}\n</system-reminder>\n`,
202
210
  synthetic: true,
203
211
  });
204
212
  }
@@ -273,7 +281,7 @@ const contextAwarenessPlugin = async ({ directory, client }) => {
273
281
  sessionID,
274
282
  messageID,
275
283
  type: 'text',
276
- text: '<system-reminder>The previous assistant message was large. If the conversation had non-obvious learnings that prevent future mistakes and are not already in code comments or AGENTS.md, add them to MEMORY.md with concise titles and brief content (2-3 sentences max).</system-reminder>',
284
+ text: '<system-reminder>The previous assistant message was large. If the conversation had non-obvious learnings that prevent future mistakes and are not already in code comments or AGENTS.md, add them to MEMORY.md with concise titles and brief content (2-3 sentences max).</system-reminder>\n',
277
285
  synthetic: true,
278
286
  });
279
287
  state.lastMemoryReminderAssistantMessageId =
@@ -36,7 +36,8 @@ describe('shouldInjectPwd', () => {
36
36
  {
37
37
  "inject": true,
38
38
  "text": "
39
- [working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You should read, write, and edit files under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
39
+ [working directory changed (cwd / pwd has changed). The user expects you to edit files in the new cwd. Previous folder (DO NOT TOUCH): /repo/main. New folder (new cwd / pwd, edit files here): /repo/worktree. You MUST read, write, and edit files only under the new folder /repo/worktree. You MUST NOT read, write, or edit any files under the previous folder /repo/main — that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes.]
40
+ ",
40
41
  }
41
42
  `);
42
43
  });
@@ -50,7 +51,8 @@ describe('shouldInjectPwd', () => {
50
51
  {
51
52
  "inject": true,
52
53
  "text": "
53
- [working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You should read, write, and edit files under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
54
+ [working directory changed (cwd / pwd has changed). The user expects you to edit files in the new cwd. Previous folder (DO NOT TOUCH): /repo/worktree-a. New folder (new cwd / pwd, edit files here): /repo/worktree-b. You MUST read, write, and edit files only under the new folder /repo/worktree-b. You MUST NOT read, write, or edit any files under the previous folder /repo/worktree-a — that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes.]
55
+ ",
54
56
  }
55
57
  `);
56
58
  });
@@ -3,14 +3,16 @@
3
3
  // and orchestrates the main event loop for the Kimaki bot.
4
4
  import { initDatabase, closeDatabase, getThreadWorktree, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, getPrisma, cancelAllPendingIpcRequests, deleteChannelDirectoryById, createPendingWorktree, setWorktreeReady, } from './database.js';
5
5
  import { stopOpencodeServer, } from './opencode.js';
6
- import { formatWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
6
+ import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
7
7
  import { validateWorktreeDirectory, git } from './worktrees.js';
8
8
  import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
9
9
  import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, } from './discord-utils.js';
10
10
  import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
11
11
  import YAML from 'yaml';
12
12
  import { getTextAttachments, resolveMentions, } from './message-formatting.js';
13
+ import { extractBtwPrefix } from './btw-prefix-detection.js';
13
14
  import { isVoiceAttachment } from './voice-attachment.js';
15
+ import { forkSessionToBtwThread } from './commands/btw.js';
14
16
  import { preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
15
17
  import { cancelPendingActionButtons } from './commands/action-buttons.js';
16
18
  import { cancelPendingQuestion, hasPendingQuestionForThread } from './commands/ask-question.js';
@@ -430,6 +432,35 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
430
432
  return;
431
433
  }
432
434
  }
435
+ // Raw `btw ` mirrors /btw for fast side-question forks from Discord.
436
+ // Keep this at ingress instead of preprocess because it must create a
437
+ // new thread/runtime, not just transform the current prompt.
438
+ // Voice-transcribed `btw` still goes through normal preprocessing.
439
+ const btwShortcut = projectDirectory && worktreeInfo?.status !== 'pending'
440
+ ? extractBtwPrefix(message.content || '')
441
+ : null;
442
+ if (btwShortcut && projectDirectory) {
443
+ const result = await forkSessionToBtwThread({
444
+ sourceThread: thread,
445
+ projectDirectory,
446
+ prompt: btwShortcut.prompt,
447
+ userId: message.author.id,
448
+ username: message.member?.displayName || message.author.displayName,
449
+ appId: currentAppId,
450
+ });
451
+ if (result instanceof Error) {
452
+ await message.reply({
453
+ content: result.message,
454
+ flags: SILENT_MESSAGE_FLAGS,
455
+ });
456
+ return;
457
+ }
458
+ await message.reply({
459
+ content: `Session forked! Continue in ${result.thread.toString()}`,
460
+ flags: SILENT_MESSAGE_FLAGS,
461
+ });
462
+ return;
463
+ }
433
464
  const hasVoiceAttachment = message.attachments.some((attachment) => {
434
465
  return isVoiceAttachment(attachment);
435
466
  });
@@ -592,7 +623,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
592
623
  // and the first message's preprocess callback awaits it before resolving.
593
624
  let worktreePromise;
594
625
  if (shouldUseWorktrees) {
595
- const worktreeName = formatWorktreeName(hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50));
626
+ // Auto-derived from thread name -- compress long slugs so the
627
+ // folder path stays short and the agent doesn't reuse old worktrees.
628
+ const worktreeName = formatAutoWorktreeName(hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50));
596
629
  discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
597
630
  const worktreeStatusMessage = await thread
598
631
  .send({
@@ -110,7 +110,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
110
110
  .toJSON(),
111
111
  new SlashCommandBuilder()
112
112
  .setName('new-worktree')
113
- .setDescription(truncateCommandDescription('Create a git worktree branch from HEAD by default. Optionally pick a base branch.'))
113
+ .setDescription(truncateCommandDescription('Create a git worktree from the current HEAD by default. Optionally pick a base branch.'))
114
114
  .addStringOption((option) => {
115
115
  option
116
116
  .setName('name')
@@ -121,7 +121,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
121
121
  .addStringOption((option) => {
122
122
  option
123
123
  .setName('base-branch')
124
- .setDescription(truncateCommandDescription('Branch to create the worktree from (default: HEAD)'))
124
+ .setDescription(truncateCommandDescription('Branch to create the worktree from (default: current HEAD)'))
125
125
  .setRequired(false)
126
126
  .setAutocomplete(true);
127
127
  return option;
@@ -276,6 +276,13 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
276
276
  new SlashCommandBuilder()
277
277
  .setName('clear-queue')
278
278
  .setDescription(truncateCommandDescription('Clear all queued messages in this thread'))
279
+ .addIntegerOption((option) => {
280
+ option
281
+ .setName('position')
282
+ .setDescription(truncateCommandDescription('1-based queued message position to clear (default: all)'))
283
+ .setMinValue(1);
284
+ return option;
285
+ })
279
286
  .setDMPermission(false)
280
287
  .toJSON(),
281
288
  new SlashCommandBuilder()