kimaki 0.26.0 → 0.28.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 (302) hide show
  1. package/dist/agent-model.e2e.test.js +250 -164
  2. package/dist/anthropic-account-identity.js +9 -1
  3. package/dist/anthropic-auth-plugin.js +10 -13
  4. package/dist/anthropic-auth-plugin.test.js +17 -0
  5. package/dist/anthropic-auth-state.js +1 -0
  6. package/dist/bash-tool-schema-plugin.js +32 -0
  7. package/dist/bash-tool-schema-plugin.test.js +49 -0
  8. package/dist/bin.js +73 -19
  9. package/dist/cli-commands/maintenance.js +46 -7
  10. package/dist/cli-commands/project.js +11 -27
  11. package/dist/cli-commands/send.js +30 -1
  12. package/dist/cli-commands/session.js +322 -90
  13. package/dist/cli-commands/task.js +34 -15
  14. package/dist/cli-parsing.test.js +83 -4
  15. package/dist/cli-runner.js +44 -8
  16. package/dist/cli-runner.test.js +27 -0
  17. package/dist/cli-send-thread.e2e.test.js +9 -9
  18. package/dist/cli.js +32 -0
  19. package/dist/commands/abort.js +5 -1
  20. package/dist/commands/action-buttons.js +2 -1
  21. package/dist/commands/add-project.js +4 -7
  22. package/dist/commands/agent.js +164 -32
  23. package/dist/commands/ask-question.js +23 -33
  24. package/dist/commands/ask-question.test.js +43 -1
  25. package/dist/commands/btw.js +28 -6
  26. package/dist/commands/file-upload.js +2 -1
  27. package/dist/commands/fork-subagent.js +2 -4
  28. package/dist/commands/fork.js +4 -4
  29. package/dist/commands/last-sessions.js +17 -10
  30. package/dist/commands/login.js +6 -0
  31. package/dist/commands/merge-worktree.js +47 -14
  32. package/dist/commands/model-variant.js +8 -1
  33. package/dist/commands/model.js +16 -9
  34. package/dist/commands/multioauth.js +1 -0
  35. package/dist/commands/new-worktree.js +170 -102
  36. package/dist/commands/queue.js +3 -2
  37. package/dist/commands/resume.js +4 -4
  38. package/dist/commands/session.js +78 -63
  39. package/dist/commands/user-command.js +6 -8
  40. package/dist/commands/worktrees.js +5 -13
  41. package/dist/config.js +16 -1
  42. package/dist/cpu-profiler.js +211 -0
  43. package/dist/cpu-profiler.test.js +160 -0
  44. package/dist/database.js +152 -3
  45. package/dist/db.js +33 -0
  46. package/dist/db.test.js +267 -1
  47. package/dist/default-channel-provisioning.test.js +148 -0
  48. package/dist/discord-bot.js +517 -488
  49. package/dist/discord-command-registration.js +25 -10
  50. package/dist/discord-command-registration.test.js +17 -0
  51. package/dist/discord-utils.js +97 -5
  52. package/dist/discord-utils.test.js +151 -1
  53. package/dist/errors.js +10 -0
  54. package/dist/exec-async.js +17 -5
  55. package/dist/external-directory-permissions.e2e.test.js +11 -7
  56. package/dist/external-directory-project-deny.e2e.test.js +5 -3
  57. package/dist/external-opencode-sync.js +94 -17
  58. package/dist/external-opencode-sync.test.js +206 -0
  59. package/dist/extract-sdk-error-message.test.js +13 -0
  60. package/dist/file-edit-log.js +197 -0
  61. package/dist/file-edit-log.test.js +258 -0
  62. package/dist/format-tables.js +52 -0
  63. package/dist/format-tables.test.js +93 -0
  64. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  65. package/dist/gateway-proxy.e2e.test.js +18 -18
  66. package/dist/git-worktree-core.js +182 -17
  67. package/dist/interaction-handler.js +416 -398
  68. package/dist/ipc-tools-plugin.js +68 -3
  69. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  70. package/dist/kimaki-opencode-plugin.js +9 -3
  71. package/dist/kimaki-workspace-adaptor.js +33 -10
  72. package/dist/logger.js +11 -1
  73. package/dist/markdown.js +3 -1
  74. package/dist/markdown.test.js +11 -3
  75. package/dist/message-formatting.js +181 -51
  76. package/dist/message-formatting.test.js +529 -22
  77. package/dist/message-preprocessing.js +105 -1
  78. package/dist/oauth-rotation-shared.js +10 -0
  79. package/dist/openai-auth-plugin.js +1 -0
  80. package/dist/openai-auth-state.js +1 -0
  81. package/dist/opencode-command.js +28 -0
  82. package/dist/opencode-command.test.js +109 -1
  83. package/dist/opencode-interrupt-plugin.js +4 -0
  84. package/dist/opencode-interrupt-plugin.test.js +24 -0
  85. package/dist/opencode.js +82 -15
  86. package/dist/project-add.test.js +62 -0
  87. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  88. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
  89. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  90. package/dist/queue-advanced-abort.e2e.test.js +8 -10
  91. package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
  92. package/dist/queue-advanced-e2e-setup.js +78 -5
  93. package/dist/queue-advanced-footer.e2e.test.js +60 -269
  94. package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
  95. package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
  96. package/dist/queue-advanced-question.e2e.test.js +32 -27
  97. package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
  98. package/dist/queue-advanced-typing.e2e.test.js +7 -9
  99. package/dist/queue-delete-message.e2e.test.js +3 -3
  100. package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
  101. package/dist/queue-interrupt-drain.e2e.test.js +122 -12
  102. package/dist/queue-question-select-drain.e2e.test.js +34 -132
  103. package/dist/runtime-lifecycle.e2e.test.js +21 -51
  104. package/dist/schema.js +35 -1
  105. package/dist/session-handler/event-stream-state.js +391 -50
  106. package/dist/session-handler/event-stream-state.test.js +736 -8
  107. package/dist/session-handler/model-utils.js +172 -5
  108. package/dist/session-handler/model-utils.test.js +445 -0
  109. package/dist/session-handler/opencode-session-event-log.js +30 -1
  110. package/dist/session-handler/thread-ingress.test.js +41 -0
  111. package/dist/session-handler/thread-runtime-state.js +2 -0
  112. package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
  113. package/dist/session-handler/thread-session-runtime.js +574 -299
  114. package/dist/session-search.js +123 -4
  115. package/dist/session-search.test.js +248 -1
  116. package/dist/session-sleep.test.js +224 -0
  117. package/dist/session-title-rename.test.js +37 -39
  118. package/dist/sleep.e2e.test.js +494 -0
  119. package/dist/startup-time.e2e.test.js +2 -1
  120. package/dist/store.js +3 -0
  121. package/dist/system-message.js +102 -16
  122. package/dist/system-message.test.js +192 -15
  123. package/dist/task-runner.js +96 -5
  124. package/dist/task-schedule.js +87 -1
  125. package/dist/task-schedule.test.js +149 -1
  126. package/dist/test-utils.js +17 -9
  127. package/dist/thinking-utils.js +10 -0
  128. package/dist/thinking-utils.test.js +35 -0
  129. package/dist/thread-message-queue.e2e.test.js +65 -213
  130. package/dist/undo-redo.e2e.test.js +17 -9
  131. package/dist/voice-attachment.js +23 -1
  132. package/dist/voice-handler.js +21 -4
  133. package/dist/voice-message.e2e.test.js +144 -30
  134. package/dist/voice-routing-input.e2e.test.js +195 -0
  135. package/dist/voice-routing-question.e2e.test.js +125 -0
  136. package/dist/voice.js +28 -8
  137. package/dist/voice.test.js +96 -30
  138. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  139. package/dist/worktree-lifecycle.e2e.test.js +55 -36
  140. package/dist/worktrees.js +226 -443
  141. package/dist/worktrees.test.js +435 -58
  142. package/dist/xai-auth-plugin.js +1 -0
  143. package/dist/xai-auth-state.js +1 -0
  144. package/package.json +8 -6
  145. package/skills/bump-holocron-consumers/SKILL.md +137 -0
  146. package/skills/critique/SKILL.md +4 -4
  147. package/skills/errore/SKILL.md +60 -218
  148. package/skills/goke/SKILL.md +150 -10
  149. package/skills/holocron/SKILL.md +154 -17
  150. package/skills/npm-package/SKILL.md +55 -35
  151. package/skills/profano/SKILL.md +17 -2
  152. package/skills/spiceflow/SKILL.md +39 -3
  153. package/skills/strada/SKILL.md +39 -0
  154. package/skills/zele/SKILL.md +19 -0
  155. package/src/agent-model.e2e.test.ts +287 -186
  156. package/src/anthropic-account-identity.ts +20 -1
  157. package/src/anthropic-auth-plugin.test.ts +19 -0
  158. package/src/anthropic-auth-plugin.ts +12 -16
  159. package/src/anthropic-auth-state.ts +1 -0
  160. package/src/bash-tool-schema-plugin.test.ts +55 -0
  161. package/src/bash-tool-schema-plugin.ts +46 -0
  162. package/src/bin.ts +78 -26
  163. package/src/cli-commands/maintenance.ts +59 -13
  164. package/src/cli-commands/project.ts +14 -35
  165. package/src/cli-commands/send.ts +31 -1
  166. package/src/cli-commands/session.ts +374 -114
  167. package/src/cli-commands/task.ts +34 -15
  168. package/src/cli-parsing.test.ts +103 -4
  169. package/src/cli-runner.test.ts +49 -0
  170. package/src/cli-runner.ts +44 -7
  171. package/src/cli-send-thread.e2e.test.ts +9 -11
  172. package/src/cli.ts +53 -0
  173. package/src/commands/abort.ts +6 -1
  174. package/src/commands/action-buttons.ts +2 -1
  175. package/src/commands/add-project.ts +4 -8
  176. package/src/commands/agent.ts +205 -29
  177. package/src/commands/ask-question.test.ts +48 -1
  178. package/src/commands/ask-question.ts +23 -33
  179. package/src/commands/btw.ts +40 -5
  180. package/src/commands/file-upload.ts +2 -1
  181. package/src/commands/fork-subagent.ts +2 -3
  182. package/src/commands/fork.ts +4 -3
  183. package/src/commands/last-sessions.ts +10 -4
  184. package/src/commands/login.ts +6 -0
  185. package/src/commands/merge-worktree.ts +54 -16
  186. package/src/commands/model-variant.ts +9 -1
  187. package/src/commands/model.ts +20 -9
  188. package/src/commands/multioauth.ts +1 -0
  189. package/src/commands/new-worktree.ts +138 -55
  190. package/src/commands/queue.ts +3 -2
  191. package/src/commands/resume.ts +4 -3
  192. package/src/commands/session.ts +94 -70
  193. package/src/commands/user-command.ts +6 -6
  194. package/src/commands/worktrees.ts +5 -12
  195. package/src/config.ts +18 -1
  196. package/src/cpu-profiler.test.ts +183 -0
  197. package/src/cpu-profiler.ts +293 -0
  198. package/src/database.ts +235 -3
  199. package/src/db.test.ts +293 -0
  200. package/src/db.ts +38 -0
  201. package/src/default-channel-provisioning.test.ts +155 -0
  202. package/src/discord-bot.ts +78 -36
  203. package/src/discord-command-registration.test.ts +20 -0
  204. package/src/discord-command-registration.ts +42 -12
  205. package/src/discord-utils.test.ts +176 -0
  206. package/src/discord-utils.ts +152 -7
  207. package/src/errors.ts +12 -0
  208. package/src/exec-async.ts +20 -11
  209. package/src/external-directory-permissions.e2e.test.ts +11 -7
  210. package/src/external-directory-project-deny.e2e.test.ts +5 -3
  211. package/src/external-opencode-sync.test.ts +248 -0
  212. package/src/external-opencode-sync.ts +122 -19
  213. package/src/extract-sdk-error-message.test.ts +18 -0
  214. package/src/file-edit-log.test.ts +287 -0
  215. package/src/file-edit-log.ts +283 -0
  216. package/src/format-tables.test.ts +101 -0
  217. package/src/format-tables.ts +75 -0
  218. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  219. package/src/gateway-proxy.e2e.test.ts +18 -17
  220. package/src/git-worktree-core.ts +247 -20
  221. package/src/interaction-handler.ts +36 -13
  222. package/src/ipc-tools-plugin.ts +82 -5
  223. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  224. package/src/kimaki-opencode-plugin.ts +9 -3
  225. package/src/kimaki-workspace-adaptor.ts +40 -11
  226. package/src/logger.ts +14 -4
  227. package/src/markdown.test.ts +13 -3
  228. package/src/markdown.ts +4 -1
  229. package/src/message-formatting.test.ts +672 -25
  230. package/src/message-formatting.ts +242 -53
  231. package/src/message-preprocessing.ts +116 -1
  232. package/src/oauth-rotation-shared.ts +10 -0
  233. package/src/openai-auth-plugin.ts +1 -0
  234. package/src/openai-auth-state.ts +1 -0
  235. package/src/opencode-command.test.ts +140 -0
  236. package/src/opencode-command.ts +38 -0
  237. package/src/opencode-interrupt-plugin.test.ts +32 -0
  238. package/src/opencode-interrupt-plugin.ts +3 -0
  239. package/src/opencode.ts +115 -15
  240. package/src/project-add.test.ts +80 -0
  241. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  242. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
  243. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  244. package/src/queue-advanced-abort.e2e.test.ts +8 -9
  245. package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
  246. package/src/queue-advanced-e2e-setup.ts +83 -4
  247. package/src/queue-advanced-footer.e2e.test.ts +67 -315
  248. package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
  249. package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
  250. package/src/queue-advanced-question.e2e.test.ts +36 -30
  251. package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
  252. package/src/queue-advanced-typing.e2e.test.ts +8 -8
  253. package/src/queue-delete-message.e2e.test.ts +3 -3
  254. package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
  255. package/src/queue-interrupt-drain.e2e.test.ts +133 -12
  256. package/src/queue-question-select-drain.e2e.test.ts +36 -161
  257. package/src/runtime-lifecycle.e2e.test.ts +21 -54
  258. package/src/schema.sql +15 -2
  259. package/src/schema.ts +37 -1
  260. package/src/session-handler/event-stream-state.test.ts +790 -7
  261. package/src/session-handler/event-stream-state.ts +568 -53
  262. package/src/session-handler/model-utils.test.ts +537 -0
  263. package/src/session-handler/model-utils.ts +281 -5
  264. package/src/session-handler/opencode-session-event-log.ts +31 -1
  265. package/src/session-handler/thread-ingress.test.ts +54 -0
  266. package/src/session-handler/thread-runtime-state.ts +3 -0
  267. package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
  268. package/src/session-handler/thread-session-runtime.ts +712 -358
  269. package/src/session-search.test.ts +281 -0
  270. package/src/session-search.ts +241 -7
  271. package/src/session-sleep.test.ts +294 -0
  272. package/src/session-title-rename.test.ts +54 -48
  273. package/src/sleep.e2e.test.ts +565 -0
  274. package/src/startup-time.e2e.test.ts +5 -1
  275. package/src/store.ts +22 -0
  276. package/src/system-message.test.ts +226 -15
  277. package/src/system-message.ts +130 -16
  278. package/src/task-runner.ts +138 -3
  279. package/src/task-schedule.test.ts +170 -0
  280. package/src/task-schedule.ts +141 -1
  281. package/src/test-utils.ts +21 -10
  282. package/src/thinking-utils.test.ts +46 -0
  283. package/src/thinking-utils.ts +21 -0
  284. package/src/thread-message-queue.e2e.test.ts +65 -245
  285. package/src/undo-redo.e2e.test.ts +17 -9
  286. package/src/voice-attachment.ts +29 -1
  287. package/src/voice-handler.ts +23 -4
  288. package/src/voice-message.e2e.test.ts +149 -29
  289. package/src/voice-routing-input.e2e.test.ts +203 -0
  290. package/src/voice-routing-question.e2e.test.ts +131 -0
  291. package/src/voice.test.ts +107 -29
  292. package/src/voice.ts +34 -6
  293. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  294. package/src/worktree-lifecycle.e2e.test.ts +55 -35
  295. package/src/worktrees.test.ts +505 -64
  296. package/src/worktrees.ts +257 -603
  297. package/src/xai-auth-plugin.ts +1 -0
  298. package/src/xai-auth-state.ts +1 -0
  299. package/dist/message-finish-field.e2e.test.js +0 -169
  300. package/dist/subagent-rate-limit-plugin.js +0 -184
  301. package/src/message-finish-field.e2e.test.ts +0 -196
  302. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -7,6 +7,7 @@
7
7
  // run internals.
8
8
 
9
9
  import crypto from 'node:crypto'
10
+ import { AsyncLocalStorage } from 'node:async_hooks'
10
11
  import { ChannelType, type ThreadChannel } from 'discord.js'
11
12
  import type {
12
13
  Event as OpenCodeEvent,
@@ -38,11 +39,27 @@ import {
38
39
  import { createLogger, LogPrefix } from '../logger.js'
39
40
  import {
40
41
  sendThreadMessage,
42
+ sendSessionPartMessage,
41
43
  SILENT_MESSAGE_FLAGS,
42
44
  NOTIFY_MESSAGE_FLAGS,
45
+ raceDiscordRename,
46
+ DISCORD_THREAD_RENAME_TIMEOUT_MS,
47
+ resolveThreadFooterMentionUserId,
43
48
  } from '../discord-utils.js'
44
- import type { DiscordFileAttachment } from '../message-formatting.js'
45
- import { formatPart, formatTaskToolTitle } from '../message-formatting.js'
49
+ import type { DiscordFileAttachment, SessionPartKind } from '../message-formatting.js'
50
+ import {
51
+ asDiscordQuote,
52
+ formatPart,
53
+ formatTaskToolTitle,
54
+ planAssistantTurnFlush,
55
+ QUEUE_PREFIX,
56
+ sessionPartKind,
57
+ shouldLeadWithBlankLine,
58
+ shouldQuoteIntermediateTextPart,
59
+ STATUS_PREFIX,
60
+ WORKTREE_PREFIX,
61
+ type AssistantTurnFlushMode,
62
+ } from '../message-formatting.js'
46
63
  import {
47
64
  getChannelVerbosity,
48
65
  getPartMessageIds,
@@ -54,14 +71,18 @@ import {
54
71
  setThreadParentSessionId,
55
72
  getThreadWorktreeOrWorkspace,
56
73
  setSessionAgent,
74
+ setSessionModel,
57
75
  clearSessionModel,
58
76
  getVariantCascade,
59
77
  setSessionStartSource,
78
+ getSessionStartSource,
79
+ getScheduledTask,
60
80
  completeScheduledTaskRunsForSession,
61
81
  failScheduledTaskRunsForSession,
62
82
  startScheduledTaskRunSession,
63
83
  appendSessionEventsSinceLastTimestamp,
64
84
  getSessionEventSnapshot,
85
+ cancelSessionSleepForThread,
65
86
  } from '../database.js'
66
87
  import * as orm from 'drizzle-orm'
67
88
  import * as schema from '../schema.js'
@@ -75,6 +96,7 @@ import {
75
96
  showAskUserQuestionDropdowns,
76
97
  pendingQuestionContexts,
77
98
  cancelPendingQuestion,
99
+ findPendingQuestionContextForRequest,
78
100
  } from '../commands/ask-question.js'
79
101
  import {
80
102
  showActionButtons,
@@ -90,6 +112,11 @@ import {
90
112
  getCurrentModelInfo,
91
113
  ensureSessionPreferencesSnapshot,
92
114
  } from '../commands/model.js'
115
+ import {
116
+ displayedModelLabel,
117
+ resolveDisplayedModelName,
118
+ validateModelId,
119
+ } from './model-utils.js'
93
120
  import {
94
121
  getOpencodePromptContext,
95
122
  getOpencodeSystemMessage,
@@ -97,8 +124,10 @@ import {
97
124
  type AgentInfo,
98
125
  type RepliedMessageContext,
99
126
  type WorktreeInfo,
127
+ type ScheduledTaskSystemContext,
100
128
  } from '../system-message.js'
101
129
  import { getDataDir } from '../config.js'
130
+ import { store } from '../store.js'
102
131
  import {
103
132
  trackEvent,
104
133
  type AnalyticsIngressMode,
@@ -114,7 +143,9 @@ import {
114
143
  } from './opencode-session-event-log.js'
115
144
  import {
116
145
  doesLatestUserTurnHaveNaturalCompletion,
146
+ didLatestUserTurnUseSleepTool,
117
147
  didQuestionQueueHandoffSinceLatestQuestionAsked,
148
+ deriveLatestUnansweredQuestion,
118
149
  getAssistantMessageIdsForLatestUserTurn,
119
150
  getCurrentTurnStartTime,
120
151
  isSessionBusy,
@@ -122,10 +153,14 @@ import {
122
153
  getIdleTokenUsageDelta,
123
154
  getDerivedSubtaskIndex,
124
155
  getDerivedSubtaskAgentType,
156
+ getTokenUsageSessionIdsForIdle,
157
+ isDerivedChildSession,
125
158
  getLatestAssistantMessageIdForLatestUserTurn,
159
+ getAssistantMessageKind,
126
160
  hasAssistantMessageCompletedBefore,
127
161
  isAssistantMessageInLatestUserTurn,
128
162
  isAssistantMessageNaturalCompletion,
163
+ shouldBufferSessionEvent,
129
164
  type EventBufferEvent,
130
165
  type EventBufferEntry,
131
166
  } from './event-stream-state.js'
@@ -188,6 +223,63 @@ const shouldLogSessionEvents =
188
223
 
189
224
  const runtimes = new Map<string, ThreadSessionRuntime>()
190
225
 
226
+ // Per-thread FIFO for Discord arrival order of one-shot slash calls vs messages.
227
+ // Covers /plan-agent (no prompt) and /foo-cmd /foo-skill. OpenCode already
228
+ // queues promptAsync. /model, /agent, and /compact are not on this queue.
229
+ const threadIngressChains = new Map<string, Promise<void>>()
230
+ const threadIngressSlotAls = new AsyncLocalStorage<ThreadIngressSlot | undefined>()
231
+
232
+ export type ThreadIngressSlot = {
233
+ wait: Promise<void>
234
+ release: () => void
235
+ }
236
+
237
+ export function reserveThreadIngress(threadId: string): ThreadIngressSlot {
238
+ const previous = threadIngressChains.get(threadId) ?? Promise.resolve()
239
+ let released = false
240
+ let releaseHeld = () => {}
241
+ const held = new Promise<void>((resolve) => {
242
+ releaseHeld = resolve
243
+ })
244
+ threadIngressChains.set(
245
+ threadId,
246
+ previous.then(() => held),
247
+ )
248
+ return {
249
+ wait: previous,
250
+ release: () => {
251
+ if (released) {
252
+ return
253
+ }
254
+ released = true
255
+ releaseHeld()
256
+ },
257
+ }
258
+ }
259
+
260
+ export async function runInThreadIngressSlot<T>(
261
+ slot: ThreadIngressSlot | undefined,
262
+ run: () => Promise<T>,
263
+ ): Promise<T> {
264
+ try {
265
+ return await threadIngressSlotAls.run(slot, run)
266
+ } finally {
267
+ slot?.release()
268
+ }
269
+ }
270
+
271
+ export async function waitForCurrentThreadIngress(): Promise<void> {
272
+ const slot = threadIngressSlotAls.getStore()
273
+ if (!slot) {
274
+ return
275
+ }
276
+ await slot.wait
277
+ }
278
+
279
+ export function releaseCurrentThreadIngress(): void {
280
+ threadIngressSlotAls.getStore()?.release()
281
+ }
282
+
191
283
  export function getRuntime(
192
284
  threadId: string,
193
285
  ): ThreadSessionRuntime | undefined {
@@ -201,6 +293,7 @@ export type RuntimeOptions = {
201
293
  sdkDirectory: string
202
294
  channelId?: string
203
295
  appId?: string
296
+ sessionId?: string
204
297
  }
205
298
 
206
299
  export function getOrCreateRuntime(
@@ -213,9 +306,15 @@ export function getOrCreateRuntime(
213
306
  `[RUNTIME] Ignoring sdkDirectory change for existing thread ${opts.threadId}: ${existing.sdkDirectory} → ${opts.sdkDirectory}`,
214
307
  )
215
308
  }
309
+ if (opts.sessionId && !existing.state?.sessionId) {
310
+ threadState.setSessionId(opts.threadId, opts.sessionId)
311
+ }
216
312
  return existing
217
313
  }
218
314
  threadState.ensureThread(opts.threadId) // add to global store
315
+ if (opts.sessionId) {
316
+ threadState.setSessionId(opts.threadId, opts.sessionId)
317
+ }
219
318
  const runtime = new ThreadSessionRuntime(opts)
220
319
  runtimes.set(opts.threadId, runtime)
221
320
  return runtime
@@ -229,6 +328,7 @@ export function disposeRuntime(threadId: string): void {
229
328
  runtime.dispose()
230
329
  runtimes.delete(threadId)
231
330
  threadState.removeThread(threadId) // remove from global store
331
+ threadIngressChains.delete(threadId)
232
332
  }
233
333
 
234
334
  export function disposeRuntimesForDirectory({
@@ -249,6 +349,7 @@ export function disposeRuntimesForDirectory({
249
349
  runtime.dispose()
250
350
  runtimes.delete(threadId)
251
351
  threadState.removeThread(threadId)
352
+ threadIngressChains.delete(threadId)
252
353
  count++
253
354
  }
254
355
  return count
@@ -279,6 +380,7 @@ export function disposeInactiveRuntimes({
279
380
  runtime.dispose()
280
381
  runtimes.delete(threadId)
281
382
  threadState.removeThread(threadId)
383
+ threadIngressChains.delete(threadId)
282
384
  disposedThreadIds.push(threadId)
283
385
  disposedDirectories.add(runtime.projectDirectory)
284
386
  }
@@ -419,16 +521,20 @@ export function isEssentialToolPart(part: Part): boolean {
419
521
  // ── Thread title derivation ──────────────────────────────────────
420
522
 
421
523
  const DISCORD_THREAD_NAME_MAX = 100
422
- const WORKTREE_THREAD_PREFIX = '⬦ '
423
-
424
- // Prefixes that should survive OpenCode session title renames.
425
- // When a thread starts with one of these, the rename preserves it.
426
524
  const PRESERVED_THREAD_PREFIXES: string[] = [
427
- WORKTREE_THREAD_PREFIX,
525
+ WORKTREE_PREFIX,
428
526
  'btw: ',
429
527
  'Fork: ',
430
528
  ]
431
529
 
530
+ function stripPreservedThreadPrefix(name: string) {
531
+ const matchedPrefix = PRESERVED_THREAD_PREFIXES.find((prefix) => {
532
+ return name.startsWith(prefix)
533
+ })
534
+ if (!matchedPrefix) return name
535
+ return name.slice(matchedPrefix.length).trim()
536
+ }
537
+
432
538
  function getThreadNameCandidateFromSessionTitle({
433
539
  sessionTitle,
434
540
  currentName,
@@ -436,18 +542,24 @@ function getThreadNameCandidateFromSessionTitle({
436
542
  sessionTitle: string | undefined | null
437
543
  currentName: string
438
544
  }) {
439
- const trimmed = sessionTitle?.trim()
545
+ const trimmed = sessionTitle
546
+ ?.replace(/<\/?callout\b[^>]*>/gi, '')
547
+ .trim()
440
548
  if (!trimmed) {
441
549
  return null
442
550
  }
443
- if (/^new session\s*-/i.test(trimmed)) {
551
+ const withoutCopiedPrefix = stripPreservedThreadPrefix(trimmed)
552
+ if (!withoutCopiedPrefix) {
553
+ return null
554
+ }
555
+ if (/^new session\s*-/i.test(withoutCopiedPrefix)) {
444
556
  return null
445
557
  }
446
558
  const matchedPrefix =
447
559
  PRESERVED_THREAD_PREFIXES.find((p) => {
448
560
  return currentName.startsWith(p)
449
561
  }) ?? ''
450
- return `${matchedPrefix}${trimmed}`.slice(0, DISCORD_THREAD_NAME_MAX)
562
+ return `${matchedPrefix}${withoutCopiedPrefix}`.slice(0, DISCORD_THREAD_NAME_MAX)
451
563
  }
452
564
 
453
565
  export function deriveThreadNameFromSessionTitle({
@@ -470,45 +582,6 @@ export function deriveThreadNameFromSessionTitle({
470
582
  return candidate
471
583
  }
472
584
 
473
- export function deriveThreadRenameFromSessionUpdate({
474
- sessionTitle,
475
- currentName,
476
- lastSyncedName,
477
- }: {
478
- sessionTitle: string | undefined | null
479
- currentName: string
480
- lastSyncedName: string | null
481
- }) {
482
- if (lastSyncedName !== null && currentName !== lastSyncedName) {
483
- return {
484
- desiredName: null,
485
- nextSyncedName: lastSyncedName,
486
- }
487
- }
488
-
489
- const candidate = getThreadNameCandidateFromSessionTitle({
490
- sessionTitle,
491
- currentName,
492
- })
493
- if (candidate === null) {
494
- return {
495
- desiredName: null,
496
- nextSyncedName: lastSyncedName,
497
- }
498
- }
499
- if (candidate === currentName) {
500
- return {
501
- desiredName: null,
502
- nextSyncedName: currentName,
503
- }
504
- }
505
-
506
- return {
507
- desiredName: candidate,
508
- nextSyncedName: candidate,
509
- }
510
- }
511
-
512
585
  // ── Ingress input type ───────────────────────────────────────────
513
586
 
514
587
  export type EnqueueResult = {
@@ -563,6 +636,11 @@ export type IngressInput = {
563
636
  // First-dispatch-only overrides (used when creating a new session)
564
637
  agent?: string
565
638
  model?: string
639
+ /**
640
+ * Thinking-level variant from `/xxx-agent variant:`. Applied after
641
+ * agent/model snapshot so it wins over cascade for this turn.
642
+ */
643
+ variant?: string
566
644
  /**
567
645
  * Raw permission rule strings from --permission flag ("tool:action" or
568
646
  * "tool:pattern:action"). Parsed into PermissionRuleset entries by
@@ -588,6 +666,12 @@ export type IngressInput = {
588
666
  * (e.g. user-to-user replies in a thread).
589
667
  */
590
668
  noReply?: boolean
669
+ /**
670
+ * True only for the wake prompt posted by the kimaki_sleep task runner.
671
+ * Every other ingress cancels a pending sleep; this one must not, because it
672
+ * is delivering that sleep rather than superseding it.
673
+ */
674
+ isSleepWake?: boolean
591
675
  /**
592
676
  * Product-analytics turn source. Defaults to discord. Set retry/cli/scheduled
593
677
  * at the ingress site so DAU queries can exclude non-user activity.
@@ -694,32 +778,28 @@ export class ThreadSessionRuntime {
694
778
  // message and showing multiple back-to-back POSTs is wasteful.
695
779
  private typingKeepaliveTimeout: ReturnType<typeof setTimeout> | null = null
696
780
  private readonly typingRepulseDebounce: ReturnType<typeof createDebouncedTimeout>
781
+ private readonly deferredQuestionShow: ReturnType<typeof createDebouncedTimeout>
697
782
 
698
783
  private static TYPING_REPULSE_DEBOUNCE_MS = 500
784
+ private static DEFERRED_QUESTION_SHOW_MS = 1000
699
785
 
700
786
  // Notification throttles for retry/context notices.
701
787
  private lastDisplayedContextPercentage = 0
702
788
  private lastRateLimitDisplayTime = 0
703
789
 
704
- // Last OpenCode-generated session title we successfully applied to the
705
- // Discord thread name. Used to dedupe repeated session.updated events so
706
- // we only call thread.setName() once per distinct title. Discord rate-limits
707
- // channel/thread renames to ~2 per 10 minutes per thread, so we must avoid
708
- // retrying. Not persisted — worst case on restart we re-apply the same title
709
- // once (which is a no-op via deriveThreadNameFromSessionTitle).
790
+ // Last OpenCode session title we applied to Discord. Dedupes session.updated
791
+ // so we only call setName once per distinct title. Not persisted.
710
792
  private appliedOpencodeTitle: string | undefined
711
793
 
712
- // Last Discord thread name known to match the OpenCode title. Persisted so a
713
- // user rename is still respected after Kimaki restarts.
714
- private lastSyncedThreadName: string | null | undefined
715
-
716
794
  // Part output buffering (write-side cache, not domain state)
717
795
  private partBuffer = new Map<string, Map<string, Part>>()
796
+ private shownQuestionRequestIds = new Set<string>()
718
797
 
719
798
  // Derivable cache (perf optimization for provider.list API call)
720
799
  private modelContextLimit: number | undefined
721
800
  private modelContextLimitKey: string | undefined
722
801
  private lastPromptWorktreeKey: string | null | undefined
802
+ private lastSentPartKind: SessionPartKind | undefined
723
803
 
724
804
  // Bounded buffer of recent SSE events with timestamps.
725
805
  // Used by waitForEvent() to scan for specific events that arrived
@@ -792,6 +872,17 @@ export class ThreadSessionRuntime {
792
872
  this.restartTypingKeepalive({ sendNow: true })
793
873
  },
794
874
  })
875
+ this.deferredQuestionShow = createDebouncedTimeout({
876
+ delayMs: ThreadSessionRuntime.DEFERRED_QUESTION_SHOW_MS,
877
+ callback: () => {
878
+ if (this.disposed) {
879
+ return
880
+ }
881
+ void this.dispatchAction(async () => {
882
+ await this.tryShowPendingQuestion({ ignoreUnfinishedText: true })
883
+ })
884
+ },
885
+ })
795
886
  }
796
887
 
797
888
  private consumeWorktreePromptChange(
@@ -989,6 +1080,52 @@ export class ThreadSessionRuntime {
989
1080
  return isSessionBusy({ events: this.eventBuffer, sessionId })
990
1081
  }
991
1082
 
1083
+ private async persistIngressVariant({
1084
+ sessionId,
1085
+ channelId,
1086
+ appId,
1087
+ agentPreference,
1088
+ getClient,
1089
+ variant,
1090
+ }: {
1091
+ sessionId: string
1092
+ channelId?: string
1093
+ appId?: string
1094
+ agentPreference?: string
1095
+ getClient: Awaited<ReturnType<typeof initializeOpencodeForDirectory>>
1096
+ variant?: string
1097
+ }) {
1098
+ if (!variant) return
1099
+ if (getClient instanceof Error) return
1100
+ const variantModelInfo = await getCurrentModelInfo({
1101
+ sessionId,
1102
+ channelId,
1103
+ appId,
1104
+ agentPreference,
1105
+ getClient,
1106
+ directory: this.sdkDirectory,
1107
+ })
1108
+ if (variantModelInfo.type === 'none') return
1109
+ const providersResponse = await getClient()
1110
+ .provider.list({ directory: this.sdkDirectory })
1111
+ .catch((e) => new OpenCodeSdkError({ operation: 'provider.list', cause: e }))
1112
+ if (providersResponse instanceof Error || !providersResponse.data) return
1113
+ const matchedVariant = matchThinkingValue({
1114
+ requestedValue: variant,
1115
+ availableValues: getThinkingValuesForModel({
1116
+ providers: providersResponse.data.all,
1117
+ providerId: variantModelInfo.providerID,
1118
+ modelId: variantModelInfo.modelID,
1119
+ }),
1120
+ })
1121
+ if (!matchedVariant) return
1122
+ await setSessionModel({
1123
+ sessionId,
1124
+ modelId: variantModelInfo.model,
1125
+ variant: matchedVariant,
1126
+ })
1127
+ }
1128
+
992
1129
  private getAssistantMessageIdsForCurrentTurn({
993
1130
  sessionId,
994
1131
  upToIndex,
@@ -1026,21 +1163,25 @@ export class ThreadSessionRuntime {
1026
1163
  if (!mainSessionId || candidateSessionId === mainSessionId) {
1027
1164
  return undefined
1028
1165
  }
1029
- const subtaskIndex = getDerivedSubtaskIndex({
1166
+ if (!isDerivedChildSession({
1030
1167
  events: this.eventBuffer,
1031
1168
  mainSessionId,
1032
1169
  candidateSessionId,
1033
- })
1034
- if (!subtaskIndex) {
1170
+ })) {
1035
1171
  return undefined
1036
1172
  }
1037
1173
 
1174
+ const subtaskIndex = getDerivedSubtaskIndex({
1175
+ events: this.eventBuffer,
1176
+ mainSessionId,
1177
+ candidateSessionId,
1178
+ })
1038
1179
  const agentType = getDerivedSubtaskAgentType({
1039
1180
  events: this.eventBuffer,
1040
1181
  mainSessionId,
1041
1182
  candidateSessionId,
1042
1183
  })
1043
- const label = `${agentType || 'task'}-${subtaskIndex}`
1184
+ const label = `${agentType || 'task'}-${subtaskIndex || 1}`
1044
1185
  const assistantMessageId = this.getLatestAssistantMessageIdForCurrentTurn({
1045
1186
  sessionId: candidateSessionId,
1046
1187
  })
@@ -1053,6 +1194,7 @@ export class ThreadSessionRuntime {
1053
1194
  this.disposed = true
1054
1195
  unregisterEventListener(this.threadId)
1055
1196
  void this.persistEventBufferDebounced.dispose()
1197
+ this.deferredQuestionShow.clear()
1056
1198
  this.stopTyping()
1057
1199
 
1058
1200
  // Release large internal buffers so GC can reclaim memory immediately
@@ -1060,6 +1202,7 @@ export class ThreadSessionRuntime {
1060
1202
  this.eventBuffer = []
1061
1203
  this.nextEventIndex = 0
1062
1204
  this.partBuffer.clear()
1205
+ this.shownQuestionRequestIds.clear()
1063
1206
  this.preprocessChain = Promise.resolve()
1064
1207
 
1065
1208
  // Don't clear actionQueue here — queued closures own resolve/reject for
@@ -1170,7 +1313,6 @@ export class ThreadSessionRuntime {
1170
1313
  })
1171
1314
  : []
1172
1315
  delete info.system
1173
- delete info.summary
1174
1316
  delete info.tools
1175
1317
  delete info.parts
1176
1318
  if (partsSummary.length > 0) {
@@ -1353,10 +1495,14 @@ export class ThreadSessionRuntime {
1353
1495
  // Subtask sessions also bypass — they're tracked in subtaskSessions.
1354
1496
 
1355
1497
  private async handleEvent(event: OpenCodeEvent): Promise<void> {
1356
- // session.diff can carry repeated full-file before/after snapshots and is
1357
- // not used by event-derived runtime state, queueing, typing, or UI routing.
1358
- // Drop it at ingress so large diff payloads never hit memory buffers.
1359
- if (event.type === 'session.diff') {
1498
+ const sessionId = this.state?.sessionId
1499
+ if (!shouldBufferSessionEvent({
1500
+ event,
1501
+ mainSessionId: sessionId,
1502
+ isKnownChildSession: (candidateSessionId) => {
1503
+ return Boolean(this.getSubtaskInfoForSession(candidateSessionId))
1504
+ },
1505
+ })) {
1360
1506
  return
1361
1507
  }
1362
1508
 
@@ -1371,8 +1517,6 @@ export class ThreadSessionRuntime {
1371
1517
  this.appendEventToBuffer(event)
1372
1518
  }
1373
1519
 
1374
- const sessionId = this.state?.sessionId
1375
-
1376
1520
  const eventSessionId = getOpencodeEventSessionId(event)
1377
1521
  const toastSessionId = event.type === 'tui.toast.show'
1378
1522
  ? extractToastSessionId({ message: event.properties.message })
@@ -1712,12 +1856,12 @@ export class ThreadSessionRuntime {
1712
1856
  })
1713
1857
  }
1714
1858
 
1715
- private shouldSendPart({
1859
+ private shouldSendPlannedPart({
1716
1860
  part,
1717
- force,
1861
+ mode,
1718
1862
  }: {
1719
1863
  part: Part
1720
- force: boolean
1864
+ mode: AssistantTurnFlushMode
1721
1865
  }): boolean {
1722
1866
  if (part.type === 'step-start' || part.type === 'step-finish') {
1723
1867
  return false
@@ -1725,21 +1869,72 @@ export class ThreadSessionRuntime {
1725
1869
  if (part.type === 'tool' && part.state.status === 'pending') {
1726
1870
  return false
1727
1871
  }
1728
- if (!force && part.type === 'text' && !part.time?.end) {
1729
- return false
1730
- }
1731
- if (!force && part.type === 'tool' && part.state.status === 'completed') {
1872
+ if (part.type === 'text' && !part.time?.end && mode === 'progress') {
1732
1873
  return false
1733
1874
  }
1734
1875
  return true
1735
1876
  }
1736
1877
 
1878
+ private getCurrentTurnParts(): Part[] {
1879
+ const sessionId = this.state?.sessionId
1880
+ const messageIds = sessionId
1881
+ ? [...this.getAssistantMessageIdsForCurrentTurn({ sessionId })]
1882
+ : []
1883
+ if (messageIds.length > 0) {
1884
+ return messageIds.flatMap((id) => this.getBufferedParts(id))
1885
+ }
1886
+ return [...this.partBuffer.keys()].flatMap((id) => this.getBufferedParts(id))
1887
+ }
1888
+
1889
+ private async flushCurrentTurnParts({
1890
+ mode,
1891
+ throughPartId,
1892
+ skipPartId,
1893
+ repulseTyping = true,
1894
+ }: {
1895
+ mode: AssistantTurnFlushMode
1896
+ throughPartId?: string
1897
+ skipPartId?: string
1898
+ repulseTyping?: boolean
1899
+ }): Promise<void> {
1900
+ const parts = this.getCurrentTurnParts().filter((part) => {
1901
+ return !this.state?.sentPartIds.has(part.id)
1902
+ })
1903
+ const planned = planAssistantTurnFlush({
1904
+ parts,
1905
+ mode,
1906
+ throughPartId,
1907
+ })
1908
+ for (const { part, quoteText } of planned.sendParts) {
1909
+ if (skipPartId && part.id === skipPartId) {
1910
+ continue
1911
+ }
1912
+ if (!this.shouldSendPlannedPart({ part, mode })) {
1913
+ continue
1914
+ }
1915
+ if (part.type === 'tool' && part.tool === 'task') {
1916
+ continue
1917
+ }
1918
+ const pulseTyping =
1919
+ part.type === 'text' && part.ignored === true
1920
+ ? false
1921
+ : repulseTyping
1922
+ await this.sendPartMessage({
1923
+ part,
1924
+ quoteText,
1925
+ repulseTyping: pulseTyping,
1926
+ })
1927
+ }
1928
+ }
1929
+
1737
1930
  private async sendPartMessage({
1738
1931
  part,
1739
1932
  repulseTyping = true,
1933
+ quoteText = false,
1740
1934
  }: {
1741
1935
  part: Part
1742
1936
  repulseTyping?: boolean
1937
+ quoteText?: boolean
1743
1938
  }): Promise<void> {
1744
1939
  const verbosity = await this.getVerbosity()
1745
1940
  if (verbosity === 'text_only' && part.type !== 'text') {
@@ -1751,7 +1946,14 @@ export class ThreadSessionRuntime {
1751
1946
  }
1752
1947
  }
1753
1948
 
1754
- const content = formatPart(part)
1949
+ const formatted = formatPart(part)
1950
+ const quote =
1951
+ quoteText
1952
+ && shouldQuoteIntermediateTextPart({
1953
+ part,
1954
+ isLastInTurn: false,
1955
+ })
1956
+ const content = quote ? asDiscordQuote(formatted) : formatted
1755
1957
  if (!content.trim() || content.length === 0) {
1756
1958
  return
1757
1959
  }
@@ -1766,7 +1968,13 @@ export class ThreadSessionRuntime {
1766
1968
  return { ...t, sentPartIds: newIds }
1767
1969
  })
1768
1970
 
1769
- const sendResult = await sendThreadMessage(this.thread, content)
1971
+ const kind = sessionPartKind(part)
1972
+ const sendResult = await sendSessionPartMessage(this.thread, content, {
1973
+ leadWithBlankLine: shouldLeadWithBlankLine({
1974
+ previousKind: this.lastSentPartKind,
1975
+ nextKind: kind,
1976
+ }),
1977
+ })
1770
1978
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
1771
1979
  if (sendResult instanceof Error) {
1772
1980
  threadState.updateThread(this.threadId, (t) => {
@@ -1780,63 +1988,15 @@ export class ThreadSessionRuntime {
1780
1988
  )
1781
1989
  return
1782
1990
  }
1991
+ this.lastSentPartKind = kind
1783
1992
  await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id })
1784
1993
  if (repulseTyping) {
1785
1994
  this.requestTypingRepulse()
1786
1995
  }
1787
1996
  }
1788
1997
 
1789
- private async flushBufferedParts({
1790
- messageID,
1791
- force,
1792
- skipPartId,
1793
- repulseTyping = true,
1794
- }: {
1795
- messageID: string | undefined
1796
- force: boolean
1797
- skipPartId?: string
1798
- repulseTyping?: boolean
1799
- }): Promise<void> {
1800
- if (!messageID) {
1801
- return
1802
- }
1803
- const parts = this.getBufferedParts(messageID)
1804
- for (const part of parts) {
1805
- if (skipPartId && part.id === skipPartId) {
1806
- continue
1807
- }
1808
- if (!this.shouldSendPart({ part, force })) {
1809
- continue
1810
- }
1811
- await this.sendPartMessage({ part, repulseTyping })
1812
- }
1813
- }
1814
-
1815
- private async flushBufferedPartsForMessages({
1816
- messageIDs,
1817
- force,
1818
- skipPartId,
1819
- repulseTyping = true,
1820
- }: {
1821
- messageIDs: ReadonlyArray<string>
1822
- force: boolean
1823
- skipPartId?: string
1824
- repulseTyping?: boolean
1825
- }): Promise<void> {
1826
- const uniqueMessageIDs = [...new Set(messageIDs)]
1827
- for (const messageID of uniqueMessageIDs) {
1828
- await this.flushBufferedParts({
1829
- messageID,
1830
- force,
1831
- skipPartId,
1832
- repulseTyping,
1833
- })
1834
- }
1835
- }
1836
-
1837
1998
  private async showInteractiveUi({
1838
1999
  skipPartId,
1839
- flushMessageId,
1840
2000
  show,
1841
2001
  }: {
1842
2002
  skipPartId?: string
@@ -1844,32 +2004,11 @@ export class ThreadSessionRuntime {
1844
2004
  show: () => Promise<void>
1845
2005
  }): Promise<void> {
1846
2006
  this.stopTyping()
1847
- const sessionId = this.state?.sessionId
1848
- const targetMessageId = (() => {
1849
- if (flushMessageId) {
1850
- return flushMessageId
1851
- }
1852
- if (!sessionId) {
1853
- return undefined
1854
- }
1855
- return this.getLatestAssistantMessageIdForCurrentTurn({ sessionId })
1856
- })()
1857
- if (targetMessageId) {
1858
- await this.flushBufferedParts({
1859
- messageID: targetMessageId,
1860
- force: true,
1861
- skipPartId,
1862
- })
1863
- } else {
1864
- const assistantMessageIds = sessionId
1865
- ? [...this.getAssistantMessageIdsForCurrentTurn({ sessionId })]
1866
- : []
1867
- await this.flushBufferedPartsForMessages({
1868
- messageIDs: assistantMessageIds,
1869
- force: true,
1870
- skipPartId,
1871
- })
1872
- }
2007
+ await this.flushCurrentTurnParts({
2008
+ mode: 'interactive',
2009
+ throughPartId: skipPartId,
2010
+ skipPartId,
2011
+ })
1873
2012
  await show()
1874
2013
  }
1875
2014
 
@@ -1920,10 +2059,21 @@ export class ThreadSessionRuntime {
1920
2059
  private async handleMessageUpdated(msg: OpenCodeMessage): Promise<void> {
1921
2060
  const sessionId = this.state?.sessionId
1922
2061
 
1923
- if (msg.sessionID !== sessionId) {
2062
+ if (msg.role !== 'assistant') {
1924
2063
  return
1925
2064
  }
1926
- if (msg.role !== 'assistant') {
2065
+ if (msg.summary === true) {
2066
+ this.clearBufferedPartsForMessages([msg.id])
2067
+ logger.info(`[SKIP] message.updated for compaction summary ${msg.id}`)
2068
+ return
2069
+ }
2070
+ if (msg.sessionID !== sessionId) {
2071
+ const subtaskInfo = this.getSubtaskInfoForSession(msg.sessionID)
2072
+ if (subtaskInfo) {
2073
+ for (const part of this.getBufferedParts(msg.id)) {
2074
+ await this.handleSubtaskPart(part, subtaskInfo)
2075
+ }
2076
+ }
1927
2077
  return
1928
2078
  }
1929
2079
  if (!sessionId) {
@@ -1934,6 +2084,7 @@ export class ThreadSessionRuntime {
1934
2084
  sessionId,
1935
2085
  messageId: msg.id,
1936
2086
  })) {
2087
+ this.clearBufferedPartsForMessages([msg.id])
1937
2088
  logger.info(`[SKIP] message.updated for old assistant message ${msg.id}, not in latest user turn`)
1938
2089
  return
1939
2090
  }
@@ -1970,9 +2121,8 @@ export class ThreadSessionRuntime {
1970
2121
  })
1971
2122
  }
1972
2123
 
1973
- await this.flushBufferedParts({
1974
- messageID: msg.id,
1975
- force: false,
2124
+ await this.flushCurrentTurnParts({
2125
+ mode: 'progress',
1976
2126
  })
1977
2127
 
1978
2128
  const wasAlreadyCompleted = hasAssistantMessageCompletedBefore({
@@ -2031,7 +2181,7 @@ export class ThreadSessionRuntime {
2031
2181
  return
2032
2182
  }
2033
2183
  this.lastDisplayedContextPercentage = thresholdCrossed
2034
- const chunk = `⬦ context usage ${currentPercentage}%`
2184
+ const chunk = `${STATUS_PREFIX}context usage ${currentPercentage}%`
2035
2185
  const sendResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
2036
2186
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
2037
2187
  if (sendResult instanceof Error) {
@@ -2040,8 +2190,24 @@ export class ThreadSessionRuntime {
2040
2190
  }
2041
2191
 
2042
2192
  private async handlePartUpdated(part: Part): Promise<void> {
2043
- this.storePart(part)
2044
2193
  const sessionId = this.state?.sessionId
2194
+ const messageKind = getAssistantMessageKind({
2195
+ events: this.eventBuffer,
2196
+ sessionId: part.sessionID,
2197
+ messageId: part.messageID,
2198
+ })
2199
+
2200
+ if (messageKind === 'summary') {
2201
+ this.clearBufferedPartsForMessages([part.messageID])
2202
+ logger.info(`[SKIP] message.part.updated for compaction summary ${part.messageID}`)
2203
+ return
2204
+ }
2205
+
2206
+ this.storePart(part)
2207
+
2208
+ if (messageKind === 'unknown') {
2209
+ return
2210
+ }
2045
2211
 
2046
2212
  const subtaskInfo = this.getSubtaskInfoForSession(part.sessionID)
2047
2213
  const isSubtaskEvent = Boolean(subtaskInfo)
@@ -2066,48 +2232,55 @@ export class ThreadSessionRuntime {
2066
2232
  return
2067
2233
  }
2068
2234
 
2069
- if (
2070
- part.type === 'tool' &&
2071
- part.tool === 'task' &&
2072
- !this.state?.sentPartIds.has(part.id)
2073
- ) {
2074
- const taskDisplay = formatTaskToolTitle(part)
2075
- if (taskDisplay && (await this.getVerbosity()) !== 'text_only') {
2076
- await this.flushBufferedParts({
2077
- messageID: part.messageID,
2078
- force: true,
2079
- skipPartId: part.id,
2080
- })
2081
- threadState.updateThread(this.threadId, (t) => {
2082
- const newIds = new Set(t.sentPartIds)
2083
- newIds.add(part.id)
2084
- return { ...t, sentPartIds: newIds }
2085
- })
2086
- const sendResult = await sendThreadMessage(this.thread, taskDisplay + '\n\n')
2087
- .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
2088
- if (sendResult instanceof Error) {
2235
+ if (part.type === 'tool' && part.state.status === 'running') {
2236
+ await this.flushCurrentTurnParts({
2237
+ mode: 'progress',
2238
+ })
2239
+ const unsent = this.getCurrentTurnParts().filter((candidate) => {
2240
+ return !this.state?.sentPartIds.has(candidate.id)
2241
+ })
2242
+ const held = planAssistantTurnFlush({
2243
+ parts: unsent,
2244
+ mode: 'progress',
2245
+ }).hold.some((entry) => entry.id === part.id)
2246
+ if (held) {
2247
+ return
2248
+ }
2249
+ if (!this.state?.sentPartIds.has(part.id) && part.tool !== 'task') {
2250
+ await this.sendPartMessage({ part })
2251
+ }
2252
+
2253
+ if (part.tool === 'task' && !this.state?.sentPartIds.has(part.id)) {
2254
+ const taskDisplay = formatTaskToolTitle(part)
2255
+ if (taskDisplay && (await this.getVerbosity()) !== 'text_only') {
2089
2256
  threadState.updateThread(this.threadId, (t) => {
2090
2257
  const newIds = new Set(t.sentPartIds)
2091
- newIds.delete(part.id)
2258
+ newIds.add(part.id)
2092
2259
  return { ...t, sentPartIds: newIds }
2093
2260
  })
2094
- discordLogger.error(
2095
- `ERROR: Failed to send task part ${part.id}:`,
2096
- sendResult,
2097
- )
2098
- return
2261
+ const sendResult = await sendSessionPartMessage(this.thread, taskDisplay, {
2262
+ leadWithBlankLine: shouldLeadWithBlankLine({
2263
+ previousKind: this.lastSentPartKind,
2264
+ nextKind: 'tool',
2265
+ }),
2266
+ })
2267
+ .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
2268
+ if (sendResult instanceof Error) {
2269
+ threadState.updateThread(this.threadId, (t) => {
2270
+ const newIds = new Set(t.sentPartIds)
2271
+ newIds.delete(part.id)
2272
+ return { ...t, sentPartIds: newIds }
2273
+ })
2274
+ discordLogger.error(
2275
+ `ERROR: Failed to send task part ${part.id}:`,
2276
+ sendResult,
2277
+ )
2278
+ return
2279
+ }
2280
+ this.lastSentPartKind = 'tool'
2281
+ await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id })
2099
2282
  }
2100
- await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id })
2101
2283
  }
2102
- }
2103
-
2104
- if (part.type === 'tool' && part.state.status === 'running') {
2105
- await this.flushBufferedParts({
2106
- messageID: part.messageID,
2107
- force: true,
2108
- skipPartId: part.id,
2109
- })
2110
- await this.sendPartMessage({ part })
2111
2284
  return
2112
2285
  }
2113
2286
 
@@ -2219,7 +2392,7 @@ export class ThreadSessionRuntime {
2219
2392
  }
2220
2393
  return ` (${pct.toFixed(1)}%)`
2221
2394
  })()
2222
- const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
2395
+ const chunk = `${STATUS_PREFIX}${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
2223
2396
  const largeOutputResult = await this.thread.send({
2224
2397
  content: chunk,
2225
2398
  flags: SILENT_MESSAGE_FLAGS,
@@ -2232,20 +2405,19 @@ export class ThreadSessionRuntime {
2232
2405
  }
2233
2406
 
2234
2407
  if (part.type === 'reasoning') {
2235
- await this.sendPartMessage({ part })
2408
+ await this.flushCurrentTurnParts({ mode: 'progress' })
2236
2409
  return
2237
2410
  }
2238
2411
 
2239
- if (part.type === 'text' && part.time?.end) {
2240
- await this.sendPartMessage({ part })
2412
+ if (part.type === 'text') {
2413
+ await this.flushCurrentTurnParts({ mode: 'progress' })
2414
+ if (part.time?.end) {
2415
+ await this.tryShowPendingQuestion()
2416
+ }
2241
2417
  return
2242
2418
  }
2243
2419
 
2244
2420
  if (part.type === 'step-finish') {
2245
- await this.flushBufferedParts({
2246
- messageID: part.messageID,
2247
- force: true,
2248
- })
2249
2421
  this.ensureTypingKeepalive()
2250
2422
  }
2251
2423
  }
@@ -2283,7 +2455,13 @@ export class ThreadSessionRuntime {
2283
2455
  if (!content.trim() || this.state?.sentPartIds.has(part.id)) {
2284
2456
  return
2285
2457
  }
2286
- const sendResult = await sendThreadMessage(this.thread, content + '\n\n')
2458
+ const kind = sessionPartKind(part)
2459
+ const sendResult = await sendSessionPartMessage(this.thread, content, {
2460
+ leadWithBlankLine: shouldLeadWithBlankLine({
2461
+ previousKind: this.lastSentPartKind,
2462
+ nextKind: kind,
2463
+ }),
2464
+ })
2287
2465
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
2288
2466
  if (sendResult instanceof Error) {
2289
2467
  discordLogger.error(
@@ -2292,6 +2470,7 @@ export class ThreadSessionRuntime {
2292
2470
  )
2293
2471
  return
2294
2472
  }
2473
+ this.lastSentPartKind = kind
2295
2474
  threadState.updateThread(this.threadId, (t) => {
2296
2475
  const newIds = new Set(t.sentPartIds)
2297
2476
  newIds.add(part.id)
@@ -2301,21 +2480,16 @@ export class ThreadSessionRuntime {
2301
2480
  this.requestTypingRepulse()
2302
2481
  }
2303
2482
 
2304
- private trackIdleTokenUsage(idleSessionId: string): void {
2305
- let idleEventIndex: number | undefined
2306
- for (let i = this.eventBuffer.length - 1; i >= 0; i--) {
2307
- const event = this.eventBuffer[i]?.event
2308
- if (event?.type === 'session.idle' && event.properties.sessionID === idleSessionId) {
2309
- idleEventIndex = i
2310
- break
2311
- }
2312
- }
2313
- if (idleEventIndex === undefined) {
2314
- return
2315
- }
2483
+ private trackIdleTokenUsage({
2484
+ sessionId,
2485
+ idleEventIndex,
2486
+ }: {
2487
+ sessionId: string
2488
+ idleEventIndex: number
2489
+ }): void {
2316
2490
  const usage = getIdleTokenUsageDelta({
2317
2491
  events: this.eventBuffer,
2318
- sessionId: idleSessionId,
2492
+ sessionId,
2319
2493
  idleEventIndex,
2320
2494
  })
2321
2495
  if (!usage) {
@@ -2331,7 +2505,7 @@ export class ThreadSessionRuntime {
2331
2505
  tokens_total: usage.total,
2332
2506
  cost: usage.cost,
2333
2507
  assistant_message_count: usage.assistantMessageCount,
2334
- is_subagent: Boolean(this.getSubtaskInfoForSession(idleSessionId)),
2508
+ is_subagent: Boolean(this.getSubtaskInfoForSession(sessionId)),
2335
2509
  }
2336
2510
  if (usage.model) {
2337
2511
  properties.model = usage.model
@@ -2342,8 +2516,37 @@ export class ThreadSessionRuntime {
2342
2516
  trackEvent('tokens_used', properties)
2343
2517
  }
2344
2518
 
2519
+ private trackIdleTokenUsageForSessionTree(idleSessionId: string): void {
2520
+ let idleEventIndex: number | undefined
2521
+ for (let i = this.eventBuffer.length - 1; i >= 0; i--) {
2522
+ const event = this.eventBuffer[i]?.event
2523
+ if (event?.type === 'session.idle' && event.properties.sessionID === idleSessionId) {
2524
+ idleEventIndex = i
2525
+ break
2526
+ }
2527
+ }
2528
+ if (idleEventIndex === undefined) {
2529
+ return
2530
+ }
2531
+ const mainSessionId = this.state?.sessionId
2532
+ const sessionIds = mainSessionId
2533
+ ? getTokenUsageSessionIdsForIdle({
2534
+ events: this.eventBuffer,
2535
+ mainSessionId,
2536
+ idleSessionId,
2537
+ upToIndex: idleEventIndex,
2538
+ })
2539
+ : [idleSessionId]
2540
+ for (const sessionId of sessionIds) {
2541
+ this.trackIdleTokenUsage({
2542
+ sessionId,
2543
+ idleEventIndex,
2544
+ })
2545
+ }
2546
+ }
2547
+
2345
2548
  private async handleSessionIdle(idleSessionId: string): Promise<void> {
2346
- this.trackIdleTokenUsage(idleSessionId)
2549
+ this.trackIdleTokenUsageForSessionTree(idleSessionId)
2347
2550
 
2348
2551
  const sessionId = this.state?.sessionId
2349
2552
 
@@ -2400,9 +2603,8 @@ export class ThreadSessionRuntime {
2400
2603
  return
2401
2604
  }
2402
2605
 
2403
- await this.flushBufferedPartsForMessages({
2404
- messageIDs: assistantMessageIds,
2405
- force: true,
2606
+ await this.flushCurrentTurnParts({
2607
+ mode: 'final',
2406
2608
  repulseTyping: false,
2407
2609
  })
2408
2610
 
@@ -2629,41 +2831,89 @@ export class ThreadSessionRuntime {
2629
2831
  this.onInteractiveUiStateChanged()
2630
2832
  }
2631
2833
 
2632
- private async handleQuestionAsked(
2633
- questionRequest: QuestionRequest,
2634
- ): Promise<void> {
2834
+ private hasUnfinishedTextPart(messageID: string): boolean {
2835
+ return this.getBufferedParts(messageID).some((part) => {
2836
+ return part.type === 'text' && !part.time?.end
2837
+ })
2838
+ }
2839
+
2840
+ // OpenCode emits question.asked when the tool starts, often before the
2841
+ // preceding text part gets time.end. Showing the dropdown on that event
2842
+ // holds the action queue while Discord posts, so the later text-end cannot
2843
+ // send and dumps after the queued » user: indicator. Wait for text-end.
2844
+ private async tryShowPendingQuestion({
2845
+ ignoreUnfinishedText = false,
2846
+ } = {}): Promise<boolean> {
2635
2847
  const sessionId = this.state?.sessionId
2636
- if (questionRequest.sessionID !== sessionId) {
2637
- logger.log(
2638
- `[QUESTION IGNORED] Question for different session (expected: ${sessionId}, got: ${questionRequest.sessionID})`,
2639
- )
2640
- return
2848
+ if (!sessionId) {
2849
+ return false
2641
2850
  }
2642
2851
 
2643
- logger.log(
2644
- `Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
2645
- )
2852
+ const request = deriveLatestUnansweredQuestion({
2853
+ events: this.eventBuffer,
2854
+ sessionId,
2855
+ })
2856
+ if (!request) {
2857
+ this.deferredQuestionShow.clear()
2858
+ return false
2859
+ }
2860
+ if (
2861
+ this.shownQuestionRequestIds.has(request.id)
2862
+ || findPendingQuestionContextForRequest({
2863
+ threadId: this.thread.id,
2864
+ requestId: request.id,
2865
+ })
2866
+ ) {
2867
+ this.deferredQuestionShow.clear()
2868
+ return true
2869
+ }
2646
2870
 
2871
+ const messageId = request.tool?.messageID
2872
+ if (!ignoreUnfinishedText && messageId && this.hasUnfinishedTextPart(messageId)) {
2873
+ return false
2874
+ }
2875
+
2876
+ this.shownQuestionRequestIds.add(request.id)
2647
2877
  await this.showInteractiveUi({
2878
+ flushMessageId: messageId,
2648
2879
  show: async () => {
2649
- if (!sessionId) {
2650
- return
2651
- }
2652
2880
  await showAskUserQuestionDropdowns({
2653
2881
  thread: this.thread,
2654
2882
  sessionId,
2655
2883
  directory: this.sdkDirectory,
2656
- requestId: questionRequest.id,
2657
- input: { questions: questionRequest.questions },
2884
+ requestId: request.id,
2885
+ input: { questions: request.questions },
2658
2886
  silent: this.getQueueLength() > 0,
2659
2887
  })
2660
2888
  },
2661
2889
  })
2662
-
2890
+ this.deferredQuestionShow.clear()
2663
2891
  this.maybeHandoffQueuedItemForPendingQuestion({
2664
2892
  sessionId,
2665
2893
  reason: 'question-shown',
2666
2894
  })
2895
+ return true
2896
+ }
2897
+
2898
+ private async handleQuestionAsked(
2899
+ questionRequest: QuestionRequest,
2900
+ ): Promise<void> {
2901
+ const sessionId = this.state?.sessionId
2902
+ if (questionRequest.sessionID !== sessionId) {
2903
+ logger.log(
2904
+ `[QUESTION IGNORED] Question for different session (expected: ${sessionId}, got: ${questionRequest.sessionID})`,
2905
+ )
2906
+ return
2907
+ }
2908
+
2909
+ logger.log(
2910
+ `Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
2911
+ )
2912
+
2913
+ const shown = await this.tryShowPendingQuestion()
2914
+ if (!shown) {
2915
+ this.deferredQuestionShow.trigger()
2916
+ }
2667
2917
  }
2668
2918
 
2669
2919
  private handleQuestionReplied(properties: { sessionID: string }): void {
@@ -2671,6 +2921,7 @@ export class ThreadSessionRuntime {
2671
2921
  if (properties.sessionID !== sessionId) {
2672
2922
  return
2673
2923
  }
2924
+ this.deferredQuestionShow.clear()
2674
2925
  this.onInteractiveUiStateChanged()
2675
2926
 
2676
2927
  // When a question is answered and the local queue has items, the model may
@@ -2752,7 +3003,7 @@ export class ThreadSessionRuntime {
2752
3003
  if (displayText.trim()) {
2753
3004
  await sendThreadMessage(
2754
3005
  this.thread,
2755
- `» **${next.username}:** ${displayText}`,
3006
+ `${QUEUE_PREFIX}**${next.username}:** ${displayText}`,
2756
3007
  )
2757
3008
  }
2758
3009
 
@@ -2805,7 +3056,7 @@ export class ThreadSessionRuntime {
2805
3056
  return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`
2806
3057
  })()
2807
3058
 
2808
- const chunk = `⬦ ${message} - retrying in ${duration} (attempt #${attempt})`
3059
+ const chunk = `${STATUS_PREFIX}${message} - retrying in ${duration} (attempt #${attempt})`
2809
3060
  const retryResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
2810
3061
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
2811
3062
  if (retryResult instanceof Error) {
@@ -2830,61 +3081,33 @@ export class ThreadSessionRuntime {
2830
3081
  if (info.id !== this.state?.sessionId) {
2831
3082
  return
2832
3083
  }
2833
- if (this.lastSyncedThreadName === undefined) {
2834
- const persistedName = await this.loadLastSyncedThreadName().catch(
2835
- (e) =>
2836
- new Error('Failed to read persisted thread rename state', { cause: e }),
2837
- )
2838
- if (persistedName instanceof Error) {
2839
- logger.warn(`[TITLE] ${persistedName.message} for thread ${this.threadId}`)
2840
- return
2841
- }
2842
- this.lastSyncedThreadName = persistedName
3084
+ const normalizedTitle = info.title.trim()
3085
+ if (this.appliedOpencodeTitle === normalizedTitle) {
3086
+ return
2843
3087
  }
2844
-
2845
- const renameDecision = deriveThreadRenameFromSessionUpdate({
3088
+ const desiredName = deriveThreadNameFromSessionTitle({
2846
3089
  sessionTitle: info.title,
2847
3090
  currentName: this.thread.name,
2848
- lastSyncedName: this.lastSyncedThreadName,
2849
3091
  })
2850
- if (renameDecision.desiredName === null) {
2851
- if (
2852
- renameDecision.nextSyncedName !== null &&
2853
- renameDecision.nextSyncedName !== this.lastSyncedThreadName
2854
- ) {
2855
- await this.persistLastSyncedThreadName(renameDecision.nextSyncedName)
2856
- }
2857
- return
2858
- }
2859
- const { desiredName } = renameDecision
2860
- const normalizedTitle = info.title.trim()
2861
- if (this.appliedOpencodeTitle === normalizedTitle) {
3092
+ // Mark before setName so concurrent session.updated events don't stack
3093
+ // renames. Keep the mark on failure — retry is almost always a rate limit.
3094
+ this.appliedOpencodeTitle = normalizedTitle
3095
+ if (!desiredName) {
2862
3096
  return
2863
3097
  }
2864
- // Mark before the call so concurrent session.updated events don't stack
2865
- // rename attempts. On failure we keep the mark — a retry won't help
2866
- // because the failure is almost always a rate limit.
2867
- this.appliedOpencodeTitle = normalizedTitle
2868
3098
 
2869
- const RENAME_TIMEOUT_MS = 3000
2870
- const timeoutSignal = AbortSignal.timeout(RENAME_TIMEOUT_MS)
2871
- const renameResult = await Promise.race([
2872
- this.thread.setName(desiredName)
3099
+ const renameResult = await raceDiscordRename({
3100
+ rename: this.thread.setName(desiredName)
2873
3101
  .catch((e) =>
2874
3102
  new Error('Failed to rename thread from OpenCode title', {
2875
3103
  cause: e,
2876
3104
  }),
2877
3105
  ),
2878
- new Promise<'timeout'>((resolve) => {
2879
- timeoutSignal.addEventListener('abort', () => {
2880
- resolve('timeout')
2881
- })
2882
- }),
2883
- ])
3106
+ })
2884
3107
 
2885
3108
  if (renameResult === 'timeout') {
2886
3109
  logger.warn(
2887
- `[TITLE] setName timed out after ${RENAME_TIMEOUT_MS}ms for thread ${this.threadId} (likely rate-limited)`,
3110
+ `[TITLE] setName timed out after ${DISCORD_THREAD_RENAME_TIMEOUT_MS}ms for thread ${this.threadId} (likely rate-limited)`,
2888
3111
  )
2889
3112
  return
2890
3113
  }
@@ -2894,38 +3117,11 @@ export class ThreadSessionRuntime {
2894
3117
  )
2895
3118
  return
2896
3119
  }
2897
- await this.persistLastSyncedThreadName(desiredName)
2898
3120
  logger.log(
2899
3121
  `[TITLE] Renamed thread ${this.threadId} to "${desiredName}" from OpenCode session title`,
2900
3122
  )
2901
3123
  }
2902
3124
 
2903
- private async loadLastSyncedThreadName() {
2904
- const db = await getDb()
2905
- const row = await db.query.thread_sessions.findFirst({
2906
- where: { thread_id: this.threadId },
2907
- columns: { last_synced_name: true },
2908
- })
2909
- return row?.last_synced_name ?? null
2910
- }
2911
-
2912
- private async persistLastSyncedThreadName(name: string): Promise<void> {
2913
- this.lastSyncedThreadName = name
2914
- const db = await getDb()
2915
- const result = await db.update(schema.thread_sessions)
2916
- .set({ last_synced_name: name })
2917
- .where(orm.eq(schema.thread_sessions.thread_id, this.threadId))
2918
- .catch(
2919
- (e) =>
2920
- new Error('Failed to persist thread rename state', {
2921
- cause: e,
2922
- }),
2923
- )
2924
- if (result instanceof Error) {
2925
- logger.warn(`[TITLE] ${result.message} for thread ${this.threadId}`)
2926
- }
2927
- }
2928
-
2929
3125
  private async handleTuiToast(properties: {
2930
3126
  title?: string
2931
3127
  message: string
@@ -2946,7 +3142,7 @@ export class ThreadSessionRuntime {
2946
3142
  const titlePrefix = properties.title
2947
3143
  ? `${properties.title.trim()}: `
2948
3144
  : ''
2949
- const chunk = `⬦ ${properties.variant}: ${titlePrefix}${toastMessage}`
3145
+ const chunk = `${STATUS_PREFIX}${properties.variant}: ${titlePrefix}${toastMessage}`
2950
3146
  const toastResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
2951
3147
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
2952
3148
  if (toastResult instanceof Error) {
@@ -2965,6 +3161,7 @@ export class ThreadSessionRuntime {
2965
3161
  * fields that the local-queue path provides.
2966
3162
  */
2967
3163
  private async submitViaOpencodeQueue(input: IngressInput): Promise<EnqueueResult> {
3164
+ await this.supersedePendingSleep(input)
2968
3165
  let skippedBySessionGuard = false
2969
3166
 
2970
3167
  await this.dispatchAction(async () => {
@@ -3039,6 +3236,18 @@ export class ThreadSessionRuntime {
3039
3236
  await clearSessionModel(session.id)
3040
3237
  }
3041
3238
 
3239
+ if (input.model) {
3240
+ const validatedModel = await validateModelId({
3241
+ model: input.model,
3242
+ getClient,
3243
+ directory: this.sdkDirectory,
3244
+ })
3245
+ if (validatedModel instanceof Error) {
3246
+ await cleanupOnError(`Failed to resolve model: ${validatedModel.message}`)
3247
+ return
3248
+ }
3249
+ }
3250
+
3042
3251
  await ensureSessionPreferencesSnapshot({
3043
3252
  sessionId: session.id,
3044
3253
  channelId,
@@ -3063,15 +3272,25 @@ export class ThreadSessionRuntime {
3063
3272
  }
3064
3273
  const resolvedAgent = agentResult.agentPreference
3065
3274
  const availableAgents = agentResult.agents
3275
+ releaseCurrentThreadIngress()
3276
+
3277
+ await this.persistIngressVariant({
3278
+ sessionId: session.id,
3279
+ channelId,
3280
+ appId: resolvedAppId,
3281
+ agentPreference: resolvedAgent,
3282
+ getClient,
3283
+ variant: input.variant,
3284
+ })
3066
3285
 
3067
3286
  const [modelResult, preferredVariant] = await Promise.all([
3068
3287
  (async () => {
3069
3288
  if (input.model) {
3070
- const [providerID, ...modelParts] = input.model.split('/')
3071
- const modelID = modelParts.join('/')
3072
- if (providerID && modelID) {
3073
- return { providerID, modelID }
3074
- }
3289
+ return validateModelId({
3290
+ model: input.model,
3291
+ getClient,
3292
+ directory: this.sdkDirectory,
3293
+ })
3075
3294
  }
3076
3295
  const modelInfo = await getCurrentModelInfo({
3077
3296
  sessionId: session.id,
@@ -3186,6 +3405,7 @@ export class ThreadSessionRuntime {
3186
3405
  userId: input.userId,
3187
3406
  sourceMessageId: input.sourceMessageId,
3188
3407
  sourceThreadId: input.sourceThreadId,
3408
+ threadName: this.thread.name || undefined,
3189
3409
  repliedMessage: input.repliedMessage,
3190
3410
  worktree,
3191
3411
  currentAgent: resolvedAgent,
@@ -3211,6 +3431,7 @@ export class ThreadSessionRuntime {
3211
3431
  username: this.state?.sessionUsername || input.username,
3212
3432
  userId: this.state?.sessionUserId || input.userId,
3213
3433
  parentSessionId: this.state?.parentSessionId || input.parentSessionId,
3434
+ scheduledTask: await this.resolveScheduledTaskContext(session.id),
3214
3435
  }),
3215
3436
  ...(resolvedAgent ? { agent: resolvedAgent } : {}),
3216
3437
  ...(modelField ? { model: modelField } : {}),
@@ -3269,7 +3490,28 @@ export class ThreadSessionRuntime {
3269
3490
  * Enqueue in kimaki's local per-thread queue.
3270
3491
  * Used for explicit queue workflows (/queue, queueMessage=true).
3271
3492
  */
3493
+ /**
3494
+ * A new turn supersedes a pending sleep.
3495
+ *
3496
+ * Called from the two terminal routers rather than from the top of
3497
+ * enqueueIncoming: arrival order is only fixed once a message reaches the
3498
+ * preprocessChain link, so awaiting anything before that lets two rapid
3499
+ * messages swap places. By here the order is already committed.
3500
+ *
3501
+ * Awaited rather than fire-and-forget so it cannot race the task runner and
3502
+ * let a stale wake land after the user took the conversation back.
3503
+ */
3504
+ private async supersedePendingSleep(input: IngressInput): Promise<void> {
3505
+ if (input.isSleepWake) return
3506
+ await cancelSessionSleepForThread({ threadId: this.threadId }).catch(
3507
+ (error) => {
3508
+ logger.error('[SLEEP] failed to cancel pending sleep:', error)
3509
+ },
3510
+ )
3511
+ }
3512
+
3272
3513
  private async enqueueViaLocalQueue(input: IngressInput): Promise<EnqueueResult> {
3514
+ await this.supersedePendingSleep(input)
3273
3515
  const queueId = crypto.randomBytes(8).toString('hex')
3274
3516
  const queuedMessage: QueuedMessage = {
3275
3517
  queueId,
@@ -3281,6 +3523,7 @@ export class ThreadSessionRuntime {
3281
3523
  command: input.command,
3282
3524
  agent: input.agent,
3283
3525
  model: input.model,
3526
+ variant: input.variant,
3284
3527
  permissions: input.permissions,
3285
3528
  injectionGuardPatterns: input.injectionGuardPatterns,
3286
3529
  parentSessionId: input.parentSessionId,
@@ -3333,8 +3576,12 @@ export class ThreadSessionRuntime {
3333
3576
  * discord-bot.ts.
3334
3577
  */
3335
3578
  async enqueueIncoming(input: IngressInput): Promise<EnqueueResult> {
3579
+ await waitForCurrentThreadIngress()
3336
3580
  threadState.setSessionUsername(this.threadId, input.username)
3337
- threadState.setSessionUserId(this.threadId, input.userId)
3581
+ const botUserId = this.thread.client.user?.id
3582
+ if (input.userId && input.userId !== botUserId) {
3583
+ threadState.setSessionUserId(this.threadId, input.userId)
3584
+ }
3338
3585
  await this.ensureParentSessionId({
3339
3586
  parentSessionId: input.parentSessionId,
3340
3587
  })
@@ -3540,6 +3787,11 @@ export class ThreadSessionRuntime {
3540
3787
  )
3541
3788
 
3542
3789
  this.stopTyping()
3790
+ this.deferredQuestionShow.clear()
3791
+
3792
+ // The aborted run owns the question request, so the dropdown dies with it.
3793
+ // Questions have no TTL, so this is the only thing that clears them here.
3794
+ void cancelPendingQuestion(this.threadId)
3543
3795
 
3544
3796
  const apiAbortPromise = sessionId
3545
3797
  ? this.abortSessionViaApi({ abortId, reason, sessionId })
@@ -3613,14 +3865,6 @@ export class ThreadSessionRuntime {
3613
3865
  return this.state?.queueItems.length ?? 0
3614
3866
  }
3615
3867
 
3616
- /** NOTIFY_MESSAGE_FLAGS unless queue has a next item, then SILENT.
3617
- * Permissions should NOT use this — they always notify. */
3618
- private getNotifyFlags(): number {
3619
- return this.getQueueLength() > 0
3620
- ? SILENT_MESSAGE_FLAGS
3621
- : NOTIFY_MESSAGE_FLAGS
3622
- }
3623
-
3624
3868
  /** Clear all queued messages. Returns the removed items. */
3625
3869
  clearQueue(): threadState.QueuedMessage[] {
3626
3870
  return threadState.clearQueueItems(this.threadId)
@@ -3721,7 +3965,7 @@ export class ThreadSessionRuntime {
3721
3965
  if (displayText.trim()) {
3722
3966
  await sendThreadMessage(
3723
3967
  this.thread,
3724
- `» **${next.username}:** ${displayText}`,
3968
+ `${QUEUE_PREFIX}**${next.username}:** ${displayText}`,
3725
3969
  )
3726
3970
  }
3727
3971
  }
@@ -3756,6 +4000,7 @@ export class ThreadSessionRuntime {
3756
4000
  private async dispatchPrompt(input: QueuedMessage): Promise<void> {
3757
4001
  this.lastDisplayedContextPercentage = 0
3758
4002
  this.lastRateLimitDisplayTime = 0
4003
+ this.lastSentPartKind = undefined
3759
4004
 
3760
4005
  // ── Ensure session ────────────────────────────────────────
3761
4006
  const sessionResult = await this.ensureSession({
@@ -3809,6 +4054,24 @@ export class ThreadSessionRuntime {
3809
4054
  await clearSessionModel(session.id)
3810
4055
  }
3811
4056
 
4057
+ if (input.model) {
4058
+ const validatedModel = await validateModelId({
4059
+ model: input.model,
4060
+ getClient,
4061
+ directory: this.sdkDirectory,
4062
+ })
4063
+ if (validatedModel instanceof Error) {
4064
+ this.stopTyping()
4065
+ await sendThreadMessage(
4066
+ this.thread,
4067
+ `Failed to resolve model: ${validatedModel.message}`,
4068
+ { flags: NOTIFY_MESSAGE_FLAGS },
4069
+ )
4070
+ await this.tryDrainQueue({ showIndicator: true })
4071
+ return
4072
+ }
4073
+ }
4074
+
3812
4075
  await ensureSessionPreferencesSnapshot({
3813
4076
  sessionId: session.id,
3814
4077
  channelId,
@@ -3841,14 +4104,23 @@ export class ThreadSessionRuntime {
3841
4104
  const earlyAgentPreference = earlyAgentResult.agentPreference
3842
4105
  const earlyAvailableAgents = earlyAgentResult.agents
3843
4106
 
4107
+ await this.persistIngressVariant({
4108
+ sessionId: session.id,
4109
+ channelId,
4110
+ appId: resolvedAppId,
4111
+ agentPreference: earlyAgentPreference,
4112
+ getClient,
4113
+ variant: input.variant,
4114
+ })
4115
+
3844
4116
  const [earlyModelResult, preferredVariant] = await Promise.all([
3845
4117
  (async () => {
3846
4118
  if (input.model) {
3847
- const [providerID, ...modelParts] = input.model.split('/')
3848
- const modelID = modelParts.join('/')
3849
- if (providerID && modelID) {
3850
- return { providerID, modelID }
3851
- }
4119
+ return validateModelId({
4120
+ model: input.model,
4121
+ getClient,
4122
+ directory: this.sdkDirectory,
4123
+ })
3852
4124
  }
3853
4125
  const modelInfo = await getCurrentModelInfo({
3854
4126
  sessionId: session.id,
@@ -3975,6 +4247,7 @@ export class ThreadSessionRuntime {
3975
4247
  userId: input.userId,
3976
4248
  sourceMessageId: input.sourceMessageId,
3977
4249
  sourceThreadId: input.sourceThreadId,
4250
+ threadName: this.thread.name || undefined,
3978
4251
  repliedMessage: input.repliedMessage,
3979
4252
  worktree,
3980
4253
  currentAgent: earlyAgentPreference,
@@ -4025,6 +4298,7 @@ export class ThreadSessionRuntime {
4025
4298
  userId: input.userId,
4026
4299
  sourceMessageId: input.sourceMessageId,
4027
4300
  sourceThreadId: input.sourceThreadId,
4301
+ threadName: this.thread.name || undefined,
4028
4302
  repliedMessage: input.repliedMessage,
4029
4303
  })
4030
4304
  // OpenCode's session.command API has no `system` field. Persist the
@@ -4042,6 +4316,7 @@ export class ThreadSessionRuntime {
4042
4316
  username: this.state?.sessionUsername || input.username,
4043
4317
  userId: this.state?.sessionUserId || input.userId,
4044
4318
  parentSessionId: this.state?.parentSessionId || input.parentSessionId,
4319
+ scheduledTask: await this.resolveScheduledTaskContext(session.id),
4045
4320
  })
4046
4321
  const systemWriteResult = await writeSessionSystemPrompt({
4047
4322
  sessionId: session.id,
@@ -4179,6 +4454,7 @@ export class ThreadSessionRuntime {
4179
4454
  username: this.state?.sessionUsername || input.username,
4180
4455
  userId: this.state?.sessionUserId || input.userId,
4181
4456
  parentSessionId: this.state?.parentSessionId || input.parentSessionId,
4457
+ scheduledTask: await this.resolveScheduledTaskContext(session.id),
4182
4458
  }),
4183
4459
  model: earlyModelParam,
4184
4460
  agent: earlyAgentPreference,
@@ -4219,6 +4495,52 @@ export class ThreadSessionRuntime {
4219
4495
  // ── Session Ensure ──────────────────────────────────────────
4220
4496
  // Creates or reuses the OpenCode session for this thread.
4221
4497
 
4498
+ /** Cached per-session scheduled task info for the system message. */
4499
+ private scheduledTaskContextCache = new Map<
4500
+ string,
4501
+ ScheduledTaskSystemContext | undefined
4502
+ >()
4503
+
4504
+ /**
4505
+ * Resolve the scheduled-task context for the system message, once per
4506
+ * session. The row in session_start_sources is immutable, so caching the
4507
+ * result keeps the system prompt identical across turns (prompt-cache safe).
4508
+ * One-shot 'at' tasks are deleted after their run, so only schedule_kind
4509
+ * survives for them.
4510
+ */
4511
+ private async resolveScheduledTaskContext(
4512
+ sessionId: string,
4513
+ ): Promise<ScheduledTaskSystemContext | undefined> {
4514
+ const cache = this.scheduledTaskContextCache
4515
+ if (cache.has(sessionId)) {
4516
+ return cache.get(sessionId)
4517
+ }
4518
+ const context = await (async (): Promise<
4519
+ ScheduledTaskSystemContext | undefined
4520
+ > => {
4521
+ const source = await getSessionStartSource({ sessionId })
4522
+ if (!source) {
4523
+ return undefined
4524
+ }
4525
+ const task = source.scheduled_task_id
4526
+ ? await getScheduledTask(source.scheduled_task_id)
4527
+ : null
4528
+ return {
4529
+ taskId: source.scheduled_task_id ?? undefined,
4530
+ scheduleKind: source.schedule_kind,
4531
+ cronExpr: task?.cron_expr,
4532
+ timezone: task?.timezone,
4533
+ }
4534
+ })().catch((error) => {
4535
+ logger.warn(
4536
+ `[SCHEDULED TASK CONTEXT] Failed to resolve for session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`,
4537
+ )
4538
+ return undefined
4539
+ })
4540
+ cache.set(sessionId, context)
4541
+ return context
4542
+ }
4543
+
4222
4544
  private async updateExistingSessionPermissions({
4223
4545
  client,
4224
4546
  sessionId,
@@ -4314,8 +4636,9 @@ export class ThreadSessionRuntime {
4314
4636
  } else if (sessionResponse.data) {
4315
4637
  session = sessionResponse.data
4316
4638
  } else {
4639
+ const sdkMessage = extractSdkErrorMessage(sessionResponse.error)
4317
4640
  logger.warn(
4318
- `[ENSURE SESSION] session.get returned no data for ${sessionId}, response=${JSON.stringify(sessionResponse)}`,
4641
+ `[ENSURE SESSION] session.get returned no data for ${sessionId}: ${sdkMessage}, response=${JSON.stringify(sessionResponse)}`,
4319
4642
  )
4320
4643
  }
4321
4644
  }
@@ -4346,35 +4669,37 @@ export class ThreadSessionRuntime {
4346
4669
  { cause: createResult },
4347
4670
  )
4348
4671
  }
4349
- session = createResult.data
4350
- if (!session) {
4351
- logger.warn(
4352
- `[ENSURE SESSION] session.create returned no data, threadId=${this.thread.id}, directory=${this.sdkDirectory}, response=${JSON.stringify(createResult)}`,
4672
+ if (createResult.error || !createResult.data) {
4673
+ const errorMessage = extractSdkErrorMessage(createResult.error)
4674
+ logger.error(
4675
+ `[ENSURE SESSION] session.create failed: ${errorMessage}, threadId=${this.thread.id}, directory=${this.sdkDirectory}, response=${JSON.stringify(createResult)}`,
4676
+ )
4677
+ return new Error(
4678
+ `Failed to create session: ${errorMessage}, threadId=${this.thread.id}, directory=${this.sdkDirectory}`,
4353
4679
  )
4354
4680
  }
4681
+ session = createResult.data
4355
4682
  // Insert DB row immediately so the external-sync poller sees
4356
4683
  // source='kimaki' before the next poll tick and skips this session.
4357
4684
  // The upsert at the end of ensureSession is kept for the reuse path.
4358
- if (session) {
4359
- await setThreadSession(this.thread.id, session.id)
4360
- if (injectionGuardPatterns?.length) {
4361
- writeInjectionGuardConfig({
4362
- sessionId: session.id,
4363
- scanPatterns: injectionGuardPatterns,
4364
- })
4365
- }
4366
- const worktree = await getThreadWorktreeOrWorkspace(this.thread.id)
4367
- trackEvent('session_created', {
4368
- has_worktree: Boolean(worktree),
4369
- source: sessionStartScheduleKind ? 'scheduled' : 'discord',
4685
+ await setThreadSession(this.thread.id, session.id)
4686
+ if (injectionGuardPatterns?.length) {
4687
+ writeInjectionGuardConfig({
4688
+ sessionId: session.id,
4689
+ scanPatterns: injectionGuardPatterns,
4370
4690
  })
4371
4691
  }
4692
+ const worktree = await getThreadWorktreeOrWorkspace(this.thread.id)
4693
+ trackEvent('session_created', {
4694
+ has_worktree: Boolean(worktree),
4695
+ source: sessionStartScheduleKind ? 'scheduled' : 'discord',
4696
+ })
4372
4697
  createdNewSession = true
4373
4698
  }
4374
4699
 
4375
4700
  if (!session) {
4376
4701
  return new Error(
4377
- `Failed to create or get session: threadId=${this.thread.id}, channelId=${this.channelId}, directory=${directory}, sdkDirectory=${this.sdkDirectory}, existingSessionId=${sessionId ?? 'none'}, createdNewSession=${createdNewSession}. session.create returned empty data, check the [ENSURE SESSION] warn log above for the full response body`,
4702
+ `Failed to create or get session: threadId=${this.thread.id}, channelId=${this.channelId}, directory=${directory}, sdkDirectory=${this.sdkDirectory}, existingSessionId=${sessionId ?? 'none'}, createdNewSession=${createdNewSession}`,
4378
4703
  )
4379
4704
  }
4380
4705
 
@@ -4443,7 +4768,7 @@ export class ThreadSessionRuntime {
4443
4768
  : ''
4444
4769
  const result = await sendThreadMessage(
4445
4770
  this.thread,
4446
- `*using ${modelLabel}${agentLabel}*`,
4771
+ asDiscordQuote(`*using ${modelLabel}${agentLabel}*`),
4447
4772
  { flags: SILENT_MESSAGE_FLAGS },
4448
4773
  ).catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
4449
4774
  if (result instanceof Error) {
@@ -4477,7 +4802,6 @@ export class ThreadSessionRuntime {
4477
4802
  elapsedMs < 1000
4478
4803
  ? '<1s'
4479
4804
  : prettyMilliseconds(elapsedMs, { secondsDecimalDigits: 0 })
4480
- const modelInfo = runInfo.model ? ` ⋅ ${runInfo.model}` : ''
4481
4805
  const agentInfo =
4482
4806
  runInfo.agent && runInfo.agent.toLowerCase() !== 'build'
4483
4807
  ? ` ⋅ **${runInfo.agent}**`
@@ -4494,7 +4818,7 @@ export class ThreadSessionRuntime {
4494
4818
  }).catch((e) => new FilesystemOperationError({ operation: 'gitBranch', cause: e })),
4495
4819
  (async () => {
4496
4820
  if (!client || !sessionId) {
4497
- return
4821
+ return []
4498
4822
  }
4499
4823
  let tokensUsed = runInfo.tokensUsed
4500
4824
  // Fetch final token count from API
@@ -4534,9 +4858,12 @@ export class ThreadSessionRuntime {
4534
4858
  })
4535
4859
  : undefined
4536
4860
 
4861
+ const providers = providersResult && !(providersResult instanceof Error)
4862
+ ? providersResult.data?.all ?? []
4863
+ : []
4537
4864
  let contextLimit = fallbackLimit
4538
- if (providersResult && !(providersResult instanceof Error)) {
4539
- const provider = providersResult.data?.all?.find((p) => {
4865
+ if (providers.length > 0) {
4866
+ const provider = providers.find((p) => {
4540
4867
  return p.id === runInfo.providerID
4541
4868
  })
4542
4869
  const model = provider?.models?.[runInfo.model || '']
@@ -4549,6 +4876,7 @@ export class ThreadSessionRuntime {
4549
4876
  )
4550
4877
  contextInfo = ` ⋅ ${percentage}%`
4551
4878
  }
4879
+ return providers
4552
4880
  })().catch((e) => new OpenCodeSdkError({ operation: 'resolveModelPreference', cause: e })),
4553
4881
  ])
4554
4882
  const branchName =
@@ -4559,6 +4887,19 @@ export class ThreadSessionRuntime {
4559
4887
  contextResult,
4560
4888
  )
4561
4889
  }
4890
+ const providers = contextResult instanceof Error ? [] : (contextResult ?? [])
4891
+ const modelLabel = runInfo.model
4892
+ ? displayedModelLabel({
4893
+ modelID: runInfo.model,
4894
+ name: await resolveDisplayedModelName({
4895
+ providers,
4896
+ providerID: runInfo.providerID,
4897
+ modelID: runInfo.model,
4898
+ sessionID: sessionId,
4899
+ }),
4900
+ })
4901
+ : undefined
4902
+ const modelInfo = modelLabel ? ` ⋅ ${modelLabel}` : ''
4562
4903
 
4563
4904
  const truncate = (s: string, max: number) => {
4564
4905
  return s.length > max ? s.slice(0, max - 1) + '\u2026' : s
@@ -4568,13 +4909,25 @@ export class ThreadSessionRuntime {
4568
4909
  const projectInfo = truncatedBranch
4569
4910
  ? `${truncatedFolder} ⋅ ${truncatedBranch} ⋅ `
4570
4911
  : `${truncatedFolder} ⋅ `
4571
- const footerText = `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*`
4912
+ const hasQueuedMessage = this.getQueueLength() > 0
4913
+ const didUseSleepTool = sessionId
4914
+ ? didLatestUserTurnUseSleepTool({ events: this.eventBuffer, sessionId })
4915
+ : false
4916
+ const shouldNotifyUser = !hasQueuedMessage && !didUseSleepTool
4917
+ const mentionUserId = store.getState().footerMentionsEnabled && shouldNotifyUser
4918
+ ? await resolveThreadFooterMentionUserId({
4919
+ sessionUserId: this.state?.sessionUserId,
4920
+ thread: this.thread,
4921
+ })
4922
+ : undefined
4923
+ const mention = mentionUserId ? ` <@${mentionUserId}>` : ''
4924
+ const footerText = asDiscordQuote(
4925
+ `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*${mention}`,
4926
+ )
4572
4927
  this.stopTyping()
4573
4928
 
4574
- // Skip notification if there's a queued message next — the user only
4575
- // needs to be notified when the entire queue finishes.
4576
4929
  await sendThreadMessage(this.thread, footerText, {
4577
- flags: this.getNotifyFlags(),
4930
+ flags: shouldNotifyUser ? NOTIFY_MESSAGE_FLAGS : SILENT_MESSAGE_FLAGS,
4578
4931
  })
4579
4932
  logger.log(
4580
4933
  `DURATION: Session completed in ${sessionDuration}, model ${runInfo.model}, tokens ${runInfo.tokensUsed}`,
@@ -4587,6 +4940,7 @@ export class ThreadSessionRuntime {
4587
4940
  this.modelContextLimitKey = undefined
4588
4941
  this.lastDisplayedContextPercentage = 0
4589
4942
  this.lastRateLimitDisplayTime = 0
4943
+ this.lastSentPartKind = undefined
4590
4944
  }
4591
4945
 
4592
4946
  // ── Retry Last User Prompt (for model-change flow) ──────────