octo-agent 0.11.2

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 (319) hide show
  1. checksums.yaml +7 -0
  2. data/.clacky/skills/commit/SKILL.md +423 -0
  3. data/.clacky/skills/gem-release/SKILL.md +199 -0
  4. data/.clacky/skills/gem-release/scripts/release.sh +304 -0
  5. data/.clacky/skills/oss-upload/SKILL.md +47 -0
  6. data/.octorules +106 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +8 -0
  9. data/CHANGELOG.md +76 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/CONTRIBUTING.md +92 -0
  12. data/Dockerfile +28 -0
  13. data/LICENSE.txt +22 -0
  14. data/POSITIONING.md +46 -0
  15. data/README.md +134 -0
  16. data/README_CN.md +134 -0
  17. data/Rakefile +34 -0
  18. data/benchmark/fixtures/sample_project/Gemfile +3 -0
  19. data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
  20. data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
  21. data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
  22. data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
  23. data/benchmark/results/EVALUATION_REPORT.md +165 -0
  24. data/benchmark/results/baseline_20260511_174424.json +128 -0
  25. data/benchmark/results/report_20260511_175256.json +271 -0
  26. data/benchmark/results/report_20260511_175444.json +271 -0
  27. data/benchmark/results/treatment_20260511_175103.json +130 -0
  28. data/benchmark/runner.rb +441 -0
  29. data/bin/octo +7 -0
  30. data/docs/agent-first-ui-design.md +77 -0
  31. data/docs/billing-system.md +318 -0
  32. data/docs/channel-architecture.md +235 -0
  33. data/docs/engineering-article.md +343 -0
  34. data/docs/session-skill-invocation.md +69 -0
  35. data/docs/time_machine_design.md +247 -0
  36. data/docs/ui2-architecture.md +124 -0
  37. data/homebrew/README.md +96 -0
  38. data/homebrew/openocto.rb +24 -0
  39. data/lib/octo/agent/hook_manager.rb +61 -0
  40. data/lib/octo/agent/llm_caller.rb +800 -0
  41. data/lib/octo/agent/memory_updater.rb +246 -0
  42. data/lib/octo/agent/message_compressor.rb +225 -0
  43. data/lib/octo/agent/message_compressor_helper.rb +869 -0
  44. data/lib/octo/agent/next_message_suggester.rb +215 -0
  45. data/lib/octo/agent/session_serializer.rb +685 -0
  46. data/lib/octo/agent/skill_auto_creator.rb +114 -0
  47. data/lib/octo/agent/skill_evolution.rb +61 -0
  48. data/lib/octo/agent/skill_manager.rb +466 -0
  49. data/lib/octo/agent/skill_reflector.rb +89 -0
  50. data/lib/octo/agent/system_prompt_builder.rb +101 -0
  51. data/lib/octo/agent/time_machine.rb +214 -0
  52. data/lib/octo/agent/tool_executor.rb +454 -0
  53. data/lib/octo/agent/tool_registry.rb +150 -0
  54. data/lib/octo/agent.rb +2180 -0
  55. data/lib/octo/agent_config.rb +989 -0
  56. data/lib/octo/agent_profile.rb +112 -0
  57. data/lib/octo/anthropic_stream_aggregator.rb +137 -0
  58. data/lib/octo/background_task_registry.rb +324 -0
  59. data/lib/octo/banner.rb +34 -0
  60. data/lib/octo/bedrock_stream_aggregator.rb +137 -0
  61. data/lib/octo/block_font.rb +331 -0
  62. data/lib/octo/cli.rb +968 -0
  63. data/lib/octo/client.rb +623 -0
  64. data/lib/octo/default_agents/SOUL.md +3 -0
  65. data/lib/octo/default_agents/USER.md +1 -0
  66. data/lib/octo/default_agents/base_prompt.md +66 -0
  67. data/lib/octo/default_agents/coding/profile.yml +2 -0
  68. data/lib/octo/default_agents/coding/system_prompt.md +67 -0
  69. data/lib/octo/default_agents/general/profile.yml +2 -0
  70. data/lib/octo/default_agents/general/system_prompt.md +16 -0
  71. data/lib/octo/default_parsers/doc_parser.rb +69 -0
  72. data/lib/octo/default_parsers/docx_parser.rb +188 -0
  73. data/lib/octo/default_parsers/pdf_parser.rb +120 -0
  74. data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
  75. data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
  76. data/lib/octo/default_parsers/pptx_parser.rb +140 -0
  77. data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
  78. data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
  79. data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
  80. data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
  81. data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
  82. data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
  83. data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
  84. data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
  85. data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
  86. data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
  87. data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
  88. data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
  89. data/lib/octo/default_skills/onboard/SKILL.md +578 -0
  90. data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
  91. data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
  92. data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
  93. data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
  94. data/lib/octo/default_skills/personal-website/publish.rb +235 -0
  95. data/lib/octo/default_skills/product-help/SKILL.md +123 -0
  96. data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
  97. data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
  98. data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
  99. data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
  100. data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
  101. data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
  102. data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
  103. data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
  104. data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
  105. data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
  106. data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
  107. data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
  108. data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
  109. data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
  110. data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
  111. data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
  112. data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
  113. data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
  114. data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
  115. data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
  116. data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
  117. data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
  118. data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
  119. data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
  120. data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
  121. data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
  122. data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
  123. data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
  124. data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
  125. data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  126. data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
  127. data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
  128. data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
  129. data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
  130. data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
  131. data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
  132. data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
  133. data/lib/octo/idle_compression_timer.rb +115 -0
  134. data/lib/octo/json_ui_controller.rb +204 -0
  135. data/lib/octo/message_format/anthropic.rb +409 -0
  136. data/lib/octo/message_format/bedrock.rb +361 -0
  137. data/lib/octo/message_format/open_ai.rb +222 -0
  138. data/lib/octo/message_history.rb +373 -0
  139. data/lib/octo/openai_stream_aggregator.rb +130 -0
  140. data/lib/octo/plain_ui_controller.rb +166 -0
  141. data/lib/octo/providers.rb +534 -0
  142. data/lib/octo/server/browser_manager.rb +397 -0
  143. data/lib/octo/server/channel/adapters/base.rb +82 -0
  144. data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
  145. data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
  146. data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
  147. data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
  148. data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
  149. data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
  150. data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
  151. data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
  152. data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
  153. data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
  154. data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
  155. data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
  156. data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
  157. data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
  158. data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
  159. data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
  160. data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
  161. data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
  162. data/lib/octo/server/channel/channel_config.rb +178 -0
  163. data/lib/octo/server/channel/channel_manager.rb +468 -0
  164. data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
  165. data/lib/octo/server/channel.rb +33 -0
  166. data/lib/octo/server/discover.rb +77 -0
  167. data/lib/octo/server/epipe_safe_io.rb +105 -0
  168. data/lib/octo/server/http_server.rb +3554 -0
  169. data/lib/octo/server/scheduler.rb +317 -0
  170. data/lib/octo/server/server_master.rb +325 -0
  171. data/lib/octo/server/session_registry.rb +431 -0
  172. data/lib/octo/server/web_ui_controller.rb +487 -0
  173. data/lib/octo/session_manager.rb +385 -0
  174. data/lib/octo/skill.rb +466 -0
  175. data/lib/octo/skill_loader.rb +328 -0
  176. data/lib/octo/tools/base.rb +118 -0
  177. data/lib/octo/tools/browser.rb +625 -0
  178. data/lib/octo/tools/edit.rb +165 -0
  179. data/lib/octo/tools/file_reader.rb +549 -0
  180. data/lib/octo/tools/glob.rb +162 -0
  181. data/lib/octo/tools/grep.rb +356 -0
  182. data/lib/octo/tools/invoke_skill.rb +96 -0
  183. data/lib/octo/tools/list_tasks.rb +54 -0
  184. data/lib/octo/tools/redo_task.rb +41 -0
  185. data/lib/octo/tools/request_user_feedback.rb +84 -0
  186. data/lib/octo/tools/security.rb +333 -0
  187. data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
  188. data/lib/octo/tools/terminal/persistent_session.rb +268 -0
  189. data/lib/octo/tools/terminal/safe_rm.sh +106 -0
  190. data/lib/octo/tools/terminal/session_manager.rb +213 -0
  191. data/lib/octo/tools/terminal.rb +1828 -0
  192. data/lib/octo/tools/todo_manager.rb +374 -0
  193. data/lib/octo/tools/trash_manager.rb +388 -0
  194. data/lib/octo/tools/undo_task.rb +35 -0
  195. data/lib/octo/tools/web_fetch.rb +242 -0
  196. data/lib/octo/tools/web_search.rb +260 -0
  197. data/lib/octo/tools/write.rb +77 -0
  198. data/lib/octo/ui2/block_font.rb +10 -0
  199. data/lib/octo/ui2/components/base_component.rb +163 -0
  200. data/lib/octo/ui2/components/command_suggestions.rb +290 -0
  201. data/lib/octo/ui2/components/common_component.rb +96 -0
  202. data/lib/octo/ui2/components/inline_input.rb +226 -0
  203. data/lib/octo/ui2/components/input_area.rb +1338 -0
  204. data/lib/octo/ui2/components/message_component.rb +99 -0
  205. data/lib/octo/ui2/components/modal_component.rb +419 -0
  206. data/lib/octo/ui2/components/todo_area.rb +149 -0
  207. data/lib/octo/ui2/components/tool_component.rb +107 -0
  208. data/lib/octo/ui2/components/welcome_banner.rb +139 -0
  209. data/lib/octo/ui2/layout_manager.rb +807 -0
  210. data/lib/octo/ui2/line_editor.rb +363 -0
  211. data/lib/octo/ui2/markdown_renderer.rb +100 -0
  212. data/lib/octo/ui2/output_buffer.rb +370 -0
  213. data/lib/octo/ui2/progress_handle.rb +362 -0
  214. data/lib/octo/ui2/progress_indicator.rb +55 -0
  215. data/lib/octo/ui2/screen_buffer.rb +273 -0
  216. data/lib/octo/ui2/terminal_detector.rb +119 -0
  217. data/lib/octo/ui2/theme_manager.rb +85 -0
  218. data/lib/octo/ui2/themes/base_theme.rb +105 -0
  219. data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
  220. data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
  221. data/lib/octo/ui2/thinking_verbs.rb +26 -0
  222. data/lib/octo/ui2/ui_controller.rb +1625 -0
  223. data/lib/octo/ui2/view_renderer.rb +177 -0
  224. data/lib/octo/ui2.rb +40 -0
  225. data/lib/octo/ui_interface.rb +154 -0
  226. data/lib/octo/utils/arguments_parser.rb +191 -0
  227. data/lib/octo/utils/browser_detector.rb +195 -0
  228. data/lib/octo/utils/encoding.rb +92 -0
  229. data/lib/octo/utils/environment_detector.rb +140 -0
  230. data/lib/octo/utils/file_ignore_helper.rb +170 -0
  231. data/lib/octo/utils/file_processor.rb +601 -0
  232. data/lib/octo/utils/gitignore_parser.rb +154 -0
  233. data/lib/octo/utils/limit_stack.rb +152 -0
  234. data/lib/octo/utils/logger.rb +124 -0
  235. data/lib/octo/utils/login_shell.rb +72 -0
  236. data/lib/octo/utils/model_pricing.rb +646 -0
  237. data/lib/octo/utils/parser_manager.rb +165 -0
  238. data/lib/octo/utils/path_helper.rb +15 -0
  239. data/lib/octo/utils/scripts_manager.rb +59 -0
  240. data/lib/octo/utils/string_matcher.rb +158 -0
  241. data/lib/octo/utils/trash_directory.rb +112 -0
  242. data/lib/octo/utils/workspace_rules.rb +46 -0
  243. data/lib/octo/version.rb +5 -0
  244. data/lib/octo/web/app.css +7141 -0
  245. data/lib/octo/web/app.js +543 -0
  246. data/lib/octo/web/apple-touch-icon.png +0 -0
  247. data/lib/octo/web/auth.js +150 -0
  248. data/lib/octo/web/channels.js +276 -0
  249. data/lib/octo/web/datepicker.js +205 -0
  250. data/lib/octo/web/favicon.png +0 -0
  251. data/lib/octo/web/i18n.js +1073 -0
  252. data/lib/octo/web/icon-512.png +0 -0
  253. data/lib/octo/web/icon-dark.svg +25 -0
  254. data/lib/octo/web/icon.svg +29 -0
  255. data/lib/octo/web/index.html +871 -0
  256. data/lib/octo/web/marked.min.js +69 -0
  257. data/lib/octo/web/onboard.js +491 -0
  258. data/lib/octo/web/profile.js +442 -0
  259. data/lib/octo/web/sessions.js +4421 -0
  260. data/lib/octo/web/settings.js +913 -0
  261. data/lib/octo/web/sidebar.js +32 -0
  262. data/lib/octo/web/skills.js +885 -0
  263. data/lib/octo/web/tasks.js +297 -0
  264. data/lib/octo/web/theme.js +105 -0
  265. data/lib/octo/web/trash.js +343 -0
  266. data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
  267. data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
  268. data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
  269. data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  270. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  271. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  272. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  273. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  274. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  275. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  276. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  277. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  278. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  279. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  280. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  281. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  282. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  283. data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  284. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  285. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  286. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  287. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  288. data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  289. data/lib/octo/web/vendor/katex/katex.min.css +1 -0
  290. data/lib/octo/web/vendor/katex/katex.min.js +1 -0
  291. data/lib/octo/web/version.js +449 -0
  292. data/lib/octo/web/weixin-qr.html +209 -0
  293. data/lib/octo/web/ws-dispatcher.js +357 -0
  294. data/lib/octo/web/ws.js +128 -0
  295. data/lib/octo.rb +145 -0
  296. data/scripts/build/build.sh +329 -0
  297. data/scripts/build/lib/apt.sh +56 -0
  298. data/scripts/build/lib/brew.sh +89 -0
  299. data/scripts/build/lib/colors.sh +17 -0
  300. data/scripts/build/lib/gem.sh +95 -0
  301. data/scripts/build/lib/mise.sh +125 -0
  302. data/scripts/build/lib/network.sh +157 -0
  303. data/scripts/build/lib/os.sh +57 -0
  304. data/scripts/build/lib/shell.sh +37 -0
  305. data/scripts/build/src/install.sh.cc +174 -0
  306. data/scripts/build/src/install_browser.sh.cc +101 -0
  307. data/scripts/build/src/install_full.sh.cc +290 -0
  308. data/scripts/build/src/install_rails_deps.sh.cc +145 -0
  309. data/scripts/build/src/install_system_deps.sh.cc +123 -0
  310. data/scripts/build/src/uninstall.sh.cc +101 -0
  311. data/scripts/install.ps1 +532 -0
  312. data/scripts/install.sh +567 -0
  313. data/scripts/install_browser.sh +479 -0
  314. data/scripts/install_full.sh +838 -0
  315. data/scripts/install_rails_deps.sh +746 -0
  316. data/scripts/install_system_deps.sh +518 -0
  317. data/scripts/uninstall.sh +287 -0
  318. data/sig/octo.rbs +4 -0
  319. metadata +614 -0
@@ -0,0 +1,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "channel_ui_controller"
4
+
5
+ module Octo
6
+ module Channel
7
+ # ChannelManager starts and supervises IM platform adapter threads.
8
+ # When an inbound message arrives it:
9
+ # 1. Resolves (or auto-creates) a Session bound to this IM identity
10
+ # 2. Retrieves the WebUIController for that session
11
+ # 3. Creates a ChannelUIController and subscribes it to the WebUIController
12
+ # 4. Runs the agent task via run_agent_task (same as HttpServer)
13
+ # 5. Unsubscribes the ChannelUIController when the task finishes
14
+ #
15
+ # Thread model: each adapter runs two long-lived threads (read loop + ping).
16
+ # ChannelManager itself is non-blocking — call #start from HttpServer after
17
+ # the WEBrick server has started.
18
+ #
19
+ # Session binding: the first message from an IM identity automatically creates
20
+ # a new session and binds it. Users can use /bind <session_id> to switch to an
21
+ # existing WebUI session instead. Bindings are stored in the session registry as
22
+ # :channel_keys => Set of channel key strings.
23
+ # WebUI sessions are persisted by HttpServer — channel adds no extra persistence.
24
+ class ChannelManager
25
+ # @param session_registry [Octo::Server::SessionRegistry]
26
+ # @param session_builder [Proc] (name:, working_dir:) => session_id — from HttpServer
27
+ # @param run_agent_task [Proc] (session_id, agent, &task) — from HttpServer
28
+ # @param interrupt_session [Proc] (session_id) — from HttpServer
29
+ # @param channel_config [Octo::ChannelConfig]
30
+ # @param binding_mode [:user | :chat | :chat_user] how to map IM identities to sessions.
31
+ # :chat_user (default) — one session per (chat, user) pair. Most natural:
32
+ # private chat = that user's session; in a group each
33
+ # user has their own session; the same user across
34
+ # different groups keeps those contexts separate.
35
+ # :chat — one session per chat (all users in a group share it).
36
+ # :user — one session per user (merges DMs and all groups).
37
+ def initialize(session_registry:, session_builder:, run_agent_task:, interrupt_session:, channel_config:, binding_mode: :chat_user)
38
+ @registry = session_registry
39
+ @session_builder = session_builder
40
+ @run_agent_task = run_agent_task
41
+ @interrupt_session = interrupt_session
42
+ @channel_config = channel_config
43
+ @binding_mode = binding_mode
44
+ @adapters = []
45
+ @adapter_threads = []
46
+ @running = false
47
+ @mutex = Mutex.new
48
+ @session_counters = Hash.new(0) # platform => count, for short session names
49
+ end
50
+
51
+ # Start all enabled adapters in background threads. Non-blocking.
52
+ def start
53
+ enabled_platforms = @channel_config.enabled_platforms
54
+ if enabled_platforms.empty?
55
+ Octo::Logger.info("[ChannelManager] No channels configured — skipping")
56
+ return
57
+ end
58
+
59
+ Octo::Logger.info("[ChannelManager] Starting channels: #{enabled_platforms.join(", ")}")
60
+ @running = true
61
+ enabled_platforms.each { |platform| start_adapter(platform) }
62
+ end
63
+
64
+ # Stop all adapters gracefully.
65
+ def stop
66
+ @running = false
67
+ @mutex.synchronize do
68
+ @adapters.each { |adapter| safe_stop_adapter(adapter) }
69
+ @adapters.clear
70
+ end
71
+ @adapter_threads.each { |t| t.join(1) }
72
+ @adapter_threads.clear
73
+ end
74
+
75
+ # @return [Array<Symbol>] platforms currently running
76
+ def running_platforms
77
+ @mutex.synchronize { @adapters.map(&:platform_id) }
78
+ end
79
+
80
+ # Proactively send a message to a user on the given platform.
81
+ #
82
+ # For Weixin (iLink protocol) a context_token is required for every outbound
83
+ # message. This method looks up the most-recently cached token for user_id.
84
+ # If no token is found the message cannot be delivered and nil is returned.
85
+ #
86
+ # For Feishu and WeCom the chat_id / user_id is sufficient — no token needed.
87
+ #
88
+ # @param platform [Symbol, String] e.g. :weixin, :feishu, :wecom
89
+ # @param user_id [String] IM user identifier
90
+ # @param message [String] plain-text (or markdown) message to send
91
+ # @return [Hash, nil] adapter result hash, or nil on failure
92
+ def send_to_user(platform, user_id, message)
93
+ platform = platform.to_sym
94
+ adapter = @mutex.synchronize { @adapters.find { |a| a.platform_id == platform } }
95
+
96
+ unless adapter
97
+ Octo::Logger.warn("[ChannelManager] send_to_user: no running adapter for :#{platform}")
98
+ return nil
99
+ end
100
+
101
+ Octo::Logger.info("[ChannelManager] send_to_user :#{platform} → #{user_id}")
102
+ adapter.send_text(user_id, message)
103
+ rescue StandardError => e
104
+ Octo::Logger.error("[ChannelManager] send_to_user failed: #{e.message}")
105
+ nil
106
+ end
107
+
108
+ # Return a list of known user IDs for the given platform.
109
+ # Collected from every message that has been processed since the server started.
110
+ # Weixin stores context_tokens keyed by user_id; feishu/wecom track chat_ids
111
+ # via the session binding table in the registry.
112
+ #
113
+ # @param platform [Symbol, String]
114
+ # @return [Array<String>]
115
+ def known_users(platform)
116
+ platform = platform.to_sym
117
+ adapter = @mutex.synchronize { @adapters.find { |a| a.platform_id == platform } }
118
+ return [] unless adapter
119
+
120
+ # Weixin adapter exposes @context_tokens whose keys are user_ids
121
+ if adapter.respond_to?(:context_token_user_ids)
122
+ return adapter.context_token_user_ids
123
+ end
124
+
125
+ # Fallback: scan session registry for channel_keys matching this platform.
126
+ # Key formats depend on binding_mode:
127
+ # :user → "platform:user:USER_ID"
128
+ # :chat → "platform:chat:CHAT_ID"
129
+ # :chat_user → "platform:chat:CHAT_ID:user:USER_ID"
130
+ #
131
+ # For send_text we need the chat_id (Feishu/WeCom use chat_id as the
132
+ # receive_id for outbound messages), so we extract the chat portion.
133
+ prefix = "#{platform}:"
134
+ ids = []
135
+ @registry.list.each do |summary|
136
+ @registry.with_session(summary[:id]) do |s|
137
+ (s[:channel_keys] || []).each do |key|
138
+ next unless key.start_with?(prefix)
139
+
140
+ remainder = key.sub(prefix, "") # e.g. "chat:OC_ID:user:OU_ID" or "user:UID" or "chat:CID"
141
+ ids << extract_chat_id(remainder)
142
+ end
143
+ end
144
+ end
145
+ ids.compact.uniq
146
+ end
147
+
148
+ # Hot-reload a single platform adapter with updated config.
149
+ # Stops the existing adapter (if running), then starts a new one if enabled.
150
+ # @param platform [Symbol]
151
+ # @param config [Octo::ChannelConfig]
152
+ def reload_platform(platform, config)
153
+ # Stop existing adapter for this platform
154
+ @mutex.synchronize do
155
+ existing = @adapters.find { |a| a.platform_id == platform }
156
+ if existing
157
+ safe_stop_adapter(existing)
158
+ @adapters.delete(existing)
159
+ end
160
+ end
161
+
162
+ # Start new adapter if enabled
163
+ if config.enabled?(platform)
164
+ @channel_config = config
165
+ start_adapter(platform)
166
+ Octo::Logger.info("[ChannelManager] :#{platform} adapter reloaded")
167
+ else
168
+ Octo::Logger.info("[ChannelManager] :#{platform} disabled — adapter not started")
169
+ end
170
+ end
171
+
172
+
173
+ def start_adapter(platform)
174
+ klass = Adapters.find(platform)
175
+ unless klass
176
+ Octo::Logger.warn("[ChannelManager] No adapter registered for :#{platform} — skipping")
177
+ return
178
+ end
179
+
180
+ raw_config = @channel_config.platform_config(platform)
181
+ Octo::Logger.info("[ChannelManager] Initializing :#{platform} adapter")
182
+ adapter = klass.new(raw_config)
183
+
184
+ errors = adapter.validate_config(raw_config)
185
+ if errors.any?
186
+ Octo::Logger.warn("[ChannelManager] Config errors for :#{platform}: #{errors.join(", ")}")
187
+ return
188
+ end
189
+
190
+ @mutex.synchronize { @adapters << adapter }
191
+ Octo::Logger.info("[ChannelManager] :#{platform} adapter ready, starting thread")
192
+
193
+ thread = Thread.new do
194
+ Thread.current.name = "channel-#{platform}"
195
+ adapter_loop(adapter)
196
+ end
197
+
198
+ @adapter_threads << thread
199
+ end
200
+
201
+ def adapter_loop(adapter)
202
+ Octo::Logger.info("[ChannelManager] :#{adapter.platform_id} adapter loop started")
203
+ adapter.start do |event|
204
+ summary = event[:text].to_s.lines.first.to_s.strip[0, 80]
205
+ summary = "[image]" if summary.empty? && !event[:files].to_a.empty?
206
+ Octo::Logger.info("[ChannelManager] :#{adapter.platform_id} message from #{event[:user_id]} in #{event[:chat_id]}: #{summary}")
207
+ route_message(adapter, event)
208
+ rescue StandardError => e
209
+ Octo::Logger.warn("[ChannelManager] Error routing :#{adapter.platform_id} message: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
210
+ adapter.send_text(event[:chat_id], "Error: #{e.message}")
211
+ end
212
+ rescue StandardError => e
213
+ Octo::Logger.warn("[ChannelManager] :#{adapter.platform_id} adapter crashed: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
214
+ if @running
215
+ Octo::Logger.info("[ChannelManager] :#{adapter.platform_id} restarting in 5s...")
216
+ sleep 5
217
+ retry
218
+ end
219
+ end
220
+
221
+ def route_message(adapter, event)
222
+ text = event[:text]&.strip
223
+ files = event[:files] || []
224
+ return if (text.nil? || text.empty?) && files.empty?
225
+
226
+ # Handle built-in commands
227
+ if text&.start_with?("/")
228
+ handle_command(adapter, event, text)
229
+ return
230
+ end
231
+
232
+ session_id = resolve_session(event)
233
+ session_id = auto_create_session(adapter, event) unless session_id
234
+
235
+ session = @registry.get(session_id)
236
+ unless session
237
+ Octo::Logger.warn("[ChannelManager] Session #{session_id[0, 8]} not found in registry after create")
238
+ adapter.send_text(event[:chat_id], "Failed to initialize session. Please try again.")
239
+ return
240
+ end
241
+
242
+ Octo::Logger.info("[ChannelManager] Routing to session #{session_id[0, 8]} (status=#{session[:status]})")
243
+
244
+ # If session is running, interrupt it automatically (mimics CLI behavior)
245
+ if session[:status] == :running
246
+ Octo::Logger.info("[ChannelManager] Session busy, interrupting previous task")
247
+ @interrupt_session.call(session_id)
248
+ # Wait briefly for the thread to catch the interrupt and update status
249
+ sleep 0.1
250
+ end
251
+
252
+ agent = session[:agent]
253
+ web_ui = session[:ui]
254
+
255
+ # Update reply context so responses thread under the current message.
256
+ # channel_ui is bound to the session for its full lifetime (created in auto_create_session).
257
+ channel_ui_for_session(session_id)&.update_message_context(event)
258
+
259
+ # Sync the inbound message to WebUI so it shows up in the browser session.
260
+ # source: :channel prevents the message from being echoed back to the IM channel.
261
+ web_ui&.show_user_message(text, source: :channel) unless text.nil? || text.empty?
262
+
263
+ # Start typing keepalive BEFORE sending any message.
264
+ # sendmessage cancels the typing indicator in WeChat protocol,
265
+ # so keepalive must be running when "Thinking..." is sent so it
266
+ # immediately re-asserts the typing state after that message.
267
+ chat_id = event[:chat_id]
268
+ context_token = event[:context_token]
269
+ adapter.start_typing_keepalive(chat_id, context_token) if adapter.respond_to?(:start_typing_keepalive)
270
+
271
+ # Acknowledge to the IM channel only — WebUI doesn't need a "Thinking..." noise.
272
+ adapter.send_text(chat_id, "Thinking...")
273
+
274
+ @run_agent_task.call(session_id, agent) do
275
+ begin
276
+ agent.run(text, files: files)
277
+ ensure
278
+ adapter.stop_typing_keepalive(chat_id) if adapter.respond_to?(:stop_typing_keepalive)
279
+ end
280
+ end
281
+ end
282
+
283
+ def handle_command(adapter, event, text)
284
+ chat_id = event[:chat_id]
285
+ key = channel_key(event)
286
+
287
+ case text
288
+ when /\A\/bind\s+(\S+)\z/i
289
+ arg = Regexp.last_match(1)
290
+ # Support numeric index from /list (1-based)
291
+ session_id = if arg =~ /\A\d+\z/
292
+ recent = @registry.list.last(5).reverse
293
+ idx = arg.to_i - 1
294
+ recent[idx]&.fetch(:id, nil)
295
+ else
296
+ arg
297
+ end
298
+ unless session_id && @registry.get(session_id)
299
+ adapter.send_text(chat_id, "Session not found. Use /list to see available sessions.")
300
+ return
301
+ end
302
+
303
+ # Detach channel_ui from the old session's web_ui, reattach to the new one.
304
+ old_session_id = resolve_session(event)
305
+ channel_ui = old_session_id ? channel_ui_for_session(old_session_id) : nil
306
+
307
+ if channel_ui
308
+ @registry.with_session(old_session_id) { |s| s[:ui]&.unsubscribe_channel(channel_ui); s.delete(:channel_ui) }
309
+ else
310
+ channel_ui = ChannelUIController.new(event, adapter)
311
+ end
312
+
313
+ bind_key_to_session(key, session_id)
314
+ @registry.with_session(session_id) do |s|
315
+ s[:ui]&.subscribe_channel(channel_ui)
316
+ s[:channel_ui] = channel_ui
317
+ end
318
+
319
+ Octo::Logger.info("[ChannelManager] Bound #{key} -> session #{session_id[0, 8]}")
320
+ adapter.send_text(chat_id, "Bound to session `#{session_id[0, 8]}`.")
321
+
322
+ when "/stop"
323
+ session_id = resolve_session(event)
324
+ unless session_id
325
+ adapter.send_text(chat_id, "No session bound.")
326
+ return
327
+ end
328
+ @interrupt_session.call(session_id)
329
+ adapter.send_text(chat_id, "Task interrupted.")
330
+
331
+ when "/unbind"
332
+ unbound = false
333
+ @registry.list.each do |summary|
334
+ @registry.with_session(summary[:id]) do |s|
335
+ unbound = true if s[:channel_keys]&.delete(key)
336
+ end
337
+ end
338
+ adapter.send_text(chat_id, unbound ? "Unbound." : "No binding found.")
339
+
340
+ when "/status"
341
+ session_id = resolve_session(event)
342
+ if session_id
343
+ session = @registry.get(session_id)
344
+ adapter.send_text(chat_id, "Bound to session `#{session_id[0, 8]}` (status: #{session&.dig(:status) || "unknown"})")
345
+ else
346
+ adapter.send_text(chat_id, "No session bound yet. Send any message to auto-create one.")
347
+ end
348
+
349
+ when "/list"
350
+ list_sessions(adapter, chat_id)
351
+
352
+ else
353
+ adapter.send_text(chat_id,
354
+ "Commands:\n" \
355
+ " /bind <n|session_id> - switch to a session (use /list to see numbers)\n" \
356
+ " /unbind - remove binding\n" \
357
+ " /stop - interrupt current task\n" \
358
+ " /status - show current binding\n" \
359
+ " /list - show recent sessions")
360
+ end
361
+ end
362
+
363
+ def resolve_session(event)
364
+ key = channel_key(event)
365
+ @registry.list.each do |summary|
366
+ found = nil
367
+ @registry.with_session(summary[:id]) { |s| found = s[:channel_keys]&.include?(key) }
368
+ return summary[:id] if found
369
+ end
370
+ nil
371
+ rescue StandardError => e
372
+ Octo::Logger.error("[ChannelManager] Session resolve failed: #{e.message}")
373
+ nil
374
+ end
375
+
376
+ def auto_create_session(adapter, event)
377
+ key = channel_key(event)
378
+ platform = event[:platform].to_s
379
+ count = @mutex.synchronize { @session_counters[platform] += 1 }
380
+ name = "#{platform}-#{count}"
381
+ session_id = @session_builder.call(name: name, working_dir: Dir.home, source: :channel)
382
+ bind_key_to_session(key, session_id)
383
+
384
+ # Create a long-lived ChannelUIController for this session and subscribe it
385
+ # to the session's WebUIController. It stays for the session's full lifetime
386
+ # so all events (agent output, errors, status) flow through web_ui → channel_ui.
387
+ channel_ui = ChannelUIController.new(event, adapter)
388
+ @registry.with_session(session_id) do |s|
389
+ s[:ui]&.subscribe_channel(channel_ui)
390
+ s[:channel_ui] = channel_ui
391
+ end
392
+
393
+ Octo::Logger.info("[ChannelManager] Auto-created session #{session_id[0, 8]} for #{key}")
394
+ session_id
395
+ end
396
+
397
+ # Retrieve the ChannelUIController bound to a session (if any).
398
+ def channel_ui_for_session(session_id)
399
+ result = nil
400
+ @registry.with_session(session_id) { |s| result = s[:channel_ui] }
401
+ result
402
+ end
403
+
404
+ def bind_key_to_session(key, session_id)
405
+ @registry.list.each do |summary|
406
+ @registry.with_session(summary[:id]) { |s| s[:channel_keys]&.delete(key) }
407
+ end
408
+ @registry.with_session(session_id) do |s|
409
+ s[:channel_keys] ||= Set.new
410
+ s[:channel_keys].add(key)
411
+ end
412
+ end
413
+
414
+ def list_sessions(adapter, chat_id)
415
+ sessions = @registry.list.last(5).reverse
416
+ if sessions.empty?
417
+ adapter.send_text(chat_id, "No sessions available.")
418
+ return
419
+ end
420
+ lines = sessions.each_with_index.map do |s, i|
421
+ name = s[:name].to_s.empty? ? "(unnamed)" : s[:name]
422
+ time = s[:updated_at].to_s[5, 11]&.tr("T", " ") || "-"
423
+ "#{i + 1}. `#{s[:id][0, 8]}` #{name} (#{s[:status]}) #{time}"
424
+ end
425
+ adapter.send_text(chat_id, "Recent sessions:\n#{lines.join("\n")}\n\nUse `/bind <n>` to switch.")
426
+ end
427
+
428
+ def channel_key(event)
429
+ platform = event[:platform].to_s
430
+ case @binding_mode
431
+ when :chat then "#{platform}:chat:#{event[:chat_id]}"
432
+ when :user then "#{platform}:user:#{event[:user_id]}"
433
+ else # :chat_user (default)
434
+ "#{platform}:chat:#{event[:chat_id]}:user:#{event[:user_id]}"
435
+ end
436
+ end
437
+
438
+ # Extract the chat_id from the remainder of a channel_key (after removing "platform:" prefix).
439
+ #
440
+ # Possible formats:
441
+ # "chat:CHAT_ID:user:USER_ID" → CHAT_ID (chat_user mode)
442
+ # "chat:CHAT_ID" → CHAT_ID (chat mode)
443
+ # "user:USER_ID" → USER_ID (user mode — use user_id as fallback)
444
+ #
445
+ # For Feishu/WeCom send_text, the chat_id is what's needed as receive_id.
446
+ private def extract_chat_id(remainder)
447
+ if remainder.start_with?("chat:")
448
+ # "chat:CHAT_ID:user:USER_ID" or "chat:CHAT_ID"
449
+ after_chat = remainder.sub("chat:", "")
450
+ # If there's a ":user:" segment, strip it and everything after
451
+ idx = after_chat.index(":user:")
452
+ idx ? after_chat[0...idx] : after_chat
453
+ elsif remainder.start_with?("user:")
454
+ # user-only mode: no chat_id available, use user_id
455
+ remainder.sub("user:", "")
456
+ else
457
+ remainder
458
+ end
459
+ end
460
+
461
+ def safe_stop_adapter(adapter)
462
+ adapter.stop
463
+ rescue StandardError => e
464
+ Octo::Logger.warn("[ChannelManager] Error stopping #{adapter.platform_id}: #{e.message}")
465
+ end
466
+ end
467
+ end
468
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../ui_interface"
4
+
5
+ module Octo
6
+ module Channel
7
+ # ChannelUIController implements UIInterface for IM platform sessions.
8
+ # It is registered as a subscriber on WebUIController so that every
9
+ # agent output event is forwarded here and sent back to the IM platform.
10
+ #
11
+ # Design notes:
12
+ # - Tool calls / results / diffs / token usage are intentionally suppressed
13
+ # to keep IM chat clean. Only high-signal events are forwarded.
14
+ # - Buffering: file/shell previews accumulate in a buffer and are flushed
15
+ # as one message before the next assistant message, avoiding flooding.
16
+ # - request_confirmation is not invoked directly on this class — the Web
17
+ # UI handles the blocking wait and only sends show_warning notifications.
18
+ class ChannelUIController
19
+ include Octo::UIInterface
20
+
21
+ BUFFER_FLUSH_SIZE = 5 # flush early when buffer is large
22
+
23
+ attr_reader :platform, :chat_id
24
+
25
+ def initialize(event, adapter)
26
+ @platform = event[:platform]
27
+ @chat_id = event[:chat_id]
28
+ @message_id = event[:message_id] # original message to reply under
29
+ @adapter = adapter
30
+ @buffer = []
31
+ @mutex = Mutex.new
32
+ end
33
+
34
+ # Update the reply context for the current inbound message.
35
+ # Called at the start of each route_message so replies are threaded correctly.
36
+ # Also updates chat_id — a session may span multiple chats (e.g. same user
37
+ # in both a direct message and a group), and each inbound event dictates
38
+ # where outbound replies should be routed.
39
+ # @param event [Hash] inbound event with :message_id and :chat_id
40
+ def update_message_context(event)
41
+ @mutex.synchronize do
42
+ @message_id = event[:message_id]
43
+ @chat_id = event[:chat_id] if event[:chat_id]
44
+ end
45
+ end
46
+
47
+ # === Output display ===
48
+
49
+ # Forward WebUI user messages to the IM channel so both sides stay in sync.
50
+ # Prefixed with the product/user context so it's clear who sent it.
51
+ def show_user_message(content)
52
+ return if content.nil? || content.to_s.strip.empty?
53
+
54
+ send_text("[USER] #{content}")
55
+ end
56
+
57
+ def show_assistant_message(content, files:)
58
+ flush_buffer
59
+ Octo::Logger.info("[ChannelUI] show_assistant_message files=#{files.size} content_len=#{content.to_s.length}")
60
+ # Strip file:// markdown links from the text sent to IM channels —
61
+ # the actual files are delivered via send_file() below, so the
62
+ # raw markdown links would just be noise in the chat.
63
+ text = content.to_s.gsub(/!?\[[^\]]*\]\(file:\/\/[^)]+\)/, "").strip
64
+ send_text(text) unless text.empty?
65
+ flush_adapter_pending
66
+ files.each do |f|
67
+ Octo::Logger.info("[ChannelUI] sending file path=#{f[:path].inspect} name=#{f[:name].inspect}")
68
+ send_file(f[:path], f[:name])
69
+ end
70
+ end
71
+
72
+ def show_tool_call(name, args)
73
+ # Suppress — too noisy for IM
74
+ end
75
+
76
+ def show_tool_result(result)
77
+ # Suppress — too noisy for IM
78
+ end
79
+
80
+ def show_tool_error(error)
81
+ msg = error.is_a?(Exception) ? error.message : error.to_s
82
+ send_text("Tool error: #{msg}")
83
+ end
84
+
85
+ def show_tool_args(formatted_args)
86
+ # Suppress
87
+ end
88
+
89
+ def show_file_write_preview(path, is_new_file:)
90
+ action = is_new_file ? "create" : "overwrite"
91
+ buffer_line("#{action}: #{path}")
92
+ end
93
+
94
+ def show_file_edit_preview(path)
95
+ buffer_line("edit: #{path}")
96
+ end
97
+
98
+ def show_shell_preview(command)
99
+ buffer_line("$ #{command}")
100
+ end
101
+
102
+ def show_file_error(error_message)
103
+ send_text("File error: #{error_message}")
104
+ end
105
+
106
+ def show_diff(old_content, new_content, max_lines: 50)
107
+ # Diffs are too verbose for IM — suppress
108
+ end
109
+
110
+ def show_token_usage(token_data)
111
+ # Suppress
112
+ end
113
+
114
+ def show_complete(iterations:, duration: nil, cache_stats: nil, awaiting_user_feedback: false)
115
+ flush_buffer
116
+ parts = ["Done", "#{iterations} step#{"s" if iterations != 1}"]
117
+ parts << "#{duration.round(1)}s" if duration
118
+ send_text(parts.join(" · "))
119
+ flush_adapter_pending
120
+ end
121
+
122
+ def append_output(content)
123
+ return if content.nil? || content.to_s.strip.empty?
124
+
125
+ send_text(content)
126
+ end
127
+
128
+ # === Status messages ===
129
+
130
+ def show_info(message, prefix_newline: true)
131
+ # Suppress informational noise in IM
132
+ end
133
+
134
+ def show_warning(message)
135
+ send_text("Warning: #{message}")
136
+ end
137
+
138
+ def show_error(message)
139
+ send_text("Error: #{message}")
140
+ end
141
+
142
+ def show_success(message)
143
+ send_text(message)
144
+ end
145
+
146
+ def log(message, level: :info)
147
+ # Suppress
148
+ end
149
+
150
+ # === Progress ===
151
+
152
+ def show_progress(message = nil, prefix_newline: true, output_buffer: nil)
153
+ # Suppress — progress spinner has no IM equivalent
154
+ end
155
+
156
+ # === State updates (no-ops for IM) ===
157
+
158
+ def update_sessionbar(tasks: nil, status: nil, latency: nil); end
159
+ def update_todos(todos); end
160
+ def set_working_status; end
161
+ def set_idle_status; end
162
+
163
+ # === Blocking interaction ===
164
+ # Not called directly — WebUIController handles the blocking wait
165
+ # and only notifies IM via show_warning. Implemented as auto-approve
166
+ # as a safety fallback in case this is ever called directly.
167
+ def request_confirmation(message, default: true)
168
+ send_text("Confirmation requested (auto-approved): #{message}")
169
+ default
170
+ end
171
+
172
+ # === Input control / lifecycle (no-ops) ===
173
+
174
+ def clear_input; end
175
+ def set_input_tips(message, type: :info); end
176
+ def stop; end
177
+
178
+
179
+ def send_text(text)
180
+ text = text.to_s.gsub(/<think>[\s\S]*?<\/think>\n*/i, "").strip
181
+ return if text.empty?
182
+
183
+ @adapter.send_text(@chat_id, text, reply_to: @message_id)
184
+ rescue StandardError => e
185
+ Octo::Logger.warn("[ChannelUI] send_text failed", platform: @platform, chat_id: @chat_id, error: e)
186
+ nil
187
+ end
188
+
189
+ def send_file(path, name = nil)
190
+ if @adapter.respond_to?(:send_file)
191
+ @adapter.send_file(@chat_id, path, name: name)
192
+ else
193
+ # Fallback for adapters that don't support file sending
194
+ send_text("File: #{name || File.basename(path)}\n#{path}")
195
+ end
196
+ rescue StandardError => e
197
+ Octo::Logger.warn("[ChannelUI] send_file failed (#{@platform}/#{@chat_id}): #{e.message}")
198
+ send_text("Failed to send file: #{File.basename(path)}\nError: #{e.message}")
199
+ end
200
+
201
+ def buffer_line(line)
202
+ @mutex.synchronize do
203
+ @buffer << line
204
+ flush_buffer_unlocked if @buffer.size >= BUFFER_FLUSH_SIZE
205
+ end
206
+ end
207
+
208
+ def flush_buffer
209
+ @mutex.synchronize { flush_buffer_unlocked }
210
+ end
211
+
212
+ def flush_buffer_unlocked
213
+ return if @buffer.empty?
214
+
215
+ send_text(@buffer.join("\n"))
216
+ @buffer.clear
217
+ end
218
+
219
+ def flush_adapter_pending
220
+ @adapter.flush_pending(@chat_id) if @adapter.respond_to?(:flush_pending)
221
+ end
222
+ end
223
+ end
224
+ end