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
@@ -6,6 +6,7 @@
6
6
  // call runtime APIs (enqueueIncoming, abortActiveRun, etc.) without inspecting
7
7
  // run internals.
8
8
  import crypto from 'node:crypto';
9
+ import { AsyncLocalStorage } from 'node:async_hooks';
9
10
  import { ChannelType } from 'discord.js';
10
11
  import path from 'node:path';
11
12
  import prettyMilliseconds from 'pretty-ms';
@@ -15,22 +16,24 @@ import { getOpencodeClient, initializeOpencodeForDirectory, buildSessionPermissi
15
16
  import { isAbortError } from '../utils.js';
16
17
  import { registerEventListener, unregisterEventListener, waitForGlobalEventListener, } from './global-event-listener.js';
17
18
  import { createLogger, LogPrefix } from '../logger.js';
18
- import { sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, } from '../discord-utils.js';
19
- import { formatPart, formatTaskToolTitle } from '../message-formatting.js';
20
- import { getChannelVerbosity, getPartMessageIds, getDb, setPartMessage, getThreadSession, setThreadSession, getThreadParentSessionId, setThreadParentSessionId, getThreadWorktreeOrWorkspace, setSessionAgent, clearSessionModel, getVariantCascade, setSessionStartSource, completeScheduledTaskRunsForSession, failScheduledTaskRunsForSession, startScheduledTaskRunSession, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, } from '../database.js';
19
+ import { sendThreadMessage, sendSessionPartMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, raceDiscordRename, DISCORD_THREAD_RENAME_TIMEOUT_MS, resolveThreadFooterMentionUserId, } from '../discord-utils.js';
20
+ import { asDiscordQuote, formatPart, formatTaskToolTitle, planAssistantTurnFlush, QUEUE_PREFIX, sessionPartKind, shouldLeadWithBlankLine, shouldQuoteIntermediateTextPart, STATUS_PREFIX, WORKTREE_PREFIX, } from '../message-formatting.js';
21
+ import { getChannelVerbosity, getPartMessageIds, getDb, setPartMessage, getThreadSession, setThreadSession, getThreadParentSessionId, setThreadParentSessionId, getThreadWorktreeOrWorkspace, setSessionAgent, setSessionModel, clearSessionModel, getVariantCascade, setSessionStartSource, getSessionStartSource, getScheduledTask, completeScheduledTaskRunsForSession, failScheduledTaskRunsForSession, startScheduledTaskRunSession, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, cancelSessionSleepForThread, } from '../database.js';
21
22
  import * as orm from 'drizzle-orm';
22
23
  import * as schema from '../schema.js';
23
24
  import { showPermissionButtons, addPermissionRequestToContext, arePatternsCoveredBy, pendingPermissionContexts, } from '../commands/permissions.js';
24
- import { showAskUserQuestionDropdowns, pendingQuestionContexts, cancelPendingQuestion, } from '../commands/ask-question.js';
25
+ import { showAskUserQuestionDropdowns, pendingQuestionContexts, cancelPendingQuestion, findPendingQuestionContextForRequest, } from '../commands/ask-question.js';
25
26
  import { showActionButtons, waitForQueuedActionButtonsRequest, pendingActionButtonContexts, cancelPendingActionButtons, } from '../commands/action-buttons.js';
26
27
  import { pendingFileUploadContexts, cancelPendingFileUpload, } from '../commands/file-upload.js';
27
28
  import { getCurrentModelInfo, ensureSessionPreferencesSnapshot, } from '../commands/model.js';
29
+ import { displayedModelLabel, resolveDisplayedModelName, validateModelId, } from './model-utils.js';
28
30
  import { getOpencodePromptContext, getOpencodeSystemMessage, writeSessionSystemPrompt, } from '../system-message.js';
29
31
  import { getDataDir } from '../config.js';
32
+ import { store } from '../store.js';
30
33
  import { trackEvent, } from '../analytics.js';
31
34
  import { resolveValidatedAgentPreference } from './agent-utils.js';
32
35
  import { appendOpencodeSessionEventLog, getOpencodeEventSessionId, isOpencodeSessionEventLogEnabled, } from './opencode-session-event-log.js';
33
- import { doesLatestUserTurnHaveNaturalCompletion, didQuestionQueueHandoffSinceLatestQuestionAsked, getAssistantMessageIdsForLatestUserTurn, getCurrentTurnStartTime, isSessionBusy, getLatestRunInfo, getIdleTokenUsageDelta, getDerivedSubtaskIndex, getDerivedSubtaskAgentType, getLatestAssistantMessageIdForLatestUserTurn, hasAssistantMessageCompletedBefore, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, } from './event-stream-state.js';
36
+ import { doesLatestUserTurnHaveNaturalCompletion, didLatestUserTurnUseSleepTool, didQuestionQueueHandoffSinceLatestQuestionAsked, deriveLatestUnansweredQuestion, getAssistantMessageIdsForLatestUserTurn, getCurrentTurnStartTime, isSessionBusy, getLatestRunInfo, getIdleTokenUsageDelta, getDerivedSubtaskIndex, getDerivedSubtaskAgentType, getTokenUsageSessionIdsForIdle, isDerivedChildSession, getLatestAssistantMessageIdForLatestUserTurn, getAssistantMessageKind, hasAssistantMessageCompletedBefore, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, shouldBufferSessionEvent, } from './event-stream-state.js';
34
37
  // Track multiple pending permissions per thread (keyed by permission ID).
35
38
  // OpenCode handles blocking/sequencing — we just need to track all pending
36
39
  // permissions to avoid duplicates and properly clean up on reply/teardown.
@@ -61,6 +64,48 @@ const shouldLogSessionEvents = process.env['KIMAKI_LOG_SESSION_EVENTS'] === '1'
61
64
  // Runtime instances are kept in a plain Map (not Zustand — the Map
62
65
  // is not reactive state, just a lookup for resource handles).
63
66
  const runtimes = new Map();
67
+ // Per-thread FIFO for Discord arrival order of one-shot slash calls vs messages.
68
+ // Covers /plan-agent (no prompt) and /foo-cmd /foo-skill. OpenCode already
69
+ // queues promptAsync. /model, /agent, and /compact are not on this queue.
70
+ const threadIngressChains = new Map();
71
+ const threadIngressSlotAls = new AsyncLocalStorage();
72
+ export function reserveThreadIngress(threadId) {
73
+ const previous = threadIngressChains.get(threadId) ?? Promise.resolve();
74
+ let released = false;
75
+ let releaseHeld = () => { };
76
+ const held = new Promise((resolve) => {
77
+ releaseHeld = resolve;
78
+ });
79
+ threadIngressChains.set(threadId, previous.then(() => held));
80
+ return {
81
+ wait: previous,
82
+ release: () => {
83
+ if (released) {
84
+ return;
85
+ }
86
+ released = true;
87
+ releaseHeld();
88
+ },
89
+ };
90
+ }
91
+ export async function runInThreadIngressSlot(slot, run) {
92
+ try {
93
+ return await threadIngressSlotAls.run(slot, run);
94
+ }
95
+ finally {
96
+ slot?.release();
97
+ }
98
+ }
99
+ export async function waitForCurrentThreadIngress() {
100
+ const slot = threadIngressSlotAls.getStore();
101
+ if (!slot) {
102
+ return;
103
+ }
104
+ await slot.wait;
105
+ }
106
+ export function releaseCurrentThreadIngress() {
107
+ threadIngressSlotAls.getStore()?.release();
108
+ }
64
109
  export function getRuntime(threadId) {
65
110
  return runtimes.get(threadId);
66
111
  }
@@ -70,9 +115,15 @@ export function getOrCreateRuntime(opts) {
70
115
  if (existing.sdkDirectory !== opts.sdkDirectory) {
71
116
  logger.warn(`[RUNTIME] Ignoring sdkDirectory change for existing thread ${opts.threadId}: ${existing.sdkDirectory} → ${opts.sdkDirectory}`);
72
117
  }
118
+ if (opts.sessionId && !existing.state?.sessionId) {
119
+ threadState.setSessionId(opts.threadId, opts.sessionId);
120
+ }
73
121
  return existing;
74
122
  }
75
123
  threadState.ensureThread(opts.threadId); // add to global store
124
+ if (opts.sessionId) {
125
+ threadState.setSessionId(opts.threadId, opts.sessionId);
126
+ }
76
127
  const runtime = new ThreadSessionRuntime(opts);
77
128
  runtimes.set(opts.threadId, runtime);
78
129
  return runtime;
@@ -85,6 +136,7 @@ export function disposeRuntime(threadId) {
85
136
  runtime.dispose();
86
137
  runtimes.delete(threadId);
87
138
  threadState.removeThread(threadId); // remove from global store
139
+ threadIngressChains.delete(threadId);
88
140
  }
89
141
  export function disposeRuntimesForDirectory({ directory, channelId, }) {
90
142
  let count = 0;
@@ -98,6 +150,7 @@ export function disposeRuntimesForDirectory({ directory, channelId, }) {
98
150
  runtime.dispose();
99
151
  runtimes.delete(threadId);
100
152
  threadState.removeThread(threadId);
153
+ threadIngressChains.delete(threadId);
101
154
  count++;
102
155
  }
103
156
  return count;
@@ -116,6 +169,7 @@ export function disposeInactiveRuntimes({ idleMs, nowMs = Date.now(), }) {
116
169
  runtime.dispose();
117
170
  runtimes.delete(threadId);
118
171
  threadState.removeThread(threadId);
172
+ threadIngressChains.delete(threadId);
119
173
  disposedThreadIds.push(threadId);
120
174
  disposedDirectories.add(runtime.projectDirectory);
121
175
  }
@@ -227,26 +281,37 @@ export function isEssentialToolPart(part) {
227
281
  }
228
282
  // ── Thread title derivation ──────────────────────────────────────
229
283
  const DISCORD_THREAD_NAME_MAX = 100;
230
- const WORKTREE_THREAD_PREFIX = '⬦ ';
231
- // Prefixes that should survive OpenCode session title renames.
232
- // When a thread starts with one of these, the rename preserves it.
233
284
  const PRESERVED_THREAD_PREFIXES = [
234
- WORKTREE_THREAD_PREFIX,
285
+ WORKTREE_PREFIX,
235
286
  'btw: ',
236
287
  'Fork: ',
237
288
  ];
289
+ function stripPreservedThreadPrefix(name) {
290
+ const matchedPrefix = PRESERVED_THREAD_PREFIXES.find((prefix) => {
291
+ return name.startsWith(prefix);
292
+ });
293
+ if (!matchedPrefix)
294
+ return name;
295
+ return name.slice(matchedPrefix.length).trim();
296
+ }
238
297
  function getThreadNameCandidateFromSessionTitle({ sessionTitle, currentName, }) {
239
- const trimmed = sessionTitle?.trim();
298
+ const trimmed = sessionTitle
299
+ ?.replace(/<\/?callout\b[^>]*>/gi, '')
300
+ .trim();
240
301
  if (!trimmed) {
241
302
  return null;
242
303
  }
243
- if (/^new session\s*-/i.test(trimmed)) {
304
+ const withoutCopiedPrefix = stripPreservedThreadPrefix(trimmed);
305
+ if (!withoutCopiedPrefix) {
306
+ return null;
307
+ }
308
+ if (/^new session\s*-/i.test(withoutCopiedPrefix)) {
244
309
  return null;
245
310
  }
246
311
  const matchedPrefix = PRESERVED_THREAD_PREFIXES.find((p) => {
247
312
  return currentName.startsWith(p);
248
313
  }) ?? '';
249
- return `${matchedPrefix}${trimmed}`.slice(0, DISCORD_THREAD_NAME_MAX);
314
+ return `${matchedPrefix}${withoutCopiedPrefix}`.slice(0, DISCORD_THREAD_NAME_MAX);
250
315
  }
251
316
  export function deriveThreadNameFromSessionTitle({ sessionTitle, currentName, }) {
252
317
  const candidate = getThreadNameCandidateFromSessionTitle({
@@ -261,34 +326,6 @@ export function deriveThreadNameFromSessionTitle({ sessionTitle, currentName, })
261
326
  }
262
327
  return candidate;
263
328
  }
264
- export function deriveThreadRenameFromSessionUpdate({ sessionTitle, currentName, lastSyncedName, }) {
265
- if (lastSyncedName !== null && currentName !== lastSyncedName) {
266
- return {
267
- desiredName: null,
268
- nextSyncedName: lastSyncedName,
269
- };
270
- }
271
- const candidate = getThreadNameCandidateFromSessionTitle({
272
- sessionTitle,
273
- currentName,
274
- });
275
- if (candidate === null) {
276
- return {
277
- desiredName: null,
278
- nextSyncedName: lastSyncedName,
279
- };
280
- }
281
- if (candidate === currentName) {
282
- return {
283
- desiredName: null,
284
- nextSyncedName: currentName,
285
- };
286
- }
287
- return {
288
- desiredName: candidate,
289
- nextSyncedName: candidate,
290
- };
291
- }
292
329
  function resolveTurnSource(input) {
293
330
  if (input.analyticsSource)
294
331
  return input.analyticsSource;
@@ -351,26 +388,23 @@ export class ThreadSessionRuntime {
351
388
  // message and showing multiple back-to-back POSTs is wasteful.
352
389
  typingKeepaliveTimeout = null;
353
390
  typingRepulseDebounce;
391
+ deferredQuestionShow;
354
392
  static TYPING_REPULSE_DEBOUNCE_MS = 500;
393
+ static DEFERRED_QUESTION_SHOW_MS = 1000;
355
394
  // Notification throttles for retry/context notices.
356
395
  lastDisplayedContextPercentage = 0;
357
396
  lastRateLimitDisplayTime = 0;
358
- // Last OpenCode-generated session title we successfully applied to the
359
- // Discord thread name. Used to dedupe repeated session.updated events so
360
- // we only call thread.setName() once per distinct title. Discord rate-limits
361
- // channel/thread renames to ~2 per 10 minutes per thread, so we must avoid
362
- // retrying. Not persisted — worst case on restart we re-apply the same title
363
- // once (which is a no-op via deriveThreadNameFromSessionTitle).
397
+ // Last OpenCode session title we applied to Discord. Dedupes session.updated
398
+ // so we only call setName once per distinct title. Not persisted.
364
399
  appliedOpencodeTitle;
365
- // Last Discord thread name known to match the OpenCode title. Persisted so a
366
- // user rename is still respected after Kimaki restarts.
367
- lastSyncedThreadName;
368
400
  // Part output buffering (write-side cache, not domain state)
369
401
  partBuffer = new Map();
402
+ shownQuestionRequestIds = new Set();
370
403
  // Derivable cache (perf optimization for provider.list API call)
371
404
  modelContextLimit;
372
405
  modelContextLimitKey;
373
406
  lastPromptWorktreeKey;
407
+ lastSentPartKind;
374
408
  // Bounded buffer of recent SSE events with timestamps.
375
409
  // Used by waitForEvent() to scan for specific events that arrived
376
410
  // after a given point in time (e.g. wait for session.idle after abort).
@@ -432,6 +466,17 @@ export class ThreadSessionRuntime {
432
466
  this.restartTypingKeepalive({ sendNow: true });
433
467
  },
434
468
  });
469
+ this.deferredQuestionShow = createDebouncedTimeout({
470
+ delayMs: ThreadSessionRuntime.DEFERRED_QUESTION_SHOW_MS,
471
+ callback: () => {
472
+ if (this.disposed) {
473
+ return;
474
+ }
475
+ void this.dispatchAction(async () => {
476
+ await this.tryShowPendingQuestion({ ignoreUnfinishedText: true });
477
+ });
478
+ },
479
+ });
435
480
  }
436
481
  consumeWorktreePromptChange(worktree) {
437
482
  const nextKey = getWorktreePromptKey(worktree);
@@ -579,6 +624,42 @@ export class ThreadSessionRuntime {
579
624
  }
580
625
  return isSessionBusy({ events: this.eventBuffer, sessionId });
581
626
  }
627
+ async persistIngressVariant({ sessionId, channelId, appId, agentPreference, getClient, variant, }) {
628
+ if (!variant)
629
+ return;
630
+ if (getClient instanceof Error)
631
+ return;
632
+ const variantModelInfo = await getCurrentModelInfo({
633
+ sessionId,
634
+ channelId,
635
+ appId,
636
+ agentPreference,
637
+ getClient,
638
+ directory: this.sdkDirectory,
639
+ });
640
+ if (variantModelInfo.type === 'none')
641
+ return;
642
+ const providersResponse = await getClient()
643
+ .provider.list({ directory: this.sdkDirectory })
644
+ .catch((e) => new OpenCodeSdkError({ operation: 'provider.list', cause: e }));
645
+ if (providersResponse instanceof Error || !providersResponse.data)
646
+ return;
647
+ const matchedVariant = matchThinkingValue({
648
+ requestedValue: variant,
649
+ availableValues: getThinkingValuesForModel({
650
+ providers: providersResponse.data.all,
651
+ providerId: variantModelInfo.providerID,
652
+ modelId: variantModelInfo.modelID,
653
+ }),
654
+ });
655
+ if (!matchedVariant)
656
+ return;
657
+ await setSessionModel({
658
+ sessionId,
659
+ modelId: variantModelInfo.model,
660
+ variant: matchedVariant,
661
+ });
662
+ }
582
663
  getAssistantMessageIdsForCurrentTurn({ sessionId, upToIndex, }) {
583
664
  const normalizedIndex = upToIndex === undefined ? undefined : upToIndex - 1;
584
665
  return getAssistantMessageIdsForLatestUserTurn({
@@ -600,20 +681,24 @@ export class ThreadSessionRuntime {
600
681
  if (!mainSessionId || candidateSessionId === mainSessionId) {
601
682
  return undefined;
602
683
  }
603
- const subtaskIndex = getDerivedSubtaskIndex({
684
+ if (!isDerivedChildSession({
604
685
  events: this.eventBuffer,
605
686
  mainSessionId,
606
687
  candidateSessionId,
607
- });
608
- if (!subtaskIndex) {
688
+ })) {
609
689
  return undefined;
610
690
  }
691
+ const subtaskIndex = getDerivedSubtaskIndex({
692
+ events: this.eventBuffer,
693
+ mainSessionId,
694
+ candidateSessionId,
695
+ });
611
696
  const agentType = getDerivedSubtaskAgentType({
612
697
  events: this.eventBuffer,
613
698
  mainSessionId,
614
699
  candidateSessionId,
615
700
  });
616
- const label = `${agentType || 'task'}-${subtaskIndex}`;
701
+ const label = `${agentType || 'task'}-${subtaskIndex || 1}`;
617
702
  const assistantMessageId = this.getLatestAssistantMessageIdForCurrentTurn({
618
703
  sessionId: candidateSessionId,
619
704
  });
@@ -624,12 +709,14 @@ export class ThreadSessionRuntime {
624
709
  this.disposed = true;
625
710
  unregisterEventListener(this.threadId);
626
711
  void this.persistEventBufferDebounced.dispose();
712
+ this.deferredQuestionShow.clear();
627
713
  this.stopTyping();
628
714
  // Release large internal buffers so GC can reclaim memory immediately
629
715
  // instead of waiting for the runtime object itself to become unreachable.
630
716
  this.eventBuffer = [];
631
717
  this.nextEventIndex = 0;
632
718
  this.partBuffer.clear();
719
+ this.shownQuestionRequestIds.clear();
633
720
  this.preprocessChain = Promise.resolve();
634
721
  // Don't clear actionQueue here — queued closures own resolve/reject for
635
722
  // dispatchAction() promises. Dropping them would leave awaiting callers
@@ -719,7 +806,6 @@ export class ThreadSessionRuntime {
719
806
  })
720
807
  : [];
721
808
  delete info.system;
722
- delete info.summary;
723
809
  delete info.tools;
724
810
  delete info.parts;
725
811
  if (partsSummary.length > 0) {
@@ -870,10 +956,14 @@ export class ThreadSessionRuntime {
870
956
  // Global events (tui.toast.show) bypass the guard.
871
957
  // Subtask sessions also bypass — they're tracked in subtaskSessions.
872
958
  async handleEvent(event) {
873
- // session.diff can carry repeated full-file before/after snapshots and is
874
- // not used by event-derived runtime state, queueing, typing, or UI routing.
875
- // Drop it at ingress so large diff payloads never hit memory buffers.
876
- if (event.type === 'session.diff') {
959
+ const sessionId = this.state?.sessionId;
960
+ if (!shouldBufferSessionEvent({
961
+ event,
962
+ mainSessionId: sessionId,
963
+ isKnownChildSession: (candidateSessionId) => {
964
+ return Boolean(this.getSubtaskInfoForSession(candidateSessionId));
965
+ },
966
+ })) {
877
967
  return;
878
968
  }
879
969
  // Skip message.part.delta from the event buffer — no derivation function
@@ -886,7 +976,6 @@ export class ThreadSessionRuntime {
886
976
  if (event.type !== 'message.part.delta') {
887
977
  this.appendEventToBuffer(event);
888
978
  }
889
- const sessionId = this.state?.sessionId;
890
979
  const eventSessionId = getOpencodeEventSessionId(event);
891
980
  const toastSessionId = event.type === 'tui.toast.show'
892
981
  ? extractToastSessionId({ message: event.properties.message })
@@ -1180,22 +1269,58 @@ export class ThreadSessionRuntime {
1180
1269
  return part.type === 'step-finish';
1181
1270
  });
1182
1271
  }
1183
- shouldSendPart({ part, force, }) {
1272
+ shouldSendPlannedPart({ part, mode, }) {
1184
1273
  if (part.type === 'step-start' || part.type === 'step-finish') {
1185
1274
  return false;
1186
1275
  }
1187
1276
  if (part.type === 'tool' && part.state.status === 'pending') {
1188
1277
  return false;
1189
1278
  }
1190
- if (!force && part.type === 'text' && !part.time?.end) {
1191
- return false;
1192
- }
1193
- if (!force && part.type === 'tool' && part.state.status === 'completed') {
1279
+ if (part.type === 'text' && !part.time?.end && mode === 'progress') {
1194
1280
  return false;
1195
1281
  }
1196
1282
  return true;
1197
1283
  }
1198
- async sendPartMessage({ part, repulseTyping = true, }) {
1284
+ getCurrentTurnParts() {
1285
+ const sessionId = this.state?.sessionId;
1286
+ const messageIds = sessionId
1287
+ ? [...this.getAssistantMessageIdsForCurrentTurn({ sessionId })]
1288
+ : [];
1289
+ if (messageIds.length > 0) {
1290
+ return messageIds.flatMap((id) => this.getBufferedParts(id));
1291
+ }
1292
+ return [...this.partBuffer.keys()].flatMap((id) => this.getBufferedParts(id));
1293
+ }
1294
+ async flushCurrentTurnParts({ mode, throughPartId, skipPartId, repulseTyping = true, }) {
1295
+ const parts = this.getCurrentTurnParts().filter((part) => {
1296
+ return !this.state?.sentPartIds.has(part.id);
1297
+ });
1298
+ const planned = planAssistantTurnFlush({
1299
+ parts,
1300
+ mode,
1301
+ throughPartId,
1302
+ });
1303
+ for (const { part, quoteText } of planned.sendParts) {
1304
+ if (skipPartId && part.id === skipPartId) {
1305
+ continue;
1306
+ }
1307
+ if (!this.shouldSendPlannedPart({ part, mode })) {
1308
+ continue;
1309
+ }
1310
+ if (part.type === 'tool' && part.tool === 'task') {
1311
+ continue;
1312
+ }
1313
+ const pulseTyping = part.type === 'text' && part.ignored === true
1314
+ ? false
1315
+ : repulseTyping;
1316
+ await this.sendPartMessage({
1317
+ part,
1318
+ quoteText,
1319
+ repulseTyping: pulseTyping,
1320
+ });
1321
+ }
1322
+ }
1323
+ async sendPartMessage({ part, repulseTyping = true, quoteText = false, }) {
1199
1324
  const verbosity = await this.getVerbosity();
1200
1325
  if (verbosity === 'text_only' && part.type !== 'text') {
1201
1326
  return;
@@ -1205,7 +1330,13 @@ export class ThreadSessionRuntime {
1205
1330
  return;
1206
1331
  }
1207
1332
  }
1208
- const content = formatPart(part);
1333
+ const formatted = formatPart(part);
1334
+ const quote = quoteText
1335
+ && shouldQuoteIntermediateTextPart({
1336
+ part,
1337
+ isLastInTurn: false,
1338
+ });
1339
+ const content = quote ? asDiscordQuote(formatted) : formatted;
1209
1340
  if (!content.trim() || content.length === 0) {
1210
1341
  return;
1211
1342
  }
@@ -1219,7 +1350,13 @@ export class ThreadSessionRuntime {
1219
1350
  newIds.add(part.id);
1220
1351
  return { ...t, sentPartIds: newIds };
1221
1352
  });
1222
- const sendResult = await sendThreadMessage(this.thread, content)
1353
+ const kind = sessionPartKind(part);
1354
+ const sendResult = await sendSessionPartMessage(this.thread, content, {
1355
+ leadWithBlankLine: shouldLeadWithBlankLine({
1356
+ previousKind: this.lastSentPartKind,
1357
+ nextKind: kind,
1358
+ }),
1359
+ })
1223
1360
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
1224
1361
  if (sendResult instanceof Error) {
1225
1362
  threadState.updateThread(this.threadId, (t) => {
@@ -1230,66 +1367,19 @@ export class ThreadSessionRuntime {
1230
1367
  discordLogger.error(`ERROR: Failed to send part ${part.id}:`, sendResult);
1231
1368
  return;
1232
1369
  }
1370
+ this.lastSentPartKind = kind;
1233
1371
  await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
1234
1372
  if (repulseTyping) {
1235
1373
  this.requestTypingRepulse();
1236
1374
  }
1237
1375
  }
1238
- async flushBufferedParts({ messageID, force, skipPartId, repulseTyping = true, }) {
1239
- if (!messageID) {
1240
- return;
1241
- }
1242
- const parts = this.getBufferedParts(messageID);
1243
- for (const part of parts) {
1244
- if (skipPartId && part.id === skipPartId) {
1245
- continue;
1246
- }
1247
- if (!this.shouldSendPart({ part, force })) {
1248
- continue;
1249
- }
1250
- await this.sendPartMessage({ part, repulseTyping });
1251
- }
1252
- }
1253
- async flushBufferedPartsForMessages({ messageIDs, force, skipPartId, repulseTyping = true, }) {
1254
- const uniqueMessageIDs = [...new Set(messageIDs)];
1255
- for (const messageID of uniqueMessageIDs) {
1256
- await this.flushBufferedParts({
1257
- messageID,
1258
- force,
1259
- skipPartId,
1260
- repulseTyping,
1261
- });
1262
- }
1263
- }
1264
- async showInteractiveUi({ skipPartId, flushMessageId, show, }) {
1376
+ async showInteractiveUi({ skipPartId, show, }) {
1265
1377
  this.stopTyping();
1266
- const sessionId = this.state?.sessionId;
1267
- const targetMessageId = (() => {
1268
- if (flushMessageId) {
1269
- return flushMessageId;
1270
- }
1271
- if (!sessionId) {
1272
- return undefined;
1273
- }
1274
- return this.getLatestAssistantMessageIdForCurrentTurn({ sessionId });
1275
- })();
1276
- if (targetMessageId) {
1277
- await this.flushBufferedParts({
1278
- messageID: targetMessageId,
1279
- force: true,
1280
- skipPartId,
1281
- });
1282
- }
1283
- else {
1284
- const assistantMessageIds = sessionId
1285
- ? [...this.getAssistantMessageIdsForCurrentTurn({ sessionId })]
1286
- : [];
1287
- await this.flushBufferedPartsForMessages({
1288
- messageIDs: assistantMessageIds,
1289
- force: true,
1290
- skipPartId,
1291
- });
1292
- }
1378
+ await this.flushCurrentTurnParts({
1379
+ mode: 'interactive',
1380
+ throughPartId: skipPartId,
1381
+ skipPartId,
1382
+ });
1293
1383
  await show();
1294
1384
  }
1295
1385
  async ensureModelContextLimit({ providerID, modelID, }) {
@@ -1325,10 +1415,21 @@ export class ThreadSessionRuntime {
1325
1415
  // These operate on runtime instance state + global store transitions.
1326
1416
  async handleMessageUpdated(msg) {
1327
1417
  const sessionId = this.state?.sessionId;
1328
- if (msg.sessionID !== sessionId) {
1418
+ if (msg.role !== 'assistant') {
1329
1419
  return;
1330
1420
  }
1331
- if (msg.role !== 'assistant') {
1421
+ if (msg.summary === true) {
1422
+ this.clearBufferedPartsForMessages([msg.id]);
1423
+ logger.info(`[SKIP] message.updated for compaction summary ${msg.id}`);
1424
+ return;
1425
+ }
1426
+ if (msg.sessionID !== sessionId) {
1427
+ const subtaskInfo = this.getSubtaskInfoForSession(msg.sessionID);
1428
+ if (subtaskInfo) {
1429
+ for (const part of this.getBufferedParts(msg.id)) {
1430
+ await this.handleSubtaskPart(part, subtaskInfo);
1431
+ }
1432
+ }
1332
1433
  return;
1333
1434
  }
1334
1435
  if (!sessionId) {
@@ -1339,6 +1440,7 @@ export class ThreadSessionRuntime {
1339
1440
  sessionId,
1340
1441
  messageId: msg.id,
1341
1442
  })) {
1443
+ this.clearBufferedPartsForMessages([msg.id]);
1342
1444
  logger.info(`[SKIP] message.updated for old assistant message ${msg.id}, not in latest user turn`);
1343
1445
  return;
1344
1446
  }
@@ -1366,9 +1468,8 @@ export class ThreadSessionRuntime {
1366
1468
  this.storePart(part);
1367
1469
  });
1368
1470
  }
1369
- await this.flushBufferedParts({
1370
- messageID: msg.id,
1371
- force: false,
1471
+ await this.flushCurrentTurnParts({
1472
+ mode: 'progress',
1372
1473
  });
1373
1474
  const wasAlreadyCompleted = hasAssistantMessageCompletedBefore({
1374
1475
  events: this.eventBuffer,
@@ -1417,7 +1518,7 @@ export class ThreadSessionRuntime {
1417
1518
  return;
1418
1519
  }
1419
1520
  this.lastDisplayedContextPercentage = thresholdCrossed;
1420
- const chunk = `⬦ context usage ${currentPercentage}%`;
1521
+ const chunk = `${STATUS_PREFIX}context usage ${currentPercentage}%`;
1421
1522
  const sendResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
1422
1523
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
1423
1524
  if (sendResult instanceof Error) {
@@ -1425,8 +1526,21 @@ export class ThreadSessionRuntime {
1425
1526
  }
1426
1527
  }
1427
1528
  async handlePartUpdated(part) {
1428
- this.storePart(part);
1429
1529
  const sessionId = this.state?.sessionId;
1530
+ const messageKind = getAssistantMessageKind({
1531
+ events: this.eventBuffer,
1532
+ sessionId: part.sessionID,
1533
+ messageId: part.messageID,
1534
+ });
1535
+ if (messageKind === 'summary') {
1536
+ this.clearBufferedPartsForMessages([part.messageID]);
1537
+ logger.info(`[SKIP] message.part.updated for compaction summary ${part.messageID}`);
1538
+ return;
1539
+ }
1540
+ this.storePart(part);
1541
+ if (messageKind === 'unknown') {
1542
+ return;
1543
+ }
1430
1544
  const subtaskInfo = this.getSubtaskInfoForSession(part.sessionID);
1431
1545
  const isSubtaskEvent = Boolean(subtaskInfo);
1432
1546
  if (part.sessionID !== sessionId && !isSubtaskEvent) {
@@ -1444,42 +1558,51 @@ export class ThreadSessionRuntime {
1444
1558
  this.ensureTypingNow();
1445
1559
  return;
1446
1560
  }
1447
- if (part.type === 'tool' &&
1448
- part.tool === 'task' &&
1449
- !this.state?.sentPartIds.has(part.id)) {
1450
- const taskDisplay = formatTaskToolTitle(part);
1451
- if (taskDisplay && (await this.getVerbosity()) !== 'text_only') {
1452
- await this.flushBufferedParts({
1453
- messageID: part.messageID,
1454
- force: true,
1455
- skipPartId: part.id,
1456
- });
1457
- threadState.updateThread(this.threadId, (t) => {
1458
- const newIds = new Set(t.sentPartIds);
1459
- newIds.add(part.id);
1460
- return { ...t, sentPartIds: newIds };
1461
- });
1462
- const sendResult = await sendThreadMessage(this.thread, taskDisplay + '\n\n')
1463
- .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
1464
- if (sendResult instanceof Error) {
1561
+ if (part.type === 'tool' && part.state.status === 'running') {
1562
+ await this.flushCurrentTurnParts({
1563
+ mode: 'progress',
1564
+ });
1565
+ const unsent = this.getCurrentTurnParts().filter((candidate) => {
1566
+ return !this.state?.sentPartIds.has(candidate.id);
1567
+ });
1568
+ const held = planAssistantTurnFlush({
1569
+ parts: unsent,
1570
+ mode: 'progress',
1571
+ }).hold.some((entry) => entry.id === part.id);
1572
+ if (held) {
1573
+ return;
1574
+ }
1575
+ if (!this.state?.sentPartIds.has(part.id) && part.tool !== 'task') {
1576
+ await this.sendPartMessage({ part });
1577
+ }
1578
+ if (part.tool === 'task' && !this.state?.sentPartIds.has(part.id)) {
1579
+ const taskDisplay = formatTaskToolTitle(part);
1580
+ if (taskDisplay && (await this.getVerbosity()) !== 'text_only') {
1465
1581
  threadState.updateThread(this.threadId, (t) => {
1466
1582
  const newIds = new Set(t.sentPartIds);
1467
- newIds.delete(part.id);
1583
+ newIds.add(part.id);
1468
1584
  return { ...t, sentPartIds: newIds };
1469
1585
  });
1470
- discordLogger.error(`ERROR: Failed to send task part ${part.id}:`, sendResult);
1471
- return;
1586
+ const sendResult = await sendSessionPartMessage(this.thread, taskDisplay, {
1587
+ leadWithBlankLine: shouldLeadWithBlankLine({
1588
+ previousKind: this.lastSentPartKind,
1589
+ nextKind: 'tool',
1590
+ }),
1591
+ })
1592
+ .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
1593
+ if (sendResult instanceof Error) {
1594
+ threadState.updateThread(this.threadId, (t) => {
1595
+ const newIds = new Set(t.sentPartIds);
1596
+ newIds.delete(part.id);
1597
+ return { ...t, sentPartIds: newIds };
1598
+ });
1599
+ discordLogger.error(`ERROR: Failed to send task part ${part.id}:`, sendResult);
1600
+ return;
1601
+ }
1602
+ this.lastSentPartKind = 'tool';
1603
+ await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
1472
1604
  }
1473
- await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
1474
1605
  }
1475
- }
1476
- if (part.type === 'tool' && part.state.status === 'running') {
1477
- await this.flushBufferedParts({
1478
- messageID: part.messageID,
1479
- force: true,
1480
- skipPartId: part.id,
1481
- });
1482
- await this.sendPartMessage({ part });
1483
1606
  return;
1484
1607
  }
1485
1608
  // Action buttons tool handler
@@ -1575,7 +1698,7 @@ export class ThreadSessionRuntime {
1575
1698
  }
1576
1699
  return ` (${pct.toFixed(1)}%)`;
1577
1700
  })();
1578
- const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`;
1701
+ const chunk = `${STATUS_PREFIX}${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`;
1579
1702
  const largeOutputResult = await this.thread.send({
1580
1703
  content: chunk,
1581
1704
  flags: SILENT_MESSAGE_FLAGS,
@@ -1587,18 +1710,17 @@ export class ThreadSessionRuntime {
1587
1710
  }
1588
1711
  }
1589
1712
  if (part.type === 'reasoning') {
1590
- await this.sendPartMessage({ part });
1713
+ await this.flushCurrentTurnParts({ mode: 'progress' });
1591
1714
  return;
1592
1715
  }
1593
- if (part.type === 'text' && part.time?.end) {
1594
- await this.sendPartMessage({ part });
1716
+ if (part.type === 'text') {
1717
+ await this.flushCurrentTurnParts({ mode: 'progress' });
1718
+ if (part.time?.end) {
1719
+ await this.tryShowPendingQuestion();
1720
+ }
1595
1721
  return;
1596
1722
  }
1597
1723
  if (part.type === 'step-finish') {
1598
- await this.flushBufferedParts({
1599
- messageID: part.messageID,
1600
- force: true,
1601
- });
1602
1724
  this.ensureTypingKeepalive();
1603
1725
  }
1604
1726
  }
@@ -1629,12 +1751,19 @@ export class ThreadSessionRuntime {
1629
1751
  if (!content.trim() || this.state?.sentPartIds.has(part.id)) {
1630
1752
  return;
1631
1753
  }
1632
- const sendResult = await sendThreadMessage(this.thread, content + '\n\n')
1754
+ const kind = sessionPartKind(part);
1755
+ const sendResult = await sendSessionPartMessage(this.thread, content, {
1756
+ leadWithBlankLine: shouldLeadWithBlankLine({
1757
+ previousKind: this.lastSentPartKind,
1758
+ nextKind: kind,
1759
+ }),
1760
+ })
1633
1761
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
1634
1762
  if (sendResult instanceof Error) {
1635
1763
  discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, sendResult);
1636
1764
  return;
1637
1765
  }
1766
+ this.lastSentPartKind = kind;
1638
1767
  threadState.updateThread(this.threadId, (t) => {
1639
1768
  const newIds = new Set(t.sentPartIds);
1640
1769
  newIds.add(part.id);
@@ -1643,21 +1772,10 @@ export class ThreadSessionRuntime {
1643
1772
  await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
1644
1773
  this.requestTypingRepulse();
1645
1774
  }
1646
- trackIdleTokenUsage(idleSessionId) {
1647
- let idleEventIndex;
1648
- for (let i = this.eventBuffer.length - 1; i >= 0; i--) {
1649
- const event = this.eventBuffer[i]?.event;
1650
- if (event?.type === 'session.idle' && event.properties.sessionID === idleSessionId) {
1651
- idleEventIndex = i;
1652
- break;
1653
- }
1654
- }
1655
- if (idleEventIndex === undefined) {
1656
- return;
1657
- }
1775
+ trackIdleTokenUsage({ sessionId, idleEventIndex, }) {
1658
1776
  const usage = getIdleTokenUsageDelta({
1659
1777
  events: this.eventBuffer,
1660
- sessionId: idleSessionId,
1778
+ sessionId,
1661
1779
  idleEventIndex,
1662
1780
  });
1663
1781
  if (!usage) {
@@ -1672,7 +1790,7 @@ export class ThreadSessionRuntime {
1672
1790
  tokens_total: usage.total,
1673
1791
  cost: usage.cost,
1674
1792
  assistant_message_count: usage.assistantMessageCount,
1675
- is_subagent: Boolean(this.getSubtaskInfoForSession(idleSessionId)),
1793
+ is_subagent: Boolean(this.getSubtaskInfoForSession(sessionId)),
1676
1794
  };
1677
1795
  if (usage.model) {
1678
1796
  properties.model = usage.model;
@@ -1682,8 +1800,36 @@ export class ThreadSessionRuntime {
1682
1800
  }
1683
1801
  trackEvent('tokens_used', properties);
1684
1802
  }
1803
+ trackIdleTokenUsageForSessionTree(idleSessionId) {
1804
+ let idleEventIndex;
1805
+ for (let i = this.eventBuffer.length - 1; i >= 0; i--) {
1806
+ const event = this.eventBuffer[i]?.event;
1807
+ if (event?.type === 'session.idle' && event.properties.sessionID === idleSessionId) {
1808
+ idleEventIndex = i;
1809
+ break;
1810
+ }
1811
+ }
1812
+ if (idleEventIndex === undefined) {
1813
+ return;
1814
+ }
1815
+ const mainSessionId = this.state?.sessionId;
1816
+ const sessionIds = mainSessionId
1817
+ ? getTokenUsageSessionIdsForIdle({
1818
+ events: this.eventBuffer,
1819
+ mainSessionId,
1820
+ idleSessionId,
1821
+ upToIndex: idleEventIndex,
1822
+ })
1823
+ : [idleSessionId];
1824
+ for (const sessionId of sessionIds) {
1825
+ this.trackIdleTokenUsage({
1826
+ sessionId,
1827
+ idleEventIndex,
1828
+ });
1829
+ }
1830
+ }
1685
1831
  async handleSessionIdle(idleSessionId) {
1686
- this.trackIdleTokenUsage(idleSessionId);
1832
+ this.trackIdleTokenUsageForSessionTree(idleSessionId);
1687
1833
  const sessionId = this.state?.sessionId;
1688
1834
  // ── Subtask idle ──────────────────────────────────────────
1689
1835
  const subtask = this.getSubtaskInfoForSession(idleSessionId);
@@ -1722,9 +1868,8 @@ export class ThreadSessionRuntime {
1722
1868
  if (assistantMessageIds.length === 0) {
1723
1869
  return;
1724
1870
  }
1725
- await this.flushBufferedPartsForMessages({
1726
- messageIDs: assistantMessageIds,
1727
- force: true,
1871
+ await this.flushCurrentTurnParts({
1872
+ mode: 'final',
1728
1873
  repulseTyping: false,
1729
1874
  });
1730
1875
  // Skip footer if model produced no visible output (no text, no tool calls,
@@ -1882,38 +2027,79 @@ export class ThreadSessionRuntime {
1882
2027
  }
1883
2028
  this.onInteractiveUiStateChanged();
1884
2029
  }
1885
- async handleQuestionAsked(questionRequest) {
2030
+ hasUnfinishedTextPart(messageID) {
2031
+ return this.getBufferedParts(messageID).some((part) => {
2032
+ return part.type === 'text' && !part.time?.end;
2033
+ });
2034
+ }
2035
+ // OpenCode emits question.asked when the tool starts, often before the
2036
+ // preceding text part gets time.end. Showing the dropdown on that event
2037
+ // holds the action queue while Discord posts, so the later text-end cannot
2038
+ // send and dumps after the queued » user: indicator. Wait for text-end.
2039
+ async tryShowPendingQuestion({ ignoreUnfinishedText = false, } = {}) {
1886
2040
  const sessionId = this.state?.sessionId;
1887
- if (questionRequest.sessionID !== sessionId) {
1888
- logger.log(`[QUESTION IGNORED] Question for different session (expected: ${sessionId}, got: ${questionRequest.sessionID})`);
1889
- return;
2041
+ if (!sessionId) {
2042
+ return false;
1890
2043
  }
1891
- logger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
2044
+ const request = deriveLatestUnansweredQuestion({
2045
+ events: this.eventBuffer,
2046
+ sessionId,
2047
+ });
2048
+ if (!request) {
2049
+ this.deferredQuestionShow.clear();
2050
+ return false;
2051
+ }
2052
+ if (this.shownQuestionRequestIds.has(request.id)
2053
+ || findPendingQuestionContextForRequest({
2054
+ threadId: this.thread.id,
2055
+ requestId: request.id,
2056
+ })) {
2057
+ this.deferredQuestionShow.clear();
2058
+ return true;
2059
+ }
2060
+ const messageId = request.tool?.messageID;
2061
+ if (!ignoreUnfinishedText && messageId && this.hasUnfinishedTextPart(messageId)) {
2062
+ return false;
2063
+ }
2064
+ this.shownQuestionRequestIds.add(request.id);
1892
2065
  await this.showInteractiveUi({
2066
+ flushMessageId: messageId,
1893
2067
  show: async () => {
1894
- if (!sessionId) {
1895
- return;
1896
- }
1897
2068
  await showAskUserQuestionDropdowns({
1898
2069
  thread: this.thread,
1899
2070
  sessionId,
1900
2071
  directory: this.sdkDirectory,
1901
- requestId: questionRequest.id,
1902
- input: { questions: questionRequest.questions },
2072
+ requestId: request.id,
2073
+ input: { questions: request.questions },
1903
2074
  silent: this.getQueueLength() > 0,
1904
2075
  });
1905
2076
  },
1906
2077
  });
2078
+ this.deferredQuestionShow.clear();
1907
2079
  this.maybeHandoffQueuedItemForPendingQuestion({
1908
2080
  sessionId,
1909
2081
  reason: 'question-shown',
1910
2082
  });
2083
+ return true;
2084
+ }
2085
+ async handleQuestionAsked(questionRequest) {
2086
+ const sessionId = this.state?.sessionId;
2087
+ if (questionRequest.sessionID !== sessionId) {
2088
+ logger.log(`[QUESTION IGNORED] Question for different session (expected: ${sessionId}, got: ${questionRequest.sessionID})`);
2089
+ return;
2090
+ }
2091
+ logger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
2092
+ const shown = await this.tryShowPendingQuestion();
2093
+ if (!shown) {
2094
+ this.deferredQuestionShow.trigger();
2095
+ }
1911
2096
  }
1912
2097
  handleQuestionReplied(properties) {
1913
2098
  const sessionId = this.state?.sessionId;
1914
2099
  if (properties.sessionID !== sessionId) {
1915
2100
  return;
1916
2101
  }
2102
+ this.deferredQuestionShow.clear();
1917
2103
  this.onInteractiveUiStateChanged();
1918
2104
  // When a question is answered and the local queue has items, the model may
1919
2105
  // continue the same run without ever reaching the local-queue idle gate.
@@ -1973,7 +2159,7 @@ export class ThreadSessionRuntime {
1973
2159
  ? `/${next.command.name}`
1974
2160
  : `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`;
1975
2161
  if (displayText.trim()) {
1976
- await sendThreadMessage(this.thread, `» **${next.username}:** ${displayText}`);
2162
+ await sendThreadMessage(this.thread, `${QUEUE_PREFIX}**${next.username}:** ${displayText}`);
1977
2163
  }
1978
2164
  this.markQuestionQueueHandoffStarted(sessionId);
1979
2165
  await this.submitViaOpencodeQueue(next);
@@ -2011,7 +2197,7 @@ export class ThreadSessionRuntime {
2011
2197
  const secs = remainingSec % 60;
2012
2198
  return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
2013
2199
  })();
2014
- const chunk = `⬦ ${message} - retrying in ${duration} (attempt #${attempt})`;
2200
+ const chunk = `${STATUS_PREFIX}${message} - retrying in ${duration} (attempt #${attempt})`;
2015
2201
  const retryResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
2016
2202
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
2017
2203
  if (retryResult instanceof Error) {
@@ -2032,80 +2218,36 @@ export class ThreadSessionRuntime {
2032
2218
  if (info.id !== this.state?.sessionId) {
2033
2219
  return;
2034
2220
  }
2035
- if (this.lastSyncedThreadName === undefined) {
2036
- const persistedName = await this.loadLastSyncedThreadName().catch((e) => new Error('Failed to read persisted thread rename state', { cause: e }));
2037
- if (persistedName instanceof Error) {
2038
- logger.warn(`[TITLE] ${persistedName.message} for thread ${this.threadId}`);
2039
- return;
2040
- }
2041
- this.lastSyncedThreadName = persistedName;
2221
+ const normalizedTitle = info.title.trim();
2222
+ if (this.appliedOpencodeTitle === normalizedTitle) {
2223
+ return;
2042
2224
  }
2043
- const renameDecision = deriveThreadRenameFromSessionUpdate({
2225
+ const desiredName = deriveThreadNameFromSessionTitle({
2044
2226
  sessionTitle: info.title,
2045
2227
  currentName: this.thread.name,
2046
- lastSyncedName: this.lastSyncedThreadName,
2047
2228
  });
2048
- if (renameDecision.desiredName === null) {
2049
- if (renameDecision.nextSyncedName !== null &&
2050
- renameDecision.nextSyncedName !== this.lastSyncedThreadName) {
2051
- await this.persistLastSyncedThreadName(renameDecision.nextSyncedName);
2052
- }
2053
- return;
2054
- }
2055
- const { desiredName } = renameDecision;
2056
- const normalizedTitle = info.title.trim();
2057
- if (this.appliedOpencodeTitle === normalizedTitle) {
2229
+ // Mark before setName so concurrent session.updated events don't stack
2230
+ // renames. Keep the mark on failure — retry is almost always a rate limit.
2231
+ this.appliedOpencodeTitle = normalizedTitle;
2232
+ if (!desiredName) {
2058
2233
  return;
2059
2234
  }
2060
- // Mark before the call so concurrent session.updated events don't stack
2061
- // rename attempts. On failure we keep the mark — a retry won't help
2062
- // because the failure is almost always a rate limit.
2063
- this.appliedOpencodeTitle = normalizedTitle;
2064
- const RENAME_TIMEOUT_MS = 3000;
2065
- const timeoutSignal = AbortSignal.timeout(RENAME_TIMEOUT_MS);
2066
- const renameResult = await Promise.race([
2067
- this.thread.setName(desiredName)
2235
+ const renameResult = await raceDiscordRename({
2236
+ rename: this.thread.setName(desiredName)
2068
2237
  .catch((e) => new Error('Failed to rename thread from OpenCode title', {
2069
2238
  cause: e,
2070
2239
  })),
2071
- new Promise((resolve) => {
2072
- timeoutSignal.addEventListener('abort', () => {
2073
- resolve('timeout');
2074
- });
2075
- }),
2076
- ]);
2240
+ });
2077
2241
  if (renameResult === 'timeout') {
2078
- logger.warn(`[TITLE] setName timed out after ${RENAME_TIMEOUT_MS}ms for thread ${this.threadId} (likely rate-limited)`);
2242
+ logger.warn(`[TITLE] setName timed out after ${DISCORD_THREAD_RENAME_TIMEOUT_MS}ms for thread ${this.threadId} (likely rate-limited)`);
2079
2243
  return;
2080
2244
  }
2081
2245
  if (renameResult instanceof Error) {
2082
2246
  logger.warn(`[TITLE] Could not rename thread ${this.threadId}: ${renameResult.message}`);
2083
2247
  return;
2084
2248
  }
2085
- await this.persistLastSyncedThreadName(desiredName);
2086
2249
  logger.log(`[TITLE] Renamed thread ${this.threadId} to "${desiredName}" from OpenCode session title`);
2087
2250
  }
2088
- async loadLastSyncedThreadName() {
2089
- const db = await getDb();
2090
- const row = await db.query.thread_sessions.findFirst({
2091
- where: { thread_id: this.threadId },
2092
- columns: { last_synced_name: true },
2093
- });
2094
- return row?.last_synced_name ?? null;
2095
- }
2096
- async persistLastSyncedThreadName(name) {
2097
- this.lastSyncedThreadName = name;
2098
- const db = await getDb();
2099
- const result = await db.update(schema.thread_sessions)
2100
- .set({ last_synced_name: name })
2101
- .where(orm.eq(schema.thread_sessions.thread_id, this.threadId))
2102
- .catch((e) => new Error('Failed to persist thread rename state', {
2103
- cause: e,
2104
- }));
2105
- if (result instanceof Error) {
2106
- logger.warn(`[TITLE] ${result.message} for thread ${this.threadId}`);
2107
- }
2108
- }
2109
2251
  async handleTuiToast(properties) {
2110
2252
  if (properties.variant === 'warning') {
2111
2253
  return;
@@ -2121,7 +2263,7 @@ export class ThreadSessionRuntime {
2121
2263
  const titlePrefix = properties.title
2122
2264
  ? `${properties.title.trim()}: `
2123
2265
  : '';
2124
- const chunk = `⬦ ${properties.variant}: ${titlePrefix}${toastMessage}`;
2266
+ const chunk = `${STATUS_PREFIX}${properties.variant}: ${titlePrefix}${toastMessage}`;
2125
2267
  const toastResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
2126
2268
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
2127
2269
  if (toastResult instanceof Error) {
@@ -2138,6 +2280,7 @@ export class ThreadSessionRuntime {
2138
2280
  * fields that the local-queue path provides.
2139
2281
  */
2140
2282
  async submitViaOpencodeQueue(input) {
2283
+ await this.supersedePendingSleep(input);
2141
2284
  let skippedBySessionGuard = false;
2142
2285
  await this.dispatchAction(async () => {
2143
2286
  if (input.expectedSessionId &&
@@ -2197,6 +2340,17 @@ export class ThreadSessionRuntime {
2197
2340
  await setSessionAgent(session.id, input.agent);
2198
2341
  await clearSessionModel(session.id);
2199
2342
  }
2343
+ if (input.model) {
2344
+ const validatedModel = await validateModelId({
2345
+ model: input.model,
2346
+ getClient,
2347
+ directory: this.sdkDirectory,
2348
+ });
2349
+ if (validatedModel instanceof Error) {
2350
+ await cleanupOnError(`Failed to resolve model: ${validatedModel.message}`);
2351
+ return;
2352
+ }
2353
+ }
2200
2354
  await ensureSessionPreferencesSnapshot({
2201
2355
  sessionId: session.id,
2202
2356
  channelId,
@@ -2220,14 +2374,23 @@ export class ThreadSessionRuntime {
2220
2374
  }
2221
2375
  const resolvedAgent = agentResult.agentPreference;
2222
2376
  const availableAgents = agentResult.agents;
2377
+ releaseCurrentThreadIngress();
2378
+ await this.persistIngressVariant({
2379
+ sessionId: session.id,
2380
+ channelId,
2381
+ appId: resolvedAppId,
2382
+ agentPreference: resolvedAgent,
2383
+ getClient,
2384
+ variant: input.variant,
2385
+ });
2223
2386
  const [modelResult, preferredVariant] = await Promise.all([
2224
2387
  (async () => {
2225
2388
  if (input.model) {
2226
- const [providerID, ...modelParts] = input.model.split('/');
2227
- const modelID = modelParts.join('/');
2228
- if (providerID && modelID) {
2229
- return { providerID, modelID };
2230
- }
2389
+ return validateModelId({
2390
+ model: input.model,
2391
+ getClient,
2392
+ directory: this.sdkDirectory,
2393
+ });
2231
2394
  }
2232
2395
  const modelInfo = await getCurrentModelInfo({
2233
2396
  sessionId: session.id,
@@ -2333,6 +2496,7 @@ export class ThreadSessionRuntime {
2333
2496
  userId: input.userId,
2334
2497
  sourceMessageId: input.sourceMessageId,
2335
2498
  sourceThreadId: input.sourceThreadId,
2499
+ threadName: this.thread.name || undefined,
2336
2500
  repliedMessage: input.repliedMessage,
2337
2501
  worktree,
2338
2502
  currentAgent: resolvedAgent,
@@ -2357,6 +2521,7 @@ export class ThreadSessionRuntime {
2357
2521
  username: this.state?.sessionUsername || input.username,
2358
2522
  userId: this.state?.sessionUserId || input.userId,
2359
2523
  parentSessionId: this.state?.parentSessionId || input.parentSessionId,
2524
+ scheduledTask: await this.resolveScheduledTaskContext(session.id),
2360
2525
  }),
2361
2526
  ...(resolvedAgent ? { agent: resolvedAgent } : {}),
2362
2527
  ...(modelField ? { model: modelField } : {}),
@@ -2407,7 +2572,26 @@ export class ThreadSessionRuntime {
2407
2572
  * Enqueue in kimaki's local per-thread queue.
2408
2573
  * Used for explicit queue workflows (/queue, queueMessage=true).
2409
2574
  */
2575
+ /**
2576
+ * A new turn supersedes a pending sleep.
2577
+ *
2578
+ * Called from the two terminal routers rather than from the top of
2579
+ * enqueueIncoming: arrival order is only fixed once a message reaches the
2580
+ * preprocessChain link, so awaiting anything before that lets two rapid
2581
+ * messages swap places. By here the order is already committed.
2582
+ *
2583
+ * Awaited rather than fire-and-forget so it cannot race the task runner and
2584
+ * let a stale wake land after the user took the conversation back.
2585
+ */
2586
+ async supersedePendingSleep(input) {
2587
+ if (input.isSleepWake)
2588
+ return;
2589
+ await cancelSessionSleepForThread({ threadId: this.threadId }).catch((error) => {
2590
+ logger.error('[SLEEP] failed to cancel pending sleep:', error);
2591
+ });
2592
+ }
2410
2593
  async enqueueViaLocalQueue(input) {
2594
+ await this.supersedePendingSleep(input);
2411
2595
  const queueId = crypto.randomBytes(8).toString('hex');
2412
2596
  const queuedMessage = {
2413
2597
  queueId,
@@ -2419,6 +2603,7 @@ export class ThreadSessionRuntime {
2419
2603
  command: input.command,
2420
2604
  agent: input.agent,
2421
2605
  model: input.model,
2606
+ variant: input.variant,
2422
2607
  permissions: input.permissions,
2423
2608
  injectionGuardPatterns: input.injectionGuardPatterns,
2424
2609
  parentSessionId: input.parentSessionId,
@@ -2463,8 +2648,12 @@ export class ThreadSessionRuntime {
2463
2648
  * discord-bot.ts.
2464
2649
  */
2465
2650
  async enqueueIncoming(input) {
2651
+ await waitForCurrentThreadIngress();
2466
2652
  threadState.setSessionUsername(this.threadId, input.username);
2467
- threadState.setSessionUserId(this.threadId, input.userId);
2653
+ const botUserId = this.thread.client.user?.id;
2654
+ if (input.userId && input.userId !== botUserId) {
2655
+ threadState.setSessionUserId(this.threadId, input.userId);
2656
+ }
2468
2657
  await this.ensureParentSessionId({
2469
2658
  parentSessionId: input.parentSessionId,
2470
2659
  });
@@ -2623,6 +2812,10 @@ export class ThreadSessionRuntime {
2623
2812
  const sessionIsBusy = this.isMainSessionBusy();
2624
2813
  logger.log(`[ABORT] id=${abortId} reason=${reason} threadId=${this.threadId} sessionId=${sessionId || 'none'} queueLength=${state.queueItems.length} ${this.formatRunStateForLog()} sessionBusy=${sessionIsBusy}`);
2625
2814
  this.stopTyping();
2815
+ this.deferredQuestionShow.clear();
2816
+ // The aborted run owns the question request, so the dropdown dies with it.
2817
+ // Questions have no TTL, so this is the only thing that clears them here.
2818
+ void cancelPendingQuestion(this.threadId);
2626
2819
  const apiAbortPromise = sessionId
2627
2820
  ? this.abortSessionViaApi({ abortId, reason, sessionId })
2628
2821
  : undefined;
@@ -2680,13 +2873,6 @@ export class ThreadSessionRuntime {
2680
2873
  getQueueLength() {
2681
2874
  return this.state?.queueItems.length ?? 0;
2682
2875
  }
2683
- /** NOTIFY_MESSAGE_FLAGS unless queue has a next item, then SILENT.
2684
- * Permissions should NOT use this — they always notify. */
2685
- getNotifyFlags() {
2686
- return this.getQueueLength() > 0
2687
- ? SILENT_MESSAGE_FLAGS
2688
- : NOTIFY_MESSAGE_FLAGS;
2689
- }
2690
2876
  /** Clear all queued messages. Returns the removed items. */
2691
2877
  clearQueue() {
2692
2878
  return threadState.clearQueueItems(this.threadId);
@@ -2763,7 +2949,7 @@ export class ThreadSessionRuntime {
2763
2949
  ? `/${next.command.name}`
2764
2950
  : `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`;
2765
2951
  if (displayText.trim()) {
2766
- await sendThreadMessage(this.thread, `» **${next.username}:** ${displayText}`);
2952
+ await sendThreadMessage(this.thread, `${QUEUE_PREFIX}**${next.username}:** ${displayText}`);
2767
2953
  }
2768
2954
  }
2769
2955
  // Start dispatch (detached — does not block the action queue).
@@ -2794,6 +2980,7 @@ export class ThreadSessionRuntime {
2794
2980
  async dispatchPrompt(input) {
2795
2981
  this.lastDisplayedContextPercentage = 0;
2796
2982
  this.lastRateLimitDisplayTime = 0;
2983
+ this.lastSentPartKind = undefined;
2797
2984
  // ── Ensure session ────────────────────────────────────────
2798
2985
  const sessionResult = await this.ensureSession({
2799
2986
  prompt: input.prompt,
@@ -2834,6 +3021,19 @@ export class ThreadSessionRuntime {
2834
3021
  await setSessionAgent(session.id, input.agent);
2835
3022
  await clearSessionModel(session.id);
2836
3023
  }
3024
+ if (input.model) {
3025
+ const validatedModel = await validateModelId({
3026
+ model: input.model,
3027
+ getClient,
3028
+ directory: this.sdkDirectory,
3029
+ });
3030
+ if (validatedModel instanceof Error) {
3031
+ this.stopTyping();
3032
+ await sendThreadMessage(this.thread, `Failed to resolve model: ${validatedModel.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
3033
+ await this.tryDrainQueue({ showIndicator: true });
3034
+ return;
3035
+ }
3036
+ }
2837
3037
  await ensureSessionPreferencesSnapshot({
2838
3038
  sessionId: session.id,
2839
3039
  channelId,
@@ -2860,14 +3060,22 @@ export class ThreadSessionRuntime {
2860
3060
  }
2861
3061
  const earlyAgentPreference = earlyAgentResult.agentPreference;
2862
3062
  const earlyAvailableAgents = earlyAgentResult.agents;
3063
+ await this.persistIngressVariant({
3064
+ sessionId: session.id,
3065
+ channelId,
3066
+ appId: resolvedAppId,
3067
+ agentPreference: earlyAgentPreference,
3068
+ getClient,
3069
+ variant: input.variant,
3070
+ });
2863
3071
  const [earlyModelResult, preferredVariant] = await Promise.all([
2864
3072
  (async () => {
2865
3073
  if (input.model) {
2866
- const [providerID, ...modelParts] = input.model.split('/');
2867
- const modelID = modelParts.join('/');
2868
- if (providerID && modelID) {
2869
- return { providerID, modelID };
2870
- }
3074
+ return validateModelId({
3075
+ model: input.model,
3076
+ getClient,
3077
+ directory: this.sdkDirectory,
3078
+ });
2871
3079
  }
2872
3080
  const modelInfo = await getCurrentModelInfo({
2873
3081
  sessionId: session.id,
@@ -2980,6 +3188,7 @@ export class ThreadSessionRuntime {
2980
3188
  userId: input.userId,
2981
3189
  sourceMessageId: input.sourceMessageId,
2982
3190
  sourceThreadId: input.sourceThreadId,
3191
+ threadName: this.thread.name || undefined,
2983
3192
  repliedMessage: input.repliedMessage,
2984
3193
  worktree,
2985
3194
  currentAgent: earlyAgentPreference,
@@ -3023,6 +3232,7 @@ export class ThreadSessionRuntime {
3023
3232
  userId: input.userId,
3024
3233
  sourceMessageId: input.sourceMessageId,
3025
3234
  sourceThreadId: input.sourceThreadId,
3235
+ threadName: this.thread.name || undefined,
3026
3236
  repliedMessage: input.repliedMessage,
3027
3237
  });
3028
3238
  // OpenCode's session.command API has no `system` field. Persist the
@@ -3040,6 +3250,7 @@ export class ThreadSessionRuntime {
3040
3250
  username: this.state?.sessionUsername || input.username,
3041
3251
  userId: this.state?.sessionUserId || input.userId,
3042
3252
  parentSessionId: this.state?.parentSessionId || input.parentSessionId,
3253
+ scheduledTask: await this.resolveScheduledTaskContext(session.id),
3043
3254
  });
3044
3255
  const systemWriteResult = await writeSessionSystemPrompt({
3045
3256
  sessionId: session.id,
@@ -3141,6 +3352,7 @@ export class ThreadSessionRuntime {
3141
3352
  username: this.state?.sessionUsername || input.username,
3142
3353
  userId: this.state?.sessionUserId || input.userId,
3143
3354
  parentSessionId: this.state?.parentSessionId || input.parentSessionId,
3355
+ scheduledTask: await this.resolveScheduledTaskContext(session.id),
3144
3356
  }),
3145
3357
  model: earlyModelParam,
3146
3358
  agent: earlyAgentPreference,
@@ -3176,6 +3388,41 @@ export class ThreadSessionRuntime {
3176
3388
  }
3177
3389
  // ── Session Ensure ──────────────────────────────────────────
3178
3390
  // Creates or reuses the OpenCode session for this thread.
3391
+ /** Cached per-session scheduled task info for the system message. */
3392
+ scheduledTaskContextCache = new Map();
3393
+ /**
3394
+ * Resolve the scheduled-task context for the system message, once per
3395
+ * session. The row in session_start_sources is immutable, so caching the
3396
+ * result keeps the system prompt identical across turns (prompt-cache safe).
3397
+ * One-shot 'at' tasks are deleted after their run, so only schedule_kind
3398
+ * survives for them.
3399
+ */
3400
+ async resolveScheduledTaskContext(sessionId) {
3401
+ const cache = this.scheduledTaskContextCache;
3402
+ if (cache.has(sessionId)) {
3403
+ return cache.get(sessionId);
3404
+ }
3405
+ const context = await (async () => {
3406
+ const source = await getSessionStartSource({ sessionId });
3407
+ if (!source) {
3408
+ return undefined;
3409
+ }
3410
+ const task = source.scheduled_task_id
3411
+ ? await getScheduledTask(source.scheduled_task_id)
3412
+ : null;
3413
+ return {
3414
+ taskId: source.scheduled_task_id ?? undefined,
3415
+ scheduleKind: source.schedule_kind,
3416
+ cronExpr: task?.cron_expr,
3417
+ timezone: task?.timezone,
3418
+ };
3419
+ })().catch((error) => {
3420
+ logger.warn(`[SCHEDULED TASK CONTEXT] Failed to resolve for session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
3421
+ return undefined;
3422
+ });
3423
+ cache.set(sessionId, context);
3424
+ return context;
3425
+ }
3179
3426
  async updateExistingSessionPermissions({ client, sessionId, createdNewSession, permissions, }) {
3180
3427
  if (createdNewSession) {
3181
3428
  return null;
@@ -3232,7 +3479,8 @@ export class ThreadSessionRuntime {
3232
3479
  session = sessionResponse.data;
3233
3480
  }
3234
3481
  else {
3235
- logger.warn(`[ENSURE SESSION] session.get returned no data for ${sessionId}, response=${JSON.stringify(sessionResponse)}`);
3482
+ const sdkMessage = extractSdkErrorMessage(sessionResponse.error);
3483
+ logger.warn(`[ENSURE SESSION] session.get returned no data for ${sessionId}: ${sdkMessage}, response=${JSON.stringify(sessionResponse)}`);
3236
3484
  }
3237
3485
  }
3238
3486
  if (!session) {
@@ -3256,31 +3504,31 @@ export class ThreadSessionRuntime {
3256
3504
  logger.error(`[ENSURE SESSION] session.create failed: ${createResult.message}`);
3257
3505
  return new Error(`Failed to create session: ${createResult.message}, threadId=${this.thread.id}, directory=${this.sdkDirectory}`, { cause: createResult });
3258
3506
  }
3259
- session = createResult.data;
3260
- if (!session) {
3261
- logger.warn(`[ENSURE SESSION] session.create returned no data, threadId=${this.thread.id}, directory=${this.sdkDirectory}, response=${JSON.stringify(createResult)}`);
3507
+ if (createResult.error || !createResult.data) {
3508
+ const errorMessage = extractSdkErrorMessage(createResult.error);
3509
+ logger.error(`[ENSURE SESSION] session.create failed: ${errorMessage}, threadId=${this.thread.id}, directory=${this.sdkDirectory}, response=${JSON.stringify(createResult)}`);
3510
+ return new Error(`Failed to create session: ${errorMessage}, threadId=${this.thread.id}, directory=${this.sdkDirectory}`);
3262
3511
  }
3512
+ session = createResult.data;
3263
3513
  // Insert DB row immediately so the external-sync poller sees
3264
3514
  // source='kimaki' before the next poll tick and skips this session.
3265
3515
  // The upsert at the end of ensureSession is kept for the reuse path.
3266
- if (session) {
3267
- await setThreadSession(this.thread.id, session.id);
3268
- if (injectionGuardPatterns?.length) {
3269
- writeInjectionGuardConfig({
3270
- sessionId: session.id,
3271
- scanPatterns: injectionGuardPatterns,
3272
- });
3273
- }
3274
- const worktree = await getThreadWorktreeOrWorkspace(this.thread.id);
3275
- trackEvent('session_created', {
3276
- has_worktree: Boolean(worktree),
3277
- source: sessionStartScheduleKind ? 'scheduled' : 'discord',
3516
+ await setThreadSession(this.thread.id, session.id);
3517
+ if (injectionGuardPatterns?.length) {
3518
+ writeInjectionGuardConfig({
3519
+ sessionId: session.id,
3520
+ scanPatterns: injectionGuardPatterns,
3278
3521
  });
3279
3522
  }
3523
+ const worktree = await getThreadWorktreeOrWorkspace(this.thread.id);
3524
+ trackEvent('session_created', {
3525
+ has_worktree: Boolean(worktree),
3526
+ source: sessionStartScheduleKind ? 'scheduled' : 'discord',
3527
+ });
3280
3528
  createdNewSession = true;
3281
3529
  }
3282
3530
  if (!session) {
3283
- return new Error(`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`);
3531
+ return new Error(`Failed to create or get session: threadId=${this.thread.id}, channelId=${this.channelId}, directory=${directory}, sdkDirectory=${this.sdkDirectory}, existingSessionId=${sessionId ?? 'none'}, createdNewSession=${createdNewSession}`);
3284
3532
  }
3285
3533
  // Store session in DB and thread state
3286
3534
  await setThreadSession(this.thread.id, session.id);
@@ -3326,7 +3574,7 @@ export class ThreadSessionRuntime {
3326
3574
  const agentLabel = agent && agent.toLowerCase() !== 'build'
3327
3575
  ? ` ⋅ ${agent}`
3328
3576
  : '';
3329
- const result = await sendThreadMessage(this.thread, `*using ${modelLabel}${agentLabel}*`, { flags: SILENT_MESSAGE_FLAGS }).catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
3577
+ const result = await sendThreadMessage(this.thread, asDiscordQuote(`*using ${modelLabel}${agentLabel}*`), { flags: SILENT_MESSAGE_FLAGS }).catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
3330
3578
  if (result instanceof Error) {
3331
3579
  logger.warn(`[SESSION INFO] Failed to send model info: ${result.message}`);
3332
3580
  }
@@ -3350,7 +3598,6 @@ export class ThreadSessionRuntime {
3350
3598
  const sessionDuration = elapsedMs < 1000
3351
3599
  ? '<1s'
3352
3600
  : prettyMilliseconds(elapsedMs, { secondsDecimalDigits: 0 });
3353
- const modelInfo = runInfo.model ? ` ⋅ ${runInfo.model}` : '';
3354
3601
  const agentInfo = runInfo.agent && runInfo.agent.toLowerCase() !== 'build'
3355
3602
  ? ` ⋅ **${runInfo.agent}**`
3356
3603
  : '';
@@ -3364,7 +3611,7 @@ export class ThreadSessionRuntime {
3364
3611
  }).catch((e) => new FilesystemOperationError({ operation: 'gitBranch', cause: e })),
3365
3612
  (async () => {
3366
3613
  if (!client || !sessionId) {
3367
- return;
3614
+ return [];
3368
3615
  }
3369
3616
  let tokensUsed = runInfo.tokensUsed;
3370
3617
  // Fetch final token count from API
@@ -3401,9 +3648,12 @@ export class ThreadSessionRuntime {
3401
3648
  providerID: runInfo.providerID,
3402
3649
  })
3403
3650
  : undefined;
3651
+ const providers = providersResult && !(providersResult instanceof Error)
3652
+ ? providersResult.data?.all ?? []
3653
+ : [];
3404
3654
  let contextLimit = fallbackLimit;
3405
- if (providersResult && !(providersResult instanceof Error)) {
3406
- const provider = providersResult.data?.all?.find((p) => {
3655
+ if (providers.length > 0) {
3656
+ const provider = providers.find((p) => {
3407
3657
  return p.id === runInfo.providerID;
3408
3658
  });
3409
3659
  const model = provider?.models?.[runInfo.model || ''];
@@ -3413,12 +3663,26 @@ export class ThreadSessionRuntime {
3413
3663
  const percentage = Math.round((tokensUsed / contextLimit) * 100);
3414
3664
  contextInfo = ` ⋅ ${percentage}%`;
3415
3665
  }
3666
+ return providers;
3416
3667
  })().catch((e) => new OpenCodeSdkError({ operation: 'resolveModelPreference', cause: e })),
3417
3668
  ]);
3418
3669
  const branchName = branchResult instanceof Error ? '' : branchResult.stdout.trim();
3419
3670
  if (contextResult instanceof Error) {
3420
3671
  logger.error('Failed to fetch provider info for context percentage:', contextResult);
3421
3672
  }
3673
+ const providers = contextResult instanceof Error ? [] : (contextResult ?? []);
3674
+ const modelLabel = runInfo.model
3675
+ ? displayedModelLabel({
3676
+ modelID: runInfo.model,
3677
+ name: await resolveDisplayedModelName({
3678
+ providers,
3679
+ providerID: runInfo.providerID,
3680
+ modelID: runInfo.model,
3681
+ sessionID: sessionId,
3682
+ }),
3683
+ })
3684
+ : undefined;
3685
+ const modelInfo = modelLabel ? ` ⋅ ${modelLabel}` : '';
3422
3686
  const truncate = (s, max) => {
3423
3687
  return s.length > max ? s.slice(0, max - 1) + '\u2026' : s;
3424
3688
  };
@@ -3427,12 +3691,22 @@ export class ThreadSessionRuntime {
3427
3691
  const projectInfo = truncatedBranch
3428
3692
  ? `${truncatedFolder} ⋅ ${truncatedBranch} ⋅ `
3429
3693
  : `${truncatedFolder} ⋅ `;
3430
- const footerText = `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*`;
3694
+ const hasQueuedMessage = this.getQueueLength() > 0;
3695
+ const didUseSleepTool = sessionId
3696
+ ? didLatestUserTurnUseSleepTool({ events: this.eventBuffer, sessionId })
3697
+ : false;
3698
+ const shouldNotifyUser = !hasQueuedMessage && !didUseSleepTool;
3699
+ const mentionUserId = store.getState().footerMentionsEnabled && shouldNotifyUser
3700
+ ? await resolveThreadFooterMentionUserId({
3701
+ sessionUserId: this.state?.sessionUserId,
3702
+ thread: this.thread,
3703
+ })
3704
+ : undefined;
3705
+ const mention = mentionUserId ? ` <@${mentionUserId}>` : '';
3706
+ const footerText = asDiscordQuote(`*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*${mention}`);
3431
3707
  this.stopTyping();
3432
- // Skip notification if there's a queued message next — the user only
3433
- // needs to be notified when the entire queue finishes.
3434
3708
  await sendThreadMessage(this.thread, footerText, {
3435
- flags: this.getNotifyFlags(),
3709
+ flags: shouldNotifyUser ? NOTIFY_MESSAGE_FLAGS : SILENT_MESSAGE_FLAGS,
3436
3710
  });
3437
3711
  logger.log(`DURATION: Session completed in ${sessionDuration}, model ${runInfo.model}, tokens ${runInfo.tokensUsed}`);
3438
3712
  }
@@ -3442,6 +3716,7 @@ export class ThreadSessionRuntime {
3442
3716
  this.modelContextLimitKey = undefined;
3443
3717
  this.lastDisplayedContextPercentage = 0;
3444
3718
  this.lastRateLimitDisplayTime = 0;
3719
+ this.lastSentPartKind = undefined;
3445
3720
  }
3446
3721
  // ── Retry Last User Prompt (for model-change flow) ──────────
3447
3722
  /**