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,506 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kramdown"
4
+ require "kramdown-parser-gfm"
5
+ require "tty-table"
6
+ require "unicode/display_width"
7
+ begin
8
+ require "io/console"
9
+ rescue LoadError
10
+ # io/console is part of stdlib; if it's somehow unavailable we fall back
11
+ # to the 80-col default and never crash.
12
+ end
13
+
14
+ module Rubino
15
+ module UI
16
+ # Renders a markdown string into a list of styled token-lines.
17
+ #
18
+ # Output shape:
19
+ # render(text) -> [LineTokens, LineTokens, ...]
20
+ # LineTokens = [[String, StyleHash], ...]
21
+ # StyleHash = { fg:, bg:, modifiers: [...] } (any subset; nil ≈ default)
22
+ #
23
+ # The caller turns these into ANSI-colored strings via Pastel.
24
+ # Keeping the output as plain Ruby data lets the renderer be tested
25
+ # without a real terminal.
26
+ #
27
+ # Coverage: headings 1-3, paragraphs, **bold**, *italic*, `inline code`,
28
+ # ```fenced``` code blocks, ordered/unordered lists (one level), block
29
+ # quotes, [links](url), horizontal rules. Anything unrecognized falls
30
+ # back to its raw text content, never blowing up.
31
+ class MarkdownRenderer
32
+ # Map of common GFM language hints we don't need to special-case. Listed
33
+ # only to acknowledge: rendering treats all languages identically (no
34
+ # syntax highlighting — too much code for marginal gain).
35
+
36
+ # Smallest width we'll ask TTY::Table to fit into. Below this, resize
37
+ # tends to raise (a column needs at least ~2 cols + borders); we clamp up
38
+ # to keep the headless/extreme-narrow paths from blowing up.
39
+ MIN_TABLE_WIDTH = 20
40
+
41
+ DEFAULT_WIDTH = 80
42
+
43
+ # @param width [Integer, nil] the column budget tables must fit into. When
44
+ # nil we detect the terminal width (IO.console winsize), falling back to
45
+ # 80 so the renderer still works headless / without a real terminal.
46
+ def initialize(width: nil)
47
+ @width = width || detect_width
48
+ end
49
+
50
+ def render(text)
51
+ return [] if text.nil? || text.to_s.strip.empty?
52
+
53
+ doc = Kramdown::Document.new(normalize(text.to_s), input: "GFM", auto_ids: false, hard_wrap: false)
54
+ block_lines(doc.root).reject { |line| line == :drop }
55
+ rescue StandardError
56
+ # Parser failure -> degrade to plain text rather than break the UI.
57
+ text.to_s.split("\n", -1).map { |l| [[l, nil]] }
58
+ end
59
+
60
+ private
61
+
62
+ # A GFM pipe-table separator row, e.g. "|---|:--:|---|" or "---|---".
63
+ TABLE_SEP_RE = /\A\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*\z/
64
+
65
+ # Kramdown's GFM parser only recognizes a pipe table when its header row is
66
+ # preceded by a blank line. LLMs frequently emit a table glued directly to
67
+ # the previous line ("Results:\n| a | b |\n|---|---|"), which then degrades
68
+ # to raw pipe text with the separator turned into an em-dash (L4). We insert
69
+ # the missing blank line before any header row that is followed by a
70
+ # separator row, so tables always parse.
71
+ def normalize(text)
72
+ lines = text.split("\n", -1)
73
+ out = []
74
+ lines.each_with_index do |line, i|
75
+ nxt = lines[i + 1]
76
+ if nxt && line.include?("|") && nxt.match?(TABLE_SEP_RE) &&
77
+ !out.empty? && !out.last.strip.empty? && !out.last.match?(TABLE_SEP_RE)
78
+ out << ""
79
+ end
80
+ out << line
81
+ end
82
+ out.join("\n")
83
+ end
84
+
85
+ # Terminal column count, headless-safe. Never raises: if there's no
86
+ # console (tests, pipes, CI) we fall back to 80.
87
+ def detect_width
88
+ IO.console&.winsize&.last || DEFAULT_WIDTH
89
+ rescue StandardError
90
+ DEFAULT_WIDTH
91
+ end
92
+
93
+ # Element -> [LineTokens, LineTokens, ...]
94
+ def block_lines(el)
95
+ case el.type
96
+ when :root
97
+ el.children.flat_map { |c| block_lines(c) }
98
+ when :header
99
+ header_lines(el)
100
+ when :p
101
+ wrap_lines(paragraph_lines(el))
102
+ when :ul
103
+ list_lines(el, ordered: false)
104
+ when :ol
105
+ list_lines(el, ordered: true)
106
+ when :blockquote
107
+ blockquote_lines(el)
108
+ when :codeblock
109
+ codeblock_lines(el)
110
+ when :hr
111
+ [[["─" * 60, { fg: :gray }]]]
112
+ when :table
113
+ table_lines(el)
114
+ when :blank
115
+ [[]]
116
+ when :html_element
117
+ # Treat HTML as paragraph of its rendered text content.
118
+ wrap_lines(paragraph_lines(el))
119
+ else
120
+ # Unknown block: try to recover any inline content.
121
+ tokens = inline_tokens(el.children, {})
122
+ tokens_to_lines(tokens)
123
+ end
124
+ end
125
+
126
+ def header_lines(el)
127
+ level = el.options[:level].to_i.clamp(1, 6)
128
+ style = case level
129
+ when 1 then { fg: :cyan, modifiers: [:bold] }
130
+ when 2 then { fg: :cyan, modifiers: [:bold] }
131
+ when 3 then { fg: :white, modifiers: [:bold] }
132
+ else { fg: :white, modifiers: %i[bold dim] }
133
+ end
134
+ # Headings are STYLED, not prefixed with literal "#" markers (L3): the
135
+ # raw "##" would otherwise show through verbatim. A leading bar gives a
136
+ # subtle visual cue without leaking markdown syntax.
137
+ body = inline_tokens(el.children, style)
138
+ wrap_lines(tokens_to_lines([["▌ ", style]] + body), hang: 2)
139
+ end
140
+
141
+ # Raw (un-wrapped) paragraph lines. Wrapping is applied by the CALLER
142
+ # (block_lines for a top-level :p, list_lines for an :li) so a list item's
143
+ # prose is wrapped ONCE, with the marker, instead of being wrapped twice
144
+ # (which dropped the hanging indent on continuation lines).
145
+ def paragraph_lines(el)
146
+ tokens_to_lines(inline_tokens(el.children, {}))
147
+ end
148
+
149
+ def list_lines(el, ordered:)
150
+ out = []
151
+ el.children.each_with_index do |li, idx|
152
+ next unless li.type == :li
153
+
154
+ marker = ordered ? "#{idx + 1}. " : "• "
155
+ indent = " " * marker.length
156
+ # Inner content UN-wrapped (a :p yields raw tokens_to_lines): we wrap
157
+ # ONCE below with the marker + hanging indent, so a long item breaks on
158
+ # words under the marker instead of being wrapped twice (which lost the
159
+ # continuation indent).
160
+ item_lines = li.children.flat_map { |c| c.type == :p ? paragraph_lines(c) : block_lines(c) }
161
+ # Strip trailing blank line a kramdown :p inside :li sometimes adds.
162
+ item_lines.pop while item_lines.last == []
163
+
164
+ if item_lines.empty?
165
+ out << [[marker, { fg: :gray }]]
166
+ else
167
+ item_lines.each_with_index do |line_tokens, i|
168
+ prefix = i.zero? ? marker : indent
169
+ line = [[prefix, { fg: :gray }]] + line_tokens
170
+ out.concat(wrap_lines([line], hang: marker.length))
171
+ end
172
+ end
173
+ end
174
+ out
175
+ end
176
+
177
+ def blockquote_lines(el)
178
+ # Inner lines UN-wrapped (a :p child yields raw tokens_to_lines); we add
179
+ # the "│ " prefix and wrap ONCE here with a hanging indent so a long
180
+ # quote breaks on words under the bar instead of being wrapped twice.
181
+ inner = el.children.flat_map { |c| c.type == :p ? paragraph_lines(c) : block_lines(c) }
182
+ inner.flat_map do |line_tokens|
183
+ dimmed = line_tokens.map { |text, style| [text, merge_style(style, fg: :gray, modifiers: [:italic])] }
184
+ wrap_lines([[["│ ", { fg: :gray }]] + dimmed], hang: 2)
185
+ end
186
+ end
187
+
188
+ def codeblock_lines(el)
189
+ text = el.value.to_s
190
+ lines = text.split("\n", -1)
191
+ # kramdown's fenced codeblock value ends with a trailing newline -> empty last line. Drop it.
192
+ lines.pop if lines.last == ""
193
+
194
+ lang = el.options[:lang].to_s
195
+ out = []
196
+ out << if lang.empty?
197
+ [["┌─ code ", { fg: :gray }], ["─" * 40, { fg: :gray }]]
198
+ else
199
+ [["┌─ ", { fg: :gray }], [lang, { fg: :gray, modifiers: [:italic] }], [" ", { fg: :gray }],
200
+ ["─" * 40, { fg: :gray }]]
201
+ end
202
+ lines.each do |line|
203
+ out << [["│ ", { fg: :gray }], [line, { fg: :bright_white }]]
204
+ end
205
+ out << [["└", { fg: :gray }], ["─" * 48, { fg: :gray }]]
206
+ out
207
+ end
208
+
209
+ # GFM tables: flatten each cell to a plain string (inline bold/italic is
210
+ # dropped inside cells — alignment matters more than per-cell styling),
211
+ # then let TTY::Table reallocate column widths to fit @width and wrap long
212
+ # cells, so the table never overflows the terminal. The rendered string is
213
+ # split back into our token format ([[line, { fg: :gray }]] per line).
214
+ def table_lines(el)
215
+ header, rows = extract_table(el)
216
+ return [[]] if header.nil? && rows.empty?
217
+
218
+ ncols = ([header&.size || 0] + rows.map(&:size)).max
219
+ return [[]] if ncols.zero?
220
+
221
+ header = pad_cells(header, ncols) if header
222
+ rows = rows.map { |r| pad_cells(r, ncols) }
223
+
224
+ rendered = render_tty_table(header, rows)
225
+ return rendered if rendered
226
+
227
+ # Pathological input (e.g. TTY::Table resize raising even after clamp):
228
+ # degrade to a plain join of the cells, never raise.
229
+ fallback_table_lines(header, rows)
230
+ end
231
+
232
+ # element -> [header (Array<String> or nil), rows (Array<Array<String>>)]
233
+ def extract_table(el)
234
+ header = nil
235
+ rows = []
236
+ el.children.each do |section|
237
+ next unless %i[thead tbody tfoot].include?(section.type)
238
+
239
+ section.children.each do |tr|
240
+ next unless tr.type == :tr
241
+
242
+ cells = tr.children.select { |c| %i[td th].include?(c.type) }
243
+ .map { |cell| flatten_cell(cell) }
244
+ if section.type == :thead && header.nil?
245
+ header = cells
246
+ else
247
+ rows << cells
248
+ end
249
+ end
250
+ end
251
+ [header, rows]
252
+ end
253
+
254
+ # A cell's inline children -> a single plain string. Hard breaks (:br)
255
+ # become spaces so TTY::Table can re-wrap freely.
256
+ def flatten_cell(cell)
257
+ inline_tokens(cell.children, {})
258
+ .map { |t, _| t == :br ? " " : t.to_s }
259
+ .join
260
+ .strip
261
+ end
262
+
263
+ def pad_cells(cells, ncols)
264
+ return Array.new(ncols, "") if cells.nil?
265
+
266
+ cells = cells.dup
267
+ cells << "" while cells.size < ncols
268
+ cells
269
+ end
270
+
271
+ # Returns Array<LineTokens> on success, or nil if TTY::Table can't render
272
+ # (so the caller can fall back).
273
+ def render_tty_table(header, rows)
274
+ fit = [@width.to_i, MIN_TABLE_WIDTH].max
275
+ table = TTY::Table.new(header: header, rows: rows.empty? ? [Array.new(header&.size || 1, "")] : rows)
276
+ # No horizontal padding: TTY::Table's resize budget ignores padding
277
+ # (it would overflow @width by ~2 cols per row), so we omit it to keep
278
+ # the fit-to-width guarantee. Cells still get the border gutters.
279
+ str = table.render(:unicode, resize: true, width: fit, multiline: true)
280
+ return nil if str.nil?
281
+
282
+ str.split("\n").map { |line| [[line, { fg: :gray }]] }
283
+ rescue StandardError
284
+ nil
285
+ end
286
+
287
+ # Last-resort plain rendering used only if TTY::Table fails. Joins cells
288
+ # with " │ " and keeps a header separator; no width fitting (the resize
289
+ # path already covers the normal case).
290
+ def fallback_table_lines(header, rows)
291
+ all = (header ? [header] : []) + rows
292
+ widths = Array.new(all.map(&:size).max || 0, 0)
293
+ all.each { |r| r.each_with_index { |c, i| widths[i] = [widths[i], c.to_s.length].max } }
294
+
295
+ out = []
296
+ join_row = lambda do |cells|
297
+ cells.each_with_index.flat_map do |c, i|
298
+ tok = [[c.to_s.ljust(widths[i]), { fg: :gray }]]
299
+ tok << [" │ ", { fg: :gray }] if i < cells.size - 1
300
+ tok
301
+ end
302
+ end
303
+ out << join_row.call(header) if header
304
+ if header
305
+ out << widths.each_with_index.flat_map do |w, i|
306
+ t = [["─" * w, { fg: :gray }]]
307
+ t << ["─┼─", { fg: :gray }] if i < widths.size - 1
308
+ t
309
+ end
310
+ end
311
+ rows.each { |r| out << join_row.call(r) }
312
+ out
313
+ end
314
+
315
+ # ---- inline ---------------------------------------------------------
316
+
317
+ # children -> flat list of [String, StyleHash] tokens. A token with text
318
+ # equal to :br is a hard line break (split lines around it).
319
+ def inline_tokens(children, parent_style)
320
+ children.flat_map { |el| inline_for(el, parent_style) }
321
+ end
322
+
323
+ def inline_for(el, parent_style)
324
+ case el.type
325
+ when :text
326
+ text_tokens(el.value.to_s, parent_style)
327
+ when :strong
328
+ inline_tokens(el.children, merge_style(parent_style, modifiers: [:bold]))
329
+ when :em
330
+ inline_tokens(el.children, merge_style(parent_style, modifiers: [:italic]))
331
+ when :codespan
332
+ [[el.value.to_s, merge_style(parent_style, fg: :yellow)]]
333
+ when :a
334
+ link_tokens(el, parent_style)
335
+ when :smart_quote
336
+ [[smart_quote_char(el.value), parent_style]]
337
+ when :typographic_sym
338
+ [[typographic_sym_char(el.value), parent_style]]
339
+ when :entity
340
+ [[el.value.char.to_s, parent_style]]
341
+ when :br, :linebreak
342
+ [[:br, nil]]
343
+ when :softbreak
344
+ [[" ", parent_style]]
345
+ when :html_element
346
+ # Render inline HTML as its text content with parent style.
347
+ inline_tokens(el.children, parent_style)
348
+ else
349
+ # Recurse into anything else (e.g. nested em/strong, raw_text)
350
+ inline_tokens(el.children, parent_style)
351
+ end
352
+ end
353
+
354
+ def link_tokens(el, parent_style)
355
+ url = el.attr["href"].to_s
356
+ text_st = merge_style(parent_style, fg: :cyan, modifiers: [:underline])
357
+ text_tok = inline_tokens(el.children, text_st)
358
+ return text_tok if url.empty?
359
+
360
+ # If the visible text equals the URL, don't repeat it.
361
+ flat = text_tok.map { |t, _| t }.join
362
+ return text_tok if flat == url
363
+
364
+ text_tok + [[" (", { fg: :gray }], [url, { fg: :gray, modifiers: [:underline] }], [")", { fg: :gray }]]
365
+ end
366
+
367
+ # Plain text -> tokens, with embedded newlines becoming :br breaks.
368
+ def text_tokens(text, style)
369
+ return [[text, style]] unless text.include?("\n")
370
+
371
+ parts = text.split("\n", -1)
372
+ tokens = []
373
+ parts.each_with_index do |part, i|
374
+ tokens << [part, style] unless part.empty?
375
+ tokens << [:br, nil] if i < parts.length - 1
376
+ end
377
+ tokens
378
+ end
379
+
380
+ # Word-wrap each LineTokens to @width, breaking on whitespace only (never
381
+ # mid-word — the terminal would otherwise hard-wrap at the column edge and
382
+ # split words, L2). Continuation lines are indented by `hang` columns so
383
+ # list items / headings stay visually aligned under their first line. A
384
+ # single word longer than the budget is left intact (an over-long line
385
+ # beats a meaningless mid-word split), mirroring the /skills wrapper (B8).
386
+ def wrap_lines(lines, hang: 0)
387
+ lines.flat_map { |line| wrap_one(line, hang) }
388
+ end
389
+
390
+ def wrap_one(line, hang)
391
+ return [line] if line.empty?
392
+
393
+ width = @width.to_i
394
+ return [line] if width <= 0 || line_length(line) <= width
395
+
396
+ words = words_of(line) # word groups: [[[frag, style], ...], ...]
397
+ return [line] if words.empty?
398
+
399
+ indent = " " * hang
400
+ out = []
401
+ cur = []
402
+ cur_len = 0
403
+
404
+ words.each do |word|
405
+ word_len = word.sum { |frag, _| display_width(frag) }
406
+ sp = cur.empty? ? 0 : 1
407
+ if cur_len + sp + word_len > width && !cur.empty?
408
+ out << cur
409
+ cur = hang.zero? ? [] : [[indent, nil]]
410
+ cur_len = hang
411
+ sp = 0
412
+ end
413
+ cur << [" ", nil] if sp == 1
414
+ cur.concat(word)
415
+ cur_len += sp + word_len
416
+ end
417
+ out << cur unless cur.empty?
418
+ out.empty? ? [line] : out
419
+ end
420
+
421
+ # Flatten a LineTokens to a list of WORD GROUPS — each an array of styled
422
+ # fragments [[text, style], ...] — splitting ONLY at real whitespace
423
+ # (collapsed to single breaks the wrapper re-inserts as spaces). Adjacent
424
+ # inline tokens with NO whitespace between them stay glued in ONE group:
425
+ # kramdown emits `don’t` as three tokens ("don", :smart_quote ’, "t"),
426
+ # and treating each token as its own word made the wrapper re-join them
427
+ # with injected spaces ("don ’ t", #104). Styles are carried per-fragment
428
+ # so bold/italic survive wrapping.
429
+ def words_of(line)
430
+ words = []
431
+ glue = false # the previous fragment did NOT end in whitespace
432
+ line.each do |text, style|
433
+ if text == :br
434
+ glue = false
435
+ next
436
+ end
437
+
438
+ str = text.to_s
439
+ next if str.empty?
440
+
441
+ parts = str.split(/\s+/).reject(&:empty?)
442
+ if parts.empty? # whitespace-only token: a break, never a word
443
+ glue = false
444
+ next
445
+ end
446
+
447
+ leading = str.match?(/\A\s/)
448
+ parts.each_with_index do |w, i|
449
+ if i.zero? && glue && !leading
450
+ words.last << [w, style]
451
+ else
452
+ words << [[w, style]]
453
+ end
454
+ end
455
+ glue = !str.match?(/\s\z/)
456
+ end
457
+ words
458
+ end
459
+
460
+ def line_length(line)
461
+ line.sum { |text, _| text == :br ? 0 : display_width(text) }
462
+ end
463
+
464
+ # Terminal display columns for a string (CJK/full-width/wide emoji count
465
+ # as 2, zero-width/combining as 0). ASCII is 1:1 identical to String#length
466
+ # so normal text wrapping is unchanged.
467
+ def display_width(str)
468
+ Unicode::DisplayWidth.of(str.to_s)
469
+ end
470
+
471
+ def tokens_to_lines(tokens)
472
+ lines = [[]]
473
+ tokens.each do |text, style|
474
+ if text == :br
475
+ lines << []
476
+ else
477
+ lines.last << [text, style]
478
+ end
479
+ end
480
+ lines
481
+ end
482
+
483
+ def merge_style(base, **add)
484
+ base ||= {}
485
+ out = base.dup
486
+ add.each do |k, v|
487
+ if k == :modifiers
488
+ out[:modifiers] = ((out[:modifiers] || []) | v).uniq
489
+ else
490
+ out[k] = v
491
+ end
492
+ end
493
+ out
494
+ end
495
+
496
+ def smart_quote_char(sym)
497
+ { lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”" }[sym] || ""
498
+ end
499
+
500
+ def typographic_sym_char(sym)
501
+ { mdash: "—", ndash: "–", hellip: "…", laquo: "«", raquo: "»",
502
+ laquo_space: "« ", raquo_space: " »" }[sym] || ""
503
+ end
504
+ end
505
+ end
506
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module UI
5
+ # Attention notifications for the moments the agent needs human eyes:
6
+ # a long agentic turn finishing, an approval prompt parking the run on a
7
+ # human decision, or a background subagent blocking on the human (an
8
+ # escalated ask_parent).
9
+ #
10
+ # Channels — mirroring the dominant pattern across coding agents (Claude
11
+ # Code's terminal bell + hooks, Codex's notify hook, aider's
12
+ # --notifications):
13
+ # * terminal bell (BEL, "\a") — default on. BEL never moves the cursor,
14
+ # so it is safe even while the bottom composer owns the screen; it is
15
+ # still routed to the composer's REAL output (never the StdoutProxy,
16
+ # whose partial-line buffer would re-ring the byte on every repaint)
17
+ # and NEVER into a pipe.
18
+ # * OSC 9 ("\e]9;msg\a") — additionally emitted on iTerm2
19
+ # (TERM_PROGRAM=iTerm.app), which renders it as a native macOS
20
+ # notification.
21
+ # * notifications.command — an optional shell command spawned
22
+ # NON-BLOCKING and best-effort per event with RUBINO_EVENT
23
+ # (turn_finished | needs_approval | blocked) and RUBINO_MESSAGE in
24
+ # its environment; failures are swallowed to the structured log.
25
+ # Covers osascript / notify-send users.
26
+ #
27
+ # Spam control: events landing within COALESCE_SECONDS of the last
28
+ # emitted one are dropped, so a burst (several children blocking at once)
29
+ # rings at most once.
30
+ class Notifier
31
+ # Event names the command hook sees via RUBINO_EVENT.
32
+ EVENTS = %i[turn_finished needs_approval blocked].freeze
33
+ # Burst window: events within this many seconds of the last emitted
34
+ # notification coalesce (are dropped).
35
+ COALESCE_SECONDS = 1.0
36
+
37
+ # @param config [Config::Configuration, nil] resolved lazily per event
38
+ # from Rubino.configuration when nil, so a config reload (or a
39
+ # test-injected configuration) is honored without rebuilding the UI.
40
+ def initialize(config: nil)
41
+ @config = config
42
+ @mutex = Mutex.new
43
+ @last_emitted_at = nil
44
+ end
45
+
46
+ # A turn ended after +seconds+. Quick turns stay silent
47
+ # (notifications.min_turn_seconds): focus detection is unreliable in
48
+ # plain terminals, so duration is the proxy for "the human probably
49
+ # looked away".
50
+ def turn_finished(seconds)
51
+ return if seconds.nil? || seconds.to_f < min_turn_seconds
52
+
53
+ notify(:turn_finished, "turn finished after #{seconds.to_i}s")
54
+ end
55
+
56
+ # An approval prompt is parked on the human — the main agent's confirm
57
+ # card, or a background child flipped to :needs_approval.
58
+ def needs_approval(message = "approval required")
59
+ notify(:needs_approval, message)
60
+ end
61
+
62
+ # A background child is blocked on the human (the ⛔ escalated
63
+ # ask_parent banner).
64
+ def blocked(message = "a subagent is waiting on you")
65
+ notify(:blocked, message)
66
+ end
67
+
68
+ # Emits one notification through every enabled channel. Best-effort: a
69
+ # channel failure is logged and never raised into the turn.
70
+ def notify(event, message)
71
+ return unless enabled?
72
+ return unless mark_emittable!
73
+
74
+ emit_bell(message)
75
+ spawn_command(event, message)
76
+ rescue StandardError => e
77
+ log_failure(e)
78
+ end
79
+
80
+ private
81
+
82
+ # Coalescing gate: claims the emission slot, or returns false when the
83
+ # last notification fired under COALESCE_SECONDS ago.
84
+ def mark_emittable!
85
+ @mutex.synchronize do
86
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
87
+ return false if @last_emitted_at && (now - @last_emitted_at) < COALESCE_SECONDS
88
+
89
+ @last_emitted_at = now
90
+ true
91
+ end
92
+ end
93
+
94
+ def emit_bell(message)
95
+ return unless bell_enabled?
96
+
97
+ sink = bell_sink
98
+ return unless sink
99
+
100
+ payload = +"\a"
101
+ payload << "\e]9;#{osc_safe(message)}\a" if iterm?
102
+ sink.write(payload)
103
+ sink.flush if sink.respond_to?(:flush)
104
+ rescue StandardError => e
105
+ log_failure(e)
106
+ end
107
+
108
+ # The REAL terminal the bell may ring on, or nil (never bell into a
109
+ # pipe). While a composer owns the screen $stdout is the StdoutProxy
110
+ # (tty? false by design); the composer's +output+ is the real IO it
111
+ # captured before the swap.
112
+ def bell_sink
113
+ out = BottomComposer.current&.output || $stdout
114
+ return out if out.respond_to?(:tty?) && out.tty?
115
+
116
+ nil
117
+ rescue StandardError
118
+ nil
119
+ end
120
+
121
+ def iterm?
122
+ ENV["TERM_PROGRAM"] == "iTerm.app"
123
+ end
124
+
125
+ # OSC payload hygiene: a control byte (including the BEL terminator
126
+ # itself) inside the message would cut or corrupt the sequence.
127
+ def osc_safe(message)
128
+ message.to_s.gsub(/[[:cntrl:]]/, " ")[0, 200]
129
+ end
130
+
131
+ # Fire-and-forget command hook: spawned detached with the event in its
132
+ # env, stdio nulled so it can never write into the composer's screen.
133
+ def spawn_command(event, message)
134
+ cmd = command
135
+ return unless cmd
136
+
137
+ pid = Process.spawn(
138
+ { "RUBINO_EVENT" => event.to_s, "RUBINO_MESSAGE" => message.to_s },
139
+ cmd,
140
+ in: File::NULL, out: File::NULL, err: File::NULL
141
+ )
142
+ Process.detach(pid)
143
+ rescue StandardError => e
144
+ log_failure(e)
145
+ end
146
+
147
+ def log_failure(error)
148
+ Rubino.logger.debug(event: "ui.notifier.failed", error: error.message)
149
+ rescue StandardError
150
+ nil
151
+ end
152
+
153
+ def config
154
+ @config || Rubino.configuration
155
+ end
156
+
157
+ def enabled? = config.notifications_enabled?
158
+ def bell_enabled? = config.notifications_bell?
159
+ def command = config.notifications_command
160
+ def min_turn_seconds = config.notifications_min_turn_seconds
161
+ end
162
+ end
163
+ end