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.
- checksums.yaml +7 -0
- data/.clacky/skills/commit/SKILL.md +423 -0
- data/.clacky/skills/gem-release/SKILL.md +199 -0
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/.clacky/skills/oss-upload/SKILL.md +47 -0
- data/.octorules +106 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +76 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +92 -0
- data/Dockerfile +28 -0
- data/LICENSE.txt +22 -0
- data/POSITIONING.md +46 -0
- data/README.md +134 -0
- data/README_CN.md +134 -0
- data/Rakefile +34 -0
- data/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/bin/octo +7 -0
- data/docs/agent-first-ui-design.md +77 -0
- data/docs/billing-system.md +318 -0
- data/docs/channel-architecture.md +235 -0
- data/docs/engineering-article.md +343 -0
- data/docs/session-skill-invocation.md +69 -0
- data/docs/time_machine_design.md +247 -0
- data/docs/ui2-architecture.md +124 -0
- data/homebrew/README.md +96 -0
- data/homebrew/openocto.rb +24 -0
- data/lib/octo/agent/hook_manager.rb +61 -0
- data/lib/octo/agent/llm_caller.rb +800 -0
- data/lib/octo/agent/memory_updater.rb +246 -0
- data/lib/octo/agent/message_compressor.rb +225 -0
- data/lib/octo/agent/message_compressor_helper.rb +869 -0
- data/lib/octo/agent/next_message_suggester.rb +215 -0
- data/lib/octo/agent/session_serializer.rb +685 -0
- data/lib/octo/agent/skill_auto_creator.rb +114 -0
- data/lib/octo/agent/skill_evolution.rb +61 -0
- data/lib/octo/agent/skill_manager.rb +466 -0
- data/lib/octo/agent/skill_reflector.rb +89 -0
- data/lib/octo/agent/system_prompt_builder.rb +101 -0
- data/lib/octo/agent/time_machine.rb +214 -0
- data/lib/octo/agent/tool_executor.rb +454 -0
- data/lib/octo/agent/tool_registry.rb +150 -0
- data/lib/octo/agent.rb +2180 -0
- data/lib/octo/agent_config.rb +989 -0
- data/lib/octo/agent_profile.rb +112 -0
- data/lib/octo/anthropic_stream_aggregator.rb +137 -0
- data/lib/octo/background_task_registry.rb +324 -0
- data/lib/octo/banner.rb +34 -0
- data/lib/octo/bedrock_stream_aggregator.rb +137 -0
- data/lib/octo/block_font.rb +331 -0
- data/lib/octo/cli.rb +968 -0
- data/lib/octo/client.rb +623 -0
- data/lib/octo/default_agents/SOUL.md +3 -0
- data/lib/octo/default_agents/USER.md +1 -0
- data/lib/octo/default_agents/base_prompt.md +66 -0
- data/lib/octo/default_agents/coding/profile.yml +2 -0
- data/lib/octo/default_agents/coding/system_prompt.md +67 -0
- data/lib/octo/default_agents/general/profile.yml +2 -0
- data/lib/octo/default_agents/general/system_prompt.md +16 -0
- data/lib/octo/default_parsers/doc_parser.rb +69 -0
- data/lib/octo/default_parsers/docx_parser.rb +188 -0
- data/lib/octo/default_parsers/pdf_parser.rb +120 -0
- data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/octo/default_parsers/pptx_parser.rb +140 -0
- data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
- data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
- data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
- data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
- data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
- data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
- data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
- data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
- data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
- data/lib/octo/default_skills/onboard/SKILL.md +578 -0
- data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
- data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
- data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
- data/lib/octo/default_skills/personal-website/publish.rb +235 -0
- data/lib/octo/default_skills/product-help/SKILL.md +123 -0
- data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
- data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
- data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
- data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
- data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
- data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
- data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
- data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
- data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
- data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
- data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
- data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
- data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
- data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
- data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
- data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
- data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
- data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
- data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
- data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
- data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
- data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
- data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
- data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
- data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
- data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
- data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
- data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
- data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
- data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
- data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
- data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
- data/lib/octo/idle_compression_timer.rb +115 -0
- data/lib/octo/json_ui_controller.rb +204 -0
- data/lib/octo/message_format/anthropic.rb +409 -0
- data/lib/octo/message_format/bedrock.rb +361 -0
- data/lib/octo/message_format/open_ai.rb +222 -0
- data/lib/octo/message_history.rb +373 -0
- data/lib/octo/openai_stream_aggregator.rb +130 -0
- data/lib/octo/plain_ui_controller.rb +166 -0
- data/lib/octo/providers.rb +534 -0
- data/lib/octo/server/browser_manager.rb +397 -0
- data/lib/octo/server/channel/adapters/base.rb +82 -0
- data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
- data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
- data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
- data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
- data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
- data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
- data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
- data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
- data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
- data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
- data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
- data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
- data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
- data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
- data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
- data/lib/octo/server/channel/channel_config.rb +178 -0
- data/lib/octo/server/channel/channel_manager.rb +468 -0
- data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
- data/lib/octo/server/channel.rb +33 -0
- data/lib/octo/server/discover.rb +77 -0
- data/lib/octo/server/epipe_safe_io.rb +105 -0
- data/lib/octo/server/http_server.rb +3554 -0
- data/lib/octo/server/scheduler.rb +317 -0
- data/lib/octo/server/server_master.rb +325 -0
- data/lib/octo/server/session_registry.rb +431 -0
- data/lib/octo/server/web_ui_controller.rb +487 -0
- data/lib/octo/session_manager.rb +385 -0
- data/lib/octo/skill.rb +466 -0
- data/lib/octo/skill_loader.rb +328 -0
- data/lib/octo/tools/base.rb +118 -0
- data/lib/octo/tools/browser.rb +625 -0
- data/lib/octo/tools/edit.rb +165 -0
- data/lib/octo/tools/file_reader.rb +549 -0
- data/lib/octo/tools/glob.rb +162 -0
- data/lib/octo/tools/grep.rb +356 -0
- data/lib/octo/tools/invoke_skill.rb +96 -0
- data/lib/octo/tools/list_tasks.rb +54 -0
- data/lib/octo/tools/redo_task.rb +41 -0
- data/lib/octo/tools/request_user_feedback.rb +84 -0
- data/lib/octo/tools/security.rb +333 -0
- data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
- data/lib/octo/tools/terminal/persistent_session.rb +268 -0
- data/lib/octo/tools/terminal/safe_rm.sh +106 -0
- data/lib/octo/tools/terminal/session_manager.rb +213 -0
- data/lib/octo/tools/terminal.rb +1828 -0
- data/lib/octo/tools/todo_manager.rb +374 -0
- data/lib/octo/tools/trash_manager.rb +388 -0
- data/lib/octo/tools/undo_task.rb +35 -0
- data/lib/octo/tools/web_fetch.rb +242 -0
- data/lib/octo/tools/web_search.rb +260 -0
- data/lib/octo/tools/write.rb +77 -0
- data/lib/octo/ui2/block_font.rb +10 -0
- data/lib/octo/ui2/components/base_component.rb +163 -0
- data/lib/octo/ui2/components/command_suggestions.rb +290 -0
- data/lib/octo/ui2/components/common_component.rb +96 -0
- data/lib/octo/ui2/components/inline_input.rb +226 -0
- data/lib/octo/ui2/components/input_area.rb +1338 -0
- data/lib/octo/ui2/components/message_component.rb +99 -0
- data/lib/octo/ui2/components/modal_component.rb +419 -0
- data/lib/octo/ui2/components/todo_area.rb +149 -0
- data/lib/octo/ui2/components/tool_component.rb +107 -0
- data/lib/octo/ui2/components/welcome_banner.rb +139 -0
- data/lib/octo/ui2/layout_manager.rb +807 -0
- data/lib/octo/ui2/line_editor.rb +363 -0
- data/lib/octo/ui2/markdown_renderer.rb +100 -0
- data/lib/octo/ui2/output_buffer.rb +370 -0
- data/lib/octo/ui2/progress_handle.rb +362 -0
- data/lib/octo/ui2/progress_indicator.rb +55 -0
- data/lib/octo/ui2/screen_buffer.rb +273 -0
- data/lib/octo/ui2/terminal_detector.rb +119 -0
- data/lib/octo/ui2/theme_manager.rb +85 -0
- data/lib/octo/ui2/themes/base_theme.rb +105 -0
- data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
- data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
- data/lib/octo/ui2/thinking_verbs.rb +26 -0
- data/lib/octo/ui2/ui_controller.rb +1625 -0
- data/lib/octo/ui2/view_renderer.rb +177 -0
- data/lib/octo/ui2.rb +40 -0
- data/lib/octo/ui_interface.rb +154 -0
- data/lib/octo/utils/arguments_parser.rb +191 -0
- data/lib/octo/utils/browser_detector.rb +195 -0
- data/lib/octo/utils/encoding.rb +92 -0
- data/lib/octo/utils/environment_detector.rb +140 -0
- data/lib/octo/utils/file_ignore_helper.rb +170 -0
- data/lib/octo/utils/file_processor.rb +601 -0
- data/lib/octo/utils/gitignore_parser.rb +154 -0
- data/lib/octo/utils/limit_stack.rb +152 -0
- data/lib/octo/utils/logger.rb +124 -0
- data/lib/octo/utils/login_shell.rb +72 -0
- data/lib/octo/utils/model_pricing.rb +646 -0
- data/lib/octo/utils/parser_manager.rb +165 -0
- data/lib/octo/utils/path_helper.rb +15 -0
- data/lib/octo/utils/scripts_manager.rb +59 -0
- data/lib/octo/utils/string_matcher.rb +158 -0
- data/lib/octo/utils/trash_directory.rb +112 -0
- data/lib/octo/utils/workspace_rules.rb +46 -0
- data/lib/octo/version.rb +5 -0
- data/lib/octo/web/app.css +7141 -0
- data/lib/octo/web/app.js +543 -0
- data/lib/octo/web/apple-touch-icon.png +0 -0
- data/lib/octo/web/auth.js +150 -0
- data/lib/octo/web/channels.js +276 -0
- data/lib/octo/web/datepicker.js +205 -0
- data/lib/octo/web/favicon.png +0 -0
- data/lib/octo/web/i18n.js +1073 -0
- data/lib/octo/web/icon-512.png +0 -0
- data/lib/octo/web/icon-dark.svg +25 -0
- data/lib/octo/web/icon.svg +29 -0
- data/lib/octo/web/index.html +871 -0
- data/lib/octo/web/marked.min.js +69 -0
- data/lib/octo/web/onboard.js +491 -0
- data/lib/octo/web/profile.js +442 -0
- data/lib/octo/web/sessions.js +4421 -0
- data/lib/octo/web/settings.js +913 -0
- data/lib/octo/web/sidebar.js +32 -0
- data/lib/octo/web/skills.js +885 -0
- data/lib/octo/web/tasks.js +297 -0
- data/lib/octo/web/theme.js +105 -0
- data/lib/octo/web/trash.js +343 -0
- data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
- data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/katex.min.css +1 -0
- data/lib/octo/web/vendor/katex/katex.min.js +1 -0
- data/lib/octo/web/version.js +449 -0
- data/lib/octo/web/weixin-qr.html +209 -0
- data/lib/octo/web/ws-dispatcher.js +357 -0
- data/lib/octo/web/ws.js +128 -0
- data/lib/octo.rb +145 -0
- data/scripts/build/build.sh +329 -0
- data/scripts/build/lib/apt.sh +56 -0
- data/scripts/build/lib/brew.sh +89 -0
- data/scripts/build/lib/colors.sh +17 -0
- data/scripts/build/lib/gem.sh +95 -0
- data/scripts/build/lib/mise.sh +125 -0
- data/scripts/build/lib/network.sh +157 -0
- data/scripts/build/lib/os.sh +57 -0
- data/scripts/build/lib/shell.sh +37 -0
- data/scripts/build/src/install.sh.cc +174 -0
- data/scripts/build/src/install_browser.sh.cc +101 -0
- data/scripts/build/src/install_full.sh.cc +290 -0
- data/scripts/build/src/install_rails_deps.sh.cc +145 -0
- data/scripts/build/src/install_system_deps.sh.cc +123 -0
- data/scripts/build/src/uninstall.sh.cc +101 -0
- data/scripts/install.ps1 +532 -0
- data/scripts/install.sh +567 -0
- data/scripts/install_browser.sh +479 -0
- data/scripts/install_full.sh +838 -0
- data/scripts/install_rails_deps.sh +746 -0
- data/scripts/install_system_deps.sh +518 -0
- data/scripts/uninstall.sh +287 -0
- data/sig/octo.rbs +4 -0
- 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
|