@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,4 +1,7 @@
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
 
@@ -16,7 +19,6 @@ import {
16
19
  } from 'discord.js'
17
20
  import {
18
21
  deleteThreadWorktree,
19
- getThreadWorktree,
20
22
  type ThreadWorktree,
21
23
  } from '../database.js'
22
24
  import { getPrisma } from '../db.js'
@@ -27,9 +29,17 @@ import {
27
29
  registerHtmlAction,
28
30
  } from '../html-actions.js'
29
31
  import * as errore from 'errore'
32
+ import crypto from 'node:crypto'
30
33
  import { GitCommandError } from '../errors.js'
31
34
  import { resolveWorkingDirectory } from '../discord-utils.js'
32
- import { deleteWorktree, git, getDefaultBranch } from '../worktrees.js'
35
+ import {
36
+ deleteWorktree,
37
+ git,
38
+ getDefaultBranch,
39
+ listGitWorktrees,
40
+ type GitWorktree,
41
+ } from '../worktrees.js'
42
+ import path from 'node:path'
33
43
 
34
44
  // Extracts the git stderr from a deleteWorktree error via errore.findCause.
35
45
  // Chain: Error { cause: GitCommandError { cause: CommandError { stderr } } }.
@@ -65,14 +75,26 @@ export function formatTimeAgo(date: Date): string {
65
75
  return remainingHours > 0 ? `${days}d ${remainingHours}h ago` : `${days}d ago`
66
76
  }
67
77
 
68
- function statusLabel(wt: ThreadWorktree): string {
69
- if (wt.status === 'ready') {
70
- return 'ready'
71
- }
72
- if (wt.status === 'error') {
73
- return 'error'
74
- }
75
- return 'pending'
78
+ // Stable button ID derived from directory path via sha1 hash.
79
+ // Avoids collisions that truncated path suffixes can cause.
80
+ function worktreeButtonKey(directory: string): string {
81
+ return crypto.createHash('sha1').update(directory).digest('hex').slice(0, 12)
82
+ }
83
+
84
+ // Unified worktree row that merges git data with optional DB metadata.
85
+ type WorktreeRow = {
86
+ directory: string
87
+ branch: string | null
88
+ name: string
89
+ threadId: string | null
90
+ guildId: string | null
91
+ createdAt: Date | null
92
+ source: 'kimaki' | 'opencode' | 'manual'
93
+ // DB-only worktrees (pending/error) won't appear in git list
94
+ dbStatus: 'ready' | 'pending' | 'error'
95
+ // Git-level flags that block deletion
96
+ locked: boolean
97
+ prunable: boolean
76
98
  }
77
99
 
78
100
  type WorktreeGitStatus = {
@@ -96,26 +118,40 @@ type WorktreesReplyTarget = {
96
118
  const GIT_CMD_TIMEOUT = 5_000
97
119
  const GLOBAL_TIMEOUT = 10_000
98
120
 
121
+ // Detect worktree source from branch name and directory path.
122
+ // opencode/kimaki-* branches → kimaki, opencode worktree paths → opencode, else manual.
123
+ function detectWorktreeSource({
124
+ branch,
125
+ directory,
126
+ }: {
127
+ branch: string | null
128
+ directory: string
129
+ }): 'kimaki' | 'opencode' | 'manual' {
130
+ if (branch?.startsWith('opencode/kimaki-')) {
131
+ return 'kimaki'
132
+ }
133
+ // opencode stores worktrees under ~/.local/share/opencode/worktree/
134
+ if (directory.includes('/opencode/worktree/')) {
135
+ return 'opencode'
136
+ }
137
+ return 'manual'
138
+ }
139
+
99
140
  // Checks dirty state and commits ahead of default branch in parallel.
100
- // Returns null for worktrees that aren't ready or when the directory is
101
- // missing / git commands fail / timeout (e.g. deleted worktree folder).
141
+ // Returns null when the directory is missing / git commands fail / timeout.
102
142
  async function getWorktreeGitStatus({
103
- wt,
143
+ directory,
104
144
  defaultBranch,
105
145
  }: {
106
- wt: ThreadWorktree
146
+ directory: string
107
147
  defaultBranch: string
108
148
  }): Promise<WorktreeGitStatus | null> {
109
- if (wt.status !== 'ready' || !wt.worktree_directory) {
110
- return null
111
- }
112
149
  try {
113
- const dir = wt.worktree_directory
114
150
  // Use raw git calls so errors/timeouts are visible — isDirty() swallows
115
151
  // errors and returns false, which would render "merged" instead of "unknown".
116
152
  const [statusResult, aheadResult] = await Promise.all([
117
- git(dir, 'status --porcelain', { timeout: GIT_CMD_TIMEOUT }),
118
- git(dir, `rev-list --count "${defaultBranch}..HEAD"`, {
153
+ git(directory, 'status --porcelain', { timeout: GIT_CMD_TIMEOUT }),
154
+ git(directory, `rev-list --count "${defaultBranch}..HEAD"`, {
119
155
  timeout: GIT_CMD_TIMEOUT,
120
156
  }),
121
157
  ])
@@ -133,23 +169,35 @@ async function getWorktreeGitStatus({
133
169
  }
134
170
 
135
171
  function buildWorktreeTable({
136
- worktrees,
172
+ rows,
137
173
  gitStatuses,
138
174
  guildId,
139
175
  }: {
140
- worktrees: ThreadWorktree[]
176
+ rows: WorktreeRow[]
141
177
  gitStatuses: (WorktreeGitStatus | null)[]
142
178
  guildId: string
143
179
  }): string {
144
- const header = '| Thread | Name | Status | Created | Folder | Action |'
180
+ const header = '| Source | Name | Status | Created | Folder | Action |'
145
181
  const separator = '|---|---|---|---|---|---|'
146
- const rows = worktrees.map((wt, i) => {
147
- const threadLink = `[thread](https://discord.com/channels/${guildId}/${wt.thread_id})`
148
- const name = wt.worktree_name
182
+ const tableRows = rows.map((row, i) => {
183
+ const sourceCell = (() => {
184
+ if (row.threadId && row.guildId) {
185
+ const threadLink = `[${row.source}](https://discord.com/channels/${row.guildId}/${row.threadId})`
186
+ return threadLink
187
+ }
188
+ return row.source
189
+ })()
190
+ const name = row.name
149
191
  const gs = gitStatuses[i] ?? null
150
192
  const status = (() => {
151
- if (wt.status !== 'ready') {
152
- return statusLabel(wt)
193
+ if (row.dbStatus !== 'ready') {
194
+ return row.dbStatus
195
+ }
196
+ if (row.locked) {
197
+ return 'locked'
198
+ }
199
+ if (row.prunable) {
200
+ return 'prunable'
153
201
  }
154
202
  if (!gs) {
155
203
  return 'unknown'
@@ -165,27 +213,26 @@ function buildWorktreeTable({
165
213
  }
166
214
  return parts.join(', ')
167
215
  })()
168
- const created = wt.created_at ? formatTimeAgo(wt.created_at) : 'unknown'
169
- const folder = wt.worktree_directory ?? wt.project_directory
170
- const action = buildActionCell({ wt, gitStatus: gs })
171
- return `| ${threadLink} | ${name} | ${status} | ${created} | ${folder} | ${action} |`
216
+ const created = row.createdAt ? formatTimeAgo(row.createdAt) : '-'
217
+ const folder = row.directory
218
+ const action = buildActionCell({ row, gitStatus: gs })
219
+ return `| ${sourceCell} | ${name} | ${status} | ${created} | ${folder} | ${action} |`
172
220
  })
173
- return [header, separator, ...rows].join('\n')
221
+ return [header, separator, ...tableRows].join('\n')
174
222
  }
175
223
 
176
224
  function buildActionCell({
177
- wt,
225
+ row,
178
226
  gitStatus,
179
227
  }: {
180
- wt: ThreadWorktree
228
+ row: WorktreeRow
181
229
  gitStatus: WorktreeGitStatus | null
182
230
  }): string {
183
- if (!canDeleteWorktree({ wt, gitStatus })) {
231
+ if (!canDeleteWorktree({ row, gitStatus })) {
184
232
  return '-'
185
233
  }
186
-
187
234
  return buildDeleteButtonHtml({
188
- buttonId: `delete-worktree-${wt.thread_id}`,
235
+ buttonId: `del-wt-${worktreeButtonKey(row.directory)}`,
189
236
  })
190
237
  }
191
238
 
@@ -198,13 +245,16 @@ function buildDeleteButtonHtml({
198
245
  }
199
246
 
200
247
  function canDeleteWorktree({
201
- wt,
248
+ row,
202
249
  gitStatus,
203
250
  }: {
204
- wt: ThreadWorktree
251
+ row: WorktreeRow
205
252
  gitStatus: WorktreeGitStatus | null
206
253
  }): boolean {
207
- if (wt.status !== 'ready' || !wt.worktree_directory) {
254
+ if (row.dbStatus !== 'ready') {
255
+ return false
256
+ }
257
+ if (row.locked) {
208
258
  return false
209
259
  }
210
260
  if (!gitStatus) {
@@ -217,17 +267,16 @@ function canDeleteWorktree({
217
267
  }
218
268
 
219
269
  // Resolves git statuses for all worktrees within a single global deadline.
220
- // Caches getDefaultBranch per project_directory to avoid redundant spawns.
221
- // Returns null for any worktree whose git calls fail, timeout, or exceed
222
- // the global deadline — the table renders those as "unknown".
223
270
  async function resolveGitStatuses({
224
- worktrees,
271
+ rows,
272
+ projectDirectory,
225
273
  timeout,
226
274
  }: {
227
- worktrees: ThreadWorktree[]
275
+ rows: WorktreeRow[]
276
+ projectDirectory: string
228
277
  timeout: number
229
278
  }): Promise<(WorktreeGitStatus | null)[]> {
230
- const nullFallback = worktrees.map(() => null)
279
+ const nullFallback = rows.map(() => null)
231
280
 
232
281
  let timer: ReturnType<typeof setTimeout> | undefined
233
282
  const deadline = new Promise<(WorktreeGitStatus | null)[]>((resolve) => {
@@ -237,24 +286,16 @@ async function resolveGitStatuses({
237
286
  })
238
287
 
239
288
  const work = (async () => {
240
- // Resolve default branch once per unique project directory (avoids
241
- // redundant git subprocess spawns when multiple worktrees share a project).
242
- const uniqueProjectDirs = [
243
- ...new Set(worktrees.map((wt) => wt.project_directory)),
244
- ]
245
- const defaultBranchEntries = await Promise.all(
246
- uniqueProjectDirs.map(async (dir) => {
247
- const branch = await getDefaultBranch(dir, { timeout: GIT_CMD_TIMEOUT })
248
- return [dir, branch] as const
249
- }),
250
- )
251
- const defaultBranchByProject = new Map(defaultBranchEntries)
289
+ const defaultBranch = await getDefaultBranch(projectDirectory, {
290
+ timeout: GIT_CMD_TIMEOUT,
291
+ })
252
292
 
253
293
  return Promise.all(
254
- worktrees.map((wt) => {
255
- const defaultBranch =
256
- defaultBranchByProject.get(wt.project_directory) ?? 'main'
257
- return getWorktreeGitStatus({ wt, defaultBranch })
294
+ rows.map((row) => {
295
+ if (row.dbStatus !== 'ready' || row.locked || row.prunable) {
296
+ return null
297
+ }
298
+ return getWorktreeGitStatus({ directory: row.directory, defaultBranch })
258
299
  }),
259
300
  )
260
301
  })()
@@ -266,19 +307,102 @@ async function resolveGitStatuses({
266
307
  }
267
308
  }
268
309
 
269
- async function getRecentWorktrees({
310
+ // Merge git worktrees with DB metadata into unified WorktreeRows.
311
+ // Git is the source of truth for what exists on disk. DB rows that aren't
312
+ // in the git list (pending/error) are appended at the end.
313
+ async function buildWorktreeRows({
270
314
  projectDirectory,
315
+ gitWorktrees,
271
316
  }: {
272
317
  projectDirectory: string
273
- }): Promise<ThreadWorktree[]> {
318
+ gitWorktrees: GitWorktree[]
319
+ }): Promise<WorktreeRow[]> {
274
320
  const prisma = await getPrisma()
275
- return await prisma.thread_worktrees.findMany({
276
- where: {
277
- project_directory: projectDirectory,
278
- },
279
- orderBy: { created_at: 'desc' },
280
- take: 10,
321
+ const dbWorktrees = await prisma.thread_worktrees.findMany({
322
+ where: { project_directory: projectDirectory },
281
323
  })
324
+
325
+ // Index DB worktrees by directory for fast lookup
326
+ const dbByDirectory = new Map<string, ThreadWorktree>()
327
+ for (const dbWt of dbWorktrees) {
328
+ if (dbWt.worktree_directory) {
329
+ dbByDirectory.set(dbWt.worktree_directory, dbWt)
330
+ }
331
+ }
332
+
333
+ // Track which DB rows got matched so we can append unmatched ones
334
+ const matchedDbThreadIds = new Set<string>()
335
+
336
+ // Build rows from git worktrees (the source of truth for on-disk state).
337
+ // Use real DB status when available — a git-visible worktree whose DB row
338
+ // is still 'pending' means setup hasn't finished (race window).
339
+ const gitRows: WorktreeRow[] = gitWorktrees.map((gw) => {
340
+ const dbMatch = dbByDirectory.get(gw.directory)
341
+ if (dbMatch) {
342
+ matchedDbThreadIds.add(dbMatch.thread_id)
343
+ }
344
+ const source = detectWorktreeSource({
345
+ branch: gw.branch,
346
+ directory: gw.directory,
347
+ })
348
+ const name = gw.branch ?? path.basename(gw.directory)
349
+ const dbStatus: 'ready' | 'pending' | 'error' = (() => {
350
+ if (!dbMatch) {
351
+ return 'ready'
352
+ }
353
+ if (dbMatch.status === 'error') {
354
+ return 'error'
355
+ }
356
+ if (dbMatch.status === 'pending') {
357
+ return 'pending'
358
+ }
359
+ return 'ready'
360
+ })()
361
+ return {
362
+ directory: gw.directory,
363
+ branch: gw.branch,
364
+ name,
365
+ threadId: dbMatch?.thread_id ?? null,
366
+ guildId: null, // filled in by caller
367
+ createdAt: dbMatch?.created_at ?? null,
368
+ source,
369
+ dbStatus,
370
+ locked: gw.locked,
371
+ prunable: gw.prunable,
372
+ }
373
+ })
374
+
375
+ // Append DB-only worktrees (pending/error/stale — not visible to git).
376
+ // Preserve actual DB status so stale 'ready' rows show as 'ready' (missing).
377
+ const dbOnlyRows: WorktreeRow[] = dbWorktrees
378
+ .filter((dbWt) => {
379
+ return !matchedDbThreadIds.has(dbWt.thread_id)
380
+ })
381
+ .map((dbWt) => {
382
+ const dbStatus: 'ready' | 'pending' | 'error' = (() => {
383
+ if (dbWt.status === 'error') {
384
+ return 'error'
385
+ }
386
+ if (dbWt.status === 'pending') {
387
+ return 'pending'
388
+ }
389
+ return 'ready'
390
+ })()
391
+ return {
392
+ directory: dbWt.worktree_directory ?? dbWt.project_directory,
393
+ branch: null,
394
+ name: dbWt.worktree_name,
395
+ threadId: dbWt.thread_id,
396
+ guildId: null,
397
+ createdAt: dbWt.created_at,
398
+ source: 'kimaki' as const,
399
+ dbStatus,
400
+ locked: false,
401
+ prunable: false,
402
+ }
403
+ })
404
+
405
+ return [...gitRows, ...dbOnlyRows]
282
406
  }
283
407
 
284
408
  function getWorktreesActionOwnerKey({
@@ -317,9 +441,23 @@ async function renderWorktreesReply({
317
441
  const ownerKey = getWorktreesActionOwnerKey({ userId, channelId })
318
442
  cancelHtmlActionsForOwner(ownerKey)
319
443
 
320
- const worktrees = await getRecentWorktrees({ projectDirectory })
321
- if (worktrees.length === 0) {
322
- const message = notice ? `${notice}\n\nNo worktrees found.` : 'No worktrees found.'
444
+ const gitWorktrees = await listGitWorktrees({
445
+ projectDirectory,
446
+ timeout: GIT_CMD_TIMEOUT,
447
+ })
448
+ // On git failure, fall back to empty list (DB-only rows still shown)
449
+ const gitList = gitWorktrees instanceof Error ? [] : gitWorktrees
450
+
451
+ const rows = await buildWorktreeRows({ projectDirectory, gitWorktrees: gitList })
452
+ // Inject guildId into all rows for thread link rendering
453
+ for (const row of rows) {
454
+ row.guildId = guildId
455
+ }
456
+
457
+ if (rows.length === 0) {
458
+ const message = notice
459
+ ? `${notice}\n\nNo worktrees found.`
460
+ : 'No worktrees found.'
323
461
  const textDisplay: APITextDisplayComponent = {
324
462
  type: ComponentType.TextDisplay,
325
463
  content: message,
@@ -332,38 +470,43 @@ async function renderWorktreesReply({
332
470
  }
333
471
 
334
472
  const gitStatuses = await resolveGitStatuses({
335
- worktrees,
473
+ rows,
474
+ projectDirectory,
336
475
  timeout: GLOBAL_TIMEOUT,
337
476
  })
338
- const deletableWorktreesByButtonId = new Map<string, ThreadWorktree>()
339
- worktrees.forEach((wt, index) => {
477
+
478
+ // Map deletable worktrees by button ID for the HTML action resolver.
479
+ // Uses the same worktreeButtonKey() as buildActionCell.
480
+ const deletableRowsByButtonId = new Map<string, WorktreeRow>()
481
+ rows.forEach((row, index) => {
340
482
  const gitStatus = gitStatuses[index] ?? null
341
- if (!canDeleteWorktree({ wt, gitStatus })) {
483
+ if (!canDeleteWorktree({ row, gitStatus })) {
342
484
  return
343
485
  }
344
- deletableWorktreesByButtonId.set(`delete-worktree-${wt.thread_id}`, wt)
486
+ deletableRowsByButtonId.set(`del-wt-${worktreeButtonKey(row.directory)}`, row)
345
487
  })
346
488
 
347
489
  const tableMarkdown = buildWorktreeTable({
348
- worktrees,
490
+ rows,
349
491
  gitStatuses,
350
492
  guildId,
351
493
  })
352
494
  const markdown = notice ? `${notice}\n\n${tableMarkdown}` : tableMarkdown
353
495
  const segments = splitTablesFromMarkdown(markdown, {
354
496
  resolveButtonCustomId: ({ button }) => {
355
- const worktree = deletableWorktreesByButtonId.get(button.id)
356
- if (!worktree) {
497
+ const row = deletableRowsByButtonId.get(button.id)
498
+ if (!row) {
357
499
  return new Error(`No worktree registered for button ${button.id}`)
358
500
  }
359
501
 
360
502
  const actionId = registerHtmlAction({
361
503
  ownerKey,
362
- threadId: worktree.thread_id,
504
+ threadId: row.threadId ?? row.directory,
363
505
  run: async ({ interaction }) => {
364
506
  await handleDeleteWorktreeAction({
365
507
  interaction,
366
- threadId: worktree.thread_id,
508
+ row,
509
+ projectDirectory,
367
510
  })
368
511
  },
369
512
  })
@@ -391,10 +534,12 @@ async function renderWorktreesReply({
391
534
 
392
535
  async function handleDeleteWorktreeAction({
393
536
  interaction,
394
- threadId,
537
+ row,
538
+ projectDirectory,
395
539
  }: {
396
540
  interaction: ButtonInteraction
397
- threadId: string
541
+ row: WorktreeRow
542
+ projectDirectory: string
398
543
  }): Promise<void> {
399
544
  const guildId = interaction.guildId
400
545
  if (!guildId) {
@@ -410,79 +555,22 @@ async function handleDeleteWorktreeAction({
410
555
  return
411
556
  }
412
557
 
413
- const worktree = await getThreadWorktree(threadId)
414
- if (!worktree) {
415
- if (!isProjectChannel(interaction.channel)) {
416
- await interaction.editReply({
417
- components: [
418
- {
419
- type: ComponentType.TextDisplay,
420
- content: 'This action can only be used in a project channel or thread.',
421
- },
422
- ],
423
- flags: MessageFlags.IsComponentsV2,
424
- })
425
- return
426
- }
427
-
428
- const resolved = await resolveWorkingDirectory({
429
- channel: interaction.channel as TextChannel | ThreadChannel,
430
- })
431
- if (!resolved) {
432
- await interaction.editReply({
433
- components: [
434
- {
435
- type: ComponentType.TextDisplay,
436
- content: 'Could not determine the project folder for this channel.',
437
- },
438
- ],
439
- flags: MessageFlags.IsComponentsV2,
440
- })
441
- return
442
- }
443
-
444
- await renderWorktreesReply({
445
- guildId,
446
- userId: interaction.user.id,
447
- channelId: interaction.channelId,
448
- projectDirectory: resolved.projectDirectory,
449
- notice: 'Worktree was already removed.',
450
- editReply: (options) => {
451
- return interaction.editReply(options)
452
- },
453
- })
454
- return
455
- }
456
-
457
- if (worktree.status !== 'ready' || !worktree.worktree_directory) {
458
- await renderWorktreesReply({
459
- guildId,
460
- userId: interaction.user.id,
461
- channelId: interaction.channelId,
462
- projectDirectory: worktree.project_directory,
463
- notice: `Cannot delete \`${worktree.worktree_name}\` because it is ${worktree.status}.`,
464
- editReply: (options) => {
465
- return interaction.editReply(options)
466
- },
467
- })
468
- return
469
- }
470
-
558
+ // Pass branch name for branch cleanup. Empty string for detached HEAD
559
+ // worktrees so deleteWorktree skips the `git branch -d` step.
560
+ const displayName = row.branch ?? row.name
471
561
  const deleteResult = await deleteWorktree({
472
- projectDirectory: worktree.project_directory,
473
- worktreeDirectory: worktree.worktree_directory,
474
- worktreeName: worktree.worktree_name,
562
+ projectDirectory,
563
+ worktreeDirectory: row.directory,
564
+ worktreeName: row.branch ?? '',
475
565
  })
476
566
  if (deleteResult instanceof Error) {
477
- // Send error as a separate ephemeral follow-up so the table stays intact.
478
- // Dig into cause chain to surface the actual git stderr when available.
479
567
  const gitStderr = extractGitStderr(deleteResult)
480
568
  const detail = gitStderr
481
569
  ? `\`\`\`\n${gitStderr}\n\`\`\``
482
570
  : deleteResult.message
483
571
  await interaction
484
572
  .followUp({
485
- content: `Failed to delete \`${worktree.worktree_name}\`\n${detail}`,
573
+ content: `Failed to delete \`${displayName}\`\n${detail}`,
486
574
  flags: MessageFlags.Ephemeral,
487
575
  })
488
576
  .catch(() => {
@@ -491,13 +579,17 @@ async function handleDeleteWorktreeAction({
491
579
  return
492
580
  }
493
581
 
494
- await deleteThreadWorktree(threadId)
582
+ // Clean up DB row if this was a kimaki-tracked worktree
583
+ if (row.threadId) {
584
+ await deleteThreadWorktree(row.threadId)
585
+ }
586
+
495
587
  await renderWorktreesReply({
496
588
  guildId,
497
589
  userId: interaction.user.id,
498
590
  channelId: interaction.channelId,
499
- projectDirectory: worktree.project_directory,
500
- notice: `Deleted \`${worktree.worktree_name}\`.`,
591
+ projectDirectory,
592
+ notice: `Deleted \`${displayName}\`.`,
501
593
  editReply: (options) => {
502
594
  return interaction.editReply(options)
503
595
  },
@@ -46,7 +46,8 @@ describe('shouldInjectPwd', () => {
46
46
  {
47
47
  "inject": true,
48
48
  "text": "
49
- [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.]",
49
+ [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.]
50
+ ",
50
51
  }
51
52
  `)
52
53
  })
@@ -62,7 +63,8 @@ describe('shouldInjectPwd', () => {
62
63
  {
63
64
  "inject": true,
64
65
  "text": "
65
- [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.]",
66
+ [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.]
67
+ ",
66
68
  }
67
69
  `)
68
70
  })
@@ -83,8 +83,10 @@ export function shouldInjectBranch({
83
83
  if (previousGitState && previousGitState.key === currentGitState.key) {
84
84
  return { inject: false }
85
85
  }
86
- const text = currentGitState.warning || `\n[current git branch is ${currentGitState.label}]`
87
- return { inject: true, text }
86
+ // Trailing newline so this synthetic part does not fuse with the next text
87
+ // part when the model concatenates message parts.
88
+ const base = currentGitState.warning || `\n[current git branch is ${currentGitState.label}]`
89
+ return { inject: true, text: `${base}\n` }
88
90
  }
89
91
 
90
92
  export function shouldInjectPwd({
@@ -107,11 +109,17 @@ export function shouldInjectPwd({
107
109
 
108
110
  return {
109
111
  inject: true,
112
+ // Trailing newline so this synthetic part does not fuse with the next text
113
+ // part when the model concatenates message parts.
110
114
  text:
111
- `\n[working directory changed. Previous working directory: ${priorDirectory}. ` +
112
- `Current working directory: ${currentDir}. ` +
113
- `You should read, write, and edit files under ${currentDir}. ` +
114
- `Do NOT read, write, or edit files under ${priorDirectory}.]`,
115
+ `\n[working directory changed (cwd / pwd has changed). ` +
116
+ `The user expects you to edit files in the new cwd. ` +
117
+ `Previous folder (DO NOT TOUCH): ${priorDirectory}. ` +
118
+ `New folder (new cwd / pwd, edit files here): ${currentDir}. ` +
119
+ `You MUST read, write, and edit files only under the new folder ${currentDir}. ` +
120
+ `You MUST NOT read, write, or edit any files under the previous folder ${priorDirectory} — ` +
121
+ `that folder is a separate checkout and the user or another agent may be actively working there, ` +
122
+ `so writing to it would override their unrelated changes.]\n`,
115
123
  }
116
124
  }
117
125
 
@@ -327,7 +335,7 @@ const contextAwarenessPlugin: Plugin = async ({ directory, client }) => {
327
335
  sessionID,
328
336
  messageID: firstTextPart.messageID,
329
337
  type: 'text' as const,
330
- text: `<system-reminder>\n${ONBOARDING_TUTORIAL_INSTRUCTIONS}\n</system-reminder>`,
338
+ text: `<system-reminder>\n${ONBOARDING_TUTORIAL_INSTRUCTIONS}\n</system-reminder>\n`,
331
339
  synthetic: true,
332
340
  })
333
341
  }
@@ -412,7 +420,7 @@ const contextAwarenessPlugin: Plugin = async ({ directory, client }) => {
412
420
  sessionID,
413
421
  messageID,
414
422
  type: 'text' as const,
415
- 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>',
423
+ 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',
416
424
  synthetic: true,
417
425
  })
418
426
  state.lastMemoryReminderAssistantMessageId =