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,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Rubino
7
+ module UI
8
+ # Bridge between Agent::Runner and the HTTP API.
9
+ #
10
+ # Streaming output (info/success/stream/...) is appended to an in-memory
11
+ # event buffer that the API server drains over SSE.
12
+ #
13
+ # Interactive prompts cross threads through an ApprovalGate:
14
+ # - #confirm emits `approval.required` on the recorder and blocks on the
15
+ # gate until an HTTP client posts a decision.
16
+ # - #ask emits `clarify.required` and blocks the same way.
17
+ #
18
+ # When no gate/recorder is wired (CLI or test contexts), both calls fall
19
+ # back to auto-approve (#confirm -> true, #ask -> nil).
20
+ #
21
+ # APPROVE_DECISIONS lists the decision strings that count as approve;
22
+ # anything else yields a false from #confirm. The two deny forms differ
23
+ # only in persistence: "deny" denies this call ONCE (nothing remembered,
24
+ # re-prompts next session); "deny_always" additionally persists a
25
+ # permissions:deny rule so ApprovalPolicy#decide auto-denies the pattern
26
+ # across sessions. The set is kept in sync with Schemas::DecideApproval so
27
+ # every value the HTTP boundary accepts is either an approve or an explicit
28
+ # deny — no unreachable values, no silent denies from typos. `always` is a
29
+ # back-compat alias for `always_command` (existing web clients post it).
30
+ class API < Base
31
+ APPROVE_DECISIONS = %w[once session always always_prefix always_command].freeze
32
+
33
+ # `always` from older web clients means the narrow "always this command"
34
+ # form (== always_command); normalized away before decision handling.
35
+ ALWAYS_ALIAS = { "always" => "always_command" }.freeze
36
+
37
+ attr_reader :events
38
+
39
+ def initialize(gate: nil, recorder: nil, session_id: nil, approval_cache: nil)
40
+ @gate = gate
41
+ @recorder = recorder
42
+ @session_id = session_id
43
+ @approval_cache = approval_cache || Rubino::Run::SessionApprovalCache.instance
44
+ @events = []
45
+ end
46
+
47
+ # The API adapter parks the run on the ApprovalGate for approvals
48
+ # (#confirm) and clarifications (#ask) — but only when a gate AND recorder
49
+ # are actually wired. Without them both calls auto-resolve and never block,
50
+ # so the loop can keep streaming. Drives Loop#interactive_turn?.
51
+ def blocking_human_input?
52
+ !@gate.nil? && !@recorder.nil?
53
+ end
54
+
55
+ def info(message) = emit_event(:info, message: message)
56
+
57
+ def success(message) = emit_event(:success, message: message)
58
+ def warning(message) = emit_event(:warning, message: message)
59
+ def error(message) = emit_event(:error, message: message)
60
+ def status(message) = emit_event(:status, message: message)
61
+ def note(text) = emit_event(:note, text: text)
62
+ def assistant_text(text) = emit_event(:assistant_text, text: text)
63
+
64
+ # The adapter no longer drops :thinking deltas in hidden mode (the CLI
65
+ # retains them unrendered for the Ctrl-O reveal, #76); the HTTP wire
66
+ # keeps the old contract — hidden means no reasoning deltas reach
67
+ # API consumers, so the gate lives here now.
68
+ def stream(chunk)
69
+ return if chunk.is_a?(Hash) && chunk[:type] == :thinking &&
70
+ Config::ReasoningPrefs.mode(Rubino.configuration) == :hidden
71
+
72
+ emit_event(:stream, chunk: chunk)
73
+ end
74
+
75
+ def stream_end = emit_event(:stream_end)
76
+ def thinking_started = emit_event(:thinking_started)
77
+ def table(headers:, rows:) = emit_event(:table, headers: headers, rows: rows)
78
+
79
+ def tool_started(name, arguments: nil, at: nil)
80
+ emit_event(:tool_started, name: name, arguments: arguments, at: at)
81
+ end
82
+
83
+ def tool_body(text, kind: :plain) = emit_event(:tool_body, text: text, kind: kind)
84
+ def tool_chunk(name, chunk) = emit_event(:tool_chunk, name: name, chunk: chunk)
85
+ def tool_finished(name, result: nil) = emit_event(:tool_finished, name: name)
86
+ def compression_started(at: nil) = emit_event(:compression_started, at: at)
87
+
88
+ def compression_finished(metadata, at: nil)
89
+ emit_event(:compression_finished, metadata: metadata, at: at)
90
+ end
91
+
92
+ def job_enqueued(type) = emit_event(:job_enqueued, type: type)
93
+ def job_started(type) = emit_event(:job_started, type: type)
94
+ def job_finished(type) = emit_event(:job_finished, type: type)
95
+ def separator = emit_event(:separator)
96
+ def blank_line = emit_event(:blank_line)
97
+ def mode_changed(name, previous: nil) = emit_event(:mode_changed, mode: name, previous: previous)
98
+ def reasoning_status(mode) = emit_event(:reasoning_status, mode: mode)
99
+ def reasoning_changed(mode, previous: nil) = emit_event(:reasoning_changed, mode: mode, previous: previous)
100
+ def think_status(effort) = emit_event(:think_status, effort: effort)
101
+ def think_changed(effort, previous: nil) = emit_event(:think_changed, effort: effort, previous: previous)
102
+
103
+ # Emits `approval.required` and blocks on the ApprovalGate until an
104
+ # HTTP client posts a decision for the generated approval_id.
105
+ # Auto-approves (returns true) when no gate/recorder is wired.
106
+ #
107
+ # @param question [String] human-readable approval prompt
108
+ # @param scope [String, nil] cache key for "session"/"always"
109
+ # decisions; pass `"<tool>:<args>"` so a second call with the
110
+ # same shape bypasses the user prompt entirely. Nil opts out.
111
+ # @param tool [String, nil] tool name, for the enriched event.
112
+ # @param command [String, nil] literal command/args, for the event +
113
+ # prefix derivation when a decision persists.
114
+ # @param pattern_key [String, nil] matched dangerous-pattern key, if any.
115
+ # @param description [String, nil] dangerous-pattern description, if any.
116
+ # @return [Boolean] true when the decision is in APPROVE_DECISIONS;
117
+ # false on an explicit deny OR when the gate's wait deadline elapses
118
+ # with no answer (abandoned run) — the safe auto-DENY default.
119
+ def confirm(question, scope: nil, tool: nil, command: nil, pattern_key: nil, description: nil)
120
+ return true unless @gate && @recorder
121
+
122
+ # Session-scope short-circuit: a prior "session" / "always_*"
123
+ # decision (or a persisted prefix) for this scope means we must NOT
124
+ # prompt again in the same session.
125
+ return true if scope && @session_id && @approval_cache.allowed?(@session_id, scope)
126
+
127
+ rule = derive_rule(tool, command, pattern_key)
128
+
129
+ approval_id = SecureRandom.uuid
130
+ # Register before emitting: a fast HTTP client could POST a decision
131
+ # the moment it sees approval.required, racing past #await; the gate
132
+ # must already know the id is valid by then.
133
+ @gate.register(approval_id, recorder: @recorder)
134
+ @recorder.emit(
135
+ "approval.required",
136
+ approval_payload(approval_id, question, tool: tool, command: command,
137
+ pattern_key: pattern_key, description: description, rule: rule)
138
+ )
139
+ decision = @gate.await(approval_id)
140
+ # Wait deadline elapsed with no human answer (abandoned run): the gate
141
+ # already emitted approval.expired. Resolve to a safe DENY — NEVER an
142
+ # auto-approve — so the gated command does not run.
143
+ return false if decision.equal?(Run::ApprovalGate::EXPIRED)
144
+
145
+ normalized = normalize_decision(decision)
146
+ approved = APPROVE_DECISIONS.include?(normalized)
147
+
148
+ if approved
149
+ apply_decision(normalized, scope: scope, command: command, rule: rule)
150
+ elsif normalized == "deny_always"
151
+ # Not an approve, but PERSIST the deny so ApprovalPolicy#decide
152
+ # auto-denies this pattern across sessions (it checks permissions:deny
153
+ # first). Plain "deny" stays a one-off — nothing persisted, re-prompts.
154
+ persist_deny(tool, command, rule)
155
+ end
156
+ approved
157
+ end
158
+
159
+ # Emits `clarify.required` and blocks on the ApprovalGate until an
160
+ # HTTP client posts a clarification response for the generated
161
+ # clarify_id. Returns nil when no gate/recorder is wired.
162
+ #
163
+ # @param prompt [String] question to ask the user
164
+ # @return [String, nil] the response text, or nil in non-API contexts
165
+ # or when the wait deadline elapsed with no answer (abandoned run)
166
+ def ask(prompt)
167
+ return nil unless @gate && @recorder
168
+
169
+ clarify_id = SecureRandom.uuid
170
+ @gate.register(clarify_id, recorder: @recorder)
171
+ @recorder.emit("clarify.required", { clarify_id: clarify_id, question: prompt.to_s })
172
+ answer = @gate.await(clarify_id)
173
+ # Deadline elapsed with no answer: the gate emitted approval.expired;
174
+ # treat an abandoned clarification as "no response".
175
+ return nil if answer.equal?(Run::ApprovalGate::EXPIRED)
176
+
177
+ answer
178
+ end
179
+
180
+ private
181
+
182
+ # Maps `always` (legacy web) to its canonical form; everything else
183
+ # passes through lowercased.
184
+ def normalize_decision(decision)
185
+ d = decision.to_s.downcase
186
+ ALWAYS_ALIAS.fetch(d, d)
187
+ end
188
+
189
+ # The rule this approval would be remembered/persisted as, derived from
190
+ # the command. Nil when there is no command (tool-wide / structured-arg
191
+ # tools), in which case no prefix is offered and persistence is skipped.
192
+ def derive_rule(tool, command, pattern_key)
193
+ return nil if command.to_s.strip.empty?
194
+
195
+ Security::PrefixDeriver.rule_for(tool: tool.to_s, command: command.to_s, pattern_key: pattern_key)
196
+ end
197
+
198
+ # The enriched approval.required payload. New fields are additive on top
199
+ # of the original {approval_id, question}; `hardline` is always false here
200
+ # (a hardline command is denied upstream and never reaches #confirm).
201
+ def approval_payload(approval_id, question, tool:, command:, pattern_key:, description:, rule:)
202
+ suggested = rule&.kind == :prefix ? rule.value : nil
203
+ {
204
+ approval_id: approval_id,
205
+ question: question.to_s,
206
+ command: command.to_s,
207
+ tool: tool.to_s,
208
+ description: description.to_s,
209
+ hardline: false,
210
+ suggested_prefix: suggested,
211
+ pattern_key: pattern_key,
212
+ choices: choices_for(suggested)
213
+ }
214
+ end
215
+
216
+ # The decisions the gem offers for THIS request. Always once/always_command/
217
+ # deny; session whenever in-memory caching applies (a gated run with a
218
+ # session id); always_prefix only when a :prefix rule is derivable. The web
219
+ # reads this list instead of synthesizing it.
220
+ def choices_for(suggested_prefix)
221
+ choices = %w[once]
222
+ choices << "session" if @session_id
223
+ choices << "always_prefix" if suggested_prefix
224
+ choices << "always_command"
225
+ choices << "deny"
226
+ choices << "deny_always"
227
+ choices
228
+ end
229
+
230
+ # Routes an approved decision to its cache/persister action:
231
+ # once -> nothing (this call only)
232
+ # session -> in-memory cache, dies with the process
233
+ # always_prefix -> persist the derived :prefix rule to command_allowlist
234
+ # always_command -> persist the NARROW rule (pattern key / exact command)
235
+ # `always` was already normalized to always_command upstream.
236
+ def apply_decision(decision, scope:, command:, rule:)
237
+ case decision
238
+ when "session"
239
+ remember_session(scope)
240
+ when "always_prefix"
241
+ remember_session(scope)
242
+ persist_rule(prefix_rule(rule, command))
243
+ when "always_command"
244
+ remember_session(scope)
245
+ persist_rule(narrow_rule(command))
246
+ end
247
+ end
248
+
249
+ def remember_session(scope)
250
+ return unless scope && @session_id
251
+
252
+ @approval_cache.remember(@session_id, scope, "session")
253
+ end
254
+
255
+ # Persists a rule value to the on-disk command_allowlist so it pre-approves
256
+ # siblings across restarts (CommandAllowlist prefix start_with?). Skips when
257
+ # there is no value to persist.
258
+ def persist_rule(rule)
259
+ Security::AllowlistPersister.persist(rule.value) if rule
260
+ end
261
+
262
+ # Persists a permissions:deny rule for the "deny_always" decision, scoped
263
+ # the SAME way the allow side scopes (derived :prefix when available, else
264
+ # the exact command). ApprovalPolicy#decide checks permissions:deny first,
265
+ # so this auto-denies the pattern across sessions. No-op when there is no
266
+ # pattern to key on. Same DenyPersister path the CLI uses.
267
+ def persist_deny(tool, command, rule)
268
+ pattern = Security::DenyPersister.pattern_for(
269
+ tool: tool.to_s, rule: rule, command: command
270
+ )
271
+ Security::DenyPersister.persist(pattern) if pattern
272
+ end
273
+
274
+ # The broad prefix rule for always_prefix. Falls back to deriving from the
275
+ # raw command when the caller didn't pass a tool-derived rule.
276
+ def prefix_rule(rule, command)
277
+ return rule if rule&.kind == :prefix
278
+
279
+ derived = Security::PrefixDeriver.rule_for(tool: "shell", command: command.to_s)
280
+ derived if derived&.kind == :prefix
281
+ end
282
+
283
+ # The narrow rule for always_command: exact command, or the dangerous
284
+ # pattern key when the command is dangerous (S3 semantics).
285
+ def narrow_rule(command)
286
+ return nil if command.to_s.strip.empty?
287
+
288
+ Security::PrefixDeriver.narrow_rule_for(tool: "shell", command: command.to_s)
289
+ end
290
+
291
+ def emit_event(type, **payload)
292
+ @events << { type: type, payload: payload, timestamp: Time.now.iso8601 }
293
+ end
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module UI
5
+ # Abstract base class for all UI adapters.
6
+ # Defines the interface that CLI, API, and Null must implement.
7
+ # No output method should be called directly from core logic;
8
+ # all output flows through one of these methods.
9
+ class Base
10
+ def info(message)
11
+ raise NotImplementedError, "#{self.class}#info not implemented"
12
+ end
13
+
14
+ def success(message)
15
+ raise NotImplementedError, "#{self.class}#success not implemented"
16
+ end
17
+
18
+ def warning(message)
19
+ raise NotImplementedError, "#{self.class}#warning not implemented"
20
+ end
21
+
22
+ def error(message)
23
+ raise NotImplementedError, "#{self.class}#error not implemented"
24
+ end
25
+
26
+ def status(message)
27
+ raise NotImplementedError, "#{self.class}#status not implemented"
28
+ end
29
+
30
+ # Opens a box: `┌─ HH:MM · type · pieces ─────` filling the box width.
31
+ # Every visible scrollback block (user, thinking, tool, assistant, replay)
32
+ # is rendered as a box; body lines below get a `│ ` prefix and the box
33
+ # is closed with `box_close`. Pieces are joined with `·`. `at:` overrides
34
+ # the timestamp so replay preserves the original time of each historical
35
+ # step instead of showing "now" for the whole resumed session. `color:`
36
+ # overrides the auto-color (used by tool_finished to flip done to red).
37
+ def box_open(*pieces, at: nil, color: nil)
38
+ raise NotImplementedError, "#{self.class}#box_open not implemented"
39
+ end
40
+
41
+ # Closes the currently-open box with `└─ pieces ─────`. When no pieces
42
+ # are given, emits a bare `└────` line — used for boxes that have no
43
+ # trailing metric (user, assistant, thinking). For tool boxes the
44
+ # caller passes `"done", name, metrics` so the bottom border carries
45
+ # the cost/scope of the call.
46
+ def box_close(*pieces, color: nil)
47
+ raise NotImplementedError, "#{self.class}#box_close not implemented"
48
+ end
49
+
50
+ # Emits the payload that goes under a header (replay assistant body,
51
+ # one-shot text dumps, anything that isn't a stream chunk). Routing it
52
+ # through the UI rather than $stdout means the Null adapter still
53
+ # records it for tests and the CLI can later style/wrap it.
54
+ def body(text)
55
+ raise NotImplementedError, "#{self.class}#body not implemented"
56
+ end
57
+
58
+ # A finished assistant message. The CLI renders it as markdown; other
59
+ # adapters fall back to plain body text. Part of the UI contract so the
60
+ # session-history replay (resume/continue) works on every adapter.
61
+ def assistant_text(text)
62
+ body(text)
63
+ end
64
+
65
+ # A status-panel key/value row (` model minimax-m3`), optionally
66
+ # followed by an actionable pointer (`(use /mcp)`). The CLI styles it
67
+ # per the panel color diet (dim label, plain value, cyan pointer — P8);
68
+ # the default assembles one plain info line so recording adapters keep
69
+ # the full content.
70
+ def panel_line(label, value, pointer: nil)
71
+ info([" #{label.to_s.ljust(10)} #{value}", pointer].compact.join(" "))
72
+ end
73
+
74
+ # A "try this" hint row: an actionable command plus its description
75
+ # (` /status what's going on right now`). The CLI renders the
76
+ # command cyan (the one accent color) and the description plain.
77
+ def hint_row(command, description)
78
+ info(" #{command.to_s.ljust(9)} #{description}")
79
+ end
80
+
81
+ # Small metadata line, dim, no header. Used for the `turn · Xs · N
82
+ # tools · Y tok` summary after the final assistant message (on adapters
83
+ # without a dedicated #turn_footer), and any
84
+ # similar low-priority annotation that should sit close to the block
85
+ # it describes without competing visually.
86
+ def note(text)
87
+ raise NotImplementedError, "#{self.class}#note not implemented"
88
+ end
89
+
90
+ def stream(chunk)
91
+ raise NotImplementedError, "#{self.class}#stream not implemented"
92
+ end
93
+
94
+ def stream_end
95
+ raise NotImplementedError, "#{self.class}#stream_end not implemented"
96
+ end
97
+
98
+ # Replays a user message from session history (resume / continue).
99
+ # Lets the CLI render past turns with a stable "you >" label so the
100
+ # scrolled-back transcript matches what the user typed at the time.
101
+ def replay_user_input(text)
102
+ raise NotImplementedError, "#{self.class}#replay_user_input not implemented"
103
+ end
104
+
105
+ # Called when the model call starts but no chunk has arrived yet.
106
+ # Lets the UI show a transient "thinking…" affordance so the user
107
+ # sees something is happening during TTFB and when show_reasoning
108
+ # is disabled (otherwise the terminal sits silent until the first
109
+ # content chunk lands).
110
+ def thinking_started
111
+ raise NotImplementedError, "#{self.class}#thinking_started not implemented"
112
+ end
113
+
114
+ def table(headers:, rows:)
115
+ raise NotImplementedError, "#{self.class}#table not implemented"
116
+ end
117
+
118
+ def ask(prompt)
119
+ raise NotImplementedError, "#{self.class}#ask not implemented"
120
+ end
121
+
122
+ # Arrow-key single-select menu. +choices+ is an array of
123
+ # [label, value] pairs; returns the chosen value, or nil when no
124
+ # interactive selection is possible (non-TTY / Null adapter) so callers
125
+ # fall back to a non-interactive path.
126
+ def select(prompt, choices)
127
+ raise NotImplementedError, "#{self.class}#select not implemented"
128
+ end
129
+
130
+ # `scope:` is part of the contract for ALL adapters (not just API):
131
+ # ToolExecutor#request_approval always passes it. CLI/Null ignore it;
132
+ # API uses it as the session-approval cache key. Keeping the keyword in
133
+ # the shared signature is what stops UI::CLI from raising
134
+ # `ArgumentError: unknown keyword: :scope` on every interactive tool
135
+ # approval. `**context` absorbs the enriched approval fields (tool/
136
+ # command/pattern_key/description) that ToolExecutor passes for the /v1
137
+ # event — only UI::API consumes them; CLI/Null/SubagentView ignore them.
138
+ def confirm(question, scope: nil, **context)
139
+ raise NotImplementedError, "#{self.class}#confirm not implemented"
140
+ end
141
+
142
+ # A destructive yes/No confirm, default No — distinct from the tool-approval
143
+ # #confirm above (#218). Used for the in-chat/CLI destructive verbs (session
144
+ # delete, memory forget), so only the CLI and Null adapters implement it;
145
+ # both fail closed (decline) off a real terminal so a piped/EOF answer can
146
+ # never destroy. The API/subagent adapters don't host these verbs and raise.
147
+ def confirm_destructive(question)
148
+ raise NotImplementedError, "#{self.class}#confirm_destructive not implemented"
149
+ end
150
+
151
+ # `at:` overrides the timestamp on the tool box top — replay uses it so
152
+ # historical tool calls show when they actually happened, not "now".
153
+ # Live calls leave `at:` nil and get current time.
154
+ def tool_started(name, arguments: nil, at: nil)
155
+ raise NotImplementedError, "#{self.class}#tool_started not implemented"
156
+ end
157
+
158
+ def tool_finished(name, result: nil)
159
+ raise NotImplementedError, "#{self.class}#tool_finished not implemented"
160
+ end
161
+
162
+ # Body block printed inside the open tool box, between the top and
163
+ # `done` rules. `kind:` controls coloring:
164
+ # :plain — every line dim (default; for shell/grep/glob/read
165
+ # previews where a leading `-` is `ls -la` permissions,
166
+ # not a diff removal)
167
+ # :diff — `+ ` lines green, `- ` lines red, rest dim (for edit)
168
+ # Caller is responsible for trimming the text first (Util::Output.preview).
169
+ def tool_body(text, kind: :plain)
170
+ raise NotImplementedError, "#{self.class}#tool_body not implemented"
171
+ end
172
+
173
+ # `at:` overrides the timestamp shown on the compaction free line.
174
+ # Live events leave it nil and pick up current time; replay (if
175
+ # compaction events ever become stored in history) can pin the
176
+ # original moment.
177
+ def compression_started(at: nil)
178
+ raise NotImplementedError, "#{self.class}#compression_started not implemented"
179
+ end
180
+
181
+ def compression_finished(metadata, at: nil)
182
+ raise NotImplementedError, "#{self.class}#compression_finished not implemented"
183
+ end
184
+
185
+ def job_enqueued(type)
186
+ raise NotImplementedError, "#{self.class}#job_enqueued not implemented"
187
+ end
188
+
189
+ def job_started(type)
190
+ raise NotImplementedError, "#{self.class}#job_started not implemented"
191
+ end
192
+
193
+ def job_finished(type)
194
+ raise NotImplementedError, "#{self.class}#job_finished not implemented"
195
+ end
196
+
197
+ def separator
198
+ raise NotImplementedError, "#{self.class}#separator not implemented"
199
+ end
200
+
201
+ def blank_line
202
+ raise NotImplementedError, "#{self.class}#blank_line not implemented"
203
+ end
204
+
205
+ # Signals a Modes transition (e.g. user typed `/mode plan` or an API
206
+ # caller invoked Modes.set). CLI renders a `┄ HH:MM · mode → plan ┄`
207
+ # free line; API emits a `mode_changed` event the orchestrator can
208
+ # forward to the web client; Null records it for tests.
209
+ # `previous:` is the mode active *before* the transition, used to
210
+ # render the arrow ("default → plan").
211
+ def mode_changed(name, previous: nil)
212
+ raise NotImplementedError, "#{self.class}#mode_changed not implemented"
213
+ end
214
+
215
+ # Echoes a message the user typed *during* a running turn — the steering
216
+ # / "talk while it works" affordance. The background reader captured the
217
+ # line and parked it for the next turn; this just confirms it visually so
218
+ # the keystrokes don't disappear into the streaming output. Concrete (not
219
+ # abstract) and a no-op by default: only the CLI shows the dim
220
+ # `queued ▸ …` echo; API/Null have nothing meaningful to render and
221
+ # inherit the no-op rather than each restating it.
222
+ def queued(text); end
223
+
224
+ # Echoes a message that was picked up MID-TURN at an agent-loop iteration
225
+ # boundary and injected as a user message into the current turn (the
226
+ # Phase-2 steering / "Enter injects into the current turn" affordance).
227
+ # Distinct from #queued, which parks text for the NEXT turn: this text is
228
+ # already part of the live turn, so the CLI renders a dim
229
+ # `↳ received while working: …` confirmation. Concrete no-op by default;
230
+ # only the CLI has something to render. API surfaces it via the
231
+ # INPUT_INJECTED bus event, not this echo.
232
+ def input_injected(text); end
233
+
234
+ # Commits the standardized `⎿ interrupted` marker right after the partial
235
+ # answer that's kept when a turn is cancelled (Ctrl+C, or the interrupt-by-
236
+ # default Enter on a type-ahead line). Concrete no-op by default; only the
237
+ # CLI renders the dim marker. API surfaces the cancel via its own events;
238
+ # Null records nothing.
239
+ def turn_interrupted; end
240
+
241
+ # True when this adapter parks the run on a cross-thread gate for human
242
+ # approvals/clarifications (the HTTP/API path) rather than prompting
243
+ # inline on a terminal. The agent loop uses this to run an interactive
244
+ # turn NON-STREAMING so no upstream LLM socket is held open during the
245
+ # wait. Default false: CLI/Null prompt inline (or auto-answer) and never
246
+ # park, so they keep streaming.
247
+ def blocking_human_input?
248
+ false
249
+ end
250
+ end
251
+ end
252
+ end