@otto-assistant/otto 0.1.2 → 0.7.15

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