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
data/lib/octo/cli.rb
ADDED
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "tty-prompt"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "ui2"
|
|
7
|
+
require_relative "json_ui_controller"
|
|
8
|
+
require_relative "plain_ui_controller"
|
|
9
|
+
|
|
10
|
+
module Octo
|
|
11
|
+
class CLI < Thor
|
|
12
|
+
def self.exit_on_failure?
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Set agent as the default command
|
|
17
|
+
default_task :agent
|
|
18
|
+
|
|
19
|
+
desc "agent", "Run agent in interactive mode with autonomous tool use (default)"
|
|
20
|
+
long_desc <<-LONGDESC
|
|
21
|
+
Run an AI agent in interactive mode that can autonomously use tools to complete tasks.
|
|
22
|
+
|
|
23
|
+
The agent runs in a continuous loop, allowing multiple tasks in one session.
|
|
24
|
+
Each task is completed with its own React (Reason-Act-Observe) cycle.
|
|
25
|
+
After completing a task, the agent waits for your next instruction.
|
|
26
|
+
|
|
27
|
+
Permission modes:
|
|
28
|
+
auto_approve - Automatically execute all tools, no human interaction (use with caution)
|
|
29
|
+
confirm_safes - Auto-approve safe operations, confirm risky ones (default)
|
|
30
|
+
confirm_all - Auto-approve all file/shell tools, but wait for human on interactive prompts
|
|
31
|
+
|
|
32
|
+
UI themes:
|
|
33
|
+
hacker - Matrix/hacker-style with bracket symbols (default)
|
|
34
|
+
minimal - Clean, simple symbols
|
|
35
|
+
|
|
36
|
+
Session management:
|
|
37
|
+
-c, --continue - Continue the most recent session for this directory
|
|
38
|
+
-l, --list - List recent sessions
|
|
39
|
+
-a, --attach N - Attach to session by number (e.g., -a 2) or session ID prefix (e.g., -a b6682a87)
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
$ octo agent --mode=auto_approve --path /path/to/project
|
|
43
|
+
$ octo agent --model gpt-5.3-codex -m "write a hello world script"
|
|
44
|
+
LONGDESC
|
|
45
|
+
option :mode, type: :string, default: "confirm_safes",
|
|
46
|
+
desc: "Permission mode: auto_approve, confirm_safes, confirm_all"
|
|
47
|
+
option :theme, type: :string, default: "hacker",
|
|
48
|
+
desc: "UI theme: hacker, minimal (default: hacker)"
|
|
49
|
+
option :verbose, type: :boolean, aliases: "-v", default: false, desc: "Show detailed output"
|
|
50
|
+
option :path, type: :string, desc: "Project directory path (defaults to current directory)"
|
|
51
|
+
option :continue, type: :boolean, aliases: "-c", desc: "Continue most recent session"
|
|
52
|
+
option :list, type: :boolean, aliases: "-l", desc: "List recent sessions"
|
|
53
|
+
option :attach, type: :string, aliases: "-a", desc: "Attach to session by number or keyword"
|
|
54
|
+
option :json, type: :boolean, default: false, desc: "Output NDJSON to stdout (for scripting/piping)"
|
|
55
|
+
option :message, type: :string, aliases: "-m", desc: "Run non-interactively with this message and exit"
|
|
56
|
+
option :file, type: :array, aliases: "-f", desc: "File path(s) to attach (use with -m; supports images and documents)"
|
|
57
|
+
option :image, type: :array, aliases: "-i", desc: "Image file path(s) to attach (alias for --file, kept for compatibility)"
|
|
58
|
+
option :agent, type: :string, default: "coding", desc: "Agent profile to use: coding, general, or any custom profile name (default: coding)"
|
|
59
|
+
option :model, type: :string, desc: "Override the model to use (by name, e.g. gpt-5.3-codex or deepseek-v4-pro). Uses default model if not specified"
|
|
60
|
+
option :help, type: :boolean, aliases: "-h", desc: "Show this help message"
|
|
61
|
+
def agent
|
|
62
|
+
# Handle help option
|
|
63
|
+
if options[:help]
|
|
64
|
+
invoke :help, ["agent"]
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# ── Sibling server discovery ───────────────────────────────────────
|
|
69
|
+
# Bare-CLI mode does NOT boot an HTTP server, so skills that call
|
|
70
|
+
# back into /api/* (channels, browser, scheduler) normally can't work.
|
|
71
|
+
# If the user happens to have a Octo server running on this machine
|
|
72
|
+
# (in another terminal or via `octo server`), auto-wire OCTO_SERVER_HOST
|
|
73
|
+
# / OCTO_SERVER_PORT so those skills can reach it transparently.
|
|
74
|
+
discover_sibling_server!
|
|
75
|
+
|
|
76
|
+
agent_config = Octo::AgentConfig.load
|
|
77
|
+
|
|
78
|
+
# Override model if --model option is specified
|
|
79
|
+
if options[:model]
|
|
80
|
+
unless agent_config.switch_model_by_name(options[:model])
|
|
81
|
+
# During early startup @ui may not be ready; use simple error output
|
|
82
|
+
$stderr.puts "Error: model '#{options[:model]}' not found. Available: #{agent_config.model_names.join(', ')}"
|
|
83
|
+
exit 1
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Handle session listing
|
|
88
|
+
if options[:list]
|
|
89
|
+
list_sessions
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Handle Ctrl+C gracefully - raise exception to be caught in the loop
|
|
94
|
+
Signal.trap("INT") do
|
|
95
|
+
Thread.main.raise(Octo::AgentInterrupted, "Interrupted by user")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Validate and get working directory
|
|
99
|
+
working_dir = validate_working_directory(options[:path], agent_config)
|
|
100
|
+
|
|
101
|
+
# Update agent config with CLI options
|
|
102
|
+
agent_config.permission_mode = options[:mode].to_sym if options[:mode]
|
|
103
|
+
agent_config.verbose = options[:verbose] if options[:verbose]
|
|
104
|
+
|
|
105
|
+
# Client factory: produces a fresh Client reflecting the *current*
|
|
106
|
+
# state of agent_config each time it's called. The CLI never holds a
|
|
107
|
+
# long-lived `client` variable — instead, anyone who needs a client
|
|
108
|
+
# (initial agent construction, /clear, etc.) calls the factory.
|
|
109
|
+
#
|
|
110
|
+
# This mirrors the server-side design (HTTPServer#client_factory) and
|
|
111
|
+
# avoids the class of bugs where a shared client is ivar_set'd field by
|
|
112
|
+
# field (easy to miss @model / @use_bedrock) and then reused for a
|
|
113
|
+
# later Agent.new, serving stale credentials.
|
|
114
|
+
client_factory = lambda do
|
|
115
|
+
Octo::Client.new(
|
|
116
|
+
agent_config.api_key,
|
|
117
|
+
base_url: agent_config.base_url,
|
|
118
|
+
model: agent_config.model_name,
|
|
119
|
+
anthropic_format: agent_config.anthropic_format?
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Resolve agent profile name from --agent option
|
|
124
|
+
agent_profile = options[:agent] || "coding"
|
|
125
|
+
|
|
126
|
+
# Handle session loading/continuation
|
|
127
|
+
session_manager = Octo::SessionManager.new
|
|
128
|
+
agent = nil
|
|
129
|
+
is_session_load = false
|
|
130
|
+
|
|
131
|
+
if options[:continue]
|
|
132
|
+
agent = load_latest_session(client_factory.call, agent_config, session_manager, working_dir, profile: agent_profile)
|
|
133
|
+
is_session_load = !agent.nil?
|
|
134
|
+
elsif options[:attach]
|
|
135
|
+
agent = load_session_by_number(client_factory.call, agent_config, session_manager, working_dir, options[:attach], profile: agent_profile)
|
|
136
|
+
is_session_load = !agent.nil?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Create new agent if no session loaded
|
|
140
|
+
if agent.nil?
|
|
141
|
+
agent = Octo::Agent.new(client_factory.call, agent_config, working_dir: working_dir, ui: nil, profile: agent_profile,
|
|
142
|
+
session_id: Octo::SessionManager.generate_id, source: :manual)
|
|
143
|
+
agent.rename("CLI Session")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Change to working directory
|
|
147
|
+
original_dir = Dir.pwd
|
|
148
|
+
should_chdir = File.realpath(working_dir) != File.realpath(original_dir)
|
|
149
|
+
Dir.chdir(working_dir) if should_chdir
|
|
150
|
+
begin
|
|
151
|
+
if options[:message]
|
|
152
|
+
file_paths = Array(options[:file]) + Array(options[:image])
|
|
153
|
+
run_non_interactive(agent, options[:message], file_paths, agent_config, session_manager)
|
|
154
|
+
elsif options[:json]
|
|
155
|
+
run_agent_with_json(agent, working_dir, agent_config, session_manager, client_factory, profile: agent_profile)
|
|
156
|
+
else
|
|
157
|
+
run_agent_with_ui2(agent, working_dir, agent_config, session_manager, client_factory, is_session_load: is_session_load)
|
|
158
|
+
end
|
|
159
|
+
ensure
|
|
160
|
+
Dir.chdir(original_dir)
|
|
161
|
+
Octo::BrowserManager.instance.stop rescue nil
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
no_commands do
|
|
166
|
+
# Detect a sibling Octo server running on this machine and expose its
|
|
167
|
+
# address to skills via ENV. Runs only in bare-CLI mode (where no server
|
|
168
|
+
# is booted by this process), and only when the user hasn't already set
|
|
169
|
+
# OCTO_SERVER_HOST / OCTO_SERVER_PORT explicitly.
|
|
170
|
+
#
|
|
171
|
+
# Why: skills like `channel-manager` and `browser-setup` call back into
|
|
172
|
+
# http://${OCTO_SERVER_HOST}:${OCTO_SERVER_PORT}/api/*. In server
|
|
173
|
+
# mode those vars are injected by HTTPServer#start. In CLI mode they
|
|
174
|
+
# would be blank, so the skill templates expand to an unreachable URL.
|
|
175
|
+
#
|
|
176
|
+
# Discovery is best-effort and non-fatal: if nothing is found we stay
|
|
177
|
+
# silent and let the skill's own pre-flight check emit a friendly error.
|
|
178
|
+
private def discover_sibling_server!
|
|
179
|
+
return if ENV["OCTO_SERVER_PORT"] && !ENV["OCTO_SERVER_PORT"].strip.empty?
|
|
180
|
+
|
|
181
|
+
require_relative "server/discover"
|
|
182
|
+
info = Octo::Server::Discover.find_local
|
|
183
|
+
return unless info
|
|
184
|
+
|
|
185
|
+
ENV["OCTO_SERVER_HOST"] = info[:host]
|
|
186
|
+
ENV["OCTO_SERVER_PORT"] = info[:port].to_s
|
|
187
|
+
Octo::Logger.debug(
|
|
188
|
+
"[CLI] Discovered local server PID=#{info[:pid]} at " \
|
|
189
|
+
"#{info[:host]}:#{info[:port]} — OCTO_SERVER_* exported."
|
|
190
|
+
)
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
# Discovery must never break `octo agent`.
|
|
193
|
+
Octo::Logger.debug("[CLI] discover_sibling_server! failed: #{e.class}: #{e.message}")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Handle the `/config` slash command.
|
|
197
|
+
#
|
|
198
|
+
# show_config_modal is a pure UI component — it only mutates @models
|
|
199
|
+
# (for add/edit/delete) and returns the user's intent as a hash:
|
|
200
|
+
# nil — user closed, no-op
|
|
201
|
+
# { action: :switch, model_id: <id> } — switch to existing model
|
|
202
|
+
# { action: :add, model_id: <id> } — user added a new model, switch to it
|
|
203
|
+
# { action: :edit, model_id: <id> } — user edited current model in place
|
|
204
|
+
# { action: :delete, model_id: <id or nil> } — user deleted current model
|
|
205
|
+
#
|
|
206
|
+
# All side-effects (switching the agent, rebuilding its Client, marking
|
|
207
|
+
# the new global default, saving config.yml, updating the UI) live here
|
|
208
|
+
# so the path is unified with the server-side api_switch_session_model.
|
|
209
|
+
private def handle_config_command(ui_controller, agent_config, agent)
|
|
210
|
+
config = agent_config
|
|
211
|
+
|
|
212
|
+
# Test callback used by the model edit form. Uses a throwaway Client
|
|
213
|
+
# with the form's (not-yet-saved) values to validate creds.
|
|
214
|
+
test_callback = lambda do |test_config|
|
|
215
|
+
test_client = Octo::Client.new(
|
|
216
|
+
test_config.api_key,
|
|
217
|
+
base_url: test_config.base_url,
|
|
218
|
+
model: test_config.model_name,
|
|
219
|
+
anthropic_format: test_config.anthropic_format?
|
|
220
|
+
)
|
|
221
|
+
test_client.test_connection(model: test_config.model_name)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
result = ui_controller.show_config_modal(config, test_callback: test_callback)
|
|
225
|
+
return if result.nil?
|
|
226
|
+
|
|
227
|
+
case result[:action]
|
|
228
|
+
when :switch, :add
|
|
229
|
+
# CLI is a single-session context: picking (or adding) a model
|
|
230
|
+
# implies "use this now AND next launch". So we:
|
|
231
|
+
# 1. switch the agent to it — this goes through the single entry
|
|
232
|
+
# point Agent#switch_model_by_id, which rebuilds the Client
|
|
233
|
+
# (recomputing @use_bedrock / @use_anthropic_format), the
|
|
234
|
+
# message compressor, and injects a session-context message.
|
|
235
|
+
# 2. mark it as the global default (type: "default" marker)
|
|
236
|
+
# 3. persist config.yml
|
|
237
|
+
target_id = result[:model_id]
|
|
238
|
+
agent.switch_model_by_id(target_id)
|
|
239
|
+
config.set_default_model_by_id(target_id)
|
|
240
|
+
config.save
|
|
241
|
+
when :edit
|
|
242
|
+
# current model was mutated in place — its stable id is unchanged.
|
|
243
|
+
# Re-run switch_model_by_id with the same id to rebuild the Client,
|
|
244
|
+
# so updated api_key / base_url / model take effect AND @use_bedrock
|
|
245
|
+
# is re-detected (the user may have edited the model name from
|
|
246
|
+
# abs-* to a non-Bedrock one or vice versa).
|
|
247
|
+
agent.switch_model_by_id(result[:model_id])
|
|
248
|
+
config.save
|
|
249
|
+
when :delete
|
|
250
|
+
# If the deleted model was the current one, show_config_modal has
|
|
251
|
+
# already re-resolved current_model and passed its new id back to
|
|
252
|
+
# us. Rebuild the Client around the new current model.
|
|
253
|
+
# If nothing is current (e.g. last model deleted — guarded by the
|
|
254
|
+
# modal, shouldn't happen), there's nothing to rebuild.
|
|
255
|
+
if result[:model_id]
|
|
256
|
+
agent.switch_model_by_id(result[:model_id])
|
|
257
|
+
end
|
|
258
|
+
config.save
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Refresh UI bar
|
|
262
|
+
ui_controller.config[:model] = config.model_name
|
|
263
|
+
ui_controller.update_sessionbar(
|
|
264
|
+
tasks: agent.total_tasks
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Show summary. Guard api_key slice against empty/short keys.
|
|
268
|
+
key = config.api_key.to_s
|
|
269
|
+
masked_key = if key.length >= 12
|
|
270
|
+
"#{key[0..7]}#{'*' * 20}#{key[-4..]}"
|
|
271
|
+
else
|
|
272
|
+
"(not set)"
|
|
273
|
+
end
|
|
274
|
+
ui_controller.show_success("Configuration updated!")
|
|
275
|
+
ui_controller.append_output(" Current Model: #{config.model_name}")
|
|
276
|
+
ui_controller.append_output(" API Key: #{masked_key}")
|
|
277
|
+
ui_controller.append_output(" Base URL: #{config.base_url}")
|
|
278
|
+
ui_controller.append_output(" Format: #{config.anthropic_format? ? 'Anthropic' : 'OpenAI'}")
|
|
279
|
+
ui_controller.append_output("")
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private def handle_time_machine_command(ui_controller, agent, session_manager)
|
|
283
|
+
# Get task history from agent
|
|
284
|
+
history = agent.get_task_history(limit: 10)
|
|
285
|
+
|
|
286
|
+
if history.empty?
|
|
287
|
+
ui_controller.show_info("No task history available yet.")
|
|
288
|
+
return
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Show time machine menu
|
|
292
|
+
selected_task_id = ui_controller.show_time_machine_menu(history)
|
|
293
|
+
|
|
294
|
+
# If user cancelled, return
|
|
295
|
+
return if selected_task_id.nil?
|
|
296
|
+
|
|
297
|
+
# Get current active task for comparison
|
|
298
|
+
current_task_id = agent.instance_variable_get(:@active_task_id)
|
|
299
|
+
|
|
300
|
+
# Perform the switch
|
|
301
|
+
begin
|
|
302
|
+
if selected_task_id < current_task_id
|
|
303
|
+
# Undo to selected task
|
|
304
|
+
ui_controller.show_info("Undoing to Task #{selected_task_id}...")
|
|
305
|
+
result = agent.switch_to_task(selected_task_id)
|
|
306
|
+
if result[:success]
|
|
307
|
+
ui_controller.show_success("✓ #{result[:message]}")
|
|
308
|
+
else
|
|
309
|
+
ui_controller.show_error(result[:message])
|
|
310
|
+
return
|
|
311
|
+
end
|
|
312
|
+
else
|
|
313
|
+
# Redo to selected task
|
|
314
|
+
ui_controller.show_info("Redoing to Task #{selected_task_id}...")
|
|
315
|
+
result = agent.switch_to_task(selected_task_id)
|
|
316
|
+
if result[:success]
|
|
317
|
+
ui_controller.show_success("✓ #{result[:message]}")
|
|
318
|
+
else
|
|
319
|
+
ui_controller.show_error(result[:message])
|
|
320
|
+
return
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Save session after switch
|
|
325
|
+
if session_manager
|
|
326
|
+
session_manager.save(agent.to_session_data(status: :success))
|
|
327
|
+
end
|
|
328
|
+
rescue StandardError => e
|
|
329
|
+
ui_controller.show_error("Time Machine failed: #{e.message}")
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
CLI_DEFAULT_SESSION_NAME = "CLI Session"
|
|
334
|
+
|
|
335
|
+
# Format a number with thousand separators for display
|
|
336
|
+
# @param num [Integer, Float] The number to format
|
|
337
|
+
# @return [String] Formatted number string
|
|
338
|
+
private def format_number(num)
|
|
339
|
+
return "0" if num.nil? || num == 0
|
|
340
|
+
num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Auto-name a CLI session from the first user message, mirroring server-side logic.
|
|
344
|
+
# Renames when the agent has no history yet (i.e. first message of the session).
|
|
345
|
+
private def auto_name_session(agent, input)
|
|
346
|
+
return unless agent.history.empty?
|
|
347
|
+
|
|
348
|
+
auto_name = input.to_s.gsub(/\s+/, " ").strip[0, 30]
|
|
349
|
+
auto_name += "…" if input.to_s.strip.length > 30
|
|
350
|
+
agent.rename(auto_name)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def validate_working_directory(path, config = nil)
|
|
354
|
+
working_dir = path || Dir.pwd
|
|
355
|
+
|
|
356
|
+
# If no path specified and currently in home directory, use configured
|
|
357
|
+
# default_working_dir (or ~/octo_workspace as fallback)
|
|
358
|
+
if path.nil? && File.expand_path(working_dir) == File.expand_path(Dir.home)
|
|
359
|
+
default = config&.default_working_dir || File.expand_path("~/octo_workspace")
|
|
360
|
+
working_dir = File.expand_path(default)
|
|
361
|
+
|
|
362
|
+
# Create directory if it doesn't exist
|
|
363
|
+
unless Dir.exist?(working_dir)
|
|
364
|
+
FileUtils.mkdir_p(working_dir)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Always expand to absolute path
|
|
369
|
+
working_dir = File.expand_path(working_dir)
|
|
370
|
+
|
|
371
|
+
# Validate directory exists
|
|
372
|
+
unless Dir.exist?(working_dir)
|
|
373
|
+
say "Error: Directory does not exist: #{working_dir}", :red
|
|
374
|
+
exit 1
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Validate it's a directory
|
|
378
|
+
unless File.directory?(working_dir)
|
|
379
|
+
say "Error: Path is not a directory: #{working_dir}", :red
|
|
380
|
+
exit 1
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
working_dir
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def list_sessions
|
|
387
|
+
session_manager = Octo::SessionManager.new
|
|
388
|
+
working_dir = validate_working_directory(options[:path])
|
|
389
|
+
sessions = session_manager.all_sessions(current_dir: working_dir, limit: 5)
|
|
390
|
+
|
|
391
|
+
if sessions.empty?
|
|
392
|
+
say "No sessions found.", :yellow
|
|
393
|
+
return
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
say "\n📋 Recent sessions:\n", :green
|
|
397
|
+
sessions.each_with_index do |session, index|
|
|
398
|
+
created_at = Time.parse(session[:created_at]).strftime("%Y-%m-%d %H:%M")
|
|
399
|
+
session_id = session[:session_id][0..7]
|
|
400
|
+
tasks = session.dig(:stats, :total_tasks) || 0
|
|
401
|
+
name = session[:name].to_s.empty? ? "Unnamed session" : session[:name]
|
|
402
|
+
is_current_dir = session[:working_dir] == working_dir
|
|
403
|
+
|
|
404
|
+
dir_marker = is_current_dir ? "📍" : " "
|
|
405
|
+
say "#{dir_marker} #{index + 1}. [#{session_id}] #{created_at} (#{tasks} tasks) - #{name}", :cyan
|
|
406
|
+
end
|
|
407
|
+
say "\n\n💡 Use `octo -a <session_id>` to resume a session.", :yellow
|
|
408
|
+
say ""
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def load_latest_session(client, agent_config, session_manager, working_dir, profile:)
|
|
412
|
+
session_data = session_manager.latest_for_directory(working_dir)
|
|
413
|
+
|
|
414
|
+
if session_data.nil?
|
|
415
|
+
say "No previous session found for this directory.", :yellow
|
|
416
|
+
return nil
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Prefer the agent_profile stored in the session; only fall back to the
|
|
420
|
+
# CLI --agent flag when the session predates the agent_profile field.
|
|
421
|
+
restored_profile = session_data[:agent_profile].to_s
|
|
422
|
+
resolved_profile = restored_profile.empty? ? profile : restored_profile
|
|
423
|
+
|
|
424
|
+
# Don't print message here - will be shown by UI after banner
|
|
425
|
+
Octo::Agent.from_session(client, agent_config, session_data, profile: resolved_profile)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def load_session_by_number(client, agent_config, session_manager, working_dir, identifier, profile:)
|
|
429
|
+
# Get a larger list to search through (for ID prefix matching)
|
|
430
|
+
sessions = session_manager.all_sessions(current_dir: working_dir, limit: 100)
|
|
431
|
+
|
|
432
|
+
if sessions.empty?
|
|
433
|
+
say "No sessions found.", :yellow
|
|
434
|
+
return nil
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
session_data = nil
|
|
438
|
+
|
|
439
|
+
# Check if identifier is a number (index-based)
|
|
440
|
+
# Heuristic: If it's a small number (1-99), treat as index; otherwise treat as session ID prefix
|
|
441
|
+
if identifier.match?(/^\d+$/) && identifier.to_i <= 99
|
|
442
|
+
index = identifier.to_i - 1
|
|
443
|
+
if index < 0 || index >= sessions.size
|
|
444
|
+
say "Invalid session number. Use -l to list available sessions.", :red
|
|
445
|
+
exit 1
|
|
446
|
+
end
|
|
447
|
+
session_data = sessions[index]
|
|
448
|
+
else
|
|
449
|
+
# Treat as session ID prefix
|
|
450
|
+
matching_sessions = sessions.select { |s| s[:session_id].start_with?(identifier) }
|
|
451
|
+
|
|
452
|
+
if matching_sessions.empty?
|
|
453
|
+
say "No session found matching ID prefix: #{identifier}", :red
|
|
454
|
+
say "Use -l to list available sessions.", :yellow
|
|
455
|
+
exit 1
|
|
456
|
+
elsif matching_sessions.size > 1
|
|
457
|
+
say "Multiple sessions found matching '#{identifier}':", :yellow
|
|
458
|
+
matching_sessions.each_with_index do |session, idx|
|
|
459
|
+
created_at = Time.parse(session[:created_at]).strftime("%Y-%m-%d %H:%M")
|
|
460
|
+
session_id = session[:session_id][0..7]
|
|
461
|
+
name = session[:name].to_s.empty? ? "Unnamed session" : session[:name]
|
|
462
|
+
say " #{idx + 1}. [#{session_id}] #{created_at} - #{name}", :cyan
|
|
463
|
+
end
|
|
464
|
+
say "\nPlease use a more specific prefix.", :yellow
|
|
465
|
+
exit 1
|
|
466
|
+
else
|
|
467
|
+
session_data = matching_sessions.first
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Prefer the agent_profile stored in the session; fall back to CLI --agent flag
|
|
472
|
+
# for sessions that predate the agent_profile field.
|
|
473
|
+
restored_profile = session_data[:agent_profile].to_s
|
|
474
|
+
resolved_profile = restored_profile.empty? ? profile : restored_profile
|
|
475
|
+
|
|
476
|
+
# Don't print message here - will be shown by UI after banner
|
|
477
|
+
Octo::Agent.from_session(client, agent_config, session_data, profile: resolved_profile)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Handle agent error/interrupt with cleanup
|
|
481
|
+
def handle_agent_exception(ui_controller, agent, session_manager, exception)
|
|
482
|
+
ui_controller.show_progress(phase: "done")
|
|
483
|
+
ui_controller.set_idle_status
|
|
484
|
+
|
|
485
|
+
if exception.is_a?(Octo::AgentInterrupted)
|
|
486
|
+
session_manager&.save(agent.to_session_data(status: :interrupted))
|
|
487
|
+
ui_controller.show_warning("Task interrupted by user")
|
|
488
|
+
else
|
|
489
|
+
error_message = "#{exception.message}\n#{exception.backtrace&.first(3)&.join("\n")}"
|
|
490
|
+
session_manager&.save(agent.to_session_data(status: :error, error_message: error_message))
|
|
491
|
+
ui_controller.show_error("Error: #{exception.message}")
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Run agent non-interactively with a single message, then exit.
|
|
496
|
+
# Forces auto_approve mode so no human confirmation is needed.
|
|
497
|
+
# Output goes directly to stdout; exits with code 0 on success, 1 on error.
|
|
498
|
+
def run_non_interactive(agent, message, file_paths, agent_config, session_manager)
|
|
499
|
+
# Force auto-approve — no one is around to confirm anything
|
|
500
|
+
agent_config.permission_mode = :auto_approve
|
|
501
|
+
|
|
502
|
+
# Validate paths up-front so we fail fast with a clear message
|
|
503
|
+
file_paths.each do |path|
|
|
504
|
+
raise ArgumentError, "File not found: #{path}" unless File.exist?(path)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Convert file paths to file hashes — agent.run decides how to handle each
|
|
508
|
+
files = file_paths.map do |path|
|
|
509
|
+
mime = Utils::FileProcessor.detect_mime_type(path) rescue "application/octet-stream"
|
|
510
|
+
{ name: File.basename(path), mime_type: mime, path: path }
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# Wire up plain-text stdout UI so all agent output is visible
|
|
514
|
+
plain_ui = Octo::PlainUIController.new
|
|
515
|
+
agent.instance_variable_set(:@ui, plain_ui)
|
|
516
|
+
|
|
517
|
+
auto_name_session(agent, message)
|
|
518
|
+
agent.run(message, files: files)
|
|
519
|
+
session_manager&.save(agent.to_session_data(status: :success))
|
|
520
|
+
exit(0)
|
|
521
|
+
rescue Octo::AgentInterrupted
|
|
522
|
+
$stderr.puts "\nInterrupted."
|
|
523
|
+
exit(1)
|
|
524
|
+
rescue => e
|
|
525
|
+
$stderr.puts "Error: #{e.message}"
|
|
526
|
+
exit(1)
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Run agent with JSON (NDJSON) output mode — persistent process.
|
|
530
|
+
# Reads JSON messages from stdin, writes NDJSON events to stdout.
|
|
531
|
+
# Stays alive until "/exit", {"type":"exit"}, or stdin EOF.
|
|
532
|
+
#
|
|
533
|
+
# Input protocol (one JSON per line on stdin):
|
|
534
|
+
# {"type":"message","content":"..."} — run agent with this message
|
|
535
|
+
# {"type":"message","content":"...","files":[{"name":"x.jpg","mime_type":"image/jpeg","data_url":"data:..."}]} — with files
|
|
536
|
+
# {"type":"exit"} — graceful shutdown
|
|
537
|
+
# {"type":"confirmation","id":"conf_1","result":"yes"} — answer to request_confirmation
|
|
538
|
+
#
|
|
539
|
+
# If a bare string line is received it is treated as a message content.
|
|
540
|
+
def run_agent_with_json(agent, working_dir, agent_config, session_manager, client_factory, profile:)
|
|
541
|
+
json_ui = Octo::JsonUIController.new
|
|
542
|
+
agent.instance_variable_set(:@ui, json_ui)
|
|
543
|
+
|
|
544
|
+
json_ui.emit("system", message: "Agent started", model: agent_config.model_name, working_dir: working_dir)
|
|
545
|
+
|
|
546
|
+
# Persistent input loop — read JSON lines from stdin
|
|
547
|
+
while (line = $stdin.gets)
|
|
548
|
+
line = line.strip
|
|
549
|
+
next if line.empty?
|
|
550
|
+
|
|
551
|
+
# Parse input
|
|
552
|
+
input = begin
|
|
553
|
+
JSON.parse(line)
|
|
554
|
+
rescue JSON::ParserError
|
|
555
|
+
# Treat bare string as a message
|
|
556
|
+
{ "type" => "message", "content" => line }
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
type = input["type"] || "message"
|
|
560
|
+
|
|
561
|
+
case type
|
|
562
|
+
when "message"
|
|
563
|
+
content = input["content"].to_s.strip
|
|
564
|
+
if content.empty?
|
|
565
|
+
json_ui.emit("error", message: "Empty message content")
|
|
566
|
+
next
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Handle built-in commands
|
|
570
|
+
case content.downcase
|
|
571
|
+
when "/exit", "/quit"
|
|
572
|
+
break
|
|
573
|
+
when "/clear"
|
|
574
|
+
# Fresh Client from factory — guarantees credentials reflect the
|
|
575
|
+
# *current* agent_config (any /config model switch since startup
|
|
576
|
+
# is applied automatically). No stale shared client reference.
|
|
577
|
+
agent = Octo::Agent.new(client_factory.call, agent_config, working_dir: working_dir, ui: nil, profile: profile,
|
|
578
|
+
session_id: Octo::SessionManager.generate_id, source: :manual)
|
|
579
|
+
agent.instance_variable_set(:@ui, json_ui)
|
|
580
|
+
json_ui.emit("info", message: "Session cleared. Starting fresh.")
|
|
581
|
+
next
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
files = input["files"] || []
|
|
585
|
+
auto_name_session(agent, content)
|
|
586
|
+
run_json_task(agent, json_ui, session_manager) { agent.run(content, files: files) }
|
|
587
|
+
when "exit"
|
|
588
|
+
break
|
|
589
|
+
else
|
|
590
|
+
json_ui.emit("error", message: "Unknown input type: #{type}")
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Final session save and shutdown
|
|
595
|
+
if session_manager && agent.total_tasks > 0
|
|
596
|
+
session_manager.save(agent.to_session_data(status: :exited))
|
|
597
|
+
end
|
|
598
|
+
json_ui.emit("done", total_tasks: agent.total_tasks)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Execute a single agent task inside the JSON loop, with error handling.
|
|
602
|
+
def run_json_task(agent, json_ui, session_manager)
|
|
603
|
+
json_ui.set_working_status
|
|
604
|
+
yield
|
|
605
|
+
session_manager&.save(agent.to_session_data(status: :success))
|
|
606
|
+
json_ui.update_sessionbar(tasks: agent.total_tasks)
|
|
607
|
+
rescue Octo::AgentInterrupted
|
|
608
|
+
session_manager&.save(agent.to_session_data(status: :interrupted))
|
|
609
|
+
json_ui.emit("interrupted")
|
|
610
|
+
rescue => e
|
|
611
|
+
session_manager&.save(agent.to_session_data(status: :error, error_message: e.message))
|
|
612
|
+
json_ui.emit("error", message: e.message)
|
|
613
|
+
ensure
|
|
614
|
+
json_ui.set_idle_status
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Run agent with UI2 split-screen interface
|
|
618
|
+
def run_agent_with_ui2(agent, working_dir, agent_config, session_manager = nil, client_factory = nil, is_session_load: false)
|
|
619
|
+
# Detect terminal background BEFORE starting UI2 to avoid output interference
|
|
620
|
+
is_dark_bg = UI2::TerminalDetector.detect_dark_background
|
|
621
|
+
|
|
622
|
+
# Pass detected background mode to theme manager (singleton)
|
|
623
|
+
UI2::ThemeManager.instance.set_background_mode(is_dark_bg)
|
|
624
|
+
|
|
625
|
+
# Validate theme
|
|
626
|
+
theme_name = options[:theme] || "hacker"
|
|
627
|
+
available_themes = UI2::ThemeManager.available_themes.map(&:to_s)
|
|
628
|
+
unless available_themes.include?(theme_name)
|
|
629
|
+
say "Error: Unknown theme '#{theme_name}'. Available themes: #{available_themes.join(', ')}", :red
|
|
630
|
+
exit 1
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Create UI2 controller with configuration
|
|
634
|
+
ui_controller = UI2::UIController.new(
|
|
635
|
+
working_dir: working_dir,
|
|
636
|
+
mode: agent_config.permission_mode.to_s,
|
|
637
|
+
model: agent_config.model_name,
|
|
638
|
+
theme: theme_name
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
# Inject UI into agent
|
|
642
|
+
agent.instance_variable_set(:@ui, ui_controller)
|
|
643
|
+
|
|
644
|
+
# Inject current session id into UI session bar (parity with WebUI #sib-id)
|
|
645
|
+
ui_controller.update_sessionbar(session_id: agent.session_id)
|
|
646
|
+
|
|
647
|
+
# Set skill loader for command suggestions, filtered by agent profile whitelist
|
|
648
|
+
ui_controller.set_skill_loader(agent.skill_loader, agent.agent_profile)
|
|
649
|
+
|
|
650
|
+
# Track current working thread (agent or idle compression that can be interrupted)
|
|
651
|
+
current_task_thread = nil
|
|
652
|
+
|
|
653
|
+
# Idle compression timer - triggers compression after 180s of inactivity
|
|
654
|
+
idle_timer = Octo::IdleCompressionTimer.new(
|
|
655
|
+
agent: agent,
|
|
656
|
+
session_manager: session_manager,
|
|
657
|
+
logger: ->(msg, level:) { ui_controller.log(msg, level: level) }
|
|
658
|
+
) do |success|
|
|
659
|
+
if success
|
|
660
|
+
ui_controller.update_sessionbar(tasks: agent.total_tasks)
|
|
661
|
+
end
|
|
662
|
+
ui_controller.set_idle_status
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# Set up mode toggle handler
|
|
666
|
+
ui_controller.on_mode_toggle do |new_mode|
|
|
667
|
+
agent_config.permission_mode = new_mode.to_sym
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
# Set up time machine handler (ESC key)
|
|
671
|
+
ui_controller.on_time_machine do
|
|
672
|
+
handle_time_machine_command(ui_controller, agent, session_manager)
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# Set up interrupt handler
|
|
676
|
+
ui_controller.on_interrupt do |input_was_empty:|
|
|
677
|
+
# Priority 1: if idle compression work is actually in flight,
|
|
678
|
+
# Ctrl+C should stop compression — not exit the program. The
|
|
679
|
+
# compress thread rolls back history cleanly on AgentInterrupted.
|
|
680
|
+
if idle_timer.compressing?
|
|
681
|
+
idle_timer.cancel
|
|
682
|
+
ui_controller.show_progress(phase: "done")
|
|
683
|
+
ui_controller.set_idle_status
|
|
684
|
+
ui_controller.show_warning("Compression interrupted by user")
|
|
685
|
+
ui_controller.clear_input
|
|
686
|
+
next
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
if (not current_task_thread&.alive?) && input_was_empty
|
|
690
|
+
# Save final session state before exit
|
|
691
|
+
if session_manager && agent.total_tasks > 0
|
|
692
|
+
session_data = agent.to_session_data(status: :exited)
|
|
693
|
+
saved_path = session_manager.save(session_data)
|
|
694
|
+
|
|
695
|
+
# Show session saved message in output area (before stopping UI)
|
|
696
|
+
session_id = session_data[:session_id][0..7]
|
|
697
|
+
ui_controller.append_output("")
|
|
698
|
+
ui_controller.append_output("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
|
699
|
+
ui_controller.append_output("")
|
|
700
|
+
ui_controller.append_output("Session saved: #{saved_path}")
|
|
701
|
+
ui_controller.append_output("Tasks completed: #{agent.total_tasks}")
|
|
702
|
+
ui_controller.append_output("")
|
|
703
|
+
ui_controller.append_output("To continue this session, run:")
|
|
704
|
+
ui_controller.append_output(" octo -a #{session_id}")
|
|
705
|
+
ui_controller.append_output("")
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# Stop UI and exit
|
|
709
|
+
ui_controller.stop
|
|
710
|
+
exit(0)
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
if current_task_thread&.alive?
|
|
714
|
+
current_task_thread.raise(Octo::AgentInterrupted, "User interrupted")
|
|
715
|
+
end
|
|
716
|
+
ui_controller.clear_input
|
|
717
|
+
ui_controller.set_input_tips("Press Ctrl+C again to exit.", type: :info)
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# Set up input handler
|
|
721
|
+
ui_controller.on_input do |input, files, display: nil|
|
|
722
|
+
# Handle commands
|
|
723
|
+
case input.downcase.strip
|
|
724
|
+
when "/config"
|
|
725
|
+
handle_config_command(ui_controller, agent_config, agent)
|
|
726
|
+
next
|
|
727
|
+
when "/undo"
|
|
728
|
+
handle_time_machine_command(ui_controller, agent, session_manager)
|
|
729
|
+
next
|
|
730
|
+
when "/clear"
|
|
731
|
+
sleep 0.1
|
|
732
|
+
# Clear output area
|
|
733
|
+
ui_controller.layout.clear_output
|
|
734
|
+
# Cancel old idle timer before replacing agent to avoid stale-agent compression
|
|
735
|
+
idle_timer.cancel
|
|
736
|
+
# Fresh Client built from the *current* agent_config (picks up any
|
|
737
|
+
# /config model switch made during this session). Never reuse a
|
|
738
|
+
# long-lived `client` — a previous implementation did, and after
|
|
739
|
+
# a DSK → Opus switch the reused Client carried stale @model /
|
|
740
|
+
# @use_bedrock, causing /chat/completions 404s on octo.com.
|
|
741
|
+
agent = Octo::Agent.new(client_factory.call, agent_config, working_dir: working_dir, ui: ui_controller, profile: agent.agent_profile.name, session_id: Octo::SessionManager.generate_id, source: :manual)
|
|
742
|
+
# Rebuild idle timer bound to the new agent
|
|
743
|
+
idle_timer = Octo::IdleCompressionTimer.new(
|
|
744
|
+
agent: agent,
|
|
745
|
+
session_manager: session_manager,
|
|
746
|
+
logger: ->(msg, level:) { ui_controller.log(msg, level: level) }
|
|
747
|
+
) do |success|
|
|
748
|
+
if success
|
|
749
|
+
ui_controller.update_sessionbar(tasks: agent.total_tasks)
|
|
750
|
+
end
|
|
751
|
+
ui_controller.set_idle_status
|
|
752
|
+
end
|
|
753
|
+
ui_controller.show_info("Session cleared. Starting fresh.")
|
|
754
|
+
# Update session bar with reset values
|
|
755
|
+
ui_controller.update_sessionbar(tasks: agent.total_tasks, session_id: agent.session_id)
|
|
756
|
+
# Clear todo area display
|
|
757
|
+
ui_controller.update_todos([])
|
|
758
|
+
next
|
|
759
|
+
when "/exit", "/quit"
|
|
760
|
+
ui_controller.stop
|
|
761
|
+
exit(0)
|
|
762
|
+
when "/help"
|
|
763
|
+
sleep 0.1
|
|
764
|
+
ui_controller.show_help
|
|
765
|
+
next
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
# If any task thread is running, interrupt it first
|
|
769
|
+
if current_task_thread&.alive?
|
|
770
|
+
current_task_thread.raise(Octo::AgentInterrupted, "New input received")
|
|
771
|
+
current_task_thread.join(2) # Wait up to 2 seconds for graceful shutdown
|
|
772
|
+
ui_controller.set_idle_status
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# Cancel idle timer if running (new input means user is active)
|
|
776
|
+
idle_timer.cancel
|
|
777
|
+
|
|
778
|
+
auto_name_session(agent, input)
|
|
779
|
+
|
|
780
|
+
# Run agent in background thread
|
|
781
|
+
current_task_thread = Thread.new do
|
|
782
|
+
begin
|
|
783
|
+
# Set status to working when agent starts
|
|
784
|
+
ui_controller.set_working_status
|
|
785
|
+
|
|
786
|
+
# Run agent (Agent will call @ui methods directly)
|
|
787
|
+
result = agent.run(input, files: files)
|
|
788
|
+
|
|
789
|
+
# Save session after each task
|
|
790
|
+
if session_manager
|
|
791
|
+
session_manager.save(agent.to_session_data(status: :success))
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# Update session bar with agent's cumulative stats
|
|
795
|
+
ui_controller.update_sessionbar(tasks: agent.total_tasks)
|
|
796
|
+
rescue Octo::AgentInterrupted, StandardError => e
|
|
797
|
+
handle_agent_exception(ui_controller, agent, session_manager, e)
|
|
798
|
+
ensure
|
|
799
|
+
current_task_thread = nil
|
|
800
|
+
# Start idle timer after agent completes
|
|
801
|
+
idle_timer.start
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
# Initialize UI screen first
|
|
807
|
+
if is_session_load
|
|
808
|
+
recent_user_messages = agent.get_recent_user_messages(limit: 5)
|
|
809
|
+
ui_controller.initialize_and_show_banner(recent_user_messages: recent_user_messages)
|
|
810
|
+
# Update session bar with restored agent stats
|
|
811
|
+
ui_controller.update_sessionbar(tasks: agent.total_tasks)
|
|
812
|
+
else
|
|
813
|
+
ui_controller.initialize_and_show_banner
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# Start input loop (blocks until exit)
|
|
817
|
+
ui_controller.start_input_loop
|
|
818
|
+
|
|
819
|
+
# Cleanup: kill any running threads
|
|
820
|
+
idle_timer.cancel
|
|
821
|
+
current_task_thread&.kill
|
|
822
|
+
|
|
823
|
+
# Save final session state
|
|
824
|
+
if session_manager && agent.total_tasks > 0
|
|
825
|
+
session_manager.save(agent.to_session_data)
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
# ── server command ─────────────────────────────────────────────────────────
|
|
834
|
+
desc "server", "Start the Octo web UI server"
|
|
835
|
+
long_desc <<-LONGDESC
|
|
836
|
+
Start a long-running HTTP + WebSocket server that serves the Octo web UI.
|
|
837
|
+
|
|
838
|
+
Open http://localhost:8888 in your browser to access the multi-session interface.
|
|
839
|
+
Multiple sessions (e.g. "coding", "copywriting") can run simultaneously.
|
|
840
|
+
|
|
841
|
+
Examples:
|
|
842
|
+
$ octo server
|
|
843
|
+
$ octo server --port 8080
|
|
844
|
+
LONGDESC
|
|
845
|
+
option :host, type: :string, aliases: ["-b", "--bind"], default: "127.0.0.1", desc: "Bind host (default: 127.0.0.1)"
|
|
846
|
+
option :port, type: :numeric, aliases: "-p", default: 8888, desc: "Listen port (default: 8888)"
|
|
847
|
+
option :no_compression, type: :boolean, default: false,
|
|
848
|
+
desc: "Disable message compression (saves tokens but may lose context)"
|
|
849
|
+
option :no_memory, type: :boolean, default: false,
|
|
850
|
+
desc: "Disable automatic memory updates"
|
|
851
|
+
option :no_caching, type: :boolean, default: false,
|
|
852
|
+
desc: "Disable prompt caching"
|
|
853
|
+
option :no_skill_evolution, type: :boolean, default: false,
|
|
854
|
+
desc: "Disable automatic skill evolution"
|
|
855
|
+
option :help, type: :boolean, aliases: "-h", desc: "Show this help message"
|
|
856
|
+
def server
|
|
857
|
+
if options[:help]
|
|
858
|
+
invoke :help, ["server"]
|
|
859
|
+
return
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
# ── Security gate ──────────────────────────────────────────────────────
|
|
863
|
+
# Binding to 0.0.0.0 exposes the server to the public network.
|
|
864
|
+
# Refuse to start unless OCTO_ACCESS_KEY env var is set.
|
|
865
|
+
if options[:host] == "0.0.0.0" && !ENV.key?("OCTO_ACCESS_KEY")
|
|
866
|
+
puts <<~MSG
|
|
867
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
868
|
+
║ ⚠️ Security Warning: Refusing to start ║
|
|
869
|
+
╠══════════════════════════════════════════════════════════════╣
|
|
870
|
+
║ ║
|
|
871
|
+
║ Binding to 0.0.0.0 exposes Octo to the public network. ║
|
|
872
|
+
║ You must set OCTO_ACCESS_KEY before starting the server. ║
|
|
873
|
+
║ ║
|
|
874
|
+
║ Generate a secure key: ║
|
|
875
|
+
║ openssl rand -hex 32 ║
|
|
876
|
+
║ ║
|
|
877
|
+
║ Then export it: ║
|
|
878
|
+
║ export OCTO_ACCESS_KEY=<your-generated-key> ║
|
|
879
|
+
║ ║
|
|
880
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
881
|
+
MSG
|
|
882
|
+
exit(1)
|
|
883
|
+
end
|
|
884
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
885
|
+
|
|
886
|
+
if ENV["OCTO_WORKER"] == "1"
|
|
887
|
+
# ── Worker mode ───────────────────────────────────────────────────────
|
|
888
|
+
# Spawned by Master. Inherit the listen socket from the file descriptor
|
|
889
|
+
# passed via OCTO_INHERIT_FD, and report back to master via OCTO_MASTER_PID.
|
|
890
|
+
require_relative "server/http_server"
|
|
891
|
+
require_relative "server/epipe_safe_io"
|
|
892
|
+
|
|
893
|
+
# Protect $stdout / $stderr from Errno::EPIPE.
|
|
894
|
+
#
|
|
895
|
+
# The worker inherits fd 1/2 from the Master process. If the Master's
|
|
896
|
+
# stdout pipe ever breaks (e.g. it was launched by an installer or GUI
|
|
897
|
+
# that has since exited), the next `puts` would raise Errno::EPIPE and
|
|
898
|
+
# crash the worker — destroying all in-memory sessions, agent loops,
|
|
899
|
+
# and SSE connections, and looping forever because the respawned
|
|
900
|
+
# worker inherits the same broken fd.
|
|
901
|
+
#
|
|
902
|
+
# In healthy state these wrappers are transparent — output goes to
|
|
903
|
+
# the user's terminal as usual. On first broken-pipe failure they
|
|
904
|
+
# silently fall back to /dev/null and the worker stays alive.
|
|
905
|
+
$stdout = Octo::Server::EPIPESafeIO.new($stdout)
|
|
906
|
+
$stderr = Octo::Server::EPIPESafeIO.new($stderr)
|
|
907
|
+
|
|
908
|
+
fd = ENV["OCTO_INHERIT_FD"].to_i
|
|
909
|
+
master_pid = ENV["OCTO_MASTER_PID"].to_i
|
|
910
|
+
# Must use TCPServer.for_fd (not Socket.for_fd) so that accept_nonblock
|
|
911
|
+
# returns a single Socket, not [Socket, Addrinfo] — WEBrick expects the former.
|
|
912
|
+
socket = TCPServer.for_fd(fd)
|
|
913
|
+
|
|
914
|
+
Octo::Logger.console = true
|
|
915
|
+
Octo::Logger.info("[cli worker PID=#{Process.pid}] OCTO_INHERIT_FD=#{fd} OCTO_MASTER_PID=#{master_pid} socket=#{socket.class} fd=#{socket.fileno}")
|
|
916
|
+
|
|
917
|
+
agent_config = Octo::AgentConfig.load
|
|
918
|
+
agent_config.permission_mode = :confirm_all
|
|
919
|
+
|
|
920
|
+
# Apply CLI overrides to agent config (--no-compression etc.)
|
|
921
|
+
# These override whatever is stored in config.yml.
|
|
922
|
+
agent_config.enable_compression = false if options[:no_compression]
|
|
923
|
+
agent_config.memory_update_enabled = false if options[:no_memory]
|
|
924
|
+
agent_config.enable_prompt_caching = false if options[:no_caching]
|
|
925
|
+
if options[:no_skill_evolution]
|
|
926
|
+
agent_config.skill_evolution[:enabled] = false
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
client_factory = lambda do
|
|
930
|
+
Octo::Client.new(
|
|
931
|
+
agent_config.api_key,
|
|
932
|
+
base_url: agent_config.base_url,
|
|
933
|
+
model: agent_config.model_name,
|
|
934
|
+
anthropic_format: agent_config.anthropic_format?
|
|
935
|
+
)
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
Octo::Server::HttpServer.new(
|
|
939
|
+
host: options[:host],
|
|
940
|
+
port: options[:port],
|
|
941
|
+
agent_config: agent_config,
|
|
942
|
+
client_factory: client_factory,
|
|
943
|
+
socket: socket,
|
|
944
|
+
master_pid: master_pid
|
|
945
|
+
).start
|
|
946
|
+
else
|
|
947
|
+
# ── Master mode ───────────────────────────────────────────────────────
|
|
948
|
+
# First invocation by the user. Start the Master process which holds the
|
|
949
|
+
# socket and supervises worker processes.
|
|
950
|
+
require_relative "server/server_master"
|
|
951
|
+
|
|
952
|
+
extra_flags = []
|
|
953
|
+
extra_flags << "--no-compression" if options[:no_compression]
|
|
954
|
+
extra_flags << "--no-memory" if options[:no_memory]
|
|
955
|
+
extra_flags << "--no-caching" if options[:no_caching]
|
|
956
|
+
extra_flags << "--no-skill-evolution" if options[:no_skill_evolution]
|
|
957
|
+
|
|
958
|
+
Octo::Logger.console = true
|
|
959
|
+
|
|
960
|
+
Octo::Server::Master.new(
|
|
961
|
+
host: options[:host],
|
|
962
|
+
port: options[:port],
|
|
963
|
+
extra_flags: extra_flags
|
|
964
|
+
).run
|
|
965
|
+
end
|
|
966
|
+
end
|
|
967
|
+
end
|
|
968
|
+
end
|