@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.
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 +24 -1
  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 +31 -1
  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
@@ -19,7 +19,7 @@ import {
19
19
  import {
20
20
  stopOpencodeServer,
21
21
  } from './opencode.js'
22
- import { formatWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js'
22
+ import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js'
23
23
  import { validateWorktreeDirectory, git } from './worktrees.js'
24
24
  import { WORKTREE_PREFIX } from './commands/merge-worktree.js'
25
25
  import {
@@ -43,7 +43,9 @@ import {
43
43
  getTextAttachments,
44
44
  resolveMentions,
45
45
  } from './message-formatting.js'
46
+ import { extractBtwPrefix } from './btw-prefix-detection.js'
46
47
  import { isVoiceAttachment } from './voice-attachment.js'
48
+ import { forkSessionToBtwThread } from './commands/btw.js'
47
49
  import {
48
50
  preprocessExistingThreadMessage,
49
51
  preprocessNewThreadMessage,
@@ -620,6 +622,40 @@ export async function startDiscordBot({
620
622
  }
621
623
  }
622
624
 
625
+ // Raw `btw ` mirrors /btw for fast side-question forks from Discord.
626
+ // Keep this at ingress instead of preprocess because it must create a
627
+ // new thread/runtime, not just transform the current prompt.
628
+ // Voice-transcribed `btw` still goes through normal preprocessing.
629
+ const btwShortcut =
630
+ projectDirectory && worktreeInfo?.status !== 'pending'
631
+ ? extractBtwPrefix(message.content || '')
632
+ : null
633
+ if (btwShortcut && projectDirectory) {
634
+ const result = await forkSessionToBtwThread({
635
+ sourceThread: thread,
636
+ projectDirectory,
637
+ prompt: btwShortcut.prompt,
638
+ userId: message.author.id,
639
+ username:
640
+ message.member?.displayName || message.author.displayName,
641
+ appId: currentAppId,
642
+ })
643
+
644
+ if (result instanceof Error) {
645
+ await message.reply({
646
+ content: result.message,
647
+ flags: SILENT_MESSAGE_FLAGS,
648
+ })
649
+ return
650
+ }
651
+
652
+ await message.reply({
653
+ content: `Session forked! Continue in ${result.thread.toString()}`,
654
+ flags: SILENT_MESSAGE_FLAGS,
655
+ })
656
+ return
657
+ }
658
+
623
659
  const hasVoiceAttachment = message.attachments.some((attachment) => {
624
660
  return isVoiceAttachment(attachment)
625
661
  })
@@ -817,7 +853,9 @@ export async function startDiscordBot({
817
853
  // and the first message's preprocess callback awaits it before resolving.
818
854
  let worktreePromise: Promise<string | Error> | undefined
819
855
  if (shouldUseWorktrees) {
820
- const worktreeName = formatWorktreeName(
856
+ // Auto-derived from thread name -- compress long slugs so the
857
+ // folder path stays short and the agent doesn't reuse old worktrees.
858
+ const worktreeName = formatAutoWorktreeName(
821
859
  hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50),
822
860
  )
823
861
  discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`)
@@ -182,7 +182,7 @@ export async function registerCommands({
182
182
  new SlashCommandBuilder()
183
183
  .setName('new-worktree')
184
184
  .setDescription(
185
- truncateCommandDescription('Create a git worktree branch from HEAD by default. Optionally pick a base branch.'),
185
+ truncateCommandDescription('Create a git worktree from the current HEAD by default. Optionally pick a base branch.'),
186
186
  )
187
187
  .addStringOption((option) => {
188
188
  option
@@ -198,7 +198,7 @@ export async function registerCommands({
198
198
  option
199
199
  .setName('base-branch')
200
200
  .setDescription(
201
- truncateCommandDescription('Branch to create the worktree from (default: HEAD)'),
201
+ truncateCommandDescription('Branch to create the worktree from (default: current HEAD)'),
202
202
  )
203
203
  .setRequired(false)
204
204
  .setAutocomplete(true)
@@ -384,6 +384,16 @@ export async function registerCommands({
384
384
  new SlashCommandBuilder()
385
385
  .setName('clear-queue')
386
386
  .setDescription(truncateCommandDescription('Clear all queued messages in this thread'))
387
+ .addIntegerOption((option) => {
388
+ option
389
+ .setName('position')
390
+ .setDescription(
391
+ truncateCommandDescription('1-based queued message position to clear (default: all)'),
392
+ )
393
+ .setMinValue(1)
394
+
395
+ return option
396
+ })
387
397
  .setDMPermission(false)
388
398
  .toJSON(),
389
399
  new SlashCommandBuilder()
@@ -33,7 +33,9 @@ function createSessionState(): SessionState {
33
33
  }
34
34
 
35
35
  function buildMemoryOverviewReminder({ condensed }: { condensed: string }): string {
36
- return `<system-reminder>Project memory from MEMORY.md (condensed table of contents, line numbers shown):\n${condensed}\nOnly headings are shown above — section bodies are hidden. Use Grep to search MEMORY.md for specific topics, or Read with offset and limit to read a section's content. When writing to MEMORY.md, keep titles concise (under 10 words) and content brief (2-3 sentences max). Only track non-obvious learnings that prevent future mistakes and are not already documented in code comments or AGENTS.md. Do not duplicate information that is self-evident from the code.</system-reminder>`
36
+ // Trailing newline so this synthetic part does not fuse with the next text
37
+ // part when the model concatenates message parts.
38
+ return `<system-reminder>Project memory from MEMORY.md (condensed table of contents, line numbers shown):\n${condensed}\nOnly headings are shown above — section bodies are hidden. Use Grep to search MEMORY.md for specific topics, or Read with offset and limit to read a section's content. When writing to MEMORY.md, keep titles concise (under 10 words) and content brief (2-3 sentences max). Only track non-obvious learnings that prevent future mistakes and are not already documented in code comments or AGENTS.md. Do not duplicate information that is self-evident from the code.</system-reminder>\n`
37
39
  }
38
40
 
39
41
  async function freezeMemoryOverview({
package/src/opencode.ts CHANGED
@@ -64,6 +64,7 @@ import {
64
64
  prependPathEntry,
65
65
  selectResolvedCommand,
66
66
  } from './opencode-command.js'
67
+ import { computeSkillPermission } from './skill-filter.js'
67
68
 
68
69
  const opencodeLogger = createLogger(LogPrefix.OPENCODE)
69
70
 
@@ -489,6 +490,9 @@ async function startSingleServer(): Promise<ServerStartError | SingleServer> {
489
490
  const opencodeConfigDir = path
490
491
  .join(os.homedir(), '.config', 'opencode')
491
492
  .replaceAll('\\', '/')
493
+ const opensrcDir = path
494
+ .join(os.homedir(), '.opensrc')
495
+ .replaceAll('\\', '/')
492
496
  const kimakiDataDir = path
493
497
  .join(os.homedir(), '.kimaki')
494
498
  .replaceAll('\\', '/')
@@ -503,6 +507,8 @@ async function startSingleServer(): Promise<ServerStartError | SingleServer> {
503
507
  [`${tmpdir}/*`]: 'allow',
504
508
  [opencodeConfigDir]: 'allow',
505
509
  [`${opencodeConfigDir}/*`]: 'allow',
510
+ [opensrcDir]: 'allow',
511
+ [`${opensrcDir}/*`]: 'allow',
506
512
  [kimakiDataDir]: 'allow',
507
513
  [`${kimakiDataDir}/*`]: 'allow',
508
514
  }
@@ -543,16 +549,30 @@ async function startSingleServer(): Promise<ServerStartError | SingleServer> {
543
549
  // priority chain, so project-level opencode.json can override kimaki defaults.
544
550
  // OPENCODE_CONFIG_CONTENT was loaded last and overrode user project configs,
545
551
  // causing issue #90 (project permissions not being respected).
552
+ const isDev = import.meta.url.endsWith('.ts') || import.meta.url.endsWith('.tsx')
553
+ // Skill whitelist/blacklist from --enable-skill / --disable-skill CLI flags.
554
+ // Applied as opencode permission.skill rules so every agent inherits the
555
+ // filter via Permission.merge(defaults, agentRules, user).
556
+ const skillPermission = computeSkillPermission({
557
+ enabledSkills: store.getState().enabledSkills,
558
+ disabledSkills: store.getState().disabledSkills,
559
+ })
546
560
  const opencodeConfig = {
547
561
  $schema: 'https://opencode.ai/config.json',
548
562
  lsp: false,
549
563
  formatter: false,
550
- plugin: [new URL('../src/kimaki-opencode-plugin.ts', import.meta.url).href],
564
+ plugin: [
565
+ new URL(
566
+ isDev ? './kimaki-opencode-plugin.ts' : './kimaki-opencode-plugin.js',
567
+ import.meta.url,
568
+ ).href,
569
+ ],
551
570
  permission: {
552
571
  edit: 'allow',
553
572
  bash: 'allow',
554
573
  external_directory: externalDirectoryPermissions,
555
574
  webfetch: 'allow',
575
+ ...(skillPermission && { skill: skillPermission }),
556
576
  },
557
577
  agent: {
558
578
  explore: {
@@ -878,6 +898,16 @@ export function buildSessionPermissions({
878
898
  { permission: 'external_directory', pattern: `${opencodeConfigDir}/*`, action: 'allow' },
879
899
  )
880
900
 
901
+ // Allow ~/.opensrc so agents can inspect cached opensrc checkouts without
902
+ // permission prompts.
903
+ const opensrcDir = path
904
+ .join(os.homedir(), '.opensrc')
905
+ .replaceAll('\\', '/')
906
+ rules.push(
907
+ { permission: 'external_directory', pattern: opensrcDir, action: 'allow' },
908
+ { permission: 'external_directory', pattern: `${opensrcDir}/*`, action: 'allow' },
909
+ )
910
+
881
911
  // Allow ~/.kimaki so the agent can access kimaki data dir (logs, db, etc.)
882
912
  // without permission prompts.
883
913
  const kimakiDataDir = path
@@ -38,6 +38,37 @@ async function waitForPendingQuestion({
38
38
  throw new Error('Timed out waiting for pending question context')
39
39
  }
40
40
 
41
+ async function expectNoBotMessageContaining({
42
+ discord,
43
+ threadId,
44
+ text,
45
+ timeout,
46
+ }: {
47
+ discord: Parameters<typeof waitForBotMessageContaining>[0]['discord']
48
+ threadId: string
49
+ text: string
50
+ timeout: number
51
+ }): Promise<void> {
52
+ const start = Date.now()
53
+ while (Date.now() - start < timeout) {
54
+ const messages = await discord.thread(threadId).getMessages()
55
+ const match = messages.find((message) => {
56
+ return (
57
+ message.author.id === discord.botUserId
58
+ && message.content.includes(text)
59
+ )
60
+ })
61
+ if (match) {
62
+ throw new Error(
63
+ `Unexpected bot message containing ${JSON.stringify(text)} while it should still be queued`,
64
+ )
65
+ }
66
+ await new Promise<void>((resolve) => {
67
+ setTimeout(resolve, 20)
68
+ })
69
+ }
70
+ }
71
+
41
72
  describe('queue drain after question select answer', () => {
42
73
  const ctx = setupQueueAdvancedSuite({
43
74
  channelId: TEXT_CHANNEL_ID,
@@ -75,16 +106,10 @@ describe('queue drain after question select answer', () => {
75
106
 
76
107
  // Get the pending question context hash from the internal map.
77
108
  // By this point the question message is visible so the context must exist.
78
- const pending = (() => {
79
- const entry = [...pendingQuestionContexts.entries()].find(([, context]) => {
80
- return context.thread.id === thread.id
81
- })
82
- return entry ? { contextHash: entry[0] } : null
83
- })()
84
- expect(pending).toBeTruthy()
85
- if (!pending) {
86
- throw new Error('Expected pending question context')
87
- }
109
+ const pending = await waitForPendingQuestion({
110
+ threadId: thread.id,
111
+ timeoutMs: 8_000,
112
+ })
88
113
  const questionMsg = questionMessages.find((m) => {
89
114
  return m.content.includes('How to proceed?')
90
115
  })!
@@ -149,4 +174,143 @@ describe('queue drain after question select answer', () => {
149
174
  },
150
175
  20_000,
151
176
  )
177
+
178
+ test(
179
+ 'only the first queued message is handed off after dropdown answer',
180
+ async () => {
181
+ const marker = 'QUESTION_SELECT_QUEUE_MARKER second-test'
182
+
183
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
184
+ content: marker,
185
+ })
186
+
187
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
188
+ timeout: 8_000,
189
+ predicate: (t) => {
190
+ return t.name === marker
191
+ },
192
+ })
193
+
194
+ const th = ctx.discord.thread(thread.id)
195
+
196
+ const questionMessages = await waitForBotMessageContaining({
197
+ discord: ctx.discord,
198
+ threadId: thread.id,
199
+ text: 'How to proceed?',
200
+ timeout: 12_000,
201
+ })
202
+
203
+ const pending = await waitForPendingQuestion({
204
+ threadId: thread.id,
205
+ timeoutMs: 8_000,
206
+ })
207
+
208
+ const questionMsg = questionMessages.find((message) => {
209
+ return message.content.includes('How to proceed?')
210
+ })
211
+ expect(questionMsg).toBeTruthy()
212
+ if (!questionMsg) {
213
+ throw new Error('Expected question message')
214
+ }
215
+
216
+ const firstQueuedPrompt = 'SLOW_ABORT_MARKER run long response'
217
+ const secondQueuedPrompt = 'Reply with exactly: post-question-second'
218
+
219
+ const { id: firstQueueInteractionId } = await th.user(TEST_USER_ID)
220
+ .runSlashCommand({
221
+ name: 'queue',
222
+ options: [{ name: 'message', type: 3, value: firstQueuedPrompt }],
223
+ })
224
+
225
+ await th.waitForInteractionAck({
226
+ interactionId: firstQueueInteractionId,
227
+ timeout: 8_000,
228
+ })
229
+
230
+ const { id: secondQueueInteractionId } = await th.user(TEST_USER_ID)
231
+ .runSlashCommand({
232
+ name: 'queue',
233
+ options: [{ name: 'message', type: 3, value: secondQueuedPrompt }],
234
+ })
235
+
236
+ await th.waitForInteractionAck({
237
+ interactionId: secondQueueInteractionId,
238
+ timeout: 8_000,
239
+ })
240
+
241
+ const interaction = await th.user(TEST_USER_ID).selectMenu({
242
+ messageId: questionMsg.id,
243
+ customId: `ask_question:${pending.contextHash}:0`,
244
+ values: ['0'],
245
+ })
246
+
247
+ await th.waitForInteractionAck({
248
+ interactionId: interaction.id,
249
+ timeout: 8_000,
250
+ })
251
+
252
+ await waitForBotMessageContaining({
253
+ discord: ctx.discord,
254
+ threadId: thread.id,
255
+ text: `» **question-select-tester:** ${firstQueuedPrompt}`,
256
+ timeout: 8_000,
257
+ })
258
+
259
+ await expectNoBotMessageContaining({
260
+ discord: ctx.discord,
261
+ threadId: thread.id,
262
+ text: `» **question-select-tester:** ${secondQueuedPrompt}`,
263
+ timeout: 200,
264
+ })
265
+
266
+ await waitForFooterMessage({
267
+ discord: ctx.discord,
268
+ threadId: thread.id,
269
+ timeout: 8_000,
270
+ afterMessageIncludes: `» **question-select-tester:** ${firstQueuedPrompt}`,
271
+ afterAuthorId: ctx.discord.botUserId,
272
+ })
273
+
274
+ await waitForBotMessageContaining({
275
+ discord: ctx.discord,
276
+ threadId: thread.id,
277
+ text: `» **question-select-tester:** ${secondQueuedPrompt}`,
278
+ timeout: 8_000,
279
+ })
280
+
281
+ await waitForFooterMessage({
282
+ discord: ctx.discord,
283
+ threadId: thread.id,
284
+ timeout: 8_000,
285
+ afterMessageIncludes: `» **question-select-tester:** ${secondQueuedPrompt}`,
286
+ afterAuthorId: ctx.discord.botUserId,
287
+ })
288
+
289
+ const timeline = await th.text({ showInteractions: true })
290
+ expect(timeline).toMatchInlineSnapshot(`
291
+ "--- from: user (question-select-tester)
292
+ QUESTION_SELECT_QUEUE_MARKER second-test
293
+ --- from: assistant (TestBot)
294
+ **Select action**
295
+ How to proceed?
296
+ ✓ _Alpha_
297
+ [user interaction]
298
+ Queued message (position 1)
299
+ [user interaction]
300
+ Queued message (position 2)
301
+ [user selects dropdown: 0]
302
+ » **question-select-tester:** SLOW_ABORT_MARKER run long response
303
+ ⬥ slow-response-started
304
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
305
+ » **question-select-tester:** Reply with exactly: post-question-second
306
+ ⬥ ok
307
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
308
+ `)
309
+ expect(timeline).toContain(`» **question-select-tester:** ${firstQueuedPrompt}`)
310
+ expect(timeline).toContain('⬥ slow-response-started')
311
+ expect(timeline).toContain(`» **question-select-tester:** ${secondQueuedPrompt}`)
312
+ expect(timeline).toContain('⬥ ok')
313
+ },
314
+ 20_000,
315
+ )
152
316
  })
@@ -80,7 +80,8 @@ export type ThreadRunState = {
80
80
  // FIFO queue of pending inputs waiting for kimaki-local dispatch.
81
81
  // Normal user messages default to opencode queue mode; this queue is
82
82
  // for explicit local-queue flows (for example /queue).
83
- // Changes: enqueueItem (append), dequeueItem (head removal), clearQueueItems.
83
+ // Changes: enqueueItem (append), dequeueItem (head removal),
84
+ // clearQueueItems, removeQueueItemAtPosition.
84
85
  // Read by: runtime queue gating, hasQueue helpers, /queue command display.
85
86
  queueItems: QueuedMessage[]
86
87
 
@@ -201,6 +202,40 @@ export function clearQueueItems(threadId: string): void {
201
202
  updateThread(threadId, (t) => ({ ...t, queueItems: [] }))
202
203
  }
203
204
 
205
+ export function removeQueueItemAtPosition(
206
+ threadId: string,
207
+ position: number,
208
+ ): QueuedMessage | undefined {
209
+ if (position < 1) {
210
+ return undefined
211
+ }
212
+
213
+ let removedItem: QueuedMessage | undefined
214
+ store.setState((s) => {
215
+ const t = s.threads.get(threadId)
216
+ if (!t) {
217
+ return s
218
+ }
219
+
220
+ const index = position - 1
221
+ const removed = t.queueItems[index]
222
+ if (!removed) {
223
+ return s
224
+ }
225
+
226
+ removedItem = removed
227
+ const newThreads = new Map(s.threads)
228
+ newThreads.set(threadId, {
229
+ ...t,
230
+ queueItems: t.queueItems.filter((_, itemIndex) => {
231
+ return itemIndex !== index
232
+ }),
233
+ })
234
+ return { threads: newThreads }
235
+ })
236
+ return removedItem
237
+ }
238
+
204
239
  // ── Queries ──────────────────────────────────────────────────────
205
240
 
206
241
  export function getThreadState(threadId: string): ThreadRunState | undefined {
@@ -404,11 +404,14 @@ export function isEssentialToolPart(part: Part): boolean {
404
404
  const DISCORD_THREAD_NAME_MAX = 100
405
405
  const WORKTREE_THREAD_PREFIX = '⬦ '
406
406
 
407
- // Pure derivation: given an OpenCode session title and the current thread name,
408
- // return the new thread name to apply, or undefined when no rename is needed.
409
- // - Skips placeholder titles ("New Session - ...") to match external-sync.
410
- // - Preserves worktree prefix when the current name carries it.
411
- // - Returns undefined when the candidate matches currentName already.
407
+ // Prefixes that should survive OpenCode session title renames.
408
+ // When a thread starts with one of these, the rename preserves it.
409
+ const PRESERVED_THREAD_PREFIXES: string[] = [
410
+ WORKTREE_THREAD_PREFIX,
411
+ 'btw: ',
412
+ 'Fork: ',
413
+ ]
414
+
412
415
  export function deriveThreadNameFromSessionTitle({
413
416
  sessionTitle,
414
417
  currentName,
@@ -423,9 +426,11 @@ export function deriveThreadNameFromSessionTitle({
423
426
  if (/^new session\s*-/i.test(trimmed)) {
424
427
  return undefined
425
428
  }
426
- const hasWorktreePrefix = currentName.startsWith(WORKTREE_THREAD_PREFIX)
427
- const prefix = hasWorktreePrefix ? WORKTREE_THREAD_PREFIX : ''
428
- const candidate = `${prefix}${trimmed}`.slice(0, DISCORD_THREAD_NAME_MAX)
429
+ const matchedPrefix =
430
+ PRESERVED_THREAD_PREFIXES.find((p) => {
431
+ return currentName.startsWith(p)
432
+ }) ?? ''
433
+ const candidate = `${matchedPrefix}${trimmed}`.slice(0, DISCORD_THREAD_NAME_MAX)
429
434
  if (candidate === currentName) {
430
435
  return undefined
431
436
  }
@@ -2069,6 +2074,8 @@ export class ThreadSessionRuntime {
2069
2074
  }
2070
2075
 
2071
2076
  private async handleMainPart(part: Part): Promise<void> {
2077
+ const sessionId = this.state?.sessionId
2078
+
2072
2079
  if (part.type === 'step-start') {
2073
2080
  this.ensureTypingNow()
2074
2081
  return
@@ -2084,13 +2091,42 @@ export class ThreadSessionRuntime {
2084
2091
 
2085
2092
  // Track task tool spawning subtask sessions
2086
2093
  if (part.tool === 'task' && !this.state?.sentPartIds.has(part.id)) {
2087
- const description = (part.state.input?.description as string) || ''
2088
- const agent = (part.state.input?.subagent_type as string) || 'task'
2089
- const childSessionId = (part.state.metadata?.sessionId as string) || ''
2094
+ const description =
2095
+ typeof part.state.input?.description === 'string'
2096
+ ? part.state.input.description
2097
+ : ''
2098
+ const agent =
2099
+ typeof part.state.input?.subagent_type === 'string'
2100
+ ? part.state.input.subagent_type
2101
+ : 'task'
2102
+ const childSessionId =
2103
+ typeof part.state.metadata?.sessionId === 'string'
2104
+ ? part.state.metadata.sessionId
2105
+ : ''
2090
2106
  if (description && childSessionId) {
2091
2107
  if ((await this.getVerbosity()) !== 'text_only') {
2092
2108
  const taskDisplay = `┣ ${agent} **${description}**`
2093
- await sendThreadMessage(this.thread, taskDisplay + '\n\n')
2109
+ threadState.updateThread(this.threadId, (t) => {
2110
+ const newIds = new Set(t.sentPartIds)
2111
+ newIds.add(part.id)
2112
+ return { ...t, sentPartIds: newIds }
2113
+ })
2114
+ const sendResult = await errore.tryAsync(() => {
2115
+ return sendThreadMessage(this.thread, taskDisplay + '\n\n')
2116
+ })
2117
+ if (sendResult instanceof Error) {
2118
+ threadState.updateThread(this.threadId, (t) => {
2119
+ const newIds = new Set(t.sentPartIds)
2120
+ newIds.delete(part.id)
2121
+ return { ...t, sentPartIds: newIds }
2122
+ })
2123
+ discordLogger.error(
2124
+ `ERROR: Failed to send task part ${part.id}:`,
2125
+ sendResult,
2126
+ )
2127
+ return
2128
+ }
2129
+ await setPartMessage(part.id, sendResult.id, this.thread.id)
2094
2130
  }
2095
2131
  }
2096
2132
  }
@@ -2594,8 +2630,9 @@ export class ThreadSessionRuntime {
2594
2630
 
2595
2631
  // When a question is answered and the local queue has items, the model may
2596
2632
  // continue the same run without ever reaching the local-queue idle gate.
2597
- // Hand the queued items to OpenCode's own prompt queue immediately instead
2598
- // of waiting for tryDrainQueue() to see an idle session.
2633
+ // Hand off only the next queued item to OpenCode immediately so the queue
2634
+ // resumes, but keep later items local so their `» user:` indicators still
2635
+ // appear one-by-one when they actually become active.
2599
2636
  if (this.getQueueLength() > 0 && !this.questionReplyQueueHandoffPromise) {
2600
2637
  logger.log(
2601
2638
  `[QUESTION REPLIED] Queue has ${this.getQueueLength()} items, handing off to opencode queue`,
@@ -2614,8 +2651,8 @@ export class ThreadSessionRuntime {
2614
2651
  }
2615
2652
 
2616
2653
  // Detached helper promise for the "question answered while local queue has
2617
- // items" flow. Prevents starting two overlapping local->opencode queue
2618
- // handoff sequences when multiple question replies land close together.
2654
+ // items" flow. Prevents starting two overlapping single-item handoffs when
2655
+ // multiple question replies land close together.
2619
2656
  private questionReplyQueueHandoffPromise: Promise<void> | null = null
2620
2657
 
2621
2658
  private async handoffQueuedItemsAfterQuestionReply({
@@ -2633,24 +2670,22 @@ export class ThreadSessionRuntime {
2633
2670
  return
2634
2671
  }
2635
2672
 
2636
- while (this.state?.sessionId === sessionId) {
2637
- const next = threadState.dequeueItem(this.threadId)
2638
- if (!next) {
2639
- return
2640
- }
2641
-
2642
- const displayText = next.command
2643
- ? `/${next.command.name}`
2644
- : `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`
2645
- if (displayText.trim()) {
2646
- await sendThreadMessage(
2647
- this.thread,
2648
- `» **${next.username}:** ${displayText}`,
2649
- )
2650
- }
2673
+ const next = threadState.dequeueItem(this.threadId)
2674
+ if (!next) {
2675
+ return
2676
+ }
2651
2677
 
2652
- await this.submitViaOpencodeQueue(next)
2678
+ const displayText = next.command
2679
+ ? `/${next.command.name}`
2680
+ : `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`
2681
+ if (displayText.trim()) {
2682
+ await sendThreadMessage(
2683
+ this.thread,
2684
+ `» **${next.username}:** ${displayText}`,
2685
+ )
2653
2686
  }
2687
+
2688
+ await this.submitViaOpencodeQueue(next)
2654
2689
  }
2655
2690
 
2656
2691
  private async handleSessionStatus(properties: {
@@ -3398,6 +3433,11 @@ export class ThreadSessionRuntime {
3398
3433
  threadState.clearQueueItems(this.threadId)
3399
3434
  }
3400
3435
 
3436
+ /** Remove a queued message by its 1-based position. */
3437
+ removeQueuePosition(position: number): threadState.QueuedMessage | undefined {
3438
+ return threadState.removeQueueItemAtPosition(this.threadId, position)
3439
+ }
3440
+
3401
3441
  // ── Queue Drain ─────────────────────────────────────────────
3402
3442
 
3403
3443
  /**
@@ -95,6 +95,24 @@ describe('deriveThreadNameFromSessionTitle', () => {
95
95
  ).toMatchInlineSnapshot(`undefined`)
96
96
  })
97
97
 
98
+ test('preserves btw: prefix from current name', () => {
99
+ expect(
100
+ deriveThreadNameFromSessionTitle({
101
+ sessionTitle: 'Side question about auth',
102
+ currentName: 'btw: why is auth broken',
103
+ }),
104
+ ).toMatchInlineSnapshot(`"btw: Side question about auth"`)
105
+ })
106
+
107
+ test('preserves Fork: prefix from current name', () => {
108
+ expect(
109
+ deriveThreadNameFromSessionTitle({
110
+ sessionTitle: 'Forked task title',
111
+ currentName: 'Fork: old session title',
112
+ }),
113
+ ).toMatchInlineSnapshot(`"Fork: Forked task title"`)
114
+ })
115
+
98
116
  test('returns undefined for null/undefined title', () => {
99
117
  expect(
100
118
  deriveThreadNameFromSessionTitle({