@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,370 @@
1
+ import fs from 'node:fs';
2
+ import http from 'node:http';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { describe, test, expect, afterAll } from 'vitest';
7
+ import Database from 'libsql';
8
+ import { PrismaLibSql } from '@prisma/adapter-libsql';
9
+ import { PrismaClient } from './generated/client.js';
10
+ import { createLibsqlHandler, createLibsqlNodeHandler, libsqlExecutor, } from 'libsqlproxy';
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+ async function migrateSchema(prisma) {
14
+ const schemaPath = path.join(__dirname, '../src/schema.sql');
15
+ const sql = fs.readFileSync(schemaPath, 'utf-8');
16
+ const statements = sql
17
+ .split(';')
18
+ .map((s) => s
19
+ .split('\n')
20
+ .filter((line) => !line.trimStart().startsWith('--'))
21
+ .join('\n')
22
+ .trim())
23
+ .filter((s) => s.length > 0 &&
24
+ !/^CREATE\s+TABLE\s+["']?sqlite_sequence["']?\s*\(/i.test(s))
25
+ .map((s) => s
26
+ .replace(/^CREATE\s+UNIQUE\s+INDEX\b(?!\s+IF)/i, 'CREATE UNIQUE INDEX IF NOT EXISTS')
27
+ .replace(/^CREATE\s+INDEX\b(?!\s+IF)/i, 'CREATE INDEX IF NOT EXISTS'));
28
+ for (const statement of statements) {
29
+ await prisma.$executeRawUnsafe(statement);
30
+ }
31
+ }
32
+ describe('hrana-server', () => {
33
+ let testServer = null;
34
+ let testDb = null;
35
+ let prisma = null;
36
+ const dbPath = path.join(process.cwd(), `tmp/test-hrana-${crypto.randomUUID().slice(0, 8)}.db`);
37
+ afterAll(async () => {
38
+ if (prisma)
39
+ await prisma.$disconnect();
40
+ if (testServer)
41
+ await new Promise((resolve) => {
42
+ testServer.close(() => {
43
+ resolve();
44
+ });
45
+ });
46
+ if (testDb)
47
+ testDb.close();
48
+ try {
49
+ fs.unlinkSync(dbPath);
50
+ }
51
+ catch (e) {
52
+ console.warn('cleanup:', dbPath, e.message);
53
+ }
54
+ try {
55
+ fs.unlinkSync(dbPath + '-wal');
56
+ }
57
+ catch (e) {
58
+ console.warn('cleanup:', dbPath + '-wal', e.message);
59
+ }
60
+ try {
61
+ fs.unlinkSync(dbPath + '-shm');
62
+ }
63
+ catch (e) {
64
+ console.warn('cleanup:', dbPath + '-shm', e.message);
65
+ }
66
+ });
67
+ test('prisma CRUD through hrana server', async () => {
68
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
69
+ const database = new Database(dbPath);
70
+ database.exec('PRAGMA journal_mode = WAL');
71
+ database.exec('PRAGMA busy_timeout = 5000');
72
+ testDb = database;
73
+ const port = 10000 + Math.floor(Math.random() * 50000);
74
+ await new Promise((resolve, reject) => {
75
+ const hranaFetchHandler = createLibsqlHandler(libsqlExecutor(database));
76
+ const hranaNodeHandler = createLibsqlNodeHandler(hranaFetchHandler);
77
+ const srv = http.createServer(hranaNodeHandler);
78
+ srv.on('error', reject);
79
+ srv.listen(port, '127.0.0.1', () => {
80
+ testServer = srv;
81
+ resolve();
82
+ });
83
+ });
84
+ const adapter = new PrismaLibSql({ url: `http://127.0.0.1:${port}` });
85
+ prisma = new PrismaClient({ adapter });
86
+ await migrateSchema(prisma);
87
+ // Create
88
+ const created = await prisma.thread_sessions.create({
89
+ data: {
90
+ thread_id: 'hrana-test-thread',
91
+ session_id: 'hrana-test-session',
92
+ },
93
+ });
94
+ expect(created.thread_id).toMatchInlineSnapshot(`"hrana-test-thread"`);
95
+ expect(created.session_id).toMatchInlineSnapshot(`"hrana-test-session"`);
96
+ // Read
97
+ const found = await prisma.thread_sessions.findUnique({
98
+ where: { thread_id: 'hrana-test-thread' },
99
+ });
100
+ expect(found?.session_id).toMatchInlineSnapshot(`"hrana-test-session"`);
101
+ // Update
102
+ await prisma.thread_sessions.update({
103
+ where: { thread_id: 'hrana-test-thread' },
104
+ data: { session_id: 'updated-session' },
105
+ });
106
+ const updated = await prisma.thread_sessions.findUnique({
107
+ where: { thread_id: 'hrana-test-thread' },
108
+ });
109
+ expect(updated?.session_id).toMatchInlineSnapshot(`"updated-session"`);
110
+ // Delete
111
+ await prisma.thread_sessions.delete({
112
+ where: { thread_id: 'hrana-test-thread' },
113
+ });
114
+ const deleted = await prisma.thread_sessions.findUnique({
115
+ where: { thread_id: 'hrana-test-thread' },
116
+ });
117
+ expect(deleted).toBeNull();
118
+ }, 30_000);
119
+ test('$executeRawUnsafe works for PRAGMAs', async () => {
120
+ if (!prisma)
121
+ throw new Error('prisma not initialized');
122
+ const result = await prisma.$executeRawUnsafe('PRAGMA journal_mode');
123
+ expect(typeof result).toBe('number');
124
+ });
125
+ test('batch transaction via Prisma $transaction', async () => {
126
+ if (!prisma)
127
+ throw new Error('prisma not initialized');
128
+ const [s1, s2] = await prisma.$transaction([
129
+ prisma.thread_sessions.create({
130
+ data: { thread_id: 'batch-1', session_id: 'sess-1' },
131
+ }),
132
+ prisma.thread_sessions.create({
133
+ data: { thread_id: 'batch-2', session_id: 'sess-2' },
134
+ }),
135
+ ]);
136
+ expect(s1.thread_id).toMatchInlineSnapshot(`"batch-1"`);
137
+ expect(s2.thread_id).toMatchInlineSnapshot(`"batch-2"`);
138
+ const count = await prisma.thread_sessions.count({
139
+ where: { thread_id: { in: ['batch-1', 'batch-2'] } },
140
+ });
141
+ expect(count).toBe(2);
142
+ await prisma.thread_sessions.deleteMany({
143
+ where: { thread_id: { in: ['batch-1', 'batch-2'] } },
144
+ });
145
+ }, 30_000);
146
+ test('schema migration DDL via $executeRawUnsafe', async () => {
147
+ if (!prisma)
148
+ throw new Error('prisma not initialized');
149
+ // CREATE TABLE IF NOT EXISTS is idempotent — running migrateSchema again
150
+ // should not throw even though tables already exist.
151
+ await migrateSchema(prisma);
152
+ // Verify DDL actually created the tables by querying sqlite_master
153
+ const tables = await prisma.$queryRawUnsafe(`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`);
154
+ const tableNames = tables.map((t) => t.name);
155
+ expect(tableNames).toContain('thread_sessions');
156
+ expect(tableNames).toContain('ipc_requests');
157
+ expect(tableNames).toContain('scheduled_tasks');
158
+ // Also verify indexes were created
159
+ const indexes = await prisma.$queryRawUnsafe(`SELECT name FROM sqlite_master WHERE type='index' AND name LIKE '%idx%' ORDER BY name`);
160
+ const indexNames = indexes.map((i) => i.name);
161
+ expect(indexNames).toContain('ipc_requests_status_created_at_idx');
162
+ expect(indexNames).toContain('scheduled_tasks_status_next_run_at_idx');
163
+ // Test CREATE INDEX IF NOT EXISTS is also idempotent
164
+ await prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS "ipc_requests_status_created_at_idx" ON "ipc_requests"("status", "created_at")`);
165
+ });
166
+ test('concurrent queries via Promise.all', async () => {
167
+ if (!prisma)
168
+ throw new Error('prisma not initialized');
169
+ // Seed some data for concurrent reads
170
+ const threads = Array.from({ length: 5 }, (_, i) => ({
171
+ thread_id: `concurrent-${i}`,
172
+ session_id: `sess-concurrent-${i}`,
173
+ }));
174
+ for (const t of threads) {
175
+ await prisma.thread_sessions.create({ data: t });
176
+ }
177
+ // Simulate kimaki's pattern of parallel Prisma queries
178
+ const [allThreads, count, single, filtered] = await Promise.all([
179
+ prisma.thread_sessions.findMany({
180
+ where: { thread_id: { startsWith: 'concurrent-' } },
181
+ orderBy: { thread_id: 'asc' },
182
+ }),
183
+ prisma.thread_sessions.count({
184
+ where: { thread_id: { startsWith: 'concurrent-' } },
185
+ }),
186
+ prisma.thread_sessions.findUnique({
187
+ where: { thread_id: 'concurrent-2' },
188
+ }),
189
+ prisma.thread_sessions.findMany({
190
+ where: { thread_id: { in: ['concurrent-0', 'concurrent-4'] } },
191
+ orderBy: { thread_id: 'asc' },
192
+ }),
193
+ ]);
194
+ expect(allThreads.length).toBe(5);
195
+ expect(count).toBe(5);
196
+ expect(single?.session_id).toMatchInlineSnapshot(`"sess-concurrent-2"`);
197
+ expect(filtered.map((f) => f.thread_id)).toMatchInlineSnapshot(`
198
+ [
199
+ "concurrent-0",
200
+ "concurrent-4",
201
+ ]
202
+ `);
203
+ // Cleanup
204
+ await prisma.thread_sessions.deleteMany({
205
+ where: { thread_id: { startsWith: 'concurrent-' } },
206
+ });
207
+ }, 30_000);
208
+ test('$queryRawUnsafe for PRAGMAs that return values', async () => {
209
+ if (!prisma)
210
+ throw new Error('prisma not initialized');
211
+ // PRAGMA that returns a value — journal_mode should be WAL
212
+ const journalMode = await prisma.$queryRawUnsafe('PRAGMA journal_mode');
213
+ expect(journalMode[0]?.journal_mode).toMatchInlineSnapshot(`"wal"`);
214
+ // PRAGMA busy_timeout returns the current timeout value
215
+ const busyTimeout = await prisma.$queryRawUnsafe('PRAGMA busy_timeout');
216
+ expect(busyTimeout[0]?.busy_timeout).toMatchInlineSnapshot(`undefined`);
217
+ // PRAGMA table_info returns column metadata
218
+ const tableInfo = await prisma.$queryRawUnsafe(`PRAGMA table_info('ipc_requests')`);
219
+ const colNames = tableInfo.map((c) => c.name);
220
+ expect(colNames).toMatchInlineSnapshot(`
221
+ [
222
+ "id",
223
+ "type",
224
+ "session_id",
225
+ "thread_id",
226
+ "payload",
227
+ "response",
228
+ "status",
229
+ "created_at",
230
+ "updated_at",
231
+ ]
232
+ `);
233
+ });
234
+ test('updateMany with complex WHERE using in operator', async () => {
235
+ if (!prisma)
236
+ throw new Error('prisma not initialized');
237
+ // Seed: create a thread + multiple IPC requests in different statuses
238
+ // (mirrors kimaki's cancelAllPendingIpcRequests pattern)
239
+ await prisma.thread_sessions.create({
240
+ data: { thread_id: 'ipc-test-thread', session_id: 'ipc-test-session' },
241
+ });
242
+ const statuses = ['pending', 'pending', 'processing', 'completed'];
243
+ for (let i = 0; i < statuses.length; i++) {
244
+ await prisma.ipc_requests.create({
245
+ data: {
246
+ id: `ipc-req-${i}`,
247
+ type: 'file_upload',
248
+ session_id: 'ipc-test-session',
249
+ thread_id: 'ipc-test-thread',
250
+ payload: JSON.stringify({ prompt: `test-${i}` }),
251
+ status: statuses[i],
252
+ },
253
+ });
254
+ }
255
+ // updateMany with WHERE status IN ['pending', 'processing']
256
+ const result = await prisma.ipc_requests.updateMany({
257
+ where: { status: { in: ['pending', 'processing'] } },
258
+ data: {
259
+ status: 'cancelled',
260
+ response: JSON.stringify({ error: 'Bot shutting down' }),
261
+ },
262
+ });
263
+ expect(result.count).toBe(3);
264
+ // Verify: only 'completed' row is untouched
265
+ const remaining = await prisma.ipc_requests.findMany({
266
+ where: { thread_id: 'ipc-test-thread' },
267
+ orderBy: { id: 'asc' },
268
+ select: { id: true, status: true },
269
+ });
270
+ expect(remaining).toMatchInlineSnapshot(`
271
+ [
272
+ {
273
+ "id": "ipc-req-0",
274
+ "status": "cancelled",
275
+ },
276
+ {
277
+ "id": "ipc-req-1",
278
+ "status": "cancelled",
279
+ },
280
+ {
281
+ "id": "ipc-req-2",
282
+ "status": "cancelled",
283
+ },
284
+ {
285
+ "id": "ipc-req-3",
286
+ "status": "completed",
287
+ },
288
+ ]
289
+ `);
290
+ // Cleanup
291
+ await prisma.ipc_requests.deleteMany({
292
+ where: { thread_id: 'ipc-test-thread' },
293
+ });
294
+ await prisma.thread_sessions.delete({
295
+ where: { thread_id: 'ipc-test-thread' },
296
+ });
297
+ }, 30_000);
298
+ test('interactive $transaction (callback form)', async () => {
299
+ if (!prisma)
300
+ throw new Error('prisma not initialized');
301
+ // Interactive transaction: reads and writes within the same tx callback.
302
+ // This exercises BEGIN/queries/COMMIT across multiple hrana pipeline
303
+ // requests with batons (stream continuity).
304
+ const result = await prisma.$transaction(async (tx) => {
305
+ await tx.thread_sessions.create({
306
+ data: { thread_id: 'tx-interactive-1', session_id: 'sess-tx-1' },
307
+ });
308
+ await tx.thread_sessions.create({
309
+ data: { thread_id: 'tx-interactive-2', session_id: 'sess-tx-2' },
310
+ });
311
+ // Read inside the same transaction — should see uncommitted rows
312
+ const count = await tx.thread_sessions.count({
313
+ where: { thread_id: { startsWith: 'tx-interactive-' } },
314
+ });
315
+ // Conditional write based on read
316
+ if (count === 2) {
317
+ await tx.thread_sessions.update({
318
+ where: { thread_id: 'tx-interactive-1' },
319
+ data: { session_id: 'sess-tx-1-updated' },
320
+ });
321
+ }
322
+ return tx.thread_sessions.findMany({
323
+ where: { thread_id: { startsWith: 'tx-interactive-' } },
324
+ orderBy: { thread_id: 'asc' },
325
+ select: { thread_id: true, session_id: true },
326
+ });
327
+ });
328
+ expect(result).toMatchInlineSnapshot(`
329
+ [
330
+ {
331
+ "session_id": "sess-tx-1-updated",
332
+ "thread_id": "tx-interactive-1",
333
+ },
334
+ {
335
+ "session_id": "sess-tx-2",
336
+ "thread_id": "tx-interactive-2",
337
+ },
338
+ ]
339
+ `);
340
+ // Verify committed outside transaction
341
+ const outside = await prisma.thread_sessions.count({
342
+ where: { thread_id: { startsWith: 'tx-interactive-' } },
343
+ });
344
+ expect(outside).toBe(2);
345
+ // Cleanup
346
+ await prisma.thread_sessions.deleteMany({
347
+ where: { thread_id: { startsWith: 'tx-interactive-' } },
348
+ });
349
+ }, 30_000);
350
+ test('interactive $transaction rolls back on error', async () => {
351
+ if (!prisma)
352
+ throw new Error('prisma not initialized');
353
+ // Verify rollback: if the callback throws, no rows should be committed
354
+ const txError = await prisma
355
+ .$transaction(async (tx) => {
356
+ await tx.thread_sessions.create({
357
+ data: { thread_id: 'tx-rollback-1', session_id: 'sess-rollback' },
358
+ });
359
+ throw new Error('intentional rollback');
360
+ })
361
+ .catch((e) => e);
362
+ expect(txError).toBeInstanceOf(Error);
363
+ expect(txError.message).toContain('intentional rollback');
364
+ // Row should NOT exist — transaction was rolled back
365
+ const ghost = await prisma.thread_sessions.findUnique({
366
+ where: { thread_id: 'tx-rollback-1' },
367
+ });
368
+ expect(ghost).toBeNull();
369
+ }, 30_000);
370
+ });
@@ -0,0 +1,123 @@
1
+ // HTML action registry for rendered Discord components.
2
+ // Stores short-lived button callbacks by generated id so HTML-backed UI can
3
+ // attach interactions without leaking closures across rerenders.
4
+ import crypto from 'node:crypto';
5
+ import { ComponentType, MessageFlags, } from 'discord.js';
6
+ import { createLogger } from './logger.js';
7
+ import { notifyError } from './sentry.js';
8
+ const logger = createLogger('HTML_ACT');
9
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
10
+ export const pendingHtmlActions = new Map();
11
+ const actionIdsByOwner = new Map();
12
+ export function buildHtmlActionCustomId(actionId) {
13
+ return `html_action:${actionId}`;
14
+ }
15
+ export function registerHtmlAction({ ownerKey, threadId, run, ttlMs = DEFAULT_TTL_MS, }) {
16
+ const actionId = crypto.randomBytes(8).toString('hex');
17
+ const timer = setTimeout(() => {
18
+ resolveHtmlAction({ actionId });
19
+ }, ttlMs);
20
+ pendingHtmlActions.set(actionId, {
21
+ actionId,
22
+ ownerKey,
23
+ threadId,
24
+ resolved: false,
25
+ timer,
26
+ run,
27
+ });
28
+ const ownerActionIds = actionIdsByOwner.get(ownerKey) ?? new Set();
29
+ ownerActionIds.add(actionId);
30
+ actionIdsByOwner.set(ownerKey, ownerActionIds);
31
+ return actionId;
32
+ }
33
+ export function cancelHtmlActionsForOwner(ownerKey) {
34
+ const actionIds = actionIdsByOwner.get(ownerKey);
35
+ if (!actionIds) {
36
+ return 0;
37
+ }
38
+ let cancelled = 0;
39
+ for (const actionId of actionIds) {
40
+ const resolved = resolveHtmlAction({ actionId });
41
+ if (!resolved) {
42
+ continue;
43
+ }
44
+ cancelled++;
45
+ }
46
+ return cancelled;
47
+ }
48
+ export function cancelHtmlActionsForThread(threadId) {
49
+ let cancelled = 0;
50
+ for (const [actionId, action] of pendingHtmlActions) {
51
+ if (action.threadId !== threadId) {
52
+ continue;
53
+ }
54
+ const resolved = resolveHtmlAction({ actionId });
55
+ if (!resolved) {
56
+ continue;
57
+ }
58
+ cancelled++;
59
+ }
60
+ return cancelled;
61
+ }
62
+ export async function handleHtmlActionButton(interaction) {
63
+ const customId = interaction.customId;
64
+ if (!customId.startsWith('html_action:')) {
65
+ return;
66
+ }
67
+ const actionId = customId.slice('html_action:'.length);
68
+ if (!actionId) {
69
+ await interaction.reply({
70
+ content: 'Invalid action button.',
71
+ flags: MessageFlags.Ephemeral,
72
+ });
73
+ return;
74
+ }
75
+ const action = pendingHtmlActions.get(actionId);
76
+ if (!action || action.resolved) {
77
+ await interaction.reply({
78
+ content: 'This action is no longer available.',
79
+ flags: MessageFlags.Ephemeral,
80
+ });
81
+ return;
82
+ }
83
+ await interaction.deferUpdate();
84
+ const resolvedAction = resolveHtmlAction({ actionId });
85
+ if (!resolvedAction) {
86
+ return;
87
+ }
88
+ try {
89
+ await resolvedAction.run({ interaction });
90
+ }
91
+ catch (error) {
92
+ logger.error('[HTML_ACTION] Failed to run action:', error);
93
+ void notifyError(error, 'HTML action button failed');
94
+ await interaction
95
+ .editReply({
96
+ components: [
97
+ {
98
+ type: ComponentType.TextDisplay,
99
+ content: `Action failed: ${error instanceof Error ? error.message : String(error)}`,
100
+ },
101
+ ],
102
+ flags: MessageFlags.IsComponentsV2,
103
+ })
104
+ .catch(() => {
105
+ return undefined;
106
+ });
107
+ }
108
+ }
109
+ function resolveHtmlAction({ actionId, }) {
110
+ const action = pendingHtmlActions.get(actionId);
111
+ if (!action || action.resolved) {
112
+ return undefined;
113
+ }
114
+ action.resolved = true;
115
+ clearTimeout(action.timer);
116
+ pendingHtmlActions.delete(actionId);
117
+ const ownerActionIds = actionIdsByOwner.get(action.ownerKey);
118
+ ownerActionIds?.delete(actionId);
119
+ if (ownerActionIds && ownerActionIds.size === 0) {
120
+ actionIdsByOwner.delete(action.ownerKey);
121
+ }
122
+ return action;
123
+ }
@@ -0,0 +1,70 @@
1
+ import { afterEach, describe, expect, test } from 'vitest';
2
+ import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, cancelHtmlActionsForThread, pendingHtmlActions, registerHtmlAction, } from './html-actions.js';
3
+ const TEST_OWNER_A = 'worktrees:user-a:channel-a';
4
+ const TEST_OWNER_B = 'worktrees:user-b:channel-a';
5
+ afterEach(() => {
6
+ cancelHtmlActionsForOwner(TEST_OWNER_A);
7
+ cancelHtmlActionsForOwner(TEST_OWNER_B);
8
+ });
9
+ describe('html action registry', () => {
10
+ test('registers action ids with expected custom id prefix', () => {
11
+ const actionId = registerHtmlAction({
12
+ ownerKey: TEST_OWNER_A,
13
+ run: async () => {
14
+ return undefined;
15
+ },
16
+ });
17
+ expect(buildHtmlActionCustomId(actionId)).toMatch(/^html_action:/);
18
+ expect(pendingHtmlActions.has(actionId)).toBe(true);
19
+ });
20
+ test('cancels actions by owner', () => {
21
+ registerHtmlAction({
22
+ ownerKey: TEST_OWNER_A,
23
+ run: async () => {
24
+ return undefined;
25
+ },
26
+ });
27
+ registerHtmlAction({
28
+ ownerKey: TEST_OWNER_A,
29
+ run: async () => {
30
+ return undefined;
31
+ },
32
+ });
33
+ expect(cancelHtmlActionsForOwner(TEST_OWNER_A)).toBe(2);
34
+ expect(pendingHtmlActions.size).toBe(0);
35
+ });
36
+ test('cancels only actions from the matching thread', () => {
37
+ const threadAActionId = registerHtmlAction({
38
+ ownerKey: TEST_OWNER_A,
39
+ threadId: 'thread-a',
40
+ run: async () => {
41
+ return undefined;
42
+ },
43
+ });
44
+ const threadBActionId = registerHtmlAction({
45
+ ownerKey: TEST_OWNER_B,
46
+ threadId: 'thread-b',
47
+ run: async () => {
48
+ return undefined;
49
+ },
50
+ });
51
+ expect(cancelHtmlActionsForThread('thread-a')).toBe(1);
52
+ expect(pendingHtmlActions.has(threadAActionId)).toBe(false);
53
+ expect(pendingHtmlActions.has(threadBActionId)).toBe(true);
54
+ });
55
+ test('expires actions after ttl', async () => {
56
+ const actionId = registerHtmlAction({
57
+ ownerKey: TEST_OWNER_A,
58
+ ttlMs: 10,
59
+ run: async () => {
60
+ return undefined;
61
+ },
62
+ });
63
+ await new Promise((resolve) => {
64
+ setTimeout(() => {
65
+ resolve();
66
+ }, 30);
67
+ });
68
+ expect(pendingHtmlActions.has(actionId)).toBe(false);
69
+ });
70
+ });
@@ -0,0 +1,117 @@
1
+ // HTML fragment parser for Discord-renderable components.
2
+ // Supports a small reusable subset today (text + button) so tables and other
3
+ // CV2 renderers can map inline HTML into Discord UI elements.
4
+ import { DomHandler, ElementType, Parser } from 'htmlparser2';
5
+ export function parseInlineHtmlRenderables({ html, }) {
6
+ let parseError;
7
+ let domNodes = [];
8
+ const handler = new DomHandler((error, dom) => {
9
+ if (error) {
10
+ parseError = new Error('Failed to parse HTML fragment', {
11
+ cause: error,
12
+ });
13
+ return;
14
+ }
15
+ domNodes = dom;
16
+ }, {
17
+ withStartIndices: false,
18
+ withEndIndices: false,
19
+ });
20
+ const parser = new Parser(handler, {
21
+ xmlMode: false,
22
+ decodeEntities: false,
23
+ recognizeSelfClosing: true,
24
+ });
25
+ parser.write(html);
26
+ parser.end();
27
+ if (parseError) {
28
+ return parseError;
29
+ }
30
+ return parseRenderableNodes({ nodes: domNodes });
31
+ }
32
+ function parseRenderableNodes({ nodes, }) {
33
+ const renderables = [];
34
+ for (const node of nodes) {
35
+ if (node.type === ElementType.Text) {
36
+ const textNode = node;
37
+ renderables.push({
38
+ type: 'text',
39
+ text: textNode.data,
40
+ });
41
+ continue;
42
+ }
43
+ if (node.type === ElementType.Tag) {
44
+ const element = node;
45
+ if (element.name !== 'button') {
46
+ return new Error(`Unsupported HTML tag: <${element.name}>`);
47
+ }
48
+ const buttonRenderable = parseButtonElement({ element });
49
+ if (buttonRenderable instanceof Error) {
50
+ return buttonRenderable;
51
+ }
52
+ renderables.push(buttonRenderable);
53
+ continue;
54
+ }
55
+ if (node.type === ElementType.Comment) {
56
+ continue;
57
+ }
58
+ return new Error(`Unsupported HTML node type: ${node.type}`);
59
+ }
60
+ return renderables;
61
+ }
62
+ function parseButtonElement({ element, }) {
63
+ const id = element.attribs.id?.trim();
64
+ if (!id) {
65
+ return new Error('<button> is missing required id attribute');
66
+ }
67
+ const label = extractNodeText({ nodes: element.children })
68
+ .replace(/\s+/g, ' ')
69
+ .trim();
70
+ if (!label) {
71
+ return new Error(`<button id="${id}"> is missing label text`);
72
+ }
73
+ const variant = normalizeButtonVariant({
74
+ value: element.attribs.variant,
75
+ });
76
+ if (variant instanceof Error) {
77
+ return variant;
78
+ }
79
+ return {
80
+ type: 'button',
81
+ id,
82
+ label,
83
+ variant,
84
+ disabled: 'disabled' in element.attribs,
85
+ };
86
+ }
87
+ function normalizeButtonVariant({ value, }) {
88
+ if (!value) {
89
+ return 'secondary';
90
+ }
91
+ if (value === 'secondary') {
92
+ return value;
93
+ }
94
+ if (value === 'primary') {
95
+ return value;
96
+ }
97
+ if (value === 'success') {
98
+ return value;
99
+ }
100
+ if (value === 'danger') {
101
+ return value;
102
+ }
103
+ return new Error(`Unsupported <button> variant: ${value}`);
104
+ }
105
+ function extractNodeText({ nodes, }) {
106
+ const parts = [];
107
+ for (const node of nodes) {
108
+ if (node.type === ElementType.Text) {
109
+ parts.push(node.data);
110
+ continue;
111
+ }
112
+ if (node.type === ElementType.Tag) {
113
+ parts.push(extractNodeText({ nodes: node.children }));
114
+ }
115
+ }
116
+ return parts.join('');
117
+ }