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,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Octo
6
+ module Tools
7
+ class Terminal < Base
8
+ # Holds (at most) ONE long-lived PTY shell session that is reused
9
+ # across multiple terminal calls. Reusing the session eliminates the
10
+ # ~1s cold-start cost of `zsh -l -i` / `bash -l -i` on every command.
11
+ #
12
+ # Reuse rules:
13
+ # - Only non-background, non-dedicated calls take from the persistent
14
+ # slot. background / env-overridden calls spawn a fresh session.
15
+ # - Before each call we diff rc-file mtime(s); if changed, we
16
+ # `source` them once inside the live shell so the user sees freshly
17
+ # installed PATH / functions / aliases on the very next command.
18
+ # - If a command leaves the session in a non-clean state (marker not
19
+ # hit — i.e. the program is still running and interactive), the
20
+ # session is "donated" to the caller as a dedicated session_id and
21
+ # the persistent slot is cleared (next call rebuilds a fresh one).
22
+ # - If cleanup fails or a spawn fails, we transparently fall back to
23
+ # the old one-shot `bash --noprofile --norc -i` spawn.
24
+ #
25
+ # Thread safety:
26
+ # - Each persistent session has its own mutex (Session#mutex) that
27
+ # serialises PTY writes (unchanged).
28
+ # - The PersistentSessionPool itself is guarded by a class-level
29
+ # mutex so concurrent terminal calls don't race on acquire/release.
30
+ class PersistentSessionPool
31
+ class << self
32
+ def instance
33
+ @instance ||= new
34
+ end
35
+
36
+ def reset!
37
+ if @instance
38
+ begin
39
+ @instance.shutdown!
40
+ rescue StandardError
41
+ # swallow — best-effort during tests / shutdown
42
+ end
43
+ end
44
+ @instance = nil
45
+ end
46
+ end
47
+
48
+ def initialize
49
+ @mutex = Mutex.new
50
+ @session = nil # currently-idle persistent session, or nil
51
+ @rc_fingerprint = nil # mtime snapshot used to detect rc changes
52
+ @last_env_keys = [] # keys we exported last time; unset them on env change
53
+ @disabled = false # set to true after a spawn failure to stop retrying
54
+ end
55
+
56
+ # Acquire a persistent session for a new command.
57
+ #
58
+ # Returns [session, reused:] where `session` is a running PTY
59
+ # session ready to accept a command (no concurrent command in
60
+ # flight). Raises SpawnFailed if we can't build one.
61
+ #
62
+ # `reused:` is true when an existing session was handed out; false
63
+ # when we had to spawn a fresh one.
64
+ #
65
+ # Side effects when reusing:
66
+ # - Sources rc files if their mtimes changed.
67
+ # - `cd`s to `cwd` if given.
68
+ # - Resets env vars that were exported last time and exports the
69
+ # new ones (only when `env` is non-nil).
70
+ def acquire(runner:, cwd: nil, env: nil)
71
+ @mutex.synchronize do
72
+ return [nil, false] if @disabled
73
+
74
+ # 1) Make sure the stored session is still healthy.
75
+ if @session
76
+ unless session_healthy?(@session)
77
+ drop_locked
78
+ end
79
+ end
80
+
81
+ # 2) Spawn a fresh one if we don't have anything warm.
82
+ unless @session
83
+ begin
84
+ @session = runner.spawn_persistent_session
85
+ rescue StandardError => e
86
+ @disabled = true
87
+ raise SpawnFailed, e.message
88
+ end
89
+ @rc_fingerprint = current_rc_fingerprint
90
+ @last_env_keys = []
91
+ reused = false
92
+ else
93
+ reused = true
94
+ end
95
+
96
+ # 3) If rc files changed since last use, re-source them once.
97
+ if reused && rc_changed?
98
+ runner.source_rc_in_session(@session, rc_files_for_shell(@session.shell_name))
99
+ @rc_fingerprint = current_rc_fingerprint
100
+ end
101
+
102
+ # 4) Reset env — unset old, export new.
103
+ if env && !env.empty?
104
+ new_keys = env.keys.map(&:to_s)
105
+ to_unset = @last_env_keys - new_keys
106
+ runner.reset_env_in_session(@session, unset_keys: to_unset, set_env: env)
107
+ @last_env_keys = new_keys
108
+ elsif !@last_env_keys.empty?
109
+ runner.reset_env_in_session(@session, unset_keys: @last_env_keys, set_env: {})
110
+ @last_env_keys = []
111
+ end
112
+
113
+ # 5) cd to the requested directory.
114
+ if cwd && Dir.exist?(cwd.to_s)
115
+ runner.cd_in_session(@session, cwd.to_s)
116
+ end
117
+
118
+ session = @session
119
+ # Remove it from the slot for the duration of the command so
120
+ # a concurrent caller can't grab the same shell mid-run.
121
+ @session = nil
122
+
123
+ [session, reused]
124
+ end
125
+ end
126
+
127
+ # Put a session back into the persistent slot after a successful
128
+ # command. Returns true if stored (caller keeps the session),
129
+ # false if the slot was already filled or the session is unhealthy
130
+ # (caller MUST clean up the session — fds and process — itself).
131
+ def release(session)
132
+ @mutex.synchronize do
133
+ if @session.nil? && session_healthy?(session)
134
+ @session = session
135
+ true
136
+ else
137
+ false
138
+ end
139
+ end
140
+ end
141
+
142
+ # The caller has decided the session is unusable (e.g. command left
143
+ # an interactive program running). Forget it without killing — the
144
+ # caller is keeping the PTY alive for their own use.
145
+ def discard
146
+ @mutex.synchronize { @session = nil }
147
+ end
148
+
149
+ # Shut the persistent session down (typically at_exit).
150
+ def shutdown!
151
+ @mutex.synchronize do
152
+ sess = @session
153
+ @session = nil
154
+ next unless sess
155
+ begin
156
+ Process.kill("TERM", sess.pid)
157
+ rescue StandardError
158
+ # ignore
159
+ end
160
+ close_fds(sess)
161
+ end
162
+ end
163
+
164
+ def drop_locked
165
+ sess = @session
166
+ @session = nil
167
+ return unless sess
168
+ begin
169
+ Process.kill("TERM", sess.pid)
170
+ rescue StandardError
171
+ # ignore
172
+ end
173
+ close_fds(sess)
174
+ SessionManager.forget(sess.id)
175
+ end
176
+
177
+ private :drop_locked
178
+
179
+ # Close all open file descriptors on a session struct. Safe to call
180
+ # multiple times (all closes are rescue-wrapped).
181
+ private def close_fds(session)
182
+ session.log_io&.close rescue nil
183
+ session.writer&.close rescue nil
184
+ session.reader&.close rescue nil
185
+ end
186
+
187
+ def session_healthy?(session)
188
+ return false unless session
189
+ return false if %w[exited killed].include?(session.status.to_s)
190
+ # Probe the child process to make sure it's still alive.
191
+ begin
192
+ Process.kill(0, session.pid)
193
+ true
194
+ rescue Errno::ESRCH
195
+ false
196
+ rescue StandardError
197
+ # EPERM etc. — assume alive
198
+ true
199
+ end
200
+ end
201
+
202
+ private :session_healthy?
203
+
204
+ # --- rc mtime tracking ---------------------------------------------------
205
+
206
+ def current_rc_fingerprint
207
+ files = rc_files_for_shell(nil) # superset of all known rc files
208
+ files.each_with_object({}) do |path, h|
209
+ h[path] = File.mtime(path).to_f if File.exist?(path)
210
+ end
211
+ end
212
+
213
+ private :current_rc_fingerprint
214
+
215
+ def rc_changed?
216
+ new_fp = current_rc_fingerprint
217
+ changed = (new_fp != @rc_fingerprint)
218
+ changed
219
+ end
220
+
221
+ private :rc_changed?
222
+
223
+ # Return the rc files relevant to the given shell, in the *startup*
224
+ # order the shell itself would read them. This order matters when
225
+ # we re-source after a user edit: later files may depend on vars /
226
+ # PATH prefixes set by earlier ones (e.g. `.zshrc` invoking
227
+ # `mise activate zsh` which expects `~/.local/bin` already on PATH
228
+ # from `.zshenv` / `.zprofile`).
229
+ #
230
+ # zsh order: .zshenv -> .zprofile (login) -> .zshrc (interactive)
231
+ # bash order: .profile / .bash_profile (login) -> .bashrc
232
+ #
233
+ # If shell_name is nil (used by current_rc_fingerprint when we have
234
+ # no session), we return a superset so we always catch changes
235
+ # regardless of shell.
236
+ def rc_files_for_shell(shell_name)
237
+ home = ENV["HOME"].to_s
238
+ case shell_name
239
+ when "zsh"
240
+ %w[.zshenv .zprofile .zshrc]
241
+ when "bash"
242
+ %w[.profile .bash_profile .bashrc]
243
+ else
244
+ %w[.zshenv .zprofile .zshrc .profile .bash_profile .bashrc]
245
+ end.map { |f| File.join(home, f) }.select { |f| File.exist?(f) }
246
+ end
247
+
248
+ private :rc_files_for_shell
249
+ end
250
+
251
+ # Raised by the pool when a persistent spawn can't be created; callers
252
+ # should fall back to a one-shot session.
253
+ class SpawnFailed < StandardError; end
254
+ end
255
+ end
256
+ end
257
+
258
+ # Ensure the persistent shell is cleaned up on interpreter exit. Session-
259
+ # level kill_all! in SessionManager handles anything that's still registered,
260
+ # but we also explicitly SIGTERM the pool's current slot so the child shell
261
+ # doesn't linger.
262
+ at_exit do
263
+ begin
264
+ Octo::Tools::Terminal::PersistentSessionPool.instance.shutdown!
265
+ rescue StandardError
266
+ # never raise from at_exit
267
+ end
268
+ end
@@ -0,0 +1,106 @@
1
+ # Safe rm shell function — sourced by Octo::Tools::Terminal at the top
2
+ # of every interactive PTY session. See terminal.rb (SAFE_RM_PATH /
3
+ # install_marker) for rationale.
4
+ #
5
+ # Defines a `rm` function that moves files to $OCTO_TRASH_DIR instead
6
+ # of deleting them, so deletions can be recovered via `trash_manager`.
7
+ # The metadata sidecar schema matches
8
+ # Octo::Tools::Security::Replacer#create_delete_metadata so
9
+ # `trash_manager list/restore` keeps working unchanged.
10
+ #
11
+ # Covers: direct `rm ...` calls in the interactive shell, including
12
+ # multi-line commands, heredocs (heredoc bodies no longer trigger
13
+ # the rewriter), and shell glob expansion.
14
+ # Does NOT cover: `command rm`, `/bin/rm` (absolute path), `xargs rm`,
15
+ # `find -exec rm`, and child scripts — these bypass shell functions
16
+ # by design. This is the same coverage the old static Ruby rewriter
17
+ # had; it could not see inside those either.
18
+
19
+ rm() {
20
+ # Parse args: respect `--`, collect flag-like and path-like args.
21
+ local __dd=0
22
+ local -a __paths=() __flags=()
23
+ local __a
24
+ for __a in "$@"; do
25
+ if [ "$__dd" = "1" ]; then
26
+ __paths+=("$__a")
27
+ elif [ "$__a" = "--" ]; then
28
+ __dd=1
29
+ elif [ "${__a:0:1}" = "-" ] && [ -n "${__a:1}" ]; then
30
+ __flags+=("$__a")
31
+ else
32
+ __paths+=("$__a")
33
+ fi
34
+ done
35
+
36
+ # Trash dir is provisioned by the Ruby side via env.
37
+ local __trash="${OCTO_TRASH_DIR:-}"
38
+ if [ -z "$__trash" ]; then
39
+ echo "[octo-rm] OCTO_TRASH_DIR not set; refusing to rm" >&2
40
+ return 1
41
+ fi
42
+ mkdir -p "$__trash" 2>/dev/null || true
43
+
44
+ # Safety: refuse catastrophic targets (pre-expansion by the shell).
45
+ local __p __norm
46
+ for __p in ${__paths[@]+"${__paths[@]}"}; do
47
+ __norm="${__p%/}"
48
+ [ -z "$__norm" ] && __norm="/"
49
+ case "$__norm" in
50
+ /|/root|/etc|/usr|/bin|/sbin|/var)
51
+ echo "[octo-rm] refused dangerous target: $__p" >&2
52
+ return 1
53
+ ;;
54
+ esac
55
+ if [ "$__norm" = "$HOME" ] || [ "$__p" = "~" ]; then
56
+ echo "[octo-rm] refused dangerous target: $__p" >&2
57
+ return 1
58
+ fi
59
+ done
60
+
61
+ # `-f` semantics: suppress "no such file" errors.
62
+ local __has_f=0 __f
63
+ for __f in ${__flags[@]+"${__flags[@]}"}; do
64
+ case "$__f" in *f*) __has_f=1 ;; esac
65
+ done
66
+
67
+ local __rc=0 __base __ts __dest __abs __size __mode __ext __now
68
+ for __p in ${__paths[@]+"${__paths[@]}"}; do
69
+ if [ ! -e "$__p" ] && [ ! -L "$__p" ]; then
70
+ if [ "$__has_f" = "0" ]; then
71
+ echo "rm: $__p: No such file or directory" >&2
72
+ __rc=1
73
+ fi
74
+ continue
75
+ fi
76
+ __base="$(basename -- "$__p")"
77
+ __ts="$(date +%Y%m%d_%H%M%S_%N 2>/dev/null || date +%Y%m%d_%H%M%S)"
78
+ __dest="$__trash/${__base}_deleted_${__ts}"
79
+ # Resolve absolute path for metadata BEFORE mv (path won't exist after).
80
+ if [ -d "$__p" ]; then
81
+ __abs="$(cd "$__p" 2>/dev/null && pwd)" || __abs="$__p"
82
+ else
83
+ __abs="$(cd "$(dirname -- "$__p")" 2>/dev/null && pwd)/$(basename -- "$__p")" || __abs="$__p"
84
+ fi
85
+ # Size / mode best-effort; ignore for dirs or on failure.
86
+ __size="$(stat -f%z "$__p" 2>/dev/null || stat -c%s "$__p" 2>/dev/null || echo 0)"
87
+ __mode="$(stat -f%Lp "$__p" 2>/dev/null || stat -c%a "$__p" 2>/dev/null || echo 644)"
88
+ case "$__base" in
89
+ *.*) __ext=".${__base##*.}" ;;
90
+ *) __ext="" ;;
91
+ esac
92
+ __now="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%s)"
93
+ if command mv -- "$__p" "$__dest" 2>/dev/null; then
94
+ # Metadata sidecar — schema matches
95
+ # Octo::Tools::Security::Replacer#create_delete_metadata so
96
+ # `trash_manager list/restore` continue to work.
97
+ printf '{"original_path":"%s","trash_directory":"%s","deleted_at":"%s","deleted_by":"octo_rm_shell","file_size":%s,"file_type":"%s","file_mode":"%s"}\n' \
98
+ "$__abs" "$__trash" "$__now" "${__size:-0}" "$__ext" "${__mode:-644}" \
99
+ > "$__dest.metadata.json" 2>/dev/null || true
100
+ else
101
+ echo "rm: failed to move $__p to trash" >&2
102
+ __rc=1
103
+ fi
104
+ done
105
+ return $__rc
106
+ }
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+ require "securerandom"
6
+
7
+ module Octo
8
+ module Tools
9
+ class Terminal < Base
10
+ # In-process registry of interactive PTY sessions.
11
+ #
12
+ # Lifecycle: sessions die with the octo process because the child
13
+ # bash is a grandchild of octo (PTY.spawn forks then execs), and
14
+ # we also SIGKILL them on interpreter exit via an at_exit hook.
15
+ #
16
+ # Thread-safety: all mutations go through a class-level Mutex. The
17
+ # reader thread writes to Session#log_io concurrently with the main
18
+ # thread reading log_file, but File IO is append-safe on POSIX so we
19
+ # don't need to lock reads — we just pin them by byte offset.
20
+ #
21
+ # Status values:
22
+ # "starting" - PTY spawned, setup in progress
23
+ # "running" - ready to receive commands
24
+ # "exited" - child process ended
25
+ # "killed" - we signalled it
26
+ class SessionManager
27
+ Session = Struct.new(
28
+ :id, # Integer, 1-based unique id within this octo process
29
+ :pid, # Integer, PID of the PTY child
30
+ :command, # String, original command launched
31
+ :cwd, # String, working directory at launch
32
+ :started_at, # Time
33
+ :log_file, # String path, raw PTY output append-only
34
+ :log_io, # File, write handle owned by reader thread
35
+ :reader, # IO, PTY read end
36
+ :writer, # IO, PTY write end
37
+ :reader_thread, # Thread, reads PTY → log file
38
+ :status, # "starting" | "running" | "exited" | "killed"
39
+ :exit_code, # Integer or nil
40
+ :mode, # "shell" (marker-based) | "raw" (idle-based)
41
+ :marker_token, # String, unique per-session token for PROMPT_COMMAND
42
+ :marker_regex, # Regexp, compiled match for marker
43
+ :read_offset, # Integer, bytes already returned by previous read calls
44
+ :mutex, # per-session mutex for PTY writes
45
+ :shell_name, # "zsh" | "bash" | "sh" — informs marker syntax & rc reload
46
+ keyword_init: true
47
+ )
48
+
49
+ @sessions = {}
50
+ @next_id = 0
51
+ @mutex = Mutex.new
52
+
53
+ class << self
54
+ # Register a new session. Caller has already spawned the PTY and
55
+ # started the reader thread; we just record the metadata.
56
+ def register(pid:, command:, cwd:, log_file:, log_io:, reader:, writer:,
57
+ reader_thread:, mode:, marker_token: nil, shell_name: nil)
58
+ @mutex.synchronize do
59
+ @next_id += 1
60
+ session = Session.new(
61
+ id: @next_id,
62
+ pid: pid,
63
+ command: command,
64
+ cwd: cwd,
65
+ started_at: Time.now,
66
+ log_file: log_file,
67
+ log_io: log_io,
68
+ reader: reader,
69
+ writer: writer,
70
+ reader_thread: reader_thread,
71
+ status: "starting",
72
+ exit_code: nil,
73
+ mode: mode,
74
+ marker_token: marker_token,
75
+ marker_regex: marker_token ? /__OCTO_DONE_#{marker_token}_(\d+)__/ : nil,
76
+ read_offset: 0,
77
+ mutex: Mutex.new,
78
+ shell_name: shell_name
79
+ )
80
+ @sessions[session.id] = session
81
+ session
82
+ end
83
+ end
84
+
85
+ def get(id)
86
+ @mutex.synchronize { @sessions[id] }
87
+ end
88
+
89
+ def list
90
+ refresh_all
91
+ @mutex.synchronize { @sessions.values.sort_by(&:id) }
92
+ end
93
+
94
+ # Send signal to child, mark as killed. Returns the Session, or nil
95
+ # if unknown.
96
+ def kill(id, signal: "TERM")
97
+ session = @mutex.synchronize { @sessions[id] }
98
+ return nil unless session
99
+
100
+ begin
101
+ Process.kill(signal, session.pid)
102
+ rescue Errno::ESRCH, Errno::EPERM
103
+ # Already dead — fall through and mark killed.
104
+ end
105
+
106
+ @mutex.synchronize do
107
+ if session.status == "starting" || session.status == "running"
108
+ session.status = "killed"
109
+ end
110
+ end
111
+ session
112
+ end
113
+
114
+ # Forget a session (after it has been killed/exited). Does NOT kill
115
+ # the process — callers should kill first.
116
+ def forget(id)
117
+ @mutex.synchronize { @sessions.delete(id) }
118
+ end
119
+
120
+ # Refresh status of one session in-place (mutex-held).
121
+ private def refresh_locked(session)
122
+ return unless session.status == "starting" || session.status == "running"
123
+
124
+ # Probe the child with kill(0).
125
+ begin
126
+ Process.kill(0, session.pid)
127
+ rescue Errno::ESRCH
128
+ session.status = "exited"
129
+ rescue Errno::EPERM
130
+ # Process exists but owned by someone else; keep as-is.
131
+ end
132
+ end
133
+
134
+ def refresh_all
135
+ @mutex.synchronize do
136
+ @sessions.each_value { |s| refresh_locked(s) }
137
+ end
138
+ end
139
+
140
+ def refresh(id)
141
+ @mutex.synchronize do
142
+ session = @sessions[id]
143
+ refresh_locked(session) if session
144
+ session
145
+ end
146
+ end
147
+
148
+ # Mark running (called by the Terminal action after setup completes).
149
+ def mark_running(id)
150
+ @mutex.synchronize do
151
+ session = @sessions[id]
152
+ session.status = "running" if session && session.status == "starting"
153
+ end
154
+ end
155
+
156
+ def advance_offset(id, new_offset)
157
+ @mutex.synchronize do
158
+ s = @sessions[id]
159
+ s.read_offset = new_offset if s
160
+ end
161
+ end
162
+
163
+ def log_dir
164
+ @log_dir ||= begin
165
+ dir = File.join(Dir.tmpdir, "octo-terminals-#{Process.pid}")
166
+ FileUtils.mkdir_p(dir)
167
+ dir
168
+ end
169
+ end
170
+
171
+ def allocate_log_file
172
+ @mutex.synchronize do
173
+ next_id = @next_id + 1
174
+ File.join(log_dir, "#{next_id}.log")
175
+ end
176
+ end
177
+
178
+ # Kill every live session and close any open fds. Called from at_exit.
179
+ def kill_all!
180
+ (@sessions.values rescue []).each do |s|
181
+ begin
182
+ Process.kill("KILL", s.pid) unless %w[exited killed].include?(s.status.to_s)
183
+ rescue StandardError
184
+ # ignore
185
+ end
186
+ s.log_io&.close rescue nil
187
+ s.writer&.close rescue nil
188
+ s.reader&.close rescue nil
189
+ end
190
+ end
191
+
192
+ # Test-only: clear state without killing processes.
193
+ def reset!
194
+ @mutex.synchronize do
195
+ @sessions.clear
196
+ @next_id = 0
197
+ @log_dir = nil
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
205
+
206
+ # Ensure orphaned PTY children are reaped even on unclean exit.
207
+ at_exit do
208
+ begin
209
+ Octo::Tools::Terminal::SessionManager.kill_all!
210
+ rescue StandardError
211
+ # never raise out of at_exit
212
+ end
213
+ end