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,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Octo
6
+ module Utils
7
+ # Detects a running browser (Chrome/Edge) that has remote debugging enabled.
8
+ #
9
+ # Detection strategy:
10
+ #
11
+ # 1. Scan known UserData directories for DevToolsActivePort file.
12
+ # This file contains the exact port + WS path — most reliable.
13
+ # Returns { mode: :ws_endpoint, value: "ws://127.0.0.1:PORT/PATH" }
14
+ #
15
+ # 2. Verify the port is actually reachable via TCP probe.
16
+ #
17
+ # 3. Nothing found or port unreachable → returns nil (browser not running).
18
+ #
19
+ # Supported environments: WSL, Linux, macOS.
20
+ module BrowserDetector
21
+
22
+ # Detect a running debuggable browser.
23
+ # Scans for DevToolsActivePort file across all platforms (macOS/Linux/WSL).
24
+ # Returns the detected WebSocket endpoint only if the port is reachable.
25
+ # @return [Hash] { mode: :ws_endpoint, value: String, status: :ok|:not_found }
26
+ def self.detect
27
+ os = EnvironmentDetector.os_type
28
+ Octo::Logger.debug("[BrowserDetector] Starting browser detection (OS: #{os})...")
29
+
30
+ detected = detect_via_active_port_file
31
+
32
+ unless detected
33
+ Octo::Logger.warn("[BrowserDetector] ✗ No reachable browser found")
34
+ return { status: :not_found }
35
+ end
36
+
37
+ Octo::Logger.info("[BrowserDetector] ✓ Browser detected and reachable: #{detected[:mode]} → #{detected[:value]}")
38
+ detected.merge(status: :ok)
39
+ end
40
+
41
+ # -----------------------------------------------------------------------
42
+ # DevToolsActivePort file scan
43
+ # -----------------------------------------------------------------------
44
+
45
+ # @return [Hash, nil]
46
+ def self.detect_via_active_port_file
47
+ Octo::Logger.debug("[BrowserDetector] Scanning UserData directories for DevToolsActivePort...")
48
+
49
+ dirs = user_data_dirs
50
+ Octo::Logger.debug("[BrowserDetector] Candidate directories: #{dirs.size} found")
51
+
52
+ dirs.each do |dir|
53
+ port_file = File.join(dir, "DevToolsActivePort")
54
+ next unless File.exist?(port_file)
55
+
56
+ Octo::Logger.debug("[BrowserDetector] Found DevToolsActivePort: #{port_file}")
57
+
58
+ ws = parse_active_port_file(port_file)
59
+ unless ws
60
+ Octo::Logger.debug("[BrowserDetector] ✗ Failed to parse #{port_file}")
61
+ next
62
+ end
63
+
64
+ Octo::Logger.debug("[BrowserDetector] Parsed WS endpoint: #{ws}")
65
+
66
+ # ⭐️ Verify port BEFORE returning — skip stale files
67
+ candidate = { mode: :ws_endpoint, value: ws }
68
+ if verify_port(candidate)
69
+ Octo::Logger.debug("[BrowserDetector] ✓ Port is reachable, using this endpoint")
70
+ return candidate
71
+ else
72
+ Octo::Logger.debug("[BrowserDetector] ✗ Port not reachable, trying next directory...")
73
+ end
74
+ end
75
+
76
+ Octo::Logger.debug("[BrowserDetector] No reachable browser found")
77
+ nil
78
+ end
79
+
80
+ # Verify that the detected browser port is actually reachable.
81
+ # Extracts port from ws:// URL and attempts TCP connection.
82
+ # @param detected [Hash] { mode: :ws_endpoint, value: String }
83
+ # @return [Boolean] true if port is open and reachable
84
+ def self.verify_port(detected)
85
+ return false unless detected
86
+
87
+ port = case detected[:mode]
88
+ when :ws_endpoint
89
+ # ws://127.0.0.1:9222/devtools/...
90
+ detected[:value][/ws:\/\/127\.0\.0\.1:(\d+)/, 1]&.to_i
91
+ end
92
+
93
+ return false unless port && port > 0
94
+
95
+ reachable = tcp_open?("127.0.0.1", port)
96
+ Octo::Logger.debug("[BrowserDetector] Port #{port} reachable: #{reachable}")
97
+ reachable
98
+ end
99
+
100
+ # -----------------------------------------------------------------------
101
+ # UserData directory candidates per OS
102
+ # -----------------------------------------------------------------------
103
+
104
+ # Returns ordered list of candidate UserData dirs to check.
105
+ # @return [Array<String>]
106
+ def self.user_data_dirs
107
+ os = EnvironmentDetector.os_type
108
+ Octo::Logger.debug("[BrowserDetector] Detected OS: #{os}")
109
+
110
+ case os
111
+ when :wsl then wsl_user_data_dirs
112
+ when :linux then linux_user_data_dirs
113
+ when :macos then macos_user_data_dirs
114
+ else
115
+ Octo::Logger.warn("[BrowserDetector] Unknown OS type: #{os}")
116
+ []
117
+ end
118
+ end
119
+
120
+ # WSL: Chrome/Edge run on Windows side — resolve via LOCALAPPDATA.
121
+ private_class_method def self.wsl_user_data_dirs
122
+ appdata = Utils::Encoding.cmd_to_utf8(
123
+ `powershell.exe -NoProfile -Command '$env:LOCALAPPDATA' 2>/dev/null`
124
+ ).strip.tr("\r\n", "")
125
+ return [] if appdata.empty?
126
+
127
+ win_paths = [
128
+ "#{appdata}\\Microsoft\\Edge\\User Data",
129
+ "#{appdata}\\Google\\Chrome\\User Data",
130
+ "#{appdata}\\Google\\Chrome Beta\\User Data",
131
+ "#{appdata}\\Google\\Chrome SxS\\User Data",
132
+ ]
133
+
134
+ win_paths.filter_map do |win_path|
135
+ linux_path = Utils::Encoding.cmd_to_utf8(
136
+ `wslpath '#{win_path}' 2>/dev/null`, source_encoding: "UTF-8"
137
+ ).strip
138
+ linux_path.empty? ? nil : linux_path
139
+ end
140
+ end
141
+
142
+ # Linux: standard XDG config paths for Chrome and Edge.
143
+ private_class_method def self.linux_user_data_dirs
144
+ config_home = ENV["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config")
145
+ [
146
+ File.join(config_home, "microsoft-edge"),
147
+ File.join(config_home, "google-chrome"),
148
+ File.join(config_home, "google-chrome-beta"),
149
+ File.join(config_home, "google-chrome-unstable"),
150
+ ]
151
+ end
152
+
153
+ # macOS: Application Support paths for Chrome and Edge.
154
+ private_class_method def self.macos_user_data_dirs
155
+ base = File.join(Dir.home, "Library", "Application Support")
156
+ [
157
+ File.join(base, "Microsoft Edge"),
158
+ File.join(base, "Google", "Chrome"),
159
+ File.join(base, "Google", "Chrome Beta"),
160
+ File.join(base, "Google", "Chrome Canary"),
161
+ ]
162
+ end
163
+
164
+ # -----------------------------------------------------------------------
165
+ # Helpers
166
+ # -----------------------------------------------------------------------
167
+
168
+ # Parse DevToolsActivePort file.
169
+ # Format: first line = port number, second line = WS path
170
+ # @return [String, nil] ws://127.0.0.1:PORT/PATH or nil on parse error
171
+ private_class_method def self.parse_active_port_file(path)
172
+ lines = File.read(path, encoding: "utf-8").split("\n").map(&:strip).reject(&:empty?)
173
+ return nil unless lines.size >= 2
174
+
175
+ port = lines[0].to_i
176
+ ws_path = lines[1]
177
+ return nil if port <= 0 || port > 65_535 || ws_path.empty?
178
+
179
+ "ws://127.0.0.1:#{port}#{ws_path}"
180
+ rescue StandardError
181
+ nil
182
+ end
183
+
184
+ # Probe TCP port with a short timeout to verify port is actually reachable.
185
+ # @param host [String] hostname
186
+ # @param port [Integer] port number
187
+ # @return [Boolean] true if port is open and reachable
188
+ private_class_method def self.tcp_open?(host, port)
189
+ Socket.tcp(host, port, connect_timeout: 0.5) { true }
190
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError, Errno::EHOSTUNREACH
191
+ false
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ module Utils
5
+ # Centralised UTF-8 encoding helpers used throughout the codebase.
6
+ #
7
+ # Three distinct use-cases exist:
8
+ #
9
+ # 1. to_utf8 – binary/unknown bytes → valid UTF-8 String.
10
+ # Used when reading shell output, HTTP response bodies,
11
+ # or any raw byte stream that is *expected* to be UTF-8
12
+ # but arrives with ASCII-8BIT (binary) encoding.
13
+ # Strategy: force_encoding("UTF-8") then scrub invalid
14
+ # sequences with U+FFFD so multibyte characters (CJK,
15
+ # emoji, …) are preserved as-is.
16
+ #
17
+ # 2. sanitize_utf8 – UTF-8 String → clean UTF-8 String.
18
+ # Used for UI rendering (terminal output, screen
19
+ # buffers) where the string is already nominally UTF-8
20
+ # but may still contain isolated invalid bytes.
21
+ # Strategy: encode UTF-8→UTF-8 replacing invalid /
22
+ # undefined codepoints with an empty string so the
23
+ # rendered output never contains replacement characters.
24
+ #
25
+ # 3. safe_check – any String → ASCII-safe UTF-8 String for regex.
26
+ # Used only for security pattern matching (terminal/Security).
27
+ # Multibyte bytes are replaced with '?' so that Ruby's
28
+ # regex engine operates on a plain ASCII-compatible
29
+ # string without raising Encoding errors.
30
+ #
31
+ module Encoding
32
+ # Convert a binary (or unknown-encoding) byte string to a valid UTF-8
33
+ # String. Multibyte sequences that are already valid UTF-8 (e.g. CJK
34
+ # characters) are preserved unchanged; only genuinely invalid byte
35
+ # sequences are replaced with U+FFFD (the Unicode replacement character).
36
+ #
37
+ # @param data [String, nil] raw bytes, typically from a pipe or HTTP body
38
+ # @return [String] valid UTF-8 string
39
+ def self.to_utf8(data)
40
+ return "" if data.nil? || data.empty?
41
+
42
+ data.dup.force_encoding("UTF-8").scrub("\u{FFFD}")
43
+ end
44
+
45
+ # Clean an already-UTF-8 string by removing (not replacing) any invalid
46
+ # or undefined byte sequences. Suitable for terminal / UI rendering where
47
+ # replacement characters would appear as visual noise.
48
+ #
49
+ # @param str [String, nil] nominally UTF-8 string
50
+ # @return [String] clean UTF-8 string (invalid bytes silently dropped)
51
+ def self.sanitize_utf8(str)
52
+ return "" if str.nil? || str.empty?
53
+
54
+ str.encode("UTF-8", "UTF-8", invalid: :replace, undef: :replace, replace: "")
55
+ end
56
+
57
+ # Convert raw shell command output to valid UTF-8.
58
+ # Handles two common cases:
59
+ # - Windows commands (e.g. powershell.exe) that output GBK/CP936 bytes
60
+ # - Unix commands that output UTF-8 or ASCII bytes with ASCII-8BIT encoding
61
+ #
62
+ # Strategy: try GBK decode first (superset of ASCII, covers Chinese Windows);
63
+ # if that fails fall back to UTF-8 scrub.
64
+ #
65
+ # @param data [String, nil] raw bytes from backtick / IO.popen
66
+ # @param source_encoding [String] hint for source encoding (default: "GBK")
67
+ # @return [String] valid UTF-8 string
68
+ def self.cmd_to_utf8(data, source_encoding: "GBK")
69
+ return "" if data.nil? || data.empty?
70
+
71
+ data.dup
72
+ .force_encoding(source_encoding)
73
+ .encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
74
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
75
+ to_utf8(data)
76
+ end
77
+
78
+ # Return an ASCII-safe UTF-8 copy of *str* suitable for security regex
79
+ # pattern matching. Any byte that is not valid in the source encoding, or
80
+ # that cannot be represented in UTF-8, is replaced with '?'. The
81
+ # original string is never mutated.
82
+ #
83
+ # @param str [String, nil]
84
+ # @return [String] UTF-8 string safe for regex matching
85
+ def self.safe_check(str)
86
+ return "" if str.nil? || str.empty?
87
+
88
+ str.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ module Utils
5
+ # Detects the current operating system environment and desktop path.
6
+ module EnvironmentDetector
7
+ # Detect OS type.
8
+ # @return [Symbol] :wsl, :linux, :macos, or :unknown
9
+ def self.os_type
10
+ return @os_type if defined?(@os_type)
11
+
12
+ @os_type = if wsl?
13
+ :wsl
14
+ elsif RUBY_PLATFORM.include?("darwin")
15
+ :macos
16
+ elsif RUBY_PLATFORM.include?("linux")
17
+ :linux
18
+ else
19
+ :unknown
20
+ end
21
+ end
22
+
23
+ # Open a file with the OS-default application.
24
+ # On WSL, uses "cmd.exe /c start" instead of explorer.exe so the opened
25
+ # window receives foreground focus even when called from a background
26
+ # thread (e.g. WEBrick request handler).
27
+ # @param path [String] Linux-side file path
28
+ # @return [Boolean, nil] true/false from system(), or nil on unsupported OS
29
+ def self.open_file(path)
30
+ case os_type
31
+ when :macos then system("open", path)
32
+ when :linux then system("xdg-open", path)
33
+ when :wsl
34
+ win_path = linux_to_win_path(path)
35
+ system("cmd.exe", "/c", "start", "", win_path)
36
+ end
37
+ end
38
+
39
+ # Convert a Windows-style path to a WSL/Linux-side path.
40
+ # e.g. "C:/Users/foo/file.txt" → "/mnt/c/Users/foo/file.txt"
41
+ # Returns the original path unchanged on non-WSL or if already a Linux path.
42
+ # @param path [String]
43
+ # @return [String]
44
+ def self.win_to_linux_path(path)
45
+ return path unless os_type == :wsl && path.match?(/\A[A-Za-z]:[\/\\]/)
46
+
47
+ drive = path[0].downcase
48
+ rest = path[2..].gsub("\\", "/")
49
+ "/mnt/#{drive}#{rest}"
50
+ end
51
+
52
+ # Convert a Linux-side path to a Windows-style path via wslpath.
53
+ # e.g. "/mnt/c/Users/foo/file.txt" → "C:\Users\foo\file.txt"
54
+ # Returns the original path unchanged on non-WSL.
55
+ # @param path [String]
56
+ # @return [String]
57
+ def self.linux_to_win_path(path)
58
+ return path unless os_type == :wsl
59
+
60
+ Octo::Utils::Encoding.cmd_to_utf8(
61
+ `wslpath -w '#{path.gsub("'", "'\''")}'`,
62
+ source_encoding: "UTF-8"
63
+ ).strip
64
+ end
65
+
66
+ # Human-readable OS label for injection into session context.
67
+ def self.os_label
68
+ case os_type
69
+ when :wsl then "WSL/Windows"
70
+ when :macos then "macOS"
71
+ when :linux then "Linux"
72
+ else "Unknown"
73
+ end
74
+ end
75
+
76
+ # Detect the desktop directory path for the current environment.
77
+ # @return [String, nil] absolute path to desktop, or nil if not found
78
+ def self.desktop_path
79
+ return @desktop_path if defined?(@desktop_path)
80
+
81
+ @desktop_path = case os_type
82
+ when :wsl
83
+ wsl_desktop_path
84
+ when :macos
85
+ macos_desktop_path
86
+ when :linux
87
+ linux_desktop_path
88
+ else
89
+ fallback_desktop_path
90
+ end
91
+ end
92
+
93
+ def self.wsl?
94
+ File.exist?("/proc/version") &&
95
+ File.read("/proc/version").downcase.include?("microsoft")
96
+ rescue
97
+ false
98
+ end
99
+
100
+ private_class_method def self.wsl_desktop_path
101
+ if Utils::Encoding.cmd_to_utf8(`which powershell.exe 2>/dev/null`).strip.empty?
102
+ return fallback_desktop_path
103
+ end
104
+
105
+ # powershell.exe on Chinese Windows outputs GBK bytes; decode explicitly
106
+ win_path = Utils::Encoding.cmd_to_utf8(
107
+ `powershell.exe -NoProfile -Command '[Environment]::GetFolderPath("Desktop")' 2>/dev/null`
108
+ ).strip.tr("\r\n", "")
109
+ return fallback_desktop_path if win_path.empty?
110
+
111
+ # wslpath output is UTF-8 (Linux side)
112
+ linux_path = Utils::Encoding.cmd_to_utf8(`wslpath '#{win_path}' 2>/dev/null`, source_encoding: "UTF-8").strip
113
+ return linux_path if !linux_path.empty? && Dir.exist?(linux_path)
114
+
115
+ fallback_desktop_path
116
+ end
117
+
118
+ private_class_method def self.linux_desktop_path
119
+ path = Utils::Encoding.cmd_to_utf8(`xdg-user-dir DESKTOP 2>/dev/null`, source_encoding: "UTF-8").strip
120
+ return path if !path.empty? && path != Dir.home && Dir.exist?(path)
121
+
122
+ fallback_desktop_path
123
+ end
124
+
125
+ private_class_method def self.macos_desktop_path
126
+ path = Utils::Encoding.cmd_to_utf8(`osascript -e 'POSIX path of (path to desktop)' 2>/dev/null`, source_encoding: "UTF-8").strip.chomp("/")
127
+ return path if !path.empty? && Dir.exist?(path)
128
+
129
+ fallback_desktop_path
130
+ end
131
+
132
+ private_class_method def self.fallback_desktop_path
133
+ [
134
+ File.join(Dir.home, "Desktop"),
135
+ File.join(Dir.home, "桌面"),
136
+ ].find { |p| Dir.exist?(p) }
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ module Utils
5
+ # Helper module for file ignoring functionality shared between tools
6
+ module FileIgnoreHelper
7
+ # Default patterns to ignore when .gitignore is not available
8
+ DEFAULT_IGNORED_PATTERNS = [
9
+ 'node_modules',
10
+ 'vendor/bundle',
11
+ '.git',
12
+ '.svn',
13
+ 'tmp',
14
+ 'log',
15
+ 'coverage',
16
+ 'dist',
17
+ 'build',
18
+ '.bundle',
19
+ '.sass-cache',
20
+ '.DS_Store',
21
+ '*.log'
22
+ ].freeze
23
+
24
+ # Config file patterns that should always be searchable/visible
25
+ CONFIG_FILE_PATTERNS = [
26
+ /\.env/,
27
+ /\.ya?ml$/,
28
+ /\.json$/,
29
+ /\.toml$/,
30
+ /\.ini$/,
31
+ /\.conf$/,
32
+ /\.config$/,
33
+ ].freeze
34
+
35
+ # Find .gitignore file in the search path or parent directories
36
+ # Only searches within the search path and up to the current working directory
37
+ def self.find_gitignore(path)
38
+ search_path = File.directory?(path) ? path : File.dirname(path)
39
+
40
+ # Look for .gitignore in current and parent directories
41
+ current = File.expand_path(search_path)
42
+ cwd = File.expand_path(Dir.pwd) # intentional: gitignore boundary uses process cwd as fallback
43
+ root = File.expand_path('/')
44
+
45
+ # Limit search: only go up to current working directory
46
+ # This prevents finding .gitignore files from unrelated parent directories
47
+ # when searching in temporary directories (like /tmp in tests)
48
+ search_limit = if current.start_with?(cwd)
49
+ cwd
50
+ else
51
+ current
52
+ end
53
+
54
+ loop do
55
+ gitignore = File.join(current, '.gitignore')
56
+ return gitignore if File.exist?(gitignore)
57
+
58
+ # Stop if we've reached the search limit or root
59
+ break if current == search_limit || current == root
60
+ current = File.dirname(current)
61
+ end
62
+
63
+ nil
64
+ end
65
+
66
+ # Directories that are always ignored regardless of .gitignore rules
67
+ ALWAYS_IGNORED_DIRS = ['.git', '.svn', '.hg'].freeze
68
+
69
+ # Check if file should be ignored based on .gitignore or default patterns
70
+ def self.should_ignore_file?(file, base_path, gitignore)
71
+ # Always calculate path relative to base_path for consistency
72
+ # Expand both paths to handle symlinks and relative paths correctly
73
+ expanded_file = File.expand_path(file)
74
+ expanded_base = File.expand_path(base_path)
75
+
76
+ # For files, use the directory as base
77
+ expanded_base = File.dirname(expanded_base) if File.file?(expanded_base)
78
+
79
+ # Calculate relative path
80
+ if expanded_file.start_with?(expanded_base)
81
+ relative_path = expanded_file[(expanded_base.length + 1)..-1] || File.basename(expanded_file)
82
+ else
83
+ # File is outside base path - use just the filename
84
+ relative_path = File.basename(expanded_file)
85
+ end
86
+
87
+ # Clean up relative path
88
+ relative_path = relative_path.sub(/^\.\//, '') if relative_path
89
+
90
+ # Always ignore version control directories regardless of .gitignore rules
91
+ return true if ALWAYS_IGNORED_DIRS.any? do |dir|
92
+ relative_path.start_with?("#{dir}/") || relative_path == dir
93
+ end
94
+
95
+ if gitignore
96
+ # Use .gitignore rules
97
+ gitignore.ignored?(relative_path)
98
+ else
99
+ # Use default ignore patterns - only match against relative path components
100
+ DEFAULT_IGNORED_PATTERNS.any? do |pattern|
101
+ if pattern.include?('*')
102
+ File.fnmatch(pattern, relative_path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
103
+ else
104
+ # Match pattern as a path component (not substring of absolute path)
105
+ relative_path.start_with?("#{pattern}/") ||
106
+ relative_path.include?("/#{pattern}/") ||
107
+ relative_path == pattern ||
108
+ File.basename(relative_path) == pattern
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ # Check if file is a config file (should not be ignored even if in .gitignore)
115
+ def self.is_config_file?(file)
116
+ CONFIG_FILE_PATTERNS.any? { |pattern| file.match?(pattern) }
117
+ end
118
+
119
+ # Walk a directory tree, pruning ignored directories early.
120
+ # Yields each non-ignored file path. Supports nested .gitignore files.
121
+ # @param skipped [Hash, nil] If provided, increments :ignored for each gitignore-skipped entry.
122
+ def self.walk_files(base_path, gitignore: nil, skipped: nil, &block)
123
+ return enum_for(:walk_files, base_path, gitignore: gitignore, skipped: skipped) unless block_given?
124
+
125
+ root_gitignore = gitignore || begin
126
+ gi_path = find_gitignore(base_path)
127
+ gi_path ? Octo::GitignoreParser.new(gi_path) : nil
128
+ end
129
+
130
+ _walk_recursive(base_path, base_path, root_gitignore, skipped, &block)
131
+ end
132
+
133
+ def self._walk_recursive(dir, base_path, gitignore, skipped, &block)
134
+ child_gitignore_path = File.join(dir, ".gitignore")
135
+ if dir != base_path && File.exist?(child_gitignore_path)
136
+ gitignore ||= Octo::GitignoreParser.new(nil)
137
+ relative_dir = dir[(base_path.length + 1)..]
138
+ gitignore.merge!(child_gitignore_path, prefix: relative_dir)
139
+ end
140
+
141
+ begin
142
+ entries = Dir.children(dir)
143
+ rescue Errno::EACCES, Errno::ENOENT
144
+ return
145
+ end
146
+
147
+ entries.sort.each do |name|
148
+ full = File.join(dir, name)
149
+ relative = full[(base_path.length + 1)..]
150
+
151
+ if File.directory?(full)
152
+ next if ALWAYS_IGNORED_DIRS.include?(name)
153
+ if gitignore&.ignored?("#{relative}/") || should_ignore_file?(full, base_path, gitignore)
154
+ next
155
+ end
156
+ _walk_recursive(full, base_path, gitignore, skipped, &block)
157
+ else
158
+ if !is_config_file?(full) && should_ignore_file?(full, base_path, gitignore)
159
+ skipped[:ignored] += 1 if skipped
160
+ next
161
+ end
162
+ yield full
163
+ end
164
+ end
165
+ end
166
+ private_class_method :_walk_recursive
167
+
168
+ end
169
+ end
170
+ end