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,510 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "time"
5
+
6
+ module Rubino
7
+ module Commands
8
+ module Handlers
9
+ # The `/agents` (alias `/tasks`) drill-in surface and the `/reply` answer
10
+ # path, extracted from Commands::Executor (batch B).
11
+ #
12
+ # The "see what other agents do" surface. Lists background subagents from
13
+ # the BackgroundTasks registry (the async `task` substrate), drills into a
14
+ # single one's result/error, steers/probes/stops a running one, and routes
15
+ # a human /reply back down to a blocked child.
16
+ #
17
+ # /agents → list
18
+ # /agents <id> → drill-in (result / error / status)
19
+ # /agents <id> --stop → cancel a running subagent
20
+ # /agents <id> steer "…" → fire-and-forget note into the child's context
21
+ # /agents <id> probe "…" → ephemeral read-only peek
22
+ # /reply <id> <answer> → answer a child blocked on a human/parent ask
23
+ class Agents
24
+ include Rubino::UI::ProbeWaitIndicator
25
+
26
+ # How many times the parked-child approval prompt re-renders after an
27
+ # empty/aborted read (#144) before giving up and leaving the child parked.
28
+ APPROVAL_ASK_ATTEMPTS = 3
29
+
30
+ def initialize(ui:)
31
+ @ui = ui
32
+ end
33
+
34
+ def handle_agents(arguments)
35
+ args = arguments.to_s.strip
36
+
37
+ if args.empty?
38
+ show_agents_list
39
+ return
40
+ end
41
+
42
+ tokens = args.split(/\s+/)
43
+ stop = tokens.delete("--stop") ? true : false
44
+ id = tokens.shift
45
+
46
+ if id.nil? || id.empty?
47
+ show_agents_list
48
+ elsif stop
49
+ stop_agent(id)
50
+ elsif tokens.first == "steer"
51
+ steer_agent(id, dequote(tokens[1..].join(" ")))
52
+ elsif tokens.first == "probe"
53
+ probe_agent(id, dequote(tokens[1..].join(" ")))
54
+ else
55
+ show_agent_detail(id)
56
+ end
57
+ end
58
+
59
+ # child->parent ASK_PARENT answer: /reply <id> <answer>. Resolves the
60
+ # child's ask gate (Run::ApprovalGate#decide) so a BLOCKING ask unwinds with
61
+ # the answer as its tool result, and ALSO pushes the answer onto the child's
62
+ # steer queue so a NON-BLOCKING ask folds it in at its next turn boundary.
63
+ # Either way the answer PERSISTS in the child's context. With no inline
64
+ # answer, falls back to an interactive prompt (the ◆ takeover, like the
65
+ # approval menu). Clears the blocked state and unblocks the tree.
66
+ def handle_reply(arguments)
67
+ tokens = arguments.to_s.strip.split(/\s+/)
68
+ id = tokens.shift
69
+ if id.nil? || id.empty?
70
+ show_blocked_agents
71
+ return
72
+ end
73
+
74
+ # /reply is UNSCOPED: the human is the ultimate supervisor and may answer
75
+ # ANY blocked node — one waiting on the human (:blocked_on_human) OR one
76
+ # waiting on its agent-parent (:blocked_on_parent), if the human chooses
77
+ # to step in.
78
+ entry = Tools::BackgroundTasks.instance.find(id)
79
+ if entry.nil? || !%i[blocked_on_human blocked_on_parent].include?(entry.status)
80
+ @ui.error("#{id} is not waiting on you.")
81
+ return
82
+ end
83
+
84
+ answer = dequote(tokens.join(" "))
85
+ answer = prompt_reply_answer(entry) if answer.to_s.strip.empty?
86
+ if answer.to_s.strip.empty?
87
+ @ui.info("No answer given — #{id} is still waiting.")
88
+ return
89
+ end
90
+
91
+ deliver_reply(entry, answer)
92
+ end
93
+
94
+ private
95
+
96
+ # parent->child STEER: a fire-and-forget note that enters the child's
97
+ # context at its next turn boundary (Loop#inject_steered_input). Pushes onto
98
+ # the child's steering queue via BackgroundTasks#steer — the SAME wire the
99
+ # human uses to steer the parent. Echoed with the existing steer vocabulary
100
+ # (▸, "enters child context") + a card repaint so the parked note shows.
101
+ def steer_agent(id, text)
102
+ if text.to_s.strip.empty?
103
+ @ui.error(%(usage: /agents #{id} steer "your note"))
104
+ return
105
+ end
106
+
107
+ if Tools::BackgroundTasks.instance.steer(id, text)
108
+ @ui.info("steer ▸ #{id} ← #{truncate(text, 80)} (parked · enters child context next turn)")
109
+ @ui.set_subagent_cards if @ui.respond_to?(:set_subagent_cards)
110
+ else
111
+ @ui.error("cannot steer #{id} — no such running subagent.")
112
+ end
113
+ end
114
+
115
+ # parent->child PROBE: an EPHEMERAL read-only peek. Snapshots the child's
116
+ # current messages, runs ONE side-inference ([child messages] + question) on
117
+ # the child's own model, prints the answer in a dashed "ephemeral · not
118
+ # saved" aside, and DISCARDS it — nothing is appended to the child's
119
+ # history, nothing enters the timeline. The absence of any saved/timeline
120
+ # entry is itself the signal that the peek changed nothing.
121
+ def probe_agent(id, question)
122
+ if question.to_s.strip.empty?
123
+ @ui.error(%(usage: /agents #{id} probe "your question"))
124
+ return
125
+ end
126
+
127
+ entry = Tools::BackgroundTasks.instance.find(id)
128
+ unless entry
129
+ @ui.error("cannot probe #{id} — no such subagent.")
130
+ return
131
+ end
132
+
133
+ @ui.info(pastel.dim("┄┄ probe → #{id} ┄┄ (ephemeral · not saved · child trajectory unchanged)"))
134
+ # A probe answers from the child's context AT THIS INSTANT; right after
135
+ # spawn that context is still empty and the child honestly says it isn't
136
+ # working on anything yet — hint so that doesn't read as broken (#112).
137
+ if entry.tool_count.to_i.zero?
138
+ @ui.info(pastel.dim(" (snapshot at this instant — the child just started and its " \
139
+ "context is still empty; probe again in a moment)"))
140
+ end
141
+ @ui.info("? #{question}")
142
+ # The peek is a synchronous side-inference (seconds of model wait) with
143
+ # nothing streaming — show the same thinking row /probe got in #58 so
144
+ # the gap before the ⟵ answer never looks frozen (#146). TTY only;
145
+ # Null/API adapters and pipes stay silent.
146
+ probe_thinking_started(@ui)
147
+ answer = begin
148
+ Tools::SubagentProbe.new.peek(entry: entry, question: question)
149
+ ensure
150
+ probe_thinking_finished(@ui)
151
+ end
152
+ @ui.info("⟵ #{answer}")
153
+ @ui.info(pastel.dim("┄┄ end probe (nothing was saved to #{id}) ┄┄"))
154
+ end
155
+
156
+ # The interactive ◆ takeover for /reply with no inline answer — mirrors the
157
+ # approval menu (composer-suspend, ◆ glyph) so answering an ask_parent feels
158
+ # exactly like answering an approval, a pattern the user already knows.
159
+ def prompt_reply_answer(entry)
160
+ @ui.info("")
161
+ @ui.info("◆ #{entry.id} (#{entry.subagent}) asks — everything is waiting on this")
162
+ @ui.info(" ❓ #{entry.ask_question}")
163
+ @ui.ask("✎ your answer › ").to_s
164
+ end
165
+
166
+ # Routes the answer back DOWN to the child: decide the gate (unblocks a
167
+ # blocking ask with the answer as its tool result) and push it onto the
168
+ # steer queue (a non-blocking ask folds it in next turn). Then clear the
169
+ # blocked state and repaint so the ⛔ marker clears.
170
+ def deliver_reply(entry, answer)
171
+ # The ONE shared answer wire (also used by the model-callable
172
+ # answer_child tool): decide the gate + push the steer note + clear the
173
+ # blocked state, all in BackgroundTasks#deliver_answer.
174
+ Tools::BackgroundTasks.instance.deliver_answer(entry.id, answer)
175
+ @ui.info("↳ answered #{entry.id}: #{truncate(answer, 80)}")
176
+ @ui.info("✓ tree unblocked · #{entry.id} resumes at its next turn")
177
+ @ui.set_subagent_cards if @ui.respond_to?(:set_subagent_cards)
178
+ end
179
+
180
+ # Lists the children currently blocked on the human (the /reply with no id
181
+ # case) so the user can see who is waiting and on what.
182
+ def show_blocked_agents
183
+ blocked = Tools::BackgroundTasks.instance.awaiting_human
184
+ if blocked.empty?
185
+ @ui.info("No subagent is waiting on you.")
186
+ return
187
+ end
188
+
189
+ @ui.info(pastel.red("⛔ #{blocked.size} subagent waiting on you:"))
190
+ blocked.each do |e|
191
+ @ui.info(" #{e.id} · #{e.subagent}: #{truncate(e.ask_question, 80)}")
192
+ end
193
+ @ui.info("/reply <id> <answer> to answer")
194
+ end
195
+
196
+ # Strips a single pair of wrapping double/single quotes from a steer/probe
197
+ # argument so `steer "be terse"` lands as `be terse`, not `"be terse"`.
198
+ def dequote(text)
199
+ t = text.to_s.strip
200
+ if t.length >= 2 && ((t.start_with?(%(")) && t.end_with?(%("))) || (t.start_with?("'") && t.end_with?("'")))
201
+ return t[1..-2]
202
+ end
203
+
204
+ t
205
+ end
206
+
207
+ def show_agents_list
208
+ entries = Tools::BackgroundTasks.instance.list
209
+ if entries.empty?
210
+ @ui.info("No background subagents. The agent starts them with its `task` tool;")
211
+ @ui.info("they run while you keep working. They'll appear here when it does.")
212
+ return
213
+ end
214
+
215
+ rows = entries.map do |e|
216
+ [e.id, agent_status_icon(e.status), agent_label(e), agent_elapsed(e)]
217
+ end
218
+ @ui.table(headers: %w[ID Status Task Elapsed], rows: rows)
219
+ @ui.info("/agents <id> for output · /agents <id> --stop to cancel")
220
+ end
221
+
222
+ def show_agent_detail(id)
223
+ entry = Tools::BackgroundTasks.instance.find(id)
224
+ unless entry
225
+ @ui.error("no background subagent with id #{id}.")
226
+ return
227
+ end
228
+
229
+ case entry.status
230
+ when :needs_approval
231
+ # Option 2: a parked child is waiting on THIS human. Lead with the
232
+ # interactive approve/deny prompt that resolves its gate.
233
+ resolve_agent_approval(entry)
234
+ when :running
235
+ # #71 live drill-in: expand to the task summary + the recent-activity
236
+ # ring, tailing the registry live until the user stops watching.
237
+ watch_agent(entry)
238
+ else
239
+ show_agent_result(entry)
240
+ end
241
+ end
242
+
243
+ # Static detail for a finished (done/failed) task — the full result/error,
244
+ # as before.
245
+ def show_agent_result(entry)
246
+ @ui.info("#{entry.id} #{agent_status_icon(entry.status)} · #{entry.subagent}")
247
+ @ui.info("task: #{truncate(entry.prompt, 200)}")
248
+ @ui.separator
249
+ case entry.status
250
+ when :failed
251
+ @ui.error(entry.error.to_s.empty? ? "(failed, no error message)" : entry.error.to_s)
252
+ when :stopped
253
+ show_stopped_summary(entry)
254
+ else
255
+ render_agent_report(entry.result.to_s)
256
+ end
257
+ end
258
+
259
+ # The child's final report is markdown (it is a model answer): render it
260
+ # through the SAME pipeline assistant answers use instead of dumping
261
+ # literal `##`/`**` into the transcript (#139). Adapters without the
262
+ # markdown seam (Null/API) keep the plain info fallback.
263
+ def render_agent_report(result)
264
+ return @ui.info("(no output)") if result.empty?
265
+
266
+ if @ui.respond_to?(:commit_markdown_block)
267
+ @ui.commit_markdown_block(result)
268
+ else
269
+ @ui.info(result)
270
+ end
271
+ end
272
+
273
+ # A stopped child may have COMPLETED side effects before the stop (#150):
274
+ # "no result" alone led the parent/human to assert nothing was produced
275
+ # while an approved write was already on disk. Surface the tool count and
276
+ # the registry's activity tail as ground truth.
277
+ def show_stopped_summary(entry)
278
+ count = entry.tool_count.to_i
279
+ if count.zero?
280
+ @ui.info("(stopped at your request before it ran any tools — no result)")
281
+ else
282
+ @ui.info("(stopped at your request after #{count} tool#{"s" if count != 1} had already run — " \
283
+ "completed tools' side effects may exist)")
284
+ Array(entry.activity_log).last(3).each { |line| @ui.info(" #{line}") }
285
+ end
286
+ end
287
+
288
+ # #71 — LIVE drill-in for a running subagent. Renders the task summary and
289
+ # the recent-activity ring (read live from the registry, which the child's
290
+ # UI::SubagentView keeps fresh), refreshing in place until the user presses a
291
+ # key (Esc/Enter/q) or the task ends. Off an interactive terminal (#ask
292
+ # returns nil — Null/API/pipe) it degrades to a SINGLE snapshot so the
293
+ # non-interactive paths and unit tests never block on a redraw loop.
294
+ def watch_agent(entry)
295
+ render_agent_watch(entry)
296
+ return unless interactive_terminal?
297
+
298
+ @ui.info("(watching live — press Enter/Esc to stop, /agents #{entry.id} --stop to cancel)")
299
+ watch_loop(entry.id)
300
+ end
301
+
302
+ # Renders ONE watch frame: header + task + the recent: ring. Public-ish
303
+ # snapshot shape reused per refresh tick. The recent ring is the registry's
304
+ # bounded activity_log, plus the live last_activity as the trailing ● line.
305
+ def render_agent_watch(entry)
306
+ @ui.info("#{entry.id} #{agent_status_icon(entry.status)} · #{entry.subagent} · #{agent_elapsed(entry)}")
307
+ @ui.info("task: #{truncate(entry.prompt, 120)}")
308
+ @ui.info("recent:")
309
+ Array(entry.activity_log).last(5).each { |line| @ui.info(" #{line}") }
310
+ last = entry.last_activity.to_s
311
+ @ui.info(" #{pastel.yellow("●")} #{last}") unless last.empty?
312
+ end
313
+
314
+ # The live refresh loop for #watch_agent. Polls the registry and re-renders
315
+ # a frame each tick until the task leaves :running or the user hits a key.
316
+ # Kept deliberately simple (a periodic re-render of the snapshot, not a
317
+ # full-screen redraw) to stay scroll-native and avoid a second raw-mode
318
+ # rendering subsystem. Bounded so it can never hang the REPL.
319
+ def watch_loop(id, ticks: 600, interval: 0.5)
320
+ ticks.times do
321
+ break if key_pressed?(interval)
322
+
323
+ entry = Tools::BackgroundTasks.instance.find(id)
324
+ break if entry.nil? || entry.status != :running
325
+
326
+ @ui.separator
327
+ render_agent_watch(entry)
328
+ end
329
+ final = Tools::BackgroundTasks.instance.find(id)
330
+ @ui.info("(stopped watching #{id})") if final && final.status == :running
331
+ end
332
+
333
+ # Option 2 — resolve a parked child's approval. Shows the command and asks
334
+ # Approve once / Approve always / Deny; the answer resolves the child's
335
+ # gate (the child's #confirm returns it). "always" approves AND persists via
336
+ # the parent CLI's allowlist (the same path an inline approval uses), so the
337
+ # child — and future calls — proceed without re-prompting.
338
+ def resolve_agent_approval(entry)
339
+ gate = entry.approval_gate
340
+ unless gate
341
+ @ui.info("#{entry.id} is no longer waiting on approval.")
342
+ return
343
+ end
344
+
345
+ @ui.info("#{entry.id} #{agent_status_icon(entry.status)} · #{entry.subagent}")
346
+ @ui.info("needs approval to run:")
347
+ @ui.info(" #{entry.approval_command.to_s.empty? ? entry.approval_question : entry.approval_command}")
348
+ answer = ask_approval_answer(entry)
349
+ return if answer.nil?
350
+
351
+ decision =
352
+ case answer
353
+ when "a", "always" then persist_agent_always(entry)
354
+ true
355
+ when "o", "once", "y" then true
356
+ else false
357
+ end
358
+ gate.decide(entry.approval_id, decision)
359
+ @ui.info(decision ? "Approved #{entry.id}." : "Denied #{entry.id}.")
360
+ end
361
+
362
+ # Reads the approval answer, re-rendering the prompt on an EMPTY read.
363
+ # A background event (another child's completion fold-in) landing while
364
+ # the prompt is open can abort the underlying TTY read, which used to
365
+ # surface as an empty answer and silently resolve the gate to DENIED
366
+ # (#144). An empty/aborted read is therefore never an answer: re-ask,
367
+ # and after APPROVAL_ASK_ATTEMPTS empty reads return nil WITHOUT
368
+ # touching the gate — the child stays parked and `/agents <id>`
369
+ # re-opens the prompt. Denying requires an explicit keypress ("n", or
370
+ # any other non-approving answer).
371
+ def ask_approval_answer(entry)
372
+ APPROVAL_ASK_ATTEMPTS.times do
373
+ answer = @ui.ask("Approve? [o]nce / [a]lways / [n]o deny: ").to_s.strip.downcase
374
+ return answer unless answer.empty?
375
+ end
376
+ @ui.info("no answer read — #{entry.id} is still waiting; /agents #{entry.id} to decide.")
377
+ nil
378
+ end
379
+
380
+ # Persists an "approve always" for a parked subagent's command via the same
381
+ # session allowlist the inline CLI approval uses, so the decision survives
382
+ # and future identical calls (parent or child) skip the prompt.
383
+ def persist_agent_always(entry)
384
+ scope = "#{entry.subagent}:#{entry.approval_command}"
385
+ Run::SessionApprovalCache.instance.remember(@ui.respond_to?(:session_id) ? @ui.session_id : nil, scope,
386
+ "session")
387
+ rescue StandardError
388
+ nil
389
+ end
390
+
391
+ # True when the REPL owns a real interactive terminal (so a live watch /
392
+ # keypress poll makes sense). Off a TTY we render a single snapshot.
393
+ def interactive_terminal?
394
+ $stdin.respond_to?(:tty?) && $stdin.tty? && $stdout.respond_to?(:tty?) && $stdout.tty?
395
+ rescue StandardError
396
+ false
397
+ end
398
+
399
+ # Non-blocking-ish single-key poll: waits up to +timeout+s for any key.
400
+ # Used to let the user stop the live watch with a keypress. Best-effort:
401
+ # returns false (keep watching) on any terminal hiccup so the bounded loop
402
+ # still terminates on its tick budget.
403
+ def key_pressed?(timeout)
404
+ return false unless interactive_terminal?
405
+
406
+ ready = $stdin.wait_readable(timeout)
407
+ return false unless ready
408
+
409
+ $stdin.read_nonblock(1)
410
+ true
411
+ rescue StandardError
412
+ false
413
+ end
414
+
415
+ def stop_agent(id)
416
+ registry = Tools::BackgroundTasks.instance
417
+ entry = registry.find(id)
418
+ unless entry
419
+ @ui.error("no background subagent with id #{id}.")
420
+ return
421
+ end
422
+
423
+ unless %i[running needs_approval blocked_on_human blocked_on_parent].include?(entry.status)
424
+ @ui.info("#{id} already #{entry.status} — nothing to stop.")
425
+ return
426
+ end
427
+
428
+ # A child parked on a human approval or an ask_parent is blocked in its
429
+ # gate's wait; cancel the gates so it wakes (Interrupted → deny/cancel) and
430
+ # unwinds instead of holding its thread until the bound. The stop-cascade
431
+ # then wakes every DESCENDANT parked on a blocking ask too, so the whole
432
+ # subtree unwinds at once (S5a — no orphaned blocked grandchild).
433
+ # Mark the stop FIRST so the very next /agents list shows ◌ stopping
434
+ # instead of a stale ● running (#108), and so the worker's terminal
435
+ # write records the unwind as :stopped, not ✗ failed (#13) — then wake
436
+ # the gates/runner.
437
+ registry.request_stop(id)
438
+ entry.approval_gate&.cancel!
439
+ entry.ask_gate&.cancel!
440
+ registry.cancel_descendant_ask_gates(id)
441
+ entry.runner&.cancel!
442
+ @ui.success("Stop requested for #{id} (#{entry.subagent}); it unwinds at its next checkpoint.")
443
+ end
444
+
445
+ # `<glyph> <word>` for a subagent's state, with a SPACE between glyph and
446
+ # word and the glyph colored by state (#86): amber ● running, red ✗ failed,
447
+ # green ✓ done — instead of a same-color, glued "●running".
448
+ def agent_status_icon(status)
449
+ glyph, word, color =
450
+ case status
451
+ when :running then ["●", "running", :yellow]
452
+ when :stopping then ["◌", "stopping", :yellow]
453
+ when :stopped then ["⊘", "stopped", :yellow]
454
+ when :needs_approval then ["●", "approval", :yellow]
455
+ when :blocked_on_human then ["⛔", "waiting on you", :red]
456
+ when :blocked_on_parent then ["◷", "waiting on parent", :cyan]
457
+ when :failed then ["✗", "failed", :red]
458
+ else ["✓", "done", :green]
459
+ end
460
+ "#{pastel.public_send(color, glyph)} #{word}"
461
+ end
462
+
463
+ # subagent name + the DISTINGUISHING detail for the list label (#127). For
464
+ # a running task the live last_activity is the most distinguishing field
465
+ # (two "explore: summarize lib/…" tasks differ by what they're doing NOW),
466
+ # so prefer it; otherwise a wider (80-char) slice of the prompt's first
467
+ # line so the tail — often the distinguishing path/arg — survives instead
468
+ # of being cut at 40.
469
+ def agent_label(entry)
470
+ if %i[running needs_approval stopping].include?(entry.status) && !entry.last_activity.to_s.empty?
471
+ return "#{entry.subagent}: #{truncate(entry.last_activity, 80)}"
472
+ end
473
+
474
+ prompt = truncate_middle(entry.prompt.to_s.lines.first.to_s.strip, 80)
475
+ prompt.empty? ? entry.subagent : "#{entry.subagent}: #{prompt}"
476
+ end
477
+
478
+ # Middle truncation for the /agents Task label (#14): similarly-phrased
479
+ # delegations share their HEAD ("Summarize the contents of lib/…") while
480
+ # the distinguishing detail — the path/arg — sits at the TAIL, so a
481
+ # head-only cut renders concurrent tasks identical. Keep both ends,
482
+ # elide the middle.
483
+ def truncate_middle(text, max)
484
+ s = text.to_s.gsub(/\s+/, " ").strip
485
+ return s if s.length <= max
486
+
487
+ head = (max - 1) * 2 / 3
488
+ tail = max - 1 - head
489
+ "#{s[0, head]}…#{s[-tail, tail]}"
490
+ end
491
+
492
+ def agent_elapsed(entry)
493
+ finish = entry.finished_at || Time.now
494
+ return "" unless entry.started_at
495
+
496
+ Rubino::Util::Duration.human_duration(finish - entry.started_at)
497
+ end
498
+
499
+ def pastel
500
+ @pastel ||= Pastel.new
501
+ end
502
+
503
+ def truncate(text, max)
504
+ s = text.to_s.gsub(/\s+/, " ").strip
505
+ s.length > max ? "#{s[0, max - 1]}…" : s
506
+ end
507
+ end
508
+ end
509
+ end
510
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Commands
5
+ module Handlers
6
+ # The `/config` in-chat read/set surface, extracted from Commands::Executor
7
+ # (batch B) — over the SAME effective config (file merged over defaults)
8
+ # the `rubino config` CLI verbs use (#187), so checking `memory.backend` no
9
+ # longer means quitting the REPL. Rendering is shared with the CLI
10
+ # (CLI::ConfigCommand.render_get / .render_show), so secret-named keys are
11
+ # masked identically on both surfaces.
12
+ #
13
+ # /config → config file path + usage hint
14
+ # /config show → the full merged config, secrets masked
15
+ # /config path → the config file path
16
+ # /config <key> → get (dot-notation; `get <key>` also works)
17
+ # /config <key> <value> → set: the same Config::Writer write-through
18
+ # /reasoning uses (`set <key> <value>` too)
19
+ class Config
20
+ def initialize(ui:)
21
+ @ui = ui
22
+ end
23
+
24
+ def handle_config(arguments)
25
+ tokens = arguments.to_s.strip.split(/\s+/)
26
+ case tokens.first
27
+ when nil then show_config_summary
28
+ when "show" then CLI::ConfigCommand.render_show(ui: @ui)
29
+ when "path" then @ui.info(Rubino::Config::Loader.new.config_path)
30
+ when "get" then config_get(tokens[1])
31
+ when "set" then config_set(tokens[1], tokens[2..])
32
+ else
33
+ tokens.length == 1 ? config_get(tokens.first) : config_set(tokens.first, tokens[1..])
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def show_config_summary
40
+ @ui.info("config #{Rubino::Config::Loader.new.config_path}")
41
+ @ui.info("/config show · /config <key> · /config <key> <value>")
42
+ end
43
+
44
+ def config_get(key)
45
+ if key.to_s.empty?
46
+ @ui.info("Usage: /config get <key> (dot-notation, e.g. memory.backend)")
47
+ return
48
+ end
49
+
50
+ CLI::ConfigCommand.render_get(key, ui: @ui)
51
+ end
52
+
53
+ # Write-through + live update, the same pair /reasoning and /think run
54
+ # (#131): the file write makes the change survive the session; the
55
+ # in-memory set applies it to config reads from the next turn. The echo
56
+ # is masked like `config show` so a freshly-set api_key never lands in
57
+ # the scrollback. Consumers that memoize their config (e.g. the memory
58
+ # backend) still need a restart — same caveat as the CLI verb.
59
+ def config_set(key, value_tokens)
60
+ value = Array(value_tokens).join(" ")
61
+ if key.to_s.empty? || value.empty?
62
+ @ui.info("Usage: /config set <key> <value>")
63
+ return
64
+ end
65
+
66
+ writer = Rubino::Config::Writer.new(config_path: Rubino::Config::Loader.new.config_path)
67
+ writer.set(key, value)
68
+ coerced = writer.get(key)
69
+ apply_config_live(key, coerced)
70
+ @ui.success("#{key} = #{CLI::ConfigCommand.redact(coerced, key: key.split(".").last)} " \
71
+ "(persisted; applies from the next turn — memoizing consumers need a restart)")
72
+ rescue Rubino::ConfigurationError => e
73
+ @ui.error(e.message)
74
+ end
75
+
76
+ # Mirrors the Writer's (already validated + coerced) value onto the live
77
+ # configuration. Best-effort: the merged in-memory tree can disagree
78
+ # with the file's shape (a default-valued scalar where the file grew a
79
+ # section), in which case the persisted value still applies on restart.
80
+ def apply_config_live(key, value)
81
+ Rubino.configuration.set(*key.split("."), value)
82
+ rescue StandardError
83
+ @ui.warning("#{key} persisted to config.yml but could not be applied live — restart to pick it up")
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end