rubino-agent 0.5.2.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +15 -0
  3. data/CHANGELOG.md +56 -0
  4. data/Dockerfile +56 -0
  5. data/agent.md +112 -0
  6. data/docs/design/bg-shell-pty-port.md +88 -0
  7. data/docs/design/bg-shell-review-refinements.md +65 -0
  8. data/docs/design/bg-shell-ux.md +130 -0
  9. data/docs/tools.md +3 -12
  10. data/lib/rubino/agent/iteration_budget.rb +13 -0
  11. data/lib/rubino/agent/loop.rb +43 -5
  12. data/lib/rubino/agent/prompts/build.txt +3 -5
  13. data/lib/rubino/agent/prompts/memory_guidance.txt +5 -0
  14. data/lib/rubino/agent/prompts/tool_use_enforcement.txt +4 -0
  15. data/lib/rubino/agent/prompts/tool_use_enforcement_google.txt +9 -0
  16. data/lib/rubino/agent/prompts/tool_use_enforcement_openai.txt +48 -0
  17. data/lib/rubino/agent/runner.rb +55 -12
  18. data/lib/rubino/agent/tool_executor.rb +1 -1
  19. data/lib/rubino/cli/chat/idle_card_host.rb +6 -1
  20. data/lib/rubino/cli/chat_command.rb +119 -17
  21. data/lib/rubino/cli/commands.rb +5 -0
  22. data/lib/rubino/commands/handlers/agents.rb +27 -18
  23. data/lib/rubino/commands/handlers/status.rb +6 -3
  24. data/lib/rubino/config/configuration.rb +25 -8
  25. data/lib/rubino/config/defaults.rb +15 -13
  26. data/lib/rubino/context/prompt_assembler.rb +89 -1
  27. data/lib/rubino/context/summary_builder.rb +0 -22
  28. data/lib/rubino/interaction/events.rb +2 -2
  29. data/lib/rubino/interaction/lifecycle.rb +54 -20
  30. data/lib/rubino/llm/ruby_llm_adapter.rb +178 -20
  31. data/lib/rubino/security/redactor.rb +1 -1
  32. data/lib/rubino/session/message.rb +12 -0
  33. data/lib/rubino/tools/background_tasks.rb +107 -12
  34. data/lib/rubino/tools/base.rb +1 -1
  35. data/lib/rubino/tools/read_attachment_tool.rb +52 -54
  36. data/lib/rubino/tools/registry.rb +21 -72
  37. data/lib/rubino/tools/shell_entry_adapter.rb +97 -0
  38. data/lib/rubino/tools/shell_input_tool.rb +1 -1
  39. data/lib/rubino/tools/shell_kill_tool.rb +4 -4
  40. data/lib/rubino/tools/shell_registry.rb +178 -38
  41. data/lib/rubino/tools/shell_tool.rb +45 -5
  42. data/lib/rubino/tools/task_result_tool.rb +4 -1
  43. data/lib/rubino/tools/task_tool.rb +74 -11
  44. data/lib/rubino/tools/vision_tool.rb +1 -1
  45. data/lib/rubino/ui/agent_menu.rb +8 -2
  46. data/lib/rubino/ui/api.rb +11 -0
  47. data/lib/rubino/ui/bottom_composer.rb +24 -11
  48. data/lib/rubino/ui/cli.rb +254 -15
  49. data/lib/rubino/ui/markdown_renderer.rb +4 -1
  50. data/lib/rubino/ui/stdout_proxy.rb +25 -10
  51. data/lib/rubino/ui/streaming_markdown.rb +67 -12
  52. data/lib/rubino/ui/subagent_cards.rb +8 -7
  53. data/lib/rubino/ui/tool_args_stream.rb +143 -0
  54. data/lib/rubino/update_check.rb +10 -2
  55. data/lib/rubino/version.rb +1 -1
  56. metadata +14 -6
  57. data/AGENTS.md +0 -97
  58. data/docs/agents.md +0 -216
  59. data/lib/rubino/jobs/handlers/summarize_session_job.rb +0 -21
  60. data/lib/rubino/tools/summarize_file_tool.rb +0 -194
@@ -3,6 +3,8 @@
3
3
  require "ruby_llm"
4
4
  require "faraday"
5
5
  require "net/http"
6
+ require "uri"
7
+ require "ipaddr"
6
8
  require_relative "tool_bridge"
7
9
  require_relative "cache_breakpoint_middleware"
8
10
  require_relative "inline_think_filter"
@@ -198,7 +200,7 @@ module Rubino
198
200
  think_filter = InlineThinkFilter.new
199
201
  buffered = +""
200
202
  last_chunk_at = monotonic_now
201
- stale_after = stale_chunk_timeout
203
+ stale_after = stale_chunk_timeout(messages)
202
204
  chunks_seen = 0
203
205
  # #488: a tool that ruby_llm runs MID-STREAM (e.g. a blocking
204
206
  # `question`/clarify parked on a human answer for up to the clarify
@@ -228,6 +230,9 @@ module Rubino
228
230
  # glued on. The headless one-shot `result` surfaces this, not `buffered`.
229
231
  last_block = +""
230
232
  last_block_seen = message_block_id
233
+ # Tool-call ids already announced as "preparing" this stream, so the
234
+ # per-chunk arg deltas of one call surface the name exactly once (#608).
235
+ announced_tools = {}
231
236
 
232
237
  emit = lambda do |type, text|
233
238
  next if text.nil? || text.empty?
@@ -313,9 +318,10 @@ module Rubino
313
318
  # stalled SSE / a 200 that never sends an event), nothing inside the
314
319
  # callback ever runs and the only backstop is the 600s socket
315
320
  # read-timeout. Bound the idle gap INDEPENDENTLY of chunk arrival with a
316
- # watchdog thread that wakes on `stale_after` (300s default, well below
317
- # 600s; configurable via providers.<name>.stale_timeout_seconds) and, on
318
- # observing an idle past the deadline, raises StreamStaleError INTO this
321
+ # watchdog thread that wakes on `stale_after` (90s remote default,
322
+ # DISABLED for local endpoints, configurable via
323
+ # providers.<name>.stale_timeout_seconds see #stale_chunk_timeout) and,
324
+ # on observing an idle past the deadline, raises StreamStaleError INTO this
319
325
  # streaming thread to break it out of the blocking socket read. The
320
326
  # rescue below then surfaces a clear "stream stalled" and lets the retry
321
327
  # ladder run. The closure reads `last_chunk_at`/`chunks_seen` live (they
@@ -344,6 +350,13 @@ module Rubino
344
350
  emit.call(:thinking, thinking_text)
345
351
  end
346
352
  think_filter.feed(chunk.content, &emit) if chunk.content.is_a?(String) && !chunk.content.empty?
353
+ # A tool-call delta (the streaming arguments of a long `write`, etc.)
354
+ # carries neither thinking nor textual content. Surface BOTH the tool
355
+ # NAME once per call (:tool_preparing, so the timeline can open the
356
+ # tool card the moment the call starts) AND every argument FRAGMENT
357
+ # (:tool_args, so the UI streams the params live and the token meter
358
+ # keeps climbing instead of freezing while a big file streams). #608.
359
+ announce_tool_stream(chunk, announced_tools, &emit)
347
360
  end
348
361
  rescue Rubino::Interrupted
349
362
  # Flush whatever the filter has buffered, then re-raise. Loop will
@@ -363,13 +376,13 @@ module Rubino
363
376
  end
364
377
  log_safely(event: "llm.stream.partial", error: e.message, buffered_bytes: buffered.bytesize)
365
378
  flush_filter(think_filter, &emit)
366
- return partial_response(buffered)
379
+ return partial_response(buffered, usage)
367
380
  rescue JSON::ParserError => e
368
381
  # Preserve whatever we've buffered so far so the user sees partial
369
382
  # output instead of a blank failure. (issues #12, #22)
370
383
  log_safely(event: "llm.stream.partial", error: e.message, buffered_bytes: buffered.bytesize)
371
384
  flush_filter(think_filter, &emit)
372
- return partial_response(buffered)
385
+ return partial_response(buffered, usage)
373
386
  rescue *STREAM_DROP_ERRORS => e
374
387
  # A genuine transport drop (the observed M3 EOF, a connection reset, a
375
388
  # read timeout, …). If NOTHING was emitted yet, re-raise so the runner
@@ -382,7 +395,7 @@ module Rubino
382
395
  log_safely(event: "llm.stream.partial_interrupted", error: e.message,
383
396
  buffered_bytes: buffered.bytesize)
384
397
  flush_filter(think_filter, &emit)
385
- return partial_response(buffered)
398
+ return partial_response(buffered, usage)
386
399
  ensure
387
400
  # Always tear the watchdog down — on success, on partial-return, and on
388
401
  # a raised StreamStaleError/transport drop — so it never leaks a thread
@@ -453,11 +466,27 @@ module Rubino
453
466
  {
454
467
  content: msg.respond_to?(:content) ? msg.content : nil,
455
468
  tool_calls: normalize_message_tool_calls(msg),
469
+ # Carry the reasoning forward so the Loop persists it and later turns
470
+ # replay it (KV-cache prefix stability, #608b — see load_history).
471
+ reasoning: message_reasoning_text(msg),
456
472
  input_tokens: msg.respond_to?(:input_tokens) ? msg.input_tokens.to_i : 0,
457
473
  output_tokens: msg.respond_to?(:output_tokens) ? msg.output_tokens.to_i : 0
458
474
  }
459
475
  end
460
476
 
477
+ # The reasoning text on a ruby_llm Message (its `thinking` is a
478
+ # RubyLLM::Thinking with `#text`), or nil. Best-effort: a chunk-shape quirk
479
+ # must never break the intermediate-persist path.
480
+ def message_reasoning_text(msg)
481
+ return nil unless msg.respond_to?(:thinking) && msg.thinking
482
+
483
+ t = msg.thinking
484
+ text = t.respond_to?(:text) ? t.text : t.to_s
485
+ text.to_s.empty? ? nil : text
486
+ rescue StandardError
487
+ nil
488
+ end
489
+
461
490
  def normalize_message_tool_calls(msg)
462
491
  return [] unless msg.respond_to?(:tool_calls) && msg.tool_calls
463
492
 
@@ -475,14 +504,56 @@ module Rubino
475
504
  log_safely(event: event, error: e.message)
476
505
  end
477
506
 
507
+ # Surface a streaming tool call to the UI as two signals (#608):
508
+ # * `:tool_preparing` — the tool NAME, emitted ONCE per call (deduped via
509
+ # +announced+, keyed by id), the instant the call first appears. The
510
+ # timeline uses it to open the tool card at the START of the call.
511
+ # * `:tool_args` — every argument FRAGMENT as it streams. ruby_llm yields
512
+ # the per-delta chunk (not the accumulator), so `tc.arguments` here is
513
+ # the NEW fragment, not the cumulative string — forward it as-is. The UI
514
+ # streams these as the live params and counts them toward the token
515
+ # meter, so a long `write` shows progress instead of a frozen footer.
516
+ # Both ride the stream's (type, text) lambda, so they share the same
517
+ # error-handled path as content/thinking. Best-effort: never let a
518
+ # chunk-shape quirk break the stream.
519
+ def announce_tool_stream(chunk, announced, &emit)
520
+ calls = chunk.respond_to?(:tool_calls) ? chunk.tool_calls : nil
521
+ return unless calls.respond_to?(:each_value)
522
+
523
+ calls.each_value do |tc|
524
+ name = tc.respond_to?(:name) ? tc.name : nil
525
+ if name && !name.empty?
526
+ id = (tc.respond_to?(:id) && tc.id) || name
527
+ unless announced[id]
528
+ announced[id] = true
529
+ emit.call(:tool_preparing, name.to_s)
530
+ end
531
+ end
532
+
533
+ args = tc.respond_to?(:arguments) ? tc.arguments : nil
534
+ emit.call(:tool_args, args) if args.is_a?(String) && !args.empty?
535
+ end
536
+ rescue StandardError => e
537
+ log_safely(event: "llm.stream.tool_stream_error", error: e.message)
538
+ end
539
+
478
540
  # Buffered-partial AdapterResponse returned when a stream is cut after at
479
541
  # least one chunk (parse error, stale, or post-first-chunk transport drop).
480
542
  # Flagged +interrupted+ so the Loop fails the turn (run.failed) instead of
481
543
  # mistaking the truncated buffer for a finished answer (the silent
482
544
  # "completed-but-empty" bug — see Rubino::StreamInterruptedError).
483
- def partial_response(buffered)
484
- AdapterResponse.new(content: buffered, tool_calls: [], input_tokens: 0,
485
- output_tokens: 0, model_id: @model_id, interrupted: true)
545
+ #
546
+ # Token accounting (#token-loss): a cut stream still SPENT the tokens of
547
+ # every round-trip ruby_llm already completed inside this one ask() — the
548
+ # `usage` accumulator (wire_round_trip_callbacks) holds their summed spend
549
+ # at the moment of the drop. A multi-round-trip TOOL turn against a flaky
550
+ # transport is exactly where this fires, so zeroing the tokens here made
551
+ # tool/file-writing turns report no token spend at all. Carry the
552
+ # accumulated usage through so the turn summary still counts what was spent.
553
+ def partial_response(buffered, usage = nil)
554
+ summed_in, summed_out = usage ? [usage[:input].to_i, usage[:output].to_i] : [0, 0]
555
+ AdapterResponse.new(content: buffered, tool_calls: [], input_tokens: summed_in,
556
+ output_tokens: summed_out, model_id: @model_id, interrupted: true)
486
557
  end
487
558
 
488
559
  def configure_ruby_llm!
@@ -971,15 +1042,86 @@ module Rubino
971
1042
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
972
1043
  end
973
1044
 
974
- def stale_chunk_timeout
1045
+ # The stale-stream watchdog deadline, ported from Hermes'
1046
+ # _compute_non_stream_stale_timeout (run_agent.py). Priority:
1047
+ # 1. providers.<id>.stale_timeout_seconds — explicit always wins.
1048
+ # 2. LOCAL endpoint (localhost / 127.x / host.docker.internal / RFC-1918
1049
+ # / Tailscale CGNAT) with NO explicit config ⇒ DISABLED (0): a large
1050
+ # local model can legitimately PREFILL for minutes before the first
1051
+ # token, and a local box isn't a flaky remote provider — the 600s
1052
+ # socket read-timeout still backstops a genuinely dead connection.
1053
+ # This is the fix for "no chunk received for 30s" on local models.
1054
+ # 3. Remote default 90s (Hermes parity; was 30/300), scaled UP for large
1055
+ # contexts whose prefill is inherently slower (>50k→150, >100k→240).
1056
+ # 0 disables the watchdog (start_stale_watchdog / check_stream_stale! both
1057
+ # early-return on a non-positive deadline).
1058
+ def stale_chunk_timeout(messages = nil)
975
1059
  explicit = @config.dig("providers", @provider, "stale_timeout_seconds")
976
1060
  return explicit if explicit
977
1061
 
978
- return 30 if openai_compatible_provider? || anthropic_compatible_provider?
1062
+ return 0 if local_endpoint?
979
1063
 
980
- @config.dig("providers", "openai", "stale_timeout_seconds") || 300
1064
+ scale_stale_for_context(default_remote_stale_timeout, messages)
981
1065
  end
982
1066
 
1067
+ DEFAULT_REMOTE_STALE_SECONDS = 90
1068
+
1069
+ def default_remote_stale_timeout
1070
+ @config.dig("providers", "openai", "stale_timeout_seconds") || DEFAULT_REMOTE_STALE_SECONDS
1071
+ end
1072
+
1073
+ # Bump the base deadline for large requests — prefill (time-to-first-token)
1074
+ # grows with context, so a flat timeout false-positives on big prompts.
1075
+ def scale_stale_for_context(base, messages)
1076
+ est = estimate_context_tokens(messages)
1077
+ return [base, 240].max if est > 100_000
1078
+ return [base, 150].max if est > 50_000
1079
+
1080
+ base
1081
+ end
1082
+
1083
+ # Cheap context-size estimate (~4 chars/token) over the request messages,
1084
+ # only used to pick a timeout tier — never for billing/truncation. Tolerant
1085
+ # of either symbol/string content keys and any odd shape (→ 0).
1086
+ def estimate_context_tokens(messages)
1087
+ return 0 unless messages.respond_to?(:sum)
1088
+
1089
+ chars = messages.sum do |m|
1090
+ content = m[:content] || m["content"] if m.respond_to?(:[])
1091
+ content.to_s.length
1092
+ end
1093
+ chars / 4
1094
+ rescue StandardError
1095
+ 0
1096
+ end
1097
+
1098
+ # True when the configured base_url points at the local machine / private
1099
+ # network — a faithful port of Hermes' agent.model_metadata.is_local_endpoint.
1100
+ # Such endpoints auto-disable the stale watchdog (slow local prefill is
1101
+ # normal, not a stall). Any parse failure ⇒ treat as remote (false).
1102
+ def local_endpoint?
1103
+ url = present_base_url(provider_cfg).to_s
1104
+ return false if url.empty?
1105
+
1106
+ host = (URI.parse(url).host || URI.parse("http://#{url}").host).to_s.downcase
1107
+ return false if host.empty?
1108
+ return true if LOCAL_HOSTS.include?(host)
1109
+ return true if CONTAINER_LOCAL_SUFFIXES.any? { |s| host.end_with?(s) }
1110
+
1111
+ ip = IPAddr.new(host)
1112
+ ip.loopback? || ip.private? || ip.link_local? || TAILSCALE_CGNAT.include?(ip)
1113
+ rescue URI::InvalidURIError, IPAddr::Error
1114
+ false
1115
+ end
1116
+
1117
+ LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1 0.0.0.0].freeze
1118
+ # Container/VM internal DNS (host.docker.internal, *.lima.internal, …) and
1119
+ # the mDNS .local suffix all resolve to the host loopback in practice.
1120
+ CONTAINER_LOCAL_SUFFIXES = %w[.internal .local .localhost].freeze
1121
+ # Tailscale CGNAT 100.64.0.0/10 — a remote-but-trusted local box reached
1122
+ # over a Tailscale mesh gets the same treatment as localhost.
1123
+ TAILSCALE_CGNAT = IPAddr.new("100.64.0.0/10")
1124
+
983
1125
  def check_stream_stale!(last_chunk_at, stale_after)
984
1126
  return if stale_after.to_f <= 0
985
1127
  return if (monotonic_now - last_chunk_at) <= stale_after
@@ -1095,7 +1237,13 @@ module Rubino
1095
1237
  chat_instance.messages << RubyLLM::Message.new(
1096
1238
  role: role,
1097
1239
  content: content,
1098
- tool_calls: tool_calls
1240
+ tool_calls: tool_calls,
1241
+ # Replay the stored reasoning as the wire `reasoning_content`
1242
+ # (openai/chat.rb#format_thinking) so the prompt prefix byte-matches
1243
+ # the server's KV cache, which holds this turn's reasoning tokens
1244
+ # (#608b). Omitting it diverged the prefix at the reasoning boundary
1245
+ # and forced a full re-prefill every turn.
1246
+ thinking: rebuild_thinking(msg[:reasoning] || msg["reasoning"])
1099
1247
  )
1100
1248
  when :tool
1101
1249
  chat_instance.messages << build_tool_message(
@@ -1175,6 +1323,16 @@ module Rubino
1175
1323
  end
1176
1324
  end
1177
1325
 
1326
+ # Reconstructs the RubyLLM::Thinking carrying a replayed assistant turn's
1327
+ # reasoning (#608b), or nil when there is none. RubyLLM::Thinking.build
1328
+ # already drops empty text, so a blank/absent reasoning yields nil and the
1329
+ # message replays without a reasoning_content field (unchanged behaviour).
1330
+ def rebuild_thinking(reasoning)
1331
+ return nil if reasoning.nil? || reasoning.to_s.empty?
1332
+
1333
+ RubyLLM::Thinking.build(text: reasoning.to_s)
1334
+ end
1335
+
1178
1336
  # +buffered+ (streaming path) is every assistant TEXT block of the turn
1179
1337
  # concatenated, not just the final one. ruby_llm runs tools mid-stream and
1180
1338
  # returns a response whose #content is only the LAST block, so any text the
@@ -1330,13 +1488,13 @@ module Rubino
1330
1488
 
1331
1489
  # Reasoning text/summary if ruby_llm surfaced it on the message; nil
1332
1490
  # otherwise. Kept defensive — older builds carry no reasoning field.
1491
+ # ruby_llm exposes a completed message's reasoning as #thinking (a
1492
+ # RubyLLM::Thinking with #text) — there is no #reasoning method, so the old
1493
+ # probe (response.reasoning) always rescued to nil and the final turn's
1494
+ # reasoning was never persisted; its replay then busted the KV-cache prefix
1495
+ # (#608b). Same extraction the intermediate path uses (#message_reasoning_text).
1333
1496
  def extract_thinking(response)
1334
- return nil unless response.respond_to?(:reasoning) && response.reasoning
1335
-
1336
- r = response.reasoning
1337
- r.respond_to?(:text) ? r.text : r.to_s
1338
- rescue StandardError
1339
- nil
1497
+ message_reasoning_text(response)
1340
1498
  end
1341
1499
 
1342
1500
  def extract_tool_calls(response)
@@ -11,7 +11,7 @@ module Rubino
11
11
  # `grep` match content (both with code_file:true to skip the ENV/JSON
12
12
  # assignment patterns that false-positive on source), `shell`/background
13
13
  # shell output (full patterns — `cat .env` / `printenv` leak keys), and
14
- # the `summarize_file` chunks shipped to the auxiliary model.
14
+ # converted-document content from `read_attachment` before it enters context.
15
15
  #
16
16
  # Short tokens (< 18 chars) are fully masked; longer ones preserve the
17
17
  # first 6 and last 4 characters for debuggability — matching Hermes'
@@ -65,6 +65,18 @@ module Rubino
65
65
  # Surface assistant tool_calls (persisted as metadata) so the adapter
66
66
  # can rebuild the toolUse block expected by strict providers on resume.
67
67
  msg[:tool_calls] = @metadata[:tool_calls] if @metadata.is_a?(Hash) && @metadata[:tool_calls]
68
+ # Replay the assistant's reasoning on every later turn (Hermes
69
+ # conversation_loop.py:940 "pass reasoning back to the API for ALL
70
+ # assistant messages"). The local server's KV cache, after generating a
71
+ # turn, holds the reasoning tokens; a replay that OMITS them diverges
72
+ # from that cache at the point the reasoning was generated, forcing a
73
+ # full re-prefill of the whole context every turn. Re-emitting the stored
74
+ # reasoning keeps the prompt prefix byte-stable so the server reuses the
75
+ # cache (verified: same assistant row got a KV hit WITH reasoning, a miss
76
+ # WITHOUT). The adapter rebuilds it into the wire `reasoning_content`.
77
+ if @role == "assistant" && @metadata.is_a?(Hash) && (reasoning = @metadata[:reasoning])
78
+ msg[:reasoning] = reasoning
79
+ end
68
80
  # #583: re-derive the error flag from the persisted outcome so a
69
81
  # denied/errored tool result replays to the model marked as an error
70
82
  # (is_error) on the next turn, exactly as it was sent live — never as a
@@ -59,10 +59,24 @@ module Rubino
59
59
  # to remember); the parking/wake/stop-cancel plumbing is identical.
60
60
  Entry = Struct.new(
61
61
  :id, :subagent, :prompt, :status, :result, :error,
62
+ # How the child's turn TERMINATED (Agent::Loop#stop_reason). :completed on
63
+ # a real answer; :max_time / :max_iterations / :stream_incomplete when the
64
+ # run was force-summarized/truncated. Lets the completion notice, the card,
65
+ # and task_result all report a truncated run as PARTIAL, not "completed".
66
+ :stop_reason,
62
67
  :thread, :runner, :started_at, :finished_at,
63
68
  :last_activity, :tool_count, :activity_log, :output_tail,
64
69
  :approval_gate, :approval_id, :approval_question, :approval_command,
65
70
  :budget_request,
71
+ # "Decide later" (#586): the user dismissed this request's AUTO-modal
72
+ # without deciding it. The child stays parked on its gate and the
73
+ # `wants +budget` card stays visible, but auto_resolve stops re-popping
74
+ # the modal at idle — the user re-engages it deliberately via the picker /
75
+ # `/agents <id>` (the manual path presents regardless). This is what makes
76
+ # the picker's ↓+Enter gesture non-destructive: a stray gesture lands on
77
+ # "Decide later" (defers) instead of "Summarize now". Cleared when the
78
+ # approval is (re)opened or decided. nil/false ⇒ auto-pops normally.
79
+ :approval_snoozed,
66
80
  # Monotonic stamp of the instant this child blocked on its approval gate
67
81
  # (begin_approval), used to order the approval MODAL QUEUE FIFO: only one
68
82
  # approval modal is presented at a time (awaiting_approval.first), and a
@@ -98,6 +112,36 @@ module Rubino
98
112
  session_id = runner&.session&.dig(:id)
99
113
  session_id ? ::Rubino::Session::Store.new.for_session(session_id) : []
100
114
  end
115
+
116
+ # A subagent IS NOT a shell — the discriminator the shared /stop, steer,
117
+ # probe and attach paths dispatch on (ShellEntryAdapter#shell? ⇒ true).
118
+ def shell? = false
119
+
120
+ # Parent→child steer: park a turn-boundary note on the child's queue
121
+ # (folded in at its next iteration). The shell analogue is a stdin write.
122
+ def steer(text)
123
+ return false unless steer_queue
124
+
125
+ steer_queue.push(text)
126
+ true
127
+ end
128
+
129
+ # Ephemeral read-only peek: a synchronous LLM side-inference over the
130
+ # child's current context. The shell analogue is an output snapshot.
131
+ def peek(question)
132
+ ::Rubino::Tools::SubagentProbe.new.peek(entry: self, question: question)
133
+ end
134
+
135
+ # A dim hint shown ABOVE a probe answer, or nil. A just-spawned subagent has
136
+ # an empty context, so its honest "I'm not doing anything yet" reply would
137
+ # read as broken (#112) without this; a shell has no such notion (its peek
138
+ # IS its output), so the adapter returns nil.
139
+ def peek_hint
140
+ return unless tool_count.to_i.zero?
141
+
142
+ "(snapshot at this instant — the child just started and its context is " \
143
+ "still empty; probe again in a moment)"
144
+ end
101
145
  end
102
146
 
103
147
  # How many recent activity lines the drill-in shows (the live `recent:` ring).
@@ -254,12 +298,13 @@ module Rubino
254
298
  # omitted from `undelivered`, yet reported delivered. Returns the notes
255
299
  # that were still queued at finalize time (never delivered to the child),
256
300
  # so the caller can surface them as undelivered.
257
- def complete(entry, status:, result: nil, error: nil)
301
+ def complete(entry, status:, result: nil, error: nil, stop_reason: nil)
258
302
  @mutex.synchronize do
259
303
  status = :stopped if entry.status == :stopping && status == :failed
260
304
  entry.status = status
261
305
  entry.result = result
262
306
  entry.error = error
307
+ entry.stop_reason = stop_reason
263
308
  entry.finished_at = Time.now
264
309
  # Drain UNDER the mutex: anything still here is undelivered (the child
265
310
  # has no further turn to fold it in), and once status is terminal no
@@ -334,11 +379,26 @@ module Rubino
334
379
  entry.approval_question = question.to_s
335
380
  entry.approval_command = command.to_s
336
381
  entry.budget_request = budget ? true : false
382
+ entry.approval_snoozed = false
337
383
  entry.approval_seq = (@approval_seq += 1)
338
384
  entry.status = :needs_approval
339
385
  end
340
386
  end
341
387
 
388
+ # "Decide later" (#586): stop auto-popping THIS request's modal at idle
389
+ # without deciding its gate — the child stays parked and the card stays
390
+ # visible; the user re-engages via the picker / `/agents <id>`. Mirrors
391
+ # begin/end_approval (one mutex-guarded mutation of the entry's approval
392
+ # state); a no-op if the entry is gone.
393
+ def snooze_approval(id)
394
+ @mutex.synchronize do
395
+ entry = @entries[id]
396
+ return unless entry
397
+
398
+ entry.approval_snoozed = true
399
+ end
400
+ end
401
+
342
402
  # Clears the approval state and returns the entry to :running once a
343
403
  # decision has been delivered (or the child unwinds).
344
404
  def end_approval(id)
@@ -351,6 +411,7 @@ module Rubino
351
411
  entry.approval_question = nil
352
412
  entry.approval_command = nil
353
413
  entry.budget_request = false
414
+ entry.approval_snoozed = false
354
415
  entry.approval_seq = nil
355
416
  entry.status = :running if entry.status == :needs_approval
356
417
  end
@@ -373,15 +434,15 @@ module Rubino
373
434
  # is no window in which a note is pushed onto a queue nobody will drain yet
374
435
  # reported delivered. Pushing inside the mutex is safe: InputQueue#push has
375
436
  # its own lock and never calls back into the registry, so no lock cycle.
437
+ # Send input to a background worker. The registry owns the liveness guard
438
+ # (one place); the ACTION is polymorphic on the entry — a subagent parks a
439
+ # turn-boundary steer note, a shell writes straight to its stdin.
376
440
  def steer(id, text)
377
- @mutex.synchronize do
378
- entry = @entries[id]
379
- return false unless entry&.steer_queue
380
- return false if terminal_status?(entry.status)
441
+ entry = find(id)
442
+ return false unless entry
443
+ return false if terminal_status?(entry.status)
381
444
 
382
- entry.steer_queue.push(text)
383
- true
384
- end
445
+ entry.steer(text)
385
446
  end
386
447
 
387
448
  # Records a BILLED live probe against a child (S3): bumps probe_count
@@ -424,12 +485,29 @@ module Rubino
424
485
  end
425
486
 
426
487
  def find(id)
427
- @mutex.synchronize { @entries[id] }
488
+ @mutex.synchronize { @entries[id] } || shell_adapter_for(id)
489
+ end
490
+
491
+ # A read-time adapter for a background SHELL by id (bg_*), or nil. Lets the
492
+ # shared /stop and attach paths resolve a shell exactly like a subagent
493
+ # without the shell living in @entries (no cap/steer/sync coupling).
494
+ def shell_adapter_for(id)
495
+ shell = ShellRegistry.instance.find(id)
496
+ shell ? ShellEntryAdapter.new(shell) : nil
428
497
  end
429
498
 
430
- # All entries, newest first — for a `task` listing (the /tasks analogue).
499
+ # All entries, newest first — for a `task` listing (the /tasks analogue) and
500
+ # the /agents list + /status count. Includes background shells (same unified
501
+ # set as #running) so a running shell is never visible in the picker/cards
502
+ # yet absent from the list/count.
431
503
  def list
432
- @mutex.synchronize { @entries.values.sort_by(&:started_at).reverse }
504
+ subs = @mutex.synchronize { @entries.values }
505
+ # The list (/agents table, /status count) keeps FINISHED subagents, so it
506
+ # keeps finished-but-retained shells too (running + retired) for symmetry —
507
+ # otherwise a just-finished shell vanished from /agents while a finished
508
+ # subagent lingered. #running stays running-only (the live picker/cards).
509
+ shells = ShellRegistry.instance.listable_entries.map { |e| ShellEntryAdapter.new(e) }
510
+ (subs + shells).sort_by(&:started_at).reverse
433
511
  end
434
512
 
435
513
  # Live (still-running) children — used by the parent stop path to cancel
@@ -437,7 +515,18 @@ module Rubino
437
515
  # approval (:needs_approval) is STILL live (its thread is alive, holding a
438
516
  # slot), so it counts as running here.
439
517
  def running
440
- @mutex.synchronize { @entries.values.select { |e| live_status?(e.status) } }
518
+ subs = @mutex.synchronize { @entries.values.select { |e| live_status?(e.status) } }
519
+ subs + shell_adapters
520
+ end
521
+
522
+ # Background SHELLS, presented as read-time adapters that duck-type a
523
+ # subagent entry — the ONE place shells join the unified live set, so every
524
+ # surface that lists "background work" (cards, picker, /agents list, /status
525
+ # count) includes them with zero shell-specific branches. No second registry
526
+ # entry ⇒ no status sync / double completion notice / concurrency-cap
527
+ # pollution / dead steer_queue.
528
+ def shell_adapters
529
+ ShellRegistry.instance.running_entries.map { |e| ShellEntryAdapter.new(e) }
441
530
  end
442
531
 
443
532
  def remove(id)
@@ -455,10 +544,16 @@ module Rubino
455
544
  # non-live status), so #cancel_all can call it across the whole registry.
456
545
  def stop_entry(entry)
457
546
  return unless entry
547
+ # A shell stops by killing its process group (polymorphic #stop on the
548
+ # adapter), not by the cooperative subagent cancel.
549
+ return entry.stop if entry.respond_to?(:shell?) && entry.shell?
458
550
 
459
551
  request_stop(entry.id)
460
552
  entry.approval_gate&.cancel!
461
553
  entry.runner&.cancel!
554
+ # Cascade-kill the child background shells this subagent opened — stopping
555
+ # a subagent stops its resources (Hermes kill_all(task_id)).
556
+ ShellRegistry.instance.terminate_owned_by(entry.id)
462
557
  end
463
558
 
464
559
  # Structured-concurrency teardown seam: cancel EVERY live subagent so the
@@ -295,7 +295,7 @@ module Rubino
295
295
  end
296
296
 
297
297
  # Typed "outside workspace" error gate, retained for the AUX-LLM read
298
- # tools (summarize_file, vision) ONLY. Those route the raw file bytes
298
+ # tools (vision) ONLY. Those route the raw file bytes
299
299
  # through a third-party auxiliary model, so an out-of-workspace read would
300
300
  # EXFILTRATE a sibling-repo secret / ~/.ssh file — a stronger threat than
301
301
  # the in-process read/grep/glob, which were relaxed to broad in #406. A