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,487 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require_relative "../ui_interface"
|
|
6
|
+
|
|
7
|
+
module Octo
|
|
8
|
+
module Server
|
|
9
|
+
# WebUIController implements UIInterface for the web server mode.
|
|
10
|
+
# Instead of writing to stdout, it broadcasts JSON events over WebSocket connections.
|
|
11
|
+
# Multiple browser tabs can subscribe to the same session_id.
|
|
12
|
+
#
|
|
13
|
+
# request_confirmation blocks the calling thread until the browser sends a response,
|
|
14
|
+
# mirroring the behaviour of JsonUIController (which reads from stdin).
|
|
15
|
+
class WebUIController
|
|
16
|
+
include Octo::UIInterface
|
|
17
|
+
|
|
18
|
+
attr_reader :session_id
|
|
19
|
+
|
|
20
|
+
def initialize(session_id, broadcaster)
|
|
21
|
+
@session_id = session_id
|
|
22
|
+
@broadcaster = broadcaster # callable: broadcaster.call(session_id, event_hash)
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
|
|
25
|
+
# Pending confirmation state: { id => ConditionVariable, result => value }
|
|
26
|
+
@pending_confirmations = {}
|
|
27
|
+
|
|
28
|
+
# Channel subscribers: array of objects implementing UIInterface.
|
|
29
|
+
# All emitted events are forwarded to each subscriber after WebSocket broadcast.
|
|
30
|
+
@channel_subscribers = []
|
|
31
|
+
@subscribers_mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Register a channel subscriber (e.g. ChannelUIController).
|
|
35
|
+
# The subscriber will receive every UIInterface call that this controller handles.
|
|
36
|
+
# @param subscriber [#UIInterface methods]
|
|
37
|
+
# @return [void]
|
|
38
|
+
def subscribe_channel(subscriber)
|
|
39
|
+
@subscribers_mutex.synchronize { @channel_subscribers << subscriber }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Remove a previously registered channel subscriber.
|
|
43
|
+
# @param subscriber [Object]
|
|
44
|
+
# @return [void]
|
|
45
|
+
def unsubscribe_channel(subscriber)
|
|
46
|
+
@subscribers_mutex.synchronize { @channel_subscribers.delete(subscriber) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Boolean] true if any channel subscribers are registered
|
|
50
|
+
def channel_subscribed?
|
|
51
|
+
@subscribers_mutex.synchronize { !@channel_subscribers.empty? }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Deliver a confirmation answer received from the browser.
|
|
55
|
+
# Called by the HTTP server when a confirmation message arrives over WebSocket.
|
|
56
|
+
def deliver_confirmation(conf_id, result)
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
pending = @pending_confirmations[conf_id]
|
|
59
|
+
return unless pending
|
|
60
|
+
|
|
61
|
+
pending[:result] = result
|
|
62
|
+
pending[:cond].signal
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# === Output display ===
|
|
67
|
+
|
|
68
|
+
def show_user_message(content, created_at: nil, files: [], source: :web)
|
|
69
|
+
# content may be an Array (multipart: text + vision image blocks) when
|
|
70
|
+
# the user uploaded images. Extract plain text and image URLs so the
|
|
71
|
+
# frontend receives strings it can render directly.
|
|
72
|
+
images = []
|
|
73
|
+
if content.is_a?(Array)
|
|
74
|
+
text_parts = []
|
|
75
|
+
content.each do |block|
|
|
76
|
+
next unless block.is_a?(Hash)
|
|
77
|
+
case block[:type]
|
|
78
|
+
when "text"
|
|
79
|
+
text_parts << block[:text]
|
|
80
|
+
when "image_url"
|
|
81
|
+
url = block.dig(:image_url, :url)
|
|
82
|
+
images << url if url
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
content = text_parts.join("\n")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
data = { content: content }
|
|
89
|
+
data[:created_at] = created_at if created_at
|
|
90
|
+
# Build ev.images for the frontend renderer (history_user_message):
|
|
91
|
+
# - Images with data_url → pass the data_url directly (<img> thumbnail)
|
|
92
|
+
# - Disk files (PDF, doc, etc., no data_url) → "pdf:name" sentinel (renders a badge)
|
|
93
|
+
rendered = Array(files).filter_map do |f|
|
|
94
|
+
url = f[:data_url] || f["data_url"]
|
|
95
|
+
name = f[:name] || f["name"]
|
|
96
|
+
url || (name ? "pdf:#{name}" : nil)
|
|
97
|
+
end
|
|
98
|
+
images.concat(rendered)
|
|
99
|
+
data[:images] = images unless images.empty?
|
|
100
|
+
emit("history_user_message", **data)
|
|
101
|
+
# Only forward to channel subscribers when the message originated from the WebUI,
|
|
102
|
+
# to avoid echoing channel messages back to the same channel.
|
|
103
|
+
return unless source == :web
|
|
104
|
+
forward_to_subscribers { |sub| sub.show_user_message(content) if sub.respond_to?(:show_user_message) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def show_assistant_message(content, files:)
|
|
108
|
+
return if (content.nil? || content.to_s.strip.empty?) && files.empty?
|
|
109
|
+
|
|
110
|
+
# Rewrite local image paths (file:// and bare absolute) to /api/local-image
|
|
111
|
+
# proxy URLs only for the browser, which runs on http://localhost and is
|
|
112
|
+
# blocked by browser security policy from loading file:// directly.
|
|
113
|
+
# Channel subscribers receive the original content so they can deliver
|
|
114
|
+
# local images as native attachments via send_file().
|
|
115
|
+
web_content = Octo::Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
|
|
116
|
+
emit("assistant_message", content: web_content, files: files)
|
|
117
|
+
forward_to_subscribers { |sub| sub.show_assistant_message(content, files: files) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def show_tool_call(name, args)
|
|
121
|
+
args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
|
|
122
|
+
|
|
123
|
+
# Special handling for request_user_feedback — emit a dedicated UI event
|
|
124
|
+
if name.to_s == "request_user_feedback"
|
|
125
|
+
question = args_data.is_a?(Hash) ? (args_data[:question] || args_data["question"]).to_s : ""
|
|
126
|
+
context = args_data.is_a?(Hash) ? (args_data[:context] || args_data["context"]).to_s : ""
|
|
127
|
+
options = args_data.is_a?(Hash) ? (args_data[:options] || args_data["options"]) : nil
|
|
128
|
+
|
|
129
|
+
# Normalize options to array (guard against malformed data)
|
|
130
|
+
options = Array(options) if options && !options.is_a?(Array)
|
|
131
|
+
|
|
132
|
+
emit("request_feedback",
|
|
133
|
+
question: question,
|
|
134
|
+
context: context,
|
|
135
|
+
options: options || [])
|
|
136
|
+
# Don't forward to IM subscribers — they get the formatted text version already
|
|
137
|
+
return
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Generate a human-readable summary using the tool's format_call method
|
|
141
|
+
summary = tool_call_summary(name, args_data)
|
|
142
|
+
|
|
143
|
+
# Remember the current in-flight tool call so replay_live_state can re-emit it
|
|
144
|
+
# when a browser tab re-subscribes after switching sessions.
|
|
145
|
+
@live_tool_call = { name: name, args: args_data, summary: summary }
|
|
146
|
+
|
|
147
|
+
emit("tool_call", name: name, args: args_data, summary: summary)
|
|
148
|
+
forward_to_subscribers { |sub| sub.show_tool_call(name, args_data) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def show_tool_result(result, ui_payload: nil)
|
|
152
|
+
@live_tool_call = nil # tool finished — no longer in-flight
|
|
153
|
+
emit("tool_result", result: result, ui_payload: ui_payload)
|
|
154
|
+
forward_to_subscribers { |sub| sub.show_tool_result(result) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def show_tool_error(error)
|
|
158
|
+
error_msg = error.is_a?(Exception) ? error.message : error.to_s
|
|
159
|
+
emit("tool_error", error: error_msg)
|
|
160
|
+
forward_to_subscribers { |sub| sub.show_tool_error(error) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def show_tool_args(formatted_args)
|
|
164
|
+
emit("tool_args", args: formatted_args)
|
|
165
|
+
forward_to_subscribers { |sub| sub.show_tool_args(formatted_args) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def show_file_write_preview(path, is_new_file:)
|
|
169
|
+
emit("file_preview", path: path, operation: "write", is_new_file: is_new_file)
|
|
170
|
+
forward_to_subscribers { |sub| sub.show_file_write_preview(path, is_new_file: is_new_file) }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def show_file_edit_preview(path)
|
|
174
|
+
emit("file_preview", path: path, operation: "edit")
|
|
175
|
+
forward_to_subscribers { |sub| sub.show_file_edit_preview(path) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def show_file_error(error_message)
|
|
179
|
+
emit("file_error", error: error_message)
|
|
180
|
+
forward_to_subscribers { |sub| sub.show_file_error(error_message) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def show_shell_preview(command)
|
|
184
|
+
emit("shell_preview", command: command)
|
|
185
|
+
forward_to_subscribers { |sub| sub.show_shell_preview(command) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def show_diff(old_content, new_content, max_lines: 50)
|
|
189
|
+
require "diffy"
|
|
190
|
+
diff = Diffy::Diff.new(old_content, new_content, context: 3).to_s
|
|
191
|
+
diff_lines = diff.lines
|
|
192
|
+
emit("diff",
|
|
193
|
+
old_size: old_content.bytesize,
|
|
194
|
+
new_size: new_content.bytesize,
|
|
195
|
+
diff: diff_lines.take(max_lines).join,
|
|
196
|
+
truncated: diff_lines.size > max_lines)
|
|
197
|
+
# Diffs are too verbose for IM — intentionally not forwarded
|
|
198
|
+
rescue LoadError
|
|
199
|
+
emit("diff", old_size: old_content.bytesize, new_size: new_content.bytesize)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def show_token_usage(token_data)
|
|
203
|
+
emit("token_usage", **token_data)
|
|
204
|
+
# Token usage is internal detail — intentionally not forwarded
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def show_complete(iterations:, duration: nil, cache_stats: nil, awaiting_user_feedback: false)
|
|
208
|
+
data = { iterations: iterations }
|
|
209
|
+
data[:duration] = duration if duration
|
|
210
|
+
data[:cache_stats] = cache_stats if cache_stats
|
|
211
|
+
data[:awaiting_user_feedback] = awaiting_user_feedback if awaiting_user_feedback
|
|
212
|
+
emit("complete", **data)
|
|
213
|
+
forward_to_subscribers do |sub|
|
|
214
|
+
sub.show_complete(iterations: iterations, duration: duration,
|
|
215
|
+
cache_stats: cache_stats, awaiting_user_feedback: awaiting_user_feedback)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def append_output(content)
|
|
220
|
+
emit("output", content: content)
|
|
221
|
+
forward_to_subscribers { |sub| sub.append_output(content) }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# === Status messages ===
|
|
225
|
+
|
|
226
|
+
def show_info(message, prefix_newline: true)
|
|
227
|
+
emit("info", message: message)
|
|
228
|
+
forward_to_subscribers { |sub| sub.show_info(message) }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def show_warning(message)
|
|
232
|
+
emit("warning", message: message)
|
|
233
|
+
forward_to_subscribers { |sub| sub.show_warning(message) }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def show_error(message)
|
|
237
|
+
emit("error", message: message)
|
|
238
|
+
forward_to_subscribers { |sub| sub.show_error(message) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def show_success(message)
|
|
242
|
+
emit("success", message: message)
|
|
243
|
+
forward_to_subscribers { |sub| sub.show_success(message) }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def log(message, level: :info)
|
|
247
|
+
emit("log", level: level.to_s, message: message)
|
|
248
|
+
# Log forwarding intentionally skipped — too noisy for IM
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# === Progress ===
|
|
252
|
+
|
|
253
|
+
def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
|
|
254
|
+
if phase == "active"
|
|
255
|
+
# Only set start time when transitioning into a fresh progress phase.
|
|
256
|
+
# Streaming LLM calls fire show_progress every chunk for token updates;
|
|
257
|
+
# resetting the timer each time would make the elapsed counter jitter
|
|
258
|
+
# back to 0 in the UI and force the frontend to rebuild its interval.
|
|
259
|
+
if @live_progress_state.nil? || @live_progress_state[:progress_type] != progress_type
|
|
260
|
+
@progress_start_time = Time.now
|
|
261
|
+
@live_stdout_buffer = []
|
|
262
|
+
end
|
|
263
|
+
@live_progress_state = {
|
|
264
|
+
message: message,
|
|
265
|
+
progress_type: progress_type,
|
|
266
|
+
metadata: metadata
|
|
267
|
+
}
|
|
268
|
+
elsif phase == "done"
|
|
269
|
+
@live_tool_call = nil # command finished — nothing left to replay
|
|
270
|
+
# Keep @live_stdout_buffer intact — it will be reset on the next show_progress call.
|
|
271
|
+
# This allows a brief replay window even after the command finishes.
|
|
272
|
+
@live_progress_state = nil
|
|
273
|
+
@progress_start_time = nil
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
data = {
|
|
277
|
+
message: message,
|
|
278
|
+
progress_type: progress_type,
|
|
279
|
+
phase: phase,
|
|
280
|
+
status: phase == "active" ? "start" : "stop" # backward compat
|
|
281
|
+
}
|
|
282
|
+
data[:metadata] = metadata unless metadata.empty?
|
|
283
|
+
# Always include started_at for "active" phase so the frontend can set the
|
|
284
|
+
# correct timer origin even on the very first event (not just replay).
|
|
285
|
+
if phase == "active" && @progress_start_time
|
|
286
|
+
data[:started_at] = (@progress_start_time.to_f * 1000).round
|
|
287
|
+
end
|
|
288
|
+
data[:elapsed] = (Time.now - @progress_start_time).round(1) if phase == "done" && @progress_start_time
|
|
289
|
+
|
|
290
|
+
emit("progress", **data)
|
|
291
|
+
forward_to_subscribers { |sub| sub.show_progress(message) }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Stream shell stdout/stderr lines to the browser while a command is running.
|
|
295
|
+
# Called immediately via on_output callback from shell.rb — no polling delay.
|
|
296
|
+
# Lines are also buffered in @live_stdout_buffer so late-joining subscribers
|
|
297
|
+
# (e.g. user switches away and back) can receive a replay of what they missed.
|
|
298
|
+
def show_tool_stdout(lines)
|
|
299
|
+
return if lines.nil? || lines.empty?
|
|
300
|
+
@live_stdout_buffer ||= []
|
|
301
|
+
@live_stdout_buffer.concat(lines)
|
|
302
|
+
emit("tool_stdout", lines: lines)
|
|
303
|
+
# Not forwarded to IM subscribers — too noisy
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Replay in-progress command state to a newly (re-)subscribing browser tab.
|
|
307
|
+
# all tool_stdout lines that fired while the user was away are lost.
|
|
308
|
+
# Replay live state when a client re-subscribes (e.g. after switching sessions).
|
|
309
|
+
#
|
|
310
|
+
# Plan C: we do NOT re-emit tool_call here.
|
|
311
|
+
# The tool-item is already rendered in the DOM via the normal flow.
|
|
312
|
+
# We only replay:
|
|
313
|
+
# 1. progress(start) — restores the spinner / progress bar
|
|
314
|
+
# 2. tool_stdout — fills in all stdout received so far
|
|
315
|
+
#
|
|
316
|
+
# The frontend's appendToolStdout will attach to the last visible .tool-item
|
|
317
|
+
# even when _liveLastToolItem is null (after the tab re-loaded).
|
|
318
|
+
def replay_live_state
|
|
319
|
+
return unless @live_progress_state
|
|
320
|
+
|
|
321
|
+
# Replay complete progress state (not just message).
|
|
322
|
+
# Include started_at (ms since epoch) so the frontend can resume the
|
|
323
|
+
# elapsed-time counter from the correct origin instead of resetting to 0.
|
|
324
|
+
state = @live_progress_state
|
|
325
|
+
started_at_ms = @progress_start_time ? (@progress_start_time.to_f * 1000).round : nil
|
|
326
|
+
|
|
327
|
+
emit("progress",
|
|
328
|
+
message: state[:message],
|
|
329
|
+
progress_type: state[:progress_type],
|
|
330
|
+
phase: "active",
|
|
331
|
+
status: "start",
|
|
332
|
+
metadata: state[:metadata] || {},
|
|
333
|
+
started_at: started_at_ms
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
buf = @live_stdout_buffer
|
|
337
|
+
emit("tool_stdout", lines: buf) if buf && !buf.empty?
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# === State updates ===
|
|
341
|
+
|
|
342
|
+
def update_sessionbar(tasks: nil, status: nil, latency: nil)
|
|
343
|
+
data = {}
|
|
344
|
+
data[:tasks] = tasks if tasks
|
|
345
|
+
data[:status] = status if status
|
|
346
|
+
data[:latency] = latency if latency
|
|
347
|
+
emit("session_update", **data) unless data.empty?
|
|
348
|
+
forward_to_subscribers { |sub| sub.update_sessionbar(tasks: tasks, status: status, latency: latency) }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def update_todos(todos)
|
|
352
|
+
emit("todo_update", todos: todos)
|
|
353
|
+
forward_to_subscribers { |sub| sub.update_todos(todos) }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def update_background_tasks(running: 0, tasks: [])
|
|
357
|
+
safe_tasks = Array(tasks).map do |t|
|
|
358
|
+
cmd = (t[:command] || t["command"]).to_s
|
|
359
|
+
cmd = "#{cmd[0, 80]}…" if cmd.length > 80
|
|
360
|
+
{
|
|
361
|
+
handle_id: (t[:handle_id] || t["handle_id"]).to_s,
|
|
362
|
+
command: cmd,
|
|
363
|
+
elapsed: (t[:elapsed] || t["elapsed"]).to_i
|
|
364
|
+
}
|
|
365
|
+
end
|
|
366
|
+
emit("background_tasks_update", running: running.to_i, tasks: safe_tasks)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def show_background_task_notice(command: nil, handle_id: nil, status: "success")
|
|
370
|
+
cmd = command.to_s
|
|
371
|
+
cmd = "#{cmd[0, 60]}…" if cmd.length > 60
|
|
372
|
+
emit("background_task_notice",
|
|
373
|
+
command: cmd,
|
|
374
|
+
handle_id: handle_id.to_s,
|
|
375
|
+
status: status.to_s)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def set_working_status
|
|
379
|
+
emit("session_update", status: "working")
|
|
380
|
+
forward_to_subscribers { |sub| sub.set_working_status }
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def set_idle_status
|
|
384
|
+
# Clear any in-progress state when transitioning to idle
|
|
385
|
+
if @live_progress_state
|
|
386
|
+
emit("progress", phase: "done", status: "stop")
|
|
387
|
+
@live_progress_state = nil
|
|
388
|
+
@progress_start_time = nil
|
|
389
|
+
end
|
|
390
|
+
emit("session_update", status: "idle")
|
|
391
|
+
forward_to_subscribers { |sub| sub.set_idle_status }
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def update_user_message_queue_status(pending: 0)
|
|
395
|
+
emit("user_message_queue_status", pending: pending.to_i)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# === Blocking interaction ===
|
|
399
|
+
# Emits a request_confirmation event and blocks until the browser responds.
|
|
400
|
+
# Timeout after 5 minutes to avoid hanging threads forever.
|
|
401
|
+
CONFIRMATION_TIMEOUT = 300 # seconds
|
|
402
|
+
|
|
403
|
+
def request_confirmation(message, default: true)
|
|
404
|
+
conf_id = "conf_#{SecureRandom.hex(4)}"
|
|
405
|
+
|
|
406
|
+
cond = ConditionVariable.new
|
|
407
|
+
pending = { cond: cond, result: nil }
|
|
408
|
+
|
|
409
|
+
@mutex.synchronize { @pending_confirmations[conf_id] = pending }
|
|
410
|
+
|
|
411
|
+
emit("request_confirmation", id: conf_id, message: message, default: default)
|
|
412
|
+
|
|
413
|
+
# Notify channel subscribers that confirmation is pending — non-blocking.
|
|
414
|
+
# They display a notice; the actual decision comes from the Web UI user.
|
|
415
|
+
forward_to_subscribers { |sub| sub.show_warning("⏳ Confirmation requested: #{message}") }
|
|
416
|
+
|
|
417
|
+
# Block until browser replies or timeout
|
|
418
|
+
@mutex.synchronize do
|
|
419
|
+
cond.wait(@mutex, CONFIRMATION_TIMEOUT)
|
|
420
|
+
@pending_confirmations.delete(conf_id)
|
|
421
|
+
result = pending[:result]
|
|
422
|
+
|
|
423
|
+
# Timed out — use default
|
|
424
|
+
return default if result.nil?
|
|
425
|
+
|
|
426
|
+
case result.to_s.downcase
|
|
427
|
+
when "yes", "y" then true
|
|
428
|
+
when "no", "n" then false
|
|
429
|
+
else result.to_s
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# === Input control (no-ops in web mode) ===
|
|
435
|
+
|
|
436
|
+
def clear_input; end
|
|
437
|
+
def set_input_tips(message, type: :info); end
|
|
438
|
+
|
|
439
|
+
# Predicted next-user-message ghost text. Web renders as placeholder +
|
|
440
|
+
# Tab-to-accept. Channel subscribers (Feishu/WeCom) intentionally do
|
|
441
|
+
# not forward — there's no input box to ghost-text into.
|
|
442
|
+
def show_next_message_suggestion(text)
|
|
443
|
+
emit("next_message_suggestion", text: text.to_s)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# === Lifecycle ===
|
|
447
|
+
|
|
448
|
+
def stop
|
|
449
|
+
emit("server_stop")
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# Generate a short human-readable summary for a tool call display.
|
|
454
|
+
# Delegates to each tool's own format_call method when available.
|
|
455
|
+
def tool_call_summary(name, args)
|
|
456
|
+
class_name = name.to_s.split("_").map(&:capitalize).join
|
|
457
|
+
return nil unless Octo::Tools.const_defined?(class_name)
|
|
458
|
+
|
|
459
|
+
tool = Octo::Tools.const_get(class_name).new
|
|
460
|
+
args_sym = args.is_a?(Hash) ? args.transform_keys(&:to_sym) : {}
|
|
461
|
+
tool.format_call(args_sym)
|
|
462
|
+
rescue StandardError
|
|
463
|
+
nil
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def emit(type, **data)
|
|
467
|
+
event = { type: type, session_id: @session_id }.merge(data)
|
|
468
|
+
@broadcaster.call(@session_id, event)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Forward a UIInterface call to all registered channel subscribers.
|
|
472
|
+
# Each subscriber is called in the same thread as the caller (Agent thread).
|
|
473
|
+
# Errors in individual subscribers are rescued and logged so they never
|
|
474
|
+
# interrupt the main agent execution.
|
|
475
|
+
def forward_to_subscribers(&block)
|
|
476
|
+
subscribers = @subscribers_mutex.synchronize { @channel_subscribers.dup }
|
|
477
|
+
return if subscribers.empty?
|
|
478
|
+
|
|
479
|
+
subscribers.each do |sub|
|
|
480
|
+
block.call(sub)
|
|
481
|
+
rescue StandardError => e
|
|
482
|
+
Octo::Logger.error("[WebUIController] channel subscriber error", error: e)
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
end
|