@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,1173 @@
1
+ // /login command — authenticate with AI providers (OAuth or API key).
2
+ //
3
+ // Uses a unified select handler (`login_select:<hash>`) for all sequential
4
+ // select menus (provider → method → plugin prompts). The context tracks a
5
+ // `step` field so one handler drives the whole flow.
6
+ //
7
+ // CustomId patterns:
8
+ // login_select:<hash> — all select menus (provider, method, prompts)
9
+ // login_apikey:<hash> — API key modal submission
10
+ // login_text:<hash> — text prompt modal submission
11
+
12
+ import {
13
+ ChatInputCommandInteraction,
14
+ StringSelectMenuInteraction,
15
+ StringSelectMenuBuilder,
16
+ ActionRowBuilder,
17
+ ModalBuilder,
18
+ TextInputBuilder,
19
+ TextInputStyle,
20
+ ModalSubmitInteraction,
21
+ ButtonBuilder,
22
+ ButtonStyle,
23
+ type ButtonInteraction,
24
+ ChannelType,
25
+ type ThreadChannel,
26
+ type TextChannel,
27
+ MessageFlags,
28
+ } from 'discord.js'
29
+ import type { AuthHook } from '@opencode-ai/plugin'
30
+ import crypto from 'node:crypto'
31
+ import {
32
+ initializeOpencodeForDirectory,
33
+ getOpencodeServerPort,
34
+ } from '../opencode.js'
35
+ import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
36
+ import { createLogger, LogPrefix } from '../logger.js'
37
+ import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js'
38
+
39
+ const loginLogger = createLogger(LogPrefix.LOGIN)
40
+
41
+ // ── Types ───────────────────────────────────────────────────────
42
+ // Derive prompt types from the plugin package so they stay in sync.
43
+ // Strip runtime-only callback fields (validate, condition) that
44
+ // aren't present in the REST response from the opencode server.
45
+ // Add `when` rule — the server's zod schema includes it but the
46
+ // published plugin package hasn't been updated yet.
47
+
48
+ type WhenRule = { key: string; op: 'eq' | 'neq'; value: string }
49
+
50
+ // Extract prompt option type from the plugin's select prompt
51
+ type PluginMethod = AuthHook['methods'][number]
52
+ type PluginSelectPrompt = Extract<
53
+ NonNullable<PluginMethod['prompts']>[number],
54
+ { type: 'select' }
55
+ >
56
+ type PromptOption = PluginSelectPrompt['options'][number]
57
+
58
+ type AuthPromptText = {
59
+ type: 'text'
60
+ key: string
61
+ message: string
62
+ placeholder?: string
63
+ when?: WhenRule
64
+ }
65
+
66
+ type AuthPromptSelect = {
67
+ type: 'select'
68
+ key: string
69
+ message: string
70
+ options: PromptOption[]
71
+ when?: WhenRule
72
+ }
73
+
74
+ type AuthPrompt = AuthPromptText | AuthPromptSelect
75
+
76
+ type ProviderAuthMethod = {
77
+ type: 'oauth' | 'api'
78
+ label: string
79
+ prompts?: AuthPrompt[]
80
+ }
81
+
82
+ // ── Login step state machine ────────────────────────────────────
83
+ // Each step describes what the next select menu should show.
84
+ // Steps are built lazily: provider step is set by /login, method
85
+ // and prompt steps are added after the provider is selected.
86
+
87
+ type StepProvider = { type: 'provider' }
88
+ type StepMethod = { type: 'method'; methods: ProviderAuthMethod[] }
89
+ type StepPrompt = { type: 'prompt'; prompt: AuthPrompt }
90
+ type LoginStep = StepProvider | StepMethod | StepPrompt
91
+
92
+ type LoginContext = {
93
+ dir: string
94
+ channelId: string
95
+ providerId?: string
96
+ providerName?: string
97
+ methodIndex?: number
98
+ methodType?: 'oauth' | 'api'
99
+ steps: LoginStep[]
100
+ stepIndex: number
101
+ inputs: Record<string, string>
102
+ providerPage?: number
103
+ }
104
+
105
+ // ── Context store ───────────────────────────────────────────────
106
+ // Keyed by random hash to stay under Discord's 100-char customId limit.
107
+ // TTL prevents unbounded growth when users open /login and never interact.
108
+
109
+ const LOGIN_CONTEXT_TTL_MS = 10 * 60 * 1000
110
+ const pendingLoginContexts = new Map<string, LoginContext>()
111
+
112
+ function createContextHash(context: LoginContext): string {
113
+ const hash = crypto.randomBytes(8).toString('hex')
114
+ pendingLoginContexts.set(hash, context)
115
+ setTimeout(() => {
116
+ pendingLoginContexts.delete(hash)
117
+ }, LOGIN_CONTEXT_TTL_MS).unref()
118
+ return hash
119
+ }
120
+
121
+ // ── Provider popularity order ───────────────────────────────────
122
+ // Discord select menus cap at 25 options, so we show popular ones first.
123
+ // IDs sourced from opencode's provider.list() API (scripts/list-providers.ts).
124
+ const PROVIDER_POPULARITY_ORDER: string[] = [
125
+ 'anthropic',
126
+ 'openai',
127
+ 'google',
128
+ 'github-copilot',
129
+ 'xai',
130
+ 'groq',
131
+ 'deepseek',
132
+ 'mistral',
133
+ 'openrouter',
134
+ 'fireworks-ai',
135
+ 'togetherai',
136
+ 'amazon-bedrock',
137
+ 'azure',
138
+ 'google-vertex',
139
+ 'google-vertex-anthropic',
140
+ 'cohere',
141
+ 'cerebras',
142
+ 'perplexity',
143
+ 'cloudflare-workers-ai',
144
+ 'novita-ai',
145
+ 'huggingface',
146
+ 'deepinfra',
147
+ 'github-models',
148
+ 'lmstudio',
149
+ 'llama',
150
+ ]
151
+
152
+ // ── Helpers ─────────────────────────────────────────────────────
153
+
154
+ function extractErrorMessage({
155
+ error,
156
+ fallback,
157
+ }: {
158
+ error: unknown
159
+ fallback: string
160
+ }): string {
161
+ if (!error || typeof error !== 'object') {
162
+ return fallback
163
+ }
164
+ const parsed = error as { message?: string; data?: { message?: string } }
165
+ return parsed.data?.message || parsed.message || fallback
166
+ }
167
+
168
+ function shouldShowPrompt(
169
+ prompt: AuthPrompt,
170
+ inputs: Record<string, string>,
171
+ ): boolean {
172
+ if (!prompt.when) {
173
+ return true
174
+ }
175
+ const value = inputs[prompt.when.key]
176
+ if (prompt.when.op === 'eq') {
177
+ return value === prompt.when.value
178
+ }
179
+ if (prompt.when.op === 'neq') {
180
+ return value !== prompt.when.value
181
+ }
182
+ return true
183
+ }
184
+
185
+ function buildSelectMenu({
186
+ customId,
187
+ placeholder,
188
+ options,
189
+ }: {
190
+ customId: string
191
+ placeholder: string
192
+ options: Array<{ label: string; value: string; description?: string }>
193
+ }): ActionRowBuilder<StringSelectMenuBuilder> {
194
+ const menu = new StringSelectMenuBuilder()
195
+ .setCustomId(customId)
196
+ .setPlaceholder(placeholder)
197
+ .addOptions(options)
198
+ return new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(menu)
199
+ }
200
+
201
+ // ── /login command ──────────────────────────────────────────────
202
+
203
+ export async function handleLoginCommand({
204
+ interaction,
205
+ }: {
206
+ interaction: ChatInputCommandInteraction
207
+ appId: string
208
+ }): Promise<void> {
209
+ loginLogger.log('[LOGIN] handleLoginCommand called')
210
+
211
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral })
212
+
213
+ const channel = interaction.channel
214
+ if (!channel) {
215
+ await interaction.editReply({
216
+ content: 'This command can only be used in a channel',
217
+ })
218
+ return
219
+ }
220
+
221
+ const isThread = [
222
+ ChannelType.PublicThread,
223
+ ChannelType.PrivateThread,
224
+ ChannelType.AnnouncementThread,
225
+ ].includes(channel.type)
226
+
227
+ let projectDirectory: string | undefined
228
+ let targetChannelId: string
229
+
230
+ if (isThread) {
231
+ const thread = channel as ThreadChannel
232
+ const textChannel = await resolveTextChannel(thread)
233
+ const metadata = await getKimakiMetadata(textChannel)
234
+ projectDirectory = metadata.projectDirectory
235
+ targetChannelId = textChannel?.id || channel.id
236
+ } else if (channel.type === ChannelType.GuildText) {
237
+ const textChannel = channel as TextChannel
238
+ const metadata = await getKimakiMetadata(textChannel)
239
+ projectDirectory = metadata.projectDirectory
240
+ targetChannelId = channel.id
241
+ } else {
242
+ await interaction.editReply({
243
+ content: 'This command can only be used in text channels or threads',
244
+ })
245
+ return
246
+ }
247
+
248
+ if (!projectDirectory) {
249
+ await interaction.editReply({
250
+ content: 'This channel is not configured with a project directory',
251
+ })
252
+ return
253
+ }
254
+
255
+ try {
256
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
257
+ if (getClient instanceof Error) {
258
+ await interaction.editReply({ content: getClient.message })
259
+ return
260
+ }
261
+
262
+ const providersResponse = await getClient().provider.list({
263
+ directory: projectDirectory,
264
+ })
265
+
266
+ if (!providersResponse.data) {
267
+ await interaction.editReply({ content: 'Failed to fetch providers' })
268
+ return
269
+ }
270
+
271
+ const { all: allProviders, connected } = providersResponse.data
272
+
273
+ if (allProviders.length === 0) {
274
+ await interaction.editReply({ content: 'No providers available.' })
275
+ return
276
+ }
277
+
278
+ const allProviderOptions = [...allProviders]
279
+ .sort((a, b) => {
280
+ const rankA = PROVIDER_POPULARITY_ORDER.indexOf(a.id)
281
+ const rankB = PROVIDER_POPULARITY_ORDER.indexOf(b.id)
282
+ const posA = rankA === -1 ? Infinity : rankA
283
+ const posB = rankB === -1 ? Infinity : rankB
284
+ if (posA !== posB) {
285
+ return posA - posB
286
+ }
287
+ return a.name.localeCompare(b.name)
288
+ })
289
+ .map((provider) => {
290
+ const isConnected = connected.includes(provider.id)
291
+ return {
292
+ label: `${provider.name}${isConnected ? ' ✓' : ''}`.slice(0, 100),
293
+ value: provider.id,
294
+ description: isConnected
295
+ ? 'Connected - select to re-authenticate'
296
+ : 'Not connected',
297
+ }
298
+ })
299
+
300
+ const { options } = buildPaginatedOptions({
301
+ allOptions: allProviderOptions,
302
+ page: 0,
303
+ })
304
+
305
+ const context: LoginContext = {
306
+ dir: projectDirectory,
307
+ channelId: targetChannelId,
308
+ steps: [{ type: 'provider' }],
309
+ stepIndex: 0,
310
+ inputs: {},
311
+ }
312
+ const hash = createContextHash(context)
313
+
314
+ await interaction.editReply({
315
+ content: '**Authenticate with Provider**\nSelect a provider:',
316
+ components: [
317
+ buildSelectMenu({
318
+ customId: `login_select:${hash}`,
319
+ placeholder: 'Select a provider to authenticate',
320
+ options,
321
+ }),
322
+ ],
323
+ })
324
+ } catch (error) {
325
+ loginLogger.error('Error loading providers:', error)
326
+ await interaction.editReply({
327
+ content: `Failed to load providers: ${error instanceof Error ? error.message : 'Unknown error'}`,
328
+ })
329
+ }
330
+ }
331
+
332
+ // ── Unified select handler ──────────────────────────────────────
333
+ // Handles all select menu interactions for the login flow.
334
+ // Reads the current step from context, processes the answer,
335
+ // then either shows the next step or proceeds to authorize/API key.
336
+
337
+ export async function handleLoginSelect(
338
+ interaction: StringSelectMenuInteraction,
339
+ ): Promise<void> {
340
+ if (!interaction.customId.startsWith('login_select:')) {
341
+ return
342
+ }
343
+
344
+ const hash = interaction.customId.replace('login_select:', '')
345
+ const ctx = pendingLoginContexts.get(hash)
346
+
347
+ if (!ctx) {
348
+ await interaction.deferUpdate()
349
+ await interaction.editReply({
350
+ content: 'Selection expired. Please run /login again.',
351
+ components: [],
352
+ })
353
+ return
354
+ }
355
+
356
+ const value = interaction.values[0]
357
+ if (!value) {
358
+ await interaction.deferUpdate()
359
+ await interaction.editReply({
360
+ content: 'No option selected.',
361
+ components: [],
362
+ })
363
+ return
364
+ }
365
+
366
+ const step = ctx.steps[ctx.stepIndex]
367
+ if (!step) {
368
+ await interaction.deferUpdate()
369
+ await interaction.editReply({
370
+ content: 'Invalid state. Please run /login again.',
371
+ components: [],
372
+ })
373
+ return
374
+ }
375
+
376
+ try {
377
+ if (step.type === 'provider') {
378
+ await handleProviderStep(interaction, ctx, hash, value)
379
+ } else if (step.type === 'method') {
380
+ await handleMethodStep(interaction, ctx, hash, value, step)
381
+ } else if (step.type === 'prompt') {
382
+ await handlePromptStep(interaction, ctx, hash, value, step)
383
+ }
384
+ } catch (error) {
385
+ loginLogger.error('Error in login select:', error)
386
+ if (!interaction.deferred && !interaction.replied) {
387
+ await interaction.deferUpdate()
388
+ }
389
+ await interaction.editReply({
390
+ content: `Login error: ${error instanceof Error ? error.message : 'Unknown error'}`,
391
+ components: [],
392
+ })
393
+ }
394
+ }
395
+
396
+ // ── Step handlers ───────────────────────────────────────────────
397
+
398
+ async function handleProviderStep(
399
+ interaction: StringSelectMenuInteraction,
400
+ ctx: LoginContext,
401
+ hash: string,
402
+ providerId: string,
403
+ ): Promise<void> {
404
+ // Handle pagination nav — re-render the same provider select with new page
405
+ const navPage = parsePaginationValue(providerId)
406
+ if (navPage !== undefined) {
407
+ await interaction.deferUpdate()
408
+ ctx.providerPage = navPage
409
+
410
+ const getClient = await initializeOpencodeForDirectory(ctx.dir)
411
+ if (getClient instanceof Error) {
412
+ await interaction.editReply({ content: getClient.message, components: [] })
413
+ return
414
+ }
415
+ const providersResponse = await getClient().provider.list({ directory: ctx.dir })
416
+ if (!providersResponse.data) {
417
+ await interaction.editReply({ content: 'Failed to fetch providers', components: [] })
418
+ return
419
+ }
420
+ const { all: allProviders, connected } = providersResponse.data
421
+ const allProviderOptions = [...allProviders]
422
+ .sort((a, b) => {
423
+ const rankA = PROVIDER_POPULARITY_ORDER.indexOf(a.id)
424
+ const rankB = PROVIDER_POPULARITY_ORDER.indexOf(b.id)
425
+ const posA = rankA === -1 ? Infinity : rankA
426
+ const posB = rankB === -1 ? Infinity : rankB
427
+ if (posA !== posB) {
428
+ return posA - posB
429
+ }
430
+ return a.name.localeCompare(b.name)
431
+ })
432
+ .map((p) => {
433
+ const isConnected = connected.includes(p.id)
434
+ return {
435
+ label: `${p.name}${isConnected ? ' ✓' : ''}`.slice(0, 100),
436
+ value: p.id,
437
+ description: isConnected ? 'Connected - select to re-authenticate' : 'Not connected',
438
+ }
439
+ })
440
+ const { options } = buildPaginatedOptions({ allOptions: allProviderOptions, page: navPage })
441
+ await interaction.editReply({
442
+ content: '**Authenticate with Provider**\nSelect a provider:',
443
+ components: [
444
+ buildSelectMenu({
445
+ customId: `login_select:${hash}`,
446
+ placeholder: 'Select a provider to authenticate',
447
+ options,
448
+ }),
449
+ ],
450
+ })
451
+ return
452
+ }
453
+
454
+ const getClient = await initializeOpencodeForDirectory(ctx.dir)
455
+ if (getClient instanceof Error) {
456
+ await interaction.deferUpdate()
457
+ await interaction.editReply({ content: getClient.message, components: [] })
458
+ return
459
+ }
460
+
461
+ const providersResponse = await getClient().provider.list({
462
+ directory: ctx.dir,
463
+ })
464
+ const provider = providersResponse.data?.all.find(
465
+ (p) => p.id === providerId,
466
+ )
467
+ const providerName = provider?.name || providerId
468
+
469
+ const authResponse = await getClient().provider.auth({ directory: ctx.dir })
470
+ if (!authResponse.data) {
471
+ await interaction.deferUpdate()
472
+ await interaction.editReply({
473
+ content: 'Failed to fetch authentication methods',
474
+ components: [],
475
+ })
476
+ return
477
+ }
478
+
479
+ // The server returns prompts in the auth response when the opencode
480
+ // version supports it (dev branch, not yet released as of v1.2.27).
481
+ // Once released, plugin-defined prompts will be collected and passed
482
+ // as inputs to the authorize call automatically.
483
+ const methods: ProviderAuthMethod[] = authResponse.data[providerId] || [
484
+ { type: 'api', label: 'API Key' },
485
+ ]
486
+
487
+ if (methods.length === 0) {
488
+ await interaction.deferUpdate()
489
+ await interaction.editReply({
490
+ content: `No authentication methods available for ${providerName}`,
491
+ components: [],
492
+ })
493
+ return
494
+ }
495
+
496
+ ctx.providerId = providerId
497
+ ctx.providerName = providerName
498
+
499
+ if (methods.length === 1) {
500
+ // Single method — skip method select, go straight to prompts or action
501
+ const method = methods[0]!
502
+ ctx.methodIndex = 0
503
+ ctx.methodType = method.type
504
+
505
+ const promptSteps = buildPromptSteps(method)
506
+ if (promptSteps.length > 0) {
507
+ // Has prompts — defer and show first prompt
508
+ ctx.steps = promptSteps
509
+ ctx.stepIndex = 0
510
+ await interaction.deferUpdate()
511
+ await showNextStep(interaction, ctx, hash)
512
+ } else if (method.type === 'api') {
513
+ // API key with no prompts — show modal directly (don't defer)
514
+ await showApiKeyModal(interaction, hash, providerName)
515
+ } else {
516
+ // OAuth with no prompts — defer and authorize
517
+ await interaction.deferUpdate()
518
+ await startOAuthFlow(interaction, ctx, hash)
519
+ }
520
+ return
521
+ }
522
+
523
+ // Multiple methods — show method select
524
+ ctx.steps = [
525
+ { type: 'method', methods },
526
+ ]
527
+ ctx.stepIndex = 0
528
+ await interaction.deferUpdate()
529
+ await showNextStep(interaction, ctx, hash)
530
+ }
531
+
532
+ async function handleMethodStep(
533
+ interaction: StringSelectMenuInteraction,
534
+ ctx: LoginContext,
535
+ hash: string,
536
+ value: string,
537
+ step: StepMethod,
538
+ ): Promise<void> {
539
+ const methodIndex = parseInt(value, 10)
540
+ const method = step.methods[methodIndex]
541
+ if (!method) {
542
+ await interaction.deferUpdate()
543
+ await interaction.editReply({
544
+ content: 'Invalid method selected.',
545
+ components: [],
546
+ })
547
+ return
548
+ }
549
+
550
+ ctx.methodIndex = methodIndex
551
+ ctx.methodType = method.type
552
+
553
+ const promptSteps = buildPromptSteps(method)
554
+ if (promptSteps.length > 0) {
555
+ // Replace remaining steps with prompt steps
556
+ ctx.steps = promptSteps
557
+ ctx.stepIndex = 0
558
+ await interaction.deferUpdate()
559
+ await showNextStep(interaction, ctx, hash)
560
+ } else if (method.type === 'api') {
561
+ // API key with no prompts — show modal directly (don't defer)
562
+ await showApiKeyModal(interaction, hash, ctx.providerName || '')
563
+ } else {
564
+ // OAuth with no prompts
565
+ await interaction.deferUpdate()
566
+ await startOAuthFlow(interaction, ctx, hash)
567
+ }
568
+ }
569
+
570
+ async function handlePromptStep(
571
+ interaction: StringSelectMenuInteraction,
572
+ ctx: LoginContext,
573
+ hash: string,
574
+ value: string,
575
+ step: StepPrompt,
576
+ ): Promise<void> {
577
+ // Store the answer
578
+ ctx.inputs[step.prompt.key] = value
579
+ ctx.stepIndex++
580
+
581
+ // Find the next prompt step that passes its `when` condition
582
+ await interaction.deferUpdate()
583
+ await showNextStep(interaction, ctx, hash)
584
+ }
585
+
586
+ // ── Step rendering ──────────────────────────────────────────────
587
+ // Advances through steps, skipping prompts whose `when` condition
588
+ // fails, until it finds one to show or reaches the end.
589
+
590
+ async function showNextStep(
591
+ interaction: StringSelectMenuInteraction | ModalSubmitInteraction,
592
+ ctx: LoginContext,
593
+ hash: string,
594
+ ): Promise<void> {
595
+ // Skip prompts whose `when` condition doesn't match
596
+ while (ctx.stepIndex < ctx.steps.length) {
597
+ const step = ctx.steps[ctx.stepIndex]!
598
+ if (step.type === 'prompt' && !shouldShowPrompt(step.prompt, ctx.inputs)) {
599
+ ctx.stepIndex++
600
+ continue
601
+ }
602
+ break
603
+ }
604
+
605
+ if (ctx.stepIndex >= ctx.steps.length) {
606
+ // All steps done — proceed to action
607
+ if (ctx.methodType === 'api') {
608
+ // We're deferred, so show a button that opens the API key modal
609
+ const button = new ButtonBuilder()
610
+ .setCustomId(`login_apikey_btn:${hash}`)
611
+ .setLabel('Enter API Key')
612
+ .setStyle(ButtonStyle.Primary)
613
+ await interaction.editReply({
614
+ content: `**Authenticate with ${ctx.providerName}**\nClick to enter your API key.`,
615
+ components: [
616
+ new ActionRowBuilder<ButtonBuilder>().addComponents(button),
617
+ ],
618
+ })
619
+ } else {
620
+ await startOAuthFlow(interaction, ctx, hash)
621
+ }
622
+ return
623
+ }
624
+
625
+ const step = ctx.steps[ctx.stepIndex]!
626
+ pendingLoginContexts.set(hash, ctx)
627
+
628
+ if (step.type === 'method') {
629
+ const options = step.methods.slice(0, 25).map((method, index) => ({
630
+ label: method.label.slice(0, 100),
631
+ value: String(index),
632
+ description:
633
+ method.type === 'oauth'
634
+ ? 'OAuth authentication'
635
+ : 'Enter API key manually',
636
+ }))
637
+
638
+ await interaction.editReply({
639
+ content: `**Authenticate with ${ctx.providerName}**\nSelect authentication method:`,
640
+ components: [
641
+ buildSelectMenu({
642
+ customId: `login_select:${hash}`,
643
+ placeholder: 'Select authentication method',
644
+ options,
645
+ }),
646
+ ],
647
+ })
648
+ return
649
+ }
650
+
651
+ if (step.type === 'prompt') {
652
+ const prompt = step.prompt
653
+ if (prompt.type === 'select') {
654
+ const options = prompt.options.slice(0, 25).map((opt) => ({
655
+ label: opt.label.slice(0, 100),
656
+ value: opt.value,
657
+ description: opt.hint?.slice(0, 100),
658
+ }))
659
+
660
+ await interaction.editReply({
661
+ content: `**Authenticate with ${ctx.providerName}**\n${prompt.message}`,
662
+ components: [
663
+ buildSelectMenu({
664
+ customId: `login_select:${hash}`,
665
+ placeholder: prompt.message.slice(0, 150),
666
+ options,
667
+ }),
668
+ ],
669
+ })
670
+ return
671
+ }
672
+
673
+ if (prompt.type === 'text') {
674
+ // Text prompts need a modal, but we're deferred. Show a button.
675
+ const button = new ButtonBuilder()
676
+ .setCustomId(`login_text_btn:${hash}`)
677
+ .setLabel(prompt.message.slice(0, 80))
678
+ .setStyle(ButtonStyle.Primary)
679
+
680
+ await interaction.editReply({
681
+ content: `**Authenticate with ${ctx.providerName}**\n${prompt.message}`,
682
+ components: [
683
+ new ActionRowBuilder<ButtonBuilder>().addComponents(button),
684
+ ],
685
+ })
686
+ return
687
+ }
688
+ }
689
+ }
690
+
691
+ function buildPromptSteps(method: ProviderAuthMethod): StepPrompt[] {
692
+ return (method.prompts || []).map((prompt) => ({
693
+ type: 'prompt' as const,
694
+ prompt,
695
+ }))
696
+ }
697
+
698
+ // ── Text prompt button + modal ──────────────────────────────────
699
+ // When a text prompt needs to be shown but we're in a deferred state,
700
+ // we show a button. Clicking it opens a modal for text input.
701
+
702
+ export async function handleLoginTextButton(
703
+ interaction: ButtonInteraction,
704
+ ): Promise<void> {
705
+ if (!interaction.customId.startsWith('login_text_btn:')) {
706
+ return
707
+ }
708
+
709
+ const hash = interaction.customId.replace('login_text_btn:', '')
710
+ const ctx = pendingLoginContexts.get(hash)
711
+
712
+ if (!ctx) {
713
+ await interaction.reply({
714
+ content: 'Selection expired. Please run /login again.',
715
+ flags: MessageFlags.Ephemeral,
716
+ })
717
+ return
718
+ }
719
+
720
+ const step = ctx.steps[ctx.stepIndex]
721
+ if (!step || step.type !== 'prompt' || step.prompt.type !== 'text') {
722
+ await interaction.reply({
723
+ content: 'Invalid state. Please run /login again.',
724
+ flags: MessageFlags.Ephemeral,
725
+ })
726
+ return
727
+ }
728
+
729
+ const modal = new ModalBuilder()
730
+ .setCustomId(`login_text:${hash}`)
731
+ .setTitle(`${ctx.providerName || 'Provider'} Login`.slice(0, 45))
732
+
733
+ const textInput = new TextInputBuilder()
734
+ .setCustomId('prompt_value')
735
+ .setLabel(step.prompt.message.slice(0, 45))
736
+ .setPlaceholder(
737
+ step.prompt.type === 'text' ? (step.prompt.placeholder || '') : '',
738
+ )
739
+ .setStyle(TextInputStyle.Short)
740
+ .setRequired(true)
741
+
742
+ modal.addComponents(
743
+ new ActionRowBuilder<TextInputBuilder>().addComponents(textInput),
744
+ )
745
+ await interaction.showModal(modal)
746
+ }
747
+
748
+ export async function handleLoginTextModalSubmit(
749
+ interaction: ModalSubmitInteraction,
750
+ ): Promise<void> {
751
+ if (!interaction.customId.startsWith('login_text:')) {
752
+ return
753
+ }
754
+
755
+ await interaction.deferUpdate()
756
+
757
+ const hash = interaction.customId.replace('login_text:', '')
758
+ const ctx = pendingLoginContexts.get(hash)
759
+
760
+ if (!ctx) {
761
+ await interaction.editReply({
762
+ content: 'Selection expired. Please run /login again.',
763
+ components: [],
764
+ })
765
+ return
766
+ }
767
+
768
+ const step = ctx.steps[ctx.stepIndex]
769
+ if (!step || step.type !== 'prompt' || step.prompt.type !== 'text') {
770
+ await interaction.editReply({
771
+ content: 'Invalid state. Please run /login again.',
772
+ components: [],
773
+ })
774
+ return
775
+ }
776
+
777
+ const value = interaction.fields.getTextInputValue('prompt_value')
778
+ if (!value?.trim()) {
779
+ await interaction.editReply({
780
+ content: 'A value is required.',
781
+ components: [],
782
+ })
783
+ return
784
+ }
785
+
786
+ ctx.inputs[step.prompt.key] = value.trim()
787
+ ctx.stepIndex++
788
+ await showNextStep(interaction, ctx, hash)
789
+ }
790
+
791
+ // ── API key button + modal ──────────────────────────────────────
792
+ // When we're deferred and need an API key modal, show a button first.
793
+
794
+ export async function handleLoginApiKeyButton(
795
+ interaction: ButtonInteraction,
796
+ ): Promise<void> {
797
+ if (!interaction.customId.startsWith('login_apikey_btn:')) {
798
+ return
799
+ }
800
+
801
+ const hash = interaction.customId.replace('login_apikey_btn:', '')
802
+ const ctx = pendingLoginContexts.get(hash)
803
+
804
+ if (!ctx || !ctx.providerName) {
805
+ await interaction.reply({
806
+ content: 'Selection expired. Please run /login again.',
807
+ flags: MessageFlags.Ephemeral,
808
+ })
809
+ return
810
+ }
811
+
812
+ await showApiKeyModal(interaction, hash, ctx.providerName)
813
+ }
814
+
815
+ async function showApiKeyModal(
816
+ interaction: StringSelectMenuInteraction | ButtonInteraction,
817
+ hash: string,
818
+ providerName: string,
819
+ ): Promise<void> {
820
+ const modal = new ModalBuilder()
821
+ .setCustomId(`login_apikey:${hash}`)
822
+ .setTitle(`${providerName} API Key`.slice(0, 45))
823
+
824
+ const apiKeyInput = new TextInputBuilder()
825
+ .setCustomId('apikey')
826
+ .setLabel('API Key')
827
+ .setPlaceholder('sk-...')
828
+ .setStyle(TextInputStyle.Short)
829
+ .setRequired(true)
830
+
831
+ modal.addComponents(
832
+ new ActionRowBuilder<TextInputBuilder>().addComponents(apiKeyInput),
833
+ )
834
+ await interaction.showModal(modal)
835
+ }
836
+
837
+ // ── OAuth code submission (code mode) ───────────────────────────
838
+ // When the OAuth flow returns method="code", the user completes login
839
+ // in a browser (possibly on a different machine) and pastes the final
840
+ // callback URL or authorization code here.
841
+
842
+ export async function handleOAuthCodeButton(
843
+ interaction: ButtonInteraction,
844
+ ): Promise<void> {
845
+ if (!interaction.customId.startsWith('login_oauth_code_btn:')) {
846
+ return
847
+ }
848
+
849
+ const hash = interaction.customId.replace('login_oauth_code_btn:', '')
850
+ const ctx = pendingLoginContexts.get(hash)
851
+
852
+ if (!ctx || !ctx.providerId || !ctx.providerName) {
853
+ await interaction.reply({
854
+ content: 'Selection expired. Please run /login again.',
855
+ flags: MessageFlags.Ephemeral,
856
+ })
857
+ return
858
+ }
859
+
860
+ const modal = new ModalBuilder()
861
+ .setCustomId(`login_oauth_code:${hash}`)
862
+ .setTitle(`${ctx.providerName} Authorization`.slice(0, 45))
863
+
864
+ const codeInput = new TextInputBuilder()
865
+ .setCustomId('oauth_code')
866
+ .setLabel('Authorization code or callback URL')
867
+ .setPlaceholder('Paste the code or full callback URL')
868
+ .setStyle(TextInputStyle.Paragraph)
869
+ .setRequired(true)
870
+
871
+ modal.addComponents(
872
+ new ActionRowBuilder<TextInputBuilder>().addComponents(codeInput),
873
+ )
874
+ await interaction.showModal(modal)
875
+ }
876
+
877
+ export async function handleOAuthCodeModalSubmit(
878
+ interaction: ModalSubmitInteraction,
879
+ ): Promise<void> {
880
+ if (!interaction.customId.startsWith('login_oauth_code:')) {
881
+ return
882
+ }
883
+
884
+ await interaction.deferUpdate()
885
+
886
+ const hash = interaction.customId.replace('login_oauth_code:', '')
887
+ const ctx = pendingLoginContexts.get(hash)
888
+
889
+ if (!ctx || !ctx.providerId || !ctx.providerName || ctx.methodIndex === undefined) {
890
+ await interaction.editReply({
891
+ content: 'Session expired. Please run /login again.',
892
+ components: [],
893
+ })
894
+ return
895
+ }
896
+
897
+ const code = interaction.fields.getTextInputValue('oauth_code')?.trim()
898
+ if (!code) {
899
+ await interaction.editReply({
900
+ content: 'Authorization code is required.',
901
+ components: [],
902
+ })
903
+ return
904
+ }
905
+
906
+ try {
907
+ const getClient = await initializeOpencodeForDirectory(ctx.dir)
908
+ if (getClient instanceof Error) {
909
+ await interaction.editReply({
910
+ content: getClient.message,
911
+ components: [],
912
+ })
913
+ return
914
+ }
915
+
916
+ await interaction.editReply({
917
+ content: `**Authenticating with ${ctx.providerName}**\nVerifying authorization...`,
918
+ components: [],
919
+ })
920
+
921
+ const callbackResponse = await getClient().provider.oauth.callback({
922
+ providerID: ctx.providerId,
923
+ method: ctx.methodIndex,
924
+ code,
925
+ directory: ctx.dir,
926
+ })
927
+
928
+ if (callbackResponse.error) {
929
+ pendingLoginContexts.delete(hash)
930
+ await interaction.editReply({
931
+ content: `**Authentication Failed**\n${extractErrorMessage({ error: callbackResponse.error, fallback: 'Authorization code was invalid or expired' })}`,
932
+ components: [],
933
+ })
934
+ return
935
+ }
936
+
937
+ await getClient().instance.dispose({ directory: ctx.dir })
938
+ pendingLoginContexts.delete(hash)
939
+
940
+ await interaction.editReply({
941
+ content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
942
+ components: [],
943
+ })
944
+ } catch (error) {
945
+ loginLogger.error('OAuth code submission error:', error)
946
+ pendingLoginContexts.delete(hash)
947
+ await interaction.editReply({
948
+ content: `**Authentication Failed**\n${error instanceof Error ? error.message : 'Unknown error'}`,
949
+ components: [],
950
+ })
951
+ }
952
+ }
953
+
954
+ export async function handleApiKeyModalSubmit(
955
+ interaction: ModalSubmitInteraction,
956
+ ): Promise<void> {
957
+ if (!interaction.customId.startsWith('login_apikey:')) {
958
+ return
959
+ }
960
+
961
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral })
962
+
963
+ const hash = interaction.customId.replace('login_apikey:', '')
964
+ const ctx = pendingLoginContexts.get(hash)
965
+
966
+ if (!ctx || !ctx.providerId || !ctx.providerName) {
967
+ await interaction.editReply({
968
+ content: 'Session expired. Please run /login again.',
969
+ })
970
+ return
971
+ }
972
+
973
+ const apiKey = interaction.fields.getTextInputValue('apikey')
974
+
975
+ if (!apiKey?.trim()) {
976
+ await interaction.editReply({ content: 'API key is required.' })
977
+ return
978
+ }
979
+
980
+ try {
981
+ const getClient = await initializeOpencodeForDirectory(ctx.dir)
982
+ if (getClient instanceof Error) {
983
+ await interaction.editReply({ content: getClient.message })
984
+ return
985
+ }
986
+
987
+ await getClient().auth.set({
988
+ providerID: ctx.providerId,
989
+ auth: { type: 'api', key: apiKey.trim() },
990
+ })
991
+
992
+ // Dispose to refresh provider state so new credentials are recognized
993
+ await getClient().instance.dispose({ directory: ctx.dir })
994
+
995
+ await interaction.editReply({
996
+ content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
997
+ })
998
+
999
+ pendingLoginContexts.delete(hash)
1000
+ } catch (error) {
1001
+ loginLogger.error('API key save error:', error)
1002
+ await interaction.editReply({
1003
+ content: `**Failed to save API key**\n${error instanceof Error ? error.message : 'Unknown error'}`,
1004
+ })
1005
+ }
1006
+ }
1007
+
1008
+ // ── OAuth flow ──────────────────────────────────────────────────
1009
+
1010
+ async function startOAuthFlow(
1011
+ interaction: StringSelectMenuInteraction | ModalSubmitInteraction,
1012
+ ctx: LoginContext,
1013
+ hash: string,
1014
+ ): Promise<void> {
1015
+ if (!ctx.providerId || ctx.methodIndex === undefined) {
1016
+ await interaction.editReply({
1017
+ content: 'Invalid context for OAuth flow',
1018
+ components: [],
1019
+ })
1020
+ return
1021
+ }
1022
+
1023
+ try {
1024
+ const getClient = await initializeOpencodeForDirectory(ctx.dir)
1025
+ if (getClient instanceof Error) {
1026
+ await interaction.editReply({
1027
+ content: getClient.message,
1028
+ components: [],
1029
+ })
1030
+ return
1031
+ }
1032
+
1033
+ await interaction.editReply({
1034
+ content: `**Authenticating with ${ctx.providerName}**\nStarting authorization...`,
1035
+ components: [],
1036
+ })
1037
+
1038
+ // Direct fetch to the server because the SDK's buildClientParams drops
1039
+ // unknown keys — `inputs` would be silently stripped. The server accepts
1040
+ // `inputs` in the body (see opencode server/routes/provider.ts).
1041
+ const port = getOpencodeServerPort()
1042
+ if (!port) {
1043
+ await interaction.editReply({
1044
+ content: 'OpenCode server is not running. Please try again.',
1045
+ components: [],
1046
+ })
1047
+ return
1048
+ }
1049
+
1050
+ const hasInputs = Object.keys(ctx.inputs).length > 0
1051
+ const authorizeUrl = new URL(
1052
+ `/provider/${encodeURIComponent(ctx.providerId)}/oauth/authorize`,
1053
+ `http://127.0.0.1:${port}`,
1054
+ )
1055
+ authorizeUrl.searchParams.set('directory', ctx.dir)
1056
+
1057
+ // Include basic auth if OPENCODE_SERVER_PASSWORD is set,
1058
+ // matching the opencode server's optional basicAuth middleware.
1059
+ const fetchHeaders: Record<string, string> = {
1060
+ 'Content-Type': 'application/json',
1061
+ 'x-opencode-directory': ctx.dir,
1062
+ }
1063
+ const serverPassword = process.env.OPENCODE_SERVER_PASSWORD
1064
+ if (serverPassword) {
1065
+ const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode'
1066
+ fetchHeaders['Authorization'] =
1067
+ `Basic ${Buffer.from(`${username}:${serverPassword}`).toString('base64')}`
1068
+ }
1069
+
1070
+ const authorizeRes = await fetch(authorizeUrl, {
1071
+ method: 'POST',
1072
+ headers: fetchHeaders,
1073
+ body: JSON.stringify({
1074
+ method: ctx.methodIndex,
1075
+ ...(hasInputs ? { inputs: ctx.inputs } : {}),
1076
+ }),
1077
+ })
1078
+
1079
+ if (!authorizeRes.ok) {
1080
+ const errorText = await authorizeRes.text().catch(() => '')
1081
+ let errorMessage = 'Unknown error'
1082
+ try {
1083
+ const parsed = JSON.parse(errorText) as {
1084
+ message?: string
1085
+ data?: { message?: string }
1086
+ }
1087
+ errorMessage = parsed?.data?.message || parsed?.message || errorMessage
1088
+ } catch {
1089
+ errorMessage = errorText || errorMessage
1090
+ }
1091
+ await interaction.editReply({
1092
+ content: `Failed to start authorization: ${errorMessage}`,
1093
+ components: [],
1094
+ })
1095
+ return
1096
+ }
1097
+
1098
+ const { url, method, instructions } = (await authorizeRes.json()) as {
1099
+ url: string
1100
+ method: 'auto' | 'code'
1101
+ instructions: string
1102
+ }
1103
+
1104
+ let message = `**Authenticating with ${ctx.providerName}**\n\n`
1105
+ message += `Open this URL to authorize:\n${url}\n\n`
1106
+
1107
+ if (instructions) {
1108
+ // Match "code: ABC-123" or "code: WXYZ1234" but not natural language
1109
+ // like "code will". Require a colon separator and uppercase alphanum code.
1110
+ const codeMatch = instructions.match(/code:\s*([A-Z0-9][A-Z0-9-]+)/)
1111
+ if (codeMatch) {
1112
+ message += `**Code:** \`${codeMatch[1]}\`\n\n`
1113
+ } else {
1114
+ message += `${instructions}\n\n`
1115
+ }
1116
+ }
1117
+
1118
+ if (method === 'auto') {
1119
+ message += '_Waiting for authorization to complete..._'
1120
+ }
1121
+
1122
+ if (method === 'code') {
1123
+ // Code mode: show a button to paste the auth code/URL after
1124
+ // completing login in a browser (possibly on a different machine).
1125
+ const button = new ButtonBuilder()
1126
+ .setCustomId(`login_oauth_code_btn:${hash}`)
1127
+ .setLabel('Paste authorization code')
1128
+ .setStyle(ButtonStyle.Primary)
1129
+
1130
+ await interaction.editReply({
1131
+ content: message,
1132
+ components: [
1133
+ new ActionRowBuilder<ButtonBuilder>().addComponents(button),
1134
+ ],
1135
+ })
1136
+ // Don't delete context — we need it for the code submission
1137
+ return
1138
+ }
1139
+
1140
+ await interaction.editReply({ content: message, components: [] })
1141
+
1142
+ // Auto mode: poll for completion (device flow / localhost callback)
1143
+ const callbackResponse = await getClient().provider.oauth.callback({
1144
+ providerID: ctx.providerId,
1145
+ method: ctx.methodIndex,
1146
+ directory: ctx.dir,
1147
+ })
1148
+
1149
+ if (callbackResponse.error) {
1150
+ pendingLoginContexts.delete(hash)
1151
+ await interaction.editReply({
1152
+ content: `**Authentication Failed**\n${extractErrorMessage({ error: callbackResponse.error, fallback: 'Authorization was not completed' })}`,
1153
+ components: [],
1154
+ })
1155
+ return
1156
+ }
1157
+
1158
+ await getClient().instance.dispose({ directory: ctx.dir })
1159
+ pendingLoginContexts.delete(hash)
1160
+
1161
+ await interaction.editReply({
1162
+ content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
1163
+ components: [],
1164
+ })
1165
+ } catch (error) {
1166
+ loginLogger.error('OAuth flow error:', error)
1167
+ pendingLoginContexts.delete(hash)
1168
+ await interaction.editReply({
1169
+ content: `**Authentication Failed**\n${error instanceof Error ? error.message : 'Unknown error'}`,
1170
+ components: [],
1171
+ })
1172
+ }
1173
+ }