octo-agent 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. checksums.yaml +7 -0
  2. data/.clacky/skills/commit/SKILL.md +423 -0
  3. data/.clacky/skills/gem-release/SKILL.md +199 -0
  4. data/.clacky/skills/gem-release/scripts/release.sh +304 -0
  5. data/.clacky/skills/oss-upload/SKILL.md +47 -0
  6. data/.octorules +106 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +8 -0
  9. data/CHANGELOG.md +76 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/CONTRIBUTING.md +92 -0
  12. data/Dockerfile +28 -0
  13. data/LICENSE.txt +22 -0
  14. data/POSITIONING.md +46 -0
  15. data/README.md +134 -0
  16. data/README_CN.md +134 -0
  17. data/Rakefile +34 -0
  18. data/benchmark/fixtures/sample_project/Gemfile +3 -0
  19. data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
  20. data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
  21. data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
  22. data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
  23. data/benchmark/results/EVALUATION_REPORT.md +165 -0
  24. data/benchmark/results/baseline_20260511_174424.json +128 -0
  25. data/benchmark/results/report_20260511_175256.json +271 -0
  26. data/benchmark/results/report_20260511_175444.json +271 -0
  27. data/benchmark/results/treatment_20260511_175103.json +130 -0
  28. data/benchmark/runner.rb +441 -0
  29. data/bin/octo +7 -0
  30. data/docs/agent-first-ui-design.md +77 -0
  31. data/docs/billing-system.md +318 -0
  32. data/docs/channel-architecture.md +235 -0
  33. data/docs/engineering-article.md +343 -0
  34. data/docs/session-skill-invocation.md +69 -0
  35. data/docs/time_machine_design.md +247 -0
  36. data/docs/ui2-architecture.md +124 -0
  37. data/homebrew/README.md +96 -0
  38. data/homebrew/openocto.rb +24 -0
  39. data/lib/octo/agent/hook_manager.rb +61 -0
  40. data/lib/octo/agent/llm_caller.rb +800 -0
  41. data/lib/octo/agent/memory_updater.rb +246 -0
  42. data/lib/octo/agent/message_compressor.rb +225 -0
  43. data/lib/octo/agent/message_compressor_helper.rb +869 -0
  44. data/lib/octo/agent/next_message_suggester.rb +215 -0
  45. data/lib/octo/agent/session_serializer.rb +685 -0
  46. data/lib/octo/agent/skill_auto_creator.rb +114 -0
  47. data/lib/octo/agent/skill_evolution.rb +61 -0
  48. data/lib/octo/agent/skill_manager.rb +466 -0
  49. data/lib/octo/agent/skill_reflector.rb +89 -0
  50. data/lib/octo/agent/system_prompt_builder.rb +101 -0
  51. data/lib/octo/agent/time_machine.rb +214 -0
  52. data/lib/octo/agent/tool_executor.rb +454 -0
  53. data/lib/octo/agent/tool_registry.rb +150 -0
  54. data/lib/octo/agent.rb +2180 -0
  55. data/lib/octo/agent_config.rb +989 -0
  56. data/lib/octo/agent_profile.rb +112 -0
  57. data/lib/octo/anthropic_stream_aggregator.rb +137 -0
  58. data/lib/octo/background_task_registry.rb +324 -0
  59. data/lib/octo/banner.rb +34 -0
  60. data/lib/octo/bedrock_stream_aggregator.rb +137 -0
  61. data/lib/octo/block_font.rb +331 -0
  62. data/lib/octo/cli.rb +968 -0
  63. data/lib/octo/client.rb +623 -0
  64. data/lib/octo/default_agents/SOUL.md +3 -0
  65. data/lib/octo/default_agents/USER.md +1 -0
  66. data/lib/octo/default_agents/base_prompt.md +66 -0
  67. data/lib/octo/default_agents/coding/profile.yml +2 -0
  68. data/lib/octo/default_agents/coding/system_prompt.md +67 -0
  69. data/lib/octo/default_agents/general/profile.yml +2 -0
  70. data/lib/octo/default_agents/general/system_prompt.md +16 -0
  71. data/lib/octo/default_parsers/doc_parser.rb +69 -0
  72. data/lib/octo/default_parsers/docx_parser.rb +188 -0
  73. data/lib/octo/default_parsers/pdf_parser.rb +120 -0
  74. data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
  75. data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
  76. data/lib/octo/default_parsers/pptx_parser.rb +140 -0
  77. data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
  78. data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
  79. data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
  80. data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
  81. data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
  82. data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
  83. data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
  84. data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
  85. data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
  86. data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
  87. data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
  88. data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
  89. data/lib/octo/default_skills/onboard/SKILL.md +578 -0
  90. data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
  91. data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
  92. data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
  93. data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
  94. data/lib/octo/default_skills/personal-website/publish.rb +235 -0
  95. data/lib/octo/default_skills/product-help/SKILL.md +123 -0
  96. data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
  97. data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
  98. data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
  99. data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
  100. data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
  101. data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
  102. data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
  103. data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
  104. data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
  105. data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
  106. data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
  107. data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
  108. data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
  109. data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
  110. data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
  111. data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
  112. data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
  113. data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
  114. data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
  115. data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
  116. data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
  117. data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
  118. data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
  119. data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
  120. data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
  121. data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
  122. data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
  123. data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
  124. data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
  125. data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  126. data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
  127. data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
  128. data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
  129. data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
  130. data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
  131. data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
  132. data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
  133. data/lib/octo/idle_compression_timer.rb +115 -0
  134. data/lib/octo/json_ui_controller.rb +204 -0
  135. data/lib/octo/message_format/anthropic.rb +409 -0
  136. data/lib/octo/message_format/bedrock.rb +361 -0
  137. data/lib/octo/message_format/open_ai.rb +222 -0
  138. data/lib/octo/message_history.rb +373 -0
  139. data/lib/octo/openai_stream_aggregator.rb +130 -0
  140. data/lib/octo/plain_ui_controller.rb +166 -0
  141. data/lib/octo/providers.rb +534 -0
  142. data/lib/octo/server/browser_manager.rb +397 -0
  143. data/lib/octo/server/channel/adapters/base.rb +82 -0
  144. data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
  145. data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
  146. data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
  147. data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
  148. data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
  149. data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
  150. data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
  151. data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
  152. data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
  153. data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
  154. data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
  155. data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
  156. data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
  157. data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
  158. data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
  159. data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
  160. data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
  161. data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
  162. data/lib/octo/server/channel/channel_config.rb +178 -0
  163. data/lib/octo/server/channel/channel_manager.rb +468 -0
  164. data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
  165. data/lib/octo/server/channel.rb +33 -0
  166. data/lib/octo/server/discover.rb +77 -0
  167. data/lib/octo/server/epipe_safe_io.rb +105 -0
  168. data/lib/octo/server/http_server.rb +3554 -0
  169. data/lib/octo/server/scheduler.rb +317 -0
  170. data/lib/octo/server/server_master.rb +325 -0
  171. data/lib/octo/server/session_registry.rb +431 -0
  172. data/lib/octo/server/web_ui_controller.rb +487 -0
  173. data/lib/octo/session_manager.rb +385 -0
  174. data/lib/octo/skill.rb +466 -0
  175. data/lib/octo/skill_loader.rb +328 -0
  176. data/lib/octo/tools/base.rb +118 -0
  177. data/lib/octo/tools/browser.rb +625 -0
  178. data/lib/octo/tools/edit.rb +165 -0
  179. data/lib/octo/tools/file_reader.rb +549 -0
  180. data/lib/octo/tools/glob.rb +162 -0
  181. data/lib/octo/tools/grep.rb +356 -0
  182. data/lib/octo/tools/invoke_skill.rb +96 -0
  183. data/lib/octo/tools/list_tasks.rb +54 -0
  184. data/lib/octo/tools/redo_task.rb +41 -0
  185. data/lib/octo/tools/request_user_feedback.rb +84 -0
  186. data/lib/octo/tools/security.rb +333 -0
  187. data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
  188. data/lib/octo/tools/terminal/persistent_session.rb +268 -0
  189. data/lib/octo/tools/terminal/safe_rm.sh +106 -0
  190. data/lib/octo/tools/terminal/session_manager.rb +213 -0
  191. data/lib/octo/tools/terminal.rb +1828 -0
  192. data/lib/octo/tools/todo_manager.rb +374 -0
  193. data/lib/octo/tools/trash_manager.rb +388 -0
  194. data/lib/octo/tools/undo_task.rb +35 -0
  195. data/lib/octo/tools/web_fetch.rb +242 -0
  196. data/lib/octo/tools/web_search.rb +260 -0
  197. data/lib/octo/tools/write.rb +77 -0
  198. data/lib/octo/ui2/block_font.rb +10 -0
  199. data/lib/octo/ui2/components/base_component.rb +163 -0
  200. data/lib/octo/ui2/components/command_suggestions.rb +290 -0
  201. data/lib/octo/ui2/components/common_component.rb +96 -0
  202. data/lib/octo/ui2/components/inline_input.rb +226 -0
  203. data/lib/octo/ui2/components/input_area.rb +1338 -0
  204. data/lib/octo/ui2/components/message_component.rb +99 -0
  205. data/lib/octo/ui2/components/modal_component.rb +419 -0
  206. data/lib/octo/ui2/components/todo_area.rb +149 -0
  207. data/lib/octo/ui2/components/tool_component.rb +107 -0
  208. data/lib/octo/ui2/components/welcome_banner.rb +139 -0
  209. data/lib/octo/ui2/layout_manager.rb +807 -0
  210. data/lib/octo/ui2/line_editor.rb +363 -0
  211. data/lib/octo/ui2/markdown_renderer.rb +100 -0
  212. data/lib/octo/ui2/output_buffer.rb +370 -0
  213. data/lib/octo/ui2/progress_handle.rb +362 -0
  214. data/lib/octo/ui2/progress_indicator.rb +55 -0
  215. data/lib/octo/ui2/screen_buffer.rb +273 -0
  216. data/lib/octo/ui2/terminal_detector.rb +119 -0
  217. data/lib/octo/ui2/theme_manager.rb +85 -0
  218. data/lib/octo/ui2/themes/base_theme.rb +105 -0
  219. data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
  220. data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
  221. data/lib/octo/ui2/thinking_verbs.rb +26 -0
  222. data/lib/octo/ui2/ui_controller.rb +1625 -0
  223. data/lib/octo/ui2/view_renderer.rb +177 -0
  224. data/lib/octo/ui2.rb +40 -0
  225. data/lib/octo/ui_interface.rb +154 -0
  226. data/lib/octo/utils/arguments_parser.rb +191 -0
  227. data/lib/octo/utils/browser_detector.rb +195 -0
  228. data/lib/octo/utils/encoding.rb +92 -0
  229. data/lib/octo/utils/environment_detector.rb +140 -0
  230. data/lib/octo/utils/file_ignore_helper.rb +170 -0
  231. data/lib/octo/utils/file_processor.rb +601 -0
  232. data/lib/octo/utils/gitignore_parser.rb +154 -0
  233. data/lib/octo/utils/limit_stack.rb +152 -0
  234. data/lib/octo/utils/logger.rb +124 -0
  235. data/lib/octo/utils/login_shell.rb +72 -0
  236. data/lib/octo/utils/model_pricing.rb +646 -0
  237. data/lib/octo/utils/parser_manager.rb +165 -0
  238. data/lib/octo/utils/path_helper.rb +15 -0
  239. data/lib/octo/utils/scripts_manager.rb +59 -0
  240. data/lib/octo/utils/string_matcher.rb +158 -0
  241. data/lib/octo/utils/trash_directory.rb +112 -0
  242. data/lib/octo/utils/workspace_rules.rb +46 -0
  243. data/lib/octo/version.rb +5 -0
  244. data/lib/octo/web/app.css +7141 -0
  245. data/lib/octo/web/app.js +543 -0
  246. data/lib/octo/web/apple-touch-icon.png +0 -0
  247. data/lib/octo/web/auth.js +150 -0
  248. data/lib/octo/web/channels.js +276 -0
  249. data/lib/octo/web/datepicker.js +205 -0
  250. data/lib/octo/web/favicon.png +0 -0
  251. data/lib/octo/web/i18n.js +1073 -0
  252. data/lib/octo/web/icon-512.png +0 -0
  253. data/lib/octo/web/icon-dark.svg +25 -0
  254. data/lib/octo/web/icon.svg +29 -0
  255. data/lib/octo/web/index.html +871 -0
  256. data/lib/octo/web/marked.min.js +69 -0
  257. data/lib/octo/web/onboard.js +491 -0
  258. data/lib/octo/web/profile.js +442 -0
  259. data/lib/octo/web/sessions.js +4421 -0
  260. data/lib/octo/web/settings.js +913 -0
  261. data/lib/octo/web/sidebar.js +32 -0
  262. data/lib/octo/web/skills.js +885 -0
  263. data/lib/octo/web/tasks.js +297 -0
  264. data/lib/octo/web/theme.js +105 -0
  265. data/lib/octo/web/trash.js +343 -0
  266. data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
  267. data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
  268. data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
  269. data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  270. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  271. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  272. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  273. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  274. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  275. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  276. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  277. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  278. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  279. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  280. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  281. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  282. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  283. data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  284. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  285. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  286. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  287. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  288. data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  289. data/lib/octo/web/vendor/katex/katex.min.css +1 -0
  290. data/lib/octo/web/vendor/katex/katex.min.js +1 -0
  291. data/lib/octo/web/version.js +449 -0
  292. data/lib/octo/web/weixin-qr.html +209 -0
  293. data/lib/octo/web/ws-dispatcher.js +357 -0
  294. data/lib/octo/web/ws.js +128 -0
  295. data/lib/octo.rb +145 -0
  296. data/scripts/build/build.sh +329 -0
  297. data/scripts/build/lib/apt.sh +56 -0
  298. data/scripts/build/lib/brew.sh +89 -0
  299. data/scripts/build/lib/colors.sh +17 -0
  300. data/scripts/build/lib/gem.sh +95 -0
  301. data/scripts/build/lib/mise.sh +125 -0
  302. data/scripts/build/lib/network.sh +157 -0
  303. data/scripts/build/lib/os.sh +57 -0
  304. data/scripts/build/lib/shell.sh +37 -0
  305. data/scripts/build/src/install.sh.cc +174 -0
  306. data/scripts/build/src/install_browser.sh.cc +101 -0
  307. data/scripts/build/src/install_full.sh.cc +290 -0
  308. data/scripts/build/src/install_rails_deps.sh.cc +145 -0
  309. data/scripts/build/src/install_system_deps.sh.cc +123 -0
  310. data/scripts/build/src/uninstall.sh.cc +101 -0
  311. data/scripts/install.ps1 +532 -0
  312. data/scripts/install.sh +567 -0
  313. data/scripts/install_browser.sh +479 -0
  314. data/scripts/install_full.sh +838 -0
  315. data/scripts/install_rails_deps.sh +746 -0
  316. data/scripts/install_system_deps.sh +518 -0
  317. data/scripts/uninstall.sh +287 -0
  318. data/sig/octo.rbs +4 -0
  319. metadata +614 -0
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