@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,389 @@
1
+ // File upload tool handler - Shows Discord modal with FileUploadBuilder.
2
+ // When the AI uses the kimaki_file_upload tool, the plugin inserts a row into
3
+ // the ipc_requests DB table. The bot polls this table, picks up the request,
4
+ // and shows a button in the thread. User clicks it to open a modal with a
5
+ // native file picker. Uploaded files are downloaded to the project directory.
6
+ // The bot writes file paths back to ipc_requests.response, and the plugin
7
+ // polls until the response appears.
8
+
9
+ import {
10
+ ButtonBuilder,
11
+ ButtonStyle,
12
+ ActionRowBuilder,
13
+ ModalBuilder,
14
+ FileUploadBuilder,
15
+ LabelBuilder,
16
+ ComponentType,
17
+ type ButtonInteraction,
18
+ type ModalSubmitInteraction,
19
+ type ThreadChannel,
20
+ MessageFlags,
21
+ } from 'discord.js'
22
+ import crypto from 'node:crypto'
23
+ import fs from 'node:fs'
24
+ import path from 'node:path'
25
+ import { createLogger, LogPrefix } from '../logger.js'
26
+ import { notifyError } from '../sentry.js'
27
+ import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js'
28
+
29
+ const logger = createLogger(LogPrefix.FILE_UPLOAD)
30
+
31
+ // 5 minute TTL for pending contexts - if user doesn't click within this time,
32
+ // clean up the context and resolve with empty array to unblock the plugin tool
33
+ const PENDING_TTL_MS = 5 * 60 * 1000
34
+
35
+ export type FileUploadRequest = {
36
+ sessionId: string
37
+ threadId: string
38
+ directory: string
39
+ prompt: string
40
+ maxFiles: number
41
+ }
42
+
43
+ type PendingFileUploadContext = {
44
+ sessionId: string
45
+ directory: string
46
+ thread: ThreadChannel
47
+ prompt: string
48
+ maxFiles: number
49
+ contextHash: string
50
+ resolve: (filePaths: string[]) => void
51
+ reject: (error: Error) => void
52
+ messageId?: string
53
+ resolved: boolean
54
+ timer: ReturnType<typeof setTimeout>
55
+ }
56
+
57
+ export const pendingFileUploadContexts = new Map<
58
+ string,
59
+ PendingFileUploadContext
60
+ >()
61
+
62
+ /**
63
+ * Sanitize an attachment filename to prevent path traversal.
64
+ * Strips directory separators, .., and null bytes from the name.
65
+ * Prepends a short random prefix to avoid collisions between uploads.
66
+ */
67
+ function sanitizeFilename(name: string): string {
68
+ // Extract just the base name (strips any directory components)
69
+ let sanitized = path.basename(name)
70
+ // Remove null bytes and other dangerous characters
71
+ sanitized = sanitized.replace(/[\x00]/g, '')
72
+ // If somehow still empty or just dots, give it a safe name
73
+ if (!sanitized || sanitized === '.' || sanitized === '..') {
74
+ sanitized = 'upload'
75
+ }
76
+ // Prefix with short random id to avoid collisions
77
+ const prefix = crypto.randomBytes(4).toString('hex')
78
+ return `${prefix}-${sanitized}`
79
+ }
80
+
81
+ /**
82
+ * Safely resolve a pending context exactly once. Prevents double-resolve from
83
+ * cancel/submit races by checking the `resolved` flag.
84
+ */
85
+ function resolveContext(
86
+ context: PendingFileUploadContext,
87
+ filePaths: string[],
88
+ ): boolean {
89
+ if (context.resolved) {
90
+ return false
91
+ }
92
+ context.resolved = true
93
+ clearTimeout(context.timer)
94
+ pendingFileUploadContexts.delete(context.contextHash)
95
+ context.resolve(filePaths)
96
+ return true
97
+ }
98
+
99
+ /**
100
+ * Show a button in the thread that opens a file upload modal when clicked.
101
+ * Returns a promise that resolves with the downloaded file paths.
102
+ */
103
+ export function showFileUploadButton({
104
+ thread,
105
+ sessionId,
106
+ directory,
107
+ prompt,
108
+ maxFiles,
109
+ }: {
110
+ thread: ThreadChannel
111
+ sessionId: string
112
+ directory: string
113
+ prompt: string
114
+ maxFiles: number
115
+ }): Promise<string[]> {
116
+ return new Promise((resolve, reject) => {
117
+ const contextHash = crypto.randomBytes(8).toString('hex')
118
+
119
+ // TTL timer: auto-cleanup if user never clicks the button
120
+ const timer = setTimeout(() => {
121
+ const ctx = pendingFileUploadContexts.get(contextHash)
122
+ if (ctx && !ctx.resolved) {
123
+ logger.log(
124
+ `File upload timed out for session ${sessionId}, hash=${contextHash}`,
125
+ )
126
+ resolveContext(ctx, [])
127
+ // Remove button from message
128
+ if (ctx.messageId) {
129
+ ctx.thread.messages
130
+ .fetch(ctx.messageId)
131
+ .then((msg) => {
132
+ return msg.edit({
133
+ content: `**File Upload Requested**\n${prompt.slice(0, 1900)}\n_Timed out_`,
134
+ components: [],
135
+ })
136
+ })
137
+ .catch(() => {})
138
+ }
139
+ }
140
+ }, PENDING_TTL_MS)
141
+
142
+ const context: PendingFileUploadContext = {
143
+ sessionId,
144
+ directory,
145
+ thread,
146
+ prompt,
147
+ maxFiles,
148
+ contextHash,
149
+ resolve,
150
+ reject,
151
+ resolved: false,
152
+ timer,
153
+ }
154
+
155
+ pendingFileUploadContexts.set(contextHash, context)
156
+
157
+ const uploadButton = new ButtonBuilder()
158
+ .setCustomId(`file_upload_btn:${contextHash}`)
159
+ .setLabel('Upload Files')
160
+ .setStyle(ButtonStyle.Primary)
161
+
162
+ const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
163
+ uploadButton,
164
+ )
165
+
166
+ thread
167
+ .send({
168
+ content: `**File Upload Requested**\n${prompt.slice(0, 1900)}`,
169
+ components: [actionRow],
170
+ flags: NOTIFY_MESSAGE_FLAGS,
171
+ })
172
+ .then((msg) => {
173
+ context.messageId = msg.id
174
+ logger.log(
175
+ `Showed file upload button for session ${sessionId}, hash=${contextHash}`,
176
+ )
177
+ })
178
+ .catch((err) => {
179
+ clearTimeout(timer)
180
+ pendingFileUploadContexts.delete(contextHash)
181
+ reject(new Error('Failed to send file upload button', { cause: err }))
182
+ })
183
+ })
184
+ }
185
+
186
+ /**
187
+ * Handle the file upload button click - opens a modal with FileUploadBuilder.
188
+ */
189
+ export async function handleFileUploadButton(
190
+ interaction: ButtonInteraction,
191
+ ): Promise<void> {
192
+ const customId = interaction.customId
193
+ if (!customId.startsWith('file_upload_btn:')) {
194
+ return
195
+ }
196
+
197
+ const contextHash = customId.replace('file_upload_btn:', '')
198
+ const context = pendingFileUploadContexts.get(contextHash)
199
+
200
+ if (!context || context.resolved) {
201
+ await interaction.reply({
202
+ content: 'This file upload request has expired.',
203
+ flags: MessageFlags.Ephemeral,
204
+ })
205
+ return
206
+ }
207
+
208
+ const fileUpload = new FileUploadBuilder()
209
+ .setCustomId('uploaded_files')
210
+ .setMinValues(1)
211
+ .setMaxValues(context.maxFiles)
212
+
213
+ const label = new LabelBuilder()
214
+ .setLabel('Files')
215
+ .setDescription(context.prompt.slice(0, 100))
216
+ .setFileUploadComponent(fileUpload)
217
+
218
+ const modal = new ModalBuilder()
219
+ .setCustomId(`file_upload_modal:${contextHash}`)
220
+ .setTitle('Upload Files')
221
+ .addLabelComponents(label)
222
+
223
+ await interaction.showModal(modal)
224
+ }
225
+
226
+ /**
227
+ * Handle the modal submission - download files and resolve the pending promise.
228
+ */
229
+ export async function handleFileUploadModalSubmit(
230
+ interaction: ModalSubmitInteraction,
231
+ ): Promise<void> {
232
+ const customId = interaction.customId
233
+ if (!customId.startsWith('file_upload_modal:')) {
234
+ return
235
+ }
236
+
237
+ const contextHash = customId.replace('file_upload_modal:', '')
238
+ const context = pendingFileUploadContexts.get(contextHash)
239
+
240
+ if (!context || context.resolved) {
241
+ await interaction.reply({
242
+ content: 'This file upload request has expired.',
243
+ flags: MessageFlags.Ephemeral,
244
+ })
245
+ return
246
+ }
247
+
248
+ try {
249
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral })
250
+
251
+ // File upload data is nested in the LabelModalData -> FileUploadModalData
252
+ const fileField = interaction.fields.getField(
253
+ 'uploaded_files',
254
+ ComponentType.FileUpload,
255
+ )
256
+ const attachments = fileField.attachments
257
+ if (!attachments || attachments.size === 0) {
258
+ await interaction.editReply({ content: 'No files were uploaded.' })
259
+ updateButtonMessage(context, '_No files uploaded_')
260
+ resolveContext(context, [])
261
+ return
262
+ }
263
+
264
+ const uploadsDir = path.join(context.directory, 'uploads')
265
+ fs.mkdirSync(uploadsDir, { recursive: true })
266
+
267
+ const downloadedPaths: string[] = []
268
+ const errors: string[] = []
269
+
270
+ for (const [, attachment] of attachments) {
271
+ // Check if context was cancelled (e.g. user sent new message) while
272
+ // we were downloading previous files - stop downloading more
273
+ if (context.resolved) {
274
+ break
275
+ }
276
+ try {
277
+ const response = await fetch(attachment.url)
278
+ if (!response.ok) {
279
+ errors.push(
280
+ `Failed to download ${attachment.name}: HTTP ${response.status}`,
281
+ )
282
+ continue
283
+ }
284
+ const buffer = Buffer.from(await response.arrayBuffer())
285
+ const safeName = sanitizeFilename(attachment.name)
286
+ const filePath = path.join(uploadsDir, safeName)
287
+ fs.writeFileSync(filePath, buffer)
288
+ downloadedPaths.push(filePath)
289
+ } catch (err) {
290
+ const msg = err instanceof Error ? err.message : String(err)
291
+ errors.push(`Failed to download ${attachment.name}: ${msg}`)
292
+ }
293
+ }
294
+
295
+ // If context was resolved by cancel/timeout during download, don't try to
296
+ // resolve again - just update the ephemeral reply
297
+ if (context.resolved) {
298
+ await interaction.editReply({ content: 'Upload was cancelled.' })
299
+ return
300
+ }
301
+
302
+ const fileNames = downloadedPaths.map((p) => {
303
+ return path.basename(p)
304
+ })
305
+ updateButtonMessage(
306
+ context,
307
+ downloadedPaths.length > 0
308
+ ? `Uploaded: ${fileNames.join(', ')}`
309
+ : '_Upload failed_',
310
+ )
311
+
312
+ const summary = (() => {
313
+ if (downloadedPaths.length > 0 && errors.length === 0) {
314
+ return `Uploaded ${downloadedPaths.length} file(s) successfully.`
315
+ }
316
+ if (downloadedPaths.length > 0 && errors.length > 0) {
317
+ return `Uploaded ${downloadedPaths.length} file(s). Errors: ${errors.join('; ')}`
318
+ }
319
+ return `Upload failed: ${errors.join('; ')}`
320
+ })()
321
+
322
+ await interaction.editReply({ content: summary })
323
+
324
+ resolveContext(context, downloadedPaths)
325
+
326
+ logger.log(
327
+ `File upload completed for session ${context.sessionId}: ${downloadedPaths.length} files`,
328
+ )
329
+ } catch (err) {
330
+ // Ensure context is always resolved even on unexpected errors
331
+ // so the plugin tool doesn't hang indefinitely
332
+ logger.error('Error in file upload modal submit:', err)
333
+ void notifyError(err, 'File upload modal submit error')
334
+ resolveContext(context, [])
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Best-effort update of the original button message (remove button, append status).
340
+ */
341
+ function updateButtonMessage(
342
+ context: PendingFileUploadContext,
343
+ status: string,
344
+ ): void {
345
+ if (!context.messageId) {
346
+ return
347
+ }
348
+ context.thread.messages
349
+ .fetch(context.messageId)
350
+ .then((msg) => {
351
+ return msg.edit({
352
+ content: `**File Upload Requested**\n${context.prompt.slice(0, 1900)}\n${status}`,
353
+ components: [],
354
+ })
355
+ })
356
+ .catch(() => {})
357
+ }
358
+
359
+ /**
360
+ * Cancel ALL pending file uploads for a thread (e.g. when user sends a new message).
361
+ */
362
+ export async function cancelPendingFileUpload(
363
+ threadId: string,
364
+ ): Promise<boolean> {
365
+ const toCancel: PendingFileUploadContext[] = []
366
+ for (const [, ctx] of pendingFileUploadContexts) {
367
+ if (ctx.thread.id === threadId) {
368
+ toCancel.push(ctx)
369
+ }
370
+ }
371
+
372
+ if (toCancel.length === 0) {
373
+ return false
374
+ }
375
+
376
+ let cancelled = 0
377
+ for (const context of toCancel) {
378
+ const didResolve = resolveContext(context, [])
379
+ if (didResolve) {
380
+ updateButtonMessage(context, '_Cancelled - user sent a new message_')
381
+ cancelled++
382
+ }
383
+ }
384
+
385
+ if (cancelled > 0) {
386
+ logger.log(`Cancelled ${cancelled} file upload(s) for thread ${threadId}`)
387
+ }
388
+ return cancelled > 0
389
+ }
@@ -0,0 +1,321 @@
1
+ // /fork command - Fork the session from a past user message.
2
+
3
+ import {
4
+ ChatInputCommandInteraction,
5
+ StringSelectMenuInteraction,
6
+ StringSelectMenuBuilder,
7
+ ActionRowBuilder,
8
+ ChannelType,
9
+ ThreadAutoArchiveDuration,
10
+ type ThreadChannel,
11
+ MessageFlags,
12
+ } from 'discord.js'
13
+ import {
14
+ getThreadSession,
15
+ setThreadSession,
16
+ setPartMessagesBatch,
17
+ } from '../database.js'
18
+ import { initializeOpencodeForDirectory } from '../opencode.js'
19
+ import {
20
+ resolveWorkingDirectory,
21
+ resolveTextChannel,
22
+ sendThreadMessage,
23
+ } from '../discord-utils.js'
24
+ import { collectSessionChunks, batchChunksForDiscord } from '../message-formatting.js'
25
+ import { createLogger, LogPrefix } from '../logger.js'
26
+ import * as errore from 'errore'
27
+
28
+ const sessionLogger = createLogger(LogPrefix.SESSION)
29
+ const forkLogger = createLogger(LogPrefix.FORK)
30
+
31
+ export async function handleForkCommand(
32
+ interaction: ChatInputCommandInteraction,
33
+ ): Promise<void> {
34
+ const channel = interaction.channel
35
+
36
+ if (!channel) {
37
+ await interaction.reply({
38
+ content: 'This command can only be used in a channel',
39
+ flags: MessageFlags.Ephemeral,
40
+ })
41
+ return
42
+ }
43
+
44
+ const isThread = [
45
+ ChannelType.PublicThread,
46
+ ChannelType.PrivateThread,
47
+ ChannelType.AnnouncementThread,
48
+ ].includes(channel.type)
49
+
50
+ if (!isThread) {
51
+ await interaction.reply({
52
+ content:
53
+ 'This command can only be used in a thread with an active session',
54
+ flags: MessageFlags.Ephemeral,
55
+ })
56
+ return
57
+ }
58
+
59
+ const resolved = await resolveWorkingDirectory({
60
+ channel: channel as ThreadChannel,
61
+ })
62
+
63
+ if (!resolved) {
64
+ await interaction.reply({
65
+ content: 'Could not determine project directory for this channel',
66
+ flags: MessageFlags.Ephemeral,
67
+ })
68
+ return
69
+ }
70
+
71
+ const { projectDirectory } = resolved
72
+
73
+ const sessionId = await getThreadSession(channel.id)
74
+
75
+ if (!sessionId) {
76
+ await interaction.reply({
77
+ content: 'No active session in this thread',
78
+ flags: MessageFlags.Ephemeral,
79
+ })
80
+ return
81
+ }
82
+
83
+ // Defer reply before API calls to avoid 3-second timeout
84
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral })
85
+
86
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
87
+ if (getClient instanceof Error) {
88
+ await interaction.editReply({
89
+ content: `Failed to load messages: ${getClient.message}`,
90
+ })
91
+ return
92
+ }
93
+
94
+ try {
95
+ const messagesResponse = await getClient().session.messages({
96
+ sessionID: sessionId,
97
+ })
98
+
99
+ if (!messagesResponse.data) {
100
+ await interaction.editReply({
101
+ content: 'Failed to fetch session messages',
102
+ })
103
+ return
104
+ }
105
+
106
+ const userMessages = messagesResponse.data.filter(
107
+ (m: { info: { role: string } }) => m.info.role === 'user',
108
+ )
109
+
110
+ if (userMessages.length === 0) {
111
+ await interaction.editReply({
112
+ content: 'No user messages found in this session',
113
+ })
114
+ return
115
+ }
116
+
117
+ const recentMessages = userMessages.slice(-25)
118
+
119
+ // Filter out synthetic parts (branch context, memory reminders, etc.)
120
+ // injected by the opencode plugin — they clutter the dropdown preview.
121
+ const options = recentMessages
122
+ .map(
123
+ (
124
+ m: {
125
+ parts: Array<{ type: string; text?: string; synthetic?: boolean }>
126
+ info: { id: string; time: { created: number } }
127
+ },
128
+ index: number,
129
+ ) => {
130
+ const textPart = m.parts.find(
131
+ (p) => p.type === 'text' && !p.synthetic,
132
+ ) as { type: 'text'; text: string } | undefined
133
+ if (!textPart?.text) {
134
+ return null
135
+ }
136
+ const preview = textPart.text.slice(0, 80)
137
+ const label = `${index + 1}. ${preview}${preview.length >= 80 ? '...' : ''}`
138
+
139
+ return {
140
+ label: label.slice(0, 100),
141
+ value: m.info.id,
142
+ description: new Date(m.info.time.created)
143
+ .toLocaleString()
144
+ .slice(0, 50),
145
+ }
146
+ },
147
+ )
148
+ .filter(
149
+ (o): o is NonNullable<typeof o> => o !== null,
150
+ )
151
+
152
+ const selectMenu = new StringSelectMenuBuilder()
153
+ // Discord component custom_id max length is 100 chars.
154
+ // Avoid embedding long directory paths (or base64 of them) in the custom ID.
155
+ // handleForkSelectMenu resolves the directory from the current thread instead.
156
+ .setCustomId(`fork_select:${sessionId}`)
157
+ .setPlaceholder('Select a message to fork from')
158
+ .addOptions(options)
159
+
160
+ const actionRow =
161
+ new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
162
+
163
+ await interaction.editReply({
164
+ content:
165
+ '**Fork Session**\nSelect the user message to fork from. The forked session will continue as if you had not sent that message:',
166
+ components: [actionRow],
167
+ })
168
+ } catch (error) {
169
+ forkLogger.error('Error loading messages:', error)
170
+ await interaction.editReply({
171
+ content: `Failed to load messages: ${error instanceof Error ? error.message : 'Unknown error'}`,
172
+ })
173
+ }
174
+ }
175
+
176
+ export async function handleForkSelectMenu(
177
+ interaction: StringSelectMenuInteraction,
178
+ ): Promise<void> {
179
+ const customId = interaction.customId
180
+
181
+ if (!customId.startsWith('fork_select:')) {
182
+ return
183
+ }
184
+
185
+ const [, sessionId] = customId.split(':')
186
+ if (!sessionId) {
187
+ await interaction.reply({
188
+ content: 'Invalid selection data',
189
+ flags: MessageFlags.Ephemeral,
190
+ })
191
+ return
192
+ }
193
+ const selectedMessageId = interaction.values[0]
194
+
195
+ if (!selectedMessageId) {
196
+ await interaction.reply({
197
+ content: 'No message selected',
198
+ flags: MessageFlags.Ephemeral,
199
+ })
200
+ return
201
+ }
202
+
203
+ await interaction.deferReply()
204
+
205
+ const threadChannel = interaction.channel
206
+ if (!threadChannel) {
207
+ await interaction.editReply('Could not access thread channel')
208
+ return
209
+ }
210
+
211
+ const resolved = await resolveWorkingDirectory({
212
+ channel: threadChannel as ThreadChannel,
213
+ })
214
+ if (!resolved) {
215
+ await interaction.editReply(
216
+ 'Could not determine project directory for this channel',
217
+ )
218
+ return
219
+ }
220
+
221
+ const { projectDirectory } = resolved
222
+
223
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
224
+ if (getClient instanceof Error) {
225
+ await interaction.editReply(`Failed to fork session: ${getClient.message}`)
226
+ return
227
+ }
228
+
229
+ try {
230
+ const forkResponse = await getClient().session.fork({
231
+ sessionID: sessionId,
232
+ messageID: selectedMessageId,
233
+ })
234
+
235
+ if (!forkResponse.data) {
236
+ await interaction.editReply('Failed to fork session')
237
+ return
238
+ }
239
+
240
+ const forkedSession = forkResponse.data
241
+ const parentChannel = interaction.channel
242
+
243
+ if (
244
+ !parentChannel ||
245
+ ![
246
+ ChannelType.PublicThread,
247
+ ChannelType.PrivateThread,
248
+ ChannelType.AnnouncementThread,
249
+ ].includes(parentChannel.type)
250
+ ) {
251
+ await interaction.editReply('Could not access parent channel')
252
+ return
253
+ }
254
+
255
+ const textChannel = await resolveTextChannel(parentChannel as ThreadChannel)
256
+
257
+ if (!textChannel) {
258
+ await interaction.editReply('Could not resolve parent text channel')
259
+ return
260
+ }
261
+
262
+ const thread = await textChannel.threads.create({
263
+ name: `Fork: ${forkedSession.title}`.slice(0, 100),
264
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
265
+ reason: `Forked from session ${sessionId}`,
266
+ })
267
+
268
+ // Claim the forked session immediately so external polling does not race
269
+ // and create a duplicate Sync thread before the rest of this setup runs.
270
+ await setThreadSession(thread.id, forkedSession.id)
271
+
272
+ // Add user to thread so it appears in their sidebar
273
+ await thread.members.add(interaction.user.id)
274
+
275
+ sessionLogger.log(
276
+ `Created forked session ${forkedSession.id} in thread ${thread.id}`,
277
+ )
278
+
279
+ await sendThreadMessage(
280
+ thread,
281
+ `**Forked session created!**\nFrom: \`${sessionId}\`\nNew session: \`${forkedSession.id}\``,
282
+ )
283
+
284
+ // Fetch and display the last assistant messages from the forked session
285
+ const messagesResponse = await getClient().session.messages({
286
+ sessionID: forkedSession.id,
287
+ })
288
+
289
+ if (messagesResponse.data) {
290
+ const { chunks } = collectSessionChunks({
291
+ messages: messagesResponse.data,
292
+ limit: 30,
293
+ })
294
+ const batched = batchChunksForDiscord(chunks)
295
+ for (const batch of batched) {
296
+ const discordMessage = await sendThreadMessage(thread, batch.content)
297
+ await setPartMessagesBatch(
298
+ batch.partIds.map((partId) => ({
299
+ partId,
300
+ messageId: discordMessage.id,
301
+ threadId: thread.id,
302
+ })),
303
+ )
304
+ }
305
+ }
306
+
307
+ await sendThreadMessage(
308
+ thread,
309
+ `You can now continue the conversation from this point.`,
310
+ )
311
+
312
+ await interaction.editReply(
313
+ `Session forked! Continue in ${thread.toString()}`,
314
+ )
315
+ } catch (error) {
316
+ forkLogger.error('Error forking session:', error)
317
+ await interaction.editReply(
318
+ `Failed to fork session: ${error instanceof Error ? error.message : 'Unknown error'}`,
319
+ )
320
+ }
321
+ }