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,431 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ module Server
5
+ # SessionRegistry is the single authoritative source for session state.
6
+ #
7
+ # It owns two concerns:
8
+ # 1. Runtime state — agent instance, thread, status, pending_task, idle_timer.
9
+ # 2. Session list — reads from disk (via session_manager) and enriches with
10
+ # live runtime status. `list` is the only place the session
11
+ # list is assembled; no callers should build it elsewhere.
12
+ #
13
+ # Lazy restore: `ensure(session_id)` loads a disk session into the registry on
14
+ # demand. All session-specific APIs call this before touching the registry so
15
+ # disk-only sessions (e.g. loaded via loadMore) just work transparently.
16
+ #
17
+ # Thread safety: all public methods are protected by a Mutex.
18
+ class SessionRegistry
19
+ SESSION_TIMEOUT = 24 * 60 * 60 # 24 hours of inactivity before cleanup
20
+
21
+ def initialize(session_manager: nil, session_restorer: nil, agent_config:)
22
+ @sessions = {}
23
+ @mutex = Mutex.new
24
+ @session_manager = session_manager
25
+ @session_restorer = session_restorer
26
+ @agent_config = agent_config
27
+ # Tracks sessions currently being restored from disk.
28
+ # Other threads calling ensure() for the same id will wait via @restore_cond
29
+ # instead of seeing a half-built session (agent=nil).
30
+ @restoring = {}
31
+ @restore_cond = ConditionVariable.new
32
+ end
33
+
34
+ # Create a new (empty) session entry and return its id.
35
+ # agent/ui/thread are set later via with_session once they are constructed.
36
+ def create(session_id:)
37
+ raise ArgumentError, "session_id is required" if session_id.nil? || session_id.empty?
38
+
39
+ session = {
40
+ id: session_id,
41
+ status: :idle,
42
+ error: nil,
43
+ updated_at: Time.now,
44
+ agent: nil,
45
+ ui: nil,
46
+ thread: nil,
47
+ idle_timer: nil,
48
+ pending_task: nil,
49
+ pending_working_dir: nil
50
+ }
51
+
52
+ @mutex.synchronize { @sessions[session_id] = session }
53
+ session_id
54
+ end
55
+
56
+ # Ensure a session is in the registry, loading from disk if necessary.
57
+ # Returns true if the session is now available, false if not found anywhere.
58
+ #
59
+ # Thread-safe: if two threads race on the same session_id, the second one
60
+ # waits for the first to finish restoring (including agent construction) rather
61
+ # than seeing a half-built entry with agent=nil.
62
+ def ensure(session_id)
63
+ session_data = nil
64
+
65
+ @mutex.synchronize do
66
+ # Another thread is currently restoring this session (including the case where
67
+ # @registry.create was already called but with_session agent-set is not yet done) —
68
+ # wait for it to finish so callers never see agent=nil.
69
+ if @restoring[session_id]
70
+ @restore_cond.wait(@mutex) until !@restoring[session_id]
71
+ return @sessions.key?(session_id)
72
+ end
73
+
74
+ # Already fully ready (not being restored) — fast path.
75
+ return true if @sessions.key?(session_id)
76
+
77
+ return false unless @session_manager && @session_restorer
78
+
79
+ session_data = @session_manager.load(session_id)
80
+ return false unless session_data
81
+
82
+ # Mark as "restore in progress" so concurrent callers wait.
83
+ @restoring[session_id] = true
84
+ end
85
+
86
+ # Run the (potentially slow) restore outside the mutex so other sessions
87
+ # are not blocked during agent construction.
88
+ begin
89
+ @session_restorer.call(session_data)
90
+ ensure
91
+ @mutex.synchronize do
92
+ @restoring.delete(session_id)
93
+ @restore_cond.broadcast
94
+ end
95
+ end
96
+
97
+ @sessions.key?(session_id)
98
+ end
99
+
100
+ # Restore all sessions from disk (up to n per source type) into the registry.
101
+ # Used at startup. Already-registered sessions are skipped.
102
+ def restore_from_disk(n: 5)
103
+ return unless @session_manager && @session_restorer
104
+
105
+ all = @session_manager.all_sessions
106
+ .sort_by { |s| s[:created_at] || "" }
107
+ .reverse
108
+
109
+ # Take up to n per source type
110
+ counts = Hash.new(0)
111
+ all.each do |session_data|
112
+ src = (session_data[:source] || "manual").to_s
113
+ next if counts[src] >= n
114
+ next if exist?(session_data[:session_id])
115
+ @session_restorer.call(session_data)
116
+ counts[src] += 1
117
+ end
118
+ end
119
+
120
+ # Retrieve a session hash by id (returns nil if not found).
121
+ def get(session_id)
122
+ @mutex.synchronize { @sessions[session_id]&.dup }
123
+ end
124
+
125
+ # Update arbitrary runtime fields of a session (status, error, pending_*, etc.).
126
+ def update(session_id, **fields)
127
+ @mutex.synchronize do
128
+ session = @sessions[session_id]
129
+ return false unless session
130
+
131
+ fields[:updated_at] = Time.now
132
+ session.merge!(fields)
133
+ true
134
+ end
135
+ end
136
+
137
+ # Return a session list from disk enriched with live registry status.
138
+ # Sorted by created_at descending (newest first).
139
+ #
140
+ # Parameters (all optional, independent):
141
+ # source: "manual"|"cron"|"channel"|"setup"|nil
142
+ # nil = no source filter (all sessions)
143
+ # profile: "general"|"coding"|nil
144
+ # nil = no agent_profile filter
145
+ # limit: max sessions to return (applies to NON-PINNED only; see below)
146
+ # before: ISO8601 cursor — only sessions with created_at < before
147
+ # (also applies to NON-PINNED only; pinned items are a separate
148
+ # logical section, they should never be paginated away)
149
+ # include_pinned: when true (default), all matching pinned sessions are
150
+ # always returned on the FIRST page (before == nil) regardless
151
+ # of limit. Subsequent pages (before set) contain only
152
+ # non-pinned sessions. This guarantees that users who pinned
153
+ # an old session always see it at the top of the sidebar,
154
+ # even if many newer sessions exist.
155
+ #
156
+ # Ordering of the returned array:
157
+ # [ ...all_pinned_matching (newest-first), ...non_pinned (newest-first, limited) ]
158
+ #
159
+ # source and profile are orthogonal — either can be nil independently.
160
+ def list(limit: nil, before: nil, q: nil, date: nil, type: nil, include_pinned: true)
161
+ return [] unless @session_manager
162
+
163
+ live = @mutex.synchronize do
164
+ @sessions.transform_values do |s|
165
+ model_info = s[:agent]&.current_model_info
166
+ live_name = s[:agent]&.name
167
+ live_name = nil if live_name&.empty?
168
+ { status: s[:status], error: s[:error], model: model_info&.dig(:model), model_id: model_info&.dig(:id), name: live_name,
169
+ total_tasks: s[:agent]&.total_tasks,
170
+ reasoning_effort: s[:agent]&.reasoning_effort,
171
+ latest_latency: s[:agent]&.latest_latency }
172
+ end
173
+ end
174
+
175
+ all = @session_manager.all_sessions # already sorted newest-first
176
+
177
+ # ── type filter (replaces old source/profile split) ──────────────────
178
+ # type=coding → agent_profile == "coding"
179
+ # type=manual/cron/channel/setup → source match (profile=general implied)
180
+ if type
181
+ if type == "coding"
182
+ all = all.select { |s| (s[:agent_profile] || "general").to_s == "coding" }
183
+ else
184
+ all = all.select { |s| s_source(s) == type && (s[:agent_profile] || "general").to_s != "coding" }
185
+ end
186
+ end
187
+
188
+ # ── date filter (YYYY-MM-DD, matches created_at prefix) ──────────────
189
+ all = all.select { |s| s[:created_at].to_s.start_with?(date) } if date
190
+
191
+ # ── name / id search ─────────────────────────────────────────────────
192
+ if q && !q.empty?
193
+ q_down = q.downcase
194
+ all = all.select { |s|
195
+ (s[:name] || "").downcase.include?(q_down) ||
196
+ (s[:session_id] || "").downcase.include?(q_down)
197
+ }
198
+ end
199
+
200
+ # ── Split pinned vs non-pinned BEFORE applying `before`/`limit`.
201
+ # Pinned sessions bypass pagination entirely so an old pinned session
202
+ # never falls off the first page just because newer sessions exist.
203
+ # (Regression fix for 0.9.37: previously `all_sessions` was only
204
+ # sorted by created_at and `limit` cut off old pinned rows, making
205
+ # them invisible until the user clicked "load more".)
206
+ pinned, non_pinned = all.partition { |s| s[:pinned] }
207
+
208
+ # `before` cursor ONLY applies to non-pinned (paginated) sessions.
209
+ non_pinned = non_pinned.select { |s| (s[:created_at] || "") < before } if before
210
+ non_pinned = non_pinned.first(limit) if limit
211
+
212
+ # Pinned section: only included on the first page (before == nil) so
213
+ # "load more" responses don't re-send them. On first page, return ALL
214
+ # matching pinned sessions regardless of limit.
215
+ pinned_section = (include_pinned && before.nil?) ? pinned : []
216
+
217
+ ordered = pinned_section + non_pinned
218
+
219
+ ordered.map { |s| build_enriched_row(s, live[s[:session_id]]) }
220
+ end
221
+
222
+ # Return the same enriched hash that a `list` row would produce, for a
223
+ # single session — merging on-disk fields with in-memory live fields.
224
+ # Returns nil if the session is unknown on disk.
225
+ #
226
+ # This is the targeted, O(1) counterpart to `list` used by the WS layer
227
+ # when it only needs one row (e.g. pushing a fresh snapshot to a client
228
+ # that just (re)subscribed, or broadcasting a status-change update).
229
+ def snapshot(session_id)
230
+ return nil unless @session_manager
231
+ disk = @session_manager.load(session_id)
232
+ return nil unless disk
233
+
234
+ live = @mutex.synchronize do
235
+ s = @sessions[session_id]
236
+ next nil unless s
237
+ model_info = s[:agent]&.current_model_info
238
+ live_name = s[:agent]&.name
239
+ live_name = nil if live_name&.empty?
240
+ { status: s[:status], error: s[:error], model: model_info&.dig(:model), model_id: model_info&.dig(:id),
241
+ name: live_name, total_tasks: s[:agent]&.total_tasks,
242
+ reasoning_effort: s[:agent]&.reasoning_effort,
243
+ latest_latency: s[:agent]&.latest_latency }
244
+ end
245
+
246
+ build_enriched_row(disk, live)
247
+ end
248
+
249
+ # Merge a single disk-side session hash with the corresponding live
250
+ # in-memory agent fields (may be nil) into the row shape the frontend
251
+ # consumes.
252
+ private def build_enriched_row(s, ls)
253
+ id = s[:session_id]
254
+ {
255
+ id: id,
256
+ name: ls&.dig(:name) || s[:name] || "",
257
+ status: ls ? ls[:status].to_s : "idle",
258
+ error: ls ? ls[:error] : nil,
259
+ model: ls&.dig(:model),
260
+ model_id: ls&.dig(:model_id),
261
+ source: s_source(s),
262
+ agent_profile: (s[:agent_profile] || "general").to_s,
263
+ working_dir: s[:working_dir],
264
+ created_at: s[:created_at],
265
+ updated_at: s[:updated_at],
266
+ total_tasks: ls&.dig(:total_tasks) || s.dig(:stats, :total_tasks) || 0,
267
+ # latest_latency is in-memory only (live sessions) — not persisted
268
+ # at the session-level on disk. The on-disk source of truth is
269
+ # per-assistant-message `latency` fields in messages[]. Reloaded
270
+ # sessions start with nil and get populated on the next LLM call.
271
+ latest_latency: ls&.dig(:latest_latency),
272
+ reasoning_effort: ls&.dig(:reasoning_effort) || s.dig(:config, :reasoning_effort),
273
+ pinned: s[:pinned] || false,
274
+ }
275
+ end
276
+
277
+
278
+ # Normalize source field from a disk session hash.
279
+ # "system" is a legacy value renamed to "setup" — treat them as equivalent.
280
+ def s_source(s)
281
+ src = (s[:source] || "manual").to_s
282
+ src == "system" ? "setup" : src
283
+ end
284
+
285
+ public
286
+
287
+ # Count all cron sessions on disk (not filtered by pagination).
288
+ def cron_count
289
+ return 0 unless @session_manager
290
+ @session_manager.all_sessions.count { |s| s_source(s) == "cron" }
291
+ end
292
+
293
+ # Delete a session from registry (and interrupt its thread).
294
+ def delete(session_id)
295
+ @mutex.synchronize do
296
+ session = @sessions.delete(session_id)
297
+ return false unless session
298
+
299
+ session[:idle_timer]&.cancel
300
+ session[:thread]&.raise(Octo::AgentInterrupted, "Session deleted")
301
+ true
302
+ end
303
+ end
304
+
305
+ # True if the session exists in registry (runtime).
306
+ def exist?(session_id)
307
+ @mutex.synchronize { @sessions.key?(session_id) }
308
+ end
309
+
310
+ # Execute a block with exclusive access to the raw session hash.
311
+ def with_session(session_id)
312
+ @mutex.synchronize do
313
+ session = @sessions[session_id]
314
+ return nil unless session
315
+ yield session
316
+ end
317
+ end
318
+
319
+ # Remove sessions idle longer than SESSION_TIMEOUT.
320
+ def cleanup_stale!
321
+ cutoff = Time.now - SESSION_TIMEOUT
322
+ @mutex.synchronize do
323
+ @sessions.delete_if do |_id, session|
324
+ session[:status] == :idle && session[:updated_at] < cutoff
325
+ end
326
+ end
327
+ end
328
+
329
+ def count_by_status(status)
330
+ @mutex.synchronize do
331
+ @sessions.count { |_, s| s[:status] == status }
332
+ end
333
+ end
334
+
335
+ def max_running_agents
336
+ @agent_config.max_running_agents
337
+ end
338
+
339
+ def max_idle_agents
340
+ @agent_config.max_idle_agents
341
+ end
342
+
343
+ def running_full?
344
+ count_by_status(:running) >= max_running_agents
345
+ end
346
+
347
+ # Evict oldest idle agents beyond MAX_IDLE_AGENTS.
348
+ # Persists session data to disk before releasing the agent from memory.
349
+ def evict_excess_idle!
350
+ to_evict = []
351
+
352
+ @mutex.synchronize do
353
+ idle = @sessions.select { |_, s| s[:status] == :idle && s[:agent] }
354
+ .sort_by { |_, s| s[:updated_at] || Time.at(0) }
355
+
356
+ while idle.size > max_idle_agents
357
+ id, session = idle.shift
358
+ to_evict << [id, session]
359
+ end
360
+ end
361
+
362
+ to_evict.each { |id, session| persist_and_release(id, session) }
363
+ end
364
+
365
+ # Yield [session_id, agent, thread] for each session that currently has
366
+ # an in-memory agent. Used by the worker's graceful-shutdown path to
367
+ # flush any unsaved @history (e.g. a user message added at the start
368
+ # of Agent#run that hasn't yet reached the save-on-completion branch
369
+ # in run_agent_task).
370
+ #
371
+ # The session id list is snapshotted under the mutex so concurrent
372
+ # mutations don't disturb iteration; the yield happens outside the
373
+ # mutex so callers can do slow I/O (JSON serialization, File.write)
374
+ # without blocking other registry operations.
375
+ def each_live_agent
376
+ snapshot = @mutex.synchronize do
377
+ @sessions.filter_map do |id, s|
378
+ agent = s[:agent]
379
+ next nil unless agent
380
+ [id, agent, s[:thread]]
381
+ end
382
+ end
383
+ snapshot.each { |id, agent, thread| yield id, agent, thread }
384
+ end
385
+
386
+ private def persist_and_release(id, session)
387
+ agent = session[:agent]
388
+ @session_manager&.save(agent.to_session_data(status: :success)) if agent
389
+
390
+ @mutex.synchronize do
391
+ s = @sessions[id]
392
+ next unless s
393
+ s[:idle_timer]&.cancel
394
+ s[:agent] = nil
395
+ s[:ui] = nil
396
+ s[:idle_timer] = nil
397
+ s[:thread] = nil
398
+ @sessions.delete(id)
399
+ end
400
+ end
401
+
402
+ # Build a summary hash for API responses (for in-registry sessions).
403
+ # Used when we need live agent fields (name, cost, etc.) after ensure().
404
+ def session_summary(session_id)
405
+ session = @mutex.synchronize { @sessions[session_id] }
406
+ return nil unless session
407
+ agent = session[:agent]
408
+ return nil unless agent
409
+
410
+ model_info = agent.current_model_info
411
+
412
+ {
413
+ id: session[:id],
414
+ name: agent.name,
415
+ working_dir: agent.working_dir,
416
+ status: session[:status],
417
+ created_at: agent.created_at,
418
+ updated_at: session[:updated_at].iso8601,
419
+ total_tasks: agent.total_tasks || 0,
420
+ error: session[:error],
421
+ model: model_info&.dig(:model),
422
+ permission_mode: agent.permission_mode,
423
+ source: agent.source.to_s,
424
+ agent_profile: agent.agent_profile.name,
425
+ pinned: agent.pinned || false,
426
+ latest_latency: agent.latest_latency,
427
+ }
428
+ end
429
+ end
430
+ end
431
+ end