kimaki 0.28.0 → 0.30.0

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 (179) hide show
  1. package/dist/agent-model.e2e.test.js +33 -33
  2. package/dist/anthropic-account-identity.js +1 -1
  3. package/dist/anthropic-auth-plugin.js +5 -5
  4. package/dist/anthropic-auth-plugin.test.js +30 -2
  5. package/dist/anthropic-auth-state.js +1 -1
  6. package/dist/bash-tool-schema-plugin.js +18 -0
  7. package/dist/bash-tool-schema-plugin.test.js +20 -1
  8. package/dist/bin.js +7 -3
  9. package/dist/bot-lifecycle.test.js +206 -0
  10. package/dist/cache-drift-plugin.js +25 -18
  11. package/dist/cache-rewrite.js +39 -0
  12. package/dist/cache-rewrite.test.js +41 -0
  13. package/dist/channel-management.js +113 -51
  14. package/dist/cli-commands/misc.js +4 -1
  15. package/dist/cli-commands/project.js +7 -4
  16. package/dist/cli-commands/session.js +118 -36
  17. package/dist/cli-commands/thread.js +63 -0
  18. package/dist/cli-parsing.test.js +3 -3
  19. package/dist/cli-runner.js +11 -1
  20. package/dist/cli-send-thread.e2e.test.js +2 -2
  21. package/dist/cli.js +6 -4
  22. package/dist/commands/ask-question.js +72 -23
  23. package/dist/commands/queue.js +84 -36
  24. package/dist/commands/queue.test.js +15 -0
  25. package/dist/commands/restart-opencode-server.js +7 -1
  26. package/dist/commands/session-id.js +5 -5
  27. package/dist/database.js +149 -6
  28. package/dist/db.js +34 -0
  29. package/dist/db.test.js +199 -1
  30. package/dist/default-channel-provisioning.test.js +2 -1
  31. package/dist/discord-bot.js +220 -146
  32. package/dist/discord-command-registration.js +1 -1
  33. package/dist/external-directory-permissions.e2e.test.js +3 -3
  34. package/dist/external-directory-project-deny.e2e.test.js +1 -1
  35. package/dist/forum-sync/watchers.js +15 -0
  36. package/dist/gateway-proxy.e2e.test.js +9 -9
  37. package/dist/guild-categories.test.js +225 -0
  38. package/dist/hrana-server.js +68 -14
  39. package/dist/interaction-handler.js +5 -1
  40. package/dist/kimaki-opencode-plugin.js +2 -0
  41. package/dist/kimaki-opencode-plugin.test.js +28 -0
  42. package/dist/list-channel-threads.js +163 -0
  43. package/dist/list-channel-threads.test.js +80 -0
  44. package/dist/markdown.js +164 -102
  45. package/dist/markdown.test.js +88 -20
  46. package/dist/message-formatting.js +29 -18
  47. package/dist/message-formatting.test.js +111 -3
  48. package/dist/opencode.js +27 -12
  49. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  50. package/dist/question-abort-resume.e2e.test.js +100 -0
  51. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  52. package/dist/question-text-before-dropdown.e2e.test.js +1 -1
  53. package/dist/queue-advanced-abort.e2e.test.js +3 -3
  54. package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
  55. package/dist/queue-advanced-e2e-setup.js +63 -0
  56. package/dist/queue-advanced-footer.e2e.test.js +18 -18
  57. package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
  58. package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
  59. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  60. package/dist/queue-delete-message.e2e.test.js +4 -4
  61. package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
  62. package/dist/queue-interrupt-drain.e2e.test.js +10 -10
  63. package/dist/queue-question-select-drain.e2e.test.js +5 -5
  64. package/dist/runtime-lifecycle.e2e.test.js +95 -11
  65. package/dist/schema.js +21 -0
  66. package/dist/session-handler/event-stream-state.js +240 -6
  67. package/dist/session-handler/event-stream-state.test.js +1116 -130
  68. package/dist/session-handler/thread-runtime-state.js +4 -0
  69. package/dist/session-handler/thread-session-runtime.js +499 -89
  70. package/dist/sleep.e2e.test.js +7 -7
  71. package/dist/store.js +1 -1
  72. package/dist/system-message.js +64 -23
  73. package/dist/system-message.test.js +69 -23
  74. package/dist/task-id-plugin.js +16 -0
  75. package/dist/task-runner.js +7 -0
  76. package/dist/task-schedule.test.js +1 -1
  77. package/dist/test-utils.js +5 -3
  78. package/dist/thread-message-queue.e2e.test.js +30 -31
  79. package/dist/undo-redo.e2e.test.js +3 -3
  80. package/dist/voice-message.e2e.test.js +64 -26
  81. package/dist/voice-routing-input.e2e.test.js +4 -4
  82. package/dist/voice-routing-question.e2e.test.js +4 -4
  83. package/dist/voice.js +107 -21
  84. package/dist/voice.test.js +52 -1
  85. package/dist/wait-session.js +24 -2
  86. package/dist/worktree-lifecycle.e2e.test.js +8 -8
  87. package/package.json +7 -7
  88. package/skills/egaki/SKILL.md +14 -0
  89. package/skills/holocron/SKILL.md +125 -42
  90. package/skills/opensrc/SKILL.md +20 -3
  91. package/skills/spiceflow/SKILL.md +53 -172
  92. package/skills/strada/SKILL.md +61 -232
  93. package/src/agent-model.e2e.test.ts +33 -33
  94. package/src/anthropic-account-identity.ts +1 -1
  95. package/src/anthropic-auth-plugin.test.ts +37 -2
  96. package/src/anthropic-auth-plugin.ts +9 -7
  97. package/src/anthropic-auth-state.ts +1 -0
  98. package/src/bash-tool-schema-plugin.test.ts +29 -0
  99. package/src/bash-tool-schema-plugin.ts +31 -0
  100. package/src/bin.ts +7 -3
  101. package/src/bot-lifecycle.test.ts +252 -0
  102. package/src/cache-drift-plugin.ts +34 -24
  103. package/src/cache-rewrite.test.ts +49 -0
  104. package/src/cache-rewrite.ts +96 -0
  105. package/src/channel-management.ts +144 -65
  106. package/src/cli-commands/misc.ts +4 -1
  107. package/src/cli-commands/project.ts +9 -4
  108. package/src/cli-commands/session.ts +167 -38
  109. package/src/cli-commands/thread.ts +86 -0
  110. package/src/cli-parsing.test.ts +3 -3
  111. package/src/cli-runner.ts +11 -0
  112. package/src/cli-send-thread.e2e.test.ts +2 -2
  113. package/src/cli.ts +8 -6
  114. package/src/commands/ask-question.ts +88 -25
  115. package/src/commands/queue.test.ts +19 -0
  116. package/src/commands/queue.ts +111 -45
  117. package/src/commands/restart-opencode-server.ts +12 -1
  118. package/src/commands/session-id.ts +5 -5
  119. package/src/database.ts +219 -6
  120. package/src/db.test.ts +236 -0
  121. package/src/db.ts +38 -0
  122. package/src/default-channel-provisioning.test.ts +2 -1
  123. package/src/discord-bot.ts +272 -185
  124. package/src/discord-command-registration.ts +3 -1
  125. package/src/external-directory-permissions.e2e.test.ts +3 -3
  126. package/src/external-directory-project-deny.e2e.test.ts +1 -1
  127. package/src/forum-sync/watchers.ts +20 -0
  128. package/src/gateway-proxy.e2e.test.ts +9 -9
  129. package/src/guild-categories.test.ts +260 -0
  130. package/src/hrana-server.ts +96 -16
  131. package/src/interaction-handler.ts +7 -0
  132. package/src/kimaki-opencode-plugin.test.ts +34 -0
  133. package/src/kimaki-opencode-plugin.ts +2 -0
  134. package/src/list-channel-threads.test.ts +90 -0
  135. package/src/list-channel-threads.ts +252 -0
  136. package/src/markdown.test.ts +108 -22
  137. package/src/markdown.ts +216 -103
  138. package/src/message-formatting.test.ts +129 -2
  139. package/src/message-formatting.ts +28 -14
  140. package/src/opencode.ts +28 -13
  141. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  142. package/src/question-abort-resume.e2e.test.ts +121 -0
  143. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  144. package/src/question-text-before-dropdown.e2e.test.ts +1 -1
  145. package/src/queue-advanced-abort.e2e.test.ts +3 -3
  146. package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
  147. package/src/queue-advanced-e2e-setup.ts +65 -0
  148. package/src/queue-advanced-footer.e2e.test.ts +18 -18
  149. package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
  150. package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
  151. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  152. package/src/queue-delete-message.e2e.test.ts +4 -4
  153. package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
  154. package/src/queue-interrupt-drain.e2e.test.ts +10 -10
  155. package/src/queue-question-select-drain.e2e.test.ts +5 -5
  156. package/src/runtime-lifecycle.e2e.test.ts +104 -10
  157. package/src/schema.sql +16 -0
  158. package/src/schema.ts +23 -0
  159. package/src/session-handler/event-stream-state.test.ts +1184 -133
  160. package/src/session-handler/event-stream-state.ts +322 -8
  161. package/src/session-handler/thread-runtime-state.ts +5 -0
  162. package/src/session-handler/thread-session-runtime.ts +597 -95
  163. package/src/sleep.e2e.test.ts +7 -7
  164. package/src/store.ts +2 -2
  165. package/src/system-message.test.ts +70 -23
  166. package/src/system-message.ts +67 -22
  167. package/src/task-id-plugin.ts +17 -0
  168. package/src/task-runner.ts +8 -0
  169. package/src/task-schedule.test.ts +1 -1
  170. package/src/test-utils.ts +5 -3
  171. package/src/thread-message-queue.e2e.test.ts +30 -31
  172. package/src/undo-redo.e2e.test.ts +3 -3
  173. package/src/voice-message.e2e.test.ts +70 -26
  174. package/src/voice-routing-input.e2e.test.ts +4 -4
  175. package/src/voice-routing-question.e2e.test.ts +4 -4
  176. package/src/voice.test.ts +56 -0
  177. package/src/voice.ts +135 -25
  178. package/src/wait-session.ts +25 -2
  179. package/src/worktree-lifecycle.e2e.test.ts +8 -8
@@ -8,7 +8,7 @@
8
8
 
9
9
  import crypto from 'node:crypto'
10
10
  import { AsyncLocalStorage } from 'node:async_hooks'
11
- import { ChannelType, type ThreadChannel } from 'discord.js'
11
+ import { ChannelType, type Client, type ThreadChannel } from 'discord.js'
12
12
  import type {
13
13
  Event as OpenCodeEvent,
14
14
  Part,
@@ -45,14 +45,17 @@ import {
45
45
  raceDiscordRename,
46
46
  DISCORD_THREAD_RENAME_TIMEOUT_MS,
47
47
  resolveThreadFooterMentionUserId,
48
+ resolveWorkingDirectory,
48
49
  } from '../discord-utils.js'
49
50
  import type { DiscordFileAttachment, SessionPartKind } from '../message-formatting.js'
50
51
  import {
51
52
  asDiscordQuote,
53
+ asSystemLine,
52
54
  formatPart,
53
55
  formatTaskToolTitle,
54
56
  planAssistantTurnFlush,
55
57
  QUEUE_PREFIX,
58
+ sessionPartContent,
56
59
  sessionPartKind,
57
60
  shouldLeadWithBlankLine,
58
61
  shouldQuoteIntermediateTextPart,
@@ -83,6 +86,11 @@ import {
83
86
  appendSessionEventsSinceLastTimestamp,
84
87
  getSessionEventSnapshot,
85
88
  cancelSessionSleepForThread,
89
+ insertThreadQueueItem,
90
+ listAllThreadQueueItems,
91
+ deleteThreadQueueItem,
92
+ deleteThreadQueueItems,
93
+ updateThreadQueueItemPayload,
86
94
  } from '../database.js'
87
95
  import * as orm from 'drizzle-orm'
88
96
  import * as schema from '../schema.js'
@@ -127,6 +135,7 @@ import {
127
135
  type ScheduledTaskSystemContext,
128
136
  } from '../system-message.js'
129
137
  import { getDataDir } from '../config.js'
138
+ import { countSystemPromptDiffLines } from '../cache-rewrite.js'
130
139
  import { store } from '../store.js'
131
140
  import {
132
141
  trackEvent,
@@ -150,6 +159,8 @@ import {
150
159
  getCurrentTurnStartTime,
151
160
  isSessionBusy,
152
161
  getLatestRunInfo,
162
+ getPromptCacheClear,
163
+ formatPromptCacheClearMessage,
153
164
  getIdleTokenUsageDelta,
154
165
  getDerivedSubtaskIndex,
155
166
  getDerivedSubtaskAgentType,
@@ -161,6 +172,8 @@ import {
161
172
  isAssistantMessageInLatestUserTurn,
162
173
  isAssistantMessageNaturalCompletion,
163
174
  shouldBufferSessionEvent,
175
+ shouldRetainSessionEvent,
176
+ trimEventBuffer,
164
177
  type EventBufferEvent,
165
178
  type EventBufferEntry,
166
179
  } from './event-stream-state.js'
@@ -320,11 +333,92 @@ export function getOrCreateRuntime(
320
333
  return runtime
321
334
  }
322
335
 
323
- export function disposeRuntime(threadId: string): void {
336
+ function groupQueueRowsByThread(
337
+ rows: Array<{ thread_id: string; queue_id: string; payload_json: string }>,
338
+ ): Map<string, QueuedMessage[]> {
339
+ const byThread = new Map<string, QueuedMessage[]>()
340
+ for (const row of rows) {
341
+ const parsed = parseQueuedMessagePayload({
342
+ queueId: row.queue_id,
343
+ payloadJson: row.payload_json,
344
+ })
345
+ if (parsed instanceof Error) {
346
+ logger.warn(
347
+ `[QUEUE] Skipping invalid queue row ${row.queue_id} in thread ${row.thread_id}: ${parsed.message}`,
348
+ )
349
+ continue
350
+ }
351
+ const items = byThread.get(row.thread_id) ?? []
352
+ items.push(parsed)
353
+ byThread.set(row.thread_id, items)
354
+ }
355
+ return byThread
356
+ }
357
+
358
+ export async function restorePersistedLocalQueues({
359
+ discordClient,
360
+ appId,
361
+ }: {
362
+ discordClient: Client
363
+ appId?: string
364
+ }): Promise<void> {
365
+ const rows = await listAllThreadQueueItems()
366
+ if (rows.length === 0) {
367
+ return
368
+ }
369
+
370
+ const byThread = groupQueueRowsByThread(rows)
371
+ for (const [threadId, items] of byThread) {
372
+ if (items.length === 0) {
373
+ continue
374
+ }
375
+ let runtime = runtimes.get(threadId)
376
+ if (!runtime) {
377
+ const fetched = await discordClient.channels.fetch(threadId).catch((error) => {
378
+ logger.warn(
379
+ `[QUEUE] Failed to fetch thread ${threadId} for restored queue: ${error instanceof Error ? error.message : String(error)}`,
380
+ )
381
+ return null
382
+ })
383
+ if (!fetched?.isThread()) {
384
+ logger.warn(`[QUEUE] Skipping restored queue for missing thread ${threadId}`)
385
+ continue
386
+ }
387
+
388
+ const resolved = await resolveWorkingDirectory({ channel: fetched })
389
+ if (!resolved) {
390
+ logger.warn(`[QUEUE] Skipping restored queue for thread ${threadId}: no project directory`)
391
+ continue
392
+ }
393
+
394
+ const sessionId = await getThreadSession(threadId)
395
+ runtime = getOrCreateRuntime({
396
+ threadId,
397
+ thread: fetched,
398
+ projectDirectory: resolved.projectDirectory,
399
+ sdkDirectory: resolved.workingDirectory,
400
+ channelId: fetched.parentId || fetched.id,
401
+ appId,
402
+ sessionId,
403
+ })
404
+ }
405
+ await runtime.dispatchAction(() => {
406
+ return runtime.mergeRestoredQueueAndDrain(items)
407
+ })
408
+ }
409
+ }
410
+
411
+ export function disposeRuntime(
412
+ threadId: string,
413
+ { abortActiveRun = false }: { abortActiveRun?: boolean } = {},
414
+ ): void {
324
415
  const runtime = runtimes.get(threadId)
325
416
  if (!runtime) {
326
417
  return
327
418
  }
419
+ if (abortActiveRun) {
420
+ runtime.abortDeletedDiscordResource()
421
+ }
328
422
  runtime.dispose()
329
423
  runtimes.delete(threadId)
330
424
  threadState.removeThread(threadId) // remove from global store
@@ -355,6 +449,12 @@ export function disposeRuntimesForDirectory({
355
449
  return count
356
450
  }
357
451
 
452
+ export function getRuntimeThreadIdsForChannel(channelId: string): string[] {
453
+ return Array.from(runtimes.entries())
454
+ .filter(([, runtime]) => runtime.channelId === channelId)
455
+ .map(([threadId]) => threadId)
456
+ }
457
+
358
458
  /** Returns number of active runtimes (useful for diagnostics). */
359
459
  export function getRuntimeCount(): number {
360
460
  return runtimes.size
@@ -722,6 +822,36 @@ function trackTurnStarted({
722
822
  })
723
823
  }
724
824
 
825
+ function parseQueuedMessagePayload({
826
+ queueId,
827
+ payloadJson,
828
+ }: {
829
+ queueId: string
830
+ payloadJson: string
831
+ }): QueuedMessage | Error {
832
+ return errore.try(
833
+ () => {
834
+ const parsed = JSON.parse(payloadJson) as QueuedMessage
835
+ if (!parsed || typeof parsed !== 'object') {
836
+ return new Error('Queued message payload is not an object')
837
+ }
838
+ if (typeof parsed.prompt !== 'string') {
839
+ return new Error('Queued message payload is missing prompt')
840
+ }
841
+ if (typeof parsed.userId !== 'string') {
842
+ return new Error('Queued message payload is missing userId')
843
+ }
844
+ if (typeof parsed.username !== 'string') {
845
+ return new Error('Queued message payload is missing username')
846
+ }
847
+ return { ...parsed, queueId }
848
+ },
849
+ (error) => {
850
+ return new Error('Failed to parse queued message payload', { cause: error })
851
+ },
852
+ )
853
+ }
854
+
725
855
  // Rewrite `{ prompt: "/build foo" }` → `{ prompt: "", command: { name, arguments }, mode: "local-queue" }`
726
856
  // when the prompt's leading token matches a registered opencode command.
727
857
  // Skip if a command is already set or there's no prompt to inspect.
@@ -770,6 +900,7 @@ export class ThreadSessionRuntime {
770
900
 
771
901
  // Set to true by dispose(). Guards against queued work running after cleanup.
772
902
  private disposed = false
903
+ private dispatchingQueueId: string | undefined
773
904
 
774
905
  // Typing indicator scheduler handles.
775
906
  // `typingKeepaliveTimeout` is the 7s keepalive loop while a run stays busy.
@@ -786,6 +917,7 @@ export class ThreadSessionRuntime {
786
917
  // Notification throttles for retry/context notices.
787
918
  private lastDisplayedContextPercentage = 0
788
919
  private lastRateLimitDisplayTime = 0
920
+ private userSystemByMessageId = new Map<string, string>()
789
921
 
790
922
  // Last OpenCode session title we applied to Discord. Dedupes session.updated
791
923
  // so we only call setName once per distinct title. Not persisted.
@@ -900,7 +1032,7 @@ export class ThreadSessionRuntime {
900
1032
  }
901
1033
 
902
1034
  getDerivedPhase(): 'idle' | 'running' {
903
- return this.isMainSessionBusy() ? 'running' : 'idle'
1035
+ return this.isBusy() ? 'running' : 'idle'
904
1036
  }
905
1037
 
906
1038
  private getLastRuntimeActivityTimestamp({
@@ -929,7 +1061,7 @@ export class ThreadSessionRuntime {
929
1061
  }
930
1062
 
931
1063
  private isIdleCandidateForInactivityCheck(): boolean {
932
- if (this.isMainSessionBusy()) {
1064
+ if (this.isBusy()) {
933
1065
  return false
934
1066
  }
935
1067
  if ((this.state?.queueItems.length ?? 0) > 0) {
@@ -1013,7 +1145,18 @@ export class ThreadSessionRuntime {
1013
1145
  ]
1014
1146
  })
1015
1147
 
1016
- this.eventBuffer = hydratedEvents.slice(-ThreadSessionRuntime.EVENT_BUFFER_MAX)
1148
+ this.eventBuffer = trimEventBuffer({
1149
+ events: hydratedEvents,
1150
+ mainSessionId: sessionId,
1151
+ max: ThreadSessionRuntime.EVENT_BUFFER_MAX,
1152
+ isKnownChildSession: (candidateSessionId) => {
1153
+ return isDerivedChildSession({
1154
+ events: hydratedEvents,
1155
+ mainSessionId: sessionId,
1156
+ candidateSessionId,
1157
+ })
1158
+ },
1159
+ })
1017
1160
  const lastHydratedEvent = this.eventBuffer[this.eventBuffer.length - 1]
1018
1161
  this.nextEventIndex = lastHydratedEvent
1019
1162
  ? Number(lastHydratedEvent.eventIndex || 0) + 1
@@ -1072,7 +1215,8 @@ export class ThreadSessionRuntime {
1072
1215
  return `phase=${phase},assistant=${latestAssistant},assistantCount=${assistantCount}`
1073
1216
  }
1074
1217
 
1075
- private isMainSessionBusy(): boolean {
1218
+ /** Whether the main session currently has an active run (derived from events). */
1219
+ isBusy(): boolean {
1076
1220
  const sessionId = this.state?.sessionId
1077
1221
  if (!sessionId) {
1078
1222
  return false
@@ -1190,6 +1334,14 @@ export class ThreadSessionRuntime {
1190
1334
 
1191
1335
  // ── Lifecycle ────────────────────────────────────────────────
1192
1336
 
1337
+ abortDeletedDiscordResource(): void {
1338
+ if (this.getDerivedPhase() === 'running') {
1339
+ void this.abortActiveRunInternal({
1340
+ reason: 'discord-resource-deleted',
1341
+ }).apiAbortPromise
1342
+ }
1343
+ }
1344
+
1193
1345
  dispose(): void {
1194
1346
  this.disposed = true
1195
1347
  unregisterEventListener(this.threadId)
@@ -1312,6 +1464,9 @@ export class ThreadSessionRuntime {
1312
1464
  return [{ id: candidate.id, type: candidate.type }]
1313
1465
  })
1314
1466
  : []
1467
+ if (info.role === 'user' && typeof info.id === 'string' && typeof info.system === 'string') {
1468
+ this.userSystemByMessageId.set(info.id, info.system)
1469
+ }
1315
1470
  delete info.system
1316
1471
  delete info.tools
1317
1472
  delete info.parts
@@ -1389,6 +1544,15 @@ export class ThreadSessionRuntime {
1389
1544
  if (!compactedEvent) {
1390
1545
  return
1391
1546
  }
1547
+ if (!shouldRetainSessionEvent({
1548
+ event: compactedEvent,
1549
+ mainSessionId: this.state?.sessionId,
1550
+ isKnownChildSession: (candidateSessionId) => {
1551
+ return Boolean(this.getSubtaskInfoForSession(candidateSessionId))
1552
+ },
1553
+ })) {
1554
+ return
1555
+ }
1392
1556
 
1393
1557
  const timestamp = Date.now()
1394
1558
  const eventIndex = this.nextEventIndex
@@ -1398,9 +1562,14 @@ export class ThreadSessionRuntime {
1398
1562
  timestamp,
1399
1563
  eventIndex,
1400
1564
  })
1401
- if (this.eventBuffer.length > ThreadSessionRuntime.EVENT_BUFFER_MAX) {
1402
- this.eventBuffer.splice(0, this.eventBuffer.length - ThreadSessionRuntime.EVENT_BUFFER_MAX)
1403
- }
1565
+ this.eventBuffer = trimEventBuffer({
1566
+ events: this.eventBuffer,
1567
+ mainSessionId: this.state?.sessionId,
1568
+ max: ThreadSessionRuntime.EVENT_BUFFER_MAX,
1569
+ isKnownChildSession: (candidateSessionId) => {
1570
+ return Boolean(this.getSubtaskInfoForSession(candidateSessionId))
1571
+ },
1572
+ })
1404
1573
  this.persistEventBufferDebounced.trigger()
1405
1574
  }
1406
1575
 
@@ -1513,6 +1682,7 @@ export class ThreadSessionRuntime {
1513
1682
  // causing tryDrainQueue to drain the local queue while the session is
1514
1683
  // actually still busy. This was the root cause of "? queue" messages
1515
1684
  // interrupting instead of queuing.
1685
+ // Child task part floods are also dropped at retain time for the same reason.
1516
1686
  if (event.type !== 'message.part.delta') {
1517
1687
  this.appendEventToBuffer(event)
1518
1688
  }
@@ -1872,6 +2042,9 @@ export class ThreadSessionRuntime {
1872
2042
  if (part.type === 'text' && !part.time?.end && mode === 'progress') {
1873
2043
  return false
1874
2044
  }
2045
+ if (part.type === 'text' && part.synthetic === true) {
2046
+ return false
2047
+ }
1875
2048
  return true
1876
2049
  }
1877
2050
 
@@ -1883,7 +2056,50 @@ export class ThreadSessionRuntime {
1883
2056
  if (messageIds.length > 0) {
1884
2057
  return messageIds.flatMap((id) => this.getBufferedParts(id))
1885
2058
  }
1886
- return [...this.partBuffer.keys()].flatMap((id) => this.getBufferedParts(id))
2059
+ return []
2060
+ }
2061
+
2062
+ private async unquoteFinalTextPart(): Promise<void> {
2063
+ const parts = this.getCurrentTurnParts()
2064
+ const finalPart = planAssistantTurnFlush({
2065
+ parts,
2066
+ mode: 'final',
2067
+ }).sendParts.at(-1)
2068
+ if (!finalPart || finalPart.quoteText || finalPart.part.type !== 'text') return
2069
+ const last = finalPart.part
2070
+ const db = await getDb()
2071
+ const row = await db.query.part_messages.findFirst({
2072
+ where: { part_id: last.id },
2073
+ columns: { message_id: true },
2074
+ }).catch((e) => new DiscordOperationError({ operation: 'getPartMessage', cause: e }))
2075
+ if (row instanceof Error) {
2076
+ discordLogger.error(`Failed to find Discord message for ${last.id}:`, row)
2077
+ return
2078
+ }
2079
+ const messageId = row?.message_id
2080
+ if (!messageId) return
2081
+ const message = await this.thread.messages.fetch(messageId)
2082
+ .catch((e) => new DiscordOperationError({ operation: 'fetchMessage', cause: e }))
2083
+ if (message instanceof Error) {
2084
+ discordLogger.error(`Failed to fetch Discord message for ${last.id}:`, message)
2085
+ return
2086
+ }
2087
+ const formatted = formatPart(last)
2088
+ const leadWithBlankLine = message.content.startsWith('\n')
2089
+ const quoted = sessionPartContent({
2090
+ content: asDiscordQuote(formatted),
2091
+ leadWithBlankLine,
2092
+ })
2093
+ if (message.content !== quoted) return
2094
+ const plain = sessionPartContent({
2095
+ content: formatted,
2096
+ leadWithBlankLine,
2097
+ })
2098
+ const edited = await message.edit({ content: plain })
2099
+ .catch((e) => new DiscordOperationError({ operation: 'editMessage', cause: e }))
2100
+ if (edited instanceof Error) {
2101
+ discordLogger.error(`ERROR: Failed to unquote final text ${last.id}:`, edited)
2102
+ }
1887
2103
  }
1888
2104
 
1889
2105
  private async flushCurrentTurnParts({
@@ -1897,15 +2113,16 @@ export class ThreadSessionRuntime {
1897
2113
  skipPartId?: string
1898
2114
  repulseTyping?: boolean
1899
2115
  }): Promise<void> {
1900
- const parts = this.getCurrentTurnParts().filter((part) => {
1901
- return !this.state?.sentPartIds.has(part.id)
1902
- })
2116
+ const parts = this.getCurrentTurnParts()
1903
2117
  const planned = planAssistantTurnFlush({
1904
2118
  parts,
1905
2119
  mode,
1906
2120
  throughPartId,
1907
2121
  })
1908
2122
  for (const { part, quoteText } of planned.sendParts) {
2123
+ if (this.state?.sentPartIds.has(part.id)) {
2124
+ continue
2125
+ }
1909
2126
  if (skipPartId && part.id === skipPartId) {
1910
2127
  continue
1911
2128
  }
@@ -2203,6 +2420,15 @@ export class ThreadSessionRuntime {
2203
2420
  return
2204
2421
  }
2205
2422
 
2423
+ if (part.type === 'text' && part.synthetic === true) {
2424
+ return
2425
+ }
2426
+
2427
+ if (part.type === 'text' && part.ignored === true) {
2428
+ await this.sendPartMessage({ part, repulseTyping: false })
2429
+ return
2430
+ }
2431
+
2206
2432
  this.storePart(part)
2207
2433
 
2208
2434
  if (messageKind === 'unknown') {
@@ -2236,11 +2462,8 @@ export class ThreadSessionRuntime {
2236
2462
  await this.flushCurrentTurnParts({
2237
2463
  mode: 'progress',
2238
2464
  })
2239
- const unsent = this.getCurrentTurnParts().filter((candidate) => {
2240
- return !this.state?.sentPartIds.has(candidate.id)
2241
- })
2242
2465
  const held = planAssistantTurnFlush({
2243
- parts: unsent,
2466
+ parts: this.getCurrentTurnParts(),
2244
2467
  mode: 'progress',
2245
2468
  }).hold.some((entry) => entry.id === part.id)
2246
2469
  if (held) {
@@ -2607,6 +2830,12 @@ export class ThreadSessionRuntime {
2607
2830
  mode: 'final',
2608
2831
  repulseTyping: false,
2609
2832
  })
2833
+ await this.unquoteFinalTextPart()
2834
+
2835
+ await this.maybeNotifyPromptCacheClear({
2836
+ sessionId,
2837
+ messageId: completedMessageId,
2838
+ })
2610
2839
 
2611
2840
  // Skip footer if model produced no visible output (no text, no tool calls,
2612
2841
  // just step-start/step-finish lifecycle parts). This happens when the model
@@ -3401,10 +3630,12 @@ export class ThreadSessionRuntime {
3401
3630
  })()
3402
3631
  const worktreeChanged = this.consumeWorktreePromptChange(worktree)
3403
3632
  const syntheticContext = getOpencodePromptContext({
3633
+ sessionId: session.id,
3634
+ threadId: this.thread.id,
3404
3635
  username: input.username,
3405
3636
  userId: input.userId,
3406
3637
  sourceMessageId: input.sourceMessageId,
3407
- sourceThreadId: input.sourceThreadId,
3638
+ sourceThreadId: input.sourceThreadId || this.thread.id,
3408
3639
  threadName: this.thread.name || undefined,
3409
3640
  repliedMessage: input.repliedMessage,
3410
3641
  worktree,
@@ -3538,7 +3769,19 @@ export class ThreadSessionRuntime {
3538
3769
  let result: EnqueueResult = { queued: false, queueId }
3539
3770
 
3540
3771
  await this.dispatchAction(async () => {
3541
- // Enqueue the message
3772
+ const persistResult = await insertThreadQueueItem({
3773
+ queueId,
3774
+ threadId: this.threadId,
3775
+ payloadJson: JSON.stringify(queuedMessage),
3776
+ }).catch((error) => {
3777
+ return new Error('Failed to persist queued message', { cause: error })
3778
+ })
3779
+ if (persistResult instanceof Error) {
3780
+ logger.error(
3781
+ `[QUEUE] Failed to persist queued message ${queueId} in thread ${this.threadId}: ${persistResult.message}`,
3782
+ )
3783
+ throw persistResult
3784
+ }
3542
3785
  threadState.enqueueItem(this.threadId, queuedMessage)
3543
3786
 
3544
3787
  // Determine if the message is genuinely waiting in queue
@@ -3547,7 +3790,7 @@ export class ThreadSessionRuntime {
3547
3790
  const willDrainNow = stateAfterEnqueue
3548
3791
  ? (
3549
3792
  stateAfterEnqueue.queueItems.length > 0
3550
- && !this.isMainSessionBusy()
3793
+ && !this.isBusy()
3551
3794
  )
3552
3795
  : false
3553
3796
  result = !willDrainNow && position > 0
@@ -3780,7 +4023,7 @@ export class ThreadSessionRuntime {
3780
4023
  }
3781
4024
 
3782
4025
  const sessionId = state.sessionId
3783
- const sessionIsBusy = this.isMainSessionBusy()
4026
+ const sessionIsBusy = this.isBusy()
3784
4027
 
3785
4028
  logger.log(
3786
4029
  `[ABORT] id=${abortId} reason=${reason} threadId=${this.threadId} sessionId=${sessionId || 'none'} queueLength=${state.queueItems.length} ${this.formatRunStateForLog()} sessionBusy=${sessionIsBusy}`,
@@ -3837,7 +4080,7 @@ export class ThreadSessionRuntime {
3837
4080
  let needsIdleWait = false
3838
4081
  const waitSinceTimestamp = Date.now()
3839
4082
  const abortResult = await this.dispatchAction(async () => {
3840
- needsIdleWait = this.isMainSessionBusy()
4083
+ needsIdleWait = this.isBusy()
3841
4084
  const outcome = this.abortActiveRunInternal({ reason })
3842
4085
  if (outcome.apiAbortPromise) {
3843
4086
  void outcome.apiAbortPromise
@@ -3866,18 +4109,72 @@ export class ThreadSessionRuntime {
3866
4109
  }
3867
4110
 
3868
4111
  /** Clear all queued messages. Returns the removed items. */
3869
- clearQueue(): threadState.QueuedMessage[] {
3870
- return threadState.clearQueueItems(this.threadId)
4112
+ async clearQueue(): Promise<threadState.QueuedMessage[]> {
4113
+ let cleared: threadState.QueuedMessage[] = []
4114
+ await this.dispatchAction(async () => {
4115
+ const persistResult = await deleteThreadQueueItems(this.threadId).catch((error) => {
4116
+ return new Error('Failed to clear persisted queue', { cause: error })
4117
+ })
4118
+ if (persistResult instanceof Error) {
4119
+ logger.error(
4120
+ `[QUEUE] Failed to clear persisted queue for thread ${this.threadId}: ${persistResult.message}`,
4121
+ )
4122
+ return
4123
+ }
4124
+ cleared = threadState.clearQueueItems(this.threadId)
4125
+ })
4126
+ return cleared
3871
4127
  }
3872
4128
 
3873
4129
  /** Remove a queued message by its 1-based position. */
3874
- removeQueuePosition(position: number): threadState.QueuedMessage | undefined {
3875
- return threadState.removeQueueItemAtPosition(this.threadId, position)
4130
+ async removeQueuePosition(position: number): Promise<threadState.QueuedMessage | undefined> {
4131
+ let removed: threadState.QueuedMessage | undefined
4132
+ await this.dispatchAction(async () => {
4133
+ const current = this.state?.queueItems[position - 1]
4134
+ if (!current) {
4135
+ return
4136
+ }
4137
+ if (current.queueId) {
4138
+ const persistResult = await deleteThreadQueueItem(current.queueId).catch((error) => {
4139
+ return new Error('Failed to delete persisted queue item', { cause: error })
4140
+ })
4141
+ if (persistResult instanceof Error) {
4142
+ logger.error(
4143
+ `[QUEUE] Failed to delete persisted queue item ${current.queueId}: ${persistResult.message}`,
4144
+ )
4145
+ return
4146
+ }
4147
+ }
4148
+ removed = threadState.removeQueueItemAtPosition(this.threadId, position)
4149
+ })
4150
+ return removed
3876
4151
  }
3877
4152
 
3878
4153
  /** Remove a queued message by stable queue id. */
3879
- removeQueueItemById(queueId: string): threadState.QueuedMessage | undefined {
3880
- return threadState.removeQueueItemById(this.threadId, queueId)
4154
+ async removeQueueItemById(queueId: string): Promise<threadState.QueuedMessage | undefined> {
4155
+ let removed: threadState.QueuedMessage | undefined
4156
+ await this.dispatchAction(async () => {
4157
+ const persistResult = await deleteThreadQueueItem(queueId).catch((error) => {
4158
+ return new Error('Failed to delete persisted queue item', { cause: error })
4159
+ })
4160
+ if (persistResult instanceof Error) {
4161
+ logger.error(
4162
+ `[QUEUE] Failed to delete persisted queue item ${queueId}: ${persistResult.message}`,
4163
+ )
4164
+ return
4165
+ }
4166
+ removed = threadState.removeQueueItemById(this.threadId, queueId)
4167
+ if (!removed && persistResult) {
4168
+ const parsed = parseQueuedMessagePayload({
4169
+ queueId: persistResult.queue_id,
4170
+ payloadJson: persistResult.payload_json,
4171
+ })
4172
+ if (!(parsed instanceof Error)) {
4173
+ removed = parsed
4174
+ }
4175
+ }
4176
+ })
4177
+ return removed
3881
4178
  }
3882
4179
 
3883
4180
  /**
@@ -3886,33 +4183,150 @@ export class ThreadSessionRuntime {
3886
4183
  * Returns { found: true, removed } if the item was in the queue,
3887
4184
  * or { found: false } if it was already dispatched or never queued.
3888
4185
  */
3889
- updateQueuedMessage(
4186
+ async updateQueuedMessage(
3890
4187
  sourceMessageId: string,
3891
4188
  newPrompt: string,
3892
- ): { found: boolean; removed: boolean } {
3893
- const trimmed = newPrompt.trim()
3894
- const original = threadState.updateQueueItemBySourceMessageId(
3895
- this.threadId,
3896
- sourceMessageId,
3897
- (item) => {
3898
- if (!trimmed) return null
3899
- return { ...item, prompt: trimmed }
3900
- },
3901
- )
3902
- if (!original) return { found: false, removed: false }
3903
- if (!trimmed) return { found: true, removed: true }
3904
- return { found: true, removed: false }
4189
+ ): Promise<{ found: boolean; removed: boolean }> {
4190
+ let result: { found: boolean; removed: boolean } = { found: false, removed: false }
4191
+ await this.dispatchAction(async () => {
4192
+ const trimmed = newPrompt.trim()
4193
+ const original = this.state?.queueItems.find((item) => {
4194
+ return item.sourceMessageId === sourceMessageId
4195
+ })
4196
+ if (!original) {
4197
+ result = { found: false, removed: false }
4198
+ return
4199
+ }
4200
+ const queueId = original.queueId
4201
+ if (queueId) {
4202
+ const persistResult = trimmed
4203
+ ? await updateThreadQueueItemPayload({
4204
+ queueId,
4205
+ payloadJson: JSON.stringify({ ...original, prompt: trimmed }),
4206
+ }).catch((error) => {
4207
+ return new Error('Failed to update persisted queue item', { cause: error })
4208
+ })
4209
+ : await deleteThreadQueueItem(queueId).catch((error) => {
4210
+ return new Error('Failed to delete persisted queue item', { cause: error })
4211
+ })
4212
+ if (persistResult instanceof Error) {
4213
+ logger.error(
4214
+ `[QUEUE] Failed to persist queue update for ${queueId}: ${persistResult.message}`,
4215
+ )
4216
+ result = { found: true, removed: false }
4217
+ return
4218
+ }
4219
+ }
4220
+ threadState.updateQueueItemBySourceMessageId(
4221
+ this.threadId,
4222
+ sourceMessageId,
4223
+ (item) => {
4224
+ if (!trimmed) return null
4225
+ return { ...item, prompt: trimmed }
4226
+ },
4227
+ )
4228
+ result = trimmed
4229
+ ? { found: true, removed: false }
4230
+ : { found: true, removed: true }
4231
+ })
4232
+ return result
3905
4233
  }
3906
4234
 
3907
4235
  /** Remove a queued message identified by its Discord source message ID. */
3908
- removeQueuedMessage(
4236
+ async removeQueuedMessage(
3909
4237
  sourceMessageId: string,
3910
- ): threadState.QueuedMessage | undefined {
3911
- return threadState.updateQueueItemBySourceMessageId(
3912
- this.threadId,
3913
- sourceMessageId,
3914
- () => null,
3915
- )
4238
+ ): Promise<threadState.QueuedMessage | undefined> {
4239
+ let removed: threadState.QueuedMessage | undefined
4240
+ await this.dispatchAction(async () => {
4241
+ const current = this.state?.queueItems.find((item) => {
4242
+ return item.sourceMessageId === sourceMessageId
4243
+ })
4244
+ if (!current) {
4245
+ return
4246
+ }
4247
+ if (current.queueId) {
4248
+ const persistResult = await deleteThreadQueueItem(current.queueId).catch((error) => {
4249
+ return new Error('Failed to delete persisted queue item', { cause: error })
4250
+ })
4251
+ if (persistResult instanceof Error) {
4252
+ logger.error(
4253
+ `[QUEUE] Failed to delete persisted queue item ${current.queueId}: ${persistResult.message}`,
4254
+ )
4255
+ return
4256
+ }
4257
+ }
4258
+ removed = threadState.updateQueueItemBySourceMessageId(
4259
+ this.threadId,
4260
+ sourceMessageId,
4261
+ () => null,
4262
+ )
4263
+ })
4264
+ return removed
4265
+ }
4266
+
4267
+ async mergeRestoredQueueAndDrain(items: QueuedMessage[]): Promise<void> {
4268
+ const current = this.state?.queueItems ?? []
4269
+ const currentIds = new Set(current.flatMap((item) => item.queueId ? [item.queueId] : []))
4270
+ threadState.replaceQueueItems(this.threadId, [
4271
+ ...items.filter((item) => item.queueId && !currentIds.has(item.queueId)),
4272
+ ...current,
4273
+ ])
4274
+ const liveStatus = await this.hydrateLiveSessionStatus()
4275
+ if (liveStatus === 'unavailable') {
4276
+ return
4277
+ }
4278
+ await this.tryDrainQueue({ showIndicator: true })
4279
+ }
4280
+
4281
+ private async hydrateLiveSessionStatus(): Promise<'idle' | 'busy' | 'unavailable'> {
4282
+ const sessionId = this.state?.sessionId
4283
+ if (!sessionId) {
4284
+ return 'idle'
4285
+ }
4286
+ await this.hydrateSessionEventsFromDatabase({ sessionId })
4287
+ const getClient = await initializeOpencodeForDirectory(this.sdkDirectory)
4288
+ if (getClient instanceof Error) {
4289
+ logger.warn(
4290
+ `[QUEUE] OpenCode unavailable while restoring queue for ${this.threadId}: ${getClient.message}`,
4291
+ )
4292
+ return 'unavailable'
4293
+ }
4294
+ const statusResponse = await getClient().session.status({
4295
+ directory: this.sdkDirectory,
4296
+ }).catch((error) => {
4297
+ logger.warn(
4298
+ `[QUEUE] Failed to read session status while restoring queue for ${this.threadId}: ${error instanceof Error ? error.message : String(error)}`,
4299
+ )
4300
+ return undefined
4301
+ })
4302
+ if (!statusResponse || statusResponse.error) {
4303
+ return 'unavailable'
4304
+ }
4305
+ const sessionStatus = statusResponse.data?.[sessionId]
4306
+ if (!sessionStatus || sessionStatus.type === 'idle') {
4307
+ this.markQueueDispatchIdle(sessionId)
4308
+ return 'idle'
4309
+ }
4310
+ this.markQueueDispatchBusy(sessionId)
4311
+ return 'busy'
4312
+ }
4313
+
4314
+ private async acknowledgeAcceptedQueueItem(item: QueuedMessage): Promise<void> {
4315
+ if (!item.queueId) {
4316
+ return
4317
+ }
4318
+ await this.dispatchAction(async () => {
4319
+ const persistResult = await deleteThreadQueueItem(item.queueId!).catch((error) => {
4320
+ return new Error('Failed to delete persisted queue item', { cause: error })
4321
+ })
4322
+ if (persistResult instanceof Error) {
4323
+ logger.error(
4324
+ `[QUEUE] Failed to persist accept of ${item.queueId}: ${persistResult.message}`,
4325
+ )
4326
+ return
4327
+ }
4328
+ threadState.removeQueueItemById(this.threadId, item.queueId!)
4329
+ })
3916
4330
  }
3917
4331
 
3918
4332
  // ── Queue Drain ─────────────────────────────────────────────
@@ -3947,10 +4361,11 @@ export class ThreadSessionRuntime {
3947
4361
  return
3948
4362
  }
3949
4363
 
3950
- const next = threadState.dequeueItem(this.threadId)
4364
+ const next = thread.queueItems.find((item) => item.queueId !== this.dispatchingQueueId)
3951
4365
  if (!next) {
3952
4366
  return
3953
4367
  }
4368
+ this.dispatchingQueueId = next.queueId
3954
4369
 
3955
4370
  logger.log(
3956
4371
  `[QUEUE DRAIN] Processing queued message from ${next.username}`,
@@ -3979,13 +4394,23 @@ export class ThreadSessionRuntime {
3979
4394
  if (dispatchSessionId) {
3980
4395
  this.markQueueDispatchBusy(dispatchSessionId)
3981
4396
  }
3982
- void this.dispatchPrompt(next).catch(async (err) => {
4397
+ let accepted = false
4398
+ void this.dispatchPrompt(next).then(async (ok) => {
4399
+ accepted = ok
4400
+ if (ok) {
4401
+ await this.acknowledgeAcceptedQueueItem(next)
4402
+ }
4403
+ }).catch(async (err) => {
3983
4404
  logger.error('[DISPATCH] Prompt dispatch failed:', err)
3984
4405
  void notifyError(err, 'Runtime prompt dispatch failed')
3985
4406
  if (dispatchSessionId) {
3986
4407
  this.markQueueDispatchIdle(dispatchSessionId)
3987
4408
  }
3988
4409
  }).finally(() => {
4410
+ this.dispatchingQueueId = undefined
4411
+ if (!accepted) {
4412
+ return
4413
+ }
3989
4414
  void this.dispatchAction(() => {
3990
4415
  return this.tryDrainQueue({ showIndicator: true })
3991
4416
  })
@@ -3997,7 +4422,7 @@ export class ThreadSessionRuntime {
3997
4422
  // The listener is already running, so this only handles
3998
4423
  // session ensure + model/agent + SDK call + state.
3999
4424
 
4000
- private async dispatchPrompt(input: QueuedMessage): Promise<void> {
4425
+ private async dispatchPrompt(input: QueuedMessage): Promise<boolean> {
4001
4426
  this.lastDisplayedContextPercentage = 0
4002
4427
  this.lastRateLimitDisplayTime = 0
4003
4428
  this.lastSentPartKind = undefined
@@ -4020,8 +4445,7 @@ export class ThreadSessionRuntime {
4020
4445
  )
4021
4446
  // Show indicator: this dispatch failed, so the next queued message
4022
4447
  // has been waiting — the user needs to see which one is starting.
4023
- await this.tryDrainQueue({ showIndicator: true })
4024
- return
4448
+ return false
4025
4449
  }
4026
4450
  const { session, getClient, createdNewSession } = sessionResult
4027
4451
 
@@ -4038,8 +4462,7 @@ export class ThreadSessionRuntime {
4038
4462
  `Failed to update session permissions: ${updatePermissionsResult.message}`,
4039
4463
  { flags: NOTIFY_MESSAGE_FLAGS },
4040
4464
  )
4041
- await this.tryDrainQueue({ showIndicator: true })
4042
- return
4465
+ return false
4043
4466
  }
4044
4467
 
4045
4468
  // ── Resolve model + agent preferences ─────────────────────
@@ -4067,8 +4490,7 @@ export class ThreadSessionRuntime {
4067
4490
  `Failed to resolve model: ${validatedModel.message}`,
4068
4491
  { flags: NOTIFY_MESSAGE_FLAGS },
4069
4492
  )
4070
- await this.tryDrainQueue({ showIndicator: true })
4071
- return
4493
+ return false
4072
4494
  }
4073
4495
  }
4074
4496
 
@@ -4097,9 +4519,7 @@ export class ThreadSessionRuntime {
4097
4519
  `Failed to resolve agent: ${earlyAgentResult.message}`,
4098
4520
  { flags: NOTIFY_MESSAGE_FLAGS },
4099
4521
  )
4100
- // Show indicator: dispatch failed mid-setup, next queued message was waiting.
4101
- await this.tryDrainQueue({ showIndicator: true })
4102
- return
4522
+ return false
4103
4523
  }
4104
4524
  const earlyAgentPreference = earlyAgentResult.agentPreference
4105
4525
  const earlyAvailableAgents = earlyAgentResult.agents
@@ -4148,9 +4568,7 @@ export class ThreadSessionRuntime {
4148
4568
  `Failed to resolve model: ${earlyModelResult.message}`,
4149
4569
  { flags: NOTIFY_MESSAGE_FLAGS },
4150
4570
  )
4151
- // Show indicator: dispatch failed mid-setup, next queued message was waiting.
4152
- await this.tryDrainQueue({ showIndicator: true })
4153
- return
4571
+ return false
4154
4572
  }
4155
4573
  const earlyModelParam = earlyModelResult
4156
4574
  if (!earlyModelParam) {
@@ -4159,9 +4577,7 @@ export class ThreadSessionRuntime {
4159
4577
  this.thread,
4160
4578
  'No AI provider connected. Configure a provider in OpenCode with `/connect` command.',
4161
4579
  )
4162
- // Show indicator: dispatch failed, next queued message was waiting.
4163
- await this.tryDrainQueue({ showIndicator: true })
4164
- return
4580
+ return false
4165
4581
  }
4166
4582
 
4167
4583
  // Resolve thinking variant
@@ -4243,10 +4659,12 @@ export class ThreadSessionRuntime {
4243
4659
  })()
4244
4660
  const worktreeChanged = this.consumeWorktreePromptChange(worktree)
4245
4661
  const syntheticContext = getOpencodePromptContext({
4662
+ sessionId: session.id,
4663
+ threadId: this.thread.id,
4246
4664
  username: input.username,
4247
4665
  userId: input.userId,
4248
4666
  sourceMessageId: input.sourceMessageId,
4249
- sourceThreadId: input.sourceThreadId,
4667
+ sourceThreadId: input.sourceThreadId || this.thread.id,
4250
4668
  threadName: this.thread.name || undefined,
4251
4669
  repliedMessage: input.repliedMessage,
4252
4670
  worktree,
@@ -4294,10 +4712,12 @@ export class ThreadSessionRuntime {
4294
4712
  // Append <discord-user /> tag to arguments so external sync can
4295
4713
  // detect this message came from Discord (same tag as promptAsync).
4296
4714
  const discordTag = getOpencodePromptContext({
4715
+ sessionId: session.id,
4716
+ threadId: this.thread.id,
4297
4717
  username: input.username,
4298
4718
  userId: input.userId,
4299
4719
  sourceMessageId: input.sourceMessageId,
4300
- sourceThreadId: input.sourceThreadId,
4720
+ sourceThreadId: input.sourceThreadId || this.thread.id,
4301
4721
  threadName: this.thread.name || undefined,
4302
4722
  repliedMessage: input.repliedMessage,
4303
4723
  })
@@ -4341,10 +4761,7 @@ export class ThreadSessionRuntime {
4341
4761
  `✗ Failed to prepare command system prompt: ${systemWriteResult.message}`,
4342
4762
  { flags: NOTIFY_MESSAGE_FLAGS },
4343
4763
  )
4344
- await this.dispatchAction(() => {
4345
- return this.tryDrainQueue({ showIndicator: true })
4346
- })
4347
- return
4764
+ return false
4348
4765
  }
4349
4766
  const commandResponse = await getClient().session.command(
4350
4767
  {
@@ -4376,10 +4793,7 @@ export class ThreadSessionRuntime {
4376
4793
  '✗ Command timed out after 30 seconds. Try a shorter command or run it with /run-shell-command.',
4377
4794
  { flags: NOTIFY_MESSAGE_FLAGS },
4378
4795
  )
4379
- await this.dispatchAction(() => {
4380
- return this.tryDrainQueue({ showIndicator: true })
4381
- })
4382
- return
4796
+ return false
4383
4797
  }
4384
4798
 
4385
4799
  const commandErrorForAbortCheck: unknown = commandResponse
@@ -4388,7 +4802,7 @@ export class ThreadSessionRuntime {
4388
4802
  `[DISPATCH] Command aborted (expected) sessionId=${session.id}`,
4389
4803
  )
4390
4804
  this.stopTyping()
4391
- return
4805
+ return true
4392
4806
  }
4393
4807
 
4394
4808
  logger.error(
@@ -4401,10 +4815,7 @@ export class ThreadSessionRuntime {
4401
4815
  `✗ Unexpected bot Error: ${commandResponse.message}`,
4402
4816
  { flags: NOTIFY_MESSAGE_FLAGS },
4403
4817
  )
4404
- await this.dispatchAction(() => {
4405
- return this.tryDrainQueue({ showIndicator: true })
4406
- })
4407
- return
4818
+ return false
4408
4819
  }
4409
4820
 
4410
4821
  if (commandResponse.error) {
@@ -4414,7 +4825,7 @@ export class ThreadSessionRuntime {
4414
4825
  `[DISPATCH] Command aborted (expected) sessionId=${session.id}`,
4415
4826
  )
4416
4827
  this.stopTyping()
4417
- return
4828
+ return true
4418
4829
  }
4419
4830
  const apiError = new Error(`OpenCode API error: ${errorMessage}`)
4420
4831
  logger.error(`[DISPATCH] ${apiError.message}`)
@@ -4423,10 +4834,7 @@ export class ThreadSessionRuntime {
4423
4834
  await sendThreadMessage(this.thread, `✗ ${apiError.message}`, {
4424
4835
  flags: NOTIFY_MESSAGE_FLAGS,
4425
4836
  })
4426
- await this.dispatchAction(() => {
4427
- return this.tryDrainQueue({ showIndicator: true })
4428
- })
4429
- return
4837
+ return false
4430
4838
  }
4431
4839
 
4432
4840
  logger.log(`[DISPATCH] Successfully ran command for session ${session.id}`)
@@ -4436,7 +4844,7 @@ export class ThreadSessionRuntime {
4436
4844
  source: resolveTurnSource(input),
4437
4845
  agent: earlyAgentPreference,
4438
4846
  })
4439
- return
4847
+ return true
4440
4848
  }
4441
4849
 
4442
4850
  await waitForGlobalEventListener()
@@ -4475,10 +4883,7 @@ export class ThreadSessionRuntime {
4475
4883
  await sendThreadMessage(this.thread, `✗ OpenCode API error: ${errorMessage}`, {
4476
4884
  flags: NOTIFY_MESSAGE_FLAGS,
4477
4885
  })
4478
- await this.dispatchAction(() => {
4479
- return this.tryDrainQueue({ showIndicator: true })
4480
- })
4481
- return
4886
+ return false
4482
4887
  }
4483
4888
 
4484
4889
  logger.log(
@@ -4490,6 +4895,7 @@ export class ThreadSessionRuntime {
4490
4895
  source: resolveTurnSource(input),
4491
4896
  agent: earlyAgentPreference,
4492
4897
  })
4898
+ return true
4493
4899
  }
4494
4900
 
4495
4901
  // ── Session Ensure ──────────────────────────────────────────
@@ -4768,7 +5174,7 @@ export class ThreadSessionRuntime {
4768
5174
  : ''
4769
5175
  const result = await sendThreadMessage(
4770
5176
  this.thread,
4771
- asDiscordQuote(`*using ${modelLabel}${agentLabel}*`),
5177
+ asSystemLine(`*using ${modelLabel}${agentLabel}*`),
4772
5178
  { flags: SILENT_MESSAGE_FLAGS },
4773
5179
  ).catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
4774
5180
  if (result instanceof Error) {
@@ -4921,7 +5327,7 @@ export class ThreadSessionRuntime {
4921
5327
  })
4922
5328
  : undefined
4923
5329
  const mention = mentionUserId ? ` <@${mentionUserId}>` : ''
4924
- const footerText = asDiscordQuote(
5330
+ const footerText = asSystemLine(
4925
5331
  `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*${mention}`,
4926
5332
  )
4927
5333
  this.stopTyping()
@@ -4943,6 +5349,77 @@ export class ThreadSessionRuntime {
4943
5349
  this.lastSentPartKind = undefined
4944
5350
  }
4945
5351
 
5352
+ private async maybeNotifyPromptCacheClear({
5353
+ sessionId,
5354
+ messageId,
5355
+ }: {
5356
+ sessionId: string
5357
+ messageId: string
5358
+ }): Promise<void> {
5359
+ const cacheClear = getPromptCacheClear({
5360
+ events: this.eventBuffer,
5361
+ sessionId,
5362
+ currentMessageId: messageId,
5363
+ })
5364
+ if (!cacheClear) {
5365
+ return
5366
+ }
5367
+ const systemDiff = await this.getSystemPromptDiffForCacheClear({
5368
+ sessionId,
5369
+ previousMessageId: cacheClear.previousMessageId,
5370
+ currentMessageId: cacheClear.currentMessageId,
5371
+ })
5372
+ const chunk = `${STATUS_PREFIX}${formatPromptCacheClearMessage(cacheClear, systemDiff)}`
5373
+ const sendResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
5374
+ .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
5375
+ if (sendResult instanceof Error) {
5376
+ discordLogger.error('Failed to send prompt cache notice:', sendResult)
5377
+ }
5378
+ }
5379
+
5380
+ private async getSystemPromptDiffForCacheClear({
5381
+ sessionId,
5382
+ previousMessageId,
5383
+ currentMessageId,
5384
+ }: {
5385
+ sessionId: string
5386
+ previousMessageId: string
5387
+ currentMessageId: string
5388
+ }): Promise<{ additions: number; deletions: number } | undefined> {
5389
+ const previousParentId = this.getAssistantParentId({ sessionId, messageId: previousMessageId })
5390
+ const currentParentId = this.getAssistantParentId({ sessionId, messageId: currentMessageId })
5391
+ if (!previousParentId || !currentParentId) {
5392
+ return undefined
5393
+ }
5394
+ const beforeText = this.userSystemByMessageId.get(previousParentId)
5395
+ const afterText = this.userSystemByMessageId.get(currentParentId)
5396
+ if (beforeText === undefined || afterText === undefined || beforeText === afterText) {
5397
+ return undefined
5398
+ }
5399
+ return countSystemPromptDiffLines({ beforeText, afterText })
5400
+ }
5401
+
5402
+ private getAssistantParentId({
5403
+ sessionId,
5404
+ messageId,
5405
+ }: {
5406
+ sessionId: string
5407
+ messageId: string
5408
+ }): string | undefined {
5409
+ for (let i = this.eventBuffer.length - 1; i >= 0; i--) {
5410
+ const event = this.eventBuffer[i]?.event
5411
+ if (event?.type !== 'message.updated') {
5412
+ continue
5413
+ }
5414
+ const info = event.properties.info
5415
+ if (info.sessionID !== sessionId || info.role !== 'assistant' || info.id !== messageId) {
5416
+ continue
5417
+ }
5418
+ return info.parentID
5419
+ }
5420
+ return undefined
5421
+ }
5422
+
4946
5423
  // ── Retry Last User Prompt (for model-change flow) ──────────
4947
5424
 
4948
5425
  /**
@@ -4965,7 +5442,7 @@ export class ThreadSessionRuntime {
4965
5442
  let needsIdleWait = false
4966
5443
  const waitSinceTimestamp = Date.now()
4967
5444
  const abortResult = await this.dispatchAction(async () => {
4968
- needsIdleWait = this.isMainSessionBusy()
5445
+ needsIdleWait = this.isBusy()
4969
5446
  const outcome = this.abortActiveRunInternal({
4970
5447
  reason: 'model-change',
4971
5448
  })
@@ -5026,6 +5503,31 @@ export class ThreadSessionRuntime {
5026
5503
 
5027
5504
  return true
5028
5505
  }
5506
+
5507
+ /**
5508
+ * Resume an idle session by sending `text` as a new user turn.
5509
+ *
5510
+ * Used when a question is answered after its run was aborted elsewhere:
5511
+ * the original run is dead, so question.reply is a no-op. We instead feed
5512
+ * the answer back as a fresh prompt so opencode continues from history.
5513
+ */
5514
+ async resumeWithText({ text }: { text: string }): Promise<boolean> {
5515
+ const sessionId = this.state?.sessionId
5516
+ if (!sessionId || this.disposed) {
5517
+ logger.log(`[RESUME] No session for thread ${this.threadId}`)
5518
+ return false
5519
+ }
5520
+ await this.enqueueIncoming({
5521
+ prompt: text,
5522
+ userId: '',
5523
+ username: '',
5524
+ appId: this.appId,
5525
+ mode: 'opencode',
5526
+ resetAssistantForNewRun: true,
5527
+ expectedSessionId: sessionId,
5528
+ })
5529
+ return true
5530
+ }
5029
5531
  }
5030
5532
 
5031
5533
  // ── Module-level helpers ──────────────────────────────────────────