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,397 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "shellwords"
5
+ require "open3"
6
+
7
+ module Octo
8
+ # BrowserManager owns the chrome-devtools-mcp daemon lifecycle.
9
+ #
10
+ # It mirrors the ChannelManager pattern:
11
+ # - start → read browser.yml; if enabled, pre-warm the MCP daemon
12
+ # - stop → kill the daemon
13
+ # - reload → stop + re-read yml + start (called after browser-setup writes yml)
14
+ # - status → { enabled: bool, daemon_running: bool, chrome_version: String|nil }
15
+ # - toggle → flip enabled in browser.yml and reload
16
+ #
17
+ # browser.yml schema:
18
+ # enabled: true/false — whether the browser tool is active
19
+ # chrome_version: "146" — detected Chrome version (set by browser-setup skill)
20
+ # configured_at: date — when setup was last run
21
+ #
22
+ # Liveness check strategy:
23
+ # process_alive? sends an MCP `ping` (standard in MCP spec 2024-11-05) and
24
+ # waits up to 3s for a response. If the ping succeeds the daemon is healthy.
25
+ # If it times out or raises an IO error the daemon is truly dead — kill it so
26
+ # ensure_process! will spawn a fresh one on the next call.
27
+ #
28
+ # Chrome connection problems (e.g. Chrome closed) surface only during the
29
+ # actual mcp_call and are reported back to the caller; they do NOT trigger a
30
+ # daemon restart.
31
+ #
32
+ # Browser tool (browser.rb) delegates daemon access here instead of using
33
+ # class-level @@mcp_process variables directly. BrowserManager holds the
34
+ # single mutable state; the mutex lives here too.
35
+ class BrowserManager
36
+ BROWSER_CONFIG_PATH = File.expand_path("~/.octo/browser.yml").freeze
37
+
38
+ class << self
39
+ def instance
40
+ @instance ||= new
41
+ end
42
+ end
43
+
44
+ def initialize
45
+ @process = nil # { stdin:, stdout:, pid:, wait_thr: }
46
+ @mutex = Mutex.new
47
+ @call_id = 2 # 1 reserved for MCP initialize handshake
48
+ @config = {} # last successfully read browser.yml content
49
+ end
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Lifecycle
53
+ # ---------------------------------------------------------------------------
54
+
55
+ # Start the daemon if browser.yml marks the browser as enabled.
56
+ # Non-blocking — returns immediately (daemon spawn takes ~200ms in background).
57
+ def start
58
+ cfg = load_config
59
+ unless cfg["enabled"] == true
60
+ Octo::Logger.info("[BrowserManager] Not enabled — skipping daemon start")
61
+ return
62
+ end
63
+
64
+ @config = cfg
65
+ Octo::Logger.info("[BrowserManager] Browser enabled, pre-warming MCP daemon...")
66
+ Thread.new do
67
+ Thread.current.name = "browser-manager-start"
68
+ @mutex.synchronize { ensure_process! }
69
+ rescue Octo::BrowserNotReachableError => e
70
+ # Expected: Chrome not running yet — will start lazily on first use
71
+ Octo::Logger.debug("[BrowserManager] Skipping pre-warm: Chrome not running")
72
+ rescue StandardError => e
73
+ # Unexpected error (handshake failure, port conflict, etc.)
74
+ msg = e.message.to_s.lines.first&.strip || e.message.to_s
75
+ Octo::Logger.warn("[BrowserManager] Pre-warm failed: #{msg}")
76
+ end
77
+ end
78
+
79
+ # Stop and clean up the daemon.
80
+ def stop
81
+ @mutex.synchronize { kill_process! }
82
+ Octo::Logger.info("[BrowserManager] Daemon stopped")
83
+ end
84
+
85
+ # Hot-reload: stop existing daemon, re-read yml, restart if enabled.
86
+ # Called by HttpServer after browser-setup writes a new browser.yml.
87
+ def reload
88
+ Octo::Logger.info("[BrowserManager] Reloading...")
89
+ @mutex.synchronize { kill_process! }
90
+
91
+ cfg = load_config
92
+ @config = cfg
93
+
94
+ if cfg["enabled"] == true
95
+ Octo::Logger.info("[BrowserManager] Browser enabled, restarting daemon")
96
+ Thread.new do
97
+ Thread.current.name = "browser-manager-reload"
98
+ @mutex.synchronize { ensure_process! }
99
+ rescue Octo::BrowserNotReachableError => e
100
+ # Expected: Chrome not running yet — will start lazily on first use
101
+ Octo::Logger.debug("[BrowserManager] Skipping reload start: Chrome not running")
102
+ rescue StandardError => e
103
+ # Unexpected error (handshake failure, port conflict, etc.)
104
+ msg = e.message.to_s.lines.first&.strip || e.message.to_s
105
+ Octo::Logger.warn("[BrowserManager] Reload start failed: #{msg}")
106
+ end
107
+ else
108
+ Octo::Logger.info("[BrowserManager] Browser disabled after reload — daemon not started")
109
+ end
110
+ end
111
+
112
+ # Returns a status hash with real daemon liveness.
113
+ # Uses wait_thr.alive? for a lightweight check — no ping, no mutex needed.
114
+ # @return [Hash] { enabled: bool, daemon_running: bool, chrome_version: String|nil }
115
+ def status
116
+ cfg = load_config
117
+ enabled = cfg["enabled"] == true
118
+ running = @process && @process[:wait_thr]&.alive?
119
+ {
120
+ enabled: enabled,
121
+ daemon_running: !!running,
122
+ chrome_version: cfg["chrome_version"]
123
+ }
124
+ end
125
+
126
+ # Write browser.yml with the given config and reload the daemon.
127
+ # Called by HttpServer POST /api/browser/configure.
128
+ # @param chrome_version [String] detected Chrome major version
129
+ def configure(chrome_version:)
130
+ cfg = {
131
+ "enabled" => true,
132
+ "browser" => "chrome",
133
+ "chrome_version" => chrome_version.to_s,
134
+ "configured_at" => Date.today.to_s
135
+ }
136
+ FileUtils.mkdir_p(File.dirname(BROWSER_CONFIG_PATH))
137
+ File.write(BROWSER_CONFIG_PATH, cfg.to_yaml)
138
+ reload
139
+ end
140
+
141
+ # Toggle the browser tool on/off by flipping `enabled` in browser.yml.
142
+ # Raises if browser.yml doesn't exist (not yet set up).
143
+ # @return [Boolean] new enabled state
144
+ def toggle
145
+ raise "Browser not configured. Run /browser-setup first." unless File.exist?(BROWSER_CONFIG_PATH)
146
+
147
+ cfg = load_config
148
+ new_enabled = !(cfg["enabled"] == true)
149
+ cfg["enabled"] = new_enabled
150
+ File.write(BROWSER_CONFIG_PATH, cfg.to_yaml)
151
+ @config = cfg
152
+ reload
153
+ new_enabled
154
+ end
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # MCP call interface — used by Browser tool
158
+ # ---------------------------------------------------------------------------
159
+
160
+ # Execute a chrome-devtools-mcp tool call. Ensures daemon is running first.
161
+ # Thread-safe via @mutex.
162
+ # @param tool_name [String]
163
+ # @param arguments [Hash]
164
+ # @return [Hash] parsed MCP result
165
+ # @raise [RuntimeError] on timeout or protocol error
166
+ # @raise [BrowserNotReachableError] when Chrome is not running
167
+ def mcp_call(tool_name, arguments = {})
168
+ call_resp = nil
169
+
170
+ @mutex.synchronize do
171
+ ensure_process! # May raise BrowserNotReachableError
172
+
173
+ call_id = @call_id
174
+ @call_id += 1
175
+
176
+ msg = json_rpc("tools/call", { name: tool_name, arguments: arguments }, id: call_id)
177
+ @process[:stdin].write(msg + "\n")
178
+ @process[:stdin].flush
179
+
180
+ call_resp = read_response(@process[:stdout], target_id: call_id,
181
+ timeout: Octo::Tools::Browser::MCP_CALL_TIMEOUT)
182
+
183
+ unless call_resp
184
+ raise "Chrome MCP tools/call '#{tool_name}' timed out after #{Octo::Tools::Browser::MCP_CALL_TIMEOUT}s"
185
+ end
186
+
187
+ if call_resp["error"]
188
+ err = call_resp["error"]
189
+ raise "Chrome MCP error: #{err.is_a?(Hash) ? err["message"] : err}"
190
+ end
191
+
192
+ result = call_resp["result"] || {}
193
+
194
+ if result["isError"]
195
+ text = extract_text_content(result)
196
+ raise text.empty? ? "Chrome MCP tool '#{tool_name}' failed" : text
197
+ end
198
+
199
+ result
200
+ end
201
+ rescue Octo::BrowserNotReachableError => e
202
+ # Return friendly error for AI to guide user
203
+ raise Octo::AgentError, e.message
204
+ end
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Private
208
+ # ---------------------------------------------------------------------------
209
+
210
+ def load_config
211
+ return {} unless File.exist?(BROWSER_CONFIG_PATH)
212
+ YAMLCompat.safe_load(File.read(BROWSER_CONFIG_PATH), permitted_classes: [Date, Time, Symbol]) || {}
213
+ rescue StandardError => e
214
+ Octo::Logger.warn("[BrowserManager] Failed to read browser.yml: #{e.message}")
215
+ {}
216
+ end
217
+
218
+ # Must be called inside @mutex
219
+ def ensure_process!
220
+ return if process_alive?
221
+
222
+ # ⭐️ Critical: Verify Chrome is reachable BEFORE starting MCP daemon
223
+ detected = Octo::Utils::BrowserDetector.detect
224
+
225
+ if detected[:status] == :not_found
226
+ raise Octo::BrowserNotReachableError, <<~MSG.strip
227
+ Chrome/Edge is not running or remote debugging is not enabled.
228
+
229
+ Please:
230
+ 1. Open Chrome or Edge
231
+ 2. Enable remote debugging: Visit chrome://inspect/#remote-debugging and click "Allow remote debugging"
232
+ 3. Retry this action
233
+
234
+ The browser tool will automatically reconnect once Chrome is running.
235
+ MSG
236
+ end
237
+
238
+ # Build command with verified detection result
239
+ cmd = build_mcp_command(detected)
240
+ Octo::Logger.info("[BrowserManager] Starting MCP daemon: #{cmd.join(' ')}")
241
+
242
+ # Wrap in a shell that manually sources rc files (.zshrc/.bashrc) so
243
+ # mise / rbenv / asdf activate and `chrome-devtools-mcp` (a node
244
+ # binary installed under mise) is on PATH — otherwise the server,
245
+ # when launched by launchd / a desktop icon with a minimal PATH,
246
+ # cannot find node.
247
+ #
248
+ # LoginShell.login_shell_command builds argv like:
249
+ # /bin/zsh -c "{ . ~/.zshrc; ... } 1>&2; exec chrome-devtools-mcp ..."
250
+ #
251
+ # The `1>&2` sends rc-time output (banners, mise warnings) to stderr,
252
+ # keeping the child's stdout 100% clean for JSON-RPC. `exec` then
253
+ # replaces the shell process with the MCP daemon itself, so the pid
254
+ # / signals / waitpid we hold point at the real target.
255
+ inner = cmd.map { |a| shell_escape(a) }.join(" ")
256
+ wrapped = Octo::Utils::LoginShell.login_shell_command(inner)
257
+
258
+ # close_others: true prevents inheriting the server's listening socket (port 8888).
259
+ # The MCP daemon is an independent external process and should not hold server fds.
260
+ stdin, stdout, stderr_io, wait_thr = Open3.popen3(*wrapped, close_others: true)
261
+ Thread.new { stderr_io.read rescue nil }
262
+
263
+ # MCP handshake
264
+ init_msg = json_rpc("initialize", {
265
+ protocolVersion: "2024-11-05",
266
+ capabilities: {},
267
+ clientInfo: { name: "octo", version: "1.0" }
268
+ }, id: 1)
269
+
270
+ notify_msg = JSON.generate({
271
+ jsonrpc: "2.0",
272
+ method: "notifications/initialized",
273
+ params: {}
274
+ })
275
+
276
+ Octo::Logger.debug("[BrowserManager] Sending MCP initialize...")
277
+ stdin.write(init_msg + "\n")
278
+ stdin.flush
279
+
280
+ init_resp = read_response(stdout, target_id: 1,
281
+ timeout: Octo::Tools::Browser::MCP_HANDSHAKE_TIMEOUT)
282
+ unless init_resp
283
+ Octo::Logger.error("[BrowserManager] MCP initialize handshake timed out after #{Octo::Tools::Browser::MCP_HANDSHAKE_TIMEOUT}s")
284
+ Process.kill("TERM", wait_thr.pid) rescue nil
285
+ raise "Chrome MCP initialize handshake timed out"
286
+ end
287
+
288
+ Octo::Logger.debug("[BrowserManager] MCP initialize successful, sending initialized notification...")
289
+ stdin.write(notify_msg + "\n")
290
+ stdin.flush
291
+
292
+ @process = { stdin: stdin, stdout: stdout, pid: wait_thr.pid, wait_thr: wait_thr }
293
+ @call_id = 2
294
+ Octo::Logger.info("[BrowserManager] MCP daemon started successfully (pid=#{wait_thr.pid})")
295
+ end
296
+
297
+ # Build chrome-devtools-mcp command with explicit connection parameters.
298
+ # Always uses the detected browser endpoint (no --autoConnect fallback).
299
+ # @param detected [Hash] { mode: :ws_endpoint, value: String } from BrowserDetector
300
+ # @return [Array<String>] command array
301
+ def build_mcp_command(detected)
302
+ args = chrome_mcp_feature_flags
303
+
304
+ case detected[:mode]
305
+ when :ws_endpoint
306
+ Octo::Logger.info("[BrowserManager] Using ws_endpoint mode: #{detected[:value]}")
307
+ ["chrome-devtools-mcp", *args, "--wsEndpoint", detected[:value]]
308
+ else
309
+ raise "Unknown detection mode: #{detected[:mode]}"
310
+ end
311
+ end
312
+
313
+ # Shell-escape a single argv token for safe interpolation into a `-c` string.
314
+ def shell_escape(token)
315
+ Shellwords.escape(token.to_s)
316
+ end
317
+
318
+ # Feature flags for chrome-devtools-mcp
319
+ def chrome_mcp_feature_flags
320
+ %w[
321
+ --experimentalStructuredContent
322
+ --experimental-page-id-routing
323
+ --experimentalVision
324
+ ]
325
+ end
326
+
327
+ # Must be called inside @mutex.
328
+ # Uses wait_thr.alive? as the primary liveness check — fast and reliable.
329
+ # Only falls back to an MCP ping if the thread is alive but we want to
330
+ # verify the protocol layer is responsive (currently skipped for simplicity).
331
+ # Kills the process only when the OS thread confirms it has actually exited.
332
+ def process_alive?
333
+ return false if @process.nil?
334
+
335
+ @process[:wait_thr]&.alive? == true
336
+ end
337
+
338
+ # Must be called inside @mutex.
339
+ # Clears @process immediately so other threads see it as gone, then
340
+ # closes IO handles and sends TERM. Uses wait_thr.join(2) in a background
341
+ # thread to reap the child and avoid zombie processes; escalates to KILL
342
+ # if the process doesn't exit within the grace period.
343
+ def kill_process!
344
+ ps = @process
345
+ return unless ps
346
+
347
+ @process = nil # Clear first — prevents other threads from re-entering
348
+
349
+ ps[:stdin].close rescue nil
350
+ ps[:stdout].close rescue nil
351
+ Process.kill("TERM", ps[:pid]) rescue nil
352
+
353
+ # Reap the child process asynchronously to avoid zombies
354
+ Thread.new do
355
+ Thread.current.name = "browser-manager-reap"
356
+ unless ps[:wait_thr].join(1)
357
+ Process.kill("KILL", ps[:pid]) rescue nil
358
+ end
359
+ rescue StandardError
360
+ nil
361
+ end
362
+
363
+ Octo::Logger.info("[BrowserManager] MCP daemon killed (pid=#{ps[:pid]})")
364
+ end
365
+
366
+ def json_rpc(method, params, id:)
367
+ JSON.generate({ jsonrpc: "2.0", id: id, method: method, params: params })
368
+ end
369
+
370
+ def read_response(io, target_id:, timeout: 10)
371
+ Timeout.timeout(timeout) do
372
+ loop do
373
+ line = io.gets
374
+ break if line.nil?
375
+ line = line.strip
376
+ next if line.empty?
377
+ begin
378
+ msg = JSON.parse(line)
379
+ return msg if msg.is_a?(Hash) && msg["id"] == target_id
380
+ rescue JSON::ParserError
381
+ next
382
+ end
383
+ end
384
+ nil
385
+ end
386
+ rescue Timeout::Error
387
+ nil
388
+ end
389
+
390
+ def extract_text_content(result)
391
+ Array(result["content"])
392
+ .select { |b| b.is_a?(Hash) && b["type"] == "text" }
393
+ .map { |b| b["text"].to_s }
394
+ .join("\n")
395
+ end
396
+ end
397
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ module Channel
5
+ module Adapters
6
+ # Adapter registry: maps platform symbol → adapter class.
7
+ # Each adapter registers itself by calling Adapters.register at load time.
8
+ @registry = {}
9
+
10
+ def self.register(platform, klass)
11
+ @registry[platform] = klass
12
+ end
13
+
14
+ def self.find(platform)
15
+ @registry[platform.to_sym]
16
+ end
17
+
18
+ def self.all
19
+ @registry.values
20
+ end
21
+
22
+ # Base adapter interface for IM platforms.
23
+ # Subclasses must implement every abstract method below.
24
+ class Base
25
+ # @return [Symbol] e.g. :feishu, :wecom
26
+ def self.platform_id
27
+ raise NotImplementedError, "#{self} must implement .platform_id"
28
+ end
29
+
30
+ # Map raw config hash (from ChannelConfig) to symbol-keyed platform config.
31
+ # @param raw [Hash] symbol-keyed raw config
32
+ # @return [Hash]
33
+ def self.platform_config(raw)
34
+ raise NotImplementedError, "#{self} must implement .platform_config"
35
+ end
36
+
37
+ # @return [Symbol]
38
+ def platform_id
39
+ self.class.platform_id
40
+ end
41
+
42
+ # Start the adapter and begin receiving messages.
43
+ # This method blocks until stopped — call it inside a Thread.
44
+ # @yield [event Hash] yields one standardized event per inbound message
45
+ def start(&on_message)
46
+ raise NotImplementedError, "#{self.class} must implement #start"
47
+ end
48
+
49
+ # Stop the adapter and release resources.
50
+ def stop
51
+ raise NotImplementedError, "#{self.class} must implement #stop"
52
+ end
53
+
54
+ # Send a plain text (or Markdown) message to a chat.
55
+ # @param chat_id [String]
56
+ # @param text [String]
57
+ # @param reply_to [String, nil] optional message_id to thread under
58
+ # @return [Hash] { message_id: String }
59
+ def send_text(chat_id, text, reply_to: nil)
60
+ raise NotImplementedError, "#{self.class} must implement #send_text"
61
+ end
62
+
63
+ # Update an existing message in-place (for streaming progress).
64
+ # @return [Boolean] true if successful
65
+ def update_message(chat_id, message_id, text)
66
+ false
67
+ end
68
+
69
+ # @return [Boolean] true if the platform supports editing a sent message
70
+ def supports_message_updates?
71
+ false
72
+ end
73
+
74
+ # Validate the provided config hash.
75
+ # @return [Array<String>] list of error strings; empty means valid
76
+ def validate_config(config)
77
+ []
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end