@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,685 @@
1
+ import fs from 'node:fs'
2
+ import {
3
+ ChannelType,
4
+ ThreadAutoArchiveDuration,
5
+ type Client,
6
+ type TextChannel,
7
+ type ThreadChannel,
8
+ } from 'discord.js'
9
+ import type {
10
+ OpencodeClient,
11
+ Part,
12
+ } from '@opencode-ai/sdk/v2'
13
+ import {
14
+ getChannelVerbosity,
15
+ getPartMessageIds,
16
+ getThreadIdBySessionId,
17
+ getThreadSessionSource,
18
+ listTrackedTextChannels,
19
+ setPartMessagesBatch,
20
+ upsertThreadSession,
21
+ } from './database.js'
22
+ import { sendThreadMessage } from './discord-utils.js'
23
+ import { createLogger, LogPrefix } from './logger.js'
24
+ import {
25
+ formatPart,
26
+ collectSessionChunks,
27
+ batchChunksForDiscord,
28
+ type SessionChunk,
29
+ } from './message-formatting.js'
30
+ import {
31
+ initializeOpencodeForDirectory,
32
+ } from './opencode.js'
33
+ import { isEssentialToolPart } from './session-handler/thread-session-runtime.js'
34
+ import { notifyError } from './sentry.js'
35
+ import { extractNonXmlContent } from './xml.js'
36
+
37
+
38
+ const logger = createLogger(LogPrefix.OPENCODE)
39
+
40
+ const EXTERNAL_SYNC_INTERVAL_MS = 5_000
41
+ // Don't sync sessions from before the CLI started. 5 min grace window
42
+ // covers sessions that were just created before the bot connected.
43
+ const CLI_START_MS = Date.now() - 5 * 60 * 1000
44
+
45
+ type RenderableUserTextPart = {
46
+ id: string
47
+ text: string
48
+ }
49
+
50
+ type SessionMessagesResponse = Awaited<
51
+ ReturnType<OpencodeClient['session']['messages']>
52
+ >
53
+ type SessionMessage = NonNullable<SessionMessagesResponse['data']>[number]
54
+ type SessionMessageLike = {
55
+ info: {
56
+ role: string
57
+ }
58
+ parts: Part[]
59
+ }
60
+
61
+ type DiscordOriginMetadata = {
62
+ messageId?: string
63
+ username: string
64
+ threadId?: string
65
+ }
66
+
67
+ type TrackedTextChannelRow = Awaited<ReturnType<typeof listTrackedTextChannels>>[number]
68
+
69
+ type DirectorySyncTarget = {
70
+ directory: string
71
+ channelId: string
72
+ startMs: number
73
+ }
74
+
75
+ let externalSyncInterval: ReturnType<typeof setInterval> | null = null
76
+
77
+ function isSyntheticTextPart(part: Extract<Part, { type: 'text' }>): boolean {
78
+ const candidate = part as Extract<Part, { type: 'text' }> & {
79
+ synthetic?: unknown
80
+ }
81
+ return candidate.synthetic === true
82
+ }
83
+
84
+ function parseDiscordOriginMetadata(text: string): DiscordOriginMetadata | null {
85
+ const match = text.match(/<discord-user\s+([^>]+)\s*\/>/)
86
+ if (!match?.[1]) {
87
+ return null
88
+ }
89
+ const attrs = [...match[1].matchAll(/([a-z-]+)="([^"]*)"/g)].reduce(
90
+ (acc, current) => {
91
+ const [, key, value] = current
92
+ if (!key) {
93
+ return acc
94
+ }
95
+ acc[key] = value || ''
96
+ return acc
97
+ },
98
+ {} as Record<string, string>,
99
+ )
100
+ const username = attrs['name']
101
+ if (!username) {
102
+ return null
103
+ }
104
+ return {
105
+ messageId: attrs['message-id'] || undefined,
106
+ username,
107
+ threadId: attrs['thread-id'] || undefined,
108
+ }
109
+ }
110
+
111
+ function getDiscordOriginMetadataFromMessage({
112
+ message,
113
+ }: {
114
+ message: SessionMessageLike
115
+ }): DiscordOriginMetadata | null {
116
+ const textParts = message.parts.filter((p): p is Extract<typeof p, { type: 'text' }> => {
117
+ return p.type === 'text'
118
+ })
119
+ // Synthetic parts first (normal promptAsync path), then non-synthetic
120
+ // (session.command() path where the tag is embedded in arguments text).
121
+ const sorted = [
122
+ ...textParts.filter((p) => { return isSyntheticTextPart(p) }),
123
+ ...textParts.filter((p) => { return !isSyntheticTextPart(p) }),
124
+ ]
125
+ for (const part of sorted) {
126
+ const metadata = parseDiscordOriginMetadata(part.text || '')
127
+ if (metadata) {
128
+ return metadata
129
+ }
130
+ }
131
+ return null
132
+ }
133
+
134
+ function getRenderableUserTextParts({
135
+ message,
136
+ }: {
137
+ message: SessionMessageLike
138
+ }): RenderableUserTextPart[] {
139
+ if (message.info.role !== 'user') {
140
+ return []
141
+ }
142
+
143
+ return message.parts.flatMap((part) => {
144
+ if (part.type !== 'text') {
145
+ return [] as RenderableUserTextPart[]
146
+ }
147
+ if (isSyntheticTextPart(part)) {
148
+ return [] as RenderableUserTextPart[]
149
+ }
150
+ const cleanedText = extractNonXmlContent(part.text || '').trim()
151
+ if (!cleanedText) {
152
+ return [] as RenderableUserTextPart[]
153
+ }
154
+ return [{ id: part.id, text: cleanedText }]
155
+ })
156
+ }
157
+
158
+ function getExternalUserMirrorText({
159
+ username,
160
+ prompt,
161
+ }: {
162
+ username: string
163
+ prompt: string
164
+ }): string {
165
+ return `» **${username}:** ${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`
166
+ }
167
+
168
+ // Pure derivation: is the latest user turn from Discord?
169
+ // Checks the newest user message with renderable text for a <discord-user />
170
+ // synthetic part. If present, the session is currently driven from Discord
171
+ // (kimaki manages it) and external sync should skip it. If absent (CLI/TUI),
172
+ // external sync should mirror it — this naturally handles the "reclaim" case
173
+ // (external → discord → external) without any DB source toggling.
174
+ function isLatestUserTurnFromDiscord({
175
+ messages,
176
+ }: {
177
+ messages: SessionMessageLike[]
178
+ }): boolean {
179
+ for (let i = messages.length - 1; i >= 0; i--) {
180
+ const message = messages[i]!
181
+ if (message.info.role !== 'user') {
182
+ continue
183
+ }
184
+ const renderableParts = getRenderableUserTextParts({ message })
185
+ if (renderableParts.length === 0) {
186
+ continue
187
+ }
188
+ // Found the latest user message with actual text content.
189
+ // If it has <discord-user /> origin metadata, it came from Discord.
190
+ return getDiscordOriginMetadataFromMessage({ message }) !== null
191
+ }
192
+ // No user messages with text — treat as external (allow sync).
193
+ return false
194
+ }
195
+
196
+ function shouldMirrorAssistantPart({
197
+ part,
198
+ verbosity,
199
+ }: {
200
+ part: Part
201
+ verbosity: 'tools_and_text' | 'text_and_essential_tools' | 'text_only'
202
+ }): boolean {
203
+ if (verbosity === 'text_only') {
204
+ return part.type === 'text'
205
+ }
206
+ if (verbosity === 'text_and_essential_tools') {
207
+ if (part.type === 'text') {
208
+ return true
209
+ }
210
+ return isEssentialToolPart(part)
211
+ }
212
+ return true
213
+ }
214
+
215
+ function getSessionThreadName({
216
+ sessionTitle,
217
+ messages,
218
+ }: {
219
+ sessionTitle?: string | null
220
+ messages: SessionMessageLike[]
221
+ }): string {
222
+ const normalizedTitle = sessionTitle?.trim()
223
+ if (normalizedTitle) {
224
+ return normalizedTitle.slice(0, 100)
225
+ }
226
+ const firstUserMessage = messages.find((message) => {
227
+ return message.info.role === 'user'
228
+ })
229
+ const firstUserText = firstUserMessage
230
+ ? getRenderableUserTextParts({ message: firstUserMessage })
231
+ .map((part) => {
232
+ return part.text
233
+ })
234
+ .join(' ')
235
+ .trim()
236
+ : ''
237
+ if (firstUserText) {
238
+ return firstUserText.slice(0, 100)
239
+ }
240
+ return 'opencode session'
241
+ }
242
+
243
+ type SessionWithTime = { time: { created: number; updated: number } }
244
+
245
+ function getSessionRecencyTimestamp(session: SessionWithTime): number {
246
+ return session.time.updated || session.time.created || 0
247
+ }
248
+
249
+ function sortSessionsByRecency<T extends SessionWithTime>(sessions: T[]): T[] {
250
+ return [...sessions].sort((left, right) => {
251
+ return getSessionRecencyTimestamp(right) - getSessionRecencyTimestamp(left)
252
+ })
253
+ }
254
+
255
+ function groupTrackedChannelsByDirectory(
256
+ trackedChannels: TrackedTextChannelRow[],
257
+ ): DirectorySyncTarget[] {
258
+ const grouped = trackedChannels.reduce((acc, channel) => {
259
+ const existing = acc.get(channel.directory)
260
+ const createdAtMs = Math.max(channel.created_at?.getTime() || 0, CLI_START_MS)
261
+ if (!existing) {
262
+ acc.set(channel.directory, {
263
+ directory: channel.directory,
264
+ channelId: channel.channel_id,
265
+ startMs: createdAtMs,
266
+ })
267
+ return acc
268
+ }
269
+ if (createdAtMs < existing.startMs) {
270
+ acc.set(channel.directory, {
271
+ directory: channel.directory,
272
+ channelId: channel.channel_id,
273
+ startMs: createdAtMs,
274
+ })
275
+ }
276
+ return acc
277
+ }, new Map<string, DirectorySyncTarget>())
278
+ return [...grouped.values()]
279
+ }
280
+
281
+ async function ensureExternalSessionThread({
282
+ discordClient,
283
+ channelId,
284
+ sessionId,
285
+ sessionTitle,
286
+ messages,
287
+ }: {
288
+ discordClient: Client
289
+ channelId: string
290
+ sessionId: string
291
+ sessionTitle?: string | null
292
+ messages: SessionMessage[]
293
+ }): Promise<ThreadChannel | Error | null> {
294
+ const existingThreadId = await getThreadIdBySessionId(sessionId)
295
+ if (existingThreadId) {
296
+ // Caller already verified via isLatestUserTurnFromDiscord that this
297
+ // session should be synced. If the thread was kimaki-owned, flip it
298
+ // to external_poll so typing and future polls work naturally.
299
+ const existingSource = await getThreadSessionSource(existingThreadId)
300
+ if (existingSource === 'kimaki') {
301
+ await upsertThreadSession({
302
+ threadId: existingThreadId,
303
+ sessionId,
304
+ source: 'external_poll',
305
+ })
306
+ logger.log(`[EXTERNAL_SYNC] Reclaimed thread ${existingThreadId} for session ${sessionId} (user resumed from OpenCode)`)
307
+ }
308
+ const existingThread = await discordClient.channels.fetch(existingThreadId).catch((error) => {
309
+ return new Error(`Failed to fetch thread ${existingThreadId}`, {
310
+ cause: error,
311
+ })
312
+ })
313
+ if (!(existingThread instanceof Error) && existingThread?.isThread()) {
314
+ return existingThread
315
+ }
316
+ }
317
+
318
+ const parentChannel = await discordClient.channels.fetch(channelId).catch((error) => {
319
+ return new Error(`Failed to fetch parent channel ${channelId}`, {
320
+ cause: error,
321
+ })
322
+ })
323
+ if (parentChannel instanceof Error) {
324
+ return parentChannel
325
+ }
326
+ if (!parentChannel || parentChannel.type !== ChannelType.GuildText) {
327
+ return new Error(`Channel ${channelId} is not a text channel`)
328
+ }
329
+
330
+ const threadName = 'Sync: ' + getSessionThreadName({ sessionTitle, messages })
331
+ const thread = await (parentChannel as TextChannel).threads.create({
332
+ name: threadName.slice(0, 100),
333
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
334
+ reason: `Sync external OpenCode session ${sessionId}`,
335
+ }).catch((error) => {
336
+ return new Error(`Failed to create thread for session ${sessionId}`, {
337
+ cause: error,
338
+ })
339
+ })
340
+ if (thread instanceof Error) {
341
+ return thread
342
+ }
343
+
344
+ await upsertThreadSession({
345
+ threadId: thread.id,
346
+ sessionId,
347
+ source: 'external_poll',
348
+ })
349
+
350
+ return thread
351
+ }
352
+
353
+ type DirectPartMapping = { partId: string; messageId: string; threadId: string }
354
+
355
+ // Collect all unsynced parts from all messages into SessionChunks.
356
+ // User messages that originated from this Discord thread are returned as
357
+ // directMappings (persisted without sending a Discord message). All other
358
+ // user and assistant parts are returned as chunks to send.
359
+ function collectUnsyncedChunks({
360
+ messages,
361
+ syncedPartIds,
362
+ verbosity,
363
+ thread,
364
+ }: {
365
+ messages: SessionMessage[]
366
+ syncedPartIds: Set<string>
367
+ verbosity: 'tools_and_text' | 'text_and_essential_tools' | 'text_only'
368
+ thread: ThreadChannel
369
+ }): { chunks: SessionChunk[]; directMappings: DirectPartMapping[] } {
370
+ const chunks: SessionChunk[] = []
371
+ const directMappings: DirectPartMapping[] = []
372
+
373
+ for (const message of messages) {
374
+ if (message.info.role === 'user') {
375
+ const renderableParts = getRenderableUserTextParts({ message })
376
+ const unsyncedParts = renderableParts.filter((p) => {
377
+ return !syncedPartIds.has(p.id)
378
+ })
379
+ if (unsyncedParts.length === 0) {
380
+ continue
381
+ }
382
+ // If the user message came from this Discord thread, skip mirroring
383
+ // — it's already visible. When message-id is available, record a
384
+ // direct mapping for part dedup. When it's missing (sourceMessageId
385
+ // is optional in IngressInput), just mark parts as synced.
386
+ const discordOrigin = getDiscordOriginMetadataFromMessage({ message })
387
+ if (discordOrigin && (!discordOrigin.threadId || discordOrigin.threadId === thread.id)) {
388
+ unsyncedParts.forEach((part) => {
389
+ directMappings.push({
390
+ partId: part.id,
391
+ messageId: discordOrigin.messageId || '',
392
+ threadId: thread.id,
393
+ })
394
+ syncedPartIds.add(part.id)
395
+ })
396
+ continue
397
+ }
398
+ const promptText = unsyncedParts.map((p) => {
399
+ return p.text
400
+ }).join('\n\n')
401
+ chunks.push({
402
+ partIds: unsyncedParts.map((p) => {
403
+ return p.id
404
+ }),
405
+ content: getExternalUserMirrorText({ username: 'user', prompt: promptText }),
406
+ })
407
+ continue
408
+ }
409
+
410
+ if (message.info.role !== 'assistant') {
411
+ continue
412
+ }
413
+ // Filter assistant parts by verbosity before passing to shared collector
414
+ const filteredParts = message.parts.filter((part) => {
415
+ return shouldMirrorAssistantPart({ part, verbosity })
416
+ })
417
+ const { chunks: assistantChunks } = collectSessionChunks({
418
+ messages: [{ info: message.info, parts: filteredParts }],
419
+ skipPartIds: syncedPartIds,
420
+ })
421
+ // Mark empty-content parts as synced (collectSessionChunks skips them)
422
+ for (const part of filteredParts) {
423
+ if (!syncedPartIds.has(part.id)) {
424
+ const content = formatPart(part)
425
+ if (!content.trim()) {
426
+ syncedPartIds.add(part.id)
427
+ }
428
+ }
429
+ }
430
+ chunks.push(...assistantChunks)
431
+ }
432
+
433
+ return { chunks, directMappings }
434
+ }
435
+
436
+ async function syncSessionToThread({
437
+ client,
438
+ discordClient,
439
+ directory,
440
+ channelId,
441
+ sessionId,
442
+ sessionTitle,
443
+ }: {
444
+ client: OpencodeClient
445
+ discordClient: Client
446
+ directory: string
447
+ channelId: string
448
+ sessionId: string
449
+ sessionTitle?: string | null
450
+ }): Promise<void> {
451
+ const messagesResponse = await client.session.messages({
452
+ sessionID: sessionId,
453
+ directory,
454
+ }).catch((error) => {
455
+ return new Error(`Failed to fetch messages for session ${sessionId}`, {
456
+ cause: error,
457
+ })
458
+ })
459
+ if (messagesResponse instanceof Error) {
460
+ throw messagesResponse
461
+ }
462
+ const messages = messagesResponse.data || []
463
+
464
+ // Pure derivation from opencode events: if the latest user turn has
465
+ // <discord-user /> metadata, kimaki's thread runtime owns this session.
466
+ // Skip external sync entirely. When the user resumes from CLI/TUI the
467
+ // latest user turn will lack the tag, so sync picks it up naturally.
468
+ if (isLatestUserTurnFromDiscord({ messages })) {
469
+ return
470
+ }
471
+
472
+ const thread = await ensureExternalSessionThread({
473
+ discordClient,
474
+ channelId,
475
+ sessionId,
476
+ sessionTitle,
477
+ messages,
478
+ })
479
+ if (thread === null) {
480
+ return
481
+ }
482
+ if (thread instanceof Error) {
483
+ throw thread
484
+ }
485
+
486
+ const [existingPartIds, verbosity] = await Promise.all([
487
+ getPartMessageIds(thread.id),
488
+ getChannelVerbosity(thread.parentId || thread.id),
489
+ ])
490
+ const syncedPartIds = new Set(existingPartIds)
491
+
492
+ const { chunks, directMappings } = collectUnsyncedChunks({ messages, syncedPartIds, verbosity, thread })
493
+
494
+ // Persist mappings for user parts that originated from this Discord thread
495
+ if (directMappings.length > 0) {
496
+ await setPartMessagesBatch(directMappings)
497
+ }
498
+
499
+ const batched = batchChunksForDiscord(chunks)
500
+ for (const batch of batched) {
501
+ const sentMessage = await sendThreadMessage(thread, batch.content)
502
+ await setPartMessagesBatch(
503
+ batch.partIds.map((partId) => ({
504
+ partId,
505
+ messageId: sentMessage.id,
506
+ threadId: thread.id,
507
+ })),
508
+ )
509
+ }
510
+ }
511
+
512
+ // Pulse typing indicator for sessions that are currently busy.
513
+ // Takes the global session statuses map (already fetched) and sends
514
+ // typing to threads whose session is busy and still managed by external_poll.
515
+ async function pulseTypingForBusySessions({
516
+ discordClient,
517
+ statuses,
518
+ }: {
519
+ discordClient: Client
520
+ statuses: Record<string, { type: string }>
521
+ }): Promise<void> {
522
+ for (const [sessionId, status] of Object.entries(statuses)) {
523
+ if (status.type !== 'busy') {
524
+ continue
525
+ }
526
+ const threadId = await getThreadIdBySessionId(sessionId)
527
+ if (!threadId) {
528
+ continue
529
+ }
530
+ // Skip sessions already managed by the runtime (source='kimaki')
531
+ const source = await getThreadSessionSource(threadId)
532
+ if (source && source !== 'external_poll') {
533
+ continue
534
+ }
535
+ const thread = await discordClient.channels.fetch(threadId).catch(() => {
536
+ return null
537
+ })
538
+ if (thread?.isThread()) {
539
+ await thread.sendTyping().catch(() => {})
540
+ }
541
+ }
542
+ }
543
+
544
+ const EXTERNAL_SYNC_MAX_SESSIONS = 50
545
+
546
+ async function pollExternalSessions({
547
+ discordClient,
548
+ }: {
549
+ discordClient: Client
550
+ }): Promise<void> {
551
+ const trackedChannels = await listTrackedTextChannels()
552
+ const directoryTargets = groupTrackedChannelsByDirectory(trackedChannels)
553
+ .filter((t) => {
554
+ return fs.existsSync(t.directory)
555
+ })
556
+ if (directoryTargets.length === 0) {
557
+ return
558
+ }
559
+
560
+ for (const target of directoryTargets) {
561
+ const directory = target.directory
562
+ const channelId = target.channelId
563
+ const startMs = target.startMs
564
+
565
+ const clientResult = await initializeOpencodeForDirectory(directory, {
566
+ channelId,
567
+ })
568
+ if (clientResult instanceof Error) {
569
+ logger.warn(
570
+ `[EXTERNAL_SYNC] Failed to initialize OpenCode for ${directory}: ${clientResult.message}`,
571
+ )
572
+ continue
573
+ }
574
+
575
+ const client = clientResult()
576
+ const sessionsResponse = await client.session.list({
577
+ directory,
578
+ start: startMs,
579
+ limit: EXTERNAL_SYNC_MAX_SESSIONS,
580
+ }).catch((error) => {
581
+ return new Error(`Failed to list sessions for ${directory}`, {
582
+ cause: error,
583
+ })
584
+ })
585
+ if (sessionsResponse instanceof Error) {
586
+ logger.warn(`[EXTERNAL_SYNC] ${sessionsResponse.message}`)
587
+ continue
588
+ }
589
+
590
+ const statusesResponse = await client.session.status({
591
+ directory,
592
+ }).catch(() => {
593
+ return null
594
+ })
595
+ if (statusesResponse?.data) {
596
+ await pulseTypingForBusySessions({
597
+ discordClient,
598
+ statuses: statusesResponse.data as Record<string, { type: string }>,
599
+ }).catch(() => {})
600
+ }
601
+
602
+ const sessions = (sessionsResponse.data || []).filter((session) => {
603
+ const title = session.title || ''
604
+ if (/^new session\s*-/i.test(title)) {
605
+ return false
606
+ }
607
+ return !/subagent\)\s*$/i.test(title)
608
+ })
609
+ const sorted = sortSessionsByRecency(sessions)
610
+
611
+ for (const session of sorted) {
612
+ await syncSessionToThread({
613
+ client,
614
+ discordClient,
615
+ directory,
616
+ channelId,
617
+ sessionId: session.id,
618
+ sessionTitle: session.title,
619
+ }).catch((error) => {
620
+ logger.warn(
621
+ `[EXTERNAL_SYNC] Failed syncing session ${session.id}: ${error instanceof Error ? error.message : String(error)}`,
622
+ )
623
+ void notifyError(
624
+ error instanceof Error ? error : new Error(String(error)),
625
+ `External session sync failed for ${session.id}`,
626
+ )
627
+ })
628
+ }
629
+ }
630
+ }
631
+
632
+ export function startExternalOpencodeSessionSync({
633
+ discordClient,
634
+ }: {
635
+ discordClient: Client
636
+ }): void {
637
+ if (
638
+ process.env.KIMAKI_VITEST &&
639
+ process.env.KIMAKI_ENABLE_EXTERNAL_OPENCODE_SYNC !== '1'
640
+ ) {
641
+ return
642
+ }
643
+ if (externalSyncInterval) {
644
+ return
645
+ }
646
+
647
+ let polling = false
648
+ const runPoll = async (): Promise<void> => {
649
+ if (polling) {
650
+ return
651
+ }
652
+ polling = true
653
+ const result = await pollExternalSessions({ discordClient }).catch(
654
+ (e) => new Error('External session poll failed', { cause: e }),
655
+ )
656
+ polling = false
657
+ if (result instanceof Error) {
658
+ logger.warn(`[EXTERNAL_SYNC] ${result.message}`)
659
+ void notifyError(result, 'External session poll top-level failure')
660
+ }
661
+ }
662
+
663
+ void runPoll()
664
+ externalSyncInterval = setInterval(() => {
665
+ void runPoll()
666
+ }, EXTERNAL_SYNC_INTERVAL_MS)
667
+ }
668
+
669
+ export function stopExternalOpencodeSessionSync(): void {
670
+ if (!externalSyncInterval) {
671
+ return
672
+ }
673
+ clearInterval(externalSyncInterval)
674
+ externalSyncInterval = null
675
+ }
676
+
677
+ export const externalOpencodeSyncInternals = {
678
+ getRenderableUserTextParts,
679
+ getSessionThreadName,
680
+ groupTrackedChannelsByDirectory,
681
+ sortSessionsByRecency,
682
+ parseDiscordOriginMetadata,
683
+ getDiscordOriginMetadataFromMessage,
684
+ isLatestUserTurnFromDiscord,
685
+ }