octo-agent 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. checksums.yaml +7 -0
  2. data/.clacky/skills/commit/SKILL.md +423 -0
  3. data/.clacky/skills/gem-release/SKILL.md +199 -0
  4. data/.clacky/skills/gem-release/scripts/release.sh +304 -0
  5. data/.clacky/skills/oss-upload/SKILL.md +47 -0
  6. data/.octorules +106 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +8 -0
  9. data/CHANGELOG.md +76 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/CONTRIBUTING.md +92 -0
  12. data/Dockerfile +28 -0
  13. data/LICENSE.txt +22 -0
  14. data/POSITIONING.md +46 -0
  15. data/README.md +134 -0
  16. data/README_CN.md +134 -0
  17. data/Rakefile +34 -0
  18. data/benchmark/fixtures/sample_project/Gemfile +3 -0
  19. data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
  20. data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
  21. data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
  22. data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
  23. data/benchmark/results/EVALUATION_REPORT.md +165 -0
  24. data/benchmark/results/baseline_20260511_174424.json +128 -0
  25. data/benchmark/results/report_20260511_175256.json +271 -0
  26. data/benchmark/results/report_20260511_175444.json +271 -0
  27. data/benchmark/results/treatment_20260511_175103.json +130 -0
  28. data/benchmark/runner.rb +441 -0
  29. data/bin/octo +7 -0
  30. data/docs/agent-first-ui-design.md +77 -0
  31. data/docs/billing-system.md +318 -0
  32. data/docs/channel-architecture.md +235 -0
  33. data/docs/engineering-article.md +343 -0
  34. data/docs/session-skill-invocation.md +69 -0
  35. data/docs/time_machine_design.md +247 -0
  36. data/docs/ui2-architecture.md +124 -0
  37. data/homebrew/README.md +96 -0
  38. data/homebrew/openocto.rb +24 -0
  39. data/lib/octo/agent/hook_manager.rb +61 -0
  40. data/lib/octo/agent/llm_caller.rb +800 -0
  41. data/lib/octo/agent/memory_updater.rb +246 -0
  42. data/lib/octo/agent/message_compressor.rb +225 -0
  43. data/lib/octo/agent/message_compressor_helper.rb +869 -0
  44. data/lib/octo/agent/next_message_suggester.rb +215 -0
  45. data/lib/octo/agent/session_serializer.rb +685 -0
  46. data/lib/octo/agent/skill_auto_creator.rb +114 -0
  47. data/lib/octo/agent/skill_evolution.rb +61 -0
  48. data/lib/octo/agent/skill_manager.rb +466 -0
  49. data/lib/octo/agent/skill_reflector.rb +89 -0
  50. data/lib/octo/agent/system_prompt_builder.rb +101 -0
  51. data/lib/octo/agent/time_machine.rb +214 -0
  52. data/lib/octo/agent/tool_executor.rb +454 -0
  53. data/lib/octo/agent/tool_registry.rb +150 -0
  54. data/lib/octo/agent.rb +2180 -0
  55. data/lib/octo/agent_config.rb +989 -0
  56. data/lib/octo/agent_profile.rb +112 -0
  57. data/lib/octo/anthropic_stream_aggregator.rb +137 -0
  58. data/lib/octo/background_task_registry.rb +324 -0
  59. data/lib/octo/banner.rb +34 -0
  60. data/lib/octo/bedrock_stream_aggregator.rb +137 -0
  61. data/lib/octo/block_font.rb +331 -0
  62. data/lib/octo/cli.rb +968 -0
  63. data/lib/octo/client.rb +623 -0
  64. data/lib/octo/default_agents/SOUL.md +3 -0
  65. data/lib/octo/default_agents/USER.md +1 -0
  66. data/lib/octo/default_agents/base_prompt.md +66 -0
  67. data/lib/octo/default_agents/coding/profile.yml +2 -0
  68. data/lib/octo/default_agents/coding/system_prompt.md +67 -0
  69. data/lib/octo/default_agents/general/profile.yml +2 -0
  70. data/lib/octo/default_agents/general/system_prompt.md +16 -0
  71. data/lib/octo/default_parsers/doc_parser.rb +69 -0
  72. data/lib/octo/default_parsers/docx_parser.rb +188 -0
  73. data/lib/octo/default_parsers/pdf_parser.rb +120 -0
  74. data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
  75. data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
  76. data/lib/octo/default_parsers/pptx_parser.rb +140 -0
  77. data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
  78. data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
  79. data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
  80. data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
  81. data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
  82. data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
  83. data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
  84. data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
  85. data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
  86. data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
  87. data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
  88. data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
  89. data/lib/octo/default_skills/onboard/SKILL.md +578 -0
  90. data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
  91. data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
  92. data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
  93. data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
  94. data/lib/octo/default_skills/personal-website/publish.rb +235 -0
  95. data/lib/octo/default_skills/product-help/SKILL.md +123 -0
  96. data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
  97. data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
  98. data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
  99. data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
  100. data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
  101. data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
  102. data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
  103. data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
  104. data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
  105. data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
  106. data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
  107. data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
  108. data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
  109. data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
  110. data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
  111. data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
  112. data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
  113. data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
  114. data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
  115. data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
  116. data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
  117. data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
  118. data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
  119. data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
  120. data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
  121. data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
  122. data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
  123. data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
  124. data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
  125. data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  126. data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
  127. data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
  128. data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
  129. data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
  130. data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
  131. data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
  132. data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
  133. data/lib/octo/idle_compression_timer.rb +115 -0
  134. data/lib/octo/json_ui_controller.rb +204 -0
  135. data/lib/octo/message_format/anthropic.rb +409 -0
  136. data/lib/octo/message_format/bedrock.rb +361 -0
  137. data/lib/octo/message_format/open_ai.rb +222 -0
  138. data/lib/octo/message_history.rb +373 -0
  139. data/lib/octo/openai_stream_aggregator.rb +130 -0
  140. data/lib/octo/plain_ui_controller.rb +166 -0
  141. data/lib/octo/providers.rb +534 -0
  142. data/lib/octo/server/browser_manager.rb +397 -0
  143. data/lib/octo/server/channel/adapters/base.rb +82 -0
  144. data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
  145. data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
  146. data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
  147. data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
  148. data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
  149. data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
  150. data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
  151. data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
  152. data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
  153. data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
  154. data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
  155. data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
  156. data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
  157. data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
  158. data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
  159. data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
  160. data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
  161. data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
  162. data/lib/octo/server/channel/channel_config.rb +178 -0
  163. data/lib/octo/server/channel/channel_manager.rb +468 -0
  164. data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
  165. data/lib/octo/server/channel.rb +33 -0
  166. data/lib/octo/server/discover.rb +77 -0
  167. data/lib/octo/server/epipe_safe_io.rb +105 -0
  168. data/lib/octo/server/http_server.rb +3554 -0
  169. data/lib/octo/server/scheduler.rb +317 -0
  170. data/lib/octo/server/server_master.rb +325 -0
  171. data/lib/octo/server/session_registry.rb +431 -0
  172. data/lib/octo/server/web_ui_controller.rb +487 -0
  173. data/lib/octo/session_manager.rb +385 -0
  174. data/lib/octo/skill.rb +466 -0
  175. data/lib/octo/skill_loader.rb +328 -0
  176. data/lib/octo/tools/base.rb +118 -0
  177. data/lib/octo/tools/browser.rb +625 -0
  178. data/lib/octo/tools/edit.rb +165 -0
  179. data/lib/octo/tools/file_reader.rb +549 -0
  180. data/lib/octo/tools/glob.rb +162 -0
  181. data/lib/octo/tools/grep.rb +356 -0
  182. data/lib/octo/tools/invoke_skill.rb +96 -0
  183. data/lib/octo/tools/list_tasks.rb +54 -0
  184. data/lib/octo/tools/redo_task.rb +41 -0
  185. data/lib/octo/tools/request_user_feedback.rb +84 -0
  186. data/lib/octo/tools/security.rb +333 -0
  187. data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
  188. data/lib/octo/tools/terminal/persistent_session.rb +268 -0
  189. data/lib/octo/tools/terminal/safe_rm.sh +106 -0
  190. data/lib/octo/tools/terminal/session_manager.rb +213 -0
  191. data/lib/octo/tools/terminal.rb +1828 -0
  192. data/lib/octo/tools/todo_manager.rb +374 -0
  193. data/lib/octo/tools/trash_manager.rb +388 -0
  194. data/lib/octo/tools/undo_task.rb +35 -0
  195. data/lib/octo/tools/web_fetch.rb +242 -0
  196. data/lib/octo/tools/web_search.rb +260 -0
  197. data/lib/octo/tools/write.rb +77 -0
  198. data/lib/octo/ui2/block_font.rb +10 -0
  199. data/lib/octo/ui2/components/base_component.rb +163 -0
  200. data/lib/octo/ui2/components/command_suggestions.rb +290 -0
  201. data/lib/octo/ui2/components/common_component.rb +96 -0
  202. data/lib/octo/ui2/components/inline_input.rb +226 -0
  203. data/lib/octo/ui2/components/input_area.rb +1338 -0
  204. data/lib/octo/ui2/components/message_component.rb +99 -0
  205. data/lib/octo/ui2/components/modal_component.rb +419 -0
  206. data/lib/octo/ui2/components/todo_area.rb +149 -0
  207. data/lib/octo/ui2/components/tool_component.rb +107 -0
  208. data/lib/octo/ui2/components/welcome_banner.rb +139 -0
  209. data/lib/octo/ui2/layout_manager.rb +807 -0
  210. data/lib/octo/ui2/line_editor.rb +363 -0
  211. data/lib/octo/ui2/markdown_renderer.rb +100 -0
  212. data/lib/octo/ui2/output_buffer.rb +370 -0
  213. data/lib/octo/ui2/progress_handle.rb +362 -0
  214. data/lib/octo/ui2/progress_indicator.rb +55 -0
  215. data/lib/octo/ui2/screen_buffer.rb +273 -0
  216. data/lib/octo/ui2/terminal_detector.rb +119 -0
  217. data/lib/octo/ui2/theme_manager.rb +85 -0
  218. data/lib/octo/ui2/themes/base_theme.rb +105 -0
  219. data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
  220. data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
  221. data/lib/octo/ui2/thinking_verbs.rb +26 -0
  222. data/lib/octo/ui2/ui_controller.rb +1625 -0
  223. data/lib/octo/ui2/view_renderer.rb +177 -0
  224. data/lib/octo/ui2.rb +40 -0
  225. data/lib/octo/ui_interface.rb +154 -0
  226. data/lib/octo/utils/arguments_parser.rb +191 -0
  227. data/lib/octo/utils/browser_detector.rb +195 -0
  228. data/lib/octo/utils/encoding.rb +92 -0
  229. data/lib/octo/utils/environment_detector.rb +140 -0
  230. data/lib/octo/utils/file_ignore_helper.rb +170 -0
  231. data/lib/octo/utils/file_processor.rb +601 -0
  232. data/lib/octo/utils/gitignore_parser.rb +154 -0
  233. data/lib/octo/utils/limit_stack.rb +152 -0
  234. data/lib/octo/utils/logger.rb +124 -0
  235. data/lib/octo/utils/login_shell.rb +72 -0
  236. data/lib/octo/utils/model_pricing.rb +646 -0
  237. data/lib/octo/utils/parser_manager.rb +165 -0
  238. data/lib/octo/utils/path_helper.rb +15 -0
  239. data/lib/octo/utils/scripts_manager.rb +59 -0
  240. data/lib/octo/utils/string_matcher.rb +158 -0
  241. data/lib/octo/utils/trash_directory.rb +112 -0
  242. data/lib/octo/utils/workspace_rules.rb +46 -0
  243. data/lib/octo/version.rb +5 -0
  244. data/lib/octo/web/app.css +7141 -0
  245. data/lib/octo/web/app.js +543 -0
  246. data/lib/octo/web/apple-touch-icon.png +0 -0
  247. data/lib/octo/web/auth.js +150 -0
  248. data/lib/octo/web/channels.js +276 -0
  249. data/lib/octo/web/datepicker.js +205 -0
  250. data/lib/octo/web/favicon.png +0 -0
  251. data/lib/octo/web/i18n.js +1073 -0
  252. data/lib/octo/web/icon-512.png +0 -0
  253. data/lib/octo/web/icon-dark.svg +25 -0
  254. data/lib/octo/web/icon.svg +29 -0
  255. data/lib/octo/web/index.html +871 -0
  256. data/lib/octo/web/marked.min.js +69 -0
  257. data/lib/octo/web/onboard.js +491 -0
  258. data/lib/octo/web/profile.js +442 -0
  259. data/lib/octo/web/sessions.js +4421 -0
  260. data/lib/octo/web/settings.js +913 -0
  261. data/lib/octo/web/sidebar.js +32 -0
  262. data/lib/octo/web/skills.js +885 -0
  263. data/lib/octo/web/tasks.js +297 -0
  264. data/lib/octo/web/theme.js +105 -0
  265. data/lib/octo/web/trash.js +343 -0
  266. data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
  267. data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
  268. data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
  269. data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  270. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  271. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  272. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  273. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  274. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  275. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  276. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  277. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  278. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  279. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  280. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  281. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  282. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  283. data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  284. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  285. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  286. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  287. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  288. data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  289. data/lib/octo/web/vendor/katex/katex.min.css +1 -0
  290. data/lib/octo/web/vendor/katex/katex.min.js +1 -0
  291. data/lib/octo/web/version.js +449 -0
  292. data/lib/octo/web/weixin-qr.html +209 -0
  293. data/lib/octo/web/ws-dispatcher.js +357 -0
  294. data/lib/octo/web/ws.js +128 -0
  295. data/lib/octo.rb +145 -0
  296. data/scripts/build/build.sh +329 -0
  297. data/scripts/build/lib/apt.sh +56 -0
  298. data/scripts/build/lib/brew.sh +89 -0
  299. data/scripts/build/lib/colors.sh +17 -0
  300. data/scripts/build/lib/gem.sh +95 -0
  301. data/scripts/build/lib/mise.sh +125 -0
  302. data/scripts/build/lib/network.sh +157 -0
  303. data/scripts/build/lib/os.sh +57 -0
  304. data/scripts/build/lib/shell.sh +37 -0
  305. data/scripts/build/src/install.sh.cc +174 -0
  306. data/scripts/build/src/install_browser.sh.cc +101 -0
  307. data/scripts/build/src/install_full.sh.cc +290 -0
  308. data/scripts/build/src/install_rails_deps.sh.cc +145 -0
  309. data/scripts/build/src/install_system_deps.sh.cc +123 -0
  310. data/scripts/build/src/uninstall.sh.cc +101 -0
  311. data/scripts/install.ps1 +532 -0
  312. data/scripts/install.sh +567 -0
  313. data/scripts/install_browser.sh +479 -0
  314. data/scripts/install_full.sh +838 -0
  315. data/scripts/install_rails_deps.sh +746 -0
  316. data/scripts/install_system_deps.sh +518 -0
  317. data/scripts/uninstall.sh +287 -0
  318. data/sig/octo.rbs +4 -0
  319. metadata +614 -0
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Octo
7
+ module Server
8
+ # Scheduler reads ~/.octo/schedules.yml and runs tasks on a cron-like schedule.
9
+ #
10
+ # It starts a background thread that ticks every 60 seconds, checks all
11
+ # configured schedules, and fires any task whose cron expression matches
12
+ # the current time.
13
+ #
14
+ # Schedule file format (~/.octo/schedules.yml):
15
+ #
16
+ # - name: daily_report
17
+ # task: daily_report # references ~/.octo/tasks/daily_report.md
18
+ # cron: "0 9 * * 1-5" # standard 5-field cron expression
19
+ # enabled: true # optional, defaults to true
20
+ #
21
+ # Cron field order: minute hour day-of-month month day-of-week
22
+ class Scheduler
23
+ SCHEDULES_FILE = File.expand_path("~/.octo/schedules.yml")
24
+ TASKS_DIR = File.expand_path("~/.octo/tasks")
25
+
26
+ def initialize(session_registry:, session_builder:, task_runner:)
27
+ @registry = session_registry
28
+ @session_builder = session_builder # callable: (name:, working_dir:) -> session_id
29
+ # Callable that runs a task on an agent with unified status/save/broadcast
30
+ # handling — signature: (session_id, agent, &block). Same contract as
31
+ # the one ChannelManager receives.
32
+ @task_runner = task_runner
33
+ @thread = nil
34
+ @running = false
35
+ @mutex = Mutex.new
36
+ end
37
+
38
+ # Start the background scheduler thread.
39
+ def start
40
+ @mutex.synchronize do
41
+ return if @running
42
+
43
+ @running = true
44
+ @thread = Thread.new { run_loop }
45
+ @thread.name = "octo-scheduler"
46
+ end
47
+ end
48
+
49
+ # Stop the background scheduler thread gracefully.
50
+ # NOTE: intentionally avoids Mutex here so it is safe to call from a
51
+ # signal trap context (Ruby disallows Mutex#synchronize inside traps).
52
+ def stop
53
+ @running = false
54
+ @thread&.wakeup rescue nil
55
+ @thread&.join(5)
56
+ end
57
+
58
+ def running?
59
+ @running
60
+ end
61
+
62
+ # Return all schedules from the config file.
63
+ def schedules
64
+ load_schedules
65
+ end
66
+
67
+ # ── Schedule CRUD ────────────────────────────────────────────────────────
68
+
69
+ # Add or update a schedule entry in schedules.yml.
70
+ def add_schedule(name:, task:, cron:, enabled: true)
71
+ list = load_schedules
72
+ # Remove existing entry with the same name
73
+ list.reject! { |s| s["name"] == name }
74
+ list << {
75
+ "name" => name,
76
+ "task" => task,
77
+ "cron" => cron,
78
+ "enabled" => enabled
79
+ }
80
+ save_schedules(list)
81
+ end
82
+
83
+ # Remove a schedule entry by name.
84
+ def remove_schedule(name)
85
+ list = load_schedules
86
+ before_count = list.size
87
+ list.reject! { |s| s["name"] == name }
88
+ save_schedules(list)
89
+ list.size < before_count
90
+ end
91
+
92
+ # Update an existing schedule entry (cron and/or enabled).
93
+ # Returns false if the schedule does not exist.
94
+ def update_schedule(name, cron: nil, enabled: nil)
95
+ list = load_schedules
96
+ entry = list.find { |s| s["name"] == name }
97
+ return false unless entry
98
+
99
+ entry["cron"] = cron unless cron.nil?
100
+ entry["enabled"] = enabled unless enabled.nil?
101
+ save_schedules(list)
102
+ true
103
+ end
104
+
105
+ # ── Composite cron-task helpers ──────────────────────────────────────────
106
+
107
+ # Create a task file and its schedule in one step.
108
+ def create_cron_task(name:, content:, cron:, enabled: true)
109
+ write_task(name, content)
110
+ add_schedule(name: name, task: name, cron: cron, enabled: enabled)
111
+ end
112
+
113
+ # Update a cron-task: optionally update content and/or schedule fields.
114
+ def update_cron_task(name, content: nil, cron: nil, enabled: nil)
115
+ raise "Cron task not found: #{name}" unless list_tasks.include?(name)
116
+
117
+ write_task(name, content) unless content.nil?
118
+ update_schedule(name, cron: cron, enabled: enabled) if cron || !enabled.nil?
119
+ end
120
+
121
+ # Delete a cron-task: remove both the task file and its schedule.
122
+ def delete_cron_task(name)
123
+ removed_schedule = remove_schedule(name)
124
+ removed_task = delete_task(name)
125
+ removed_schedule || removed_task
126
+ end
127
+
128
+ # Return a merged list of cron-tasks (task content + schedule metadata).
129
+ def list_cron_tasks
130
+ schedule_map = load_schedules.each_with_object({}) do |s, h|
131
+ h[s["task"]] = s if s.is_a?(Hash)
132
+ end
133
+
134
+ list_tasks.map do |task_name|
135
+ content = begin; read_task(task_name); rescue StandardError; ""; end
136
+ schedule = schedule_map[task_name] || {}
137
+ {
138
+ "name" => task_name,
139
+ "content" => content,
140
+ "cron" => schedule["cron"],
141
+ "enabled" => schedule.fetch("enabled", true),
142
+ "scheduled" => !schedule.empty?
143
+ }
144
+ end
145
+ end
146
+
147
+ # ── Task file helpers ────────────────────────────────────────────────────
148
+
149
+ # Read the prompt content of a named task.
150
+ def read_task(task_name)
151
+ path = task_file_path(task_name)
152
+ raise "Task not found: #{task_name} (expected #{path})" unless File.exist?(path)
153
+
154
+ File.read(path)
155
+ end
156
+
157
+ # Write the prompt content for a named task.
158
+ def write_task(task_name, content)
159
+ FileUtils.mkdir_p(TASKS_DIR)
160
+ File.write(task_file_path(task_name), content)
161
+ end
162
+
163
+ # List all existing task names.
164
+ def list_tasks
165
+ return [] unless Dir.exist?(TASKS_DIR)
166
+
167
+ Dir.glob(File.join(TASKS_DIR, "*.md")).map do |path|
168
+ File.basename(path, ".md")
169
+ end.sort
170
+ end
171
+
172
+ # Delete a task file and remove all schedules that reference it.
173
+ # Returns true if the task file existed and was deleted, false otherwise.
174
+ def delete_task(task_name)
175
+ path = task_file_path(task_name)
176
+ return false unless File.exist?(path)
177
+
178
+ File.delete(path)
179
+ # Remove all schedules referencing this task
180
+ load_schedules.select { |s| s["task"] == task_name }.each do |s|
181
+ remove_schedule(s["name"])
182
+ end
183
+ true
184
+ end
185
+
186
+ # Return the file path for a task.
187
+ def task_file_path(task_name)
188
+ File.join(TASKS_DIR, "#{task_name}.md")
189
+ end
190
+
191
+ # ── Internal ─────────────────────────────────────────────────────────────
192
+
193
+ private def run_loop
194
+ loop do
195
+ break unless @running
196
+
197
+ tick(Time.now)
198
+
199
+ # Sleep until the start of the next minute
200
+ now = Time.now
201
+ sleep_s = 60 - now.sec
202
+ sleep(sleep_s)
203
+ end
204
+ rescue => e
205
+ Octo::Logger.error("scheduler_fatal_error", error: e)
206
+ end
207
+
208
+ # Check all enabled schedules against the given time and fire matching ones.
209
+ private def tick(now)
210
+ load_schedules.each do |schedule|
211
+ next unless schedule["enabled"] != false
212
+ next unless cron_matches?(schedule["cron"].to_s, now)
213
+
214
+ fire_task(schedule)
215
+ rescue => e
216
+ Octo::Logger.error("scheduler_tick_error", schedule: schedule["name"], error: e)
217
+ end
218
+ end
219
+
220
+ # Execute a scheduled task by creating a new agent session.
221
+ private def fire_task(schedule)
222
+ task_name = schedule["task"].to_s
223
+ prompt = read_task(task_name)
224
+ name = "⏰ #{schedule["name"]} #{Time.now.strftime("%H:%M")}"
225
+
226
+ working_dir = File.expand_path("~/octo_workspace")
227
+ FileUtils.mkdir_p(working_dir)
228
+
229
+ # Scheduled tasks run unattended — use auto_approve so request_user_feedback doesn't block.
230
+ session_id = @session_builder.call(name: name, working_dir: working_dir, permission_mode: :auto_approve, source: :cron)
231
+
232
+ Octo::Logger.info("scheduler_task_fired", task: task_name, session: session_id)
233
+
234
+ agent = nil
235
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
236
+ return unless agent
237
+
238
+ # Delegate to the unified task runner (same code path as manual runs and
239
+ # channel-triggered runs). It handles:
240
+ # * status transitions (:running → :idle/:error)
241
+ # * broadcasting session_update
242
+ # * persisting the session JSON on success/interrupted/error ← the bit we were missing
243
+ # * idle-compression timer lifecycle
244
+ @task_runner.call(session_id, agent) { agent.run(prompt) }
245
+
246
+ Octo::Logger.info("scheduler_task_dispatched", task: task_name, session: session_id)
247
+ rescue => e
248
+ Octo::Logger.error("scheduler_fire_error", task: schedule["task"], error: e)
249
+ end
250
+
251
+ # ── Cron parsing ─────────────────────────────────────────────────────────
252
+
253
+ # Returns true if the 5-field cron expression matches the given Time.
254
+ # Fields: minute hour day-of-month month day-of-week
255
+ private def cron_matches?(expr, time)
256
+ fields = expr.strip.split(/\s+/)
257
+ return false unless fields.size == 5
258
+
259
+ minute, hour, dom, month, dow = fields
260
+
261
+ cron_field_matches?(minute, time.min) &&
262
+ cron_field_matches?(hour, time.hour) &&
263
+ cron_field_matches?(dom, time.day) &&
264
+ cron_field_matches?(month, time.month) &&
265
+ cron_field_matches?(dow, time.wday)
266
+ end
267
+
268
+ # Match a single cron field value against the actual time value.
269
+ # Supports: * (any), */n (step), n-m (range), n-m/s (range with step),
270
+ # and comma-separated lists of the above.
271
+ private def cron_field_matches?(field, value)
272
+ field.split(",").any? { |part| cron_part_matches?(part.strip, value) }
273
+ end
274
+
275
+ private def cron_part_matches?(part, value)
276
+ if part == "*"
277
+ true
278
+ elsif part.include?("/")
279
+ base, step = part.split("/")
280
+ step = step.to_i
281
+ return false if step.zero?
282
+
283
+ if base == "*"
284
+ (value % step).zero?
285
+ else
286
+ min, max = base.split("-").map(&:to_i)
287
+ max ||= value
288
+ value.between?(min, max) && ((value - min) % step).zero?
289
+ end
290
+ elsif part.include?("-")
291
+ min, max = part.split("-").map(&:to_i)
292
+ value.between?(min, max)
293
+ else
294
+ part.to_i == value
295
+ end
296
+ end
297
+
298
+ # ── File I/O ─────────────────────────────────────────────────────────────
299
+
300
+ private def load_schedules
301
+ return [] unless File.exist?(SCHEDULES_FILE)
302
+
303
+ data = YAMLCompat.load_file(SCHEDULES_FILE, permitted_classes: [Symbol])
304
+ raw = data.is_a?(Hash) ? data["schedules"] : data
305
+ Array(raw).select { |s| s.is_a?(Hash) }
306
+ rescue => e
307
+ Octo::Logger.error("scheduler_load_schedules_error", error: e)
308
+ []
309
+ end
310
+
311
+ private def save_schedules(list)
312
+ FileUtils.mkdir_p(File.dirname(SCHEDULES_FILE))
313
+ File.write(SCHEDULES_FILE, YAML.dump(list))
314
+ end
315
+ end
316
+ end
317
+ end
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "tmpdir"
5
+ require_relative "../banner"
6
+ require_relative "../version"
7
+
8
+ module Octo
9
+ module Server
10
+ # Master process — owns the listen socket, spawns/monitors worker processes.
11
+ #
12
+ # Lifecycle:
13
+ # octo server
14
+ # └─ Master.run (this file)
15
+ # ├─ creates TCPServer, holds it forever
16
+ # ├─ spawns Worker via spawn() — full new Ruby process, loads fresh gem
17
+ # ├─ traps USR1 → hot_restart (spawn new worker, gracefully stop old)
18
+ # └─ traps TERM/INT → shutdown (stop worker, exit cleanly)
19
+ #
20
+ # Worker receives:
21
+ # OCTO_WORKER=1 — "I am a worker, start HttpServer directly"
22
+ # OCTO_INHERIT_FD=<n> — file descriptor number of the inherited TCPServer socket
23
+ # OCTO_MASTER_PID=<n> — master PID so worker can send USR1 back on upgrade
24
+ class Master
25
+ # Worker exits with this code to request a hot restart (e.g. after gem upgrade).
26
+ RESTART_EXIT_CODE = 75
27
+ MAX_CONSECUTIVE_FAILURES = 5
28
+
29
+ def initialize(host:, port:, argv: nil, extra_flags: [])
30
+ @host = host
31
+ @port = port
32
+ @argv = argv # kept for backward compat but no longer used
33
+ @extra_flags = extra_flags
34
+
35
+ @socket = nil
36
+ @worker_pid = nil
37
+ @restart_requested = false
38
+ @shutdown_requested = false
39
+ end
40
+
41
+ def run
42
+ # 0. Kill any existing master on this port before binding.
43
+ kill_existing_master
44
+
45
+ # 1. Try to bind the socket.
46
+ # If port is 8888 (default), try fallback ports 8889-8893 if occupied.
47
+ # If port is non-default (user-specified), only try that exact port.
48
+ original_port = @port
49
+ max_port = (@port == 8888) ? (@port + 5) : @port
50
+ @socket = bind_with_fallback(@host, @port, max_port: max_port)
51
+
52
+ if @socket.nil?
53
+ if @port == 8888
54
+ Octo::Logger.error("[Master] No available ports in range 8888-8893")
55
+ else
56
+ Octo::Logger.error("[Master] Port #{@port} is in use")
57
+ end
58
+ exit(1)
59
+ end
60
+
61
+ @socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
62
+ @port = @socket.local_address.ip_port # Update to actual bound port
63
+
64
+ # 2. Print banner after port is determined
65
+ print_banner(port_changed: @port != original_port, original_port: original_port)
66
+
67
+ write_pid_file
68
+
69
+ # 3. Signal handlers
70
+ Signal.trap("USR1") { @restart_requested = true }
71
+ Signal.trap("TERM") { @shutdown_requested = true }
72
+ Signal.trap("INT") { @shutdown_requested = true }
73
+ Signal.trap("HUP") { @shutdown_requested = true }
74
+
75
+ # 4. Spawn first worker
76
+ @worker_pid = spawn_worker
77
+ @consecutive_failures = 0
78
+
79
+ # 4. Monitor loop
80
+ loop do
81
+ if @shutdown_requested
82
+ shutdown
83
+ break
84
+ end
85
+
86
+ if @restart_requested
87
+ @restart_requested = false
88
+ hot_restart
89
+ @consecutive_failures = 0
90
+ end
91
+
92
+ # Non-blocking wait: check if worker has exited
93
+ pid, status = Process.waitpid2(@worker_pid, Process::WNOHANG)
94
+ if pid
95
+ exit_code = status.exitstatus
96
+ if exit_code == RESTART_EXIT_CODE
97
+ Octo::Logger.info("[Master] Worker requested restart (exit #{RESTART_EXIT_CODE}).")
98
+ @worker_pid = spawn_worker
99
+ @consecutive_failures = 0
100
+ elsif @shutdown_requested
101
+ break
102
+ else
103
+ @consecutive_failures += 1
104
+ if @consecutive_failures >= MAX_CONSECUTIVE_FAILURES
105
+ Octo::Logger.error("[Master] Worker failed #{MAX_CONSECUTIVE_FAILURES} times in a row, giving up.")
106
+ shutdown
107
+ break
108
+ end
109
+ delay = [0.5 * (2 ** (@consecutive_failures - 1)), 30].min # exponential backoff, max 30s
110
+ Octo::Logger.warn("[Master] Worker exited unexpectedly (exit #{exit_code}), failure #{@consecutive_failures}/#{MAX_CONSECUTIVE_FAILURES}, restarting in #{delay}s...")
111
+ sleep delay
112
+ @worker_pid = spawn_worker
113
+ end
114
+ end
115
+
116
+ sleep 0.1
117
+ end
118
+ ensure
119
+ remove_pid_file
120
+ end
121
+
122
+
123
+ # Spawn a fresh Ruby process that loads the (possibly updated) gem from disk.
124
+ # The listen socket is inherited via its file descriptor number.
125
+ def spawn_worker
126
+ env = {
127
+ "OCTO_WORKER" => "1",
128
+ "OCTO_INHERIT_FD" => @socket.fileno.to_s,
129
+ "OCTO_MASTER_PID" => Process.pid.to_s
130
+ }
131
+ # Keep the socket fd open across exec — mark it as non-CLOEXEC.
132
+ @socket.close_on_exec = false
133
+
134
+ # Reconstruct the worker command explicitly.
135
+ # We cannot rely on ARGV (Thor has already consumed it), so we rebuild
136
+ # the minimal args: `octo server --host HOST --port PORT [extra_flags]`
137
+ ruby = RbConfig.ruby
138
+ script = File.expand_path($0)
139
+ worker_argv = ["server", "--host", @host.to_s, "--port", @port.to_s] + @extra_flags
140
+
141
+ Octo::Logger.info("[Master PID=#{Process.pid}] spawn: #{ruby} #{script} #{worker_argv.join(' ')}")
142
+ Octo::Logger.info("[Master PID=#{Process.pid}] env: #{env.inspect}")
143
+
144
+ # pgroup: 0 puts worker in its own process group.
145
+ # This lets Master send TERM/KILL to the entire group (-pid) on shutdown,
146
+ # ensuring grandchildren (e.g. chrome-devtools-mcp node process) are also
147
+ # cleaned up even if the worker is force-killed before its shutdown_proc runs.
148
+ #
149
+ # NOTE on stdio: we deliberately let the worker inherit Master's fd 0/1/2
150
+ # so users see startup banner / request logs in their terminal. Protection
151
+ # against Errno::EPIPE on broken parent stdout is installed inside the
152
+ # worker itself (see cli.rb worker entry — EPIPESafeIO wrapper).
153
+ pid = spawn(env, ruby, script, *worker_argv, pgroup: 0)
154
+ Octo::Logger.info("[Master PID=#{Process.pid}] Spawned worker PID=#{pid} pgroup=#{pid}")
155
+ pid
156
+ end
157
+
158
+ # Gracefully stop the old worker (so it can persist in-memory sessions),
159
+ # wait for it to exit, then spawn a new one.
160
+ def hot_restart
161
+ old_pid = @worker_pid
162
+ Octo::Logger.info("[Master] Hot restart: stopping old worker PID=#{old_pid}...")
163
+
164
+ # TERM the old worker's process group so grandchildren (node MCP, etc.)
165
+ # also get a chance to shut down cleanly (triggering interrupt_all_agents).
166
+ begin
167
+ Process.kill("TERM", -old_pid)
168
+ deadline = Time.now + 5
169
+ loop do
170
+ pid, = Process.waitpid2(old_pid, Process::WNOHANG)
171
+ break if pid
172
+ break if Time.now > deadline
173
+ sleep 0.1
174
+ end
175
+ Process.kill("KILL", -old_pid) rescue nil # force-kill entire group if still alive
176
+ rescue Errno::ESRCH
177
+ # already gone — fine
178
+ end
179
+
180
+ # Old worker is gone; now spawn the replacement.
181
+ new_pid = spawn_worker
182
+ @worker_pid = new_pid
183
+ Octo::Logger.info("[Master] Hot restart complete. New worker PID=#{new_pid}")
184
+ end
185
+
186
+ def shutdown
187
+ Octo::Logger.info("[Master] Shutting down (worker PID=#{@worker_pid})...")
188
+ if @worker_pid
189
+ begin
190
+ # TERM the entire worker process group so grandchildren (node MCP, etc.)
191
+ # are also signalled and can clean up before we force-kill.
192
+ Process.kill("TERM", -@worker_pid)
193
+ # Wait up to 2s for worker graceful exit, then KILL the whole group
194
+ deadline = Time.now + 3
195
+ loop do
196
+ pid, = Process.waitpid2(@worker_pid, Process::WNOHANG)
197
+ break if pid
198
+ if Time.now > deadline
199
+ Octo::Logger.warn("[Master] Worker did not exit in time, sending KILL...")
200
+ Process.kill("KILL", -@worker_pid) rescue nil
201
+ break
202
+ end
203
+ sleep 0.1
204
+ end
205
+ rescue Errno::ESRCH, Errno::ECHILD
206
+ # already gone
207
+ end
208
+ end
209
+ @socket.close rescue nil
210
+ Octo::Logger.info("[Master] Exited.")
211
+ exit(0)
212
+ end
213
+
214
+ def pid_file_path
215
+ File.join(Dir.tmpdir, "octo-master-#{@port}.pid")
216
+ end
217
+
218
+ def write_pid_file
219
+ File.write(pid_file_path, Process.pid.to_s)
220
+ end
221
+
222
+ def remove_pid_file
223
+ File.delete(pid_file_path) if File.exist?(pid_file_path)
224
+ end
225
+
226
+ def port_free_within?(seconds)
227
+ deadline = Time.now + seconds
228
+ loop do
229
+ begin
230
+ TCPServer.new(@host, @port).close
231
+ return true
232
+ rescue Errno::EADDRINUSE
233
+ return false if Time.now > deadline
234
+ sleep 0.1
235
+ end
236
+ end
237
+ end
238
+
239
+ # Try to bind to preferred_port, fall back to next ports if occupied.
240
+ # Returns the bound TCPServer, or nil if all ports in range are occupied.
241
+ def bind_with_fallback(host, preferred_port, max_port:)
242
+ (preferred_port..max_port).each do |port|
243
+ begin
244
+ server = TCPServer.new(host, port)
245
+ Octo::Logger.info("[Master] Bound to port #{port}") if port != preferred_port
246
+ return server
247
+ rescue Errno::EADDRINUSE
248
+ next
249
+ end
250
+ end
251
+ nil
252
+ end
253
+
254
+ def print_banner(port_changed: false, original_port: nil)
255
+ banner = Octo::Banner.new
256
+ puts ""
257
+ puts banner.logo
258
+ puts banner.tagline
259
+ puts ""
260
+
261
+ if port_changed
262
+ puts " [!] Port #{original_port} is in use, using #{@port} instead"
263
+ puts ""
264
+ end
265
+
266
+ puts " Web UI: #{banner.highlight("http://#{@host}:#{@port}")}"
267
+ puts " Version: #{Octo::VERSION}"
268
+ puts " Press Ctrl-C to stop."
269
+ puts ""
270
+ end
271
+
272
+ # Scan all fallback port PID files to prevent duplicate masters
273
+ # when a previous instance bound to a non-default fallback port.
274
+ def kill_existing_master
275
+ max_port = (@port == 8888) ? (@port + 5) : @port
276
+ (@port..max_port).each do |port|
277
+ kill_master_on_port(port)
278
+ end
279
+ end
280
+
281
+ private def kill_master_on_port(port)
282
+ path = File.join(Dir.tmpdir, "octo-master-#{port}.pid")
283
+ return unless File.exist?(path)
284
+
285
+ pid = File.read(path).strip.to_i
286
+ if pid <= 0
287
+ File.delete(path) rescue nil
288
+ return
289
+ end
290
+
291
+ begin
292
+ Process.kill("TERM", pid)
293
+ Octo::Logger.info("[Master] Sent TERM to existing master (PID=#{pid}, port=#{port}), waiting...")
294
+
295
+ deadline = Time.now + 5
296
+ until process_dead?(pid) || Time.now > deadline
297
+ sleep 0.1
298
+ end
299
+
300
+ unless process_dead?(pid)
301
+ Octo::Logger.warn("[Master] PID=#{pid} still alive after 5s, sending KILL...")
302
+ Process.kill("KILL", pid) rescue Errno::ESRCH
303
+ end
304
+
305
+ Octo::Logger.info("[Master] Existing master PID=#{pid} (port=#{port}) stopped.")
306
+ rescue Errno::ESRCH
307
+ Octo::Logger.info("[Master] Existing master PID=#{pid} already gone.")
308
+ rescue Errno::EPERM
309
+ Octo::Logger.warn("[Master] Could not stop existing master (PID=#{pid}) — permission denied.")
310
+ ensure
311
+ File.delete(path) if File.exist?(path)
312
+ end
313
+ end
314
+
315
+ private def process_dead?(pid)
316
+ Process.kill(0, pid)
317
+ false
318
+ rescue Errno::ESRCH
319
+ true
320
+ rescue Errno::EPERM
321
+ false
322
+ end
323
+ end
324
+ end
325
+ end