@otto-assistant/bridge 0.4.92

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