@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
@@ -49,6 +49,48 @@ type PendingQuestionContext = {
49
49
  const QUESTION_CONTEXT_TTL_MS = 10 * 60 * 1000
50
50
  export const pendingQuestionContexts = new Map<string, PendingQuestionContext>()
51
51
 
52
+ export function findPendingQuestionContextForRequest({
53
+ threadId,
54
+ requestId,
55
+ }: {
56
+ threadId: string
57
+ requestId: string
58
+ }): { contextHash: string; context: PendingQuestionContext } | null {
59
+ for (const [contextHash, context] of pendingQuestionContexts) {
60
+ if (context.thread.id !== threadId) {
61
+ continue
62
+ }
63
+ if (context.requestId !== requestId) {
64
+ continue
65
+ }
66
+ return { contextHash, context }
67
+ }
68
+ return null
69
+ }
70
+
71
+ export function deletePendingQuestionContextsForRequest({
72
+ threadId,
73
+ requestId,
74
+ }: {
75
+ threadId: string
76
+ requestId: string
77
+ }): number {
78
+ const matchingContextHashes = [...pendingQuestionContexts.entries()]
79
+ .filter(([, context]) => {
80
+ return context.thread.id === threadId && context.requestId === requestId
81
+ })
82
+ .map(([contextHash]) => {
83
+ return contextHash
84
+ })
85
+
86
+ matchingContextHashes.map((contextHash) => {
87
+ pendingQuestionContexts.delete(contextHash)
88
+ return contextHash
89
+ })
90
+
91
+ return matchingContextHashes.length
92
+ }
93
+
52
94
  export function hasPendingQuestionForThread(threadId: string): boolean {
53
95
  return [...pendingQuestionContexts.values()].some((ctx) => {
54
96
  return ctx.thread.id === threadId
@@ -75,6 +117,17 @@ export async function showAskUserQuestionDropdowns({
75
117
  /** Suppress notification when queue has pending items */
76
118
  silent?: boolean
77
119
  }): Promise<void> {
120
+ const existingPending = findPendingQuestionContextForRequest({
121
+ threadId: thread.id,
122
+ requestId,
123
+ })
124
+ if (existingPending) {
125
+ logger.log(
126
+ `Deduped question ${requestId} for thread ${thread.id} (existing context ${existingPending.contextHash})`,
127
+ )
128
+ return
129
+ }
130
+
78
131
  const contextHash = crypto.randomBytes(8).toString('hex')
79
132
 
80
133
  const context: PendingQuestionContext = {
@@ -103,7 +156,10 @@ export async function showAskUserQuestionDropdowns({
103
156
  // Without this, a user clicking during the abort() await would still
104
157
  // be accepted by handleAskQuestionSelectMenu, then abort() would
105
158
  // kill that valid run.
106
- pendingQuestionContexts.delete(contextHash)
159
+ deletePendingQuestionContextsForRequest({
160
+ threadId: ctx.thread.id,
161
+ requestId: ctx.requestId,
162
+ })
107
163
  // Abort the session so OpenCode isn't stuck waiting for a reply
108
164
  const client = getOpencodeClient(ctx.directory)
109
165
  if (client) {
@@ -232,7 +288,10 @@ export async function handleAskQuestionSelectMenu(
232
288
  if (context.answeredCount >= context.totalQuestions) {
233
289
  // All questions answered - send result back to session
234
290
  await submitQuestionAnswers(context)
235
- pendingQuestionContexts.delete(contextHash)
291
+ deletePendingQuestionContextsForRequest({
292
+ threadId: context.thread.id,
293
+ requestId: context.requestId,
294
+ })
236
295
  }
237
296
  }
238
297
 
@@ -357,7 +416,10 @@ export async function cancelPendingQuestion(
357
416
  // the question without providing an answer (e.g. voice/attachment-only
358
417
  // messages where content needs transcription before it can be an answer).
359
418
  if (userMessage === undefined) {
360
- pendingQuestionContexts.delete(contextHash)
419
+ deletePendingQuestionContextsForRequest({
420
+ threadId: context.thread.id,
421
+ requestId: context.requestId,
422
+ })
361
423
  return 'no-pending'
362
424
  }
363
425
 
@@ -385,6 +447,9 @@ export async function cancelPendingQuestion(
385
447
  return 'reply-failed'
386
448
  }
387
449
 
388
- pendingQuestionContexts.delete(contextHash)
450
+ deletePendingQuestionContextsForRequest({
451
+ threadId: context.thread.id,
452
+ requestId: context.requestId,
453
+ })
389
454
  return 'replied'
390
455
  }
@@ -22,6 +22,94 @@ import type { CommandContext } from './types.js'
22
22
 
23
23
  const logger = createLogger(LogPrefix.FORK)
24
24
 
25
+ export async function forkSessionToBtwThread({
26
+ sourceThread,
27
+ projectDirectory,
28
+ prompt,
29
+ userId,
30
+ username,
31
+ appId,
32
+ }: {
33
+ sourceThread: ThreadChannel
34
+ projectDirectory: string
35
+ prompt: string
36
+ userId: string
37
+ username: string
38
+ appId: string | undefined
39
+ }): Promise<{ thread: ThreadChannel; forkedSessionId: string } | Error> {
40
+ const sessionId = await getThreadSession(sourceThread.id)
41
+ if (!sessionId) {
42
+ return new Error('No active session in this thread')
43
+ }
44
+
45
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
46
+ if (getClient instanceof Error) {
47
+ return new Error(`Failed to fork session: ${getClient.message}`, {
48
+ cause: getClient,
49
+ })
50
+ }
51
+
52
+ const forkResponse = await getClient().session.fork({
53
+ sessionID: sessionId,
54
+ })
55
+ if (!forkResponse.data) {
56
+ return new Error('Failed to fork session')
57
+ }
58
+
59
+ const textChannel = await resolveTextChannel(sourceThread)
60
+ if (!textChannel) {
61
+ return new Error('Could not resolve parent text channel')
62
+ }
63
+
64
+ const forkedSession = forkResponse.data
65
+ const thread = await textChannel.threads.create({
66
+ name: `btw: ${prompt}`.slice(0, 100),
67
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
68
+ reason: `btw fork from session ${sessionId}`,
69
+ })
70
+
71
+ await setThreadSession(thread.id, forkedSession.id)
72
+ await thread.members.add(userId)
73
+
74
+ logger.log(
75
+ `Created btw fork session ${forkedSession.id} in thread ${thread.id} from ${sessionId}`,
76
+ )
77
+
78
+ const sourceThreadLink = `<#${sourceThread.id}>`
79
+ await sendThreadMessage(
80
+ thread,
81
+ `Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`,
82
+ )
83
+
84
+ const wrappedPrompt = [
85
+ `The user asked a side question while you were working on another task.`,
86
+ `This is a forked session whose ONLY goal is to answer this question.`,
87
+ `Do NOT continue, resume, or reference the previous task. Only answer the question below.\n`,
88
+ prompt,
89
+ ].join('\n')
90
+
91
+ const runtime = getOrCreateRuntime({
92
+ threadId: thread.id,
93
+ thread,
94
+ projectDirectory,
95
+ sdkDirectory: projectDirectory,
96
+ channelId: textChannel.id,
97
+ appId,
98
+ })
99
+ await runtime.enqueueIncoming({
100
+ prompt: wrappedPrompt,
101
+ userId,
102
+ username,
103
+ appId,
104
+ mode: 'opencode',
105
+ })
106
+
107
+ return {
108
+ thread,
109
+ forkedSessionId: forkedSession.id,
110
+ }
111
+ }
112
+
25
113
  export async function handleBtwCommand({
26
114
  command,
27
115
  appId,
@@ -36,13 +124,11 @@ export async function handleBtwCommand({
36
124
  return
37
125
  }
38
126
 
39
- const isThread = [
40
- ChannelType.PublicThread,
41
- ChannelType.PrivateThread,
42
- ChannelType.AnnouncementThread,
43
- ].includes(channel.type)
44
-
45
- if (!isThread) {
127
+ if (
128
+ channel.type !== ChannelType.PublicThread
129
+ && channel.type !== ChannelType.PrivateThread
130
+ && channel.type !== ChannelType.AnnouncementThread
131
+ ) {
46
132
  await command.reply({
47
133
  content:
48
134
  'This command can only be used in a thread with an active session',
@@ -51,10 +137,12 @@ export async function handleBtwCommand({
51
137
  return
52
138
  }
53
139
 
140
+ const threadChannel = channel
141
+
54
142
  const prompt = command.options.getString('prompt', true)
55
143
 
56
144
  const resolved = await resolveWorkingDirectory({
57
- channel: channel as ThreadChannel,
145
+ channel: threadChannel,
58
146
  })
59
147
 
60
148
  if (!resolved) {
@@ -67,93 +155,25 @@ export async function handleBtwCommand({
67
155
 
68
156
  const { projectDirectory } = resolved
69
157
 
70
- const sessionId = await getThreadSession(channel.id)
71
-
72
- if (!sessionId) {
73
- await command.reply({
74
- content: 'No active session in this thread',
75
- flags: MessageFlags.Ephemeral,
76
- })
77
- return
78
- }
79
-
80
158
  await command.deferReply({ flags: MessageFlags.Ephemeral })
81
159
 
82
- const getClient = await initializeOpencodeForDirectory(projectDirectory)
83
- if (getClient instanceof Error) {
84
- await command.editReply({
85
- content: `Failed to fork session: ${getClient.message}`,
86
- })
87
- return
88
- }
89
-
90
160
  try {
91
- // Fork the entire session (no messageID = fork at the latest point)
92
- const forkResponse = await getClient().session.fork({
93
- sessionID: sessionId,
94
- })
95
-
96
- if (!forkResponse.data) {
97
- await command.editReply('Failed to fork session')
98
- return
99
- }
100
-
101
- const forkedSession = forkResponse.data
102
-
103
- const textChannel = await resolveTextChannel(channel as ThreadChannel)
104
- if (!textChannel) {
105
- await command.editReply('Could not resolve parent text channel')
106
- return
107
- }
108
-
109
- const threadName = `btw: ${prompt}`.slice(0, 100)
110
- const thread = await textChannel.threads.create({
111
- name: threadName,
112
- autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
113
- reason: `btw fork from session ${sessionId}`,
114
- })
115
-
116
- // Claim the forked session immediately so external polling does not race
117
- await setThreadSession(thread.id, forkedSession.id)
118
-
119
- await thread.members.add(command.user.id)
120
-
121
- logger.log(
122
- `Created btw fork session ${forkedSession.id} in thread ${thread.id} from ${sessionId}`,
123
- )
124
-
125
- // Short status message with prompt instead of replaying past messages
126
- const sourceThreadLink = `<#${channel.id}>`
127
- await sendThreadMessage(
128
- thread,
129
- `Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`,
130
- )
131
-
132
- const wrappedPrompt = [
133
- `The user asked a side question while you were working on another task.`,
134
- `This is a forked session whose ONLY goal is to answer this question.`,
135
- `Do NOT continue, resume, or reference the previous task. Only answer the question below.\n`,
136
- prompt,
137
- ].join('\n')
138
-
139
- const runtime = getOrCreateRuntime({
140
- threadId: thread.id,
141
- thread,
161
+ const result = await forkSessionToBtwThread({
162
+ sourceThread: threadChannel,
142
163
  projectDirectory,
143
- sdkDirectory: projectDirectory,
144
- channelId: textChannel.id,
145
- appId,
146
- })
147
- await runtime.enqueueIncoming({
148
- prompt: wrappedPrompt,
164
+ prompt,
149
165
  userId: command.user.id,
150
166
  username: command.user.displayName,
151
167
  appId,
152
- mode: 'opencode',
153
168
  })
154
169
 
170
+ if (result instanceof Error) {
171
+ await command.editReply(result.message)
172
+ return
173
+ }
174
+
155
175
  await command.editReply(
156
- `Session forked! Continue in ${thread.toString()}`,
176
+ `Session forked! Continue in ${result.thread.toString()}`,
157
177
  )
158
178
  } catch (error) {
159
179
  logger.error('Error in /btw:', error)
@@ -36,6 +36,26 @@ import type { AutocompleteContext } from './types.js'
36
36
  import * as errore from 'errore'
37
37
 
38
38
  const logger = createLogger(LogPrefix.WORKTREE)
39
+ const DEFAULT_WORKTREE_BASE_REF = 'HEAD'
40
+
41
+ async function resolveRequestedWorktreeBaseRef({
42
+ projectDirectory,
43
+ rawBaseBranch,
44
+ }: {
45
+ projectDirectory: string
46
+ rawBaseBranch?: string
47
+ }): Promise<string | Error> {
48
+ if (!rawBaseBranch) {
49
+ // Default to the current local HEAD so worktrees can branch from
50
+ // unpublished commits in the main checkout.
51
+ return DEFAULT_WORKTREE_BASE_REF
52
+ }
53
+
54
+ return validateBranchRef({
55
+ directory: projectDirectory,
56
+ ref: rawBaseBranch,
57
+ })
58
+ }
39
59
 
40
60
  /** Status message shown while a worktree is being created. */
41
61
  export function worktreeCreatingMessage(worktreeName: string): string {
@@ -43,33 +63,89 @@ export function worktreeCreatingMessage(worktreeName: string): string {
43
63
  }
44
64
 
45
65
  class WorktreeError extends Error {
46
- constructor(message: string, options?: { cause?: unknown }) {
66
+ constructor(message: string, options?: ErrorOptions) {
47
67
  super(message, options)
48
68
  this.name = 'WorktreeError'
49
69
  }
50
70
  }
51
71
 
52
72
  /**
53
- * Format worktree name: lowercase, spaces to dashes, remove special chars, add opencode/kimaki- prefix.
54
- * "My Feature" "opencode/kimaki-my-feature"
55
- * Returns empty string if no valid name can be extracted.
73
+ * Lowercase, collapse whitespace to dashes, drop non-[a-z0-9-] chars.
74
+ * Does NOT add the `opencode/kimaki-` prefix — callers do that so they can
75
+ * optionally compress the slug first for auto-derived names.
56
76
  */
57
- export function formatWorktreeName(name: string): string {
58
- const formatted = name
77
+ export function slugifyWorktreeName(name: string): string {
78
+ return name
59
79
  .toLowerCase()
60
80
  .trim()
61
81
  .replace(/\s+/g, '-')
62
82
  .replace(/[^a-z0-9-]/g, '')
83
+ }
63
84
 
64
- if (!formatted) {
85
+ /**
86
+ * Compress a slug by stripping vowels from each dash-separated word, but
87
+ * keeping the first character so the word stays recognizable.
88
+ * Only applied to slugs longer than 20 chars — short names are left alone.
89
+ *
90
+ * "configurable-sidebar-width-by-component" → "cnfgrbl-sdbr-wdth-by-cmpnnt"
91
+ *
92
+ * Used ONLY for auto-derived worktree names (thread name, prompt slug)
93
+ * so long Discord titles don't produce 80-char folder paths that make
94
+ * the agent lazy and reuse the previous worktree. User-provided names
95
+ * via `--worktree <name>` or `/new-worktree name:` are never compressed.
96
+ */
97
+ export function shortenWorktreeSlug(slug: string): string {
98
+ if (slug.length <= 20) {
99
+ return slug
100
+ }
101
+ const shortened = slug
102
+ .split('-')
103
+ .map((word) => {
104
+ if (!word) {
105
+ return word
106
+ }
107
+ const first = word[0]
108
+ const rest = word.slice(1).replace(/[aeiou]/g, '')
109
+ return first + rest
110
+ })
111
+ .join('-')
112
+ return shortened || slug
113
+ }
114
+
115
+ /**
116
+ * Format worktree name: lowercase, spaces to dashes, remove special chars, add opencode/kimaki- prefix.
117
+ * "My Feature" → "opencode/kimaki-my-feature"
118
+ * Returns empty string if no valid name can be extracted.
119
+ *
120
+ * This is the "explicit" path used when the user provides a specific name.
121
+ * The slug is NOT compressed — if you ask for `my-long-explicit-branch-name`
122
+ * you get `opencode/kimaki-my-long-explicit-branch-name` verbatim.
123
+ */
124
+ export function formatWorktreeName(name: string): string {
125
+ const slug = slugifyWorktreeName(name)
126
+ if (!slug) {
65
127
  return ''
66
128
  }
67
- return `opencode/kimaki-${formatted}`
129
+ return `opencode/kimaki-${slug}`
130
+ }
131
+
132
+ /**
133
+ * Format an auto-derived worktree name (from a Discord thread title or a
134
+ * prompt). Same as formatWorktreeName but compresses slugs longer than 20
135
+ * chars by stripping vowels so the on-disk folder name stays short.
136
+ */
137
+ export function formatAutoWorktreeName(name: string): string {
138
+ const slug = slugifyWorktreeName(name)
139
+ if (!slug) {
140
+ return ''
141
+ }
142
+ return `opencode/kimaki-${shortenWorktreeSlug(slug)}`
68
143
  }
69
144
 
70
145
  /**
71
146
  * Derive worktree name from thread name.
72
147
  * Handles existing "⬦ worktree: opencode/kimaki-name" format or uses thread name directly.
148
+ * Uses formatAutoWorktreeName so long thread titles get vowel-compressed.
73
149
  */
74
150
  function deriveWorktreeNameFromThread(threadName: string): string {
75
151
  // Handle existing "⬦ worktree: opencode/kimaki-name" format
@@ -80,10 +156,10 @@ function deriveWorktreeNameFromThread(threadName: string): string {
80
156
  if (extractedName.startsWith('opencode/kimaki-')) {
81
157
  return extractedName
82
158
  }
83
- return formatWorktreeName(extractedName)
159
+ return formatAutoWorktreeName(extractedName)
84
160
  }
85
- // Use thread name directly
86
- return formatWorktreeName(threadName)
161
+ // Use thread name directly (compressed if > 20 chars)
162
+ return formatAutoWorktreeName(threadName)
87
163
  }
88
164
 
89
165
  /**
@@ -254,15 +330,14 @@ export async function handleNewWorktreeCommand({
254
330
  return
255
331
  }
256
332
 
257
- const isThread =
333
+ // Handle command in existing thread - attach worktree to this thread
334
+ if (
258
335
  channel.type === ChannelType.PublicThread ||
259
336
  channel.type === ChannelType.PrivateThread
260
-
261
- // Handle command in existing thread - attach worktree to this thread
262
- if (isThread) {
337
+ ) {
263
338
  await handleWorktreeInThread({
264
339
  command,
265
- thread: channel as ThreadChannel,
340
+ thread: channel,
266
341
  })
267
342
  return
268
343
  }
@@ -292,7 +367,7 @@ export async function handleNewWorktreeCommand({
292
367
  return
293
368
  }
294
369
 
295
- const textChannel = channel as TextChannel
370
+ const textChannel = channel
296
371
 
297
372
  const projectDirectory = await getProjectDirectoryFromChannel(
298
373
  textChannel,
@@ -302,17 +377,13 @@ export async function handleNewWorktreeCommand({
302
377
  return
303
378
  }
304
379
 
305
- let baseBranch = rawBaseBranch
306
- if (baseBranch) {
307
- const validated = await validateBranchRef({
308
- directory: projectDirectory,
309
- ref: baseBranch,
310
- })
311
- if (validated instanceof Error) {
312
- await command.editReply(`Invalid base branch: \`${baseBranch}\``)
313
- return
314
- }
315
- baseBranch = validated
380
+ const baseBranch = await resolveRequestedWorktreeBaseRef({
381
+ projectDirectory,
382
+ rawBaseBranch,
383
+ })
384
+ if (baseBranch instanceof Error) {
385
+ await command.editReply(`Invalid base branch: \`${rawBaseBranch}\``)
386
+ return
316
387
  }
317
388
 
318
389
  const existingWorktree = await findExistingWorktreePath({
@@ -415,24 +486,20 @@ async function handleWorktreeInThread({
415
486
  }
416
487
 
417
488
  const projectDirectory = await getProjectDirectoryFromChannel(
418
- parent as TextChannel,
489
+ parent,
419
490
  )
420
491
  if (errore.isError(projectDirectory)) {
421
492
  await command.editReply(projectDirectory.message)
422
493
  return
423
494
  }
424
495
 
425
- let baseBranch = rawBaseBranch
426
- if (baseBranch) {
427
- const validated = await validateBranchRef({
428
- directory: projectDirectory,
429
- ref: baseBranch,
430
- })
431
- if (validated instanceof Error) {
432
- await command.editReply(`Invalid base branch: \`${baseBranch}\``)
433
- return
434
- }
435
- baseBranch = validated
496
+ const baseBranch = await resolveRequestedWorktreeBaseRef({
497
+ projectDirectory,
498
+ rawBaseBranch,
499
+ })
500
+ if (baseBranch instanceof Error) {
501
+ await command.editReply(`Invalid base branch: \`${rawBaseBranch}\``)
502
+ return
436
503
  }
437
504
 
438
505
  const existingWorktreePath = await findExistingWorktreePath({
@@ -100,6 +100,7 @@ export async function handleClearQueueCommand({
100
100
  command,
101
101
  }: CommandContext): Promise<void> {
102
102
  const channel = command.channel
103
+ const position = command.options.getInteger('position') ?? undefined
103
104
 
104
105
  if (!channel) {
105
106
  await command.reply({
@@ -134,6 +135,27 @@ export async function handleClearQueueCommand({
134
135
  return
135
136
  }
136
137
 
138
+ if (position !== undefined) {
139
+ const removed = runtime?.removeQueuePosition(position)
140
+ if (!removed) {
141
+ await command.reply({
142
+ content: `No queued message at position ${position}`,
143
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
144
+ })
145
+ return
146
+ }
147
+
148
+ await command.reply({
149
+ content: `Cleared queued message at position ${position}`,
150
+ flags: SILENT_MESSAGE_FLAGS,
151
+ })
152
+
153
+ logger.log(
154
+ `[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`,
155
+ )
156
+ return
157
+ }
158
+
137
159
  runtime?.clearQueue()
138
160
 
139
161
  await command.reply({