@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,1053 @@
1
+ // /model command - Set the preferred model for this channel or session.
2
+
3
+ import {
4
+ ChatInputCommandInteraction,
5
+ StringSelectMenuInteraction,
6
+ StringSelectMenuBuilder,
7
+ ActionRowBuilder,
8
+ ChannelType,
9
+ type ThreadChannel,
10
+ type TextChannel,
11
+ MessageFlags,
12
+ } from 'discord.js'
13
+ import crypto from 'node:crypto'
14
+ import {
15
+ setChannelModel,
16
+ setSessionModel,
17
+ setSessionAgent,
18
+ getChannelModel,
19
+ getSessionModel,
20
+ getSessionAgent,
21
+ getChannelAgent,
22
+ getThreadSession,
23
+ getGlobalModel,
24
+ setGlobalModel,
25
+ getVariantCascade,
26
+ } from '../database.js'
27
+ import { initializeOpencodeForDirectory } from '../opencode.js'
28
+ import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
29
+ import { getDefaultModel } from '../session-handler/model-utils.js'
30
+ import { getRuntime } from '../session-handler/thread-session-runtime.js'
31
+ import { getThinkingValuesForModel } from '../thinking-utils.js'
32
+ import { createLogger, LogPrefix } from '../logger.js'
33
+ import * as errore from 'errore'
34
+ import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js'
35
+
36
+ const modelLogger = createLogger(LogPrefix.MODEL)
37
+
38
+ // Store context by hash to avoid customId length limits (Discord max: 100 chars).
39
+ // Entries are TTL'd to prevent unbounded growth when users open /model and never
40
+ // interact with the select menu.
41
+ const MODEL_CONTEXT_TTL_MS = 10 * 60 * 1000
42
+
43
+ type PendingModelContext = {
44
+ dir: string
45
+ channelId: string
46
+ sessionId?: string
47
+ isThread: boolean
48
+ providerId?: string
49
+ providerName?: string
50
+ thread?: ThreadChannel
51
+ appId?: string
52
+ selectedModelId?: string
53
+ selectedVariant?: string | null
54
+ availableVariants?: string[]
55
+ providerPage?: number
56
+ modelPage?: number
57
+ /** Header text shown above the provider select (current model info). */
58
+ providerSelectHeader?: string
59
+ }
60
+
61
+ const pendingModelContexts = new Map<string, PendingModelContext>()
62
+
63
+ function setModelContext(contextHash: string, context: PendingModelContext): void {
64
+ pendingModelContexts.set(contextHash, context)
65
+ setTimeout(() => {
66
+ pendingModelContexts.delete(contextHash)
67
+ }, MODEL_CONTEXT_TTL_MS).unref()
68
+ }
69
+
70
+ export type ProviderInfo = {
71
+ id: string
72
+ name: string
73
+ models: Record<
74
+ string,
75
+ {
76
+ id: string
77
+ name: string
78
+ release_date: string
79
+ }
80
+ >
81
+ }
82
+
83
+ export type ModelSource =
84
+ | 'session'
85
+ | 'agent'
86
+ | 'channel'
87
+ | 'global'
88
+ | 'opencode-config'
89
+ | 'opencode-recent'
90
+ | 'opencode-provider-default'
91
+
92
+ export type CurrentModelInfo =
93
+ | { type: 'session'; model: string; providerID: string; modelID: string }
94
+ | {
95
+ type: 'agent'
96
+ model: string
97
+ providerID: string
98
+ modelID: string
99
+ agentName: string
100
+ }
101
+ | { type: 'channel'; model: string; providerID: string; modelID: string }
102
+ | { type: 'global'; model: string; providerID: string; modelID: string }
103
+ | {
104
+ type: 'opencode-config'
105
+ model: string
106
+ providerID: string
107
+ modelID: string
108
+ }
109
+ | {
110
+ type: 'opencode-recent'
111
+ model: string
112
+ providerID: string
113
+ modelID: string
114
+ }
115
+ | {
116
+ type: 'opencode-provider-default'
117
+ model: string
118
+ providerID: string
119
+ modelID: string
120
+ }
121
+ | { type: 'none' }
122
+
123
+ function parseModelId(
124
+ modelString: string,
125
+ ): { providerID: string; modelID: string } | undefined {
126
+ const [providerID, ...modelParts] = modelString.split('/')
127
+ const modelID = modelParts.join('/')
128
+ if (providerID && modelID) {
129
+ return { providerID, modelID }
130
+ }
131
+ return undefined
132
+ }
133
+
134
+ export async function ensureSessionPreferencesSnapshot({
135
+ sessionId,
136
+ channelId,
137
+ appId,
138
+ getClient,
139
+ agentOverride,
140
+ modelOverride,
141
+ force,
142
+ }: {
143
+ sessionId: string
144
+ channelId?: string
145
+ appId?: string
146
+ getClient: Awaited<ReturnType<typeof initializeOpencodeForDirectory>>
147
+ agentOverride?: string
148
+ modelOverride?: string
149
+ force?: boolean
150
+ }): Promise<void> {
151
+ const [sessionAgentPreference, sessionModelPreference] = await Promise.all([
152
+ getSessionAgent(sessionId),
153
+ getSessionModel(sessionId),
154
+ ])
155
+ const shouldBootstrapSessionPreferences =
156
+ force || (!sessionAgentPreference && !sessionModelPreference)
157
+ if (!shouldBootstrapSessionPreferences) {
158
+ return
159
+ }
160
+
161
+ const bootstrappedAgent =
162
+ agentOverride ||
163
+ sessionAgentPreference ||
164
+ (channelId ? await getChannelAgent(channelId) : undefined)
165
+ if (!sessionAgentPreference && bootstrappedAgent) {
166
+ await setSessionAgent(sessionId, bootstrappedAgent)
167
+ modelLogger.log(
168
+ `[MODEL] Snapshotted session agent ${bootstrappedAgent} for session ${sessionId}`,
169
+ )
170
+ }
171
+
172
+ if (sessionModelPreference) {
173
+ return
174
+ }
175
+
176
+ if (modelOverride) {
177
+ const parsedModelOverride = parseModelId(modelOverride)
178
+ if (parsedModelOverride) {
179
+ const bootstrappedVariant = await getVariantCascade({
180
+ sessionId,
181
+ channelId,
182
+ appId,
183
+ })
184
+ await setSessionModel({
185
+ sessionId,
186
+ modelId: modelOverride,
187
+ variant: bootstrappedVariant ?? null,
188
+ })
189
+ modelLogger.log(
190
+ `[MODEL] Snapshotted explicit session model ${modelOverride} for session ${sessionId}`,
191
+ )
192
+ return
193
+ }
194
+ modelLogger.warn(
195
+ `[MODEL] Ignoring invalid explicit model override "${modelOverride}" for session ${sessionId}`,
196
+ )
197
+ }
198
+
199
+ const bootstrappedModel = await getCurrentModelInfo({
200
+ sessionId,
201
+ channelId,
202
+ appId,
203
+ agentPreference: bootstrappedAgent,
204
+ getClient,
205
+ })
206
+ if (bootstrappedModel.type === 'none') {
207
+ return
208
+ }
209
+
210
+ const bootstrappedVariant = await getVariantCascade({
211
+ sessionId,
212
+ channelId,
213
+ appId,
214
+ })
215
+ await setSessionModel({
216
+ sessionId,
217
+ modelId: bootstrappedModel.model,
218
+ variant: bootstrappedVariant ?? null,
219
+ })
220
+ modelLogger.log(
221
+ `[MODEL] Snapshotted session model ${bootstrappedModel.model} for session ${sessionId}`,
222
+ )
223
+ }
224
+
225
+ /**
226
+ * Get the current model info for a channel/session, including where it comes from.
227
+ * Priority: session > agent > channel > global > opencode default
228
+ */
229
+ export async function getCurrentModelInfo({
230
+ sessionId,
231
+ channelId,
232
+ appId,
233
+ agentPreference,
234
+ getClient,
235
+ }: {
236
+ sessionId?: string
237
+ channelId?: string
238
+ appId?: string
239
+ agentPreference?: string
240
+ getClient: Awaited<ReturnType<typeof initializeOpencodeForDirectory>>
241
+ }): Promise<CurrentModelInfo> {
242
+ if (getClient instanceof Error) {
243
+ return { type: 'none' }
244
+ }
245
+
246
+ // 1. Check session model preference
247
+ if (sessionId) {
248
+ const sessionPref = await getSessionModel(sessionId)
249
+ if (sessionPref) {
250
+ const parsed = parseModelId(sessionPref.modelId)
251
+ if (parsed) {
252
+ return { type: 'session', model: sessionPref.modelId, ...parsed }
253
+ }
254
+ }
255
+ }
256
+
257
+ // 2. Check agent's configured model
258
+ const effectiveAgent =
259
+ agentPreference ??
260
+ (sessionId
261
+ ? (await getSessionAgent(sessionId)) ||
262
+ (channelId ? await getChannelAgent(channelId) : undefined)
263
+ : channelId
264
+ ? await getChannelAgent(channelId)
265
+ : undefined)
266
+ if (effectiveAgent) {
267
+ const agentsResponse = await getClient().app.agents({})
268
+ if (agentsResponse.data) {
269
+ const agent = agentsResponse.data.find((a) => a.name === effectiveAgent)
270
+ if (agent?.model) {
271
+ const model = `${agent.model.providerID}/${agent.model.modelID}`
272
+ return {
273
+ type: 'agent',
274
+ model,
275
+ providerID: agent.model.providerID,
276
+ modelID: agent.model.modelID,
277
+ agentName: effectiveAgent,
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ // 3. Check channel model preference
284
+ if (channelId) {
285
+ const channelPref = await getChannelModel(channelId)
286
+ if (channelPref) {
287
+ const parsed = parseModelId(channelPref.modelId)
288
+ if (parsed) {
289
+ return { type: 'channel', model: channelPref.modelId, ...parsed }
290
+ }
291
+ }
292
+ }
293
+
294
+ // 4. Check global model preference
295
+ if (appId) {
296
+ const globalPref = await getGlobalModel(appId)
297
+ if (globalPref) {
298
+ const parsed = parseModelId(globalPref.modelId)
299
+ if (parsed) {
300
+ return { type: 'global', model: globalPref.modelId, ...parsed }
301
+ }
302
+ }
303
+ }
304
+
305
+ // 5. Get opencode default (config > recent > provider default)
306
+ const defaultModel = await getDefaultModel({ getClient })
307
+ if (defaultModel) {
308
+ const model = `${defaultModel.providerID}/${defaultModel.modelID}`
309
+ return {
310
+ type: defaultModel.source,
311
+ model,
312
+ providerID: defaultModel.providerID,
313
+ modelID: defaultModel.modelID,
314
+ }
315
+ }
316
+
317
+ return { type: 'none' }
318
+ }
319
+
320
+ /**
321
+ * Handle the /model slash command.
322
+ * Shows a select menu with available providers.
323
+ */
324
+ export async function handleModelCommand({
325
+ interaction,
326
+ appId,
327
+ }: {
328
+ interaction: ChatInputCommandInteraction
329
+ appId: string
330
+ }): Promise<void> {
331
+ modelLogger.log('[MODEL] handleModelCommand called')
332
+
333
+ // Defer reply immediately to avoid 3-second timeout
334
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral })
335
+ modelLogger.log('[MODEL] Deferred reply')
336
+
337
+ const channel = interaction.channel
338
+
339
+ if (!channel) {
340
+ await interaction.editReply({
341
+ content: 'This command can only be used in a channel',
342
+ })
343
+ return
344
+ }
345
+
346
+ // Determine if we're in a thread or text channel
347
+ const isThread = [
348
+ ChannelType.PublicThread,
349
+ ChannelType.PrivateThread,
350
+ ChannelType.AnnouncementThread,
351
+ ].includes(channel.type)
352
+
353
+ let projectDirectory: string | undefined
354
+ let targetChannelId: string
355
+ let sessionId: string | undefined
356
+
357
+ if (isThread) {
358
+ const thread = channel as ThreadChannel
359
+ // Parallelize: resolve metadata and session ID at the same time
360
+ const [textChannel, threadSessionId] = await Promise.all([
361
+ resolveTextChannel(thread),
362
+ getThreadSession(thread.id),
363
+ ])
364
+ const metadata = await getKimakiMetadata(textChannel)
365
+ projectDirectory = metadata.projectDirectory
366
+ targetChannelId = textChannel?.id || channel.id
367
+ sessionId = threadSessionId
368
+ } else if (channel.type === ChannelType.GuildText) {
369
+ const textChannel = channel as TextChannel
370
+ const metadata = await getKimakiMetadata(textChannel)
371
+ projectDirectory = metadata.projectDirectory
372
+ targetChannelId = channel.id
373
+ } else {
374
+ await interaction.editReply({
375
+ content: 'This command can only be used in text channels or threads',
376
+ })
377
+ return
378
+ }
379
+
380
+ if (!projectDirectory) {
381
+ await interaction.editReply({
382
+ content: 'This channel is not configured with a project directory',
383
+ })
384
+ return
385
+ }
386
+
387
+ try {
388
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
389
+ if (getClient instanceof Error) {
390
+ await interaction.editReply({ content: getClient.message })
391
+ return
392
+ }
393
+
394
+ const effectiveAppId = appId
395
+
396
+ if (isThread && sessionId) {
397
+ await ensureSessionPreferencesSnapshot({
398
+ sessionId,
399
+ channelId: targetChannelId,
400
+ appId: effectiveAppId,
401
+ getClient,
402
+ })
403
+ }
404
+
405
+ // Parallelize: fetch providers, current model info, and variant cascade at the same time.
406
+ // getCurrentModelInfo does DB lookups first (fast) and only hits provider.list as fallback.
407
+ const [providersResponse, currentModelInfo, cascadeVariant] =
408
+ await Promise.all([
409
+ getClient().provider.list({ directory: projectDirectory }),
410
+ getCurrentModelInfo({
411
+ sessionId,
412
+ channelId: targetChannelId,
413
+ appId: effectiveAppId,
414
+ getClient,
415
+ }),
416
+ getVariantCascade({
417
+ sessionId,
418
+ channelId: targetChannelId,
419
+ appId: effectiveAppId,
420
+ }),
421
+ ])
422
+
423
+ if (!providersResponse.data) {
424
+ await interaction.editReply({
425
+ content: 'Failed to fetch providers',
426
+ })
427
+ return
428
+ }
429
+
430
+ const { all: allProviders, connected } = providersResponse.data
431
+
432
+ // Filter to only connected providers (have credentials)
433
+ const availableProviders = allProviders.filter((p) => {
434
+ return connected.includes(p.id)
435
+ })
436
+
437
+ if (availableProviders.length === 0) {
438
+ await interaction.editReply({
439
+ content:
440
+ 'No providers with credentials found. Use `/login` to connect a provider and add credentials.',
441
+ })
442
+ return
443
+ }
444
+
445
+ const currentModelText = (() => {
446
+ switch (currentModelInfo.type) {
447
+ case 'session':
448
+ return `**Current (this thread):** \`${currentModelInfo.model}\``
449
+ case 'agent':
450
+ return `**Current (agent "${currentModelInfo.agentName}"):** \`${currentModelInfo.model}\``
451
+ case 'channel':
452
+ return `**Current (channel override):** \`${currentModelInfo.model}\``
453
+ case 'global':
454
+ return `**Current (global default):** \`${currentModelInfo.model}\``
455
+ case 'opencode-config':
456
+ case 'opencode-recent':
457
+ case 'opencode-provider-default':
458
+ return `**Current (opencode default):** \`${currentModelInfo.model}\``
459
+ case 'none':
460
+ return '**Current:** none'
461
+ }
462
+ })()
463
+
464
+ const variantText = (() => {
465
+ if (currentModelInfo.type === 'none' || !cascadeVariant) {
466
+ return ''
467
+ }
468
+ return `\n**Variant:** \`${cascadeVariant}\``
469
+ })()
470
+
471
+ // Store context with a short hash key to avoid customId length limits.
472
+ const providerSelectHeader = `**Set Model Preference**\n${currentModelText}${variantText}\nSelect a provider:`
473
+ const context = {
474
+ dir: projectDirectory,
475
+ channelId: targetChannelId,
476
+ sessionId: sessionId,
477
+ isThread: isThread,
478
+ thread: isThread ? (channel as ThreadChannel) : undefined,
479
+ appId,
480
+ providerSelectHeader,
481
+ }
482
+ const contextHash = crypto.randomBytes(8).toString('hex')
483
+ setModelContext(contextHash, context)
484
+
485
+ const allProviderOptions = [...availableProviders]
486
+ .sort((a, b) => a.name.localeCompare(b.name))
487
+ .map((provider) => {
488
+ const modelCount = Object.keys(provider.models || {}).length
489
+ return {
490
+ label: provider.name.slice(0, 100),
491
+ value: provider.id,
492
+ description:
493
+ `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(
494
+ 0,
495
+ 100,
496
+ ),
497
+ }
498
+ })
499
+
500
+ const { options } = buildPaginatedOptions({
501
+ allOptions: allProviderOptions,
502
+ page: 0,
503
+ })
504
+
505
+ const selectMenu = new StringSelectMenuBuilder()
506
+ .setCustomId(`model_provider:${contextHash}`)
507
+ .setPlaceholder('Select a provider')
508
+ .addOptions(options)
509
+
510
+ const actionRow =
511
+ new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
512
+
513
+ await interaction.editReply({
514
+ content: providerSelectHeader,
515
+ components: [actionRow],
516
+ })
517
+ } catch (error) {
518
+ modelLogger.error('Error loading providers:', error)
519
+ await interaction.editReply({
520
+ content: `Failed to load providers: ${error instanceof Error ? error.message : 'Unknown error'}`,
521
+ })
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Handle the provider select menu interaction.
527
+ * Shows a second select menu with models for the chosen provider.
528
+ */
529
+ export async function handleProviderSelectMenu(
530
+ interaction: StringSelectMenuInteraction,
531
+ ): Promise<void> {
532
+ const customId = interaction.customId
533
+
534
+ if (!customId.startsWith('model_provider:')) {
535
+ return
536
+ }
537
+
538
+ // Defer update immediately to avoid timeout
539
+ await interaction.deferUpdate()
540
+
541
+ const contextHash = customId.replace('model_provider:', '')
542
+ const context = pendingModelContexts.get(contextHash)
543
+
544
+ if (!context) {
545
+ await interaction.editReply({
546
+ content: 'Selection expired. Please run /model again.',
547
+ components: [],
548
+ })
549
+ return
550
+ }
551
+
552
+ const selectedProviderId = interaction.values[0]
553
+ if (!selectedProviderId) {
554
+ await interaction.editReply({
555
+ content: 'No provider selected',
556
+ components: [],
557
+ })
558
+ return
559
+ }
560
+
561
+ // Handle pagination nav — re-render the same provider select with new page
562
+ const providerNavPage = parsePaginationValue(selectedProviderId)
563
+ if (providerNavPage !== undefined) {
564
+ context.providerPage = providerNavPage
565
+ setModelContext(contextHash, context)
566
+
567
+ const getClient = await initializeOpencodeForDirectory(context.dir)
568
+ if (getClient instanceof Error) {
569
+ await interaction.editReply({ content: getClient.message, components: [] })
570
+ return
571
+ }
572
+ const providersResponse = await getClient().provider.list({ directory: context.dir })
573
+ if (!providersResponse.data) {
574
+ await interaction.editReply({ content: 'Failed to fetch providers', components: [] })
575
+ return
576
+ }
577
+ const { all: allProviders, connected } = providersResponse.data
578
+ const availableProviders = allProviders.filter((p) => connected.includes(p.id))
579
+ const allProviderOptions = [...availableProviders]
580
+ .sort((a, b) => a.name.localeCompare(b.name))
581
+ .map((p) => {
582
+ const modelCount = Object.keys(p.models || {}).length
583
+ return {
584
+ label: p.name.slice(0, 100),
585
+ value: p.id,
586
+ description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(0, 100),
587
+ }
588
+ })
589
+ const { options } = buildPaginatedOptions({ allOptions: allProviderOptions, page: providerNavPage })
590
+ const selectMenu = new StringSelectMenuBuilder()
591
+ .setCustomId(`model_provider:${contextHash}`)
592
+ .setPlaceholder('Select a provider')
593
+ .addOptions(options)
594
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
595
+ await interaction.editReply({
596
+ content: context.providerSelectHeader || `**Set Model Preference**\nSelect a provider:`,
597
+ components: [actionRow],
598
+ })
599
+ return
600
+ }
601
+
602
+ try {
603
+ const getClient = await initializeOpencodeForDirectory(context.dir)
604
+ if (getClient instanceof Error) {
605
+ await interaction.editReply({
606
+ content: getClient.message,
607
+ components: [],
608
+ })
609
+ return
610
+ }
611
+
612
+ const providersResponse = await getClient().provider.list({
613
+ directory: context.dir,
614
+ })
615
+
616
+ if (!providersResponse.data) {
617
+ await interaction.editReply({
618
+ content: 'Failed to fetch providers',
619
+ components: [],
620
+ })
621
+ return
622
+ }
623
+
624
+ const provider = providersResponse.data.all.find(
625
+ (p) => p.id === selectedProviderId,
626
+ )
627
+
628
+ if (!provider) {
629
+ await interaction.editReply({
630
+ content: 'Provider not found',
631
+ components: [],
632
+ })
633
+ return
634
+ }
635
+
636
+ const models = Object.entries(provider.models || {})
637
+ .map(([modelId, model]) => ({
638
+ id: modelId,
639
+ name: model.name,
640
+ releaseDate: model.release_date,
641
+ }))
642
+ .sort((a, b) => a.name.localeCompare(b.name))
643
+
644
+ if (models.length === 0) {
645
+ await interaction.editReply({
646
+ content: `No models available for ${provider.name}`,
647
+ components: [],
648
+ })
649
+ return
650
+ }
651
+
652
+ // Update context with provider info and reuse the same hash
653
+ context.providerId = selectedProviderId
654
+ context.providerName = provider.name
655
+ context.modelPage = 0
656
+ setModelContext(contextHash, context)
657
+
658
+ const allModelOptions = models.map((model) => {
659
+ const dateStr = model.releaseDate
660
+ ? new Date(model.releaseDate).toLocaleDateString()
661
+ : 'Unknown date'
662
+ return {
663
+ label: model.name.slice(0, 100),
664
+ value: model.id,
665
+ description: dateStr.slice(0, 100),
666
+ }
667
+ })
668
+
669
+ const { options } = buildPaginatedOptions({
670
+ allOptions: allModelOptions,
671
+ page: 0,
672
+ })
673
+
674
+ const selectMenu = new StringSelectMenuBuilder()
675
+ .setCustomId(`model_select:${contextHash}`)
676
+ .setPlaceholder('Select a model')
677
+ .addOptions(options)
678
+
679
+ const actionRow =
680
+ new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
681
+
682
+ await interaction.editReply({
683
+ content: `**Set Model Preference**\nProvider: **${provider.name}**\nSelect a model:`,
684
+ components: [actionRow],
685
+ })
686
+ } catch (error) {
687
+ modelLogger.error('Error loading models:', error)
688
+ await interaction.editReply({
689
+ content: `Failed to load models: ${error instanceof Error ? error.message : 'Unknown error'}`,
690
+ components: [],
691
+ })
692
+ }
693
+ }
694
+
695
+ /**
696
+ * Handle the model select menu interaction.
697
+ * Stores the model preference in the database.
698
+ */
699
+ export async function handleModelSelectMenu(
700
+ interaction: StringSelectMenuInteraction,
701
+ ): Promise<void> {
702
+ const customId = interaction.customId
703
+
704
+ if (!customId.startsWith('model_select:')) {
705
+ return
706
+ }
707
+
708
+ // Defer update immediately
709
+ await interaction.deferUpdate()
710
+
711
+ const contextHash = customId.replace('model_select:', '')
712
+ const context = pendingModelContexts.get(contextHash)
713
+
714
+ if (!context || !context.providerId || !context.providerName) {
715
+ await interaction.editReply({
716
+ content: 'Selection expired. Please run /model again.',
717
+ components: [],
718
+ })
719
+ return
720
+ }
721
+
722
+ const selectedModelId = interaction.values[0]
723
+ if (!selectedModelId) {
724
+ await interaction.editReply({
725
+ content: 'No model selected',
726
+ components: [],
727
+ })
728
+ return
729
+ }
730
+
731
+ // Handle pagination nav — re-render the same model select with new page
732
+ const modelNavPage = parsePaginationValue(selectedModelId)
733
+ if (modelNavPage !== undefined) {
734
+ context.modelPage = modelNavPage
735
+ setModelContext(contextHash, context)
736
+
737
+ const getClient = await initializeOpencodeForDirectory(context.dir)
738
+ if (getClient instanceof Error) {
739
+ await interaction.editReply({ content: getClient.message, components: [] })
740
+ return
741
+ }
742
+ const providersResponse = await getClient().provider.list({ directory: context.dir })
743
+ const provider = providersResponse.data?.all.find((p) => p.id === context.providerId)
744
+ if (!provider) {
745
+ await interaction.editReply({ content: 'Provider not found', components: [] })
746
+ return
747
+ }
748
+ const allModelOptions = Object.entries(provider.models || {})
749
+ .map(([modelId, model]) => ({
750
+ label: model.name.slice(0, 100),
751
+ value: modelId,
752
+ description: (model.release_date
753
+ ? new Date(model.release_date).toLocaleDateString()
754
+ : 'Unknown date'
755
+ ).slice(0, 100),
756
+ }))
757
+ .sort((a, b) => a.label.localeCompare(b.label))
758
+ const { options } = buildPaginatedOptions({ allOptions: allModelOptions, page: modelNavPage })
759
+ const selectMenu = new StringSelectMenuBuilder()
760
+ .setCustomId(`model_select:${contextHash}`)
761
+ .setPlaceholder('Select a model')
762
+ .addOptions(options)
763
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
764
+ await interaction.editReply({
765
+ content: `**Set Model Preference**\nProvider: **${context.providerName}**\nSelect a model:`,
766
+ components: [actionRow],
767
+ })
768
+ return
769
+ }
770
+
771
+ // Build full model ID: provider_id/model_id
772
+ const fullModelId = `${context.providerId}/${selectedModelId}`
773
+
774
+ try {
775
+ context.selectedModelId = fullModelId
776
+ setModelContext(contextHash, context)
777
+
778
+ // Check if model has variants (thinking levels) - if so, show variant picker first
779
+ const getClient = await initializeOpencodeForDirectory(context.dir)
780
+ if (!(getClient instanceof Error)) {
781
+ const providersResponse = await getClient().provider.list({
782
+ directory: context.dir,
783
+ })
784
+ if (providersResponse.data) {
785
+ const variants = getThinkingValuesForModel({
786
+ providers: providersResponse.data.all,
787
+ providerId: context.providerId!,
788
+ modelId: selectedModelId,
789
+ })
790
+ if (variants.length > 0) {
791
+ context.availableVariants = variants
792
+ setModelContext(contextHash, context)
793
+
794
+ const variantOptions = [
795
+ {
796
+ label: 'None (default)',
797
+ value: '__none__',
798
+ description: 'Use the model without a specific thinking level',
799
+ },
800
+ ...variants.slice(0, 24).map((v: string) => ({
801
+ label: v.slice(0, 100),
802
+ value: v,
803
+ description: `Use ${v} thinking`.slice(0, 100),
804
+ })),
805
+ ]
806
+
807
+ const selectMenu = new StringSelectMenuBuilder()
808
+ .setCustomId(`model_variant:${contextHash}`)
809
+ .setPlaceholder('Select a thinking level')
810
+ .addOptions(variantOptions)
811
+
812
+ const actionRow =
813
+ new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
814
+ selectMenu,
815
+ )
816
+
817
+ await interaction.editReply({
818
+ content: `**Set Model Preference**\nModel: **${context.providerName}** / **${selectedModelId}**\n\`${fullModelId}\`\nSelect a thinking level:`,
819
+ components: [actionRow],
820
+ })
821
+ return
822
+ }
823
+ }
824
+ }
825
+
826
+ // No variants available - skip to scope
827
+ context.selectedVariant = null
828
+ setModelContext(contextHash, context)
829
+ await showScopeMenu({ interaction, contextHash, context })
830
+ } catch (error) {
831
+ modelLogger.error('Error saving model preference:', error)
832
+ await interaction.editReply({
833
+ content: `Failed to save model preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
834
+ components: [],
835
+ })
836
+ }
837
+ }
838
+
839
+ /**
840
+ * Handle the variant select menu interaction.
841
+ * Stores the selected variant and shows the scope menu.
842
+ */
843
+ export async function handleModelVariantSelectMenu(
844
+ interaction: StringSelectMenuInteraction,
845
+ ): Promise<void> {
846
+ const customId = interaction.customId
847
+ if (!customId.startsWith('model_variant:')) {
848
+ return
849
+ }
850
+
851
+ await interaction.deferUpdate()
852
+
853
+ const contextHash = customId.replace('model_variant:', '')
854
+ const context = pendingModelContexts.get(contextHash)
855
+
856
+ if (!context || !context.selectedModelId) {
857
+ await interaction.editReply({
858
+ content: 'Selection expired. Please run /model again.',
859
+ components: [],
860
+ })
861
+ return
862
+ }
863
+
864
+ const selectedValue = interaction.values[0]
865
+ if (!selectedValue) {
866
+ await interaction.editReply({
867
+ content: 'No variant selected',
868
+ components: [],
869
+ })
870
+ return
871
+ }
872
+
873
+ context.selectedVariant = selectedValue === '__none__' ? null : selectedValue
874
+ setModelContext(contextHash, context)
875
+
876
+ await showScopeMenu({ interaction, contextHash, context })
877
+ }
878
+
879
+ async function showScopeMenu({
880
+ interaction,
881
+ contextHash,
882
+ context,
883
+ }: {
884
+ interaction: StringSelectMenuInteraction
885
+ contextHash: string
886
+ context: NonNullable<ReturnType<typeof pendingModelContexts.get>>
887
+ }): Promise<void> {
888
+ const modelId = context.selectedModelId!
889
+ const modelDisplay = modelId.split('/')[1] || modelId
890
+ const variantSuffix = context.selectedVariant
891
+ ? ` (${context.selectedVariant})`
892
+ : ''
893
+
894
+ const scopeOptions = [
895
+ ...(context.isThread && context.sessionId
896
+ ? [
897
+ {
898
+ label: 'This session only',
899
+ value: 'session',
900
+ description: 'Override for this session only',
901
+ },
902
+ ]
903
+ : []),
904
+ {
905
+ label: 'This channel only',
906
+ value: 'channel',
907
+ description: 'Override for this channel only',
908
+ },
909
+ {
910
+ label: 'Global default',
911
+ value: 'global',
912
+ description: 'Set for this channel and as default for all others',
913
+ },
914
+ ]
915
+
916
+ const selectMenu = new StringSelectMenuBuilder()
917
+ .setCustomId(`model_scope:${contextHash}`)
918
+ .setPlaceholder('Apply to...')
919
+ .addOptions(scopeOptions)
920
+
921
+ const actionRow =
922
+ new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
923
+
924
+ await interaction.editReply({
925
+ content: `**Set Model Preference**\nModel: **${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`\nApply to:`,
926
+ components: [actionRow],
927
+ })
928
+ }
929
+
930
+ /**
931
+ * Handle the scope select menu interaction.
932
+ * Applies the model to either the channel or globally.
933
+ */
934
+ export async function handleModelScopeSelectMenu(
935
+ interaction: StringSelectMenuInteraction,
936
+ ): Promise<void> {
937
+ const customId = interaction.customId
938
+
939
+ if (!customId.startsWith('model_scope:')) {
940
+ return
941
+ }
942
+
943
+ // Defer update immediately
944
+ await interaction.deferUpdate()
945
+
946
+ const contextHash = customId.replace('model_scope:', '')
947
+ const context = pendingModelContexts.get(contextHash)
948
+
949
+ if (
950
+ !context ||
951
+ !context.providerId ||
952
+ !context.providerName ||
953
+ !context.selectedModelId
954
+ ) {
955
+ await interaction.editReply({
956
+ content: 'Selection expired. Please run /model again.',
957
+ components: [],
958
+ })
959
+ return
960
+ }
961
+
962
+ const selectedScope = interaction.values[0]
963
+ if (!selectedScope) {
964
+ await interaction.editReply({
965
+ content: 'No scope selected',
966
+ components: [],
967
+ })
968
+ return
969
+ }
970
+
971
+ const modelId = context.selectedModelId
972
+ const modelDisplay = modelId.split('/')[1] || modelId
973
+ const variant = context.selectedVariant ?? null
974
+ const variantSuffix = variant ? ` (${variant})` : ''
975
+ const agentTip =
976
+ '\n_Tip: create [agent .md files](https://github.com/remorses/kimaki/blob/main/docs/model-switching.md) in .opencode/agent/ for one-command model switching_'
977
+
978
+ try {
979
+ if (selectedScope === 'session') {
980
+ if (!context.sessionId) {
981
+ pendingModelContexts.delete(contextHash)
982
+ await interaction.editReply({
983
+ content:
984
+ 'No active session in this thread. Please run /model in a thread with a session.',
985
+ components: [],
986
+ })
987
+ return
988
+ }
989
+ await setSessionModel({ sessionId: context.sessionId, modelId, variant })
990
+ modelLogger.log(
991
+ `Set model ${modelId}${variantSuffix} for session ${context.sessionId}`,
992
+ )
993
+
994
+ let retried = false
995
+ if (context.thread) {
996
+ const runtime = getRuntime(context.thread.id)
997
+ if (runtime) {
998
+ retried = await runtime.retryLastUserPrompt()
999
+ }
1000
+ }
1001
+
1002
+ const retryNote = retried
1003
+ ? '\n_Restarting current request with new model..._'
1004
+ : ''
1005
+ await interaction.editReply({
1006
+ content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${retryNote}${agentTip}`,
1007
+ flags: MessageFlags.SuppressEmbeds,
1008
+ components: [],
1009
+ })
1010
+ } else if (selectedScope === 'global') {
1011
+ if (!context.appId) {
1012
+ pendingModelContexts.delete(contextHash)
1013
+ await interaction.editReply({
1014
+ content: 'Cannot set global model: channel is not linked to a bot',
1015
+ components: [],
1016
+ })
1017
+ return
1018
+ }
1019
+ await setGlobalModel({ appId: context.appId, modelId, variant })
1020
+ await setChannelModel({ channelId: context.channelId, modelId, variant })
1021
+ modelLogger.log(
1022
+ `Set global model ${modelId}${variantSuffix} for app ${context.appId} and channel ${context.channelId}`,
1023
+ )
1024
+
1025
+ await interaction.editReply({
1026
+ content: `Model set for this channel and as global default:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`\nAll channels will use this model (unless they have their own override).${agentTip}`,
1027
+ flags: MessageFlags.SuppressEmbeds,
1028
+ components: [],
1029
+ })
1030
+ } else {
1031
+ // channel scope
1032
+ await setChannelModel({ channelId: context.channelId, modelId, variant })
1033
+ modelLogger.log(
1034
+ `Set model ${modelId}${variantSuffix} for channel ${context.channelId}`,
1035
+ )
1036
+
1037
+ await interaction.editReply({
1038
+ content: `Model preference set for this channel:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`\nAll new sessions in this channel will use this model.${agentTip}`,
1039
+ flags: MessageFlags.SuppressEmbeds,
1040
+ components: [],
1041
+ })
1042
+ }
1043
+
1044
+ // Clean up the context from memory
1045
+ pendingModelContexts.delete(contextHash)
1046
+ } catch (error) {
1047
+ modelLogger.error('Error saving model preference:', error)
1048
+ await interaction.editReply({
1049
+ content: `Failed to save model preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
1050
+ components: [],
1051
+ })
1052
+ }
1053
+ }