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