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,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ # MessageHistory wraps the conversation message list and exposes
5
+ # business-meaningful operations instead of raw array manipulation.
6
+ #
7
+ # Internal fields (task_id, created_at, system_injected, etc.) are kept
8
+ # in the internal store but stripped when calling #to_api.
9
+ class MessageHistory
10
+ # Fields that are internal to the agent and must not be sent to the API.
11
+ INTERNAL_FIELDS = %i[
12
+ task_id created_at system_injected session_context memory_update
13
+ subagent_instructions subagent_result token_usage
14
+ compressed_summary chunk_path truncated transient
15
+ chunk_index chunk_count
16
+ ].freeze
17
+
18
+ def initialize(messages = [])
19
+ @messages = messages.dup
20
+ end
21
+
22
+ # ─────────────────────────────────────────────
23
+ # Write operations
24
+ # ─────────────────────────────────────────────
25
+
26
+ # Append a single message hash to the history.
27
+ #
28
+ # When appending a user message, automatically drop any trailing assistant
29
+ # message that has unanswered tool_calls (no tool_result follows it).
30
+ # This prevents API error 2013 ("tool call result does not follow tool call")
31
+ # when a previous task ended before observe() could append tool results
32
+ # (e.g. subagent crash, interrupt, or error).
33
+ def append(message)
34
+ if message[:role] == "user"
35
+ drop_dangling_tool_calls!
36
+ end
37
+ @messages << deep_sanitize_utf8(message)
38
+ self
39
+ end
40
+
41
+ # Replace (or insert at head) the system prompt message.
42
+ # Used by session_serializer#refresh_system_prompt.
43
+ def replace_system_prompt(content, **extra)
44
+ msg = { role: "system", content: content }.merge(extra)
45
+ idx = @messages.index { |m| m[:role] == "system" }
46
+ if idx
47
+ @messages[idx] = msg
48
+ else
49
+ @messages.unshift(msg)
50
+ end
51
+ self
52
+ end
53
+
54
+ # Replace the entire message list (used by compression rebuild).
55
+ def replace_all(new_messages)
56
+ @messages = new_messages.map { |m| deep_sanitize_utf8(m) }
57
+ self
58
+ end
59
+
60
+ # Remove and return the last message.
61
+ def pop_last
62
+ @messages.pop
63
+ end
64
+
65
+ # Remove all messages matching the block in-place.
66
+ # Generic history pruning utility — used by callers that need to
67
+ # strip transient/system-injected messages out of the persisted
68
+ # history (e.g. compaction, rollback on 400 errors).
69
+ def delete_where(&block)
70
+ @messages.reject!(&block)
71
+ self
72
+ end
73
+
74
+ # Mutate the last message matching the predicate lambda in-place.
75
+ # Used by execute_skill_with_subagent to update instruction messages.
76
+ def mutate_last_matching(predicate, &block)
77
+ msg = @messages.reverse.find { |m| predicate.call(m) }
78
+ block.call(msg) if msg
79
+ self
80
+ end
81
+
82
+ # Remove all messages from index onward (used by restore_session on error).
83
+ def truncate_from(index)
84
+ @messages = @messages[0...index]
85
+ self
86
+ end
87
+
88
+ # Roll back the history to just before the given message object.
89
+ # Removes the message and anything appended after it.
90
+ # Used to undo a failed speculative append (e.g. compression message that errored).
91
+ def rollback_before(message)
92
+ idx = @messages.index { |m| m.equal?(message) }
93
+ return self unless idx
94
+
95
+ @messages = @messages[0...idx]
96
+ self
97
+ end
98
+
99
+ # ─────────────────────────────────────────────
100
+ # Business queries
101
+ # ─────────────────────────────────────────────
102
+
103
+ # True when a system prompt message is present in the history.
104
+ # Used by inject_session_context to avoid injecting context messages
105
+ # before the system prompt has been built (which would cause the
106
+ # guard in run() to skip building it altogether).
107
+ def has_system_prompt?
108
+ @messages.any? { |m| m[:role] == "system" }
109
+ end
110
+
111
+ # True when the last assistant message has tool_calls but no
112
+ # tool_result has been appended yet (would cause a 400 from the API).
113
+ def pending_tool_calls?
114
+ return false if @messages.empty?
115
+
116
+ last = @messages.last
117
+ return false unless last[:role] == "assistant" && last[:tool_calls]&.any?
118
+
119
+ last_assistant_idx = @messages.rindex { |m| m == last }
120
+ @messages[(last_assistant_idx + 1)..].none? { |m| m[:role] == "tool" || m[:tool_results] }
121
+ end
122
+
123
+ # Return the session_date value from the most recent session_context message.
124
+ # Used by inject_session_context_if_needed to avoid re-injecting on the same date.
125
+ def last_session_context_date
126
+ msg = @messages.reverse.find { |m| m[:session_context] }
127
+ msg&.dig(:session_date)
128
+ end
129
+
130
+ # Return the chunk_count from the most recently injected chunk index message.
131
+ # Used by inject_chunk_index_if_needed to avoid re-injecting when nothing changed.
132
+ def last_injected_chunk_count
133
+ msg = @messages.reverse.find { |m| m[:chunk_index] }
134
+ msg&.dig(:chunk_count) || 0
135
+ end
136
+
137
+ # Return only real (non-system-injected) user messages.
138
+ def real_user_messages
139
+ @messages.select { |m| m[:role] == "user" && !m[:system_injected] }
140
+ end
141
+
142
+ # Return the index of the last real (non-system-injected) user message.
143
+ # Used by restore_session to trim back to a clean state on error.
144
+ def last_real_user_index
145
+ @messages.rindex { |m| m[:role] == "user" && !m[:system_injected] }
146
+ end
147
+
148
+ # Return the message with :subagent_instructions set.
149
+ def subagent_instruction_message
150
+ @messages.find { |m| m[:subagent_instructions] }
151
+ end
152
+
153
+ # Return all messages where task_id <= given id (Time Machine support).
154
+ def for_task(task_id)
155
+ @messages.select { |m| !m[:task_id] || m[:task_id] <= task_id }
156
+ end
157
+
158
+ # ─────────────────────────────────────────────
159
+ # Size helpers
160
+ # ─────────────────────────────────────────────
161
+
162
+ def size
163
+ @messages.size
164
+ end
165
+
166
+ def empty?
167
+ @messages.empty?
168
+ end
169
+
170
+ # Estimate total token count for all messages.
171
+ # Uses the ~4 chars/token heuristic (works well for English/code).
172
+ # Handles string content, array content blocks, and tool_calls.
173
+ def estimate_tokens
174
+ @messages.sum { |m| estimate_message_tokens(m) }
175
+ end
176
+
177
+ # ─────────────────────────────────────────────
178
+ # Output
179
+ # ─────────────────────────────────────────────
180
+
181
+ # Return a clean copy of messages suitable for sending to the LLM API:
182
+ # - strips internal-only fields
183
+ # - pads reasoning_content on synthetic assistant messages when the
184
+ # conversation is running against a thinking-mode provider
185
+ #
186
+ # @param force_reasoning_content_pad [Boolean]
187
+ # When true, unconditionally pad every assistant message that lacks a
188
+ # reasoning_content field with an empty string. This is set by the
189
+ # LLM caller AFTER a 400 "reasoning_content must be passed back" error
190
+ # as a one-shot retry signal — the history-evidence heuristic below
191
+ # can't fire when the previous turns came from a provider that keeps
192
+ # thinking inline (e.g. MiniMax: <think>...</think> in content), so
193
+ # this bypass lets us recover on the retry without a server restart.
194
+ def to_api(force_reasoning_content_pad: false)
195
+ msgs = @messages.map { |m| strip_for_api(m) }
196
+ ensure_reasoning_content_consistency(msgs, force: force_reasoning_content_pad)
197
+ end
198
+
199
+ # Return a shallow copy of the message list, excluding transient messages.
200
+ # Transient messages are valid during the current session but must not be
201
+ # persisted to session.json.
202
+ # For serialization, compression, and cloning.
203
+ def to_a
204
+ @messages.reject { |m| m[:transient] }.dup
205
+ end
206
+
207
+ # Estimate token count for a single message (role overhead + content).
208
+ private def estimate_message_tokens(message)
209
+ # ~4 tokens of overhead per message (role, formatting)
210
+ tokens = 4
211
+ tokens += estimate_content_tokens(message[:content])
212
+
213
+ # tool_calls: each call adds name + arguments chars
214
+ if message[:tool_calls].is_a?(Array)
215
+ message[:tool_calls].each do |tc|
216
+ tokens += estimate_content_tokens(tc.dig(:function, :name))
217
+ tokens += estimate_content_tokens(tc.dig(:function, :arguments))
218
+ end
219
+ end
220
+
221
+ tokens
222
+ end
223
+
224
+ # Estimate tokens from a content value (string, array of blocks, or nil).
225
+ # Heuristic: ASCII/code ~4 chars/token; CJK/multibyte ~1.5 chars/token.
226
+ private def estimate_content_tokens(content)
227
+ case content
228
+ when String
229
+ ascii_chars = content.scan(/[ -~]/).length
230
+ multibyte_chars = content.length - ascii_chars
231
+ ((ascii_chars / 4.0) + (multibyte_chars / 1.5)).ceil
232
+ when Array
233
+ content.sum do |block|
234
+ block.is_a?(Hash) ? estimate_content_tokens(block[:text] || block["text"]) : 0
235
+ end
236
+ else
237
+ 0
238
+ end
239
+ end
240
+
241
+ # Drop the trailing assistant message if it has tool_calls with no subsequent
242
+ # tool_result — i.e. the tool call was never answered (dangling).
243
+ # Called automatically before appending any user message.
244
+ private def drop_dangling_tool_calls!
245
+ return unless pending_tool_calls?
246
+
247
+ @messages.pop
248
+ end
249
+
250
+ private def strip_for_api(message)
251
+ msg = strip_internal_fields(message)
252
+ content = msg[:content]
253
+ return msg unless content.is_a?(Array)
254
+
255
+ cleaned = content.filter_map do |block|
256
+ next block unless block.is_a?(Hash)
257
+
258
+ if block[:type] == "image_url" &&
259
+ block.dig(:image_url, :url) == "[image stripped]"
260
+ next nil
261
+ end
262
+
263
+ block.key?(:image_path) ? block.reject { |k, _| k == :image_path } : block
264
+ end
265
+
266
+ return msg if cleaned == content
267
+
268
+ if cleaned.empty?
269
+ msg.merge(content: "[images were shown to you in a previous turn]")
270
+ else
271
+ msg.merge(content: cleaned)
272
+ end
273
+ end
274
+
275
+ private def strip_internal_fields(message)
276
+ message.reject { |k, _| INTERNAL_FIELDS.include?(k) }
277
+ end
278
+
279
+ # Detect thinking-mode providers purely from history content and pad
280
+ # synthetic assistant messages with an empty reasoning_content when needed.
281
+ #
282
+ # WHY: Providers like DeepSeek V4 and Kimi K2 in thinking mode return a
283
+ # `reasoning_content` field on every assistant turn and REQUIRE the caller
284
+ # to echo a `reasoning_content` field back on every subsequent assistant
285
+ # message in the payload — omitting it triggers:
286
+ # HTTP 400: "The reasoning_content in the thinking mode must be passed
287
+ # back to the API"
288
+ #
289
+ # The canonical history contains assistant messages from two sources:
290
+ # 1. Real LLM responses — carry reasoning_content when returned by the
291
+ # provider (preserved in agent.rb via parse_response).
292
+ # 2. Synthetic / locally-injected messages — skill injection, subagent
293
+ # acks, slash-command notices, truncation fallbacks. These are never
294
+ # produced by the LLM so they naturally lack reasoning_content.
295
+ #
296
+ # RULE: If ANY assistant message in the history carries reasoning_content,
297
+ # the conversation is provably running against a thinking-mode provider
298
+ # (the provider itself produced it). In that case, every other assistant
299
+ # message must echo the field, so we pad with an empty string.
300
+ #
301
+ # This is a purely structural inference with no model-name coupling —
302
+ # it self-adapts to new thinking-mode providers and new synthetic-message
303
+ # injection sites without any code changes elsewhere.
304
+ #
305
+ # For non-thinking providers (Claude / OpenAI / Gemini / Bedrock) no
306
+ # assistant message ever has reasoning_content, so this is a no-op.
307
+ # The Anthropic adapter also filters unknown fields via a whitelist, so
308
+ # even mid-session fallback between providers remains safe.
309
+ private def ensure_reasoning_content_consistency(msgs, force: false)
310
+ self.class.pad_reasoning_content_if_needed(msgs, force: force)
311
+ end
312
+
313
+ # Public helper: pad assistant messages that lack a reasoning_content
314
+ # field with an empty string, either when forced or when the payload
315
+ # already shows evidence of thinking-mode (at least one assistant
316
+ # message with reasoning_content).
317
+ #
318
+ # Exposed as a class method so Time Machine's active_messages path can
319
+ # reuse the exact same logic without routing through #to_api.
320
+ def self.pad_reasoning_content_if_needed(msgs, force: false)
321
+ should_pad = force || msgs.any? { |m| m[:role] == "assistant" && m[:reasoning_content] }
322
+ return msgs unless should_pad
323
+
324
+ msgs.map do |m|
325
+ next m unless m[:role] == "assistant"
326
+ next m if m.key?(:reasoning_content)
327
+
328
+ m.merge(reasoning_content: "")
329
+ end
330
+ end
331
+
332
+ # Defense-in-depth: recursively scrub invalid UTF-8 bytes from every String
333
+ # stored in the message tree. Even if a tool forgets to scrub its output,
334
+ # nothing poisoned will ever reach session persistence or JSON.generate.
335
+ #
336
+ # Fast path: if the tree contains only valid UTF-8 strings, the original
337
+ # object is returned unchanged — preserving object identity for callers
338
+ # that rely on `equal?` (e.g. rollback_before).
339
+ # Slow path: any invalid byte triggers a rebuild with scrubbed strings
340
+ # (invalid bytes → U+FFFD).
341
+ private def deep_sanitize_utf8(obj)
342
+ case obj
343
+ when String
344
+ return obj if obj.encoding == Encoding::UTF_8 && obj.valid_encoding?
345
+ obj.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
346
+ when Hash
347
+ return obj unless contains_dirty_utf8?(obj)
348
+ obj.transform_values { |v| deep_sanitize_utf8(v) }
349
+ when Array
350
+ return obj unless contains_dirty_utf8?(obj)
351
+ obj.map { |v| deep_sanitize_utf8(v) }
352
+ else
353
+ obj
354
+ end
355
+ end
356
+
357
+ # Cheap recursive check: does this subtree contain any invalid-UTF-8 string?
358
+ # Short-circuits on first offender. Keeps the common case (all valid UTF-8)
359
+ # allocation-free.
360
+ private def contains_dirty_utf8?(obj)
361
+ case obj
362
+ when String
363
+ !(obj.encoding == Encoding::UTF_8 && obj.valid_encoding?)
364
+ when Hash
365
+ obj.any? { |_, v| contains_dirty_utf8?(v) }
366
+ when Array
367
+ obj.any? { |v| contains_dirty_utf8?(v) }
368
+ else
369
+ false
370
+ end
371
+ end
372
+ end
373
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Octo
6
+ # Reassembles an OpenAI-compatible chat-completion event stream into the
7
+ # non-streaming response shape that MessageFormat::OpenAI.parse_response
8
+ # consumes, while invoking on_chunk(input_tokens:, output_tokens:) every
9
+ # time the upstream emits a new usage frame.
10
+ #
11
+ # Streaming frames look like:
12
+ #
13
+ # {"id":"...","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
14
+ # {"id":"...","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}
15
+ # {"id":"...","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_x","function":{"name":"shell","arguments":"{\"cmd"}}]}}]}
16
+ # {"id":"...","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\"ls\"}"}}]}}]}
17
+ # {"id":"...","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}
18
+ # {"id":"...","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"prompt_tokens_details":{"cached_tokens":2}}}
19
+ # data: [DONE]
20
+ class OpenAIStreamAggregator
21
+ def initialize(on_chunk: nil)
22
+ @on_chunk = on_chunk
23
+ @content = +""
24
+ @reasoning_content = +""
25
+ @role = "assistant"
26
+ @finish_reason = nil
27
+ @tool_calls = {}
28
+ @usage = nil
29
+ @last_input_tokens = 0
30
+ @last_output_tokens = 0
31
+ end
32
+
33
+ def handle(data_str)
34
+ return if data_str == "[DONE]"
35
+ data = parse_or_nil(data_str)
36
+ return unless data
37
+
38
+ if (choice = (data["choices"] || []).first)
39
+ delta = choice["delta"] || {}
40
+ @role = delta["role"] if delta["role"]
41
+ @content << delta["content"] if delta["content"].is_a?(String)
42
+ @reasoning_content << delta["reasoning_content"] if delta["reasoning_content"].is_a?(String)
43
+ if (tcs = delta["tool_calls"])
44
+ tcs.each { |tc| merge_tool_call(tc) }
45
+ end
46
+ @finish_reason = choice["finish_reason"] if choice["finish_reason"]
47
+ emit_estimate_progress
48
+ end
49
+
50
+ if (u = data["usage"])
51
+ @usage = u
52
+ emit_usage_progress(u)
53
+ end
54
+ end
55
+
56
+ # Render the canonical non-streaming response shape.
57
+ def to_h
58
+ tool_calls = @tool_calls.keys.sort.map do |idx|
59
+ tc = @tool_calls[idx]
60
+ {
61
+ "id" => tc[:id],
62
+ "type" => tc[:type] || "function",
63
+ "function" => {
64
+ "name" => tc[:name],
65
+ "arguments" => tc[:arguments].to_s
66
+ }
67
+ }
68
+ end
69
+
70
+ message = {
71
+ "role" => @role,
72
+ "content" => @content.empty? ? nil : @content
73
+ }
74
+ message["tool_calls"] = tool_calls unless tool_calls.empty?
75
+ message["reasoning_content"] = @reasoning_content unless @reasoning_content.empty?
76
+
77
+ {
78
+ "choices" => [{ "index" => 0, "message" => message, "finish_reason" => @finish_reason }],
79
+ "usage" => @usage || {}
80
+ }
81
+ end
82
+
83
+ private def merge_tool_call(tc)
84
+ idx = tc["index"] || @tool_calls.size
85
+ slot = (@tool_calls[idx] ||= { id: nil, type: nil, name: nil, arguments: +"" })
86
+ slot[:id] ||= tc["id"] if tc["id"]
87
+ slot[:type] ||= tc["type"] if tc["type"]
88
+ if (fn = tc["function"])
89
+ slot[:name] ||= fn["name"] if fn["name"]
90
+ slot[:arguments] << fn["arguments"].to_s if fn["arguments"]
91
+ end
92
+ end
93
+
94
+ private def parse_or_nil(s)
95
+ JSON.parse(s)
96
+ rescue JSON::ParserError
97
+ nil
98
+ end
99
+
100
+ private def emit_estimate_progress
101
+ return unless @on_chunk
102
+ output = approximate_output_tokens
103
+ return if output == @last_output_tokens
104
+ @last_output_tokens = output
105
+ @on_chunk.call(input_tokens: @last_input_tokens, output_tokens: output)
106
+ rescue => e
107
+ Octo::Logger.warn("[OpenAIStreamAggregator] on_chunk: #{e.class}: #{e.message}")
108
+ end
109
+
110
+ # Rough char/4 estimate; replaced by the real count when the upstream
111
+ # finally emits a usage frame (with stream_options.include_usage=true).
112
+ private def approximate_output_tokens
113
+ total_chars = @content.bytesize + @reasoning_content.bytesize +
114
+ @tool_calls.values.sum { |tc| tc[:arguments].to_s.bytesize }
115
+ (total_chars / 4.0).ceil
116
+ end
117
+
118
+ private def emit_usage_progress(u)
119
+ return unless @on_chunk
120
+ total_prompt = u["prompt_tokens"].to_i
121
+ output = u["completion_tokens"].to_i
122
+ return if total_prompt == @last_input_tokens && output == @last_output_tokens
123
+ @last_input_tokens = total_prompt
124
+ @last_output_tokens = output
125
+ @on_chunk.call(input_tokens: total_prompt, output_tokens: output)
126
+ rescue => e
127
+ Octo::Logger.warn("[OpenAIStreamAggregator] on_chunk: #{e.class}: #{e.message}")
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ui_interface"
4
+
5
+ module Octo
6
+ # PlainUIController implements UIInterface for non-interactive (--message) mode.
7
+ # Writes human-readable plain text directly to stdout so the caller can capture
8
+ # or pipe the output. No spinners, no TUI — just clean lines.
9
+ class PlainUIController
10
+ include Octo::UIInterface
11
+
12
+ def initialize(output: $stdout)
13
+ @output = output
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ # === Output display ===
18
+
19
+ def show_assistant_message(content, files:)
20
+ puts_line(content) unless content.nil? || content.strip.empty?
21
+ files.each { |f| puts_line("📄 File: #{f[:path]}") }
22
+ end
23
+
24
+ def show_tool_call(name, args)
25
+ args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
26
+
27
+ # Special handling for request_user_feedback — display as a readable prompt
28
+ if name.to_s == "request_user_feedback"
29
+ question = args_data.is_a?(Hash) ? (args_data[:question] || args_data["question"]).to_s : ""
30
+ context = args_data.is_a?(Hash) ? (args_data[:context] || args_data["context"]).to_s : ""
31
+ options = args_data.is_a?(Hash) ? (args_data[:options] || args_data["options"]) : nil
32
+ options = Array(options) if options && !options.is_a?(Array)
33
+
34
+ parts = []
35
+ parts << "**Context:** #{context.strip}" if context && !context.strip.empty?
36
+ parts << "**Question:** #{question.strip}"
37
+ if options && !options.empty?
38
+ parts << "**Options:**"
39
+ options.each_with_index { |opt, i| parts << " #{i + 1}. #{opt}" }
40
+ end
41
+ puts_line(parts.join("\n"))
42
+ return
43
+ end
44
+
45
+ display = case name
46
+ when "terminal"
47
+ cmd = args_data.is_a?(Hash) ? (args_data[:command] || args_data["command"]) : args_data
48
+ sid = args_data.is_a?(Hash) ? (args_data[:session_id] || args_data["session_id"]) : nil
49
+ if cmd
50
+ "$ #{cmd}"
51
+ elsif sid
52
+ "$ (session ##{sid})"
53
+ else
54
+ "$ terminal"
55
+ end
56
+ when "write"
57
+ path = args_data.is_a?(Hash) ? (args_data[:path] || args_data["path"]) : args_data
58
+ "Write → #{path}"
59
+ when "edit"
60
+ path = args_data.is_a?(Hash) ? (args_data[:path] || args_data["path"]) : args_data
61
+ "Edit → #{path}"
62
+ else
63
+ label = args_data.is_a?(Hash) ? args_data.map { |k, v| "#{k}=#{v.to_s[0..40]}" }.join(", ") : args_data.to_s[0..80]
64
+ "#{name}(#{label})"
65
+ end
66
+ puts_line("[tool] #{display}")
67
+ end
68
+
69
+ def show_tool_result(result)
70
+ text = result.to_s.strip
71
+ return if text.empty?
72
+
73
+ # Indent multi-line results for readability
74
+ indented = text.lines.map { |l| " #{l}" }.join
75
+ puts_line(indented)
76
+ end
77
+
78
+ def show_tool_error(error)
79
+ msg = error.is_a?(Exception) ? error.message : error.to_s
80
+ puts_line("[error] #{msg}")
81
+ end
82
+
83
+ def show_file_write_preview(path, is_new_file:)
84
+ action = is_new_file ? "create" : "overwrite"
85
+ puts_line("[file] #{action}: #{path}")
86
+ end
87
+
88
+ def show_file_edit_preview(path)
89
+ puts_line("[file] edit: #{path}")
90
+ end
91
+
92
+ def show_file_error(error_message)
93
+ puts_line("[file error] #{error_message}")
94
+ end
95
+
96
+ def show_shell_preview(command)
97
+ puts_line("[shell] #{command}")
98
+ end
99
+
100
+ def show_complete(iterations:, duration: nil, cache_stats: nil, awaiting_user_feedback: false)
101
+ parts = ["[done] iterations=#{iterations}"]
102
+ parts << "duration=#{duration.round(1)}s" if duration
103
+ puts_line(parts.join(" "))
104
+ end
105
+
106
+ def append_output(content)
107
+ puts_line(content)
108
+ end
109
+
110
+ # === Status messages ===
111
+
112
+ def show_info(message, prefix_newline: true)
113
+ puts_line("[info] #{message}")
114
+ end
115
+
116
+ def show_warning(message)
117
+ puts_line("[warn] #{message}")
118
+ end
119
+
120
+ def show_error(message)
121
+ puts_line("[error] #{message}")
122
+ end
123
+
124
+ def show_success(message)
125
+ puts_line("[ok] #{message}")
126
+ end
127
+
128
+ def log(message, level: :info)
129
+ # Only surface errors/warnings; suppress debug noise in plain mode
130
+ puts_line("[#{level}] #{message}") if %i[error warn].include?(level.to_sym)
131
+ end
132
+
133
+ # === Progress (no-ops — no spinner in plain mode) ===
134
+
135
+ def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {}); end
136
+
137
+ # === State updates (no-ops) ===
138
+
139
+ def update_sessionbar(tasks: nil, status: nil, latency: nil); end
140
+ def update_todos(todos); end
141
+ def set_working_status; end
142
+ def set_idle_status; end
143
+
144
+ # === Blocking interaction (auto-approve in non-interactive mode) ===
145
+
146
+ def request_confirmation(message, default: true)
147
+ # Should not be reached because permission_mode is forced to auto_approve,
148
+ # but return true as a safety net.
149
+ true
150
+ end
151
+
152
+ # === Input control / Lifecycle (no-ops) ===
153
+
154
+ def clear_input; end
155
+ def set_input_tips(message, type: :info); end
156
+ def stop; end
157
+
158
+
159
+ def puts_line(text)
160
+ @mutex.synchronize do
161
+ @output.puts(text)
162
+ @output.flush
163
+ end
164
+ end
165
+ end
166
+ end