rubino-agent 0.3.0

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 (376) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +115 -0
  4. data/.rubocop_todo.yml +955 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +97 -0
  7. data/CHANGELOG.md +344 -0
  8. data/CONTRIBUTING.md +69 -0
  9. data/LICENSE +21 -0
  10. data/README.md +200 -0
  11. data/Rakefile +8 -0
  12. data/docs/agents.md +190 -0
  13. data/docs/api/v1.md +414 -0
  14. data/docs/architecture.md +177 -0
  15. data/docs/commands.md +375 -0
  16. data/docs/configuration.md +590 -0
  17. data/docs/getting-started.md +143 -0
  18. data/docs/jobs.md +332 -0
  19. data/docs/mcp.md +128 -0
  20. data/docs/memory.md +98 -0
  21. data/docs/models-and-keys.md +173 -0
  22. data/docs/oauth-providers.md +145 -0
  23. data/docs/plugins.md +195 -0
  24. data/docs/security.md +145 -0
  25. data/docs/skills.md +322 -0
  26. data/docs/tools.md +395 -0
  27. data/docs/troubleshooting.md +73 -0
  28. data/exe/rubino +9 -0
  29. data/install.sh +275 -0
  30. data/lib/rubino/active_skill.rb +50 -0
  31. data/lib/rubino/agent/agent_registry.rb +120 -0
  32. data/lib/rubino/agent/backoff_policy.rb +116 -0
  33. data/lib/rubino/agent/definition.rb +128 -0
  34. data/lib/rubino/agent/degenerate_recovery.rb +271 -0
  35. data/lib/rubino/agent/fallback_chain.rb +194 -0
  36. data/lib/rubino/agent/iteration_budget.rb +50 -0
  37. data/lib/rubino/agent/loop.rb +617 -0
  38. data/lib/rubino/agent/model_call_runner.rb +383 -0
  39. data/lib/rubino/agent/prompts/build.txt +69 -0
  40. data/lib/rubino/agent/prompts/compaction.txt +20 -0
  41. data/lib/rubino/agent/prompts/explore.txt +19 -0
  42. data/lib/rubino/agent/prompts/general.txt +20 -0
  43. data/lib/rubino/agent/prompts/plan.txt +31 -0
  44. data/lib/rubino/agent/response_validator.rb +70 -0
  45. data/lib/rubino/agent/router.rb +65 -0
  46. data/lib/rubino/agent/runner.rb +195 -0
  47. data/lib/rubino/agent/tool_executor.rb +402 -0
  48. data/lib/rubino/agent/truncation_continuation.rb +137 -0
  49. data/lib/rubino/api/middleware/auth.rb +43 -0
  50. data/lib/rubino/api/middleware/error_handler.rb +65 -0
  51. data/lib/rubino/api/middleware/json_parser.rb +100 -0
  52. data/lib/rubino/api/middleware/observability.rb +59 -0
  53. data/lib/rubino/api/middleware/rate_limit.rb +136 -0
  54. data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
  55. data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
  56. data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
  57. data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
  58. data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
  59. data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
  60. data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
  61. data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
  62. data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
  63. data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
  64. data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
  65. data/lib/rubino/api/operations/files/read_operation.rb +40 -0
  66. data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
  67. data/lib/rubino/api/operations/health_operation.rb +46 -0
  68. data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
  69. data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
  70. data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
  71. data/lib/rubino/api/operations/metrics_operation.rb +18 -0
  72. data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
  73. data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
  74. data/lib/rubino/api/operations/models/list_operation.rb +45 -0
  75. data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
  76. data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
  77. data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
  78. data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
  79. data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
  80. data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
  81. data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
  82. data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
  83. data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
  84. data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
  85. data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
  86. data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
  87. data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
  88. data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
  89. data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
  90. data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
  91. data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
  92. data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
  93. data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
  94. data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
  95. data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
  96. data/lib/rubino/api/request.rb +54 -0
  97. data/lib/rubino/api/responses.rb +64 -0
  98. data/lib/rubino/api/router.rb +72 -0
  99. data/lib/rubino/api/schemas.rb +103 -0
  100. data/lib/rubino/api/server.rb +102 -0
  101. data/lib/rubino/api/tls.rb +108 -0
  102. data/lib/rubino/attachments/classification.rb +16 -0
  103. data/lib/rubino/attachments/classify.rb +171 -0
  104. data/lib/rubino/attachments/defang.rb +47 -0
  105. data/lib/rubino/attachments/policy.rb +36 -0
  106. data/lib/rubino/attachments/preamble.rb +120 -0
  107. data/lib/rubino/boot/encryption_key.rb +32 -0
  108. data/lib/rubino/cli/chat/bang_shell.rb +257 -0
  109. data/lib/rubino/cli/chat/completion_builder.rb +290 -0
  110. data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
  111. data/lib/rubino/cli/chat/image_inbox.rb +168 -0
  112. data/lib/rubino/cli/chat/session_resolver.rb +176 -0
  113. data/lib/rubino/cli/chat_command.rb +1674 -0
  114. data/lib/rubino/cli/commands.rb +250 -0
  115. data/lib/rubino/cli/config_command.rb +96 -0
  116. data/lib/rubino/cli/doctor_command.rb +251 -0
  117. data/lib/rubino/cli/jobs_command.rb +60 -0
  118. data/lib/rubino/cli/memory_command.rb +135 -0
  119. data/lib/rubino/cli/onboarding_wizard.rb +207 -0
  120. data/lib/rubino/cli/server_command.rb +139 -0
  121. data/lib/rubino/cli/session_command.rb +125 -0
  122. data/lib/rubino/cli/setup_command.rb +107 -0
  123. data/lib/rubino/cli/skills_command.rb +85 -0
  124. data/lib/rubino/cli/tools_command.rb +81 -0
  125. data/lib/rubino/cli/trust_gate.rb +71 -0
  126. data/lib/rubino/commands/built_ins.rb +46 -0
  127. data/lib/rubino/commands/command.rb +116 -0
  128. data/lib/rubino/commands/executor.rb +550 -0
  129. data/lib/rubino/commands/handlers/agents.rb +510 -0
  130. data/lib/rubino/commands/handlers/config.rb +88 -0
  131. data/lib/rubino/commands/handlers/help.rb +148 -0
  132. data/lib/rubino/commands/handlers/jobs.rb +71 -0
  133. data/lib/rubino/commands/handlers/mcp.rb +229 -0
  134. data/lib/rubino/commands/handlers/memory.rb +200 -0
  135. data/lib/rubino/commands/handlers/sessions.rb +207 -0
  136. data/lib/rubino/commands/handlers/skills.rb +195 -0
  137. data/lib/rubino/commands/handlers/status.rb +211 -0
  138. data/lib/rubino/commands/loader.rb +90 -0
  139. data/lib/rubino/config/configuration.rb +455 -0
  140. data/lib/rubino/config/defaults.rb +569 -0
  141. data/lib/rubino/config/loader.rb +115 -0
  142. data/lib/rubino/config/reasoning_prefs.rb +67 -0
  143. data/lib/rubino/config/writer.rb +72 -0
  144. data/lib/rubino/context/compressor.rb +149 -0
  145. data/lib/rubino/context/environment_inspector.rb +176 -0
  146. data/lib/rubino/context/file_discovery.rb +45 -0
  147. data/lib/rubino/context/message_boundary.rb +39 -0
  148. data/lib/rubino/context/prompt_assembler.rb +382 -0
  149. data/lib/rubino/context/summary_builder.rb +159 -0
  150. data/lib/rubino/context/token_budget.rb +68 -0
  151. data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
  152. data/lib/rubino/database/connection.rb +77 -0
  153. data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
  154. data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
  155. data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
  156. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
  157. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
  158. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
  159. data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
  160. data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
  161. data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
  162. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
  163. data/lib/rubino/database/migrator.rb +48 -0
  164. data/lib/rubino/documents/converters/csv.rb +79 -0
  165. data/lib/rubino/documents/converters/docx.rb +129 -0
  166. data/lib/rubino/documents/converters/html.rb +28 -0
  167. data/lib/rubino/documents/converters/json.rb +35 -0
  168. data/lib/rubino/documents/converters/pdf.rb +59 -0
  169. data/lib/rubino/documents/converters/plain.rb +68 -0
  170. data/lib/rubino/documents/converters/pptx.rb +64 -0
  171. data/lib/rubino/documents/converters/xlsx.rb +62 -0
  172. data/lib/rubino/documents/converters/xml.rb +45 -0
  173. data/lib/rubino/documents/html.rb +71 -0
  174. data/lib/rubino/documents/registry.rb +68 -0
  175. data/lib/rubino/documents/table.rb +63 -0
  176. data/lib/rubino/documents.rb +50 -0
  177. data/lib/rubino/errors.rb +119 -0
  178. data/lib/rubino/files/workspace.rb +93 -0
  179. data/lib/rubino/interaction/cancel_token.rb +43 -0
  180. data/lib/rubino/interaction/clipboard_image.rb +84 -0
  181. data/lib/rubino/interaction/event_bus.rb +48 -0
  182. data/lib/rubino/interaction/events.rb +101 -0
  183. data/lib/rubino/interaction/image_input.rb +127 -0
  184. data/lib/rubino/interaction/input_queue.rb +117 -0
  185. data/lib/rubino/interaction/lifecycle.rb +299 -0
  186. data/lib/rubino/interaction/probe.rb +65 -0
  187. data/lib/rubino/interaction/state.rb +56 -0
  188. data/lib/rubino/jobs/cron_job_repository.rb +75 -0
  189. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
  190. data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
  191. data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
  192. data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
  193. data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
  194. data/lib/rubino/jobs/queue.rb +184 -0
  195. data/lib/rubino/jobs/registry.rb +45 -0
  196. data/lib/rubino/jobs/runner.rb +79 -0
  197. data/lib/rubino/jobs/scheduler.rb +138 -0
  198. data/lib/rubino/jobs/webhook_delivery.rb +225 -0
  199. data/lib/rubino/jobs/worker.rb +59 -0
  200. data/lib/rubino/llm/adapter_factory.rb +47 -0
  201. data/lib/rubino/llm/adapter_response.rb +65 -0
  202. data/lib/rubino/llm/auxiliary_client.rb +61 -0
  203. data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
  204. data/lib/rubino/llm/content_builder.rb +55 -0
  205. data/lib/rubino/llm/credential_check.rb +93 -0
  206. data/lib/rubino/llm/error_classifier.rb +364 -0
  207. data/lib/rubino/llm/fake_provider.rb +292 -0
  208. data/lib/rubino/llm/inline_think_filter.rb +58 -0
  209. data/lib/rubino/llm/model_catalog.rb +29 -0
  210. data/lib/rubino/llm/provider_resolver.rb +48 -0
  211. data/lib/rubino/llm/reasoning_manager.rb +100 -0
  212. data/lib/rubino/llm/request.rb +56 -0
  213. data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
  214. data/lib/rubino/llm/scenario_loader.rb +68 -0
  215. data/lib/rubino/llm/scenario_selector.rb +80 -0
  216. data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
  217. data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
  218. data/lib/rubino/llm/scenarios/analysis.yml +501 -0
  219. data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
  220. data/lib/rubino/llm/scenarios/failure.yml +65 -0
  221. data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
  222. data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
  223. data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
  224. data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
  225. data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
  226. data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
  227. data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
  228. data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
  229. data/lib/rubino/llm/thinking_support.rb +84 -0
  230. data/lib/rubino/llm/tool_bridge.rb +89 -0
  231. data/lib/rubino/logger.rb +99 -0
  232. data/lib/rubino/mcp/manager.rb +180 -0
  233. data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
  234. data/lib/rubino/mcp.rb +57 -0
  235. data/lib/rubino/memory/backend.rb +104 -0
  236. data/lib/rubino/memory/backends/default.rb +101 -0
  237. data/lib/rubino/memory/backends/sqlite.rb +653 -0
  238. data/lib/rubino/memory/backends.rb +53 -0
  239. data/lib/rubino/memory/deduplicator.rb +74 -0
  240. data/lib/rubino/memory/extractor.rb +85 -0
  241. data/lib/rubino/memory/flusher.rb +31 -0
  242. data/lib/rubino/memory/retriever.rb +50 -0
  243. data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
  244. data/lib/rubino/memory/sqlite_graph.rb +154 -0
  245. data/lib/rubino/memory/store.rb +228 -0
  246. data/lib/rubino/memory/threat_scanner.rb +68 -0
  247. data/lib/rubino/metrics.rb +175 -0
  248. data/lib/rubino/modes.rb +93 -0
  249. data/lib/rubino/oauth/connection_repository.rb +95 -0
  250. data/lib/rubino/oauth/provider/github.rb +75 -0
  251. data/lib/rubino/oauth/provider/google.rb +59 -0
  252. data/lib/rubino/oauth/provider.rb +149 -0
  253. data/lib/rubino/oauth/registry.rb +86 -0
  254. data/lib/rubino/oauth/token_encryptor.rb +87 -0
  255. data/lib/rubino/plugins/registry.rb +75 -0
  256. data/lib/rubino/plugins.rb +86 -0
  257. data/lib/rubino/run/approval_gate.rb +243 -0
  258. data/lib/rubino/run/attachment_downloader.rb +166 -0
  259. data/lib/rubino/run/event_store.rb +74 -0
  260. data/lib/rubino/run/executor.rb +383 -0
  261. data/lib/rubino/run/gate_registry.rb +39 -0
  262. data/lib/rubino/run/recorder.rb +69 -0
  263. data/lib/rubino/run/repository.rb +118 -0
  264. data/lib/rubino/run/session_approval_cache.rb +118 -0
  265. data/lib/rubino/security/allowlist_persister.rb +55 -0
  266. data/lib/rubino/security/approval_policy.rb +227 -0
  267. data/lib/rubino/security/command_allowlist.rb +24 -0
  268. data/lib/rubino/security/dangerous_patterns.rb +118 -0
  269. data/lib/rubino/security/deny_persister.rb +73 -0
  270. data/lib/rubino/security/doom_loop_detector.rb +43 -0
  271. data/lib/rubino/security/hardline_guard.rb +105 -0
  272. data/lib/rubino/security/pattern_matcher.rb +62 -0
  273. data/lib/rubino/security/prefix_deriver.rb +124 -0
  274. data/lib/rubino/security/readonly_commands.rb +211 -0
  275. data/lib/rubino/session/exporter.rb +101 -0
  276. data/lib/rubino/session/message.rb +77 -0
  277. data/lib/rubino/session/repository.rb +295 -0
  278. data/lib/rubino/session/store.rb +198 -0
  279. data/lib/rubino/session/summary_store.rb +65 -0
  280. data/lib/rubino/skills/prompt_index.rb +85 -0
  281. data/lib/rubino/skills/registry.rb +208 -0
  282. data/lib/rubino/skills/skill.rb +176 -0
  283. data/lib/rubino/skills/skill_tool.rb +215 -0
  284. data/lib/rubino/skills/state_repository.rb +37 -0
  285. data/lib/rubino/skills/toggle.rb +26 -0
  286. data/lib/rubino/tools/answer_child_tool.rb +83 -0
  287. data/lib/rubino/tools/ask_parent_tool.rb +232 -0
  288. data/lib/rubino/tools/attach_file_tool.rb +120 -0
  289. data/lib/rubino/tools/background_tasks.rb +520 -0
  290. data/lib/rubino/tools/base.rb +222 -0
  291. data/lib/rubino/tools/custom_tool_loader.rb +119 -0
  292. data/lib/rubino/tools/edit_tool.rb +122 -0
  293. data/lib/rubino/tools/git_tool.rb +71 -0
  294. data/lib/rubino/tools/github_tool.rb +233 -0
  295. data/lib/rubino/tools/glob_tool.rb +69 -0
  296. data/lib/rubino/tools/grep_tool.rb +206 -0
  297. data/lib/rubino/tools/memory_tool.rb +184 -0
  298. data/lib/rubino/tools/multi_edit_tool.rb +110 -0
  299. data/lib/rubino/tools/patch_tool.rb +260 -0
  300. data/lib/rubino/tools/probe_tool.rb +175 -0
  301. data/lib/rubino/tools/question_tool.rb +128 -0
  302. data/lib/rubino/tools/read_attachment_tool.rb +180 -0
  303. data/lib/rubino/tools/read_tool.rb +212 -0
  304. data/lib/rubino/tools/read_tracker.rb +98 -0
  305. data/lib/rubino/tools/registry.rb +166 -0
  306. data/lib/rubino/tools/result.rb +113 -0
  307. data/lib/rubino/tools/ruby_tool.rb +0 -0
  308. data/lib/rubino/tools/session_search_tool.rb +103 -0
  309. data/lib/rubino/tools/shell_input_tool.rb +96 -0
  310. data/lib/rubino/tools/shell_kill_tool.rb +76 -0
  311. data/lib/rubino/tools/shell_output_tool.rb +72 -0
  312. data/lib/rubino/tools/shell_registry.rb +158 -0
  313. data/lib/rubino/tools/shell_tail_tool.rb +118 -0
  314. data/lib/rubino/tools/shell_tool.rb +330 -0
  315. data/lib/rubino/tools/steer_tool.rb +118 -0
  316. data/lib/rubino/tools/subagent_probe.rb +89 -0
  317. data/lib/rubino/tools/summarize_file_tool.rb +182 -0
  318. data/lib/rubino/tools/task_result_tool.rb +90 -0
  319. data/lib/rubino/tools/task_stop_tool.rb +80 -0
  320. data/lib/rubino/tools/task_tool.rb +622 -0
  321. data/lib/rubino/tools/test_tool.rb +454 -0
  322. data/lib/rubino/tools/todo_tool.rb +93 -0
  323. data/lib/rubino/tools/tool_call_repository.rb +33 -0
  324. data/lib/rubino/tools/vision_tool.rb +85 -0
  325. data/lib/rubino/tools/webfetch_tool.rb +153 -0
  326. data/lib/rubino/tools/websearch_tool.rb +179 -0
  327. data/lib/rubino/tools/write_tool.rb +61 -0
  328. data/lib/rubino/trust.rb +88 -0
  329. data/lib/rubino/ui/api.rb +296 -0
  330. data/lib/rubino/ui/base.rb +252 -0
  331. data/lib/rubino/ui/bottom_composer.rb +1599 -0
  332. data/lib/rubino/ui/cli.rb +1987 -0
  333. data/lib/rubino/ui/completion_menu.rb +321 -0
  334. data/lib/rubino/ui/completion_source.rb +284 -0
  335. data/lib/rubino/ui/escape_reader.rb +169 -0
  336. data/lib/rubino/ui/indented_io.rb +88 -0
  337. data/lib/rubino/ui/input_history.rb +108 -0
  338. data/lib/rubino/ui/live_region.rb +183 -0
  339. data/lib/rubino/ui/markdown_renderer.rb +506 -0
  340. data/lib/rubino/ui/notifier.rb +163 -0
  341. data/lib/rubino/ui/null.rb +195 -0
  342. data/lib/rubino/ui/paste_store.rb +176 -0
  343. data/lib/rubino/ui/printer_base.rb +79 -0
  344. data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
  345. data/lib/rubino/ui/queued_indicators.rb +66 -0
  346. data/lib/rubino/ui/status_bar.rb +100 -0
  347. data/lib/rubino/ui/stdout_proxy.rb +161 -0
  348. data/lib/rubino/ui/streaming_markdown.rb +186 -0
  349. data/lib/rubino/ui/subagent_cards.rb +134 -0
  350. data/lib/rubino/ui/subagent_view.rb +255 -0
  351. data/lib/rubino/ui.rb +21 -0
  352. data/lib/rubino/update_check.rb +187 -0
  353. data/lib/rubino/util/duration.rb +23 -0
  354. data/lib/rubino/util/hyperlink.rb +105 -0
  355. data/lib/rubino/util/output.rb +145 -0
  356. data/lib/rubino/util/secrets_mask.rb +83 -0
  357. data/lib/rubino/version.rb +5 -0
  358. data/lib/rubino/workspace.rb +85 -0
  359. data/lib/rubino-agent.rb +5 -0
  360. data/lib/rubino.rb +318 -0
  361. data/mise.toml +2 -0
  362. data/rubino-agent.gemspec +103 -0
  363. data/skills/ruby-expert/SKILL.md +67 -0
  364. data/skills/ruby-expert/references/concurrency.md +357 -0
  365. data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
  366. data/skills/ruby-expert/references/errors-and-types.md +460 -0
  367. data/skills/ruby-expert/references/gem-authoring.md +459 -0
  368. data/skills/ruby-expert/references/language-idioms.md +465 -0
  369. data/skills/ruby-expert/references/metaprogramming.md +339 -0
  370. data/skills/ruby-expert/references/oo-design.md +553 -0
  371. data/skills/ruby-expert/references/performance.md +383 -0
  372. data/skills/ruby-expert/references/rails.md +424 -0
  373. data/skills/ruby-expert/references/security.md +404 -0
  374. data/skills/ruby-expert/references/testing.md +473 -0
  375. data/skills/ruby-expert/references/tooling.md +466 -0
  376. metadata +856 -0
@@ -0,0 +1,520 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Rubino
6
+ module Tools
7
+ # Process-wide registry for subagents started by the `task` tool in the
8
+ # BACKGROUND (the default). Mirrors ShellRegistry — the in-repo precedent
9
+ # for "fire-and-forget + poll later + kill" — but the unit of work is a
10
+ # nested Agent::Runner thread instead of a detached OS process.
11
+ #
12
+ # Each entry owns:
13
+ # - the worker Thread running the child Runner#run!,
14
+ # - the child Runner (so #cancel can flip its CancelToken — exactly the
15
+ # mechanism Run::Executor's stop-watcher uses for top-level runs),
16
+ # - the terminal status/result/error captured in the worker's `ensure`.
17
+ #
18
+ # The registry survives a single CLI/server process — like ShellRegistry it
19
+ # is intentionally NOT persisted. Background subagents die with the process.
20
+ #
21
+ # Concurrency cap (mirrors the reference _DEFAULT_MAX_CONCURRENT_CHILDREN = 3): a
22
+ # background subagent is a full LLM run = real cost, so #spawn refuses past
23
+ # MAX_CONCURRENT live children rather than fanning out unbounded threads.
24
+ class BackgroundTasks
25
+ MAX_CONCURRENT = 3
26
+
27
+ # Fallback caps for the nested-subagent tree, used when config is absent
28
+ # (e.g. a bare registry in a unit test with no Configuration wired). The
29
+ # live values come from config (tasks.max_depth / max_children_per_node /
30
+ # max_concurrent_total); these constants are the built-in defaults the
31
+ # config keys themselves default to. All three are enforced in #reserve.
32
+ MAX_DEPTH = 2
33
+ MAX_CHILDREN_PER_NODE = 3
34
+ MAX_CONCURRENT_TOTAL = 8
35
+
36
+ # last_activity / tool_count / activity_log — live-progress fields written
37
+ # by UI::SubagentView#tool_started / #tool_finished (via
38
+ # #record_tool_started / #record_tool_finished) under the registry mutex
39
+ # and read by the parent renderer (UI::SubagentCards) and
40
+ # the /agents drill-in. activity_log is a bounded ring of the last few
41
+ # `✓ verb · hint` lines for the live drill-in; nothing is persisted (it
42
+ # dies with the process, like the rest of the registry).
43
+ #
44
+ # approval_gate / approval_question / approval_command are the
45
+ # Option-2 approval-surfacing state: when a background child's tool needs
46
+ # approval the child thread parks on `approval_gate` (a Run::ApprovalGate)
47
+ # and the entry flips to status :needs_approval with the question/command
48
+ # shown on the card; the user resolves it via /agents <id>.
49
+ Entry = Struct.new(
50
+ :id, :subagent, :prompt, :status, :result, :error,
51
+ :thread, :runner, :started_at, :finished_at,
52
+ :last_activity, :tool_count, :activity_log,
53
+ :approval_gate, :approval_id, :approval_question, :approval_command,
54
+ # Parent->child steer (the `/agents <id> steer "..."` note). Wired into
55
+ # the child Loop as its Interaction::InputQueue (the SAME turn-boundary
56
+ # steering channel the human uses on the parent); the parent pushes a
57
+ # note, the child folds it in at its next iteration via
58
+ # Loop#inject_steered_input. nil ⇒ no steer wire (sync/foreground path).
59
+ :steer_queue,
60
+ # child->parent ask_parent escalation (Run::ApprovalGate handoff). When a
61
+ # subagent calls ask_parent and it escalates to the HUMAN, the child
62
+ # parks on `ask_gate` keyed by `ask_id`, the entry flips to
63
+ # :blocked_on_human, and the card/banner surface `ask_question`. A
64
+ # blocking ask holds the child's worker thread on the gate (bounded only
65
+ # by an explicit /reply or stop — see ask_parent_tool.rb); a non-blocking
66
+ # ask returns immediately and the answer is delivered later via
67
+ # `steer_queue`. The human answers via /reply <id>, which decides the gate.
68
+ :ask_gate, :ask_id, :ask_question, :ask_blocking,
69
+ # Ownership link (S1 — foundation for model-driven steer/probe/ask_parent).
70
+ # owner_subagent_id is the `sa_*` id of the subagent that spawned this
71
+ # child, or nil when the spawner is the human / top-level agent. depth is
72
+ # 0 for a human-spawned child and owner.depth + 1 otherwise. The registry
73
+ # stays a FLAT map keyed by id; the parent/child tree is computed over
74
+ # owner_subagent_id (see #children_of / #descendants_of / #ancestors_of).
75
+ :owner_subagent_id, :depth,
76
+ # Model-driven LIVE-probe budget (S3). probe_count is how many BILLED
77
+ # `probe(live:true)` peeks the owner has run against this child;
78
+ # last_probe_at is when the last one ran (for an optional min-interval).
79
+ # Free snapshot probes (live:false) never touch these. Per-process, dies
80
+ # with the registry like the rest of the live-progress state.
81
+ :probe_count, :last_probe_at,
82
+ # The SPAWNING side's input queue, captured on the PARENT thread at
83
+ # spawn time (TaskTool#run_background) — the same spawn-captured sink
84
+ # the [background-task] completion notice rides. ask_parent's
85
+ # [subagent-question] notice for a top-level-owned child MUST use this:
86
+ # reading the thread-local Rubino.background_sink on the CHILD's thread
87
+ # resolves to the child's OWN steer_queue and misroutes the question
88
+ # back into the asking child (#195). nil ⇒ no queue was wired
89
+ # (sync/foreground spawn, headless).
90
+ :parent_sink,
91
+ keyword_init: true
92
+ )
93
+
94
+ # How many recent activity lines the drill-in shows (the live `recent:` ring).
95
+ ACTIVITY_LOG_MAX = 6
96
+
97
+ class << self
98
+ def instance
99
+ @instance ||= new
100
+ end
101
+
102
+ # Test seam: drop all state between examples.
103
+ def reset!
104
+ @instance = nil
105
+ end
106
+ end
107
+
108
+ def initialize
109
+ @entries = {}
110
+ @mutex = Mutex.new
111
+ end
112
+
113
+ # Reserves a slot and registers a `running` entry, returning it. The
114
+ # caller then attaches the worker thread + runner via #attach.
115
+ #
116
+ # owner_subagent_id is the `sa_*` id of the SPAWNING subagent (nil ⇒ the
117
+ # human / top-level agent spawned this child). depth is the caller's hint
118
+ # for a human-spawned child (0); for an owner-spawned child the depth is
119
+ # recomputed here from the owner entry (owner.depth + 1) so a stale hint
120
+ # can't smuggle a child past the depth cap.
121
+ #
122
+ # Returns nil — so TaskTool can surface a clear message instead of spawning
123
+ # unbounded work — when ANY of the three nesting caps is hit. The reason is
124
+ # available via #last_refusal_reason for the caller to phrase the message:
125
+ # :depth — depth >= max_depth (no deeper nesting allowed)
126
+ # :per_owner — this owner already has max_children_per_node live kids
127
+ # :global — total live subagents across the tree >= max total
128
+ # This is the SINGLE enforcement point for every nesting limit.
129
+ def reserve(subagent:, prompt:, owner_subagent_id: nil, depth: 0)
130
+ @mutex.synchronize do
131
+ owner = owner_subagent_id ? @entries[owner_subagent_id] : nil
132
+ effective_depth = owner ? owner.depth.to_i + 1 : depth.to_i
133
+
134
+ @last_refusal_reason = refusal_reason(owner_subagent_id, effective_depth)
135
+ return nil if @last_refusal_reason
136
+
137
+ entry = Entry.new(
138
+ id: new_id,
139
+ subagent: subagent.to_s,
140
+ prompt: prompt.to_s,
141
+ status: :running,
142
+ started_at: Time.now,
143
+ tool_count: 0,
144
+ activity_log: [],
145
+ # Every background child gets its OWN steering queue at reserve time
146
+ # so the parent can `/agents <id> steer "..."` it the instant it is
147
+ # listed — no separate wiring step, no nil window.
148
+ steer_queue: Interaction::InputQueue.new,
149
+ owner_subagent_id: owner_subagent_id,
150
+ depth: effective_depth
151
+ )
152
+ @entries[entry.id] = entry
153
+ entry
154
+ end
155
+ end
156
+
157
+ # Why the most recent #reserve returned nil (one of :depth / :per_owner /
158
+ # :global), or nil when the last reserve succeeded. Read by TaskTool to
159
+ # phrase a reason-specific at-capacity message.
160
+ attr_reader :last_refusal_reason
161
+
162
+ # Binds the live worker thread + child runner to a reserved entry so the
163
+ # registry can later cancel it. Done after reserve so the entry exists in
164
+ # the map before the thread starts (no race on completion writing back).
165
+ def attach(entry, thread:, runner:)
166
+ @mutex.synchronize do
167
+ entry.thread = thread
168
+ entry.runner = runner
169
+ end
170
+ end
171
+
172
+ # Marks a stop REQUEST (the /agents <id> --stop / task_stop path) on a
173
+ # live entry so the list/cards immediately show ◌ stopping instead of a
174
+ # stale ● running while the child unwinds at its next checkpoint (#108).
175
+ # Returns true when the entry flipped. #complete then maps a failure on
176
+ # a :stopping entry to the terminal :stopped, so a deliberate stop never
177
+ # reads as ✗ failed (#13).
178
+ def request_stop(id)
179
+ @mutex.synchronize do
180
+ entry = @entries[id]
181
+ return false unless entry && live_status?(entry.status)
182
+
183
+ entry.status = :stopping
184
+ true
185
+ end
186
+ end
187
+
188
+ # Records terminal state when the worker finishes (called from its
189
+ # `ensure`). Single writer per entry, but guarded so #find/#list readers
190
+ # see a consistent snapshot. A failure landing on a :stopping entry is a
191
+ # USER-REQUESTED stop unwinding (Interrupted at the next checkpoint), so
192
+ # it is recorded as :stopped — distinct from a genuine :failed (#108/#13).
193
+ def complete(entry, status:, result: nil, error: nil)
194
+ @mutex.synchronize do
195
+ status = :stopped if entry.status == :stopping && status == :failed
196
+ entry.status = status
197
+ entry.result = result
198
+ entry.error = error
199
+ entry.finished_at = Time.now
200
+ end
201
+ end
202
+
203
+ # Records a child tool STARTING: bumps the tool counter and sets the
204
+ # last-activity string the card/list show so concurrent tasks stay
205
+ # distinguishable (#124/#127). Called from UI::SubagentView#tool_started,
206
+ # which runs on the CHILD thread, so it MUST take the mutex (the parent
207
+ # renderer reads these fields concurrently). No-op for an unknown id (a late event
208
+ # after #remove).
209
+ def record_tool_started(id, activity)
210
+ @mutex.synchronize do
211
+ entry = @entries[id]
212
+ return unless entry
213
+
214
+ entry.tool_count = entry.tool_count.to_i + 1
215
+ entry.last_activity = activity.to_s
216
+ end
217
+ end
218
+
219
+ # Records a child tool FINISHING: appends a terse line to the bounded
220
+ # activity ring the live drill-in (#71) tails. Keeps the last
221
+ # ACTIVITY_LOG_MAX entries so the ring never grows unbounded for a
222
+ # read-heavy child.
223
+ def record_tool_finished(id, line)
224
+ @mutex.synchronize do
225
+ entry = @entries[id]
226
+ return unless entry
227
+
228
+ log = (entry.activity_log ||= [])
229
+ log << line.to_s
230
+ log.shift while log.size > ACTIVITY_LOG_MAX
231
+ end
232
+ end
233
+
234
+ # Flips an entry into the :needs_approval state and stores the gate +
235
+ # question/command the card surfaces (Option 2). The child thread then
236
+ # parks on `gate.await(approval_id)`; the user resolves it via
237
+ # /agents <id>. Returns the previous status so the child can restore it.
238
+ def begin_approval(id, gate:, approval_id:, question:, command:)
239
+ @mutex.synchronize do
240
+ entry = @entries[id]
241
+ return unless entry
242
+
243
+ entry.approval_gate = gate
244
+ entry.approval_id = approval_id
245
+ entry.approval_question = question.to_s
246
+ entry.approval_command = command.to_s
247
+ entry.status = :needs_approval
248
+ end
249
+ end
250
+
251
+ # Clears the approval state and returns the entry to :running once a
252
+ # decision has been delivered (or the child unwinds).
253
+ def end_approval(id)
254
+ @mutex.synchronize do
255
+ entry = @entries[id]
256
+ return unless entry
257
+
258
+ entry.approval_gate = nil
259
+ entry.approval_id = nil
260
+ entry.approval_question = nil
261
+ entry.approval_command = nil
262
+ entry.status = :running if entry.status == :needs_approval
263
+ end
264
+ end
265
+
266
+ # Records a parent->child steer note (the `/agents <id> steer \"...\"`
267
+ # affordance). Pushes the text onto the child's steering queue, which the
268
+ # child Loop drains at its next iteration boundary (Loop#inject_steered_input)
269
+ # — between turns, never between a tool_use and its results. Best-effort:
270
+ # returns false (and pushes nothing) when the entry is gone or has no queue
271
+ # (e.g. a finished child), true when the note was queued.
272
+ def steer(id, text)
273
+ queue = @mutex.synchronize do
274
+ entry = @entries[id]
275
+ entry&.steer_queue
276
+ end
277
+ return false unless queue
278
+
279
+ queue.push(text)
280
+ true
281
+ end
282
+
283
+ # Records a BILLED live probe against a child (S3): bumps probe_count and
284
+ # stamps last_probe_at, under the mutex (the owner runs this on its own
285
+ # thread while the parent renderer may read the entry). Returns the new
286
+ # count, or nil for an unknown id. Free snapshot probes (live:false) never
287
+ # call this — only `probe(live:true)` does, after the budget check passes.
288
+ def record_live_probe(id)
289
+ @mutex.synchronize do
290
+ entry = @entries[id]
291
+ return nil unless entry
292
+
293
+ entry.probe_count = entry.probe_count.to_i + 1
294
+ entry.last_probe_at = Time.now
295
+ entry.probe_count
296
+ end
297
+ end
298
+
299
+ # Flips an entry into the :blocked_on_human state for an escalated
300
+ # ask_parent: stores the gate + question + blocking flag the card/banner
301
+ # surface (mirror of #begin_approval, but for a child->parent question that
302
+ # the parent couldn't answer and escalated to the human). The child thread
303
+ # then parks on `ask_gate.await(ask_id)` (blocking ask) until /reply <id>
304
+ # decides the gate, or keeps working (non-blocking ask) with the answer
305
+ # delivered later via the steer queue. A child in this state still holds a
306
+ # concurrency slot (its thread is alive, or it is awaiting the human), so it
307
+ # counts as live.
308
+ # The status depends on WHO owns the asking child (S4): owner_id present (an
309
+ # agent-parent) → :blocked_on_parent (the parent MODEL answers via
310
+ # answer_child; the question was pushed onto the owner's steer_queue, NOT
311
+ # the human's job); owner_id nil (the human / top-level) → :blocked_on_human
312
+ # (the human answers via /reply <id>).
313
+ def begin_ask(id, gate:, ask_id:, question:, blocking:, owner_id: nil)
314
+ @mutex.synchronize do
315
+ entry = @entries[id]
316
+ return unless entry
317
+
318
+ entry.ask_gate = gate
319
+ entry.ask_id = ask_id
320
+ entry.ask_question = question.to_s
321
+ entry.ask_blocking = blocking ? true : false
322
+ entry.status = owner_id ? :blocked_on_parent : :blocked_on_human
323
+ end
324
+ end
325
+
326
+ # Clears the ask state and returns the entry to :running once the question
327
+ # has been answered (by the human via /reply, or the agent-parent via
328
+ # answer_child), or the child unwinds / is stopped.
329
+ def end_ask(id)
330
+ @mutex.synchronize do
331
+ entry = @entries[id]
332
+ return unless entry
333
+
334
+ entry.ask_gate = nil
335
+ entry.ask_id = nil
336
+ entry.ask_question = nil
337
+ entry.ask_blocking = nil
338
+ entry.status = :running if %i[blocked_on_human blocked_on_parent].include?(entry.status)
339
+ end
340
+ end
341
+
342
+ # The ONE shared answer wire for an escalated ask_parent, used by BOTH the
343
+ # human /reply path (Commands::Executor#deliver_reply) and the model-callable
344
+ # `answer_child` tool: route the answer back DOWN to the asking child by
345
+ # (1) deciding its ask gate — unblocks a BLOCKING ask with the answer as its
346
+ # tool result — and (2) pushing the answer onto its steer queue so a
347
+ # NON-BLOCKING ask folds it in at its next turn boundary; then clear the
348
+ # blocked state (#end_ask). Either way the answer PERSISTS in the child's
349
+ # context. No-op (returns false) for an unknown id or one not awaiting an
350
+ # answer (no ask_gate); true when the answer was routed.
351
+ def deliver_answer(id, answer)
352
+ entry = find(id)
353
+ return false unless entry&.ask_gate
354
+
355
+ entry.ask_gate.decide(entry.ask_id, answer)
356
+ steer(entry.id, "[parent answer] #{answer}")
357
+ end_ask(entry.id)
358
+ true
359
+ end
360
+
361
+ # Entries parked on an escalated ask_parent, waiting on THE HUMAN — the
362
+ # source of the persistent \"\u26d4 N subagent waiting on you\" marker and
363
+ # answerable via /reply <id>. Counts ONLY :blocked_on_human: a
364
+ # :blocked_on_parent child is its agent-parent's job (answer_child), not the
365
+ # human's, so it must NOT inflate the human's "waiting on you" count.
366
+ def awaiting_human
367
+ @mutex.synchronize { @entries.values.select { |e| e.status == :blocked_on_human } }
368
+ end
369
+
370
+ # Entries currently parked on a human approval — surfaced on their card
371
+ # and answerable via /agents <id>.
372
+ def awaiting_approval
373
+ @mutex.synchronize { @entries.values.select { |e| e.status == :needs_approval } }
374
+ end
375
+
376
+ def find(id)
377
+ @mutex.synchronize { @entries[id] }
378
+ end
379
+
380
+ # All entries, newest first — for a `task` listing (the /tasks analogue).
381
+ def list
382
+ @mutex.synchronize { @entries.values.sort_by(&:started_at).reverse }
383
+ end
384
+
385
+ # Live (still-running) children — used by the parent stop path to cancel
386
+ # orphans, and to enforce the concurrency cap. A child parked on a human
387
+ # approval (:needs_approval) is STILL live (its thread is alive, holding a
388
+ # slot), so it counts as running here.
389
+ def running
390
+ @mutex.synchronize { @entries.values.select { |e| live_status?(e.status) } }
391
+ end
392
+
393
+ def remove(id)
394
+ @mutex.synchronize { @entries.delete(id) }
395
+ end
396
+
397
+ # --- Tree over owner_subagent_id (the registry stays a flat map) ---------
398
+
399
+ # Direct children of `id`: entries whose owner_subagent_id == id. Pass nil
400
+ # for the human/top-level node's direct children.
401
+ def children_of(id)
402
+ @mutex.synchronize { @entries.values.select { |e| e.owner_subagent_id == id } }
403
+ end
404
+
405
+ # All transitive descendants of `id` (BFS over owner_subagent_id), in
406
+ # breadth order. Cycle-safe (an id is visited at most once).
407
+ def descendants_of(id)
408
+ @mutex.synchronize do
409
+ out = []
410
+ seen = {}
411
+ frontier = @entries.values.select { |e| e.owner_subagent_id == id }
412
+ until frontier.empty?
413
+ nxt = []
414
+ frontier.each do |e|
415
+ next if seen[e.id]
416
+
417
+ seen[e.id] = true
418
+ out << e
419
+ nxt.concat(@entries.values.select { |c| c.owner_subagent_id == e.id })
420
+ end
421
+ frontier = nxt
422
+ end
423
+ out
424
+ end
425
+ end
426
+
427
+ # The chain of ancestors of `id`, nearest parent first, walking
428
+ # owner_subagent_id up to the human/top-level root. Cycle-safe.
429
+ def ancestors_of(id)
430
+ @mutex.synchronize do
431
+ out = []
432
+ seen = { id => true }
433
+ cur = @entries[id]&.owner_subagent_id
434
+ while cur && (entry = @entries[cur]) && !seen[cur]
435
+ seen[cur] = true
436
+ out << entry
437
+ cur = entry.owner_subagent_id
438
+ end
439
+ out
440
+ end
441
+ end
442
+
443
+ # Stop-cascade (S5a): when a node is stopped, cancel the ask-gates of ALL
444
+ # its descendants so a blocking ask anywhere in the subtree unwinds at once
445
+ # (Run::ApprovalGate#cancel! wakes the parked child thread with Interrupted)
446
+ # instead of leaving an orphaned grandchild parked until its bound elapses.
447
+ # The descendant runners' CancelTokens are flipped by the caller's cancel!
448
+ # of the node; this just makes the gate-parked ones wake immediately. Safe
449
+ # to call on a node with no descendants or no blocked descendants.
450
+ def cancel_descendant_ask_gates(id)
451
+ descendants_of(id).each { |e| e.ask_gate&.cancel! }
452
+ end
453
+
454
+ # True iff `child_id`'s direct owner is `parent_id` (the ownership predicate
455
+ # later slices' steer/probe/answer_child AUTHORIZATION checks will build on).
456
+ def owned_by?(parent_id, child_id)
457
+ @mutex.synchronize do
458
+ child = @entries[child_id]
459
+ !child.nil? && child.owner_subagent_id == parent_id
460
+ end
461
+ end
462
+
463
+ private
464
+
465
+ # The reason (if any) a reserve at this owner/depth must be refused, checked
466
+ # in the documented order. nil ⇒ allowed. Runs UNDER the mutex (callers hold
467
+ # it), reading the live entry map for the per-owner and global live counts.
468
+ def refusal_reason(owner_subagent_id, effective_depth)
469
+ return :depth if effective_depth >= max_depth
470
+ return :global if running_count >= max_concurrent_total
471
+
472
+ live_children = @entries.values.count do |e|
473
+ e.owner_subagent_id == owner_subagent_id && live_status?(e.status)
474
+ end
475
+ return :per_owner if live_children >= max_children_per_node
476
+
477
+ nil
478
+ end
479
+
480
+ # Live cap values, from config when wired, else the built-in constants (so a
481
+ # bare registry in a unit test with no Configuration still has sane caps).
482
+ def max_depth
483
+ config_int(:tasks_max_depth, MAX_DEPTH)
484
+ end
485
+
486
+ def max_children_per_node
487
+ config_int(:tasks_max_children_per_node, MAX_CHILDREN_PER_NODE)
488
+ end
489
+
490
+ def max_concurrent_total
491
+ config_int(:tasks_max_concurrent_total, MAX_CONCURRENT_TOTAL)
492
+ end
493
+
494
+ def config_int(accessor, fallback)
495
+ cfg = Rubino.configuration if Rubino.respond_to?(:configuration)
496
+ val = cfg&.respond_to?(accessor) ? cfg.public_send(accessor) : nil
497
+ Integer(val)
498
+ rescue StandardError, TypeError, ArgumentError
499
+ fallback
500
+ end
501
+
502
+ # A child holds a concurrency slot while its thread is alive — whether
503
+ # actively running, parked on a human approval, parked on an escalated
504
+ # ask_parent question (waiting on the human OR on its agent-parent), or
505
+ # unwinding after a stop request (:stopping). All of these hold a live
506
+ # thread, so all count as live.
507
+ def live_status?(status)
508
+ %i[running needs_approval blocked_on_human blocked_on_parent stopping].include?(status)
509
+ end
510
+
511
+ def running_count
512
+ @entries.values.count { |e| live_status?(e.status) }
513
+ end
514
+
515
+ def new_id
516
+ "sa_#{SecureRandom.hex(4)}"
517
+ end
518
+ end
519
+ end
520
+ end