@otto-assistant/bridge 0.4.92

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 (483) hide show
  1. package/bin.js +2 -0
  2. package/dist/agent-model.e2e.test.js +755 -0
  3. package/dist/ai-tool-to-genai.js +233 -0
  4. package/dist/ai-tool-to-genai.test.js +267 -0
  5. package/dist/ai-tool.js +6 -0
  6. package/dist/anthropic-auth-plugin.js +728 -0
  7. package/dist/anthropic-auth-plugin.test.js +125 -0
  8. package/dist/anthropic-auth-state.js +231 -0
  9. package/dist/bin.js +90 -0
  10. package/dist/channel-management.js +227 -0
  11. package/dist/cli-parsing.test.js +137 -0
  12. package/dist/cli-send-thread.e2e.test.js +356 -0
  13. package/dist/cli.js +3276 -0
  14. package/dist/commands/abort.js +65 -0
  15. package/dist/commands/action-buttons.js +245 -0
  16. package/dist/commands/add-project.js +113 -0
  17. package/dist/commands/agent.js +335 -0
  18. package/dist/commands/ask-question.js +274 -0
  19. package/dist/commands/btw.js +116 -0
  20. package/dist/commands/compact.js +120 -0
  21. package/dist/commands/context-usage.js +140 -0
  22. package/dist/commands/create-new-project.js +130 -0
  23. package/dist/commands/diff.js +63 -0
  24. package/dist/commands/file-upload.js +275 -0
  25. package/dist/commands/fork.js +220 -0
  26. package/dist/commands/gemini-apikey.js +70 -0
  27. package/dist/commands/login.js +885 -0
  28. package/dist/commands/mcp.js +239 -0
  29. package/dist/commands/memory-snapshot.js +24 -0
  30. package/dist/commands/mention-mode.js +44 -0
  31. package/dist/commands/merge-worktree.js +159 -0
  32. package/dist/commands/model-variant.js +364 -0
  33. package/dist/commands/model.js +776 -0
  34. package/dist/commands/new-worktree.js +366 -0
  35. package/dist/commands/paginated-select.js +57 -0
  36. package/dist/commands/permissions.js +274 -0
  37. package/dist/commands/queue.js +206 -0
  38. package/dist/commands/remove-project.js +115 -0
  39. package/dist/commands/restart-opencode-server.js +127 -0
  40. package/dist/commands/resume.js +149 -0
  41. package/dist/commands/run-command.js +79 -0
  42. package/dist/commands/screenshare.js +303 -0
  43. package/dist/commands/screenshare.test.js +20 -0
  44. package/dist/commands/session-id.js +78 -0
  45. package/dist/commands/session.js +176 -0
  46. package/dist/commands/share.js +80 -0
  47. package/dist/commands/tasks.js +205 -0
  48. package/dist/commands/types.js +2 -0
  49. package/dist/commands/undo-redo.js +305 -0
  50. package/dist/commands/unset-model.js +138 -0
  51. package/dist/commands/upgrade.js +42 -0
  52. package/dist/commands/user-command.js +155 -0
  53. package/dist/commands/verbosity.js +125 -0
  54. package/dist/commands/worktree-settings.js +43 -0
  55. package/dist/commands/worktrees.js +410 -0
  56. package/dist/condense-memory.js +33 -0
  57. package/dist/config.js +94 -0
  58. package/dist/context-awareness-plugin.js +363 -0
  59. package/dist/context-awareness-plugin.test.js +124 -0
  60. package/dist/critique-utils.js +95 -0
  61. package/dist/database.js +1310 -0
  62. package/dist/db.js +251 -0
  63. package/dist/db.test.js +138 -0
  64. package/dist/debounce-timeout.js +28 -0
  65. package/dist/debounced-process-flush.js +77 -0
  66. package/dist/discord-bot.js +1008 -0
  67. package/dist/discord-command-registration.js +524 -0
  68. package/dist/discord-urls.js +81 -0
  69. package/dist/discord-utils.js +591 -0
  70. package/dist/discord-utils.test.js +134 -0
  71. package/dist/errors.js +157 -0
  72. package/dist/escape-backticks.test.js +429 -0
  73. package/dist/event-stream-real-capture.e2e.test.js +533 -0
  74. package/dist/eventsource-parser.test.js +327 -0
  75. package/dist/exec-async.js +26 -0
  76. package/dist/external-opencode-sync.js +480 -0
  77. package/dist/format-tables.js +302 -0
  78. package/dist/format-tables.test.js +308 -0
  79. package/dist/forum-sync/config.js +79 -0
  80. package/dist/forum-sync/discord-operations.js +154 -0
  81. package/dist/forum-sync/index.js +5 -0
  82. package/dist/forum-sync/markdown.js +113 -0
  83. package/dist/forum-sync/sync-to-discord.js +417 -0
  84. package/dist/forum-sync/sync-to-files.js +190 -0
  85. package/dist/forum-sync/types.js +53 -0
  86. package/dist/forum-sync/watchers.js +307 -0
  87. package/dist/gateway-proxy-reconnect.e2e.test.js +394 -0
  88. package/dist/gateway-proxy.e2e.test.js +483 -0
  89. package/dist/genai-worker-wrapper.js +111 -0
  90. package/dist/genai-worker.js +311 -0
  91. package/dist/genai.js +232 -0
  92. package/dist/generated/browser.js +17 -0
  93. package/dist/generated/client.js +37 -0
  94. package/dist/generated/commonInputTypes.js +10 -0
  95. package/dist/generated/enums.js +52 -0
  96. package/dist/generated/internal/class.js +49 -0
  97. package/dist/generated/internal/prismaNamespace.js +253 -0
  98. package/dist/generated/internal/prismaNamespaceBrowser.js +223 -0
  99. package/dist/generated/models/bot_api_keys.js +1 -0
  100. package/dist/generated/models/bot_tokens.js +1 -0
  101. package/dist/generated/models/channel_agents.js +1 -0
  102. package/dist/generated/models/channel_directories.js +1 -0
  103. package/dist/generated/models/channel_mention_mode.js +1 -0
  104. package/dist/generated/models/channel_models.js +1 -0
  105. package/dist/generated/models/channel_verbosity.js +1 -0
  106. package/dist/generated/models/channel_worktrees.js +1 -0
  107. package/dist/generated/models/forum_sync_configs.js +1 -0
  108. package/dist/generated/models/global_models.js +1 -0
  109. package/dist/generated/models/ipc_requests.js +1 -0
  110. package/dist/generated/models/part_messages.js +1 -0
  111. package/dist/generated/models/scheduled_tasks.js +1 -0
  112. package/dist/generated/models/session_agents.js +1 -0
  113. package/dist/generated/models/session_events.js +1 -0
  114. package/dist/generated/models/session_models.js +1 -0
  115. package/dist/generated/models/session_start_sources.js +1 -0
  116. package/dist/generated/models/thread_sessions.js +1 -0
  117. package/dist/generated/models/thread_worktrees.js +1 -0
  118. package/dist/generated/models.js +1 -0
  119. package/dist/heap-monitor.js +122 -0
  120. package/dist/hrana-server.js +263 -0
  121. package/dist/hrana-server.test.js +370 -0
  122. package/dist/html-actions.js +123 -0
  123. package/dist/html-actions.test.js +70 -0
  124. package/dist/html-components.js +117 -0
  125. package/dist/html-components.test.js +34 -0
  126. package/dist/image-optimizer-plugin.js +153 -0
  127. package/dist/image-utils.js +112 -0
  128. package/dist/interaction-handler.js +397 -0
  129. package/dist/ipc-polling.js +252 -0
  130. package/dist/ipc-tools-plugin.js +193 -0
  131. package/dist/kimaki-digital-twin.e2e.test.js +161 -0
  132. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +87 -0
  133. package/dist/kimaki-opencode-plugin.js +17 -0
  134. package/dist/kimaki-opencode-plugin.test.js +98 -0
  135. package/dist/limit-heading-depth.js +25 -0
  136. package/dist/limit-heading-depth.test.js +105 -0
  137. package/dist/logger.js +165 -0
  138. package/dist/markdown.js +342 -0
  139. package/dist/markdown.test.js +257 -0
  140. package/dist/message-finish-field.e2e.test.js +165 -0
  141. package/dist/message-formatting.js +413 -0
  142. package/dist/message-formatting.test.js +73 -0
  143. package/dist/message-preprocessing.js +330 -0
  144. package/dist/onboarding-tutorial.js +172 -0
  145. package/dist/onboarding-welcome.js +37 -0
  146. package/dist/openai-realtime.js +224 -0
  147. package/dist/opencode-command-detection.js +65 -0
  148. package/dist/opencode-command-detection.test.js +240 -0
  149. package/dist/opencode-command.js +129 -0
  150. package/dist/opencode-command.test.js +48 -0
  151. package/dist/opencode-interrupt-plugin.js +361 -0
  152. package/dist/opencode-interrupt-plugin.test.js +458 -0
  153. package/dist/opencode.js +861 -0
  154. package/dist/otto/branding.js +22 -0
  155. package/dist/otto/index.js +21 -0
  156. package/dist/parse-permission-rules.test.js +117 -0
  157. package/dist/patch-text-parser.js +97 -0
  158. package/dist/plugin-logger.js +59 -0
  159. package/dist/privacy-sanitizer.js +105 -0
  160. package/dist/queue-advanced-abort.e2e.test.js +293 -0
  161. package/dist/queue-advanced-action-buttons.e2e.test.js +206 -0
  162. package/dist/queue-advanced-e2e-setup.js +786 -0
  163. package/dist/queue-advanced-footer.e2e.test.js +472 -0
  164. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  165. package/dist/queue-advanced-permissions-typing.e2e.test.js +180 -0
  166. package/dist/queue-advanced-question.e2e.test.js +261 -0
  167. package/dist/queue-advanced-typing-interrupt.e2e.test.js +114 -0
  168. package/dist/queue-advanced-typing.e2e.test.js +153 -0
  169. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  170. package/dist/queue-interrupt-drain.e2e.test.js +135 -0
  171. package/dist/queue-question-select-drain.e2e.test.js +120 -0
  172. package/dist/runtime-idle-sweeper.js +52 -0
  173. package/dist/runtime-lifecycle.e2e.test.js +508 -0
  174. package/dist/sentry.js +23 -0
  175. package/dist/session-handler/agent-utils.js +67 -0
  176. package/dist/session-handler/event-stream-state.js +420 -0
  177. package/dist/session-handler/event-stream-state.test.js +563 -0
  178. package/dist/session-handler/model-utils.js +124 -0
  179. package/dist/session-handler/opencode-session-event-log.js +94 -0
  180. package/dist/session-handler/thread-runtime-state.js +104 -0
  181. package/dist/session-handler/thread-session-runtime.js +3258 -0
  182. package/dist/session-handler.js +9 -0
  183. package/dist/session-search.js +100 -0
  184. package/dist/session-search.test.js +40 -0
  185. package/dist/session-title-rename.test.js +80 -0
  186. package/dist/startup-service.js +153 -0
  187. package/dist/startup-time.e2e.test.js +296 -0
  188. package/dist/store.js +17 -0
  189. package/dist/system-message.js +613 -0
  190. package/dist/system-message.test.js +602 -0
  191. package/dist/task-runner.js +295 -0
  192. package/dist/task-schedule.js +209 -0
  193. package/dist/task-schedule.test.js +71 -0
  194. package/dist/test-utils.js +299 -0
  195. package/dist/thinking-utils.js +35 -0
  196. package/dist/thread-message-queue.e2e.test.js +999 -0
  197. package/dist/tools.js +357 -0
  198. package/dist/undo-redo.e2e.test.js +161 -0
  199. package/dist/unnest-code-blocks.js +146 -0
  200. package/dist/unnest-code-blocks.test.js +673 -0
  201. package/dist/upgrade.js +114 -0
  202. package/dist/utils.js +144 -0
  203. package/dist/voice-attachment.js +34 -0
  204. package/dist/voice-handler.js +646 -0
  205. package/dist/voice-message.e2e.test.js +1021 -0
  206. package/dist/voice.js +447 -0
  207. package/dist/voice.test.js +235 -0
  208. package/dist/wait-session.js +94 -0
  209. package/dist/websockify.js +69 -0
  210. package/dist/worker-types.js +4 -0
  211. package/dist/worktree-lifecycle.e2e.test.js +308 -0
  212. package/dist/worktree-utils.js +3 -0
  213. package/dist/worktrees.js +929 -0
  214. package/dist/worktrees.test.js +189 -0
  215. package/dist/xml.js +92 -0
  216. package/dist/xml.test.js +32 -0
  217. package/package.json +98 -0
  218. package/schema.prisma +295 -0
  219. package/skills/batch/SKILL.md +87 -0
  220. package/skills/critique/SKILL.md +112 -0
  221. package/skills/egaki/SKILL.md +100 -0
  222. package/skills/errore/SKILL.md +647 -0
  223. package/skills/event-sourcing-state/SKILL.md +252 -0
  224. package/skills/gitchamber/SKILL.md +93 -0
  225. package/skills/goke/SKILL.md +644 -0
  226. package/skills/jitter/EDITOR.md +219 -0
  227. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  228. package/skills/jitter/SKILL.md +158 -0
  229. package/skills/jitter/jitter-clipboard.json +1042 -0
  230. package/skills/jitter/package.json +14 -0
  231. package/skills/jitter/tsconfig.json +15 -0
  232. package/skills/jitter/utils/actions.ts +212 -0
  233. package/skills/jitter/utils/export.ts +114 -0
  234. package/skills/jitter/utils/index.ts +141 -0
  235. package/skills/jitter/utils/snapshot.ts +154 -0
  236. package/skills/jitter/utils/traverse.ts +246 -0
  237. package/skills/jitter/utils/types.ts +279 -0
  238. package/skills/jitter/utils/wait.ts +133 -0
  239. package/skills/lintcn/SKILL.md +873 -0
  240. package/skills/new-skill/SKILL.md +211 -0
  241. package/skills/npm-package/SKILL.md +239 -0
  242. package/skills/playwriter/SKILL.md +35 -0
  243. package/skills/proxyman/SKILL.md +215 -0
  244. package/skills/security-review/SKILL.md +208 -0
  245. package/skills/simplify/SKILL.md +58 -0
  246. package/skills/spiceflow/SKILL.md +14 -0
  247. package/skills/termcast/SKILL.md +945 -0
  248. package/skills/tuistory/SKILL.md +250 -0
  249. package/skills/usecomputer/SKILL.md +264 -0
  250. package/skills/x-articles/SKILL.md +554 -0
  251. package/skills/zele/SKILL.md +112 -0
  252. package/skills/zustand-centralized-state/SKILL.md +1004 -0
  253. package/src/agent-model.e2e.test.ts +976 -0
  254. package/src/ai-tool-to-genai.test.ts +296 -0
  255. package/src/ai-tool-to-genai.ts +283 -0
  256. package/src/ai-tool.ts +39 -0
  257. package/src/anthropic-auth-plugin.test.ts +159 -0
  258. package/src/anthropic-auth-plugin.ts +861 -0
  259. package/src/anthropic-auth-state.ts +282 -0
  260. package/src/bin.ts +111 -0
  261. package/src/channel-management.ts +334 -0
  262. package/src/cli-parsing.test.ts +195 -0
  263. package/src/cli-send-thread.e2e.test.ts +464 -0
  264. package/src/cli.ts +4581 -0
  265. package/src/commands/abort.ts +89 -0
  266. package/src/commands/action-buttons.ts +364 -0
  267. package/src/commands/add-project.ts +149 -0
  268. package/src/commands/agent.ts +473 -0
  269. package/src/commands/ask-question.ts +390 -0
  270. package/src/commands/btw.ts +164 -0
  271. package/src/commands/compact.ts +157 -0
  272. package/src/commands/context-usage.ts +199 -0
  273. package/src/commands/create-new-project.ts +190 -0
  274. package/src/commands/diff.ts +91 -0
  275. package/src/commands/file-upload.ts +389 -0
  276. package/src/commands/fork.ts +321 -0
  277. package/src/commands/gemini-apikey.ts +104 -0
  278. package/src/commands/login.ts +1173 -0
  279. package/src/commands/mcp.ts +307 -0
  280. package/src/commands/memory-snapshot.ts +30 -0
  281. package/src/commands/mention-mode.ts +68 -0
  282. package/src/commands/merge-worktree.ts +223 -0
  283. package/src/commands/model-variant.ts +483 -0
  284. package/src/commands/model.ts +1053 -0
  285. package/src/commands/new-worktree.ts +510 -0
  286. package/src/commands/paginated-select.ts +81 -0
  287. package/src/commands/permissions.ts +397 -0
  288. package/src/commands/queue.ts +271 -0
  289. package/src/commands/remove-project.ts +155 -0
  290. package/src/commands/restart-opencode-server.ts +162 -0
  291. package/src/commands/resume.ts +230 -0
  292. package/src/commands/run-command.ts +123 -0
  293. package/src/commands/screenshare.test.ts +30 -0
  294. package/src/commands/screenshare.ts +366 -0
  295. package/src/commands/session-id.ts +109 -0
  296. package/src/commands/session.ts +227 -0
  297. package/src/commands/share.ts +106 -0
  298. package/src/commands/tasks.ts +293 -0
  299. package/src/commands/types.ts +25 -0
  300. package/src/commands/undo-redo.ts +386 -0
  301. package/src/commands/unset-model.ts +173 -0
  302. package/src/commands/upgrade.ts +52 -0
  303. package/src/commands/user-command.ts +198 -0
  304. package/src/commands/verbosity.ts +173 -0
  305. package/src/commands/worktree-settings.ts +70 -0
  306. package/src/commands/worktrees.ts +552 -0
  307. package/src/condense-memory.ts +36 -0
  308. package/src/config.ts +111 -0
  309. package/src/context-awareness-plugin.test.ts +142 -0
  310. package/src/context-awareness-plugin.ts +510 -0
  311. package/src/critique-utils.ts +139 -0
  312. package/src/database.ts +1876 -0
  313. package/src/db.test.ts +162 -0
  314. package/src/db.ts +286 -0
  315. package/src/debounce-timeout.ts +43 -0
  316. package/src/debounced-process-flush.ts +104 -0
  317. package/src/discord-bot.ts +1330 -0
  318. package/src/discord-command-registration.ts +693 -0
  319. package/src/discord-urls.ts +88 -0
  320. package/src/discord-utils.test.ts +153 -0
  321. package/src/discord-utils.ts +800 -0
  322. package/src/errors.ts +201 -0
  323. package/src/escape-backticks.test.ts +469 -0
  324. package/src/event-stream-real-capture.e2e.test.ts +692 -0
  325. package/src/eventsource-parser.test.ts +351 -0
  326. package/src/exec-async.ts +35 -0
  327. package/src/external-opencode-sync.ts +685 -0
  328. package/src/format-tables.test.ts +335 -0
  329. package/src/format-tables.ts +445 -0
  330. package/src/forum-sync/config.ts +92 -0
  331. package/src/forum-sync/discord-operations.ts +241 -0
  332. package/src/forum-sync/index.ts +9 -0
  333. package/src/forum-sync/markdown.ts +172 -0
  334. package/src/forum-sync/sync-to-discord.ts +595 -0
  335. package/src/forum-sync/sync-to-files.ts +294 -0
  336. package/src/forum-sync/types.ts +175 -0
  337. package/src/forum-sync/watchers.ts +454 -0
  338. package/src/gateway-proxy-reconnect.e2e.test.ts +523 -0
  339. package/src/gateway-proxy.e2e.test.ts +640 -0
  340. package/src/genai-worker-wrapper.ts +164 -0
  341. package/src/genai-worker.ts +386 -0
  342. package/src/genai.ts +321 -0
  343. package/src/generated/browser.ts +114 -0
  344. package/src/generated/client.ts +138 -0
  345. package/src/generated/commonInputTypes.ts +736 -0
  346. package/src/generated/enums.ts +88 -0
  347. package/src/generated/internal/class.ts +384 -0
  348. package/src/generated/internal/prismaNamespace.ts +2386 -0
  349. package/src/generated/internal/prismaNamespaceBrowser.ts +326 -0
  350. package/src/generated/models/bot_api_keys.ts +1288 -0
  351. package/src/generated/models/bot_tokens.ts +1656 -0
  352. package/src/generated/models/channel_agents.ts +1256 -0
  353. package/src/generated/models/channel_directories.ts +1859 -0
  354. package/src/generated/models/channel_mention_mode.ts +1300 -0
  355. package/src/generated/models/channel_models.ts +1288 -0
  356. package/src/generated/models/channel_verbosity.ts +1228 -0
  357. package/src/generated/models/channel_worktrees.ts +1300 -0
  358. package/src/generated/models/forum_sync_configs.ts +1452 -0
  359. package/src/generated/models/global_models.ts +1288 -0
  360. package/src/generated/models/ipc_requests.ts +1485 -0
  361. package/src/generated/models/part_messages.ts +1302 -0
  362. package/src/generated/models/scheduled_tasks.ts +2320 -0
  363. package/src/generated/models/session_agents.ts +1086 -0
  364. package/src/generated/models/session_events.ts +1439 -0
  365. package/src/generated/models/session_models.ts +1114 -0
  366. package/src/generated/models/session_start_sources.ts +1408 -0
  367. package/src/generated/models/thread_sessions.ts +1781 -0
  368. package/src/generated/models/thread_worktrees.ts +1356 -0
  369. package/src/generated/models.ts +30 -0
  370. package/src/heap-monitor.ts +152 -0
  371. package/src/hrana-server.test.ts +434 -0
  372. package/src/hrana-server.ts +314 -0
  373. package/src/html-actions.test.ts +87 -0
  374. package/src/html-actions.ts +174 -0
  375. package/src/html-components.test.ts +38 -0
  376. package/src/html-components.ts +181 -0
  377. package/src/image-optimizer-plugin.ts +194 -0
  378. package/src/image-utils.ts +149 -0
  379. package/src/interaction-handler.ts +576 -0
  380. package/src/ipc-polling.ts +326 -0
  381. package/src/ipc-tools-plugin.ts +236 -0
  382. package/src/kimaki-digital-twin.e2e.test.ts +199 -0
  383. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +109 -0
  384. package/src/kimaki-opencode-plugin.test.ts +108 -0
  385. package/src/kimaki-opencode-plugin.ts +18 -0
  386. package/src/limit-heading-depth.test.ts +116 -0
  387. package/src/limit-heading-depth.ts +26 -0
  388. package/src/logger.ts +208 -0
  389. package/src/markdown.test.ts +308 -0
  390. package/src/markdown.ts +410 -0
  391. package/src/message-finish-field.e2e.test.ts +192 -0
  392. package/src/message-formatting.test.ts +81 -0
  393. package/src/message-formatting.ts +533 -0
  394. package/src/message-preprocessing.ts +455 -0
  395. package/src/onboarding-tutorial.ts +176 -0
  396. package/src/onboarding-welcome.ts +49 -0
  397. package/src/openai-realtime.ts +358 -0
  398. package/src/opencode-command-detection.test.ts +307 -0
  399. package/src/opencode-command-detection.ts +76 -0
  400. package/src/opencode-command.test.ts +70 -0
  401. package/src/opencode-command.ts +188 -0
  402. package/src/opencode-interrupt-plugin.test.ts +677 -0
  403. package/src/opencode-interrupt-plugin.ts +477 -0
  404. package/src/opencode.ts +1110 -0
  405. package/src/otto/branding.ts +23 -0
  406. package/src/otto/index.ts +22 -0
  407. package/src/parse-permission-rules.test.ts +127 -0
  408. package/src/patch-text-parser.ts +107 -0
  409. package/src/plugin-logger.ts +68 -0
  410. package/src/privacy-sanitizer.ts +142 -0
  411. package/src/queue-advanced-abort.e2e.test.ts +382 -0
  412. package/src/queue-advanced-action-buttons.e2e.test.ts +268 -0
  413. package/src/queue-advanced-e2e-setup.ts +873 -0
  414. package/src/queue-advanced-footer.e2e.test.ts +576 -0
  415. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  416. package/src/queue-advanced-permissions-typing.e2e.test.ts +245 -0
  417. package/src/queue-advanced-question.e2e.test.ts +316 -0
  418. package/src/queue-advanced-typing-interrupt.e2e.test.ts +146 -0
  419. package/src/queue-advanced-typing.e2e.test.ts +199 -0
  420. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  421. package/src/queue-interrupt-drain.e2e.test.ts +166 -0
  422. package/src/queue-question-select-drain.e2e.test.ts +152 -0
  423. package/src/runtime-idle-sweeper.ts +76 -0
  424. package/src/runtime-lifecycle.e2e.test.ts +641 -0
  425. package/src/schema.sql +173 -0
  426. package/src/sentry.ts +26 -0
  427. package/src/session-handler/agent-utils.ts +97 -0
  428. package/src/session-handler/event-stream-fixtures/real-session-action-buttons.jsonl +45 -0
  429. package/src/session-handler/event-stream-fixtures/real-session-footer-suppressed-on-pre-idle-interrupt.jsonl +40 -0
  430. package/src/session-handler/event-stream-fixtures/real-session-permission-external-file.jsonl +23 -0
  431. package/src/session-handler/event-stream-fixtures/real-session-task-normal.jsonl +22 -0
  432. package/src/session-handler/event-stream-fixtures/real-session-task-three-parallel-sleeps.jsonl +277 -0
  433. package/src/session-handler/event-stream-fixtures/real-session-task-user-interruption.jsonl +46 -0
  434. package/src/session-handler/event-stream-fixtures/session-abort-after-idle-race.jsonl +21 -0
  435. package/src/session-handler/event-stream-fixtures/session-concurrent-messages-serialized.jsonl +56 -0
  436. package/src/session-handler/event-stream-fixtures/session-explicit-abort.jsonl +44 -0
  437. package/src/session-handler/event-stream-fixtures/session-normal-completion.jsonl +29 -0
  438. package/src/session-handler/event-stream-fixtures/session-tool-call-noisy-stream.jsonl +29 -0
  439. package/src/session-handler/event-stream-fixtures/session-two-completions-same-session.jsonl +50 -0
  440. package/src/session-handler/event-stream-fixtures/session-user-interruption.jsonl +59 -0
  441. package/src/session-handler/event-stream-fixtures/session-voice-queued-followup.jsonl +52 -0
  442. package/src/session-handler/event-stream-state.test.ts +645 -0
  443. package/src/session-handler/event-stream-state.ts +608 -0
  444. package/src/session-handler/model-utils.ts +183 -0
  445. package/src/session-handler/opencode-session-event-log.ts +130 -0
  446. package/src/session-handler/thread-runtime-state.ts +212 -0
  447. package/src/session-handler/thread-session-runtime.ts +4281 -0
  448. package/src/session-handler.ts +15 -0
  449. package/src/session-search.test.ts +50 -0
  450. package/src/session-search.ts +148 -0
  451. package/src/session-title-rename.test.ts +112 -0
  452. package/src/startup-service.ts +200 -0
  453. package/src/startup-time.e2e.test.ts +373 -0
  454. package/src/store.ts +122 -0
  455. package/src/system-message.test.ts +612 -0
  456. package/src/system-message.ts +723 -0
  457. package/src/task-runner.ts +421 -0
  458. package/src/task-schedule.test.ts +84 -0
  459. package/src/task-schedule.ts +311 -0
  460. package/src/test-utils.ts +435 -0
  461. package/src/thinking-utils.ts +61 -0
  462. package/src/thread-message-queue.e2e.test.ts +1219 -0
  463. package/src/tools.ts +430 -0
  464. package/src/undici.d.ts +12 -0
  465. package/src/undo-redo.e2e.test.ts +209 -0
  466. package/src/unnest-code-blocks.test.ts +713 -0
  467. package/src/unnest-code-blocks.ts +185 -0
  468. package/src/upgrade.ts +127 -0
  469. package/src/utils.ts +212 -0
  470. package/src/voice-attachment.ts +51 -0
  471. package/src/voice-handler.ts +908 -0
  472. package/src/voice-message.e2e.test.ts +1255 -0
  473. package/src/voice.test.ts +281 -0
  474. package/src/voice.ts +627 -0
  475. package/src/wait-session.ts +147 -0
  476. package/src/websockify.ts +101 -0
  477. package/src/worker-types.ts +64 -0
  478. package/src/worktree-lifecycle.e2e.test.ts +391 -0
  479. package/src/worktree-utils.ts +4 -0
  480. package/src/worktrees.test.ts +223 -0
  481. package/src/worktrees.ts +1294 -0
  482. package/src/xml.test.ts +38 -0
  483. package/src/xml.ts +121 -0
@@ -0,0 +1,4281 @@
1
+ // ThreadSessionRuntime — one per active thread.
2
+ // Owns resource handles (listener controller, typing timers, part buffer).
3
+ // Delegates all state to the global store via thread-runtime-state.ts transitions.
4
+ //
5
+ // This is the sole session orchestrator. Discord handlers and slash commands
6
+ // call runtime APIs (enqueueIncoming, abortActiveRun, etc.) without inspecting
7
+ // run internals.
8
+
9
+ import { ChannelType, type ThreadChannel } from 'discord.js'
10
+ import type {
11
+ Event as OpenCodeEvent,
12
+ Part,
13
+ PermissionRequest,
14
+ QuestionRequest,
15
+ Message as OpenCodeMessage,
16
+ } from '@opencode-ai/sdk/v2'
17
+ import path from 'node:path'
18
+ import prettyMilliseconds from 'pretty-ms'
19
+ import * as errore from 'errore'
20
+ import * as threadState from './thread-runtime-state.js'
21
+ import type { QueuedMessage } from './thread-runtime-state.js'
22
+ import type { OpencodeClient } from '@opencode-ai/sdk/v2'
23
+ import {
24
+ getOpencodeClient,
25
+ initializeOpencodeForDirectory,
26
+ buildSessionPermissions,
27
+ parsePermissionRules,
28
+ subscribeOpencodeServerLifecycle,
29
+ writeInjectionGuardConfig,
30
+ } from '../opencode.js'
31
+ import { isAbortError } from '../utils.js'
32
+ import { createLogger, LogPrefix } from '../logger.js'
33
+ import {
34
+ sendThreadMessage,
35
+ SILENT_MESSAGE_FLAGS,
36
+ NOTIFY_MESSAGE_FLAGS,
37
+ } from '../discord-utils.js'
38
+ import type { DiscordFileAttachment } from '../message-formatting.js'
39
+ import { formatPart } from '../message-formatting.js'
40
+ import {
41
+ getChannelVerbosity,
42
+ getPartMessageIds,
43
+ setPartMessage,
44
+ getThreadSession,
45
+ setThreadSession,
46
+ getThreadWorktree,
47
+ setSessionAgent,
48
+ getVariantCascade,
49
+ setSessionStartSource,
50
+ appendSessionEventsSinceLastTimestamp,
51
+ getSessionEventSnapshot,
52
+ } from '../database.js'
53
+ import {
54
+ showPermissionButtons,
55
+ cleanupPermissionContext,
56
+ addPermissionRequestToContext,
57
+ arePatternsCoveredBy,
58
+ pendingPermissionContexts,
59
+ } from '../commands/permissions.js'
60
+ import {
61
+ showAskUserQuestionDropdowns,
62
+ pendingQuestionContexts,
63
+ cancelPendingQuestion,
64
+ } from '../commands/ask-question.js'
65
+ import {
66
+ showActionButtons,
67
+ waitForQueuedActionButtonsRequest,
68
+ pendingActionButtonContexts,
69
+ cancelPendingActionButtons,
70
+ } from '../commands/action-buttons.js'
71
+ import {
72
+ pendingFileUploadContexts,
73
+ cancelPendingFileUpload,
74
+ } from '../commands/file-upload.js'
75
+ import {
76
+ getCurrentModelInfo,
77
+ ensureSessionPreferencesSnapshot,
78
+ } from '../commands/model.js'
79
+ import {
80
+ getOpencodePromptContext,
81
+ getOpencodeSystemMessage,
82
+ type AgentInfo,
83
+ type RepliedMessageContext,
84
+ type WorktreeInfo,
85
+ } from '../system-message.js'
86
+ import { resolveValidatedAgentPreference } from './agent-utils.js'
87
+ import {
88
+ appendOpencodeSessionEventLog,
89
+ getOpencodeEventSessionId,
90
+ isOpencodeSessionEventLogEnabled,
91
+ } from './opencode-session-event-log.js'
92
+ import {
93
+ doesLatestUserTurnHaveNaturalCompletion,
94
+ getAssistantMessageIdsForLatestUserTurn,
95
+ getCurrentTurnStartTime,
96
+ isSessionBusy,
97
+ getLatestRunInfo,
98
+ getDerivedSubtaskIndex,
99
+ getDerivedSubtaskAgentType,
100
+ getLatestAssistantMessageIdForLatestUserTurn,
101
+ hasAssistantMessageCompletedBefore,
102
+ isAssistantMessageInLatestUserTurn,
103
+ isAssistantMessageNaturalCompletion,
104
+ type EventBufferEntry,
105
+ } from './event-stream-state.js'
106
+
107
+ // Track multiple pending permissions per thread (keyed by permission ID).
108
+ // OpenCode handles blocking/sequencing — we just need to track all pending
109
+ // permissions to avoid duplicates and properly clean up on reply/teardown.
110
+ // The runtime is the sole owner of pending permissions per thread.
111
+ export const pendingPermissions = new Map<
112
+ string, // threadId
113
+ Map<
114
+ string,
115
+ {
116
+ permission: PermissionRequest
117
+ messageId: string
118
+ directory: string
119
+ permissionDirectory: string
120
+ contextHash: string
121
+ dedupeKey: string
122
+ }
123
+ > // permissionId -> data
124
+ >()
125
+ import {
126
+ getThinkingValuesForModel,
127
+ matchThinkingValue,
128
+ } from '../thinking-utils.js'
129
+ import { execAsync } from '../worktrees.js'
130
+
131
+ import { notifyError } from '../sentry.js'
132
+ import { createDebouncedProcessFlush } from '../debounced-process-flush.js'
133
+ import { cancelHtmlActionsForThread } from '../html-actions.js'
134
+ import { createDebouncedTimeout } from '../debounce-timeout.js'
135
+ import { extractLeadingOpencodeCommand } from '../opencode-command-detection.js'
136
+
137
+ const logger = createLogger(LogPrefix.SESSION)
138
+ const discordLogger = createLogger(LogPrefix.DISCORD)
139
+ const DETERMINISTIC_CONTEXT_LIMIT = 100_000
140
+ const shouldLogSessionEvents =
141
+ process.env['KIMAKI_LOG_SESSION_EVENTS'] === '1' ||
142
+ process.env['KIMAKI_VITEST'] === '1'
143
+
144
+ // ── Registry ─────────────────────────────────────────────────────
145
+ // Runtime instances are kept in a plain Map (not Zustand — the Map
146
+ // is not reactive state, just a lookup for resource handles).
147
+
148
+ const runtimes = new Map<string, ThreadSessionRuntime>()
149
+
150
+ subscribeOpencodeServerLifecycle((event) => {
151
+ if (event.type !== 'started') {
152
+ return
153
+ }
154
+ for (const runtime of runtimes.values()) {
155
+ runtime.handleSharedServerStarted({ port: event.port })
156
+ }
157
+ })
158
+
159
+ export function getRuntime(
160
+ threadId: string,
161
+ ): ThreadSessionRuntime | undefined {
162
+ return runtimes.get(threadId)
163
+ }
164
+
165
+ export type RuntimeOptions = {
166
+ threadId: string
167
+ thread: ThreadChannel
168
+ projectDirectory: string
169
+ sdkDirectory: string
170
+ channelId?: string
171
+ appId?: string
172
+ }
173
+
174
+ export function getOrCreateRuntime(
175
+ opts: RuntimeOptions,
176
+ ): ThreadSessionRuntime {
177
+ const existing = runtimes.get(opts.threadId)
178
+ if (existing) {
179
+ // Reconcile sdkDirectory: worktree threads transition from pending
180
+ // (projectDirectory) to ready (worktree path) after runtime creation.
181
+ if (existing.sdkDirectory !== opts.sdkDirectory) {
182
+ existing.handleDirectoryChanged({
183
+ oldDirectory: existing.sdkDirectory,
184
+ newDirectory: opts.sdkDirectory,
185
+ })
186
+ }
187
+ return existing
188
+ }
189
+ threadState.ensureThread(opts.threadId) // add to global store
190
+ const runtime = new ThreadSessionRuntime(opts)
191
+ runtimes.set(opts.threadId, runtime)
192
+ return runtime
193
+ }
194
+
195
+ export function disposeRuntime(threadId: string): void {
196
+ const runtime = runtimes.get(threadId)
197
+ if (!runtime) {
198
+ return
199
+ }
200
+ runtime.dispose()
201
+ runtimes.delete(threadId)
202
+ threadState.removeThread(threadId) // remove from global store
203
+ }
204
+
205
+ export function disposeRuntimesForDirectory({
206
+ directory,
207
+ channelId,
208
+ }: {
209
+ directory: string
210
+ channelId?: string
211
+ }): number {
212
+ let count = 0
213
+ for (const [threadId, runtime] of runtimes) {
214
+ if (runtime.projectDirectory !== directory) {
215
+ continue
216
+ }
217
+ if (channelId && runtime.channelId !== channelId) {
218
+ continue
219
+ }
220
+ runtime.dispose()
221
+ runtimes.delete(threadId)
222
+ threadState.removeThread(threadId)
223
+ count++
224
+ }
225
+ return count
226
+ }
227
+
228
+ /** Returns number of active runtimes (useful for diagnostics). */
229
+ export function getRuntimeCount(): number {
230
+ return runtimes.size
231
+ }
232
+
233
+ export function disposeInactiveRuntimes({
234
+ idleMs,
235
+ nowMs = Date.now(),
236
+ }: {
237
+ idleMs: number
238
+ nowMs?: number
239
+ }): {
240
+ disposedThreadIds: string[]
241
+ disposedDirectories: string[]
242
+ } {
243
+ const candidates = [...runtimes.entries()].filter(([, runtime]) => {
244
+ return runtime.isIdleForInactivityTimeout({ idleMs, nowMs })
245
+ })
246
+ const disposedDirectories = new Set<string>()
247
+ const disposedThreadIds: string[] = []
248
+
249
+ for (const [threadId, runtime] of candidates) {
250
+ runtime.dispose()
251
+ runtimes.delete(threadId)
252
+ threadState.removeThread(threadId)
253
+ disposedThreadIds.push(threadId)
254
+ disposedDirectories.add(runtime.projectDirectory)
255
+ }
256
+
257
+ return {
258
+ disposedThreadIds,
259
+ disposedDirectories: [...disposedDirectories],
260
+ }
261
+ }
262
+
263
+ // ── Pending UI cleanup ───────────────────────────────────────────
264
+ // Clears all pending interactive UI state for a thread on dispose/delete.
265
+ // Uses existing cancel functions which handle upstream replies (so OpenCode
266
+ // doesn't hang waiting for answers that will never come).
267
+
268
+ function cleanupPendingUiForThread(threadId: string): void {
269
+ // Permissions: reject each pending permission so OpenCode doesn't hang,
270
+ // then delete the per-thread tracking map.
271
+ const threadPerms = pendingPermissions.get(threadId)
272
+ if (threadPerms) {
273
+ for (const [, entry] of threadPerms) {
274
+ const ctx = pendingPermissionContexts.get(entry.contextHash)
275
+ if (ctx) {
276
+ const client = getOpencodeClient(ctx.directory)
277
+ if (client) {
278
+ const requestIds: string[] = ctx.requestIds.length > 0
279
+ ? ctx.requestIds
280
+ : [ctx.permission.id]
281
+ void Promise.all(
282
+ requestIds.map((requestId) => {
283
+ return client.permission.reply({
284
+ requestID: requestId,
285
+ directory: ctx.permissionDirectory,
286
+ reply: 'reject',
287
+ })
288
+ }),
289
+ ).catch(() => {})
290
+ }
291
+ pendingPermissionContexts.delete(entry.contextHash)
292
+ }
293
+ }
294
+ pendingPermissions.delete(threadId)
295
+ }
296
+
297
+ // Questions: cancel deletes pending context without replying to OpenCode.
298
+ void cancelPendingQuestion(threadId)
299
+
300
+ // Action buttons: resolves context and clears timer.
301
+ cancelPendingActionButtons(threadId)
302
+
303
+ // File uploads: resolves with empty files so OpenCode unblocks.
304
+ void cancelPendingFileUpload(threadId)
305
+
306
+ // HTML actions: clears registered action callbacks for this thread.
307
+ cancelHtmlActionsForThread(threadId)
308
+ }
309
+
310
+ // ── Helpers ──────────────────────────────────────────────────────
311
+
312
+ function delay(ms: number): Promise<void> {
313
+ return new Promise((resolve) => {
314
+ setTimeout(resolve, ms)
315
+ })
316
+ }
317
+
318
+ function getTimestampFromSnowflake(snowflake: string): number | undefined {
319
+ const discordEpochMs = 1_420_070_400_000n
320
+ const snowflakeIdResult = errore.try({
321
+ try: () => {
322
+ return BigInt(snowflake)
323
+ },
324
+ catch: () => {
325
+ return new Error('Invalid Discord snowflake')
326
+ },
327
+ })
328
+ if (snowflakeIdResult instanceof Error) {
329
+ return undefined
330
+ }
331
+ const timestampBigInt = (snowflakeIdResult >> 22n) + discordEpochMs
332
+ const timestampMs = Number(timestampBigInt)
333
+ if (!Number.isFinite(timestampMs) || timestampMs <= 0) {
334
+ return undefined
335
+ }
336
+ return timestampMs
337
+ }
338
+
339
+ type TokenUsage = {
340
+ input: number
341
+ output: number
342
+ reasoning: number
343
+ cache: { read: number; write: number }
344
+ }
345
+
346
+ function getTokenTotal(tokens: TokenUsage): number {
347
+ return (
348
+ tokens.input +
349
+ tokens.output +
350
+ tokens.reasoning +
351
+ tokens.cache.read +
352
+ tokens.cache.write
353
+ )
354
+ }
355
+
356
+ /** Check if a tool part is "essential" (shown in text-and-essential-tools mode). */
357
+ export function isEssentialToolName(toolName: string): boolean {
358
+ const essentialTools = [
359
+ 'edit',
360
+ 'write',
361
+ 'apply_patch',
362
+ 'bash',
363
+ 'webfetch',
364
+ 'websearch',
365
+ 'googlesearch',
366
+ 'codesearch',
367
+ 'task',
368
+ 'todowrite',
369
+ 'skill',
370
+ ]
371
+ // Also match any MCP tool that contains these names
372
+ return essentialTools.some((name) => {
373
+ return toolName === name || toolName.endsWith(`_${name}`)
374
+ })
375
+ }
376
+
377
+ export function isEssentialToolPart(part: Part): boolean {
378
+ if (part.type !== 'tool') {
379
+ return false
380
+ }
381
+ if (!isEssentialToolName(part.tool)) {
382
+ return false
383
+ }
384
+ if (part.tool === 'bash') {
385
+ const hasSideEffect = part.state.input?.hasSideEffect
386
+ return hasSideEffect !== false
387
+ }
388
+ return true
389
+ }
390
+
391
+ // ── Thread title derivation ──────────────────────────────────────
392
+
393
+ const DISCORD_THREAD_NAME_MAX = 100
394
+ const WORKTREE_THREAD_PREFIX = '⬦ '
395
+
396
+ // Pure derivation: given an OpenCode session title and the current thread name,
397
+ // return the new thread name to apply, or undefined when no rename is needed.
398
+ // - Skips placeholder titles ("New Session - ...") to match external-sync.
399
+ // - Preserves worktree prefix when the current name carries it.
400
+ // - Returns undefined when the candidate matches currentName already.
401
+ export function deriveThreadNameFromSessionTitle({
402
+ sessionTitle,
403
+ currentName,
404
+ }: {
405
+ sessionTitle: string | undefined | null
406
+ currentName: string
407
+ }): string | undefined {
408
+ const trimmed = sessionTitle?.trim()
409
+ if (!trimmed) {
410
+ return undefined
411
+ }
412
+ if (/^new session\s*-/i.test(trimmed)) {
413
+ return undefined
414
+ }
415
+ const hasWorktreePrefix = currentName.startsWith(WORKTREE_THREAD_PREFIX)
416
+ const prefix = hasWorktreePrefix ? WORKTREE_THREAD_PREFIX : ''
417
+ const candidate = `${prefix}${trimmed}`.slice(0, DISCORD_THREAD_NAME_MAX)
418
+ if (candidate === currentName) {
419
+ return undefined
420
+ }
421
+ return candidate
422
+ }
423
+
424
+ // ── Ingress input type ───────────────────────────────────────────
425
+
426
+ export type EnqueueResult = {
427
+ /** True if the message is waiting in queue behind an active run. */
428
+ queued: boolean
429
+ /** Queue position (1-based). Only set when queued is true. */
430
+ position?: number
431
+ }
432
+
433
+ /**
434
+ * Result of the preprocess callback. Returns the resolved prompt, images,
435
+ * and mode after expensive async work (voice transcription, context fetch,
436
+ * attachment download) completes.
437
+ */
438
+ export type PreprocessResult = {
439
+ prompt: string
440
+ images?: DiscordFileAttachment[]
441
+ repliedMessage?: RepliedMessageContext
442
+ /** Resolved mode based on voice transcription result. */
443
+ mode: 'opencode' | 'local-queue'
444
+ /** When true, preprocessing determined the message should be silently dropped. */
445
+ skip?: boolean
446
+ /** Agent name extracted from voice transcription. Applied to the session if set. */
447
+ agent?: string
448
+ }
449
+
450
+ export type IngressInput = {
451
+ prompt: string
452
+ userId: string
453
+ username: string
454
+ // Discord message ID and thread ID for the source message, embedded in
455
+ // <discord-user> synthetic context so the external sync loop can detect
456
+ // messages that originated from Discord and skip re-mirroring them.
457
+ sourceMessageId?: string
458
+ sourceThreadId?: string
459
+ repliedMessage?: RepliedMessageContext
460
+ images?: DiscordFileAttachment[]
461
+ appId?: string
462
+ command?: { name: string; arguments: string }
463
+ /**
464
+ * `opencode` (default): send via session.promptAsync and let opencode
465
+ * serialize pending user turns internally.
466
+ * `local-queue`: keep in kimaki's local queue (used by /queue flows).
467
+ */
468
+ mode?: 'opencode' | 'local-queue'
469
+ // Force a new assistant-part routing window by resetting run-state to
470
+ // running before enqueue. Used by model-switch retry flows where old
471
+ // assistant IDs can linger briefly after abort.
472
+ resetAssistantForNewRun?: boolean
473
+ // First-dispatch-only overrides (used when creating a new session)
474
+ agent?: string
475
+ model?: string
476
+ /**
477
+ * Raw permission rule strings from --permission flag ("tool:action" or
478
+ * "tool:pattern:action"). Parsed into PermissionRuleset entries by
479
+ * parsePermissionRules() and appended after buildSessionPermissions()
480
+ * so they win via opencode's findLast() evaluation. Only used on
481
+ * session creation (first dispatch).
482
+ */
483
+ permissions?: string[]
484
+ injectionGuardPatterns?: string[]
485
+ sessionStartSource?: { scheduleKind: 'at' | 'cron'; scheduledTaskId?: number }
486
+ /** Optional guard for retries: skip enqueue when session has changed. */
487
+ expectedSessionId?: string
488
+ /**
489
+ * Lazy preprocessing callback. When set, the runtime serializes it via a
490
+ * lightweight promise chain (preprocessChain) to resolve prompt/images/mode
491
+ * from the raw Discord message. This replaces the threadIngressQueue in
492
+ * discord-bot.ts: expensive async work (voice transcription, context fetch,
493
+ * attachment download) runs in arrival order but outside dispatchAction,
494
+ * so SSE event handling and permission UI are not blocked.
495
+ *
496
+ * The closure captures Discord objects (Message, ThreadChannel) so the
497
+ * runtime stays platform-agnostic — it just awaits the callback.
498
+ */
499
+ preprocess?: () => Promise<PreprocessResult>
500
+ }
501
+
502
+ // Rewrite `{ prompt: "/build foo" }` → `{ prompt: "", command: { name, arguments }, mode: "local-queue" }`
503
+ // when the prompt's leading token matches a registered opencode command.
504
+ // Skip if a command is already set or there's no prompt to inspect.
505
+ function maybeConvertLeadingCommand(input: IngressInput): IngressInput {
506
+ if (input.command) return input
507
+ if (!input.prompt) return input
508
+ const extracted = extractLeadingOpencodeCommand(input.prompt)
509
+ if (!extracted) return input
510
+ return {
511
+ ...input,
512
+ prompt: '',
513
+ command: extracted.command,
514
+ mode: 'local-queue',
515
+ }
516
+ }
517
+
518
+ type AbortRunOutcome = {
519
+ abortId: string
520
+ reason: string
521
+ apiAbortPromise: Promise<void> | undefined
522
+ }
523
+
524
+ function getWorktreePromptKey(worktree: WorktreeInfo | undefined): string | null {
525
+ if (!worktree) {
526
+ return null
527
+ }
528
+ return [
529
+ worktree.worktreeDirectory,
530
+ worktree.branch,
531
+ worktree.mainRepoDirectory,
532
+ ].join('::')
533
+ }
534
+
535
+
536
+ // ── Runtime class ────────────────────────────────────────────────
537
+
538
+ export class ThreadSessionRuntime {
539
+ readonly threadId: string
540
+ readonly projectDirectory: string
541
+ // Mutable: worktree threads transition from pending (projectDirectory)
542
+ // to ready (worktree path) after creation. getOrCreateRuntime reconciles
543
+ // this on each call so dispatch always uses the current path.
544
+ sdkDirectory: string
545
+ readonly channelId: string | undefined
546
+ readonly appId: string | undefined
547
+ readonly thread: ThreadChannel
548
+
549
+ // ── Resource handles (mechanisms, not domain state) ──
550
+
551
+ // Reentrancy guard for startEventListener (not domain state —
552
+ // just prevents calling the async loop twice).
553
+ private listenerLoopRunning = false
554
+
555
+ // Set to true by dispose(). Guards against queued work running after cleanup
556
+ // and lets dispatchAction/startEventListener bail out early.
557
+ private disposed = false
558
+
559
+ // Typing indicator scheduler handles.
560
+ // `typingKeepaliveTimeout` is the 7s keepalive loop while a run stays busy.
561
+ // `typingRepulseDebounce` collapses clustered immediate re-pulses after bot
562
+ // messages into one last pulse, because Discord hides typing on the next bot
563
+ // message and showing multiple back-to-back POSTs is wasteful.
564
+ private typingKeepaliveTimeout: ReturnType<typeof setTimeout> | null = null
565
+ private readonly typingRepulseDebounce: ReturnType<typeof createDebouncedTimeout>
566
+
567
+ private static TYPING_REPULSE_DEBOUNCE_MS = 500
568
+
569
+ // Notification throttles for retry/context notices.
570
+ private lastDisplayedContextPercentage = 0
571
+ private lastRateLimitDisplayTime = 0
572
+
573
+ // Last OpenCode-generated session title we successfully applied to the
574
+ // Discord thread name. Used to dedupe repeated session.updated events so
575
+ // we only call thread.setName() once per distinct title. Discord rate-limits
576
+ // channel/thread renames to ~2 per 10 minutes per thread, so we must avoid
577
+ // retrying. Not persisted — worst case on restart we re-apply the same title
578
+ // once (which is a no-op via deriveThreadNameFromSessionTitle).
579
+ private appliedOpencodeTitle: string | undefined
580
+
581
+ // Part output buffering (write-side cache, not domain state)
582
+ private partBuffer = new Map<string, Map<string, Part>>()
583
+
584
+ // Derivable cache (perf optimization for provider.list API call)
585
+ private modelContextLimit: number | undefined
586
+ private modelContextLimitKey: string | undefined
587
+ private lastPromptWorktreeKey: string | null | undefined
588
+
589
+ // Bounded buffer of recent SSE events with timestamps.
590
+ // Used by waitForEvent() to scan for specific events that arrived
591
+ // after a given point in time (e.g. wait for session.idle after abort).
592
+ // Generic: any future "wait for X event" can reuse this buffer.
593
+ private static EVENT_BUFFER_MAX = 1000
594
+ private static EVENT_BUFFER_DB_FLUSH_MS = 2_000
595
+ private static EVENT_BUFFER_TEXT_MAX_CHARS = 512
596
+ private eventBuffer: EventBufferEntry[] = []
597
+ private nextEventIndex = 0
598
+ private persistEventBufferDebounced: ReturnType<
599
+ typeof createDebouncedProcessFlush
600
+ >
601
+
602
+ // Serialized action queue for per-thread runtime transitions.
603
+ // Ingress and event handling both flow through this queue to keep ordering
604
+ // deterministic and avoid interleaving shared mutable structures.
605
+ private actionQueue: Array<() => Promise<void>> = []
606
+ private processingAction = false
607
+
608
+ // Lightweight promise chain for serializing preprocess callbacks.
609
+ // Runs OUTSIDE dispatchAction so heavy work (voice transcription, context
610
+ // fetch, attachment download) doesn't block SSE event handling, permission
611
+ // UI, or queue drain. Only preprocess ordering is serialized here; the
612
+ // resolved input is then routed through the normal enqueue paths which
613
+ // use dispatchAction internally.
614
+ private preprocessChain: Promise<void> = Promise.resolve()
615
+
616
+ constructor(opts: RuntimeOptions) {
617
+ this.threadId = opts.threadId
618
+ this.projectDirectory = opts.projectDirectory
619
+ this.sdkDirectory = opts.sdkDirectory
620
+ this.channelId = opts.channelId
621
+ this.appId = opts.appId
622
+ this.thread = opts.thread
623
+ threadState.updateThread(this.threadId, (t) => ({
624
+ ...t,
625
+ listenerController: new AbortController(),
626
+ }))
627
+ this.persistEventBufferDebounced = createDebouncedProcessFlush({
628
+ waitMs: ThreadSessionRuntime.EVENT_BUFFER_DB_FLUSH_MS,
629
+ callback: async () => {
630
+ await this.persistSessionEventsToDatabase()
631
+ },
632
+ onError: (error) => {
633
+ logger.error(
634
+ `[SESSION EVENT DB] Debounced persistence failed for thread ${this.threadId}:`,
635
+ error,
636
+ )
637
+ },
638
+ })
639
+ this.typingRepulseDebounce = createDebouncedTimeout({
640
+ delayMs: ThreadSessionRuntime.TYPING_REPULSE_DEBOUNCE_MS,
641
+ callback: () => {
642
+ if (!this.shouldTypeNow()) {
643
+ return
644
+ }
645
+ this.restartTypingKeepalive({ sendNow: true })
646
+ },
647
+ })
648
+ }
649
+
650
+ private consumeWorktreePromptChange(
651
+ worktree: WorktreeInfo | undefined,
652
+ ): boolean {
653
+ const nextKey = getWorktreePromptKey(worktree)
654
+ const changed = this.lastPromptWorktreeKey !== nextKey
655
+ this.lastPromptWorktreeKey = nextKey
656
+ return changed
657
+ }
658
+
659
+ // Read own state from global store
660
+ get state(): threadState.ThreadRunState | undefined {
661
+ return threadState.getThreadState(this.threadId)
662
+ }
663
+
664
+ getDerivedPhase(): 'idle' | 'running' {
665
+ return this.isMainSessionBusy() ? 'running' : 'idle'
666
+ }
667
+
668
+ /** Whether the listener has been disposed. */
669
+ private get listenerAborted(): boolean {
670
+ return this.state?.listenerController?.signal.aborted ?? true
671
+ }
672
+
673
+ /** The listener AbortSignal, used to pass to SDK subscribe calls. */
674
+ private get listenerSignal(): AbortSignal | undefined {
675
+ return this.state?.listenerController?.signal
676
+ }
677
+
678
+ private getLastRuntimeActivityTimestamp({
679
+ nowMs: _nowMs,
680
+ }: {
681
+ nowMs: number
682
+ }): number {
683
+ const lastEvent = this.eventBuffer[this.eventBuffer.length - 1]
684
+ const lastEventTimestamp = lastEvent?.timestamp
685
+ if (typeof lastEventTimestamp === 'number' && Number.isFinite(lastEventTimestamp)) {
686
+ return lastEventTimestamp
687
+ }
688
+ const threadCreatedTimestamp = this.thread.createdTimestamp
689
+ if (
690
+ typeof threadCreatedTimestamp === 'number'
691
+ && Number.isFinite(threadCreatedTimestamp)
692
+ && threadCreatedTimestamp > 0
693
+ ) {
694
+ return threadCreatedTimestamp
695
+ }
696
+ const snowflakeTimestamp = getTimestampFromSnowflake(this.thread.id)
697
+ if (snowflakeTimestamp) {
698
+ return snowflakeTimestamp
699
+ }
700
+ return 0
701
+ }
702
+
703
+ private isIdleCandidateForInactivityCheck(): boolean {
704
+ if (this.isMainSessionBusy()) {
705
+ return false
706
+ }
707
+ if ((this.state?.queueItems.length ?? 0) > 0) {
708
+ return false
709
+ }
710
+ if (this.hasPendingInteractiveUi()) {
711
+ return false
712
+ }
713
+ if (this.processingAction || this.actionQueue.length > 0) {
714
+ return false
715
+ }
716
+ return true
717
+ }
718
+
719
+ getInactivitySnapshot({
720
+ nowMs,
721
+ }: {
722
+ nowMs: number
723
+ }): {
724
+ idleCandidate: boolean
725
+ inactiveForMs: number
726
+ } {
727
+ const lastActivityTimestamp = this.getLastRuntimeActivityTimestamp({ nowMs })
728
+ return {
729
+ idleCandidate: this.isIdleCandidateForInactivityCheck(),
730
+ inactiveForMs: Math.max(0, nowMs - lastActivityTimestamp),
731
+ }
732
+ }
733
+
734
+ isIdleForInactivityTimeout({
735
+ idleMs,
736
+ nowMs,
737
+ }: {
738
+ idleMs: number
739
+ nowMs: number
740
+ }): boolean {
741
+ const snapshot = this.getInactivitySnapshot({ nowMs })
742
+ if (!snapshot.idleCandidate) {
743
+ return false
744
+ }
745
+ return snapshot.inactiveForMs >= idleMs
746
+ }
747
+
748
+ private async hydrateSessionEventsFromDatabase({
749
+ sessionId,
750
+ }: {
751
+ sessionId: string
752
+ }): Promise<void> {
753
+ if (this.eventBuffer.length > 0) {
754
+ return
755
+ }
756
+
757
+ const rows = await getSessionEventSnapshot({ sessionId })
758
+ if (rows.length === 0) {
759
+ return
760
+ }
761
+
762
+ const hydratedEvents: EventBufferEntry[] = rows.flatMap((row) => {
763
+ const eventResult = errore.try({
764
+ try: () => {
765
+ return JSON.parse(row.event_json) as OpenCodeEvent
766
+ },
767
+ catch: (error) => {
768
+ return new Error('Failed to parse persisted session event JSON', {
769
+ cause: error,
770
+ })
771
+ },
772
+ })
773
+ if (eventResult instanceof Error) {
774
+ logger.warn(
775
+ `[SESSION EVENT DB] Skipping invalid persisted event row for session ${sessionId}: ${eventResult.message}`,
776
+ )
777
+ return []
778
+ }
779
+ return [
780
+ {
781
+ event: eventResult,
782
+ timestamp: Number(row.timestamp),
783
+ eventIndex: Number(row.event_index),
784
+ },
785
+ ]
786
+ })
787
+
788
+ this.eventBuffer = hydratedEvents.slice(-ThreadSessionRuntime.EVENT_BUFFER_MAX)
789
+ const lastHydratedEvent = this.eventBuffer[this.eventBuffer.length - 1]
790
+ this.nextEventIndex = lastHydratedEvent
791
+ ? Number(lastHydratedEvent.eventIndex || 0) + 1
792
+ : 0
793
+ logger.log(
794
+ `[SESSION EVENT DB] Hydrated ${this.eventBuffer.length} events for session ${sessionId}`,
795
+ )
796
+ }
797
+
798
+ private async persistSessionEventsToDatabase(): Promise<void> {
799
+ const sessionId = this.state?.sessionId
800
+ if (!sessionId) {
801
+ return
802
+ }
803
+
804
+ const events = this.eventBuffer.flatMap((entry) => {
805
+ const eventSessionId = getOpencodeEventSessionId(entry.event)
806
+ if (eventSessionId !== sessionId) {
807
+ return []
808
+ }
809
+ return [
810
+ {
811
+ session_id: sessionId,
812
+ thread_id: this.threadId,
813
+ timestamp: BigInt(entry.timestamp),
814
+ event_index: entry.eventIndex || 0,
815
+ event_json: JSON.stringify(entry.event),
816
+ },
817
+ ]
818
+ })
819
+
820
+ await appendSessionEventsSinceLastTimestamp({
821
+ sessionId,
822
+ events,
823
+ })
824
+ }
825
+
826
+ private nextAbortId(reason: string): string {
827
+ return `${reason}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
828
+ }
829
+
830
+ private formatRunStateForLog(): string {
831
+ const sessionId = this.state?.sessionId
832
+ if (!sessionId) {
833
+ return 'none'
834
+ }
835
+ const latestAssistant = this.getLatestAssistantMessageIdForCurrentTurn({
836
+ sessionId,
837
+ }) || 'none'
838
+ const assistantCount = this.getAssistantMessageIdsForCurrentTurn({
839
+ sessionId,
840
+ }).size
841
+ const phase = this.getDerivedPhase()
842
+ return `phase=${phase},assistant=${latestAssistant},assistantCount=${assistantCount}`
843
+ }
844
+
845
+ private isMainSessionBusy(): boolean {
846
+ const sessionId = this.state?.sessionId
847
+ if (!sessionId) {
848
+ return false
849
+ }
850
+ return isSessionBusy({ events: this.eventBuffer, sessionId })
851
+ }
852
+
853
+ private getAssistantMessageIdsForCurrentTurn({
854
+ sessionId,
855
+ upToIndex,
856
+ }: {
857
+ sessionId: string
858
+ upToIndex?: number
859
+ }): Set<string> {
860
+ const normalizedIndex = upToIndex === undefined ? undefined : upToIndex - 1
861
+ return getAssistantMessageIdsForLatestUserTurn({
862
+ events: this.eventBuffer,
863
+ sessionId,
864
+ upToIndex: normalizedIndex,
865
+ })
866
+ }
867
+
868
+ private getLatestAssistantMessageIdForCurrentTurn({
869
+ sessionId,
870
+ upToIndex,
871
+ }: {
872
+ sessionId: string
873
+ upToIndex?: number
874
+ }): string | undefined {
875
+ const normalizedIndex = upToIndex === undefined ? undefined : upToIndex - 1
876
+ return getLatestAssistantMessageIdForLatestUserTurn({
877
+ events: this.eventBuffer,
878
+ sessionId,
879
+ upToIndex: normalizedIndex,
880
+ })
881
+ }
882
+
883
+ private getSubtaskInfoForSession(
884
+ candidateSessionId: string,
885
+ ): { label: string; assistantMessageId?: string } | undefined {
886
+ const mainSessionId = this.state?.sessionId
887
+ if (!mainSessionId || candidateSessionId === mainSessionId) {
888
+ return undefined
889
+ }
890
+ const subtaskIndex = getDerivedSubtaskIndex({
891
+ events: this.eventBuffer,
892
+ mainSessionId,
893
+ candidateSessionId,
894
+ })
895
+ if (!subtaskIndex) {
896
+ return undefined
897
+ }
898
+
899
+ const agentType = getDerivedSubtaskAgentType({
900
+ events: this.eventBuffer,
901
+ mainSessionId,
902
+ candidateSessionId,
903
+ })
904
+ const label = `${agentType || 'task'}-${subtaskIndex}`
905
+ const assistantMessageId = this.getLatestAssistantMessageIdForCurrentTurn({
906
+ sessionId: candidateSessionId,
907
+ })
908
+ return { label, assistantMessageId }
909
+ }
910
+
911
+ // ── Lifecycle ────────────────────────────────────────────────
912
+
913
+ dispose(): void {
914
+ this.disposed = true
915
+ this.state?.listenerController?.abort()
916
+ // waitForEvent loops check listenerAborted and exit naturally.
917
+ threadState.updateThread(this.threadId, (t) => ({
918
+ ...t,
919
+ listenerController: undefined,
920
+ }))
921
+ void this.persistEventBufferDebounced.dispose()
922
+ this.stopTyping()
923
+
924
+ // Release large internal buffers so GC can reclaim memory immediately
925
+ // instead of waiting for the runtime object itself to become unreachable.
926
+ this.eventBuffer = []
927
+ this.nextEventIndex = 0
928
+ this.partBuffer.clear()
929
+ this.preprocessChain = Promise.resolve()
930
+
931
+ // Don't clear actionQueue here — queued closures own resolve/reject for
932
+ // dispatchAction() promises. Dropping them would leave awaiting callers
933
+ // hanging forever. Instead, drain them: each closure checks this.disposed
934
+ // and resolves early without executing real work.
935
+ void this.processActionQueue()
936
+
937
+ // Clean up all pending UI state for this thread (permissions, questions,
938
+ // action buttons, file uploads, html actions).
939
+ cleanupPendingUiForThread(this.thread.id)
940
+ }
941
+
942
+ // Called when sdkDirectory changes (e.g. worktree becomes ready after
943
+ // /new-worktree in an existing thread). The event listener was subscribed
944
+ // to the old directory's Instance in opencode — events from the new
945
+ // directory's Instance won't reach it. We must reconnect the listener
946
+ // and clear the old session so ensureSession creates a fresh one under
947
+ // the new Instance.
948
+ handleDirectoryChanged({
949
+ oldDirectory,
950
+ newDirectory,
951
+ }: {
952
+ oldDirectory: string
953
+ newDirectory: string
954
+ }): void {
955
+ logger.log(
956
+ `[LISTENER] sdkDirectory changed for thread ${this.threadId}: ${oldDirectory} → ${newDirectory}`,
957
+ )
958
+ this.sdkDirectory = newDirectory
959
+
960
+ // Clear cached session — it was created under the old directory's
961
+ // opencode Instance and can't be reused from the new one.
962
+ threadState.updateThread(this.threadId, (t) => ({
963
+ ...t,
964
+ sessionId: undefined,
965
+ }))
966
+
967
+ // Restart event listener to subscribe under the new directory.
968
+ const currentController = this.state?.listenerController
969
+ if (currentController) {
970
+ currentController.abort(new Error('sdkDirectory changed'))
971
+ threadState.updateThread(this.threadId, (t) => ({
972
+ ...t,
973
+ listenerController: new AbortController(),
974
+ }))
975
+ this.listenerLoopRunning = false
976
+ void this.startEventListener()
977
+ }
978
+ }
979
+
980
+ handleSharedServerStarted({
981
+ port,
982
+ }: {
983
+ port: number
984
+ }): void {
985
+ const currentController = this.state?.listenerController
986
+ if (!currentController) {
987
+ return
988
+ }
989
+ logger.log(
990
+ `[LISTENER] Refreshing listener for thread ${this.threadId} after shared server start on port ${port}`,
991
+ )
992
+ currentController.abort(new Error('Shared OpenCode server restarted'))
993
+ threadState.updateThread(this.threadId, (t) => ({
994
+ ...t,
995
+ listenerController: new AbortController(),
996
+ }))
997
+ this.listenerLoopRunning = false
998
+ void this.startEventListener()
999
+ }
1000
+
1001
+ private compactTextForEventBuffer(text: string): string {
1002
+ if (text.length <= ThreadSessionRuntime.EVENT_BUFFER_TEXT_MAX_CHARS) {
1003
+ return text
1004
+ }
1005
+ return `${text.slice(0, ThreadSessionRuntime.EVENT_BUFFER_TEXT_MAX_CHARS)}…`
1006
+ }
1007
+
1008
+ private isDefinedEventBufferValue<T>(value: T | undefined): value is T {
1009
+ return value !== undefined
1010
+ }
1011
+
1012
+ private pruneLargeStringsForEventBuffer(
1013
+ value: unknown,
1014
+ seen: WeakSet<object>,
1015
+ ): void {
1016
+ if (typeof value !== 'object' || value === null) {
1017
+ return
1018
+ }
1019
+ if (seen.has(value)) {
1020
+ return
1021
+ }
1022
+ seen.add(value)
1023
+
1024
+ if (Array.isArray(value)) {
1025
+ const compactedItems = value
1026
+ .map((item) => {
1027
+ if (typeof item === 'string') {
1028
+ if (item.length > ThreadSessionRuntime.EVENT_BUFFER_TEXT_MAX_CHARS) {
1029
+ return undefined
1030
+ }
1031
+ return item
1032
+ }
1033
+ this.pruneLargeStringsForEventBuffer(item, seen)
1034
+ return item
1035
+ })
1036
+ .filter((item) => {
1037
+ return this.isDefinedEventBufferValue(item)
1038
+ })
1039
+ value.splice(0, value.length, ...compactedItems)
1040
+ return
1041
+ }
1042
+
1043
+ const objectValue = value as Record<string, unknown>
1044
+ for (const [key, nestedValue] of Object.entries(objectValue)) {
1045
+ if (typeof nestedValue === 'string') {
1046
+ if (nestedValue.length > ThreadSessionRuntime.EVENT_BUFFER_TEXT_MAX_CHARS) {
1047
+ delete objectValue[key]
1048
+ }
1049
+ continue
1050
+ }
1051
+ this.pruneLargeStringsForEventBuffer(nestedValue, seen)
1052
+ }
1053
+ }
1054
+
1055
+ private finalizeCompactedEventForEventBuffer(
1056
+ event: OpenCodeEvent,
1057
+ ): OpenCodeEvent {
1058
+ this.pruneLargeStringsForEventBuffer(event, new WeakSet<object>())
1059
+ return event
1060
+ }
1061
+
1062
+ private compactEventForEventBuffer(
1063
+ event: OpenCodeEvent,
1064
+ ): OpenCodeEvent | undefined {
1065
+ if (event.type === 'session.diff') {
1066
+ return undefined
1067
+ }
1068
+
1069
+ const compacted = structuredClone(event)
1070
+
1071
+ if (compacted.type === 'message.updated') {
1072
+ // Strip heavy fields from ALL roles. Derivation only needs lightweight
1073
+ // metadata (id, role, sessionID, parentID, time, finish, error, modelID,
1074
+ // providerID, mode, tokens). The parts array on assistant messages grows
1075
+ // with every tool call and was the primary OOM vector — 1000 buffer entries
1076
+ // each carrying the full cumulative parts array reached 4GB+.
1077
+ const info = compacted.properties.info as Record<string, unknown>
1078
+ const partsSummary = Array.isArray(info.parts)
1079
+ ? info.parts.flatMap((part) => {
1080
+ if (!part || typeof part !== 'object') {
1081
+ return [] as Array<{ id: string; type: string }>
1082
+ }
1083
+ const candidate = part as { id?: unknown; type?: unknown }
1084
+ if (
1085
+ typeof candidate.id !== 'string'
1086
+ || typeof candidate.type !== 'string'
1087
+ ) {
1088
+ return [] as Array<{ id: string; type: string }>
1089
+ }
1090
+ return [{ id: candidate.id, type: candidate.type }]
1091
+ })
1092
+ : []
1093
+ delete info.system
1094
+ delete info.summary
1095
+ delete info.tools
1096
+ delete info.parts
1097
+ if (partsSummary.length > 0) {
1098
+ info.partsSummary = partsSummary
1099
+ }
1100
+ return this.finalizeCompactedEventForEventBuffer(compacted)
1101
+ }
1102
+
1103
+ if (compacted.type !== 'message.part.updated') {
1104
+ return this.finalizeCompactedEventForEventBuffer(compacted)
1105
+ }
1106
+
1107
+ const part = compacted.properties.part
1108
+
1109
+ if (part.type === 'text') {
1110
+ part.text = this.compactTextForEventBuffer(part.text)
1111
+ return this.finalizeCompactedEventForEventBuffer(compacted)
1112
+ }
1113
+
1114
+ if (part.type === 'reasoning') {
1115
+ part.text = this.compactTextForEventBuffer(part.text)
1116
+ return this.finalizeCompactedEventForEventBuffer(compacted)
1117
+ }
1118
+
1119
+ if (part.type === 'snapshot') {
1120
+ part.snapshot = this.compactTextForEventBuffer(part.snapshot)
1121
+ return this.finalizeCompactedEventForEventBuffer(compacted)
1122
+ }
1123
+
1124
+ if (part.type === 'step-start' && part.snapshot) {
1125
+ part.snapshot = this.compactTextForEventBuffer(part.snapshot)
1126
+ return this.finalizeCompactedEventForEventBuffer(compacted)
1127
+ }
1128
+
1129
+ if (part.type !== 'tool') {
1130
+ return this.finalizeCompactedEventForEventBuffer(compacted)
1131
+ }
1132
+
1133
+ const state = part.state
1134
+ // Preserve subagent_type for task tools so derivation can build labels
1135
+ // like "explore-1" instead of generic "task-1" after compaction strips input
1136
+ const taskSubagentType =
1137
+ part.tool === 'task' ? state.input?.subagent_type : undefined
1138
+ state.input = {}
1139
+ if (typeof taskSubagentType === 'string') {
1140
+ state.input.subagent_type = taskSubagentType
1141
+ }
1142
+
1143
+ if (state.status === 'pending') {
1144
+ state.raw = this.compactTextForEventBuffer(state.raw)
1145
+ return this.finalizeCompactedEventForEventBuffer(compacted)
1146
+ }
1147
+
1148
+ if (state.status === 'running') {
1149
+ return this.finalizeCompactedEventForEventBuffer(compacted)
1150
+ }
1151
+
1152
+ if (state.status === 'completed') {
1153
+ state.output = this.compactTextForEventBuffer(state.output)
1154
+ delete state.attachments
1155
+ return this.finalizeCompactedEventForEventBuffer(compacted)
1156
+ }
1157
+
1158
+ if (state.status === 'error') {
1159
+ state.error = this.compactTextForEventBuffer(state.error)
1160
+ return this.finalizeCompactedEventForEventBuffer(compacted)
1161
+ }
1162
+
1163
+ return this.finalizeCompactedEventForEventBuffer(compacted)
1164
+ }
1165
+
1166
+ private appendEventToBuffer(event: OpenCodeEvent): void {
1167
+ const compactedEvent = this.compactEventForEventBuffer(event)
1168
+ if (!compactedEvent) {
1169
+ return
1170
+ }
1171
+
1172
+ const timestamp = Date.now()
1173
+ const eventIndex = this.nextEventIndex
1174
+ this.nextEventIndex += 1
1175
+ this.eventBuffer.push({
1176
+ event: compactedEvent,
1177
+ timestamp,
1178
+ eventIndex,
1179
+ })
1180
+ if (this.eventBuffer.length > ThreadSessionRuntime.EVENT_BUFFER_MAX) {
1181
+ this.eventBuffer.splice(0, this.eventBuffer.length - ThreadSessionRuntime.EVENT_BUFFER_MAX)
1182
+ }
1183
+ this.persistEventBufferDebounced.trigger()
1184
+ }
1185
+
1186
+ // Queue-dispatch lifecycle markers are synthetic buffer-only events.
1187
+ // They are not fed into handleEvent(), so they do not emit Discord messages;
1188
+ // they only stabilize event-derived busy/idle gating for local queue drains.
1189
+ private markQueueDispatchBusy(sessionId: string): void {
1190
+ this.appendEventToBuffer({
1191
+ type: 'session.status',
1192
+ properties: {
1193
+ sessionID: sessionId,
1194
+ status: { type: 'busy' },
1195
+ },
1196
+ })
1197
+ }
1198
+
1199
+ private markQueueDispatchIdle(sessionId: string): void {
1200
+ this.appendEventToBuffer({
1201
+ type: 'session.idle',
1202
+ properties: {
1203
+ sessionID: sessionId,
1204
+ },
1205
+ })
1206
+ }
1207
+
1208
+ /**
1209
+ * Generic event waiter: polls the event buffer until a matching event
1210
+ * appears (with timestamp >= sinceTimestamp), or timeout/abort.
1211
+ *
1212
+ * Unlike the old idleWaiter (a promise wired into handleSessionIdle),
1213
+ * this has zero coupling to specific event handlers — it just scans
1214
+ * the buffer that handleEvent() fills. Works for any event type.
1215
+ */
1216
+ private async waitForEvent(opts: {
1217
+ predicate: (event: OpenCodeEvent) => boolean
1218
+ sinceTimestamp: number
1219
+ timeoutMs: number
1220
+ pollMs?: number
1221
+ }): Promise<OpenCodeEvent | undefined> {
1222
+ const { predicate, sinceTimestamp, timeoutMs, pollMs = 50 } = opts
1223
+ const deadline = Date.now() + timeoutMs
1224
+
1225
+ while (Date.now() < deadline) {
1226
+ if (this.listenerAborted) {
1227
+ return undefined
1228
+ }
1229
+ const match = this.eventBuffer.find((entry) => {
1230
+ return entry.timestamp >= sinceTimestamp && predicate(entry.event)
1231
+ })
1232
+ if (match) {
1233
+ return match.event
1234
+ }
1235
+ await delay(pollMs)
1236
+ }
1237
+
1238
+ logger.warn(
1239
+ `[WAIT EVENT] Timeout after ${timeoutMs}ms for thread ${this.threadId}, proceeding`,
1240
+ )
1241
+ return undefined
1242
+ }
1243
+
1244
+ // Seed sentPartIds from DB to avoid re-sending parts that were
1245
+ // already sent in a previous runtime or before a reconnect.
1246
+ private async bootstrapSentPartIds(): Promise<void> {
1247
+ const existingPartIds = await getPartMessageIds(this.thread.id)
1248
+ if (existingPartIds.length === 0) {
1249
+ return
1250
+ }
1251
+ threadState.updateThread(this.threadId, (t) => {
1252
+ const newIds = new Set(t.sentPartIds)
1253
+ for (const id of existingPartIds) {
1254
+ newIds.add(id)
1255
+ }
1256
+ return { ...t, sentPartIds: newIds }
1257
+ })
1258
+ }
1259
+
1260
+ // ── Event Listener Loop (§7.3) ──────────────────────────────
1261
+ // Persistent event.subscribe loop with exponential backoff.
1262
+ // Reconnects automatically on transient disconnects.
1263
+ // Only killed when listenerController is aborted (dispose/fatal).
1264
+ // Run abort never affects this loop.
1265
+
1266
+ async startEventListener(): Promise<void> {
1267
+ if (this.listenerLoopRunning || this.disposed) {
1268
+ return
1269
+ }
1270
+ this.listenerLoopRunning = true
1271
+
1272
+ // Bootstrap sentPartIds from DB so we don't re-send parts that
1273
+ // were already sent in a previous runtime or before a reconnect.
1274
+ await this.bootstrapSentPartIds()
1275
+
1276
+ let backoffMs = 500
1277
+ const maxBackoffMs = 30_000
1278
+
1279
+ while (!this.listenerAborted) {
1280
+ const signal = this.listenerSignal
1281
+ if (!signal) {
1282
+ return // disposed before we could subscribe
1283
+ }
1284
+ const client = getOpencodeClient(this.projectDirectory)
1285
+ if (!client) {
1286
+ // This is expected during shared-server transitions: the listener can
1287
+ // outlive the current opencode process across cold start, explicit
1288
+ // restart, shutdown, or crash recovery. stopOpencodeServer()/exit clears
1289
+ // the cached per-directory clients immediately, so existing runtimes may
1290
+ // observe a brief no-client window before initialize/restart publishes
1291
+ // the next shared server and repopulates the client cache.
1292
+ logger.warn(
1293
+ `[LISTENER] No OpenCode client for thread ${this.threadId}, retrying in ${backoffMs}ms`,
1294
+ )
1295
+ await delay(backoffMs)
1296
+ backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
1297
+ continue
1298
+ }
1299
+ const subscribeResult = await errore.tryAsync(() => {
1300
+ return client.event.subscribe(
1301
+ { directory: this.sdkDirectory },
1302
+ { signal },
1303
+ )
1304
+ })
1305
+
1306
+ if (subscribeResult instanceof Error) {
1307
+ if (isAbortError(subscribeResult)) {
1308
+ return // disposed
1309
+ }
1310
+ const subscribeError: Error = subscribeResult
1311
+ logger.warn(
1312
+ `[LISTENER] Subscribe failed for thread ${this.threadId}, retrying in ${backoffMs}ms:`,
1313
+ subscribeError.message,
1314
+ )
1315
+ await delay(backoffMs)
1316
+ backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
1317
+ continue
1318
+ }
1319
+
1320
+ // Reset backoff on successful connection
1321
+ backoffMs = 500
1322
+ const events = subscribeResult.stream
1323
+
1324
+ logger.log(
1325
+ `[LISTENER] Connected to event stream for thread ${this.threadId}`,
1326
+ )
1327
+
1328
+ // Re-bootstrap sentPartIds on reconnect to prevent re-sending
1329
+ // parts that arrived while we were disconnected.
1330
+ await this.bootstrapSentPartIds()
1331
+
1332
+ const iterResult = await errore.tryAsync(async () => {
1333
+ for await (const event of events) {
1334
+ // Each event is dispatched through the serialized action queue
1335
+ // to prevent interleaving mutations from concurrent events.
1336
+ await this.dispatchAction(() => {
1337
+ return this.handleEvent(event)
1338
+ })
1339
+ }
1340
+ })
1341
+
1342
+ if (iterResult instanceof Error) {
1343
+ if (isAbortError(iterResult)) {
1344
+ return // disposed
1345
+ }
1346
+ const iterError: Error = iterResult
1347
+ logger.warn(
1348
+ `[LISTENER] Stream broke for thread ${this.threadId}, reconnecting in ${backoffMs}ms:`,
1349
+ iterError.message,
1350
+ )
1351
+ await delay(backoffMs)
1352
+ backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
1353
+ }
1354
+ }
1355
+ }
1356
+
1357
+ // ── Session Demux Guard ─────────────────────────────────────
1358
+ // Events scoped to a session must match the current session.
1359
+ // Global events (tui.toast.show) bypass the guard.
1360
+ // Subtask sessions also bypass — they're tracked in subtaskSessions.
1361
+
1362
+ private async handleEvent(event: OpenCodeEvent): Promise<void> {
1363
+ // session.diff can carry repeated full-file before/after snapshots and is
1364
+ // not used by event-derived runtime state, queueing, typing, or UI routing.
1365
+ // Drop it at ingress so large diff payloads never hit memory buffers.
1366
+ if (event.type === 'session.diff') {
1367
+ return
1368
+ }
1369
+
1370
+ // Skip message.part.delta from the event buffer — no derivation function
1371
+ // (isSessionBusy, doesLatestUserTurnHaveNaturalCompletion, waitForEvent,
1372
+ // etc.) uses them. During long streaming responses they flood the 1000-slot
1373
+ // buffer, evicting session.status busy events that isSessionBusy needs,
1374
+ // causing tryDrainQueue to drain the local queue while the session is
1375
+ // actually still busy. This was the root cause of "? queue" messages
1376
+ // interrupting instead of queuing.
1377
+ if (event.type !== 'message.part.delta') {
1378
+ this.appendEventToBuffer(event)
1379
+ }
1380
+
1381
+ const sessionId = this.state?.sessionId
1382
+
1383
+ const eventSessionId = getOpencodeEventSessionId(event)
1384
+
1385
+ if (shouldLogSessionEvents) {
1386
+ const eventDetails = (() => {
1387
+ if (event.type === 'session.error') {
1388
+ const errorName = event.properties.error?.name || 'unknown'
1389
+ return ` error=${errorName}`
1390
+ }
1391
+ if (event.type === 'session.status') {
1392
+ const status = event.properties.status || 'unknown'
1393
+ return ` status=${status}`
1394
+ }
1395
+ if (event.type === 'message.updated') {
1396
+ return ` role=${event.properties.info.role} messageID=${event.properties.info.id}`
1397
+ }
1398
+ if (event.type === 'message.part.updated') {
1399
+ const partType = event.properties.part.type
1400
+ const partId = event.properties.part.id
1401
+ const messageId = event.properties.part.messageID
1402
+ const toolSuffix = partType === 'tool'
1403
+ ? ` tool=${event.properties.part.tool} status=${event.properties.part.state.status}`
1404
+ : ''
1405
+ return ` part=${partType} partID=${partId} messageID=${messageId}${toolSuffix}`
1406
+ }
1407
+ return ''
1408
+ })()
1409
+ logger.log(
1410
+ `[EVENT] type=${event.type} eventSessionId=${eventSessionId || 'none'} activeSessionId=${sessionId || 'none'} ${this.formatRunStateForLog()}${eventDetails}`,
1411
+ )
1412
+ }
1413
+
1414
+ const isGlobalEvent = event.type === 'tui.toast.show'
1415
+
1416
+ // Drop events that don't match current session (stale events from
1417
+ // previous sessions), unless it's a global event or a subtask session.
1418
+ if (!isGlobalEvent && eventSessionId && eventSessionId !== sessionId) {
1419
+ if (!this.getSubtaskInfoForSession(eventSessionId)) {
1420
+ return // stale event from previous session
1421
+ }
1422
+ }
1423
+
1424
+ if (isOpencodeSessionEventLogEnabled()) {
1425
+ const eventLogResult = await appendOpencodeSessionEventLog({
1426
+ threadId: this.threadId,
1427
+ projectDirectory: this.projectDirectory,
1428
+ event,
1429
+ })
1430
+ if (eventLogResult instanceof Error) {
1431
+ logger.error(
1432
+ '[SESSION EVENT JSONL] Failed to write session event log:',
1433
+ eventLogResult,
1434
+ )
1435
+ }
1436
+ }
1437
+
1438
+ switch (event.type) {
1439
+ case 'message.updated':
1440
+ await this.handleMessageUpdated(event.properties.info)
1441
+ break
1442
+ case 'message.part.updated':
1443
+ await this.handlePartUpdated(event.properties.part)
1444
+ break
1445
+ case 'session.idle':
1446
+ await this.handleSessionIdle(event.properties.sessionID)
1447
+ break
1448
+ case 'session.error':
1449
+ await this.handleSessionError(event.properties)
1450
+ break
1451
+ case 'permission.asked':
1452
+ await this.handlePermissionAsked(event.properties)
1453
+ break
1454
+ case 'permission.replied':
1455
+ this.handlePermissionReplied(event.properties)
1456
+ break
1457
+ case 'question.asked':
1458
+ await this.handleQuestionAsked(event.properties)
1459
+ break
1460
+ case 'question.replied':
1461
+ this.handleQuestionReplied(event.properties)
1462
+ break
1463
+ case 'session.status':
1464
+ await this.handleSessionStatus(event.properties)
1465
+ break
1466
+ case 'session.updated':
1467
+ await this.handleSessionUpdated(event.properties.info)
1468
+ break
1469
+ case 'tui.toast.show':
1470
+ await this.handleTuiToast(event.properties)
1471
+ break
1472
+ default:
1473
+ break
1474
+ }
1475
+ }
1476
+
1477
+ // ── Serialized Action Queue (§7.4) ──────────────────────────
1478
+ // Serializes event handling + local-queue state mutations.
1479
+
1480
+ async dispatchAction(action: () => Promise<void>): Promise<void> {
1481
+ if (this.disposed) {
1482
+ return
1483
+ }
1484
+ return new Promise<void>((resolve, reject) => {
1485
+ this.actionQueue.push(async () => {
1486
+ if (this.disposed) {
1487
+ resolve()
1488
+ return
1489
+ }
1490
+ const result = await errore.tryAsync(action)
1491
+ if (result instanceof Error) {
1492
+ reject(result)
1493
+ return
1494
+ }
1495
+ resolve()
1496
+ })
1497
+ void this.processActionQueue()
1498
+ })
1499
+ }
1500
+
1501
+ // Process serialized action queue. Uses try/finally to guarantee
1502
+ // processingAction is always reset — if we didn't, a thrown action
1503
+ // would leave the flag true and deadlock all future actions.
1504
+ private async processActionQueue(): Promise<void> {
1505
+ if (this.processingAction) {
1506
+ return
1507
+ }
1508
+ this.processingAction = true
1509
+ try {
1510
+ while (this.actionQueue.length > 0) {
1511
+ const next = this.actionQueue.shift()
1512
+ if (!next) {
1513
+ continue
1514
+ }
1515
+ // Each queued action already wraps itself with errore.tryAsync
1516
+ // and calls resolve/reject, so this should not throw. But if it
1517
+ // does, the try/finally ensures we don't deadlock.
1518
+ const result = await errore.tryAsync(next)
1519
+ if (result instanceof Error) {
1520
+ logger.error('[ACTION QUEUE] Unexpected action failure:', result)
1521
+ }
1522
+ }
1523
+ } finally {
1524
+ this.processingAction = false
1525
+ }
1526
+ }
1527
+
1528
+ // ── Typing Indicator Management ─────────────────────────────
1529
+
1530
+ private hasPendingInteractiveUi(): boolean {
1531
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
1532
+ (ctx) => {
1533
+ return ctx.thread.id === this.thread.id
1534
+ },
1535
+ )
1536
+ if (hasPendingQuestion) {
1537
+ return true
1538
+ }
1539
+ const hasPendingActionButtons = [...pendingActionButtonContexts.values()].some(
1540
+ (ctx) => {
1541
+ return ctx.thread.id === this.thread.id
1542
+ },
1543
+ )
1544
+ if (hasPendingActionButtons) {
1545
+ return true
1546
+ }
1547
+ const hasPendingFileUpload = [...pendingFileUploadContexts.values()].some(
1548
+ (ctx) => {
1549
+ return ctx.thread.id === this.thread.id
1550
+ },
1551
+ )
1552
+ if (hasPendingFileUpload) {
1553
+ return true
1554
+ }
1555
+ return (pendingPermissions.get(this.thread.id)?.size ?? 0) > 0
1556
+ }
1557
+
1558
+ onInteractiveUiStateChanged(): void {
1559
+ this.ensureTypingNow()
1560
+ void this.dispatchAction(() => {
1561
+ return this.tryDrainQueue({ showIndicator: true })
1562
+ })
1563
+ }
1564
+
1565
+ private shouldTypeNow(): boolean {
1566
+ if (this.listenerAborted) {
1567
+ return false
1568
+ }
1569
+ if (this.hasPendingInteractiveUi()) {
1570
+ return false
1571
+ }
1572
+ const sessionId = this.state?.sessionId
1573
+ if (!sessionId) {
1574
+ return false
1575
+ }
1576
+ return isSessionBusy({ events: this.eventBuffer, sessionId })
1577
+ }
1578
+
1579
+ private async sendTypingPulse(): Promise<void> {
1580
+ const result = await errore.tryAsync(() => {
1581
+ return this.thread.sendTyping()
1582
+ })
1583
+ if (result instanceof Error) {
1584
+ discordLogger.log(`Failed to send typing: ${result}`)
1585
+ }
1586
+ }
1587
+
1588
+ private clearTypingKeepalive(): void {
1589
+ if (!this.typingKeepaliveTimeout) {
1590
+ return
1591
+ }
1592
+ clearTimeout(this.typingKeepaliveTimeout)
1593
+ this.typingKeepaliveTimeout = null
1594
+ }
1595
+
1596
+ private armTypingKeepalive({
1597
+ delayMs,
1598
+ }: {
1599
+ delayMs: number
1600
+ }): void {
1601
+ this.typingKeepaliveTimeout = setTimeout(() => {
1602
+ const activeTimer = this.typingKeepaliveTimeout
1603
+ if (!activeTimer) {
1604
+ return
1605
+ }
1606
+ void (async () => {
1607
+ if (!this.shouldTypeNow()) {
1608
+ this.stopTyping()
1609
+ return
1610
+ }
1611
+ await this.sendTypingPulse()
1612
+ if (this.typingKeepaliveTimeout !== activeTimer) {
1613
+ return
1614
+ }
1615
+ if (!this.shouldTypeNow()) {
1616
+ this.stopTyping()
1617
+ return
1618
+ }
1619
+ this.armTypingKeepalive({ delayMs: 7000 })
1620
+ })()
1621
+ }, delayMs)
1622
+ }
1623
+
1624
+ private restartTypingKeepalive({
1625
+ sendNow,
1626
+ }: {
1627
+ sendNow: boolean
1628
+ }): void {
1629
+ this.clearTypingKeepalive()
1630
+ this.armTypingKeepalive({ delayMs: sendNow ? 0 : 7000 })
1631
+ }
1632
+
1633
+ private ensureTypingNow(): void {
1634
+ if (!this.shouldTypeNow()) {
1635
+ this.stopTyping()
1636
+ return
1637
+ }
1638
+ if (!this.typingKeepaliveTimeout && !this.typingRepulseDebounce.isPending()) {
1639
+ this.armTypingKeepalive({ delayMs: 0 })
1640
+ return
1641
+ }
1642
+ this.typingRepulseDebounce.trigger()
1643
+ }
1644
+
1645
+ private ensureTypingKeepalive(): void {
1646
+ if (!this.shouldTypeNow()) {
1647
+ this.stopTyping()
1648
+ return
1649
+ }
1650
+ if (this.typingKeepaliveTimeout || this.typingRepulseDebounce.isPending()) {
1651
+ return
1652
+ }
1653
+ this.armTypingKeepalive({ delayMs: 7000 })
1654
+ }
1655
+
1656
+ private stopTyping(): void {
1657
+ this.typingRepulseDebounce.clear()
1658
+ this.clearTypingKeepalive()
1659
+ }
1660
+
1661
+ private requestTypingRepulse(): void {
1662
+ if (!this.shouldTypeNow()) {
1663
+ return
1664
+ }
1665
+ this.typingRepulseDebounce.trigger()
1666
+ }
1667
+
1668
+ // ── Part Buffering & Output ─────────────────────────────────
1669
+
1670
+ private getVerbosityChannelId(): string {
1671
+ return this.channelId || this.thread.parentId || this.thread.id
1672
+ }
1673
+
1674
+ private async getVerbosity() {
1675
+ return getChannelVerbosity(this.getVerbosityChannelId())
1676
+ }
1677
+
1678
+ private storePart(part: Part): void {
1679
+ const messageParts =
1680
+ this.partBuffer.get(part.messageID) || new Map<string, Part>()
1681
+ messageParts.set(part.id, part)
1682
+ this.partBuffer.set(part.messageID, messageParts)
1683
+ }
1684
+
1685
+ private getBufferedParts(messageID: string): Part[] {
1686
+ return Array.from(this.partBuffer.get(messageID)?.values() ?? [])
1687
+ }
1688
+
1689
+ private clearBufferedPartsForMessages(messageIDs: ReadonlyArray<string>): void {
1690
+ const uniqueMessageIDs = new Set(messageIDs)
1691
+ uniqueMessageIDs.forEach((messageID) => {
1692
+ this.partBuffer.delete(messageID)
1693
+ })
1694
+ }
1695
+
1696
+ private hasBufferedStepFinish(messageID: string): boolean {
1697
+ return this.getBufferedParts(messageID).some((part) => {
1698
+ return part.type === 'step-finish'
1699
+ })
1700
+ }
1701
+
1702
+ private shouldSendPart({
1703
+ part,
1704
+ force,
1705
+ }: {
1706
+ part: Part
1707
+ force: boolean
1708
+ }): boolean {
1709
+ if (part.type === 'step-start' || part.type === 'step-finish') {
1710
+ return false
1711
+ }
1712
+ if (part.type === 'tool' && part.state.status === 'pending') {
1713
+ return false
1714
+ }
1715
+ if (!force && part.type === 'text' && !part.time?.end) {
1716
+ return false
1717
+ }
1718
+ if (!force && part.type === 'tool' && part.state.status === 'completed') {
1719
+ return false
1720
+ }
1721
+ return true
1722
+ }
1723
+
1724
+ private async sendPartMessage({
1725
+ part,
1726
+ repulseTyping = true,
1727
+ }: {
1728
+ part: Part
1729
+ repulseTyping?: boolean
1730
+ }): Promise<void> {
1731
+ const verbosity = await this.getVerbosity()
1732
+ if (verbosity === 'text_only' && part.type !== 'text') {
1733
+ return
1734
+ }
1735
+ if (verbosity === 'text_and_essential_tools') {
1736
+ if (part.type !== 'text' && !(part.type === 'tool' && isEssentialToolPart(part))) {
1737
+ return
1738
+ }
1739
+ }
1740
+
1741
+ const content = formatPart(part)
1742
+ if (!content.trim() || content.length === 0) {
1743
+ return
1744
+ }
1745
+ if (this.state?.sentPartIds.has(part.id)) {
1746
+ return
1747
+ }
1748
+ // Mark as sent BEFORE the async send to prevent concurrent flushes
1749
+ // from sending the same part while this await is in-flight.
1750
+ threadState.updateThread(this.threadId, (t) => {
1751
+ const newIds = new Set(t.sentPartIds)
1752
+ newIds.add(part.id)
1753
+ return { ...t, sentPartIds: newIds }
1754
+ })
1755
+
1756
+ const sendResult = await errore.tryAsync(() => {
1757
+ return sendThreadMessage(this.thread, content)
1758
+ })
1759
+ if (sendResult instanceof Error) {
1760
+ threadState.updateThread(this.threadId, (t) => {
1761
+ const newIds = new Set(t.sentPartIds)
1762
+ newIds.delete(part.id)
1763
+ return { ...t, sentPartIds: newIds }
1764
+ })
1765
+ discordLogger.error(
1766
+ `ERROR: Failed to send part ${part.id}:`,
1767
+ sendResult,
1768
+ )
1769
+ return
1770
+ }
1771
+ await setPartMessage(part.id, sendResult.id, this.thread.id)
1772
+ if (repulseTyping) {
1773
+ this.requestTypingRepulse()
1774
+ }
1775
+ }
1776
+
1777
+ private async flushBufferedParts({
1778
+ messageID,
1779
+ force,
1780
+ skipPartId,
1781
+ repulseTyping = true,
1782
+ }: {
1783
+ messageID: string | undefined
1784
+ force: boolean
1785
+ skipPartId?: string
1786
+ repulseTyping?: boolean
1787
+ }): Promise<void> {
1788
+ if (!messageID) {
1789
+ return
1790
+ }
1791
+ const parts = this.getBufferedParts(messageID)
1792
+ for (const part of parts) {
1793
+ if (skipPartId && part.id === skipPartId) {
1794
+ continue
1795
+ }
1796
+ if (!this.shouldSendPart({ part, force })) {
1797
+ continue
1798
+ }
1799
+ await this.sendPartMessage({ part, repulseTyping })
1800
+ }
1801
+ }
1802
+
1803
+ private async flushBufferedPartsForMessages({
1804
+ messageIDs,
1805
+ force,
1806
+ skipPartId,
1807
+ repulseTyping = true,
1808
+ }: {
1809
+ messageIDs: ReadonlyArray<string>
1810
+ force: boolean
1811
+ skipPartId?: string
1812
+ repulseTyping?: boolean
1813
+ }): Promise<void> {
1814
+ const uniqueMessageIDs = [...new Set(messageIDs)]
1815
+ for (const messageID of uniqueMessageIDs) {
1816
+ await this.flushBufferedParts({
1817
+ messageID,
1818
+ force,
1819
+ skipPartId,
1820
+ repulseTyping,
1821
+ })
1822
+ }
1823
+ }
1824
+
1825
+ private async showInteractiveUi({
1826
+ skipPartId,
1827
+ flushMessageId,
1828
+ show,
1829
+ }: {
1830
+ skipPartId?: string
1831
+ flushMessageId?: string
1832
+ show: () => Promise<void>
1833
+ }): Promise<void> {
1834
+ this.stopTyping()
1835
+ const sessionId = this.state?.sessionId
1836
+ const targetMessageId = (() => {
1837
+ if (flushMessageId) {
1838
+ return flushMessageId
1839
+ }
1840
+ if (!sessionId) {
1841
+ return undefined
1842
+ }
1843
+ return this.getLatestAssistantMessageIdForCurrentTurn({ sessionId })
1844
+ })()
1845
+ if (targetMessageId) {
1846
+ await this.flushBufferedParts({
1847
+ messageID: targetMessageId,
1848
+ force: true,
1849
+ skipPartId,
1850
+ })
1851
+ } else {
1852
+ const assistantMessageIds = sessionId
1853
+ ? [...this.getAssistantMessageIdsForCurrentTurn({ sessionId })]
1854
+ : []
1855
+ await this.flushBufferedPartsForMessages({
1856
+ messageIDs: assistantMessageIds,
1857
+ force: true,
1858
+ skipPartId,
1859
+ })
1860
+ }
1861
+ await show()
1862
+ }
1863
+
1864
+ private async ensureModelContextLimit({
1865
+ providerID,
1866
+ modelID,
1867
+ }: {
1868
+ providerID: string
1869
+ modelID: string
1870
+ }): Promise<void> {
1871
+ const key = `${providerID}/${modelID}`
1872
+ if (this.modelContextLimit && this.modelContextLimitKey === key) {
1873
+ return
1874
+ }
1875
+ const client = getOpencodeClient(this.projectDirectory)
1876
+ if (!client) {
1877
+ return
1878
+ }
1879
+ const providersResponse = await errore.tryAsync(() => {
1880
+ return client.provider.list({ directory: this.sdkDirectory })
1881
+ })
1882
+ if (providersResponse instanceof Error) {
1883
+ logger.error(
1884
+ 'Failed to fetch provider info for context limit:',
1885
+ providersResponse,
1886
+ )
1887
+ return
1888
+ }
1889
+ const provider = providersResponse.data?.all?.find(
1890
+ (p) => {
1891
+ return p.id === providerID
1892
+ },
1893
+ )
1894
+ const model = provider?.models?.[modelID]
1895
+ const contextLimit = model?.limit?.context || getFallbackContextLimit({
1896
+ providerID,
1897
+ })
1898
+ if (!contextLimit) {
1899
+ return
1900
+ }
1901
+ this.modelContextLimit = contextLimit
1902
+ this.modelContextLimitKey = key
1903
+ }
1904
+
1905
+ // ── Event Handlers ──────────────────────────────────────────
1906
+ // Extracted from session-handler.ts eventHandler closure.
1907
+ // These operate on runtime instance state + global store transitions.
1908
+
1909
+ private async handleMessageUpdated(msg: OpenCodeMessage): Promise<void> {
1910
+ const sessionId = this.state?.sessionId
1911
+
1912
+ if (msg.sessionID !== sessionId) {
1913
+ return
1914
+ }
1915
+ if (msg.role !== 'assistant') {
1916
+ return
1917
+ }
1918
+ if (!sessionId) {
1919
+ return
1920
+ }
1921
+ if (!isAssistantMessageInLatestUserTurn({
1922
+ events: this.eventBuffer,
1923
+ sessionId,
1924
+ messageId: msg.id,
1925
+ })) {
1926
+ logger.info(`[SKIP] message.updated for old assistant message ${msg.id}, not in latest user turn`)
1927
+ return
1928
+ }
1929
+
1930
+ const knownMessage = this.partBuffer.has(msg.id)
1931
+
1932
+ // promptAsync paths can deliver complete parts via message.updated even when
1933
+ // message.part.updated events are sparse or absent. Seed the part buffer
1934
+ // from message.parts when we have not seen per-part events for this message.
1935
+ if (!knownMessage) {
1936
+ const messageParts = (() => {
1937
+ const candidate: { parts?: unknown } = msg as { parts?: unknown }
1938
+ if (!Array.isArray(candidate.parts)) {
1939
+ return [] as Part[]
1940
+ }
1941
+ return candidate.parts.filter((part): part is Part => {
1942
+ if (!part || typeof part !== 'object') {
1943
+ return false
1944
+ }
1945
+ const maybePart = part as {
1946
+ id?: unknown
1947
+ type?: unknown
1948
+ messageID?: unknown
1949
+ }
1950
+ return (
1951
+ typeof maybePart.id === 'string' &&
1952
+ typeof maybePart.type === 'string' &&
1953
+ typeof maybePart.messageID === 'string'
1954
+ )
1955
+ })
1956
+ })()
1957
+ messageParts.forEach((part) => {
1958
+ this.storePart(part)
1959
+ })
1960
+ }
1961
+
1962
+ await this.flushBufferedParts({
1963
+ messageID: msg.id,
1964
+ force: false,
1965
+ })
1966
+
1967
+ const wasAlreadyCompleted = hasAssistantMessageCompletedBefore({
1968
+ events: this.eventBuffer,
1969
+ sessionId,
1970
+ messageId: msg.id,
1971
+ upToIndex: this.eventBuffer.length - 2,
1972
+ })
1973
+ const completedAt = msg.time.completed
1974
+ if (
1975
+ !wasAlreadyCompleted
1976
+ && typeof completedAt === 'number'
1977
+ && isAssistantMessageNaturalCompletion({ message: msg })
1978
+ ) {
1979
+ await this.handleNaturalAssistantCompletion({
1980
+ completedMessageId: msg.id,
1981
+ completedAt,
1982
+ })
1983
+ return
1984
+ }
1985
+
1986
+ // Context usage notice.
1987
+ // Skip the final assistant update for a run: by the time the last
1988
+ // message.updated arrives, the final text part has already ended and the
1989
+ // buffered parts usually include step-finish, so a notice here would land
1990
+ // immediately above the footer and add noise.
1991
+ if (this.hasBufferedStepFinish(msg.id)) {
1992
+ return
1993
+ }
1994
+ const latestRunInfo = getLatestRunInfo({
1995
+ events: this.eventBuffer,
1996
+ sessionId,
1997
+ })
1998
+ if (
1999
+ latestRunInfo.tokensUsed === 0
2000
+ || !latestRunInfo.providerID
2001
+ || !latestRunInfo.model
2002
+ ) {
2003
+ return
2004
+ }
2005
+ await this.ensureModelContextLimit({
2006
+ providerID: latestRunInfo.providerID,
2007
+ modelID: latestRunInfo.model,
2008
+ })
2009
+ if (!this.modelContextLimit) {
2010
+ return
2011
+ }
2012
+ const currentPercentage = Math.floor(
2013
+ (latestRunInfo.tokensUsed / this.modelContextLimit) * 100,
2014
+ )
2015
+ const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
2016
+ if (
2017
+ thresholdCrossed <= this.lastDisplayedContextPercentage ||
2018
+ thresholdCrossed < 10
2019
+ ) {
2020
+ return
2021
+ }
2022
+ this.lastDisplayedContextPercentage = thresholdCrossed
2023
+ const chunk = `⬦ context usage ${currentPercentage}%`
2024
+ const sendResult = await errore.tryAsync(() => {
2025
+ return this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
2026
+ })
2027
+ if (sendResult instanceof Error) {
2028
+ discordLogger.error('Failed to send context usage notice:', sendResult)
2029
+ }
2030
+ }
2031
+
2032
+ private async handlePartUpdated(part: Part): Promise<void> {
2033
+ this.storePart(part)
2034
+ const sessionId = this.state?.sessionId
2035
+
2036
+ const subtaskInfo = this.getSubtaskInfoForSession(part.sessionID)
2037
+ const isSubtaskEvent = Boolean(subtaskInfo)
2038
+
2039
+ if (part.sessionID !== sessionId && !isSubtaskEvent) {
2040
+ return
2041
+ }
2042
+
2043
+ if (isSubtaskEvent && subtaskInfo) {
2044
+ await this.handleSubtaskPart(part, subtaskInfo)
2045
+ return
2046
+ }
2047
+
2048
+ await this.handleMainPart(part)
2049
+ }
2050
+
2051
+ private async handleMainPart(part: Part): Promise<void> {
2052
+ if (part.type === 'step-start') {
2053
+ this.ensureTypingNow()
2054
+ return
2055
+ }
2056
+
2057
+ if (part.type === 'tool' && part.state.status === 'running') {
2058
+ await this.flushBufferedParts({
2059
+ messageID: part.messageID,
2060
+ force: true,
2061
+ skipPartId: part.id,
2062
+ })
2063
+ await this.sendPartMessage({ part })
2064
+
2065
+ // Track task tool spawning subtask sessions
2066
+ if (part.tool === 'task' && !this.state?.sentPartIds.has(part.id)) {
2067
+ const description = (part.state.input?.description as string) || ''
2068
+ const agent = (part.state.input?.subagent_type as string) || 'task'
2069
+ const childSessionId = (part.state.metadata?.sessionId as string) || ''
2070
+ if (description && childSessionId) {
2071
+ if ((await this.getVerbosity()) !== 'text_only') {
2072
+ const taskDisplay = `┣ ${agent} **${description}**`
2073
+ await sendThreadMessage(this.thread, taskDisplay + '\n\n')
2074
+ }
2075
+ }
2076
+ }
2077
+ return
2078
+ }
2079
+
2080
+ // Action buttons tool handler
2081
+ if (
2082
+ part.type === 'tool' &&
2083
+ part.state.status === 'completed' &&
2084
+ part.tool.endsWith('kimaki_action_buttons')
2085
+ ) {
2086
+ const sessionId = this.state?.sessionId
2087
+ await this.showInteractiveUi({
2088
+ skipPartId: part.id,
2089
+ flushMessageId: part.messageID,
2090
+ show: async () => {
2091
+ if (!sessionId) {
2092
+ return
2093
+ }
2094
+ const request = await waitForQueuedActionButtonsRequest({
2095
+ sessionId,
2096
+ timeoutMs: 1500,
2097
+ })
2098
+ if (!request) {
2099
+ logger.warn(
2100
+ `[ACTION] No queued action-buttons request found for session ${sessionId}`,
2101
+ )
2102
+ return
2103
+ }
2104
+ if (request.threadId !== this.thread.id) {
2105
+ logger.warn(
2106
+ `[ACTION] Ignoring queued action-buttons for different thread`,
2107
+ )
2108
+ return
2109
+ }
2110
+ const showResult = await errore.tryAsync(() => {
2111
+ return showActionButtons({
2112
+ thread: this.thread,
2113
+ sessionId: request.sessionId,
2114
+ directory: request.directory,
2115
+ buttons: request.buttons,
2116
+ silent: this.getQueueLength() > 0,
2117
+ })
2118
+ })
2119
+ if (showResult instanceof Error) {
2120
+ logger.error(
2121
+ '[ACTION] Failed to show action buttons:',
2122
+ showResult,
2123
+ )
2124
+ await sendThreadMessage(
2125
+ this.thread,
2126
+ `Failed to show action buttons: ${showResult.message}`,
2127
+ { flags: NOTIFY_MESSAGE_FLAGS },
2128
+ )
2129
+ }
2130
+ },
2131
+ })
2132
+ return
2133
+ }
2134
+
2135
+ // Large output notification for completed tools
2136
+ if (part.type === 'tool' && part.state.status === 'completed') {
2137
+ const sessionId = this.state?.sessionId
2138
+ if (sessionId) {
2139
+ const isCurrentRunMessage = isAssistantMessageInLatestUserTurn({
2140
+ events: this.eventBuffer,
2141
+ sessionId,
2142
+ messageId: part.messageID,
2143
+ })
2144
+ if (!isCurrentRunMessage) {
2145
+ logger.info(`[SKIP] tool part ${part.id} for old assistant message ${part.messageID}, not in latest user turn`)
2146
+ return
2147
+ }
2148
+ }
2149
+ const showLargeOutput = await (async () => {
2150
+ const verbosity = await this.getVerbosity()
2151
+ if (verbosity === 'text_only') {
2152
+ return false
2153
+ }
2154
+ if (verbosity === 'text_and_essential_tools') {
2155
+ return isEssentialToolPart(part)
2156
+ }
2157
+ return true
2158
+ })()
2159
+ if (showLargeOutput) {
2160
+ const output = part.state.output || ''
2161
+ const outputTokens = Math.ceil(output.length / 4)
2162
+ const largeOutputThreshold = 3000
2163
+ if (outputTokens >= largeOutputThreshold) {
2164
+ if (sessionId) {
2165
+ const latestRunInfo = getLatestRunInfo({
2166
+ events: this.eventBuffer,
2167
+ sessionId,
2168
+ })
2169
+ if (latestRunInfo.providerID && latestRunInfo.model) {
2170
+ await this.ensureModelContextLimit({
2171
+ providerID: latestRunInfo.providerID,
2172
+ modelID: latestRunInfo.model,
2173
+ })
2174
+ }
2175
+ }
2176
+ const formattedTokens =
2177
+ outputTokens >= 1000
2178
+ ? `${(outputTokens / 1000).toFixed(1)}k`
2179
+ : String(outputTokens)
2180
+ const percentageSuffix = (() => {
2181
+ if (!this.modelContextLimit) {
2182
+ return ''
2183
+ }
2184
+ const pct = (outputTokens / this.modelContextLimit) * 100
2185
+ if (pct < 1) {
2186
+ return ''
2187
+ }
2188
+ return ` (${pct.toFixed(1)}%)`
2189
+ })()
2190
+ const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
2191
+ const largeOutputResult = await errore.tryAsync(() => {
2192
+ return this.thread.send({
2193
+ content: chunk,
2194
+ flags: SILENT_MESSAGE_FLAGS,
2195
+ })
2196
+ })
2197
+ if (largeOutputResult instanceof Error) {
2198
+ discordLogger.error('Failed to send large output notice:', largeOutputResult)
2199
+ }
2200
+ }
2201
+ }
2202
+ }
2203
+
2204
+ if (part.type === 'reasoning') {
2205
+ await this.sendPartMessage({ part })
2206
+ return
2207
+ }
2208
+
2209
+ if (part.type === 'text' && part.time?.end) {
2210
+ await this.sendPartMessage({ part })
2211
+ return
2212
+ }
2213
+
2214
+ if (part.type === 'step-finish') {
2215
+ await this.flushBufferedParts({
2216
+ messageID: part.messageID,
2217
+ force: true,
2218
+ })
2219
+ this.ensureTypingKeepalive()
2220
+ }
2221
+ }
2222
+
2223
+ private async handleSubtaskPart(
2224
+ part: Part,
2225
+ subtaskInfo: { label: string; assistantMessageId?: string },
2226
+ ): Promise<void> {
2227
+ const verbosity = await this.getVerbosity()
2228
+ if (verbosity === 'text_only') {
2229
+ return
2230
+ }
2231
+ if (verbosity === 'text_and_essential_tools') {
2232
+ if (!isEssentialToolPart(part)) {
2233
+ return
2234
+ }
2235
+ }
2236
+ if (part.type === 'step-start' || part.type === 'step-finish') {
2237
+ return
2238
+ }
2239
+ if (part.type === 'tool' && part.state.status === 'pending') {
2240
+ return
2241
+ }
2242
+ if (part.type === 'text') {
2243
+ return
2244
+ }
2245
+ if (
2246
+ !subtaskInfo.assistantMessageId ||
2247
+ part.messageID !== subtaskInfo.assistantMessageId
2248
+ ) {
2249
+ return
2250
+ }
2251
+
2252
+ const content = formatPart(part, subtaskInfo.label)
2253
+ if (!content.trim() || this.state?.sentPartIds.has(part.id)) {
2254
+ return
2255
+ }
2256
+ const sendResult = await errore.tryAsync(() => {
2257
+ return sendThreadMessage(this.thread, content + '\n\n')
2258
+ })
2259
+ if (sendResult instanceof Error) {
2260
+ discordLogger.error(
2261
+ `ERROR: Failed to send subtask part ${part.id}:`,
2262
+ sendResult,
2263
+ )
2264
+ return
2265
+ }
2266
+ threadState.updateThread(this.threadId, (t) => {
2267
+ const newIds = new Set(t.sentPartIds)
2268
+ newIds.add(part.id)
2269
+ return { ...t, sentPartIds: newIds }
2270
+ })
2271
+ await setPartMessage(part.id, sendResult.id, this.thread.id)
2272
+ this.requestTypingRepulse()
2273
+ }
2274
+
2275
+ private async handleSessionIdle(idleSessionId: string): Promise<void> {
2276
+ const sessionId = this.state?.sessionId
2277
+
2278
+ // ── Subtask idle ──────────────────────────────────────────
2279
+ const subtask = this.getSubtaskInfoForSession(idleSessionId)
2280
+ if (subtask) {
2281
+ logger.log(
2282
+ `[SUBTASK IDLE] Subtask "${subtask?.label}" completed`,
2283
+ )
2284
+ return
2285
+ }
2286
+
2287
+ // ── Main session idle ─────────────────────────────────────
2288
+ // The event is also pushed into the event buffer by handleEvent(),
2289
+ // so waitForEvent() consumers (abort settlement) will see it too.
2290
+ if (idleSessionId === sessionId) {
2291
+ const shouldDrainQueuedMessages = doesLatestUserTurnHaveNaturalCompletion({
2292
+ events: this.eventBuffer,
2293
+ sessionId: idleSessionId,
2294
+ })
2295
+
2296
+ logger.log(
2297
+ `[SESSION IDLE] session became idle sessionId=${sessionId} drainQueue=${shouldDrainQueuedMessages} ${this.formatRunStateForLog()}`,
2298
+ )
2299
+ await this.persistEventBufferDebounced.flush()
2300
+
2301
+ if (!shouldDrainQueuedMessages) {
2302
+ return
2303
+ }
2304
+ // Drain any local-queue items that arrived while the session was busy
2305
+ // (e.g. slow voice transcription with queueMessage=true completing
2306
+ // during or just before idle). Same pattern as handleSessionError.
2307
+ await this.tryDrainQueue({ showIndicator: true })
2308
+ return
2309
+ }
2310
+ }
2311
+
2312
+ private async handleNaturalAssistantCompletion({
2313
+ completedMessageId,
2314
+ completedAt,
2315
+ }: {
2316
+ completedMessageId: string
2317
+ completedAt: number
2318
+ }): Promise<void> {
2319
+ const sessionId = this.state?.sessionId
2320
+ if (!sessionId) {
2321
+ return
2322
+ }
2323
+
2324
+ const assistantMessageIds = [
2325
+ ...this.getAssistantMessageIdsForCurrentTurn({ sessionId }),
2326
+ ]
2327
+ if (assistantMessageIds.length === 0) {
2328
+ return
2329
+ }
2330
+
2331
+ await this.flushBufferedPartsForMessages({
2332
+ messageIDs: assistantMessageIds,
2333
+ force: true,
2334
+ repulseTyping: false,
2335
+ })
2336
+
2337
+ this.stopTyping()
2338
+
2339
+ const turnStartTime = getCurrentTurnStartTime({
2340
+ events: this.eventBuffer,
2341
+ sessionId,
2342
+ })
2343
+ if (turnStartTime !== undefined) {
2344
+ await this.emitFooter({
2345
+ completedAt,
2346
+ runStartTime: turnStartTime,
2347
+ })
2348
+ }
2349
+
2350
+ this.resetPerRunState()
2351
+ this.clearBufferedPartsForMessages(assistantMessageIds)
2352
+ logger.log(
2353
+ `[ASSISTANT COMPLETED] footer emitted for message ${completedMessageId} sessionId=${sessionId} ${this.formatRunStateForLog()}`,
2354
+ )
2355
+ }
2356
+
2357
+ private async handleSessionError(properties: {
2358
+ sessionID?: string
2359
+ error?: {
2360
+ name?: string
2361
+ data?: {
2362
+ message?: string
2363
+ statusCode?: number
2364
+ providerID?: string
2365
+ isRetryable?: boolean
2366
+ responseBody?: string
2367
+ }
2368
+ }
2369
+ }): Promise<void> {
2370
+ const sessionId = this.state?.sessionId
2371
+ if (!properties.sessionID || properties.sessionID !== sessionId) {
2372
+ logger.log(
2373
+ `Ignoring error for different session (expected: ${sessionId}, got: ${properties.sessionID})`,
2374
+ )
2375
+ return
2376
+ }
2377
+
2378
+ // Skip abort errors — they are expected when operations are cancelled
2379
+ if (properties.error?.name === 'MessageAbortedError') {
2380
+ logger.log(
2381
+ `[SESSION ERROR] Operation aborted (expected) sessionId=${sessionId} ${this.formatRunStateForLog()}`,
2382
+ )
2383
+ await this.persistEventBufferDebounced.flush()
2384
+ return
2385
+ }
2386
+
2387
+ const errorMessage = formatSessionErrorFromProps(properties.error)
2388
+ logger.error(`Sending error to thread: ${errorMessage}`)
2389
+ await sendThreadMessage(
2390
+ this.thread,
2391
+ `✗ opencode session error: ${errorMessage}`,
2392
+ { flags: NOTIFY_MESSAGE_FLAGS },
2393
+ )
2394
+ await this.persistEventBufferDebounced.flush()
2395
+
2396
+ // Inject synthetic idle so isSessionBusy() returns false and queued
2397
+ // messages can drain. Without this, a session error leaves the event
2398
+ // buffer in a "busy" state forever (no session.idle follows the error),
2399
+ // causing local-queue items to be stuck indefinitely. See #74.
2400
+ this.markQueueDispatchIdle(sessionId)
2401
+ await this.tryDrainQueue({ showIndicator: true })
2402
+ }
2403
+
2404
+ private async handlePermissionAsked(
2405
+ permission: PermissionRequest,
2406
+ ): Promise<void> {
2407
+ const sessionId = this.state?.sessionId
2408
+ const subtaskInfo = this.getSubtaskInfoForSession(permission.sessionID)
2409
+ const isMainSession = permission.sessionID === sessionId
2410
+ const isSubtaskSession = Boolean(subtaskInfo)
2411
+
2412
+ if (!isMainSession && !isSubtaskSession) {
2413
+ logger.log(
2414
+ `[PERMISSION IGNORED] Permission for unknown session (expected: ${sessionId} or subtask, got: ${permission.sessionID})`,
2415
+ )
2416
+ return
2417
+ }
2418
+
2419
+ const subtaskLabel = subtaskInfo?.label
2420
+
2421
+ const dedupeKey = buildPermissionDedupeKey({
2422
+ permission,
2423
+ directory: this.projectDirectory,
2424
+ })
2425
+ const threadPermissions = pendingPermissions.get(this.thread.id)
2426
+ const existingPending = threadPermissions
2427
+ ? Array.from(threadPermissions.values()).find((pending) => {
2428
+ if (pending.dedupeKey === dedupeKey) {
2429
+ return true
2430
+ }
2431
+ if (pending.directory !== this.projectDirectory) {
2432
+ return false
2433
+ }
2434
+ if (pending.permission.permission !== permission.permission) {
2435
+ return false
2436
+ }
2437
+ return arePatternsCoveredBy({
2438
+ patterns: permission.patterns,
2439
+ coveringPatterns: pending.permission.patterns,
2440
+ })
2441
+ })
2442
+ : undefined
2443
+
2444
+ if (existingPending) {
2445
+ logger.log(
2446
+ `[PERMISSION] Deduped permission ${permission.id} (matches pending ${existingPending.permission.id})`,
2447
+ )
2448
+ this.stopTyping()
2449
+ if (!pendingPermissions.has(this.thread.id)) {
2450
+ pendingPermissions.set(this.thread.id, new Map())
2451
+ }
2452
+ pendingPermissions.get(this.thread.id)!.set(permission.id, {
2453
+ permission,
2454
+ messageId: existingPending.messageId,
2455
+ directory: this.projectDirectory,
2456
+ permissionDirectory: existingPending.permissionDirectory,
2457
+ contextHash: existingPending.contextHash,
2458
+ dedupeKey,
2459
+ })
2460
+ const added = addPermissionRequestToContext({
2461
+ contextHash: existingPending.contextHash,
2462
+ requestId: permission.id,
2463
+ })
2464
+ if (!added) {
2465
+ logger.log(
2466
+ `[PERMISSION] Failed to attach duplicate request ${permission.id} to context`,
2467
+ )
2468
+ }
2469
+ return
2470
+ }
2471
+
2472
+ logger.log(
2473
+ `Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}${subtaskLabel ? `, subtask=${subtaskLabel}` : ''}`,
2474
+ )
2475
+
2476
+ this.stopTyping()
2477
+
2478
+ const { messageId, contextHash } = await showPermissionButtons({
2479
+ thread: this.thread,
2480
+ permission,
2481
+ directory: this.projectDirectory,
2482
+ permissionDirectory: this.sdkDirectory,
2483
+ subtaskLabel,
2484
+ })
2485
+
2486
+ if (!pendingPermissions.has(this.thread.id)) {
2487
+ pendingPermissions.set(this.thread.id, new Map())
2488
+ }
2489
+ pendingPermissions.get(this.thread.id)!.set(permission.id, {
2490
+ permission,
2491
+ messageId,
2492
+ directory: this.projectDirectory,
2493
+ permissionDirectory: this.sdkDirectory,
2494
+ contextHash,
2495
+ dedupeKey,
2496
+ })
2497
+ }
2498
+
2499
+ private handlePermissionReplied(properties: {
2500
+ requestID: string
2501
+ reply: string
2502
+ sessionID: string
2503
+ }): void {
2504
+ const sessionId = this.state?.sessionId
2505
+ const subtaskInfo = this.getSubtaskInfoForSession(properties.sessionID)
2506
+ const isMainSession = properties.sessionID === sessionId
2507
+ const isSubtaskSession = Boolean(subtaskInfo)
2508
+
2509
+ if (!isMainSession && !isSubtaskSession) {
2510
+ return
2511
+ }
2512
+
2513
+ logger.log(
2514
+ `Permission ${properties.requestID} replied with: ${properties.reply}`,
2515
+ )
2516
+
2517
+ const threadPermissions = pendingPermissions.get(this.thread.id)
2518
+ if (!threadPermissions) {
2519
+ return
2520
+ }
2521
+ const pending = threadPermissions.get(properties.requestID)
2522
+ if (!pending) {
2523
+ return
2524
+ }
2525
+ cleanupPermissionContext(pending.contextHash)
2526
+ threadPermissions.delete(properties.requestID)
2527
+ if (threadPermissions.size === 0) {
2528
+ pendingPermissions.delete(this.thread.id)
2529
+ }
2530
+ this.onInteractiveUiStateChanged()
2531
+ }
2532
+
2533
+ private async handleQuestionAsked(
2534
+ questionRequest: QuestionRequest,
2535
+ ): Promise<void> {
2536
+ const sessionId = this.state?.sessionId
2537
+ if (questionRequest.sessionID !== sessionId) {
2538
+ logger.log(
2539
+ `[QUESTION IGNORED] Question for different session (expected: ${sessionId}, got: ${questionRequest.sessionID})`,
2540
+ )
2541
+ return
2542
+ }
2543
+
2544
+ logger.log(
2545
+ `Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
2546
+ )
2547
+
2548
+ await this.showInteractiveUi({
2549
+ show: async () => {
2550
+ if (!sessionId) {
2551
+ return
2552
+ }
2553
+ await showAskUserQuestionDropdowns({
2554
+ thread: this.thread,
2555
+ sessionId,
2556
+ directory: this.projectDirectory,
2557
+ requestId: questionRequest.id,
2558
+ input: { questions: questionRequest.questions },
2559
+ silent: this.getQueueLength() > 0,
2560
+ })
2561
+ },
2562
+ })
2563
+
2564
+ // Queue drain is intentionally NOT done here — tryDrainQueue() already
2565
+ // blocks dispatch while interactive UI (question/permission) is pending.
2566
+ }
2567
+
2568
+ private handleQuestionReplied(properties: { sessionID: string }): void {
2569
+ const sessionId = this.state?.sessionId
2570
+ if (properties.sessionID !== sessionId) {
2571
+ return
2572
+ }
2573
+ this.onInteractiveUiStateChanged()
2574
+
2575
+ // When a question is answered and the local queue has items, the model may
2576
+ // continue the same run without ever reaching the local-queue idle gate.
2577
+ // Hand the queued items to OpenCode's own prompt queue immediately instead
2578
+ // of waiting for tryDrainQueue() to see an idle session.
2579
+ if (this.getQueueLength() > 0 && !this.questionReplyQueueHandoffPromise) {
2580
+ logger.log(
2581
+ `[QUESTION REPLIED] Queue has ${this.getQueueLength()} items, handing off to opencode queue`,
2582
+ )
2583
+ this.questionReplyQueueHandoffPromise = this.handoffQueuedItemsAfterQuestionReply({
2584
+ sessionId,
2585
+ }).catch((error) => {
2586
+ logger.error('[QUESTION REPLIED] Failed to hand off queued messages:', error)
2587
+ if (error instanceof Error) {
2588
+ void notifyError(error, 'Failed to hand off queued messages after question reply')
2589
+ }
2590
+ }).finally(() => {
2591
+ this.questionReplyQueueHandoffPromise = null
2592
+ })
2593
+ }
2594
+ }
2595
+
2596
+ // Detached helper promise for the "question answered while local queue has
2597
+ // items" flow. Prevents starting two overlapping local->opencode queue
2598
+ // handoff sequences when multiple question replies land close together.
2599
+ private questionReplyQueueHandoffPromise: Promise<void> | null = null
2600
+
2601
+ private async handoffQueuedItemsAfterQuestionReply({
2602
+ sessionId,
2603
+ }: {
2604
+ sessionId: string
2605
+ }): Promise<void> {
2606
+ if (this.listenerAborted) {
2607
+ return
2608
+ }
2609
+ if (this.state?.sessionId !== sessionId) {
2610
+ logger.log(
2611
+ `[QUESTION REPLIED] Session changed before queue handoff for thread ${this.threadId}`,
2612
+ )
2613
+ return
2614
+ }
2615
+
2616
+ while (this.state?.sessionId === sessionId) {
2617
+ const next = threadState.dequeueItem(this.threadId)
2618
+ if (!next) {
2619
+ return
2620
+ }
2621
+
2622
+ const displayText = next.command
2623
+ ? `/${next.command.name}`
2624
+ : `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`
2625
+ if (displayText.trim()) {
2626
+ await sendThreadMessage(
2627
+ this.thread,
2628
+ `» **${next.username}:** ${displayText}`,
2629
+ )
2630
+ }
2631
+
2632
+ await this.submitViaOpencodeQueue(next)
2633
+ }
2634
+ }
2635
+
2636
+ private async handleSessionStatus(properties: {
2637
+ sessionID: string
2638
+ status:
2639
+ | { type: 'idle' }
2640
+ | { type: 'retry'; attempt: number; message: string; next: number }
2641
+ | { type: 'busy' }
2642
+ }): Promise<void> {
2643
+ const sessionId = this.state?.sessionId
2644
+ if (properties.sessionID !== sessionId) {
2645
+ return
2646
+ }
2647
+
2648
+ if (properties.status.type === 'idle') {
2649
+ this.stopTyping()
2650
+ return
2651
+ }
2652
+
2653
+ if (properties.status.type === 'busy') {
2654
+ this.ensureTypingNow()
2655
+ return
2656
+ }
2657
+
2658
+ if (properties.status.type !== 'retry') {
2659
+ return
2660
+ }
2661
+
2662
+ // Throttle to once per 10 seconds
2663
+ const now = Date.now()
2664
+ if (now - this.lastRateLimitDisplayTime < 10_000) {
2665
+ return
2666
+ }
2667
+ this.lastRateLimitDisplayTime = now
2668
+
2669
+ const { attempt, message, next } = properties.status
2670
+ const remainingMs = Math.max(0, next - now)
2671
+ const remainingSec = Math.ceil(remainingMs / 1000)
2672
+ const duration = (() => {
2673
+ if (remainingSec < 60) {
2674
+ return `${remainingSec}s`
2675
+ }
2676
+ const mins = Math.floor(remainingSec / 60)
2677
+ const secs = remainingSec % 60
2678
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`
2679
+ })()
2680
+
2681
+ const chunk = `⬦ ${message} - retrying in ${duration} (attempt #${attempt})`
2682
+ const retryResult = await errore.tryAsync(() => {
2683
+ return this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
2684
+ })
2685
+ if (retryResult instanceof Error) {
2686
+ discordLogger.error('Failed to send retry notice:', retryResult)
2687
+ }
2688
+ }
2689
+
2690
+ // Rename the Discord thread to match the OpenCode-generated session title.
2691
+ //
2692
+ // Discord rate-limits channel/thread renames heavily — reported as ~2 per
2693
+ // 10 minutes per thread (discord/discord-api-docs#1900, discordjs/discord.js#6651)
2694
+ // and discord.js setName() can block silently on the 3rd attempt. We therefore:
2695
+ // - rename at most once per distinct title (deduped via appliedOpencodeTitle)
2696
+ // - race setName() against an AbortSignal.timeout() so a throttled call never
2697
+ // blocks the event loop
2698
+ // - fail soft (log + continue) on timeout, 429, or any other error
2699
+ private async handleSessionUpdated(info: {
2700
+ id: string
2701
+ title: string
2702
+ }): Promise<void> {
2703
+ // Only act on the main session for this thread
2704
+ if (info.id !== this.state?.sessionId) {
2705
+ return
2706
+ }
2707
+ const desiredName = deriveThreadNameFromSessionTitle({
2708
+ sessionTitle: info.title,
2709
+ currentName: this.thread.name,
2710
+ })
2711
+ if (!desiredName) {
2712
+ return
2713
+ }
2714
+ const normalizedTitle = info.title.trim()
2715
+ if (this.appliedOpencodeTitle === normalizedTitle) {
2716
+ return
2717
+ }
2718
+ // Mark before the call so concurrent session.updated events don't stack
2719
+ // rename attempts. On failure we keep the mark — a retry won't help
2720
+ // because the failure is almost always a rate limit.
2721
+ this.appliedOpencodeTitle = normalizedTitle
2722
+
2723
+ const RENAME_TIMEOUT_MS = 3000
2724
+ const timeoutSignal = AbortSignal.timeout(RENAME_TIMEOUT_MS)
2725
+ const renameResult = await Promise.race([
2726
+ errore.tryAsync({
2727
+ try: () => this.thread.setName(desiredName),
2728
+ catch: (e) =>
2729
+ new Error('Failed to rename thread from OpenCode title', {
2730
+ cause: e,
2731
+ }),
2732
+ }),
2733
+ new Promise<'timeout'>((resolve) => {
2734
+ timeoutSignal.addEventListener('abort', () => {
2735
+ resolve('timeout')
2736
+ })
2737
+ }),
2738
+ ])
2739
+
2740
+ if (renameResult === 'timeout') {
2741
+ logger.warn(
2742
+ `[TITLE] setName timed out after ${RENAME_TIMEOUT_MS}ms for thread ${this.threadId} (likely rate-limited)`,
2743
+ )
2744
+ return
2745
+ }
2746
+ if (renameResult instanceof Error) {
2747
+ logger.warn(
2748
+ `[TITLE] Could not rename thread ${this.threadId}: ${renameResult.message}`,
2749
+ )
2750
+ return
2751
+ }
2752
+ logger.log(
2753
+ `[TITLE] Renamed thread ${this.threadId} to "${desiredName}" from OpenCode session title`,
2754
+ )
2755
+ }
2756
+
2757
+ private async handleTuiToast(properties: {
2758
+ title?: string
2759
+ message: string
2760
+ variant: 'info' | 'success' | 'warning' | 'error'
2761
+ duration?: number
2762
+ }): Promise<void> {
2763
+ if (properties.variant === 'warning') {
2764
+ return
2765
+ }
2766
+ const toastMessage = properties.message.trim()
2767
+ if (!toastMessage) {
2768
+ return
2769
+ }
2770
+ const titlePrefix = properties.title
2771
+ ? `${properties.title.trim()}: `
2772
+ : ''
2773
+ const chunk = `⬦ ${properties.variant}: ${titlePrefix}${toastMessage}`
2774
+ const toastResult = await errore.tryAsync(() => {
2775
+ return this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
2776
+ })
2777
+ if (toastResult instanceof Error) {
2778
+ discordLogger.error('Failed to send toast notice:', toastResult)
2779
+ }
2780
+ }
2781
+
2782
+ // ── Ingress API ─────────────────────────────────────────────
2783
+
2784
+ /**
2785
+ * Submit a user turn directly to opencode's internal session queue.
2786
+ * This is the default path for normal Discord messages.
2787
+ *
2788
+ * Mirrors dispatchPrompt's preference resolution, abort handling, and error
2789
+ * recovery so that promptAsync receives the same agent/model/variant/system
2790
+ * fields that the local-queue path provides.
2791
+ */
2792
+ private async submitViaOpencodeQueue(input: IngressInput): Promise<EnqueueResult> {
2793
+ let skippedBySessionGuard = false
2794
+
2795
+ await this.dispatchAction(async () => {
2796
+ if (
2797
+ input.expectedSessionId &&
2798
+ this.state?.sessionId !== input.expectedSessionId
2799
+ ) {
2800
+ logger.log(
2801
+ `[ENQUEUE] Skipping stale promptAsync enqueue for thread ${this.threadId}: expected session ${input.expectedSessionId}, current session ${this.state?.sessionId || 'none'}`,
2802
+ )
2803
+ skippedBySessionGuard = true
2804
+ return
2805
+ }
2806
+
2807
+ if (!this.listenerLoopRunning) {
2808
+ void this.startEventListener()
2809
+ }
2810
+
2811
+ // Helper: stop typing and drain queued local messages on error.
2812
+ const cleanupOnError = async (errorMessage: string) => {
2813
+ this.stopTyping()
2814
+ await sendThreadMessage(this.thread, errorMessage, {
2815
+ flags: NOTIFY_MESSAGE_FLAGS,
2816
+ })
2817
+ await this.tryDrainQueue({ showIndicator: true })
2818
+ }
2819
+
2820
+ // ── Ensure session ──────────────────────────────────────
2821
+ const sessionResult = await this.ensureSession({
2822
+ prompt: input.prompt,
2823
+ agent: input.agent,
2824
+ permissions: input.permissions,
2825
+ injectionGuardPatterns: input.injectionGuardPatterns,
2826
+ sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,
2827
+ sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,
2828
+ })
2829
+ if (sessionResult instanceof Error) {
2830
+ await cleanupOnError(`✗ ${sessionResult.message}`)
2831
+ return
2832
+ }
2833
+
2834
+ const { session, getClient, createdNewSession } = sessionResult
2835
+
2836
+ // If listener startup happened before initializeOpencodeForDirectory(),
2837
+ // startEventListener may have exited early with "No OpenCode client".
2838
+ // Re-check after ensureSession so first promptAsync on a cold directory
2839
+ // still has an active SSE listener for message parts.
2840
+ if (!this.listenerLoopRunning) {
2841
+ void this.startEventListener()
2842
+ }
2843
+
2844
+ // ── Resolve model + agent preferences (mirrors dispatchPrompt) ──
2845
+ const channelId = this.channelId
2846
+ const resolvedAppId = input.appId
2847
+
2848
+ if (input.agent && createdNewSession) {
2849
+ await setSessionAgent(session.id, input.agent)
2850
+ }
2851
+
2852
+ await ensureSessionPreferencesSnapshot({
2853
+ sessionId: session.id,
2854
+ channelId,
2855
+ appId: resolvedAppId,
2856
+ getClient,
2857
+ agentOverride: input.agent,
2858
+ modelOverride: input.model,
2859
+ force: createdNewSession,
2860
+ })
2861
+
2862
+ const agentResult = await errore.tryAsync(() => {
2863
+ return resolveValidatedAgentPreference({
2864
+ agent: input.agent,
2865
+ sessionId: session.id,
2866
+ channelId,
2867
+ getClient,
2868
+ })
2869
+ })
2870
+ if (agentResult instanceof Error) {
2871
+ await cleanupOnError(`Failed to resolve agent: ${agentResult.message}`)
2872
+ return
2873
+ }
2874
+ const resolvedAgent = agentResult.agentPreference
2875
+ const availableAgents = agentResult.agents
2876
+
2877
+ const [modelResult, preferredVariant] = await Promise.all([
2878
+ errore.tryAsync(async () => {
2879
+ if (input.model) {
2880
+ const [providerID, ...modelParts] = input.model.split('/')
2881
+ const modelID = modelParts.join('/')
2882
+ if (providerID && modelID) {
2883
+ return { providerID, modelID }
2884
+ }
2885
+ }
2886
+ const modelInfo = await getCurrentModelInfo({
2887
+ sessionId: session.id,
2888
+ channelId,
2889
+ appId: resolvedAppId,
2890
+ agentPreference: resolvedAgent,
2891
+ getClient,
2892
+ })
2893
+ if (modelInfo.type === 'none') {
2894
+ return undefined
2895
+ }
2896
+ return { providerID: modelInfo.providerID, modelID: modelInfo.modelID }
2897
+ }),
2898
+ getVariantCascade({
2899
+ sessionId: session.id,
2900
+ channelId,
2901
+ appId: resolvedAppId,
2902
+ }),
2903
+ ])
2904
+ if (modelResult instanceof Error) {
2905
+ await cleanupOnError(`Failed to resolve model: ${modelResult.message}`)
2906
+ return
2907
+ }
2908
+ const modelField = modelResult
2909
+ if (!modelField) {
2910
+ await cleanupOnError(
2911
+ 'No AI provider connected. Configure a provider in OpenCode with `/connect` command.',
2912
+ )
2913
+ return
2914
+ }
2915
+
2916
+ // Resolve thinking variant
2917
+ const thinkingValue = await (async (): Promise<string | undefined> => {
2918
+ if (!preferredVariant) {
2919
+ return undefined
2920
+ }
2921
+ const providersResponse = await errore.tryAsync(() => {
2922
+ return getClient().provider.list({ directory: this.sdkDirectory })
2923
+ })
2924
+ if (providersResponse instanceof Error || !providersResponse.data) {
2925
+ return undefined
2926
+ }
2927
+ const availableValues = getThinkingValuesForModel({
2928
+ providers: providersResponse.data.all,
2929
+ providerId: modelField.providerID,
2930
+ modelId: modelField.modelID,
2931
+ })
2932
+ if (availableValues.length === 0) {
2933
+ return undefined
2934
+ }
2935
+ return matchThinkingValue({
2936
+ requestedValue: preferredVariant,
2937
+ availableValues,
2938
+ }) || undefined
2939
+ })()
2940
+
2941
+ const variantField = thinkingValue
2942
+ ? { variant: thinkingValue }
2943
+ : {}
2944
+
2945
+ // ── Build prompt parts ──────────────────────────────────
2946
+ const images = input.images || []
2947
+ const promptWithImagePaths = (() => {
2948
+ if (images.length === 0) {
2949
+ return input.prompt
2950
+ }
2951
+ const imageList = images
2952
+ .map((img) => {
2953
+ return `- ${img.sourceUrl || img.filename}`
2954
+ })
2955
+ .join('\n')
2956
+ return `${input.prompt}\n\n**The following images are already included in this message as inline content (do not use Read tool on these):**\n${imageList}`
2957
+ })()
2958
+
2959
+ // ── Worktree + channel topic for per-turn prompt context ──
2960
+ const worktreeInfo = await getThreadWorktree(this.thread.id)
2961
+ const worktree: WorktreeInfo | undefined =
2962
+ worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
2963
+ ? {
2964
+ worktreeDirectory: worktreeInfo.worktree_directory,
2965
+ branch: worktreeInfo.worktree_name,
2966
+ mainRepoDirectory: worktreeInfo.project_directory,
2967
+ }
2968
+ : undefined
2969
+
2970
+ const channelTopic = await (async () => {
2971
+ if (this.thread.parent?.type === ChannelType.GuildText) {
2972
+ return this.thread.parent.topic?.trim() || undefined
2973
+ }
2974
+ if (!channelId) {
2975
+ return undefined
2976
+ }
2977
+ const fetched = await errore.tryAsync(() => {
2978
+ return this.thread.guild.channels.fetch(channelId)
2979
+ })
2980
+ if (fetched instanceof Error || !fetched) {
2981
+ return undefined
2982
+ }
2983
+ if (fetched.type !== ChannelType.GuildText) {
2984
+ return undefined
2985
+ }
2986
+ return fetched.topic?.trim() || undefined
2987
+ })()
2988
+ const worktreeChanged = this.consumeWorktreePromptChange(worktree)
2989
+ const syntheticContext = getOpencodePromptContext({
2990
+ username: input.username,
2991
+ userId: input.userId,
2992
+ sourceMessageId: input.sourceMessageId,
2993
+ sourceThreadId: input.sourceThreadId,
2994
+ repliedMessage: input.repliedMessage,
2995
+ worktree,
2996
+ currentAgent: resolvedAgent,
2997
+ worktreeChanged,
2998
+ })
2999
+ const parts = [
3000
+ { type: 'text' as const, text: promptWithImagePaths },
3001
+ { type: 'text' as const, text: syntheticContext, synthetic: true },
3002
+ ...images,
3003
+ ]
3004
+
3005
+ const request = {
3006
+ sessionID: session.id,
3007
+ directory: this.sdkDirectory,
3008
+ parts,
3009
+ system: getOpencodeSystemMessage({
3010
+ sessionId: session.id,
3011
+ channelId,
3012
+ guildId: this.thread.guildId,
3013
+ threadId: this.thread.id,
3014
+ channelTopic,
3015
+ agents: availableAgents,
3016
+ username: this.state?.sessionUsername || input.username,
3017
+ }),
3018
+ ...(resolvedAgent ? { agent: resolvedAgent } : {}),
3019
+ ...(modelField ? { model: modelField } : {}),
3020
+ ...variantField,
3021
+ }
3022
+ const promptResult = await errore.tryAsync(() => {
3023
+ return getClient().session.promptAsync(request)
3024
+ })
3025
+ if (promptResult instanceof Error || promptResult.error) {
3026
+ const errorMessage = (() => {
3027
+ if (promptResult instanceof Error) {
3028
+ return promptResult.message
3029
+ }
3030
+ const err = promptResult.error
3031
+ if (err && typeof err === 'object') {
3032
+ if (
3033
+ 'data' in err &&
3034
+ err.data &&
3035
+ typeof err.data === 'object' &&
3036
+ 'message' in err.data
3037
+ ) {
3038
+ return String(err.data.message)
3039
+ }
3040
+ if (
3041
+ 'errors' in err &&
3042
+ Array.isArray(err.errors) &&
3043
+ err.errors.length > 0
3044
+ ) {
3045
+ return JSON.stringify(err.errors)
3046
+ }
3047
+ }
3048
+ return 'Unknown OpenCode API error'
3049
+ })()
3050
+ const errObj = promptResult instanceof Error
3051
+ ? promptResult
3052
+ : new Error(errorMessage)
3053
+ void notifyError(errObj, 'promptAsync failed in submitViaOpencodeQueue')
3054
+ await cleanupOnError(`✗ OpenCode API error: ${errorMessage}`)
3055
+ return
3056
+ }
3057
+
3058
+ logger.log(
3059
+ `[INGRESS] promptAsync accepted by opencode queue sessionId=${session.id} threadId=${this.threadId}`,
3060
+ )
3061
+ this.markQueueDispatchBusy(session.id)
3062
+ })
3063
+
3064
+ if (skippedBySessionGuard) {
3065
+ return { queued: false }
3066
+ }
3067
+ return { queued: false }
3068
+ }
3069
+
3070
+ /**
3071
+ * Enqueue in kimaki's local per-thread queue.
3072
+ * Used for explicit queue workflows (/queue, queueMessage=true).
3073
+ */
3074
+ private async enqueueViaLocalQueue(input: IngressInput): Promise<EnqueueResult> {
3075
+ const queuedMessage: QueuedMessage = {
3076
+ prompt: input.prompt,
3077
+ userId: input.userId,
3078
+ username: input.username,
3079
+ images: input.images,
3080
+ appId: input.appId,
3081
+ command: input.command,
3082
+ agent: input.agent,
3083
+ model: input.model,
3084
+ permissions: input.permissions,
3085
+ injectionGuardPatterns: input.injectionGuardPatterns,
3086
+ sourceMessageId: input.sourceMessageId,
3087
+ sourceThreadId: input.sourceThreadId,
3088
+ repliedMessage: input.repliedMessage,
3089
+ sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,
3090
+ sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,
3091
+ }
3092
+
3093
+ let result: EnqueueResult = { queued: false }
3094
+
3095
+ await this.dispatchAction(async () => {
3096
+ // Enqueue the message
3097
+ threadState.enqueueItem(this.threadId, queuedMessage)
3098
+
3099
+ // Determine if the message is genuinely waiting in queue
3100
+ const stateAfterEnqueue = threadState.getThreadState(this.threadId)
3101
+ const position = stateAfterEnqueue?.queueItems.length ?? 0
3102
+ const willDrainNow = stateAfterEnqueue
3103
+ ? (
3104
+ stateAfterEnqueue.queueItems.length > 0
3105
+ && !this.isMainSessionBusy()
3106
+ )
3107
+ : false
3108
+ result = !willDrainNow && position > 0
3109
+ ? { queued: true, position }
3110
+ : { queued: false }
3111
+
3112
+ // Ensure listener is running
3113
+ if (!this.listenerLoopRunning) {
3114
+ void this.startEventListener()
3115
+ }
3116
+
3117
+ await this.tryDrainQueue()
3118
+ })
3119
+ return result
3120
+ }
3121
+
3122
+ /**
3123
+ * Ingress API for Discord handlers and commands.
3124
+ * Defaults to opencode queue mode; local queue mode is explicit.
3125
+ *
3126
+ * When input.preprocess is set, the preprocessor runs inside dispatchAction
3127
+ * (serialized) to resolve prompt/images/mode before routing. This replaces
3128
+ * the threadIngressQueue that previously serialized pre-enqueue work in
3129
+ * discord-bot.ts.
3130
+ */
3131
+ async enqueueIncoming(input: IngressInput): Promise<EnqueueResult> {
3132
+ threadState.setSessionUsername(this.threadId, input.username)
3133
+
3134
+ // When a preprocessor is provided, we must resolve it inside
3135
+ // dispatchAction before we know the final mode for routing.
3136
+ if (input.preprocess) {
3137
+ return this.enqueueWithPreprocess(input)
3138
+ }
3139
+ // If the prompt starts with `/cmdname ...` (and no explicit command is
3140
+ // already set), rewrite it into a command invocation so it goes through
3141
+ // opencode's session.command API instead of being sent to the model as
3142
+ // plain text. Covers Discord chat messages, /new-session, /queue, CLI
3143
+ // `kimaki send --prompt`, and scheduled tasks — all funnel through here.
3144
+ input = maybeConvertLeadingCommand(input)
3145
+ if (input.mode === 'local-queue') {
3146
+ return this.enqueueViaLocalQueue(input)
3147
+ }
3148
+ if (input.command) {
3149
+ // Commands keep using local queue so they still support /queue-command.
3150
+ return this.enqueueViaLocalQueue(input)
3151
+ }
3152
+ return this.submitViaOpencodeQueue(input)
3153
+ }
3154
+
3155
+ /**
3156
+ * Serialize the preprocess callback via a lightweight promise chain, then
3157
+ * route the resolved input through the normal enqueue paths.
3158
+ *
3159
+ * The preprocess chain is separate from dispatchAction so heavy work
3160
+ * (voice transcription, context fetch, attachment download) doesn't
3161
+ * block SSE event handling, permission UI, or queue drain. Only the
3162
+ * preprocessing order is serialized here — the enqueue itself goes
3163
+ * through dispatchAction as usual.
3164
+ */
3165
+ private async enqueueWithPreprocess(input: IngressInput): Promise<EnqueueResult> {
3166
+ // Deferred result: the chain link resolves/rejects this promise.
3167
+ let resolveOuter!: (value: EnqueueResult | PromiseLike<EnqueueResult>) => void
3168
+ let rejectOuter!: (reason: unknown) => void
3169
+ const resultPromise = new Promise<EnqueueResult>((resolve, reject) => {
3170
+ resolveOuter = resolve
3171
+ rejectOuter = reject
3172
+ })
3173
+
3174
+ // Chain preprocess + enqueue calls so they run in arrival order but
3175
+ // outside dispatchAction. The chain awaits the full enqueue (including
3176
+ // ensureSession / setThreadSession) before releasing to the next
3177
+ // message, so session-creation races on fresh threads are avoided.
3178
+ // The chain itself never rejects (catch + resolve via rejectOuter)
3179
+ // so the next link always runs.
3180
+ this.preprocessChain = this.preprocessChain.then(async () => {
3181
+ try {
3182
+ const result = await input.preprocess!()
3183
+ if (result.skip) {
3184
+ resolveOuter({ queued: false })
3185
+ return
3186
+ }
3187
+ const resolvedInput: IngressInput = maybeConvertLeadingCommand({
3188
+ ...input,
3189
+ prompt: result.prompt,
3190
+ images: result.images,
3191
+ mode: result.mode,
3192
+ // Voice transcription can extract an agent name — apply it only if
3193
+ // no explicit agent was already set (CLI --agent flag wins).
3194
+ agent: input.agent || result.agent,
3195
+ repliedMessage: result.repliedMessage,
3196
+ preprocess: undefined,
3197
+ })
3198
+
3199
+ const hasPromptText = resolvedInput.prompt.trim().length > 0
3200
+ const hasImages = (resolvedInput.images?.length || 0) > 0
3201
+ if (!hasPromptText && !hasImages && !resolvedInput.command) {
3202
+ logger.warn(
3203
+ `[INGRESS] Skipping empty preprocessed input threadId=${this.threadId}`,
3204
+ )
3205
+ resolveOuter({ queued: false })
3206
+ return
3207
+ }
3208
+
3209
+ // Route with the resolved mode through normal paths.
3210
+ // Await the enqueue so session state (ensureSession, setThreadSession)
3211
+ // is persisted before the next message's preprocessing reads it.
3212
+ const enqueueResult =
3213
+ resolvedInput.mode === 'local-queue' || resolvedInput.command
3214
+ ? await this.enqueueViaLocalQueue(resolvedInput)
3215
+ : await this.submitViaOpencodeQueue(resolvedInput)
3216
+ resolveOuter(enqueueResult)
3217
+ } catch (err) {
3218
+ rejectOuter(err)
3219
+ }
3220
+ })
3221
+
3222
+ return resultPromise
3223
+ }
3224
+
3225
+ /**
3226
+ * Abort the currently active run. Does NOT kill the listener.
3227
+ * Calls session.abort best-effort and lets event-stream idle settle the run.
3228
+ */
3229
+ private async abortSessionViaApi({
3230
+ abortId,
3231
+ reason,
3232
+ sessionId,
3233
+ }: {
3234
+ abortId: string
3235
+ reason: string
3236
+ sessionId: string
3237
+ }): Promise<void> {
3238
+ const client = getOpencodeClient(this.projectDirectory)
3239
+ if (!client) {
3240
+ logger.log(
3241
+ `[ABORT API] id=${abortId} reason=${reason} sessionId=${sessionId} skipped=no-client`,
3242
+ )
3243
+ return
3244
+ }
3245
+
3246
+ const startedAt = Date.now()
3247
+ logger.log(
3248
+ `[ABORT API] id=${abortId} reason=${reason} sessionId=${sessionId} start`,
3249
+ )
3250
+ const abortResult = await errore.tryAsync(() => {
3251
+ return client.session.abort({
3252
+ sessionID: sessionId,
3253
+ directory: this.sdkDirectory,
3254
+ })
3255
+ })
3256
+ if (!(abortResult instanceof Error)) {
3257
+ logger.log(
3258
+ `[ABORT API] id=${abortId} reason=${reason} sessionId=${sessionId} success durationMs=${Date.now() - startedAt}`,
3259
+ )
3260
+ return
3261
+ }
3262
+ logger.log(
3263
+ `[ABORT API] id=${abortId} reason=${reason} sessionId=${sessionId} failed durationMs=${Date.now() - startedAt} message=${abortResult.message}`,
3264
+ )
3265
+ }
3266
+
3267
+ private abortActiveRunInternal({
3268
+ reason,
3269
+ }: {
3270
+ reason: string
3271
+ }): AbortRunOutcome {
3272
+ const abortId = this.nextAbortId(reason)
3273
+ const state = this.state
3274
+ if (!state) {
3275
+ logger.log(
3276
+ `[ABORT] id=${abortId} reason=${reason} threadId=${this.threadId} skipped=no-state`,
3277
+ )
3278
+ return {
3279
+ abortId,
3280
+ reason,
3281
+ apiAbortPromise: undefined,
3282
+ }
3283
+ }
3284
+
3285
+ const sessionId = state.sessionId
3286
+ const sessionIsBusy = this.isMainSessionBusy()
3287
+
3288
+ logger.log(
3289
+ `[ABORT] id=${abortId} reason=${reason} threadId=${this.threadId} sessionId=${sessionId || 'none'} queueLength=${state.queueItems.length} ${this.formatRunStateForLog()} sessionBusy=${sessionIsBusy}`,
3290
+ )
3291
+
3292
+ this.stopTyping()
3293
+
3294
+ const apiAbortPromise = sessionId
3295
+ ? this.abortSessionViaApi({ abortId, reason, sessionId })
3296
+ : undefined
3297
+
3298
+ logger.log(
3299
+ `[ABORT] id=${abortId} reason=${reason} threadId=${this.threadId} apiAbort=${Boolean(sessionId)} ${this.formatRunStateForLog()}`,
3300
+ )
3301
+
3302
+ return {
3303
+ abortId,
3304
+ reason,
3305
+ apiAbortPromise,
3306
+ }
3307
+ }
3308
+
3309
+ abortActiveRun(reason: string): void {
3310
+ const outcome = this.abortActiveRunInternal({
3311
+ reason,
3312
+ })
3313
+ if (outcome.apiAbortPromise) {
3314
+ void outcome.apiAbortPromise
3315
+ }
3316
+ // Drain local queued messages after explicit abort.
3317
+ void this.dispatchAction(() => {
3318
+ return this.tryDrainQueue({ showIndicator: true })
3319
+ })
3320
+ }
3321
+
3322
+ async abortActiveRunAndWait({
3323
+ reason,
3324
+ timeoutMs = 2_000,
3325
+ }: {
3326
+ reason: string
3327
+ timeoutMs?: number
3328
+ }): Promise<void> {
3329
+ const state = this.state
3330
+ const sessionId = state?.sessionId
3331
+ if (!sessionId) {
3332
+ return
3333
+ }
3334
+
3335
+ let needsIdleWait = false
3336
+ const waitSinceTimestamp = Date.now()
3337
+ const abortResult = await errore.tryAsync(() => {
3338
+ return this.dispatchAction(async () => {
3339
+ needsIdleWait = this.isMainSessionBusy()
3340
+ const outcome = this.abortActiveRunInternal({ reason })
3341
+ if (outcome.apiAbortPromise) {
3342
+ void outcome.apiAbortPromise
3343
+ }
3344
+ })
3345
+ })
3346
+ if (abortResult instanceof Error) {
3347
+ logger.error(`[ABORT WAIT] Failed to abort active run: ${abortResult.message}`)
3348
+ return
3349
+ }
3350
+ if (!needsIdleWait) {
3351
+ return
3352
+ }
3353
+ await this.waitForEvent({
3354
+ predicate: (event) => {
3355
+ return event.type === 'session.idle'
3356
+ && (event.properties as { sessionID?: string }).sessionID === sessionId
3357
+ },
3358
+ sinceTimestamp: waitSinceTimestamp,
3359
+ timeoutMs,
3360
+ })
3361
+ }
3362
+
3363
+ /** Number of messages waiting in the queue. */
3364
+ getQueueLength(): number {
3365
+ return this.state?.queueItems.length ?? 0
3366
+ }
3367
+
3368
+ /** NOTIFY_MESSAGE_FLAGS unless queue has a next item, then SILENT.
3369
+ * Permissions should NOT use this — they always notify. */
3370
+ private getNotifyFlags(): number {
3371
+ return this.getQueueLength() > 0
3372
+ ? SILENT_MESSAGE_FLAGS
3373
+ : NOTIFY_MESSAGE_FLAGS
3374
+ }
3375
+
3376
+ /** Clear all queued messages. */
3377
+ clearQueue(): void {
3378
+ threadState.clearQueueItems(this.threadId)
3379
+ }
3380
+
3381
+ // ── Queue Drain ─────────────────────────────────────────────
3382
+
3383
+ /**
3384
+ * Check if we can dispatch the next queued message. If so, dequeue and
3385
+ * start dispatchPrompt (detached — does not block the action queue).
3386
+ * Called after enqueue, after run finishes, or after a blocker resolves.
3387
+ *
3388
+ * @param showIndicator - When true, shows "» username: prompt" in Discord.
3389
+ * Only set to true when draining after a previous run finishes or a
3390
+ * blocker resolves — not on the immediate first dispatch from enqueueIncoming.
3391
+ */
3392
+ private async tryDrainQueue({ showIndicator = false } = {}): Promise<void> {
3393
+ const thread = threadState.getThreadState(this.threadId)
3394
+ if (!thread) {
3395
+ return
3396
+ }
3397
+ if (thread.queueItems.length === 0) {
3398
+ return
3399
+ }
3400
+ // Interactive UI (action buttons, questions, permissions) does NOT block
3401
+ // queue drain. The isSessionBusy check is sufficient: questions and
3402
+ // permissions keep the OpenCode session busy, so drain is naturally
3403
+ // blocked. Action buttons are fire-and-forget (session already idle),
3404
+ // so queued messages should dispatch immediately.
3405
+
3406
+ const sessionBusy = thread.sessionId
3407
+ ? isSessionBusy({ events: this.eventBuffer, sessionId: thread.sessionId })
3408
+ : false
3409
+ if (sessionBusy) {
3410
+ return
3411
+ }
3412
+
3413
+ const next = threadState.dequeueItem(this.threadId)
3414
+ if (!next) {
3415
+ return
3416
+ }
3417
+
3418
+ logger.log(
3419
+ `[QUEUE DRAIN] Processing queued message from ${next.username}`,
3420
+ )
3421
+
3422
+ // Show queued message indicator only for messages that actually waited
3423
+ // behind a running request — not for the first immediate dispatch.
3424
+ if (showIndicator) {
3425
+ const displayText = next.command
3426
+ ? `/${next.command.name}`
3427
+ : `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`
3428
+ if (displayText.trim()) {
3429
+ await sendThreadMessage(
3430
+ this.thread,
3431
+ `» **${next.username}:** ${displayText}`,
3432
+ )
3433
+ }
3434
+ }
3435
+
3436
+ // Start dispatch (detached — does not block the action queue).
3437
+ // The prompt call is long-running. Events continue to flow through
3438
+ // the action queue while the SDK call is in-flight. Event-derived busy
3439
+ // gating prevents concurrent local-queue dispatches. Mark busy now to
3440
+ // close the tiny window before the first session.status busy arrives.
3441
+ const dispatchSessionId = thread.sessionId
3442
+ if (dispatchSessionId) {
3443
+ this.markQueueDispatchBusy(dispatchSessionId)
3444
+ }
3445
+ void this.dispatchPrompt(next).catch(async (err) => {
3446
+ logger.error('[DISPATCH] Prompt dispatch failed:', err)
3447
+ void notifyError(err, 'Runtime prompt dispatch failed')
3448
+ if (dispatchSessionId) {
3449
+ this.markQueueDispatchIdle(dispatchSessionId)
3450
+ }
3451
+ }).finally(() => {
3452
+ void this.dispatchAction(() => {
3453
+ return this.tryDrainQueue({ showIndicator: true })
3454
+ })
3455
+ })
3456
+ }
3457
+
3458
+ // ── Prompt Dispatch ─────────────────────────────────────────
3459
+ // Resolve session, build system message, send to OpenCode.
3460
+ // The listener is already running, so this only handles
3461
+ // session ensure + model/agent + SDK call + state.
3462
+
3463
+ private async dispatchPrompt(input: QueuedMessage): Promise<void> {
3464
+ this.lastDisplayedContextPercentage = 0
3465
+ this.lastRateLimitDisplayTime = 0
3466
+
3467
+ // ── Ensure session ────────────────────────────────────────
3468
+ const sessionResult = await this.ensureSession({
3469
+ prompt: input.prompt,
3470
+ agent: input.agent,
3471
+ permissions: input.permissions,
3472
+ injectionGuardPatterns: input.injectionGuardPatterns,
3473
+ sessionStartScheduleKind: input.sessionStartScheduleKind,
3474
+ sessionStartScheduledTaskId: input.sessionStartScheduledTaskId,
3475
+ })
3476
+ if (sessionResult instanceof Error) {
3477
+ this.stopTyping()
3478
+ await sendThreadMessage(
3479
+ this.thread,
3480
+ `✗ ${sessionResult.message}`,
3481
+ { flags: NOTIFY_MESSAGE_FLAGS },
3482
+ )
3483
+ // Show indicator: this dispatch failed, so the next queued message
3484
+ // has been waiting — the user needs to see which one is starting.
3485
+ await this.tryDrainQueue({ showIndicator: true })
3486
+ return
3487
+ }
3488
+ const { session, getClient, createdNewSession } = sessionResult
3489
+
3490
+ // Ensure listener is running now that we have a valid OpenCode client.
3491
+ // The eager start in enqueueIncoming may have failed if the client
3492
+ // wasn't initialized yet (fresh thread, first message).
3493
+ if (!this.listenerLoopRunning) {
3494
+ void this.startEventListener()
3495
+ }
3496
+
3497
+ // ── Resolve model + agent preferences ─────────────────────
3498
+ const channelId = this.channelId
3499
+ const resolvedAppId = input.appId
3500
+
3501
+ if (input.agent && createdNewSession) {
3502
+ await setSessionAgent(session.id, input.agent)
3503
+ }
3504
+
3505
+ await ensureSessionPreferencesSnapshot({
3506
+ sessionId: session.id,
3507
+ channelId,
3508
+ appId: resolvedAppId,
3509
+ getClient,
3510
+ agentOverride: input.agent,
3511
+ modelOverride: input.model,
3512
+ force: createdNewSession,
3513
+ })
3514
+
3515
+ const earlyAgentResult = await errore.tryAsync(() => {
3516
+ return resolveValidatedAgentPreference({
3517
+ agent: input.agent,
3518
+ sessionId: session.id,
3519
+ channelId,
3520
+ getClient,
3521
+ })
3522
+ })
3523
+ if (earlyAgentResult instanceof Error) {
3524
+ this.stopTyping()
3525
+ await sendThreadMessage(
3526
+ this.thread,
3527
+ `Failed to resolve agent: ${earlyAgentResult.message}`,
3528
+ { flags: NOTIFY_MESSAGE_FLAGS },
3529
+ )
3530
+ // Show indicator: dispatch failed mid-setup, next queued message was waiting.
3531
+ await this.tryDrainQueue({ showIndicator: true })
3532
+ return
3533
+ }
3534
+ const earlyAgentPreference = earlyAgentResult.agentPreference
3535
+ const earlyAvailableAgents = earlyAgentResult.agents
3536
+
3537
+ const [earlyModelResult, preferredVariant] = await Promise.all([
3538
+ errore.tryAsync(async () => {
3539
+ if (input.model) {
3540
+ const [providerID, ...modelParts] = input.model.split('/')
3541
+ const modelID = modelParts.join('/')
3542
+ if (providerID && modelID) {
3543
+ return { providerID, modelID }
3544
+ }
3545
+ }
3546
+ const modelInfo = await getCurrentModelInfo({
3547
+ sessionId: session.id,
3548
+ channelId,
3549
+ appId: resolvedAppId,
3550
+ agentPreference: earlyAgentPreference,
3551
+ getClient,
3552
+ })
3553
+ if (modelInfo.type === 'none') {
3554
+ return undefined
3555
+ }
3556
+ return { providerID: modelInfo.providerID, modelID: modelInfo.modelID }
3557
+ }),
3558
+ getVariantCascade({
3559
+ sessionId: session.id,
3560
+ channelId,
3561
+ appId: resolvedAppId,
3562
+ }),
3563
+ ])
3564
+ if (earlyModelResult instanceof Error) {
3565
+ this.stopTyping()
3566
+ await sendThreadMessage(
3567
+ this.thread,
3568
+ `Failed to resolve model: ${earlyModelResult.message}`,
3569
+ { flags: NOTIFY_MESSAGE_FLAGS },
3570
+ )
3571
+ // Show indicator: dispatch failed mid-setup, next queued message was waiting.
3572
+ await this.tryDrainQueue({ showIndicator: true })
3573
+ return
3574
+ }
3575
+ const earlyModelParam = earlyModelResult
3576
+ if (!earlyModelParam) {
3577
+ this.stopTyping()
3578
+ await sendThreadMessage(
3579
+ this.thread,
3580
+ 'No AI provider connected. Configure a provider in OpenCode with `/connect` command.',
3581
+ )
3582
+ // Show indicator: dispatch failed, next queued message was waiting.
3583
+ await this.tryDrainQueue({ showIndicator: true })
3584
+ return
3585
+ }
3586
+
3587
+ // Resolve thinking variant
3588
+ const earlyThinkingValue = await (async (): Promise<string | undefined> => {
3589
+ if (!preferredVariant) {
3590
+ return undefined
3591
+ }
3592
+ const providersResponse = await errore.tryAsync(() => {
3593
+ return getClient().provider.list({ directory: this.sdkDirectory })
3594
+ })
3595
+ if (providersResponse instanceof Error || !providersResponse.data) {
3596
+ return undefined
3597
+ }
3598
+ const availableValues = getThinkingValuesForModel({
3599
+ providers: providersResponse.data.all,
3600
+ providerId: earlyModelParam.providerID,
3601
+ modelId: earlyModelParam.modelID,
3602
+ })
3603
+ if (availableValues.length === 0) {
3604
+ return undefined
3605
+ }
3606
+ return matchThinkingValue({
3607
+ requestedValue: preferredVariant,
3608
+ availableValues,
3609
+ }) || undefined
3610
+ })()
3611
+
3612
+ await this.ensureModelContextLimit({
3613
+ providerID: earlyModelParam.providerID,
3614
+ modelID: earlyModelParam.modelID,
3615
+ })
3616
+
3617
+ // ── Build prompt parts ────────────────────────────────────
3618
+ const images = input.images || []
3619
+ const promptWithImagePaths = (() => {
3620
+ if (images.length === 0) {
3621
+ return input.prompt
3622
+ }
3623
+ const imageList = images
3624
+ .map((img) => {
3625
+ return `- ${img.sourceUrl || img.filename}`
3626
+ })
3627
+ .join('\n')
3628
+ return `${input.prompt}\n\n**The following images are already included in this message as inline content (do not use Read tool on these):**\n${imageList}`
3629
+ })()
3630
+
3631
+ // ── Worktree info for per-turn prompt context ─────────────
3632
+ const worktreeInfo = await getThreadWorktree(this.thread.id)
3633
+ const worktree: WorktreeInfo | undefined =
3634
+ worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
3635
+ ? {
3636
+ worktreeDirectory: worktreeInfo.worktree_directory,
3637
+ branch: worktreeInfo.worktree_name,
3638
+ mainRepoDirectory: worktreeInfo.project_directory,
3639
+ }
3640
+ : undefined
3641
+
3642
+ const channelTopic = await (async () => {
3643
+ if (this.thread.parent?.type === ChannelType.GuildText) {
3644
+ return this.thread.parent.topic?.trim() || undefined
3645
+ }
3646
+ if (!channelId) {
3647
+ return undefined
3648
+ }
3649
+ const fetched = await errore.tryAsync(() => {
3650
+ return this.thread.guild.channels.fetch(channelId)
3651
+ })
3652
+ if (fetched instanceof Error || !fetched) {
3653
+ return undefined
3654
+ }
3655
+ if (fetched.type !== ChannelType.GuildText) {
3656
+ return undefined
3657
+ }
3658
+ return fetched.topic?.trim() || undefined
3659
+ })()
3660
+ const worktreeChanged = this.consumeWorktreePromptChange(worktree)
3661
+ const syntheticContext = getOpencodePromptContext({
3662
+ username: input.username,
3663
+ userId: input.userId,
3664
+ sourceMessageId: input.sourceMessageId,
3665
+ sourceThreadId: input.sourceThreadId,
3666
+ repliedMessage: input.repliedMessage,
3667
+ worktree,
3668
+ currentAgent: earlyAgentPreference,
3669
+ worktreeChanged,
3670
+ })
3671
+ const parts = [
3672
+ { type: 'text' as const, text: promptWithImagePaths },
3673
+ { type: 'text' as const, text: syntheticContext, synthetic: true },
3674
+ ...images,
3675
+ ]
3676
+
3677
+ const variantField = earlyThinkingValue
3678
+ ? { variant: earlyThinkingValue }
3679
+ : {}
3680
+
3681
+ const parseOpenCodeErrorMessage = (err: unknown): string => {
3682
+ if (err && typeof err === 'object') {
3683
+ if (
3684
+ 'data' in err &&
3685
+ err.data &&
3686
+ typeof err.data === 'object' &&
3687
+ 'message' in err.data
3688
+ ) {
3689
+ return String(err.data.message)
3690
+ }
3691
+ if (
3692
+ 'errors' in err &&
3693
+ Array.isArray(err.errors) &&
3694
+ err.errors.length > 0
3695
+ ) {
3696
+ return JSON.stringify(err.errors)
3697
+ }
3698
+ if ('message' in err && typeof err.message === 'string') {
3699
+ return err.message
3700
+ }
3701
+ }
3702
+ return 'Unknown OpenCode API error'
3703
+ }
3704
+
3705
+ if (input.command) {
3706
+ const queuedCommand = input.command
3707
+ const commandSignal = AbortSignal.timeout(30_000)
3708
+ // session.command() only accepts FilePart in parts, not text parts.
3709
+ // Append <discord-user /> tag to arguments so external sync can
3710
+ // detect this message came from Discord (same tag as promptAsync).
3711
+ const discordTag = getOpencodePromptContext({
3712
+ username: input.username,
3713
+ userId: input.userId,
3714
+ sourceMessageId: input.sourceMessageId,
3715
+ sourceThreadId: input.sourceThreadId,
3716
+ repliedMessage: input.repliedMessage,
3717
+ })
3718
+ const commandResponse = await errore.tryAsync(() => {
3719
+ return getClient().session.command(
3720
+ {
3721
+ sessionID: session.id,
3722
+
3723
+ directory: this.sdkDirectory,
3724
+ command: queuedCommand.name,
3725
+ arguments: queuedCommand.arguments + (discordTag ? `\n${discordTag}` : ''),
3726
+ agent: earlyAgentPreference,
3727
+ ...variantField,
3728
+ },
3729
+ { signal: commandSignal },
3730
+ )
3731
+ })
3732
+
3733
+ if (commandResponse instanceof Error) {
3734
+ const timeoutReason = commandSignal.reason
3735
+ const timedOut =
3736
+ commandSignal.aborted &&
3737
+ timeoutReason instanceof Error &&
3738
+ timeoutReason.name === 'TimeoutError'
3739
+ if (timedOut) {
3740
+ logger.warn(
3741
+ `[DISPATCH] Command timed out after 30s sessionId=${session.id}`,
3742
+ )
3743
+ this.stopTyping()
3744
+ await sendThreadMessage(
3745
+ this.thread,
3746
+ '✗ Command timed out after 30 seconds. Try a shorter command or run it with /run-shell-command.',
3747
+ { flags: NOTIFY_MESSAGE_FLAGS },
3748
+ )
3749
+ await this.dispatchAction(() => {
3750
+ return this.tryDrainQueue({ showIndicator: true })
3751
+ })
3752
+ return
3753
+ }
3754
+
3755
+ const commandErrorForAbortCheck: unknown = commandResponse
3756
+ if (isAbortError(commandErrorForAbortCheck)) {
3757
+ logger.log(
3758
+ `[DISPATCH] Command aborted (expected) sessionId=${session.id}`,
3759
+ )
3760
+ this.stopTyping()
3761
+ return
3762
+ }
3763
+
3764
+ logger.error(
3765
+ `[DISPATCH] Command SDK call failed: ${commandResponse.message}`,
3766
+ )
3767
+ void notifyError(commandResponse, 'Failed to send command to OpenCode')
3768
+ this.stopTyping()
3769
+ await sendThreadMessage(
3770
+ this.thread,
3771
+ `✗ Unexpected bot Error: ${commandResponse.message}`,
3772
+ { flags: NOTIFY_MESSAGE_FLAGS },
3773
+ )
3774
+ await this.dispatchAction(() => {
3775
+ return this.tryDrainQueue({ showIndicator: true })
3776
+ })
3777
+ return
3778
+ }
3779
+
3780
+ if (commandResponse.error) {
3781
+ const errorMessage = parseOpenCodeErrorMessage(commandResponse.error)
3782
+ if (errorMessage.includes('aborted')) {
3783
+ logger.log(
3784
+ `[DISPATCH] Command aborted (expected) sessionId=${session.id}`,
3785
+ )
3786
+ this.stopTyping()
3787
+ return
3788
+ }
3789
+ const apiError = new Error(`OpenCode API error: ${errorMessage}`)
3790
+ logger.error(`[DISPATCH] ${apiError.message}`)
3791
+ void notifyError(apiError, 'OpenCode API error during command')
3792
+ this.stopTyping()
3793
+ await sendThreadMessage(this.thread, `✗ ${apiError.message}`, {
3794
+ flags: NOTIFY_MESSAGE_FLAGS,
3795
+ })
3796
+ await this.dispatchAction(() => {
3797
+ return this.tryDrainQueue({ showIndicator: true })
3798
+ })
3799
+ return
3800
+ }
3801
+
3802
+ logger.log(`[DISPATCH] Successfully ran command for session ${session.id}`)
3803
+ return
3804
+ }
3805
+
3806
+ const promptResponse = await errore.tryAsync(() => {
3807
+ return getClient().session.promptAsync({
3808
+ sessionID: session.id,
3809
+ directory: this.sdkDirectory,
3810
+ parts,
3811
+ system: getOpencodeSystemMessage({
3812
+ sessionId: session.id,
3813
+ channelId,
3814
+ guildId: this.thread.guildId,
3815
+ threadId: this.thread.id,
3816
+ channelTopic,
3817
+ agents: earlyAvailableAgents,
3818
+ username: this.state?.sessionUsername || input.username,
3819
+ }),
3820
+ model: earlyModelParam,
3821
+ agent: earlyAgentPreference,
3822
+ ...variantField,
3823
+ })
3824
+ })
3825
+
3826
+ if (promptResponse instanceof Error || promptResponse.error) {
3827
+ const errorMessage = (() => {
3828
+ if (promptResponse instanceof Error) {
3829
+ return promptResponse.message
3830
+ }
3831
+ return parseOpenCodeErrorMessage(promptResponse.error)
3832
+ })()
3833
+ const errorObject = promptResponse instanceof Error
3834
+ ? promptResponse
3835
+ : new Error(errorMessage)
3836
+ logger.error(`[DISPATCH] Prompt API call failed: ${errorMessage}`)
3837
+ void notifyError(errorObject, 'OpenCode API error during local queue prompt')
3838
+ this.stopTyping()
3839
+ await sendThreadMessage(this.thread, `✗ OpenCode API error: ${errorMessage}`, {
3840
+ flags: NOTIFY_MESSAGE_FLAGS,
3841
+ })
3842
+ await this.dispatchAction(() => {
3843
+ return this.tryDrainQueue({ showIndicator: true })
3844
+ })
3845
+ return
3846
+ }
3847
+
3848
+ logger.log(
3849
+ `[DISPATCH] promptAsync accepted by opencode queue sessionId=${session.id} threadId=${this.threadId}`,
3850
+ )
3851
+ }
3852
+
3853
+ // ── Session Ensure ──────────────────────────────────────────
3854
+ // Creates or reuses the OpenCode session for this thread.
3855
+
3856
+ private async ensureSession({
3857
+ prompt,
3858
+ agent,
3859
+ permissions,
3860
+ injectionGuardPatterns,
3861
+ sessionStartScheduleKind,
3862
+ sessionStartScheduledTaskId,
3863
+ }: {
3864
+ prompt: string
3865
+ agent?: string
3866
+ /** Raw "tool:action" strings from --permission flag */
3867
+ permissions?: string[]
3868
+ injectionGuardPatterns?: string[]
3869
+ sessionStartScheduleKind?: 'at' | 'cron'
3870
+ sessionStartScheduledTaskId?: number
3871
+ }): Promise<
3872
+ | Error
3873
+ | {
3874
+ session: { id: string }
3875
+ getClient: () => OpencodeClient
3876
+ createdNewSession: boolean
3877
+ }
3878
+ > {
3879
+ const directory = this.projectDirectory
3880
+
3881
+ // Resolve worktree info for server initialization
3882
+ const worktreeInfo = await getThreadWorktree(this.thread.id)
3883
+ const worktreeDirectory =
3884
+ worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
3885
+ ? worktreeInfo.worktree_directory
3886
+ : undefined
3887
+ const originalRepoDirectory = worktreeDirectory
3888
+ ? worktreeInfo?.project_directory
3889
+ : undefined
3890
+
3891
+ const getClientResult = await initializeOpencodeForDirectory(directory, {
3892
+ originalRepoDirectory,
3893
+ channelId: this.channelId,
3894
+ })
3895
+ if (getClientResult instanceof Error) {
3896
+ return getClientResult
3897
+ }
3898
+ const getClient = getClientResult
3899
+
3900
+ // Check thread state for existing session ID
3901
+ let sessionId = this.state?.sessionId
3902
+ if (!sessionId) {
3903
+ // Fallback to DB
3904
+ sessionId = await getThreadSession(this.thread.id) || undefined
3905
+ }
3906
+
3907
+ let session: { id: string } | undefined
3908
+ let createdNewSession = false
3909
+
3910
+ if (sessionId) {
3911
+ const sessionResponse = await errore.tryAsync(() => {
3912
+ return getClient().session.get({
3913
+ sessionID: sessionId,
3914
+ directory: this.sdkDirectory,
3915
+ })
3916
+ })
3917
+ if (!(sessionResponse instanceof Error) && sessionResponse.data) {
3918
+ session = sessionResponse.data
3919
+ }
3920
+ }
3921
+
3922
+ if (!session) {
3923
+ // Pass per-session external_directory permissions so this session can
3924
+ // access its own project directory (and worktree origin if applicable)
3925
+ // without prompts. These override the server-level 'ask' default via
3926
+ // opencode's findLast() rule evaluation.
3927
+ // CLI --permission rules are appended after base rules so they win
3928
+ // via opencode's findLast() evaluation.
3929
+ const sessionPermissions = [
3930
+ ...buildSessionPermissions({
3931
+ directory: this.sdkDirectory,
3932
+ originalRepoDirectory,
3933
+ }),
3934
+ ...parsePermissionRules(permissions ?? []),
3935
+ ]
3936
+ // Omit title so OpenCode auto-generates a summary from the conversation
3937
+ const sessionResponse = await getClient().session.create({
3938
+ directory: this.sdkDirectory,
3939
+ permission: sessionPermissions,
3940
+ })
3941
+ session = sessionResponse.data
3942
+ // Insert DB row immediately so the external-sync poller sees
3943
+ // source='kimaki' before the next poll tick and skips this session.
3944
+ // The upsert at the end of ensureSession is kept for the reuse path.
3945
+ if (session) {
3946
+ await setThreadSession(this.thread.id, session.id)
3947
+ if (injectionGuardPatterns?.length) {
3948
+ writeInjectionGuardConfig({
3949
+ sessionId: session.id,
3950
+ scanPatterns: injectionGuardPatterns,
3951
+ })
3952
+ }
3953
+ }
3954
+ createdNewSession = true
3955
+ }
3956
+
3957
+ if (!session) {
3958
+ return new Error('Failed to create or get session')
3959
+ }
3960
+
3961
+ // Store session in DB and thread state
3962
+ await setThreadSession(this.thread.id, session.id)
3963
+ threadState.setSessionId(this.threadId, session.id)
3964
+ await this.hydrateSessionEventsFromDatabase({ sessionId: session.id })
3965
+
3966
+ // Store session start source for scheduled tasks
3967
+ if (createdNewSession && sessionStartScheduleKind) {
3968
+ const sessionStartSourceResult = await errore.tryAsync({
3969
+ try: () => {
3970
+ return setSessionStartSource({
3971
+ sessionId: session.id,
3972
+ scheduleKind: sessionStartScheduleKind,
3973
+ scheduledTaskId: sessionStartScheduledTaskId,
3974
+ })
3975
+ },
3976
+ catch: (e) =>
3977
+ new Error('Failed to persist scheduled session start source', {
3978
+ cause: e,
3979
+ }),
3980
+ })
3981
+ if (sessionStartSourceResult instanceof Error) {
3982
+ logger.warn(
3983
+ `[SESSION START SOURCE] ${sessionStartSourceResult.message}`,
3984
+ )
3985
+ }
3986
+ }
3987
+
3988
+ // Store agent preference if provided
3989
+ if (agent && createdNewSession) {
3990
+ await setSessionAgent(session.id, agent)
3991
+ }
3992
+
3993
+ return { session, getClient, createdNewSession }
3994
+ }
3995
+
3996
+ /**
3997
+ * Emit the run footer: duration, model, context%, project info.
3998
+ * Triggered directly from the terminal assistant message.updated event so the
3999
+ * footer lands next to the assistant output instead of waiting for session.idle.
4000
+ */
4001
+ private async emitFooter({
4002
+ completedAt,
4003
+ runStartTime,
4004
+ }: {
4005
+ completedAt: number
4006
+ runStartTime: number
4007
+ }): Promise<void> {
4008
+ const sessionId = this.state?.sessionId
4009
+ const runInfo = sessionId
4010
+ ? getLatestRunInfo({ events: this.eventBuffer, sessionId })
4011
+ : {
4012
+ model: undefined,
4013
+ providerID: undefined,
4014
+ agent: undefined,
4015
+ tokensUsed: 0,
4016
+ }
4017
+ const elapsedMs = completedAt - runStartTime
4018
+ const sessionDuration =
4019
+ elapsedMs < 1000
4020
+ ? '<1s'
4021
+ : prettyMilliseconds(elapsedMs, { secondsDecimalDigits: 0 })
4022
+ const modelInfo = runInfo.model ? ` ⋅ ${runInfo.model}` : ''
4023
+ const agentInfo =
4024
+ runInfo.agent && runInfo.agent.toLowerCase() !== 'build'
4025
+ ? ` ⋅ **${runInfo.agent}**`
4026
+ : ''
4027
+ let contextInfo = ''
4028
+ const folderName = path.basename(this.sdkDirectory)
4029
+
4030
+ const client = getOpencodeClient(this.projectDirectory)
4031
+
4032
+ // Run git branch and token fetch in parallel (fast, no external CLI)
4033
+ const [branchResult, contextResult] = await Promise.all([
4034
+ errore.tryAsync(() => {
4035
+ return execAsync('git symbolic-ref --short HEAD', {
4036
+ cwd: this.sdkDirectory,
4037
+ })
4038
+ }),
4039
+ errore.tryAsync(async () => {
4040
+ if (!client || !sessionId) {
4041
+ return
4042
+ }
4043
+ let tokensUsed = runInfo.tokensUsed
4044
+ // Fetch final token count from API
4045
+ const [messagesResult, providersResult] = await Promise.all([
4046
+ tokensUsed === 0
4047
+ ? errore.tryAsync(() => {
4048
+ return client.session.messages({
4049
+ sessionID: sessionId,
4050
+ directory: this.sdkDirectory,
4051
+ })
4052
+ })
4053
+ : null,
4054
+ errore.tryAsync(() => {
4055
+ return client.provider.list({
4056
+ directory: this.sdkDirectory,
4057
+ })
4058
+ }),
4059
+ ])
4060
+
4061
+ if (messagesResult && !(messagesResult instanceof Error)) {
4062
+ const messages = messagesResult.data || []
4063
+ const lastAssistant = [...messages]
4064
+ .reverse()
4065
+ .find((m) => {
4066
+ if (m.info.role !== 'assistant') {
4067
+ return false
4068
+ }
4069
+ if (!m.info.tokens) {
4070
+ return false
4071
+ }
4072
+ return getTokenTotal(m.info.tokens) > 0
4073
+ })
4074
+ if (lastAssistant && 'tokens' in lastAssistant.info) {
4075
+ tokensUsed = getTokenTotal(lastAssistant.info.tokens)
4076
+ }
4077
+ }
4078
+
4079
+ const fallbackLimit = runInfo.providerID
4080
+ ? getFallbackContextLimit({
4081
+ providerID: runInfo.providerID,
4082
+ })
4083
+ : undefined
4084
+
4085
+ let contextLimit = fallbackLimit
4086
+ if (providersResult && !(providersResult instanceof Error)) {
4087
+ const provider = providersResult.data?.all?.find((p) => {
4088
+ return p.id === runInfo.providerID
4089
+ })
4090
+ const model = provider?.models?.[runInfo.model || '']
4091
+ contextLimit = model?.limit?.context || contextLimit
4092
+ }
4093
+
4094
+ if (contextLimit) {
4095
+ const percentage = Math.round(
4096
+ (tokensUsed / contextLimit) * 100,
4097
+ )
4098
+ contextInfo = ` ⋅ ${percentage}%`
4099
+ }
4100
+ }),
4101
+ ])
4102
+ const branchName =
4103
+ branchResult instanceof Error ? '' : branchResult.stdout.trim()
4104
+ if (contextResult instanceof Error) {
4105
+ logger.error(
4106
+ 'Failed to fetch provider info for context percentage:',
4107
+ contextResult,
4108
+ )
4109
+ }
4110
+
4111
+ const truncate = (s: string, max: number) => {
4112
+ return s.length > max ? s.slice(0, max - 1) + '\u2026' : s
4113
+ }
4114
+ const truncatedFolder = truncate(folderName, 15)
4115
+ const truncatedBranch = truncate(branchName, 15)
4116
+ const projectInfo = truncatedBranch
4117
+ ? `${truncatedFolder} ⋅ ${truncatedBranch} ⋅ `
4118
+ : `${truncatedFolder} ⋅ `
4119
+ const footerText = `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*`
4120
+ this.stopTyping()
4121
+
4122
+ // Skip notification if there's a queued message next — the user only
4123
+ // needs to be notified when the entire queue finishes.
4124
+ await sendThreadMessage(this.thread, footerText, {
4125
+ flags: this.getNotifyFlags(),
4126
+ })
4127
+ logger.log(
4128
+ `DURATION: Session completed in ${sessionDuration}, model ${runInfo.model}, tokens ${runInfo.tokensUsed}`,
4129
+ )
4130
+ }
4131
+
4132
+ /** Reset per-run state for the next prompt dispatch. */
4133
+ private resetPerRunState(): void {
4134
+ this.modelContextLimit = undefined
4135
+ this.modelContextLimitKey = undefined
4136
+ this.lastDisplayedContextPercentage = 0
4137
+ this.lastRateLimitDisplayTime = 0
4138
+ }
4139
+
4140
+ // ── Retry Last User Prompt (for model-change flow) ──────────
4141
+
4142
+ /**
4143
+ * Abort the active run and immediately send an empty user prompt.
4144
+ *
4145
+ * Used by /model and /unset-model so opencode can restart from the
4146
+ * current session history with the updated model preference, without
4147
+ * replaying/fetching the last user message in kimaki.
4148
+ */
4149
+ async retryLastUserPrompt(): Promise<boolean> {
4150
+ const state = this.state
4151
+ if (!state?.sessionId) {
4152
+ logger.log(`[RETRY] No session for thread ${this.threadId}`)
4153
+ return false
4154
+ }
4155
+
4156
+ const sessionId = state.sessionId
4157
+
4158
+ // 1. Abort active run.
4159
+ let needsIdleWait = false
4160
+ const waitSinceTimestamp = Date.now()
4161
+ const abortResult = await errore.tryAsync(() => {
4162
+ return this.dispatchAction(async () => {
4163
+ needsIdleWait = this.isMainSessionBusy()
4164
+ const outcome = this.abortActiveRunInternal({
4165
+ reason: 'model-change',
4166
+ })
4167
+ if (outcome.apiAbortPromise) {
4168
+ void outcome.apiAbortPromise
4169
+ }
4170
+ })
4171
+ })
4172
+ if (abortResult instanceof Error) {
4173
+ logger.error('[RETRY] Failed to abort active run before retry:', abortResult)
4174
+ return false
4175
+ }
4176
+
4177
+ if (needsIdleWait) {
4178
+ await this.waitForEvent({
4179
+ predicate: (event) => {
4180
+ return event.type === 'session.idle'
4181
+ && (event.properties as { sessionID?: string }).sessionID === sessionId
4182
+ },
4183
+ sinceTimestamp: waitSinceTimestamp,
4184
+ timeoutMs: 2000,
4185
+ })
4186
+ }
4187
+
4188
+ if (this.listenerAborted) {
4189
+ logger.log(`[RETRY] Runtime disposed before retry for thread ${this.threadId}`)
4190
+ return false
4191
+ }
4192
+
4193
+ if (this.state?.sessionId !== sessionId) {
4194
+ logger.log(
4195
+ `[RETRY] Session changed before retry for thread ${this.threadId}`,
4196
+ )
4197
+ return false
4198
+ }
4199
+
4200
+ logger.log(
4201
+ `[RETRY] Re-submitting with empty prompt for session ${sessionId}`,
4202
+ )
4203
+
4204
+ // 2. Re-submit with empty prompt so opencode continues from session history.
4205
+ await this.enqueueIncoming({
4206
+ prompt: '',
4207
+ userId: '',
4208
+ username: '',
4209
+ appId: this.appId,
4210
+ mode: 'opencode',
4211
+ resetAssistantForNewRun: true,
4212
+ expectedSessionId: sessionId,
4213
+ })
4214
+
4215
+ if (this.state?.sessionId !== sessionId) {
4216
+ logger.log(
4217
+ `[RETRY] Session changed while retry was enqueued for thread ${this.threadId}`,
4218
+ )
4219
+ return false
4220
+ }
4221
+
4222
+ return true
4223
+ }
4224
+ }
4225
+
4226
+ // ── Module-level helpers ──────────────────────────────────────────
4227
+
4228
+ function buildPermissionDedupeKey({
4229
+ permission,
4230
+ directory,
4231
+ }: {
4232
+ permission: PermissionRequest
4233
+ directory: string
4234
+ }): string {
4235
+ const normalizedPatterns = [...permission.patterns].sort((a, b) => {
4236
+ return a.localeCompare(b)
4237
+ })
4238
+ return `${directory}::${permission.permission}::${normalizedPatterns.join('|')}`
4239
+ }
4240
+
4241
+ function getFallbackContextLimit({
4242
+ providerID,
4243
+ }: {
4244
+ providerID: string
4245
+ }): number | undefined {
4246
+ if (providerID === 'deterministic-provider') {
4247
+ return DETERMINISTIC_CONTEXT_LIMIT
4248
+ }
4249
+ return undefined
4250
+ }
4251
+
4252
+ /** Format a session error from event properties for display. */
4253
+ function formatSessionErrorFromProps(error?: {
4254
+ name?: string
4255
+ data?: {
4256
+ message?: string
4257
+ statusCode?: number
4258
+ providerID?: string
4259
+ isRetryable?: boolean
4260
+ responseBody?: string
4261
+ }
4262
+ }): string {
4263
+ if (!error) {
4264
+ return 'Unknown error'
4265
+ }
4266
+ const data = error.data
4267
+ if (!data) {
4268
+ return error.name || 'Unknown error'
4269
+ }
4270
+ const parts: string[] = []
4271
+ if (data.message) {
4272
+ parts.push(data.message)
4273
+ }
4274
+ if (data.statusCode) {
4275
+ parts.push(`(${data.statusCode})`)
4276
+ }
4277
+ if (data.providerID) {
4278
+ parts.push(`[${data.providerID}]`)
4279
+ }
4280
+ return parts.length > 0 ? parts.join(' ') : error.name || 'Unknown error'
4281
+ }