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,3554 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webrick"
4
+ require "websocket"
5
+ require "socket"
6
+ require "json"
7
+ require "thread"
8
+ require "fileutils"
9
+ require "tmpdir"
10
+ require "uri"
11
+ require "securerandom"
12
+ require "timeout"
13
+ require "yaml"
14
+ require "date"
15
+ require_relative "session_registry"
16
+ require_relative "web_ui_controller"
17
+ require_relative "scheduler"
18
+
19
+ require_relative "channel"
20
+ require_relative "../banner"
21
+ require_relative "../utils/file_processor"
22
+ require_relative "../background_task_registry"
23
+
24
+ module Octo
25
+ module Server
26
+ # Lightweight UI collector used by api_session_messages to capture events
27
+ # emitted by Agent#replay_history without broadcasting over WebSocket.
28
+ # Implements the same show_* interface as WebUIController.
29
+ class HistoryCollector
30
+ def initialize(session_id, events)
31
+ @session_id = session_id
32
+ @events = events
33
+ end
34
+
35
+ def show_user_message(content, created_at: nil, files: [])
36
+ images = []
37
+ if content.is_a?(Array)
38
+ text_parts = []
39
+ content.each do |block|
40
+ next unless block.is_a?(Hash)
41
+ case block[:type]
42
+ when "text"
43
+ text_parts << block[:text]
44
+ when "image_url"
45
+ url = block.dig(:image_url, :url)
46
+ images << url if url
47
+ end
48
+ end
49
+ content = text_parts.join("\n")
50
+ end
51
+
52
+ ev = { type: "history_user_message", session_id: @session_id, content: content }
53
+ ev[:created_at] = created_at if created_at
54
+ rendered = Array(files).filter_map do |f|
55
+ url = f[:data_url] || f["data_url"]
56
+ name = f[:name] || f["name"]
57
+ path = f[:path] || f["path"]
58
+
59
+ if url
60
+ url
61
+ elsif path && File.exist?(path.to_s)
62
+ # Reconstruct data_url from the tmp file (still present on disk)
63
+ Utils::FileProcessor.image_path_to_data_url(path) rescue "expired:#{name}"
64
+ elsif name
65
+ # File badge for non-image disk files, or image whose tmp file is gone
66
+ type = f[:type] || f["type"] || ""
67
+ type.to_s == "image" ? "expired:#{name}" : "pdf:#{name}"
68
+ end
69
+ end
70
+ images.concat(rendered)
71
+ ev[:images] = images unless images.empty?
72
+ @events << ev
73
+ end
74
+
75
+ def show_assistant_message(content, files:)
76
+ return if content.nil? || content.to_s.strip.empty?
77
+
78
+ # Rewrite local image paths to /api/local-image proxy URLs for browser rendering
79
+ rewritten = Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
80
+ @events << { type: "assistant_message", session_id: @session_id, content: rewritten }
81
+ end
82
+
83
+ def show_tool_call(name, args)
84
+ args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
85
+ summary = tool_call_summary(name, args_data)
86
+ @events << { type: "tool_call", session_id: @session_id, name: name, args: args_data, summary: summary }
87
+ end
88
+
89
+ private def tool_call_summary(name, args)
90
+ class_name = name.to_s.split("_").map(&:capitalize).join
91
+ return nil unless Octo::Tools.const_defined?(class_name)
92
+
93
+ tool = Octo::Tools.const_get(class_name).new
94
+ args_sym = args.is_a?(Hash) ? args.transform_keys(&:to_sym) : {}
95
+ tool.format_call(args_sym)
96
+ rescue StandardError
97
+ nil
98
+ end
99
+
100
+ def show_tool_result(result, ui_payload: nil)
101
+ ev = { type: "tool_result", session_id: @session_id, result: result }
102
+ ev[:ui_payload] = ui_payload if ui_payload
103
+ @events << ev
104
+ end
105
+
106
+ def show_token_usage(token_data)
107
+ return unless token_data.is_a?(Hash)
108
+
109
+ @events << { type: "token_usage", session_id: @session_id }.merge(token_data)
110
+ end
111
+
112
+ # Ignore all other UI methods (progress, errors, etc.) during history replay
113
+ def method_missing(name, *args, **kwargs); end
114
+ def respond_to_missing?(name, include_private = false); true; end
115
+ end
116
+
117
+ # HttpServer runs an embedded WEBrick HTTP server with WebSocket support.
118
+ #
119
+ # Routes:
120
+ # GET /ws → WebSocket upgrade (all real-time communication)
121
+ # * /api/* → JSON REST API (sessions, tasks, schedules)
122
+ # GET /** → static files served from lib/octo/web/ directory
123
+ class HttpServer
124
+ WEB_ROOT = File.expand_path("../web", __dir__)
125
+
126
+ # Default SOUL.md written when the user skips the onboard conversation.
127
+ # A richer version is created by the Agent during the soul_setup phase.
128
+ DEFAULT_SOUL_MD = <<~MD.freeze
129
+ # Octo — Agent Soul
130
+
131
+ You are Octo, a friendly and capable AI coding assistant and technical
132
+ co-founder. You are sharp, concise, and proactive. You speak plainly and
133
+ avoid unnecessary formality. You love helping people ship great software.
134
+
135
+ ## Personality
136
+ - Warm and encouraging, but direct and honest
137
+ - Think step-by-step before acting; explain your reasoning briefly
138
+ - Prefer doing over talking — use tools, write code, ship results
139
+ - Adapt your language and tone to match the user's style
140
+
141
+ ## Strengths
142
+ - Full-stack software development (Ruby, Python, JS, and more)
143
+ - Architectural thinking and code review
144
+ - Debugging tricky problems with patience and creativity
145
+ - Breaking big goals into small, executable steps
146
+ MD
147
+
148
+ # Default SOUL.md for Chinese-language users.
149
+ DEFAULT_SOUL_MD_ZH = <<~MD.freeze
150
+ # Octo — 助手灵魂
151
+
152
+ 你是 Octo,一位友好、能干的 AI 编程助手和技术联合创始人。
153
+ 你思维敏锐、言简意赅、主动积极。你说话直接,不喜欢过度客套。
154
+ 你热爱帮助用户打造优秀的软件产品。
155
+
156
+ **重要:始终用中文回复用户。**
157
+
158
+ ## 性格特点
159
+ - 热情鼓励,但直接诚实
160
+ - 行动前先思考;简要说明你的推理过程
161
+ - 重行动而非空谈 —— 善用工具,写代码,交付结果
162
+ - 根据用户的风格调整语气和表达方式
163
+
164
+ ## 核心能力
165
+ - 全栈软件开发(Ruby、Python、JS 等)
166
+ - 架构设计与代码审查
167
+ - 耐心细致地调试复杂问题
168
+ - 将大目标拆解为可执行的小步骤
169
+ MD
170
+
171
+ def initialize(host: "127.0.0.1", port: 8888, agent_config:, client_factory:, sessions_dir: nil, socket: nil, master_pid: nil)
172
+ @host = host
173
+ @port = port
174
+ @agent_config = agent_config
175
+ @client_factory = client_factory # callable: -> { Octo::Client.new(...) }
176
+ @inherited_socket = socket # TCPServer socket passed from Master (nil = standalone mode)
177
+ @master_pid = master_pid # Master PID so we can send USR1 on upgrade/restart
178
+ # Capture the absolute path of the entry script and original ARGV at startup,
179
+ # so api_restart can re-exec the correct binary even if cwd changes later.
180
+ @restart_script = File.expand_path($0)
181
+ @restart_argv = ARGV.dup
182
+ @session_manager = Octo::SessionManager.new(sessions_dir: sessions_dir)
183
+ @registry = SessionRegistry.new(
184
+ session_manager: @session_manager,
185
+ session_restorer: method(:build_session_from_data),
186
+ agent_config: @agent_config
187
+ )
188
+ @ws_clients = {} # session_id => [WebSocketConnection, ...]
189
+ @all_ws_conns = [] # every connected WS client, regardless of session subscription
190
+ @ws_mutex = Mutex.new
191
+ # Version cache: { latest: "x.y.z", checked_at: Time }
192
+ @version_cache = nil
193
+ @version_mutex = Mutex.new
194
+ @scheduler = Scheduler.new(
195
+ session_registry: @registry,
196
+ session_builder: method(:build_session),
197
+ task_runner: method(:run_agent_task)
198
+ )
199
+ @channel_manager = Octo::Channel::ChannelManager.new(
200
+ session_registry: @registry,
201
+ session_builder: method(:build_session),
202
+ run_agent_task: method(:run_agent_task),
203
+ interrupt_session: method(:interrupt_session),
204
+ channel_config: Octo::ChannelConfig.load
205
+ )
206
+ @browser_manager = Octo::BrowserManager.instance
207
+ @skill_loader = Octo::SkillLoader.new(working_dir: nil)
208
+ # Access key authentication:
209
+ # - localhost (127.0.0.1 / ::1) is always trusted; auth is skipped entirely.
210
+ # - Any other bind address requires OCTO_ACCESS_KEY env var.
211
+ @localhost_only = local_host?(@host)
212
+ @access_key = @localhost_only ? nil : resolve_access_key
213
+ @auth_failures = {}
214
+ @auth_failures_mutex = Mutex.new
215
+ if @localhost_only
216
+ Octo::Logger.info("[HttpServer] Localhost mode — authentication disabled")
217
+ else
218
+ Octo::Logger.info("[HttpServer] Public mode — access key authentication ENABLED")
219
+ end
220
+ end
221
+
222
+ def start
223
+ # Enable console logging for the server process so log lines are visible in the terminal.
224
+ Octo::Logger.console = true
225
+
226
+ Octo::Logger.info("[HttpServer PID=#{Process.pid}] start() mode=#{@inherited_socket ? 'worker' : 'standalone'} inherited_socket=#{@inherited_socket.inspect} master_pid=#{@master_pid.inspect}")
227
+
228
+ # Expose server address and product name to all child processes (skill scripts, shell commands, etc.)
229
+ # so they can call back into the server without hardcoding the port.
230
+ ENV["OCTO_SERVER_PORT"] = @port.to_s
231
+ ENV["OCTO_SERVER_HOST"] = (@host == "0.0.0.0" ? "127.0.0.1" : @host)
232
+ ENV["OCTO_PRODUCT_NAME"] = "Octo"
233
+
234
+ # Override WEBrick's built-in signal traps via StartCallback,
235
+ # which fires after WEBrick sets its own INT/TERM handlers.
236
+ # This ensures Ctrl-C always exits immediately.
237
+ #
238
+ # When running as a worker under Master, DoNotListen: true prevents WEBrick
239
+ # from calling bind() on its own — we inject the inherited socket instead.
240
+ webrick_opts = {
241
+ BindAddress: @host,
242
+ Port: @port,
243
+ Logger: WEBrick::Log.new(File::NULL),
244
+ AccessLog: [],
245
+ StartCallback: proc { } # signal traps set below, after `server` is created
246
+ }
247
+ webrick_opts[:DoNotListen] = true if @inherited_socket
248
+ Octo::Logger.info("[HttpServer PID=#{Process.pid}] WEBrick DoNotListen=#{webrick_opts[:DoNotListen].inspect}")
249
+
250
+ server = WEBrick::HTTPServer.new(**webrick_opts)
251
+
252
+ # Override WEBrick's signal traps now that `server` is available.
253
+ # On INT/TERM: call server.shutdown (graceful), with a 1s hard-kill fallback.
254
+ # Also stop BrowserManager so the chrome-devtools-mcp node process is killed
255
+ # before this worker exits — otherwise it becomes an orphan and holds port 8888.
256
+ shutdown_once = false
257
+ shutdown_proc = proc do
258
+ next if shutdown_once
259
+ shutdown_once = true
260
+ # Persist in-flight agent sessions BEFORE starting the forced-exit
261
+ # timer, so any new messages added to @history since the last save
262
+ # are on disk before the new worker reads them after a hot restart.
263
+ interrupt_all_agents
264
+
265
+ # Detach the inherited (shared) listen socket BEFORE WEBrick.shutdown
266
+ # so that cleanup_listener does not call shutdown(SHUT_RDWR)+close on
267
+ # it — that would propagate to every process sharing the underlying
268
+ # kernel socket (Master + new worker), breaking subsequent accept()
269
+ # on Linux. macOS's BSD stack tolerates this; Linux does not.
270
+ if @inherited_socket && server.listeners.include?(@inherited_socket)
271
+ server.listeners.delete(@inherited_socket)
272
+ Octo::Logger.info("[HttpServer PID=#{Process.pid}] detached inherited socket fd=#{@inherited_socket.fileno} before shutdown")
273
+ end
274
+ t1 = Thread.new { @channel_manager.stop rescue nil }
275
+ t2 = Thread.new { Octo::BrowserManager.instance.stop rescue nil }
276
+ t1.join(1.5)
277
+ t2.join(1.5)
278
+ server.shutdown rescue nil
279
+ end
280
+ trap("INT") { shutdown_proc.call }
281
+ trap("TERM") { shutdown_proc.call }
282
+
283
+ if @inherited_socket
284
+ server.listeners << @inherited_socket
285
+ Octo::Logger.info("[HttpServer PID=#{Process.pid}] injected inherited fd=#{@inherited_socket.fileno} listeners=#{server.listeners.map(&:fileno).inspect}")
286
+ else
287
+ Octo::Logger.info("[HttpServer PID=#{Process.pid}] standalone, WEBrick listeners=#{server.listeners.map(&:fileno).inspect}")
288
+ end
289
+
290
+ # Mount API + WebSocket handler (takes priority).
291
+ # Use a custom Servlet so that DELETE/PUT/PATCH requests are not rejected
292
+ # by WEBrick's default method whitelist before reaching our dispatcher.
293
+ dispatcher = self
294
+ servlet_class = Class.new(WEBrick::HTTPServlet::AbstractServlet) do
295
+ define_method(:do_GET) { |req, res| dispatcher.send(:dispatch, req, res) }
296
+ define_method(:do_POST) { |req, res| dispatcher.send(:dispatch, req, res) }
297
+ define_method(:do_PUT) { |req, res| dispatcher.send(:dispatch, req, res) }
298
+ define_method(:do_DELETE) { |req, res| dispatcher.send(:dispatch, req, res) }
299
+ define_method(:do_PATCH) { |req, res| dispatcher.send(:dispatch, req, res) }
300
+ define_method(:do_OPTIONS) { |req, res| dispatcher.send(:dispatch, req, res) }
301
+ end
302
+ server.mount("/api", servlet_class)
303
+ server.mount("/ws", servlet_class)
304
+
305
+ # Mount static file handler for the entire web directory.
306
+ # Use mount_proc so we can inject no-cache headers on every response,
307
+ # preventing stale JS/CSS from being served after a gem update.
308
+ #
309
+ file_handler = WEBrick::HTTPServlet::FileHandler.new(server, WEB_ROOT,
310
+ FancyIndexing: false)
311
+
312
+ server.mount_proc("/") do |req, res|
313
+ file_handler.service(req, res)
314
+ res["Cache-Control"] = "no-store"
315
+ res["Pragma"] = "no-cache"
316
+ end
317
+
318
+ # Auto-create a default session on startup
319
+ create_default_session
320
+
321
+ # Start the background scheduler
322
+ @scheduler.start
323
+ puts " Scheduler: #{@scheduler.schedules.size} task(s) loaded"
324
+
325
+ # Start IM channel adapters (non-blocking — each platform runs in its own thread)
326
+ @channel_manager.start
327
+
328
+ # Start browser MCP daemon if browser.yml is configured (non-blocking)
329
+ @browser_manager.start
330
+
331
+ server.start
332
+ end
333
+
334
+
335
+ # ── Router ────────────────────────────────────────────────────────────────
336
+
337
+ def dispatch(req, res)
338
+ path = req.path
339
+ method = req.request_method
340
+
341
+ # Access key guard (skip for WebSocket upgrades)
342
+ return unless check_access_key(req, res)
343
+
344
+ # WebSocket upgrade — no timeout applied (long-lived connection)
345
+ if websocket_upgrade?(req)
346
+ handle_websocket(req, res)
347
+ return
348
+ end
349
+
350
+ # Wrap all REST handlers in a timeout so a hung handler (e.g. infinite
351
+ # recursion in chunk parsing) returns a proper 503 instead of an empty 200.
352
+ timeout_sec = if path == "/api/tool/browser"
353
+ 30
354
+ else
355
+ 10
356
+ end
357
+ Timeout.timeout(timeout_sec) do
358
+ _dispatch_rest(req, res)
359
+ end
360
+ rescue Timeout::Error
361
+ Octo::Logger.warn("[HTTP 503] #{method} #{path} timed out after #{timeout_sec}s")
362
+ json_response(res, 503, { error: "Request timed out" })
363
+ rescue => e
364
+ Octo::Logger.warn("[HTTP 500] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
365
+ json_response(res, 500, { error: e.message })
366
+ end
367
+
368
+ def _dispatch_rest(req, res)
369
+ path = req.path
370
+ method = req.request_method
371
+
372
+ case [method, path]
373
+ when ["GET", "/api/sessions"] then api_list_sessions(req, res)
374
+ when ["POST", "/api/sessions"] then api_create_session(req, res)
375
+ when ["GET", "/api/cron-tasks"] then api_list_cron_tasks(res)
376
+ when ["POST", "/api/cron-tasks"] then api_create_cron_task(req, res)
377
+ when ["GET", "/api/skills"] then api_list_skills(res)
378
+ when ["GET", "/api/config"] then api_get_config(res)
379
+ when ["POST", "/api/config/models"] then api_add_model(req, res)
380
+ when ["POST", "/api/config/test"] then api_test_config(req, res)
381
+ when ["GET", "/api/providers"] then api_list_providers(res)
382
+ when ["GET", "/api/onboard/status"] then api_onboard_status(res)
383
+ when ["GET", "/api/browser/status"] then api_browser_status(res)
384
+ when ["POST", "/api/browser/configure"] then api_browser_configure(req, res)
385
+ when ["POST", "/api/browser/reload"] then api_browser_reload(res)
386
+ when ["POST", "/api/browser/toggle"] then api_browser_toggle(res)
387
+ when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
388
+ when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
389
+
390
+ when ["GET", "/api/trash"] then api_trash(req, res)
391
+ when ["POST", "/api/trash/restore"] then api_trash_restore(req, res)
392
+ when ["DELETE", "/api/trash"] then api_trash_delete(req, res)
393
+ when ["GET", "/api/profile"] then api_profile_get(res)
394
+ when ["PUT", "/api/profile"] then api_profile_put(req, res)
395
+ when ["GET", "/api/memories"] then api_memories_list(res)
396
+ when ["POST", "/api/memories"] then api_memories_create(req, res)
397
+ when ["GET", "/api/channels"] then api_list_channels(res)
398
+ when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
399
+ when ["POST", "/api/upload"] then api_upload_file(req, res)
400
+ when ["POST", "/api/file-action"] then api_file_action(req, res)
401
+ when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
402
+ when ["GET", "/api/version"] then api_get_version(res)
403
+ when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
404
+ when ["POST", "/api/restart"] then api_restart(req, res)
405
+
406
+ when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
407
+ when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
408
+ else
409
+ if method == "POST" && path.match?(%r{^/api/channels/[^/]+/send$})
410
+ platform = path.sub("/api/channels/", "").sub("/send", "")
411
+ api_send_channel_message(platform, req, res)
412
+ elsif method == "GET" && path.match?(%r{^/api/channels/[^/]+/users$})
413
+ platform = path.sub("/api/channels/", "").sub("/users", "")
414
+ api_list_channel_users(platform, res)
415
+ elsif method == "POST" && path.match?(%r{^/api/channels/[^/]+/test$})
416
+ platform = path.sub("/api/channels/", "").sub("/test", "")
417
+ api_test_channel(platform, req, res)
418
+ elsif method == "PATCH" && path.match?(%r{^/api/channels/[^/]+/enabled$})
419
+ platform = path.sub("/api/channels/", "").sub("/enabled", "")
420
+ api_toggle_channel(platform, req, res)
421
+ elsif method == "POST" && path.start_with?("/api/channels/")
422
+ platform = path.sub("/api/channels/", "")
423
+ api_save_channel(platform, req, res)
424
+ elsif method == "DELETE" && path.start_with?("/api/channels/")
425
+ platform = path.sub("/api/channels/", "")
426
+ api_delete_channel(platform, res)
427
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
428
+ session_id = path.sub("/api/sessions/", "").sub("/skills", "")
429
+ api_session_skills(session_id, res)
430
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/export$})
431
+ session_id = path.sub("/api/sessions/", "").sub("/export", "")
432
+ api_export_session(session_id, res)
433
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/messages$})
434
+ session_id = path.sub("/api/sessions/", "").sub("/messages", "")
435
+ api_session_messages(session_id, req, res)
436
+ elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+$})
437
+ session_id = path.sub("/api/sessions/", "")
438
+ api_rename_session(session_id, req, res)
439
+ elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/model$})
440
+ session_id = path.sub("/api/sessions/", "").sub("/model", "")
441
+ api_switch_session_model(session_id, req, res)
442
+ elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/reasoning_effort$})
443
+ session_id = path.sub("/api/sessions/", "").sub("/reasoning_effort", "")
444
+ api_switch_session_reasoning_effort(session_id, req, res)
445
+ elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/benchmark$})
446
+ session_id = path.sub("/api/sessions/", "").sub("/benchmark", "")
447
+ api_benchmark_session_models(session_id, req, res)
448
+ elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/working_dir$})
449
+ session_id = path.sub("/api/sessions/", "").sub("/working_dir", "")
450
+ api_change_session_working_dir(session_id, req, res)
451
+ elsif method == "DELETE" && path.start_with?("/api/sessions/")
452
+ session_id = path.sub("/api/sessions/", "")
453
+ api_delete_session(session_id, res)
454
+ elsif method == "POST" && path.match?(%r{^/api/config/models/[^/]+/default$})
455
+ id = path.sub("/api/config/models/", "").sub("/default", "")
456
+ api_set_default_model(id, res)
457
+ elsif method == "PATCH" && path.match?(%r{^/api/config/models/[^/]+$})
458
+ id = path.sub("/api/config/models/", "")
459
+ api_update_model(id, req, res)
460
+ elsif method == "DELETE" && path.match?(%r{^/api/config/models/[^/]+$})
461
+ id = path.sub("/api/config/models/", "")
462
+ api_delete_model(id, res)
463
+ elsif method == "POST" && path.match?(%r{^/api/cron-tasks/[^/]+/run$})
464
+ name = URI.decode_www_form_component(path.sub("/api/cron-tasks/", "").sub("/run", ""))
465
+ api_run_cron_task(name, res)
466
+ elsif method == "PATCH" && path.match?(%r{^/api/cron-tasks/[^/]+$})
467
+ name = URI.decode_www_form_component(path.sub("/api/cron-tasks/", ""))
468
+ api_update_cron_task(name, req, res)
469
+ elsif method == "DELETE" && path.match?(%r{^/api/cron-tasks/[^/]+$})
470
+ name = URI.decode_www_form_component(path.sub("/api/cron-tasks/", ""))
471
+ api_delete_cron_task(name, res)
472
+ elsif method == "PATCH" && path.match?(%r{^/api/skills/[^/]+/toggle$})
473
+ name = URI.decode_www_form_component(path.sub("/api/skills/", "").sub("/toggle", ""))
474
+ api_toggle_skill(name, req, res)
475
+
476
+ elsif method == "GET" && path.match?(%r{^/api/memories/[^/]+$})
477
+ filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
478
+ api_memories_get(filename, res)
479
+ elsif method == "PUT" && path.match?(%r{^/api/memories/[^/]+$})
480
+ filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
481
+ api_memories_update(filename, req, res)
482
+ elsif method == "DELETE" && path.match?(%r{^/api/memories/[^/]+$})
483
+ filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
484
+ api_memories_delete(filename, res)
485
+ else
486
+ not_found(res)
487
+ end
488
+ end
489
+ end
490
+
491
+ # ── REST API ──────────────────────────────────────────────────────────────
492
+
493
+ def api_list_sessions(req, res)
494
+ query = URI.decode_www_form(req.query_string.to_s).to_h
495
+ limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 50].min
496
+ before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
497
+ q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
498
+ date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
499
+ type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
500
+ # Backward-compat: ?source=<x> and ?profile=coding → type
501
+ type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
502
+ type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
503
+
504
+ # Fetch one extra NON-PINNED row to detect has_more without a separate count query.
505
+ # `registry.list` always returns ALL matching pinned rows first (on the
506
+ # first page; `before` == nil), followed by non-pinned rows up to `limit+1`.
507
+ # So has_more is determined by whether the non-pinned section overflowed.
508
+ sessions = @registry.list(limit: limit + 1, before: before, q: q, date: date, type: type)
509
+
510
+ # Split pinned vs non-pinned to apply has_more only to the non-pinned tail.
511
+ pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
512
+ has_more = non_pinned_part.size > limit
513
+ non_pinned_part = non_pinned_part.first(limit)
514
+ sessions = pinned_part + non_pinned_part
515
+
516
+ json_response(res, 200, { sessions: sessions, has_more: has_more, cron_count: @registry.cron_count })
517
+ end
518
+
519
+ def api_create_session(req, res)
520
+ body = parse_json_body(req)
521
+ name = body["name"]
522
+ return json_response(res, 400, { error: "name is required" }) if name.nil? || name.strip.empty?
523
+
524
+ # Optional agent_profile; defaults to "general" if omitted or invalid
525
+ profile = body["agent_profile"].to_s.strip
526
+ profile = "general" if profile.empty?
527
+
528
+ # Optional source; defaults to :manual. Accept "system" for skill-launched sessions
529
+ # (e.g. /onboard, /browser-setup, /channel-manager).
530
+ raw_source = body["source"].to_s.strip
531
+ source = %w[manual cron channel setup].include?(raw_source) ? raw_source.to_sym : :manual
532
+
533
+ raw_dir = body["working_dir"].to_s.strip
534
+ working_dir = raw_dir.empty? ? default_working_dir : File.expand_path(raw_dir)
535
+
536
+ # Optional model override — passed as a stable model id (matches the
537
+ # id returned by GET /api/config). Name-based override was removed:
538
+ # a bare model name can't disambiguate between entries from different
539
+ # providers (e.g. "deepseek-v4-pro" on DeepSeek direct vs its dsk-*
540
+ # alias on Octo/Bedrock), and mutating current_model["model"]
541
+ # kept the wrong api_key / base_url / api format, producing
542
+ # "unknown model" errors at the provider.
543
+ model_id_override = body["model_id"].to_s.strip
544
+ model_id_override = nil if model_id_override.empty?
545
+
546
+ if model_id_override && !@agent_config.models.any? { |m| m["id"] == model_id_override }
547
+ return json_response(res, 400, { error: "Model not found in configuration" })
548
+ end
549
+
550
+ # Create working directory if it doesn't exist
551
+ # Allow multiple sessions in the same directory
552
+ FileUtils.mkdir_p(working_dir)
553
+
554
+ session_id = build_session(name: name, working_dir: working_dir, profile: profile, source: source, model_id: model_id_override)
555
+ broadcast_session_update(session_id)
556
+ json_response(res, 201, { session: @registry.session_summary(session_id) })
557
+ end
558
+
559
+ # Auto-restore persisted sessions (or create a fresh default) when the server starts.
560
+ # Skipped when no API key is configured (onboard flow will handle it).
561
+ #
562
+ # Strategy: load the most recent sessions from ~/.octo/sessions/ for the
563
+ # current working directory and restore them into @registry so their IDs are
564
+ # stable across restarts (frontend hash stays valid). If no persisted sessions
565
+ # exist, fall back to creating a new default session.
566
+ def create_default_session
567
+ return unless @agent_config.models_configured?
568
+
569
+ # Restore up to 5 sessions per source type from disk into the registry.
570
+ @registry.restore_from_disk(n: 5)
571
+
572
+ # Recover any orphaned .jsonl incremental logs from crashed sessions
573
+ # and merge them back into their parent session .json files.
574
+ recovered = @session_manager.recover_jsonl_sessions
575
+ Octo::Logger.info("http_server.recovered_jsonl_sessions", count: recovered) if recovered > 0
576
+
577
+ # If nothing was restored (no persisted sessions), create a fresh default.
578
+ unless @registry.list(limit: 1).any?
579
+ working_dir = default_working_dir
580
+ FileUtils.mkdir_p(working_dir) unless Dir.exist?(working_dir)
581
+ build_session(name: "Session 1", working_dir: working_dir)
582
+ end
583
+ end
584
+
585
+ # ── Onboard API ───────────────────────────────────────────────────────────
586
+
587
+ # GET /api/onboard/status
588
+ # Phase "key_setup" → no API key configured yet
589
+ # Phase "soul_setup" → key configured, but ~/.octo/agents/SOUL.md missing
590
+ # needs_onboard: false → fully set up
591
+ def api_onboard_status(res)
592
+ if !@agent_config.models_configured?
593
+ json_response(res, 200, { needs_onboard: true, phase: "key_setup" })
594
+ else
595
+ json_response(res, 200, { needs_onboard: false })
596
+ end
597
+ end
598
+
599
+ # GET /api/browser/status
600
+ # Returns real daemon liveness from BrowserManager (not just yml read).
601
+ def api_browser_status(res)
602
+ json_response(res, 200, @browser_manager.status)
603
+ end
604
+
605
+ # POST /api/browser/configure
606
+ # Called by browser-setup skill to write browser.yml and hot-reload the daemon.
607
+ # Body: { chrome_version: "146" }
608
+ def api_browser_configure(req, res)
609
+ body = JSON.parse(req.body.to_s) rescue {}
610
+ chrome_version = body["chrome_version"].to_s.strip
611
+ return json_response(res, 422, { ok: false, error: "chrome_version is required" }) if chrome_version.empty?
612
+
613
+ @browser_manager.configure(chrome_version: chrome_version)
614
+ json_response(res, 200, { ok: true })
615
+ rescue StandardError => e
616
+ json_response(res, 500, { ok: false, error: e.message })
617
+ end
618
+
619
+ # POST /api/browser/reload
620
+ # Called by browser-setup skill after writing browser.yml.
621
+ # Hot-reloads the MCP daemon with the new configuration.
622
+ def api_browser_reload(res)
623
+ @browser_manager.reload
624
+ json_response(res, 200, { ok: true })
625
+ rescue StandardError => e
626
+ json_response(res, 500, { ok: false, error: e.message })
627
+ end
628
+
629
+ # POST /api/browser/toggle
630
+ def api_browser_toggle(res)
631
+ enabled = @browser_manager.toggle
632
+ json_response(res, 200, { ok: true, enabled: enabled })
633
+ rescue StandardError => e
634
+ json_response(res, 500, { ok: false, error: e.message })
635
+ end
636
+
637
+ # POST /api/onboard/complete
638
+ # Called after key setup is done (soul_setup is optional/skipped).
639
+ # Creates the default session if none exists yet, returns it.
640
+ def api_onboard_complete(req, res)
641
+ create_default_session if @registry.list(limit: 1).empty?
642
+ first_session = @registry.list(limit: 1).first
643
+ json_response(res, 200, { ok: true, session: first_session })
644
+ end
645
+
646
+ # POST /api/onboard/skip-soul
647
+ # Writes a minimal SOUL.md so the soul_setup phase is not re-triggered
648
+ # on the next server start when the user chooses to skip the conversation.
649
+ def api_onboard_skip_soul(req, res)
650
+ body = parse_json_body(req)
651
+ lang = body["lang"].to_s.strip
652
+ soul_content = lang == "zh" ? DEFAULT_SOUL_MD_ZH : DEFAULT_SOUL_MD
653
+
654
+ agents_dir = File.expand_path("~/.octo/agents")
655
+ FileUtils.mkdir_p(agents_dir)
656
+ soul_path = File.join(agents_dir, "SOUL.md")
657
+ unless File.exist?(soul_path)
658
+ File.write(soul_path, soul_content)
659
+ end
660
+ json_response(res, 200, { ok: true })
661
+ end
662
+
663
+ # GET /api/version
664
+ # Returns current version and latest version from RubyGems (cached for 1 hour).
665
+ def api_get_version(res)
666
+ current = Octo::VERSION
667
+ latest = fetch_latest_version_cached
668
+ json_response(res, 200, {
669
+ current: current,
670
+ latest: latest,
671
+ needs_update: latest ? version_older?(current, latest) : false,
672
+ launcher: ENV["OCTO_LAUNCHER"] || "cli",
673
+ cli_command: "octo"
674
+ })
675
+ end
676
+
677
+ # POST /api/version/upgrade
678
+ # Upgrades octo in a background thread, streaming output via WebSocket broadcast.
679
+ # If the user's gem source is the official RubyGems, use `gem update`.
680
+ # Otherwise (e.g. Aliyun mirror) download the .gem from OSS CDN to bypass mirror lag.
681
+ def api_upgrade_version(req, res)
682
+ json_response(res, 202, { ok: true, message: "Upgrade started" })
683
+
684
+ Thread.new do
685
+ begin
686
+ if official_gem_source?
687
+ upgrade_via_gem_update
688
+ else
689
+ upgrade_via_oss_cdn
690
+ end
691
+ rescue StandardError => e
692
+ Octo::Logger.error("[Upgrade] Exception: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
693
+ broadcast_all(type: "upgrade_log", line: "\n✗ Error during upgrade: #{e.message}\n")
694
+ broadcast_all(type: "upgrade_complete", success: false)
695
+ end
696
+ end
697
+ end
698
+
699
+ # Returns true when the bind host is loopback-only.
700
+ private def local_host?(host)
701
+ ["127.0.0.1", "::1", "localhost"].include?(host.to_s.strip)
702
+ end
703
+
704
+ # Resolve access key from OCTO_ACCESS_KEY env var only.
705
+ private def resolve_access_key
706
+ key = ENV.fetch("OCTO_ACCESS_KEY", "").strip
707
+ key.empty? ? nil : key
708
+ end
709
+
710
+ # Extract bearer token or query param from a WEBrick request.
711
+ # Priority: Authorization: Bearer > ?access_key=
712
+ # The query string form is only used by WebSocket connections, which
713
+ # cannot set custom headers from the browser. All HTTP clients —
714
+ # including the web UI (via a fetch interceptor in auth.js) — use the
715
+ # Authorization header.
716
+ private def extract_key(req)
717
+ auth = req["Authorization"].to_s.strip
718
+ if auth.start_with?("Bearer ")
719
+ token = auth.sub(/\ABearer\s+/i, "").strip
720
+ return token unless token.empty?
721
+ end
722
+
723
+ query = URI.decode_www_form(req.query_string.to_s).to_h
724
+ token = query["access_key"].to_s.strip
725
+ return token unless token.empty?
726
+
727
+ req.cookies.each do |c|
728
+ return c.value if c.name == "octo_access_key" && !c.value.to_s.empty?
729
+ end
730
+
731
+ nil
732
+ end
733
+
734
+ # Constant-time string comparison to prevent timing attacks.
735
+ private def secure_compare(a, b)
736
+ return false unless a.bytesize == b.bytesize
737
+
738
+ result = 0
739
+ a.unpack("C*").zip(b.unpack("C*")) { |x, y| result |= x ^ y }
740
+ result.zero?
741
+ end
742
+
743
+ # Returns true if the request is authenticated or auth is disabled.
744
+ # Writes 401/429 to res and returns false on failure.
745
+ private def check_access_key(req, res)
746
+ # Localhost binding — always trusted, no auth needed.
747
+ return true if @localhost_only
748
+ return true unless @access_key # public but no key configured (cli already blocked this)
749
+
750
+ ip = req.peeraddr.last rescue "unknown"
751
+ candidate = extract_key(req)
752
+
753
+ # Lazily evict expired lockout entries to prevent unbounded memory growth.
754
+ @auth_failures_mutex.synchronize do
755
+ @auth_failures.delete_if { |_, e| Time.now >= e[:reset_at] }
756
+ end
757
+
758
+ # No key provided — reject immediately without counting as a failure.
759
+ if candidate.nil? || candidate.empty?
760
+ json_response(res, 401, {
761
+ error: "Unauthorized: access key required",
762
+ hint: "Pass key via 'Authorization: Bearer <key>' header or '?access_key=<key>'"
763
+ })
764
+ return false
765
+ end
766
+
767
+ # Check if IP is currently locked out.
768
+ blocked, wait_secs = @auth_failures_mutex.synchronize do
769
+ entry = @auth_failures[ip]
770
+ if entry && entry[:count] >= 10 && Time.now < entry[:reset_at]
771
+ [true, (entry[:reset_at] - Time.now).ceil]
772
+ else
773
+ [false, 0]
774
+ end
775
+ end
776
+
777
+ if blocked
778
+ json_response(res, 429, { error: "Too many failed attempts", retry_after: wait_secs })
779
+ return false
780
+ end
781
+
782
+ if secure_compare(@access_key, candidate)
783
+ @auth_failures_mutex.synchronize { @auth_failures.delete(ip) }
784
+ return true
785
+ end
786
+
787
+ @auth_failures_mutex.synchronize do
788
+ entry = @auth_failures[ip] ||= { count: 0, reset_at: Time.now + 300 }
789
+ entry[:count] += 1
790
+ Octo::Logger.warn("[Auth] Failed attempt #{entry[:count]}/10 from #{ip}")
791
+ end
792
+
793
+ json_response(res, 401, {
794
+ error: "Unauthorized: invalid access key",
795
+ hint: "Pass key via 'Authorization: Bearer <key>' header or '?access_key=<key>'"
796
+ })
797
+ false
798
+ end
799
+
800
+ # Returns true when the configured gem source is the official RubyGems.org.
801
+ # Raises on error — caller's rescue will handle it.
802
+ private def official_gem_source?
803
+ output, exit_code = run_shell("gem sources -l")
804
+ raise "gem sources -l failed (exit #{exit_code}): #{output}" unless exit_code&.zero?
805
+
806
+ Octo::Logger.info("[Upgrade] gem sources: #{output.strip}")
807
+ output.include?("https://rubygems.org") &&
808
+ !output.match?(%r{mirrors\.|aliyun|tuna|ustc|ruby-china})
809
+ end
810
+
811
+ # Upgrade via `gem update octo --no-document` (official RubyGems source).
812
+ private def upgrade_via_gem_update
813
+ cmd = "gem update octo --no-document"
814
+ Octo::Logger.info("[Upgrade] Official source — running: #{cmd}")
815
+ broadcast_all(type: "upgrade_log", line: "Starting upgrade: #{cmd}\n")
816
+
817
+ output, exit_code = run_shell(cmd, timeout: 600)
818
+
819
+ Octo::Logger.info("[Upgrade] exit_code=#{exit_code}")
820
+ Octo::Logger.info("[Upgrade] output=#{output.slice(0, 1000)}")
821
+
822
+ success = exit_code&.zero? || false
823
+
824
+ broadcast_all(type: "upgrade_log", line: output)
825
+ finish_upgrade(success, fallback_hint: "gem update octo")
826
+ end
827
+
828
+ # Upgrade via OSS CDN: fetch latest.txt → download .gem → gem install (bypasses mirror lag).
829
+ private def upgrade_via_oss_cdn
830
+ require "net/http"
831
+ require "uri"
832
+
833
+ oss_base = "https://oss.1024code.com/octo"
834
+ latest_url = "#{oss_base}/latest.txt"
835
+
836
+ Octo::Logger.info("[Upgrade] Non-official source — fetching latest version from OSS CDN")
837
+ broadcast_all(type: "upgrade_log", line: "Non-official gem source detected — fetching latest version from OSS CDN...\n")
838
+
839
+ # Step 1: fetch latest version from OSS
840
+ latest_version = fetch_oss_latest_version(latest_url)
841
+ unless latest_version
842
+ broadcast_all(type: "upgrade_log", line: "✗ Failed to fetch latest version from OSS CDN\n")
843
+ broadcast_all(type: "upgrade_complete", success: false)
844
+ return
845
+ end
846
+
847
+ broadcast_all(type: "upgrade_log", line: "Latest version: #{latest_version}\n")
848
+
849
+ # Already up to date?
850
+ unless version_older?(Octo::VERSION, latest_version)
851
+ broadcast_all(type: "upgrade_log", line: "✓ Already at latest version (#{Octo::VERSION})\n")
852
+ broadcast_all(type: "upgrade_complete", success: true)
853
+ return
854
+ end
855
+
856
+ # Step 2: download .gem file from OSS
857
+ gem_url = "#{oss_base}/octo-#{latest_version}.gem"
858
+ gem_file = "/tmp/octo-#{latest_version}.gem"
859
+ broadcast_all(type: "upgrade_log", line: "Downloading octo-#{latest_version}.gem from OSS...\n")
860
+ Octo::Logger.info("[Upgrade] Downloading #{gem_url}")
861
+
862
+ shell_cmd = "curl -fsSL '#{gem_url}' -o '#{gem_file}'"
863
+ dl_out, dl_exit = run_shell(shell_cmd, timeout: 300)
864
+ unless dl_exit&.zero?
865
+ broadcast_all(type: "upgrade_log", line: "✗ Download failed: #{dl_out}\n")
866
+ broadcast_all(type: "upgrade_complete", success: false)
867
+ return
868
+ end
869
+
870
+ # Step 3: install the downloaded .gem (dependencies resolved via configured gem source)
871
+ cmd = "gem install '#{gem_file}' --no-document"
872
+ broadcast_all(type: "upgrade_log", line: "Installing...\n")
873
+ Octo::Logger.info("[Upgrade] Running: #{cmd}")
874
+
875
+ output, exit_code = run_shell(cmd, timeout: 600)
876
+ success = exit_code&.zero? || false
877
+
878
+ broadcast_all(type: "upgrade_log", line: output)
879
+ finish_upgrade(success, fallback_hint: "gem install #{gem_url}")
880
+ ensure
881
+ File.delete(gem_file) if gem_file && File.exist?(gem_file) rescue nil
882
+ end
883
+
884
+ # Fetch the latest version string from OSS latest.txt.
885
+ private def fetch_oss_latest_version(url)
886
+ require "net/http"
887
+ uri = URI(url)
888
+ http = Net::HTTP.new(uri.host, uri.port)
889
+ http.use_ssl = uri.scheme == "https"
890
+ http.open_timeout = 10
891
+ http.read_timeout = 10
892
+ res = http.get(uri.request_uri)
893
+ return nil unless res.is_a?(Net::HTTPSuccess)
894
+
895
+ version = res.body.to_s.strip
896
+ version.empty? ? nil : version
897
+ rescue StandardError => e
898
+ Octo::Logger.warn("[Upgrade] fetch_oss_latest_version error: #{e.message}")
899
+ nil
900
+ end
901
+
902
+ # Broadcast final upgrade result with appropriate log message.
903
+ #
904
+ # Defensive post-check: if `run_shell` reported failure but the gem
905
+ # is in fact now installed at the latest version, reverse the verdict.
906
+ # This guards against false negatives from the Terminal idle-poll
907
+ # mechanism (see: 0.9.36 upgrade failure bug).
908
+ private def finish_upgrade(success, fallback_hint: "gem update octo")
909
+ if !success && gem_actually_upgraded?
910
+ Octo::Logger.warn("[Upgrade] run_shell reported failure, but installed version matches latest — treating as success.")
911
+ broadcast_all(type: "upgrade_log", line: "\n(Verified: the new version is installed — reclassifying as success.)\n")
912
+ success = true
913
+ end
914
+
915
+ if success
916
+ Octo::Logger.info("[Upgrade] Success!")
917
+ broadcast_all(type: "upgrade_log", line: "\n✓ Upgrade successful! Please restart the server to apply the new version.\n")
918
+ broadcast_all(type: "upgrade_complete", success: true)
919
+ else
920
+ Octo::Logger.warn("[Upgrade] Failed.")
921
+ broadcast_all(type: "upgrade_log", line: "\n✗ Upgrade failed. Please try manually: #{fallback_hint}\n")
922
+ broadcast_all(type: "upgrade_complete", success: false)
923
+ end
924
+ end
925
+
926
+ # Check whether the latest published version of octo is already
927
+ # installed locally. Used as a post-upgrade sanity check so a flaky
928
+ # run_shell result doesn't mask a successful install.
929
+ # Returns false on any error (conservative — don't fabricate success).
930
+ private def gem_actually_upgraded?
931
+ latest = fetch_latest_version_from_rubygems_api
932
+ return false unless latest
933
+
934
+ out, exit_code = run_shell("gem list octo -i -v #{latest}", timeout: 30)
935
+ return false unless exit_code&.zero?
936
+ out.to_s.strip.downcase == "true"
937
+ rescue StandardError => e
938
+ Octo::Logger.warn("[Upgrade] gem_actually_upgraded? error: #{e.message}")
939
+ false
940
+ end
941
+
942
+ # POST /api/restart
943
+ # Re-execs the current process so the newly installed gem version is loaded.
944
+ # Uses the absolute script path captured at startup to avoid relative-path issues.
945
+ # Responds 200 first, then waits briefly for WEBrick to flush the response before exec.
946
+ def api_restart(req, res)
947
+ json_response(res, 200, { ok: true, message: "Restarting…" })
948
+
949
+ Thread.new do
950
+ sleep 0.5 # Let WEBrick flush the HTTP response
951
+
952
+ if @master_pid
953
+ # Worker mode: tell master to hot-restart. Master will TERM us after the
954
+ # new worker boots; our trap("TERM") then runs shutdown_proc, which detaches
955
+ # the inherited listen socket before WEBrick shutdown. Do NOT exit(0) here —
956
+ # that bypasses trap handlers and lets the OS close(fd) on a socket shared
957
+ # with master+new worker, corrupting the listener on Linux/WSL.
958
+ Octo::Logger.info("[Restart] Sending USR1 to master (PID=#{@master_pid})")
959
+ begin
960
+ Process.kill("USR1", @master_pid)
961
+ rescue Errno::ESRCH
962
+ Octo::Logger.warn("[Restart] Master PID=#{@master_pid} not found, falling back to exec.")
963
+ standalone_exec_restart
964
+ end
965
+ else
966
+ # Standalone mode (no master): fall back to the original exec approach.
967
+ standalone_exec_restart
968
+ end
969
+ end
970
+ end
971
+
972
+ # Re-exec the current process via a login shell (rbenv/mise shim compatible).
973
+ private def standalone_exec_restart
974
+ script = @restart_script
975
+ argv = @restart_argv
976
+ shell = ENV["SHELL"].to_s
977
+ shell = "/bin/bash" if shell.empty?
978
+ cmd_parts = [Shellwords.escape(script), *argv.map { |a| Shellwords.escape(a) }]
979
+ cmd_string = cmd_parts.join(" ")
980
+ Octo::Logger.info("[Restart] exec: #{shell} -l -c #{cmd_string}")
981
+ exec(shell, "-l", "-c", cmd_string)
982
+ end
983
+
984
+ # Fetch the latest gem version using `gem list -r`, with a 1-hour in-memory cache.
985
+ # Uses Terminal (PTY + login shell) so rbenv/mise shims and gem mirrors work correctly.
986
+ private def fetch_latest_version_cached
987
+ @version_mutex.synchronize do
988
+ now = Time.now
989
+ if @version_cache && (now - @version_cache[:checked_at]) < 3600
990
+ return @version_cache[:latest]
991
+ end
992
+ end
993
+
994
+ # Fetch outside the mutex to avoid blocking other requests
995
+ latest = fetch_latest_version_from_gem
996
+
997
+ @version_mutex.synchronize do
998
+ @version_cache = { latest: latest, checked_at: Time.now }
999
+ end
1000
+
1001
+ latest
1002
+ end
1003
+
1004
+ # Query the latest octo version.
1005
+ # Strategy: try RubyGems official REST API first (most accurate, not affected by mirror lag),
1006
+ # then fall back to `gem list -r` (respects user's configured gem source).
1007
+ # Uses Terminal (PTY + login shell) so rbenv/mise shims and gem mirrors work correctly.
1008
+ private def fetch_latest_version_from_gem
1009
+ fetch_latest_version_from_rubygems_api || fetch_latest_version_from_gem_command
1010
+ end
1011
+
1012
+ # Try RubyGems official REST API — fast and always up-to-date.
1013
+ # Returns nil if the request fails or times out.
1014
+ private def fetch_latest_version_from_rubygems_api
1015
+ require "net/http"
1016
+ require "json"
1017
+
1018
+ uri = URI("https://rubygems.org/api/v1/gems/octo.json")
1019
+ http = Net::HTTP.new(uri.host, uri.port)
1020
+ http.use_ssl = true
1021
+ http.open_timeout = 5
1022
+ http.read_timeout = 8
1023
+
1024
+ res = http.get(uri.request_uri)
1025
+ return nil unless res.is_a?(Net::HTTPSuccess)
1026
+
1027
+ data = JSON.parse(res.body)
1028
+ data["version"].to_s.strip.then { |v| v.empty? ? nil : v }
1029
+ rescue StandardError
1030
+ nil
1031
+ end
1032
+
1033
+ # Fall back to `gem list -r octo` via login shell.
1034
+ # Respects the user's configured gem source (rbenv/mise mirrors, etc.).
1035
+ # Output format: "octo (0.9.0)"
1036
+ private def fetch_latest_version_from_gem_command
1037
+ out, exit_code = run_shell("gem list -r octo", timeout: 30)
1038
+ return nil unless exit_code&.zero?
1039
+
1040
+ match = out.match(/^octo\s+\(([^)]+)\)/)
1041
+ match ? match[1].strip : nil
1042
+ rescue StandardError
1043
+ nil
1044
+ end
1045
+
1046
+ # Returns true if version string `a` is strictly older than `b`.
1047
+ private def version_older?(a, b)
1048
+ Gem::Version.new(a) < Gem::Version.new(b)
1049
+ rescue ArgumentError
1050
+ false
1051
+ end
1052
+
1053
+ # Run a shell command via the unified Terminal tool and return
1054
+ # [output, exit_code] — drop-in replacement for Open3.capture2e.
1055
+ #
1056
+ # Delegates to Terminal.run_sync which handles the idle-poll loop
1057
+ # internally (see its docs for why that's needed — this wrapper used
1058
+ # to re-implement it wrong and caused the 0.9.36 upgrade bug).
1059
+ private def run_shell(command, timeout: 120)
1060
+ Octo::Tools::Terminal.run_sync(command, timeout: timeout)
1061
+ end
1062
+
1063
+ # ── Channel API ───────────────────────────────────────────────────────────
1064
+
1065
+ # GET /api/channels
1066
+ # Returns current config and running status for all supported platforms.
1067
+ # POST /api/tool/browser
1068
+ # Executes a browser tool action via the shared BrowserManager daemon.
1069
+ # Used by skill scripts (e.g. feishu_setup.rb) to reuse the server's
1070
+ # existing Chrome connection without spawning a second MCP daemon.
1071
+ #
1072
+ # Request body: JSON with same params as the browser tool
1073
+ # { "action": "snapshot", "interactive": true, ... }
1074
+ #
1075
+ # Response: JSON result from the browser tool
1076
+ def api_tool_browser(req, res)
1077
+ params = parse_json_body(req)
1078
+ action = params["action"]
1079
+ return json_response(res, 400, { error: "action is required" }) if action.nil? || action.empty?
1080
+
1081
+ tool = Octo::Tools::Browser.new
1082
+ result = tool.execute(**params.transform_keys(&:to_sym))
1083
+
1084
+ json_response(res, 200, result)
1085
+ rescue StandardError => e
1086
+ json_response(res, 500, { error: e.message })
1087
+ end
1088
+
1089
+ def api_list_channels(res)
1090
+ config = Octo::ChannelConfig.load
1091
+ running = @channel_manager.running_platforms
1092
+
1093
+ platforms = Octo::Channel::Adapters.all.map do |klass|
1094
+ platform = klass.platform_id
1095
+ raw = config.instance_variable_get(:@channels)[platform.to_s] || {}
1096
+ {
1097
+ platform: platform,
1098
+ enabled: !!raw["enabled"],
1099
+ running: running.include?(platform),
1100
+ has_config: !config.platform_config(platform).nil?
1101
+ }.merge(platform_safe_fields(platform, config))
1102
+ end
1103
+
1104
+ json_response(res, 200, { channels: platforms })
1105
+ end
1106
+
1107
+ # POST /api/channels/:platform/send
1108
+ # Proactively send a message to a user via the given IM platform.
1109
+ #
1110
+ # Body:
1111
+ # { "message": "hello", # required
1112
+ # "user_id": "some_user_id" } # optional — defaults to most-recently active user
1113
+ #
1114
+ # Response:
1115
+ # 200 { ok: true }
1116
+ # 400 { ok: false, error: "..." } — missing/invalid params or platform not running
1117
+ # 503 { ok: false, error: "..." } — no known users (nobody has messaged the bot yet)
1118
+ #
1119
+ # Constraints:
1120
+ # - The platform adapter must be running (channel must be enabled + connected).
1121
+ # - For Weixin (iLink protocol), a context_token is required per message. This is
1122
+ # automatically looked up from the in-memory cache populated by inbound messages.
1123
+ # If no token exists for the target user (i.e. the user has never messaged the bot
1124
+ # in this server session), the message cannot be delivered.
1125
+ def api_send_channel_message(platform, req, res)
1126
+ platform = platform.to_sym
1127
+ body = parse_json_body(req)
1128
+ message = body["message"].to_s.strip
1129
+
1130
+ if message.empty?
1131
+ json_response(res, 400, { ok: false, error: "message is required" })
1132
+ return
1133
+ end
1134
+
1135
+ # Resolve target user_id
1136
+ user_id = body["user_id"].to_s.strip
1137
+ if user_id.empty?
1138
+ # Default to the most-recently active user for this platform
1139
+ known = @channel_manager.known_users(platform)
1140
+ if known.empty?
1141
+ json_response(res, 503, {
1142
+ ok: false,
1143
+ error: "No known users for :#{platform}. The user must send a message to the bot first."
1144
+ })
1145
+ return
1146
+ end
1147
+ user_id = known.last
1148
+ end
1149
+
1150
+ result = @channel_manager.send_to_user(platform, user_id, message)
1151
+ if result.nil?
1152
+ json_response(res, 400, {
1153
+ ok: false,
1154
+ error: "Failed to send message. The :#{platform} adapter may not be running, or no context_token is available for user #{user_id}."
1155
+ })
1156
+ else
1157
+ json_response(res, 200, { ok: true, platform: platform, user_id: user_id })
1158
+ end
1159
+ rescue StandardError => e
1160
+ json_response(res, 500, { ok: false, error: e.message })
1161
+ end
1162
+
1163
+ # GET /api/channels/:platform/users
1164
+ # Returns the list of known user IDs for the given platform.
1165
+ # These are users who have sent at least one message to the bot in this server session.
1166
+ #
1167
+ # For Weixin: returns users with a cached context_token (required for proactive messaging).
1168
+ # For Feishu / WeCom: returns user IDs extracted from channel session bindings.
1169
+ #
1170
+ # Response:
1171
+ # 200 { users: ["uid1", "uid2", ...] }
1172
+ def api_list_channel_users(platform, res)
1173
+ platform = platform.to_sym
1174
+ users = @channel_manager.known_users(platform)
1175
+ json_response(res, 200, { platform: platform, users: users })
1176
+ rescue StandardError => e
1177
+ json_response(res, 500, { ok: false, error: e.message })
1178
+ end
1179
+
1180
+ # POST /api/upload
1181
+ # Accepts a multipart/form-data file upload (field name: "file").
1182
+ # Runs the file through FileProcessor: saves original + generates structured
1183
+ # preview (Markdown) for Office/ZIP files so the agent can read them directly.
1184
+ def api_upload_file(req, res)
1185
+ upload = parse_multipart_upload(req, "file")
1186
+ unless upload
1187
+ json_response(res, 400, { ok: false, error: "No file field found in multipart body" })
1188
+ return
1189
+ end
1190
+
1191
+ saved = Octo::Utils::FileProcessor.save(
1192
+ body: upload[:data],
1193
+ filename: upload[:filename].to_s
1194
+ )
1195
+
1196
+ json_response(res, 200, { ok: true, name: saved[:name], path: saved[:path] })
1197
+ rescue => e
1198
+ json_response(res, 500, { ok: false, error: e.message })
1199
+ end
1200
+
1201
+ # POST /api/file-action
1202
+ # Unified file action endpoint — open locally or download.
1203
+ # Body: { path: String, action: "open" | "download" }
1204
+ # open: opens the file with the OS default handler (local deployments).
1205
+ # download: returns the file as a download (remote deployments).
1206
+ def api_file_action(req, res)
1207
+ body = parse_json_body(req)
1208
+ path = body["path"]
1209
+ action = body["action"] || "open"
1210
+
1211
+ return json_response(res, 400, { error: "path is required" }) unless path && !path.empty?
1212
+
1213
+ # Expand ~ to the user's home directory (e.g. "~/Desktop/file.pdf").
1214
+ # Ruby's File.exist? does NOT automatically expand ~ — that's a shell feature.
1215
+ path = File.expand_path(path)
1216
+
1217
+ # On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
1218
+ # Convert it to the Linux-side path so File.exist? works.
1219
+ linux_path = Utils::EnvironmentDetector.win_to_linux_path(path)
1220
+
1221
+ return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
1222
+
1223
+ case action
1224
+ when "open"
1225
+ result = Utils::EnvironmentDetector.open_file(linux_path)
1226
+ return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
1227
+ json_response(res, 200, { ok: true })
1228
+ when "download"
1229
+ serve_file_download(res, linux_path)
1230
+ else
1231
+ json_response(res, 400, { error: "invalid action. Must be 'open' or 'download'" })
1232
+ end
1233
+ rescue => e
1234
+ json_response(res, 500, { ok: false, error: e.message })
1235
+ end
1236
+
1237
+ # Stream a file to the client as a download.
1238
+ # Content-Type is always application/octet-stream — the browser determines
1239
+ # file type and handling from the filename extension in Content-Disposition.
1240
+ def serve_file_download(res, path)
1241
+ filename = File.basename(path)
1242
+
1243
+ res.status = 200
1244
+ res["Content-Type"] = "application/octet-stream"
1245
+ res["Content-Disposition"] = "attachment; filename=\"#{filename}\""
1246
+ res["Content-Length"] = File.size(path).to_s
1247
+ res.body = File.binread(path)
1248
+ end
1249
+
1250
+ # GET /api/local-image?path=file:///path/to/image.png
1251
+ # GET /api/local-image?path=/path/to/image.png
1252
+ #
1253
+ # Serves a local image file with the correct Content-Type.
1254
+ # Used by the Web UI to render local images that would otherwise be blocked
1255
+ # by the browser's security policy (file:// from http:// origin).
1256
+ #
1257
+ def api_serve_local_image(req, res)
1258
+ raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
1259
+ return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
1260
+
1261
+ # Strip file:// prefix if present
1262
+ path = raw_path.sub(%r{\Afile://}, "")
1263
+ path = CGI.unescape(path)
1264
+ path = File.expand_path(path)
1265
+
1266
+ # On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
1267
+ # Convert it to the Linux-side path so File.exist? works.
1268
+ path = Utils::EnvironmentDetector.win_to_linux_path(path)
1269
+
1270
+ # Security: only serve image files
1271
+ ext = File.extname(path).downcase
1272
+ unless Utils::FileProcessor::LOCAL_IMAGE_EXTENSIONS.include?(ext)
1273
+ return json_response(res, 403, { error: "not an image file" })
1274
+ end
1275
+
1276
+ return json_response(res, 404, { error: "file not found" }) unless File.exist?(path)
1277
+
1278
+ mime = Utils::FileProcessor::MIME_TYPES[ext] || "application/octet-stream"
1279
+ res.status = 200
1280
+ res["Content-Type"] = mime
1281
+ res["Cache-Control"] = "private, max-age=3600"
1282
+ res.body = File.binread(path)
1283
+ rescue => e
1284
+ json_response(res, 500, { error: e.message })
1285
+ end
1286
+
1287
+ # POST /api/channels/:platform
1288
+ # Body: { fields... } (platform-specific credential fields)
1289
+ # Saves credentials and optionally (re)starts the adapter.
1290
+ def api_save_channel(platform, req, res)
1291
+ platform = platform.to_sym
1292
+ body = parse_json_body(req)
1293
+ config = Octo::ChannelConfig.load
1294
+
1295
+ fields = body.transform_keys(&:to_sym).reject { |k, _| k == :platform }
1296
+ fields = fields.transform_values { |v| v.is_a?(String) ? v.strip : v }
1297
+
1298
+ # Record when the token was last updated so clients can detect re-login
1299
+ fields[:token_updated_at] = Time.now.to_i if platform == :weixin && fields.key?(:token)
1300
+ fields[:token_updated_at] = Time.now.to_i if platform == :discord && fields.key?(:bot_token)
1301
+
1302
+ # Validate credentials against live API before persisting.
1303
+ # Merge with existing config so partial updates (e.g. allowed_users only) still validate correctly.
1304
+ klass = Octo::Channel::Adapters.find(platform)
1305
+ if klass && klass.respond_to?(:test_connection)
1306
+ existing = config.platform_config(platform) || {}
1307
+ merged = existing.merge(fields)
1308
+ result = klass.test_connection(merged)
1309
+ unless result[:ok]
1310
+ json_response(res, 422, { ok: false, error: result[:error] || "Credential validation failed" })
1311
+ return
1312
+ end
1313
+ end
1314
+
1315
+ config.set_platform(platform, **fields)
1316
+ config.save
1317
+
1318
+ # Hot-reload: stop existing adapter for this platform (if running) and restart
1319
+ @channel_manager.reload_platform(platform, config)
1320
+
1321
+ json_response(res, 200, { ok: true })
1322
+ rescue StandardError => e
1323
+ json_response(res, 422, { ok: false, error: e.message })
1324
+ end
1325
+
1326
+ # DELETE /api/channels/:platform
1327
+ # Disables the platform (keeps credentials, sets enabled: false).
1328
+ def api_delete_channel(platform, res)
1329
+ platform = platform.to_sym
1330
+ config = Octo::ChannelConfig.load
1331
+ config.disable_platform(platform)
1332
+ config.save
1333
+
1334
+ @channel_manager.reload_platform(platform, config)
1335
+
1336
+ json_response(res, 200, { ok: true })
1337
+ rescue StandardError => e
1338
+ json_response(res, 422, { ok: false, error: e.message })
1339
+ end
1340
+
1341
+ # PATCH /api/channels/:platform/enabled
1342
+ # Body: { enabled: true|false }
1343
+ # Toggles the platform on/off without touching credentials.
1344
+ # Enabling requires the platform to already be configured.
1345
+ def api_toggle_channel(platform, req, res)
1346
+ platform = platform.to_sym
1347
+ enabled = parse_json_body(req)["enabled"] == true
1348
+
1349
+ config = Octo::ChannelConfig.load
1350
+
1351
+ if enabled
1352
+ unless config.platform_config(platform)
1353
+ json_response(res, 422, { ok: false, error: "Platform is not configured yet" })
1354
+ return
1355
+ end
1356
+ config.enable_platform(platform)
1357
+ else
1358
+ config.disable_platform(platform)
1359
+ end
1360
+
1361
+ config.save
1362
+ @channel_manager.reload_platform(platform, config)
1363
+
1364
+ json_response(res, 200, { ok: true, enabled: config.enabled?(platform) })
1365
+ rescue StandardError => e
1366
+ json_response(res, 422, { ok: false, error: e.message })
1367
+ end
1368
+
1369
+ # POST /api/channels/:platform/test
1370
+ # Body: { fields... } (credentials to test — NOT saved)
1371
+ # Tests connectivity using the provided credentials without persisting.
1372
+ def api_test_channel(platform, req, res)
1373
+ platform = platform.to_sym
1374
+ body = parse_json_body(req)
1375
+ fields = body.transform_keys(&:to_sym).reject { |k, _| k == :platform }
1376
+
1377
+ klass = Octo::Channel::Adapters.find(platform)
1378
+ unless klass
1379
+ json_response(res, 404, { ok: false, error: "Unknown platform: #{platform}" })
1380
+ return
1381
+ end
1382
+
1383
+ result = klass.test_connection(fields)
1384
+ json_response(res, 200, result)
1385
+ rescue StandardError => e
1386
+ json_response(res, 200, { ok: false, error: e.message })
1387
+ end
1388
+
1389
+ # Returns non-secret fields for a platform (masked secrets).
1390
+ private def platform_safe_fields(platform, config)
1391
+ raw = config.instance_variable_get(:@channels)[platform.to_s] || {}
1392
+ case platform.to_sym
1393
+ when :feishu
1394
+ {
1395
+ app_id: raw["app_id"] || "",
1396
+ domain: raw["domain"] || Octo::Channel::Adapters::Feishu::DEFAULT_DOMAIN,
1397
+ allowed_users: raw["allowed_users"] || []
1398
+ }
1399
+ when :wecom
1400
+ {
1401
+ bot_id: raw["bot_id"] || ""
1402
+ }
1403
+ when :weixin
1404
+ {
1405
+ base_url: raw["base_url"] || Octo::Channel::Adapters::Weixin::ApiClient::DEFAULT_BASE_URL,
1406
+ allowed_users: raw["allowed_users"] || [],
1407
+ has_token: !raw["token"].to_s.strip.empty?,
1408
+ token_updated_at: raw["token_updated_at"] # Unix timestamp, nil if never set
1409
+ }
1410
+ when :discord
1411
+ {
1412
+ allowed_users: raw["allowed_users"] || [],
1413
+ has_token: !raw["bot_token"].to_s.strip.empty?,
1414
+ token_updated_at: raw["token_updated_at"]
1415
+ }
1416
+ when :telegram
1417
+ {
1418
+ base_url: raw["base_url"] || Octo::Channel::Adapters::Telegram::ApiClient::DEFAULT_BASE_URL,
1419
+ parse_mode: raw.key?("parse_mode") ? raw["parse_mode"] : "Markdown",
1420
+ allowed_users: raw["allowed_users"] || [],
1421
+ has_token: !raw["bot_token"].to_s.strip.empty?
1422
+ }
1423
+ when :dingtalk
1424
+ {
1425
+ client_id: raw["client_id"] || "",
1426
+ allowed_users: raw["allowed_users"] || []
1427
+ }
1428
+ else
1429
+ {}
1430
+ end
1431
+ end
1432
+
1433
+
1434
+ # ── Cron-Tasks API ───────────────────────────────────────────────────────
1435
+ # Unified API that manages task file + schedule as a single resource.
1436
+
1437
+ # GET /api/cron-tasks
1438
+ def api_list_cron_tasks(res)
1439
+ json_response(res, 200, { cron_tasks: @scheduler.list_cron_tasks })
1440
+ end
1441
+
1442
+ # POST /api/cron-tasks — create task file + schedule in one step
1443
+ # Body: { name, content, cron, enabled? }
1444
+ def api_create_cron_task(req, res)
1445
+ body = parse_json_body(req)
1446
+ name = body["name"].to_s.strip
1447
+ content = body["content"].to_s
1448
+ cron = body["cron"].to_s.strip
1449
+ enabled = body.key?("enabled") ? body["enabled"] : true
1450
+
1451
+ return json_response(res, 422, { error: "name is required" }) if name.empty?
1452
+ return json_response(res, 422, { error: "content is required" }) if content.empty?
1453
+ return json_response(res, 422, { error: "cron is required" }) if cron.empty?
1454
+
1455
+ fields = cron.strip.split(/\s+/)
1456
+ unless fields.size == 5
1457
+ return json_response(res, 422, { error: "cron must have 5 fields (min hour dom month dow)" })
1458
+ end
1459
+
1460
+ @scheduler.create_cron_task(name: name, content: content, cron: cron, enabled: enabled)
1461
+ json_response(res, 201, { ok: true, name: name })
1462
+ end
1463
+
1464
+ # PATCH /api/cron-tasks/:name — update content and/or cron/enabled
1465
+ # Body: { content?, cron?, enabled? }
1466
+ def api_update_cron_task(name, req, res)
1467
+ body = parse_json_body(req)
1468
+ content = body["content"]
1469
+ cron = body["cron"]&.to_s&.strip
1470
+ enabled = body["enabled"]
1471
+
1472
+ if cron && cron.split(/\s+/).size != 5
1473
+ return json_response(res, 422, { error: "cron must have 5 fields (min hour dom month dow)" })
1474
+ end
1475
+
1476
+ @scheduler.update_cron_task(name, content: content, cron: cron, enabled: enabled)
1477
+ json_response(res, 200, { ok: true, name: name })
1478
+ rescue => e
1479
+ json_response(res, 404, { error: e.message })
1480
+ end
1481
+
1482
+ # DELETE /api/cron-tasks/:name — remove task file + schedule
1483
+ def api_delete_cron_task(name, res)
1484
+ if @scheduler.delete_cron_task(name)
1485
+ json_response(res, 200, { ok: true })
1486
+ else
1487
+ json_response(res, 404, { error: "Cron task not found: #{name}" })
1488
+ end
1489
+ end
1490
+
1491
+ # POST /api/cron-tasks/:name/run — execute immediately
1492
+ def api_run_cron_task(name, res)
1493
+ unless @scheduler.list_tasks.include?(name)
1494
+ return json_response(res, 404, { error: "Cron task not found: #{name}" })
1495
+ end
1496
+
1497
+ prompt = @scheduler.read_task(name)
1498
+ session_name = "▶ #{name} #{Time.now.strftime("%H:%M")}"
1499
+ working_dir = File.expand_path("~/octo_workspace")
1500
+ FileUtils.mkdir_p(working_dir)
1501
+
1502
+ session_id = build_session(name: session_name, working_dir: working_dir, permission_mode: :auto_approve)
1503
+ @registry.update(session_id, pending_task: prompt, pending_working_dir: working_dir)
1504
+
1505
+ json_response(res, 202, { ok: true, session: @registry.session_summary(session_id) })
1506
+ rescue => e
1507
+ json_response(res, 422, { error: e.message })
1508
+ end
1509
+
1510
+ # ── Skills API ────────────────────────────────────────────────────────────
1511
+
1512
+ # GET /api/skills — list all loaded skills with metadata
1513
+ def api_list_skills(res)
1514
+ @skill_loader.load_all # refresh from disk on each request
1515
+
1516
+ skills = @skill_loader.all_skills.map do |skill|
1517
+ source = @skill_loader.loaded_from[skill.identifier]
1518
+
1519
+ # Compute local modification time of SKILL.md for "has local changes" indicator
1520
+ skill_md_path = File.join(skill.directory.to_s, "SKILL.md")
1521
+ local_modified_at = File.exist?(skill_md_path) ? File.mtime(skill_md_path).utc.iso8601 : nil
1522
+
1523
+ entry = {
1524
+ name: skill.identifier,
1525
+ name_zh: skill.name_zh,
1526
+ description: skill.context_description,
1527
+ description_zh: skill.description_zh,
1528
+ source: source,
1529
+ enabled: !skill.disabled?,
1530
+ invalid: skill.invalid?,
1531
+ warnings: skill.warnings,
1532
+ local_modified_at: local_modified_at
1533
+ }
1534
+ entry[:invalid_reason] = skill.invalid_reason if skill.invalid?
1535
+ entry
1536
+ end
1537
+ json_response(res, 200, { skills: skills })
1538
+ end
1539
+
1540
+ # GET /api/sessions/:id/skills — list user-invocable skills for a session,
1541
+ # filtered by the session's agent profile. Used by the frontend slash-command
1542
+ # autocomplete so only skills valid for the current profile are suggested.
1543
+ def api_session_skills(session_id, res)
1544
+ unless @registry.ensure(session_id)
1545
+ json_response(res, 404, { error: "Session not found" })
1546
+ return
1547
+ end
1548
+ session = @registry.get(session_id)
1549
+ unless session
1550
+ json_response(res, 404, { error: "Session not found" })
1551
+ return
1552
+ end
1553
+
1554
+ agent = session[:agent]
1555
+ unless agent
1556
+ json_response(res, 404, { error: "Agent not found" })
1557
+ return
1558
+ end
1559
+
1560
+ agent.skill_loader.load_all
1561
+ profile = agent.agent_profile
1562
+
1563
+ skills = agent.skill_loader.user_invocable_skills
1564
+ skills = skills.select { |s| s.allowed_for_agent?(profile.name) } if profile
1565
+
1566
+ loader = agent.skill_loader
1567
+ loaded_from = loader.loaded_from
1568
+
1569
+ skill_data = skills.map do |skill|
1570
+ source_type = loaded_from[skill.identifier]
1571
+ {
1572
+ name: skill.identifier,
1573
+ name_zh: skill.name_zh,
1574
+ description: skill.description || skill.context_description,
1575
+ description_zh: skill.description_zh,
1576
+ source_type: source_type
1577
+ }
1578
+ end
1579
+
1580
+ json_response(res, 200, { skills: skill_data })
1581
+ end
1582
+
1583
+ # PATCH /api/skills/:name/toggle — enable or disable a skill
1584
+ # Body: { enabled: true/false }
1585
+ def api_toggle_skill(name, req, res)
1586
+ body = parse_json_body(req)
1587
+ enabled = body["enabled"]
1588
+
1589
+ if enabled.nil?
1590
+ json_response(res, 422, { error: "enabled field required" })
1591
+ return
1592
+ end
1593
+
1594
+ skill = @skill_loader.toggle_skill(name, enabled: enabled)
1595
+ json_response(res, 200, { ok: true, name: skill.identifier, enabled: !skill.disabled? })
1596
+ rescue Octo::AgentError => e
1597
+ json_response(res, 422, { error: e.message })
1598
+ end
1599
+
1600
+ # POST /api/my-skills/:name/publish
1601
+
1602
+
1603
+ # GET /api/trash[?project=<path>]
1604
+ # Lists recently deleted files in the AI trash.
1605
+ #
1606
+ # The trash is organized by project_root; each project gets its own
1607
+ # hashed subdirectory under ~/.octo/trash/ (see TrashDirectory).
1608
+ # Returns ALL projects' deletions by default, with a per-file
1609
+ # project_root field so the UI can group or filter.
1610
+ #
1611
+ # Optional ?project=<absolute-path> restricts to a single project.
1612
+ # Response:
1613
+ # { ok: true,
1614
+ # files: [ { original_path, deleted_at, file_size, file_type,
1615
+ # project_root, project_name, trash_file } ],
1616
+ # projects: [ { project_root, project_name, file_count, total_size } ],
1617
+ # total_count, total_size }
1618
+ private def api_trash(req, res)
1619
+ query = URI.decode_www_form(req.query_string.to_s).to_h
1620
+ filter_project = query["project"].to_s.strip
1621
+ filter_project = nil if filter_project.empty?
1622
+
1623
+ projects =
1624
+ if filter_project
1625
+ [{ project_root: File.expand_path(filter_project),
1626
+ project_name: File.basename(File.expand_path(filter_project)),
1627
+ trash_dir: Octo::TrashDirectory.new(filter_project).trash_dir }]
1628
+ else
1629
+ Octo::TrashDirectory.all_projects
1630
+ end
1631
+
1632
+ all_files = []
1633
+ project_rows = []
1634
+
1635
+ projects.each do |p|
1636
+ files = _trash_files_in(p[:trash_dir], p[:project_root])
1637
+ next if files.empty? && filter_project.nil?
1638
+
1639
+ total_size = files.sum { |f| f[:file_size].to_i }
1640
+ project_rows << {
1641
+ project_root: p[:project_root],
1642
+ project_name: p[:project_name],
1643
+ file_count: files.size,
1644
+ total_size: total_size
1645
+ }
1646
+
1647
+ files.each do |f|
1648
+ all_files << f.merge(
1649
+ project_root: p[:project_root],
1650
+ project_name: p[:project_name]
1651
+ )
1652
+ end
1653
+ end
1654
+
1655
+ all_files.sort_by! { |f| f[:deleted_at].to_s }.reverse!
1656
+
1657
+ json_response(res, 200, {
1658
+ ok: true,
1659
+ files: all_files,
1660
+ projects: project_rows,
1661
+ total_count: all_files.size,
1662
+ total_size: all_files.sum { |f| f[:file_size].to_i }
1663
+ })
1664
+ end
1665
+
1666
+ # POST /api/trash/restore
1667
+ # Body: { project_root: "...", original_path: "..." }
1668
+ # Restores a single file from trash back to its original location.
1669
+ # Refuses if the target already exists on disk.
1670
+ private def api_trash_restore(req, res)
1671
+ data = parse_json_body(req)
1672
+ project_root = data["project_root"].to_s.strip
1673
+ original_path = data["original_path"].to_s.strip
1674
+
1675
+ if project_root.empty? || original_path.empty?
1676
+ json_response(res, 400, { ok: false, error: "project_root and original_path are required" })
1677
+ return
1678
+ end
1679
+
1680
+ tool = Octo::Tools::TrashManager.new
1681
+ result = tool.execute(action: "restore",
1682
+ file_path: original_path,
1683
+ working_dir: project_root)
1684
+
1685
+ if result[:success]
1686
+ json_response(res, 200, { ok: true, restored_file: result[:restored_file], message: result[:message] })
1687
+ else
1688
+ json_response(res, 422, { ok: false, error: result[:message] })
1689
+ end
1690
+ end
1691
+
1692
+ # DELETE /api/trash[?project=<path>][&days_old=<n>][&file=<original_path>]
1693
+ # Three modes:
1694
+ # ?file=<original_path>&project=<root> → permanently delete one file
1695
+ # ?project=<root>[&days_old=0] → empty that project's trash
1696
+ # (no project, days_old required) → empty ALL projects older than N days
1697
+ private def api_trash_delete(req, res)
1698
+ query = URI.decode_www_form(req.query_string.to_s).to_h
1699
+ project_root = query["project"].to_s.strip
1700
+ days_old = query["days_old"].to_s.strip
1701
+ file_path = query["file"].to_s.strip
1702
+
1703
+ project_root = nil if project_root.empty?
1704
+ file_path = nil if file_path.empty?
1705
+
1706
+ # Mode 1: single-file permanent delete
1707
+ if file_path
1708
+ unless project_root
1709
+ json_response(res, 400, { ok: false, error: "project is required when file is given" })
1710
+ return
1711
+ end
1712
+ deleted = _trash_delete_single(project_root, file_path)
1713
+ if deleted
1714
+ json_response(res, 200, { ok: true, deleted_count: 1, freed_size: deleted[:file_size].to_i })
1715
+ else
1716
+ json_response(res, 404, { ok: false, error: "File not found in trash: #{file_path}" })
1717
+ end
1718
+ return
1719
+ end
1720
+
1721
+ # Mode 2 & 3: bulk empty (optionally scoped to one project, optionally by age)
1722
+ days_i = days_old.empty? ? 0 : days_old.to_i
1723
+ tool = Octo::Tools::TrashManager.new
1724
+
1725
+ targets =
1726
+ if project_root
1727
+ [project_root]
1728
+ else
1729
+ Octo::TrashDirectory.all_projects.map { |p| p[:project_root] }
1730
+ end
1731
+
1732
+ total_deleted = 0
1733
+ total_freed = 0
1734
+ targets.each do |root|
1735
+ result = tool.execute(action: "empty", days_old: days_i, working_dir: root)
1736
+ next unless result[:success]
1737
+ total_deleted += result[:deleted_count].to_i
1738
+ total_freed += result[:freed_size].to_i
1739
+ end
1740
+
1741
+ json_response(res, 200, {
1742
+ ok: true,
1743
+ deleted_count: total_deleted,
1744
+ freed_size: total_freed,
1745
+ days_old: days_i
1746
+ })
1747
+ end
1748
+
1749
+ # ── Trash helpers (private) ─────────────────────────────────────
1750
+ # Reads all metadata sidecars in `trash_dir` and returns enriched
1751
+ # file records. Silently skips sidecars whose payload file has
1752
+ # already been purged from disk.
1753
+ private def _trash_files_in(trash_dir, project_root)
1754
+ return [] unless trash_dir && Dir.exist?(trash_dir)
1755
+
1756
+ files = []
1757
+ Dir.glob(File.join(trash_dir, "*.metadata.json")).each do |meta_path|
1758
+ begin
1759
+ meta = JSON.parse(File.read(meta_path))
1760
+ trash = meta_path.sub(/\.metadata\.json\z/, "")
1761
+ next unless File.exist?(trash)
1762
+ files << {
1763
+ original_path: meta["original_path"],
1764
+ deleted_at: meta["deleted_at"],
1765
+ deleted_by: meta["deleted_by"],
1766
+ file_size: meta["file_size"].to_i,
1767
+ file_type: meta["file_type"],
1768
+ file_mode: meta["file_mode"],
1769
+ trash_file: trash
1770
+ }
1771
+ rescue StandardError
1772
+ # Corrupt or partial metadata — skip.
1773
+ end
1774
+ end
1775
+ files
1776
+ end
1777
+
1778
+ # Permanently deletes the single trash entry whose original_path
1779
+ # matches inside `project_root`'s trash. Returns the removed
1780
+ # metadata hash, or nil if not found.
1781
+ private def _trash_delete_single(project_root, original_path)
1782
+ trash_dir = Octo::TrashDirectory.new(project_root).trash_dir
1783
+ expanded = File.expand_path(original_path, project_root)
1784
+ entry = _trash_files_in(trash_dir, project_root).find do |f|
1785
+ f[:original_path] == expanded
1786
+ end
1787
+ return nil unless entry
1788
+
1789
+ File.delete(entry[:trash_file]) if File.exist?(entry[:trash_file])
1790
+ File.delete("#{entry[:trash_file]}.metadata.json") if File.exist?("#{entry[:trash_file]}.metadata.json")
1791
+ entry
1792
+ rescue StandardError
1793
+ nil
1794
+ end
1795
+
1796
+ # ── Profile API (USER.md / SOUL.md) ──────────────────────────────
1797
+ #
1798
+ # User can override the built-in defaults by writing their own
1799
+ # ~/.octo/agents/USER.md and ~/.octo/agents/SOUL.md. These
1800
+ # endpoints let the Web UI read and edit those files.
1801
+
1802
+ PROFILE_USER_AGENTS_DIR = File.expand_path("~/.octo/agents").freeze
1803
+ PROFILE_DEFAULT_AGENTS_DIR = File.expand_path("../../default_agents", __dir__).freeze
1804
+ PROFILE_MAX_BYTES = 50_000 # Hard limit; prevents runaway content.
1805
+
1806
+ # GET /api/profile
1807
+ # Returns { ok:, user: { path, content, is_default }, soul: { ... } }
1808
+ private def api_profile_get(res)
1809
+ json_response(res, 200, {
1810
+ ok: true,
1811
+ user: _profile_read_file("USER.md"),
1812
+ soul: _profile_read_file("SOUL.md")
1813
+ })
1814
+ end
1815
+
1816
+ # PUT /api/profile
1817
+ # Body: { kind: "user"|"soul", content: "..." }
1818
+ # Writes the file to ~/.octo/agents/<KIND>.md. Empty content
1819
+ # deletes the override so the built-in default is used again.
1820
+ private def api_profile_put(req, res)
1821
+ data = parse_json_body(req)
1822
+ kind = data["kind"].to_s.downcase
1823
+ content = data["content"].to_s
1824
+
1825
+ filename = case kind
1826
+ when "user" then "USER.md"
1827
+ when "soul" then "SOUL.md"
1828
+ else
1829
+ json_response(res, 400, { ok: false, error: "kind must be 'user' or 'soul'" })
1830
+ return
1831
+ end
1832
+
1833
+ if content.bytesize > PROFILE_MAX_BYTES
1834
+ json_response(res, 413, { ok: false, error: "Content too large (max #{PROFILE_MAX_BYTES} bytes)" })
1835
+ return
1836
+ end
1837
+
1838
+ FileUtils.mkdir_p(PROFILE_USER_AGENTS_DIR)
1839
+ target = File.join(PROFILE_USER_AGENTS_DIR, filename)
1840
+
1841
+ # Treat whitespace-only payload as "reset to built-in default":
1842
+ # delete the override file so AgentProfile falls back to default.
1843
+ if content.strip.empty?
1844
+ File.delete(target) if File.exist?(target)
1845
+ json_response(res, 200, { ok: true, reset: true, file: _profile_read_file(filename) })
1846
+ return
1847
+ end
1848
+
1849
+ File.write(target, content)
1850
+ json_response(res, 200, { ok: true, file: _profile_read_file(filename) })
1851
+ rescue StandardError => e
1852
+ json_response(res, 500, { ok: false, error: e.message })
1853
+ end
1854
+
1855
+ # Read a profile file — user override if present, else built-in default.
1856
+ # Returns { path:, content:, is_default:, writable: }.
1857
+ private def _profile_read_file(filename)
1858
+ user_path = File.join(PROFILE_USER_AGENTS_DIR, filename)
1859
+ default_path = File.join(PROFILE_DEFAULT_AGENTS_DIR, filename)
1860
+
1861
+ if File.exist?(user_path) && !File.zero?(user_path)
1862
+ {
1863
+ path: user_path,
1864
+ content: File.read(user_path),
1865
+ is_default: false
1866
+ }
1867
+ elsif File.exist?(default_path)
1868
+ {
1869
+ path: default_path,
1870
+ content: File.read(default_path),
1871
+ is_default: true
1872
+ }
1873
+ else
1874
+ {
1875
+ path: user_path, # Where it WILL be written
1876
+ content: "",
1877
+ is_default: true
1878
+ }
1879
+ end
1880
+ rescue StandardError => e
1881
+ { path: "", content: "", is_default: true, error: e.message }
1882
+ end
1883
+
1884
+ # ── Memories API (~/.octo/memories/*.md) ───────────────────────
1885
+ #
1886
+ # Long-term memories are plain Markdown files with YAML frontmatter
1887
+ # stored under ~/.octo/memories/. These endpoints let the user
1888
+ # inspect, edit, create, and delete them from the Web UI.
1889
+
1890
+ MEMORIES_DIR = File.expand_path("~/.octo/memories").freeze
1891
+ MEMORY_MAX_BYTES = 50_000
1892
+
1893
+ # GET /api/memories
1894
+ # Returns { ok:, dir:, memories: [ { filename, topic, description, updated_at, size, preview } ] }
1895
+ # Sorted by updated_at (YAML frontmatter) descending, falling back to file mtime.
1896
+ private def api_memories_list(res)
1897
+ FileUtils.mkdir_p(MEMORIES_DIR)
1898
+ memories = Dir.glob(File.join(MEMORIES_DIR, "*.md")).map do |path|
1899
+ _memory_summary(path)
1900
+ end.compact
1901
+
1902
+ # Sort key: prefer updated_at string (ISO-ish sorts correctly), fall back to mtime.
1903
+ # `mtime` is always present in the summary (ISO 8601), so we use it as the
1904
+ # ultimate tiebreaker. Negate by reversing after sort for descending order.
1905
+ memories.sort_by! do |m|
1906
+ key = m[:updated_at].to_s
1907
+ key = m[:mtime].to_s if key.empty?
1908
+ key
1909
+ end
1910
+ memories.reverse!
1911
+
1912
+ json_response(res, 200, { ok: true, dir: MEMORIES_DIR, memories: memories })
1913
+ end
1914
+
1915
+ # GET /api/memories/:filename
1916
+ # Returns { ok:, filename:, path:, content: }
1917
+ private def api_memories_get(filename, res)
1918
+ safe = _memory_safe_filename(filename)
1919
+ unless safe
1920
+ json_response(res, 400, { ok: false, error: "Invalid filename" })
1921
+ return
1922
+ end
1923
+ path = File.join(MEMORIES_DIR, safe)
1924
+ unless File.exist?(path)
1925
+ json_response(res, 404, { ok: false, error: "Memory not found" })
1926
+ return
1927
+ end
1928
+ json_response(res, 200, {
1929
+ ok: true,
1930
+ filename: safe,
1931
+ path: path,
1932
+ content: File.read(path)
1933
+ })
1934
+ end
1935
+
1936
+ # POST /api/memories
1937
+ # Body: { filename: "topic.md", content: "..." }
1938
+ # Create a new memory file. Refuses to overwrite existing.
1939
+ private def api_memories_create(req, res)
1940
+ data = parse_json_body(req)
1941
+ filename = _memory_safe_filename(data["filename"].to_s)
1942
+ content = data["content"].to_s
1943
+
1944
+ unless filename
1945
+ json_response(res, 400, { ok: false, error: "Invalid filename (must end in .md, no path separators)" })
1946
+ return
1947
+ end
1948
+ if content.bytesize > MEMORY_MAX_BYTES
1949
+ json_response(res, 413, { ok: false, error: "Content too large (max #{MEMORY_MAX_BYTES} bytes)" })
1950
+ return
1951
+ end
1952
+
1953
+ FileUtils.mkdir_p(MEMORIES_DIR)
1954
+ path = File.join(MEMORIES_DIR, filename)
1955
+ if File.exist?(path)
1956
+ json_response(res, 409, { ok: false, error: "Memory '#{filename}' already exists" })
1957
+ return
1958
+ end
1959
+
1960
+ File.write(path, content)
1961
+ json_response(res, 201, { ok: true, memory: _memory_summary(path) })
1962
+ rescue StandardError => e
1963
+ json_response(res, 500, { ok: false, error: e.message })
1964
+ end
1965
+
1966
+ # PUT /api/memories/:filename
1967
+ # Body: { content: "..." }
1968
+ private def api_memories_update(filename, req, res)
1969
+ safe = _memory_safe_filename(filename)
1970
+ unless safe
1971
+ json_response(res, 400, { ok: false, error: "Invalid filename" })
1972
+ return
1973
+ end
1974
+ data = parse_json_body(req)
1975
+ content = data["content"].to_s
1976
+ if content.bytesize > MEMORY_MAX_BYTES
1977
+ json_response(res, 413, { ok: false, error: "Content too large (max #{MEMORY_MAX_BYTES} bytes)" })
1978
+ return
1979
+ end
1980
+
1981
+ path = File.join(MEMORIES_DIR, safe)
1982
+ unless File.exist?(path)
1983
+ json_response(res, 404, { ok: false, error: "Memory not found" })
1984
+ return
1985
+ end
1986
+
1987
+ File.write(path, content)
1988
+ json_response(res, 200, { ok: true, memory: _memory_summary(path) })
1989
+ rescue StandardError => e
1990
+ json_response(res, 500, { ok: false, error: e.message })
1991
+ end
1992
+
1993
+ # DELETE /api/memories/:filename
1994
+ private def api_memories_delete(filename, res)
1995
+ safe = _memory_safe_filename(filename)
1996
+ unless safe
1997
+ json_response(res, 400, { ok: false, error: "Invalid filename" })
1998
+ return
1999
+ end
2000
+ path = File.join(MEMORIES_DIR, safe)
2001
+ unless File.exist?(path)
2002
+ json_response(res, 404, { ok: false, error: "Memory not found" })
2003
+ return
2004
+ end
2005
+ File.delete(path)
2006
+ json_response(res, 200, { ok: true, filename: safe })
2007
+ rescue StandardError => e
2008
+ json_response(res, 500, { ok: false, error: e.message })
2009
+ end
2010
+
2011
+ # Returns nil if the filename is unsafe. Must end in .md, contain
2012
+ # no path separators or shell metacharacters, and be non-empty.
2013
+ private def _memory_safe_filename(name)
2014
+ s = name.to_s.strip
2015
+ return nil if s.empty?
2016
+ return nil if s.include?("/") || s.include?("\\")
2017
+ return nil if s.start_with?(".")
2018
+ return nil unless s.end_with?(".md")
2019
+ return nil unless s.match?(/\A[A-Za-z0-9._\-]+\z/)
2020
+ s
2021
+ end
2022
+
2023
+ # Build a summary record for a memory file. Parses YAML frontmatter
2024
+ # if present; otherwise falls back to filename-derived topic.
2025
+ # Returns nil if the file can't be read.
2026
+ private def _memory_summary(path)
2027
+ content = File.read(path)
2028
+ stat = File.stat(path)
2029
+
2030
+ topic = File.basename(path, ".md")
2031
+ description = ""
2032
+ updated_at = stat.mtime.strftime("%Y-%m-%d")
2033
+
2034
+ # Parse YAML frontmatter: --- ... --- at the top of the file.
2035
+ if content.start_with?("---")
2036
+ if (m = content.match(/\A---\s*\n(.*?)\n---\s*\n/m))
2037
+ begin
2038
+ # permitted_classes: Date so YAML `updated_at: 2026-05-01`
2039
+ # parses to a Date instance instead of raising DisallowedClass.
2040
+ fm = YAML.safe_load(m[1], permitted_classes: [Date, Time]) || {}
2041
+ topic = fm["topic"].to_s unless fm["topic"].to_s.strip.empty?
2042
+ description = fm["description"].to_s
2043
+ updated_at = fm["updated_at"].to_s unless fm["updated_at"].to_s.strip.empty?
2044
+ rescue StandardError
2045
+ # Bad frontmatter — fall back to defaults above.
2046
+ end
2047
+ end
2048
+ end
2049
+
2050
+ preview = content.sub(/\A---.*?---\s*\n/m, "").strip[0, 200]
2051
+
2052
+ {
2053
+ filename: File.basename(path),
2054
+ path: path,
2055
+ topic: topic,
2056
+ description: description,
2057
+ updated_at: updated_at,
2058
+ size: stat.size,
2059
+ mtime: stat.mtime.iso8601,
2060
+ preview: preview
2061
+ }
2062
+ rescue StandardError
2063
+ nil
2064
+ end
2065
+
2066
+
2067
+
2068
+ # ── Config API ────────────────────────────────────────────────────────────
2069
+
2070
+ # GET /api/config — return current model configurations
2071
+ def api_get_config(res)
2072
+ models = @agent_config.models.map.with_index do |m, i|
2073
+ {
2074
+ id: m["id"], # Stable runtime id — use this for switching
2075
+ index: i,
2076
+ model: m["model"],
2077
+ base_url: m["base_url"],
2078
+ api_key_masked: mask_api_key(m["api_key"]),
2079
+ anthropic_format: m["anthropic_format"] || false,
2080
+ type: m["type"]
2081
+ }
2082
+ end
2083
+ # Filter out auto-injected models (like lite) from UI display
2084
+ models.reject! { |m| @agent_config.models[m[:index]]["auto_injected"] }
2085
+ json_response(res, 200, {
2086
+ models: models,
2087
+ current_index: @agent_config.current_model_index,
2088
+ current_id: @agent_config.current_model&.dig("id")
2089
+ })
2090
+ end
2091
+
2092
+ # POST /api/config — save updated model list
2093
+ # DEPRECATED: this endpoint previously accepted the entire models array
2094
+ # and replaced @models in place. That design was fragile — any missing
2095
+ # or stale field on ANY row could wipe other rows' api_keys. It has
2096
+ # been removed in favour of single-item RESTful endpoints below:
2097
+ # POST /api/config/models — add one model
2098
+ # PATCH /api/config/models/:id — update one model
2099
+ # DELETE /api/config/models/:id — remove one model
2100
+ # POST /api/config/models/:id/default — set one model as default
2101
+ #
2102
+ # Each handler only touches the single targeted entry, so a bug in any
2103
+ # one call can never corrupt unrelated models. Front-end code must
2104
+ # never send "the whole list" anymore.
2105
+
2106
+ # POST /api/config/models
2107
+ # Body: { model, base_url, api_key, anthropic_format, type? }
2108
+ # Creates a new model entry, returns { ok:true, id, index } so the
2109
+ # frontend can record the new id without reloading the whole list.
2110
+ def api_add_model(req, res)
2111
+ body = parse_json_body(req)
2112
+ return json_response(res, 400, { error: "Invalid JSON" }) unless body
2113
+
2114
+ model = body["model"].to_s.strip
2115
+ base_url = body["base_url"].to_s.strip
2116
+ api_key = body["api_key"].to_s
2117
+ # Masked placeholders are never a valid api_key on creation —
2118
+ # a new model MUST come with a real key.
2119
+ if api_key.empty? || api_key.include?("****")
2120
+ return json_response(res, 422, { error: "api_key is required" })
2121
+ end
2122
+
2123
+ entry = {
2124
+ "id" => SecureRandom.uuid,
2125
+ "model" => model,
2126
+ "base_url" => base_url,
2127
+ "api_key" => api_key,
2128
+ "anthropic_format" => body["anthropic_format"] || false
2129
+ }
2130
+ type = body["type"].to_s
2131
+ unless type.empty?
2132
+ # Preserve the single-slot "default" invariant.
2133
+ if type == "default"
2134
+ @agent_config.models.each { |m| m.delete("type") if m["type"] == "default" }
2135
+ end
2136
+ entry["type"] = type
2137
+ end
2138
+
2139
+ @agent_config.models << entry
2140
+ # If this is the only model and no default marker exists yet,
2141
+ # adopt it as the default so downstream lookups resolve cleanly.
2142
+ if @agent_config.models.none? { |m| m["type"] == "default" }
2143
+ entry["type"] = "default"
2144
+ @agent_config.current_model_id = entry["id"]
2145
+ @agent_config.current_model_index = @agent_config.models.length - 1
2146
+ elsif type == "default"
2147
+ # Re-anchor current_* to the newly-defaulted entry.
2148
+ @agent_config.current_model_id = entry["id"]
2149
+ @agent_config.current_model_index = @agent_config.models.length - 1
2150
+ end
2151
+
2152
+ @agent_config.save
2153
+ json_response(res, 200, {
2154
+ ok: true,
2155
+ id: entry["id"],
2156
+ index: @agent_config.models.length - 1
2157
+ })
2158
+ rescue => e
2159
+ json_response(res, 422, { error: e.message })
2160
+ end
2161
+
2162
+ # PATCH /api/config/models/:id
2163
+ # Body: any subset of { model, base_url, api_key, anthropic_format, type }
2164
+ # Rules (the whole reason we moved off bulk save):
2165
+ # - Missing key → field untouched
2166
+ # - api_key with "****" (masked display value) → IGNORED (never overwrites)
2167
+ # - api_key empty string → IGNORED (defensive; treat as "not changed")
2168
+ # - api_key real non-masked value → stored
2169
+ # - type="default" transparently clears the marker on other models
2170
+ # - Unknown id → 404
2171
+ def api_update_model(id, req, res)
2172
+ body = parse_json_body(req)
2173
+ return json_response(res, 400, { error: "Invalid JSON" }) unless body
2174
+
2175
+ target = @agent_config.models.find { |m| m["id"] == id }
2176
+ return json_response(res, 404, { error: "model not found" }) unless target
2177
+
2178
+ if body.key?("model")
2179
+ v = body["model"].to_s.strip
2180
+ target["model"] = v unless v.empty?
2181
+ end
2182
+ if body.key?("base_url")
2183
+ v = body["base_url"].to_s.strip
2184
+ target["base_url"] = v unless v.empty?
2185
+ end
2186
+ if body.key?("anthropic_format")
2187
+ target["anthropic_format"] = !!body["anthropic_format"]
2188
+ end
2189
+ if body.key?("api_key")
2190
+ new_key = body["api_key"].to_s
2191
+ # Only store a real, unmasked, non-empty value. This is the
2192
+ # single place the "api_key disappeared" bug can no longer
2193
+ # happen — there is no path that writes "" into api_key.
2194
+ if !new_key.empty? && !new_key.include?("****")
2195
+ target["api_key"] = new_key
2196
+ end
2197
+ end
2198
+ if body.key?("type")
2199
+ new_type = body["type"]
2200
+ new_type = nil if new_type.is_a?(String) && new_type.strip.empty?
2201
+ if new_type == "default"
2202
+ @agent_config.models.each do |m|
2203
+ next if m["id"] == id
2204
+ m.delete("type") if m["type"] == "default"
2205
+ end
2206
+ target["type"] = "default"
2207
+ @agent_config.current_model_id = target["id"]
2208
+ @agent_config.current_model_index = @agent_config.models.find_index { |m| m["id"] == id } || 0
2209
+ elsif new_type.nil?
2210
+ target.delete("type")
2211
+ else
2212
+ target["type"] = new_type
2213
+ end
2214
+ end
2215
+
2216
+ @agent_config.save
2217
+ json_response(res, 200, { ok: true })
2218
+ rescue => e
2219
+ json_response(res, 422, { error: e.message })
2220
+ end
2221
+
2222
+ # DELETE /api/config/models/:id
2223
+ def api_delete_model(id, res)
2224
+ models = @agent_config.models
2225
+ return json_response(res, 404, { error: "model not found" }) unless models.any? { |m| m["id"] == id }
2226
+ return json_response(res, 422, { error: "cannot delete the last model" }) if models.length <= 1
2227
+
2228
+ index = models.find_index { |m| m["id"] == id }
2229
+ removed = models.delete_at(index)
2230
+
2231
+ # Re-anchor current_* if we just deleted the active model.
2232
+ if @agent_config.current_model_id == removed["id"]
2233
+ new_default = models.find { |m| m["type"] == "default" } || models.first
2234
+ # If the removed model was the default, promote the new current
2235
+ # model so the config always has exactly one default entry.
2236
+ if removed["type"] == "default" && new_default && new_default["type"] != "default"
2237
+ new_default["type"] = "default"
2238
+ end
2239
+ @agent_config.current_model_id = new_default["id"]
2240
+ @agent_config.current_model_index = models.find_index { |m| m["id"] == new_default["id"] } || 0
2241
+ elsif @agent_config.current_model_index >= models.length
2242
+ @agent_config.current_model_index = models.length - 1
2243
+ end
2244
+
2245
+ @agent_config.save
2246
+ json_response(res, 200, { ok: true })
2247
+ rescue => e
2248
+ json_response(res, 422, { error: e.message })
2249
+ end
2250
+
2251
+ # POST /api/config/models/:id/default
2252
+ # Makes the identified model the new "default" (global initial model
2253
+ # for new sessions AND current model for this server instance).
2254
+ def api_set_default_model(id, res)
2255
+ ok = @agent_config.set_default_model_by_id(id)
2256
+ return json_response(res, 404, { error: "model not found" }) unless ok
2257
+
2258
+ @agent_config.current_model_id = id
2259
+ @agent_config.current_model_index = @agent_config.models.find_index { |m| m["id"] == id } || 0
2260
+ @agent_config.save
2261
+ json_response(res, 200, { ok: true })
2262
+ rescue => e
2263
+ json_response(res, 422, { error: e.message })
2264
+ end
2265
+
2266
+ # POST /api/config/test — test connection for a single model config
2267
+ # Body: { model, base_url, api_key, anthropic_format }
2268
+ def api_test_config(req, res)
2269
+ body = parse_json_body(req)
2270
+ return json_response(res, 400, { error: "Invalid JSON" }) unless body
2271
+
2272
+ api_key = body["api_key"].to_s
2273
+ # If masked, use the stored key from the matching model (by index or current)
2274
+ if api_key.include?("****")
2275
+ idx = body["index"]&.to_i || @agent_config.current_model_index
2276
+ api_key = @agent_config.models.dig(idx, "api_key").to_s
2277
+ end
2278
+
2279
+ begin
2280
+ model = body["model"].to_s
2281
+ test_client = Octo::Client.new(
2282
+ api_key,
2283
+ base_url: body["base_url"].to_s,
2284
+ model: model,
2285
+ anthropic_format: body["anthropic_format"] || false
2286
+ )
2287
+ result = test_client.test_connection(model: model)
2288
+ if result[:success]
2289
+ json_response(res, 200, { ok: true, message: "Connected successfully" })
2290
+ else
2291
+ json_response(res, 200, { ok: false, message: result[:error].to_s })
2292
+ end
2293
+ rescue => e
2294
+ json_response(res, 200, { ok: false, message: e.message })
2295
+ end
2296
+ end
2297
+
2298
+ # GET /api/providers — return built-in provider presets for quick setup
2299
+ def api_list_providers(res)
2300
+ providers = Octo::Providers::PRESETS.map do |id, preset|
2301
+ {
2302
+ id: id,
2303
+ name: preset["name"],
2304
+ base_url: preset["base_url"],
2305
+ default_model: preset["default_model"],
2306
+ models: preset["models"] || [],
2307
+ # Frontend uses this to render a Base URL dropdown (regional /
2308
+ # provider variants) when present. Absent for single-endpoint
2309
+ # providers — UI renders a plain text input in that case.
2310
+ endpoint_variants: preset["endpoint_variants"],
2311
+ website_url: preset["website_url"]
2312
+ }
2313
+ end
2314
+ json_response(res, 200, { providers: providers })
2315
+ end
2316
+
2317
+ # GET /api/sessions/:id/messages?limit=20&before=1709123456.789
2318
+ # Replays conversation history for a session via the agent's replay_history method.
2319
+ # Returns a list of UI events (same format as WS events) for the frontend to render.
2320
+ def api_session_messages(session_id, req, res)
2321
+ unless @registry.ensure(session_id)
2322
+ Octo::Logger.warn("[messages] registry.ensure failed", session_id: session_id)
2323
+ return json_response(res, 404, { error: "Session not found" })
2324
+ end
2325
+
2326
+ # Parse query params
2327
+ query = URI.decode_www_form(req.query_string.to_s).to_h
2328
+ limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 100].min
2329
+ before = query["before"]&.to_f
2330
+
2331
+ agent = nil
2332
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
2333
+
2334
+ unless agent
2335
+ Octo::Logger.warn("[messages] agent is nil", session_id: session_id)
2336
+ return json_response(res, 200, { events: [], has_more: false })
2337
+ end
2338
+
2339
+ # Collect events emitted by replay_history via a lightweight collector UI
2340
+ collected = []
2341
+ collector = HistoryCollector.new(session_id, collected)
2342
+ result = agent.replay_history(collector, limit: limit, before: before)
2343
+
2344
+ json_response(res, 200, { events: collected, has_more: result[:has_more] })
2345
+ end
2346
+
2347
+ def api_rename_session(session_id, req, res)
2348
+ body = parse_json_body(req)
2349
+ new_name = body["name"]&.to_s&.strip
2350
+ pinned = body["pinned"]
2351
+
2352
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
2353
+
2354
+ agent = nil
2355
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
2356
+
2357
+ # Update name if provided
2358
+ if new_name && !new_name.empty?
2359
+ agent.rename(new_name)
2360
+ end
2361
+
2362
+ # Update pinned status if provided
2363
+ if !pinned.nil?
2364
+ agent.pinned = pinned
2365
+ end
2366
+
2367
+ # Save session data
2368
+ @session_manager.save(agent.to_session_data)
2369
+
2370
+ # Broadcast update event
2371
+ update_data = { type: "session_updated", session_id: session_id }
2372
+ update_data[:name] = new_name if new_name && !new_name.empty?
2373
+ update_data[:pinned] = pinned unless pinned.nil?
2374
+ broadcast(session_id, update_data)
2375
+
2376
+ response_data = { ok: true }
2377
+ response_data[:name] = new_name if new_name && !new_name.empty?
2378
+ response_data[:pinned] = pinned unless pinned.nil?
2379
+ json_response(res, 200, response_data)
2380
+ rescue => e
2381
+ json_response(res, 500, { error: e.message })
2382
+ end
2383
+
2384
+ def api_switch_session_model(session_id, req, res)
2385
+ body = parse_json_body(req)
2386
+ model_id = body["model_id"].to_s.strip
2387
+
2388
+ return json_response(res, 400, { error: "model_id is required" }) if model_id.empty?
2389
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
2390
+
2391
+ agent = nil
2392
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
2393
+
2394
+ # With Plan B (shared @models reference), every session's AgentConfig
2395
+ # points at the same @models array as the global @agent_config. So
2396
+ # resolving the model by stable id here and in agent.switch_model_by_id
2397
+ # will always agree — no more index divergence after add/delete.
2398
+ target_model = @agent_config.models.find { |m| m["id"] == model_id }
2399
+ if target_model.nil?
2400
+ return json_response(res, 400, { error: "Model not found in configuration" })
2401
+ end
2402
+
2403
+ # Switch to the model by id (unified interface with CLI)
2404
+ # Handles: config.switch_model_by_id + client rebuild + message_compressor rebuild
2405
+ success = agent.switch_model_by_id(model_id)
2406
+
2407
+ unless success
2408
+ return json_response(res, 500, { error: "Failed to switch model" })
2409
+ end
2410
+
2411
+ # Persist the change (saves to session file, NOT global config.yml)
2412
+ @session_manager.save(agent.to_session_data)
2413
+
2414
+ # Broadcast update to all clients
2415
+ broadcast_session_update(session_id)
2416
+
2417
+ json_response(res, 200, { ok: true, model_id: model_id, model: target_model["model"] })
2418
+ rescue => e
2419
+ json_response(res, 500, { error: e.message })
2420
+ end
2421
+
2422
+ # PATCH /api/sessions/:id/reasoning_effort
2423
+ # Body: { "reasoning_effort": "off" | "low" | "medium" | "high" }
2424
+ def api_switch_session_reasoning_effort(session_id, req, res)
2425
+ body = parse_json_body(req)
2426
+ raw = body["reasoning_effort"]
2427
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
2428
+
2429
+ agent = nil
2430
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
2431
+ return json_response(res, 404, { error: "Session not found" }) unless agent
2432
+
2433
+ agent.reasoning_effort = raw
2434
+ @session_manager.save(agent.to_session_data)
2435
+ broadcast_session_update(session_id)
2436
+
2437
+ json_response(res, 200, { ok: true, reasoning_effort: agent.reasoning_effort })
2438
+ rescue => e
2439
+ json_response(res, 500, { error: e.message })
2440
+ end
2441
+
2442
+ # POST /api/sessions/:id/benchmark
2443
+ #
2444
+ # Speed-test every configured model in one shot so the user can pick the
2445
+ # fastest available model for this session. We send a minimal one-token
2446
+ # request to each model *in parallel* (one thread per model) and measure
2447
+ # total HTTP duration — for non-streaming calls this equals the user's
2448
+ # perceived time-to-first-token, so the field is named `ttft_ms` for
2449
+ # forward-compatibility with a future streaming implementation.
2450
+ #
2451
+ # Cost note: each request is `max_tokens: 1` + a 2-byte prompt, so the
2452
+ # total cost across a dozen models is well under one cent.
2453
+ #
2454
+ # Response shape:
2455
+ # {
2456
+ # ok: true,
2457
+ # results: [
2458
+ # { model_id: "...", model: "...", ttft_ms: 812, ok: true },
2459
+ # { model_id: "...", model: "...", ok: false, error: "timeout" },
2460
+ # ...
2461
+ # ]
2462
+ # }
2463
+ def api_benchmark_session_models(session_id, _req, res)
2464
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
2465
+
2466
+ # Snapshot the models list — @agent_config.models is a shared reference
2467
+ # that the user might mutate from the settings panel during the test;
2468
+ # a shallow dup is enough since we only read string fields below.
2469
+ models = Array(@agent_config.models).dup
2470
+ return json_response(res, 200, { ok: true, results: [] }) if models.empty?
2471
+
2472
+ # Kick off one thread per model. We deliberately cap per-request wall
2473
+ # time inside each thread via a Faraday timeout so a single dead model
2474
+ # can't block the response. The outer join uses a generous ceiling
2475
+ # (timeout + small buffer) as a last-resort safety net.
2476
+ per_model_timeout = 15
2477
+ threads = models.map do |m|
2478
+ Thread.new do
2479
+ Thread.current.report_on_exception = false
2480
+ benchmark_single_model(m, per_model_timeout)
2481
+ end
2482
+ end
2483
+
2484
+ results = threads.map do |t|
2485
+ t.join(per_model_timeout + 3)
2486
+ t.value rescue { ok: false, error: "thread failed" }
2487
+ end
2488
+
2489
+ json_response(res, 200, { ok: true, results: results })
2490
+ rescue => e
2491
+ Octo::Logger.error("[benchmark] #{e.class}: #{e.message}", error: e)
2492
+ json_response(res, 500, { error: e.message })
2493
+ end
2494
+
2495
+ # Runs one speed-test request against a single model config hash and
2496
+ # returns a result row for api_benchmark_session_models. Pure function —
2497
+ # no shared state — so it's safe to call from worker threads.
2498
+ private def benchmark_single_model(model_cfg, timeout_sec)
2499
+ model_id = model_cfg["id"].to_s
2500
+ model_name = model_cfg["model"].to_s
2501
+ base = { model_id: model_id, model: model_name }
2502
+
2503
+ client = Octo::Client.new(
2504
+ model_cfg["api_key"].to_s,
2505
+ base_url: model_cfg["base_url"].to_s,
2506
+ model: model_name,
2507
+ anthropic_format: model_cfg["anthropic_format"] || false
2508
+ )
2509
+
2510
+ # Override Faraday timeouts via a short-lived env var isn't ideal;
2511
+ # instead we rely on test_connection's own network path and wrap
2512
+ # the call in Timeout as a last line of defence. Most providers
2513
+ # respond within 2-3s for a 16-token reply.
2514
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
2515
+ result = nil
2516
+ begin
2517
+ Timeout.timeout(timeout_sec) { result = client.test_connection(model: model_name) }
2518
+ rescue Timeout::Error
2519
+ return base.merge(ok: false, error: "timeout after #{timeout_sec}s")
2520
+ end
2521
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
2522
+
2523
+ if result && result[:success]
2524
+ base.merge(ok: true, ttft_ms: ((t1 - t0) * 1000).round)
2525
+ else
2526
+ base.merge(ok: false, error: (result && result[:error]).to_s[0, 200])
2527
+ end
2528
+ rescue => e
2529
+ base.merge(ok: false, error: "#{e.class}: #{e.message}"[0, 200])
2530
+ end
2531
+
2532
+
2533
+ def api_change_session_working_dir(session_id, req, res)
2534
+ body = parse_json_body(req)
2535
+ new_dir = body["working_dir"].to_s.strip
2536
+
2537
+ return json_response(res, 400, { error: "working_dir is required" }) if new_dir.empty?
2538
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
2539
+
2540
+ # Expand ~ to home directory
2541
+ expanded_dir = File.expand_path(new_dir)
2542
+
2543
+ # Validate directory exists
2544
+ unless Dir.exist?(expanded_dir)
2545
+ return json_response(res, 400, { error: "Directory does not exist: #{expanded_dir}" })
2546
+ end
2547
+
2548
+ agent = nil
2549
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
2550
+
2551
+ # Change the agent's working directory
2552
+ agent.change_working_dir(expanded_dir)
2553
+
2554
+ # Persist the change
2555
+ @session_manager.save(agent.to_session_data)
2556
+
2557
+ # Broadcast update to all clients
2558
+ broadcast_session_update(session_id)
2559
+
2560
+ json_response(res, 200, { ok: true, working_dir: expanded_dir })
2561
+ rescue => e
2562
+ json_response(res, 500, { error: e.message })
2563
+ end
2564
+
2565
+ def api_delete_session(session_id, res)
2566
+ # A session exists if it's either in the runtime registry OR on disk.
2567
+ # Old sessions that were never restored into memory this server run
2568
+ # (e.g. shown via "load more" in the WebUI list) are disk-only — we
2569
+ # must still be able to delete them. Previously this endpoint only
2570
+ # consulted @registry and returned 404 for disk-only sessions,
2571
+ # causing the "can't delete old sessions" bug.
2572
+ in_registry = @registry.exist?(session_id)
2573
+ on_disk = !@session_manager.load(session_id).nil?
2574
+
2575
+ unless in_registry || on_disk
2576
+ return json_response(res, 404, { error: "Session not found" })
2577
+ end
2578
+
2579
+ # Registry delete is best-effort — only meaningful when the session
2580
+ # is actually live (cancels idle timer, interrupts the agent thread).
2581
+ # For disk-only sessions this is a no-op and returns false, which is
2582
+ # fine and no longer blocks the disk cleanup below.
2583
+ @registry.delete(session_id) if in_registry
2584
+
2585
+ # Always physically remove the persisted session file (+ chunks).
2586
+ @session_manager.delete(session_id) if on_disk
2587
+
2588
+ # Notify any still-connected clients (mainly matters when the
2589
+ # session was live, but harmless otherwise).
2590
+ broadcast(session_id, { type: "session_deleted", session_id: session_id })
2591
+ unsubscribe_all(session_id)
2592
+
2593
+ json_response(res, 200, { ok: true })
2594
+ end
2595
+
2596
+ # Export a session bundle as a .zip download containing:
2597
+ # - session.json (always)
2598
+ # - chunk-*.md (0..N archived conversation chunks)
2599
+ # - logs/octo-YYYY-MM-DD.log (today's logger file, if present)
2600
+ # Useful for debugging — user clicks "download" in the WebUI status bar
2601
+ # and we can ask them to attach the zip to a bug report.
2602
+ def api_export_session(session_id, res)
2603
+ bundle = @session_manager.files_for(session_id)
2604
+ unless bundle
2605
+ return json_response(res, 404, { error: "Session not found" })
2606
+ end
2607
+
2608
+ require "zip"
2609
+
2610
+ short_id = bundle[:session][:session_id].to_s[0..7]
2611
+ # Build the zip entirely in memory — session files are small (< few MB).
2612
+ buffer = Zip::OutputStream.write_buffer do |zos|
2613
+ zos.put_next_entry("session.json")
2614
+ zos.write(File.binread(bundle[:json_path]))
2615
+
2616
+ bundle[:chunks].each do |chunk_path|
2617
+ # Preserve original chunk filename so the ordering (chunk-1.md, chunk-2.md, ...) is clear.
2618
+ zos.put_next_entry(File.basename(chunk_path))
2619
+ zos.write(File.binread(chunk_path))
2620
+ end
2621
+
2622
+ log_path = Octo::Logger.current_log_file
2623
+ if log_path && File.exist?(log_path)
2624
+ zos.put_next_entry("logs/#{File.basename(log_path)}")
2625
+ zos.write(File.binread(log_path))
2626
+ end
2627
+ end
2628
+ buffer.rewind
2629
+ data = buffer.read
2630
+
2631
+ filename = "octo-session-#{short_id}.zip"
2632
+ res.status = 200
2633
+ res.content_type = "application/zip"
2634
+ res["Content-Disposition"] = %(attachment; filename="#{filename}")
2635
+ res["Access-Control-Allow-Origin"] = "*"
2636
+ # Force a fresh copy each time — debugging sessions get new chunks over time.
2637
+ res["Cache-Control"] = "no-store"
2638
+ res.body = data
2639
+ rescue => e
2640
+ Octo::Logger.error("Session export failed: #{e.message}") if defined?(Octo::Logger)
2641
+ json_response(res, 500, { error: "Export failed: #{e.message}" })
2642
+ end
2643
+
2644
+ # ── WebSocket ─────────────────────────────────────────────────────────────
2645
+
2646
+ def websocket_upgrade?(req)
2647
+ req["Upgrade"]&.downcase == "websocket"
2648
+ end
2649
+
2650
+ # Hijacks the TCP socket from WEBrick and upgrades it to WebSocket.
2651
+ def handle_websocket(req, res)
2652
+ socket = req.instance_variable_get(:@socket)
2653
+
2654
+ # Server handshake — parse the upgrade request
2655
+ handshake = WebSocket::Handshake::Server.new
2656
+ handshake << build_handshake_request(req)
2657
+ unless handshake.finished? && handshake.valid?
2658
+ Octo::Logger.warn("WebSocket handshake invalid")
2659
+ return
2660
+ end
2661
+
2662
+ # Send the 101 Switching Protocols response
2663
+ socket.write(handshake.to_s)
2664
+
2665
+ version = handshake.version
2666
+ incoming = WebSocket::Frame::Incoming::Server.new(version: version)
2667
+ conn = WebSocketConnection.new(socket, version)
2668
+
2669
+ on_ws_open(conn)
2670
+
2671
+ begin
2672
+ buf = String.new("", encoding: "BINARY")
2673
+ loop do
2674
+ chunk = socket.read_nonblock(4096, buf, exception: false)
2675
+ case chunk
2676
+ when :wait_readable
2677
+ IO.select([socket], nil, nil, 30)
2678
+ when nil
2679
+ break # EOF
2680
+ else
2681
+ incoming << chunk.dup
2682
+ while (frame = incoming.next)
2683
+ case frame.type
2684
+ when :text
2685
+ on_ws_message(conn, frame.data)
2686
+ when :binary
2687
+ on_ws_message(conn, frame.data)
2688
+ when :ping
2689
+ conn.send_raw(:pong, frame.data)
2690
+ when :close
2691
+ conn.send_raw(:close, "")
2692
+ break
2693
+ end
2694
+ end
2695
+ end
2696
+ end
2697
+ rescue IOError, Errno::ECONNRESET, Errno::EPIPE, Errno::EBADF
2698
+ # Client disconnected or socket became invalid
2699
+ ensure
2700
+ on_ws_close(conn)
2701
+ socket.close rescue nil
2702
+ end
2703
+
2704
+ # Tell WEBrick not to send any response (we handled everything)
2705
+ res.instance_variable_set(:@header, {})
2706
+ res.status = -1
2707
+ rescue => e
2708
+ Octo::Logger.error("WebSocket handler error: #{e.class}: #{e.message}")
2709
+ end
2710
+
2711
+ # Build a raw HTTP request string from WEBrick request for WebSocket::Handshake::Server
2712
+ private def build_handshake_request(req)
2713
+ lines = ["#{req.request_method} #{req.request_uri.request_uri} HTTP/1.1\r\n"]
2714
+ req.each { |k, v| lines << "#{k}: #{v}\r\n" }
2715
+ lines << "\r\n"
2716
+ lines.join
2717
+ end
2718
+
2719
+ def on_ws_open(conn)
2720
+ @ws_mutex.synchronize { @all_ws_conns << conn }
2721
+ # Client will send a "subscribe" message to bind to a session
2722
+ end
2723
+
2724
+ def on_ws_message(conn, raw)
2725
+ msg = JSON.parse(raw)
2726
+ unless msg.is_a?(Hash)
2727
+ Octo::Logger.warn("[on_ws_message] Ignoring non-Hash message: #{raw[0,200].inspect}")
2728
+ return
2729
+ end
2730
+ type = msg["type"]
2731
+
2732
+ case type
2733
+ when "subscribe"
2734
+ session_id = msg["session_id"]
2735
+ if @registry.ensure(session_id)
2736
+ conn.session_id = session_id
2737
+ subscribe(session_id, conn)
2738
+ conn.send_json(type: "subscribed", session_id: session_id)
2739
+ # Push a fresh snapshot so a reconnecting tab sees the true current
2740
+ # status (it may have missed session_update events while offline).
2741
+ if (snap = @registry.snapshot(session_id))
2742
+ conn.send_json(type: "session_update", session: snap)
2743
+ end
2744
+ # If a shell command is still running, replay progress + buffered stdout
2745
+ # to the newly subscribed tab so it sees the live state it may have missed.
2746
+ @registry.with_session(session_id) { |s| s[:ui]&.replay_live_state }
2747
+ # Replay inbox queue status AND pending message content so the
2748
+ # reconnected tab sees both the count hint AND the pending
2749
+ # ghost bubbles for messages still waiting to be drained.
2750
+ @registry.with_session(session_id) do |s|
2751
+ if (agent = s[:agent])
2752
+ pending = agent.inbox_user_message_count
2753
+ if pending > 0
2754
+ s[:ui]&.update_user_message_queue_status(pending: pending)
2755
+ conn.send_json({
2756
+ type: "pending_user_messages",
2757
+ session_id: session_id,
2758
+ messages: agent.inbox_user_messages_snapshot
2759
+ })
2760
+ end
2761
+ end
2762
+ end
2763
+ # Push the current background-task badge so a refreshed tab doesn't
2764
+ # lose track of in-flight async tasks.
2765
+ _push_background_tasks_snapshot(session_id, conn)
2766
+ else
2767
+ conn.send_json(type: "error", message: "Session not found: #{session_id}")
2768
+ end
2769
+
2770
+ when "message"
2771
+ session_id = msg["session_id"] || conn.session_id
2772
+ # Merge legacy images array into files as { data_url:, name:, mime_type: } entries
2773
+ raw_images = (msg["images"] || []).map do |data_url|
2774
+ { "data_url" => data_url, "name" => "image.jpg", "mime_type" => "image/jpeg" }
2775
+ end
2776
+ handle_user_message(session_id, msg["content"].to_s, (msg["files"] || []) + raw_images)
2777
+
2778
+ when "confirmation"
2779
+ session_id = msg["session_id"] || conn.session_id
2780
+ deliver_confirmation(session_id, msg["id"], msg["result"])
2781
+
2782
+ when "interrupt"
2783
+ session_id = msg["session_id"] || conn.session_id
2784
+ interrupt_session(session_id)
2785
+
2786
+ when "list_sessions"
2787
+ # Initial load: newest 20 sessions regardless of source/profile.
2788
+ # Single unified query — frontend shows all in one time-sorted list.
2789
+ page = @registry.list(limit: 21)
2790
+ has_more = page.size > 20
2791
+ all_sessions = page.first(20)
2792
+ conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more, cron_count: @registry.cron_count)
2793
+
2794
+ when "run_task"
2795
+ # Client sends this after subscribing to guarantee it's ready to receive
2796
+ # broadcasts before the agent starts executing.
2797
+ session_id = msg["session_id"] || conn.session_id
2798
+ start_pending_task(session_id)
2799
+
2800
+ when "ping"
2801
+ conn.send_json(type: "pong")
2802
+
2803
+ else
2804
+ conn.send_json(type: "error", message: "Unknown message type: #{type}")
2805
+ end
2806
+ rescue JSON::ParserError => e
2807
+ conn.send_json(type: "error", message: "Invalid JSON: #{e.message}")
2808
+ rescue => e
2809
+ Octo::Logger.error("[on_ws_message] #{e.class}: #{e.message}\n#{e.backtrace.first(10).join("\n")}")
2810
+ conn.send_json(type: "error", message: e.message)
2811
+ end
2812
+
2813
+ def on_ws_close(conn)
2814
+ @ws_mutex.synchronize { @all_ws_conns.delete(conn) }
2815
+ unsubscribe(conn)
2816
+ end
2817
+
2818
+ # ── Session actions ───────────────────────────────────────────────────────
2819
+
2820
+ def handle_user_message(session_id, content, files = [])
2821
+ return unless @registry.exist?(session_id)
2822
+
2823
+ agent = nil
2824
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
2825
+ return unless agent
2826
+
2827
+ # Auto-name the session from the first user message (before agent starts running).
2828
+ # Skip if the name looks like it was set by the user (not a system-generated "Session N").
2829
+ if agent.history.empty? && agent.name.match?(/\ASession \d+\z/)
2830
+ auto_name = content.gsub(/\s+/, " ").strip[0, 30]
2831
+ auto_name += "…" if content.strip.length > 30
2832
+ agent.rename(auto_name)
2833
+ broadcast(session_id, { type: "session_renamed", session_id: session_id, name: auto_name })
2834
+ end
2835
+
2836
+ # The frontend always renders a ghost bubble on send. The real
2837
+ # bubble is rendered by the agent when it drains the inbox —
2838
+ # this avoids duplicate bubbles for idle agents.
2839
+
2840
+ # Enqueue — files (if any) are processed eagerly on this HTTP thread
2841
+ # inside enqueue_user_message, so the inbox carries a fully-formed
2842
+ # payload by the time it lands. The agent decides whether to drain
2843
+ # in an in-flight run or spawn a fresh drain-only one.
2844
+ decision = agent.enqueue_user_message(content, files: files)
2845
+ case decision
2846
+ when :running, :spawn_pending
2847
+ # Existing or imminent run will drain the inbox at its next
2848
+ # iteration. Nothing more to do here.
2849
+ return
2850
+ when :spawn
2851
+ # Agent was idle and we won the right to spawn. Kick off a
2852
+ # drain-only run; it will pick up our queued message (and any
2853
+ # other items queued concurrently) at iteration top.
2854
+ run_agent_task(session_id, agent) { agent.run }
2855
+ end
2856
+ end
2857
+
2858
+ def deliver_confirmation(session_id, conf_id, result)
2859
+ ui = nil
2860
+ @registry.with_session(session_id) { |s| ui = s[:ui] }
2861
+ ui&.deliver_confirmation(conf_id, result)
2862
+ end
2863
+
2864
+ # Push a fresh background-tasks snapshot to a newly subscribed WS
2865
+ # client so the badge survives page refresh. Mirrors the format
2866
+ # produced by WebUiController#update_background_tasks.
2867
+ private def _push_background_tasks_snapshot(session_id, conn)
2868
+ now = Time.now
2869
+ tasks = BackgroundTaskRegistry.list_running(agent_session_id: session_id).map do |t|
2870
+ cmd = (t[:command] || "").to_s
2871
+ cmd = "#{cmd[0, 80]}…" if cmd.length > 80
2872
+ elapsed = t[:started_at] ? (now - t[:started_at]).round : 0
2873
+ { handle_id: t[:handle_id].to_s, command: cmd, elapsed: elapsed }
2874
+ end
2875
+ conn.send_json(type: "background_tasks_update", session_id: session_id, running: tasks.size, tasks: tasks)
2876
+ rescue => e
2877
+ Octo::Logger.warn("[WS] background_tasks_snapshot error: #{e.message}")
2878
+ end
2879
+
2880
+ # Interrupt a running agent session.
2881
+ #
2882
+ # Thread#raise alone is not reliable enough in practice — it's
2883
+ # best-effort against blocked syscalls (socket writes, OpenSSL read,
2884
+ # ConditionVariable#wait with a held mutex) and we've seen sessions
2885
+ # that stay "running" forever even after multiple interrupt attempts.
2886
+ #
2887
+ # Strategy: three-tier escalation in a background watchdog Thread so
2888
+ # the HTTP handler returns immediately.
2889
+ #
2890
+ # Tier 1 (t=0): Thread#raise(AgentInterrupted).
2891
+ # Unblocks most pure-Ruby waits and Faraday reads.
2892
+ # Handles the common case.
2893
+ # Tier 2 (t=3): force-close this session's WebSocket connections
2894
+ # so any send_raw stuck on socket write wakes up.
2895
+ # Try Thread#raise again (idempotent).
2896
+ # Tier 3 (t=8): Thread#kill — last resort. Leaks any held
2897
+ # resources but frees the session so the user can
2898
+ # move on.
2899
+ #
2900
+ # Each transition is logged so that when users report "stuck
2901
+ # sessions" we can see in the log whether tier 2/3 ever had to
2902
+ # fire — that's our signal to dig deeper on the underlying block.
2903
+ def interrupt_session(session_id)
2904
+ thread = nil
2905
+ agent = nil
2906
+ @registry.with_session(session_id) do |s|
2907
+ s[:idle_timer]&.cancel
2908
+ thread = s[:thread]
2909
+ agent = s[:agent]
2910
+
2911
+ if thread&.alive?
2912
+ Octo::Logger.info("[interrupt] session=#{session_id} tier=1 raise")
2913
+ begin
2914
+ thread.raise(Octo::AgentInterrupted, "Interrupted by user")
2915
+ rescue ThreadError => e
2916
+ Octo::Logger.warn("[interrupt] tier=1 raise failed: #{e.message}")
2917
+ end
2918
+ end
2919
+ end
2920
+
2921
+ # Also set @discard_threshold + raise into Agent's tracked run thread.
2922
+ # This covers drain-only inbox runs that may bypass session[:thread].
2923
+ agent&.interrupt_current_run!
2924
+
2925
+ return unless thread&.alive?
2926
+
2927
+ start_interrupt_watchdog(session_id, thread)
2928
+ end
2929
+
2930
+ # Background watchdog: escalates from WebSocket force-close (tier 2)
2931
+ # to Thread#kill (tier 3) if the agent thread refuses to die.
2932
+ private def start_interrupt_watchdog(session_id, thread)
2933
+ Thread.new do
2934
+ Thread.current.name = "interrupt-watchdog[#{session_id}]" rescue nil
2935
+
2936
+ # Give the first Thread#raise a few seconds to unwind.
2937
+ sleep 3
2938
+ next unless thread.alive?
2939
+
2940
+ Octo::Logger.warn(
2941
+ "[interrupt] session=#{session_id} tier=2 raise failed after 3s, " \
2942
+ "force-closing session resources"
2943
+ )
2944
+ force_close_session_sockets(session_id)
2945
+ # Re-raise — sometimes the first raise was swallowed deep in a
2946
+ # C-extension syscall; after we force-close the socket the
2947
+ # syscall returns and the next raise sticks.
2948
+ begin
2949
+ thread.raise(Octo::AgentInterrupted, "Interrupted by user (escalated)")
2950
+ rescue ThreadError
2951
+ # already dead between checks — fine
2952
+ end
2953
+
2954
+ sleep 5
2955
+ next unless thread.alive?
2956
+
2957
+ Octo::Logger.error(
2958
+ "[interrupt] session=#{session_id} tier=3 still alive after 8s, Thread#kill"
2959
+ )
2960
+ begin
2961
+ thread.kill
2962
+ rescue StandardError => e
2963
+ Octo::Logger.error("[interrupt] Thread#kill raised: #{e.class}: #{e.message}")
2964
+ end
2965
+
2966
+ # Record the forced-kill so the UI can show a warning and operators
2967
+ # can correlate with any backtrace dumps. The session is left in
2968
+ # :idle state by run_agent_task's rescue clause; if the kill
2969
+ # happened before the rescue could run, patch the state directly.
2970
+ begin
2971
+ @registry.update(session_id, status: :idle, error: "Force-killed (interrupt watchdog)")
2972
+ broadcast_session_update(session_id)
2973
+ rescue StandardError
2974
+ # best effort
2975
+ end
2976
+ end
2977
+ end
2978
+
2979
+ # Close every WebSocket connection bound to the given session. Used by
2980
+ # the interrupt watchdog to unblock agent threads stuck in a WS write.
2981
+ private def force_close_session_sockets(session_id)
2982
+ conns = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
2983
+ conns.each do |conn|
2984
+ Octo::Logger.warn(
2985
+ "[interrupt] session=#{session_id} force-closing WS conn"
2986
+ )
2987
+ conn.force_close!
2988
+ end
2989
+ rescue StandardError => e
2990
+ Octo::Logger.error("[interrupt] force_close_session_sockets error: #{e.class}: #{e.message}")
2991
+ end
2992
+
2993
+ # Start the pending task for a session.
2994
+ # Called when the client sends "run_task" over WS — by that point the
2995
+ # client has already subscribed, so every broadcast will be delivered.
2996
+ def start_pending_task(session_id)
2997
+ return unless @registry.exist?(session_id)
2998
+
2999
+ session = @registry.get(session_id)
3000
+ prompt = session[:pending_task]
3001
+ working_dir = session[:pending_working_dir]
3002
+ return unless prompt # nothing pending
3003
+
3004
+ # Clear the pending fields so a re-connect doesn't re-run
3005
+ @registry.update(session_id, pending_task: nil, pending_working_dir: nil)
3006
+
3007
+ agent = nil
3008
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
3009
+ return unless agent
3010
+
3011
+ run_agent_task(session_id, agent) { agent.run(prompt) }
3012
+ end
3013
+
3014
+ # Interrupt every running agent thread and persist its session state.
3015
+ private def interrupt_all_agents
3016
+ return unless @registry && @session_manager
3017
+
3018
+ @registry.each_live_agent do |id, agent, thread|
3019
+ next unless thread&.alive?
3020
+ begin
3021
+ thread.raise(Octo::AgentInterrupted, "Worker shutting down")
3022
+ Octo::Logger.info("[shutdown] interrupted session=#{id}")
3023
+ rescue => e
3024
+ Octo::Logger.error("[shutdown] interrupt failed for session=#{id}: #{e.message}")
3025
+ end
3026
+ thread.join(2)
3027
+ @session_manager.save(agent.to_session_data(status: :interrupted))
3028
+ end
3029
+ end
3030
+
3031
+ # Run an agent task in a background thread, handling status updates,
3032
+ # session persistence, and idle compression timer lifecycle.
3033
+ # Yields to the caller to perform the actual agent.run call.
3034
+ private def run_agent_task(session_id, agent, &task)
3035
+ if @registry.running_full?
3036
+ broadcast(session_id, { type: "error", session_id: session_id,
3037
+ message: "Too many concurrent tasks (max #{@registry.max_running_agents}), please try again later" })
3038
+ return
3039
+ end
3040
+
3041
+ @registry.evict_excess_idle!
3042
+
3043
+ idle_timer = nil
3044
+ @registry.with_session(session_id) { |s| idle_timer = s[:idle_timer] }
3045
+
3046
+ # Cancel any pending idle compression before starting a new task
3047
+ idle_timer&.cancel
3048
+
3049
+ @registry.update(session_id, status: :running)
3050
+ broadcast_session_update(session_id)
3051
+
3052
+ # Wire up incremental checkpoint callback before the thread starts.
3053
+ # Each completed iteration (think + act + observe) appends new messages
3054
+ # to a .jsonl file so crash recovery can restore them on restart.
3055
+ agent.reset_checkpoint!
3056
+ agent.on_checkpoint do |msgs|
3057
+ @session_manager.append_message_log(session_id, msgs)
3058
+ end
3059
+
3060
+ thread = Thread.new do
3061
+ begin
3062
+ result = task.call
3063
+ @registry.update(session_id, status: :idle, error: nil)
3064
+ broadcast_session_update(session_id)
3065
+ @session_manager.save(agent.to_session_data(status: :success))
3066
+
3067
+ # If the agent run left user messages in the inbox, spawn a
3068
+ # registered drain-only run so they are processed under the same
3069
+ # interrupt / idle-timer lifecycle as any other task.
3070
+ if agent.inbox_user_message_count > 0
3071
+ run_agent_task(session_id, agent) { agent.run }
3072
+ end
3073
+
3074
+ # Start idle compression timer now that the agent is idle
3075
+ idle_timer&.start
3076
+ rescue Octo::AgentInterrupted
3077
+ @registry.update(session_id, status: :idle)
3078
+ broadcast_session_update(session_id)
3079
+ broadcast(session_id, { type: "interrupted", session_id: session_id })
3080
+ # Re-broadcast inbox queue status so the frontend shows the
3081
+ # accurate pending count after interruption (messages queued
3082
+ # during the run are preserved, not discarded).
3083
+ pending = agent.inbox_user_message_count
3084
+ s = nil
3085
+ @registry.with_session(session_id) { |sess| s = sess }
3086
+ s[:ui]&.update_user_message_queue_status(pending: pending)
3087
+ @session_manager.save(agent.to_session_data(status: :interrupted))
3088
+
3089
+ # If the inbox still has queued messages after interruption,
3090
+ # immediately resume draining them — don't go idle.
3091
+ if pending > 0
3092
+ run_agent_task(session_id, agent) { agent.run }
3093
+ end
3094
+ rescue => e
3095
+ @registry.update(session_id, status: :error, error: e.message)
3096
+ broadcast_session_update(session_id)
3097
+ # Route error through web_ui so channel subscribers (飞书/企微) receive it too.
3098
+ web_ui = nil
3099
+ @registry.with_session(session_id) { |s| web_ui = s[:ui] }
3100
+ web_ui&.show_error(e.message)
3101
+ @session_manager.save(agent.to_session_data(status: :error, error_message: e.message))
3102
+ ensure
3103
+ # Normal completion (success / interrupted / error) means the
3104
+ # full session has been saved to .json — the .jsonl is now
3105
+ # redundant. On server crash (kill -9) this ensure does NOT run,
3106
+ # leaving the .jsonl behind for recover_jsonl_sessions on restart.
3107
+ @session_manager.delete_message_log(session_id)
3108
+ agent.on_checkpoint(nil)
3109
+ end
3110
+ end
3111
+ @registry.with_session(session_id) { |s| s[:thread] = thread }
3112
+ end
3113
+
3114
+ # ── WebSocket subscription management ─────────────────────────────────────
3115
+
3116
+ def subscribe(session_id, conn)
3117
+ @ws_mutex.synchronize do
3118
+ # Remove conn from any previous session subscription first,
3119
+ # so switching sessions never results in duplicate delivery.
3120
+ @ws_clients.each_value { |list| list.delete(conn) }
3121
+ @ws_clients[session_id] ||= []
3122
+ @ws_clients[session_id] << conn unless @ws_clients[session_id].include?(conn)
3123
+ end
3124
+ end
3125
+
3126
+ def unsubscribe(conn)
3127
+ @ws_mutex.synchronize do
3128
+ @ws_clients.each_value { |list| list.delete(conn) }
3129
+ end
3130
+ end
3131
+
3132
+ def unsubscribe_all(session_id)
3133
+ @ws_mutex.synchronize { @ws_clients.delete(session_id) }
3134
+ end
3135
+
3136
+ # Broadcast an event to all clients subscribed to a session.
3137
+ # Dead connections (broken pipe / closed socket / deadline exceeded) are
3138
+ # removed automatically. Connections already marked closed are skipped
3139
+ # upfront so one sluggish client can't delay delivery to healthy ones.
3140
+ def broadcast(session_id, event)
3141
+ clients = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
3142
+ dead = []
3143
+ clients.each do |conn|
3144
+ if conn.closed?
3145
+ dead << conn
3146
+ next
3147
+ end
3148
+ dead << conn unless conn.send_json(event)
3149
+ end
3150
+ return if dead.empty?
3151
+
3152
+ @ws_mutex.synchronize do
3153
+ (@ws_clients[session_id] || []).reject! { |conn| dead.include?(conn) }
3154
+ @all_ws_conns.reject! { |conn| dead.include?(conn) }
3155
+ end
3156
+ end
3157
+
3158
+ # Broadcast an event to every connected client (regardless of session subscription).
3159
+ # Dead connections are removed automatically.
3160
+ def broadcast_all(event)
3161
+ clients = @ws_mutex.synchronize { @all_ws_conns.dup }
3162
+ dead = []
3163
+ clients.each do |conn|
3164
+ if conn.closed?
3165
+ dead << conn
3166
+ next
3167
+ end
3168
+ dead << conn unless conn.send_json(event)
3169
+ end
3170
+ return if dead.empty?
3171
+
3172
+ @ws_mutex.synchronize do
3173
+ @all_ws_conns.reject! { |conn| dead.include?(conn) }
3174
+ @ws_clients.each_value { |list| list.reject! { |conn| dead.include?(conn) } }
3175
+ end
3176
+ end
3177
+
3178
+ # Broadcast a session_update event to all clients so they can patch their
3179
+ # local session list without needing a full session_list refresh.
3180
+ def broadcast_session_update(session_id)
3181
+ session = @registry.snapshot(session_id)
3182
+ return unless session
3183
+
3184
+ broadcast_all(type: "session_update", session: session)
3185
+ end
3186
+
3187
+ # ── Helpers ───────────────────────────────────────────────────────────────
3188
+
3189
+ def default_working_dir
3190
+ @agent_config&.default_working_dir || File.expand_path("~/octo_workspace")
3191
+ end
3192
+
3193
+ # Create a session in the registry and wire up Agent + WebUIController.
3194
+ # Returns the new session_id.
3195
+ # Build a new agent session.
3196
+ # @param name [String] display name for the session
3197
+ # @param working_dir [String] working directory for the agent
3198
+ # @param permission_mode [Symbol] :confirm_all (default, human present) or
3199
+ # :auto_approve (unattended — suppresses request_user_feedback waits)
3200
+ def build_session(name:, working_dir:, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil)
3201
+ session_id = Octo::SessionManager.generate_id
3202
+ @registry.create(session_id: session_id)
3203
+
3204
+ config = @agent_config.deep_copy
3205
+ config.permission_mode = permission_mode
3206
+
3207
+ # Apply model override BEFORE creating the client — otherwise the
3208
+ # client is built from the default model entry and may route through
3209
+ # the wrong provider (e.g. sending a deepseek-v4-pro request to the
3210
+ # Bedrock-format Octo endpoint, which replies "unknown model").
3211
+ #
3212
+ # We use switch_model_by_id (not a name-based rewrite of
3213
+ # current_model["model"]) because:
3214
+ # 1. Ids uniquely identify an entry across providers; names can
3215
+ # collide between entries (deepseek vs dsk-deepseek aliases).
3216
+ # 2. switch_model_by_id only flips per-session @current_model_id
3217
+ # in the dup'd config — it never mutates the shared @models
3218
+ # array (see AgentConfig#deep_copy's shared-ref contract).
3219
+ # A name rewrite would have leaked into every live session
3220
+ # AND corrupted the on-disk config at next save.
3221
+ config.switch_model_by_id(model_id) if model_id
3222
+
3223
+ # Build client from the (possibly overridden) config so api format
3224
+ # detection (Bedrock vs OpenAI vs Anthropic) uses the correct model.
3225
+ client = Octo::Client.new(
3226
+ config.api_key,
3227
+ base_url: config.base_url,
3228
+ model: config.model_name,
3229
+ anthropic_format: config.anthropic_format?
3230
+ )
3231
+
3232
+ broadcaster = method(:broadcast)
3233
+ ui = WebUIController.new(session_id, broadcaster)
3234
+ agent = Octo::Agent.new(client, config, working_dir: working_dir, ui: ui, profile: profile,
3235
+ session_id: session_id, source: source)
3236
+ agent.rename(name) unless name.nil? || name.empty?
3237
+ idle_timer = build_idle_timer(session_id, agent)
3238
+
3239
+ @registry.with_session(session_id) do |s|
3240
+ s[:agent] = agent
3241
+ s[:ui] = ui
3242
+ s[:idle_timer] = idle_timer
3243
+ end
3244
+
3245
+ # Persist an initial snapshot so the session is immediately visible in registry.list
3246
+ # (which reads from disk). Without this, new sessions only appear after their first task.
3247
+ @session_manager.save(agent.to_session_data)
3248
+
3249
+ session_id
3250
+ end
3251
+
3252
+ # Restore a persisted session from saved session_data (from SessionManager).
3253
+ # The agent keeps its original session_id so the frontend URL hash stays valid
3254
+ # across server restarts.
3255
+ def build_session_from_data(session_data, permission_mode: :confirm_all)
3256
+ original_id = session_data[:session_id]
3257
+
3258
+ client = @client_factory.call
3259
+ config = @agent_config.deep_copy
3260
+ config.permission_mode = permission_mode
3261
+ broadcaster = method(:broadcast)
3262
+ ui = WebUIController.new(original_id, broadcaster)
3263
+ # Restore the agent profile from the persisted session; fall back to "general"
3264
+ # for sessions saved before the agent_profile field was introduced.
3265
+ profile = session_data[:agent_profile].to_s
3266
+ profile = "general" if profile.empty?
3267
+ agent = Octo::Agent.from_session(client, config, session_data, ui: ui, profile: profile)
3268
+ idle_timer = build_idle_timer(original_id, agent)
3269
+
3270
+ # Register session atomically with a fully-built agent so no concurrent
3271
+ # caller ever sees agent=nil for this session. The duplicate-restore guard
3272
+ # is handled upstream by SessionRegistry#ensure via @restoring.
3273
+ @registry.create(session_id: original_id)
3274
+ @registry.with_session(original_id) do |s|
3275
+ s[:agent] = agent
3276
+ s[:ui] = ui
3277
+ s[:idle_timer] = idle_timer
3278
+ end
3279
+
3280
+ original_id
3281
+ end
3282
+
3283
+ # Build an IdleCompressionTimer for a session.
3284
+ # Broadcasts session_update after successful compression so clients see the new cost.
3285
+ private def build_idle_timer(session_id, agent)
3286
+ Octo::IdleCompressionTimer.new(
3287
+ agent: agent,
3288
+ session_manager: @session_manager
3289
+ ) do |_success|
3290
+ broadcast_session_update(session_id)
3291
+ end
3292
+ end
3293
+
3294
+ # Mask API key for display: show first 8 + last 4 chars, middle replaced with ****
3295
+ # Mask an api_key for safe display / transport to the browser.
3296
+ #
3297
+ # Contract: the returned string MUST contain "****" so callers (incl.
3298
+ # the frontend) can reliably detect "this is a display placeholder,
3299
+ # not a real key" and refuse to treat it as input. The old behaviour
3300
+ # of returning the raw value for short keys was a correctness bug —
3301
+ # it leaked short keys in plaintext to GET /api/config, and it let
3302
+ # short masked values slip past the frontend's mask-detection.
3303
+ def mask_api_key(key)
3304
+ return "" if key.nil? || key.empty?
3305
+ if key.length <= 12
3306
+ # Very short key — show the first char only, redact the rest.
3307
+ return "#{key[0]}****"
3308
+ end
3309
+ "#{key[0..7]}****#{key[-4..]}"
3310
+ end
3311
+
3312
+ def json_response(res, status, data)
3313
+ res.status = status
3314
+ res.content_type = "application/json; charset=utf-8"
3315
+ res["Access-Control-Allow-Origin"] = "*"
3316
+ res.body = JSON.generate(data)
3317
+ end
3318
+
3319
+ def parse_json_body(req)
3320
+ return {} if req.body.nil? || req.body.empty?
3321
+
3322
+ JSON.parse(req.body)
3323
+ rescue JSON::ParserError
3324
+ {}
3325
+ end
3326
+
3327
+ # Parse a multipart/form-data request body to extract a single file upload.
3328
+ # Returns { filename:, data: } or nil when the field is not found.
3329
+ # This is a lightweight parser that handles the standard WEBrick multipart format.
3330
+ #
3331
+ # @param req [WEBrick::HTTPRequest]
3332
+ # @param field_name [String] The form field name to look for
3333
+ # @return [Hash, nil] { filename: String, data: String (binary) }
3334
+ private def parse_multipart_upload(req, field_name)
3335
+ content_type = req["Content-Type"].to_s
3336
+ return nil unless content_type.include?("multipart/form-data")
3337
+
3338
+ # Extract boundary from Content-Type header
3339
+ boundary_match = content_type.match(/boundary=([^\s;]+)/)
3340
+ return nil unless boundary_match
3341
+
3342
+ boundary = "--" + boundary_match[1].strip.gsub(/^"(.*)"$/, '')
3343
+ body = req.body.to_s.b # treat as binary
3344
+
3345
+ # Split body by boundary and find the target field
3346
+ parts = body.split(Regexp.new(Regexp.escape(boundary)))
3347
+ parts.each do |part|
3348
+ # Each part has headers, then blank line, then body
3349
+ # Use \r\n\r\n or \n\n as separator between headers and body
3350
+ header_body_sep = part.index("\r\n\r\n") || part.index("\n\n")
3351
+ next unless header_body_sep
3352
+
3353
+ sep_len = part[header_body_sep, 4] == "\r\n\r\n" ? 4 : 2
3354
+ raw_headers = part[0, header_body_sep]
3355
+ raw_body = part[(header_body_sep + sep_len)..]
3356
+
3357
+ # Remove trailing CRLF from part body
3358
+ raw_body = raw_body.sub(/\r\n\z/, "").sub(/\n\z/, "")
3359
+
3360
+ # Check Content-Disposition for our field name
3361
+ next unless raw_headers.include?("Content-Disposition")
3362
+
3363
+ name_match = raw_headers.match(/name="([^"]+)"/)
3364
+ next unless name_match && name_match[1] == field_name
3365
+
3366
+ file_match = raw_headers.match(/filename="([^"]*)"/)
3367
+ filename = file_match ? file_match[1] : field_name
3368
+
3369
+ return { filename: filename, data: raw_body }
3370
+ end
3371
+
3372
+ nil
3373
+ end
3374
+
3375
+ def not_found(res)
3376
+ res.status = 404
3377
+ res.body = "Not Found"
3378
+ end
3379
+
3380
+ # ── Inner classes ─────────────────────────────────────────────────────────
3381
+
3382
+ # Wraps a raw TCP socket, providing thread-safe WebSocket frame sending.
3383
+ #
3384
+ # IMPORTANT: send_raw is called from the Agent thread via broadcast() →
3385
+ # send_json(). A blocking socket write with no deadline can pin the Agent
3386
+ # thread indefinitely when the client's receive buffer fills up (silent
3387
+ # disconnects such as Wi-Fi handoff or NAT timeout, where TCP keepalive
3388
+ # defaults are measured in hours). Thread#raise on blocking native socket
3389
+ # writes is best-effort and unreliable, so instead we bound every write
3390
+ # with an explicit deadline using IO.select + write_nonblock and declare
3391
+ # the connection dead on timeout.
3392
+ class WebSocketConnection
3393
+ attr_accessor :session_id
3394
+
3395
+ # Maximum time a single send_raw call is allowed to spend writing.
3396
+ # 5 seconds is generous for healthy LAN/Internet clients and short
3397
+ # enough that a stuck Agent becomes responsive again quickly.
3398
+ SEND_DEADLINE = 5.0
3399
+
3400
+ # Warn threshold — any individual send_raw that exceeds this is logged
3401
+ # so we can spot sluggish clients before they fully hang.
3402
+ SEND_SLOW_WARN = 1.0
3403
+
3404
+ def initialize(socket, version)
3405
+ @socket = socket
3406
+ @version = version
3407
+ @send_mutex = Mutex.new
3408
+ @closed = false
3409
+ WebSocketConnection.apply_keepalive(socket)
3410
+ end
3411
+
3412
+ # Returns true if the underlying socket has been detected as dead.
3413
+ def closed?
3414
+ @closed
3415
+ end
3416
+
3417
+ # Force-close the connection (used by the interrupt watchdog when an
3418
+ # Agent thread is stuck on an unresponsive socket write).
3419
+ def force_close!
3420
+ @closed = true
3421
+ @socket.close
3422
+ rescue StandardError
3423
+ # best effort
3424
+ end
3425
+
3426
+ # Send a JSON-serializable object over the WebSocket.
3427
+ # Returns true on success, false if the connection is dead.
3428
+ def send_json(data)
3429
+ send_raw(:text, JSON.generate(data))
3430
+ rescue => e
3431
+ Octo::Logger.debug("WS send error (connection dead): #{e.message}")
3432
+ false
3433
+ end
3434
+
3435
+ # Send a raw WebSocket frame.
3436
+ # Returns true on success, false on broken/closed/sluggish socket.
3437
+ #
3438
+ # Uses write_nonblock with an overall deadline so the caller (typically
3439
+ # the Agent thread) never blocks longer than SEND_DEADLINE, even if the
3440
+ # client silently stopped reading.
3441
+ def send_raw(type, data)
3442
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
3443
+
3444
+ @send_mutex.synchronize do
3445
+ return false if @closed
3446
+
3447
+ outgoing = WebSocket::Frame::Outgoing::Server.new(
3448
+ version: @version,
3449
+ data: data,
3450
+ type: type
3451
+ )
3452
+ bytes = outgoing.to_s
3453
+
3454
+ unless write_with_deadline(bytes, SEND_DEADLINE)
3455
+ # Deadline exceeded — treat as a dead connection so broadcast
3456
+ # purges it and the Agent thread is freed immediately.
3457
+ @closed = true
3458
+ begin
3459
+ @socket.close
3460
+ rescue StandardError
3461
+ # ignore
3462
+ end
3463
+ Octo::Logger.warn(
3464
+ "[WS] send_raw deadline exceeded — closing sluggish connection " \
3465
+ "(bytes=#{bytes.bytesize}, deadline=#{SEND_DEADLINE}s)"
3466
+ )
3467
+ return false
3468
+ end
3469
+ end
3470
+
3471
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
3472
+ if elapsed > SEND_SLOW_WARN
3473
+ Octo::Logger.warn(
3474
+ "[WS] send_raw slow: #{elapsed.round(2)}s (type=#{type})"
3475
+ )
3476
+ end
3477
+ true
3478
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError, Errno::EBADF => e
3479
+ @closed = true
3480
+ Octo::Logger.debug("WS send_raw error (client disconnected): #{e.message}")
3481
+ false
3482
+ rescue => e
3483
+ @closed = true
3484
+ Octo::Logger.debug("WS send_raw unexpected error: #{e.message}")
3485
+ false
3486
+ end
3487
+
3488
+ # Write `data` to the underlying socket, bounded by `deadline` seconds
3489
+ # of *total* wall time across partial writes. Returns true on full
3490
+ # success, false on timeout.
3491
+ private def write_with_deadline(data, deadline)
3492
+ remaining = data
3493
+ deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + deadline
3494
+
3495
+ until remaining.empty?
3496
+ time_left = deadline_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
3497
+ return false if time_left <= 0
3498
+
3499
+ begin
3500
+ written = @socket.write_nonblock(remaining, exception: false)
3501
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError, Errno::EBADF
3502
+ raise
3503
+ end
3504
+
3505
+ case written
3506
+ when :wait_writable
3507
+ ready = IO.select(nil, [@socket], nil, [time_left, 0.25].min)
3508
+ # Not ready → loop and re-check the overall deadline.
3509
+ next unless ready
3510
+ when Integer
3511
+ remaining = remaining.byteslice(written, remaining.bytesize - written)
3512
+ else
3513
+ # Nil or unexpected — treat as dead.
3514
+ return false
3515
+ end
3516
+ end
3517
+
3518
+ true
3519
+ end
3520
+
3521
+ # Enable TCP keepalive on the underlying socket so silently dead
3522
+ # peers are detected in minutes instead of the OS default of hours.
3523
+ # Best-effort: any failure is logged at debug level and ignored.
3524
+ def self.apply_keepalive(socket)
3525
+ return unless socket.respond_to?(:setsockopt)
3526
+
3527
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
3528
+
3529
+ # TCP-level keepalive tuning — constants vary by platform and are
3530
+ # only set when available. Values chosen to detect dead peers in
3531
+ # roughly 60-90 seconds total.
3532
+ if defined?(Socket::IPPROTO_TCP)
3533
+ # Idle time before first probe (Linux: TCP_KEEPIDLE, macOS: TCP_KEEPALIVE)
3534
+ idle_const = if Socket.const_defined?(:TCP_KEEPIDLE)
3535
+ Socket::TCP_KEEPIDLE
3536
+ elsif Socket.const_defined?(:TCP_KEEPALIVE)
3537
+ Socket::TCP_KEEPALIVE
3538
+ end
3539
+ socket.setsockopt(Socket::IPPROTO_TCP, idle_const, 60) if idle_const
3540
+
3541
+ if Socket.const_defined?(:TCP_KEEPINTVL)
3542
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPINTVL, 10)
3543
+ end
3544
+ if Socket.const_defined?(:TCP_KEEPCNT)
3545
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPCNT, 3)
3546
+ end
3547
+ end
3548
+ rescue StandardError => e
3549
+ Octo::Logger.debug("[WS] failed to set keepalive: #{e.class}: #{e.message}")
3550
+ end
3551
+ end
3552
+ end
3553
+ end
3554
+ end