@otto-assistant/bridge 0.4.101 → 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.
- package/dist/agent-model.e2e.test.js +1 -0
- package/dist/anthropic-auth-plugin.js +22 -1
- package/dist/anthropic-auth-state.js +31 -0
- package/dist/btw-prefix-detection.js +17 -0
- package/dist/btw-prefix-detection.test.js +63 -0
- package/dist/cli.js +101 -15
- package/dist/commands/agent.js +21 -2
- package/dist/commands/ask-question.js +50 -4
- package/dist/commands/ask-question.test.js +92 -0
- package/dist/commands/btw.js +71 -66
- package/dist/commands/new-worktree.js +92 -35
- package/dist/commands/queue.js +17 -0
- package/dist/commands/worktrees.js +196 -139
- package/dist/context-awareness-plugin.js +16 -8
- package/dist/context-awareness-plugin.test.js +4 -2
- package/dist/discord-bot.js +35 -2
- package/dist/discord-command-registration.js +9 -2
- package/dist/memory-overview-plugin.js +3 -1
- package/dist/opencode.js +24 -1
- package/dist/queue-question-select-drain.e2e.test.js +135 -10
- package/dist/session-handler/thread-runtime-state.js +27 -0
- package/dist/session-handler/thread-session-runtime.js +58 -28
- package/dist/session-title-rename.test.js +12 -0
- package/dist/skill-filter.js +31 -0
- package/dist/skill-filter.test.js +65 -0
- package/dist/store.js +2 -0
- package/dist/system-message.js +12 -3
- package/dist/system-message.test.js +10 -6
- package/dist/thread-message-queue.e2e.test.js +109 -0
- package/dist/worktree-lifecycle.e2e.test.js +4 -1
- package/dist/worktrees.js +106 -12
- package/dist/worktrees.test.js +232 -6
- package/package.json +2 -2
- package/skills/goke/SKILL.md +13 -619
- package/skills/new-skill/SKILL.md +34 -10
- package/skills/npm-package/SKILL.md +336 -2
- package/skills/profano/SKILL.md +24 -0
- package/skills/zele/SKILL.md +50 -21
- package/src/agent-model.e2e.test.ts +1 -0
- package/src/anthropic-auth-plugin.ts +24 -4
- package/src/anthropic-auth-state.ts +45 -0
- package/src/btw-prefix-detection.test.ts +73 -0
- package/src/btw-prefix-detection.ts +23 -0
- package/src/cli.ts +138 -46
- package/src/commands/agent.ts +24 -2
- package/src/commands/ask-question.test.ts +111 -0
- package/src/commands/ask-question.ts +69 -4
- package/src/commands/btw.ts +105 -85
- package/src/commands/new-worktree.ts +107 -40
- package/src/commands/queue.ts +22 -0
- package/src/commands/worktrees.ts +246 -154
- package/src/context-awareness-plugin.test.ts +4 -2
- package/src/context-awareness-plugin.ts +16 -8
- package/src/discord-bot.ts +40 -2
- package/src/discord-command-registration.ts +12 -2
- package/src/memory-overview-plugin.ts +3 -1
- package/src/opencode.ts +31 -1
- package/src/queue-question-select-drain.e2e.test.ts +174 -10
- package/src/session-handler/thread-runtime-state.ts +36 -1
- package/src/session-handler/thread-session-runtime.ts +72 -32
- package/src/session-title-rename.test.ts +18 -0
- package/src/skill-filter.test.ts +83 -0
- package/src/skill-filter.ts +42 -0
- package/src/store.ts +17 -0
- package/src/system-message.test.ts +10 -6
- package/src/system-message.ts +12 -3
- package/src/thread-message-queue.e2e.test.ts +126 -0
- package/src/worktree-lifecycle.e2e.test.ts +6 -1
- package/src/worktrees.test.ts +274 -9
- package/src/worktrees.ts +144 -23
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
// /worktrees command — list
|
|
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 {
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
143
|
+
directory,
|
|
104
144
|
defaultBranch,
|
|
105
145
|
}: {
|
|
106
|
-
|
|
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(
|
|
118
|
-
git(
|
|
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
|
-
|
|
172
|
+
rows,
|
|
137
173
|
gitStatuses,
|
|
138
174
|
guildId,
|
|
139
175
|
}: {
|
|
140
|
-
|
|
176
|
+
rows: WorktreeRow[]
|
|
141
177
|
gitStatuses: (WorktreeGitStatus | null)[]
|
|
142
178
|
guildId: string
|
|
143
179
|
}): string {
|
|
144
|
-
const header = '|
|
|
180
|
+
const header = '| Source | Name | Status | Created | Folder | Action |'
|
|
145
181
|
const separator = '|---|---|---|---|---|---|'
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
|
|
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 (
|
|
152
|
-
return
|
|
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 =
|
|
169
|
-
const folder =
|
|
170
|
-
const action = buildActionCell({
|
|
171
|
-
return `| ${
|
|
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, ...
|
|
221
|
+
return [header, separator, ...tableRows].join('\n')
|
|
174
222
|
}
|
|
175
223
|
|
|
176
224
|
function buildActionCell({
|
|
177
|
-
|
|
225
|
+
row,
|
|
178
226
|
gitStatus,
|
|
179
227
|
}: {
|
|
180
|
-
|
|
228
|
+
row: WorktreeRow
|
|
181
229
|
gitStatus: WorktreeGitStatus | null
|
|
182
230
|
}): string {
|
|
183
|
-
if (!canDeleteWorktree({
|
|
231
|
+
if (!canDeleteWorktree({ row, gitStatus })) {
|
|
184
232
|
return '-'
|
|
185
233
|
}
|
|
186
|
-
|
|
187
234
|
return buildDeleteButtonHtml({
|
|
188
|
-
buttonId: `
|
|
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
|
-
|
|
248
|
+
row,
|
|
202
249
|
gitStatus,
|
|
203
250
|
}: {
|
|
204
|
-
|
|
251
|
+
row: WorktreeRow
|
|
205
252
|
gitStatus: WorktreeGitStatus | null
|
|
206
253
|
}): boolean {
|
|
207
|
-
if (
|
|
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
|
-
|
|
271
|
+
rows,
|
|
272
|
+
projectDirectory,
|
|
225
273
|
timeout,
|
|
226
274
|
}: {
|
|
227
|
-
|
|
275
|
+
rows: WorktreeRow[]
|
|
276
|
+
projectDirectory: string
|
|
228
277
|
timeout: number
|
|
229
278
|
}): Promise<(WorktreeGitStatus | null)[]> {
|
|
230
|
-
const nullFallback =
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
318
|
+
gitWorktrees: GitWorktree[]
|
|
319
|
+
}): Promise<WorktreeRow[]> {
|
|
274
320
|
const prisma = await getPrisma()
|
|
275
|
-
|
|
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
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
473
|
+
rows,
|
|
474
|
+
projectDirectory,
|
|
336
475
|
timeout: GLOBAL_TIMEOUT,
|
|
337
476
|
})
|
|
338
|
-
|
|
339
|
-
worktrees
|
|
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({
|
|
483
|
+
if (!canDeleteWorktree({ row, gitStatus })) {
|
|
342
484
|
return
|
|
343
485
|
}
|
|
344
|
-
|
|
486
|
+
deletableRowsByButtonId.set(`del-wt-${worktreeButtonKey(row.directory)}`, row)
|
|
345
487
|
})
|
|
346
488
|
|
|
347
489
|
const tableMarkdown = buildWorktreeTable({
|
|
348
|
-
|
|
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
|
|
356
|
-
if (!
|
|
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:
|
|
504
|
+
threadId: row.threadId ?? row.directory,
|
|
363
505
|
run: async ({ interaction }) => {
|
|
364
506
|
await handleDeleteWorktreeAction({
|
|
365
507
|
interaction,
|
|
366
|
-
|
|
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
|
-
|
|
537
|
+
row,
|
|
538
|
+
projectDirectory,
|
|
395
539
|
}: {
|
|
396
540
|
interaction: ButtonInteraction
|
|
397
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
|
473
|
-
worktreeDirectory:
|
|
474
|
-
worktreeName:
|
|
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 \`${
|
|
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
|
-
|
|
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
|
|
500
|
-
notice: `Deleted \`${
|
|
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
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
112
|
-
`
|
|
113
|
-
`
|
|
114
|
-
`
|
|
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 =
|