@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,800 @@
1
+ // Discord-specific utility functions.
2
+ // Handles markdown splitting for Discord's 2000-char limit, code block escaping,
3
+ // thread message sending, and channel metadata extraction from topic tags.
4
+
5
+ import {
6
+ type APIInteractionGuildMember,
7
+ type AutocompleteInteraction,
8
+ ChannelType,
9
+ GuildMember,
10
+ MessageFlags,
11
+ PermissionsBitField,
12
+ type Guild,
13
+ type Message,
14
+ type TextChannel,
15
+ type ThreadChannel,
16
+ } from 'discord.js'
17
+ import { REST, Routes } from 'discord.js'
18
+ import type { OpencodeClient } from '@opencode-ai/sdk/v2'
19
+ import { discordApiUrl } from './discord-urls.js'
20
+ import { Lexer } from 'marked'
21
+ import { splitTablesFromMarkdown } from './format-tables.js'
22
+ import { getChannelDirectory, getThreadWorktree } from './database.js'
23
+ import { limitHeadingDepth } from './limit-heading-depth.js'
24
+ import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js'
25
+ import { createLogger, LogPrefix } from './logger.js'
26
+ import * as errore from 'errore'
27
+ import mime from 'mime'
28
+ import fs from 'node:fs'
29
+ import path from 'node:path'
30
+
31
+ const discordLogger = createLogger(LogPrefix.DISCORD)
32
+
33
+ /**
34
+ * Centralized permission check for Kimaki bot access.
35
+ * Returns true if the member has permission to use the bot:
36
+ * - Server owner, Administrator, Manage Server, or "Kimaki" role (case-insensitive).
37
+ * Returns false if member is null or has the "no-kimaki" role (overrides all).
38
+ */
39
+ export function hasKimakiBotPermission(
40
+ member: GuildMember | APIInteractionGuildMember | null,
41
+ guild?: Guild | null,
42
+ ): boolean {
43
+ if (!member) {
44
+ return false
45
+ }
46
+ const hasNoKimakiRole = hasRoleByName(member, 'no-kimaki', guild)
47
+ if (hasNoKimakiRole) {
48
+ return false
49
+ }
50
+ const memberPermissions =
51
+ member instanceof GuildMember
52
+ ? member.permissions
53
+ : new PermissionsBitField(BigInt(member.permissions))
54
+ const ownerId = member instanceof GuildMember ? member.guild.ownerId : guild?.ownerId
55
+ const memberId = member instanceof GuildMember ? member.id : member.user.id
56
+ const isOwner = ownerId ? memberId === ownerId : false
57
+ const isAdmin = memberPermissions.has(PermissionsBitField.Flags.Administrator)
58
+ const canManageServer = memberPermissions.has(PermissionsBitField.Flags.ManageGuild)
59
+ const hasKimakiRole = hasRoleByName(member, 'kimaki', guild)
60
+ return isOwner || isAdmin || canManageServer || hasKimakiRole
61
+ }
62
+
63
+ function hasRoleByName(
64
+ member: GuildMember | APIInteractionGuildMember,
65
+ roleName: string,
66
+ guild?: Guild | null,
67
+ ): boolean {
68
+ const target = roleName.toLowerCase()
69
+
70
+ if (member instanceof GuildMember) {
71
+ return member.roles.cache.some((role) => role.name.toLowerCase() === target)
72
+ }
73
+
74
+ if (!guild) {
75
+ return false
76
+ }
77
+
78
+ const roleIds = Array.isArray(member.roles) ? member.roles : []
79
+ for (const roleId of roleIds) {
80
+ const role = guild.roles.cache.get(roleId)
81
+ if (role?.name.toLowerCase() === target) {
82
+ return true
83
+ }
84
+ }
85
+ return false
86
+ }
87
+
88
+ /**
89
+ * Check if the member has the "no-kimaki" role that blocks bot access.
90
+ * Separate from hasKimakiBotPermission so callers can show a specific error message.
91
+ */
92
+ export function hasNoKimakiRole(member: GuildMember | null): boolean {
93
+ if (!member?.roles?.cache) {
94
+ return false
95
+ }
96
+ return member.roles.cache.some(
97
+ (role) => role.name.toLowerCase() === 'no-kimaki',
98
+ )
99
+ }
100
+
101
+ /**
102
+ * React to a thread's starter message with an emoji.
103
+ * Thread ID equals the starter message ID in Discord.
104
+ */
105
+ export async function reactToThread({
106
+ rest,
107
+ threadId,
108
+ channelId,
109
+ emoji,
110
+ }: {
111
+ rest: REST
112
+ threadId: string
113
+ /** Parent channel ID where the thread starter message lives.
114
+ * If not provided, fetches the thread info from Discord API to resolve it. */
115
+ channelId?: string
116
+ emoji: string
117
+ }): Promise<void> {
118
+ const parentChannelId = await (async () => {
119
+ if (channelId) {
120
+ return channelId
121
+ }
122
+ // Fetch the thread to get its parent channel ID
123
+ const threadResult = await errore.tryAsync(() => {
124
+ return rest.get(Routes.channel(threadId)) as Promise<{
125
+ parent_id?: string
126
+ }>
127
+ })
128
+ if (threadResult instanceof Error) {
129
+ discordLogger.warn(
130
+ `Failed to fetch thread ${threadId}:`,
131
+ threadResult.message,
132
+ )
133
+ return null
134
+ }
135
+ return threadResult.parent_id || null
136
+ })()
137
+
138
+ if (!parentChannelId) {
139
+ discordLogger.warn(
140
+ `Could not resolve parent channel for thread ${threadId}`,
141
+ )
142
+ return
143
+ }
144
+
145
+ // React to the thread starter message in the parent channel.
146
+ // Thread ID equals the starter message ID for threads created from messages.
147
+ const result = await errore.tryAsync(() => {
148
+ return rest.put(
149
+ Routes.channelMessageOwnReaction(
150
+ parentChannelId,
151
+ threadId,
152
+ encodeURIComponent(emoji),
153
+ ),
154
+ )
155
+ })
156
+ if (result instanceof Error) {
157
+ discordLogger.warn(
158
+ `Failed to react to thread ${threadId} with ${emoji}:`,
159
+ result.message,
160
+ )
161
+ }
162
+ }
163
+
164
+ export async function archiveThread({
165
+ rest,
166
+ threadId,
167
+ parentChannelId,
168
+ sessionId,
169
+ client,
170
+ archiveDelay = 0,
171
+ }: {
172
+ rest: REST
173
+ threadId: string
174
+ parentChannelId?: string
175
+ sessionId?: string
176
+ client?: OpencodeClient | null
177
+ archiveDelay?: number
178
+ }): Promise<void> {
179
+ await reactToThread({
180
+ rest,
181
+ threadId,
182
+ channelId: parentChannelId,
183
+ emoji: '📁',
184
+ })
185
+
186
+ if (client && sessionId) {
187
+ const updateResult = await errore.tryAsync({
188
+ try: async () => {
189
+ const sessionResponse = await client.session.get({
190
+ sessionID: sessionId,
191
+ })
192
+ if (!sessionResponse.data) {
193
+ return
194
+ }
195
+ const currentTitle = sessionResponse.data.title || ''
196
+ const newTitle = currentTitle.startsWith('📁')
197
+ ? currentTitle
198
+ : `📁 ${currentTitle}`.trim()
199
+ await client.session.update({
200
+ sessionID: sessionId,
201
+ title: newTitle,
202
+ })
203
+ },
204
+ catch: (e) => new Error('Failed to update session title', { cause: e }),
205
+ })
206
+ if (updateResult instanceof Error) {
207
+ discordLogger.warn(`[archive-thread] ${updateResult.message}`)
208
+ }
209
+
210
+ const abortResult = await errore.tryAsync({
211
+ try: async () => {
212
+ await client.session.abort({ sessionID: sessionId })
213
+ },
214
+ catch: (e) => new Error('Failed to abort session', { cause: e }),
215
+ })
216
+ if (abortResult instanceof Error) {
217
+ discordLogger.warn(`[archive-thread] ${abortResult.message}`)
218
+ }
219
+ }
220
+
221
+ if (archiveDelay > 0) {
222
+ await new Promise<void>((resolve) => {
223
+ setTimeout(() => {
224
+ resolve()
225
+ }, archiveDelay)
226
+ })
227
+ }
228
+
229
+ await rest.patch(Routes.channel(threadId), {
230
+ body: { archived: true },
231
+ })
232
+ }
233
+
234
+ /** Remove Discord mentions from text so they don't appear in thread titles */
235
+ export function stripMentions(text: string): string {
236
+ return text
237
+ .replace(/<@!?\d+>/g, '') // user mentions
238
+ .replace(/<@&\d+>/g, '') // role mentions
239
+ .replace(/<#\d+>/g, '') // channel mentions
240
+ .replace(/\s+/g, ' ')
241
+ .trim()
242
+ }
243
+
244
+ export const SILENT_MESSAGE_FLAGS = 4 | 4096
245
+ // Same as SILENT but without SuppressNotifications - triggers badge/notification
246
+ export const NOTIFY_MESSAGE_FLAGS = 4
247
+
248
+ export function escapeBackticksInCodeBlocks(markdown: string): string {
249
+ const lexer = new Lexer()
250
+ const tokens = lexer.lex(markdown)
251
+
252
+ let result = ''
253
+
254
+ for (const token of tokens) {
255
+ if (token.type === 'code') {
256
+ const escapedCode = token.text.replace(/`/g, '\\`')
257
+ result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n'
258
+ } else {
259
+ result += token.raw
260
+ }
261
+ }
262
+
263
+ return result
264
+ }
265
+
266
+ type LineInfo = {
267
+ text: string
268
+ inCodeBlock: boolean
269
+ lang: string
270
+ isOpeningFence: boolean
271
+ isClosingFence: boolean
272
+ }
273
+
274
+ export function splitMarkdownForDiscord({
275
+ content,
276
+ maxLength,
277
+ }: {
278
+ content: string
279
+ maxLength: number
280
+ }): string[] {
281
+ if (content.length <= maxLength) {
282
+ return [content]
283
+ }
284
+
285
+ const lexer = new Lexer()
286
+ const tokens = lexer.lex(content)
287
+
288
+ const lines: LineInfo[] = []
289
+ const ensureNewlineBeforeCode = (): void => {
290
+ const last = lines[lines.length - 1]
291
+ if (!last) {
292
+ return
293
+ }
294
+ if (last.text.endsWith('\n')) {
295
+ return
296
+ }
297
+ lines.push({
298
+ text: '\n',
299
+ inCodeBlock: false,
300
+ lang: '',
301
+ isOpeningFence: false,
302
+ isClosingFence: false,
303
+ })
304
+ }
305
+ for (const token of tokens) {
306
+ if (token.type === 'code') {
307
+ ensureNewlineBeforeCode()
308
+ const lang = token.lang || ''
309
+ lines.push({
310
+ text: '```' + lang + '\n',
311
+ inCodeBlock: false,
312
+ lang,
313
+ isOpeningFence: true,
314
+ isClosingFence: false,
315
+ })
316
+ const codeLines = token.text.split('\n')
317
+ for (const codeLine of codeLines) {
318
+ lines.push({
319
+ text: codeLine + '\n',
320
+ inCodeBlock: true,
321
+ lang,
322
+ isOpeningFence: false,
323
+ isClosingFence: false,
324
+ })
325
+ }
326
+ lines.push({
327
+ text: '```\n',
328
+ inCodeBlock: false,
329
+ lang: '',
330
+ isOpeningFence: false,
331
+ isClosingFence: true,
332
+ })
333
+ } else {
334
+ const rawLines = token.raw.split('\n')
335
+ for (let i = 0; i < rawLines.length; i++) {
336
+ const isLast = i === rawLines.length - 1
337
+ const text = isLast ? rawLines[i]! : rawLines[i]! + '\n'
338
+ if (text) {
339
+ lines.push({
340
+ text,
341
+ inCodeBlock: false,
342
+ lang: '',
343
+ isOpeningFence: false,
344
+ isClosingFence: false,
345
+ })
346
+ }
347
+ }
348
+ }
349
+ }
350
+
351
+ const chunks: string[] = []
352
+ let currentChunk = ''
353
+ let currentLang: string | null = null
354
+
355
+ // helper to split a long line into smaller pieces at word boundaries or hard breaks
356
+ const splitLongLine = (
357
+ text: string,
358
+ available: number,
359
+ inCode: boolean,
360
+ ): string[] => {
361
+ const pieces: string[] = []
362
+ let remaining = text
363
+
364
+ while (remaining.length > available) {
365
+ let splitAt = available
366
+ // for non-code, try to split at word boundary
367
+ if (!inCode) {
368
+ const lastSpace = remaining.lastIndexOf(' ', available)
369
+ if (lastSpace > available * 0.5) {
370
+ splitAt = lastSpace + 1
371
+ }
372
+ }
373
+ pieces.push(remaining.slice(0, splitAt))
374
+ remaining = remaining.slice(splitAt)
375
+ }
376
+ if (remaining) {
377
+ pieces.push(remaining)
378
+ }
379
+ return pieces
380
+ }
381
+
382
+ const closingFence = '```\n'
383
+
384
+ for (const line of lines) {
385
+ // openingFenceSize accounts for the fence text when starting a fresh chunk
386
+ const openingFenceSize =
387
+ currentChunk.length === 0 && (line.inCodeBlock || line.isOpeningFence)
388
+ ? ('```' + line.lang + '\n').length
389
+ : 0
390
+ // When opening fence starts a fresh chunk, its size is in openingFenceSize.
391
+ // Otherwise count it normally so the overflow check doesn't miss the fence text.
392
+ const lineLength =
393
+ line.isOpeningFence && currentChunk.length === 0 ? 0 : line.text.length
394
+ const activeFenceOverhead =
395
+ currentLang !== null || openingFenceSize > 0 ? closingFence.length : 0
396
+ const wouldExceed =
397
+ currentChunk.length +
398
+ openingFenceSize +
399
+ lineLength +
400
+ activeFenceOverhead >
401
+ maxLength
402
+
403
+ if (wouldExceed) {
404
+ // handle case where single line is longer than maxLength
405
+ if (line.text.length > maxLength) {
406
+ // first, flush current chunk if any
407
+ if (currentChunk) {
408
+ if (currentLang !== null) {
409
+ currentChunk += '```\n'
410
+ }
411
+ chunks.push(currentChunk)
412
+ currentChunk = ''
413
+ }
414
+
415
+ // calculate overhead for code block markers
416
+ const codeBlockOverhead = line.inCodeBlock
417
+ ? ('```' + line.lang + '\n').length + '```\n'.length
418
+ : 0
419
+ // ensure at least 10 chars available, even if maxLength is very small
420
+ const availablePerChunk = Math.max(
421
+ 10,
422
+ maxLength - codeBlockOverhead - 50,
423
+ )
424
+
425
+ const pieces = splitLongLine(
426
+ line.text,
427
+ availablePerChunk,
428
+ line.inCodeBlock,
429
+ )
430
+
431
+ for (let i = 0; i < pieces.length; i++) {
432
+ const piece = pieces[i]!
433
+ if (line.inCodeBlock) {
434
+ chunks.push('```' + line.lang + '\n' + piece + '```\n')
435
+ } else {
436
+ chunks.push(piece)
437
+ }
438
+ }
439
+
440
+ currentLang = null
441
+ continue
442
+ }
443
+
444
+ // normal case: line fits in a chunk but current chunk would overflow
445
+ if (currentChunk) {
446
+ if (currentLang !== null) {
447
+ currentChunk += '```\n'
448
+ }
449
+ chunks.push(currentChunk)
450
+
451
+ if (line.isClosingFence && currentLang !== null) {
452
+ currentChunk = ''
453
+ currentLang = null
454
+ continue
455
+ }
456
+
457
+ if (line.inCodeBlock || line.isOpeningFence) {
458
+ const lang = line.lang
459
+ currentChunk = '```' + lang + '\n'
460
+ if (!line.isOpeningFence) {
461
+ currentChunk += line.text
462
+ }
463
+ currentLang = lang
464
+ } else {
465
+ currentChunk = line.text
466
+ currentLang = null
467
+ }
468
+ } else {
469
+ // currentChunk is empty but line still exceeds - shouldn't happen after above check
470
+ const openingFence = line.inCodeBlock || line.isOpeningFence
471
+ const openingFenceSize = openingFence
472
+ ? ('```' + line.lang + '\n').length
473
+ : 0
474
+ if (
475
+ line.text.length + openingFenceSize + activeFenceOverhead >
476
+ maxLength
477
+ ) {
478
+ const fencedOverhead = openingFence
479
+ ? ('```' + line.lang + '\n').length + closingFence.length
480
+ : 0
481
+ const availablePerChunk = Math.max(
482
+ 10,
483
+ maxLength - fencedOverhead - 50,
484
+ )
485
+ const pieces = splitLongLine(
486
+ line.text,
487
+ availablePerChunk,
488
+ line.inCodeBlock,
489
+ )
490
+ for (const piece of pieces) {
491
+ if (openingFence) {
492
+ chunks.push('```' + line.lang + '\n' + piece + closingFence)
493
+ } else {
494
+ chunks.push(piece)
495
+ }
496
+ }
497
+ currentChunk = ''
498
+ currentLang = null
499
+ } else {
500
+ if (openingFence) {
501
+ currentChunk = '```' + line.lang + '\n'
502
+ if (!line.isOpeningFence) {
503
+ currentChunk += line.text
504
+ }
505
+ currentLang = line.lang
506
+ } else {
507
+ currentChunk = line.text
508
+ currentLang = null
509
+ }
510
+ }
511
+ }
512
+ } else {
513
+ currentChunk += line.text
514
+ if (line.inCodeBlock || line.isOpeningFence) {
515
+ currentLang = line.lang
516
+ } else if (line.isClosingFence) {
517
+ currentLang = null
518
+ }
519
+ }
520
+ }
521
+
522
+ if (currentChunk) {
523
+ if (currentLang !== null) {
524
+ currentChunk += closingFence
525
+ }
526
+ chunks.push(currentChunk)
527
+ }
528
+
529
+ return chunks
530
+ }
531
+
532
+ export async function sendThreadMessage(
533
+ thread: ThreadChannel,
534
+ content: string,
535
+ options?: { flags?: number },
536
+ ): Promise<Message> {
537
+ const MAX_LENGTH = 2000
538
+
539
+ // Split content into text and CV2 component segments (tables → Container components)
540
+ const segments = splitTablesFromMarkdown(content)
541
+ const baseFlags = options?.flags ?? SILENT_MESSAGE_FLAGS
542
+
543
+ let firstMessage: Message | undefined
544
+
545
+ for (const segment of segments) {
546
+ if (segment.type === 'components') {
547
+ const message = await thread.send({
548
+ components: segment.components,
549
+ flags: MessageFlags.IsComponentsV2 | baseFlags,
550
+ })
551
+ if (!firstMessage) {
552
+ firstMessage = message
553
+ }
554
+ continue
555
+ }
556
+
557
+ // Apply text transformations to text segments
558
+ let text = segment.text
559
+ text = unnestCodeBlocksFromLists(text)
560
+ text = limitHeadingDepth(text)
561
+ text = escapeBackticksInCodeBlocks(text)
562
+
563
+ if (!text.trim()) {
564
+ continue
565
+ }
566
+
567
+ const sendFlags = options?.flags ?? SILENT_MESSAGE_FLAGS
568
+ const chunks = splitMarkdownForDiscord({
569
+ content: text,
570
+ maxLength: MAX_LENGTH,
571
+ })
572
+
573
+ if (chunks.length > 1) {
574
+ discordLogger.log(
575
+ `MESSAGE: Splitting ${text.length} chars into ${chunks.length} messages`,
576
+ )
577
+ }
578
+
579
+ for (let chunk of chunks) {
580
+ if (!chunk) {
581
+ continue
582
+ }
583
+ // Safety net: hard-truncate if splitting still produced an oversized chunk
584
+ if (chunk.length > MAX_LENGTH) {
585
+ chunk = chunk.slice(0, MAX_LENGTH - 4) + '...'
586
+ }
587
+ const message = await thread.send({ content: chunk, flags: sendFlags })
588
+ if (!firstMessage) {
589
+ firstMessage = message
590
+ }
591
+ }
592
+ }
593
+
594
+ return firstMessage!
595
+ }
596
+
597
+ export async function resolveTextChannel(
598
+ channel: TextChannel | ThreadChannel | null | undefined,
599
+ ): Promise<TextChannel | null> {
600
+ if (!channel) {
601
+ return null
602
+ }
603
+
604
+ if (channel.type === ChannelType.GuildText) {
605
+ return channel as TextChannel
606
+ }
607
+
608
+ if (
609
+ channel.type === ChannelType.PublicThread ||
610
+ channel.type === ChannelType.PrivateThread ||
611
+ channel.type === ChannelType.AnnouncementThread
612
+ ) {
613
+ const parentId = channel.parentId
614
+ if (parentId) {
615
+ const parent = await channel.guild.channels.fetch(parentId)
616
+ if (parent?.type === ChannelType.GuildText) {
617
+ return parent as TextChannel
618
+ }
619
+ }
620
+ }
621
+
622
+ return null
623
+ }
624
+
625
+ export function escapeDiscordFormatting(text: string): string {
626
+ return text.replace(/```/g, '\\`\\`\\`').replace(/````/g, '\\`\\`\\`\\`')
627
+ }
628
+
629
+ export async function getKimakiMetadata(
630
+ textChannel: TextChannel | null,
631
+ ): Promise<{
632
+ projectDirectory?: string
633
+ }> {
634
+ if (!textChannel) {
635
+ return {}
636
+ }
637
+
638
+ const channelConfig = await getChannelDirectory(textChannel.id)
639
+
640
+ if (!channelConfig) {
641
+ return {}
642
+ }
643
+
644
+ return {
645
+ projectDirectory: channelConfig.directory,
646
+ }
647
+ }
648
+
649
+ /**
650
+ * Resolve project directory from an autocomplete interaction.
651
+ * Uses interaction.channelId (always available from raw payload) instead of
652
+ * interaction.channel (cache-based getter, often null with gateway-proxy).
653
+ * Checks the channel ID directly in DB, then tries thread worktree lookup,
654
+ * then falls back to fetching the channel to resolve thread parent.
655
+ */
656
+ export async function resolveProjectDirectoryFromAutocomplete(
657
+ interaction: Pick<AutocompleteInteraction, 'channelId' | 'channel' | 'client'>,
658
+ ): Promise<string | undefined> {
659
+ const channelId = interaction.channelId
660
+
661
+ // Direct channel lookup — works when the command is run from a project text channel
662
+ const channelConfig = await getChannelDirectory(channelId)
663
+ if (channelConfig) {
664
+ return channelConfig.directory
665
+ }
666
+
667
+ // If we're in a thread, try worktree info first (has project_directory)
668
+ const worktreeInfo = await getThreadWorktree(channelId)
669
+ if (worktreeInfo?.project_directory) {
670
+ return worktreeInfo.project_directory
671
+ }
672
+
673
+ // Thread fallback: resolve parent channel ID and look up its directory.
674
+ // Try cached channel first, then fetch if cache misses (gateway-proxy scenario).
675
+ const cachedParentId = interaction.channel?.isThread() ? interaction.channel.parentId : null
676
+ if (cachedParentId) {
677
+ const parentConfig = await getChannelDirectory(cachedParentId)
678
+ if (parentConfig) {
679
+ return parentConfig.directory
680
+ }
681
+ }
682
+
683
+ // Last resort: fetch the channel from Discord API to get parentId for threads
684
+ // when the channel isn't cached at all (common with gateway-proxy).
685
+ if (!cachedParentId) {
686
+ const fetched = await errore.tryAsync({
687
+ try: () => { return interaction.client.channels.fetch(channelId) },
688
+ catch: (e) => { return e as Error },
689
+ })
690
+ if (!(fetched instanceof Error) && fetched?.isThread() && fetched.parentId) {
691
+ const parentConfig = await getChannelDirectory(fetched.parentId)
692
+ if (parentConfig) {
693
+ return parentConfig.directory
694
+ }
695
+ }
696
+ }
697
+
698
+ return undefined
699
+ }
700
+
701
+ /**
702
+ * Resolve the working directory for a channel or thread.
703
+ * Returns both the base project directory (for server init) and the working directory
704
+ * (worktree directory if in a worktree thread, otherwise same as projectDirectory).
705
+ * This prevents commands from accidentally running in the base project dir when a
706
+ * worktree is active — the bug that caused /diff, /compact, etc. to use wrong cwd.
707
+ */
708
+ export async function resolveWorkingDirectory({
709
+ channel,
710
+ }: {
711
+ channel: TextChannel | ThreadChannel
712
+ }): Promise<
713
+ | {
714
+ projectDirectory: string
715
+ workingDirectory: string
716
+ }
717
+ | undefined
718
+ > {
719
+ const isThread = [
720
+ ChannelType.PublicThread,
721
+ ChannelType.PrivateThread,
722
+ ChannelType.AnnouncementThread,
723
+ ].includes(channel.type)
724
+
725
+ const textChannel = isThread
726
+ ? await resolveTextChannel(channel as ThreadChannel)
727
+ : (channel as TextChannel)
728
+
729
+ const metadata = await getKimakiMetadata(textChannel)
730
+ if (!metadata.projectDirectory) {
731
+ return undefined
732
+ }
733
+
734
+ let workingDirectory = metadata.projectDirectory
735
+ if (isThread) {
736
+ const worktreeInfo = await getThreadWorktree(channel.id)
737
+ if (worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory) {
738
+ workingDirectory = worktreeInfo.worktree_directory
739
+ }
740
+ }
741
+
742
+ return {
743
+ projectDirectory: metadata.projectDirectory,
744
+ workingDirectory,
745
+ }
746
+ }
747
+
748
+ /**
749
+ * Upload files to a Discord thread/channel in a single message.
750
+ * Sending all files in one message causes Discord to display images in a grid layout.
751
+ */
752
+ export async function uploadFilesToDiscord({
753
+ threadId,
754
+ botToken,
755
+ files,
756
+ }: {
757
+ threadId: string
758
+ botToken: string
759
+ files: string[]
760
+ }): Promise<void> {
761
+ if (files.length === 0) {
762
+ return
763
+ }
764
+
765
+ // Build attachments array for all files
766
+ const attachments = files.map((file, index) => ({
767
+ id: index,
768
+ filename: path.basename(file),
769
+ }))
770
+
771
+ const formData = new FormData()
772
+ formData.append('payload_json', JSON.stringify({ attachments }))
773
+
774
+ // Append each file with its array index, with correct MIME type for grid display
775
+ files.forEach((file, index) => {
776
+ const buffer = fs.readFileSync(file)
777
+ const mimeType = mime.getType(file) || 'application/octet-stream'
778
+ formData.append(
779
+ `files[${index}]`,
780
+ new Blob([buffer], { type: mimeType }),
781
+ path.basename(file),
782
+ )
783
+ })
784
+
785
+ const response = await fetch(
786
+ discordApiUrl(`/channels/${threadId}/messages`),
787
+ {
788
+ method: 'POST',
789
+ headers: {
790
+ Authorization: `Bot ${botToken}`,
791
+ },
792
+ body: formData,
793
+ },
794
+ )
795
+
796
+ if (!response.ok) {
797
+ const error = await response.text()
798
+ throw new Error(`Discord API error: ${response.status} - ${error}`)
799
+ }
800
+ }