rubino-agent 0.5.1 → 0.5.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +15 -0
  3. data/CHANGELOG.md +127 -0
  4. data/Dockerfile +56 -0
  5. data/agent.md +112 -0
  6. data/docs/api/v1.md +2 -0
  7. data/docs/commands.md +3 -6
  8. data/docs/configuration.md +13 -6
  9. data/docs/design/bg-shell-pty-port.md +88 -0
  10. data/docs/design/bg-shell-review-refinements.md +65 -0
  11. data/docs/design/bg-shell-ux.md +130 -0
  12. data/docs/oauth-providers.md +21 -0
  13. data/docs/tools.md +3 -12
  14. data/lib/rubino/agent/iteration_budget.rb +13 -0
  15. data/lib/rubino/agent/loop.rb +43 -5
  16. data/lib/rubino/agent/prompts/build.txt +10 -5
  17. data/lib/rubino/agent/prompts/memory_guidance.txt +5 -0
  18. data/lib/rubino/agent/prompts/tool_use_enforcement.txt +4 -0
  19. data/lib/rubino/agent/prompts/tool_use_enforcement_google.txt +9 -0
  20. data/lib/rubino/agent/prompts/tool_use_enforcement_openai.txt +48 -0
  21. data/lib/rubino/agent/runner.rb +55 -12
  22. data/lib/rubino/agent/tool_executor.rb +1 -1
  23. data/lib/rubino/api/operations/tasks/stop_operation.rb +0 -3
  24. data/lib/rubino/attachments/classify.rb +0 -1
  25. data/lib/rubino/cli/chat/completion_builder.rb +0 -8
  26. data/lib/rubino/cli/chat/idle_card_host.rb +6 -1
  27. data/lib/rubino/cli/chat_command.rb +324 -171
  28. data/lib/rubino/cli/commands.rb +5 -0
  29. data/lib/rubino/commands/built_ins.rb +0 -1
  30. data/lib/rubino/commands/executor.rb +1 -7
  31. data/lib/rubino/commands/handlers/agents.rb +55 -265
  32. data/lib/rubino/commands/handlers/status.rb +6 -3
  33. data/lib/rubino/compression/line_skeleton.rb +1 -1
  34. data/lib/rubino/compression/python_code_skeleton.rb +1 -1
  35. data/lib/rubino/compression/ruby_code_skeleton.rb +1 -1
  36. data/lib/rubino/compression/tree_sitter_code_skeleton.rb +1 -1
  37. data/lib/rubino/config/configuration.rb +47 -18
  38. data/lib/rubino/config/defaults.rb +57 -33
  39. data/lib/rubino/context/prompt_assembler.rb +89 -1
  40. data/lib/rubino/context/summary_builder.rb +0 -22
  41. data/lib/rubino/context/token_budget.rb +0 -5
  42. data/lib/rubino/errors.rb +2 -2
  43. data/lib/rubino/interaction/events.rb +2 -2
  44. data/lib/rubino/interaction/lifecycle.rb +54 -20
  45. data/lib/rubino/llm/anthropic_role_merge.rb +75 -0
  46. data/lib/rubino/llm/error_classifier.rb +34 -1
  47. data/lib/rubino/llm/fake_provider.rb +0 -4
  48. data/lib/rubino/llm/ruby_llm_adapter.rb +222 -59
  49. data/lib/rubino/llm/stream_tool_call_recovery.rb +91 -0
  50. data/lib/rubino/llm/tool_call_recovery.rb +177 -0
  51. data/lib/rubino/memory/sqlite_extraction_prompt.rb +0 -2
  52. data/lib/rubino/memory/store.rb +0 -19
  53. data/lib/rubino/security/pattern_matcher.rb +0 -2
  54. data/lib/rubino/security/redactor.rb +1 -1
  55. data/lib/rubino/security/secret_path.rb +16 -4
  56. data/lib/rubino/session/message.rb +12 -0
  57. data/lib/rubino/skills/registry.rb +16 -2
  58. data/lib/rubino/tools/background_tasks.rb +132 -228
  59. data/lib/rubino/tools/base.rb +1 -17
  60. data/lib/rubino/tools/grep_tool.rb +13 -1
  61. data/lib/rubino/tools/question_tool.rb +3 -4
  62. data/lib/rubino/tools/read_attachment_tool.rb +52 -54
  63. data/lib/rubino/tools/registry.rb +21 -72
  64. data/lib/rubino/tools/shell_entry_adapter.rb +97 -0
  65. data/lib/rubino/tools/shell_input_tool.rb +1 -1
  66. data/lib/rubino/tools/shell_kill_tool.rb +4 -4
  67. data/lib/rubino/tools/shell_registry.rb +178 -38
  68. data/lib/rubino/tools/shell_tool.rb +45 -5
  69. data/lib/rubino/tools/steer_tool.rb +3 -4
  70. data/lib/rubino/tools/task_result_tool.rb +4 -1
  71. data/lib/rubino/tools/task_stop_tool.rb +5 -7
  72. data/lib/rubino/tools/task_tool.rb +81 -35
  73. data/lib/rubino/tools/vision_tool.rb +1 -1
  74. data/lib/rubino/tools/write_tool.rb +22 -2
  75. data/lib/rubino/ui/agent_menu.rb +8 -4
  76. data/lib/rubino/ui/api.rb +11 -0
  77. data/lib/rubino/ui/bottom_composer.rb +240 -374
  78. data/lib/rubino/ui/cli.rb +381 -155
  79. data/lib/rubino/ui/input_history.rb +0 -5
  80. data/lib/rubino/ui/live_region.rb +18 -1
  81. data/lib/rubino/ui/markdown_renderer.rb +51 -4
  82. data/lib/rubino/ui/markdown_repair.rb +114 -0
  83. data/lib/rubino/ui/notifier.rb +4 -10
  84. data/lib/rubino/ui/stdout_proxy.rb +25 -10
  85. data/lib/rubino/ui/streaming_markdown.rb +79 -12
  86. data/lib/rubino/ui/subagent_cards.rb +18 -44
  87. data/lib/rubino/ui/tool_args_stream.rb +143 -0
  88. data/lib/rubino/update_check.rb +10 -2
  89. data/lib/rubino/util/ignore_rules.rb +18 -2
  90. data/lib/rubino/util/secrets_mask.rb +0 -9
  91. data/lib/rubino/version.rb +1 -1
  92. data/lib/rubino.rb +33 -7
  93. data/rubino-agent.gemspec +1 -0
  94. metadata +31 -5
  95. data/AGENTS.md +0 -97
  96. data/docs/agents.md +0 -224
  97. data/lib/rubino/jobs/handlers/summarize_session_job.rb +0 -21
  98. data/lib/rubino/tools/summarize_file_tool.rb +0 -194
@@ -601,6 +601,11 @@ module Rubino
601
601
  ui.warning("gem update failed. If this is a permission error, re-run the installer or try `gem update --user-install #{Rubino::UpdateCheck::GEM_NAME}`.")
602
602
  return
603
603
  end
604
+ # The subprocess installed the new gem into this process's gem paths,
605
+ # but our in-memory spec list predates it — refresh so the version
606
+ # query below sees what `gem update` just wrote (else we'd report the
607
+ # pre-update version and claim "already up to date").
608
+ Gem.refresh
604
609
  new_v = Rubino::UpdateCheck.installed_gem_version(Rubino::UpdateCheck::GEM_NAME)
605
610
  if new_v && Gem::Version.new(new_v) > Gem::Version.new(current)
606
611
  ui.info("rubino is now on v#{new_v} (was v#{current}).")
@@ -22,7 +22,6 @@ module Rubino
22
22
  "/agent" => "Switch the primary agent (/agent <name>; a bare /<name> or Tab cycles)",
23
23
  "/agents" => "List background subagents; ↓+Enter to attach & steer one live, or steer/probe/view by id",
24
24
  "/tasks" => "Alias for /agents",
25
- "/reply" => "Answer a subagent that is blocked waiting on you (e.g. an approval)",
26
25
  "/stop" => "Stop a running subagent (/stop <id>; alias for /agents <id> --stop)",
27
26
  "/jobs" => "List the background job queue (status counts); /jobs <id> for detail",
28
27
  "/skills" => "List skills; activate one ('none' clears), or enable/disable NAME",
@@ -20,10 +20,7 @@ module Rubino
20
20
  # conversation / config / turn state, so they run on the composer's reader
21
21
  # thread concurrently with the turn thread without a race (output routes
22
22
  # through the SAME render-mutex-serialized UI). /stop reuses the cancel
23
- # machinery Esc / `--stop` use (already concurrent-safe). /reply is kept
24
- # BLOCKED: its interactive form `/reply <id>` (-> @ui.ask) can't be told
25
- # apart by NAME from the safe inline form, and would steal the reader's
26
- # stdin (default-to-blocked on a concurrency hazard). Single source of
23
+ # machinery Esc / `--stop` use (already concurrent-safe). Single source of
27
24
  # truth for the busy-time classification — #busy_disposition reads it.
28
25
  IMMEDIATE_WHILE_BUSY = %w[agents tasks stop status jobs help commands dirs].freeze
29
26
 
@@ -210,9 +207,6 @@ module Rubino
210
207
  result.is_a?(Hash) ? result : :handled
211
208
  when "stop" # `/stop <id>` → `/agents <id> --stop` alias (FRICTION-4)
212
209
  agents_handler.handle_stop_alias(arguments) # returns :handled
213
- when "reply"
214
- agents_handler.handle_reply(arguments)
215
- :handled
216
210
  when "sessions"
217
211
  sessions_handler.handle_sessions(arguments)
218
212
  when "probe"
@@ -6,21 +6,19 @@ require "time"
6
6
  module Rubino
7
7
  module Commands
8
8
  module Handlers
9
- # The `/agents` (alias `/tasks`) drill-in surface and the `/reply` answer
10
- # path, extracted from Commands::Executor (batch B).
9
+ # The `/agents` (alias `/tasks`) drill-in surface, extracted from
10
+ # Commands::Executor (batch B).
11
11
  #
12
12
  # The "see what other agents do" surface. Lists background subagents from
13
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.
14
+ # single one's result/error, and steers/probes/stops a running one.
16
15
  #
17
16
  # /agents → list
18
17
  # /agents <id> → drill-in (result / error / status)
19
18
  # /agents <id> --stop → cancel a running subagent
20
19
  # /agents <id> steer "…" → fire-and-forget note into the child's context
21
20
  # /agents <id> probe "…" → ephemeral read-only peek
22
- # /reply <id> <answer> → answer a child blocked on a human/parent ask
23
- class Agents # rubocop:disable Metrics/ClassLength -- one cohesive /agents command surface (list/attach/steer/probe/reply/approval/budget); splitting would scatter the routing
21
+ class Agents
24
22
  include Rubino::UI::ProbeWaitIndicator
25
23
 
26
24
  # How many times the parked-child approval prompt re-renders after an
@@ -32,34 +30,23 @@ module Rubino
32
30
  # is in-memory, never persisted — so a prior session's id is genuinely
33
31
  # gone after a REPL restart. The bare "no such id" left the user thinking
34
32
  # they'd mistyped; this names the real reason so they don't hunt for a
35
- # typo. Surfaced from EVERY not-found path (/agents <id>, /reply <id>,
36
- # /stop <id>, steer, probe).
37
- RESET_HINT = "(subagents reset when rubino restarts)"
33
+ # typo. Surfaced from EVERY not-found path (/agents <id>, /stop <id>,
34
+ # steer, probe).
35
+ RESET_HINT = "(background tasks reset when rubino restarts)"
38
36
 
39
37
  def initialize(ui:)
40
38
  @ui = ui
41
39
  end
42
40
 
43
- # Auto-open the EXISTING interactive prompt for ONE pending subagent
44
- # request the human must act on — the REPL idle loop calls this at every
45
- # idle tick so the affordance presents ITSELF instead of forcing the user
46
- # to guess `/agents <id>` or `/reply <id>` (the maintainer's "auto-open
47
- # the existing dropdown" ask). A request that arrives mid-turn, or
48
- # survives a turn that is interrupted/aborted, is re-detected here the
49
- # next time the REPL returns to idle, so it is never lost.
50
- #
51
- # Reuses the SAME primitives the manual slash paths use:
52
- # :needs_approval → #resolve_agent_approval (the approve/deny/always
53
- # prompt, identical to /agents <id>)
54
- # :blocked_on_human → #prompt_reply_answer + #deliver_reply (the ◆ ask
55
- # takeover, identical to /reply <id> with no inline
56
- # answer)
57
- # The manual slash commands stay as a fallback; this is just the primary,
58
- # zero-typing surface. Approval is offered FIRST (a parked tool holds a
59
- # concurrency slot and a possibly dangerous side effect, so it is the more
60
- # urgent gate). Resolves at most ONE request per call so the loop repaints
61
- # and re-checks between each. Returns true when it presented a request
62
- # (the caller re-polls), false when nothing was pending.
41
+ # Auto-open the EXISTING interactive approval prompt for ONE pending
42
+ # subagent request the human must act on — the REPL idle loop calls this at
43
+ # every idle tick so the affordance presents ITSELF instead of forcing the
44
+ # user to guess `/agents <id>`. A request that arrives mid-turn, or
45
+ # survives a turn that is interrupted/aborted, is re-detected here the next
46
+ # time the REPL returns to idle, so it is never lost. Resolves at most ONE
47
+ # request per call so the loop repaints and re-checks between each. Returns
48
+ # true when it presented a request (the caller re-polls), false when
49
+ # nothing was pending.
63
50
  #
64
51
  # SECURITY: this changes WHEN the existing approval prompt appears (now it
65
52
  # auto-presents), never WHAT requires approval — the gate semantics, the
@@ -68,64 +55,16 @@ module Rubino
68
55
  # decision through the same gate.
69
56
  def auto_resolve_pending # rubocop:disable Naming/PredicateMethod -- a prompt-presenting mutator that reports whether it surfaced a request, not a pure query
70
57
  registry = Tools::BackgroundTasks.instance
71
- if (entry = registry.awaiting_approval.first)
58
+ # Oldest-first (FIFO), skipping any the user dismissed with "Decide
59
+ # later" (#586): those stay parked cards the user re-opens deliberately,
60
+ # so the auto-modal doesn't re-pop them at every idle tick.
61
+ if (entry = registry.awaiting_approval.find { |e| !e.approval_snoozed })
72
62
  resolve_agent_approval(entry)
73
63
  return true
74
64
  end
75
- if (entry = registry.awaiting_human.first)
76
- answer_one_human(entry)
77
- return true
78
- end
79
65
  false
80
66
  end
81
67
 
82
- # The ONE shared "surface the answer affordance for a child blocked on the
83
- # human, read the human's answer, deliver it down the SAME wire" step —
84
- # used by BOTH the idle poll (#auto_resolve_pending) and the mid-turn
85
- # auto-open (BottomComposer#request_takeover, triggered by the child's
86
- # ask_parent the instant it blocks). Keeping it in one method means the
87
- # delivery semantics (free-text or pick-an-option → #deliver_reply →
88
- # BackgroundTasks#deliver_answer) are identical on both paths and the
89
- # parent turn's state is NEVER touched (deliver_answer only decides the
90
- # child's gate + pushes its steer note under the registry mutex).
91
- #
92
- # An empty answer (the human cancelled — Esc in the dropdown / blank
93
- # free-text) leaves the child PARKED and reports it: the affordance/hint
94
- # stays so it can re-open. Returns true once it surfaced the request
95
- # (the caller re-polls / re-reads awaiting_human for the next head).
96
- def answer_one_human(entry) # rubocop:disable Naming/PredicateMethod -- a prompt-presenting mutator that reports it surfaced a request
97
- answer = prompt_reply_answer(entry)
98
- if answer.to_s.strip.empty?
99
- @ui.info("No answer given — #{entry.id} is still waiting.")
100
- else
101
- deliver_reply(entry, answer)
102
- end
103
- true
104
- end
105
-
106
- # FIFO drain of the children blocked on the human, used by the MID-TURN
107
- # auto-open: deliver the head, then RE-READ awaiting_human (a 2nd child
108
- # may have asked while the dropdown was open, or the head may have been
109
- # delivered/timed-out) and surface the next head, until the queue is
110
- # empty. Each #answer_one_human runs its own dropdown takeover; a child
111
- # that arrives mid-open simply appends and is picked up on the re-read.
112
- # Bounded by the live awaiting_human snapshot shrinking each pass, so it
113
- # always terminates. Runs on the INPUT thread (it owns the keyboard).
114
- def answer_all_human
115
- loop do
116
- entry = Tools::BackgroundTasks.instance.awaiting_human.first
117
- break unless entry
118
-
119
- answer_one_human(entry)
120
- # A cancelled (still-blocked) head would otherwise re-surface forever:
121
- # stop once the head is no longer awaiting an answer it just got, OR
122
- # the human declined it. We break when the FIRST awaiting_human entry
123
- # is unchanged after the attempt (cancelled), so an Esc doesn't loop.
124
- still = Tools::BackgroundTasks.instance.awaiting_human.first
125
- break if still && still.id == entry.id
126
- end
127
- end
128
-
129
68
  def handle_agents(arguments)
130
69
  args = arguments.to_s.strip
131
70
  return show_agents_list if args.empty?
@@ -165,45 +104,6 @@ module Rubino
165
104
  :handled
166
105
  end
167
106
 
168
- # child->parent ASK_PARENT answer: /reply <id> <answer>. Resolves the
169
- # child's ask gate (Run::ApprovalGate#decide) so a BLOCKING ask unwinds with
170
- # the answer as its tool result, and ALSO pushes the answer onto the child's
171
- # steer queue so a NON-BLOCKING ask folds it in at its next turn boundary.
172
- # Either way the answer PERSISTS in the child's context. With no inline
173
- # answer, falls back to an interactive prompt (the ◆ takeover, like the
174
- # approval menu). Clears the blocked state and unblocks the tree.
175
- def handle_reply(arguments)
176
- tokens = arguments.to_s.strip.split(/\s+/)
177
- id = tokens.shift
178
- if id.nil? || id.empty?
179
- show_blocked_agents
180
- return
181
- end
182
-
183
- # /reply is UNSCOPED: the human is the ultimate supervisor and may answer
184
- # ANY blocked node — one waiting on the human (:blocked_on_human) OR one
185
- # waiting on its agent-parent (:blocked_on_parent), if the human chooses
186
- # to step in.
187
- entry = Tools::BackgroundTasks.instance.find(id)
188
- if entry.nil?
189
- @ui.error("no background subagent with id #{id}. #{RESET_HINT}")
190
- return
191
- end
192
- unless %i[blocked_on_human blocked_on_parent].include?(entry.status)
193
- @ui.error("#{id} is not waiting on you.")
194
- return
195
- end
196
-
197
- answer = dequote(tokens.join(" "))
198
- answer = prompt_reply_answer(entry) if answer.to_s.strip.empty?
199
- if answer.to_s.strip.empty?
200
- @ui.info("No answer given — #{id} is still waiting.")
201
- return
202
- end
203
-
204
- deliver_reply(entry, answer)
205
- end
206
-
207
107
  private
208
108
 
209
109
  # parent->child STEER: a fire-and-forget note that enters the child's
@@ -218,10 +118,10 @@ module Rubino
218
118
  end
219
119
 
220
120
  if Tools::BackgroundTasks.instance.steer(id, text)
221
- @ui.info("steer ▸ #{id} ← #{truncate(text, 80)} (parked · enters child context next turn)")
121
+ @ui.info("steer ▸ #{id} ← #{truncate(text, 80)}")
222
122
  @ui.set_subagent_cards if @ui.respond_to?(:set_subagent_cards)
223
123
  else
224
- @ui.error("cannot steer #{id} — no such running subagent. #{RESET_HINT}")
124
+ @ui.error("cannot steer #{id} — no such running background task. #{RESET_HINT}")
225
125
  end
226
126
  end
227
127
 
@@ -239,26 +139,21 @@ module Rubino
239
139
 
240
140
  entry = Tools::BackgroundTasks.instance.find(id)
241
141
  unless entry
242
- @ui.error("cannot probe #{id} — no such subagent. #{RESET_HINT}")
142
+ @ui.error("cannot probe #{id} — no such background task. #{RESET_HINT}")
243
143
  return
244
144
  end
245
145
 
246
- @ui.info(pastel.dim("┄┄ probe → #{id} ┄┄ (ephemeral · not saved · child trajectory unchanged)"))
247
- # A probe answers from the child's context AT THIS INSTANT; right after
248
- # spawn that context is still empty and the child honestly says it isn't
249
- # working on anything yet — hint so that doesn't read as broken (#112).
250
- if entry.tool_count.to_i.zero?
251
- @ui.info(pastel.dim(" (snapshot at this instant — the child just started and its " \
252
- "context is still empty; probe again in a moment)"))
253
- end
146
+ @ui.info(pastel.dim("┄┄ probe → #{id} ┄┄ (ephemeral · not saved · trajectory unchanged)"))
147
+ hint = entry.peek_hint
148
+ @ui.info(pastel.dim(" #{hint}")) if hint
254
149
  @ui.info("? #{question}")
255
- # The peek is a synchronous side-inference (seconds of model wait) with
256
- # nothing streaming — show the same thinking row /probe got in #58 so
257
- # the gap before the answer never looks frozen (#146). TTY only;
258
- # Null/API adapters and pipes stay silent.
150
+ # The peek is polymorphic: a subagent runs a synchronous LLM side-inference
151
+ # (seconds of model wait — show the thinking row so the gap doesn't look
152
+ # frozen, #58/#146), while a shell returns an instant output snapshot with
153
+ # no model call. Either way #peek lives on the entry, not here.
259
154
  probe_thinking_started(@ui)
260
155
  answer = begin
261
- Tools::SubagentProbe.new.peek(entry: entry, question: question)
156
+ entry.peek(question)
262
157
  ensure
263
158
  probe_thinking_finished(@ui)
264
159
  end
@@ -266,118 +161,6 @@ module Rubino
266
161
  @ui.info(pastel.dim("┄┄ end probe (nothing was saved to #{id}) ┄┄"))
267
162
  end
268
163
 
269
- # The interactive ◆ takeover for /reply with no inline answer — mirrors the
270
- # approval menu (composer-suspend, ◆ glyph) so answering an ask_parent feels
271
- # exactly like answering an approval, a pattern the user already knows.
272
- #
273
- # Content of the affordance (LOCKED "options if present, else free text"):
274
- # * the asking child supplied `options:` → an arrow-SELECT of those
275
- # concrete options PLUS a trailing "✎ Answer (type)…" entry that opens
276
- # the free-text field. Reuses @ui.select (the same TTY::Prompt picker
277
- # /sessions resume uses) under run_in_terminal.
278
- # * no options → the original free-text @ui.ask, but offered as a
279
- # [Answer / Dismiss] choice first so Esc/Dismiss cleanly CANCELS this
280
- # answer (the child stays blocked) instead of forcing a blank line.
281
- # Returns the chosen/typed answer, or "" when the human cancels (Esc /
282
- # Dismiss / blank) — answer_one_human then leaves the child parked.
283
- def prompt_reply_answer(entry)
284
- @ui.info("")
285
- @ui.info("◆ #{entry.id} (#{entry.subagent}) asks — everything is waiting on this")
286
- @ui.info(" ❓ #{entry.ask_question}")
287
- options = Array(entry.ask_options)
288
- options.empty? ? prompt_free_or_dismiss : prompt_pick_option(options)
289
- end
290
-
291
- # No options: [Answer / Dismiss]. "Answer" opens the free-text field;
292
- # "Dismiss" (or Esc, which #select returns as nil) cancels — child stays
293
- # blocked. A UI without #select (scripted/legacy) falls straight through
294
- # to the free-text @ui.ask so the existing behaviour is preserved.
295
- def prompt_free_or_dismiss
296
- return @ui.ask("✎ your answer › ").to_s unless @ui.respond_to?(:select)
297
-
298
- choice = @ui.select("Answer this subagent?",
299
- [["✎ Answer (type)…", :answer], ["Dismiss (leave blocked)", :dismiss]])
300
- return "" unless choice == :answer
301
-
302
- @ui.ask("✎ your answer › ").to_s
303
- end
304
-
305
- # Options present: arrow-select one of them, or the trailing free-text
306
- # entry. Esc (nil from #select) cancels. Reuses @ui.select; a UI without
307
- # it answers free-text so scripted callers keep working.
308
- def prompt_pick_option(options)
309
- return @ui.ask("✎ your answer › ").to_s unless @ui.respond_to?(:select)
310
-
311
- # An option is either a plain string (label==value) or a
312
- # {label, description} map. Show the clean LABEL (with the description
313
- # as a dim hint when present) and deliver the label STRING as the
314
- # answer — never a raw hash literal (#475-3).
315
- choices = options.map { |o| [option_label(o), option_value(o)] }
316
- choices << ["✎ Answer (type)…", :__free__]
317
- choice = @ui.select("Pick an answer for the subagent:", choices)
318
- return "" if choice.nil? # Esc / cancelled
319
- return @ui.ask("✎ your answer › ").to_s if choice == :__free__
320
-
321
- choice
322
- end
323
-
324
- # The display label for a picker option: the bare label for a string,
325
- # or "label — description" (description dimmed) for a {label, description}
326
- # map. The description rides the label since @ui.select takes only
327
- # [label, value] pairs (no separate hint slot).
328
- def option_label(opt)
329
- return opt.to_s unless opt.is_a?(Hash)
330
-
331
- label = opt["label"].to_s
332
- desc = opt["description"].to_s
333
- desc.empty? ? label : "#{label} #{pastel.dim("— #{desc}")}"
334
- end
335
-
336
- # The value DELIVERED to the child for a picker option: always the label
337
- # STRING (the description is presentational only), so the child's answer
338
- # is clean text, never a hash literal.
339
- def option_value(opt)
340
- opt.is_a?(Hash) ? opt["label"].to_s : opt.to_s
341
- end
342
-
343
- # Routes the answer back DOWN to the child: decide the gate (unblocks a
344
- # blocking ask with the answer as its tool result) and push it onto the
345
- # steer queue (a non-blocking ask folds it in next turn). Then clear the
346
- # blocked state and repaint so the ⛔ marker clears.
347
- def deliver_reply(entry, answer)
348
- # The ONE shared answer wire (also used by the model-callable
349
- # answer_child tool): decide the gate + push the steer note + clear the
350
- # blocked state, all in BackgroundTasks#deliver_answer.
351
- # H5 — deliver_answer reports HONESTLY now: false when the child has
352
- # already finished and neither delivery path landed. Say so instead of
353
- # the false "resumes at its next turn" — there is no next turn.
354
- delivered = Tools::BackgroundTasks.instance.deliver_answer(entry.id, answer)
355
- if delivered
356
- @ui.info("↳ answered #{entry.id}: #{truncate(answer, 80)}")
357
- @ui.info("✓ tree unblocked · #{entry.id} resumes at its next turn")
358
- else
359
- @ui.info("↳ answer to #{entry.id}: #{truncate(answer, 80)}")
360
- @ui.error("⚠ not delivered — #{entry.id} already finished; it never saw your answer.")
361
- end
362
- @ui.set_subagent_cards if @ui.respond_to?(:set_subagent_cards)
363
- end
364
-
365
- # Lists the children currently blocked on the human (the /reply with no id
366
- # case) so the user can see who is waiting and on what.
367
- def show_blocked_agents
368
- blocked = Tools::BackgroundTasks.instance.awaiting_human
369
- if blocked.empty?
370
- @ui.info("No subagent is waiting on you.")
371
- return
372
- end
373
-
374
- @ui.info(pastel.red("⛔ #{blocked.size} subagent waiting on you:"))
375
- blocked.each do |e|
376
- @ui.info(" #{e.id} · #{e.subagent}: #{truncate(e.ask_question, 80)}")
377
- end
378
- @ui.info("/reply <id> <answer> to answer")
379
- end
380
-
381
164
  # Strips a single pair of wrapping double/single quotes from a steer/probe
382
165
  # argument so `steer "be terse"` lands as `be terse`, not `"be terse"`.
383
166
  def dequote(text)
@@ -426,7 +209,7 @@ module Rubino
426
209
 
427
210
  def show_agent_snapshot(entry)
428
211
  return render_agent_watch(entry) if %i[
429
- running stopping blocked_on_human blocked_on_parent needs_approval
212
+ running stopping needs_approval
430
213
  ].include?(entry.status)
431
214
 
432
215
  show_agent_result(entry)
@@ -597,6 +380,17 @@ module Rubino
597
380
  choice = ask_budget_answer(entry)
598
381
  return if choice.nil?
599
382
 
383
+ if choice == :later
384
+ # "Decide later" (#586): don't decide the gate — leave the child parked
385
+ # and SNOOZE its auto-modal so it stops re-popping at idle. It stays a
386
+ # `wants +budget` card the user resolves deliberately via the picker /
387
+ # `/agents <id>`. This is the safe ↓-target that keeps a mis-aimed
388
+ # picker ↓+Enter from force-summarizing a child.
389
+ Tools::BackgroundTasks.instance.snooze_approval(entry.id)
390
+ @ui.info("#{entry.id} left waiting — /agents #{entry.id} to grant or summarize.")
391
+ return
392
+ end
393
+
600
394
  grant = choice == :grant
601
395
  gate.decide(entry.approval_id, grant)
602
396
  @ui.info(grant ? "Granted more budget to #{entry.id}." : "#{entry.id} will summarize now.")
@@ -700,20 +494,18 @@ module Rubino
700
494
  return
701
495
  end
702
496
 
703
- unless %i[running needs_approval blocked_on_human blocked_on_parent].include?(entry.status)
497
+ unless %i[running needs_approval stopping].include?(entry.status)
704
498
  @ui.info("#{id} already #{entry.status} — nothing to stop.")
705
499
  return
706
500
  end
707
501
 
708
- # A child parked on a human approval or an ask_parent is blocked in its
709
- # gate's wait; the shared #stop_entry cancels the gates so it wakes
710
- # (Interrupted → deny/cancel) and unwinds instead of holding its thread
711
- # until the bound, runs the stop-cascade so every DESCENDANT parked on a
712
- # blocking ask unwinds too (S5a no orphaned blocked grandchild), marks
713
- # the stop FIRST so the very next /agents list shows stopping instead
714
- # of a stale ● running (#108) and the worker's terminal write records the
715
- # unwind as :stopped, not ✗ failed (#13), then flips the runner token. The
716
- # SAME body the parent-teardown #cancel_all uses — one implementation.
502
+ # A child parked on a human approval is blocked in its gate's wait; the
503
+ # shared #stop_entry cancels the gate so it wakes (Interrupted →
504
+ # deny/cancel) and unwinds instead of holding its thread, marks the stop
505
+ # FIRST so the very next /agents list shows stopping instead of a stale
506
+ # running (#108) and the worker's terminal write records the unwind as
507
+ # :stopped, not failed (#13), then flips the runner token. The SAME
508
+ # body the parent-teardown #cancel_all uses one implementation.
717
509
  registry.stop_entry(entry)
718
510
  @ui.success("Stop requested for #{id} (#{entry.subagent}); it unwinds at its next checkpoint.")
719
511
  end
@@ -728,8 +520,6 @@ module Rubino
728
520
  when :stopping then ["◌", "stopping", :yellow]
729
521
  when :stopped then ["⊘", "stopped", :yellow]
730
522
  when :needs_approval then ["●", "approval", :yellow]
731
- when :blocked_on_human then ["⛔", "waiting on you", :red]
732
- when :blocked_on_parent then ["◷", "waiting on parent", :cyan]
733
523
  when :failed then ["✗", "failed", :red]
734
524
  else ["✓", "done", :green]
735
525
  end
@@ -784,10 +574,10 @@ module Rubino
784
574
  end
785
575
 
786
576
  # Direct entry points for the REPL's agent-attach view: it calls these with
787
- # the user's RAW text, so a steer/probe/reply note keeps embedded quotes
788
- # intact instead of being serialized into a "steer \"…\"" command string
789
- # and mangled by the executor's whitespace-split + single-pair dequote.
790
- public :steer_agent, :probe_agent, :deliver_reply
577
+ # the user's RAW text, so a steer/probe note keeps embedded quotes intact
578
+ # instead of being serialized into a "steer \"…\"" command string and
579
+ # mangled by the executor's whitespace-split + single-pair dequote.
580
+ public :steer_agent, :probe_agent
791
581
  end
792
582
  end
793
583
  end
@@ -232,9 +232,12 @@ module Rubino
232
232
 
233
233
  def status_background_line
234
234
  entries = Tools::BackgroundTasks.instance.list
235
- running = entries.count { |e| e.status == :running }
236
- ids = entries.first(3).map(&:id).join(", ")
237
- line = "#{running} running · #{entries.size} total"
235
+ running = entries.select { |e| e.status == :running }
236
+ # The parenthetical names the RUNNING ids (capped), matching the count
237
+ # and the picker — not the first rows of the total table, which could
238
+ # name a finished task while hiding a running one (subagents + shells).
239
+ ids = running.first(3).map(&:id).join(", ")
240
+ line = "#{running.size} running · #{entries.size} total"
238
241
  ids.empty? ? line : "#{line} (#{ids})"
239
242
  rescue StandardError
240
243
  "(unavailable)"
@@ -23,7 +23,7 @@ module Rubino
23
23
  # leaves behind. `first_line`/`line_count` are the 1-based read window into
24
24
  # the ORIGINAL file (so a `read offset=first_line limit=line_count` returns
25
25
  # exactly these bytes — the drill-in invariant).
26
- Elision = Struct.new(:first_line, :line_count, :indent, keyword_init: true)
26
+ Elision = Struct.new(:first_line, :line_count, keyword_init: true)
27
27
 
28
28
  def initialize(keep_method_body_max_lines:)
29
29
  @keep_max = keep_method_body_max_lines.to_i
@@ -87,7 +87,7 @@ module Rubino
87
87
  return nil if ranges.nil?
88
88
 
89
89
  ranges
90
- .map { |first, count| Elision.new(first_line: first, line_count: count, indent: nil) }
90
+ .map { |first, count| Elision.new(first_line: first, line_count: count) }
91
91
  .sort_by(&:first_line)
92
92
  end
93
93
 
@@ -73,7 +73,7 @@ module Rubino
73
73
  line_count = last - first + 1
74
74
  return nil if line_count <= @keep_max
75
75
 
76
- Elision.new(first_line: first, line_count: line_count, indent: nil)
76
+ Elision.new(first_line: first, line_count: line_count)
77
77
  end
78
78
  end
79
79
  end
@@ -111,7 +111,7 @@ module Rubino
111
111
  line_count = inner_last - inner_first + 1
112
112
  return nil unless line_count > @keep_max
113
113
 
114
- Elision.new(first_line: inner_first, line_count: line_count, indent: nil)
114
+ Elision.new(first_line: inner_first, line_count: line_count)
115
115
  end
116
116
  end
117
117
  end
@@ -60,6 +60,26 @@ module Rubino
60
60
  value.positive? ? value : UI::BottomComposer::MAX_INPUT_ROWS
61
61
  end
62
62
 
63
+ # Render the in-flight streamed block as formatted markdown in the live
64
+ # region (display.live_markdown). Default true; only an explicit false
65
+ # falls back to the legacy raw live tail.
66
+ def display_live_markdown?
67
+ dig("display", "live_markdown") != false
68
+ end
69
+
70
+ # Wrap each live-region frame in DEC-2026 synchronized output
71
+ # (display.synchronized_output). Default true; only an explicit false
72
+ # falls back to the legacy per-write frames.
73
+ def display_synchronized_output?
74
+ dig("display", "synchronized_output") != false
75
+ end
76
+
77
+ # Syntax-highlight committed code blocks (display.code_highlight). Default
78
+ # true; only an explicit false falls back to plain (uncoloured) code.
79
+ def display_code_highlight?
80
+ dig("display", "code_highlight") != false
81
+ end
82
+
63
83
  # -- Paste section (UI::PasteStore: the file-backed paste pipeline) --
64
84
  # A paste with MORE than this many lines collapses to a
65
85
  # "[Pasted text #N +M lines]" placeholder in the composer (expanded to
@@ -192,18 +212,10 @@ module Rubino
192
212
  dig("tasks", "max_live_probes_per_child") || Defaults.dig("tasks", "max_live_probes_per_child")
193
213
  end
194
214
 
195
- # Bound (seconds) a BLOCKING ask_parent waits for an answer before the child
196
- # self-heals and proceeds with its best judgement (S5a). Reuses the
197
- # approval-gate timeout convention — a sane upper bound, never "forever" —
198
- # so an abandoned ask never parks the child's thread indefinitely. Default 900.
199
- def tasks_ask_parent_timeout
200
- dig("tasks", "ask_parent_timeout") || Defaults.dig("tasks", "ask_parent_timeout")
201
- end
202
-
203
215
  # Bound (seconds) an interactive `question`/clarify waits for the human to
204
216
  # answer before it EXPIRES CLEANLY and the agent proceeds with its best
205
- # judgement (#552). Mirrors the ask_parent / Hermes clarify_timeout
206
- # convention — a generous upper bound (default 600s = 10 min, well above
217
+ # judgement (#552). Mirrors the Hermes clarify_timeout convention — a
218
+ # generous upper bound (default 600s = 10 min, well above
207
219
  # human reading/deliberation time), never the 30s stale-chunk window and
208
220
  # never "forever". An abandoned clarify self-heals into the NO_ANSWER
209
221
  # outcome instead of hanging the run or being killed by the stale watchdog.
@@ -272,14 +284,6 @@ module Rubino
272
284
  dig("memory", "auto_extract") == true
273
285
  end
274
286
 
275
- # Background session-summary aux-LLM job (SummarizeSessionJob). Default ON
276
- # (absent ⇒ true), so existing behaviour is unchanged; an explicit false
277
- # turns it off — letting the whole background aux-LLM surface
278
- # (extract/distill/summarize) be disabled together.
279
- def memory_auto_summarize?
280
- dig("memory", "auto_summarize") != false
281
- end
282
-
283
287
  # Throttle interval (in turns) for memory.auto_extract (#412). Returns a
284
288
  # positive Integer; nil/<=1 (or absent) ⇒ 1 = every turn. The lifecycle
285
289
  # only enqueues ExtractMemoryJob when turns-since-last >= this.
@@ -437,6 +441,31 @@ module Rubino
437
441
  dig("auxiliary", task.to_s) || {}
438
442
  end
439
443
 
444
+ # True when the auxiliary +task+ resolves to the SAME server ENDPOINT as the
445
+ # main model — i.e. its LLM calls land on the main model server's KV slot.
446
+ # Slot-sharing is about the endpoint (provider + base_url), NOT the model: a
447
+ # different model on the SAME server still shares the single slot. At the
448
+ # defaults (auxiliary.<task>.provider:"main", empty base_url) this is true.
449
+ #
450
+ # It matters for local single-slot servers: an aux call sharing the slot
451
+ # OVERWRITES the live conversation's KV-cache prefix, so the next user turn
452
+ # re-prefills the whole context (the "freeze after N turns"). The post-turn
453
+ # extraction/distill gates use this to stay OFF the interactive slot,
454
+ # mirroring how Hermes/Claude Code keep automatic memory work off the live
455
+ # conversation (extract at session end instead). A DISTINCT aux endpoint
456
+ # (its own server/slot) does not evict, so inter-turn extraction stays on.
457
+ def auxiliary_on_main_endpoint?(task)
458
+ cfg = auxiliary_config(task)
459
+ provider = cfg["provider"].to_s.strip
460
+ aux_provider = provider.empty? || provider == "main" ? dig("model", "provider").to_s : provider
461
+
462
+ aux_base = cfg["base_url"].to_s.strip
463
+ aux_base = provider_config(aux_provider)["base_url"].to_s.strip if aux_base.empty?
464
+ main_base = provider_config(dig("model", "provider").to_s)["base_url"].to_s.strip
465
+
466
+ aux_provider == dig("model", "provider").to_s && aux_base == main_base
467
+ end
468
+
440
469
  # Returns true when the primary model can ingest images directly. Honours
441
470
  # an explicit `model.supports_vision` override; otherwise falls back to
442
471
  # ContentBuilder's name-pattern heuristic. Used by VisionTool to decide